rich_ext/data/redact.rs
1//! Masking secrets before a document is shown.
2
3use super::search::{find, wildcard};
4use super::{Node, Path, Value};
5
6/// Decides whether (and how) to replace a node before display.
7///
8/// Called for every node, parents first; a replacement is used as-is and its
9/// children are not visited. [`Redaction`] implements it; implement it
10/// yourself for other rules (by path, by value shape, …).
11pub trait Redactor {
12 /// The node to show instead of `node`, or `None` to keep it.
13 fn redact(&self, path: &Path, node: &Node) -> Option<Node>;
14}
15
16impl<F: Fn(&Path, &Node) -> Option<Node>> Redactor for F {
17 fn redact(&self, path: &Path, node: &Node) -> Option<Node> {
18 self(path, node)
19 }
20}
21
22/// Mask string and number leaves whose key matches a pattern.
23///
24/// A leaf's key is the last key on its path, so the items of a `tokens`
25/// list are masked too. Everything under a matching key is masked, so
26/// `{"password": {"value": "…"}}` and an XML `<password type="…">…</password>`
27/// (whose text sits under `#text`) are covered. Patterns match
28/// case-insensitively, with `-` and `_` interchangeable (`api-key` matches
29/// `api_key`): as a substring, or as a whole-key glob when they contain `*`
30/// or `?`. Masked leaves become the mask string and keep their metadata;
31/// booleans and nulls are left alone, and so is the structure.
32///
33/// ```
34/// use rich_ext::data::{parse, Format, Redaction, Value};
35///
36/// let node = parse(Format::Json, r#"{"db": {"user": "u", "Password": "hunter2"}}"#).unwrap();
37/// let safe = node.redacted(&Redaction::secrets());
38/// assert_eq!(safe.get("db").unwrap().get("Password").unwrap().value, Value::String("********".into()));
39/// assert_eq!(safe.get("db").unwrap().get("user").unwrap().value, Value::String("u".into()));
40/// ```
41#[derive(Clone, Debug)]
42pub struct Redaction {
43 patterns: Vec<String>,
44 mask: String,
45}
46
47/// The key fragments [`Redaction::secrets`] masks. Shared with the text
48/// detectors in [`crate::redact`], where it is defined so that builds
49/// without the `data` feature have it too.
50pub use crate::redact::SECRET_KEYS;
51
52impl Default for Redaction {
53 fn default() -> Self {
54 Redaction {
55 patterns: Vec::new(),
56 mask: "********".into(),
57 }
58 }
59}
60
61impl Redaction {
62 /// No patterns, mask `********`.
63 pub fn new() -> Self {
64 Self::default()
65 }
66
67 /// Common secret key names (see [`SECRET_KEYS`]).
68 pub fn secrets() -> Self {
69 Self::new().patterns(SECRET_KEYS.iter().copied())
70 }
71
72 /// Add a key pattern.
73 #[must_use]
74 pub fn pattern(mut self, pattern: impl Into<String>) -> Self {
75 self.patterns.push(pattern.into());
76 self
77 }
78
79 /// Add key patterns.
80 #[must_use]
81 pub fn patterns<S: Into<String>>(mut self, patterns: impl IntoIterator<Item = S>) -> Self {
82 self.patterns.extend(patterns.into_iter().map(Into::into));
83 self
84 }
85
86 /// The replacement text.
87 #[must_use]
88 pub fn mask(mut self, mask: impl Into<String>) -> Self {
89 self.mask = mask.into();
90 self
91 }
92
93 /// Whether `key` matches a pattern. `-` and `_` are interchangeable.
94 pub fn matches_key(&self, key: &str) -> bool {
95 let dashless = key.replace('-', "_");
96 self.patterns.iter().any(|pattern| {
97 if pattern.contains(['*', '?']) {
98 wildcard(pattern, key, true)
99 || wildcard(&pattern.replace('-', "_"), &dashless, true)
100 } else {
101 find(&dashless, &pattern.replace('-', "_"), true).is_some()
102 }
103 })
104 }
105
106 /// The masked copy of `node`: scalar leaves replaced, structure and
107 /// metadata kept.
108 fn masked(&self, node: &Node) -> Node {
109 let value = match &node.value {
110 Value::String(_)
111 | Value::DateTime(_)
112 | Value::Int(_)
113 | Value::UInt(_)
114 | Value::Float(_) => Value::String(self.mask.clone()),
115 Value::Seq(items) => Value::Seq(items.iter().map(|item| self.masked(item)).collect()),
116 Value::Map(entries) => Value::Map(
117 entries
118 .iter()
119 .map(|(key, value)| (key.clone(), self.masked(value)))
120 .collect(),
121 ),
122 other => other.clone(),
123 };
124 Node::with_meta(value, node.meta.clone())
125 }
126}
127
128impl Redactor for Redaction {
129 fn redact(&self, path: &Path, node: &Node) -> Option<Node> {
130 // Parents come first, so a match masks the whole subtree here.
131 path.last_key()
132 .is_some_and(|key| self.matches_key(key))
133 .then(|| self.masked(node))
134 }
135}
136
137/// `node` with `redactor` applied throughout.
138pub(crate) fn apply(node: &Node, redactor: &dyn Redactor) -> Node {
139 fn go(node: &Node, path: &mut Path, redactor: &dyn Redactor) -> Node {
140 if let Some(replacement) = redactor.redact(path, node) {
141 return replacement;
142 }
143 let value = match &node.value {
144 Value::Seq(items) => Value::Seq(
145 items
146 .iter()
147 .enumerate()
148 .map(|(i, item)| {
149 path.push(super::PathSegment::Index(i));
150 let out = go(item, path, redactor);
151 path.0.pop();
152 out
153 })
154 .collect(),
155 ),
156 Value::Map(entries) => Value::Map(
157 entries
158 .iter()
159 .map(|(key, value)| {
160 path.push(super::PathSegment::Key(key.clone()));
161 let out = go(value, path, redactor);
162 path.0.pop();
163 (key.clone(), out)
164 })
165 .collect(),
166 ),
167 other => other.clone(),
168 };
169 Node::with_meta(value, node.meta.clone())
170 }
171 go(node, &mut Path::root(), redactor)
172}