1pub mod compile;
20pub mod config;
21mod detect;
22mod hash;
23
24pub use compile::CompiledMasking;
25pub use config::{Detector, MaskAction, MaskRule, MaskingSpec, MatchSpec};
26
27use compile::CompiledRule;
28use serde_json::Value;
29
30#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct MaskHit {
33 pub rule: String,
35 pub action: &'static str,
37 pub detector: Option<&'static str>,
39}
40
41#[derive(Debug, Clone, Default)]
43pub struct MaskingOutcome {
44 pub records: Vec<Value>,
46 pub hits: Vec<MaskHit>,
48}
49
50pub fn apply_masking(records: Vec<Value>, masking: &CompiledMasking) -> MaskingOutcome {
59 let mut out = MaskingOutcome {
60 records: Vec::with_capacity(records.len()),
61 hits: Vec::new(),
62 };
63 for mut rec in records {
64 mask_node(&mut rec, "", masking, &mut out.hits);
65 out.records.push(rec);
66 }
67 out
68}
69
70fn mask_node(node: &mut Value, path: &str, m: &CompiledMasking, hits: &mut Vec<MaskHit>) {
73 if let Some((rule, detector)) = first_match(node, path, m) {
75 match &rule.action {
76 MaskAction::Redact { mask } => {
77 *node = mask.clone();
78 hits.push(hit(rule, detector));
79 return; }
81 action => {
84 if let Some(s) = scalar_to_string(node) {
85 *node = Value::String(apply_scalar_action(action, &s, &m.hasher));
86 hits.push(hit(rule, detector));
87 return;
88 }
89 }
90 }
91 }
92
93 match node {
95 Value::Object(map) => {
96 for (k, v) in map.iter_mut() {
97 let child = child_path(path, k);
98 mask_node(v, &child, m, hits);
99 }
100 }
101 Value::Array(items) => {
102 for (i, v) in items.iter_mut().enumerate() {
103 let child = child_path(path, &i.to_string());
104 mask_node(v, &child, m, hits);
105 }
106 }
107 _ => {}
108 }
109}
110
111fn first_match<'a>(
114 node: &Value,
115 path: &str,
116 m: &'a CompiledMasking,
117) -> Option<(&'a CompiledRule, Option<&'static str>)> {
118 let scalar = scalar_to_string(node);
120 for rule in &m.rules {
121 if !path.is_empty() {
124 if let Some(re) = &rule.field_pattern
125 && re.is_match(path)
126 {
127 return Some((rule, None));
128 }
129 if rule.fields.contains(path) {
130 return Some((rule, None));
131 }
132 }
133 if let (Some(det), Some(s)) = (rule.value_detector, scalar.as_deref())
135 && detect::detects(det, s)
136 {
137 return Some((rule, Some(detector_label(det))));
138 }
139 }
140 None
141}
142
143fn apply_scalar_action(action: &MaskAction, value: &str, hasher: &hash::Hasher) -> String {
144 match action {
145 MaskAction::Hash => hasher.digest_hex(value),
146 MaskAction::Tokenize { prefix } => hasher.token(value, prefix.as_deref().unwrap_or("")),
147 MaskAction::Partial {
148 keep_last,
149 mask_char,
150 } => partial(value, *keep_last, mask_char.unwrap_or('*')),
151 MaskAction::Redact { .. } => "***".to_string(),
153 }
154}
155
156fn partial(value: &str, keep_last: usize, mask_char: char) -> String {
159 let chars: Vec<char> = value.chars().collect();
160 let n = chars.len();
161 let revealed = if keep_last >= n { 0 } else { keep_last };
164 let masked_len = n - revealed;
165 let mut s = String::with_capacity(n);
166 for _ in 0..masked_len {
167 s.push(mask_char);
168 }
169 s.extend(&chars[masked_len..]);
170 s
171}
172
173fn scalar_to_string(v: &Value) -> Option<String> {
176 match v {
177 Value::String(s) => Some(s.clone()),
178 Value::Number(n) => Some(n.to_string()),
179 Value::Bool(b) => Some(b.to_string()),
180 _ => None,
181 }
182}
183
184fn child_path(prefix: &str, key: &str) -> String {
185 if prefix.is_empty() {
186 key.to_string()
187 } else {
188 format!("{prefix}.{key}")
189 }
190}
191
192fn hit(rule: &CompiledRule, detector: Option<&'static str>) -> MaskHit {
193 MaskHit {
194 rule: rule.label.clone(),
195 action: rule.action_label,
196 detector,
197 }
198}
199
200fn detector_label(d: Detector) -> &'static str {
201 match d {
202 Detector::Email => "email",
203 Detector::CreditCard => "credit_card",
204 Detector::Ssn => "ssn",
205 Detector::Phone => "phone",
206 Detector::Ipv4 => "ipv4",
207 }
208}
209
210#[cfg(test)]
211mod tests {
212 use super::*;
213 use serde_json::json;
214
215 fn compile(v: Value) -> CompiledMasking {
216 let spec: MaskingSpec = serde_json::from_value(v).unwrap();
217 CompiledMasking::compile(&spec).unwrap()
218 }
219
220 #[test]
221 fn redact_replaces_named_field() {
222 let m = compile(json!({
223 "rules": [{ "match": { "field_pattern": "^email$" }, "action": { "type": "redact" } }]
224 }));
225 let out = apply_masking(vec![json!({"email": "a@b.com", "name": "Al"})], &m);
226 assert_eq!(out.records[0], json!({"email": "***", "name": "Al"}));
227 assert_eq!(out.hits.len(), 1);
228 assert_eq!(out.hits[0].action, "redact");
229 assert_eq!(out.hits[0].detector, None);
230 }
231
232 #[test]
233 fn redact_custom_mask_value() {
234 let m = compile(json!({
236 "rules": [{ "match": { "fields": ["ssn"] },
237 "action": { "type": "redact", "mask": null } }]
238 }));
239 let out = apply_masking(vec![json!({"ssn": "123-45-6789"})], &m);
240 assert_eq!(out.records[0], json!({"ssn": null}));
241
242 let m2 = compile(json!({
244 "rules": [{ "match": { "fields": ["x"] },
245 "action": { "type": "redact", "mask": "REDACTED" } }]
246 }));
247 let out2 = apply_masking(vec![json!({"x": "secret"})], &m2);
248 assert_eq!(out2.records[0], json!({"x": "REDACTED"}));
249 }
250
251 #[test]
252 fn value_detector_matches_across_field_names() {
253 let m = compile(json!({
254 "rules": [{ "match": { "value_detector": "email" }, "action": { "type": "redact" } }]
255 }));
256 let out = apply_masking(
257 vec![json!({"contact": "a@b.com", "note": "hi", "n": 5})],
258 &m,
259 );
260 assert_eq!(
261 out.records[0],
262 json!({"contact": "***", "note": "hi", "n": 5})
263 );
264 assert_eq!(out.hits[0].detector, Some("email"));
265 }
266
267 #[test]
268 fn hash_is_deterministic_and_joinable() {
269 let m = compile(json!({
270 "key": "k",
271 "rules": [{ "match": { "fields": ["uid"] }, "action": { "type": "hash" } }]
272 }));
273 let out = apply_masking(
274 vec![
275 json!({"uid": "u1"}),
276 json!({"uid": "u1"}),
277 json!({"uid": "u2"}),
278 ],
279 &m,
280 );
281 let h1 = out.records[0]["uid"].as_str().unwrap();
282 assert_eq!(
283 h1,
284 out.records[1]["uid"].as_str().unwrap(),
285 "same input → same hash"
286 );
287 assert_ne!(h1, out.records[2]["uid"].as_str().unwrap());
288 assert_eq!(h1.len(), 64);
289 }
290
291 #[test]
292 fn tokenize_with_prefix() {
293 let m = compile(json!({
294 "key": "k",
295 "rules": [{ "match": { "fields": ["id"] },
296 "action": { "type": "tokenize", "prefix": "tok_" } }]
297 }));
298 let out = apply_masking(vec![json!({"id": "abc"})], &m);
299 assert!(out.records[0]["id"].as_str().unwrap().starts_with("tok_"));
300 }
301
302 #[test]
303 fn partial_keeps_last_n() {
304 let m = compile(json!({
305 "rules": [{ "match": { "fields": ["card"] },
306 "action": { "type": "partial", "keep_last": 4 } }]
307 }));
308 let out = apply_masking(vec![json!({"card": "4111111111111111"})], &m);
309 assert_eq!(out.records[0]["card"], json!("************1111"));
310 }
311
312 #[test]
313 fn partial_masks_all_when_keep_ge_len() {
314 let m = compile(json!({
315 "rules": [{ "match": { "fields": ["pin"] },
316 "action": { "type": "partial", "keep_last": 10 } }]
317 }));
318 let out = apply_masking(vec![json!({"pin": "1234"})], &m);
319 assert_eq!(
320 out.records[0]["pin"],
321 json!("****"),
322 "never reveal a short value whole"
323 );
324 }
325
326 #[test]
327 fn hash_coerces_number_to_string() {
328 let m = compile(json!({
329 "rules": [{ "match": { "fields": ["n"] }, "action": { "type": "hash" } }]
330 }));
331 let out = apply_masking(vec![json!({"n": 42})], &m);
332 assert!(out.records[0]["n"].is_string());
333 }
334
335 #[test]
336 fn nested_object_and_array_paths_masked() {
337 let m = compile(json!({
338 "rules": [{ "match": { "field_pattern": r"contacts\.\d+\.email" },
339 "action": { "type": "redact" } }]
340 }));
341 let out = apply_masking(
342 vec![json!({
343 "user": {"name": "Al"},
344 "contacts": [ {"email": "a@b.com"}, {"email": "c@d.com"} ]
345 })],
346 &m,
347 );
348 assert_eq!(out.records[0]["contacts"][0]["email"], json!("***"));
349 assert_eq!(out.records[0]["contacts"][1]["email"], json!("***"));
350 assert_eq!(out.records[0]["user"]["name"], json!("Al"));
351 assert_eq!(out.hits.len(), 2);
352 }
353
354 #[test]
355 fn redact_whole_subtree_by_name() {
356 let m = compile(json!({
357 "rules": [{ "match": { "fields": ["address"] }, "action": { "type": "redact" } }]
358 }));
359 let out = apply_masking(
360 vec![json!({"address": {"street": "1 Main", "zip": "94103"}, "id": 1})],
361 &m,
362 );
363 assert_eq!(out.records[0], json!({"address": "***", "id": 1}));
364 }
365
366 #[test]
367 fn first_matching_rule_wins() {
368 let m = compile(json!({
369 "rules": [
370 { "name": "first", "match": { "fields": ["x"] }, "action": { "type": "redact" } },
371 { "name": "second", "match": { "fields": ["x"] }, "action": { "type": "hash" } }
372 ]
373 }));
374 let out = apply_masking(vec![json!({"x": "v"})], &m);
375 assert_eq!(out.records[0]["x"], json!("***"));
376 assert_eq!(out.hits[0].rule, "first");
377 }
378
379 #[test]
380 fn scalar_action_on_container_falls_through_to_children() {
381 let m = compile(json!({
384 "rules": [
385 { "match": { "field_pattern": "^blob$" }, "action": { "type": "hash" } },
386 { "match": { "value_detector": "email" }, "action": { "type": "redact" } }
387 ]
388 }));
389 let out = apply_masking(vec![json!({"blob": {"e": "a@b.com"}})], &m);
390 assert_eq!(out.records[0]["blob"]["e"], json!("***"));
391 }
392
393 #[test]
394 fn null_is_not_masked() {
395 let m = compile(json!({
396 "rules": [{ "match": { "fields": ["x"] }, "action": { "type": "hash" } }]
397 }));
398 let out = apply_masking(vec![json!({"x": null})], &m);
399 assert_eq!(out.records[0]["x"], json!(null));
400 assert!(out.hits.is_empty());
401 }
402
403 #[test]
404 fn non_object_record_untouched() {
405 let m = compile(json!({
406 "rules": [{ "match": { "value_detector": "email" }, "action": { "type": "redact" } }]
407 }));
408 let out = apply_masking(vec![json!("a@b.com")], &m);
411 assert_eq!(out.records[0], json!("***"));
412 }
413
414 #[test]
415 fn no_match_leaves_page_untouched() {
416 let m = compile(json!({
417 "rules": [{ "match": { "fields": ["absent"] }, "action": { "type": "redact" } }]
418 }));
419 let page = vec![json!({"a": 1, "b": "x"})];
420 let out = apply_masking(page.clone(), &m);
421 assert_eq!(out.records, page);
422 assert!(out.hits.is_empty());
423 }
424}