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 pub fn pattern(mut self, pattern: impl Into<String>) -> Self {
74 self.patterns.push(pattern.into());
75 self
76 }
77
78 /// Add key patterns.
79 pub fn patterns<S: Into<String>>(mut self, patterns: impl IntoIterator<Item = S>) -> Self {
80 self.patterns.extend(patterns.into_iter().map(Into::into));
81 self
82 }
83
84 /// The replacement text.
85 pub fn mask(mut self, mask: impl Into<String>) -> Self {
86 self.mask = mask.into();
87 self
88 }
89
90 /// Whether `key` matches a pattern. `-` and `_` are interchangeable.
91 pub fn matches_key(&self, key: &str) -> bool {
92 let dashless = key.replace('-', "_");
93 self.patterns.iter().any(|pattern| {
94 if pattern.contains(['*', '?']) {
95 wildcard(pattern, key, true)
96 || wildcard(&pattern.replace('-', "_"), &dashless, true)
97 } else {
98 find(&dashless, &pattern.replace('-', "_"), true).is_some()
99 }
100 })
101 }
102
103 /// The masked copy of `node`: scalar leaves replaced, structure and
104 /// metadata kept.
105 fn masked(&self, node: &Node) -> Node {
106 let value = match &node.value {
107 Value::String(_)
108 | Value::DateTime(_)
109 | Value::Int(_)
110 | Value::UInt(_)
111 | Value::Float(_) => Value::String(self.mask.clone()),
112 Value::Seq(items) => Value::Seq(items.iter().map(|item| self.masked(item)).collect()),
113 Value::Map(entries) => Value::Map(
114 entries
115 .iter()
116 .map(|(key, value)| (key.clone(), self.masked(value)))
117 .collect(),
118 ),
119 other => other.clone(),
120 };
121 Node::with_meta(value, node.meta.clone())
122 }
123}
124
125impl Redactor for Redaction {
126 fn redact(&self, path: &Path, node: &Node) -> Option<Node> {
127 // Parents come first, so a match masks the whole subtree here.
128 path.last_key()
129 .is_some_and(|key| self.matches_key(key))
130 .then(|| self.masked(node))
131 }
132}
133
134/// `node` with `redactor` applied throughout.
135pub(crate) fn apply(node: &Node, redactor: &dyn Redactor) -> Node {
136 fn go(node: &Node, path: &mut Path, redactor: &dyn Redactor) -> Node {
137 if let Some(replacement) = redactor.redact(path, node) {
138 return replacement;
139 }
140 let value = match &node.value {
141 Value::Seq(items) => Value::Seq(
142 items
143 .iter()
144 .enumerate()
145 .map(|(i, item)| {
146 path.push(super::PathSegment::Index(i));
147 let out = go(item, path, redactor);
148 path.0.pop();
149 out
150 })
151 .collect(),
152 ),
153 Value::Map(entries) => Value::Map(
154 entries
155 .iter()
156 .map(|(key, value)| {
157 path.push(super::PathSegment::Key(key.clone()));
158 let out = go(value, path, redactor);
159 path.0.pop();
160 (key.clone(), out)
161 })
162 .collect(),
163 ),
164 other => other.clone(),
165 };
166 Node::with_meta(value, node.meta.clone())
167 }
168 go(node, &mut Path::root(), redactor)
169}