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 them back.
124const TOMBSTONE_DAYS: i64 = 90;
125/// A computer silent this long no longer holds back the removal of old rounds.
126const SILENT_DAYS: i64 = 180;
127/// How long a file must stay empty, or gone, before its entries count as taken
128/// out: one being written, a full disk or a crash never takes everything out
129/// everywhere at once.
130const EMPTIED_WAIT: i64 = 10 * 60_000;
131
132/// How long a round waits for the end of the round before (`Turn`), which a
133/// sync app may bring late, before that end is taken for lost: an hour
134/// (milliseconds) of exchanges, time asleep left out (`AWAIT_STEP`). Then the
135/// gap is said, the doses doubt what that computer marked until its next
136/// full round, and its later records are read.
137const AWAITED: i64 = 60 * 60_000;
138
139/// The most one exchange counts of the wait (`AWAITED`): the time since the
140/// one before, five minutes at most (milliseconds). A device asleep, or
141/// closed, counts none of that time: woken, its sync app may not have
142/// brought the round's end yet, and passing the round over then would ask
143/// for a full round for nothing (on a phone, megabytes of its texts: review
144/// of 10 October 2026, third pass).
145const AWAIT_STEP: i64 = 5 * 60_000;
146
147/// The longest a full round owed or asked for (`Memory::owed`) waits for a
148/// connection not metered: six hours of exchanges, counted as `AWAITED` is
149/// (milliseconds). A phone always on mobile data, which marks most doses,
150/// would otherwise leave the others saying "check" on its doses for days
151/// after one damaged line, until 4 MiB of growth.
152const OWED_METERED: i64 = 6 * 60 * 60_000;
153/// A line of a computer's records longer than this is none (the longest, a
154/// file whole of 16 MiB sealed in a line, is about 30 MiB): skipped as broken.
155const LONGEST_LINE: usize = 32 << 20;
156/// Records are read this much at a time, never a whole file.
157const READ_CHUNK: usize = 8 << 20;
158/// More files sealed apart than this gone from a folder at once (or three and
159/// more, past a fifth of it) are held, said, until you say to take them out.
160const MASS: usize = 10;
161const MINUTE: i64 = 60_000;
162const HOUR: i64 = 60 * MINUTE;
163const DAY: i64 = 24 * HOUR;
164
165/// The beginning of the names of folders whose files are sealed apart (`Shape::Files`).
166const FILES: &str = "files/";
167
168/// How a file divides into entries.
169#[derive(Debug, Clone, Copy)]
170pub enum Shape {
171    /// The file whole: a draft, an invoice.
172    Whole,
173    /// One entry per line: the sender lists.
174    Lines,
175    /// One entry per setting of a TOML file.
176    Toml(&'static Rules),
177    /// The file whole, sealed apart in the folder (`blobs`), its record saying
178    /// which content it holds: notes, papers, their pictures and PDFs; the
179    /// spam filter's table, a single file (`SPAM_TABLE`).
180    Files,
181}
182
183/// How a TOML file divides, beyond one entry per setting.
184#[derive(Debug)]
185pub struct Rules {
186    /// Lists whose elements are entries.
187    pub keyed: &'static [Keyed],
188    /// Tables shared whole, their fields together; `*` stands for any name.
189    pub whole: &'static [&'static str],
190    /// Settings that stay on each computer.
191    pub local: &'static [&'static str],
192    /// How the file divides in format 2 (`FORMAT`), when it differs: its
193    /// lists named by ids, lists shared whole until then divided element by
194    /// element. A device writes it once every device in the sharing reads it
195    /// (`format_open`); until then, these rules (format 1), which an older
196    /// Sioul reads.
197    pub next: Option<&'static Rules>,
198}
199
200impl Rules {
201    /// The rules of a file in a format: format 2's from 2 on, when they differ.
202    pub fn at(&'static self, format: u32) -> &'static Rules {
203        match self.next {
204            Some(next) if format >= 2 => next,
205            _ => self,
206        }
207    }
208}
209
210/// A list whose elements are entries, each named by some of its fields (none:
211/// the element itself), less the fields that stay on each computer.
212#[derive(Debug)]
213pub struct Keyed {
214    /// Its name; within tables, its path, `*` standing for any name
215    /// (`words.*.*.add`: each list's words you added).
216    pub list: &'static str,
217    pub by: &'static [&'static str],
218    pub local: &'static [&'static str],
219    /// Other names the list may have in a file, its entries the same: the
220    /// records always say `list`, and a file keeps the name it has. A file
221    /// with neither, of another name than its record's, takes the first.
222    pub also: &'static [&'static str],
223    /// Format 2: each element named by its own `id`, given when it is made
224    /// (`sioul_core::ids`), so that it stays one element whatever changes in
225    /// it; an element without one (made by an older Sioul, or by hand) by an
226    /// id derived from what it holds, the same on every device (`names`).
227    /// `by` then says how format 1 named them, to read its records.
228    pub ids: bool,
229}
230
231/// A keyed list as format 1 names its elements: by `by`, or by themselves.
232const fn keyed(list: &'static str, by: &'static [&'static str]) -> Keyed {
233    Keyed { list, by, local: &[], also: &[], ids: false }
234}
235
236/// A keyed list whose elements are named by their ids (format 2); `by`: as format 1 named them.
237const fn with_ids(list: &'static str, by: &'static [&'static str]) -> Keyed {
238    Keyed { list, by, local: &[], also: &[], ids: true }
239}
240
241/// Where things are on this computer, and how text reads on its screen.
242const CONFIG_LOCAL: &[&str] = &["case_store", "known_senders", "blocked_senders", "reading", "history_weeks", "letters.inbox", "dnd.background"];
243static 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) };
244/// Format 2: pinned sites, routines and task kinds one each, by their ids;
245/// days off one each; the words you added to a list and those you took
246/// away, one each (docs/words.md). Until then each of these lists travels
247/// whole, and two devices adding to one in the same exchange keep only the
248/// later list.
249static CONFIG_RULES_2: Rules = Rules {
250    keyed: &[
251        Keyed { list: "account", by: &["id"], local: &["maildir", "history_weeks"], also: &[], ids: false },
252        with_ids("site", &["id"]),
253        with_ids("routine", &["id"]),
254        with_ids("tasks.kind", &["id"]),
255        with_ids("time_off", &[]),
256        keyed("words.*.*.add", &[]),
257        keyed("words.*.*.remove", &[]),
258        // A named list's names and their words: `add = { "Ma Banque" = ["mabanque.example"] }`.
259        keyed("words.*.*.add.*", &[]),
260    ],
261    whole: &[],
262    local: CONFIG_LOCAL,
263    next: None,
264};
265static HEALTH_RULES: Rules = Rules {
266    keyed: &[keyed("prescription", &["id"]), keyed("medicine", &["id"])],
267    whole: &[],
268    // An older Sioul's folder of a watch's files (until 8 October 2026), a
269    // path on its own computer: never carried, as before.
270    local: &["watch_folder"],
271    next: None,
272};
273static LINKS_RULES: Rules = Rules { keyed: &[keyed("link", &[])], whole: &[], local: &[], next: None };
274static PORCH_RULES: Rules = Rules { keyed: &[], whole: &["done.*"], local: &[], next: None };
275static MONEY_RULES: Rules = Rules { keyed: &[keyed("ignored", &[])], whole: &[], local: &[], next: None };
276static TODAY_RULES: Rules = Rules { keyed: &[keyed("aside", &[])], whole: &[], local: &[], next: None };
277static TIME_RULES: Rules = Rules { keyed: &[keyed("session", &["start", "task", "project"])], whole: &[], local: &[], next: Some(&TIME_RULES_2) };
278/// Format 2: each session by its id, so that one changed on two devices stays
279/// one; a session without one by its start, task and project, as before.
280static TIME_RULES_2: Rules = Rules { keyed: &[with_ids("session", &["start", "task", "project"])], whole: &[], local: &[], next: None };
281/// The hours' overrides and the two pauses (`sioul_core::quiet`); format 2:
282/// the lighter days one each, by their date.
283static QUIET_RULES: Rules = Rules { keyed: &[], whole: &[], local: &[], next: Some(&QUIET_RULES_2) };
284static QUIET_RULES_2: Rules = Rules { keyed: &[keyed("lighter", &[])], whole: &[], local: &[], next: None };
285/// How each day went (`sioul_core::reviews`): a date's review at the end of
286/// work and the one before sleep travel whole, so that two devices answering
287/// the same one keep the later answer, never a mix of both; the weather field by field.
288static REVIEWS_RULES: Rules = Rules { keyed: &[], whole: &["*.work", "*.night"], local: &[], next: None };
289static PLAIN_RULES: Rules = Rules { keyed: &[], whole: &[], local: &[], next: None };
290/// Do-not-disturb's switch (docs/do-not-disturb.md): a table per device, each
291/// written by its own device alone, shared whole.
292static DND_RULES: Rules = Rules { keyed: &[], whole: &["device.*"], local: &[], next: None };
293/// Who may reach you during do-not-disturb: one person each, by its id.
294static DND_PEOPLE_RULES: Rules = Rules { keyed: &[keyed("person", &["id"])], whole: &[], local: &[], next: None };
295/// Each dose that fell due (`sioul_core::doses`): one entry per field, each
296/// device's opening apart, and each device's answer whole, so that two
297/// devices never write one entry and an answer never travels in halves.
298static DOSES_RULES: Rules = Rules { keyed: &[], whole: &["dose.*.answer.*"], local: &[], next: None };
299// Projects and budgets, at the notes folder's root, when they travel here (`share_projects`).
300// The projects' records keep their first names, `notes/sioul-cases.toml` and
301// `case`, which every version reads; a file renamed `sioul-projects.toml`, its
302// tables `[[project]]`, reads and is written under them (`sioul_core::projects`).
303static PROJECTS_RULES: Rules = Rules { keyed: &[Keyed { list: "case", by: &["id"], local: &[], also: &["project"], ids: false }], whole: &[], local: &[], next: None };
304// The bank's movements: each account by its id, each movement by its account and the bank's own id.
305static BANK_RULES: Rules = Rules { keyed: &[keyed("account", &["id"]), keyed("movement", &["account", "id"])], whole: &[], local: &[], next: None };
306static LEDGER_RULES: Rules = Rules {
307    keyed: &[
308        keyed("budget", &["id"]),
309        keyed("preset", &["id"]),
310        keyed("reserve", &["id"]),
311        keyed("bank_account", &["id"]),
312        keyed("assign", &["account", "movement"]),
313        // No name of their own: each one is itself, so two devices adding some keep both.
314        keyed("line", &[]),
315        keyed("cover", &[]),
316        keyed("mail_rule", &[]),
317        keyed("split", &[]),
318    ],
319    whole: &[],
320    local: &[],
321    next: Some(&LEDGER_RULES_2),
322};
323/// Format 2: lines, covers, mail rules, splits and presets each by its id, so
324/// that two the same stay two and one changed on two devices stays one; one
325/// without an id (made by an older Sioul, or by hand) by an id derived from
326/// what it holds.
327static LEDGER_RULES_2: Rules = Rules {
328    keyed: &[
329        keyed("budget", &["id"]),
330        with_ids("preset", &["id"]),
331        keyed("reserve", &["id"]),
332        keyed("bank_account", &["id"]),
333        keyed("assign", &["account", "movement"]),
334        with_ids("line", &[]),
335        with_ids("cover", &[]),
336        with_ids("mail_rule", &[]),
337        with_ids("split", &[]),
338    ],
339    whole: &[],
340    local: &[],
341    next: None,
342};
343static CONTRACTS_RULES: Rules = Rules { keyed: &[keyed("contract", &["id"])], whole: &[], local: &[], next: None };
344static PAPERS_RULES: Rules = Rules { keyed: &[keyed("paper", &["id"])], whole: &[], local: &[], next: None };
345/// In the notes folder, what the other parts carry: projects, budgets, the
346/// bank, contracts (projects), the papers' wallet and its folder (papers).
347const NOT_NOTES: &[&str] = &[
348    sioul_core::projects::FILE,
349    sioul_core::projects::OLD_FILE,
350    sioul_core::projects::BEFORE_RENAME,
351    sioul_core::budget::LEDGER,
352    sioul_core::bank::MANIFEST,
353    sioul_core::contracts::MANIFEST,
354    sioul_core::papers::MANIFEST,
355    sioul_core::papers::FOLDER,
356];
357
358/// The parts of what is shared, each switched on or off on each device
359/// (docs/database.md, "Parts"): settings and accounts, senders, the calls
360/// your phones screened, the messages from your phones' notifications, the
361/// spam filter's table and label logs, health, time, drafts and invoices,
362/// projects and money, lists kept here, notes, papers.
363/// (texts: the part "texts", SMS phase b, after the phone's messages.)
364pub const PARTS: [&str; 13] = ["settings", "senders", "calls", sioul_core::phonemsgs::PART, sioul_core::texts::PART, "spam", "health", "time", "drafts", "projects", "lists", "notes", "papers"];
365
366/// Stores an older Sioul shared and this one no longer has: a Garmin
367/// watch's days and the memory of its offers, taken out on 8 October 2026
368/// (docs/health.md, "Your watch"). A device on an older version may still
369/// send their changes: they are left aside, neither written nor kept
370/// waiting, and the rest of the exchange goes on. The files stay where they
371/// are on every device. texts: the first build of the texts, never
372/// released, kept them in the state folder (`state/texts/`).
373const RETIRED: &[&str] = &["data/watch/", "state/watch-offers.json", "state/texts/"];
374
375/// Whether a record's key names a store that left (`RETIRED`).
376fn retired(key: &str) -> bool {
377    let file = file_of(key);
378    RETIRED.iter().any(|store| if store.ends_with('/') { file.starts_with(store) } else { file == *store })
379}
380
381/// The phones' logs of the calls they screened (`sioul_core::calls`): each
382/// phone writes its own file, a line per call, taken out after a month by
383/// that phone alone; every device reads them all, so that a computer's Porch
384/// lists what your phones declined. Never a blocked caller's call.
385pub const CALLS_LOG: &str = "state/calls/log/";
386/// The calls marked Seen, each device's own file, a line per press: seen on
387/// one device, gone from the Porch of every device.
388pub const CALLS_SEEN: &str = "state/calls/seen/";
389/// Where the calls' part keeps its files in the records: remembered beside
390/// the notes (`files.json`, not `memory.json`), never kept as an earlier
391/// version nor copied aside when sharing starts, so that a call leaves every
392/// device after its month (`calls_store`).
393const CALLS: &str = "state/calls/";
394
395/// The phones' logs of the messages their notifications brought
396/// (`sioul_core::phonemsgs`), for the apps you switched on there: each phone
397/// writes its own file, a line per message, padded, taken out after a week
398/// by that phone alone; every device reads them all, so that a computer's
399/// Porch says what came. Never a code, never a blocked sender's.
400pub const PHONE_MESSAGES_LOG: &str = "state/phone-messages/log/";
401/// The messages marked Seen, each device's own file: seen on one device,
402/// gone from the Porch of every device.
403pub const PHONE_MESSAGES_SEEN: &str = "state/phone-messages/seen/";
404/// Where the phones' messages keep their files in the records: as the
405/// calls', remembered beside the notes, never kept as an earlier version nor
406/// copied aside when sharing starts (`calls_store`).
407const PHONE_MESSAGES: &str = "state/phone-messages/";
408
409// texts: SMS phase (b) (docs/texts.md, docs/database.md "Texts"). Each
410// store a folder of lines in the data folder (an archive), one writer per
411// file, every line sealed at rest by its writer (`textseal`) and again in
412// the folder; media travel as blobs, outside the records.
413/// Each phone's texts, the whole history (`sioul_core::texts::Text`).
414pub const TEXTS_LOG: &str = "data/texts/log/";
415/// Each computer's texts to send, a random key each.
416pub const TEXTS_SEND: &str = "data/texts/send/";
417/// Each phone's word on each text it was asked to send.
418pub const TEXTS_OUTCOME: &str = "data/texts/outcome/";
419/// Where the texts keep their files in the records: as the calls', remembered
420/// beside the notes, never kept as an earlier version nor copied aside (`calls_store`).
421const TEXTS: &str = "data/texts/";
422
423/// A store, a file or an entry of the calls' part, or of the phones'
424/// messages, by its name in the records: logs of lines that leave every
425/// device after their time.
426fn calls_store(name: &str) -> bool {
427    // texts: their stores too.
428    name.starts_with(CALLS) || name.starts_with(PHONE_MESSAGES) || name.starts_with(TEXTS)
429}
430
431/// The spam filter's table (`sioul_core::spam::table`), made by a training
432/// on a computer, read by every device. Sealed apart, as its content's hash:
433/// a new table is sent once, and the records stay small (whole in a record,
434/// its two megabytes would go again in every round its computer starts). A
435/// single file of Sioul's own, in its private folders: read in every
436/// exchange, a phone's background step's too. Never the language model nor
437/// the corpus beside it: they stay on the computer that made them.
438pub const SPAM_TABLE: &str = "files/spam/table.bin";
439
440/// The spam filter's label logs (`sioul_core::spam::labels`): each device
441/// writes its own file, a line per act, never rewritten; every device reads
442/// them all, so that a message you said is not spam on one is never flagged
443/// again on another, and the training on your computer learns from what you
444/// said on your phone. Small, read in every exchange.
445pub const SPAM_LABELS: &str = "state/spam/labels/";
446/// What your own spam filter moved into a Junk folder, each device's own
447/// file: no word of yours; the Porch's lane of its catches reads it, and the
448/// training, which learns from it until you say otherwise.
449pub const SPAM_MOVED: &str = "state/spam/moved/";
450/// What your own spam filter flagged where it is, each device's own file:
451/// the training's, which learns from it until you say otherwise.
452pub const SPAM_FLAGGED: &str = "state/spam/flagged/";
453
454/// Whether a device that never chose shares a part: what was shared before
455/// parts had switches, everything but notes and papers, and projects as the
456/// setting all devices followed then said (`share_projects`); the spam
457/// filter's table, which every device reads. Never the phones' messages,
458/// newer than the switches: each device shares them once you turn them on there.
459pub fn shared_by_default(part: &str, config: &Config) -> bool {
460    match part {
461        "projects" => config.share_projects,
462        "notes" | "papers" => false,
463        // A notification's words leave the phone only once you say so, on each device.
464        sioul_core::phonemsgs::PART => false,
465        // texts: read and sent through your phone, once you say so on each device.
466        sioul_core::texts::PART => false,
467        _ => true,
468    }
469}
470
471/// A file, or a folder of files, that is shared.
472#[derive(Debug, Clone)]
473pub struct Store {
474    /// Its name in the records, the same on every computer: "config/config.toml", "data/drafts/".
475    pub name: String,
476    pub path: PathBuf,
477    /// A folder: each file in it, and below, on its own.
478    pub folder: bool,
479    pub shape: Shape,
480    /// In a folder: names left out (of files sealed apart, at its top only).
481    pub skip: &'static [&'static str],
482    /// The part it belongs to (`PARTS`).
483    pub part: &'static str,
484}
485
486/// The folders Sioul keeps its files in.
487pub struct Roots {
488    pub config: PathBuf,
489    pub data: PathBuf,
490    pub state: PathBuf,
491}
492
493impl Roots {
494    pub fn here() -> Roots {
495        Roots { config: sioul_core::config::config_dir(), data: sioul_core::config::data_dir(), state: sioul_core::config::state_dir() }
496    }
497}
498
499/// What is shared, each part as a device that never chose shares it (`shared_by_default`).
500pub fn stores(config: &Config, roots: &Roots) -> Vec<Store> {
501    stores_of(config, roots, &|part| shared_by_default(part, config))
502}
503
504/// The format of what travels, as this Sioul reads and writes it
505/// (docs/database.md, "The format of what travels"). 1: as Sioul 0.0.4 and
506/// every earlier one wrote it. 2: money lines, covers, mail rules, splits,
507/// presets and time sessions each named by its own id (`Keyed::ids`);
508/// pinned sites, routines, task kinds, days off, the words you added or took
509/// away and the lighter days one each, where they travelled whole. Each
510/// device says it in its entry (`devices::Entry::format`), and writes format
511/// 2 once every device in the sharing says it (`format_open`): an older
512/// Sioul, which reads format 1 only, is never sent what it cannot read.
513pub const FORMAT: u32 = 2;
514
515/// The format each part is written in at `FORMAT`: 2 for the parts whose
516/// records changed (settings, time, projects and money), 1 for the others.
517/// Each record says its part's format when it is not 1 (`Change::f`).
518pub fn part_format(part: &str) -> u32 {
519    match part {
520        "settings" | "time" | "projects" => 2,
521        _ => 1,
522    }
523}
524
525/// What is shared of the parts `shares` keeps. Not shared: caches, how far
526/// each sync got, how pages are shown on this screen, the site notices of this
527/// computer's browser, your own PGP keys (secret keys never leave the computer
528/// they were made on).
529pub fn stores_of(config: &Config, roots: &Roots, shares: &dyn Fn(&str) -> bool) -> Vec<Store> {
530    let (c, d, s) = (&roots.config, &roots.data, &roots.state);
531    let file = |part: &'static str, name: &str, path: PathBuf, shape: Shape| Store { name: name.into(), path, folder: false, shape, skip: &[], part };
532    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 };
533    let senders = |chosen: &Option<String>, name: &str| chosen.as_deref().map_or_else(|| c.join(name), sioul_core::config::expand_home);
534    let local = sioul_core::vdir::LOCAL;
535    let mut stores = vec![
536        file("settings", "config/config.toml", c.join("config.toml"), Shape::Toml(&CONFIG_RULES)),
537        file("senders", "config/known-senders.txt", senders(&config.known_senders, "known-senders.txt"), Shape::Lines),
538        file("senders", "config/blocked-senders.txt", senders(&config.blocked_senders, "blocked-senders.txt"), Shape::Lines),
539        file("senders", "config/safe-senders.txt", c.join("safe-senders.txt"), Shape::Lines),
540        file("senders", "config/neutral-senders.txt", c.join("neutral-senders.txt"), Shape::Lines),
541        file("senders", "config/restricted-senders.txt", c.join("restricted-senders.txt"), Shape::Lines),
542        // Who may reach you during do-not-disturb, beside the mail lists (docs/do-not-disturb.md).
543        file("senders", "config/dnd-people.toml", c.join(sioul_core::everywhere::PEOPLE_FILE), Shape::Toml(&DND_PEOPLE_RULES)),
544        file("settings", "data/links.toml", d.join("links.toml"), Shape::Toml(&LINKS_RULES)),
545        file("health", "data/health.toml", d.join("health.toml"), Shape::Toml(&HEALTH_RULES)),
546        // Each day's own meals, naps and nights: one entry per field of a block of a day.
547        file("health", "data/health-days.toml", d.join("health-days.toml"), Shape::Toml(&PLAIN_RULES)),
548        file("time", "data/time/running.toml", d.join("time").join("running.toml"), Shape::Whole),
549        folder("time", "data/time/", d.join("time"), Shape::Toml(&TIME_RULES), &["running.toml"]),
550        // How each day went, with the plan it is the outcome of (today.toml, quiet.toml, the sessions):
551        // every device that plans learns the same (docs/reviews.md).
552        folder("time", "data/reviews/", d.join("reviews"), Shape::Toml(&REVIEWS_RULES), &[]),
553        folder("drafts", "data/drafts/", d.join("drafts"), Shape::Whole, &[]),
554        folder("drafts", "data/invoices/", d.join("invoices"), Shape::Whole, &[]),
555        folder("senders", "data/pgp/others/", d.join("pgp").join("others"), Shape::Whole, &[]),
556        folder("lists", "data/calendars/local/", d.join("calendars").join(local), Shape::Whole, &[]),
557        folder("lists", "data/contacts/local/", d.join("contacts").join(local), Shape::Whole, &[]),
558        file("settings", "state/porch.toml", s.join("porch.toml"), Shape::Toml(&PORCH_RULES)),
559        file("settings", "state/money.toml", s.join("money.toml"), Shape::Toml(&MONEY_RULES)),
560        // Do-not-disturb's switch, pressed on any device (docs/do-not-disturb.md).
561        file("settings", "state/do-not-disturb.toml", s.join(sioul_core::everywhere::SWITCH_FILE), Shape::Toml(&DND_RULES)),
562        file("time", "state/today.toml", s.join("today.toml"), Shape::Toml(&TODAY_RULES)),
563        file("time", "state/quiet.toml", s.join("quiet.toml"), Shape::Toml(&QUIET_RULES)),
564        file("time", "state/stopped.toml", s.join("stopped.toml"), Shape::Toml(&PLAIN_RULES)),
565        file("health", "state/health-state.toml", s.join("health-state.toml"), Shape::Toml(&PLAIN_RULES)),
566        // Each dose that fell due and the answers your devices captured (docs/health.md,
567        // "Doses as records"): a file of its own, which an older Sioul leaves alone.
568        file("health", "state/health-doses.toml", s.join(sioul_core::doses::FILE), Shape::Toml(&DOSES_RULES)),
569        folder("senders", "state/shield/", s.join("shield"), Shape::Whole, &[]),
570        folder("lists", "state/dav/local/", s.join("dav").join(local), Shape::Whole, &[]),
571        // The spam filter's table alone: its language model and its corpus, beside it, stay here.
572        file("spam", SPAM_TABLE, d.join("spam").join("table.bin"), Shape::Files),
573        // What you said is spam or not, what your own filter moved and what it
574        // flagged: each device's own log (`<device>.jsonl`), a line each,
575        // every device's read.
576        folder("spam", SPAM_LABELS, s.join("spam").join(sioul_core::spam::labels::FOLDER), Shape::Lines, &[]),
577        folder("spam", SPAM_MOVED, s.join("spam").join(sioul_core::spam::labels::MOVED), Shape::Lines, &[]),
578        folder("spam", SPAM_FLAGGED, s.join("spam").join(sioul_core::spam::labels::FLAGGED), Shape::Lines, &[]),
579        // The calls each phone screened, and those each device marked Seen:
580        // each device's own file (`<device>.jsonl`), a line each, every device's read.
581        folder("calls", CALLS_LOG, s.join(sioul_core::calls::FOLDER).join(sioul_core::calls::LOG), Shape::Lines, &[]),
582        folder("calls", CALLS_SEEN, s.join(sioul_core::calls::FOLDER).join(sioul_core::calls::SEEN_LOG), Shape::Lines, &[]),
583        // The messages each phone's notifications brought, and those each device
584        // marked Seen: each device's own file, a line each, every device's read.
585        folder(sioul_core::phonemsgs::PART, PHONE_MESSAGES_LOG, s.join(sioul_core::phonemsgs::FOLDER).join(sioul_core::phonemsgs::LOG), Shape::Lines, &[]),
586        folder(sioul_core::phonemsgs::PART, PHONE_MESSAGES_SEEN, s.join(sioul_core::phonemsgs::FOLDER).join(sioul_core::phonemsgs::SEEN_LOG), Shape::Lines, &[]),
587        // texts: each phone's texts and outcomes, each computer's requests, sealed lines, in the data folder.
588        folder(sioul_core::texts::PART, TEXTS_LOG, d.join(sioul_core::texts::FOLDER).join(sioul_core::texts::LOG), Shape::Lines, &[]),
589        folder(sioul_core::texts::PART, TEXTS_SEND, d.join(sioul_core::texts::FOLDER).join(sioul_core::texts::SEND), Shape::Lines, &[]),
590        folder(sioul_core::texts::PART, TEXTS_OUTCOME, d.join(sioul_core::texts::FOLDER).join(sioul_core::texts::OUTCOME), Shape::Lines, &[]),
591    ];
592    // From the notes folder each device keeps where it likes: projects,
593    // budgets, the bank's movements and contracts; the papers' wallet and its
594    // files; the notes themselves, each file sealed apart.
595    if let Some(notes) = config.notes_root_path() {
596        stores.push(file("projects", "notes/sioul-cases.toml", sioul_core::projects::file_in(&notes), Shape::Toml(&PROJECTS_RULES)));
597        stores.push(file("projects", "notes/sioul-budgets.toml", notes.join(sioul_core::budget::LEDGER), Shape::Toml(&LEDGER_RULES)));
598        stores.push(file("projects", "notes/sioul-bank.toml", notes.join(sioul_core::bank::MANIFEST), Shape::Toml(&BANK_RULES)));
599        stores.push(file("projects", "notes/sioul-contracts.toml", notes.join(sioul_core::contracts::MANIFEST), Shape::Toml(&CONTRACTS_RULES)));
600        stores.push(file("papers", "notes/sioul-papers.toml", notes.join(sioul_core::papers::MANIFEST), Shape::Toml(&PAPERS_RULES)));
601        stores.push(folder("papers", "files/papers/", notes.join(sioul_core::papers::FOLDER), Shape::Files, &[]));
602        stores.push(folder("notes", "files/notes/", notes, Shape::Files, NOT_NOTES));
603    }
604    stores.retain(|store| shares(store.part));
605    stores
606}
607
608/// The stores as a format divides their files (`Rules::at`): format 2's
609/// rules from 2 on, where they differ.
610pub fn shaped(stores: &[Store], format: u32) -> Vec<Store> {
611    stores
612        .iter()
613        .map(|store| Store {
614            shape: match store.shape {
615                Shape::Toml(rules) => Shape::Toml(rules.at(format)),
616                other => other,
617            },
618            ..store.clone()
619        })
620        .collect()
621}
622
623/// What notes or papers would send from here, before their part is switched
624/// on: how many files, how many bytes, and how many stay (too big). Listed as
625/// an exchange lists them, nothing read through.
626pub fn estimate(stores: &[Store]) -> (usize, u64, usize) {
627    let (mut found, mut files, known) = (Found::default(), Vec::new(), BTreeMap::new());
628    for store in stores.iter().filter(|s| matches!(s.shape, Shape::Files) && s.path.is_dir()) {
629        Listing { store: &store.name, root: &store.path, skip: store.skip, known: &known, confirmed: false, files: &mut files, found: &mut found }.list(&store.path);
630    }
631    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) })
632}
633
634/// The part a file of the records belongs to, whether this device shares it
635/// or not; none for a store this version does not know (its changes wait for
636/// a version that knows it).
637fn known_part(file: &str) -> Option<&'static str> {
638    static KNOWN: std::sync::OnceLock<Vec<(String, bool, &'static str)>> = std::sync::OnceLock::new();
639    let known = KNOWN.get_or_init(|| {
640        let roots = Roots { config: PathBuf::from("config"), data: PathBuf::from("data"), state: PathBuf::from("state") };
641        let config = Config { notes_root: Some("notes".into()), ..Config::default() };
642        stores_of(&config, &roots, &|_| true).into_iter().map(|store| (store.name, store.folder, store.part)).collect()
643    });
644    #[cfg(test)]
645    if tests::OLDER.with(|older| older.borrow().iter().any(|store| file.starts_with(store.as_str()))) {
646        return None;
647    }
648    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)
649}
650
651/// A file's name in the records, as shown: without the folder Sioul keeps it in
652/// ("config/config.toml" → "config.toml", "files/notes/admin/lease.md" → "admin/lease.md").
653pub fn shown(file: &str) -> &str {
654    ["files/notes/", "files/", "config/", "data/", "state/", "notes/"].iter().find_map(|root| file.strip_prefix(root)).unwrap_or(file)
655}
656
657// ---------------------------------------------------------------- entries
658
659/// What the files hold now, entry by entry.
660#[derive(Debug, Default)]
661struct Found {
662    /// Each entry's value, hashed: `<store><file>#<entry>` → its hash.
663    hashes: BTreeMap<String, String>,
664    /// The values of the entries in files read this time; the others' files did not change.
665    values: BTreeMap<String, String>,
666    /// When each file last changed, in milliseconds: dates the changes found in it.
667    changed: BTreeMap<String, i64>,
668    /// Files that could not be read (a TOML file half written, a folder not
669    /// there): their entries are neither taken out nor written to.
670    unknown: BTreeSet<String>,
671    /// Each file's size, time and entries: a file unchanged is not read again.
672    files: BTreeMap<String, Stat>,
673    /// Files sealed apart left here, too big to travel (`blobs::LARGEST`): said.
674    too_big: Vec<String>,
675    /// Folders of files sealed apart that list empty while they held files at
676    /// the last look, with how many they held: unknown, said.
677    emptied: Vec<(String, usize)>,
678    /// What else the look has to say (names that are not text).
679    said: Vec<String>,
680}
681
682/// A file as last read: its size, when it changed (nanoseconds), its entries'
683/// hashes, and when it was read (milliseconds, this computer's clock).
684#[derive(Debug, Clone, Default, Serialize, Deserialize)]
685struct Stat {
686    size: u64,
687    modified: u64,
688    entries: Vec<(String, String)>,
689    /// A file read within `settling_ms` of its change may have changed again
690    /// since at the same size and time (a coarse clock: a FAT card's 2 s): read again.
691    #[serde(default, skip_serializing_if = "is_zero")]
692    seen: i64,
693}
694
695fn is_zero(n: &i64) -> bool {
696    *n == 0
697}
698
699impl Found {
700    fn is_unknown(&self, key: &str) -> bool {
701        let file = file_of(key);
702        self.unknown.iter().any(|u| file == u || (u.ends_with('/') && file.starts_with(u.as_str())))
703    }
704}
705
706/// Folders whose files travel whole (`Shape::Whole`, `Shape::Files`): their
707/// keys are the file's name and an empty entry, `#`, and a name may hold `#`
708/// itself ("Facture #123.pdf").
709const WHOLE_FOLDERS: &[&str] = &["data/drafts/", "data/invoices/", "data/pgp/others/", "data/calendars/local/", "data/contacts/local/", "state/shield/", "state/dav/local/", FILES];
710
711/// The drafts' folder. A draft taken out on one device (sent, or thrown away)
712/// while another changed it, each unaware of the other, stays taken out on
713/// every device, whichever came later; the change made meanwhile is kept
714/// among that device's earlier versions (review of 5 October 2026, F22: a
715/// draft sent could come back, and be sent twice). Each record of a draft
716/// names the version it replaced (`Change::b`), and each device keeps every
717/// version of a draft it met (`Memory::drafts`): a change of a change of the
718/// version sent loses too (`sent_meanwhile`). An older Sioul's records name
719/// none: the later word wins, as before.
720const DRAFTS: &str = "data/drafts/";
721
722/// How a record of a draft is decided (`DRAFTS`): Some(true) it wins, Some(false)
723/// it loses, whichever came later; None, the later word wins, as ever. `here`:
724/// the draft here, or what came for it earlier in this reading (taken out, its
725/// version, the version it replaced); `met`: every version of it met here.
726fn sent_meanwhile(met: Option<&BTreeSet<String>>, here: Option<(bool, &str, &str)>, removal: bool, base: &str) -> Option<bool> {
727    let (gone, version, replaced) = here?;
728    let known = |v: &str| !v.is_empty() && (v == version || v == replaced || met.is_some_and(|met| met.contains(v)));
729    match (removal, gone) {
730        // A change of a version that was taken out: it was sent.
731        (false, true) if known(base) => Some(false),
732        // Taken out where this draft came from: sent, whatever was changed here since.
733        (true, false) if known(base) => Some(true),
734        _ => None,
735    }
736}
737
738/// `<store><file>`: the part of a key before its entry.
739fn file_of(key: &str) -> &str {
740    if WHOLE_FOLDERS.iter().any(|folder| key.starts_with(folder))
741        && let Some(file) = key.strip_suffix('#')
742    {
743        return file;
744    }
745    key.split_once('#').map_or(key, |(file, _)| file)
746}
747
748/// The entry a key names in its file.
749fn entry_of(key: &str) -> &str {
750    key.get(file_of(key).len() + 1..).unwrap_or("")
751}
752
753pub(crate) fn modified_ns(meta: &std::fs::Metadata) -> u64 {
754    meta.modified().ok().and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map_or(0, |d| d.as_nanos() as u64)
755}
756
757/// How a look goes: which stores it reads, whether it must hurry, which
758/// folders' emptying you confirmed, this computer's clock (milliseconds).
759struct Look<'a> {
760    /// Stores read this time; the others are left as last read.
761    reads: &'a dyn Fn(&Store) -> bool,
762    /// Files sealed apart are not read through while a hurried exchange waits.
763    hurry: &'a dyn Fn() -> bool,
764    confirmed: &'a BTreeSet<String>,
765    clock: i64,
766}
767
768/// This computer's clock, in milliseconds.
769fn clock_ms() -> i64 {
770    std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_millis() as i64)
771}
772
773#[cfg(test)]
774thread_local! {
775    /// How long a file sealed apart settles before it is read, in this test's thread: not at all, unless a test says.
776    static SETTLE: std::cell::Cell<i64> = const { std::cell::Cell::new(0) };
777}
778
779/// A file sealed apart changed this recently may still be being written (an
780/// editor writing it in place): read at the next look.
781fn settling_ms() -> i64 {
782    #[cfg(test)]
783    return SETTLE.with(std::cell::Cell::get);
784    #[cfg(not(test))]
785    2_000
786}
787
788/// What the stores hold; a file whose size and time did not change since
789/// `known` is taken from there, not read. What could not be read is unknown,
790/// its last look kept: nothing of it is taken out elsewhere.
791fn gather(stores: &[Store], known: &BTreeMap<String, Stat>, look: &Look) -> Found {
792    let mut found = Found::default();
793    for store in stores {
794        if !(look.reads)(store) {
795            // Not read this time (notes and papers in a hurried exchange).
796            found.unknown.insert(store.name.clone());
797            continue;
798        }
799        if store.folder {
800            if !store.path.is_dir() {
801                // Notes or papers that held files and are gone (a disk not
802                // mounted, a folder moved): unknown, said. A folder that never
803                // held any is a new one, filled.
804                let held = known.range(store.name.clone()..).take_while(|(file, _)| file.starts_with(&store.name)).count();
805                if matches!(store.shape, Shape::Files) && held == 0 {
806                    continue;
807                }
808                if matches!(store.shape, Shape::Files) && !look.confirmed.contains(&store.name) {
809                    found.emptied.push((store.name.clone(), held));
810                }
811                found.unknown.insert(store.name.clone());
812                continue;
813            }
814            let mut files = Vec::new();
815            if matches!(store.shape, Shape::Files) {
816                let mut listing = Listing { store: &store.name, root: &store.path, skip: store.skip, known, confirmed: look.confirmed.contains(&store.name), files: &mut files, found: &mut found };
817                listing.list(&store.path);
818            } else {
819                list_files(&store.path, &store.path, store.skip, &mut files);
820            }
821            for (relative, path) in files {
822                read_file(store, &format!("{}{relative}", store.name), &path, known, look, &mut found);
823            }
824        } else if store.path.is_file() {
825            read_file(store, &store.name, &store.path, known, look, &mut found);
826        } else if !matches!(store.shape, Shape::Whole | Shape::Files) {
827            // A settings file not there is not a file emptied; a file whole or
828            // sealed apart not there is none yet (the spam filter's table before a training).
829            found.unknown.insert(store.name.clone());
830        }
831    }
832    // What is unknown keeps its last look: next time, a folder that held files is still known to have held them.
833    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();
834    found.files.extend(carried);
835    // The logs of lines kept (`LINES`) that are no longer there, forgotten.
836    if let Ok(mut all) = LINES.lock() {
837        all.retain(|path, _| path.exists() || !stores.iter().any(|s| matches!(s.shape, Shape::Lines) && path.starts_with(&s.path)));
838    }
839    found
840}
841
842/// Every file below a folder, by its path from it with `/`; temporary and hidden files left out.
843fn list_files(root: &Path, dir: &Path, skip: &[&str], out: &mut Vec<(String, PathBuf)>) {
844    let Ok(entries) = std::fs::read_dir(dir) else { return };
845    for entry in entries.filter_map(Result::ok) {
846        let name = entry.file_name().to_string_lossy().to_string();
847        if name.starts_with('.') || name.ends_with(".new") || name.ends_with(".tmp") || name.ends_with('~') || skip.contains(&name.as_str()) {
848            continue;
849        }
850        let path = entry.path();
851        if path.is_dir() {
852            list_files(root, &path, skip, out);
853        } else if let Ok(relative) = path.strip_prefix(root) {
854            out.push((relative.components().map(|c| c.as_os_str().to_string_lossy()).collect::<Vec<_>>().join("/"), path));
855        }
856    }
857}
858
859/// A listing of a folder of files sealed apart (notes, papers).
860struct Listing<'a> {
861    store: &'a str,
862    root: &'a Path,
863    skip: &'a [&'a str],
864    known: &'a BTreeMap<String, Stat>,
865    /// You said to take out what went from it: a folder listing empty is emptied.
866    confirmed: bool,
867    files: &'a mut Vec<(String, PathBuf)>,
868    found: &'a mut Found,
869}
870
871impl Listing<'_> {
872    /// Every file below `dir`, by its path from the root with `/`; how many.
873    /// Left out: what sync apps, editors and systems make (`made_by_tools`),
874    /// `skip` at the top (whatever its case), tools' own folders, a folder
875    /// shared through (it holds a seal), links, names that are not text (said).
876    /// A folder that cannot be read whole (one name that errs is enough), or
877    /// that lists empty while it held files at the last look (a disk not
878    /// mounted, access withdrawn), is unknown: nothing in it is taken out
879    /// elsewhere, nothing is written into it.
880    fn list(&mut self, dir: &Path) -> usize {
881        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();
882        let within = if relative.is_empty() { String::new() } else { format!("{relative}/") };
883        let prefix = format!("{}{within}", self.store);
884        let mut whole = true;
885        let mut count = 0;
886        match std::fs::read_dir(dir) {
887            Err(_) => whole = false,
888            Ok(entries) => {
889                for entry in entries {
890                    let Ok(entry) = entry else {
891                        whole = false;
892                        continue;
893                    };
894                    let Ok(kind) = entry.file_type() else {
895                        whole = false;
896                        continue;
897                    };
898                    let name = entry.file_name();
899                    let Some(name) = name.to_str() else {
900                        self.found.said.push(format!("share-not-text:{prefix}{}", name.to_string_lossy()));
901                        continue;
902                    };
903                    let path = entry.path();
904                    if is_leftover(name) {
905                        remove_if_old(&path);
906                        continue;
907                    }
908                    if made_by_tools(name) || kind.is_symlink() || (dir == self.root && self.skip.iter().any(|skip| skip.eq_ignore_ascii_case(name))) {
909                        continue;
910                    }
911                    if kind.is_dir() {
912                        if !matches!(name, "node_modules" | "target") && !path.join("seal.toml").is_file() {
913                            count += self.list(&path);
914                        }
915                    } else if kind.is_file() {
916                        self.files.push((format!("{within}{name}"), path));
917                        count += 1;
918                    }
919                }
920            }
921        }
922        let held = self.known.range(prefix.clone()..).take_while(|(file, _)| file.starts_with(&prefix)).count();
923        if !whole || (count == 0 && held > 0 && !self.confirmed) {
924            if whole {
925                self.found.emptied.push((prefix.clone(), held));
926            }
927            self.found.unknown.insert(prefix);
928        }
929        count
930    }
931}
932
933/// What a crash left half written by Sioul: its hidden names ending so.
934fn is_leftover(name: &str) -> bool {
935    name.starts_with('.') && [TEMPORARY, ".shared.tmp", ".conflict.tmp"].iter().any(|end| name.ends_with(end))
936}
937
938/// A file left half written, removed once an hour old: never one still being written.
939fn remove_if_old(path: &Path) {
940    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) {
941        let _ = std::fs::remove_file(path);
942    }
943}
944
945/// What a crash left half written in a folder (the sealed files, the history), removed after an hour.
946pub(crate) fn clean_leftovers(dir: &Path) {
947    for entry in std::fs::read_dir(dir).into_iter().flatten().filter_map(Result::ok) {
948        if entry.file_name().to_str().is_some_and(is_leftover) {
949            remove_if_old(&entry.path());
950        }
951    }
952}
953
954/// Names that sync apps, editors and systems make beside your files: hidden
955/// ones, temporary and backup copies, conflicted copies, locks. Sioul's own
956/// copies of a file two devices changed ("lease (conflict …).md") are none of them.
957fn made_by_tools(name: &str) -> bool {
958    let lower = name.to_lowercase();
959    name.starts_with('.')
960        || name.starts_with('~')
961        || name.ends_with('~')
962        || (name.starts_with('#') && name.ends_with('#'))
963        || [".tmp", ".temp", ".part", ".partial", ".crdownload", ".download", ".swp", ".swx", ".new"].iter().any(|end| lower.ends_with(end))
964        || ["conflicted copy", ".sync-conflict-", "_conflict-", "sfconflict"].iter().any(|word| lower.contains(word))
965        || matches!(lower.as_str(), "thumbs.db" | "desktop.ini" | "4913")
966}
967
968/// A file sealed apart, as its record says it: its content's hash, its size, when it changed (milliseconds).
969#[derive(Debug, Serialize, Deserialize)]
970struct Reference {
971    h: String,
972    s: u64,
973    t: i64,
974}
975
976fn reference(h: &str, size: u64, modified_ns: u64) -> String {
977    serde_json::to_string(&Reference { h: h.to_string(), s: size, t: (modified_ns / 1_000_000) as i64 }).unwrap_or_default()
978}
979
980/// What an entry's value is known by: a file sealed apart by its content's hash, anything else by its value's.
981fn value_hash(key: &str, value: &str) -> String {
982    if key.starts_with(FILES) { serde_json::from_str::<Reference>(value).map(|r| r.h).unwrap_or_default() } else { hash(value) }
983}
984
985fn read_file(store: &Store, file: &str, path: &Path, known: &BTreeMap<String, Stat>, look: &Look, found: &mut Found) {
986    let Ok(meta) = std::fs::metadata(path) else {
987        found.unknown.insert(file.to_string());
988        return;
989    };
990    let sealed_apart = matches!(store.shape, Shape::Files);
991    if meta.len() > if sealed_apart { crate::blobs::LARGEST } else { LARGEST } {
992        found.unknown.insert(file.to_string());
993        if sealed_apart {
994            found.too_big.push(file.to_string());
995        }
996        return;
997    }
998    let modified = modified_ns(&meta);
999    found.changed.insert(file.to_string(), (modified / 1_000_000) as i64);
1000    // As last read: the same size and time, read well after it changed. Not
1001    // a log of lines (calls, texts, labels): what it holds is not kept twice
1002    // in the memory (its lines are its entries' names), it is read again.
1003    let lines = matches!(store.shape, Shape::Lines);
1004    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)) {
1005        for (entry, h) in &stat.entries {
1006            found.hashes.insert(format!("{file}#{entry}"), h.clone());
1007            // A file sealed apart: its record made from what was kept, the file not read again.
1008            if sealed_apart {
1009                found.values.insert(format!("{file}#{entry}"), reference(h, stat.size, stat.modified));
1010            }
1011        }
1012        found.files.insert(file.to_string(), stat.clone());
1013        return;
1014    }
1015    if sealed_apart {
1016        // Changed a moment ago (maybe still being written), or a hurried
1017        // exchange waits: read at the next look. Read through for its hash,
1018        // never held whole; one that changed while read is read next time.
1019        let fresh = look.clock - ((modified / 1_000_000) as i64) < settling_ms();
1020        let read = (!fresh && !(look.hurry)()).then(|| crate::blobs::hash_file(path).ok()).flatten().filter(|(_, size)| *size == meta.len());
1021        let Some((h, _)) = read else {
1022            found.unknown.insert(file.to_string());
1023            return;
1024        };
1025        let key = format!("{file}#");
1026        found.hashes.insert(key.clone(), h.clone());
1027        found.values.insert(key, reference(&h, meta.len(), modified));
1028        found.files.insert(file.to_string(), Stat { size: meta.len(), modified, entries: vec![(String::new(), h)], seen: look.clock });
1029        return;
1030    }
1031    if lines {
1032        // A log of lines: its lines as this process last read them, while the
1033        // file is that one (`LINES`), else read now and kept.
1034        let Some(lines) = lines_of(path, &meta, look.clock) else {
1035            found.unknown.insert(file.to_string());
1036            return;
1037        };
1038        let h = hash("");
1039        for line in lines.iter() {
1040            let key = format!("{file}#{line}");
1041            found.hashes.insert(key.clone(), h.clone());
1042            found.values.insert(key, String::new());
1043        }
1044        // When it was read is not kept (the memory would change at each).
1045        found.files.insert(file.to_string(), Stat { size: meta.len(), modified, entries: Vec::new(), seen: 0 });
1046        return;
1047    }
1048    let Some(entries) = std::fs::read(path).ok().and_then(|bytes| entries_of(store, &bytes)) else {
1049        found.unknown.insert(file.to_string());
1050        return;
1051    };
1052    let mut stat = Stat { size: meta.len(), modified, entries: Vec::with_capacity(entries.len()), seen: look.clock };
1053    for (entry, value) in entries {
1054        let key = format!("{file}#{entry}");
1055        let h = hash(&value);
1056        stat.entries.push((entry, h.clone()));
1057        found.hashes.insert(key.clone(), h);
1058        found.values.insert(key, value);
1059    }
1060    found.files.insert(file.to_string(), stat);
1061}
1062
1063/// Each log of lines (`Shape::Lines`) as this process last read it, by its
1064/// path: the file's stamp then, when it was read (milliseconds), its lines.
1065/// Read again only once the file changed, or when it was read within
1066/// `settling_ms` of its change (a coarse clock: it may have changed again at
1067/// the same size and time). The texts' log alone is megabytes of sealed lines,
1068/// once read at every exchange of a phone's background step.
1069type Lines = BTreeMap<PathBuf, (FileStamp, i64, std::sync::Arc<Vec<String>>)>;
1070static LINES: std::sync::Mutex<Lines> = std::sync::Mutex::new(BTreeMap::new());
1071
1072/// A log's lines, as `entries_of` reads them: from `LINES` while the file is
1073/// the one read there, else read now (`clock`: this computer's, milliseconds) and kept.
1074fn lines_of(path: &Path, meta: &std::fs::Metadata, clock: i64) -> Option<std::sync::Arc<Vec<String>>> {
1075    let stamp = file_stamp(meta);
1076    let settled = |read: i64| (stamp.modified / 1_000_000) as i64 + settling_ms() < read;
1077    if let Some((_, _, lines)) = keeping().then(|| LINES.lock().ok().and_then(|all| all.get(path).filter(|(was, read, _)| *was == stamp && settled(*read)).cloned())).flatten() {
1078        return Some(lines);
1079    }
1080    let bytes = std::fs::read(path).ok()?;
1081    #[cfg(test)]
1082    LINES_READ.with(|n| n.set(n.get() + 1));
1083    // Not text (a list kept by another tool in another encoding): unknown, left as it is.
1084    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());
1085    // Kept, read whole only if it did not change meanwhile: else read again next time.
1086    if keeping()
1087        && bytes.len() as u64 == stamp.size
1088        && let Ok(mut all) = LINES.lock()
1089    {
1090        all.insert(path.to_path_buf(), (stamp, clock, std::sync::Arc::clone(&lines)));
1091    }
1092    Some(lines)
1093}
1094
1095/// A file's entries and their values; none when it cannot be read as its
1096/// shape says. Files sealed apart are read through, never whole (`read_file`).
1097/// A list or a settings file that is not text (saved by another tool in
1098/// another encoding, damaged) is none: never decoded lossily, which would
1099/// send its replaced characters and, written back, take its other lines out
1100/// everywhere (review of 5 October 2026, F25).
1101fn entries_of(store: &Store, bytes: &[u8]) -> Option<Vec<(String, String)>> {
1102    match store.shape {
1103        Shape::Whole => Some(vec![(String::new(), B64.encode(bytes))]),
1104        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()),
1105        Shape::Toml(rules) => toml_entries(std::str::from_utf8(bytes).ok()?, rules).ok(),
1106        Shape::Files => None,
1107    }
1108}
1109
1110/// A list's or a settings file's text, to write entries into: none there yet
1111/// is an empty one; one that cannot be read, or is not text, is an error, and
1112/// what came for it waits (never the incoming entries alone written in its place).
1113fn text_to_write(path: &Path) -> Result<String, String> {
1114    match std::fs::read(path) {
1115        Ok(bytes) => String::from_utf8(bytes).map_err(|_| format!("share-unreadable:{}", path.display())),
1116        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(String::new()),
1117        Err(e) => Err(format!("share-unreadable:{}: {e}", path.display())),
1118    }
1119}
1120
1121/// An entry's value, read from its file now.
1122fn value_of(stores: &[Store], key: &str) -> Option<String> {
1123    let (file, entry) = (file_of(key), entry_of(key));
1124    let (store, path) = locate(stores, file)?;
1125    if matches!(store.shape, Shape::Files) {
1126        let meta = std::fs::metadata(&path).ok()?;
1127        let (h, size) = crate::blobs::hash_file(&path).ok()?;
1128        return entry.is_empty().then(|| reference(&h, size, modified_ns(&meta)));
1129    }
1130    let bytes = std::fs::read(path).ok()?;
1131    entries_of(store, &bytes)?.into_iter().find(|(e, _)| e == entry).map(|(_, value)| value)
1132}
1133
1134/// A value as TOML text, `v = …`: one form for one value, whatever the file's layout.
1135fn leaf_text(value: &toml::Value) -> String {
1136    let mut table = toml::Table::new();
1137    table.insert("v".into(), value.clone());
1138    toml::to_string(&table).unwrap_or_default()
1139}
1140
1141fn leaf_value(text: &str) -> Result<toml::Value, String> {
1142    let mut table: toml::Table = text.parse().map_err(|e: toml::de::Error| e.to_string())?;
1143    table.remove("v").ok_or_else(|| "no value".to_string())
1144}
1145
1146fn matches_rule(path: &[String], rule: &str) -> bool {
1147    let parts: Vec<&str> = rule.split('.').collect();
1148    parts.len() == path.len() && parts.iter().zip(path).all(|(r, p)| *r == "*" || r == p)
1149}
1150
1151/// An element's name in its list: its naming fields, or the element itself.
1152fn identity(element: &toml::Value, by: &[&str]) -> String {
1153    if by.is_empty() {
1154        return leaf_text(element);
1155    }
1156    by.iter()
1157        .map(|field| match element.get(field) {
1158            Some(toml::Value::String(s)) => s.clone(),
1159            Some(other) => other.to_string(),
1160            None => String::new(),
1161        })
1162        .collect::<Vec<_>>()
1163        .join("\u{1d}")
1164}
1165
1166fn strip_local(element: &mut toml::Value, local: &[&str]) {
1167    if let toml::Value::Table(table) = element {
1168        for field in local {
1169            table.remove(*field);
1170        }
1171    }
1172}
1173
1174/// The keyed list at `path` in a file: by its name at the top (or one of its
1175/// other names, `Keyed::also`), by its pattern within tables.
1176fn keyed_at<'r>(rules: &'r Rules, path: &[String]) -> Option<&'r Keyed> {
1177    rules.keyed.iter().find(|k| match path {
1178        [name] => k.list == name || k.also.contains(&name.as_str()),
1179        _ => k.list.contains('.') && matches_rule(path, k.list),
1180    })
1181}
1182
1183/// The keyed list a record's entry names (`<list>␟␞<element>`, `<table>␟…␟<list>␟␞<element>` within tables).
1184fn keyed_named<'r>(rules: &'r Rules, list: &[&str]) -> Option<&'r Keyed> {
1185    let path: Vec<String> = list.iter().map(|part| (*part).to_string()).collect();
1186    rules.keyed.iter().find(|k| match list {
1187        [name] => k.list == *name,
1188        _ => k.list.contains('.') && matches_rule(&path, k.list),
1189    })
1190}
1191
1192/// An element's own id, when it has one.
1193fn own_id(element: &toml::Value) -> Option<&str> {
1194    element.get("id").and_then(toml::Value::as_str).filter(|id| !id.is_empty())
1195}
1196
1197/// The id format 2 gives an element without one: from its list and `what`,
1198/// the stable key format 1 named it by (`identity`: a session's start, task
1199/// and project; a money line itself), the same on every device. Written into
1200/// the element once, when the device takes format 2 (`switch_format`); until
1201/// then, and for an element made without one since (by hand), its name.
1202/// Shaped as a UUID (version 8: made here, by name).
1203pub(crate) fn derived(list: &str, what: &str) -> String {
1204    use sha2::Digest;
1205    let mut hasher = sha2::Sha256::new();
1206    hasher.update(b"sioul-id\x1f");
1207    hasher.update(list.as_bytes());
1208    hasher.update(b"\x1f");
1209    hasher.update(what.as_bytes());
1210    let digest = hasher.finalize();
1211    let mut bytes = [0u8; 16];
1212    bytes.copy_from_slice(&digest[..16]);
1213    bytes[6] = (bytes[6] & 0x0f) | 0x80;
1214    bytes[8] = (bytes[8] & 0x3f) | 0x80;
1215    uuid::Uuid::from_bytes(bytes).to_string()
1216}
1217
1218/// The names of a keyed list's elements (their local fields taken out), in
1219/// their order. Format 1: as `by` says, or the element itself. Format 2
1220/// (`Keyed::ids`): each element's own `id`; one without (made by hand), or
1221/// whose id another element of the list holds too, by the id derived from
1222/// the key format 1 named it by (`derived`): a stable key, which changing
1223/// what the element holds does not change for a session (its start, task
1224/// and project), and the same on every device. Elements format 1 named
1225/// alike are one entry, as they were in format 1.
1226fn names(elements: &[toml::Value], keyed: &Keyed) -> Vec<String> {
1227    if !keyed.ids {
1228        return elements.iter().map(|e| identity(e, keyed.by)).collect();
1229    }
1230    let mut ids: BTreeMap<&str, usize> = BTreeMap::new();
1231    for id in elements.iter().filter_map(own_id) {
1232        *ids.entry(id).or_default() += 1;
1233    }
1234    elements
1235        .iter()
1236        .map(|element| match own_id(element) {
1237            Some(id) if ids.get(id) == Some(&1) => id.to_string(),
1238            _ => derived(keyed.list, &identity(element, keyed.by)),
1239        })
1240        .collect()
1241}
1242
1243/// A TOML file's entries: one per setting, one per element of a keyed list,
1244/// one per table kept whole; the settings that stay here left out.
1245fn toml_entries(text: &str, rules: &Rules) -> Result<Vec<(String, String)>, String> {
1246    let table: toml::Table = text.parse().map_err(|e: toml::de::Error| e.to_string())?;
1247    let mut out = Vec::new();
1248    for (name, value) in &table {
1249        flatten(vec![name.clone()], value, rules, &mut out);
1250    }
1251    Ok(out)
1252}
1253
1254/// The record's name of a keyed list at `path`: its own at the top, its path within tables.
1255fn list_name(keyed: &Keyed, path: &[String]) -> String {
1256    if path.len() == 1 { keyed.list.to_string() } else { path.join(&SEP.to_string()) }
1257}
1258
1259fn flatten(path: Vec<String>, value: &toml::Value, rules: &Rules, out: &mut Vec<(String, String)>) {
1260    if rules.local.iter().any(|rule| matches_rule(&path, rule)) {
1261        return;
1262    }
1263    if let toml::Value::Array(elements) = value
1264        && let Some(keyed) = keyed_at(rules, &path)
1265    {
1266        let list = list_name(keyed, &path);
1267        let elements: Vec<toml::Value> = elements
1268            .iter()
1269            .map(|element| {
1270                let mut element = element.clone();
1271                strip_local(&mut element, keyed.local);
1272                element
1273            })
1274            .collect();
1275        for (element, name) in elements.iter().zip(names(&elements, keyed)) {
1276            out.push((format!("{list}{SEP}{MARK}{name}"), leaf_text(element)));
1277        }
1278        return;
1279    }
1280    match value {
1281        toml::Value::Table(table) if !rules.whole.iter().any(|rule| matches_rule(&path, rule)) => {
1282            for (name, value) in table {
1283                let mut deeper = path.clone();
1284                deeper.push(name.clone());
1285                flatten(deeper, value, rules, out);
1286            }
1287        }
1288        _ => out.push((path.join(&SEP.to_string()), leaf_text(value))),
1289    }
1290}
1291
1292// ---------------------------------------------------------------- writing entries
1293
1294/// Writes entries into one file: each `(entry, value)`, `None` taking it out;
1295/// under the file's lock, as the window writes some of these files too
1296/// (the doses' record: `sioul_core::filelock`). Before the file changes, or
1297/// goes, `keep` keeps it as it was (`history`).
1298fn write_entries(store: &Store, path: &Path, changes: &[(&str, Option<&str>)], keep: impl FnOnce() -> Result<(), String>) -> Result<BTreeMap<String, String>, String> {
1299    sioul_core::filelock::with_lock(path, || {
1300        let new = rewritten(store, path, changes)?;
1301        // What the file holds once written, entry by entry, by hash: what the next look compares with.
1302        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();
1303        let old = std::fs::read(path).ok();
1304        if old == new {
1305            return Ok(written);
1306        }
1307        // A doses' record not here that would hold no entry (only removals
1308        // came): nothing written. Written empty, with its witness, it would
1309        // read as lost and be repaired again and again.
1310        if old.is_none() && of_the_doses(store) && written.is_empty() {
1311            return Ok(written);
1312        }
1313        if old.is_some() {
1314            keep()?;
1315        }
1316        match new {
1317            // The doses' records as the window writes them (`write_witnessed`):
1318            // their bytes on the disk before they take the name, and their
1319            // witnesses made, so that a power cut after a mark received here
1320            // leaves a record lost, said, never a sound empty one (review of
1321            // 10 October 2026, R6).
1322            Some(bytes) if of_the_doses(store) => sioul_core::health::write_witnessed(path, &bytes)?,
1323            Some(bytes) => write_atomically(path, &bytes)?,
1324            None => match std::fs::remove_file(path) {
1325                Err(e) if e.kind() != std::io::ErrorKind::NotFound => return Err(format!("{}: {e}", path.display())),
1326                _ => {}
1327            },
1328        }
1329        Ok(written)
1330    })
1331}
1332
1333/// The doses' two records: the marks (`health-state.toml`) and each dose's
1334/// record (`health-doses.toml`).
1335fn of_the_doses(store: &Store) -> bool {
1336    matches!(store.name.as_str(), "state/health-state.toml" | "state/health-doses.toml")
1337}
1338
1339/// What a file holds once entries are written into it; none when it goes.
1340fn rewritten(store: &Store, path: &Path, changes: &[(&str, Option<&str>)]) -> Result<Option<Vec<u8>>, String> {
1341    match store.shape {
1342        Shape::Whole => {
1343            let (_, value) = changes.last().ok_or("nothing")?;
1344            value.map(|value| B64.decode(value).map_err(|e| e.to_string())).transpose()
1345        }
1346        Shape::Files => Err(format!("{}: sealed apart", path.display())),
1347        Shape::Lines => {
1348            let text = text_to_write(path)?;
1349            let mut lines: Vec<String> = text.lines().map(str::to_string).collect();
1350            for (entry, value) in changes {
1351                let at = lines.iter().position(|l| l.trim() == *entry);
1352                match (value, at) {
1353                    (Some(_), None) => lines.push(entry.to_string()),
1354                    (None, Some(at)) => {
1355                        lines.remove(at);
1356                    }
1357                    _ => {}
1358                }
1359            }
1360            let mut text = lines.join("\n");
1361            if !text.is_empty() {
1362                text.push('\n');
1363            }
1364            Ok(Some(text.into_bytes()))
1365        }
1366        Shape::Toml(rules) => {
1367            let text = text_to_write(path)?;
1368            // A file here under another name than its record's (the projects'
1369            // file renamed): a list new to it takes its other name.
1370            let renamed = path.file_name() != Path::new(&store.name).file_name();
1371            Ok(Some(toml_write(&text, rules, changes, renamed)?.into_bytes()))
1372        }
1373    }
1374}
1375
1376/// A file written whole beside its place under a hidden name, yours alone
1377/// (`new_private`), then renamed over it: never half a file, and its folders
1378/// made yours alone (`private_dirs`).
1379pub fn write_atomically(path: &Path, bytes: &[u8]) -> Result<(), String> {
1380    write_then_rename(path, bytes, false)
1381}
1382
1383/// The same, the bytes on the disk before the name changes: a file whose loss
1384/// costs more than a minute's work (this computer's name and sharing, its memory).
1385pub(crate) fn write_synced(path: &Path, bytes: &[u8]) -> Result<(), String> {
1386    write_then_rename(path, bytes, true)
1387}
1388
1389fn write_then_rename(path: &Path, bytes: &[u8], synced: bool) -> Result<(), String> {
1390    let fail = |e: std::io::Error| format!("{}: {e}", path.display());
1391    if let Some(parent) = path.parent() {
1392        private_dirs(parent).map_err(fail)?;
1393    }
1394    // A name starting with a dot: sync apps leave it alone (eDrive skips them,
1395    // Nextcloud's client by default), so a half-written file never travels.
1396    // Yours alone (`new_private`): the file it replaces takes its mode too.
1397    let temporary = temporary(path);
1398    let written = (|| {
1399        let mut file = new_private(&temporary)?;
1400        file.write_all(bytes)?;
1401        if synced {
1402            file.sync_all()?;
1403        }
1404        std::fs::rename(&temporary, path)
1405    })();
1406    if written.is_err() {
1407        let _ = std::fs::remove_file(&temporary);
1408    }
1409    written.map_err(fail)
1410}
1411
1412/// A small file of the folder (a computer's notes, the seal, a claim), never
1413/// read past `SMALL_FILE`: one larger is no such file (whoever wrote it).
1414pub(crate) fn read_small(path: &Path) -> Option<String> {
1415    use std::io::Read;
1416    let mut text = String::new();
1417    std::fs::File::open(path).ok()?.take(SMALL_FILE + 1).read_to_string(&mut text).ok()?;
1418    (text.len() as u64 <= SMALL_FILE).then_some(text)
1419}
1420
1421/// Small files of the folder are never read past this.
1422pub(crate) const SMALL_FILE: u64 = 64 << 10;
1423
1424/// A hidden name beside `path` for a file being written, never another's:
1425/// two exchanges, two Sioul running, never write into one (".lease.md.4211-7.sioul.tmp").
1426pub(crate) fn temporary(path: &Path) -> PathBuf {
1427    static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
1428    let name = path.file_name().map_or_else(|| "file".into(), |n| n.to_string_lossy().to_string());
1429    let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1430    path.with_file_name(format!(".{name}.{}-{n}{TEMPORARY}", std::process::id()))
1431}
1432
1433/// How the names of files being written end: what is left of them after a
1434/// crash is cleaned after an hour (`clean_leftovers`).
1435pub(crate) const TEMPORARY: &str = ".sioul.tmp";
1436
1437// ---------------------------------------------------------------- yours alone
1438
1439/// What the sharing writes is yours alone on Unix, as the calls' logs are
1440/// (`sioul_core::calls::append_private`): its files 0600, the folders it
1441/// makes 0700, whatever the umask would let others read (the records, sealed
1442/// files, the files it brings in from your other devices, the copies it
1443/// keeps). A file there already with a wider mode is made yours alone the
1444/// next time the sharing writes it: written beside then renamed, it takes the
1445/// new file's mode; appended to or copied, its mode is set. Never by a sweep
1446/// of your folders. Where modes mean nothing (a phone's shared storage,
1447/// Windows), nothing is asked and nothing said. These folders made, with
1448/// their parents, each 0700; folders there already keep their mode.
1449pub fn private_dirs(dir: &Path) -> std::io::Result<()> {
1450    let mut builder = std::fs::DirBuilder::new();
1451    builder.recursive(true);
1452    #[cfg(unix)]
1453    std::os::unix::fs::DirBuilderExt::mode(&mut builder, 0o700);
1454    builder.create(dir)
1455}
1456
1457/// A new file to write, yours alone (0600 on Unix); an error when one is there (`create_new`).
1458pub(crate) fn new_private(path: &Path) -> std::io::Result<std::fs::File> {
1459    let mut options = std::fs::OpenOptions::new();
1460    options.write(true).create_new(true);
1461    #[cfg(unix)]
1462    std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600);
1463    options.open(path)
1464}
1465
1466/// A file the sharing copied (a copy takes its source's mode) or appends to,
1467/// made yours alone (0600) when others could read it. Never through a link.
1468pub(crate) fn make_private(path: &Path) {
1469    #[cfg(unix)]
1470    {
1471        use std::os::unix::fs::PermissionsExt;
1472        if let Ok(metadata) = std::fs::symlink_metadata(path)
1473            && metadata.is_file()
1474            && metadata.permissions().mode() & 0o077 != 0
1475        {
1476            let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
1477        }
1478    }
1479    #[cfg(not(unix))]
1480    let _ = path;
1481}
1482
1483/// `std::fs::copy`, the copy yours alone (`make_private`).
1484pub(crate) fn copy_private(from: &Path, to: &Path) -> std::io::Result<()> {
1485    std::fs::copy(from, to)?;
1486    make_private(to);
1487    Ok(())
1488}
1489
1490/// How far a rewritten file's size climbs before it starts again from the smallest.
1491const PAD_LIMIT: u64 = 4096;
1492
1493/// A file written again in place must change size. The sharing asks little of
1494/// the sync app that carries the folder (docs/database.md, "What the sync app
1495/// must do"): new files and files that grow, copied some day, in any order.
1496/// Some sync apps tell a change by its size alone: eDrive (Murena's, on /e/OS)
1497/// downloads a file changed elsewhere only when its size differs from the copy
1498/// it holds (its `FileDiffUtils.getActionForFileDiff`), and others compare size
1499/// and time. So each rewrite is padded one step past the size of the file there
1500/// now, up to `PAD_LIMIT`, then from the smallest again: a copy held elsewhere
1501/// matches the newest only when a whole turn of rewrites (several hundred)
1502/// went by unseen, and the next rewrite mends it. `size(pad)` is the file's
1503/// size with `pad` characters of padding.
1504pub(crate) fn pad_for(path: &Path, size: impl Fn(usize) -> u64) -> usize {
1505    let base = size(0);
1506    let Ok(before) = std::fs::metadata(path).map(|m| m.len()) else { return 0 };
1507    if before < base + PAD_LIMIT {
1508        // One step past it: the padding grows a character at a time from about the right length.
1509        let mut pad = usize::try_from(before.saturating_sub(base) * 3 / 4).unwrap_or(0);
1510        while size(pad) <= before {
1511            pad += 1;
1512        }
1513        if size(pad) <= base + PAD_LIMIT {
1514            return pad;
1515        }
1516    }
1517    // A whole turn: the smallest again, never the size there now.
1518    (0..).find(|&pad| size(pad) != before).unwrap_or(0)
1519}
1520
1521/// A TOML value as toml_edit holds it: a table stays a table, a list of tables a list of tables.
1522fn edit_item(text: &str) -> Result<toml_edit::Item, String> {
1523    let doc: toml_edit::DocumentMut = text.parse().map_err(|e: toml_edit::TomlError| e.to_string())?;
1524    let mut item = doc.as_table().get("v").cloned().ok_or("no value")?;
1525    match &mut item {
1526        toml_edit::Item::Value(value) => value.decor_mut().clear(),
1527        toml_edit::Item::Table(table) => table.decor_mut().clear(),
1528        _ => {}
1529    }
1530    Ok(item)
1531}
1532
1533/// Sets or takes out entries in a TOML file's text, keeping the rest as
1534/// written; `renamed`: the file has another name than its record's (`Keyed::also`).
1535fn toml_write(text: &str, rules: &Rules, changes: &[(&str, Option<&str>)], renamed: bool) -> Result<String, String> {
1536    let mut doc: toml_edit::DocumentMut = text.parse().map_err(|e: toml_edit::TomlError| e.to_string())?;
1537    // The elements of keyed lists, list by list, named as the file holds them before any of them changes.
1538    let mut lists: Vec<(Vec<&str>, Vec<(&str, Option<&str>)>)> = Vec::new();
1539    for (entry, value) in changes {
1540        let path: Vec<&str> = entry.split(SEP).collect();
1541        if let Some((element, list)) = path.split_last()
1542            && let Some(id) = element.strip_prefix(MARK)
1543            && !list.is_empty()
1544        {
1545            match lists.iter_mut().find(|(known, _)| known.as_slice() == list) {
1546                Some((_, elements)) => elements.push((id, *value)),
1547                None => lists.push((list.to_vec(), vec![(id, *value)])),
1548            }
1549        } else {
1550            let item = value.map(edit_item).transpose()?;
1551            set_path(doc.as_table_mut(), &path, item.as_ref())?;
1552        }
1553    }
1554    for (list, elements) in lists {
1555        let keyed = keyed_named(rules, &list).ok_or_else(|| format!("{}: not a list", list.join(".")))?;
1556        let Some((name, within)) = list.split_last() else { continue };
1557        let Some(table) = table_at(doc.as_table_mut(), within, elements.iter().any(|(_, value)| value.is_some()))? else { continue };
1558        write_elements(table, keyed, name, &elements, renamed && within.is_empty())?;
1559    }
1560    Ok(doc.to_string())
1561}
1562
1563/// The table at `path` below `table`: made when `make` (implicit, as a
1564/// file's own tables are), else none when it is not there. A table written
1565/// inline becomes a table of its own.
1566fn table_at<'t>(table: &'t mut toml_edit::Table, path: &[&str], make: bool) -> Result<Option<&'t mut toml_edit::Table>, String> {
1567    let Some((first, rest)) = path.split_first() else { return Ok(Some(table)) };
1568    if !make && !table.contains_key(first) {
1569        return Ok(None);
1570    }
1571    let item = table.entry(first).or_insert_with(|| {
1572        let mut new = toml_edit::Table::new();
1573        new.set_implicit(true);
1574        toml_edit::Item::Table(new)
1575    });
1576    if let Some(inline) = item.as_inline_table().cloned() {
1577        *item = toml_edit::Item::Table(inline.into_table());
1578    }
1579    let inner = item.as_table_mut().ok_or_else(|| format!("{first}: not a table"))?;
1580    table_at(inner, rest, make)
1581}
1582
1583/// The names of a list's elements as read (local fields out); none for one that does not read.
1584fn names_read(elements: &[Option<toml::Value>], keyed: &Keyed) -> Vec<Option<String>> {
1585    let read: Vec<toml::Value> = elements.iter().flatten().cloned().collect();
1586    let mut named = names(&read, keyed).into_iter();
1587    elements.iter().map(|element| element.as_ref().and_then(|_| named.next())).collect()
1588}
1589
1590fn set_path(table: &mut toml_edit::Table, path: &[&str], value: Option<&toml_edit::Item>) -> Result<(), String> {
1591    match path {
1592        [] => Ok(()),
1593        [last] => {
1594            match value {
1595                // A value replaced keeps its comments and spacing.
1596                Some(toml_edit::Item::Value(new)) if table.get(last).is_some_and(toml_edit::Item::is_value) => {
1597                    if let Some(old) = table.get_mut(last).and_then(toml_edit::Item::as_value_mut) {
1598                        let decor = old.decor().clone();
1599                        *old = new.clone();
1600                        *old.decor_mut() = decor;
1601                    }
1602                }
1603                Some(item) => {
1604                    table.insert(last, item.clone());
1605                }
1606                None => {
1607                    table.remove(last);
1608                }
1609            }
1610            Ok(())
1611        }
1612        [first, rest @ ..] => {
1613            if value.is_none() && !table.contains_key(first) {
1614                return Ok(());
1615            }
1616            let item = table.entry(first).or_insert_with(|| {
1617                let mut new = toml_edit::Table::new();
1618                new.set_implicit(true);
1619                toml_edit::Item::Table(new)
1620            });
1621            if let Some(inline) = item.as_inline_table().cloned() {
1622                *item = toml_edit::Item::Table(inline.into_table());
1623            }
1624            let inner = item.as_table_mut().ok_or_else(|| format!("{first}: not a table"))?;
1625            set_path(inner, rest, value)
1626        }
1627    }
1628}
1629
1630/// Where a store is read, as the memory keeps it (`Memory::places`): its
1631/// path, the projects' file under its first name whichever it has, so that
1632/// renaming it (`sioul_core::projects::rename_file`) is no move: a move
1633/// rebuilds the file from the records, and a change made since would go.
1634fn place_of(store: &Store) -> String {
1635    let renamed = !store.folder && store.path.file_name().is_some_and(|name| name == sioul_core::projects::FILE);
1636    let path = if renamed { store.path.with_file_name(sioul_core::projects::OLD_FILE) } else { store.path.clone() };
1637    path.display().to_string()
1638}
1639
1640/// A table read from an entry's text, placed in a file: written where it
1641/// stands, its own tables under it. toml_edit writes a file's tables in the
1642/// order of the positions they were read at; those read from the entry's
1643/// text would put them out of place (a project's routes under the project
1644/// after it). So its tables keep none, and it takes `at`, the position of
1645/// the one it replaces.
1646fn placed(mut table: toml_edit::Table, at: Option<isize>) -> toml_edit::Table {
1647    fn clear(table: &mut toml_edit::Table) {
1648        table.set_position(None);
1649        for (_, item) in table.iter_mut() {
1650            match item {
1651                toml_edit::Item::Table(inner) => clear(inner),
1652                toml_edit::Item::ArrayOfTables(list) => list.iter_mut().for_each(clear),
1653                _ => {}
1654            }
1655        }
1656    }
1657    clear(&mut table);
1658    table.set_position(at);
1659    table
1660}
1661
1662/// The elements named so of a keyed list in `table` (`changes`: each name
1663/// and its value, none to take it out), all named as the list holds them
1664/// before any of them changes: replaced (their local fields kept), added, or
1665/// taken out. `list`:
1666/// its name in the records; at the top, the list keeps the name it has in
1667/// the file (`Keyed::also`).
1668fn write_elements(table: &mut toml_edit::Table, keyed: &Keyed, list: &str, changes: &[(&str, Option<&str>)], renamed: bool) -> Result<(), String> {
1669    let normal = |text: String| -> Option<toml::Value> {
1670        let mut element = leaf_value(&text).ok()?;
1671        strip_local(&mut element, keyed.local);
1672        Some(element)
1673    };
1674    let news: Vec<(&str, Option<toml_edit::Item>)> = changes.iter().map(|(id, value)| Ok((*id, value.map(edit_item).transpose()?))).collect::<Result<_, String>>()?;
1675    let others: &[&str] = if keyed.list == list { keyed.also } else { &[] };
1676    let name = std::iter::once(list).chain(others.iter().copied()).find(|n| table.contains_key(n)).unwrap_or(match others.first() {
1677        Some(other) if renamed => other,
1678        _ => list,
1679    });
1680    if !table.contains_key(name) {
1681        match news.iter().find_map(|(_, new)| new.as_ref()) {
1682            Some(toml_edit::Item::Table(_)) => {
1683                table.insert(name, toml_edit::Item::ArrayOfTables(toml_edit::ArrayOfTables::new()));
1684            }
1685            Some(_) => {
1686                table.insert(name, toml_edit::value(toml_edit::Array::new()));
1687            }
1688            None => return Ok(()),
1689        }
1690    }
1691    let mut gone: Vec<usize> = Vec::new();
1692    match table.get_mut(name) {
1693        Some(toml_edit::Item::ArrayOfTables(elements)) => {
1694            let named = names_read(&elements.iter().map(|t| normal(format!("[v]\n{t}"))).collect::<Vec<_>>(), keyed);
1695            let mut added: Vec<toml_edit::Table> = Vec::new();
1696            for (id, new) in news {
1697                let at = named.iter().position(|n| n.as_deref() == Some(id));
1698                match (new, at) {
1699                    (Some(toml_edit::Item::Table(mut element)), Some(at)) => {
1700                        if let Some(old) = elements.get(at) {
1701                            for field in keyed.local {
1702                                if let Some(kept) = old.get(field) {
1703                                    element.insert(field, kept.clone());
1704                                }
1705                            }
1706                            // Its id never stripped by a value written without one (a
1707                            // version from before the switch put back, an older
1708                            // Sioul's): it names this element.
1709                            if keyed.ids
1710                                && !element.contains_key("id")
1711                                && let Some(id) = old.get("id")
1712                            {
1713                                element.insert("id", id.clone());
1714                            }
1715                        }
1716                        if let Some(place) = elements.get_mut(at) {
1717                            let position = place.position();
1718                            *place = placed(element, position);
1719                        }
1720                    }
1721                    (Some(toml_edit::Item::Table(element)), None) => added.push(element),
1722                    (None, Some(at)) => gone.push(at),
1723                    _ => {}
1724                }
1725            }
1726            for element in added {
1727                elements.push(placed(element, None));
1728            }
1729            gone.sort_unstable();
1730            for at in gone.into_iter().rev() {
1731                elements.remove(at);
1732            }
1733        }
1734        Some(toml_edit::Item::Value(toml_edit::Value::Array(elements))) => {
1735            let named = names_read(&elements.iter().map(|v| normal(format!("v = {v}"))).collect::<Vec<_>>(), keyed);
1736            let mut added: Vec<toml_edit::Value> = Vec::new();
1737            for (id, new) in news {
1738                let at = named.iter().position(|n| n.as_deref() == Some(id));
1739                match (new, at) {
1740                    (Some(toml_edit::Item::Value(element)), Some(at)) => {
1741                        elements.replace(at, element);
1742                    }
1743                    (Some(toml_edit::Item::Value(element)), None) => added.push(element),
1744                    (None, Some(at)) => gone.push(at),
1745                    _ => {}
1746                }
1747            }
1748            for element in added {
1749                elements.push(element);
1750            }
1751            gone.sort_unstable();
1752            for at in gone.into_iter().rev() {
1753                elements.remove(at);
1754            }
1755            elements.fmt();
1756        }
1757        _ => return Err(format!("{name}: not a list")),
1758    }
1759    Ok(())
1760}
1761
1762// ---------------------------------------------------------------- the seal
1763
1764/// The folder's seal: how the key is made from the passphrase, and a value to
1765/// check it with. The same on every computer; the passphrase never written.
1766#[derive(Debug, Serialize, Deserialize)]
1767struct SealFile {
1768    version: u32,
1769    salt: String,
1770    memory_kib: u32,
1771    passes: u32,
1772    check: String,
1773}
1774
1775const CHECK: &[u8] = b"sioul: the passphrase is right";
1776
1777/// Why sharing cannot start.
1778#[derive(Debug, PartialEq, Eq)]
1779pub enum Refused {
1780    /// Not the passphrase chosen on the first computer.
1781    WrongPassphrase,
1782    Other(String),
1783}
1784
1785fn derive(passphrase: &str, salt: &[u8], memory_kib: u32, passes: u32) -> Result<[u8; 32], String> {
1786    let params = argon2::Params::new(memory_kib, passes, 1, Some(32)).map_err(|e| e.to_string())?;
1787    let mut key = [0u8; 32];
1788    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())?;
1789    Ok(key)
1790}
1791
1792/// The key for a folder: made from the passphrase and the folder's seal, which
1793/// the first computer writes (64 MiB, 3 passes: half a second, once per computer).
1794pub fn key_for(folder: &Path, passphrase: &str) -> Result<[u8; 32], Refused> {
1795    key_with(folder, passphrase, 64 * 1024, 3)
1796}
1797
1798fn key_with(folder: &Path, passphrase: &str, memory_kib: u32, passes: u32) -> Result<[u8; 32], Refused> {
1799    let path = folder.join("seal.toml");
1800    if path.exists() {
1801        // Written by Sioul in a few lines: one larger is no seal of its.
1802        let text = read_small(&path).ok_or_else(|| Refused::Other(format!("{}: not a seal", path.display())))?;
1803        let seal: SealFile = toml::from_str(&text).map_err(|e| Refused::Other(format!("{}: {e}", path.display())))?;
1804        // The folder passes through others' servers: a seal asking far more than
1805        // Sioul writes (64 MiB, 3 passes) would only exhaust this computer.
1806        if seal.memory_kib > 1 << 20 || seal.passes > 16 {
1807            return Err(Refused::Other(format!("{}: {} KiB, {} passes", path.display(), seal.memory_kib, seal.passes)));
1808        }
1809        let salt = B64.decode(&seal.salt).map_err(|e| Refused::Other(e.to_string()))?;
1810        let key = derive(passphrase, &salt, seal.memory_kib, seal.passes).map_err(Refused::Other)?;
1811        return match open(&key, "check", &seal.check) {
1812            Some(plain) if plain == CHECK => Ok(key),
1813            _ => Err(Refused::WrongPassphrase),
1814        };
1815    }
1816    let mut salt = [0u8; 16];
1817    OsRng.fill_bytes(&mut salt);
1818    let key = derive(passphrase, &salt, memory_kib, passes).map_err(Refused::Other)?;
1819    let seal = SealFile { version: 1, salt: B64.encode(salt), memory_kib, passes, check: seal(&key, "check", CHECK) };
1820    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()))?);
1821    private_dirs(folder).map_err(|e| Refused::Other(format!("{}: {e}", folder.display())))?;
1822    write_atomically(&path, text.as_bytes()).map_err(Refused::Other)?;
1823    Ok(key)
1824}
1825
1826/// The sharing folder as each exchange finds it first (review of 5 October
1827/// 2026, F5 and F20). Gone (a disk not mounted, a sync app's folder moved):
1828/// an error, and nothing is made, read or written (`share-no-seal`), never a
1829/// new empty folder whose files would later meet the real ones as conflicted
1830/// copies. Else its seal: none (a seal that does not read, an empty
1831/// placeholder of one, is none), or whether it opens with this device's key,
1832/// and its salt (which sharing it is).
1833/// Gone, or not opening, where this device saw it open before
1834/// (`Memory::seal_seen`: a folder made again by a sync app, another device's
1835/// seal put in its place), nothing is written either, said (`share-no-seal`,
1836/// `share-sealed-otherwise`): never under a key the others lack; sharing set
1837/// up again with the passphrase the folder's seal holds mends it.
1838fn sealed_for(folder: &Path, key: &[u8; 32]) -> Result<Option<(bool, String)>, ()> {
1839    if !folder.is_dir() {
1840        return Err(());
1841    }
1842    let Some(seal) = read_small(&folder.join("seal.toml")).and_then(|text| toml::from_str::<SealFile>(&text).ok()) else { return Ok(None) };
1843    Ok(Some((open(key, "check", &seal.check).is_some_and(|plain| plain == CHECK), seal.salt)))
1844}
1845
1846pub(crate) fn seal(key: &[u8; 32], bound: &str, plain: &[u8]) -> String {
1847    let cipher = XChaCha20Poly1305::new(key.into());
1848    let nonce = XChaCha20Poly1305::generate_nonce(&mut OsRng);
1849    let sealed = cipher.encrypt(&nonce, Payload { msg: plain, aad: bound.as_bytes() }).unwrap_or_default();
1850    B64.encode([nonce.as_slice(), &sealed].concat())
1851}
1852
1853pub(crate) fn open(key: &[u8; 32], bound: &str, text: &str) -> Option<Vec<u8>> {
1854    let bytes = B64.decode(text).ok()?;
1855    if bytes.len() < 24 {
1856        return None;
1857    }
1858    let (nonce, sealed) = bytes.split_at(24);
1859    XChaCha20Poly1305::new(key.into()).decrypt(XNonce::from_slice(nonce), Payload { msg: sealed, aad: bound.as_bytes() }).ok()
1860}
1861
1862// ---------------------------------------------------------------- this computer
1863
1864/// This computer, as sharing knows it: its name, the machine it was made on
1865/// (a copied configuration on another machine gets a name of its own), the
1866/// folder it shares through.
1867#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1868pub struct Here {
1869    #[serde(default)]
1870    pub id: String,
1871    #[serde(default)]
1872    pub machine: String,
1873    #[serde(default, skip_serializing_if = "Option::is_none")]
1874    pub folder: Option<String>,
1875    /// The parts this device shares or not, as chosen here (`PARTS`): never
1876    /// shared, each device chooses its own.
1877    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1878    pub parts: BTreeMap<String, bool>,
1879    /// Whether a sync app carries this device's notes folder, as you said on
1880    /// a phone, where Sioul cannot read the sync apps' settings; none: guessed
1881    /// (the app's `notes_carried`). Never shared.
1882    #[serde(default, skip_serializing_if = "Option::is_none")]
1883    pub notes_carried: Option<bool>,
1884    /// The notes folder that answer was given for: it counts for that folder
1885    /// alone; the notes moved (into a folder a sync app carries, or out of
1886    /// one), it is asked again, guessed meanwhile (review of 10 October 2026,
1887    /// R12). None: an answer given before, taken for the notes folder of
1888    /// then (the app writes it in at its next start).
1889    #[serde(default, skip_serializing_if = "Option::is_none")]
1890    pub notes_carried_for: Option<String>,
1891}
1892
1893impl Here {
1894    /// Whether this device shares a part: as chosen here, else as before parts had switches.
1895    pub fn shares(&self, part: &str, config: &Config) -> bool {
1896        self.parts.get(part).copied().unwrap_or_else(|| shared_by_default(part, config))
1897    }
1898
1899    pub fn path(state: &Path) -> PathBuf {
1900        state.join("share").join("here.toml")
1901    }
1902
1903    /// This computer's name, made once.
1904    pub fn load(state: &Path) -> Here {
1905        let path = Here::path(state);
1906        let mut here: Here = std::fs::read_to_string(&path).ok().and_then(|t| toml::from_str(&t).ok()).unwrap_or_default();
1907        let machine = machine();
1908        if here.id.is_empty() || (!machine.is_empty() && here.machine != machine) {
1909            here.id = uuid::Uuid::new_v4().to_string();
1910            here.machine = machine;
1911            let _ = here.save(state);
1912        }
1913        here
1914    }
1915
1916    /// Its bytes on the disk before its name changes: lost, this computer would take another name.
1917    pub fn save(&self, state: &Path) -> Result<(), String> {
1918        write_synced(&Here::path(state), toml::to_string(self).map_err(|e| e.to_string())?.as_bytes())
1919    }
1920
1921    /// The folder shared through, `~` expanded.
1922    pub fn folder_path(&self) -> Option<PathBuf> {
1923        self.folder.as_deref().filter(|f| !f.is_empty()).map(sioul_core::config::expand_home)
1924    }
1925
1926    /// What you said of the notes folder `notes` (`notes_carried`), when you
1927    /// said it of that folder.
1928    pub fn notes_said(&self, notes: &Path) -> Option<bool> {
1929        self.notes_carried.filter(|_| self.notes_carried_for.as_deref().is_none_or(|said| Path::new(said) == notes))
1930    }
1931}
1932
1933/// A hash of the machine's own id (`/etc/machine-id`), else of its name.
1934fn machine() -> String {
1935    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();
1936    let id = id.trim();
1937    if id.is_empty() { String::new() } else { format!("{:016x}", fnv(id.as_bytes())) }
1938}
1939
1940fn fnv(bytes: &[u8]) -> u64 {
1941    bytes.iter().fold(0xcbf2_9ce4_8422_2325u64, |hash, b| (hash ^ u64::from(*b)).wrapping_mul(0x0100_0000_01b3))
1942}
1943
1944pub(crate) fn hash(value: &str) -> String {
1945    format!("{:016x}", fnv(value.as_bytes()))
1946}
1947
1948// ---------------------------------------------------------------- memory
1949
1950/// What this computer remembers between two exchanges (`memory.json`);
1951/// what it remembers of files sealed apart aside (`files.json`, `Sealed`): a
1952/// big notes folder makes that long, and what the doses and claims ask
1953/// (`heard`, `written`, `traffic`) reads the rest alone.
1954#[derive(Debug, Default, Serialize, Deserialize)]
1955struct Memory {
1956    #[serde(default)]
1957    computer: String,
1958    /// The last clock given or read.
1959    #[serde(default)]
1960    clock: u64,
1961    /// This computer's round (its file) and the records in it.
1962    #[serde(default)]
1963    round: u32,
1964    #[serde(default)]
1965    seq: u64,
1966    /// The size of this computer's file after its last writing: shorter, a sync
1967    /// app put back an older copy of it, and a new one starts (`exchange`).
1968    #[serde(default)]
1969    own_size: u64,
1970    /// The size of this computer's file when its round began: a new round once
1971    /// it grew `ROUND_SIZE` past it (a round opening on more than that does not
1972    /// start another one each minute).
1973    #[serde(default)]
1974    round_base: u64,
1975    /// The last round that opens on every entry this computer holds (a full
1976    /// round, `new_round`); the rounds after it continue it, `SEGMENT` each at
1977    /// most. 0: kept by an older Sioul, whose every round was full.
1978    #[serde(default)]
1979    full_round: u32,
1980    /// What this computer appended since its last full round (bytes).
1981    #[serde(default)]
1982    grown: u64,
1983    /// The number of the last line of the round before, when this round
1984    /// continues it (`SEGMENT`): said on its first line (`Turn::p`). 0 for a
1985    /// full round.
1986    #[serde(default, skip_serializing_if = "is_zero_u64")]
1987    continues: u64,
1988    /// A full round is owed: the last round after lost lines of this
1989    /// computer's (its file cut short) could not restate all it holds (a file
1990    /// not readable then), and a reader that had not read those lines waits
1991    /// for a full round; or another device asks for one (`Seen::full`).
1992    /// Written as soon as all reads again (`new_round`).
1993    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
1994    owed: bool,
1995    /// How long the full round owed waited for a connection not metered,
1996    /// exchanges running (milliseconds, as `Awaited::waited`), and the last
1997    /// exchange that counted it: past `OWED_METERED`, it goes all the same.
1998    #[serde(default, skip_serializing_if = "is_zero_i64")]
1999    owed_waited: i64,
2000    #[serde(default, skip_serializing_if = "is_zero_i64")]
2001    owed_last: i64,
2002    /// This memory enters a round only once the one before was read to its
2003    /// end (`Turn`). One kept before (10 October 2026) may have moved past
2004    /// the end of a round not read whole: every other computer's kept rounds
2005    /// are read again once, what was known already changing nothing.
2006    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
2007    turns: bool,
2008    /// The folder's seal was seen here, opening with this device's key: gone
2009    /// from it later, nothing is written there until it is back (`sealed_for`).
2010    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
2011    seal_seen: bool,
2012    /// The salt of that seal: which sharing this memory is of. Another one
2013    /// met (another folder set up with this memory kept): started anew.
2014    #[serde(default, skip_serializing_if = "String::is_empty")]
2015    seal_salt: String,
2016    /// When this device last found its own records in the folder ahead of
2017    /// its memory (milliseconds): again soon after, another device writes
2018    /// under its name (`share-twin`).
2019    #[serde(default, skip_serializing_if = "is_zero")]
2020    ahead_at: i64,
2021    /// Every version of each draft met here, by its key: made here, received,
2022    /// or passed over (`DRAFTS`, `sent_meanwhile`). Forgotten with the draft's entry.
2023    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2024    drafts: BTreeMap<String, BTreeSet<String>>,
2025    /// When this computer's sealed files were last looked for, and the
2026    /// history and leftovers last tidied (milliseconds).
2027    #[serde(default)]
2028    checked: i64,
2029    #[serde(default)]
2030    tidied: i64,
2031    /// When what a crash left half written among the folder's sealed files
2032    /// was last cleaned (milliseconds): once a day, since listing `blobs/`
2033    /// reads thousands of names (on a phone, through its storage's slow layer).
2034    #[serde(default, skip_serializing_if = "is_zero")]
2035    cleaned: i64,
2036    /// Others' records already read in: done once, when sharing starts.
2037    #[serde(default)]
2038    joined: bool,
2039    /// How far each other computer's records were read: its round and the bytes read in it.
2040    #[serde(default)]
2041    read: BTreeMap<String, (u32, u64)>,
2042    /// The last record read from each other computer: its round and number.
2043    /// Its claims say how far it wrote (`lease::Claim::wrote`): read that far,
2044    /// everything it said until then is known here (docs/health.md, "Knowing").
2045    #[serde(default)]
2046    read_n: BTreeMap<String, (u32, u64)>,
2047    /// The last line of each other computer met here, read or found damaged
2048    /// (its number then inferred: the one after the line met before): its
2049    /// round and number. A round continuing the one before is entered once
2050    /// that one was met to its last line (`Turn`), whatever was lost in it,
2051    /// which `read_n` keeps said for the doses. Missing (a memory kept before
2052    /// 10 October 2026): as `read_n`.
2053    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2054    met: BTreeMap<String, (u32, u64)>,
2055    /// Each other computer's round waiting here for the end of the one
2056    /// before (`Turn`), and how long it waited, exchanges running: passed
2057    /// over as lost past `AWAITED`. (`awaited`, which counted the clock's
2058    /// time, is left aside.)
2059    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2060    waiting_for: BTreeMap<String, Awaited>,
2061    /// When a line of another computer's could not be read here (Unix seconds): what it said is lost.
2062    #[serde(default)]
2063    broken: BTreeMap<String, i64>,
2064    /// Files whose entries are read again from every computer's records, this
2065    /// one's too, at the next exchange (`rebuild`): a record lost here comes back.
2066    #[serde(default)]
2067    rebuild: BTreeSet<String>,
2068    /// Others' changes not written yet (their file could not be read, or is
2069    /// one this version does not know): tried again at each exchange.
2070    #[serde(default)]
2071    pending: BTreeMap<String, (Option<String>, u64, String)>,
2072    /// Each entry's last change: its clock, the computer that made it, its value's hash ("" once taken out).
2073    #[serde(default)]
2074    entries: BTreeMap<String, Known>,
2075    /// Where each store was read (its path): a store new here, or moved (another
2076    /// notes folder), joins as a new computer would (`exchange`).
2077    #[serde(default)]
2078    places: BTreeMap<String, String>,
2079    /// Files found empty or gone after holding entries, since when (milliseconds):
2080    /// their entries count as taken out only once that has lasted (`EMPTIED_WAIT`).
2081    #[serde(default)]
2082    emptied: BTreeMap<String, i64>,
2083    /// The files as last read.
2084    #[serde(default)]
2085    files: BTreeMap<String, Stat>,
2086    /// When each part last sent a change, and last received one here (Unix seconds).
2087    #[serde(default)]
2088    traffic: BTreeMap<String, (i64, i64)>,
2089    /// The format this device writes (`FORMAT`): 0 (an older Sioul's memory)
2090    /// or 1 until every device in the sharing reads format 2 (`format_open`),
2091    /// then 2, once and for all (`switch_format`).
2092    #[serde(default)]
2093    format: u32,
2094    /// A record in format 2 met while this device still wrote format 1:
2095    /// another device writes it already; this one does from its next exchange.
2096    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
2097    switch: bool,
2098    /// Each format-1 entry format 2 renamed: what format 1 last said under it
2099    /// here, by a hash of its name (`Floor`, `floor_key`): read again, format
2100    /// 1 at or below it is passed over (`translate`).
2101    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2102    floors: BTreeMap<String, Floor>,
2103    /// Others' changes in a format this device does not write yet (format 2,
2104    /// before its switch) or does not know (a newer Sioul's): their value,
2105    /// clock, computer and format, written once it can.
2106    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2107    waiting: BTreeMap<String, (Option<String>, u64, String, u32)>,
2108    /// Parts met in a format newer than this Sioul knows (`Newer`). Nothing of
2109    /// them is sent from here, nor written here, until this Sioul is updated,
2110    /// or their writers no longer count (`release_newer`) (`share-newer`).
2111    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2112    newer: BTreeMap<String, Newer>,
2113    /// Parts no longer held for writers that no longer count: their records
2114    /// in that newer format up to this clock, read again, are passed over.
2115    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2116    released: BTreeMap<String, u64>,
2117    /// Changes found here while their part was held (`newer`): when they were
2118    /// found (a clock) and what they held (a hash; "" taken out). Once the part
2119    /// is no longer held they go out at that time, not later: a newer
2120    /// device's later change still wins over them.
2121    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2122    held: BTreeMap<String, (u64, String)>,
2123    /// When each other device was last heard of here, from its records read
2124    /// (milliseconds, its clock): what tells a device still there from one
2125    /// gone, with its sealed entry, never its plain notes (`present`).
2126    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2127    heard_at: BTreeMap<String, i64>,
2128    /// Devices you said are gone (`forget_device`): when they were last heard
2129    /// of then (milliseconds). They count again once heard of later.
2130    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2131    forgotten: BTreeMap<String, i64>,
2132    /// Each other device's latest sealed entry seen here (`note_entries`): an
2133    /// older copy put back in the folder never lowers what is known of it.
2134    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2135    seen: BTreeMap<String, crate::devices::Entry>,
2136    /// The devices known here by records read before (`read_n`) of which no
2137    /// clock is known at all (their rounds gone from the folder, read by a
2138    /// memory from before `heard_at` was kept, none of their changes the last
2139    /// word here), with when this device first found them so (milliseconds,
2140    /// its clock): what ages them (`present`), so that one gone for good
2141    /// stops counting 180 days later.
2142    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2143    unclocked: BTreeMap<String, i64>,
2144    /// What the whole lists of format 1 seen here held (lists format 2
2145    /// divides), by format 2's names: each list record read, at its clock, in
2146    /// whatever order (`note_whole_record`), and, while this device writes
2147    /// format 1, the file between exchanges (`note_whole_lists`); what a later
2148    /// list took out is passed over at or below that clock (`below_whole_floor`)
2149    /// and kept with the list's floor at the switch (`Floor::removed`).
2150    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2151    lists: BTreeMap<String, WholeList>,
2152    /// What it remembers of files sealed apart, in `files.json` beside.
2153    #[serde(skip)]
2154    sealed: Sealed,
2155    /// `memory.json` as `save` last wrote it, kept with the memory in this
2156    /// process (`Memory::keep`); never written itself.
2157    #[serde(skip)]
2158    saved: Vec<u8>,
2159}
2160
2161/// What this computer remembers of files sealed apart (notes, papers), beside
2162/// its memory (`files.json`). Their entries, their files as last read and the
2163/// changes waiting for them are kept here too, out of `memory.json`.
2164#[derive(Debug, Default, Serialize, Deserialize)]
2165struct Sealed {
2166    #[serde(default)]
2167    computer: String,
2168    #[serde(default)]
2169    entries: BTreeMap<String, Known>,
2170    #[serde(default)]
2171    files: BTreeMap<String, Stat>,
2172    #[serde(default)]
2173    pending: BTreeMap<String, (Option<String>, u64, String)>,
2174    /// For each change waiting, the content it replaces where it was made: its base.
2175    #[serde(default)]
2176    bases: BTreeMap<String, String>,
2177    /// For each change waiting, every content the others' records named for
2178    /// its file so far, versions and bases (`receive`).
2179    #[serde(default)]
2180    named: BTreeMap<String, BTreeSet<String>>,
2181    /// The sealed files this computer put in the folder (`blobs`): when a
2182    /// record last pointed to each (milliseconds), and the size it sealed.
2183    #[serde(default)]
2184    mine: BTreeMap<String, (i64, u64)>,
2185    /// Sealed files the others' records named lately, by name: when
2186    /// (milliseconds). None is taken out within 90 days of that.
2187    #[serde(default)]
2188    named_at: BTreeMap<String, i64>,
2189    /// Sealed files found damaged, as they were then (`blobs::fingerprint`):
2190    /// not opened again until another copy comes.
2191    #[serde(default)]
2192    damaged: BTreeMap<String, (u64, u64, u64)>,
2193    /// Changes waiting for their sealed file, since when (milliseconds): said after a day.
2194    #[serde(default)]
2195    missing: BTreeMap<String, i64>,
2196    /// Changes that could not be written here (a full disk, a name a folder
2197    /// takes here): their sealed file, when to try again, how many times so far.
2198    #[serde(default)]
2199    failing: BTreeMap<String, (String, i64, u32)>,
2200    /// Files gone here, since when (milliseconds): taken out elsewhere once
2201    /// gone a while (`EMPTIED_WAIT`).
2202    #[serde(default)]
2203    gone: BTreeMap<String, i64>,
2204    /// Folders whose files gone at once you said to take out everywhere (`confirm_gone`).
2205    #[serde(default)]
2206    confirmed: BTreeSet<String>,
2207    /// When this computer's sealed files were last looked for, and the
2208    /// history and leftovers last tidied (milliseconds): kept in `memory.json`
2209    /// now (`Memory::checked`, `tidied`), so that this file, large with many
2210    /// notes or texts, is written only when what it holds changes. Read once,
2211    /// from an older Sioul's.
2212    #[serde(default, skip_serializing)]
2213    checked: i64,
2214    #[serde(default, skip_serializing)]
2215    tidied: i64,
2216    /// The spam filter's tables this computer sealed in the folder, by name,
2217    /// oldest first: past the newest `KEPT_TABLES`, the older go from the
2218    /// folder at once rather than 90 days on (`prune_tables`). This device's
2219    /// own memory, never shared; empty in an older Sioul's, whose tables go
2220    /// as before.
2221    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2222    tables: Vec<String>,
2223}
2224
2225/// The spam filter's tables a computer keeps in the folder: the one in use
2226/// and the one before it, for a device that has not read the newest record
2227/// yet. Each training that does no worse seals a new one, about two
2228/// megabytes, once a week at most (docs/spam-filter.md); kept 90 days each,
2229/// they would be a dozen, and a phone whose sync app never deletes a file
2230/// (eDrive) would keep every one it brought.
2231const KEPT_TABLES: usize = 2;
2232
2233/// This computer's own spam filter's tables past the newest `KEPT_TABLES`
2234/// taken out of the folder now, unless a record of this computer's names
2235/// one again (`used`: a table put back): that one goes by the usual rule.
2236/// Never another device's file: only those this computer sealed (`mine`).
2237/// A device that reads only the newest record, as every Sioul does, never
2238/// asks for them; an earlier version of the table kept by reference on
2239/// another device can no longer be put back from the folder.
2240fn prune_tables(folder: &Path, sealed: &mut Sealed, used: &BTreeSet<String>) {
2241    sealed.tables.retain(|name| sealed.mine.contains_key(name));
2242    while sealed.tables.len() > KEPT_TABLES {
2243        let old = sealed.tables.remove(0);
2244        if used.contains(&old) {
2245            continue;
2246        }
2247        let _ = std::fs::remove_file(folder.join("blobs").join(&old));
2248        sealed.mine.remove(&old);
2249    }
2250}
2251
2252#[derive(Debug, Clone, Serialize, Deserialize)]
2253struct Known {
2254    c: u64,
2255    w: String,
2256    h: String,
2257    /// For a file sealed apart changed here: the content it replaced, said again in a new round.
2258    #[serde(default, skip_serializing_if = "String::is_empty")]
2259    b: String,
2260}
2261
2262impl Memory {
2263    /// `memory.json` alone: what the doses and claims ask.
2264    fn load(path: &Path, computer: &str) -> Memory {
2265        let memory: Memory = std::fs::read_to_string(path).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_default();
2266        if memory.computer == computer { memory } else { Memory { computer: computer.to_string(), round: 1, ..Memory::default() } }
2267    }
2268
2269    /// The whole memory, files sealed apart too (`files.json`), their entries
2270    /// and changes among the others while an exchange runs: as the last
2271    /// exchange in this process left it while both files are as it wrote
2272    /// them (`Kept`), else read from them.
2273    fn load_all(path: &Path, computer: &str) -> Memory {
2274        if let Some(kept) = Memory::kept(path, computer) {
2275            return kept;
2276        }
2277        let mut memory = Memory::load(path, computer);
2278        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) {
2279            Some(sealed) => {
2280                // An older Sioul's times, kept in this file then.
2281                memory.checked = memory.checked.max(sealed.checked);
2282                memory.tidied = memory.tidied.max(sealed.tidied);
2283                memory.sealed = sealed;
2284            }
2285            // Lost, or not readable: notes and papers, and the calls, join
2286            // again as a new device's would (their files read again from the
2287            // records first), rather than each going out as new.
2288            None => memory.places.retain(|store, _| !store.starts_with(FILES) && !calls_store(store)),
2289        }
2290        memory.entries.append(&mut memory.sealed.entries);
2291        memory.files.append(&mut memory.sealed.files);
2292        memory.pending.append(&mut memory.sealed.pending);
2293        memory
2294    }
2295
2296    /// Both files written, each when it changed only (a phone's storage
2297    /// wears), its bytes on the disk before its name changes. The calls'
2298    /// entries go beside the notes': a log of lines names each entry by its
2299    /// line, and `memory.json`, written at every exchange and read by the
2300    /// doses' knowing, stays small.
2301    fn save(&mut self, path: &Path) -> Result<(), String> {
2302        let apart = |key: &String| key.starts_with(FILES) || calls_store(key);
2303        let (sealed, kept): (BTreeMap<_, _>, BTreeMap<_, _>) = std::mem::take(&mut self.entries).into_iter().partition(|(key, _)| apart(key));
2304        (self.sealed.entries, self.entries) = (sealed, kept);
2305        let (sealed, kept): (BTreeMap<_, _>, BTreeMap<_, _>) = std::mem::take(&mut self.files).into_iter().partition(|(file, _)| apart(file));
2306        (self.sealed.files, self.files) = (sealed, kept);
2307        let (sealed, kept): (BTreeMap<_, _>, BTreeMap<_, _>) = std::mem::take(&mut self.pending).into_iter().partition(|(key, _)| apart(key));
2308        (self.sealed.pending, self.pending) = (sealed, kept);
2309        self.sealed.computer = self.computer.clone();
2310        let write = |path: &Path, text: String| {
2311            if std::fs::read(path).is_ok_and(|old| old == text.as_bytes()) {
2312                return Ok(());
2313            }
2314            write_synced(path, text.as_bytes())
2315        };
2316        write(&sealed_path(path), serde_json::to_string(&self.sealed).map_err(|e| e.to_string())?)?;
2317        let text = serde_json::to_string(self).map_err(|e| e.to_string())?;
2318        write(path, text.clone())?;
2319        self.saved = text.into_bytes();
2320        Ok(())
2321    }
2322
2323    /// Kept in this process once saved, for the next exchange or send to take
2324    /// rather than read both files again (`Kept`).
2325    fn keep(mut self, path: &Path) {
2326        let text = std::mem::take(&mut self.saved);
2327        if text.is_empty() {
2328            return;
2329        }
2330        let sealed = stamp_at(&sealed_path(path));
2331        if let Ok(mut mine) = MINE.lock() {
2332            mine.insert(path.to_path_buf(), (sealed, self.computer.clone(), self.sealed.mine.iter().map(|(name, (_, size))| (name.clone(), *size)).collect()));
2333        }
2334        if !keeping() {
2335            return;
2336        }
2337        self.entries.append(&mut self.sealed.entries);
2338        self.files.append(&mut self.sealed.files);
2339        self.pending.append(&mut self.sealed.pending);
2340        let kept = Kept { own: stamp_at(path), sealed, text, memory: self };
2341        if let Ok(mut all) = KEPT.lock() {
2342            all.insert(path.to_path_buf(), kept);
2343        }
2344    }
2345
2346    /// The memory kept in this process (`keep`), taken, when it is this
2347    /// computer's and both its files are still as it wrote them: `memory.json`
2348    /// the same bytes, `files.json` the same file (its inode, size and time).
2349    fn kept(path: &Path, computer: &str) -> Option<Memory> {
2350        if !keeping() {
2351            return None;
2352        }
2353        let kept = KEPT.lock().ok()?.remove(path)?;
2354        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);
2355        same.then_some(kept.memory)
2356    }
2357
2358    /// A new clock: after every clock given or read, and not before `physical` (milliseconds).
2359    fn tick(&mut self, physical: i64) -> u64 {
2360        self.clock = (self.clock + 1).max((physical.max(0) as u64) << 16);
2361        self.clock
2362    }
2363}
2364
2365/// Where what this computer remembers of files sealed apart is kept: `files.json`, beside its memory.
2366fn sealed_path(memory: &Path) -> PathBuf {
2367    memory.with_file_name("files.json")
2368}
2369
2370/// A file as it stands: its inode (on Unix; a file written anew and renamed
2371/// into place is another), its size and its time (nanoseconds).
2372#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
2373struct FileStamp {
2374    ino: u64,
2375    size: u64,
2376    modified: u64,
2377}
2378
2379fn file_stamp(meta: &std::fs::Metadata) -> FileStamp {
2380    #[cfg(unix)]
2381    let ino = std::os::unix::fs::MetadataExt::ino(meta);
2382    #[cfg(not(unix))]
2383    let ino = 0;
2384    FileStamp { ino, size: meta.len(), modified: modified_ns(meta) }
2385}
2386
2387fn stamp_at(path: &Path) -> Option<FileStamp> {
2388    std::fs::metadata(path).ok().map(|meta| file_stamp(&meta))
2389}
2390
2391/// The memory as the last exchange in this process saved it (`Memory::keep`),
2392/// with `memory.json` as written and both files' stamps: the next exchange,
2393/// or a send asking what this device sealed (`own_sealed`), takes it while
2394/// neither file changed, rather than reading them again. A phone's background
2395/// service exchanges every few minutes, and its `files.json` holds tens of
2396/// thousands of entries (the texts' lines): megabytes read at each.
2397struct Kept {
2398    memory: Memory,
2399    text: Vec<u8>,
2400    own: Option<FileStamp>,
2401    sealed: Option<FileStamp>,
2402}
2403
2404/// The memories kept in this process, by the path of their `memory.json`.
2405static KEPT: std::sync::Mutex<BTreeMap<PathBuf, Kept>> = std::sync::Mutex::new(BTreeMap::new());
2406
2407/// Whether this process keeps the memory and the logs of lines between its
2408/// exchanges (`set_keeping`); always in tests, which run the sharing through it.
2409static KEEPING: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(cfg!(test));
2410
2411/// Said once by a process that exchanges every few minutes for a long time
2412/// (a phone's background service): the memory and the logs of lines are kept
2413/// between its exchanges (`Kept`, `LINES`), rather than read again at each.
2414/// They weigh some tens of megabytes with many texts shared (about 30 MB for
2415/// 10 MB of texts): a window's process, which holds little between its
2416/// minutes, reads them again at each (docs/android.md, "In the background").
2417pub fn set_keeping(on: bool) {
2418    KEEPING.store(on, std::sync::atomic::Ordering::Relaxed);
2419    if !on {
2420        KEPT.lock().map(|mut all| all.clear()).ok();
2421        LINES.lock().map(|mut all| all.clear()).ok();
2422    }
2423}
2424
2425fn keeping() -> bool {
2426    KEEPING.load(std::sync::atomic::Ordering::Relaxed)
2427}
2428
2429/// What this device sealed in the folder (`Sealed::mine`, by name, the size
2430/// sealed), as `files.json` said it when last written or read in this process
2431/// (its stamp, and whose it is): what each send asks first (`own_sealed`), a few names.
2432type Mine = BTreeMap<PathBuf, (Option<FileStamp>, String, BTreeMap<String, u64>)>;
2433static MINE: std::sync::Mutex<Mine> = std::sync::Mutex::new(BTreeMap::new());
2434
2435/// One line of a computer's file: its number in the round, its clock, the sealed change.
2436#[derive(Debug, Serialize, Deserialize)]
2437struct Line {
2438    n: u64,
2439    c: u64,
2440    s: String,
2441}
2442
2443/// A change, sealed in a line: the entry and its new value, none once taken out.
2444#[derive(Debug, Serialize, Deserialize)]
2445struct Change {
2446    k: String,
2447    v: Option<String>,
2448    /// For a file sealed apart: the content it replaces where it was made, by
2449    /// its hash ("" for a file new there). Another device holding something
2450    /// else changed it too: both are kept.
2451    #[serde(default, skip_serializing_if = "String::is_empty")]
2452    b: String,
2453    /// The build that wrote it (`sioul_core::build::DESCRIBED`), on the first
2454    /// line of each batch this computer appends, a new round's first line
2455    /// among them: the lines after it, until the next that says one, are that
2456    /// build's. Sealed with the change: the server never learns it. An older
2457    /// Sioul passes over it.
2458    #[serde(default, skip_serializing_if = "String::is_empty")]
2459    build: String,
2460    /// The format its part is written in (`part_format`), when not 1: a
2461    /// device that does not know it keeps the change waiting and says so
2462    /// (`share-newer`); one that does not write it yet writes it from its
2463    /// next exchange (`Memory::switch`). Sealed with the change. An older
2464    /// Sioul passes over it, and its changes say none: format 1.
2465    #[serde(default, skip_serializing_if = "first_format")]
2466    f: u32,
2467}
2468
2469fn first_format(f: &u32) -> bool {
2470    *f <= 1
2471}
2472
2473/// What the first line of a round says of where it stands, sealed with its
2474/// change: a full round (`new_round`, a computer's first), which restates all
2475/// its writer holds, or one continuing the round before (`SEGMENT`), with the
2476/// number of that round's last line. A reader enters a continuing round only
2477/// once it has read the one before to that line: a sync app may bring a
2478/// round before the end of the one before, and that end, a dose marked taken
2479/// among it, would never be read (docs/database.md, "The log"). An older
2480/// Sioul's lines say neither, and are read as before; an older Sioul passes
2481/// over both fields.
2482#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2483struct Turn {
2484    /// The round before's last number, on the first line of a round continuing it.
2485    #[serde(default, skip_serializing_if = "is_zero_u64")]
2486    p: u64,
2487    /// On the first line of a full round.
2488    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
2489    full: bool,
2490}
2491
2492fn is_zero_i64(n: &i64) -> bool {
2493    *n == 0
2494}
2495
2496fn is_zero_u64(n: &u64) -> bool {
2497    *n == 0
2498}
2499
2500/// A round waiting for the end of the round before (`Memory::waiting_for`).
2501#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2502struct Awaited {
2503    /// The round waiting.
2504    round: u32,
2505    /// How long it waited, the exchanges' time counted (`AWAIT_STEP`), in milliseconds.
2506    waited: i64,
2507    /// The last exchange that counted it (milliseconds).
2508    last: i64,
2509}
2510
2511/// The turn of a round that does not know where the round before it ended
2512/// (`Turn::p`): one that could not restate all (`new_round`) after a round
2513/// of its own found without a line. No reader ever met that line: it waits
2514/// for the round before as for a lost one (`AWAITED`), then reads on with
2515/// the gap said, the doses in doubt until a full round, never as an older
2516/// Sioul's round, which would take all before it as read.
2517const UNKNOWN_TURN: u64 = u64::MAX;
2518
2519/// A sealed line's change and its round's turn (`Turn`), read in one pass.
2520#[derive(Deserialize)]
2521struct Opened {
2522    k: String,
2523    v: Option<String>,
2524    #[serde(default)]
2525    b: String,
2526    #[serde(default)]
2527    build: String,
2528    #[serde(default)]
2529    f: u32,
2530    #[serde(default)]
2531    p: u64,
2532    #[serde(default)]
2533    full: bool,
2534}
2535
2536impl Opened {
2537    fn parts(self) -> (Change, Turn) {
2538        (Change { k: self.k, v: self.v, b: self.b, build: self.build, f: self.f }, Turn { p: self.p, full: self.full })
2539    }
2540}
2541
2542/// A change as it is sealed in a line: the change, and on a round's first
2543/// line, its turn (`Turn`).
2544#[derive(Serialize)]
2545struct Sealing<'a> {
2546    #[serde(flatten)]
2547    change: &'a Change,
2548    #[serde(flatten)]
2549    turn: Turn,
2550}
2551
2552/// A computer's own notes in the folder: when it last exchanged, its round, how far it read the others.
2553#[derive(Debug, Default, Serialize, Deserialize)]
2554struct Seen {
2555    #[serde(default)]
2556    at: i64,
2557    #[serde(default)]
2558    round: u32,
2559    #[serde(default)]
2560    read: BTreeMap<String, u32>,
2561    /// The computers it asks for a full round, each with the latest round of
2562    /// theirs it met: some of their records were lost here (a damaged line,
2563    /// a round taken for lost), so what they wrote is not all known here,
2564    /// the doses in doubt, until a full round of theirs after that one. An
2565    /// older Sioul passes it over (review of 10 October 2026).
2566    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2567    full: BTreeMap<String, u32>,
2568    /// Its size changed at each writing (`pad_for`): read by nobody.
2569    #[serde(default, skip_serializing_if = "String::is_empty")]
2570    pad: String,
2571}
2572
2573fn bound(computer: &str, round: u32, n: u64, clock: u64) -> String {
2574    format!("{computer}:{round}:{n}:{clock}")
2575}
2576
2577fn round_file(folder: &Path, computer: &str, round: u32) -> PathBuf {
2578    folder.join(format!("{computer}-{round}.jsonl"))
2579}
2580
2581/// Every computer sharing through the folder: those that wrote records, and
2582/// those that only read so far (their notes); in the copy fetched from the
2583/// server too (`remote::overlay`).
2584fn computers(folder: &Path) -> BTreeSet<String> {
2585    let mut out: BTreeSet<String> = rounds_with(folder).into_keys().collect();
2586    for dir in std::iter::once(folder.to_path_buf()).chain(crate::remote::overlay(folder)) {
2587        for entry in std::fs::read_dir(&dir).into_iter().flatten().filter_map(Result::ok) {
2588            let name = entry.file_name().to_string_lossy().to_string();
2589            if let Some(id) = name.strip_suffix(".toml").filter(|id| id.len() == 36 && uuid::Uuid::parse_str(id).is_ok()) {
2590                out.insert(id.to_string());
2591            }
2592        }
2593    }
2594    out
2595}
2596
2597/// The computers' files in the folder and in the copy fetched from the
2598/// server (`remote::overlay`): computer → its rounds, those of either.
2599fn rounds_with(folder: &Path) -> BTreeMap<String, Vec<u32>> {
2600    let mut all = rounds(folder);
2601    if let Some(fetched) = crate::remote::overlay(folder) {
2602        for (computer, theirs) in rounds(&fetched) {
2603            let list = all.entry(computer).or_default();
2604            list.extend(theirs);
2605            list.sort_unstable();
2606            list.dedup();
2607        }
2608    }
2609    all
2610}
2611
2612/// Another computer's round, from the longer of its two copies: the folder's,
2613/// and the one fetched from the server (`remote::overlay`). Records only
2614/// grow: the longer holds all the shorter does, and where each line starts
2615/// is the same in both.
2616fn open_round(folder: &Path, computer: &str, round: u32) -> std::io::Result<std::fs::File> {
2617    let here = round_file(folder, computer, round);
2618    let Some(fetched) = crate::remote::overlay(folder).map(|f| round_file(&f, computer, round)) else { return std::fs::File::open(here) };
2619    let size = |path: &Path| std::fs::metadata(path).map(|m| m.len()).ok();
2620    match (size(&here), size(&fetched)) {
2621        (Some(a), Some(b)) if b > a => std::fs::File::open(fetched),
2622        (None, Some(_)) => std::fs::File::open(fetched),
2623        _ => std::fs::File::open(here),
2624    }
2625}
2626
2627/// The computers' files in the folder: computer → its rounds.
2628fn rounds(folder: &Path) -> BTreeMap<String, Vec<u32>> {
2629    let mut out: BTreeMap<String, Vec<u32>> = BTreeMap::new();
2630    for entry in std::fs::read_dir(folder).into_iter().flatten().filter_map(Result::ok) {
2631        let name = entry.file_name().to_string_lossy().to_string();
2632        let Some(stem) = name.strip_suffix(".jsonl") else { continue };
2633        // "<uuid>-<round>": a UUID is 36 characters; a conflicted copy's name is longer.
2634        let Some((computer, round)) = stem.rsplit_once('-') else { continue };
2635        if computer.len() != 36 || uuid::Uuid::parse_str(computer).is_err() {
2636            continue;
2637        }
2638        if let Ok(round) = round.parse::<u32>() {
2639            out.entry(computer.to_string()).or_default().push(round);
2640        }
2641    }
2642    for list in out.values_mut() {
2643        list.sort_unstable();
2644    }
2645    out
2646}
2647
2648// ---------------------------------------------------------------- formats
2649
2650/// A change kept waiting (`Memory::waiting`): the later of two for one entry.
2651fn wait(waiting: &mut BTreeMap<String, (Option<String>, u64, String, u32)>, key: String, change: (Option<String>, u64, String, u32)) {
2652    if waiting.get(&key).is_none_or(|(_, c, w, _)| (change.1, change.2.as_str()) > (*c, w.as_str())) {
2653        waiting.insert(key, change);
2654    }
2655}
2656
2657/// A part met in a format newer than this Sioul knows (`Memory::newer`).
2658#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2659struct Newer {
2660    /// The newest format met.
2661    format: u32,
2662    /// The build that wrote it, as its batch says.
2663    #[serde(default)]
2664    build: String,
2665    /// The devices that wrote it: while none of them is still in the
2666    /// sharing (gone, forgotten, silent 180 days: a test build tried once),
2667    /// the part is no longer held (`release_newer`).
2668    #[serde(default)]
2669    writers: BTreeSet<String>,
2670}
2671
2672/// What a format-1 entry renamed by format 2 last said, kept once this device
2673/// writes format 2 (`Memory::floors`): a record of format 1 under that entry
2674/// read again (a store joining, a record rebuilt, an older copy of a round
2675/// put back) at or below it is passed over, so that nothing format 1 already
2676/// said comes back.
2677#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2678struct Floor {
2679    c: u64,
2680    w: String,
2681    /// For a list format 1 sends whole: the names, in format 2, of the
2682    /// elements it held then. A later whole list takes out only those of
2683    /// them it no longer holds, never an element added since in format 2.
2684    #[serde(default, skip_serializing_if = "Option::is_none")]
2685    held: Option<BTreeSet<String>>,
2686    /// For a list format 1 sends whole: the elements a whole list took out
2687    /// here, with the clock of the list that did (`Memory::lists`). A record
2688    /// of one at or below that clock is passed over (`below_whole_floor`).
2689    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2690    removed: BTreeMap<String, u64>,
2691}
2692
2693/// A list format 1 sends whole, as the whole lists seen here said it, each
2694/// at its clock (`Memory::lists`), in whatever order they came: for each
2695/// element one of them held, the latest clock of a list that held it, and
2696/// the latest of one that did not. An element is taken out when a list
2697/// without it came after the last one with it (`removed`). An element no
2698/// list held is not in it: a list that did not know it did not take it out.
2699#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2700struct WholeList {
2701    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2702    held_at: BTreeMap<String, u64>,
2703    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2704    lacked_at: BTreeMap<String, u64>,
2705    /// The latest clock of any list seen.
2706    #[serde(default)]
2707    top: u64,
2708}
2709
2710impl WholeList {
2711    /// A whole list seen, at clock `c`, holding `names`: a record read (in one
2712    /// exchange, a later list may come first), or the file between exchanges.
2713    fn saw(&mut self, c: u64, names: &BTreeSet<String>) {
2714        for name in self.held_at.keys().filter(|name| !names.contains(*name)) {
2715            let lacked = self.lacked_at.entry(name.clone()).or_default();
2716            *lacked = (*lacked).max(c);
2717        }
2718        for name in names {
2719            // Every list seen before did not hold it.
2720            if !self.held_at.contains_key(name) && self.top > 0 {
2721                self.lacked_at.insert(name.clone(), self.top);
2722            }
2723            let held = self.held_at.entry(name.clone()).or_default();
2724            *held = (*held).max(c);
2725        }
2726        self.top = self.top.max(c);
2727    }
2728
2729    /// When an element was taken out: the clock of the latest list without
2730    /// it, when that came after the latest list with it.
2731    fn removed_at(&self, name: &str) -> Option<u64> {
2732        let held = self.held_at.get(name)?;
2733        self.lacked_at.get(name).copied().filter(|lacked| lacked > held)
2734    }
2735
2736    fn removed(&self) -> BTreeMap<String, u64> {
2737        self.held_at.keys().filter_map(|name| self.removed_at(name).map(|at| (name.clone(), at))).collect()
2738    }
2739}
2740
2741/// A record of a list format 1 sends whole (format 2 dividing it), as
2742/// `WholeList::saw` takes it: the names, in format 2, of what it holds. Any
2743/// other record: nothing.
2744fn note_whole_record(stores: &[Store], memory: &mut Memory, key: &str, value: Option<&str>, c: u64) {
2745    let (file, entry) = (file_of(key), entry_of(key));
2746    let Some((store, _)) = locate(stores, file) else { return };
2747    let Shape::Toml(rules) = store.shape else { return };
2748    if rules.next.is_none() {
2749        return;
2750    }
2751    let Renamed::Whole(keyed) = renamed(rules, rules.at(2), entry) else { return };
2752    let elements: Vec<toml::Value> = value
2753        .and_then(|v| leaf_value(v).ok())
2754        .and_then(|v| v.as_array().cloned())
2755        .unwrap_or_default()
2756        .into_iter()
2757        .map(|mut element| {
2758            strip_local(&mut element, keyed.local);
2759            element
2760        })
2761        .collect();
2762    let held: BTreeSet<String> = names(&elements, keyed).into_iter().collect();
2763    memory.lists.entry(key.to_string()).or_default().saw(c, &held);
2764}
2765
2766/// What each list format 1 sends whole holds now in the files format 2
2767/// divides otherwise, by format 2's names (`Memory::lists`): an element a
2768/// list no longer holds is noted as taken out at the clock of the list's
2769/// last word here (this device's or another's), until a list holds it again.
2770/// While this device writes format 1, after each exchange and before the
2771/// switch renames its memory.
2772fn note_whole_lists(stores: &[Store], memory: &mut Memory) {
2773    for (file, path, store) in divided(stores) {
2774        let Shape::Toml(rules) = store.shape else { continue };
2775        let next = rules.at(2);
2776        let Ok(text) = std::fs::read_to_string(&path) else { continue };
2777        if text.parse::<toml::Table>().is_err() {
2778            continue;
2779        }
2780        let mut now: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
2781        for (before, after, _) in aligned(&text, rules, next) {
2782            if matches!(renamed(rules, next, &before), Renamed::Whole(_)) && !after.is_empty() {
2783                now.entry(format!("{file}#{before}")).or_default().insert(element_name(&after).to_string());
2784            }
2785        }
2786        let keys: BTreeSet<String> = memory.lists.keys().filter(|key| file_of(key) == file).cloned().chain(now.keys().cloned()).collect();
2787        for key in keys {
2788            let held = now.remove(&key).unwrap_or_default();
2789            let clock = memory.entries.get(&key).map_or(memory.clock, |known| known.c);
2790            memory.lists.entry(key).or_default().saw(clock, &held);
2791        }
2792    }
2793}
2794
2795/// The name a floor is kept under: a hash of the format-1 entry (whose name may be a whole money line).
2796fn floor_key(key: &str) -> String {
2797    derived("floor", key)
2798}
2799
2800/// Whether a record of an element of a list format 1 sent whole (pinned
2801/// sites, words, days off, routines, task kinds, lighter days) comes at or
2802/// below the clock of a whole list that took that element out here
2803/// (`Floor::removed`), the list not holding it since. Passed over, so that
2804/// an element taken out before the switch never comes back, as when a
2805/// device restates its old entries in a new round (`share-own-cut`). An
2806/// element no whole list here ever held (added in format 2 elsewhere, its
2807/// record waiting here) is never passed over: a list that did not know it
2808/// did not take it out.
2809fn below_whole_floor(memory: &Memory, key: &str, c: u64) -> bool {
2810    let (file, entry) = (file_of(key), entry_of(key));
2811    let Some((list, element)) = entry.rsplit_once(SEP) else { return false };
2812    let Some(name) = element.strip_prefix(MARK) else { return false };
2813    let whole = format!("{file}#{list}");
2814    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));
2815    // 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).
2816    by_floor || memory.lists.get(&whole).and_then(|list| list.removed_at(name)).is_some_and(|at| c <= at)
2817}
2818
2819/// Why a device holds back format 2 (`format_holders`).
2820#[derive(Debug, Clone, PartialEq, Eq)]
2821pub enum Holds {
2822    /// Its Sioul does not read format 2, or does not say: an older one, or a
2823    /// device known by its records alone.
2824    Older,
2825    /// Its entry does not read here (its seal not arrived, damaged, taken away).
2826    Unread,
2827    /// It reads format 2, but what it wrote before is not all read here yet.
2828    Unheard,
2829}
2830
2831/// When an entry was written, as far as its own times tell (each writing
2832/// sets one of them to its device's clock then, and none goes back).
2833fn entry_stamp(entry: &crate::devices::Entry) -> i64 {
2834    entry.started.max(entry.imported).max(entry.closed).max(entry.exported)
2835}
2836
2837/// What this device has seen of each other's sealed entry, kept
2838/// (`Memory::seen`): the latest by its own times. An older copy put back in
2839/// the folder (a replay by a hostile server) never lowers it.
2840fn note_entries(folder: &Path, key: &[u8; 32], memory: &mut Memory) {
2841    for entry in crate::devices::all(folder, key).0 {
2842        if memory.seen.get(&entry.id).is_none_or(|seen| entry_stamp(&entry) >= entry_stamp(seen)) {
2843            memory.seen.insert(entry.id.clone(), entry);
2844        }
2845    }
2846}
2847
2848/// The clock of each other device's last record in the folder, opened (sealed,
2849/// bound to its device, round, number and clock), for the devices this
2850/// memory has not heard of yet (`Memory::heard_at`): a memory from before it
2851/// was kept learns when each device it read last wrote.
2852///
2853/// A device read here whose rounds are gone from the folder takes the latest
2854/// clock of its changes this memory still holds (each the clock of a record
2855/// read here, sealed); one with none at all is noted as found without a
2856/// clock (`Memory::unclocked`), at `now_ms`, once.
2857fn fill_heard_at(folder: &Path, key: &[u8; 32], computer: &str, memory: &mut Memory, now_ms: i64) {
2858    for (other, their_rounds) in rounds_with(folder) {
2859        if other == computer || memory.heard_at.contains_key(&other) {
2860            continue;
2861        }
2862        let Some(&round) = their_rounds.last() else { continue };
2863        if let Some(clock) = last_record_clock(folder, key, &other, round) {
2864            memory.heard_at.insert(other, (clock >> 16) as i64);
2865        }
2866    }
2867    let read: Vec<String> = memory.read_n.keys().filter(|id| *id != computer && !memory.heard_at.contains_key(*id)).cloned().collect();
2868    for other in read {
2869        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();
2870        match held {
2871            Some(clock) => {
2872                memory.heard_at.insert(other.clone(), (clock >> 16) as i64);
2873                memory.unclocked.remove(&other);
2874            }
2875            None => {
2876                memory.unclocked.entry(other).or_insert(now_ms);
2877            }
2878        }
2879    }
2880    let heard: Vec<String> = memory.unclocked.keys().filter(|id| memory.heard_at.contains_key(*id)).cloned().collect();
2881    for id in heard {
2882        memory.unclocked.remove(&id);
2883    }
2884}
2885
2886/// The clock of the last record of a round that opens with the key, read from its end.
2887fn last_record_clock(folder: &Path, key: &[u8; 32], computer: &str, round: u32) -> Option<u64> {
2888    use std::io::{Read, Seek};
2889    let mut file = open_round(folder, computer, round).ok()?;
2890    let size = file.metadata().ok()?.len();
2891    let from = size.saturating_sub(256 * 1024);
2892    let mut tail = Vec::new();
2893    file.seek(std::io::SeekFrom::Start(from)).ok()?;
2894    file.read_to_end(&mut tail).ok()?;
2895    tail.split(|b| *b == b'\n').rev().find_map(|line| {
2896        let record = std::str::from_utf8(line).ok().and_then(|t| serde_json::from_str::<Line>(t).ok())?;
2897        open(key, &bound(computer, round, record.n, record.c), &record.s).map(|_| record.c)
2898    })
2899}
2900
2901/// When another device was last heard of, from sealed facts only: its entry's
2902/// times (sealed, bound to it) and the clock of its last record read here
2903/// (`Memory::heard_at`), in milliseconds; 0 when nothing is known. Never its
2904/// notes in the folder, which are plain and could be forged.
2905fn last_heard(entry: Option<&crate::devices::Entry>, memory: &Memory, id: &str) -> i64 {
2906    let entry = entry.map_or(0, |e| entry_stamp(e) * 1000);
2907    entry.max(memory.heard_at.get(id).copied().unwrap_or(0))
2908}
2909
2910/// The other devices of the sharing that count, and what each says of
2911/// itself (its latest sealed entry seen here, `Memory::seen`, a replayed
2912/// older copy never lowering it): every device known here by a sealed fact
2913/// (its readable entry, its records in the folder, or its records read here
2914/// before, `read_n`, `heard_at`, whatever the folder shows now: a hostile
2915/// folder hiding a device's files never makes it go), but those that left
2916/// (their entry says so), those you forgot until they say anything newer
2917/// (`forget_device`), and those not heard of for `SILENT_DAYS` (one of which
2918/// no clock is known at all, from when this device first found it so,
2919/// `Memory::unclocked`). A file alone (plain notes, an entry that does not
2920/// read) with no record is a stray file, not a device.
2921fn present(folder: &Path, key: &[u8; 32], computer: &str, memory: &Memory, now_ms: i64) -> Vec<(String, Option<crate::devices::Entry>, bool)> {
2922    let (entries, unread) = crate::devices::all(folder, key);
2923    let recorded: BTreeSet<String> = rounds_with(folder).into_keys().chain(memory.read_n.keys().cloned()).chain(memory.heard_at.keys().cloned()).collect();
2924    let mut ids = computers(folder);
2925    ids.extend(entries.iter().map(|e| e.id.clone()));
2926    ids.extend(unread.iter().cloned());
2927    ids.extend(recorded.iter().cloned());
2928    ids.into_iter()
2929        .filter(|id| id != computer)
2930        .filter_map(|id| {
2931            let here = entries.iter().find(|e| e.id == id).cloned();
2932            let entry = match (here, memory.seen.get(&id)) {
2933                (Some(here), Some(seen)) if entry_stamp(seen) > entry_stamp(&here) => Some(seen.clone()),
2934                (None, Some(seen)) => Some(seen.clone()),
2935                (here, _) => here,
2936            };
2937            if entry.is_none() && !recorded.contains(&id) {
2938                return None;
2939            }
2940            if entry.as_ref().is_some_and(|e| e.left) {
2941                return None;
2942            }
2943            let last = last_heard(entry.as_ref(), memory, &id);
2944            if memory.forgotten.get(&id).is_some_and(|mark| last <= *mark) {
2945                return None;
2946            }
2947            // No clock known at all: aged from when this device first found it so.
2948            let aged = if last > 0 { last } else { memory.unclocked.get(&id).copied().unwrap_or(0) };
2949            if aged > 0 && now_ms - aged > SILENT_DAYS * DAY {
2950                return None;
2951            }
2952            let unread = unread.contains(&id) && entries.iter().all(|e| e.id != id);
2953            Some((id, entry, unread))
2954        })
2955        .collect()
2956}
2957
2958/// The devices that hold back format 2 (`FORMAT`), and why: of the devices
2959/// that count (`present`), each must say in its sealed entry that it reads
2960/// format 2 (`devices::Entry::format`), and all it wrote up to that entry
2961/// (`wrote`) must be read here (`read_n`). A device whose entry does not read
2962/// here, or known by its records alone, holds it back: an older Sioul cannot
2963/// read format 2 and would double what it changes (an entry that no longer
2964/// reads is said as such, even when an older one was seen here). A hostile
2965/// folder can only hold it back longer, never open it.
2966fn format_holders(folder: &Path, key: &[u8; 32], computer: &str, memory: &Memory, now_ms: i64) -> Vec<(String, Holds)> {
2967    let heard = Heard { read: memory.read_n.clone(), broken: BTreeMap::new() };
2968    present(folder, key, computer, memory, now_ms)
2969        .into_iter()
2970        .filter_map(|(id, entry, unread)| match entry {
2971            Some(entry) if entry.format >= 2 => entry.wrote.is_some_and(|wrote| !heard.complete(&id, wrote)).then_some((id, Holds::Unheard)),
2972            _ if unread => Some((id, Holds::Unread)),
2973            _ => Some((id, Holds::Older)),
2974        })
2975        .collect()
2976}
2977
2978/// Whether this device may write format 2: another device at least counts
2979/// (`present`; alone, it writes format 1: a device joining later may run an
2980/// older Sioul), and none holds it back (`format_holders`). A device not
2981/// joined yet holds nothing format 1 could lose: every other device saying
2982/// it reads format 2 is enough, and it joins in format 2.
2983fn format_open(sharing: &Sharing, memory: &Memory, now_ms: i64) -> bool {
2984    !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)
2985}
2986
2987/// A device you said is gone (Settings ▸ Your folder and sharing ▸ Forget
2988/// this device), as the sharing sees it: it no longer holds format 2 back,
2989/// nor keeps a part held, until it says anything newer than now (an entry
2990/// written since, a record read here: `present`). Waits for an exchange running.
2991pub fn forget_device(folder: &Path, key: &[u8; 32], memory: &Path, computer: &str, device: &str) -> Result<(), String> {
2992    let _running = exchange_lock(memory, true)?;
2993    let mut state = Memory::load_all(memory, computer);
2994    let (entries, _) = crate::devices::all(folder, key);
2995    let mark = last_heard(entries.iter().find(|e| e.id == device), &state, device);
2996    state.forgotten.insert(device.to_string(), mark.max(1));
2997    state.save(memory)?;
2998    state.keep(memory);
2999    Ok(())
3000}
3001
3002/// A device counted again (Count it again), whatever it said since.
3003pub fn count_device_again(memory: &Path, computer: &str, device: &str) -> Result<(), String> {
3004    let _running = exchange_lock(memory, true)?;
3005    let mut state = Memory::load_all(memory, computer);
3006    if state.forgotten.remove(device).is_some() {
3007        state.save(memory)?;
3008        state.keep(memory);
3009    }
3010    Ok(())
3011}
3012
3013/// What this device knows of the sharing's formats, for the window
3014/// (Settings ▸ Your folder and sharing, the status line): the format it
3015/// writes, the parts met in a newer format than it knows (their format,
3016/// the build that wrote it), and, while it writes format 1, the devices that
3017/// hold format 2 back and why.
3018#[derive(Debug, Default, Clone)]
3019pub struct Formats {
3020    pub writes: u32,
3021    pub newer: Vec<(String, u32, String)>,
3022    pub holders: Vec<(String, Holds)>,
3023    /// The devices that count (`present`) whose entry says they read an
3024    /// older format than this one's: said once this device writes format 2.
3025    pub older: Vec<String>,
3026    /// When each device of `holders` and `older` was last heard of, from
3027    /// sealed facts (`last_heard`), in milliseconds; 0 when nothing says it.
3028    /// Settings lists each, with "Forget this device" for one not counted
3029    /// otherwise.
3030    pub heard: BTreeMap<String, i64>,
3031}
3032
3033pub fn formats(folder: &Path, key: &[u8; 32], memory: &Path, computer: &str, now_ms: i64) -> Formats {
3034    let state = Memory::load(memory, computer);
3035    let holders = if state.format < 2 && state.joined { format_holders(folder, key, computer, &state, now_ms) } else { Vec::new() };
3036    let counting = present(folder, key, computer, &state, now_ms);
3037    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() };
3038    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();
3039    Formats { writes: state.format.max(1), newer: state.newer.into_iter().map(|(part, newer)| (part, newer.format, newer.build)).collect(), holders, older, heard }
3040}
3041
3042/// Whether this device holds a part met in a newer format than its Sioul
3043/// reads (`Memory::newer`), as its memory says on the disk: the same in every
3044/// process, after a restart too. For the doses: this device's entry then says
3045/// its doses are not known, and it never closes as if they were (docs/health.md).
3046pub fn holds(memory: &Path, part: &str) -> bool {
3047    // Only the parts held are kept from the reading: the rest of the memory is passed over, never built.
3048    #[derive(Deserialize)]
3049    struct Held {
3050        #[serde(default)]
3051        newer: BTreeMap<String, serde::de::IgnoredAny>,
3052    }
3053    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))
3054}
3055
3056/// The format this device writes (`FORMAT`): 1 until every device reads format 2 (`format_open`), then 2.
3057pub fn writes(memory: &Path) -> u32 {
3058    format_of(memory).max(1)
3059}
3060
3061/// The format this device writes, as its memory says (`Memory::format`), whichever computer's it is.
3062fn format_of(memory: &Path) -> u32 {
3063    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)
3064}
3065
3066/// Parts held for writers that no longer count (`present`: gone, forgotten,
3067/// silent 180 days: a test build tried once): no longer held. Their changes in that newer format, kept waiting,
3068/// are dropped; read again later, they are passed over (`Memory::released`).
3069fn release_newer(sharing: &Sharing, memory: &mut Memory, now_ms: i64) {
3070    if memory.newer.is_empty() {
3071        return;
3072    }
3073    // Only writers that no longer count, by sealed facts (`present`): never
3074    // an entry's format, which a record ahead of its new entry, or an older
3075    // copy put back, could say.
3076    let counting: BTreeSet<String> = present(sharing.folder, sharing.key, sharing.computer, memory, now_ms).into_iter().map(|(id, _, _)| id).collect();
3077    let gone: Vec<String> = memory.newer.iter().filter(|(_, newer)| newer.writers.iter().all(|w| !counting.contains(w))).map(|(part, _)| part.clone()).collect();
3078    for part in gone {
3079        memory.newer.remove(&part);
3080        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();
3081        for key in dropped {
3082            if let Some((_, c, _, _)) = memory.waiting.remove(&key) {
3083                let released = memory.released.entry(part.clone()).or_default();
3084                *released = (*released).max(c);
3085            }
3086        }
3087    }
3088}
3089
3090/// How format 2 names an entry format 1 named so (its path in its file).
3091enum Renamed<'r> {
3092    /// As format 1 does.
3093    Same,
3094    /// An element of a list whose elements format 2 names otherwise (`Keyed::ids`).
3095    Element(&'r Keyed),
3096    /// A list format 1 sends whole, divided element by element in format 2.
3097    Whole(&'r Keyed),
3098}
3099
3100fn renamed<'r>(rules: &Rules, next: &'r Rules, entry: &str) -> Renamed<'r> {
3101    let path: Vec<&str> = entry.split(SEP).collect();
3102    match path.split_last() {
3103        Some((element, list)) if element.starts_with(MARK) && !list.is_empty() => match (keyed_named(rules, list), keyed_named(next, list)) {
3104            (Some(before), Some(after)) if before.ids == after.ids && before.by == after.by => Renamed::Same,
3105            (_, Some(after)) => Renamed::Element(after),
3106            _ => Renamed::Same,
3107        },
3108        _ => match (keyed_named(rules, &path), keyed_named(next, &path)) {
3109            (None, Some(after)) => Renamed::Whole(after),
3110            _ => Renamed::Same,
3111        },
3112    }
3113}
3114
3115/// The elements of a file's keyed lists (format 2's), each with its entry
3116/// in format 1 (its own, or its whole list's when format 1 sends the list
3117/// whole), its entry in format 2, and its value.
3118fn aligned(text: &str, rules: &Rules, next: &Rules) -> Vec<(String, String, String)> {
3119    let Ok(table) = text.parse::<toml::Table>() else { return Vec::new() };
3120    let mut out = Vec::new();
3121    for (name, value) in &table {
3122        align(vec![name.clone()], value, rules, next, &mut out);
3123    }
3124    out
3125}
3126
3127fn align(path: Vec<String>, value: &toml::Value, rules: &Rules, next: &Rules, out: &mut Vec<(String, String, String)>) {
3128    if next.local.iter().any(|rule| matches_rule(&path, rule)) {
3129        return;
3130    }
3131    if let toml::Value::Array(elements) = value
3132        && let Some(keyed) = keyed_at(next, &path)
3133    {
3134        let (before, after) = list_names(elements, &path, rules, keyed);
3135        let list = list_name(keyed, &path);
3136        for ((entry, name), element) in before.into_iter().zip(names(&after, keyed)).zip(&after) {
3137            out.push((entry, format!("{list}{SEP}{MARK}{name}"), leaf_text(element)));
3138        }
3139        return;
3140    }
3141    if let toml::Value::Table(table) = value
3142        && !next.whole.iter().any(|rule| matches_rule(&path, rule))
3143    {
3144        for (name, value) in table {
3145            let mut deeper = path.clone();
3146            deeper.push(name.clone());
3147            align(deeper, value, rules, next, out);
3148        }
3149    }
3150}
3151
3152/// A keyed list's elements at `path`: their entries in format 1 (their own,
3153/// or the whole list's), and the elements as format 2 reads them (its local
3154/// fields out).
3155fn list_names(elements: &[toml::Value], path: &[String], rules: &Rules, keyed: &Keyed) -> (Vec<String>, Vec<toml::Value>) {
3156    let strip = |local: &[&str]| -> Vec<toml::Value> {
3157        elements
3158            .iter()
3159            .map(|element| {
3160                let mut element = element.clone();
3161                strip_local(&mut element, local);
3162                element
3163            })
3164            .collect()
3165    };
3166    let before: Vec<String> = match keyed_at(rules, path) {
3167        Some(before) => {
3168            let list = list_name(before, path);
3169            names(&strip(before.local), before).into_iter().map(|name| format!("{list}{SEP}{MARK}{name}")).collect()
3170        }
3171        None => vec![path.join(&SEP.to_string()); elements.len()],
3172    };
3173    (before, strip(keyed.local))
3174}
3175
3176/// The name format 2 gives an element format 1 named `ident` when the file
3177/// here does not say otherwise: format 1 named it by itself (`by` empty),
3178/// and its own id, else the id derived from it, names it; by its id: that
3179/// id; by other fields (a session by its start, task and project): the id
3180/// derived from them, the one written into it when a device took format 2.
3181fn stateless(keyed: &Keyed, ident: &str) -> String {
3182    if keyed.by.is_empty()
3183        && let Some(id) = leaf_value(ident).ok().as_ref().and_then(own_id).map(str::to_string)
3184    {
3185        return id;
3186    }
3187    if keyed.by == ["id"] && !ident.is_empty() {
3188        return ident.to_string();
3189    }
3190    derived(keyed.list, ident)
3191}
3192
3193/// The element's name (after `␞`) in an entry.
3194fn element_name(entry: &str) -> &str {
3195    entry.rsplit_once(MARK).map_or("", |(_, name)| name)
3196}
3197
3198/// An element as text, its id left out: what two versions of one element compare by.
3199fn without_id(text: &str) -> String {
3200    match leaf_value(text) {
3201        Ok(toml::Value::Table(mut table)) => {
3202            table.remove("id");
3203            leaf_text(&toml::Value::Table(table))
3204        }
3205        Ok(other) => leaf_text(&other),
3206        Err(_) => text.to_string(),
3207    }
3208}
3209
3210/// Format 1's changes (`older`: by entry, the later of each), for the files
3211/// format 2 divides otherwise, in format 2's names. Passed over: one at or
3212/// below what format 1 already said under that entry here (`Floor`). An
3213/// element's change takes the name format 2 gives it: its own id; for a
3214/// session (named by its start, task and project) without one, the id of
3215/// the session of that name here (a device on format 1 writes a session
3216/// without the id the switch gave it here: the id is kept), else the id
3217/// derived from that name; for a money line, its id, else the id derived
3218/// from it. A value without its id takes the id it goes under, written into
3219/// it: what the switch wrote stays. From an older Sioul (`modern`: the
3220/// writers whose sealed entry says they read format 2; the others), a line's
3221/// removal named by a version this device does not hold any more (a copy
3222/// changed since) is passed over; from a device that reads format 2 and
3223/// still writes format 1 for a few minutes, the later word wins. Several
3224/// entries for one element (an older Sioul changes a line by adding its new
3225/// version and taking the old one out) make one change: the latest version
3226/// held, at the latest clock; none held, taken out. A list sent whole sets
3227/// what it holds and takes out, of what the previous whole list held, what
3228/// it no longer holds, at its clock: never an element added since in format
3229/// 2. Returns the changes, and the names that differ from what a device
3230/// holding none of them would give (a session whose own id was kept here,
3231/// its value carrying none): sent again from here in format 2, so that every
3232/// device, one joining later too, holds it under one name.
3233fn 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)>) {
3234    let mut versions: BTreeMap<String, Vec<(Option<String>, u64, String)>> = BTreeMap::new();
3235    let mut out = Vec::new();
3236    let mut again = Vec::new();
3237    let mut here: BTreeMap<String, Vec<(String, String, String)>> = BTreeMap::new();
3238    for (key, (value, c, w)) in older {
3239        let (file, entry) = (file_of(key), entry_of(key));
3240        let Some((store, path)) = locate(stores, file) else { continue };
3241        let Shape::Toml(rules) = store.shape else { continue };
3242        let next = rules.at(2);
3243        let renaming = renamed(rules, next, entry);
3244        if matches!(renaming, Renamed::Same) {
3245            out.push((key.clone(), value.clone(), *c, w.clone()));
3246            continue;
3247        }
3248        let floor = floor_key(key);
3249        let previous = memory.floors.get(&floor).cloned();
3250        if previous.as_ref().is_some_and(|f| (*c, w.as_str()) <= (f.c, f.w.as_str())) {
3251            continue;
3252        }
3253        let elements = here.entry(file.to_string()).or_insert_with(|| aligned(&std::fs::read_to_string(&path).unwrap_or_default(), rules, next));
3254        match renaming {
3255            Renamed::Same => {}
3256            Renamed::Element(keyed) => {
3257                let (list, ident) = entry.rsplit_once(SEP).map_or(("", ""), |(list, element)| (list, element.trim_start_matches(MARK)));
3258                let given = value.as_deref().and_then(|v| leaf_value(v).ok()).map(|mut element| {
3259                    strip_local(&mut element, keyed.local);
3260                    element
3261                });
3262                let fallback = stateless(keyed, ident);
3263                // The element format 1 names so here (a session by its start,
3264                // task and project), when none holds the id format 2 derives
3265                // from that name: one whose id was kept (an older Sioul wrote it
3266                // back without).
3267                let derived_here = elements.iter().any(|(_, after, _)| element_name(after) == fallback);
3268                let mine = (!keyed.by.is_empty() && !derived_here).then(|| elements.iter().find(|(before, _, _)| before == entry)).flatten();
3269                let mut value = value.clone();
3270                // The id kept from the session of that name here (an older Sioul wrote it back without).
3271                let mut kept = false;
3272                let name = match (&given, given.as_ref().and_then(own_id), mine) {
3273                    (Some(_), Some(id), _) => id.to_string(),
3274                    (Some(_), None, Some((_, after, text))) => {
3275                        kept = leaf_value(text).ok().as_ref().and_then(own_id).is_some();
3276                        element_name(after).to_string()
3277                    }
3278                    (None, _, Some((_, after, _))) => element_name(after).to_string(),
3279                    _ => fallback.clone(),
3280                };
3281                // An element's value without its id (format 1 named it otherwise) takes the id it
3282                // goes under, as the switch wrote it into the element: never written back without.
3283                if let Some(element) = given.as_ref().filter(|e| own_id(e).is_none() && e.is_table()) {
3284                    let mut element = element.clone();
3285                    if let toml::Value::Table(table) = &mut element {
3286                        table.insert("id".into(), toml::Value::String(name.clone()));
3287                    }
3288                    value = Some(leaf_text(&element));
3289                }
3290                // A line taken out as a version this device no longer holds under
3291                // that name, by an older Sioul (its sealed entry says no newer
3292                // format): passed over. From a device that reads format 2 (it
3293                // still writes format 1 for a few minutes), the later word wins,
3294                // as it will when that device takes format 2.
3295                if given.is_none() && keyed.by.is_empty() && !modern.contains(w) {
3296                    let key2 = format!("{file}#{list}{SEP}{MARK}{name}");
3297                    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()));
3298                    if current.is_some_and(|text| without_id(&text) != without_id(ident)) {
3299                        memory.floors.insert(floor, Floor { c: *c, w: w.clone(), held: None, removed: BTreeMap::new() });
3300                        continue;
3301                    }
3302                }
3303                memory.floors.insert(floor, Floor { c: *c, w: w.clone(), held: None, removed: BTreeMap::new() });
3304                if kept && name != fallback {
3305                    again.push((format!("{file}#{list}{SEP}{MARK}{name}"), format!("{file}#{list}{SEP}{MARK}{fallback}")));
3306                }
3307                versions.entry(format!("{file}#{list}{SEP}{MARK}{name}")).or_default().push((value, *c, w.clone()));
3308            }
3309            Renamed::Whole(keyed) => {
3310                let path: Vec<String> = entry.split(SEP).map(str::to_string).collect();
3311                let prefix = format!("{file}#{}{SEP}{MARK}", list_name(keyed, &path));
3312                let elements: Vec<toml::Value> = value
3313                    .as_deref()
3314                    .and_then(|v| leaf_value(v).ok())
3315                    .and_then(|v| v.as_array().cloned())
3316                    .unwrap_or_default()
3317                    .into_iter()
3318                    .map(|mut element| {
3319                        strip_local(&mut element, keyed.local);
3320                        element
3321                    })
3322                    .collect();
3323                let listed: BTreeSet<String> = names(&elements, keyed).into_iter().collect();
3324                for (element, name) in elements.iter().zip(names(&elements, keyed)) {
3325                    versions.entry(format!("{prefix}{name}")).or_default().push((Some(leaf_text(element)), *c, w.clone()));
3326                }
3327                // Of what the previous whole list held, what this one no longer holds.
3328                let mut removed = previous.as_ref().map(|f| f.removed.clone()).unwrap_or_default();
3329                for gone in previous.and_then(|f| f.held).unwrap_or_default().difference(&listed) {
3330                    versions.entry(format!("{prefix}{gone}")).or_default().push((None, *c, w.clone()));
3331                    removed.insert(gone.clone(), *c);
3332                }
3333                removed.retain(|name, _| !listed.contains(name));
3334                memory.floors.insert(floor, Floor { c: *c, w: w.clone(), held: Some(listed), removed });
3335            }
3336        }
3337    }
3338    for (key, list) in versions {
3339        let Some(last) = list.iter().max_by(|a, b| (a.1, &a.2).cmp(&(b.1, &b.2))) else { continue };
3340        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());
3341        out.push((key, held, last.1, last.2.clone()));
3342    }
3343    (out, again)
3344}
3345
3346/// The files of the stores format 2 divides otherwise: their names in the
3347/// records, where they are, their store.
3348fn divided<'s>(stores: &'s [Store]) -> Vec<(String, PathBuf, &'s Store)> {
3349    let mut out = Vec::new();
3350    for store in stores.iter().filter(|s| matches!(s.shape, Shape::Toml(rules) if rules.next.is_some())) {
3351        if store.folder {
3352            let mut files = Vec::new();
3353            list_files(&store.path, &store.path, store.skip, &mut files);
3354            out.extend(files.into_iter().map(|(relative, path)| (format!("{}{relative}", store.name), path, store)));
3355        } else if store.path.is_file() {
3356            out.push((store.name.clone(), store.path.clone(), store));
3357        }
3358    }
3359    out
3360}
3361
3362/// This device takes format 2 (`FORMAT`), once and for all, before this
3363/// exchange goes on:
3364/// 1. every file format 2 divides otherwise must read whole: one that does
3365///    not (half written, broken by hand), or of no bytes while it held
3366///    entries, keeps this device on format 1 for now, said
3367///    (`share-format-wait`), and the switch is tried at the next exchange;
3368/// 2. what changed in them since the last look goes out first, in format 1
3369///    and as format 1 names it (`flush`): a removal, a corrected session
3370///    reach the devices that still read format 1;
3371/// 3. each element of a list format 2 names by ids is given its id in the
3372///    file, once, through the file's lock, the file as it was kept first
3373///    among the earlier versions (`give_ids`); from then on every element
3374///    carries an id of its own, which changing it never changes;
3375/// 4. the memory is said in format 2's names, each renamed entry's last
3376///    clock kept as its floor (`Floor`).
3377///
3378/// Returns whether it did. Stopped half way, the next exchange does it
3379/// again from where the files and the memory are: the ids already written
3380/// stay, what was sent is known.
3381fn switch_format(sharing: &Sharing, memory: &mut Memory, stores: &[Store], now_ms: i64, problems: &mut Vec<String>) -> Result<bool, String> {
3382    let files = divided(stores);
3383    for (name, path, _) in &files {
3384        let text = std::fs::read_to_string(path);
3385        let held = memory.entries.iter().any(|(key, known)| file_of(key) == name && !known.h.is_empty());
3386        let reads = match &text {
3387            Ok(text) if text.trim().is_empty() => !held,
3388            Ok(text) => text.parse::<toml::Table>().is_ok(),
3389            Err(_) => false,
3390        };
3391        if !reads {
3392            problems.push(format!("share-format-wait:{name}"));
3393            return Ok(false);
3394        }
3395    }
3396    if memory.joined {
3397        flush(sharing, memory, stores, now_ms)?;
3398    }
3399    // What each whole list holds after that, and what one took out: kept with its floor.
3400    note_whole_lists(stores, memory);
3401    // Ids written first, file by file; the memory renamed only once all are:
3402    // one that cannot be written (no room for the copy kept, a file being
3403    // written) keeps this device on format 1 for now, said; tried again at the
3404    // next exchange, the ids already written stay theirs.
3405    let history = crate::history::root(sharing.memory);
3406    let mut done = Vec::new();
3407    for (name, path, store) in &files {
3408        match give_ids(store, name, path, memory, &history, now_ms) {
3409            Ok(mut placed) => {
3410                placed.extend(whole_lists(store, name, path, memory));
3411                done.push((name, *store, placed));
3412            }
3413            Err(e) => {
3414                problems.push(e);
3415                problems.push(format!("share-format-wait:{name}"));
3416                return Ok(false);
3417            }
3418        }
3419    }
3420    for (name, store, placed) in done {
3421        rename_memory(memory, store, name, &placed);
3422    }
3423    let belongs = |file: &str| locate(stores, file).is_some_and(|(store, _)| matches!(store.shape, Shape::Toml(rules) if rules.next.is_some()));
3424    memory.files.retain(|file, _| !belongs(file));
3425    // Changes of these files waiting to be written: with the others' format-1 records, translated.
3426    let pending: Vec<String> = memory.pending.keys().filter(|key| belongs(file_of(key))).cloned().collect();
3427    for key in pending {
3428        if let Some((value, c, w)) = memory.pending.remove(&key) {
3429            wait(&mut memory.waiting, key, (value, c, w, 1));
3430        }
3431    }
3432    memory.format = 2;
3433    memory.switch = false;
3434    Ok(true)
3435}
3436
3437/// What changed in the files format 2 divides otherwise since the last look,
3438/// sent in format 1 (this device still writes it), as an exchange sends it;
3439/// not for a store joining in this exchange, nor a part held under a newer format.
3440fn flush(sharing: &Sharing, memory: &mut Memory, stores: &[Store], now_ms: i64) -> Result<(), String> {
3441    // Not a store joining in this exchange (switched on, or moved: it joins as
3442    // a new device would, its file rebuilt from the records first), nor a part
3443    // held under a newer format (nothing of it goes out from here).
3444    let joining = |store: &Store| !memory.places.is_empty() && memory.places.get(&store.name) != Some(&place_of(store));
3445    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();
3446    let confirmed = BTreeSet::new();
3447    let found = gather(&subset, &memory.files, &Look { reads: &|_| true, hurry: &|| false, confirmed: &confirmed, clock: clock_ms() });
3448    let mut out: Vec<(String, Option<String>, u64, String)> = Vec::new();
3449    for (key, h) in &found.hashes {
3450        if memory.entries.get(key).is_some_and(|k| k.h == *h) {
3451            continue;
3452        }
3453        let Some(value) = found.values.get(key).cloned().or_else(|| value_of(&subset, key)) else { continue };
3454        let clock = memory.tick(found.changed.get(file_of(key)).copied().unwrap_or(now_ms).min(now_ms));
3455        memory.entries.insert(key.clone(), Known { c: clock, w: sharing.computer.to_string(), h: h.clone(), b: String::new() });
3456        out.push((key.clone(), Some(value), clock, String::new()));
3457    }
3458    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();
3459    for key in gone {
3460        let clock = memory.tick(now_ms);
3461        memory.entries.insert(key.clone(), Known { c: clock, w: sharing.computer.to_string(), h: String::new(), b: String::new() });
3462        out.push((key, None, clock, String::new()));
3463    }
3464    if memory.round == 0 {
3465        memory.round = 1;
3466    }
3467    append(sharing, memory, &out)?;
3468    for (file, stat) in found.files {
3469        memory.files.insert(file, stat);
3470    }
3471    Ok(())
3472}
3473
3474/// An element of a list format 2 names by ids, as `give_ids` left it: its
3475/// entry in format 1 before, its entry in format 2, its value, and whether it
3476/// stands for that format-1 entry (what format 1 last sent or received
3477/// under it: the other devices hold it under that name, and give it the same id).
3478struct Placed {
3479    before: String,
3480    after: String,
3481    text: String,
3482    stands: bool,
3483    /// It holds exactly what was last sent or received: else (changed
3484    /// meanwhile, or one of several named alike) it is sent again in format
3485    /// 2, under its id, for every device to hold it as it is here.
3486    exact: bool,
3487}
3488
3489/// Each element of the lists of `path` that format 2 names by ids is given
3490/// one in the file, once: the element that stands for a format-1 entry
3491/// (what was last sent or received under it) the id derived from that entry
3492/// (`derived`), as every device holding it derives it; another (a second
3493/// session at one start, a second money line alike, a preset an older Sioul
3494/// never carried) an id of its own, drawn at random, since it was never
3495/// shared. Two elements holding one id (two devices changed one element
3496/// apart while they wrote format 1): the one holding the greatest text keeps
3497/// it, the other takes the id derived from its own format-1 entry. A
3498/// preset's id is put where splits and lines named it by its place
3499/// (`#3`). Written through the file's lock, the file as it was kept first
3500/// (`history`). Returns every element of those lists.
3501fn give_ids(store: &Store, file: &str, path: &Path, memory: &Memory, history: &Path, now_ms: i64) -> Result<Vec<Placed>, String> {
3502    let Shape::Toml(rules) = store.shape else { return Ok(Vec::new()) };
3503    let next = rules.at(2);
3504    sioul_core::filelock::with_lock(path, || {
3505        let text = std::fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?;
3506        let mut doc: toml_edit::DocumentMut = text.parse().map_err(|e: toml_edit::TomlError| format!("{}: {e}", path.display()))?;
3507        let mut before: Vec<(Vec<String>, Vec<String>, Vec<(bool, bool)>)> = Vec::new();
3508        let mut presets: BTreeMap<String, String> = BTreeMap::new();
3509        let mut changed = false;
3510        each_id_list(doc.as_table_mut(), Vec::new(), next, &mut |path, keyed, list| {
3511            let normal = |t: &toml_edit::Table| leaf_value(&format!("[v]\n{t}")).ok();
3512            let read: Vec<toml::Value> = list.iter().map(|t| normal(t).unwrap_or_else(|| toml::Value::Table(toml::Table::new()))).collect();
3513            let (entries, _) = list_names(&read, path, rules, keyed);
3514            let whole = keyed_at(rules, path).is_none();
3515            // A whole list stands for its format-1 entry when the file holds what was last sent.
3516            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)));
3517            let texts: Vec<String> = read
3518                .iter()
3519                .map(|element| {
3520                    let mut element = element.clone();
3521                    strip_local(&mut element, keyed_at(rules, path).map_or(keyed.local, |b| b.local));
3522                    leaf_text(&element)
3523                })
3524                .collect();
3525            let mut ids: BTreeMap<String, Vec<usize>> = BTreeMap::new();
3526            for (i, element) in read.iter().enumerate() {
3527                if let Some(id) = own_id(element) {
3528                    ids.entry(id.to_string()).or_default().push(i);
3529                }
3530            }
3531            // The element each format-1 entry stands for: its one element; of
3532            // several format 1 named alike (two sessions an older Sioul noted
3533            // at one start), the first when that entry's last word came from
3534            // another device (format 1 wrote it there), the last when from
3535            // this one (format 1 sent that one).
3536            let mut alike: BTreeMap<&str, Vec<usize>> = BTreeMap::new();
3537            for (i, entry) in entries.iter().enumerate() {
3538                alike.entry(entry.as_str()).or_default().push(i);
3539            }
3540            let standing = |i: usize| -> (bool, bool) {
3541                if whole {
3542                    return (whole_stands, true);
3543                }
3544                let Some(known) = memory.entries.get(&format!("{file}#{}", entries[i])).filter(|k| !k.h.is_empty()) else { return (false, false) };
3545                let at = &alike[entries[i].as_str()];
3546                let chosen = if at.len() == 1 { at[0] } else if known.w == memory.computer { at[at.len() - 1] } else { at[0] };
3547                (chosen == i, known.h == hash(&texts[i]))
3548            };
3549            let mut claimed: BTreeSet<String> = BTreeSet::new();
3550            let mut stands = vec![(false, false); read.len()];
3551            for (i, element) in read.iter().enumerate() {
3552                let entry = &entries[i];
3553                let (shared, exact) = standing(i);
3554                let ident = element_name(entry).to_string();
3555                let new_id = match own_id(element) {
3556                    Some(id) if ids.get(id).is_some_and(|at| at.len() == 1) => {
3557                        stands[i] = (shared, exact);
3558                        None
3559                    }
3560                    Some(id) => {
3561                        let at = &ids[id];
3562                        let keeper = at.iter().copied().max_by(|a, b| texts[*a].cmp(&texts[*b]).then(b.cmp(a))).unwrap_or(i);
3563                        stands[i] = (shared, exact);
3564                        (keeper != i).then(|| derived(keyed.list, if whole { &texts[i] } else { &ident }))
3565                    }
3566                    None => {
3567                        let what = if whole { texts[i].clone() } else { ident.clone() };
3568                        if (shared || !memory.joined) && claimed.insert(what.clone()) {
3569                            stands[i] = (shared, exact);
3570                            Some(derived(keyed.list, &what))
3571                        } else {
3572                            Some(sioul_core::ids::new())
3573                        }
3574                    }
3575                };
3576                if let Some(id) = new_id {
3577                    if keyed.list == "preset" && path.len() == 1 {
3578                        presets.insert(format!("#{i}"), id.clone());
3579                    }
3580                    if let Some(table) = list.get_mut(i) {
3581                        table.insert("id", toml_edit::value(id));
3582                        changed = true;
3583                    }
3584                }
3585            }
3586            before.push((path.to_vec(), entries, stands));
3587        });
3588        // Splits, lines and mail rules that named a preset by its place name it
3589        // by its id. Format 1 carried one preset without an id only, so places
3590        // may name different presets on each device: each element so changed
3591        // goes out again (not exact), and one version wins everywhere.
3592        let mut repointed: BTreeSet<(String, usize)> = BTreeSet::new();
3593        if !presets.is_empty() {
3594            for list in ["split", "line", "mail_rule"] {
3595                if let Some(tables) = doc.get_mut(list).and_then(toml_edit::Item::as_array_of_tables_mut) {
3596                    for (at, table) in tables.iter_mut().enumerate() {
3597                        if let Some(id) = table.get("preset").and_then(toml_edit::Item::as_str).and_then(|place| presets.get(place)).cloned() {
3598                            table.insert("preset", toml_edit::value(id));
3599                            repointed.insert((list.to_string(), at));
3600                        }
3601                    }
3602                }
3603            }
3604        }
3605        let written = doc.to_string();
3606        if changed {
3607            crate::history::keep(history, store.part, file, path, now_ms)?;
3608            write_atomically(path, written.as_bytes())?;
3609        }
3610        // Every element as format 2 now reads it, beside its entry in format 1 before.
3611        let now: toml::Table = written.parse().map_err(|e: toml::de::Error| format!("{}: {e}", path.display()))?;
3612        let mut placed = Vec::new();
3613        for (list_path, entries, stands) in before {
3614            let Some(keyed) = keyed_at(next, &list_path) else { continue };
3615            let Some(elements) = array_at(&now, &list_path) else { continue };
3616            let (_, after) = list_names(elements, &list_path, rules, keyed);
3617            let list = list_name(keyed, &list_path);
3618            for (at, (((entry, (stands, exact)), name), element)) in entries.into_iter().zip(stands).zip(names(&after, keyed)).zip(&after).enumerate() {
3619                let exact = exact && !(list_path.len() == 1 && repointed.contains(&(list_path[0].clone(), at)));
3620                placed.push(Placed { before: entry, after: format!("{list}{SEP}{MARK}{name}"), text: leaf_text(element), stands, exact });
3621            }
3622        }
3623        Ok(placed)
3624    })
3625}
3626
3627/// The array at `path` in a table.
3628fn array_at<'t>(table: &'t toml::Table, path: &[String]) -> Option<&'t Vec<toml::Value>> {
3629    let (last, within) = path.split_last()?;
3630    let mut at = table;
3631    for part in within {
3632        at = at.get(part)?.as_table()?;
3633    }
3634    at.get(last)?.as_array()
3635}
3636
3637/// The elements of the lists of words and dates format 1 sends whole (format
3638/// 2 names them by themselves, no id to give): each stands for its whole
3639/// list's entry when the file holds what was last sent under it.
3640fn whole_lists(store: &Store, file: &str, path: &Path, memory: &Memory) -> Vec<Placed> {
3641    let Shape::Toml(rules) = store.shape else { return Vec::new() };
3642    let next = rules.at(2);
3643    let Ok(text) = std::fs::read_to_string(path) else { return Vec::new() };
3644    let sent: BTreeMap<String, String> = toml_entries(&text, rules).unwrap_or_default().into_iter().map(|(entry, value)| (entry, hash(&value))).collect();
3645    aligned(&text, rules, next)
3646        .into_iter()
3647        .filter(|(before, after, _)| matches!(renamed(rules, next, before), Renamed::Whole(keyed) if !keyed.ids) && !after.is_empty())
3648        .map(|(before, after, text)| {
3649            let stands = memory.entries.get(&format!("{file}#{before}")).is_some_and(|known| !known.h.is_empty() && sent.get(&before) == Some(&known.h));
3650            Placed { before, after, text, stands, exact: true }
3651        })
3652        .collect()
3653}
3654
3655/// Each list of tables in `table` that format 2 names by ids (`Keyed::ids`), with its path.
3656fn each_id_list(table: &mut toml_edit::Table, path: Vec<String>, next: &Rules, f: &mut dyn FnMut(&[String], &Keyed, &mut toml_edit::ArrayOfTables)) {
3657    let keys: Vec<String> = table.iter().map(|(key, _)| key.to_string()).collect();
3658    for key in keys {
3659        let mut deeper = path.clone();
3660        deeper.push(key.clone());
3661        if next.local.iter().any(|rule| matches_rule(&deeper, rule)) {
3662            continue;
3663        }
3664        let Some(item) = table.get_mut(&key) else { continue };
3665        match item {
3666            toml_edit::Item::ArrayOfTables(list) => {
3667                if let Some(keyed) = keyed_at(next, &deeper).filter(|k| k.ids) {
3668                    f(&deeper, keyed, list);
3669                }
3670            }
3671            toml_edit::Item::Table(inner) if !next.whole.iter().any(|rule| matches_rule(&deeper, rule)) => each_id_list(inner, deeper, next, f),
3672            _ => {}
3673        }
3674    }
3675}
3676
3677/// The memory of one file said in format 2's names, once its elements carry
3678/// their ids (`give_ids`): the entry each element stands for becomes its
3679/// entry in format 2 (its value now holding the id: its hash too), at the
3680/// same clock; a list format 1 sent whole becomes an entry per element, its
3681/// clock and what it held kept as its floor; an entry for an element no
3682/// longer here (taken out) is named as `stateless` names it. Several of
3683/// format 1's entries for one element (its versions) make one: the latest
3684/// clock, the version held. Each renamed entry keeps its clock as its floor.
3685fn rename_memory(memory: &mut Memory, store: &Store, file: &str, placed: &[Placed]) {
3686    let Shape::Toml(rules) = store.shape else { return };
3687    let next = rules.at(2);
3688    let mut grouped: BTreeMap<String, Vec<Known>> = BTreeMap::new();
3689    let mut wholes: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
3690    for element in placed {
3691        let before = format!("{file}#{}", element.before);
3692        if let Renamed::Whole(_) = renamed(rules, next, &element.before) {
3693            wholes.entry(before.clone()).or_default().insert(element_name(&element.after).to_string());
3694        }
3695        if !element.stands {
3696            continue;
3697        }
3698        if let Some(known) = memory.entries.get(&before) {
3699            // Not exactly what was shared: a hash nothing holds, so that the next look sends it.
3700            let h = if element.exact { hash(&element.text) } else { "?".to_string() };
3701            grouped.entry(format!("{file}#{}", element.after)).or_default().push(Known { c: known.c, w: known.w.clone(), h, b: String::new() });
3702        }
3703    }
3704    let keys: Vec<String> = memory.entries.keys().filter(|key| file_of(key) == file).cloned().collect();
3705    for key in keys {
3706        let entry = entry_of(&key).to_string();
3707        let renaming = renamed(rules, next, &entry);
3708        if matches!(renaming, Renamed::Same) {
3709            continue;
3710        }
3711        let Some(known) = memory.entries.remove(&key) else { continue };
3712        match renaming {
3713            Renamed::Same => {}
3714            Renamed::Whole(_) => {
3715                let held = wholes.get(&key).cloned().filter(|_| !known.h.is_empty());
3716                let removed = memory.lists.get(&key).map(WholeList::removed).unwrap_or_default();
3717                memory.floors.insert(floor_key(&key), Floor { c: known.c, w: known.w.clone(), held: Some(held.unwrap_or_default()), removed });
3718            }
3719            Renamed::Element(keyed) => {
3720                memory.floors.insert(floor_key(&key), Floor { c: known.c, w: known.w.clone(), held: None, removed: BTreeMap::new() });
3721                if !placed.iter().any(|p| p.stands && format!("{file}#{}", p.before) == key) {
3722                    let (list, ident) = entry.rsplit_once(SEP).map_or(("", ""), |(list, element)| (list, element.trim_start_matches(MARK)));
3723                    grouped.entry(format!("{file}#{list}{SEP}{MARK}{}", stateless(keyed, ident))).or_default().push(Known { h: String::new(), ..known });
3724                }
3725            }
3726        }
3727    }
3728    for (key, versions) in grouped {
3729        let Some(last) = versions.iter().max_by(|a, b| (a.c, &a.w).cmp(&(b.c, &b.w))).cloned() else { continue };
3730        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();
3731        memory.entries.insert(key, Known { c: last.c, w: last.w, h: held, b: String::new() });
3732    }
3733}
3734
3735// ---------------------------------------------------------------- exchange
3736
3737/// Sharing as set up on this computer.
3738pub struct Sharing<'a> {
3739    pub folder: &'a Path,
3740    pub computer: &'a str,
3741    pub key: &'a [u8; 32],
3742    /// Where this computer keeps what it remembers: `<state>/share/memory.json`.
3743    pub memory: &'a Path,
3744    /// Notes and papers too (`Shape::Files`): not for an exchange that must be
3745    /// quick (a dose's alarm, a notification's button, Sioul closing), nor
3746    /// without the system's leave to read them all. A single file sealed apart
3747    /// (the spam filter's table) goes in every exchange.
3748    pub files: bool,
3749    /// Set while a hurried exchange waits for this one: its work on notes and
3750    /// papers stops where it is, and goes on at the next.
3751    pub hurry: Option<&'a std::sync::atomic::AtomicBool>,
3752}
3753
3754/// What an exchange did.
3755#[derive(Debug, Default)]
3756pub struct Outcome {
3757    /// The stores written to, by name: what to read again.
3758    pub written: BTreeSet<String>,
3759    /// Why notes and papers received wait, by reason, counted (never named):
3760    /// "no-store" (not shared here), "not-read" (not this time: a quick
3761    /// exchange, or Android's leave to read them all not given), "hurried",
3762    /// "unknown" (their file here not readable as it stands), "backoff" (could
3763    /// not be written lately, tried again later), "no-blob" (their sealed file
3764    /// neither in the folder nor fetched beside it yet: waited for, `blobs::Wanted`),
3765    /// "later" (changed here since it was looked at: the next exchange sends that first).
3766    pub waits: BTreeMap<String, usize>,
3767    /// Changes found here and sent.
3768    pub sent: usize,
3769    /// Changes from the others written here.
3770    pub received: usize,
3771    /// What went wrong, in a word each; the rest went on.
3772    pub problems: Vec<String>,
3773    /// The others' changes waiting to be written here (their file unreadable, or not shared here).
3774    pub pending: usize,
3775    /// When this computer's files were read for it (milliseconds, its clock):
3776    /// everything captured here before is in its records, up to `wrote`. 0
3777    /// when it did not run (`share-busy`).
3778    pub looked: i64,
3779    /// How far this computer's records went once it ended: round and number;
3780    /// none before sharing joined.
3781    pub wrote: Option<(u32, u64)>,
3782    /// Nothing it reads had changed since an exchange left everything
3783    /// settled: nothing was read nor written (`quiet`).
3784    pub quiet: bool,
3785}
3786
3787/// Before the first exchange, a copy of every shared file, in case. Not of
3788/// notes and papers, which may be large: each file is kept before anything
3789/// is written over it (`history`). Not of the calls' logs: nothing removes
3790/// these copies, and a call leaves every device after its month. A copy
3791/// there already is never written over: a first exchange tried again (one
3792/// that failed after writing the others' changes), or sharing stopped and
3793/// started again the same day, keeps the files as they were before sharing
3794/// (review of 5 October 2026, F22).
3795fn keep_copies(stores: &[Store], memory: &Path, today: &str) {
3796    let Some(base) = memory.parent() else { return };
3797    let aside = base.join(format!("before-sharing-{today}"));
3798    let keep = |from: &Path, to: &Path| {
3799        if to.exists() {
3800            return;
3801        }
3802        let _ = to.parent().map(private_dirs);
3803        let _ = copy_private(from, to);
3804    };
3805    for store in stores.iter().filter(|s| !matches!(s.shape, Shape::Files) && !calls_store(&s.name)) {
3806        let target = aside.join(store.name.trim_end_matches('/'));
3807        if store.folder {
3808            let mut files = Vec::new();
3809            list_files(&store.path, &store.path, store.skip, &mut files);
3810            for (relative, path) in files {
3811                keep(&path, &target.join(relative));
3812            }
3813        } else if store.path.is_file() {
3814            keep(&store.path, &target);
3815        }
3816    }
3817}
3818
3819/// One exchange: the changes found here go out, the others' come in. One at a
3820/// time on this computer, whatever runs it (`exchange_lock`): another running,
3821/// a quick one waits for it; a whole one does nothing, and says so (`share-busy`).
3822pub fn exchange(sharing: &Sharing, stores: &[Store], now_ms: i64) -> Result<Outcome, String> {
3823    // The folder as it was set up, its seal opening with this device's key;
3824    // else nothing is read, written or made, and it is said (`sealed_for`).
3825    let (sealed, salt) = match sealed_for(sharing.folder, sharing.key) {
3826        Ok(Some((opens, salt))) => (Some(opens), salt),
3827        Ok(None) => (None, String::new()),
3828        Err(()) => return Ok(Outcome { problems: vec!["share-no-seal".into()], ..Outcome::default() }),
3829    };
3830    let Some(_running) = exchange_lock(sharing.memory, !sharing.files)? else { return Ok(Outcome { problems: vec!["share-busy".into()], ..Outcome::default() }) };
3831    // What was fetched from the server too, read beside the folder, the newer
3832    // copy of each other device's file (`remote`); none until a server's
3833    // folder is confirmed to be this one.
3834    crate::remote::attach(sharing.folder, sharing.memory);
3835    let mut outcome = Outcome::default();
3836    // Notes and papers only when asked (not for a dose's alarm, a button
3837    // pressed, Sioul closing), and while no hurried exchange waits for this
3838    // one: their work stops where it is, and goes on at the next.
3839    let hurried = || sharing.hurry.is_some_and(|hurry| hurry.load(std::sync::atomic::Ordering::Relaxed));
3840    // A single file sealed apart (the spam filter's table), in every exchange:
3841    // one file of Sioul's own, quick to read, never in a shared storage.
3842    let reads = |store: &Store| reads_now(sharing, store);
3843    // What it reads, as it stands before anything is read (`Standing`): as
3844    // when the last exchange in this process left everything settled, there
3845    // is nothing to do, and nothing is read (`quiet`). Not with notes and
3846    // papers: the window's exchanges read them through.
3847    // Its time taken first: whatever was written before it is in the stamps.
3848    let standing = (!sharing.files).then(|| {
3849        let taken = clock_ms();
3850        (Standing::now(sharing, stores, &reads), taken)
3851    });
3852    if let Some(outcome) = standing.as_ref().and_then(|(standing, taken)| quiet(sharing, standing, *taken, now_ms)) {
3853        return Ok(outcome);
3854    }
3855    let mut memory = Memory::load_all(sharing.memory, sharing.computer);
3856    // Another sharing than the one this memory knew (its seal made from
3857    // another salt): this device set up again with another folder while its
3858    // memory stayed (the keyring's key lost, the passphrase typed again with
3859    // another folder). How far it read there, and that it joined, mean nothing
3860    // here: it starts anew, as when sharing stops and starts again (review
3861    // of 5 October 2026, F22), its own records here read first if any.
3862    if sealed == Some(true) && !memory.seal_salt.is_empty() && memory.seal_salt != salt {
3863        // Its own records here, if any, were sealed under another key (two
3864        // devices that sealed the folder apart): a new round of its own, full,
3865        // which the others can open and read on from (`later_full`).
3866        let round = last_own(sharing.folder, sharing.computer).map_or(1, |(round, _, _)| round + 1);
3867        memory = Memory { computer: sharing.computer.to_string(), round, full_round: round, ..Memory::default() };
3868    }
3869    if sealed == Some(true) {
3870        memory.seal_salt = salt;
3871    }
3872    // Its seal gone from a folder this device saw sealed (a sync app that made
3873    // the folder again, empty, or brought an empty placeholder of it), or
3874    // there but no longer opening with this device's key (another device's
3875    // seal put in its place: two devices sealed the folder apart): nothing
3876    // written there until it is as it was, said (`sealed_for`).
3877    match (sealed, memory.seal_seen) {
3878        (None, true) => return Ok(Outcome { problems: vec!["share-no-seal".into()], ..Outcome::default() }),
3879        (Some(false), true) => return Ok(Outcome { problems: vec!["share-sealed-otherwise".into()], ..Outcome::default() }),
3880        (Some(true), false) => memory.seal_seen = true,
3881        _ => {}
3882    }
3883    // This computer's own records in the folder go further than its memory: the
3884    // memory was restored from a backup, lost, or started again ("Stop sharing",
3885    // then again). Its numbering goes on after the folder's last line (the
3886    // others never read a number twice, nor miss one), and every file is read
3887    // again from all the records, its own too: what it marked after its memory's
3888    // time comes back here.
3889    let mut restate = false;
3890    if let Some((round, n, size)) = last_own(sharing.folder, sharing.computer)
3891        && (round, n) > (memory.round, memory.seq)
3892    {
3893        outcome.problems.push("share-own-ahead".into());
3894        // Lines this device did not append (`appended`: its own last append,
3895        // noted before it was made, where a process stopped before saving its
3896        // memory left it) again within hours: another device writes under this
3897        // one's name (a disk or a state folder copied to it), each finding the
3898        // other's lines in its own file; a memory restored finds them once.
3899        // Said, for you to stop sharing on one of them and start it again
3900        // there, which gives it a name of its own (review of 5 October 2026,
3901        // F22). Meanwhile the others meet gaps in that name's records: the
3902        // doses say the doubt.
3903        if appended(sharing.memory, sharing.computer).is_none_or(|end| (round, n) > end) {
3904            if memory.ahead_at > 0 && now_ms - memory.ahead_at < TWIN_WITHIN {
3905                outcome.problems.push("share-twin".into());
3906            }
3907            memory.ahead_at = now_ms;
3908        }
3909        memory.round = round;
3910        memory.seq = n;
3911        memory.own_size = size;
3912        memory.round_base = 0;
3913        // Where its rounds stood is read from the folder, not from the memory
3914        // (`Turn`): a round found without a line (its first writing lost, a
3915        // placeholder) continues the round before as that round ends; with
3916        // none before it, a full round follows, restating all it holds.
3917        memory.continues = 0;
3918        if n == 0 && round > 1 {
3919            match last_line(sharing.folder, sharing.computer, round - 1) {
3920                Some(last) if last > 0 => memory.continues = last,
3921                _ => restate = true,
3922            }
3923        }
3924        // Which of its rounds was full is not known: none taken out until
3925        // the next full round (`remove_old_rounds` keeps every round from 1).
3926        memory.full_round = 1;
3927        memory.read.clear();
3928        memory.rebuild.insert("*".into());
3929    }
3930    // A memory kept before rounds said where they stand (`Turn`) may have
3931    // moved past the end of a round it had not read whole: every other
3932    // computer's kept rounds are read again, once; what was known already
3933    // changes nothing, what was passed over comes in.
3934    // Entries of stores that left (`RETIRED`) are forgotten: nothing reads or
3935    // writes them any more (review of 10 October 2026, S2-B).
3936    memory.entries.retain(|key, _| !retired(key));
3937    if !memory.turns {
3938        if memory.joined {
3939            memory.read.clear();
3940        }
3941        memory.turns = true;
3942    }
3943    if !memory.joined {
3944        let today = jiff::Timestamp::from_millisecond(now_ms).map(|t| t.strftime("%Y-%m-%d").to_string()).unwrap_or_default();
3945        keep_copies(stores, sharing.memory, &today);
3946    }
3947    // The format this device writes (`FORMAT`): format 2 once every device
3948    // in the sharing reads it (`format_open`), or once another device writes
3949    // it already (`Memory::switch`); its memory is then said in format 2's
3950    // names, once (`switch_format`). Its files are read and written as its
3951    // format divides them; an older format's records are translated
3952    // (`translate`).
3953    // Parts met in a newer format that this Sioul now reads (it was updated),
3954    // or whose writers no longer count: no longer held (`release_newer`).
3955    memory.newer.retain(|part, newer| newer.format > part_format(part));
3956    // What is known of the others, from sealed facts (`present`).
3957    note_entries(sharing.folder, sharing.key, &mut memory);
3958    fill_heard_at(sharing.folder, sharing.key, sharing.computer, &mut memory, now_ms);
3959    release_newer(sharing, &mut memory, now_ms);
3960    if memory.format < 2 && (memory.switch || format_open(sharing, &memory, now_ms)) {
3961        switch_format(sharing, &mut memory, stores, now_ms, &mut outcome.problems)?;
3962    }
3963    let originals = stores;
3964    let shaped_stores = shaped(stores, memory.format);
3965    let stores = shaped_stores.as_slice();
3966    // Parts met in a newer format: nothing of them goes out from here until this Sioul is updated.
3967    let held: BTreeSet<&'static str> = memory.newer.keys().filter_map(|part| PARTS.iter().find(|p| **p == part.as_str()).copied()).collect();
3968    let holds = |key: &str| known_part(file_of(key)).is_some_and(|part| held.contains(part));
3969    let clock = clock_ms();
3970    let confirmed = memory.sealed.confirmed.clone();
3971    let mut found = gather(stores, &memory.files, &Look { reads: &reads, hurry: &hurried, confirmed: &confirmed, clock });
3972    outcome.problems.extend(found.too_big.iter().map(|file| format!("share-too-big:{file}")));
3973    outcome.problems.append(&mut found.said);
3974    outcome.problems.extend(found.emptied.iter().map(|(folder, held)| format!("share-vanished:{folder}:{held}")));
3975    let mut out: Vec<(String, Option<String>, u64, String)> = Vec::new();
3976
3977    // Stores new here (projects shared from now on, a store a new version
3978    // adds) or moved (another notes folder): joined as a new computer joins.
3979    // Their files are rebuilt from all the records first, then only what they
3980    // alone hold goes out: an old copy here never beats the others' newer
3981    // values, an empty new folder is filled, and nothing is taken out
3982    // elsewhere. A memory from before places were kept takes every store where
3983    // it is. A store not read this time joins when it is.
3984    let place = place_of;
3985    if memory.joined && memory.places.is_empty() {
3986        memory.places = stores.iter().map(|s| (s.name.clone(), place(s))).collect();
3987    }
3988    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() };
3989    let joins = |key: &str| locate(stores, file_of(key)).is_some_and(|(store, _)| joining.contains(&store.name));
3990    if !joining.is_empty() {
3991        memory.entries.retain(|key, _| !joins(key));
3992        memory.read.clear();
3993        memory.rebuild.extend(joining.iter().cloned());
3994    }
3995
3996    // A file sealed apart found empty that was not at the last look (an
3997    // editor writing it in place, a crash): held as it was, a while.
3998    let mut held_empty: BTreeSet<String> = BTreeSet::new();
3999    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();
4000    for file in emptied_now {
4001        let since = *memory.emptied.entry(file.clone()).or_insert(now_ms);
4002        if now_ms - since < EMPTIED_WAIT {
4003            found.hashes.remove(&format!("{file}#"));
4004            found.unknown.insert(file.clone());
4005            if let Some(before) = memory.files.get(&file) {
4006                found.files.insert(file.clone(), before.clone());
4007            }
4008            held_empty.insert(file);
4009        }
4010    }
4011    // Not read this time (a quick exchange): held as they were.
4012    let unread = |file: &str| locate(stores, file).is_some_and(|(store, _)| !reads(store));
4013    memory.emptied.retain(|file, _| !file.starts_with(FILES) || held_empty.contains(file) || unread(file));
4014
4015    // Files sealed apart gone here: taken out elsewhere once gone a while
4016    // (`EMPTIED_WAIT`: a file being replaced, a disk away a moment), and never
4017    // many at once (a folder moved away, access withdrawn, a disk not
4018    // mounted) until you say so (`confirm_gone`). Meanwhile another device's
4019    // change to one of them still comes, unless many went at once.
4020    let mut held_gone: BTreeSet<String> = BTreeSet::new();
4021    let mut gone_since: BTreeMap<String, i64> = memory.sealed.gone.iter().filter(|(file, _)| unread(file)).map(|(file, since)| (file.clone(), *since)).collect();
4022    if memory.joined {
4023        let mut by_store: BTreeMap<String, (usize, Vec<String>)> = BTreeMap::new();
4024        for key in memory.entries.iter().filter(|(key, known)| key.starts_with(FILES) && !known.h.is_empty()).map(|(key, _)| key) {
4025            let Some((store, _)) = locate(stores, file_of(key)) else { continue };
4026            if !reads(store) || joining.contains(&store.name) {
4027                continue;
4028            }
4029            let counted = by_store.entry(store.name.clone()).or_default();
4030            counted.0 += 1;
4031            if !found.hashes.contains_key(key) && !found.is_unknown(key) {
4032                counted.1.push(file_of(key).to_string());
4033            }
4034        }
4035        for (store, (held, gone)) in by_store.into_iter().filter(|(_, (_, gone))| !gone.is_empty()) {
4036            let confirmed = memory.sealed.confirmed.contains(&store);
4037            let mass = gone.len() > MASS || (gone.len() >= 3 && gone.len() * 5 > held);
4038            if mass && !confirmed {
4039                outcome.problems.push(format!("share-vanished:{store}:{}", gone.len()));
4040            }
4041            for file in gone {
4042                let since = memory.sealed.gone.get(&file).copied().unwrap_or(now_ms);
4043                if mass && !confirmed {
4044                    gone_since.insert(file.clone(), since);
4045                    found.unknown.insert(file);
4046                } else if !confirmed && now_ms - since < EMPTIED_WAIT {
4047                    gone_since.insert(file.clone(), since);
4048                    held_gone.insert(file);
4049                }
4050            }
4051        }
4052    }
4053    memory.sealed.gone = gone_since;
4054    let value = |key: &str| found.values.get(key).cloned().or_else(|| value_of(stores, key));
4055
4056    // Changes made here since the last look, dated by their file. When joining,
4057    // the others' records come first, and only what they do not hold goes out.
4058    let mut older: Vec<String> = Vec::new();
4059    if memory.joined {
4060        for (key, h) in &found.hashes {
4061            if memory.entries.get(key).is_some_and(|k| k.h == *h) || joins(key) {
4062                memory.held.remove(key);
4063                continue;
4064            }
4065            // Found while its part is held: when it was found is kept; it goes
4066            // once the part is no longer held, at that time (`Memory::held`).
4067            if holds(key) {
4068                if memory.held.get(key).is_none_or(|(_, held)| held != h) {
4069                    let at = memory.tick(found.changed.get(file_of(key)).copied().unwrap_or(now_ms).min(now_ms));
4070                    memory.held.insert(key.clone(), (at, h.clone()));
4071                }
4072                continue;
4073            }
4074            if key.starts_with(FILES) {
4075                // An older copy put back by hand (a backup's, its time before
4076                // the version last seen here): not sent; that version comes
4077                // back, the copy kept beside (`older`).
4078                let file = file_of(key);
4079                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) {
4080                    older.push(key.clone());
4081                    continue;
4082                }
4083                if hurried() {
4084                    continue;
4085                }
4086            }
4087            let Some(value) = value(key) else { continue };
4088            // A file sealed apart goes into the folder before its record.
4089            if !seal_apart(sharing, stores, key, &value, &mut memory.sealed, &mut outcome.problems, now_ms) {
4090                continue;
4091            }
4092            let base = base_of(&memory, key);
4093            let clock = match memory.held.remove(key) {
4094                Some((at, held)) if held == *h => at,
4095                _ => memory.tick(found.changed.get(file_of(key)).copied().unwrap_or(now_ms).min(now_ms)),
4096            };
4097            memory.entries.insert(key.clone(), Known { c: clock, w: sharing.computer.to_string(), h: h.clone(), b: base.clone() });
4098            met_draft(&mut memory, key, [&base, h]);
4099            out.push((key.clone(), Some(value), clock, base));
4100        }
4101        // A settings file (the settings, the doses' record…) that held entries
4102        // and is now gone or of no bytes at all: a crash, a full disk, a file
4103        // being written in place, rather than a choice. Held for a while
4104        // (`EMPTIED_WAIT`): never everything taken out, everywhere, at once. A
4105        // list emptied on purpose, or a file gone from a folder of files (a
4106        // draft sent), is taken out at once; a note or a paper after a while
4107        // (above). A store no longer shared here (a part switched off, a notes
4108        // folder unset) takes nothing out elsewhere.
4109        let settings = |file: &str| stores.iter().find(|s| !s.folder && s.name == file && matches!(s.shape, Shape::Toml(_)));
4110        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();
4111        let mut waiting: BTreeSet<String> = BTreeSet::new();
4112        for file in held {
4113            let nothing = settings(&file).is_some_and(|s| std::fs::metadata(&s.path).map_or(true, |m| m.len() == 0));
4114            if !nothing || found.hashes.keys().any(|k| file_of(k) == file) {
4115                memory.emptied.remove(&file);
4116            } else if now_ms - *memory.emptied.entry(file.clone()).or_insert(now_ms) < EMPTIED_WAIT {
4117                outcome.problems.push(format!("share-emptied:{file}"));
4118                waiting.insert(file);
4119            }
4120        }
4121        let gone: Vec<String> = memory
4122            .entries
4123            .iter()
4124            .filter(|(key, known)| !known.h.is_empty() && !found.hashes.contains_key(*key) && !found.is_unknown(key) && !held_gone.contains(file_of(key)))
4125            .filter(|(key, _)| locate(stores, file_of(key)).is_some_and(|(store, _)| reads(store)) && !waiting.contains(file_of(key)))
4126            .map(|(key, _)| key.clone())
4127            .collect();
4128        for key in gone {
4129            if holds(&key) {
4130                if memory.held.get(&key).is_none_or(|(_, held)| !held.is_empty()) {
4131                    let at = memory.tick(now_ms);
4132                    memory.held.insert(key.clone(), (at, String::new()));
4133                }
4134                continue;
4135            }
4136            let base = base_of(&memory, &key);
4137            let clock = match memory.held.remove(&key) {
4138                Some((at, held)) if held.is_empty() => at,
4139                _ => memory.tick(now_ms),
4140            };
4141            memory.entries.insert(key.clone(), Known { c: clock, w: sharing.computer.to_string(), h: String::new(), b: base.clone() });
4142            met_draft(&mut memory, &key, [&base, ""]);
4143            out.push((key, None, clock, base));
4144        }
4145    }
4146
4147    // The others' records, from where each was left; those not written last
4148    // time first. A change for a part this device does not share is not kept
4149    // waiting: switched on, the part joins and reads it again.
4150    let newer = |c: u64, w: &str, than: Option<(u64, &str)>| than.is_none_or(|(tc, tw)| (c, w) > (tc, tw));
4151    // Nor one for a store that left (`RETIRED`), which an older device may still send.
4152    let off = |key: &str| retired(key) || (locate(stores, file_of(key)).is_none() && known_part(file_of(key)).is_some());
4153    let mut winners: BTreeMap<String, (Option<String>, u64, String)> = std::mem::take(&mut memory.pending)
4154        .into_iter()
4155        .filter(|(key, (_, c, w))| !off(key) && newer(*c, w, memory.entries.get(key).map(|k| (k.c, k.w.as_str()))))
4156        .collect();
4157    // Records of an older format (format 1: an older Sioul's, or this device's
4158    // own before it wrote format 2), for files format 2 divides otherwise: by
4159    // their entries, the later of each, translated once all are read (`translate`).
4160    let mut older_format: BTreeMap<String, (Option<String>, u64, String)> = BTreeMap::new();
4161    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()));
4162    let later = |older_format: &mut BTreeMap<String, (Option<String>, u64, String)>, key: String, value: Option<String>, c: u64, w: &str| {
4163        if older_format.get(&key).is_none_or(|(_, tc, tw)| (c, w) > (*tc, tw.as_str())) {
4164            older_format.insert(key, (value, c, w.to_string()));
4165        }
4166    };
4167    // Changes that waited for this device to write format 2, or for this
4168    // Sioul to know their part's format: in, once it can.
4169    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();
4170    for key in ready {
4171        let Some((value, c, w, f)) = memory.waiting.remove(&key) else { continue };
4172        if off(&key) {
4173            continue;
4174        }
4175        if f < 2 && divided_otherwise(&key) {
4176            later(&mut older_format, key, value, c, &w);
4177        } 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()))) {
4178            winners.insert(key, (value, c, w));
4179        }
4180    }
4181    // The build each computer's lines were written by, as its batches say: for `share-newer`.
4182    let mut builds: BTreeMap<String, String> = BTreeMap::new();
4183    // For files sealed apart: the content each change replaces where it was
4184    // made (its base), and every content the others' records name for each
4185    // file, their versions and what those replaced: a file here among them is
4186    // an older version, not a change of its own (`receive`).
4187    let mut bases: BTreeMap<String, String> = std::mem::take(&mut memory.sealed.bases).into_iter().filter(|(key, _)| winners.contains_key(key)).collect();
4188    let mut named: BTreeMap<String, BTreeSet<String>> = std::mem::take(&mut memory.sealed.named).into_iter().filter(|(key, _)| winners.contains_key(key)).collect();
4189    for (key, (value, _, _)) in winners.iter().filter(|(key, _)| key.starts_with(FILES)) {
4190        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()));
4191    }
4192    let all = rounds_with(sharing.folder);
4193    // Rebuilding a file: this computer's own records are read again too, for its entries only.
4194    let rebuilding = std::mem::take(&mut memory.rebuild);
4195    let rebuilt = |file: &str| rebuilding.contains("*") || rebuilding.contains(file) || rebuilding.iter().any(|r| r.ends_with('/') && file.starts_with(r.as_str()));
4196    for (computer, their_rounds) in all.iter().filter(|(c, _)| *c != sharing.computer || !rebuilding.is_empty()) {
4197        let own = computer == sharing.computer;
4198        let (mut start, mut skip) = if own { (0, 0) } else { memory.read.get(computer).copied().unwrap_or((0, 0)) };
4199        if !their_rounds.contains(&start) {
4200            // Not begun, or that round was removed: the oldest kept starts with all that counts.
4201            (start, skip) = (their_rounds[0], 0);
4202        }
4203        // A round waiting for the end of the one before is passed over for a
4204        // later full round, which restates all its computer holds (`Turn`).
4205        let mut from_round = start;
4206        'rounds: for &round in their_rounds.iter().filter(|r| **r >= start) {
4207            if round < from_round {
4208                continue;
4209            }
4210            let mut offset = if round == start { skip } else { 0 };
4211            let opened = if own { std::fs::File::open(round_file(sharing.folder, computer, round)) } else { open_round(sharing.folder, computer, round) };
4212            let Ok(mut file) = opened else {
4213                if !own {
4214                    memory.read.insert(computer.clone(), (round, offset));
4215                }
4216                continue;
4217            };
4218            // Shorter than what was read of it: an older copy put back by a sync
4219            // app. What it held past that point is lost here, said; it is read
4220            // again from its start (lines already known change nothing).
4221            if !own && file.metadata().is_ok_and(|m| m.len() < offset) {
4222                outcome.problems.push(format!("share-other-cut:{computer}"));
4223                say_broken(&mut memory, computer, now_ms / 1000);
4224                offset = 0;
4225            }
4226            {
4227                use std::io::Seek;
4228                let _ = file.seek(std::io::SeekFrom::Start(offset));
4229            }
4230            // Only whole lines: one still arriving is read next time. Each line on
4231            // its own: one that is not text (a disk's damage), or longer than any
4232            // record, is one broken line, not the end of everything after it.
4233            let record_of = |line: &[u8]| std::str::from_utf8(line).ok().and_then(|text| serde_json::from_str::<Line>(text).ok());
4234            let opens = |record: &Line| {
4235                let plain = open(sharing.key, &bound(computer, round, record.n, record.c), &record.s)?;
4236                // The change and, on its round's first line, where the round stands
4237                // (`Turn`; an older Sioul says nothing): one reading.
4238                serde_json::from_slice::<Opened>(&plain).ok().map(Opened::parts)
4239            };
4240            // The last line of this round that opened in this reading: its number,
4241            // and its clock (none for where an earlier reading stopped).
4242            let mut good: Option<(u64, Option<u64>)> = memory.read_n.get(computer).filter(|(r, _)| *r == round && offset > 0).map(|(_, n)| (*n, None));
4243            let mut lines = LineReader::new(file);
4244            let mut next = lines.next();
4245            while let Some((line, length)) = next.take() {
4246                next = lines.next();
4247                let Some(record) = record_of(&line) else {
4248                    // Never passed over in silence: it may have been a dose marked taken.
4249                    offset += length;
4250                    if !own {
4251                        if let Some(at) = lost_line(memory.read_n.get(computer).copied(), round, good, next.as_ref().map(|(n, _)| n.as_slice()), now_ms) {
4252                            outcome.problems.push(format!("share-other-line:{computer}"));
4253                            say_broken(&mut memory, computer, at);
4254                        }
4255                        lost_here(&mut memory, computer, round, None);
4256                    }
4257                    continue;
4258                };
4259                let Some((change, turn)) = opens(&record) else {
4260                    outcome.problems.push(format!("share-other-seal:{computer}"));
4261                    // One line that does not open while the next does: a damaged line,
4262                    // lost and said. None opening: another key, or a seal not here
4263                    // yet (the sync app is slow): read again from there next time.
4264                    if !own && next.as_ref().and_then(|(next, _)| record_of(next)).is_some_and(|next| opens(&next).is_some()) {
4265                        offset += length;
4266                        if let Some(at) = lost_line(memory.read_n.get(computer).copied(), round, good, next.as_ref().map(|(n, _)| n.as_slice()), now_ms) {
4267                            say_broken(&mut memory, computer, at);
4268                        }
4269                        lost_here(&mut memory, computer, round, Some(record.n));
4270                        continue;
4271                    }
4272                    if !own {
4273                        // A later full round that opens (that device set up again
4274                        // with this folder's passphrase, its old lines under
4275                        // another): read on from there, what it holds restated.
4276                        if let Some(full) = later_full(sharing, computer, their_rounds, round) {
4277                            from_round = full;
4278                            continue 'rounds;
4279                        }
4280                        memory.read.insert(computer.clone(), (round, offset));
4281                    }
4282                    break 'rounds;
4283                };
4284                // The turn of a round (`Turn`): one continuing the round before is
4285                // entered only once that round was met here to its last line, read
4286                // or found damaged (`Memory::met`; a line lost on the way stays said
4287                // for the doses through `read_n` and `broken`). Its end, or the
4288                // whole round before, still on its way (each file travels on its
4289                // own, in any order), this round waits for it, unless a later full
4290                // round restates it all. Rounds go from the folder oldest first
4291                // (`remove_old_rounds`): the round before missing while an earlier
4292                // one is kept has not come yet; missing with every earlier one, it
4293                // was taken out, and what it held past here is lost: a gap, said below.
4294                // Waited for an hour of exchanges (`AWAITED`; a round truly lost: an older copy
4295                // kept for good, a file taken out of the server), it is passed over
4296                // as lost: the gap said, what that computer wrote not all known
4297                // here (`read_n` stays) until its next full round, which this device
4298                // asks for (`Seen::full`), and the rest of its records flow
4299                // meanwhile (review of 10 October 2026).
4300                if !own
4301                    && record.n == 1
4302                    && turn.p > 0
4303                    && let Some(before) = round.checked_sub(1)
4304                    && memory.met.get(computer).or_else(|| memory.read_n.get(computer)).is_some_and(|met| *met < (before, turn.p))
4305                {
4306                    if let Some(full) = later_full(sharing, computer, their_rounds, round) {
4307                        memory.waiting_for.remove(computer);
4308                        from_round = full;
4309                        continue 'rounds;
4310                    }
4311                    if their_rounds.iter().any(|r| *r <= before) {
4312                        let waiting = memory.waiting_for.entry(computer.clone()).or_default();
4313                        if waiting.round == round {
4314                            waiting.waited += (now_ms - waiting.last).clamp(0, AWAIT_STEP);
4315                        } else {
4316                            *waiting = Awaited { round, waited: 0, last: now_ms };
4317                        }
4318                        waiting.last = now_ms;
4319                        if waiting.waited < AWAITED {
4320                            break 'rounds;
4321                        }
4322                    }
4323                }
4324                if !own && record.n == 1 {
4325                    memory.waiting_for.remove(computer);
4326                }
4327                offset += length;
4328                good = Some((record.n, Some(record.c)));
4329                if !change.build.is_empty() {
4330                    builds.insert(computer.clone(), change.build.clone());
4331                }
4332                // Sealed files named lately are never taken out from under the record naming them (`blobs::sweep`).
4333                if change.k.starts_with(FILES)
4334                    && let Some(value) = change.v.as_deref()
4335                {
4336                    memory.sealed.named_at.insert(crate::blobs::name(sharing.key, &value_hash(&change.k, value)), now_ms);
4337                }
4338                if own {
4339                    // Only the entries of the files rebuilt; the rest is this computer's as it is.
4340                    if rebuilt(file_of(&change.k)) && !off(&change.k) && memory.format < 2 && change.f >= 2 {
4341                        // Its own format 2, met while its memory says format 1 (a
4342                        // memory put back from before its switch): never written
4343                        // with format 1's rules; it takes format 2 again from its
4344                        // next exchange, and the record waits until then.
4345                        memory.switch = true;
4346                        wait(&mut memory.waiting, change.k, (change.v, record.c, computer.clone(), change.f));
4347                    } else if rebuilt(file_of(&change.k)) && !off(&change.k) && memory.format >= 2 && change.f < 2 && divided_otherwise(&change.k) {
4348                        later(&mut older_format, change.k, change.v, record.c, computer);
4349                    } 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())) {
4350                        bases.insert(change.k.clone(), change.b);
4351                        winners.insert(change.k, (change.v, record.c, computer.clone()));
4352                    }
4353                    continue;
4354                }
4355                // The lines follow each other: after a gap (lines lost to a stale copy of
4356                // the file, or to a damaged one) what that computer marked is not known
4357                // here, said as a doubt, until a full round restates all it holds. A
4358                // round's first line follows when it opens a full round, or continues
4359                // the round before as read here to its last line (`Turn`); an older
4360                // Sioul's says neither, and follows as before. A device read here for
4361                // the first time, from a round continuing one gone, knows where it
4362                // stands at its computer's next full round, with nothing said.
4363                if memory.met.get(computer).is_none_or(|met| (round, record.n) > *met) {
4364                    memory.met.insert(computer.clone(), (round, record.n));
4365                }
4366                let last = memory.read_n.get(computer).copied();
4367                if last.is_none_or(|last| (round, record.n) > last) {
4368                    let follows = match last {
4369                        Some((r, n)) if r == round => record.n == n + 1,
4370                        _ if record.n != 1 => false,
4371                        _ if turn.full => true,
4372                        _ if turn.p > 0 => last.is_some() && last == round.checked_sub(1).map(|before| (before, turn.p)),
4373                        _ => true,
4374                    };
4375                    if follows {
4376                        memory.read_n.insert(computer.clone(), (round, record.n));
4377                    } else if last.is_some() {
4378                        if !outcome.problems.iter().any(|p| *p == format!("share-other-gap:{computer}")) {
4379                            outcome.problems.push(format!("share-other-gap:{computer}"));
4380                        }
4381                        // What was lost was written before this line: about then.
4382                        say_broken(&mut memory, computer, ((record.c >> 16) as i64 / 1000).min(now_ms / 1000));
4383                    }
4384                }
4385                memory.clock = memory.clock.max(record.c);
4386                // Heard of now, by a sealed record (`present`).
4387                let heard_at = (record.c >> 16) as i64;
4388                if memory.heard_at.get(computer).is_none_or(|at| *at < heard_at) {
4389                    memory.heard_at.insert(computer.clone(), heard_at);
4390                }
4391                if off(&change.k) {
4392                    continue;
4393                }
4394                // Its part in a format this Sioul does not know (a newer one's):
4395                // kept waiting, never written here, said once; nothing of that
4396                // part goes out from here until this Sioul is updated.
4397                let format = change.f.max(1);
4398                if let Some(part) = known_part(file_of(&change.k))
4399                    && format > part_format(part)
4400                {
4401                    // Of writers that no longer count, released: passed over.
4402                    if memory.released.get(part).is_some_and(|c| record.c <= *c) {
4403                        continue;
4404                    }
4405                    if !memory.newer.contains_key(part) {
4406                        outcome.problems.push(format!("share-newer:{part}"));
4407                    }
4408                    let newer = memory.newer.entry(part.to_string()).or_default();
4409                    newer.format = newer.format.max(format);
4410                    if let Some(build) = builds.get(computer) {
4411                        newer.build = build.clone();
4412                    }
4413                    newer.writers.insert(computer.clone());
4414                    wait(&mut memory.waiting, change.k, (change.v, record.c, computer.clone(), format));
4415                    continue;
4416                }
4417                // A whole list of format 1: what it holds, at its clock (`WholeList`),
4418                // whatever this device writes, before its record is applied or translated.
4419                if format < 2 {
4420                    note_whole_record(originals, &mut memory, &change.k, change.v.as_deref(), record.c);
4421                }
4422                // Format 2 while this device writes format 1: another device
4423                // writes it already; this one does from its next exchange, and
4424                // the change waits for it.
4425                if format >= 2 && memory.format < 2 {
4426                    memory.switch = true;
4427                    wait(&mut memory.waiting, change.k, (change.v, record.c, computer.clone(), format));
4428                    continue;
4429                }
4430                // Format 1 for a file format 2 divides otherwise: translated once all are read.
4431                if format < 2 && memory.format >= 2 && divided_otherwise(&change.k) {
4432                    later(&mut older_format, change.k, change.v, record.c, computer);
4433                    continue;
4434                }
4435                if change.k.starts_with(FILES) {
4436                    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()));
4437                }
4438                // A draft taken out while another device changed it: it was sent (`DRAFTS`).
4439                let sent = change.k.starts_with(DRAFTS).then(|| {
4440                    let here = match winners.get(&change.k) {
4441                        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())),
4442                        None => memory.entries.get(&change.k).map(|k| (k.h.is_empty(), k.h.clone(), k.b.clone())),
4443                    };
4444                    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);
4445                    let version = change.v.as_deref().map(|v| value_hash(&change.k, v)).unwrap_or_default();
4446                    met_draft(&mut memory, &change.k, [&change.b, &version]);
4447                    decided
4448                });
4449                let known = memory.entries.get(&change.k).map(|k| (k.c, k.w.as_str()));
4450                let pending = winners.get(&change.k).map(|(_, c, w)| (*c, w.as_str()));
4451                // An element a later whole list of format 1 took out, restated at its old clock: passed over.
4452                let wins = match sent.flatten() {
4453                    Some(wins) => wins,
4454                    None => newer(record.c, computer, known) && newer(record.c, computer, pending) && !below_whole_floor(&memory, &change.k, record.c),
4455                };
4456                if wins {
4457                    bases.insert(change.k.clone(), change.b);
4458                    winners.insert(change.k, (change.v, record.c, computer.clone()));
4459                }
4460            }
4461            if !own {
4462                memory.read.insert(computer.clone(), (round, offset));
4463            }
4464        }
4465    }
4466
4467    // An older format's changes, in format 2's names (`translate`), then as any other.
4468    let mut again: Vec<(String, String)> = Vec::new();
4469    if !older_format.is_empty() {
4470        // The writers whose sealed entry says they read format 2 (`translate`).
4471        let modern: BTreeSet<String> = memory.seen.values().filter(|e| e.format >= 2).map(|e| e.id.clone()).collect();
4472        let (translated, named_here) = translate(originals, &older_format, &mut memory, &winners, &modern);
4473        again = named_here;
4474        for (key, value, c, w) in translated {
4475            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()))) {
4476                bases.remove(&key);
4477                winners.insert(key, (value, c, w));
4478            }
4479        }
4480    }
4481
4482    // The others' changes, written into the files: notes and papers taken out
4483    // first, so that a file renamed by its case comes in the same exchange.
4484    let mut by_file: BTreeMap<String, Vec<(String, Option<String>)>> = BTreeMap::new();
4485    for (key, (value, _, _)) in &winners {
4486        by_file.entry(file_of(key).to_string()).or_default().push((key.clone(), value.clone()));
4487    }
4488    let mut order: Vec<&String> = by_file.keys().collect();
4489    order.sort_by_key(|file| !(file.starts_with(FILES) && by_file[*file].first().is_some_and(|(_, value)| value.is_none())));
4490    let mut written_keys: BTreeSet<String> = BTreeSet::new();
4491    // What was written, by key: what the next look compares with, whatever happens to the file after.
4492    let mut written_hashes: BTreeMap<String, String> = BTreeMap::new();
4493    // Files written: as they are now (a note, a paper: read next time only if it changes), or to read again.
4494    let mut written_files: BTreeMap<String, Option<Stat>> = BTreeMap::new();
4495    // Files sealed apart already as the others' change says: known, nothing written.
4496    let mut settled: BTreeSet<String> = BTreeSet::new();
4497    let history = crate::history::root(sharing.memory);
4498    let mut kept = false;
4499    let mut names: Option<Names> = None;
4500    let receiving = Receiving { sharing, history: &history, now_ms, clock };
4501    // Sealed files the others' changes name: not here yet (their content's hash and size), or here.
4502    let mut wanted: BTreeMap<String, (String, u64)> = BTreeMap::new();
4503    let mut there: BTreeSet<String> = BTreeSet::new();
4504    for file in order {
4505        let changes = &by_file[file];
4506        let Some((store, path)) = locate(stores, file) else {
4507            if file.starts_with(FILES) {
4508                *outcome.waits.entry("no-store".into()).or_default() += 1;
4509            }
4510            continue;
4511        };
4512        if matches!(store.shape, Shape::Files) {
4513            // Not read this time, too big here, in a folder that cannot be read
4514            // or emptied of a sudden, or a hurried exchange waits: left waiting.
4515            let Some((key, value)) = changes.first() else { continue };
4516            let why = if !reads(store) { "not-read" } else if hurried() { "hurried" } else if found.is_unknown(file) { "unknown" } else { "" };
4517            if !why.is_empty() {
4518                *outcome.waits.entry(why.into()).or_default() += 1;
4519                continue;
4520            }
4521            let reference = value.as_deref().and_then(|v| serde_json::from_str::<Reference>(v).ok());
4522            let blob = reference.as_ref().map(|r| crate::blobs::name(sharing.key, &r.h)).unwrap_or_default();
4523            // Could not be written lately (a full disk, a name a folder takes here): tried again after a while, not at every exchange.
4524            if memory.sealed.failing.get(key).is_some_and(|(failed, again, _)| *failed == blob && now_ms < *again) {
4525                *outcome.waits.entry("backoff".into()).or_default() += 1;
4526                continue;
4527            }
4528            if !writable(store, &path, sharing) {
4529                outcome.problems.push(format!("share-refused:{file}"));
4530                continue;
4531            }
4532            let names = names.get_or_insert_with(|| Names::new(&memory.entries, &found));
4533            if value.is_some() && names.clash(file) {
4534                outcome.problems.push(format!("share-name-clash:{file}"));
4535                continue;
4536            }
4537            let base = bases.get(key).map_or("", String::as_str);
4538            let seen = |h: &str| h == base || named.get(key).is_some_and(|n| n.contains(h));
4539            let back_off = |failing: &mut BTreeMap<String, (String, i64, u32)>| {
4540                let tried = failing.get(key).map_or(0, |(_, _, n)| *n);
4541                let wait = [MINUTE, 10 * MINUTE, HOUR, 6 * HOUR][(tried as usize).min(3)];
4542                failing.insert(key.clone(), (blob.clone(), now_ms + wait, tried + 1));
4543            };
4544            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);
4545            // Its sealed file neither in the folder nor fetched beside it: waited for (`note_wanted`); else no longer.
4546            match (&came, &reference) {
4547                (Err(crate::blobs::Fault::Missing), Some(reference)) => {
4548                    wanted.insert(blob.clone(), (reference.h.clone(), reference.s));
4549                }
4550                (_, Some(_)) => {
4551                    there.insert(blob.clone());
4552                }
4553                _ => {}
4554            }
4555            match came {
4556                Ok(Received::AlreadySo) => {
4557                    settled.insert(key.clone());
4558                    memory.sealed.missing.remove(key);
4559                    memory.sealed.failing.remove(key);
4560                }
4561                Ok(Received::Later) => *outcome.waits.entry("later".into()).or_default() += 1,
4562                Ok(Received::Written { stat, copy, kept: into_history }) => {
4563                    kept |= into_history;
4564                    outcome.written.insert(store.name.clone());
4565                    outcome.received += 1;
4566                    written_keys.insert(key.clone());
4567                    written_hashes.insert(key.clone(), stat.as_ref().and_then(|s| s.entries.first()).map(|(_, h)| h.clone()).unwrap_or_default());
4568                    names.written(file, stat.is_some());
4569                    written_files.insert(file.clone(), stat);
4570                    memory.sealed.missing.remove(key);
4571                    memory.sealed.failing.remove(key);
4572                    if let Some(copy) = copy {
4573                        outcome.problems.push(format!("{}:{copy}", if value.is_some() { "share-conflict" } else { "share-conflict-gone" }));
4574                    }
4575                }
4576                // Not here yet: it comes with the sync app, or fetched from the
4577                // folder's server (`remote::fetch_wanted`), and is written then; said after a day.
4578                Err(crate::blobs::Fault::Missing) => {
4579                    *outcome.waits.entry("no-blob".into()).or_default() += 1;
4580                    if now_ms - *memory.sealed.missing.entry(key.clone()).or_insert(now_ms) > DAY {
4581                        outcome.problems.push(format!("share-missing:{file}"));
4582                    }
4583                }
4584                Err(crate::blobs::Fault::Broken) => outcome.problems.push(format!("share-damaged:{file}")),
4585                Err(crate::blobs::Fault::Room) => {
4586                    outcome.problems.push(format!("share-no-room:{file}"));
4587                    back_off(&mut memory.sealed.failing);
4588                }
4589                Err(crate::blobs::Fault::Local(e)) => {
4590                    outcome.problems.push(e);
4591                    back_off(&mut memory.sealed.failing);
4592                }
4593            }
4594            continue;
4595        }
4596        if found.unknown.contains(file) && !matches!(store.shape, Shape::Whole) && path.exists() {
4597            // Half written, or written by hand and broken: left as it is.
4598            outcome.problems.push(format!("share-unreadable:{}", path.display()));
4599            continue;
4600        }
4601        if store.folder && !writable(store, &path, sharing) {
4602            outcome.problems.push(format!("share-refused:{file}"));
4603            continue;
4604        }
4605        let entries: Vec<(&str, Option<&str>)> = changes.iter().map(|(key, value)| (entry_of(key), value.as_deref())).collect();
4606        // The file as it was, kept to be put back; never a log of calls, which
4607        // has nothing to put back and leaves every device after its month.
4608        let keep = || {
4609            if calls_store(file) {
4610                return Ok(());
4611            }
4612            kept = true;
4613            crate::history::keep(&history, store.part, file, &path, now_ms)
4614        };
4615        match write_entries(store, &path, &entries, keep) {
4616            Ok(hashes) => {
4617                outcome.written.insert(store.name.clone());
4618                outcome.received += changes.len();
4619                for (key, _) in changes {
4620                    written_keys.insert(key.clone());
4621                    written_hashes.insert(key.clone(), hashes.get(entry_of(key)).cloned().unwrap_or_default());
4622                }
4623                written_files.insert(file.clone(), None);
4624            }
4625            Err(e) => outcome.problems.push(e),
4626        }
4627    }
4628    for (key, (value, clock, computer)) in winners {
4629        if written_keys.contains(&key) || settled.contains(&key) {
4630            let h = written_hashes.get(&key).cloned().unwrap_or_else(|| value.as_deref().map(|v| value_hash(&key, v)).unwrap_or_default());
4631            memory.entries.insert(key, Known { c: clock, w: computer, h, b: String::new() });
4632        } else {
4633            if let Some(base) = bases.remove(&key).filter(|b| !b.is_empty()) {
4634                memory.sealed.bases.insert(key.clone(), base);
4635            }
4636            if let Some(contents) = named.remove(&key) {
4637                memory.sealed.named.insert(key.clone(), contents);
4638            }
4639            memory.pending.insert(key, (value, clock, computer));
4640        }
4641    }
4642    // A session whose id was kept here as format 1 changed it (`translate`):
4643    // sent again from here, under that id, and the name a device holding none
4644    // of it would give taken out, so that every device, one joining later
4645    // too, holds it once.
4646    for (name, fallback) in again.into_iter().filter(|(name, _)| written_keys.contains(name)) {
4647        let value = value_of(stores, &name);
4648        let clock = memory.tick(now_ms);
4649        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() });
4650        out.push((name.clone(), value, clock, String::new()));
4651        if fallback != name {
4652            let clock = memory.tick(now_ms);
4653            memory.entries.insert(fallback.clone(), Known { c: clock, w: sharing.computer.to_string(), h: String::new(), b: String::new() });
4654            out.push((fallback, None, clock, String::new()));
4655        }
4656    }
4657    // What the next look compares with: the files as found, those written
4658    // here as they are now (notes and papers), or to read again (the rest:
4659    // small). What was written is known by what was written (`written_hashes`):
4660    // a change made here after it is a change made here, sent.
4661    let mut files = found.files.clone();
4662    for (file, stat) in written_files {
4663        match stat {
4664            Some(stat) => files.insert(file, stat),
4665            None => files.remove(&file),
4666        };
4667    }
4668
4669    // Joining, the whole sharing or a store: what only this computer holds goes out.
4670    if !memory.joined || !joining.is_empty() {
4671        for (key, h) in &found.hashes {
4672            if memory.entries.contains_key(key) || memory.pending.contains_key(key) || (memory.joined && !joins(key)) || (key.starts_with(FILES) && hurried()) || holds(key) {
4673                continue;
4674            }
4675            let Some(value) = value(key) else { continue };
4676            if !seal_apart(sharing, stores, key, &value, &mut memory.sealed, &mut outcome.problems, now_ms) {
4677                continue;
4678            }
4679            let clock = memory.tick(found.changed.get(file_of(key)).copied().unwrap_or(now_ms).min(now_ms));
4680            memory.entries.insert(key.clone(), Known { c: clock, w: sharing.computer.to_string(), h: h.clone(), b: String::new() });
4681            out.push((key.clone(), Some(value), clock, String::new()));
4682        }
4683        memory.joined = true;
4684    }
4685
4686    // Older copies put back by hand: the version last seen here comes back,
4687    // the copy kept beside, said; one that cannot come back now (not here
4688    // yet) is held, its copy not sent.
4689    for key in older.into_iter().filter(|key| !written_keys.contains(key)) {
4690        let file = file_of(&key).to_string();
4691        let (Some((store, path)), Some(known), Some(before)) = (locate(stores, &file), memory.entries.get(&key), memory.files.get(&file).cloned()) else { continue };
4692        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();
4693        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) };
4694        match came {
4695            Ok(Received::Written { stat, copy, kept: into_history }) => {
4696                kept |= into_history;
4697                outcome.problems.push(format!("share-older-copy:{}", copy.unwrap_or_else(|| shown(&file).to_string())));
4698                if let Some(stat) = stat {
4699                    files.insert(file, stat);
4700                }
4701            }
4702            _ => {
4703                outcome.problems.push(format!("share-older-copy:{}", shown(&file)));
4704                files.insert(file, before);
4705            }
4706        }
4707    }
4708
4709    outcome.sent = out.len();
4710    outcome.pending = memory.pending.len();
4711    if memory.round == 0 {
4712        memory.round = 1;
4713    }
4714    // This computer's file cut short, or gone: a sync app put back an older copy
4715    // (eDrive keeps a file's old content when only its version changed, and may
4716    // send it back). What it said past that point is lost for whoever had not
4717    // read it: a new file opens on every entry this computer has the last word on.
4718    let own = |memory: &Memory| std::fs::metadata(round_file(sharing.folder, sharing.computer, memory.round)).map_or(0, |m| m.len());
4719    if memory.own_size > own(&memory) || restate {
4720        if !restate {
4721            outcome.problems.push("share-own-cut".into());
4722        }
4723        new_round(sharing, &mut memory, stores, &found, &hurried, now_ms, true)?;
4724        memory.round_base = own(&memory);
4725    }
4726    // Past `SEGMENT`, what comes goes into a new round continuing this one,
4727    // nothing restated: the file sent at each change stays small.
4728    if !out.is_empty() && own(&memory) >= SEGMENT {
4729        if memory.full_round == 0 {
4730            memory.full_round = memory.round;
4731        }
4732        // Said on its first line: a reader enters it once it read this round to its last line (`Turn`).
4733        memory.continues = memory.seq;
4734        memory.round += 1;
4735        memory.seq = 0;
4736        memory.round_base = 0;
4737    }
4738    let before = own(&memory);
4739    append(sharing, &mut memory, &out)?;
4740    memory.grown += own(&memory).saturating_sub(before);
4741    // The first round holds all this computer had when it joined: a full one.
4742    if memory.full_round == 0 && memory.round == 1 {
4743        memory.full_round = 1;
4744        memory.grown = 0;
4745    }
4746    // When each part last sent and received a change, for the window.
4747    let part_of = |key: &str| locate(stores, file_of(key)).map(|(store, _)| store.part);
4748    for part in out.iter().filter_map(|(key, ..)| part_of(key)) {
4749        memory.traffic.entry(part.to_string()).or_default().0 = now_ms / 1000;
4750    }
4751    for part in written_keys.iter().filter_map(|key| part_of(key)) {
4752        memory.traffic.entry(part.to_string()).or_default().1 = now_ms / 1000;
4753    }
4754    // Now and then, the sealed files this computer's records point to looked for (`look_after_sealed`).
4755    if sharing.files && !hurried() && now_ms - memory.checked >= 10 * MINUTE {
4756        look_after_sealed(sharing, stores, &mut memory, &found, &hurried, &mut outcome.problems, now_ms);
4757        memory.checked = now_ms;
4758    }
4759    note_wanted(sharing, &memory, &wanted, &there, now_ms);
4760    // Damaged sealed files no change waits for any more are forgotten.
4761    if !memory.sealed.damaged.is_empty() {
4762        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();
4763        memory.sealed.damaged.retain(|name, _| waited.contains(name));
4764    }
4765    // The files this computer sealed in the folder that no record points to
4766    // any more go, in time; never one a record named lately.
4767    memory.sealed.named_at.retain(|_, at| now_ms - *at < crate::blobs::UNUSED_DAYS * DAY);
4768    if !memory.sealed.mine.is_empty() {
4769        let used: BTreeSet<String> = memory
4770            .entries
4771            .iter()
4772            .filter(|(key, known)| key.starts_with(FILES) && !known.h.is_empty())
4773            .map(|(_, known)| known.h.clone())
4774            .chain(memory.pending.iter().filter(|(key, _)| key.starts_with(FILES)).filter_map(|(key, (value, _, _))| value.as_deref().map(|v| value_hash(key, v))))
4775            .map(|h| crate::blobs::name(sharing.key, &h))
4776            .chain(memory.sealed.named_at.keys().cloned())
4777            .collect();
4778        prune_tables(sharing.folder, &mut memory.sealed, &used);
4779        let mut last: BTreeMap<String, i64> = memory.sealed.mine.iter().map(|(name, (at, _))| (name.clone(), *at)).collect();
4780        crate::blobs::sweep(sharing.folder, &mut last, &used, now_ms);
4781        memory.sealed.mine.retain(|name, (at, _)| last.get(name).map(|kept| *at = *kept).is_some());
4782    }
4783    // The history held within bounds, and what a crash left half written there cleaned: after a writing, or hourly.
4784    if kept || now_ms - memory.tidied >= HOUR {
4785        crate::history::prune(&history, now_ms, crate::history::cap(&history), &|_, file| locate(stores, file).is_some_and(|(_, path)| path.exists()));
4786        memory.tidied = now_ms;
4787    }
4788    // What a crash left half written among the folder's sealed files, once a day (`Memory::cleaned`).
4789    if now_ms - memory.cleaned >= DAY {
4790        clean_leftovers(&sharing.folder.join("blobs"));
4791        memory.cleaned = now_ms;
4792    }
4793    // A full round, past `ROUND_SIZE` appended since the last: then the
4794    // rounds before it can go. Waits while the connection is metered or slow,
4795    // and while a bulk import goes on (`set_importing`).
4796    let waits = if importing(sharing.memory) { memory.grown <= IMPORT_ROUND_SIZE } else { frugal(sharing.memory) && memory.grown <= FRUGAL_ROUND_SIZE };
4797    // Another device asks for one (it lost some of this computer's records:
4798    // `Seen::full`): owed.
4799    if !memory.owed && asked_for_full(sharing, &memory, now_ms) {
4800        memory.owed = true;
4801    }
4802    // One owed (a round after lost lines that could not restate all, R7, or
4803    // asked for): as soon as all reads again, and not while the connection is
4804    // metered or slow, nor while a bulk import goes on, as a full round past
4805    // `ROUND_SIZE` waits (on a phone always on mobile data, a full round
4806    // restates every text it holds: review of 10 October 2026, third pass).
4807    // The others' doubt holds meanwhile. Metered, it waits `OWED_METERED` of
4808    // exchanges at most (counted as `AWAITED` is), then goes all the same: a
4809    // phone always on mobile data, which marks most doses, never leaves the
4810    // others in doubt for days; past the growth those waits allow, it comes
4811    // sooner.
4812    let metered = frugal(sharing.memory) && !importing(sharing.memory);
4813    if memory.owed && metered {
4814        if memory.owed_last > 0 {
4815            memory.owed_waited += (now_ms - memory.owed_last).clamp(0, AWAIT_STEP);
4816        }
4817        memory.owed_last = now_ms;
4818    }
4819    let thrifty = importing(sharing.memory) || (metered && memory.owed_waited < OWED_METERED);
4820    let owed = memory.owed && !thrifty && restatement(sharing, &memory, stores, &found).1 == 0;
4821    if (memory.grown > ROUND_SIZE && !waits) || owed {
4822        new_round(sharing, &mut memory, stores, &found, &hurried, now_ms, false)?;
4823        memory.round_base = own(&memory);
4824    }
4825    memory.own_size = own(&memory);
4826    memory.files = files;
4827    // Where each store was read; one not read this time where it was.
4828    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();
4829    memory.places = places;
4830    // What you said to take out went with this exchange; what waited and no longer does is forgotten.
4831    memory.sealed.confirmed.retain(|store| !stores.iter().any(|s| s.name == *store && reads(s)));
4832    memory.sealed.missing.retain(|key, _| memory.pending.contains_key(key));
4833    memory.sealed.failing.retain(|key, _| memory.pending.contains_key(key));
4834    let seen_due = write_seen(sharing, &memory, &all, now_ms);
4835    // What the devices' registry says of this export (`devices`).
4836    // The doses' part held (a newer format met): this device's answers wait
4837    // here, unsent, so its export claims nothing (`Entry::exported`): the
4838    // others then doubt rather than know (docs/health.md, "Knowing").
4839    outcome.looked = if memory.newer.contains_key("health") { 0 } else { clock };
4840    outcome.wrote = memory.joined.then_some((memory.round.max(1), memory.seq));
4841    remove_old_rounds(sharing, &memory, now_ms);
4842    // Still on format 1: what each whole list holds now, and what one took out (`Floor::removed` at the switch).
4843    if memory.format < 2 {
4844        note_whole_lists(originals, &mut memory);
4845    }
4846    // Everything settled, nothing said: the next exchanges have nothing to do
4847    // while nothing they read changes, until the hour's tidying or this
4848    // device's notes are due again (`quiet`). Changes of others' that wait
4849    // only for notes or papers (not read here) wait there as well.
4850    let settled = outcome.problems.is_empty()
4851        && memory.joined
4852        && !memory.switch
4853        && memory.newer.is_empty()
4854        && memory.waiting.is_empty()
4855        && memory.held.is_empty()
4856        && memory.emptied.is_empty()
4857        && memory.sealed.gone.is_empty()
4858        && memory.sealed.failing.is_empty()
4859        && memory.sealed.missing.is_empty()
4860        && memory.waiting_for.is_empty()
4861        && !memory.owed
4862        && memory.pending.keys().all(|key| locate(stores, file_of(key)).is_some_and(|(store, _)| !reads(store)));
4863    let until = (now_ms + HOUR).min(memory.tidied + HOUR).min(memory.cleaned + DAY).min(seen_due);
4864    let (round, own_size) = (memory.round, memory.own_size);
4865    // Drafts' versions kept while their draft is known here.
4866    let drafts: BTreeSet<String> = memory.drafts.keys().filter(|key| !memory.entries.contains_key(*key) && !memory.pending.contains_key(*key)).cloned().collect();
4867    for key in drafts {
4868        memory.drafts.remove(&key);
4869    }
4870    memory.save(sharing.memory)?;
4871    if let Some((standing, _)) = standing.filter(|(standing, taken)| settled && (standing.newest / 1_000_000) as i64 + settling_ms() < *taken) {
4872        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 };
4873        if let Ok(mut all) = QUIET.lock() {
4874            all.insert(sharing.memory.to_path_buf(), quiet);
4875        }
4876    }
4877    memory.keep(sharing.memory);
4878    Ok(outcome)
4879}
4880
4881// ---------------------------------------------------------------- nothing to do
4882
4883/// What an exchange reads, as it stands before it reads anything: the files
4884/// of the stores it reads (each by its stamp), the folder's top and what was
4885/// fetched from the server beside it (`remote::overlay`: the pull writes there
4886/// only what changed on the server), the other devices' rounds and the seal
4887/// by their stamps, every other name there by itself, and what each other
4888/// device's entry says but for its exchanges' times (`devices::steady`). The
4889/// same at the next exchange, and that one has nothing to read (`quiet`).
4890#[derive(Debug, Clone, Default, PartialEq, Eq)]
4891struct Standing {
4892    sharing: (PathBuf, String, String),
4893    stores: Vec<(String, PathBuf)>,
4894    files: BTreeMap<PathBuf, FileStamp>,
4895    folder: BTreeMap<PathBuf, Option<FileStamp>>,
4896    entries: BTreeMap<String, String>,
4897    overlay: Option<PathBuf>,
4898    /// Full rounds waiting (`set_frugal`, `set_importing`), and this device in
4899    /// use (its notes then restated each quarter of an hour, `write_seen`).
4900    waits: (bool, bool),
4901    in_use: bool,
4902    /// The latest time among the files stamped (nanoseconds).
4903    newest: u64,
4904}
4905
4906impl Standing {
4907    fn now(sharing: &Sharing, stores: &[Store], reads: &dyn Fn(&Store) -> bool) -> Standing {
4908        let mut standing = Standing { sharing: (sharing.folder.to_path_buf(), sharing.computer.to_string(), hash(&B64.encode(sharing.key))), ..Standing::default() };
4909        for store in stores.iter().filter(|s| reads(s)) {
4910            standing.stores.push((store.name.clone(), store.path.clone()));
4911            stamp_below(&store.path, &mut standing.files);
4912        }
4913        let overlay = crate::remote::overlay(sharing.folder);
4914        for dir in std::iter::once(sharing.folder.to_path_buf()).chain(overlay.clone()) {
4915            for entry in std::fs::read_dir(&dir).into_iter().flatten().filter_map(Result::ok) {
4916                let name = entry.file_name().to_string_lossy().to_string();
4917                if name.starts_with('.') || entry.file_type().is_ok_and(|t| t.is_dir()) {
4918                    continue;
4919                }
4920                // This device's own files change only by its hand (their size checked apart, `quiet`).
4921                let stamped = !name.starts_with(sharing.computer) && (name.ends_with(".jsonl") || name == "seal.toml");
4922                standing.folder.insert(entry.path(), if stamped { entry.metadata().ok().map(|m| file_stamp(&m)) } else { None });
4923            }
4924        }
4925        let (entries, unread) = crate::devices::all(sharing.folder, sharing.key);
4926        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();
4927        standing.overlay = overlay;
4928        standing.waits = (frugal(sharing.memory), importing(sharing.memory));
4929        standing.in_use = crate::devices::in_use(sharing.memory);
4930        standing.newest = standing.files.values().chain(standing.folder.values().flatten()).map(|s| s.modified).max().unwrap_or(0);
4931        standing
4932    }
4933}
4934
4935/// Every file below `path` (or `path` itself), hidden ones left out, by its stamp.
4936fn stamp_below(path: &Path, out: &mut BTreeMap<PathBuf, FileStamp>) {
4937    let Ok(meta) = std::fs::metadata(path) else { return };
4938    if meta.is_file() {
4939        out.insert(path.to_path_buf(), file_stamp(&meta));
4940        return;
4941    }
4942    for entry in std::fs::read_dir(path).into_iter().flatten().filter_map(Result::ok) {
4943        if entry.file_name().to_string_lossy().starts_with('.') {
4944            continue;
4945        }
4946        stamp_below(&entry.path(), out);
4947    }
4948}
4949
4950/// An exchange that left everything settled (`exchange`): what it read, as
4951/// it stood (`Standing`); its time and until when the next may do nothing
4952/// (the hour's tidying, this device's notes due again); its memory's two
4953/// files as it wrote them; its own round and that file's size; what it said.
4954struct Quiet {
4955    standing: Standing,
4956    at: i64,
4957    until: i64,
4958    own: (Option<FileStamp>, Option<FileStamp>),
4959    round: u32,
4960    own_size: u64,
4961    wrote: Option<(u32, u64)>,
4962    pending: usize,
4963}
4964
4965/// The settled exchanges of this process, by the path of their memory.
4966static QUIET: std::sync::Mutex<BTreeMap<PathBuf, Quiet>> = std::sync::Mutex::new(BTreeMap::new());
4967
4968/// An exchange with nothing to do: the last one here left everything settled
4969/// (`Quiet`), and since then nothing it reads changed (`Standing`, noted at
4970/// `taken`, this computer's clock), neither its memory nor this device's own
4971/// file in the folder (a sync app putting back an older copy changes its
4972/// size), and nothing fell due. Its outcome is that one's: nothing sent,
4973/// nothing received, how far this device wrote, and its files looked at when
4974/// their stamps were: as they were, so every answer captured here before is
4975/// in its records (`Outcome::looked`). Else none, and the exchange runs.
4976fn quiet(sharing: &Sharing, standing: &Standing, taken: i64, now_ms: i64) -> Option<Outcome> {
4977    let mut all = QUIET.lock().ok()?;
4978    let quiet = all.get(sharing.memory)?;
4979    let own_size = std::fs::metadata(round_file(sharing.folder, sharing.computer, quiet.round)).map_or(0, |m| m.len());
4980    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;
4981    if !still {
4982        all.remove(sharing.memory);
4983        return None;
4984    }
4985    #[cfg(test)]
4986    QUIETED.with(|n| n.set(n.get() + 1));
4987    Some(Outcome { looked: taken, wrote: quiet.wrote, pending: quiet.pending, quiet: true, ..Outcome::default() })
4988}
4989
4990#[cfg(test)]
4991thread_local! {
4992    /// In this test's thread: the exchanges that had nothing to do (`quiet`),
4993    /// and the logs of lines read through (`lines_of`).
4994    pub(super) static QUIETED: std::cell::Cell<u32> = const { std::cell::Cell::new(0) };
4995    pub(super) static LINES_READ: std::cell::Cell<u32> = const { std::cell::Cell::new(0) };
4996}
4997
4998/// The whole lines of a computer's records from where they were left, read a
4999/// piece at a time (`READ_CHUNK`), one line held whole at most: a line longer
5000/// than any record (`LONGEST_LINE`: whoever wrote it) is given empty, a
5001/// broken line, and skipped to its end. A last line not ended yet is left for
5002/// the next exchange.
5003struct LineReader {
5004    file: std::fs::File,
5005    held: Vec<u8>,
5006    start: usize,
5007    /// Inside a line too long: its bytes so far, skipped.
5008    skipping: Option<u64>,
5009    end: bool,
5010}
5011
5012impl LineReader {
5013    fn new(file: std::fs::File) -> LineReader {
5014        LineReader { file, held: Vec::new(), start: 0, skipping: None, end: false }
5015    }
5016
5017    /// The next whole line and its length in the file.
5018    fn next(&mut self) -> Option<(Vec<u8>, u64)> {
5019        use std::io::Read;
5020        loop {
5021            if let Some(at) = self.held[self.start..].iter().position(|b| *b == b'\n') {
5022                let line = &self.held[self.start..=self.start + at];
5023                self.start += at + 1;
5024                return Some(match self.skipping.take() {
5025                    Some(skipped) => (Vec::new(), skipped + line.len() as u64),
5026                    None => (line.to_vec(), line.len() as u64),
5027                });
5028            }
5029            self.held.drain(..self.start);
5030            self.start = 0;
5031            if self.skipping.is_some() || self.held.len() > LONGEST_LINE {
5032                *self.skipping.get_or_insert(0) += self.held.len() as u64;
5033                self.held.clear();
5034            }
5035            if self.end {
5036                return None;
5037            }
5038            let mut piece = vec![0u8; READ_CHUNK];
5039            match self.file.read(&mut piece) {
5040                Ok(0) | Err(_) => self.end = true,
5041                Ok(n) => self.held.extend_from_slice(&piece[..n]),
5042            }
5043        }
5044    }
5045}
5046
5047/// A line of another computer's round found damaged here (it does not read,
5048/// or does not open), said and dated already (`lost_line`): its number, as
5049/// the line says it (one that does not open), or the one after the line met
5050/// before in its round (one that does not read). Met (`Memory::met`), so that
5051/// a round continuing this one is entered and the rest of that computer's
5052/// records flow; never counted as read (`read_n` stays before it): what that
5053/// computer wrote is not all known here until its next full round, and the
5054/// doses doubt until then, as they did before rounds turned aside (5fa4dd1:
5055/// review of 10 October 2026, S2-A). This device asks for that full round
5056/// (`Seen::full`).
5057fn lost_here(memory: &mut Memory, computer: &str, round: u32, n: Option<u64>) {
5058    let met = memory.met.get(computer).or_else(|| memory.read_n.get(computer)).copied();
5059    let n = n.unwrap_or(match met {
5060        Some((r, m)) if r == round => m + 1,
5061        _ => 1,
5062    });
5063    if met.is_none_or(|met| (round, n) > met) {
5064        memory.met.insert(computer.to_string(), (round, n));
5065    }
5066}
5067
5068/// A line of another computer's lost here, dated (`Memory::broken`): the
5069/// latest such date is kept, never an earlier one, so a doubt once said only
5070/// grows.
5071fn say_broken(memory: &mut Memory, computer: &str, at: i64) {
5072    let kept = memory.broken.entry(computer.to_string()).or_insert(at);
5073    *kept = (*kept).max(at);
5074}
5075
5076/// The first of another computer's rounds after `round` that opens full
5077/// (`Turn`): it restates all that computer held, so a reader waiting for the
5078/// end of an earlier round, which may never come (an older copy of it put
5079/// back), reads on from there. Each round's first line read alone.
5080fn later_full(sharing: &Sharing, computer: &str, rounds: &[u32], round: u32) -> Option<u32> {
5081    rounds.iter().copied().filter(|r| *r > round).find(|r| {
5082        // A round's first line never changes (records only grow): read once per process.
5083        let at = (sharing.folder.to_path_buf(), computer.to_string(), *r);
5084        if let Some(full) = FIRST_LINES.lock().ok().and_then(|seen| seen.get(&at).copied()) {
5085            return full;
5086        }
5087        let Ok(file) = open_round(sharing.folder, computer, *r) else { return false };
5088        let Some((line, _)) = LineReader::new(file).next() else { return false };
5089        let Some(record) = std::str::from_utf8(&line).ok().and_then(|text| serde_json::from_str::<Line>(text).ok()) else { return false };
5090        let Some(turn) = open(sharing.key, &bound(computer, *r, record.n, record.c), &record.s).and_then(|plain| serde_json::from_slice::<Turn>(&plain).ok()) else { return false };
5091        let full = record.n == 1 && turn.full;
5092        if let Ok(mut seen) = FIRST_LINES.lock() {
5093            seen.insert(at, full);
5094        }
5095        full
5096    })
5097}
5098
5099/// Whether each round's first line read by `later_full` opens a full round,
5100/// by folder, computer and round: a reader waiting for a round's end looks at
5101/// the later rounds once, not at each exchange.
5102static FIRST_LINES: std::sync::Mutex<BTreeMap<(PathBuf, String, u32), bool>> = std::sync::Mutex::new(BTreeMap::new());
5103
5104/// A line of another computer's round lost here (it does not read, or does
5105/// not open): when its computer wrote it, at the latest, in seconds (the
5106/// clock of the line after it, else now; never the line before it, which
5107/// may be days older: review of 10 October 2026, S2-A), for the doses'
5108/// doubt (`Memory::broken`). None when it was met before: a line after
5109/// it was read already (`read_n`), and this reading goes over the round again
5110/// (a store joining, a record rebuilt): said once, never stamped anew.
5111/// `good`: the last line of the round that opened in this reading, its
5112/// number and clock (none for where an earlier reading stopped).
5113fn lost_line(read_n: Option<(u32, u64)>, round: u32, good: Option<(u64, Option<u64>)>, next: Option<&[u8]>, now_ms: i64) -> Option<i64> {
5114    let before = good.map_or(0, |(n, _)| n);
5115    if read_n.is_some_and(|read| read >= (round, before + 1)) {
5116        return None;
5117    }
5118    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);
5119    Some(after.map_or(now_ms / 1000, |c| ((c >> 16) as i64 / 1000).min(now_ms / 1000)))
5120}
5121
5122/// The sealed files this computer's records point to, looked for now and
5123/// then: one gone from the folder (taken out by hand, by another device that
5124/// sealed it long ago and swept it, by the server) or not of the size it was
5125/// sealed at (cut, damaged on the way) is sealed again from the file here,
5126/// while the file still holds that content.
5127fn look_after_sealed(sharing: &Sharing, stores: &[Store], memory: &mut Memory, found: &Found, hurried: &dyn Fn() -> bool, problems: &mut Vec<String>, now_ms: i64) {
5128    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();
5129    for (key, h) in ours {
5130        if hurried() {
5131            return;
5132        }
5133        let name = crate::blobs::name(sharing.key, &h);
5134        let there = std::fs::metadata(sharing.folder.join("blobs").join(&name)).map_or(0, |m| m.len());
5135        if there > 0 && memory.sealed.mine.get(&name).is_none_or(|(_, size)| *size == there) {
5136            continue;
5137        }
5138        let Some((_, path)) = locate(stores, file_of(&key)) else { continue };
5139        match crate::blobs::put_again(sharing.folder, sharing.key, &path, &h) {
5140            Ok(size) => {
5141                memory.sealed.mine.insert(name, (now_ms, size));
5142            }
5143            Err(e) => problems.push(e),
5144        }
5145    }
5146}
5147
5148/// The names of the notes and papers here or known, as a storage that does not
5149/// tell case, nor how an accent is written, sees them (a phone's, a memory
5150/// card's, Windows', a Mac's): two it would take for one are never both written.
5151struct Names {
5152    by_folded: BTreeMap<String, String>,
5153}
5154
5155impl Names {
5156    fn new(entries: &BTreeMap<String, Known>, found: &Found) -> Names {
5157        let known = entries.iter().filter(|(key, known)| key.starts_with(FILES) && !known.h.is_empty()).map(|(key, _)| key);
5158        let here = found.hashes.keys().filter(|key| key.starts_with(FILES));
5159        Names { by_folded: known.chain(here).map(|key| (folded(file_of(key)), file_of(key).to_string())).collect() }
5160    }
5161
5162    /// Whether another file of that name, as such a storage sees it, is here or known.
5163    fn clash(&self, file: &str) -> bool {
5164        self.by_folded.get(&folded(file)).is_some_and(|other| other != file)
5165    }
5166
5167    fn written(&mut self, file: &str, there: bool) {
5168        let name = folded(file);
5169        if there {
5170            self.by_folded.insert(name, file.to_string());
5171        } else if self.by_folded.get(&name).is_some_and(|other| other == file) {
5172            self.by_folded.remove(&name);
5173        }
5174    }
5175}
5176
5177/// A name composed one way (NFC), its case folded.
5178fn folded(name: &str) -> String {
5179    icu_normalizer::ComposingNormalizerBorrowed::new_nfc().normalize(name).to_lowercase()
5180}
5181
5182/// The sealed files this device waits for, said for the pull to fetch
5183/// (`blobs::Wanted`, `remote::fetch_wanted`): those this exchange found
5184/// neither in the folder nor fetched beside it (`missing`: their content's
5185/// hash and size) join; those it found (`there`), and those no change waits
5186/// for any more (written, or another change came), leave. Those of changes it
5187/// did not look at (not read this time, hurried) stay. Nothing read nor
5188/// written while nothing waits.
5189fn note_wanted(sharing: &Sharing, memory: &Memory, missing: &BTreeMap<String, (String, u64)>, there: &BTreeSet<String>, now_ms: i64) {
5190    if missing.is_empty() && !crate::blobs::Wanted::path(sharing.memory).exists() {
5191        return;
5192    }
5193    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();
5194    let _ = crate::blobs::Wanted::change(sharing.memory, |wanted| {
5195        wanted.blobs.retain(|name, _| waited.contains(name) && !there.contains(name));
5196        for (name, (hash, size)) in missing.iter().filter(|(name, _)| waited.contains(*name)) {
5197            wanted.blobs.entry(name.clone()).or_insert_with(|| crate::blobs::Want { hash: hash.clone(), size: *size, since: now_ms / 1000, ..Default::default() });
5198        }
5199    });
5200}
5201
5202/// What came of another device's change to a file sealed apart.
5203enum Received {
5204    /// The file here holds it already.
5205    AlreadySo,
5206    /// Written: the file as it is now, for the next look to compare with
5207    /// (none once taken out); the copy kept beside of a version changed here
5208    /// too, if one was made; whether a version went into the history.
5209    Written { stat: Option<Stat>, copy: Option<String>, kept: bool },
5210    /// The file changed here since it was looked at: left for the next
5211    /// exchange, which sends that change, both kept.
5212    Later,
5213}
5214
5215/// What writing the others' changes needs.
5216struct Receiving<'a> {
5217    sharing: &'a Sharing<'a>,
5218    history: &'a Path,
5219    now_ms: i64,
5220    /// This computer's clock (milliseconds): a file is never dated after it.
5221    clock: i64,
5222}
5223
5224#[cfg(test)]
5225thread_local! {
5226    /// Run just before a received file takes its place, in this test's thread: an edit made then.
5227    static BEFORE_WRITING: std::cell::RefCell<Option<Box<dyn Fn(&Path)>>> = std::cell::RefCell::new(None);
5228}
5229
5230/// Another device's change to a file sealed apart, written here: its content
5231/// opened from the folder beside the file and checked, then put in its place,
5232/// the file as it was kept first (`history`: by reference when a record named
5233/// that content, else copied). A file here that is neither what that change
5234/// replaced nor a content the others' records name (`seen`) changed here too:
5235/// it is kept beside, under a name that says so (its path in the folder
5236/// returned), never lost. A file changed here since it was looked at
5237/// (`gathered`) is not written over. Nothing is written without room for it,
5238/// for the copy kept, and some left (`disk::kept_free`).
5239#[allow(clippy::too_many_arguments)]
5240fn 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> {
5241    use crate::blobs::Fault;
5242    let sharing = how.sharing;
5243    let wanted = value.map(serde_json::from_str::<Reference>).transpose().map_err(|_| Fault::Broken)?;
5244    // What is here: as gathered, else (written since, or not read) read now.
5245    let current = match current {
5246        Some(here) => Some(here.to_string()),
5247        None if path.is_file() => crate::blobs::hash_file(path).ok().map(|(h, _)| h),
5248        None => None,
5249    };
5250    match (&wanted, &current) {
5251        (Some(wanted), Some(here)) if wanted.h == *here => return Ok(Received::AlreadySo),
5252        (None, None) => return Ok(Received::AlreadySo),
5253        _ => {}
5254    }
5255    let local = |e: std::io::Error| Fault::Local(format!("{}: {e}", path.display()));
5256    let size_here = std::fs::metadata(path).map_or(0, |m| m.len());
5257    let diverged = current.as_deref().is_some_and(|here| !seen(here));
5258    // The version here kept by reference when a record named it: its content is in the folder.
5259    let by_reference = current.as_deref().is_some_and(|here| known.is_some_and(|k| k.h == here) || seen(here));
5260    let room = wanted.as_ref().map_or(0, |w| w.s) + if by_reference { 0 } else { size_here } + if diverged { size_here } else { 0 };
5261    if wanted.as_ref().is_some_and(|w| w.s > crate::blobs::LARGEST) {
5262        return Err(Fault::Broken);
5263    }
5264    if !crate::disk::fits(path, room) {
5265        return Err(Fault::Room);
5266    }
5267    let temporary = temporary(path);
5268    if let Some(wanted) = &wanted {
5269        // The folder's copy found damaged before, and the same copy still: not
5270        // opened again (the one fetched from the server beside it is, `blobs::get`).
5271        let blob = crate::blobs::name(sharing.key, &wanted.h);
5272        let copy = std::fs::metadata(crate::blobs::path(sharing.folder, sharing.key, &wanted.h)).ok().map(|m| crate::blobs::fingerprint(&m));
5273        let known_damaged = copy.is_some() && damaged.get(&blob) == copy.as_ref();
5274        if let Some(parent) = path.parent() {
5275            private_dirs(parent).map_err(local)?;
5276        }
5277        match crate::blobs::get_unless(sharing.folder, sharing.key, &wanted.h, &temporary, known_damaged) {
5278            Err(Fault::Broken) => {
5279                damaged.extend(copy.map(|copy| (blob, copy)));
5280                return Err(Fault::Broken);
5281            }
5282            Err(fault) => return Err(fault),
5283            Ok(()) => {
5284                damaged.remove(&blob);
5285            }
5286        }
5287    }
5288    let placed = (|| {
5289        #[cfg(test)]
5290        BEFORE_WRITING.with(|hook| {
5291            if let Some(hook) = hook.borrow().as_ref() {
5292                hook(path);
5293            }
5294        });
5295        // Changed here since it was looked at (an editor, another program): not
5296        // written over; the next exchange sends that change first.
5297        let same = match (std::fs::symlink_metadata(path).ok(), gathered) {
5298            (None, None) => true,
5299            (Some(now), Some(then)) => now.len() == then.size && modified_ns(&now) == then.modified,
5300            _ => false,
5301        };
5302        if !same {
5303            return Ok(None);
5304        }
5305        let copy = match &current {
5306            Some(here) if diverged => Some(conflict_copy(path, here, known, how.now_ms).map_err(Fault::Local)?),
5307            _ => None,
5308        };
5309        let mut kept = false;
5310        if let Some(here) = current.as_deref()
5311            && path.exists()
5312        {
5313            let kept_now = if by_reference { crate::history::keep_sealed(how.history, store.part, file, here, size_here, how.now_ms) } else { crate::history::keep(how.history, store.part, file, path, how.now_ms) };
5314            kept_now.map_err(|e| if e.starts_with("share-no-room") { Fault::Room } else { Fault::Local(e) })?;
5315            kept = true;
5316        }
5317        let stat = match &wanted {
5318            Some(wanted) => {
5319                // The spam filter's table it replaces, kept beside when this Sioul can use it (`table.prev.bin`).
5320                if store.name == SPAM_TABLE {
5321                    sioul_core::spam::table::keep_previous(path);
5322                }
5323                std::fs::rename(&temporary, path).map_err(local)?;
5324                // Its time as where it was made, never after this computer's clock (another's may be ahead).
5325                if let Ok(written) = std::fs::File::options().write(true).open(path) {
5326                    let _ = written.set_modified(std::time::UNIX_EPOCH + std::time::Duration::from_millis(wanted.t.min(how.clock).max(0) as u64));
5327                }
5328                let now = std::fs::metadata(path).map_err(local)?;
5329                Some(Stat { size: now.len(), modified: modified_ns(&now), entries: vec![(String::new(), wanted.h.clone())], seen: how.clock })
5330            }
5331            None => {
5332                match std::fs::remove_file(path) {
5333                    Err(e) if e.kind() != std::io::ErrorKind::NotFound => return Err(local(e)),
5334                    _ => {}
5335                }
5336                // Its folders, left empty, go too.
5337                let mut dir = path.parent();
5338                while let Some(folder) = dir.filter(|d| *d != store.path && d.starts_with(&store.path)) {
5339                    if std::fs::remove_dir(folder).is_err() {
5340                        break;
5341                    }
5342                    dir = folder.parent();
5343                }
5344                None
5345            }
5346        };
5347        Ok(Some((stat, copy, kept)))
5348    })();
5349    if !matches!(placed, Ok(Some(_))) {
5350        let _ = std::fs::remove_file(&temporary);
5351    }
5352    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("/"));
5353    Ok(match placed? {
5354        Some((stat, copy, kept)) => Received::Written { stat, copy: copy.map(in_store), kept },
5355        None => Received::Later,
5356    })
5357}
5358
5359/// The name of the copy kept beside a file two devices changed, the same on
5360/// every device whatever its language: "lease (conflict 2026-10-05 16.05)",
5361/// dated (in this computer's time zone) by when the version it holds was made.
5362pub fn conflict_name(stem: &str, at_ms: i64) -> String {
5363    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();
5364    format!("{stem} (conflict {when})")
5365}
5366
5367/// A file two devices changed, copied beside it before the other's version
5368/// takes its place, under `conflict_name`: dated by when the version it holds
5369/// was made, so that every device holding that version names it alike and one
5370/// copy travels, not one each. Returns where it is.
5371fn conflict_copy(path: &Path, here: &str, known: Option<&Known>, now_ms: i64) -> Result<PathBuf, String> {
5372    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);
5373    let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
5374    let (stem, extension) = match name.rsplit_once('.') {
5375        Some((stem, extension)) if !stem.is_empty() => (stem.to_string(), format!(".{extension}")),
5376        _ => (name.clone(), String::new()),
5377    };
5378    let named = conflict_name(&stem, at);
5379    let mut n = 1;
5380    loop {
5381        let copy = path.with_file_name(if n == 1 { format!("{named}{extension}") } else { format!("{named} {n}{extension}") });
5382        if !copy.exists() {
5383            let temporary = temporary(&copy);
5384            return copy_private(path, &temporary).and_then(|()| std::fs::rename(&temporary, &copy)).map(|()| copy.clone()).map_err(|e| {
5385                let _ = std::fs::remove_file(&temporary);
5386                format!("{}: {e}", copy.display())
5387            });
5388        }
5389        // Kept already: another device's copy of the same version came first.
5390        if crate::blobs::hash_file(&copy).is_ok_and(|(h, _)| h == here) {
5391            return Ok(copy);
5392        }
5393        n += 1;
5394    }
5395}
5396
5397/// Whether a file may be written where a record says: never through a link
5398/// below its store's folder (a link to elsewhere would carry the write out of
5399/// it), nor over a link, nor inside the sharing folder (it would travel in
5400/// plain) or Sioul's own state, but for the stores of that state itself
5401/// (`state/…`: the shield's answers, local lists' state, the spam filter's
5402/// label logs), which live there by design, each in its own folder.
5403fn writable(store: &Store, path: &Path, sharing: &Sharing) -> bool {
5404    writable_in(store, path, Some(sharing.folder), sharing.memory)
5405}
5406
5407fn writable_in(store: &Store, path: &Path, folder: Option<&Path>, memory: &Path) -> bool {
5408    let Ok(relative) = path.strip_prefix(&store.path) else { return false };
5409    let mut at = store.path.clone();
5410    for part in relative.components() {
5411        at.push(part);
5412        match std::fs::symlink_metadata(&at) {
5413            Ok(meta) if meta.file_type().is_symlink() => return false,
5414            Ok(_) => {}
5415            Err(_) => break,
5416        }
5417    }
5418    // Where it would land: the deepest of its folders that is there.
5419    let Some(there) = path.ancestors().skip(1).find(|p| p.exists()).and_then(|p| p.canonicalize().ok()) else { return true };
5420    let inside = |root: &Path| root.canonicalize().is_ok_and(|root| there.starts_with(root));
5421    let state = memory.parent().and_then(Path::parent);
5422    // A store of Sioul's own state writes in its own folder there (the path was
5423    // checked above: no link, nothing outside it); every other store, never in that state.
5424    let own_state = store.name.starts_with("state/") && state.is_some_and(|state| store.path.starts_with(state));
5425    !folder.is_some_and(inside) && (own_state || !state.is_some_and(inside))
5426}
5427
5428/// Names Windows keeps for its devices ("aux.md", "COM1.txt"), and names
5429/// ending in a dot or a space, which it cuts.
5430fn kept_by_windows(name: &str) -> bool {
5431    let stem = name.split('.').next().unwrap_or(name).to_ascii_uppercase();
5432    let numbered = (stem.starts_with("COM") || stem.starts_with("LPT")) && stem.len() == 4 && matches!(stem.as_bytes()[3], b'1'..=b'9');
5433    matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL") || numbered || name.ends_with('.') || name.ends_with(' ')
5434}
5435
5436/// The store and the file a key's file part names.
5437fn locate<'a>(stores: &'a [Store], file: &str) -> Option<(&'a Store, PathBuf)> {
5438    if let Some(store) = stores.iter().find(|s| !s.folder && s.name == file) {
5439        return Some((store, store.path.clone()));
5440    }
5441    let store = stores.iter().filter(|s| s.folder && file.starts_with(s.name.as_str())).max_by_key(|s| s.name.len())?;
5442    let relative = &file[store.name.len()..];
5443    // Never outside its folder, on Windows too, where "\" also separates and
5444    // "C:" starts another place.
5445    if relative.is_empty() || relative.split('/').any(|part| part.is_empty() || part == ".." || part == "." || part.contains(['\\', ':'])) {
5446        return None;
5447    }
5448    // Files sealed apart: never what tools make, nor what another part carries
5449    // (whatever its case), nor a name Windows keeps for itself, there.
5450    if matches!(store.shape, Shape::Files)
5451        && (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))))
5452    {
5453        return None;
5454    }
5455    Some((store, relative.split('/').fold(store.path.clone(), |path, part| path.join(part))))
5456}
5457
5458/// What putting a version back would change, said before it is done (`put_back_preview`).
5459#[derive(Debug, Default, PartialEq, Eq)]
5460pub struct Back {
5461    /// A file whole (a note, a paper, a draft): it goes back as it was.
5462    pub whole: bool,
5463    /// Entries (settings, lines, marks, sessions) that go back to what they were.
5464    pub changed: usize,
5465    /// Entries taken out since, which come back.
5466    pub returning: usize,
5467    /// Entries added since, which stay.
5468    pub kept: usize,
5469}
5470
5471/// What putting back `stamp` would change in `file` (`put_back`).
5472pub fn put_back_preview(memory: &Path, stores: &[Store], part: &str, file: &str, stamp: &str) -> Result<Back, String> {
5473    // Entries as this device's format divides the file.
5474    let shaped_stores = shaped(stores, format_of(memory));
5475    let stores = shaped_stores.as_slice();
5476    let (store, path) = locate(stores, file).filter(|(store, _)| store.part == part).ok_or_else(|| format!("{file}: not shared"))?;
5477    let kept = crate::history::version(&crate::history::root(memory), part, file, stamp).ok_or_else(|| format!("{file}: {stamp}: not kept"))?;
5478    let (crate::history::Kept::Copy(version), Shape::Toml(_) | Shape::Lines) = (kept, store.shape) else { return Ok(Back { whole: true, ..Back::default() }) };
5479    let (old, now) = entries_then_and_now(store, &version, &path)?;
5480    Ok(Back {
5481        whole: false,
5482        changed: old.iter().filter(|(entry, value)| now.get(*entry).is_some_and(|v| v != *value)).count(),
5483        returning: old.keys().filter(|entry| !now.contains_key(*entry)).count(),
5484        kept: now.keys().filter(|entry| !old.contains_key(*entry)).count(),
5485    })
5486}
5487
5488/// A file's entries in a version kept, and in the file now.
5489fn entries_then_and_now(store: &Store, version: &Path, path: &Path) -> Result<(BTreeMap<String, String>, BTreeMap<String, String>), String> {
5490    let old = std::fs::read(version).ok().and_then(|bytes| entries_of(store, &bytes)).ok_or_else(|| format!("{}: not readable", version.display()))?;
5491    let now = std::fs::read(path).ok().and_then(|bytes| entries_of(store, &bytes)).unwrap_or_default();
5492    Ok((old.into_iter().collect(), now.into_iter().collect()))
5493}
5494
5495/// A version put back (`history`). A file whole goes back as it was, the file
5496/// as it is now kept first. A file of entries (settings, lists, the doses'
5497/// record, sessions) is put back entry by entry: each entry the version held
5498/// goes back to what it was, those taken out since come back, and those added
5499/// since stay. Putting back never takes out, on any device, what was added
5500/// after that version. The next exchange sends what changed as changes made
5501/// here, dated now. `stores` holds every part, those switched off too (a part
5502/// off here is put back here only); `vault` the sharing folder and its key,
5503/// for a version kept by reference. Waits for an exchange running.
5504pub fn put_back(memory: &Path, vault: Option<(&Path, &[u8; 32])>, stores: &[Store], part: &str, file: &str, stamp: &str, now_ms: i64) -> Result<(), String> {
5505    let shaped_stores = shaped(stores, format_of(memory));
5506    let stores = shaped_stores.as_slice();
5507    let (store, path) = locate(stores, file).filter(|(store, _)| store.part == part).ok_or_else(|| format!("{file}: not shared"))?;
5508    let root = crate::history::root(memory);
5509    let kept = crate::history::version(&root, part, file, stamp).ok_or_else(|| format!("{file}: {stamp}: not kept"))?;
5510    let _running = exchange_lock(memory, true)?;
5511    if store.folder && !writable_in(store, &path, vault.map(|(folder, _)| folder), memory) {
5512        return Err(format!("{}: not written there", path.display()));
5513    }
5514    if let (crate::history::Kept::Copy(version), Shape::Toml(_) | Shape::Lines) = (&kept, store.shape) {
5515        let (old, now) = entries_then_and_now(store, version, &path)?;
5516        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();
5517        if back.is_empty() {
5518            return Ok(());
5519        }
5520        return write_entries(store, &path, &back, || crate::history::keep(&root, part, file, &path, now_ms)).map(|_| ());
5521    }
5522    let fail = |e: std::io::Error| format!("{}: {e}", path.display());
5523    if let Some(parent) = path.parent() {
5524        private_dirs(parent).map_err(fail)?;
5525    }
5526    let temporary = temporary(&path);
5527    let brought = match kept {
5528        crate::history::Kept::Copy(version) => copy_private(&version, &temporary).map_err(fail),
5529        crate::history::Kept::Sealed { hash, .. } => {
5530            let (folder, key) = vault.ok_or_else(|| format!("{file}: kept in the sharing folder, which is not set here"))?;
5531            crate::blobs::get(folder, key, &hash, &temporary).map_err(|fault| match fault {
5532                crate::blobs::Fault::Missing => format!("share-missing:{file}"),
5533                crate::blobs::Fault::Broken => format!("share-damaged:{file}"),
5534                crate::blobs::Fault::Room => format!("share-no-room:{file}"),
5535                crate::blobs::Fault::Local(e) => e,
5536            })
5537        }
5538    };
5539    let put = || {
5540        brought?;
5541        if path.exists() {
5542            crate::history::keep(&root, part, file, &path, now_ms)?;
5543        }
5544        // Dated now, wherever the copy took its time from: a change made here, which the others take.
5545        let _ = std::fs::File::options().write(true).open(&temporary).and_then(|f| f.set_modified(std::time::SystemTime::now()));
5546        std::fs::rename(&temporary, &path).map_err(fail)
5547    };
5548    // A file sealed apart (a note) under its lock only when one of Sioul's own writers takes it (the letters' list).
5549    let done = if matches!(store.shape, Shape::Files) && !sioul_core::filelock::has_lock(&path) { put() } else { sioul_core::filelock::with_lock(&path, put) };
5550    if done.is_err() {
5551        let _ = std::fs::remove_file(&temporary);
5552    }
5553    done
5554}
5555
5556/// For a file sealed apart, the content a change made here replaces: its hash as last known ("" new).
5557fn base_of(memory: &Memory, key: &str) -> String {
5558    if key.starts_with(FILES) || key.starts_with(DRAFTS) { memory.entries.get(key).map(|k| k.h.clone()).unwrap_or_default() } else { String::new() }
5559}
5560
5561/// A version of a draft met here (`Memory::drafts`), with the one it replaced.
5562fn met_draft(memory: &mut Memory, key: &str, versions: [&str; 2]) {
5563    if key.starts_with(DRAFTS) {
5564        memory.drafts.entry(key.to_string()).or_default().extend(versions.iter().filter(|v| !v.is_empty()).map(|v| (*v).to_string()));
5565    }
5566}
5567
5568/// A file sealed apart put in the folder before its record goes out (`blobs`);
5569/// false when it could not be (it changed while read, a full disk): its record
5570/// waits for the next look. Anything else needs nothing.
5571fn seal_apart(sharing: &Sharing, stores: &[Store], key: &str, value: &str, sealed: &mut Sealed, problems: &mut Vec<String>, now_ms: i64) -> bool {
5572    if !key.starts_with(FILES) {
5573        return true;
5574    }
5575    let (Some((_, path)), Ok(reference)) = (locate(stores, file_of(key)), serde_json::from_str::<Reference>(value)) else { return false };
5576    match crate::blobs::put(sharing.folder, sharing.key, &path, &reference.h) {
5577        Ok(written) => {
5578            let name = crate::blobs::name(sharing.key, &reference.h);
5579            if let Some(size) = written {
5580                sealed.mine.insert(name.clone(), (now_ms, size));
5581            }
5582            // A table of the spam filter this computer sealed: the newest of its own (`prune_tables`).
5583            if file_of(key) == SPAM_TABLE && sealed.mine.contains_key(&name) {
5584                sealed.tables.retain(|t| *t != name);
5585                sealed.tables.push(name);
5586            }
5587            true
5588        }
5589        Err(e) => {
5590            problems.push(e);
5591            false
5592        }
5593    }
5594}
5595
5596fn append(sharing: &Sharing, memory: &mut Memory, changes: &[(String, Option<String>, u64, String)]) -> Result<(), String> {
5597    if changes.is_empty() {
5598        return Ok(());
5599    }
5600    let path = round_file(sharing.folder, sharing.computer, memory.round);
5601    let mut text = String::new();
5602    for (at, (key, value, clock, base)) in changes.iter().enumerate() {
5603        // A round's first line says where it stands (`Turn`): it continues the
5604        // round before, to that round's last line, or it is full (its
5605        // computer's first round, or one restating all, `new_round`).
5606        let turn = if at == 0 && memory.seq == 0 {
5607            if memory.continues > 0 {
5608                Turn { p: memory.continues, full: false }
5609            } else if memory.full_round == memory.round || memory.round <= 1 {
5610                Turn { p: 0, full: true }
5611            } else {
5612                Turn::default()
5613            }
5614        } else {
5615            Turn::default()
5616        };
5617        memory.seq += 1;
5618        let build = if at == 0 { sioul_core::build::DESCRIBED.to_string() } else { String::new() };
5619        // Its part's format, once this device writes format 2.
5620        let f = if memory.format >= 2 { known_part(file_of(key)).map_or(1, part_format) } else { 1 };
5621        let change = Change { k: key.clone(), v: value.clone(), b: base.clone(), build, f };
5622        let plain = serde_json::to_vec(&Sealing { change: &change, turn }).map_err(|e| e.to_string())?;
5623        let line = Line { n: memory.seq, c: *clock, s: seal(sharing.key, &bound(sharing.computer, memory.round, memory.seq, *clock), &plain) };
5624        text.push_str(&serde_json::to_string(&line).map_err(|e| e.to_string())?);
5625        text.push('\n');
5626    }
5627    let fail = |e: std::io::Error| format!("{}: {e}", path.display());
5628    // Where this append ends, noted on the disk before it is made: its own
5629    // lines told from another device's under its name (`appended`). Not
5630    // noted (a full disk), nothing is appended: a line of its own is never
5631    // taken for another device's (`share-twin`).
5632    write_synced(&sharing.memory.with_file_name("appended.toml"), format!("computer = \"{}\"\nround = {}\nn = {}\n", sharing.computer, memory.round, memory.seq).as_bytes())?;
5633    // A line left half written (Sioul stopped, a full disk): cut back to the
5634    // last whole line, so that the next record is not glued to it and lost.
5635    if let Some(whole) = unfinished(&path) {
5636        let file = std::fs::OpenOptions::new().write(true).open(&path).map_err(fail)?;
5637        file.set_len(whole).map_err(fail)?;
5638    }
5639    let mut options = std::fs::OpenOptions::new();
5640    options.create(true).append(true);
5641    #[cfg(unix)]
5642    std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600);
5643    let mut file = options.open(&path).map_err(fail)?;
5644    // A round begun before its files were yours alone: made so at this write.
5645    make_private(&path);
5646    file.write_all(text.as_bytes()).map_err(fail)?;
5647    file.sync_all().map_err(fail)
5648}
5649
5650/// Where this device's last append ended (its round and last number), as
5651/// noted before it was made (`append`): its own records found ahead of its
5652/// memory up to there were its own, its memory not saved after (a process
5653/// stopped, a full disk; an append cut short ends before it), not another
5654/// device's under its name (`share-twin`).
5655fn appended(memory: &Path, computer: &str) -> Option<(u32, u64)> {
5656    #[derive(Deserialize)]
5657    struct Appended {
5658        computer: String,
5659        round: u32,
5660        n: u64,
5661    }
5662    let text = read_small(&memory.with_file_name("appended.toml"))?;
5663    toml::from_str::<Appended>(&text).ok().filter(|a| a.computer == computer).map(|a| (a.round, a.n))
5664}
5665
5666/// Where a file's last whole line ends, when it does not end one: its end
5667/// read back a piece at a time, never the file whole. None when it ends a
5668/// line, or is empty or not there.
5669fn unfinished(path: &Path) -> Option<u64> {
5670    use std::io::{Read, Seek, SeekFrom};
5671    let mut file = std::fs::File::open(path).ok()?;
5672    let size = file.metadata().ok()?.len();
5673    let mut last = [0u8];
5674    file.seek(SeekFrom::Start(size.checked_sub(1)?)).ok()?;
5675    file.read_exact(&mut last).ok()?;
5676    if last[0] == b'\n' {
5677        return None;
5678    }
5679    let mut end = size;
5680    while end > 0 {
5681        let from = end.saturating_sub(64 << 10);
5682        let mut piece = vec![0u8; (end - from) as usize];
5683        file.seek(SeekFrom::Start(from)).ok()?;
5684        file.read_exact(&mut piece).ok()?;
5685        if let Some(at) = piece.iter().rposition(|b| *b == b'\n') {
5686            return Some(from + at as u64 + 1);
5687        }
5688        end = from;
5689    }
5690    Some(0)
5691}
5692
5693/// This computer's last record in the folder: its round, number, and that file's size.
5694fn last_own(folder: &Path, computer: &str) -> Option<(u32, u64, u64)> {
5695    let round = *rounds(folder).get(computer)?.last()?;
5696    let (n, size) = last_of(&round_file(folder, computer, round))?;
5697    Some((round, n, size))
5698}
5699
5700/// The number of the last line of one of a computer's rounds (0 for none).
5701fn last_line(folder: &Path, computer: &str, round: u32) -> Option<u64> {
5702    last_of(&round_file(folder, computer, round)).map(|(n, _)| n)
5703}
5704
5705/// A round file's last line's number (0 for none) and its size.
5706fn last_of(path: &Path) -> Option<(u64, u64)> {
5707    use std::io::{Read, Seek};
5708    let mut file = std::fs::File::open(path).ok()?;
5709    let size = file.metadata().ok()?.len();
5710    // The tail is enough: lines are short; a line longer than that was a whole round's opening.
5711    let from = size.saturating_sub(64 * 1024);
5712    let mut tail = Vec::new();
5713    file.seek(std::io::SeekFrom::Start(from)).ok()?;
5714    file.read_to_end(&mut tail).ok()?;
5715    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);
5716    Some((n, size))
5717}
5718
5719/// What a full round restates: every entry this computer holds the last
5720/// word on, with its value as it reads now, and how many of them it could
5721/// not restate though their store is here and read (their file not readable
5722/// now, a settings file held empty (`EMPTIED_WAIT`), a change found and not
5723/// sent yet: their value here is not the one this computer last said). An
5724/// entry of a store not here (a part switched off on this device, a store
5725/// that left) is passed over, as it always was: it stops no round from
5726/// being full (review of 10 October 2026, S2-B).
5727fn restatement(sharing: &Sharing, memory: &Memory, stores: &[Store], found: &Found) -> (Vec<(String, Option<String>, u64, String)>, usize) {
5728    let mut omitted = 0usize;
5729    let ours = memory
5730        .entries
5731        .iter()
5732        .filter(|(_, known)| known.w == sharing.computer)
5733        .filter_map(|(key, known)| {
5734            if known.h.is_empty() {
5735                return (!found.hashes.contains_key(key)).then(|| (key.clone(), None, known.c, known.b.clone()));
5736            }
5737            let (store, _) = locate(stores, file_of(key))?;
5738            // A file sealed apart from its last look, never read through for this.
5739            let value = match found.values.get(key) {
5740                Some(value) => Some(value.clone()),
5741                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))),
5742                None => value_of(stores, key),
5743            };
5744            match value {
5745                Some(value) if value_hash(key, &value) == known.h => Some((key.clone(), Some(value), known.c, known.b.clone())),
5746                _ => {
5747                    if reads_now(sharing, store) {
5748                        omitted += 1;
5749                    }
5750                    None
5751                }
5752            }
5753        })
5754        .collect();
5755    (ours, omitted)
5756}
5757
5758/// Whether this exchange reads a store: every one, but notes and papers
5759/// (files sealed apart in a folder) only when it reads them (`Sharing::files`).
5760fn reads_now(sharing: &Sharing, store: &Store) -> bool {
5761    sharing.files || !matches!(store.shape, Shape::Files) || !store.folder
5762}
5763
5764/// A new file for this computer, opening on every entry it holds the last
5765/// word on, with their clocks; entries taken out long ago are forgotten. A
5766/// file sealed apart missing from the folder (taken out there by hand) is
5767/// put back first. `cut`: the round before lost lines (its file cut short,
5768/// or found without a line), which a reader that had not read them waits for
5769/// until a full round restates all.
5770fn new_round(sharing: &Sharing, memory: &mut Memory, stores: &[Store], found: &Found, hurried: &dyn Fn() -> bool, now_ms: i64, cut: bool) -> Result<(), String> {
5771    let oldest = ((now_ms - TOMBSTONE_DAYS * 86_400_000).max(0) as u64) << 16;
5772    memory.entries.retain(|_, known| !known.h.is_empty() || known.c >= oldest);
5773    let (ours, omitted) = restatement(sharing, memory, stores, found);
5774    let mut problems = Vec::new();
5775    for (key, value, _, _) in ours.iter().filter(|_| sharing.files && !hurried()) {
5776        if let Some(value) = value {
5777            seal_apart(sharing, stores, key, value, &mut memory.sealed, &mut problems, now_ms);
5778        }
5779    }
5780    // Restating all: a full round, which a reader waiting for the end of an
5781    // earlier round may read on from (`later_full`). One that could not
5782    // restate all (a file not readable now: the doses' record held empty) is
5783    // not (review of 10 October 2026, R7): it continues the round before,
5784    // whose rounds are kept; after lost lines, a full round follows once all
5785    // reads again (`Memory::owed`).
5786    let previous = memory.seq;
5787    memory.round += 1;
5788    memory.seq = 0;
5789    memory.grown = 0;
5790    if omitted == 0 {
5791        memory.full_round = memory.round;
5792        memory.continues = 0;
5793        memory.owed = false;
5794        (memory.owed_waited, memory.owed_last) = (0, 0);
5795    } else {
5796        memory.continues = if previous > 0 { previous } else { UNKNOWN_TURN };
5797        memory.owed |= cut;
5798    }
5799    append(sharing, memory, &ours)
5800}
5801
5802/// The sealed files this computer put in the folder (`blobs/<name>`), by
5803/// name, and the size it sealed each at: what it sends to the server itself
5804/// beside the sync app (`remote::send`), whole, and nothing else of `blobs/`.
5805/// Asked before each send: as this process last wrote or read `files.json`
5806/// while it is still that file (`MINE`), else from that part of the file
5807/// alone, the rest passed over, never built (on a phone, tens of thousands
5808/// of the texts' entries).
5809pub fn own_sealed(memory: &Path, computer: &str) -> BTreeMap<String, u64> {
5810    let path = sealed_path(memory);
5811    let stamp = stamp_at(&path);
5812    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()))) {
5813        return if whose == computer { mine } else { BTreeMap::new() };
5814    }
5815    #[derive(Deserialize)]
5816    struct Part {
5817        #[serde(default)]
5818        computer: String,
5819        #[serde(default)]
5820        mine: BTreeMap<String, (i64, u64)>,
5821    }
5822    let Some(read) = std::fs::read(&path).ok().and_then(|bytes| serde_json::from_slice::<Part>(&bytes).ok()) else { return BTreeMap::new() };
5823    let mine: BTreeMap<String, u64> = read.mine.into_iter().map(|(name, (_, size))| (name, size)).collect();
5824    if let Ok(mut all) = MINE.lock() {
5825        all.insert(memory.to_path_buf(), (stamp, read.computer.clone(), mine.clone()));
5826    }
5827    if read.computer == computer { mine } else { BTreeMap::new() }
5828}
5829
5830/// How far this computer wrote its records: its round and the number of the
5831/// last, for its claims to say (`lease::Claim::wrote`); none before sharing.
5832pub fn written(memory: &Path, computer: &str) -> Option<(u32, u64)> {
5833    let memory = Memory::load(memory, computer);
5834    memory.joined.then_some((memory.round.max(1), memory.seq))
5835}
5836
5837/// What this computer read of the others' records.
5838#[derive(Debug, Default, Clone)]
5839pub struct Heard {
5840    /// Each computer's last record read here: its round and number.
5841    pub read: BTreeMap<String, (u32, u64)>,
5842    /// When a line of each could not be read here (Unix seconds).
5843    pub broken: BTreeMap<String, i64>,
5844}
5845
5846impl Heard {
5847    /// Whether everything `computer` wrote up to `wrote` (its round and
5848    /// number, as its claim says) is read here. Nothing written yet (number 0)
5849    /// is all read; a round begun anew opens with everything it holds.
5850    pub fn complete(&self, computer: &str, wrote: (u32, u64)) -> bool {
5851        wrote.1 == 0 || self.read.get(computer).is_some_and(|read| *read >= wrote)
5852    }
5853}
5854
5855/// When each part last sent a change from here, and last received one (Unix seconds; 0 never).
5856pub fn traffic(memory: &Path, computer: &str) -> BTreeMap<String, (i64, i64)> {
5857    Memory::load(memory, computer).traffic
5858}
5859
5860pub fn heard(memory: &Path, computer: &str) -> Heard {
5861    let memory = Memory::load(memory, computer);
5862    let mut read = memory.read_n;
5863    // A device whose doses' records wait here, in a format this Sioul does
5864    // not read: not all it wrote is known here, whatever was read of its file.
5865    for (key, (_, _, writer, f)) in &memory.waiting {
5866        if known_part(file_of(key)) == Some("health") && *f > part_format("health") {
5867            read.remove(writer);
5868        }
5869    }
5870    Heard { read, broken: memory.broken }
5871}
5872
5873/// The entries of `files` read again from every computer's records, this
5874/// one's too, at the next exchange; this computer forgets it held them, so
5875/// that a record lost or broken here, started again empty, takes nothing out
5876/// elsewhere and gets back what was written (docs/database.md, "Prudence").
5877pub fn rebuild(memory: &Path, computer: &str, files: &[&str]) -> Result<(), String> {
5878    let _running = exchange_lock(memory, true)?;
5879    let mut state = Memory::load_all(memory, computer);
5880    state.entries.retain(|key, _| !files.contains(&file_of(key)));
5881    state.pending.retain(|key, _| !files.contains(&file_of(key)));
5882    for file in files {
5883        state.files.remove(*file);
5884        state.rebuild.insert((*file).to_string());
5885    }
5886    // The others' records read again from their oldest kept round.
5887    state.read.clear();
5888    state.save(memory)?;
5889    state.keep(memory);
5890    Ok(())
5891}
5892
5893/// The files gone at once from a folder of notes or papers (`share-vanished`)
5894/// taken out everywhere at the next exchange, as you said: the folder by its
5895/// name in the records ("files/notes/").
5896pub fn confirm_gone(memory: &Path, computer: &str, store: &str) -> Result<(), String> {
5897    let _running = exchange_lock(memory, true)?;
5898    let mut state = Memory::load_all(memory, computer);
5899    state.sealed.confirmed.insert(store.to_string());
5900    state.save(memory)?;
5901    state.keep(memory);
5902    Ok(())
5903}
5904
5905/// One exchange at a time on this computer, whatever runs it (two Sioul
5906/// started, the command line): a lock on `<state>/share/exchange.lock`, held
5907/// while it runs, let go when the file returned is. `wait`: until the
5908/// running one ends; else none while another holds it a second on.
5909pub fn exchange_lock(memory: &Path, wait: bool) -> Result<Option<std::fs::File>, String> {
5910    let path = memory.with_file_name("exchange.lock");
5911    let fail = |e: std::io::Error| format!("{}: {e}", path.display());
5912    if let Some(parent) = path.parent() {
5913        private_dirs(parent).map_err(fail)?;
5914    }
5915    let file = std::fs::OpenOptions::new().create(true).truncate(false).write(true).open(&path).map_err(fail)?;
5916    if wait {
5917        file.lock().map_err(fail)?;
5918        return Ok(Some(file));
5919    }
5920    // A program starting elsewhere in Sioul holds a copy of every file open
5921    // then until it runs (a Mac's take milliseconds): the last exchange's lock,
5922    // let go a moment before, may still be held by it. Tried again for a second.
5923    for _ in 0..50 {
5924        match file.try_lock() {
5925            Ok(()) => return Ok(Some(file)),
5926            Err(std::fs::TryLockError::WouldBlock) => std::thread::sleep(std::time::Duration::from_millis(20)),
5927            Err(std::fs::TryLockError::Error(e)) => return Err(fail(e)),
5928        }
5929    }
5930    Ok(None)
5931}
5932
5933fn seen_path(folder: &Path, computer: &str) -> PathBuf {
5934    folder.join(format!("{computer}.toml"))
5935}
5936
5937/// This computer's notes in the folder, when what they say changes (its
5938/// round, how far it read the others); else restated a quarter of an hour
5939/// after the last while this device is in use (its entry says it: another
5940/// device's do-not-disturb hears of a computer in use by them), once a day
5941/// while it is put away (a phone's background step: how far it read changes
5942/// rarely, and the sync is not asked to carry a file every few minutes).
5943/// Returns when they are due again (milliseconds).
5944fn write_seen(sharing: &Sharing, memory: &Memory, all: &BTreeMap<String, Vec<u32>>, now_ms: i64) -> i64 {
5945    let again = if crate::devices::in_use(sharing.memory) { 15 * 60 } else { 24 * 3600 };
5946    let mut seen = Seen {
5947        at: now_ms / 1000,
5948        round: memory.round,
5949        read: memory.read.iter().filter(|(c, _)| all.contains_key(*c)).map(|(c, (round, _))| (c.clone(), *round)).collect(),
5950        full: full_asked(memory, all),
5951        pad: String::new(),
5952    };
5953    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) {
5954        return (old.at + again) * 1000;
5955    }
5956    let path = seen_path(sharing.folder, sharing.computer);
5957    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();
5958    seen.pad = ".".repeat(pad_for(&path, |pad| text(&seen, pad).len() as u64));
5959    if let Ok(text) = toml::to_string(&seen) {
5960        let _ = write_atomically(&path, text.as_bytes());
5961    }
5962    (seen.at + again) * 1000
5963}
5964
5965/// The other computers whose records this one met past what it read in
5966/// order (`Memory::met` ahead of `read_n`: a line lost, a round passed over
5967/// as lost, a device first read from a round continuing one gone), each with
5968/// the latest round of theirs met: a full round of theirs after it tells
5969/// where they stand (`Seen::full`).
5970fn full_asked(memory: &Memory, all: &BTreeMap<String, Vec<u32>>) -> BTreeMap<String, u32> {
5971    memory.met.iter().filter(|(computer, met)| all.contains_key(*computer) && memory.read_n.get(*computer) != Some(*met)).map(|(computer, (round, _))| (computer.clone(), *round)).collect()
5972}
5973
5974/// Whether another computer heard lately asks this one for a full round
5975/// after a round of its own that no full round followed yet (`Seen::full`):
5976/// a round this computer wrote, at or after its last full one.
5977fn asked_for_full(sharing: &Sharing, memory: &Memory, now_ms: i64) -> bool {
5978    let last_full = memory.full_round.max(1);
5979    computers(sharing.folder)
5980        .iter()
5981        .filter(|c| *c != sharing.computer)
5982        .filter_map(|c| read_seen(sharing.folder, c))
5983        .filter(|s| now_ms / 1000 - s.at < SILENT_DAYS * 86_400)
5984        .any(|s| s.full.get(sharing.computer).is_some_and(|round| (last_full..=memory.round).contains(round)))
5985}
5986
5987fn read_seen_in(folder: &Path, computer: &str) -> Option<Seen> {
5988    read_small(&seen_path(folder, computer)).and_then(|t| toml::from_str(&t).ok())
5989}
5990
5991/// Another computer's notes, the later of the folder's and the copy fetched
5992/// from the server (`remote::overlay`); the folder's on a tie.
5993fn read_seen(folder: &Path, computer: &str) -> Option<Seen> {
5994    let here = read_seen_in(folder, computer);
5995    match (here, crate::remote::overlay(folder).and_then(|fetched| read_seen_in(&fetched, computer))) {
5996        (Some(here), Some(fetched)) => Some(if fetched.at > here.at { fetched } else { here }),
5997        (here, fetched) => here.or(fetched),
5998    }
5999}
6000
6001/// This computer's old files, once every other computer heard lately has read past them.
6002fn remove_old_rounds(sharing: &Sharing, memory: &Memory, now_ms: i64) {
6003    let all = rounds(sharing.folder);
6004    let others: Vec<Seen> = computers(sharing.folder).iter().filter(|c| *c != sharing.computer).filter_map(|c| read_seen(sharing.folder, c)).filter(|s| now_ms / 1000 - s.at < SILENT_DAYS * 86_400).collect();
6005    let read_up_to = others.iter().map(|s| s.read.get(sharing.computer).copied().unwrap_or(0)).min().unwrap_or(memory.round);
6006    // Never the last full round, nor the rounds continuing it: a device
6007    // joining reads from the oldest kept, and finds every entry there.
6008    let full = if memory.full_round == 0 { memory.round } else { memory.full_round };
6009    for round in all.get(sharing.computer).into_iter().flatten().filter(|r| **r < full && **r < read_up_to) {
6010        let _ = std::fs::remove_file(round_file(sharing.folder, sharing.computer, *round));
6011    }
6012}
6013
6014/// Another computer sharing through the folder, and when it last exchanged (Unix seconds).
6015#[derive(Debug, Clone, PartialEq, Eq)]
6016pub struct Other {
6017    pub id: String,
6018    pub heard: i64,
6019}
6020
6021/// The other computers sharing through the folder.
6022pub fn others(folder: &Path, computer: &str) -> Vec<Other> {
6023    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()
6024}
6025
6026/// The same, each heard when its notes, its entry in the devices' registry
6027/// (`devices`) or its last record that opens last said so, the latest: a
6028/// device put away restates its notes once a day (`write_seen`), its entry
6029/// once an hour (`devices::RESTATED`), as Settings says when each was last
6030/// heard of. A device whose records came before its notes and its entry (each
6031/// file travels on its own) is heard of by its records: the doses count it,
6032/// in doubt, until its entry comes (review of 5 October 2026, F5).
6033pub fn others_heard(folder: &Path, key: &[u8; 32], computer: &str) -> Vec<Other> {
6034    let (entries, _) = crate::devices::all(folder, key);
6035    let all = rounds_with(folder);
6036    let mut out = others(folder, computer);
6037    for other in &mut out {
6038        if let Some(entry) = entries.iter().find(|e| e.id == other.id) {
6039            other.heard = other.heard.max(entry_stamp(entry));
6040        }
6041        if let Some(clock) = all.get(&other.id).and_then(|rounds| rounds.last()).and_then(|round| last_record_clock(folder, key, &other.id, *round)) {
6042            other.heard = other.heard.max((clock >> 16) as i64 / 1000);
6043        }
6044    }
6045    out
6046}
6047
6048/// Whether the folder already holds a seal: another computer shares through it.
6049pub fn sealed(folder: &Path) -> bool {
6050    folder.join("seal.toml").is_file()
6051}
6052
6053#[cfg(test)]
6054mod tests {
6055    use super::*;
6056
6057    thread_local! {
6058        /// Stores this thread's exchanges know nothing of, as an older Sioul's
6059        /// (`known_part`): their records wait, written nowhere, taken out nowhere.
6060        pub(super) static OLDER: std::cell::RefCell<Vec<String>> = const { std::cell::RefCell::new(Vec::new()) };
6061    }
6062
6063    /// A computer of its own: its folders, its name, its memory.
6064    pub(super) struct Computer {
6065        pub(super) roots: Roots,
6066        pub(super) id: String,
6067        pub(super) memory: PathBuf,
6068    }
6069
6070    impl Computer {
6071        pub(super) fn new(base: &Path, name: &str) -> Computer {
6072            let root = base.join(name);
6073            let roots = Roots { config: root.join("config"), data: root.join("data"), state: root.join("state") };
6074            for dir in [&roots.config, &roots.data, &roots.state] {
6075                std::fs::create_dir_all(dir).unwrap();
6076            }
6077            let memory = roots.state.join("share").join("memory.json");
6078            Computer { roots, id: uuid::Uuid::new_v4().to_string(), memory }
6079        }
6080
6081        pub(super) fn exchange(&self, folder: &Path, key: &[u8; 32], now: i64) -> Outcome {
6082            let stores = stores(&Config::default(), &self.roots);
6083            exchange(&Sharing { folder, computer: &self.id, key, memory: &self.memory, files: true, hurry: None }, &stores, now).unwrap()
6084        }
6085
6086        /// An exchange carrying the projects and budgets of `notes`, this computer's notes folder.
6087        pub(super) fn exchange_notes(&self, folder: &Path, key: &[u8; 32], now: i64, notes: &Path) -> Outcome {
6088            let config = Config { share_projects: true, notes_root: Some(notes.display().to_string()), ..Config::default() };
6089            let stores = stores(&config, &self.roots);
6090            exchange(&Sharing { folder, computer: &self.id, key, memory: &self.memory, files: true, hurry: None }, &stores, now).unwrap()
6091        }
6092
6093        /// An exchange with `notes` as this computer's notes folder, the parts `on` shared besides those shared by default.
6094        fn exchange_with(&self, folder: &Path, key: &[u8; 32], now: i64, notes: &Path, on: &[&str]) -> Outcome {
6095            let stores = self.stores_with(notes, &|part| on.contains(&part));
6096            exchange(&Sharing { folder, computer: &self.id, key, memory: &self.memory, files: true, hurry: None }, &stores, now).unwrap()
6097        }
6098
6099        fn stores_with(&self, notes: &Path, on: &dyn Fn(&str) -> bool) -> Vec<Store> {
6100            let config = Config { notes_root: Some(notes.display().to_string()), ..Config::default() };
6101            stores_of(&config, &self.roots, &|part| on(part) || shared_by_default(part, &config))
6102        }
6103
6104        pub(super) fn write(&self, relative: &str, text: &str) {
6105            let path = self.path(relative);
6106            std::fs::create_dir_all(path.parent().unwrap()).unwrap();
6107            std::fs::write(path, text).unwrap();
6108        }
6109
6110        pub(super) fn read(&self, relative: &str) -> String {
6111            std::fs::read_to_string(self.path(relative)).unwrap_or_default()
6112        }
6113
6114        pub(super) fn path(&self, relative: &str) -> PathBuf {
6115            let (root, rest) = relative.split_once('/').unwrap();
6116            match root {
6117                "config" => self.roots.config.join(rest),
6118                "data" => self.roots.data.join(rest),
6119                _ => self.roots.state.join(rest),
6120            }
6121        }
6122    }
6123
6124    pub(super) fn scratch(name: &str) -> PathBuf {
6125        let dir = std::env::temp_dir().join(format!("sioul-share-{name}-{}", std::process::id()));
6126        let _ = std::fs::remove_dir_all(&dir);
6127        std::fs::create_dir_all(&dir).unwrap();
6128        dir
6129    }
6130
6131    pub(super) fn quick_key(folder: &Path, passphrase: &str) -> Result<[u8; 32], Refused> {
6132        key_with(folder, passphrase, 64, 1)
6133    }
6134
6135    fn put(dir: &Path, relative: &str, bytes: &[u8]) {
6136        let path = dir.join(relative);
6137        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
6138        std::fs::write(path, bytes).unwrap();
6139    }
6140
6141    /// Bytes that do not compress, the same for the same seed: a scan, a picture.
6142    fn noise(length: usize, seed: u32) -> Vec<u8> {
6143        let mut x = seed.wrapping_mul(2_654_435_761).wrapping_add(1);
6144        (0..length)
6145            .map(|_| {
6146                x ^= x << 13;
6147                x ^= x >> 17;
6148                x ^= x << 5;
6149                x as u8
6150            })
6151            .collect()
6152    }
6153
6154    /// The files of a notes folder, hidden ones left out, by their path from it.
6155    fn files_in(dir: &Path) -> BTreeMap<String, Vec<u8>> {
6156        let mut found = Vec::new();
6157        list_files(dir, dir, &[], &mut found);
6158        found.into_iter().map(|(name, path)| (name, std::fs::read(path).unwrap())).collect()
6159    }
6160
6161    /// A version kept, copied or by reference (its content opened from the folder).
6162    fn version_text(history: &Path, part: &str, file: &str, stamp: &str, folder: &Path, key: &[u8; 32]) -> String {
6163        match crate::history::version(history, part, file, stamp) {
6164            Some(crate::history::Kept::Copy(path)) => std::fs::read_to_string(path).unwrap(),
6165            Some(crate::history::Kept::Sealed { hash, .. }) => {
6166                let target = temporary(&history.join("opened"));
6167                crate::blobs::get(folder, key, &hash, &target).unwrap();
6168                let text = std::fs::read_to_string(&target).unwrap();
6169                std::fs::remove_file(target).unwrap();
6170                text
6171            }
6172            None => panic!("{file}: {stamp}: not kept"),
6173        }
6174    }
6175
6176    /// The copies a device kept of a file two devices changed: "lease (… …).md", not a sync app's.
6177    fn copies_of(dir: &Path, stem: &str) -> Vec<Vec<u8>> {
6178        files_in(dir).into_iter().filter(|(name, _)| name.starts_with(&format!("{stem} (")) && name.ends_with(").md") && !made_by_tools(name)).map(|(_, bytes)| bytes).collect()
6179    }
6180
6181    pub(super) const MINUTE: i64 = 60_000;
6182    pub(super) const NOW: i64 = 1_790_000_000_000;
6183
6184    /// How eDrive (Murena's sync on /e/OS) carries a folder between a phone and
6185    /// the server, after its `FileDiffUtils`: a file new on either side is
6186    /// copied; a file changed on the phone goes up; a file changed on the server
6187    /// comes down only when its size differs from the phone's copy (else only
6188    /// its version is noted); nothing is ever deleted. The server here is the
6189    /// desktop's folder: Nextcloud's client keeps them equal.
6190    #[derive(Default)]
6191    struct Edrive {
6192        /// path → (the server's content when last seen, the phone's content then).
6193        known: BTreeMap<String, (String, String)>,
6194    }
6195
6196    impl Edrive {
6197        fn files(root: &Path) -> BTreeMap<String, Vec<u8>> {
6198            let mut found = Vec::new();
6199            list_files(root, root, &[], &mut found);
6200            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()
6201        }
6202
6203        fn carry(&mut self, server: &Path, phone: &Path) {
6204            let digest = |bytes: &[u8]| hash(&String::from_utf8_lossy(bytes));
6205            let (up, down) = (Self::files(phone), Self::files(server));
6206            for (name, bytes) in &up {
6207                let changed_here = self.known.get(name).is_none_or(|(_, local)| *local != digest(bytes));
6208                if changed_here {
6209                    let to = server.join(name);
6210                    std::fs::create_dir_all(to.parent().unwrap()).unwrap();
6211                    std::fs::write(&to, bytes).unwrap();
6212                    self.known.insert(name.clone(), (digest(bytes), digest(bytes)));
6213                }
6214            }
6215            for (name, bytes) in &down {
6216                let local = phone.join(name);
6217                match (self.known.get(name), std::fs::read(&local)) {
6218                    (Some((remote, _)), _) if *remote == digest(bytes) => {}
6219                    // Changed there, the same size here: only the version is noted.
6220                    (Some(_), Ok(here)) if here.len() == bytes.len() => {
6221                        self.known.insert(name.clone(), (digest(bytes), digest(&here)));
6222                    }
6223                    _ => {
6224                        std::fs::create_dir_all(local.parent().unwrap()).unwrap();
6225                        std::fs::write(&local, bytes).unwrap();
6226                        self.known.insert(name.clone(), (digest(bytes), digest(bytes)));
6227                    }
6228                }
6229            }
6230        }
6231    }
6232
6233    /// How a sync app carries the folder between a phone and the server, in
6234    /// the ways they differ: the sharing must hold with each (docs/database.md,
6235    /// "What the sync app must do").
6236    #[derive(Clone, Copy, Debug, PartialEq)]
6237    enum Carrying {
6238        /// Every change, both ways, deletions too (Nextcloud's desktop client, Dropbox, Drive, OneDrive).
6239        Exact,
6240        /// A change from the server comes only when the size differs (eDrive).
6241        SizeOnly,
6242        /// Half the files each time, late, in any order; nothing deleted.
6243        Late,
6244        /// An older copy of a file put back from time to time, a conflicted copy beside it.
6245        StaleCopies,
6246        /// New files first come as empty placeholders, the content at the next look (on-demand files).
6247        Placeholders,
6248    }
6249
6250    struct Carrier {
6251        how: Carrying,
6252        turn: u64,
6253        /// path → (the server's content when last seen, the phone's content then).
6254        known: BTreeMap<String, (String, String)>,
6255        /// Every version seen on the server, to put an older one back.
6256        history: BTreeMap<String, Vec<Vec<u8>>>,
6257    }
6258
6259    impl Carrier {
6260        fn new(how: Carrying) -> Carrier {
6261            Carrier { how, turn: 0, known: BTreeMap::new(), history: BTreeMap::new() }
6262        }
6263
6264        fn files(root: &Path) -> BTreeMap<String, Vec<u8>> {
6265            let mut found = Vec::new();
6266            list_files(root, root, &[], &mut found);
6267            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()
6268        }
6269
6270        fn write(path: &Path, bytes: &[u8]) {
6271            std::fs::create_dir_all(path.parent().unwrap()).unwrap();
6272            std::fs::write(path, bytes).unwrap();
6273        }
6274
6275        fn carry(&mut self, server: &Path, phone: &Path) {
6276            self.turn += 1;
6277            let digest = |bytes: &[u8]| hash(&String::from_utf8_lossy(bytes));
6278            let (up, down) = (Self::files(phone), Self::files(server));
6279            for (name, bytes) in &up {
6280                let changed_here = self.known.get(name).is_none_or(|(_, local)| *local != digest(bytes));
6281                // A placeholder never goes up.
6282                if changed_here && !(self.how == Carrying::Placeholders && bytes.is_empty() && down.contains_key(name)) {
6283                    Self::write(&server.join(name), bytes);
6284                    self.known.insert(name.clone(), (digest(bytes), digest(bytes)));
6285                }
6286            }
6287            for (i, (name, bytes)) in down.iter().enumerate() {
6288                self.history.entry(name.clone()).or_default().push(bytes.clone());
6289                if self.how == Carrying::Late && (i as u64 + self.turn) % 2 == 0 {
6290                    continue;
6291                }
6292                let local = phone.join(name);
6293                let here = std::fs::read(&local).ok();
6294                match (self.known.get(name), here) {
6295                    (Some((remote, _)), _) if *remote == digest(bytes) => {}
6296                    (Some(_), Some(here)) if self.how == Carrying::SizeOnly && here.len() == bytes.len() => {
6297                        self.known.insert(name.clone(), (digest(bytes), digest(&here)));
6298                    }
6299                    (None, None) if self.how == Carrying::Placeholders => {
6300                        Self::write(&local, b"");
6301                        self.known.insert(name.clone(), (String::new(), digest(b"")));
6302                    }
6303                    _ => {
6304                        Self::write(&local, bytes);
6305                        self.known.insert(name.clone(), (digest(bytes), digest(bytes)));
6306                    }
6307                }
6308            }
6309            if self.how == Carrying::Exact {
6310                for name in up.keys().filter(|n| !down.contains_key(*n) && self.known.contains_key(*n)) {
6311                    let _ = std::fs::remove_file(phone.join(name));
6312                }
6313            }
6314            // From time to time, an older copy of a record put back on the server, a conflicted copy beside it.
6315            if self.how == Carrying::StaleCopies && self.turn % 7 == 0 {
6316                for (name, versions) in self.history.iter().filter(|(n, _)| n.ends_with(".jsonl")) {
6317                    if versions.len() > 2 {
6318                        let current = std::fs::read(server.join(name)).unwrap_or_default();
6319                        Self::write(&server.join(format!("{name} (conflicted copy)")), &current);
6320                        Self::write(&server.join(name), &versions[versions.len() / 2]);
6321                    }
6322                }
6323            }
6324        }
6325    }
6326
6327    #[test]
6328    fn doses_are_never_known_wrongly_whatever_carries_them() {
6329        for how in [Carrying::Exact, Carrying::SizeOnly, Carrying::Late, Carrying::StaleCopies, Carrying::Placeholders] {
6330            let base = scratch(&format!("carrier-{how:?}").to_lowercase());
6331            let (server, phone_folder) = (base.join("server").join("Sioul"), base.join("phone").join("Sioul"));
6332            let key = quick_key(&server, "four words make a passphrase").unwrap();
6333            let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
6334            let mut carrier = Carrier::new(how);
6335            let mut marked: Vec<String> = Vec::new();
6336            for minute in 0..120 {
6337                let now = NOW + minute * MINUTE;
6338                // A dose marked on the desk every ten minutes, its claim renewed each minute.
6339                if minute % 10 == 0 {
6340                    marked.push(format!("dose@{minute}"));
6341                    let record: String = marked.iter().map(|d| format!("\"{d}\" = {minute}\n")).collect();
6342                    desk.write("state/health-state.toml", &format!("[taken]\n{record}"));
6343                }
6344                desk.exchange(&server, &key, now);
6345                let wrote = written(&desk.memory, &desk.id);
6346                crate::lease::renew(&server, &key, "health", &desk.id, now / 1000, now / 1000, false, crate::lease::Rule::FollowsYou, wrote).unwrap();
6347                carrier.carry(&server, &phone_folder);
6348                // texts: a first import running on the phone beside it, a batch each minute and a picture each ten.
6349                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();
6350                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();
6351                if minute % 10 == 0 {
6352                    let picture = base.join(format!("picture-{minute}.bin"));
6353                    std::fs::write(&picture, vec![minute as u8; 256 * 1024]).unwrap();
6354                    let (hash, _) = crate::blobs::hash_file(&picture).unwrap();
6355                    crate::blobs::put(&phone_folder, &key, &picture, &hash).unwrap();
6356                }
6357                phone.exchange_with(&phone_folder, &key, now + 30_000, &base.join("phone-notes"), &[sioul_core::texts::PART]);
6358                // What the phone takes as known must be there: a dose known not taken that was taken is the harm.
6359                if let Some(claim) = crate::lease::claims(&phone_folder, &key, "health").into_iter().find(|c| c.computer == desk.id)
6360                    && let Some(wrote) = claim.wrote
6361                    && heard(&phone.memory, &phone.id).complete(&desk.id, wrote)
6362                {
6363                    let record = phone.read("state/health-state.toml");
6364                    let claimed = (claim.renewed - NOW / 1000) / 60;
6365                    for dose in marked.iter().filter(|d| d[5..].parse::<i64>().unwrap() < claimed) {
6366                        assert!(record.contains(dose.as_str()), "{how:?}, minute {minute}: the phone takes {dose} as known, its record lacks it:\n{record}");
6367                    }
6368                }
6369            }
6370            // In the end every dose is there, whatever carried them.
6371            for minute in 120..140 {
6372                let now = NOW + minute * MINUTE;
6373                desk.exchange(&server, &key, now);
6374                carrier.carry(&server, &phone_folder);
6375                phone.exchange(&phone_folder, &key, now + 30_000);
6376            }
6377            let record = phone.read("state/health-state.toml");
6378            for dose in &marked {
6379                assert!(record.contains(dose.as_str()), "{how:?}: {dose} never came:\n{record}");
6380            }
6381            let _ = std::fs::remove_dir_all(&base);
6382        }
6383    }
6384
6385    #[test]
6386    fn edrive_brings_every_rewrite() {
6387        let base = scratch("edrive");
6388        let (server, phone_folder) = (base.join("server").join("Sioul"), base.join("phone").join("Sioul"));
6389        let key = quick_key(&server, "four words make a passphrase").unwrap();
6390        let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
6391        let mut edrive = Edrive::default();
6392        let health = |computer: &Computer, folder: &Path, now: i64, closed: bool| {
6393            let wrote = written(&computer.memory, &computer.id);
6394            if closed {
6395                crate::lease::close(folder, &key, "health", &computer.id, now / 1000, wrote).unwrap();
6396            } else {
6397                crate::lease::renew(folder, &key, "health", &computer.id, now / 1000, now / 1000, false, crate::lease::Rule::FollowsYou, wrote).unwrap();
6398            }
6399        };
6400
6401        // The desk marks a dose and shares it; the phone joins through eDrive.
6402        desk.write("state/health-state.toml", "[taken]\n\"levothyroxine@2026-10-05T07:30\" = 1790000000\n");
6403        desk.exchange(&server, &key, NOW);
6404        health(&desk, &server, NOW, false);
6405        edrive.carry(&server, &phone_folder);
6406        phone.exchange(&phone_folder, &key, NOW + MINUTE);
6407        assert!(phone.read("state/health-state.toml").contains("levothyroxine@2026-10-05T07:30"));
6408
6409        // Hours of rewrites on the desk, each carried: every one comes, the last one closed.
6410        for minute in 2..200 {
6411            let now = NOW + minute * MINUTE;
6412            if minute == 120 {
6413                desk.write("state/health-state.toml", &format!("{}\"levothyroxine@2026-10-06T07:30\" = 1790090000\n", desk.read("state/health-state.toml")));
6414            }
6415            desk.exchange(&server, &key, now);
6416            health(&desk, &server, now, minute == 199);
6417            edrive.carry(&server, &phone_folder);
6418            for name in [format!("{}.toml", desk.id), format!("leases/health/{}.lease", desk.id)] {
6419                assert_eq!(std::fs::read(phone_folder.join(&name)).ok(), std::fs::read(server.join(&name)).ok(), "minute {minute}: {name} stale on the phone");
6420            }
6421        }
6422        // On the phone: the second dose, and the desk's claim saying it closed and how far it wrote.
6423        phone.exchange(&phone_folder, &key, NOW + 200 * MINUTE);
6424        assert!(phone.read("state/health-state.toml").contains("levothyroxine@2026-10-06T07:30"));
6425        let claim = crate::lease::claims(&phone_folder, &key, "health").into_iter().find(|c| c.computer == desk.id).unwrap();
6426        assert!(claim.closed && claim.wrote == written(&desk.memory, &desk.id), "{claim:?}");
6427        // The phone's own files went up untouched.
6428        let phone_record = format!("{}-1.jsonl", phone.id);
6429        assert_eq!(std::fs::read(server.join(&phone_record)).ok(), std::fs::read(phone_folder.join(&phone_record)).ok());
6430        let _ = std::fs::remove_dir_all(&base);
6431    }
6432
6433    /// The build that wrote them (docs/database.md, "The log"): on the first
6434    /// line of each batch a computer appends, sealed with it; lines without it
6435    /// (an older Sioul's) read as ever.
6436    #[test]
6437    fn each_batch_of_records_says_its_build() {
6438        let base = scratch("build");
6439        let folder = base.join("Sioul");
6440        let key = quick_key(&folder, "four words make a passphrase").unwrap();
6441        let desk = Computer::new(&base, "desk");
6442        desk.write("config/safe-senders.txt", "a@example.org\nb@example.org\n");
6443        desk.exchange(&folder, &key, NOW);
6444        let own = round_file(&folder, &desk.id, 1);
6445        let first = std::fs::read_to_string(&own).unwrap().lines().count();
6446        desk.write("config/safe-senders.txt", "a@example.org\nb@example.org\nc@example.org\n");
6447        desk.exchange(&folder, &key, NOW + MINUTE);
6448        let text = std::fs::read_to_string(&own).unwrap();
6449        let builds: Vec<String> = text
6450            .lines()
6451            .map(|l| {
6452                let line: Line = serde_json::from_str(l).unwrap();
6453                serde_json::from_slice::<Change>(&open(&key, &bound(&desk.id, 1, line.n, line.c), &line.s).unwrap()).unwrap().build
6454            })
6455            .collect();
6456        assert!(first >= 2 && builds.len() > first, "{builds:?}");
6457        for (n, build) in builds.iter().enumerate() {
6458            assert_eq!(build.as_str(), if n == 0 || n == first { sioul_core::build::DESCRIBED } else { "" }, "line {n}");
6459        }
6460        assert!(!text.contains(sioul_core::build::DESCRIBED), "sealed: the server never learns it");
6461        // A new round opens with the build too.
6462        std::fs::write(&own, text.lines().next().unwrap().to_string() + "\n").unwrap();
6463        desk.exchange(&folder, &key, NOW + 2 * MINUTE);
6464        let line: Line = serde_json::from_str(std::fs::read_to_string(round_file(&folder, &desk.id, 2)).unwrap().lines().next().unwrap()).unwrap();
6465        let change: Change = serde_json::from_slice(&open(&key, &bound(&desk.id, 2, line.n, line.c), &line.s).unwrap()).unwrap();
6466        assert_eq!(change.build, sioul_core::build::DESCRIBED);
6467        // Another computer reads them all, those written before the build was said among them.
6468        let laptop = Computer::new(&base, "laptop");
6469        let stranger = uuid::Uuid::new_v4().to_string();
6470        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 }));
6471        laptop.exchange(&folder, &key, NOW + 3 * MINUTE);
6472        let safe = laptop.read("config/safe-senders.txt");
6473        assert!(["a@", "b@", "c@", "d@"].iter().all(|a| safe.contains(a)), "{safe}");
6474        let _ = std::fs::remove_dir_all(&base);
6475    }
6476
6477    #[test]
6478    fn a_record_put_back_shorter_starts_again() {
6479        let base = scratch("cut");
6480        let folder = base.join("Sioul");
6481        let key = quick_key(&folder, "four words make a passphrase").unwrap();
6482        let desk = Computer::new(&base, "desk");
6483        desk.write("config/safe-senders.txt", "a@example.org\n");
6484        desk.exchange(&folder, &key, NOW);
6485        desk.write("config/safe-senders.txt", "a@example.org\nb@example.org\n");
6486        desk.exchange(&folder, &key, NOW + MINUTE);
6487        // A sync app puts back the first version of the desk's file: its second line is lost.
6488        let own = round_file(&folder, &desk.id, 1);
6489        let text = std::fs::read_to_string(&own).unwrap();
6490        std::fs::write(&own, text.lines().next().unwrap().to_string() + "\n").unwrap();
6491        desk.write("config/safe-senders.txt", "a@example.org\nb@example.org\nc@example.org\n");
6492        let outcome = desk.exchange(&folder, &key, NOW + 2 * MINUTE);
6493        assert!(outcome.problems.iter().any(|p| p == "share-own-cut"), "{outcome:?}");
6494        assert!(round_file(&folder, &desk.id, 2).exists(), "a new file, opening on all the desk holds");
6495        // A computer joining now hears all three.
6496        let laptop = Computer::new(&base, "laptop");
6497        laptop.exchange(&folder, &key, NOW + 3 * MINUTE);
6498        let safe = laptop.read("config/safe-senders.txt");
6499        assert!(["a@", "b@", "c@"].iter().all(|a| safe.contains(a)), "{safe}");
6500        let _ = std::fs::remove_dir_all(&base);
6501    }
6502
6503    #[test]
6504    fn a_memory_restored_or_lost_goes_on_numbering() {
6505        let base = scratch("restored");
6506        let folder = base.join("Sioul");
6507        let key = quick_key(&folder, "four words make a passphrase").unwrap();
6508        let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
6509        desk.write("state/health-state.toml", "[taken]\n\"a@100\" = 100\n");
6510        desk.exchange(&folder, &key, NOW);
6511        phone.exchange(&folder, &key, NOW + MINUTE);
6512        // A backup of the desk now; then a dose marked and shared.
6513        let backup = (std::fs::read(&desk.memory).unwrap(), desk.read("state/health-state.toml"));
6514        desk.write("state/health-state.toml", "[taken]\n\"a@100\" = 100\n\"b@800\" = 800\n");
6515        desk.exchange(&folder, &key, NOW + 2 * MINUTE);
6516        phone.exchange(&folder, &key, NOW + 3 * MINUTE);
6517        assert!(phone.read("state/health-state.toml").contains("b@800"));
6518        let before = written(&desk.memory, &desk.id).unwrap();
6519        // The desk restored from its backup: the dose it marked comes back, its numbering goes on.
6520        std::fs::write(&desk.memory, &backup.0).unwrap();
6521        desk.write("state/health-state.toml", &backup.1);
6522        let outcome = desk.exchange(&folder, &key, NOW + 4 * MINUTE);
6523        assert!(outcome.problems.iter().any(|p| p == "share-own-ahead"), "{outcome:?}");
6524        assert!(desk.read("state/health-state.toml").contains("b@800"), "{}", desk.read("state/health-state.toml"));
6525        desk.write("state/health-state.toml", &format!("{}\"c@900\" = 900\n", desk.read("state/health-state.toml")));
6526        desk.exchange(&folder, &key, NOW + 5 * MINUTE);
6527        let after = written(&desk.memory, &desk.id).unwrap();
6528        assert!(after > before && after.0 == before.0, "numbering goes on in the same round: {before:?} → {after:?}");
6529        // The phone reads it all, without a gap, and knows it all.
6530        let outcome = phone.exchange(&folder, &key, NOW + 6 * MINUTE);
6531        assert!(!outcome.problems.iter().any(|p| p.starts_with("share-other-gap")), "{outcome:?}");
6532        assert!(phone.read("state/health-state.toml").contains("c@900"));
6533        assert!(heard(&phone.memory, &phone.id).complete(&desk.id, after));
6534        // "Stop sharing", then again: the memory gone, the same.
6535        std::fs::remove_file(&desk.memory).unwrap();
6536        desk.write("state/health-state.toml", &format!("{}\"d@950\" = 950\n", desk.read("state/health-state.toml")));
6537        desk.exchange(&folder, &key, NOW + 7 * MINUTE);
6538        assert!(written(&desk.memory, &desk.id).unwrap() > after);
6539        phone.exchange(&folder, &key, NOW + 8 * MINUTE);
6540        assert!(phone.read("state/health-state.toml").contains("d@950"));
6541        assert!(heard(&phone.memory, &phone.id).complete(&desk.id, written(&desk.memory, &desk.id).unwrap()));
6542        let _ = std::fs::remove_dir_all(&base);
6543    }
6544
6545    #[test]
6546    fn a_gap_is_a_doubt_and_a_damaged_line_is_one_line() {
6547        let base = scratch("gap");
6548        let folder = base.join("Sioul");
6549        let key = quick_key(&folder, "four words make a passphrase").unwrap();
6550        let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
6551        desk.write("config/safe-senders.txt", "a@example.org\n");
6552        desk.exchange(&folder, &key, NOW);
6553        phone.exchange(&folder, &key, NOW + MINUTE);
6554        // A line that is not text, then one that is: the second is read all the same.
6555        let own = round_file(&folder, &desk.id, 1);
6556        let mut bytes = std::fs::read(&own).unwrap();
6557        bytes.extend_from_slice(b"\xff\xfe broken\n");
6558        std::fs::write(&own, &bytes).unwrap();
6559        desk.write("config/safe-senders.txt", "a@example.org\nb@example.org\n");
6560        desk.exchange(&folder, &key, NOW + 2 * MINUTE);
6561        let outcome = phone.exchange(&folder, &key, NOW + 3 * MINUTE);
6562        assert!(outcome.problems.iter().any(|p| p.starts_with("share-other-line")), "{outcome:?}");
6563        assert!(phone.read("config/safe-senders.txt").contains("b@example.org"));
6564        // Lines missing (a stale copy of the file put back, then written after): a gap, said, and not known.
6565        let line = |n: u64, value: &str| {
6566            let clock = (NOW as u64) << 16 | n;
6567            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();
6568            serde_json::to_string(&Line { n, c: clock, s: seal(&key, &bound(&desk.id, 1, n, clock), &plain) }).unwrap() + "\n"
6569        };
6570        let last = written(&desk.memory, &desk.id).unwrap().1;
6571        let mut bytes = std::fs::read(&own).unwrap();
6572        bytes.extend_from_slice(line(last + 3, "c@example.org").as_bytes());
6573        std::fs::write(&own, bytes).unwrap();
6574        let outcome = phone.exchange(&folder, &key, NOW + 4 * MINUTE);
6575        assert!(outcome.problems.iter().any(|p| p.starts_with("share-other-gap")), "{outcome:?}");
6576        assert!(!heard(&phone.memory, &phone.id).complete(&desk.id, (1, last + 3)), "past a gap, nothing is known");
6577        let _ = std::fs::remove_dir_all(&base);
6578    }
6579
6580    #[test]
6581    fn stores_that_leave_arrive_or_move_take_nothing_out() {
6582        let base = scratch("stores");
6583        let folder = base.join("Sioul");
6584        let key = quick_key(&folder, "four words make a passphrase").unwrap();
6585        let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
6586        let (desk_notes, phone_notes, moved) = (base.join("desk-notes"), base.join("phone-notes"), base.join("moved-notes"));
6587        let ledger = |notes: &Path| std::fs::read_to_string(notes.join("sioul-budgets.toml")).unwrap_or_default();
6588        let line = |label: &str, amount: f64| format!("\n[[line]]\nbudget = \"home\"\ndate = \"2026-10-01\"\namount = {amount}\nlabel = \"{label}\"\n");
6589        std::fs::create_dir_all(&desk_notes).unwrap();
6590        std::fs::write(desk_notes.join("sioul-budgets.toml"), format!("[[budget]]\nid = \"home\"\ntitle = \"Home\"\n{}", line("Rent", -620.0))).unwrap();
6591        // The phone holds an old copy of its own, made long ago, and shares without its projects.
6592        std::fs::create_dir_all(&phone_notes).unwrap();
6593        std::fs::write(phone_notes.join("sioul-budgets.toml"), format!("[[budget]]\nid = \"home\"\ntitle = \"Old home\"\n{}", line("Old", -1.0))).unwrap();
6594        let old = std::fs::File::options().write(true).open(phone_notes.join("sioul-budgets.toml")).unwrap();
6595        old.set_modified(std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_700_000_000)).unwrap();
6596        desk.exchange_notes(&folder, &key, NOW, &desk_notes);
6597        phone.exchange(&folder, &key, NOW + MINUTE);
6598        // Projects shared on the phone from now on: the desk's newer budget wins there, nothing old goes out.
6599        phone.exchange_notes(&folder, &key, NOW + 2 * MINUTE, &phone_notes);
6600        desk.exchange_notes(&folder, &key, NOW + 3 * MINUTE, &desk_notes);
6601        assert!(ledger(&phone_notes).contains("title = \"Home\"") && ledger(&phone_notes).contains("Rent"), "{}", ledger(&phone_notes));
6602        assert!(ledger(&desk_notes).contains("title = \"Home\""), "{}", ledger(&desk_notes));
6603        // The phone's notes folder moved to an empty one: filled; nothing taken out on the desk.
6604        phone.exchange_notes(&folder, &key, NOW + 4 * MINUTE, &moved);
6605        desk.exchange_notes(&folder, &key, NOW + 5 * MINUTE, &desk_notes);
6606        assert!(ledger(&moved).contains("Rent"), "{}", ledger(&moved));
6607        assert!(ledger(&desk_notes).contains("Rent"), "{}", ledger(&desk_notes));
6608        // Projects no longer shared on the phone: nothing taken out on the desk.
6609        phone.exchange(&folder, &key, NOW + 6 * MINUTE);
6610        desk.exchange_notes(&folder, &key, NOW + 7 * MINUTE, &desk_notes);
6611        assert!(ledger(&desk_notes).contains("Rent"), "{}", ledger(&desk_notes));
6612        let _ = std::fs::remove_dir_all(&base);
6613    }
6614
6615    #[test]
6616    fn a_file_emptied_takes_nothing_out_at_once() {
6617        let base = scratch("emptied");
6618        let folder = base.join("Sioul");
6619        let key = quick_key(&folder, "four words make a passphrase").unwrap();
6620        let (desk, laptop) = (Computer::new(&base, "desk"), Computer::new(&base, "laptop"));
6621        let settings = "language = \"fr\"\n\n[tasks]\nkind = \"plan\"\n";
6622        desk.write("config/config.toml", settings);
6623        desk.exchange(&folder, &key, NOW);
6624        laptop.exchange(&folder, &key, NOW + MINUTE);
6625        // Read of no bytes (a crash, a full disk, written in place): held.
6626        desk.write("config/config.toml", "");
6627        let outcome = desk.exchange(&folder, &key, NOW + 2 * MINUTE);
6628        assert!(outcome.problems.iter().any(|p| p.starts_with("share-emptied")), "{outcome:?}");
6629        laptop.exchange(&folder, &key, NOW + 3 * MINUTE);
6630        assert!(laptop.read("config/config.toml").contains("language = \"fr\""));
6631        // Back before the wait: nothing happened.
6632        desk.write("config/config.toml", settings);
6633        assert_eq!(desk.exchange(&folder, &key, NOW + 4 * MINUTE).sent, 0);
6634        // A list emptied on purpose is taken out at once.
6635        desk.write("config/safe-senders.txt", "a@example.org\n");
6636        desk.exchange(&folder, &key, NOW + 5 * MINUTE);
6637        desk.write("config/safe-senders.txt", "");
6638        desk.exchange(&folder, &key, NOW + 6 * MINUTE);
6639        laptop.exchange(&folder, &key, NOW + 7 * MINUTE);
6640        assert!(!laptop.read("config/safe-senders.txt").contains("a@example.org"));
6641        let _ = std::fs::remove_dir_all(&base);
6642    }
6643
6644    /// The file a server is sent at each change stays small (docs/database.md,
6645    /// "The log"): past `SEGMENT`, a round is continued in a new one, nothing
6646    /// restated; a full round only past `ROUND_SIZE` appended (or later while
6647    /// the connection is metered); old rounds go only before the last full
6648    /// one; a device joining late reads every entry from what is kept.
6649    #[test]
6650    fn a_change_sends_a_small_file_and_a_late_joiner_reads_everything() {
6651        let base = scratch("segments");
6652        let folder = base.join("Sioul");
6653        let key = quick_key(&folder, "four words make a passphrase").unwrap();
6654        let (desk, laptop) = (Computer::new(&base, "desk"), Computer::new(&base, "laptop"));
6655        // A desk holding much: three thousand senders, its opening far past a segment.
6656        let many: String = (0..3000).map(|n| format!("sender{n}@example.org\n")).collect();
6657        desk.write("config/known-senders.txt", &many);
6658        desk.exchange(&folder, &key, NOW);
6659        laptop.exchange(&folder, &key, NOW + 30_000);
6660        let current = |c: &Computer| {
6661            let all = rounds(&folder);
6662            let last = *all.get(&c.id).unwrap().last().unwrap();
6663            std::fs::metadata(round_file(&folder, &c.id, last)).unwrap().len()
6664        };
6665        assert!(current(&desk) > SEGMENT, "the first file holds it all");
6666        // A sender a minute for an hour: each change goes into a small file.
6667        let mut list = many.clone();
6668        for minute in 1..=60 {
6669            list.push_str(&format!("new{minute}@example.org\n"));
6670            desk.write("config/known-senders.txt", &list);
6671            desk.exchange(&folder, &key, NOW + minute * MINUTE);
6672            assert!(current(&desk) <= SEGMENT + 4096, "minute {minute}: the file sent is {} bytes", current(&desk));
6673            laptop.exchange(&folder, &key, NOW + minute * MINUTE + 30_000);
6674        }
6675        assert!(laptop.read("config/known-senders.txt").contains("new60@example.org"));
6676        // Past a megabyte appended: a full round, then the rounds before it go once the laptop read past them.
6677        let more: String = (0..8000).map(|n| format!("later{n}@example.org\n")).collect();
6678        list.push_str(&more);
6679        desk.write("config/known-senders.txt", &list);
6680        desk.exchange(&folder, &key, NOW + 61 * MINUTE);
6681        let full = Memory::load(&desk.memory, &desk.id).full_round;
6682        assert!(full > 1, "a full round opened");
6683        for minute in 62..66 {
6684            laptop.exchange(&folder, &key, NOW + minute * MINUTE + 30_000);
6685            list.push_str(&format!("after{minute}@example.org\n"));
6686            desk.write("config/known-senders.txt", &list);
6687            desk.exchange(&folder, &key, NOW + minute * MINUTE);
6688        }
6689        let kept = rounds(&folder).get(&desk.id).cloned().unwrap();
6690        assert!(kept[0] >= full && kept.len() > 1, "only the last full round and those continuing it: {kept:?} (full {full})");
6691        // A device joining now reads everything from what is kept.
6692        let late = Computer::new(&base, "late");
6693        late.exchange(&folder, &key, NOW + 70 * MINUTE);
6694        let known = late.read("config/known-senders.txt");
6695        for address in ["sender0@", "sender2999@", "new1@", "new60@", "later7999@", "after65@"] {
6696            assert!(known.contains(address), "{address} missing for the late joiner");
6697        }
6698        let _ = std::fs::remove_dir_all(&base);
6699    }
6700
6701    /// While the connection is metered or slow, a full round waits, within
6702    /// `FRUGAL_ROUND_SIZE`; then it opens all the same.
6703    #[test]
6704    fn a_full_round_waits_while_the_connection_is_metered() {
6705        let base = scratch("frugal");
6706        let folder = base.join("Sioul");
6707        let key = quick_key(&folder, "four words make a passphrase").unwrap();
6708        let desk = Computer::new(&base, "desk");
6709        let memory_full = || Memory::load(&desk.memory, &desk.id).full_round;
6710        let lines = |from: usize, count: usize| -> String { (from..from + count).map(|n| format!("s{n}@example.org\n")).collect() };
6711        set_frugal(&desk.memory, true);
6712        let mut list = lines(0, 6000);
6713        desk.write("config/known-senders.txt", &list);
6714        desk.exchange(&folder, &key, NOW);
6715        list.push_str(&lines(6000, 7000));
6716        desk.write("config/known-senders.txt", &list);
6717        desk.exchange(&folder, &key, NOW + MINUTE);
6718        let waited = memory_full();
6719        assert!(Memory::load(&desk.memory, &desk.id).grown > ROUND_SIZE && waited <= 1, "past a megabyte, metered: no full round yet ({waited})");
6720        set_frugal(&desk.memory, false);
6721        list.push_str(&lines(13000, 10));
6722        desk.write("config/known-senders.txt", &list);
6723        desk.exchange(&folder, &key, NOW + 2 * MINUTE);
6724        assert!(memory_full() > 1, "not metered any more: the full round opens");
6725        let _ = std::fs::remove_dir_all(&base);
6726    }
6727
6728    #[test]
6729    fn a_big_round_does_not_start_another_each_minute() {
6730        let base = scratch("big");
6731        let folder = base.join("Sioul");
6732        let key = quick_key(&folder, "four words make a passphrase").unwrap();
6733        let desk = Computer::new(&base, "desk");
6734        desk.write("data/drafts/big.toml", &format!("body = \"{}\"\n", "x".repeat(ROUND_SIZE as usize + 1000)));
6735        desk.exchange(&folder, &key, NOW);
6736        desk.write("config/safe-senders.txt", "a@example.org\n");
6737        desk.exchange(&folder, &key, NOW + MINUTE);
6738        let rounds_then = rounds(&folder).get(&desk.id).map_or(0, |r| *r.last().unwrap());
6739        for minute in 2..6 {
6740            desk.write("config/safe-senders.txt", &format!("a@example.org\nn{minute}@example.org\n"));
6741            desk.exchange(&folder, &key, NOW + minute * MINUTE);
6742        }
6743        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");
6744        let _ = std::fs::remove_dir_all(&base);
6745    }
6746
6747    #[test]
6748    fn a_half_written_line_is_cut_before_the_next() {
6749        let base = scratch("half");
6750        let folder = base.join("Sioul");
6751        let key = quick_key(&folder, "four words make a passphrase").unwrap();
6752        let (desk, laptop) = (Computer::new(&base, "desk"), Computer::new(&base, "laptop"));
6753        desk.write("config/safe-senders.txt", "a@example.org\n");
6754        desk.exchange(&folder, &key, NOW);
6755        let own = round_file(&folder, &desk.id, 1);
6756        let mut text = std::fs::read_to_string(&own).unwrap();
6757        text.push_str("{\"n\":99,\"c\":1,\"s\":\"half");
6758        std::fs::write(&own, text).unwrap();
6759        desk.write("config/safe-senders.txt", "a@example.org\nb@example.org\n");
6760        desk.exchange(&folder, &key, NOW + MINUTE);
6761        assert!(std::fs::read_to_string(&own).unwrap().lines().all(|l| l.ends_with('}')), "whole lines only");
6762        laptop.exchange(&folder, &key, NOW + 2 * MINUTE);
6763        assert!(laptop.read("config/safe-senders.txt").contains("b@example.org"));
6764        let _ = std::fs::remove_dir_all(&base);
6765    }
6766
6767    #[test]
6768    fn the_days_reviews_travel_whole() {
6769        use sioul_core::reviews::{Felt, Mix, Reviews};
6770        let base = scratch("reviews");
6771        let folder = base.join("Nextcloud").join("sioul-shared");
6772        let key = quick_key(&folder, "four words make a passphrase").unwrap();
6773        let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
6774        let at = |ms: i64| std::time::UNIX_EPOCH + std::time::Duration::from_millis(ms as u64);
6775        let month = "data/reviews/2026-10.toml";
6776        // The morning's weather and the end of work, on the desk.
6777        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");
6778        dated(&desk.path(month), at(NOW - 10 * MINUTE));
6779        desk.exchange(&folder, &key, NOW);
6780        phone.exchange(&folder, &key, NOW + MINUTE);
6781        assert!(phone.read(month).contains("felt = \"heavy\""));
6782        // The same work review answered again on both: its feeling on the desk,
6783        // then its mix on the phone, with the night.
6784        desk.write(month, &desk.read(month).replace("felt = \"heavy\"", "felt = \"usual\""));
6785        dated(&desk.path(month), at(NOW + 90_000));
6786        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\"")));
6787        dated(&phone.path(month), at(NOW + 100_000));
6788        phone.exchange(&folder, &key, NOW + 2 * MINUTE);
6789        desk.exchange(&folder, &key, NOW + 3 * MINUTE);
6790        phone.exchange(&folder, &key, NOW + 4 * MINUTE);
6791        for computer in [&desk, &phone] {
6792            let text = computer.read(month);
6793            let reviews = Reviews::load(&computer.path("data/reviews"));
6794            let day = reviews.day("2026-10-06".parse().unwrap()).unwrap();
6795            assert_eq!(day.weather, Some(sioul_core::today::Weather::Haze), "{text}");
6796            // The later answer whole: never the desk's feeling with the phone's mix.
6797            assert_eq!(day.work.as_ref().map(|r| (r.felt, r.mix)), Some((Some(Felt::Heavy), Some(Mix::AboutRight))), "{text}");
6798            assert_eq!(day.night.as_ref().map(|r| (r.mix, r.memo.trim().to_string())), Some((Some(Mix::AboutRight), "A walk.".to_string())), "{text}");
6799        }
6800        let _ = std::fs::remove_dir_all(&base);
6801    }
6802
6803    #[test]
6804    fn two_computers_agree() {
6805        let base = scratch("agree");
6806        let folder = base.join("Nextcloud").join("sioul-shared");
6807        let key = quick_key(&folder, "four words make a passphrase").unwrap();
6808        let (desk, laptop) = (Computer::new(&base, "desk"), Computer::new(&base, "laptop"));
6809
6810        // The desk shares first: its settings, its lists, a draft, a session.
6811        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");
6812        desk.write("config/blocked-senders.txt", "spam@example.org\n");
6813        desk.write("data/drafts/a1.toml", "subject = \"Hello\"\n");
6814        desk.write("data/time/2026-10.toml", "[[session]]\ntask = \"t1\"\nstart = 100\nminutes = 25\n");
6815        assert_eq!(desk.exchange(&folder, &key, NOW).received, 0);
6816
6817        // The laptop joins with its own: its notes folder stays, its blocked sender joins the desk's.
6818        laptop.write("config/config.toml", "language = \"en\"\ncase_store = \"~/Nextcloud/Notes\"\n");
6819        laptop.write("config/blocked-senders.txt", "*@pushy.example.com\n");
6820        let outcome = laptop.exchange(&folder, &key, NOW + MINUTE);
6821        assert!(outcome.received > 0 && outcome.sent > 0, "{outcome:?}");
6822        let config = laptop.read("config/config.toml");
6823        assert!(config.contains("language = \"fr\""), "the shared settings win when joining: {config}");
6824        assert!(config.contains("case_store = \"~/Nextcloud/Notes\""), "where things are stays here: {config}");
6825        assert!(config.contains("id = \"perso\"") && !config.contains("/desk/mail"), "accounts come, without this computer's paths: {config}");
6826        assert_eq!(laptop.read("data/drafts/a1.toml"), "subject = \"Hello\"\n");
6827        assert!(laptop.read("data/time/2026-10.toml").contains("task = \"t1\""));
6828        let blocked = laptop.read("config/blocked-senders.txt");
6829        assert!(blocked.contains("spam@example.org") && blocked.contains("*@pushy.example.com"), "{blocked}");
6830
6831        // The desk hears the laptop's sender.
6832        desk.exchange(&folder, &key, NOW + 2 * MINUTE);
6833        assert!(desk.read("config/blocked-senders.txt").contains("*@pushy.example.com"));
6834
6835        // Both work apart: a session each in the same month, a setting each, a draft sent on the laptop.
6836        desk.write("data/time/2026-10.toml", "[[session]]\ntask = \"t1\"\nstart = 100\nminutes = 25\n\n[[session]]\ntask = \"t2\"\nstart = 200\nminutes = 50\n");
6837        desk.write("config/config.toml", &desk.read("config/config.toml").replace("kind = \"plan\"", "kind = \"list\""));
6838        laptop.write("data/time/2026-10.toml", &format!("{}\n[[session]]\nproject = \"p\"\nstart = 300\nminutes = 10\n", laptop.read("data/time/2026-10.toml")));
6839        let config = laptop.read("config/config.toml").replace("language = \"fr\"", "language = \"de\"");
6840        laptop.write("config/config.toml", &config);
6841        std::fs::remove_file(laptop.path("data/drafts/a1.toml")).unwrap();
6842        desk.exchange(&folder, &key, NOW + 3 * MINUTE);
6843        laptop.exchange(&folder, &key, NOW + 4 * MINUTE);
6844        desk.exchange(&folder, &key, NOW + 5 * MINUTE);
6845        for computer in [&desk, &laptop] {
6846            let time = computer.read("data/time/2026-10.toml");
6847            assert!(time.contains("\"t1\"") && time.contains("\"t2\"") && time.contains("project = \"p\""), "{time}");
6848            let config = computer.read("config/config.toml");
6849            assert!(config.contains("language = \"de\"") && config.contains("kind = \"list\""), "{config}");
6850            assert!(!computer.path("data/drafts/a1.toml").exists(), "a draft sent on one is gone on both");
6851        }
6852        assert!(desk.read("config/config.toml").contains("maildir = \"/desk/mail\""), "the desk keeps its own path");
6853
6854        // Nothing changed: nothing goes out.
6855        assert_eq!(desk.exchange(&folder, &key, NOW + 6 * MINUTE).sent, 0);
6856        assert_eq!(laptop.exchange(&folder, &key, NOW + 7 * MINUTE).sent, 0);
6857        let _ = std::fs::remove_dir_all(&base);
6858    }
6859
6860    #[test]
6861    fn projects_and_budgets_travel_when_asked() {
6862        let base = scratch("projects");
6863        let folder = base.join("Nextcloud").join("Documents").join("Sioul");
6864        let key = quick_key(&folder, "four words make a passphrase").unwrap();
6865        let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
6866        let (desk_notes, phone_notes) = (base.join("desk-notes"), base.join("phone-notes"));
6867        let projects = |notes: &Path| std::fs::read_to_string(sioul_core::projects::file_in(notes)).unwrap_or_default();
6868        let ledger = |notes: &Path| std::fs::read_to_string(notes.join("sioul-budgets.toml")).unwrap_or_default();
6869        let bank = |notes: &Path| std::fs::read_to_string(notes.join("sioul-bank.toml")).unwrap_or_default();
6870        let movement = |id: &str, label: &str| format!("\n[[movement]]\naccount = \"main\"\ndate = 2026-10-01\namount = -20.0\nlabel = \"{label}\"\nid = \"{id}\"\n");
6871        std::fs::create_dir_all(&desk_notes).unwrap();
6872        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();
6873        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();
6874        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();
6875        desk.exchange_notes(&folder, &key, NOW, &desk_notes);
6876
6877        // The phone, with a notes folder of its own: the project, the budget and the bank come.
6878        phone.exchange_notes(&folder, &key, NOW + MINUTE, &phone_notes);
6879        assert!(projects(&phone_notes).contains("id = \"acme\"") && projects(&phone_notes).contains("acme.example"), "{}", projects(&phone_notes));
6880        assert!(ledger(&phone_notes).contains("Rent"), "{}", ledger(&phone_notes));
6881        assert!(bank(&phone_notes).contains("fitid-1") && bank(&phone_notes).contains("1200"), "{}", bank(&phone_notes));
6882        // The phone had no projects' file: it makes one of the new name, the desk keeps its own.
6883        assert!(phone_notes.join(sioul_core::projects::FILE).exists() && projects(&phone_notes).contains("[[project]]") && !projects(&phone_notes).contains("[[case"), "{}", projects(&phone_notes));
6884        assert!(projects(&desk_notes).contains("[[case]]") && !desk_notes.join(sioul_core::projects::FILE).exists());
6885
6886        // A line each, apart: both kept on both.
6887        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();
6888        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();
6889        // An export taken in on each: both movements on both.
6890        std::fs::write(desk_notes.join("sioul-bank.toml"), format!("{}{}", bank(&desk_notes), movement("fitid-2", "Bread"))).unwrap();
6891        std::fs::write(phone_notes.join("sioul-bank.toml"), format!("{}{}", bank(&phone_notes), movement("fitid-3", "Coffee"))).unwrap();
6892        desk.exchange_notes(&folder, &key, NOW + 2 * MINUTE, &desk_notes);
6893        phone.exchange_notes(&folder, &key, NOW + 3 * MINUTE, &phone_notes);
6894        desk.exchange_notes(&folder, &key, NOW + 4 * MINUTE, &desk_notes);
6895        for notes in [&desk_notes, &phone_notes] {
6896            let text = ledger(notes);
6897            assert!(text.contains("Rent") && text.contains("Bread") && text.contains("Coffee"), "{text}");
6898            let mut ids: Vec<String> = sioul_core::bank::Bank::load(notes).unwrap().movements.into_iter().map(|m| m.id).collect();
6899            ids.sort();
6900            assert_eq!(ids, ["fitid-1", "fitid-2", "fitid-3"], "{}", bank(notes));
6901        }
6902
6903        // Each file keeps its names: a project made on the phone reaches the desk's
6904        // `[[case]]`, as an older Sioul reads it; renamed on the desk, the file
6905        // still exchanges under the same records, both ways.
6906        use sioul_core::projects::{ProjectEdit, ProjectStore, file_in, rename_file, save_project, set_ai};
6907        save_project(&file_in(&phone_notes), "", &ProjectEdit { title: "Garden".into(), ..ProjectEdit::default() }).unwrap();
6908        phone.exchange_notes(&folder, &key, NOW + 5 * MINUTE, &phone_notes);
6909        desk.exchange_notes(&folder, &key, NOW + 6 * MINUTE, &desk_notes);
6910        assert!(projects(&desk_notes).matches("[[case]]").count() == 2 && !projects(&desk_notes).contains("[[project"), "{}", projects(&desk_notes));
6911        // Added after a project with a route: each route stays under its own project.
6912        let desk_store = ProjectStore::load(&desk_notes).unwrap();
6913        assert!(desk_store.get("acme").unwrap().routes.len() == 1 && desk_store.get("garden").unwrap().routes.is_empty(), "{}", projects(&desk_notes));
6914        rename_file(&desk_notes).unwrap();
6915        set_ai(&file_in(&desk_notes), "acme", true).unwrap();
6916        desk.exchange_notes(&folder, &key, NOW + 7 * MINUTE, &desk_notes);
6917        phone.exchange_notes(&folder, &key, NOW + 8 * MINUTE, &phone_notes);
6918        desk.exchange_notes(&folder, &key, NOW + 9 * MINUTE, &desk_notes);
6919        for notes in [&desk_notes, &phone_notes] {
6920            let store = ProjectStore::load(notes).unwrap();
6921            let mut ids: Vec<&str> = store.projects.iter().map(|p| p.id.as_str()).collect();
6922            ids.sort();
6923            assert_eq!(ids, ["acme", "garden"], "{}", projects(notes));
6924            assert!(store.get("acme").unwrap().ai && store.get("acme").unwrap().routes.len() == 1 && store.get("garden").unwrap().routes.is_empty(), "{}", projects(notes));
6925            assert!(!projects(notes).contains("[[case"), "{}", projects(notes));
6926        }
6927        assert!(desk_notes.join(sioul_core::projects::BEFORE_RENAME).exists());
6928
6929        // Without the choice, a notes folder stays home.
6930        let laptop = Computer::new(&base, "laptop");
6931        // A literal string: a Windows path's backslashes are no escapes.
6932        laptop.write("config/config.toml", &format!("case_store = '{}'\n", base.join("laptop-notes").display()));
6933        let config: Config = toml::from_str(&laptop.read("config/config.toml")).unwrap();
6934        assert!(stores(&config, &laptop.roots).iter().all(|s| !s.name.starts_with("notes/")));
6935        let _ = std::fs::remove_dir_all(&base);
6936    }
6937
6938    #[test]
6939    fn what_was_read_is_known_and_a_lost_record_comes_back() {
6940        let base = scratch("doses");
6941        let folder = base.join("shared");
6942        let key = quick_key(&folder, "four words make a passphrase").unwrap();
6943        let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
6944        desk.write("state/health-state.toml", "[taken]\n\"d@100\" = 110\n");
6945        desk.exchange(&folder, &key, NOW);
6946        phone.exchange(&folder, &key, NOW + MINUTE);
6947        assert!(phone.read("state/health-state.toml").contains("d@100"));
6948        // What the desk wrote, the phone read: as far as the desk's claim would say.
6949        let wrote = written(&desk.memory, &desk.id).unwrap();
6950        assert!(wrote.1 > 0);
6951        let read = heard(&phone.memory, &phone.id);
6952        assert!(read.complete(&desk.id, wrote));
6953        assert!(!read.complete(&desk.id, (wrote.0, wrote.1 + 1)), "a record not read yet is not known");
6954        assert!(read.complete("someone", (1, 0)), "nothing written, nothing to read");
6955
6956        // A line of the desk's that does not read is said, never passed over; what follows is still read.
6957        desk.write("state/health-state.toml", "[taken]\n\"d@100\" = 110\n\"d@200\" = 210\n");
6958        desk.exchange(&folder, &key, NOW + 2 * MINUTE);
6959        let round = round_file(&folder, &desk.id, wrote.0);
6960        let mut text = std::fs::read_to_string(&round).unwrap();
6961        let last = text.trim_end().rfind('\n').map_or(0, |i| i + 1);
6962        text.insert_str(last, "{broken\n");
6963        std::fs::write(&round, text).unwrap();
6964        let outcome = phone.exchange(&folder, &key, NOW + 3 * MINUTE);
6965        assert!(outcome.problems.iter().any(|p| p == &format!("share-other-line:{}", desk.id)), "{:?}", outcome.problems);
6966        assert!(heard(&phone.memory, &phone.id).broken.contains_key(&desk.id));
6967        assert!(phone.read("state/health-state.toml").contains("d@200"), "the next line came");
6968
6969        // The phone's record is lost: set aside, started again empty, rebuilt from the records.
6970        phone.write("state/health-state.toml", &format!("{}\"d@300\" = 310\n", phone.read("state/health-state.toml")));
6971        phone.exchange(&folder, &key, NOW + 4 * MINUTE);
6972        assert!(desk.exchange(&folder, &key, NOW + 5 * MINUTE).received > 0);
6973        assert!(desk.read("state/health-state.toml").contains("d@300"));
6974        std::fs::remove_file(phone.path("state/health-state.toml")).unwrap();
6975        rebuild(&phone.memory, &phone.id, &["state/health-state.toml"]).unwrap();
6976        // A dose marked meanwhile, in a new record holding only it.
6977        phone.write("state/health-state.toml", "[taken]\n\"d@400\" = 410\n");
6978        phone.exchange(&folder, &key, NOW + 6 * MINUTE);
6979        let back = phone.read("state/health-state.toml");
6980        assert!(back.contains("d@100") && back.contains("d@200") && back.contains("d@300") && back.contains("d@400"), "{back}");
6981        // Nothing taken out on the desk: every mark is still there, the new one too.
6982        desk.exchange(&folder, &key, NOW + 7 * MINUTE);
6983        let there = desk.read("state/health-state.toml");
6984        assert!(there.contains("d@100") && there.contains("d@200") && there.contains("d@300") && there.contains("d@400"), "{there}");
6985        let _ = std::fs::remove_dir_all(&base);
6986    }
6987
6988    #[test]
6989    fn the_later_word_wins() {
6990        let base = scratch("later");
6991        let folder = base.join("shared");
6992        let key = quick_key(&folder, "a passphrase of some length").unwrap();
6993        let (a, b) = (Computer::new(&base, "a"), Computer::new(&base, "b"));
6994        a.write("config/safe-senders.txt", "friend@example.org\n");
6995        a.exchange(&folder, &key, NOW);
6996        b.exchange(&folder, &key, NOW + MINUTE);
6997        assert!(b.read("config/safe-senders.txt").contains("friend@example.org"));
6998
6999        // Safe on one, then blocked later on the other: blocked everywhere, and safe nowhere.
7000        b.write("config/safe-senders.txt", "");
7001        b.write("config/blocked-senders.txt", "friend@example.org\n");
7002        b.exchange(&folder, &key, NOW + 2 * MINUTE);
7003        a.exchange(&folder, &key, NOW + 3 * MINUTE);
7004        assert!(!a.read("config/safe-senders.txt").contains("friend@example.org"));
7005        assert!(a.read("config/blocked-senders.txt").contains("friend@example.org"));
7006
7007        // A list that cannot be read is left alone, and nothing of it is taken out elsewhere.
7008        a.write("state/quiet.toml", "work_until = 5\n");
7009        let sent = a.exchange(&folder, &key, NOW + 4 * MINUTE);
7010        let received = b.exchange(&folder, &key, NOW + 5 * MINUTE);
7011        assert!(b.read("state/quiet.toml").contains("work_until = 5"), "{sent:?} {received:?}");
7012        b.write("state/quiet.toml", "work_until = [ broken");
7013        b.exchange(&folder, &key, NOW + 6 * MINUTE);
7014        a.exchange(&folder, &key, NOW + 7 * MINUTE);
7015        assert!(a.read("state/quiet.toml").contains("work_until = 5"));
7016        let _ = std::fs::remove_dir_all(&base);
7017    }
7018
7019    #[test]
7020    fn sealed_with_one_passphrase() {
7021        let base = scratch("seal");
7022        let folder = base.join("shared");
7023        let key = quick_key(&folder, "the right passphrase").unwrap();
7024        assert_eq!(quick_key(&folder, "the right passphrase"), Ok(key));
7025        assert_eq!(quick_key(&folder, "another passphrase"), Err(Refused::WrongPassphrase));
7026
7027        // What travels says nothing of what it holds.
7028        let a = Computer::new(&base, "a");
7029        a.write("config/safe-senders.txt", "friend@example.org\n");
7030        a.exchange(&folder, &key, NOW);
7031        let file = std::fs::read_to_string(round_file(&folder, &a.id, 1)).unwrap();
7032        assert!(!file.contains("friend") && !file.contains("safe-senders"), "{file}");
7033        assert_eq!(others(&folder, "someone else").len(), 1);
7034        // A seal asking more than this computer can give is refused before it is tried.
7035        let text = std::fs::read_to_string(folder.join("seal.toml")).unwrap();
7036        std::fs::write(folder.join("seal.toml"), text.replace("memory_kib = 64", "memory_kib = 4294967295")).unwrap();
7037        assert!(matches!(quick_key(&folder, "the right passphrase"), Err(Refused::Other(_))));
7038        let _ = std::fs::remove_dir_all(&base);
7039    }
7040
7041    #[test]
7042    fn records_stay_in_their_folders() {
7043        let roots = Roots { config: PathBuf::from("config"), data: PathBuf::from("data"), state: PathBuf::from("state") };
7044        let stores = stores(&Config::default(), &roots);
7045        assert!(locate(&stores, "data/drafts/a1.toml").is_some());
7046        for file in ["data/drafts/../x", "data/drafts/", "data/drafts//x", "data/drafts/a\\..\\..\\x", "data/drafts/C:x"] {
7047            assert!(locate(&stores, file).is_none(), "{file}");
7048        }
7049    }
7050
7051    #[test]
7052    fn rounds_start_over_and_old_ones_go() {
7053        let base = scratch("rounds");
7054        let folder = base.join("shared");
7055        let key = quick_key(&folder, "a passphrase of some length").unwrap();
7056        let (a, b) = (Computer::new(&base, "a"), Computer::new(&base, "b"));
7057        a.write("config/safe-senders.txt", "friend@example.org\n");
7058        a.exchange(&folder, &key, NOW);
7059        b.exchange(&folder, &key, NOW + MINUTE);
7060        // A large file, changed until a full round opens (a megabyte appended), the rounds before it continued in between.
7061        let mut now = NOW + MINUTE;
7062        while Memory::load(&a.memory, &a.id).full_round <= 1 {
7063            now += MINUTE;
7064            a.write("data/drafts/2026-10-01.json", &format!("{{\"steps\": {now}, \"pad\": \"{}\"}}", "x".repeat(100_000)));
7065            a.exchange(&folder, &key, now);
7066        }
7067        assert!(round_file(&folder, &a.id, 1).exists(), "kept until b has read past it");
7068        // b reads past round 1.
7069        b.exchange(&folder, &key, now + MINUTE);
7070        assert!(b.read("data/drafts/2026-10-01.json").contains(&format!("\"steps\": {now}")));
7071        a.exchange(&folder, &key, now + 2 * MINUTE);
7072        assert!(!round_file(&folder, &a.id, 1).exists(), "read by everyone: removed");
7073        // A third computer joins later: round 2 alone holds the day.
7074        let c = Computer::new(&base, "c");
7075        c.exchange(&folder, &key, now + 3 * MINUTE);
7076        assert!(c.read("data/drafts/2026-10-01.json").contains(&format!("\"steps\": {now}")));
7077        let _ = std::fs::remove_dir_all(&base);
7078    }
7079
7080    #[test]
7081    fn notes_travel_file_by_file_whatever_carries_them() {
7082        for how in [Carrying::Exact, Carrying::SizeOnly, Carrying::Late, Carrying::StaleCopies, Carrying::Placeholders] {
7083            let base = scratch(&format!("notes-{how:?}").to_lowercase());
7084            let (server, phone_folder) = (base.join("server").join("Sioul"), base.join("phone").join("Sioul"));
7085            let key = quick_key(&server, "four words make a passphrase").unwrap();
7086            let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
7087            let (desk_notes, phone_notes) = (base.join("desk-notes"), base.join("phone-notes"));
7088            put(&desk_notes, "lease.md", b"# Lease\nSigned on 2 October.\n");
7089            put(&desk_notes, "admin/taxes.md", b"# Taxes\nNotice received.\n");
7090            put(&desk_notes, "admin/scan.pdf", &noise(1_500_000, 1));
7091            // Made by tools, or carried by another part: they stay.
7092            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"] {
7093                put(&desk_notes, name, b"stays home");
7094            }
7095            let mut carrier = Carrier::new(how);
7096            let run = |carrier: &mut Carrier, minutes: std::ops::Range<i64>| {
7097                for minute in minutes {
7098                    let now = NOW + minute * MINUTE;
7099                    desk.exchange_with(&server, &key, now, &desk_notes, &["notes"]);
7100                    carrier.carry(&server, &phone_folder);
7101                    phone.exchange_with(&phone_folder, &key, now + 30_000, &phone_notes, &["notes"]);
7102                }
7103            };
7104            run(&mut carrier, 0..20);
7105            let there = files_in(&phone_notes);
7106            assert_eq!(there.keys().map(String::as_str).collect::<Vec<_>>(), ["admin/scan.pdf", "admin/taxes.md", "lease.md"], "{how:?}");
7107            assert!(there["admin/scan.pdf"] == noise(1_500_000, 1), "{how:?}: the scan came whole");
7108            // The phone changes a note and writes another; the desk takes one out.
7109            put(&phone_notes, "lease.md", b"# Lease\nSigned on 2 October.\nKeys: two.\n");
7110            put(&phone_notes, "new.md", b"# New\n");
7111            std::fs::remove_file(desk_notes.join("admin/taxes.md")).unwrap();
7112            run(&mut carrier, 20..40);
7113            for notes in [&desk_notes, &phone_notes] {
7114                let files = files_in(notes);
7115                assert!(!files.contains_key("admin/taxes.md"), "{how:?}: {}", notes.display());
7116                assert_eq!(files.get("lease.md").map(Vec::as_slice), Some(&b"# Lease\nSigned on 2 October.\nKeys: two.\n"[..]), "{how:?}: {}", notes.display());
7117                assert_eq!(files.get("new.md").map(Vec::as_slice), Some(&b"# New\n"[..]), "{how:?}: {}", notes.display());
7118                assert!(copies_of(notes, "lease").is_empty(), "{how:?}: no conflict where there was none");
7119            }
7120            // Each content sealed once, apart; nothing of the notes readable in the folder.
7121            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();
7122            assert_eq!(sealed, 5, "{how:?}: the lease twice, the taxes, the scan, the new note");
7123            let mut everything = Vec::new();
7124            list_files(&server, &server, &[], &mut everything);
7125            for (_, path) in everything {
7126                let bytes = std::fs::read(&path).unwrap();
7127                for secret in [&b"Signed on"[..], b"Taxes", b"Keys: two", b"stays home", b"lease.md"] {
7128                    assert!(!bytes.windows(secret.len()).any(|w| w == secret), "{how:?}: {} shows {}", path.display(), String::from_utf8_lossy(secret));
7129                }
7130            }
7131            let _ = std::fs::remove_dir_all(&base);
7132        }
7133    }
7134
7135    #[test]
7136    fn a_note_changed_on_both_keeps_both_whatever_carries_them() {
7137        for how in [Carrying::Exact, Carrying::SizeOnly, Carrying::Late, Carrying::StaleCopies, Carrying::Placeholders] {
7138            let base = scratch(&format!("notes-both-{how:?}").to_lowercase());
7139            let (server, phone_folder) = (base.join("server").join("Sioul"), base.join("phone").join("Sioul"));
7140            let key = quick_key(&server, "four words make a passphrase").unwrap();
7141            let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
7142            let (desk_notes, phone_notes) = (base.join("desk-notes"), base.join("phone-notes"));
7143            put(&desk_notes, "lease.md", b"Rent: 620\n");
7144            put(&desk_notes, "taxes.md", b"Notice received.\n");
7145            let mut carrier = Carrier::new(how);
7146            let run = |carrier: &mut Carrier, minutes: std::ops::Range<i64>| {
7147                for minute in minutes {
7148                    let now = NOW + minute * MINUTE;
7149                    desk.exchange_with(&server, &key, now, &desk_notes, &["notes"]);
7150                    carrier.carry(&server, &phone_folder);
7151                    phone.exchange_with(&phone_folder, &key, now + 30_000, &phone_notes, &["notes"]);
7152                }
7153            };
7154            run(&mut carrier, 0..10);
7155            assert_eq!(files_in(&phone_notes).len(), 2, "{how:?}");
7156            // In the same minute: the lease changed on both; the taxes taken out on the desk, changed on the phone.
7157            put(&desk_notes, "lease.md", b"Rent: 640\n");
7158            put(&phone_notes, "lease.md", b"Rent: 620\nKeys: two\n");
7159            std::fs::remove_file(desk_notes.join("taxes.md")).unwrap();
7160            put(&phone_notes, "taxes.md", b"Notice received.\nPaid on 3 October.\n");
7161            run(&mut carrier, 10..40);
7162            for notes in [&desk_notes, &phone_notes] {
7163                let files = files_in(notes);
7164                assert_eq!(files.get("lease.md").map(Vec::as_slice), Some(&b"Rent: 620\nKeys: two\n"[..]), "{how:?}: {}", notes.display());
7165                assert_eq!(copies_of(notes, "lease"), [b"Rent: 640\n".to_vec()], "{how:?}: {}: {:?}", notes.display(), files.keys().collect::<Vec<_>>());
7166                assert_eq!(files.get("taxes.md").map(Vec::as_slice), Some(&b"Notice received.\nPaid on 3 October.\n"[..]), "{how:?}: {}", notes.display());
7167                assert_eq!(files.len(), 3, "{how:?}: {}: {:?}", notes.display(), files.keys().collect::<Vec<_>>());
7168            }
7169            let _ = std::fs::remove_dir_all(&base);
7170        }
7171    }
7172
7173    #[test]
7174    fn a_big_folder_sends_only_what_changed() {
7175        let base = scratch("big-folder");
7176        let notes = base.join("notes");
7177        // Shared through a folder inside the notes folder, as Sioul may suggest: not carried as notes.
7178        let folder = notes.join("sioul-shared");
7179        let key = quick_key(&folder, "four words make a passphrase").unwrap();
7180        let desk = Computer::new(&base, "desk");
7181        for n in 0..300 {
7182            put(&notes, &format!("days/{n:03}.md"), format!("# Day {n}\n").as_bytes());
7183        }
7184        for n in 0..3 {
7185            put(&notes, &format!("scans/{n}.pdf"), &noise(1_500_000, n));
7186        }
7187        // Read after they changed, not in the same millisecond (such a file is read again, its time too coarse to trust).
7188        std::thread::sleep(std::time::Duration::from_millis(5));
7189        let blobs = || std::fs::read_dir(folder.join("blobs")).unwrap().count();
7190        assert_eq!(desk.exchange_with(&folder, &key, NOW, &notes, &["notes"]).sent, 303);
7191        assert_eq!(blobs(), 303);
7192        // Nothing changed: no file read again, nothing sent.
7193        crate::blobs::HASHED.with(|n| n.set(0));
7194        assert_eq!(desk.exchange_with(&folder, &key, NOW + MINUTE, &notes, &["notes"]).sent, 0);
7195        assert_eq!(crate::blobs::HASHED.with(std::cell::Cell::get), 0, "a file of the same size and time is not read");
7196        // One file touched (its time, not its content), one changed: both read, one sent, sealed alone.
7197        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();
7198        put(&notes, "scans/1.pdf", &noise(1_500_000, 9));
7199        let outcome = desk.exchange_with(&folder, &key, NOW + 2 * MINUTE, &notes, &["notes"]);
7200        assert_eq!(outcome.sent, 1, "{outcome:?}");
7201        assert_eq!(crate::blobs::HASHED.with(std::cell::Cell::get), 2);
7202        assert_eq!(blobs(), 304);
7203        // Too big to travel: it stays, said; nothing else moves.
7204        std::fs::File::create(notes.join("scans/film.mp4")).unwrap().set_len(crate::blobs::LARGEST + 1).unwrap();
7205        let outcome = desk.exchange_with(&folder, &key, NOW + 3 * MINUTE, &notes, &["notes"]);
7206        assert!(outcome.problems.iter().any(|p| p == "share-too-big:files/notes/scans/film.mp4"), "{outcome:?}");
7207        assert_eq!(outcome.sent, 0);
7208        let _ = std::fs::remove_dir_all(&base);
7209    }
7210
7211    #[test]
7212    fn a_note_changed_on_both_keeps_both() {
7213        for desk_later in [false, true] {
7214            let base = scratch(&format!("both-{desk_later}"));
7215            let folder = base.join("Sioul");
7216            let key = quick_key(&folder, "four words make a passphrase").unwrap();
7217            let (desk, laptop) = (Computer::new(&base, "desk"), Computer::new(&base, "laptop"));
7218            let (desk_notes, laptop_notes) = (base.join("desk-notes"), base.join("laptop-notes"));
7219            put(&desk_notes, "lease.md", b"Rent: 620\n");
7220            desk.exchange_with(&folder, &key, NOW, &desk_notes, &["notes"]);
7221            laptop.exchange_with(&folder, &key, NOW + MINUTE, &laptop_notes, &["notes"]);
7222            assert_eq!(std::fs::read(laptop_notes.join("lease.md")).unwrap(), b"Rent: 620\n");
7223            // Changed on both, apart; the one that exchanges later is the later word.
7224            put(&desk_notes, "lease.md", b"Rent: 640\n");
7225            put(&laptop_notes, "lease.md", b"Rent: 620\nKeys: two\n");
7226            let (first, second): (&Computer, &Computer) = if desk_later { (&laptop, &desk) } else { (&desk, &laptop) };
7227            let notes = |c: &Computer| if std::ptr::eq(c, &desk) { desk_notes.clone() } else { laptop_notes.clone() };
7228            let mut said = Vec::new();
7229            for minute in 2..10 {
7230                let (who, at) = if minute % 2 == 0 { (first, minute) } else { (second, minute) };
7231                said.extend(who.exchange_with(&folder, &key, NOW + at * MINUTE, &notes(who), &["notes"]).problems);
7232            }
7233            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") };
7234            for notes in [&desk_notes, &laptop_notes] {
7235                assert_eq!(std::fs::read(notes.join("lease.md")).unwrap(), later, "desk later: {desk_later}");
7236                assert_eq!(copies_of(notes, "lease"), [earlier.to_vec()], "desk later: {desk_later}: {:?}", files_in(notes).keys().collect::<Vec<_>>());
7237            }
7238            assert!(said.iter().any(|p| p.starts_with("share-conflict:lease (")), "{said:?}");
7239            // Settled: nothing more goes either way.
7240            assert_eq!(desk.exchange_with(&folder, &key, NOW + 10 * MINUTE, &desk_notes, &["notes"]).sent, 0);
7241            assert_eq!(laptop.exchange_with(&folder, &key, NOW + 11 * MINUTE, &laptop_notes, &["notes"]).sent, 0);
7242            let _ = std::fs::remove_dir_all(&base);
7243        }
7244    }
7245
7246    #[test]
7247    fn a_part_switched_off_takes_nothing_out_and_sends_nothing() {
7248        let base = scratch("part-off");
7249        let folder = base.join("Sioul");
7250        let key = quick_key(&folder, "four words make a passphrase").unwrap();
7251        let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
7252        let (desk_notes, phone_notes) = (base.join("desk-notes"), base.join("phone-notes"));
7253        for name in ["a", "b", "c", "d", "e", "f"] {
7254            put(&desk_notes, &format!("{name}.md"), format!("# {name}\n").as_bytes());
7255        }
7256        desk.exchange_with(&folder, &key, NOW, &desk_notes, &["notes"]);
7257        phone.exchange_with(&folder, &key, NOW + MINUTE, &phone_notes, &["notes"]);
7258        assert_eq!(files_in(&phone_notes).len(), 6);
7259        // Notes switched off on the phone, then changed there; the desk changes one meanwhile.
7260        phone.exchange_with(&folder, &key, NOW + 2 * MINUTE, &phone_notes, &[]);
7261        std::fs::remove_file(phone_notes.join("a.md")).unwrap();
7262        put(&phone_notes, "b.md", b"# b, changed on the phone while off\n");
7263        put(&phone_notes, "new.md", b"# new\n");
7264        put(&desk_notes, "c.md", b"# c, changed on the desk\n");
7265        desk.exchange_with(&folder, &key, NOW + 3 * MINUTE, &desk_notes, &["notes"]);
7266        assert_eq!(phone.exchange_with(&folder, &key, NOW + 4 * MINUTE, &phone_notes, &[]).sent, 0, "a part switched off sends nothing");
7267        desk.exchange_with(&folder, &key, NOW + 5 * MINUTE, &desk_notes, &["notes"]);
7268        let desk_files = files_in(&desk_notes);
7269        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:?}");
7270        assert_eq!(std::fs::read(phone_notes.join("c.md")).unwrap(), b"# c\n", "nothing comes into a part switched off");
7271        // Switched on again: it joins as a new device. What the others hold comes
7272        // first, a note changed here meanwhile is kept beside, what only it holds goes.
7273        phone.exchange_with(&folder, &key, NOW + 6 * MINUTE, &phone_notes, &["notes"]);
7274        desk.exchange_with(&folder, &key, NOW + 7 * MINUTE, &desk_notes, &["notes"]);
7275        phone.exchange_with(&folder, &key, NOW + 8 * MINUTE, &phone_notes, &["notes"]);
7276        desk.exchange_with(&folder, &key, NOW + 9 * MINUTE, &desk_notes, &["notes"]);
7277        for notes in [&desk_notes, &phone_notes] {
7278            let files = files_in(notes);
7279            assert_eq!(files.get("a.md").map(Vec::as_slice), Some(&b"# a\n"[..]), "{}", notes.display());
7280            assert_eq!(files.get("b.md").map(Vec::as_slice), Some(&b"# b\n"[..]), "{}", notes.display());
7281            assert_eq!(files.get("c.md").map(Vec::as_slice), Some(&b"# c, changed on the desk\n"[..]), "{}", notes.display());
7282            assert_eq!(files.get("new.md").map(Vec::as_slice), Some(&b"# new\n"[..]), "{}", notes.display());
7283            assert_eq!(copies_of(notes, "b"), [b"# b, changed on the phone while off\n".to_vec()], "{}", notes.display());
7284        }
7285        let _ = std::fs::remove_dir_all(&base);
7286    }
7287
7288    /// Item 8 of 9 October: on Unix, what the sharing writes is yours alone,
7289    /// as the calls' logs are: its files 0600, the folders it makes 0700 (the
7290    /// seal, the records, the sealed files, the notes and settings it brings
7291    /// in, the copies and versions it keeps). A file here already readable by
7292    /// others becomes yours alone the next time the sharing writes it; one it
7293    /// never writes keeps its mode: nothing sweeps your folders.
7294    #[cfg(unix)]
7295    #[test]
7296    fn what_the_sharing_writes_is_yours_alone() {
7297        use std::os::unix::fs::PermissionsExt;
7298        let mode = |path: &Path| std::fs::symlink_metadata(path).unwrap().permissions().mode() & 0o777;
7299        let set = |path: &Path, mode: u32| std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)).unwrap();
7300        let base = scratch("yours-alone");
7301        let folder = base.join("Sioul");
7302        let key = quick_key(&folder, "four words make a passphrase").unwrap();
7303        let (desk, laptop) = (Computer::new(&base, "desk"), Computer::new(&base, "laptop"));
7304        let (desk_notes, laptop_notes) = (base.join("desk-notes"), base.join("laptop-notes"));
7305        put(&desk_notes, "admin/lease.md", b"Rent: 620\n");
7306        desk.write("config/config.toml", "language = \"fr\"\n");
7307        // The laptop's own files, as an editor leaves them (others may read
7308        // them): one the desk's version will replace, a setting the desk's
7309        // will join, and a note the sharing never writes here.
7310        put(&laptop_notes, "admin/lease.md", b"Rent: 600\n");
7311        put(&laptop_notes, "mine.md", b"Mine\n");
7312        laptop.write("config/config.toml", "theme = \"dark\"\n");
7313        for path in [laptop_notes.join("admin/lease.md"), laptop_notes.join("mine.md"), laptop.path("config/config.toml")] {
7314            set(&path, 0o644);
7315        }
7316        set(&laptop_notes.join("admin"), 0o755);
7317        // The laptop's lease from the day before: the desk's is the later word.
7318        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();
7319        let mut now = NOW;
7320        for _ in 0..3 {
7321            now += MINUTE;
7322            desk.exchange_with(&folder, &key, now, &desk_notes, &["notes"]);
7323            now += MINUTE;
7324            laptop.exchange_with(&folder, &key, now, &laptop_notes, &["notes"]);
7325        }
7326        assert_eq!(std::fs::read(laptop_notes.join("admin/lease.md")).unwrap(), b"Rent: 620\n");
7327        assert!(laptop.read("config/config.toml").contains("language = \"fr\""), "{}", laptop.read("config/config.toml"));
7328        assert_eq!(copies_of(&laptop_notes.join("admin"), "lease").len(), 1, "the laptop's version kept beside");
7329        // Written by the sharing: yours alone, whatever they were.
7330        assert_eq!(mode(&laptop_notes.join("admin/lease.md")), 0o600);
7331        assert_eq!(mode(&laptop.path("config/config.toml")), 0o600);
7332        for (name, _) in files_in(&laptop_notes.join("admin")).into_iter().filter(|(n, _)| n.starts_with("lease (")) {
7333            assert_eq!(mode(&laptop_notes.join("admin").join(&name)), 0o600, "{name}");
7334        }
7335        assert_eq!(mode(&desk_notes.join("mine.md")), 0o600, "brought to the desk");
7336        // Never written here by the sharing: as they were.
7337        assert_eq!(mode(&laptop_notes.join("mine.md")), 0o644);
7338        assert_eq!(mode(&desk_notes.join("admin/lease.md")), 0o644);
7339        assert_eq!(mode(&laptop_notes.join("admin")), 0o755, "a folder there already keeps its mode");
7340        // Everything in the sharing folder, and the versions kept: files 0600, folders 0700.
7341        let history = crate::history::root(&laptop.memory);
7342        assert!(!crate::history::files(&history, "settings").is_empty(), "a version kept");
7343        for root in [folder.clone(), history.clone()] {
7344            let mut stack = vec![root];
7345            while let Some(dir) = stack.pop() {
7346                assert_eq!(mode(&dir), 0o700, "{}", dir.display());
7347                for entry in std::fs::read_dir(&dir).unwrap().map(Result::unwrap) {
7348                    let path = entry.path();
7349                    if path.is_dir() {
7350                        stack.push(path);
7351                    } else {
7352                        assert_eq!(mode(&path), 0o600, "{}", path.display());
7353                    }
7354                }
7355            }
7356        }
7357        // A record file from before this was so: yours alone at its next line.
7358        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();
7359        assert!(!rounds.is_empty(), "the desk's records");
7360        for round in &rounds {
7361            set(round, 0o644);
7362        }
7363        desk.write("config/config.toml", "language = \"en\"\n");
7364        desk.exchange_with(&folder, &key, now + MINUTE, &desk_notes, &["notes"]);
7365        let written: Vec<&PathBuf> = rounds.iter().filter(|r| mode(r) == 0o600).collect();
7366        assert_eq!(written.len(), 1, "the round written to, and it alone: {rounds:?}");
7367        let _ = std::fs::remove_dir_all(&base);
7368    }
7369
7370    #[test]
7371    fn earlier_versions_are_kept_and_put_back() {
7372        let base = scratch("history");
7373        let folder = base.join("Sioul");
7374        let key = quick_key(&folder, "four words make a passphrase").unwrap();
7375        let (desk, laptop) = (Computer::new(&base, "desk"), Computer::new(&base, "laptop"));
7376        let (desk_notes, laptop_notes) = (base.join("desk-notes"), base.join("laptop-notes"));
7377        let history = crate::history::root(&laptop.memory);
7378        let mut now = NOW;
7379        let both = |now: &mut i64| {
7380            *now += MINUTE;
7381            desk.exchange_with(&folder, &key, *now, &desk_notes, &["notes"]);
7382            *now += MINUTE;
7383            laptop.exchange_with(&folder, &key, *now, &laptop_notes, &["notes"])
7384        };
7385        // Three versions written on the desk, of one size and in one millisecond:
7386        // the laptop keeps the two it had before each came.
7387        desk.write("config/config.toml", "language = \"fr\"\n");
7388        for n in 1..=3 {
7389            put(&desk_notes, "lease.md", format!("version {n}\n").as_bytes());
7390            let at = std::time::UNIX_EPOCH + std::time::Duration::from_nanos(NOW as u64 * 1_000_000 + n * 100);
7391            std::fs::File::options().write(true).open(desk_notes.join("lease.md")).unwrap().set_modified(at).unwrap();
7392            both(&mut now);
7393        }
7394        desk.write("config/config.toml", "language = \"en\"\n");
7395        both(&mut now);
7396        assert_eq!(std::fs::read(laptop_notes.join("lease.md")).unwrap(), b"version 3\n");
7397        let kept = crate::history::versions(&history, "notes", "files/notes/lease.md");
7398        let texts: Vec<String> = kept.iter().map(|v| version_text(&history, "notes", "files/notes/lease.md", &v.stamp, &folder, &key)).collect();
7399        assert_eq!(texts, ["version 2\n", "version 1\n"], "the newest first");
7400        assert_eq!(crate::history::files(&history, "settings").iter().map(|(f, _)| f.as_str()).collect::<Vec<_>>(), ["config/config.toml"]);
7401        // Version 1 put back on the laptop: the one there now is kept too, and the next exchange sends it.
7402        let every = laptop.stores_with(&laptop_notes, &|_| true);
7403        put_back(&laptop.memory, Some((&folder, &key)), &every, "notes", "files/notes/lease.md", &kept[1].stamp, now).unwrap();
7404        assert_eq!(std::fs::read(laptop_notes.join("lease.md")).unwrap(), b"version 1\n");
7405        assert_eq!(crate::history::versions(&history, "notes", "files/notes/lease.md").len(), 3);
7406        // The setting as it was before the desk's last change, put back the same way.
7407        let setting = crate::history::versions(&history, "settings", "config/config.toml");
7408        put_back(&laptop.memory, Some((&folder, &key)), &every, "settings", "config/config.toml", &setting[0].stamp, now).unwrap();
7409        assert!(laptop.read("config/config.toml").contains("\"fr\""));
7410        // A file of one part is not put back as another's; nor a version never kept.
7411        assert!(put_back(&laptop.memory, Some((&folder, &key)), &every, "settings", "files/notes/lease.md", &kept[1].stamp, now).is_err());
7412        assert!(put_back(&laptop.memory, Some((&folder, &key)), &every, "notes", "files/notes/lease.md", "2020-01-01T00-00-00.000Z", now).is_err());
7413        now += MINUTE;
7414        assert!(laptop.exchange_with(&folder, &key, now, &laptop_notes, &["notes"]).sent >= 2);
7415        now += MINUTE;
7416        desk.exchange_with(&folder, &key, now, &desk_notes, &["notes"]);
7417        assert_eq!(std::fs::read(desk_notes.join("lease.md")).unwrap(), b"version 1\n", "put back on one, put back on all");
7418        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<_>>());
7419        assert!(desk.read("config/config.toml").contains("\"fr\""));
7420        // A note taken out on the desk (not its last: a folder emptied waits
7421        // for a word) goes on the laptop once gone a while, kept there, and can be put back.
7422        put(&desk_notes, "other.md", b"other\n");
7423        both(&mut now);
7424        std::fs::remove_file(desk_notes.join("lease.md")).unwrap();
7425        both(&mut now);
7426        assert!(laptop_notes.join("lease.md").exists(), "not before ten minutes");
7427        for _ in 0..6 {
7428            both(&mut now);
7429        }
7430        assert!(!laptop_notes.join("lease.md").exists());
7431        let kept = crate::history::versions(&history, "notes", "files/notes/lease.md");
7432        put_back(&laptop.memory, Some((&folder, &key)), &every, "notes", "files/notes/lease.md", &kept[0].stamp, now).unwrap();
7433        both(&mut now);
7434        both(&mut now);
7435        assert_eq!(std::fs::read(desk_notes.join("lease.md")).unwrap(), b"version 1\n");
7436        let _ = std::fs::remove_dir_all(&base);
7437    }
7438
7439    #[test]
7440    fn a_damaged_blob_or_record_never_erases_the_file_here() {
7441        let base = scratch("damaged");
7442        let folder = base.join("Sioul");
7443        let key = quick_key(&folder, "four words make a passphrase").unwrap();
7444        let (desk, laptop) = (Computer::new(&base, "desk"), Computer::new(&base, "laptop"));
7445        let (desk_notes, laptop_notes) = (base.join("desk-notes"), base.join("laptop-notes"));
7446        put(&desk_notes, "lease.md", b"version 1\n");
7447        desk.exchange_with(&folder, &key, NOW, &desk_notes, &["notes"]);
7448        laptop.exchange_with(&folder, &key, NOW + MINUTE, &laptop_notes, &["notes"]);
7449        // The next version's sealed file damaged on the way: said, the file here stays as it is.
7450        put(&desk_notes, "lease.md", b"version 2, longer\n");
7451        desk.exchange_with(&folder, &key, NOW + 2 * MINUTE, &desk_notes, &["notes"]);
7452        let sealed = crate::blobs::path(&folder, &key, &crate::blobs::hash_file(&desk_notes.join("lease.md")).unwrap().0);
7453        let mut bytes = std::fs::read(&sealed).unwrap();
7454        let middle = bytes.len() / 2;
7455        bytes[middle] ^= 0x20;
7456        std::fs::write(&sealed, &bytes).unwrap();
7457        for minute in [3, 4] {
7458            let outcome = laptop.exchange_with(&folder, &key, NOW + minute * MINUTE, &laptop_notes, &["notes"]);
7459            assert!(outcome.problems.iter().any(|p| p == "share-damaged:files/notes/lease.md") && outcome.pending == 1, "{outcome:?}");
7460            assert_eq!(std::fs::read(laptop_notes.join("lease.md")).unwrap(), b"version 1\n");
7461        }
7462        // Only a placeholder of the next (a file kept on demand): not there yet, nothing said, nothing lost.
7463        put(&desk_notes, "lease.md", b"version 3\n");
7464        desk.exchange_with(&folder, &key, NOW + 5 * MINUTE, &desk_notes, &["notes"]);
7465        let next = crate::blobs::path(&folder, &key, &crate::blobs::hash_file(&desk_notes.join("lease.md")).unwrap().0);
7466        let whole = std::fs::read(&next).unwrap();
7467        std::fs::write(&next, b"").unwrap();
7468        let outcome = laptop.exchange_with(&folder, &key, NOW + 6 * MINUTE, &laptop_notes, &["notes"]);
7469        assert!(outcome.problems.is_empty() && outcome.pending == 1, "{outcome:?}");
7470        assert_eq!(std::fs::read(laptop_notes.join("lease.md")).unwrap(), b"version 1\n");
7471        // A record that is not one, and a line that does not open: said, the file here stays.
7472        let last = written(&desk.memory, &desk.id).unwrap();
7473        let clock = ((NOW + 7 * MINUTE) as u64) << 16;
7474        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();
7475        let mut text = std::fs::read_to_string(round_file(&folder, &desk.id, last.0)).unwrap();
7476        text.push_str("{\"n\": 99, \"c\": 1, \"s\": \"broken\"}\n");
7477        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"));
7478        std::fs::write(round_file(&folder, &desk.id, last.0), text).unwrap();
7479        let outcome = laptop.exchange_with(&folder, &key, NOW + 8 * MINUTE, &laptop_notes, &["notes"]);
7480        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:?}");
7481        assert_eq!(std::fs::read(laptop_notes.join("lease.md")).unwrap(), b"version 1\n");
7482        // The whole one comes at last, then a later change: written.
7483        std::fs::write(&next, &whole).unwrap();
7484        put(&desk_notes, "lease.md", b"version 4\n");
7485        desk.exchange_with(&folder, &key, NOW + 9 * MINUTE, &desk_notes, &["notes"]);
7486        laptop.exchange_with(&folder, &key, NOW + 10 * MINUTE, &laptop_notes, &["notes"]);
7487        assert_eq!(std::fs::read(laptop_notes.join("lease.md")).unwrap(), b"version 4\n");
7488        assert!(copies_of(&laptop_notes, "lease").is_empty());
7489        let _ = std::fs::remove_dir_all(&base);
7490    }
7491
7492    #[test]
7493    fn a_notes_folder_found_empty_takes_nothing_out() {
7494        let base = scratch("notes-emptied");
7495        let folder = base.join("Sioul");
7496        let key = quick_key(&folder, "four words make a passphrase").unwrap();
7497        let (desk, laptop) = (Computer::new(&base, "desk"), Computer::new(&base, "laptop"));
7498        let (desk_notes, laptop_notes) = (base.join("desk-notes"), base.join("laptop-notes"));
7499        for n in 0..6 {
7500            put(&desk_notes, &format!("note-{n}.md"), format!("# {n}\n").as_bytes());
7501        }
7502        desk.exchange_with(&folder, &key, NOW, &desk_notes, &["notes"]);
7503        laptop.exchange_with(&folder, &key, NOW + MINUTE, &laptop_notes, &["notes"]);
7504        // Every note gone at once here (a card not mounted, another program): said, nothing taken out there.
7505        for n in 0..6 {
7506            std::fs::remove_file(desk_notes.join(format!("note-{n}.md"))).unwrap();
7507        }
7508        let outcome = desk.exchange_with(&folder, &key, NOW + 2 * MINUTE, &desk_notes, &["notes"]);
7509        assert!(outcome.problems.iter().any(|p| p == "share-vanished:files/notes/:6") && outcome.sent == 0, "{outcome:?}");
7510        // Gone as a folder, the same.
7511        std::fs::remove_dir_all(&desk_notes).unwrap();
7512        assert_eq!(desk.exchange_with(&folder, &key, NOW + 3 * MINUTE, &desk_notes, &["notes"]).sent, 0);
7513        laptop.exchange_with(&folder, &key, NOW + 4 * MINUTE, &laptop_notes, &["notes"]);
7514        assert_eq!(files_in(&laptop_notes).len(), 6);
7515        // One taken out on purpose there goes, as ever, once gone a while; nothing is written here meanwhile.
7516        std::fs::remove_file(laptop_notes.join("note-0.md")).unwrap();
7517        put(&laptop_notes, "note-1.md", b"# 1, changed\n");
7518        laptop.exchange_with(&folder, &key, NOW + 5 * MINUTE, &laptop_notes, &["notes"]);
7519        laptop.exchange_with(&folder, &key, NOW + 16 * MINUTE, &laptop_notes, &["notes"]);
7520        assert!(desk.exchange_with(&folder, &key, NOW + 17 * MINUTE, &desk_notes, &["notes"]).pending > 0);
7521        assert!(!desk_notes.exists());
7522        // Notes switched off and on here: filled again from the others, as a new device's.
7523        desk.exchange_with(&folder, &key, NOW + 18 * MINUTE, &desk_notes, &[]);
7524        desk.exchange_with(&folder, &key, NOW + 19 * MINUTE, &desk_notes, &["notes"]);
7525        let files = files_in(&desk_notes);
7526        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"]);
7527        assert_eq!(files["note-1.md"], b"# 1, changed\n");
7528        let _ = std::fs::remove_dir_all(&base);
7529    }
7530
7531    /// A record of another device's, sealed: its file, as a sync app would bring it.
7532    pub(super) fn record_of(key: &[u8; 32], computer: &str, n: u64, clock: u64, change: &Change) -> String {
7533        let plain = serde_json::to_vec(change).unwrap();
7534        serde_json::to_string(&Line { n, c: clock, s: seal(key, &bound(computer, 1, n, clock), &plain) }).unwrap() + "\n"
7535    }
7536
7537    pub(super) fn append_to(path: &Path, text: &str) {
7538        let mut file = std::fs::OpenOptions::new().create(true).append(true).open(path).unwrap();
7539        file.write_all(text.as_bytes()).unwrap();
7540    }
7541
7542    pub(super) fn copy_dir(from: &Path, to: &Path) {
7543        for entry in std::fs::read_dir(from).unwrap().filter_map(Result::ok) {
7544            let target = to.join(entry.file_name());
7545            if entry.path().is_dir() {
7546                std::fs::create_dir_all(&target).unwrap();
7547                copy_dir(&entry.path(), &target);
7548            } else {
7549                std::fs::create_dir_all(to).unwrap();
7550                std::fs::copy(entry.path(), target).unwrap();
7551            }
7552        }
7553    }
7554
7555    fn dated(path: &Path, at: std::time::SystemTime) {
7556        std::fs::File::options().write(true).open(path).unwrap().set_modified(at).unwrap();
7557    }
7558
7559    // F1
7560    #[test]
7561    fn a_put_back_takes_out_nothing_added_since() {
7562        let base = scratch("put-back-entries");
7563        let folder = base.join("Sioul");
7564        let key = quick_key(&folder, "four words make a passphrase").unwrap();
7565        let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
7566        let mut now = NOW;
7567        let both = |now: &mut i64| {
7568            *now += MINUTE;
7569            desk.exchange(&folder, &key, *now);
7570            *now += MINUTE;
7571            phone.exchange(&folder, &key, *now);
7572        };
7573        desk.write("state/health-state.toml", "[taken]\nd1 = 1\n");
7574        desk.write("config/config.toml", "language = \"fr\"\n");
7575        both(&mut now);
7576        // On the phone: a dose, then another, the language changed, an account added.
7577        phone.write("state/health-state.toml", "[taken]\nd1 = 1\nd2 = 2\n");
7578        both(&mut now);
7579        phone.write("state/health-state.toml", "[taken]\nd1 = 1\nd2 = 2\nd3 = 3\n");
7580        phone.write("config/config.toml", "language = \"en\"\n\n[[account]]\nid = \"new\"\nkind = \"imap\"\n");
7581        both(&mut now);
7582        both(&mut now);
7583        let history = crate::history::root(&desk.memory);
7584        let doses = crate::history::versions(&history, "health", "state/health-state.toml");
7585        assert_eq!(doses.len(), 2, "{doses:?}");
7586        let every = stores_of(&Config::default(), &desk.roots, &|_| true);
7587        // The doses' record as it was before the last dose came: said first, nothing taken out.
7588        let back = put_back_preview(&desk.memory, &every, "health", "state/health-state.toml", &doses[0].stamp).unwrap();
7589        assert_eq!(back, Back { whole: false, changed: 0, returning: 0, kept: 1 });
7590        put_back(&desk.memory, Some((&folder, &key)), &every, "health", "state/health-state.toml", &doses[0].stamp, now).unwrap();
7591        // The settings as they were: the language back, the account added since kept.
7592        let settings = crate::history::versions(&history, "settings", "config/config.toml");
7593        let back = put_back_preview(&desk.memory, &every, "settings", "config/config.toml", &settings[0].stamp).unwrap();
7594        assert_eq!(back, Back { whole: false, changed: 1, returning: 0, kept: 1 });
7595        put_back(&desk.memory, Some((&folder, &key)), &every, "settings", "config/config.toml", &settings[0].stamp, now).unwrap();
7596        both(&mut now);
7597        both(&mut now);
7598        for computer in [&desk, &phone] {
7599            let doses = computer.read("state/health-state.toml");
7600            assert!(["d1", "d2", "d3"].iter().all(|d| doses.contains(d)), "a dose marked after that version was taken out: {doses}");
7601            let config = computer.read("config/config.toml");
7602            assert!(config.contains("language = \"fr\"") && config.contains("id = \"new\""), "{config}");
7603        }
7604        let _ = std::fs::remove_dir_all(&base);
7605    }
7606
7607    // F2
7608    #[test]
7609    fn a_quick_exchange_leaves_notes_for_later() {
7610        let base = scratch("quick");
7611        let folder = base.join("Sioul");
7612        let key = quick_key(&folder, "four words make a passphrase").unwrap();
7613        let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
7614        let (dn, pn) = (base.join("desk-notes"), base.join("phone-notes"));
7615        put(&dn, "a.md", b"# a\n");
7616        desk.exchange_with(&folder, &key, NOW, &dn, &["notes"]);
7617        phone.exchange_with(&folder, &key, NOW + MINUTE, &pn, &["notes"]);
7618        // A dose and a note on the desk: the phone's alarm takes the dose, leaves the note.
7619        desk.write("state/health-state.toml", "[taken]\nd1 = 1\n");
7620        put(&dn, "a.md", b"# a, changed\n");
7621        desk.exchange_with(&folder, &key, NOW + 2 * MINUTE, &dn, &["notes"]);
7622        let stores = phone.stores_with(&pn, &|part| part == "notes");
7623        let quick = exchange(&Sharing { folder: &folder, computer: &phone.id, key: &key, memory: &phone.memory, files: false, hurry: None }, &stores, NOW + 3 * MINUTE).unwrap();
7624        assert!(phone.read("state/health-state.toml").contains("d1"));
7625        assert_eq!(std::fs::read(pn.join("a.md")).unwrap(), b"# a\n");
7626        assert_eq!(quick.pending, 1, "{quick:?}");
7627        // The next whole exchange brings it; nothing joins again (nothing goes out).
7628        let whole = phone.exchange_with(&folder, &key, NOW + 4 * MINUTE, &pn, &["notes"]);
7629        assert_eq!(std::fs::read(pn.join("a.md")).unwrap(), b"# a, changed\n");
7630        assert_eq!(whole.sent, 0, "{whole:?}");
7631        // Hurried while it runs: its notes wait where they are, the rest is done.
7632        put(&dn, "a.md", b"# a, again\n");
7633        desk.write("state/health-state.toml", "[taken]\nd1 = 1\nd2 = 2\n");
7634        desk.exchange_with(&folder, &key, NOW + 5 * MINUTE, &dn, &["notes"]);
7635        let hurry = std::sync::atomic::AtomicBool::new(true);
7636        exchange(&Sharing { folder: &folder, computer: &phone.id, key: &key, memory: &phone.memory, files: true, hurry: Some(&hurry) }, &stores, NOW + 6 * MINUTE).unwrap();
7637        assert!(phone.read("state/health-state.toml").contains("d2"));
7638        assert_eq!(std::fs::read(pn.join("a.md")).unwrap(), b"# a, changed\n");
7639        hurry.store(false, std::sync::atomic::Ordering::Relaxed);
7640        exchange(&Sharing { folder: &folder, computer: &phone.id, key: &key, memory: &phone.memory, files: true, hurry: Some(&hurry) }, &stores, NOW + 7 * MINUTE).unwrap();
7641        assert_eq!(std::fs::read(pn.join("a.md")).unwrap(), b"# a, again\n");
7642        let _ = std::fs::remove_dir_all(&base);
7643    }
7644
7645    // F4
7646    #[test]
7647    fn notes_gone_at_once_wait_for_a_word() {
7648        let base = scratch("vanished");
7649        let folder = base.join("Sioul");
7650        let key = quick_key(&folder, "four words make a passphrase").unwrap();
7651        let (desk, laptop) = (Computer::new(&base, "desk"), Computer::new(&base, "laptop"));
7652        let (dn, ln) = (base.join("desk-notes"), base.join("laptop-notes"));
7653        put(&dn, "today.md", b"# Today\n");
7654        for n in 0..20 {
7655            put(&dn, &format!("archive/{n}.md"), format!("# {n}\n").as_bytes());
7656        }
7657        desk.exchange_with(&folder, &key, NOW, &dn, &["notes"]);
7658        laptop.exchange_with(&folder, &key, NOW + MINUTE, &ln, &["notes"]);
7659        // A folder listing empty (a disk not mounted on its place): unknown, said, nothing taken out.
7660        std::fs::remove_dir_all(dn.join("archive")).unwrap();
7661        std::fs::create_dir(dn.join("archive")).unwrap();
7662        let outcome = desk.exchange_with(&folder, &key, NOW + 2 * MINUTE, &dn, &["notes"]);
7663        assert!(outcome.problems.iter().any(|p| p == "share-vanished:files/notes/archive/:20") && outcome.sent == 0, "{outcome:?}");
7664        laptop.exchange_with(&folder, &key, NOW + 3 * MINUTE, &ln, &["notes"]);
7665        assert_eq!(files_in(&ln).len(), 21);
7666        // Gone with its folder, many at once: held the same, ten minutes and more.
7667        std::fs::remove_dir(dn.join("archive")).unwrap();
7668        for minute in [4, 20] {
7669            let outcome = desk.exchange_with(&folder, &key, NOW + minute * MINUTE, &dn, &["notes"]);
7670            assert!(outcome.problems.iter().any(|p| p == "share-vanished:files/notes/:20") && outcome.sent == 0, "{outcome:?}");
7671        }
7672        laptop.exchange_with(&folder, &key, NOW + 21 * MINUTE, &ln, &["notes"]);
7673        assert_eq!(files_in(&ln).len(), 21);
7674        // A folder that cannot be read: unknown, nothing of it taken out.
7675        #[cfg(unix)]
7676        {
7677            use std::os::unix::fs::PermissionsExt;
7678            put(&dn, "locked/x.md", b"# x\n");
7679            desk.exchange_with(&folder, &key, NOW + 22 * MINUTE, &dn, &["notes"]);
7680            std::fs::set_permissions(dn.join("locked"), std::fs::Permissions::from_mode(0o000)).unwrap();
7681            if std::fs::read_dir(dn.join("locked")).is_err() {
7682                for minute in [23, 40] {
7683                    desk.exchange_with(&folder, &key, NOW + minute * MINUTE, &dn, &["notes"]);
7684                }
7685                laptop.exchange_with(&folder, &key, NOW + 41 * MINUTE, &ln, &["notes"]);
7686                assert!(ln.join("locked/x.md").exists());
7687            }
7688            std::fs::set_permissions(dn.join("locked"), std::fs::Permissions::from_mode(0o755)).unwrap();
7689        }
7690        // You say so: they go, everywhere.
7691        confirm_gone(&desk.memory, &desk.id, "files/notes/").unwrap();
7692        let outcome = desk.exchange_with(&folder, &key, NOW + 42 * MINUTE, &dn, &["notes"]);
7693        assert!(outcome.sent >= 20, "{outcome:?}");
7694        laptop.exchange_with(&folder, &key, NOW + 43 * MINUTE, &ln, &["notes"]);
7695        let left: Vec<String> = files_in(&ln).into_keys().collect();
7696        assert!(left.iter().all(|f| !f.starts_with("archive/")) && left.contains(&"today.md".to_string()), "{left:?}");
7697        assert!(!ln.join("archive").exists(), "its folder, left empty, goes too");
7698        let _ = std::fs::remove_dir_all(&base);
7699    }
7700
7701    // F5 and F21
7702    #[test]
7703    fn no_room_holds_a_file_back_and_tries_again_later() {
7704        let base = scratch("no-room");
7705        let folder = base.join("Sioul");
7706        let key = quick_key(&folder, "four words make a passphrase").unwrap();
7707        let (desk, laptop) = (Computer::new(&base, "desk"), Computer::new(&base, "laptop"));
7708        let (dn, ln) = (base.join("desk-notes"), base.join("laptop-notes"));
7709        put(&dn, "scan.pdf", &noise(100_000, 1));
7710        put(&dn, "other.md", b"other\n");
7711        desk.exchange_with(&folder, &key, NOW, &dn, &["notes"]);
7712        laptop.exchange_with(&folder, &key, NOW + MINUTE, &ln, &["notes"]);
7713        put(&dn, "scan.pdf", &noise(100_000, 2));
7714        desk.exchange_with(&folder, &key, NOW + 2 * MINUTE, &dn, &["notes"]);
7715        // No room on the laptop: said, the file there as it was, tried again after a while only.
7716        crate::disk::ROOM.with(|room| room.set(Some(1000)));
7717        let outcome = laptop.exchange_with(&folder, &key, NOW + 3 * MINUTE, &ln, &["notes"]);
7718        assert!(outcome.problems.iter().any(|p| p == "share-no-room:files/notes/scan.pdf") && outcome.pending == 1, "{outcome:?}");
7719        let outcome = laptop.exchange_with(&folder, &key, NOW + 3 * MINUTE + 30_000, &ln, &["notes"]);
7720        assert!(outcome.problems.is_empty(), "not opened again within the minute: {outcome:?}");
7721        assert_eq!(std::fs::read(ln.join("scan.pdf")).unwrap(), noise(100_000, 1));
7722        crate::disk::ROOM.with(|room| room.set(Some(u64::MAX)));
7723        laptop.exchange_with(&folder, &key, NOW + 5 * MINUTE, &ln, &["notes"]);
7724        assert_eq!(std::fs::read(ln.join("scan.pdf")).unwrap(), noise(100_000, 2));
7725        // What it held kept by reference (its content was sealed in the folder already), not copied.
7726        let kept = crate::history::versions(&crate::history::root(&laptop.memory), "notes", "files/notes/scan.pdf");
7727        assert!(kept.len() == 1 && kept[0].sealed.is_some(), "{kept:?}");
7728        assert_eq!(version_text_bytes(&crate::history::root(&laptop.memory), "notes", "files/notes/scan.pdf", &kept[0].stamp, &folder, &key), noise(100_000, 1));
7729        let _ = std::fs::remove_dir_all(&base);
7730    }
7731
7732    fn version_text_bytes(history: &Path, part: &str, file: &str, stamp: &str, folder: &Path, key: &[u8; 32]) -> Vec<u8> {
7733        let Some(crate::history::Kept::Sealed { hash, .. }) = crate::history::version(history, part, file, stamp) else { panic!("{file}: not by reference") };
7734        let target = temporary(&history.join("opened"));
7735        crate::blobs::get(folder, key, &hash, &target).unwrap();
7736        let bytes = std::fs::read(&target).unwrap();
7737        std::fs::remove_file(target).unwrap();
7738        bytes
7739    }
7740
7741    // F6
7742    #[test]
7743    fn huge_or_endless_files_in_the_folder_are_never_read_whole() {
7744        let base = scratch("huge");
7745        let folder = base.join("Sioul");
7746        let key = quick_key(&folder, "four words make a passphrase").unwrap();
7747        let (desk, laptop) = (Computer::new(&base, "desk"), Computer::new(&base, "laptop"));
7748        desk.write("config/safe-senders.txt", "a@example.org\n");
7749        desk.exchange(&folder, &key, NOW);
7750        // 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.
7751        let stranger = uuid::Uuid::new_v4().to_string();
7752        {
7753            let mut file = std::fs::File::create(round_file(&folder, &stranger, 1)).unwrap();
7754            let piece = vec![b'x'; 1 << 20];
7755            for _ in 0..40 {
7756                file.write_all(&piece).unwrap();
7757            }
7758            file.write_all(b"\n").unwrap();
7759        }
7760        let clock = ((NOW + MINUTE) as u64) << 16;
7761        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 }));
7762        std::fs::write(seen_path(&folder, &stranger), "x".repeat(100_000)).unwrap();
7763        let outcome = laptop.exchange(&folder, &key, NOW + 2 * MINUTE);
7764        assert!(outcome.problems.iter().any(|p| *p == format!("share-other-line:{stranger}")), "{outcome:?}");
7765        let safe = laptop.read("config/safe-senders.txt");
7766        assert!(safe.contains("a@example.org") && safe.contains("b@example.org"), "{safe}");
7767        assert!(others(&folder, &laptop.id).iter().any(|o| o.id == stranger && o.heard == 0), "its notes, too long, not read");
7768        // An own file that does not end a line is mended from its end, never read whole.
7769        let own = round_file(&folder, &desk.id, 1);
7770        let whole = std::fs::metadata(&own).unwrap().len();
7771        append_to(&own, "{\"n\": 99, \"c\": 1, \"s\": \"half");
7772        assert_eq!(unfinished(&own), Some(whole));
7773        desk.write("config/safe-senders.txt", "a@example.org\nc@example.org\n");
7774        desk.exchange(&folder, &key, NOW + 3 * MINUTE);
7775        assert!(std::fs::read_to_string(&own).unwrap().lines().all(|l| l.ends_with('}')));
7776        let _ = std::fs::remove_dir_all(&base);
7777    }
7778
7779    // F7 and F8
7780    #[test]
7781    fn an_edit_made_while_receiving_is_never_lost() {
7782        let base = scratch("while-receiving");
7783        let folder = base.join("Sioul");
7784        let key = quick_key(&folder, "four words make a passphrase").unwrap();
7785        let (desk, laptop) = (Computer::new(&base, "desk"), Computer::new(&base, "laptop"));
7786        let (dn, ln) = (base.join("desk-notes"), base.join("laptop-notes"));
7787        for name in ["a.md", "b.md", "lease.md"] {
7788            put(&dn, name, format!("{name}: one\n").as_bytes());
7789        }
7790        desk.exchange_with(&folder, &key, NOW, &dn, &["notes"]);
7791        laptop.exchange_with(&folder, &key, NOW + MINUTE, &ln, &["notes"]);
7792        for name in ["a.md", "b.md", "lease.md"] {
7793            put(&dn, name, format!("{name}: two, from the desk\n").as_bytes());
7794        }
7795        desk.exchange_with(&folder, &key, NOW + 2 * MINUTE, &dn, &["notes"]);
7796        // While the laptop writes them: the lease edited there just before its turn (an editor
7797        // saving), and a.md, written already, edited while b.md is written.
7798        let (lease, first) = (ln.join("lease.md"), ln.join("a.md"));
7799        BEFORE_WRITING.with(|hook| {
7800            *hook.borrow_mut() = Some(Box::new(move |path: &Path| {
7801                if path.ends_with("lease.md") {
7802                    std::fs::write(&lease, "lease.md: edited on the laptop\n").unwrap();
7803                }
7804                if path.ends_with("b.md") {
7805                    std::fs::write(&first, "a.md: edited on the laptop\n").unwrap();
7806                }
7807            }))
7808        });
7809        laptop.exchange_with(&folder, &key, NOW + 3 * MINUTE, &ln, &["notes"]);
7810        BEFORE_WRITING.with(|hook| *hook.borrow_mut() = None);
7811        assert_eq!(std::fs::read_to_string(ln.join("lease.md")).unwrap(), "lease.md: edited on the laptop\n", "not written over");
7812        for minute in 4..10 {
7813            if minute % 2 == 0 {
7814                laptop.exchange_with(&folder, &key, NOW + minute * MINUTE, &ln, &["notes"]);
7815            } else {
7816                desk.exchange_with(&folder, &key, NOW + minute * MINUTE, &dn, &["notes"]);
7817            }
7818        }
7819        // Both edits went out. The lease, edited before the desk's came, is
7820        // kept both ways; a.md, edited on top of the desk's, follows it.
7821        for notes in [&dn, &ln] {
7822            let all: Vec<String> = files_in(notes).into_values().map(|b| String::from_utf8(b).unwrap()).collect();
7823            for text in ["lease.md: edited on the laptop\n", "lease.md: two, from the desk\n", "a.md: edited on the laptop\n"] {
7824                assert!(all.iter().any(|t| t == text), "{}: {text:?} lost: {all:?}", notes.display());
7825            }
7826            assert_eq!(std::fs::read_to_string(notes.join("a.md")).unwrap(), "a.md: edited on the laptop\n");
7827        }
7828        let _ = std::fs::remove_dir_all(&base);
7829    }
7830
7831    // F9
7832    #[test]
7833    fn a_part_switched_off_keeps_nothing_waiting() {
7834        let base = scratch("off-pending");
7835        let folder = base.join("Sioul");
7836        let key = quick_key(&folder, "four words make a passphrase").unwrap();
7837        let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
7838        let off = stores_of(&Config::default(), &phone.roots, &|part| part != "drafts");
7839        for n in 0..20 {
7840            desk.write(&format!("data/drafts/{n}.eml"), &"x".repeat(200_000));
7841            desk.exchange(&folder, &key, NOW + 2 * n * MINUTE);
7842            exchange(&Sharing { folder: &folder, computer: &phone.id, key: &key, memory: &phone.memory, files: true, hurry: None }, &off, NOW + (2 * n + 1) * MINUTE).unwrap();
7843        }
7844        assert!(std::fs::metadata(&phone.memory).unwrap().len() < 200_000, "memory.json holds the drafts of a part switched off");
7845        // Switched on: it joins, and the drafts come.
7846        phone.exchange(&folder, &key, NOW + 41 * MINUTE);
7847        assert_eq!(phone.read("data/drafts/19.eml").len(), 200_000);
7848        let _ = std::fs::remove_dir_all(&base);
7849    }
7850
7851    /// A device on an older Sioul still shares a watch's days and the memory
7852    /// of its offers (taken out on 8 October 2026): left aside here, never
7853    /// written, never kept waiting, never a problem; the rest of the
7854    /// exchange comes in, and the older device's files stay as they are.
7855    #[test]
7856    fn an_older_sioul_s_watch_is_left_aside() {
7857        let base = scratch("watch-older");
7858        let folder = base.join("Sioul");
7859        let key = quick_key(&folder, "four words make a passphrase").unwrap();
7860        let (older, desk) = (Computer::new(&base, "older"), Computer::new(&base, "desk"));
7861        // The older Sioul's stores: this version's, and the watch's as it shared them.
7862        let mut with_watch = stores(&Config::default(), &older.roots);
7863        with_watch.push(Store { name: "data/watch/".into(), path: older.roots.data.join("watch"), folder: true, shape: Shape::Whole, skip: &["imported.json"], part: "watch" });
7864        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" });
7865        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();
7866        older.write("data/watch/2026-10-07.json", "{\"steps\": 4200}");
7867        older.write("state/watch-offers.json", "{\"declined\": {}}");
7868        older.write("config/safe-senders.txt", "friend@example.org\n");
7869        let sent = older_exchange(NOW);
7870        assert!(sent.problems.is_empty() && sent.sent >= 3, "{sent:?}");
7871        for n in 1..=2 {
7872            let came = desk.exchange(&folder, &key, NOW + n * MINUTE);
7873            assert!(came.problems.is_empty() && came.pending == 0, "{came:?}");
7874        }
7875        assert_eq!(desk.read("config/safe-senders.txt"), "friend@example.org\n", "the rest came in");
7876        assert!(!desk.path("data/watch").exists() && !desk.path("state/watch-offers.json").exists(), "nothing of the watch written");
7877        // Its records named by their stores (the folders' own paths hold the test's name).
7878        let memory = std::fs::read_to_string(&desk.memory).unwrap();
7879        assert!(!memory.contains("data/watch/") && !memory.contains("watch-offers"), "nothing of the watch kept waiting");
7880        // A new day from the older device, and a change it receives: the exchanges go on both ways.
7881        older.write("data/watch/2026-10-08.json", "{\"steps\": 900}");
7882        older_exchange(NOW + 3 * MINUTE);
7883        desk.write("config/safe-senders.txt", "friend@example.org\nneighbour@example.org\n");
7884        let came = desk.exchange(&folder, &key, NOW + 4 * MINUTE);
7885        assert!(came.problems.is_empty() && came.pending == 0 && came.sent >= 1, "{came:?}");
7886        let back = older_exchange(NOW + 5 * MINUTE);
7887        assert!(back.problems.is_empty() && back.received >= 1, "{back:?}");
7888        assert_eq!(older.read("config/safe-senders.txt"), "friend@example.org\nneighbour@example.org\n");
7889        assert_eq!(older.read("data/watch/2026-10-07.json"), "{\"steps\": 4200}", "its own files as they were");
7890        assert!(!desk.path("data/watch").exists());
7891        let _ = std::fs::remove_dir_all(&base);
7892    }
7893
7894    // F10
7895    #[cfg(unix)]
7896    #[test]
7897    fn nothing_is_written_through_a_link_nor_into_the_sharing_folder() {
7898        let base = scratch("through-link");
7899        let notes = base.join("desk-notes");
7900        // Shared through a folder inside the notes, as Sioul may suggest.
7901        let folder = notes.join("sioul-shared");
7902        let key = quick_key(&folder, "four words make a passphrase").unwrap();
7903        let desk = Computer::new(&base, "desk");
7904        let outside = base.join("outside");
7905        put(&notes, "lease.md", b"x\n");
7906        std::fs::create_dir_all(&outside).unwrap();
7907        std::os::unix::fs::symlink(&outside, notes.join("link")).unwrap();
7908        desk.exchange_with(&folder, &key, NOW, &notes, &["notes"]);
7909        // A key holder's records: one through the link, one into the sharing folder.
7910        let source = base.join("evil");
7911        std::fs::write(&source, b"[Desktop Entry]\nExec=sh -c id\n").unwrap();
7912        let (h, s) = crate::blobs::hash_file(&source).unwrap();
7913        crate::blobs::put(&folder, &key, &source, &h).unwrap();
7914        let other = uuid::Uuid::new_v4().to_string();
7915        let value = reference(&h, s, 0);
7916        let mut text = String::new();
7917        for (n, file) in [(1, "files/notes/link/evil.desktop#"), (2, "files/notes/sioul-shared/plain.md#")] {
7918            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 });
7919        }
7920        std::fs::write(round_file(&folder, &other, 1), text).unwrap();
7921        let outcome = desk.exchange_with(&folder, &key, NOW + 2 * MINUTE, &notes, &["notes"]);
7922        assert!(!outside.join("evil.desktop").exists(), "written through the link");
7923        assert!(!folder.join("plain.md").exists(), "written in plain into the sharing folder");
7924        assert!(outcome.problems.iter().filter(|p| p.starts_with("share-refused:")).count() == 2, "{outcome:?}");
7925        let _ = std::fs::remove_dir_all(&base);
7926    }
7927
7928    // F11
7929    #[test]
7930    fn a_content_used_again_is_never_lost_to_a_sweep() {
7931        let base = scratch("sweep-race");
7932        let (server, phone_folder) = (base.join("server").join("Sioul"), base.join("phone").join("Sioul"));
7933        let key = quick_key(&server, "four words make a passphrase").unwrap();
7934        let (desk, phone, laptop) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"), Computer::new(&base, "laptop"));
7935        let (dn, pn, ln) = (base.join("desk-notes"), base.join("phone-notes"), base.join("laptop-notes"));
7936        let day = 86_400_000;
7937        put(&dn, "a.md", b"template\n");
7938        put(&dn, "keep.md", b"keep\n");
7939        desk.exchange_with(&server, &key, NOW, &dn, &["notes"]);
7940        put(&dn, "a.md", b"changed\n");
7941        desk.exchange_with(&server, &key, NOW + MINUTE, &dn, &["notes"]);
7942        // The template's sealed file is old (written long ago).
7943        std::fs::write(base.join("template"), b"template\n").unwrap();
7944        let sealed = crate::blobs::path(&server, &key, &crate::blobs::hash_file(&base.join("template")).unwrap().0);
7945        dated(&sealed, std::time::SystemTime::now() - std::time::Duration::from_secs(100 * 86_400));
7946        copy_dir(&server, &phone_folder);
7947        // The phone holds the same content again: it finds it sealed, seals nothing, and its record goes.
7948        put(&pn, "b.md", b"template\n");
7949        phone.exchange_with(&phone_folder, &key, NOW + 91 * day, &pn, &["notes"]);
7950        // The desk, not hearing of it yet, sweeps it.
7951        desk.exchange_with(&server, &key, NOW + 91 * day + MINUTE, &dn, &["notes"]);
7952        assert!(!sealed.exists(), "swept, as unused for 90 days by what the desk knew");
7953        // The phone's sync app carries both ways: its records up, the sweep down.
7954        for entry in std::fs::read_dir(&phone_folder).unwrap().filter_map(Result::ok) {
7955            if entry.file_name().to_string_lossy().ends_with(".jsonl") {
7956                std::fs::copy(entry.path(), server.join(entry.file_name())).unwrap();
7957            }
7958        }
7959        let _ = std::fs::remove_file(phone_folder.join("blobs").join(sealed.file_name().unwrap()));
7960        laptop.exchange_with(&server, &key, NOW + 91 * day + 2 * MINUTE, &ln, &["notes"]);
7961        assert!(!ln.join("b.md").exists());
7962        // A day later, still missing: said.
7963        let outcome = laptop.exchange_with(&server, &key, NOW + 92 * day + 3 * MINUTE, &ln, &["notes"]);
7964        assert!(outcome.problems.iter().any(|p| p == "share-missing:files/notes/b.md"), "{outcome:?}");
7965        // The phone looks after its sealed files: gone, sealed again; it comes.
7966        phone.exchange_with(&phone_folder, &key, NOW + 92 * day + 4 * MINUTE, &pn, &["notes"]);
7967        assert!(phone_folder.join("blobs").join(sealed.file_name().unwrap()).exists());
7968        copy_dir(&phone_folder.join("blobs"), &server.join("blobs"));
7969        laptop.exchange_with(&server, &key, NOW + 92 * day + 5 * MINUTE, &ln, &["notes"]);
7970        assert_eq!(std::fs::read(ln.join("b.md")).ok().as_deref(), Some(&b"template\n"[..]));
7971        let _ = std::fs::remove_dir_all(&base);
7972    }
7973
7974    // F12
7975    #[test]
7976    fn one_exchange_at_a_time() {
7977        let base = scratch("one-at-a-time");
7978        let folder = base.join("Sioul");
7979        let key = quick_key(&folder, "four words make a passphrase").unwrap();
7980        let desk = Computer::new(&base, "desk");
7981        desk.write("config/safe-senders.txt", "a@example.org\n");
7982        // Another Sioul (or the command line) exchanging: a whole exchange does nothing, and says so.
7983        let running = exchange_lock(&desk.memory, true).unwrap();
7984        let outcome = desk.exchange(&folder, &key, NOW);
7985        assert_eq!((outcome.problems.as_slice(), outcome.sent), (&["share-busy".to_string()][..], 0));
7986        drop(running);
7987        assert_eq!(desk.exchange(&folder, &key, NOW + MINUTE).sent, 1);
7988        // Held a moment only (a program starting elsewhere in Sioul holds a copy
7989        // of the lock until it runs, a Mac's for milliseconds): waited for.
7990        let memory = desk.memory.clone();
7991        let (held, release) = std::sync::mpsc::channel();
7992        let holder = std::thread::spawn(move || {
7993            let running = exchange_lock(&memory, true).unwrap();
7994            held.send(()).unwrap();
7995            std::thread::sleep(std::time::Duration::from_millis(150));
7996            drop(running);
7997        });
7998        release.recv().unwrap();
7999        desk.write("config/safe-senders.txt", "a@example.org\nb@example.org\n");
8000        let outcome = desk.exchange(&folder, &key, NOW + 2 * MINUTE);
8001        assert_eq!((outcome.problems.len(), outcome.sent), (0, 1), "{outcome:?}");
8002        holder.join().unwrap();
8003        // Files being written never share a name.
8004        let path = folder.join("x.toml");
8005        let (one, two) = (temporary(&path), temporary(&path));
8006        assert!(one != two && one.file_name().unwrap().to_string_lossy().starts_with('.'));
8007        let _ = std::fs::remove_dir_all(&base);
8008    }
8009
8010    // F14
8011    #[test]
8012    fn names_one_storage_takes_for_one_are_never_both_written() {
8013        // The decision, whatever the disk running this: a name another file
8014        // here or known has by its case, or by how an accent is written, is not its own.
8015        let known = |file: &str| (format!("{file}#"), Known { c: 1, w: "desk".into(), h: "h".into(), b: String::new() });
8016        let names = Names::new(&[known("files/notes/Lease.md"), known("files/notes/\u{e9}t\u{e9}.md")].into(), &Found::default());
8017        assert!(names.clash("files/notes/lease.md") && names.clash("files/notes/LEASE.md") && names.clash("files/notes/e\u{301}te\u{301}.md"));
8018        assert!(!names.clash("files/notes/Lease.md") && !names.clash("files/notes/\u{e9}t\u{e9}.md") && !names.clash("files/notes/other.md"));
8019        // Between two devices, with the names this disk can hold apart: Windows'
8020        // and a Mac's take "Lease.md" and "lease.md" for one file, a Mac's
8021        // "\u{e9}t\u{e9}" written two ways too; Linux's holds both.
8022        let base = scratch("case");
8023        let folder = base.join("Sioul");
8024        let key = quick_key(&folder, "four words make a passphrase").unwrap();
8025        let (desk, laptop) = (Computer::new(&base, "desk"), Computer::new(&base, "laptop"));
8026        let (dn, ln) = (base.join("desk-notes"), base.join("laptop-notes"));
8027        let apart = |one: &str, other: &str| {
8028            let probe = base.join("probe");
8029            std::fs::create_dir_all(&probe).unwrap();
8030            std::fs::write(probe.join(one), b"").unwrap();
8031            let apart = !probe.join(other).exists();
8032            std::fs::remove_dir_all(&probe).unwrap();
8033            apart
8034        };
8035        put(&dn, "other.md", b"other\n");
8036        let mut pairs = 0;
8037        for (one, other) in [("Lease.md", "lease.md"), ("\u{e9}t\u{e9}.md", "e\u{301}te\u{301}.md")] {
8038            if apart(one, other) {
8039                put(&dn, one, b"one\n");
8040                put(&dn, other, b"other way\n");
8041                pairs += 1;
8042            }
8043        }
8044        desk.exchange_with(&folder, &key, NOW, &dn, &["notes"]);
8045        let outcome = laptop.exchange_with(&folder, &key, NOW + MINUTE, &ln, &["notes"]);
8046        assert_eq!(outcome.problems.iter().filter(|p| p.starts_with("share-name-clash:")).count(), pairs, "{outcome:?}");
8047        assert_eq!(files_in(&ln).len(), 1 + pairs);
8048        // What another part carries is never a note, whatever its case.
8049        let config = Config { notes_root: Some("/notes".into()), ..Config::default() };
8050        let every = stores_of(&config, &laptop.roots, &|_| true);
8051        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());
8052        assert_eq!(kept_by_windows("aux.md"), true);
8053        assert_eq!(kept_by_windows("auxiliary.md"), false);
8054        let _ = std::fs::remove_dir_all(&base);
8055    }
8056
8057    // F15
8058    #[test]
8059    fn a_name_with_a_hash_travels() {
8060        let base = scratch("hash-name");
8061        let folder = base.join("Sioul");
8062        let key = quick_key(&folder, "four words make a passphrase").unwrap();
8063        let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
8064        let (dn, pn) = (base.join("desk-notes"), base.join("phone-notes"));
8065        put(&dn, "Issue #12.md", b"# Issue 12\n");
8066        put(&dn, "#ideas.md", b"# Ideas\n");
8067        put(&dn, "papers/Facture #123.pdf", &noise(10_000, 3));
8068        desk.write("data/drafts/a#1.toml", "subject = \"Hash\"\n");
8069        for m in 0..3 {
8070            desk.exchange_with(&folder, &key, NOW + 2 * m * MINUTE, &dn, &["notes", "papers"]);
8071            phone.exchange_with(&folder, &key, NOW + (2 * m + 1) * MINUTE, &pn, &["notes", "papers"]);
8072        }
8073        let files = files_in(&pn);
8074        assert_eq!(files.get("Issue #12.md").map(Vec::as_slice), Some(&b"# Issue 12\n"[..]), "{:?}", files.keys().collect::<Vec<_>>());
8075        assert!(files.contains_key("#ideas.md") && files.get("papers/Facture #123.pdf") == Some(&noise(10_000, 3)));
8076        assert_eq!(phone.read("data/drafts/a#1.toml"), "subject = \"Hash\"\n");
8077        assert_eq!(file_of("files/papers/Facture #123.pdf#"), "files/papers/Facture #123.pdf");
8078        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"));
8079        let _ = std::fs::remove_dir_all(&base);
8080    }
8081
8082    // F16
8083    #[test]
8084    fn an_old_copy_does_not_roll_the_others_back() {
8085        let base = scratch("old-copy");
8086        let folder = base.join("Sioul");
8087        let key = quick_key(&folder, "four words make a passphrase").unwrap();
8088        let (desk, laptop) = (Computer::new(&base, "desk"), Computer::new(&base, "laptop"));
8089        let (dn, ln) = (base.join("desk-notes"), base.join("laptop-notes"));
8090        let old = std::time::UNIX_EPOCH + std::time::Duration::from_millis(NOW as u64);
8091        put(&dn, "lease.md", b"Rent: 620\n");
8092        put(&dn, "other.md", b"other\n");
8093        dated(&dn.join("lease.md"), old);
8094        desk.exchange_with(&folder, &key, NOW, &dn, &["notes"]);
8095        laptop.exchange_with(&folder, &key, NOW + MINUTE, &ln, &["notes"]);
8096        put(&ln, "lease.md", b"Rent: 640\n");
8097        laptop.exchange_with(&folder, &key, NOW + 86_400_000, &ln, &["notes"]);
8098        desk.exchange_with(&folder, &key, NOW + 86_400_000 + MINUTE, &dn, &["notes"]);
8099        // Restored from a backup: the old content, with its old time.
8100        put(&dn, "lease.md", b"Rent: 620\n");
8101        dated(&dn.join("lease.md"), old);
8102        let outcome = desk.exchange_with(&folder, &key, NOW + 86_400_000 + 2 * MINUTE, &dn, &["notes"]);
8103        assert!(outcome.problems.iter().any(|p| p.starts_with("share-older-copy:lease (conflict ")), "{outcome:?}");
8104        assert_eq!(std::fs::read(dn.join("lease.md")).unwrap(), b"Rent: 640\n", "the version last seen came back");
8105        desk.exchange_with(&folder, &key, NOW + 86_400_000 + 3 * MINUTE, &dn, &["notes"]);
8106        laptop.exchange_with(&folder, &key, NOW + 86_400_000 + 4 * MINUTE, &ln, &["notes"]);
8107        assert_eq!(std::fs::read(ln.join("lease.md")).unwrap(), b"Rent: 640\n");
8108        assert_eq!(copies_of(&ln, "lease"), [b"Rent: 620\n".to_vec()], "the older copy kept beside, everywhere");
8109        let _ = std::fs::remove_dir_all(&base);
8110    }
8111
8112    // F22
8113    #[test]
8114    fn a_file_being_written_waits() {
8115        let base = scratch("settling");
8116        let folder = base.join("Sioul");
8117        let key = quick_key(&folder, "four words make a passphrase").unwrap();
8118        let (desk, laptop) = (Computer::new(&base, "desk"), Computer::new(&base, "laptop"));
8119        let (dn, ln) = (base.join("desk-notes"), base.join("laptop-notes"));
8120        put(&dn, "keep.md", b"keep\n");
8121        dated(&dn.join("keep.md"), std::time::SystemTime::now() - std::time::Duration::from_secs(120));
8122        // Changed this very moment: read once it has settled.
8123        SETTLE.with(|settle| settle.set(60_000));
8124        put(&dn, "a.md", b"# a\n");
8125        assert_eq!(desk.exchange_with(&folder, &key, NOW, &dn, &["notes"]).sent, 1);
8126        dated(&dn.join("a.md"), std::time::SystemTime::now() - std::time::Duration::from_secs(120));
8127        assert_eq!(desk.exchange_with(&folder, &key, NOW + MINUTE, &dn, &["notes"]).sent, 1);
8128        SETTLE.with(|settle| settle.set(0));
8129        laptop.exchange_with(&folder, &key, NOW + 2 * MINUTE, &ln, &["notes"]);
8130        // Emptied in place (an editor writing it, a crash): held as it was for ten minutes.
8131        std::fs::write(dn.join("a.md"), b"").unwrap();
8132        assert_eq!(desk.exchange_with(&folder, &key, NOW + 3 * MINUTE, &dn, &["notes"]).sent, 0);
8133        laptop.exchange_with(&folder, &key, NOW + 4 * MINUTE, &ln, &["notes"]);
8134        assert_eq!(std::fs::read(ln.join("a.md")).unwrap(), b"# a\n");
8135        put(&dn, "a.md", b"# a, written\n");
8136        assert_eq!(desk.exchange_with(&folder, &key, NOW + 5 * MINUTE, &dn, &["notes"]).sent, 1);
8137        let _ = std::fs::remove_dir_all(&base);
8138    }
8139
8140    // F23
8141    #[test]
8142    fn the_doses_never_read_the_notes_memory() {
8143        let base = scratch("memory-apart");
8144        let folder = base.join("Sioul");
8145        let key = quick_key(&folder, "four words make a passphrase").unwrap();
8146        let desk = Computer::new(&base, "desk");
8147        let dn = base.join("desk-notes");
8148        for n in 0..50 {
8149            put(&dn, &format!("note-{n}.md"), format!("# {n}\n").as_bytes());
8150        }
8151        desk.write("state/health-state.toml", "[taken]\nd1 = 1\n");
8152        desk.exchange_with(&folder, &key, NOW, &dn, &["notes"]);
8153        let memory = std::fs::read_to_string(&desk.memory).unwrap();
8154        assert!(!memory.contains("files/notes/note-") && memory.contains("health-state"), "{memory}");
8155        assert!(std::fs::read_to_string(sealed_path(&desk.memory)).unwrap().contains("files/notes/note-7.md"));
8156        assert!(written(&desk.memory, &desk.id).is_some_and(|(_, n)| n > 50));
8157        // Its own file lost: notes and papers join again, nothing goes out as new, nothing copied beside.
8158        std::fs::remove_file(sealed_path(&desk.memory)).unwrap();
8159        let outcome = desk.exchange_with(&folder, &key, NOW + MINUTE, &dn, &["notes"]);
8160        assert_eq!(outcome.sent, 0, "{outcome:?}");
8161        assert_eq!(files_in(&dn).len(), 50);
8162        let _ = std::fs::remove_dir_all(&base);
8163    }
8164
8165    // F24
8166    #[test]
8167    fn what_is_said_and_left_behind() {
8168        // Named alike in every language: one copy, not one per device.
8169        let at = NOW;
8170        assert_eq!(conflict_name("lease", at), conflict_name("lease", at));
8171        assert!(conflict_name("lease", at).starts_with("lease (conflict 20"));
8172        let base = scratch("left-behind");
8173        let folder = base.join("Sioul");
8174        let key = quick_key(&folder, "four words make a passphrase").unwrap();
8175        let desk = Computer::new(&base, "desk");
8176        let dn = base.join("desk-notes");
8177        put(&dn, "lease.md", b"lease\n");
8178        // What a crash left half written, an hour old: cleaned; one being written: left.
8179        put(&dn, ".lease.md.1-1.sioul.tmp", b"half");
8180        dated(&dn.join(".lease.md.1-1.sioul.tmp"), std::time::SystemTime::now() - std::time::Duration::from_secs(7200));
8181        put(&dn, ".lease.md.1-2.sioul.tmp", b"being written");
8182        // A name that is not text, where the disk holds one (Linux's does; a Mac's refuses it, Windows' names are text).
8183        #[cfg(unix)]
8184        let latin = {
8185            use std::os::unix::ffi::OsStrExt;
8186            std::fs::write(dn.join(std::ffi::OsStr::from_bytes(b"caf\xe9.md")), b"latin-1").is_ok()
8187        };
8188        #[cfg(not(unix))]
8189        let latin = false;
8190        let outcome = desk.exchange_with(&folder, &key, NOW, &dn, &["notes"]);
8191        assert!(!dn.join(".lease.md.1-1.sioul.tmp").exists() && dn.join(".lease.md.1-2.sioul.tmp").exists());
8192        assert_eq!(outcome.problems.iter().any(|p| p.starts_with("share-not-text:files/notes/caf")), latin, "{outcome:?}");
8193        assert_eq!(outcome.sent, 1, "{outcome:?}");
8194        let _ = std::fs::remove_dir_all(&base);
8195    }
8196
8197    #[test]
8198    fn each_device_chooses_its_parts() {
8199        let roots = Roots { config: PathBuf::from("config"), data: PathBuf::from("data"), state: PathBuf::from("state") };
8200        let config = Config { notes_root: Some("/notes".into()), ..Config::default() };
8201        let parts = |stores: &[Store]| stores.iter().map(|s| s.part).collect::<BTreeSet<_>>();
8202        // A device that never chose: what was shared before parts had switches.
8203        assert_eq!(parts(&stores(&config, &roots)), ["calls", "drafts", "health", "lists", "senders", "settings", "spam", "time"].into());
8204        let old = Config { share_projects: true, ..config.clone() };
8205        assert!(parts(&stores(&old, &roots)).contains("projects"), "projects as the old setting said");
8206        // Its own choices, kept in its own file, never in a shared one.
8207        let mut here = Here::default();
8208        here.parts.insert("notes".into(), true);
8209        here.parts.insert("health".into(), false);
8210        here.parts.insert("projects".into(), false);
8211        let chosen = stores_of(&old, &roots, &|part| here.shares(part, &old));
8212        assert!(chosen.iter().any(|s| s.name == "files/notes/") && !chosen.iter().any(|s| s.part == "health" || s.part == "projects"));
8213        let notes = chosen.iter().find(|s| s.name == "files/notes/").unwrap();
8214        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");
8215        let back: Here = toml::from_str(&toml::to_string(&here).unwrap()).unwrap();
8216        assert_eq!(back.parts, here.parts);
8217        assert!(stores_of(&config, &roots, &|_| true).iter().all(|s| s.name != "state/share/here.toml"));
8218        // Written into the notes folder, never what tools make nor what another part carries.
8219        let every = stores_of(&config, &roots, &|_| true);
8220        assert!(locate(&every, "files/notes/admin/lease.md").is_some());
8221        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"] {
8222            assert!(locate(&every, file).is_none(), "{file}");
8223        }
8224        assert_eq!(locate(&every, "files/papers/id.pdf").map(|(s, _)| s.part), Some("papers"));
8225        assert_eq!(shown("files/notes/admin/lease.md"), "admin/lease.md");
8226        assert_eq!(shown("files/papers/id.pdf"), "papers/id.pdf");
8227        assert_eq!(shown("config/config.toml"), "config.toml");
8228    }
8229
8230    /// The spam filter's table: from the computer that trained it to the
8231    /// others, sealed apart, in every exchange (a phone's background step's
8232    /// too); the good one it replaces kept beside; never the language model
8233    /// nor the corpus beside it.
8234    #[test]
8235    fn the_spam_table_travels_alone_in_every_exchange() {
8236        use sioul_core::spam::features::{FEATURES, N};
8237        use sioul_core::spam::table::{self, Table};
8238        let base = scratch("spam-table");
8239        let folder = base.join("folder");
8240        let key = quick_key(&folder, "four words make a passphrase").unwrap();
8241        let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
8242        let made = |bias: f32| {
8243            let mut made = Table {
8244                tokenizer: sioul_core::spam::tokenize::TOKENIZER,
8245                features: FEATURES,
8246                dim: 2,
8247                minn: 3,
8248                maxn: 6,
8249                bucket: 4,
8250                words: vec![(table::word_hash("lotteri"), 2.0)],
8251                buckets: vec![0.0; 4],
8252                weights: vec![0.0; N],
8253                means: vec![0.0; N],
8254                bias,
8255                text_mean: 0.0,
8256                platt_a: -1.0,
8257                platt_b: 0.0,
8258                meta: table::Meta { device: "desk".into(), ..table::Meta::default() },
8259            };
8260            made.sort();
8261            made
8262        };
8263        // Each table its own time: a file is known by its size and time.
8264        let trained = |bias: f32, second: u64| {
8265            let path = desk.path("data/spam/table.bin");
8266            made(bias).write(&path).unwrap();
8267            std::fs::File::options().write(true).open(&path).unwrap().set_modified(std::time::UNIX_EPOCH + std::time::Duration::from_secs(second)).unwrap();
8268        };
8269        // What a training leaves on the desk: the table, the language model, the corpus.
8270        trained(0.5, 1_789_990_000);
8271        desk.write("data/spam/language.bin", "the vocabulary in plain text: Jane, the lease, the lottery");
8272        desk.write("data/spam/corpus/home/INBOX.jsonl.gz", "Subject: the lease");
8273        // Quick exchanges only (a dose's alarm, a phone's background step): it travels all the same.
8274        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();
8275        quick(&desk, NOW);
8276        let came = quick(&phone, NOW + MINUTE);
8277        assert!(came.written.contains(SPAM_TABLE), "{came:?}");
8278        assert_eq!(std::fs::read(phone.path("data/spam/table.bin")).unwrap(), std::fs::read(desk.path("data/spam/table.bin")).unwrap());
8279        assert!(!phone.path("data/spam/language.bin").exists() && !phone.path("data/spam/corpus").exists());
8280        assert!(!table::previous(&phone.path("data/spam/table.bin")).exists(), "nothing before the first");
8281        // Sealed: nothing of the table, nor of what is beside it, readable in the folder.
8282        let mut files = Vec::new();
8283        list_files(&folder, &folder, &[], &mut files);
8284        for (name, path) in &files {
8285            let bytes = std::fs::read(path).unwrap();
8286            for plain in [&b"SIOULSPM"[..], b"vocabulary", b"lottery"] {
8287                assert!(!bytes.windows(plain.len()).any(|w| w == plain), "{name}");
8288            }
8289        }
8290        // A new training: the phone takes it, the good one before kept beside.
8291        trained(1.5, 1_789_990_600);
8292        quick(&desk, NOW + 2 * MINUTE);
8293        assert!(quick(&phone, NOW + 3 * MINUTE).written.contains(SPAM_TABLE));
8294        let path = phone.path("data/spam/table.bin");
8295        assert_eq!((Table::read(&path).unwrap().bias, Table::read(&table::previous(&path)).unwrap().bias), (1.5, 0.5));
8296        // The language model and the corpus have no place in the sharing.
8297        let every = stores_of(&Config::default(), &desk.roots, &|_| true);
8298        for file in ["files/spam/language.bin", "files/spam/corpus/home/INBOX.jsonl.gz", "files/spam/", "data/spam/language.bin"] {
8299            assert!(locate(&every, file).is_none(), "{file}");
8300        }
8301        assert_eq!(locate(&every, SPAM_TABLE).map(|(s, p)| (s.part, p)), Some(("spam", desk.path("data/spam/table.bin"))));
8302        assert_eq!(shown(SPAM_TABLE), "spam/table.bin");
8303        let _ = std::fs::remove_dir_all(&base);
8304    }
8305
8306    /// Item 11 of 9 October: the spam filter's tables a computer seals, about
8307    /// one a week, are not kept 90 days each: past the newest two, its own
8308    /// older ones leave the folder at once; another device's file stays. A
8309    /// phone that read none of them for a while takes the newest and asks
8310    /// for nothing older: a device reads the newest record of a file only,
8311    /// so their absence is never missed, today's Sioul or an older one.
8312    #[test]
8313    fn only_the_two_newest_spam_tables_stay_in_the_folder() {
8314        let base = scratch("spam-tables");
8315        let folder = base.join("folder");
8316        let key = quick_key(&folder, "four words make a passphrase").unwrap();
8317        let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
8318        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();
8319        // Another device's sealed file: never this computer's to take out.
8320        let stranger = folder.join("blobs").join("0f".repeat(32));
8321        put(&folder, &format!("blobs/{}", "0f".repeat(32)), b"another device's");
8322        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();
8323        // A training: a table of its own content, and its own time.
8324        let trained = |n: u64| {
8325            let path = desk.path("data/spam/table.bin");
8326            put(path.parent().unwrap(), "table.bin", &noise(4096, n as u32));
8327            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();
8328        };
8329        let mut now = NOW;
8330        for n in 1..=4 {
8331            trained(n);
8332            now += MINUTE;
8333            quick(&desk, now);
8334            now += MINUTE;
8335            let came = quick(&phone, now);
8336            assert!(came.problems.is_empty(), "{came:?}");
8337            assert_eq!(std::fs::read(phone.path("data/spam/table.bin")).unwrap(), noise(4096, n as u32));
8338            assert_eq!(tables(), n.min(2) as usize, "after training {n}");
8339        }
8340        assert_eq!(Memory::load_all(&desk.memory, &desk.id).sealed.tables.len(), 2);
8341        // The phone away for three trainings: back, it takes the newest, and nothing waits.
8342        for n in 5..=7 {
8343            trained(n);
8344            now += MINUTE;
8345            quick(&desk, now);
8346        }
8347        assert_eq!(tables(), 2);
8348        now += MINUTE;
8349        let came = quick(&phone, now);
8350        assert!(came.problems.is_empty() && came.pending == 0, "{came:?}");
8351        assert_eq!(std::fs::read(phone.path("data/spam/table.bin")).unwrap(), noise(4096, 7));
8352        let came = quick(&phone, now + 2 * DAY);
8353        assert!(came.problems.is_empty() && came.pending == 0, "nothing missing, a day on: {came:?}");
8354        assert!(stranger.exists(), "another device's file stays");
8355        let _ = std::fs::remove_dir_all(&base);
8356    }
8357
8358    /// Each device writes its own label log; every device reads them all: a
8359    /// message said not spam on the phone is not spam on the desk (its
8360    /// `said_ham`), and what the phone's filter moved or flagged is known
8361    /// there, quick exchanges only. Sealed: no Message-ID readable in the folder.
8362    #[test]
8363    fn a_label_on_one_device_reaches_the_others() {
8364        use sioul_core::spam::labels::{self, Entry, Label, Moved, Source};
8365        let base = scratch("spam-labels");
8366        let folder = base.join("folder");
8367        let key = quick_key(&folder, "four words make a passphrase").unwrap();
8368        let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
8369        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();
8370        let spam_state = |c: &Computer| c.roots.state.join("spam");
8371        // A message the desk's filter flagged, as the desk's Porch reads it.
8372        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();
8373        card.account = Some("home".into());
8374        card.origin = Some(sioul_core::card::ImapOrigin { validity: 7, uid: 42 });
8375        assert!(!labels::said_ham_in(&labels::read_all_in(&spam_state(&desk)), &card));
8376        // "Not spam" on the phone; the phone's filter moved another message.
8377        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 };
8378        labels::append_to(&labels::own_log(&spam_state(&phone), &phone.id), &said).unwrap();
8379        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 };
8380        labels::append_to(&labels::own_moved_log(&spam_state(&phone), &phone.id), &moved).unwrap();
8381        // And flagged a third where it is, as maybe spam: the training on the desk must know it.
8382        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() };
8383        labels::append_to(&labels::own_flagged_log(&spam_state(&phone), &phone.id), &flagged).unwrap();
8384        quick(&phone, NOW);
8385        let came = quick(&desk, NOW + MINUTE);
8386        let theirs = format!("{SPAM_LABELS}{}.jsonl", phone.id);
8387        assert!(came.written.contains(SPAM_LABELS) && came.written.contains(SPAM_MOVED) && came.written.contains(SPAM_FLAGGED) && came.received == 3, "{came:?}");
8388        assert!(desk.roots.state.join("spam/labels").join(format!("{}.jsonl", phone.id)).exists(), "the phone's own file, beside the desk's");
8389        // On the desk: the phone's own file, read with the desk's own; the message not spam there too.
8390        assert!(labels::said_ham_in(&labels::read_all_in(&spam_state(&desk)), &card));
8391        assert_eq!(labels::read_moved_in(&spam_state(&desk)), vec![moved]);
8392        assert_eq!(labels::read_flagged_in(&spam_state(&desk)), vec![flagged]);
8393        // The desk says spam later, on its own log: the newest word wins on both devices.
8394        let later = Entry { at: 1_791_360_500, label: Label::Spam, source: Source::Junk, ..said.clone() };
8395        labels::append_to(&labels::own_log(&spam_state(&desk), &desk.id), &later).unwrap();
8396        quick(&desk, NOW + 2 * MINUTE);
8397        quick(&phone, NOW + 3 * MINUTE);
8398        for device in [&desk, &phone] {
8399            let all = labels::read_all_in(&spam_state(device));
8400            assert_eq!(all.len(), 2);
8401            assert!(!labels::said_ham_in(&all, &card));
8402        }
8403        // Each file has one writer: the phone's own log unchanged on the phone but for its own line.
8404        assert_eq!(labels::read_from::<Entry>(&labels::own_log(&spam_state(&phone), &phone.id)), vec![said]);
8405        // Sealed: nothing of them readable in the folder.
8406        let mut files = Vec::new();
8407        list_files(&folder, &folder, &[], &mut files);
8408        for (name, path) in &files {
8409            let bytes = std::fs::read(path).unwrap();
8410            for plain in [&b"win-1@lottery.test"[..], b"not-spam", b"deal-2", b"flag-3"] {
8411                assert!(!bytes.windows(plain.len()).any(|w| w == plain), "{name}");
8412            }
8413        }
8414        let every = stores_of(&Config::default(), &desk.roots, &|_| true);
8415        assert_eq!(locate(&every, &theirs).map(|(s, _)| s.part), Some("spam"));
8416        let _ = std::fs::remove_dir_all(&base);
8417    }
8418
8419    /// A call a phone declined, as Java wrote it (fiction numbers only).
8420    fn declined(at: i64, key: &str) -> sioul_core::calls::Held {
8421        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() }
8422    }
8423
8424    /// The calls' folder of a device.
8425    fn calls_of(c: &Computer) -> PathBuf {
8426        c.roots.state.join(sioul_core::calls::FOLDER)
8427    }
8428
8429    /// A phone copies Java's call into its own log; the desk hears of it,
8430    /// marks it seen, and the phone hears of that. Sealed in the folder,
8431    /// remembered beside `memory.json`, never kept as an earlier version or a
8432    /// first-share copy; with `files.json` lost, nothing goes out again.
8433    #[test]
8434    fn a_phone_s_calls_reach_the_desk_and_seen_comes_back() {
8435        use sioul_core::calls;
8436        let base = scratch("calls");
8437        let folder = base.join("folder");
8438        let key = quick_key(&folder, "four words make a passphrase").unwrap();
8439        let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
8440        let own = calls::own_file(&calls_of(&phone), calls::LOG, &phone.id);
8441        assert_eq!(calls::carry_into(&own, &[declined(NOW - 3_600_000, "+33199001234")], None, NOW).unwrap(), 1);
8442        phone.exchange(&folder, &key, NOW);
8443        let came = desk.exchange(&folder, &key, NOW + MINUTE);
8444        assert!(came.written.contains(CALLS_LOG), "{came:?}");
8445        let on_desk = calls::read_logs(&calls_of(&desk), NOW + MINUTE);
8446        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")]);
8447        // Seen on the desk, in its own log: the phone hears of it.
8448        calls::mark_seen(&calls_of(&desk), &desk.id, &[on_desk[0].id()], NOW + 2 * MINUTE).unwrap();
8449        desk.exchange(&folder, &key, NOW + 3 * MINUTE);
8450        let came = phone.exchange(&folder, &key, NOW + 4 * MINUTE);
8451        assert!(came.written.contains(CALLS_SEEN), "{came:?}");
8452        assert!(calls::read_seen(&calls_of(&phone)).seen.contains(&on_desk[0].id()));
8453        // Each file has one writer: the phone never wrote a seen line, the desk no call.
8454        assert!(!calls::own_file(&calls_of(&phone), calls::SEEN_LOG, &phone.id).exists());
8455        assert!(!calls::own_file(&calls_of(&desk), calls::LOG, &desk.id).exists());
8456        // Sealed: nothing of them readable in the folder.
8457        let mut files = Vec::new();
8458        list_files(&folder, &folder, &[], &mut files);
8459        for (name, path) in &files {
8460            let bytes = std::fs::read(path).unwrap();
8461            for plain in [&b"199001234"[..], b"Cabinet", b"stranger"] {
8462                assert!(!bytes.windows(plain.len()).any(|w| w == plain), "{name}");
8463            }
8464        }
8465        let every = stores_of(&Config::default(), &desk.roots, &|_| true);
8466        assert_eq!(locate(&every, &format!("{CALLS_LOG}{}.jsonl", phone.id)).map(|(s, _)| s.part), Some("calls"));
8467        for c in [&desk, &phone] {
8468            // Remembered beside memory.json, never in it.
8469            let small = Memory::load(&c.memory, &c.id);
8470            assert!(small.entries.keys().chain(small.files.keys()).chain(small.pending.keys()).all(|k| !calls_store(k)), "memory.json stays small");
8471            assert!(Memory::load_all(&c.memory, &c.id).entries.keys().any(|k| calls_store(k)));
8472            // Neither an earlier version nor a first-share copy of a log of calls.
8473            let share = c.memory.parent().unwrap();
8474            let mut kept = Vec::new();
8475            list_files(share, share, &[], &mut kept);
8476            assert!(kept.iter().all(|(name, _)| !name.contains("calls")), "{kept:?}");
8477        }
8478        // files.json lost: the calls' stores join again, and nothing goes out again as new.
8479        std::fs::remove_file(sealed_path(&desk.memory)).unwrap();
8480        let again = desk.exchange(&folder, &key, NOW + 5 * MINUTE);
8481        assert_eq!(again.sent, 0, "{again:?}");
8482        assert_eq!(calls::read_logs(&calls_of(&desk), NOW + 5 * MINUTE).len(), 1);
8483        let _ = std::fs::remove_dir_all(&base);
8484    }
8485
8486    /// Two phones and a desk: each phone's log its own, every device reading both.
8487    #[test]
8488    fn several_phones_each_keep_their_own_log() {
8489        use sioul_core::calls;
8490        let base = scratch("calls-phones");
8491        let folder = base.join("folder");
8492        let key = quick_key(&folder, "four words make a passphrase").unwrap();
8493        let (desk, a, b) = (Computer::new(&base, "desk"), Computer::new(&base, "phone-a"), Computer::new(&base, "phone-b"));
8494        calls::carry_into(&calls::own_file(&calls_of(&a), calls::LOG, &a.id), &[declined(NOW - 7_200_000, "+33199001234")], None, NOW).unwrap();
8495        calls::carry_into(&calls::own_file(&calls_of(&b), calls::LOG, &b.id), &[declined(NOW - 3_600_000, "+33465710042")], None, NOW).unwrap();
8496        for (n, c) in [&a, &b, &desk, &a, &b].iter().enumerate() {
8497            c.exchange(&folder, &key, NOW + n as i64 * MINUTE);
8498        }
8499        for c in [&desk, &a, &b] {
8500            let heard = calls::read_logs(&calls_of(c), NOW + 10 * MINUTE);
8501            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())]);
8502        }
8503        let _ = std::fs::remove_dir_all(&base);
8504    }
8505
8506    /// An older Sioul knows neither of the calls' stores: their records wait,
8507    /// written nowhere, taken out nowhere; updated, it reads them.
8508    #[test]
8509    fn an_older_sioul_keeps_the_calls_waiting() {
8510        use sioul_core::calls;
8511        let base = scratch("calls-older");
8512        let folder = base.join("folder");
8513        let key = quick_key(&folder, "four words make a passphrase").unwrap();
8514        let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
8515        let own = calls::own_file(&calls_of(&phone), calls::LOG, &phone.id);
8516        calls::carry_into(&own, &[declined(NOW - 3_600_000, "+33199001234")], None, NOW).unwrap();
8517        phone.exchange(&folder, &key, NOW);
8518        // The desk's older Sioul: no store of calls, and none known.
8519        let older_stores = stores(&Config::default(), &desk.roots).into_iter().filter(|s| !calls_store(&s.name)).collect::<Vec<_>>();
8520        OLDER.with(|older| *older.borrow_mut() = vec![CALLS_LOG.to_string(), CALLS_SEEN.to_string()]);
8521        for n in 1..=3 {
8522            let outcome = exchange(&Sharing { folder: &folder, computer: &desk.id, key: &key, memory: &desk.memory, files: true, hurry: None }, &older_stores, NOW + n * MINUTE).unwrap();
8523            assert_eq!(outcome.pending, 1, "kept waiting: {outcome:?}");
8524        }
8525        assert!(!calls_of(&desk).exists(), "nothing written");
8526        phone.exchange(&folder, &key, NOW + 4 * MINUTE);
8527        assert_eq!(calls::read_held(&own).len(), 1, "nothing taken out");
8528        // Updated: it knows both stores, they join, and the call comes in.
8529        OLDER.with(|older| older.borrow_mut().clear());
8530        let came = desk.exchange(&folder, &key, NOW + 5 * MINUTE);
8531        assert!(came.written.contains(CALLS_LOG) && came.pending == 0, "{came:?}");
8532        assert_eq!(calls::read_logs(&calls_of(&desk), NOW + 5 * MINUTE).len(), 1);
8533        let _ = std::fs::remove_dir_all(&base);
8534    }
8535
8536    /// The part switched off on the desk: nothing of it comes, nothing waits.
8537    #[test]
8538    fn the_calls_part_switched_off_receives_nothing_and_keeps_nothing_waiting() {
8539        use sioul_core::calls;
8540        let base = scratch("calls-off");
8541        let folder = base.join("folder");
8542        let key = quick_key(&folder, "four words make a passphrase").unwrap();
8543        let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
8544        calls::carry_into(&calls::own_file(&calls_of(&phone), calls::LOG, &phone.id), &[declined(NOW - 3_600_000, "+33199001234")], None, NOW).unwrap();
8545        phone.exchange(&folder, &key, NOW);
8546        let off = stores_of(&Config::default(), &desk.roots, &|part| part != "calls" && shared_by_default(part, &Config::default()));
8547        let outcome = exchange(&Sharing { folder: &folder, computer: &desk.id, key: &key, memory: &desk.memory, files: true, hurry: None }, &off, NOW + MINUTE).unwrap();
8548        assert_eq!(outcome.pending, 0, "{outcome:?}");
8549        assert!(!calls_of(&desk).exists());
8550        // Switched on again, it joins and reads them then.
8551        let came = desk.exchange(&folder, &key, NOW + 2 * MINUTE);
8552        assert!(came.written.contains(CALLS_LOG), "{came:?}");
8553        let _ = std::fs::remove_dir_all(&base);
8554    }
8555
8556    /// A month on, the phone takes its own line out: every device's copy loses it.
8557    #[test]
8558    fn a_trimmed_line_leaves_every_device() {
8559        use sioul_core::calls;
8560        let base = scratch("calls-trim");
8561        let folder = base.join("folder");
8562        let key = quick_key(&folder, "four words make a passphrase").unwrap();
8563        let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
8564        let own = calls::own_file(&calls_of(&phone), calls::LOG, &phone.id);
8565        calls::carry_into(&own, &[declined(NOW - 3_600_000, "+33199001234")], None, NOW).unwrap();
8566        phone.exchange(&folder, &key, NOW);
8567        desk.exchange(&folder, &key, NOW + MINUTE);
8568        assert_eq!(calls::read_held(&calls::own_file(&calls_of(&desk), calls::LOG, &phone.id)).len(), 1);
8569        let later = NOW + (calls::KEPT_DAYS + 1) * 86_400_000;
8570        assert_eq!(calls::trim_own(&calls_of(&phone), &phone.id, later).unwrap(), 1);
8571        phone.exchange(&folder, &key, later);
8572        desk.exchange(&folder, &key, later + MINUTE);
8573        assert!(calls::read_held(&calls::own_file(&calls_of(&desk), calls::LOG, &phone.id)).is_empty(), "gone from the desk's copy too");
8574        let _ = std::fs::remove_dir_all(&base);
8575    }
8576
8577    /// A message a phone's notification brought (fiction numbers only).
8578    fn texted(at: i64) -> sioul_core::phonemsgs::Line {
8579        sioul_core::phonemsgs::Line {
8580            at,
8581            id: format!("{at}-0123456789abcdef"),
8582            app: "foundation.e.message".into(),
8583            label: "Message".into(),
8584            kind: "text".into(),
8585            name: "Cabinet du Dr Martin".into(),
8586            key: "+33199001234".into(),
8587            who: "neutral".into(),
8588            text: "Votre rendez-vous est déplacé à jeudi 10 h 30.".into(),
8589            shows: at,
8590            ..sioul_core::phonemsgs::Line::default()
8591        }
8592    }
8593
8594    /// The phones' messages' folder of a device.
8595    fn messages_of(c: &Computer) -> PathBuf {
8596        c.roots.state.join(sioul_core::phonemsgs::FOLDER)
8597    }
8598
8599    /// A phone's message reaches the desk, both with the part on: sealed and
8600    /// padded in the folder, remembered beside `memory.json`, never kept as an
8601    /// earlier version; Seen on the desk comes back to the phone.
8602    #[test]
8603    fn a_phone_s_messages_reach_the_desk_sealed_and_seen_comes_back() {
8604        use sioul_core::{calls, phonemsgs};
8605        let base = scratch("phone-messages");
8606        let (folder, notes) = (base.join("folder"), base.join("notes"));
8607        let key = quick_key(&folder, "four words make a passphrase").unwrap();
8608        let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
8609        let on = [phonemsgs::PART];
8610        let own = calls::own_file(&messages_of(&phone), phonemsgs::LOG, &phone.id);
8611        assert_eq!(phonemsgs::carry_into(&own, &[texted(NOW - 3_600_000)], NOW).unwrap(), 1);
8612        assert!(std::fs::read_to_string(&own).unwrap().lines().all(|l| l.len() % phonemsgs::PAD_STEP == 0), "padded");
8613        phone.exchange_with(&folder, &key, NOW, &notes, &on);
8614        let came = desk.exchange_with(&folder, &key, NOW + MINUTE, &notes, &on);
8615        assert!(came.written.contains(PHONE_MESSAGES_LOG), "{came:?}");
8616        let on_desk = phonemsgs::read_logs(&messages_of(&desk), NOW + MINUTE);
8617        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.")]);
8618        // Seen on the desk, in its own log: the phone hears of it.
8619        calls::mark_seen(&messages_of(&desk), &desk.id, &[on_desk[0].id.clone()], NOW + 2 * MINUTE).unwrap();
8620        desk.exchange_with(&folder, &key, NOW + 3 * MINUTE, &notes, &on);
8621        let came = phone.exchange_with(&folder, &key, NOW + 4 * MINUTE, &notes, &on);
8622        assert!(came.written.contains(PHONE_MESSAGES_SEEN), "{came:?}");
8623        assert!(calls::read_seen(&messages_of(&phone)).seen.contains(&on_desk[0].id));
8624        // Sealed: nothing of it readable in the folder.
8625        let mut files = Vec::new();
8626        list_files(&folder, &folder, &[], &mut files);
8627        for (name, path) in &files {
8628            let bytes = std::fs::read(path).unwrap();
8629            for plain in [&b"199001234"[..], b"Cabinet", b"rendez-vous", b"neutral"] {
8630                assert!(!bytes.windows(plain.len()).any(|w| w == plain), "{name}");
8631            }
8632        }
8633        let every = stores_of(&Config::default(), &desk.roots, &|_| true);
8634        assert_eq!(locate(&every, &format!("{PHONE_MESSAGES_LOG}{}.jsonl", phone.id)).map(|(s, _)| s.part), Some(phonemsgs::PART));
8635        for c in [&desk, &phone] {
8636            let small = Memory::load(&c.memory, &c.id);
8637            assert!(small.entries.keys().chain(small.files.keys()).chain(small.pending.keys()).all(|k| !calls_store(k)), "memory.json stays small");
8638            let share = c.memory.parent().unwrap();
8639            let mut kept = Vec::new();
8640            list_files(share, share, &[], &mut kept);
8641            assert!(kept.iter().all(|(name, _)| !name.contains("phone-messages")), "{kept:?}");
8642        }
8643        // files.json lost: nothing goes out again as new.
8644        std::fs::remove_file(sealed_path(&desk.memory)).unwrap();
8645        let again = desk.exchange_with(&folder, &key, NOW + 5 * MINUTE, &notes, &on);
8646        assert_eq!(again.sent, 0, "{again:?}");
8647        let _ = std::fs::remove_dir_all(&base);
8648    }
8649
8650    /// The part is off until turned on, on every device: a desk that never
8651    /// turned it on receives nothing and keeps nothing waiting; turned on, it
8652    /// reads them. A phone that never turned it on sends nothing.
8653    #[test]
8654    fn the_phone_messages_part_is_off_until_turned_on_and_off_receives_nothing() {
8655        use sioul_core::{calls, phonemsgs};
8656        let base = scratch("phone-messages-off");
8657        let (folder, notes) = (base.join("folder"), base.join("notes"));
8658        let key = quick_key(&folder, "four words make a passphrase").unwrap();
8659        let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
8660        assert!(!shared_by_default(phonemsgs::PART, &Config::default()));
8661        assert!(stores(&Config::default(), &desk.roots).iter().all(|s| s.part != phonemsgs::PART));
8662        phonemsgs::carry_into(&calls::own_file(&messages_of(&phone), phonemsgs::LOG, &phone.id), &[texted(NOW - 3_600_000)], NOW).unwrap();
8663        // The phone's part off: nothing of it leaves.
8664        phone.exchange(&folder, &key, NOW);
8665        desk.exchange_with(&folder, &key, NOW + MINUTE, &notes, &[phonemsgs::PART]);
8666        assert!(phonemsgs::read_logs(&messages_of(&desk), NOW + MINUTE).is_empty(), "the phone's part off");
8667        // The phone's on, the desk's off: nothing comes, nothing waits.
8668        phone.exchange_with(&folder, &key, NOW + 2 * MINUTE, &notes, &[phonemsgs::PART]);
8669        let outcome = desk.exchange(&folder, &key, NOW + 3 * MINUTE);
8670        assert_eq!(outcome.pending, 0, "{outcome:?}");
8671        assert!(!messages_of(&desk).join(phonemsgs::LOG).exists());
8672        // Turned on on the desk: it joins and reads it.
8673        let came = desk.exchange_with(&folder, &key, NOW + 4 * MINUTE, &notes, &[phonemsgs::PART]);
8674        assert!(came.written.contains(PHONE_MESSAGES_LOG), "{came:?}");
8675        assert_eq!(phonemsgs::read_logs(&messages_of(&desk), NOW + 4 * MINUTE).len(), 1);
8676        let _ = std::fs::remove_dir_all(&base);
8677    }
8678
8679    /// A week on, the phone takes its own line out: every device's copy loses it.
8680    #[test]
8681    fn a_trimmed_line_of_phone_messages_leaves_every_device() {
8682        use sioul_core::{calls, phonemsgs};
8683        let base = scratch("phone-messages-trim");
8684        let (folder, notes) = (base.join("folder"), base.join("notes"));
8685        let key = quick_key(&folder, "four words make a passphrase").unwrap();
8686        let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
8687        let on = [phonemsgs::PART];
8688        let own = calls::own_file(&messages_of(&phone), phonemsgs::LOG, &phone.id);
8689        phonemsgs::carry_into(&own, &[texted(NOW - 3_600_000)], NOW).unwrap();
8690        phone.exchange_with(&folder, &key, NOW, &notes, &on);
8691        desk.exchange_with(&folder, &key, NOW + MINUTE, &notes, &on);
8692        let theirs = calls::own_file(&messages_of(&desk), phonemsgs::LOG, &phone.id);
8693        assert_eq!(phonemsgs::read_file(&theirs).len(), 1);
8694        let later = NOW + phonemsgs::KEPT_DAYS * 86_400_000;
8695        assert_eq!(phonemsgs::trim_own(&messages_of(&phone), &phone.id, later).unwrap(), 1);
8696        phone.exchange_with(&folder, &key, later, &notes, &on);
8697        desk.exchange_with(&folder, &key, later + MINUTE, &notes, &on);
8698        assert!(phonemsgs::read_file(&theirs).is_empty(), "gone from the desk's copy too");
8699        let _ = std::fs::remove_dir_all(&base);
8700    }
8701
8702    /// An older Sioul knows neither of the phones' messages' stores: their
8703    /// records wait, written nowhere; updated, it reads them.
8704    #[test]
8705    fn an_older_sioul_keeps_the_phone_s_messages_waiting() {
8706        use sioul_core::{calls, phonemsgs};
8707        let base = scratch("phone-messages-older");
8708        let (folder, notes) = (base.join("folder"), base.join("notes"));
8709        let key = quick_key(&folder, "four words make a passphrase").unwrap();
8710        let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
8711        let on = [phonemsgs::PART];
8712        phonemsgs::carry_into(&calls::own_file(&messages_of(&phone), phonemsgs::LOG, &phone.id), &[texted(NOW - 3_600_000)], NOW).unwrap();
8713        phone.exchange_with(&folder, &key, NOW, &notes, &on);
8714        // The desk's older Sioul: no store of the phones' messages, and none known.
8715        let older_stores = desk.stores_with(&notes, &|part| part == phonemsgs::PART).into_iter().filter(|s| s.part != phonemsgs::PART).collect::<Vec<_>>();
8716        OLDER.with(|older| *older.borrow_mut() = vec![PHONE_MESSAGES_LOG.to_string(), PHONE_MESSAGES_SEEN.to_string()]);
8717        for n in 1..=2 {
8718            let outcome = exchange(&Sharing { folder: &folder, computer: &desk.id, key: &key, memory: &desk.memory, files: true, hurry: None }, &older_stores, NOW + n * MINUTE).unwrap();
8719            assert_eq!(outcome.pending, 1, "kept waiting: {outcome:?}");
8720        }
8721        assert!(!messages_of(&desk).exists(), "nothing written");
8722        OLDER.with(|older| older.borrow_mut().clear());
8723        let came = desk.exchange_with(&folder, &key, NOW + 3 * MINUTE, &notes, &on);
8724        assert!(came.written.contains(PHONE_MESSAGES_LOG) && came.pending == 0, "{came:?}");
8725        let _ = std::fs::remove_dir_all(&base);
8726    }
8727
8728    // ---------------------------------------------------------------- texts (SMS phase b)
8729
8730    /// The texts' folder of a device (in its data folder).
8731    fn texts_of(c: &Computer) -> PathBuf {
8732        c.roots.data.join(sioul_core::texts::FOLDER)
8733    }
8734
8735    /// A text a computer asks the phone to send (fiction numbers only).
8736    fn to_send(key: &str, phone: &str, written: i64) -> sioul_core::texts::Request {
8737        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, ahead: 0, again: String::new(), device: String::new() }
8738    }
8739
8740    /// The phone's step as the app takes it: every request read, decided,
8741    /// claimed in the ledger before it is "sent", its outcome written.
8742    fn phone_step(phone: &Computer, ledger: &Path, seal: &crate::textseal::TextSeal, now: i64) -> Vec<String> {
8743        use sioul_core::texts;
8744        let book = texts::Ledger::open(ledger, now).unwrap();
8745        let mut sent = Vec::new();
8746        for r in texts::read_requests(&texts_of(phone), seal) {
8747            if texts::verdict(&r, &phone.id, now, &book, None) == texts::Verdict::Send {
8748                texts::record(ledger, &texts::Claim { key: r.key.clone(), at: now, state: "claimed".into(), ..texts::Claim::default() }).unwrap();
8749                let outcome = texts::Outcome { key: r.key.clone(), state: "sent".into(), at: now, row: "sms-412".into(), ..texts::Outcome::default() };
8750                texts::append(&texts::own_file(&texts_of(phone), texts::OUTCOME, &phone.id), &[outcome], seal, None).unwrap();
8751                sent.push(r.key);
8752            }
8753        }
8754        sent
8755    }
8756
8757    /// A text written on the desk reaches the phone, which sends it once and
8758    /// says so; the desk hears it. Sealed in the folder and at rest on each
8759    /// device, remembered beside `memory.json`; read again after `files.json`
8760    /// is lost, it is never sent twice.
8761    #[test]
8762    fn a_text_written_on_the_desk_is_sent_once_by_the_phone_and_its_outcome_comes_back() {
8763        use sioul_core::texts::{self, Sealer};
8764        let base = scratch("texts");
8765        let (folder, notes, ledger) = (base.join("folder"), base.join("notes"), base.join("phone-private"));
8766        let key = quick_key(&folder, "four words make a passphrase").unwrap();
8767        let seal = crate::textseal::TextSeal::new(&key);
8768        let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
8769        let on = [texts::PART];
8770        texts::Ledger::open(&ledger, NOW - MINUTE).unwrap();
8771        let request = to_send("0123456789abcdef0123456789abcdef", &phone.id, NOW);
8772        texts::append(&texts::own_file(&texts_of(&desk), texts::SEND, &desk.id), std::slice::from_ref(&request), &seal, None).unwrap();
8773        desk.exchange_with(&folder, &key, NOW, &notes, &on);
8774        let came = phone.exchange_with(&folder, &key, NOW + MINUTE, &notes, &on);
8775        assert!(came.written.contains(TEXTS_SEND), "{came:?}");
8776        assert_eq!(phone_step(&phone, &ledger, &seal, NOW + MINUTE), [request.key.clone()]);
8777        phone.exchange_with(&folder, &key, NOW + 2 * MINUTE, &notes, &on);
8778        let came = desk.exchange_with(&folder, &key, NOW + 3 * MINUTE, &notes, &on);
8779        assert!(came.written.contains(TEXTS_OUTCOME), "{came:?}");
8780        let outcomes = texts::read_outcomes(&texts_of(&desk), &seal);
8781        assert_eq!(texts::state_of(&request.key, &outcomes).map(|o| o.state.as_str()), Some("sent"));
8782        // Sealed in the folder, and at rest on both devices.
8783        for root in [&folder, &desk.roots.data, &phone.roots.data] {
8784            let mut files = Vec::new();
8785            list_files(root, root, &[], &mut files);
8786            for (name, path) in &files {
8787                let bytes = std::fs::read(path).unwrap();
8788                for plain in [&b"199001234"[..], b"dix minutes", b"sms-412"] {
8789                    assert!(!bytes.windows(plain.len()).any(|w| w == plain), "{name}");
8790                }
8791            }
8792        }
8793        assert!(seal.open(std::fs::read_to_string(texts::own_file(&texts_of(&phone), texts::SEND, &desk.id)).unwrap().lines().next().unwrap()).is_some());
8794        for c in [&desk, &phone] {
8795            let small = Memory::load(&c.memory, &c.id);
8796            assert!(small.entries.keys().chain(small.files.keys()).all(|k| !calls_store(k)), "memory.json stays small");
8797        }
8798        // The phone's files.json lost: the request read again, never sent again.
8799        std::fs::remove_file(sealed_path(&phone.memory)).unwrap();
8800        phone.exchange_with(&folder, &key, NOW + 4 * MINUTE, &notes, &on);
8801        assert!(phone_step(&phone, &ledger, &seal, NOW + 4 * MINUTE).is_empty());
8802        let _ = std::fs::remove_dir_all(&base);
8803    }
8804
8805    /// The part is off until turned on: nothing of the texts travels from a
8806    /// device that never turned it on, and a desk with it off receives nothing.
8807    #[test]
8808    fn the_texts_part_is_off_until_turned_on() {
8809        use sioul_core::texts;
8810        assert!(!shared_by_default(texts::PART, &Config::default()));
8811        let base = scratch("texts-off");
8812        let (folder, notes) = (base.join("folder"), base.join("notes"));
8813        let key = quick_key(&folder, "four words make a passphrase").unwrap();
8814        let seal = crate::textseal::TextSeal::new(&key);
8815        let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
8816        assert!(stores(&Config::default(), &desk.roots).iter().all(|s| s.part != texts::PART));
8817        let text = texts::Text { id: "sms-1".into(), at: NOW - MINUTE, direction: "in".into(), with: vec!["+33199001234".into()], body: "Bonjour".into(), ..texts::Text::default() };
8818        texts::append(&texts::own_file(&texts_of(&phone), texts::LOG, &phone.id), &[text], &seal, None).unwrap();
8819        phone.exchange_with(&folder, &key, NOW, &notes, &[texts::PART]);
8820        let outcome = desk.exchange(&folder, &key, NOW + MINUTE);
8821        assert_eq!(outcome.pending, 0, "{outcome:?}");
8822        assert!(!texts_of(&desk).exists());
8823        let came = desk.exchange_with(&folder, &key, NOW + 2 * MINUTE, &notes, &[texts::PART]);
8824        assert!(came.written.contains(TEXTS_LOG), "{came:?}");
8825        assert_eq!(texts::read_logs(&texts_of(&desk), &seal).len(), 1);
8826        let _ = std::fs::remove_dir_all(&base);
8827    }
8828
8829    /// The archive only grows: a year on, nothing is trimmed by age; a text
8830    /// deleted on the phone gets a line of its own, and every device keeps
8831    /// the text, marked.
8832    #[test]
8833    fn the_texts_archive_only_grows_and_a_deletion_is_marked_everywhere() {
8834        use sioul_core::texts;
8835        let base = scratch("texts-archive");
8836        let (folder, notes) = (base.join("folder"), base.join("notes"));
8837        let key = quick_key(&folder, "four words make a passphrase").unwrap();
8838        let seal = crate::textseal::TextSeal::new(&key);
8839        let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
8840        let on = [texts::PART];
8841        let own = texts::own_file(&texts_of(&phone), texts::LOG, &phone.id);
8842        let id = |t: &texts::Text| t.line_id();
8843        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() };
8844        texts::append(&own, std::slice::from_ref(&text), &seal, Some(&id)).unwrap();
8845        phone.exchange_with(&folder, &key, NOW, &notes, &on);
8846        desk.exchange_with(&folder, &key, NOW + MINUTE, &notes, &on);
8847        let later = NOW + 400 * 86_400_000;
8848        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);
8849        texts::append(&own, &[texts::Text { deleted: later, body: String::new(), ..text }], &seal, Some(&id)).unwrap();
8850        phone.exchange_with(&folder, &key, later, &notes, &on);
8851        desk.exchange_with(&folder, &key, later + MINUTE, &notes, &on);
8852        let kept = texts::read_logs(&texts_of(&desk), &seal);
8853        assert_eq!(kept.iter().map(|t| (t.body.as_str(), t.deleted > 0)).collect::<Vec<_>>(), [("Bonjour", true)]);
8854        let _ = std::fs::remove_dir_all(&base);
8855    }
8856
8857    /// The first import of a whole history goes a batch at a time (500 texts
8858    /// and their media each step); every exchange in between still carries
8859    /// the dose marked and do-not-disturb pressed just before it, and the
8860    /// media go as blobs, never in the records.
8861    #[test]
8862    fn the_first_texts_build_s_records_are_passed_over() {
8863        // texts: its stores were in the state folder; left aside, never kept waiting, and written nowhere.
8864        let key = |folder: &str| format!("{folder}phone.jsonl#a line");
8865        assert!(retired(&key("state/texts/log/")));
8866        assert!(!retired(&key(TEXTS_LOG)));
8867    }
8868
8869    /// A computer holding ten thousand texts (the owner's, 8 October 2026:
8870    /// 9,833 and a 25 MB `files.json` rewritten at each exchange): an idle
8871    /// exchange writes nothing of its memory; what it keeps of them is measured.
8872    #[test]
8873    fn an_idle_exchange_writes_nothing_with_ten_thousand_texts() {
8874        use sioul_core::texts;
8875        let base = scratch("texts-idle");
8876        let (folder, notes) = (base.join("folder"), base.join("notes"));
8877        let key = quick_key(&folder, "four words make a passphrase").unwrap();
8878        let seal = crate::textseal::TextSeal::new(&key);
8879        let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
8880        let on = [texts::PART];
8881        let own = texts::own_file(&texts_of(&phone), texts::LOG, &phone.id);
8882        for batch in 0..20 {
8883            let lines: Vec<texts::Text> = (0..texts::BATCH)
8884                .map(|i| {
8885                    let n = batch * texts::BATCH + i;
8886                    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() }
8887                })
8888                .collect();
8889            texts::append(&own, &lines, &seal, None).unwrap();
8890        }
8891        // The desk's own sealed file too (its spam filter's table): its time
8892        // of use was set at every exchange, and the 25 MB file written again.
8893        desk.write("data/spam/table.bin", "a table of the desk's training");
8894        phone.exchange_with(&folder, &key, NOW, &notes, &on);
8895        desk.exchange_with(&folder, &key, NOW + MINUTE, &notes, &on);
8896        desk.exchange_with(&folder, &key, NOW + 2 * MINUTE, &notes, &on);
8897        assert!(!Memory::load_all(&desk.memory, &desk.id).sealed.mine.is_empty(), "the desk sealed its table");
8898        let sealed = sealed_path(&desk.memory);
8899        let (size, memory_size) = (std::fs::metadata(&sealed).unwrap().len(), std::fs::metadata(&desk.memory).unwrap().len());
8900        let stamp = |p: &Path| std::fs::metadata(p).unwrap().modified().unwrap();
8901        let before = stamp(&sealed);
8902        std::thread::sleep(std::time::Duration::from_millis(20));
8903        // Half an hour without a change: the sealed files looked for, the history tidied, nothing of it written.
8904        for minute in 3..33 {
8905            desk.exchange_with(&folder, &key, NOW + minute * MINUTE, &notes, &on);
8906        }
8907        eprintln!("ten thousand texts: files.json {size} bytes, memory.json {memory_size} bytes; the phone's log {} bytes", std::fs::metadata(&own).unwrap().len());
8908        assert_eq!(stamp(&sealed), before, "files.json rewritten by idle exchanges");
8909        assert!(std::fs::metadata(&desk.memory).unwrap().len() < 64 << 10, "memory.json stays small");
8910        assert!(size < 2 * std::fs::metadata(&own).unwrap().len(), "the texts' lines not kept twice: {size}");
8911        let _ = std::fs::remove_dir_all(&base);
8912    }
8913
8914    #[test]
8915    fn a_first_import_goes_in_batches_and_never_holds_back_the_doses() {
8916        use sioul_core::texts;
8917        let base = scratch("texts-import");
8918        let (folder, notes) = (base.join("folder"), base.join("notes"));
8919        let key = quick_key(&folder, "four words make a passphrase").unwrap();
8920        let seal = crate::textseal::TextSeal::new(&key);
8921        let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
8922        let on = [texts::PART];
8923        let own = texts::own_file(&texts_of(&phone), texts::LOG, &phone.id);
8924        let picture = vec![7u8; 512 * 1024];
8925        let source = base.join("picture.bin");
8926        std::fs::write(&source, &picture).unwrap();
8927        let (hash, size) = crate::blobs::hash_file(&source).unwrap();
8928        for batch in 0..6 {
8929            let now = NOW + batch * 10 * MINUTE;
8930            // A dose taken and do-not-disturb pressed on the phone, then the import's next step.
8931            phone.write("state/health-state.toml", &format!("[taken]\n\"dose@{batch}\" = {}\n", now / 1000));
8932            phone.write("state/do-not-disturb.toml", &format!("[device.{}]\npressed = {now}\n", phone.id));
8933            let lines: Vec<texts::Text> = (0..texts::BATCH)
8934                .map(|i| {
8935                    let n = batch as usize * texts::BATCH + i;
8936                    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() };
8937                    if i == 0 {
8938                        text.parts = vec![texts::Part { seq: 0, ct: "image/png".into(), size, hash: hash.clone(), state: "here".into(), ..texts::Part::default() }];
8939                    }
8940                    text
8941                })
8942                .collect();
8943            crate::blobs::put(&folder, &key, &source, &hash).unwrap();
8944            texts::append(&own, &lines, &seal, None).unwrap();
8945            let sent = phone.exchange_with(&folder, &key, now, &notes, &on);
8946            assert!(sent.sent <= texts::BATCH + 10, "a batch at a time: {sent:?}");
8947            desk.exchange_with(&folder, &key, now + MINUTE, &notes, &on);
8948            // On time: the dose and the press of this very step are on the desk.
8949            assert!(desk.read("state/health-state.toml").contains(&format!("dose@{batch}")), "batch {batch}");
8950            assert!(desk.read("state/do-not-disturb.toml").contains(&now.to_string()), "batch {batch}");
8951            assert_eq!(texts::read_logs(&texts_of(&desk), &seal).len(), (batch as usize + 1) * texts::BATCH);
8952        }
8953        // The media: a blob in the folder, never in the records.
8954        let mut files = Vec::new();
8955        list_files(&folder, &folder, &[], &mut files);
8956        let records: u64 = files.iter().filter(|(name, _)| !name.starts_with("blobs")).filter_map(|(_, p)| std::fs::metadata(p).ok()).map(|m| m.len()).sum();
8957        assert!(records < 6 * texts::BATCH as u64 * 2048, "the records hold lines, not media: {records} bytes");
8958        let target = base.join("fetched.bin");
8959        crate::blobs::get(&folder, &key, &hash, &target).unwrap();
8960        assert_eq!(std::fs::read(&target).unwrap(), picture);
8961        let _ = std::fs::remove_dir_all(&base);
8962    }
8963
8964    #[test]
8965    fn toml_entries_and_back() {
8966        let rules = &CONFIG_RULES;
8967        let text = "# mine\nlanguage = \"fr\" # kept\ncase_store = \"~/Notes\"\n\n[quiet]\npersonal = [\"joy\", \"family\"]\n\n[[account]]\nid = \"a\"\nmaildir = \"/x\"\n";
8968        let entries = toml_entries(text, rules).unwrap();
8969        let names: Vec<&str> = entries.iter().map(|(k, _)| k.as_str()).collect();
8970        assert!(names.contains(&"language") && names.contains(&"quiet\u{1f}personal") && names.contains(&"account\u{1f}\u{1e}a"), "{names:?}");
8971        assert!(!names.iter().any(|n| n.starts_with("case_store")));
8972        // Writing keeps the rest of the file as written.
8973        let account = entries.iter().find(|(k, _)| k.starts_with("account")).unwrap().1.replace("id = \"a\"", "id = \"a\"\nmuted = true");
8974        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();
8975        assert!(new.starts_with("# mine\nlanguage = \"en\""), "{new}");
8976        assert!(new.contains("muted = true") && new.contains("maildir = \"/x\""), "{new}");
8977        assert!(new.contains("work = [\"client\"]"), "{new}");
8978        // A list of words, one at a time.
8979        let money = "ignored = [\"a\", \"b\"]\n";
8980        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();
8981        assert_eq!(new, "ignored = [\"b\", \"c\"]\n");
8982    }
8983
8984    /// A medicine's takes and their own amounts, its generic name and its
8985    /// strength travel with it, one entry by
8986    /// its id (docs/health.md, "Kept as"): written into the file of a device
8987    /// whose copy has no amounts (an older Sioul's), they arrive whole, and the
8988    /// times every dose falls due at are the same.
8989    #[test]
8990    fn a_medicines_takes_travel_whole() {
8991        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";
8992        let entries = toml_entries(new, &HEALTH_RULES).unwrap();
8993        assert_eq!(entries.len(), 1, "one entry for the medicine, its takes in it: {entries:?}");
8994        let (entry, value) = &entries[0];
8995        assert!(entry.starts_with("medicine") && value.contains("2 tablets"), "{entry} {value}");
8996        let old = "[[medicine]]\nid = \"iron\"\nname = \"Iron\"\ndose = \"1 tablet\"\n\n[medicine.schedule]\nevery = \"day\"\ntimes = [\"08:00\", \"20:00\"]\n";
8997        let written = toml_write(old, &HEALTH_RULES, &[(entry.as_str(), Some(value.as_str()))], false).unwrap();
8998        let health: sioul_core::health::Health = toml::from_str(&written).unwrap();
8999        let takes: Vec<(String, String)> = health.medicines[0].takes().into_iter().map(|t| (t.time, t.amount)).collect();
9000        assert_eq!(takes, [("08:00".to_string(), "1 tablet".to_string()), ("20:00".to_string(), "2 tablets".to_string())]);
9001        // Its generic name, strength and first day too.
9002        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())));
9003        let before: sioul_core::health::Health = toml::from_str(old).unwrap();
9004        let day = |h: &sioul_core::health::Health| {
9005            let start: jiff::Zoned = "2026-10-08T00:00[Europe/Paris]".parse().unwrap();
9006            let end: jiff::Zoned = "2026-10-09T00:00[Europe/Paris]".parse().unwrap();
9007            h.doses(&start, &end).into_iter().map(|d| (d.key, d.dose)).collect::<Vec<_>>()
9008        };
9009        let (now, then) = (day(&health), day(&before));
9010        assert_eq!(now.iter().map(|d| &d.0).collect::<Vec<_>>(), then.iter().map(|d| &d.0).collect::<Vec<_>>(), "the same doses, the same keys");
9011        assert_eq!(now.iter().map(|d| d.1.as_str()).collect::<Vec<_>>(), ["1 tablet", "2 tablets"]);
9012    }
9013
9014    /// Do-not-disturb on every device (docs/do-not-disturb.md): each device's
9015    /// table of the switch travels whole and is never written by another; the
9016    /// list of people travels a person at a time; a Sioul that knows neither
9017    /// file shares the rest and takes nothing of them out.
9018    #[test]
9019    fn do_not_disturb_travels_each_device_its_table_and_the_list_a_person_at_a_time() {
9020        use sioul_core::everywhere::{People, Person, Switch, change_own, change_people};
9021        let base = scratch("dnd");
9022        let folder = base.join("Sioul");
9023        let key = quick_key(&folder, "four words make a passphrase").unwrap();
9024        let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
9025        let switch = |c: &Computer| c.roots.state.join(sioul_core::everywhere::SWITCH_FILE);
9026        let people = |c: &Computer| c.roots.config.join(sioul_core::everywhere::PEOPLE_FILE);
9027        // The desk turns it on; each adds someone to the list, before hearing of the other.
9028        change_own(&switch(&desk), &desk.id, |s| {
9029            s.press(&desk.id, true, 0, NOW);
9030            true
9031        })
9032        .unwrap();
9033        change_people(&people(&phone), |p| {
9034            p.add(Person { name: "Alice".into(), phones: vec!["+262639981234".into()], ..Person::default() }, None);
9035        })
9036        .unwrap();
9037        change_people(&people(&desk), |p| {
9038            p.add(Person { name: "Bob".into(), emails: vec!["bob@example.org".into()], ..Person::default() }, None);
9039        })
9040        .unwrap();
9041        desk.exchange(&folder, &key, NOW + MINUTE);
9042        phone.exchange(&folder, &key, NOW + 2 * MINUTE);
9043        desk.exchange(&folder, &key, NOW + 3 * MINUTE);
9044        let heard = Switch::read(&switch(&phone)).unwrap();
9045        let latest = heard.latest().unwrap();
9046        assert!(latest.on && latest.from == desk.id, "{heard:?}");
9047        for c in [&desk, &phone] {
9048            let names: Vec<String> = People::read(&people(c)).unwrap().people.into_iter().map(|p| p.name).collect();
9049            assert!(names.contains(&"Alice".to_string()) && names.contains(&"Bob".to_string()), "{names:?}");
9050        }
9051        // The phone turns it off, having heard the desk: its own table only.
9052        change_own(&switch(&phone), &phone.id, |s| {
9053            s.press(&phone.id, false, 0, NOW + 4 * MINUTE);
9054            true
9055        })
9056        .unwrap();
9057        phone.exchange(&folder, &key, NOW + 5 * MINUTE);
9058        desk.exchange(&folder, &key, NOW + 6 * MINUTE);
9059        let seen = Switch::read(&switch(&desk)).unwrap();
9060        assert!(!seen.latest().unwrap().on, "{seen:?}");
9061        assert!(seen.device[&desk.id].on, "the desk's own table as it wrote it");
9062        // Someone taken off on the desk: gone on the phone too.
9063        let bob = People::read(&people(&desk)).unwrap().people.into_iter().find(|p| p.name == "Bob").unwrap().id;
9064        change_people(&people(&desk), |p| {
9065            p.remove(&bob);
9066        })
9067        .unwrap();
9068        desk.exchange(&folder, &key, NOW + 7 * MINUTE);
9069        phone.exchange(&folder, &key, NOW + 8 * MINUTE);
9070        assert_eq!(People::read(&people(&phone)).unwrap().people.iter().map(|p| p.name.as_str()).collect::<Vec<_>>(), ["Alice"]);
9071        // A Sioul that knows neither file (an older one): writes neither, takes nothing out.
9072        let old = Computer::new(&base, "old");
9073        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();
9074        exchange(&Sharing { folder: &folder, computer: &old.id, key: &key, memory: &old.memory, files: true, hurry: None }, &older, NOW + 9 * MINUTE).unwrap();
9075        assert!(!switch(&old).exists() && !people(&old).exists());
9076        desk.exchange(&folder, &key, NOW + 10 * MINUTE);
9077        assert_eq!(People::read(&people(&desk)).unwrap().people.len(), 1);
9078        assert_eq!(Switch::read(&switch(&desk)).unwrap().device.len(), 2);
9079        let _ = std::fs::remove_dir_all(&base);
9080    }
9081
9082    /// A computer's table written as Sioul quits there (its inhibition ended
9083    /// with it) travels, and the phone reads it as closed, never silenced.
9084    #[test]
9085    fn a_computers_table_written_as_sioul_quits_reaches_the_phone_as_closed() {
9086        use sioul_core::everywhere::{Follows, Switch, change_own, others};
9087        let base = scratch("dnd-closed");
9088        let folder = base.join("Sioul");
9089        let key = quick_key(&folder, "four words make a passphrase").unwrap();
9090        let (desk, phone) = (Computer::new(&base, "desk"), Computer::new(&base, "phone"));
9091        let switch = |c: &Computer| c.roots.state.join(sioul_core::everywhere::SWITCH_FILE);
9092        // The phone turns do-not-disturb on; the desk silences itself, says so, then Sioul quits there.
9093        change_own(&switch(&phone), &phone.id, |s| {
9094            s.press(&phone.id, true, 0, NOW);
9095            true
9096        })
9097        .unwrap();
9098        phone.exchange(&folder, &key, NOW + MINUTE);
9099        desk.exchange(&folder, &key, NOW + 2 * MINUTE);
9100        change_own(&switch(&desk), &desk.id, |s| {
9101            let own = s.device.entry(desk.id.clone()).or_default();
9102            (own.kind, own.why, own.silenced, own.at) = ("computer".into(), "manual".into(), true, NOW + 2 * MINUTE);
9103            true
9104        })
9105        .unwrap();
9106        desk.exchange(&folder, &key, NOW + 3 * MINUTE);
9107        phone.exchange(&folder, &key, NOW + 4 * MINUTE);
9108        let read = |at: i64| {
9109            let s = Switch::read(&switch(&phone)).unwrap();
9110            others(&s, &phone.id, &[(desk.id.clone(), at / 1000)], at / 1000, NOW, &|_| true)
9111        };
9112        assert_eq!(read(NOW + 4 * MINUTE), vec![(desk.id.clone(), Follows::Silenced)]);
9113        // Sioul quits on the desk (the window closed, SIGTERM): its table as it then holds, sent.
9114        change_own(&switch(&desk), &desk.id, |s| {
9115            let own = s.device.get_mut(&desk.id).unwrap();
9116            own.closing(false);
9117            own.at = NOW + 5 * MINUTE;
9118            true
9119        })
9120        .unwrap();
9121        desk.exchange(&folder, &key, NOW + 5 * MINUTE);
9122        phone.exchange(&folder, &key, NOW + 6 * MINUTE);
9123        assert_eq!(read(NOW + 6 * MINUTE), vec![(desk.id.clone(), Follows::Closed)]);
9124        let table = &Switch::read(&switch(&phone)).unwrap().device[&desk.id];
9125        assert!(table.closed && !table.silenced, "{table:?}");
9126        let _ = std::fs::remove_dir_all(&base);
9127    }
9128}
9129
9130#[cfg(test)]
9131#[path = "share_format_tests.rs"]
9132mod format_tests;
9133
9134#[cfg(test)]
9135#[path = "share_switch_tests.rs"]
9136mod switch_tests;
9137
9138#[cfg(test)]
9139#[path = "share_switch_more_tests.rs"]
9140mod switch_more_tests;
9141
9142#[cfg(test)]
9143#[path = "share_switch_third_tests.rs"]
9144mod switch_third_tests;
9145
9146#[cfg(test)]
9147#[path = "share_quiet_tests.rs"]
9148mod quiet_tests;
9149
9150#[cfg(test)]
9151#[path = "share_idle_bench.rs"]
9152mod idle_bench;
9153
9154#[cfg(test)]
9155#[path = "share_correctness_tests.rs"]
9156mod correctness_tests;
9157
9158#[cfg(test)]
9159mod probe {
9160    /// What a copy of real folders holds, in counts: `SIOUL_SHARE_PROBE=<dir with config, data, state>`.
9161    #[test]
9162    #[ignore]
9163    fn probe() {
9164        let Some(base) = std::env::var_os("SIOUL_SHARE_PROBE").map(std::path::PathBuf::from) else { return };
9165        let roots = super::Roots { config: base.join("config"), data: base.join("data"), state: base.join("state") };
9166        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();
9167        let stores = super::stores(&config, &roots);
9168        let look = super::Look { reads: &|_| true, hurry: &|| false, confirmed: &Default::default(), clock: super::clock_ms() };
9169        let found = super::gather(&stores, &Default::default(), &look);
9170        for store in &stores {
9171            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();
9172            println!("{:32} {count}", store.name);
9173        }
9174        println!("unknown: {:?}", found.unknown);
9175    }
9176}