Skip to main content

sioul_sync/
blobs.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Notes and papers in the shared folder, one file at a time (docs/database.md,
5//! "Notes and papers"). Each file is compressed (gzip), sealed in pieces
6//! (XChaCha20-Poly1305), and kept once under a name made from its content:
7//! `blobs/<name>`. A record in the log says which content each file holds
8//! (`share`): a change sends that file alone, and a content in two places is
9//! sent once. Written once, under a hidden name then renamed: any sync app
10//! carries a new file. The folder's server sees how big each is, never its
11//! name nor what it holds.
12//!
13//! Two keys of its own, made from the records' key (HKDF-SHA-256): one seals
14//! the pieces, one names the files; the records' key itself seals nothing here.
15//!
16//! Never a whole file in memory: read, compressed and sealed a piece at a
17//! time, opened and written back the same way beside its place, and checked
18//! against its record before it takes that place.
19
20use chacha20poly1305::aead::{Aead, AeadCore, KeyInit, OsRng, Payload};
21use chacha20poly1305::{XChaCha20Poly1305, XNonce};
22use hmac::{Hmac, Mac};
23use sha2::{Digest, Sha256};
24use std::collections::{BTreeMap, BTreeSet};
25use std::io::{BufReader, BufWriter, Read, Write};
26use std::path::{Path, PathBuf};
27
28/// Files larger than this stay on their device.
29pub const LARGEST: u64 = 64 << 20;
30/// A sealed file no record points to any more is removed by its writer after
31/// this long. A device needs a superseded content only between reading the
32/// record that names it and the one that supersedes it, and a device back
33/// after a long time reads both in one exchange and writes the latest: two
34/// weeks is ample. Until 15 October 2026 it was 90 days, and some 25 to 45 MB
35/// of the owner's folder were replaced contents (docs/database.md, "Old
36/// contents go"). Earlier versions kept by reference stay less long
37/// (`history::SEALED_DAYS`), and a content used again is made fresh well
38/// within it (`FRESH_DAYS`). Only once every device of the sharing keeps
39/// them so too (`KEEPING`, `share::unused_days`): else `UNUSED_DAYS_BEFORE`.
40pub const UNUSED_DAYS: i64 = 14;
41/// How long they stay while a device of the sharing runs a Sioul from before
42/// 15 October 2026 (its entry says no `KEEPING`): it makes a content it uses
43/// again fresh only past 30 days, and keeps earlier versions by reference 60
44/// days, both counting on 90. A phone of that kind under eDrive, which keeps
45/// every sealed file it brought, putting back a version swept from the
46/// server, would name a content that no device sends again.
47pub const UNUSED_DAYS_BEFORE: i64 = 90;
48/// What a device's sealed entry says of how it keeps sealed files
49/// (`devices::Entry::keeping`), the latest this Sioul says: `KEEPING_MOVED`.
50/// 0, unsaid: an older Sioul's 90, 30 and 60.
51pub const KEEPING: u32 = KEEPING_MOVED;
52/// From 15 October 2026: two weeks, made fresh past a week, earlier versions
53/// by reference 12 days (`share::unused_days`).
54pub const KEEPING_TWO_WEEKS: u32 = 1;
55/// From 17 October 2026, the same, and a file another device took out kept
56/// among its earlier versions as a copy, never by reference
57/// (`history::keep_moved`): once every device that counts says so, the
58/// content of a note or paper taken out may leave the folder as soon as each
59/// read that it was (`share::removed_due`).
60pub const KEEPING_MOVED: u32 = 2;
61/// A content sealed in the folder longer ago than this and used again has
62/// its file made fresh (`put`), so that its writer, which may not have read
63/// the record using it again yet, never sweeps it from under that record
64/// (`sweep` keeps a file made fresh within `UNUSED_DAYS`). Half of
65/// `UNUSED_DAYS`: it must stay below it (it was 30 days, below 90).
66pub const FRESH_DAYS: i64 = UNUSED_DAYS / 2;
67/// What is sealed at once.
68const PIECE: usize = 1 << 20;
69/// What is opened is inflated this much at a time: never far past `LARGEST`.
70const INFLATED: usize = 64 << 10;
71/// How a sealed file begins.
72const MAGIC: &[u8] = b"sioul-blob 1\n";
73/// In a piece's length: the last one, which holds nothing and says the file ends there.
74const END: u32 = 1 << 31;
75/// A nonce and a tag around each piece.
76const AROUND: usize = 24 + 16;
77
78/// Why a content could not be had from the folder.
79#[derive(Debug, PartialEq, Eq)]
80pub enum Fault {
81    /// Not there yet: the sync app has not brought it, or only its empty placeholder.
82    Missing,
83    /// There, but it does not open whole, or opens to another content: damaged.
84    Broken,
85    /// It could not be written here (a folder not writable, a name a folder takes here).
86    Local(String),
87    /// No room for it here, and some left free (`disk::kept_free`).
88    Room,
89}
90
91/// The keys of sealed files, made from the records' key for this format: one
92/// seals the pieces, one names the files.
93fn keys(key: &[u8; 32]) -> ([u8; 32], [u8; 32]) {
94    let made = hkdf::Hkdf::<Sha256>::new(None, key);
95    let (mut sealing, mut naming) = ([0u8; 32], [0u8; 32]);
96    made.expand(b"sioul blobs 1: sealing", &mut sealing).expect("32 bytes is a length HKDF gives");
97    made.expand(b"sioul blobs 1: naming", &mut naming).expect("32 bytes is a length HKDF gives");
98    (sealing, naming)
99}
100
101/// A content's name in the folder: made with a key, so that it tells nothing
102/// of the content to whoever lacks it (not even that two folders hold the same file).
103pub fn name(key: &[u8; 32], hash: &str) -> String {
104    let mut mac = <Hmac<Sha256> as Mac>::new_from_slice(&keys(key).1).expect("HMAC takes any key");
105    mac.update(hash.as_bytes());
106    hex(&mac.finalize().into_bytes())
107}
108
109pub fn path(folder: &Path, key: &[u8; 32], hash: &str) -> PathBuf {
110    folder.join("blobs").join(name(key, hash))
111}
112
113fn hex(bytes: &[u8]) -> String {
114    bytes.iter().map(|b| format!("{b:02x}")).collect()
115}
116
117#[cfg(test)]
118thread_local! {
119    /// Files read through for their hash, in this test's thread.
120    pub(crate) static HASHED: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
121}
122
123/// A file's content hash (SHA-256) and size, read a piece at a time.
124pub fn hash_file(path: &Path) -> std::io::Result<(String, u64)> {
125    #[cfg(test)]
126    HASHED.with(|n| n.set(n.get() + 1));
127    let mut reading = Hashing { inner: std::fs::File::open(path)?, hasher: Sha256::new(), size: 0 };
128    std::io::copy(&mut reading, &mut std::io::sink())?;
129    Ok((hex(&reading.hasher.finalize()), reading.size))
130}
131
132/// What tells one copy of a file in the folder from another: its size, its
133/// time, and, where the system says, its place on the disk and when that
134/// changed (a copy brought again whole is another, even of the same size and time).
135pub fn fingerprint(meta: &std::fs::Metadata) -> (u64, u64, u64) {
136    let modified = meta.modified().ok().and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map_or(0, |d| d.as_nanos() as u64);
137    #[cfg(unix)]
138    let other = {
139        use std::os::unix::fs::MetadataExt;
140        meta.ino().wrapping_mul(0x9e37_79b9_7f4a_7c15) ^ (meta.ctime() as u64).wrapping_mul(1_000_000_000).wrapping_add(meta.ctime_nsec() as u64)
141    };
142    #[cfg(not(unix))]
143    let other = meta.created().ok().and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map_or(0, |d| d.as_nanos() as u64);
144    (meta.len(), modified, other)
145}
146
147/// What passes through, hashed and counted.
148struct Hashing<T> {
149    inner: T,
150    hasher: Sha256,
151    size: u64,
152}
153
154impl<R: Read> Read for Hashing<R> {
155    fn read(&mut self, buffer: &mut [u8]) -> std::io::Result<usize> {
156        let n = self.inner.read(buffer)?;
157        self.hasher.update(&buffer[..n]);
158        self.size += n as u64;
159        Ok(n)
160    }
161}
162
163impl<W: Write> Write for Hashing<W> {
164    fn write(&mut self, buffer: &[u8]) -> std::io::Result<usize> {
165        let n = self.inner.write(buffer)?;
166        self.hasher.update(&buffer[..n]);
167        self.size += n as u64;
168        Ok(n)
169    }
170
171    fn flush(&mut self) -> std::io::Result<()> {
172        self.inner.flush()
173    }
174}
175
176/// What a piece is bound to: its file, its place, and whether it is the end.
177fn bound(name: &str, index: u64, end: bool) -> String {
178    format!("blob\u{1f}{name}\u{1f}{index}{}", if end { "\u{1f}end" } else { "" })
179}
180
181/// As much as `reader` gives, up to `buffer`'s length.
182fn fill(reader: &mut impl Read, buffer: &mut [u8]) -> std::io::Result<usize> {
183    let mut filled = 0;
184    while filled < buffer.len() {
185        match reader.read(&mut buffer[filled..]) {
186            Ok(0) => break,
187            Ok(n) => filled += n,
188            Err(e) if e.kind() == std::io::ErrorKind::Interrupted => {}
189            Err(e) => return Err(e),
190        }
191    }
192    Ok(filled)
193}
194
195/// A file sealed into the folder, unless its content is there already (from
196/// any device). `hash` is its content when gathered: read again here, a file
197/// that changed meanwhile is not sent, the next look sends it. The size sealed
198/// when written here; none when it was there. One there for long (`FRESH_DAYS`)
199/// is made fresh: its writer does not take it out from under the record that
200/// names it again, and a sync app carries the new time (eDrive sends a file
201/// whose time changed), so that a copy taken out of the server comes back.
202pub fn put(folder: &Path, key: &[u8; 32], source: &Path, hash: &str) -> Result<Option<u64>, String> {
203    let target = path(folder, key, hash);
204    if let Ok(meta) = std::fs::metadata(&target)
205        && meta.len() > 0
206    {
207        let old = meta.modified().ok().and_then(|t| t.elapsed().ok()).is_some_and(|age| age.as_secs() > FRESH_DAYS as u64 * 86_400);
208        if old {
209            let _ = std::fs::File::options().write(true).open(&target).and_then(|f| f.set_modified(std::time::SystemTime::now()));
210        }
211        return Ok(None);
212    }
213    seal_into(folder, key, source, hash).map(Some)
214}
215
216/// A content sealed again over a copy in the folder that is damaged or cut
217/// (not of the size it was sealed at), or gone: the size sealed.
218pub fn put_again(folder: &Path, key: &[u8; 32], source: &Path, hash: &str) -> Result<u64, String> {
219    seal_into(folder, key, source, hash)
220}
221
222fn seal_into(folder: &Path, key: &[u8; 32], source: &Path, hash: &str) -> Result<u64, String> {
223    let target = path(folder, key, hash);
224    let name = name(key, hash);
225    let dir = folder.join("blobs");
226    crate::share::private_dirs(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
227    // A hidden name of its own: sync apps leave it alone until it is whole, and
228    // two exchanges sealing one content never write into one file.
229    let temporary = crate::share::temporary(&target);
230    let sealed = (|| {
231        let fail = |e: std::io::Error| format!("{}: {e}", temporary.display());
232        let mut reading = Hashing { inner: std::fs::File::open(source).map_err(|e| format!("{}: {e}", source.display()))?, hasher: Sha256::new(), size: 0 };
233        let mut packed = flate2::read::GzEncoder::new(&mut reading, flate2::Compression::default());
234        let mut out = BufWriter::new(crate::share::new_private(&temporary).map_err(fail)?);
235        out.write_all(MAGIC).map_err(fail)?;
236        let cipher = XChaCha20Poly1305::new((&keys(key).0).into());
237        let mut piece = vec![0u8; PIECE];
238        let mut index = 0;
239        loop {
240            let n = fill(&mut packed, &mut piece).map_err(|e| format!("{}: {e}", source.display()))?;
241            if n == 0 {
242                break;
243            }
244            if packed.get_ref().size > LARGEST {
245                return Err(format!("{}: changed while it was sealed", source.display()));
246            }
247            write_piece(&mut out, &cipher, &name, index, false, &piece[..n]).map_err(fail)?;
248            index += 1;
249        }
250        write_piece(&mut out, &cipher, &name, index, true, &[]).map_err(fail)?;
251        drop(packed);
252        if hex(&reading.hasher.finalize()) != hash {
253            return Err(format!("{}: changed while it was sealed", source.display()));
254        }
255        let file = out.into_inner().map_err(|e| fail(e.into_error()))?;
256        file.sync_all().map_err(fail)?;
257        let size = file.metadata().map_err(fail)?.len();
258        std::fs::rename(&temporary, &target).map_err(|e| format!("{}: {e}", target.display()))?;
259        Ok(size)
260    })();
261    if sealed.is_err() {
262        let _ = std::fs::remove_file(&temporary);
263    }
264    sealed
265}
266
267/// A piece sealed and written: its length (its top bit set for the end), its nonce, what is sealed.
268fn write_piece(out: &mut impl Write, cipher: &XChaCha20Poly1305, name: &str, index: u64, end: bool, plain: &[u8]) -> std::io::Result<()> {
269    let nonce = XChaCha20Poly1305::generate_nonce(&mut OsRng);
270    let sealed = cipher.encrypt(&nonce, Payload { msg: plain, aad: bound(name, index, end).as_bytes() }).map_err(|_| std::io::Error::other("sealing"))?;
271    let length = (nonce.len() + sealed.len()) as u32 | if end { END } else { 0 };
272    out.write_all(&length.to_be_bytes())?;
273    out.write_all(&nonce)?;
274    out.write_all(&sealed)
275}
276
277/// A content opened into `target` (a new hidden file beside the one it will
278/// replace, `share::temporary`), and checked against its hash: whole and
279/// right, or nothing (`target` removed). Never more than `LARGEST` bytes
280/// written, whatever the file says. Opened from the folder, else from the
281/// copy fetched from its server beside it (`remote::overlay`, brought by
282/// `remote::fetch_wanted`): a copy fetched that does not open whole is taken
283/// out there, and fetched again.
284pub fn get(folder: &Path, key: &[u8; 32], hash: &str, target: &Path) -> Result<(), Fault> {
285    get_unless(folder, key, hash, target, false)
286}
287
288/// As `get`, the folder's copy passed over as damaged when `damaged`: found
289/// so before, and the same copy still (`share::receive`).
290pub(crate) fn get_unless(folder: &Path, key: &[u8; 32], hash: &str, target: &Path, damaged: bool) -> Result<(), Fault> {
291    let here = if damaged { Err(Fault::Broken) } else { get_from(folder, key, hash, target) };
292    let Err(fault @ (Fault::Missing | Fault::Broken)) = here else { return here };
293    let Some(fetched) = crate::remote::overlay(folder) else { return Err(fault) };
294    match get_from(&fetched, key, hash, target) {
295        Ok(()) => Ok(()),
296        Err(Fault::Broken) => {
297            let _ = std::fs::remove_file(path(&fetched, key, hash));
298            Err(fault)
299        }
300        Err(Fault::Missing) => Err(fault),
301        Err(other) => Err(other),
302    }
303}
304
305fn get_from(folder: &Path, key: &[u8; 32], hash: &str, target: &Path) -> Result<(), Fault> {
306    let opened = open_into(folder, key, hash, target);
307    if opened.is_err() {
308        let _ = std::fs::remove_file(target);
309    }
310    opened
311}
312
313/// The sealed file at `path` opens whole to the content `hash`, checked as
314/// `get` checks it, nothing written: a copy fetched is kept only so
315/// (`remote::fetch_wanted`).
316pub fn check(path: &Path, key: &[u8; 32], hash: &str) -> Result<(), Fault> {
317    let Some(file) = present(path) else { return Err(Fault::Missing) };
318    let mut reader = BufReader::new(file);
319    begins(&mut reader)?;
320    unseal(reader, key, &name(key, hash), hash, std::io::sink(), &|e| Fault::Local(format!("{}: {e}", path.display()))).map(|_| ())
321}
322
323/// A sealed file there, opened; none when it is not, or only its empty
324/// placeholder (files kept on demand), which is not a damaged one.
325fn present(path: &Path) -> Option<std::fs::File> {
326    let file = std::fs::File::open(path).ok()?;
327    (file.metadata().map_or(0, |m| m.len()) > 0).then_some(file)
328}
329
330/// A sealed file begins as one does.
331fn begins(reader: &mut impl Read) -> Result<(), Fault> {
332    let mut magic = [0u8; MAGIC.len()];
333    if reader.read_exact(&mut magic).is_err() || magic != MAGIC {
334        return Err(Fault::Broken);
335    }
336    Ok(())
337}
338
339fn open_into(folder: &Path, key: &[u8; 32], hash: &str, target: &Path) -> Result<(), Fault> {
340    let name = name(key, hash);
341    let Some(file) = present(&folder.join("blobs").join(&name)) else { return Err(Fault::Missing) };
342    let mut reader = BufReader::new(file);
343    begins(&mut reader)?;
344    let local = |e: std::io::Error| Fault::Local(format!("{}: {e}", target.display()));
345    // Yours alone (0600 on Unix): it takes the place of the file here.
346    let out = BufWriter::new(crate::share::new_private(target).map_err(local)?);
347    let out = unseal(reader, key, &name, hash, out, &local)?;
348    out.into_inner().map_err(|e| local(e.into_error()))?.sync_all().map_err(local)
349}
350
351/// A sealed file's pieces, past its beginning, opened into `out` and checked
352/// against the content's hash: `out` given back once all of it opened whole and right.
353fn unseal<W: Write>(mut reader: impl Read, key: &[u8; 32], name: &str, hash: &str, out: W, local: &dyn Fn(std::io::Error) -> Fault) -> Result<W, Fault> {
354    let mut unpacked = flate2::write::GzDecoder::new(Hashing { inner: out, hasher: Sha256::new(), size: 0 });
355    let cipher = XChaCha20Poly1305::new((&keys(key).0).into());
356    // A write that fails on bad compressed data, or on data past its end, is
357    // a damaged file; on anything else, a problem here.
358    let fault = |e: std::io::Error| if matches!(e.kind(), std::io::ErrorKind::InvalidInput | std::io::ErrorKind::InvalidData | std::io::ErrorKind::WriteZero) { Fault::Broken } else { local(e) };
359    let mut index = 0;
360    loop {
361        let mut length = [0u8; 4];
362        // Cut short before its end: damaged (or still arriving, which a sync app never shows).
363        reader.read_exact(&mut length).map_err(|_| Fault::Broken)?;
364        let length = u32::from_be_bytes(length);
365        let (end, length) = (length & END != 0, (length & !END) as usize);
366        if !(AROUND..=PIECE + AROUND).contains(&length) {
367            return Err(Fault::Broken);
368        }
369        let mut sealed = vec![0u8; length];
370        reader.read_exact(&mut sealed).map_err(|_| Fault::Broken)?;
371        let (nonce, body) = sealed.split_at(24);
372        let plain = cipher.decrypt(XNonce::from_slice(nonce), Payload { msg: body, aad: bound(name, index, end).as_bytes() }).map_err(|_| Fault::Broken)?;
373        if end {
374            break;
375        }
376        // Inflated a little at a time: a piece that inflates past the largest file is stopped there.
377        for part in plain.chunks(INFLATED) {
378            unpacked.write_all(part).map_err(fault)?;
379            if unpacked.get_ref().size > LARGEST {
380                return Err(Fault::Broken);
381            }
382        }
383        index += 1;
384    }
385    // Nothing after the end.
386    if reader.read(&mut [0u8; 1]).map_or(true, |n| n > 0) {
387        return Err(Fault::Broken);
388    }
389    let Hashing { inner, hasher, .. } = unpacked.finish().map_err(fault)?;
390    if hex(&hasher.finalize()) != hash {
391        return Err(Fault::Broken);
392    }
393    Ok(inner)
394}
395
396/// The sealed files this device wrote that no record points to any more,
397/// removed once that has lasted `UNUSED_DAYS`. `mine` holds when a record last
398/// pointed to each (milliseconds), `used` the names records point to now, or
399/// named lately. One whose file was made fresh lately (sealed again, named
400/// again by another device) stays.
401pub fn sweep(folder: &Path, mine: &mut BTreeMap<String, i64>, used: &BTreeSet<String>, now_ms: i64) {
402    sweep_after(folder, mine, used, now_ms, UNUSED_DAYS);
403}
404
405/// As `sweep`, after `days` unused: `UNUSED_DAYS`, or `UNUSED_DAYS_BEFORE`
406/// while a device of the sharing runs an older Sioul (`share::unused_days`).
407pub fn sweep_after(folder: &Path, mine: &mut BTreeMap<String, i64>, used: &BTreeSet<String>, now_ms: i64, days: i64) {
408    let unused = days * 86_400_000;
409    // Said used again at most once a day: the time counts in weeks, and the
410    // memory holding it is written only when it changes (a computer's held
411    // 25 MB, rewritten at every exchange for these times alone, 8 October 2026).
412    // So a content goes a day early at most: `UNUSED_DAYS` less a day after
413    // its last use, which earlier versions kept by reference stay within
414    // (`history::SEALED_DAYS`).
415    let day = 86_400_000;
416    mine.retain(|name, last| {
417        if used.contains(name) {
418            if now_ms - *last >= day {
419                *last = now_ms;
420            }
421            return true;
422        }
423        if now_ms - *last < unused {
424            return true;
425        }
426        let path = folder.join("blobs").join(name);
427        let fresh = std::fs::metadata(&path).ok().and_then(|m| m.modified().ok()).and_then(|t| t.elapsed().ok()).is_some_and(|age| (age.as_millis() as i64) < unused);
428        if fresh {
429            *last = now_ms;
430            return true;
431        }
432        let _ = std::fs::remove_file(path);
433        false
434    });
435}
436
437/// The sealed files of the others' notes and papers this device waits for:
438/// their records came, and the files they name are neither in the folder nor
439/// fetched beside it yet (docs/database.md, "Sealed files fetched when
440/// waited for"). The exchange says which (`share::exchange`), the pull's
441/// fetch brings them from the folder's server (`remote::fetch_wanted`).
442/// Kept in `share/wanted.toml` beside the sharing's memory, never shared;
443/// none when nothing waits.
444#[derive(Debug, Default, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
445pub struct Wanted {
446    /// By the sealed file's name in the folder (`name`).
447    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
448    pub blobs: BTreeMap<String, Want>,
449    /// Sioul keeping the folder itself: the texts' media a computer lacks,
450    /// by the sealed file's name, asked by the texts' page as it brings them
451    /// (the window's `texts::fetch_media`), fetched by name as the others
452    /// are (`remote`); never touched by the exchange, which asks `blobs`.
453    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
454    pub media: BTreeMap<String, Want>,
455    /// When a large one last came on a metered or slow connection (Unix seconds).
456    #[serde(default)]
457    pub large_at: i64,
458}
459
460/// A sealed file waited for.
461#[derive(Debug, Default, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
462pub struct Want {
463    /// Its content's hash: the copy fetched is checked against it (`check`).
464    #[serde(default)]
465    pub hash: String,
466    /// The file's size, as its record says it (bytes).
467    #[serde(default)]
468    pub size: u64,
469    /// Waited for since (Unix seconds).
470    #[serde(default)]
471    pub since: i64,
472    /// Fetches in a row that did not bring it whole, and none tried before `again` (Unix seconds).
473    #[serde(default)]
474    pub tries: u32,
475    #[serde(default)]
476    pub again: i64,
477}
478
479impl Wanted {
480    /// Beside the sharing's memory: `<state>/share/wanted.toml`.
481    pub fn path(memory: &Path) -> PathBuf {
482        memory.with_file_name("wanted.toml")
483    }
484
485    pub fn load(memory: &Path) -> Wanted {
486        std::fs::read_to_string(Wanted::path(memory)).ok().and_then(|text| toml::from_str(&text).ok()).unwrap_or_default()
487    }
488
489    /// Changed under its lock (the exchange and the fetch, in two processes
490    /// maybe), written only when it changed; taken out once nothing waits.
491    pub fn change(memory: &Path, change: impl FnOnce(&mut Wanted)) -> Result<(), String> {
492        let path = Wanted::path(memory);
493        sioul_core::filelock::with_lock(&path, || {
494            let before = Wanted::load(memory);
495            let mut wanted = before.clone();
496            change(&mut wanted);
497            if wanted == before {
498                return Ok(());
499            }
500            if wanted.blobs.is_empty() && wanted.media.is_empty() {
501                return match std::fs::remove_file(&path) {
502                    Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(format!("{}: {e}", path.display())),
503                    _ => Ok(()),
504                };
505            }
506            let text = toml::to_string(&wanted).map_err(|e| e.to_string())?;
507            crate::share::write_atomically(&path, format!("# Sioul: the others' sealed notes and papers this device waits for (docs/database.md). Never shared.\n{text}").as_bytes())
508        })
509    }
510}
511
512#[cfg(test)]
513mod tests {
514    use super::*;
515
516    fn scratch(name: &str) -> PathBuf {
517        let dir = std::env::temp_dir().join(format!("sioul-blobs-{name}-{}", std::process::id()));
518        let _ = std::fs::remove_dir_all(&dir);
519        std::fs::create_dir_all(&dir).unwrap();
520        dir
521    }
522
523    fn temporary(base: &Path) -> PathBuf {
524        crate::share::temporary(&base.join("scan.pdf"))
525    }
526
527    #[test]
528    fn sealed_whole_and_opened_whole() {
529        let base = scratch("whole");
530        let folder = base.join("vault");
531        let key = [3u8; 32];
532        // Larger than a piece, and hard to compress: several pieces.
533        let mut bytes = Vec::new();
534        let mut x = 7u32;
535        while bytes.len() < 2 * PIECE + 1000 {
536            x = x.wrapping_mul(1_103_515_245).wrapping_add(12_345);
537            bytes.extend_from_slice(&x.to_le_bytes());
538        }
539        let source = base.join("scan.pdf");
540        std::fs::write(&source, &bytes).unwrap();
541        let (hash, size) = hash_file(&source).unwrap();
542        assert_eq!(size, bytes.len() as u64);
543        let sealed_size = put(&folder, &key, &source, &hash).unwrap().unwrap();
544        assert_eq!(std::fs::metadata(path(&folder, &key, &hash)).unwrap().len(), sealed_size);
545        assert_eq!(put(&folder, &key, &source, &hash).unwrap(), None, "a content already there is not sealed again");
546        let target = temporary(&base);
547        get(&folder, &key, &hash, &target).unwrap();
548        assert_eq!(std::fs::read(&target).unwrap(), bytes);
549        // Another key: nothing opens.
550        let other = temporary(&base);
551        assert_eq!(get(&folder, &[4u8; 32], &hash, &other), Err(Fault::Missing), "another key names it otherwise");
552        let sealed = path(&folder, &key, &hash);
553        std::fs::copy(&sealed, path(&folder, &[4u8; 32], &hash)).unwrap();
554        assert_eq!(get(&folder, &[4u8; 32], &hash, &other), Err(Fault::Broken));
555        assert!(!other.exists(), "nothing half written is left");
556        // Damaged in the middle, cut short, longer than its end, emptied: never opened to something else.
557        let whole = std::fs::read(&sealed).unwrap();
558        let mut damaged = whole.clone();
559        damaged[whole.len() / 2] ^= 0x40;
560        for (bytes, fault) in [(damaged, Fault::Broken), (whole[..whole.len() - 50].to_vec(), Fault::Broken), ([whole.clone(), b"more".to_vec()].concat(), Fault::Broken), (Vec::new(), Fault::Missing)] {
561            std::fs::write(&sealed, &bytes).unwrap();
562            assert_eq!(get(&folder, &key, &hash, &temporary(&base)), Err(fault));
563        }
564        // Sealed again over a damaged copy: whole again.
565        put_again(&folder, &key, &source, &hash).unwrap();
566        get(&folder, &key, &hash, &temporary(&base)).unwrap();
567        // The name says nothing of the content: not its hash, not what the records' key alone would make.
568        assert!(!name(&key, &hash).contains(&hash[..16]));
569        let mut plain = <Hmac<Sha256> as Mac>::new_from_slice(&key).unwrap();
570        plain.update(hash.as_bytes());
571        assert_ne!(name(&key, &hash), hex(&plain.finalize().into_bytes()));
572        let _ = std::fs::remove_dir_all(&base);
573    }
574
575    /// A copy fetched is checked as `get` checks it, nothing written: whole
576    /// and right, damaged, of another content, or only a placeholder.
577    #[test]
578    fn a_copy_is_checked_whole_without_being_written() {
579        let base = scratch("check");
580        let folder = base.join("vault");
581        let key = [6u8; 32];
582        let source = base.join("note.md");
583        std::fs::write(&source, "a note\n".repeat(300)).unwrap();
584        let (hash, _) = hash_file(&source).unwrap();
585        put(&folder, &key, &source, &hash).unwrap();
586        let sealed = path(&folder, &key, &hash);
587        assert_eq!(check(&sealed, &key, &hash), Ok(()));
588        assert_eq!(check(&sealed, &key, &"0".repeat(64)), Err(Fault::Broken), "another content");
589        let whole = std::fs::read(&sealed).unwrap();
590        let mut damaged = whole.clone();
591        damaged[whole.len() - 30] ^= 0x01;
592        std::fs::write(&sealed, &damaged).unwrap();
593        assert_eq!(check(&sealed, &key, &hash), Err(Fault::Broken));
594        std::fs::write(&sealed, b"").unwrap();
595        assert_eq!(check(&sealed, &key, &hash), Err(Fault::Missing));
596        assert_eq!(check(&base.join("none"), &key, &hash), Err(Fault::Missing));
597        assert_eq!(std::fs::read_dir(folder.join("blobs")).unwrap().count(), 1, "nothing written beside");
598        let _ = std::fs::remove_dir_all(&base);
599    }
600
601    #[test]
602    fn a_bomb_stops_at_the_largest_file() {
603        let base = scratch("bomb");
604        let folder = base.join("vault");
605        let key = [5u8; 32];
606        // A key holder's file: pieces of zeros that inflate a thousand times.
607        let zeros = vec![0u8; 3 * PIECE];
608        let mut packed = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::best());
609        for _ in 0..30 {
610            packed.write_all(&zeros).unwrap();
611        }
612        let packed = packed.finish().unwrap();
613        let hash = "0".repeat(64);
614        let name = name(&key, &hash);
615        let cipher = XChaCha20Poly1305::new((&keys(&key).0).into());
616        let mut out = MAGIC.to_vec();
617        for (index, piece) in packed.chunks(PIECE).enumerate() {
618            write_piece(&mut out, &cipher, &name, index as u64, false, piece).unwrap();
619        }
620        write_piece(&mut out, &cipher, &name, packed.chunks(PIECE).count() as u64, true, &[]).unwrap();
621        std::fs::create_dir_all(folder.join("blobs")).unwrap();
622        std::fs::write(folder.join("blobs").join(&name), &out).unwrap();
623        let target = temporary(&base);
624        assert_eq!(get(&folder, &key, &hash, &target), Err(Fault::Broken));
625        assert!(!target.exists());
626        let _ = std::fs::remove_dir_all(&base);
627    }
628
629    #[test]
630    fn a_file_changed_while_sealed_is_not_sent() {
631        let base = scratch("changed");
632        let folder = base.join("vault");
633        let source = base.join("note.md");
634        std::fs::write(&source, "before").unwrap();
635        let (hash, _) = hash_file(&source).unwrap();
636        std::fs::write(&source, "after, longer").unwrap();
637        assert!(put(&folder, &[1u8; 32], &source, &hash).is_err());
638        assert!(!path(&folder, &[1u8; 32], &hash).exists());
639        assert_eq!(std::fs::read_dir(folder.join("blobs")).unwrap().count(), 0, "no hidden file left behind");
640        let _ = std::fs::remove_dir_all(&base);
641    }
642
643    /// Two weeks unused, gone (90 days until 15 October 2026: this test's
644    /// "recent" was 20 days, "unused" 100; the times now follow `UNUSED_DAYS`).
645    #[test]
646    fn unused_ones_go_after_two_weeks() {
647        let base = scratch("sweep");
648        let folder = base.join("vault");
649        std::fs::create_dir_all(folder.join("blobs")).unwrap();
650        let old = std::time::SystemTime::now() - std::time::Duration::from_secs(100 * 86_400);
651        for name in ["used", "unused", "recent", "fresh", "just-past"] {
652            std::fs::write(folder.join("blobs").join(name), "x").unwrap();
653            if name != "fresh" {
654                std::fs::File::options().write(true).open(folder.join("blobs").join(name)).unwrap().set_modified(old).unwrap();
655            }
656        }
657        let day = 86_400_000;
658        let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_millis() as i64;
659        let mut mine: BTreeMap<String, i64> = [
660            ("used".to_string(), now - 100 * day),
661            ("unused".to_string(), now - 100 * day),
662            ("recent".to_string(), now - (UNUSED_DAYS - 1) * day),
663            ("fresh".to_string(), now - 100 * day),
664            ("just-past".to_string(), now - UNUSED_DAYS * day - 1),
665        ]
666        .into();
667        sweep(&folder, &mut mine, &["used".to_string()].into(), now);
668        assert!(folder.join("blobs/used").exists() && folder.join("blobs/recent").exists());
669        assert!(folder.join("blobs/fresh").exists(), "sealed or named again lately: kept");
670        assert!(!folder.join("blobs/unused").exists());
671        assert!(!folder.join("blobs/just-past").exists(), "two weeks and a moment: gone");
672        assert_eq!(mine.get("used"), Some(&now));
673        let _ = std::fs::remove_dir_all(&base);
674    }
675
676    /// A content used again is made fresh when it was sealed more than
677    /// `FRESH_DAYS` ago, so that its writer's sweep, two weeks after its last
678    /// use, never takes it from under the record using it again; a recent one
679    /// is left as it is (its sync app has nothing to carry).
680    #[test]
681    fn a_content_used_again_is_made_fresh_within_the_sweeps_time() {
682        const { assert!(FRESH_DAYS < UNUSED_DAYS && FRESH_DAYS > 0) };
683        let base = scratch("fresh");
684        let folder = base.join("vault");
685        let key = [8u8; 32];
686        let day = std::time::Duration::from_secs(86_400);
687        for (n, age, touched) in [(1, FRESH_DAYS + 1, true), (2, FRESH_DAYS - 1, false)] {
688            let source = base.join(format!("note-{n}.md"));
689            std::fs::write(&source, format!("note {n}\n").repeat(50)).unwrap();
690            let (hash, _) = hash_file(&source).unwrap();
691            assert!(put(&folder, &key, &source, &hash).unwrap().is_some());
692            let sealed = path(&folder, &key, &hash);
693            let then = std::time::SystemTime::now() - day * age as u32;
694            std::fs::File::options().write(true).open(&sealed).unwrap().set_modified(then).unwrap();
695            assert_eq!(put(&folder, &key, &source, &hash).unwrap(), None, "there already: not sealed again");
696            let modified = std::fs::metadata(&sealed).unwrap().modified().unwrap();
697            assert_eq!(modified > then + day, touched, "sealed {age} days ago");
698            // Made fresh, the sweep keeps it whatever its writer last knew of it.
699            let mut mine: BTreeMap<String, i64> = [(name(&key, &hash), 0)].into();
700            let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_millis() as i64;
701            sweep(&folder, &mut mine, &BTreeSet::new(), now);
702            assert!(sealed.exists(), "sealed {age} days ago, used again: kept");
703        }
704        let _ = std::fs::remove_dir_all(&base);
705    }
706}