Skip to main content

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}