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