faucet_core/masking/
compile.rs1use 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#[derive(Debug, Clone)]
19pub struct CompiledMasking {
20 pub(crate) rules: Vec<CompiledRule>,
21 pub(crate) hasher: Hasher,
22}
23
24#[derive(Debug, Clone)]
26pub(crate) struct CompiledRule {
27 pub(crate) label: String,
29 pub(crate) field_pattern: Option<Regex>,
31 pub(crate) value_detector: Option<Detector>,
33 pub(crate) fields: HashSet<String>,
35 pub(crate) action: MaskAction,
37 pub(crate) action_label: &'static str,
39}
40
41impl CompiledMasking {
42 pub fn compile(spec: &MaskingSpec) -> Result<Self, FaucetError> {
45 Self::compile_scoped(spec, None)
46 }
47
48 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)?; 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 pub fn is_empty(&self) -> bool {
77 self.rules.is_empty()
78 }
79
80 pub fn rule_count(&self) -> usize {
82 self.rules.len()
83 }
84}
85
86fn 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 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 assert_eq!(def.rule_count(), 2);
219 let sec = CompiledMasking::compile_for_sink(&s, &["secure"]).unwrap();
220 assert_eq!(sec.rule_count(), 2); let other = CompiledMasking::compile_for_sink(&s, &["elsewhere"]).unwrap();
222 assert_eq!(other.rule_count(), 1); assert!(!other.is_empty());
224 let by_kind = CompiledMasking::compile_for_sink(&s, &["postgres", "secure"]).unwrap();
226 assert_eq!(by_kind.rule_count(), 2); }
228
229 #[test]
230 fn sink_scoping_still_validates_inapplicable_rule_regex() {
231 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}