1use serde::{Deserialize, Serialize};
86use std::sync::{Arc, LazyLock};
87
88use super::{BaselineDetector, NoopDetector, PiiDetector, SECRET_PREFIXES, mask_spans};
89
90pub const REDACTED_MARKER: &str = "[REDACTED]";
94
95static BASELINE_DETECTOR: LazyLock<Arc<dyn PiiDetector>> = LazyLock::new(|| {
98 BaselineDetector::new().map_or_else(
99 |_| Arc::new(NoopDetector) as Arc<dyn PiiDetector>,
100 |d| Arc::new(d) as Arc<dyn PiiDetector>,
101 )
102});
103
104static NOOP_DETECTOR: LazyLock<Arc<dyn PiiDetector>> =
106 LazyLock::new(|| Arc::new(NoopDetector) as Arc<dyn PiiDetector>);
107
108fn default_detector() -> Arc<dyn PiiDetector> {
112 BASELINE_DETECTOR.clone()
113}
114
115#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
121#[serde(rename_all = "snake_case")]
122pub enum RedactionLevel {
123 None,
125 Baseline,
127 Full,
129}
130
131#[derive(Clone, Debug, Serialize, Deserialize)]
154pub struct RedactionPolicy {
155 pub input_level: RedactionLevel,
157 pub output_level: RedactionLevel,
159 pub error_level: RedactionLevel,
161 pub sensitive_key_patterns: Vec<String>,
164 pub sensitive_value_prefixes: Vec<String>,
167 #[serde(skip, default = "default_detector")]
171 pub detector: Arc<dyn PiiDetector>,
172}
173
174impl RedactionPolicy {
175 #[must_use]
192 pub fn baseline() -> Self {
193 Self {
194 input_level: RedactionLevel::Baseline,
195 output_level: RedactionLevel::Baseline,
196 error_level: RedactionLevel::Baseline,
197 sensitive_key_patterns: vec![
198 "password".into(),
199 "passwd".into(),
200 "secret".into(),
201 "token".into(),
202 "api_key".into(),
203 "apikey".into(),
204 "authorization".into(),
205 "credential".into(),
206 "private_key".into(),
207 "private".into(),
208 "access_key".into(),
209 "session".into(),
210 "cookie".into(),
211 "bearer".into(),
212 "ssn".into(),
213 "credit_card".into(),
214 "cpf".into(),
215 "cnh".into(),
216 "cnpj".into(),
217 "crm".into(),
218 "passport".into(),
219 "driver_license".into(),
220 "social_security".into(),
221 "social_security_number".into(),
222 ],
223 sensitive_value_prefixes: SECRET_PREFIXES.iter().map(|p| (*p).to_owned()).collect(),
227 detector: default_detector(),
228 }
229 }
230
231 #[must_use]
244 pub fn with_keys(keys: impl IntoIterator<Item = String>) -> Self {
245 let mut policy = Self::baseline();
246 policy.extend(keys);
247 policy
248 }
249
250 pub fn extend(&mut self, keys: impl IntoIterator<Item = String>) {
255 for key in keys {
256 let lower = key.to_lowercase();
257 if !self.sensitive_key_patterns.contains(&lower) {
258 self.sensitive_key_patterns.push(lower);
259 }
260 }
261 }
262
263 #[must_use]
268 pub fn none() -> Self {
269 Self {
270 input_level: RedactionLevel::None,
271 output_level: RedactionLevel::None,
272 error_level: RedactionLevel::None,
273 sensitive_key_patterns: Vec::new(),
274 sensitive_value_prefixes: Vec::new(),
275 detector: NOOP_DETECTOR.clone(),
276 }
277 }
278
279 #[must_use]
284 pub fn full() -> Self {
285 Self {
286 input_level: RedactionLevel::Full,
287 output_level: RedactionLevel::Full,
288 error_level: RedactionLevel::Full,
289 sensitive_key_patterns: Vec::new(),
290 sensitive_value_prefixes: Vec::new(),
291 detector: NOOP_DETECTOR.clone(),
292 }
293 }
294
295 #[must_use]
302 pub fn redact(&self, value: &serde_json::Value) -> serde_json::Value {
303 redact_value(value, self)
304 }
305
306 pub fn redact_in_place(&self, value: &mut serde_json::Value) {
312 match self.input_level {
313 RedactionLevel::None => {}
314 RedactionLevel::Full => {
315 *value = serde_json::json!(REDACTED_MARKER);
316 }
317 RedactionLevel::Baseline => self.redact_baseline_in_place(value),
318 }
319 }
320
321 fn redact_baseline_in_place(&self, value: &mut serde_json::Value) {
322 self.redact_baseline_in_place_with(value, &*self.detector);
323 }
324
325 fn redact_baseline_in_place_with(
332 &self,
333 value: &mut serde_json::Value,
334 detector: &dyn PiiDetector,
335 ) {
336 match value {
337 serde_json::Value::Object(map) => {
338 for (key, val) in map.iter_mut() {
339 if self.is_sensitive_key(key) {
340 *val = serde_json::json!(REDACTED_MARKER);
341 } else {
342 self.redact_baseline_in_place_with(val, detector);
343 }
344 }
345 }
346 serde_json::Value::Array(arr) => {
347 for v in arr.iter_mut() {
348 self.redact_baseline_in_place_with(v, detector);
349 }
350 }
351 serde_json::Value::String(s) => {
352 if self.is_sensitive_value(s) {
353 *value = serde_json::json!(REDACTED_MARKER);
354 return;
355 }
356 let spans = detector.detect(s);
357 if !spans.is_empty() {
358 *s = mask_spans(s, &spans);
359 }
360 }
361 _ => {}
362 }
363 }
364
365 #[must_use]
373 fn is_sensitive_key(&self, key: &str) -> bool {
374 self.sensitive_key_patterns
375 .iter()
376 .any(|pattern| ascii_contains_ignore_case(key, pattern))
377 }
378
379 #[must_use]
381 fn is_sensitive_value(&self, value: &str) -> bool {
382 self.sensitive_value_prefixes
383 .iter()
384 .any(|prefix| value.starts_with(prefix.as_str()))
385 }
386}
387
388impl Default for RedactionPolicy {
389 fn default() -> Self {
398 Self::baseline()
399 }
400}
401
402#[must_use]
416pub fn redact_value(value: &serde_json::Value, policy: &RedactionPolicy) -> serde_json::Value {
417 apply_redaction(value, policy.input_level, policy)
418}
419
420#[must_use]
430pub fn redact_string(value: &str, policy: &RedactionPolicy) -> String {
431 match policy.output_level {
432 RedactionLevel::None => value.to_owned(),
433 RedactionLevel::Baseline => baseline_redact_str(value, &*policy.detector, policy),
434 RedactionLevel::Full => REDACTED_MARKER.to_owned(),
435 }
436}
437
438#[must_use]
442pub fn redact_error(value: &str, policy: &RedactionPolicy) -> String {
443 match policy.error_level {
444 RedactionLevel::None => value.to_owned(),
445 RedactionLevel::Baseline => baseline_redact_str(value, &*policy.detector, policy),
446 RedactionLevel::Full => REDACTED_MARKER.to_owned(),
447 }
448}
449
450#[must_use]
477pub fn redact_for_observability(
478 value: &serde_json::Value,
479 policy: &RedactionPolicy,
480 detector: &dyn PiiDetector,
481) -> serde_json::Value {
482 match policy.input_level {
483 RedactionLevel::None => value.clone(),
484 RedactionLevel::Full => serde_json::json!(REDACTED_MARKER),
485 RedactionLevel::Baseline => redact_baseline_with_detector(value, policy, detector),
486 }
487}
488
489fn baseline_redact_str(
496 value: &str,
497 detector: &dyn PiiDetector,
498 policy: &RedactionPolicy,
499) -> String {
500 if policy.is_sensitive_value(value) {
501 return REDACTED_MARKER.to_owned();
502 }
503 let spans = detector.detect(value);
504 if spans.is_empty() {
505 value.to_owned()
506 } else {
507 mask_spans(value, &spans)
508 }
509}
510
511fn apply_redaction(
513 value: &serde_json::Value,
514 level: RedactionLevel,
515 policy: &RedactionPolicy,
516) -> serde_json::Value {
517 match level {
518 RedactionLevel::None => value.clone(),
519 RedactionLevel::Full => serde_json::json!(REDACTED_MARKER),
520 RedactionLevel::Baseline => redact_baseline(value, policy),
521 }
522}
523
524fn redact_baseline(value: &serde_json::Value, policy: &RedactionPolicy) -> serde_json::Value {
526 redact_baseline_with_detector(value, policy, &*policy.detector)
527}
528
529fn redact_baseline_with_detector(
534 value: &serde_json::Value,
535 policy: &RedactionPolicy,
536 detector: &dyn PiiDetector,
537) -> serde_json::Value {
538 let mut cloned = value.clone();
539 policy.redact_baseline_in_place_with(&mut cloned, detector);
540 cloned
541}
542
543fn ascii_contains_ignore_case(haystack: &str, needle: &str) -> bool {
551 let needle = needle.as_bytes();
552 if needle.is_empty() {
553 return true;
554 }
555 let haystack = haystack.as_bytes();
556 if needle.len() > haystack.len() {
557 return false;
558 }
559 haystack.windows(needle.len()).any(|window| {
560 window
561 .iter()
562 .zip(needle)
563 .all(|(h, n)| h.eq_ignore_ascii_case(n))
564 })
565}
566
567#[cfg(test)]
572mod tests {
573 use super::*;
574 use crate::privacy::BaselineDetector;
575
576 #[test]
579 fn redaction_level_round_trips_through_json() -> serde_json::Result<()> {
580 for level in [
581 RedactionLevel::None,
582 RedactionLevel::Baseline,
583 RedactionLevel::Full,
584 ] {
585 let json = serde_json::to_string(&level)?;
586 let back: RedactionLevel = serde_json::from_str(&json)?;
587 assert_eq!(back, level);
588 }
589 Ok(())
590 }
591
592 #[test]
595 fn baseline_policy_has_expected_defaults() {
596 let policy = RedactionPolicy::baseline();
597 assert_eq!(policy.input_level, RedactionLevel::Baseline);
598 assert_eq!(policy.output_level, RedactionLevel::Baseline);
599 assert_eq!(policy.error_level, RedactionLevel::Baseline);
602 assert!(!policy.sensitive_key_patterns.is_empty());
603 assert!(!policy.sensitive_value_prefixes.is_empty());
604 }
605
606 #[test]
607 fn default_impl_returns_baseline_not_empty() {
608 let default_policy = RedactionPolicy::default();
611 let baseline = RedactionPolicy::baseline();
612 assert_eq!(default_policy.input_level, baseline.input_level);
613 assert_eq!(
614 default_policy.sensitive_key_patterns,
615 baseline.sensitive_key_patterns
616 );
617 assert_eq!(
618 default_policy.sensitive_value_prefixes,
619 baseline.sensitive_value_prefixes
620 );
621 }
622
623 #[test]
624 fn none_policy_has_no_redaction() {
625 let policy = RedactionPolicy::none();
626 assert_eq!(policy.input_level, RedactionLevel::None);
627 assert_eq!(policy.output_level, RedactionLevel::None);
628 assert_eq!(policy.error_level, RedactionLevel::None);
629 }
630
631 #[test]
632 fn full_policy_redacts_everything() {
633 let policy = RedactionPolicy::full();
634 assert_eq!(policy.input_level, RedactionLevel::Full);
635 assert_eq!(policy.output_level, RedactionLevel::Full);
636 assert_eq!(policy.error_level, RedactionLevel::Full);
637 }
638
639 #[test]
640 fn policy_round_trips_through_json() -> serde_json::Result<()> {
641 let policy = RedactionPolicy::baseline();
642 let json = serde_json::to_string(&policy)?;
643 let back: RedactionPolicy = serde_json::from_str(&json)?;
644 assert_eq!(back.input_level, policy.input_level);
645 assert_eq!(
646 back.sensitive_key_patterns.len(),
647 policy.sensitive_key_patterns.len(),
648 );
649 Ok(())
650 }
651
652 #[test]
655 fn with_keys_includes_baseline_and_custom_keys() {
656 let policy = RedactionPolicy::with_keys(["chave_pix".to_owned()]);
657 assert!(
659 policy
660 .sensitive_key_patterns
661 .iter()
662 .any(|k| k == "password")
663 );
664 assert!(policy.sensitive_key_patterns.iter().any(|k| k == "api_key"));
665 assert!(
667 policy
668 .sensitive_key_patterns
669 .iter()
670 .any(|k| k == "chave_pix")
671 );
672 let input = serde_json::json!({
674 "chave_pix": "abc-123",
675 "password": "secret",
676 "ok": "visible",
677 });
678 let redacted = redact_value(&input, &policy);
679 assert_eq!(redacted["chave_pix"], REDACTED_MARKER);
680 assert_eq!(redacted["password"], REDACTED_MARKER);
681 assert_eq!(redacted["ok"], "visible");
682 }
683
684 #[test]
685 fn with_keys_normalises_case() {
686 let policy = RedactionPolicy::with_keys(["Chave_Pix".to_owned()]);
689 let input = serde_json::json!({ "CHAVE_PIX": "abc-123" });
690 let redacted = redact_value(&input, &policy);
691 assert_eq!(redacted["CHAVE_PIX"], REDACTED_MARKER);
692 }
693
694 #[test]
695 fn extend_appends_keys_to_existing_policy() {
696 let mut policy = RedactionPolicy::baseline();
697 let baseline_len = policy.sensitive_key_patterns.len();
698 policy.extend(["chave_pix".to_owned(), "internal_id".to_owned()]);
699 assert_eq!(policy.sensitive_key_patterns.len(), baseline_len + 2);
700 let input = serde_json::json!({ "internal_id": "xyz" });
701 let redacted = redact_value(&input, &policy);
702 assert_eq!(redacted["internal_id"], REDACTED_MARKER);
703 }
704
705 #[test]
706 fn extend_drops_duplicates() {
707 let mut policy = RedactionPolicy::baseline();
708 let baseline_len = policy.sensitive_key_patterns.len();
709 policy.extend(["PASSWORD".to_owned()]);
711 assert_eq!(policy.sensitive_key_patterns.len(), baseline_len);
712 }
713
714 #[test]
717 fn redact_method_matches_redact_value() {
718 let policy = RedactionPolicy::baseline();
719 let input = serde_json::json!({
720 "api_key": "sk-abc",
721 "name": "test",
722 });
723 assert_eq!(policy.redact(&input), redact_value(&input, &policy));
724 }
725
726 #[test]
727 fn redact_in_place_mutates_in_place() {
728 let policy = RedactionPolicy::baseline();
729 let mut value = serde_json::json!({
730 "api_key": "sk-abc",
731 "nested": {
732 "password": "shh",
733 "name": "ok",
734 },
735 "note": "CPF 111.444.777-35 attached",
736 });
737 policy.redact_in_place(&mut value);
738 assert_eq!(value["api_key"], REDACTED_MARKER);
739 assert_eq!(value["nested"]["password"], REDACTED_MARKER);
740 assert_eq!(value["nested"]["name"], "ok");
741 let note = value["note"].as_str().expect("note remains a string");
742 assert!(note.contains("[REDACTED:cpf]"), "got: {note}");
743 }
744
745 #[test]
746 fn redact_in_place_handles_full_level() {
747 let policy = RedactionPolicy::full();
748 let mut value = serde_json::json!({"a": 1, "b": "two"});
749 policy.redact_in_place(&mut value);
750 assert_eq!(value, serde_json::json!(REDACTED_MARKER));
751 }
752
753 #[test]
754 fn redact_in_place_handles_none_level() {
755 let policy = RedactionPolicy::none();
756 let original = serde_json::json!({"api_key": "sk-abc", "ok": "vis"});
757 let mut value = original.clone();
758 policy.redact_in_place(&mut value);
759 assert_eq!(value, original);
760 }
761
762 #[test]
765 fn none_level_preserves_all_values() {
766 let policy = RedactionPolicy::none();
767 let input = serde_json::json!({
768 "password": "secret123",
769 "api_key": "sk-abc",
770 "normal": "hello",
771 });
772 let result = redact_value(&input, &policy);
773 assert_eq!(result, input);
774 }
775
776 #[test]
779 fn full_level_redacts_entire_value() {
780 let policy = RedactionPolicy::full();
781 let input = serde_json::json!({
782 "command": "echo hello",
783 "data": [1, 2, 3],
784 });
785 let result = redact_value(&input, &policy);
786 assert_eq!(result, serde_json::json!(REDACTED_MARKER));
787 }
788
789 #[test]
792 fn baseline_redacts_sensitive_keys() {
793 let policy = RedactionPolicy::baseline();
794 let input = serde_json::json!({
795 "command": "echo hello",
796 "password": "secret123",
797 "api_key": "sk-abc",
798 "normal_field": "visible",
799 });
800 let result = redact_value(&input, &policy);
801
802 assert_eq!(result["command"], "echo hello");
803 assert_eq!(result["password"], REDACTED_MARKER);
804 assert_eq!(result["api_key"], REDACTED_MARKER);
805 assert_eq!(result["normal_field"], "visible");
806 }
807
808 #[test]
809 fn baseline_redacts_case_insensitively() {
810 let policy = RedactionPolicy::baseline();
811 let input = serde_json::json!({
812 "Password": "secret",
813 "API_KEY": "key",
814 "Authorization": "Bearer xyz",
815 });
816 let result = redact_value(&input, &policy);
817
818 assert_eq!(result["Password"], REDACTED_MARKER);
819 assert_eq!(result["API_KEY"], REDACTED_MARKER);
820 assert_eq!(result["Authorization"], REDACTED_MARKER);
821 }
822
823 #[test]
824 fn baseline_redacts_sensitive_value_prefixes() {
825 let policy = RedactionPolicy::baseline();
826 let input = serde_json::json!({
827 "header": "Bearer eyJ...",
828 "key": "sk-abc123",
829 "normal": "just a string",
830 });
831 let result = redact_value(&input, &policy);
832
833 assert_eq!(result["header"], REDACTED_MARKER);
834 assert_eq!(result["key"], REDACTED_MARKER);
835 assert_eq!(result["normal"], "just a string");
836 }
837
838 #[test]
839 fn baseline_recurses_into_nested_objects() {
840 let policy = RedactionPolicy::baseline();
841 let input = serde_json::json!({
842 "config": {
843 "api_key": "sk-nested",
844 "endpoint": "https://example.com",
845 },
846 "name": "test",
847 });
848 let result = redact_value(&input, &policy);
849
850 assert_eq!(result["config"]["api_key"], REDACTED_MARKER);
851 assert_eq!(result["config"]["endpoint"], "https://example.com");
852 assert_eq!(result["name"], "test");
853 }
854
855 #[test]
856 fn baseline_recurses_into_arrays() {
857 let policy = RedactionPolicy::baseline();
858 let input = serde_json::json!([
859 {"password": "secret", "name": "test"},
860 {"token": "abc", "data": 42},
861 ]);
862 let result = redact_value(&input, &policy);
863
864 assert_eq!(result[0]["password"], REDACTED_MARKER);
865 assert_eq!(result[0]["name"], "test");
866 assert_eq!(result[1]["token"], REDACTED_MARKER);
867 assert_eq!(result[1]["data"], 42);
868 }
869
870 #[test]
871 fn baseline_preserves_non_string_values() {
872 let policy = RedactionPolicy::baseline();
873 let input = serde_json::json!({
874 "count": 42,
875 "active": true,
876 "ratio": 2.72,
877 "empty": null,
878 });
879 let result = redact_value(&input, &policy);
880 assert_eq!(result, input);
881 }
882
883 #[test]
884 fn redact_value_is_noop_for_explicit_null() {
885 let policy = RedactionPolicy::baseline();
886 let input = serde_json::Value::Null;
887 let result = redact_value(&input, &policy);
888 assert_eq!(result, serde_json::Value::Null);
889 }
890
891 #[test]
894 fn redact_string_none_preserves() {
895 let policy = RedactionPolicy::none();
896 assert_eq!(redact_string("Bearer token123", &policy), "Bearer token123");
897 }
898
899 #[test]
900 fn redact_string_baseline_masks_sensitive() {
901 let policy = RedactionPolicy::baseline();
902 assert_eq!(redact_string("Bearer token123", &policy), REDACTED_MARKER);
903 assert_eq!(redact_string("sk-abc123", &policy), REDACTED_MARKER);
904 assert_eq!(
905 redact_string("just normal output", &policy),
906 "just normal output"
907 );
908 }
909
910 #[test]
911 fn redact_string_full_masks_everything() {
912 let policy = RedactionPolicy::full();
913 assert_eq!(
914 redact_string("totally safe output", &policy),
915 REDACTED_MARKER
916 );
917 }
918
919 #[test]
922 fn redact_error_baseline_preserves_non_pii() {
923 let policy = RedactionPolicy::baseline();
926 assert_eq!(
927 redact_error("connection timeout after 30s", &policy),
928 "connection timeout after 30s"
929 );
930 }
931
932 #[test]
933 fn redact_error_baseline_masks_pii_by_default() {
934 let policy = RedactionPolicy::baseline();
937 let masked = redact_error(
938 "Failed to process order for user CPF 111.444.777-35",
939 &policy,
940 );
941 assert!(masked.contains("[REDACTED:cpf]"), "got: {masked}");
942 assert!(!masked.contains("111.444.777-35"));
943 }
944
945 #[test]
946 fn redact_error_explicit_none_passes_through() {
947 let policy = RedactionPolicy {
949 error_level: RedactionLevel::None,
950 ..RedactionPolicy::baseline()
951 };
952 let raw = "Failed to process order for user CPF 111.444.777-35";
953 assert_eq!(redact_error(raw, &policy), raw);
954 }
955
956 #[test]
957 fn redact_error_full_masks() {
958 let policy = RedactionPolicy::full();
959 assert_eq!(
960 redact_error("internal error details", &policy),
961 REDACTED_MARKER
962 );
963 }
964
965 #[test]
968 fn sensitive_key_detection() {
969 let policy = RedactionPolicy::baseline();
970
971 assert!(policy.is_sensitive_key("password"));
973 assert!(policy.is_sensitive_key("user_password"));
974 assert!(policy.is_sensitive_key("api_key"));
975 assert!(policy.is_sensitive_key("MY_API_KEY"));
976 assert!(policy.is_sensitive_key("Authorization"));
977 assert!(policy.is_sensitive_key("session_id"));
978 assert!(policy.is_sensitive_key("private_key"));
979 assert!(policy.is_sensitive_key("access_key_id"));
980
981 assert!(!policy.is_sensitive_key("username"));
983 assert!(!policy.is_sensitive_key("command"));
984 assert!(!policy.is_sensitive_key("amount"));
985 assert!(!policy.is_sensitive_key("path"));
986 assert!(!policy.is_sensitive_key("args"));
987 assert!(!policy.is_sensitive_key("target"));
988 assert!(!policy.is_sensitive_key("author"));
989 assert!(!policy.is_sensitive_key("org_id"));
990 assert!(!policy.is_sensitive_key("merge"));
991 }
992
993 #[test]
996 fn sensitive_value_detection() {
997 let policy = RedactionPolicy::baseline();
998
999 assert!(policy.is_sensitive_value("Bearer eyJhbGciOiJIUzI1NiJ9"));
1001 assert!(policy.is_sensitive_value("sk-abc123def456"));
1002 assert!(policy.is_sensitive_value("ghp_xxxxxxxxxxxx"));
1003 assert!(policy.is_sensitive_value("xoxb-token-value"));
1004 assert!(policy.is_sensitive_value("AKIAIOSFODNN7EXAMPLE"));
1005
1006 assert!(!policy.is_sensitive_value("hello world"));
1008 assert!(!policy.is_sensitive_value("echo test"));
1009 assert!(!policy.is_sensitive_value("123.45"));
1010 }
1011
1012 #[test]
1015 fn redact_empty_object() {
1016 let policy = RedactionPolicy::baseline();
1017 let input = serde_json::json!({});
1018 let result = redact_value(&input, &policy);
1019 assert_eq!(result, serde_json::json!({}));
1020 }
1021
1022 #[test]
1023 fn redact_empty_array() {
1024 let policy = RedactionPolicy::baseline();
1025 let input = serde_json::json!([]);
1026 let result = redact_value(&input, &policy);
1027 assert_eq!(result, serde_json::json!([]));
1028 }
1029
1030 #[test]
1031 fn redact_scalar_string() {
1032 let policy = RedactionPolicy::baseline();
1033 let input = serde_json::json!("sk-secret");
1034 let result = redact_value(&input, &policy);
1035 assert_eq!(result, serde_json::json!(REDACTED_MARKER));
1036 }
1037
1038 #[test]
1039 fn redact_scalar_number() {
1040 let policy = RedactionPolicy::baseline();
1041 let input = serde_json::json!(42);
1042 let result = redact_value(&input, &policy);
1043 assert_eq!(result, serde_json::json!(42));
1044 }
1045
1046 #[test]
1047 fn deeply_nested_redaction() {
1048 let policy = RedactionPolicy::baseline();
1049 let input = serde_json::json!({
1050 "level1": {
1051 "level2": {
1052 "level3": {
1053 "api_key": "sk-deep",
1054 "value": "safe",
1055 }
1056 }
1057 }
1058 });
1059 let result = redact_value(&input, &policy);
1060 assert_eq!(
1061 result["level1"]["level2"]["level3"]["api_key"],
1062 REDACTED_MARKER,
1063 );
1064 assert_eq!(result["level1"]["level2"]["level3"]["value"], "safe");
1065 }
1066
1067 #[test]
1068 fn non_ascii_keys_do_not_panic() {
1069 let policy = RedactionPolicy::baseline();
1073 let input = serde_json::json!({
1074 "contraseña": "secret",
1075 "密码": "shh",
1076 "ok": "visible",
1077 });
1078 let result = redact_value(&input, &policy);
1079 assert_eq!(result["contraseña"], "secret");
1081 assert_eq!(result["密码"], "shh");
1082 assert_eq!(result["ok"], "visible");
1083 }
1084
1085 #[test]
1088 fn baseline_masks_email_in_non_sensitive_string_value() {
1089 let policy = RedactionPolicy::baseline();
1090 let input = serde_json::json!({
1091 "note": "forward to ana.silva+tag@example.com please"
1092 });
1093 let result = redact_value(&input, &policy);
1094 let note = result["note"].as_str().expect("note is string");
1095 assert!(note.contains("[REDACTED:email]"), "got: {note}");
1096 assert!(!note.contains("ana.silva+tag@example.com"));
1097 }
1098
1099 #[test]
1100 fn baseline_masks_cpf_in_freeform_text() {
1101 let policy = RedactionPolicy::baseline();
1102 let input = serde_json::json!({
1103 "description": "confirmou pelo CPF 111.444.777-35 ontem"
1104 });
1105 let result = redact_value(&input, &policy);
1106 let desc = result["description"].as_str().expect("desc is string");
1107 assert!(desc.contains("[REDACTED:cpf]"), "got: {desc}");
1108 assert!(!desc.contains("111.444.777-35"));
1109 }
1110
1111 #[test]
1112 fn baseline_masks_cnpj_in_freeform_text() {
1113 let policy = RedactionPolicy::baseline();
1114 let input = serde_json::json!({
1115 "description": "pagar CNPJ 11.222.333/0001-81 até sexta"
1116 });
1117 let result = redact_value(&input, &policy);
1118 let desc = result["description"].as_str().expect("desc is string");
1119 assert!(desc.contains("[REDACTED:cnpj]"), "got: {desc}");
1120 }
1121
1122 #[test]
1123 fn baseline_masks_luhn_valid_pan_in_tool_output() {
1124 let policy = RedactionPolicy::baseline();
1125 let output = "charged card 4111 1111 1111 1111 successfully for 150 BRL";
1126 let result = redact_string(output, &policy);
1127 assert!(result.contains("[REDACTED:credit_card]"), "got: {result}");
1128 assert!(!result.contains("4111 1111 1111 1111"));
1129 }
1130
1131 #[test]
1132 fn baseline_does_not_mask_luhn_invalid_digits() {
1133 let policy = RedactionPolicy::baseline();
1135 let output = "order 1234 5678 9012 3456 processed";
1136 let result = redact_string(output, &policy);
1137 assert!(
1138 !result.contains("[REDACTED:"),
1139 "false positive on non-PAN digits: {result}"
1140 );
1141 }
1142
1143 #[test]
1144 fn baseline_masks_pan_followed_by_amount() {
1145 let policy = RedactionPolicy::baseline();
1149 let output = "charged card 4111 1111 1111 1111 150 successfully";
1150 let result = redact_string(output, &policy);
1151 assert!(result.contains("[REDACTED:credit_card]"), "got: {result}");
1152 assert!(
1153 !result.contains("4111 1111 1111 1111"),
1154 "PAN leaked: {result}"
1155 );
1156 }
1157
1158 #[test]
1159 fn baseline_value_prefixes_track_shared_const() {
1160 let policy = RedactionPolicy::baseline();
1164 let expected: Vec<String> = crate::privacy::SECRET_PREFIXES
1165 .iter()
1166 .map(|p| (*p).to_owned())
1167 .collect();
1168 assert_eq!(policy.sensitive_value_prefixes, expected);
1169 for p in ["ghs_", "ghu_", "AIza"] {
1170 assert!(
1171 policy.sensitive_value_prefixes.iter().any(|x| x == p),
1172 "missing prefix {p}"
1173 );
1174 }
1175 }
1176
1177 #[test]
1178 fn is_sensitive_key_ascii_case_insensitive_without_alloc() {
1179 let policy = RedactionPolicy::baseline();
1180 assert!(policy.is_sensitive_key("API_KEY"));
1182 assert!(policy.is_sensitive_key("Api_Key"));
1183 assert!(policy.is_sensitive_key("xXpasswordXx"));
1184 assert!(!policy.is_sensitive_key("naïve_field"));
1186 assert!(!policy.is_sensitive_key("contraseña"));
1187 }
1188
1189 #[test]
1190 fn baseline_masks_embedded_secret_token() {
1191 let policy = RedactionPolicy::baseline();
1195 let output = "deploy failed: key=sk-abcdefghijklmnopqrstuv rejected";
1196 let result = redact_string(output, &policy);
1197 assert!(result.contains("[REDACTED:secret]"), "got: {result}");
1198 }
1199
1200 #[test]
1201 fn baseline_preserves_wholesale_prefix_behaviour() {
1202 let policy = RedactionPolicy::baseline();
1206 let result = redact_string("sk-abc123def456ghi789jkl", &policy);
1207 assert_eq!(result, REDACTED_MARKER);
1208 }
1209
1210 #[test]
1211 fn baseline_masks_pii_in_nested_string_leaves() {
1212 let policy = RedactionPolicy::baseline();
1213 let input = serde_json::json!({
1214 "audit_log": [
1215 {
1216 "actor": "system",
1217 "details": "user CPF 111.444.777-35 contacted from 192.168.1.100"
1218 }
1219 ]
1220 });
1221 let result = redact_value(&input, &policy);
1222 let details = result["audit_log"][0]["details"]
1223 .as_str()
1224 .expect("details string");
1225 assert!(details.contains("[REDACTED:cpf]"), "got: {details}");
1226 assert!(details.contains("[REDACTED:ip_address]"), "got: {details}");
1227 }
1228
1229 #[test]
1230 fn sensitive_key_match_wins_over_entity_detection() {
1231 let policy = RedactionPolicy::baseline();
1235 let input = serde_json::json!({
1236 "api_key": "sk-leaky",
1237 "access_token": "Bearer eyJ..."
1238 });
1239 let result = redact_value(&input, &policy);
1240 assert_eq!(result["api_key"], REDACTED_MARKER);
1241 assert_eq!(result["access_token"], REDACTED_MARKER);
1242 }
1243
1244 #[test]
1245 fn none_policy_performs_no_entity_detection() {
1246 let policy = RedactionPolicy::none();
1247 let input = serde_json::json!({
1248 "note": "CPF 111.444.777-35 email a@b.co"
1249 });
1250 let result = redact_value(&input, &policy);
1251 assert_eq!(result, input, "none policy must not mutate input");
1252 }
1253
1254 #[test]
1255 fn deserialized_policy_retains_baseline_entity_detection() -> serde_json::Result<()> {
1256 let policy = RedactionPolicy::baseline();
1260 let json = serde_json::to_string(&policy)?;
1261 let back: RedactionPolicy = serde_json::from_str(&json)?;
1262 let result = redact_string("pix para CPF 111.444.777-35 agora", &back);
1263 assert!(
1264 result.contains("[REDACTED:cpf]"),
1265 "deserialized policy stopped detecting CPF: {result}"
1266 );
1267 Ok(())
1268 }
1269
1270 #[test]
1271 fn error_level_baseline_masks_entities_in_stack_trace() {
1272 let policy = RedactionPolicy {
1275 error_level: RedactionLevel::Baseline,
1276 ..RedactionPolicy::baseline()
1277 };
1278 let trace = "NotFound: user with CPF 111.444.777-35 missing in table users";
1279 let result = redact_error(trace, &policy);
1280 assert!(result.contains("[REDACTED:cpf]"), "got: {result}");
1281 }
1282
1283 #[test]
1286 fn redact_for_observability_runs_structural_then_pii() -> Result<(), regex::Error> {
1287 let policy = RedactionPolicy::baseline();
1288 let detector = BaselineDetector::new()?;
1289 let input = serde_json::json!({
1290 "api_key": "sk-leaky",
1291 "details": "user CPF 111.444.777-35 in table users",
1292 "ok": "visible",
1293 });
1294 let result = redact_for_observability(&input, &policy, &detector);
1295 assert_eq!(result["api_key"], REDACTED_MARKER);
1296 let details = result["details"].as_str().expect("string");
1297 assert!(details.contains("[REDACTED:cpf]"), "got: {details}");
1298 assert_eq!(result["ok"], "visible");
1299 Ok(())
1300 }
1301
1302 #[test]
1303 fn redact_for_observability_idempotent_on_already_masked() -> Result<(), regex::Error> {
1304 let policy = RedactionPolicy::baseline();
1305 let detector = BaselineDetector::new()?;
1306 let input = serde_json::json!({
1307 "details": "user CPF 111.444.777-35 contacted",
1308 });
1309 let once = redact_for_observability(&input, &policy, &detector);
1310 let twice = redact_for_observability(&once, &policy, &detector);
1311 assert_eq!(once, twice);
1314 Ok(())
1315 }
1316
1317 #[test]
1318 fn redact_for_observability_honours_full_level() -> Result<(), regex::Error> {
1319 let policy = RedactionPolicy::full();
1320 let detector = BaselineDetector::new()?;
1321 let input = serde_json::json!({"a": "b"});
1322 let result = redact_for_observability(&input, &policy, &detector);
1323 assert_eq!(result, serde_json::json!(REDACTED_MARKER));
1324 Ok(())
1325 }
1326
1327 #[test]
1328 fn redact_for_observability_honours_none_level() -> Result<(), regex::Error> {
1329 let policy = RedactionPolicy::none();
1330 let detector = BaselineDetector::new()?;
1331 let input = serde_json::json!({
1332 "api_key": "sk-leaky",
1333 "note": "CPF 111.444.777-35",
1334 });
1335 let result = redact_for_observability(&input, &policy, &detector);
1336 assert_eq!(result, input);
1337 Ok(())
1338 }
1339}