Skip to main content

sioul_core/
draftcopies.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! The copy of the message a draft answers or forwards, kept beside it
5//! (`drafts/<id>.eml`, [`Draft::keep_original`]), stays as it came:
6//! encrypted when it came so, opened only at Send, in memory
7//! (`sioul_sync::send::build_draft`). Until 19 October 2026 Sioul wrote there
8//! the decrypted text of an encrypted message it answered; the sharing
9//! carried that text to each of your devices, and kept its earlier versions
10//! on each (`sioul_sync::history`). [`seal_beside`] and [`seal_versions`]
11//! encrypt those clear copies again, to your own certificates, on each
12//! device that holds them, and overwrite what they held where the filesystem
13//! lets it ([`crate::filelock::Placed::finish`]; docs/client.md, "What a
14//! disk keeps"). Nothing else is touched: a draft's own file, its text and
15//! its choices stay as they are, and a copy is replaced whole, never missing.
16//!
17//! Each copy's new bytes are written beside it first; then it takes its
18//! place under the sharing's exchange lock (`Hold`), a short hold at a time,
19//! only if the file is still as it was read; its old bytes are overwritten
20//! once the lock is let go. The sharing reads these files only under that
21//! lock, by their names: it never holds a file being replaced, so it never
22//! reads, seals nor sends the zeros that follow. Every other reader (Send, the
23//! writing window) reads through [`crate::filelock::read_whole`], which never
24//! returns them.
25//!
26//! Which copies may hold decrypted text, in doubt sealed (review of 19
27//! October 2026, F6 and N7), judged by where they come from, never by their
28//! shape alone: a copy kept as it came (its draft says so, from 19 October
29//! 2026: `Draft::copy_as_received`) never. Any other was written before: the
30//! message itself when it came in clear, its decrypted text when it came
31//! encrypted. It is sealed unless it is, byte for byte, a message found here
32//! under its Message-ID or the one its draft answers (`Draft::in_reply_to`),
33//! every message of those IDs here having come in clear: a copy of a message
34//! found nowhere, or of one another message shares its ID with (anyone who
35//! knows an ID can send one), is sealed. A decrypted entity with no header
36//! of its own looks like a message that came as it is (N7): its shape says
37//! nothing. Its earlier versions and what was kept before sharing began are
38//! judged the same way, and "Put back" opens a sealed one with your key at
39//! Send.
40//!
41//! What a look decides is kept with the file as it was read (its stamp
42//! taken from the descriptor that read it, or from the new file before it
43//! took its place), never as the path names it afterwards: a version an
44//! exchange puts in place meanwhile is looked at again (N6).
45
46use crate::compose::Draft;
47use crate::filelock::{FileStamp, Prepared};
48use crate::pgp::{self, Keys};
49use sequoia_openpgp::Cert;
50use std::collections::{HashSet, VecDeque};
51use std::path::{Path, PathBuf};
52use std::time::{Duration, Instant};
53
54/// The sharing's exchange lock, taken for a batch of replacements and let go
55/// when what it returns is dropped (`sioul_sync::share::exchange_lock`); None
56/// when it cannot be taken: the copies wait for the next look. A device that
57/// has never shared gives a guard that holds nothing ([`unshared`]).
58pub type Hold<'a> = &'a dyn Fn() -> Option<Box<dyn Held>>;
59
60/// A hold of the exchanges' lock, as a [`Hold`] gives it: let go when dropped.
61pub trait Held {
62    /// Whether an exchange waits for the lock now (a dose's alarm): the hold
63    /// then ends after the copy it places (review of part 4, D-1b).
64    fn wanted(&self) -> bool {
65        false
66    }
67}
68
69impl Held for () {}
70
71/// The guard of a device that has never shared: nothing to hold.
72pub fn unshared() -> Option<Box<dyn Held>> {
73    Some(Box::new(()))
74}
75
76/// The longest the replacements made under one hold of that lock take: an
77/// exchange waiting for it (a dose's) never waits longer (the lesson of the
78/// review of 18 October 2026, L-3). Only renames happen then: each copy's
79/// new bytes are written before, its old bytes overwritten after.
80const HOLD_AT_MOST: Duration = Duration::from_millis(200);
81/// How long the lock is let go between two holds, for an exchange that waits,
82/// beside the hold's own hand-off to one waiting in this process (the guard
83/// `sioul_sync::share::replacements_hold` gives waits for it to take the lock).
84const LET_GO: Duration = Duration::from_millis(20);
85/// The copies prepared at once, their new bytes beside them on the disk.
86const BATCH_FILES: usize = 32;
87const BATCH_BYTES: usize = 16 << 20;
88
89/// What a look did, in counts only: nothing of what the copies say.
90#[derive(Debug, Default, Clone, PartialEq, Eq)]
91pub struct Sealed {
92    /// Copies beside drafts encrypted again.
93    pub copies: usize,
94    /// Earlier versions of them, kept on this device, encrypted again.
95    pub versions: usize,
96    /// Copies that could not be rewritten (a full disk, a folder not
97    /// writable): looked at again next time.
98    pub failed: usize,
99    /// Copies to encrypt while no certificate of yours takes encryption
100    /// here: looked at again once one does.
101    pub waiting: usize,
102    /// Encrypted copies whose draft is gone (sent, or thrown away while the
103    /// look replaced the copy), taken out a day after (review of 19 October
104    /// 2026, F17).
105    pub orphans: usize,
106}
107
108impl Sealed {
109    /// Whether anything is worth saying.
110    pub fn any(&self) -> bool {
111        self.copies + self.versions + self.failed + self.waiting + self.orphans > 0
112    }
113
114    /// Both looks' counts.
115    pub fn and(self, other: Sealed) -> Sealed {
116        Sealed {
117            copies: self.copies + other.copies,
118            versions: self.versions + other.versions,
119            failed: self.failed + other.failed,
120            waiting: self.waiting + other.waiting,
121            orphans: self.orphans + other.orphans,
122        }
123    }
124}
125
126/// The files looked at, as they were then (their device, number, size and
127/// time): not read again until they change. Kept on the disk between
128/// processes ([`Judged::load`], [`Judged::save`]: `state/draftcopies.json`,
129/// never shared), so that a process started for a moment (a phone's
130/// background step) reads only what changed since the last look; one that
131/// does not read (another format, damaged) is a look from nothing, which
132/// seals nothing twice.
133#[derive(Debug, Default)]
134pub struct Judged {
135    files: std::collections::BTreeMap<PathBuf, FileStamp>,
136    /// Copies the last look left waiting for a certificate of yours (said in Accounts ▸ Encryption).
137    waiting: usize,
138    /// Looked at in this look: the others, gone or out of reach, are forgotten at the save.
139    met: HashSet<PathBuf>,
140    changed: bool,
141}
142
143/// The verdicts' file: its format, and each file's stamp.
144#[derive(serde::Serialize, serde::Deserialize)]
145struct JudgedFile {
146    format: u32,
147    files: std::collections::BTreeMap<PathBuf, FileStamp>,
148    #[serde(default)]
149    waiting: usize,
150}
151
152/// The verdicts' format: another one is read as none.
153const JUDGED_FORMAT: u32 = 1;
154
155impl Judged {
156    /// The verdicts kept at `path`; none when it does not read.
157    pub fn load(path: &Path) -> Judged {
158        let (files, waiting) = std::fs::read(path).ok().and_then(|bytes| serde_json::from_slice::<JudgedFile>(&bytes).ok()).filter(|f| f.format == JUDGED_FORMAT).map(|f| (f.files, f.waiting)).unwrap_or_default();
159        Judged { files, waiting, met: HashSet::new(), changed: false }
160    }
161
162    /// The copies the last look left waiting for a certificate of yours that takes a seal.
163    pub fn waiting(&self) -> usize {
164        self.waiting
165    }
166
167    /// What a look found waiting, kept for the next save.
168    pub fn set_waiting(&mut self, waiting: usize) {
169        if self.waiting != waiting {
170            self.waiting = waiting;
171            self.changed = true;
172        }
173    }
174
175    /// The verdicts written at `path` when they changed (beside it, then
176    /// renamed: never half a file), the files not met since the last save
177    /// forgotten.
178    pub fn save(&mut self, path: &Path) -> std::io::Result<()> {
179        let before = self.files.len();
180        let met = std::mem::take(&mut self.met);
181        self.files.retain(|file, _| met.contains(file));
182        if !self.changed && self.files.len() == before {
183            return Ok(());
184        }
185        if let Some(parent) = path.parent() {
186            std::fs::create_dir_all(parent)?;
187        }
188        let text = serde_json::to_vec(&JudgedFile { format: JUDGED_FORMAT, files: self.files.clone(), waiting: self.waiting }).map_err(std::io::Error::other)?;
189        crate::filelock::replace(path, &text)?;
190        self.changed = false;
191        Ok(())
192    }
193
194    /// The file's stamp now, when it was not judged as it is (None: judged,
195    /// or gone).
196    fn unseen(&mut self, path: &Path) -> Option<FileStamp> {
197        let now = FileStamp::now(path)?;
198        self.met.insert(path.to_path_buf());
199        (self.files.get(path) != Some(&now)).then_some(now)
200    }
201
202    /// What was judged: the file as it was then (`stamp`), never what the
203    /// path names now, which another version may have taken meanwhile (N6).
204    fn mark(&mut self, path: &Path, stamp: FileStamp) {
205        self.met.insert(path.to_path_buf());
206        if self.files.insert(path.to_path_buf(), stamp) != Some(stamp) {
207            self.changed = true;
208        }
209    }
210}
211
212/// Your own certificates, to seal to: read only once a copy is to be sealed
213/// (reading every key, others' too, costs a phone's step its time: 18 ms for
214/// 300 others' keys, review of 19 October 2026, F9).
215pub struct Certs<'a> {
216    load: Box<dyn Fn() -> Keys + 'a>,
217    keys: std::cell::OnceCell<Keys>,
218}
219
220impl<'a> Certs<'a> {
221    /// Read by `load` when first needed.
222    pub fn lazy(load: impl Fn() -> Keys + 'a) -> Certs<'a> {
223        Certs { load: Box::new(load), keys: std::cell::OnceCell::new() }
224    }
225
226    /// Those of `keys`, read already.
227    pub fn of(keys: Keys) -> Certs<'a> {
228        let cell = std::cell::OnceCell::new();
229        let _ = cell.set(keys);
230        Certs { load: Box::new(Keys::default), keys: cell }
231    }
232
233    fn get(&self) -> Vec<&Cert> {
234        self.keys.get_or_init(|| (self.load)()).own_certs()
235    }
236
237    /// Whether they were read: never when nothing was to be sealed.
238    pub fn read(&self) -> bool {
239        self.keys.get().is_some()
240    }
241}
242
243#[cfg(test)]
244thread_local! {
245    /// The copies read whole by this thread's looks: none for a file met before, unchanged.
246    static READ: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
247}
248
249/// A copy read whole for a look (counted in tests).
250fn read_copy(path: &Path) -> std::io::Result<(Vec<u8>, std::fs::Metadata)> {
251    #[cfg(test)]
252    READ.with(|n| n.set(n.get() + 1));
253    crate::filelock::read_whole_with(path)
254}
255
256/// The draft's name of a copy beside it: `<id>.eml`, its id plain letters
257/// and digits as a draft's is ([`Draft::by_id`]).
258fn copy_id(path: &Path) -> Option<String> {
259    let name = path.file_name()?.to_str()?;
260    let id = name.strip_suffix(".eml")?;
261    (!id.is_empty() && id.chars().all(|c| c.is_ascii_alphanumeric())).then(|| id.to_string())
262}
263
264/// The Message-ID a message names, bare ("abc@example.org").
265pub fn message_id(raw: &[u8]) -> Option<String> {
266    let message = mail_parser::MessageParser::default().parse_headers(raw)?;
267    Some(crate::mailindex::bare_id(message.message_id()?)).filter(|id| !id.is_empty())
268}
269
270/// Whether a copy in clear may hold an encrypted message's text (in doubt,
271/// yes), a copy not kept as it came: see the module's words. `answers`, the
272/// Message-ID its draft answers, if any; `find` gives every file of a
273/// Message-ID in your mail here.
274fn may_hold_decrypted(raw: &[u8], answers: Option<&str>, find: &mut dyn FnMut(&str) -> Vec<PathBuf>) -> bool {
275    let mut ids: Vec<String> = message_id(raw).into_iter().collect();
276    if let Some(answers) = answers.map(crate::mailindex::bare_id).filter(|id| !id.is_empty() && !ids.contains(id)) {
277        ids.push(answers);
278    }
279    if ids.is_empty() {
280        return true;
281    }
282    let found: Vec<Vec<u8>> = ids.iter().flat_map(|id| find(id)).filter_map(|path| crate::filelock::read_whole(&path).ok()).collect();
283    let every_clear = !found.is_empty() && found.iter().all(|message| !pgp::is_encrypted(message));
284    !(every_clear && found.iter().any(|message| message.as_slice() == raw))
285}
286
287/// The clear copies beside the drafts of `drafts` that may hold an
288/// encrypted message's text, encrypted again to your own certificates.
289/// `find` gives every file of a Message-ID in your mail here; `hold`, the
290/// sharing's lock, under which each copy takes its place.
291pub fn seal_beside(drafts: &Path, certs: &Certs, find: &mut dyn FnMut(&str) -> Vec<PathBuf>, judged: &mut Judged, hold: Hold) -> Sealed {
292    let mut out = Sealed::default();
293    let Ok(entries) = std::fs::read_dir(drafts) else { return out };
294    let mut copies: Vec<(PathBuf, String)> = entries.filter_map(Result::ok).filter(|e| e.file_type().is_ok_and(|t| t.is_file())).filter_map(|e| Some((e.path(), copy_id(&e.path())?))).collect();
295    copies.sort();
296    let mut batch = Batch { waiting: VecDeque::new(), bytes: 0, hold };
297    let mut orphans = Vec::new();
298    for (path, id) in copies {
299        let draft = Draft::load(&drafts.join(format!("{id}.toml")));
300        // Encrypted, its draft gone more than a day ago: an orphan, taken out below.
301        if draft.is_none() && orphaned(&path) {
302            orphans.push(path);
303            continue;
304        }
305        let Some(stamp) = judged.unseen(&path) else { continue };
306        // Kept as it came, wherever the draft was written: never decrypted.
307        if draft.as_ref().is_some_and(|d| d.copy_as_received) {
308            judged.mark(&path, stamp);
309            continue;
310        }
311        let Ok((raw, meta)) = read_copy(&path) else { continue };
312        // Judged as read: the stamp of the bytes read, from their descriptor (N6).
313        let read = FileStamp::of(&meta);
314        let answers = draft.as_ref().and_then(|d| d.in_reply_to.clone());
315        if pgp::is_encrypted(&raw) || !may_hold_decrypted(&raw, answers.as_deref(), find) {
316            judged.mark(&path, read);
317            continue;
318        }
319        match prepare(&path, &raw, &certs.get()) {
320            Some(Ok((prepared, size))) => batch.push(Waiting { prepared, was: read, path, version: false }, size, judged, &mut out),
321            Some(Err(_)) => out.failed += 1,
322            None => out.waiting += 1,
323        }
324    }
325    batch.flush(judged, &mut out);
326    let mut orphans: VecDeque<PathBuf> = orphans.into();
327    // Taken out under the lock as the copies take their place: a short hold at a time.
328    while !orphans.is_empty() {
329        let Some(held) = hold() else { break };
330        let started = Instant::now();
331        while let Some(path) = orphans.pop_front() {
332            // Still an orphan under the lock (its draft did not come meanwhile).
333            if !drafts.join(format!("{}.toml", copy_id(&path).unwrap_or_default())).exists() && std::fs::remove_file(&path).is_ok() {
334                out.orphans += 1;
335            }
336            if started.elapsed() >= HOLD_AT_MOST || held.wanted() {
337                break;
338            }
339        }
340        drop(held);
341        if !orphans.is_empty() {
342            std::thread::sleep(LET_GO);
343        }
344    }
345    out
346}
347
348/// An encrypted copy left alone more than a day: its draft, gone, will not come.
349fn orphaned(path: &Path) -> bool {
350    let old = std::fs::metadata(path).ok().and_then(|m| m.modified().ok()).and_then(|t| t.elapsed().ok()).is_some_and(|age| age.as_secs() > 24 * 3600);
351    old && crate::filelock::read_whole(path).is_ok_and(|raw| pgp::is_encrypted(&raw))
352}
353
354/// Earlier versions of those copies (`paths`, each a file), judged as the
355/// copies are, encrypted again to your own certificates, each under `hold`
356/// as above: only "Put back" reads them, which then opens a sealed one with
357/// your key at Send.
358pub fn seal_versions(paths: &[PathBuf], certs: &Certs, find: &mut dyn FnMut(&str) -> Vec<PathBuf>, judged: &mut Judged, hold: Hold) -> Sealed {
359    let mut out = Sealed::default();
360    let mut batch = Batch { waiting: VecDeque::new(), bytes: 0, hold };
361    for path in paths {
362        if judged.unseen(path).is_none() {
363            continue;
364        }
365        let Ok((raw, meta)) = read_copy(path) else { continue };
366        let read = FileStamp::of(&meta);
367        if pgp::is_encrypted(&raw) || !may_hold_decrypted(&raw, None, find) {
368            judged.mark(path, read);
369            continue;
370        }
371        match prepare(path, &raw, &certs.get()) {
372            Some(Ok((prepared, size))) => batch.push(Waiting { prepared, was: read, path: path.clone(), version: true }, size, judged, &mut out),
373            Some(Err(_)) => out.failed += 1,
374            None => out.waiting += 1,
375        }
376    }
377    batch.flush(judged, &mut out);
378    out
379}
380
381/// `clear`, the content of the copy at `path`, encrypted to `to` and written
382/// beside it, waiting for its place, with its size; None when no certificate
383/// takes it.
384fn prepare(path: &Path, clear: &[u8], to: &[&Cert]) -> Option<Result<(Prepared, usize), String>> {
385    if to.is_empty() {
386        return None;
387    }
388    Some(pgp::seal_message(clear, to).and_then(|sealed| Prepared::new(path, &sealed).map(|prepared| (prepared, sealed.len())).map_err(|e| format!("{}: {e}", path.display()))))
389}
390
391/// A copy's new bytes beside it, waiting for its place: the file as it was
392/// read, and what it counts as.
393struct Waiting {
394    prepared: Prepared,
395    was: FileStamp,
396    path: PathBuf,
397    version: bool,
398}
399
400/// Copies prepared, placed under the lock in short holds.
401struct Batch<'a> {
402    waiting: VecDeque<Waiting>,
403    bytes: usize,
404    hold: Hold<'a>,
405}
406
407impl Batch<'_> {
408    fn push(&mut self, waiting: Waiting, size: usize, judged: &mut Judged, out: &mut Sealed) {
409        self.bytes += size;
410        self.waiting.push_back(waiting);
411        if self.waiting.len() >= BATCH_FILES || self.bytes >= BATCH_BYTES {
412            self.flush(judged, out);
413        }
414    }
415
416    /// Each copy waiting put in its place under the lock, a short hold at a
417    /// time, when the file is still as it was read (else it is looked at
418    /// again next time: the sharing wrote another version meanwhile, or the
419    /// draft went); its old bytes overwritten once the lock is let go.
420    fn flush(&mut self, judged: &mut Judged, out: &mut Sealed) {
421        self.bytes = 0;
422        while !self.waiting.is_empty() {
423            let Some(held) = (self.hold)() else {
424                out.failed += self.waiting.len();
425                self.waiting.clear();
426                return;
427            };
428            let started = Instant::now();
429            let mut placed = Vec::new();
430            while let Some(waiting) = self.waiting.pop_front() {
431                match waiting.prepared.place(Some(waiting.was)) {
432                    Ok(Some(done)) => placed.push((done, waiting.path, waiting.version)),
433                    Ok(None) => {}
434                    Err(_) => out.failed += 1,
435                }
436                if started.elapsed() >= HOLD_AT_MOST || held.wanted() {
437                    break;
438                }
439            }
440            drop(held);
441            for (done, path, version) in placed {
442                // The sealed copy as it took its place, never what an exchange puts there after the hold (N6).
443                let stamp = done.stamp();
444                done.finish();
445                if version {
446                    out.versions += 1;
447                } else {
448                    out.copies += 1;
449                }
450                if let Some(stamp) = stamp {
451                    judged.mark(&path, stamp);
452                }
453            }
454            if !self.waiting.is_empty() {
455                std::thread::sleep(LET_GO);
456            }
457        }
458    }
459}
460
461#[cfg(test)]
462mod tests {
463    use super::*;
464    use crate::pgp::{SessionKeys, Unlock};
465
466    fn scratch(name: &str) -> PathBuf {
467        let base = std::env::temp_dir().join(format!("sioul-draftcopies-{name}-{}", std::process::id()));
468        let _ = std::fs::remove_dir_all(&base);
469        std::fs::create_dir_all(&base).unwrap();
470        base
471    }
472
473    fn keys_of(key: &[u8]) -> Keys {
474        let cert = Cert::from_bytes(key).unwrap();
475        Keys { own: vec![cert], ..Keys::default() }
476    }
477
478    use sequoia_openpgp::parse::Parse;
479
480    const CLEAR: &str = "From: Jane <jane@example.org>\r\nTo: Noa <noa@example.org>\r\nSubject: The lease\r\nMessage-ID: <lease@example.org>\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=utf-8\r\n\r\nThe secret clause is on page four.\r\n";
481
482    fn draft_with(folder: &Path, id: &str, encrypt: bool) {
483        let draft = Draft { id: id.to_string(), encrypt, ..Draft::default() };
484        std::fs::write(folder.join(format!("{id}.toml")), toml::to_string(&draft).unwrap()).unwrap();
485    }
486
487    fn opens(raw: &[u8], keys: &Keys) -> Vec<u8> {
488        let sessions = SessionKeys::new();
489        let none = |_: &pgp::Fingerprint| None;
490        pgp::open(raw, keys, &Unlock::new(&none, &sessions)).and_then(|(opened, _)| opened).expect("opens with your key")
491    }
492
493    #[test]
494    fn a_copy_of_an_encrypted_message_is_sealed_again_and_a_clear_one_left() {
495        let base = scratch("beside");
496        let (drafts, mail) = (base.join("drafts"), base.join("mail"));
497        std::fs::create_dir_all(&drafts).unwrap();
498        std::fs::create_dir_all(&mail).unwrap();
499        let key = pgp::stand_in_key("Noa <noa@example.org>").unwrap();
500        let keys = keys_of(&key);
501        let cert = Cert::from_bytes(&key).unwrap();
502        // In your mail: the lease came encrypted, the invoice in clear.
503        std::fs::write(mail.join("lease"), pgp::seal_message(CLEAR.as_bytes(), &[&cert]).unwrap()).unwrap();
504        let invoice = CLEAR.replace("lease@example.org", "invoice@example.org").replace("The secret clause is on page four.", "Your invoice.");
505        std::fs::write(mail.join("invoice"), &invoice).unwrap();
506        // The copies as Sioul wrote them until now: the lease's decrypted, the invoice's as it came.
507        std::fs::write(drafts.join("a1.eml"), CLEAR).unwrap();
508        std::fs::write(drafts.join("a2.eml"), &invoice).unwrap();
509        // A copy whose message is in no mail here, of a draft you do not encrypt; and of one you do.
510        let gone = CLEAR.replace("lease@example.org", "gone@example.org");
511        std::fs::write(drafts.join("a3.eml"), &gone).unwrap();
512        std::fs::write(drafts.join("a4.eml"), gone.replace("gone@", "gone2@")).unwrap();
513        for (id, encrypt) in [("a1", false), ("a2", false), ("a3", false), ("a4", true)] {
514            draft_with(&drafts, id, encrypt);
515        }
516        let mut find = |id: &str| match id {
517            "lease@example.org" => vec![mail.join("lease")],
518            "invoice@example.org" => vec![mail.join("invoice")],
519            _ => Vec::new(),
520        };
521        let mut judged = Judged::default();
522        let sealed = seal_beside(&drafts, &Certs::of(keys.clone()), &mut find, &mut judged, &unshared);
523        // a1 (its message came encrypted), a3 and a4 (their message found nowhere: in doubt) sealed; a2 left.
524        assert_eq!(sealed, Sealed { copies: 3, ..Sealed::default() });
525        // Sealed: no clear text left in the file, and it opens with your key to what it said.
526        let a1 = std::fs::read(drafts.join("a1.eml")).unwrap();
527        assert!(pgp::is_encrypted(&a1));
528        assert!(!String::from_utf8_lossy(&a1).contains("secret clause"));
529        assert!(String::from_utf8_lossy(&opens(&a1, &keys)).contains("The secret clause is on page four."));
530        assert!(pgp::is_encrypted(&std::fs::read(drafts.join("a4.eml")).unwrap()));
531        // Left: the clear message's copy, byte for byte that message.
532        assert_eq!(std::fs::read_to_string(drafts.join("a2.eml")).unwrap(), invoice);
533        assert!(pgp::is_encrypted(&std::fs::read(drafts.join("a3.eml")).unwrap()), "found nowhere: sealed, in doubt");
534        // The drafts themselves untouched; nothing else left in the folder.
535        for id in ["a1", "a2", "a3", "a4"] {
536            assert!(Draft::load(&drafts.join(format!("{id}.toml"))).is_some());
537        }
538        let names: Vec<String> = std::fs::read_dir(&drafts).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().to_string()).collect();
539        assert_eq!(names.len(), 8, "{names:?}");
540        // Looked at again: nothing more to do, nothing read again.
541        let again = seal_beside(&drafts, &Certs::of(keys.clone()), &mut |_| panic!("not asked again"), &mut judged, &unshared);
542        assert_eq!(again, Sealed::default());
543        // A new process: what was sealed stays sealed, once.
544        let fresh = seal_beside(&drafts, &Certs::of(keys.clone()), &mut find, &mut Judged::default(), &unshared);
545        assert_eq!(fresh, Sealed::default());
546        let _ = std::fs::remove_dir_all(&base);
547    }
548
549    /// Review of 19 October 2026, P6 (F6): the message came encrypted
550    /// (archived), and a clear message with the same Message-ID waits in the
551    /// inbox (anyone who knows the ID can send one); the copy beside the
552    /// answer, decrypted as Sioul wrote it before, its draft not encrypted:
553    /// sealed, never left on the other's word. A copy without a Message-ID:
554    /// sealed too.
555    #[test]
556    fn a_forged_duplicate_or_no_message_id_leaves_no_copy_in_clear() {
557        let base = scratch("forged");
558        let (drafts, mail) = (base.join("drafts"), base.join("mail"));
559        std::fs::create_dir_all(&drafts).unwrap();
560        std::fs::create_dir_all(&mail).unwrap();
561        let key = pgp::stand_in_key("Noa <noa@example.org>").unwrap();
562        let keys = keys_of(&key);
563        std::fs::write(mail.join("archive"), pgp::seal_message(CLEAR.as_bytes(), &keys.own_certs()).unwrap()).unwrap();
564        std::fs::write(mail.join("inbox"), CLEAR.replace("The secret clause is on page four.", "Nothing to see.")).unwrap();
565        std::fs::write(drafts.join("a1.eml"), CLEAR).unwrap();
566        std::fs::write(drafts.join("a2.eml"), CLEAR.replace("Message-ID: <lease@example.org>\r\n", "")).unwrap();
567        for id in ["a1", "a2"] {
568            draft_with(&drafts, id, false);
569        }
570        // The inbox first, as the index lists it.
571        let mut find = |id: &str| if id == "lease@example.org" { vec![mail.join("inbox"), mail.join("archive")] } else { Vec::new() };
572        assert_eq!(seal_beside(&drafts, &Certs::of(keys.clone()), &mut find, &mut Judged::default(), &unshared), Sealed { copies: 2, ..Sealed::default() });
573        for id in ["a1", "a2"] {
574            assert!(pgp::is_encrypted(&std::fs::read(drafts.join(format!("{id}.eml"))).unwrap()), "{id}");
575        }
576        let _ = std::fs::remove_dir_all(&base);
577    }
578
579    /// Review of 19 October 2026, P7 (F7): an encrypted message whose text
580    /// quotes an older armored block; its copy, as decrypted before, beside an
581    /// answer: never taken for sealed. A message whose whole text is one
582    /// armored block is encrypted as it came.
583    #[test]
584    fn a_copy_quoting_an_armored_block_is_still_sealed() {
585        let base = scratch("armored");
586        let (drafts, mail) = (base.join("drafts"), base.join("mail"));
587        std::fs::create_dir_all(&drafts).unwrap();
588        std::fs::create_dir_all(&mail).unwrap();
589        let keys = keys_of(&pgp::stand_in_key("Noa <noa@example.org>").unwrap());
590        let quoting = format!("{CLEAR}> -----BEGIN PGP MESSAGE-----\r\n> hQEMA0aY\r\n> -----END PGP MESSAGE-----\r\n");
591        assert!(!pgp::is_encrypted(quoting.as_bytes()));
592        std::fs::write(mail.join("lease"), pgp::seal_message(quoting.as_bytes(), &keys.own_certs()).unwrap()).unwrap();
593        std::fs::write(drafts.join("a1.eml"), &quoting).unwrap();
594        draft_with(&drafts, "a1", false);
595        let mut find = |_: &str| vec![mail.join("lease")];
596        assert_eq!(seal_beside(&drafts, &Certs::of(keys.clone()), &mut find, &mut Judged::default(), &unshared).copies, 1);
597        let inline = "From: Jane <jane@example.org>\r\nSubject: The lease\r\n\r\n-----BEGIN PGP MESSAGE-----\r\n\r\nhQEMA0aY\r\n-----END PGP MESSAGE-----\r\n";
598        assert!(pgp::is_encrypted(inline.as_bytes()));
599        let _ = std::fs::remove_dir_all(&base);
600    }
601
602    /// A copy kept as it came (its draft says so, from 19 October 2026) is
603    /// never judged, even when its message is found nowhere here (a phone),
604    /// whatever its shape. One written before is judged by where it comes
605    /// from, never by its shape (review of 19 October 2026, N7): a clear
606    /// message's copy is left where that message is found here byte for
607    /// byte (by its Message-ID, or the one its draft answers), sealed where
608    /// it is found nowhere, whatever mailer wrote it.
609    #[test]
610    fn copies_kept_as_they_came_are_left_and_the_others_judged_by_their_message() {
611        let base = scratch("as-came");
612        let mail = scratch("as-came-mail");
613        let keys = keys_of(&pgp::stand_in_key("Noa <noa@example.org>").unwrap());
614        std::fs::write(base.join("a1.eml"), CLEAR).unwrap();
615        let draft = Draft { id: "a1".into(), copy_as_received: true, ..Draft::default() };
616        std::fs::write(base.join("a1.toml"), toml::to_string(&draft).unwrap()).unwrap();
617        let apple = "Content-Type: text/plain; charset=us-ascii\r\nMime-Version: 1.0 (Mac OS X Mail 16.0)\r\nFrom: Jane <jane@example.org>\r\nSubject: The lease\r\nMessage-ID: <a@example.org>\r\n\r\nThe clause.\r\n";
618        let outlook = "From: Jane <jane@example.org>\r\nSubject: The lease\r\nContent-Type: text/plain; charset=utf-8\r\nMIME-Version: 1.0\r\nMessage-ID: <o@example.org>\r\n\r\nThe clause.\r\n";
619        let plain = "From: Jane <jane@example.org>\r\nSubject: The lease\r\n\r\nThe clause, no Message-ID.\r\n";
620        for (id, text) in [("a2", apple), ("a3", outlook), ("a4", plain)] {
621            std::fs::write(base.join(format!("{id}.eml")), text).unwrap();
622            std::fs::write(mail.join(id), text).unwrap();
623        }
624        draft_with(&base, "a2", false);
625        draft_with(&base, "a3", false);
626        // The plain one names no Message-ID: its draft says what it answers.
627        let answering = Draft { id: "a4".into(), in_reply_to: Some("<p@example.org>".into()), ..Draft::default() };
628        std::fs::write(base.join("a4.toml"), toml::to_string(&answering).unwrap()).unwrap();
629        let mut find = |id: &str| match id {
630            "a@example.org" => vec![mail.join("a2")],
631            "o@example.org" => vec![mail.join("a3")],
632            "p@example.org" => vec![mail.join("a4")],
633            _ => Vec::new(),
634        };
635        let left = seal_beside(&base, &Certs::lazy(|| -> Keys { panic!("nothing to seal") }), &mut find, &mut Judged::default(), &unshared);
636        assert_eq!(left, Sealed::default(), "kept as it came, or byte for byte a clear message found here");
637        // On a device where those messages are not (a phone): sealed, the kept one aside.
638        let sealed = seal_beside(&base, &Certs::of(keys.clone()), &mut |_| Vec::new(), &mut Judged::default(), &unshared);
639        assert_eq!(sealed, Sealed { copies: 3, ..Sealed::default() });
640        assert_eq!(std::fs::read_to_string(base.join("a1.eml")).unwrap(), CLEAR, "kept as it came: untouched");
641        let _ = std::fs::remove_dir_all(&base);
642        let _ = std::fs::remove_dir_all(&mail);
643    }
644
645    /// Review of 19 October 2026, N7 (probe `probe2_shapes_of_copies`): the
646    /// copy of an encrypted message whose encrypted entity had an empty
647    /// head, or none, as Sioul wrote it before: no `Content-` line follows
648    /// the `MIME-Version: 1.0` it added, and the shape looked like a message
649    /// that came as it is. Sealed, its message found encrypted here or
650    /// found nowhere.
651    #[test]
652    fn a_decrypted_copy_without_headers_of_its_own_is_sealed() {
653        let base = scratch("headless");
654        let mail = scratch("headless-mail");
655        let key = pgp::stand_in_key("Noa <noa@example.org>").unwrap();
656        let keys = keys_of(&key);
657        let cert = Cert::from_bytes(&key).unwrap();
658        let none = |_: &pgp::Fingerprint| None;
659        let sessions = SessionKeys::new();
660        let unlock = Unlock::new(&none, &sessions);
661        for (n, entity) in ["\r\nSECRET-2 the clause\r\n", "SECRET-3 the clause\r\nline two\r\n"].iter().enumerate() {
662            let armored = pgp::encrypt(entity.as_bytes(), &[&cert], None).unwrap();
663            let received = format!(
664                "From: Jane <jane@example.org>\nTo: Noa <noa@example.org>\nSubject: ...\nMessage-ID: <s{n}@example.org>\nMIME-Version: 1.0\nContent-Type: multipart/encrypted; protocol=\"application/pgp-encrypted\"; boundary=\"o\"\n\n--o\nContent-Type: application/pgp-encrypted\n\nVersion: 1\n\n--o\nContent-Type: application/octet-stream\n\n{armored}\n--o--\n"
665            );
666            std::fs::write(mail.join(format!("m{n}")), &received).unwrap();
667            let (opened, _) = pgp::open(received.as_bytes(), &keys, &unlock).unwrap();
668            let copy = opened.unwrap();
669            assert!(!pgp::looks_opened(&copy), "its shape says nothing");
670            std::fs::write(base.join(format!("b{n}.eml")), &copy).unwrap();
671            draft_with(&base, &format!("b{n}"), false);
672        }
673        let mut find = |id: &str| if id == "s0@example.org" { vec![mail.join("m0")] } else { Vec::new() };
674        let done = seal_beside(&base, &Certs::of(keys.clone()), &mut find, &mut Judged::default(), &unshared);
675        assert_eq!(done, Sealed { copies: 2, ..Sealed::default() });
676        for n in 0..2 {
677            let copy = std::fs::read(base.join(format!("b{n}.eml"))).unwrap();
678            assert!(pgp::is_encrypted(&copy) && !String::from_utf8_lossy(&copy).contains("SECRET-"));
679        }
680        let _ = std::fs::remove_dir_all(&base);
681        let _ = std::fs::remove_dir_all(&mail);
682    }
683
684    /// Review of 19 October 2026, N6 (probe `probe2_race_a_…`): a clear
685    /// copy, byte for byte its clear message; while the look reads the mail,
686    /// an exchange puts another device's decrypted version in its place. The
687    /// verdict is kept for the file read, not for the one there now: the
688    /// next look reads the new one and seals it, in this process and in a
689    /// later one.
690    #[test]
691    fn a_copy_replaced_while_the_look_reads_the_mail_is_looked_at_again() {
692        let base = scratch("race-a");
693        let mail = scratch("race-a-mail");
694        let keys = keys_of(&pgp::stand_in_key("Noa <noa@example.org>").unwrap());
695        let thunderbird = "Message-ID: <lease@example.org>\r\nMIME-Version: 1.0\r\nSubject: The lease\r\nFrom: Jane <jane@example.org>\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\nNothing secret here.\r\n";
696        std::fs::write(mail.join("m1"), thunderbird).unwrap();
697        let path = base.join("a1.eml");
698        std::fs::write(&path, thunderbird).unwrap();
699        draft_with(&base, "a1", false);
700        let incoming = CLEAR.to_string();
701        let mut find = |id: &str| {
702            let beside = base.join(".incoming");
703            std::fs::write(&beside, &incoming).unwrap();
704            std::fs::rename(&beside, &path).unwrap();
705            if id == "lease@example.org" { vec![mail.join("m1")] } else { Vec::new() }
706        };
707        let verdicts = base.join("state").join("draftcopies.json");
708        let mut judged = Judged::load(&verdicts);
709        assert_eq!(seal_beside(&base, &Certs::of(keys.clone()), &mut find, &mut judged, &unshared), Sealed::default(), "the copy read was its clear message");
710        judged.save(&verdicts).unwrap();
711        let mut later = Judged::load(&verdicts);
712        assert_eq!(seal_beside(&base, &Certs::of(keys.clone()), &mut |_| Vec::new(), &mut later, &unshared).copies, 1, "the version put in its place meanwhile, looked at");
713        assert!(pgp::is_encrypted(&std::fs::read(&path).unwrap()));
714        let _ = std::fs::remove_dir_all(&base);
715        let _ = std::fs::remove_dir_all(&mail);
716    }
717
718    /// Review of 19 October 2026, N6 (probe `probe2_race_b_…`): a decrypted
719    /// copy sealed; as soon as the lock is let go, an exchange waiting puts
720    /// another device's decrypted version in its place. The verdict is kept
721    /// for the sealed file that took the place, not for the one there now:
722    /// the next look seals the new one.
723    #[test]
724    fn a_copy_replaced_as_soon_as_the_look_lets_go_is_looked_at_again() {
725        struct Swap {
726            path: PathBuf,
727            bytes: Vec<u8>,
728            armed: std::rc::Rc<std::cell::Cell<bool>>,
729        }
730        impl Held for Swap {}
731        impl Drop for Swap {
732            fn drop(&mut self) {
733                if self.armed.replace(false) {
734                    let beside = self.path.with_extension("incoming");
735                    std::fs::write(&beside, &self.bytes).unwrap();
736                    std::fs::rename(&beside, &self.path).unwrap();
737                }
738            }
739        }
740        let base = scratch("race-b");
741        let keys = keys_of(&pgp::stand_in_key("Noa <noa@example.org>").unwrap());
742        let path = base.join("a1.eml");
743        std::fs::write(&path, CLEAR).unwrap();
744        draft_with(&base, "a1", true);
745        let second = CLEAR.replace("page four", "page five");
746        let armed = std::rc::Rc::new(std::cell::Cell::new(true));
747        let hold = || -> Option<Box<dyn Held>> { Some(Box::new(Swap { path: path.clone(), bytes: second.clone().into_bytes(), armed: armed.clone() })) };
748        let mut judged = Judged::default();
749        assert_eq!(seal_beside(&base, &Certs::of(keys.clone()), &mut |_| Vec::new(), &mut judged, &hold).copies, 1);
750        assert!(String::from_utf8_lossy(&std::fs::read(&path).unwrap()).contains("page five"), "the other version took its place");
751        assert_eq!(seal_beside(&base, &Certs::of(keys.clone()), &mut |_| Vec::new(), &mut judged, &unshared).copies, 1, "and is looked at");
752        assert!(pgp::is_encrypted(&std::fs::read(&path).unwrap()));
753        let _ = std::fs::remove_dir_all(&base);
754    }
755
756    #[test]
757    fn without_a_certificate_of_yours_nothing_is_written() {
758        let base = scratch("unkeyed");
759        std::fs::write(base.join("a1.eml"), CLEAR).unwrap();
760        draft_with(&base, "a1", true);
761        let mut judged = Judged::default();
762        let none = Keys::default();
763        assert_eq!(seal_beside(&base, &Certs::of(none.clone()), &mut |_| Vec::new(), &mut judged, &unshared), Sealed { waiting: 1, ..Sealed::default() });
764        assert_eq!(std::fs::read_to_string(base.join("a1.eml")).unwrap(), CLEAR);
765        // Looked at again once a key takes it.
766        let keys = keys_of(&pgp::stand_in_key("Noa <noa@example.org>").unwrap());
767        assert_eq!(seal_beside(&base, &Certs::of(keys.clone()), &mut |_| Vec::new(), &mut judged, &unshared).copies, 1);
768        let _ = std::fs::remove_dir_all(&base);
769    }
770
771    /// The lock not given (the sharing's state not writable): nothing takes
772    /// its place, the copy stays as it was, nothing is left beside it, and it
773    /// is looked at again next time.
774    /// The builder's own known gap (review of 19 October 2026, F17): a draft
775    /// sent while its copy took its place left an encrypted copy without its
776    /// draft, which travelled to every device and stayed there. Taken out
777    /// once a day old, under the sharing's lock alone; a fresh one (its draft
778    /// may still come) and one whose draft is there stay.
779    #[test]
780    fn an_encrypted_copy_whose_draft_went_is_taken_out_a_day_later() {
781        let base = scratch("orphans");
782        let key = pgp::stand_in_key("Noa <noa@example.org>").unwrap();
783        let keys = keys_of(&key);
784        let sealed = pgp::seal_message(CLEAR.as_bytes(), &[&Cert::from_bytes(&key).unwrap()]).unwrap();
785        for id in ["gone", "fresh", "kept"] {
786            std::fs::write(base.join(format!("{id}.eml")), &sealed).unwrap();
787        }
788        let day_old = |id: &str| std::fs::File::options().write(true).open(base.join(format!("{id}.eml"))).unwrap().set_modified(std::time::SystemTime::now() - Duration::from_secs(25 * 3600)).unwrap();
789        day_old("gone");
790        day_old("kept");
791        draft_with(&base, "kept", true);
792        let refused = || -> Option<Box<dyn Held>> { None };
793        assert_eq!(seal_beside(&base, &Certs::of(keys.clone()), &mut |_| Vec::new(), &mut Judged::default(), &refused), Sealed::default());
794        assert!(base.join("gone.eml").exists(), "without the lock: left");
795        assert_eq!(seal_beside(&base, &Certs::of(keys.clone()), &mut |_| Vec::new(), &mut Judged::default(), &unshared), Sealed { orphans: 1, ..Sealed::default() });
796        assert!(!base.join("gone.eml").exists(), "a day old, its draft gone: taken out");
797        assert!(base.join("fresh.eml").exists() && base.join("kept.eml").exists() && base.join("kept.toml").exists());
798        let _ = std::fs::remove_dir_all(&base);
799    }
800
801    #[test]
802    fn nothing_takes_its_place_without_the_lock() {
803        let base = scratch("unlocked");
804        std::fs::write(base.join("a1.eml"), CLEAR).unwrap();
805        draft_with(&base, "a1", true);
806        let keys = keys_of(&pgp::stand_in_key("Noa <noa@example.org>").unwrap());
807        let mut judged = Judged::default();
808        let refused = || -> Option<Box<dyn Held>> { None };
809        assert_eq!(seal_beside(&base, &Certs::of(keys.clone()), &mut |_| Vec::new(), &mut judged, &refused), Sealed { failed: 1, ..Sealed::default() });
810        assert_eq!(std::fs::read_to_string(base.join("a1.eml")).unwrap(), CLEAR);
811        assert_eq!(std::fs::read_dir(&base).unwrap().count(), 2, "the copy and its draft alone");
812        // Each placement happens while the lock is held, and the lock is given back between holds.
813        let holding = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
814        struct Guard(std::sync::Arc<std::sync::atomic::AtomicUsize>);
815        impl Held for Guard {}
816        impl Drop for Guard {
817            fn drop(&mut self) {
818                self.0.fetch_sub(1, std::sync::atomic::Ordering::SeqCst);
819            }
820        }
821        let counted = {
822            let holding = holding.clone();
823            move || -> Option<Box<dyn Held>> {
824                assert_eq!(holding.fetch_add(1, std::sync::atomic::Ordering::SeqCst), 0, "one hold at a time");
825                Some(Box::new(Guard(holding.clone())))
826            }
827        };
828        assert_eq!(seal_beside(&base, &Certs::of(keys.clone()), &mut |_| Vec::new(), &mut judged, &counted).copies, 1);
829        assert_eq!(holding.load(std::sync::atomic::Ordering::SeqCst), 0, "given back");
830        assert!(pgp::is_encrypted(&std::fs::read(base.join("a1.eml")).unwrap()));
831        let _ = std::fs::remove_dir_all(&base);
832    }
833
834    /// A copy the sharing wrote again between its reading and its place (or
835    /// that went with its draft): left as it is now, nothing beside it.
836    #[test]
837    fn a_copy_changed_since_it_was_read_keeps_its_new_version() {
838        let base = scratch("changed");
839        let path = base.join("a1.eml");
840        std::fs::write(&path, CLEAR).unwrap();
841        let (_, meta) = crate::filelock::read_whole_with(&path).unwrap();
842        let prepared = Prepared::new(&path, b"sealed").unwrap();
843        // Another version comes meanwhile, as the sharing writes it (beside, then renamed).
844        std::fs::write(base.join(".a1.eml.incoming"), "another version").unwrap();
845        std::fs::rename(base.join(".a1.eml.incoming"), &path).unwrap();
846        assert!(prepared.place(Some(FileStamp::of(&meta))).unwrap().is_none());
847        assert_eq!(std::fs::read_to_string(&path).unwrap(), "another version");
848        assert_eq!(std::fs::read_dir(&base).unwrap().count(), 1, "nothing left beside it");
849        // Gone with its draft: never made again.
850        let (_, meta) = crate::filelock::read_whole_with(&path).unwrap();
851        let prepared = Prepared::new(&path, b"sealed").unwrap();
852        std::fs::remove_file(&path).unwrap();
853        assert!(prepared.place(Some(FileStamp::of(&meta))).unwrap().is_none());
854        assert!(!path.exists());
855        assert_eq!(std::fs::read_dir(&base).unwrap().count(), 0);
856        let _ = std::fs::remove_dir_all(&base);
857    }
858
859    /// A later process (a phone's background step, started for a moment)
860    /// reads none of the copies met before and unchanged, and reads no key:
861    /// the verdicts are kept on the disk. Review of 19 October 2026, F9: each
862    /// fresh process read and parsed every copy kept in the history again (21
863    /// to 54 ms for 150 of 100 KB), and read every key (18 ms for 300 others').
864    #[test]
865    fn a_later_process_reads_nothing_met_before_and_no_key() {
866        let base = scratch("verdicts");
867        let keys = keys_of(&pgp::stand_in_key("Noa <noa@example.org>").unwrap());
868        let verdicts = base.join("state").join("draftcopies.json");
869        let large = format!("{CLEAR}{}\r\n", "The lease runs three years. ".repeat(3_700));
870        let versions: Vec<PathBuf> = (0..40)
871            .map(|n| {
872                let path = base.join("history").join(format!("2026-10-18T10-00-{n:02}.000Z"));
873                std::fs::create_dir_all(path.parent().unwrap()).unwrap();
874                std::fs::write(&path, &large).unwrap();
875                path
876            })
877            .collect();
878        let mut judged = Judged::load(&verdicts);
879        assert_eq!(seal_versions(&versions, &Certs::of(keys.clone()), &mut |_| Vec::new(), &mut judged, &unshared).versions, 40);
880        judged.save(&verdicts).unwrap();
881        // A later process: the verdicts read from the disk, no copy read, no key read.
882        READ.with(|n| n.set(0));
883        let mut later = Judged::load(&verdicts);
884        let certs = Certs::lazy(|| -> Keys { panic!("no key read for a look with nothing to seal") });
885        assert_eq!(seal_versions(&versions, &certs, &mut |_| Vec::new(), &mut later, &unshared), Sealed::default());
886        assert_eq!(READ.with(|n| n.get()), 0, "nothing read again");
887        assert!(!certs.read());
888        // A copy that changed is read again, that one alone, and sealed.
889        std::fs::write(&versions[7], CLEAR).unwrap();
890        READ.with(|n| n.set(0));
891        assert_eq!(seal_versions(&versions, &Certs::of(keys.clone()), &mut |_| Vec::new(), &mut later, &unshared).versions, 1);
892        assert_eq!(READ.with(|n| n.get()), 1);
893        // Saved, a file no longer met is forgotten; a verdicts' file that does not read is none.
894        std::fs::remove_file(&versions[3]).unwrap();
895        later.save(&verdicts).unwrap();
896        let mut after = Judged::load(&verdicts);
897        assert_eq!(seal_versions(&versions, &Certs::of(keys.clone()), &mut |_| Vec::new(), &mut after, &unshared), Sealed::default());
898        after.save(&verdicts).unwrap();
899        let kept: JudgedFile = serde_json::from_slice(&std::fs::read(&verdicts).unwrap()).unwrap();
900        assert_eq!(kept.files.len(), 39);
901        std::fs::write(&verdicts, "not json").unwrap();
902        assert!(Judged::load(&verdicts).files.is_empty());
903        let _ = std::fs::remove_dir_all(&base);
904    }
905
906    #[test]
907    fn earlier_versions_are_sealed_too() {
908        let base = scratch("versions");
909        let keys = keys_of(&pgp::stand_in_key("Noa <noa@example.org>").unwrap());
910        let (clear, sealed) = (base.join("2026-10-18T10-00-00.000Z"), base.join("2026-10-19T10-00-00.000Z"));
911        std::fs::write(&clear, CLEAR).unwrap();
912        std::fs::write(&sealed, pgp::seal_message(CLEAR.as_bytes(), &keys.own_certs()).unwrap()).unwrap();
913        let before = std::fs::read(&sealed).unwrap();
914        let done = seal_versions(&[clear.clone(), sealed.clone()], &Certs::of(keys.clone()), &mut |_| Vec::new(), &mut Judged::default(), &unshared);
915        assert_eq!(done, Sealed { versions: 1, ..Sealed::default() });
916        assert!(!String::from_utf8_lossy(&std::fs::read(&clear).unwrap()).contains("secret clause"));
917        assert_eq!(std::fs::read(&sealed).unwrap(), before, "one sealed already is left as it is");
918        let _ = std::fs::remove_dir_all(&base);
919    }
920
921    // The old file's bytes overwritten once it has no name: read through a
922    // descriptor opened before, as anyone holding the old blocks would.
923    #[cfg(unix)]
924    #[test]
925    fn the_old_bytes_are_overwritten_and_the_path_never_missing() {
926        use std::io::{Read, Seek};
927        let base = scratch("wipe");
928        let path = base.join("a1.eml");
929        std::fs::write(&path, CLEAR).unwrap();
930        let mut before = std::fs::File::open(&path).unwrap();
931        crate::filelock::replace_wiping(&path, b"sealed").unwrap();
932        assert_eq!(std::fs::read(&path).unwrap(), b"sealed");
933        let mut old = Vec::new();
934        before.seek(std::io::SeekFrom::Start(0)).unwrap();
935        before.read_to_end(&mut old).unwrap();
936        assert_eq!(old.len(), CLEAR.len());
937        assert!(old.iter().all(|&b| b == 0), "the old file still says {:?}", String::from_utf8_lossy(&old));
938        // Yours alone, and no hidden file left beside it.
939        assert_eq!(std::os::unix::fs::PermissionsExt::mode(&std::fs::metadata(&path).unwrap().permissions()) & 0o777, 0o600);
940        assert_eq!(std::fs::read_dir(&base).unwrap().count(), 1);
941        // Another name of the same file (a hard link) keeps it whole.
942        let other = base.join("other");
943        std::fs::hard_link(&path, &other).unwrap();
944        crate::filelock::replace_wiping(&path, b"sealed again").unwrap();
945        assert_eq!(std::fs::read(&other).unwrap(), b"sealed");
946        // Meanwhile, through 1,000 replacements, a reader reading as Sioul's
947        // readers do (`read_whole`) finds the old file or the new one, never
948        // the zeros written over the old one once it has no name: a plain
949        // read may (main's final pass, 19 October 2026, read twelve zeros).
950        let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
951        let reader = {
952            let (path, stop) = (path.clone(), stop.clone());
953            std::thread::spawn(move || {
954                let (mut read, mut again) = (0usize, 0usize);
955                while !stop.load(std::sync::atomic::Ordering::Relaxed) {
956                    match crate::filelock::read_whole(&path) {
957                        Ok(bytes) => {
958                            assert!(bytes == b"sealed again" || bytes == b"third", "{bytes:?}");
959                            read += 1;
960                        }
961                        // Replaced each of its eight times: said, never returned.
962                        Err(e) if e.kind() == std::io::ErrorKind::Interrupted => again += 1,
963                        Err(e) => panic!("never missing: {e}"),
964                    }
965                }
966                (read, again)
967            })
968        };
969        for _ in 0..500 {
970            crate::filelock::replace_wiping(&path, b"third").unwrap();
971            crate::filelock::replace_wiping(&path, b"sealed again").unwrap();
972        }
973        stop.store(true, std::sync::atomic::Ordering::Relaxed);
974        let (read, again) = reader.join().unwrap();
975        assert!(read > 0 && again <= read / 10 + 1, "{read} reads, {again} given up");
976        let _ = std::fs::remove_dir_all(&base);
977    }
978}