1use std::path::{Path, PathBuf};
16
17pub const VERSIONS: usize = 20;
19pub const DAYS: i64 = 30;
21pub const SEALED_DAYS: i64 = 60;
24const SEALED: &str = ".sealed";
26const DAY: i64 = 86_400_000;
27
28#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct Version {
32 pub stamp: String,
33 pub at: i64,
34 pub size: u64,
35 pub sealed: Option<String>,
36}
37
38#[derive(Debug, PartialEq, Eq)]
40pub enum Kept {
41 Copy(PathBuf),
42 Sealed { hash: String, size: u64 },
43}
44
45pub fn root(memory: &Path) -> PathBuf {
47 memory.parent().map_or_else(|| PathBuf::from("history"), |share| share.join("history"))
48}
49
50fn stamp(ms: i64) -> String {
52 let Ok(at) = jiff::Timestamp::from_millisecond(ms) else { return ms.to_string() };
53 format!("{}.{:03}Z", at.strftime("%Y-%m-%dT%H-%M-%S"), ms.rem_euclid(1000))
54}
55
56pub fn kept_at(stamp: &str) -> Option<i64> {
58 let (seconds, ms) = stamp.strip_suffix('Z')?.split_once('.')?;
59 let at = jiff::civil::DateTime::strptime("%Y-%m-%dT%H-%M-%S", seconds).ok()?.to_zoned(jiff::tz::TimeZone::UTC).ok()?;
60 Some(at.timestamp().as_millisecond() + ms.parse::<i64>().ok()?)
61}
62
63fn part_dir(root: &Path, part: &str) -> Option<PathBuf> {
65 plain(part).then(|| root.join(part))
66}
67
68fn plain(piece: &str) -> bool {
69 !piece.is_empty() && piece != "." && piece != ".." && !piece.contains(['/', '\\', ':'])
70}
71
72fn below(root: &Path, part: &str, file: &str) -> Option<PathBuf> {
75 let dir = part_dir(root, part)?;
76 file.split('/').all(plain).then(|| file.split('/').fold(dir, |path, piece| path.join(piece)))
77}
78
79fn new_stamp(dir: &Path, now_ms: i64) -> String {
81 let mut ms = now_ms;
82 while dir.join(stamp(ms)).exists() || dir.join(format!("{}{SEALED}", stamp(ms))).exists() {
83 ms += 1;
84 }
85 stamp(ms)
86}
87
88pub fn keep(root: &Path, part: &str, file: &str, path: &Path, now_ms: i64) -> Result<(), String> {
93 let dir = below(root, part, file).ok_or_else(|| format!("{file}: not a file's name"))?;
94 let size = std::fs::metadata(path).map_or(0, |m| m.len());
95 if !crate::disk::fits(root, size) {
96 return Err(format!("share-no-room:{file}"));
97 }
98 crate::share::private_dirs(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
99 let target = dir.join(new_stamp(&dir, now_ms));
100 let temporary = crate::share::temporary(&target);
102 crate::share::copy_private(path, &temporary).and_then(|()| std::fs::rename(&temporary, &target)).map_err(|e| {
103 let _ = std::fs::remove_file(&temporary);
104 format!("{}: {e}", target.display())
105 })?;
106 prune_file(&dir, now_ms, false);
107 Ok(())
108}
109
110pub const ACCOUNTS: &str = "accounts";
116
117pub fn keep_bytes(root: &Path, part: &str, file: &str, bytes: &[u8], now_ms: i64) -> Result<PathBuf, String> {
120 let dir = below(root, part, file).ok_or_else(|| format!("{file}: not a file's name"))?;
121 crate::share::private_dirs(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
122 let target = dir.join(new_stamp(&dir, now_ms));
123 crate::share::write_atomically(&target, bytes)?;
124 prune_file(&dir, now_ms, false);
125 Ok(target)
126}
127
128pub fn keep_sealed(root: &Path, part: &str, file: &str, hash: &str, size: u64, now_ms: i64) -> Result<(), String> {
131 let dir = below(root, part, file).ok_or_else(|| format!("{file}: not a file's name"))?;
132 crate::share::private_dirs(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
133 let target = dir.join(format!("{}{SEALED}", new_stamp(&dir, now_ms)));
134 crate::share::write_atomically(&target, format!("{hash} {size}\n").as_bytes())?;
135 prune_file(&dir, now_ms, false);
136 Ok(())
137}
138
139fn prune_file(dir: &Path, now_ms: i64, gone: bool) {
143 for (i, version) in versions_in(dir).into_iter().enumerate() {
144 let old = now_ms - version.at > DAYS * DAY;
145 if (old && (i >= VERSIONS || gone)) || (version.sealed.is_some() && now_ms - version.at > SEALED_DAYS * DAY) {
146 let _ = std::fs::remove_file(dir.join(name_of(&version)));
147 }
148 }
149}
150
151fn name_of(version: &Version) -> String {
152 if version.sealed.is_some() { format!("{}{SEALED}", version.stamp) } else { version.stamp.clone() }
153}
154
155fn versions_in(dir: &Path) -> Vec<Version> {
157 let mut out: Vec<Version> = std::fs::read_dir(dir)
158 .into_iter()
159 .flatten()
160 .filter_map(Result::ok)
161 .filter(|e| e.file_type().is_ok_and(|t| t.is_file()))
162 .filter_map(|e| {
163 let name = e.file_name().to_string_lossy().to_string();
164 match name.strip_suffix(SEALED) {
165 Some(stamp) => {
166 let text = crate::share::read_small(&e.path())?;
168 let (hash, size) = text.trim().split_once(' ')?;
169 Some(Version { at: kept_at(stamp)?, size: size.parse().ok()?, sealed: Some(hash.to_string()), stamp: stamp.to_string() })
170 }
171 None => Some(Version { at: kept_at(&name)?, size: e.metadata().map_or(0, |m| m.len()), sealed: None, stamp: name }),
172 }
173 })
174 .collect();
175 out.sort_by(|a, b| b.at.cmp(&a.at).then_with(|| b.stamp.cmp(&a.stamp)));
176 out
177}
178
179pub fn versions(root: &Path, part: &str, file: &str) -> Vec<Version> {
181 below(root, part, file).map(|dir| versions_in(&dir)).unwrap_or_default()
182}
183
184pub fn files(root: &Path, part: &str) -> Vec<(String, Vec<Version>)> {
187 let mut out = Vec::new();
188 if let Some(dir) = part_dir(root, part) {
189 walk(&dir, &dir, &mut out);
190 }
191 out.sort_by(|a, b| b.1.first().map(|v| v.at).cmp(&a.1.first().map(|v| v.at)).then_with(|| a.0.cmp(&b.0)));
192 out
193}
194
195fn walk(top: &Path, dir: &Path, out: &mut Vec<(String, Vec<Version>)>) {
197 let versions = versions_in(dir);
198 if !versions.is_empty()
199 && let Ok(relative) = dir.strip_prefix(top)
200 {
201 out.push((relative.components().map(|c| c.as_os_str().to_string_lossy()).collect::<Vec<_>>().join("/"), versions));
202 }
203 for entry in std::fs::read_dir(dir).into_iter().flatten().filter_map(Result::ok) {
204 if entry.file_type().is_ok_and(|t| t.is_dir()) {
205 walk(top, &entry.path(), out);
206 }
207 }
208}
209
210pub fn version(root: &Path, part: &str, file: &str, stamp: &str) -> Option<Kept> {
212 kept_at(stamp)?;
213 let dir = below(root, part, file)?;
214 if dir.join(stamp).is_file() {
215 return Some(Kept::Copy(dir.join(stamp)));
216 }
217 versions_in(&dir).into_iter().find(|v| v.stamp == stamp).and_then(|v| v.sealed.map(|hash| Kept::Sealed { hash, size: v.size }))
218}
219
220pub fn prune(root: &Path, now_ms: i64, cap: u64, here: &dyn Fn(&str, &str) -> bool) {
226 let mut copies: Vec<(i64, PathBuf, u64)> = Vec::new();
227 for entry in std::fs::read_dir(root).into_iter().flatten().filter_map(Result::ok).filter(|e| e.file_type().is_ok_and(|t| t.is_dir())) {
228 let part = entry.file_name().to_string_lossy().to_string();
229 let mut listed = Vec::new();
230 walk(&entry.path(), &entry.path(), &mut listed);
231 for (file, _) in listed {
232 let Some(dir) = below(root, &part, &file) else { continue };
233 crate::share::clean_leftovers(&dir);
234 prune_file(&dir, now_ms, part != ACCOUNTS && !here(&part, &file));
238 copies.extend(versions_in(&dir).into_iter().filter(|v| v.sealed.is_none()).map(|v| (v.at, dir.join(&v.stamp), v.size)));
239 }
240 }
241 let mut total: u64 = copies.iter().map(|(_, _, size)| size).sum();
242 copies.sort_by_key(|(at, _, _)| *at);
243 for (_, path, size) in copies {
244 if total <= cap {
245 break;
246 }
247 if std::fs::remove_file(&path).is_ok() {
248 total -= size;
249 }
250 }
251}
252
253pub fn cap(root: &Path) -> u64 {
255 crate::disk::space(root).map_or(1 << 30, |(_, total)| (1u64 << 30).min(total / 20))
256}
257
258#[cfg(test)]
259mod tests {
260 use super::*;
261
262 fn scratch(name: &str) -> PathBuf {
263 let base = std::env::temp_dir().join(format!("sioul-history-{name}-{}", std::process::id()));
264 let _ = std::fs::remove_dir_all(&base);
265 std::fs::create_dir_all(&base).unwrap();
266 base
267 }
268
269 #[test]
270 fn twenty_versions_and_thirty_days() {
271 let base = scratch("kept");
272 let root = base.join("history");
273 let file = base.join("lease.md");
274 let start = 1_790_000_000_000;
275 for n in 0..40 {
277 std::fs::write(&file, format!("version {n}")).unwrap();
278 keep(&root, "notes", "files/notes/admin/lease.md", &file, start + n * DAY).unwrap();
279 }
280 let kept = versions(&root, "notes", "files/notes/admin/lease.md");
281 assert_eq!(kept.len(), 31, "{kept:?}");
282 assert_eq!(std::fs::read_to_string(root.join("notes/files/notes/admin/lease.md").join(&kept[0].stamp)).unwrap(), "version 39");
283 for n in 0..25 {
285 keep(&root, "settings", "config/config.toml", &file, start + n * 100 * DAY).unwrap();
286 }
287 assert_eq!(versions(&root, "settings", "config/config.toml").len(), VERSIONS);
288 let listed = files(&root, "notes");
289 assert_eq!(listed.len(), 1);
290 assert_eq!(listed[0].0, "files/notes/admin/lease.md");
291 assert!(matches!(version(&root, "notes", "files/notes/admin/lease.md", &kept[3].stamp), Some(Kept::Copy(_))));
292 assert!(version(&root, "notes", "files/notes/../../x", &kept[3].stamp).is_none());
294 assert!(version(&root, "../notes", "files/notes/admin/lease.md", &kept[3].stamp).is_none());
295 assert!(version(&root, "notes", "files/notes/admin/lease.md", "../../config.toml").is_none());
296 assert_eq!(kept_at(&stamp(start + 123)), Some(start + 123));
297 let _ = std::fs::remove_dir_all(&base);
298 }
299
300 #[test]
301 fn by_reference_capped_and_gone_files_expire() {
302 let base = scratch("bounds");
303 let root = base.join("history");
304 let file = base.join("scan.pdf");
305 std::fs::write(&file, vec![7u8; 10_000]).unwrap();
306 let start = 1_790_000_000_000;
307 keep_sealed(&root, "papers", "files/papers/scan.pdf", &"a".repeat(64), 10_000, start).unwrap();
309 let kept = versions(&root, "papers", "files/papers/scan.pdf");
310 assert_eq!(kept.len(), 1);
311 assert_eq!((kept[0].sealed.as_deref(), kept[0].size), (Some("a".repeat(64).as_str()), 10_000));
312 assert_eq!(version(&root, "papers", "files/papers/scan.pdf", &kept[0].stamp), Some(Kept::Sealed { hash: "a".repeat(64), size: 10_000 }));
313 keep(&root, "notes", "files/notes/gone.md", &file, start).unwrap();
315 keep(&root, "notes", "files/notes/here.md", &file, start).unwrap();
316 prune(&root, start + 40 * DAY, u64::MAX, &|_, file| file != "files/notes/gone.md");
317 assert!(versions(&root, "notes", "files/notes/gone.md").is_empty());
318 assert_eq!(versions(&root, "notes", "files/notes/here.md").len(), 1);
319 assert_eq!(versions(&root, "papers", "files/papers/scan.pdf").len(), 1);
320 prune(&root, start + 61 * DAY, u64::MAX, &|_, _| true);
321 assert!(versions(&root, "papers", "files/papers/scan.pdf").is_empty());
322 for n in 0..5 {
324 keep(&root, "notes", "files/notes/big.md", &file, start + 70 * DAY + n).unwrap();
325 }
326 prune(&root, start + 70 * DAY + 10, 25_000, &|_, _| true);
327 let left: u64 = files(&root, "notes").iter().flat_map(|(_, v)| v.iter().map(|v| v.size)).sum();
328 assert!(left <= 25_000, "{left}");
329 assert_eq!(versions(&root, "notes", "files/notes/big.md").first().map(|v| v.at), Some(start + 70 * DAY + 4), "the newest stays");
330 let _ = std::fs::remove_dir_all(&base);
331 }
332}