1use 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 pub(crate) needs_paths: bool,
24}
25
26impl CompiledMasking {
27 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#[derive(Debug, Clone)]
38pub(crate) struct CompiledRule {
39 pub(crate) label: String,
41 pub(crate) field_pattern: Option<Regex>,
43 pub(crate) value_detector: Option<Detector>,
45 pub(crate) fields: HashSet<String>,
47 pub(crate) action: MaskAction,
49 pub(crate) action_label: &'static str,
51}
52
53impl CompiledMasking {
54 pub fn compile(spec: &MaskingSpec) -> Result<Self, FaucetError> {
57 Self::compile_scoped(spec, None)
58 }
59
60 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)?; 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 pub fn is_empty(&self) -> bool {
93 self.rules.is_empty()
94 }
95
96 pub fn rule_count(&self) -> usize {
98 self.rules.len()
99 }
100
101 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
125fn 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 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 assert_eq!(def.rule_count(), 2);
295 let sec = CompiledMasking::compile_for_sink(&s, &["secure"]).unwrap();
296 assert_eq!(sec.rule_count(), 2); let other = CompiledMasking::compile_for_sink(&s, &["elsewhere"]).unwrap();
298 assert_eq!(other.rule_count(), 1); assert!(!other.is_empty());
300 let by_kind = CompiledMasking::compile_for_sink(&s, &["postgres", "secure"]).unwrap();
302 assert_eq!(by_kind.rule_count(), 2); }
304
305 #[test]
306 fn sink_scoping_still_validates_inapplicable_rule_regex() {
307 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}