Skip to main content

faucet_core/masking/
compile.rs

1//! Compilation + fail-fast validation for the masking layer (issue #206).
2//! Turns a [`MaskingSpec`] into a [`CompiledMasking`] with pre-compiled
3//! regexes and a resolved `Hasher`, optionally scoped to a single
4//! destination sink. Bad regexes / empty rule sets surface as
5//! [`FaucetError::Config`] at config-load time, never mid-run.
6
7use super::config::{Detector, MaskAction, MaskRule, MaskingSpec, MatchSpec};
8use super::hash::Hasher;
9use crate::error::FaucetError;
10use regex::Regex;
11use std::collections::HashSet;
12
13fn config_err(msg: impl Into<String>) -> FaucetError {
14    FaucetError::Config(format!("masking: {}", msg.into()))
15}
16
17/// A masking policy compiled and ready to apply to pages.
18#[derive(Debug, Clone)]
19pub struct CompiledMasking {
20    pub(crate) rules: Vec<CompiledRule>,
21    pub(crate) hasher: Hasher,
22    /// Some rule matches by field name, so the walk must build dot-paths.
23    pub(crate) needs_paths: bool,
24}
25
26impl CompiledMasking {
27    /// Whether a name-based rule matches the top-level field `name`, so the
28    /// pass may rewrite (and re-type) every value of it.
29    pub(crate) fn rewrites_name(&self, name: &str) -> bool {
30        self.rules.iter().any(|r| {
31            r.fields.contains(name) || r.field_pattern.as_ref().is_some_and(|p| p.is_match(name))
32        })
33    }
34}
35
36/// One compiled rule: its matchers plus the action to apply.
37#[derive(Debug, Clone)]
38pub(crate) struct CompiledRule {
39    /// Stable label for logs + the `rule` metric dimension.
40    pub(crate) label: String,
41    /// Compiled field-name (dot-path) regex.
42    pub(crate) field_pattern: Option<Regex>,
43    /// Value-based detector.
44    pub(crate) value_detector: Option<Detector>,
45    /// Explicit dot-paths.
46    pub(crate) fields: HashSet<String>,
47    /// The action to apply to a matching field.
48    pub(crate) action: MaskAction,
49    /// Stable action label for the `action` metric dimension.
50    pub(crate) action_label: &'static str,
51}
52
53impl CompiledMasking {
54    /// Compile the full policy (every rule, ignoring `applies_to`). Use this
55    /// for library callers that don't scope by destination.
56    pub fn compile(spec: &MaskingSpec) -> Result<Self, FaucetError> {
57        Self::compile_scoped(spec, None)
58    }
59
60    /// Compile the policy scoped to one destination sink, identified by any of
61    /// `sink_ids` (typically its template name and its connector kind): rules
62    /// with a non-empty `applies_to` that names none of `sink_ids` are dropped.
63    /// Every rule is still validated (so a bad regex fails the load even if the
64    /// rule doesn't apply to this sink).
65    pub fn compile_for_sink(spec: &MaskingSpec, sink_ids: &[&str]) -> Result<Self, FaucetError> {
66        Self::compile_scoped(spec, Some(sink_ids))
67    }
68
69    fn compile_scoped(spec: &MaskingSpec, sink_ids: Option<&[&str]>) -> Result<Self, FaucetError> {
70        if spec.rules.is_empty() {
71            return Err(config_err("at least one rule is required"));
72        }
73        let mut rules = Vec::with_capacity(spec.rules.len());
74        for (i, rule) in spec.rules.iter().enumerate() {
75            let compiled = compile_rule(rule, i)?; // validate every rule
76            if applies(rule, sink_ids) {
77                rules.push(compiled);
78            }
79        }
80        let needs_paths = rules
81            .iter()
82            .any(|r| r.field_pattern.is_some() || !r.fields.is_empty());
83        Ok(Self {
84            rules,
85            hasher: Hasher::from_key(spec.key.as_deref()),
86            needs_paths,
87        })
88    }
89
90    /// `true` when no rule applies (e.g. every rule is scoped to other sinks).
91    /// The caller can skip attaching the pass entirely.
92    pub fn is_empty(&self) -> bool {
93        self.rules.is_empty()
94    }
95
96    /// Number of active (post-scoping) rules.
97    pub fn rule_count(&self) -> usize {
98        self.rules.len()
99    }
100
101    /// The action a name-based rule applies to the field at the dot-path
102    /// `column`, matched on the field itself or on any ancestor — the same
103    /// depth-first walk [`apply_masking`](super::apply_masking) performs, so a
104    /// container matched by name masks every leaf beneath it. Value-detector
105    /// rules are not consulted: they depend on the value, not the name.
106    pub fn name_action_for(&self, column: &str) -> Option<&'static str> {
107        let mut end = 0;
108        while end < column.len() {
109            end = column[end..].find('.').map_or(column.len(), |i| end + i);
110            let prefix = &column[..end];
111            if let Some(rule) = self.rules.iter().find(|r| {
112                r.fields.contains(prefix)
113                    || r.field_pattern
114                        .as_ref()
115                        .is_some_and(|re| re.is_match(prefix))
116            }) {
117                return Some(rule.action_label);
118            }
119            end += 1;
120        }
121        None
122    }
123}
124
125/// Whether `rule` applies to a sink identified by any of `sink_ids` (unscoped
126/// rules — empty `applies_to` — apply everywhere).
127fn applies(rule: &MaskRule, sink_ids: Option<&[&str]>) -> bool {
128    match sink_ids {
129        None => true,
130        Some(ids) => {
131            rule.applies_to.is_empty() || rule.applies_to.iter().any(|t| ids.contains(&t.as_str()))
132        }
133    }
134}
135
136fn compile_rule(rule: &MaskRule, index: usize) -> Result<CompiledRule, FaucetError> {
137    let MatchSpec {
138        field_pattern,
139        value_detector,
140        fields,
141    } = &rule.matcher;
142
143    if field_pattern.is_none() && value_detector.is_none() && fields.is_empty() {
144        return Err(config_err(format!(
145            "rule {index}: `match` must set at least one of field_pattern / value_detector / fields"
146        )));
147    }
148
149    let field_pattern = match field_pattern {
150        Some(p) => Some(Regex::new(p).map_err(|e| {
151            config_err(format!(
152                "rule {index}: invalid field_pattern regex '{p}': {e}"
153            ))
154        })?),
155        None => None,
156    };
157
158    // Validate the partial action's config.
159    if let MaskAction::Tokenize { prefix: Some(p) } = &rule.action
160        && p.is_empty()
161    {
162        return Err(config_err(format!(
163            "rule {index}: tokenize prefix, when set, must be non-empty (omit it for no prefix)"
164        )));
165    }
166
167    let label = rule.name.clone().unwrap_or_else(|| format!("rule_{index}"));
168
169    Ok(CompiledRule {
170        label,
171        field_pattern,
172        value_detector: *value_detector,
173        fields: fields.iter().cloned().collect(),
174        action: rule.action.clone(),
175        action_label: rule.action.label(),
176    })
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182    use serde_json::json;
183
184    fn spec(v: serde_json::Value) -> MaskingSpec {
185        serde_json::from_value(v).unwrap()
186    }
187
188    #[test]
189    fn compiles_valid_policy() {
190        let c = CompiledMasking::compile(&spec(json!({
191            "rules": [
192                { "match": { "field_pattern": "email" }, "action": { "type": "redact" } },
193                { "match": { "value_detector": "ssn" }, "action": { "type": "hash" } }
194            ]
195        })))
196        .unwrap();
197        assert_eq!(c.rule_count(), 2);
198        assert!(!c.is_empty());
199        assert_eq!(c.rules[0].label, "rule_0");
200        assert_eq!(c.rules[0].action_label, "redact");
201    }
202
203    #[test]
204    fn empty_rules_rejected() {
205        let err = CompiledMasking::compile(&spec(json!({ "rules": [] }))).unwrap_err();
206        assert!(matches!(err, FaucetError::Config(m) if m.contains("at least one rule")));
207    }
208
209    #[test]
210    fn empty_match_rejected() {
211        let err = CompiledMasking::compile(&spec(json!({
212            "rules": [{ "match": {}, "action": { "type": "redact" } }]
213        })))
214        .unwrap_err();
215        assert!(matches!(err, FaucetError::Config(m) if m.contains("at least one of")));
216    }
217
218    #[test]
219    fn bad_regex_rejected() {
220        let err = CompiledMasking::compile(&spec(json!({
221            "rules": [{ "match": { "field_pattern": "(" }, "action": { "type": "hash" } }]
222        })))
223        .unwrap_err();
224        assert!(matches!(err, FaucetError::Config(m) if m.contains("invalid field_pattern")));
225    }
226
227    #[test]
228    fn empty_tokenize_prefix_rejected() {
229        let err = CompiledMasking::compile(&spec(json!({
230            "rules": [{ "match": { "fields": ["t"] }, "action": { "type": "tokenize", "prefix": "" } }]
231        })))
232        .unwrap_err();
233        assert!(matches!(err, FaucetError::Config(m) if m.contains("tokenize prefix")));
234    }
235
236    #[test]
237    fn name_action_for_matches_the_field_or_an_ancestor() {
238        let c = CompiledMasking::compile_for_sink(
239            &spec(json!({
240                "rules": [
241                    { "match": { "value_detector": "email" }, "action": { "type": "redact" } },
242                    { "match": { "fields": ["phones"] }, "action": { "type": "hash" } },
243                    { "match": { "field_pattern": "^card$" }, "action": { "type": "partial" } },
244                    { "match": { "fields": ["user.ssn"] }, "action": { "type": "tokenize" } },
245                    { "match": { "fields": ["other"] }, "action": { "type": "redact" },
246                      "applies_to": ["elsewhere"] }
247                ]
248            })),
249            &["here"],
250        )
251        .unwrap();
252        assert_eq!(c.name_action_for("phones"), Some("hash"));
253        assert_eq!(c.name_action_for("phones.0"), Some("hash"));
254        assert_eq!(c.name_action_for("card.number"), Some("partial"));
255        assert_eq!(c.name_action_for("card"), Some("partial"));
256        assert_eq!(c.name_action_for("user.ssn"), Some("tokenize"));
257        assert_eq!(
258            c.name_action_for("ssn"),
259            None,
260            "fields match the full path only"
261        );
262        assert_eq!(c.name_action_for("user"), None);
263        assert_eq!(c.name_action_for("cardholder"), None);
264        assert_eq!(c.name_action_for("other"), None, "scoped to another sink");
265        assert_eq!(
266            c.name_action_for("email"),
267            None,
268            "detectors are value-based"
269        );
270        assert_eq!(c.name_action_for(""), None);
271    }
272
273    #[test]
274    fn custom_rule_name_used_as_label() {
275        let c = CompiledMasking::compile(&spec(json!({
276            "rules": [{ "name": "cards", "match": { "value_detector": "credit_card" },
277                        "action": { "type": "partial" } }]
278        })))
279        .unwrap();
280        assert_eq!(c.rules[0].label, "cards");
281    }
282
283    #[test]
284    fn sink_scoping_filters_rules_but_validates_all() {
285        let s = spec(json!({
286            "rules": [
287                { "match": { "fields": ["a"] }, "action": { "type": "redact" }, "applies_to": ["secure"] },
288                { "match": { "fields": ["b"] }, "action": { "type": "redact" }, "applies_to": ["default"] },
289                { "match": { "fields": ["c"] }, "action": { "type": "redact" } }
290            ]
291        }));
292        let def = CompiledMasking::compile_for_sink(&s, &["default"]).unwrap();
293        // rule b (default) + rule c (unscoped) apply; rule a (secure) does not.
294        assert_eq!(def.rule_count(), 2);
295        let sec = CompiledMasking::compile_for_sink(&s, &["secure"]).unwrap();
296        assert_eq!(sec.rule_count(), 2); // rule a + rule c
297        let other = CompiledMasking::compile_for_sink(&s, &["elsewhere"]).unwrap();
298        assert_eq!(other.rule_count(), 1); // only unscoped rule c
299        assert!(!other.is_empty());
300        // Multiple identifiers (template name + kind): matches on either.
301        let by_kind = CompiledMasking::compile_for_sink(&s, &["postgres", "secure"]).unwrap();
302        assert_eq!(by_kind.rule_count(), 2); // rule a (secure) + rule c
303    }
304
305    #[test]
306    fn sink_scoping_still_validates_inapplicable_rule_regex() {
307        // The bad-regex rule is scoped to "secure" but must still fail when
308        // compiling for "default".
309        let s = spec(json!({
310            "rules": [{ "match": { "field_pattern": "(" }, "action": { "type": "hash" },
311                        "applies_to": ["secure"] }]
312        }));
313        assert!(CompiledMasking::compile_for_sink(&s, &["default"]).is_err());
314    }
315}