Skip to main content

memory_core/store/
privacy.rs

1use 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}