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    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}