use std::io;
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};
pub const SPILL_DIR_NAME: &str = "spill";
pub const SPILL_MAX_AGE: Duration = Duration::from_secs(7 * 24 * 60 * 60);
const MAX_SWEEP_ENTRIES: usize = 4096;
const MAX_COMPONENT_BYTES: usize = 128;
pub fn default_spill_root() -> PathBuf {
spill_root(&crate::config::base_dir())
}
pub fn spill_root(base: &Path) -> PathBuf {
base.join(SPILL_DIR_NAME)
}
pub fn session_component(id: &str) -> Option<&str> {
let ok = !id.is_empty()
&& id.len() <= MAX_COMPONENT_BYTES
&& id != "."
&& id != ".."
&& id
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.'));
ok.then_some(id)
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct SpillRemoval {
pub entries: usize,
pub files: usize,
pub bytes: u64,
}
impl SpillRemoval {
fn add(&mut self, other: SpillRemoval) {
self.entries += other.entries;
self.files += other.files;
self.bytes += other.bytes;
}
}
pub fn remove_session_spill(root: &Path, session_id: &str) -> io::Result<SpillRemoval> {
let Some(component) = session_component(session_id) else {
return Ok(SpillRemoval::default());
};
remove_entry(&root.join(component))
}
fn remove_entry(path: &Path) -> io::Result<SpillRemoval> {
let meta = match std::fs::symlink_metadata(path) {
Ok(meta) => meta,
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(SpillRemoval::default()),
Err(e) => return Err(e),
};
if !meta.is_dir() {
let (files, bytes) = if meta.is_file() { (1, meta.len()) } else { (0, 0) };
std::fs::remove_file(path)?;
return Ok(SpillRemoval { entries: 1, files, bytes });
}
let mut freed = SpillRemoval { entries: 1, ..Default::default() };
if let Ok(entries) = std::fs::read_dir(path) {
for entry in entries.flatten() {
if let Ok(meta) = entry.metadata() {
if meta.is_file() {
freed.files += 1;
freed.bytes += meta.len();
}
}
}
}
match std::fs::remove_dir_all(path) {
Err(e) if e.kind() == io::ErrorKind::NotFound => {}
other => other?,
}
Ok(freed)
}
pub fn sweep_stale_spill(root: &Path, max_age: Duration, now: SystemTime) -> SpillRemoval {
let mut removed = SpillRemoval::default();
let Ok(entries) = std::fs::read_dir(root) else {
return removed;
};
for entry in entries.flatten().take(MAX_SWEEP_ENTRIES) {
let path = entry.path();
let Ok(meta) = std::fs::symlink_metadata(&path) else {
continue;
};
let stale = if meta.is_dir() {
meta.modified()
.ok()
.and_then(|m| now.duration_since(m).ok())
.is_some_and(|age| age > max_age)
} else {
true
};
if stale {
if let Ok(freed) = remove_entry(&path) {
removed.add(freed);
}
}
}
removed
}
#[cfg(test)]
mod tests {
use super::*;
const DAY: Duration = Duration::from_secs(24 * 60 * 60);
fn spill_file(root: &Path, session: &str, name: &str, body: &str) {
let dir = root.join(session);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join(name), body).unwrap();
}
#[test]
fn session_component_accepts_session_and_trace_ids_only() {
assert_eq!(session_component("20261029-143000-ab12"), Some("20261029-143000-ab12"));
assert_eq!(session_component("session-123-1700000000000-4"), Some("session-123-1700000000000-4"));
for bad in ["", ".", "..", "a/b", "a\\b", "../x", "a b", "a:b", &"x".repeat(129)] {
assert_eq!(session_component(bad), None, "{bad:?}");
}
}
#[test]
fn remove_session_spill_removes_the_dir_and_counts_it() {
let tmp = tempfile::tempdir().unwrap();
let root = spill_root(tmp.path());
spill_file(&root, "s1", "toolu_a.txt", "12345");
spill_file(&root, "s1", "toolu_b.txt", "123");
spill_file(&root, "s2", "toolu_c.txt", "keep");
let freed = remove_session_spill(&root, "s1").unwrap();
assert_eq!(freed, SpillRemoval { entries: 1, files: 2, bytes: 8 });
assert!(!root.join("s1").exists());
assert!(root.join("s2/toolu_c.txt").exists(), "other sessions untouched");
assert_eq!(remove_session_spill(&root, "s1").unwrap(), SpillRemoval::default());
assert_eq!(remove_session_spill(&root, "..").unwrap(), SpillRemoval::default());
assert!(root.exists());
}
#[cfg(unix)]
#[test]
fn remove_session_spill_never_follows_a_planted_symlink() {
let tmp = tempfile::tempdir().unwrap();
let root = spill_root(tmp.path());
let victim = tmp.path().join("victim");
std::fs::create_dir_all(&victim).unwrap();
std::fs::write(victim.join("precious"), "x").unwrap();
std::fs::create_dir_all(&root).unwrap();
std::os::unix::fs::symlink(&victim, root.join("s1")).unwrap();
remove_session_spill(&root, "s1").unwrap();
assert!(!root.join("s1").exists(), "the link itself is removed");
assert!(victim.join("precious").exists(), "its target is untouched");
}
#[test]
fn sweep_removes_only_stale_dirs_and_stray_files() {
let tmp = tempfile::tempdir().unwrap();
let root = spill_root(tmp.path());
spill_file(&root, "old", "toolu_a.txt", "a");
std::fs::write(root.join("stray.txt"), "x").unwrap();
let now = SystemTime::now();
let swept = sweep_stale_spill(&root, SPILL_MAX_AGE, now);
assert_eq!(swept, SpillRemoval { entries: 1, files: 1, bytes: 1 });
assert!(root.join("old/toolu_a.txt").exists());
let swept = sweep_stale_spill(&root, SPILL_MAX_AGE, now + 8 * DAY);
assert_eq!(swept, SpillRemoval { entries: 1, files: 1, bytes: 1 });
assert!(!root.join("old").exists());
assert!(root.exists(), "the root itself stays");
let swept = sweep_stale_spill(&tmp.path().join("nope"), SPILL_MAX_AGE, now);
assert_eq!(swept, SpillRemoval::default());
}
}