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