1pub mod compile;
20pub mod config;
21pub mod 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 => {
82 if let Some(s) = scalar_to_string(node) {
83 *node = Value::String(apply_scalar_action(action, &s, &m.hasher));
84 hits.push(hit(rule, detector));
85 return;
86 }
87 if matches!(node, Value::Object(_) | Value::Array(_)) {
88 mask_leaves(node, rule, action, &m.hasher, hits);
89 return;
90 }
91 }
92 }
93 }
94
95 match node {
97 Value::Object(map) => {
98 for (k, v) in map.iter_mut() {
99 if m.needs_paths {
101 mask_node(v, &child_path(path, k), m, hits);
102 } else {
103 mask_node(v, UNTRACKED_PATH, m, hits);
104 }
105 }
106 }
107 Value::Array(items) => {
108 for (i, v) in items.iter_mut().enumerate() {
109 if m.needs_paths {
110 mask_node(v, &child_path(path, &i.to_string()), m, hits);
111 } else {
112 mask_node(v, UNTRACKED_PATH, m, hits);
113 }
114 }
115 }
116 _ => {}
117 }
118}
119
120fn mask_leaves(
122 node: &mut Value,
123 rule: &CompiledRule,
124 action: &MaskAction,
125 hasher: &hash::Hasher,
126 hits: &mut Vec<MaskHit>,
127) {
128 match node {
129 Value::Object(map) => {
130 for v in map.values_mut() {
131 mask_leaves(v, rule, action, hasher, hits);
132 }
133 }
134 Value::Array(items) => {
135 for v in items.iter_mut() {
136 mask_leaves(v, rule, action, hasher, hits);
137 }
138 }
139 leaf => {
140 if let Some(s) = scalar_to_string(leaf) {
141 *leaf = Value::String(apply_scalar_action(action, &s, hasher));
142 hits.push(hit(rule, None));
143 }
144 }
145 }
146}
147
148fn first_match<'a>(
151 node: &Value,
152 path: &str,
153 m: &'a CompiledMasking,
154) -> Option<(&'a CompiledRule, Option<&'static str>)> {
155 for rule in &m.rules {
156 if !path.is_empty() {
159 if let Some(re) = &rule.field_pattern
160 && re.is_match(path)
161 {
162 return Some((rule, None));
163 }
164 if rule.fields.contains(path) {
165 return Some((rule, None));
166 }
167 }
168 if let Some(det) = rule.value_detector
171 && detect::detects_value(det, node)
172 {
173 return Some((rule, Some(detector_label(det))));
174 }
175 }
176 None
177}
178
179fn apply_scalar_action(action: &MaskAction, value: &str, hasher: &hash::Hasher) -> String {
180 match action {
181 MaskAction::Hash => hasher.digest_hex(value),
182 MaskAction::Tokenize { prefix } => hasher.token(value, prefix.as_deref().unwrap_or("")),
183 MaskAction::Partial {
184 keep_last,
185 mask_char,
186 } => partial(value, *keep_last, mask_char.unwrap_or('*')),
187 MaskAction::Redact { .. } => "***".to_string(),
189 }
190}
191
192fn partial(value: &str, keep_last: usize, mask_char: char) -> String {
195 let chars: Vec<char> = value.chars().collect();
196 let n = chars.len();
197 let revealed = if keep_last >= n { 0 } else { keep_last };
200 let masked_len = n - revealed;
201 let mut s = String::with_capacity(n);
202 for _ in 0..masked_len {
203 s.push(mask_char);
204 }
205 s.extend(&chars[masked_len..]);
206 s
207}
208
209fn scalar_to_string(v: &Value) -> Option<String> {
212 match v {
213 Value::String(s) => Some(s.clone()),
214 Value::Number(n) => Some(n.to_string()),
215 Value::Bool(b) => Some(b.to_string()),
216 _ => None,
217 }
218}
219
220const UNTRACKED_PATH: &str = "*";
222
223fn child_path(prefix: &str, key: &str) -> String {
224 if prefix.is_empty() {
225 key.to_string()
226 } else {
227 format!("{prefix}.{key}")
228 }
229}
230
231fn hit(rule: &CompiledRule, detector: Option<&'static str>) -> MaskHit {
232 MaskHit {
233 rule: rule.label.clone(),
234 action: rule.action_label,
235 detector,
236 }
237}
238
239fn detector_label(d: Detector) -> &'static str {
240 match d {
241 Detector::Email => "email",
242 Detector::CreditCard => "credit_card",
243 Detector::Ssn => "ssn",
244 Detector::Phone => "phone",
245 Detector::Ipv4 => "ipv4",
246 }
247}
248
249#[cfg(test)]
250mod tests {
251 use super::*;
252 use serde_json::json;
253
254 fn compile(v: Value) -> CompiledMasking {
255 let spec: MaskingSpec = serde_json::from_value(v).unwrap();
256 CompiledMasking::compile(&spec).unwrap()
257 }
258
259 #[test]
260 fn redact_replaces_named_field() {
261 let m = compile(json!({
262 "rules": [{ "match": { "field_pattern": "^email$" }, "action": { "type": "redact" } }]
263 }));
264 let out = apply_masking(vec![json!({"email": "a@b.com", "name": "Al"})], &m);
265 assert_eq!(out.records[0], json!({"email": "***", "name": "Al"}));
266 assert_eq!(out.hits.len(), 1);
267 assert_eq!(out.hits[0].action, "redact");
268 assert_eq!(out.hits[0].detector, None);
269 }
270
271 #[test]
272 fn redact_custom_mask_value() {
273 let m = compile(json!({
275 "rules": [{ "match": { "fields": ["ssn"] },
276 "action": { "type": "redact", "mask": null } }]
277 }));
278 let out = apply_masking(vec![json!({"ssn": "123-45-6789"})], &m);
279 assert_eq!(out.records[0], json!({"ssn": null}));
280
281 let m2 = compile(json!({
283 "rules": [{ "match": { "fields": ["x"] },
284 "action": { "type": "redact", "mask": "REDACTED" } }]
285 }));
286 let out2 = apply_masking(vec![json!({"x": "secret"})], &m2);
287 assert_eq!(out2.records[0], json!({"x": "REDACTED"}));
288 }
289
290 #[test]
291 fn value_detector_matches_across_field_names() {
292 let m = compile(json!({
293 "rules": [{ "match": { "value_detector": "email" }, "action": { "type": "redact" } }]
294 }));
295 let out = apply_masking(
296 vec![json!({"contact": "a@b.com", "note": "hi", "n": 5})],
297 &m,
298 );
299 assert_eq!(
300 out.records[0],
301 json!({"contact": "***", "note": "hi", "n": 5})
302 );
303 assert_eq!(out.hits[0].detector, Some("email"));
304 }
305
306 #[test]
307 fn numbers_are_not_phones_but_can_be_cards() {
308 let m = compile(json!({
309 "rules": [
310 { "match": { "value_detector": "phone" }, "action": { "type": "redact" } },
311 { "match": { "value_detector": "credit_card" }, "action": { "type": "redact" } }
312 ]
313 }));
314 let out = apply_masking(
315 vec![json!({
316 "ts": 1717200000000u64, "amount": 4155552671u64, "flag": true,
317 "tel": "415-555-2671", "cc": 4111111111111111u64
318 })],
319 &m,
320 );
321 assert_eq!(
322 out.records[0],
323 json!({"ts": 1717200000000u64, "amount": 4155552671u64, "flag": true,
324 "tel": "***", "cc": "***"})
325 );
326 }
327
328 #[test]
329 fn hash_is_deterministic_and_joinable() {
330 let m = compile(json!({
331 "key": "k",
332 "rules": [{ "match": { "fields": ["uid"] }, "action": { "type": "hash" } }]
333 }));
334 let out = apply_masking(
335 vec![
336 json!({"uid": "u1"}),
337 json!({"uid": "u1"}),
338 json!({"uid": "u2"}),
339 ],
340 &m,
341 );
342 let h1 = out.records[0]["uid"].as_str().unwrap();
343 assert_eq!(
344 h1,
345 out.records[1]["uid"].as_str().unwrap(),
346 "same input → same hash"
347 );
348 assert_ne!(h1, out.records[2]["uid"].as_str().unwrap());
349 assert_eq!(h1.len(), 64);
350 }
351
352 #[test]
353 fn tokenize_with_prefix() {
354 let m = compile(json!({
355 "key": "k",
356 "rules": [{ "match": { "fields": ["id"] },
357 "action": { "type": "tokenize", "prefix": "tok_" } }]
358 }));
359 let out = apply_masking(vec![json!({"id": "abc"})], &m);
360 assert!(out.records[0]["id"].as_str().unwrap().starts_with("tok_"));
361 }
362
363 #[test]
364 fn partial_keeps_last_n() {
365 let m = compile(json!({
366 "rules": [{ "match": { "fields": ["card"] },
367 "action": { "type": "partial", "keep_last": 4 } }]
368 }));
369 let out = apply_masking(vec![json!({"card": "4111111111111111"})], &m);
370 assert_eq!(out.records[0]["card"], json!("************1111"));
371 }
372
373 #[test]
374 fn partial_masks_all_when_keep_ge_len() {
375 let m = compile(json!({
376 "rules": [{ "match": { "fields": ["pin"] },
377 "action": { "type": "partial", "keep_last": 10 } }]
378 }));
379 let out = apply_masking(vec![json!({"pin": "1234"})], &m);
380 assert_eq!(
381 out.records[0]["pin"],
382 json!("****"),
383 "never reveal a short value whole"
384 );
385 }
386
387 #[test]
388 fn hash_coerces_number_to_string() {
389 let m = compile(json!({
390 "rules": [{ "match": { "fields": ["n"] }, "action": { "type": "hash" } }]
391 }));
392 let out = apply_masking(vec![json!({"n": 42})], &m);
393 assert!(out.records[0]["n"].is_string());
394 }
395
396 #[test]
397 fn nested_object_and_array_paths_masked() {
398 let m = compile(json!({
399 "rules": [{ "match": { "field_pattern": r"contacts\.\d+\.email" },
400 "action": { "type": "redact" } }]
401 }));
402 let out = apply_masking(
403 vec![json!({
404 "user": {"name": "Al"},
405 "contacts": [ {"email": "a@b.com"}, {"email": "c@d.com"} ]
406 })],
407 &m,
408 );
409 assert_eq!(out.records[0]["contacts"][0]["email"], json!("***"));
410 assert_eq!(out.records[0]["contacts"][1]["email"], json!("***"));
411 assert_eq!(out.records[0]["user"]["name"], json!("Al"));
412 assert_eq!(out.hits.len(), 2);
413 }
414
415 #[test]
416 fn redact_whole_subtree_by_name() {
417 let m = compile(json!({
418 "rules": [{ "match": { "fields": ["address"] }, "action": { "type": "redact" } }]
419 }));
420 let out = apply_masking(
421 vec![json!({"address": {"street": "1 Main", "zip": "94103"}, "id": 1})],
422 &m,
423 );
424 assert_eq!(out.records[0], json!({"address": "***", "id": 1}));
425 }
426
427 #[test]
428 fn first_matching_rule_wins() {
429 let m = compile(json!({
430 "rules": [
431 { "name": "first", "match": { "fields": ["x"] }, "action": { "type": "redact" } },
432 { "name": "second", "match": { "fields": ["x"] }, "action": { "type": "hash" } }
433 ]
434 }));
435 let out = apply_masking(vec![json!({"x": "v"})], &m);
436 assert_eq!(out.records[0]["x"], json!("***"));
437 assert_eq!(out.hits[0].rule, "first");
438 }
439
440 #[test]
441 fn scalar_action_on_container_masks_every_leaf_before_later_rules() {
442 let m = compile(json!({
443 "key": "k",
444 "rules": [
445 { "match": { "field_pattern": "^blob$" }, "action": { "type": "hash" } },
446 { "match": { "value_detector": "email" }, "action": { "type": "redact" } }
447 ]
448 }));
449 let out = apply_masking(vec![json!({"blob": {"e": "a@b.com"}})], &m);
450 let e = out.records[0]["blob"]["e"].as_str().unwrap();
451 assert_eq!(
452 e.len(),
453 64,
454 "the name-matched hash rule wins over the detector"
455 );
456 assert_eq!(out.hits.len(), 1);
457 assert_eq!(out.hits[0].action, "hash");
458 }
459
460 #[test]
461 fn hash_on_name_matched_array_masks_each_element() {
462 let m = compile(json!({
463 "key": "k",
464 "rules": [{ "match": { "fields": ["phones"] }, "action": { "type": "hash" } }]
465 }));
466 let out = apply_masking(
467 vec![json!({"phones": ["+14155552671", "+14155550000", null], "id": 1})],
468 &m,
469 );
470 let phones = out.records[0]["phones"].as_array().unwrap();
471 for p in &phones[..2] {
472 let s = p.as_str().unwrap();
473 assert_eq!(s.len(), 64);
474 assert!(!s.contains("415"));
475 }
476 assert_eq!(phones[2], json!(null));
477 assert_eq!(out.records[0]["id"], json!(1));
478 assert_eq!(out.hits.len(), 2);
479 }
480
481 #[test]
482 fn partial_on_anchored_pattern_masks_nested_object_leaves() {
483 let m = compile(json!({
484 "rules": [{ "match": { "field_pattern": "^card$" },
485 "action": { "type": "partial", "keep_last": 4 } }]
486 }));
487 let out = apply_masking(
488 vec![json!({"card": {"number": "4111111111111111", "exp": {"m": 12, "ok": true}}})],
489 &m,
490 );
491 let card = &out.records[0]["card"];
492 assert_eq!(card["number"], json!("************1111"));
493 assert_eq!(card["exp"]["m"], json!("**"));
494 assert_eq!(card["exp"]["ok"], json!("****"));
495 assert_eq!(out.hits.len(), 3);
496 }
497
498 #[test]
499 fn tokenize_on_anchored_pattern_masks_nested_object_leaves() {
500 let m = compile(json!({
501 "key": "k",
502 "rules": [{ "match": { "field_pattern": "^card$" },
503 "action": { "type": "tokenize", "prefix": "tok_" } }]
504 }));
505 let out = apply_masking(vec![json!({"card": {"number": "4111111111111111"}})], &m);
506 let n = out.records[0]["card"]["number"].as_str().unwrap();
507 assert!(n.starts_with("tok_"));
508 assert!(!n.contains("4111"));
509 assert_eq!(out.hits.len(), 1);
510 }
511
512 #[test]
513 fn null_is_not_masked() {
514 let m = compile(json!({
515 "rules": [{ "match": { "fields": ["x"] }, "action": { "type": "hash" } }]
516 }));
517 let out = apply_masking(vec![json!({"x": null})], &m);
518 assert_eq!(out.records[0]["x"], json!(null));
519 assert!(out.hits.is_empty());
520 }
521
522 #[test]
523 fn non_object_record_untouched() {
524 let m = compile(json!({
525 "rules": [{ "match": { "value_detector": "email" }, "action": { "type": "redact" } }]
526 }));
527 let out = apply_masking(vec![json!("a@b.com")], &m);
530 assert_eq!(out.records[0], json!("***"));
531 }
532
533 #[test]
534 fn no_match_leaves_page_untouched() {
535 let m = compile(json!({
536 "rules": [{ "match": { "fields": ["absent"] }, "action": { "type": "redact" } }]
537 }));
538 let page = vec![json!({"a": 1, "b": "x"})];
539 let out = apply_masking(page.clone(), &m);
540 assert_eq!(out.records, page);
541 assert!(out.hits.is_empty());
542 }
543}