1use super::config::{Detector, MaskAction, MaskRule, MaskingSpec, MatchSpec};
8use super::hash::Hasher;
9use crate::error::FaucetError;
10use regex::Regex;
11use std::collections::HashSet;
12
13pub(crate) fn warn_phone_narrowed() {
16 static WARNED: std::sync::Once = std::sync::Once::new();
17 WARNED.call_once(|| {
18 tracing::warn!(
19 "masking/policy: `value_detector: phone` matches only E.164 (`+…`) and separated \
20 North-American numbers; bare digit runs and national formats such as \
21 `020 7946 0958` pass through. Use `phone_loose` for the broader matcher"
22 );
23 });
24}
25
26fn config_err(msg: impl Into<String>) -> FaucetError {
27 FaucetError::Config(format!("masking: {}", msg.into()))
28}
29
30#[derive(Debug, Clone)]
32pub struct CompiledMasking {
33 pub(crate) rules: Vec<CompiledRule>,
34 pub(crate) hasher: Hasher,
35 pub(crate) needs_paths: bool,
37 pub(crate) stringify_numbers: bool,
41}
42
43impl CompiledMasking {
44 #[cfg_attr(not(feature = "arrow"), allow(dead_code))]
47 pub(crate) fn rewrites_name(&self, name: &str) -> bool {
48 self.rules.iter().any(|r| {
49 r.fields.contains(name) || r.field_pattern.as_ref().is_some_and(|p| p.is_match(name))
50 })
51 }
52
53 #[cfg_attr(not(feature = "arrow"), allow(dead_code))]
57 pub(crate) fn retypes_by_value(&self) -> bool {
58 self.stringify_numbers
59 }
60}
61
62#[derive(Debug, Clone)]
64pub(crate) struct CompiledRule {
65 pub(crate) label: String,
67 pub(crate) field_pattern: Option<Regex>,
69 pub(crate) value_detector: Option<Detector>,
71 pub(crate) fields: HashSet<String>,
73 pub(crate) action: MaskAction,
75 pub(crate) action_label: &'static str,
77}
78
79impl CompiledMasking {
80 pub fn compile(spec: &MaskingSpec) -> Result<Self, FaucetError> {
83 Self::compile_scoped(spec, None)
84 }
85
86 pub fn compile_for_sink(spec: &MaskingSpec, sink_ids: &[&str]) -> Result<Self, FaucetError> {
92 Self::compile_scoped(spec, Some(sink_ids))
93 }
94
95 fn compile_scoped(spec: &MaskingSpec, sink_ids: Option<&[&str]>) -> Result<Self, FaucetError> {
96 if spec.rules.is_empty() {
97 return Err(config_err("at least one rule is required"));
98 }
99 let mut rules = Vec::with_capacity(spec.rules.len());
100 for (i, rule) in spec.rules.iter().enumerate() {
101 let compiled = compile_rule(rule, i)?; if applies(rule, sink_ids) {
103 rules.push(compiled);
104 }
105 }
106 if rules
107 .iter()
108 .any(|r| r.value_detector == Some(Detector::Phone))
109 {
110 warn_phone_narrowed();
111 }
112 let needs_paths = rules
113 .iter()
114 .any(|r| r.field_pattern.is_some() || !r.fields.is_empty());
115 let stringify_numbers = rules
116 .iter()
117 .any(|r| r.value_detector.is_some_and(super::detect::matches_numbers));
118 Ok(Self {
119 rules,
120 hasher: Hasher::from_key(spec.key.as_deref()),
121 needs_paths,
122 stringify_numbers,
123 })
124 }
125
126 pub fn is_empty(&self) -> bool {
129 self.rules.is_empty()
130 }
131
132 pub fn rule_count(&self) -> usize {
134 self.rules.len()
135 }
136
137 pub fn name_action_for(&self, column: &str) -> Option<&'static str> {
144 let mut end = 0;
145 while end < column.len() {
146 end = column[end..].find('.').map_or(column.len(), |i| end + i);
147 let prefix = &column[..end];
148 let leaf = end == column.len();
149 for r in &self.rules {
150 if r.fields.contains(prefix)
151 || r.field_pattern
152 .as_ref()
153 .is_some_and(|re| re.is_match(prefix))
154 {
155 return Some(r.action_label);
156 }
157 if leaf && r.value_detector.is_some() {
158 return None;
159 }
160 }
161 end += 1;
162 }
163 None
164 }
165}
166
167fn applies(rule: &MaskRule, sink_ids: Option<&[&str]>) -> bool {
170 match sink_ids {
171 None => true,
172 Some(ids) => {
173 rule.applies_to.is_empty() || rule.applies_to.iter().any(|t| ids.contains(&t.as_str()))
174 }
175 }
176}
177
178fn compile_rule(rule: &MaskRule, index: usize) -> Result<CompiledRule, FaucetError> {
179 let MatchSpec {
180 field_pattern,
181 value_detector,
182 fields,
183 } = &rule.matcher;
184
185 if field_pattern.is_none() && value_detector.is_none() && fields.is_empty() {
186 return Err(config_err(format!(
187 "rule {index}: `match` must set at least one of field_pattern / value_detector / fields"
188 )));
189 }
190
191 let field_pattern = match field_pattern {
192 Some(p) => Some(Regex::new(p).map_err(|e| {
193 config_err(format!(
194 "rule {index}: invalid field_pattern regex '{p}': {e}"
195 ))
196 })?),
197 None => None,
198 };
199
200 if let MaskAction::Tokenize { prefix: Some(p) } = &rule.action
202 && p.is_empty()
203 {
204 return Err(config_err(format!(
205 "rule {index}: tokenize prefix, when set, must be non-empty (omit it for no prefix)"
206 )));
207 }
208
209 let label = rule.name.clone().unwrap_or_else(|| format!("rule_{index}"));
210
211 Ok(CompiledRule {
212 label,
213 field_pattern,
214 value_detector: *value_detector,
215 fields: fields.iter().cloned().collect(),
216 action: rule.action.clone(),
217 action_label: rule.action.label(),
218 })
219}
220
221#[cfg(test)]
222mod tests {
223 use super::*;
224 use serde_json::json;
225
226 #[test]
227 fn retypes_by_value_only_for_number_matching_detectors() {
228 let with = |det: &str| {
229 let s = spec(json!({"rules": [
230 {"match": {"fields": ["id"]}, "action": {"type": "hash"}},
231 {"match": {"value_detector": det}, "action": {"type": "hash"}}
232 ]}));
233 CompiledMasking::compile(&s).unwrap().retypes_by_value()
234 };
235 assert!(with("credit_card"));
236 assert!(with("phone_loose"));
237 for det in ["email", "ssn", "phone", "ipv4"] {
238 assert!(!with(det), "{det}");
239 }
240 let name_only = spec(json!({"rules": [
241 {"match": {"field_pattern": "card"}, "action": {"type": "hash"}}
242 ]}));
243 assert!(
244 !CompiledMasking::compile(&name_only)
245 .unwrap()
246 .retypes_by_value()
247 );
248 }
249
250 #[test]
251 fn a_phone_rule_compiles_and_warns_once() {
252 let s = spec(
253 json!({"rules": [{"match": {"value_detector": "phone"}, "action": {"type": "hash"}}]}),
254 );
255 assert_eq!(CompiledMasking::compile(&s).unwrap().rule_count(), 1);
256 assert_eq!(CompiledMasking::compile(&s).unwrap().rule_count(), 1);
257 }
258
259 fn spec(v: serde_json::Value) -> MaskingSpec {
260 serde_json::from_value(v).unwrap()
261 }
262
263 #[test]
264 fn compiles_valid_policy() {
265 let c = CompiledMasking::compile(&spec(json!({
266 "rules": [
267 { "match": { "field_pattern": "email" }, "action": { "type": "redact" } },
268 { "match": { "value_detector": "ssn" }, "action": { "type": "hash" } }
269 ]
270 })))
271 .unwrap();
272 assert_eq!(c.rule_count(), 2);
273 assert!(!c.is_empty());
274 assert_eq!(c.rules[0].label, "rule_0");
275 assert_eq!(c.rules[0].action_label, "redact");
276 }
277
278 #[test]
279 fn empty_rules_rejected() {
280 let err = CompiledMasking::compile(&spec(json!({ "rules": [] }))).unwrap_err();
281 assert!(matches!(err, FaucetError::Config(m) if m.contains("at least one rule")));
282 }
283
284 #[test]
285 fn empty_match_rejected() {
286 let err = CompiledMasking::compile(&spec(json!({
287 "rules": [{ "match": {}, "action": { "type": "redact" } }]
288 })))
289 .unwrap_err();
290 assert!(matches!(err, FaucetError::Config(m) if m.contains("at least one of")));
291 }
292
293 #[test]
294 fn bad_regex_rejected() {
295 let err = CompiledMasking::compile(&spec(json!({
296 "rules": [{ "match": { "field_pattern": "(" }, "action": { "type": "hash" } }]
297 })))
298 .unwrap_err();
299 assert!(matches!(err, FaucetError::Config(m) if m.contains("invalid field_pattern")));
300 }
301
302 #[test]
303 fn empty_tokenize_prefix_rejected() {
304 let err = CompiledMasking::compile(&spec(json!({
305 "rules": [{ "match": { "fields": ["t"] }, "action": { "type": "tokenize", "prefix": "" } }]
306 })))
307 .unwrap_err();
308 assert!(matches!(err, FaucetError::Config(m) if m.contains("tokenize prefix")));
309 }
310
311 #[test]
316 fn name_action_for_is_unknown_behind_an_earlier_detector_rule() {
317 let c = CompiledMasking::compile(&spec(json!({
318 "rules": [
319 { "match": { "value_detector": "email" }, "action": { "type": "redact" } },
320 { "match": { "fields": ["contact"] }, "action": { "type": "hash" } },
321 { "match": { "fields": ["mail"] }, "action": { "type": "hash" } }
322 ]
323 })))
324 .unwrap();
325 assert_eq!(c.name_action_for("mail"), None);
326 assert_eq!(c.name_action_for("contact.mail"), Some("hash"));
327 }
328
329 #[test]
330 fn name_action_for_matches_the_field_or_an_ancestor() {
331 let c = CompiledMasking::compile_for_sink(
332 &spec(json!({
333 "rules": [
334 { "match": { "fields": ["phones"] }, "action": { "type": "hash" } },
335 { "match": { "field_pattern": "^card$" }, "action": { "type": "partial" } },
336 { "match": { "fields": ["user.ssn"] }, "action": { "type": "tokenize" } },
337 { "match": { "fields": ["other"] }, "action": { "type": "redact" },
338 "applies_to": ["elsewhere"] },
339 { "match": { "value_detector": "email" }, "action": { "type": "redact" } }
340 ]
341 })),
342 &["here"],
343 )
344 .unwrap();
345 assert_eq!(c.name_action_for("phones"), Some("hash"));
346 assert_eq!(c.name_action_for("phones.0"), Some("hash"));
347 assert_eq!(c.name_action_for("card.number"), Some("partial"));
348 assert_eq!(c.name_action_for("card"), Some("partial"));
349 assert_eq!(c.name_action_for("user.ssn"), Some("tokenize"));
350 assert_eq!(
351 c.name_action_for("ssn"),
352 None,
353 "fields match the full path only"
354 );
355 assert_eq!(c.name_action_for("user"), None);
356 assert_eq!(c.name_action_for("cardholder"), None);
357 assert_eq!(c.name_action_for("other"), None, "scoped to another sink");
358 assert_eq!(
359 c.name_action_for("email"),
360 None,
361 "detectors are value-based"
362 );
363 assert_eq!(c.name_action_for(""), None);
364 }
365
366 #[test]
367 fn custom_rule_name_used_as_label() {
368 let c = CompiledMasking::compile(&spec(json!({
369 "rules": [{ "name": "cards", "match": { "value_detector": "credit_card" },
370 "action": { "type": "partial" } }]
371 })))
372 .unwrap();
373 assert_eq!(c.rules[0].label, "cards");
374 }
375
376 #[test]
377 fn sink_scoping_filters_rules_but_validates_all() {
378 let s = spec(json!({
379 "rules": [
380 { "match": { "fields": ["a"] }, "action": { "type": "redact" }, "applies_to": ["secure"] },
381 { "match": { "fields": ["b"] }, "action": { "type": "redact" }, "applies_to": ["default"] },
382 { "match": { "fields": ["c"] }, "action": { "type": "redact" } }
383 ]
384 }));
385 let def = CompiledMasking::compile_for_sink(&s, &["default"]).unwrap();
386 assert_eq!(def.rule_count(), 2);
388 let sec = CompiledMasking::compile_for_sink(&s, &["secure"]).unwrap();
389 assert_eq!(sec.rule_count(), 2); let other = CompiledMasking::compile_for_sink(&s, &["elsewhere"]).unwrap();
391 assert_eq!(other.rule_count(), 1); assert!(!other.is_empty());
393 let by_kind = CompiledMasking::compile_for_sink(&s, &["postgres", "secure"]).unwrap();
395 assert_eq!(by_kind.rule_count(), 2); }
397
398 #[test]
399 fn sink_scoping_still_validates_inapplicable_rule_regex() {
400 let s = spec(json!({
403 "rules": [{ "match": { "field_pattern": "(" }, "action": { "type": "hash" },
404 "applies_to": ["secure"] }]
405 }));
406 assert!(CompiledMasking::compile_for_sink(&s, &["default"]).is_err());
407 }
408}