Skip to main content

sioul_sync/
history.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! What a shared file held before another device's change was written into it
5//! (docs/database.md, "Earlier versions"), kept on this device only and never
6//! shared: `<state>/share/history/<part>/<file>/<time>`. Of each file, the last
7//! 20 versions are kept, and all those of the last 30 days; of a file no longer
8//! here, those of the last 30 days only; never more than a cap in all, the
9//! oldest going first. A note or a paper whose content a record named is kept
10//! by reference (`<time>.sealed`): its content is sealed in the sharing folder
11//! already, and stays there long enough. Any version can be put back
12//! (`share::put_back`): the file as it is then is kept first, and the next
13//! exchange sends what was put back as a change made here.
14
15use std::path::{Path, PathBuf};
16
17/// Versions kept of each file, however old.
18pub const VERSIONS: usize = 20;
19/// Days during which every version is kept.
20pub const DAYS: i64 = 30;
21/// Days a version kept by reference stays: its content stays in the sharing
22/// folder 90 days after a record last named it (`blobs::UNUSED_DAYS`).
23pub const SEALED_DAYS: i64 = 60;
24/// A version kept by reference: its name ends so.
25const SEALED: &str = ".sealed";
26const DAY: i64 = 86_400_000;
27
28/// A version kept: its name (when it was kept), that moment (milliseconds),
29/// its size; kept by reference, its content's hash (sealed in the sharing folder).
30#[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/// Where a version is: a copy here, or a content sealed in the sharing folder.
39#[derive(Debug, PartialEq, Eq)]
40pub enum Kept {
41    Copy(PathBuf),
42    Sealed { hash: String, size: u64 },
43}
44
45/// Where versions are kept: beside the sharing's memory (`<state>/share/history`).
46pub fn root(memory: &Path) -> PathBuf {
47    memory.parent().map_or_else(|| PathBuf::from("history"), |share| share.join("history"))
48}
49
50/// A version's name: when it was kept, in UTC, to the millisecond ("2026-10-05T14-05-33.120Z").
51fn 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
56/// When a version was kept (milliseconds), from its name.
57pub 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
63/// A part's folder in the history: one plain name, never outside it.
64fn 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
72/// A file's folder of versions: its name in the records, as a path below its
73/// part's folder, never outside it, on Windows too.
74fn 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
79/// A name for a new version in `dir`, none taken.
80fn 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
88/// A file as it is now, copied before another device's change is written over
89/// it, or takes it out; then the oldest versions past what is kept go. Refused
90/// when the copy would leave the disk short (`disk::kept_free`): nothing is
91/// written over a file whose copy could not be kept.
92pub 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    // Copied under a hidden name, yours alone, then renamed: never half a version.
101    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
110/// The part of the history that holds your calendars' and contacts' versions
111/// set aside: an item changed both here and on its server, the server's
112/// version kept, yours kept here (`dav`), to be put back from the window, on
113/// a phone too, whose state folder no file manager reaches. Its files are
114/// named by their place in the data folder (`data/calendars/<account>/…`).
115pub const ACCOUNTS: &str = "accounts";
116
117/// Bytes kept as a version of `file` (a calendar's item set aside, `ACCOUNTS`),
118/// as `keep` keeps a file: under a hidden name, then renamed. Returns where.
119pub 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
128/// A file's content kept by reference: a record named it, so it is sealed in
129/// the sharing folder already; nothing copied.
130pub 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
139/// A file's versions past what is kept taken out: beyond the newest 20, those
140/// older than 30 days (all of those when the file is no longer here), and
141/// those kept by reference past `SEALED_DAYS`.
142fn 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
155/// The versions in a file's folder, the newest first.
156fn 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                    // "<hash> <size>": a few bytes, read as such.
167                    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
179/// A file's versions, the newest first.
180pub 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
184/// The files of a part with versions kept, the one changed last first: each
185/// with its versions, the newest first.
186pub 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
195/// A folder holding versions is a file's; the others hold files' folders.
196fn 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
210/// Where a version kept is, if it is there: never outside the history.
211pub 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
220/// The history held within bounds, now and then: each file's versions as
221/// `keep` keeps them, those of a file no longer here (`here` says which are)
222/// past 30 days, then the oldest copies past `cap` bytes in all. Your
223/// versions of calendars' and contacts' items (`ACCOUNTS`) are kept as those
224/// of a file still here: the last 20, and all those of the last 30 days.
225pub 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            // Your versions of your calendars' and contacts' items are kept as
235            // a file still here's, the item on its server or not: no shared
236            // store holds them (review of 10 October 2026, R4).
237            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
253/// What the history may hold in all: a GB, or a twentieth of the disk if less.
254pub 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        // One version a day for 40 days: all of the last 30 days stay, and never fewer than 20.
276        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        // Rarely changed: twenty kept, however old.
284        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        // Never outside the history.
293        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        // A content a record named: kept by reference, a few bytes here.
308        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        // References go after 60 days, versions of a file no longer here after 30, whatever their number.
314        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        // Past the cap, the oldest copies go first.
323        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}