Skip to main content

zeph_tools/
overflow.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use std::path::{Path, PathBuf};
5use std::time::{Duration, SystemTime};
6
7use crate::config::OverflowConfig;
8
9fn overflow_dir(custom: Option<&Path>) -> PathBuf {
10    if let Some(p) = custom {
11        return p.to_path_buf();
12    }
13    dirs::home_dir()
14        .unwrap_or_else(|| PathBuf::from("."))
15        .join(".zeph/data/tool-output")
16}
17
18/// Save full output to overflow file if it exceeds `config.threshold`.
19/// Returns the filename (UUID.txt) of the saved file, or `None` if output fits.
20pub fn save_overflow(output: &str, config: &OverflowConfig) -> Option<String> {
21    if output.len() <= config.threshold {
22        return None;
23    }
24    let dir = overflow_dir(config.dir.as_deref());
25    if let Err(e) = std::fs::create_dir_all(&dir) {
26        tracing::warn!("failed to create overflow dir: {e}");
27        return None;
28    }
29    let canonical_dir = match std::fs::canonicalize(&dir) {
30        Ok(p) => p,
31        Err(e) => {
32            tracing::warn!("failed to canonicalize overflow dir: {e}");
33            return None;
34        }
35    };
36    let filename = format!("{}.txt", uuid::Uuid::new_v4());
37    let path = canonical_dir.join(&filename);
38
39    #[cfg(unix)]
40    {
41        use std::os::unix::fs::OpenOptionsExt;
42        match std::fs::OpenOptions::new()
43            .write(true)
44            .create_new(true)
45            .mode(0o600)
46            .open(&path)
47            .and_then(|mut f| {
48                use std::io::Write;
49                f.write_all(output.as_bytes())
50            }) {
51            Ok(()) => {}
52            Err(e) => {
53                tracing::warn!("failed to write overflow file: {e}");
54                return None;
55            }
56        }
57    }
58    #[cfg(not(unix))]
59    {
60        if let Err(e) = std::fs::write(&path, output) {
61            tracing::warn!("failed to write overflow file: {e}");
62            return None;
63        }
64    }
65
66    Some(filename)
67}
68
69/// Remove overflow files older than `config.retention_days`. Creates directory if missing.
70pub fn cleanup_overflow_files(config: &OverflowConfig) {
71    let dir = overflow_dir(config.dir.as_deref());
72    let max_age = Duration::from_secs(config.retention_days * 86_400);
73    cleanup_overflow_files_in(&dir, max_age);
74}
75
76fn cleanup_overflow_files_in(dir: &Path, max_age: Duration) {
77    if let Err(e) = std::fs::create_dir_all(dir) {
78        tracing::warn!("failed to create overflow dir: {e}");
79        return;
80    }
81    let entries = match std::fs::read_dir(dir) {
82        Ok(e) => e,
83        Err(e) => {
84            tracing::warn!("failed to read overflow dir: {e}");
85            return;
86        }
87    };
88    let now = SystemTime::now();
89    for entry in entries.flatten() {
90        // Use symlink_metadata to avoid following symlinks — we only remove regular files.
91        let Ok(meta) = std::fs::symlink_metadata(entry.path()) else {
92            continue;
93        };
94        if !meta.file_type().is_file() {
95            continue;
96        }
97        let Ok(modified) = meta.modified() else {
98            continue;
99        };
100        // TOCTOU race between metadata check and remove_file is benign here:
101        // the worst case is a spurious warning if another process removes the file first.
102        if now.duration_since(modified).unwrap_or_default() > max_age
103            && let Err(e) = std::fs::remove_file(entry.path())
104        {
105            tracing::warn!("failed to remove stale overflow file: {e}");
106        }
107    }
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113
114    fn cfg(threshold: usize) -> OverflowConfig {
115        OverflowConfig {
116            threshold,
117            retention_days: 7,
118            dir: None,
119        }
120    }
121
122    #[test]
123    fn small_output_no_overflow() {
124        assert!(save_overflow("short", &cfg(50_000)).is_none());
125    }
126
127    #[test]
128    fn overflow_creates_file() {
129        let dir = tempfile::tempdir().unwrap();
130        let config = OverflowConfig {
131            threshold: 50_000,
132            retention_days: 7,
133            dir: Some(dir.path().to_path_buf()),
134        };
135        let long = "x".repeat(50_001);
136        let filename = save_overflow(&long, &config);
137        assert!(filename.is_some());
138        let name = filename.unwrap();
139        let p = dir.path().join(&name);
140        assert!(p.exists());
141        let contents = std::fs::read_to_string(&p).unwrap();
142        assert_eq!(contents.len(), long.len());
143    }
144
145    #[test]
146    fn custom_threshold_respected() {
147        let dir = tempfile::tempdir().unwrap();
148        let output = "x".repeat(1_001);
149
150        let config_low = OverflowConfig {
151            threshold: 1_000,
152            retention_days: 7,
153            dir: Some(dir.path().to_path_buf()),
154        };
155        assert!(save_overflow(&output, &config_low).is_some());
156
157        assert!(save_overflow(&output, &cfg(2_000)).is_none());
158    }
159
160    #[test]
161    fn save_returns_filename_only() {
162        let dir = tempfile::tempdir().unwrap();
163        let config = OverflowConfig {
164            threshold: 0,
165            retention_days: 7,
166            dir: Some(dir.path().to_path_buf()),
167        };
168        let filename = save_overflow("any", &config).unwrap();
169        // Must be just "UUID.txt", not a full path
170        assert!(!filename.contains('/'));
171        assert!(filename.ends_with(".txt"));
172    }
173
174    #[test]
175    fn custom_dir_used() {
176        let dir = tempfile::tempdir().unwrap();
177        let config = OverflowConfig {
178            threshold: 0,
179            retention_days: 7,
180            dir: Some(dir.path().to_path_buf()),
181        };
182        let filename = save_overflow("any", &config);
183        assert!(filename.is_some());
184        let p = dir.path().join(filename.unwrap());
185        assert!(p.exists());
186    }
187
188    #[test]
189    fn stale_files_removed() {
190        let dir = tempfile::tempdir().unwrap();
191        let file = dir.path().join("old.txt");
192        std::fs::write(&file, "data").unwrap();
193        let old_time = SystemTime::now() - Duration::from_secs(86_500);
194        let ft = filetime::FileTime::from_system_time(old_time);
195        filetime::set_file_mtime(&file, ft).unwrap();
196        cleanup_overflow_files_in(dir.path(), Duration::from_secs(86_400));
197        assert!(!file.exists());
198    }
199
200    #[test]
201    fn fresh_files_kept() {
202        let dir = tempfile::tempdir().unwrap();
203        let file = dir.path().join("fresh.txt");
204        std::fs::write(&file, "data").unwrap();
205        cleanup_overflow_files_in(dir.path(), Duration::from_secs(86_400));
206        assert!(file.exists());
207    }
208
209    #[test]
210    fn missing_dir_created() {
211        let dir = tempfile::tempdir().unwrap();
212        let sub = dir.path().join("sub/dir");
213        cleanup_overflow_files_in(&sub, Duration::from_secs(86_400));
214        assert!(sub.exists());
215    }
216
217    #[test]
218    #[cfg(unix)]
219    fn cleanup_skips_symlinks() {
220        let dir = tempfile::tempdir().unwrap();
221        // Create a regular file outside the overflow dir that the symlink points to
222        let outside_dir = tempfile::tempdir().unwrap();
223        let target = outside_dir.path().join("target.txt");
224        std::fs::write(&target, "data").unwrap();
225
226        // Create a symlink inside the overflow dir pointing to the external file
227        let link = dir.path().join("link.txt");
228        std::os::unix::fs::symlink(&target, &link).unwrap();
229
230        // Age the symlink mtime (not the target)
231        let old_time = SystemTime::now() - Duration::from_secs(86_500);
232        let ft = filetime::FileTime::from_system_time(old_time);
233        filetime::set_file_mtime(&link, ft).unwrap();
234
235        cleanup_overflow_files_in(dir.path(), Duration::from_secs(86_400));
236
237        // The symlink is not a regular file — cleanup must not remove it
238        assert!(link.exists(), "symlink should not be removed by cleanup");
239        // The external target must also be untouched
240        assert!(target.exists(), "symlink target must not be removed");
241    }
242
243    #[test]
244    fn cleanup_uses_retention_days() {
245        let dir = tempfile::tempdir().unwrap();
246        let file = dir.path().join("stale.txt");
247        std::fs::write(&file, "data").unwrap();
248        let old_time = SystemTime::now() - Duration::from_secs(7 * 86_400 + 100);
249        let ft = filetime::FileTime::from_system_time(old_time);
250        filetime::set_file_mtime(&file, ft).unwrap();
251
252        let config = OverflowConfig {
253            threshold: 50_000,
254            retention_days: 7,
255            dir: Some(dir.path().to_path_buf()),
256        };
257        cleanup_overflow_files(&config);
258        assert!(!file.exists());
259    }
260
261    #[test]
262    fn default_config_values() {
263        let config = OverflowConfig::default();
264        assert_eq!(config.threshold, 50_000);
265        assert_eq!(config.retention_days, 7);
266        assert!(config.dir.is_none());
267    }
268}