Skip to main content

lean_ctx/shell/
redact.rs

1//! Tee logging for shell output.
2//!
3//! Secret masking is delegated to [`crate::core::redaction`] — the single
4//! source of truth shared with `ctx_read` redaction — so the regex set can
5//! never drift between the two layers again (it used to be a hand-copied
6//! duplicate). `save_tee` then runs the config-driven secret scanner on top
7//! for defense in depth.
8
9pub fn save_tee(command: &str, output: &str) -> Option<String> {
10    let tee_dir = crate::core::paths::state_dir().ok()?.join("tee");
11    std::fs::create_dir_all(&tee_dir).ok()?;
12
13    // #950: cleanup is an O(N) read_dir + per-file metadata() scan of the
14    // whole tee directory. Running it on every save_tee (i.e. every
15    // compressed shell call) means its cost scales with directory size on
16    // every single invocation under heavy shell activity. Entries already
17    // carry a 24h TTL, so throttling the scan to once per interval is enough
18    // to keep the directory bounded without paying the O(N) cost every time.
19    if let Ok(now) = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)
20        && TEE_CLEANUP_THROTTLE.is_due(now.as_secs())
21    {
22        cleanup_old_tee_logs(&tee_dir);
23    }
24
25    let cmd_slug: String = command
26        .chars()
27        .take(40)
28        .map(|c| {
29            if c.is_alphanumeric() || c == '-' {
30                c
31            } else {
32                '_'
33            }
34        })
35        .collect();
36    // Content-addressed path (#498): the same command always maps to the same
37    // file, so repeated tool outputs stay byte-identical (provider prompt
38    // caches reward stable text). Re-runs overwrite — newest output wins;
39    // the 24h TTL cleanup works on mtime, not the filename.
40    let cmd_hash = blake3::hash(command.as_bytes()).to_hex();
41    let filename = format!("{cmd_slug}_{}.log", &cmd_hash.as_str()[..8]);
42    let path = tee_dir.join(&filename);
43
44    let masked = crate::core::redaction::redact_text(output);
45    let (redacted, _) = crate::core::secret_detection::scan_and_redact_from_config(&masked);
46    std::fs::write(&path, redacted).ok()?;
47    #[cfg(unix)]
48    {
49        use std::os::unix::fs::PermissionsExt;
50        let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600));
51    }
52    Some(path.to_string_lossy().to_string())
53}
54
55/// Lock-free gate that lets [`cleanup_old_tee_logs`]'s directory scan run at
56/// most once per `interval_secs`, no matter how many `save_tee` calls land
57/// concurrently. Only the caller whose compare-exchange wins gets `true`.
58struct CleanupThrottle {
59    last_run_unix_secs: std::sync::atomic::AtomicU64,
60    interval_secs: u64,
61}
62
63impl CleanupThrottle {
64    const fn new(interval_secs: u64) -> Self {
65        Self {
66            last_run_unix_secs: std::sync::atomic::AtomicU64::new(0),
67            interval_secs,
68        }
69    }
70
71    /// `0` means "never run" and is always due, regardless of `now_unix_secs`
72    /// — avoids the throttle depending on `now` being a large real epoch time.
73    fn is_due(&self, now_unix_secs: u64) -> bool {
74        use std::sync::atomic::Ordering;
75        let last = self.last_run_unix_secs.load(Ordering::Relaxed);
76        let due = last == 0 || now_unix_secs.saturating_sub(last) >= self.interval_secs;
77        due && self
78            .last_run_unix_secs
79            .compare_exchange(last, now_unix_secs, Ordering::Relaxed, Ordering::Relaxed)
80            .is_ok()
81    }
82}
83
84static TEE_CLEANUP_THROTTLE: CleanupThrottle = CleanupThrottle::new(10 * 60);
85
86/// Removes tee log entries older than 24h. Throttled by [`TEE_CLEANUP_THROTTLE`]
87/// (called from `save_tee`) rather than run on every call — the read_dir +
88/// per-file metadata() scan is O(N) in directory size.
89pub(crate) fn cleanup_old_tee_logs(tee_dir: &std::path::Path) {
90    let cutoff = std::time::SystemTime::now().checked_sub(std::time::Duration::from_hours(24));
91    let Some(cutoff) = cutoff else { return };
92
93    if let Ok(entries) = std::fs::read_dir(tee_dir) {
94        for entry in entries.flatten() {
95            if let Ok(meta) = entry.metadata()
96                && let Ok(modified) = meta.modified()
97                && modified < cutoff
98            {
99                let _ = std::fs::remove_file(entry.path());
100            }
101        }
102    }
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108
109    // --- #950: tee cleanup throttle ---
110
111    #[test]
112    fn cleanup_throttle_gates_by_interval() {
113        let throttle = CleanupThrottle::new(600);
114        assert!(
115            throttle.is_due(1_000),
116            "never run before: first call is always due"
117        );
118        assert!(
119            !throttle.is_due(1_100),
120            "only 100s elapsed of a 600s interval"
121        );
122        assert!(!throttle.is_due(1_599), "still short of the interval by 1s");
123        assert!(
124            throttle.is_due(1_600),
125            "exactly interval-elapsed must be due again"
126        );
127    }
128
129    #[test]
130    fn cleanup_throttle_lets_only_one_racer_through_per_interval() {
131        let throttle = CleanupThrottle::new(600);
132        let hits = std::thread::scope(|scope| {
133            let handles: Vec<_> = (0..16)
134                .map(|_| scope.spawn(|| throttle.is_due(1_000)))
135                .collect();
136            handles
137                .into_iter()
138                .map(|h| h.join().unwrap())
139                .filter(|&due| due)
140                .count()
141        });
142        assert_eq!(hits, 1, "exactly one racer should win the cleanup slot");
143    }
144
145    /// Determinism contract (#498): the tee path must be content-addressed —
146    /// the same command always maps to the same file so repeated tool outputs
147    /// stay byte-identical for provider prompt caching.
148    #[test]
149    fn tee_path_is_content_addressed() {
150        // Serialize against tests that repoint LEAN_CTX_DATA_DIR (isolated_data_dir);
151        // without the lock the resolved tee base races and the paths diverge.
152        let _lock = crate::core::data_dir::test_env_lock();
153        let first = save_tee("cargo test --lib", "output run 1").expect("tee saved");
154        let second = save_tee("cargo test --lib", "output run 2").expect("tee saved");
155        assert_eq!(first, second, "same command must map to the same tee path");
156
157        let other = save_tee("cargo build", "output").expect("tee saved");
158        assert_ne!(first, other, "different commands get different tee paths");
159
160        // Latest output wins on overwrite.
161        let content = std::fs::read_to_string(&second).unwrap();
162        assert!(content.contains("run 2"));
163
164        for p in [first, other] {
165            let _ = std::fs::remove_file(p);
166        }
167    }
168}