Skip to main content

sioul_sync/
share.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! What only this computer keeps, shared with your other computers through a
5//! folder your sync already carries: Nextcloud, Dropbox, Syncthing
6//! (docs/database.md, "The log").
7//!
8//! Mail, contacts, the agenda and tasks travel by their servers; projects and
9//! notes by the folder that holds them. The rest lives in small files here:
10//! settings, who may write to you, ties, time, drafts, health, lists kept
11//! on this computer. Each computer has a name, a UUID made once,
12//! and appends each change it finds in them, as one sealed record, to its own
13//! file in the folder (`<computer>-<round>.jsonl`): one writer per file, so
14//! the sync never makes conflicted copies.
15//!
16//! Files stay the truth: Sioul reads and writes them as before. A change is
17//! found by comparing them with what they held at the last look; a record from
18//! another computer is written into them. For each entry (a setting, a line of
19//! a list, a session, a whole file) the record with the later clock wins: a
20//! hybrid logical clock, the time made strictly increasing here and never
21//! behind a record already read. A change is dated by its file's time, so a
22//! change made here before another computer's, but found after, still loses.
23//!
24//! Sealed: each record is encrypted here (XChaCha20-Poly1305) with a key made
25//! from a passphrase (Argon2id) and kept in each computer's keyring. The
26//! folder and its server see which computer wrote, when and how much, never
27//! what.
28//!
29//! What is shared comes in parts (`PARTS`: settings, senders, the spam
30//! filter's table and label logs, health, time, drafts, projects, lists,
31//! notes, papers), each switched on or off on each device. Notes and papers travel
32//! one file at a time, each sealed apart in the folder (`blobs`), their
33//! records saying which content each file holds; two devices changing one
34//! file keep both versions. The spam filter's table is sealed apart too: one
35//! file of a few megabytes, read in every exchange (`SPAM_TABLE`). Before another
36//! device's change is written into a file here, the file as it was is kept on
37//! this device (`history`), to be put back.
38
39use base64::Engine;
40use base64::engine::general_purpose::STANDARD as B64;
41use chacha20poly1305::aead::rand_core::RngCore;
42use chacha20poly1305::aead::{Aead, AeadCore, KeyInit, OsRng, Payload};
43use chacha20poly1305::{XChaCha20Poly1305, XNonce};
44use serde::{Deserialize, Serialize};
45use sioul_core::config::Config;
46use std::collections::{BTreeMap, BTreeSet};
47use std::io::Write;
48use std::path::{Path, PathBuf};
49
50/// The keyring's name for the key made from the passphrase.
51pub const KEY_NAME: &str = "Sharing key";
52
53/// Between the parts of a setting's name.
54const SEP: char = '\u{1f}';
55/// Before an element's identity, in a list split by elements.
56const MARK: char = '\u{1e}';
57/// Files larger than this stay here.
58const LARGEST: u64 = 16 << 20;
59/// What a computer appends past its last full round before it opens a new
60/// one, restating every entry it holds (`new_round`): old rounds can then go.
61const ROUND_SIZE: u64 = 1 << 20;
62/// A computer's file past this size is continued in a new round of its own,
63/// nothing restated (docs/database.md, "The log"): a server sent the file whole
64/// at each change (WebDAV has no append) is sent a small one, never the opening
65/// of a full round (on a computer, a megabyte or two) again and again.
66const SEGMENT: u64 = 32 << 10;
67/// While the connection is metered, or measured slow (`set_frugal`), a full
68/// round, a megabyte or two sent at once, waits, as long as this computer
69/// appended less than this since the last one.
70const FRUGAL_ROUND_SIZE: u64 = 4 << 20;
71
72/// The sharings (by their memory) whose connection is metered or slow: full
73/// rounds wait there (`FRUGAL_ROUND_SIZE`).
74static FRUGAL: std::sync::Mutex<BTreeSet<PathBuf>> = std::sync::Mutex::new(BTreeSet::new());
75
76/// Said by the window before its exchanges: the connection is metered, or
77/// measured slow (`remote::Sending::slow`); a full round, sent whole, then
78/// waits for a better one, within `FRUGAL_ROUND_SIZE`. `memory`: the sharing's.
79pub fn set_frugal(memory: &Path, on: bool) {
80    if let Ok(mut frugal) = FRUGAL.lock() {
81        if on {
82            frugal.insert(memory.to_path_buf());
83        } else {
84            frugal.remove(memory);
85        }
86    }
87}
88
89fn frugal(memory: &Path) -> bool {
90    FRUGAL.lock().is_ok_and(|f| f.contains(memory))
91}
92
93/// While a bulk import goes on (`set_importing`), full rounds wait until this
94/// device appended this much since the last one: the import's records go out
95/// once, in the rounds that continue the last full one, and a single full
96/// round follows the import, not one every few megabytes of it.
97const IMPORT_ROUND_SIZE: u64 = 64 << 20;
98
99/// The sharings (by their memory) whose device imports in bulk now (`set_importing`).
100static IMPORTING: std::sync::Mutex<BTreeSet<PathBuf>> = std::sync::Mutex::new(BTreeSet::new());
101
102/// Said before each exchange, by the background step and the window alike:
103/// a bulk import goes on here (a phone's first import of its texts, or one
104/// it fell behind in), so that no full round restating every record starts
105/// meanwhile (`IMPORT_ROUND_SIZE`). On 8 October 2026 a first import of
106/// 10 MB of texts made six full rounds, 63 MB in all, each sent twice.
107pub fn set_importing(memory: &Path, on: bool) {
108    if let Ok(mut importing) = IMPORTING.lock() {
109        if on {
110            importing.insert(memory.to_path_buf());
111        } else {
112            importing.remove(memory);
113        }
114    }
115}
116
117fn importing(memory: &Path) -> bool {
118    IMPORTING.lock().is_ok_and(|i| i.contains(memory))
119}
120/// A device finding its own file ahead of its memory twice within this
121/// (milliseconds) shares its name with another device (`share-twin`).
122const TWIN_WITHIN: i64 = 6 * 60 * 60_000;
123/// Entries taken out are remembered this long, so an old copy does not bring
124/// them back, and each full round restates them (`new_round`): as long as a
125/// device may be away and still count (`SILENT_DAYS`). A device you forgot,
126/// or one another replaces, no longer holds the others' rounds back; back
127/// within that time, it counts again and reads from a full round, which
128/// must still say what was taken out meanwhile (but the lines of the logs
129/// every device ages out by itself: `AGED_TOMBSTONE_DAYS`). Until 17 October
130/// 2026 it was 90 days, and such a device back after more than 90 days kept
131/// for good what the others had taken out (review of 17 October 2026, M1).
132const TOMBSTONE_DAYS: i64 = SILENT_DAYS;
133const _: () = assert!(TOMBSTONE_DAYS >= SILENT_DAYS);
134/// The lines of the logs every device ages out by itself (`ages_out`) taken
135/// out are remembered this long, past the longest one of them keeps a line
136/// anywhere (its writer `KEPT_DAYS`, the others `LATE_DAYS` more): a line
137/// older than that is never shown and goes by itself on a device back after
138/// longer. They are many, a line for each call and for each message of the
139/// phone's, each a few hundred bytes (a message's padded), its whole line its
140/// name, and each full round restates them: 90 days, as before 17 October 2026.
141const AGED_TOMBSTONE_DAYS: i64 = 90;
142const _: () = assert!(
143    AGED_TOMBSTONE_DAYS > sioul_core::calls::KEPT_DAYS + sioul_core::calls::LATE_DAYS
144        && AGED_TOMBSTONE_DAYS > sioul_core::phonemsgs::KEPT_DAYS + sioul_core::phonemsgs::LATE_DAYS
145        && AGED_TOMBSTONE_DAYS > sioul_core::texts::KEPT_DAYS + sioul_core::texts::LATE_DAYS
146);
147/// A computer silent this long no longer holds back the removal of old rounds.
148const SILENT_DAYS: i64 = 180;
149/// How long a file must stay empty, or gone, before its entries count as taken
150/// out: one being written, a full disk or a crash never takes everything out
151/// everywhere at once.
152const EMPTIED_WAIT: i64 = 10 * 60_000;
153/// Notes or papers gone many at once from a folder still there (deleted in
154/// bulk, a folder moved within the notes): held this long from when they
155/// went (`Sealed::gone`), said on the Porch with "Put them back" and "Take
156/// them out now" (`withheld`), then taken out everywhere by themselves,
157/// unless they came back. Nothing has to be pressed (until 17 October 2026,
158/// they waited for "Take them out everywhere", however long that took). Two
159/// weeks, as replaced contents stay (`blobs::UNUSED_DAYS`).
160const GONE_GRACE: i64 = GONE_GRACE_DAYS * DAY;
161/// The same in days, for the window to say.
162pub const GONE_GRACE_DAYS: i64 = crate::blobs::UNUSED_DAYS;
163
164/// How long a round waits for the end of the round before (`Turn`), which a
165/// sync app may bring late, before that end is taken for lost: an hour
166/// (milliseconds) of exchanges, time asleep left out (`AWAIT_STEP`). Then the
167/// gap is said, the doses doubt what that computer marked until its next
168/// full round, and its later records are read.
169const AWAITED: i64 = 60 * 60_000;
170
171/// The most one exchange counts of the wait (`AWAITED`): the time since the
172/// one before, five minutes at most (milliseconds). A device asleep, or
173/// closed, counts none of that time: woken, its sync app may not have
174/// brought the round's end yet, and passing the round over then would ask
175/// for a full round for nothing (on a phone, megabytes of its texts: review
176/// of 10 October 2026, third pass).
177const AWAIT_STEP: i64 = 5 * 60_000;
178
179/// The longest a full round owed or asked for (`Memory::owed`) waits for a
180/// connection not metered: six hours of exchanges, counted as `AWAITED` is
181/// (milliseconds). A phone always on mobile data, which marks most doses,
182/// would otherwise leave the others saying "check" on its doses for days
183/// after one damaged line, until 4 MiB of growth.
184const OWED_METERED: i64 = 6 * 60 * 60_000;
185/// A line of a computer's records longer than this is none (the longest, a
186/// file whole of 16 MiB sealed in a line, is about 30 MiB): skipped as broken.
187const LONGEST_LINE: usize = 32 << 20;
188/// Records are read this much at a time, never a whole file.
189const READ_CHUNK: usize = 8 << 20;
190/// More files sealed apart than this gone from a folder at once (or three and
191/// more, past a fifth of it) are held, said, until you say to take them out.
192const MASS: usize = 10;
193const MINUTE: i64 = 60_000;
194const HOUR: i64 = 60 * MINUTE;
195const DAY: i64 = 24 * HOUR;
196
197/// The beginning of the names of folders whose files are sealed apart (`Shape::Files`).
198const FILES: &str = "files/";
199
200/// How a file divides into entries.
201#[derive(Debug, Clone, Copy)]
202pub enum Shape {
203    /// The file whole: a draft, an invoice.
204    Whole,
205    /// One entry per line: the sender lists.
206    Lines,
207    /// One entry per setting of a TOML file.
208    Toml(&'static Rules),
209    /// The file whole, sealed apart in the folder (`blobs`), its record saying
210    /// which content it holds: notes, papers, their pictures and PDFs; the
211    /// spam filter's table, a single file (`SPAM_TABLE`).
212    Files,
213}
214
215/// How a TOML file divides, beyond one entry per setting.
216#[derive(Debug)]
217pub struct Rules {
218    /// Lists whose elements are entries.
219    pub keyed: &'static [Keyed],
220    /// Tables shared whole, their fields together; `*` stands for any name.
221    pub whole: &'static [&'static str],
222    /// Settings that stay on each computer.
223    pub local: &'static [&'static str],
224    /// How the file divides in format 2 (`FORMAT`), when it differs: its
225    /// lists named by ids, lists shared whole until then divided element by
226    /// element. A device writes it once every device in the sharing reads it
227    /// (`format_open`); until then, these rules (format 1), which an older
228    /// Sioul reads.
229    pub next: Option<&'static Rules>,
230}
231
232impl Rules {
233    /// The rules of a file in a format: format 2's from 2 on, when they differ.
234    pub fn at(&'static self, format: u32) -> &'static Rules {
235        match self.next {
236            Some(next) if format >= 2 => next,
237            _ => self,
238        }
239    }
240}
241
242/// A list whose elements are entries, each named by some of its fields (none:
243/// the element itself), less the fields that stay on each computer.
244#[derive(Debug)]
245pub struct Keyed {
246    /// Its name; within tables, its path, `*` standing for any name
247    /// (`words.*.*.add`: each list's words you added).
248    pub list: &'static str,
249    pub by: &'static [&'static str],
250    pub local: &'static [&'static str],
251    /// Other names the list may have in a file, its entries the same: the
252    /// records always say `list`, and a file keeps the name it has. A file
253    /// with neither, of another name than its record's, takes the first.
254    pub also: &'static [&'static str],
255    /// Format 2: each element named by its own `id`, given when it is made
256    /// (`sioul_core::ids`), so that it stays one element whatever changes in
257    /// it; an element without one (made by an older Sioul, or by hand) by an
258    /// id derived from what it holds, the same on every device (`names`).
259    /// `by` then says how format 1 named them, to read its records.
260    pub ids: bool,
261}
262
263/// A keyed list as format 1 names its elements: by `by`, or by themselves.
264const fn keyed(list: &'static str, by: &'static [&'static str]) -> Keyed {
265    Keyed { list, by, local: &[], also: &[], ids: false }
266}
267
268/// A keyed list whose elements are named by their ids (format 2); `by`: as format 1 named them.
269const fn with_ids(list: &'static str, by: &'static [&'static str]) -> Keyed {
270    Keyed { list, by, local: &[], also: &[], ids: true }
271}
272
273/// Where things are on this computer, and how text reads on its screen.
274const CONFIG_LOCAL: &[&str] = &["case_store", "known_senders", "blocked_senders", "reading", "history_weeks", "letters.inbox", "dnd.background"];
275static CONFIG_RULES: Rules = Rules { keyed: &[Keyed { list: "account", by: &["id"], local: &["maildir", "history_weeks"], also: &[], ids: false }], whole: &[], local: CONFIG_LOCAL, next: Some(&CONFIG_RULES_2) };
276/// Format 2: pinned sites, routines and task kinds one each, by their ids;
277/// days off one each; the words you added to a list and those you took
278/// away, one each (docs/words.md). Until then each of these lists travels
279/// whole, and two devices adding to one in the same exchange keep only the
280/// later list.
281static CONFIG_RULES_2: Rules = Rules {
282    keyed: &[
283        Keyed { list: "account", by: &["id"], local: &["maildir", "history_weeks"], also: &[], ids: false },
284        with_ids("site", &["id"]),
285        with_ids("routine", &["id"]),
286        with_ids("tasks.kind", &["id"]),
287        with_ids("time_off", &[]),
288        keyed("words.*.*.add", &[]),
289        keyed("words.*.*.remove", &[]),
290        // A named list's names and their words: `add = { "Ma Banque" = ["mabanque.example"] }`.
291        keyed("words.*.*.add.*", &[]),
292    ],
293    whole: &[],
294    local: CONFIG_LOCAL,
295    next: None,
296};
297static HEALTH_RULES: Rules = Rules {
298    keyed: &[keyed("prescription", &["id"]), keyed("medicine", &["id"])],
299    whole: &[],
300    // An older Sioul's folder of a watch's files (until 8 October 2026), a
301    // path on its own computer: never carried, as before.
302    local: &["watch_folder"],
303    next: None,
304};
305static LINKS_RULES: Rules = Rules { keyed: &[keyed("link", &[])], whole: &[], local: &[], next: None };
306static PORCH_RULES: Rules = Rules { keyed: &[], whole: &["done.*"], local: &[], next: None };
307static MONEY_RULES: Rules = Rules { keyed: &[keyed("ignored", &[])], whole: &[], local: &[], next: None };
308static TODAY_RULES: Rules = Rules { keyed: &[keyed("aside", &[])], whole: &[], local: &[], next: None };
309static TIME_RULES: Rules = Rules { keyed: &[keyed("session", &["start", "task", "project"])], whole: &[], local: &[], next: Some(&TIME_RULES_2) };
310/// Format 2: each session by its id, so that one changed on two devices stays
311/// one; a session without one by its start, task and project, as before.
312static TIME_RULES_2: Rules = Rules { keyed: &[with_ids("session", &["start", "task", "project"])], whole: &[], local: &[], next: None };
313/// The hours' overrides and the two pauses (`sioul_core::quiet`); format 2:
314/// the lighter days one each, by their date.
315static QUIET_RULES: Rules = Rules { keyed: &[], whole: &[], local: &[], next: Some(&QUIET_RULES_2) };
316static QUIET_RULES_2: Rules = Rules { keyed: &[keyed("lighter", &[])], whole: &[], local: &[], next: None };
317/// How each day went (`sioul_core::reviews`): a date's review at the end of
318/// work and the one before sleep travel whole, so that two devices answering
319/// the same one keep the later answer, never a mix of both; the weather field by field.
320static REVIEWS_RULES: Rules = Rules { keyed: &[], whole: &["*.work", "*.night"], local: &[], next: None };
321static PLAIN_RULES: Rules = Rules { keyed: &[], whole: &[], local: &[], next: None };
322/// Do-not-disturb's switch (docs/do-not-disturb.md): a table per device, each
323/// written by its own device alone, shared whole.
324static DND_RULES: Rules = Rules { keyed: &[], whole: &["device.*"], local: &[], next: None };
325/// Who may reach you during do-not-disturb: one person each, by its id.
326static DND_PEOPLE_RULES: Rules = Rules { keyed: &[keyed("person", &["id"])], whole: &[], local: &[], next: None };
327/// Each dose that fell due (`sioul_core::doses`): one entry per field, each
328/// device's opening apart, and each device's answer whole, so that two
329/// devices never write one entry and an answer never travels in halves.
330static DOSES_RULES: Rules = Rules { keyed: &[], whole: &["dose.*.answer.*"], local: &[], next: None };
331/// What each device did to remind each dose (`sioul_core::dose_reminders`):
332/// each device's entry for a dose written by it alone, and travelling whole,
333/// so that a notice said sent and a promise of quiet never travel apart.
334static REMINDERS_RULES: Rules = Rules { keyed: &[], whole: &["dose.*.*"], local: &[], next: None };
335// Projects and budgets, at the notes folder's root, when they travel here (`share_projects`).
336// The projects' records keep their first names, `notes/sioul-cases.toml` and
337// `case`, which every version reads; a file renamed `sioul-projects.toml`, its
338// tables `[[project]]`, reads and is written under them (`sioul_core::projects`).
339static PROJECTS_RULES: Rules = Rules { keyed: &[Keyed { list: "case", by: &["id"], local: &[], also: &["project"], ids: false }], whole: &[], local: &[], next: None };
340// The bank's movements: each account by its id, each movement by its account and the bank's own id.
341static BANK_RULES: Rules = Rules { keyed: &[keyed("account", &["id"]), keyed("movement", &["account", "id"])], whole: &[], local: &[], next: None };
342static LEDGER_RULES: Rules = Rules {
343    keyed: &[
344        keyed("budget", &["id"]),
345        keyed("preset", &["id"]),
346        keyed("reserve", &["id"]),
347        keyed("bank_account", &["id"]),
348        keyed("assign", &["account", "movement"]),
349        // No name of their own: each one is itself, so two devices adding some keep both.
350        keyed("line", &[]),
351        keyed("cover", &[]),
352        keyed("mail_rule", &[]),
353        keyed("split", &[]),
354    ],
355    whole: &[],
356    local: &[],
357    next: Some(&LEDGER_RULES_2),
358};
359/// Format 2: lines, covers, mail rules, splits and presets each by its id, so
360/// that two the same stay two and one changed on two devices stays one; one
361/// without an id (made by an older Sioul, or by hand) by an id derived from
362/// what it holds.
363static LEDGER_RULES_2: Rules = Rules {
364    keyed: &[
365        keyed("budget", &["id"]),
366        with_ids("preset", &["id"]),
367        keyed("reserve", &["id"]),
368        keyed("bank_account", &["id"]),
369        keyed("assign", &["account", "movement"]),
370        with_ids("line", &[]),
371        with_ids("cover", &[]),
372        with_ids("mail_rule", &[]),
373        with_ids("split", &[]),
374    ],
375    whole: &[],
376    local: &[],
377    next: None,
378};
379static CONTRACTS_RULES: Rules = Rules { keyed: &[keyed("contract", &["id"])], whole: &[], local: &[], next: None };
380static PAPERS_RULES: Rules = Rules { keyed: &[keyed("paper", &["id"])], whole: &[], local: &[], next: None };
381/// In the notes folder, what the other parts carry: projects, budgets, the
382/// bank, contracts (projects), the papers' wallet and its folder (papers).
383const NOT_NOTES: &[&str] = &[
384    sioul_core::projects::FILE,
385    sioul_core::projects::OLD_FILE,
386    sioul_core::projects::BEFORE_RENAME,
387    sioul_core::budget::LEDGER,
388    sioul_core::bank::MANIFEST,
389    sioul_core::contracts::MANIFEST,
390    sioul_core::papers::MANIFEST,
391    sioul_core::papers::FOLDER,
392];
393
394/// The parts of what is shared, each switched on or off on each device
395/// (docs/database.md, "Parts"): settings and accounts, senders, the calls
396/// your phones screened, the messages from your phones' notifications, the
397/// spam filter's table and label logs, health, time, drafts and invoices,
398/// projects and money, lists kept here, notes, papers.
399/// (texts: the part "texts", SMS phase b, after the phone's messages.)
400pub const PARTS: [&str; 13] = ["settings", "senders", "calls", sioul_core::phonemsgs::PART, sioul_core::texts::PART, "spam", "health", "time", "drafts", "projects", "lists", "notes", "papers"];
401
402/// Stores an older Sioul shared and this one no longer has: a Garmin
403/// watch's days and the memory of its offers, taken out on 8 October 2026
404/// (docs/health.md, "Your watch"). A device on an older version may still
405/// send their changes: they are left aside, neither written nor kept
406/// waiting, and the rest of the exchange goes on. The files stay where they
407/// are on every device. texts: the first build of the texts, never
408/// released, kept them in the state folder (`state/texts/`).
409const RETIRED: &[&str] = &["data/watch/", "state/watch-offers.json", "state/texts/"];
410
411/// Whether a record's key names a store that left (`RETIRED`).
412fn retired(key: &str) -> bool {
413    let file = file_of(key);
414    RETIRED.iter().any(|store| if store.ends_with('/') { file.starts_with(store) } else { file == *store })
415}
416
417/// The phones' logs of the calls they screened (`sioul_core::calls`): each
418/// phone writes its own file, a line per call, taken out after a month by
419/// that phone alone; every device reads them all, so that a computer's Porch
420/// lists what your phones declined. Never a blocked caller's call.
421pub const CALLS_LOG: &str = "state/calls/log/";
422/// The calls marked Seen, each device's own file, a line per press: seen on
423/// one device, gone from the Porch of every device.
424pub const CALLS_SEEN: &str = "state/calls/seen/";
425/// Where the calls' part keeps its files in the records: remembered beside
426/// the notes (`files.json`, not `memory.json`), never kept as an earlier
427/// version nor copied aside when sharing starts, so that a call leaves every
428/// device after its month (`calls_store`).
429const CALLS: &str = "state/calls/";
430
431/// The phones' logs of the messages their notifications brought
432/// (`sioul_core::phonemsgs`), for the apps you switched on there: each phone
433/// writes its own file, a line per message, padded, taken out after a week
434/// by that phone alone; every device reads them all, so that a computer's
435/// Porch says what came. Never a code, never a blocked sender's.
436pub const PHONE_MESSAGES_LOG: &str = "state/phone-messages/log/";
437/// The messages marked Seen, each device's own file: seen on one device,
438/// gone from the Porch of every device.
439pub const PHONE_MESSAGES_SEEN: &str = "state/phone-messages/seen/";
440/// What each relayed notification offers a reply from a computer, each
441/// phone's own file (`sioul_core::replies::Live`): a Reply, a Mark as read,
442/// or that it went; no words, no names. Taken out with the log.
443pub const PHONE_MESSAGES_LIVE: &str = "state/phone-messages/live/";
444/// Each computer's replies to send to the phones' notifications, its own
445/// file, each line padded and sealed at rest by its writer
446/// (`sioul_core::replies::Ask`); also sent on the lane of the texts to send
447/// (`remote::place_asks`). A week, then gone.
448pub const PHONE_MESSAGES_ASK: &str = "state/phone-messages/ask/";
449/// Each phone's word on each reply it was asked to send
450/// (`sioul_core::replies::Outcome`): never the words. A week, then gone.
451pub const PHONE_MESSAGES_OUTCOME: &str = "state/phone-messages/outcome/";
452/// Where the phones' messages keep their files in the records: as the
453/// calls', remembered beside the notes, never kept as an earlier version nor
454/// copied aside when sharing starts (`calls_store`).
455const PHONE_MESSAGES: &str = "state/phone-messages/";
456
457// texts: SMS phase (b) (docs/texts.md, docs/database.md "Texts"). Each
458// store a folder of lines in the data folder (an archive), one writer per
459// file, every line sealed at rest by its writer (`textseal`) and again in
460// the folder; media travel as blobs, outside the records.
461/// Each phone's texts, the whole history (`sioul_core::texts::Text`).
462pub const TEXTS_LOG: &str = "data/texts/log/";
463/// Each computer's texts to send, a random key each.
464pub const TEXTS_SEND: &str = "data/texts/send/";
465/// Each phone's word on each text it was asked to send.
466pub const TEXTS_OUTCOME: &str = "data/texts/outcome/";
467/// Each device's marks on conversations: read, hidden (deleted in Sioul).
468pub const TEXTS_MARKS: &str = "data/texts/marks/";
469/// Where the texts keep their files in the records: as the calls', remembered
470/// beside the notes, never kept as an earlier version nor copied aside (`calls_store`).
471const TEXTS: &str = "data/texts/";
472
473/// A store, a file or an entry of the calls' part, or of the phones'
474/// messages, by its name in the records: logs of lines that leave every
475/// device after their time.
476fn calls_store(name: &str) -> bool {
477    // texts: their stores too.
478    name.starts_with(CALLS) || name.starts_with(PHONE_MESSAGES) || name.starts_with(TEXTS)
479}
480
481/// Whether an entry is a line of a log every device ages out by itself
482/// (`AGED_TOMBSTONE_DAYS`): the calls' logs, the phone's messages' logs, the
483/// texts' requests and outcomes; never the texts themselves, whose log only grows.
484fn ages_out(key: &str) -> bool {
485    key.starts_with(CALLS) || key.starts_with(PHONE_MESSAGES) || key.starts_with(TEXTS_SEND) || key.starts_with(TEXTS_OUTCOME)
486}
487
488/// The spam filter's table (`sioul_core::spam::table`), made by a training
489/// on a computer, read by every device. Sealed apart, as its content's hash:
490/// a new table is sent once, and the records stay small (whole in a record,
491/// its two megabytes would go again in every round its computer starts). A
492/// single file of Sioul's own, in its private folders: read in every
493/// exchange, a phone's background step's too. Never the language model nor
494/// the corpus beside it: they stay on the computer that made them.
495pub const SPAM_TABLE: &str = "files/spam/table.bin";
496
497/// The spam filter's label logs (`sioul_core::spam::labels`): each device
498/// writes its own file, a line per act, never rewritten; every device reads
499/// them all, so that a message you said is not spam on one is never flagged
500/// again on another, and the training on your computer learns from what you
501/// said on your phone. Small, read in every exchange.
502pub const SPAM_LABELS: &str = "state/spam/labels/";
503/// What your own spam filter moved into a Junk folder, each device's own
504/// file: no word of yours; the Porch's lane of its catches reads it, and the
505/// training, which learns from it until you say otherwise.
506pub const SPAM_MOVED: &str = "state/spam/moved/";
507/// What your own spam filter flagged where it is, each device's own file:
508/// the training's, which learns from it until you say otherwise.
509pub const SPAM_FLAGGED: &str = "state/spam/flagged/";
510
511/// Whether a device that never chose shares a part: what was shared before
512/// parts had switches, everything but notes and papers, and projects as the
513/// setting all devices followed then said (`share_projects`); the spam
514/// filter's table, which every device reads. Never the phones' messages,
515/// newer than the switches: each device shares them once you turn them on there.
516pub fn shared_by_default(part: &str, config: &Config) -> bool {
517    match part {
518        "projects" => config.share_projects,
519        "notes" | "papers" => false,
520        // A notification's words leave the phone only once you say so, on each device.
521        sioul_core::phonemsgs::PART => false,
522        // texts: read and sent through your phone, once you say so on each device.
523        sioul_core::texts::PART => false,
524        _ => true,
525    }
526}
527
528/// A file, or a folder of files, that is shared.
529#[derive(Debug, Clone)]
530pub struct Store {
531    /// Its name in the records, the same on every computer: "config/config.toml", "data/drafts/".
532    pub name: String,
533    pub path: PathBuf,
534    /// A folder: each file in it, and below, on its own.
535    pub folder: bool,
536    pub shape: Shape,
537    /// In a folder: names left out (of files sealed apart, at its top only).
538    pub skip: &'static [&'static str],
539    /// The part it belongs to (`PARTS`).
540    pub part: &'static str,
541}
542
543/// The folders Sioul keeps its files in.
544pub struct Roots {
545    pub config: PathBuf,
546    pub data: PathBuf,
547    pub state: PathBuf,
548}
549
550impl Roots {
551    pub fn here() -> Roots {
552        Roots { config: sioul_core::config::config_dir(), data: sioul_core::config::data_dir(), state: sioul_core::config::state_dir() }
553    }
554}
555
556/// What is shared, each part as a device that never chose shares it (`shared_by_default`).
557pub fn stores(config: &Config, roots: &Roots) -> Vec<Store> {
558    stores_of(config, roots, &|part| shared_by_default(part, config))
559}
560
561/// The format of what travels, as this Sioul reads and writes it
562/// (docs/database.md, "The format of what travels"). 1: as Sioul 0.0.4 and
563/// every earlier one wrote it. 2: money lines, covers, mail rules, splits,
564/// presets and time sessions each named by its own id (`Keyed::ids`);
565/// pinned sites, routines, task kinds, days off, the words you added or took
566/// away and the lighter days one each, where they travelled whole. Each
567/// device says it in its entry (`devices::Entry::format`), and writes format
568/// 2 once every device in the sharing says it (`format_open`): an older
569/// Sioul, which reads format 1 only, is never sent what it cannot read.
570pub const FORMAT: u32 = 2;
571
572/// The format each part is written in at `FORMAT`: 2 for the parts whose
573/// records changed (settings, time, projects and money), 1 for the others.
574/// Each record says its part's format when it is not 1 (`Change::f`).
575pub fn part_format(part: &str) -> u32 {
576    match part {
577        "settings" | "time" | "projects" => 2,
578        _ => 1,
579    }
580}
581
582/// What is shared of the parts `shares` keeps. Not shared: caches, how far
583/// each sync got, how pages are shown on this screen, the site notices of this
584/// computer's browser, your own PGP keys (secret keys never leave the computer
585/// they were made on).
586pub fn stores_of(config: &Config, roots: &Roots, shares: &dyn Fn(&str) -> bool) -> Vec<Store> {
587    let (c, d, s) = (&roots.config, &roots.data, &roots.state);
588    let file = |part: &'static str, name: &str, path: PathBuf, shape: Shape| Store { name: name.into(), path, folder: false, shape, skip: &[], part };
589    let folder = |part: &'static str, name: &str, path: PathBuf, shape: Shape, skip: &'static [&'static str]| Store { name: name.into(), path, folder: true, shape, skip, part };
590    let senders = |chosen: &Option<String>, name: &str| chosen.as_deref().map_or_else(|| c.join(name), sioul_core::config::expand_home);
591    let local = sioul_core::vdir::LOCAL;
592    let mut stores = vec![
593        file("settings", "config/config.toml", c.join("config.toml"), Shape::Toml(&CONFIG_RULES)),
594        file("senders", "config/known-senders.txt", senders(&config.known_senders, "known-senders.txt"), Shape::Lines),
595        file("senders", "config/blocked-senders.txt", senders(&config.blocked_senders, "blocked-senders.txt"), Shape::Lines),
596        file("senders", "config/safe-senders.txt", c.join("safe-senders.txt"), Shape::Lines),
597        file("senders", "config/neutral-senders.txt", c.join("neutral-senders.txt"), Shape::Lines),
598        file("senders", "config/restricted-senders.txt", c.join("restricted-senders.txt"), Shape::Lines),
599        // Who may reach you during do-not-disturb, beside the mail lists (docs/do-not-disturb.md).
600        file("senders", "config/dnd-people.toml", c.join(sioul_core::everywhere::PEOPLE_FILE), Shape::Toml(&DND_PEOPLE_RULES)),
601        file("settings", "data/links.toml", d.join("links.toml"), Shape::Toml(&LINKS_RULES)),
602        file("health", "data/health.toml", d.join("health.toml"), Shape::Toml(&HEALTH_RULES)),
603        // Each day's own meals, naps and nights: one entry per field of a block of a day.
604        file("health", "data/health-days.toml", d.join("health-days.toml"), Shape::Toml(&PLAIN_RULES)),
605        file("time", "data/time/running.toml", d.join("time").join("running.toml"), Shape::Whole),
606        folder("time", "data/time/", d.join("time"), Shape::Toml(&TIME_RULES), &["running.toml"]),
607        // How each day went, with the plan it is the outcome of (today.toml, quiet.toml, the sessions):
608        // every device that plans learns the same (docs/reviews.md).
609        folder("time", "data/reviews/", d.join("reviews"), Shape::Toml(&REVIEWS_RULES), &[]),
610        folder("drafts", "data/drafts/", d.join("drafts"), Shape::Whole, &[]),
611        folder("drafts", "data/invoices/", d.join("invoices"), Shape::Whole, &[]),
612        folder("senders", "data/pgp/others/", d.join("pgp").join("others"), Shape::Whole, &[]),
613        // Your security keys as a computer of yours read them, their certificates with them (public
614        // parts only: `securitykey::shared_cards`), so that every device encrypts to you, hands your
615        // key by Autocrypt and says what waits for the key. A store an older Sioul leaves alone.
616        folder("settings", "data/pgp/cards/", d.join("pgp").join(sioul_core::securitykey::SHARED_CARDS), Shape::Whole, &[]),
617        folder("lists", "data/calendars/local/", d.join("calendars").join(local), Shape::Whole, &[]),
618        folder("lists", "data/contacts/local/", d.join("contacts").join(local), Shape::Whole, &[]),
619        file("settings", "state/porch.toml", s.join("porch.toml"), Shape::Toml(&PORCH_RULES)),
620        file("settings", "state/money.toml", s.join("money.toml"), Shape::Toml(&MONEY_RULES)),
621        // Do-not-disturb's switch, pressed on any device (docs/do-not-disturb.md).
622        file("settings", "state/do-not-disturb.toml", s.join(sioul_core::everywhere::SWITCH_FILE), Shape::Toml(&DND_RULES)),
623        file("time", "state/today.toml", s.join("today.toml"), Shape::Toml(&TODAY_RULES)),
624        file("time", "state/quiet.toml", s.join("quiet.toml"), Shape::Toml(&QUIET_RULES)),
625        file("time", "state/stopped.toml", s.join("stopped.toml"), Shape::Toml(&PLAIN_RULES)),
626        file("health", "state/health-state.toml", s.join("health-state.toml"), Shape::Toml(&PLAIN_RULES)),
627        // Each dose that fell due and the answers your devices captured (docs/health.md,
628        // "Doses as records"): a file of its own, which an older Sioul leaves alone.
629        file("health", "state/health-doses.toml", s.join(sioul_core::doses::FILE), Shape::Toml(&DOSES_RULES)),
630        // What each device did to remind each dose (docs/health.md, "What became of a
631        // reminder"): a file of its own too, which an older Sioul leaves alone.
632        file("health", "state/health-reminders.toml", s.join(sioul_core::dose_reminders::FILE), Shape::Toml(&REMINDERS_RULES)),
633        folder("senders", "state/shield/", s.join("shield"), Shape::Whole, &[]),
634        folder("lists", "state/dav/local/", s.join("dav").join(local), Shape::Whole, &[]),
635        // The spam filter's table alone: its language model and its corpus, beside it, stay here.
636        file("spam", SPAM_TABLE, d.join("spam").join("table.bin"), Shape::Files),
637        // What you said is spam or not, what your own filter moved and what it
638        // flagged: each device's own log (`<device>.jsonl`), a line each,
639        // every device's read.
640        folder("spam", SPAM_LABELS, s.join("spam").join(sioul_core::spam::labels::FOLDER), Shape::Lines, &[]),
641        folder("spam", SPAM_MOVED, s.join("spam").join(sioul_core::spam::labels::MOVED), Shape::Lines, &[]),
642        folder("spam", SPAM_FLAGGED, s.join("spam").join(sioul_core::spam::labels::FLAGGED), Shape::Lines, &[]),
643        // The calls each phone screened, and those each device marked Seen:
644        // each device's own file (`<device>.jsonl`), a line each, every device's read.
645        folder("calls", CALLS_LOG, s.join(sioul_core::calls::FOLDER).join(sioul_core::calls::LOG), Shape::Lines, &[]),
646        folder("calls", CALLS_SEEN, s.join(sioul_core::calls::FOLDER).join(sioul_core::calls::SEEN_LOG), Shape::Lines, &[]),
647        // The messages each phone's notifications brought, and those each device
648        // marked Seen: each device's own file, a line each, every device's read.
649        folder(sioul_core::phonemsgs::PART, PHONE_MESSAGES_LOG, s.join(sioul_core::phonemsgs::FOLDER).join(sioul_core::phonemsgs::LOG), Shape::Lines, &[]),
650        folder(sioul_core::phonemsgs::PART, PHONE_MESSAGES_SEEN, s.join(sioul_core::phonemsgs::FOLDER).join(sioul_core::phonemsgs::SEEN_LOG), Shape::Lines, &[]),
651        // What each relayed notification offers a reply from your computers, each phone's own file.
652        folder(sioul_core::phonemsgs::PART, PHONE_MESSAGES_LIVE, s.join(sioul_core::phonemsgs::FOLDER).join(sioul_core::replies::LIVE), Shape::Lines, &[]),
653        // Each computer's replies to send, sealed lines, and each phone's word on them.
654        folder(sioul_core::phonemsgs::PART, PHONE_MESSAGES_ASK, s.join(sioul_core::phonemsgs::FOLDER).join(sioul_core::replies::ASK), Shape::Lines, &[]),
655        folder(sioul_core::phonemsgs::PART, PHONE_MESSAGES_OUTCOME, s.join(sioul_core::phonemsgs::FOLDER).join(sioul_core::replies::OUTCOME), Shape::Lines, &[]),
656        // texts: each phone's texts and outcomes, each computer's requests, sealed lines, in the data folder.
657        folder(sioul_core::texts::PART, TEXTS_LOG, d.join(sioul_core::texts::FOLDER).join(sioul_core::texts::LOG), Shape::Lines, &[]),
658        folder(sioul_core::texts::PART, TEXTS_SEND, d.join(sioul_core::texts::FOLDER).join(sioul_core::texts::SEND), Shape::Lines, &[]),
659        folder(sioul_core::texts::PART, TEXTS_OUTCOME, d.join(sioul_core::texts::FOLDER).join(sioul_core::texts::OUTCOME), Shape::Lines, &[]),
660        folder(sioul_core::texts::PART, TEXTS_MARKS, d.join(sioul_core::texts::FOLDER).join(sioul_core::texts::MARKS), Shape::Lines, &[]),
661    ];
662    // From the notes folder each device keeps where it likes: projects,
663    // budgets, the bank's movements and contracts; the papers' wallet and its
664    // files; the notes themselves, each file sealed apart.
665    if let Some(notes) = config.notes_root_path() {
666        stores.push(file("projects", "notes/sioul-cases.toml", sioul_core::projects::file_in(&notes), Shape::Toml(&PROJECTS_RULES)));
667        stores.push(file("projects", "notes/sioul-budgets.toml", notes.join(sioul_core::budget::LEDGER), Shape::Toml(&LEDGER_RULES)));
668        stores.push(file("projects", "notes/sioul-bank.toml", notes.join(sioul_core::bank::MANIFEST), Shape::Toml(&BANK_RULES)));
669        stores.push(file("projects", "notes/sioul-contracts.toml", notes.join(sioul_core::contracts::MANIFEST), Shape::Toml(&CONTRACTS_RULES)));
670        stores.push(file("papers", "notes/sioul-papers.toml", notes.join(sioul_core::papers::MANIFEST), Shape::Toml(&PAPERS_RULES)));
671        stores.push(folder("papers", "files/papers/", notes.join(sioul_core::papers::FOLDER), Shape::Files, &[]));
672        stores.push(folder("notes", "files/notes/", notes, Shape::Files, NOT_NOTES));
673    }
674    stores.retain(|store| shares(store.part));
675    stores
676}
677
678/// The stores as a format divides their files (`Rules::at`): format 2's
679/// rules from 2 on, where they differ.
680pub fn shaped(stores: &[Store], format: u32) -> Vec<Store> {
681    stores
682        .iter()
683        .map(|store| Store {
684            shape: match store.shape {
685                Shape::Toml(rules) => Shape::Toml(rules.at(format)),
686                other => other,
687            },
688            ..store.clone()
689        })
690        .collect()
691}
692
693/// What notes or papers would send from here, before their part is switched
694/// on: how many files, how many bytes, and how many stay (too big). Listed as
695/// an exchange lists them, nothing read through, nothing removed.
696pub fn estimate(stores: &[Store]) -> (usize, u64, usize) {
697    let (mut found, mut files, known, confirmed) = (Found::default(), Vec::new(), BTreeMap::new(), BTreeSet::new());
698    for store in stores.iter().filter(|s| matches!(s.shape, Shape::Files) && s.path.is_dir()) {
699        Listing { store: &store.name, root: &store.path, skip: store.skip, known: &known, confirmed: &confirmed, files: &mut files, found: &mut found, sweep: false }.list(&store.path);
700    }
701    files.iter().filter_map(|(_, path)| std::fs::metadata(path).ok()).fold((0, 0, 0), |(count, bytes, big), meta| if meta.len() > crate::blobs::LARGEST { (count, bytes, big + 1) } else { (count + 1, bytes + meta.len(), big) })
702}
703
704/// The part a file of the records belongs to, whether this device shares it
705/// or not; none for a store this version does not know (its changes wait for
706/// a version that knows it).
707fn known_part(file: &str) -> Option<&'static str> {
708    static KNOWN: std::sync::OnceLock<Vec<(String, bool, &'static str)>> = std::sync::OnceLock::new();
709    let known = KNOWN.get_or_init(|| {
710        let roots = Roots { config: PathBuf::from("config"), data: PathBuf::from("data"), state: PathBuf::from("state") };
711        let config = Config { notes_root: Some("notes".into()), ..Config::default() };
712        stores_of(&config, &roots, &|_| true).into_iter().map(|store| (store.name, store.folder, store.part)).collect()
713    });
714    #[cfg(test)]
715    if tests::OLDER.with(|older| older.borrow().iter().any(|store| file.starts_with(store.as_str()))) {
716        return None;
717    }
718    known.iter().find(|(name, folder, _)| !folder && name == file).or_else(|| known.iter().filter(|(name, folder, _)| *folder && file.starts_with(name.as_str())).max_by_key(|(name, _, _)| name.len())).map(|(_, _, part)| *part)
719}
720
721/// A file's name in the records, as shown: without the folder Sioul keeps it in
722/// ("config/config.toml" → "config.toml", "files/notes/admin/lease.md" → "admin/lease.md").
723pub fn shown(file: &str) -> &str {
724    ["files/notes/", "files/", "config/", "data/", "state/", "notes/"].iter().find_map(|root| file.strip_prefix(root)).unwrap_or(file)
725}
726
727// ---------------------------------------------------------------- entries
728
729/// What the files hold now, entry by entry.
730#[derive(Debug, Default)]
731struct Found {
732    /// Each entry's value, hashed: `<store><file>#<entry>` → its hash.
733    hashes: BTreeMap<String, String>,
734    /// The values of the entries in files read this time; the others' files did not change.
735    values: BTreeMap<String, String>,
736    /// When each file last changed, in milliseconds: dates the changes found in it.
737    changed: BTreeMap<String, i64>,
738    /// Files that could not be read (a TOML file half written, a folder not
739    /// there): their entries are neither taken out nor written to.
740    unknown: BTreeSet<String>,
741    /// Each file's size, time and entries: a file unchanged is not read again.
742    files: BTreeMap<String, Stat>,
743    /// Files sealed apart left here, too big to travel (`blobs::LARGEST`): said.
744    too_big: Vec<String>,
745    /// Folders of files sealed apart that list empty while they held files at
746    /// the last look, with how many they held: unknown, said.
747    emptied: Vec<(String, usize)>,
748    /// What else the look has to say (names that are not text).
749    said: Vec<String>,
750    /// Large notes and papers found changed since they were last read, not
751    /// read yet: since when (`Sealed::changing`).
752    changing: BTreeMap<String, i64>,
753}
754
755/// A file as last read: its size, when it changed (nanoseconds), its entries'
756/// hashes, and when it was read (milliseconds, this computer's clock).
757#[derive(Debug, Clone, Default, Serialize, Deserialize)]
758struct Stat {
759    size: u64,
760    modified: u64,
761    entries: Vec<(String, String)>,
762    /// A file read within `settling_ms` of its change may have changed again
763    /// since at the same size and time (a coarse clock: a FAT card's 2 s): read again.
764    #[serde(default, skip_serializing_if = "is_zero")]
765    seen: i64,
766}
767
768fn is_zero(n: &i64) -> bool {
769    *n == 0
770}
771
772impl Found {
773    fn is_unknown(&self, key: &str) -> bool {
774        let file = file_of(key);
775        self.unknown.iter().any(|u| file == u || (u.ends_with('/') && file.starts_with(u.as_str())))
776    }
777}
778
779/// Folders whose files travel whole (`Shape::Whole`, `Shape::Files`): their
780/// keys are the file's name and an empty entry, `#`, and a name may hold `#`
781/// itself ("Facture #123.pdf").
782const WHOLE_FOLDERS: &[&str] = &["data/drafts/", "data/invoices/", "data/pgp/others/", "data/calendars/local/", "data/contacts/local/", "state/shield/", "state/dav/local/", FILES];
783
784/// The drafts' folder. A draft taken out on one device (sent, or thrown away)
785/// while another changed it, each unaware of the other, stays taken out on
786/// every device, whichever came later; the change made meanwhile is kept
787/// among that device's earlier versions (review of 5 October 2026, F22: a
788/// draft sent could come back, and be sent twice). Each record of a draft
789/// names the version it replaced (`Change::b`), and each device keeps every
790/// version of a draft it met (`Memory::drafts`): a change of a change of the
791/// version sent loses too (`sent_meanwhile`). An older Sioul's records name
792/// none: the later word wins, as before.
793const DRAFTS: &str = "data/drafts/";
794
795/// How a record of a draft is decided (`DRAFTS`): Some(true) it wins, Some(false)
796/// it loses, whichever came later; None, the later word wins, as ever. `here`:
797/// the draft here, or what came for it earlier in this reading (taken out, its
798/// version, the version it replaced); `met`: every version of it met here.
799fn sent_meanwhile(met: Option<&BTreeSet<String>>, here: Option<(bool, &str, &str)>, removal: bool, base: &str) -> Option<bool> {
800    let (gone, version, replaced) = here?;
801    let known = |v: &str| !v.is_empty() && (v == version || v == replaced || met.is_some_and(|met| met.contains(v)));
802    match (removal, gone) {
803        // A change of a version that was taken out: it was sent.
804        (false, true) if known(base) => Some(false),
805        // Taken out where this draft came from: sent, whatever was changed here since.
806        (true, false) if known(base) => Some(true),
807        _ => None,
808    }
809}
810
811/// `<store><file>`: the part of a key before its entry.
812fn file_of(key: &str) -> &str {
813    if WHOLE_FOLDERS.iter().any(|folder| key.starts_with(folder))
814        && let Some(file) = key.strip_suffix('#')
815    {
816        return file;
817    }
818    key.split_once('#').map_or(key, |(file, _)| file)
819}
820
821/// The entry a key names in its file.
822fn entry_of(key: &str) -> &str {
823    key.get(file_of(key).len() + 1..).unwrap_or("")
824}
825
826pub(crate) fn modified_ns(meta: &std::fs::Metadata) -> u64 {
827    meta.modified().ok().and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map_or(0, |d| d.as_nanos() as u64)
828}
829
830/// How a look goes: which stores it reads, whether it must hurry, which
831/// folders' emptying you confirmed, this computer's clock (milliseconds).
832struct Look<'a> {
833    /// Stores read this time; the others are left as last read.
834    reads: &'a dyn Fn(&Store) -> bool,
835    /// Files sealed apart are not read through while a hurried exchange waits.
836    hurry: &'a dyn Fn() -> bool,
837    confirmed: &'a BTreeSet<String>,
838    clock: i64,
839    /// Large notes and papers found changed since they were last read, and since when (`Sealed::changing`).
840    changing: &'a BTreeMap<String, i64>,
841}
842
843/// This computer's clock, in milliseconds.
844fn clock_ms() -> i64 {
845    std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_millis() as i64)
846}
847
848#[cfg(test)]
849thread_local! {
850    /// How long a file sealed apart settles before it is read, in this test's thread: not at all, unless a test says.
851    static SETTLE: std::cell::Cell<i64> = const { std::cell::Cell::new(0) };
852}
853
854/// A file sealed apart changed this recently may still be being written (an
855/// editor writing it in place): read at the next look.
856fn settling_ms() -> i64 {
857    #[cfg(test)]
858    return SETTLE.with(std::cell::Cell::get);
859    #[cfg(not(test))]
860    2_000
861}
862
863/// A note or a paper this large (bytes) is sealed only once it has settled
864/// (`large_settling_ms`): a recording or a scan saved again within minutes
865/// goes once, its last save, not each one. On 14 October 2026 four earlier
866/// takes of the owner's recordings, 15.7 MB, were sealed 13, 16, 25 and 1
867/// minute before the next save, and each stayed in the folder as long as
868/// replaced contents stay: ten minutes would have kept only the last of them
869/// (3.5 MB) off it. Smaller notes go as before.
870const LARGE_SETTLES: u64 = 1 << 20;
871
872#[cfg(test)]
873thread_local! {
874    /// How long a large note or paper settles, in this test's thread: not at all, unless a test says.
875    pub(crate) static LARGE_SETTLE: std::cell::Cell<i64> = const { std::cell::Cell::new(0) };
876}
877
878/// How long a note or a paper of `LARGE_SETTLES` or more stays unchanged
879/// before it is read and sealed. One that never stops changing still goes an
880/// hour after it was first found changed (`read_file`, `Sealed::changing`).
881pub(crate) const LARGE_SETTLING: i64 = 10 * MINUTE;
882
883fn large_settling_ms() -> i64 {
884    #[cfg(test)]
885    return LARGE_SETTLE.with(std::cell::Cell::get);
886    #[cfg(not(test))]
887    LARGE_SETTLING
888}
889
890/// What the stores hold; a file whose size and time did not change since
891/// `known` is taken from there, not read. What could not be read is unknown,
892/// its last look kept: nothing of it is taken out elsewhere.
893fn gather(stores: &[Store], known: &BTreeMap<String, Stat>, look: &Look) -> Found {
894    let mut found = Found::default();
895    for store in stores {
896        if !(look.reads)(store) {
897            // Not read this time (notes and papers in a hurried exchange).
898            found.unknown.insert(store.name.clone());
899            continue;
900        }
901        if store.folder {
902            if !store.path.is_dir() {
903                // Notes or papers that held files and are gone (a disk not
904                // mounted, a folder moved): unknown, said. A folder that never
905                // held any is a new one, filled.
906                let held = known.range(store.name.clone()..).take_while(|(file, _)| file.starts_with(&store.name)).count();
907                if matches!(store.shape, Shape::Files) && held == 0 {
908                    continue;
909                }
910                if matches!(store.shape, Shape::Files) && !look.confirmed.iter().any(|said| confirms(said, &store.name)) {
911                    found.emptied.push((store.name.clone(), held));
912                }
913                found.unknown.insert(store.name.clone());
914                continue;
915            }
916            let mut files = Vec::new();
917            if matches!(store.shape, Shape::Files) {
918                let mut listing = Listing { store: &store.name, root: &store.path, skip: store.skip, known, confirmed: look.confirmed, files: &mut files, found: &mut found, sweep: true };
919                listing.list(&store.path);
920            } else {
921                list_files(&store.path, &store.path, store.skip, &mut files);
922            }
923            for (relative, path) in files {
924                read_file(store, &format!("{}{relative}", store.name), &path, known, look, &mut found);
925            }
926        } else if store.path.is_file() {
927            read_file(store, &store.name, &store.path, known, look, &mut found);
928        } else if !matches!(store.shape, Shape::Whole | Shape::Files) {
929            // A settings file not there is not a file emptied; a file whole or
930            // sealed apart not there is none yet (the spam filter's table before a training).
931            found.unknown.insert(store.name.clone());
932        }
933    }
934    // What is unknown keeps its last look: next time, a folder that held files is still known to have held them.
935    let carried: Vec<(String, Stat)> = known.iter().filter(|(file, _)| !found.files.contains_key(*file) && found.is_unknown(file)).map(|(file, stat)| (file.clone(), stat.clone())).collect();
936    found.files.extend(carried);
937    // The logs of lines kept (`LINES`) that are no longer there, forgotten.
938    if let Ok(mut all) = LINES.lock() {
939        all.retain(|path, _| path.exists() || !stores.iter().any(|s| matches!(s.shape, Shape::Lines) && path.starts_with(&s.path)));
940    }
941    found
942}
943
944/// Every file below a folder, by its path from it with `/`; temporary and hidden files left out.
945fn list_files(root: &Path, dir: &Path, skip: &[&str], out: &mut Vec<(String, PathBuf)>) {
946    let Ok(entries) = std::fs::read_dir(dir) else { return };
947    for entry in entries.filter_map(Result::ok) {
948        let name = entry.file_name().to_string_lossy().to_string();
949        if name.starts_with('.') || name.ends_with(".new") || name.ends_with(".tmp") || name.ends_with('~') || skip.contains(&name.as_str()) {
950            continue;
951        }
952        let path = entry.path();
953        if path.is_dir() {
954            list_files(root, &path, skip, out);
955        } else if let Ok(relative) = path.strip_prefix(root) {
956            out.push((relative.components().map(|c| c.as_os_str().to_string_lossy()).collect::<Vec<_>>().join("/"), path));
957        }
958    }
959}
960
961/// A listing of a folder of files sealed apart (notes, papers).
962struct Listing<'a> {
963    store: &'a str,
964    root: &'a Path,
965    skip: &'a [&'a str],
966    known: &'a BTreeMap<String, Stat>,
967    /// The folders you said to take out what went from (`Sealed::confirmed`):
968    /// one of them, or a folder below one, listing empty is emptied.
969    confirmed: &'a BTreeSet<String>,
970    files: &'a mut Vec<(String, PathBuf)>,
971    found: &'a mut Found,
972    /// Whether what a crash left half written goes once an hour old: in an
973    /// exchange, under its lock; never in a look from outside it
974    /// (`estimate`), which removes nothing (review of 18 October 2026, I-2).
975    sweep: bool,
976}
977
978impl Listing<'_> {
979    /// Every file below `dir`, by its path from the root with `/`; how many.
980    /// Left out: what sync apps, editors and systems make (`made_by_tools`),
981    /// `skip` at the top (whatever its case), tools' own folders, a folder
982    /// shared through (it holds a seal), links, names that are not text (said).
983    /// A folder that cannot be read whole (one name that errs is enough), or
984    /// that lists empty while it held files at the last look (a disk not
985    /// mounted, access withdrawn), is unknown: nothing in it is taken out
986    /// elsewhere, nothing is written into it.
987    fn list(&mut self, dir: &Path) -> usize {
988        let relative = dir.strip_prefix(self.root).map(|r| r.components().map(|c| c.as_os_str().to_string_lossy()).collect::<Vec<_>>().join("/")).unwrap_or_default();
989        let within = if relative.is_empty() { String::new() } else { format!("{relative}/") };
990        let prefix = format!("{}{within}", self.store);
991        let mut whole = true;
992        let mut count = 0;
993        match std::fs::read_dir(dir) {
994            Err(_) => whole = false,
995            Ok(entries) => {
996                for entry in entries {
997                    let Ok(entry) = entry else {
998                        whole = false;
999                        continue;
1000                    };
1001                    let Ok(kind) = entry.file_type() else {
1002                        whole = false;
1003                        continue;
1004                    };
1005                    let name = entry.file_name();
1006                    let Some(name) = name.to_str() else {
1007                        self.found.said.push(format!("share-not-text:{prefix}{}", name.to_string_lossy()));
1008                        continue;
1009                    };
1010                    let path = entry.path();
1011                    if is_leftover(name) {
1012                        if self.sweep {
1013                            remove_if_old(&path);
1014                        }
1015                        continue;
1016                    }
1017                    if kind.is_symlink() {
1018                        // A folder or a file put back as a link (moved out of
1019                        // the notes, and linked back): links are never
1020                        // followed, and what was there is unknown, said as a
1021                        // folder gone, never taken for deleted (review of 17
1022                        // October 2026, L4).
1023                        let place = format!("{prefix}{name}");
1024                        let below = format!("{place}/");
1025                        let held = self.known.range(place.clone()..).take_while(|(file, _)| file.starts_with(&place)).filter(|(file, _)| **file == place || file.starts_with(&below)).count();
1026                        if held > 0 && !self.confirmed.iter().any(|said| confirms(said, &below)) {
1027                            // Said as what it stands for: a note ("…/single.md"), or a folder ("…/archive/").
1028                            let one = self.known.contains_key(&place);
1029                            self.found.emptied.push((if one { place.clone() } else { below.clone() }, held));
1030                            self.found.unknown.insert(place);
1031                            self.found.unknown.insert(below);
1032                        }
1033                        continue;
1034                    }
1035                    if made_by_tools(name) || (dir == self.root && self.skip.iter().any(|skip| skip.eq_ignore_ascii_case(name))) {
1036                        continue;
1037                    }
1038                    if kind.is_dir() {
1039                        if !matches!(name, "node_modules" | "target") && !path.join("seal.toml").is_file() {
1040                            count += self.list(&path);
1041                        }
1042                    } else if kind.is_file() {
1043                        self.files.push((format!("{within}{name}"), path));
1044                        count += 1;
1045                    }
1046                }
1047            }
1048        }
1049        let held = self.known.range(prefix.clone()..).take_while(|(file, _)| file.starts_with(&prefix)).count();
1050        let confirmed = self.confirmed.iter().any(|said| confirms(said, &prefix));
1051        if !whole || (count == 0 && held > 0 && !confirmed) {
1052            if whole {
1053                self.found.emptied.push((prefix.clone(), held));
1054            }
1055            self.found.unknown.insert(prefix);
1056        }
1057        count
1058    }
1059}
1060
1061/// Whether what you said to take out everywhere (`Sealed::confirmed`: a
1062/// folder by its place, "files/notes/disk/", or one note replaced by a
1063/// link, "files/notes/single.md") covers `file` (a file's or a folder's
1064/// place): what is below that folder, or that note; never a name beside it.
1065fn confirms(said: &str, file: &str) -> bool {
1066    if said.ends_with('/') { file.starts_with(said) } else { file == said || file.strip_prefix(said).is_some_and(|rest| rest.starts_with('/')) }
1067}
1068
1069/// What a crash left half written by Sioul: its hidden names ending so, and
1070/// those its replacements write beside a file (`filelock::Prepared`,
1071/// `filelock::replace`: `.<name>.<process>-<number>.new`).
1072fn is_leftover(name: &str) -> bool {
1073    let replacement = name.strip_suffix(".new").and_then(|rest| rest.rsplit_once('.')).is_some_and(|(_, tail)| tail.split_once('-').is_some_and(|(process, number)| !process.is_empty() && !number.is_empty() && process.bytes().all(|b| b.is_ascii_digit()) && number.bytes().all(|b| b.is_ascii_digit())));
1074    name.starts_with('.') && ([TEMPORARY, ".shared.tmp", ".conflict.tmp"].iter().any(|end| name.ends_with(end)) || replacement)
1075}
1076
1077/// [`clean_leftovers`] in `dir` and its folders, a few deep: the folders whose
1078/// files travel whole (drafts, calendars and address books kept here), where
1079/// a crash in a receive, a Put back or a copy's replacement leaves a hidden
1080/// file that nothing else ever sweeps (review of 19 October 2026, F17).
1081fn clean_leftovers_below(dir: &Path, depth: usize) {
1082    clean_leftovers(dir);
1083    if depth == 0 {
1084        return;
1085    }
1086    for entry in std::fs::read_dir(dir).into_iter().flatten().filter_map(Result::ok) {
1087        if entry.file_type().is_ok_and(|t| t.is_dir()) {
1088            clean_leftovers_below(&entry.path(), depth - 1);
1089        }
1090    }
1091}
1092
1093/// A file left half written, removed once an hour old: never one still being written.
1094fn remove_if_old(path: &Path) {
1095    if std::fs::metadata(path).ok().and_then(|m| m.modified().ok()).and_then(|t| t.elapsed().ok()).is_some_and(|age| age.as_secs() > 3600) {
1096        let _ = std::fs::remove_file(path);
1097    }
1098}
1099
1100/// What a crash left half written in a folder (the sealed files, the history), removed after an hour.
1101pub(crate) fn clean_leftovers(dir: &Path) {
1102    for entry in std::fs::read_dir(dir).into_iter().flatten().filter_map(Result::ok) {
1103        if entry.file_name().to_str().is_some_and(is_leftover) {
1104            remove_if_old(&entry.path());
1105        }
1106    }
1107}
1108
1109/// Names that sync apps, editors and systems make beside your files: hidden
1110/// ones, temporary and backup copies, conflicted copies, locks. Sioul's own
1111/// copies of a file two devices changed ("lease (conflict …).md") are none of them.
1112fn made_by_tools(name: &str) -> bool {
1113    let lower = name.to_lowercase();
1114    name.starts_with('.')
1115        || name.starts_with('~')
1116        || name.ends_with('~')
1117        || (name.starts_with('#') && name.ends_with('#'))
1118        || [".tmp", ".temp", ".part", ".partial", ".crdownload", ".download", ".swp", ".swx", ".new"].iter().any(|end| lower.ends_with(end))
1119        || ["conflicted copy", ".sync-conflict-", "_conflict-", "sfconflict"].iter().any(|word| lower.contains(word))
1120        || matches!(lower.as_str(), "thumbs.db" | "desktop.ini" | "4913")
1121}
1122
1123/// A file sealed apart, as its record says it: its content's hash, its size, when it changed (milliseconds).
1124#[derive(Debug, Serialize, Deserialize)]
1125struct Reference {
1126    h: String,
1127    s: u64,
1128    t: i64,
1129}
1130
1131fn reference(h: &str, size: u64, modified_ns: u64) -> String {
1132    serde_json::to_string(&Reference { h: h.to_string(), s: size, t: (modified_ns / 1_000_000) as i64 }).unwrap_or_default()
1133}
1134
1135/// What an entry's value is known by: a file sealed apart by its content's hash, anything else by its value's.
1136fn value_hash(key: &str, value: &str) -> String {
1137    if key.starts_with(FILES) { serde_json::from_str::<Reference>(value).map(|r| r.h).unwrap_or_default() } else { hash(value) }
1138}
1139
1140fn read_file(store: &Store, file: &str, path: &Path, known: &BTreeMap<String, Stat>, look: &Look, found: &mut Found) {
1141    let Ok(meta) = std::fs::metadata(path) else {
1142        found.unknown.insert(file.to_string());
1143        return;
1144    };
1145    let sealed_apart = matches!(store.shape, Shape::Files);
1146    if meta.len() > if sealed_apart { crate::blobs::LARGEST } else { LARGEST } {
1147        found.unknown.insert(file.to_string());
1148        if sealed_apart {
1149            found.too_big.push(file.to_string());
1150        }
1151        return;
1152    }
1153    let modified = modified_ns(&meta);
1154    found.changed.insert(file.to_string(), (modified / 1_000_000) as i64);
1155    // As last read: the same size and time, read well after it changed. Not
1156    // a log of lines (calls, texts, labels): what it holds is not kept twice
1157    // in the memory (its lines are its entries' names), it is read again.
1158    let lines = matches!(store.shape, Shape::Lines);
1159    if let Some(stat) = known.get(file).filter(|s| !lines && s.size == meta.len() && s.modified == modified && (s.seen == 0 || (s.modified / 1_000_000) as i64 + settling_ms() < s.seen)) {
1160        for (entry, h) in &stat.entries {
1161            found.hashes.insert(format!("{file}#{entry}"), h.clone());
1162            // A file sealed apart: its record made from what was kept, the file not read again.
1163            if sealed_apart {
1164                found.values.insert(format!("{file}#{entry}"), reference(h, stat.size, stat.modified));
1165            }
1166        }
1167        found.files.insert(file.to_string(), stat.clone());
1168        return;
1169    }
1170    if sealed_apart {
1171        // Changed a moment ago (maybe still being written), or a hurried
1172        // exchange waits: read at the next look. A large note or paper, until
1173        // it has not changed for ten minutes (`LARGE_SETTLES`), or an hour
1174        // after it was first found changed, whichever comes first: one that
1175        // never stops changing still goes, a new one too; one saved again
1176        // after a quiet hour waits as well. Unknown meanwhile, as a file
1177        // being written is: the others' changes to it wait, nothing of it is
1178        // taken out. Read through for its hash, never held whole; one that
1179        // changed while read is read next time.
1180        let large = meta.len() >= LARGE_SETTLES && matches!(store.part, "notes" | "papers");
1181        // Changed since it was last read (else taken as it was, above): since
1182        // when it was first found so, kept until it is read (`Sealed::changing`).
1183        let since = look.changing.get(file).copied().unwrap_or(look.clock);
1184        let settling = if large && look.clock - since < HOUR { large_settling_ms().max(settling_ms()) } else { settling_ms() };
1185        let fresh = look.clock - ((modified / 1_000_000) as i64) < settling;
1186        let read = (!fresh && !(look.hurry)()).then(|| crate::blobs::hash_file(path).ok()).flatten().filter(|(_, size)| *size == meta.len());
1187        let Some((h, _)) = read else {
1188            found.unknown.insert(file.to_string());
1189            if large {
1190                found.changing.insert(file.to_string(), since);
1191            }
1192            return;
1193        };
1194        let key = format!("{file}#");
1195        found.hashes.insert(key.clone(), h.clone());
1196        found.values.insert(key, reference(&h, meta.len(), modified));
1197        found.files.insert(file.to_string(), Stat { size: meta.len(), modified, entries: vec![(String::new(), h)], seen: look.clock });
1198        return;
1199    }
1200    if lines {
1201        // A log of lines: its lines as this process last read them, while the
1202        // file is that one (`LINES`), else read now and kept.
1203        let Some(lines) = lines_of(path, &meta, look.clock) else {
1204            found.unknown.insert(file.to_string());
1205            return;
1206        };
1207        let h = hash("");
1208        for line in lines.iter() {
1209            let key = format!("{file}#{line}");
1210            found.hashes.insert(key.clone(), h.clone());
1211            found.values.insert(key, String::new());
1212        }
1213        // When it was read is not kept (the memory would change at each).
1214        found.files.insert(file.to_string(), Stat { size: meta.len(), modified, entries: Vec::new(), seen: 0 });
1215        return;
1216    }
1217    // Read whole, as one version held it (`filelock::read_whole_with`): never
1218    // a file replaced while it was read, whose old bytes may be zeros by the
1219    // end (a copy beside a draft encrypted again, `draftcopies`, which takes
1220    // its place under the exchange lock this exchange holds, so this is a
1221    // last guard). What is recorded, the size, the time and each entry's
1222    // hash, is that version's own, from the descriptor that read it: what
1223    // goes out is always bytes read whole from a file that had its name.
1224    let Some((entries, held)) = sioul_core::filelock::read_whole_with(path).ok().and_then(|(bytes, held)| Some((entries_of(store, &bytes)?, held))) else {
1225        found.unknown.insert(file.to_string());
1226        return;
1227    };
1228    let mut stat = Stat { size: held.len(), modified: modified_ns(&held), entries: Vec::with_capacity(entries.len()), seen: look.clock };
1229    for (entry, value) in entries {
1230        let key = format!("{file}#{entry}");
1231        let h = hash(&value);
1232        stat.entries.push((entry, h.clone()));
1233        found.hashes.insert(key.clone(), h);
1234        found.values.insert(key, value);
1235    }
1236    found.files.insert(file.to_string(), stat);
1237}
1238
1239/// Each log of lines (`Shape::Lines`) as this process last read it, by its
1240/// path: the file's stamp then, when it was read (milliseconds), its lines.
1241/// Read again only once the file changed, or when it was read within
1242/// `settling_ms` of its change (a coarse clock: it may have changed again at
1243/// the same size and time). The texts' log alone is megabytes of sealed lines,
1244/// once read at every exchange of a phone's background step.
1245type Lines = BTreeMap<PathBuf, (FileStamp, i64, std::sync::Arc<Vec<String>>)>;
1246static LINES: std::sync::Mutex<Lines> = std::sync::Mutex::new(BTreeMap::new());
1247
1248/// A log's lines, as `entries_of` reads them: from `LINES` while the file is
1249/// the one read there, else read now (`clock`: this computer's, milliseconds) and kept.
1250fn lines_of(path: &Path, meta: &std::fs::Metadata, clock: i64) -> Option<std::sync::Arc<Vec<String>>> {
1251    let stamp = file_stamp(meta);
1252    let settled = |read: i64| (stamp.modified / 1_000_000) as i64 + settling_ms() < read;
1253    if let Some((_, _, lines)) = keeping().then(|| LINES.lock().ok().and_then(|all| all.get(path).filter(|(was, read, _)| *was == stamp && settled(*read)).cloned())).flatten() {
1254        return Some(lines);
1255    }
1256    let bytes = std::fs::read(path).ok()?;
1257    #[cfg(test)]
1258    LINES_READ.with(|n| n.set(n.get() + 1));
1259    // Not text (a list kept by another tool in another encoding): unknown, left as it is.
1260    let lines: std::sync::Arc<Vec<String>> = std::sync::Arc::new(std::str::from_utf8(&bytes).ok()?.lines().map(str::trim).filter(|l| !l.is_empty() && !l.starts_with('#')).map(str::to_string).collect());
1261    // Kept, read whole only if it did not change meanwhile: else read again next time.
1262    if keeping()
1263        && bytes.len() as u64 == stamp.size
1264        && let Ok(mut all) = LINES.lock()
1265    {
1266        all.insert(path.to_path_buf(), (stamp, clock, std::sync::Arc::clone(&lines)));
1267    }
1268    Some(lines)
1269}
1270
1271/// A file's entries and their values; none when it cannot be read as its
1272/// shape says. Files sealed apart are read through, never whole (`read_file`).
1273/// A list or a settings file that is not text (saved by another tool in
1274/// another encoding, damaged) is none: never decoded lossily, which would
1275/// send its replaced characters and, written back, take its other lines out
1276/// everywhere (review of 5 October 2026, F25).
1277fn entries_of(store: &Store, bytes: &[u8]) -> Option<Vec<(String, String)>> {
1278    match store.shape {
1279        Shape::Whole => Some(vec![(String::new(), B64.encode(bytes))]),
1280        Shape::Lines => Some(std::str::from_utf8(bytes).ok()?.lines().map(str::trim).filter(|l| !l.is_empty() && !l.starts_with('#')).map(|l| (l.to_string(), String::new())).collect()),
1281        Shape::Toml(rules) => toml_entries(std::str::from_utf8(bytes).ok()?, rules).ok(),
1282        Shape::Files => None,
1283    }
1284}
1285
1286/// A list's or a settings file's text, to write entries into: none there yet
1287/// is an empty one; one that cannot be read, or is not text, is an error, and
1288/// what came for it waits (never the incoming entries alone written in its place).
1289fn text_to_write(path: &Path) -> Result<String, String> {
1290    match std::fs::read(path) {
1291        Ok(bytes) => String::from_utf8(bytes).map_err(|_| format!("share-unreadable:{}", path.display())),
1292        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(String::new()),
1293        Err(e) => Err(format!("share-unreadable:{}: {e}", path.display())),
1294    }
1295}
1296
1297/// An entry's value, read from its file now.
1298fn value_of(stores: &[Store], key: &str) -> Option<String> {
1299    let (file, entry) = (file_of(key), entry_of(key));
1300    let (store, path) = locate(stores, file)?;
1301    if matches!(store.shape, Shape::Files) {
1302        let meta = std::fs::metadata(&path).ok()?;
1303        let (h, size) = crate::blobs::hash_file(&path).ok()?;
1304        return entry.is_empty().then(|| reference(&h, size, modified_ns(&meta)));
1305    }
1306    let bytes = sioul_core::filelock::read_whole(&path).ok()?;
1307    entries_of(store, &bytes)?.into_iter().find(|(e, _)| e == entry).map(|(_, value)| value)
1308}
1309
1310/// A value as TOML text, `v = …`: one form for one value, whatever the file's layout.
1311fn leaf_text(value: &toml::Value) -> String {
1312    let mut table = toml::Table::new();
1313    table.insert("v".into(), value.clone());
1314    toml::to_string(&table).unwrap_or_default()
1315}
1316
1317fn leaf_value(text: &str) -> Result<toml::Value, String> {
1318    let mut table: toml::Table = text.parse().map_err(|e: toml::de::Error| e.to_string())?;
1319    table.remove("v").ok_or_else(|| "no value".to_string())
1320}
1321
1322fn matches_rule(path: &[String], rule: &str) -> bool {
1323    let parts: Vec<&str> = rule.split('.').collect();
1324    parts.len() == path.len() && parts.iter().zip(path).all(|(r, p)| *r == "*" || r == p)
1325}
1326
1327/// An element's name in its list: its naming fields, or the element itself.
1328fn identity(element: &toml::Value, by: &[&str]) -> String {
1329    if by.is_empty() {
1330        return leaf_text(element);
1331    }
1332    by.iter()
1333        .map(|field| match element.get(field) {
1334            Some(toml::Value::String(s)) => s.clone(),
1335            Some(other) => other.to_string(),
1336            None => String::new(),
1337        })
1338        .collect::<Vec<_>>()
1339        .join("\u{1d}")
1340}
1341
1342fn strip_local(element: &mut toml::Value, local: &[&str]) {
1343    if let toml::Value::Table(table) = element {
1344        for field in local {
1345            table.remove(*field);
1346        }
1347    }
1348}
1349
1350/// The keyed list at `path` in a file: by its name at the top (or one of its
1351/// other names, `Keyed::also`), by its pattern within tables.
1352fn keyed_at<'r>(rules: &'r Rules, path: &[String]) -> Option<&'r Keyed> {
1353    rules.keyed.iter().find(|k| match path {
1354        [name] => k.list == name || k.also.contains(&name.as_str()),
1355        _ => k.list.contains('.') && matches_rule(path, k.list),
1356    })
1357}
1358
1359/// The keyed list a record's entry names (`<list>␟␞<element>`, `<table>␟…␟<list>␟␞<element>` within tables).
1360fn keyed_named<'r>(rules: &'r Rules, list: &[&str]) -> Option<&'r Keyed> {
1361    let path: Vec<String> = list.iter().map(|part| (*part).to_string()).collect();
1362    rules.keyed.iter().find(|k| match list {
1363        [name] => k.list == *name,
1364        _ => k.list.contains('.') && matches_rule(&path, k.list),
1365    })
1366}
1367
1368/// An element's own id, when it has one.
1369fn own_id(element: &toml::Value) -> Option<&str> {
1370    element.get("id").and_then(toml::Value::as_str).filter(|id| !id.is_empty())
1371}
1372
1373/// The id format 2 gives an element without one: from its list and `what`,
1374/// the stable key format 1 named it by (`identity`: a session's start, task
1375/// and project; a money line itself), the same on every device. Written into
1376/// the element once, when the device takes format 2 (`switch_format`); until
1377/// then, and for an element made without one since (by hand), its name.
1378/// Shaped as a UUID (version 8: made here, by name).
1379pub(crate) fn derived(list: &str, what: &str) -> String {
1380    use sha2::Digest;
1381    let mut hasher = sha2::Sha256::new();
1382    hasher.update(b"sioul-id\x1f");
1383    hasher.update(list.as_bytes());
1384    hasher.update(b"\x1f");
1385    hasher.update(what.as_bytes());
1386    let digest = hasher.finalize();
1387    let mut bytes = [0u8; 16];
1388    bytes.copy_from_slice(&digest[..16]);
1389    bytes[6] = (bytes[6] & 0x0f) | 0x80;
1390    bytes[8] = (bytes[8] & 0x3f) | 0x80;
1391    uuid::Uuid::from_bytes(bytes).to_string()
1392}
1393
1394/// The names of a keyed list's elements (their local fields taken out), in
1395/// their order. Format 1: as `by` says, or the element itself. Format 2
1396/// (`Keyed::ids`): each element's own `id`; one without (made by hand), or
1397/// whose id another element of the list holds too, by the id derived from
1398/// the key format 1 named it by (`derived`): a stable key, which changing
1399/// what the element holds does not change for a session (its start, task
1400/// and project), and the same on every device. Elements format 1 named
1401/// alike are one entry, as they were in format 1.
1402fn names(elements: &[toml::Value], keyed: &Keyed) -> Vec<String> {
1403    if !keyed.ids {
1404        return elements.iter().map(|e| identity(e, keyed.by)).collect();
1405    }
1406    let mut ids: BTreeMap<&str, usize> = BTreeMap::new();
1407    for id in elements.iter().filter_map(own_id) {
1408        *ids.entry(id).or_default() += 1;
1409    }
1410    elements
1411        .iter()
1412        .map(|element| match own_id(element) {
1413            Some(id) if ids.get(id) == Some(&1) => id.to_string(),
1414            _ => derived(keyed.list, &identity(element, keyed.by)),
1415        })
1416        .collect()
1417}
1418
1419/// A TOML file's entries: one per setting, one per element of a keyed list,
1420/// one per table kept whole; the settings that stay here left out.
1421fn toml_entries(text: &str, rules: &Rules) -> Result<Vec<(String, String)>, String> {
1422    let table: toml::Table = text.parse().map_err(|e: toml::de::Error| e.to_string())?;
1423    let mut out = Vec::new();
1424    for (name, value) in &table {
1425        flatten(vec![name.clone()], value, rules, &mut out);
1426    }
1427    Ok(out)
1428}
1429
1430/// The record's name of a keyed list at `path`: its own at the top, its path within tables.
1431fn list_name(keyed: &Keyed, path: &[String]) -> String {
1432    if path.len() == 1 { keyed.list.to_string() } else { path.join(&SEP.to_string()) }
1433}
1434
1435fn flatten(path: Vec<String>, value: &toml::Value, rules: &Rules, out: &mut Vec<(String, String)>) {
1436    if rules.local.iter().any(|rule| matches_rule(&path, rule)) {
1437        return;
1438    }
1439    if let toml::Value::Array(elements) = value
1440        && let Some(keyed) = keyed_at(rules, &path)
1441    {
1442        let list = list_name(keyed, &path);
1443        let elements: Vec<toml::Value> = elements
1444            .iter()
1445            .map(|element| {
1446                let mut element = element.clone();
1447                strip_local(&mut element, keyed.local);
1448                element
1449            })
1450            .collect();
1451        for (element, name) in elements.iter().zip(names(&elements, keyed)) {
1452            out.push((format!("{list}{SEP}{MARK}{name}"), leaf_text(element)));
1453        }
1454        return;
1455    }
1456    match value {
1457        toml::Value::Table(table) if !rules.whole.iter().any(|rule| matches_rule(&path, rule)) => {
1458            for (name, value) in table {
1459                let mut deeper = path.clone();
1460                deeper.push(name.clone());
1461                flatten(deeper, value, rules, out);
1462            }
1463        }
1464        _ => out.push((path.join(&SEP.to_string()), leaf_text(value))),
1465    }
1466}
1467
1468// ---------------------------------------------------------------- writing entries
1469
1470/// Writes entries into one file: each `(entry, value)`, `None` taking it out;
1471/// under the file's lock, as the window writes some of these files too
1472/// (the doses' record: `sioul_core::filelock`). Before the file changes, or
1473/// goes, `keep` keeps it as it was (`history`).
1474fn write_entries(store: &Store, path: &Path, changes: &[(&str, Option<&str>)], keep: impl FnOnce() -> Result<(), String>) -> Result<BTreeMap<String, String>, String> {
1475    sioul_core::filelock::with_lock(path, || {
1476        let new = rewritten(store, path, changes)?;
1477        // What the file holds once written, entry by entry, by hash: what the next look compares with.
1478        let written: BTreeMap<String, String> = new.as_deref().and_then(|bytes| entries_of(store, bytes)).unwrap_or_default().into_iter().map(|(entry, value)| (entry, hash(&value))).collect();
1479        let old = std::fs::read(path).ok();
1480        if old == new {
1481            return Ok(written);
1482        }
1483        // A doses' record not here that would hold no entry (only removals
1484        // came): nothing written. Written empty, with its witness, it would
1485        // read as lost and be repaired again and again.
1486        if old.is_none() && of_the_doses(store) && written.is_empty() {
1487            return Ok(written);
1488        }
1489        if old.is_some() {
1490            keep()?;
1491        }
1492        match new {
1493            // The doses' records as the window writes them (`write_witnessed`):
1494            // their bytes on the disk before they take the name, and their
1495            // witnesses made, so that a power cut after a mark received here
1496            // leaves a record lost, said, never a sound empty one (review of
1497            // 10 October 2026, R6).
1498            Some(bytes) if of_the_doses(store) => sioul_core::health::write_witnessed(path, &bytes)?,
1499            Some(bytes) => write_atomically(path, &bytes)?,
1500            None => match std::fs::remove_file(path) {
1501                Err(e) if e.kind() != std::io::ErrorKind::NotFound => return Err(format!("{}: {e}", path.display())),
1502                _ => {}
1503            },
1504        }
1505        Ok(written)
1506    })
1507}
1508
1509/// The doses' records: the marks (`health-state.toml`), each dose's record
1510/// (`health-doses.toml`), and what each device did to remind it
1511/// (`health-reminders.toml`).
1512fn of_the_doses(store: &Store) -> bool {
1513    matches!(store.name.as_str(), "state/health-state.toml" | "state/health-doses.toml" | "state/health-reminders.toml")
1514}
1515
1516/// What a file holds once entries are written into it; none when it goes.
1517fn rewritten(store: &Store, path: &Path, changes: &[(&str, Option<&str>)]) -> Result<Option<Vec<u8>>, String> {
1518    match store.shape {
1519        Shape::Whole => {
1520            let (_, value) = changes.last().ok_or("nothing")?;
1521            value.map(|value| B64.decode(value).map_err(|e| e.to_string())).transpose()
1522        }
1523        Shape::Files => Err(format!("{}: sealed apart", path.display())),
1524        Shape::Lines => {
1525            let text = text_to_write(path)?;
1526            let mut lines: Vec<String> = text.lines().map(str::to_string).collect();
1527            for (entry, value) in changes {
1528                let at = lines.iter().position(|l| l.trim() == *entry);
1529                match (value, at) {
1530                    (Some(_), None) => lines.push(entry.to_string()),
1531                    (None, Some(at)) => {
1532                        lines.remove(at);
1533                    }
1534                    _ => {}
1535                }
1536            }
1537            let mut text = lines.join("\n");
1538            if !text.is_empty() {
1539                text.push('\n');
1540            }
1541            Ok(Some(text.into_bytes()))
1542        }
1543        Shape::Toml(rules) => {
1544            let text = text_to_write(path)?;
1545            // A file here under another name than its record's (the projects'
1546            // file renamed): a list new to it takes its other name.
1547            let renamed = path.file_name() != Path::new(&store.name).file_name();
1548            Ok(Some(toml_write(&text, rules, changes, renamed)?.into_bytes()))
1549        }
1550    }
1551}
1552
1553/// A file written whole beside its place under a hidden name, yours alone
1554/// (`new_private`), then renamed over it: never half a file, and its folders
1555/// made yours alone (`private_dirs`).
1556pub fn write_atomically(path: &Path, bytes: &[u8]) -> Result<(), String> {
1557    write_then_rename(path, bytes, false)
1558}
1559
1560/// The same, the bytes on the disk before the name changes: a file whose loss
1561/// costs more than a minute's work (this computer's name and sharing, its memory).
1562pub(crate) fn write_synced(path: &Path, bytes: &[u8]) -> Result<(), String> {
1563    write_then_rename(path, bytes, true)
1564}
1565
1566fn write_then_rename(path: &Path, bytes: &[u8], synced: bool) -> Result<(), String> {
1567    let fail = |e: std::io::Error| format!("{}: {e}", path.display());
1568    if let Some(parent) = path.parent() {
1569        private_dirs(parent).map_err(fail)?;
1570    }
1571    // A name starting with a dot: sync apps leave it alone (eDrive skips them,
1572    // Nextcloud's client by default), so a half-written file never travels.
1573    // Yours alone (`new_private`): the file it replaces takes its mode too.
1574    let temporary = temporary(path);
1575    let written = (|| {
1576        let mut file = new_private(&temporary)?;
1577        file.write_all(bytes)?;
1578        if synced {
1579            file.sync_all()?;
1580        }
1581        std::fs::rename(&temporary, path)
1582    })();
1583    if written.is_err() {
1584        let _ = std::fs::remove_file(&temporary);
1585    }
1586    written.map_err(fail)
1587}
1588
1589/// A small file of the folder (a computer's notes, the seal, a claim), never
1590/// read past `SMALL_FILE`: one larger is no such file (whoever wrote it).
1591pub(crate) fn read_small(path: &Path) -> Option<String> {
1592    use std::io::Read;
1593    let mut text = String::new();
1594    std::fs::File::open(path).ok()?.take(SMALL_FILE + 1).read_to_string(&mut text).ok()?;
1595    (text.len() as u64 <= SMALL_FILE).then_some(text)
1596}
1597
1598/// Small files of the folder are never read past this.
1599pub(crate) const SMALL_FILE: u64 = 64 << 10;
1600
1601/// A hidden name beside `path` for a file being written, never another's:
1602/// two exchanges, two Sioul running, never write into one (".lease.md.4211-7.sioul.tmp").
1603pub(crate) fn temporary(path: &Path) -> PathBuf {
1604    static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
1605    let name = path.file_name().map_or_else(|| "file".into(), |n| n.to_string_lossy().to_string());
1606    let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1607    path.with_file_name(format!(".{name}.{}-{n}{TEMPORARY}", std::process::id()))
1608}
1609
1610/// How the names of files being written end: what is left of them after a
1611/// crash is cleaned after an hour (`clean_leftovers`).
1612pub(crate) const TEMPORARY: &str = ".sioul.tmp";
1613
1614/// A file's last change set to `when`, through a handle that cannot write it.
1615/// On Windows, a handle on its attributes alone (`FILE_WRITE_ATTRIBUTES`):
1616/// closing a handle that could write the file has the antivirus read it
1617/// whole before the close returns, a quarter of a second and more for a
1618/// note of 24 MB, under the exchanges' lock when a put back dates it there
1619/// (CI's Windows job, 19 October 2026); an attribute's handle is not
1620/// scanned. Elsewhere a read handle: the owner sets its times
1621/// (`futimens`); where a file system asks for write access to set them, a
1622/// write handle after all.
1623pub(crate) fn set_time(path: &Path, when: std::time::SystemTime) -> std::io::Result<()> {
1624    #[cfg(windows)]
1625    {
1626        use std::os::windows::fs::OpenOptionsExt;
1627        const FILE_WRITE_ATTRIBUTES: u32 = 0x0100;
1628        const SYNCHRONIZE: u32 = 0x0010_0000;
1629        std::fs::OpenOptions::new().access_mode(FILE_WRITE_ATTRIBUTES | SYNCHRONIZE).open(path)?.set_modified(when)
1630    }
1631    #[cfg(not(windows))]
1632    {
1633        match std::fs::File::open(path)?.set_modified(when) {
1634            Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => std::fs::File::options().write(true).open(path)?.set_modified(when),
1635            done => done,
1636        }
1637    }
1638}
1639
1640// ---------------------------------------------------------------- yours alone
1641
1642/// What the sharing writes is yours alone on Unix, as the calls' logs are
1643/// (`sioul_core::calls::append_private`): its files 0600, the folders it
1644/// makes 0700, whatever the umask would let others read (the records, sealed
1645/// files, the files it brings in from your other devices, the copies it
1646/// keeps). A file there already with a wider mode is made yours alone the
1647/// next time the sharing writes it: written beside then renamed, it takes the
1648/// new file's mode; appended to or copied, its mode is set. Never by a sweep
1649/// of your folders. Where modes mean nothing (a phone's shared storage,
1650/// Windows), nothing is asked and nothing said. These folders made, with
1651/// their parents, each 0700; folders there already keep their mode.
1652pub fn private_dirs(dir: &Path) -> std::io::Result<()> {
1653    let mut builder = std::fs::DirBuilder::new();
1654    builder.recursive(true);
1655    #[cfg(unix)]
1656    std::os::unix::fs::DirBuilderExt::mode(&mut builder, 0o700);
1657    builder.create(dir)
1658}
1659
1660/// A new file to write, yours alone (0600 on Unix); an error when one is there (`create_new`).
1661pub(crate) fn new_private(path: &Path) -> std::io::Result<std::fs::File> {
1662    let mut options = std::fs::OpenOptions::new();
1663    options.write(true).create_new(true);
1664    #[cfg(unix)]
1665    std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600);
1666    options.open(path)
1667}
1668
1669/// A file the sharing copied (a copy takes its source's mode) or appends to,
1670/// made yours alone (0600) when others could read it. Never through a link.
1671pub(crate) fn make_private(path: &Path) {
1672    #[cfg(unix)]
1673    {
1674        use std::os::unix::fs::PermissionsExt;
1675        if let Ok(metadata) = std::fs::symlink_metadata(path)
1676            && metadata.is_file()
1677            && metadata.permissions().mode() & 0o077 != 0
1678        {
1679            let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
1680        }
1681    }
1682    #[cfg(not(unix))]
1683    let _ = path;
1684}
1685
1686/// `from` copied to `to`, yours alone (`make_private`), as one version of it
1687/// held it whole (`filelock::copy_whole`: never a file replaced while it was
1688/// copied, whose old bytes may be zeros by the end).
1689pub(crate) fn copy_private(from: &Path, to: &Path) -> std::io::Result<()> {
1690    sioul_core::filelock::copy_whole(from, to)?;
1691    make_private(to);
1692    Ok(())
1693}
1694
1695/// How far a rewritten file's size climbs before it starts again from the smallest.
1696const PAD_LIMIT: u64 = 4096;
1697
1698/// A file written again in place must change size. The sharing asks little of
1699/// the sync app that carries the folder (docs/database.md, "What the sync app
1700/// must do"): new files and files that grow, copied some day, in any order.
1701/// Some sync apps tell a change by its size alone: eDrive (Murena's, on /e/OS)
1702/// downloads a file changed elsewhere only when its size differs from the copy
1703/// it holds (its `FileDiffUtils.getActionForFileDiff`), and others compare size
1704/// and time. So each rewrite is padded one step past the size of the file there
1705/// now, up to `PAD_LIMIT`, then from the smallest again: a copy held elsewhere
1706/// matches the newest only when a whole turn of rewrites (several hundred)
1707/// went by unseen, and the next rewrite mends it. `size(pad)` is the file's
1708/// size with `pad` characters of padding.
1709pub(crate) fn pad_for(path: &Path, size: impl Fn(usize) -> u64) -> usize {
1710    let base = size(0);
1711    let Ok(before) = std::fs::metadata(path).map(|m| m.len()) else { return 0 };
1712    if before < base + PAD_LIMIT {
1713        // One step past it: the padding grows a character at a time from about the right length.
1714        let mut pad = usize::try_from(before.saturating_sub(base) * 3 / 4).unwrap_or(0);
1715        while size(pad) <= before {
1716            pad += 1;
1717        }
1718        if size(pad) <= base + PAD_LIMIT {
1719            return pad;
1720        }
1721    }
1722    // A whole turn: the smallest again, never the size there now.
1723    (0..).find(|&pad| size(pad) != before).unwrap_or(0)
1724}
1725
1726/// A TOML value as toml_edit holds it: a table stays a table, a list of tables a list of tables.
1727fn edit_item(text: &str) -> Result<toml_edit::Item, String> {
1728    let doc: toml_edit::DocumentMut = text.parse().map_err(|e: toml_edit::TomlError| e.to_string())?;
1729    let mut item = doc.as_table().get("v").cloned().ok_or("no value")?;
1730    match &mut item {
1731        toml_edit::Item::Value(value) => value.decor_mut().clear(),
1732        toml_edit::Item::Table(table) => table.decor_mut().clear(),
1733        _ => {}
1734    }
1735    Ok(item)
1736}
1737
1738/// Sets or takes out entries in a TOML file's text, keeping the rest as
1739/// written; `renamed`: the file has another name than its record's (`Keyed::also`).
1740fn toml_write(text: &str, rules: &Rules, changes: &[(&str, Option<&str>)], renamed: bool) -> Result<String, String> {
1741    let mut doc: toml_edit::DocumentMut = text.parse().map_err(|e: toml_edit::TomlError| e.to_string())?;
1742    // The elements of keyed lists, list by list, named as the file holds them before any of them changes.
1743    let mut lists: Vec<(Vec<&str>, Vec<(&str, Option<&str>)>)> = Vec::new();
1744    for (entry, value) in changes {
1745        let path: Vec<&str> = entry.split(SEP).collect();
1746        if let Some((element, list)) = path.split_last()
1747            && let Some(id) = element.strip_prefix(MARK)
1748            && !list.is_empty()
1749        {
1750            match lists.iter_mut().find(|(known, _)| known.as_slice() == list) {
1751                Some((_, elements)) => elements.push((id, *value)),
1752                None => lists.push((list.to_vec(), vec![(id, *value)])),
1753            }
1754        } else {
1755            let item = value.map(edit_item).transpose()?;
1756            set_path(doc.as_table_mut(), &path, item.as_ref())?;
1757        }
1758    }
1759    for (list, elements) in lists {
1760        let keyed = keyed_named(rules, &list).ok_or_else(|| format!("{}: not a list", list.join(".")))?;
1761        let Some((name, within)) = list.split_last() else { continue };
1762        let Some(table) = table_at(doc.as_table_mut(), within, elements.iter().any(|(_, value)| value.is_some()))? else { continue };
1763        write_elements(table, keyed, name, &elements, renamed && within.is_empty())?;
1764    }
1765    Ok(doc.to_string())
1766}
1767
1768/// The table at `path` below `table`: made when `make` (implicit, as a
1769/// file's own tables are), else none when it is not there. A table written
1770/// inline becomes a table of its own.
1771fn table_at<'t>(table: &'t mut toml_edit::Table, path: &[&str], make: bool) -> Result<Option<&'t mut toml_edit::Table>, String> {
1772    let Some((first, rest)) = path.split_first() else { return Ok(Some(table)) };
1773    if !make && !table.contains_key(first) {
1774        return Ok(None);
1775    }
1776    let item = table.entry(first).or_insert_with(|| {
1777        let mut new = toml_edit::Table::new();
1778        new.set_implicit(true);
1779        toml_edit::Item::Table(new)
1780    });
1781    if let Some(inline) = item.as_inline_table().cloned() {
1782        *item = toml_edit::Item::Table(inline.into_table());
1783    }
1784    let inner = item.as_table_mut().ok_or_else(|| format!("{first}: not a table"))?;
1785    table_at(inner, rest, make)
1786}
1787
1788/// The names of a list's elements as read (local fields out); none for one that does not read.
1789fn names_read(elements: &[Option<toml::Value>], keyed: &Keyed) -> Vec<Option<String>> {
1790    let read: Vec<toml::Value> = elements.iter().flatten().cloned().collect();
1791    let mut named = names(&read, keyed).into_iter();
1792    elements.iter().map(|element| element.as_ref().and_then(|_| named.next())).collect()
1793}
1794
1795fn set_path(table: &mut toml_edit::Table, path: &[&str], value: Option<&toml_edit::Item>) -> Result<(), String> {
1796    match path {
1797        [] => Ok(()),
1798        [last] => {
1799            match value {
1800                // A value replaced keeps its comments and spacing.
1801                Some(toml_edit::Item::Value(new)) if table.get(last).is_some_and(toml_edit::Item::is_value) => {
1802                    if let Some(old) = table.get_mut(last).and_then(toml_edit::Item::as_value_mut) {
1803                        let decor = old.decor().clone();
1804                        *old = new.clone();
1805                        *old.decor_mut() = decor;
1806                    }
1807                }
1808                Some(item) => {
1809                    table.insert(last, item.clone());
1810                }
1811                None => {
1812                    table.remove(last);
1813                }
1814            }
1815            Ok(())
1816        }
1817        [first, rest @ ..] => {
1818            if value.is_none() && !table.contains_key(first) {
1819                return Ok(());
1820            }
1821            let item = table.entry(first).or_insert_with(|| {
1822                let mut new = toml_edit::Table::new();
1823                new.set_implicit(true);
1824                toml_edit::Item::Table(new)
1825            });
1826            if let Some(inline) = item.as_inline_table().cloned() {
1827                *item = toml_edit::Item::Table(inline.into_table());
1828            }
1829            let inner = item.as_table_mut().ok_or_else(|| format!("{first}: not a table"))?;
1830            set_path(inner, rest, value)
1831        }
1832    }
1833}
1834
1835/// Where a store is read, as the memory keeps it (`Memory::places`): its
1836/// path, the projects' file under its first name whichever it has, so that
1837/// renaming it (`sioul_core::projects::rename_file`) is no move: a move
1838/// rebuilds the file from the records, and a change made since would go.
1839fn place_of(store: &Store) -> String {
1840    let renamed = !store.folder && store.path.file_name().is_some_and(|name| name == sioul_core::projects::FILE);
1841    let path = if renamed { store.path.with_file_name(sioul_core::projects::OLD_FILE) } else { store.path.clone() };
1842    path.display().to_string()
1843}
1844
1845/// A table read from an entry's text, placed in a file: written where it
1846/// stands, its own tables under it. toml_edit writes a file's tables in the
1847/// order of the positions they were read at; those read from the entry's
1848/// text would put them out of place (a project's routes under the project
1849/// after it). So its tables keep none, and it takes `at`, the position of
1850/// the one it replaces.
1851fn placed(mut table: toml_edit::Table, at: Option<isize>) -> toml_edit::Table {
1852    fn clear(table: &mut toml_edit::Table) {
1853        table.set_position(None);
1854        for (_, item) in table.iter_mut() {
1855            match item {
1856                toml_edit::Item::Table(inner) => clear(inner),
1857                toml_edit::Item::ArrayOfTables(list) => list.iter_mut().for_each(clear),
1858                _ => {}
1859            }
1860        }
1861    }
1862    clear(&mut table);
1863    table.set_position(at);
1864    table
1865}
1866
1867/// The elements named so of a keyed list in `table` (`changes`: each name
1868/// and its value, none to take it out), all named as the list holds them
1869/// before any of them changes: replaced (their local fields kept), added, or
1870/// taken out. `list`:
1871/// its name in the records; at the top, the list keeps the name it has in
1872/// the file (`Keyed::also`).
1873fn write_elements(table: &mut toml_edit::Table, keyed: &Keyed, list: &str, changes: &[(&str, Option<&str>)], renamed: bool) -> Result<(), String> {
1874    let normal = |text: String| -> Option<toml::Value> {
1875        let mut element = leaf_value(&text).ok()?;
1876        strip_local(&mut element, keyed.local);
1877        Some(element)
1878    };
1879    let news: Vec<(&str, Option<toml_edit::Item>)> = changes.iter().map(|(id, value)| Ok((*id, value.map(edit_item).transpose()?))).collect::<Result<_, String>>()?;
1880    let others: &[&str] = if keyed.list == list { keyed.also } else { &[] };
1881    let name = std::iter::once(list).chain(others.iter().copied()).find(|n| table.contains_key(n)).unwrap_or(match others.first() {
1882        Some(other) if renamed => other,
1883        _ => list,
1884    });
1885    if !table.contains_key(name) {
1886        match news.iter().find_map(|(_, new)| new.as_ref()) {
1887            Some(toml_edit::Item::Table(_)) => {
1888                table.insert(name, toml_edit::Item::ArrayOfTables(toml_edit::ArrayOfTables::new()));
1889            }
1890            Some(_) => {
1891                table.insert(name, toml_edit::value(toml_edit::Array::new()));
1892            }
1893            None => return Ok(()),
1894        }
1895    }
1896    let mut gone: Vec<usize> = Vec::new();
1897    match table.get_mut(name) {
1898        Some(toml_edit::Item::ArrayOfTables(elements)) => {
1899            let named = names_read(&elements.iter().map(|t| normal(format!("[v]\n{t}"))).collect::<Vec<_>>(), keyed);
1900            let mut added: Vec<toml_edit::Table> = Vec::new();
1901            for (id, new) in news {
1902                let at = named.iter().position(|n| n.as_deref() == Some(id));
1903                match (new, at) {
1904                    (Some(toml_edit::Item::Table(mut element)), Some(at)) => {
1905                        if let Some(old) = elements.get(at) {
1906                            for field in keyed.local {
1907                                if let Some(kept) = old.get(field) {
1908                                    element.insert(field, kept.clone());
1909                                }
1910                            }
1911                            // Its id never stripped by a value written without one (a
1912                            // version from before the switch put back, an older
1913                            // Sioul's): it names this element.
1914                            if keyed.ids
1915                                && !element.contains_key("id")
1916                                && let Some(id) = old.get("id")
1917                            {
1918                                element.insert("id", id.clone());
1919                            }
1920                        }
1921                        if let Some(place) = elements.get_mut(at) {
1922                            let position = place.position();
1923                            *place = placed(element, position);
1924                        }
1925                    }
1926                    (Some(toml_edit::Item::Table(element)), None) => added.push(element),
1927                    (None, Some(at)) => gone.push(at),
1928                    _ => {}
1929                }
1930            }
1931            for element in added {
1932                elements.push(placed(element, None));
1933            }
1934            gone.sort_unstable();
1935            for at in gone.into_iter().rev() {
1936                elements.remove(at);
1937            }
1938        }
1939        Some(toml_edit::Item::Value(toml_edit::Value::Array(elements))) => {
1940            let named = names_read(&elements.iter().map(|v| normal(format!("v = {v}"))).collect::<Vec<_>>(), keyed);
1941            let mut added: Vec<toml_edit::Value> = Vec::new();
1942            for (id, new) in news {
1943                let at = named.iter().position(|n| n.as_deref() == Some(id));
1944                match (new, at) {
1945                    (Some(toml_edit::Item::Value(element)), Some(at)) => {
1946                        elements.replace(at, element);
1947                    }
1948                    (Some(toml_edit::Item::Value(element)), None) => added.push(element),
1949                    (None, Some(at)) => gone.push(at),
1950                    _ => {}
1951                }
1952            }
1953            for element in added {
1954                elements.push(element);
1955            }
1956            gone.sort_unstable();
1957            for at in gone.into_iter().rev() {
1958                elements.remove(at);
1959            }
1960            elements.fmt();
1961        }
1962        _ => return Err(format!("{name}: not a list")),
1963    }
1964    Ok(())
1965}
1966
1967// ---------------------------------------------------------------- the seal
1968
1969/// The folder's seal: how the key is made from the passphrase, and a value to
1970/// check it with. The same on every computer; the passphrase never written.
1971#[derive(Debug, Serialize, Deserialize)]
1972struct SealFile {
1973    version: u32,
1974    salt: String,
1975    memory_kib: u32,
1976    passes: u32,
1977    check: String,
1978}
1979
1980const CHECK: &[u8] = b"sioul: the passphrase is right";
1981
1982/// Why sharing cannot start.
1983#[derive(Debug, PartialEq, Eq)]
1984pub enum Refused {
1985    /// Not the passphrase chosen on the first computer.
1986    WrongPassphrase,
1987    Other(String),
1988}
1989
1990fn derive(passphrase: &str, salt: &[u8], memory_kib: u32, passes: u32) -> Result<[u8; 32], String> {
1991    let params = argon2::Params::new(memory_kib, passes, 1, Some(32)).map_err(|e| e.to_string())?;
1992    let mut key = [0u8; 32];
1993    argon2::Argon2::new(argon2::Algorithm::Argon2id, argon2::Version::V0x13, params).hash_password_into(passphrase.as_bytes(), salt, &mut key).map_err(|e| e.to_string())?;
1994    Ok(key)
1995}
1996
1997/// Whether `bytes` are a seal of Sioul's (a folder's `seal.toml`): a few
1998/// lines that read as one, with a salt and a check. Says nothing of the
1999/// passphrase (`key_for` does): for finding the folder on a server
2000/// (`remote::seek`).
2001pub fn is_seal(bytes: &[u8]) -> bool {
2002    bytes.len() <= 4096 && std::str::from_utf8(bytes).ok().and_then(|text| toml::from_str::<SealFile>(text).ok()).is_some_and(|seal| !seal.salt.is_empty() && !seal.check.is_empty())
2003}
2004
2005/// The key for a folder: made from the passphrase and the folder's seal, which
2006/// the first computer writes (64 MiB, 3 passes: half a second, once per computer).
2007pub fn key_for(folder: &Path, passphrase: &str) -> Result<[u8; 32], Refused> {
2008    key_with(folder, passphrase, 64 * 1024, 3)
2009}
2010
2011fn key_with(folder: &Path, passphrase: &str, memory_kib: u32, passes: u32) -> Result<[u8; 32], Refused> {
2012    let path = folder.join("seal.toml");
2013    if path.exists() {
2014        // Written by Sioul in a few lines: one larger is no seal of its.
2015        let text = read_small(&path).ok_or_else(|| Refused::Other(format!("{}: not a seal", path.display())))?;
2016        let seal: SealFile = toml::from_str(&text).map_err(|e| Refused::Other(format!("{}: {e}", path.display())))?;
2017        // The folder passes through others' servers: a seal asking far more than
2018        // Sioul writes (64 MiB, 3 passes) would only exhaust this computer.
2019        if seal.memory_kib > 1 << 20 || seal.passes > 16 {
2020            return Err(Refused::Other(format!("{}: {} KiB, {} passes", path.display(), seal.memory_kib, seal.passes)));
2021        }
2022        let salt = B64.decode(&seal.salt).map_err(|e| Refused::Other(e.to_string()))?;
2023        let key = derive(passphrase, &salt, seal.memory_kib, seal.passes).map_err(Refused::Other)?;
2024        return match open(&key, "check", &seal.check) {
2025            Some(plain) if plain == CHECK => Ok(key),
2026            _ => Err(Refused::WrongPassphrase),
2027        };
2028    }
2029    let mut salt = [0u8; 16];
2030    OsRng.fill_bytes(&mut salt);
2031    let key = derive(passphrase, &salt, memory_kib, passes).map_err(Refused::Other)?;
2032    let seal = SealFile { version: 1, salt: B64.encode(salt), memory_kib, passes, check: seal(&key, "check", CHECK) };
2033    let text = format!("# Sioul: how your devices' shared records are sealed (docs/database.md).\n{}", toml::to_string(&seal).map_err(|e| Refused::Other(e.to_string()))?);
2034    private_dirs(folder).map_err(|e| Refused::Other(format!("{}: {e}", folder.display())))?;
2035    write_atomically(&path, text.as_bytes()).map_err(Refused::Other)?;
2036    Ok(key)
2037}
2038
2039/// The sharing folder as each exchange finds it first (review of 5 October
2040/// 2026, F5 and F20). Gone (a disk not mounted, a sync app's folder moved):
2041/// an error, and nothing is made, read or written (`share-no-seal`), never a
2042/// new empty folder whose files would later meet the real ones as conflicted
2043/// copies. Else its seal: none (a seal that does not read, an empty
2044/// placeholder of one, is none), or whether it opens with this device's key,
2045/// and its salt (which sharing it is).
2046/// Gone, or not opening, where this device saw it open before
2047/// (`Memory::seal_seen`: a folder made again by a sync app, another device's
2048/// seal put in its place), nothing is written either, said (`share-no-seal`,
2049/// `share-sealed-otherwise`): never under a key the others lack; sharing set
2050/// up again with the passphrase the folder's seal holds mends it.
2051fn sealed_for(folder: &Path, key: &[u8; 32]) -> Result<Option<(bool, String)>, ()> {
2052    if !folder.is_dir() {
2053        return Err(());
2054    }
2055    let Some(seal) = read_small(&folder.join("seal.toml")).and_then(|text| toml::from_str::<SealFile>(&text).ok()) else { return Ok(None) };
2056    Ok(Some((open(key, "check", &seal.check).is_some_and(|plain| plain == CHECK), seal.salt)))
2057}
2058
2059pub(crate) fn seal(key: &[u8; 32], bound: &str, plain: &[u8]) -> String {
2060    let cipher = XChaCha20Poly1305::new(key.into());
2061    let nonce = XChaCha20Poly1305::generate_nonce(&mut OsRng);
2062    let sealed = cipher.encrypt(&nonce, Payload { msg: plain, aad: bound.as_bytes() }).unwrap_or_default();
2063    B64.encode([nonce.as_slice(), &sealed].concat())
2064}
2065
2066pub(crate) fn open(key: &[u8; 32], bound: &str, text: &str) -> Option<Vec<u8>> {
2067    let bytes = B64.decode(text).ok()?;
2068    if bytes.len() < 24 {
2069        return None;
2070    }
2071    let (nonce, sealed) = bytes.split_at(24);
2072    XChaCha20Poly1305::new(key.into()).decrypt(XNonce::from_slice(nonce), Payload { msg: sealed, aad: bound.as_bytes() }).ok()
2073}
2074
2075// ---------------------------------------------------------------- this computer
2076
2077/// This computer, as sharing knows it: its name, the machine it was made on
2078/// (a copied configuration on another machine gets a name of its own), the
2079/// folder it shares through.
2080#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2081pub struct Here {
2082    #[serde(default)]
2083    pub id: String,
2084    #[serde(default)]
2085    pub machine: String,
2086    #[serde(default, skip_serializing_if = "Option::is_none")]
2087    pub folder: Option<String>,
2088    /// The parts this device shares or not, as chosen here (`PARTS`): never
2089    /// shared, each device chooses its own.
2090    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2091    pub parts: BTreeMap<String, bool>,
2092    /// Whether a sync app carries this device's notes folder, as you said on
2093    /// a phone, where Sioul cannot read the sync apps' settings; none: guessed
2094    /// (the app's `notes_carried`). Never shared.
2095    #[serde(default, skip_serializing_if = "Option::is_none")]
2096    pub notes_carried: Option<bool>,
2097    /// The notes folder that answer was given for: it counts for that folder
2098    /// alone; the notes moved (into a folder a sync app carries, or out of
2099    /// one), it is asked again, guessed meanwhile (review of 10 October 2026,
2100    /// R12). None: an answer given before, taken for the notes folder of
2101    /// then (the app writes it in at its next start).
2102    #[serde(default, skip_serializing_if = "Option::is_none")]
2103    pub notes_carried_for: Option<String>,
2104}
2105
2106impl Here {
2107    /// Whether this device shares a part: as chosen here, else as before parts had switches.
2108    pub fn shares(&self, part: &str, config: &Config) -> bool {
2109        self.parts.get(part).copied().unwrap_or_else(|| shared_by_default(part, config))
2110    }
2111
2112    pub fn path(state: &Path) -> PathBuf {
2113        state.join("share").join("here.toml")
2114    }
2115
2116    /// This computer's name, made once.
2117    pub fn load(state: &Path) -> Here {
2118        let path = Here::path(state);
2119        let mut here: Here = std::fs::read_to_string(&path).ok().and_then(|t| toml::from_str(&t).ok()).unwrap_or_default();
2120        let machine = machine();
2121        if here.id.is_empty() || (!machine.is_empty() && here.machine != machine) {
2122            here.id = uuid::Uuid::new_v4().to_string();
2123            here.machine = machine;
2124            let _ = here.save(state);
2125        }
2126        here
2127    }
2128
2129    /// Its bytes on the disk before its name changes: lost, this computer would take another name.
2130    pub fn save(&self, state: &Path) -> Result<(), String> {
2131        write_synced(&Here::path(state), toml::to_string(self).map_err(|e| e.to_string())?.as_bytes())
2132    }
2133
2134    /// The folder shared through, `~` expanded.
2135    pub fn folder_path(&self) -> Option<PathBuf> {
2136        self.folder.as_deref().filter(|f| !f.is_empty()).map(sioul_core::config::expand_home)
2137    }
2138
2139    /// What you said of the notes folder `notes` (`notes_carried`), when you
2140    /// said it of that folder.
2141    pub fn notes_said(&self, notes: &Path) -> Option<bool> {
2142        self.notes_carried.filter(|_| self.notes_carried_for.as_deref().is_none_or(|said| Path::new(said) == notes))
2143    }
2144}
2145
2146/// A hash of the machine's own id (`/etc/machine-id`), else of its name.
2147fn machine() -> String {
2148    let id = ["/etc/machine-id", "/var/lib/dbus/machine-id"].iter().find_map(|p| std::fs::read_to_string(p).ok()).or_else(|| std::env::var("COMPUTERNAME").ok()).or_else(|| std::fs::read_to_string("/etc/hostname").ok()).unwrap_or_default();
2149    let id = id.trim();
2150    if id.is_empty() { String::new() } else { format!("{:016x}", fnv(id.as_bytes())) }
2151}
2152
2153fn fnv(bytes: &[u8]) -> u64 {
2154    bytes.iter().fold(0xcbf2_9ce4_8422_2325u64, |hash, b| (hash ^ u64::from(*b)).wrapping_mul(0x0100_0000_01b3))
2155}
2156
2157pub(crate) fn hash(value: &str) -> String {
2158    format!("{:016x}", fnv(value.as_bytes()))
2159}
2160
2161// ---------------------------------------------------------------- memory
2162
2163/// What this computer remembers between two exchanges (`memory.json`);
2164/// what it remembers of files sealed apart aside (`files.json`, `Sealed`): a
2165/// big notes folder makes that long, and what the doses and claims ask
2166/// (`heard`, `written`, `traffic`) reads the rest alone.
2167#[derive(Debug, Default, Serialize, Deserialize)]
2168struct Memory {
2169    #[serde(default)]
2170    computer: String,
2171    /// The last clock given or read.
2172    #[serde(default)]
2173    clock: u64,
2174    /// This computer's round (its file) and the records in it.
2175    #[serde(default)]
2176    round: u32,
2177    #[serde(default)]
2178    seq: u64,
2179    /// The size of this computer's file after its last writing: shorter, a sync
2180    /// app put back an older copy of it, and a new one starts (`exchange`).
2181    #[serde(default)]
2182    own_size: u64,
2183    /// The size of this computer's file when its round began: a new round once
2184    /// it grew `ROUND_SIZE` past it (a round opening on more than that does not
2185    /// start another one each minute).
2186    #[serde(default)]
2187    round_base: u64,
2188    /// The last round that opens on every entry this computer holds (a full
2189    /// round, `new_round`); the rounds after it continue it, `SEGMENT` each at
2190    /// most. 0: kept by an older Sioul, whose every round was full.
2191    #[serde(default)]
2192    full_round: u32,
2193    /// What this computer appended since its last full round (bytes).
2194    #[serde(default)]
2195    grown: u64,
2196    /// The size of its last full round's file once it opened (bytes): the
2197    /// next one waits until as much was appended since (`full_round_after`).
2198    /// 0: not known yet (a memory from before 15 October 2026), read from
2199    /// that round's file in the folder.
2200    #[serde(default, skip_serializing_if = "is_zero_u64")]
2201    full_size: u64,
2202    /// The number of the last line of the round before, when this round
2203    /// continues it (`SEGMENT`): said on its first line (`Turn::p`). 0 for a
2204    /// full round.
2205    #[serde(default, skip_serializing_if = "is_zero_u64")]
2206    continues: u64,
2207    /// A full round is owed: the last round after lost lines of this
2208    /// computer's (its file cut short) could not restate all it holds (a file
2209    /// not readable then), and a reader that had not read those lines waits
2210    /// for a full round; or another device asks for one (`Seen::full`).
2211    /// Written as soon as all reads again (`new_round`).
2212    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
2213    owed: bool,
2214    /// How long the full round owed waited for a connection not metered,
2215    /// exchanges running (milliseconds, as `Awaited::waited`), and the last
2216    /// exchange that counted it: past `OWED_METERED`, it goes all the same.
2217    #[serde(default, skip_serializing_if = "is_zero_i64")]
2218    owed_waited: i64,
2219    #[serde(default, skip_serializing_if = "is_zero_i64")]
2220    owed_last: i64,
2221    /// This memory enters a round only once the one before was read to its
2222    /// end (`Turn`). One kept before (10 October 2026) may have moved past
2223    /// the end of a round not read whole: every other computer's kept rounds
2224    /// are read again once, what was known already changing nothing.
2225    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
2226    turns: bool,
2227    /// The folder's seal was seen here, opening with this device's key: gone
2228    /// from it later, nothing is written there until it is back (`sealed_for`).
2229    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
2230    seal_seen: bool,
2231    /// The salt of that seal: which sharing this memory is of. Another one
2232    /// met (another folder set up with this memory kept): started anew.
2233    #[serde(default, skip_serializing_if = "String::is_empty")]
2234    seal_salt: String,
2235    /// When this device last found its own records in the folder ahead of
2236    /// its memory (milliseconds): again soon after, another device writes
2237    /// under its name (`share-twin`).
2238    #[serde(default, skip_serializing_if = "is_zero")]
2239    ahead_at: i64,
2240    /// Every version of each draft met here, by its key: made here, received,
2241    /// or passed over (`DRAFTS`, `sent_meanwhile`). Forgotten with the draft's entry.
2242    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2243    drafts: BTreeMap<String, BTreeSet<String>>,
2244    /// When this computer's sealed files were last looked for, and the
2245    /// history and leftovers last tidied (milliseconds).
2246    #[serde(default)]
2247    checked: i64,
2248    #[serde(default)]
2249    tidied: i64,
2250    /// When what a crash left half written among the folder's sealed files
2251    /// was last cleaned (milliseconds): once a day, since listing `blobs/`
2252    /// reads thousands of names (on a phone, through its storage's slow layer).
2253    #[serde(default, skip_serializing_if = "is_zero")]
2254    cleaned: i64,
2255    /// Others' records already read in: done once, when sharing starts.
2256    #[serde(default)]
2257    joined: bool,
2258    /// How far each other computer's records were read: its round and the bytes read in it.
2259    #[serde(default)]
2260    read: BTreeMap<String, (u32, u64)>,
2261    /// The last record read from each other computer: its round and number.
2262    /// Its claims say how far it wrote (`lease::Claim::wrote`): read that far,
2263    /// everything it said until then is known here (docs/health.md, "Knowing").
2264    #[serde(default)]
2265    read_n: BTreeMap<String, (u32, u64)>,
2266    /// The last line of each other computer met here, read or found damaged
2267    /// (its number then inferred: the one after the line met before): its
2268    /// round and number. A round continuing the one before is entered once
2269    /// that one was met to its last line (`Turn`), whatever was lost in it,
2270    /// which `read_n` keeps said for the doses. Missing (a memory kept before
2271    /// 10 October 2026): as `read_n`.
2272    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2273    met: BTreeMap<String, (u32, u64)>,
2274    /// Each other computer's round waiting here for the end of the one
2275    /// before (`Turn`), and how long it waited, exchanges running: passed
2276    /// over as lost past `AWAITED`. (`awaited`, which counted the clock's
2277    /// time, is left aside.)
2278    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2279    waiting_for: BTreeMap<String, Awaited>,
2280    /// When a line of another computer's could not be read here (Unix seconds): what it said is lost.
2281    #[serde(default)]
2282    broken: BTreeMap<String, i64>,
2283    /// Files whose entries are read again from every computer's records, this
2284    /// one's too, at the next exchange (`rebuild`): a record lost here comes back.
2285    #[serde(default)]
2286    rebuild: BTreeSet<String>,
2287    /// Of those, the files rebuilt without being set aside (`rebuild`): what
2288    /// they hold may be what was there before, so at that exchange they join
2289    /// as a store new here does, read from the records first, only what the
2290    /// records lack going out. Not those set aside in the same step
2291    /// (`rebuild_then`): what they hold was written after the repair, and goes
2292    /// out as changes made here (review of 15 October 2026, RF-J).
2293    #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
2294    rejoin: BTreeSet<String>,
2295    /// Others' changes not written yet (their file could not be read, or is
2296    /// one this version does not know): tried again at each exchange.
2297    #[serde(default)]
2298    pending: BTreeMap<String, (Option<String>, u64, String)>,
2299    /// Each entry's last change: its clock, the computer that made it, its value's hash ("" once taken out).
2300    #[serde(default)]
2301    entries: BTreeMap<String, Known>,
2302    /// Where each store was read (its path): a store new here, or moved (another
2303    /// notes folder), joins as a new computer would (`exchange`).
2304    #[serde(default)]
2305    places: BTreeMap<String, String>,
2306    /// Files found empty or gone after holding entries, since when (milliseconds):
2307    /// their entries count as taken out only once that has lasted (`EMPTIED_WAIT`).
2308    #[serde(default)]
2309    emptied: BTreeMap<String, i64>,
2310    /// The files as last read.
2311    #[serde(default)]
2312    files: BTreeMap<String, Stat>,
2313    /// When each part last sent a change, and last received one here (Unix seconds).
2314    #[serde(default)]
2315    traffic: BTreeMap<String, (i64, i64)>,
2316    /// The format this device writes (`FORMAT`): 0 (an older Sioul's memory)
2317    /// or 1 until every device in the sharing reads format 2 (`format_open`),
2318    /// then 2, once and for all (`switch_format`).
2319    #[serde(default)]
2320    format: u32,
2321    /// A record in format 2 met while this device still wrote format 1:
2322    /// another device writes it already; this one does from its next exchange.
2323    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
2324    switch: bool,
2325    /// Each format-1 entry format 2 renamed: what format 1 last said under it
2326    /// here, by a hash of its name (`Floor`, `floor_key`): read again, format
2327    /// 1 at or below it is passed over (`translate`).
2328    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2329    floors: BTreeMap<String, Floor>,
2330    /// Others' changes in a format this device does not write yet (format 2,
2331    /// before its switch) or does not know (a newer Sioul's): their value,
2332    /// clock, computer and format, written once it can.
2333    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2334    waiting: BTreeMap<String, (Option<String>, u64, String, u32)>,
2335    /// Parts met in a format newer than this Sioul knows (`Newer`). Nothing of
2336    /// them is sent from here, nor written here, until this Sioul is updated,
2337    /// or their writers no longer count (`release_newer`) (`share-newer`).
2338    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2339    newer: BTreeMap<String, Newer>,
2340    /// Parts no longer held for writers that no longer count: their records
2341    /// in that newer format up to this clock, read again, are passed over.
2342    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2343    released: BTreeMap<String, u64>,
2344    /// Changes found here while their part was held (`newer`): when they were
2345    /// found (a clock) and what they held (a hash; "" taken out). Once the part
2346    /// is no longer held they go out at that time, not later: a newer
2347    /// device's later change still wins over them.
2348    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2349    held: BTreeMap<String, (u64, String)>,
2350    /// When each other device was last heard of here, from its records read
2351    /// (milliseconds, its clock): what tells a device still there from one
2352    /// gone, with its sealed entry, never its plain notes (`present`).
2353    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2354    heard_at: BTreeMap<String, i64>,
2355    /// Devices you said are gone (`forget_device`): when they were last heard
2356    /// of then (milliseconds). They count again once heard of later.
2357    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2358    forgotten: BTreeMap<String, i64>,
2359    /// Each other device's latest sealed entry seen here (`note_entries`): an
2360    /// older copy put back in the folder never lowers what is known of it.
2361    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2362    seen: BTreeMap<String, crate::devices::Entry>,
2363    /// The devices known here by records read before (`read_n`) of which no
2364    /// clock is known at all (their rounds gone from the folder, read by a
2365    /// memory from before `heard_at` was kept, none of their changes the last
2366    /// word here), with when this device first found them so (milliseconds,
2367    /// its clock): what ages them (`present`), so that one gone for good
2368    /// stops counting 180 days later.
2369    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2370    unclocked: BTreeMap<String, i64>,
2371    /// The other devices that count, as the last exchange found them by
2372    /// sealed facts (`counted`), and those sealed facts say no longer count
2373    /// (they left, you forgot them, another replaces them, silent
2374    /// `SILENT_DAYS`): who holds this device's rounds back, as the sending
2375    /// reads it without the key (`rounds_past`). None in a memory saved
2376    /// before 16 October 2026: the rule as it was, until the next exchange.
2377    #[serde(default, skip_serializing_if = "Option::is_none")]
2378    counting: Option<BTreeSet<String>>,
2379    #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
2380    uncounted: BTreeSet<String>,
2381    /// Each device that no longer counts by a sealed fact and has rounds
2382    /// here: the round below which they are past their time (`departed_rounds`),
2383    /// taken out of the folder here, never fetched again, taken out of the
2384    /// server by the sending (`departed_past`).
2385    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2386    departed: BTreeMap<String, u32>,
2387    /// Sealed files swept here as written by a device that no longer counts
2388    /// (`sweep_theirs`), and when (milliseconds): the sending takes each out
2389    /// of the server too (`swept_theirs`); forgotten after `UNUSED_DAYS`, or
2390    /// once one is back in the folder (a device uses that content again).
2391    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2392    taken: BTreeMap<String, i64>,
2393    /// Notes and papers gone many at once from this device, held until they
2394    /// leave by themselves (`GONE_GRACE`), summed up for the Porch and
2395    /// Settings (`withheld`): as the last exchange found them.
2396    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2397    withheld: Vec<Withheld>,
2398    /// Devices whose files you removed from the folder here ("Remove its
2399    /// files", `remove_device_files`), and what went (`RemovedDevice`). What
2400    /// they had written then is never fetched again, and the sending takes
2401    /// what is still on the server out; what such a device writes after
2402    /// (still in use, it sends everything again) comes as any device's, and
2403    /// it counts again.
2404    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2405    removed_devices: BTreeMap<String, RemovedDevice>,
2406    /// What the whole lists of format 1 seen here held (lists format 2
2407    /// divides), by format 2's names: each list record read, at its clock, in
2408    /// whatever order (`note_whole_record`), and, while this device writes
2409    /// format 1, the file between exchanges (`note_whole_lists`); what a later
2410    /// list took out is passed over at or below that clock (`below_whole_floor`)
2411    /// and kept with the list's floor at the switch (`Floor::removed`).
2412    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2413    lists: BTreeMap<String, WholeList>,
2414    /// What it remembers of files sealed apart, in `files.json` beside.
2415    #[serde(skip)]
2416    sealed: Sealed,
2417    /// `memory.json` as `save` last wrote it, kept with the memory in this
2418    /// process (`Memory::keep`); never written itself.
2419    #[serde(skip)]
2420    saved: Vec<u8>,
2421}
2422
2423/// What this computer remembers of files sealed apart (notes, papers), beside
2424/// its memory (`files.json`). Their entries, their files as last read and the
2425/// changes waiting for them are kept here too, out of `memory.json`.
2426#[derive(Debug, Default, Serialize, Deserialize)]
2427struct Sealed {
2428    #[serde(default)]
2429    computer: String,
2430    #[serde(default)]
2431    entries: BTreeMap<String, Known>,
2432    #[serde(default)]
2433    files: BTreeMap<String, Stat>,
2434    #[serde(default)]
2435    pending: BTreeMap<String, (Option<String>, u64, String)>,
2436    /// For each change waiting, the content it replaces where it was made: its base.
2437    #[serde(default)]
2438    bases: BTreeMap<String, String>,
2439    /// For each change waiting, every content the others' records named for
2440    /// its file so far, versions and bases (`receive`).
2441    #[serde(default)]
2442    named: BTreeMap<String, BTreeSet<String>>,
2443    /// The sealed files this computer put in the folder (`blobs`): when a
2444    /// record last pointed to each (milliseconds), and the size it sealed.
2445    #[serde(default)]
2446    mine: BTreeMap<String, (i64, u64)>,
2447    /// Sealed files the others' records named lately, by name: when this
2448    /// device first read such a record (milliseconds); never from its own
2449    /// records, nor from a record read again. None is taken out within the
2450    /// time sealed files stay (`unused_days`: two weeks, or 90 days) of that.
2451    #[serde(default)]
2452    named_at: BTreeMap<String, i64>,
2453    /// Sealed files of the others' notes and papers that stopped being
2454    /// current here (replaced, taken out, or passed by a later change read in
2455    /// the same exchange), by name: the device whose record named it, and its
2456    /// last use here (milliseconds). Its writer sweeps it (`blobs::sweep`);
2457    /// forgotten here once it left the folder. A writer that no longer counts
2458    /// by a sealed fact never will (a phone dead for good): swept here then,
2459    /// `unused_days` after that last use (`sweep_theirs`). Empty in an older
2460    /// Sioul's memory, which passes it over.
2461    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2462    theirs: BTreeMap<String, (String, i64)>,
2463    /// Sealed files of notes and papers this device sealed (`mine`) whose
2464    /// file was taken out, by name: the device whose record took it out, and
2465    /// that record's round. Each goes as soon as every device that counts
2466    /// read past that round, with `blobs::sweep`'s care, rather than two weeks
2467    /// on (`removed_due`): a device that takes such a file out keeps it
2468    /// among its earlier versions as a copy (`history::keep_moved`), and
2469    /// nothing else needs it. Empty in an older Sioul's memory, which passes
2470    /// it over.
2471    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2472    removed: BTreeMap<String, (String, u32)>,
2473    /// Sealed files found damaged, as they were then (`blobs::fingerprint`):
2474    /// not opened again until another copy comes.
2475    #[serde(default)]
2476    damaged: BTreeMap<String, (u64, u64, u64)>,
2477    /// Changes waiting for their sealed file, since when (milliseconds): said after a day.
2478    #[serde(default)]
2479    missing: BTreeMap<String, i64>,
2480    /// Changes that could not be written here (a full disk, a name a folder
2481    /// takes here): their sealed file, when to try again, how many times so far.
2482    #[serde(default)]
2483    failing: BTreeMap<String, (String, i64, u32)>,
2484    /// Files gone here, since when (milliseconds): taken out elsewhere once
2485    /// gone a while (`EMPTIED_WAIT`).
2486    #[serde(default)]
2487    gone: BTreeMap<String, i64>,
2488    /// Folders whose files gone at once you said to take out everywhere
2489    /// (`confirm_gone`), by their place in the records ("files/notes/disk/"):
2490    /// a folder that lists empty, or a whole store; a folder below one
2491    /// confirmed is confirmed too, never one beside it.
2492    #[serde(default)]
2493    confirmed: BTreeSet<String>,
2494    /// Files held after going many at once that you said to take out now
2495    /// ("Take them out now", `confirm_withheld`): those alone, never a
2496    /// folder that lists empty beside them (review of 17 October 2026, M1).
2497    #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
2498    confirmed_files: BTreeSet<String>,
2499    /// Files held as gone many at once (`GONE_GRACE`): held so until their
2500    /// own grace ends, whatever goes or comes back beside them, so that a
2501    /// bulk's remainder never leaves days early (review of 17 October 2026, L3).
2502    #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
2503    bulk: BTreeSet<String>,
2504    /// Sealed files whose content a change of a note or paper replaced, made
2505    /// here or read in another device's records (its base), by name, and
2506    /// when (milliseconds), for `history::SEALED_DAYS` and a day: a device
2507    /// that received such a change may keep that content by reference among
2508    /// its earlier versions. None of them leaves early (`removed_due`), and a
2509    /// copy of the folder kept by Sioul itself keeps them (`needed`)
2510    /// (review of 17 October 2026, M2).
2511    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2512    replaced: BTreeMap<String, i64>,
2513    /// Sioul keeping the folder itself: the sealed files its copy keeps
2514    /// though the server may no longer list them, by name, for the pull,
2515    /// which has no key (`needed_sealed`): the contents of the current notes
2516    /// and papers of the parts shared here, the files held after going many
2517    /// at once among them (a listing that leaves files out never takes what
2518    /// a record names: review of 18 October 2026, L-1), those changes waiting
2519    /// here name, and those a change replaced lately (`replaced`: an earlier
2520    /// version kept here by reference opens from that copy). Kept here, out
2521    /// of `memory.json`, which is written at every exchange. None in an older
2522    /// Sioul's files, until this one's first exchange says it: the pull lets
2523    /// go of nothing meanwhile.
2524    #[serde(default, skip_serializing_if = "Option::is_none")]
2525    needed: Option<BTreeSet<String>>,
2526    /// When this computer's sealed files were last looked for, and the
2527    /// history and leftovers last tidied (milliseconds): kept in `memory.json`
2528    /// now (`Memory::checked`, `tidied`), so that this file, large with many
2529    /// notes or texts, is written only when what it holds changes. Read once,
2530    /// from an older Sioul's.
2531    #[serde(default, skip_serializing)]
2532    checked: i64,
2533    #[serde(default, skip_serializing)]
2534    tidied: i64,
2535    /// The spam filter's tables this computer sealed in the folder, by name,
2536    /// oldest first: past the newest `KEPT_TABLES`, the older go from the
2537    /// folder at once rather than 90 days on (`prune_tables`). This device's
2538    /// own memory, never shared; empty in an older Sioul's, whose tables go
2539    /// as before.
2540    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2541    tables: Vec<String>,
2542    /// Large notes and papers found changed since they were last read, and
2543    /// since when (milliseconds, this computer's clock): each is read and
2544    /// sealed once it has not changed for ten minutes, or an hour after that
2545    /// time (`LARGE_SETTLES`). Kept here rather than in each file's last look
2546    /// (`Stat::seen`, when it was last read), which cannot tell a file quiet
2547    /// for an hour, then saved again, from one changing for an hour, and
2548    /// which a new file has none of (review of 15 October 2026, M2).
2549    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2550    changing: BTreeMap<String, i64>,
2551}
2552
2553/// The spam filter's tables a computer keeps in the folder: the one in use
2554/// and the one before it, for a device that has not read the newest record
2555/// yet. Each training that does no worse seals a new one, about two
2556/// megabytes, once a week at most (docs/spam-filter.md); kept 90 days each,
2557/// they would be a dozen, and a phone whose sync app never deletes a file
2558/// (eDrive) would keep every one it brought.
2559const KEPT_TABLES: usize = 2;
2560
2561/// This computer's own spam filter's tables past the newest `KEPT_TABLES`
2562/// taken out of the folder now, unless a record of this computer's names
2563/// one again (`used`: a table put back): that one goes by the usual rule.
2564/// Never another device's file: only those this computer sealed (`mine`).
2565/// A device that reads only the newest record, as every Sioul does, never
2566/// asks for them; an earlier version of the table kept by reference on
2567/// another device can no longer be put back from the folder.
2568fn prune_tables(folder: &Path, sealed: &mut Sealed, used: &BTreeSet<String>) {
2569    sealed.tables.retain(|name| sealed.mine.contains_key(name));
2570    while sealed.tables.len() > KEPT_TABLES {
2571        let old = sealed.tables.remove(0);
2572        if used.contains(&old) {
2573            continue;
2574        }
2575        let _ = std::fs::remove_file(folder.join("blobs").join(&old));
2576        sealed.mine.remove(&old);
2577    }
2578}
2579
2580/// A change read in as the winner of its entry. The content of a file sealed
2581/// apart that another device's change named, and that this one passes,
2582/// noted as theirs (`theirs_now`, `Sealed::theirs`).
2583fn win(winners: &mut BTreeMap<String, (Option<String>, u64, String)>, theirs_now: &mut Vec<(String, String)>, computer: &str, key: String, change: (Option<String>, u64, String)) {
2584    let files = key.starts_with(FILES).then(|| key.clone());
2585    if let Some((Some(value), _, writer)) = winners.insert(key, change)
2586        && writer != computer
2587        && let Some(key) = files
2588    {
2589        let h = value_hash(&key, &value);
2590        if !h.is_empty() {
2591            theirs_now.push((h, writer));
2592        }
2593    }
2594}
2595
2596/// The contents of notes and papers this device sealed whose file was taken
2597/// out (`removed_now`: by this device, or read in another device's record),
2598/// each with that record's writer and round (`Sealed::removed`); each is due
2599/// once every device that counts read past that round (`readers`: a reader
2600/// without notes in the folder read none, and holds it): its last use then
2601/// counts as `days` ago, and `blobs::sweep_after` takes it out at once, with
2602/// its care all the same: never a content used again or named lately, never
2603/// one made fresh within `days` (on a phone under eDrive, which keeps every
2604/// sealed file it brought, a content used again past that is made fresh, and
2605/// sent there again). Nothing else needs it: a device taking such a file out
2606/// keeps it as a copy among its earlier versions (`history::keep_moved`), and
2607/// a device joining, or back after long, reads that it was taken out. Only
2608/// when `early`: every device that counts says it keeps such a file so
2609/// (`blobs::KEEPING_MOVED`); an earlier Sioul keeps it by reference, its
2610/// content needed in the folder for `history::SEALED_DAYS`. Until 17 October
2611/// 2026 such a content stayed two weeks more (90 days while an older Sioul
2612/// counts, as it still does: `days`), as it does when not `early`. Forgotten
2613/// once swept, or once current again.
2614#[allow(clippy::too_many_arguments)]
2615fn removed_due(folder: &Path, key: &[u8; 32], sealed: &mut Sealed, removed_now: Vec<(String, String, u32)>, current: &BTreeSet<String>, reading: &BTreeSet<String>, last: &mut BTreeMap<String, i64>, days: i64, early: bool, now_ms: i64) {
2616    for (h, writer, round) in removed_now.into_iter().filter(|(h, _, _)| !current.contains(h)) {
2617        let name = crate::blobs::name(key, &h);
2618        if sealed.mine.contains_key(&name) {
2619            sealed.removed.insert(name, (writer, round));
2620        }
2621    }
2622    let current: BTreeSet<String> = current.iter().map(|h| crate::blobs::name(key, h)).collect();
2623    sealed.removed.retain(|name, _| sealed.mine.contains_key(name) && !current.contains(name));
2624    if !early {
2625        return;
2626    }
2627    let read_past = |writer: &str, round: u32| reading.iter().filter(|r| r.as_str() != writer).all(|r| read_seen(folder, r).and_then(|s| s.read.get(writer).copied()).unwrap_or(0) > round);
2628    // Never one a change replaced lately (`Sealed::replaced`): the same bytes
2629    // may be another note's earlier version, kept by reference (review of 17
2630    // October 2026, M2); it goes in its time.
2631    for (name, (writer, round)) in &sealed.removed {
2632        if read_past(writer, *round)
2633            && !sealed.replaced.contains_key(name)
2634            && let Some(at) = last.get_mut(name)
2635        {
2636            *at = (*at).min(now_ms - days * DAY);
2637        }
2638    }
2639}
2640
2641/// The parts whose files are sealed apart (`Shape::Files`): what one of their
2642/// records names is a sealed file of the folder, the same bytes the same
2643/// file whatever the part (`blobs::name`): a note and a paper alike.
2644const FILE_PARTS: [&str; 3] = ["spam", "papers", "notes"];
2645
2646/// The others' contents of notes and papers that stopped being current in
2647/// this exchange (`theirs_now`: replaced, taken out, or passed by a later
2648/// change read in it), noted (`Sealed::theirs`); and those whose writer no
2649/// longer counts by a sealed fact (`Counted::departed`: it left, you forgot
2650/// it, another replaces it) swept, since that writer never sweeps them again
2651/// (a phone dead for good), with the care `blobs::sweep` takes of this
2652/// device's own (`blobs::sweep_after`): unused `unused_days` since their last
2653/// use here, never fresh in the folder (another device uses that content
2654/// again, `blobs::put`), never one a current record names (a note's, a change
2655/// waiting, one the others named lately, one this device sealed itself, the
2656/// texts' media: `texts_media`), and nothing at all while the texts' media
2657/// are not known here, nor while a part whose files are sealed apart is not
2658/// shared here (`FILE_PARTS`): the records of a part a device does not share
2659/// are passed over, and a current paper with the same bytes as a departed
2660/// device's replaced note would go (review of 17 October 2026, L1). Each
2661/// one swept is said for the server (`Memory::taken`).
2662/// A device that counts sweeps them, each its own folder; two at once find
2663/// it gone, and their sending a file gone (`404`) or changed (`412`).
2664fn sweep_theirs(sharing: &Sharing, stores: &[Store], memory: &mut Memory, counted: &[(String, Counted)], theirs_now: Vec<(String, String)>, current_before: &BTreeSet<String>, now_ms: i64) {
2665    let blobs = sharing.folder.join("blobs");
2666    memory.taken.retain(|name, at| now_ms - *at < crate::blobs::UNUSED_DAYS * DAY && !blobs.join(name).exists());
2667    let current: BTreeSet<String> = memory.entries.iter().filter(|(key, known)| key.starts_with(FILES) && !known.h.is_empty()).map(|(_, known)| known.h.clone()).collect();
2668    for (h, writer) in theirs_now.into_iter().filter(|(h, _)| !current.contains(h)) {
2669        memory.sealed.theirs.insert(crate::blobs::name(sharing.key, &h), (writer, now_ms));
2670    }
2671    if memory.sealed.theirs.is_empty() {
2672        return;
2673    }
2674    let days = unused_days(sharing, memory, now_ms);
2675    let departed: BTreeSet<&str> = counted.iter().filter(|(_, counted)| counted.departed()).map(|(id, _)| id.as_str()).collect();
2676    // A writer that counts sweeps its own: forgotten here once it left the folder.
2677    memory.sealed.theirs.retain(|name, (writer, at)| departed.contains(writer.as_str()) || now_ms - *at < days * DAY || blobs.join(name).exists());
2678    let mut last: BTreeMap<String, i64> = memory.sealed.theirs.iter().filter(|(_, (writer, _))| departed.contains(writer.as_str())).map(|(name, (_, at))| (name.clone(), *at)).collect();
2679    if last.is_empty() {
2680        return;
2681    }
2682    // The last words of a device whose files you removed (`remove_device_files`)
2683    // that no other current record names: no record in the folder names them
2684    // any more, and they go as its replaced contents do.
2685    let removed: BTreeSet<&str> = memory.removed_devices.keys().map(String::as_str).collect();
2686    let orphaned: BTreeSet<&String> = if removed.is_empty() {
2687        BTreeSet::new()
2688    } else {
2689        let files = memory.entries.iter().filter(|(key, known)| key.starts_with(FILES) && !known.h.is_empty());
2690        let named: BTreeSet<&String> = files.clone().filter(|(_, known)| !removed.contains(known.w.as_str())).map(|(_, known)| &known.h).collect();
2691        files.filter(|(_, known)| removed.contains(known.w.as_str()) && !named.contains(&known.h)).map(|(_, known)| &known.h).collect()
2692    };
2693    let used: BTreeSet<String> = current
2694        .iter()
2695        .chain(current_before)
2696        .filter(|h| !orphaned.contains(h))
2697        .cloned()
2698        .chain(memory.pending.iter().filter(|(key, _)| key.starts_with(FILES)).filter_map(|(key, (value, _, _))| value.as_deref().map(|v| value_hash(key, v))))
2699        .map(|h| crate::blobs::name(sharing.key, &h))
2700        .chain(memory.sealed.named_at.keys().cloned())
2701        .chain(memory.sealed.mine.keys().cloned())
2702        .collect();
2703    let unused = days * DAY;
2704    let fresh = |name: &str| std::fs::metadata(blobs.join(name)).ok().and_then(|m| m.modified().ok()).and_then(|t| t.elapsed().ok()).is_some_and(|age| (age.as_millis() as i64) < unused);
2705    let due = last.iter().any(|(name, at)| !used.contains(name) && now_ms - *at >= unused && !fresh(name));
2706    let every_part = FILE_PARTS.iter().all(|part| stores.iter().any(|s| s.part == *part && matches!(s.shape, Shape::Files)));
2707    if due && every_part && let Some(media) = texts_media(sharing, stores, memory, counted) {
2708        let used: BTreeSet<String> = used.into_iter().chain(media).collect();
2709        let before: Vec<String> = last.keys().cloned().collect();
2710        crate::blobs::sweep_after(sharing.folder, &mut last, &used, now_ms, days);
2711        for name in before.into_iter().filter(|name| !last.contains_key(name)) {
2712            memory.sealed.theirs.remove(&name);
2713            memory.taken.insert(name, now_ms);
2714        }
2715    } else {
2716        // Used again: its use now (once a day at most, as `blobs::sweep_after` does).
2717        for (_, at) in last.iter_mut().filter(|(name, _)| used.contains(*name)) {
2718            if now_ms - *at >= DAY {
2719                *at = now_ms;
2720            }
2721        }
2722    }
2723    for (name, at) in last {
2724        if let Some(theirs) = memory.sealed.theirs.get_mut(&name) {
2725            theirs.1 = at;
2726        }
2727    }
2728}
2729
2730/// The sealed files the texts name, by name (`blobs::name`): each part of
2731/// every text in each phone's log as this device holds it (`TEXTS_LOG`, each
2732/// line sealed at rest, `textseal`), the texts taken out included. None when
2733/// they are not known here: the texts not shared on this device, or a whole
2734/// line of their log that does not open or read (a last line still being
2735/// written, with no line break yet, is passed over: its medium went into the
2736/// folder before it, and is fresh there); then no other device's sealed
2737/// file is swept here (`sweep_theirs`). A sharing where no phone was ever
2738/// seen (every other device known here by its entry, none a phone, nor this
2739/// one) holds none: only a phone brings the texts' media.
2740fn texts_media(sharing: &Sharing, stores: &[Store], memory: &Memory, counted: &[(String, Counted)]) -> Option<BTreeSet<String>> {
2741    if let Some(store) = stores.iter().find(|s| s.name == TEXTS_LOG) {
2742        use sioul_core::texts::Sealer;
2743        #[derive(Deserialize)]
2744        struct Text {
2745            #[serde(default)]
2746            parts: Vec<Part>,
2747        }
2748        #[derive(Deserialize)]
2749        struct Part {
2750            #[serde(default)]
2751            hash: String,
2752        }
2753        let seal = crate::textseal::TextSeal::new(sharing.key);
2754        let mut out = BTreeSet::new();
2755        for entry in std::fs::read_dir(&store.path).into_iter().flatten().filter_map(Result::ok) {
2756            let path = entry.path();
2757            if !path.is_file() || path.extension().is_none_or(|e| e != "jsonl") || entry.file_name().to_string_lossy().starts_with('.') {
2758                continue;
2759            }
2760            let text = std::fs::read_to_string(&path).ok()?;
2761            for line in text.split_inclusive('\n') {
2762                // A last line still being written (no line break yet) names nothing yet: its medium went first, and is fresh.
2763                let opened = seal.open(line.trim()).and_then(|plain| serde_json::from_str::<Text>(&plain).ok());
2764                let Some(text) = opened else {
2765                    if line.trim().is_empty() || !line.ends_with('\n') {
2766                        continue;
2767                    }
2768                    return None;
2769                };
2770                out.extend(text.parts.into_iter().filter(|part| !part.hash.is_empty()).map(|part| crate::blobs::name(sharing.key, &part.hash)));
2771            }
2772        }
2773        return Some(out);
2774    }
2775    let phone = |entry: Option<&crate::devices::Entry>| entry.is_none_or(|e| e.kind == crate::devices::PHONE);
2776    let phones = crate::devices::own(&sharing.memory.with_file_name("device.toml")).kind == crate::devices::PHONE || counted.iter().any(|(id, _)| phone(memory.seen.get(id)));
2777    (!phones).then(BTreeSet::new)
2778}
2779
2780#[derive(Debug, Clone, Serialize, Deserialize)]
2781struct Known {
2782    c: u64,
2783    w: String,
2784    h: String,
2785    /// For a file sealed apart changed here: the content it replaced, said again in a new round.
2786    #[serde(default, skip_serializing_if = "String::is_empty")]
2787    b: String,
2788}
2789
2790impl Memory {
2791    /// `memory.json` alone: what the doses and claims ask.
2792    fn load(path: &Path, computer: &str) -> Memory {
2793        let memory: Memory = std::fs::read_to_string(path).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_default();
2794        if memory.computer == computer { memory } else { Memory { computer: computer.to_string(), round: 1, ..Memory::default() } }
2795    }
2796
2797    /// The whole memory, files sealed apart too (`files.json`), their entries
2798    /// and changes among the others while an exchange runs: as the last
2799    /// exchange in this process left it while both files are as it wrote
2800    /// them (`Kept`), else read from them.
2801    fn load_all(path: &Path, computer: &str) -> Memory {
2802        if let Some(kept) = Memory::kept(path, computer) {
2803            return kept;
2804        }
2805        let mut memory = Memory::load(path, computer);
2806        match std::fs::read_to_string(sealed_path(path)).ok().and_then(|t| serde_json::from_str::<Sealed>(&t).ok()).filter(|s| s.computer == computer) {
2807            Some(sealed) => {
2808                // An older Sioul's times, kept in this file then.
2809                memory.checked = memory.checked.max(sealed.checked);
2810                memory.tidied = memory.tidied.max(sealed.tidied);
2811                memory.sealed = sealed;
2812            }
2813            // Lost, or not readable: notes and papers, and the calls, join
2814            // again as a new device's would (their files read again from the
2815            // records first), rather than each going out as new.
2816            None => memory.places.retain(|store, _| !store.starts_with(FILES) && !calls_store(store)),
2817        }
2818        memory.entries.append(&mut memory.sealed.entries);
2819        memory.files.append(&mut memory.sealed.files);
2820        memory.pending.append(&mut memory.sealed.pending);
2821        memory
2822    }
2823
2824    /// Both files written, each when it changed only (a phone's storage
2825    /// wears), its bytes on the disk before its name changes. The calls'
2826    /// entries go beside the notes': a log of lines names each entry by its
2827    /// line, and `memory.json`, written at every exchange and read by the
2828    /// doses' knowing, stays small.
2829    fn save(&mut self, path: &Path) -> Result<(), String> {
2830        self.prepare_save(path)?.commit()
2831    }
2832
2833    /// What `save` writes, written ahead under hidden names, synced and
2834    /// closed (on Windows, the antivirus reads a file written whole at its
2835    /// close), each file only when it changed: put in place by renames alone
2836    /// (`SavePrepared::commit`), which is all "Remove its files" does under
2837    /// the exchanges' lock, however large the memory (review of part 3, R-6;
2838    /// CI's Windows job of 20 October 2026: 0.79 s under the lock).
2839    fn prepare_save(&mut self, path: &Path) -> Result<SavePrepared, String> {
2840        let apart = |key: &String| key.starts_with(FILES) || calls_store(key);
2841        let (sealed, kept): (BTreeMap<_, _>, BTreeMap<_, _>) = std::mem::take(&mut self.entries).into_iter().partition(|(key, _)| apart(key));
2842        (self.sealed.entries, self.entries) = (sealed, kept);
2843        let (sealed, kept): (BTreeMap<_, _>, BTreeMap<_, _>) = std::mem::take(&mut self.files).into_iter().partition(|(file, _)| apart(file));
2844        (self.sealed.files, self.files) = (sealed, kept);
2845        let (sealed, kept): (BTreeMap<_, _>, BTreeMap<_, _>) = std::mem::take(&mut self.pending).into_iter().partition(|(key, _)| apart(key));
2846        (self.sealed.pending, self.pending) = (sealed, kept);
2847        self.sealed.computer = self.computer.clone();
2848        let mut prepared = SavePrepared { renames: Vec::new() };
2849        let mut write = |target: PathBuf, text: &[u8]| -> Result<(), String> {
2850            if std::fs::read(&target).is_ok_and(|old| old == text) {
2851                return Ok(());
2852            }
2853            let fail = |e: std::io::Error| format!("{}: {e}", target.display());
2854            if let Some(parent) = target.parent() {
2855                private_dirs(parent).map_err(fail)?;
2856            }
2857            let temporary = temporary(&target);
2858            let written = (|| {
2859                let mut file = new_private(&temporary)?;
2860                file.write_all(text)?;
2861                file.sync_all()
2862            })();
2863            if let Err(e) = written {
2864                let _ = std::fs::remove_file(&temporary);
2865                return Err(fail(e));
2866            }
2867            slow_memory_write();
2868            prepared.renames.push((temporary, target));
2869            Ok(())
2870        };
2871        write(sealed_path(path), serde_json::to_string(&self.sealed).map_err(|e| e.to_string())?.as_bytes())?;
2872        let text = serde_json::to_string(self).map_err(|e| e.to_string())?;
2873        write(path.to_path_buf(), text.as_bytes())?;
2874        self.saved = text.into_bytes();
2875        Ok(prepared)
2876    }
2877
2878    /// Kept in this process once saved, for the next exchange or send to take
2879    /// rather than read both files again (`Kept`).
2880    fn keep(mut self, path: &Path) {
2881        let text = std::mem::take(&mut self.saved);
2882        if text.is_empty() {
2883            return;
2884        }
2885        let sealed = stamp_at(&sealed_path(path));
2886        if let Ok(mut mine) = MINE.lock() {
2887            mine.insert(path.to_path_buf(), (sealed, self.computer.clone(), self.sealed.mine.iter().map(|(name, (_, size))| (name.clone(), *size)).collect()));
2888        }
2889        if !keeping() {
2890            return;
2891        }
2892        self.entries.append(&mut self.sealed.entries);
2893        self.files.append(&mut self.sealed.files);
2894        self.pending.append(&mut self.sealed.pending);
2895        let kept = Kept { own: stamp_at(path), sealed, text, memory: self };
2896        if let Ok(mut all) = KEPT.lock() {
2897            all.insert(path.to_path_buf(), kept);
2898        }
2899    }
2900
2901    /// The memory kept in this process (`keep`), taken, when it is this
2902    /// computer's and both its files are still as it wrote them: `memory.json`
2903    /// the same bytes, `files.json` the same file (its inode, size and time).
2904    fn kept(path: &Path, computer: &str) -> Option<Memory> {
2905        if !keeping() {
2906            return None;
2907        }
2908        let kept = KEPT.lock().ok()?.remove(path)?;
2909        let same = kept.memory.computer == computer && kept.own.is_some() && stamp_at(path) == kept.own && stamp_at(&sealed_path(path)) == kept.sealed && std::fs::read(path).is_ok_and(|text| text == kept.text);
2910        same.then_some(kept.memory)
2911    }
2912
2913    /// A new clock: after every clock given or read, and not before `physical` (milliseconds).
2914    fn tick(&mut self, physical: i64) -> u64 {
2915        self.clock = (self.clock + 1).max((physical.max(0) as u64) << 16);
2916        self.clock
2917    }
2918}
2919
2920/// Where what this computer remembers of files sealed apart is kept: `files.json`, beside its memory.
2921fn sealed_path(memory: &Path) -> PathBuf {
2922    memory.with_file_name("files.json")
2923}
2924
2925/// A file as it stands: its inode (on Unix; a file written anew and renamed
2926/// into place is another), its size and its time (nanoseconds).
2927#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
2928struct FileStamp {
2929    ino: u64,
2930    size: u64,
2931    modified: u64,
2932}
2933
2934fn file_stamp(meta: &std::fs::Metadata) -> FileStamp {
2935    #[cfg(unix)]
2936    let ino = std::os::unix::fs::MetadataExt::ino(meta);
2937    #[cfg(not(unix))]
2938    let ino = 0;
2939    FileStamp { ino, size: meta.len(), modified: modified_ns(meta) }
2940}
2941
2942fn stamp_at(path: &Path) -> Option<FileStamp> {
2943    std::fs::metadata(path).ok().map(|meta| file_stamp(&meta))
2944}
2945
2946/// The memory as the last exchange in this process saved it (`Memory::keep`),
2947/// with `memory.json` as written and both files' stamps: the next exchange,
2948/// or a send asking what this device sealed (`own_sealed`), takes it while
2949/// neither file changed, rather than reading them again. A phone's background
2950/// service exchanges every few minutes, and its `files.json` holds tens of
2951/// thousands of entries (the texts' lines): megabytes read at each.
2952struct Kept {
2953    memory: Memory,
2954    text: Vec<u8>,
2955    own: Option<FileStamp>,
2956    sealed: Option<FileStamp>,
2957}
2958
2959#[cfg(test)]
2960thread_local! {
2961    /// Tests only: how long each file of the memory written takes more on
2962    /// this thread, as the antivirus' read at its close on Windows. Never in a build.
2963    pub(crate) static SLOW_MEMORY_WRITE: std::cell::Cell<std::time::Duration> = const { std::cell::Cell::new(std::time::Duration::ZERO) };
2964}
2965
2966fn slow_memory_write() {
2967    #[cfg(test)]
2968    std::thread::sleep(SLOW_MEMORY_WRITE.with(std::cell::Cell::get));
2969}
2970
2971/// The memory's files written ahead (`Memory::prepare_save`): hidden, synced
2972/// and closed, each beside the file it replaces; taken away unless put in
2973/// place.
2974struct SavePrepared {
2975    renames: Vec<(PathBuf, PathBuf)>,
2976}
2977
2978impl SavePrepared {
2979    /// Each put in place, `files.json` first, by a rename alone.
2980    fn commit(mut self) -> Result<(), String> {
2981        for (temporary, target) in std::mem::take(&mut self.renames) {
2982            if let Err(e) = std::fs::rename(&temporary, &target) {
2983                let _ = std::fs::remove_file(&temporary);
2984                return Err(format!("{}: {e}", target.display()));
2985            }
2986        }
2987        Ok(())
2988    }
2989}
2990
2991impl Drop for SavePrepared {
2992    fn drop(&mut self) {
2993        for (temporary, _) in &self.renames {
2994            let _ = std::fs::remove_file(temporary);
2995        }
2996    }
2997}
2998
2999/// The memories kept in this process, by the path of their `memory.json`.
3000static KEPT: std::sync::Mutex<BTreeMap<PathBuf, Kept>> = std::sync::Mutex::new(BTreeMap::new());
3001
3002/// Whether this process keeps the memory and the logs of lines between its
3003/// exchanges (`set_keeping`); always in tests, which run the sharing through it.
3004static KEEPING: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(cfg!(test));
3005
3006/// Said once by a process that exchanges every few minutes for a long time
3007/// (a phone's background service): the memory and the logs of lines are kept
3008/// between its exchanges (`Kept`, `LINES`), rather than read again at each.
3009/// They weigh some tens of megabytes with many texts shared (about 30 MB for
3010/// 10 MB of texts): a window's process, which holds little between its
3011/// minutes, reads them again at each (docs/android.md, "In the background").
3012pub fn set_keeping(on: bool) {
3013    KEEPING.store(on, std::sync::atomic::Ordering::Relaxed);
3014    if !on {
3015        KEPT.lock().map(|mut all| all.clear()).ok();
3016        LINES.lock().map(|mut all| all.clear()).ok();
3017    }
3018}
3019
3020fn keeping() -> bool {
3021    KEEPING.load(std::sync::atomic::Ordering::Relaxed)
3022}
3023
3024/// What this device sealed in the folder (`Sealed::mine`, by name, the size
3025/// sealed), as `files.json` said it when last written or read in this process
3026/// (its stamp, and whose it is): what each send asks first (`own_sealed`), a few names.
3027type Mine = BTreeMap<PathBuf, (Option<FileStamp>, String, BTreeMap<String, u64>)>;
3028static MINE: std::sync::Mutex<Mine> = std::sync::Mutex::new(BTreeMap::new());
3029
3030/// One line of a computer's file: its number in the round, its clock, the sealed change.
3031#[derive(Debug, Serialize, Deserialize)]
3032struct Line {
3033    n: u64,
3034    c: u64,
3035    s: String,
3036}
3037
3038/// A change, sealed in a line: the entry and its new value, none once taken out.
3039#[derive(Debug, Serialize, Deserialize)]
3040struct Change {
3041    k: String,
3042    v: Option<String>,
3043    /// For a file sealed apart: the content it replaces where it was made, by
3044    /// its hash ("" for a file new there). Another device holding something
3045    /// else changed it too: both are kept.
3046    #[serde(default, skip_serializing_if = "String::is_empty")]
3047    b: String,
3048    /// The build that wrote it (`sioul_core::build::DESCRIBED`), on the first
3049    /// line of each batch this computer appends, a new round's first line
3050    /// among them: the lines after it, until the next that says one, are that
3051    /// build's. Sealed with the change: the server never learns it. An older
3052    /// Sioul passes over it.
3053    #[serde(default, skip_serializing_if = "String::is_empty")]
3054    build: String,
3055    /// The format its part is written in (`part_format`), when not 1: a
3056    /// device that does not know it keeps the change waiting and says so
3057    /// (`share-newer`); one that does not write it yet writes it from its
3058    /// next exchange (`Memory::switch`). Sealed with the change. An older
3059    /// Sioul passes over it, and its changes say none: format 1.
3060    #[serde(default, skip_serializing_if = "first_format")]
3061    f: u32,
3062}
3063
3064fn first_format(f: &u32) -> bool {
3065    *f <= 1
3066}
3067
3068/// What the first line of a round says of where it stands, sealed with its
3069/// change: a full round (`new_round`, a computer's first), which restates all
3070/// its writer holds, or one continuing the round before (`SEGMENT`), with the
3071/// number of that round's last line. A reader enters a continuing round only
3072/// once it has read the one before to that line: a sync app may bring a
3073/// round before the end of the one before, and that end, a dose marked taken
3074/// among it, would never be read (docs/database.md, "The log"). An older
3075/// Sioul's lines say neither, and are read as before; an older Sioul passes
3076/// over both fields.
3077#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
3078struct Turn {
3079    /// The round before's last number, on the first line of a round continuing it.
3080    #[serde(default, skip_serializing_if = "is_zero_u64")]
3081    p: u64,
3082    /// On the first line of a full round.
3083    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
3084    full: bool,
3085}
3086
3087fn is_zero_i64(n: &i64) -> bool {
3088    *n == 0
3089}
3090
3091fn is_zero_u64(n: &u64) -> bool {
3092    *n == 0
3093}
3094
3095/// A round waiting for the end of the round before (`Memory::waiting_for`).
3096#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
3097struct Awaited {
3098    /// The round waiting.
3099    round: u32,
3100    /// How long it waited, the exchanges' time counted (`AWAIT_STEP`), in milliseconds.
3101    waited: i64,
3102    /// The last exchange that counted it (milliseconds).
3103    last: i64,
3104}
3105
3106/// The turn of a round that does not know where the round before it ended
3107/// (`Turn::p`): one that could not restate all (`new_round`) after a round
3108/// of its own found without a line. No reader ever met that line: it waits
3109/// for the round before as for a lost one (`AWAITED`), then reads on with
3110/// the gap said, the doses in doubt until a full round, never as an older
3111/// Sioul's round, which would take all before it as read.
3112const UNKNOWN_TURN: u64 = u64::MAX;
3113
3114/// A sealed line's change and its round's turn (`Turn`), read in one pass.
3115#[derive(Deserialize)]
3116struct Opened {
3117    k: String,
3118    v: Option<String>,
3119    #[serde(default)]
3120    b: String,
3121    #[serde(default)]
3122    build: String,
3123    #[serde(default)]
3124    f: u32,
3125    #[serde(default)]
3126    p: u64,
3127    #[serde(default)]
3128    full: bool,
3129}
3130
3131impl Opened {
3132    fn parts(self) -> (Change, Turn) {
3133        (Change { k: self.k, v: self.v, b: self.b, build: self.build, f: self.f }, Turn { p: self.p, full: self.full })
3134    }
3135}
3136
3137/// A change as it is sealed in a line: the change, and on a round's first
3138/// line, its turn (`Turn`).
3139#[derive(Serialize)]
3140struct Sealing<'a> {
3141    #[serde(flatten)]
3142    change: &'a Change,
3143    #[serde(flatten)]
3144    turn: Turn,
3145}
3146
3147/// A computer's own notes in the folder: when it last exchanged, its round, how far it read the others.
3148#[derive(Debug, Default, Serialize, Deserialize)]
3149struct Seen {
3150    #[serde(default)]
3151    at: i64,
3152    #[serde(default)]
3153    round: u32,
3154    #[serde(default)]
3155    read: BTreeMap<String, u32>,
3156    /// The computers it asks for a full round, each with the latest round of
3157    /// theirs it met: some of their records were lost here (a damaged line,
3158    /// a round taken for lost), so what they wrote is not all known here,
3159    /// the doses in doubt, until a full round of theirs after that one. An
3160    /// older Sioul passes it over (review of 10 October 2026).
3161    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
3162    full: BTreeMap<String, u32>,
3163    /// Its size changed at each writing (`pad_for`): read by nobody.
3164    #[serde(default, skip_serializing_if = "String::is_empty")]
3165    pad: String,
3166}
3167
3168fn bound(computer: &str, round: u32, n: u64, clock: u64) -> String {
3169    format!("{computer}:{round}:{n}:{clock}")
3170}
3171
3172fn round_file(folder: &Path, computer: &str, round: u32) -> PathBuf {
3173    folder.join(format!("{computer}-{round}.jsonl"))
3174}
3175
3176/// Every computer sharing through the folder: those that wrote records, and
3177/// those that only read so far (their notes); in the copy fetched from the
3178/// server too (`remote::overlay`).
3179fn computers(folder: &Path) -> BTreeSet<String> {
3180    let mut out: BTreeSet<String> = rounds_with(folder).into_keys().collect();
3181    for dir in std::iter::once(folder.to_path_buf()).chain(crate::remote::overlay(folder)) {
3182        for entry in std::fs::read_dir(&dir).into_iter().flatten().filter_map(Result::ok) {
3183            let name = entry.file_name().to_string_lossy().to_string();
3184            if let Some(id) = name.strip_suffix(".toml").filter(|id| id.len() == 36 && uuid::Uuid::parse_str(id).is_ok()) {
3185                out.insert(id.to_string());
3186            }
3187        }
3188    }
3189    out
3190}
3191
3192/// The computers' files in the folder and in the copy fetched from the
3193/// server (`remote::overlay`): computer → its rounds, those of either.
3194fn rounds_with(folder: &Path) -> BTreeMap<String, Vec<u32>> {
3195    let mut all = rounds(folder);
3196    if let Some(fetched) = crate::remote::overlay(folder) {
3197        for (computer, theirs) in rounds(&fetched) {
3198            let list = all.entry(computer).or_default();
3199            list.extend(theirs);
3200            list.sort_unstable();
3201            list.dedup();
3202        }
3203    }
3204    all
3205}
3206
3207/// Another computer's round, from the longer of its two copies: the folder's,
3208/// and the one fetched from the server (`remote::overlay`). Records only
3209/// grow: the longer holds all the shorter does, and where each line starts
3210/// is the same in both.
3211fn open_round(folder: &Path, computer: &str, round: u32) -> std::io::Result<std::fs::File> {
3212    let here = round_file(folder, computer, round);
3213    let Some(fetched) = crate::remote::overlay(folder).map(|f| round_file(&f, computer, round)) else { return std::fs::File::open(here) };
3214    let size = |path: &Path| std::fs::metadata(path).map(|m| m.len()).ok();
3215    match (size(&here), size(&fetched)) {
3216        (Some(a), Some(b)) if b > a => std::fs::File::open(fetched),
3217        (None, Some(_)) => std::fs::File::open(fetched),
3218        _ => std::fs::File::open(here),
3219    }
3220}
3221
3222/// The computers' files in the folder: computer → its rounds.
3223fn rounds(folder: &Path) -> BTreeMap<String, Vec<u32>> {
3224    let mut out: BTreeMap<String, Vec<u32>> = BTreeMap::new();
3225    for entry in std::fs::read_dir(folder).into_iter().flatten().filter_map(Result::ok) {
3226        let name = entry.file_name().to_string_lossy().to_string();
3227        let Some(stem) = name.strip_suffix(".jsonl") else { continue };
3228        // "<uuid>-<round>": a UUID is 36 characters; a conflicted copy's name is longer.
3229        let Some((computer, round)) = stem.rsplit_once('-') else { continue };
3230        if computer.len() != 36 || uuid::Uuid::parse_str(computer).is_err() {
3231            continue;
3232        }
3233        if let Ok(round) = round.parse::<u32>() {
3234            out.entry(computer.to_string()).or_default().push(round);
3235        }
3236    }
3237    for list in out.values_mut() {
3238        list.sort_unstable();
3239    }
3240    out
3241}
3242
3243// ---------------------------------------------------------------- formats
3244
3245/// A change kept waiting (`Memory::waiting`): the later of two for one entry.
3246fn wait(waiting: &mut BTreeMap<String, (Option<String>, u64, String, u32)>, key: String, change: (Option<String>, u64, String, u32)) {
3247    if waiting.get(&key).is_none_or(|(_, c, w, _)| (change.1, change.2.as_str()) > (*c, w.as_str())) {
3248        waiting.insert(key, change);
3249    }
3250}
3251
3252/// A part met in a format newer than this Sioul knows (`Memory::newer`).
3253#[derive(Debug, Clone, Default, Serialize, Deserialize)]
3254struct Newer {
3255    /// The newest format met.
3256    format: u32,
3257    /// The build that wrote it, as its batch says.
3258    #[serde(default)]
3259    build: String,
3260    /// The devices that wrote it: while none of them is still in the
3261    /// sharing (gone, forgotten, silent 180 days: a test build tried once),
3262    /// the part is no longer held (`release_newer`).
3263    #[serde(default)]
3264    writers: BTreeSet<String>,
3265}
3266
3267/// What a format-1 entry renamed by format 2 last said, kept once this device
3268/// writes format 2 (`Memory::floors`): a record of format 1 under that entry
3269/// read again (a store joining, a record rebuilt, an older copy of a round
3270/// put back) at or below it is passed over, so that nothing format 1 already
3271/// said comes back.
3272#[derive(Debug, Clone, Default, Serialize, Deserialize)]
3273struct Floor {
3274    c: u64,
3275    w: String,
3276    /// For a list format 1 sends whole: the names, in format 2, of the
3277    /// elements it held then. A later whole list takes out only those of
3278    /// them it no longer holds, never an element added since in format 2.
3279    #[serde(default, skip_serializing_if = "Option::is_none")]
3280    held: Option<BTreeSet<String>>,
3281    /// For a list format 1 sends whole: the elements a whole list took out
3282    /// here, with the clock of the list that did (`Memory::lists`). A record
3283    /// of one at or below that clock is passed over (`below_whole_floor`).
3284    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
3285    removed: BTreeMap<String, u64>,
3286}
3287
3288/// A list format 1 sends whole, as the whole lists seen here said it, each
3289/// at its clock (`Memory::lists`), in whatever order they came: for each
3290/// element one of them held, the latest clock of a list that held it, and
3291/// the latest of one that did not. An element is taken out when a list
3292/// without it came after the last one with it (`removed`). An element no
3293/// list held is not in it: a list that did not know it did not take it out.
3294#[derive(Debug, Clone, Default, Serialize, Deserialize)]
3295struct WholeList {
3296    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
3297    held_at: BTreeMap<String, u64>,
3298    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
3299    lacked_at: BTreeMap<String, u64>,
3300    /// The latest clock of any list seen.
3301    #[serde(default)]
3302    top: u64,
3303}
3304
3305impl WholeList {
3306    /// A whole list seen, at clock `c`, holding `names`: a record read (in one
3307    /// exchange, a later list may come first), or the file between exchanges.
3308    fn saw(&mut self, c: u64, names: &BTreeSet<String>) {
3309        for name in self.held_at.keys().filter(|name| !names.contains(*name)) {
3310            let lacked = self.lacked_at.entry(name.clone()).or_default();
3311            *lacked = (*lacked).max(c);
3312        }
3313        for name in names {
3314            // Every list seen before did not hold it.
3315            if !self.held_at.contains_key(name) && self.top > 0 {
3316                self.lacked_at.insert(name.clone(), self.top);
3317            }
3318            let held = self.held_at.entry(name.clone()).or_default();
3319            *held = (*held).max(c);
3320        }
3321        self.top = self.top.max(c);
3322    }
3323
3324    /// When an element was taken out: the clock of the latest list without
3325    /// it, when that came after the latest list with it.
3326    fn removed_at(&self, name: &str) -> Option<u64> {
3327        let held = self.held_at.get(name)?;
3328        self.lacked_at.get(name).copied().filter(|lacked| lacked > held)
3329    }
3330
3331    fn removed(&self) -> BTreeMap<String, u64> {
3332        self.held_at.keys().filter_map(|name| self.removed_at(name).map(|at| (name.clone(), at))).collect()
3333    }
3334}
3335
3336/// A record of a list format 1 sends whole (format 2 dividing it), as
3337/// `WholeList::saw` takes it: the names, in format 2, of what it holds. Any
3338/// other record: nothing.
3339fn note_whole_record(stores: &[Store], memory: &mut Memory, key: &str, value: Option<&str>, c: u64) {
3340    let (file, entry) = (file_of(key), entry_of(key));
3341    let Some((store, _)) = locate(stores, file) else { return };
3342    let Shape::Toml(rules) = store.shape else { return };
3343    if rules.next.is_none() {
3344        return;
3345    }
3346    let Renamed::Whole(keyed) = renamed(rules, rules.at(2), entry) else { return };
3347    let elements: Vec<toml::Value> = value
3348        .and_then(|v| leaf_value(v).ok())
3349        .and_then(|v| v.as_array().cloned())
3350        .unwrap_or_default()
3351        .into_iter()
3352        .map(|mut element| {
3353            strip_local(&mut element, keyed.local);
3354            element
3355        })
3356        .collect();
3357    let held: BTreeSet<String> = names(&elements, keyed).into_iter().collect();
3358    memory.lists.entry(key.to_string()).or_default().saw(c, &held);
3359}
3360
3361/// What each list format 1 sends whole holds now in the files format 2
3362/// divides otherwise, by format 2's names (`Memory::lists`): an element a
3363/// list no longer holds is noted as taken out at the clock of the list's
3364/// last word here (this device's or another's), until a list holds it again.
3365/// While this device writes format 1, after each exchange and before the
3366/// switch renames its memory.
3367fn note_whole_lists(stores: &[Store], memory: &mut Memory) {
3368    for (file, path, store) in divided(stores) {
3369        let Shape::Toml(rules) = store.shape else { continue };
3370        let next = rules.at(2);
3371        let Ok(text) = std::fs::read_to_string(&path) else { continue };
3372        if text.parse::<toml::Table>().is_err() {
3373            continue;
3374        }
3375        let mut now: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
3376        for (before, after, _) in aligned(&text, rules, next) {
3377            if matches!(renamed(rules, next, &before), Renamed::Whole(_)) && !after.is_empty() {
3378                now.entry(format!("{file}#{before}")).or_default().insert(element_name(&after).to_string());
3379            }
3380        }
3381        let keys: BTreeSet<String> = memory.lists.keys().filter(|key| file_of(key) == file).cloned().chain(now.keys().cloned()).collect();
3382        for key in keys {
3383            let held = now.remove(&key).unwrap_or_default();
3384            let clock = memory.entries.get(&key).map_or(memory.clock, |known| known.c);
3385            memory.lists.entry(key).or_default().saw(clock, &held);
3386        }
3387    }
3388}
3389
3390/// The name a floor is kept under: a hash of the format-1 entry (whose name may be a whole money line).
3391fn floor_key(key: &str) -> String {
3392    derived("floor", key)
3393}
3394
3395/// Whether a record of an element of a list format 1 sent whole (pinned
3396/// sites, words, days off, routines, task kinds, lighter days) comes at or
3397/// below the clock of a whole list that took that element out here
3398/// (`Floor::removed`), the list not holding it since. Passed over, so that
3399/// an element taken out before the switch never comes back, as when a
3400/// device restates its old entries in a new round (`share-own-cut`). An
3401/// element no whole list here ever held (added in format 2 elsewhere, its
3402/// record waiting here) is never passed over: a list that did not know it
3403/// did not take it out.
3404fn below_whole_floor(memory: &Memory, key: &str, c: u64) -> bool {
3405    let (file, entry) = (file_of(key), entry_of(key));
3406    let Some((list, element)) = entry.rsplit_once(SEP) else { return false };
3407    let Some(name) = element.strip_prefix(MARK) else { return false };
3408    let whole = format!("{file}#{list}");
3409    let by_floor = memory.floors.get(&floor_key(&whole)).is_some_and(|floor| floor.held.as_ref().is_some_and(|held| !held.contains(name)) && floor.removed.get(name).is_some_and(|at| c <= *at));
3410    // The whole lists' records read here, in any order, in any format this device wrote (T7b: a device joining reads a list with it and a later one without in one exchange).
3411    by_floor || memory.lists.get(&whole).and_then(|list| list.removed_at(name)).is_some_and(|at| c <= at)
3412}
3413
3414/// Why a device holds back format 2 (`format_holders`).
3415#[derive(Debug, Clone, PartialEq, Eq)]
3416pub enum Holds {
3417    /// Its Sioul does not read format 2, or does not say: an older one, or a
3418    /// device known by its records alone.
3419    Older,
3420    /// Its entry does not read here (its seal not arrived, damaged, taken away).
3421    Unread,
3422    /// It reads format 2, but what it wrote before is not all read here yet.
3423    Unheard,
3424}
3425
3426/// When an entry was written, as far as its own times tell (each writing
3427/// sets one of them to its device's clock then, and none goes back).
3428fn entry_stamp(entry: &crate::devices::Entry) -> i64 {
3429    entry.started.max(entry.imported).max(entry.closed).max(entry.exported)
3430}
3431
3432/// What this device has seen of each other's sealed entry, kept
3433/// (`Memory::seen`): the latest by its own times. An older copy put back in
3434/// the folder (a replay by a hostile server) never lowers it.
3435fn note_entries(folder: &Path, key: &[u8; 32], memory: &mut Memory) {
3436    for entry in crate::devices::all(folder, key).0 {
3437        if memory.seen.get(&entry.id).is_none_or(|seen| entry_stamp(&entry) >= entry_stamp(seen)) {
3438            memory.seen.insert(entry.id.clone(), entry);
3439        }
3440    }
3441}
3442
3443/// The clock of each other device's last record in the folder, opened (sealed,
3444/// bound to its device, round, number and clock), for the devices this
3445/// memory has not heard of yet (`Memory::heard_at`): a memory from before it
3446/// was kept learns when each device it read last wrote.
3447///
3448/// A device read here whose rounds are gone from the folder takes the latest
3449/// clock of its changes this memory still holds (each the clock of a record
3450/// read here, sealed); one with none at all is noted as found without a
3451/// clock (`Memory::unclocked`), at `now_ms`, once.
3452fn fill_heard_at(folder: &Path, key: &[u8; 32], computer: &str, memory: &mut Memory, now_ms: i64) {
3453    for (other, their_rounds) in rounds_with(folder) {
3454        if other == computer || memory.heard_at.contains_key(&other) {
3455            continue;
3456        }
3457        let Some(&round) = their_rounds.last() else { continue };
3458        if let Some(clock) = last_record_clock(folder, key, &other, round) {
3459            memory.heard_at.insert(other, (clock >> 16) as i64);
3460        }
3461    }
3462    let read: Vec<String> = memory.read_n.keys().filter(|id| *id != computer && !memory.heard_at.contains_key(*id)).cloned().collect();
3463    for other in read {
3464        let held = memory.entries.values().filter(|k| k.w == other).map(|k| k.c).chain(memory.floors.values().filter(|f| f.w == other).map(|f| f.c)).max();
3465        match held {
3466            Some(clock) => {
3467                memory.heard_at.insert(other.clone(), (clock >> 16) as i64);
3468                memory.unclocked.remove(&other);
3469            }
3470            None => {
3471                memory.unclocked.entry(other).or_insert(now_ms);
3472            }
3473        }
3474    }
3475    let heard: Vec<String> = memory.unclocked.keys().filter(|id| memory.heard_at.contains_key(*id)).cloned().collect();
3476    for id in heard {
3477        memory.unclocked.remove(&id);
3478    }
3479}
3480
3481/// The clock of the last record of a round that opens with the key, read from its end.
3482fn last_record_clock(folder: &Path, key: &[u8; 32], computer: &str, round: u32) -> Option<u64> {
3483    use std::io::{Read, Seek};
3484    let mut file = open_round(folder, computer, round).ok()?;
3485    let size = file.metadata().ok()?.len();
3486    let from = size.saturating_sub(256 * 1024);
3487    let mut tail = Vec::new();
3488    file.seek(std::io::SeekFrom::Start(from)).ok()?;
3489    file.read_to_end(&mut tail).ok()?;
3490    tail.split(|b| *b == b'\n').rev().find_map(|line| {
3491        let record = std::str::from_utf8(line).ok().and_then(|t| serde_json::from_str::<Line>(t).ok())?;
3492        open(key, &bound(computer, round, record.n, record.c), &record.s).map(|_| record.c)
3493    })
3494}
3495
3496/// When another device was last heard of, from sealed facts only: its entry's
3497/// times (sealed, bound to it) and the clock of its last record read here
3498/// (`Memory::heard_at`), in milliseconds; 0 when nothing is known. Never its
3499/// notes in the folder, which are plain and could be forged.
3500fn last_heard(entry: Option<&crate::devices::Entry>, memory: &Memory, id: &str) -> i64 {
3501    let entry = entry.map_or(0, |e| entry_stamp(e) * 1000);
3502    entry.max(memory.heard_at.get(id).copied().unwrap_or(0))
3503}
3504
3505/// The other devices of the sharing that count, and what each says of
3506/// itself (its latest sealed entry seen here, `Memory::seen`, a replayed
3507/// older copy never lowering it): every device known here by a sealed fact
3508/// (its readable entry, its records in the folder, or its records read here
3509/// before, `read_n`, `heard_at`, whatever the folder shows now: a hostile
3510/// folder hiding a device's files never makes it go), but those that left
3511/// (their entry says so), those you forgot until they say anything newer
3512/// (`forget_device`), those another device's entry says it replaces, until
3513/// they say anything newer than it says (`devices::Entry::replaces`), and
3514/// those not heard of for `SILENT_DAYS` (one of which no clock is known at
3515/// all, from when this device first found it so, `Memory::unclocked`). A
3516/// file alone (plain notes, an entry that does not read) with no record is a
3517/// stray file, not a device. The same devices as `counted` says count.
3518fn present(folder: &Path, key: &[u8; 32], computer: &str, memory: &Memory, now_ms: i64) -> Vec<(String, Option<crate::devices::Entry>, bool)> {
3519    counted(folder, key, computer, memory, now_ms)
3520        .into_iter()
3521        .filter_map(|(id, counted)| match counted {
3522            Counted::Yes(entry, unread) => Some((id, entry, unread)),
3523            _ => None,
3524        })
3525        .collect()
3526}
3527
3528/// Where another device of the sharing stands, by sealed facts (`counted`):
3529/// what `present` keeps, and why it leaves a device out.
3530#[derive(Debug, Clone, PartialEq, Eq)]
3531enum Counted {
3532    /// It counts: its latest sealed entry seen here (none: known by its
3533    /// records alone), and whether its entry does not read here.
3534    Yes(Option<crate::devices::Entry>, bool),
3535    /// Its entry says it stopped sharing.
3536    Left,
3537    /// You said it is gone (`forget_device`), and it said nothing newer since.
3538    Forgotten,
3539    /// Another device's entry says it replaces it (`devices::Entry::replaces`),
3540    /// and it said nothing newer since.
3541    Replaced,
3542    /// Not heard of for `SILENT_DAYS`, by sealed facts.
3543    Silent,
3544    /// A file alone (plain notes, an entry that does not read) with no
3545    /// record: a stray file, not a device; a device whose entry has not come yet.
3546    Stray,
3547}
3548
3549impl Counted {
3550    /// Gone by a sealed fact that a device of its own never undoes by
3551    /// silence: it left, you forgot it, another device replaces it. Its past
3552    /// rounds and the sealed files it wrote that no record names any more are
3553    /// the others' to take out (`departed_rounds`, `sweep_theirs`).
3554    fn departed(&self) -> bool {
3555        matches!(self, Counted::Left | Counted::Forgotten | Counted::Replaced)
3556    }
3557}
3558
3559/// Every other device known here, and where it stands (`Counted`): every
3560/// device known by a sealed fact, as `present` says, and the stray files of
3561/// the folder (notes alone). A stray file you forgot, or that a replacement
3562/// names, is said so.
3563fn counted(folder: &Path, key: &[u8; 32], computer: &str, memory: &Memory, now_ms: i64) -> Vec<(String, Counted)> {
3564    let (entries, unread) = crate::devices::all(folder, key);
3565    // Every replacement said: by the entries read now and the latest seen
3566    // here (an older copy put back never takes one away), this device's own among them.
3567    let replaced: Vec<crate::devices::Replaced> = entries.iter().chain(memory.seen.values()).flat_map(|e| e.replaces.iter().cloned()).collect();
3568    let recorded: BTreeSet<String> = rounds_with(folder).into_keys().chain(memory.read_n.keys().cloned()).chain(memory.heard_at.keys().cloned()).collect();
3569    let mut ids = computers(folder);
3570    ids.extend(entries.iter().map(|e| e.id.clone()));
3571    ids.extend(unread.iter().cloned());
3572    ids.extend(recorded.iter().cloned());
3573    ids.into_iter()
3574        .filter(|id| id != computer)
3575        .map(|id| {
3576            let here = entries.iter().find(|e| e.id == id).cloned();
3577            let entry = match (here, memory.seen.get(&id)) {
3578                (Some(here), Some(seen)) if entry_stamp(seen) > entry_stamp(&here) => Some(seen.clone()),
3579                (None, Some(seen)) => Some(seen.clone()),
3580                (here, _) => here,
3581            };
3582            let last = last_heard(entry.as_ref(), memory, &id);
3583            let forgotten = memory.forgotten.get(&id).is_some_and(|mark| last <= *mark);
3584            let replaces = replaced.iter().any(|r| r.id == id && r.holds(entry.as_ref().map(|e| e.started), last));
3585            // Replaced before forgotten: every device reads a replacement
3586            // (sealed in another device's entry), a forget is this one's word
3587            // alone, and what goes with a removed device's files follows it
3588            // (`small_go`: forgotten here, then replaced, its entry goes too).
3589            let counted = if entry.is_none() && !recorded.contains(&id) {
3590                if replaces {
3591                    Counted::Replaced
3592                } else if forgotten {
3593                    Counted::Forgotten
3594                } else {
3595                    Counted::Stray
3596                }
3597            } else if entry.as_ref().is_some_and(|e| e.left) {
3598                Counted::Left
3599            } else if replaces {
3600                Counted::Replaced
3601            } else if forgotten {
3602                Counted::Forgotten
3603            } else {
3604                // No clock known at all: aged from when this device first found it so.
3605                let aged = if last > 0 { last } else { memory.unclocked.get(&id).copied().unwrap_or(0) };
3606                if aged > 0 && now_ms - aged > SILENT_DAYS * DAY {
3607                    Counted::Silent
3608                } else {
3609                    let unread = unread.contains(&id) && entries.iter().all(|e| e.id != id);
3610                    Counted::Yes(entry, unread)
3611                }
3612            };
3613            (id, counted)
3614        })
3615        .collect()
3616}
3617
3618/// The devices whose reading holds back the removal of a device's rounds
3619/// (`past_below`): every device that counts (`counted`), whose notes say how
3620/// far it read (none in the folder: nothing read, everything held back; a
3621/// hostile folder hiding them never makes a round go), and a file of notes
3622/// alone whose notes say it read within `SILENT_DAYS` (a device whose entry
3623/// has not come yet: plain notes only ever hold more back). Never a device
3624/// that left, that you forgot, that another replaces, or silent for
3625/// `SILENT_DAYS` by sealed facts, whatever its notes say: until 16 October
3626/// 2026 every device whose plain notes were heard within 180 days held them
3627/// back, a phone dead for good among them, and nothing of the others' left
3628/// the folder for half a year.
3629fn readers(folder: &Path, counted: &[(String, Counted)], now_ms: i64) -> BTreeSet<String> {
3630    counted
3631        .iter()
3632        .filter(|(id, counted)| match counted {
3633            Counted::Yes(..) => true,
3634            Counted::Stray => read_seen(folder, id).is_some_and(|s| now_ms / 1000 - s.at < SILENT_DAYS * 86_400),
3635            _ => false,
3636        })
3637        .map(|(id, _)| id.clone())
3638        .collect()
3639}
3640
3641/// The devices that hold back format 2 (`FORMAT`), and why: of the devices
3642/// that count (`present`), each must say in its sealed entry that it reads
3643/// format 2 (`devices::Entry::format`), and all it wrote up to that entry
3644/// (`wrote`) must be read here (`read_n`). A device whose entry does not read
3645/// here, or known by its records alone, holds it back: an older Sioul cannot
3646/// read format 2 and would double what it changes (an entry that no longer
3647/// reads is said as such, even when an older one was seen here). A hostile
3648/// folder can only hold it back longer, never open it.
3649fn format_holders(folder: &Path, key: &[u8; 32], computer: &str, memory: &Memory, now_ms: i64) -> Vec<(String, Holds)> {
3650    let heard = Heard { read: memory.read_n.clone(), broken: BTreeMap::new() };
3651    present(folder, key, computer, memory, now_ms)
3652        .into_iter()
3653        .filter_map(|(id, entry, unread)| match entry {
3654            Some(entry) if entry.format >= 2 => entry.wrote.is_some_and(|wrote| !heard.complete(&id, wrote)).then_some((id, Holds::Unheard)),
3655            _ if unread => Some((id, Holds::Unread)),
3656            _ => Some((id, Holds::Older)),
3657        })
3658        .collect()
3659}
3660
3661/// How long this device keeps the sealed files no record points to any more
3662/// (days): two weeks (`blobs::UNUSED_DAYS`) once every other device that
3663/// counts (`present`) says in its sealed entry that it keeps them so too
3664/// (`devices::Entry::keeping`), else 90 days (`blobs::UNUSED_DAYS_BEFORE`), as
3665/// until 15 October 2026. An older Sioul makes a content it uses again fresh
3666/// only past 30 days (`blobs::put`): on a phone under eDrive, which keeps every
3667/// sealed file it brought, it could put back an earlier version whose
3668/// content left the server, and name it in a record no device could follow.
3669/// A device whose entry does not read, or known by its records alone, counts
3670/// as an older one; a hostile folder can only keep contents longer.
3671fn unused_days(sharing: &Sharing, memory: &Memory, now_ms: i64) -> i64 {
3672    let all = present(sharing.folder, sharing.key, sharing.computer, memory, now_ms).iter().all(|(_, entry, _)| entry.as_ref().is_some_and(|e| e.keeping >= crate::blobs::KEEPING_TWO_WEEKS));
3673    if all { crate::blobs::UNUSED_DAYS } else { crate::blobs::UNUSED_DAYS_BEFORE }
3674}
3675
3676/// Whether this device may write format 2: another device at least counts
3677/// (`present`; alone, it writes format 1: a device joining later may run an
3678/// older Sioul), and none holds it back (`format_holders`). A device not
3679/// joined yet holds nothing format 1 could lose: every other device saying
3680/// it reads format 2 is enough, and it joins in format 2.
3681fn format_open(sharing: &Sharing, memory: &Memory, now_ms: i64) -> bool {
3682    !present(sharing.folder, sharing.key, sharing.computer, memory, now_ms).is_empty() && format_holders(sharing.folder, sharing.key, sharing.computer, memory, now_ms).iter().all(|(_, holds)| !memory.joined && *holds == Holds::Unheard)
3683}
3684
3685/// When `device` was last heard of here, from sealed facts only (`last_heard`:
3686/// its latest entry read here, its records' last clock read), in
3687/// milliseconds; 0 when nothing is known. What a replacement says of it
3688/// (`devices::Replaced::heard`).
3689pub fn heard_of(folder: &Path, key: &[u8; 32], memory: &Path, computer: &str, device: &str) -> i64 {
3690    let state = Memory::load_all(memory, computer);
3691    let (entries, _) = crate::devices::all(folder, key);
3692    let here = entries.iter().find(|e| e.id == device);
3693    let entry = match (here, state.seen.get(device)) {
3694        (Some(here), Some(seen)) if entry_stamp(seen) > entry_stamp(here) => Some(seen),
3695        (None, Some(seen)) => Some(seen),
3696        (here, _) => here,
3697    };
3698    last_heard(entry, &state, device)
3699}
3700
3701/// A device you said is gone (Settings ▸ Your folder and sharing ▸ Forget
3702/// this device), as the sharing sees it: it no longer holds format 2 back,
3703/// nor keeps a part held, until it says anything newer than now (an entry
3704/// written since, a record read here: `present`). Waits for an exchange running.
3705pub fn forget_device(folder: &Path, key: &[u8; 32], memory: &Path, computer: &str, device: &str) -> Result<(), String> {
3706    let _running = exchange_lock(memory, true)?;
3707    let mut state = Memory::load_all(memory, computer);
3708    let (entries, _) = crate::devices::all(folder, key);
3709    let mark = last_heard(entries.iter().find(|e| e.id == device), &state, device);
3710    state.forgotten.insert(device.to_string(), mark.max(1));
3711    state.save(memory)?;
3712    state.keep(memory);
3713    Ok(())
3714}
3715
3716/// A device counted again (Count it again), whatever it said since.
3717pub fn count_device_again(memory: &Path, computer: &str, device: &str) -> Result<(), String> {
3718    let _running = exchange_lock(memory, true)?;
3719    let mut state = Memory::load_all(memory, computer);
3720    if state.forgotten.remove(device).is_some() {
3721        state.save(memory)?;
3722        state.keep(memory);
3723    }
3724    Ok(())
3725}
3726
3727/// What this device knows of the sharing's formats, for the window
3728/// (Settings ▸ Your folder and sharing, the status line): the format it
3729/// writes, the parts met in a newer format than it knows (their format,
3730/// the build that wrote it), and, while it writes format 1, the devices that
3731/// hold format 2 back and why.
3732#[derive(Debug, Default, Clone)]
3733pub struct Formats {
3734    pub writes: u32,
3735    pub newer: Vec<(String, u32, String)>,
3736    pub holders: Vec<(String, Holds)>,
3737    /// The devices that count (`present`) whose entry says they read an
3738    /// older format than this one's: said once this device writes format 2.
3739    pub older: Vec<String>,
3740    /// When each device of `holders` and `older` was last heard of, from
3741    /// sealed facts (`last_heard`), in milliseconds; 0 when nothing says it.
3742    /// Settings lists each, with "Forget this device" for one not counted
3743    /// otherwise.
3744    pub heard: BTreeMap<String, i64>,
3745}
3746
3747pub fn formats(folder: &Path, key: &[u8; 32], memory: &Path, computer: &str, now_ms: i64) -> Formats {
3748    let state = Memory::load(memory, computer);
3749    let holders = if state.format < 2 && state.joined { format_holders(folder, key, computer, &state, now_ms) } else { Vec::new() };
3750    let counting = present(folder, key, computer, &state, now_ms);
3751    let older = if state.format >= 2 { counting.iter().filter(|(_, entry, _)| entry.as_ref().is_some_and(|e| e.format < FORMAT)).map(|(id, _, _)| id.clone()).collect() } else { Vec::new() };
3752    let heard = counting.iter().filter(|(id, _, _)| holders.iter().any(|(holder, _)| holder == id) || older.contains(id)).map(|(id, entry, _)| (id.clone(), last_heard(entry.as_ref(), &state, id))).collect();
3753    Formats { writes: state.format.max(1), newer: state.newer.into_iter().map(|(part, newer)| (part, newer.format, newer.build)).collect(), holders, older, heard }
3754}
3755
3756/// Whether this device holds a part met in a newer format than its Sioul
3757/// reads (`Memory::newer`), as its memory says on the disk: the same in every
3758/// process, after a restart too. For the doses: this device's entry then says
3759/// its doses are not known, and it never closes as if they were (docs/health.md).
3760pub fn holds(memory: &Path, part: &str) -> bool {
3761    // Only the parts held are kept from the reading: the rest of the memory is passed over, never built.
3762    #[derive(Deserialize)]
3763    struct Held {
3764        #[serde(default)]
3765        newer: BTreeMap<String, serde::de::IgnoredAny>,
3766    }
3767    std::fs::read_to_string(memory).ok().and_then(|t| serde_json::from_str::<Held>(&t).ok()).is_some_and(|held| held.newer.contains_key(part))
3768}
3769
3770/// The format this device writes (`FORMAT`): 1 until every device reads format 2 (`format_open`), then 2.
3771pub fn writes(memory: &Path) -> u32 {
3772    format_of(memory).max(1)
3773}
3774
3775/// The format this device writes, as its memory says (`Memory::format`), whichever computer's it is.
3776fn format_of(memory: &Path) -> u32 {
3777    std::fs::read_to_string(memory).ok().and_then(|t| serde_json::from_str::<serde_json::Value>(&t).ok()).and_then(|v| v.get("format").and_then(serde_json::Value::as_u64)).map_or(0, |f| f as u32)
3778}
3779
3780/// Parts held for writers that no longer count (`present`: gone, forgotten,
3781/// silent 180 days: a test build tried once): no longer held. Their changes in that newer format, kept waiting,
3782/// are dropped; read again later, they are passed over (`Memory::released`).
3783fn release_newer(sharing: &Sharing, memory: &mut Memory, now_ms: i64) {
3784    if memory.newer.is_empty() {
3785        return;
3786    }
3787    // Only writers that no longer count, by sealed facts (`present`): never
3788    // an entry's format, which a record ahead of its new entry, or an older
3789    // copy put back, could say.
3790    let counting: BTreeSet<String> = present(sharing.folder, sharing.key, sharing.computer, memory, now_ms).into_iter().map(|(id, _, _)| id).collect();
3791    let gone: Vec<String> = memory.newer.iter().filter(|(_, newer)| newer.writers.iter().all(|w| !counting.contains(w))).map(|(part, _)| part.clone()).collect();
3792    for part in gone {
3793        memory.newer.remove(&part);
3794        let dropped: Vec<String> = memory.waiting.iter().filter(|(key, (_, _, _, f))| known_part(file_of(key)) == Some(part.as_str()) && *f > part_format(&part)).map(|(key, _)| key.clone()).collect();
3795        for key in dropped {
3796            if let Some((_, c, _, _)) = memory.waiting.remove(&key) {
3797                let released = memory.released.entry(part.clone()).or_default();
3798                *released = (*released).max(c);
3799            }
3800        }
3801    }
3802}
3803
3804/// How format 2 names an entry format 1 named so (its path in its file).
3805enum Renamed<'r> {
3806    /// As format 1 does.
3807    Same,
3808    /// An element of a list whose elements format 2 names otherwise (`Keyed::ids`).
3809    Element(&'r Keyed),
3810    /// A list format 1 sends whole, divided element by element in format 2.
3811    Whole(&'r Keyed),
3812}
3813
3814fn renamed<'r>(rules: &Rules, next: &'r Rules, entry: &str) -> Renamed<'r> {
3815    let path: Vec<&str> = entry.split(SEP).collect();
3816    match path.split_last() {
3817        Some((element, list)) if element.starts_with(MARK) && !list.is_empty() => match (keyed_named(rules, list), keyed_named(next, list)) {
3818            (Some(before), Some(after)) if before.ids == after.ids && before.by == after.by => Renamed::Same,
3819            (_, Some(after)) => Renamed::Element(after),
3820            _ => Renamed::Same,
3821        },
3822        _ => match (keyed_named(rules, &path), keyed_named(next, &path)) {
3823            (None, Some(after)) => Renamed::Whole(after),
3824            _ => Renamed::Same,
3825        },
3826    }
3827}
3828
3829/// The elements of a file's keyed lists (format 2's), each with its entry
3830/// in format 1 (its own, or its whole list's when format 1 sends the list
3831/// whole), its entry in format 2, and its value.
3832fn aligned(text: &str, rules: &Rules, next: &Rules) -> Vec<(String, String, String)> {
3833    let Ok(table) = text.parse::<toml::Table>() else { return Vec::new() };
3834    let mut out = Vec::new();
3835    for (name, value) in &table {
3836        align(vec![name.clone()], value, rules, next, &mut out);
3837    }
3838    out
3839}
3840
3841fn align(path: Vec<String>, value: &toml::Value, rules: &Rules, next: &Rules, out: &mut Vec<(String, String, String)>) {
3842    if next.local.iter().any(|rule| matches_rule(&path, rule)) {
3843        return;
3844    }
3845    if let toml::Value::Array(elements) = value
3846        && let Some(keyed) = keyed_at(next, &path)
3847    {
3848        let (before, after) = list_names(elements, &path, rules, keyed);
3849        let list = list_name(keyed, &path);
3850        for ((entry, name), element) in before.into_iter().zip(names(&after, keyed)).zip(&after) {
3851            out.push((entry, format!("{list}{SEP}{MARK}{name}"), leaf_text(element)));
3852        }
3853        return;
3854    }
3855    if let toml::Value::Table(table) = value
3856        && !next.whole.iter().any(|rule| matches_rule(&path, rule))
3857    {
3858        for (name, value) in table {
3859            let mut deeper = path.clone();
3860            deeper.push(name.clone());
3861            align(deeper, value, rules, next, out);
3862        }
3863    }
3864}
3865
3866/// A keyed list's elements at `path`: their entries in format 1 (their own,
3867/// or the whole list's), and the elements as format 2 reads them (its local
3868/// fields out).
3869fn list_names(elements: &[toml::Value], path: &[String], rules: &Rules, keyed: &Keyed) -> (Vec<String>, Vec<toml::Value>) {
3870    let strip = |local: &[&str]| -> Vec<toml::Value> {
3871        elements
3872            .iter()
3873            .map(|element| {
3874                let mut element = element.clone();
3875                strip_local(&mut element, local);
3876                element
3877            })
3878            .collect()
3879    };
3880    let before: Vec<String> = match keyed_at(rules, path) {
3881        Some(before) => {
3882            let list = list_name(before, path);
3883            names(&strip(before.local), before).into_iter().map(|name| format!("{list}{SEP}{MARK}{name}")).collect()
3884        }
3885        None => vec![path.join(&SEP.to_string()); elements.len()],
3886    };
3887    (before, strip(keyed.local))
3888}
3889
3890/// The name format 2 gives an element format 1 named `ident` when the file
3891/// here does not say otherwise: format 1 named it by itself (`by` empty),
3892/// and its own id, else the id derived from it, names it; by its id: that
3893/// id; by other fields (a session by its start, task and project): the id
3894/// derived from them, the one written into it when a device took format 2.
3895fn stateless(keyed: &Keyed, ident: &str) -> String {
3896    if keyed.by.is_empty()
3897        && let Some(id) = leaf_value(ident).ok().as_ref().and_then(own_id).map(str::to_string)
3898    {
3899        return id;
3900    }
3901    if keyed.by == ["id"] && !ident.is_empty() {
3902        return ident.to_string();
3903    }
3904    derived(keyed.list, ident)
3905}
3906
3907/// The element's name (after `␞`) in an entry.
3908fn element_name(entry: &str) -> &str {
3909    entry.rsplit_once(MARK).map_or("", |(_, name)| name)
3910}
3911
3912/// An element as text, its id left out: what two versions of one element compare by.
3913fn without_id(text: &str) -> String {
3914    match leaf_value(text) {
3915        Ok(toml::Value::Table(mut table)) => {
3916            table.remove("id");
3917            leaf_text(&toml::Value::Table(table))
3918        }
3919        Ok(other) => leaf_text(&other),
3920        Err(_) => text.to_string(),
3921    }
3922}
3923
3924/// Format 1's changes (`older`: by entry, the later of each), for the files
3925/// format 2 divides otherwise, in format 2's names. Passed over: one at or
3926/// below what format 1 already said under that entry here (`Floor`). An
3927/// element's change takes the name format 2 gives it: its own id; for a
3928/// session (named by its start, task and project) without one, the id of
3929/// the session of that name here (a device on format 1 writes a session
3930/// without the id the switch gave it here: the id is kept), else the id
3931/// derived from that name; for a money line, its id, else the id derived
3932/// from it. A value without its id takes the id it goes under, written into
3933/// it: what the switch wrote stays. From an older Sioul (`modern`: the
3934/// writers whose sealed entry says they read format 2; the others), a line's
3935/// removal named by a version this device does not hold any more (a copy
3936/// changed since) is passed over; from a device that reads format 2 and
3937/// still writes format 1 for a few minutes, the later word wins. Several
3938/// entries for one element (an older Sioul changes a line by adding its new
3939/// version and taking the old one out) make one change: the latest version
3940/// held, at the latest clock; none held, taken out. A list sent whole sets
3941/// what it holds and takes out, of what the previous whole list held, what
3942/// it no longer holds, at its clock: never an element added since in format
3943/// 2. Returns the changes, and the names that differ from what a device
3944/// holding none of them would give (a session whose own id was kept here,
3945/// its value carrying none): sent again from here in format 2, so that every
3946/// device, one joining later too, holds it under one name.
3947fn translate(stores: &[Store], older: &BTreeMap<String, (Option<String>, u64, String)>, memory: &mut Memory, winners: &BTreeMap<String, (Option<String>, u64, String)>, modern: &BTreeSet<String>) -> (Vec<(String, Option<String>, u64, String)>, Vec<(String, String)>) {
3948    let mut versions: BTreeMap<String, Vec<(Option<String>, u64, String)>> = BTreeMap::new();
3949    let mut out = Vec::new();
3950    let mut again = Vec::new();
3951    let mut here: BTreeMap<String, Vec<(String, String, String)>> = BTreeMap::new();
3952    for (key, (value, c, w)) in older {
3953        let (file, entry) = (file_of(key), entry_of(key));
3954        let Some((store, path)) = locate(stores, file) else { continue };
3955        let Shape::Toml(rules) = store.shape else { continue };
3956        let next = rules.at(2);
3957        let renaming = renamed(rules, next, entry);
3958        if matches!(renaming, Renamed::Same) {
3959            out.push((key.clone(), value.clone(), *c, w.clone()));
3960            continue;
3961        }
3962        let floor = floor_key(key);
3963        let previous = memory.floors.get(&floor).cloned();
3964        if previous.as_ref().is_some_and(|f| (*c, w.as_str()) <= (f.c, f.w.as_str())) {
3965            continue;
3966        }
3967        let elements = here.entry(file.to_string()).or_insert_with(|| aligned(&std::fs::read_to_string(&path).unwrap_or_default(), rules, next));
3968        match renaming {
3969            Renamed::Same => {}
3970            Renamed::Element(keyed) => {
3971                let (list, ident) = entry.rsplit_once(SEP).map_or(("", ""), |(list, element)| (list, element.trim_start_matches(MARK)));
3972                let given = value.as_deref().and_then(|v| leaf_value(v).ok()).map(|mut element| {
3973                    strip_local(&mut element, keyed.local);
3974                    element
3975                });
3976                let fallback = stateless(keyed, ident);
3977                // The element format 1 names so here (a session by its start,
3978                // task and project), when none holds the id format 2 derives
3979                // from that name: one whose id was kept (an older Sioul wrote it
3980                // back without).
3981                let derived_here = elements.iter().any(|(_, after, _)| element_name(after) == fallback);
3982                let mine = (!keyed.by.is_empty() && !derived_here).then(|| elements.iter().find(|(before, _, _)| before == entry)).flatten();
3983                let mut value = value.clone();
3984                // The id kept from the session of that name here (an older Sioul wrote it back without).
3985                let mut kept = false;
3986                let name = match (&given, given.as_ref().and_then(own_id), mine) {
3987                    (Some(_), Some(id), _) => id.to_string(),
3988                    (Some(_), None, Some((_, after, text))) => {
3989                        kept = leaf_value(text).ok().as_ref().and_then(own_id).is_some();
3990                        element_name(after).to_string()
3991                    }
3992                    (None, _, Some((_, after, _))) => element_name(after).to_string(),
3993                    _ => fallback.clone(),
3994                };
3995                // An element's value without its id (format 1 named it otherwise) takes the id it
3996                // goes under, as the switch wrote it into the element: never written back without.
3997                if let Some(element) = given.as_ref().filter(|e| own_id(e).is_none() && e.is_table()) {
3998                    let mut element = element.clone();
3999                    if let toml::Value::Table(table) = &mut element {
4000                        table.insert("id".into(), toml::Value::String(name.clone()));
4001                    }
4002                    value = Some(leaf_text(&element));
4003                }
4004                // A line taken out as a version this device no longer holds under
4005                // that name, by an older Sioul (its sealed entry says no newer
4006                // format): passed over. From a device that reads format 2 (it
4007                // still writes format 1 for a few minutes), the later word wins,
4008                // as it will when that device takes format 2.
4009                if given.is_none() && keyed.by.is_empty() && !modern.contains(w) {
4010                    let key2 = format!("{file}#{list}{SEP}{MARK}{name}");
4011                    let current = winners.get(&key2).map(|(v, _, _)| v.clone()).unwrap_or_else(|| elements.iter().find(|(_, after, _)| *after == format!("{list}{SEP}{MARK}{name}")).map(|(_, _, text)| text.clone()));
4012                    if current.is_some_and(|text| without_id(&text) != without_id(ident)) {
4013                        memory.floors.insert(floor, Floor { c: *c, w: w.clone(), held: None, removed: BTreeMap::new() });
4014                        continue;
4015                    }
4016                }
4017                memory.floors.insert(floor, Floor { c: *c, w: w.clone(), held: None, removed: BTreeMap::new() });
4018                if kept && name != fallback {
4019                    again.push((format!("{file}#{list}{SEP}{MARK}{name}"), format!("{file}#{list}{SEP}{MARK}{fallback}")));
4020                }
4021                versions.entry(format!("{file}#{list}{SEP}{MARK}{name}")).or_default().push((value, *c, w.clone()));
4022            }
4023            Renamed::Whole(keyed) => {
4024                let path: Vec<String> = entry.split(SEP).map(str::to_string).collect();
4025                let prefix = format!("{file}#{}{SEP}{MARK}", list_name(keyed, &path));
4026                let elements: Vec<toml::Value> = value
4027                    .as_deref()
4028                    .and_then(|v| leaf_value(v).ok())
4029                    .and_then(|v| v.as_array().cloned())
4030                    .unwrap_or_default()
4031                    .into_iter()
4032                    .map(|mut element| {
4033                        strip_local(&mut element, keyed.local);
4034                        element
4035                    })
4036                    .collect();
4037                let listed: BTreeSet<String> = names(&elements, keyed).into_iter().collect();
4038                for (element, name) in elements.iter().zip(names(&elements, keyed)) {
4039                    versions.entry(format!("{prefix}{name}")).or_default().push((Some(leaf_text(element)), *c, w.clone()));
4040                }
4041                // Of what the previous whole list held, what this one no longer holds.
4042                let mut removed = previous.as_ref().map(|f| f.removed.clone()).unwrap_or_default();
4043                for gone in previous.and_then(|f| f.held).unwrap_or_default().difference(&listed) {
4044                    versions.entry(format!("{prefix}{gone}")).or_default().push((None, *c, w.clone()));
4045                    removed.insert(gone.clone(), *c);
4046                }
4047                removed.retain(|name, _| !listed.contains(name));
4048                memory.floors.insert(floor, Floor { c: *c, w: w.clone(), held: Some(listed), removed });
4049            }
4050        }
4051    }
4052    for (key, list) in versions {
4053        let Some(last) = list.iter().max_by(|a, b| (a.1, &a.2).cmp(&(b.1, &b.2))) else { continue };
4054        let held = list.iter().filter(|v| v.0.is_some()).max_by(|a, b| (a.1, &a.2).cmp(&(b.1, &b.2))).and_then(|v| v.0.clone());
4055        out.push((key, held, last.1, last.2.clone()));
4056    }
4057    (out, again)
4058}
4059
4060/// The files of the stores format 2 divides otherwise: their names in the
4061/// records, where they are, their store.
4062fn divided<'s>(stores: &'s [Store]) -> Vec<(String, PathBuf, &'s Store)> {
4063    let mut out = Vec::new();
4064    for store in stores.iter().filter(|s| matches!(s.shape, Shape::Toml(rules) if rules.next.is_some())) {
4065        if store.folder {
4066            let mut files = Vec::new();
4067            list_files(&store.path, &store.path, store.skip, &mut files);
4068            out.extend(files.into_iter().map(|(relative, path)| (format!("{}{relative}", store.name), path, store)));
4069        } else if store.path.is_file() {
4070            out.push((store.name.clone(), store.path.clone(), store));
4071        }
4072    }
4073    out
4074}
4075
4076/// This device takes format 2 (`FORMAT`), once and for all, before this
4077/// exchange goes on:
4078/// 1. every file format 2 divides otherwise must read whole: one that does
4079///    not (half written, broken by hand), or of no bytes while it held
4080///    entries, keeps this device on format 1 for now, said
4081///    (`share-format-wait`), and the switch is tried at the next exchange;
4082/// 2. what changed in them since the last look goes out first, in format 1
4083///    and as format 1 names it (`flush`): a removal, a corrected session
4084///    reach the devices that still read format 1;
4085/// 3. each element of a list format 2 names by ids is given its id in the
4086///    file, once, through the file's lock, the file as it was kept first
4087///    among the earlier versions (`give_ids`); from then on every element
4088///    carries an id of its own, which changing it never changes;
4089/// 4. the memory is said in format 2's names, each renamed entry's last
4090///    clock kept as its floor (`Floor`).
4091///
4092/// Returns whether it did. Stopped half way, the next exchange does it
4093/// again from where the files and the memory are: the ids already written
4094/// stay, what was sent is known.
4095fn switch_format(sharing: &Sharing, memory: &mut Memory, stores: &[Store], now_ms: i64, problems: &mut Vec<String>) -> Result<bool, String> {
4096    let files = divided(stores);
4097    for (name, path, _) in &files {
4098        let text = std::fs::read_to_string(path);
4099        let held = memory.entries.iter().any(|(key, known)| file_of(key) == name && !known.h.is_empty());
4100        let reads = match &text {
4101            Ok(text) if text.trim().is_empty() => !held,
4102            Ok(text) => text.parse::<toml::Table>().is_ok(),
4103            Err(_) => false,
4104        };
4105        if !reads {
4106            problems.push(format!("share-format-wait:{name}"));
4107            return Ok(false);
4108        }
4109    }
4110    if memory.joined {
4111        flush(sharing, memory, stores, now_ms)?;
4112    }
4113    // What each whole list holds after that, and what one took out: kept with its floor.
4114    note_whole_lists(stores, memory);
4115    // Ids written first, file by file; the memory renamed only once all are:
4116    // one that cannot be written (no room for the copy kept, a file being
4117    // written) keeps this device on format 1 for now, said; tried again at the
4118    // next exchange, the ids already written stay theirs.
4119    let history = crate::history::root(sharing.memory);
4120    let mut done = Vec::new();
4121    for (name, path, store) in &files {
4122        match give_ids(store, name, path, memory, &history, now_ms) {
4123            Ok(mut placed) => {
4124                placed.extend(whole_lists(store, name, path, memory));
4125                done.push((name, *store, placed));
4126            }
4127            Err(e) => {
4128                problems.push(e);
4129                problems.push(format!("share-format-wait:{name}"));
4130                return Ok(false);
4131            }
4132        }
4133    }
4134    for (name, store, placed) in done {
4135        rename_memory(memory, store, name, &placed);
4136    }
4137    let belongs = |file: &str| locate(stores, file).is_some_and(|(store, _)| matches!(store.shape, Shape::Toml(rules) if rules.next.is_some()));
4138    memory.files.retain(|file, _| !belongs(file));
4139    // Changes of these files waiting to be written: with the others' format-1 records, translated.
4140    let pending: Vec<String> = memory.pending.keys().filter(|key| belongs(file_of(key))).cloned().collect();
4141    for key in pending {
4142        if let Some((value, c, w)) = memory.pending.remove(&key) {
4143            wait(&mut memory.waiting, key, (value, c, w, 1));
4144        }
4145    }
4146    memory.format = 2;
4147    memory.switch = false;
4148    Ok(true)
4149}
4150
4151/// What changed in the files format 2 divides otherwise since the last look,
4152/// sent in format 1 (this device still writes it), as an exchange sends it;
4153/// not for a store joining in this exchange, nor a part held under a newer format.
4154fn flush(sharing: &Sharing, memory: &mut Memory, stores: &[Store], now_ms: i64) -> Result<(), String> {
4155    // Not a store joining in this exchange (switched on, or moved: it joins as
4156    // a new device would, its file rebuilt from the records first), nor a part
4157    // held under a newer format (nothing of it goes out from here).
4158    let joining = |store: &Store| !memory.places.is_empty() && memory.places.get(&store.name) != Some(&place_of(store));
4159    let subset: Vec<Store> = stores.iter().filter(|s| matches!(s.shape, Shape::Toml(rules) if rules.next.is_some()) && !joining(s) && !memory.newer.contains_key(s.part)).cloned().collect();
4160    let confirmed = BTreeSet::new();
4161    let found = gather(&subset, &memory.files, &Look { reads: &|_| true, hurry: &|| false, confirmed: &confirmed, clock: clock_ms(), changing: &BTreeMap::new() });
4162    let mut out: Vec<(String, Option<String>, u64, String)> = Vec::new();
4163    for (key, h) in &found.hashes {
4164        if memory.entries.get(key).is_some_and(|k| k.h == *h) {
4165            continue;
4166        }
4167        let Some(value) = found.values.get(key).cloned().or_else(|| value_of(&subset, key)) else { continue };
4168        let clock = memory.tick(found.changed.get(file_of(key)).copied().unwrap_or(now_ms).min(now_ms));
4169        memory.entries.insert(key.clone(), Known { c: clock, w: sharing.computer.to_string(), h: h.clone(), b: String::new() });
4170        out.push((key.clone(), Some(value), clock, String::new()));
4171    }
4172    let gone: Vec<String> = memory.entries.iter().filter(|(key, known)| !known.h.is_empty() && !found.hashes.contains_key(*key) && !found.is_unknown(key) && locate(&subset, file_of(key)).is_some()).map(|(key, _)| key.clone()).collect();
4173    for key in gone {
4174        let clock = memory.tick(now_ms);
4175        memory.entries.insert(key.clone(), Known { c: clock, w: sharing.computer.to_string(), h: String::new(), b: String::new() });
4176        out.push((key, None, clock, String::new()));
4177    }
4178    if memory.round == 0 {
4179        memory.round = 1;
4180    }
4181    append(sharing, memory, &out)?;
4182    for (file, stat) in found.files {
4183        memory.files.insert(file, stat);
4184    }
4185    Ok(())
4186}
4187
4188/// An element of a list format 2 names by ids, as `give_ids` left it: its
4189/// entry in format 1 before, its entry in format 2, its value, and whether it
4190/// stands for that format-1 entry (what format 1 last sent or received
4191/// under it: the other devices hold it under that name, and give it the same id).
4192struct Placed {
4193    before: String,
4194    after: String,
4195    text: String,
4196    stands: bool,
4197    /// It holds exactly what was last sent or received: else (changed
4198    /// meanwhile, or one of several named alike) it is sent again in format
4199    /// 2, under its id, for every device to hold it as it is here.
4200    exact: bool,
4201}
4202
4203/// Each element of the lists of `path` that format 2 names by ids is given
4204/// one in the file, once: the element that stands for a format-1 entry
4205/// (what was last sent or received under it) the id derived from that entry
4206/// (`derived`), as every device holding it derives it; another (a second
4207/// session at one start, a second money line alike, a preset an older Sioul
4208/// never carried) an id of its own, drawn at random, since it was never
4209/// shared. Two elements holding one id (two devices changed one element
4210/// apart while they wrote format 1): the one holding the greatest text keeps
4211/// it, the other takes the id derived from its own format-1 entry. A
4212/// preset's id is put where splits and lines named it by its place
4213/// (`#3`). Written through the file's lock, the file as it was kept first
4214/// (`history`). Returns every element of those lists.
4215fn give_ids(store: &Store, file: &str, path: &Path, memory: &Memory, history: &Path, now_ms: i64) -> Result<Vec<Placed>, String> {
4216    let Shape::Toml(rules) = store.shape else { return Ok(Vec::new()) };
4217    let next = rules.at(2);
4218    sioul_core::filelock::with_lock(path, || {
4219        let text = std::fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?;
4220        let mut doc: toml_edit::DocumentMut = text.parse().map_err(|e: toml_edit::TomlError| format!("{}: {e}", path.display()))?;
4221        let mut before: Vec<(Vec<String>, Vec<String>, Vec<(bool, bool)>)> = Vec::new();
4222        let mut presets: BTreeMap<String, String> = BTreeMap::new();
4223        let mut changed = false;
4224        each_id_list(doc.as_table_mut(), Vec::new(), next, &mut |path, keyed, list| {
4225            let normal = |t: &toml_edit::Table| leaf_value(&format!("[v]\n{t}")).ok();
4226            let read: Vec<toml::Value> = list.iter().map(|t| normal(t).unwrap_or_else(|| toml::Value::Table(toml::Table::new()))).collect();
4227            let (entries, _) = list_names(&read, path, rules, keyed);
4228            let whole = keyed_at(rules, path).is_none();
4229            // A whole list stands for its format-1 entry when the file holds what was last sent.
4230            let whole_stands = whole && memory.entries.get(&format!("{file}#{}", entries.first().cloned().unwrap_or_default())).is_some_and(|k| !k.h.is_empty() && toml_entries(&text, rules).is_ok_and(|all| all.iter().any(|(e, v)| entries.first() == Some(e) && hash(v) == k.h)));
4231            let texts: Vec<String> = read
4232                .iter()
4233                .map(|element| {
4234                    let mut element = element.clone();
4235                    strip_local(&mut element, keyed_at(rules, path).map_or(keyed.local, |b| b.local));
4236                    leaf_text(&element)
4237                })
4238                .collect();
4239            let mut ids: BTreeMap<String, Vec<usize>> = BTreeMap::new();
4240            for (i, element) in read.iter().enumerate() {
4241                if let Some(id) = own_id(element) {
4242                    ids.entry(id.to_string()).or_default().push(i);
4243                }
4244            }
4245            // The element each format-1 entry stands for: its one element; of
4246            // several format 1 named alike (two sessions an older Sioul noted
4247            // at one start), the first when that entry's last word came from
4248            // another device (format 1 wrote it there), the last when from
4249            // this one (format 1 sent that one).
4250            let mut alike: BTreeMap<&str, Vec<usize>> = BTreeMap::new();
4251            for (i, entry) in entries.iter().enumerate() {
4252                alike.entry(entry.as_str()).or_default().push(i);
4253            }
4254            let standing = |i: usize| -> (bool, bool) {
4255                if whole {
4256                    return (whole_stands, true);
4257                }
4258                let Some(known) = memory.entries.get(&format!("{file}#{}", entries[i])).filter(|k| !k.h.is_empty()) else { return (false, false) };
4259                let at = &alike[entries[i].as_str()];
4260                let chosen = if at.len() == 1 { at[0] } else if known.w == memory.computer { at[at.len() - 1] } else { at[0] };
4261                (chosen == i, known.h == hash(&texts[i]))
4262            };
4263            let mut claimed: BTreeSet<String> = BTreeSet::new();
4264            let mut stands = vec![(false, false); read.len()];
4265            for (i, element) in read.iter().enumerate() {
4266                let entry = &entries[i];
4267                let (shared, exact) = standing(i);
4268                let ident = element_name(entry).to_string();
4269                let new_id = match own_id(element) {
4270                    Some(id) if ids.get(id).is_some_and(|at| at.len() == 1) => {
4271                        stands[i] = (shared, exact);
4272                        None
4273                    }
4274                    Some(id) => {
4275                        let at = &ids[id];
4276                        let keeper = at.iter().copied().max_by(|a, b| texts[*a].cmp(&texts[*b]).then(b.cmp(a))).unwrap_or(i);
4277                        stands[i] = (shared, exact);
4278                        (keeper != i).then(|| derived(keyed.list, if whole { &texts[i] } else { &ident }))
4279                    }
4280                    None => {
4281                        let what = if whole { texts[i].clone() } else { ident.clone() };
4282                        if (shared || !memory.joined) && claimed.insert(what.clone()) {
4283                            stands[i] = (shared, exact);
4284                            Some(derived(keyed.list, &what))
4285                        } else {
4286                            Some(sioul_core::ids::new())
4287                        }
4288                    }
4289                };
4290                if let Some(id) = new_id {
4291                    if keyed.list == "preset" && path.len() == 1 {
4292                        presets.insert(format!("#{i}"), id.clone());
4293                    }
4294                    if let Some(table) = list.get_mut(i) {
4295                        table.insert("id", toml_edit::value(id));
4296                        changed = true;
4297                    }
4298                }
4299            }
4300            before.push((path.to_vec(), entries, stands));
4301        });
4302        // Splits, lines and mail rules that named a preset by its place name it
4303        // by its id. Format 1 carried one preset without an id only, so places
4304        // may name different presets on each device: each element so changed
4305        // goes out again (not exact), and one version wins everywhere.
4306        let mut repointed: BTreeSet<(String, usize)> = BTreeSet::new();
4307        if !presets.is_empty() {
4308            for list in ["split", "line", "mail_rule"] {
4309                if let Some(tables) = doc.get_mut(list).and_then(toml_edit::Item::as_array_of_tables_mut) {
4310                    for (at, table) in tables.iter_mut().enumerate() {
4311                        if let Some(id) = table.get("preset").and_then(toml_edit::Item::as_str).and_then(|place| presets.get(place)).cloned() {
4312                            table.insert("preset", toml_edit::value(id));
4313                            repointed.insert((list.to_string(), at));
4314                        }
4315                    }
4316                }
4317            }
4318        }
4319        let written = doc.to_string();
4320        if changed {
4321            crate::history::keep(history, store.part, file, path, now_ms)?;
4322            write_atomically(path, written.as_bytes())?;
4323        }
4324        // Every element as format 2 now reads it, beside its entry in format 1 before.
4325        let now: toml::Table = written.parse().map_err(|e: toml::de::Error| format!("{}: {e}", path.display()))?;
4326        let mut placed = Vec::new();
4327        for (list_path, entries, stands) in before {
4328            let Some(keyed) = keyed_at(next, &list_path) else { continue };
4329            let Some(elements) = array_at(&now, &list_path) else { continue };
4330            let (_, after) = list_names(elements, &list_path, rules, keyed);
4331            let list = list_name(keyed, &list_path);
4332            for (at, (((entry, (stands, exact)), name), element)) in entries.into_iter().zip(stands).zip(names(&after, keyed)).zip(&after).enumerate() {
4333                let exact = exact && !(list_path.len() == 1 && repointed.contains(&(list_path[0].clone(), at)));
4334                placed.push(Placed { before: entry, after: format!("{list}{SEP}{MARK}{name}"), text: leaf_text(element), stands, exact });
4335            }
4336        }
4337        Ok(placed)
4338    })
4339}
4340
4341/// The array at `path` in a table.
4342fn array_at<'t>(table: &'t toml::Table, path: &[String]) -> Option<&'t Vec<toml::Value>> {
4343    let (last, within) = path.split_last()?;
4344    let mut at = table;
4345    for part in within {
4346        at = at.get(part)?.as_table()?;
4347    }
4348    at.get(last)?.as_array()
4349}
4350
4351/// The elements of the lists of words and dates format 1 sends whole (format
4352/// 2 names them by themselves, no id to give): each stands for its whole
4353/// list's entry when the file holds what was last sent under it.
4354fn whole_lists(store: &Store, file: &str, path: &Path, memory: &Memory) -> Vec<Placed> {
4355    let Shape::Toml(rules) = store.shape else { return Vec::new() };
4356    let next = rules.at(2);
4357    let Ok(text) = std::fs::read_to_string(path) else { return Vec::new() };
4358    let sent: BTreeMap<String, String> = toml_entries(&text, rules).unwrap_or_default().into_iter().map(|(entry, value)| (entry, hash(&value))).collect();
4359    aligned(&text, rules, next)
4360        .into_iter()
4361        .filter(|(before, after, _)| matches!(renamed(rules, next, before), Renamed::Whole(keyed) if !keyed.ids) && !after.is_empty())
4362        .map(|(before, after, text)| {
4363            let stands = memory.entries.get(&format!("{file}#{before}")).is_some_and(|known| !known.h.is_empty() && sent.get(&before) == Some(&known.h));
4364            Placed { before, after, text, stands, exact: true }
4365        })
4366        .collect()
4367}
4368
4369/// Each list of tables in `table` that format 2 names by ids (`Keyed::ids`), with its path.
4370fn each_id_list(table: &mut toml_edit::Table, path: Vec<String>, next: &Rules, f: &mut dyn FnMut(&[String], &Keyed, &mut toml_edit::ArrayOfTables)) {
4371    let keys: Vec<String> = table.iter().map(|(key, _)| key.to_string()).collect();
4372    for key in keys {
4373        let mut deeper = path.clone();
4374        deeper.push(key.clone());
4375        if next.local.iter().any(|rule| matches_rule(&deeper, rule)) {
4376            continue;
4377        }
4378        let Some(item) = table.get_mut(&key) else { continue };
4379        match item {
4380            toml_edit::Item::ArrayOfTables(list) => {
4381                if let Some(keyed) = keyed_at(next, &deeper).filter(|k| k.ids) {
4382                    f(&deeper, keyed, list);
4383                }
4384            }
4385            toml_edit::Item::Table(inner) if !next.whole.iter().any(|rule| matches_rule(&deeper, rule)) => each_id_list(inner, deeper, next, f),
4386            _ => {}
4387        }
4388    }
4389}
4390
4391/// The memory of one file said in format 2's names, once its elements carry
4392/// their ids (`give_ids`): the entry each element stands for becomes its
4393/// entry in format 2 (its value now holding the id: its hash too), at the
4394/// same clock; a list format 1 sent whole becomes an entry per element, its
4395/// clock and what it held kept as its floor; an entry for an element no
4396/// longer here (taken out) is named as `stateless` names it. Several of
4397/// format 1's entries for one element (its versions) make one: the latest
4398/// clock, the version held. Each renamed entry keeps its clock as its floor.
4399fn rename_memory(memory: &mut Memory, store: &Store, file: &str, placed: &[Placed]) {
4400    let Shape::Toml(rules) = store.shape else { return };
4401    let next = rules.at(2);
4402    let mut grouped: BTreeMap<String, Vec<Known>> = BTreeMap::new();
4403    let mut wholes: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
4404    for element in placed {
4405        let before = format!("{file}#{}", element.before);
4406        if let Renamed::Whole(_) = renamed(rules, next, &element.before) {
4407            wholes.entry(before.clone()).or_default().insert(element_name(&element.after).to_string());
4408        }
4409        if !element.stands {
4410            continue;
4411        }
4412        if let Some(known) = memory.entries.get(&before) {
4413            // Not exactly what was shared: a hash nothing holds, so that the next look sends it.
4414            let h = if element.exact { hash(&element.text) } else { "?".to_string() };
4415            grouped.entry(format!("{file}#{}", element.after)).or_default().push(Known { c: known.c, w: known.w.clone(), h, b: String::new() });
4416        }
4417    }
4418    let keys: Vec<String> = memory.entries.keys().filter(|key| file_of(key) == file).cloned().collect();
4419    for key in keys {
4420        let entry = entry_of(&key).to_string();
4421        let renaming = renamed(rules, next, &entry);
4422        if matches!(renaming, Renamed::Same) {
4423            continue;
4424        }
4425        let Some(known) = memory.entries.remove(&key) else { continue };
4426        match renaming {
4427            Renamed::Same => {}
4428            Renamed::Whole(_) => {
4429                let held = wholes.get(&key).cloned().filter(|_| !known.h.is_empty());
4430                let removed = memory.lists.get(&key).map(WholeList::removed).unwrap_or_default();
4431                memory.floors.insert(floor_key(&key), Floor { c: known.c, w: known.w.clone(), held: Some(held.unwrap_or_default()), removed });
4432            }
4433            Renamed::Element(keyed) => {
4434                memory.floors.insert(floor_key(&key), Floor { c: known.c, w: known.w.clone(), held: None, removed: BTreeMap::new() });
4435                if !placed.iter().any(|p| p.stands && format!("{file}#{}", p.before) == key) {
4436                    let (list, ident) = entry.rsplit_once(SEP).map_or(("", ""), |(list, element)| (list, element.trim_start_matches(MARK)));
4437                    grouped.entry(format!("{file}#{list}{SEP}{MARK}{}", stateless(keyed, ident))).or_default().push(Known { h: String::new(), ..known });
4438                }
4439            }
4440        }
4441    }
4442    for (key, versions) in grouped {
4443        let Some(last) = versions.iter().max_by(|a, b| (a.c, &a.w).cmp(&(b.c, &b.w))).cloned() else { continue };
4444        let held = versions.iter().filter(|v| !v.h.is_empty()).max_by(|a, b| (a.c, &a.w).cmp(&(b.c, &b.w))).map(|v| v.h.clone()).unwrap_or_default();
4445        memory.entries.insert(key, Known { c: last.c, w: last.w, h: held, b: String::new() });
4446    }
4447}
4448
4449// ---------------------------------------------------------------- exchange
4450
4451/// Sharing as set up on this computer.
4452pub struct Sharing<'a> {
4453    pub folder: &'a Path,
4454    pub computer: &'a str,
4455    pub key: &'a [u8; 32],
4456    /// Where this computer keeps what it remembers: `<state>/share/memory.json`.
4457    pub memory: &'a Path,
4458    /// Notes and papers too (`Shape::Files`): not for an exchange that must be
4459    /// quick (a dose's alarm, a notification's button, Sioul closing), nor
4460    /// without the system's leave to read them all. A single file sealed apart
4461    /// (the spam filter's table) goes in every exchange.
4462    pub files: bool,
4463    /// Set while a hurried exchange waits for this one: its work on notes and
4464    /// papers stops where it is, and goes on at the next.
4465    pub hurry: Option<&'a std::sync::atomic::AtomicBool>,
4466}
4467
4468/// What an exchange did.
4469#[derive(Debug, Default)]
4470pub struct Outcome {
4471    /// The stores written to, by name: what to read again.
4472    pub written: BTreeSet<String>,
4473    /// Why notes and papers received wait, by reason, counted (never named):
4474    /// "no-store" (not shared here), "not-read" (not this time: a quick
4475    /// exchange, or Android's leave to read them all not given), "hurried",
4476    /// "unknown" (their file here not readable as it stands), "backoff" (could
4477    /// not be written lately, tried again later), "no-blob" (their sealed file
4478    /// neither in the folder nor fetched beside it yet: waited for, `blobs::Wanted`),
4479    /// "later" (changed here since it was looked at: the next exchange sends that first),
4480    /// "copies" (a take-out past what an exchange copies into the history, `TAKE_OUT_COPIES`).
4481    pub waits: BTreeMap<String, usize>,
4482    /// Changes found here and sent.
4483    pub sent: usize,
4484    /// Changes from the others written here.
4485    pub received: usize,
4486    /// What went wrong, in a word each; the rest went on.
4487    pub problems: Vec<String>,
4488    /// The others' changes waiting to be written here (their file unreadable, or not shared here).
4489    pub pending: usize,
4490    /// When this computer's files were read for it (milliseconds, its clock):
4491    /// everything captured here before is in its records, up to `wrote`. 0
4492    /// when it did not run (`share-busy`).
4493    pub looked: i64,
4494    /// How far this computer's records went once it ended: round and number;
4495    /// none before sharing joined.
4496    pub wrote: Option<(u32, u64)>,
4497    /// Nothing it reads had changed since an exchange left everything
4498    /// settled: nothing was read nor written (`quiet`).
4499    pub quiet: bool,
4500    /// The notes and papers gone many at once here and held, as this exchange
4501    /// left them (`withheld`); none when it did not get that far (`share-busy`,
4502    /// a seal missing, nothing to do).
4503    pub withheld: Option<Vec<Withheld>>,
4504}
4505
4506/// Before the first exchange, a copy of every shared file, in case. Not of
4507/// notes and papers, which may be large: each file is kept before anything
4508/// is written over it (`history`). Not of the calls' logs: nothing removes
4509/// these copies, and a call leaves every device after its month. A copy
4510/// there already is never written over: a first exchange tried again (one
4511/// that failed after writing the others' changes), or sharing stopped and
4512/// started again the same day, keeps the files as they were before sharing
4513/// (review of 5 October 2026, F22).
4514fn keep_copies(stores: &[Store], memory: &Path, today: &str) {
4515    let Some(base) = memory.parent() else { return };
4516    let aside = base.join(format!("before-sharing-{today}"));
4517    let keep = |from: &Path, to: &Path| {
4518        if to.exists() {
4519            return;
4520        }
4521        let _ = to.parent().map(private_dirs);
4522        let _ = copy_private(from, to);
4523    };
4524    for store in stores.iter().filter(|s| !matches!(s.shape, Shape::Files) && !calls_store(&s.name)) {
4525        let target = aside.join(store.name.trim_end_matches('/'));
4526        if store.folder {
4527            let mut files = Vec::new();
4528            list_files(&store.path, &store.path, store.skip, &mut files);
4529            for (relative, path) in files {
4530                keep(&path, &target.join(relative));
4531            }
4532        } else if store.path.is_file() {
4533            keep(&store.path, &target);
4534        }
4535    }
4536}
4537
4538/// The copies beside drafts kept before sharing began (`keep_copies`:
4539/// `before-sharing-<day>/data/drafts/<id>.eml`, beside `memory`), which
4540/// nothing removes: until 19 October 2026 one could hold an encrypted
4541/// message's text in clear (`sioul_core::draftcopies::seal_versions`
4542/// encrypts those again).
4543pub fn draft_copies_kept_before_sharing(memory: &Path) -> Vec<PathBuf> {
4544    let Some(base) = memory.parent() else { return Vec::new() };
4545    let mut out = Vec::new();
4546    for aside in std::fs::read_dir(base).into_iter().flatten().filter_map(Result::ok) {
4547        if !aside.file_name().to_string_lossy().starts_with("before-sharing-") || !aside.file_type().is_ok_and(|t| t.is_dir()) {
4548            continue;
4549        }
4550        for copy in std::fs::read_dir(aside.path().join("data").join("drafts")).into_iter().flatten().filter_map(Result::ok) {
4551            if copy.file_type().is_ok_and(|t| t.is_file()) && copy.path().extension().is_some_and(|e| e == "eml") {
4552                out.push(copy.path());
4553            }
4554        }
4555    }
4556    out.sort();
4557    out
4558}
4559
4560/// The exchange lock as `sioul_core::draftcopies::Hold` asks it, for the
4561/// copies beside drafts encrypted again: taken for each short batch of their
4562/// replacements, waiting for an exchange running, so that no exchange reads a
4563/// copy while it is replaced (nor seals and sends the zeros written over the
4564/// old one after). A device that has never shared (no `share/` folder beside
4565/// `memory`) runs no exchange: a guard that holds nothing, no folder made.
4566pub fn replacements_hold(memory: &Path) -> impl Fn() -> Option<Box<dyn sioul_core::draftcopies::Held>> + '_ {
4567    move || {
4568        if !memory.parent().is_some_and(Path::exists) {
4569            return sioul_core::draftcopies::unshared();
4570        }
4571        exchange_lock(memory, true).ok().flatten().map(|file| Box::new(HandedOff(memory.to_path_buf(), Some(file))) as Box<dyn sioul_core::draftcopies::Held>)
4572    }
4573}
4574
4575/// The lock held for a batch of the copies beside drafts, handed to an
4576/// exchange waiting in this process when let go (`hand_off`), let go at once
4577/// when none waits here; it ends after its copy once one does (`waiting_here`).
4578struct HandedOff(PathBuf, Option<std::fs::File>);
4579
4580impl sioul_core::draftcopies::Held for HandedOff {
4581    fn wanted(&self) -> bool {
4582        waiting_here(&self.0)
4583    }
4584}
4585
4586impl Drop for HandedOff {
4587    fn drop(&mut self) {
4588        hand_off(&self.0, self.1.take(), std::time::Duration::ZERO);
4589    }
4590}
4591
4592/// One exchange: the changes found here go out, the others' come in. One at a
4593/// time on this computer, whatever runs it (`exchange_lock`): another running,
4594/// a quick one waits for it; a whole one does nothing, and says so (`share-busy`).
4595pub fn exchange(sharing: &Sharing, stores: &[Store], now_ms: i64) -> Result<Outcome, String> {
4596    // The folder as it was set up, its seal opening with this device's key;
4597    // else nothing is read, written or made, and it is said (`sealed_for`).
4598    let (sealed, salt) = match sealed_for(sharing.folder, sharing.key) {
4599        Ok(Some((opens, salt))) => (Some(opens), salt),
4600        Ok(None) => (None, String::new()),
4601        Err(()) => return Ok(Outcome { problems: vec!["share-no-seal".into()], ..Outcome::default() }),
4602    };
4603    let Some(_running) = exchange_lock(sharing.memory, !sharing.files)? else { return Ok(Outcome { problems: vec!["share-busy".into()], ..Outcome::default() }) };
4604    // What was fetched from the server too, read beside the folder, the newer
4605    // copy of each other device's file (`remote`); none until a server's
4606    // folder is confirmed to be this one.
4607    crate::remote::attach(sharing.folder, sharing.memory);
4608    let mut outcome = Outcome::default();
4609    // Notes and papers only when asked (not for a dose's alarm, a button
4610    // pressed, Sioul closing), and while no hurried exchange waits for this
4611    // one: their work stops where it is, and goes on at the next.
4612    let hurried = || sharing.hurry.is_some_and(|hurry| hurry.load(std::sync::atomic::Ordering::Relaxed));
4613    // A single file sealed apart (the spam filter's table), in every exchange:
4614    // one file of Sioul's own, quick to read, never in a shared storage.
4615    let reads = |store: &Store| reads_now(sharing, store);
4616    // What it reads, as it stands before anything is read (`Standing`): as
4617    // when the last exchange in this process left everything settled, there
4618    // is nothing to do, and nothing is read (`quiet`). Not with notes and
4619    // papers: the window's exchanges read them through.
4620    // Its time taken first: whatever was written before it is in the stamps.
4621    let standing = (!sharing.files).then(|| {
4622        let taken = clock_ms();
4623        (Standing::now(sharing, stores, &reads), taken)
4624    });
4625    if let Some(outcome) = standing.as_ref().and_then(|(standing, taken)| quiet(sharing, standing, *taken, now_ms)) {
4626        return Ok(outcome);
4627    }
4628    let mut memory = Memory::load_all(sharing.memory, sharing.computer);
4629    // Another sharing than the one this memory knew (its seal made from
4630    // another salt): this device set up again with another folder while its
4631    // memory stayed (the keyring's key lost, the passphrase typed again with
4632    // another folder). How far it read there, and that it joined, mean nothing
4633    // here: it starts anew, as when sharing stops and starts again (review
4634    // of 5 October 2026, F22), its own records here read first if any.
4635    if sealed == Some(true) && !memory.seal_salt.is_empty() && memory.seal_salt != salt {
4636        // Its own records here, if any, were sealed under another key (two
4637        // devices that sealed the folder apart): a new round of its own, full,
4638        // which the others can open and read on from (`later_full`).
4639        let round = last_own(sharing.folder, sharing.computer).map_or(1, |(round, _, _)| round + 1);
4640        memory = Memory { computer: sharing.computer.to_string(), round, full_round: round, ..Memory::default() };
4641    }
4642    if sealed == Some(true) {
4643        memory.seal_salt = salt;
4644    }
4645    // Its seal gone from a folder this device saw sealed (a sync app that made
4646    // the folder again, empty, or brought an empty placeholder of it), or
4647    // there but no longer opening with this device's key (another device's
4648    // seal put in its place: two devices sealed the folder apart): nothing
4649    // written there until it is as it was, said (`sealed_for`).
4650    match (sealed, memory.seal_seen) {
4651        (None, true) => return Ok(Outcome { problems: vec!["share-no-seal".into()], ..Outcome::default() }),
4652        (Some(false), true) => return Ok(Outcome { problems: vec!["share-sealed-otherwise".into()], ..Outcome::default() }),
4653        (Some(true), false) => memory.seal_seen = true,
4654        _ => {}
4655    }
4656    // This computer's own records in the folder go further than its memory: the
4657    // memory was restored from a backup, lost, or started again ("Stop sharing",
4658    // then again). Its numbering goes on after the folder's last line (the
4659    // others never read a number twice, nor miss one), and every file is read
4660    // again from all the records, its own too: what it marked after its memory's
4661    // time comes back here.
4662    let mut restate = false;
4663    if let Some((round, n, size)) = last_own(sharing.folder, sharing.computer)
4664        && (round, n) > (memory.round, memory.seq)
4665    {
4666        outcome.problems.push("share-own-ahead".into());
4667        // Lines this device did not append (`appended`: its own last append,
4668        // noted before it was made, where a process stopped before saving its
4669        // memory left it) again within hours: another device writes under this
4670        // one's name (a disk or a state folder copied to it), each finding the
4671        // other's lines in its own file; a memory restored finds them once.
4672        // Said, for you to stop sharing on one of them and start it again
4673        // there, which gives it a name of its own (review of 5 October 2026,
4674        // F22). Meanwhile the others meet gaps in that name's records: the
4675        // doses say the doubt.
4676        if appended(sharing.memory, sharing.computer).is_none_or(|end| (round, n) > end) {
4677            if memory.ahead_at > 0 && now_ms - memory.ahead_at < TWIN_WITHIN {
4678                outcome.problems.push("share-twin".into());
4679            }
4680            memory.ahead_at = now_ms;
4681        }
4682        memory.round = round;
4683        memory.seq = n;
4684        memory.own_size = size;
4685        memory.round_base = 0;
4686        // Where its rounds stood is read from the folder, not from the memory
4687        // (`Turn`): a round found without a line (its first writing lost, a
4688        // placeholder) continues the round before as that round ends; with
4689        // none before it, a full round follows, restating all it holds.
4690        memory.continues = 0;
4691        if n == 0 && round > 1 {
4692            match last_line(sharing.folder, sharing.computer, round - 1) {
4693                Some(last) if last > 0 => memory.continues = last,
4694                _ => restate = true,
4695            }
4696        }
4697        // Which of its rounds was full is not known: none taken out until
4698        // the next full round (`remove_old_rounds` keeps every round from 1).
4699        // Nor how much that one restated: read again from the folder
4700        // (`full_round_after`), never the size this memory knew when it was
4701        // put away, which would hold the next full round back, and every
4702        // round meanwhile, for that many bytes (review of 15 October 2026, L3).
4703        memory.full_round = 1;
4704        memory.full_size = 0;
4705        memory.read.clear();
4706        memory.rebuild.insert("*".into());
4707    }
4708    // A memory kept before rounds said where they stand (`Turn`) may have
4709    // moved past the end of a round it had not read whole: every other
4710    // computer's kept rounds are read again, once; what was known already
4711    // changes nothing, what was passed over comes in.
4712    // Entries of stores that left (`RETIRED`) are forgotten: nothing reads or
4713    // writes them any more (review of 10 October 2026, S2-B).
4714    memory.entries.retain(|key, _| !retired(key));
4715    if !memory.turns {
4716        if memory.joined {
4717            memory.read.clear();
4718        }
4719        memory.turns = true;
4720    }
4721    if !memory.joined {
4722        let today = jiff::Timestamp::from_millisecond(now_ms).map(|t| t.strftime("%Y-%m-%d").to_string()).unwrap_or_default();
4723        keep_copies(stores, sharing.memory, &today);
4724    }
4725    // The format this device writes (`FORMAT`): format 2 once every device
4726    // in the sharing reads it (`format_open`), or once another device writes
4727    // it already (`Memory::switch`); its memory is then said in format 2's
4728    // names, once (`switch_format`). Its files are read and written as its
4729    // format divides them; an older format's records are translated
4730    // (`translate`).
4731    // Parts met in a newer format that this Sioul now reads (it was updated),
4732    // or whose writers no longer count: no longer held (`release_newer`).
4733    memory.newer.retain(|part, newer| newer.format > part_format(part));
4734    // What is known of the others, from sealed facts (`present`).
4735    note_entries(sharing.folder, sharing.key, &mut memory);
4736    fill_heard_at(sharing.folder, sharing.key, sharing.computer, &mut memory, now_ms);
4737    release_newer(sharing, &mut memory, now_ms);
4738    if memory.format < 2 && (memory.switch || format_open(sharing, &memory, now_ms)) {
4739        switch_format(sharing, &mut memory, stores, now_ms, &mut outcome.problems)?;
4740    }
4741    let originals = stores;
4742    let shaped_stores = shaped(stores, memory.format);
4743    let stores = shaped_stores.as_slice();
4744    // Parts met in a newer format: nothing of them goes out from here until this Sioul is updated.
4745    let held: BTreeSet<&'static str> = memory.newer.keys().filter_map(|part| PARTS.iter().find(|p| **p == part.as_str()).copied()).collect();
4746    let holds = |key: &str| known_part(file_of(key)).is_some_and(|part| held.contains(part));
4747    let clock = clock_ms();
4748    let confirmed = memory.sealed.confirmed.clone();
4749    let mut found = gather(stores, &memory.files, &Look { reads: &reads, hurry: &hurried, confirmed: &confirmed, clock, changing: &memory.sealed.changing });
4750    // Large notes and papers settling: those of the stores read now as this
4751    // look found them, the others' as they were (`Sealed::changing`).
4752    let mut changing = std::mem::take(&mut found.changing);
4753    changing.extend(memory.sealed.changing.iter().filter(|(file, _)| locate(stores, file).is_some_and(|(store, _)| !reads(store))).map(|(file, since)| (file.clone(), *since)));
4754    memory.sealed.changing = changing;
4755    outcome.problems.extend(found.too_big.iter().map(|file| format!("share-too-big:{file}")));
4756    outcome.problems.append(&mut found.said);
4757    outcome.problems.extend(found.emptied.iter().map(|(folder, held)| format!("share-vanished:{folder}:{held}")));
4758    let mut out: Vec<(String, Option<String>, u64, String)> = Vec::new();
4759
4760    // Stores new here (projects shared from now on, a store a new version
4761    // adds) or moved (another notes folder): joined as a new computer joins.
4762    // Their files are rebuilt from all the records first, then only what they
4763    // alone hold goes out: an old copy here never beats the others' newer
4764    // values, an empty new folder is filled, and nothing is taken out
4765    // elsewhere. A memory from before places were kept takes every store where
4766    // it is. A store not read this time joins when it is.
4767    let place = place_of;
4768    if memory.joined && memory.places.is_empty() {
4769        memory.places = stores.iter().map(|s| (s.name.clone(), place(s))).collect();
4770    }
4771    let joining: BTreeSet<String> = if memory.joined { stores.iter().filter(|s| reads(s) && memory.places.get(&s.name) != Some(&place(s))).map(|s| s.name.clone()).collect() } else { BTreeSet::new() };
4772    let joins_store = |key: &str| locate(stores, file_of(key)).is_some_and(|(store, _)| joining.contains(&store.name));
4773    if !joining.is_empty() {
4774        memory.entries.retain(|key, _| !joins_store(key));
4775        memory.read.clear();
4776        memory.rebuild.extend(joining.iter().cloned());
4777    }
4778    // Files rebuilt without being set aside (`rebuild`, `Memory::rejoin`)
4779    // join as the stores joining do: read from the records first, then only
4780    // what the records lack goes out; what they hold may be what was there
4781    // before, and never goes out first, with a clock later than everything
4782    // read, over another device's newer word not read yet (a dose's mark the
4783    // phone had taken back came back on both devices: review of 15 October
4784    // 2026, RV-P2). Not the files set aside in the same step (`rebuild_then`,
4785    // the doses' repair): they hold only what was written after the repair,
4786    // a change made here that goes, never replaced by an older record of the
4787    // same entry (RF-J). Nor every file read again because this device's own
4788    // records go further than its memory (`share-own-ahead`): that memory
4789    // keeps its entries, and what changed here since them goes.
4790    let rebuilt_here: BTreeSet<String> = joining.iter().cloned().chain(std::mem::take(&mut memory.rejoin)).collect();
4791    let joins = |key: &str| {
4792        let file = file_of(key);
4793        rebuilt_here.contains(file) || rebuilt_here.iter().any(|r| r.ends_with('/') && file.starts_with(r.as_str()))
4794    };
4795
4796    // A file sealed apart found empty that was not at the last look (an
4797    // editor writing it in place, a crash): held as it was, a while.
4798    let mut held_empty: BTreeSet<String> = BTreeSet::new();
4799    let emptied_now: Vec<String> = found.files.iter().filter(|(file, stat)| file.starts_with(FILES) && stat.size == 0 && memory.files.get(*file).is_some_and(|before| before.size > 0)).map(|(file, _)| file.clone()).collect();
4800    for file in emptied_now {
4801        let since = *memory.emptied.entry(file.clone()).or_insert(now_ms);
4802        if now_ms - since < EMPTIED_WAIT {
4803            found.hashes.remove(&format!("{file}#"));
4804            found.unknown.insert(file.clone());
4805            if let Some(before) = memory.files.get(&file) {
4806                found.files.insert(file.clone(), before.clone());
4807            }
4808            held_empty.insert(file);
4809        }
4810    }
4811    // Not read this time (a quick exchange): held as they were.
4812    let unread = |file: &str| locate(stores, file).is_some_and(|(store, _)| !reads(store));
4813    memory.emptied.retain(|file, _| !file.starts_with(FILES) || held_empty.contains(file) || unread(file));
4814
4815    // Files sealed apart gone here: taken out elsewhere once gone a while
4816    // (`EMPTIED_WAIT`: a file being replaced, a disk away a moment); many at
4817    // once (a folder moved away, deleted in bulk) held until you say so
4818    // (`confirm_gone`) or for `GONE_GRACE`, then out by themselves unless
4819    // they came back (`withheld` says when). A folder missing, unmounted or
4820    // not readable whole is not a deletion: its files are unknown (`gather`),
4821    // never gone, and nothing of them leaves. Meanwhile another device's
4822    // change to one of them still comes, unless many went at once.
4823    let mut held_gone: BTreeSet<String> = BTreeSet::new();
4824    let mut gone_since: BTreeMap<String, i64> = memory.sealed.gone.iter().filter(|(file, _)| unread(file)).map(|(file, since)| (file.clone(), *since)).collect();
4825    // Those held as gone many at once, by store, for the Porch (`withheld`);
4826    // a store not read this time keeps what the last exchange said of it.
4827    let mut mass_held: BTreeMap<String, BTreeMap<String, i64>> = BTreeMap::new();
4828    let mut bulk_now: BTreeSet<String> = memory.sealed.bulk.iter().filter(|file| unread(file)).cloned().collect();
4829    let mut withheld: Vec<Withheld> = std::mem::take(&mut memory.withheld).into_iter().filter(|w| stores.iter().any(|s| s.name == w.store && !reads(s))).collect();
4830    if memory.joined {
4831        let mut by_store: BTreeMap<String, (usize, Vec<String>)> = BTreeMap::new();
4832        for key in memory.entries.iter().filter(|(key, known)| key.starts_with(FILES) && !known.h.is_empty()).map(|(key, _)| key) {
4833            let Some((store, _)) = locate(stores, file_of(key)) else { continue };
4834            if !reads(store) || joining.contains(&store.name) {
4835                continue;
4836            }
4837            let counted = by_store.entry(store.name.clone()).or_default();
4838            counted.0 += 1;
4839            if !found.hashes.contains_key(key) && !found.is_unknown(key) {
4840                counted.1.push(file_of(key).to_string());
4841            }
4842        }
4843        for (store, (held, gone)) in by_store.into_iter().filter(|(_, (_, gone))| !gone.is_empty()) {
4844            // You said to take them out: the folder they went from (its own
4845            // line, `confirm_gone`), or these files ("Take them out now",
4846            // `confirm_withheld`).
4847            let confirmed = |file: &String| memory.sealed.confirmed.iter().any(|said| confirms(said, file)) || memory.sealed.confirmed_files.contains(file);
4848            let mass = gone.len() > MASS || (gone.len() >= 3 && gone.len() * 5 > held);
4849            let since_of = |file: &String| memory.sealed.gone.get(file).copied().unwrap_or(now_ms);
4850            // Held as gone many at once: now, or before and until its own grace ends (`Sealed::bulk`).
4851            let bulk = |file: &String| mass || memory.sealed.bulk.contains(file);
4852            let held_now = gone.iter().any(|file| bulk(file) && !confirmed(file) && now_ms - since_of(file) < GONE_GRACE);
4853            if held_now {
4854                outcome.problems.push(format!("share-vanished:{store}:{}", gone.len()));
4855            }
4856            for file in gone {
4857                let since = since_of(&file);
4858                if bulk(&file) && !confirmed(&file) && now_ms - since < GONE_GRACE {
4859                    gone_since.insert(file.clone(), since);
4860                    mass_held.entry(store.clone()).or_default().insert(file.clone(), since);
4861                    bulk_now.insert(file.clone());
4862                    // Held as it was, its date too: put back, it is dated as it was (`put_back_withheld`).
4863                    if let Some(before) = memory.files.get(&file) {
4864                        found.files.insert(file.clone(), before.clone());
4865                    }
4866                    found.unknown.insert(file);
4867                } else if !bulk(&file) && !confirmed(&file) && now_ms - since < EMPTIED_WAIT {
4868                    gone_since.insert(file.clone(), since);
4869                    held_gone.insert(file);
4870                }
4871            }
4872        }
4873    }
4874    memory.sealed.gone = gone_since;
4875    memory.sealed.bulk = bulk_now;
4876    // What you said to take out that went, or came back, is forgotten.
4877    let gone_now = &memory.sealed.gone;
4878    memory.sealed.confirmed_files.retain(|file| gone_now.contains_key(file));
4879    withheld.extend(mass_held.iter().map(|(store, files)| withheld_of(store, files)));
4880    withheld.sort_by(|a, b| a.store.cmp(&b.store));
4881    memory.withheld = withheld;
4882    let value = |key: &str| found.values.get(key).cloned().or_else(|| value_of(stores, key));
4883    // The contents of the files sealed apart as this exchange finds them: used
4884    // in it, so that one replaced in it (here, or by another device's change
4885    // read now) is swept two weeks from now, not from its last use this device
4886    // noted, which may be days old after days away (`blobs::sweep_after`).
4887    let current_before: BTreeSet<String> = memory.entries.iter().filter(|(key, known)| key.starts_with(FILES) && !known.h.is_empty()).map(|(_, known)| known.h.clone()).collect();
4888    // Those of them the others' records named, by content, with whose record:
4889    // one no longer current at the end is noted as theirs (`Sealed::theirs`).
4890    // So are the others' contents passed by a later change in this reading.
4891    let mut theirs_now: Vec<(String, String)> = memory.entries.iter().filter(|(key, known)| key.starts_with(FILES) && !known.h.is_empty() && known.w != sharing.computer).map(|(_, known)| (known.h.clone(), known.w.clone())).collect();
4892    // The contents of the files sealed apart taken out in this exchange, with
4893    // the writer and round of the record that took each out (`removed_due`).
4894    let mut removed_now: Vec<(String, String, u32)> = Vec::new();
4895    // The contents of the files sealed apart that a change made or read in
4896    // this exchange replaced (its base): kept by reference somewhere, maybe
4897    // (`Sealed::replaced`).
4898    let mut replaced_now: Vec<String> = Vec::new();
4899
4900    // Changes made here since the last look, dated by their file. When joining,
4901    // the others' records come first, and only what they do not hold goes out.
4902    let mut older: Vec<String> = Vec::new();
4903    if memory.joined {
4904        for (key, h) in &found.hashes {
4905            if memory.entries.get(key).is_some_and(|k| k.h == *h) || joins(key) {
4906                memory.held.remove(key);
4907                continue;
4908            }
4909            // Found while its part is held: when it was found is kept; it goes
4910            // once the part is no longer held, at that time (`Memory::held`).
4911            if holds(key) {
4912                if memory.held.get(key).is_none_or(|(_, held)| held != h) {
4913                    let at = memory.tick(found.changed.get(file_of(key)).copied().unwrap_or(now_ms).min(now_ms));
4914                    memory.held.insert(key.clone(), (at, h.clone()));
4915                }
4916                continue;
4917            }
4918            if key.starts_with(FILES) {
4919                // An older copy put back by hand (a backup's, its time before
4920                // the version last seen here): not sent; that version comes
4921                // back, the copy kept beside (`older`).
4922                let file = file_of(key);
4923                if memory.entries.get(key).is_some_and(|k| !k.h.is_empty()) && found.files.get(file).zip(memory.files.get(file)).is_some_and(|(now, before)| now.modified < before.modified) {
4924                    older.push(key.clone());
4925                    continue;
4926                }
4927                if hurried() {
4928                    continue;
4929                }
4930            }
4931            let Some(value) = value(key) else { continue };
4932            // A file sealed apart goes into the folder before its record.
4933            if !seal_apart(sharing, stores, key, &value, &mut memory.sealed, &mut outcome.problems, now_ms) {
4934                continue;
4935            }
4936            let base = base_of(&memory, key);
4937            let clock = match memory.held.remove(key) {
4938                Some((at, held)) if held == *h => at,
4939                _ => memory.tick(found.changed.get(file_of(key)).copied().unwrap_or(now_ms).min(now_ms)),
4940            };
4941            memory.entries.insert(key.clone(), Known { c: clock, w: sharing.computer.to_string(), h: h.clone(), b: base.clone() });
4942            met_draft(&mut memory, key, [&base, h]);
4943            out.push((key.clone(), Some(value), clock, base));
4944        }
4945        // A settings file (the settings, the doses' record…) that held entries
4946        // and is now gone or of no bytes at all: a crash, a full disk, a file
4947        // being written in place, rather than a choice. Held for a while
4948        // (`EMPTIED_WAIT`): never everything taken out, everywhere, at once. A
4949        // list emptied on purpose, or a file gone from a folder of files (a
4950        // draft sent), is taken out at once; a note or a paper after a while
4951        // (above). A store no longer shared here (a part switched off, a notes
4952        // folder unset) takes nothing out elsewhere.
4953        let settings = |file: &str| stores.iter().find(|s| !s.folder && s.name == file && matches!(s.shape, Shape::Toml(_)));
4954        let held: BTreeSet<String> = memory.entries.iter().filter(|(_, k)| !k.h.is_empty()).map(|(key, _)| file_of(key).to_string()).filter(|file| settings(file).is_some()).collect();
4955        let mut waiting: BTreeSet<String> = BTreeSet::new();
4956        for file in held {
4957            let nothing = settings(&file).is_some_and(|s| std::fs::metadata(&s.path).map_or(true, |m| m.len() == 0));
4958            if !nothing || found.hashes.keys().any(|k| file_of(k) == file) {
4959                memory.emptied.remove(&file);
4960            } else if now_ms - *memory.emptied.entry(file.clone()).or_insert(now_ms) < EMPTIED_WAIT {
4961                outcome.problems.push(format!("share-emptied:{file}"));
4962                waiting.insert(file);
4963            }
4964        }
4965        let gone: Vec<String> = memory
4966            .entries
4967            .iter()
4968            .filter(|(key, known)| !known.h.is_empty() && !found.hashes.contains_key(*key) && !found.is_unknown(key) && !held_gone.contains(file_of(key)))
4969            .filter(|(key, _)| locate(stores, file_of(key)).is_some_and(|(store, _)| reads(store)) && !waiting.contains(file_of(key)))
4970            .map(|(key, _)| key.clone())
4971            .collect();
4972        for key in gone {
4973            if holds(&key) {
4974                if memory.held.get(&key).is_none_or(|(_, held)| !held.is_empty()) {
4975                    let at = memory.tick(now_ms);
4976                    memory.held.insert(key.clone(), (at, String::new()));
4977                }
4978                continue;
4979            }
4980            let base = base_of(&memory, &key);
4981            let clock = match memory.held.remove(&key) {
4982                Some((at, held)) if held.is_empty() => at,
4983                _ => memory.tick(now_ms),
4984            };
4985            memory.entries.insert(key.clone(), Known { c: clock, w: sharing.computer.to_string(), h: String::new(), b: base.clone() });
4986            met_draft(&mut memory, &key, [&base, ""]);
4987            out.push((key, None, clock, base));
4988        }
4989    }
4990
4991    // The others' records, from where each was left; those not written last
4992    // time first. A change for a part this device does not share is not kept
4993    // waiting: switched on, the part joins and reads it again.
4994    let newer = |c: u64, w: &str, than: Option<(u64, &str)>| than.is_none_or(|(tc, tw)| (c, w) > (tc, tw));
4995    // Nor one for a store that left (`RETIRED`), which an older device may still send.
4996    let off = |key: &str| retired(key) || (locate(stores, file_of(key)).is_none() && known_part(file_of(key)).is_some());
4997    let mut winners: BTreeMap<String, (Option<String>, u64, String)> = std::mem::take(&mut memory.pending)
4998        .into_iter()
4999        .filter(|(key, (_, c, w))| !off(key) && newer(*c, w, memory.entries.get(key).map(|k| (k.c, k.w.as_str()))))
5000        .collect();
5001    // Records of an older format (format 1: an older Sioul's, or this device's
5002    // own before it wrote format 2), for files format 2 divides otherwise: by
5003    // their entries, the later of each, translated once all are read (`translate`).
5004    let mut older_format: BTreeMap<String, (Option<String>, u64, String)> = BTreeMap::new();
5005    let divided_otherwise = |key: &str| locate(originals, file_of(key)).is_some_and(|(store, _)| matches!(store.shape, Shape::Toml(rules) if rules.next.is_some()));
5006    let later = |older_format: &mut BTreeMap<String, (Option<String>, u64, String)>, key: String, value: Option<String>, c: u64, w: &str| {
5007        if older_format.get(&key).is_none_or(|(_, tc, tw)| (c, w) > (*tc, tw.as_str())) {
5008            older_format.insert(key, (value, c, w.to_string()));
5009        }
5010    };
5011    // Changes that waited for this device to write format 2, or for this
5012    // Sioul to know their part's format: in, once it can.
5013    let ready: Vec<String> = memory.waiting.iter().filter(|(key, (_, _, _, f))| memory.format >= 2 && known_part(file_of(key)).is_some_and(|part| *f <= part_format(part))).map(|(key, _)| key.clone()).collect();
5014    for key in ready {
5015        let Some((value, c, w, f)) = memory.waiting.remove(&key) else { continue };
5016        if off(&key) {
5017            continue;
5018        }
5019        if f < 2 && divided_otherwise(&key) {
5020            later(&mut older_format, key, value, c, &w);
5021        } else if !below_whole_floor(&memory, &key, c) && newer(c, &w, memory.entries.get(&key).map(|k| (k.c, k.w.as_str()))) && newer(c, &w, winners.get(&key).map(|(_, c, w)| (*c, w.as_str()))) {
5022            winners.insert(key, (value, c, w));
5023        }
5024    }
5025    // The build each computer's lines were written by, as its batches say: for `share-newer`.
5026    let mut builds: BTreeMap<String, String> = BTreeMap::new();
5027    // For files sealed apart: the content each change replaces where it was
5028    // made (its base), and every content the others' records name for each
5029    // file, their versions and what those replaced: a file here among them is
5030    // an older version, not a change of its own (`receive`).
5031    let mut bases: BTreeMap<String, String> = std::mem::take(&mut memory.sealed.bases).into_iter().filter(|(key, _)| winners.contains_key(key)).collect();
5032    let mut named: BTreeMap<String, BTreeSet<String>> = std::mem::take(&mut memory.sealed.named).into_iter().filter(|(key, _)| winners.contains_key(key)).collect();
5033    for (key, (value, _, _)) in winners.iter().filter(|(key, _)| key.starts_with(FILES)) {
5034        named.entry(key.clone()).or_default().extend(value.as_deref().map(|v| value_hash(key, v)).into_iter().chain(bases.get(key).cloned()).filter(|h| !h.is_empty()));
5035    }
5036    let all = rounds_with(sharing.folder);
5037    // Rebuilding a file: this computer's own records are read again too, for its entries only.
5038    let rebuilding = std::mem::take(&mut memory.rebuild);
5039    let rebuilt = |file: &str| rebuilding.contains("*") || rebuilding.contains(file) || rebuilding.iter().any(|r| r.ends_with('/') && file.starts_with(r.as_str()));
5040    for (computer, their_rounds) in all.iter().filter(|(c, _)| *c != sharing.computer || !rebuilding.is_empty()) {
5041        let own = computer == sharing.computer;
5042        let (mut start, mut skip) = if own { (0, 0) } else { memory.read.get(computer).copied().unwrap_or((0, 0)) };
5043        if !their_rounds.contains(&start) {
5044            // Not begun, or that round was removed: the oldest kept starts with all that counts.
5045            (start, skip) = (their_rounds[0], 0);
5046        }
5047        // A round waiting for the end of the one before is passed over for a
5048        // later full round, which restates all its computer holds (`Turn`).
5049        let mut from_round = start;
5050        'rounds: for &round in their_rounds.iter().filter(|r| **r >= start) {
5051            if round < from_round {
5052                continue;
5053            }
5054            let mut offset = if round == start { skip } else { 0 };
5055            let opened = if own { std::fs::File::open(round_file(sharing.folder, computer, round)) } else { open_round(sharing.folder, computer, round) };
5056            let Ok(mut file) = opened else {
5057                if !own {
5058                    memory.read.insert(computer.clone(), (round, offset));
5059                }
5060                continue;
5061            };
5062            // Shorter than what was read of it: an older copy put back by a sync
5063            // app. What it held past that point is lost here, said; it is read
5064            // again from its start (lines already known change nothing).
5065            if !own && file.metadata().is_ok_and(|m| m.len() < offset) {
5066                outcome.problems.push(format!("share-other-cut:{computer}"));
5067                say_broken(&mut memory, computer, now_ms / 1000);
5068                offset = 0;
5069            }
5070            {
5071                use std::io::Seek;
5072                let _ = file.seek(std::io::SeekFrom::Start(offset));
5073            }
5074            // Only whole lines: one still arriving is read next time. Each line on
5075            // its own: one that is not text (a disk's damage), or longer than any
5076            // record, is one broken line, not the end of everything after it.
5077            let record_of = |line: &[u8]| std::str::from_utf8(line).ok().and_then(|text| serde_json::from_str::<Line>(text).ok());
5078            let opens = |record: &Line| {
5079                let plain = open(sharing.key, &bound(computer, round, record.n, record.c), &record.s)?;
5080                // The change and, on its round's first line, where the round stands
5081                // (`Turn`; an older Sioul says nothing): one reading.
5082                serde_json::from_slice::<Opened>(&plain).ok().map(Opened::parts)
5083            };
5084            // The last line of this round that opened in this reading: its number,
5085            // and its clock (none for where an earlier reading stopped).
5086            let mut good: Option<(u64, Option<u64>)> = memory.read_n.get(computer).filter(|(r, _)| *r == round && offset > 0).map(|(_, n)| (*n, None));
5087            let mut lines = LineReader::new(file);
5088            let mut next = lines.next();
5089            while let Some((line, length)) = next.take() {
5090                next = lines.next();
5091                let Some(record) = record_of(&line) else {
5092                    // Never passed over in silence: it may have been a dose marked taken.
5093                    offset += length;
5094                    if !own {
5095                        if let Some(at) = lost_line(memory.read_n.get(computer).copied(), round, good, next.as_ref().map(|(n, _)| n.as_slice()), now_ms) {
5096                            outcome.problems.push(format!("share-other-line:{computer}"));
5097                            say_broken(&mut memory, computer, at);
5098                        }
5099                        lost_here(&mut memory, computer, round, None);
5100                    }
5101                    continue;
5102                };
5103                let Some((change, turn)) = opens(&record) else {
5104                    outcome.problems.push(format!("share-other-seal:{computer}"));
5105                    // One line that does not open while the next does: a damaged line,
5106                    // lost and said. None opening: another key, or a seal not here
5107                    // yet (the sync app is slow): read again from there next time.
5108                    if !own && next.as_ref().and_then(|(next, _)| record_of(next)).is_some_and(|next| opens(&next).is_some()) {
5109                        offset += length;
5110                        if let Some(at) = lost_line(memory.read_n.get(computer).copied(), round, good, next.as_ref().map(|(n, _)| n.as_slice()), now_ms) {
5111                            say_broken(&mut memory, computer, at);
5112                        }
5113                        lost_here(&mut memory, computer, round, Some(record.n));
5114                        continue;
5115                    }
5116                    if !own {
5117                        // A later full round that opens (that device set up again
5118                        // with this folder's passphrase, its old lines under
5119                        // another): read on from there, what it holds restated.
5120                        if let Some(full) = later_full(sharing, computer, their_rounds, round) {
5121                            from_round = full;
5122                            continue 'rounds;
5123                        }
5124                        memory.read.insert(computer.clone(), (round, offset));
5125                    }
5126                    break 'rounds;
5127                };
5128                // The turn of a round (`Turn`): one continuing the round before is
5129                // entered only once that round was met here to its last line, read
5130                // or found damaged (`Memory::met`; a line lost on the way stays said
5131                // for the doses through `read_n` and `broken`). Its end, or the
5132                // whole round before, still on its way (each file travels on its
5133                // own, in any order), this round waits for it, unless a later full
5134                // round restates it all. Rounds go from the folder oldest first
5135                // (`remove_old_rounds`): the round before missing while an earlier
5136                // one is kept has not come yet; missing with every earlier one, it
5137                // was taken out, and what it held past here is lost: a gap, said below.
5138                // Waited for an hour of exchanges (`AWAITED`; a round truly lost: an older copy
5139                // kept for good, a file taken out of the server), it is passed over
5140                // as lost: the gap said, what that computer wrote not all known
5141                // here (`read_n` stays) until its next full round, which this device
5142                // asks for (`Seen::full`), and the rest of its records flow
5143                // meanwhile (review of 10 October 2026).
5144                if !own
5145                    && record.n == 1
5146                    && turn.p > 0
5147                    && let Some(before) = round.checked_sub(1)
5148                    && memory.met.get(computer).or_else(|| memory.read_n.get(computer)).is_some_and(|met| *met < (before, turn.p))
5149                {
5150                    if let Some(full) = later_full(sharing, computer, their_rounds, round) {
5151                        memory.waiting_for.remove(computer);
5152                        from_round = full;
5153                        continue 'rounds;
5154                    }
5155                    if their_rounds.iter().any(|r| *r <= before) {
5156                        let waiting = memory.waiting_for.entry(computer.clone()).or_default();
5157                        if waiting.round == round {
5158                            waiting.waited += (now_ms - waiting.last).clamp(0, AWAIT_STEP);
5159                        } else {
5160                            *waiting = Awaited { round, waited: 0, last: now_ms };
5161                        }
5162                        waiting.last = now_ms;
5163                        if waiting.waited < AWAITED {
5164                            break 'rounds;
5165                        }
5166                    }
5167                }
5168                if !own && record.n == 1 {
5169                    memory.waiting_for.remove(computer);
5170                }
5171                offset += length;
5172                good = Some((record.n, Some(record.c)));
5173                if !change.build.is_empty() {
5174                    builds.insert(computer.clone(), change.build.clone());
5175                }
5176                // Sealed files another device's record named lately are never
5177                // taken out from under it (`blobs::sweep`), `UNUSED_DAYS` from
5178                // when this device first read that record. Never from its own
5179                // records, nor from a record read before (a store rebuilt, or
5180                // a memory from before rounds said where they stand, reads
5181                // every kept round again): until 15 October 2026 each rebuild
5182                // restarted that time for every superseded content this
5183                // computer's kept rounds named, and those never aged while new
5184                // versions kept coming (`named_at` itself says the others' records).
5185                let first = memory.met.get(computer).or_else(|| memory.read_n.get(computer)).is_none_or(|met| (round, record.n) > *met);
5186                if !own
5187                    && first
5188                    && change.k.starts_with(FILES)
5189                    && let Some(value) = change.v.as_deref()
5190                {
5191                    memory.sealed.named_at.insert(crate::blobs::name(sharing.key, &value_hash(&change.k, value)), now_ms);
5192                }
5193                if own {
5194                    // Only the entries of the files rebuilt; the rest is this computer's as it is.
5195                    if rebuilt(file_of(&change.k)) && !off(&change.k) && memory.format < 2 && change.f >= 2 {
5196                        // Its own format 2, met while its memory says format 1 (a
5197                        // memory put back from before its switch): never written
5198                        // with format 1's rules; it takes format 2 again from its
5199                        // next exchange, and the record waits until then.
5200                        memory.switch = true;
5201                        wait(&mut memory.waiting, change.k, (change.v, record.c, computer.clone(), change.f));
5202                    } else if rebuilt(file_of(&change.k)) && !off(&change.k) && memory.format >= 2 && change.f < 2 && divided_otherwise(&change.k) {
5203                        later(&mut older_format, change.k, change.v, record.c, computer);
5204                    } else if rebuilt(file_of(&change.k)) && !off(&change.k) && !below_whole_floor(&memory, &change.k, record.c) && newer(record.c, computer, winners.get(&change.k).map(|(_, c, w)| (*c, w.as_str()))) && memory.entries.get(&change.k).is_none_or(|k| (record.c, computer.as_str()) > (k.c, k.w.as_str())) {
5205                        bases.insert(change.k.clone(), change.b);
5206                        win(&mut winners, &mut theirs_now, sharing.computer, change.k, (change.v, record.c, computer.clone()));
5207                    }
5208                    continue;
5209                }
5210                // The lines follow each other: after a gap (lines lost to a stale copy of
5211                // the file, or to a damaged one) what that computer marked is not known
5212                // here, said as a doubt, until a full round restates all it holds. A
5213                // round's first line follows when it opens a full round, or continues
5214                // the round before as read here to its last line (`Turn`); an older
5215                // Sioul's says neither, and follows as before. A device read here for
5216                // the first time, from a round continuing one gone, knows where it
5217                // stands at its computer's next full round, with nothing said.
5218                if memory.met.get(computer).is_none_or(|met| (round, record.n) > *met) {
5219                    memory.met.insert(computer.clone(), (round, record.n));
5220                }
5221                let last = memory.read_n.get(computer).copied();
5222                if last.is_none_or(|last| (round, record.n) > last) {
5223                    let follows = match last {
5224                        Some((r, n)) if r == round => record.n == n + 1,
5225                        _ if record.n != 1 => false,
5226                        _ if turn.full => true,
5227                        _ if turn.p > 0 => last.is_some() && last == round.checked_sub(1).map(|before| (before, turn.p)),
5228                        _ => true,
5229                    };
5230                    if follows {
5231                        memory.read_n.insert(computer.clone(), (round, record.n));
5232                    } else if last.is_some() {
5233                        if !outcome.problems.iter().any(|p| *p == format!("share-other-gap:{computer}")) {
5234                            outcome.problems.push(format!("share-other-gap:{computer}"));
5235                        }
5236                        // What was lost was written before this line: about then.
5237                        say_broken(&mut memory, computer, ((record.c >> 16) as i64 / 1000).min(now_ms / 1000));
5238                    }
5239                }
5240                memory.clock = memory.clock.max(record.c);
5241                // Heard of now, by a sealed record (`present`).
5242                let heard_at = (record.c >> 16) as i64;
5243                if memory.heard_at.get(computer).is_none_or(|at| *at < heard_at) {
5244                    memory.heard_at.insert(computer.clone(), heard_at);
5245                }
5246                if off(&change.k) {
5247                    continue;
5248                }
5249                // Its part in a format this Sioul does not know (a newer one's):
5250                // kept waiting, never written here, said once; nothing of that
5251                // part goes out from here until this Sioul is updated.
5252                let format = change.f.max(1);
5253                if let Some(part) = known_part(file_of(&change.k))
5254                    && format > part_format(part)
5255                {
5256                    // Of writers that no longer count, released: passed over.
5257                    if memory.released.get(part).is_some_and(|c| record.c <= *c) {
5258                        continue;
5259                    }
5260                    if !memory.newer.contains_key(part) {
5261                        outcome.problems.push(format!("share-newer:{part}"));
5262                    }
5263                    let newer = memory.newer.entry(part.to_string()).or_default();
5264                    newer.format = newer.format.max(format);
5265                    if let Some(build) = builds.get(computer) {
5266                        newer.build = build.clone();
5267                    }
5268                    newer.writers.insert(computer.clone());
5269                    wait(&mut memory.waiting, change.k, (change.v, record.c, computer.clone(), format));
5270                    continue;
5271                }
5272                // A whole list of format 1: what it holds, at its clock (`WholeList`),
5273                // whatever this device writes, before its record is applied or translated.
5274                if format < 2 {
5275                    note_whole_record(originals, &mut memory, &change.k, change.v.as_deref(), record.c);
5276                }
5277                // Format 2 while this device writes format 1: another device
5278                // writes it already; this one does from its next exchange, and
5279                // the change waits for it.
5280                if format >= 2 && memory.format < 2 {
5281                    memory.switch = true;
5282                    wait(&mut memory.waiting, change.k, (change.v, record.c, computer.clone(), format));
5283                    continue;
5284                }
5285                // Format 1 for a file format 2 divides otherwise: translated once all are read.
5286                if format < 2 && memory.format >= 2 && divided_otherwise(&change.k) {
5287                    later(&mut older_format, change.k, change.v, record.c, computer);
5288                    continue;
5289                }
5290                if change.k.starts_with(FILES) {
5291                    named.entry(change.k.clone()).or_default().extend(change.v.as_deref().map(|v| value_hash(&change.k, v)).into_iter().chain([change.b.clone()]).filter(|h| !h.is_empty()));
5292                    // Replaced by a new version, not taken out: a take-out is kept as a copy (`history::keep_moved`).
5293                    if change.v.is_some() && !change.b.is_empty() {
5294                        replaced_now.push(change.b.clone());
5295                    }
5296                }
5297                // A draft taken out while another device changed it: it was sent (`DRAFTS`).
5298                let sent = change.k.starts_with(DRAFTS).then(|| {
5299                    let here = match winners.get(&change.k) {
5300                        Some((value, _, _)) => Some((value.is_none(), value.as_deref().map(|v| value_hash(&change.k, v)).unwrap_or_default(), bases.get(&change.k).cloned().unwrap_or_default())),
5301                        None => memory.entries.get(&change.k).map(|k| (k.h.is_empty(), k.h.clone(), k.b.clone())),
5302                    };
5303                    let decided = sent_meanwhile(memory.drafts.get(&change.k), here.as_ref().map(|(gone, h, b)| (*gone, h.as_str(), b.as_str())), change.v.is_none(), &change.b);
5304                    let version = change.v.as_deref().map(|v| value_hash(&change.k, v)).unwrap_or_default();
5305                    met_draft(&mut memory, &change.k, [&change.b, &version]);
5306                    decided
5307                });
5308                let known = memory.entries.get(&change.k).map(|k| (k.c, k.w.as_str()));
5309                let pending = winners.get(&change.k).map(|(_, c, w)| (*c, w.as_str()));
5310                // An element a later whole list of format 1 took out, restated at its old clock: passed over.
5311                let wins = match sent.flatten() {
5312                    Some(wins) => wins,
5313                    None => newer(record.c, computer, known) && newer(record.c, computer, pending) && !below_whole_floor(&memory, &change.k, record.c),
5314                };
5315                if wins {
5316                    // A file sealed apart taken out by another device: what it held, and where that record is.
5317                    if change.v.is_none() && change.k.starts_with(FILES) {
5318                        let before = match winners.get(&change.k) {
5319                            Some((value, _, _)) => value.as_deref().map(|v| value_hash(&change.k, v)).unwrap_or_default(),
5320                            None => memory.entries.get(&change.k).map(|k| k.h.clone()).unwrap_or_default(),
5321                        };
5322                        if !before.is_empty() {
5323                            removed_now.push((before, computer.clone(), round));
5324                        }
5325                    }
5326                    bases.insert(change.k.clone(), change.b);
5327                    win(&mut winners, &mut theirs_now, sharing.computer, change.k, (change.v, record.c, computer.clone()));
5328                }
5329            }
5330            if !own {
5331                memory.read.insert(computer.clone(), (round, offset));
5332            }
5333        }
5334    }
5335
5336    // An older format's changes, in format 2's names (`translate`), then as any other.
5337    let mut again: Vec<(String, String)> = Vec::new();
5338    if !older_format.is_empty() {
5339        // The writers whose sealed entry says they read format 2 (`translate`).
5340        let modern: BTreeSet<String> = memory.seen.values().filter(|e| e.format >= 2).map(|e| e.id.clone()).collect();
5341        let (translated, named_here) = translate(originals, &older_format, &mut memory, &winners, &modern);
5342        again = named_here;
5343        for (key, value, c, w) in translated {
5344            if newer(c, &w, memory.entries.get(&key).map(|k| (k.c, k.w.as_str()))) && newer(c, &w, winners.get(&key).map(|(_, c, w)| (*c, w.as_str()))) {
5345                bases.remove(&key);
5346                winners.insert(key, (value, c, w));
5347            }
5348        }
5349    }
5350
5351    // The others' changes, written into the files: notes and papers taken out
5352    // first, so that a file renamed by its case comes in the same exchange.
5353    let mut by_file: BTreeMap<String, Vec<(String, Option<String>)>> = BTreeMap::new();
5354    for (key, (value, _, _)) in &winners {
5355        by_file.entry(file_of(key).to_string()).or_default().push((key.clone(), value.clone()));
5356    }
5357    let mut order: Vec<&String> = by_file.keys().collect();
5358    order.sort_by_key(|file| !(file.starts_with(FILES) && by_file[*file].first().is_some_and(|(_, value)| value.is_none())));
5359    let mut written_keys: BTreeSet<String> = BTreeSet::new();
5360    // What was written, by key: what the next look compares with, whatever happens to the file after.
5361    let mut written_hashes: BTreeMap<String, String> = BTreeMap::new();
5362    // Files written: as they are now (a note, a paper: read next time only if it changes), or to read again.
5363    let mut written_files: BTreeMap<String, Option<Stat>> = BTreeMap::new();
5364    // Files sealed apart already as the others' change says: known, nothing written.
5365    let mut settled: BTreeSet<String> = BTreeSet::new();
5366    let history = crate::history::root(sharing.memory);
5367    let mut kept = false;
5368    let mut names: Option<Names> = None;
5369    let receiving = Receiving { sharing, history: &history, now_ms, clock, copied: std::cell::Cell::new(0) };
5370    // Sealed files the others' changes name: not here yet (their content's hash and size), or here.
5371    let mut wanted: BTreeMap<String, (String, u64)> = BTreeMap::new();
5372    let mut there: BTreeSet<String> = BTreeSet::new();
5373    for file in order {
5374        let changes = &by_file[file];
5375        let Some((store, path)) = locate(stores, file) else {
5376            if file.starts_with(FILES) {
5377                *outcome.waits.entry("no-store".into()).or_default() += 1;
5378            }
5379            continue;
5380        };
5381        if matches!(store.shape, Shape::Files) {
5382            // Not read this time, too big here, in a folder that cannot be read
5383            // or emptied of a sudden, or a hurried exchange waits: left waiting.
5384            let Some((key, value)) = changes.first() else { continue };
5385            let why = if !reads(store) { "not-read" } else if hurried() { "hurried" } else if found.is_unknown(file) { "unknown" } else { "" };
5386            if !why.is_empty() {
5387                *outcome.waits.entry(why.into()).or_default() += 1;
5388                // Sioul keeping the folder itself, an exchange that leaves the
5389                // notes for later (a phone's background step, an alarm's): their
5390                // contents asked for all the same, nothing written, so that the
5391                // window writes them as soon as it opens, offline too (review of
5392                // 18 October 2026, L-2).
5393                if keeps_itself(sharing)
5394                    && let Some(reference) = value.as_deref().and_then(|v| serde_json::from_str::<Reference>(v).ok())
5395                {
5396                    let blob = crate::blobs::name(sharing.key, &reference.h);
5397                    if std::fs::metadata(sharing.folder.join("blobs").join(&blob)).is_ok_and(|m| m.len() > 0) {
5398                        there.insert(blob);
5399                    } else {
5400                        wanted.insert(blob, (reference.h.clone(), reference.s));
5401                    }
5402                }
5403                continue;
5404            }
5405            // Take-outs copied into the history: so many an exchange, the rest at the next (`TAKE_OUT_COPIES`).
5406            if value.is_none() && receiving.copied.get() >= take_out_copies() {
5407                *outcome.waits.entry("copies".into()).or_default() += 1;
5408                continue;
5409            }
5410            let reference = value.as_deref().and_then(|v| serde_json::from_str::<Reference>(v).ok());
5411            let blob = reference.as_ref().map(|r| crate::blobs::name(sharing.key, &r.h)).unwrap_or_default();
5412            // Could not be written lately (a full disk, a name a folder takes here): tried again after a while, not at every exchange.
5413            if memory.sealed.failing.get(key).is_some_and(|(failed, again, _)| *failed == blob && now_ms < *again) {
5414                *outcome.waits.entry("backoff".into()).or_default() += 1;
5415                continue;
5416            }
5417            if !writable(store, &path, sharing) {
5418                outcome.problems.push(format!("share-refused:{file}"));
5419                continue;
5420            }
5421            let names = names.get_or_insert_with(|| Names::new(&memory.entries, &found));
5422            if value.is_some() && names.clash(file) {
5423                outcome.problems.push(format!("share-name-clash:{file}"));
5424                continue;
5425            }
5426            let base = bases.get(key).map_or("", String::as_str);
5427            let seen = |h: &str| h == base || named.get(key).is_some_and(|n| n.contains(h));
5428            let back_off = |failing: &mut BTreeMap<String, (String, i64, u32)>| {
5429                let tried = failing.get(key).map_or(0, |(_, _, n)| *n);
5430                let wait = [MINUTE, 10 * MINUTE, HOUR, 6 * HOUR][(tried as usize).min(3)];
5431                failing.insert(key.clone(), (blob.clone(), now_ms + wait, tried + 1));
5432            };
5433            let came = receive(&receiving, store, file, &path, value.as_deref(), found.hashes.get(key).map(String::as_str), &seen, memory.entries.get(key), found.files.get(file), &mut memory.sealed.damaged);
5434            // Its sealed file neither in the folder nor fetched beside it: waited for (`note_wanted`); else no longer.
5435            match (&came, &reference) {
5436                (Err(crate::blobs::Fault::Missing), Some(reference)) => {
5437                    wanted.insert(blob.clone(), (reference.h.clone(), reference.s));
5438                }
5439                (_, Some(_)) => {
5440                    there.insert(blob.clone());
5441                }
5442                _ => {}
5443            }
5444            match came {
5445                Ok(Received::AlreadySo) => {
5446                    settled.insert(key.clone());
5447                    memory.sealed.missing.remove(key);
5448                    memory.sealed.failing.remove(key);
5449                }
5450                Ok(Received::Later) => *outcome.waits.entry("later".into()).or_default() += 1,
5451                Ok(Received::Written { stat, copy, kept: into_history }) => {
5452                    kept |= into_history;
5453                    outcome.written.insert(store.name.clone());
5454                    outcome.received += 1;
5455                    written_keys.insert(key.clone());
5456                    written_hashes.insert(key.clone(), stat.as_ref().and_then(|s| s.entries.first()).map(|(_, h)| h.clone()).unwrap_or_default());
5457                    names.written(file, stat.is_some());
5458                    written_files.insert(file.clone(), stat);
5459                    memory.sealed.missing.remove(key);
5460                    memory.sealed.failing.remove(key);
5461                    if let Some(copy) = copy {
5462                        outcome.problems.push(format!("{}:{copy}", if value.is_some() { "share-conflict" } else { "share-conflict-gone" }));
5463                    }
5464                }
5465                // Not here yet: it comes with the sync app, or fetched from the
5466                // folder's server (`remote::fetch_wanted`), and is written then; said after a day.
5467                Err(crate::blobs::Fault::Missing) => {
5468                    *outcome.waits.entry("no-blob".into()).or_default() += 1;
5469                    if now_ms - *memory.sealed.missing.entry(key.clone()).or_insert(now_ms) > DAY {
5470                        outcome.problems.push(format!("share-missing:{file}"));
5471                    }
5472                }
5473                Err(crate::blobs::Fault::Broken) => outcome.problems.push(format!("share-damaged:{file}")),
5474                Err(crate::blobs::Fault::Room) => {
5475                    outcome.problems.push(format!("share-no-room:{file}"));
5476                    back_off(&mut memory.sealed.failing);
5477                }
5478                Err(crate::blobs::Fault::Local(e)) => {
5479                    outcome.problems.push(e);
5480                    back_off(&mut memory.sealed.failing);
5481                }
5482            }
5483            continue;
5484        }
5485        if found.unknown.contains(file) && !matches!(store.shape, Shape::Whole) && path.exists() {
5486            // Half written, or written by hand and broken: left as it is.
5487            outcome.problems.push(format!("share-unreadable:{}", path.display()));
5488            continue;
5489        }
5490        if store.folder && !writable(store, &path, sharing) {
5491            outcome.problems.push(format!("share-refused:{file}"));
5492            continue;
5493        }
5494        let entries: Vec<(&str, Option<&str>)> = changes.iter().map(|(key, value)| (entry_of(key), value.as_deref())).collect();
5495        // The file as it was, kept to be put back; never a log of calls, which
5496        // has nothing to put back and leaves every device after its month.
5497        let keep = || {
5498            if calls_store(file) {
5499                return Ok(());
5500            }
5501            kept = true;
5502            crate::history::keep(&history, store.part, file, &path, now_ms)
5503        };
5504        match write_entries(store, &path, &entries, keep) {
5505            Ok(hashes) => {
5506                outcome.written.insert(store.name.clone());
5507                outcome.received += changes.len();
5508                for (key, _) in changes {
5509                    written_keys.insert(key.clone());
5510                    written_hashes.insert(key.clone(), hashes.get(entry_of(key)).cloned().unwrap_or_default());
5511                }
5512                written_files.insert(file.clone(), None);
5513            }
5514            Err(e) => outcome.problems.push(e),
5515        }
5516    }
5517    for (key, (value, clock, computer)) in winners {
5518        if written_keys.contains(&key) || settled.contains(&key) {
5519            let h = written_hashes.get(&key).cloned().unwrap_or_else(|| value.as_deref().map(|v| value_hash(&key, v)).unwrap_or_default());
5520            memory.entries.insert(key, Known { c: clock, w: computer, h, b: String::new() });
5521        } else {
5522            if let Some(base) = bases.remove(&key).filter(|b| !b.is_empty()) {
5523                memory.sealed.bases.insert(key.clone(), base);
5524            }
5525            if let Some(contents) = named.remove(&key) {
5526                memory.sealed.named.insert(key.clone(), contents);
5527            }
5528            memory.pending.insert(key, (value, clock, computer));
5529        }
5530    }
5531    // A session whose id was kept here as format 1 changed it (`translate`):
5532    // sent again from here, under that id, and the name a device holding none
5533    // of it would give taken out, so that every device, one joining later
5534    // too, holds it once.
5535    for (name, fallback) in again.into_iter().filter(|(name, _)| written_keys.contains(name)) {
5536        let value = value_of(stores, &name);
5537        let clock = memory.tick(now_ms);
5538        memory.entries.insert(name.clone(), Known { c: clock, w: sharing.computer.to_string(), h: value.as_deref().map(hash).unwrap_or_default(), b: String::new() });
5539        out.push((name.clone(), value, clock, String::new()));
5540        if fallback != name {
5541            let clock = memory.tick(now_ms);
5542            memory.entries.insert(fallback.clone(), Known { c: clock, w: sharing.computer.to_string(), h: String::new(), b: String::new() });
5543            out.push((fallback, None, clock, String::new()));
5544        }
5545    }
5546    // What the next look compares with: the files as found, those written
5547    // here as they are now (notes and papers), or to read again (the rest:
5548    // small). What was written is known by what was written (`written_hashes`):
5549    // a change made here after it is a change made here, sent.
5550    let mut files = found.files.clone();
5551    for (file, stat) in written_files {
5552        match stat {
5553            Some(stat) => files.insert(file, stat),
5554            None => files.remove(&file),
5555        };
5556    }
5557
5558    // Joining, the whole sharing, a store or a file rebuilt: what only this computer holds goes out.
5559    if !memory.joined || !rebuilt_here.is_empty() {
5560        for (key, h) in &found.hashes {
5561            if memory.entries.contains_key(key) || memory.pending.contains_key(key) || (memory.joined && !joins(key)) || (key.starts_with(FILES) && hurried()) || holds(key) {
5562                continue;
5563            }
5564            let Some(value) = value(key) else { continue };
5565            if !seal_apart(sharing, stores, key, &value, &mut memory.sealed, &mut outcome.problems, now_ms) {
5566                continue;
5567            }
5568            let clock = memory.tick(found.changed.get(file_of(key)).copied().unwrap_or(now_ms).min(now_ms));
5569            memory.entries.insert(key.clone(), Known { c: clock, w: sharing.computer.to_string(), h: h.clone(), b: String::new() });
5570            out.push((key.clone(), Some(value), clock, String::new()));
5571        }
5572        memory.joined = true;
5573    }
5574
5575    // Older copies put back by hand: the version last seen here comes back,
5576    // the copy kept beside, said; one that cannot come back now (not here
5577    // yet) is held, its copy not sent.
5578    for key in older.into_iter().filter(|key| !written_keys.contains(key)) {
5579        let file = file_of(&key).to_string();
5580        let (Some((store, path)), Some(known), Some(before)) = (locate(stores, &file), memory.entries.get(&key), memory.files.get(&file).cloned()) else { continue };
5581        let back = serde_json::to_string(&Reference { h: known.h.clone(), s: before.size, t: (before.modified / 1_000_000) as i64 }).unwrap_or_default();
5582        let came = if hurried() || !writable(store, &path, sharing) { Ok(Received::Later) } else { receive(&receiving, store, &file, &path, Some(&back), found.hashes.get(&key).map(String::as_str), &|_| false, Some(known), found.files.get(&file), &mut memory.sealed.damaged) };
5583        match came {
5584            Ok(Received::Written { stat, copy, kept: into_history }) => {
5585                kept |= into_history;
5586                outcome.problems.push(format!("share-older-copy:{}", copy.unwrap_or_else(|| shown(&file).to_string())));
5587                if let Some(stat) = stat {
5588                    files.insert(file, stat);
5589                }
5590            }
5591            _ => {
5592                outcome.problems.push(format!("share-older-copy:{}", shown(&file)));
5593                files.insert(file, before);
5594            }
5595        }
5596    }
5597
5598    outcome.sent = out.len();
5599    outcome.pending = memory.pending.len();
5600    if memory.round == 0 {
5601        memory.round = 1;
5602    }
5603    // This computer's file cut short, or gone: a sync app put back an older copy
5604    // (eDrive keeps a file's old content when only its version changed, and may
5605    // send it back). What it said past that point is lost for whoever had not
5606    // read it: a new file opens on every entry this computer has the last word on.
5607    let own = |memory: &Memory| std::fs::metadata(round_file(sharing.folder, sharing.computer, memory.round)).map_or(0, |m| m.len());
5608    if memory.own_size > own(&memory) || restate {
5609        if !restate {
5610            outcome.problems.push("share-own-cut".into());
5611        }
5612        new_round(sharing, &mut memory, stores, &found, &hurried, now_ms, true)?;
5613        memory.round_base = own(&memory);
5614    }
5615    // Past `SEGMENT`, what comes goes into a new round continuing this one,
5616    // nothing restated: the file sent at each change stays small.
5617    if !out.is_empty() && own(&memory) >= SEGMENT {
5618        if memory.full_round == 0 {
5619            memory.full_round = memory.round;
5620        }
5621        // Said on its first line: a reader enters it once it read this round to its last line (`Turn`).
5622        memory.continues = memory.seq;
5623        memory.round += 1;
5624        memory.seq = 0;
5625        memory.round_base = 0;
5626    }
5627    let before = own(&memory);
5628    append(sharing, &mut memory, &out)?;
5629    // This device's own take-outs of files sealed apart, and the round they went into (`removed_due`);
5630    // the contents its own changes replaced (`Sealed::replaced`).
5631    for (key, value, _, base) in &out {
5632        if value.is_none() && key.starts_with(FILES) && !base.is_empty() {
5633            removed_now.push((base.clone(), sharing.computer.to_string(), memory.round));
5634        }
5635        if value.is_some() && key.starts_with(FILES) && !base.is_empty() {
5636            replaced_now.push(base.clone());
5637        }
5638    }
5639    memory.grown += own(&memory).saturating_sub(before);
5640    // The first round holds all this computer had when it joined: a full one.
5641    if memory.full_round == 0 && memory.round == 1 {
5642        memory.full_round = 1;
5643        memory.grown = 0;
5644    }
5645    // When each part last sent and received a change, for the window.
5646    let part_of = |key: &str| locate(stores, file_of(key)).map(|(store, _)| store.part);
5647    for part in out.iter().filter_map(|(key, ..)| part_of(key)) {
5648        memory.traffic.entry(part.to_string()).or_default().0 = now_ms / 1000;
5649    }
5650    for part in written_keys.iter().filter_map(|key| part_of(key)) {
5651        memory.traffic.entry(part.to_string()).or_default().1 = now_ms / 1000;
5652    }
5653    // Now and then, the sealed files this computer's records point to looked for (`look_after_sealed`).
5654    if sharing.files && !hurried() && now_ms - memory.checked >= 10 * MINUTE {
5655        look_after_sealed(sharing, stores, &mut memory, &found, &hurried, &mut outcome.problems, now_ms);
5656        memory.checked = now_ms;
5657    }
5658    note_wanted(sharing, &memory, &wanted, &there, now_ms);
5659    // Damaged sealed files no change waits for any more are forgotten.
5660    if !memory.sealed.damaged.is_empty() {
5661        let waited: BTreeSet<String> = memory.pending.iter().filter(|(key, _)| key.starts_with(FILES)).filter_map(|(key, (value, _, _))| value.as_deref().map(|v| crate::blobs::name(sharing.key, &value_hash(key, v)))).collect();
5662        memory.sealed.damaged.retain(|name, _| waited.contains(name));
5663    }
5664    // Where each other device stands (`counted`): who holds rounds back
5665    // (`readers`), and whose past files are another device's to take out.
5666    let counts = counted(sharing.folder, sharing.key, sharing.computer, &memory, now_ms);
5667    let reading = readers(sharing.folder, &counts, now_ms);
5668    // The contents replaced lately, by name, for `history::SEALED_DAYS` and a day.
5669    for h in replaced_now {
5670        memory.sealed.replaced.insert(crate::blobs::name(sharing.key, &h), now_ms);
5671    }
5672    memory.sealed.replaced.retain(|_, at| now_ms - *at < (crate::history::SEALED_DAYS + 1) * DAY);
5673    // The files this computer sealed in the folder that no record points to
5674    // any more go, in time (`unused_days`); never one a record named lately.
5675    let days = if memory.sealed.named_at.is_empty() && memory.sealed.mine.is_empty() { crate::blobs::UNUSED_DAYS } else { unused_days(sharing, &memory, now_ms) };
5676    memory.sealed.named_at.retain(|_, at| now_ms - *at < days * DAY);
5677    if !memory.sealed.mine.is_empty() {
5678        let current: BTreeSet<String> = memory.entries.iter().filter(|(key, known)| key.starts_with(FILES) && !known.h.is_empty()).map(|(_, known)| known.h.clone()).collect();
5679        let used: BTreeSet<String> = current
5680            .iter()
5681            .cloned()
5682            .chain(memory.pending.iter().filter(|(key, _)| key.starts_with(FILES)).filter_map(|(key, (value, _, _))| value.as_deref().map(|v| value_hash(key, v))))
5683            .map(|h| crate::blobs::name(sharing.key, &h))
5684            .chain(memory.sealed.named_at.keys().cloned())
5685            .collect();
5686        prune_tables(sharing.folder, &mut memory.sealed, &used);
5687        // What this exchange found current and replaced, used in it (`current_before`).
5688        let used: BTreeSet<String> = current_before.difference(&current).map(|h| crate::blobs::name(sharing.key, h)).chain(used).collect();
5689        let mut last: BTreeMap<String, i64> = memory.sealed.mine.iter().map(|(name, (at, _))| (name.clone(), *at)).collect();
5690        // Those whose file was taken out, as soon as every device that counts
5691        // read so, once each keeps such a file as a copy (`removed_due`).
5692        let early = counts.iter().all(|(_, counted)| match counted {
5693            Counted::Yes(entry, _) => entry.as_ref().is_some_and(|e| e.keeping >= crate::blobs::KEEPING_MOVED),
5694            _ => true,
5695        });
5696        removed_due(sharing.folder, sharing.key, &mut memory.sealed, std::mem::take(&mut removed_now), &current, &reading, &mut last, days, early, now_ms);
5697        crate::blobs::sweep_after(sharing.folder, &mut last, &used, now_ms, days);
5698        memory.sealed.mine.retain(|name, (at, _)| last.get(name).map(|kept| *at = *kept).is_some());
5699        let mine = &memory.sealed.mine;
5700        memory.sealed.removed.retain(|name, _| mine.contains_key(name));
5701    } else {
5702        memory.sealed.removed.clear();
5703    }
5704    // The others' contents that stopped being current here; those of a
5705    // device that no longer counts swept in time (`sweep_theirs`).
5706    sweep_theirs(sharing, stores, &mut memory, &counts, theirs_now, &current_before, now_ms);
5707    // The history held within bounds, and what a crash left half written there cleaned: after a writing, or hourly.
5708    if kept || now_ms - memory.tidied >= HOUR {
5709        crate::history::prune(&history, now_ms, crate::history::cap(&history), &|_, file| locate(stores, file).is_some_and(|(_, path)| path.exists()));
5710        memory.tidied = now_ms;
5711    }
5712    // What a crash left half written among the folder's sealed files, and in
5713    // the folders whose files travel whole (F17), once a day
5714    // (`Memory::cleaned`): listing them reads thousands of names.
5715    // What a crash left of the memory written ahead (`Memory::prepare_save`) beside it, at each
5716    // exchange: a few names (check of a23cf0c, C-4).
5717    if let Some(dir) = sharing.memory.parent() {
5718        clean_leftovers(dir);
5719    }
5720    if now_ms - memory.cleaned >= DAY {
5721        clean_leftovers(&sharing.folder.join("blobs"));
5722        for store in stores.iter().filter(|s| s.folder && matches!(s.shape, Shape::Whole)) {
5723            clean_leftovers_below(&store.path, 3);
5724        }
5725        memory.cleaned = now_ms;
5726    }
5727    // A full round, once as much as the last one restated was appended since,
5728    // a megabyte at least (`full_round_after`): then the rounds before it can
5729    // go. Waits while the connection is metered or slow, and while a bulk
5730    // import goes on (`set_importing`).
5731    let waits = if importing(sharing.memory) { memory.grown <= IMPORT_ROUND_SIZE } else { frugal(sharing.memory) && memory.grown <= FRUGAL_ROUND_SIZE };
5732    // Another device asks for one (it lost some of this computer's records:
5733    // `Seen::full`): owed.
5734    if !memory.owed && asked_for_full(sharing, &memory, now_ms) {
5735        memory.owed = true;
5736    }
5737    // One owed (a round after lost lines that could not restate all, R7, or
5738    // asked for): as soon as all reads again, and not while the connection is
5739    // metered or slow, nor while a bulk import goes on, as a full round past
5740    // its growth waits (on a phone always on mobile data, a full round
5741    // restates every text it holds: review of 10 October 2026, third pass).
5742    // The others' doubt holds meanwhile. Metered, it waits `OWED_METERED` of
5743    // exchanges at most (counted as `AWAITED` is), then goes all the same: a
5744    // phone always on mobile data, which marks most doses, never leaves the
5745    // others in doubt for days. Sooner, the full round that growth opens:
5746    // once more than the last full round restated was appended
5747    // (`full_round_after`), past what those waits allow.
5748    let metered = frugal(sharing.memory) && !importing(sharing.memory);
5749    if memory.owed && metered {
5750        if memory.owed_last > 0 {
5751            memory.owed_waited += (now_ms - memory.owed_last).clamp(0, AWAIT_STEP);
5752        }
5753        memory.owed_last = now_ms;
5754    }
5755    let thrifty = importing(sharing.memory) || (metered && memory.owed_waited < OWED_METERED);
5756    let owed = memory.owed && !thrifty && restatement(sharing, &memory, stores, &found).1 == 0;
5757    if (memory.grown > full_round_after(sharing, &mut memory) && !waits) || owed {
5758        new_round(sharing, &mut memory, stores, &found, &hurried, now_ms, false)?;
5759        memory.round_base = own(&memory);
5760    }
5761    memory.own_size = own(&memory);
5762    memory.files = files;
5763    // Where each store was read; one not read this time where it was.
5764    let places: BTreeMap<String, String> = stores.iter().filter_map(|s| if reads(s) { Some((s.name.clone(), place(s))) } else { memory.places.get(&s.name).map(|p| (s.name.clone(), p.clone())) }).collect();
5765    memory.places = places;
5766    // What you said to take out went with this exchange; what waited and no longer does is forgotten.
5767    memory.sealed.confirmed.retain(|folder| !stores.iter().any(|s| folder.starts_with(s.name.as_str()) && reads(s)));
5768    memory.sealed.missing.retain(|key, _| memory.pending.contains_key(key));
5769    memory.sealed.failing.retain(|key, _| memory.pending.contains_key(key));
5770    let seen_due = write_seen(sharing, &memory, &all, now_ms);
5771    // What the devices' registry says of this export (`devices`).
5772    // The doses' part held (a newer format met): this device's answers wait
5773    // here, unsent, so its export claims nothing (`Entry::exported`): the
5774    // others then doubt rather than know (docs/health.md, "Knowing").
5775    outcome.looked = if memory.newer.contains_key("health") { 0 } else { clock };
5776    outcome.wrote = memory.joined.then_some((memory.round.max(1), memory.seq));
5777    // Who holds rounds back, by sealed facts, kept for the sending, which
5778    // has no key (`rounds_past`); the past rounds of the devices that no
5779    // longer count (`departed_rounds`); then the rounds past their time go.
5780    memory.counting = Some(counts.iter().filter(|(_, counted)| matches!(counted, Counted::Yes(..))).map(|(id, _)| id.clone()).collect());
5781    memory.uncounted = counts.iter().filter(|(_, counted)| !matches!(counted, Counted::Yes(..) | Counted::Stray)).map(|(id, _)| id.clone()).collect();
5782    memory.departed = departed_rounds(sharing, &memory, &counts, &reading);
5783    // Devices whose files you removed (`remove_device_files`): none of their
5784    // rounds is kept, nor a file of theirs a sync app brought back; one that
5785    // counts again (it said anything newer) is no longer removed.
5786    memory.removed_devices.retain(|id, _| counts.iter().any(|(other, counted)| other == id && counted.departed()));
5787    // What goes with each judged again: forgotten here, then replaced, its entry and note go then.
5788    let judged: BTreeMap<String, (bool, bool)> = memory.removed_devices.keys().filter_map(|id| counts.iter().find(|(other, _)| other == id).map(|(_, counted)| (id.clone(), small_go(counted, last_heard(memory.seen.get(id), &memory, id), now_ms)))).collect();
5789    for (id, removed) in memory.removed_devices.iter_mut() {
5790        let floor = memory.departed.entry(id.clone()).or_default();
5791        *floor = (*floor).max(removed.last);
5792        if let Some((entry, notes)) = judged.get(id) {
5793            (removed.entry, removed.notes) = (removed.entry || *entry, removed.notes || *notes);
5794        }
5795        let last = std::iter::once(sharing.folder.to_path_buf()).chain(crate::remote::overlay(sharing.folder)).map(|dir| round_file(&dir, id, removed.last)).filter(|path| std::fs::metadata(path).is_ok_and(|m| removed.holds_round(removed.last, m.len())));
5796        // A small file only while it is as it was when its files went: a copy brought back, never one it wrote since.
5797        let as_it_went = |path: &Path, place: &str| match removed.small_seen.get(place) {
5798            Some(h) => content_hash(path).as_deref() == Some(h.as_str()),
5799            None => removed.small.get(place).is_some_and(|size| std::fs::metadata(path).is_ok_and(|m| m.len() == *size)),
5800        };
5801        let small = device_small_places(sharing.folder, id, removed.entry, removed.notes).into_iter().filter(|(path, place)| as_it_went(path, place)).map(|(path, _)| path);
5802        for path in small.chain(last).collect::<Vec<_>>() {
5803            let _ = std::fs::remove_file(path);
5804        }
5805    }
5806    remove_old_rounds(sharing, &memory, &reading);
5807    // Still on format 1: what each whole list holds now, and what one took out (`Floor::removed` at the switch).
5808    if memory.format < 2 {
5809        note_whole_lists(originals, &mut memory);
5810    }
5811    // Everything settled, nothing said: the next exchanges have nothing to do
5812    // while nothing they read changes, until the hour's tidying or this
5813    // device's notes are due again (`quiet`). Changes of others' that wait
5814    // only for notes or papers (not read here) wait there as well.
5815    let settled = outcome.problems.is_empty()
5816        && memory.joined
5817        && !memory.switch
5818        && memory.newer.is_empty()
5819        && memory.waiting.is_empty()
5820        && memory.held.is_empty()
5821        && memory.emptied.is_empty()
5822        && memory.sealed.gone.is_empty()
5823        && memory.sealed.failing.is_empty()
5824        && memory.sealed.missing.is_empty()
5825        && memory.waiting_for.is_empty()
5826        && !memory.owed
5827        && memory.pending.keys().all(|key| locate(stores, file_of(key)).is_some_and(|(store, _)| !reads(store)));
5828    let until = (now_ms + HOUR).min(memory.tidied + HOUR).min(memory.cleaned + DAY).min(seen_due);
5829    let (round, own_size) = (memory.round, memory.own_size);
5830    // Drafts' versions kept while their draft is known here.
5831    let drafts: BTreeSet<String> = memory.drafts.keys().filter(|key| !memory.entries.contains_key(*key) && !memory.pending.contains_key(*key)).cloned().collect();
5832    for key in drafts {
5833        memory.drafts.remove(&key);
5834    }
5835    outcome.withheld = Some(memory.withheld.clone());
5836    let needed: Option<BTreeSet<String>> = keeps_itself(sharing).then(|| {
5837        let name = |h: &str| crate::blobs::name(sharing.key, h);
5838        let waiting = memory.pending.iter().filter(|(key, _)| key.starts_with(FILES)).filter_map(|(key, (value, _, _))| value.as_deref().map(|v| name(&value_hash(key, v))));
5839        // The current contents of the parts shared here, the held ones among them.
5840        let current = memory.entries.iter().filter(|(key, known)| key.starts_with(FILES) && !known.h.is_empty() && (locate(stores, file_of(key)).is_some() || memory.sealed.gone.contains_key(file_of(key)))).map(|(_, known)| name(&known.h));
5841        waiting.chain(current).chain(memory.sealed.replaced.keys().cloned()).collect()
5842    });
5843    memory.sealed.needed = needed;
5844    memory.save(sharing.memory)?;
5845    if let Some((standing, _)) = standing.filter(|(standing, taken)| settled && (standing.newest / 1_000_000) as i64 + settling_ms() < *taken) {
5846        let quiet = Quiet { standing, at: now_ms, until, own: (stamp_at(sharing.memory), stamp_at(&sealed_path(sharing.memory))), round, own_size, wrote: outcome.wrote, pending: outcome.pending };
5847        if let Ok(mut all) = QUIET.lock() {
5848            all.insert(sharing.memory.to_path_buf(), quiet);
5849        }
5850    }
5851    memory.keep(sharing.memory);
5852    Ok(outcome)
5853}
5854
5855// ---------------------------------------------------------------- nothing to do
5856
5857/// What an exchange reads, as it stands before it reads anything: the files
5858/// of the stores it reads (each by its stamp), the folder's top and what was
5859/// fetched from the server beside it (`remote::overlay`: the pull writes there
5860/// only what changed on the server), the other devices' rounds and the seal
5861/// by their stamps, every other name there by itself, and what each other
5862/// device's entry says but for its exchanges' times (`devices::steady`). The
5863/// same at the next exchange, and that one has nothing to read (`quiet`).
5864#[derive(Debug, Clone, Default, PartialEq, Eq)]
5865struct Standing {
5866    sharing: (PathBuf, String, String),
5867    stores: Vec<(String, PathBuf)>,
5868    files: BTreeMap<PathBuf, FileStamp>,
5869    folder: BTreeMap<PathBuf, Option<FileStamp>>,
5870    entries: BTreeMap<String, String>,
5871    overlay: Option<PathBuf>,
5872    /// Full rounds waiting (`set_frugal`, `set_importing`), and this device in
5873    /// use (its notes then restated each quarter of an hour, `write_seen`).
5874    waits: (bool, bool),
5875    in_use: bool,
5876    /// The latest time among the files stamped (nanoseconds).
5877    newest: u64,
5878}
5879
5880impl Standing {
5881    fn now(sharing: &Sharing, stores: &[Store], reads: &dyn Fn(&Store) -> bool) -> Standing {
5882        let mut standing = Standing { sharing: (sharing.folder.to_path_buf(), sharing.computer.to_string(), hash(&B64.encode(sharing.key))), ..Standing::default() };
5883        for store in stores.iter().filter(|s| reads(s)) {
5884            standing.stores.push((store.name.clone(), store.path.clone()));
5885            stamp_below(&store.path, &mut standing.files);
5886        }
5887        let overlay = crate::remote::overlay(sharing.folder);
5888        for dir in std::iter::once(sharing.folder.to_path_buf()).chain(overlay.clone()) {
5889            for entry in std::fs::read_dir(&dir).into_iter().flatten().filter_map(Result::ok) {
5890                let name = entry.file_name().to_string_lossy().to_string();
5891                if name.starts_with('.') || entry.file_type().is_ok_and(|t| t.is_dir()) {
5892                    continue;
5893                }
5894                // This device's own files change only by its hand (their size checked apart, `quiet`).
5895                let stamped = !name.starts_with(sharing.computer) && (name.ends_with(".jsonl") || name == "seal.toml");
5896                standing.folder.insert(entry.path(), if stamped { entry.metadata().ok().map(|m| file_stamp(&m)) } else { None });
5897            }
5898        }
5899        let (entries, unread) = crate::devices::all(sharing.folder, sharing.key);
5900        standing.entries = entries.iter().filter(|e| e.id != sharing.computer).map(|e| (e.id.clone(), crate::devices::steady(e))).chain(unread.into_iter().map(|id| (id, String::new()))).collect();
5901        standing.overlay = overlay;
5902        standing.waits = (frugal(sharing.memory), importing(sharing.memory));
5903        standing.in_use = crate::devices::in_use(sharing.memory);
5904        standing.newest = standing.files.values().chain(standing.folder.values().flatten()).map(|s| s.modified).max().unwrap_or(0);
5905        standing
5906    }
5907}
5908
5909/// Every file below `path` (or `path` itself), hidden ones left out, by its stamp.
5910fn stamp_below(path: &Path, out: &mut BTreeMap<PathBuf, FileStamp>) {
5911    let Ok(meta) = std::fs::metadata(path) else { return };
5912    if meta.is_file() {
5913        out.insert(path.to_path_buf(), file_stamp(&meta));
5914        return;
5915    }
5916    for entry in std::fs::read_dir(path).into_iter().flatten().filter_map(Result::ok) {
5917        if entry.file_name().to_string_lossy().starts_with('.') {
5918            continue;
5919        }
5920        stamp_below(&entry.path(), out);
5921    }
5922}
5923
5924/// An exchange that left everything settled (`exchange`): what it read, as
5925/// it stood (`Standing`); its time and until when the next may do nothing
5926/// (the hour's tidying, this device's notes due again); its memory's two
5927/// files as it wrote them; its own round and that file's size; what it said.
5928struct Quiet {
5929    standing: Standing,
5930    at: i64,
5931    until: i64,
5932    own: (Option<FileStamp>, Option<FileStamp>),
5933    round: u32,
5934    own_size: u64,
5935    wrote: Option<(u32, u64)>,
5936    pending: usize,
5937}
5938
5939/// The settled exchanges of this process, by the path of their memory.
5940static QUIET: std::sync::Mutex<BTreeMap<PathBuf, Quiet>> = std::sync::Mutex::new(BTreeMap::new());
5941
5942/// An exchange with nothing to do: the last one here left everything settled
5943/// (`Quiet`), and since then nothing it reads changed (`Standing`, noted at
5944/// `taken`, this computer's clock), neither its memory nor this device's own
5945/// file in the folder (a sync app putting back an older copy changes its
5946/// size), and nothing fell due. Its outcome is that one's: nothing sent,
5947/// nothing received, how far this device wrote, and its files looked at when
5948/// their stamps were: as they were, so every answer captured here before is
5949/// in its records (`Outcome::looked`). Else none, and the exchange runs.
5950fn quiet(sharing: &Sharing, standing: &Standing, taken: i64, now_ms: i64) -> Option<Outcome> {
5951    let mut all = QUIET.lock().ok()?;
5952    let quiet = all.get(sharing.memory)?;
5953    let own_size = std::fs::metadata(round_file(sharing.folder, sharing.computer, quiet.round)).map_or(0, |m| m.len());
5954    let still = quiet.standing == *standing && (quiet.at..quiet.until).contains(&now_ms) && (stamp_at(sharing.memory), stamp_at(&sealed_path(sharing.memory))) == quiet.own && own_size == quiet.own_size;
5955    if !still {
5956        all.remove(sharing.memory);
5957        return None;
5958    }
5959    #[cfg(test)]
5960    QUIETED.with(|n| n.set(n.get() + 1));
5961    Some(Outcome { looked: taken, wrote: quiet.wrote, pending: quiet.pending, quiet: true, ..Outcome::default() })
5962}
5963
5964#[cfg(test)]
5965thread_local! {
5966    /// In this test's thread: the exchanges that had nothing to do (`quiet`),
5967    /// and the logs of lines read through (`lines_of`).
5968    pub(super) static QUIETED: std::cell::Cell<u32> = const { std::cell::Cell::new(0) };
5969    pub(super) static LINES_READ: std::cell::Cell<u32> = const { std::cell::Cell::new(0) };
5970}
5971
5972/// The whole lines of a computer's records from where they were left, read a
5973/// piece at a time (`READ_CHUNK`), one line held whole at most: a line longer
5974/// than any record (`LONGEST_LINE`: whoever wrote it) is given empty, a
5975/// broken line, and skipped to its end. A last line not ended yet is left for
5976/// the next exchange.
5977struct LineReader {
5978    file: std::fs::File,
5979    held: Vec<u8>,
5980    start: usize,
5981    /// Inside a line too long: its bytes so far, skipped.
5982    skipping: Option<u64>,
5983    end: bool,
5984}
5985
5986impl LineReader {
5987    fn new(file: std::fs::File) -> LineReader {
5988        LineReader { file, held: Vec::new(), start: 0, skipping: None, end: false }
5989    }
5990
5991    /// The next whole line and its length in the file.
5992    fn next(&mut self) -> Option<(Vec<u8>, u64)> {
5993        use std::io::Read;
5994        loop {
5995            if let Some(at) = self.held[self.start..].iter().position(|b| *b == b'\n') {
5996                let line = &self.held[self.start..=self.start + at];
5997                self.start += at + 1;
5998                return Some(match self.skipping.take() {
5999                    Some(skipped) => (Vec::new(), skipped + line.len() as u64),
6000                    None => (line.to_vec(), line.len() as u64),
6001                });
6002            }
6003            self.held.drain(..self.start);
6004            self.start = 0;
6005            if self.skipping.is_some() || self.held.len() > LONGEST_LINE {
6006                *self.skipping.get_or_insert(0) += self.held.len() as u64;
6007                self.held.clear();
6008            }
6009            if self.end {
6010                return None;
6011            }
6012            let mut piece = vec![0u8; READ_CHUNK];
6013            match self.file.read(&mut piece) {
6014                Ok(0) | Err(_) => self.end = true,
6015                Ok(n) => self.held.extend_from_slice(&piece[..n]),
6016            }
6017        }
6018    }
6019}
6020
6021/// A line of another computer's round found damaged here (it does not read,
6022/// or does not open), said and dated already (`lost_line`): its number, as
6023/// the line says it (one that does not open), or the one after the line met
6024/// before in its round (one that does not read). Met (`Memory::met`), so that
6025/// a round continuing this one is entered and the rest of that computer's
6026/// records flow; never counted as read (`read_n` stays before it): what that
6027/// computer wrote is not all known here until its next full round, and the
6028/// doses doubt until then, as they did before rounds turned aside (5fa4dd1:
6029/// review of 10 October 2026, S2-A). This device asks for that full round
6030/// (`Seen::full`).
6031fn lost_here(memory: &mut Memory, computer: &str, round: u32, n: Option<u64>) {
6032    let met = memory.met.get(computer).or_else(|| memory.read_n.get(computer)).copied();
6033    let n = n.unwrap_or(match met {
6034        Some((r, m)) if r == round => m + 1,
6035        _ => 1,
6036    });
6037    if met.is_none_or(|met| (round, n) > met) {
6038        memory.met.insert(computer.to_string(), (round, n));
6039    }
6040}
6041
6042/// A line of another computer's lost here, dated (`Memory::broken`): the
6043/// latest such date is kept, never an earlier one, so a doubt once said only
6044/// grows.
6045fn say_broken(memory: &mut Memory, computer: &str, at: i64) {
6046    let kept = memory.broken.entry(computer.to_string()).or_insert(at);
6047    *kept = (*kept).max(at);
6048}
6049
6050/// The first of another computer's rounds after `round` that opens full
6051/// (`Turn`): it restates all that computer held, so a reader waiting for the
6052/// end of an earlier round, which may never come (an older copy of it put
6053/// back), reads on from there. Each round's first line read alone.
6054fn later_full(sharing: &Sharing, computer: &str, rounds: &[u32], round: u32) -> Option<u32> {
6055    rounds.iter().copied().filter(|r| *r > round).find(|r| opens_full(sharing, computer, *r))
6056}
6057
6058/// The last of a computer's rounds in the folder that opens full (`Turn`):
6059/// it restates all that computer held then, and every round before it is
6060/// past for whoever read that far (`departed_rounds`).
6061fn last_full(sharing: &Sharing, computer: &str, rounds: &[u32]) -> Option<u32> {
6062    rounds.iter().rev().copied().find(|r| opens_full(sharing, computer, *r))
6063}
6064
6065/// Whether a computer's round opens full: its first line opens with the key,
6066/// bound to that computer and round, and says so (`Turn::full`).
6067fn opens_full(sharing: &Sharing, computer: &str, round: u32) -> bool {
6068    // A round's first line never changes (records only grow): read once per process.
6069    let at = (sharing.folder.to_path_buf(), computer.to_string(), round);
6070    if let Some(full) = FIRST_LINES.lock().ok().and_then(|seen| seen.get(&at).copied()) {
6071        return full;
6072    }
6073    let Ok(file) = open_round(sharing.folder, computer, round) else { return false };
6074    let Some((line, _)) = LineReader::new(file).next() else { return false };
6075    let Some(record) = std::str::from_utf8(&line).ok().and_then(|text| serde_json::from_str::<Line>(text).ok()) else { return false };
6076    let Some(turn) = open(sharing.key, &bound(computer, round, record.n, record.c), &record.s).and_then(|plain| serde_json::from_slice::<Turn>(&plain).ok()) else { return false };
6077    let full = record.n == 1 && turn.full;
6078    if let Ok(mut seen) = FIRST_LINES.lock() {
6079        seen.insert(at, full);
6080    }
6081    full
6082}
6083
6084/// Whether each round's first line read by `later_full` opens a full round,
6085/// by folder, computer and round: a reader waiting for a round's end looks at
6086/// the later rounds once, not at each exchange.
6087static FIRST_LINES: std::sync::Mutex<BTreeMap<(PathBuf, String, u32), bool>> = std::sync::Mutex::new(BTreeMap::new());
6088
6089/// A line of another computer's round lost here (it does not read, or does
6090/// not open): when its computer wrote it, at the latest, in seconds (the
6091/// clock of the line after it, else now; never the line before it, which
6092/// may be days older: review of 10 October 2026, S2-A), for the doses'
6093/// doubt (`Memory::broken`). None when it was met before: a line after
6094/// it was read already (`read_n`), and this reading goes over the round again
6095/// (a store joining, a record rebuilt): said once, never stamped anew.
6096/// `good`: the last line of the round that opened in this reading, its
6097/// number and clock (none for where an earlier reading stopped).
6098fn lost_line(read_n: Option<(u32, u64)>, round: u32, good: Option<(u64, Option<u64>)>, next: Option<&[u8]>, now_ms: i64) -> Option<i64> {
6099    let before = good.map_or(0, |(n, _)| n);
6100    if read_n.is_some_and(|read| read >= (round, before + 1)) {
6101        return None;
6102    }
6103    let after = next.and_then(|line| std::str::from_utf8(line).ok()).and_then(|text| serde_json::from_str::<Line>(text).ok()).map(|line| line.c);
6104    Some(after.map_or(now_ms / 1000, |c| ((c >> 16) as i64 / 1000).min(now_ms / 1000)))
6105}
6106
6107/// The sealed files this computer's records point to, looked for now and
6108/// then: one gone from the folder (taken out by hand, by another device that
6109/// sealed it long ago and swept it, by the server) or not of the size it was
6110/// sealed at (cut, damaged on the way) is sealed again from the file here,
6111/// while the file still holds that content.
6112fn look_after_sealed(sharing: &Sharing, stores: &[Store], memory: &mut Memory, found: &Found, hurried: &dyn Fn() -> bool, problems: &mut Vec<String>, now_ms: i64) {
6113    let ours: Vec<(String, String)> = memory.entries.iter().filter(|(key, known)| key.starts_with(FILES) && known.w == sharing.computer && !known.h.is_empty() && found.hashes.get(*key) == Some(&known.h)).map(|(key, known)| (key.clone(), known.h.clone())).collect();
6114    for (key, h) in ours {
6115        if hurried() {
6116            return;
6117        }
6118        let name = crate::blobs::name(sharing.key, &h);
6119        let there = std::fs::metadata(sharing.folder.join("blobs").join(&name)).map_or(0, |m| m.len());
6120        if there > 0 && memory.sealed.mine.get(&name).is_none_or(|(_, size)| *size == there) {
6121            continue;
6122        }
6123        let Some((_, path)) = locate(stores, file_of(&key)) else { continue };
6124        match crate::blobs::put_again(sharing.folder, sharing.key, &path, &h) {
6125            Ok(size) => {
6126                memory.sealed.mine.insert(name, (now_ms, size));
6127            }
6128            Err(e) => problems.push(e),
6129        }
6130    }
6131}
6132
6133/// The names of the notes and papers here or known, as a storage that does not
6134/// tell case, nor how an accent is written, sees them (a phone's, a memory
6135/// card's, Windows', a Mac's): two it would take for one are never both written.
6136struct Names {
6137    by_folded: BTreeMap<String, String>,
6138}
6139
6140impl Names {
6141    fn new(entries: &BTreeMap<String, Known>, found: &Found) -> Names {
6142        let known = entries.iter().filter(|(key, known)| key.starts_with(FILES) && !known.h.is_empty()).map(|(key, _)| key);
6143        let here = found.hashes.keys().filter(|key| key.starts_with(FILES));
6144        Names { by_folded: known.chain(here).map(|key| (folded(file_of(key)), file_of(key).to_string())).collect() }
6145    }
6146
6147    /// Whether another file of that name, as such a storage sees it, is here or known.
6148    fn clash(&self, file: &str) -> bool {
6149        self.by_folded.get(&folded(file)).is_some_and(|other| other != file)
6150    }
6151
6152    fn written(&mut self, file: &str, there: bool) {
6153        let name = folded(file);
6154        if there {
6155            self.by_folded.insert(name, file.to_string());
6156        } else if self.by_folded.get(&name).is_some_and(|other| other == file) {
6157            self.by_folded.remove(&name);
6158        }
6159    }
6160}
6161
6162/// A name composed one way (NFC), its case folded.
6163fn folded(name: &str) -> String {
6164    icu_normalizer::ComposingNormalizerBorrowed::new_nfc().normalize(name).to_lowercase()
6165}
6166
6167/// The sealed files this device waits for, said for the pull to fetch
6168/// (`blobs::Wanted`, `remote::fetch_wanted`): those this exchange found
6169/// neither in the folder nor fetched beside it (`missing`: their content's
6170/// hash and size) join; those it found (`there`), and those no change waits
6171/// for any more (written, or another change came), leave. Those of changes it
6172/// did not look at (not read this time, hurried) stay. Nothing read nor
6173/// written while nothing waits.
6174fn note_wanted(sharing: &Sharing, memory: &Memory, missing: &BTreeMap<String, (String, u64)>, there: &BTreeSet<String>, now_ms: i64) {
6175    if missing.is_empty() && !crate::blobs::Wanted::path(sharing.memory).exists() {
6176        return;
6177    }
6178    let waited: BTreeSet<String> = memory.pending.iter().filter(|(key, _)| key.starts_with(FILES)).filter_map(|(key, (value, _, _))| value.as_deref().map(|v| crate::blobs::name(sharing.key, &value_hash(key, v)))).collect();
6179    let _ = crate::blobs::Wanted::change(sharing.memory, |wanted| {
6180        wanted.blobs.retain(|name, _| waited.contains(name) && !there.contains(name));
6181        for (name, (hash, size)) in missing.iter().filter(|(name, _)| waited.contains(*name)) {
6182            wanted.blobs.entry(name.clone()).or_insert_with(|| crate::blobs::Want { hash: hash.clone(), size: *size, since: now_ms / 1000, ..Default::default() });
6183        }
6184    });
6185}
6186
6187/// What came of another device's change to a file sealed apart.
6188enum Received {
6189    /// The file here holds it already.
6190    AlreadySo,
6191    /// Written: the file as it is now, for the next look to compare with
6192    /// (none once taken out); the copy kept beside of a version changed here
6193    /// too, if one was made; whether a version went into the history.
6194    Written { stat: Option<Stat>, copy: Option<String>, kept: bool },
6195    /// The file changed here since it was looked at: left for the next
6196    /// exchange, which sends that change, both kept.
6197    Later,
6198}
6199
6200/// What writing the others' changes needs.
6201struct Receiving<'a> {
6202    sharing: &'a Sharing<'a>,
6203    history: &'a Path,
6204    now_ms: i64,
6205    /// This computer's clock (milliseconds): a file is never dated after it.
6206    clock: i64,
6207    /// Bytes the take-outs of this exchange copied into the history (`TAKE_OUT_COPIES`).
6208    copied: std::cell::Cell<u64>,
6209}
6210
6211/// The take-outs of an exchange copy at most this many bytes into the
6212/// earlier versions (where a file cannot be moved there: a phone's shared
6213/// storage), the first whatever its size; the rest wait for the next
6214/// exchanges, a minute apart. A bulk of thousands of notes taken out on a
6215/// phone would otherwise copy them all in one exchange (review of 17
6216/// October 2026, L9); the history's cap holds them all in time (a GB, or a
6217/// twentieth of the disk).
6218pub(crate) const TAKE_OUT_COPIES: u64 = 64 << 20;
6219
6220#[cfg(test)]
6221thread_local! {
6222    /// The bytes an exchange's take-outs copy, in this test's thread (`TAKE_OUT_COPIES` unless a test says).
6223    pub(crate) static TAKE_OUT_BUDGET: std::cell::Cell<u64> = const { std::cell::Cell::new(TAKE_OUT_COPIES) };
6224}
6225
6226fn take_out_copies() -> u64 {
6227    #[cfg(test)]
6228    return TAKE_OUT_BUDGET.with(std::cell::Cell::get);
6229    #[cfg(not(test))]
6230    TAKE_OUT_COPIES
6231}
6232
6233#[cfg(test)]
6234thread_local! {
6235    /// Run just before a received file takes its place, in this test's thread: an edit made then.
6236    static BEFORE_WRITING: std::cell::RefCell<Option<Box<dyn Fn(&Path)>>> = std::cell::RefCell::new(None);
6237}
6238
6239/// Another device's change to a file sealed apart, written here: its content
6240/// opened from the folder beside the file and checked, then put in its place,
6241/// the file as it was kept first (`history`: by reference when a record named
6242/// that content, else copied). A file here that is neither what that change
6243/// replaced nor a content the others' records name (`seen`) changed here too:
6244/// it is kept beside, under a name that says so (its path in the folder
6245/// returned), never lost. A file changed here since it was looked at
6246/// (`gathered`) is not written over. Nothing is written without room for it,
6247/// for the copy kept, and some left (`disk::kept_free`).
6248#[allow(clippy::too_many_arguments)]
6249fn receive(how: &Receiving, store: &Store, file: &str, path: &Path, value: Option<&str>, current: Option<&str>, seen: &dyn Fn(&str) -> bool, known: Option<&Known>, gathered: Option<&Stat>, damaged: &mut BTreeMap<String, (u64, u64, u64)>) -> Result<Received, crate::blobs::Fault> {
6250    use crate::blobs::Fault;
6251    let sharing = how.sharing;
6252    let wanted = value.map(serde_json::from_str::<Reference>).transpose().map_err(|_| Fault::Broken)?;
6253    // What is here: as gathered, else (written since, or not read) read now.
6254    let current = match current {
6255        Some(here) => Some(here.to_string()),
6256        None if path.is_file() => crate::blobs::hash_file(path).ok().map(|(h, _)| h),
6257        None => None,
6258    };
6259    match (&wanted, &current) {
6260        (Some(wanted), Some(here)) if wanted.h == *here => return Ok(Received::AlreadySo),
6261        (None, None) => return Ok(Received::AlreadySo),
6262        _ => {}
6263    }
6264    let local = |e: std::io::Error| Fault::Local(format!("{}: {e}", path.display()));
6265    let size_here = std::fs::metadata(path).map_or(0, |m| m.len());
6266    // The version this device last received from another device, unchanged
6267    // here since (a change made here is this device's own word, `Known::w`,
6268    // sent before the others' records are read): an older version, never a
6269    // change of its own, even when no record read now names it. A device back
6270    // after its rounds went reads only what a full round restates, and made a
6271    // conflicted copy of every note changed twice meanwhile (16 October 2026).
6272    let received = |here: &str| known.is_some_and(|k| k.h == here && k.w != sharing.computer);
6273    let diverged = current.as_deref().is_some_and(|here| !seen(here) && !received(here));
6274    // The version here kept by reference when a record named it and its
6275    // content is in the folder, or fetched beside it; else copied, as any
6276    // file is: a device back after weeks away, whose content left the folder
6277    // meanwhile (two weeks after it was replaced, `blobs::UNUSED_DAYS`), keeps
6278    // its own last version, not a reference that no longer opens.
6279    let sealed_there = |h: &str| {
6280        let name = crate::blobs::name(sharing.key, h);
6281        std::iter::once(sharing.folder.to_path_buf()).chain(crate::remote::overlay(sharing.folder)).any(|dir| std::fs::metadata(dir.join("blobs").join(&name)).is_ok_and(|m| m.len() > 0))
6282    };
6283    // Taken out by another device's change: the file moved among the earlier
6284    // versions as it is (`history::keep_moved`), never a reference to its
6285    // content, which leaves the folder as soon as every device read that it
6286    // was taken out (`removed_due`); moved, it costs no room (copied when it
6287    // cannot be moved, `history::keep` asks for the room).
6288    let by_reference = wanted.is_some() && current.as_deref().is_some_and(|here| (known.is_some_and(|k| k.h == here) || seen(here)) && sealed_there(here));
6289    let room = wanted.as_ref().map_or(0, |w| w.s) + if by_reference || wanted.is_none() { 0 } else { size_here } + if diverged { size_here } else { 0 };
6290    if wanted.as_ref().is_some_and(|w| w.s > crate::blobs::LARGEST) {
6291        return Err(Fault::Broken);
6292    }
6293    if !crate::disk::fits(path, room) {
6294        return Err(Fault::Room);
6295    }
6296    let temporary = temporary(path);
6297    if let Some(wanted) = &wanted {
6298        // The folder's copy found damaged before, and the same copy still: not
6299        // opened again (the one fetched from the server beside it is, `blobs::get`).
6300        let blob = crate::blobs::name(sharing.key, &wanted.h);
6301        let copy = std::fs::metadata(crate::blobs::path(sharing.folder, sharing.key, &wanted.h)).ok().map(|m| crate::blobs::fingerprint(&m));
6302        let known_damaged = copy.is_some() && damaged.get(&blob) == copy.as_ref();
6303        if let Some(parent) = path.parent() {
6304            private_dirs(parent).map_err(local)?;
6305        }
6306        match crate::blobs::get_unless(sharing.folder, sharing.key, &wanted.h, &temporary, known_damaged) {
6307            Err(Fault::Broken) => {
6308                damaged.extend(copy.map(|copy| (blob, copy)));
6309                return Err(Fault::Broken);
6310            }
6311            Err(fault) => return Err(fault),
6312            Ok(()) => {
6313                damaged.remove(&blob);
6314            }
6315        }
6316    }
6317    let placed = (|| {
6318        #[cfg(test)]
6319        BEFORE_WRITING.with(|hook| {
6320            if let Some(hook) = hook.borrow().as_ref() {
6321                hook(path);
6322            }
6323        });
6324        // Changed here since it was looked at (an editor, another program): not
6325        // written over; the next exchange sends that change first.
6326        let same = match (std::fs::symlink_metadata(path).ok(), gathered) {
6327            (None, None) => true,
6328            (Some(now), Some(then)) => now.len() == then.size && modified_ns(&now) == then.modified,
6329            _ => false,
6330        };
6331        if !same {
6332            return Ok(None);
6333        }
6334        let copy = match &current {
6335            Some(here) if diverged => Some(conflict_copy(path, here, known, how.now_ms).map_err(Fault::Local)?),
6336            _ => None,
6337        };
6338        let mut kept = false;
6339        if let Some(here) = current.as_deref()
6340            && path.exists()
6341        {
6342            let kept_now = if by_reference {
6343                crate::history::keep_sealed(how.history, store.part, file, here, size_here, how.now_ms)
6344            } else if wanted.is_none() {
6345                crate::history::keep_moved(how.history, store.part, file, path, how.now_ms).map(|copied| how.copied.set(how.copied.get() + copied))
6346            } else {
6347                crate::history::keep(how.history, store.part, file, path, how.now_ms)
6348            };
6349            kept_now.map_err(|e| if e.starts_with("share-no-room") { Fault::Room } else { Fault::Local(e) })?;
6350            kept = true;
6351        }
6352        let stat = match &wanted {
6353            Some(wanted) => {
6354                // The spam filter's table it replaces, kept beside when this Sioul can use it (`table.prev.bin`).
6355                if store.name == SPAM_TABLE {
6356                    sioul_core::spam::table::keep_previous(path);
6357                }
6358                std::fs::rename(&temporary, path).map_err(local)?;
6359                // Its time as where it was made, never after this computer's clock (another's may be ahead).
6360                let _ = set_time(path, std::time::UNIX_EPOCH + std::time::Duration::from_millis(wanted.t.min(how.clock).max(0) as u64));
6361                let now = std::fs::metadata(path).map_err(local)?;
6362                Some(Stat { size: now.len(), modified: modified_ns(&now), entries: vec![(String::new(), wanted.h.clone())], seen: how.clock })
6363            }
6364            None => {
6365                match std::fs::remove_file(path) {
6366                    Err(e) if e.kind() != std::io::ErrorKind::NotFound => return Err(local(e)),
6367                    _ => {}
6368                }
6369                // Its folders, left empty, go too.
6370                let mut dir = path.parent();
6371                while let Some(folder) = dir.filter(|d| *d != store.path && d.starts_with(&store.path)) {
6372                    if std::fs::remove_dir(folder).is_err() {
6373                        break;
6374                    }
6375                    dir = folder.parent();
6376                }
6377                None
6378            }
6379        };
6380        Ok(Some((stat, copy, kept)))
6381    })();
6382    if !matches!(placed, Ok(Some(_))) {
6383        let _ = std::fs::remove_file(&temporary);
6384    }
6385    let in_store = |copy: PathBuf| copy.strip_prefix(&store.path).map_or_else(|_| copy.display().to_string(), |r| r.components().map(|c| c.as_os_str().to_string_lossy()).collect::<Vec<_>>().join("/"));
6386    Ok(match placed? {
6387        Some((stat, copy, kept)) => Received::Written { stat, copy: copy.map(in_store), kept },
6388        None => Received::Later,
6389    })
6390}
6391
6392/// The name of the copy kept beside a file two devices changed, the same on
6393/// every device whatever its language: "lease (conflict 2026-10-05 16.05)",
6394/// dated (in this computer's time zone) by when the version it holds was made.
6395pub fn conflict_name(stem: &str, at_ms: i64) -> String {
6396    let when = jiff::Timestamp::from_millisecond(at_ms).map(|t| t.to_zoned(jiff::tz::TimeZone::system()).strftime("%Y-%m-%d %H.%M").to_string()).unwrap_or_default();
6397    format!("{stem} (conflict {when})")
6398}
6399
6400/// A file two devices changed, copied beside it before the other's version
6401/// takes its place, under `conflict_name`: dated by when the version it holds
6402/// was made, so that every device holding that version names it alike and one
6403/// copy travels, not one each. Returns where it is.
6404fn conflict_copy(path: &Path, here: &str, known: Option<&Known>, now_ms: i64) -> Result<PathBuf, String> {
6405    let at = known.filter(|k| k.h == here).map(|k| (k.c >> 16) as i64).or_else(|| std::fs::metadata(path).ok().map(|m| (modified_ns(&m) / 1_000_000) as i64)).unwrap_or(now_ms);
6406    let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
6407    let (stem, extension) = match name.rsplit_once('.') {
6408        Some((stem, extension)) if !stem.is_empty() => (stem.to_string(), format!(".{extension}")),
6409        _ => (name.clone(), String::new()),
6410    };
6411    let named = conflict_name(&stem, at);
6412    let mut n = 1;
6413    loop {
6414        let copy = path.with_file_name(if n == 1 { format!("{named}{extension}") } else { format!("{named} {n}{extension}") });
6415        if !copy.exists() {
6416            let temporary = temporary(&copy);
6417            return copy_private(path, &temporary).and_then(|()| std::fs::rename(&temporary, &copy)).map(|()| copy.clone()).map_err(|e| {
6418                let _ = std::fs::remove_file(&temporary);
6419                format!("{}: {e}", copy.display())
6420            });
6421        }
6422        // Kept already: another device's copy of the same version came first.
6423        if crate::blobs::hash_file(&copy).is_ok_and(|(h, _)| h == here) {
6424            return Ok(copy);
6425        }
6426        n += 1;
6427    }
6428}
6429
6430/// Whether a file may be written where a record says: never through a link
6431/// below its store's folder (a link to elsewhere would carry the write out of
6432/// it), nor over a link, nor inside the sharing folder (it would travel in
6433/// plain) or Sioul's own state, but for the stores of that state itself
6434/// (`state/…`: the shield's answers, local lists' state, the spam filter's
6435/// label logs), which live there by design, each in its own folder.
6436fn writable(store: &Store, path: &Path, sharing: &Sharing) -> bool {
6437    writable_in(store, path, Some(sharing.folder), sharing.memory)
6438}
6439
6440fn writable_in(store: &Store, path: &Path, folder: Option<&Path>, memory: &Path) -> bool {
6441    let Ok(relative) = path.strip_prefix(&store.path) else { return false };
6442    let mut at = store.path.clone();
6443    for part in relative.components() {
6444        at.push(part);
6445        match std::fs::symlink_metadata(&at) {
6446            Ok(meta) if meta.file_type().is_symlink() => return false,
6447            Ok(_) => {}
6448            Err(_) => break,
6449        }
6450    }
6451    // Where it would land: the deepest of its folders that is there.
6452    let Some(there) = path.ancestors().skip(1).find(|p| p.exists()).and_then(|p| p.canonicalize().ok()) else { return true };
6453    let inside = |root: &Path| root.canonicalize().is_ok_and(|root| there.starts_with(root));
6454    let state = memory.parent().and_then(Path::parent);
6455    // A store of Sioul's own state writes in its own folder there (the path was
6456    // checked above: no link, nothing outside it); every other store, never in that state.
6457    let own_state = store.name.starts_with("state/") && state.is_some_and(|state| store.path.starts_with(state));
6458    !folder.is_some_and(inside) && (own_state || !state.is_some_and(inside))
6459}
6460
6461/// Names Windows keeps for its devices ("aux.md", "COM1.txt"), and names
6462/// ending in a dot or a space, which it cuts.
6463fn kept_by_windows(name: &str) -> bool {
6464    let stem = name.split('.').next().unwrap_or(name).to_ascii_uppercase();
6465    let numbered = (stem.starts_with("COM") || stem.starts_with("LPT")) && stem.len() == 4 && matches!(stem.as_bytes()[3], b'1'..=b'9');
6466    matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL") || numbered || name.ends_with('.') || name.ends_with(' ')
6467}
6468
6469/// The store and the file a key's file part names.
6470fn locate<'a>(stores: &'a [Store], file: &str) -> Option<(&'a Store, PathBuf)> {
6471    if let Some(store) = stores.iter().find(|s| !s.folder && s.name == file) {
6472        return Some((store, store.path.clone()));
6473    }
6474    let store = stores.iter().filter(|s| s.folder && file.starts_with(s.name.as_str())).max_by_key(|s| s.name.len())?;
6475    let relative = &file[store.name.len()..];
6476    // Never outside its folder, on Windows too, where "\" also separates and
6477    // "C:" starts another place.
6478    if relative.is_empty() || relative.split('/').any(|part| part.is_empty() || part == ".." || part == "." || part.contains(['\\', ':'])) {
6479        return None;
6480    }
6481    // Files sealed apart: never what tools make, nor what another part carries
6482    // (whatever its case), nor a name Windows keeps for itself, there.
6483    if matches!(store.shape, Shape::Files)
6484        && (relative.split('/').any(|part| made_by_tools(part) || matches!(part, "node_modules" | "target") || (cfg!(windows) && kept_by_windows(part))) || relative.split('/').next().is_some_and(|top| store.skip.iter().any(|skip| skip.eq_ignore_ascii_case(top))))
6485    {
6486        return None;
6487    }
6488    Some((store, relative.split('/').fold(store.path.clone(), |path, part| path.join(part))))
6489}
6490
6491/// What putting a version back would change, said before it is done (`put_back_preview`).
6492#[derive(Debug, Default, PartialEq, Eq)]
6493pub struct Back {
6494    /// A file whole (a note, a paper, a draft): it goes back as it was.
6495    pub whole: bool,
6496    /// Entries (settings, lines, marks, sessions) that go back to what they were.
6497    pub changed: usize,
6498    /// Entries taken out since, which come back.
6499    pub returning: usize,
6500    /// Entries added since, which stay.
6501    pub kept: usize,
6502}
6503
6504/// What putting back `stamp` would change in `file` (`put_back`).
6505pub fn put_back_preview(memory: &Path, stores: &[Store], part: &str, file: &str, stamp: &str) -> Result<Back, String> {
6506    // Entries as this device's format divides the file.
6507    let shaped_stores = shaped(stores, format_of(memory));
6508    let stores = shaped_stores.as_slice();
6509    let (store, path) = locate(stores, file).filter(|(store, _)| store.part == part).ok_or_else(|| format!("{file}: not shared"))?;
6510    let kept = crate::history::version(&crate::history::root(memory), part, file, stamp).ok_or_else(|| format!("{file}: {stamp}: not kept"))?;
6511    let (crate::history::Kept::Copy(version), Shape::Toml(_) | Shape::Lines) = (kept, store.shape) else { return Ok(Back { whole: true, ..Back::default() }) };
6512    let (old, now) = entries_then_and_now(store, &version, &path)?;
6513    Ok(Back {
6514        whole: false,
6515        changed: old.iter().filter(|(entry, value)| now.get(*entry).is_some_and(|v| v != *value)).count(),
6516        returning: old.keys().filter(|entry| !now.contains_key(*entry)).count(),
6517        kept: now.keys().filter(|entry| !old.contains_key(*entry)).count(),
6518    })
6519}
6520
6521/// A file's entries in a version kept, and in the file now.
6522fn entries_then_and_now(store: &Store, version: &Path, path: &Path) -> Result<(BTreeMap<String, String>, BTreeMap<String, String>), String> {
6523    let old = std::fs::read(version).ok().and_then(|bytes| entries_of(store, &bytes)).ok_or_else(|| format!("{}: not readable", version.display()))?;
6524    let now = std::fs::read(path).ok().and_then(|bytes| entries_of(store, &bytes)).unwrap_or_default();
6525    Ok((old.into_iter().collect(), now.into_iter().collect()))
6526}
6527
6528/// A version put back (`history`). A file whole goes back as it was, the file
6529/// as it is now kept first. A file of entries (settings, lists, the doses'
6530/// record, sessions) is put back entry by entry: each entry the version held
6531/// goes back to what it was, those taken out since come back, and those added
6532/// since stay. Putting back never takes out, on any device, what was added
6533/// after that version. The next exchange sends what changed as changes made
6534/// here, dated now. `stores` holds every part, those switched off too (a part
6535/// off here is put back here only); `vault` the sharing folder and its key,
6536/// for a version kept by reference. Waits for an exchange running.
6537pub fn put_back(memory: &Path, vault: Option<(&Path, &[u8; 32])>, stores: &[Store], part: &str, file: &str, stamp: &str, now_ms: i64) -> Result<(), String> {
6538    let shaped_stores = shaped(stores, format_of(memory));
6539    let stores = shaped_stores.as_slice();
6540    let (store, path) = locate(stores, file).filter(|(store, _)| store.part == part).ok_or_else(|| format!("{file}: not shared"))?;
6541    let root = crate::history::root(memory);
6542    let kept = crate::history::version(&root, part, file, stamp).ok_or_else(|| format!("{file}: {stamp}: not kept"))?;
6543    let not_there = || format!("{}: not written there", path.display());
6544    if let (crate::history::Kept::Copy(version), Shape::Toml(_) | Shape::Lines) = (&kept, store.shape) {
6545        // A file of entries (settings, lists, records): small, put back entry by entry under the lock.
6546        let _running = exchange_lock(memory, true)?;
6547        if store.folder && !writable_in(store, &path, vault.map(|(folder, _)| folder), memory) {
6548            return Err(not_there());
6549        }
6550        let (old, now) = entries_then_and_now(store, version, &path)?;
6551        let back: Vec<(&str, Option<&str>)> = old.iter().filter(|(entry, value)| now.get(*entry) != Some(*value)).map(|(entry, value)| (entry.as_str(), Some(value.as_str()))).collect();
6552        if back.is_empty() {
6553            return Ok(());
6554        }
6555        return write_entries(store, &path, &back, || crate::history::keep(&root, part, file, &path, now_ms)).map(|_| ());
6556    }
6557    let fail = |e: std::io::Error| format!("{}: {e}", path.display());
6558    let bring = |to: &Path| match &kept {
6559        crate::history::Kept::Copy(version) => copy_private(version, to).map_err(fail),
6560        crate::history::Kept::Sealed { hash, .. } => {
6561            let (folder, key) = vault.ok_or_else(|| format!("{file}: kept in the sharing folder, which is not set here"))?;
6562            crate::blobs::get(folder, key, hash, to).map_err(|fault| match fault {
6563                crate::blobs::Fault::Missing => format!("share-missing:{file}"),
6564                crate::blobs::Fault::Broken => format!("share-damaged:{file}"),
6565                crate::blobs::Fault::Room => format!("share-no-room:{file}"),
6566                crate::blobs::Fault::Local(e) => e,
6567            })
6568        }
6569    };
6570    // A whole file: its version opened beside its place outside the exchanges' lock (a
6571    // large note's takes a second here, more on a phone, and a dose's exchange waits for
6572    // the lock), in the deepest of its folders that is there, never in one made now (L-1),
6573    // dated now while it waits (an exchange's sweep leaves a leftover alone its first
6574    // hour); the lock is taken only to keep the file as it is, date it and put it in
6575    // place (review of part 3, J2, as `put_back_withheld` does since L-a).
6576    let name = path.file_name().ok_or_else(not_there)?;
6577    let there = path.ancestors().skip(1).find(|dir| dir.is_dir()).filter(|dir| path.parent() == Some(*dir) || dir.starts_with(&store.path)).ok_or_else(not_there)?;
6578    let opened = temporary(&there.join(name));
6579    if let Err(e) = bring(&opened) {
6580        let _ = std::fs::remove_file(&opened);
6581        return Err(e);
6582    }
6583    let _ = set_time(&opened, std::time::SystemTime::now());
6584    // The file as it is now copied into its versions ahead of the lock too, closed there, so that
6585    // under the lock it is kept by a rename alone, while it is as it was copied (review of part 3, R-7).
6586    let prepare = || {
6587        let prepared = if path.exists() { crate::history::prepare(&root, part, file, &path).map(Some) } else { Ok(None) };
6588        after_prepare();
6589        prepared
6590    };
6591    let mut prepared = match prepare() {
6592        Ok(prepared) => prepared,
6593        Err(e) => {
6594            let _ = std::fs::remove_file(&opened);
6595            return Err(e);
6596        }
6597    };
6598    // The file changed between its copy and the lock (another program wrote
6599    // it): copied again outside the lock, a few times, never under it
6600    // (22 October 2026: CI's Linux job waited 0.49 s for a 64 MB copy made under it).
6601    let mut again = 0;
6602    let placed = loop {
6603        let changed = |prepared: &Option<crate::history::Prepared>| prepared.as_ref().and_then(crate::history::Prepared::changed);
6604        let looked = (|| {
6605            let running = exchange_lock(memory, true)?;
6606            if again < PREPARE_AGAIN && changed(&prepared).is_some() {
6607                return Ok(None);
6608            }
6609            Ok(Some(running))
6610        })();
6611        let running = match looked {
6612            Ok(Some(running)) => running,
6613            Ok(None) => {
6614                again += 1;
6615                prepared = match prepare() {
6616                    Ok(prepared) => prepared,
6617                    Err(e) => break Err(e),
6618                };
6619                continue;
6620            }
6621            Err(e) => break Err(e),
6622        };
6623        break (|| {
6624        let _running = running;
6625        // A last look, under the lock.
6626        if store.folder && !writable_in(store, &path, vault.map(|(folder, _)| folder), memory) {
6627            return Err(not_there());
6628        }
6629        let parent = path.parent().ok_or_else(not_there)?;
6630        private_dirs(parent).map_err(fail)?;
6631        // Into its own folder: a rename on one disk; across disks (one mounted below), opened there again.
6632        let beside = if opened.parent() == Some(parent) {
6633            opened.clone()
6634        } else {
6635            let beside = temporary(&path);
6636            if std::fs::rename(&opened, &beside).is_err() {
6637                let _ = std::fs::remove_file(&opened);
6638                if let Err(e) = bring(&beside) {
6639                    let _ = std::fs::remove_file(&beside);
6640                    return Err(e);
6641                }
6642            }
6643            beside
6644        };
6645        let mut put = || {
6646            // Kept by its copy made ahead; copied now only when the file came, or changed again and again.
6647            let why = match &prepared {
6648                Some(prepared) => prepared.changed().unwrap_or_else(|| "its copy could not be renamed".into()),
6649                None => "it came meanwhile".into(),
6650            };
6651            let kept_ahead = prepared.take().is_some_and(|prepared| prepared.keep(now_ms));
6652            if path.exists() && !kept_ahead {
6653                crate::history::copied_under_lock(&why);
6654                crate::history::keep(&root, part, file, &path, now_ms)?;
6655            }
6656            // Dated now, wherever the copy took its time from: a change made here, which the others take.
6657            let _ = set_time(&beside, std::time::SystemTime::now());
6658            std::fs::rename(&beside, &path).map_err(fail)
6659        };
6660        // A file sealed apart (a note) under its lock only when one of Sioul's own writers takes it (the letters' list).
6661        let done = if matches!(store.shape, Shape::Files) && !sioul_core::filelock::has_lock(&path) { put() } else { sioul_core::filelock::with_lock(&path, put) };
6662        if done.is_err() {
6663            let _ = std::fs::remove_file(&beside);
6664        }
6665        done
6666        })();
6667    };
6668    let _ = std::fs::remove_file(&opened);
6669    placed
6670}
6671
6672/// How many times a file changed between its copy and the exchanges' lock
6673/// is copied again outside it before it is copied under it (`put_back`).
6674const PREPARE_AGAIN: usize = 3;
6675
6676/// For a file sealed apart, the content a change made here replaces: its hash as last known ("" new).
6677fn base_of(memory: &Memory, key: &str) -> String {
6678    if key.starts_with(FILES) || key.starts_with(DRAFTS) { memory.entries.get(key).map(|k| k.h.clone()).unwrap_or_default() } else { String::new() }
6679}
6680
6681/// A version of a draft met here (`Memory::drafts`), with the one it replaced.
6682fn met_draft(memory: &mut Memory, key: &str, versions: [&str; 2]) {
6683    if key.starts_with(DRAFTS) {
6684        memory.drafts.entry(key.to_string()).or_default().extend(versions.iter().filter(|v| !v.is_empty()).map(|v| (*v).to_string()));
6685    }
6686}
6687
6688/// A file sealed apart put in the folder before its record goes out (`blobs`);
6689/// false when it could not be (it changed while read, a full disk): its record
6690/// waits for the next look. Anything else needs nothing.
6691fn seal_apart(sharing: &Sharing, stores: &[Store], key: &str, value: &str, sealed: &mut Sealed, problems: &mut Vec<String>, now_ms: i64) -> bool {
6692    if !key.starts_with(FILES) {
6693        return true;
6694    }
6695    let (Some((_, path)), Ok(reference)) = (locate(stores, file_of(key)), serde_json::from_str::<Reference>(value)) else { return false };
6696    match crate::blobs::put(sharing.folder, sharing.key, &path, &reference.h) {
6697        Ok(written) => {
6698            let name = crate::blobs::name(sharing.key, &reference.h);
6699            if let Some(size) = written {
6700                sealed.mine.insert(name.clone(), (now_ms, size));
6701            } else if keeps_itself(sharing) && !sealed.mine.contains_key(&name) {
6702                // Sioul keeping the folder itself: a content already in its
6703                // copy (fetched there once) is this device's own now that its
6704                // own change names it, and its push sends it where the server
6705                // no longer has it (its writer took it out meanwhile: a note
6706                // put back from its earlier versions). Where the server has
6707                // it, the send finds it there and sends nothing (review of 17
6708                // October 2026, H1).
6709                let size = std::fs::metadata(crate::blobs::path(sharing.folder, sharing.key, &reference.h)).map_or(0, |m| m.len());
6710                sealed.mine.insert(name.clone(), (now_ms, size));
6711            }
6712            // A table of the spam filter this computer sealed: the newest of its own (`prune_tables`).
6713            if file_of(key) == SPAM_TABLE && sealed.mine.contains_key(&name) {
6714                sealed.tables.retain(|t| *t != name);
6715                sealed.tables.push(name);
6716            }
6717            true
6718        }
6719        Err(e) => {
6720            problems.push(e);
6721            false
6722        }
6723    }
6724}
6725
6726/// Whether Sioul keeps this sharing's folder itself, with no sync app
6727/// (`remote::mirror_of`): its folder is its own copy of the server's.
6728fn keeps_itself(sharing: &Sharing) -> bool {
6729    crate::remote::mirror_of(sharing.memory) == sharing.folder
6730}
6731
6732fn append(sharing: &Sharing, memory: &mut Memory, changes: &[(String, Option<String>, u64, String)]) -> Result<(), String> {
6733    if changes.is_empty() {
6734        return Ok(());
6735    }
6736    let path = round_file(sharing.folder, sharing.computer, memory.round);
6737    let mut text = String::new();
6738    for (at, (key, value, clock, base)) in changes.iter().enumerate() {
6739        // A round's first line says where it stands (`Turn`): it continues the
6740        // round before, to that round's last line, or it is full (its
6741        // computer's first round, or one restating all, `new_round`).
6742        let turn = if at == 0 && memory.seq == 0 {
6743            if memory.continues > 0 {
6744                Turn { p: memory.continues, full: false }
6745            } else if memory.full_round == memory.round || memory.round <= 1 {
6746                Turn { p: 0, full: true }
6747            } else {
6748                Turn::default()
6749            }
6750        } else {
6751            Turn::default()
6752        };
6753        memory.seq += 1;
6754        let build = if at == 0 { sioul_core::build::DESCRIBED.to_string() } else { String::new() };
6755        // Its part's format, once this device writes format 2.
6756        let f = if memory.format >= 2 { known_part(file_of(key)).map_or(1, part_format) } else { 1 };
6757        let change = Change { k: key.clone(), v: value.clone(), b: base.clone(), build, f };
6758        let plain = serde_json::to_vec(&Sealing { change: &change, turn }).map_err(|e| e.to_string())?;
6759        let line = Line { n: memory.seq, c: *clock, s: seal(sharing.key, &bound(sharing.computer, memory.round, memory.seq, *clock), &plain) };
6760        text.push_str(&serde_json::to_string(&line).map_err(|e| e.to_string())?);
6761        text.push('\n');
6762    }
6763    let fail = |e: std::io::Error| format!("{}: {e}", path.display());
6764    // Where this append ends, noted on the disk before it is made: its own
6765    // lines told from another device's under its name (`appended`). Not
6766    // noted (a full disk), nothing is appended: a line of its own is never
6767    // taken for another device's (`share-twin`).
6768    write_synced(&sharing.memory.with_file_name("appended.toml"), format!("computer = \"{}\"\nround = {}\nn = {}\n", sharing.computer, memory.round, memory.seq).as_bytes())?;
6769    // A line left half written (Sioul stopped, a full disk): cut back to the
6770    // last whole line, so that the next record is not glued to it and lost.
6771    if let Some(whole) = unfinished(&path) {
6772        let file = std::fs::OpenOptions::new().write(true).open(&path).map_err(fail)?;
6773        file.set_len(whole).map_err(fail)?;
6774    }
6775    let mut options = std::fs::OpenOptions::new();
6776    options.create(true).append(true);
6777    #[cfg(unix)]
6778    std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600);
6779    let mut file = options.open(&path).map_err(fail)?;
6780    // A round begun before its files were yours alone: made so at this write.
6781    make_private(&path);
6782    file.write_all(text.as_bytes()).map_err(fail)?;
6783    file.sync_all().map_err(fail)
6784}
6785
6786/// Where this device's last append ended (its round and last number), as
6787/// noted before it was made (`append`): its own records found ahead of its
6788/// memory up to there were its own, its memory not saved after (a process
6789/// stopped, a full disk; an append cut short ends before it), not another
6790/// device's under its name (`share-twin`).
6791fn appended(memory: &Path, computer: &str) -> Option<(u32, u64)> {
6792    #[derive(Deserialize)]
6793    struct Appended {
6794        computer: String,
6795        round: u32,
6796        n: u64,
6797    }
6798    let text = read_small(&memory.with_file_name("appended.toml"))?;
6799    toml::from_str::<Appended>(&text).ok().filter(|a| a.computer == computer).map(|a| (a.round, a.n))
6800}
6801
6802/// Where a file's last whole line ends, when it does not end one: its end
6803/// read back a piece at a time, never the file whole. None when it ends a
6804/// line, or is empty or not there.
6805fn unfinished(path: &Path) -> Option<u64> {
6806    use std::io::{Read, Seek, SeekFrom};
6807    let mut file = std::fs::File::open(path).ok()?;
6808    let size = file.metadata().ok()?.len();
6809    let mut last = [0u8];
6810    file.seek(SeekFrom::Start(size.checked_sub(1)?)).ok()?;
6811    file.read_exact(&mut last).ok()?;
6812    if last[0] == b'\n' {
6813        return None;
6814    }
6815    let mut end = size;
6816    while end > 0 {
6817        let from = end.saturating_sub(64 << 10);
6818        let mut piece = vec![0u8; (end - from) as usize];
6819        file.seek(SeekFrom::Start(from)).ok()?;
6820        file.read_exact(&mut piece).ok()?;
6821        if let Some(at) = piece.iter().rposition(|b| *b == b'\n') {
6822            return Some(from + at as u64 + 1);
6823        }
6824        end = from;
6825    }
6826    Some(0)
6827}
6828
6829/// This computer's last record in the folder: its round, number, and that file's size.
6830fn last_own(folder: &Path, computer: &str) -> Option<(u32, u64, u64)> {
6831    let round = *rounds(folder).get(computer)?.last()?;
6832    let (n, size) = last_of(&round_file(folder, computer, round))?;
6833    Some((round, n, size))
6834}
6835
6836/// The number of the last line of one of a computer's rounds (0 for none).
6837fn last_line(folder: &Path, computer: &str, round: u32) -> Option<u64> {
6838    last_of(&round_file(folder, computer, round)).map(|(n, _)| n)
6839}
6840
6841/// A round file's last line's number (0 for none) and its size.
6842fn last_of(path: &Path) -> Option<(u64, u64)> {
6843    use std::io::{Read, Seek};
6844    let mut file = std::fs::File::open(path).ok()?;
6845    let size = file.metadata().ok()?.len();
6846    // The tail is enough: lines are short; a line longer than that was a whole round's opening.
6847    let from = size.saturating_sub(64 * 1024);
6848    let mut tail = Vec::new();
6849    file.seek(std::io::SeekFrom::Start(from)).ok()?;
6850    file.read_to_end(&mut tail).ok()?;
6851    let n = tail.split(|b| *b == b'\n').rev().find_map(|line| std::str::from_utf8(line).ok().and_then(|t| serde_json::from_str::<Line>(t).ok())).map_or(0, |l| l.n);
6852    Some((n, size))
6853}
6854
6855/// What a full round restates: every entry this computer holds the last
6856/// word on, with its value as it reads now, and how many of them it could
6857/// not restate though their store is here and read (their file not readable
6858/// now, a settings file held empty (`EMPTIED_WAIT`), a change found and not
6859/// sent yet: their value here is not the one this computer last said). An
6860/// entry of a store not here (a part switched off on this device, a store
6861/// that left) is passed over, as it always was: it stops no round from
6862/// being full (review of 10 October 2026, S2-B).
6863fn restatement(sharing: &Sharing, memory: &Memory, stores: &[Store], found: &Found) -> (Vec<(String, Option<String>, u64, String)>, usize) {
6864    let mut omitted = 0usize;
6865    let ours = memory
6866        .entries
6867        .iter()
6868        .filter(|(_, known)| known.w == sharing.computer)
6869        .filter_map(|(key, known)| {
6870            if known.h.is_empty() {
6871                return (!found.hashes.contains_key(key)).then(|| (key.clone(), None, known.c, known.b.clone()));
6872            }
6873            let (store, _) = locate(stores, file_of(key))?;
6874            // A file sealed apart from its last look, never read through for this.
6875            let value = match found.values.get(key) {
6876                Some(value) => Some(value.clone()),
6877                None if key.starts_with(FILES) => found.files.get(file_of(key)).and_then(|stat| stat.entries.first().map(|(_, h)| reference(h, stat.size, stat.modified))),
6878                None => value_of(stores, key),
6879            };
6880            match value {
6881                Some(value) if value_hash(key, &value) == known.h => Some((key.clone(), Some(value), known.c, known.b.clone())),
6882                _ => {
6883                    if reads_now(sharing, store) {
6884                        omitted += 1;
6885                    }
6886                    None
6887                }
6888            }
6889        })
6890        .collect();
6891    (ours, omitted)
6892}
6893
6894/// Whether this exchange reads a store: every one, but notes and papers
6895/// (files sealed apart in a folder) only when it reads them (`Sharing::files`).
6896fn reads_now(sharing: &Sharing, store: &Store) -> bool {
6897    sharing.files || !matches!(store.shape, Shape::Files) || !store.folder
6898}
6899
6900/// A new file for this computer, opening on every entry it holds the last
6901/// word on, with their clocks; entries taken out long ago are forgotten. A
6902/// file sealed apart missing from the folder (taken out there by hand) is
6903/// put back first. `cut`: the round before lost lines (its file cut short,
6904/// or found without a line), which a reader that had not read them waits for
6905/// until a full round restates all.
6906fn new_round(sharing: &Sharing, memory: &mut Memory, stores: &[Store], found: &Found, hurried: &dyn Fn() -> bool, now_ms: i64, cut: bool) -> Result<(), String> {
6907    let before = |days: i64| ((now_ms - days * 86_400_000).max(0) as u64) << 16;
6908    let (oldest, aged) = (before(TOMBSTONE_DAYS), before(AGED_TOMBSTONE_DAYS));
6909    memory.entries.retain(|key, known| !known.h.is_empty() || known.c >= if ages_out(key) { aged } else { oldest });
6910    let (ours, omitted) = restatement(sharing, memory, stores, found);
6911    let mut problems = Vec::new();
6912    for (key, value, _, _) in ours.iter().filter(|_| sharing.files && !hurried()) {
6913        if let Some(value) = value {
6914            seal_apart(sharing, stores, key, value, &mut memory.sealed, &mut problems, now_ms);
6915        }
6916    }
6917    // Restating all: a full round, which a reader waiting for the end of an
6918    // earlier round may read on from (`later_full`). One that could not
6919    // restate all (a file not readable now: the doses' record held empty) is
6920    // not (review of 10 October 2026, R7): it continues the round before,
6921    // whose rounds are kept; after lost lines, a full round follows once all
6922    // reads again (`Memory::owed`).
6923    let previous = memory.seq;
6924    memory.round += 1;
6925    memory.seq = 0;
6926    memory.grown = 0;
6927    if omitted == 0 {
6928        memory.full_round = memory.round;
6929        memory.continues = 0;
6930        memory.owed = false;
6931        (memory.owed_waited, memory.owed_last) = (0, 0);
6932    } else {
6933        memory.continues = if previous > 0 { previous } else { UNKNOWN_TURN };
6934        memory.owed |= cut;
6935    }
6936    append(sharing, memory, &ours)?;
6937    // What it restated, for when the next one opens (`full_round_after`).
6938    if omitted == 0 {
6939        memory.full_size = std::fs::metadata(round_file(sharing.folder, sharing.computer, memory.round)).map_or(0, |m| m.len());
6940    }
6941    Ok(())
6942}
6943
6944/// What this computer appends since its last full round before it opens the
6945/// next: as much as that round restated, and a megabyte at least
6946/// (`ROUND_SIZE`). A phone's full round restates its whole archive of texts
6947/// (16 MB on the owner's phone, 14 October 2026); opened at each megabyte
6948/// appended, as until 15 October 2026, the full rounds cost the folder and
6949/// the connection many times what was appended (eDrive sends each twice).
6950/// Now restating never costs more than what was appended since, and the
6951/// rounds a device joining reads stay within twice the last full round. Its
6952/// size, not known to a memory from before, is read once from that round's file.
6953fn full_round_after(sharing: &Sharing, memory: &mut Memory) -> u64 {
6954    if memory.full_size == 0 {
6955        let full = if memory.full_round == 0 { memory.round } else { memory.full_round };
6956        memory.full_size = std::fs::metadata(round_file(sharing.folder, sharing.computer, full)).map_or(0, |m| m.len());
6957    }
6958    ROUND_SIZE.max(memory.full_size)
6959}
6960
6961/// The sealed files this computer put in the folder (`blobs/<name>`), by
6962/// name, and the size it sealed each at: what it sends to the server itself
6963/// beside the sync app (`remote::send`), whole, and nothing else of `blobs/`.
6964/// Asked before each send: as this process last wrote or read `files.json`
6965/// while it is still that file (`MINE`), else from that part of the file
6966/// alone, the rest passed over, never built (on a phone, tens of thousands
6967/// of the texts' entries).
6968pub fn own_sealed(memory: &Path, computer: &str) -> BTreeMap<String, u64> {
6969    let path = sealed_path(memory);
6970    let stamp = stamp_at(&path);
6971    if let Some((whose, mine)) = MINE.lock().ok().and_then(|all| all.get(memory).filter(|(was, _, _)| stamp.is_some() && *was == stamp).map(|(_, whose, mine)| (whose.clone(), mine.clone()))) {
6972        return if whose == computer { mine } else { BTreeMap::new() };
6973    }
6974    #[derive(Deserialize)]
6975    struct Part {
6976        #[serde(default)]
6977        computer: String,
6978        #[serde(default)]
6979        mine: BTreeMap<String, (i64, u64)>,
6980    }
6981    let Some(read) = std::fs::read(&path).ok().and_then(|bytes| serde_json::from_slice::<Part>(&bytes).ok()) else { return BTreeMap::new() };
6982    let mine: BTreeMap<String, u64> = read.mine.into_iter().map(|(name, (_, size))| (name, size)).collect();
6983    if let Ok(mut all) = MINE.lock() {
6984        all.insert(memory.to_path_buf(), (stamp, read.computer.clone(), mine.clone()));
6985    }
6986    if read.computer == computer { mine } else { BTreeMap::new() }
6987}
6988
6989/// How far this computer wrote its records: its round and the number of the
6990/// last, for its claims to say (`lease::Claim::wrote`); none before sharing.
6991pub fn written(memory: &Path, computer: &str) -> Option<(u32, u64)> {
6992    let memory = Memory::load(memory, computer);
6993    memory.joined.then_some((memory.round.max(1), memory.seq))
6994}
6995
6996/// What this computer read of the others' records.
6997#[derive(Debug, Default, Clone)]
6998pub struct Heard {
6999    /// Each computer's last record read here: its round and number.
7000    pub read: BTreeMap<String, (u32, u64)>,
7001    /// When a line of each could not be read here (Unix seconds).
7002    pub broken: BTreeMap<String, i64>,
7003}
7004
7005impl Heard {
7006    /// Whether everything `computer` wrote up to `wrote` (its round and
7007    /// number, as its claim says) is read here. Nothing written yet (number 0)
7008    /// is all read; a round begun anew opens with everything it holds.
7009    pub fn complete(&self, computer: &str, wrote: (u32, u64)) -> bool {
7010        wrote.1 == 0 || self.read.get(computer).is_some_and(|read| *read >= wrote)
7011    }
7012}
7013
7014/// When each part last sent a change from here, and last received one (Unix seconds; 0 never).
7015pub fn traffic(memory: &Path, computer: &str) -> BTreeMap<String, (i64, i64)> {
7016    Memory::load(memory, computer).traffic
7017}
7018
7019pub fn heard(memory: &Path, computer: &str) -> Heard {
7020    let memory = Memory::load(memory, computer);
7021    let mut read = memory.read_n;
7022    // A device whose doses' records wait here, in a format this Sioul does
7023    // not read: not all it wrote is known here, whatever was read of its file.
7024    for (key, (_, _, writer, f)) in &memory.waiting {
7025        if known_part(file_of(key)) == Some("health") && *f > part_format("health") {
7026            read.remove(writer);
7027        }
7028    }
7029    Heard { read, broken: memory.broken }
7030}
7031
7032/// The entries of `files` read again from every computer's records, this
7033/// one's too, at the next exchange; this computer forgets it held them, so
7034/// that a record lost or broken here, started again empty, takes nothing out
7035/// elsewhere and gets back what was written (docs/database.md, "Prudence").
7036/// The files left as they are, what they hold may be what was there before:
7037/// at that exchange they join as a store new here does, read from the
7038/// records first, only what the records lack going out (`Memory::rejoin`),
7039/// so that an old mark never goes out again with a fresh clock (review of
7040/// 15 October 2026, RV-P2).
7041pub fn rebuild(memory: &Path, computer: &str, files: &[&str]) -> Result<(), String> {
7042    rebuild_with(memory, computer, files, true, || ())
7043}
7044
7045/// As `rebuild`, then `then`, which sets the files aside (the doses' repair:
7046/// `HealthState::set_aside`), run while the exchanges' lock is still held,
7047/// before any exchange can read those files again. Run apart, an exchange
7048/// between the two steps filled the lost record again from the records, the
7049/// full record was then set aside, and the next mark written made every other
7050/// mark look taken out, on every device (review of 15 October 2026, P1). Set
7051/// aside in that same step, the files hold afterwards only what was written
7052/// after the repair: it goes out as changes made here, never replaced by an
7053/// older record of the same entry (a dose marked again after it was taken
7054/// back elsewhere was lost on every device: RF-J).
7055pub fn rebuild_then<T>(memory: &Path, computer: &str, files: &[&str], then: impl FnOnce() -> T) -> Result<T, String> {
7056    rebuild_with(memory, computer, files, false, then)
7057}
7058
7059fn rebuild_with<T>(memory: &Path, computer: &str, files: &[&str], rejoin: bool, then: impl FnOnce() -> T) -> Result<T, String> {
7060    let _running = exchange_lock(memory, true)?;
7061    let mut state = Memory::load_all(memory, computer);
7062    state.entries.retain(|key, _| !files.contains(&file_of(key)));
7063    state.pending.retain(|key, _| !files.contains(&file_of(key)));
7064    for file in files {
7065        state.files.remove(*file);
7066        state.rebuild.insert((*file).to_string());
7067        if rejoin {
7068            state.rejoin.insert((*file).to_string());
7069        } else {
7070            state.rejoin.remove(*file);
7071        }
7072    }
7073    // The others' records read again from their oldest kept round.
7074    state.read.clear();
7075    state.save(memory)?;
7076    state.keep(memory);
7077    Ok(then())
7078}
7079
7080/// Notes or papers gone many at once from this device and held (`GONE_GRACE`),
7081/// by folder of the records ("files/notes/"): how many, when the first and
7082/// the last went and when the last leave your other devices by themselves
7083/// (milliseconds), unless they come back, and where they were, said
7084/// shortly (`bulk_folders`: the deepest folder holding four in five of them,
7085/// else the few that hold most, else the folder above them all).
7086#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
7087pub struct Withheld {
7088    pub store: String,
7089    pub count: usize,
7090    pub first: i64,
7091    pub last: i64,
7092    pub leaves: i64,
7093    #[serde(default, skip_serializing_if = "Vec::is_empty")]
7094    pub folders: Vec<String>,
7095}
7096
7097/// The files of a store held after going many at once, and since when
7098/// each (`Sealed::gone`), summed up: how many, the first and the last gone,
7099/// when the last leave, and where they were (`bulk_folders`).
7100fn withheld_of(store: &str, gone: &BTreeMap<String, i64>) -> Withheld {
7101    let mut sum = Withheld { store: store.to_string(), first: i64::MAX, ..Withheld::default() };
7102    for since in gone.values() {
7103        sum.count += 1;
7104        sum.first = sum.first.min(*since);
7105        sum.last = sum.last.max(*since);
7106    }
7107    sum.first = sum.first.min(sum.last);
7108    sum.leaves = sum.last + GONE_GRACE;
7109    sum.folders = bulk_folders(gone.keys().map(|file| file.strip_prefix(store).unwrap_or(file)));
7110    sum
7111}
7112
7113/// Where files gone many at once were, said shortly, by their place in the
7114/// store ("" for its own top): the deepest folder that holds four in five of
7115/// them; else the few folders below it, three at most, that hold four in
7116/// five of them together; else that folder, which holds them all. For
7117/// thousands of files in hundreds of folders (a tool's run folders), the
7118/// folder above them all, never three of them picked at random (review of
7119/// 17 October 2026, L5).
7120fn bulk_folders<'a>(files: impl Iterator<Item = &'a str>) -> Vec<String> {
7121    let mut under: BTreeMap<String, usize> = BTreeMap::new();
7122    let mut total = 0;
7123    for file in files {
7124        total += 1;
7125        let dirs: Vec<&str> = file.split('/').collect();
7126        for depth in 0..dirs.len() {
7127            *under.entry(dirs[..depth].join("/")).or_default() += 1;
7128        }
7129    }
7130    let children = |folder: &str| -> Vec<(usize, String)> {
7131        let mut out: Vec<(usize, String)> = under.iter().filter(|(child, _)| !child.is_empty() && (if folder.is_empty() { !child.contains('/') } else { child.strip_prefix(folder).and_then(|rest| rest.strip_prefix('/')).is_some_and(|rest| !rest.contains('/')) })).map(|(child, n)| (*n, child.clone())).collect();
7132        out.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1)));
7133        out
7134    };
7135    let most = |n: usize| n * 5 >= total * 4;
7136    let mut at = String::new();
7137    loop {
7138        match children(&at).first() {
7139            Some((n, child)) if most(*n) => at = child.clone(),
7140            _ => break,
7141        }
7142    }
7143    let few = children(&at);
7144    let mut held = 0;
7145    let mut named = Vec::new();
7146    for (n, child) in few.into_iter().take(3) {
7147        held += n;
7148        named.push(child);
7149        if most(held) {
7150            return named;
7151        }
7152    }
7153    vec![at]
7154}
7155
7156/// The notes and papers gone many at once from this device and held, as its
7157/// last exchange found them (`Memory::withheld`): for the Porch to say once,
7158/// with "Put them back" and "Take them out now"; nothing has to be pressed.
7159/// Empty when the memory is not this computer's.
7160pub fn withheld(memory: &Path, computer: &str) -> Vec<Withheld> {
7161    #[derive(Deserialize)]
7162    struct Part {
7163        #[serde(default)]
7164        computer: String,
7165        #[serde(default)]
7166        withheld: Vec<Withheld>,
7167    }
7168    std::fs::read(memory).ok().and_then(|bytes| serde_json::from_slice::<Part>(&bytes).ok()).filter(|part| part.computer == computer).map(|part| part.withheld).unwrap_or_default()
7169}
7170
7171/// The notes or papers gone many at once from a folder of the records
7172/// ("files/notes/") and held (`Sealed::bulk`, as the Porch counts them:
7173/// never a note you deleted on purpose beside a bulk's remainder, as Take
7174/// them out now takes those alone: review of 18 October 2026, I-1), put
7175/// back where they were, each from its content sealed in the folder (or
7176/// fetched beside it), dated as it was last seen here, or, held before this
7177/// was kept (Sioul up to 17 October 2026), as its last record was made
7178/// (within a minute of the change, on a computer in use; at its first
7179/// exchange, for a file older than the sharing): the next exchange finds
7180/// them back, and nothing leaves. Never through a link, nor into the
7181/// sharing folder or Sioul's state, nor over a file there, one written
7182/// there meanwhile included; never without room for it and some left (as
7183/// every write of the sharing, `disk::fits`). A quarter of a second's worth
7184/// at a time, each file opened under a hidden name in the deepest of its
7185/// folders that is there, outside the exchanges' lock, which is taken only
7186/// to make its own folders, date it and place it, after a last look under
7187/// that hold, a fifth of a second at a time (`in_short_holds`), then left
7188/// free a moment: a dose's exchange never waits for more than a fifth of a
7189/// second of placing them and the one file placed as it passes, even behind
7190/// a note or paper of tens of megabytes, even on storage that takes tens of
7191/// milliseconds a file (thousands take half a minute: reviews of 17 and 18
7192/// October 2026, L1, L-3 and L-a; CI's Windows job of 20 October 2026). No
7193/// exchange meets a folder made for them before its note is in it: holding
7194/// hidden files alone, it would list empty, taken for a folder emptied, and
7195/// its notes would leave the hold, for good after a stop meanwhile (L-1); a
7196/// stop leaves them held, and Put them back again brings them. Each is
7197/// dated as it was only under the lock, just before it is placed: an
7198/// exchange's sweep of leftovers takes a hidden file dated more than an
7199/// hour ago, and none runs while the lock is held (M-1). How many came
7200/// back, of how many were held (`PutBack`); a disk too full stops it, said.
7201pub fn put_back_withheld(folder: &Path, key: &[u8; 32], memory: &Path, computer: &str, stores: &[Store], store: &str) -> Result<PutBack, String> {
7202    // What there is to put back, and its date: read once; how many are held from that folder.
7203    let (held, count) = {
7204        let _running = exchange_lock(memory, true)?;
7205        let state = Memory::load_all(memory, computer);
7206        let gone: Vec<&String> = state.sealed.gone.keys().filter(|file| file.starts_with(store) && state.sealed.bulk.contains(*file)).collect();
7207        let held: Vec<(String, String, std::time::SystemTime, Option<u64>)> = gone
7208            .iter()
7209            .copied()
7210            .filter_map(|file| {
7211                let known = state.entries.get(&format!("{file}#")).filter(|known| !known.h.is_empty())?;
7212                let when = match state.files.get(file) {
7213                    Some(stat) => std::time::UNIX_EPOCH + std::time::Duration::from_nanos(stat.modified),
7214                    None => std::time::UNIX_EPOCH + std::time::Duration::from_millis(known.c >> 16),
7215                };
7216                Some((file.clone(), known.h.clone(), when, state.files.get(file).map(|stat| stat.size)))
7217            })
7218            .collect();
7219        (held, gone.len())
7220    };
7221    let mut back = 0;
7222    let mut rest = held.as_slice();
7223    while !rest.is_empty() {
7224        // A batch opened outside the lock: a quarter of a second's worth,
7225        // never a whole bulk (review of 18 October 2026, L-3).
7226        let (started, mut done) = (std::time::Instant::now(), 0);
7227        let mut ready: Vec<(std::path::PathBuf, std::path::PathBuf, &Store, std::time::SystemTime, &String)> = Vec::new();
7228        let mut stop = None;
7229        for (file, h, when, size) in rest {
7230            if done >= PUT_BACK_BATCH || started.elapsed() >= PUT_BACK_HOLD {
7231                break;
7232            }
7233            done += 1;
7234            let Some((store, path)) = locate(stores, file) else { continue };
7235            if std::fs::symlink_metadata(&path).is_ok() || !writable_in(store, &path, Some(folder), memory) {
7236                continue;
7237            }
7238            // Opened in the deepest of its folders that is there, never in one made now (L-1).
7239            let Some(name) = path.file_name() else { continue };
7240            let Some(there) = path.ancestors().skip(1).find(|dir| dir.is_dir()).filter(|dir| dir.starts_with(&store.path)) else { continue };
7241            // Its size as last seen here; else four times its sealed size (compressed).
7242            let size = size.unwrap_or_else(|| std::fs::metadata(crate::blobs::path(folder, key, h)).map_or(0, |m| m.len()).saturating_mul(4));
7243            if !crate::disk::fits(there, size) {
7244                stop = Some(format!("share-no-room:{file}"));
7245                break;
7246            }
7247            // Dated now while it waits: the exchanges' sweep leaves a leftover alone its first hour.
7248            let opened = temporary(&there.join(name));
7249            if crate::blobs::get(folder, key, h, &opened).is_err() {
7250                let _ = std::fs::remove_file(&opened);
7251                continue;
7252            }
7253            ready.push((opened, path, store, *when, h));
7254        }
7255        rest = &rest[done..];
7256        // The lock taken only to place them, each after a last look under the
7257        // hold that places it: never over a file written meanwhile, never
7258        // through a link (review of 18 October 2026, L-a); a fifth of a second
7259        // at a time, then left free a moment (`in_short_holds`): on slow
7260        // storage, placing a whole batch passed a second, and a dose's
7261        // exchange waited for it (1.1 s on CI's Windows job of 20 October 2026).
7262        let placed = in_short_holds(memory, ready.into_iter(), |(opened, path, store, when, h)| {
7263            if place_put_back(folder, key, memory, &opened, &path, store, when, h, &mut stop) {
7264                back += 1;
7265            }
7266        });
7267        if let Err((e, left)) = placed {
7268            for (opened, ..) in left {
7269                let _ = std::fs::remove_file(opened);
7270            }
7271            return Err(e);
7272        }
7273        if let Some(said) = stop {
7274            return Err(said);
7275        }
7276        if !rest.is_empty() {
7277            std::thread::sleep(PUT_BACK_FREE);
7278        }
7279    }
7280    Ok(PutBack { back, held: count })
7281}
7282
7283/// One file of "Put them back" placed, under the exchanges' lock (its
7284/// caller's hold): after a last look (nothing at its place, a link
7285/// included, and still a place the sharing writes), in its own folders made
7286/// only now (L-1), dated as it was only now (M-1), through a hard link that
7287/// takes only a free name. Whether it came back; the file opened beside goes
7288/// in any case. A folder that cannot be made stops the put back, said.
7289#[allow(clippy::too_many_arguments)]
7290fn place_put_back(folder: &Path, key: &[u8; 32], memory: &Path, opened: &Path, path: &Path, store: &Store, when: std::time::SystemTime, h: &str, stop: &mut Option<String>) -> bool {
7291    slow_under_lock();
7292    if std::fs::symlink_metadata(path).is_ok() || !writable_in(store, path, Some(folder), memory) {
7293        let _ = std::fs::remove_file(opened);
7294        return false;
7295    }
7296    // Its own folders made only now, its note placed in them before any exchange lists them (L-1).
7297    let Some(parent) = path.parent() else {
7298        let _ = std::fs::remove_file(opened);
7299        return false;
7300    };
7301    if let Err(e) = private_dirs(parent) {
7302        let _ = std::fs::remove_file(opened);
7303        stop.get_or_insert_with(|| format!("{}: {e}", parent.display()));
7304        return false;
7305    }
7306    // Into its own folder: a rename on one disk; across disks (one
7307    // mounted below), opened there again, as before 18 October 2026.
7308    let beside = if opened.parent() == Some(parent) {
7309        opened.to_path_buf()
7310    } else {
7311        let beside = temporary(path);
7312        if std::fs::rename(opened, &beside).is_err() {
7313            let _ = std::fs::remove_file(opened);
7314            if crate::blobs::get(folder, key, h, &beside).is_err() {
7315                let _ = std::fs::remove_file(&beside);
7316                return false;
7317            }
7318        }
7319        beside
7320    };
7321    // Dated as it was only now, under the lock: no exchange's sweep runs meanwhile (M-1);
7322    // through a handle that cannot write it, which no antivirus reads at its close (`set_time`).
7323    let _ = set_time(&beside, when);
7324    place_new(&beside, path)
7325}
7326
7327/// `each` done for every one of `items` under the exchanges' lock, a fifth
7328/// of a second at a time (`SHORT_HOLD`), the lock then left free a moment
7329/// (`PUT_BACK_FREE`) for whatever exchange waits for it, a dose's first, and
7330/// taken again for the rest: work that grows with a count of files never
7331/// keeps an exchange waiting for its whole. An exchange may run between two
7332/// holds: `each` looks again at what it does under its own hold. A hold
7333/// passes its fifth of a second by the one item it does then, at most, and
7334/// ends after the item it does when an exchange of this process comes to wait
7335/// (`waiting_here`): a dose's alarm waits for one item, not the hold's rest.
7336/// The lock that cannot be taken stops it: why, and the items left.
7337fn in_short_holds<I: Iterator>(memory: &Path, items: I, mut each: impl FnMut(I::Item)) -> Result<(), (String, std::iter::Peekable<I>)> {
7338    let mut items = items.peekable();
7339    while items.peek().is_some() {
7340        let running = match exchange_lock(memory, true) {
7341            Ok(running) => running,
7342            Err(e) => return Err((e, items)),
7343        };
7344        let held = std::time::Instant::now();
7345        while let Some(item) = items.next() {
7346            each(item);
7347            if held.elapsed() >= SHORT_HOLD || waiting_here(memory) {
7348                break;
7349            }
7350        }
7351        // Handed to an exchange waiting, before it is taken again (`hand_off`).
7352        if items.peek().is_some() {
7353            hand_off(memory, running, PUT_BACK_FREE);
7354        } else {
7355            drop(running);
7356        }
7357    }
7358    Ok(())
7359}
7360
7361/// How many times "Remove its files" plans what goes outside the lock, an
7362/// exchange having written the memory meanwhile each time, before it says busy.
7363const REMOVAL_PLANS: u32 = 5;
7364
7365#[cfg(test)]
7366thread_local! {
7367    /// Tests only: what runs on this thread after each plan of a removal (a
7368    /// slow plan, an exchange writing the memory meanwhile). Never in a build.
7369    pub(crate) static AFTER_PLAN: std::cell::RefCell<Option<Box<dyn FnMut()>>> = const { std::cell::RefCell::new(None) };
7370}
7371
7372#[cfg(test)]
7373thread_local! {
7374    /// Tests only: what runs on this thread after each copy a put back makes
7375    /// ahead of the lock (the file written meanwhile). Never in a build.
7376    pub(crate) static AFTER_PREPARE: std::cell::RefCell<Option<Box<dyn FnMut()>>> = const { std::cell::RefCell::new(None) };
7377}
7378
7379/// After each copy made ahead of the lock, what a test asks (`AFTER_PREPARE`); nothing in a build.
7380fn after_prepare() {
7381    #[cfg(test)]
7382    AFTER_PREPARE.with(|hook| {
7383        if let Some(hook) = hook.borrow_mut().as_mut() {
7384            hook();
7385        }
7386    });
7387}
7388
7389/// After each plan of a removal, what a test asks (`AFTER_PLAN`); nothing in a build.
7390fn after_plan() {
7391    #[cfg(test)]
7392    AFTER_PLAN.with(|hook| {
7393        if let Some(hook) = hook.borrow_mut().as_mut() {
7394            hook();
7395        }
7396    });
7397}
7398
7399/// The longest one hold of the exchanges' lock does work that grows with a
7400/// count of files (`in_short_holds`): placing notes put back, taking a
7401/// device's files out. The copies beside drafts keep the same bound
7402/// (`sioul_core::draftcopies`, `HOLD_AT_MOST`).
7403const SHORT_HOLD: std::time::Duration = std::time::Duration::from_millis(200);
7404
7405#[cfg(test)]
7406thread_local! {
7407    /// Tests only: how long each file done under the lock in short holds
7408    /// takes more on this thread, for slow storage (Windows' scans, a
7409    /// phone's shared storage). Never in a build of the programs.
7410    pub(crate) static SLOW_UNDER_LOCK: std::cell::Cell<std::time::Duration> = const { std::cell::Cell::new(std::time::Duration::ZERO) };
7411}
7412
7413/// Each file done under the lock in short holds, as slow as a test asks (`SLOW_UNDER_LOCK`); nothing in a build.
7414fn slow_under_lock() {
7415    #[cfg(test)]
7416    std::thread::sleep(SLOW_UNDER_LOCK.with(std::cell::Cell::get));
7417}
7418
7419/// What "Put them back" did (`put_back_withheld`): how many came back, of
7420/// how many were held from that folder (as the Porch counts them). Fewer is
7421/// said as such, never as done: other files or links are at their places,
7422/// or their contents are not on this device (review of 18 October 2026, M-1).
7423#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7424pub struct PutBack {
7425    /// Written back where they were.
7426    pub back: usize,
7427    /// Held from that folder when it began.
7428    pub held: usize,
7429}
7430
7431/// How many held files are opened at once, outside the exchanges' lock, at
7432/// most, and for how long at most (`PUT_BACK_HOLD`); they are then placed
7433/// under it in short holds (`in_short_holds`), the lock left free a while
7434/// between two (`PUT_BACK_FREE`), for whatever exchange waits for it, a
7435/// dose's first. Until 18 October 2026 the put back took it again at once,
7436/// and a dose's exchange waited up to 14 s for it on a computer; until 20
7437/// October 2026 it placed a whole batch in one hold, 1.1 s on CI's Windows job.
7438const PUT_BACK_BATCH: usize = 200;
7439const PUT_BACK_HOLD: std::time::Duration = std::time::Duration::from_millis(250);
7440const PUT_BACK_FREE: std::time::Duration = std::time::Duration::from_millis(25);
7441
7442/// A file written beside its place taken there only while none is (a hard
7443/// link refuses to take an existing name; where links cannot be made, a
7444/// last look, then a rename). Whether it took the place; the file beside
7445/// goes in any case.
7446fn place_new(temporary: &Path, path: &Path) -> bool {
7447    let placed = match std::fs::hard_link(temporary, path) {
7448        Ok(()) => true,
7449        Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => false,
7450        Err(_) => std::fs::symlink_metadata(path).is_err() && std::fs::rename(temporary, path).is_ok(),
7451    };
7452    let _ = std::fs::remove_file(temporary);
7453    placed
7454}
7455
7456/// The files gone at once from a folder of notes or papers (`share-vanished`)
7457/// taken out everywhere at the next exchange, as you said: the folder by its
7458/// name in the records ("files/notes/disk/", or a whole store,
7459/// "files/notes/"), what is below it too, never a folder beside it; or a
7460/// note replaced by a link ("files/notes/single.md": `confirms`).
7461pub fn confirm_gone(memory: &Path, computer: &str, store: &str) -> Result<(), String> {
7462    let _running = exchange_lock(memory, true)?;
7463    let mut state = Memory::load_all(memory, computer);
7464    state.sealed.confirmed.insert(store.to_string());
7465    state.save(memory)?;
7466    state.keep(memory);
7467    Ok(())
7468}
7469
7470/// "Take them out now": the files held after going many at once from a
7471/// folder of the records ("files/notes/") taken out everywhere at the next
7472/// exchange, those alone (`Sealed::confirmed_files`): a folder that lists
7473/// empty beside them (a disk not mounted at its place in the notes) stays
7474/// unknown until its own line says so (`confirm_gone`; review of 17 October
7475/// 2026, M1). How many.
7476pub fn confirm_withheld(memory: &Path, computer: &str, store: &str) -> Result<usize, String> {
7477    let _running = exchange_lock(memory, true)?;
7478    let mut state = Memory::load_all(memory, computer);
7479    let held: Vec<String> = state.sealed.gone.keys().filter(|file| file.starts_with(store) && state.sealed.bulk.contains(*file)).cloned().collect();
7480    let count = held.len();
7481    state.sealed.confirmed_files.extend(held);
7482    state.save(memory)?;
7483    state.keep(memory);
7484    Ok(count)
7485}
7486
7487/// One exchange at a time on this computer, whatever runs it (two Sioul
7488/// started, the command line): a lock on `<state>/share/exchange.lock`, held
7489/// while it runs, let go when the file returned is. `wait`: until the
7490/// running one ends; else none while another holds it a second on.
7491pub fn exchange_lock(memory: &Path, wait: bool) -> Result<Option<std::fs::File>, String> {
7492    let path = memory.with_file_name("exchange.lock");
7493    let fail = |e: std::io::Error| format!("{}: {e}", path.display());
7494    if let Some(parent) = path.parent() {
7495        private_dirs(parent).map_err(fail)?;
7496    }
7497    let file = std::fs::OpenOptions::new().create(true).truncate(false).write(true).open(&path).map_err(fail)?;
7498    // Counted while it waits, and once it holds, for a holder letting go in short holds (`hand_off`).
7499    let _waiting = Waiting::start(&path);
7500    if wait {
7501        lock_waiting(&path, &file).map_err(fail)?;
7502        _waiting.taken();
7503        return Ok(Some(file));
7504    }
7505    // A program starting elsewhere in Sioul holds a copy of every file open
7506    // then until it runs (a Mac's take milliseconds): the last exchange's lock,
7507    // let go a moment before, may still be held by it. Tried again for a second.
7508    for _ in 0..50 {
7509        match file.try_lock() {
7510            Ok(()) => {
7511                _waiting.taken();
7512                return Ok(Some(file));
7513            }
7514            Err(std::fs::TryLockError::WouldBlock) => std::thread::sleep(std::time::Duration::from_millis(20)),
7515            Err(std::fs::TryLockError::Error(e)) => return Err(fail(e)),
7516        }
7517    }
7518    Ok(None)
7519}
7520
7521/// By the exchanges' lock file, in this process: how many wait for it now,
7522/// and how many times one that waited took it (`exchange_lock`).
7523static WAITERS: std::sync::Mutex<BTreeMap<PathBuf, (usize, u64)>> = std::sync::Mutex::new(BTreeMap::new());
7524
7525/// One exchange waiting for the lock, counted until it holds it or gives up.
7526struct Waiting(PathBuf);
7527
7528impl Waiting {
7529    fn start(path: &Path) -> Waiting {
7530        if let Ok(mut all) = WAITERS.lock() {
7531            all.entry(path.to_path_buf()).or_default().0 += 1;
7532        }
7533        Waiting(path.to_path_buf())
7534    }
7535
7536    fn taken(&self) {
7537        if let Ok(mut all) = WAITERS.lock() {
7538            all.entry(self.0.clone()).or_default().1 += 1;
7539        }
7540    }
7541}
7542
7543impl Drop for Waiting {
7544    fn drop(&mut self) {
7545        if let Ok(mut all) = WAITERS.lock()
7546            && let Some(counts) = all.get_mut(&self.0)
7547        {
7548            counts.0 = counts.0.saturating_sub(1);
7549        }
7550    }
7551}
7552
7553/// Whether an exchange of this process waits for the lock beside `memory` now (`WAITERS`).
7554fn waiting_here(memory: &Path) -> bool {
7555    let path = memory.with_file_name("exchange.lock");
7556    WAITERS.lock().ok().and_then(|all| all.get(&path).map(|c| c.0 > 0)).unwrap_or(false)
7557}
7558
7559/// The longest a holder letting the lock go waits for an exchange of this
7560/// process to take it before taking it again (`hand_off`).
7561const HAND_OFF_MOST: std::time::Duration = std::time::Duration::from_secs(1);
7562
7563/// The exchanges' lock let go between two short holds (`in_short_holds`,
7564/// the copies beside drafts, "Remove its files" planning again): when an
7565/// exchange of this process waits for it (a dose's alarm, the window's), the
7566/// holder takes it again only once that one has taken it, or a second has
7567/// passed, so that a waiter slow to wake (a loaded machine, a busy phone)
7568/// never loses the hand to it again and again (CI's macOS job of 20 October
7569/// 2026: two holds back to back, 0.41 s). It stops waiting too as soon as
7570/// fewer wait than when it let go: a waiter that gave up, or that lost the
7571/// lock to another process, never keeps it a second (review of part 4, D-1a).
7572/// With none waiting here, a moment (`free`: `PUT_BACK_FREE` between short
7573/// holds; none for the copies beside drafts, which let go `LET_GO` between
7574/// their batches and nothing after the last, D-1b): a waiter in another
7575/// process (the command line, a phone's background service) gets that moment
7576/// alone, as before, since a file's lock keeps no queue.
7577pub(crate) fn hand_off(memory: &Path, running: Option<std::fs::File>, free: std::time::Duration) {
7578    let path = memory.with_file_name("exchange.lock");
7579    let (waiting, taken) = WAITERS.lock().ok().and_then(|all| all.get(&path).copied()).unwrap_or((0, 0));
7580    drop(running);
7581    #[cfg(test)]
7582    if let Ok(mut released) = RELEASED.lock() {
7583        released.insert(path.clone(), std::time::Instant::now());
7584    }
7585    if waiting == 0 {
7586        std::thread::sleep(free);
7587        return;
7588    }
7589    let since = std::time::Instant::now();
7590    while since.elapsed() < HAND_OFF_MOST && WAITERS.lock().ok().and_then(|all| all.get(&path).copied()).is_some_and(|(now_waiting, now_taken)| now_taken == taken && now_waiting >= waiting) {
7591        std::thread::sleep(std::time::Duration::from_millis(1));
7592    }
7593}
7594
7595#[cfg(test)]
7596thread_local! {
7597    /// Tests only: an exchange waiting on this thread that takes the lock
7598    /// only so long after it was last let go between two short holds (a
7599    /// loaded machine, whose woken thread runs late). Never in a build.
7600    pub(crate) static SLOW_WAKE: std::cell::Cell<std::time::Duration> = const { std::cell::Cell::new(std::time::Duration::ZERO) };
7601}
7602
7603/// Tests only: when each lock was last let go between two short holds
7604/// (`hand_off`), by its file: another test's lock never delays this one's.
7605#[cfg(test)]
7606static RELEASED: std::sync::Mutex<BTreeMap<PathBuf, std::time::Instant>> = std::sync::Mutex::new(BTreeMap::new());
7607
7608/// The lock taken, waiting for it: the system's wait (as slow to wake as a
7609/// test asks, `SLOW_WAKE`).
7610fn lock_waiting(#[cfg_attr(not(test), allow(unused_variables))] path: &Path, file: &std::fs::File) -> std::io::Result<()> {
7611    #[cfg(test)]
7612    {
7613        let slow = SLOW_WAKE.with(std::cell::Cell::get);
7614        if !slow.is_zero() {
7615            loop {
7616                // Never before `slow` has passed since the lock was last let go: it wakes that late.
7617                let since = RELEASED.lock().ok().and_then(|r| r.get(path).copied()).map(|r| r.elapsed());
7618                if let Some(since) = since.filter(|s| *s < slow) {
7619                    std::thread::sleep(slow - since);
7620                    continue;
7621                }
7622                match file.try_lock() {
7623                    Ok(()) => return Ok(()),
7624                    Err(std::fs::TryLockError::WouldBlock) => std::thread::sleep(std::time::Duration::from_millis(1)),
7625                    Err(std::fs::TryLockError::Error(e)) => return Err(e),
7626                }
7627            }
7628        }
7629    }
7630    file.lock()
7631}
7632
7633fn seen_path(folder: &Path, computer: &str) -> PathBuf {
7634    folder.join(format!("{computer}.toml"))
7635}
7636
7637/// This computer's notes in the folder, when what they say changes (its
7638/// round, how far it read the others); else restated a quarter of an hour
7639/// after the last while this device is in use (its entry says it: another
7640/// device's do-not-disturb hears of a computer in use by them), once a day
7641/// while it is put away (a phone's background step: how far it read changes
7642/// rarely, and the sync is not asked to carry a file every few minutes).
7643/// Returns when they are due again (milliseconds).
7644fn write_seen(sharing: &Sharing, memory: &Memory, all: &BTreeMap<String, Vec<u32>>, now_ms: i64) -> i64 {
7645    let again = if crate::devices::in_use(sharing.memory) { 15 * 60 } else { 24 * 3600 };
7646    let mut seen = Seen {
7647        at: now_ms / 1000,
7648        round: memory.round,
7649        read: memory.read.iter().filter(|(c, _)| all.contains_key(*c)).map(|(c, (round, _))| (c.clone(), *round)).collect(),
7650        full: full_asked(memory, all),
7651        pad: String::new(),
7652    };
7653    if let Some(old) = read_seen_in(sharing.folder, sharing.computer).filter(|old| old.round == seen.round && old.read == seen.read && old.full == seen.full && seen.at - old.at < again) {
7654        return (old.at + again) * 1000;
7655    }
7656    let path = seen_path(sharing.folder, sharing.computer);
7657    let text = |seen: &Seen, pad: usize| toml::to_string(&Seen { pad: ".".repeat(pad), read: seen.read.clone(), full: seen.full.clone(), ..*seen }).unwrap_or_default();
7658    seen.pad = ".".repeat(pad_for(&path, |pad| text(&seen, pad).len() as u64));
7659    if let Ok(text) = toml::to_string(&seen) {
7660        let _ = write_atomically(&path, text.as_bytes());
7661    }
7662    (seen.at + again) * 1000
7663}
7664
7665/// The other computers whose records this one met past what it read in
7666/// order (`Memory::met` ahead of `read_n`: a line lost, a round passed over
7667/// as lost, a device first read from a round continuing one gone), each with
7668/// the latest round of theirs met: a full round of theirs after it tells
7669/// where they stand (`Seen::full`).
7670fn full_asked(memory: &Memory, all: &BTreeMap<String, Vec<u32>>) -> BTreeMap<String, u32> {
7671    memory.met.iter().filter(|(computer, met)| all.contains_key(*computer) && memory.read_n.get(*computer) != Some(*met)).map(|(computer, (round, _))| (computer.clone(), *round)).collect()
7672}
7673
7674/// Whether another computer heard lately asks this one for a full round
7675/// after a round of its own that no full round followed yet (`Seen::full`):
7676/// a round this computer wrote, at or after its last full one.
7677fn asked_for_full(sharing: &Sharing, memory: &Memory, now_ms: i64) -> bool {
7678    let last_full = memory.full_round.max(1);
7679    computers(sharing.folder)
7680        .iter()
7681        .filter(|c| *c != sharing.computer)
7682        .filter_map(|c| read_seen(sharing.folder, c))
7683        .filter(|s| now_ms / 1000 - s.at < SILENT_DAYS * 86_400)
7684        .any(|s| s.full.get(sharing.computer).is_some_and(|round| (last_full..=memory.round).contains(round)))
7685}
7686
7687fn read_seen_in(folder: &Path, computer: &str) -> Option<Seen> {
7688    read_small(&seen_path(folder, computer)).and_then(|t| toml::from_str(&t).ok())
7689}
7690
7691/// Another computer's notes, the later of the folder's and the copy fetched
7692/// from the server (`remote::overlay`); the folder's on a tie.
7693fn read_seen(folder: &Path, computer: &str) -> Option<Seen> {
7694    let here = read_seen_in(folder, computer);
7695    match (here, crate::remote::overlay(folder).and_then(|fetched| read_seen_in(&fetched, computer))) {
7696        (Some(here), Some(fetched)) => Some(if fetched.at > here.at { fetched } else { here }),
7697        (here, fetched) => here.or(fetched),
7698    }
7699}
7700
7701/// This computer's old files, once every device that counts has read past
7702/// them (`readers`), and the past rounds of the devices that no longer count
7703/// by a sealed fact (`Memory::departed`), here and in the copy fetched from
7704/// the server beside (`remote::overlay`): never fetched again, and taken out
7705/// of the server by the sending (`remote`).
7706fn remove_old_rounds(sharing: &Sharing, memory: &Memory, readers: &BTreeSet<String>) {
7707    let all = rounds(sharing.folder);
7708    let below = past_below(sharing.folder, sharing.computer, memory.round, memory.full_round, readers);
7709    for round in all.get(sharing.computer).into_iter().flatten().filter(|r| **r < below) {
7710        let _ = std::fs::remove_file(round_file(sharing.folder, sharing.computer, *round));
7711    }
7712    if memory.departed.is_empty() {
7713        return;
7714    }
7715    for dir in std::iter::once(sharing.folder.to_path_buf()).chain(crate::remote::overlay(sharing.folder)) {
7716        let theirs = if dir == sharing.folder { all.clone() } else { rounds(&dir) };
7717        for (id, floor) in &memory.departed {
7718            for round in theirs.get(id).into_iter().flatten().filter(|r| **r < *floor) {
7719                let _ = std::fs::remove_file(round_file(&dir, id, *round));
7720            }
7721        }
7722    }
7723}
7724
7725/// The round below which a computer's rounds are past their time: every
7726/// reader (`readers`) read past them (its notes say a later round: it met
7727/// their end, `Memory::met`, or read on from a later full round; a reader
7728/// without notes in the folder read none), and never its last full round nor
7729/// the rounds continuing it: a device joining reads from the oldest kept, and
7730/// finds every entry there (`share::rounds_tests`). A device forgotten that
7731/// comes back counts again at once, its notes with it, and reads from the
7732/// oldest kept the same way.
7733fn past_below(folder: &Path, computer: &str, round: u32, full_round: u32, readers: &BTreeSet<String>) -> u32 {
7734    let read_up_to = readers.iter().filter(|r| *r != computer).map(|r| read_seen(folder, r).and_then(|s| s.read.get(computer).copied()).unwrap_or(0)).min().unwrap_or(round);
7735    let full = if full_round == 0 { round } else { full_round };
7736    full.min(read_up_to)
7737}
7738
7739/// The readers as the rule was until 16 October 2026: every other computer
7740/// whose plain notes were heard within `SILENT_DAYS`. For a memory that does
7741/// not say who counts yet (`Memory::counting`: one saved by an older Sioul,
7742/// before its first exchange since).
7743fn readers_by_notes(folder: &Path, computer: &str, now_ms: i64) -> BTreeSet<String> {
7744    computers(folder).into_iter().filter(|c| c != computer).filter(|c| read_seen(folder, c).is_some_and(|s| now_ms / 1000 - s.at < SILENT_DAYS * 86_400)).collect()
7745}
7746
7747/// This computer's rounds below the one returned are past their time, as
7748/// `remove_old_rounds` judges them from its memory as last saved and the
7749/// others' notes: its sending takes those still on the server out of it
7750/// (`remote`), where a sync app left them (eDrive passes no deletion on).
7751/// Who counts comes from the memory, as the last exchange found it by sealed
7752/// facts (`Memory::counting`, `Memory::uncounted`: the sending has no key);
7753/// a file of notes alone that it did not know of holds back as before, by its
7754/// notes; a memory that does not say: the rule as it was (`readers_by_notes`).
7755/// None when the memory does not read, or is not this computer's.
7756pub fn rounds_past(folder: &Path, memory: &Path, computer: &str, now_ms: i64) -> Option<u32> {
7757    #[derive(Deserialize)]
7758    struct Part {
7759        #[serde(default)]
7760        computer: String,
7761        #[serde(default)]
7762        round: u32,
7763        #[serde(default)]
7764        full_round: u32,
7765        #[serde(default)]
7766        counting: Option<BTreeSet<String>>,
7767        #[serde(default)]
7768        uncounted: BTreeSet<String>,
7769    }
7770    let part = std::fs::read(memory).ok().and_then(|bytes| serde_json::from_slice::<Part>(&bytes).ok())?;
7771    if part.computer != computer || part.round == 0 {
7772        return None;
7773    }
7774    let readers = match part.counting {
7775        Some(counting) => {
7776            let strays: Vec<String> = readers_by_notes(folder, computer, now_ms).into_iter().filter(|id| !counting.contains(id) && !part.uncounted.contains(id)).collect();
7777            counting.into_iter().chain(strays).collect()
7778        }
7779        None => readers_by_notes(folder, computer, now_ms),
7780    };
7781    Some(past_below(folder, computer, part.round, part.full_round, &readers))
7782}
7783
7784/// The past rounds of the devices that no longer count by a sealed fact
7785/// (`Counted::departed`: they left, you forgot them, another device replaces
7786/// them), each device's below the round returned: below its last full round
7787/// found in the folder (`Turn::full` on its first line, sealed: a hostile
7788/// folder can hide a later one, never show one that is not), never that round
7789/// nor those continuing it, and only those every reader read past
7790/// (`readers`, this device's own reading among them). Such a device never
7791/// takes its own out again (a phone dead for good): any device that counts
7792/// does, each in its own folder, and its sending on the server over the ETag
7793/// listed there; two at once find it gone (`404`) or changed (`412`), and
7794/// leave it. A device with no full round found (an older Sioul's rounds say
7795/// none) keeps every round. A device forgotten that says anything newer
7796/// counts again at once (`counted`), and its rounds are its own again.
7797fn departed_rounds(sharing: &Sharing, memory: &Memory, counted: &[(String, Counted)], readers: &BTreeSet<String>) -> BTreeMap<String, u32> {
7798    let all = rounds_with(sharing.folder);
7799    let mut out = BTreeMap::new();
7800    for (id, _) in counted.iter().filter(|(_, counted)| counted.departed()) {
7801        let Some(theirs) = all.get(id).filter(|rounds| !rounds.is_empty()) else { continue };
7802        let Some(full) = last_full(sharing, id, theirs) else { continue };
7803        let own = memory.read.get(id).map_or(0, |(round, _)| *round);
7804        let others = readers.iter().filter(|r| *r != id).map(|r| read_seen(sharing.folder, r).and_then(|s| s.read.get(id).copied()).unwrap_or(0));
7805        let floor = std::iter::once(full).chain(std::iter::once(own)).chain(others).min().unwrap_or(0);
7806        out.insert(id.clone(), floor);
7807    }
7808    out
7809}
7810
7811/// What the sending and the pull ask of the memory without the key
7812/// (`remote`), as the last exchange left it: each device that no longer
7813/// counts by a sealed fact, and the round below which its rounds are past
7814/// their time here (`Memory::departed`): never fetched again, taken out of
7815/// the server at a listing. Empty when the memory is not this computer's.
7816pub fn departed_past(memory: &Path, computer: &str) -> BTreeMap<String, u32> {
7817    #[derive(Deserialize)]
7818    struct Part {
7819        #[serde(default)]
7820        computer: String,
7821        #[serde(default)]
7822        departed: BTreeMap<String, u32>,
7823    }
7824    std::fs::read(memory).ok().and_then(|bytes| serde_json::from_slice::<Part>(&bytes).ok()).filter(|part| part.computer == computer).map(|part| part.departed).unwrap_or_default()
7825}
7826
7827/// The sealed files this device swept as written by a device that no longer
7828/// counts (`Memory::taken`, `sweep_theirs`), by name, and when (milliseconds):
7829/// the sending takes each out of the server too, when it is still as old
7830/// there, and the pull of Sioul keeping the folder itself never brings it
7831/// again. Empty when the memory is not this computer's.
7832pub fn swept_theirs(memory: &Path, computer: &str) -> BTreeMap<String, i64> {
7833    #[derive(Deserialize)]
7834    struct Part {
7835        #[serde(default)]
7836        computer: String,
7837        #[serde(default)]
7838        taken: BTreeMap<String, i64>,
7839    }
7840    std::fs::read(memory).ok().and_then(|bytes| serde_json::from_slice::<Part>(&bytes).ok()).filter(|part| part.computer == computer).map(|part| part.taken).unwrap_or_default()
7841}
7842
7843/// A device whose files you removed here (`Memory::removed_devices`): when
7844/// (milliseconds); its last round then and that round's size: the rounds
7845/// before it stay out by the floor of the rounds past (`Memory::departed`),
7846/// that one while it is no longer than it was (a copy a sync app brings
7847/// back; a device still in use that goes on writing it makes it longer, and
7848/// it comes); whether its entry and its note of how far it read go too
7849/// (`small_go`, judged again at each exchange: forgotten here, then
7850/// replaced, they go then); and each of its small files then, by its place
7851/// in the folder and its content (`small_seen`; its size, `small`, for a
7852/// removal by an earlier build): a copy of one is taken out again only
7853/// while it is the same bytes (a sync app bringing it back), never one it
7854/// wrote since (a claim renewed, its texts to send) (reviews of 17 and 18
7855/// October 2026, L7 and L-5). Its claims and its texts to send went in
7856/// every case.
7857#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
7858pub struct RemovedDevice {
7859    pub at: i64,
7860    pub last: u32,
7861    #[serde(default)]
7862    pub last_size: u64,
7863    #[serde(default)]
7864    pub entry: bool,
7865    #[serde(default)]
7866    pub notes: bool,
7867    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
7868    pub small: BTreeMap<String, u64>,
7869    /// The same, by each file's content (`content_hash`), from 18 October
7870    /// 2026: a claim written again from nothing has the size its first had
7871    /// (`pad_for` pads nothing then), never the same bytes (sealed anew).
7872    /// Read before `small`, which a removal by an earlier build kept alone.
7873    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
7874    pub small_seen: BTreeMap<String, String>,
7875}
7876
7877impl RemovedDevice {
7878    /// Whether a copy of its round `round`, of `size` bytes, is one it had
7879    /// written when you removed its files: out (one before its last by the
7880    /// floor of the rounds past).
7881    pub fn holds_round(&self, round: u32, size: u64) -> bool {
7882        round < self.last || (round == self.last && size <= self.last_size)
7883    }
7884}
7885
7886/// Whether a removed device's entry and its note of how far it read go with
7887/// its files, as nothing needs them any more; a few hundred bytes each.
7888/// - Its note: once it left or another device replaces it, every device has
7889///   stopped counting it. One you only forgot here may still count
7890///   elsewhere, where that note says how far it read: without it, it would
7891///   hold back every round there (`readers`).
7892/// - Its entry: once another device's entry says it replaces it (a sealed
7893///   fact every device reads: none counts it then, the doses neither), or
7894///   once it left `SILENT_DAYS` ago. Before, its entry is what tells your
7895///   other devices, the doses first, that it left or how it closed: without
7896///   it, they would know it by its claims, as an older Sioul, and doubt.
7897fn small_go(counted: &Counted, heard_ms: i64, now_ms: i64) -> (bool, bool) {
7898    let entry = match counted {
7899        Counted::Replaced => true,
7900        Counted::Left => now_ms - heard_ms > SILENT_DAYS * DAY,
7901        _ => false,
7902    };
7903    (entry, matches!(counted, Counted::Left | Counted::Replaced))
7904}
7905
7906/// A device's own small files in the folder and in what was fetched beside
7907/// it (`remote::overlay`): its claims, its texts to send, its entry when
7908/// `entry`, its note of how far it read when `notes`. Those there only.
7909fn device_small_files(folder: &Path, id: &str, entry: bool, notes: bool) -> Vec<PathBuf> {
7910    device_small_places(folder, id, entry, notes).into_iter().map(|(path, _)| path).collect()
7911}
7912
7913/// A small file's content, by its hash (SHA-256): a copy brought back is the
7914/// same bytes; one written since never is, whatever its size.
7915fn content_hash(path: &Path) -> Option<String> {
7916    use sha2::Digest;
7917    let bytes = std::fs::read(path).ok()?;
7918    Some(sha2::Sha256::digest(&bytes).iter().map(|b| format!("{b:02x}")).collect())
7919}
7920
7921/// The same, each with its place in the folder (`leases/health/<id>.lease`).
7922fn device_small_places(folder: &Path, id: &str, entry: bool, notes: bool) -> Vec<(PathBuf, String)> {
7923    let mut out = Vec::new();
7924    for dir in std::iter::once(folder.to_path_buf()).chain(crate::remote::overlay(folder)) {
7925        let mut names = vec![format!("texts/send/{id}.jsonl"), format!("phone-messages/ask/{id}.jsonl")];
7926        if entry {
7927            names.push(format!("devices/{id}.device"));
7928        }
7929        if notes {
7930            names.push(format!("{id}.toml"));
7931        }
7932        for part in std::fs::read_dir(dir.join("leases")).into_iter().flatten().filter_map(Result::ok).filter(|e| e.file_type().is_ok_and(|t| t.is_dir())) {
7933            names.push(format!("leases/{}/{id}.lease", part.file_name().to_string_lossy()));
7934        }
7935        out.extend(names.into_iter().map(|place| (dir.join(&place), place)).filter(|(path, _)| path.is_file()));
7936    }
7937    out
7938}
7939
7940/// What "Remove its files" takes out for a device, and what stays, as the
7941/// window says it before you press (`device_files`, `remove_device_files`).
7942#[derive(Debug, Clone, Default, PartialEq, Eq)]
7943pub struct DeviceFiles {
7944    pub id: String,
7945    /// It still counts here: forgotten first; if it is still in use, it sends everything again.
7946    pub counts: bool,
7947    /// Its rounds of records, every one (its last full round too), and their bytes.
7948    pub rounds: usize,
7949    pub round_bytes: u64,
7950    /// Its claims, its texts to send, its entry and its note of how far it
7951    /// read when nothing needs them any more (`small_go`), and their bytes.
7952    pub small: usize,
7953    pub small_bytes: u64,
7954    /// Its note of how far it read stays: you only forgot it here, and a device that still counts it reads it.
7955    pub notes_stay: bool,
7956    /// Its entry stays: your other devices may still count it, the doses
7957    /// first, and read in it that it left or how it closed (`small_go`).
7958    pub entry_stays: bool,
7959    /// Whether its entry and its note of how far it read go (`small_go`).
7960    pub entry_goes: bool,
7961    pub notes_go: bool,
7962    /// The sealed contents of its notes and papers still current (its last
7963    /// word on them) that no other record names: they go now, but those
7964    /// sealed or used again lately, which go once that is as old as the
7965    /// sweep wants (`sealed_later`; `sweep_theirs`). Its earlier contents,
7966    /// replaced since, go in time as any departed device's (`sweep_theirs`).
7967    pub sealed: usize,
7968    pub sealed_bytes: u64,
7969    pub sealed_later: usize,
7970    /// How old (days) those sealed lately must be before they go: two weeks, 90 while an older Sioul counts.
7971    pub later_days: i64,
7972    /// When it was last heard of, by sealed facts (`last_heard`, milliseconds; 0 when never).
7973    pub heard: i64,
7974    /// Whether its sealed files could be weighed here at all: this device
7975    /// shares every part whose files are sealed apart, and knows the texts'
7976    /// media; else none of them goes.
7977    pub sealed_known: bool,
7978    /// The devices that count and have not read all of it yet, with their
7979    /// name and kind as their entries said them ("" when none did): what they
7980    /// have not read never reaches them.
7981    pub unread_by: Vec<(String, String, String)>,
7982    /// Its last round in the folder, and its size: those up to it go.
7983    pub last_round: u32,
7984    pub last_size: u64,
7985    /// Its name and kind, as its last entry seen here said them; "" when none did.
7986    pub name: String,
7987    pub kind: String,
7988}
7989
7990/// What "Remove its files" would take out for `device`: its paths, its
7991/// sealed files' names, those that go later, and what is said
7992/// (`DeviceFiles`). None for a device not known here.
7993fn plan_removal(sharing: &Sharing, stores: &[Store], memory: &Memory, device: &str, now_ms: i64) -> Option<(DeviceFiles, Vec<PathBuf>, Vec<String>, Vec<String>)> {
7994    let counts = counted(sharing.folder, sharing.key, sharing.computer, memory, now_ms);
7995    let standing = counts.iter().find(|(id, _)| id == device).map(|(_, counted)| counted.clone())?;
7996    let departed = standing.departed();
7997    let mut files = DeviceFiles { id: device.to_string(), counts: !departed, ..DeviceFiles::default() };
7998    if let Some(entry) = memory.seen.get(device) {
7999        (files.name, files.kind) = (entry.name.clone(), entry.kind.clone());
8000    }
8001    // Its rounds, here and fetched beside: each once, at the longer copy's size.
8002    let mut paths = Vec::new();
8003    let mut sizes: BTreeMap<u32, u64> = BTreeMap::new();
8004    for dir in std::iter::once(sharing.folder.to_path_buf()).chain(crate::remote::overlay(sharing.folder)) {
8005        for round in rounds(&dir).get(device).into_iter().flatten() {
8006            let path = round_file(&dir, device, *round);
8007            let size = sizes.entry(*round).or_default();
8008            *size = (*size).max(std::fs::metadata(&path).map_or(0, |m| m.len()));
8009            paths.push(path);
8010        }
8011    }
8012    (files.rounds, files.round_bytes) = (sizes.len(), sizes.values().sum());
8013    let last_round = sizes.keys().max().copied().unwrap_or(0);
8014    (files.last_round, files.last_size) = (last_round, sizes.get(&last_round).copied().unwrap_or(0));
8015    // A live device is forgotten first: what stays is judged as for one forgotten.
8016    let entry_seen = memory.seen.get(device).cloned().or_else(|| crate::devices::all(sharing.folder, sharing.key).0.into_iter().find(|e| e.id == device));
8017    files.heard = last_heard(entry_seen.as_ref(), memory, device);
8018    let (entry, notes) = if departed { small_go(&standing, files.heard, now_ms) } else { (false, false) };
8019    (files.entry_goes, files.notes_go) = (entry, notes);
8020    files.notes_stay = !notes && seen_path(sharing.folder, device).is_file();
8021    files.entry_stays = !entry && sharing.folder.join("devices").join(format!("{device}.device")).is_file();
8022    for path in device_small_files(sharing.folder, device, entry, notes) {
8023        files.small += usize::from(path.starts_with(sharing.folder));
8024        files.small_bytes += std::fs::metadata(&path).map_or(0, |m| m.len()) * u64::from(path.starts_with(sharing.folder));
8025        paths.push(path);
8026    }
8027    let reading = readers(sharing.folder, &counts, now_ms);
8028    files.unread_by = reading
8029        .iter()
8030        .filter(|r| *r != device)
8031        .filter(|r| read_seen(sharing.folder, r).and_then(|s| s.read.get(device).copied()).unwrap_or(0) < last_round)
8032        .map(|r| {
8033            let entry = memory.seen.get(r);
8034            (r.clone(), entry.map(|e| e.name.clone()).unwrap_or_default(), entry.map(|e| e.kind.clone()).unwrap_or_default())
8035        })
8036        .collect();
8037    // The sealed contents its last words on notes and papers name, that no
8038    // other record known here names: never a content another record names,
8039    // nor one named lately, nor one this device sealed, nor a medium a text
8040    // names; none at all while a part whose files are sealed apart is not
8041    // shared here, or the texts' media are not known (as `sweep_theirs`).
8042    // One sealed or used again lately (`fresh`, as the sweep judges it: a
8043    // device may have sealed the same bytes again in a record not read here
8044    // yet) goes later, swept as a departed device's replaced contents are.
8045    let every_part = FILE_PARTS.iter().all(|part| stores.iter().any(|s| s.part == *part && matches!(s.shape, Shape::Files)));
8046    let media = if every_part { texts_media(sharing, stores, memory, &counts) } else { None };
8047    files.sealed_known = media.is_some();
8048    let (mut names, mut later) = (Vec::new(), Vec::new());
8049    if let Some(media) = media {
8050        // As the sweep keeps them once this device no longer counts it (`unused_days`).
8051        let keeps = present(sharing.folder, sharing.key, sharing.computer, memory, now_ms).iter().filter(|(id, _, _)| id != device).all(|(_, entry, _)| entry.as_ref().is_some_and(|e| e.keeping >= crate::blobs::KEEPING_TWO_WEEKS));
8052        let unused = if keeps { crate::blobs::UNUSED_DAYS } else { crate::blobs::UNUSED_DAYS_BEFORE } * DAY;
8053        files.later_days = unused / DAY;
8054        let fresh = |path: &Path| 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);
8055        let theirs: BTreeSet<String> = memory.entries.iter().filter(|(key, known)| key.starts_with(FILES) && !known.h.is_empty() && known.w == device).map(|(_, known)| crate::blobs::name(sharing.key, &known.h)).collect();
8056        // Not the names its own records gave lately (`Sealed::named_at` says
8057        // not whose): another device's content replaced lately, which a
8058        // device may keep by reference among its earlier versions, is.
8059        let used: BTreeSet<String> = memory
8060            .entries
8061            .iter()
8062            .filter(|(key, known)| key.starts_with(FILES) && !known.h.is_empty() && known.w != device)
8063            .map(|(_, known)| known.h.clone())
8064            .chain(memory.pending.iter().filter(|(key, _)| key.starts_with(FILES)).filter_map(|(key, (value, _, _))| value.as_deref().map(|v| value_hash(key, v))))
8065            .map(|h| crate::blobs::name(sharing.key, &h))
8066            .chain(memory.sealed.theirs.iter().filter(|(_, (writer, at))| writer != device && now_ms - *at < unused).map(|(name, _)| name.clone()))
8067            .chain(memory.sealed.mine.keys().cloned())
8068            // Nor the contents of the notes and papers held after going many
8069            // at once here: "Put them back" writes them from there (review of
8070            // 17 October 2026, M3); they go with the hold, or in their time.
8071            .chain(memory.sealed.gone.keys().filter_map(|file| memory.entries.get(&format!("{file}#")).filter(|known| !known.h.is_empty()).map(|known| crate::blobs::name(sharing.key, &known.h))))
8072            .chain(media)
8073            .collect();
8074        for name in theirs.difference(&used) {
8075            let path = sharing.folder.join("blobs").join(name);
8076            let Ok(meta) = std::fs::metadata(&path) else { continue };
8077            if fresh(&path) {
8078                files.sealed_later += 1;
8079                later.push(name.clone());
8080            } else {
8081                files.sealed += 1;
8082                files.sealed_bytes += meta.len();
8083                names.push(name.clone());
8084            }
8085        }
8086    }
8087    Some((files, paths, names, later))
8088}
8089
8090/// What "Remove its files" would take out for `device`, and what stays, for
8091/// the window to say before you press; None for a device not known here.
8092pub fn device_files(folder: &Path, key: &[u8; 32], memory: &Path, computer: &str, stores: &[Store], device: &str, now_ms: i64) -> Option<DeviceFiles> {
8093    let state = Memory::load_all(memory, computer);
8094    let sharing = Sharing { folder, computer, key, memory, files: false, hurry: None };
8095    plan_removal(&sharing, stores, &state, device, now_ms).map(|(files, ..)| files)
8096}
8097
8098/// "Remove its files" for a device that no longer counts here (it left, you
8099/// forgot it, another replaces it: sealed facts and your own word only; a
8100/// live one is forgotten first, by the window): every round of its records
8101/// (its last full round too), its entry, its claims, its texts to send, its
8102/// note of how far it read once it left or was replaced, and its notes' and
8103/// papers' sealed files that no record known here names, taken out of the
8104/// folder here and of what was fetched beside it; the sending takes them out
8105/// of the server (`remote::Departed`), and none is fetched again. Each device
8106/// keeps what it already read; a device joining later never gets its records
8107/// unless another device's records carry them (texts restored on a new
8108/// phone). A device that counts here is refused ("share-device-counts").
8109pub fn remove_device_files(folder: &Path, key: &[u8; 32], memory: &Path, computer: &str, stores: &[Store], device: &str, now_ms: i64) -> Result<DeviceFiles, String> {
8110    let sharing = Sharing { folder, computer, key, memory, files: false, hurry: None };
8111    // The removal planned outside the exchanges' lock (it reads every entry of the devices,
8112    // each round of the device and the sealed names, and hashes its small files: it grows
8113    // with them; review of part 3, J1), then kept under the lock while the memory it was
8114    // planned from is the memory there; planned again there when an exchange wrote it meanwhile.
8115    // The size of each of its small files before they go, the entry and note too (they may go later, `small_go`).
8116    let small_seen = || -> BTreeMap<String, String> { device_small_places(folder, device, true, true).into_iter().filter_map(|(path, place)| content_hash(&path).map(|h| (place, h))).collect() };
8117    // Planned, and the memory written as it will be, outside the lock; under it, only a look
8118    // at the memory's stamps and two renames, so that nothing there grows with the device's
8119    // files or the memory's size (review of part 3, R-6). An exchange having written the memory
8120    // meanwhile, all of it is done again outside the lock, a few times, then said busy.
8121    let mut tries = 0;
8122    let (files, going, state) = loop {
8123        let read_from = (stamp_at(memory), stamp_at(&sealed_path(memory)));
8124        let mut state = Memory::load_all(memory, computer);
8125        let planned = plan_removal(&sharing, stores, &state, device, now_ms);
8126        let small_seen = small_seen();
8127        after_plan();
8128        let Some((files, paths, names, later)) = planned else { return Err("share-device-unknown".into()) };
8129        if files.counts {
8130            return Err("share-device-counts".into());
8131        }
8132        let mut going = paths;
8133        for name in names {
8134            going.push(folder.join("blobs").join(&name));
8135            state.sealed.theirs.remove(&name);
8136            state.taken.insert(name, now_ms);
8137        }
8138        // Those sealed or used again lately: swept in time, from now (`sweep_theirs`).
8139        for name in later {
8140            state.sealed.theirs.insert(name, (device.to_string(), now_ms));
8141        }
8142        state.removed_devices.insert(device.to_string(), RemovedDevice { at: now_ms, last: files.last_round, last_size: files.last_size, entry: files.entry_goes, notes: files.notes_go, small: BTreeMap::new(), small_seen });
8143        let floor = state.departed.entry(device.to_string()).or_default();
8144        *floor = (*floor).max(files.last_round);
8145        let prepared = state.prepare_save(memory)?;
8146        let running = exchange_lock(memory, true)?;
8147        if (stamp_at(memory), stamp_at(&sealed_path(memory))) == read_from {
8148            prepared.commit()?;
8149            drop(running);
8150            break (files, going, state);
8151        }
8152        drop(prepared);
8153        tries += 1;
8154        if tries >= REMOVAL_PLANS {
8155            return Err("share-busy".into());
8156        }
8157        hand_off(memory, running, PUT_BACK_FREE);
8158    };
8159    // Kept in this process, once the lock is let go: an exchange that wrote the memory since
8160    // leaves files this one never takes for its own (`Memory::kept`).
8161    state.keep(memory);
8162    // Then its files go, a short hold at a time (`in_short_holds`), its
8163    // rounds oldest first: thousands of them never keep a dose's exchange
8164    // waiting. An exchange between two holds reads the memory written above:
8165    // none of its rounds below that floor is read, what is left of its
8166    // rounds and small files it takes out itself (`Memory::removed_devices`),
8167    // its sealed files are taken (`Memory::taken`).
8168    in_short_holds(memory, going.into_iter(), |path| {
8169        slow_under_lock();
8170        let _ = std::fs::remove_file(path);
8171    })
8172    .map_err(|(e, _)| e)?;
8173    Ok(files)
8174}
8175
8176/// Sioul keeping the folder itself: the sealed files its copy keeps though
8177/// the server may no longer list them (`Sealed::needed`), by name, as the
8178/// last exchange said them; for the pull, which has no key and lets go of
8179/// the others (`remote`). None when the memory is not this computer's, or
8180/// does not say it yet (an older Sioul's, before this one's first
8181/// exchange): then nothing is let go.
8182pub fn needed_sealed(memory: &Path, computer: &str) -> Option<BTreeSet<String>> {
8183    #[derive(Deserialize)]
8184    struct Part {
8185        #[serde(default)]
8186        computer: String,
8187        #[serde(default)]
8188        needed: Option<BTreeSet<String>>,
8189    }
8190    std::fs::read(sealed_path(memory)).ok().and_then(|bytes| serde_json::from_slice::<Part>(&bytes).ok()).filter(|part| part.computer == computer).and_then(|part| part.needed)
8191}
8192
8193/// The devices whose files you removed here (`remove_device_files`), and
8194/// what went, as the last exchange or removal left them: for the sending and
8195/// the pull (`remote::Departed`), which never fetch such a file again and
8196/// take those still on the server out. Empty when the memory is not this computer's.
8197pub fn removed_devices(memory: &Path, computer: &str) -> BTreeMap<String, RemovedDevice> {
8198    #[derive(Deserialize)]
8199    struct Part {
8200        #[serde(default)]
8201        computer: String,
8202        #[serde(default)]
8203        removed_devices: BTreeMap<String, RemovedDevice>,
8204    }
8205    std::fs::read(memory).ok().and_then(|bytes| serde_json::from_slice::<Part>(&bytes).ok()).filter(|part| part.computer == computer).map(|part| part.removed_devices).unwrap_or_default()
8206}
8207
8208/// A device that no longer counts here and still has files in the folder
8209/// (`departed_devices`): why it no longer counts ("left", "forgotten",
8210/// "replaced"), the bytes of its records, its name and kind as its last
8211/// entry seen here said them.
8212#[derive(Debug, Clone, Default, PartialEq, Eq)]
8213pub struct GoneDevice {
8214    pub id: String,
8215    pub why: &'static str,
8216    pub bytes: u64,
8217    pub name: String,
8218    pub kind: String,
8219}
8220
8221/// The devices that no longer count here (they left, you forgot them,
8222/// another replaces them) and still have files in the folder: Settings
8223/// lists each with "Remove its files".
8224pub fn departed_devices(folder: &Path, key: &[u8; 32], memory: &Path, computer: &str, now_ms: i64) -> Vec<GoneDevice> {
8225    let state = Memory::load(memory, computer);
8226    let all = rounds_with(folder);
8227    counted(folder, key, computer, &state, now_ms)
8228        .into_iter()
8229        .filter_map(|(id, counted)| {
8230            let why = match counted {
8231                Counted::Left => "left",
8232                Counted::Forgotten => "forgotten",
8233                Counted::Replaced => "replaced",
8234                _ => return None,
8235            };
8236            let bytes: u64 = all.get(&id).into_iter().flatten().map(|round| std::fs::metadata(round_file(folder, &id, *round)).map_or(0, |m| m.len())).sum();
8237            let entry = state.seen.get(&id);
8238            let (entry_goes, notes_go) = small_go(&counted, last_heard(entry, &state, &id), now_ms);
8239            let small = device_small_files(folder, &id, entry_goes, notes_go);
8240            let bytes = bytes + small.iter().filter(|path| path.starts_with(folder)).map(|path| std::fs::metadata(path).map_or(0, |m| m.len())).sum::<u64>();
8241            (bytes > 0 || !small.is_empty()).then(|| GoneDevice { why, bytes, name: entry.map(|e| e.name.clone()).unwrap_or_default(), kind: entry.map(|e| e.kind.clone()).unwrap_or_default(), id })
8242        })
8243        .collect()
8244}
8245
8246/// The other devices that count here now, by sealed facts (`counted`): not
8247/// left, forgotten, replaced, silent for `SILENT_DAYS`, nor a stray file.
8248/// Only these may ask this phone for what your own devices alone may ask
8249/// (a reply from a computer, `sioul_core::replies::verdict`).
8250pub fn counting(folder: &Path, key: &[u8; 32], memory: &Path, computer: &str, now_ms: i64) -> BTreeSet<String> {
8251    let state = Memory::load(memory, computer);
8252    present(folder, key, computer, &state, now_ms).into_iter().map(|(id, _, _)| id).collect()
8253}
8254
8255/// Who may ask this device to act for them (texts' and replies' requests),
8256/// in two sets: the devices the sharing counts now (as `counting`), and
8257/// those gone by a sealed fact (they left, you forgot them, another device
8258/// replaces them: `Counted::departed`). Any other device is not known yet:
8259/// its records have not come (a new computer's first request may come before
8260/// them), a file alone, or a device silent for weeks that wakes; its request
8261/// waits while it is fresh, never refused for good.
8262pub fn asking(folder: &Path, key: &[u8; 32], memory: &Path, computer: &str, now_ms: i64) -> (BTreeSet<String>, BTreeSet<String>) {
8263    let state = Memory::load(memory, computer);
8264    let (mut counts, mut gone) = (BTreeSet::new(), BTreeSet::new());
8265    for (id, standing) in counted(folder, key, computer, &state, now_ms) {
8266        match standing {
8267            Counted::Yes(..) => {
8268                counts.insert(id);
8269            }
8270            standing if standing.departed() => {
8271                gone.insert(id);
8272            }
8273            _ => {}
8274        }
8275    }
8276    (counts, gone)
8277}
8278
8279/// Another computer sharing through the folder, and when it last exchanged (Unix seconds).
8280#[derive(Debug, Clone, PartialEq, Eq)]
8281pub struct Other {
8282    pub id: String,
8283    pub heard: i64,
8284}
8285
8286/// The other computers sharing through the folder.
8287pub fn others(folder: &Path, computer: &str) -> Vec<Other> {
8288    computers(folder).into_iter().filter(|c| c != computer).map(|c| Other { heard: read_seen(folder, &c).map_or(0, |s| s.at), id: c }).collect()
8289}
8290
8291/// The same, each heard when its notes, its entry in the devices' registry
8292/// (`devices`) or its last record that opens last said so, the latest: a
8293/// device put away restates its notes once a day (`write_seen`), its entry
8294/// once an hour (`devices::RESTATED`), as Settings says when each was last
8295/// heard of. A device whose records came before its notes and its entry (each
8296/// file travels on its own) is heard of by its records: the doses count it,
8297/// in doubt, until its entry comes (review of 5 October 2026, F5).
8298pub fn others_heard(folder: &Path, key: &[u8; 32], computer: &str) -> Vec<Other> {
8299    let (entries, _) = crate::devices::all(folder, key);
8300    let all = rounds_with(folder);
8301    let mut out = others(folder, computer);
8302    for other in &mut out {
8303        if let Some(entry) = entries.iter().find(|e| e.id == other.id) {
8304            other.heard = other.heard.max(entry_stamp(entry));
8305        }
8306        if let Some(clock) = all.get(&other.id).and_then(|rounds| rounds.last()).and_then(|round| last_record_clock(folder, key, &other.id, *round)) {
8307            other.heard = other.heard.max((clock >> 16) as i64 / 1000);
8308        }
8309    }
8310    out
8311}
8312
8313/// Whether the folder already holds a seal: another computer shares through it.
8314pub fn sealed(folder: &Path) -> bool {
8315    folder.join("seal.toml").is_file()
8316}
8317
8318#[cfg(test)]
8319mod tests {
8320    use super::*;
8321
8322    thread_local! {
8323        /// Stores this thread's exchanges know nothing of, as an older Sioul's
8324        /// (`known_part`): their records wait, written nowhere, taken out nowhere.
8325        pub(super) static OLDER: std::cell::RefCell<Vec<String>> = const { std::cell::RefCell::new(Vec::new()) };
8326    }
8327
8328    /// A computer of its own: its folders, its name, its memory.
8329    pub(super) struct Computer {
8330        pub(super) roots: Roots,
8331        pub(super) id: String,
8332        pub(super) memory: PathBuf,
8333    }
8334
8335    impl Computer {
8336        pub(super) fn new(base: &Path, name: &str) -> Computer {
8337            let root = base.join(name);
8338            let roots = Roots { config: root.join("config"), data: root.join("data"), state: root.join("state") };
8339            for dir in [&roots.config, &roots.data, &roots.state] {
8340                std::fs::create_dir_all(dir).unwrap();
8341            }
8342            let memory = roots.state.join("share").join("memory.json");
8343            Computer { roots, id: uuid::Uuid::new_v4().to_string(), memory }
8344        }
8345
8346        pub(super) fn exchange(&self, folder: &Path, key: &[u8; 32], now: i64) -> Outcome {
8347            let stores = stores(&Config::default(), &self.roots);
8348            exchange(&Sharing { folder, computer: &self.id, key, memory: &self.memory, files: true, hurry: None }, &stores, now).unwrap()
8349        }
8350
8351        /// An exchange carrying the projects and budgets of `notes`, this computer's notes folder.
8352        pub(super) fn exchange_notes(&self, folder: &Path, key: &[u8; 32], now: i64, notes: &Path) -> Outcome {
8353            let config = Config { share_projects: true, notes_root: Some(notes.display().to_string()), ..Config::default() };
8354            let stores = stores(&config, &self.roots);
8355            exchange(&Sharing { folder, computer: &self.id, key, memory: &self.memory, files: true, hurry: None }, &stores, now).unwrap()
8356        }
8357
8358        /// An exchange with `notes` as this computer's notes folder, the parts `on` shared besides those shared by default.
8359        fn exchange_with(&self, folder: &Path, key: &[u8; 32], now: i64, notes: &Path, on: &[&str]) -> Outcome {
8360            let stores = self.stores_with(notes, &|part| on.contains(&part));
8361            exchange(&Sharing { folder, computer: &self.id, key, memory: &self.memory, files: true, hurry: None }, &stores, now).unwrap()
8362        }
8363
8364        fn stores_with(&self, notes: &Path, on: &dyn Fn(&str) -> bool) -> Vec<Store> {
8365            let config = Config { notes_root: Some(notes.display().to_string()), ..Config::default() };
8366            stores_of(&config, &self.roots, &|part| on(part) || shared_by_default(part, &config))
8367        }
8368
8369        pub(super) fn write(&self, relative: &str, text: &str) {
8370            let path = self.path(relative);
8371            std::fs::create_dir_all(path.parent().unwrap()).unwrap();
8372            std::fs::write(path, text).unwrap();
8373        }
8374
8375        pub(super) fn read(&self, relative: &str) -> String {
8376            std::fs::read_to_string(self.path(relative)).unwrap_or_default()
8377        }
8378
8379        pub(super) fn path(&self, relative: &str) -> PathBuf {
8380            let (root, rest) = relative.split_once('/').unwrap();
8381            match root {
8382                "config" => self.roots.config.join(rest),
8383                "data" => self.roots.data.join(rest),
8384                _ => self.roots.state.join(rest),
8385            }
8386        }
8387    }
8388
8389    pub(super) fn scratch(name: &str) -> PathBuf {
8390        let dir = std::env::temp_dir().join(format!("sioul-share-{name}-{}", std::process::id()));
8391        let _ = std::fs::remove_dir_all(&dir);
8392        std::fs::create_dir_all(&dir).unwrap();
8393        dir
8394    }
8395
8396    pub(super) fn quick_key(folder: &Path, passphrase: &str) -> Result<[u8; 32], Refused> {
8397        key_with(folder, passphrase, 64, 1)
8398    }
8399
8400    /// A file dated through a handle that cannot write it (`set_time`: on
8401    /// Windows its attributes alone, no antivirus reading the file at its
8402    /// close; elsewhere a read handle): its time set, the file unchanged; a
8403    /// file only its owner may read dated too, where a write handle could
8404    /// not even be opened.
8405    #[test]
8406    fn a_file_is_dated_without_a_handle_that_writes_it() {
8407        let base = scratch("set-time");
8408        let path = base.join("note.md");
8409        std::fs::write(&path, b"# Note\n").unwrap();
8410        let when = std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_789_990_000);
8411        set_time(&path, when).unwrap();
8412        assert_eq!(std::fs::metadata(&path).unwrap().modified().unwrap(), when);
8413        assert_eq!(std::fs::read(&path).unwrap(), b"# Note\n");
8414        #[cfg(unix)]
8415        {
8416            use std::os::unix::fs::PermissionsExt;
8417            std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o400)).unwrap();
8418            let later = when + std::time::Duration::from_secs(60);
8419            set_time(&path, later).unwrap();
8420            assert_eq!(std::fs::metadata(&path).unwrap().modified().unwrap(), later, "read-only: dated all the same");
8421            std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
8422        }
8423        assert!(set_time(&base.join("gone.md"), when).is_err());
8424        let _ = std::fs::remove_dir_all(&base);
8425    }
8426
8427    fn put(dir: &Path, relative: &str, bytes: &[u8]) {
8428        let path = dir.join(relative);
8429        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
8430        std::fs::write(path, bytes).unwrap();
8431    }
8432
8433    /// Bytes that do not compress, the same for the same seed: a scan, a picture.
8434    fn noise(length: usize, seed: u32) -> Vec<u8> {
8435        let mut x = seed.wrapping_mul(2_654_435_761).wrapping_add(1);
8436        (0..length)
8437            .map(|_| {
8438                x ^= x << 13;
8439                x ^= x >> 17;
8440                x ^= x << 5;
8441                x as u8
8442            })
8443            .collect()
8444    }
8445
8446    /// The files of a notes folder, hidden ones left out, by their path from it.
8447    fn files_in(dir: &Path) -> BTreeMap<String, Vec<u8>> {
8448        let mut found = Vec::new();
8449        list_files(dir, dir, &[], &mut found);
8450        found.into_iter().map(|(name, path)| (name, std::fs::read(path).unwrap())).collect()
8451    }
8452
8453    /// A version kept, copied or by reference (its content opened from the folder).
8454    fn version_text(history: &Path, part: &str, file: &str, stamp: &str, folder: &Path, key: &[u8; 32]) -> String {
8455        match crate::history::version(history, part, file, stamp) {
8456            Some(crate::history::Kept::Copy(path)) => std::fs::read_to_string(path).unwrap(),
8457            Some(crate::history::Kept::Sealed { hash, .. }) => {
8458                let target = temporary(&history.join("opened"));
8459                crate::blobs::get(folder, key, &hash, &target).unwrap();
8460                let text = std::fs::read_to_string(&target).unwrap();
8461                std::fs::remove_file(target).unwrap();
8462                text
8463            }
8464            None => panic!("{file}: {stamp}: not kept"),
8465        }
8466    }
8467
8468    /// The copies a device kept of a file two devices changed: "lease (… …).md", not a sync app's.
8469    fn copies_of(dir: &Path, stem: &str) -> Vec<Vec<u8>> {
8470        files_in(dir).into_iter().filter(|(name, _)| name.starts_with(&format!("{stem} (")) && name.ends_with(").md") && !made_by_tools(name)).map(|(_, bytes)| bytes).collect()
8471    }
8472
8473    pub(super) const MINUTE: i64 = 60_000;
8474    pub(super) const NOW: i64 = 1_790_000_000_000;
8475
8476    /// How eDrive (Murena's sync on /e/OS) carries a folder between a phone and
8477    /// the server, after its `FileDiffUtils`: a file new on either side is
8478    /// copied; a file changed on the phone goes up; a file changed on the server
8479    /// comes down only when its size differs from the phone's copy (else only
8480    /// its version is noted); nothing is ever deleted. The server here is the
8481    /// desktop's folder: Nextcloud's client keeps them equal.
8482    #[derive(Default)]
8483    struct Edrive {
8484        /// path → (the server's content when last seen, the phone's content then).
8485        known: BTreeMap<String, (String, String)>,
8486    }
8487
8488    impl Edrive {
8489        fn files(root: &Path) -> BTreeMap<String, Vec<u8>> {
8490            let mut found = Vec::new();
8491            list_files(root, root, &[], &mut found);
8492            found.into_iter().filter(|(name, _)| !name.split('/').any(|p| p.starts_with('.'))).filter_map(|(name, path)| std::fs::read(path).ok().map(|b| (name, b))).collect()
8493        }
8494
8495        fn carry(&mut self, server: &Path, phone: &Path) {
8496            let digest = |bytes: &[u8]| hash(&String::from_utf8_lossy(bytes));
8497            let (up, down) = (Self::files(phone), Self::files(server));
8498            for (name, bytes) in &up {
8499                let changed_here = self.known.get(name).is_none_or(|(_, local)| *local != digest(bytes));
8500                if changed_here {
8501                    let to = server.join(name);
8502                    std::fs::create_dir_all(to.parent().unwrap()).unwrap();
8503                    std::fs::write(&to, bytes).unwrap();
8504                    self.known.insert(name.clone(), (digest(bytes), digest(bytes)));
8505                }
8506            }
8507            for (name, bytes) in &down {
8508                let local = phone.join(name);
8509                match (self.known.get(name), std::fs::read(&local)) {
8510                    (Some((remote, _)), _) if *remote == digest(bytes) => {}
8511                    // Changed there, the same size here: only the version is noted.
8512                    (Some(_), Ok(here)) if here.len() == bytes.len() => {
8513                        self.known.insert(name.clone(), (digest(bytes), digest(&here)));
8514                    }
8515                    _ => {
8516                        std::fs::create_dir_all(local.parent().unwrap()).unwrap();
8517                        std::fs::write(&local, bytes).unwrap();
8518                        self.known.insert(name.clone(), (digest(bytes), digest(bytes)));
8519                    }
8520                }
8521            }
8522        }
8523    }
8524
8525    /// How a sync app carries the folder between a phone and the server, in
8526    /// the ways they differ: the sharing must hold with each (docs/database.md,
8527    /// "What the sync app must do").
8528    #[derive(Clone, Copy, Debug, PartialEq)]
8529    enum Carrying {
8530        /// Every change, both ways, deletions too (Nextcloud's desktop client, Dropbox, Drive, OneDrive).
8531        Exact,
8532        /// A change from the server comes only when the size differs (eDrive).
8533        SizeOnly,
8534        /// Half the files each time, late, in any order; nothing deleted.
8535        Late,
8536        /// An older copy of a file put back from time to time, a conflicted copy beside it.
8537        StaleCopies,
8538        /// New files first come as empty placeholders, the content at the next look (on-demand files).
8539        Placeholders,
8540    }
8541
8542    struct Carrier {
8543        how: Carrying,
8544        turn: u64,
8545        /// path → (the server's content when last seen, the phone's content then).
8546        known: BTreeMap<String, (String, String)>,
8547        /// Every version seen on the server, to put an older one back.
8548        history: BTreeMap<String, Vec<Vec<u8>>>,
8549    }
8550
8551    impl Carrier {
8552        fn new(how: Carrying) -> Carrier {
8553            Carrier { how, turn: 0, known: BTreeMap::new(), history: BTreeMap::new() }
8554        }
8555
8556        fn files(root: &Path) -> BTreeMap<String, Vec<u8>> {
8557            let mut found = Vec::new();
8558            list_files(root, root, &[], &mut found);
8559            found.into_iter().filter(|(name, _)| !name.split('/').any(|p| p.starts_with('.'))).filter_map(|(name, path)| std::fs::read(path).ok().map(|b| (name, b))).collect()
8560        }
8561
8562        fn write(path: &Path, bytes: &[u8]) {
8563            std::fs::create_dir_all(path.parent().unwrap()).unwrap();
8564            std::fs::write(path, bytes).unwrap();
8565        }
8566
8567        fn carry(&mut self, server: &Path, phone: &Path) {
8568            self.turn += 1;
8569            let digest = |bytes: &[u8]| hash(&String::from_utf8_lossy(bytes));
8570            let (up, down) = (Self::files(phone), Self::files(server));
8571            for (name, bytes) in &up {
8572                let changed_here = self.known.get(name).is_none_or(|(_, local)| *local != digest(bytes));
8573                // A placeholder never goes up.
8574                if changed_here && !(self.how == Carrying::Placeholders && bytes.is_empty() && down.contains_key(name)) {
8575                    Self::write(&server.join(name), bytes);
8576                    self.known.insert(name.clone(), (digest(bytes), digest(bytes)));
8577                }
8578            }
8579            for (i, (name, bytes)) in down.iter().enumerate() {
8580                self.history.entry(name.clone()).or_default().push(bytes.clone());
8581                if self.how == Carrying::Late && (i as u64 + self.turn) % 2 == 0 {
8582                    continue;
8583                }
8584                let local = phone.join(name);
8585                let here = std::fs::read(&local).ok();
8586                match (self.known.get(name), here) {
8587                    (Some((remote, _)), _) if *remote == digest(bytes) => {}
8588                    (Some(_), Some(here)) if self.how == Carrying::SizeOnly && here.len() == bytes.len() => {
8589                        self.known.insert(name.clone(), (digest(bytes), digest(&here)));
8590                    }
8591                    (None, None) if self.how == Carrying::Placeholders => {
8592                        Self::write(&local, b"");
8593                        self.known.insert(name.clone(), (String::new(), digest(b"")));
8594                    }
8595                    _ => {
8596                        Self::write(&local, bytes);
8597                        self.known.insert(name.clone(), (digest(bytes), digest(bytes)));
8598                    }
8599                }
8600            }
8601            if self.how == Carrying::Exact {
8602                for name in up.keys().filter(|n| !down.contains_key(*n) && self.known.contains_key(*n)) {
8603                    let _ = std::fs::remove_file(phone.join(name));
8604                }
8605            }
8606            // From time to time, an older copy of a record put back on the server, a conflicted copy beside it.
8607            if self.how == Carrying::StaleCopies && self.turn % 7 == 0 {
8608                for (name, versions) in self.history.iter().filter(|(n, _)| n.ends_with(".jsonl")) {
8609                    if versions.len() > 2 {
8610                        let current = std::fs::read(server.join(name)).unwrap_or_default();
8611                        Self::write(&server.join(format!("{name} (conflicted copy)")), &current);
8612                        Self::write(&server.join(name), &versions[versions.len() / 2]);
8613                    }
8614                }
8615            }
8616        }
8617    }
8618
8619    #[test]
8620    fn doses_are_never_known_wrongly_whatever_carries_them() {
8621        for how in [Carrying::Exact, Carrying::SizeOnly, Carrying::Late, Carrying::StaleCopies, Carrying::Placeholders] {
8622            let base = scratch(&format!("carrier-{how:?}").to_lowercase());
8623            let (server, phone_folder) = (base.join("server").join("Sioul"), base.join("phone").join("Sioul"));
8624            let key = quick_key(&server, "four words make a passphrase").unwrap();
8625            let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
8626            let mut carrier = Carrier::new(how);
8627            let mut marked: Vec<String> = Vec::new();
8628            for minute in 0..120 {
8629                let now = NOW + minute * MINUTE;
8630                // A dose marked on the desk every ten minutes, its claim renewed each minute.
8631                if minute % 10 == 0 {
8632                    marked.push(format!("dose@{minute}"));
8633                    let record: String = marked.iter().map(|d| format!("\"{d}\" = {minute}\n")).collect();
8634                    desk.write("state/health-state.toml", &format!("[taken]\n{record}"));
8635                }
8636                desk.exchange(&server, &key, now);
8637                let wrote = written(&desk.memory, &desk.id);
8638                crate::lease::renew(&server, &key, "health", &desk.id, now / 1000, now / 1000, false, crate::lease::Rule::FollowsYou, wrote).unwrap();
8639                carrier.carry(&server, &phone_folder);
8640                // texts: a first import running on the phone beside it, a batch each minute and a picture each ten.
8641                let import: Vec<sioul_core::texts::Text> = (0..20).map(|i| sioul_core::texts::Text { id: format!("sms-{minute}-{i}"), at: NOW - 86_400_000 + minute * MINUTE + i, direction: "in".into(), with: vec!["+33199001234".into()], body: format!("Message {minute}-{i}"), ..sioul_core::texts::Text::default() }).collect();
8642                sioul_core::texts::append(&sioul_core::texts::own_file(&texts_of(&phone), sioul_core::texts::LOG, &phone.id), &import, &crate::textseal::TextSeal::new(&key), None).unwrap();
8643                if minute % 10 == 0 {
8644                    let picture = base.join(format!("picture-{minute}.bin"));
8645                    std::fs::write(&picture, vec![minute as u8; 256 * 1024]).unwrap();
8646                    let (hash, _) = crate::blobs::hash_file(&picture).unwrap();
8647                    crate::blobs::put(&phone_folder, &key, &picture, &hash).unwrap();
8648                }
8649                phone.exchange_with(&phone_folder, &key, now + 30_000, &base.join("phone-notes"), &[sioul_core::texts::PART]);
8650                // What the phone takes as known must be there: a dose known not taken that was taken is the harm.
8651                if let Some(claim) = crate::lease::claims(&phone_folder, &key, "health").into_iter().find(|c| c.computer == desk.id)
8652                    && let Some(wrote) = claim.wrote
8653                    && heard(&phone.memory, &phone.id).complete(&desk.id, wrote)
8654                {
8655                    let record = phone.read("state/health-state.toml");
8656                    let claimed = (claim.renewed - NOW / 1000) / 60;
8657                    for dose in marked.iter().filter(|d| d[5..].parse::<i64>().unwrap() < claimed) {
8658                        assert!(record.contains(dose.as_str()), "{how:?}, minute {minute}: the phone takes {dose} as known, its record lacks it:\n{record}");
8659                    }
8660                }
8661            }
8662            // In the end every dose is there, whatever carried them.
8663            for minute in 120..140 {
8664                let now = NOW + minute * MINUTE;
8665                desk.exchange(&server, &key, now);
8666                carrier.carry(&server, &phone_folder);
8667                phone.exchange(&phone_folder, &key, now + 30_000);
8668            }
8669            let record = phone.read("state/health-state.toml");
8670            for dose in &marked {
8671                assert!(record.contains(dose.as_str()), "{how:?}: {dose} never came:\n{record}");
8672            }
8673            let _ = std::fs::remove_dir_all(&base);
8674        }
8675    }
8676
8677    #[test]
8678    fn edrive_brings_every_rewrite() {
8679        let base = scratch("edrive");
8680        let (server, phone_folder) = (base.join("server").join("Sioul"), base.join("phone").join("Sioul"));
8681        let key = quick_key(&server, "four words make a passphrase").unwrap();
8682        let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
8683        let mut edrive = Edrive::default();
8684        let health = |computer: &Computer, folder: &Path, now: i64, closed: bool| {
8685            let wrote = written(&computer.memory, &computer.id);
8686            if closed {
8687                crate::lease::close(folder, &key, "health", &computer.id, now / 1000, wrote).unwrap();
8688            } else {
8689                crate::lease::renew(folder, &key, "health", &computer.id, now / 1000, now / 1000, false, crate::lease::Rule::FollowsYou, wrote).unwrap();
8690            }
8691        };
8692
8693        // The desk marks a dose and shares it; the phone joins through eDrive.
8694        desk.write("state/health-state.toml", "[taken]\n\"levothyroxine@2026-10-05T07:30\" = 1790000000\n");
8695        desk.exchange(&server, &key, NOW);
8696        health(&desk, &server, NOW, false);
8697        edrive.carry(&server, &phone_folder);
8698        phone.exchange(&phone_folder, &key, NOW + MINUTE);
8699        assert!(phone.read("state/health-state.toml").contains("levothyroxine@2026-10-05T07:30"));
8700
8701        // Hours of rewrites on the desk, each carried: every one comes, the last one closed.
8702        for minute in 2..200 {
8703            let now = NOW + minute * MINUTE;
8704            if minute == 120 {
8705                desk.write("state/health-state.toml", &format!("{}\"levothyroxine@2026-10-06T07:30\" = 1790090000\n", desk.read("state/health-state.toml")));
8706            }
8707            desk.exchange(&server, &key, now);
8708            health(&desk, &server, now, minute == 199);
8709            edrive.carry(&server, &phone_folder);
8710            for name in [format!("{}.toml", desk.id), format!("leases/health/{}.lease", desk.id)] {
8711                assert_eq!(std::fs::read(phone_folder.join(&name)).ok(), std::fs::read(server.join(&name)).ok(), "minute {minute}: {name} stale on the phone");
8712            }
8713        }
8714        // On the phone: the second dose, and the desk's claim saying it closed and how far it wrote.
8715        phone.exchange(&phone_folder, &key, NOW + 200 * MINUTE);
8716        assert!(phone.read("state/health-state.toml").contains("levothyroxine@2026-10-06T07:30"));
8717        let claim = crate::lease::claims(&phone_folder, &key, "health").into_iter().find(|c| c.computer == desk.id).unwrap();
8718        assert!(claim.closed && claim.wrote == written(&desk.memory, &desk.id), "{claim:?}");
8719        // The phone's own files went up untouched.
8720        let phone_record = format!("{}-1.jsonl", phone.id);
8721        assert_eq!(std::fs::read(server.join(&phone_record)).ok(), std::fs::read(phone_folder.join(&phone_record)).ok());
8722        let _ = std::fs::remove_dir_all(&base);
8723    }
8724
8725    /// The build that wrote them (docs/database.md, "The log"): on the first
8726    /// line of each batch a computer appends, sealed with it; lines without it
8727    /// (an older Sioul's) read as ever.
8728    #[test]
8729    fn each_batch_of_records_says_its_build() {
8730        let base = scratch("build");
8731        let folder = base.join("Sioul");
8732        let key = quick_key(&folder, "four words make a passphrase").unwrap();
8733        let desk = Computer::new(&base, "desk");
8734        desk.write("config/safe-senders.txt", "a@example.org\nb@example.org\n");
8735        desk.exchange(&folder, &key, NOW);
8736        let own = round_file(&folder, &desk.id, 1);
8737        let first = std::fs::read_to_string(&own).unwrap().lines().count();
8738        desk.write("config/safe-senders.txt", "a@example.org\nb@example.org\nc@example.org\n");
8739        desk.exchange(&folder, &key, NOW + MINUTE);
8740        let text = std::fs::read_to_string(&own).unwrap();
8741        let builds: Vec<String> = text
8742            .lines()
8743            .map(|l| {
8744                let line: Line = serde_json::from_str(l).unwrap();
8745                serde_json::from_slice::<Change>(&open(&key, &bound(&desk.id, 1, line.n, line.c), &line.s).unwrap()).unwrap().build
8746            })
8747            .collect();
8748        assert!(first >= 2 && builds.len() > first, "{builds:?}");
8749        for (n, build) in builds.iter().enumerate() {
8750            assert_eq!(build.as_str(), if n == 0 || n == first { sioul_core::build::DESCRIBED } else { "" }, "line {n}");
8751        }
8752        assert!(!text.contains(sioul_core::build::DESCRIBED), "sealed: the server never learns it");
8753        // A new round opens with the build too.
8754        std::fs::write(&own, text.lines().next().unwrap().to_string() + "\n").unwrap();
8755        desk.exchange(&folder, &key, NOW + 2 * MINUTE);
8756        let line: Line = serde_json::from_str(std::fs::read_to_string(round_file(&folder, &desk.id, 2)).unwrap().lines().next().unwrap()).unwrap();
8757        let change: Change = serde_json::from_slice(&open(&key, &bound(&desk.id, 2, line.n, line.c), &line.s).unwrap()).unwrap();
8758        assert_eq!(change.build, sioul_core::build::DESCRIBED);
8759        // Another computer reads them all, those written before the build was said among them.
8760        let laptop = Computer::new(&base, "laptop");
8761        let stranger = uuid::Uuid::new_v4().to_string();
8762        append_to(&round_file(&folder, &stranger, 1), &record_of(&key, &stranger, 1, ((NOW + MINUTE) as u64) << 16, &Change { k: "config/safe-senders.txt#d@example.org".into(), v: Some(String::new()), b: String::new(), build: String::new(), f: 0 }));
8763        laptop.exchange(&folder, &key, NOW + 3 * MINUTE);
8764        let safe = laptop.read("config/safe-senders.txt");
8765        assert!(["a@", "b@", "c@", "d@"].iter().all(|a| safe.contains(a)), "{safe}");
8766        let _ = std::fs::remove_dir_all(&base);
8767    }
8768
8769    #[test]
8770    fn a_record_put_back_shorter_starts_again() {
8771        let base = scratch("cut");
8772        let folder = base.join("Sioul");
8773        let key = quick_key(&folder, "four words make a passphrase").unwrap();
8774        let desk = Computer::new(&base, "desk");
8775        desk.write("config/safe-senders.txt", "a@example.org\n");
8776        desk.exchange(&folder, &key, NOW);
8777        desk.write("config/safe-senders.txt", "a@example.org\nb@example.org\n");
8778        desk.exchange(&folder, &key, NOW + MINUTE);
8779        // A sync app puts back the first version of the desk's file: its second line is lost.
8780        let own = round_file(&folder, &desk.id, 1);
8781        let text = std::fs::read_to_string(&own).unwrap();
8782        std::fs::write(&own, text.lines().next().unwrap().to_string() + "\n").unwrap();
8783        desk.write("config/safe-senders.txt", "a@example.org\nb@example.org\nc@example.org\n");
8784        let outcome = desk.exchange(&folder, &key, NOW + 2 * MINUTE);
8785        assert!(outcome.problems.iter().any(|p| p == "share-own-cut"), "{outcome:?}");
8786        assert!(round_file(&folder, &desk.id, 2).exists(), "a new file, opening on all the desk holds");
8787        // A computer joining now hears all three.
8788        let laptop = Computer::new(&base, "laptop");
8789        laptop.exchange(&folder, &key, NOW + 3 * MINUTE);
8790        let safe = laptop.read("config/safe-senders.txt");
8791        assert!(["a@", "b@", "c@"].iter().all(|a| safe.contains(a)), "{safe}");
8792        let _ = std::fs::remove_dir_all(&base);
8793    }
8794
8795    #[test]
8796    fn a_memory_restored_or_lost_goes_on_numbering() {
8797        let base = scratch("restored");
8798        let folder = base.join("Sioul");
8799        let key = quick_key(&folder, "four words make a passphrase").unwrap();
8800        let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
8801        desk.write("state/health-state.toml", "[taken]\n\"a@100\" = 100\n");
8802        desk.exchange(&folder, &key, NOW);
8803        phone.exchange(&folder, &key, NOW + MINUTE);
8804        // A backup of the desk now; then a dose marked and shared.
8805        let backup = (std::fs::read(&desk.memory).unwrap(), desk.read("state/health-state.toml"));
8806        desk.write("state/health-state.toml", "[taken]\n\"a@100\" = 100\n\"b@800\" = 800\n");
8807        desk.exchange(&folder, &key, NOW + 2 * MINUTE);
8808        phone.exchange(&folder, &key, NOW + 3 * MINUTE);
8809        assert!(phone.read("state/health-state.toml").contains("b@800"));
8810        let before = written(&desk.memory, &desk.id).unwrap();
8811        // The desk restored from its backup: the dose it marked comes back, its numbering goes on.
8812        std::fs::write(&desk.memory, &backup.0).unwrap();
8813        desk.write("state/health-state.toml", &backup.1);
8814        let outcome = desk.exchange(&folder, &key, NOW + 4 * MINUTE);
8815        assert!(outcome.problems.iter().any(|p| p == "share-own-ahead"), "{outcome:?}");
8816        assert!(desk.read("state/health-state.toml").contains("b@800"), "{}", desk.read("state/health-state.toml"));
8817        desk.write("state/health-state.toml", &format!("{}\"c@900\" = 900\n", desk.read("state/health-state.toml")));
8818        desk.exchange(&folder, &key, NOW + 5 * MINUTE);
8819        let after = written(&desk.memory, &desk.id).unwrap();
8820        assert!(after > before && after.0 == before.0, "numbering goes on in the same round: {before:?} → {after:?}");
8821        // The phone reads it all, without a gap, and knows it all.
8822        let outcome = phone.exchange(&folder, &key, NOW + 6 * MINUTE);
8823        assert!(!outcome.problems.iter().any(|p| p.starts_with("share-other-gap")), "{outcome:?}");
8824        assert!(phone.read("state/health-state.toml").contains("c@900"));
8825        assert!(heard(&phone.memory, &phone.id).complete(&desk.id, after));
8826        // "Stop sharing", then again: the memory gone, the same.
8827        std::fs::remove_file(&desk.memory).unwrap();
8828        desk.write("state/health-state.toml", &format!("{}\"d@950\" = 950\n", desk.read("state/health-state.toml")));
8829        desk.exchange(&folder, &key, NOW + 7 * MINUTE);
8830        assert!(written(&desk.memory, &desk.id).unwrap() > after);
8831        phone.exchange(&folder, &key, NOW + 8 * MINUTE);
8832        assert!(phone.read("state/health-state.toml").contains("d@950"));
8833        assert!(heard(&phone.memory, &phone.id).complete(&desk.id, written(&desk.memory, &desk.id).unwrap()));
8834        let _ = std::fs::remove_dir_all(&base);
8835    }
8836
8837    #[test]
8838    fn a_gap_is_a_doubt_and_a_damaged_line_is_one_line() {
8839        let base = scratch("gap");
8840        let folder = base.join("Sioul");
8841        let key = quick_key(&folder, "four words make a passphrase").unwrap();
8842        let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
8843        desk.write("config/safe-senders.txt", "a@example.org\n");
8844        desk.exchange(&folder, &key, NOW);
8845        phone.exchange(&folder, &key, NOW + MINUTE);
8846        // A line that is not text, then one that is: the second is read all the same.
8847        let own = round_file(&folder, &desk.id, 1);
8848        let mut bytes = std::fs::read(&own).unwrap();
8849        bytes.extend_from_slice(b"\xff\xfe broken\n");
8850        std::fs::write(&own, &bytes).unwrap();
8851        desk.write("config/safe-senders.txt", "a@example.org\nb@example.org\n");
8852        desk.exchange(&folder, &key, NOW + 2 * MINUTE);
8853        let outcome = phone.exchange(&folder, &key, NOW + 3 * MINUTE);
8854        assert!(outcome.problems.iter().any(|p| p.starts_with("share-other-line")), "{outcome:?}");
8855        assert!(phone.read("config/safe-senders.txt").contains("b@example.org"));
8856        // Lines missing (a stale copy of the file put back, then written after): a gap, said, and not known.
8857        let line = |n: u64, value: &str| {
8858            let clock = (NOW as u64) << 16 | n;
8859            let plain = serde_json::to_vec(&Change { k: "config/safe-senders.txt#".to_string() + value, v: Some(String::new()), b: String::new(), build: String::new(), f: 0 }).unwrap();
8860            serde_json::to_string(&Line { n, c: clock, s: seal(&key, &bound(&desk.id, 1, n, clock), &plain) }).unwrap() + "\n"
8861        };
8862        let last = written(&desk.memory, &desk.id).unwrap().1;
8863        let mut bytes = std::fs::read(&own).unwrap();
8864        bytes.extend_from_slice(line(last + 3, "c@example.org").as_bytes());
8865        std::fs::write(&own, bytes).unwrap();
8866        let outcome = phone.exchange(&folder, &key, NOW + 4 * MINUTE);
8867        assert!(outcome.problems.iter().any(|p| p.starts_with("share-other-gap")), "{outcome:?}");
8868        assert!(!heard(&phone.memory, &phone.id).complete(&desk.id, (1, last + 3)), "past a gap, nothing is known");
8869        let _ = std::fs::remove_dir_all(&base);
8870    }
8871
8872    #[test]
8873    fn stores_that_leave_arrive_or_move_take_nothing_out() {
8874        let base = scratch("stores");
8875        let folder = base.join("Sioul");
8876        let key = quick_key(&folder, "four words make a passphrase").unwrap();
8877        let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
8878        let (desk_notes, phone_notes, moved) = (base.join("desk-notes"), base.join("phone-notes"), base.join("moved-notes"));
8879        let ledger = |notes: &Path| std::fs::read_to_string(notes.join("sioul-budgets.toml")).unwrap_or_default();
8880        let line = |label: &str, amount: f64| format!("\n[[line]]\nbudget = \"home\"\ndate = \"2026-10-01\"\namount = {amount}\nlabel = \"{label}\"\n");
8881        std::fs::create_dir_all(&desk_notes).unwrap();
8882        std::fs::write(desk_notes.join("sioul-budgets.toml"), format!("[[budget]]\nid = \"home\"\ntitle = \"Home\"\n{}", line("Rent", -620.0))).unwrap();
8883        // The phone holds an old copy of its own, made long ago, and shares without its projects.
8884        std::fs::create_dir_all(&phone_notes).unwrap();
8885        std::fs::write(phone_notes.join("sioul-budgets.toml"), format!("[[budget]]\nid = \"home\"\ntitle = \"Old home\"\n{}", line("Old", -1.0))).unwrap();
8886        let old = std::fs::File::options().write(true).open(phone_notes.join("sioul-budgets.toml")).unwrap();
8887        old.set_modified(std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_700_000_000)).unwrap();
8888        desk.exchange_notes(&folder, &key, NOW, &desk_notes);
8889        phone.exchange(&folder, &key, NOW + MINUTE);
8890        // Projects shared on the phone from now on: the desk's newer budget wins there, nothing old goes out.
8891        phone.exchange_notes(&folder, &key, NOW + 2 * MINUTE, &phone_notes);
8892        desk.exchange_notes(&folder, &key, NOW + 3 * MINUTE, &desk_notes);
8893        assert!(ledger(&phone_notes).contains("title = \"Home\"") && ledger(&phone_notes).contains("Rent"), "{}", ledger(&phone_notes));
8894        assert!(ledger(&desk_notes).contains("title = \"Home\""), "{}", ledger(&desk_notes));
8895        // The phone's notes folder moved to an empty one: filled; nothing taken out on the desk.
8896        phone.exchange_notes(&folder, &key, NOW + 4 * MINUTE, &moved);
8897        desk.exchange_notes(&folder, &key, NOW + 5 * MINUTE, &desk_notes);
8898        assert!(ledger(&moved).contains("Rent"), "{}", ledger(&moved));
8899        assert!(ledger(&desk_notes).contains("Rent"), "{}", ledger(&desk_notes));
8900        // Projects no longer shared on the phone: nothing taken out on the desk.
8901        phone.exchange(&folder, &key, NOW + 6 * MINUTE);
8902        desk.exchange_notes(&folder, &key, NOW + 7 * MINUTE, &desk_notes);
8903        assert!(ledger(&desk_notes).contains("Rent"), "{}", ledger(&desk_notes));
8904        let _ = std::fs::remove_dir_all(&base);
8905    }
8906
8907    #[test]
8908    fn a_file_emptied_takes_nothing_out_at_once() {
8909        let base = scratch("emptied");
8910        let folder = base.join("Sioul");
8911        let key = quick_key(&folder, "four words make a passphrase").unwrap();
8912        let (desk, laptop) = (Computer::new(&base, "desk"), Computer::new(&base, "laptop"));
8913        let settings = "language = \"fr\"\n\n[tasks]\nkind = \"plan\"\n";
8914        desk.write("config/config.toml", settings);
8915        desk.exchange(&folder, &key, NOW);
8916        laptop.exchange(&folder, &key, NOW + MINUTE);
8917        // Read of no bytes (a crash, a full disk, written in place): held.
8918        desk.write("config/config.toml", "");
8919        let outcome = desk.exchange(&folder, &key, NOW + 2 * MINUTE);
8920        assert!(outcome.problems.iter().any(|p| p.starts_with("share-emptied")), "{outcome:?}");
8921        laptop.exchange(&folder, &key, NOW + 3 * MINUTE);
8922        assert!(laptop.read("config/config.toml").contains("language = \"fr\""));
8923        // Back before the wait: nothing happened.
8924        desk.write("config/config.toml", settings);
8925        assert_eq!(desk.exchange(&folder, &key, NOW + 4 * MINUTE).sent, 0);
8926        // A list emptied on purpose is taken out at once.
8927        desk.write("config/safe-senders.txt", "a@example.org\n");
8928        desk.exchange(&folder, &key, NOW + 5 * MINUTE);
8929        desk.write("config/safe-senders.txt", "");
8930        desk.exchange(&folder, &key, NOW + 6 * MINUTE);
8931        laptop.exchange(&folder, &key, NOW + 7 * MINUTE);
8932        assert!(!laptop.read("config/safe-senders.txt").contains("a@example.org"));
8933        let _ = std::fs::remove_dir_all(&base);
8934    }
8935
8936    /// The file a server is sent at each change stays small (docs/database.md,
8937    /// "The log"): past `SEGMENT`, a round is continued in a new one, nothing
8938    /// restated; a full round only past `ROUND_SIZE` appended (or later while
8939    /// the connection is metered); old rounds go only before the last full
8940    /// one; a device joining late reads every entry from what is kept.
8941    #[test]
8942    fn a_change_sends_a_small_file_and_a_late_joiner_reads_everything() {
8943        let base = scratch("segments");
8944        let folder = base.join("Sioul");
8945        let key = quick_key(&folder, "four words make a passphrase").unwrap();
8946        let (desk, laptop) = (Computer::new(&base, "desk"), Computer::new(&base, "laptop"));
8947        // A desk holding much: three thousand senders, its opening far past a segment.
8948        let many: String = (0..3000).map(|n| format!("sender{n}@example.org\n")).collect();
8949        desk.write("config/known-senders.txt", &many);
8950        desk.exchange(&folder, &key, NOW);
8951        laptop.exchange(&folder, &key, NOW + 30_000);
8952        let current = |c: &Computer| {
8953            let all = rounds(&folder);
8954            let last = *all.get(&c.id).unwrap().last().unwrap();
8955            std::fs::metadata(round_file(&folder, &c.id, last)).unwrap().len()
8956        };
8957        assert!(current(&desk) > SEGMENT, "the first file holds it all");
8958        // A sender a minute for an hour: each change goes into a small file.
8959        let mut list = many.clone();
8960        for minute in 1..=60 {
8961            list.push_str(&format!("new{minute}@example.org\n"));
8962            desk.write("config/known-senders.txt", &list);
8963            desk.exchange(&folder, &key, NOW + minute * MINUTE);
8964            assert!(current(&desk) <= SEGMENT + 4096, "minute {minute}: the file sent is {} bytes", current(&desk));
8965            laptop.exchange(&folder, &key, NOW + minute * MINUTE + 30_000);
8966        }
8967        assert!(laptop.read("config/known-senders.txt").contains("new60@example.org"));
8968        // Past a megabyte appended: a full round, then the rounds before it go once the laptop read past them.
8969        let more: String = (0..8000).map(|n| format!("later{n}@example.org\n")).collect();
8970        list.push_str(&more);
8971        desk.write("config/known-senders.txt", &list);
8972        desk.exchange(&folder, &key, NOW + 61 * MINUTE);
8973        let full = Memory::load(&desk.memory, &desk.id).full_round;
8974        assert!(full > 1, "a full round opened");
8975        for minute in 62..66 {
8976            laptop.exchange(&folder, &key, NOW + minute * MINUTE + 30_000);
8977            list.push_str(&format!("after{minute}@example.org\n"));
8978            desk.write("config/known-senders.txt", &list);
8979            desk.exchange(&folder, &key, NOW + minute * MINUTE);
8980        }
8981        let kept = rounds(&folder).get(&desk.id).cloned().unwrap();
8982        assert!(kept[0] >= full && kept.len() > 1, "only the last full round and those continuing it: {kept:?} (full {full})");
8983        // A device joining now reads everything from what is kept.
8984        let late = Computer::new(&base, "late");
8985        late.exchange(&folder, &key, NOW + 70 * MINUTE);
8986        let known = late.read("config/known-senders.txt");
8987        for address in ["sender0@", "sender2999@", "new1@", "new60@", "later7999@", "after65@"] {
8988            assert!(known.contains(address), "{address} missing for the late joiner");
8989        }
8990        let _ = std::fs::remove_dir_all(&base);
8991    }
8992
8993    /// While the connection is metered or slow, a full round waits, within
8994    /// `FRUGAL_ROUND_SIZE`; then it opens all the same.
8995    #[test]
8996    fn a_full_round_waits_while_the_connection_is_metered() {
8997        let base = scratch("frugal");
8998        let folder = base.join("Sioul");
8999        let key = quick_key(&folder, "four words make a passphrase").unwrap();
9000        let desk = Computer::new(&base, "desk");
9001        let memory_full = || Memory::load(&desk.memory, &desk.id).full_round;
9002        let lines = |from: usize, count: usize| -> String { (from..from + count).map(|n| format!("s{n}@example.org\n")).collect() };
9003        set_frugal(&desk.memory, true);
9004        let mut list = lines(0, 6000);
9005        desk.write("config/known-senders.txt", &list);
9006        desk.exchange(&folder, &key, NOW);
9007        list.push_str(&lines(6000, 7000));
9008        desk.write("config/known-senders.txt", &list);
9009        desk.exchange(&folder, &key, NOW + MINUTE);
9010        let waited = memory_full();
9011        assert!(Memory::load(&desk.memory, &desk.id).grown > ROUND_SIZE && waited <= 1, "past a megabyte, metered: no full round yet ({waited})");
9012        set_frugal(&desk.memory, false);
9013        list.push_str(&lines(13000, 10));
9014        desk.write("config/known-senders.txt", &list);
9015        desk.exchange(&folder, &key, NOW + 2 * MINUTE);
9016        assert!(memory_full() > 1, "not metered any more: the full round opens");
9017        let _ = std::fs::remove_dir_all(&base);
9018    }
9019
9020    #[test]
9021    fn a_big_round_does_not_start_another_each_minute() {
9022        let base = scratch("big");
9023        let folder = base.join("Sioul");
9024        let key = quick_key(&folder, "four words make a passphrase").unwrap();
9025        let desk = Computer::new(&base, "desk");
9026        desk.write("data/drafts/big.toml", &format!("body = \"{}\"\n", "x".repeat(ROUND_SIZE as usize + 1000)));
9027        desk.exchange(&folder, &key, NOW);
9028        desk.write("config/safe-senders.txt", "a@example.org\n");
9029        desk.exchange(&folder, &key, NOW + MINUTE);
9030        let rounds_then = rounds(&folder).get(&desk.id).map_or(0, |r| *r.last().unwrap());
9031        for minute in 2..6 {
9032            desk.write("config/safe-senders.txt", &format!("a@example.org\nn{minute}@example.org\n"));
9033            desk.exchange(&folder, &key, NOW + minute * MINUTE);
9034        }
9035        assert_eq!(rounds(&folder).get(&desk.id).map_or(0, |r| *r.last().unwrap()), rounds_then, "small changes after a big round stay in it");
9036        let _ = std::fs::remove_dir_all(&base);
9037    }
9038
9039    #[test]
9040    fn a_half_written_line_is_cut_before_the_next() {
9041        let base = scratch("half");
9042        let folder = base.join("Sioul");
9043        let key = quick_key(&folder, "four words make a passphrase").unwrap();
9044        let (desk, laptop) = (Computer::new(&base, "desk"), Computer::new(&base, "laptop"));
9045        desk.write("config/safe-senders.txt", "a@example.org\n");
9046        desk.exchange(&folder, &key, NOW);
9047        let own = round_file(&folder, &desk.id, 1);
9048        let mut text = std::fs::read_to_string(&own).unwrap();
9049        text.push_str("{\"n\":99,\"c\":1,\"s\":\"half");
9050        std::fs::write(&own, text).unwrap();
9051        desk.write("config/safe-senders.txt", "a@example.org\nb@example.org\n");
9052        desk.exchange(&folder, &key, NOW + MINUTE);
9053        assert!(std::fs::read_to_string(&own).unwrap().lines().all(|l| l.ends_with('}')), "whole lines only");
9054        laptop.exchange(&folder, &key, NOW + 2 * MINUTE);
9055        assert!(laptop.read("config/safe-senders.txt").contains("b@example.org"));
9056        let _ = std::fs::remove_dir_all(&base);
9057    }
9058
9059    #[test]
9060    fn the_days_reviews_travel_whole() {
9061        use sioul_core::reviews::{Felt, Mix, Reviews};
9062        let base = scratch("reviews");
9063        let folder = base.join("Nextcloud").join("sioul-shared");
9064        let key = quick_key(&folder, "four words make a passphrase").unwrap();
9065        let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
9066        let at = |ms: i64| std::time::UNIX_EPOCH + std::time::Duration::from_millis(ms as u64);
9067        let month = "data/reviews/2026-10.toml";
9068        // The morning's weather and the end of work, on the desk.
9069        desk.write(month, "[2026-10-06]\nweather = \"haze\"\n\n[2026-10-06.work]\nat = \"2026-10-06T17:04:00+02:00\"\nfelt = \"heavy\"\nmix = \"too_much\"\n");
9070        dated(&desk.path(month), at(NOW - 10 * MINUTE));
9071        desk.exchange(&folder, &key, NOW);
9072        phone.exchange(&folder, &key, NOW + MINUTE);
9073        assert!(phone.read(month).contains("felt = \"heavy\""));
9074        // The same work review answered again on both: its feeling on the desk,
9075        // then its mix on the phone, with the night.
9076        desk.write(month, &desk.read(month).replace("felt = \"heavy\"", "felt = \"usual\""));
9077        dated(&desk.path(month), at(NOW + 90_000));
9078        phone.write(month, &format!("{}\n[2026-10-06.night]\nat = \"2026-10-06T22:10:00+02:00\"\nmix = \"about_right\"\nmemo = \"\"\"\nA walk.\n\"\"\"\n", phone.read(month).replace("mix = \"too_much\"", "mix = \"about_right\"")));
9079        dated(&phone.path(month), at(NOW + 100_000));
9080        phone.exchange(&folder, &key, NOW + 2 * MINUTE);
9081        desk.exchange(&folder, &key, NOW + 3 * MINUTE);
9082        phone.exchange(&folder, &key, NOW + 4 * MINUTE);
9083        for computer in [&desk, &phone] {
9084            let text = computer.read(month);
9085            let reviews = Reviews::load(&computer.path("data/reviews"));
9086            let day = reviews.day("2026-10-06".parse().unwrap()).unwrap();
9087            assert_eq!(day.weather, Some(sioul_core::today::Weather::Haze), "{text}");
9088            // The later answer whole: never the desk's feeling with the phone's mix.
9089            assert_eq!(day.work.as_ref().map(|r| (r.felt, r.mix)), Some((Some(Felt::Heavy), Some(Mix::AboutRight))), "{text}");
9090            assert_eq!(day.night.as_ref().map(|r| (r.mix, r.memo.trim().to_string())), Some((Some(Mix::AboutRight), "A walk.".to_string())), "{text}");
9091        }
9092        let _ = std::fs::remove_dir_all(&base);
9093    }
9094
9095    #[test]
9096    fn two_computers_agree() {
9097        let base = scratch("agree");
9098        let folder = base.join("Nextcloud").join("sioul-shared");
9099        let key = quick_key(&folder, "four words make a passphrase").unwrap();
9100        let (desk, laptop) = (Computer::new(&base, "desk"), Computer::new(&base, "laptop"));
9101
9102        // The desk shares first: its settings, its lists, a draft, a session.
9103        desk.write("config/config.toml", "language = \"fr\"\ncase_store = \"~/Notes\"\n\n[tasks]\nkind = \"plan\"\n\n[[account]]\nid = \"perso\"\nkind = \"imap\"\nmaildir = \"/desk/mail\"\n");
9104        desk.write("config/blocked-senders.txt", "spam@example.org\n");
9105        desk.write("data/drafts/a1.toml", "subject = \"Hello\"\n");
9106        desk.write("data/time/2026-10.toml", "[[session]]\ntask = \"t1\"\nstart = 100\nminutes = 25\n");
9107        assert_eq!(desk.exchange(&folder, &key, NOW).received, 0);
9108
9109        // The laptop joins with its own: its notes folder stays, its blocked sender joins the desk's.
9110        laptop.write("config/config.toml", "language = \"en\"\ncase_store = \"~/Nextcloud/Notes\"\n");
9111        laptop.write("config/blocked-senders.txt", "*@pushy.example.com\n");
9112        let outcome = laptop.exchange(&folder, &key, NOW + MINUTE);
9113        assert!(outcome.received > 0 && outcome.sent > 0, "{outcome:?}");
9114        let config = laptop.read("config/config.toml");
9115        assert!(config.contains("language = \"fr\""), "the shared settings win when joining: {config}");
9116        assert!(config.contains("case_store = \"~/Nextcloud/Notes\""), "where things are stays here: {config}");
9117        assert!(config.contains("id = \"perso\"") && !config.contains("/desk/mail"), "accounts come, without this computer's paths: {config}");
9118        assert_eq!(laptop.read("data/drafts/a1.toml"), "subject = \"Hello\"\n");
9119        assert!(laptop.read("data/time/2026-10.toml").contains("task = \"t1\""));
9120        let blocked = laptop.read("config/blocked-senders.txt");
9121        assert!(blocked.contains("spam@example.org") && blocked.contains("*@pushy.example.com"), "{blocked}");
9122
9123        // The desk hears the laptop's sender.
9124        desk.exchange(&folder, &key, NOW + 2 * MINUTE);
9125        assert!(desk.read("config/blocked-senders.txt").contains("*@pushy.example.com"));
9126
9127        // Both work apart: a session each in the same month, a setting each, a draft sent on the laptop.
9128        desk.write("data/time/2026-10.toml", "[[session]]\ntask = \"t1\"\nstart = 100\nminutes = 25\n\n[[session]]\ntask = \"t2\"\nstart = 200\nminutes = 50\n");
9129        desk.write("config/config.toml", &desk.read("config/config.toml").replace("kind = \"plan\"", "kind = \"list\""));
9130        laptop.write("data/time/2026-10.toml", &format!("{}\n[[session]]\nproject = \"p\"\nstart = 300\nminutes = 10\n", laptop.read("data/time/2026-10.toml")));
9131        let config = laptop.read("config/config.toml").replace("language = \"fr\"", "language = \"de\"");
9132        laptop.write("config/config.toml", &config);
9133        std::fs::remove_file(laptop.path("data/drafts/a1.toml")).unwrap();
9134        desk.exchange(&folder, &key, NOW + 3 * MINUTE);
9135        laptop.exchange(&folder, &key, NOW + 4 * MINUTE);
9136        desk.exchange(&folder, &key, NOW + 5 * MINUTE);
9137        for computer in [&desk, &laptop] {
9138            let time = computer.read("data/time/2026-10.toml");
9139            assert!(time.contains("\"t1\"") && time.contains("\"t2\"") && time.contains("project = \"p\""), "{time}");
9140            let config = computer.read("config/config.toml");
9141            assert!(config.contains("language = \"de\"") && config.contains("kind = \"list\""), "{config}");
9142            assert!(!computer.path("data/drafts/a1.toml").exists(), "a draft sent on one is gone on both");
9143        }
9144        assert!(desk.read("config/config.toml").contains("maildir = \"/desk/mail\""), "the desk keeps its own path");
9145
9146        // Nothing changed: nothing goes out.
9147        assert_eq!(desk.exchange(&folder, &key, NOW + 6 * MINUTE).sent, 0);
9148        assert_eq!(laptop.exchange(&folder, &key, NOW + 7 * MINUTE).sent, 0);
9149        let _ = std::fs::remove_dir_all(&base);
9150    }
9151
9152    #[test]
9153    fn projects_and_budgets_travel_when_asked() {
9154        let base = scratch("projects");
9155        let folder = base.join("Nextcloud").join("Documents").join("Sioul");
9156        let key = quick_key(&folder, "four words make a passphrase").unwrap();
9157        let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
9158        let (desk_notes, phone_notes) = (base.join("desk-notes"), base.join("phone-notes"));
9159        let projects = |notes: &Path| std::fs::read_to_string(sioul_core::projects::file_in(notes)).unwrap_or_default();
9160        let ledger = |notes: &Path| std::fs::read_to_string(notes.join("sioul-budgets.toml")).unwrap_or_default();
9161        let bank = |notes: &Path| std::fs::read_to_string(notes.join("sioul-bank.toml")).unwrap_or_default();
9162        let movement = |id: &str, label: &str| format!("\n[[movement]]\naccount = \"main\"\ndate = 2026-10-01\namount = -20.0\nlabel = \"{label}\"\nid = \"{id}\"\n");
9163        std::fs::create_dir_all(&desk_notes).unwrap();
9164        std::fs::write(desk_notes.join("sioul-cases.toml"), "[[case]]\nid = \"acme\"\ntitle = \"Acme site\"\nstatus = \"open\"\n\n[[case.route]]\nfrom_domains = [\"acme.example\"]\n").unwrap();
9165        std::fs::write(desk_notes.join("sioul-budgets.toml"), "[[budget]]\nid = \"home\"\ntitle = \"Home\"\n\n[[line]]\nbudget = \"home\"\ndate = \"2026-10-01\"\namount = -620.0\nlabel = \"Rent\"\n").unwrap();
9166        std::fs::write(desk_notes.join("sioul-bank.toml"), format!("[[account]]\nid = \"main\"\ntitle = \"Main\"\nbalance = 1200.0\nas_of = 2026-10-01\n{}", movement("fitid-1", "Rent"))).unwrap();
9167        desk.exchange_notes(&folder, &key, NOW, &desk_notes);
9168
9169        // The phone, with a notes folder of its own: the project, the budget and the bank come.
9170        phone.exchange_notes(&folder, &key, NOW + MINUTE, &phone_notes);
9171        assert!(projects(&phone_notes).contains("id = \"acme\"") && projects(&phone_notes).contains("acme.example"), "{}", projects(&phone_notes));
9172        assert!(ledger(&phone_notes).contains("Rent"), "{}", ledger(&phone_notes));
9173        assert!(bank(&phone_notes).contains("fitid-1") && bank(&phone_notes).contains("1200"), "{}", bank(&phone_notes));
9174        // The phone had no projects' file: it makes one of the new name, the desk keeps its own.
9175        assert!(phone_notes.join(sioul_core::projects::FILE).exists() && projects(&phone_notes).contains("[[project]]") && !projects(&phone_notes).contains("[[case"), "{}", projects(&phone_notes));
9176        assert!(projects(&desk_notes).contains("[[case]]") && !desk_notes.join(sioul_core::projects::FILE).exists());
9177
9178        // A line each, apart: both kept on both.
9179        std::fs::write(desk_notes.join("sioul-budgets.toml"), format!("{}\n[[line]]\nbudget = \"home\"\ndate = \"2026-10-02\"\namount = -12.5\nlabel = \"Bread\"\n", ledger(&desk_notes))).unwrap();
9180        std::fs::write(phone_notes.join("sioul-budgets.toml"), format!("{}\n[[line]]\nbudget = \"home\"\ndate = \"2026-10-02\"\namount = -3.2\nlabel = \"Coffee\"\n", ledger(&phone_notes))).unwrap();
9181        // An export taken in on each: both movements on both.
9182        std::fs::write(desk_notes.join("sioul-bank.toml"), format!("{}{}", bank(&desk_notes), movement("fitid-2", "Bread"))).unwrap();
9183        std::fs::write(phone_notes.join("sioul-bank.toml"), format!("{}{}", bank(&phone_notes), movement("fitid-3", "Coffee"))).unwrap();
9184        desk.exchange_notes(&folder, &key, NOW + 2 * MINUTE, &desk_notes);
9185        phone.exchange_notes(&folder, &key, NOW + 3 * MINUTE, &phone_notes);
9186        desk.exchange_notes(&folder, &key, NOW + 4 * MINUTE, &desk_notes);
9187        for notes in [&desk_notes, &phone_notes] {
9188            let text = ledger(notes);
9189            assert!(text.contains("Rent") && text.contains("Bread") && text.contains("Coffee"), "{text}");
9190            let mut ids: Vec<String> = sioul_core::bank::Bank::load(notes).unwrap().movements.into_iter().map(|m| m.id).collect();
9191            ids.sort();
9192            assert_eq!(ids, ["fitid-1", "fitid-2", "fitid-3"], "{}", bank(notes));
9193        }
9194
9195        // Each file keeps its names: a project made on the phone reaches the desk's
9196        // `[[case]]`, as an older Sioul reads it; renamed on the desk, the file
9197        // still exchanges under the same records, both ways.
9198        use sioul_core::projects::{ProjectEdit, ProjectStore, file_in, rename_file, save_project, set_ai};
9199        save_project(&file_in(&phone_notes), "", &ProjectEdit { title: "Garden".into(), ..ProjectEdit::default() }).unwrap();
9200        phone.exchange_notes(&folder, &key, NOW + 5 * MINUTE, &phone_notes);
9201        desk.exchange_notes(&folder, &key, NOW + 6 * MINUTE, &desk_notes);
9202        assert!(projects(&desk_notes).matches("[[case]]").count() == 2 && !projects(&desk_notes).contains("[[project"), "{}", projects(&desk_notes));
9203        // Added after a project with a route: each route stays under its own project.
9204        let desk_store = ProjectStore::load(&desk_notes).unwrap();
9205        assert!(desk_store.get("acme").unwrap().routes.len() == 1 && desk_store.get("garden").unwrap().routes.is_empty(), "{}", projects(&desk_notes));
9206        rename_file(&desk_notes).unwrap();
9207        set_ai(&file_in(&desk_notes), "acme", true).unwrap();
9208        desk.exchange_notes(&folder, &key, NOW + 7 * MINUTE, &desk_notes);
9209        phone.exchange_notes(&folder, &key, NOW + 8 * MINUTE, &phone_notes);
9210        desk.exchange_notes(&folder, &key, NOW + 9 * MINUTE, &desk_notes);
9211        for notes in [&desk_notes, &phone_notes] {
9212            let store = ProjectStore::load(notes).unwrap();
9213            let mut ids: Vec<&str> = store.projects.iter().map(|p| p.id.as_str()).collect();
9214            ids.sort();
9215            assert_eq!(ids, ["acme", "garden"], "{}", projects(notes));
9216            assert!(store.get("acme").unwrap().ai && store.get("acme").unwrap().routes.len() == 1 && store.get("garden").unwrap().routes.is_empty(), "{}", projects(notes));
9217            assert!(!projects(notes).contains("[[case"), "{}", projects(notes));
9218        }
9219        assert!(desk_notes.join(sioul_core::projects::BEFORE_RENAME).exists());
9220
9221        // Without the choice, a notes folder stays home.
9222        let laptop = Computer::new(&base, "laptop");
9223        // A literal string: a Windows path's backslashes are no escapes.
9224        laptop.write("config/config.toml", &format!("case_store = '{}'\n", base.join("laptop-notes").display()));
9225        let config: Config = toml::from_str(&laptop.read("config/config.toml")).unwrap();
9226        assert!(stores(&config, &laptop.roots).iter().all(|s| !s.name.starts_with("notes/")));
9227        let _ = std::fs::remove_dir_all(&base);
9228    }
9229
9230    #[test]
9231    fn what_was_read_is_known_and_a_lost_record_comes_back() {
9232        let base = scratch("doses");
9233        let folder = base.join("shared");
9234        let key = quick_key(&folder, "four words make a passphrase").unwrap();
9235        let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
9236        desk.write("state/health-state.toml", "[taken]\n\"d@100\" = 110\n");
9237        desk.exchange(&folder, &key, NOW);
9238        phone.exchange(&folder, &key, NOW + MINUTE);
9239        assert!(phone.read("state/health-state.toml").contains("d@100"));
9240        // What the desk wrote, the phone read: as far as the desk's claim would say.
9241        let wrote = written(&desk.memory, &desk.id).unwrap();
9242        assert!(wrote.1 > 0);
9243        let read = heard(&phone.memory, &phone.id);
9244        assert!(read.complete(&desk.id, wrote));
9245        assert!(!read.complete(&desk.id, (wrote.0, wrote.1 + 1)), "a record not read yet is not known");
9246        assert!(read.complete("someone", (1, 0)), "nothing written, nothing to read");
9247
9248        // A line of the desk's that does not read is said, never passed over; what follows is still read.
9249        desk.write("state/health-state.toml", "[taken]\n\"d@100\" = 110\n\"d@200\" = 210\n");
9250        desk.exchange(&folder, &key, NOW + 2 * MINUTE);
9251        let round = round_file(&folder, &desk.id, wrote.0);
9252        let mut text = std::fs::read_to_string(&round).unwrap();
9253        let last = text.trim_end().rfind('\n').map_or(0, |i| i + 1);
9254        text.insert_str(last, "{broken\n");
9255        std::fs::write(&round, text).unwrap();
9256        let outcome = phone.exchange(&folder, &key, NOW + 3 * MINUTE);
9257        assert!(outcome.problems.iter().any(|p| p == &format!("share-other-line:{}", desk.id)), "{:?}", outcome.problems);
9258        assert!(heard(&phone.memory, &phone.id).broken.contains_key(&desk.id));
9259        assert!(phone.read("state/health-state.toml").contains("d@200"), "the next line came");
9260
9261        // The phone's record is lost: set aside, started again empty, rebuilt from the records.
9262        phone.write("state/health-state.toml", &format!("{}\"d@300\" = 310\n", phone.read("state/health-state.toml")));
9263        phone.exchange(&folder, &key, NOW + 4 * MINUTE);
9264        assert!(desk.exchange(&folder, &key, NOW + 5 * MINUTE).received > 0);
9265        assert!(desk.read("state/health-state.toml").contains("d@300"));
9266        std::fs::remove_file(phone.path("state/health-state.toml")).unwrap();
9267        rebuild(&phone.memory, &phone.id, &["state/health-state.toml"]).unwrap();
9268        // A dose marked meanwhile, in a new record holding only it.
9269        phone.write("state/health-state.toml", "[taken]\n\"d@400\" = 410\n");
9270        phone.exchange(&folder, &key, NOW + 6 * MINUTE);
9271        let back = phone.read("state/health-state.toml");
9272        assert!(back.contains("d@100") && back.contains("d@200") && back.contains("d@300") && back.contains("d@400"), "{back}");
9273        // Nothing taken out on the desk: every mark is still there, the new one too.
9274        desk.exchange(&folder, &key, NOW + 7 * MINUTE);
9275        let there = desk.read("state/health-state.toml");
9276        assert!(there.contains("d@100") && there.contains("d@200") && there.contains("d@300") && there.contains("d@400"), "{there}");
9277        let _ = std::fs::remove_dir_all(&base);
9278    }
9279
9280    #[test]
9281    fn the_later_word_wins() {
9282        let base = scratch("later");
9283        let folder = base.join("shared");
9284        let key = quick_key(&folder, "a passphrase of some length").unwrap();
9285        let (a, b) = (Computer::new(&base, "a"), Computer::new(&base, "b"));
9286        a.write("config/safe-senders.txt", "friend@example.org\n");
9287        a.exchange(&folder, &key, NOW);
9288        b.exchange(&folder, &key, NOW + MINUTE);
9289        assert!(b.read("config/safe-senders.txt").contains("friend@example.org"));
9290
9291        // Safe on one, then blocked later on the other: blocked everywhere, and safe nowhere.
9292        b.write("config/safe-senders.txt", "");
9293        b.write("config/blocked-senders.txt", "friend@example.org\n");
9294        b.exchange(&folder, &key, NOW + 2 * MINUTE);
9295        a.exchange(&folder, &key, NOW + 3 * MINUTE);
9296        assert!(!a.read("config/safe-senders.txt").contains("friend@example.org"));
9297        assert!(a.read("config/blocked-senders.txt").contains("friend@example.org"));
9298
9299        // A list that cannot be read is left alone, and nothing of it is taken out elsewhere.
9300        a.write("state/quiet.toml", "work_until = 5\n");
9301        let sent = a.exchange(&folder, &key, NOW + 4 * MINUTE);
9302        let received = b.exchange(&folder, &key, NOW + 5 * MINUTE);
9303        assert!(b.read("state/quiet.toml").contains("work_until = 5"), "{sent:?} {received:?}");
9304        b.write("state/quiet.toml", "work_until = [ broken");
9305        b.exchange(&folder, &key, NOW + 6 * MINUTE);
9306        a.exchange(&folder, &key, NOW + 7 * MINUTE);
9307        assert!(a.read("state/quiet.toml").contains("work_until = 5"));
9308        let _ = std::fs::remove_dir_all(&base);
9309    }
9310
9311    #[test]
9312    fn sealed_with_one_passphrase() {
9313        let base = scratch("seal");
9314        let folder = base.join("shared");
9315        let key = quick_key(&folder, "the right passphrase").unwrap();
9316        assert_eq!(quick_key(&folder, "the right passphrase"), Ok(key));
9317        assert_eq!(quick_key(&folder, "another passphrase"), Err(Refused::WrongPassphrase));
9318
9319        // What travels says nothing of what it holds.
9320        let a = Computer::new(&base, "a");
9321        a.write("config/safe-senders.txt", "friend@example.org\n");
9322        a.exchange(&folder, &key, NOW);
9323        let file = std::fs::read_to_string(round_file(&folder, &a.id, 1)).unwrap();
9324        assert!(!file.contains("friend") && !file.contains("safe-senders"), "{file}");
9325        assert_eq!(others(&folder, "someone else").len(), 1);
9326        // A seal asking more than this computer can give is refused before it is tried.
9327        let text = std::fs::read_to_string(folder.join("seal.toml")).unwrap();
9328        std::fs::write(folder.join("seal.toml"), text.replace("memory_kib = 64", "memory_kib = 4294967295")).unwrap();
9329        assert!(matches!(quick_key(&folder, "the right passphrase"), Err(Refused::Other(_))));
9330        let _ = std::fs::remove_dir_all(&base);
9331    }
9332
9333    #[test]
9334    fn records_stay_in_their_folders() {
9335        let roots = Roots { config: PathBuf::from("config"), data: PathBuf::from("data"), state: PathBuf::from("state") };
9336        let stores = stores(&Config::default(), &roots);
9337        assert!(locate(&stores, "data/drafts/a1.toml").is_some());
9338        for file in ["data/drafts/../x", "data/drafts/", "data/drafts//x", "data/drafts/a\\..\\..\\x", "data/drafts/C:x"] {
9339            assert!(locate(&stores, file).is_none(), "{file}");
9340        }
9341    }
9342
9343    #[test]
9344    fn rounds_start_over_and_old_ones_go() {
9345        let base = scratch("rounds");
9346        let folder = base.join("shared");
9347        let key = quick_key(&folder, "a passphrase of some length").unwrap();
9348        let (a, b) = (Computer::new(&base, "a"), Computer::new(&base, "b"));
9349        a.write("config/safe-senders.txt", "friend@example.org\n");
9350        a.exchange(&folder, &key, NOW);
9351        b.exchange(&folder, &key, NOW + MINUTE);
9352        // A large file, changed until a full round opens (a megabyte appended), the rounds before it continued in between.
9353        let mut now = NOW + MINUTE;
9354        while Memory::load(&a.memory, &a.id).full_round <= 1 {
9355            now += MINUTE;
9356            a.write("data/drafts/2026-10-01.json", &format!("{{\"steps\": {now}, \"pad\": \"{}\"}}", "x".repeat(100_000)));
9357            a.exchange(&folder, &key, now);
9358        }
9359        assert!(round_file(&folder, &a.id, 1).exists(), "kept until b has read past it");
9360        // b reads past round 1.
9361        b.exchange(&folder, &key, now + MINUTE);
9362        assert!(b.read("data/drafts/2026-10-01.json").contains(&format!("\"steps\": {now}")));
9363        a.exchange(&folder, &key, now + 2 * MINUTE);
9364        assert!(!round_file(&folder, &a.id, 1).exists(), "read by everyone: removed");
9365        // A third computer joins later: round 2 alone holds the day.
9366        let c = Computer::new(&base, "c");
9367        c.exchange(&folder, &key, now + 3 * MINUTE);
9368        assert!(c.read("data/drafts/2026-10-01.json").contains(&format!("\"steps\": {now}")));
9369        let _ = std::fs::remove_dir_all(&base);
9370    }
9371
9372    #[test]
9373    fn notes_travel_file_by_file_whatever_carries_them() {
9374        for how in [Carrying::Exact, Carrying::SizeOnly, Carrying::Late, Carrying::StaleCopies, Carrying::Placeholders] {
9375            let base = scratch(&format!("notes-{how:?}").to_lowercase());
9376            let (server, phone_folder) = (base.join("server").join("Sioul"), base.join("phone").join("Sioul"));
9377            let key = quick_key(&server, "four words make a passphrase").unwrap();
9378            let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
9379            let (desk_notes, phone_notes) = (base.join("desk-notes"), base.join("phone-notes"));
9380            put(&desk_notes, "lease.md", b"# Lease\nSigned on 2 October.\n");
9381            put(&desk_notes, "admin/taxes.md", b"# Taxes\nNotice received.\n");
9382            put(&desk_notes, "admin/scan.pdf", &noise(1_500_000, 1));
9383            // Made by tools, or carried by another part: they stay.
9384            for name in ["lease.md~", ".lease.md.swp", "lease (conflicted copy 2026-10-05 140533).md", "taxes.sync-conflict-20261005-140533-ABCDEFG.md", "~$letter.docx", "sioul-cases.toml", "sioul-projects.toml", "sioul-cases.toml.before-rename", "papers/passport.pdf"] {
9385                put(&desk_notes, name, b"stays home");
9386            }
9387            let mut carrier = Carrier::new(how);
9388            let run = |carrier: &mut Carrier, minutes: std::ops::Range<i64>| {
9389                for minute in minutes {
9390                    let now = NOW + minute * MINUTE;
9391                    desk.exchange_with(&server, &key, now, &desk_notes, &["notes"]);
9392                    carrier.carry(&server, &phone_folder);
9393                    phone.exchange_with(&phone_folder, &key, now + 30_000, &phone_notes, &["notes"]);
9394                }
9395            };
9396            run(&mut carrier, 0..20);
9397            let there = files_in(&phone_notes);
9398            assert_eq!(there.keys().map(String::as_str).collect::<Vec<_>>(), ["admin/scan.pdf", "admin/taxes.md", "lease.md"], "{how:?}");
9399            assert!(there["admin/scan.pdf"] == noise(1_500_000, 1), "{how:?}: the scan came whole");
9400            // The phone changes a note and writes another; the desk takes one out.
9401            put(&phone_notes, "lease.md", b"# Lease\nSigned on 2 October.\nKeys: two.\n");
9402            put(&phone_notes, "new.md", b"# New\n");
9403            std::fs::remove_file(desk_notes.join("admin/taxes.md")).unwrap();
9404            run(&mut carrier, 20..40);
9405            for notes in [&desk_notes, &phone_notes] {
9406                let files = files_in(notes);
9407                assert!(!files.contains_key("admin/taxes.md"), "{how:?}: {}", notes.display());
9408                assert_eq!(files.get("lease.md").map(Vec::as_slice), Some(&b"# Lease\nSigned on 2 October.\nKeys: two.\n"[..]), "{how:?}: {}", notes.display());
9409                assert_eq!(files.get("new.md").map(Vec::as_slice), Some(&b"# New\n"[..]), "{how:?}: {}", notes.display());
9410                assert!(copies_of(notes, "lease").is_empty(), "{how:?}: no conflict where there was none");
9411            }
9412            // Each content sealed once, apart; nothing of the notes readable in the folder.
9413            let sealed = std::fs::read_dir(server.join("blobs")).unwrap().filter_map(Result::ok).filter(|e| !e.file_name().to_string_lossy().starts_with('.')).count();
9414            assert_eq!(sealed, 5, "{how:?}: the lease twice, the taxes, the scan, the new note");
9415            let mut everything = Vec::new();
9416            list_files(&server, &server, &[], &mut everything);
9417            for (_, path) in everything {
9418                let bytes = std::fs::read(&path).unwrap();
9419                for secret in [&b"Signed on"[..], b"Taxes", b"Keys: two", b"stays home", b"lease.md"] {
9420                    assert!(!bytes.windows(secret.len()).any(|w| w == secret), "{how:?}: {} shows {}", path.display(), String::from_utf8_lossy(secret));
9421                }
9422            }
9423            let _ = std::fs::remove_dir_all(&base);
9424        }
9425    }
9426
9427    #[test]
9428    fn a_note_changed_on_both_keeps_both_whatever_carries_them() {
9429        for how in [Carrying::Exact, Carrying::SizeOnly, Carrying::Late, Carrying::StaleCopies, Carrying::Placeholders] {
9430            let base = scratch(&format!("notes-both-{how:?}").to_lowercase());
9431            let (server, phone_folder) = (base.join("server").join("Sioul"), base.join("phone").join("Sioul"));
9432            let key = quick_key(&server, "four words make a passphrase").unwrap();
9433            let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
9434            let (desk_notes, phone_notes) = (base.join("desk-notes"), base.join("phone-notes"));
9435            put(&desk_notes, "lease.md", b"Rent: 620\n");
9436            put(&desk_notes, "taxes.md", b"Notice received.\n");
9437            let mut carrier = Carrier::new(how);
9438            let run = |carrier: &mut Carrier, minutes: std::ops::Range<i64>| {
9439                for minute in minutes {
9440                    let now = NOW + minute * MINUTE;
9441                    desk.exchange_with(&server, &key, now, &desk_notes, &["notes"]);
9442                    carrier.carry(&server, &phone_folder);
9443                    phone.exchange_with(&phone_folder, &key, now + 30_000, &phone_notes, &["notes"]);
9444                }
9445            };
9446            run(&mut carrier, 0..10);
9447            assert_eq!(files_in(&phone_notes).len(), 2, "{how:?}");
9448            // In the same minute: the lease changed on both; the taxes taken out on the desk, changed on the phone.
9449            put(&desk_notes, "lease.md", b"Rent: 640\n");
9450            put(&phone_notes, "lease.md", b"Rent: 620\nKeys: two\n");
9451            std::fs::remove_file(desk_notes.join("taxes.md")).unwrap();
9452            put(&phone_notes, "taxes.md", b"Notice received.\nPaid on 3 October.\n");
9453            run(&mut carrier, 10..40);
9454            for notes in [&desk_notes, &phone_notes] {
9455                let files = files_in(notes);
9456                assert_eq!(files.get("lease.md").map(Vec::as_slice), Some(&b"Rent: 620\nKeys: two\n"[..]), "{how:?}: {}", notes.display());
9457                assert_eq!(copies_of(notes, "lease"), [b"Rent: 640\n".to_vec()], "{how:?}: {}: {:?}", notes.display(), files.keys().collect::<Vec<_>>());
9458                assert_eq!(files.get("taxes.md").map(Vec::as_slice), Some(&b"Notice received.\nPaid on 3 October.\n"[..]), "{how:?}: {}", notes.display());
9459                assert_eq!(files.len(), 3, "{how:?}: {}: {:?}", notes.display(), files.keys().collect::<Vec<_>>());
9460            }
9461            let _ = std::fs::remove_dir_all(&base);
9462        }
9463    }
9464
9465    #[test]
9466    fn a_big_folder_sends_only_what_changed() {
9467        let base = scratch("big-folder");
9468        let notes = base.join("notes");
9469        // Shared through a folder inside the notes folder, as Sioul may suggest: not carried as notes.
9470        let folder = notes.join("sioul-shared");
9471        let key = quick_key(&folder, "four words make a passphrase").unwrap();
9472        let desk = Computer::new(&base, "desk");
9473        for n in 0..300 {
9474            put(&notes, &format!("days/{n:03}.md"), format!("# Day {n}\n").as_bytes());
9475        }
9476        for n in 0..3 {
9477            put(&notes, &format!("scans/{n}.pdf"), &noise(1_500_000, n));
9478        }
9479        // Read after they changed, not in the same millisecond (such a file is read again, its time too coarse to trust).
9480        std::thread::sleep(std::time::Duration::from_millis(5));
9481        let blobs = || std::fs::read_dir(folder.join("blobs")).unwrap().count();
9482        assert_eq!(desk.exchange_with(&folder, &key, NOW, &notes, &["notes"]).sent, 303);
9483        assert_eq!(blobs(), 303);
9484        // Nothing changed: no file read again, nothing sent.
9485        crate::blobs::HASHED.with(|n| n.set(0));
9486        assert_eq!(desk.exchange_with(&folder, &key, NOW + MINUTE, &notes, &["notes"]).sent, 0);
9487        assert_eq!(crate::blobs::HASHED.with(std::cell::Cell::get), 0, "a file of the same size and time is not read");
9488        // One file touched (its time, not its content), one changed: both read, one sent, sealed alone.
9489        std::fs::File::options().write(true).open(notes.join("days/007.md")).unwrap().set_modified(std::time::SystemTime::now() - std::time::Duration::from_secs(60)).unwrap();
9490        put(&notes, "scans/1.pdf", &noise(1_500_000, 9));
9491        let outcome = desk.exchange_with(&folder, &key, NOW + 2 * MINUTE, &notes, &["notes"]);
9492        assert_eq!(outcome.sent, 1, "{outcome:?}");
9493        assert_eq!(crate::blobs::HASHED.with(std::cell::Cell::get), 2);
9494        assert_eq!(blobs(), 304);
9495        // Too big to travel: it stays, said; nothing else moves.
9496        std::fs::File::create(notes.join("scans/film.mp4")).unwrap().set_len(crate::blobs::LARGEST + 1).unwrap();
9497        let outcome = desk.exchange_with(&folder, &key, NOW + 3 * MINUTE, &notes, &["notes"]);
9498        assert!(outcome.problems.iter().any(|p| p == "share-too-big:files/notes/scans/film.mp4"), "{outcome:?}");
9499        assert_eq!(outcome.sent, 0);
9500        let _ = std::fs::remove_dir_all(&base);
9501    }
9502
9503    #[test]
9504    fn a_note_changed_on_both_keeps_both() {
9505        for desk_later in [false, true] {
9506            let base = scratch(&format!("both-{desk_later}"));
9507            let folder = base.join("Sioul");
9508            let key = quick_key(&folder, "four words make a passphrase").unwrap();
9509            let (desk, laptop) = (Computer::new(&base, "desk"), Computer::new(&base, "laptop"));
9510            let (desk_notes, laptop_notes) = (base.join("desk-notes"), base.join("laptop-notes"));
9511            put(&desk_notes, "lease.md", b"Rent: 620\n");
9512            desk.exchange_with(&folder, &key, NOW, &desk_notes, &["notes"]);
9513            laptop.exchange_with(&folder, &key, NOW + MINUTE, &laptop_notes, &["notes"]);
9514            assert_eq!(std::fs::read(laptop_notes.join("lease.md")).unwrap(), b"Rent: 620\n");
9515            // Changed on both, apart; the one that exchanges later is the later word.
9516            put(&desk_notes, "lease.md", b"Rent: 640\n");
9517            put(&laptop_notes, "lease.md", b"Rent: 620\nKeys: two\n");
9518            let (first, second): (&Computer, &Computer) = if desk_later { (&laptop, &desk) } else { (&desk, &laptop) };
9519            let notes = |c: &Computer| if std::ptr::eq(c, &desk) { desk_notes.clone() } else { laptop_notes.clone() };
9520            let mut said = Vec::new();
9521            for minute in 2..10 {
9522                let (who, at) = if minute % 2 == 0 { (first, minute) } else { (second, minute) };
9523                said.extend(who.exchange_with(&folder, &key, NOW + at * MINUTE, &notes(who), &["notes"]).problems);
9524            }
9525            let (later, earlier): (&[u8], &[u8]) = if desk_later { (b"Rent: 640\n", b"Rent: 620\nKeys: two\n") } else { (b"Rent: 620\nKeys: two\n", b"Rent: 640\n") };
9526            for notes in [&desk_notes, &laptop_notes] {
9527                assert_eq!(std::fs::read(notes.join("lease.md")).unwrap(), later, "desk later: {desk_later}");
9528                assert_eq!(copies_of(notes, "lease"), [earlier.to_vec()], "desk later: {desk_later}: {:?}", files_in(notes).keys().collect::<Vec<_>>());
9529            }
9530            assert!(said.iter().any(|p| p.starts_with("share-conflict:lease (")), "{said:?}");
9531            // Settled: nothing more goes either way.
9532            assert_eq!(desk.exchange_with(&folder, &key, NOW + 10 * MINUTE, &desk_notes, &["notes"]).sent, 0);
9533            assert_eq!(laptop.exchange_with(&folder, &key, NOW + 11 * MINUTE, &laptop_notes, &["notes"]).sent, 0);
9534            let _ = std::fs::remove_dir_all(&base);
9535        }
9536    }
9537
9538    #[test]
9539    fn a_part_switched_off_takes_nothing_out_and_sends_nothing() {
9540        let base = scratch("part-off");
9541        let folder = base.join("Sioul");
9542        let key = quick_key(&folder, "four words make a passphrase").unwrap();
9543        let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
9544        let (desk_notes, phone_notes) = (base.join("desk-notes"), base.join("phone-notes"));
9545        for name in ["a", "b", "c", "d", "e", "f"] {
9546            put(&desk_notes, &format!("{name}.md"), format!("# {name}\n").as_bytes());
9547        }
9548        desk.exchange_with(&folder, &key, NOW, &desk_notes, &["notes"]);
9549        phone.exchange_with(&folder, &key, NOW + MINUTE, &phone_notes, &["notes"]);
9550        assert_eq!(files_in(&phone_notes).len(), 6);
9551        // Notes switched off on the phone, then changed there; the desk changes one meanwhile.
9552        phone.exchange_with(&folder, &key, NOW + 2 * MINUTE, &phone_notes, &[]);
9553        std::fs::remove_file(phone_notes.join("a.md")).unwrap();
9554        put(&phone_notes, "b.md", b"# b, changed on the phone while off\n");
9555        put(&phone_notes, "new.md", b"# new\n");
9556        put(&desk_notes, "c.md", b"# c, changed on the desk\n");
9557        desk.exchange_with(&folder, &key, NOW + 3 * MINUTE, &desk_notes, &["notes"]);
9558        assert_eq!(phone.exchange_with(&folder, &key, NOW + 4 * MINUTE, &phone_notes, &[]).sent, 0, "a part switched off sends nothing");
9559        desk.exchange_with(&folder, &key, NOW + 5 * MINUTE, &desk_notes, &["notes"]);
9560        let desk_files = files_in(&desk_notes);
9561        assert!(desk_files.contains_key("a.md") && desk_files["b.md"] == b"# b\n" && !desk_files.contains_key("new.md"), "nothing taken out, nothing came: {desk_files:?}");
9562        assert_eq!(std::fs::read(phone_notes.join("c.md")).unwrap(), b"# c\n", "nothing comes into a part switched off");
9563        // Switched on again: it joins as a new device. What the others hold comes
9564        // first, a note changed here meanwhile is kept beside, what only it holds goes.
9565        phone.exchange_with(&folder, &key, NOW + 6 * MINUTE, &phone_notes, &["notes"]);
9566        desk.exchange_with(&folder, &key, NOW + 7 * MINUTE, &desk_notes, &["notes"]);
9567        phone.exchange_with(&folder, &key, NOW + 8 * MINUTE, &phone_notes, &["notes"]);
9568        desk.exchange_with(&folder, &key, NOW + 9 * MINUTE, &desk_notes, &["notes"]);
9569        for notes in [&desk_notes, &phone_notes] {
9570            let files = files_in(notes);
9571            assert_eq!(files.get("a.md").map(Vec::as_slice), Some(&b"# a\n"[..]), "{}", notes.display());
9572            assert_eq!(files.get("b.md").map(Vec::as_slice), Some(&b"# b\n"[..]), "{}", notes.display());
9573            assert_eq!(files.get("c.md").map(Vec::as_slice), Some(&b"# c, changed on the desk\n"[..]), "{}", notes.display());
9574            assert_eq!(files.get("new.md").map(Vec::as_slice), Some(&b"# new\n"[..]), "{}", notes.display());
9575            assert_eq!(copies_of(notes, "b"), [b"# b, changed on the phone while off\n".to_vec()], "{}", notes.display());
9576        }
9577        let _ = std::fs::remove_dir_all(&base);
9578    }
9579
9580    /// Item 8 of 9 October: on Unix, what the sharing writes is yours alone,
9581    /// as the calls' logs are: its files 0600, the folders it makes 0700 (the
9582    /// seal, the records, the sealed files, the notes and settings it brings
9583    /// in, the copies and versions it keeps). A file here already readable by
9584    /// others becomes yours alone the next time the sharing writes it; one it
9585    /// never writes keeps its mode: nothing sweeps your folders.
9586    #[cfg(unix)]
9587    #[test]
9588    fn what_the_sharing_writes_is_yours_alone() {
9589        use std::os::unix::fs::PermissionsExt;
9590        let mode = |path: &Path| std::fs::symlink_metadata(path).unwrap().permissions().mode() & 0o777;
9591        let set = |path: &Path, mode: u32| std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)).unwrap();
9592        let base = scratch("yours-alone");
9593        let folder = base.join("Sioul");
9594        let key = quick_key(&folder, "four words make a passphrase").unwrap();
9595        let (desk, laptop) = (Computer::new(&base, "desk"), Computer::new(&base, "laptop"));
9596        let (desk_notes, laptop_notes) = (base.join("desk-notes"), base.join("laptop-notes"));
9597        put(&desk_notes, "admin/lease.md", b"Rent: 620\n");
9598        desk.write("config/config.toml", "language = \"fr\"\n");
9599        // The laptop's own files, as an editor leaves them (others may read
9600        // them): one the desk's version will replace, a setting the desk's
9601        // will join, and a note the sharing never writes here.
9602        put(&laptop_notes, "admin/lease.md", b"Rent: 600\n");
9603        put(&laptop_notes, "mine.md", b"Mine\n");
9604        laptop.write("config/config.toml", "theme = \"dark\"\n");
9605        for path in [laptop_notes.join("admin/lease.md"), laptop_notes.join("mine.md"), laptop.path("config/config.toml")] {
9606            set(&path, 0o644);
9607        }
9608        set(&laptop_notes.join("admin"), 0o755);
9609        // The laptop's lease from the day before: the desk's is the later word.
9610        std::fs::File::options().write(true).open(laptop_notes.join("admin/lease.md")).unwrap().set_modified(std::time::UNIX_EPOCH + std::time::Duration::from_millis((NOW - 24 * 60 * MINUTE) as u64)).unwrap();
9611        let mut now = NOW;
9612        for _ in 0..3 {
9613            now += MINUTE;
9614            desk.exchange_with(&folder, &key, now, &desk_notes, &["notes"]);
9615            now += MINUTE;
9616            laptop.exchange_with(&folder, &key, now, &laptop_notes, &["notes"]);
9617        }
9618        assert_eq!(std::fs::read(laptop_notes.join("admin/lease.md")).unwrap(), b"Rent: 620\n");
9619        assert!(laptop.read("config/config.toml").contains("language = \"fr\""), "{}", laptop.read("config/config.toml"));
9620        assert_eq!(copies_of(&laptop_notes.join("admin"), "lease").len(), 1, "the laptop's version kept beside");
9621        // Written by the sharing: yours alone, whatever they were.
9622        assert_eq!(mode(&laptop_notes.join("admin/lease.md")), 0o600);
9623        assert_eq!(mode(&laptop.path("config/config.toml")), 0o600);
9624        for (name, _) in files_in(&laptop_notes.join("admin")).into_iter().filter(|(n, _)| n.starts_with("lease (")) {
9625            assert_eq!(mode(&laptop_notes.join("admin").join(&name)), 0o600, "{name}");
9626        }
9627        assert_eq!(mode(&desk_notes.join("mine.md")), 0o600, "brought to the desk");
9628        // Never written here by the sharing: as they were.
9629        assert_eq!(mode(&laptop_notes.join("mine.md")), 0o644);
9630        assert_eq!(mode(&desk_notes.join("admin/lease.md")), 0o644);
9631        assert_eq!(mode(&laptop_notes.join("admin")), 0o755, "a folder there already keeps its mode");
9632        // Everything in the sharing folder, and the versions kept: files 0600, folders 0700.
9633        let history = crate::history::root(&laptop.memory);
9634        assert!(!crate::history::files(&history, "settings").is_empty(), "a version kept");
9635        for root in [folder.clone(), history.clone()] {
9636            let mut stack = vec![root];
9637            while let Some(dir) = stack.pop() {
9638                assert_eq!(mode(&dir), 0o700, "{}", dir.display());
9639                for entry in std::fs::read_dir(&dir).unwrap().map(Result::unwrap) {
9640                    let path = entry.path();
9641                    if path.is_dir() {
9642                        stack.push(path);
9643                    } else {
9644                        assert_eq!(mode(&path), 0o600, "{}", path.display());
9645                    }
9646                }
9647            }
9648        }
9649        // A record file from before this was so: yours alone at its next line.
9650        let rounds: Vec<PathBuf> = std::fs::read_dir(&folder).unwrap().map(|e| e.unwrap().path()).filter(|p| p.extension().is_some_and(|e| e == "jsonl") && p.to_string_lossy().contains(&desk.id)).collect();
9651        assert!(!rounds.is_empty(), "the desk's records");
9652        for round in &rounds {
9653            set(round, 0o644);
9654        }
9655        desk.write("config/config.toml", "language = \"en\"\n");
9656        desk.exchange_with(&folder, &key, now + MINUTE, &desk_notes, &["notes"]);
9657        let written: Vec<&PathBuf> = rounds.iter().filter(|r| mode(r) == 0o600).collect();
9658        assert_eq!(written.len(), 1, "the round written to, and it alone: {rounds:?}");
9659        let _ = std::fs::remove_dir_all(&base);
9660    }
9661
9662    #[test]
9663    fn earlier_versions_are_kept_and_put_back() {
9664        let base = scratch("history");
9665        let folder = base.join("Sioul");
9666        let key = quick_key(&folder, "four words make a passphrase").unwrap();
9667        let (desk, laptop) = (Computer::new(&base, "desk"), Computer::new(&base, "laptop"));
9668        let (desk_notes, laptop_notes) = (base.join("desk-notes"), base.join("laptop-notes"));
9669        let history = crate::history::root(&laptop.memory);
9670        let mut now = NOW;
9671        let both = |now: &mut i64| {
9672            *now += MINUTE;
9673            desk.exchange_with(&folder, &key, *now, &desk_notes, &["notes"]);
9674            *now += MINUTE;
9675            laptop.exchange_with(&folder, &key, *now, &laptop_notes, &["notes"])
9676        };
9677        // Three versions written on the desk, of one size and in one millisecond:
9678        // the laptop keeps the two it had before each came.
9679        desk.write("config/config.toml", "language = \"fr\"\n");
9680        for n in 1..=3 {
9681            put(&desk_notes, "lease.md", format!("version {n}\n").as_bytes());
9682            let at = std::time::UNIX_EPOCH + std::time::Duration::from_nanos(NOW as u64 * 1_000_000 + n * 100);
9683            std::fs::File::options().write(true).open(desk_notes.join("lease.md")).unwrap().set_modified(at).unwrap();
9684            both(&mut now);
9685        }
9686        desk.write("config/config.toml", "language = \"en\"\n");
9687        both(&mut now);
9688        assert_eq!(std::fs::read(laptop_notes.join("lease.md")).unwrap(), b"version 3\n");
9689        let kept = crate::history::versions(&history, "notes", "files/notes/lease.md");
9690        let texts: Vec<String> = kept.iter().map(|v| version_text(&history, "notes", "files/notes/lease.md", &v.stamp, &folder, &key)).collect();
9691        assert_eq!(texts, ["version 2\n", "version 1\n"], "the newest first");
9692        assert_eq!(crate::history::files(&history, "settings").iter().map(|(f, _)| f.as_str()).collect::<Vec<_>>(), ["config/config.toml"]);
9693        // Version 1 put back on the laptop: the one there now is kept too, and the next exchange sends it.
9694        let every = laptop.stores_with(&laptop_notes, &|_| true);
9695        put_back(&laptop.memory, Some((&folder, &key)), &every, "notes", "files/notes/lease.md", &kept[1].stamp, now).unwrap();
9696        assert_eq!(std::fs::read(laptop_notes.join("lease.md")).unwrap(), b"version 1\n");
9697        assert_eq!(crate::history::versions(&history, "notes", "files/notes/lease.md").len(), 3);
9698        // The setting as it was before the desk's last change, put back the same way.
9699        let setting = crate::history::versions(&history, "settings", "config/config.toml");
9700        put_back(&laptop.memory, Some((&folder, &key)), &every, "settings", "config/config.toml", &setting[0].stamp, now).unwrap();
9701        assert!(laptop.read("config/config.toml").contains("\"fr\""));
9702        // A file of one part is not put back as another's; nor a version never kept.
9703        assert!(put_back(&laptop.memory, Some((&folder, &key)), &every, "settings", "files/notes/lease.md", &kept[1].stamp, now).is_err());
9704        assert!(put_back(&laptop.memory, Some((&folder, &key)), &every, "notes", "files/notes/lease.md", "2020-01-01T00-00-00.000Z", now).is_err());
9705        now += MINUTE;
9706        assert!(laptop.exchange_with(&folder, &key, now, &laptop_notes, &["notes"]).sent >= 2);
9707        now += MINUTE;
9708        desk.exchange_with(&folder, &key, now, &desk_notes, &["notes"]);
9709        assert_eq!(std::fs::read(desk_notes.join("lease.md")).unwrap(), b"version 1\n", "put back on one, put back on all");
9710        assert!(copies_of(&desk_notes, "lease").is_empty(), "{:?}", files_in(&desk_notes).into_iter().map(|(n, b)| (n, String::from_utf8_lossy(&b).to_string())).collect::<Vec<_>>());
9711        assert!(desk.read("config/config.toml").contains("\"fr\""));
9712        // A note taken out on the desk (not its last: a folder emptied waits
9713        // for a word) goes on the laptop once gone a while, kept there, and can be put back.
9714        put(&desk_notes, "other.md", b"other\n");
9715        both(&mut now);
9716        std::fs::remove_file(desk_notes.join("lease.md")).unwrap();
9717        both(&mut now);
9718        assert!(laptop_notes.join("lease.md").exists(), "not before ten minutes");
9719        for _ in 0..6 {
9720            both(&mut now);
9721        }
9722        assert!(!laptop_notes.join("lease.md").exists());
9723        let kept = crate::history::versions(&history, "notes", "files/notes/lease.md");
9724        put_back(&laptop.memory, Some((&folder, &key)), &every, "notes", "files/notes/lease.md", &kept[0].stamp, now).unwrap();
9725        both(&mut now);
9726        both(&mut now);
9727        assert_eq!(std::fs::read(desk_notes.join("lease.md")).unwrap(), b"version 1\n");
9728        let _ = std::fs::remove_dir_all(&base);
9729    }
9730
9731    #[test]
9732    fn a_damaged_blob_or_record_never_erases_the_file_here() {
9733        let base = scratch("damaged");
9734        let folder = base.join("Sioul");
9735        let key = quick_key(&folder, "four words make a passphrase").unwrap();
9736        let (desk, laptop) = (Computer::new(&base, "desk"), Computer::new(&base, "laptop"));
9737        let (desk_notes, laptop_notes) = (base.join("desk-notes"), base.join("laptop-notes"));
9738        put(&desk_notes, "lease.md", b"version 1\n");
9739        desk.exchange_with(&folder, &key, NOW, &desk_notes, &["notes"]);
9740        laptop.exchange_with(&folder, &key, NOW + MINUTE, &laptop_notes, &["notes"]);
9741        // The next version's sealed file damaged on the way: said, the file here stays as it is.
9742        put(&desk_notes, "lease.md", b"version 2, longer\n");
9743        desk.exchange_with(&folder, &key, NOW + 2 * MINUTE, &desk_notes, &["notes"]);
9744        let sealed = crate::blobs::path(&folder, &key, &crate::blobs::hash_file(&desk_notes.join("lease.md")).unwrap().0);
9745        let mut bytes = std::fs::read(&sealed).unwrap();
9746        let middle = bytes.len() / 2;
9747        bytes[middle] ^= 0x20;
9748        std::fs::write(&sealed, &bytes).unwrap();
9749        for minute in [3, 4] {
9750            let outcome = laptop.exchange_with(&folder, &key, NOW + minute * MINUTE, &laptop_notes, &["notes"]);
9751            assert!(outcome.problems.iter().any(|p| p == "share-damaged:files/notes/lease.md") && outcome.pending == 1, "{outcome:?}");
9752            assert_eq!(std::fs::read(laptop_notes.join("lease.md")).unwrap(), b"version 1\n");
9753        }
9754        // Only a placeholder of the next (a file kept on demand): not there yet, nothing said, nothing lost.
9755        put(&desk_notes, "lease.md", b"version 3\n");
9756        desk.exchange_with(&folder, &key, NOW + 5 * MINUTE, &desk_notes, &["notes"]);
9757        let next = crate::blobs::path(&folder, &key, &crate::blobs::hash_file(&desk_notes.join("lease.md")).unwrap().0);
9758        let whole = std::fs::read(&next).unwrap();
9759        std::fs::write(&next, b"").unwrap();
9760        let outcome = laptop.exchange_with(&folder, &key, NOW + 6 * MINUTE, &laptop_notes, &["notes"]);
9761        assert!(outcome.problems.is_empty() && outcome.pending == 1, "{outcome:?}");
9762        assert_eq!(std::fs::read(laptop_notes.join("lease.md")).unwrap(), b"version 1\n");
9763        // A record that is not one, and a line that does not open: said, the file here stays.
9764        let last = written(&desk.memory, &desk.id).unwrap();
9765        let clock = ((NOW + 7 * MINUTE) as u64) << 16;
9766        let plain = serde_json::to_vec(&Change { k: "files/notes/lease.md#".into(), v: Some("not a reference".into()), b: String::new(), build: String::new(), f: 0 }).unwrap();
9767        let mut text = std::fs::read_to_string(round_file(&folder, &desk.id, last.0)).unwrap();
9768        text.push_str("{\"n\": 99, \"c\": 1, \"s\": \"broken\"}\n");
9769        text.push_str(&(serde_json::to_string(&Line { n: last.1 + 1, c: clock, s: seal(&key, &bound(&desk.id, last.0, last.1 + 1, clock), &plain) }).unwrap() + "\n"));
9770        std::fs::write(round_file(&folder, &desk.id, last.0), text).unwrap();
9771        let outcome = laptop.exchange_with(&folder, &key, NOW + 8 * MINUTE, &laptop_notes, &["notes"]);
9772        assert!(outcome.problems.iter().any(|p| p.starts_with("share-other-seal")) && outcome.problems.iter().any(|p| p == "share-damaged:files/notes/lease.md"), "{outcome:?}");
9773        assert_eq!(std::fs::read(laptop_notes.join("lease.md")).unwrap(), b"version 1\n");
9774        // The whole one comes at last, then a later change: written.
9775        std::fs::write(&next, &whole).unwrap();
9776        put(&desk_notes, "lease.md", b"version 4\n");
9777        desk.exchange_with(&folder, &key, NOW + 9 * MINUTE, &desk_notes, &["notes"]);
9778        laptop.exchange_with(&folder, &key, NOW + 10 * MINUTE, &laptop_notes, &["notes"]);
9779        assert_eq!(std::fs::read(laptop_notes.join("lease.md")).unwrap(), b"version 4\n");
9780        assert!(copies_of(&laptop_notes, "lease").is_empty());
9781        let _ = std::fs::remove_dir_all(&base);
9782    }
9783
9784    #[test]
9785    fn a_notes_folder_found_empty_takes_nothing_out() {
9786        let base = scratch("notes-emptied");
9787        let folder = base.join("Sioul");
9788        let key = quick_key(&folder, "four words make a passphrase").unwrap();
9789        let (desk, laptop) = (Computer::new(&base, "desk"), Computer::new(&base, "laptop"));
9790        let (desk_notes, laptop_notes) = (base.join("desk-notes"), base.join("laptop-notes"));
9791        for n in 0..6 {
9792            put(&desk_notes, &format!("note-{n}.md"), format!("# {n}\n").as_bytes());
9793        }
9794        desk.exchange_with(&folder, &key, NOW, &desk_notes, &["notes"]);
9795        laptop.exchange_with(&folder, &key, NOW + MINUTE, &laptop_notes, &["notes"]);
9796        // Every note gone at once here (a card not mounted, another program): said, nothing taken out there.
9797        for n in 0..6 {
9798            std::fs::remove_file(desk_notes.join(format!("note-{n}.md"))).unwrap();
9799        }
9800        let outcome = desk.exchange_with(&folder, &key, NOW + 2 * MINUTE, &desk_notes, &["notes"]);
9801        assert!(outcome.problems.iter().any(|p| p == "share-vanished:files/notes/:6") && outcome.sent == 0, "{outcome:?}");
9802        // Gone as a folder, the same.
9803        std::fs::remove_dir_all(&desk_notes).unwrap();
9804        assert_eq!(desk.exchange_with(&folder, &key, NOW + 3 * MINUTE, &desk_notes, &["notes"]).sent, 0);
9805        laptop.exchange_with(&folder, &key, NOW + 4 * MINUTE, &laptop_notes, &["notes"]);
9806        assert_eq!(files_in(&laptop_notes).len(), 6);
9807        // One taken out on purpose there goes, as ever, once gone a while; nothing is written here meanwhile.
9808        std::fs::remove_file(laptop_notes.join("note-0.md")).unwrap();
9809        put(&laptop_notes, "note-1.md", b"# 1, changed\n");
9810        laptop.exchange_with(&folder, &key, NOW + 5 * MINUTE, &laptop_notes, &["notes"]);
9811        laptop.exchange_with(&folder, &key, NOW + 16 * MINUTE, &laptop_notes, &["notes"]);
9812        assert!(desk.exchange_with(&folder, &key, NOW + 17 * MINUTE, &desk_notes, &["notes"]).pending > 0);
9813        assert!(!desk_notes.exists());
9814        // Notes switched off and on here: filled again from the others, as a new device's.
9815        desk.exchange_with(&folder, &key, NOW + 18 * MINUTE, &desk_notes, &[]);
9816        desk.exchange_with(&folder, &key, NOW + 19 * MINUTE, &desk_notes, &["notes"]);
9817        let files = files_in(&desk_notes);
9818        assert_eq!(files.keys().map(String::as_str).collect::<Vec<_>>(), ["note-1.md", "note-2.md", "note-3.md", "note-4.md", "note-5.md"]);
9819        assert_eq!(files["note-1.md"], b"# 1, changed\n");
9820        let _ = std::fs::remove_dir_all(&base);
9821    }
9822
9823    /// A record of another device's, sealed: its file, as a sync app would bring it.
9824    pub(super) fn record_of(key: &[u8; 32], computer: &str, n: u64, clock: u64, change: &Change) -> String {
9825        let plain = serde_json::to_vec(change).unwrap();
9826        serde_json::to_string(&Line { n, c: clock, s: seal(key, &bound(computer, 1, n, clock), &plain) }).unwrap() + "\n"
9827    }
9828
9829    pub(super) fn append_to(path: &Path, text: &str) {
9830        let mut file = std::fs::OpenOptions::new().create(true).append(true).open(path).unwrap();
9831        file.write_all(text.as_bytes()).unwrap();
9832    }
9833
9834    pub(super) fn copy_dir(from: &Path, to: &Path) {
9835        for entry in std::fs::read_dir(from).unwrap().filter_map(Result::ok) {
9836            let target = to.join(entry.file_name());
9837            if entry.path().is_dir() {
9838                std::fs::create_dir_all(&target).unwrap();
9839                copy_dir(&entry.path(), &target);
9840            } else {
9841                std::fs::create_dir_all(to).unwrap();
9842                std::fs::copy(entry.path(), target).unwrap();
9843            }
9844        }
9845    }
9846
9847    fn dated(path: &Path, at: std::time::SystemTime) {
9848        std::fs::File::options().write(true).open(path).unwrap().set_modified(at).unwrap();
9849    }
9850
9851    // F1
9852    #[test]
9853    fn a_put_back_takes_out_nothing_added_since() {
9854        let base = scratch("put-back-entries");
9855        let folder = base.join("Sioul");
9856        let key = quick_key(&folder, "four words make a passphrase").unwrap();
9857        let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
9858        let mut now = NOW;
9859        let both = |now: &mut i64| {
9860            *now += MINUTE;
9861            desk.exchange(&folder, &key, *now);
9862            *now += MINUTE;
9863            phone.exchange(&folder, &key, *now);
9864        };
9865        desk.write("state/health-state.toml", "[taken]\nd1 = 1\n");
9866        desk.write("config/config.toml", "language = \"fr\"\n");
9867        both(&mut now);
9868        // On the phone: a dose, then another, the language changed, an account added.
9869        phone.write("state/health-state.toml", "[taken]\nd1 = 1\nd2 = 2\n");
9870        both(&mut now);
9871        phone.write("state/health-state.toml", "[taken]\nd1 = 1\nd2 = 2\nd3 = 3\n");
9872        phone.write("config/config.toml", "language = \"en\"\n\n[[account]]\nid = \"new\"\nkind = \"imap\"\n");
9873        both(&mut now);
9874        both(&mut now);
9875        let history = crate::history::root(&desk.memory);
9876        let doses = crate::history::versions(&history, "health", "state/health-state.toml");
9877        assert_eq!(doses.len(), 2, "{doses:?}");
9878        let every = stores_of(&Config::default(), &desk.roots, &|_| true);
9879        // The doses' record as it was before the last dose came: said first, nothing taken out.
9880        let back = put_back_preview(&desk.memory, &every, "health", "state/health-state.toml", &doses[0].stamp).unwrap();
9881        assert_eq!(back, Back { whole: false, changed: 0, returning: 0, kept: 1 });
9882        put_back(&desk.memory, Some((&folder, &key)), &every, "health", "state/health-state.toml", &doses[0].stamp, now).unwrap();
9883        // The settings as they were: the language back, the account added since kept.
9884        let settings = crate::history::versions(&history, "settings", "config/config.toml");
9885        let back = put_back_preview(&desk.memory, &every, "settings", "config/config.toml", &settings[0].stamp).unwrap();
9886        assert_eq!(back, Back { whole: false, changed: 1, returning: 0, kept: 1 });
9887        put_back(&desk.memory, Some((&folder, &key)), &every, "settings", "config/config.toml", &settings[0].stamp, now).unwrap();
9888        both(&mut now);
9889        both(&mut now);
9890        for computer in [&desk, &phone] {
9891            let doses = computer.read("state/health-state.toml");
9892            assert!(["d1", "d2", "d3"].iter().all(|d| doses.contains(d)), "a dose marked after that version was taken out: {doses}");
9893            let config = computer.read("config/config.toml");
9894            assert!(config.contains("language = \"fr\"") && config.contains("id = \"new\""), "{config}");
9895        }
9896        let _ = std::fs::remove_dir_all(&base);
9897    }
9898
9899    // F2
9900    #[test]
9901    fn a_quick_exchange_leaves_notes_for_later() {
9902        let base = scratch("quick");
9903        let folder = base.join("Sioul");
9904        let key = quick_key(&folder, "four words make a passphrase").unwrap();
9905        let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
9906        let (dn, pn) = (base.join("desk-notes"), base.join("phone-notes"));
9907        put(&dn, "a.md", b"# a\n");
9908        desk.exchange_with(&folder, &key, NOW, &dn, &["notes"]);
9909        phone.exchange_with(&folder, &key, NOW + MINUTE, &pn, &["notes"]);
9910        // A dose and a note on the desk: the phone's alarm takes the dose, leaves the note.
9911        desk.write("state/health-state.toml", "[taken]\nd1 = 1\n");
9912        put(&dn, "a.md", b"# a, changed\n");
9913        desk.exchange_with(&folder, &key, NOW + 2 * MINUTE, &dn, &["notes"]);
9914        let stores = phone.stores_with(&pn, &|part| part == "notes");
9915        let quick = exchange(&Sharing { folder: &folder, computer: &phone.id, key: &key, memory: &phone.memory, files: false, hurry: None }, &stores, NOW + 3 * MINUTE).unwrap();
9916        assert!(phone.read("state/health-state.toml").contains("d1"));
9917        assert_eq!(std::fs::read(pn.join("a.md")).unwrap(), b"# a\n");
9918        assert_eq!(quick.pending, 1, "{quick:?}");
9919        // The next whole exchange brings it; nothing joins again (nothing goes out).
9920        let whole = phone.exchange_with(&folder, &key, NOW + 4 * MINUTE, &pn, &["notes"]);
9921        assert_eq!(std::fs::read(pn.join("a.md")).unwrap(), b"# a, changed\n");
9922        assert_eq!(whole.sent, 0, "{whole:?}");
9923        // Hurried while it runs: its notes wait where they are, the rest is done.
9924        put(&dn, "a.md", b"# a, again\n");
9925        desk.write("state/health-state.toml", "[taken]\nd1 = 1\nd2 = 2\n");
9926        desk.exchange_with(&folder, &key, NOW + 5 * MINUTE, &dn, &["notes"]);
9927        let hurry = std::sync::atomic::AtomicBool::new(true);
9928        exchange(&Sharing { folder: &folder, computer: &phone.id, key: &key, memory: &phone.memory, files: true, hurry: Some(&hurry) }, &stores, NOW + 6 * MINUTE).unwrap();
9929        assert!(phone.read("state/health-state.toml").contains("d2"));
9930        assert_eq!(std::fs::read(pn.join("a.md")).unwrap(), b"# a, changed\n");
9931        hurry.store(false, std::sync::atomic::Ordering::Relaxed);
9932        exchange(&Sharing { folder: &folder, computer: &phone.id, key: &key, memory: &phone.memory, files: true, hurry: Some(&hurry) }, &stores, NOW + 7 * MINUTE).unwrap();
9933        assert_eq!(std::fs::read(pn.join("a.md")).unwrap(), b"# a, again\n");
9934        let _ = std::fs::remove_dir_all(&base);
9935    }
9936
9937    // F4
9938    #[test]
9939    fn notes_gone_at_once_wait_for_a_word() {
9940        let base = scratch("vanished");
9941        let folder = base.join("Sioul");
9942        let key = quick_key(&folder, "four words make a passphrase").unwrap();
9943        let (desk, laptop) = (Computer::new(&base, "desk"), Computer::new(&base, "laptop"));
9944        let (dn, ln) = (base.join("desk-notes"), base.join("laptop-notes"));
9945        put(&dn, "today.md", b"# Today\n");
9946        for n in 0..20 {
9947            put(&dn, &format!("archive/{n}.md"), format!("# {n}\n").as_bytes());
9948        }
9949        desk.exchange_with(&folder, &key, NOW, &dn, &["notes"]);
9950        laptop.exchange_with(&folder, &key, NOW + MINUTE, &ln, &["notes"]);
9951        // A folder listing empty (a disk not mounted on its place): unknown, said, nothing taken out.
9952        std::fs::remove_dir_all(dn.join("archive")).unwrap();
9953        std::fs::create_dir(dn.join("archive")).unwrap();
9954        let outcome = desk.exchange_with(&folder, &key, NOW + 2 * MINUTE, &dn, &["notes"]);
9955        assert!(outcome.problems.iter().any(|p| p == "share-vanished:files/notes/archive/:20") && outcome.sent == 0, "{outcome:?}");
9956        laptop.exchange_with(&folder, &key, NOW + 3 * MINUTE, &ln, &["notes"]);
9957        assert_eq!(files_in(&ln).len(), 21);
9958        // Gone with its folder, many at once: held the same, ten minutes and more.
9959        std::fs::remove_dir(dn.join("archive")).unwrap();
9960        for minute in [4, 20] {
9961            let outcome = desk.exchange_with(&folder, &key, NOW + minute * MINUTE, &dn, &["notes"]);
9962            assert!(outcome.problems.iter().any(|p| p == "share-vanished:files/notes/:20") && outcome.sent == 0, "{outcome:?}");
9963        }
9964        laptop.exchange_with(&folder, &key, NOW + 21 * MINUTE, &ln, &["notes"]);
9965        assert_eq!(files_in(&ln).len(), 21);
9966        // A folder that cannot be read: unknown, nothing of it taken out.
9967        #[cfg(unix)]
9968        {
9969            use std::os::unix::fs::PermissionsExt;
9970            put(&dn, "locked/x.md", b"# x\n");
9971            desk.exchange_with(&folder, &key, NOW + 22 * MINUTE, &dn, &["notes"]);
9972            std::fs::set_permissions(dn.join("locked"), std::fs::Permissions::from_mode(0o000)).unwrap();
9973            if std::fs::read_dir(dn.join("locked")).is_err() {
9974                for minute in [23, 40] {
9975                    desk.exchange_with(&folder, &key, NOW + minute * MINUTE, &dn, &["notes"]);
9976                }
9977                laptop.exchange_with(&folder, &key, NOW + 41 * MINUTE, &ln, &["notes"]);
9978                assert!(ln.join("locked/x.md").exists());
9979            }
9980            std::fs::set_permissions(dn.join("locked"), std::fs::Permissions::from_mode(0o755)).unwrap();
9981        }
9982        // You say so: they go, everywhere.
9983        confirm_gone(&desk.memory, &desk.id, "files/notes/").unwrap();
9984        let outcome = desk.exchange_with(&folder, &key, NOW + 42 * MINUTE, &dn, &["notes"]);
9985        assert!(outcome.sent >= 20, "{outcome:?}");
9986        laptop.exchange_with(&folder, &key, NOW + 43 * MINUTE, &ln, &["notes"]);
9987        let left: Vec<String> = files_in(&ln).into_keys().collect();
9988        assert!(left.iter().all(|f| !f.starts_with("archive/")) && left.contains(&"today.md".to_string()), "{left:?}");
9989        assert!(!ln.join("archive").exists(), "its folder, left empty, goes too");
9990        let _ = std::fs::remove_dir_all(&base);
9991    }
9992
9993    // F5 and F21
9994    #[test]
9995    fn no_room_holds_a_file_back_and_tries_again_later() {
9996        let base = scratch("no-room");
9997        let folder = base.join("Sioul");
9998        let key = quick_key(&folder, "four words make a passphrase").unwrap();
9999        let (desk, laptop) = (Computer::new(&base, "desk"), Computer::new(&base, "laptop"));
10000        let (dn, ln) = (base.join("desk-notes"), base.join("laptop-notes"));
10001        put(&dn, "scan.pdf", &noise(100_000, 1));
10002        put(&dn, "other.md", b"other\n");
10003        desk.exchange_with(&folder, &key, NOW, &dn, &["notes"]);
10004        laptop.exchange_with(&folder, &key, NOW + MINUTE, &ln, &["notes"]);
10005        put(&dn, "scan.pdf", &noise(100_000, 2));
10006        desk.exchange_with(&folder, &key, NOW + 2 * MINUTE, &dn, &["notes"]);
10007        // No room on the laptop: said, the file there as it was, tried again after a while only.
10008        crate::disk::ROOM.with(|room| room.set(Some(1000)));
10009        let outcome = laptop.exchange_with(&folder, &key, NOW + 3 * MINUTE, &ln, &["notes"]);
10010        assert!(outcome.problems.iter().any(|p| p == "share-no-room:files/notes/scan.pdf") && outcome.pending == 1, "{outcome:?}");
10011        let outcome = laptop.exchange_with(&folder, &key, NOW + 3 * MINUTE + 30_000, &ln, &["notes"]);
10012        assert!(outcome.problems.is_empty(), "not opened again within the minute: {outcome:?}");
10013        assert_eq!(std::fs::read(ln.join("scan.pdf")).unwrap(), noise(100_000, 1));
10014        crate::disk::ROOM.with(|room| room.set(Some(u64::MAX)));
10015        laptop.exchange_with(&folder, &key, NOW + 5 * MINUTE, &ln, &["notes"]);
10016        assert_eq!(std::fs::read(ln.join("scan.pdf")).unwrap(), noise(100_000, 2));
10017        // What it held kept by reference (its content was sealed in the folder already), not copied.
10018        let kept = crate::history::versions(&crate::history::root(&laptop.memory), "notes", "files/notes/scan.pdf");
10019        assert!(kept.len() == 1 && kept[0].sealed.is_some(), "{kept:?}");
10020        assert_eq!(version_text_bytes(&crate::history::root(&laptop.memory), "notes", "files/notes/scan.pdf", &kept[0].stamp, &folder, &key), noise(100_000, 1));
10021        let _ = std::fs::remove_dir_all(&base);
10022    }
10023
10024    fn version_text_bytes(history: &Path, part: &str, file: &str, stamp: &str, folder: &Path, key: &[u8; 32]) -> Vec<u8> {
10025        let Some(crate::history::Kept::Sealed { hash, .. }) = crate::history::version(history, part, file, stamp) else { panic!("{file}: not by reference") };
10026        let target = temporary(&history.join("opened"));
10027        crate::blobs::get(folder, key, &hash, &target).unwrap();
10028        let bytes = std::fs::read(&target).unwrap();
10029        std::fs::remove_file(target).unwrap();
10030        bytes
10031    }
10032
10033    // F6
10034    #[test]
10035    fn huge_or_endless_files_in_the_folder_are_never_read_whole() {
10036        let base = scratch("huge");
10037        let folder = base.join("Sioul");
10038        let key = quick_key(&folder, "four words make a passphrase").unwrap();
10039        let (desk, laptop) = (Computer::new(&base, "desk"), Computer::new(&base, "laptop"));
10040        desk.write("config/safe-senders.txt", "a@example.org\n");
10041        desk.exchange(&folder, &key, NOW);
10042        // From anyone who can write the folder: a line of 40 MB, then a record of a key holder's; notes to each other of 100 kB.
10043        let stranger = uuid::Uuid::new_v4().to_string();
10044        {
10045            let mut file = std::fs::File::create(round_file(&folder, &stranger, 1)).unwrap();
10046            let piece = vec![b'x'; 1 << 20];
10047            for _ in 0..40 {
10048                file.write_all(&piece).unwrap();
10049            }
10050            file.write_all(b"\n").unwrap();
10051        }
10052        let clock = ((NOW + MINUTE) as u64) << 16;
10053        append_to(&round_file(&folder, &stranger, 1), &record_of(&key, &stranger, 1, clock, &Change { k: "config/safe-senders.txt#b@example.org".into(), v: Some(String::new()), b: String::new(), build: String::new(), f: 0 }));
10054        std::fs::write(seen_path(&folder, &stranger), "x".repeat(100_000)).unwrap();
10055        let outcome = laptop.exchange(&folder, &key, NOW + 2 * MINUTE);
10056        assert!(outcome.problems.iter().any(|p| *p == format!("share-other-line:{stranger}")), "{outcome:?}");
10057        let safe = laptop.read("config/safe-senders.txt");
10058        assert!(safe.contains("a@example.org") && safe.contains("b@example.org"), "{safe}");
10059        assert!(others(&folder, &laptop.id).iter().any(|o| o.id == stranger && o.heard == 0), "its notes, too long, not read");
10060        // An own file that does not end a line is mended from its end, never read whole.
10061        let own = round_file(&folder, &desk.id, 1);
10062        let whole = std::fs::metadata(&own).unwrap().len();
10063        append_to(&own, "{\"n\": 99, \"c\": 1, \"s\": \"half");
10064        assert_eq!(unfinished(&own), Some(whole));
10065        desk.write("config/safe-senders.txt", "a@example.org\nc@example.org\n");
10066        desk.exchange(&folder, &key, NOW + 3 * MINUTE);
10067        assert!(std::fs::read_to_string(&own).unwrap().lines().all(|l| l.ends_with('}')));
10068        let _ = std::fs::remove_dir_all(&base);
10069    }
10070
10071    // F7 and F8
10072    #[test]
10073    fn an_edit_made_while_receiving_is_never_lost() {
10074        let base = scratch("while-receiving");
10075        let folder = base.join("Sioul");
10076        let key = quick_key(&folder, "four words make a passphrase").unwrap();
10077        let (desk, laptop) = (Computer::new(&base, "desk"), Computer::new(&base, "laptop"));
10078        let (dn, ln) = (base.join("desk-notes"), base.join("laptop-notes"));
10079        for name in ["a.md", "b.md", "lease.md"] {
10080            put(&dn, name, format!("{name}: one\n").as_bytes());
10081        }
10082        desk.exchange_with(&folder, &key, NOW, &dn, &["notes"]);
10083        laptop.exchange_with(&folder, &key, NOW + MINUTE, &ln, &["notes"]);
10084        for name in ["a.md", "b.md", "lease.md"] {
10085            put(&dn, name, format!("{name}: two, from the desk\n").as_bytes());
10086        }
10087        desk.exchange_with(&folder, &key, NOW + 2 * MINUTE, &dn, &["notes"]);
10088        // While the laptop writes them: the lease edited there just before its turn (an editor
10089        // saving), and a.md, written already, edited while b.md is written.
10090        let (lease, first) = (ln.join("lease.md"), ln.join("a.md"));
10091        BEFORE_WRITING.with(|hook| {
10092            *hook.borrow_mut() = Some(Box::new(move |path: &Path| {
10093                if path.ends_with("lease.md") {
10094                    std::fs::write(&lease, "lease.md: edited on the laptop\n").unwrap();
10095                }
10096                if path.ends_with("b.md") {
10097                    std::fs::write(&first, "a.md: edited on the laptop\n").unwrap();
10098                }
10099            }))
10100        });
10101        laptop.exchange_with(&folder, &key, NOW + 3 * MINUTE, &ln, &["notes"]);
10102        BEFORE_WRITING.with(|hook| *hook.borrow_mut() = None);
10103        assert_eq!(std::fs::read_to_string(ln.join("lease.md")).unwrap(), "lease.md: edited on the laptop\n", "not written over");
10104        for minute in 4..10 {
10105            if minute % 2 == 0 {
10106                laptop.exchange_with(&folder, &key, NOW + minute * MINUTE, &ln, &["notes"]);
10107            } else {
10108                desk.exchange_with(&folder, &key, NOW + minute * MINUTE, &dn, &["notes"]);
10109            }
10110        }
10111        // Both edits went out. The lease, edited before the desk's came, is
10112        // kept both ways; a.md, edited on top of the desk's, follows it.
10113        for notes in [&dn, &ln] {
10114            let all: Vec<String> = files_in(notes).into_values().map(|b| String::from_utf8(b).unwrap()).collect();
10115            for text in ["lease.md: edited on the laptop\n", "lease.md: two, from the desk\n", "a.md: edited on the laptop\n"] {
10116                assert!(all.iter().any(|t| t == text), "{}: {text:?} lost: {all:?}", notes.display());
10117            }
10118            assert_eq!(std::fs::read_to_string(notes.join("a.md")).unwrap(), "a.md: edited on the laptop\n");
10119        }
10120        let _ = std::fs::remove_dir_all(&base);
10121    }
10122
10123    // F9
10124    #[test]
10125    fn a_part_switched_off_keeps_nothing_waiting() {
10126        let base = scratch("off-pending");
10127        let folder = base.join("Sioul");
10128        let key = quick_key(&folder, "four words make a passphrase").unwrap();
10129        let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
10130        let off = stores_of(&Config::default(), &phone.roots, &|part| part != "drafts");
10131        for n in 0..20 {
10132            desk.write(&format!("data/drafts/{n}.eml"), &"x".repeat(200_000));
10133            desk.exchange(&folder, &key, NOW + 2 * n * MINUTE);
10134            exchange(&Sharing { folder: &folder, computer: &phone.id, key: &key, memory: &phone.memory, files: true, hurry: None }, &off, NOW + (2 * n + 1) * MINUTE).unwrap();
10135        }
10136        assert!(std::fs::metadata(&phone.memory).unwrap().len() < 200_000, "memory.json holds the drafts of a part switched off");
10137        // Switched on: it joins, and the drafts come.
10138        phone.exchange(&folder, &key, NOW + 41 * MINUTE);
10139        assert_eq!(phone.read("data/drafts/19.eml").len(), 200_000);
10140        let _ = std::fs::remove_dir_all(&base);
10141    }
10142
10143    /// A device on an older Sioul still shares a watch's days and the memory
10144    /// of its offers (taken out on 8 October 2026): left aside here, never
10145    /// written, never kept waiting, never a problem; the rest of the
10146    /// exchange comes in, and the older device's files stay as they are.
10147    #[test]
10148    fn an_older_sioul_s_watch_is_left_aside() {
10149        let base = scratch("watch-older");
10150        let folder = base.join("Sioul");
10151        let key = quick_key(&folder, "four words make a passphrase").unwrap();
10152        let (older, desk) = (Computer::new(&base, "older"), Computer::new(&base, "desk"));
10153        // The older Sioul's stores: this version's, and the watch's as it shared them.
10154        let mut with_watch = stores(&Config::default(), &older.roots);
10155        with_watch.push(Store { name: "data/watch/".into(), path: older.roots.data.join("watch"), folder: true, shape: Shape::Whole, skip: &["imported.json"], part: "watch" });
10156        with_watch.push(Store { name: "state/watch-offers.json".into(), path: older.roots.state.join("watch-offers.json"), folder: false, shape: Shape::Whole, skip: &[], part: "watch" });
10157        let older_exchange = |now: i64| exchange(&Sharing { folder: &folder, computer: &older.id, key: &key, memory: &older.memory, files: true, hurry: None }, &with_watch, now).unwrap();
10158        older.write("data/watch/2026-10-07.json", "{\"steps\": 4200}");
10159        older.write("state/watch-offers.json", "{\"declined\": {}}");
10160        older.write("config/safe-senders.txt", "friend@example.org\n");
10161        let sent = older_exchange(NOW);
10162        assert!(sent.problems.is_empty() && sent.sent >= 3, "{sent:?}");
10163        for n in 1..=2 {
10164            let came = desk.exchange(&folder, &key, NOW + n * MINUTE);
10165            assert!(came.problems.is_empty() && came.pending == 0, "{came:?}");
10166        }
10167        assert_eq!(desk.read("config/safe-senders.txt"), "friend@example.org\n", "the rest came in");
10168        assert!(!desk.path("data/watch").exists() && !desk.path("state/watch-offers.json").exists(), "nothing of the watch written");
10169        // Its records named by their stores (the folders' own paths hold the test's name).
10170        let memory = std::fs::read_to_string(&desk.memory).unwrap();
10171        assert!(!memory.contains("data/watch/") && !memory.contains("watch-offers"), "nothing of the watch kept waiting");
10172        // A new day from the older device, and a change it receives: the exchanges go on both ways.
10173        older.write("data/watch/2026-10-08.json", "{\"steps\": 900}");
10174        older_exchange(NOW + 3 * MINUTE);
10175        desk.write("config/safe-senders.txt", "friend@example.org\nneighbour@example.org\n");
10176        let came = desk.exchange(&folder, &key, NOW + 4 * MINUTE);
10177        assert!(came.problems.is_empty() && came.pending == 0 && came.sent >= 1, "{came:?}");
10178        let back = older_exchange(NOW + 5 * MINUTE);
10179        assert!(back.problems.is_empty() && back.received >= 1, "{back:?}");
10180        assert_eq!(older.read("config/safe-senders.txt"), "friend@example.org\nneighbour@example.org\n");
10181        assert_eq!(older.read("data/watch/2026-10-07.json"), "{\"steps\": 4200}", "its own files as they were");
10182        assert!(!desk.path("data/watch").exists());
10183        let _ = std::fs::remove_dir_all(&base);
10184    }
10185
10186    // F10
10187    #[cfg(unix)]
10188    #[test]
10189    fn nothing_is_written_through_a_link_nor_into_the_sharing_folder() {
10190        let base = scratch("through-link");
10191        let notes = base.join("desk-notes");
10192        // Shared through a folder inside the notes, as Sioul may suggest.
10193        let folder = notes.join("sioul-shared");
10194        let key = quick_key(&folder, "four words make a passphrase").unwrap();
10195        let desk = Computer::new(&base, "desk");
10196        let outside = base.join("outside");
10197        put(&notes, "lease.md", b"x\n");
10198        std::fs::create_dir_all(&outside).unwrap();
10199        std::os::unix::fs::symlink(&outside, notes.join("link")).unwrap();
10200        desk.exchange_with(&folder, &key, NOW, &notes, &["notes"]);
10201        // A key holder's records: one through the link, one into the sharing folder.
10202        let source = base.join("evil");
10203        std::fs::write(&source, b"[Desktop Entry]\nExec=sh -c id\n").unwrap();
10204        let (h, s) = crate::blobs::hash_file(&source).unwrap();
10205        crate::blobs::put(&folder, &key, &source, &h).unwrap();
10206        let other = uuid::Uuid::new_v4().to_string();
10207        let value = reference(&h, s, 0);
10208        let mut text = String::new();
10209        for (n, file) in [(1, "files/notes/link/evil.desktop#"), (2, "files/notes/sioul-shared/plain.md#")] {
10210            text += &record_of(&key, &other, n, ((NOW + MINUTE) as u64) << 16 | n, &Change { k: file.into(), v: Some(value.clone()), b: String::new(), build: String::new(), f: 0 });
10211        }
10212        std::fs::write(round_file(&folder, &other, 1), text).unwrap();
10213        let outcome = desk.exchange_with(&folder, &key, NOW + 2 * MINUTE, &notes, &["notes"]);
10214        assert!(!outside.join("evil.desktop").exists(), "written through the link");
10215        assert!(!folder.join("plain.md").exists(), "written in plain into the sharing folder");
10216        assert!(outcome.problems.iter().filter(|p| p.starts_with("share-refused:")).count() == 2, "{outcome:?}");
10217        let _ = std::fs::remove_dir_all(&base);
10218    }
10219
10220    // F11
10221    #[test]
10222    fn a_content_used_again_is_never_lost_to_a_sweep() {
10223        let base = scratch("sweep-race");
10224        let (server, phone_folder) = (base.join("server").join("Sioul"), base.join("phone").join("Sioul"));
10225        let key = quick_key(&server, "four words make a passphrase").unwrap();
10226        let (desk, phone, laptop) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"), Computer::new(&base, "laptop"));
10227        let (dn, pn, ln) = (base.join("desk-notes"), base.join("phone-notes"), base.join("laptop-notes"));
10228        let day = 86_400_000;
10229        put(&dn, "a.md", b"template\n");
10230        put(&dn, "keep.md", b"keep\n");
10231        desk.exchange_with(&server, &key, NOW, &dn, &["notes"]);
10232        put(&dn, "a.md", b"changed\n");
10233        desk.exchange_with(&server, &key, NOW + MINUTE, &dn, &["notes"]);
10234        // The template's sealed file is old (written long ago).
10235        std::fs::write(base.join("template"), b"template\n").unwrap();
10236        let sealed = crate::blobs::path(&server, &key, &crate::blobs::hash_file(&base.join("template")).unwrap().0);
10237        dated(&sealed, std::time::SystemTime::now() - std::time::Duration::from_secs(100 * 86_400));
10238        copy_dir(&server, &phone_folder);
10239        // The phone holds the same content again: it finds it sealed, seals nothing, and its record goes.
10240        put(&pn, "b.md", b"template\n");
10241        phone.exchange_with(&phone_folder, &key, NOW + 91 * day, &pn, &["notes"]);
10242        // The desk, not hearing of it yet, sweeps it.
10243        desk.exchange_with(&server, &key, NOW + 91 * day + MINUTE, &dn, &["notes"]);
10244        assert!(!sealed.exists(), "swept, as unused for 90 days by what the desk knew");
10245        // The phone's sync app carries both ways: its records up, the sweep down.
10246        for entry in std::fs::read_dir(&phone_folder).unwrap().filter_map(Result::ok) {
10247            if entry.file_name().to_string_lossy().ends_with(".jsonl") {
10248                std::fs::copy(entry.path(), server.join(entry.file_name())).unwrap();
10249            }
10250        }
10251        let _ = std::fs::remove_file(phone_folder.join("blobs").join(sealed.file_name().unwrap()));
10252        laptop.exchange_with(&server, &key, NOW + 91 * day + 2 * MINUTE, &ln, &["notes"]);
10253        assert!(!ln.join("b.md").exists());
10254        // A day later, still missing: said.
10255        let outcome = laptop.exchange_with(&server, &key, NOW + 92 * day + 3 * MINUTE, &ln, &["notes"]);
10256        assert!(outcome.problems.iter().any(|p| p == "share-missing:files/notes/b.md"), "{outcome:?}");
10257        // The phone looks after its sealed files: gone, sealed again; it comes.
10258        phone.exchange_with(&phone_folder, &key, NOW + 92 * day + 4 * MINUTE, &pn, &["notes"]);
10259        assert!(phone_folder.join("blobs").join(sealed.file_name().unwrap()).exists());
10260        copy_dir(&phone_folder.join("blobs"), &server.join("blobs"));
10261        laptop.exchange_with(&server, &key, NOW + 92 * day + 5 * MINUTE, &ln, &["notes"]);
10262        assert_eq!(std::fs::read(ln.join("b.md")).ok().as_deref(), Some(&b"template\n"[..]));
10263        let _ = std::fs::remove_dir_all(&base);
10264    }
10265
10266    // F12
10267    #[test]
10268    fn one_exchange_at_a_time() {
10269        let base = scratch("one-at-a-time");
10270        let folder = base.join("Sioul");
10271        let key = quick_key(&folder, "four words make a passphrase").unwrap();
10272        let desk = Computer::new(&base, "desk");
10273        desk.write("config/safe-senders.txt", "a@example.org\n");
10274        // Another Sioul (or the command line) exchanging: a whole exchange does nothing, and says so.
10275        let running = exchange_lock(&desk.memory, true).unwrap();
10276        let outcome = desk.exchange(&folder, &key, NOW);
10277        assert_eq!((outcome.problems.as_slice(), outcome.sent), (&["share-busy".to_string()][..], 0));
10278        drop(running);
10279        assert_eq!(desk.exchange(&folder, &key, NOW + MINUTE).sent, 1);
10280        // Held a moment only (a program starting elsewhere in Sioul holds a copy
10281        // of the lock until it runs, a Mac's for milliseconds): waited for.
10282        let memory = desk.memory.clone();
10283        let (held, release) = std::sync::mpsc::channel();
10284        let holder = std::thread::spawn(move || {
10285            let running = exchange_lock(&memory, true).unwrap();
10286            held.send(()).unwrap();
10287            std::thread::sleep(std::time::Duration::from_millis(150));
10288            drop(running);
10289        });
10290        release.recv().unwrap();
10291        desk.write("config/safe-senders.txt", "a@example.org\nb@example.org\n");
10292        let outcome = desk.exchange(&folder, &key, NOW + 2 * MINUTE);
10293        assert_eq!((outcome.problems.len(), outcome.sent), (0, 1), "{outcome:?}");
10294        holder.join().unwrap();
10295        // Files being written never share a name.
10296        let path = folder.join("x.toml");
10297        let (one, two) = (temporary(&path), temporary(&path));
10298        assert!(one != two && one.file_name().unwrap().to_string_lossy().starts_with('.'));
10299        let _ = std::fs::remove_dir_all(&base);
10300    }
10301
10302    // F14
10303    #[test]
10304    fn names_one_storage_takes_for_one_are_never_both_written() {
10305        // The decision, whatever the disk running this: a name another file
10306        // here or known has by its case, or by how an accent is written, is not its own.
10307        let known = |file: &str| (format!("{file}#"), Known { c: 1, w: "desk".into(), h: "h".into(), b: String::new() });
10308        let names = Names::new(&[known("files/notes/Lease.md"), known("files/notes/\u{e9}t\u{e9}.md")].into(), &Found::default());
10309        assert!(names.clash("files/notes/lease.md") && names.clash("files/notes/LEASE.md") && names.clash("files/notes/e\u{301}te\u{301}.md"));
10310        assert!(!names.clash("files/notes/Lease.md") && !names.clash("files/notes/\u{e9}t\u{e9}.md") && !names.clash("files/notes/other.md"));
10311        // Between two devices, with the names this disk can hold apart: Windows'
10312        // and a Mac's take "Lease.md" and "lease.md" for one file, a Mac's
10313        // "\u{e9}t\u{e9}" written two ways too; Linux's holds both.
10314        let base = scratch("case");
10315        let folder = base.join("Sioul");
10316        let key = quick_key(&folder, "four words make a passphrase").unwrap();
10317        let (desk, laptop) = (Computer::new(&base, "desk"), Computer::new(&base, "laptop"));
10318        let (dn, ln) = (base.join("desk-notes"), base.join("laptop-notes"));
10319        let apart = |one: &str, other: &str| {
10320            let probe = base.join("probe");
10321            std::fs::create_dir_all(&probe).unwrap();
10322            std::fs::write(probe.join(one), b"").unwrap();
10323            let apart = !probe.join(other).exists();
10324            std::fs::remove_dir_all(&probe).unwrap();
10325            apart
10326        };
10327        put(&dn, "other.md", b"other\n");
10328        let mut pairs = 0;
10329        for (one, other) in [("Lease.md", "lease.md"), ("\u{e9}t\u{e9}.md", "e\u{301}te\u{301}.md")] {
10330            if apart(one, other) {
10331                put(&dn, one, b"one\n");
10332                put(&dn, other, b"other way\n");
10333                pairs += 1;
10334            }
10335        }
10336        desk.exchange_with(&folder, &key, NOW, &dn, &["notes"]);
10337        let outcome = laptop.exchange_with(&folder, &key, NOW + MINUTE, &ln, &["notes"]);
10338        assert_eq!(outcome.problems.iter().filter(|p| p.starts_with("share-name-clash:")).count(), pairs, "{outcome:?}");
10339        assert_eq!(files_in(&ln).len(), 1 + pairs);
10340        // What another part carries is never a note, whatever its case.
10341        let config = Config { notes_root: Some("/notes".into()), ..Config::default() };
10342        let every = stores_of(&config, &laptop.roots, &|_| true);
10343        assert!(locate(&every, "files/notes/Sioul-Cases.toml").is_none() && locate(&every, "files/notes/Sioul-Projects.toml").is_none() && locate(&every, "files/notes/Papers/id.pdf").is_none());
10344        assert_eq!(kept_by_windows("aux.md"), true);
10345        assert_eq!(kept_by_windows("auxiliary.md"), false);
10346        let _ = std::fs::remove_dir_all(&base);
10347    }
10348
10349    // F15
10350    #[test]
10351    fn a_name_with_a_hash_travels() {
10352        let base = scratch("hash-name");
10353        let folder = base.join("Sioul");
10354        let key = quick_key(&folder, "four words make a passphrase").unwrap();
10355        let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
10356        let (dn, pn) = (base.join("desk-notes"), base.join("phone-notes"));
10357        put(&dn, "Issue #12.md", b"# Issue 12\n");
10358        put(&dn, "#ideas.md", b"# Ideas\n");
10359        put(&dn, "papers/Facture #123.pdf", &noise(10_000, 3));
10360        desk.write("data/drafts/a#1.toml", "subject = \"Hash\"\n");
10361        for m in 0..3 {
10362            desk.exchange_with(&folder, &key, NOW + 2 * m * MINUTE, &dn, &["notes", "papers"]);
10363            phone.exchange_with(&folder, &key, NOW + (2 * m + 1) * MINUTE, &pn, &["notes", "papers"]);
10364        }
10365        let files = files_in(&pn);
10366        assert_eq!(files.get("Issue #12.md").map(Vec::as_slice), Some(&b"# Issue 12\n"[..]), "{:?}", files.keys().collect::<Vec<_>>());
10367        assert!(files.contains_key("#ideas.md") && files.get("papers/Facture #123.pdf") == Some(&noise(10_000, 3)));
10368        assert_eq!(phone.read("data/drafts/a#1.toml"), "subject = \"Hash\"\n");
10369        assert_eq!(file_of("files/papers/Facture #123.pdf#"), "files/papers/Facture #123.pdf");
10370        assert_eq!((file_of("config/safe-senders.txt#a#b@example.org"), entry_of("config/safe-senders.txt#a#b@example.org")), ("config/safe-senders.txt", "a#b@example.org"));
10371        let _ = std::fs::remove_dir_all(&base);
10372    }
10373
10374    // F16
10375    #[test]
10376    fn an_old_copy_does_not_roll_the_others_back() {
10377        let base = scratch("old-copy");
10378        let folder = base.join("Sioul");
10379        let key = quick_key(&folder, "four words make a passphrase").unwrap();
10380        let (desk, laptop) = (Computer::new(&base, "desk"), Computer::new(&base, "laptop"));
10381        let (dn, ln) = (base.join("desk-notes"), base.join("laptop-notes"));
10382        let old = std::time::UNIX_EPOCH + std::time::Duration::from_millis(NOW as u64);
10383        put(&dn, "lease.md", b"Rent: 620\n");
10384        put(&dn, "other.md", b"other\n");
10385        dated(&dn.join("lease.md"), old);
10386        desk.exchange_with(&folder, &key, NOW, &dn, &["notes"]);
10387        laptop.exchange_with(&folder, &key, NOW + MINUTE, &ln, &["notes"]);
10388        put(&ln, "lease.md", b"Rent: 640\n");
10389        laptop.exchange_with(&folder, &key, NOW + 86_400_000, &ln, &["notes"]);
10390        desk.exchange_with(&folder, &key, NOW + 86_400_000 + MINUTE, &dn, &["notes"]);
10391        // Restored from a backup: the old content, with its old time.
10392        put(&dn, "lease.md", b"Rent: 620\n");
10393        dated(&dn.join("lease.md"), old);
10394        let outcome = desk.exchange_with(&folder, &key, NOW + 86_400_000 + 2 * MINUTE, &dn, &["notes"]);
10395        assert!(outcome.problems.iter().any(|p| p.starts_with("share-older-copy:lease (conflict ")), "{outcome:?}");
10396        assert_eq!(std::fs::read(dn.join("lease.md")).unwrap(), b"Rent: 640\n", "the version last seen came back");
10397        desk.exchange_with(&folder, &key, NOW + 86_400_000 + 3 * MINUTE, &dn, &["notes"]);
10398        laptop.exchange_with(&folder, &key, NOW + 86_400_000 + 4 * MINUTE, &ln, &["notes"]);
10399        assert_eq!(std::fs::read(ln.join("lease.md")).unwrap(), b"Rent: 640\n");
10400        assert_eq!(copies_of(&ln, "lease"), [b"Rent: 620\n".to_vec()], "the older copy kept beside, everywhere");
10401        let _ = std::fs::remove_dir_all(&base);
10402    }
10403
10404    // F22
10405    #[test]
10406    fn a_file_being_written_waits() {
10407        let base = scratch("settling");
10408        let folder = base.join("Sioul");
10409        let key = quick_key(&folder, "four words make a passphrase").unwrap();
10410        let (desk, laptop) = (Computer::new(&base, "desk"), Computer::new(&base, "laptop"));
10411        let (dn, ln) = (base.join("desk-notes"), base.join("laptop-notes"));
10412        put(&dn, "keep.md", b"keep\n");
10413        dated(&dn.join("keep.md"), std::time::SystemTime::now() - std::time::Duration::from_secs(120));
10414        // Changed this very moment: read once it has settled.
10415        SETTLE.with(|settle| settle.set(60_000));
10416        put(&dn, "a.md", b"# a\n");
10417        assert_eq!(desk.exchange_with(&folder, &key, NOW, &dn, &["notes"]).sent, 1);
10418        dated(&dn.join("a.md"), std::time::SystemTime::now() - std::time::Duration::from_secs(120));
10419        assert_eq!(desk.exchange_with(&folder, &key, NOW + MINUTE, &dn, &["notes"]).sent, 1);
10420        SETTLE.with(|settle| settle.set(0));
10421        laptop.exchange_with(&folder, &key, NOW + 2 * MINUTE, &ln, &["notes"]);
10422        // Emptied in place (an editor writing it, a crash): held as it was for ten minutes.
10423        std::fs::write(dn.join("a.md"), b"").unwrap();
10424        assert_eq!(desk.exchange_with(&folder, &key, NOW + 3 * MINUTE, &dn, &["notes"]).sent, 0);
10425        laptop.exchange_with(&folder, &key, NOW + 4 * MINUTE, &ln, &["notes"]);
10426        assert_eq!(std::fs::read(ln.join("a.md")).unwrap(), b"# a\n");
10427        put(&dn, "a.md", b"# a, written\n");
10428        assert_eq!(desk.exchange_with(&folder, &key, NOW + 5 * MINUTE, &dn, &["notes"]).sent, 1);
10429        let _ = std::fs::remove_dir_all(&base);
10430    }
10431
10432    // F23
10433    #[test]
10434    fn the_doses_never_read_the_notes_memory() {
10435        let base = scratch("memory-apart");
10436        let folder = base.join("Sioul");
10437        let key = quick_key(&folder, "four words make a passphrase").unwrap();
10438        let desk = Computer::new(&base, "desk");
10439        let dn = base.join("desk-notes");
10440        for n in 0..50 {
10441            put(&dn, &format!("note-{n}.md"), format!("# {n}\n").as_bytes());
10442        }
10443        desk.write("state/health-state.toml", "[taken]\nd1 = 1\n");
10444        desk.exchange_with(&folder, &key, NOW, &dn, &["notes"]);
10445        let memory = std::fs::read_to_string(&desk.memory).unwrap();
10446        assert!(!memory.contains("files/notes/note-") && memory.contains("health-state"), "{memory}");
10447        assert!(std::fs::read_to_string(sealed_path(&desk.memory)).unwrap().contains("files/notes/note-7.md"));
10448        assert!(written(&desk.memory, &desk.id).is_some_and(|(_, n)| n > 50));
10449        // Its own file lost: notes and papers join again, nothing goes out as new, nothing copied beside.
10450        std::fs::remove_file(sealed_path(&desk.memory)).unwrap();
10451        let outcome = desk.exchange_with(&folder, &key, NOW + MINUTE, &dn, &["notes"]);
10452        assert_eq!(outcome.sent, 0, "{outcome:?}");
10453        assert_eq!(files_in(&dn).len(), 50);
10454        let _ = std::fs::remove_dir_all(&base);
10455    }
10456
10457    // F24
10458    #[test]
10459    fn what_is_said_and_left_behind() {
10460        // Named alike in every language: one copy, not one per device.
10461        let at = NOW;
10462        assert_eq!(conflict_name("lease", at), conflict_name("lease", at));
10463        assert!(conflict_name("lease", at).starts_with("lease (conflict 20"));
10464        let base = scratch("left-behind");
10465        let folder = base.join("Sioul");
10466        let key = quick_key(&folder, "four words make a passphrase").unwrap();
10467        let desk = Computer::new(&base, "desk");
10468        let dn = base.join("desk-notes");
10469        put(&dn, "lease.md", b"lease\n");
10470        // What a crash left half written, an hour old: cleaned; one being written: left.
10471        put(&dn, ".lease.md.1-1.sioul.tmp", b"half");
10472        dated(&dn.join(".lease.md.1-1.sioul.tmp"), std::time::SystemTime::now() - std::time::Duration::from_secs(7200));
10473        put(&dn, ".lease.md.1-2.sioul.tmp", b"being written");
10474        // A name that is not text, where the disk holds one (Linux's does; a Mac's refuses it, Windows' names are text).
10475        #[cfg(unix)]
10476        let latin = {
10477            use std::os::unix::ffi::OsStrExt;
10478            std::fs::write(dn.join(std::ffi::OsStr::from_bytes(b"caf\xe9.md")), b"latin-1").is_ok()
10479        };
10480        #[cfg(not(unix))]
10481        let latin = false;
10482        let outcome = desk.exchange_with(&folder, &key, NOW, &dn, &["notes"]);
10483        assert!(!dn.join(".lease.md.1-1.sioul.tmp").exists() && dn.join(".lease.md.1-2.sioul.tmp").exists());
10484        assert_eq!(outcome.problems.iter().any(|p| p.starts_with("share-not-text:files/notes/caf")), latin, "{outcome:?}");
10485        assert_eq!(outcome.sent, 1, "{outcome:?}");
10486        let _ = std::fs::remove_dir_all(&base);
10487    }
10488
10489    #[test]
10490    fn each_device_chooses_its_parts() {
10491        let roots = Roots { config: PathBuf::from("config"), data: PathBuf::from("data"), state: PathBuf::from("state") };
10492        let config = Config { notes_root: Some("/notes".into()), ..Config::default() };
10493        let parts = |stores: &[Store]| stores.iter().map(|s| s.part).collect::<BTreeSet<_>>();
10494        // A device that never chose: what was shared before parts had switches.
10495        assert_eq!(parts(&stores(&config, &roots)), ["calls", "drafts", "health", "lists", "senders", "settings", "spam", "time"].into());
10496        let old = Config { share_projects: true, ..config.clone() };
10497        assert!(parts(&stores(&old, &roots)).contains("projects"), "projects as the old setting said");
10498        // Its own choices, kept in its own file, never in a shared one.
10499        let mut here = Here::default();
10500        here.parts.insert("notes".into(), true);
10501        here.parts.insert("health".into(), false);
10502        here.parts.insert("projects".into(), false);
10503        let chosen = stores_of(&old, &roots, &|part| here.shares(part, &old));
10504        assert!(chosen.iter().any(|s| s.name == "files/notes/") && !chosen.iter().any(|s| s.part == "health" || s.part == "projects"));
10505        let notes = chosen.iter().find(|s| s.name == "files/notes/").unwrap();
10506        assert!(["sioul-projects.toml", "sioul-cases.toml", "sioul-cases.toml.before-rename", "sioul-papers.toml", "papers"].iter().all(|n| notes.skip.contains(n)), "what other parts carry stays out of the notes");
10507        let back: Here = toml::from_str(&toml::to_string(&here).unwrap()).unwrap();
10508        assert_eq!(back.parts, here.parts);
10509        assert!(stores_of(&config, &roots, &|_| true).iter().all(|s| s.name != "state/share/here.toml"));
10510        // Written into the notes folder, never what tools make nor what another part carries.
10511        let every = stores_of(&config, &roots, &|_| true);
10512        assert!(locate(&every, "files/notes/admin/lease.md").is_some());
10513        for file in ["files/notes/.obsidian/app.json", "files/notes/lease.md~", "files/notes/sioul-cases.toml", "files/notes/sioul-projects.toml", "files/notes/papers/id.pdf", "files/notes/a (conflicted copy).md"] {
10514            assert!(locate(&every, file).is_none(), "{file}");
10515        }
10516        assert_eq!(locate(&every, "files/papers/id.pdf").map(|(s, _)| s.part), Some("papers"));
10517        assert_eq!(shown("files/notes/admin/lease.md"), "admin/lease.md");
10518        assert_eq!(shown("files/papers/id.pdf"), "papers/id.pdf");
10519        assert_eq!(shown("config/config.toml"), "config.toml");
10520    }
10521
10522    /// The spam filter's table: from the computer that trained it to the
10523    /// others, sealed apart, in every exchange (a phone's background step's
10524    /// too); the good one it replaces kept beside; never the language model
10525    /// nor the corpus beside it.
10526    #[test]
10527    fn the_spam_table_travels_alone_in_every_exchange() {
10528        use sioul_core::spam::features::{FEATURES, N};
10529        use sioul_core::spam::table::{self, Table};
10530        let base = scratch("spam-table");
10531        let folder = base.join("folder");
10532        let key = quick_key(&folder, "four words make a passphrase").unwrap();
10533        let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
10534        let made = |bias: f32| {
10535            let mut made = Table {
10536                tokenizer: sioul_core::spam::tokenize::TOKENIZER,
10537                features: FEATURES,
10538                dim: 2,
10539                minn: 3,
10540                maxn: 6,
10541                bucket: 4,
10542                words: vec![(table::word_hash("lotteri"), 2.0)],
10543                buckets: vec![0.0; 4],
10544                weights: vec![0.0; N],
10545                means: vec![0.0; N],
10546                bias,
10547                text_mean: 0.0,
10548                platt_a: -1.0,
10549                platt_b: 0.0,
10550                meta: table::Meta { device: "desk".into(), ..table::Meta::default() },
10551            };
10552            made.sort();
10553            made
10554        };
10555        // Each table its own time: a file is known by its size and time.
10556        let trained = |bias: f32, second: u64| {
10557            let path = desk.path("data/spam/table.bin");
10558            made(bias).write(&path).unwrap();
10559            std::fs::File::options().write(true).open(&path).unwrap().set_modified(std::time::UNIX_EPOCH + std::time::Duration::from_secs(second)).unwrap();
10560        };
10561        // What a training leaves on the desk: the table, the language model, the corpus.
10562        trained(0.5, 1_789_990_000);
10563        desk.write("data/spam/language.bin", "the vocabulary in plain text: Jane, the lease, the lottery");
10564        desk.write("data/spam/corpus/home/INBOX.jsonl.gz", "Subject: the lease");
10565        // Quick exchanges only (a dose's alarm, a phone's background step): it travels all the same.
10566        let quick = |c: &Computer, now: i64| exchange(&Sharing { folder: &folder, computer: &c.id, key: &key, memory: &c.memory, files: false, hurry: None }, &stores(&Config::default(), &c.roots), now).unwrap();
10567        quick(&desk, NOW);
10568        let came = quick(&phone, NOW + MINUTE);
10569        assert!(came.written.contains(SPAM_TABLE), "{came:?}");
10570        assert_eq!(std::fs::read(phone.path("data/spam/table.bin")).unwrap(), std::fs::read(desk.path("data/spam/table.bin")).unwrap());
10571        assert!(!phone.path("data/spam/language.bin").exists() && !phone.path("data/spam/corpus").exists());
10572        assert!(!table::previous(&phone.path("data/spam/table.bin")).exists(), "nothing before the first");
10573        // Sealed: nothing of the table, nor of what is beside it, readable in the folder.
10574        let mut files = Vec::new();
10575        list_files(&folder, &folder, &[], &mut files);
10576        for (name, path) in &files {
10577            let bytes = std::fs::read(path).unwrap();
10578            for plain in [&b"SIOULSPM"[..], b"vocabulary", b"lottery"] {
10579                assert!(!bytes.windows(plain.len()).any(|w| w == plain), "{name}");
10580            }
10581        }
10582        // A new training: the phone takes it, the good one before kept beside.
10583        trained(1.5, 1_789_990_600);
10584        quick(&desk, NOW + 2 * MINUTE);
10585        assert!(quick(&phone, NOW + 3 * MINUTE).written.contains(SPAM_TABLE));
10586        let path = phone.path("data/spam/table.bin");
10587        assert_eq!((Table::read(&path).unwrap().bias, Table::read(&table::previous(&path)).unwrap().bias), (1.5, 0.5));
10588        // The language model and the corpus have no place in the sharing.
10589        let every = stores_of(&Config::default(), &desk.roots, &|_| true);
10590        for file in ["files/spam/language.bin", "files/spam/corpus/home/INBOX.jsonl.gz", "files/spam/", "data/spam/language.bin"] {
10591            assert!(locate(&every, file).is_none(), "{file}");
10592        }
10593        assert_eq!(locate(&every, SPAM_TABLE).map(|(s, p)| (s.part, p)), Some(("spam", desk.path("data/spam/table.bin"))));
10594        assert_eq!(shown(SPAM_TABLE), "spam/table.bin");
10595        let _ = std::fs::remove_dir_all(&base);
10596    }
10597
10598    /// Item 11 of 9 October: the spam filter's tables a computer seals, about
10599    /// one a week, are not kept 90 days each: past the newest two, its own
10600    /// older ones leave the folder at once; another device's file stays. A
10601    /// phone that read none of them for a while takes the newest and asks
10602    /// for nothing older: a device reads the newest record of a file only,
10603    /// so their absence is never missed, today's Sioul or an older one.
10604    #[test]
10605    fn only_the_two_newest_spam_tables_stay_in_the_folder() {
10606        let base = scratch("spam-tables");
10607        let folder = base.join("folder");
10608        let key = quick_key(&folder, "four words make a passphrase").unwrap();
10609        let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
10610        let quick = |c: &Computer, now: i64| exchange(&Sharing { folder: &folder, computer: &c.id, key: &key, memory: &c.memory, files: false, hurry: None }, &stores(&Config::default(), &c.roots), now).unwrap();
10611        // Another device's sealed file: never this computer's to take out.
10612        let stranger = folder.join("blobs").join("0f".repeat(32));
10613        put(&folder, &format!("blobs/{}", "0f".repeat(32)), b"another device's");
10614        let tables = || std::fs::read_dir(folder.join("blobs")).unwrap().filter_map(Result::ok).filter(|e| !e.file_name().to_string_lossy().starts_with('.') && e.path() != stranger).count();
10615        // A training: a table of its own content, and its own time.
10616        let trained = |n: u64| {
10617            let path = desk.path("data/spam/table.bin");
10618            put(path.parent().unwrap(), "table.bin", &noise(4096, n as u32));
10619            std::fs::File::options().write(true).open(&path).unwrap().set_modified(std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_789_990_000 + n * 600)).unwrap();
10620        };
10621        let mut now = NOW;
10622        for n in 1..=4 {
10623            trained(n);
10624            now += MINUTE;
10625            quick(&desk, now);
10626            now += MINUTE;
10627            let came = quick(&phone, now);
10628            assert!(came.problems.is_empty(), "{came:?}");
10629            assert_eq!(std::fs::read(phone.path("data/spam/table.bin")).unwrap(), noise(4096, n as u32));
10630            assert_eq!(tables(), n.min(2) as usize, "after training {n}");
10631        }
10632        assert_eq!(Memory::load_all(&desk.memory, &desk.id).sealed.tables.len(), 2);
10633        // The phone away for three trainings: back, it takes the newest, and nothing waits.
10634        for n in 5..=7 {
10635            trained(n);
10636            now += MINUTE;
10637            quick(&desk, now);
10638        }
10639        assert_eq!(tables(), 2);
10640        now += MINUTE;
10641        let came = quick(&phone, now);
10642        assert!(came.problems.is_empty() && came.pending == 0, "{came:?}");
10643        assert_eq!(std::fs::read(phone.path("data/spam/table.bin")).unwrap(), noise(4096, 7));
10644        let came = quick(&phone, now + 2 * DAY);
10645        assert!(came.problems.is_empty() && came.pending == 0, "nothing missing, a day on: {came:?}");
10646        assert!(stranger.exists(), "another device's file stays");
10647        let _ = std::fs::remove_dir_all(&base);
10648    }
10649
10650    /// Each device writes its own label log; every device reads them all: a
10651    /// message said not spam on the phone is not spam on the desk (its
10652    /// `said_ham`), and what the phone's filter moved or flagged is known
10653    /// there, quick exchanges only. Sealed: no Message-ID readable in the folder.
10654    #[test]
10655    fn a_label_on_one_device_reaches_the_others() {
10656        use sioul_core::spam::labels::{self, Entry, Label, Moved, Source};
10657        let base = scratch("spam-labels");
10658        let folder = base.join("folder");
10659        let key = quick_key(&folder, "four words make a passphrase").unwrap();
10660        let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
10661        let quick = |c: &Computer, now: i64| exchange(&Sharing { folder: &folder, computer: &c.id, key: &key, memory: &c.memory, files: false, hurry: None }, &stores(&Config::default(), &c.roots), now).unwrap();
10662        let spam_state = |c: &Computer| c.roots.state.join("spam");
10663        // A message the desk's filter flagged, as the desk's Porch reads it.
10664        let mut card = sioul_core::card::Card::from_bytes(b"From: Prize <win@lottery.test>\r\nMessage-ID: <win-1@lottery.test>\r\nSubject: You won\r\n\r\nClaim it.\r\n").unwrap();
10665        card.account = Some("home".into());
10666        card.origin = Some(sioul_core::card::ImapOrigin { validity: 7, uid: 42 });
10667        assert!(!labels::said_ham_in(&labels::read_all_in(&spam_state(&desk)), &card));
10668        // "Not spam" on the phone; the phone's filter moved another message.
10669        let said = Entry { at: 1_791_360_000, account: "home".into(), folder: "INBOX".into(), uidvalidity: 7, uid: 42, message_id: Some("win-1@lottery.test".into()), label: Label::Ham, source: Source::NotSpam };
10670        labels::append_to(&labels::own_log(&spam_state(&phone), &phone.id), &said).unwrap();
10671        let moved = Moved { at: 1_791_360_100, account: "home".into(), folder: "INBOX".into(), uidvalidity: 7, uid: 43, message_id: Some("deal-2@shop.test".into()), class: sioul_core::spam::Class::Spam };
10672        labels::append_to(&labels::own_moved_log(&spam_state(&phone), &phone.id), &moved).unwrap();
10673        // And flagged a third where it is, as maybe spam: the training on the desk must know it.
10674        let flagged = Moved { at: 1_791_360_200, uid: 44, message_id: Some("flag-3@shop.test".into()), class: sioul_core::spam::Class::Unsure, ..moved.clone() };
10675        labels::append_to(&labels::own_flagged_log(&spam_state(&phone), &phone.id), &flagged).unwrap();
10676        quick(&phone, NOW);
10677        let came = quick(&desk, NOW + MINUTE);
10678        let theirs = format!("{SPAM_LABELS}{}.jsonl", phone.id);
10679        assert!(came.written.contains(SPAM_LABELS) && came.written.contains(SPAM_MOVED) && came.written.contains(SPAM_FLAGGED) && came.received == 3, "{came:?}");
10680        assert!(desk.roots.state.join("spam/labels").join(format!("{}.jsonl", phone.id)).exists(), "the phone's own file, beside the desk's");
10681        // On the desk: the phone's own file, read with the desk's own; the message not spam there too.
10682        assert!(labels::said_ham_in(&labels::read_all_in(&spam_state(&desk)), &card));
10683        assert_eq!(labels::read_moved_in(&spam_state(&desk)), vec![moved]);
10684        assert_eq!(labels::read_flagged_in(&spam_state(&desk)), vec![flagged]);
10685        // The desk says spam later, on its own log: the newest word wins on both devices.
10686        let later = Entry { at: 1_791_360_500, label: Label::Spam, source: Source::Junk, ..said.clone() };
10687        labels::append_to(&labels::own_log(&spam_state(&desk), &desk.id), &later).unwrap();
10688        quick(&desk, NOW + 2 * MINUTE);
10689        quick(&phone, NOW + 3 * MINUTE);
10690        for device in [&desk, &phone] {
10691            let all = labels::read_all_in(&spam_state(device));
10692            assert_eq!(all.len(), 2);
10693            assert!(!labels::said_ham_in(&all, &card));
10694        }
10695        // Each file has one writer: the phone's own log unchanged on the phone but for its own line.
10696        assert_eq!(labels::read_from::<Entry>(&labels::own_log(&spam_state(&phone), &phone.id)), vec![said]);
10697        // Sealed: nothing of them readable in the folder.
10698        let mut files = Vec::new();
10699        list_files(&folder, &folder, &[], &mut files);
10700        for (name, path) in &files {
10701            let bytes = std::fs::read(path).unwrap();
10702            for plain in [&b"win-1@lottery.test"[..], b"not-spam", b"deal-2", b"flag-3"] {
10703                assert!(!bytes.windows(plain.len()).any(|w| w == plain), "{name}");
10704            }
10705        }
10706        let every = stores_of(&Config::default(), &desk.roots, &|_| true);
10707        assert_eq!(locate(&every, &theirs).map(|(s, _)| s.part), Some("spam"));
10708        let _ = std::fs::remove_dir_all(&base);
10709    }
10710
10711    /// A call a phone declined, as Java wrote it (fiction numbers only).
10712    fn declined(at: i64, key: &str) -> sioul_core::calls::Held {
10713        sioul_core::calls::Held { at, key: key.into(), number: key.into(), who: "stranger".into(), name: "Cabinet du Dr Martin".into(), column: "sleep".into(), why: "matrix".into(), ..sioul_core::calls::Held::default() }
10714    }
10715
10716    /// The calls' folder of a device.
10717    fn calls_of(c: &Computer) -> PathBuf {
10718        c.roots.state.join(sioul_core::calls::FOLDER)
10719    }
10720
10721    /// A phone copies Java's call into its own log; the desk hears of it,
10722    /// marks it seen, and the phone hears of that. Sealed in the folder,
10723    /// remembered beside `memory.json`, never kept as an earlier version or a
10724    /// first-share copy; with `files.json` lost, nothing goes out again.
10725    #[test]
10726    fn a_phone_s_calls_reach_the_desk_and_seen_comes_back() {
10727        use sioul_core::calls;
10728        let base = scratch("calls");
10729        let folder = base.join("folder");
10730        let key = quick_key(&folder, "four words make a passphrase").unwrap();
10731        let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
10732        let own = calls::own_file(&calls_of(&phone), calls::LOG, &phone.id);
10733        assert_eq!(calls::carry_into(&own, &[declined(NOW - 3_600_000, "+33199001234")], None, NOW).unwrap(), 1);
10734        phone.exchange(&folder, &key, NOW);
10735        let came = desk.exchange(&folder, &key, NOW + MINUTE);
10736        assert!(came.written.contains(CALLS_LOG), "{came:?}");
10737        let on_desk = calls::read_logs(&calls_of(&desk), NOW + MINUTE);
10738        assert_eq!(on_desk.iter().map(|h| (h.device.as_str(), h.key.as_str(), h.rang, h.name.as_str())).collect::<Vec<_>>(), [(phone.id.as_str(), "+33199001234", false, "Cabinet du Dr Martin")]);
10739        // Seen on the desk, in its own log: the phone hears of it.
10740        calls::mark_seen(&calls_of(&desk), &desk.id, &[on_desk[0].id()], NOW + 2 * MINUTE).unwrap();
10741        desk.exchange(&folder, &key, NOW + 3 * MINUTE);
10742        let came = phone.exchange(&folder, &key, NOW + 4 * MINUTE);
10743        assert!(came.written.contains(CALLS_SEEN), "{came:?}");
10744        assert!(calls::read_seen(&calls_of(&phone)).seen.contains(&on_desk[0].id()));
10745        // Each file has one writer: the phone never wrote a seen line, the desk no call.
10746        assert!(!calls::own_file(&calls_of(&phone), calls::SEEN_LOG, &phone.id).exists());
10747        assert!(!calls::own_file(&calls_of(&desk), calls::LOG, &desk.id).exists());
10748        // Sealed: nothing of them readable in the folder.
10749        let mut files = Vec::new();
10750        list_files(&folder, &folder, &[], &mut files);
10751        for (name, path) in &files {
10752            let bytes = std::fs::read(path).unwrap();
10753            for plain in [&b"199001234"[..], b"Cabinet", b"stranger"] {
10754                assert!(!bytes.windows(plain.len()).any(|w| w == plain), "{name}");
10755            }
10756        }
10757        let every = stores_of(&Config::default(), &desk.roots, &|_| true);
10758        assert_eq!(locate(&every, &format!("{CALLS_LOG}{}.jsonl", phone.id)).map(|(s, _)| s.part), Some("calls"));
10759        for c in [&desk, &phone] {
10760            // Remembered beside memory.json, never in it.
10761            let small = Memory::load(&c.memory, &c.id);
10762            assert!(small.entries.keys().chain(small.files.keys()).chain(small.pending.keys()).all(|k| !calls_store(k)), "memory.json stays small");
10763            assert!(Memory::load_all(&c.memory, &c.id).entries.keys().any(|k| calls_store(k)));
10764            // Neither an earlier version nor a first-share copy of a log of calls.
10765            let share = c.memory.parent().unwrap();
10766            let mut kept = Vec::new();
10767            list_files(share, share, &[], &mut kept);
10768            assert!(kept.iter().all(|(name, _)| !name.contains("calls")), "{kept:?}");
10769        }
10770        // files.json lost: the calls' stores join again, and nothing goes out again as new.
10771        std::fs::remove_file(sealed_path(&desk.memory)).unwrap();
10772        let again = desk.exchange(&folder, &key, NOW + 5 * MINUTE);
10773        assert_eq!(again.sent, 0, "{again:?}");
10774        assert_eq!(calls::read_logs(&calls_of(&desk), NOW + 5 * MINUTE).len(), 1);
10775        let _ = std::fs::remove_dir_all(&base);
10776    }
10777
10778    /// Two phones and a desk: each phone's log its own, every device reading both.
10779    #[test]
10780    fn several_phones_each_keep_their_own_log() {
10781        use sioul_core::calls;
10782        let base = scratch("calls-phones");
10783        let folder = base.join("folder");
10784        let key = quick_key(&folder, "four words make a passphrase").unwrap();
10785        let (desk, a, b) = (Computer::new(&base, "desk"), Computer::new(&base, "phone-a"), Computer::new(&base, "phone-b"));
10786        calls::carry_into(&calls::own_file(&calls_of(&a), calls::LOG, &a.id), &[declined(NOW - 7_200_000, "+33199001234")], None, NOW).unwrap();
10787        calls::carry_into(&calls::own_file(&calls_of(&b), calls::LOG, &b.id), &[declined(NOW - 3_600_000, "+33465710042")], None, NOW).unwrap();
10788        for (n, c) in [&a, &b, &desk, &a, &b].iter().enumerate() {
10789            c.exchange(&folder, &key, NOW + n as i64 * MINUTE);
10790        }
10791        for c in [&desk, &a, &b] {
10792            let heard = calls::read_logs(&calls_of(c), NOW + 10 * MINUTE);
10793            assert_eq!(heard.iter().map(|h| (h.device.clone(), h.key.clone())).collect::<Vec<_>>(), [(a.id.clone(), "+33199001234".to_string()), (b.id.clone(), "+33465710042".to_string())]);
10794        }
10795        let _ = std::fs::remove_dir_all(&base);
10796    }
10797
10798    /// An older Sioul knows neither of the calls' stores: their records wait,
10799    /// written nowhere, taken out nowhere; updated, it reads them.
10800    #[test]
10801    fn an_older_sioul_keeps_the_calls_waiting() {
10802        use sioul_core::calls;
10803        let base = scratch("calls-older");
10804        let folder = base.join("folder");
10805        let key = quick_key(&folder, "four words make a passphrase").unwrap();
10806        let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
10807        let own = calls::own_file(&calls_of(&phone), calls::LOG, &phone.id);
10808        calls::carry_into(&own, &[declined(NOW - 3_600_000, "+33199001234")], None, NOW).unwrap();
10809        phone.exchange(&folder, &key, NOW);
10810        // The desk's older Sioul: no store of calls, and none known.
10811        let older_stores = stores(&Config::default(), &desk.roots).into_iter().filter(|s| !calls_store(&s.name)).collect::<Vec<_>>();
10812        OLDER.with(|older| *older.borrow_mut() = vec![CALLS_LOG.to_string(), CALLS_SEEN.to_string()]);
10813        for n in 1..=3 {
10814            let outcome = exchange(&Sharing { folder: &folder, computer: &desk.id, key: &key, memory: &desk.memory, files: true, hurry: None }, &older_stores, NOW + n * MINUTE).unwrap();
10815            assert_eq!(outcome.pending, 1, "kept waiting: {outcome:?}");
10816        }
10817        assert!(!calls_of(&desk).exists(), "nothing written");
10818        phone.exchange(&folder, &key, NOW + 4 * MINUTE);
10819        assert_eq!(calls::read_held(&own).len(), 1, "nothing taken out");
10820        // Updated: it knows both stores, they join, and the call comes in.
10821        OLDER.with(|older| older.borrow_mut().clear());
10822        let came = desk.exchange(&folder, &key, NOW + 5 * MINUTE);
10823        assert!(came.written.contains(CALLS_LOG) && came.pending == 0, "{came:?}");
10824        assert_eq!(calls::read_logs(&calls_of(&desk), NOW + 5 * MINUTE).len(), 1);
10825        let _ = std::fs::remove_dir_all(&base);
10826    }
10827
10828    /// Batch A (18 October 2026): your security keys as a computer read them
10829    /// (`data/pgp/cards/`) come to every device that knows the store; an
10830    /// older Sioul keeps them waiting, writes them nowhere, takes nothing
10831    /// out; updated, it reads them.
10832    #[test]
10833    fn an_older_sioul_keeps_your_security_keys_waiting() {
10834        let base = scratch("cards-older");
10835        let folder = base.join("folder");
10836        let key = quick_key(&folder, "four words make a passphrase").unwrap();
10837        let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
10838        let store = "data/pgp/cards/";
10839        let card_file = desk.roots.data.join("pgp").join(sioul_core::securitykey::SHARED_CARDS).join("0006-12345678.toml");
10840        std::fs::create_dir_all(card_file.parent().unwrap()).unwrap();
10841        std::fs::write(&card_file, "[card]\nident = \"0006:12345678\"\ncert = \"AB\"\n").unwrap();
10842        desk.exchange(&folder, &key, NOW);
10843        // The phone's older Sioul: no such store, none known.
10844        let older_stores = stores(&Config::default(), &phone.roots).into_iter().filter(|s| s.name != store).collect::<Vec<_>>();
10845        OLDER.with(|older| *older.borrow_mut() = vec![store.to_string()]);
10846        for n in 1..=2 {
10847            let outcome = exchange(&Sharing { folder: &folder, computer: &phone.id, key: &key, memory: &phone.memory, files: true, hurry: None }, &older_stores, NOW + n * MINUTE).unwrap();
10848            assert_eq!(outcome.pending, 1, "kept waiting: {outcome:?}");
10849        }
10850        assert!(!phone.roots.data.join("pgp").join("cards").exists(), "nothing written");
10851        desk.exchange(&folder, &key, NOW + 3 * MINUTE);
10852        assert!(card_file.exists(), "nothing taken out");
10853        // Updated: it knows the store, and the key's file comes in.
10854        OLDER.with(|older| older.borrow_mut().clear());
10855        let came = phone.exchange(&folder, &key, NOW + 4 * MINUTE);
10856        assert!(came.written.contains(store) && came.pending == 0, "{came:?}");
10857        assert_eq!(std::fs::read(phone.roots.data.join("pgp").join("cards").join("0006-12345678.toml")).unwrap(), std::fs::read(&card_file).unwrap());
10858        let _ = std::fs::remove_dir_all(&base);
10859    }
10860
10861    /// The part switched off on the desk: nothing of it comes, nothing waits.
10862    #[test]
10863    fn the_calls_part_switched_off_receives_nothing_and_keeps_nothing_waiting() {
10864        use sioul_core::calls;
10865        let base = scratch("calls-off");
10866        let folder = base.join("folder");
10867        let key = quick_key(&folder, "four words make a passphrase").unwrap();
10868        let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
10869        calls::carry_into(&calls::own_file(&calls_of(&phone), calls::LOG, &phone.id), &[declined(NOW - 3_600_000, "+33199001234")], None, NOW).unwrap();
10870        phone.exchange(&folder, &key, NOW);
10871        let off = stores_of(&Config::default(), &desk.roots, &|part| part != "calls" && shared_by_default(part, &Config::default()));
10872        let outcome = exchange(&Sharing { folder: &folder, computer: &desk.id, key: &key, memory: &desk.memory, files: true, hurry: None }, &off, NOW + MINUTE).unwrap();
10873        assert_eq!(outcome.pending, 0, "{outcome:?}");
10874        assert!(!calls_of(&desk).exists());
10875        // Switched on again, it joins and reads them then.
10876        let came = desk.exchange(&folder, &key, NOW + 2 * MINUTE);
10877        assert!(came.written.contains(CALLS_LOG), "{came:?}");
10878        let _ = std::fs::remove_dir_all(&base);
10879    }
10880
10881    /// A month on, the phone takes its own line out: every device's copy loses it.
10882    #[test]
10883    fn a_trimmed_line_leaves_every_device() {
10884        use sioul_core::calls;
10885        let base = scratch("calls-trim");
10886        let folder = base.join("folder");
10887        let key = quick_key(&folder, "four words make a passphrase").unwrap();
10888        let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
10889        let own = calls::own_file(&calls_of(&phone), calls::LOG, &phone.id);
10890        calls::carry_into(&own, &[declined(NOW - 3_600_000, "+33199001234")], None, NOW).unwrap();
10891        phone.exchange(&folder, &key, NOW);
10892        desk.exchange(&folder, &key, NOW + MINUTE);
10893        assert_eq!(calls::read_held(&calls::own_file(&calls_of(&desk), calls::LOG, &phone.id)).len(), 1);
10894        let later = NOW + (calls::KEPT_DAYS + 1) * 86_400_000;
10895        assert_eq!(calls::trim_own(&calls_of(&phone), &phone.id, later).unwrap(), 1);
10896        phone.exchange(&folder, &key, later);
10897        desk.exchange(&folder, &key, later + MINUTE);
10898        assert!(calls::read_held(&calls::own_file(&calls_of(&desk), calls::LOG, &phone.id)).is_empty(), "gone from the desk's copy too");
10899        let _ = std::fs::remove_dir_all(&base);
10900    }
10901
10902    /// A message a phone's notification brought (fiction numbers only).
10903    fn texted(at: i64) -> sioul_core::phonemsgs::Line {
10904        sioul_core::phonemsgs::Line {
10905            at,
10906            id: format!("{at}-0123456789abcdef"),
10907            app: "foundation.e.message".into(),
10908            label: "Message".into(),
10909            kind: "text".into(),
10910            name: "Cabinet du Dr Martin".into(),
10911            key: "+33199001234".into(),
10912            who: "neutral".into(),
10913            text: "Votre rendez-vous est déplacé à jeudi 10 h 30.".into(),
10914            shows: at,
10915            notice: sioul_core::replies::notice("foundation.e.message", "0|foundation.e.message|7|null|10123"),
10916            ..sioul_core::phonemsgs::Line::default()
10917        }
10918    }
10919
10920    /// The phones' messages' folder of a device.
10921    fn messages_of(c: &Computer) -> PathBuf {
10922        c.roots.state.join(sioul_core::phonemsgs::FOLDER)
10923    }
10924
10925    /// A phone's message reaches the desk, both with the part on: sealed and
10926    /// padded in the folder, remembered beside `memory.json`, never kept as an
10927    /// earlier version; Seen on the desk comes back to the phone.
10928    #[test]
10929    fn a_phone_s_messages_reach_the_desk_sealed_and_seen_comes_back() {
10930        use sioul_core::{calls, phonemsgs};
10931        let base = scratch("phone-messages");
10932        let (folder, notes) = (base.join("folder"), base.join("notes"));
10933        let key = quick_key(&folder, "four words make a passphrase").unwrap();
10934        let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
10935        let on = [phonemsgs::PART];
10936        let own = calls::own_file(&messages_of(&phone), phonemsgs::LOG, &phone.id);
10937        assert_eq!(phonemsgs::carry_into(&own, &[texted(NOW - 3_600_000)], NOW).unwrap(), 1);
10938        assert!(std::fs::read_to_string(&own).unwrap().lines().all(|l| l.len() % phonemsgs::PAD_STEP == 0), "padded");
10939        phone.exchange_with(&folder, &key, NOW, &notes, &on);
10940        let came = desk.exchange_with(&folder, &key, NOW + MINUTE, &notes, &on);
10941        assert!(came.written.contains(PHONE_MESSAGES_LOG), "{came:?}");
10942        let on_desk = phonemsgs::read_logs(&messages_of(&desk), NOW + MINUTE);
10943        assert_eq!(on_desk.iter().map(|l| (l.device.as_str(), l.key.as_str(), l.text.as_str())).collect::<Vec<_>>(), [(phone.id.as_str(), "+33199001234", "Votre rendez-vous est déplacé à jeudi 10 h 30.")]);
10944        // Seen on the desk, in its own log: the phone hears of it.
10945        calls::mark_seen(&messages_of(&desk), &desk.id, &[on_desk[0].id.clone()], NOW + 2 * MINUTE).unwrap();
10946        desk.exchange_with(&folder, &key, NOW + 3 * MINUTE, &notes, &on);
10947        let came = phone.exchange_with(&folder, &key, NOW + 4 * MINUTE, &notes, &on);
10948        assert!(came.written.contains(PHONE_MESSAGES_SEEN), "{came:?}");
10949        assert!(calls::read_seen(&messages_of(&phone)).seen.contains(&on_desk[0].id));
10950        // What its notification offers a reply, from the phone's own live file: the desk reads it.
10951        let live = sioul_core::replies::Live { notice: on_desk[0].notice.clone(), app: "foundation.e.message".into(), reply: true, read: true, at: NOW + 4 * MINUTE, ..Default::default() };
10952        assert!(!live.notice.is_empty());
10953        assert!(sioul_core::replies::offer(&sioul_core::replies::live_file(&messages_of(&phone), &phone.id), &live).unwrap());
10954        phone.exchange_with(&folder, &key, NOW + 4 * MINUTE + 1_000, &notes, &on);
10955        let came = desk.exchange_with(&folder, &key, NOW + 4 * MINUTE + 2_000, &notes, &on);
10956        assert!(came.written.contains(PHONE_MESSAGES_LIVE), "{came:?}");
10957        let offered = sioul_core::replies::read_live(&messages_of(&desk), NOW + 5 * MINUTE);
10958        assert_eq!(offered.get(&(phone.id.clone(), live.notice.clone())).map(|l| (l.reply, l.read)), Some((true, true)));
10959        // Sealed: nothing of it readable in the folder.
10960        let mut files = Vec::new();
10961        list_files(&folder, &folder, &[], &mut files);
10962        for (name, path) in &files {
10963            let bytes = std::fs::read(path).unwrap();
10964            for plain in [&b"199001234"[..], b"Cabinet", b"rendez-vous", b"neutral", b"foundation.e.message", on_desk[0].notice.as_bytes()] {
10965                assert!(!bytes.windows(plain.len()).any(|w| w == plain), "{name}");
10966            }
10967        }
10968        let every = stores_of(&Config::default(), &desk.roots, &|_| true);
10969        assert_eq!(locate(&every, &format!("{PHONE_MESSAGES_LOG}{}.jsonl", phone.id)).map(|(s, _)| s.part), Some(phonemsgs::PART));
10970        for c in [&desk, &phone] {
10971            let small = Memory::load(&c.memory, &c.id);
10972            assert!(small.entries.keys().chain(small.files.keys()).chain(small.pending.keys()).all(|k| !calls_store(k)), "memory.json stays small");
10973            let share = c.memory.parent().unwrap();
10974            let mut kept = Vec::new();
10975            list_files(share, share, &[], &mut kept);
10976            assert!(kept.iter().all(|(name, _)| !name.contains("phone-messages")), "{kept:?}");
10977        }
10978        // files.json lost: nothing goes out again as new.
10979        std::fs::remove_file(sealed_path(&desk.memory)).unwrap();
10980        let again = desk.exchange_with(&folder, &key, NOW + 5 * MINUTE, &notes, &on);
10981        assert_eq!(again.sent, 0, "{again:?}");
10982        let _ = std::fs::remove_dir_all(&base);
10983    }
10984
10985    /// A reply written on the desk to the phone's notification reaches the
10986    /// phone sealed, at rest and in the folder; the phone counts the desk,
10987    /// decides the request once (its ledger, whatever is read again), and its
10988    /// outcome comes back to the desk without the words.
10989    #[test]
10990    fn a_reply_written_on_the_desk_is_decided_once_and_its_outcome_comes_back() {
10991        use sioul_core::{calls, phonemsgs, replies, texts::Sealer};
10992        let base = scratch("replies");
10993        let (folder, notes, private) = (base.join("folder"), base.join("notes"), base.join("phone-private"));
10994        let key = quick_key(&folder, "four words make a passphrase").unwrap();
10995        let seal = crate::textseal::ReplySeal::new(&key);
10996        let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
10997        let on = [phonemsgs::PART];
10998        // The phone's line the reply answers, in its own log.
10999        let line = texted(NOW - 3 * MINUTE);
11000        phonemsgs::carry_into(&calls::own_file(&messages_of(&phone), phonemsgs::LOG, &phone.id), std::slice::from_ref(&line), NOW).unwrap();
11001        // The listener relayed it: the replies' ledger's mark made then, before any request (`replies::mark_ledger`).
11002        replies::mark_ledger(&private, NOW).unwrap();
11003        phone.exchange_with(&folder, &key, NOW, &notes, &on);
11004        desk.exchange_with(&folder, &key, NOW + 1_000, &notes, &on);
11005        let ask = replies::Ask { key: "0123456789abcdef0123456789abcdef".into(), phone: phone.id.clone(), verb: "reply".into(), notice: line.notice.clone(), app: line.app.clone(), line: line.id.clone(), words: "J'arrive dans dix minutes.".into(), written: NOW + MINUTE, ..replies::Ask::default() };
11006        assert!(replies::write_ask(&messages_of(&desk), &desk.id, &ask, &seal).unwrap());
11007        desk.exchange_with(&folder, &key, NOW + MINUTE, &notes, &on);
11008        let came = phone.exchange_with(&folder, &key, NOW + 2 * MINUTE, &notes, &on);
11009        assert!(came.written.contains(PHONE_MESSAGES_ASK), "{came:?}");
11010        let (asks, _) = replies::read_asks(&[messages_of(&phone)], &seal);
11011        assert_eq!(asks.iter().map(|a| (a.key.as_str(), a.device.as_str(), a.words.as_str())).collect::<Vec<_>>(), [(ask.key.as_str(), desk.id.as_str(), "J'arrive dans dix minutes.")]);
11012        let counted = counting(&folder, &key, &phone.memory, &phone.id, NOW + 2 * MINUTE);
11013        assert!(counted.contains(&desk.id), "{counted:?}");
11014        let decide = |now: i64| {
11015            let ledger = replies::open_ledger(&private, now).unwrap();
11016            let lines = phonemsgs::read_file(&calls::own_file(&messages_of(&phone), phonemsgs::LOG, &phone.id));
11017            let (asks, cancelled) = replies::read_asks(&[messages_of(&phone)], &seal);
11018            let (unsent, went) = (replies::unsent(&private), replies::went_keys(&private));
11019            let judge = replies::Judge { here: &phone.id, now_ms: now, ledger: &ledger, lines: &lines, standing: &|d: &str| if counted.contains(d) { sioul_core::texts::Standing::Counted } else { sioul_core::texts::Standing::Gone }, cancelled: &cancelled, answers: &|_: &str| true, unsent: &unsent, went: &went };
11020            asks.iter().map(|a| replies::verdict(a, &judge)).collect::<Vec<_>>()
11021        };
11022        assert_eq!(decide(NOW + 2 * MINUTE), [replies::Verdict::Send]);
11023        sioul_core::texts::record(&private, &replies::claim_of(&ask, &line.talk, NOW + 2 * MINUTE)).unwrap();
11024        // The listener handed it: the phone's word, back to the desk.
11025        let own = calls::own_file(&messages_of(&phone), replies::OUTCOME, &phone.id);
11026        replies::say_outcomes(&own, &[replies::Outcome { key: ask.key.clone(), state: "handed".into(), at: NOW + 2 * MINUTE, ..replies::Outcome::default() }]).unwrap();
11027        phone.exchange_with(&folder, &key, NOW + 3 * MINUTE, &notes, &on);
11028        let came = desk.exchange_with(&folder, &key, NOW + 4 * MINUTE, &notes, &on);
11029        assert!(came.written.contains(PHONE_MESSAGES_OUTCOME), "{came:?}");
11030        let outcomes = replies::read_outcomes(&messages_of(&desk));
11031        assert_eq!(outcomes.iter().map(|o| (o.key.as_str(), o.state.as_str(), o.device.as_str())).collect::<Vec<_>>(), [(ask.key.as_str(), "handed", phone.id.as_str())]);
11032        // Sealed in the folder, and at rest on both devices.
11033        for root in [&folder, &desk.roots.state, &phone.roots.state] {
11034            let mut files = Vec::new();
11035            list_files(root, root, &[], &mut files);
11036            for (name, path) in &files {
11037                let bytes = std::fs::read(path).unwrap();
11038                assert!(!bytes.windows(b"dix minutes".len()).any(|w| w == b"dix minutes"), "{name}");
11039            }
11040        }
11041        assert!(seal.open(std::fs::read_to_string(calls::own_file(&messages_of(&phone), replies::ASK, &desk.id)).unwrap().lines().next().unwrap()).is_some());
11042        // The phone's files.json lost: the request read again, decided once.
11043        std::fs::remove_file(sealed_path(&phone.memory)).unwrap();
11044        phone.exchange_with(&folder, &key, NOW + 5 * MINUTE, &notes, &on);
11045        assert_eq!(decide(NOW + 5 * MINUTE), [replies::Verdict::Decided]);
11046        let _ = std::fs::remove_dir_all(&base);
11047    }
11048
11049    /// Who may ask the phone (`counting`): a computer you forgot counts again
11050    /// only by a sealed fact newer than the forgetting, as `present` says (a
11051    /// record of its read here since, or an entry it wrote since); its plain
11052    /// notes written fresh in the folder, which anyone could write, change
11053    /// nothing. A forgotten computer still running Sioul counts again as soon
11054    /// as it shares anything: only a new passphrase cuts a device off.
11055    #[test]
11056    fn a_forgotten_computer_counts_again_only_by_a_sealed_fact() {
11057        let base = scratch("counting-forgotten");
11058        let folder = base.join("folder");
11059        let key = quick_key(&folder, "four words make a passphrase").unwrap();
11060        let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
11061        desk.write("data/drafts/2026-10-01.json", "{\"steps\": 1}");
11062        desk.exchange(&folder, &key, NOW);
11063        phone.exchange(&folder, &key, NOW + MINUTE);
11064        assert!(counting(&folder, &key, &phone.memory, &phone.id, NOW + MINUTE).contains(&desk.id));
11065        forget_device(&folder, &key, &phone.memory, &phone.id, &desk.id).unwrap();
11066        assert!(!counting(&folder, &key, &phone.memory, &phone.id, NOW + 2 * MINUTE).contains(&desk.id), "forgotten");
11067        // Plain notes in its name, said fresh: no sealed fact.
11068        std::fs::write(seen_path(&folder, &desk.id), format!("at = {}\nround = 9\n", (NOW + 3 * MINUTE) / 1000)).unwrap();
11069        phone.exchange(&folder, &key, NOW + 3 * MINUTE);
11070        assert!(!counting(&folder, &key, &phone.memory, &phone.id, NOW + 3 * MINUTE).contains(&desk.id), "plain notes are no sealed fact");
11071        // A record of its, sealed, read here since: it counts again.
11072        desk.write("data/drafts/2026-10-02.json", "{\"steps\": 2}");
11073        desk.exchange(&folder, &key, NOW + 4 * MINUTE);
11074        phone.exchange(&folder, &key, NOW + 5 * MINUTE);
11075        assert!(counting(&folder, &key, &phone.memory, &phone.id, NOW + 5 * MINUTE).contains(&desk.id), "a sealed record newer than the forgetting");
11076        let _ = std::fs::remove_dir_all(&base);
11077    }
11078
11079    /// Who may ask, in three (`asking`): counted; gone by a sealed fact (you
11080    /// forgot it); not known yet (no record of it here: a new computer whose
11081    /// first request comes before its records), which neither set holds.
11082    #[test]
11083    fn a_device_not_known_yet_is_neither_counted_nor_gone() {
11084        let base = scratch("asking");
11085        let folder = base.join("folder");
11086        let key = quick_key(&folder, "four words make a passphrase").unwrap();
11087        let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
11088        phone.exchange(&folder, &key, NOW);
11089        let (counted, gone) = asking(&folder, &key, &phone.memory, &phone.id, NOW);
11090        assert!(!counted.contains(&desk.id) && !gone.contains(&desk.id), "no record of the desk yet");
11091        desk.write("data/drafts/2026-10-01.json", "{\"steps\": 1}");
11092        desk.exchange(&folder, &key, NOW + MINUTE);
11093        phone.exchange(&folder, &key, NOW + 2 * MINUTE);
11094        let (counted, gone) = asking(&folder, &key, &phone.memory, &phone.id, NOW + 2 * MINUTE);
11095        assert!(counted.contains(&desk.id) && !gone.contains(&desk.id));
11096        forget_device(&folder, &key, &phone.memory, &phone.id, &desk.id).unwrap();
11097        let (counted, gone) = asking(&folder, &key, &phone.memory, &phone.id, NOW + 3 * MINUTE);
11098        assert!(!counted.contains(&desk.id) && gone.contains(&desk.id), "forgotten: gone");
11099        let (counted, gone) = asking(&folder, &key, &phone.memory, &phone.id, NOW + 3 * MINUTE);
11100        assert!(!counted.contains("laptop-never-seen") && !gone.contains("laptop-never-seen"));
11101        let _ = std::fs::remove_dir_all(&base);
11102    }
11103
11104    /// The part is off until turned on, on every device: a desk that never
11105    /// turned it on receives nothing and keeps nothing waiting; turned on, it
11106    /// reads them. A phone that never turned it on sends nothing.
11107    #[test]
11108    fn the_phone_messages_part_is_off_until_turned_on_and_off_receives_nothing() {
11109        use sioul_core::{calls, phonemsgs};
11110        let base = scratch("phone-messages-off");
11111        let (folder, notes) = (base.join("folder"), base.join("notes"));
11112        let key = quick_key(&folder, "four words make a passphrase").unwrap();
11113        let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
11114        assert!(!shared_by_default(phonemsgs::PART, &Config::default()));
11115        assert!(stores(&Config::default(), &desk.roots).iter().all(|s| s.part != phonemsgs::PART));
11116        phonemsgs::carry_into(&calls::own_file(&messages_of(&phone), phonemsgs::LOG, &phone.id), &[texted(NOW - 3_600_000)], NOW).unwrap();
11117        // The phone's part off: nothing of it leaves.
11118        phone.exchange(&folder, &key, NOW);
11119        desk.exchange_with(&folder, &key, NOW + MINUTE, &notes, &[phonemsgs::PART]);
11120        assert!(phonemsgs::read_logs(&messages_of(&desk), NOW + MINUTE).is_empty(), "the phone's part off");
11121        // The phone's on, the desk's off: nothing comes, nothing waits.
11122        phone.exchange_with(&folder, &key, NOW + 2 * MINUTE, &notes, &[phonemsgs::PART]);
11123        let outcome = desk.exchange(&folder, &key, NOW + 3 * MINUTE);
11124        assert_eq!(outcome.pending, 0, "{outcome:?}");
11125        assert!(!messages_of(&desk).join(phonemsgs::LOG).exists());
11126        // Turned on on the desk: it joins and reads it.
11127        let came = desk.exchange_with(&folder, &key, NOW + 4 * MINUTE, &notes, &[phonemsgs::PART]);
11128        assert!(came.written.contains(PHONE_MESSAGES_LOG), "{came:?}");
11129        assert_eq!(phonemsgs::read_logs(&messages_of(&desk), NOW + 4 * MINUTE).len(), 1);
11130        let _ = std::fs::remove_dir_all(&base);
11131    }
11132
11133    /// A week on, the phone takes its own line out: every device's copy loses it.
11134    #[test]
11135    fn a_trimmed_line_of_phone_messages_leaves_every_device() {
11136        use sioul_core::{calls, phonemsgs};
11137        let base = scratch("phone-messages-trim");
11138        let (folder, notes) = (base.join("folder"), base.join("notes"));
11139        let key = quick_key(&folder, "four words make a passphrase").unwrap();
11140        let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
11141        let on = [phonemsgs::PART];
11142        let own = calls::own_file(&messages_of(&phone), phonemsgs::LOG, &phone.id);
11143        phonemsgs::carry_into(&own, &[texted(NOW - 3_600_000)], NOW).unwrap();
11144        phone.exchange_with(&folder, &key, NOW, &notes, &on);
11145        desk.exchange_with(&folder, &key, NOW + MINUTE, &notes, &on);
11146        let theirs = calls::own_file(&messages_of(&desk), phonemsgs::LOG, &phone.id);
11147        assert_eq!(phonemsgs::read_file(&theirs).len(), 1);
11148        let later = NOW + phonemsgs::KEPT_DAYS * 86_400_000;
11149        assert_eq!(phonemsgs::trim_own(&messages_of(&phone), &phone.id, later).unwrap(), 1);
11150        phone.exchange_with(&folder, &key, later, &notes, &on);
11151        desk.exchange_with(&folder, &key, later + MINUTE, &notes, &on);
11152        assert!(phonemsgs::read_file(&theirs).is_empty(), "gone from the desk's copy too");
11153        let _ = std::fs::remove_dir_all(&base);
11154    }
11155
11156    /// An older Sioul knows neither of the phones' messages' stores: their
11157    /// records wait, written nowhere; updated, it reads them.
11158    #[test]
11159    fn an_older_sioul_keeps_the_phone_s_messages_waiting() {
11160        use sioul_core::{calls, phonemsgs};
11161        let base = scratch("phone-messages-older");
11162        let (folder, notes) = (base.join("folder"), base.join("notes"));
11163        let key = quick_key(&folder, "four words make a passphrase").unwrap();
11164        let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
11165        let on = [phonemsgs::PART];
11166        phonemsgs::carry_into(&calls::own_file(&messages_of(&phone), phonemsgs::LOG, &phone.id), &[texted(NOW - 3_600_000)], NOW).unwrap();
11167        phone.exchange_with(&folder, &key, NOW, &notes, &on);
11168        // The desk's older Sioul: no store of the phones' messages, and none known.
11169        let older_stores = desk.stores_with(&notes, &|part| part == phonemsgs::PART).into_iter().filter(|s| s.part != phonemsgs::PART).collect::<Vec<_>>();
11170        OLDER.with(|older| *older.borrow_mut() = vec![PHONE_MESSAGES_LOG.to_string(), PHONE_MESSAGES_SEEN.to_string()]);
11171        for n in 1..=2 {
11172            let outcome = exchange(&Sharing { folder: &folder, computer: &desk.id, key: &key, memory: &desk.memory, files: true, hurry: None }, &older_stores, NOW + n * MINUTE).unwrap();
11173            assert_eq!(outcome.pending, 1, "kept waiting: {outcome:?}");
11174        }
11175        assert!(!messages_of(&desk).exists(), "nothing written");
11176        OLDER.with(|older| older.borrow_mut().clear());
11177        let came = desk.exchange_with(&folder, &key, NOW + 3 * MINUTE, &notes, &on);
11178        assert!(came.written.contains(PHONE_MESSAGES_LOG) && came.pending == 0, "{came:?}");
11179        let _ = std::fs::remove_dir_all(&base);
11180    }
11181
11182    // ---------------------------------------------------------------- texts (SMS phase b)
11183
11184    /// The texts' folder of a device (in its data folder).
11185    fn texts_of(c: &Computer) -> PathBuf {
11186        c.roots.data.join(sioul_core::texts::FOLDER)
11187    }
11188
11189    /// A text a computer asks the phone to send (fiction numbers only).
11190    fn to_send(key: &str, phone: &str, written: i64) -> sioul_core::texts::Request {
11191        sioul_core::texts::Request { key: key.into(), phone: phone.into(), to: "01 99 00 12 34".into(), body: "J'arrive dans dix minutes.".into(), sub: -1, written, ..sioul_core::texts::Request::default() }
11192    }
11193
11194    /// The phone's step as the app takes it: every request read, decided,
11195    /// claimed in the ledger before it is "sent", its outcome written.
11196    fn phone_step(phone: &Computer, ledger: &Path, seal: &crate::textseal::TextSeal, now: i64) -> Vec<String> {
11197        use sioul_core::texts;
11198        let book = texts::Ledger::open(ledger, now).unwrap();
11199        let mut sent = Vec::new();
11200        for r in texts::read_requests(&texts_of(phone), seal) {
11201            if texts::verdict(&r, &phone.id, now, &book, None, &|_| texts::Standing::Counted) == texts::Verdict::Send {
11202                texts::record(ledger, &texts::Claim { key: r.key.clone(), at: now, state: "claimed".into(), ..texts::Claim::default() }).unwrap();
11203                let outcome = texts::Outcome { key: r.key.clone(), state: "sent".into(), at: now, row: "sms-412".into(), ..texts::Outcome::default() };
11204                texts::append(&texts::own_file(&texts_of(phone), texts::OUTCOME, &phone.id), &[outcome], seal, None).unwrap();
11205                sent.push(r.key);
11206            }
11207        }
11208        sent
11209    }
11210
11211    /// A text written on the desk reaches the phone, which sends it once and
11212    /// says so; the desk hears it. Sealed in the folder and at rest on each
11213    /// device, remembered beside `memory.json`; read again after `files.json`
11214    /// is lost, it is never sent twice.
11215    #[test]
11216    fn a_text_written_on_the_desk_is_sent_once_by_the_phone_and_its_outcome_comes_back() {
11217        use sioul_core::texts::{self, Sealer};
11218        let base = scratch("texts");
11219        let (folder, notes, ledger) = (base.join("folder"), base.join("notes"), base.join("phone-private"));
11220        let key = quick_key(&folder, "four words make a passphrase").unwrap();
11221        let seal = crate::textseal::TextSeal::new(&key);
11222        let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
11223        let on = [texts::PART];
11224        texts::Ledger::open(&ledger, NOW - MINUTE).unwrap();
11225        let request = to_send("0123456789abcdef0123456789abcdef", &phone.id, NOW);
11226        texts::write_request(&texts_of(&desk), &desk.id, &request, &seal).unwrap();
11227        desk.exchange_with(&folder, &key, NOW, &notes, &on);
11228        let came = phone.exchange_with(&folder, &key, NOW + MINUTE, &notes, &on);
11229        assert!(came.written.contains(TEXTS_SEND), "{came:?}");
11230        assert_eq!(phone_step(&phone, &ledger, &seal, NOW + MINUTE), [request.key.clone()]);
11231        phone.exchange_with(&folder, &key, NOW + 2 * MINUTE, &notes, &on);
11232        let came = desk.exchange_with(&folder, &key, NOW + 3 * MINUTE, &notes, &on);
11233        assert!(came.written.contains(TEXTS_OUTCOME), "{came:?}");
11234        let outcomes = texts::read_outcomes(&texts_of(&desk), &seal);
11235        assert_eq!(texts::state_of(&request.key, &outcomes).map(|o| o.state.as_str()), Some("sent"));
11236        // Sealed in the folder, and at rest on both devices.
11237        for root in [&folder, &desk.roots.data, &phone.roots.data] {
11238            let mut files = Vec::new();
11239            list_files(root, root, &[], &mut files);
11240            for (name, path) in &files {
11241                let bytes = std::fs::read(path).unwrap();
11242                for plain in [&b"199001234"[..], b"dix minutes", b"sms-412"] {
11243                    assert!(!bytes.windows(plain.len()).any(|w| w == plain), "{name}");
11244                }
11245            }
11246        }
11247        assert!(seal.open(std::fs::read_to_string(texts::own_file(&texts_of(&phone), texts::SEND, &desk.id)).unwrap().lines().next().unwrap()).is_some());
11248        for c in [&desk, &phone] {
11249            let small = Memory::load(&c.memory, &c.id);
11250            assert!(small.entries.keys().chain(small.files.keys()).all(|k| !calls_store(k)), "memory.json stays small");
11251        }
11252        // The phone's files.json lost: the request read again, never sent again.
11253        std::fs::remove_file(sealed_path(&phone.memory)).unwrap();
11254        phone.exchange_with(&folder, &key, NOW + 4 * MINUTE, &notes, &on);
11255        assert!(phone_step(&phone, &ledger, &seal, NOW + 4 * MINUTE).is_empty());
11256        let _ = std::fs::remove_dir_all(&base);
11257    }
11258
11259    /// The part is off until turned on: nothing of the texts travels from a
11260    /// device that never turned it on, and a desk with it off receives nothing.
11261    #[test]
11262    fn the_texts_part_is_off_until_turned_on() {
11263        use sioul_core::texts;
11264        assert!(!shared_by_default(texts::PART, &Config::default()));
11265        let base = scratch("texts-off");
11266        let (folder, notes) = (base.join("folder"), base.join("notes"));
11267        let key = quick_key(&folder, "four words make a passphrase").unwrap();
11268        let seal = crate::textseal::TextSeal::new(&key);
11269        let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
11270        assert!(stores(&Config::default(), &desk.roots).iter().all(|s| s.part != texts::PART));
11271        let text = texts::Text { id: "sms-1".into(), at: NOW - MINUTE, direction: "in".into(), with: vec!["+33199001234".into()], body: "Bonjour".into(), ..texts::Text::default() };
11272        texts::append(&texts::own_file(&texts_of(&phone), texts::LOG, &phone.id), &[text], &seal, None).unwrap();
11273        phone.exchange_with(&folder, &key, NOW, &notes, &[texts::PART]);
11274        let outcome = desk.exchange(&folder, &key, NOW + MINUTE);
11275        assert_eq!(outcome.pending, 0, "{outcome:?}");
11276        assert!(!texts_of(&desk).exists());
11277        let came = desk.exchange_with(&folder, &key, NOW + 2 * MINUTE, &notes, &[texts::PART]);
11278        assert!(came.written.contains(TEXTS_LOG), "{came:?}");
11279        assert_eq!(texts::read_logs(&texts_of(&desk), &seal).len(), 1);
11280        let _ = std::fs::remove_dir_all(&base);
11281    }
11282
11283    /// The archive only grows: a year on, nothing is trimmed by age; a text
11284    /// deleted on the phone gets a line of its own, and every device keeps
11285    /// the text, marked.
11286    #[test]
11287    fn the_texts_archive_only_grows_and_a_deletion_is_marked_everywhere() {
11288        use sioul_core::texts;
11289        let base = scratch("texts-archive");
11290        let (folder, notes) = (base.join("folder"), base.join("notes"));
11291        let key = quick_key(&folder, "four words make a passphrase").unwrap();
11292        let seal = crate::textseal::TextSeal::new(&key);
11293        let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
11294        let on = [texts::PART];
11295        let own = texts::own_file(&texts_of(&phone), texts::LOG, &phone.id);
11296        let id = |t: &texts::Text| t.line_id();
11297        let text = texts::Text { id: "sms-1".into(), at: NOW - 3 * 365 * 86_400_000, direction: "in".into(), with: vec!["+33199001234".into()], body: "Bonjour".into(), ..texts::Text::default() };
11298        texts::append(&own, std::slice::from_ref(&text), &seal, Some(&id)).unwrap();
11299        phone.exchange_with(&folder, &key, NOW, &notes, &on);
11300        desk.exchange_with(&folder, &key, NOW + MINUTE, &notes, &on);
11301        let later = NOW + 400 * 86_400_000;
11302        assert_eq!(texts::trim_own(&texts_of(&phone), &phone.id, &seal, later).unwrap() + texts::trim_others(&texts_of(&desk), &desk.id, &seal, later).unwrap(), 0);
11303        texts::append(&own, &[texts::Text { deleted: later, body: String::new(), ..text }], &seal, Some(&id)).unwrap();
11304        phone.exchange_with(&folder, &key, later, &notes, &on);
11305        desk.exchange_with(&folder, &key, later + MINUTE, &notes, &on);
11306        let kept = texts::read_logs(&texts_of(&desk), &seal);
11307        assert_eq!(kept.iter().map(|t| (t.body.as_str(), t.deleted > 0)).collect::<Vec<_>>(), [("Bonjour", true)]);
11308        let _ = std::fs::remove_dir_all(&base);
11309    }
11310
11311    /// The first import of a whole history goes a batch at a time (500 texts
11312    /// and their media each step); every exchange in between still carries
11313    /// the dose marked and do-not-disturb pressed just before it, and the
11314    /// media go as blobs, never in the records.
11315    #[test]
11316    fn the_first_texts_build_s_records_are_passed_over() {
11317        // texts: its stores were in the state folder; left aside, never kept waiting, and written nowhere.
11318        let key = |folder: &str| format!("{folder}phone.jsonl#a line");
11319        assert!(retired(&key("state/texts/log/")));
11320        assert!(!retired(&key(TEXTS_LOG)));
11321    }
11322
11323    /// A computer holding ten thousand texts (the owner's, 8 October 2026:
11324    /// 9,833 and a 25 MB `files.json` rewritten at each exchange): an idle
11325    /// exchange writes nothing of its memory; what it keeps of them is measured.
11326    #[test]
11327    fn an_idle_exchange_writes_nothing_with_ten_thousand_texts() {
11328        use sioul_core::texts;
11329        let base = scratch("texts-idle");
11330        let (folder, notes) = (base.join("folder"), base.join("notes"));
11331        let key = quick_key(&folder, "four words make a passphrase").unwrap();
11332        let seal = crate::textseal::TextSeal::new(&key);
11333        let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
11334        let on = [texts::PART];
11335        let own = texts::own_file(&texts_of(&phone), texts::LOG, &phone.id);
11336        for batch in 0..20 {
11337            let lines: Vec<texts::Text> = (0..texts::BATCH)
11338                .map(|i| {
11339                    let n = batch * texts::BATCH + i;
11340                    texts::Text { id: format!("sms-{n}"), at: NOW - 400 * 86_400_000 + n as i64 * MINUTE, direction: "in".into(), with: vec!["+33199001234".into()], body: format!("Message {n}, about as long as a text usually is, give or take."), ..texts::Text::default() }
11341                })
11342                .collect();
11343            texts::append(&own, &lines, &seal, None).unwrap();
11344        }
11345        // The desk's own sealed file too (its spam filter's table): its time
11346        // of use was set at every exchange, and the 25 MB file written again.
11347        desk.write("data/spam/table.bin", "a table of the desk's training");
11348        phone.exchange_with(&folder, &key, NOW, &notes, &on);
11349        desk.exchange_with(&folder, &key, NOW + MINUTE, &notes, &on);
11350        desk.exchange_with(&folder, &key, NOW + 2 * MINUTE, &notes, &on);
11351        assert!(!Memory::load_all(&desk.memory, &desk.id).sealed.mine.is_empty(), "the desk sealed its table");
11352        let sealed = sealed_path(&desk.memory);
11353        let (size, memory_size) = (std::fs::metadata(&sealed).unwrap().len(), std::fs::metadata(&desk.memory).unwrap().len());
11354        let stamp = |p: &Path| std::fs::metadata(p).unwrap().modified().unwrap();
11355        let before = stamp(&sealed);
11356        std::thread::sleep(std::time::Duration::from_millis(20));
11357        // Half an hour without a change: the sealed files looked for, the history tidied, nothing of it written.
11358        for minute in 3..33 {
11359            desk.exchange_with(&folder, &key, NOW + minute * MINUTE, &notes, &on);
11360        }
11361        eprintln!("ten thousand texts: files.json {size} bytes, memory.json {memory_size} bytes; the phone's log {} bytes", std::fs::metadata(&own).unwrap().len());
11362        assert_eq!(stamp(&sealed), before, "files.json rewritten by idle exchanges");
11363        assert!(std::fs::metadata(&desk.memory).unwrap().len() < 64 << 10, "memory.json stays small");
11364        assert!(size < 2 * std::fs::metadata(&own).unwrap().len(), "the texts' lines not kept twice: {size}");
11365        let _ = std::fs::remove_dir_all(&base);
11366    }
11367
11368    #[test]
11369    fn a_first_import_goes_in_batches_and_never_holds_back_the_doses() {
11370        use sioul_core::texts;
11371        let base = scratch("texts-import");
11372        let (folder, notes) = (base.join("folder"), base.join("notes"));
11373        let key = quick_key(&folder, "four words make a passphrase").unwrap();
11374        let seal = crate::textseal::TextSeal::new(&key);
11375        let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
11376        let on = [texts::PART];
11377        let own = texts::own_file(&texts_of(&phone), texts::LOG, &phone.id);
11378        let picture = vec![7u8; 512 * 1024];
11379        let source = base.join("picture.bin");
11380        std::fs::write(&source, &picture).unwrap();
11381        let (hash, size) = crate::blobs::hash_file(&source).unwrap();
11382        for batch in 0..6 {
11383            let now = NOW + batch * 10 * MINUTE;
11384            // A dose taken and do-not-disturb pressed on the phone, then the import's next step.
11385            phone.write("state/health-state.toml", &format!("[taken]\n\"dose@{batch}\" = {}\n", now / 1000));
11386            phone.write("state/do-not-disturb.toml", &format!("[device.{}]\npressed = {now}\n", phone.id));
11387            let lines: Vec<texts::Text> = (0..texts::BATCH)
11388                .map(|i| {
11389                    let n = batch as usize * texts::BATCH + i;
11390                    let mut text = texts::Text { id: format!("sms-{n}"), at: NOW - 400 * 86_400_000 + n as i64 * MINUTE, direction: "in".into(), with: vec!["+33199001234".into()], body: format!("Message {n}"), ..texts::Text::default() };
11391                    if i == 0 {
11392                        text.parts = vec![texts::Part { seq: 0, ct: "image/png".into(), size, hash: hash.clone(), state: "here".into(), ..texts::Part::default() }];
11393                    }
11394                    text
11395                })
11396                .collect();
11397            crate::blobs::put(&folder, &key, &source, &hash).unwrap();
11398            texts::append(&own, &lines, &seal, None).unwrap();
11399            let sent = phone.exchange_with(&folder, &key, now, &notes, &on);
11400            assert!(sent.sent <= texts::BATCH + 10, "a batch at a time: {sent:?}");
11401            desk.exchange_with(&folder, &key, now + MINUTE, &notes, &on);
11402            // On time: the dose and the press of this very step are on the desk.
11403            assert!(desk.read("state/health-state.toml").contains(&format!("dose@{batch}")), "batch {batch}");
11404            assert!(desk.read("state/do-not-disturb.toml").contains(&now.to_string()), "batch {batch}");
11405            assert_eq!(texts::read_logs(&texts_of(&desk), &seal).len(), (batch as usize + 1) * texts::BATCH);
11406        }
11407        // The media: a blob in the folder, never in the records.
11408        let mut files = Vec::new();
11409        list_files(&folder, &folder, &[], &mut files);
11410        let records: u64 = files.iter().filter(|(name, _)| !name.starts_with("blobs")).filter_map(|(_, p)| std::fs::metadata(p).ok()).map(|m| m.len()).sum();
11411        assert!(records < 6 * texts::BATCH as u64 * 2048, "the records hold lines, not media: {records} bytes");
11412        let target = base.join("fetched.bin");
11413        crate::blobs::get(&folder, &key, &hash, &target).unwrap();
11414        assert_eq!(std::fs::read(&target).unwrap(), picture);
11415        let _ = std::fs::remove_dir_all(&base);
11416    }
11417
11418    #[test]
11419    fn toml_entries_and_back() {
11420        let rules = &CONFIG_RULES;
11421        let text = "# mine\nlanguage = \"fr\" # kept\ncase_store = \"~/Notes\"\n\n[quiet]\npersonal = [\"joy\", \"family\"]\n\n[[account]]\nid = \"a\"\nmaildir = \"/x\"\n";
11422        let entries = toml_entries(text, rules).unwrap();
11423        let names: Vec<&str> = entries.iter().map(|(k, _)| k.as_str()).collect();
11424        assert!(names.contains(&"language") && names.contains(&"quiet\u{1f}personal") && names.contains(&"account\u{1f}\u{1e}a"), "{names:?}");
11425        assert!(!names.iter().any(|n| n.starts_with("case_store")));
11426        // Writing keeps the rest of the file as written.
11427        let account = entries.iter().find(|(k, _)| k.starts_with("account")).unwrap().1.replace("id = \"a\"", "id = \"a\"\nmuted = true");
11428        let new = toml_write(text, rules, &[("language", Some("v = \"en\"\n")), ("account\u{1f}\u{1e}a", Some(&account)), ("quiet\u{1f}work", Some("v = [\"client\"]\n"))], false).unwrap();
11429        assert!(new.starts_with("# mine\nlanguage = \"en\""), "{new}");
11430        assert!(new.contains("muted = true") && new.contains("maildir = \"/x\""), "{new}");
11431        assert!(new.contains("work = [\"client\"]"), "{new}");
11432        // A list of words, one at a time.
11433        let money = "ignored = [\"a\", \"b\"]\n";
11434        let new = toml_write(money, &MONEY_RULES, &[("ignored\u{1f}\u{1e}v = \"a\"\n", None), ("ignored\u{1f}\u{1e}v = \"c\"\n", Some("v = \"c\"\n"))], false).unwrap();
11435        assert_eq!(new, "ignored = [\"b\", \"c\"]\n");
11436    }
11437
11438    /// A medicine's takes and their own amounts, its generic name and its
11439    /// strength travel with it, one entry by
11440    /// its id (docs/health.md, "Kept as"): written into the file of a device
11441    /// whose copy has no amounts (an older Sioul's), they arrive whole, and the
11442    /// times every dose falls due at are the same.
11443    #[test]
11444    fn a_medicines_takes_travel_whole() {
11445        let new = "[[medicine]]\nid = \"iron\"\nname = \"Iron\"\ndose = \"08:00 · 1 tablet, 20:00 · 2 tablets\"\ngeneric = \"ferrous sulfate\"\nstrength = \"80 mg\"\nsince = 2026-09-01\n\n[medicine.schedule]\nevery = \"day\"\ntimes = [\"08:00\", \"20:00\"]\n\n[medicine.schedule.amounts]\n\"08:00\" = \"1 tablet\"\n\"20:00\" = \"2 tablets\"\n";
11446        let entries = toml_entries(new, &HEALTH_RULES).unwrap();
11447        assert_eq!(entries.len(), 1, "one entry for the medicine, its takes in it: {entries:?}");
11448        let (entry, value) = &entries[0];
11449        assert!(entry.starts_with("medicine") && value.contains("2 tablets"), "{entry} {value}");
11450        let old = "[[medicine]]\nid = \"iron\"\nname = \"Iron\"\ndose = \"1 tablet\"\n\n[medicine.schedule]\nevery = \"day\"\ntimes = [\"08:00\", \"20:00\"]\n";
11451        let written = toml_write(old, &HEALTH_RULES, &[(entry.as_str(), Some(value.as_str()))], false).unwrap();
11452        let health: sioul_core::health::Health = toml::from_str(&written).unwrap();
11453        let takes: Vec<(String, String)> = health.medicines[0].takes().into_iter().map(|t| (t.time, t.amount)).collect();
11454        assert_eq!(takes, [("08:00".to_string(), "1 tablet".to_string()), ("20:00".to_string(), "2 tablets".to_string())]);
11455        // Its generic name, strength and first day too.
11456        assert_eq!((health.medicines[0].generic.as_str(), health.medicines[0].strength.as_str(), health.medicines[0].since.map(|d| d.to_string())), ("ferrous sulfate", "80 mg", Some("2026-09-01".to_string())));
11457        let before: sioul_core::health::Health = toml::from_str(old).unwrap();
11458        let day = |h: &sioul_core::health::Health| {
11459            let start: jiff::Zoned = "2026-10-08T00:00[Europe/Paris]".parse().unwrap();
11460            let end: jiff::Zoned = "2026-10-09T00:00[Europe/Paris]".parse().unwrap();
11461            h.doses(&start, &end).into_iter().map(|d| (d.key, d.dose)).collect::<Vec<_>>()
11462        };
11463        let (now, then) = (day(&health), day(&before));
11464        assert_eq!(now.iter().map(|d| &d.0).collect::<Vec<_>>(), then.iter().map(|d| &d.0).collect::<Vec<_>>(), "the same doses, the same keys");
11465        assert_eq!(now.iter().map(|d| d.1.as_str()).collect::<Vec<_>>(), ["1 tablet", "2 tablets"]);
11466    }
11467
11468    /// Do-not-disturb on every device (docs/do-not-disturb.md): each device's
11469    /// table of the switch travels whole and is never written by another; the
11470    /// list of people travels a person at a time; a Sioul that knows neither
11471    /// file shares the rest and takes nothing of them out.
11472    #[test]
11473    fn do_not_disturb_travels_each_device_its_table_and_the_list_a_person_at_a_time() {
11474        use sioul_core::everywhere::{People, Person, Switch, change_own, change_people};
11475        let base = scratch("dnd");
11476        let folder = base.join("Sioul");
11477        let key = quick_key(&folder, "four words make a passphrase").unwrap();
11478        let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
11479        let switch = |c: &Computer| c.roots.state.join(sioul_core::everywhere::SWITCH_FILE);
11480        let people = |c: &Computer| c.roots.config.join(sioul_core::everywhere::PEOPLE_FILE);
11481        // The desk turns it on; each adds someone to the list, before hearing of the other.
11482        change_own(&switch(&desk), &desk.id, |s| {
11483            s.press(&desk.id, true, 0, NOW);
11484            true
11485        })
11486        .unwrap();
11487        change_people(&people(&phone), |p| {
11488            p.add(Person { name: "Alice".into(), phones: vec!["+262639981234".into()], ..Person::default() }, None);
11489        })
11490        .unwrap();
11491        change_people(&people(&desk), |p| {
11492            p.add(Person { name: "Bob".into(), emails: vec!["bob@example.org".into()], ..Person::default() }, None);
11493        })
11494        .unwrap();
11495        desk.exchange(&folder, &key, NOW + MINUTE);
11496        phone.exchange(&folder, &key, NOW + 2 * MINUTE);
11497        desk.exchange(&folder, &key, NOW + 3 * MINUTE);
11498        let heard = Switch::read(&switch(&phone)).unwrap();
11499        let latest = heard.latest().unwrap();
11500        assert!(latest.on && latest.from == desk.id, "{heard:?}");
11501        for c in [&desk, &phone] {
11502            let names: Vec<String> = People::read(&people(c)).unwrap().people.into_iter().map(|p| p.name).collect();
11503            assert!(names.contains(&"Alice".to_string()) && names.contains(&"Bob".to_string()), "{names:?}");
11504        }
11505        // The phone turns it off, having heard the desk: its own table only.
11506        change_own(&switch(&phone), &phone.id, |s| {
11507            s.press(&phone.id, false, 0, NOW + 4 * MINUTE);
11508            true
11509        })
11510        .unwrap();
11511        phone.exchange(&folder, &key, NOW + 5 * MINUTE);
11512        desk.exchange(&folder, &key, NOW + 6 * MINUTE);
11513        let seen = Switch::read(&switch(&desk)).unwrap();
11514        assert!(!seen.latest().unwrap().on, "{seen:?}");
11515        assert!(seen.device[&desk.id].on, "the desk's own table as it wrote it");
11516        // Someone taken off on the desk: gone on the phone too.
11517        let bob = People::read(&people(&desk)).unwrap().people.into_iter().find(|p| p.name == "Bob").unwrap().id;
11518        change_people(&people(&desk), |p| {
11519            p.remove(&bob);
11520        })
11521        .unwrap();
11522        desk.exchange(&folder, &key, NOW + 7 * MINUTE);
11523        phone.exchange(&folder, &key, NOW + 8 * MINUTE);
11524        assert_eq!(People::read(&people(&phone)).unwrap().people.iter().map(|p| p.name.as_str()).collect::<Vec<_>>(), ["Alice"]);
11525        // A Sioul that knows neither file (an older one): writes neither, takes nothing out.
11526        let old = Computer::new(&base, "old");
11527        let older: Vec<Store> = stores(&Config::default(), &old.roots).into_iter().filter(|s| !s.name.contains("do-not-disturb") && !s.name.contains("dnd-people")).collect();
11528        exchange(&Sharing { folder: &folder, computer: &old.id, key: &key, memory: &old.memory, files: true, hurry: None }, &older, NOW + 9 * MINUTE).unwrap();
11529        assert!(!switch(&old).exists() && !people(&old).exists());
11530        desk.exchange(&folder, &key, NOW + 10 * MINUTE);
11531        assert_eq!(People::read(&people(&desk)).unwrap().people.len(), 1);
11532        assert_eq!(Switch::read(&switch(&desk)).unwrap().device.len(), 2);
11533        let _ = std::fs::remove_dir_all(&base);
11534    }
11535
11536    /// A computer's table written as Sioul quits there (its inhibition ended
11537    /// with it) travels, and the phone reads it as closed, never silenced.
11538    #[test]
11539    fn a_computers_table_written_as_sioul_quits_reaches_the_phone_as_closed() {
11540        use sioul_core::everywhere::{Follows, Switch, change_own, others};
11541        let base = scratch("dnd-closed");
11542        let folder = base.join("Sioul");
11543        let key = quick_key(&folder, "four words make a passphrase").unwrap();
11544        let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
11545        let switch = |c: &Computer| c.roots.state.join(sioul_core::everywhere::SWITCH_FILE);
11546        // The phone turns do-not-disturb on; the desk silences itself, says so, then Sioul quits there.
11547        change_own(&switch(&phone), &phone.id, |s| {
11548            s.press(&phone.id, true, 0, NOW);
11549            true
11550        })
11551        .unwrap();
11552        phone.exchange(&folder, &key, NOW + MINUTE);
11553        desk.exchange(&folder, &key, NOW + 2 * MINUTE);
11554        change_own(&switch(&desk), &desk.id, |s| {
11555            let own = s.device.entry(desk.id.clone()).or_default();
11556            (own.kind, own.why, own.silenced, own.at) = ("computer".into(), "manual".into(), true, NOW + 2 * MINUTE);
11557            true
11558        })
11559        .unwrap();
11560        desk.exchange(&folder, &key, NOW + 3 * MINUTE);
11561        phone.exchange(&folder, &key, NOW + 4 * MINUTE);
11562        let read = |at: i64| {
11563            let s = Switch::read(&switch(&phone)).unwrap();
11564            others(&s, &phone.id, &[(desk.id.clone(), at / 1000)], at / 1000, NOW, &|_| true)
11565        };
11566        assert_eq!(read(NOW + 4 * MINUTE), vec![(desk.id.clone(), Follows::Silenced)]);
11567        // Sioul quits on the desk (the window closed, SIGTERM): its table as it then holds, sent.
11568        change_own(&switch(&desk), &desk.id, |s| {
11569            let own = s.device.get_mut(&desk.id).unwrap();
11570            own.closing(false);
11571            own.at = NOW + 5 * MINUTE;
11572            true
11573        })
11574        .unwrap();
11575        desk.exchange(&folder, &key, NOW + 5 * MINUTE);
11576        phone.exchange(&folder, &key, NOW + 6 * MINUTE);
11577        assert_eq!(read(NOW + 6 * MINUTE), vec![(desk.id.clone(), Follows::Closed)]);
11578        let table = &Switch::read(&switch(&phone)).unwrap().device[&desk.id];
11579        assert!(table.closed && !table.silenced, "{table:?}");
11580        let _ = std::fs::remove_dir_all(&base);
11581    }
11582}
11583
11584#[cfg(test)]
11585#[path = "share_format_tests.rs"]
11586mod format_tests;
11587
11588#[cfg(test)]
11589#[path = "share_switch_tests.rs"]
11590mod switch_tests;
11591
11592#[cfg(test)]
11593#[path = "share_switch_more_tests.rs"]
11594mod switch_more_tests;
11595
11596#[cfg(test)]
11597#[path = "share_switch_third_tests.rs"]
11598mod switch_third_tests;
11599
11600#[cfg(test)]
11601#[path = "share_quiet_tests.rs"]
11602mod quiet_tests;
11603
11604#[cfg(test)]
11605#[path = "share_idle_bench.rs"]
11606mod idle_bench;
11607
11608#[cfg(test)]
11609#[path = "share_correctness_tests.rs"]
11610mod correctness_tests;
11611
11612#[cfg(test)]
11613#[path = "share_storage_tests.rs"]
11614mod storage_tests;
11615
11616#[cfg(test)]
11617#[path = "share_rounds_tests.rs"]
11618mod rounds_tests;
11619
11620#[cfg(test)]
11621#[path = "share_gc_tests.rs"]
11622mod gc_tests;
11623
11624#[cfg(test)]
11625#[path = "share_drafts_tests.rs"]
11626mod drafts_tests;
11627
11628#[cfg(test)]
11629mod probe {
11630    /// What a copy of real folders holds, in counts: `SIOUL_SHARE_PROBE=<dir with config, data, state>`.
11631    #[test]
11632    #[ignore]
11633    fn probe() {
11634        let Some(base) = std::env::var_os("SIOUL_SHARE_PROBE").map(std::path::PathBuf::from) else { return };
11635        let roots = super::Roots { config: base.join("config"), data: base.join("data"), state: base.join("state") };
11636        let config: sioul_core::config::Config = std::fs::read_to_string(roots.config.join("config.toml")).ok().and_then(|t| toml::from_str(&t).ok()).unwrap_or_default();
11637        let stores = super::stores(&config, &roots);
11638        let look = super::Look { reads: &|_| true, hurry: &|| false, confirmed: &Default::default(), clock: super::clock_ms(), changing: &Default::default() };
11639        let found = super::gather(&stores, &Default::default(), &look);
11640        for store in &stores {
11641            let count = found.hashes.keys().filter(|k| super::file_of(k).starts_with(store.name.as_str()) && (store.folder || super::file_of(k) == store.name)).count();
11642            println!("{:32} {count}", store.name);
11643        }
11644        println!("unknown: {:?}", found.unknown);
11645    }
11646}