Skip to main content

av_redact/
lib.rs

1//! Real-time sensitive-data redaction engine for audit event payloads.
2//!
3//! Scans JSON values (or raw JSON bytes) for sensitive data — API keys,
4//! email addresses, credit card numbers, Social Security numbers, public
5//! IPv4 addresses — and replaces matching spans with a configurable
6//! placeholder (default `[REDACTED]`). JSON pointer paths can be used to
7//! unconditionally redact specific subtrees.
8//!
9//! All regex compilation happens once at engine construction. The resulting
10//! [`RedactionEngine`] is `Send + Sync` and safe for use across async
11//! worker tasks.
12
13pub mod engine;
14
15pub use engine::{RedactError, RedactionConfig, RedactionEngine};
16
17#[cfg(test)]
18mod tests {
19    #![allow(
20        clippy::unwrap_used,
21        clippy::expect_used,
22        clippy::panic,
23        clippy::indexing_slicing
24    )]
25
26    use super::*;
27    use serde_json::json;
28
29    /// Helper: build an engine with default (built-in) patterns.
30    fn default_engine() -> RedactionEngine {
31        RedactionEngine::new(RedactionConfig::default()).unwrap()
32    }
33
34    // ---------------------------------------------------------------
35    // Send + Sync compile-time check
36    // ---------------------------------------------------------------
37
38    fn _assert_send_sync<T: Send + Sync>() {}
39
40    #[test]
41    fn engine_is_send_and_sync() {
42        _assert_send_sync::<RedactionEngine>();
43    }
44
45    // ---------------------------------------------------------------
46    // Bearer tokens
47    // ---------------------------------------------------------------
48
49    #[test]
50    fn bearer_token_is_redacted() {
51        let engine = default_engine();
52        let mut val = json!("Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.abc123");
53        engine.redact_value(&mut val);
54        assert_eq!(val, json!("[REDACTED]"));
55    }
56
57    #[test]
58    fn bearer_in_context_preserves_surrounding_text() {
59        let engine = default_engine();
60        let mut val = json!("Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.abc123XY");
61        engine.redact_value(&mut val);
62        let s = val.as_str().unwrap();
63        assert!(s.starts_with("Authorization: [REDACTED]"), "got: {s}");
64        assert!(!s.contains("eyJ"));
65    }
66
67    #[test]
68    fn bearer_of_bad_news_is_not_redacted() {
69        let engine = default_engine();
70        let mut val = json!("Bearer of bad news");
71        engine.redact_value(&mut val);
72        assert_eq!(val, json!("Bearer of bad news"));
73    }
74
75    // ---------------------------------------------------------------
76    // API keys (sk-*, key-*)
77    // ---------------------------------------------------------------
78
79    #[test]
80    fn sk_api_key_is_redacted() {
81        let engine = default_engine();
82        let mut val = json!("sk-abc123def456ghi789jk");
83        engine.redact_value(&mut val);
84        let s = val.as_str().unwrap();
85        assert!(s.contains("[REDACTED]"), "got: {s}");
86        assert!(!s.contains("sk-abc123"));
87    }
88
89    #[test]
90    fn key_api_key_is_redacted() {
91        let engine = default_engine();
92        let mut val = json!("key-ABCDEF0123456789XY");
93        engine.redact_value(&mut val);
94        let s = val.as_str().unwrap();
95        assert!(s.contains("[REDACTED]"), "got: {s}");
96    }
97
98    #[test]
99    fn key_value_is_not_redacted() {
100        let engine = default_engine();
101        let mut val = json!("key-value");
102        engine.redact_value(&mut val);
103        assert_eq!(val, json!("key-value"));
104    }
105
106    #[test]
107    fn segmented_api_keys_are_redacted_in_full() {
108        let engine = default_engine();
109        for key in [
110            "sk-proj-Ab3dEf_Gh1jK-Lm4nOp5qRs6tUv7wXy8zA9bC0dE_",
111            "sk-svcacct-AbCdEf0123456789_-",
112            "sk-admin-AbCdEf0123456789_-",
113            "sk-ant-api03-AbCdEfGhIjKlMnOpQrStUvWxYz0123456789-_AbCdAA",
114            "key-AbCdEf0123456789_-",
115        ] {
116            let mut value = json!(format!("key: {key}; done"));
117            engine.redact_value(&mut value);
118            assert_eq!(value, json!("key: [REDACTED]; done"));
119        }
120    }
121
122    #[test]
123    fn sensitive_values_touching_unicode_text_are_redacted() {
124        let engine = default_engine();
125        for (input, expected) in [
126            ("カード番号は4111111111111111です", "カード番号は[REDACTED]です"),
127            ("社会保障番号123-45-6789です", "社会保障番号[REDACTED]です"),
128            ("IP地址8.8.8.8です", "IP地址[REDACTED]です"),
129            ("密钥sk-proj-AbCdEf0123456789_です", "密钥[REDACTED]です"),
130            ("Nº4111111111111111", "Nº[REDACTED]"),
131        ] {
132            let mut value = json!(input);
133            engine.redact_value(&mut value);
134            assert_eq!(value, json!(expected));
135        }
136    }
137
138    #[test]
139    fn task_something_is_not_redacted() {
140        let engine = default_engine();
141        let mut val = json!("task-manager");
142        engine.redact_value(&mut val);
143        assert_eq!(val, json!("task-manager"));
144    }
145
146    // ---------------------------------------------------------------
147    // Email addresses
148    // ---------------------------------------------------------------
149
150    #[test]
151    fn email_is_redacted() {
152        let engine = default_engine();
153        let mut val = json!("user@example.com");
154        engine.redact_value(&mut val);
155        let s = val.as_str().unwrap();
156        assert!(s.contains("[REDACTED]"), "got: {s}");
157        assert!(!s.contains("user@"));
158    }
159
160    #[test]
161    fn email_in_sentence_preserves_context() {
162        let engine = default_engine();
163        let mut val = json!("Contact alice@example.org for help");
164        engine.redact_value(&mut val);
165        let s = val.as_str().unwrap();
166        assert!(s.starts_with("Contact "), "got: {s}");
167        assert!(s.ends_with(" for help"), "got: {s}");
168        assert!(!s.contains("alice@"));
169    }
170
171    #[test]
172    fn non_email_at_sign_is_not_redacted() {
173        let engine = default_engine();
174        let mut val = json!("@mention in chat");
175        engine.redact_value(&mut val);
176        assert_eq!(val, json!("@mention in chat"));
177    }
178
179    // ---------------------------------------------------------------
180    // Credit card numbers (Luhn-validated)
181    // ---------------------------------------------------------------
182
183    #[test]
184    fn valid_visa_card_is_redacted() {
185        let engine = default_engine();
186        // 4111111111111111 passes Luhn.
187        let mut val = json!("4111111111111111");
188        engine.redact_value(&mut val);
189        let s = val.as_str().unwrap();
190        assert!(s.contains("[REDACTED]"), "got: {s}");
191    }
192
193    #[test]
194    fn valid_card_with_dashes_is_redacted() {
195        let engine = default_engine();
196        let mut val = json!("4111-1111-1111-1111");
197        engine.redact_value(&mut val);
198        let s = val.as_str().unwrap();
199        assert!(s.contains("[REDACTED]"), "got: {s}");
200    }
201
202    #[test]
203    fn valid_card_with_spaces_is_redacted() {
204        let engine = default_engine();
205        let mut val = json!("4111 1111 1111 1111");
206        engine.redact_value(&mut val);
207        let s = val.as_str().unwrap();
208        assert!(s.contains("[REDACTED]"), "got: {s}");
209    }
210
211    #[test]
212    fn invalid_luhn_card_is_not_redacted() {
213        let engine = default_engine();
214        // 4111111111111112 fails Luhn.
215        let mut val = json!("4111111111111112");
216        engine.redact_value(&mut val);
217        assert_eq!(val, json!("4111111111111112"));
218    }
219
220    // ---------------------------------------------------------------
221    // US Social Security Numbers
222    // ---------------------------------------------------------------
223
224    #[test]
225    fn ssn_is_redacted() {
226        let engine = default_engine();
227        let mut val = json!("SSN: 123-45-6789");
228        engine.redact_value(&mut val);
229        let s = val.as_str().unwrap();
230        assert!(s.contains("[REDACTED]"), "got: {s}");
231        assert!(!s.contains("123-45-6789"));
232    }
233
234    #[test]
235    fn non_ssn_dashes_are_not_redacted() {
236        let engine = default_engine();
237        let mut val = json!("phone: 555-1234");
238        engine.redact_value(&mut val);
239        assert_eq!(val, json!("phone: 555-1234"));
240    }
241
242    // ---------------------------------------------------------------
243    // IPv4 addresses (non-RFC 1918)
244    // ---------------------------------------------------------------
245
246    #[test]
247    fn public_ipv4_is_redacted() {
248        let engine = default_engine();
249        let mut val = json!("Connected from 8.8.8.8 on port 443");
250        engine.redact_value(&mut val);
251        let s = val.as_str().unwrap();
252        assert!(s.contains("[REDACTED]"), "got: {s}");
253        assert!(!s.contains("8.8.8.8"));
254        assert!(s.contains("on port 443"), "surrounding text lost: {s}");
255    }
256
257    #[test]
258    fn private_10_x_is_not_redacted() {
259        let engine = default_engine();
260        let mut val = json!("10.0.0.1");
261        engine.redact_value(&mut val);
262        assert_eq!(val, json!("10.0.0.1"));
263    }
264
265    #[test]
266    fn private_172_16_is_not_redacted() {
267        let engine = default_engine();
268        let mut val = json!("172.16.0.1");
269        engine.redact_value(&mut val);
270        assert_eq!(val, json!("172.16.0.1"));
271    }
272
273    #[test]
274    fn private_172_31_is_not_redacted() {
275        let engine = default_engine();
276        let mut val = json!("172.31.255.255");
277        engine.redact_value(&mut val);
278        assert_eq!(val, json!("172.31.255.255"));
279    }
280
281    #[test]
282    fn public_172_15_is_redacted() {
283        let engine = default_engine();
284        let mut val = json!("172.15.255.255");
285        engine.redact_value(&mut val);
286        let s = val.as_str().unwrap();
287        assert!(s.contains("[REDACTED]"), "got: {s}");
288    }
289
290    #[test]
291    fn public_172_32_is_redacted() {
292        let engine = default_engine();
293        let mut val = json!("172.32.0.0");
294        engine.redact_value(&mut val);
295        let s = val.as_str().unwrap();
296        assert!(s.contains("[REDACTED]"), "got: {s}");
297    }
298
299    #[test]
300    fn private_192_168_is_not_redacted() {
301        let engine = default_engine();
302        let mut val = json!("192.168.1.1");
303        engine.redact_value(&mut val);
304        assert_eq!(val, json!("192.168.1.1"));
305    }
306
307    #[test]
308    fn invalid_ipv4_999_is_not_redacted() {
309        let engine = default_engine();
310        let mut val = json!("999.1.1.1");
311        engine.redact_value(&mut val);
312        assert_eq!(val, json!("999.1.1.1"));
313    }
314
315    // ---------------------------------------------------------------
316    // Custom regex patterns
317    // ---------------------------------------------------------------
318
319    #[test]
320    fn custom_pattern_works() {
321        let config = RedactionConfig {
322            regex_patterns: vec![r"secret_[a-z]+".to_owned()],
323            include_builtin_patterns: false,
324            ..RedactionConfig::default()
325        };
326        let engine = RedactionEngine::new(config).unwrap();
327        let mut val = json!("my secret_token is here");
328        engine.redact_value(&mut val);
329        let s = val.as_str().unwrap();
330        assert!(s.contains("[REDACTED]"), "got: {s}");
331        assert!(!s.contains("secret_token"));
332        assert!(s.contains("my "));
333    }
334
335    #[test]
336    fn invalid_regex_returns_error() {
337        let config = RedactionConfig {
338            regex_patterns: vec![r"[invalid".to_owned()],
339            include_builtin_patterns: false,
340            ..RedactionConfig::default()
341        };
342        let result = RedactionEngine::new(config);
343        assert!(result.is_err());
344        let err = result.unwrap_err();
345        assert!(matches!(err, RedactError::InvalidPattern { .. }));
346    }
347
348    // ---------------------------------------------------------------
349    // Custom replacement string
350    // ---------------------------------------------------------------
351
352    #[test]
353    fn custom_replacement_string() {
354        let config = RedactionConfig {
355            regex_patterns: vec![r"secret".to_owned()],
356            replacement: "***".to_owned(),
357            include_builtin_patterns: false,
358            ..RedactionConfig::default()
359        };
360        let engine = RedactionEngine::new(config).unwrap();
361        let mut val = json!("the secret is here");
362        engine.redact_value(&mut val);
363        assert_eq!(val, json!("the *** is here"));
364    }
365
366    // ---------------------------------------------------------------
367    // JSON pointer redaction
368    // ---------------------------------------------------------------
369
370    #[test]
371    fn pointer_path_redacts_string_value() {
372        let config = RedactionConfig {
373            pointer_paths: vec!["/password".to_owned()],
374            include_builtin_patterns: false,
375            ..RedactionConfig::default()
376        };
377        let engine = RedactionEngine::new(config).unwrap();
378        let mut val = json!({"username": "alice", "password": "hunter2"});
379        engine.redact_value(&mut val);
380        assert_eq!(val["password"], json!("[REDACTED]"));
381        assert_eq!(val["username"], json!("alice"));
382    }
383
384    #[test]
385    fn pointer_path_redacts_nested_value() {
386        let config = RedactionConfig {
387            pointer_paths: vec!["/credentials/secret".to_owned()],
388            include_builtin_patterns: false,
389            ..RedactionConfig::default()
390        };
391        let engine = RedactionEngine::new(config).unwrap();
392        let mut val = json!({"credentials": {"secret": "abc", "id": "xyz"}});
393        engine.redact_value(&mut val);
394        assert_eq!(val["credentials"]["secret"], json!("[REDACTED]"));
395        assert_eq!(val["credentials"]["id"], json!("xyz"));
396    }
397
398    #[test]
399    fn pointer_path_redacts_non_string_value() {
400        let config = RedactionConfig {
401            pointer_paths: vec!["/debug_flag".to_owned()],
402            include_builtin_patterns: false,
403            ..RedactionConfig::default()
404        };
405        let engine = RedactionEngine::new(config).unwrap();
406        let mut val = json!({"debug_flag": true, "name": "test"});
407        engine.redact_value(&mut val);
408        assert_eq!(val["debug_flag"], json!("[REDACTED]"));
409        assert_eq!(val["name"], json!("test"));
410    }
411
412    #[test]
413    fn invalid_pointer_path_returns_error() {
414        let config = RedactionConfig {
415            pointer_paths: vec!["no-leading-slash".to_owned()],
416            include_builtin_patterns: false,
417            ..RedactionConfig::default()
418        };
419        let result = RedactionEngine::new(config);
420        assert!(result.is_err());
421        assert!(matches!(
422            result.unwrap_err(),
423            RedactError::InvalidPointerPath { .. }
424        ));
425    }
426
427    #[test]
428    fn missing_pointer_path_is_harmless() {
429        let config = RedactionConfig {
430            pointer_paths: vec!["/nonexistent".to_owned()],
431            include_builtin_patterns: false,
432            ..RedactionConfig::default()
433        };
434        let engine = RedactionEngine::new(config).unwrap();
435        let mut val = json!({"name": "alice"});
436        engine.redact_value(&mut val);
437        assert_eq!(val, json!({"name": "alice"}));
438    }
439
440    // ---------------------------------------------------------------
441    // Idempotency
442    // ---------------------------------------------------------------
443
444    #[test]
445    fn idempotency_across_all_patterns() {
446        let engine = default_engine();
447        let fixtures = vec![
448            json!("Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.abc123"),
449            json!("sk-abc123def456ghi789jk"),
450            json!("user@example.com"),
451            json!("4111111111111111"),
452            json!("123-45-6789"),
453            json!("8.8.8.8"),
454            json!({"password": "secret", "email": "a@b.com"}),
455            json!(["Bearer token123456789012", "10.0.0.1"]),
456        ];
457        for original in fixtures {
458            let mut first = original.clone();
459            engine.redact_value(&mut first);
460            let mut second = first.clone();
461            engine.redact_value(&mut second);
462            assert_eq!(first, second, "idempotency failed for {original}");
463        }
464    }
465
466    #[test]
467    fn replacement_string_is_not_re_redacted() {
468        let engine = default_engine();
469        let mut val = json!("[REDACTED]");
470        engine.redact_value(&mut val);
471        assert_eq!(val, json!("[REDACTED]"));
472    }
473
474    // ---------------------------------------------------------------
475    // Recursive JSON walking
476    // ---------------------------------------------------------------
477
478    #[test]
479    fn nested_objects_are_walked() {
480        let engine = default_engine();
481        let mut val = json!({
482            "outer": {
483                "inner": {
484                    "deep": "user@example.com"
485                }
486            }
487        });
488        engine.redact_value(&mut val);
489        let deep = val["outer"]["inner"]["deep"].as_str().unwrap();
490        assert!(deep.contains("[REDACTED]"), "got: {deep}");
491    }
492
493    #[test]
494    fn array_elements_are_walked() {
495        let engine = default_engine();
496        let mut val = json!([
497            "safe text",
498            "user@example.com",
499            {"key": "123-45-6789"}
500        ]);
501        engine.redact_value(&mut val);
502        assert_eq!(val[0], json!("safe text"));
503        let email = val[1].as_str().unwrap();
504        assert!(email.contains("[REDACTED]"), "email not redacted: {email}");
505        let ssn = val[2]["key"].as_str().unwrap();
506        assert!(ssn.contains("[REDACTED]"), "SSN not redacted: {ssn}");
507    }
508
509    // ---------------------------------------------------------------
510    // Non-string values left alone
511    // ---------------------------------------------------------------
512
513    #[test]
514    fn numbers_are_not_modified() {
515        let engine = default_engine();
516        let mut val = json!({"count": 42, "price": 9.99});
517        engine.redact_value(&mut val);
518        assert_eq!(val["count"], json!(42));
519        assert_eq!(val["price"], json!(9.99));
520    }
521
522    #[test]
523    fn user_integer_cards_are_redacted_without_changing_typed_event_numbers() {
524        let engine = default_engine();
525        let input = json!({"card": 4_111_111_111_111_111_u64, "count": 42, "price": 9.99});
526        let mut event = input.clone();
527        engine.redact_value(&mut event);
528        assert_eq!(event, input);
529        let mut arguments = input;
530        engine.redact_user_value(&mut arguments);
531        assert_eq!(
532            arguments,
533            json!({"card": "[REDACTED]", "count": 42, "price": 9.99})
534        );
535    }
536
537    #[test]
538    fn redacted_user_keys_preserve_colliding_values_and_are_idempotent() {
539        let engine = default_engine();
540        let mut value = json!({"share_with": {
541            "alice@example.com": "editor",
542            "bob@example.com": ["viewer", "reader"],
543            "[REDACTED]": "owner",
544            "safe": "kept"
545        }});
546        engine.redact_user_value(&mut value);
547        assert_eq!(
548            value,
549            json!({"share_with": {
550                "[REDACTED]": ["editor", ["viewer", "reader"], "owner"],
551                "safe": "kept"
552            }})
553        );
554        let once = value.clone();
555        engine.redact_user_value(&mut value);
556        assert_eq!(value, once);
557    }
558
559    #[test]
560    fn booleans_are_not_modified() {
561        let engine = default_engine();
562        let mut val = json!({"active": true, "deleted": false});
563        engine.redact_value(&mut val);
564        assert_eq!(val["active"], json!(true));
565        assert_eq!(val["deleted"], json!(false));
566    }
567
568    #[test]
569    fn null_is_not_modified() {
570        let engine = default_engine();
571        let mut val = json!({"field": null});
572        engine.redact_value(&mut val);
573        assert!(val["field"].is_null());
574    }
575
576    // ---------------------------------------------------------------
577    // Empty and whitespace strings
578    // ---------------------------------------------------------------
579
580    #[test]
581    fn empty_string_is_left_alone() {
582        let engine = default_engine();
583        let mut val = json!("");
584        engine.redact_value(&mut val);
585        assert_eq!(val, json!(""));
586    }
587
588    #[test]
589    fn whitespace_only_string_is_left_alone() {
590        let engine = default_engine();
591        let mut val = json!("   \t\n  ");
592        engine.redact_value(&mut val);
593        assert_eq!(val, json!("   \t\n  "));
594    }
595
596    // ---------------------------------------------------------------
597    // redact_bytes round-trip
598    // ---------------------------------------------------------------
599
600    #[test]
601    fn redact_bytes_round_trip() {
602        let engine = default_engine();
603        let input = br#"{"email":"user@example.com","safe":"hello"}"#;
604        let output = engine.redact_bytes(input).unwrap();
605        let parsed: serde_json::Value = serde_json::from_slice(&output).unwrap();
606        let email = parsed["email"].as_str().unwrap();
607        assert!(email.contains("[REDACTED]"), "got: {email}");
608        assert_eq!(parsed["safe"], json!("hello"));
609    }
610
611    #[test]
612    fn redact_bytes_invalid_json_returns_error() {
613        let engine = default_engine();
614        let result = engine.redact_bytes(b"not json");
615        assert!(result.is_err());
616        assert!(matches!(result.unwrap_err(), RedactError::JsonParse(_)));
617    }
618
619    #[test]
620    fn redact_bytes_idempotency() {
621        let engine = default_engine();
622        let input = br#"{"token":"Bearer eyJhbGciOiJIUzI1NiJ9.test1234"}"#;
623        let first = engine.redact_bytes(input).unwrap();
624        let second = engine.redact_bytes(&first).unwrap();
625        assert_eq!(first, second);
626    }
627
628    // ---------------------------------------------------------------
629    // Negative cases: things that must NOT be redacted
630    // ---------------------------------------------------------------
631
632    #[test]
633    fn uuid_is_not_redacted() {
634        let engine = default_engine();
635        let mut val = json!("550e8400-e29b-41d4-a716-446655440000");
636        engine.redact_value(&mut val);
637        assert_eq!(val, json!("550e8400-e29b-41d4-a716-446655440000"));
638    }
639
640    #[test]
641    fn iso_date_is_not_redacted() {
642        let engine = default_engine();
643        let mut val = json!("2024-01-15T10:30:00Z");
644        engine.redact_value(&mut val);
645        assert_eq!(val, json!("2024-01-15T10:30:00Z"));
646    }
647
648    #[test]
649    fn plain_text_is_not_redacted() {
650        let engine = default_engine();
651        let mut val = json!("This is a normal log message with no sensitive data");
652        engine.redact_value(&mut val);
653        assert_eq!(val, json!("This is a normal log message with no sensitive data"));
654    }
655
656    // ---------------------------------------------------------------
657    // Construction-time validation
658    // ---------------------------------------------------------------
659
660    #[test]
661    fn replacement_matching_a_pattern_is_rejected() {
662        let config = RedactionConfig {
663            regex_patterns: vec![r"REDACTED".to_owned()],
664            replacement: "REDACTED".to_owned(),
665            include_builtin_patterns: false,
666            ..RedactionConfig::default()
667        };
668        let result = RedactionEngine::new(config);
669        assert!(result.is_err());
670        assert!(matches!(
671            result.unwrap_err(),
672            RedactError::ReplacementMatchesPattern { .. }
673        ));
674    }
675
676    // ---------------------------------------------------------------
677    // Mixed scenario: multiple sensitive values in one payload
678    // ---------------------------------------------------------------
679
680    #[test]
681    fn mixed_payload_redaction() {
682        let engine = default_engine();
683        let mut val = json!({
684            "event": "login",
685            "user_email": "admin@corp.io",
686            "source_ip": "203.0.113.5",
687            "internal_ip": "10.1.2.3",
688            "metadata": {
689                "auth_header": "Bearer eyJhbGciOiJIUzI1NiJ9.test1234",
690                "timestamp": "2024-01-15T10:30:00Z",
691                "count": 42
692            },
693            "tags": ["production", "us-east"]
694        });
695        engine.redact_value(&mut val);
696
697        // Email redacted.
698        let email = val["user_email"].as_str().unwrap();
699        assert!(email.contains("[REDACTED]"), "email not redacted: {email}");
700
701        // Public IP redacted.
702        let src_ip = val["source_ip"].as_str().unwrap();
703        assert!(src_ip.contains("[REDACTED]"), "public IP not redacted: {src_ip}");
704
705        // Private IP preserved.
706        assert_eq!(val["internal_ip"], json!("10.1.2.3"));
707
708        // Bearer token redacted.
709        let auth = val["metadata"]["auth_header"].as_str().unwrap();
710        assert!(auth.contains("[REDACTED]"), "bearer not redacted: {auth}");
711
712        // Timestamp preserved.
713        assert_eq!(val["metadata"]["timestamp"], json!("2024-01-15T10:30:00Z"));
714
715        // Number preserved.
716        assert_eq!(val["metadata"]["count"], json!(42));
717
718        // Array strings preserved.
719        assert_eq!(val["tags"][0], json!("production"));
720    }
721
722    // ---------------------------------------------------------------
723    // Card numbers with adjacent digit groups (bug fix coverage)
724    // ---------------------------------------------------------------
725
726    #[test]
727    fn card_with_trailing_cvv_is_redacted() {
728        let engine = default_engine();
729        let mut val = json!("4111111111111111 123");
730        engine.redact_value(&mut val);
731        assert_eq!(val, json!("[REDACTED] 123"));
732    }
733
734    #[test]
735    fn card_with_trailing_year_is_redacted() {
736        let engine = default_engine();
737        let mut val = json!("4111 1111 1111 1111 2025");
738        engine.redact_value(&mut val);
739        assert_eq!(val, json!("[REDACTED] 2025"));
740    }
741
742    #[test]
743    fn card_with_leading_ref_number() {
744        let engine = default_engine();
745        let mut val = json!("Ref 1234 4111 1111 1111 1111");
746        engine.redact_value(&mut val);
747        assert_eq!(val, json!("Ref 1234 [REDACTED]"));
748    }
749
750    #[test]
751    fn amex_card_in_standard_spacing() {
752        let engine = default_engine();
753        // 378282246310005 passes Luhn (standard Amex test number).
754        let mut val = json!("3782 822463 10005");
755        engine.redact_value(&mut val);
756        assert_eq!(val, json!("[REDACTED]"));
757    }
758
759    #[test]
760    fn card_extraction_prefers_the_longest_valid_window() {
761        let engine = default_engine();
762        // Both 15 and 18 zero digits pass Luhn. All 18 must be removed.
763        let mut value = json!("000 000 000 000 000 000");
764        engine.redact_value(&mut value);
765        assert_eq!(value, json!("[REDACTED]"));
766    }
767
768    #[test]
769    fn long_digit_group_runs_complete_with_bounded_work() {
770        let engine = default_engine();
771        // Neither five nor six groups of 100 pass Luhn, so every start
772        // position is visited. The former cubic scan cannot finish this run.
773        let input = "100 ".repeat(100_000);
774        let mut value = json!(input);
775        let started = std::time::Instant::now();
776        engine.redact_value(&mut value);
777        assert!(started.elapsed() < std::time::Duration::from_secs(10));
778        assert_eq!(value, json!(input));
779    }
780
781    // ---------------------------------------------------------------
782    // Idempotency with adjacent sensitive data (word-boundary bug fix)
783    // ---------------------------------------------------------------
784
785    #[test]
786    fn adjacent_email_and_ssn_idempotency() {
787        let engine = default_engine();
788        let mut val = json!("user@example.com123-45-6789");
789        engine.redact_value(&mut val);
790        // After full redaction, both should be gone.
791        let s = val.as_str().unwrap();
792        assert!(!s.contains("user@"), "email leaked: {s}");
793        assert!(!s.contains("123-45-6789"), "SSN leaked: {s}");
794        // Verify idempotency.
795        let mut second = val.clone();
796        engine.redact_value(&mut second);
797        assert_eq!(val, second, "idempotency failed for adjacent email+SSN");
798    }
799
800    // ---------------------------------------------------------------
801    // Case-insensitive Bearer
802    // ---------------------------------------------------------------
803
804    #[test]
805    fn lowercase_bearer_is_redacted() {
806        let engine = default_engine();
807        let mut val = json!("bearer eyJhbGciOiJIUzI1NiJ9.test1234");
808        engine.redact_value(&mut val);
809        assert_eq!(val, json!("[REDACTED]"));
810    }
811
812    #[test]
813    fn mixed_case_bearer_is_redacted() {
814        let engine = default_engine();
815        let mut val = json!("BEARER eyJhbGciOiJIUzI1NiJ9.test1234");
816        engine.redact_value(&mut val);
817        assert_eq!(val, json!("[REDACTED]"));
818    }
819
820    // ---------------------------------------------------------------
821    // Exact-output assertions for core patterns
822    // ---------------------------------------------------------------
823
824    #[test]
825    fn bearer_exact_output() {
826        let engine = default_engine();
827        let mut val = json!("Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.abc123");
828        engine.redact_value(&mut val);
829        assert_eq!(val, json!("Authorization: [REDACTED]"));
830    }
831
832    #[test]
833    fn email_exact_output() {
834        let engine = default_engine();
835        let mut val = json!("Contact alice@example.org for help");
836        engine.redact_value(&mut val);
837        assert_eq!(val, json!("Contact [REDACTED] for help"));
838    }
839
840    #[test]
841    fn ssn_exact_output() {
842        let engine = default_engine();
843        let mut val = json!("SSN: 123-45-6789");
844        engine.redact_value(&mut val);
845        assert_eq!(val, json!("SSN: [REDACTED]"));
846    }
847
848    #[test]
849    fn public_ipv4_exact_output() {
850        let engine = default_engine();
851        let mut val = json!("Connected from 8.8.8.8 on port 443");
852        engine.redact_value(&mut val);
853        assert_eq!(val, json!("Connected from [REDACTED] on port 443"));
854    }
855}