1macro_rules! static_regex {
2 ($pattern:expr) => {{
3 static RE: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
4 RE.get_or_init(|| {
5 regex::Regex::new($pattern).expect(concat!("BUG: invalid static regex: ", $pattern))
6 })
7 }};
8}
9
10fn mask_sensitive_data(input: &str) -> String {
11 let patterns: Vec<(&str, ®ex::Regex)> = vec![
12 (
13 "Bearer token",
14 static_regex!(r"(?i)(bearer\s+)[a-zA-Z0-9\-_\.]{8,}"),
15 ),
16 (
17 "Authorization header",
18 static_regex!(r"(?i)(authorization:\s*(?:basic|bearer|token)\s+)[^\s\r\n]+"),
19 ),
20 (
21 "API key param",
22 static_regex!(
23 r#"(?i)((?:api[_-]?key|apikey|access[_-]?key|secret[_-]?key|token|password|passwd|pwd|secret)\s*[=:]\s*)[^\s\r\n,;&"']+"#
24 ),
25 ),
26 ("AWS key", static_regex!(r"(AKIA[0-9A-Z]{12,})")),
27 (
28 "Private key block",
29 static_regex!(
30 r"(?s)(-----BEGIN\s+(?:RSA\s+)?PRIVATE\s+KEY-----).+?(-----END\s+(?:RSA\s+)?PRIVATE\s+KEY-----)"
31 ),
32 ),
33 (
34 "GitHub token",
35 static_regex!(r"(gh[pousr]_)[a-zA-Z0-9]{20,}"),
36 ),
37 (
38 "Generic long hex/base64 secret",
39 static_regex!(
40 r#"(?i)(?:key|token|secret|password|credential|auth)\s*[=:]\s*['"]?([a-zA-Z0-9+/=\-_]{32,})['"]?"#
41 ),
42 ),
43 ];
44
45 let mut result = input.to_string();
46 for (label, re) in &patterns {
47 result = re
48 .replace_all(&result, |caps: ®ex::Captures| {
49 if let Some(prefix) = caps.get(1) {
50 format!("{}[REDACTED:{}]", prefix.as_str(), label)
51 } else {
52 format!("[REDACTED:{label}]")
53 }
54 })
55 .to_string();
56 }
57 result
58}
59
60pub fn save_tee(command: &str, output: &str) -> Option<String> {
61 let tee_dir = dirs::home_dir()?.join(".lean-ctx").join("tee");
62 std::fs::create_dir_all(&tee_dir).ok()?;
63
64 cleanup_old_tee_logs(&tee_dir);
65
66 let cmd_slug: String = command
67 .chars()
68 .take(40)
69 .map(|c| {
70 if c.is_alphanumeric() || c == '-' {
71 c
72 } else {
73 '_'
74 }
75 })
76 .collect();
77 let cmd_hash = blake3::hash(command.as_bytes()).to_hex();
82 let filename = format!("{cmd_slug}_{}.log", &cmd_hash.as_str()[..8]);
83 let path = tee_dir.join(&filename);
84
85 let masked = mask_sensitive_data(output);
86 let (redacted, _) = crate::core::secret_detection::scan_and_redact_from_config(&masked);
87 std::fs::write(&path, redacted).ok()?;
88 #[cfg(unix)]
89 {
90 use std::os::unix::fs::PermissionsExt;
91 let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600));
92 }
93 Some(path.to_string_lossy().to_string())
94}
95
96fn cleanup_old_tee_logs(tee_dir: &std::path::Path) {
97 let cutoff = std::time::SystemTime::now().checked_sub(std::time::Duration::from_hours(24));
98 let Some(cutoff) = cutoff else { return };
99
100 if let Ok(entries) = std::fs::read_dir(tee_dir) {
101 for entry in entries.flatten() {
102 if let Ok(meta) = entry.metadata() {
103 if let Ok(modified) = meta.modified() {
104 if modified < cutoff {
105 let _ = std::fs::remove_file(entry.path());
106 }
107 }
108 }
109 }
110 }
111}
112
113#[cfg(test)]
114mod tests {
115 use super::*;
116
117 #[test]
121 fn tee_path_is_content_addressed() {
122 let first = save_tee("cargo test --lib", "output run 1").expect("tee saved");
123 let second = save_tee("cargo test --lib", "output run 2").expect("tee saved");
124 assert_eq!(first, second, "same command must map to the same tee path");
125
126 let other = save_tee("cargo build", "output").expect("tee saved");
127 assert_ne!(first, other, "different commands get different tee paths");
128
129 let content = std::fs::read_to_string(&second).unwrap();
131 assert!(content.contains("run 2"));
132
133 for p in [first, other] {
134 let _ = std::fs::remove_file(p);
135 }
136 }
137}