memory_core/store/
privacy.rs1use std::sync::{Mutex, OnceLock};
2
3use regex_lite::Regex;
4
5use crate::config::PrivacyConfig;
6
7static PRIVATE_TAG_RE: OnceLock<Regex> = OnceLock::new();
8static SECRET_PATTERNS_CACHE: OnceLock<Mutex<CachedPatterns>> = OnceLock::new();
9
10struct CachedPatterns {
11 source: Vec<String>,
12 compiled: Vec<Regex>,
13}
14
15pub fn strip_private_tags(content: &str) -> String {
16 let re = PRIVATE_TAG_RE.get_or_init(|| {
17 Regex::new(r"(?si)<private>.*?</private>").unwrap()
18 });
19 re.replace_all(content, "[REDACTED]").to_string()
20}
21
22pub fn strip_secrets(content: &str, config: &PrivacyConfig) -> String {
23 let compiled = get_or_compile_patterns(config);
24 let mut result = content.to_string();
25 for re in &compiled {
26 result = re.replace_all(&result, "[SECRET_REDACTED]").to_string();
27 }
28 result
29}
30
31fn get_or_compile_patterns(config: &PrivacyConfig) -> Vec<Regex> {
32 let current = effective_patterns(config);
33 let cache = SECRET_PATTERNS_CACHE.get_or_init(|| {
34 Mutex::new(CachedPatterns {
35 source: Vec::new(),
36 compiled: Vec::new(),
37 })
38 });
39 let mut guard = cache.lock().unwrap_or_else(|e| e.into_inner());
40 if guard.source == current {
41 return guard.compiled.clone();
42 }
43 let compiled: Vec<Regex> = current
44 .iter()
45 .filter_map(|p| match Regex::new(p) {
46 Ok(r) => Some(r),
47 Err(e) => {
48 eprintln!("[memory-core] invalid privacy pattern '{}': {}", p, e);
49 None
50 }
51 })
52 .collect();
53 guard.source = current;
54 guard.compiled = compiled.clone();
55 compiled
56}
57
58fn effective_patterns(config: &PrivacyConfig) -> Vec<String> {
59 if config.replace_defaults {
60 config.extra_patterns.clone()
61 } else {
62 let mut patterns = config.secret_patterns.clone();
63 patterns.extend(config.extra_patterns.clone());
64 patterns
65 }
66}
67
68#[cfg(test)]
69mod tests {
70 use super::*;
71 use crate::config::PrivacyConfig;
72
73 #[test]
74 fn strips_private_tags() {
75 let input = "public info <private>secret stuff</private> more public";
76 assert_eq!(strip_private_tags(input), "public info [REDACTED] more public");
77 }
78
79 #[test]
80 fn strips_multiline_private_tags() {
81 let input = "before <private>\nline1\nline2\n</private> after";
82 assert_eq!(strip_private_tags(input), "before [REDACTED] after");
83 }
84
85 #[test]
86 fn detects_aws_key() {
87 let config = PrivacyConfig::default();
88 let input = "my key is AKIAIOSFODNN7EXAMPLE";
89 assert!(strip_secrets(input, &config).contains("[SECRET_REDACTED]"));
90 }
91
92 #[test]
93 fn detects_private_key_block() {
94 let config = PrivacyConfig::default();
95 let input = "-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIB...";
96 assert!(strip_secrets(input, &config).contains("[SECRET_REDACTED]"));
97 }
98
99 #[test]
100 fn detects_api_key_assignment() {
101 let config = PrivacyConfig::default();
102 let input = "config: api_key=abc123xyz";
103 assert!(strip_secrets(input, &config).contains("[SECRET_REDACTED]"));
104 }
105
106 #[test]
107 fn detects_connection_strings() {
108 let config = PrivacyConfig::default();
109 for input in [
110 "mongodb://user:pass@host:27017/db",
111 "postgres://user:pass@host/db",
112 "mysql://user:pass@host/db",
113 "redis://user:pass@host:6379",
114 ] {
115 assert!(
116 strip_secrets(input, &config).contains("[SECRET_REDACTED]"),
117 "Failed to detect: {input}"
118 );
119 }
120 }
121
122 #[test]
123 fn detects_github_token() {
124 let config = PrivacyConfig::default();
125 let input = "token: ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghij";
126 assert!(strip_secrets(input, &config).contains("[SECRET_REDACTED]"));
127 }
128
129 #[test]
130 fn extra_patterns_additive() {
131 let mut config = PrivacyConfig::default();
132 config.extra_patterns.push(r"CUSTOM_[0-9]{6}".into());
133 let input = "custom: CUSTOM_123456";
134 assert!(strip_secrets(input, &config).contains("[SECRET_REDACTED]"));
135 }
136
137 #[test]
138 fn replace_defaults_removes_builtins() {
139 let mut config = PrivacyConfig::default();
140 config.replace_defaults = true;
141 config.extra_patterns.push(r"ONLY_THIS".into());
142 let input = "AKIAIOSFODNN7EXAMPLE should not be caught";
143 let result = strip_secrets(input, &config);
144 assert!(!result.contains("[SECRET_REDACTED]"));
145
146 let input2 = "ONLY_THIS should be caught";
147 assert!(strip_secrets(input2, &config).contains("[SECRET_REDACTED]"));
148 }
149}