1pub 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 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 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
55struct 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 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
86pub(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 #[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 #[test]
149 fn tee_path_is_content_addressed() {
150 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 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}