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(|| {
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}