//! Retention for the backup directory. Only two kinds of entries are ever removed: //! complete sets (`forge-backup-*` directories that hold a `manifest.json`) beyond the newest //! `keep`, and `.partial-*` directories older than a day. Anything else in the directory is left //! alone, including files, symlinks and directories with other names. use std::path::{Path, PathBuf}; use std::time::{Duration, SystemTime}; use forge_core::Result; use super::{BACKUP_DIR_PREFIX, MANIFEST_FILE, PARTIAL_PREFIX}; /// Partial directories younger than this may belong to a run that is still writing them. pub const PARTIAL_MIN_AGE: Duration = Duration::from_secs(24 * 60 * 60); /// What [`prune`] removed. #[derive(Debug, Default, PartialEq, Eq)] pub struct PruneReport { pub sets_removed: Vec, pub partials_removed: Vec, } /// Applies the retention rules to `dir` as of `now`. Sets are ordered by name, which is their UTC /// stamp, so the newest are the last names. pub fn prune(dir: &Path, keep: usize, now: SystemTime) -> Result { let mut report = PruneReport::default(); let mut sets: Vec<(String, PathBuf)> = Vec::new(); for entry in std::fs::read_dir(dir)? { let entry = entry?; let Some(name) = entry.file_name().to_str().map(str::to_owned) else { continue; }; // file_type does not follow symlinks, so a symlink is never treated as a directory. if !entry.file_type()?.is_dir() { continue; } let path = entry.path(); if name.starts_with(PARTIAL_PREFIX) { let modified = entry.metadata()?.modified()?; let age = now.duration_since(modified).unwrap_or(Duration::ZERO); if age >= PARTIAL_MIN_AGE { std::fs::remove_dir_all(&path)?; report.partials_removed.push(path); } } else if name.starts_with(BACKUP_DIR_PREFIX) && is_complete_set(&path) { sets.push((name, path)); } } sets.sort(); let excess = sets.len().saturating_sub(keep); for (_, path) in sets.into_iter().take(excess) { std::fs::remove_dir_all(&path)?; report.sets_removed.push(path); } Ok(report) } /// A set is complete when its manifest is a regular file, not a symlink. fn is_complete_set(path: &Path) -> bool { std::fs::symlink_metadata(path.join(MANIFEST_FILE)).is_ok_and(|meta| meta.file_type().is_file()) } #[cfg(test)] mod tests { use super::*; fn make_set(root: &Path, stamp: &str) -> PathBuf { let set = root.join(format!("{BACKUP_DIR_PREFIX}{stamp}")); std::fs::create_dir_all(&set).expect("set dir"); std::fs::write(set.join(MANIFEST_FILE), b"{}").expect("manifest"); set } fn make_partial(root: &Path, name: &str, age: Duration) -> PathBuf { let dir = root.join(format!("{PARTIAL_PREFIX}{name}")); std::fs::create_dir_all(&dir).expect("partial dir"); std::fs::write(dir.join("half.bin"), b"x").expect("file"); let when = SystemTime::now() - age; std::fs::File::open(&dir) .expect("open") .set_modified(when) .expect("mtime"); dir } #[test] fn keeps_the_newest_complete_sets_and_leaves_foreign_entries() { let dir = tempfile::tempdir().expect("tempdir"); let root = dir.path(); let old = make_set(root, "20260101T000000Z"); let mid = make_set(root, "20260102T000000Z"); let new = make_set(root, "20260103T000000Z"); // Foreign entries: a plain file, a directory with another name, an incomplete set // without a manifest, and a set-like name that is a file. std::fs::write(root.join("notes.txt"), b"keep me").expect("file"); std::fs::create_dir_all(root.join("photos")).expect("dir"); let incomplete = root.join("forge-backup-20250101T000000Z"); std::fs::create_dir_all(&incomplete).expect("incomplete"); std::fs::write(root.join("forge-backup-20240101T000000Z"), b"file").expect("file"); let report = prune(root, 2, SystemTime::now()).expect("prune"); assert_eq!(report.sets_removed, vec![old.clone()]); assert!(report.partials_removed.is_empty()); assert!(!old.exists()); assert!(mid.exists() && new.exists()); assert!(root.join("notes.txt").exists()); assert!(root.join("photos").is_dir()); assert!( incomplete.is_dir(), "a set without a manifest is not ours to delete" ); assert!(root.join("forge-backup-20240101T000000Z").is_file()); } #[test] fn keep_larger_than_the_count_removes_nothing() { let dir = tempfile::tempdir().expect("tempdir"); make_set(dir.path(), "20260101T000000Z"); let report = prune(dir.path(), 7, SystemTime::now()).expect("prune"); assert_eq!(report, PruneReport::default()); } #[test] fn removes_only_stale_partials() { let dir = tempfile::tempdir().expect("tempdir"); let root = dir.path(); let stale = make_partial( root, "forge-backup-20260101T000000Z-1", Duration::from_secs(3 * 86400), ); let fresh = make_partial( root, "forge-backup-20260102T000000Z-2", Duration::from_secs(3600), ); let report = prune(root, 7, SystemTime::now()).expect("prune"); assert_eq!(report.partials_removed, vec![stale.clone()]); assert!(!stale.exists()); assert!(fresh.exists(), "a recent partial may still be in progress"); } #[cfg(unix)] #[test] fn symlinks_are_never_followed_or_removed() { let dir = tempfile::tempdir().expect("tempdir"); let root = dir.path().join("backups"); std::fs::create_dir_all(&root).expect("root"); let outside = dir.path().join("outside"); let target = make_set(&outside, "20260101T000000Z"); std::os::unix::fs::symlink(&target, root.join("forge-backup-20250101T000000Z")) .expect("symlink"); let report = prune(&root, 1, SystemTime::now()).expect("prune"); assert!(report.sets_removed.is_empty()); assert!( target.join(MANIFEST_FILE).is_file(), "the symlink target is untouched" ); assert!(root.join("forge-backup-20250101T000000Z").is_symlink()); } }