forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
6.3 KiB
1//! Retention for the backup directory. Only two kinds of entries are ever removed:
2//! complete sets (`forge-backup-*` directories that hold a `manifest.json`) beyond the newest
3//! `keep`, and `.partial-*` directories older than a day. Anything else in the directory is left
4//! alone, including files, symlinks and directories with other names.
5
6use std::path::{Path, PathBuf};
7use std::time::{Duration, SystemTime};
8
9use forge_core::Result;
10
11use super::{BACKUP_DIR_PREFIX, MANIFEST_FILE, PARTIAL_PREFIX};
12
13/// Partial directories younger than this may belong to a run that is still writing them.
14pub const PARTIAL_MIN_AGE: Duration = Duration::from_secs(24 * 60 * 60);
15
16/// What [`prune`] removed.
17#[derive(Debug, Default, PartialEq, Eq)]
18pub struct PruneReport {
19 pub sets_removed: Vec<PathBuf>,
20 pub partials_removed: Vec<PathBuf>,
21}
22
23/// Applies the retention rules to `dir` as of `now`. Sets are ordered by name, which is their UTC
24/// stamp, so the newest are the last names.
25pub fn prune(dir: &Path, keep: usize, now: SystemTime) -> Result<PruneReport> {
26 let mut report = PruneReport::default();
27 let mut sets: Vec<(String, PathBuf)> = Vec::new();
28 for entry in std::fs::read_dir(dir)? {
29 let entry = entry?;
30 let Some(name) = entry.file_name().to_str().map(str::to_owned) else {
31 continue;
32 };
33 // file_type does not follow symlinks, so a symlink is never treated as a directory.
34 if !entry.file_type()?.is_dir() {
35 continue;
36 }
37 let path = entry.path();
38 if name.starts_with(PARTIAL_PREFIX) {
39 let modified = entry.metadata()?.modified()?;
40 let age = now.duration_since(modified).unwrap_or(Duration::ZERO);
41 if age >= PARTIAL_MIN_AGE {
42 std::fs::remove_dir_all(&path)?;
43 report.partials_removed.push(path);
44 }
45 } else if name.starts_with(BACKUP_DIR_PREFIX) && is_complete_set(&path) {
46 sets.push((name, path));
47 }
48 }
49 sets.sort();
50 let excess = sets.len().saturating_sub(keep);
51 for (_, path) in sets.into_iter().take(excess) {
52 std::fs::remove_dir_all(&path)?;
53 report.sets_removed.push(path);
54 }
55 Ok(report)
56}
57
58/// A set is complete when its manifest is a regular file, not a symlink.
59fn is_complete_set(path: &Path) -> bool {
60 std::fs::symlink_metadata(path.join(MANIFEST_FILE)).is_ok_and(|meta| meta.file_type().is_file())
61}
62
63#[cfg(test)]
64mod tests {
65 use super::*;
66
67 fn make_set(root: &Path, stamp: &str) -> PathBuf {
68 let set = root.join(format!("{BACKUP_DIR_PREFIX}{stamp}"));
69 std::fs::create_dir_all(&set).expect("set dir");
70 std::fs::write(set.join(MANIFEST_FILE), b"{}").expect("manifest");
71 set
72 }
73
74 fn make_partial(root: &Path, name: &str, age: Duration) -> PathBuf {
75 let dir = root.join(format!("{PARTIAL_PREFIX}{name}"));
76 std::fs::create_dir_all(&dir).expect("partial dir");
77 std::fs::write(dir.join("half.bin"), b"x").expect("file");
78 let when = SystemTime::now() - age;
79 std::fs::File::open(&dir)
80 .expect("open")
81 .set_modified(when)
82 .expect("mtime");
83 dir
84 }
85
86 #[test]
87 fn keeps_the_newest_complete_sets_and_leaves_foreign_entries() {
88 let dir = tempfile::tempdir().expect("tempdir");
89 let root = dir.path();
90 let old = make_set(root, "20260101T000000Z");
91 let mid = make_set(root, "20260102T000000Z");
92 let new = make_set(root, "20260103T000000Z");
93 // Foreign entries: a plain file, a directory with another name, an incomplete set
94 // without a manifest, and a set-like name that is a file.
95 std::fs::write(root.join("notes.txt"), b"keep me").expect("file");
96 std::fs::create_dir_all(root.join("photos")).expect("dir");
97 let incomplete = root.join("forge-backup-20250101T000000Z");
98 std::fs::create_dir_all(&incomplete).expect("incomplete");
99 std::fs::write(root.join("forge-backup-20240101T000000Z"), b"file").expect("file");
100
101 let report = prune(root, 2, SystemTime::now()).expect("prune");
102 assert_eq!(report.sets_removed, vec![old.clone()]);
103 assert!(report.partials_removed.is_empty());
104 assert!(!old.exists());
105 assert!(mid.exists() && new.exists());
106 assert!(root.join("notes.txt").exists());
107 assert!(root.join("photos").is_dir());
108 assert!(
109 incomplete.is_dir(),
110 "a set without a manifest is not ours to delete"
111 );
112 assert!(root.join("forge-backup-20240101T000000Z").is_file());
113 }
114
115 #[test]
116 fn keep_larger_than_the_count_removes_nothing() {
117 let dir = tempfile::tempdir().expect("tempdir");
118 make_set(dir.path(), "20260101T000000Z");
119 let report = prune(dir.path(), 7, SystemTime::now()).expect("prune");
120 assert_eq!(report, PruneReport::default());
121 }
122
123 #[test]
124 fn removes_only_stale_partials() {
125 let dir = tempfile::tempdir().expect("tempdir");
126 let root = dir.path();
127 let stale = make_partial(
128 root,
129 "forge-backup-20260101T000000Z-1",
130 Duration::from_secs(3 * 86400),
131 );
132 let fresh = make_partial(
133 root,
134 "forge-backup-20260102T000000Z-2",
135 Duration::from_secs(3600),
136 );
137 let report = prune(root, 7, SystemTime::now()).expect("prune");
138 assert_eq!(report.partials_removed, vec![stale.clone()]);
139 assert!(!stale.exists());
140 assert!(fresh.exists(), "a recent partial may still be in progress");
141 }
142
143 #[cfg(unix)]
144 #[test]
145 fn symlinks_are_never_followed_or_removed() {
146 let dir = tempfile::tempdir().expect("tempdir");
147 let root = dir.path().join("backups");
148 std::fs::create_dir_all(&root).expect("root");
149 let outside = dir.path().join("outside");
150 let target = make_set(&outside, "20260101T000000Z");
151 std::os::unix::fs::symlink(&target, root.join("forge-backup-20250101T000000Z"))
152 .expect("symlink");
153 let report = prune(&root, 1, SystemTime::now()).expect("prune");
154 assert!(report.sets_removed.is_empty());
155 assert!(
156 target.join(MANIFEST_FILE).is_file(),
157 "the symlink target is untouched"
158 );
159 assert!(root.join("forge-backup-20250101T000000Z").is_symlink());
160 }
161}