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}
23
24/// One compiled rule: its matchers plus the action to apply.
25#[derive(Debug, Clone)]
26pub(crate) struct CompiledRule {
27    /// Stable label for logs + the `rule` metric dimension.
28    pub(crate) label: String,
29    /// Compiled field-name (dot-path) regex.
30    pub(crate) field_pattern: Option<Regex>,
31    /// Value-based detector.
32    pub(crate) value_detector: Option<Detector>,
33    /// Explicit dot-paths.
34    pub(crate) fields: HashSet<String>,
35    /// The action to apply to a matching field.
36    pub(crate) action: MaskAction,
37    /// Stable action label for the `action` metric dimension.
38    pub(crate) action_label: &'static str,
39}
40
41impl CompiledMasking {
42    /// Compile the full policy (every rule, ignoring `applies_to`). Use this
43    /// for library callers that don't scope by destination.
44    pub fn compile(spec: &MaskingSpec) -> Result<Self, FaucetError> {
45        Self::compile_scoped(spec, None)
46    }
47
48    /// Compile the policy scoped to one destination sink, identified by any of
49    /// `sink_ids` (typically its template name and its connector kind): rules
50    /// with a non-empty `applies_to` that names none of `sink_ids` are dropped.
51    /// Every rule is still validated (so a bad regex fails the load even if the
52    /// rule doesn't apply to this sink).
53    pub fn compile_for_sink(spec: &MaskingSpec, sink_ids: &[&str]) -> Result<Self, FaucetError> {
54        Self::compile_scoped(spec, Some(sink_ids))
55    }
56
57    fn compile_scoped(spec: &MaskingSpec, sink_ids: Option<&[&str]>) -> Result<Self, FaucetError> {
58        if spec.rules.is_empty() {
59            return Err(config_err("at least one rule is required"));
60        }
61        let mut rules = Vec::with_capacity(spec.rules.len());
62        for (i, rule) in spec.rules.iter().enumerate() {
63            let compiled = compile_rule(rule, i)?; // validate every rule
64            if applies(rule, sink_ids) {
65                rules.push(compiled);
66            }
67        }
68        Ok(Self {
69            rules,
70            hasher: Hasher::from_key(spec.key.as_deref()),
71        })
72    }
73
74    /// `true` when no rule applies (e.g. every rule is scoped to other sinks).
75    /// The caller can skip attaching the pass entirely.
76    pub fn is_empty(&self) -> bool {
77        self.rules.is_empty()
78    }
79
80    /// Number of active (post-scoping) rules.
81    pub fn rule_count(&self) -> usize {
82        self.rules.len()
83    }
84}
85
86/// Whether `rule` applies to a sink identified by any of `sink_ids` (unscoped
87/// rules — empty `applies_to` — apply everywhere).
88fn applies(rule: &MaskRule, sink_ids: Option<&[&str]>) -> bool {
89    match sink_ids {
90        None => true,
91        Some(ids) => {
92            rule.applies_to.is_empty() || rule.applies_to.iter().any(|t| ids.contains(&t.as_str()))
93        }
94    }
95}
96
97fn compile_rule(rule: &MaskRule, index: usize) -> Result<CompiledRule, FaucetError> {
98    let MatchSpec {
99        field_pattern,
100        value_detector,
101        fields,
102    } = &rule.matcher;
103
104    if field_pattern.is_none() && value_detector.is_none() && fields.is_empty() {
105        return Err(config_err(format!(
106            "rule {index}: `match` must set at least one of field_pattern / value_detector / fields"
107        )));
108    }
109
110    let field_pattern = match field_pattern {
111        Some(p) => Some(Regex::new(p).map_err(|e| {
112            config_err(format!(
113                "rule {index}: invalid field_pattern regex '{p}': {e}"
114            ))
115        })?),
116        None => None,
117    };
118
119    // Validate the partial action's config.
120    if let MaskAction::Tokenize { prefix: Some(p) } = &rule.action
121        && p.is_empty()
122    {
123        return Err(config_err(format!(
124            "rule {index}: tokenize prefix, when set, must be non-empty (omit it for no prefix)"
125        )));
126    }
127
128    let label = rule.name.clone().unwrap_or_else(|| format!("rule_{index}"));
129
130    Ok(CompiledRule {
131        label,
132        field_pattern,
133        value_detector: *value_detector,
134        fields: fields.iter().cloned().collect(),
135        action: rule.action.clone(),
136        action_label: rule.action.label(),
137    })
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143    use serde_json::json;
144
145    fn spec(v: serde_json::Value) -> MaskingSpec {
146        serde_json::from_value(v).unwrap()
147    }
148
149    #[test]
150    fn compiles_valid_policy() {
151        let c = CompiledMasking::compile(&spec(json!({
152            "rules": [
153                { "match": { "field_pattern": "email" }, "action": { "type": "redact" } },
154                { "match": { "value_detector": "ssn" }, "action": { "type": "hash" } }
155            ]
156        })))
157        .unwrap();
158        assert_eq!(c.rule_count(), 2);
159        assert!(!c.is_empty());
160        assert_eq!(c.rules[0].label, "rule_0");
161        assert_eq!(c.rules[0].action_label, "redact");
162    }
163
164    #[test]
165    fn empty_rules_rejected() {
166        let err = CompiledMasking::compile(&spec(json!({ "rules": [] }))).unwrap_err();
167        assert!(matches!(err, FaucetError::Config(m) if m.contains("at least one rule")));
168    }
169
170    #[test]
171    fn empty_match_rejected() {
172        let err = CompiledMasking::compile(&spec(json!({
173            "rules": [{ "match": {}, "action": { "type": "redact" } }]
174        })))
175        .unwrap_err();
176        assert!(matches!(err, FaucetError::Config(m) if m.contains("at least one of")));
177    }
178
179    #[test]
180    fn bad_regex_rejected() {
181        let err = CompiledMasking::compile(&spec(json!({
182            "rules": [{ "match": { "field_pattern": "(" }, "action": { "type": "hash" } }]
183        })))
184        .unwrap_err();
185        assert!(matches!(err, FaucetError::Config(m) if m.contains("invalid field_pattern")));
186    }
187
188    #[test]
189    fn empty_tokenize_prefix_rejected() {
190        let err = CompiledMasking::compile(&spec(json!({
191            "rules": [{ "match": { "fields": ["t"] }, "action": { "type": "tokenize", "prefix": "" } }]
192        })))
193        .unwrap_err();
194        assert!(matches!(err, FaucetError::Config(m) if m.contains("tokenize prefix")));
195    }
196
197    #[test]
198    fn custom_rule_name_used_as_label() {
199        let c = CompiledMasking::compile(&spec(json!({
200            "rules": [{ "name": "cards", "match": { "value_detector": "credit_card" },
201                        "action": { "type": "partial" } }]
202        })))
203        .unwrap();
204        assert_eq!(c.rules[0].label, "cards");
205    }
206
207    #[test]
208    fn sink_scoping_filters_rules_but_validates_all() {
209        let s = spec(json!({
210            "rules": [
211                { "match": { "fields": ["a"] }, "action": { "type": "redact" }, "applies_to": ["secure"] },
212                { "match": { "fields": ["b"] }, "action": { "type": "redact" }, "applies_to": ["default"] },
213                { "match": { "fields": ["c"] }, "action": { "type": "redact" } }
214            ]
215        }));
216        let def = CompiledMasking::compile_for_sink(&s, &["default"]).unwrap();
217        // rule b (default) + rule c (unscoped) apply; rule a (secure) does not.
218        assert_eq!(def.rule_count(), 2);
219        let sec = CompiledMasking::compile_for_sink(&s, &["secure"]).unwrap();
220        assert_eq!(sec.rule_count(), 2); // rule a + rule c
221        let other = CompiledMasking::compile_for_sink(&s, &["elsewhere"]).unwrap();
222        assert_eq!(other.rule_count(), 1); // only unscoped rule c
223        assert!(!other.is_empty());
224        // Multiple identifiers (template name + kind): matches on either.
225        let by_kind = CompiledMasking::compile_for_sink(&s, &["postgres", "secure"]).unwrap();
226        assert_eq!(by_kind.rule_count(), 2); // rule a (secure) + rule c
227    }
228
229    #[test]
230    fn sink_scoping_still_validates_inapplicable_rule_regex() {
231        // The bad-regex rule is scoped to "secure" but must still fail when
232        // compiling for "default".
233        let s = spec(json!({
234            "rules": [{ "match": { "field_pattern": "(" }, "action": { "type": "hash" },
235                        "applies_to": ["secure"] }]
236        }));
237        assert!(CompiledMasking::compile_for_sink(&s, &["default"]).is_err());
238    }
239}