Skip to main content

agent_core/core/
context_archive.rs

1//! Durable, disclosure-filtered context segments; never a summary or a session log.
2//!
3//! The trusted host supplies an absolute base directory, scope key and logical
4//! conversation id (never model/tool path arguments). Scope and logical id are
5//! hashed; project scope determines the directory name. Only user/assistant text
6//! and tool-call structure
7//! are retained. System/developer messages, reasoning, unknown blocks, binary,
8//! non-normal sensitivity and restricted/unknown disclosure are omitted.
9//! Arguments/results are WITHHELD unless a trusted redactor is installed. All
10//! retained strings pass the disclosure gate and a conservative credential
11//! screen. This screen is not a universal secret detector: host classification
12//! and the optional redactor remain the authority for unlabelled secrets.
13//!
14//! Each segment contains its hidden, screened note and source indices in ONE
15//! atomic file. Notes are not searched or returned by message fetch. Eligible
16//! text is otherwise exact: no truncation, whitespace rewriting or summarizing
17//! (one marked exception: a reduced message, below).
18//! `fetch_with_provenance` reports indices into the original `seal` slice.
19//!
20//! A window over the limits of one segment is refused by `seal`, and sealed
21//! by [`ArchiveStore::seal_window`] as consecutive parts: each is filled from
22//! where the one before ended until the next message would not fit, each is
23//! an ordinary segment, and the returned part list is what puts the window
24//! back together. Messages added to the end of a window change its last
25//! part only. Only a single message that is
26//! over a limit by itself is stored reduced, by as little as makes it fit.
27//! First nothing is left out: a tool input that is nested too deep or has
28//! too many nodes is stored as one string, its JSON text
29//! ([`INPUT_JSON_KEY`]). Then what is too large as text too goes: the tool
30//! inputs that are too large, then every tool input, then long strings and
31//! block lists are cut short. Each omission is marked in place with its
32//! size, and a reduced message carries `_archive_reduced` ([`REDUCED_KEY`]),
33//! which no source message can supply, so it is never read back as an exact
34//! one.
35//!
36//! The store keeps every segment: there is no cap on how many a project holds
37//! or on their total size, and no seal deletes, moves or replaces another
38//! segment. A project directory holds numbered shelf directories (`s000001`,
39//! `s000002`, ...) of at most 128 segments each; a full shelf is followed by a
40//! new one and shelves are never merged, renumbered or removed. A segment's
41//! file name is its id (one half of its digest: the first 32 hex characters,
42//! or the last 32 when something else has that name), so finding
43//! one is one `openat` per shelf and one file read, never a scan. Directories
44//! written before shelves existed keep their numbered slot files (`000.json`
45//! to `127.json`); those are read and tombstoned in place and never added to.
46//!
47//! A seal is not refused because something else in the directory is odd. A
48//! shelf name that is not a directory, a shelf that cannot be opened or made
49//! private and a legacy slot that cannot be read are passed over, never
50//! followed and never written to; the segment goes to the next shelf number
51//! if the newest is one of them. That is for a shelf or a slot that is itself
52//! the trouble, an I/O error reading it included, and it has a price: what
53//! it holds is not seen, so a window in it is stored again and a tombstone in
54//! it no longer refuses. An error that will pass (no descriptor or memory
55//! left, an interrupted call: EMFILE, ENFILE, ENOMEM, EINTR, EAGAIN) is not
56//! about the shelf, and the seal fails on it instead and is retried. If the file under a segment's id is not that
57//! segment (corrupt, a link, another projection) it is left as it is and the
58//! segment is stored under the other half of its digest instead. A seal
59//! looks under both halves before it writes anything, so a later seal of the
60//! same projection finds it under either, whatever has become of the other
61//! name. Only what a file is sends a segment to its other name: a read that
62//! fails for another reason (an I/O error, no descriptor left) fails the
63//! seal, which is retried, rather than store a second copy. The one thing a seal still
64//! honours in a file it cannot validate is a tombstone: JSON that names the
65//! projection and says it was forgotten refuses that seal. A fetch or a
66//! forget is stricter: when it does not find its segment and had to pass
67//! over a shelf or a legacy slot, the error says so instead of "not found".
68//! Neither is failed by a damaged neighbour: a segment that can be read is
69//! found whatever the shelf or slot next to it is, and a search passes over
70//! a legacy slot it cannot read as it passes over a corrupt shelf file.
71//!
72//! One segment is limited to 4096 messages, 16 MiB of eligible
73//! strings/structure, 65536 visited nodes and depth 32 on input (excluded
74//! payload descendants are opaque), an 8 KiB note and a 16 MiB file. Fetch
75//! returns <=128 messages and <=16 MiB; search queries <=512 bytes, results
76//! <=32 with <=256-byte snippets. Search reads newest first, one file in
77//! memory at a time, and stops once it has read 1024 files or 256 MiB
78//! ([`ArchiveStore::search_scan`] says when that left older segments unread).
79//! Budget exhaustion and corruption fail closed, never silently truncate seal.
80//!
81//! Seal and forget hold an advisory lock on the project directory. Fetch and
82//! search read shelf files without it: a file is published by renaming a
83//! complete temporary file onto its name, a live record is never modified, and
84//! forget renames a complete tombstone over it, so a reader sees a whole live
85//! record or a whole tombstone and nothing else. Legacy slot files are read
86//! under the lock, shared, if the directory has a lock file.
87//!
88//! The lock file (`lock` in the project directory) is only ever locked: the
89//! store never writes to it, truncates it or reads it. So it is usable as
90//! long as it is a regular file of this user, whatever its link count (a
91//! hard-link backup gives it a second name) and whatever its mode (read-only
92//! after a restore: it is then opened for reading, and a lock on that
93//! descriptor excludes as any other does). A link (never followed), anything
94//! that is not a regular file, a file of another user and a file its mode
95//! does not let its owner open are refused, with an error that names the file.
96//!
97//! A read writes nothing. [`ArchiveStore::new`] is for a seal or a forget: it
98//! creates the directories, makes them private and syncs them.
99//! [`ArchiveStore::open_existing`] is for a fetch or a search: it opens what
100//! is there, and a project with no directory has nothing to read. Whichever
101//! opened the store, a fetch, a note read and a search create no file (not
102//! the lock file either), change no mode and sync nothing.
103//!
104//! Unix-only private, handle-relative no-symlink filesystem below the base
105//! (the base itself may be reached through links: it is resolved once, when
106//! a store is opened, and everything below it is opened by name from that
107//! handle, never following a link); other platforms
108//! fail Unsupported. Live records are immutable; forget atomically replaces a
109//! record with a content-free id/digest/scope tombstone, and is the only
110//! operation that replaces a file. The digest covers ONLY
111//! the eligible projection and source indices, not the note or forbidden data.
112//! Nor does it cover `created_ms`, the time a live record was first written:
113//! a seal writes the time of the write, a record from before the field has
114//! none, and a tombstone never has one.
115//! Identical projections in one logical conversation deduplicate permanently;
116//! a changed note does not create a new segment. Retrying a tombstoned projection
117//! returns PermissionDenied. A successful seal fsyncs data and directory before
118//! returning; callers MUST keep source history on any error (even if publication
119//! happened before a directory-fsync error). Advisory locks serialize cooperating
120//! processes, not malicious processes running as the same OS user.
121//!
122//! What a seal promises does not rest on that lock (a lock file can be deleted
123//! while it is held, and some filesystems do not lock at all). Every write
124//! goes to a temporary file that no other write names, and a seal's rename
125//! cannot replace (`renameat2(RENAME_NOREPLACE)`, `renameatx_np(RENAME_EXCL)`):
126//! two seals at once each end with their own whole file under their own id,
127//! or with an error. Only a kernel or filesystem without such a rename falls
128//! back to looking and then renaming, which the lock alone makes one step.
129//! Temporary files a crash left behind are removed by a later seal into the
130//! same shelf, once they are older than any write still running could be.
131//!
132//! Within one process, seals and forgets on the same archive directory wait for
133//! each other (not in FIFO order) and are admitted within [`LOCK_WAIT`] or fail
134//! as busy. A lock held by another process is polled for up to
135//! [`PROCESS_LOCK_WAIT`]; after that the operation fails closed ("archive
136//! busy") and the caller keeps its source. The lock is released explicitly, so
137//! a descriptor inherited by a concurrently forked child cannot keep it held.
138//! An operation can be abandoned until it holds both locks
139//! ([`ArchiveStore::with_abort`]); after that it always completes.
140
141use crate::core::disclosure::{gate_for_model, may_persist, DisclosureClass, ModelVisibility};
142use crate::core::stream_types::SharedMessage;
143use serde::{Deserialize, Serialize};
144use serde_json::{json, Value};
145use sha2::{Digest, Sha256};
146use std::cell::Cell;
147use std::collections::HashMap;
148use std::io::{self, Write};
149#[cfg(unix)]
150use std::io::Read;
151use std::path::{Path, PathBuf};
152use std::sync::atomic::{AtomicBool, Ordering};
153use std::sync::{Arc, Condvar, Mutex, OnceLock, PoisonError, Weak};
154use std::time::{Duration, Instant};
155
156/// Segments per shelf directory. Not a store limit: a full shelf is followed
157/// by a new one.
158pub const SHELF_FILES: usize = 128;
159/// The highest shelf number. Shelves are numbered in the order they are made,
160/// so the store gets here after 128 million segments; a directory named with
161/// a higher number was not made by the store and is not a shelf.
162const LAST_SHELF: u64 = 999_999;
163/// The number of legacy slots.
164pub const MAX_SEGMENTS: usize = SHELF_FILES;
165pub const MAX_SEGMENT_BYTES: usize = 16 * 1024 * 1024;
166/// A search stops once it has read this many segment files...
167pub const SEARCH_SCAN_FILES: usize = 1024;
168/// ...or this many bytes, whichever comes first.
169pub const SEARCH_SCAN_BYTES: usize = 256 * 1024 * 1024;
170pub const MAX_INPUT_BYTES: usize = 16 * 1024 * 1024;
171pub const MAX_MESSAGES: usize = 4096;
172pub const MAX_NOTE_BYTES: usize = 8192;
173pub const MAX_FETCH_MESSAGES: usize = 128;
174pub const MAX_SEARCH_RESULTS: usize = 32;
175/// Key of a stored message that says it is reduced: [`ArchiveStore::seal_window`]
176/// left something out of it, and marked each omission in place. Only the
177/// store sets it; a message without it is exactly what the projection gives.
178pub const REDUCED_KEY: &str = "_archive_reduced";
179/// Key of a tool input that is stored as one string: the input's JSON text,
180/// screened like any string. [`ArchiveStore::seal_window`] stores an input
181/// this way when it is nested too deep or has too many nodes for a segment,
182/// instead of leaving it out. Content can imitate the key; only in a message
183/// that carries [`REDUCED_KEY`] is it the store's.
184pub const INPUT_JSON_KEY: &str = "_archive_json";
185/// The most a reduced message keeps of one string that is cut short...
186const REDUCED_TEXT_BYTES: usize = 4096;
187/// ...and of its content blocks.
188const REDUCED_BLOCKS: usize = 256;
189/// Longest an operation waits for earlier operations on the same archive in
190/// this process before failing closed as busy.
191pub const LOCK_WAIT: Duration = Duration::from_secs(30);
192/// How often a queued operation with an abort flag rechecks it.
193const ABORT_POLL: Duration = Duration::from_millis(20);
194/// Longest an operation that needs the archive lock waits for another process
195/// to release it before failing closed as busy.
196pub const PROCESS_LOCK_WAIT: Duration = Duration::from_secs(5);
197/// How often that wait tries the lock again.
198const PROCESS_LOCK_POLL: Duration = Duration::from_millis(25);
199/// How long a temporary file is left to the write that made it. A write keeps
200/// its file for as long as writing one segment takes, so a file this old was
201/// left by a write that never finished.
202const STALE_TEMP: Duration = Duration::from_secs(60 * 60);
203const WITHHELD: &str = "[archive: withheld]";
204/// What a fetch says of an archive that is not there, was forgotten, or is in
205/// another project: the three are not told apart.
206pub const NOT_FOUND: &str = "archive not available in this scope";
207const BUSY: &str = "archive busy; retain source and retry";
208
209/// Host-owned sanitizer, never supplied by model input. Must not expand output
210/// beyond the archive budgets. Tool arguments are passed as serialized JSON;
211/// if the returned text is not JSON the entire argument payload is withheld.
212pub type ArchiveRedactor = dyn Fn(&str) -> String + Send + Sync;
213
214#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
215pub struct ArchiveRef {
216    pub id: String,
217    /// Eligible messages, not the length of the source slice.
218    pub message_count: usize,
219    pub source_message_count: usize,
220}
221
222/// One of the segments a window was sealed as.
223#[derive(Debug, Clone, PartialEq, Eq)]
224pub struct ArchivePart {
225    pub archive: ArchiveRef,
226    /// Index in the window of the first source message this part covers; it
227    /// covers `archive.source_message_count` messages from there, and the
228    /// `source_index` of its messages counts from there too.
229    pub first_source_index: usize,
230    /// Source messages in this part stored with an over-limit payload omitted
231    /// (0 or 1): the messages that carry [`REDUCED_KEY`].
232    pub reduced_messages: usize,
233}
234
235/// What [`ArchiveStore::seal_window`] stored. The segments record nothing
236/// about each other: this list is what puts the window back together.
237#[derive(Debug, Clone, PartialEq, Eq)]
238pub struct SealedWindow {
239    /// In source order, contiguous, covering the whole window. Never empty.
240    pub parts: Vec<ArchivePart>,
241    /// The window's screened note, as stored with the last part: the earlier
242    /// note if that part had been sealed before. When the last part is the
243    /// same segment as an earlier part of this window, which was stored
244    /// without a note, it is the screened note of this call.
245    pub note: String,
246}
247
248/// What [`ArchiveStore::forget`] did.
249#[derive(Debug, Clone, PartialEq, Eq)]
250pub struct Forgotten {
251    /// The id of every file that held the content live and is a tombstone
252    /// now: the id asked for first if it is one of them, then every other
253    /// file the same content was stored in. An id is here twice when two
254    /// shelves held a file under it. Empty when every copy that could be
255    /// read had been forgotten before.
256    pub ids: Vec<String>,
257    /// `Some` when a shelf, a legacy slot or a file that may hold another
258    /// copy could not be read, or was read and is not a valid record: how
259    /// many such places, and the first with its error. A copy there was not reached and is still live. Forgetting
260    /// the id again once the place can be read reaches it.
261    pub unread: Option<String>,
262}
263
264#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
265pub struct ArchiveDescriptor {
266    pub id: String,
267    pub message_count: usize,
268    pub source_message_count: usize,
269    /// A bounded excerpt of eligible source JSON, never the hidden note.
270    pub snippet: String,
271}
272
273#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
274#[serde(deny_unknown_fields)]
275pub struct ArchivedMessage {
276    /// Zero-based offset in the slice passed to seal, including omitted messages.
277    pub source_index: usize,
278    /// Original content-array indices; empty for string content.
279    pub block_indices: Vec<usize>,
280    pub message: SharedMessage,
281}
282
283/// Eligible, screened source evidence without filesystem state. `logical_id`
284/// is the legacy 64-hex hash of the raw host session ID; the digest deliberately
285/// excludes the hidden note and omitted content. Never log this payload.
286#[derive(Clone, PartialEq, Serialize, Deserialize)]
287#[serde(deny_unknown_fields)]
288pub struct ArchiveProjection {
289    pub logical_id: String,
290    pub source_message_count: usize,
291    pub messages: Vec<ArchivedMessage>,
292    pub note: String,
293    pub digest: String,
294}
295
296/// Pure projection: no path discovery, directory creation, or archive I/O.
297/// The trusted host supplies the raw logical session ID and optional sanitizer.
298pub fn project_archive(
299    logical_id: &str,
300    messages: &[SharedMessage],
301    note: &str,
302    redactor: Option<Arc<ArchiveRedactor>>,
303) -> io::Result<ArchiveProjection> {
304    validate_key(logical_id)?;
305    project_hashed(
306        &digest_parts(&[logical_id.as_bytes()]),
307        messages,
308        note,
309        redactor,
310        Reduce::None,
311    )
312}
313
314/// What is changed in a message that is over a segment budget by itself: it
315/// cannot be stored whole, and being alone it cannot be cut out of its part.
316/// A step is tried only when the one before it still does not fit, and
317/// whatever a step does not name stays exact. The first two steps leave
318/// nothing out: the structural budgets (depth, nodes) are about the shape of
319/// a tool input, and as one string, its JSON text under
320/// [`INPUT_JSON_KEY`], the same input has no shape to count. Only what is
321/// over the byte budget as text too is left out. Each omission is replaced
322/// by a marker that says how large it was, and the stored message carries
323/// [`REDUCED_KEY`] in every case.
324#[derive(Clone, Copy, PartialEq, Eq)]
325enum Reduce {
326    /// Nothing: the exact projection, the only one `seal` and
327    /// [`project_archive`] ever make.
328    None,
329    /// Each tool input that is over a budget by itself: stored as its JSON
330    /// text, or left out if that text is over the byte budget.
331    LargeInputs,
332    /// Every tool input as its JSON text: for inputs that each fit and
333    /// together have too many nodes.
334    InputsAsText,
335    /// Every tool input left out.
336    Inputs,
337    /// Every tool input; of each string, what follows its first
338    /// [`REDUCED_TEXT_BYTES`]; the content blocks that follow the first
339    /// [`REDUCED_BLOCKS`] kept; and of a tool result's block list, what
340    /// follows its first text. Always fits.
341    Text,
342}
343
344impl Reduce {
345    fn next(self) -> Option<Self> {
346        match self {
347            Reduce::None => Some(Reduce::LargeInputs),
348            Reduce::LargeInputs => Some(Reduce::InputsAsText),
349            Reduce::InputsAsText => Some(Reduce::Inputs),
350            Reduce::Inputs => Some(Reduce::Text),
351            Reduce::Text => None,
352        }
353    }
354
355    /// What the preflight charges for a tool input that this step does not
356    /// store exact, instead of walking it: the bytes of its JSON text, or 0
357    /// when it is left out. `None` when the input is stored exact. Under
358    /// `LargeInputs` the projector also stores as text an input whose
359    /// screened form is over a budget; the preflight has then charged for it
360    /// exact, which errs on the strict side.
361    fn in_place_of(self, input: &Value) -> Option<usize> {
362        let as_text = || input_text_cost(input).unwrap_or(0);
363        match self {
364            Reduce::None => None,
365            Reduce::LargeInputs => InputBudget::alone(input).is_err().then(as_text),
366            Reduce::InputsAsText => Some(as_text()),
367            Reduce::Inputs | Reduce::Text => Some(0),
368        }
369    }
370}
371
372/// What a tool input costs against the byte budget when it is stored as one
373/// string, its JSON text under [`INPUT_JSON_KEY`]. `None` when that is over
374/// the budget of one segment by itself: such an input is left out.
375fn input_text_cost(input: &Value) -> Option<usize> {
376    let cost = json_len(input).saturating_add(INPUT_JSON_KEY.len() + 16);
377    (cost <= MAX_INPUT_BYTES).then_some(cost)
378}
379
380/// Whether nothing anywhere in a tool input is marked as not to be stored:
381/// the rule of [`InputBudget::input`], which withholds the whole input for
382/// one such object, without its structural budget. Not recursive, so it does
383/// not matter how deep the input is.
384fn input_eligible(input: &Value) -> bool {
385    let mut pending = vec![input];
386    while let Some(value) = pending.pop() {
387        if eligible_class(value).is_none() {
388            return false;
389        }
390        match value {
391            Value::Array(values) => pending.extend(values),
392            Value::Object(values) => pending.extend(values.values()),
393            _ => {}
394        }
395    }
396    true
397}
398
399fn project_hashed(
400    logical_id: &str,
401    messages: &[SharedMessage],
402    note: &str,
403    redactor: Option<Arc<ArchiveRedactor>>,
404    reduce: Reduce,
405) -> io::Result<ArchiveProjection> {
406    debug_assert!(reduce == Reduce::None || messages.len() <= 1);
407    // Sealing fewer messages does not make a note smaller: not `over`.
408    if note.len() > MAX_NOTE_BYTES {
409        return Err(invalid("archive message/note budget exceeded"));
410    }
411    if messages.len() > MAX_MESSAGES {
412        return Err(over("archive message/note budget exceeded"));
413    }
414    // A `Text` projection is bounded by what it keeps, whatever the source
415    // holds, and walks no tool input.
416    if reduce != Reduce::Text {
417        let mut budget = InputBudget {
418            bytes: 0,
419            nodes: 0,
420            reduce,
421        };
422        for message in messages {
423            budget.message(message, redactor.is_some())?;
424        }
425    }
426    let projector = Projector {
427        redactor,
428        reduce,
429        omitted: Cell::new(0),
430        as_text: Cell::new(0),
431    };
432    let mut eligible = Vec::new();
433    for (source_index, message) in messages.iter().enumerate() {
434        if let Some((message, block_indices)) = projector.project(message)? {
435            eligible.push(ArchivedMessage {
436                source_index,
437                block_indices,
438                message: Arc::new(message),
439            });
440        }
441    }
442    let digest = projection_digest(logical_id, messages.len(), &eligible)?;
443    let note = projector.screen(note, DisclosureClass::ModelVisible)?;
444    if note.len() > MAX_NOTE_BYTES {
445        return Err(invalid("redacted note exceeds archive budget"));
446    }
447    Ok(ArchiveProjection {
448        logical_id: logical_id.to_owned(),
449        source_message_count: messages.len(),
450        messages: eligible,
451        note,
452        digest,
453    })
454}
455
456struct Projector {
457    redactor: Option<Arc<ArchiveRedactor>>,
458    reduce: Reduce,
459    /// Omissions made so far in the message being projected.
460    omitted: Cell<usize>,
461    /// Tool inputs of that message stored as their JSON text so far.
462    as_text: Cell<usize>,
463}
464
465/// No Debug implementation: do not accidentally log redactor state or content.
466pub struct ArchiveStore {
467    fs: PrivateDir,
468    scope: String,
469    logical: String,
470    redactor: Option<Arc<ArchiveRedactor>>,
471    abort: Option<Arc<AtomicBool>>,
472    wait: Duration,
473    /// [`PROCESS_LOCK_WAIT`]; tests shorten it.
474    process_wait: Duration,
475    /// The project directory as a path, for messages only: nothing is
476    /// opened by it.
477    directory: PathBuf,
478}
479
480/// Process-local mutual exclusion for one archive directory. The advisory
481/// file lock excludes other processes, but each operation opens its own
482/// descriptor, so without this two threads of one process would see each
483/// other as busy. Waiters are not served in FIFO order.
484struct Gate {
485    held: Mutex<bool>,
486    released: Condvar,
487    /// Operations that have waited at least once (tests synchronize on it).
488    #[cfg(test)]
489    waited: std::sync::atomic::AtomicUsize,
490}
491
492/// Field order is drop order: unlock the file, then admit the next operation.
493struct Locked {
494    _file: Option<FlockGuard>,
495    _gate: GateGuard,
496}
497
498struct GateGuard(Arc<Gate>);
499
500/// Device and inode of an archive directory.
501type DirIdentity = (u64, u64);
502type Gates = Mutex<HashMap<DirIdentity, Weak<Gate>>>;
503
504impl Gate {
505    /// One gate per directory identity, however the directory was reached.
506    fn for_directory(identity: DirIdentity) -> Arc<Gate> {
507        static GATES: OnceLock<Gates> = OnceLock::new();
508        let mut gates = GATES
509            .get_or_init(Default::default)
510            .lock()
511            .unwrap_or_else(PoisonError::into_inner);
512        if let Some(gate) = gates.get(&identity).and_then(Weak::upgrade) {
513            return gate;
514        }
515        gates.retain(|_, gate| gate.strong_count() > 0);
516        let gate = Arc::new(Gate {
517            held: Mutex::new(false),
518            released: Condvar::new(),
519            #[cfg(test)]
520            waited: Default::default(),
521        });
522        gates.insert(identity, Arc::downgrade(&gate));
523        gate
524    }
525
526    fn enter(
527        gate: Arc<Gate>,
528        wait: Duration,
529        abort: Option<&AtomicBool>,
530        busy: &'static str,
531    ) -> io::Result<GateGuard> {
532        let deadline = Instant::now() + wait;
533        {
534            let mut held = gate.held.lock().unwrap_or_else(PoisonError::into_inner);
535            let mut waited = false;
536            loop {
537                if abort.is_some_and(|abort| abort.load(Ordering::Acquire)) {
538                    return Err(canceled());
539                }
540                // An uncontended first attempt is admitted at once; after
541                // waiting, an expired operation is never admitted, even when
542                // the gate has just become free.
543                let now = Instant::now();
544                if waited && now >= deadline {
545                    return Err(io::Error::new(io::ErrorKind::WouldBlock, busy));
546                }
547                if !*held {
548                    *held = true;
549                    break;
550                }
551                if now >= deadline {
552                    return Err(io::Error::new(io::ErrorKind::WouldBlock, busy));
553                }
554                let mut slice = deadline - now;
555                if abort.is_some() {
556                    slice = slice.min(ABORT_POLL);
557                }
558                #[cfg(test)]
559                if !waited {
560                    gate.waited.fetch_add(1, Ordering::AcqRel);
561                }
562                waited = true;
563                held = gate
564                    .released
565                    .wait_timeout(held, slice)
566                    .unwrap_or_else(PoisonError::into_inner)
567                    .0;
568            }
569        }
570        Ok(GateGuard(gate))
571    }
572}
573
574impl Drop for GateGuard {
575    fn drop(&mut self) {
576        *self.0.held.lock().unwrap_or_else(PoisonError::into_inner) = false;
577        // Every waiter rechecks; an aborted or expired one must not absorb the
578        // only wakeup.
579        self.0.released.notify_all();
580    }
581}
582
583/// Unlocks explicitly before closing. A flock belongs to the open file
584/// description, and a child forked by any thread holds a copy of it until it
585/// execs (O_CLOEXEC applies only at exec); closing our copy alone would leave
586/// the archive locked meanwhile.
587struct FlockGuard(std::fs::File);
588
589impl Drop for FlockGuard {
590    fn drop(&mut self) {
591        let _ = fs4::fs_std::FileExt::unlock(&self.0);
592    }
593}
594
595/// Why the lock file of a project directory is not one the store uses.
596/// [`ArchiveStore::lock_error`] makes the message, with the file's path.
597#[derive(Debug)]
598struct LockRefused(&'static str);
599
600impl std::fmt::Display for LockRefused {
601    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
602        write!(f, "archive lock file {}", self.0)
603    }
604}
605
606impl std::error::Error for LockRefused {}
607
608/// What a lookup by id found on the shelves: the newest valid record under
609/// the id.
610struct Located {
611    /// The name of the shelf it is on.
612    shelf: String,
613    record: Record,
614    /// A newer shelf has a file under the id that is not a valid record and
615    /// still says this record's content was forgotten.
616    forgotten: bool,
617}
618
619/// What a seal finds under the id it would store a projection as.
620enum Shelved {
621    Nothing,
622    /// The projection itself, live or forgotten, and the shelf it is on.
623    This(PrivateDir, Record),
624    /// Something else, or something that cannot be read.
625    Other,
626}
627
628/// What a search did not read, so that an empty or a short result is not
629/// taken for "the project holds nothing else".
630#[derive(Debug, Clone, PartialEq, Eq)]
631pub struct Unscanned {
632    /// `Some(n)`: the scan budget stopped the search after the `n` newest
633    /// segment files, and older ones were not read.
634    pub stopped_after: Option<usize>,
635    /// The shelves, legacy slots and segment files the search passed over
636    /// because they could not be read or do not hold a valid record: how
637    /// many, and the first by name with its error. Names and errors only,
638    /// never content.
639    pub unread: Option<String>,
640}
641
642/// The shelves, legacy slots and files an operation had to pass over
643/// because they could not be read. What it was looking for may be in one of
644/// them, so "not found" or "that is all" would be a guess.
645#[derive(Default)]
646struct Unread {
647    places: usize,
648    first: Option<(String, io::Error)>,
649}
650
651impl Unread {
652    fn pass(&mut self, place: &str, error: io::Error) {
653        // The name and the error only: never content.
654        tracing::warn!(%place, %error, "archive lookup passed over what it could not read");
655        self.places += 1;
656        self.first.get_or_insert_with(|| (place.to_owned(), error));
657    }
658
659    /// What an operation that went on without these places tells its caller:
660    /// how many there were and the first, by name, with its error. `None`
661    /// when everything could be read.
662    fn notice(&self) -> Option<String> {
663        let (place, error) = self.first.as_ref()?;
664        let places = self.places;
665        Some(format!("{places} place(s) could not be read, first {place}: {error}"))
666    }
667
668    /// [`notice`](Self::notice) with the project's `directory`, which the
669    /// places are in: for a caller who has to go and look at the file.
670    fn notice_in(&self, directory: &Path) -> Option<String> {
671        let notice = self.notice()?;
672        Some(format!("{notice} (in {})", directory.display()))
673    }
674
675    /// The error of a lookup that did not find its segment: "not found" when
676    /// everything could be read, otherwise what could not be, with the kind
677    /// of the first failure and never `NotFound`.
678    fn not_found(self) -> io::Error {
679        let Some((place, error)) = self.first else {
680            return not_found();
681        };
682        let kind = match error.kind() {
683            io::ErrorKind::NotFound => io::ErrorKind::Other,
684            kind => kind,
685        };
686        let places = self.places;
687        io::Error::new(
688            kind,
689            format!(
690                "archive not found in what could be read; {places} place(s) that may hold it \
691                 could not be read, first {place}: {error}"
692            ),
693        )
694    }
695}
696
697/// The check a record failed ([`ArchiveStore::check`]). A read does not say
698/// which: every one of them is `invalid archive record` to it
699/// ([`Fault::into_read_error`]). It prints as a reason that names the check
700/// and holds nothing of the record but its version number and digests.
701#[derive(Debug)]
702enum Fault {
703    /// Not JSON, or not exactly a record: a field missing, unknown or of
704    /// the wrong type.
705    Parse(serde_json::Error),
706    Version(u32),
707    /// Not the scope of the directory it is in.
708    Scope,
709    Id,
710    Logical,
711    Digest,
712    /// On a shelf, under a file name that is not its id.
713    Name,
714    /// More messages, a higher source count or a longer note than one
715    /// archive holds.
716    Limit,
717    /// A `source_index` that does not grow, or is not below the count.
718    Order,
719    /// The digest in the record is not the digest of its content.
720    Mismatch { record: String, content: String },
721    /// A tombstone that says when it was written.
722    TombstoneCreated,
723    /// The messages do not serialize within a segment's size.
724    Size(io::Error),
725}
726
727impl Fault {
728    /// What a read reports: one error for every failed check, as before the
729    /// checks had names. The messages being over a segment's size is the
730    /// error it always was.
731    fn into_read_error(self) -> io::Error {
732        match self {
733            Fault::Size(error) => error,
734            _ => corrupt(),
735        }
736    }
737}
738
739impl std::fmt::Display for Fault {
740    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
741        match self {
742            // serde's own text can quote what it read: only the kind of
743            // failure and where it is are taken from it.
744            Fault::Parse(error) => {
745                let text = error.to_string();
746                let what = match error.classify() {
747                    serde_json::error::Category::Data if text.starts_with("unknown field") => {
748                        "an unknown field"
749                    }
750                    serde_json::error::Category::Data if text.starts_with("missing field") => {
751                        "a field is missing"
752                    }
753                    serde_json::error::Category::Data if text.starts_with("duplicate field") => {
754                        "a field is there twice"
755                    }
756                    serde_json::error::Category::Data if text.starts_with("unknown variant") => {
757                        "an unknown state"
758                    }
759                    serde_json::error::Category::Data => "a field of the wrong type",
760                    serde_json::error::Category::Eof => "the JSON ends early",
761                    _ => "not JSON",
762                };
763                write!(f, "not an archive record: {what} (column {})", error.column())
764            }
765            Fault::Version(version) => write!(f, "version {version} is not supported"),
766            Fault::Scope => f.write_str("scope is not this project's"),
767            Fault::Id => f.write_str("invalid id"),
768            Fault::Logical => f.write_str("invalid logical"),
769            Fault::Digest => f.write_str("invalid digest"),
770            Fault::Name => f.write_str("file name is not the record's id"),
771            Fault::Limit => f.write_str("over a per-archive limit"),
772            Fault::Order => f.write_str("source_index out of order"),
773            Fault::Mismatch { record, content } => {
774                write!(f, "digest does not match content (record {record}, content {content})")
775            }
776            Fault::TombstoneCreated => f.write_str("a tombstone carries no created_ms"),
777            Fault::Size(_) => f.write_str("over the 16 MiB archive size"),
778        }
779    }
780}
781
782#[derive(Serialize, Deserialize)]
783#[serde(deny_unknown_fields)]
784struct Record {
785    version: u32,
786    scope: String,
787    logical: String,
788    id: String,
789    digest: String,
790    /// When the record was first written, in milliseconds since the Unix
791    /// epoch. Optional: a record from before the field existed has none and
792    /// is as valid as one that has it, and a tombstone never has one (a
793    /// forget leaves no time behind). Not part of the digest, so it says
794    /// nothing about which content a record holds. No other field may be
795    /// added to a version 1 record: any other unknown key is still refused.
796    #[serde(default, deserialize_with = "present", skip_serializing_if = "Option::is_none")]
797    created_ms: Option<u64>,
798    body: Body,
799}
800
801/// A `created_ms` that is in a record is a whole number of milliseconds:
802/// `null` is not a way to leave it out.
803fn present<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result<Option<u64>, D::Error> {
804    u64::deserialize(deserializer).map(Some)
805}
806
807/// When a record is to say it was written, and what time its file is to
808/// carry: what an import supplies for a record that was first written
809/// somewhere else. A seal has neither to give and uses [`Written::now`].
810#[derive(Debug, Clone, Copy, PartialEq, Eq)]
811pub(crate) struct Written {
812    /// The record's `created_ms`, stored as given. `None` stores a record
813    /// without the field.
814    pub(crate) created_ms: Option<u64>,
815    /// The time of last access and modification of the record's file.
816    /// `None` leaves it the time of the write.
817    pub(crate) modified: Option<std::time::SystemTime>,
818}
819
820impl Written {
821    /// What a seal writes: the time of the write. A clock set before 1970
822    /// has no such number, and the record then goes without the field
823    /// rather than the save be refused.
824    fn now() -> Self {
825        let since_epoch = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH);
826        Written {
827            created_ms: since_epoch.ok().and_then(|since| u64::try_from(since.as_millis()).ok()),
828            modified: None,
829        }
830    }
831}
832
833#[derive(Serialize, Deserialize)]
834#[serde(tag = "state", rename_all = "snake_case", deny_unknown_fields)]
835enum Body {
836    Live {
837        source_message_count: usize,
838        messages: Vec<ArchivedMessage>,
839        note: String,
840    },
841    Tombstone,
842}
843
844impl Record {
845    fn reference(&self) -> io::Result<ArchiveRef> {
846        match &self.body {
847            Body::Live {
848                source_message_count,
849                messages,
850                ..
851            } => Ok(ArchiveRef {
852                id: self.id.clone(),
853                message_count: messages.len(),
854                source_message_count: *source_message_count,
855            }),
856            Body::Tombstone => Err(not_found()),
857        }
858    }
859}
860
861/// How far after this machine's clock the `created_ms` of an imported
862/// record may be: one day.
863const CREATED_AHEAD_MS: u64 = 24 * 60 * 60 * 1000;
864
865/// What [`ArchiveStore::import`] did with one record.
866#[derive(Debug, Clone, PartialEq, Eq)]
867pub enum ImportOutcome {
868    /// Written, as a new file under the record's own id.
869    Stored,
870    /// The store already holds this content under this id, in the same
871    /// state (live, or a tombstone). Nothing was written; the stored note,
872    /// `created_ms` and file time stay. `note_differs`: both are live and
873    /// the stored note is not the record's.
874    Present { note_differs: bool },
875    /// The record is a tombstone and the store held the archive live: it
876    /// was forgotten, by the store's own forget.
877    Forgotten,
878    /// The record is live and the store holds its tombstone. Nothing was
879    /// written: an import does not undo a forget.
880    KeptForgotten,
881}
882
883/// One record an import accepted. Nothing in it is content.
884#[derive(Debug, Clone, PartialEq, Eq)]
885pub struct Imported {
886    pub outcome: ImportOutcome,
887    /// The record's id, which is the id it is stored under.
888    pub id: String,
889    /// Whether the record that came in is live (not a tombstone).
890    pub live: bool,
891    /// The messages in the record that came in; 0 for a tombstone.
892    pub message_count: usize,
893    /// The shelf the record's file is on, or in a dry run would be written
894    /// to. `None` when the store holds it in a legacy slot file.
895    pub shelf: Option<String>,
896    /// The id of another live archive of this store with the same content
897    /// (the same digest), found where a seal looks. The record is stored
898    /// under its own id all the same.
899    pub same_content_as: Option<String>,
900    /// For [`ImportOutcome::Forgotten`]: [`Forgotten::unread`] of the
901    /// forget. `Some` means a copy of the content may not have been reached.
902    pub unread: Option<String>,
903}
904
905/// Why an import refused one record: the record's fault, not the machine's.
906/// Nothing was written for it, and the next record can be tried.
907#[derive(Debug, Clone, PartialEq, Eq)]
908pub struct ImportRefused {
909    /// The check that failed. Never content: at most a version number, a
910    /// column, digests and ids.
911    pub reason: String,
912    /// The record's id, when the line got far enough to have a valid one.
913    pub id: Option<String>,
914}
915
916/// What a dry run of an import has "written" so far, so that a later line
917/// of the same run is judged as the real run would judge it. One for a whole
918/// run, over every file and project of it. A real run does not use it.
919#[derive(Default)]
920pub struct ImportSeen {
921    projects: HashMap<String, Planned>,
922}
923
924/// One project directory of a dry run.
925#[derive(Default)]
926struct Planned {
927    /// The files the run would have written, by id.
928    ids: HashMap<String, Held>,
929    /// The digests the run would have forgotten: every copy is a tombstone.
930    forgotten: std::collections::HashSet<String>,
931    /// The newest shelf's number and how many segment files it holds.
932    shelf: Option<(u64, usize)>,
933}
934
935/// What is under an id: in the store, or in a dry run's plan.
936#[derive(Clone)]
937struct Held {
938    digest: String,
939    /// The note of a live record; `None` for a tombstone.
940    note: Option<String>,
941    shelf: Option<String>,
942}
943
944/// What is where a seal would look for a digest: under its two halves on
945/// the shelves and, by digest, in the legacy slots.
946#[derive(Default)]
947struct Probe {
948    /// A tombstone of the digest, or a damaged file that still says so.
949    forgotten: bool,
950    /// The id of a live record of the digest.
951    live: Option<String>,
952    /// A shelf has the digest, live or forgotten, under one of the halves:
953    /// what a forget takes for "a seal already finds it".
954    held: bool,
955    /// Which of the two halves a shelf (or a dry run's plan) has a file
956    /// under, whatever it is.
957    taken: [bool; 2],
958}
959
960impl Probe {
961    /// The id the tombstone a seal will find would be written under
962    /// ([`ArchiveStore::write_guard`]), or `None` when none is written: one
963    /// is there, or both names are taken.
964    fn guard(&self, digest: &str) -> Option<String> {
965        let free = self.taken.iter().position(|taken| !taken)?;
966        (!self.held).then(|| digest[free * 32..(free + 1) * 32].to_owned())
967    }
968}
969
970impl Planned {
971    /// The shelf the next file of the dry run would go to, as
972    /// [`ArchiveStore::writable_shelf`] chooses it for a store whose
973    /// shelves can all be opened, counting that file.
974    fn shelve(&mut self, store: Option<&ArchiveStore>, shelves: &[String]) -> String {
975        let (number, held) = self.shelf.get_or_insert_with(|| {
976            let newest = shelves.first().and_then(|name| Some((shelf_number(name)?, name)));
977            let Some((number, name)) = newest else {
978                return (0, SHELF_FILES);
979            };
980            let names = store
981                .and_then(|store| store.fs.open_child(name, true).ok().flatten())
982                .and_then(|shelf| shelf.names().ok());
983            // A shelf that cannot be read is not written to either.
984            let held = names.map_or(SHELF_FILES, |names| {
985                names.iter().filter(|(name, ..)| name.ends_with(".json")).count()
986            });
987            (number, held)
988        });
989        if *held >= SHELF_FILES {
990            *number += 1;
991            *held = 0;
992        }
993        *held += 1;
994        shelf_name(*number)
995    }
996}
997
998/// The directory name of a project: what `scope` is in its records.
999pub fn project_scope(scope_key: &str) -> io::Result<String> {
1000    validate_key(scope_key)?;
1001    Ok(digest_parts(&[scope_key.as_bytes()]))
1002}
1003
1004/// The `scope` of a record line: the project directory it says it is of,
1005/// for an import that files each record where it was. Nothing else of the
1006/// line is looked at, so `Ok` does not say the line is a record. A line
1007/// that is not JSON with a `scope` of 64 lowercase hex characters is
1008/// refused, with the reason and never the line.
1009pub fn record_scope(line: &[u8]) -> Result<String, ImportRefused> {
1010    #[derive(Deserialize)]
1011    struct Scoped {
1012        scope: String,
1013    }
1014    let refused = |reason: String| ImportRefused { reason, id: None };
1015    let scoped: Scoped =
1016        serde_json::from_slice(line).map_err(|error| refused(Fault::Parse(error).to_string()))?;
1017    if !is_scope(&scoped.scope) {
1018        return Err(refused("invalid scope".to_owned()));
1019    }
1020    Ok(scoped.scope)
1021}
1022
1023/// A project directory's name: 64 lowercase hex characters.
1024fn is_scope(scope: &str) -> bool {
1025    scope.len() == 64 && lower_hex(scope)
1026}
1027fn lower_hex(text: &str) -> bool {
1028    text.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
1029}
1030
1031impl ArchiveStore {
1032    /// Open a project's archive for a seal or a forget. Creates the base,
1033    /// `context-archives` and project directories if they are missing, makes
1034    /// them private again and syncs them, every time. A read must not go
1035    /// through here: see [`open_existing`](Self::open_existing).
1036    ///
1037    /// `base` may be reached through links (a home directory on another
1038    /// disk): it is resolved once, here, and the store lives in the real
1039    /// directory. Below the base no link is followed: `context-archives`,
1040    /// the project directory, a shelf or a file that is a link is refused.
1041    pub fn new(base: &Path, scope_key: &str, logical_id: &str) -> io::Result<Self> {
1042        let (scope, logical) = Self::keys(scope_key, logical_id)?;
1043        let fs = PrivateDir::new(base, &scope)?;
1044        Ok(Self::opened(fs, base, scope, logical))
1045    }
1046
1047    /// Open a project's archive for a read (fetch, fetch_note, search), if
1048    /// it is there. Nothing is created, no mode is changed and nothing is
1049    /// synced, so this also works where nothing can be written. `None` when
1050    /// the project has no archive directory: a search of it finds nothing and
1051    /// a fetch is [`NOT_FOUND`]. The base is resolved as `new` resolves it,
1052    /// and a link below the base is an error, as it is for `new`.
1053    pub fn open_existing(
1054        base: &Path,
1055        scope_key: &str,
1056        logical_id: &str,
1057    ) -> io::Result<Option<Self>> {
1058        let (scope, logical) = Self::keys(scope_key, logical_id)?;
1059        match PrivateDir::open_existing(base, &scope) {
1060            Ok(fs) => Ok(Some(Self::opened(fs, base, scope, logical))),
1061            Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
1062            Err(error) => Err(error),
1063        }
1064    }
1065
1066    /// The directory name of a project and the stored name of a
1067    /// conversation: the hashes of the two keys.
1068    fn keys(scope_key: &str, logical_id: &str) -> io::Result<(String, String)> {
1069        for key in [scope_key, logical_id] {
1070            validate_key(key)?;
1071        }
1072        let scope = digest_parts(&[scope_key.as_bytes()]);
1073        Ok((scope, digest_parts(&[logical_id.as_bytes()])))
1074    }
1075
1076    fn opened(fs: PrivateDir, base: &Path, scope: String, logical: String) -> Self {
1077        Self {
1078            directory: base.join("context-archives").join(&scope),
1079            fs,
1080            scope,
1081            logical,
1082            redactor: None,
1083            abort: None,
1084            wait: LOCK_WAIT,
1085            process_wait: PROCESS_LOCK_WAIT,
1086        }
1087    }
1088
1089    /// Opt in to screened tool evidence. Install the SAME trusted policy when
1090    /// reopening a store; retrieval never restores material withheld at seal.
1091    pub fn with_redactor(mut self, redactor: Arc<ArchiveRedactor>) -> Self {
1092        self.redactor = Some(redactor);
1093        self
1094    }
1095
1096    /// Once `abort` is set, an operation that has not been admitted yet (one
1097    /// waiting behind another operation in this process, or for another
1098    /// process to release the lock) fails with `Interrupted` without reading
1099    /// or writing any record. A read that needs no lock looks at the flag
1100    /// once, before it starts. Setup before admission (opening the store,
1101    /// projecting a seal) is not undone. An admitted operation always
1102    /// completes, so callers must still treat an abandoned seal as possibly
1103    /// published.
1104    pub fn with_abort(mut self, abort: Arc<AtomicBool>) -> Self {
1105        self.abort = Some(abort);
1106        self
1107    }
1108
1109    /// Queue behind other operations on this archive in this process, then
1110    /// take the cross-process lock, trying again for up to `process_wait`
1111    /// while another process holds it. The gate stays held meanwhile: the
1112    /// operations behind it would meet the same lock. Leaving early (busy,
1113    /// abandoned, any other error) releases the gate.
1114    fn exclusive(&self) -> io::Result<Locked> {
1115        self.locked(|fs| fs.lock().map(Some))
1116    }
1117
1118    /// The error of a lock file that is not one the store uses, with the
1119    /// file by its path and what to do about it; any other error as it is.
1120    fn lock_error(&self, error: io::Error) -> io::Error {
1121        let refused = error.get_ref().and_then(|inner| inner.downcast_ref::<LockRefused>());
1122        let Some(LockRefused(what)) = refused else {
1123            return error;
1124        };
1125        let file = self.directory.join("lock");
1126        let file = file.display();
1127        io::Error::new(
1128            error.kind(),
1129            format!(
1130                "context archive lock file {file} {what}; nothing is saved or forgotten while \
1131                 it is. It holds no data: remove it (rm '{file}') and try again"
1132            ),
1133        )
1134    }
1135
1136    /// What a read of the legacy slots holds instead: the gate and, with the
1137    /// same wait, the lock shared. The lock file is opened only if it is
1138    /// there and only for reading, because a read creates nothing and
1139    /// changes no mode. Where there is none the read goes on without it: a
1140    /// slot name points at a whole file either way.
1141    fn shared(&self) -> io::Result<Locked> {
1142        self.locked(PrivateDir::lock_existing)
1143    }
1144
1145    fn locked(
1146        &self,
1147        lock: impl Fn(&PrivateDir) -> io::Result<Option<FlockGuard>>,
1148    ) -> io::Result<Locked> {
1149        let gate = self.enter(BUSY)?;
1150        // The wait the notes store shares (`lock_file::lock_within`).
1151        let file = crate::core::lock_file::lock_within(
1152            self.process_wait,
1153            PROCESS_LOCK_POLL,
1154            || match lock(&self.fs) {
1155                Err(error) if error.kind() == io::ErrorKind::WouldBlock => {
1156                    self.check_abort()?;
1157                    Ok(None)
1158                }
1159                result => result.map(Some).map_err(|error| self.lock_error(error)),
1160            },
1161        )?
1162        .ok_or_else(|| io::Error::new(io::ErrorKind::WouldBlock, BUSY))?;
1163        Ok(Locked { _file: file, _gate: gate })
1164    }
1165
1166    /// `Interrupted` once the caller has abandoned this operation.
1167    fn check_abort(&self) -> io::Result<()> {
1168        match &self.abort {
1169            Some(abort) if abort.load(Ordering::Acquire) => Err(canceled()),
1170            _ => Ok(()),
1171        }
1172    }
1173
1174    fn enter(&self, busy: &'static str) -> io::Result<GateGuard> {
1175        let gate = Gate::for_directory(self.fs.identity()?);
1176        Gate::enter(gate, self.wait, self.abort.as_deref(), busy)
1177    }
1178
1179    pub fn seal(&self, messages: &[SharedMessage], note: &str) -> io::Result<ArchiveRef> {
1180        self.seal_with_note(messages, note)
1181            .map(|(reference, _)| reference)
1182    }
1183
1184    /// [`seal`](Self::seal) that also returns the screened note stored with the
1185    /// segment (the earlier note when this projection was already sealed), in
1186    /// the same locked operation.
1187    pub fn seal_with_note(
1188        &self,
1189        messages: &[SharedMessage],
1190        note: &str,
1191    ) -> io::Result<(ArchiveRef, String)> {
1192        let (reference, note, _) = self.seal_one(messages, note, Reduce::None)?;
1193        Ok((reference, note))
1194    }
1195
1196    /// Seal one window as one or more segments ("parts"). No per-segment
1197    /// budget fails it; what still can is what fails any seal (I/O, a busy
1198    /// lock, an abort, a part that was forgotten) and a note over its limit,
1199    /// which is refused before anything is written.
1200    ///
1201    /// A window within the budgets is one part: the same segment, id and note
1202    /// as [`seal_with_note`](Self::seal_with_note). A larger one is filled
1203    /// from its start: a part ends before the first message that would take
1204    /// it over a budget, and the next part starts there. Every part is an
1205    /// ordinary segment; together they hold every message of the window
1206    /// exactly once, in order. Only a message that is over a budget by
1207    /// itself is stored reduced, and is then a part of its own: it costs the
1208    /// window two parts more, the one that ends before it and itself.
1209    ///
1210    /// Where a part ends depends on the messages from its start to the one
1211    /// after its end, and on nothing after that, so every part but the last
1212    /// is the same however long the window is. (One corner: a last part that
1213    /// was over the size of a segment only with the note in it was cut
1214    /// shorter, and is not cut when it is no longer the last.) Parts are
1215    /// sealed first to last, each in its own locked operation, and the note
1216    /// goes to the last one. After an error the parts already sealed stay
1217    /// (callers keep the source history, as for `seal`). A retry on the same
1218    /// window finds them by id, writes only what is missing and returns the
1219    /// same list. A retry on a window that has grown since (a failed turn
1220    /// leaves the user's next message in it) finds every part but the last
1221    /// one of the earlier attempt; that one, if it was written, stays as a
1222    /// segment no list names, and its messages are stored again in the new
1223    /// last part.
1224    pub fn seal_window(&self, messages: &[SharedMessage], note: &str) -> io::Result<SealedWindow> {
1225        // Sealing fewer messages does not make a note smaller: one over its
1226        // limit is refused here, before any part exists.
1227        let redactor = self.redactor.clone();
1228        let screened = project_hashed(&self.logical, &[], note, redactor, Reduce::None)?.note;
1229        let mut window = SealedWindow {
1230            parts: Vec::new(),
1231            note: String::new(),
1232        };
1233        let mut start = 0;
1234        loop {
1235            // An abandoned seal projects no further part.
1236            self.check_abort()?;
1237            let mut end = self.fill(messages, start);
1238            // The fill knows the budgets a part is refused on before it is
1239            // projected. Its size once screened and written is known only
1240            // then. A part over that is tried again at the largest power of
1241            // two messages below its length, and so on down: the part ends
1242            // up as the largest power of two messages that fits, whatever
1243            // length it was first tried at.
1244            loop {
1245                let note = if end == messages.len() { note } else { "" };
1246                match self.seal_part(messages, start..end, note, &mut window) {
1247                    Ok(()) => break,
1248                    Err(error) if over_budget(&error) && end - start > 1 => {
1249                        end = start + (1 << (end - start - 1).ilog2());
1250                    }
1251                    Err(error) => return Err(error),
1252                }
1253            }
1254            start = end;
1255            if start == messages.len() {
1256                break;
1257            }
1258        }
1259        // Parts with identical content are one segment. When the last part is
1260        // also an earlier one, that segment was stored for the earlier part,
1261        // without the note; the window's note is still this one.
1262        if let Some((last, earlier)) = window.parts.split_last() {
1263            if earlier.iter().any(|part| part.archive.id == last.archive.id) {
1264                window.note = screened;
1265            }
1266        }
1267        Ok(window)
1268    }
1269
1270    /// Where the part that starts at `start` ends when it is filled: before
1271    /// the first message that would take it over the message count or an
1272    /// input budget of one segment, which is what [`project_hashed`] refuses
1273    /// before it projects anything. Never before `start + 1` in a window
1274    /// that has a message there: a message that is over a budget by itself
1275    /// is a part of its own. One pass over the messages, without the
1276    /// redactor and without allocating.
1277    fn fill(&self, messages: &[SharedMessage], start: usize) -> usize {
1278        let mut budget = InputBudget {
1279            bytes: 0,
1280            nodes: 0,
1281            reduce: Reduce::None,
1282        };
1283        let limit = messages.len().min(start.saturating_add(MAX_MESSAGES));
1284        let redactor = self.redactor.is_some();
1285        let over = (start..limit).find(|&index| budget.message(&messages[index], redactor).is_err());
1286        let end = over.unwrap_or(limit).max(start + 1).min(messages.len());
1287        before_results(messages, start, end)
1288    }
1289
1290    /// Seal `messages[range]` as one part, or fail with the budget it is
1291    /// over. `note` is the window's note for the part that ends the window
1292    /// and empty for every other.
1293    fn seal_part(
1294        &self,
1295        messages: &[SharedMessage],
1296        range: std::ops::Range<usize>,
1297        note: &str,
1298        window: &mut SealedWindow,
1299    ) -> io::Result<()> {
1300        let mut reduce = Reduce::None;
1301        loop {
1302            match self.seal_one(&messages[range.clone()], note, reduce) {
1303                Ok((archive, stored, reduced_messages)) => {
1304                    window.parts.push(ArchivePart {
1305                        archive,
1306                        first_source_index: range.start,
1307                        reduced_messages,
1308                    });
1309                    window.note = stored;
1310                    return Ok(());
1311                }
1312                Err(error) if !over_budget(&error) => return Err(error),
1313                // Reduced only when a shorter part cannot help: one message,
1314                // alone.
1315                Err(error) => match reduce.next().filter(|_| range.len() == 1) {
1316                    Some(next) => reduce = next,
1317                    None => return Err(error),
1318                },
1319            }
1320        }
1321    }
1322
1323    /// [`seal_with_note`](Self::seal_with_note) of the projection `reduce`
1324    /// selects. Also returns how many of its messages are stored reduced.
1325    fn seal_one(
1326        &self,
1327        messages: &[SharedMessage],
1328        note: &str,
1329        reduce: Reduce,
1330    ) -> io::Result<(ArchiveRef, String, usize)> {
1331        let redactor = self.redactor.clone();
1332        let projection = project_hashed(&self.logical, messages, note, redactor, reduce)?;
1333        let reduced = projection
1334            .messages
1335            .iter()
1336            .filter(|archived| archived.message.get(REDUCED_KEY).is_some())
1337            .count();
1338        let (reference, note) = self.seal_projection(projection, None)?;
1339        Ok((reference, note, reduced))
1340    }
1341
1342    /// Store `projection` under the lock, as every seal does. `written` is
1343    /// `None` for a seal: the record gets the time of the write as its
1344    /// `created_ms`. An import, which stores records that were first written
1345    /// somewhere else, gives [`Written`] instead, and then that is what is
1346    /// stored: nothing on the way to the file puts the time of this write in
1347    /// its place, and a `created_ms` of `None` stores a record without the
1348    /// field. Content that is already stored is the answer either way and is
1349    /// left as it is, its `created_ms` and the time of its file included.
1350    ///
1351    /// The projection is not taken on trust ([`Self::admit`]): one of
1352    /// another chat, or one whose digest is not the digest of its content,
1353    /// is refused as `InvalidInput` before the lock is taken, so a refusal
1354    /// writes nothing at all.
1355    pub(crate) fn seal_projection(
1356        &self,
1357        projection: ArchiveProjection,
1358        written: Option<Written>,
1359    ) -> io::Result<(ArchiveRef, String)> {
1360        self.admit(&projection)?;
1361        let _lock = self.exclusive()?;
1362        self.store(projection, written.unwrap_or_else(Written::now))
1363    }
1364
1365    /// Whether `projection` is one this store may write: of this chat, and
1366    /// with the digest of its own content. A projection has public fields
1367    /// and can be deserialized, so neither is a given. Stored unchecked, a
1368    /// record of another chat or with a digest that is not its content's is
1369    /// a file no read accepts, under a name that is then taken for good.
1370    /// The digest is looked at as text first: it is sliced into the two ids
1371    /// later, and only 64 ASCII characters can be.
1372    fn admit(&self, projection: &ArchiveProjection) -> io::Result<()> {
1373        // A store opened by its directory ([`Self::for_scope`]) is of no
1374        // chat: it imports and reads, and has nothing to seal as.
1375        if self.logical.is_empty() {
1376            return Err(invalid("archive store was opened for a project, not a chat: no seal"));
1377        }
1378        let count = projection.source_message_count;
1379        Self::admit_content(
1380            &self.logical,
1381            &projection.logical_id,
1382            count,
1383            &projection.messages,
1384            &projection.digest,
1385        )
1386    }
1387
1388    /// The checks of [`Self::admit`], which an import is put through too,
1389    /// as the chat its record names: the digest is 64 hex characters, the
1390    /// content is of the chat `chat`, and `digest` is the digest of this
1391    /// content, computed here and never taken from where it came from.
1392    fn admit_content(
1393        chat: &str,
1394        logical: &str,
1395        count: usize,
1396        messages: &[ArchivedMessage],
1397        digest: &str,
1398    ) -> io::Result<()> {
1399        if digest.len() != 64 || !digest.bytes().all(|b| b.is_ascii_hexdigit()) {
1400            return Err(invalid("archive projection digest is not 64 hex characters"));
1401        }
1402        if logical != chat {
1403            return Err(invalid("archive projection is of another chat"));
1404        }
1405        if projection_digest(logical, count, messages)? != digest {
1406            return Err(invalid("archive projection digest is not the digest of its content"));
1407        }
1408        Ok(())
1409    }
1410
1411    /// What a seal does once it holds the lock: find `projection` stored, or
1412    /// store it. The lock keeps two seals from doing this at once, but what
1413    /// comes back does not depend on it: a segment is written to a file of
1414    /// this call's own and renamed without replacing, so `Ok` always means
1415    /// that the file under the returned id holds this projection, whole.
1416    ///
1417    /// A projection has two names, the two halves of its digest, and both
1418    /// are looked under before anything is written: a copy under either is
1419    /// the answer, and a tombstone under either refuses the seal, whatever
1420    /// the other name holds. Only then is it written, under the first half
1421    /// if that name is free and under the second if something else has the
1422    /// first. Nothing else in the directory refuses it.
1423    ///
1424    /// `projection` has passed [`Self::admit`]: its digest is 64 ASCII
1425    /// characters, which the slices below rely on.
1426    fn store(
1427        &self,
1428        projection: ArchiveProjection,
1429        written: Written,
1430    ) -> io::Result<(ArchiveRef, String)> {
1431        let ArchiveProjection {
1432            digest,
1433            source_message_count,
1434            messages,
1435            note,
1436            ..
1437        } = projection;
1438        let mut record = Record {
1439            version: 1,
1440            scope: self.scope.clone(),
1441            logical: self.logical.clone(),
1442            id: String::new(),
1443            digest,
1444            created_ms: written.created_ms,
1445            body: Body::Live {
1446                source_message_count,
1447                messages,
1448                note: note.clone(),
1449            },
1450        };
1451        let mut legacy_read = false;
1452        // A name that was free when it was looked at can be taken by the
1453        // time of the rename, by another seal that the lock should have
1454        // kept out. Whatever is there then is looked at once more, like any
1455        // file found under the name: once for each of the two names.
1456        for _ in 0..3 {
1457            let shelves = self.shelves()?;
1458            let (mut stored, mut free) = (None, None);
1459            // Same projection, same names in the same order.
1460            for half in [0..32, 32..64] {
1461                let id = &record.digest[half];
1462                match self.shelved(&shelves, id, &record.digest, false)? {
1463                    Shelved::This(_, found) if matches!(found.body, Body::Tombstone) => {
1464                        return Err(forgotten());
1465                    }
1466                    Shelved::This(shelf, found) => {
1467                        stored.get_or_insert((shelf, found));
1468                    }
1469                    Shelved::Other => {}
1470                    Shelved::Nothing => {
1471                        free.get_or_insert_with(|| id.to_owned());
1472                    }
1473                }
1474            }
1475            if let Some((shelf, found)) = stored {
1476                return Self::already(&shelf, &found);
1477            }
1478            if !legacy_read {
1479                // Sealed before shelves existed, under a random id.
1480                if let Some(found) = self.legacy_projection(&record.digest, false)? {
1481                    return Self::already(&self.fs, &found);
1482                }
1483                legacy_read = true;
1484            }
1485            let Some(id) = free else {
1486                break;
1487            };
1488            record.id = id;
1489            let bytes = bounded_json(&record, MAX_SEGMENT_BYTES)?;
1490            let name = archive_name(&record.id);
1491            // What is about to be written is what a read must accept.
1492            self.readable(&name, &bytes)?;
1493            // Created new: a file that is already there is never renamed over.
1494            let (shelf, _) = self.writable_shelf(&shelves)?;
1495            match shelf.publish_dated(&name, &bytes, true, written.modified) {
1496                Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {}
1497                result => return result.and_then(|()| Ok((record.reference()?, note))),
1498            }
1499        }
1500        Err(io::Error::new(
1501            io::ErrorKind::AlreadyExists,
1502            "both archive ids of this projection are taken by other files",
1503        ))
1504    }
1505
1506    /// The writer's last step before a record gets a file: `bytes`, exactly
1507    /// as they are about to be written under `name`, are put through the
1508    /// reader's own check ([`Self::parsed`]). A record that would not be
1509    /// read back is refused as `InvalidInput` with the name of the check it
1510    /// fails, and nothing is written. [`Self::admit`] checks a projection's
1511    /// digest and chat and no more (not the note's length, the number of
1512    /// messages or their indexes), and a file written past the reader is one
1513    /// no fetch, search or forget accepts, under a name taken for good. With
1514    /// this the writer and the reader cannot disagree, whatever either
1515    /// checks later.
1516    fn readable(&self, name: &str, bytes: &[u8]) -> io::Result<()> {
1517        match Self::parsed(&self.scope, name, bytes) {
1518            Ok(_) => Ok(()),
1519            Err(fault) => Err(io::Error::new(
1520                io::ErrorKind::InvalidInput,
1521                format!("archive record would not be read back and is not written: {fault}"),
1522            )),
1523        }
1524    }
1525
1526    /// What a seal finds under `id` on the shelves, newest first. A shelf
1527    /// name that is not a directory, or a shelf that cannot be opened or made
1528    /// private, is passed over: not followed, not changed, and so not looked
1529    /// in. The price is the one a legacy slot has: a duplicate or a tombstone
1530    /// in such a shelf is not seen, and the projection is stored again. That
1531    /// price is paid only for a shelf that is itself the trouble. An error
1532    /// that will pass ([`PrivateDir::passing`]) fails the seal instead: the
1533    /// shelf is healthy, and what it holds must be seen first.
1534    ///
1535    /// `readonly`, for a dry run of an import: no shelf or file is made
1536    /// private on the way.
1537    ///
1538    /// A file under the name that is not this projection, or is not a file
1539    /// the store reads (a link, a FIFO, a directory, a file that is not ours
1540    /// alone or is too large), has the name taken, and it is left exactly as
1541    /// it is. It does not end the search: an older shelf may hold the
1542    /// projection or its tombstone under the same name, and that is the
1543    /// answer. `Other` is the answer only when no shelf holds either. A read
1544    /// that fails for any other reason says nothing about what the file is,
1545    /// and it may be this very segment: that error fails the seal, which the
1546    /// caller retries, instead of sending the projection to its other name
1547    /// as a second copy.
1548    fn shelved(
1549        &self,
1550        shelves: &[String],
1551        id: &str,
1552        digest: &str,
1553        readonly: bool,
1554    ) -> io::Result<Shelved> {
1555        let name = archive_name(id);
1556        let mut taken = false;
1557        for shelf in shelves {
1558            let dir = match self.fs.open_child(shelf, readonly) {
1559                Ok(Some(dir)) => dir,
1560                Ok(None) => continue,
1561                Err(error) if PrivateDir::passing(&error) => return Err(error),
1562                Err(error) => {
1563                    tracing::warn!(%shelf, %error, "seal passed over an archive shelf");
1564                    continue;
1565                }
1566            };
1567            let raw = match dir.read_with(&name, MAX_SEGMENT_BYTES, readonly) {
1568                Ok(Some(raw)) => raw,
1569                Ok(None) => continue,
1570                Err(error) if PrivateDir::not_a_segment(&error) => {
1571                    tracing::warn!(%shelf, file = %name, %error, "seal found its archive id taken");
1572                    taken = true;
1573                    continue;
1574                }
1575                Err(error) => return Err(error),
1576            };
1577            match self.record(&name, &raw) {
1578                Ok(record) if record.digest == digest => return Ok(Shelved::This(dir, record)),
1579                Err(_) if says_forgotten(&raw, digest) => return Err(forgotten()),
1580                // Another projection, or not a record: never replaced. An
1581                // older shelf is still looked in.
1582                _ => {
1583                    tracing::warn!(%shelf, file = %name, "seal found its archive id taken");
1584                    taken = true;
1585                }
1586            }
1587        }
1588        Ok(if taken { Shelved::Other } else { Shelved::Nothing })
1589    }
1590
1591    /// What a seal returns for a projection that is already stored, as
1592    /// `record` in `dir`: its reference and the note stored with it, or the
1593    /// refusal if it was forgotten.
1594    fn already(dir: &PrivateDir, record: &Record) -> io::Result<(ArchiveRef, String)> {
1595        let Body::Live { note, .. } = &record.body else {
1596            return Err(forgotten());
1597        };
1598        // A previous call may have failed AFTER rename but BEFORE fsync.
1599        dir.sync()?;
1600        Ok((record.reference()?, note.clone()))
1601    }
1602
1603    pub fn fetch(&self, id: &str, start: usize, limit: usize) -> io::Result<Vec<SharedMessage>> {
1604        Ok(self
1605            .fetch_with_provenance(id, start, limit)?
1606            .into_iter()
1607            .map(|m| m.message)
1608            .collect())
1609    }
1610
1611    /// `start` indexes ELIGIBLE messages; source_index retains original offsets.
1612    /// Oversized limits are rejected rather than clamped; start beyond end is empty.
1613    pub fn fetch_with_provenance(
1614        &self,
1615        id: &str,
1616        start: usize,
1617        limit: usize,
1618    ) -> io::Result<Vec<ArchivedMessage>> {
1619        validate_id(id)?;
1620        if limit > MAX_FETCH_MESSAGES {
1621            return Err(invalid("archive fetch limit exceeds 128"));
1622        }
1623        match self.find(id)?.body {
1624            Body::Live { messages, .. } => {
1625                Ok(messages.into_iter().skip(start).take(limit).collect())
1626            }
1627            Body::Tombstone => Err(not_found()),
1628        }
1629    }
1630
1631    /// Explicit host-only access to the hidden, screened note. Never include it
1632    /// in search descriptors or inject it as system/developer instructions.
1633    pub fn fetch_note(&self, id: &str) -> io::Result<String> {
1634        validate_id(id)?;
1635        match self.find(id)?.body {
1636            Body::Live { note, .. } => Ok(note),
1637            Body::Tombstone => Err(not_found()),
1638        }
1639    }
1640
1641    /// Case-insensitive literal substring search of eligible source JSON,
1642    /// newest first. Empty query lists descriptors. Not an exhaustive ranking
1643    /// service: output is bounded by limit, with no hidden-note matches, and
1644    /// the scan by [`SEARCH_SCAN_FILES`] / [`SEARCH_SCAN_BYTES`].
1645    pub fn search(&self, query: &str, limit: usize) -> io::Result<Vec<ArchiveDescriptor>> {
1646        self.search_scan(query, limit).map(|(rows, _)| rows)
1647    }
1648
1649    /// [`search`](Self::search) that also says what it did not read:
1650    /// `None` when nothing it was asked for is missing, otherwise an
1651    /// [`Unscanned`]. Shelves are read newest to oldest, a shelf's files by
1652    /// modification time, the legacy slots last.
1653    ///
1654    /// One place that cannot be read hides only itself. A shelf name that is
1655    /// not a directory that can be opened and listed, a link, a FIFO or a
1656    /// file that is not ours alone where a segment belongs, a legacy slot
1657    /// that cannot be read, and a file that is read and is not a valid
1658    /// record are each passed over, and each is counted in
1659    /// [`Unscanned::unread`]: the rows are what the rest of the project
1660    /// holds, and the caller is told that they are not all of it. Only the
1661    /// project directory itself failing to list fails the search.
1662    ///
1663    /// Shelf files are read without the lock; it is taken, shared and only
1664    /// if there is a lock file, when the search gets as far as reading a
1665    /// legacy slot. Nothing is written either way.
1666    pub fn search_scan(
1667        &self,
1668        query: &str,
1669        limit: usize,
1670    ) -> io::Result<(Vec<ArchiveDescriptor>, Option<Unscanned>)> {
1671        if query.len() > 512 || limit > MAX_SEARCH_RESULTS {
1672            return Err(invalid("archive search query/result budget exceeded"));
1673        }
1674        if limit == 0 {
1675            return Ok((Vec::new(), None));
1676        }
1677        let query = query.to_lowercase();
1678        self.check_abort()?;
1679        let (shelves, mut slots) = self.layout()?;
1680        let mut rows = Vec::new();
1681        let (mut files, mut bytes) = (0, 0);
1682        let mut legacy_lock = None;
1683        let mut unread = Unread::default();
1684        // A full page and the end of the scan have nothing more to report
1685        // than what was passed over on the way; the budget adds its count.
1686        let done = |rows: Vec<ArchiveDescriptor>, stopped_after: Option<usize>, unread: &Unread| {
1687            let unread = unread.notice();
1688            let unscanned = (stopped_after.is_some() || unread.is_some()).then_some(Unscanned {
1689                stopped_after,
1690                unread,
1691            });
1692            Ok::<_, io::Error>((rows, unscanned))
1693        };
1694        // `None` is the project directory itself: the legacy slots, oldest of all.
1695        for shelf in shelves.iter().map(Some).chain([None]) {
1696            let opened;
1697            let (dir, names) = match shelf {
1698                Some(shelf) => {
1699                    let listed = self.fs.open_child(shelf, true).and_then(|dir| match dir {
1700                        Some(dir) => Ok(Some((dir.names()?, dir))),
1701                        None => Ok(None),
1702                    });
1703                    let (mut entries, dir) = match listed {
1704                        Ok(Some(listed)) => listed,
1705                        Ok(None) => continue,
1706                        // The rest of the project is still searched.
1707                        Err(error) => {
1708                            unread.pass(shelf, error);
1709                            continue;
1710                        }
1711                    };
1712                    entries.retain(|(name, ..)| archive_id(name).is_some());
1713                    // Modification time only orders the files of one shelf;
1714                    // nothing else depends on it.
1715                    entries.sort_unstable_by(|a, b| b.2.cmp(&a.2).then_with(|| a.0.cmp(&b.0)));
1716                    opened = dir;
1717                    let names: Vec<_> = entries.into_iter().map(|(name, ..)| name).collect();
1718                    (&opened, names)
1719                }
1720                None => (&self.fs, std::mem::take(&mut slots)),
1721            };
1722            for name in names {
1723                if rows.len() == limit {
1724                    return done(rows, None, &unread);
1725                }
1726                if files == SEARCH_SCAN_FILES || bytes >= SEARCH_SCAN_BYTES {
1727                    return done(rows, Some(files), &unread);
1728                }
1729                if shelf.is_none() && legacy_lock.is_none() {
1730                    legacy_lock = Some(self.shared()?);
1731                }
1732                let place = || match shelf {
1733                    Some(shelf) => format!("{shelf}/{name}"),
1734                    None => name.clone(),
1735                };
1736                let raw = match dir.read_with(&name, MAX_SEGMENT_BYTES, true) {
1737                    Ok(Some(raw)) => raw,
1738                    Ok(None) => continue,
1739                    // Too large to be a segment, a link, a FIFO, a file that
1740                    // is not ours alone, a read that failed: it hides only
1741                    // itself. It still counts against the scan budget.
1742                    Err(error) => {
1743                        unread.pass(&place(), error);
1744                        files += 1;
1745                        continue;
1746                    }
1747                };
1748                files += 1;
1749                bytes += raw.len();
1750                match self.record(&name, &raw) {
1751                    Ok(record) => rows.extend(describe(record, &query)?),
1752                    Err(error) => unread.pass(&place(), error),
1753                }
1754            }
1755        }
1756        done(rows, None, &unread)
1757    }
1758
1759    /// Durable, idempotent deletion of the content of segment `id`, in every
1760    /// file that holds it. Each is replaced, under the same name, by a
1761    /// marker; the id and digest are never freed for reuse. Unknown ids are
1762    /// NotFound. This is logical deletion, not secure erasure of filesystem
1763    /// snapshots. The only operation that replaces a file.
1764    ///
1765    /// The store can hold one projection more than once: under each half of
1766    /// its digest, under one id on two shelves (stored again while a shelf
1767    /// could not be read), in a legacy slot, under an id an import kept. So
1768    /// forget goes by content. The record found under `id` names the
1769    /// projection, and every live record of it becomes a tombstone: under
1770    /// `id` and under both halves of the digest on every shelf that can be
1771    /// opened, and in every legacy slot that can be read.
1772    ///
1773    /// When it returns, a tombstone of the projection is on a shelf under
1774    /// one of the two ids its digest gives it, which is where a later seal
1775    /// of the same window looks. If the projection was only stored under
1776    /// another id (a legacy slot's, an import's), that tombstone is written
1777    /// new, on the newest shelf. It is written before any other file is
1778    /// replaced, and a copy under one of those two ids is replaced before
1779    /// the others: an error or a crash part-way leaves the content refused
1780    /// to a seal, and forgetting again finishes. An id that is already a
1781    /// tombstone is not an early return for the same reason.
1782    ///
1783    /// A shelf that cannot be opened, and a legacy slot or a file under one
1784    /// of the names that cannot be read or is not a valid record (and does
1785    /// not say "forgotten" for this projection), may hold a copy that was
1786    /// not reached: a damaged copy can still have the window's text in it.
1787    /// That is never silent: [`Forgotten::unread`] says so, and forgetting
1788    /// again when the place can be read reaches it. A file under `id` on a
1789    /// newer shelf that is not a valid record is such a place too: the valid
1790    /// record under `id` on an older shelf is still found and forgotten.
1791    pub fn forget(&self, id: &str) -> io::Result<Forgotten> {
1792        validate_id(id)?;
1793        let _lock = self.exclusive()?;
1794        self.forget_locked(id)
1795    }
1796
1797    /// The tombstone a seal of `digest` will find: written, create-new, on
1798    /// the newest shelf, under the first of the digest's two ids that no
1799    /// shelf has a file under (`taken` says which have one). For content
1800    /// that is forgotten while it is stored under neither of those ids: a
1801    /// legacy slot, an archive that was imported with the id it had, or a
1802    /// tombstone that an import brings. With both names taken by other
1803    /// files there is none to write and none is needed, because a seal has
1804    /// no name to store under. The caller holds the lock, has found no
1805    /// tombstone or copy of the digest under either id, and writes this
1806    /// BEFORE the forget it belongs to: stopped in between, the content is
1807    /// still there and already refused to a seal, and the forget is run
1808    /// again.
1809    fn write_guard(
1810        &self,
1811        shelves: &[String],
1812        logical: &str,
1813        digest: &str,
1814        taken: [bool; 2],
1815    ) -> io::Result<()> {
1816        for (index, id) in [&digest[..32], &digest[32..]].into_iter().enumerate() {
1817            if taken[index] {
1818                continue;
1819            }
1820            let guard = Record {
1821                version: 1,
1822                scope: self.scope.clone(),
1823                logical: logical.to_owned(),
1824                id: id.to_owned(),
1825                digest: digest.to_owned(),
1826                created_ms: None,
1827                body: Body::Tombstone,
1828            };
1829            let bytes = bounded_json(&guard, MAX_SEGMENT_BYTES)?;
1830            let name = archive_name(id);
1831            self.readable(&name, &bytes)?;
1832            let (shelf, _) = self.writable_shelf(shelves)?;
1833            match shelf.publish(&name, &bytes, true) {
1834                Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {}
1835                result => return result,
1836            }
1837        }
1838        Ok(())
1839    }
1840
1841    /// [`forget`](Self::forget) for a caller that holds the lock: an import
1842    /// that was given a tombstone for an archive the store holds live.
1843    fn forget_locked(&self, id: &str) -> io::Result<Forgotten> {
1844        let (shelves, slots) = self.layout()?;
1845        // The record asked for says which content to forget.
1846        let mut unread = Unread::default();
1847        let asked = match self.locate(&shelves, id, false, &mut unread)? {
1848            Some(found) => found.record,
1849            None => match self.legacy_segment(&slots, id, false, &mut unread) {
1850                Some((_, record)) => record,
1851                None => return Err(unread.not_found()),
1852            },
1853        };
1854        let digest = asked.digest.as_str();
1855        let derived = [&digest[..32], &digest[32..]];
1856        let mut names: Vec<_> = derived.iter().map(|id| archive_name(id)).collect();
1857        if !derived.contains(&id) {
1858            names.push(archive_name(id));
1859        }
1860        // Every copy that can be read, as (shelf or, for a slot, none; file
1861        // name; record), and the places that could not be: counted afresh,
1862        // each once.
1863        let mut unread = Unread::default();
1864        let mut copies = Vec::new();
1865        // Whether a seal already finds a tombstone under one of its two
1866        // names, and which of the two names something has.
1867        let (mut guarded, mut taken) = (false, [false; 2]);
1868        for shelf in &shelves {
1869            let dir = match self.fs.open_child(shelf, false) {
1870                Ok(Some(dir)) => dir,
1871                Ok(None) => continue,
1872                Err(error) => {
1873                    unread.pass(shelf, error);
1874                    continue;
1875                }
1876            };
1877            for (index, name) in names.iter().enumerate() {
1878                let raw = match dir.read_with(name, MAX_SEGMENT_BYTES, false) {
1879                    Ok(Some(raw)) => Some(raw),
1880                    Ok(None) => continue,
1881                    Err(error) => {
1882                        unread.pass(&format!("{shelf}/{name}"), error);
1883                        None
1884                    }
1885                };
1886                if let Some(held) = taken.get_mut(index) {
1887                    *held = true;
1888                }
1889                let Some(raw) = raw else {
1890                    continue;
1891                };
1892                match self.record(name, &raw) {
1893                    Ok(record) if record.digest == digest => {
1894                        copies.push((Some(shelf), name.clone(), record));
1895                    }
1896                    // Another projection: not a copy.
1897                    Ok(_) => {}
1898                    // What a seal of this projection takes for a tombstone.
1899                    Err(_) if says_forgotten(&raw, digest) => guarded |= index < 2,
1900                    // Not a record, under the window's own name: it may be
1901                    // a damaged copy, with the window's text still in it.
1902                    Err(error) => unread.pass(&format!("{shelf}/{name}"), error),
1903                }
1904            }
1905            // A copy under an id that is none of the three: an archive that
1906            // was imported keeps the id it came with, and the same content
1907            // can be here under any number of those. No name says which
1908            // files they are, so every segment file of the shelf is read
1909            // for its digest.
1910            for (name, record) in self.twins(&dir, shelf, digest, &names, false, &mut unread) {
1911                copies.push((Some(shelf), name, record));
1912            }
1913        }
1914        for slot in &slots {
1915            let raw = self.fs.read_with(slot, MAX_SEGMENT_BYTES, false);
1916            match raw.and_then(|raw| raw.map(|raw| self.record(slot, &raw)).transpose()) {
1917                Ok(Some(record)) if record.digest == digest => {
1918                    copies.push((None, slot.clone(), record));
1919                }
1920                Ok(_) => {}
1921                // A legacy id is not in the file name: this may be a copy.
1922                Err(error) => unread.pass(slot, error),
1923            }
1924        }
1925        // The copies a seal looks under come first.
1926        let seen_by_a_seal = |(shelf, name, _): &(Option<&String>, String, Record)| {
1927            shelf.is_some() && archive_id(name).is_some_and(|id| derived.contains(&id))
1928        };
1929        copies.sort_by_key(|copy| !seen_by_a_seal(copy));
1930        guarded |= copies.first().is_some_and(seen_by_a_seal);
1931        if !guarded {
1932            // Stored under no name a seal looks under: the tombstone it will
1933            // find, before anything is replaced.
1934            self.write_guard(&shelves, &asked.logical, digest, taken)?;
1935        }
1936        let mut ids = Vec::new();
1937        for (shelf, name, mut record) in copies {
1938            let opened;
1939            let dir = match shelf {
1940                Some(shelf) => {
1941                    opened = self.fs.open_child(shelf, false)?.ok_or_else(not_found)?;
1942                    &opened
1943                }
1944                None => &self.fs,
1945            };
1946            if matches!(record.body, Body::Tombstone) {
1947                // A previous call may have failed after rename, before fsync.
1948                dir.sync()?;
1949                continue;
1950            }
1951            // A tombstone keeps what names the record and nothing else: not
1952            // the content, and not when it was written.
1953            record.body = Body::Tombstone;
1954            record.created_ms = None;
1955            dir.publish(&name, &bounded_json(&record, MAX_SEGMENT_BYTES)?, false)?;
1956            ids.push(record.id);
1957        }
1958        ids.sort_by_key(|forgotten| forgotten != id);
1959        Ok(Forgotten {
1960            ids,
1961            unread: unread.notice_in(&self.directory),
1962        })
1963    }
1964
1965    /// The records of `digest` on the shelf `dir` under file names other
1966    /// than `names`, each with its file name: every segment file of the
1967    /// shelf is read for its digest. What may be such a record and could
1968    /// not be read as one goes to `unread`. `readonly` changes nothing on
1969    /// disk.
1970    fn twins(
1971        &self,
1972        dir: &PrivateDir,
1973        shelf: &str,
1974        digest: &str,
1975        names: &[String],
1976        readonly: bool,
1977        unread: &mut Unread,
1978    ) -> Vec<(String, Record)> {
1979        let listed = match dir.names() {
1980            Ok(listed) => listed,
1981            Err(error) => {
1982                unread.pass(shelf, error);
1983                return Vec::new();
1984            }
1985        };
1986        let mut twins = Vec::new();
1987        for (name, is_dir, _) in listed {
1988            if is_dir || archive_id(&name).is_none() || names.contains(&name) {
1989                continue;
1990            }
1991            let raw = match dir.read_with(&name, MAX_SEGMENT_BYTES, readonly) {
1992                Ok(Some(raw)) => raw,
1993                // Gone since it was listed, or not a file the store reads
1994                // (a link, a FIFO, closed to us, too large): no fetch gives
1995                // out what is in it. Passed over.
1996                Ok(None) => continue,
1997                Err(error) if PrivateDir::not_a_segment(&error) => continue,
1998                // A read that failed says nothing about what the file is:
1999                // it may be a live copy.
2000                Err(error) => {
2001                    unread.pass(&format!("{shelf}/{name}"), error);
2002                    continue;
2003                }
2004            };
2005            // A file that holds another digest, or none, is not a copy.
2006            if !names_digest(&raw, digest) {
2007                continue;
2008            }
2009            match self.record(&name, &raw) {
2010                Ok(record) => twins.push((name, record)),
2011                Err(_) if says_forgotten(&raw, digest) => {}
2012                // It names this content and is not a record: it may be
2013                // a damaged copy.
2014                Err(error) => unread.pass(&format!("{shelf}/{name}"), error),
2015            }
2016        }
2017        twins
2018    }
2019
2020    /// The id of a live record of `digest` on a shelf under an id that is
2021    /// not one of `names`, found by the scan a forget uses
2022    /// ([`Self::twins`]). A shelf or a file that may hold one and could not
2023    /// be read is an error: "none" would be a guess.
2024    fn live_twin(
2025        &self,
2026        shelves: &[String],
2027        digest: &str,
2028        names: &[String],
2029        readonly: bool,
2030    ) -> io::Result<Option<String>> {
2031        let mut unread = Unread::default();
2032        let mut live = None;
2033        for shelf in shelves {
2034            let dir = match self.fs.open_child(shelf, readonly) {
2035                Ok(Some(dir)) => dir,
2036                Ok(None) => continue,
2037                Err(error) => {
2038                    unread.pass(shelf, error);
2039                    continue;
2040                }
2041            };
2042            for (_, record) in self.twins(&dir, shelf, digest, names, readonly, &mut unread) {
2043                if matches!(record.body, Body::Live { .. }) {
2044                    live.get_or_insert(record.id);
2045                }
2046            }
2047        }
2048        match (live, unread.notice()) {
2049            (Some(id), _) => Ok(Some(id)),
2050            (None, None) => Ok(None),
2051            (None, Some(notice)) => Err(io::Error::other(format!(
2052                "a tombstone is not imported while a file that may hold its content live                  cannot be read: {notice}"
2053            ))),
2054        }
2055    }
2056
2057    /// Open the project directory named `scope` (64 lowercase hex
2058    /// characters, what a record's `scope` is), for an import. Creates what
2059    /// is missing and makes it private, as [`new`](Self::new) does. The
2060    /// store is of no chat: it imports, reads and forgets, and a seal
2061    /// through it is refused.
2062    pub fn for_scope(base: &Path, scope: &str) -> io::Result<Self> {
2063        if !is_scope(scope) {
2064            return Err(invalid("archive scope must be 64 lowercase hex characters"));
2065        }
2066        let fs = PrivateDir::new(base, scope)?;
2067        Ok(Self::opened(fs, base, scope.to_owned(), String::new()))
2068    }
2069
2070    /// [`for_scope`](Self::for_scope) that creates and changes nothing, as
2071    /// [`open_existing`](Self::open_existing): `None` when the project has
2072    /// no directory. What a dry run of an import opens.
2073    pub fn existing_scope(base: &Path, scope: &str) -> io::Result<Option<Self>> {
2074        if !is_scope(scope) {
2075            return Err(invalid("archive scope must be 64 lowercase hex characters"));
2076        }
2077        match PrivateDir::open_existing(base, scope) {
2078            Ok(fs) => Ok(Some(Self::opened(fs, base, scope.to_owned(), String::new()))),
2079            Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
2080            Err(error) => Err(error),
2081        }
2082    }
2083
2084    /// The project directory's name: the `scope` of every record in it.
2085    pub fn scope(&self) -> &str {
2086        &self.scope
2087    }
2088
2089    /// Bring one archive record into this project, as it is: `line` is the
2090    /// JSON object an archive file holds. It is stored under its own id
2091    /// with its own `logical`, `digest`, `created_ms`, messages and note;
2092    /// nothing is recomputed to make it fit. The one field an import may
2093    /// change is `scope`: with `rehome` it is set to this project's,
2094    /// whatever the line says (the scope is not in the digest); without,
2095    /// a record of another project is refused.
2096    ///
2097    /// The outer error is the machine's (I/O, the lock held too long, a
2098    /// file under the id that cannot be read as a record, a shelf or slot
2099    /// that cannot be read and may hold the id): stop, and run again
2100    /// later. The inner one is the record's ([`ImportRefused`]): nothing was
2101    /// written for it. What is refused: a line that is not exactly a
2102    /// record; whatever a read of the store refuses, by the store's own
2103    /// check on the bytes that would be written, with the check's name; a
2104    /// digest that is not the digest of the content, computed here; a
2105    /// `message` that is not an object; an id that holds other content;
2106    /// live content that was forgotten in this store; a tombstone of
2107    /// content this store holds live under another id.
2108    ///
2109    /// An id is looked up as a fetch looks it up (every shelf, then the
2110    /// legacy slots), a digest as a seal does (its two halves on every
2111    /// shelf, then the slots). Importing a line a second time is
2112    /// [`ImportOutcome::Present`] and changes nothing. No file is ever
2113    /// replaced, except by the forget of [`ImportOutcome::Forgotten`]. A
2114    /// live record with `created_ms` gets that as its file's time.
2115    ///
2116    /// `dry_run` writes nothing at all (no file, shelf, lock file or mode)
2117    /// and answers what the real run would, given `seen`, which carries
2118    /// what the earlier lines of the run would have written. For a project
2119    /// that has no directory yet, see [`Self::import_dry_absent`].
2120    pub fn import(
2121        &self,
2122        line: &[u8],
2123        rehome: bool,
2124        dry_run: bool,
2125        seen: &mut ImportSeen,
2126    ) -> io::Result<Result<Imported, ImportRefused>> {
2127        Self::import_in(Some(self), &self.scope, line, rehome, dry_run, seen)
2128    }
2129
2130    /// The dry run of [`import`](Self::import) into the project directory
2131    /// `scope` when it does not exist ([`existing_scope`](Self::existing_scope)
2132    /// is `None`): an empty store, and nothing is created.
2133    pub fn import_dry_absent(
2134        scope: &str,
2135        line: &[u8],
2136        rehome: bool,
2137        seen: &mut ImportSeen,
2138    ) -> io::Result<Result<Imported, ImportRefused>> {
2139        if !is_scope(scope) {
2140            return Err(invalid("archive scope must be 64 lowercase hex characters"));
2141        }
2142        Self::import_in(None, scope, line, rehome, true, seen)
2143    }
2144
2145    fn import_in(
2146        store: Option<&Self>,
2147        scope: &str,
2148        line: &[u8],
2149        rehome: bool,
2150        dry_run: bool,
2151        seen: &mut ImportSeen,
2152    ) -> io::Result<Result<Imported, ImportRefused>> {
2153        let refuse = |reason: String, id: Option<&str>| {
2154            Ok(Err(ImportRefused {
2155                reason,
2156                id: id.map(str::to_owned),
2157            }))
2158        };
2159        let mut record: Record = match serde_json::from_slice(line) {
2160            Ok(record) => record,
2161            Err(error) => return refuse(Fault::Parse(error).to_string(), None),
2162        };
2163        let id = validate_id(&record.id).is_ok().then(|| record.id.clone());
2164        let id = id.as_deref();
2165        if !is_scope(&record.scope) {
2166            return refuse("invalid scope".to_owned(), id);
2167        }
2168        if rehome {
2169            scope.clone_into(&mut record.scope);
2170        }
2171        // The bytes that will be the file, and the store's own check of
2172        // them: what is validated is what is written.
2173        let Ok(bytes) = bounded_json(&record, MAX_SEGMENT_BYTES) else {
2174            return refuse("over the 16 MiB archive size".to_owned(), id);
2175        };
2176        let name = archive_name(&record.id);
2177        let record = match Self::parsed(scope, &name, &bytes) {
2178            Ok(record) => record,
2179            Err(fault) => return refuse(fault.to_string(), id),
2180        };
2181        // A seal's digest is lowercase, and so are the two ids cut from it.
2182        if !lower_hex(&record.digest) {
2183            return refuse(Fault::Digest.to_string(), id);
2184        }
2185        let mut modified = None;
2186        if let Body::Live {
2187            source_message_count,
2188            messages,
2189            ..
2190        } = &record.body
2191        {
2192            if !messages.iter().all(|archived| archived.message.is_object()) {
2193                return refuse("message is not an object".to_owned(), id);
2194            }
2195            // As a seal is admitted, as the chat the record names.
2196            let (logical, digest) = (&record.logical, &record.digest);
2197            let admitted =
2198                Self::admit_content(logical, logical, *source_message_count, messages, digest);
2199            if let Err(error) = admitted {
2200                return refuse(error.to_string(), id);
2201            }
2202            if let Some(created_ms) = record.created_ms {
2203                // A time that has not come is no time of an archive. Its
2204                // file would be the newest on its shelf in every search,
2205                // for good. A day allows for another machine's clock.
2206                let now = Written::now().created_ms.unwrap_or(0);
2207                if created_ms > now.saturating_add(CREATED_AHEAD_MS) {
2208                    let reason = "created_ms is more than a day after this machine's clock";
2209                    return refuse(reason.to_owned(), id);
2210                }
2211                let time = std::time::UNIX_EPOCH.checked_add(Duration::from_millis(created_ms));
2212                let Some(time) = time else {
2213                    return refuse("created_ms is not a time a file can have".to_owned(), id);
2214                };
2215                modified = Some(time);
2216            }
2217        }
2218        let incoming = match &record.body {
2219            Body::Live { note, .. } => Some(note.as_str()),
2220            Body::Tombstone => None,
2221        };
2222        let message_count = match &record.body {
2223            Body::Live { messages, .. } => messages.len(),
2224            Body::Tombstone => 0,
2225        };
2226        let digest = record.digest.as_str();
2227        let mut plan = match dry_run {
2228            true => Some(seen.projects.entry(scope.to_owned()).or_default()),
2229            false => None,
2230        };
2231        // A real run holds the lock from here to the end. A dry run reads
2232        // as a fetch reads: the lock shared, and only for the legacy slots.
2233        let mut _lock = None;
2234        let (shelves, slots) = match (store, dry_run) {
2235            (Some(store), false) => {
2236                _lock = Some(store.exclusive()?);
2237                store.layout()?
2238            }
2239            (Some(store), true) => {
2240                store.check_abort()?;
2241                let layout = store.layout()?;
2242                if !layout.1.is_empty() {
2243                    _lock = Some(store.shared()?);
2244                }
2245                layout
2246            }
2247            (None, true) => Default::default(),
2248            (None, false) => return Err(invalid("an import that writes needs a store")),
2249        };
2250
2251        // What is under the id, looked up as a fetch and a forget do.
2252        let mut found = plan.as_ref().and_then(|plan| plan.ids.get(&record.id).cloned());
2253        if let (None, Some(store)) = (&found, store) {
2254            let mut unread = Unread::default();
2255            let located = match store.locate(&shelves, &record.id, dry_run, &mut unread)? {
2256                Some(located) if located.forgotten => {
2257                    return Err(io::Error::new(
2258                        io::ErrorKind::InvalidData,
2259                        "a damaged file under this archive id says its content was forgotten, \
2260                         over a valid record on an older shelf",
2261                    ));
2262                }
2263                Some(located) => Some((Some(located.shelf), located.record)),
2264                None => store
2265                    .legacy_segment(&slots, &record.id, dry_run, &mut unread)
2266                    .map(|(_, record)| (None, record)),
2267            };
2268            if located.is_none() && unread.places > 0 {
2269                // The id may be in what could not be read.
2270                return Err(unread.not_found());
2271            }
2272            found = located.map(|(shelf, held)| Held {
2273                digest: held.digest,
2274                note: match held.body {
2275                    Body::Live { note, .. } => Some(note),
2276                    Body::Tombstone => None,
2277                },
2278                shelf,
2279            });
2280        }
2281        if let (Some(held), Some(plan)) = (&mut found, &plan) {
2282            if plan.forgotten.contains(&held.digest) {
2283                held.note = None;
2284            }
2285        }
2286        let imported = |outcome, shelf, same_content_as, unread| {
2287            Ok(Ok(Imported {
2288                outcome,
2289                id: record.id.clone(),
2290                live: incoming.is_some(),
2291                message_count,
2292                shelf,
2293                same_content_as,
2294                unread,
2295            }))
2296        };
2297        if let Some(held) = found {
2298            if held.digest != digest {
2299                return refuse("id already holds different content".to_owned(), id);
2300            }
2301            let mut unread = None;
2302            let outcome = match (held.note.as_deref(), incoming) {
2303                (Some(stored), Some(note)) => ImportOutcome::Present {
2304                    note_differs: stored != note,
2305                },
2306                (None, None) => ImportOutcome::Present { note_differs: false },
2307                (None, Some(_)) => ImportOutcome::KeptForgotten,
2308                (Some(_), None) => {
2309                    match (&mut plan, store) {
2310                        (Some(plan), _) => {
2311                            // What the store's forget would write: the
2312                            // tombstone a seal finds, if none is there.
2313                            let probe =
2314                                Self::probe(store, Some(&**plan), &shelves, digest, true)?;
2315                            if let Some(free) = probe.guard(digest) {
2316                                let shelf = Some(plan.shelve(store, &shelves));
2317                                let guard = Held {
2318                                    digest: digest.to_owned(),
2319                                    note: None,
2320                                    shelf,
2321                                };
2322                                plan.ids.insert(free, guard);
2323                            }
2324                            plan.forgotten.insert(digest.to_owned());
2325                            for planned in plan.ids.values_mut() {
2326                                if planned.digest == digest {
2327                                    planned.note = None;
2328                                }
2329                            }
2330                        }
2331                        (None, Some(store)) => unread = store.forget_locked(&record.id)?.unread,
2332                        (None, None) => {}
2333                    }
2334                    ImportOutcome::Forgotten
2335                }
2336            };
2337            if let (false, Some(store)) = (dry_run, store) {
2338                if outcome != ImportOutcome::Forgotten {
2339                    // "It is here already" is an answer about the disk. An
2340                    // earlier run may have been cut after its rename and
2341                    // before the directory was synced: confirmed now, as a
2342                    // forget confirms a tombstone it finds.
2343                    match &held.shelf {
2344                        Some(shelf) => {
2345                            store.fs.open_child(shelf, false)?.ok_or_else(not_found)?.sync()?;
2346                        }
2347                        None => store.fs.sync()?,
2348                    }
2349                }
2350            }
2351            return imported(outcome, held.shelf, None, unread);
2352        }
2353
2354        // Nothing under the id. Where a seal would look for the content:
2355        let mut probe = Self::probe(store, plan.as_deref(), &shelves, digest, dry_run)?;
2356        let mut same_content_as = None;
2357        if incoming.is_some() {
2358            if probe.forgotten {
2359                return refuse("this content was forgotten in this store".to_owned(), id);
2360            }
2361            same_content_as = probe.live.take();
2362        } else if let Some(other) =
2363            Self::live_copy(store, plan.as_deref(), &probe, &shelves, &record, dry_run)?
2364        {
2365            // A tombstone for content that is live here under another id.
2366            // Publishing it would leave that copy readable beside it, and
2367            // forgetting a record the line does not name is not this
2368            // line's to decide.
2369            let reason = format!(
2370                "a tombstone of content that is live in this store as {other}: forget that \
2371                 archive here, then import again"
2372            );
2373            return refuse(reason, id);
2374        }
2375        // A tombstone under an id a seal does not look under: the one it
2376        // will find comes first, by the step a forget uses.
2377        let derived = record.id == digest[..32] || record.id == digest[32..];
2378        let guard = match incoming.is_none() && !derived {
2379            true => probe.guard(digest),
2380            false => None,
2381        };
2382        let shelf = match (plan, store) {
2383            (Some(plan), _) => {
2384                if let Some(guard) = guard {
2385                    let held = Held {
2386                        digest: digest.to_owned(),
2387                        note: None,
2388                        shelf: Some(plan.shelve(store, &shelves)),
2389                    };
2390                    plan.ids.insert(guard, held);
2391                }
2392                let shelf = plan.shelve(store, &shelves);
2393                let held = Held {
2394                    digest: digest.to_owned(),
2395                    note: incoming.map(str::to_owned),
2396                    shelf: Some(shelf.clone()),
2397                };
2398                plan.ids.insert(record.id.clone(), held);
2399                shelf
2400            }
2401            (None, Some(store)) => {
2402                let mut shelves = shelves;
2403                if guard.is_some() {
2404                    store.write_guard(&shelves, &record.logical, digest, probe.taken)?;
2405                    shelves = store.shelves()?;
2406                }
2407                // Created new, under its own id: never renamed over a file.
2408                let (dir, shelf) = store.writable_shelf(&shelves)?;
2409                dir.publish_dated(&name, &bytes, true, modified)?;
2410                shelf
2411            }
2412            (None, None) => return Err(invalid("an import that writes needs a store")),
2413        };
2414        imported(ImportOutcome::Stored, Some(shelf), same_content_as, None)
2415    }
2416
2417    /// The id of a live copy of the content of the tombstone `record`,
2418    /// whose own id holds nothing: where a seal would look (`probe`), then
2419    /// what the run would have written (`plan`), then every archive file on
2420    /// the shelves, as a forget looks ([`Self::live_twin`]).
2421    fn live_copy(
2422        store: Option<&Self>,
2423        plan: Option<&Planned>,
2424        probe: &Probe,
2425        shelves: &[String],
2426        record: &Record,
2427        readonly: bool,
2428    ) -> io::Result<Option<String>> {
2429        let digest = record.digest.as_str();
2430        if probe.live.is_some() {
2431            return Ok(probe.live.clone());
2432        }
2433        if let Some(plan) = plan {
2434            if plan.forgotten.contains(digest) {
2435                // The run would have forgotten every copy by now.
2436                return Ok(None);
2437            }
2438            let live = |(_, held): &(&String, &Held)| held.digest == digest && held.note.is_some();
2439            if let Some((id, _)) = plan.ids.iter().filter(live).min_by_key(|(id, _)| *id) {
2440                return Ok(Some(id.clone()));
2441            }
2442        }
2443        let Some(store) = store else {
2444            return Ok(None);
2445        };
2446        let names = [&digest[..32], &digest[32..], &record.id].map(archive_name);
2447        store.live_twin(shelves, digest, &names, readonly)
2448    }
2449
2450    /// What is where a seal would look for `digest`, by the lookups a seal
2451    /// uses ([`Self::shelved`], [`Self::legacy_projection`]), and in a dry
2452    /// run also what the run would have written (`plan`).
2453    fn probe(
2454        store: Option<&Self>,
2455        plan: Option<&Planned>,
2456        shelves: &[String],
2457        digest: &str,
2458        readonly: bool,
2459    ) -> io::Result<Probe> {
2460        let mut probe = Probe::default();
2461        for (index, half) in [0..32, 32..64].into_iter().enumerate() {
2462            let id = &digest[half];
2463            probe.taken[index] = true;
2464            let shelved = match store {
2465                Some(store) => store.shelved(shelves, id, digest, readonly),
2466                None => Ok(Shelved::Nothing),
2467            };
2468            match shelved {
2469                Ok(Shelved::This(_, record)) => {
2470                    probe.held = true;
2471                    match record.body {
2472                        Body::Tombstone => probe.forgotten = true,
2473                        Body::Live { .. } => {
2474                            probe.live.get_or_insert(record.id);
2475                        }
2476                    }
2477                }
2478                Ok(Shelved::Other) => {}
2479                Ok(Shelved::Nothing) => match plan.and_then(|plan| plan.ids.get(id)) {
2480                    Some(planned) if planned.digest == digest => {
2481                        probe.held = true;
2482                        match planned.note {
2483                            None => probe.forgotten = true,
2484                            Some(_) => {
2485                                probe.live.get_or_insert_with(|| id.to_owned());
2486                            }
2487                        }
2488                    }
2489                    Some(_) => {}
2490                    None => probe.taken[index] = false,
2491                },
2492                Err(error) if is_forgotten(&error) => {
2493                    probe.held = true;
2494                    probe.forgotten = true;
2495                }
2496                Err(error) => return Err(error),
2497            }
2498        }
2499        if let Some(store) = store {
2500            match store.legacy_projection(digest, readonly) {
2501                Ok(Some(record)) => match record.body {
2502                    Body::Tombstone => probe.forgotten = true,
2503                    Body::Live { .. } => {
2504                        probe.live.get_or_insert(record.id);
2505                    }
2506                },
2507                Ok(None) => {}
2508                Err(error) if is_forgotten(&error) => probe.forgotten = true,
2509                Err(error) => return Err(error),
2510            }
2511        }
2512        if plan.is_some_and(|plan| plan.forgotten.contains(digest)) {
2513            probe.forgotten = true;
2514            probe.live = None;
2515        }
2516        Ok(probe)
2517    }
2518
2519    /// Shelf names in the project directory, newest first.
2520    fn shelves(&self) -> io::Result<Vec<String>> {
2521        Ok(self.layout()?.0)
2522    }
2523
2524    /// The project directory's shelf names and legacy slot files, each newest
2525    /// first. One directory listing; nothing is opened, so a shelf name here
2526    /// may turn out to be a file or a link, or a directory that cannot be
2527    /// opened. A seal passes over such a name, a fetch reports it if it
2528    /// finds its segment nowhere else, a search passes over it and says so
2529    /// with its rows, and an export fails on it.
2530    fn layout(&self) -> io::Result<(Vec<String>, Vec<String>)> {
2531        let (mut shelves, mut slots) = (Vec::new(), Vec::new());
2532        for (name, ..) in self.fs.names()? {
2533            if let Some(number) = shelf_number(&name) {
2534                shelves.push((number, name));
2535            } else if slot_number(&name).is_some() {
2536                slots.push(name);
2537            }
2538        }
2539        shelves.sort_unstable();
2540        slots.sort_unstable();
2541        let shelves = shelves.into_iter().rev().map(|(_, name)| name).collect();
2542        slots.reverse();
2543        Ok((shelves, slots))
2544    }
2545
2546    /// The shelf the next segment goes to: the newest one, or a new one after
2547    /// it once it holds [`SHELF_FILES`]. An empty shelf left by a crash is
2548    /// used. When the newest shelf name is not a directory that can be
2549    /// opened and made private, nothing is written to it and the segment
2550    /// goes to a new shelf, numbered after it. The shelf comes with its
2551    /// name. An error that will pass
2552    /// fails the seal instead of starting a shelf. A new shelf that was made
2553    /// and then could not be opened stays, empty, and is the newest shelf of
2554    /// the next seal: one failed attempt after another leaves that one.
2555    fn writable_shelf(&self, shelves: &[String]) -> io::Result<(PrivateDir, String)> {
2556        let highest = shelves.first().and_then(|name| shelf_number(name));
2557        let highest = highest.unwrap_or(0);
2558        // With no number left for a new shelf, the newest shelf that can be
2559        // written to takes more than its share rather than refuse.
2560        let last = highest >= LAST_SHELF;
2561        for (index, name) in shelves.iter().enumerate() {
2562            // `child` also makes the shelf's own directory entry durable.
2563            let shelf = self.fs.child(name, true);
2564            match shelf.and_then(|shelf| Ok((shelf.names()?, shelf))) {
2565                Ok((names, shelf)) => {
2566                    sweep(&shelf, &names);
2567                    let held = names.iter().filter(|(name, ..)| name.ends_with(".json"));
2568                    if last || (index == 0 && held.count() < SHELF_FILES) {
2569                        return Ok((shelf, name.clone()));
2570                    }
2571                    break;
2572                }
2573                // The shelf is not the trouble: no other one is started
2574                // because of it.
2575                Err(error) if PrivateDir::passing(&error) => return Err(error),
2576                Err(error) => {
2577                    tracing::warn!(shelf = %name, %error, "seal passed over an archive shelf");
2578                    if !last {
2579                        break;
2580                    }
2581                }
2582            }
2583        }
2584        if last {
2585            return Err(invalid("archive shelf numbers exhausted"));
2586        }
2587        let name = shelf_name(highest + 1);
2588        Ok((self.fs.child(&name, true)?, name))
2589    }
2590
2591    /// The shelf that holds segment `id`, by name and opened, and its
2592    /// record: one `openat` per shelf, newest first, and one file read. Never
2593    /// a scan. A shelf name that is not a directory that can be opened is
2594    /// passed over and noted in `unread`: the segment may be in it.
2595    ///
2596    /// A file under the name that is not a valid record, or is not a file
2597    /// the store reads ([`PrivateDir::not_a_segment`]), is passed over too,
2598    /// as a seal passes over it: the segment is the valid record under the
2599    /// name on an older shelf, if there is one. If there is none, the error
2600    /// is that of the newest such file, named by its shelf and file name:
2601    /// there, and not readable as its record. A read that fails for any other reason says nothing about
2602    /// what the file is and is the error at once.
2603    ///
2604    /// A passed-over file may be a damaged tombstone of the very content the
2605    /// older record holds. [`Located::forgotten`] says so, and a caller that
2606    /// would hand out that record's content must not.
2607    fn locate(
2608        &self,
2609        shelves: &[String],
2610        id: &str,
2611        readonly: bool,
2612        unread: &mut Unread,
2613    ) -> io::Result<Option<Located>> {
2614        let name = archive_name(id);
2615        // The error of the newest file passed over, and what was read of
2616        // each one that could be read.
2617        let (mut first, mut passed) = (None, Vec::<Vec<u8>>::new());
2618        for shelf in shelves {
2619            let dir = match self.fs.open_child(shelf, readonly) {
2620                Ok(Some(dir)) => dir,
2621                Ok(None) => continue,
2622                Err(error) => {
2623                    unread.pass(shelf, error);
2624                    continue;
2625                }
2626            };
2627            let error = match dir.read_with(&name, MAX_SEGMENT_BYTES, readonly) {
2628                Ok(None) => continue,
2629                Ok(Some(raw)) => match self.record(&name, &raw) {
2630                    Ok(record) => {
2631                        let forgotten =
2632                            passed.iter().any(|raw| says_forgotten(raw, &record.digest));
2633                        return Ok(Some(Located {
2634                            shelf: shelf.clone(),
2635                            record,
2636                            forgotten,
2637                        }));
2638                    }
2639                    Err(error) => {
2640                        passed.push(raw);
2641                        error
2642                    }
2643                },
2644                Err(error) if PrivateDir::not_a_segment(&error) => error,
2645                Err(error) => return Err(error),
2646            };
2647            tracing::warn!(%shelf, file = %name, %error, "archive lookup passed over a file under its id");
2648            // Named: the caller knows the id, not which shelf holds the file.
2649            first.get_or_insert_with(|| {
2650                io::Error::new(
2651                    error.kind(),
2652                    format!("the newest file under this id is not its record: {shelf}/{name}: {error}"),
2653                )
2654            });
2655        }
2656        first.map_or(Ok(None), Err)
2657    }
2658
2659    /// The record of segment `id`, for a caller that holds no lock: one file
2660    /// when it is on a shelf, read without the gate or the lock. Only when no
2661    /// shelf has it and the directory has legacy slot files are those read,
2662    /// under the shared lock, one at a time until one of them is the
2663    /// segment. Nothing is written: no lock file, no mode.
2664    fn find(&self, id: &str) -> io::Result<Record> {
2665        self.check_abort()?;
2666        let (shelves, slots) = self.layout()?;
2667        let mut unread = Unread::default();
2668        if let Some(found) = self.locate(&shelves, id, true, &mut unread)? {
2669            // Forgotten, by a file that no longer validates: not handed out.
2670            return if found.forgotten { Err(not_found()) } else { Ok(found.record) };
2671        }
2672        if !slots.is_empty() {
2673            let _lock = self.shared()?;
2674            if let Some((_, record)) = self.legacy_segment(&slots, id, true, &mut unread) {
2675                return Ok(record);
2676            }
2677        }
2678        Err(unread.not_found())
2679    }
2680
2681    /// The legacy slot that holds segment `id`, by file name, and its
2682    /// record: what a fetch or a forget asks of the slots. They are read one
2683    /// at a time and the first valid record with that id is the answer,
2684    /// whatever its neighbours are. A slot that cannot be read or is not a
2685    /// valid record is passed over, left as it is and noted in `unread`: a
2686    /// legacy id is not in the file name, so such a slot cannot be told
2687    /// apart from the one asked for.
2688    fn legacy_segment(
2689        &self,
2690        slots: &[String],
2691        id: &str,
2692        readonly: bool,
2693        unread: &mut Unread,
2694    ) -> Option<(String, Record)> {
2695        for name in slots {
2696            let raw = self.fs.read_with(name, MAX_SEGMENT_BYTES, readonly);
2697            match raw.and_then(|raw| raw.map(|raw| self.record(name, &raw)).transpose()) {
2698                Ok(Some(record)) if record.id == id => return Some((name.clone(), record)),
2699                Ok(_) => {}
2700                Err(error) => unread.pass(name, error),
2701            }
2702        }
2703        None
2704    }
2705
2706    /// Parse and validate the contents of the segment file `name`. Anything
2707    /// that is not a whole record of this scope is `InvalidData`, whichever
2708    /// check it failed ([`Fault`]): a reader is told that much and no more.
2709    fn record(&self, name: &str, raw: &[u8]) -> io::Result<Record> {
2710        Self::parsed(&self.scope, name, raw).map_err(Fault::into_read_error)
2711    }
2712
2713    /// [`record`](Self::record), saying which check failed.
2714    fn parsed(scope: &str, name: &str, raw: &[u8]) -> Result<Record, Fault> {
2715        let record: Record = serde_json::from_slice(raw).map_err(Fault::Parse)?;
2716        Self::check(scope, &record, name)?;
2717        Ok(record)
2718    }
2719
2720    /// Whether `record`, read from the file `name` of the project directory
2721    /// `scope`, is one the store reads, and if not, the first check it
2722    /// fails. The one place that decides it.
2723    fn check(scope: &str, record: &Record, name: &str) -> Result<(), Fault> {
2724        let hex = |text: &str| text.len() == 64 && text.bytes().all(|b| b.is_ascii_hexdigit());
2725        if record.version != 1 {
2726            return Err(Fault::Version(record.version));
2727        }
2728        if record.scope != scope {
2729            return Err(Fault::Scope);
2730        }
2731        if validate_id(&record.id).is_err() {
2732            return Err(Fault::Id);
2733        }
2734        if !hex(&record.logical) {
2735            return Err(Fault::Logical);
2736        }
2737        if !hex(&record.digest) {
2738            return Err(Fault::Digest);
2739        }
2740        // A shelf file is named by its id; only a legacy slot is not.
2741        if name != archive_name(&record.id) && slot_number(name).is_none() {
2742            return Err(Fault::Name);
2743        }
2744        let Body::Live {
2745            messages,
2746            source_message_count,
2747            note,
2748        } = &record.body
2749        else {
2750            // A tombstone is what names the record and nothing else.
2751            return match record.created_ms {
2752                Some(_) => Err(Fault::TombstoneCreated),
2753                None => Ok(()),
2754            };
2755        };
2756        if messages.len() > MAX_MESSAGES
2757            || *source_message_count > MAX_MESSAGES
2758            || note.len() > MAX_NOTE_BYTES
2759        {
2760            return Err(Fault::Limit);
2761        }
2762        if messages.iter().any(|m| m.source_index >= *source_message_count)
2763            || messages.windows(2).any(|m| m[0].source_index >= m[1].source_index)
2764        {
2765            return Err(Fault::Order);
2766        }
2767        let content = projection_digest(&record.logical, *source_message_count, messages)
2768            .map_err(Fault::Size)?;
2769        if content != record.digest {
2770            return Err(Fault::Mismatch {
2771                record: record.digest.clone(),
2772                content,
2773            });
2774        }
2775        Ok(())
2776    }
2777
2778    /// The legacy slot that holds the projection `digest`, live or forgotten:
2779    /// what a seal asks of the slots. A seal writes to none of them, so it
2780    /// must not fail because of one. It passes over a slot that cannot be
2781    /// read (a link, a FIFO, a file
2782    /// that is not ours alone or is over the size of a segment, an I/O error)
2783    /// or that is not a valid record of this scope. Only an error that will
2784    /// pass ([`PrivateDir::passing`]) fails the seal. A slot passed over is
2785    /// left as it is: a link is not followed, and a file that is not ours
2786    /// alone is neither read nor made private. One slot is in memory at a
2787    /// time, so their total size is not bounded here.
2788    ///
2789    /// One thing is taken from a slot that is not a valid record: JSON that
2790    /// names this projection and says it was forgotten (`says_forgotten`)
2791    /// refuses the seal, as the tombstone it once was would.
2792    ///
2793    /// The price: if the slot passed over was the tombstone of this very
2794    /// projection and can no longer be read as that much, the projection is
2795    /// sealed again.
2796    ///
2797    /// `readonly`, for a dry run of an import: no mode is changed.
2798    fn legacy_projection(&self, digest: &str, readonly: bool) -> io::Result<Option<Record>> {
2799        for slot in 0..MAX_SEGMENTS {
2800            let name = slot_name(slot);
2801            let record = match self.fs.read_with(&name, MAX_SEGMENT_BYTES, readonly) {
2802                Ok(Some(raw)) => match self.record(&name, &raw) {
2803                    Err(_) if says_forgotten(&raw, digest) => return Err(forgotten()),
2804                    record => record,
2805                },
2806                Ok(None) => continue,
2807                Err(error) => Err(error),
2808            };
2809            match record {
2810                Ok(record) if record.digest == digest => return Ok(Some(record)),
2811                Ok(_) => {}
2812                // Not the slot's fault, and it may hold this projection.
2813                Err(error) if PrivateDir::passing(&error) => return Err(error),
2814                // The file name and the error only: never content.
2815                Err(error) => {
2816                    tracing::warn!(slot = %name, %error, "seal passed over a legacy archive slot");
2817                }
2818            }
2819        }
2820        Ok(None)
2821    }
2822}
2823
2824/// The search row of a live record that matches `query` (already lowercased).
2825fn describe(record: Record, query: &str) -> io::Result<Option<ArchiveDescriptor>> {
2826    let Body::Live {
2827        messages,
2828        source_message_count,
2829        ..
2830    } = &record.body
2831    else {
2832        return Ok(None);
2833    };
2834    let mut snippet = None;
2835    for message in messages {
2836        let text = serde_json::to_string(&message.message).map_err(json_error)?;
2837        if query.is_empty() || text.to_lowercase().contains(query) {
2838            snippet = Some(prefix(&text, 256).to_owned());
2839            break;
2840        }
2841    }
2842    if snippet.is_none() && !(query.is_empty() && messages.is_empty()) {
2843        return Ok(None);
2844    }
2845    Ok(Some(ArchiveDescriptor {
2846        message_count: messages.len(),
2847        source_message_count: *source_message_count,
2848        snippet: snippet.unwrap_or_default(),
2849        id: record.id,
2850    }))
2851}
2852
2853impl Projector {
2854    fn screen(&self, text: &str, class: DisclosureClass) -> io::Result<String> {
2855        if class == DisclosureClass::ModelVisibleAfterRedaction && self.redactor.is_none() {
2856            return Ok(WITHHELD.to_owned());
2857        }
2858        let redactor = |text: &str| {
2859            let text = self
2860                .redactor
2861                .as_ref()
2862                .map_or_else(|| text.to_owned(), |r| r(text));
2863            if credential_like(&text) {
2864                WITHHELD.to_owned()
2865            } else {
2866                text
2867            }
2868        };
2869        // All text is screened, including the ordinary model-visible class.
2870        let text = redactor(text);
2871        // A `Text` reduction bounds what it stores after screening: a message
2872        // string by cutting it (`text`), the note by its own limit.
2873        if text.len() > MAX_SEGMENT_BYTES && self.reduce != Reduce::Text {
2874            return Err(over("redactor output exceeds archive budget"));
2875        }
2876        match gate_for_model(class, &text, false, Some(&|s| s.to_owned())) {
2877            ModelVisibility::Visible(text) => Ok(text),
2878            ModelVisibility::Withheld(_) => Ok(WITHHELD.to_owned()),
2879        }
2880    }
2881
2882    /// A message string as stored: screened whole and then, in a `Text`
2883    /// reduction, cut to its first [`REDUCED_TEXT_BYTES`]. The cut comes last
2884    /// so that the redactor never sees half of something it would remove.
2885    fn text(&self, text: &str, class: DisclosureClass) -> io::Result<String> {
2886        let text = self.screen(text, class)?;
2887        if self.reduce != Reduce::Text || text.len() <= REDUCED_TEXT_BYTES {
2888            return Ok(text);
2889        }
2890        let kept = prefix(&text, REDUCED_TEXT_BYTES);
2891        let omitted = self.omission(format_args!("{} bytes", text.len() - kept.len()));
2892        Ok(format!("{kept}\n{omitted}"))
2893    }
2894
2895    /// What stands in place of something a reduction left out: what it was
2896    /// and how large.
2897    fn omission(&self, what: std::fmt::Arguments<'_>) -> String {
2898        self.omitted.set(self.omitted.get() + 1);
2899        format!("[archive: {what} omitted, over the per-archive limit]")
2900    }
2901
2902    /// The stored form of a message. One that a reduction changed says so
2903    /// under [`REDUCED_KEY`], which a source message cannot supply: only its
2904    /// role and content are ever taken. The markers inside the content can
2905    /// be imitated by content; this key cannot. It says how many things were
2906    /// left out and how many tool inputs are stored as text, of which only
2907    /// the first is a loss.
2908    fn stored(&self, role: &str, content: Value) -> Value {
2909        let mut message = json!({"role": role, "content": content});
2910        let (omitted, as_text) = (self.omitted.replace(0), self.as_text.replace(0));
2911        let changed = match (omitted, as_text) {
2912            (0, 0) => return message,
2913            (omitted, 0) => format!("{omitted} omission(s)"),
2914            (0, as_text) => format!("{as_text} tool input(s) as JSON text"),
2915            (omitted, as_text) => {
2916                format!("{omitted} omission(s) and {as_text} tool input(s) as JSON text")
2917            }
2918        };
2919        message[REDUCED_KEY] = json!(format!(
2920            "this message alone is over the per-archive limit; it is stored with {changed}, \
2921             each marked in place"
2922        ));
2923        message
2924    }
2925
2926    fn project(&self, message: &Value) -> io::Result<Option<(Value, Vec<usize>)>> {
2927        let Some((class, role, content)) = projection_content(message) else {
2928            return Ok(None);
2929        };
2930        self.omitted.set(0);
2931        self.as_text.set(0);
2932        if let Some(text) = content.as_str() {
2933            let content = json!(self.text(text, class)?);
2934            return Ok(Some((self.stored(role, content), Vec::new())));
2935        }
2936        let Some(blocks) = content.as_array() else {
2937            return Ok(None);
2938        };
2939        let mut output = Vec::new();
2940        let mut indices = Vec::new();
2941        for (index, block) in blocks.iter().enumerate() {
2942            if self.reduce == Reduce::Text && output.len() == REDUCED_BLOCKS {
2943                // This block and all after it, under the index of this one.
2944                let rest = self.omission(format_args!("{} blocks", blocks.len() - index));
2945                output.push(json!({"type":"text", "text":rest}));
2946                indices.push(index);
2947                break;
2948            }
2949            let Some(mut block_class) = eligible_class(block) else {
2950                continue;
2951            };
2952            if class == DisclosureClass::ModelVisibleAfterRedaction {
2953                block_class = class;
2954            }
2955            let kept = match block.get("type").and_then(Value::as_str) {
2956                Some("text") => block
2957                    .get("text")
2958                    .and_then(Value::as_str)
2959                    .map(|text| {
2960                        self.text(text, block_class)
2961                            .map(|text| json!({"type":"text", "text":text}))
2962                    })
2963                    .transpose()?,
2964                Some("tool_use") if role == "assistant" => {
2965                    if let (Some(id), Some(name)) =
2966                        (identifier(block, "id"), identifier(block, "name"))
2967                    {
2968                        let input = if name == "context_checkpoint" {
2969                            // The working note has its own hidden field. Do not
2970                            // expose it a second time via tool arguments.
2971                            json!({"_archive_withheld":true})
2972                        } else if self.redactor.is_some() {
2973                            self.tool_input(block.get("input"), block_class)?
2974                        } else {
2975                            json!({"_archive_withheld":true})
2976                        };
2977                        Some(json!({"type":"tool_use", "id":id, "name":name, "input":input}))
2978                    } else {
2979                        None
2980                    }
2981                }
2982                Some("tool_result") if role == "user" => {
2983                    if let Some(id) = identifier(block, "tool_use_id") {
2984                        let content = if self.redactor.is_some() {
2985                            self.tool_result(&block["content"], block_class)?
2986                        } else {
2987                            Value::String(WITHHELD.to_owned())
2988                        };
2989                        let mut result =
2990                            json!({"type":"tool_result", "tool_use_id":id, "content":content});
2991                        if let Some(error) = block.get("is_error").and_then(Value::as_bool) {
2992                            result["is_error"] = json!(error);
2993                        }
2994                        Some(result)
2995                    } else {
2996                        None
2997                    }
2998                }
2999                _ => None,
3000            };
3001            if let Some(kept) = kept {
3002                output.push(kept);
3003                indices.push(index);
3004            }
3005        }
3006        if output.is_empty() {
3007            Ok(None)
3008        } else {
3009            Ok(Some((self.stored(role, json!(output)), indices)))
3010        }
3011    }
3012
3013    /// A tool input as stored: exact, or, where a reduction says so, as its
3014    /// JSON text or left out with a marker.
3015    fn tool_input(&self, input: Option<&Value>, class: DisclosureClass) -> io::Result<Value> {
3016        match (self.reduce, input) {
3017            (Reduce::Inputs | Reduce::Text, Some(input)) => Ok(self.omitted_input(input)),
3018            (Reduce::InputsAsText, Some(input)) => self.input_as_text(input, class),
3019            (Reduce::LargeInputs, Some(value)) => match self.exact_input(input, class) {
3020                Err(error) if over_budget(&error) => self.input_as_text(value, class),
3021                exact => exact,
3022            },
3023            _ => self.exact_input(input, class),
3024        }
3025    }
3026
3027    /// What stands in place of a tool input that is left out: a marker with
3028    /// its size as JSON.
3029    fn omitted_input(&self, input: &Value) -> Value {
3030        let bytes = json_len(input);
3031        let marker = self.omission(format_args!("tool input of {bytes} bytes"));
3032        json!({"_archive_omitted": marker})
3033    }
3034
3035    /// A tool input as one string under [`INPUT_JSON_KEY`]: its JSON text,
3036    /// screened as a whole like the exact form is. The depth and node
3037    /// budgets do not apply to a string, so an input that is only too deep
3038    /// or has too many nodes is all there. It is withheld on the terms of
3039    /// the exact form (something in it is marked as not to be stored, or the
3040    /// screen withholds it), and left out only when the text is over the
3041    /// byte budget. The screened text is stored as it is: the redactor may
3042    /// have changed it, and nothing here needs it to parse.
3043    fn input_as_text(&self, input: &Value, class: DisclosureClass) -> io::Result<Value> {
3044        if !input_eligible(input) {
3045            return Ok(json!({"_archive_withheld":true}));
3046        }
3047        if input_text_cost(input).is_none() {
3048            return Ok(self.omitted_input(input));
3049        }
3050        let encoded = bounded_json(input, MAX_INPUT_BYTES)?;
3051        let text = std::str::from_utf8(&encoded).map_err(|_| corrupt())?;
3052        match self.screen(text, class) {
3053            Ok(text) if text == WITHHELD => Ok(json!({"_archive_withheld":true})),
3054            Ok(text) => {
3055                self.as_text.set(self.as_text.get() + 1);
3056                Ok(json!({INPUT_JSON_KEY: text}))
3057            }
3058            Err(error) if over_budget(&error) => Ok(self.omitted_input(input)),
3059            Err(error) => Err(error),
3060        }
3061    }
3062
3063    fn exact_input(&self, input: Option<&Value>, class: DisclosureClass) -> io::Result<Value> {
3064        if let Some(input) = input {
3065            // The full preflight already enforced aggregate limits. Reuse its
3066            // guarded admission predicate so recursion and withholding cannot
3067            // drift. JSON escaping expands at most sixfold (node allowance
3068            // covers punctuation/numbers); redactors may shrink that scratch
3069            // string below the unchanged final output limit.
3070            if let Some(bytes) = InputBudget::alone(input)? {
3071                let limit = bytes
3072                    .checked_mul(6)
3073                    .ok_or_else(|| over("archive input byte budget exceeded"))?;
3074                let encoded = bounded_json(input, limit)?;
3075                let text =
3076                    self.screen(std::str::from_utf8(&encoded).map_err(|_| corrupt())?, class)?;
3077                return Ok(serde_json::from_str(&text)
3078                    .unwrap_or_else(|_| json!({"_archive_withheld":true})));
3079            }
3080        }
3081        Ok(json!({"_archive_withheld":true}))
3082    }
3083
3084    fn tool_result(&self, content: &Value, class: DisclosureClass) -> io::Result<Value> {
3085        if let Some(text) = content.as_str() {
3086            return Ok(json!(self.text(text, class)?));
3087        }
3088        let mut output = Vec::new();
3089        if let Some(blocks) = content.as_array() {
3090            for (index, block) in blocks.iter().enumerate() {
3091                if let Some(class) = eligible_class(block) {
3092                    if block["type"] == "text" {
3093                        if let Some(text) = block["text"].as_str() {
3094                            let text = self.text(text, class)?;
3095                            if self.reduce == Reduce::Text {
3096                                // One string: the first text, then how many
3097                                // blocks followed it.
3098                                let rest = blocks.len() - index - 1;
3099                                if rest == 0 {
3100                                    return Ok(json!(text));
3101                                }
3102                                let rest = self.omission(format_args!("{rest} blocks"));
3103                                return Ok(json!(format!("{text}\n{rest}")));
3104                            }
3105                            output.push(json!({"type":"text", "text":text}));
3106                        }
3107                    }
3108                }
3109            }
3110        }
3111        if output.is_empty() {
3112            Ok(json!(WITHHELD))
3113        } else {
3114            Ok(json!(output))
3115        }
3116    }
3117}
3118
3119/// The conversation name of a store that is opened only to read a project:
3120/// a read finds the segments of every conversation in it.
3121const READER: &str = "project-reader";
3122
3123/// Absolute path of the file holding segment `id`, or `None`. Opens nothing
3124/// for writing, creates nothing and takes no lock (files are published by
3125/// atomic rename). The file is the validated record of `id`, which may be a
3126/// tombstone, or become one later; a corrupt or unsafe path fails closed.
3127pub fn archive_file(base: &Path, scope_key: &str, id: &str) -> io::Result<Option<PathBuf>> {
3128    validate_id(id)?;
3129    let Some(store) = ArchiveStore::open_existing(base, scope_key, READER)? else {
3130        return Ok(None);
3131    };
3132    let directory = base.join("context-archives").join(&store.scope);
3133    let mut unread = Unread::default();
3134    let (shelves, slots) = store.layout()?;
3135    if let Some(found) = store.locate(&shelves, id, true, &mut unread)? {
3136        if found.forgotten {
3137            // A newer file under the id says so and is not a valid record.
3138            return Err(corrupt());
3139        }
3140        return Ok(Some(directory.join(found.shelf).join(archive_name(id))));
3141    }
3142    match store.legacy_segment(&slots, id, true, &mut unread) {
3143        Some((slot, _)) => Ok(Some(directory.join(slot))),
3144        None if unread.places == 0 => Ok(None),
3145        None => Err(unread.not_found()),
3146    }
3147}
3148
3149/// Project-wide archive lookup; base and scope are trusted host values, never
3150/// model-supplied paths. The same budgets and disclosure policy apply. A
3151/// read: nothing is created, and a project without archives has no rows.
3152pub fn project_search(
3153    base: &Path,
3154    scope_key: &str,
3155    query: &str,
3156    limit: usize,
3157) -> io::Result<Vec<ArchiveDescriptor>> {
3158    match ArchiveStore::open_existing(base, scope_key, READER)? {
3159        Some(store) => store.search(query, limit),
3160        None => Ok(Vec::new()),
3161    }
3162}
3163
3164/// Fetch an opaque reference from any logical conversation in this project.
3165/// A read: nothing is created, and in a project without archives nothing is
3166/// found.
3167pub fn project_fetch(
3168    base: &Path,
3169    scope_key: &str,
3170    id: &str,
3171    start: usize,
3172    limit: usize,
3173) -> io::Result<Vec<SharedMessage>> {
3174    validate_id(id)?;
3175    match ArchiveStore::open_existing(base, scope_key, READER)? {
3176        Some(store) => store.fetch(id, start, limit),
3177        None => Err(not_found()),
3178    }
3179}
3180
3181fn eligible_class(value: &Value) -> Option<DisclosureClass> {
3182    // Provider/UI envelopes sometimes label private reasoning as assistant text.
3183    // Do not rely on the role or content-block type alone.
3184    if value
3185        .get("channel")
3186        .is_some_and(|v| !matches!(v.as_str(), Some("final")))
3187        || value.get("content_class").is_some_and(|v| {
3188            !matches!(
3189                v.as_str(),
3190                Some("user_text" | "assistant_text" | "tool_calls" | "tool_results")
3191            )
3192        })
3193        || value
3194            .get("private")
3195            .is_some_and(|v| v != &Value::Bool(false))
3196    {
3197        return None;
3198    }
3199    for key in ["sensitivity", "retention", "retention_class"] {
3200        if let Some(v) = value.get(key) {
3201            if !matches!(v.as_str(), Some("normal" | "standard" | "model_visible")) {
3202                return None;
3203            }
3204        }
3205    }
3206    let mut class = DisclosureClass::ModelVisible;
3207    for key in ["disclosure", "disclosure_class"] {
3208        if let Some(v) = value.get(key) {
3209            let parsed = DisclosureClass::parse(v.as_str()?)?;
3210            if !may_persist(parsed)
3211                || !matches!(
3212                    parsed,
3213                    DisclosureClass::ModelVisible | DisclosureClass::ModelVisibleAfterRedaction
3214                )
3215            {
3216                return None;
3217            }
3218            class = parsed;
3219        }
3220    }
3221    Some(class)
3222}
3223
3224/// Shared message-envelope selection for preflight and projection. Host
3225/// continuation notes and the host's tool-in-flight note are derived
3226/// working context, never source evidence (nor the user's words).
3227fn projection_content(message: &Value) -> Option<(DisclosureClass, &str, &Value)> {
3228    if message.get("_synaps_context").is_some()
3229        || crate::core::host_note::is_tool_in_flight_note(message)
3230    {
3231        return None;
3232    }
3233    let class = eligible_class(message)?;
3234    let role @ ("user" | "assistant") = message.get("role")?.as_str()? else {
3235        return None;
3236    };
3237    Some((class, role, message.get("content")?))
3238}
3239
3240fn identifier<'a>(value: &'a Value, key: &str) -> Option<&'a str> {
3241    let text = value.get(key)?.as_str()?;
3242    (!text.is_empty()
3243        && text.len() <= 128
3244        && !credential_like(text)
3245        && text
3246            .bytes()
3247            .all(|b| b.is_ascii_alphanumeric() || b"_-.:".contains(&b)))
3248    .then_some(text)
3249}
3250
3251/// Conservatively withhold an entire field rather than risk partial redaction.
3252fn credential_like(text: &str) -> bool {
3253    let lower = text.to_ascii_lowercase();
3254    [
3255        "password",
3256        "passwd",
3257        "api_key",
3258        "apikey",
3259        "api-key",
3260        "access_token",
3261        "refresh_token",
3262        "secret",
3263        "authorization",
3264        "bearer ",
3265        "private key",
3266        "sk-",
3267        "ghp_",
3268        "github_pat_",
3269        "xoxb-",
3270        "akia",
3271        "eyjh",
3272    ]
3273    .iter()
3274    .any(|needle| lower.contains(needle))
3275}
3276
3277struct InputBudget {
3278    bytes: usize,
3279    nodes: usize,
3280    reduce: Reduce,
3281}
3282impl InputBudget {
3283    fn node(&mut self, depth: usize) -> io::Result<()> {
3284        self.nodes += 1;
3285        if depth > 32 || self.nodes > 65536 {
3286            return Err(over("archive input structural budget exceeded"));
3287        }
3288        Ok(())
3289    }
3290
3291    fn charge(&mut self, bytes: usize) -> io::Result<()> {
3292        self.bytes = self.bytes.saturating_add(bytes);
3293        if self.bytes > MAX_INPUT_BYTES {
3294            return Err(over("archive input byte budget exceeded"));
3295        }
3296        Ok(())
3297    }
3298
3299    /// One tool input against a budget of its own: its cost when admitted
3300    /// (`None`: withheld whole), an error when it is over a budget by itself.
3301    fn alone(input: &Value) -> io::Result<Option<usize>> {
3302        let mut budget = InputBudget {
3303            bytes: 0,
3304            nodes: 0,
3305            reduce: Reduce::None,
3306        };
3307        let bytes = budget.input(input, 3)?;
3308        if let Some(bytes) = bytes {
3309            budget.charge(bytes)?;
3310        }
3311        Ok(bytes)
3312    }
3313
3314    // Only selected fields are charged; ignored metadata is opaque. Include the
3315    // old per-node/key allowance, never more than the corresponding raw subtree.
3316    fn field(&mut self, key: &str, text: &str, depth: usize) -> io::Result<()> {
3317        self.node(depth)?;
3318        self.charge(key.len().saturating_add(8).saturating_add(text.len()))
3319    }
3320
3321    fn text_field(
3322        &mut self,
3323        key: &str,
3324        text: &str,
3325        class: DisclosureClass,
3326        redactor: bool,
3327        depth: usize,
3328    ) -> io::Result<()> {
3329        if class == DisclosureClass::ModelVisibleAfterRedaction && !redactor {
3330            return self.node(depth); // screen() returns the fixed placeholder
3331        }
3332        self.field(key, text, depth)
3333    }
3334
3335    /// Allocation-free semantic preflight. Complete for ALL messages before
3336    /// invoking a redactor or materializing the projection. Keep the selected
3337    /// branches in sync with Projector; skipped payload descendants are opaque.
3338    fn message(&mut self, message: &Value, redactor: bool) -> io::Result<()> {
3339        self.node(0)?;
3340        let Some((class, role, content)) = projection_content(message) else {
3341            return Ok(());
3342        };
3343        self.charge(8)?;
3344        self.field("role", role, 1)?;
3345        if let Some(text) = content.as_str() {
3346            return self.text_field("content", text, class, redactor, 1);
3347        }
3348        self.node(1)?;
3349        let Some(blocks) = content.as_array() else {
3350            return Ok(());
3351        };
3352        self.charge("content".len() + 8)?;
3353        for block in blocks {
3354            self.node(2)?;
3355            let Some(mut block_class) = eligible_class(block) else {
3356                continue;
3357            };
3358            if class == DisclosureClass::ModelVisibleAfterRedaction {
3359                block_class = class;
3360            }
3361            match block.get("type").and_then(Value::as_str) {
3362                Some("text") => {
3363                    if let Some(text) = block.get("text").and_then(Value::as_str) {
3364                        self.charge(8)?;
3365                        self.field("type", "text", 3)?;
3366                        self.text_field("text", text, block_class, redactor, 3)?;
3367                    }
3368                }
3369                Some("tool_use") if role == "assistant" => {
3370                    if let (Some(id), Some(name)) =
3371                        (identifier(block, "id"), identifier(block, "name"))
3372                    {
3373                        self.charge(8)?;
3374                        self.field("type", "tool_use", 3)?;
3375                        self.field("id", id, 3)?;
3376                        self.field("name", name, 3)?;
3377                        if name != "context_checkpoint" && redactor {
3378                            let input = block.get("input");
3379                            let in_place = input.and_then(|input| self.reduce.in_place_of(input));
3380                            match (input, in_place) {
3381                                // Stored exact: walked.
3382                                (Some(input), None) => {
3383                                    if let Some(bytes) = self.input(input, 3)? {
3384                                        self.charge("input".len().saturating_add(bytes))?;
3385                                    }
3386                                }
3387                                // Left out: its marker is not charged.
3388                                (_, None | Some(0)) => {}
3389                                // One string in its place.
3390                                (_, Some(bytes)) => {
3391                                    self.node(4)?;
3392                                    self.charge("input".len().saturating_add(bytes))?;
3393                                }
3394                            }
3395                        }
3396                    }
3397                }
3398                Some("tool_result") if role == "user" => {
3399                    if let Some(id) = identifier(block, "tool_use_id") {
3400                        self.charge(8)?;
3401                        self.field("type", "tool_result", 3)?;
3402                        self.field("tool_use_id", id, 3)?;
3403                        if block.get("is_error").and_then(Value::as_bool).is_some() {
3404                            self.field("is_error", "", 3)?;
3405                        }
3406                        if redactor {
3407                            if let Some(content) = block.get("content") {
3408                                self.tool_result(content, block_class)?;
3409                            }
3410                        }
3411                    }
3412                }
3413                _ => {}
3414            }
3415        }
3416        Ok(())
3417    }
3418
3419    fn tool_result(&mut self, content: &Value, class: DisclosureClass) -> io::Result<()> {
3420        if let Some(text) = content.as_str() {
3421            return self.text_field("content", text, class, true, 3);
3422        }
3423        self.node(3)?;
3424        if let Some(blocks) = content.as_array() {
3425            self.charge("content".len() + 8)?;
3426            for block in blocks {
3427                self.node(4)?;
3428                if let Some(class) = eligible_class(block) {
3429                    if block["type"] == "text" {
3430                        if let Some(text) = block["text"].as_str() {
3431                            self.charge(8)?;
3432                            self.field("type", "text", 5)?;
3433                            self.text_field("text", text, class, true, 5)?;
3434                        }
3435                    }
3436                }
3437            }
3438        }
3439        Ok(())
3440    }
3441
3442    /// Guarded whole-argument admission, shared with Projector. Byte accounting
3443    /// is provisional until every visited child is eligible: a later excluded
3444    /// child withholds the WHOLE input, regardless of tentative byte cost.
3445    /// Structural visits are never rolled back. Arbitrary tool JSON is not a
3446    /// content-block array; unclassified data/base64 fields remain eligible.
3447    fn input(&mut self, value: &Value, depth: usize) -> io::Result<Option<usize>> {
3448        self.node(depth)?;
3449        if eligible_class(value).is_none() {
3450            return Ok(None);
3451        }
3452        let mut bytes: usize = 8;
3453        match value {
3454            Value::String(text) => bytes = bytes.saturating_add(text.len()),
3455            Value::Array(values) => {
3456                for value in values {
3457                    let Some(cost) = self.input(value, depth + 1)? else {
3458                        return Ok(None);
3459                    };
3460                    bytes = bytes.saturating_add(cost);
3461                }
3462            }
3463            Value::Object(values) => {
3464                for (key, value) in values {
3465                    let Some(cost) = self.input(value, depth + 1)? else {
3466                        return Ok(None);
3467                    };
3468                    bytes = bytes.saturating_add(key.len()).saturating_add(cost);
3469                }
3470            }
3471            _ => {}
3472        }
3473        Ok(Some(bytes))
3474    }
3475}
3476
3477fn projection_digest(
3478    scope: &str,
3479    count: usize,
3480    messages: &[ArchivedMessage],
3481) -> io::Result<String> {
3482    let bytes = bounded_json(messages, MAX_SEGMENT_BYTES)?;
3483    Ok(digest_parts(&[
3484        scope.as_bytes(),
3485        &(count as u64).to_be_bytes(),
3486        &bytes,
3487    ]))
3488}
3489fn digest_parts(parts: &[&[u8]]) -> String {
3490    let mut hash = Sha256::new();
3491    for part in parts {
3492        hash.update((part.len() as u64).to_be_bytes());
3493        hash.update(part);
3494    }
3495    format!("{:x}", hash.finalize())
3496}
3497fn bounded_json<T: Serialize + ?Sized>(value: &T, limit: usize) -> io::Result<Vec<u8>> {
3498    struct Sink {
3499        bytes: Vec<u8>,
3500        limit: usize,
3501    }
3502    impl Write for Sink {
3503        fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
3504            if bytes.len() > self.limit - self.bytes.len() {
3505                return Err(over("archive serialized byte budget exceeded"));
3506            }
3507            self.bytes.extend_from_slice(bytes);
3508            Ok(bytes.len())
3509        }
3510        fn flush(&mut self) -> io::Result<()> {
3511            Ok(())
3512        }
3513    }
3514    let mut sink = Sink {
3515        bytes: Vec::new(),
3516        limit,
3517    };
3518    serde_json::to_writer(&mut sink, value).map_err(json_error)?;
3519    Ok(sink.bytes)
3520}
3521/// The length of `value` as JSON, without building it.
3522fn json_len(value: &Value) -> usize {
3523    struct Count(usize);
3524    impl Write for Count {
3525        fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
3526            self.0 += bytes.len();
3527            Ok(bytes.len())
3528        }
3529        fn flush(&mut self) -> io::Result<()> {
3530            Ok(())
3531        }
3532    }
3533    let mut count = Count(0);
3534    // Cannot fail: the sink takes everything and a `Value` always serializes.
3535    let _ = serde_json::to_writer(&mut count, value);
3536    count.0
3537}
3538/// Where the part `messages[start..end]` is cut so that the next part does
3539/// not begin with tool results: `end`, moved back to before the call those
3540/// results follow, so that a call and its results stay in one part where
3541/// they can. It cannot when that would leave nothing in this part, and it
3542/// need not when `end` ends the window. A courtesy to readers, not a rule:
3543/// the store pairs nothing.
3544fn before_results(messages: &[SharedMessage], start: usize, end: usize) -> usize {
3545    let is_result = |index: usize| {
3546        let message = &messages[index];
3547        let blocks = message["content"].as_array();
3548        message["role"] == "user"
3549            && blocks.is_some_and(|blocks| blocks.iter().any(|b| b["type"] == "tool_result"))
3550    };
3551    if end >= messages.len() {
3552        return end;
3553    }
3554    let mut cut = end;
3555    while cut > start + 1 && is_result(cut) {
3556        cut -= 1;
3557    }
3558    // Nothing but results back to the start of the part: cut where it was.
3559    if is_result(cut) {
3560        end
3561    } else {
3562        cut
3563    }
3564}
3565/// File name of a numbered legacy slot in the project directory.
3566fn slot_name(slot: usize) -> String {
3567    format!("{slot:03}.json")
3568}
3569fn slot_number(name: &str) -> Option<usize> {
3570    let slot = name.strip_suffix(".json")?.parse().ok()?;
3571    (slot < MAX_SEGMENTS && slot_name(slot) == name).then_some(slot)
3572}
3573/// File name of segment `id` in its shelf.
3574fn archive_name(id: &str) -> String {
3575    format!("{id}.json")
3576}
3577fn archive_id(name: &str) -> Option<&str> {
3578    name.strip_suffix(".json")
3579        .filter(|id| validate_id(id).is_ok())
3580}
3581/// Shelves are `s` and six digits, numbered from 1 in creation order.
3582fn shelf_name(number: u64) -> String {
3583    format!("s{number:06}")
3584}
3585/// Only the one spelling `shelf_name` produces is a shelf, and only up to
3586/// [`LAST_SHELF`]: a name with a number no store has counted to is not one.
3587fn shelf_number(name: &str) -> Option<u64> {
3588    let number = name.strip_prefix('s')?.parse().ok()?;
3589    (number <= LAST_SHELF && shelf_name(number) == name).then_some(number)
3590}
3591/// Whether `raw`, the contents of a file that is not a valid record, still
3592/// reads as JSON that names the projection `digest` and says it was
3593/// forgotten. Forgetting is a promise to the user, and a seal keeps it on
3594/// this much evidence. It is the one deliberate refusal a damaged file can
3595/// cause, and only for its own projection. Nothing else in the file is read.
3596fn says_forgotten(raw: &[u8], digest: &str) -> bool {
3597    #[derive(Deserialize)]
3598    struct Marked {
3599        digest: String,
3600        body: State,
3601    }
3602    #[derive(Deserialize)]
3603    struct State {
3604        state: String,
3605    }
3606    serde_json::from_slice::<Marked>(raw)
3607        .is_ok_and(|marked| marked.digest == digest && marked.body.state == "tombstone")
3608}
3609/// Whether `raw` reads as JSON whose `digest` is `digest`: all a forget
3610/// asks of a file under another id before it reads it as a record.
3611fn names_digest(raw: &[u8], digest: &str) -> bool {
3612    #[derive(Deserialize)]
3613    struct Named {
3614        digest: String,
3615    }
3616    serde_json::from_slice::<Named>(raw).is_ok_and(|named| named.digest == digest)
3617}
3618/// The name of one write's temporary file. No two writes have the same, so no
3619/// write removes, reuses or renames the file of another. Readers open segment
3620/// and slot names only, never this.
3621#[cfg(unix)]
3622fn temp_name() -> String {
3623    format!("pending.{}.tmp", uuid::Uuid::new_v4().simple())
3624}
3625/// Whether `name` is a temporary file's: one of `temp_name`'s, or the one
3626/// name (`pending.tmp`) every write shared before.
3627fn is_temp_name(name: &str) -> bool {
3628    name.starts_with("pending.") && name.ends_with(".tmp")
3629}
3630/// Remove what unfinished writes left in `dir`: of `names`, its listing, the
3631/// temporary files last written more than [`STALE_TEMP`] ago. A younger one
3632/// may belong to a write that is still running, here or in another process,
3633/// and is left to it; so is anything under such a name that is not a plain
3634/// file of ours. Called by a seal, under the lock, for the shelf it writes
3635/// to. A leftover costs space and nothing else, so a failure is only logged.
3636fn sweep(dir: &PrivateDir, names: &[(String, bool, i64)]) {
3637    let now = std::time::SystemTime::now()
3638        .duration_since(std::time::UNIX_EPOCH)
3639        .map_or(0, |since| since.as_nanos() as i128);
3640    for (name, is_dir, modified) in names {
3641        let stale = now - i128::from(*modified) > STALE_TEMP.as_nanos() as i128;
3642        if is_temp_name(name) && !is_dir && stale {
3643            if let Err(error) = dir.discard(name) {
3644                tracing::warn!(file = %name, %error, "stale temporary archive file not removed");
3645            }
3646        }
3647    }
3648}
3649fn prefix(text: &str, bytes: usize) -> &str {
3650    let mut end = bytes.min(text.len());
3651    while !text.is_char_boundary(end) {
3652        end -= 1;
3653    }
3654    &text[..end]
3655}
3656fn validate_key(key: &str) -> io::Result<()> {
3657    if key.is_empty() || key.len() > 4096 {
3658        return Err(invalid("archive scope/logical id must be 1..4096 bytes"));
3659    }
3660    Ok(())
3661}
3662
3663fn validate_id(id: &str) -> io::Result<()> {
3664    if id.len() != 32
3665        || !id
3666            .bytes()
3667            .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
3668    {
3669        Err(invalid("invalid opaque archive id"))
3670    } else {
3671        Ok(())
3672    }
3673}
3674fn invalid(message: &'static str) -> io::Error {
3675    io::Error::new(io::ErrorKind::InvalidInput, message)
3676}
3677fn corrupt() -> io::Error {
3678    io::Error::new(io::ErrorKind::InvalidData, "invalid archive record")
3679}
3680fn not_found() -> io::Error {
3681    io::Error::new(io::ErrorKind::NotFound, NOT_FOUND)
3682}
3683const FORGOTTEN: &str = "archive projection was forgotten";
3684fn forgotten() -> io::Error {
3685    io::Error::new(io::ErrorKind::PermissionDenied, FORGOTTEN)
3686}
3687/// Whether `error` is [`forgotten`]: a lookup's refusal, not its failure.
3688fn is_forgotten(error: &io::Error) -> bool {
3689    error.kind() == io::ErrorKind::PermissionDenied
3690        && error.raw_os_error().is_none()
3691        && error.to_string() == FORGOTTEN
3692}
3693fn canceled() -> io::Error {
3694    io::Error::new(
3695        io::ErrorKind::Interrupted,
3696        "archive operation canceled before it started",
3697    )
3698}
3699fn json_error(_: serde_json::Error) -> io::Error {
3700    over("archive JSON serialization failed or exceeded budget")
3701}
3702/// The payload of an error that says one segment's budget was exceeded: the
3703/// only kind of error that sealing fewer messages, or less of one message,
3704/// can cure. It prints as its message.
3705#[derive(Debug)]
3706struct OverBudget(&'static str);
3707impl std::fmt::Display for OverBudget {
3708    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3709        f.write_str(self.0)
3710    }
3711}
3712impl std::error::Error for OverBudget {}
3713fn over(message: &'static str) -> io::Error {
3714    io::Error::new(io::ErrorKind::InvalidInput, OverBudget(message))
3715}
3716fn over_budget(error: &io::Error) -> bool {
3717    error.get_ref().is_some_and(|inner| inner.is::<OverBudget>())
3718}
3719
3720// ConfinedDir has no public directory fsync API. Keep a private directory fd
3721// using the same openat/O_NOFOLLOW policy and modes as private_fs. Every method
3722// takes a single name inside this directory, never a path.
3723#[cfg(unix)]
3724struct PrivateDir(std::fs::File);
3725#[cfg(unix)]
3726impl PrivateDir {
3727    /// The base as given must be absolute, not the root, and name no `.` or
3728    /// `..`; its names must be UTF-8.
3729    fn check_base(base: &Path) -> io::Result<()> {
3730        if !base.is_absolute() || base == Path::new("/") {
3731            return Err(invalid("archive base must be an absolute non-root path"));
3732        }
3733        for component in base.components() {
3734            match component {
3735                std::path::Component::RootDir => {}
3736                std::path::Component::Normal(name) => {
3737                    name.to_str().ok_or_else(|| invalid("archive path must be UTF-8"))?;
3738                }
3739                _ => return Err(invalid("archive base contains traversal")),
3740            }
3741        }
3742        Ok(())
3743    }
3744    /// Where the base really is, resolved once, when a store is opened: the
3745    /// deepest part of it that exists with every link in it followed, and
3746    /// the names after that, which do not exist yet. The base is the user's
3747    /// own choice of place and may be reached through a link (a home
3748    /// directory on another disk). Everything below it is the store's, and
3749    /// there no link is ever followed.
3750    fn resolve(base: &Path) -> io::Result<(PathBuf, Vec<String>)> {
3751        Self::check_base(base)?;
3752        let (mut existing, mut missing) = (base.to_path_buf(), Vec::new());
3753        let real = loop {
3754            match std::fs::canonicalize(&existing) {
3755                Ok(real) => break real,
3756                Err(error) if error.kind() == io::ErrorKind::NotFound => {
3757                    // `check_base`: every name is UTF-8, and the root exists.
3758                    let name = existing.file_name().and_then(|name| name.to_str());
3759                    missing.push(name.ok_or(error)?.to_owned());
3760                    existing.pop();
3761                }
3762                Err(error) => return Err(error),
3763            }
3764        };
3765        if real == Path::new("/") && missing.is_empty() {
3766            return Err(invalid("archive base must be an absolute non-root path"));
3767        }
3768        missing.reverse();
3769        Ok((real, missing))
3770    }
3771    /// A directory by its resolved path. Used for the base and what is above
3772    /// it only; everything below is opened by name from this handle.
3773    fn at(path: &Path) -> io::Result<Self> {
3774        use std::os::unix::fs::OpenOptionsExt;
3775        let mut options = std::fs::OpenOptions::new();
3776        options.read(true);
3777        options.custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC);
3778        options.open(path).map(Self)
3779    }
3780    fn new(base: &Path, scope: &str) -> io::Result<Self> {
3781        use crate::core::private_fs::DIR_MODE;
3782        use std::os::unix::fs::PermissionsExt;
3783        let (real, mut missing) = Self::resolve(base)?;
3784        // The directory the first name is made or opened in: the deepest
3785        // one that exists or, when the base itself exists, its parent, so
3786        // that the base is made private and its entry synced as before.
3787        let mut above = real.clone();
3788        if missing.is_empty() {
3789            let name = real.file_name().and_then(|name| name.to_str());
3790            missing.push(name.ok_or_else(|| invalid("archive path must be UTF-8"))?.to_owned());
3791            above.pop();
3792        }
3793        let closed = |error: &io::Error| {
3794            matches!(error.raw_os_error(), Some(libc::EACCES | libc::EPERM))
3795        };
3796        let mut dir = match Self::at(&above) {
3797            Ok(above) => {
3798                let last = missing.len() - 1;
3799                let mut dir = above;
3800                for (index, name) in missing.iter().enumerate() {
3801                    dir = dir.child(name, index == last)?;
3802                }
3803                dir
3804            }
3805            // The parent of a base that exists cannot be opened (it can be
3806            // passed through and not listed): the base itself can.
3807            Err(error) if closed(&error) && above != real => {
3808                let dir = Self::at(&real)?;
3809                dir.0.set_permissions(std::fs::Permissions::from_mode(DIR_MODE))?;
3810                dir
3811            }
3812            Err(error) => return Err(error),
3813        };
3814        dir = dir.child("context-archives", true)?;
3815        dir.child(scope, true)
3816    }
3817    fn open_existing(base: &Path, scope: &str) -> io::Result<Self> {
3818        Self::check_base(base)?;
3819        // `NotFound` when the base, or a folder on the way to it, is not
3820        // there: no store. A base that is there, as a link to a folder that
3821        // is away (a disk that is not mounted), is not "no store": the
3822        // archives are where it points, and cannot be reached.
3823        let real = match std::fs::canonicalize(base) {
3824            Ok(real) => real,
3825            Err(error) if error.kind() == io::ErrorKind::NotFound => {
3826                return Err(match std::fs::symlink_metadata(base) {
3827                    Ok(_) => io::Error::other(format!(
3828                        "the archive base is a link to a folder that is not there: {error}"
3829                    )),
3830                    Err(_) => error,
3831                });
3832            }
3833            Err(error) => return Err(error),
3834        };
3835        if real == Path::new("/") {
3836            return Err(invalid("archive base must be an absolute non-root path"));
3837        }
3838        let mut dir = Self::at(&real)?;
3839        for name in ["context-archives", scope] {
3840            dir = Self(dir.open(&c_name(name)?, libc::O_RDONLY | libc::O_DIRECTORY)?);
3841        }
3842        Ok(dir)
3843    }
3844
3845    fn fd(&self) -> libc::c_int {
3846        use std::os::fd::AsRawFd;
3847        self.0.as_raw_fd()
3848    }
3849    fn child(&self, name: &str, private: bool) -> io::Result<Self> {
3850        use crate::core::private_fs::DIR_MODE;
3851        use std::os::unix::fs::PermissionsExt;
3852        let name = c_name(name)?;
3853        // `mode_t` is u16 on macOS, u32 on Linux; DIR_MODE (0o700) fits both.
3854        let created =
3855            unsafe { libc::mkdirat(self.fd(), name.as_ptr(), DIR_MODE as libc::mode_t) } == 0;
3856        if !created {
3857            let error = io::Error::last_os_error();
3858            if error.raw_os_error() != Some(libc::EEXIST) {
3859                return Err(error);
3860            }
3861        }
3862        let file = self.open(&name, libc::O_RDONLY | libc::O_DIRECTORY)?;
3863        if private || created {
3864            file.set_permissions(std::fs::Permissions::from_mode(DIR_MODE))?;
3865        }
3866        // A previous mkdir may have succeeded but its parent fsync failed.
3867        // EEXIST on retry is not evidence that the directory entry is durable.
3868        self.sync()?;
3869        Ok(Self(file))
3870    }
3871    /// An existing child directory, or `None`. A link is refused, never
3872    /// followed. Unless `readonly`, its mode is made private again, as `child`
3873    /// does.
3874    fn open_child(&self, name: &str, readonly: bool) -> io::Result<Option<Self>> {
3875        use crate::core::private_fs::DIR_MODE;
3876        use std::os::unix::fs::PermissionsExt;
3877        let file = match self.open(&c_name(name)?, libc::O_RDONLY | libc::O_DIRECTORY) {
3878            Ok(file) => file,
3879            Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
3880            Err(error) => return Err(error),
3881        };
3882        if !readonly {
3883            file.set_permissions(std::fs::Permissions::from_mode(DIR_MODE))?;
3884        }
3885        Ok(Some(Self(file)))
3886    }
3887    /// Direct children as (name, is a directory, modification time in ns), in
3888    /// no order. Nothing is followed, so a link is not a directory. Names that
3889    /// are not valid components are left out; a failed read is an error, never
3890    /// a shorter listing.
3891    fn names(&self) -> io::Result<Vec<(String, bool, i64)>> {
3892        use std::os::fd::IntoRawFd;
3893        // The stream owns its own handle on this directory, opened afresh so
3894        // that listings share no read position (a duplicate would).
3895        let fd = self
3896            .open(c".", libc::O_RDONLY | libc::O_DIRECTORY)?
3897            .into_raw_fd();
3898        let stream = unsafe { libc::fdopendir(fd) };
3899        if stream.is_null() {
3900            let error = io::Error::last_os_error();
3901            unsafe { libc::close(fd) };
3902            return Err(error);
3903        }
3904        let names = self.read_names(stream);
3905        unsafe { libc::closedir(stream) }; // closes its handle too
3906        names
3907    }
3908    /// The rest of `names`: every entry `stream` has left, which must be a
3909    /// stream on this directory.
3910    fn read_names(&self, stream: *mut libc::DIR) -> io::Result<Vec<(String, bool, i64)>> {
3911        let mut names = Vec::new();
3912        loop {
3913            // `readdir` returns null at the end of the directory and when the
3914            // read failed, and only `errno` tells the two apart: clear it,
3915            // call, look. (Not `readdir_r` into a `dirent` of our own: musl
3916            // copies an entry of any length into it, and glibc fails the
3917            // whole listing on a name that does not fit.)
3918            unsafe { *errno() = 0 };
3919            let entry = unsafe { libc::readdir(stream) };
3920            if entry.is_null() {
3921                return match unsafe { *errno() } {
3922                    0 => Ok(names),
3923                    code => Err(io::Error::from_raw_os_error(code)),
3924                };
3925            }
3926            // The name is read where the C library keeps it, up to its NUL
3927            // and however long it is. No `dirent` is copied, and no reference
3928            // to a whole one is made: the entry may be shorter than the type.
3929            let (raw, name) = unsafe {
3930                let raw = std::ptr::addr_of!((*entry).d_name).cast::<libc::c_char>();
3931                (raw, std::ffi::CStr::from_ptr(raw))
3932            };
3933            let Some(name) = name.to_str().ok().filter(|name| c_name(name).is_ok()) else {
3934                continue;
3935            };
3936            let mut stat: libc::stat = unsafe { std::mem::zeroed() };
3937            let found =
3938                unsafe { libc::fstatat(self.fd(), raw, &mut stat, libc::AT_SYMLINK_NOFOLLOW) };
3939            if found != 0 {
3940                let error = io::Error::last_os_error();
3941                // Gone since it was listed: whoever opens it next decides.
3942                if error.kind() == io::ErrorKind::NotFound {
3943                    continue;
3944                }
3945                return Err(error);
3946            }
3947            let kind = stat.st_mode & libc::S_IFMT;
3948            #[allow(clippy::useless_conversion)]
3949            let modified = i64::from(stat.st_mtime)
3950                .saturating_mul(1_000_000_000)
3951                .saturating_add(i64::from(stat.st_mtime_nsec));
3952            names.push((name.to_owned(), kind == libc::S_IFDIR, modified));
3953        }
3954    }
3955    fn open(&self, name: &std::ffi::CStr, flags: i32) -> io::Result<std::fs::File> {
3956        use std::os::fd::FromRawFd;
3957        #[cfg(test)]
3958        if let Some(error) = fault("open", name) {
3959            return Err(error);
3960        }
3961        let fd = unsafe {
3962            libc::openat(
3963                self.fd(),
3964                name.as_ptr(),
3965                flags | libc::O_NOFOLLOW | libc::O_CLOEXEC | libc::O_NONBLOCK,
3966                crate::core::private_fs::FILE_MODE,
3967            )
3968        };
3969        if fd < 0 {
3970            Err(io::Error::last_os_error())
3971        } else {
3972            Ok(unsafe { std::fs::File::from_raw_fd(fd) })
3973        }
3974    }
3975    fn check_readonly(file: &std::fs::File) -> io::Result<()> {
3976        Self::check_links(file, 1)
3977    }
3978    /// The check for a read that changes nothing and may hold no lock. A
3979    /// forget can rename its tombstone over the name between that read's open
3980    /// and this check; the record the read opened is still whole and then has
3981    /// no name at all. Only a second name (a hard link) is refused here; where
3982    /// the store is about to write, `check` still wants exactly one.
3983    fn check_unlocked(file: &std::fs::File) -> io::Result<()> {
3984        Self::check_links(file, 0)
3985    }
3986    fn check_links(file: &std::fs::File, fewest: u64) -> io::Result<()> {
3987        use std::os::unix::fs::MetadataExt;
3988        let meta = file.metadata()?;
3989        let named = (fewest..=1).contains(&meta.nlink());
3990        if !meta.is_file() || !named || meta.uid() != unsafe { libc::geteuid() } {
3991            return Err(io::Error::new(
3992                io::ErrorKind::PermissionDenied,
3993                "archive requires owned regular single-link files",
3994            ));
3995        }
3996        Ok(())
3997    }
3998    fn check(file: &std::fs::File) -> io::Result<()> {
3999        use std::os::unix::fs::PermissionsExt;
4000        Self::check_readonly(file)?;
4001        file.set_permissions(std::fs::Permissions::from_mode(
4002            crate::core::private_fs::FILE_MODE,
4003        ))
4004    }
4005    /// Whether a failed read of the file under a segment's name says what
4006    /// that file is, rather than that reading failed: the store's own
4007    /// refusal (not a plain file of ours alone, or too large for a segment),
4008    /// which carries no system error, and the system's refusal to open it
4009    /// for what it is (a link, a file closed to us or that cannot be made
4010    /// private, a socket). Such a file is odd and stays odd. Everything
4011    /// else (an I/O error, no descriptor or memory left, an interrupted
4012    /// call) can happen to a healthy file.
4013    fn not_a_segment(error: &io::Error) -> bool {
4014        let of_the_file = [libc::ELOOP, libc::EACCES, libc::EPERM, libc::ENXIO];
4015        error.raw_os_error().is_none_or(|code| of_the_file.contains(&code))
4016    }
4017    /// Whether an error is about the state of this process or the system at
4018    /// this moment (no descriptor or memory left, an interrupted call) and
4019    /// not about the shelf, slot or file it was met at. Such an error passes
4020    /// and a seal fails on it: the caller keeps its history and retries.
4021    /// Every other error from a shelf or a slot (not a directory, a link,
4022    /// closed to us, an I/O error reading it) is taken to stay, and a seal
4023    /// passes over that shelf or slot rather than be refused for good.
4024    fn passing(error: &io::Error) -> bool {
4025        let passing = [libc::EMFILE, libc::ENFILE, libc::ENOMEM, libc::EINTR, libc::EAGAIN];
4026        error.raw_os_error().is_some_and(|code| passing.contains(&code))
4027    }
4028    /// Directory identity for the in-process gate.
4029    fn identity(&self) -> io::Result<DirIdentity> {
4030        use std::os::unix::fs::MetadataExt;
4031        let meta = self.0.metadata()?;
4032        Ok((meta.dev(), meta.ino()))
4033    }
4034    /// The lock file, opened and looked at, or `None` if there is none.
4035    /// Only ever locked: never written, truncated or read, so neither a
4036    /// second name nor its mode matters. With `create` it is made if it is
4037    /// missing and opened read-write, or for reading if its mode refuses
4038    /// that; a group or other bit in its mode is cleared, unless the file
4039    /// has a second name. Without `create` nothing is changed. A link is not followed.
4040    fn lock_file(&self, create: bool) -> io::Result<Option<std::fs::File>> {
4041        use std::os::unix::fs::{MetadataExt, PermissionsExt};
4042        let refused = |what| io::Error::new(io::ErrorKind::PermissionDenied, LockRefused(what));
4043        let name = c_name("lock")?;
4044        let mut opened = match create {
4045            true => self.open(&name, libc::O_RDWR | libc::O_CREAT),
4046            false => self.open(&name, libc::O_RDONLY),
4047        };
4048        if create && opened.as_ref().is_err_and(|error| error.raw_os_error() == Some(libc::EACCES)) {
4049            // Read-only, or in a directory nothing can be made in.
4050            match self.open(&name, libc::O_RDONLY) {
4051                // There is none and none can be made: the error as it is.
4052                Err(error) if error.kind() == io::ErrorKind::NotFound => return opened.map(Some),
4053                again => opened = again,
4054            }
4055        }
4056        let file = match opened {
4057            Ok(file) => file,
4058            Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
4059            Err(error) => {
4060                return Err(match error.raw_os_error() {
4061                    // `O_NOFOLLOW`; FreeBSD says EMLINK.
4062                    Some(libc::ELOOP | libc::EMLINK) => refused("is a link"),
4063                    Some(libc::EISDIR | libc::ENXIO) => refused("is not a regular file"),
4064                    Some(libc::EACCES) => refused("cannot be opened by its owner with its mode"),
4065                    _ => error,
4066                });
4067            }
4068        };
4069        let meta = file.metadata()?;
4070        if !meta.is_file() {
4071            return Err(refused("is not a regular file"));
4072        }
4073        if meta.uid() != unsafe { libc::geteuid() } {
4074            return Err(refused("is owned by another user"));
4075        }
4076        let mode = meta.permissions().mode() & 0o7777;
4077        // A file with a second name is someone's copy of it too (a
4078        // hard-link backup): its mode is theirs as much as ours. It is
4079        // only ever locked, so it is used as it is.
4080        if create && mode & 0o077 != 0 && meta.nlink() == 1 {
4081            file.set_permissions(std::fs::Permissions::from_mode(mode & 0o700))?;
4082        }
4083        Ok(Some(file))
4084    }
4085    fn lock_existing(&self) -> io::Result<Option<FlockGuard>> {
4086        let Some(file) = self.lock_file(false)? else {
4087            return Ok(None);
4088        };
4089        if !fs4::fs_std::FileExt::try_lock_shared(&file)? {
4090            return Err(io::Error::new(io::ErrorKind::WouldBlock, BUSY));
4091        }
4092        Ok(Some(FlockGuard(file)))
4093    }
4094    /// Never waits: a lock held by another process fails closed at once;
4095    /// `ArchiveStore::exclusive` polls it.
4096    fn lock(&self) -> io::Result<FlockGuard> {
4097        let file = self.lock_file(true)?.ok_or_else(not_found)?;
4098        if !fs4::fs_std::FileExt::try_lock_exclusive(&file)? {
4099            return Err(io::Error::new(io::ErrorKind::WouldBlock, BUSY));
4100        }
4101        Ok(FlockGuard(file))
4102    }
4103    fn read_with(
4104        &self,
4105        name: &str,
4106        remaining: usize,
4107        readonly: bool,
4108    ) -> io::Result<Option<Vec<u8>>> {
4109        let file = match self.open(&c_name(name)?, libc::O_RDONLY) {
4110            Ok(file) => file,
4111            Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
4112            Err(error) => return Err(error),
4113        };
4114        Self::read_open(file, remaining, readonly).map(Some)
4115    }
4116    /// The rest of `read_with` once the file is open: whatever happens to its
4117    /// name from here on, these are the bytes of the file that was opened.
4118    /// `readonly` changes nothing on disk.
4119    fn read_open(file: std::fs::File, remaining: usize, readonly: bool) -> io::Result<Vec<u8>> {
4120        if readonly {
4121            Self::check_unlocked(&file)?;
4122        } else {
4123            Self::check(&file)?;
4124        }
4125        let limit = remaining.min(MAX_SEGMENT_BYTES);
4126        if file.metadata()?.len() > limit as u64 {
4127            return Err(corrupt());
4128        }
4129        let mut bytes = Vec::new();
4130        file.take(limit as u64 + 1).read_to_end(&mut bytes)?;
4131        if bytes.len() > limit {
4132            return Err(corrupt());
4133        }
4134        Ok(bytes)
4135    }
4136    /// Write `bytes` to a temporary file of this call's own, sync it and
4137    /// rename it onto `name`: a name only ever points at a whole file. With
4138    /// `create_new` the rename cannot replace, so an existing `name` is
4139    /// `AlreadyExists` and is left as it is, whatever it is. Without it
4140    /// (forget) the file at `name` is replaced.
4141    fn publish(&self, name: &str, bytes: &[u8], create_new: bool) -> io::Result<()> {
4142        self.publish_dated(name, bytes, create_new, None)
4143    }
4144    /// [`publish`](Self::publish) that can also give the file a time of last
4145    /// access and modification, for a record that was first written
4146    /// somewhere else. The time is set after the rename, through the
4147    /// descriptor the file was written by, and synced: a temporary file
4148    /// always has the time of its write. A reader in between sees the whole
4149    /// record with the time of the write. No directory's time is ever set.
4150    fn publish_dated(
4151        &self,
4152        name: &str,
4153        bytes: &[u8],
4154        create_new: bool,
4155        modified: Option<std::time::SystemTime>,
4156    ) -> io::Result<()> {
4157        let target = c_name(name)?;
4158        // Created new under a name no other write has: nothing else writes
4159        // to this file, removes it while it is fresh or renames it.
4160        let tmp = c_name(&temp_name())?;
4161        let mut file = self.open(&tmp, libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL)?;
4162        let result = (|| {
4163            file.write_all(bytes)?;
4164            file.sync_all()?;
4165            if create_new {
4166                self.rename_new(&tmp, &target)?;
4167            } else {
4168                // Refuse a target that is a symlink or has a second link.
4169                match self.open(&target, libc::O_RDONLY) {
4170                    Ok(existing) => Self::check(&existing)?,
4171                    Err(error) if error.kind() == io::ErrorKind::NotFound => {}
4172                    Err(error) => return Err(error),
4173                }
4174                self.rename(&tmp, &target)?;
4175            }
4176            if let Some(time) = modified {
4177                // The time goes on the file once it has its name, through
4178                // the descriptor it was written by: a temporary file never
4179                // has an old time, so the sweep of stale ones (which goes
4180                // by their time) cannot take one that is being written.
4181                // Stopped between the rename and here, the record is stored
4182                // whole with the time of the write: it reads the same, and
4183                // sorts on its shelf as written now.
4184                #[cfg(test)]
4185                if let Some(error) = fault("date", &target) {
4186                    return Err(error);
4187                }
4188                let times = std::fs::FileTimes::new().set_accessed(time).set_modified(time);
4189                file.set_times(times)?;
4190                file.sync_all()?;
4191            }
4192            self.sync()
4193        })();
4194        if result.is_err() {
4195            // This call's own file, or nothing if it was renamed already.
4196            unsafe {
4197                libc::unlinkat(self.fd(), tmp.as_ptr(), 0);
4198            }
4199        }
4200        result
4201    }
4202    /// Rename `from` onto `to` unless there is a `to`, which is then
4203    /// `AlreadyExists` whatever it is (a link is not followed): one step,
4204    /// in the kernel, that holds with or without the archive lock.
4205    fn rename_new(&self, from: &std::ffi::CStr, to: &std::ffi::CStr) -> io::Result<()> {
4206        // The system call itself: libc declares the `renameat2` wrapper for
4207        // glibc only (which has it from 2.28) and not for musl. Linux 3.15.
4208        #[cfg(target_os = "linux")]
4209        let code = unsafe {
4210            libc::syscall(
4211                libc::SYS_renameat2,
4212                self.fd(),
4213                from.as_ptr(),
4214                self.fd(),
4215                to.as_ptr(),
4216                libc::RENAME_NOREPLACE,
4217            )
4218        };
4219        #[cfg(target_os = "macos")]
4220        let code = unsafe {
4221            libc::renameatx_np(
4222                self.fd(),
4223                from.as_ptr(),
4224                self.fd(),
4225                to.as_ptr(),
4226                libc::RENAME_EXCL,
4227            )
4228        };
4229        #[cfg(any(target_os = "linux", target_os = "macos"))]
4230        {
4231            if code == 0 {
4232                return Ok(());
4233            }
4234            let error = io::Error::last_os_error();
4235            // A kernel without the call (ENOSYS) or a filesystem without the
4236            // flag (EINVAL, ENOTSUP) is the only way on to the two steps.
4237            let unsupported = [libc::ENOSYS, libc::EINVAL, libc::ENOTSUP];
4238            if !error.raw_os_error().is_some_and(|code| unsupported.contains(&code)) {
4239                return Err(error);
4240            }
4241        }
4242        self.rename_probed(from, to)
4243    }
4244    /// `rename_new` where nothing renames without replacing: look, then
4245    /// rename. Two steps, which only the caller's lock makes one.
4246    fn rename_probed(&self, from: &std::ffi::CStr, to: &std::ffi::CStr) -> io::Result<()> {
4247        match self.open(to, libc::O_RDONLY) {
4248            Ok(_) => Err(io::Error::new(
4249                io::ErrorKind::AlreadyExists,
4250                "archive file already exists",
4251            )),
4252            Err(error) if error.kind() == io::ErrorKind::NotFound => self.rename(from, to),
4253            Err(error) => Err(error),
4254        }
4255    }
4256    /// The plain rename, which replaces.
4257    fn rename(&self, from: &std::ffi::CStr, to: &std::ffi::CStr) -> io::Result<()> {
4258        #[cfg(test)]
4259        if let Some(error) = fault("rename", to) {
4260            return Err(error);
4261        }
4262        match unsafe { libc::renameat(self.fd(), from.as_ptr(), self.fd(), to.as_ptr()) } {
4263            0 => Ok(()),
4264            _ => Err(io::Error::last_os_error()),
4265        }
4266    }
4267    /// Remove the file `name` if it is a plain file of ours with no other
4268    /// name. A directory, a link or a FIFO under that name is left where it
4269    /// is.
4270    fn discard(&self, name: &str) -> io::Result<()> {
4271        let name = c_name(name)?;
4272        let mut stat: libc::stat = unsafe { std::mem::zeroed() };
4273        let found = unsafe {
4274            libc::fstatat(self.fd(), name.as_ptr(), &mut stat, libc::AT_SYMLINK_NOFOLLOW)
4275        };
4276        if found != 0 {
4277            return Err(io::Error::last_os_error());
4278        }
4279        let ours = stat.st_uid == unsafe { libc::geteuid() } && stat.st_nlink == 1;
4280        if stat.st_mode & libc::S_IFMT != libc::S_IFREG || !ours {
4281            return Ok(());
4282        }
4283        match unsafe { libc::unlinkat(self.fd(), name.as_ptr(), 0) } {
4284            0 => Ok(()),
4285            _ => Err(io::Error::last_os_error()),
4286        }
4287    }
4288    fn sync(&self) -> io::Result<()> {
4289        #[cfg(test)]
4290        if let Some(error) = fault("sync", c"") {
4291            return Err(error);
4292        }
4293        self.0.sync_all()
4294    }
4295}
4296// Where the C library keeps this thread's `errno`: the only thing that tells a
4297// failed `readdir` from the end of a directory. Each platform names it
4298// differently and `libc` has no common name for it; a Unix that is not listed
4299// here does not compile until its name is added.
4300#[cfg(target_os = "linux")]
4301use libc::__errno_location as errno;
4302#[cfg(any(target_vendor = "apple", target_os = "freebsd"))]
4303use libc::__error as errno;
4304#[cfg(any(target_os = "android", target_os = "netbsd", target_os = "openbsd"))]
4305use libc::__errno as errno;
4306// Failures a test asks for: system calls that fail only when a disk or the
4307// system is in trouble, which no temporary directory brings about. Each is
4308// (the call, the start of the names it fails for, the error number), for
4309// calls made on the thread that set it and until that thread clears it.
4310#[cfg(all(test, unix))]
4311thread_local! {
4312    static FAULTS: std::cell::RefCell<Vec<(&'static str, String, i32)>> =
4313        const { std::cell::RefCell::new(Vec::new()) };
4314}
4315#[cfg(all(test, unix))]
4316fn fault(call: &str, name: &std::ffi::CStr) -> Option<io::Error> {
4317    FAULTS.with_borrow(|faults| {
4318        let hit = |(failing, prefix, _): &&(&str, String, i32)| {
4319            *failing == call && name.to_bytes().starts_with(prefix.as_bytes())
4320        };
4321        let code = faults.iter().find(hit).map(|(.., code)| *code);
4322        code.map(io::Error::from_raw_os_error)
4323    })
4324}
4325#[cfg(unix)]
4326fn c_name(name: &str) -> io::Result<std::ffi::CString> {
4327    if name.is_empty() || matches!(name, "." | "..") || name.contains(['/', '\\']) {
4328        return Err(invalid("invalid archive component"));
4329    }
4330    std::ffi::CString::new(name).map_err(|_| invalid("invalid archive component"))
4331}
4332
4333#[cfg(not(unix))]
4334struct PrivateDir;
4335#[cfg(not(unix))]
4336impl PrivateDir {
4337    fn unsupported<T>() -> io::Result<T> {
4338        Err(io::Error::new(
4339            io::ErrorKind::Unsupported,
4340            "private context archive requires Unix",
4341        ))
4342    }
4343    fn new(_: &Path, _: &str) -> io::Result<Self> {
4344        Self::unsupported()
4345    }
4346    fn not_a_segment(_: &io::Error) -> bool {
4347        false
4348    }
4349    fn passing(_: &io::Error) -> bool {
4350        false
4351    }
4352    fn identity(&self) -> io::Result<DirIdentity> {
4353        Self::unsupported()
4354    }
4355    fn lock(&self) -> io::Result<FlockGuard> {
4356        Self::unsupported()
4357    }
4358    fn open_existing(_: &Path, _: &str) -> io::Result<Self> {
4359        Self::unsupported()
4360    }
4361    fn child(&self, _: &str, _: bool) -> io::Result<Self> {
4362        Self::unsupported()
4363    }
4364    fn open_child(&self, _: &str, _: bool) -> io::Result<Option<Self>> {
4365        Self::unsupported()
4366    }
4367    fn names(&self) -> io::Result<Vec<(String, bool, i64)>> {
4368        Self::unsupported()
4369    }
4370    fn lock_existing(&self) -> io::Result<Option<FlockGuard>> {
4371        Self::unsupported()
4372    }
4373    fn read_with(&self, _: &str, _: usize, _: bool) -> io::Result<Option<Vec<u8>>> {
4374        Self::unsupported()
4375    }
4376    fn publish(&self, _: &str, _: &[u8], _: bool) -> io::Result<()> {
4377        Self::unsupported()
4378    }
4379    fn publish_dated(
4380        &self,
4381        _: &str,
4382        _: &[u8],
4383        _: bool,
4384        _: Option<std::time::SystemTime>,
4385    ) -> io::Result<()> {
4386        Self::unsupported()
4387    }
4388    fn discard(&self, _: &str) -> io::Result<()> {
4389        Self::unsupported()
4390    }
4391    fn sync(&self) -> io::Result<()> {
4392        Self::unsupported()
4393    }
4394}
4395
4396#[cfg(test)]
4397mod projection_tests {
4398    use super::*;
4399
4400    fn excluded_fixture(blob: &str) -> Vec<SharedMessage> {
4401        vec![
4402            Arc::new(json!({"role":"system","content":blob})),
4403            Arc::new(json!({"role":"developer","content":blob})),
4404            Arc::new(json!({"role":"user","_synaps_context":{"note":blob},"content":blob})),
4405            Arc::new(json!({"role":"user","retention":"local_only","content":blob})),
4406            Arc::new(json!({"role":"assistant","metadata":blob,"content":[
4407                {"type":"thinking","thinking":blob},
4408                {"type":"text","private":true,"text":blob},
4409                {"type":"image","source":{"data":blob}},
4410                {"type":"document","source":{"data":blob}},
4411                {"type":"text","text":" exact evidence αβ\n","metadata":blob},
4412                {"type":"tool_use","id":"cp","name":"context_checkpoint","input":{"note":blob}}
4413            ]})),
4414            Arc::new(json!({"role":"user","content":[
4415                {"type":"tool_result","tool_use_id":"r1","metadata":blob,"content":[
4416                    {"type":"image","source":{"data":blob}},
4417                    {"type":"text","sensitivity":"secret","text":blob},
4418                    {"type":"text","text":"exact tool evidence\n","metadata":blob}
4419                ]}
4420            ]})),
4421        ]
4422    }
4423
4424    #[test]
4425    fn excluded_aggregate_does_not_consume_eligible_budget_or_change_digest() {
4426        // Every blob fits by itself; collectively the raw input exceeds 16MiB.
4427        let messages = excluded_fixture(&"x".repeat(2 * 1024 * 1024));
4428        let source = messages.clone();
4429        for redactor in [None, Some(Arc::new(str::to_owned) as Arc<ArchiveRedactor>)] {
4430            let small =
4431                project_archive("excluded", &excluded_fixture("x"), "note", redactor.clone())
4432                    .unwrap();
4433            let large = project_archive("excluded", &messages, "note", redactor).unwrap();
4434            assert!(small == large);
4435            assert_eq!(large.source_message_count, 6);
4436            assert_eq!(large.messages[0].source_index, 4);
4437            assert_eq!(large.messages[0].block_indices, vec![4, 5]);
4438        }
4439        for (old, new) in source.iter().zip(&messages) {
4440            assert!(Arc::ptr_eq(old, new));
4441        }
4442    }
4443
4444    #[test]
4445    fn eligible_aggregate_refuses_before_any_redactor_call() {
4446        use std::sync::atomic::{AtomicUsize, Ordering};
4447        let calls = Arc::new(AtomicUsize::new(0));
4448        let count = calls.clone();
4449        let redactor: Arc<ArchiveRedactor> = Arc::new(move |_| {
4450            count.fetch_add(1, Ordering::SeqCst);
4451            "small".to_owned()
4452        });
4453        let piece = "x".repeat(MAX_INPUT_BYTES / 2);
4454        for messages in [
4455            vec![
4456                Arc::new(json!({"role":"user","content":piece})),
4457                Arc::new(json!({"role":"assistant","content":piece})),
4458            ],
4459            vec![Arc::new(json!({"role":"user","content":[{
4460                "type":"tool_result","tool_use_id":"r1","content":[
4461                    {"type":"text","text":piece}, {"type":"text","text":piece}
4462                ]
4463            }]}))],
4464        ] {
4465            let before = messages.clone();
4466            let error = project_archive("overflow", &messages, "", Some(redactor.clone()))
4467                .err()
4468                .unwrap();
4469            assert!(error.to_string().contains("input byte budget"));
4470            assert_eq!(calls.load(Ordering::SeqCst), 0);
4471            assert_eq!(before, messages);
4472        }
4473    }
4474
4475    #[test]
4476    fn large_tool_results_exact_with_redactor_opaque_without_it() {
4477        let text = "evidence αβ\n".repeat(250_000);
4478        let make = |text: &str| {
4479            vec![Arc::new(json!({"role":"user","content":[{
4480                "type":"tool_result","tool_use_id":"r1","content":text
4481            }]}))]
4482        };
4483        let p = project_archive("logs", &make(&text), "", Some(Arc::new(str::to_owned))).unwrap();
4484        assert_eq!(p.messages[0].message["content"][0]["content"], text);
4485        let huge = make(&"x".repeat(MAX_INPUT_BYTES + 1));
4486        let p = project_archive("logs", &huge, "", None).unwrap();
4487        assert_eq!(p.messages[0].message["content"][0]["content"], WITHHELD);
4488        assert!(project_archive("logs", &huge, "", Some(Arc::new(str::to_owned))).is_err());
4489    }
4490
4491    #[test]
4492    fn whole_argument_withholding_discards_tentative_bytes_not_structural_guards() {
4493        let huge = "x".repeat(MAX_INPUT_BYTES + 1);
4494        let make = |input: Value| {
4495            vec![Arc::new(json!({"role":"assistant","content":[{
4496                "type":"tool_use","id":"r1","name":"read","input":input
4497            }]}))]
4498        };
4499        // Both orders: the otherwise eligible field can precede the exclusion.
4500        for input in [
4501            json!({"a":huge,"z":{"sensitivity":"secret","body":huge}}),
4502            json!({"a":{"sensitivity":"secret","body":huge},"z":huge}),
4503        ] {
4504            let p =
4505                project_archive("input", &make(input), "", Some(Arc::new(str::to_owned))).unwrap();
4506            assert_eq!(
4507                p.messages[0].message["content"][0]["input"],
4508                json!({"_archive_withheld":true})
4509            );
4510        }
4511        // Arbitrary binary-looking tool arguments are NOT content blocks.
4512        let input = json!({"type":"image","data":"YWJj"});
4513        let p = project_archive(
4514            "input",
4515            &make(input.clone()),
4516            "",
4517            Some(Arc::new(str::to_owned)),
4518        )
4519        .unwrap();
4520        assert_eq!(p.messages[0].message["content"][0]["input"], input);
4521        let large = make(json!({"type":"image","data":huge}));
4522        assert!(project_archive("input", &large, "", Some(Arc::new(str::to_owned))).is_err());
4523        assert!(project_archive("input", &large, "", None).is_ok());
4524    }
4525
4526    #[test]
4527    fn visited_structure_is_bounded_but_excluded_descendants_are_opaque() {
4528        let mut deep = json!("leaf");
4529        for _ in 0..40 {
4530            deep = json!([deep]);
4531        }
4532        let make = |input: Value| {
4533            vec![Arc::new(json!({"role":"assistant","content":[{
4534                "type":"tool_use","id":"r1","name":"read","input":input
4535            }]}))]
4536        };
4537        let identity = Some(Arc::new(str::to_owned) as Arc<ArchiveRedactor>);
4538        assert!(project_archive("depth", &make(deep.clone()), "", identity.clone()).is_err());
4539        assert!(project_archive("depth", &make(deep.clone()), "", None).is_ok());
4540        assert!(project_archive(
4541            "depth",
4542            &make(json!({"private":true,"blob":deep})),
4543            "",
4544            identity.clone()
4545        )
4546        .is_ok());
4547        // A malformed content block isn't recursively interpreted as a block array.
4548        assert!(project_archive(
4549            "depth",
4550            &[Arc::new(json!({"role":"user","content":deep}))],
4551            "",
4552            identity.clone()
4553        )
4554        .is_ok());
4555        let wide = vec![Value::Null; 65536];
4556        assert!(project_archive("nodes", &make(json!(wide)), "", identity.clone()).is_err());
4557        let excluded = vec![Arc::new(json!({"role":"user","content":wide}))];
4558        assert!(project_archive("nodes", &excluded, "", identity).is_err());
4559    }
4560
4561    #[test]
4562    fn withheld_after_redaction_text_is_not_scanned_without_redactor() {
4563        let messages = vec![Arc::new(json!({"role":"user",
4564            "disclosure":"model_visible_after_redaction", "content":"x".repeat(MAX_INPUT_BYTES+1)
4565        }))];
4566        let p = project_archive("withheld", &messages, "", None).unwrap();
4567        assert_eq!(p.messages[0].message["content"], WITHHELD);
4568        assert!(project_archive("withheld", &messages, "", Some(Arc::new(str::to_owned))).is_err());
4569    }
4570
4571    #[test]
4572    fn json_escape_scratch_can_exceed_segment_limit_before_redaction() {
4573        // 3MiB source ->18MiB JSON, accepted by the old raw preflight and then
4574        // reduced by the trusted redactor. Do not introduce a new 16MiB scratch cap.
4575        let input = json!({"text":"\0".repeat(3 * 1024 * 1024)});
4576        let messages = vec![Arc::new(json!({"role":"assistant","content":[{
4577            "type":"tool_use","id":"r1","name":"read","input":input
4578        }]}))];
4579        let redactor: Arc<ArchiveRedactor> = Arc::new(|s| {
4580            if s.starts_with('{') {
4581                assert!(s.len() > MAX_SEGMENT_BYTES);
4582                "{\"text\":\"screened\"}".to_owned()
4583            } else {
4584                s.to_owned()
4585            }
4586        });
4587        let p = project_archive("escaping", &messages, "", Some(redactor)).unwrap();
4588        assert_eq!(
4589            p.messages[0].message["content"][0]["input"],
4590            json!({"text":"screened"})
4591        );
4592        assert!(project_archive("escaping", &messages, "", Some(Arc::new(str::to_owned))).is_err());
4593    }
4594
4595    fn compatibility_fixture() -> Vec<SharedMessage> {
4596        vec![
4597            Arc::new(json!({"role":"system","content":"omitted"})),
4598            Arc::new(json!({"role":"user","content":" exact αβ\n"})),
4599            Arc::new(json!({"role":"assistant","content":[
4600                {"type":"thinking","thinking":"private"},
4601                {"type":"text","text":"classified-value"},
4602                {"type":"tool_use","id":"r1","name":"read","input":{"path":"src/lib.rs","n":7,"binary":{"type":"image","data":"YWJj"}}},
4603                {"type":"tool_use","id":"c1","name":"context_checkpoint","input":{"note":"hidden"}},
4604                {"type":"tool_use","id":"x1","name":"read","input":{"nested":{"disclosure":"local_only","body":"omitted"}}}
4605            ]})),
4606            Arc::new(json!({"role":"user","content":[
4607                {"type":"tool_result","tool_use_id":"r1","is_error":false,"content":[
4608                    {"type":"image","data":"omitted"},
4609                    {"type":"text","text":"exact result\n"},
4610                    {"type":"text","text":"classified-value","disclosure":"model_visible_after_redaction"}
4611                ]}
4612            ]})),
4613            Arc::new(
4614                json!({"role":"user","disclosure":"model_visible_after_redaction","content":"classified-value"}),
4615            ),
4616            Arc::new(json!({"role":"user","_synaps_context":{},"content":"hidden"})),
4617        ]
4618    }
4619
4620    #[test]
4621    fn legacy_projection_golden() {
4622        // Captured against the legacy raw-input preflight before this fix.
4623        for (redactor, digest) in [
4624            (
4625                None,
4626                "aeb544a71629f741def049575eefb1a765117d65aa914bdcc1657fa677622031",
4627            ),
4628            (
4629                Some(
4630                    Arc::new(|s: &str| s.replace("classified-value", "[redacted]"))
4631                        as Arc<ArchiveRedactor>,
4632                ),
4633                "d70b321210ca3d2bc531b31a35cc02ee267eeb572e8a6dd88f1fb62725b082ec",
4634            ),
4635        ] {
4636            let p = project_archive(
4637                "compat-session",
4638                &compatibility_fixture(),
4639                "hidden note",
4640                redactor,
4641            )
4642            .unwrap();
4643            assert_eq!(p.digest, digest);
4644        }
4645    }
4646
4647    #[test]
4648    fn pure_projection_keeps_legacy_logical_digest_and_never_opens_paths() {
4649        let tmp = tempfile::tempdir().unwrap();
4650        let logical = tmp
4651            .path()
4652            .join("not-created/session")
4653            .to_str()
4654            .unwrap()
4655            .to_owned();
4656        let messages = vec![
4657            Arc::new(json!({"role":"system","content":"OMITTED"})),
4658            Arc::new(json!({"role":"assistant","content":[
4659                {"type":"thinking","thinking":"OMITTED"},
4660                {"type":"text","text":" exact αβ evidence\n"}
4661            ]})),
4662        ];
4663        let source = serde_json::to_vec(&messages).unwrap();
4664        let first = project_archive(&logical, &messages, "hidden note", None).unwrap();
4665        assert_eq!(first.logical_id, digest_parts(&[logical.as_bytes()]));
4666        assert_eq!(
4667            first.digest,
4668            projection_digest(&first.logical_id, 2, &first.messages).unwrap()
4669        );
4670        assert_eq!(first.messages[0].source_index, 1);
4671        assert_eq!(first.messages[0].block_indices, vec![1]);
4672        assert_eq!(first.source_message_count, 2);
4673        let mut changed = messages.clone();
4674        changed[0] = Arc::new(json!({"role":"developer","content":"DIFFERENT OMITTED"}));
4675        let second = project_archive(&logical, &changed, "changed note", None).unwrap();
4676        assert_eq!(first.digest, second.digest);
4677        assert_ne!(
4678            first.digest,
4679            project_archive("other-session", &messages, "hidden note", None)
4680                .unwrap()
4681                .digest
4682        );
4683        assert_eq!(serde_json::to_vec(&messages).unwrap(), source);
4684        assert_eq!(std::fs::read_dir(tmp.path()).unwrap().count(), 0);
4685    }
4686
4687    #[test]
4688    fn pure_projection_shares_disclosure_redaction_and_input_limits() {
4689        let redactor: Arc<ArchiveRedactor> =
4690            Arc::new(|s| s.replace("classified-value", "[redacted]"));
4691        let messages = vec![
4692            Arc::new(json!({"role":"assistant","content":[
4693                {"type":"tool_use","id":"read_1","name":"read","input":{"path":"src/lib.rs"}},
4694                {"type":"tool_use","id":"cp","name":"context_checkpoint","input":{"note":"NOTE_ONLY"}}
4695            ]})),
4696            Arc::new(
4697                json!({"role":"user","content":[{"type":"tool_result","tool_use_id":"read_1","content":"classified-value"}]}),
4698            ),
4699            Arc::new(json!({"role":"user","sensitivity":"secret","content":"OMITTED"})),
4700            Arc::new(json!({"role":"user","_synaps_context":{},"content":"NOTE_ONLY"})),
4701            // The host's tool-in-flight note: never archived as user evidence.
4702            Arc::new(json!({"role":"user","content":crate::core::host_note::tool_in_flight_note_text(
4703                "Canceled by user",
4704                &["bash".into()],
4705            )})),
4706        ];
4707        let projected =
4708            project_archive("session", &messages, "classified-value", Some(redactor)).unwrap();
4709        assert_eq!(projected.note, "[redacted]");
4710        let json = serde_json::to_string(&projected.messages).unwrap();
4711        assert!(!json.contains("classified-value"));
4712        assert!(!json.contains("NOTE_ONLY"));
4713        assert!(!json.contains("partially applied"), "the tool note is not archived");
4714        assert!(!json.contains("OMITTED"));
4715        assert!(json.contains("src/lib.rs"));
4716        assert_eq!(projected.messages.len(), 2);
4717        let withheld = project_archive("session", &messages, "password=bad", None).unwrap();
4718        assert_eq!(withheld.note, WITHHELD);
4719        assert!(!serde_json::to_string(&withheld.messages)
4720            .unwrap()
4721            .contains("src/lib.rs"));
4722        assert!(project_archive("", &[], "", None).is_err());
4723        assert!(project_archive(&"l".repeat(4097), &[], "", None).is_err());
4724        assert!(project_archive("s", &[], &"n".repeat(MAX_NOTE_BYTES + 1), None).is_err());
4725        assert!(
4726            project_archive("s", &vec![messages[0].clone(); MAX_MESSAGES + 1], "", None).is_err()
4727        );
4728    }
4729}
4730
4731// Archive locks are non-blocking: a concurrently spawned test process can
4732// briefly hold an inherited lock descriptor until it executes, see
4733// memory::repository tests.
4734#[cfg(all(test, unix))]
4735#[serial_test::parallel(process_spawn)]
4736mod tests {
4737    use super::*;
4738    use std::os::unix::fs::{symlink, PermissionsExt};
4739
4740    fn msg(role: &str, content: Value) -> SharedMessage {
4741        Arc::new(json!({"role":role,"content":content}))
4742    }
4743    fn setup() -> (tempfile::TempDir, ArchiveStore) {
4744        let tmp = tempfile::tempdir().unwrap();
4745        let store = setup_in(tmp.path(), "synthetic-project");
4746        (tmp, store)
4747    }
4748    fn setup_in(base: &Path, project: &str) -> ArchiveStore {
4749        ArchiveStore::new(base, project, "synthetic-logical").unwrap()
4750    }
4751    fn directory(tmp: &tempfile::TempDir, store: &ArchiveStore) -> std::path::PathBuf {
4752        tmp.path().join("context-archives").join(&store.scope)
4753    }
4754    /// Every segment file on the shelves of a project directory, in path order.
4755    fn shelf_files(dir: &Path) -> Vec<PathBuf> {
4756        let mut files = Vec::new();
4757        for shelf in std::fs::read_dir(dir).unwrap() {
4758            let shelf = shelf.unwrap().path();
4759            if std::fs::symlink_metadata(&shelf).unwrap().is_dir() {
4760                let entries = std::fs::read_dir(&shelf).unwrap();
4761                files.extend(entries.map(|entry| entry.unwrap().path()));
4762            }
4763        }
4764        files.retain(|file| file.extension().is_some_and(|extension| extension == "json"));
4765        files.sort();
4766        files
4767    }
4768    /// The one segment file of a store that has sealed once.
4769    fn only_archive_file(dir: &Path) -> PathBuf {
4770        let files = shelf_files(dir);
4771        assert_eq!(files.len(), 1);
4772        assert_eq!(files[0].parent().unwrap(), dir.join("s000001"));
4773        files[0].clone()
4774    }
4775    /// A segment as stores wrote it before shelves existed: a random id in a
4776    /// numbered slot of the project directory.
4777    fn seal_legacy_slot(
4778        store: &ArchiveStore,
4779        slot: usize,
4780        messages: &[SharedMessage],
4781        note: &str,
4782    ) -> ArchiveRef {
4783        let projection =
4784            project_hashed(&store.logical, messages, note, None, Reduce::None).unwrap();
4785        let record = Record {
4786            version: 1,
4787            scope: store.scope.clone(),
4788            logical: store.logical.clone(),
4789            id: uuid::Uuid::new_v4().simple().to_string(),
4790            digest: projection.digest,
4791            // As every record was then: without the field.
4792            created_ms: None,
4793            body: Body::Live {
4794                source_message_count: projection.source_message_count,
4795                messages: projection.messages,
4796                note: projection.note,
4797            },
4798        };
4799        let bytes = bounded_json(&record, MAX_SEGMENT_BYTES).unwrap();
4800        let _lock = store.exclusive().unwrap();
4801        store.fs.publish(&slot_name(slot), &bytes, false).unwrap();
4802        record.reference().unwrap()
4803    }
4804    fn text(content: &str) -> Vec<SharedMessage> {
4805        vec![msg("user", json!(content))]
4806    }
4807    fn mode(path: &Path) -> u32 {
4808        std::fs::metadata(path).unwrap().permissions().mode() & 0o777
4809    }
4810    /// What tells a file from another one with the same bytes under the same
4811    /// name: its device and inode.
4812    fn identity(path: &Path) -> (u64, u64) {
4813        use std::os::unix::fs::MetadataExt;
4814        let meta = std::fs::symlink_metadata(path).unwrap();
4815        (meta.dev(), meta.ino())
4816    }
4817    /// Every temporary file under a project directory.
4818    fn temporary_files(dir: &Path) -> Vec<PathBuf> {
4819        let mut found = Vec::new();
4820        let mut pending = vec![dir.to_path_buf()];
4821        while let Some(directory) = pending.pop() {
4822            for entry in std::fs::read_dir(&directory).unwrap() {
4823                let path = entry.unwrap().path();
4824                if is_temp_name(path.file_name().unwrap().to_str().unwrap()) {
4825                    found.push(path);
4826                } else if std::fs::symlink_metadata(&path).unwrap().is_dir() {
4827                    pending.push(path);
4828                }
4829            }
4830        }
4831        found
4832    }
4833    /// Make the plain file `path` one that was last written `age` ago.
4834    fn written_ago(path: &Path, age: Duration) {
4835        let file = std::fs::File::options().write(true).open(path).unwrap();
4836        file.set_modified(std::time::SystemTime::now() - age).unwrap();
4837    }
4838    /// Path -> SHA-256 and identity of every file under a directory: equal
4839    /// only if no file was changed, added, removed or replaced, even by one
4840    /// with the same bytes.
4841    fn snapshot(dir: &Path) -> std::collections::BTreeMap<PathBuf, String> {
4842        let mut files = std::collections::BTreeMap::new();
4843        let mut pending = vec![dir.to_path_buf()];
4844        while let Some(directory) = pending.pop() {
4845            for entry in std::fs::read_dir(&directory).unwrap() {
4846                let path = entry.unwrap().path();
4847                if std::fs::symlink_metadata(&path).unwrap().is_dir() {
4848                    pending.push(path);
4849                } else {
4850                    let hash = digest_parts(&[&std::fs::read(&path).unwrap()]);
4851                    let file = format!("{hash} {:?}", identity(&path));
4852                    files.insert(path, file);
4853                }
4854            }
4855        }
4856        files
4857    }
4858
4859    #[test]
4860    fn attachments_remain_outside_eligible_memory_archive() {
4861        let (tmp, store) = setup();
4862        let messages = vec![msg(
4863            "user",
4864            json!([
4865                {"type":"text","text":"Compare the selected files"},
4866                {"type":"image","source":{"type":"base64","media_type":"image/png","data":"ATTACHMENT_IMAGE_SECRET"}},
4867                {"type":"document","title":"ATTACHMENT_FILENAME_SECRET","source":{"type":"base64","media_type":"application/pdf","data":"ATTACHMENT_PDF_SECRET"}},
4868                {"type":"document","source":{"type":"text","media_type":"text/plain","data":"ATTACHMENT_TEXT_SECRET"}}
4869            ]),
4870        )];
4871        let reference = store.seal(&messages, "safe checkpoint").unwrap();
4872        let fetched = store.fetch(&reference.id, 0, 10).unwrap();
4873        let serialized = serde_json::to_string(&fetched).unwrap();
4874        assert!(serialized.contains("Compare the selected files"));
4875        assert!(!serialized.contains("ATTACHMENT_"));
4876        let raw = std::fs::read_to_string(only_archive_file(&directory(&tmp, &store))).unwrap();
4877        assert!(!raw.contains("ATTACHMENT_"));
4878        assert!(messages[0].to_string().contains("ATTACHMENT_TEXT_SECRET"));
4879    }
4880
4881    #[test]
4882    fn exact_text_source_indices_note_and_restart() {
4883        let (tmp, store) = setup();
4884        let messages = vec![
4885            msg("system", json!("FORBIDDEN SYSTEM")),
4886            msg("user", json!(" exact\n αβ  ")),
4887            msg(
4888                "assistant",
4889                json!([
4890                    {"type":"thinking","thinking":"FORBIDDEN REASONING"},
4891                    {"type":"text","text":"evidence✓", "cache_control":{"type":"ephemeral"}},
4892                    {"type":"image","data":"FORBIDDEN IMAGE"}
4893                ]),
4894            ),
4895            msg("developer", json!("FORBIDDEN DEVELOPER")),
4896        ];
4897        let reference = store.seal(&messages, "hidden routing note").unwrap();
4898        assert_eq!(reference.message_count, 2);
4899        assert_eq!(reference.source_message_count, 4);
4900        let reopened =
4901            ArchiveStore::new(tmp.path(), "synthetic-project", "synthetic-logical").unwrap();
4902        let fetched = reopened
4903            .fetch_with_provenance(&reference.id, 0, 128)
4904            .unwrap();
4905        assert_eq!(fetched[0].message, messages[1]);
4906        assert_eq!(fetched[0].source_index, 1);
4907        assert_eq!(fetched[1].source_index, 2);
4908        assert_eq!(fetched[1].block_indices, vec![1]);
4909        assert_eq!(
4910            reopened.fetch_note(&reference.id).unwrap(),
4911            "hidden routing note"
4912        );
4913        assert!(reopened.search("hidden routing", 10).unwrap().is_empty());
4914        assert_eq!(reopened.search("evidence", 10).unwrap()[0].id, reference.id);
4915        assert!(reopened
4916            .fetch(&reference.id, usize::MAX, 10)
4917            .unwrap()
4918            .is_empty());
4919        let raw = std::fs::read_to_string(only_archive_file(&directory(&tmp, &store))).unwrap();
4920        assert!(!raw.contains("FORBIDDEN"));
4921        assert!(!raw.contains("cache_control"));
4922        assert_eq!(messages[0]["content"], "FORBIDDEN SYSTEM");
4923    }
4924
4925    #[test]
4926    fn duplicate_retry_ignores_note_but_not_logical_id() {
4927        let (tmp, store) = setup();
4928        let messages = vec![msg("user", json!("same eligible words"))];
4929        let first = store.seal(&messages, "first").unwrap();
4930        assert_eq!(store.seal(&messages, "second").unwrap(), first);
4931        assert_eq!(store.fetch_note(&first.id).unwrap(), "first");
4932        let other = ArchiveStore::new(tmp.path(), "synthetic-project", "other-logical").unwrap();
4933        let second = other.seal(&messages, "").unwrap();
4934        assert_ne!(first.id, second.id);
4935        assert_eq!(
4936            project_search(tmp.path(), "synthetic-project", "", 10)
4937                .unwrap()
4938                .len(),
4939            2
4940        );
4941        assert_eq!(
4942            project_fetch(tmp.path(), "synthetic-project", &first.id, 0, 10).unwrap(),
4943            messages
4944        );
4945        assert_eq!(other.fetch(&first.id, 0, 1).unwrap(), messages);
4946        store.forget(&first.id).unwrap();
4947        assert!(store.seal(&messages, "third").is_err());
4948        assert_eq!(other.fetch(&second.id, 0, 1).unwrap(), messages);
4949        assert_eq!(other.seal(&messages, "third").unwrap(), second);
4950    }
4951
4952    #[test]
4953    fn deletion_is_content_free_durable_and_cannot_resurrect() {
4954        let (tmp, store) = setup();
4955        let messages = vec![msg("user", json!("uniquely removable content"))];
4956        let reference = store.seal(&messages, "removable note").unwrap();
4957        store.forget(&reference.id).unwrap();
4958        store.forget(&reference.id).unwrap();
4959        let raw = std::fs::read_to_string(only_archive_file(&directory(&tmp, &store))).unwrap();
4960        assert!(raw.contains("tombstone"));
4961        assert!(!raw.contains("removable"));
4962        assert!(!raw.contains("source_message_count"));
4963        let store =
4964            ArchiveStore::new(tmp.path(), "synthetic-project", "synthetic-logical").unwrap();
4965        assert_eq!(
4966            store.fetch(&reference.id, 0, 1).unwrap_err().kind(),
4967            io::ErrorKind::NotFound
4968        );
4969        assert!(store.fetch_note(&reference.id).is_err());
4970        assert!(store.search("", 10).unwrap().is_empty());
4971        assert_eq!(
4972            store.seal(&messages, "changed note").unwrap_err().kind(),
4973            io::ErrorKind::PermissionDenied
4974        );
4975    }
4976
4977    #[test]
4978    fn foreign_scope_copied_records_and_traversal_fail_closed() {
4979        let (tmp, store) = setup();
4980        let reference = store
4981            .seal(&[msg("user", json!("scope evidence"))], "")
4982            .unwrap();
4983        let foreign =
4984            ArchiveStore::new(tmp.path(), "foreign-project", "synthetic-logical").unwrap();
4985        assert!(foreign.fetch(&reference.id, 0, 1).is_err());
4986        for id in [
4987            "../000.json",
4988            "/tmp/file",
4989            "",
4990            "a/../b",
4991            "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
4992        ] {
4993            assert_eq!(
4994                store.fetch(id, 0, 1).unwrap_err().kind(),
4995                io::ErrorKind::InvalidInput
4996            );
4997            assert!(store.forget(id).is_err());
4998        }
4999        // Copied into the foreign project, as a shelf file or as a legacy slot,
5000        // the record is invalid there: never fetched, and search passes over it.
5001        let source = only_archive_file(&directory(&tmp, &store));
5002        let foreign_dir = directory(&tmp, &foreign);
5003        std::fs::create_dir(foreign_dir.join("s000001")).unwrap();
5004        for target in [
5005            foreign_dir.join("s000001").join(source.file_name().unwrap()),
5006            foreign_dir.join("000.json"),
5007        ] {
5008            std::fs::copy(&source, &target).unwrap();
5009            assert_eq!(
5010                foreign.fetch(&reference.id, 0, 1).unwrap_err().kind(),
5011                io::ErrorKind::InvalidData
5012            );
5013            assert!(foreign.search("", 1).unwrap().is_empty());
5014            std::fs::remove_file(&target).unwrap();
5015        }
5016        assert!(ArchiveStore::new(&tmp.path().join("../escape"), "p", "l").is_err());
5017    }
5018
5019    #[test]
5020    fn disclosure_and_raw_tool_data_are_withheld() {
5021        let (tmp, store) = setup();
5022        let mut messages = vec![
5023            msg(
5024                "assistant",
5025                json!([{"type":"tool_use","id":"call_1","name":"read","input":{"path":"unclassified raw argument"}}]),
5026            ),
5027            msg(
5028                "user",
5029                json!([{"type":"tool_result","tool_use_id":"call_1","content":"unclassified raw output"}]),
5030            ),
5031            msg("user", json!("password=synthetic-credential")),
5032        ];
5033        for class in [
5034            "local_only",
5035            "persist_never_transmit",
5036            "never_persist",
5037            "model_visible_after_consent",
5038            "unknown",
5039        ] {
5040            messages.push(Arc::new(
5041                json!({"role":"user","content":"FORBIDDEN DISCLOSURE","disclosure":class}),
5042            ));
5043        }
5044        messages.push(Arc::new(
5045            json!({"role":"user","content":"FORBIDDEN SENSITIVITY","sensitivity":"secret"}),
5046        ));
5047        let reference = store.seal(&messages, "").unwrap();
5048        assert_eq!(reference.message_count, 3);
5049        let raw = std::fs::read_to_string(only_archive_file(&directory(&tmp, &store))).unwrap();
5050        for forbidden in ["unclassified raw", "synthetic-credential", "FORBIDDEN"] {
5051            assert!(!raw.contains(forbidden));
5052        }
5053        assert!(raw.contains("call_1"));
5054        assert!(raw.contains("_archive_withheld"));
5055    }
5056
5057    #[test]
5058    fn derived_context_envelope_is_not_searchable_source() {
5059        let (_tmp, store) = setup();
5060        let first = store
5061            .seal(
5062                &[msg("assistant", json!("original evidence"))],
5063                "NOTE_ONLY_SENTINEL",
5064            )
5065            .unwrap();
5066        let second=store.seal(&[
5067            Arc::new(json!({"role":"user","_synaps_context":{"schema":"synaps-context-window/1","archive":first.id},"content":"NOTE_ONLY_SENTINEL"})),
5068            Arc::new(json!({"role":"assistant","content":[{"type":"tool_use","id":"checkpoint-note","name":"context_checkpoint","input":{"phase":"plan","note":"NOTE_ONLY_SENTINEL"}}]})),
5069            msg("assistant",json!("second-window evidence")),
5070        ],"next note").unwrap();
5071        assert!(store.search("NOTE_ONLY_SENTINEL", 10).unwrap().is_empty());
5072        let fetched = store.fetch_with_provenance(&second.id, 0, 10).unwrap();
5073        assert_eq!(fetched.len(), 2);
5074        assert_eq!(fetched[0].source_index, 1);
5075        assert_eq!(fetched[1].source_index, 2);
5076        assert!(!serde_json::to_string(&fetched)
5077            .unwrap()
5078            .contains("NOTE_ONLY_SENTINEL"));
5079    }
5080
5081    #[test]
5082    fn checkpoint_tool_note_stays_hidden_even_with_redactor() {
5083        let (_tmp, store) = setup();
5084        let store = store.with_redactor(Arc::new(str::to_owned));
5085        let reference = store
5086            .seal(
5087                &[msg(
5088                    "assistant",
5089                    json!([
5090                        {"type":"tool_use","id":"checkpoint-note","name":"context_checkpoint",
5091                         "input":{"phase":"plan","note":"NOTE_ARGUMENT_SENTINEL"}}
5092                    ]),
5093                )],
5094                "NOTE_ARGUMENT_SENTINEL",
5095            )
5096            .unwrap();
5097        assert!(store
5098            .search("NOTE_ARGUMENT_SENTINEL", 10)
5099            .unwrap()
5100            .is_empty());
5101        assert!(
5102            !serde_json::to_string(&store.fetch(&reference.id, 0, 10).unwrap())
5103                .unwrap()
5104                .contains("NOTE_ARGUMENT_SENTINEL")
5105        );
5106        assert_eq!(
5107            store.fetch_note(&reference.id).unwrap(),
5108            "NOTE_ARGUMENT_SENTINEL"
5109        );
5110    }
5111
5112    #[test]
5113    fn trusted_redactor_retains_exact_eligible_tool_evidence() {
5114        let (_tmp, store) = setup();
5115        let store = store.with_redactor(Arc::new(|s| s.replace("classified-value", "[redacted]")));
5116        let messages = vec![
5117            msg(
5118                "assistant",
5119                json!([{"type":"tool_use","id":"call_1","name":"read","input":{"path":"src/lib.rs","line":7}}]),
5120            ),
5121            msg(
5122                "user",
5123                json!([{"type":"tool_result","tool_use_id":"call_1","is_error":false,"content":"line 7\n  exact evidence\nclassified-value"}]),
5124            ),
5125        ];
5126        let reference = store.seal(&messages, "").unwrap();
5127        let fetched = store.fetch(&reference.id, 0, 10).unwrap();
5128        assert_eq!(fetched[0], messages[0]);
5129        assert_eq!(
5130            fetched[1]["content"][0]["content"],
5131            "line 7\n  exact evidence\n[redacted]"
5132        );
5133    }
5134
5135    #[test]
5136    fn oversized_input_output_limits_and_depth_leave_source_intact() {
5137        let (_tmp, store) = setup();
5138        let huge = vec![msg("user", json!("x".repeat(MAX_INPUT_BYTES + 1)))];
5139        assert!(store.seal(&huge, "").is_err());
5140        assert_eq!(
5141            huge[0]["content"].as_str().unwrap().len(),
5142            MAX_INPUT_BYTES + 1
5143        );
5144        let file_too_large = vec![msg("user", json!("\n".repeat(MAX_SEGMENT_BYTES / 2)))];
5145        assert!(store.seal(&file_too_large, "").is_err());
5146        assert!(store
5147            .seal(&vec![msg("user", json!("a")); MAX_MESSAGES + 1], "")
5148            .is_err());
5149        assert!(store.seal(&[], &"n".repeat(MAX_NOTE_BYTES + 1)).is_err());
5150        let mut deep = json!("value");
5151        for _ in 0..40 {
5152            deep = json!([deep]);
5153        }
5154        // Unknown content blocks are opaque; admitted tool JSON still has a
5155        // strict recursion limit before serialization or redaction.
5156        let deep = vec![msg(
5157            "assistant",
5158            json!([{
5159                "type":"tool_use", "id":"r1", "name":"read", "input":deep
5160            }]),
5161        )];
5162        let store = store.with_redactor(Arc::new(str::to_owned));
5163        assert!(store.seal(&deep, "").is_err());
5164        assert!(store.search(&"q".repeat(513), 1).is_err());
5165        assert!(store.search("", MAX_SEARCH_RESULTS + 1).is_err());
5166        assert!(store
5167            .fetch(&"a".repeat(32), 0, MAX_FETCH_MESSAGES + 1)
5168            .is_err());
5169        assert!(store.search("", 10).unwrap().is_empty());
5170    }
5171
5172    #[test]
5173    fn torn_final_fails_closed_stale_temporary_is_not_visible() {
5174        let (tmp, store) = setup();
5175        let dir = directory(&tmp, &store);
5176        let reference = store.seal(&text("complete"), "").unwrap();
5177        let file = only_archive_file(&dir);
5178        let stale = dir.join("s000001").join("pending.tmp");
5179        std::fs::write(&stale, b"unpublished partial").unwrap();
5180        written_ago(&stale, STALE_TEMP * 2);
5181        assert_eq!(store.search("", 2).unwrap().len(), 1);
5182        let other = store.seal(&text("another"), "").unwrap();
5183        assert!(!stale.exists());
5184        std::fs::write(&file, b"{\"version\":1,").unwrap();
5185        let torn = identity(&file);
5186        for result in [
5187            store.fetch(&reference.id, 0, 1).map(drop),
5188            store.fetch_note(&reference.id).map(drop),
5189            store.forget(&reference.id).map(drop),
5190        ] {
5191            assert_eq!(result.unwrap_err().kind(), io::ErrorKind::InvalidData);
5192        }
5193        // The torn file is neither trusted as the same projection nor
5194        // replaced, and it does not refuse the seal: the window is stored
5195        // under its other id.
5196        let again = store.seal(&text("complete"), "").unwrap();
5197        assert_ne!(again.id, reference.id);
5198        assert_eq!(store.fetch(&again.id, 0, 1).unwrap(), text("complete"));
5199        assert_eq!(std::fs::read(&file).unwrap(), b"{\"version\":1,");
5200        assert_eq!(identity(&file), torn);
5201        // Other segments do not depend on it.
5202        assert_eq!(store.fetch(&other.id, 0, 1).unwrap(), text("another"));
5203        assert!(store.seal(&text("next"), "").is_ok());
5204    }
5205
5206    #[test]
5207    fn private_modes_symlink_hardlink_fifo_and_lock_refusal() {
5208        let (tmp, store) = setup();
5209        let dir = directory(&tmp, &store);
5210        let reference = store.seal(&text("private"), "").unwrap();
5211        let file = only_archive_file(&dir);
5212        for path in [
5213            tmp.path().to_path_buf(),
5214            tmp.path().join("context-archives"),
5215            dir.clone(),
5216            dir.join("s000001"),
5217        ] {
5218            assert_eq!(mode(&path), 0o700);
5219        }
5220        for path in [&file, &dir.join("lock")] {
5221            assert_eq!(mode(path), 0o600);
5222        }
5223        // Loosened modes are made private again by the next seal or forget,
5224        // but not by the read-only path lookup.
5225        std::fs::set_permissions(dir.join("s000001"), std::fs::Permissions::from_mode(0o755))
5226            .unwrap();
5227        std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o644)).unwrap();
5228        let located = archive_file(tmp.path(), "synthetic-project", &reference.id).unwrap();
5229        assert_eq!(located, Some(file.clone()));
5230        assert_eq!((mode(&dir.join("s000001")), mode(&file)), (0o755, 0o644));
5231        store.seal(&text("private"), "").unwrap();
5232        assert_eq!((mode(&dir.join("s000001")), mode(&file)), (0o700, 0o600));
5233        let victim = tmp.path().join("synthetic-victim");
5234        std::fs::write(&victim, "do not change").unwrap();
5235        std::fs::set_permissions(&victim, std::fs::Permissions::from_mode(0o644)).unwrap();
5236        let refused = || {
5237            // A search passes over the file and says so.
5238            let (_, unscanned) = store.search_scan("", 2).unwrap();
5239            let unread = unscanned.unwrap().unread.unwrap();
5240            let place = format!("first s000001/{}: ", archive_name(&reference.id));
5241            assert!(unread.starts_with("1 place(s) could not be read, ") && unread.contains(&place));
5242            assert!(store.fetch(&reference.id, 0, 1).is_err());
5243            assert!(store.fetch_note(&reference.id).is_err());
5244            assert!(store.forget(&reference.id).is_err());
5245            // The same projection would be published under this very name.
5246            // What has the name is neither followed nor replaced, and the
5247            // seal is not refused: it uses the projection's other id.
5248            let other = store.seal(&text("private"), "").unwrap();
5249            assert_ne!(other.id, reference.id);
5250            assert_eq!(store.fetch(&other.id, 0, 1).unwrap(), text("private"));
5251            assert!(archive_file(tmp.path(), "synthetic-project", &reference.id).is_err());
5252            assert_eq!(std::fs::read_to_string(&victim).unwrap(), "do not change");
5253            assert_eq!(mode(&victim), 0o644);
5254        };
5255        std::fs::remove_file(&file).unwrap();
5256        symlink(&victim, &file).unwrap();
5257        refused();
5258        std::fs::remove_file(&file).unwrap();
5259        std::fs::hard_link(&victim, &file).unwrap();
5260        refused();
5261        std::fs::remove_file(&file).unwrap();
5262        let fifo = std::ffi::CString::new(file.to_str().unwrap()).unwrap();
5263        assert_eq!(unsafe { libc::mkfifo(fifo.as_ptr(), 0o600) }, 0);
5264        refused();
5265        std::fs::remove_file(dir.join("lock")).unwrap();
5266        symlink(&victim, dir.join("lock")).unwrap();
5267        assert!(store.fs.lock().is_err());
5268    }
5269
5270    /// The lock file is only ever locked, so a second name (a hard-link
5271    /// backup) or a read-only mode (a restore) does not refuse a save. What
5272    /// is not a regular file of ours is refused, and the error names it.
5273    #[test]
5274    fn a_lock_file_with_two_names_or_read_only_is_used_and_a_link_is_refused_by_name() {
5275        let (tmp, store) = setup();
5276        let dir = directory(&tmp, &store);
5277        let lock = dir.join("lock");
5278        let mut other = setup_in(tmp.path(), "synthetic-project");
5279        other.process_wait = Duration::ZERO;
5280        store.seal(&text("first"), "").unwrap();
5281        // A fetch of a legacy slot takes the lock shared, from a lock file
5282        // it only opens: the same lock file, the same answers.
5283        let legacy = seal_legacy_slot(&store, 0, &text("sealed long ago"), "");
5284        let works = |words: &str| {
5285            assert_eq!(store.fetch(&legacy.id, 0, 1).unwrap(), text("sealed long ago"), "{words}");
5286            let sealed = store.seal(&text(words), "").unwrap();
5287            assert_eq!(store.forget(&sealed.id).unwrap(), forgotten(&[&sealed.id]));
5288            // And it still excludes: a save by another store is busy while
5289            // the lock is held, and goes through once it is not.
5290            let held = store.fs.lock().unwrap();
5291            assert!(store.fs.lock().is_err_and(|error| error.kind() == io::ErrorKind::WouldBlock));
5292            let busy = other.seal(&text(words), "").unwrap_err();
5293            assert_eq!(busy.kind(), io::ErrorKind::WouldBlock, "{words}: {busy}");
5294            assert_eq!(busy.to_string(), BUSY);
5295            drop(held);
5296            assert!(other.seal(&text(&format!("{words}, after")), "").is_ok());
5297        };
5298        // A second name, as `cp -al` of the base gives every file.
5299        let second = tmp.path().join("a-backup-of-the-lock");
5300        std::fs::hard_link(&lock, &second).unwrap();
5301        works("with two names");
5302        // Open to others and with two names: the mode is the other name's
5303        // too, and is left.
5304        std::fs::set_permissions(&lock, std::fs::Permissions::from_mode(0o644)).unwrap();
5305        works("loosened, with two names");
5306        assert_eq!((mode(&lock), mode(&second)), (0o644, 0o644));
5307        // Read-only, with the second name still there and then without.
5308        for links in [2, 1] {
5309            std::fs::set_permissions(&lock, std::fs::Permissions::from_mode(0o400)).unwrap();
5310            works(&format!("read-only, {links} name(s)"));
5311            assert_eq!(mode(&lock), 0o400, "left as it is");
5312            if links == 2 {
5313                assert_eq!(identity(&lock), identity(&second), "not replaced");
5314                std::fs::remove_file(&second).unwrap();
5315            }
5316        }
5317        assert_eq!(std::fs::metadata(&lock).unwrap().len(), 0, "never written");
5318        // Open to others: closed to them again, as it always was.
5319        std::fs::set_permissions(&lock, std::fs::Permissions::from_mode(0o644)).unwrap();
5320        works("loosened");
5321        assert_eq!(mode(&lock), 0o600);
5322
5323        // Not a regular file of ours: refused, named, and left as it is.
5324        let victim = tmp.path().join("synthetic-victim");
5325        std::fs::write(&victim, "do not change").unwrap();
5326        std::fs::set_permissions(&victim, std::fs::Permissions::from_mode(0o644)).unwrap();
5327        let sealed = store.seal(&text("to forget later"), "").unwrap();
5328        let refused = |what: &str| {
5329            let before = snapshot(&dir.join("s000001"));
5330            let errors = [
5331                store.seal(&text("refused"), "").unwrap_err(),
5332                store.forget(&sealed.id).unwrap_err(),
5333                store.fetch(&legacy.id, 0, 1).unwrap_err(),
5334            ];
5335            for error in errors {
5336                assert_eq!(error.kind(), io::ErrorKind::PermissionDenied, "{what}: {error}");
5337                let message = error.to_string();
5338                let named = format!("context archive lock file {} {what}", lock.display());
5339                assert!(message.starts_with(&named), "{message}");
5340                assert!(message.contains(&format!("rm '{}'", lock.display())), "{message}");
5341            }
5342            assert_eq!(snapshot(&dir.join("s000001")), before, "{what}");
5343            // A read by id of a shelved archive needs no lock.
5344            assert_eq!(store.fetch(&sealed.id, 0, 1).unwrap(), text("to forget later"));
5345        };
5346        std::fs::remove_file(&lock).unwrap();
5347        symlink(&victim, &lock).unwrap();
5348        refused("is a link");
5349        assert!(std::fs::symlink_metadata(&lock).unwrap().file_type().is_symlink());
5350        assert_eq!(std::fs::read_to_string(&victim).unwrap(), "do not change");
5351        assert_eq!(mode(&victim), 0o644);
5352        std::fs::remove_file(&lock).unwrap();
5353        std::fs::create_dir(&lock).unwrap();
5354        refused("is not a regular file");
5355        std::fs::remove_dir(&lock).unwrap();
5356        let fifo = std::ffi::CString::new(lock.to_str().unwrap()).unwrap();
5357        assert_eq!(unsafe { libc::mkfifo(fifo.as_ptr(), 0o600) }, 0);
5358        refused("is not a regular file");
5359        std::fs::remove_file(&lock).unwrap();
5360        if unsafe { libc::geteuid() } != 0 {
5361            std::fs::write(&lock, b"").unwrap();
5362            std::fs::set_permissions(&lock, std::fs::Permissions::from_mode(0o000)).unwrap();
5363            refused("cannot be opened by its owner with its mode");
5364            std::fs::remove_file(&lock).unwrap();
5365        }
5366        // The fix the message gives is the fix.
5367        assert_eq!(store.forget(&sealed.id).unwrap(), forgotten(&[&sealed.id]));
5368        assert_eq!(mode(&lock), 0o600);
5369    }
5370
5371    #[test]
5372    fn lock_free_read_does_not_change_file_mode() {
5373        let (tmp, store) = setup();
5374        let dir = directory(&tmp, &store);
5375        let reference = store.seal(&text("private"), "note").unwrap();
5376        let (shelf, file) = (dir.join("s000001"), only_archive_file(&dir));
5377        std::fs::set_permissions(&shelf, std::fs::Permissions::from_mode(0o755)).unwrap();
5378        std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o644)).unwrap();
5379        assert_eq!(store.fetch(&reference.id, 0, 1).unwrap(), text("private"));
5380        assert_eq!(store.fetch_note(&reference.id).unwrap(), "note");
5381        assert_eq!(store.search("private", 1).unwrap().len(), 1);
5382        assert_eq!((mode(&shelf), mode(&file)), (0o755, 0o644));
5383        // The next operation that holds the lock repairs both.
5384        assert_eq!(store.seal(&text("private"), "").unwrap(), reference);
5385        assert_eq!((mode(&shelf), mode(&file)), (0o700, 0o600));
5386    }
5387
5388    /// Mode, change time and modification time.
5389    type Stamp = (u32, (i64, i64), (i64, i64));
5390    /// The stamp of everything under `root`, itself included: equal only if
5391    /// nothing was created, removed, written, renamed or had its mode set
5392    /// (even to what it already was).
5393    fn stamps(root: &Path) -> std::collections::BTreeMap<PathBuf, Stamp> {
5394        use std::os::unix::fs::MetadataExt;
5395        let mut found = std::collections::BTreeMap::new();
5396        let mut pending = vec![root.to_path_buf()];
5397        while let Some(path) = pending.pop() {
5398            let meta = std::fs::symlink_metadata(&path).unwrap();
5399            if meta.is_dir() {
5400                let entries = std::fs::read_dir(&path).unwrap();
5401                pending.extend(entries.map(|entry| entry.unwrap().path()));
5402            }
5403            let changed = (meta.ctime(), meta.ctime_nsec());
5404            let modified = (meta.mtime(), meta.mtime_nsec());
5405            found.insert(path, (meta.mode(), changed, modified));
5406        }
5407        found
5408    }
5409
5410    /// Every read there is, each through a store opened for it there and
5411    /// then: what the first read in a process does.
5412    fn read_everything(
5413        base: &Path,
5414        live: &[(&ArchiveRef, &str, &str)],
5415        gone: &[String],
5416        rows: usize,
5417    ) {
5418        let open = || ArchiveStore::open_existing(base, "synthetic-project", "any-reader");
5419        for (reference, words, note) in live {
5420            let id = &reference.id;
5421            assert_eq!(open().unwrap().unwrap().fetch(id, 0, 1).unwrap(), text(words));
5422            assert_eq!(&open().unwrap().unwrap().fetch_note(id).unwrap(), note);
5423            let found = project_fetch(base, "synthetic-project", id, 0, 1);
5424            assert_eq!(found.unwrap(), text(words));
5425            let located = archive_file(base, "synthetic-project", id).unwrap().unwrap();
5426            assert!(std::fs::read_to_string(located).unwrap().contains(words));
5427            let found = open().unwrap().unwrap().search(words, 8).unwrap();
5428            assert_eq!(found.len(), 1, "{words}");
5429            assert_eq!(found[0].id, *id);
5430        }
5431        for id in gone {
5432            for result in [
5433                open().unwrap().unwrap().fetch(id, 0, 1).map(drop),
5434                open().unwrap().unwrap().fetch_note(id).map(drop),
5435                project_fetch(base, "synthetic-project", id, 0, 1).map(drop),
5436            ] {
5437                let error = result.unwrap_err();
5438                assert_eq!(error.kind(), io::ErrorKind::NotFound);
5439                assert_eq!(error.to_string(), NOT_FOUND);
5440            }
5441        }
5442        assert_eq!(open().unwrap().unwrap().search("", 8).unwrap().len(), rows);
5443        assert_eq!(project_search(base, "synthetic-project", "", 8).unwrap().len(), rows);
5444        assert_eq!(open().unwrap().unwrap().search_scan("", 8).unwrap().1, None);
5445    }
5446
5447    /// A read writes nothing, the first one included: no directory, no lock
5448    /// file, no mode, no change time, no sync. So reads also work where
5449    /// nothing can be written. `ArchiveStore::new` did all of that on every
5450    /// open, and the legacy slots were read with the lock file created and
5451    /// their modes set.
5452    #[test]
5453    fn reads_write_nothing_and_work_where_nothing_can_be_written() {
5454        let (tmp, store) = setup();
5455        let dir = directory(&tmp, &store);
5456        let shelved = store.seal(&text("read only"), "note").unwrap();
5457        let forgotten = store.seal(&text("forgotten"), "").unwrap();
5458        store.forget(&forgotten.id).unwrap();
5459        let legacy = seal_legacy_slot(&store, 0, &text("sealed long ago"), "old note");
5460        let old_forgotten = seal_legacy_slot(&store, 1, &text("forgotten long ago"), "");
5461        store.forget(&old_forgotten.id).unwrap();
5462        drop(store);
5463        let live = [(&shelved, "read only", "note"), (&legacy, "sealed long ago", "old note")];
5464        let gone = [forgotten.id.clone(), old_forgotten.id.clone(), "a".repeat(32)];
5465        let context = tmp.path().join("context-archives");
5466        let dirs = vec![tmp.path().to_path_buf(), context, dir.clone(), dir.join("s000001")];
5467        let _reopen = Reopen(dirs.clone());
5468        // Once with the lock file a seal left, and once without one: taking
5469        // the lock as a seal does would then mean creating it.
5470        for lock in [true, false] {
5471            if !lock {
5472                std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).unwrap();
5473                std::fs::remove_file(dir.join("lock")).unwrap();
5474            }
5475            // Looser than the store makes them: a read does not repair.
5476            for path in &dirs {
5477                std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap();
5478            }
5479            for path in shelf_files(&dir).into_iter().chain([dir.join("000.json")]) {
5480                std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o644)).unwrap();
5481            }
5482            // Whatever is changed from here on gets a later change time.
5483            std::thread::sleep(Duration::from_millis(50));
5484            let before = stamps(tmp.path());
5485            assert_eq!(before.len(), if lock { 10 } else { 9 }, "4 directories, 5 files, lock");
5486            read_everything(tmp.path(), &live, &gone, 2);
5487            assert_eq!(stamps(tmp.path()), before, "lock file: {lock}");
5488
5489            // And where nothing could be written at all.
5490            for path in &dirs {
5491                std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o500)).unwrap();
5492            }
5493            for path in shelf_files(&dir).into_iter().chain([dir.join("000.json")]) {
5494                std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o400)).unwrap();
5495            }
5496            if lock {
5497                let read_only = std::fs::Permissions::from_mode(0o400);
5498                std::fs::set_permissions(dir.join("lock"), read_only).unwrap();
5499            }
5500            std::thread::sleep(Duration::from_millis(50));
5501            let before = stamps(tmp.path());
5502            read_everything(tmp.path(), &live, &gone, 2);
5503            assert_eq!(stamps(tmp.path()), before, "read-only, lock file: {lock}");
5504        }
5505        // What a read must not go through: opening the store to write makes
5506        // its three directories private again, whoever asked.
5507        ArchiveStore::new(tmp.path(), "synthetic-project", "any").unwrap();
5508        assert_eq!((mode(tmp.path()), mode(&dir), mode(&dir.join("s000001"))), (0o700, 0o700, 0o500));
5509    }
5510
5511    /// A read of a project that has archived nothing, or of a base that
5512    /// does not exist: nothing is found, and nothing is created to say so.
5513    #[test]
5514    fn a_read_of_a_project_without_archives_creates_nothing() {
5515        let tmp = tempfile::tempdir().unwrap();
5516        let id = "a".repeat(32);
5517        let absent = |base: &Path, project: &str| {
5518            assert!(ArchiveStore::open_existing(base, project, "reader").unwrap().is_none());
5519            assert!(project_search(base, project, "", 8).unwrap().is_empty());
5520            let error = project_fetch(base, project, &id, 0, 1).unwrap_err();
5521            assert_eq!(error.kind(), io::ErrorKind::NotFound);
5522            assert_eq!(error.to_string(), NOT_FOUND);
5523            assert_eq!(archive_file(base, project, &id).unwrap(), None);
5524        };
5525        absent(&tmp.path().join("missing"), "synthetic-project");
5526        absent(tmp.path(), "synthetic-project");
5527        assert_eq!(std::fs::read_dir(tmp.path()).unwrap().count(), 0);
5528        // Another project of the same base has archives; this one still has
5529        // no directory, before and after it is read.
5530        let other = setup_in(tmp.path(), "another-project");
5531        other.seal(&text("elsewhere"), "").unwrap();
5532        std::thread::sleep(Duration::from_millis(50));
5533        let before = stamps(tmp.path());
5534        absent(tmp.path(), "synthetic-project");
5535        assert_eq!(stamps(tmp.path()), before);
5536        assert_eq!(std::fs::read_dir(tmp.path().join("context-archives")).unwrap().count(), 1);
5537        // The same checks of its arguments as a store that exists makes.
5538        for (project, id) in [("", id.as_str()), ("synthetic-project", "../x")] {
5539            let error = project_fetch(tmp.path(), project, id, 0, 1).unwrap_err();
5540            assert_eq!(error.kind(), io::ErrorKind::InvalidInput);
5541        }
5542        // A link below the base is refused, not followed and not "nothing
5543        // there".
5544        let context = tmp.path().join("context-archives");
5545        let scope = digest_parts(&[b"synthetic-project"]);
5546        symlink(context.join(&other.scope), context.join(&scope)).unwrap();
5547        assert!(ArchiveStore::open_existing(tmp.path(), "synthetic-project", "reader").is_err());
5548        assert!(project_search(tmp.path(), "synthetic-project", "", 8).is_err());
5549    }
5550
5551    /// The base is the user's choice of place and may be reached through a
5552    /// link: it is resolved when the store is opened and the files are in
5553    /// the real folder. Below the base nothing that is a link is followed.
5554    #[test]
5555    fn a_base_reached_through_a_link_works_and_a_link_below_the_base_is_refused() {
5556        let tmp = tempfile::tempdir().unwrap();
5557        // Where a temporary directory really is (on macOS, behind a link).
5558        let root = std::fs::canonicalize(tmp.path()).unwrap();
5559        let disk = root.join("another-disk");
5560        std::fs::create_dir_all(disk.join("synaps")).unwrap();
5561        std::fs::create_dir(root.join("home")).unwrap();
5562        // The base itself is a link; and a link above a base that does not
5563        // exist yet, two names deep.
5564        symlink(disk.join("synaps"), root.join("home/.synaps-cli")).unwrap();
5565        symlink(&disk, root.join("mount")).unwrap();
5566        let bases = [
5567            (root.join("home/.synaps-cli"), disk.join("synaps")),
5568            (root.join("mount/not/yet"), disk.join("not/yet")),
5569        ];
5570        for (base, real) in &bases {
5571            let store = setup_in(base, "synthetic-project");
5572            let real_dir = real.join("context-archives").join(&store.scope);
5573            let words = "through a link";
5574            let sealed = store.seal_with_note(&text(words), "a note").unwrap().0;
5575            let file = only_archive_file(&real_dir);
5576            assert_eq!(file.file_name().unwrap().to_str(), Some(archive_name(&sealed.id).as_str()));
5577            assert_eq!(mode(real), 0o700);
5578            assert_eq!(store.fetch(&sealed.id, 0, 1).unwrap(), text(words));
5579            assert_eq!(store.search("through", 8).unwrap().len(), 1);
5580            // The reads that open the store for themselves, and a second
5581            // store on a base that now exists.
5582            assert_eq!(project_fetch(base, "synthetic-project", &sealed.id, 0, 1).unwrap(), text(words));
5583            assert_eq!(project_search(base, "synthetic-project", "through", 8).unwrap().len(), 1);
5584            let located = archive_file(base, "synthetic-project", &sealed.id).unwrap().unwrap();
5585            assert_eq!(std::fs::canonicalize(located).unwrap(), file);
5586            let again = setup_in(base, "synthetic-project");
5587            assert_eq!(again.seal(&text(words), "").unwrap(), sealed);
5588            assert_eq!(again.forget(&sealed.id).unwrap(), forgotten(&[&sealed.id]));
5589            assert!(is_tombstone_of(&store, &file, &digest_of(words)));
5590            assert!(store.search("through", 8).unwrap().is_empty());
5591            assert_eq!(shelf_files(&real_dir).len(), 1);
5592        }
5593        for link in [root.join("home/.synaps-cli"), root.join("mount")] {
5594            assert!(std::fs::symlink_metadata(link).unwrap().file_type().is_symlink());
5595        }
5596
5597        // Below the base: `context-archives`, the project folder and a shelf
5598        // that are links are not followed, through a linked base or not.
5599        let outside = root.join("outside");
5600        std::fs::create_dir(&outside).unwrap();
5601        let empty = || std::fs::read_dir(&outside).unwrap().count() == 0;
5602        let (base, real) = &bases[0];
5603        let scope = digest_parts(&[b"linked-project"]);
5604        symlink(&outside, real.join("context-archives").join(&scope)).unwrap();
5605        assert!(ArchiveStore::new(base, "linked-project", "l").is_err());
5606        assert!(ArchiveStore::open_existing(base, "linked-project", "l").is_err());
5607        assert!(project_search(base, "linked-project", "", 8).is_err());
5608        for base in [root.join("plain"), root.join("mount/linked-below")] {
5609            std::fs::create_dir(&base).unwrap();
5610            symlink(&outside, base.join("context-archives")).unwrap();
5611            assert!(ArchiveStore::new(&base, "synthetic-project", "l").is_err());
5612            assert!(ArchiveStore::open_existing(&base, "synthetic-project", "l").is_err());
5613        }
5614        let store = setup_in(base, "synthetic-project");
5615        let real_dir = real.join("context-archives").join(&store.scope);
5616        symlink(&outside, real_dir.join("s000002")).unwrap();
5617        let sealed = store.seal(&text("past a shelf that is a link"), "").unwrap();
5618        let file = real_dir.join("s000003").join(archive_name(&sealed.id));
5619        assert!(file.is_file());
5620        assert!(empty(), "nothing was written through a link");
5621
5622        // A parent that can be passed through and not listed.
5623        if unsafe { libc::geteuid() } == 0 {
5624            return; // nothing is closed to root
5625        }
5626        let parent = root.join("closed");
5627        let base = parent.join("base");
5628        std::fs::create_dir_all(&base).unwrap();
5629        std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o100)).unwrap();
5630        let result = (|| {
5631            let store = ArchiveStore::new(&base, "synthetic-project", "synthetic-logical")?;
5632            let sealed = store.seal(&text("under a closed parent"), "")?;
5633            let read = project_fetch(&base, "synthetic-project", &sealed.id, 0, 1)?;
5634            store.forget(&sealed.id)?;
5635            Ok::<_, io::Error>(read)
5636        })();
5637        std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o700)).unwrap();
5638        assert_eq!(result.unwrap(), text("under a closed parent"));
5639        assert_eq!(mode(&base), 0o700);
5640    }
5641
5642    #[test]
5643    fn a_shelf_name_that_is_a_link_or_a_file_is_not_followed_and_does_not_stop_a_seal() {
5644        let (tmp, store) = setup();
5645        let dir = directory(&tmp, &store);
5646        let reference = store.seal(&text("shelved"), "").unwrap();
5647        let shelf = dir.join("s000001");
5648        let outside = tmp.path().join("outside-shelf");
5649        std::fs::rename(&shelf, &outside).unwrap();
5650        let before = snapshot(&outside);
5651        let unread = |result: io::Result<()>| {
5652            let error = result.unwrap_err();
5653            assert_ne!(error.kind(), io::ErrorKind::NotFound, "{error}");
5654            assert!(error.to_string().contains("s000001"), "{error}");
5655            assert!(error.to_string().contains("could not be read"), "{error}");
5656        };
5657        // A segment that is found nowhere is not called "not found" while
5658        // there is a shelf name that could not be looked into. A search
5659        // answers from the other shelves and names this one.
5660        let refused = |id: &str| {
5661            unread(store.fetch(id, 0, 1).map(drop));
5662            unread(store.fetch_note(id).map(drop));
5663            unread(store.forget(id).map(drop));
5664            unread(archive_file(tmp.path(), "synthetic-project", id).map(drop));
5665            let (_, unscanned) = store.search_scan("", MAX_SEARCH_RESULTS).unwrap();
5666            let passed = unscanned.unwrap().unread.unwrap();
5667            assert!(passed.starts_with("1 place(s) could not be read, first s000001: "), "{passed}");
5668        };
5669        symlink(&outside, &shelf).unwrap();
5670        refused(&reference.id);
5671        std::fs::remove_file(&shelf).unwrap();
5672        std::fs::write(&shelf, b"not a directory").unwrap();
5673        refused(&reference.id);
5674        // Nothing was read into, written through or created beside the link.
5675        assert_eq!(snapshot(&outside), before);
5676        assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 2, "lock and s000001");
5677
5678        // A seal passes over the name and takes the number after it.
5679        let new = store.seal(&text("something new"), "").unwrap();
5680        assert!(dir.join("s000002").join(archive_name(&new.id)).is_file());
5681        assert_eq!(std::fs::read(&shelf).unwrap(), b"not a directory");
5682        std::fs::remove_file(&shelf).unwrap();
5683        symlink(&outside, &shelf).unwrap();
5684        // What is behind the link is not seen, so it is stored again.
5685        assert_eq!(store.seal(&text("shelved"), "").unwrap(), reference);
5686        assert!(dir.join("s000002").join(archive_name(&reference.id)).is_file());
5687        assert_eq!(snapshot(&outside), before);
5688        // A segment on a shelf that can be read is found whatever its
5689        // neighbour is; one that is not found is still not "not found".
5690        assert_eq!(store.fetch(&new.id, 0, 1).unwrap(), text("something new"));
5691        assert_eq!(store.fetch(&reference.id, 0, 1).unwrap(), text("shelved"));
5692        let located = archive_file(tmp.path(), "synthetic-project", &new.id).unwrap();
5693        assert_eq!(located, Some(dir.join("s000002").join(archive_name(&new.id))));
5694        refused(&"a".repeat(32));
5695        // With such a name as the newest, the next number is used.
5696        std::fs::write(dir.join("s000003"), b"not a directory").unwrap();
5697        let next = store.seal(&text("after a file"), "").unwrap();
5698        assert!(dir.join("s000004").join(archive_name(&next.id)).is_file());
5699        symlink(&outside, dir.join("s000005")).unwrap();
5700        let last = store.seal(&text("after a link"), "").unwrap();
5701        assert!(dir.join("s000006").join(archive_name(&last.id)).is_file());
5702        assert_eq!(std::fs::read(dir.join("s000003")).unwrap(), b"not a directory");
5703        assert_eq!(snapshot(&outside), before);
5704        std::fs::remove_file(&shelf).unwrap();
5705        std::fs::remove_file(dir.join("s000003")).unwrap();
5706        std::fs::remove_file(dir.join("s000005")).unwrap();
5707        assert_eq!(store.search("", MAX_SEARCH_RESULTS).unwrap().len(), 4);
5708    }
5709
5710    /// Makes directories removable again when a test ends, however it ends.
5711    struct Reopen(Vec<PathBuf>);
5712    impl Drop for Reopen {
5713        fn drop(&mut self) {
5714            for dir in &self.0 {
5715                let _ = std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700));
5716            }
5717        }
5718    }
5719
5720    #[test]
5721    fn a_shelf_that_cannot_be_opened_does_not_stop_a_seal() {
5722        if unsafe { libc::geteuid() } == 0 {
5723            return; // nothing is closed to root
5724        }
5725        let (tmp, store) = setup();
5726        let dir = directory(&tmp, &store);
5727        let seal = |index: usize| store.seal(&text(&format!("archive {index}")), "").unwrap();
5728        let ids: Vec<_> = (0..=SHELF_FILES).map(|index| seal(index).id).collect();
5729        let (older, newer) = (dir.join("s000001"), dir.join("s000002"));
5730        let _reopen = Reopen(vec![older.clone(), newer.clone()]);
5731        let close = |shelf: &Path, mode: u32| {
5732            std::fs::set_permissions(shelf, std::fs::Permissions::from_mode(mode)).unwrap();
5733        };
5734        // An older shelf: passed over when the seal looks for a duplicate.
5735        close(&older, 0o000);
5736        let next = seal(1_000);
5737        assert!(newer.join(archive_name(&next.id)).is_file());
5738        // The price: a window that is in it is not seen, and stored again.
5739        assert_eq!(seal(0).id, ids[0]);
5740        assert!(newer.join(archive_name(&ids[0])).is_file());
5741        // A read finds what it can reach, and says what it could not.
5742        assert_eq!(store.fetch(&next.id, 0, 1).unwrap(), text("archive 1000"));
5743        assert_eq!(store.fetch(&ids[0], 0, 1).unwrap(), text("archive 0"));
5744        for id in [ids[5].clone(), "a".repeat(32)] {
5745            for result in [store.fetch(&id, 0, 1).map(drop), store.forget(&id).map(drop)] {
5746                let error = result.unwrap_err();
5747                assert_eq!(error.kind(), io::ErrorKind::PermissionDenied, "{error}");
5748                assert!(error.to_string().contains("s000001"), "{error}");
5749                assert!(error.to_string().contains("could not be read"), "{error}");
5750            }
5751        }
5752        assert_eq!(mode(&older), 0o000);
5753        // The newest shelf: nothing is written to it, and the next is used.
5754        close(&older, 0o700);
5755        close(&newer, 0o000);
5756        let later = seal(2_000);
5757        assert!(dir.join("s000003").join(archive_name(&later.id)).is_file());
5758        assert_eq!(store.fetch(&ids[5], 0, 1).unwrap(), text("archive 5"));
5759        assert_eq!(mode(&newer), 0o000);
5760        close(&newer, 0o700);
5761        assert_eq!(std::fs::read_dir(&newer).unwrap().count(), 3);
5762        assert_eq!(std::fs::read_dir(&older).unwrap().count(), SHELF_FILES);
5763        for (id, index) in [(&ids[5], 5), (&ids[SHELF_FILES], SHELF_FILES), (&later.id, 2_000)] {
5764            assert_eq!(store.fetch(id, 0, 1).unwrap(), text(&format!("archive {index}")));
5765        }
5766    }
5767
5768    #[test]
5769    fn a_directory_with_an_absurd_shelf_number_is_not_a_shelf() {
5770        let (tmp, store) = setup();
5771        let dir = directory(&tmp, &store);
5772        let seal = |index: usize| store.seal(&text(&format!("archive {index}")), "").unwrap();
5773        let first = seal(0);
5774        let file = only_archive_file(&dir);
5775        // The largest number there is, the one after the last shelf's, and
5776        // other spellings of numbers: none was made by the store. Each holds
5777        // a copy of a segment, which would be found if it were looked into.
5778        let absurd = ["s18446744073709551615", "s1000000", "s0999999", "s99999999999999999999"];
5779        for name in absurd {
5780            std::fs::create_dir(dir.join(name)).unwrap();
5781            std::fs::copy(&file, dir.join(name).join(archive_name(&first.id))).unwrap();
5782        }
5783        let before = snapshot(&dir);
5784        assert_eq!(store.shelves().unwrap(), ["s000001"]);
5785        let second = seal(1);
5786        assert!(dir.join("s000001").join(archive_name(&second.id)).is_file());
5787        assert_eq!(store.search("", 8).unwrap().len(), 2);
5788        let after = snapshot(&dir);
5789        assert!(before.iter().all(|(path, file)| after.get(path) == Some(file)));
5790        assert_eq!(after.len(), before.len() + 1);
5791
5792        // The last number there is belongs to a shelf like any other. With
5793        // no number after it, it takes more than 128 rather than refuse.
5794        let last = dir.join("s999999");
5795        std::fs::create_dir(&last).unwrap();
5796        let ids: Vec<_> = (2..SHELF_FILES + 5).map(|index| seal(index).id).collect();
5797        assert_eq!(std::fs::read_dir(&last).unwrap().count(), SHELF_FILES + 3);
5798        assert_eq!(mode(&last), 0o700);
5799        assert_eq!(store.shelves().unwrap(), ["s999999", "s000001"]);
5800        for (id, index) in [(&ids[0], 2), (&ids[SHELF_FILES + 2], SHELF_FILES + 4)] {
5801            assert_eq!(store.fetch(id, 0, 1).unwrap(), text(&format!("archive {index}")));
5802        }
5803        assert_eq!(store.seal(&text("archive 0"), "").unwrap(), first);
5804        // And if that name is not a directory at all, the newest shelf
5805        // that can be written to does.
5806        std::fs::rename(&last, tmp.path().join("moved-away")).unwrap();
5807        std::fs::write(&last, b"not a directory").unwrap();
5808        let fallback = seal(1_000);
5809        assert!(dir.join("s000001").join(archive_name(&fallback.id)).is_file());
5810        assert_eq!(std::fs::read(&last).unwrap(), b"not a directory");
5811    }
5812
5813    /// The bytes of a record of `store` as a seal of `messages` would write
5814    /// it, under `id` and with the changes a test asks for.
5815    fn record_bytes(
5816        store: &ArchiveStore,
5817        id: &str,
5818        messages: &[SharedMessage],
5819        change: impl FnOnce(&mut Record),
5820    ) -> Vec<u8> {
5821        let projection = project_hashed(&store.logical, messages, "", None, Reduce::None).unwrap();
5822        let mut record = Record {
5823            version: 1,
5824            scope: store.scope.clone(),
5825            logical: store.logical.clone(),
5826            id: id.to_owned(),
5827            digest: projection.digest,
5828            created_ms: None,
5829            body: Body::Live {
5830                source_message_count: projection.source_message_count,
5831                messages: projection.messages,
5832                note: projection.note,
5833            },
5834        };
5835        change(&mut record);
5836        bounded_json(&record, MAX_SEGMENT_BYTES).unwrap()
5837    }
5838
5839    #[test]
5840    fn a_file_under_the_id_that_is_not_the_segment_is_left_and_the_other_id_is_used() {
5841        let (tmp, store) = setup();
5842        let dir = directory(&tmp, &store);
5843        let shelf = dir.join("s000001");
5844        store.seal(&text("a shelf to begin with"), "").unwrap();
5845        let digest = |words: &str| {
5846            let projection = project_archive("synthetic-logical", &text(words), "", None);
5847            projection.unwrap().digest
5848        };
5849        // Not a record at all; a record of another window that was given
5850        // this name, valid in itself; and the same with this window's own
5851        // digest but content that does not match it.
5852        let other = |id: &str| record_bytes(&store, id, &text("another window"), |_| {});
5853        let forged = |id: &str, digest: &str| {
5854            record_bytes(&store, id, &text("another window"), |record| record.digest = digest.into())
5855        };
5856        for (case, words) in ["torn", "another projection", "forged digest"].into_iter().enumerate() {
5857            let digest = digest(words);
5858            let (first, second) = (&digest[..32], &digest[32..]);
5859            let taken = shelf.join(archive_name(first));
5860            let bytes = match case {
5861                0 => b"{\"version\":1,".to_vec(),
5862                1 => other(first),
5863                _ => forged(first, &digest),
5864            };
5865            std::fs::write(&taken, &bytes).unwrap();
5866            let before = snapshot(&dir);
5867            let (sealed, note) = store.seal_with_note(&text(words), "first note").unwrap();
5868            assert_eq!((sealed.id.as_str(), note.as_str()), (second, "first note"), "{words}");
5869            assert_eq!(store.fetch(second, 0, 1).unwrap(), text(words), "{words}");
5870            assert!(shelf.join(archive_name(second)).is_file(), "{words}");
5871            // One file more; the one that has the name is as it was.
5872            let after = snapshot(&dir);
5873            assert!(before.iter().all(|(path, file)| after.get(path) == Some(file)), "{words}");
5874            assert_eq!(after.len(), before.len() + 1, "{words}");
5875            // A second seal of the window looks in the same order, finds
5876            // it, and writes nothing.
5877            let again = store.seal_with_note(&text(words), "second note").unwrap();
5878            assert_eq!(again, (sealed.clone(), "first note".to_owned()), "{words}");
5879            assert_eq!(snapshot(&dir), after, "{words}");
5880            // The first id still answers for the file that has its name.
5881            match store.fetch(first, 0, 1) {
5882                Ok(found) => assert_eq!((case, found), (1, text("another window"))),
5883                Err(error) => assert_eq!(error.kind(), io::ErrorKind::InvalidData, "{words}"),
5884            }
5885            // Forgotten under its second id, it stays forgotten.
5886            store.forget(second).unwrap();
5887            let refused = store.seal(&text(words), "").unwrap_err();
5888            assert_eq!(refused.kind(), io::ErrorKind::PermissionDenied, "{words}");
5889            assert_eq!(std::fs::read(&taken).unwrap(), bytes, "{words}");
5890        }
5891        // Both names taken by other things: the one seal that is refused
5892        // for what a file is, and it writes nothing.
5893        let digest = digest("no name left");
5894        for id in [&digest[..32], &digest[32..]] {
5895            std::fs::write(shelf.join(archive_name(id)), other(id)).unwrap();
5896        }
5897        let before = snapshot(&dir);
5898        let refused = store.seal(&text("no name left"), "").unwrap_err();
5899        assert_eq!(refused.kind(), io::ErrorKind::AlreadyExists);
5900        assert_eq!(snapshot(&dir), before);
5901        assert!(store.seal(&text("any other window"), "").is_ok());
5902    }
5903
5904    /// The one refusal a file that cannot be validated still causes: it
5905    /// reads as JSON, names this projection and says it was forgotten.
5906    #[test]
5907    fn a_tombstone_that_no_longer_validates_still_forbids_its_projection() {
5908        let (tmp, store) = setup();
5909        let dir = directory(&tmp, &store);
5910        let words = "forgotten words";
5911        let gone = seal_legacy_slot(&store, 0, &text(words), "");
5912        store.forget(&gone.id).unwrap();
5913        let digest = project_archive("synthetic-logical", &text(words), "", None);
5914        let digest = digest.unwrap().digest;
5915        let tombstone = std::fs::read_to_string(dir.join("000.json")).unwrap();
5916        // Still a tombstone of this projection to a reader, and no longer a
5917        // valid record: a later version, a field this version does not
5918        // know, a record of another project.
5919        let altered = [
5920            tombstone.replace("\"version\":1", "\"version\":2"),
5921            tombstone.replacen('{', "{\"added\":true,", 1),
5922            tombstone.replace(&store.scope, &"0".repeat(64)),
5923        ];
5924        let refused = |kind: &str| {
5925            let error = store.seal(&text(words), "").unwrap_err();
5926            assert_eq!(error.kind(), io::ErrorKind::PermissionDenied, "{kind}");
5927            assert!(error.to_string().contains("was forgotten"), "{kind}");
5928        };
5929        // In a legacy slot, and on a shelf under either id of the projection.
5930        let shelf = dir.join("s000001");
5931        let places = [
5932            dir.join("000.json"),
5933            shelf.join(archive_name(&digest[..32])),
5934            shelf.join(archive_name(&digest[32..])),
5935        ];
5936        // Each place is tried by itself: neither the slot's tombstone nor
5937        // the one the forget left under the first id stays.
5938        std::fs::remove_file(&places[0]).unwrap();
5939        std::fs::remove_file(&places[1]).unwrap();
5940        // The second id is looked under whether the first is free or taken.
5941        for (index, place) in places.iter().enumerate() {
5942            for bytes in &altered {
5943                assert!(store.record("000.json", bytes.as_bytes()).is_err());
5944                std::fs::write(place, bytes).unwrap();
5945                let before = snapshot(&dir);
5946                refused(bytes);
5947                assert_eq!(snapshot(&dir), before);
5948            }
5949            if index < 2 {
5950                std::fs::remove_file(place).unwrap();
5951            }
5952        }
5953        std::fs::write(&places[1], b"not a record").unwrap();
5954        refused("with the first name taken");
5955        // Only that projection, and only while the file says so.
5956        assert!(store.seal(&text("other words"), "").is_ok());
5957        std::fs::write(&places[2], tombstone.replace("tombstone", "tombstoned")).unwrap();
5958        let error = store.seal(&text(words), "").unwrap_err();
5959        assert_eq!(error.kind(), io::ErrorKind::AlreadyExists, "no name left: {error}");
5960        std::fs::remove_file(&places[2]).unwrap();
5961        let again = store.seal(&text(words), "").unwrap();
5962        assert_eq!(again.id, digest[32..]);
5963    }
5964
5965    /// Makes the system call `call` fail with `code` for every name that
5966    /// starts with `prefix`, on this thread, until it is dropped.
5967    struct Fault;
5968    impl Fault {
5969        fn on(call: &'static str, prefix: &str, code: i32) -> Self {
5970            FAULTS.with_borrow_mut(|faults| faults.push((call, prefix.to_owned(), code)));
5971            Fault
5972        }
5973    }
5974    impl Drop for Fault {
5975        fn drop(&mut self) {
5976            FAULTS.with_borrow_mut(Vec::clear);
5977        }
5978    }
5979    fn digest_of(words: &str) -> String {
5980        let projection = project_archive("synthetic-logical", &text(words), "", None);
5981        projection.unwrap().digest
5982    }
5983
5984    /// A window that went under its second id because something odd had the
5985    /// first is still that one segment when the odd thing is gone.
5986    #[test]
5987    fn a_segment_under_its_second_id_is_found_when_the_first_name_is_free_again() {
5988        let (tmp, store) = setup();
5989        let dir = directory(&tmp, &store);
5990        let shelf = dir.join("s000001");
5991        store.seal(&text("a shelf to begin with"), "").unwrap();
5992        let digest = digest_of("stored once");
5993        let (first, second) = (&digest[..32], &digest[32..]);
5994        let odd = shelf.join(archive_name(first));
5995        std::fs::write(&odd, b"{\"version\":1,").unwrap();
5996        let sealed = store.seal_with_note(&text("stored once"), "first note").unwrap();
5997        assert_eq!(sealed.0.id, second);
5998        // The odd file is removed. A seal of the same window finds it where
5999        // it is and writes nothing under the name that is free again.
6000        std::fs::remove_file(&odd).unwrap();
6001        let before = snapshot(&dir);
6002        let again = store.seal_with_note(&text("stored once"), "second note").unwrap();
6003        assert_eq!(again, sealed);
6004        assert_eq!(snapshot(&dir), before);
6005        assert_eq!(store.search("stored once", 8).unwrap().len(), 1);
6006        // Forgotten under its second id, it stays forgotten although the
6007        // first name is free.
6008        store.forget(second).unwrap();
6009        let forgotten = snapshot(&dir);
6010        let refused = store.seal(&text("stored once"), "").unwrap_err();
6011        assert_eq!(refused.kind(), io::ErrorKind::PermissionDenied);
6012        assert!(refused.to_string().contains("was forgotten"));
6013        assert_eq!(snapshot(&dir), forgotten);
6014        assert!(!odd.exists());
6015    }
6016
6017    /// With a copy under each name, one live and one forgotten (which a
6018    /// store from before both names were looked under can hold), the
6019    /// tombstone decides, whichever name has it.
6020    #[test]
6021    fn a_tombstone_under_either_name_refuses_whatever_the_other_holds() {
6022        let (tmp, store) = setup();
6023        let dir = directory(&tmp, &store);
6024        let shelf = dir.join("s000001");
6025        store.seal(&text("a shelf to begin with"), "").unwrap();
6026        for (words, forgotten) in [("tombstone first", 0), ("tombstone second", 1)] {
6027            let digest = digest_of(words);
6028            let ids = [&digest[..32], &digest[32..]];
6029            for (index, id) in ids.into_iter().enumerate() {
6030                let bytes = record_bytes(&store, id, &text(words), |record| {
6031                    if index == forgotten {
6032                        record.body = Body::Tombstone;
6033                    }
6034                });
6035                std::fs::write(shelf.join(archive_name(id)), bytes).unwrap();
6036            }
6037            assert_eq!(store.fetch(ids[1 - forgotten], 0, 1).unwrap(), text(words));
6038            let before = snapshot(&dir);
6039            let refused = store.seal(&text(words), "").unwrap_err();
6040            assert_eq!(refused.kind(), io::ErrorKind::PermissionDenied, "{words}");
6041            assert_eq!(snapshot(&dir), before, "{words}");
6042        }
6043    }
6044
6045    /// A newer shelf with something else under the window's name does not
6046    /// hide what an older shelf holds under it: a tombstone there still
6047    /// refuses the seal, and a live copy there is still the answer.
6048    #[test]
6049    fn a_file_under_the_name_on_a_newer_shelf_does_not_hide_an_older_tombstone_or_copy() {
6050        let (tmp, store) = setup();
6051        let dir = directory(&tmp, &store);
6052        let (older, newer) = (dir.join("s000001"), dir.join("s000002"));
6053        store.seal(&text("a shelf to begin with"), "").unwrap();
6054        std::fs::create_dir(&newer).unwrap();
6055        std::fs::write(tmp.path().join("elsewhere"), b"not in the store").unwrap();
6056        // What has the name on the newer shelf: a file that is not a record,
6057        // or one the store does not read at all.
6058        let odd = |name: &str, link: bool| {
6059            if link {
6060                symlink(tmp.path().join("elsewhere"), newer.join(name)).unwrap();
6061            } else {
6062                std::fs::write(newer.join(name), b"not a record").unwrap();
6063            }
6064        };
6065        let refused = |words: &str| {
6066            let before = snapshot(&dir);
6067            let error = store.seal(&text(words), "").unwrap_err();
6068            assert_eq!(error.kind(), io::ErrorKind::PermissionDenied, "{words}: {error}");
6069            assert_eq!(snapshot(&dir), before, "{words}");
6070            assert!(store.search(words, 8).unwrap().is_empty(), "{words}");
6071        };
6072        let imported = ["0123456789abcdef0123456789abcdef", "fedcba9876543210fedcba9876543210"];
6073        for (link, imported) in [false, true].into_iter().zip(imported) {
6074            // Forgotten with the odd file already there. The forget goes by
6075            // another id of the window, as a forget of an imported copy does.
6076            let words = format!("forgotten behind an odd file, link {link}");
6077            let sealed = store.seal(&text(&words), "").unwrap();
6078            let name = archive_name(&sealed.id);
6079            assert_eq!(sealed.id, digest_of(&words)[..32]);
6080            std::fs::rename(newer.join(&name), older.join(&name)).unwrap();
6081            let copy = record_bytes(&store, imported, &text(&words), |_| {});
6082            std::fs::write(older.join(archive_name(imported)), copy).unwrap();
6083            odd(&name, link);
6084            let mut ids = store.forget(imported).unwrap().ids;
6085            ids.sort_unstable();
6086            let mut expected = [imported.to_owned(), sealed.id.clone()];
6087            expected.sort_unstable();
6088            assert_eq!(ids, expected);
6089            assert!(is_tombstone_of(&store, &older.join(&name), &digest_of(&words)));
6090            refused(&words);
6091
6092            // No forget in between: an older tombstone, whole or one that no
6093            // longer validates, behind a newer odd file of the same name.
6094            for whole in [true, false] {
6095                let words = format!("an older tombstone, link {link}, whole {whole}");
6096                let digest = digest_of(&words);
6097                let id = &digest[..32];
6098                let bytes = record_bytes(&store, id, &text(&words), |record| {
6099                    record.body = Body::Tombstone;
6100                    if !whole {
6101                        record.version = 2;
6102                    }
6103                });
6104                std::fs::write(older.join(archive_name(id)), bytes).unwrap();
6105                odd(&archive_name(id), link);
6106                refused(&words);
6107            }
6108
6109            // A live copy on the older shelf is the segment: no second copy.
6110            let words = format!("an older copy, link {link}");
6111            let sealed = store.seal_with_note(&text(&words), "first note").unwrap();
6112            let name = archive_name(&sealed.0.id);
6113            std::fs::rename(newer.join(&name), older.join(&name)).unwrap();
6114            odd(&name, link);
6115            let before = snapshot(&dir);
6116            assert_eq!(store.seal_with_note(&text(&words), "a later note").unwrap(), sealed);
6117            assert_eq!(snapshot(&dir), before);
6118        }
6119        // With nothing of the window on any shelf, the name is only taken.
6120        let words = "only an odd file";
6121        let digest = digest_of(words);
6122        odd(&archive_name(&digest[..32]), false);
6123        assert_eq!(store.seal(&text(words), "").unwrap().id, digest[32..]);
6124    }
6125
6126    /// A read of the file under the id that fails says nothing about what
6127    /// the file is. The seal fails and is tried again; it does not store the
6128    /// window a second time under its other id.
6129    #[test]
6130    fn a_failed_read_of_the_file_under_the_id_fails_the_seal_and_stores_no_second_copy() {
6131        let (tmp, store) = setup();
6132        let dir = directory(&tmp, &store);
6133        let sealed = store.seal_with_note(&text("stored once"), "first note").unwrap();
6134        let before = snapshot(&dir);
6135        for code in [libc::EIO, libc::EMFILE, libc::ENFILE, libc::ENOMEM, libc::EINTR] {
6136            let failing = Fault::on("open", &sealed.0.id, code);
6137            let error = store.seal(&text("stored once"), "").unwrap_err();
6138            assert_eq!(error.raw_os_error(), Some(code));
6139            drop(failing);
6140            assert_eq!(snapshot(&dir), before, "error {code}");
6141        }
6142        let again = store.seal_with_note(&text("stored once"), "a later note").unwrap();
6143        assert_eq!(again, sealed);
6144        assert_eq!(snapshot(&dir), before);
6145        // What a file is still never refuses a save: one that cannot be
6146        // opened by its owner has the name taken, like a link or a FIFO.
6147        for code in [libc::ELOOP, libc::EACCES, libc::EPERM, libc::ENXIO] {
6148            assert!(PrivateDir::not_a_segment(&io::Error::from_raw_os_error(code)));
6149        }
6150        assert!(PrivateDir::not_a_segment(&corrupt()));
6151        if unsafe { libc::geteuid() } == 0 {
6152            return; // nothing is closed to root
6153        }
6154        let file = only_archive_file(&dir);
6155        std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o000)).unwrap();
6156        let other = store.seal(&text("stored once"), "").unwrap();
6157        assert_eq!(other.id, digest_of("stored once")[32..]);
6158        assert_eq!(mode(&file), 0o000);
6159    }
6160
6161    /// Whether the file is a tombstone of `digest` under the id its name gives.
6162    fn is_tombstone_of(store: &ArchiveStore, file: &Path, digest: &str) -> bool {
6163        let name = file.file_name().unwrap().to_str().unwrap();
6164        let record = store.record(name, &std::fs::read(file).unwrap()).unwrap();
6165        record.digest == digest && matches!(record.body, Body::Tombstone)
6166    }
6167    fn forgotten(ids: &[&str]) -> Forgotten {
6168        Forgotten {
6169            ids: ids.iter().map(|id| (*id).to_owned()).collect(),
6170            unread: None,
6171        }
6172    }
6173
6174    /// One window in several files: under both halves of its digest, under
6175    /// one id on two shelves, in a legacy slot. Forgetting any one id
6176    /// forgets the window.
6177    #[test]
6178    fn forget_reaches_every_copy_of_the_window() {
6179        let (tmp, store) = setup();
6180        let dir = directory(&tmp, &store);
6181        let (older, newer) = (dir.join("s000001"), dir.join("s000002"));
6182        let gone = |words: &str| {
6183            assert!(store.search(words, 8).unwrap().is_empty(), "{words}");
6184            let refused = store.seal(&text(words), "").unwrap_err();
6185            assert_eq!(refused.kind(), io::ErrorKind::PermissionDenied, "{words}");
6186        };
6187        // Under both halves of the digest, as a build before both names were
6188        // looked under could leave it. Forgotten by the second.
6189        let words = "under both halves";
6190        let digest = digest_of(words);
6191        let (first, second) = (&digest[..32], &digest[32..]);
6192        assert_eq!(store.seal(&text(words), "").unwrap().id, first);
6193        let copy = record_bytes(&store, second, &text(words), |_| {});
6194        std::fs::write(older.join(archive_name(second)), copy).unwrap();
6195        assert_eq!(store.search(words, 8).unwrap().len(), 2);
6196        assert_eq!(store.forget(second).unwrap(), forgotten(&[second, first]));
6197        for id in [first, second] {
6198            assert_eq!(store.fetch(id, 0, 1).unwrap_err().kind(), io::ErrorKind::NotFound);
6199            assert!(is_tombstone_of(&store, &older.join(archive_name(id)), &digest));
6200        }
6201        gone(words);
6202        // Forgotten again: nothing is left to do, and nothing is written.
6203        let before = snapshot(&dir);
6204        assert_eq!(store.forget(first).unwrap(), forgotten(&[]));
6205        assert_eq!(snapshot(&dir), before);
6206
6207        // Under one id on two shelves, as a seal that could not open the
6208        // older shelf leaves it.
6209        let words = "one id on two shelves";
6210        let twice = store.seal(&text(words), "").unwrap();
6211        let name = archive_name(&twice.id);
6212        std::fs::create_dir(&newer).unwrap();
6213        std::fs::copy(older.join(&name), newer.join(&name)).unwrap();
6214        assert_eq!(store.search(words, 8).unwrap().len(), 2);
6215        assert_eq!(store.forget(&twice.id).unwrap(), forgotten(&[&twice.id, &twice.id]));
6216        for shelf in [&older, &newer] {
6217            assert!(is_tombstone_of(&store, &shelf.join(&name), &digest_of(words)));
6218        }
6219        gone(words);
6220
6221        // In a legacy slot and on a shelf. Forgotten by the slot's id.
6222        let words = "in a slot and on a shelf";
6223        let slot = seal_legacy_slot(&store, 0, &text(words), "");
6224        let digest = digest_of(words);
6225        let copy = record_bytes(&store, &digest[32..], &text(words), |_| {});
6226        std::fs::write(older.join(archive_name(&digest[32..])), copy).unwrap();
6227        // The copy a seal looks under is replaced first.
6228        let done = store.forget(&slot.id).unwrap();
6229        assert_eq!(done, forgotten(&[&slot.id, &digest[32..]]));
6230        assert!(is_tombstone_of(&store, &dir.join("000.json"), &digest));
6231        gone(words);
6232        assert!(temporary_files(&dir).is_empty());
6233    }
6234
6235    /// A later seal of a forgotten window looks under the two ids its digest
6236    /// gives it. A window that was only stored under another id (a legacy
6237    /// slot's, an import's) leaves a tombstone there too, written first.
6238    #[test]
6239    fn a_forget_leaves_a_tombstone_where_a_seal_looks() {
6240        let (tmp, store) = setup();
6241        let dir = directory(&tmp, &store);
6242        let shelf = dir.join("s000001");
6243        let imported = "0123456789abcdef0123456789abcdef";
6244        // In a legacy slot, in a project that has no shelf yet.
6245        let words = "only in a slot";
6246        let digest = digest_of(words);
6247        let slot = seal_legacy_slot(&store, 0, &text(words), "");
6248        assert_eq!(store.forget(&slot.id).unwrap(), forgotten(&[&slot.id]));
6249        let guard = shelf.join(archive_name(&digest[..32]));
6250        assert!(is_tombstone_of(&store, &guard, &digest));
6251        assert!(is_tombstone_of(&store, &dir.join("000.json"), &digest));
6252        // The slot can go bad; the window stays forgotten.
6253        std::fs::write(dir.join("000.json"), b"{\"version\":1,").unwrap();
6254        let refused = store.seal(&text(words), "").unwrap_err();
6255        assert_eq!(refused.kind(), io::ErrorKind::PermissionDenied);
6256        std::fs::remove_file(dir.join("000.json")).unwrap();
6257
6258        // On a shelf under an id its digest does not give it, with the first
6259        // of the two names taken by something else: the second is used.
6260        let words = "under an imported id";
6261        let digest = digest_of(words);
6262        let file = shelf.join(archive_name(imported));
6263        std::fs::write(&file, record_bytes(&store, imported, &text(words), |_| {})).unwrap();
6264        std::fs::write(shelf.join(archive_name(&digest[..32])), b"not a record").unwrap();
6265        assert_eq!(store.fetch(imported, 0, 1).unwrap(), text(words));
6266        // The tombstone a seal will find is written before the archive is
6267        // replaced: when that fails, the window is still there and can
6268        // already not be sealed again.
6269        let failing = Fault::on("rename", imported, libc::EIO);
6270        assert_eq!(store.forget(imported).unwrap_err().raw_os_error(), Some(libc::EIO));
6271        drop(failing);
6272        let guard = shelf.join(archive_name(&digest[32..]));
6273        assert!(is_tombstone_of(&store, &guard, &digest));
6274        assert_eq!(store.fetch(imported, 0, 1).unwrap(), text(words));
6275        let refused = store.seal(&text(words), "").unwrap_err();
6276        assert_eq!(refused.kind(), io::ErrorKind::PermissionDenied);
6277        // Forgetting again finishes, and writes no second tombstone.
6278        let files = shelf_files(&dir).len();
6279        // The file that has the first name is not a record: it may be a
6280        // damaged copy, so neither forget calls itself complete.
6281        let place = format!("1 place(s) could not be read, first s000001/{}: ", archive_name(&digest[..32]));
6282        let done = store.forget(imported).unwrap();
6283        assert_eq!(done.ids, [imported]);
6284        assert!(done.unread.unwrap().starts_with(&place));
6285        assert!(is_tombstone_of(&store, &file, &digest));
6286        assert_eq!(shelf_files(&dir).len(), files);
6287        assert_eq!(std::fs::read(shelf.join(archive_name(&digest[..32]))).unwrap(), b"not a record");
6288        // An archive that was forgotten before this rule gets its tombstone
6289        // the next time it is forgotten.
6290        std::fs::remove_file(&guard).unwrap();
6291        let done = store.forget(imported).unwrap();
6292        assert!(done.ids.is_empty());
6293        assert!(done.unread.unwrap().starts_with(&place));
6294        assert!(is_tombstone_of(&store, &guard, &digest));
6295
6296        // Stored under one of its two ids, the archive's own tombstone is
6297        // the one a seal finds: nothing is added.
6298        let native = store.seal(&text("under its own id"), "").unwrap();
6299        let files = shelf_files(&dir).len();
6300        assert_eq!(store.forget(&native.id).unwrap(), forgotten(&[&native.id]));
6301        assert_eq!(shelf_files(&dir).len(), files);
6302        assert!(temporary_files(&dir).is_empty());
6303    }
6304
6305    /// A copy in a place that cannot be read is not reached. The forget
6306    /// says so, and forgetting again reaches it.
6307    #[test]
6308    fn a_forget_says_which_places_it_could_not_read() {
6309        let (tmp, store) = setup();
6310        let dir = directory(&tmp, &store);
6311        let (older, newer) = (dir.join("s000001"), dir.join("s000002"));
6312        let words = "one id on two shelves";
6313        let twice = store.seal(&text(words), "").unwrap();
6314        let name = archive_name(&twice.id);
6315        std::fs::create_dir(&newer).unwrap();
6316        std::fs::copy(older.join(&name), newer.join(&name)).unwrap();
6317        // The older shelf cannot be opened: its copy stays live.
6318        let failing = Fault::on("open", "s000001", libc::EIO);
6319        let done = store.forget(&twice.id).unwrap();
6320        drop(failing);
6321        assert_eq!(done.ids, std::slice::from_ref(&twice.id));
6322        let unread = done.unread.unwrap();
6323        assert!(unread.starts_with("1 place(s) could not be read, first s000001: "), "{unread}");
6324        assert!(is_tombstone_of(&store, &newer.join(&name), &digest_of(words)));
6325        let rows = store.search(words, 8).unwrap();
6326        assert_eq!(rows.len(), 1, "the copy that was not reached is still found");
6327        // Forgotten again, with the shelf readable: reached.
6328        assert_eq!(store.forget(&twice.id).unwrap(), forgotten(&[&twice.id]));
6329        assert!(store.search(words, 8).unwrap().is_empty());
6330
6331        // A file under one of the window's names that the store does not
6332        // read may be a copy too.
6333        let words = "beside a file with two names";
6334        let digest = digest_of(words);
6335        let sealed = store.seal(&text(words), "").unwrap();
6336        let linked = newer.join(archive_name(&digest[32..]));
6337        std::fs::hard_link(newer.join(archive_name(&sealed.id)), tmp.path().join("a-second-name")).unwrap();
6338        std::fs::write(&linked, b"x").unwrap();
6339        std::fs::hard_link(&linked, tmp.path().join("another-second-name")).unwrap();
6340        let error = store.forget(&sealed.id).unwrap_err();
6341        assert_eq!(error.kind(), io::ErrorKind::PermissionDenied, "its own file has two names");
6342        std::fs::remove_file(tmp.path().join("a-second-name")).unwrap();
6343        let done = store.forget(&sealed.id).unwrap();
6344        assert_eq!(done.ids, std::slice::from_ref(&sealed.id));
6345        let place = format!("first s000002/{}: ", archive_name(&digest[32..]));
6346        assert!(done.unread.unwrap().contains(&place));
6347        assert_eq!(std::fs::read(&linked).unwrap(), b"x");
6348    }
6349
6350    /// A file under the id on a newer shelf that is not a valid record does
6351    /// not hide the valid record under the id on an older shelf: a fetch
6352    /// returns it, and a forget by that id forgets it and names the odd file
6353    /// as not reached.
6354    #[test]
6355    fn a_lookup_by_id_passes_over_a_file_that_is_not_a_record_to_an_older_shelf() {
6356        let (tmp, store) = setup();
6357        let dir = directory(&tmp, &store);
6358        let (older, newer) = (dir.join("s000001"), dir.join("s000002"));
6359        std::fs::create_dir(&older).unwrap();
6360        std::fs::create_dir(&newer).unwrap();
6361        std::fs::write(tmp.path().join("elsewhere"), b"not in the store").unwrap();
6362        let base = tmp.path();
6363        for link in [false, true] {
6364            let words = format!("behind an odd file of its name, link {link}");
6365            let digest = digest_of(&words);
6366            let sealed = store.seal_with_note(&text(&words), "its note").unwrap().0;
6367            let name = archive_name(&sealed.id);
6368            assert_eq!(sealed.id, digest[..32]);
6369            std::fs::rename(newer.join(&name), older.join(&name)).unwrap();
6370            if link {
6371                symlink(tmp.path().join("elsewhere"), newer.join(&name)).unwrap();
6372            } else {
6373                std::fs::write(newer.join(&name), b"not a record").unwrap();
6374            }
6375            // Read by id: the valid copy.
6376            assert_eq!(store.fetch(&sealed.id, 0, 1).unwrap(), text(&words));
6377            assert_eq!(store.fetch_note(&sealed.id).unwrap(), "its note");
6378            let file = archive_file(base, "synthetic-project", &sealed.id).unwrap();
6379            assert_eq!(file, Some(older.join(&name)));
6380            // Forgotten by that id: the valid copy, and never a plain "done".
6381            let done = store.forget(&sealed.id).unwrap();
6382            assert_eq!(done.ids, std::slice::from_ref(&sealed.id));
6383            let unread = done.unread.unwrap();
6384            let place = format!("1 place(s) could not be read, first s000002/{name}: ");
6385            assert!(unread.starts_with(&place), "{unread}");
6386            assert!(is_tombstone_of(&store, &older.join(&name), &digest));
6387            let kind = std::fs::symlink_metadata(newer.join(&name)).unwrap().file_type();
6388            assert_eq!(kind.is_symlink(), link, "the odd file is left as it is");
6389            let error = store.fetch(&sealed.id, 0, 1).unwrap_err();
6390            assert_eq!(error.kind(), io::ErrorKind::NotFound);
6391            let before = snapshot(&dir);
6392            let refused = store.seal(&text(&words), "").unwrap_err();
6393            assert_eq!(refused.kind(), io::ErrorKind::PermissionDenied);
6394            assert_eq!(snapshot(&dir), before);
6395            assert!(store.search(&words, 8).unwrap().is_empty());
6396        }
6397
6398        // With no valid record under the id on any shelf, the error is the
6399        // newest odd file's, named by its shelf and file.
6400        let lone = "0123456789abcdef0123456789abcdef";
6401        std::fs::write(newer.join(archive_name(lone)), b"not a record").unwrap();
6402        std::fs::write(older.join(archive_name(lone)), b"{}").unwrap();
6403        let named = format!(
6404            "the newest file under this id is not its record: s000002/{}: invalid archive record",
6405            archive_name(lone)
6406        );
6407        for error in [store.fetch(lone, 0, 1).unwrap_err(), store.forget(lone).unwrap_err()] {
6408            assert_eq!(error.kind(), io::ErrorKind::InvalidData);
6409            assert_eq!(error.to_string(), named);
6410        }
6411        assert!(archive_file(base, "synthetic-project", lone).is_err());
6412        // A read that failed says nothing about the file: still the error.
6413        let words = "behind a file that cannot be read now";
6414        let sealed = store.seal(&text(words), "").unwrap();
6415        let name = archive_name(&sealed.id);
6416        std::fs::copy(newer.join(&name), older.join(&name)).unwrap();
6417        let failing = Fault::on("open", &sealed.id, libc::EIO);
6418        assert_eq!(store.fetch(&sealed.id, 0, 1).unwrap_err().raw_os_error(), Some(libc::EIO));
6419        assert_eq!(store.forget(&sealed.id).unwrap_err().raw_os_error(), Some(libc::EIO));
6420        drop(failing);
6421
6422        // The odd file is a tombstone of this very window that no longer
6423        // validates: the older copy is not handed out, and a forget by the
6424        // id still reaches it.
6425        let words = "behind a damaged tombstone";
6426        let digest = digest_of(words);
6427        let sealed = store.seal(&text(words), "").unwrap();
6428        let name = archive_name(&sealed.id);
6429        std::fs::rename(newer.join(&name), older.join(&name)).unwrap();
6430        let damaged = record_bytes(&store, &sealed.id, &text(words), |record| {
6431            record.body = Body::Tombstone;
6432            record.version = 2;
6433        });
6434        std::fs::write(newer.join(&name), damaged).unwrap();
6435        assert_eq!(store.fetch(&sealed.id, 0, 1).unwrap_err().kind(), io::ErrorKind::NotFound);
6436        assert_eq!(store.fetch_note(&sealed.id).unwrap_err().kind(), io::ErrorKind::NotFound);
6437        assert!(archive_file(base, "synthetic-project", &sealed.id).is_err());
6438        assert_eq!(store.forget(&sealed.id).unwrap(), forgotten(&[&sealed.id]));
6439        assert!(is_tombstone_of(&store, &older.join(&name), &digest));
6440    }
6441
6442    /// A file under one of the window's own names that was read and is not
6443    /// a valid record may be a damaged copy, with the window's text still on
6444    /// disk. The forget does not call itself complete over it: `unread` is
6445    /// what `memory_forget` turns into "NOT COMPLETE".
6446    #[test]
6447    fn a_forget_is_not_complete_over_a_damaged_copy_under_the_windows_name() {
6448        let (tmp, store) = setup();
6449        let dir = directory(&tmp, &store);
6450        let shelf = dir.join("s000001");
6451        let words = "a window with a damaged copy";
6452        let digest = digest_of(words);
6453        let (first, second) = (&digest[..32], &digest[32..]);
6454        assert_eq!(store.seal(&text(words), "").unwrap().id, first);
6455        // A second copy, cut short: not a record, and the text is in it.
6456        let copy = record_bytes(&store, second, &text(words), |_| {});
6457        let damaged = &copy[..copy.len() - 2];
6458        assert!(String::from_utf8_lossy(damaged).contains(words));
6459        let file = shelf.join(archive_name(second));
6460        std::fs::write(&file, damaged).unwrap();
6461
6462        let done = store.forget(first).unwrap();
6463        assert_eq!(done.ids, [first]);
6464        assert_ne!(done, forgotten(&[first]), "not a plain 'forgot'");
6465        let unread = done.unread.unwrap();
6466        let place = format!("1 place(s) could not be read, first s000001/{}: ", archive_name(second));
6467        assert!(unread.starts_with(&place), "{unread}");
6468        assert!(is_tombstone_of(&store, &shelf.join(archive_name(first)), &digest));
6469        assert_eq!(std::fs::read(&file).unwrap(), damaged, "left as it is");
6470        // Said again for as long as the file is there.
6471        assert!(store.forget(first).unwrap().unread.is_some());
6472
6473        // A file there that is a valid record of another window is not a
6474        // copy, and a damaged tombstone of this one says "forgotten":
6475        // neither makes the forget incomplete.
6476        let other = record_bytes(&store, second, &text("another window"), |_| {});
6477        std::fs::write(&file, other).unwrap();
6478        assert_eq!(store.forget(first).unwrap(), forgotten(&[]));
6479        let tombstone = record_bytes(&store, second, &text(words), |record| {
6480            record.body = Body::Tombstone;
6481            record.version = 2;
6482        });
6483        std::fs::write(&file, tombstone).unwrap();
6484        assert_eq!(store.forget(first).unwrap(), forgotten(&[]));
6485    }
6486
6487    const PASSING: [i32; 5] = [libc::EMFILE, libc::ENFILE, libc::ENOMEM, libc::EINTR, libc::EAGAIN];
6488
6489    /// An error that is not about the shelf (the process is out of
6490    /// descriptors, say) is not a reason to pass over it: what the shelf
6491    /// holds is not seen then, and a window in it would be stored again and
6492    /// a forgotten one would be back.
6493    #[test]
6494    fn a_passing_error_on_a_shelf_or_a_slot_fails_the_seal_and_changes_nothing() {
6495        let (tmp, store) = setup();
6496        let dir = directory(&tmp, &store);
6497        let slot = seal_legacy_slot(&store, 0, &text("in a slot"), "");
6498        store.forget(&slot.id).unwrap();
6499        // As a build from before a forget left a tombstone on a shelf
6500        // forgot it: only the slot says so.
6501        std::fs::remove_file(only_archive_file(&dir)).unwrap();
6502        let kept = store.seal(&text("kept"), "").unwrap();
6503        let gone = store.seal(&text("forgotten"), "").unwrap();
6504        store.forget(&gone.id).unwrap();
6505        // Later seals go to a newer shelf.
6506        std::fs::create_dir(dir.join("s000002")).unwrap();
6507        let before = snapshot(&dir);
6508        let entries = || std::fs::read_dir(&dir).unwrap().count();
6509        let listed = entries();
6510        let refused = |words: &str, code: i32| {
6511            let error = store.seal(&text(words), "").unwrap_err();
6512            assert_eq!(error.raw_os_error(), Some(code), "{words}: {error}");
6513        };
6514        for code in PASSING {
6515            assert!(PrivateDir::passing(&io::Error::from_raw_os_error(code)));
6516            // The older shelf, the newest one, every shelf, the slot.
6517            for place in ["s000001", "s000002", "s", "000.json"] {
6518                let failing = Fault::on("open", place, code);
6519                // A window found on a shelf is not looked for in the slots.
6520                if place != "000.json" {
6521                    refused("kept", code);
6522                    refused("forgotten", code);
6523                }
6524                refused("in a slot", code);
6525                refused("something new", code);
6526                drop(failing);
6527                assert_eq!(snapshot(&dir), before, "{place}, error {code}");
6528                assert_eq!(entries(), listed, "{place}, error {code}: a shelf was started");
6529            }
6530        }
6531        // The trouble over, everything is what it was.
6532        assert_eq!(store.seal(&text("kept"), "").unwrap(), kept);
6533        for words in ["forgotten", "in a slot"] {
6534            let error = store.seal(&text(words), "").unwrap_err();
6535            assert_eq!(error.kind(), io::ErrorKind::PermissionDenied, "{words}");
6536        }
6537        assert_eq!(snapshot(&dir), before);
6538    }
6539
6540    /// A shelf or a slot that is itself the trouble, an I/O error reading it
6541    /// included, is still passed over: one bad shelf never stops a save.
6542    #[test]
6543    fn an_io_error_on_a_shelf_or_a_slot_is_passed_over() {
6544        let (tmp, store) = setup();
6545        let dir = directory(&tmp, &store);
6546        seal_legacy_slot(&store, 0, &text("in a slot"), "");
6547        let kept = store.seal(&text("kept"), "").unwrap();
6548        for code in [libc::EIO, libc::EACCES, libc::ENOTDIR, libc::ELOOP, libc::ESTALE] {
6549            assert!(!PrivateDir::passing(&io::Error::from_raw_os_error(code)));
6550        }
6551        // An older shelf: the new window goes to the newest.
6552        std::fs::create_dir(dir.join("s000002")).unwrap();
6553        let failing = Fault::on("open", "s000001", libc::EIO);
6554        let new = store.seal(&text("something new"), "").unwrap();
6555        assert!(dir.join("s000002").join(archive_name(&new.id)).is_file());
6556        // The price: a window in it is not seen and is stored again.
6557        assert_eq!(store.seal(&text("kept"), "").unwrap(), kept);
6558        assert!(dir.join("s000002").join(archive_name(&kept.id)).is_file());
6559        drop(failing);
6560        // The newest shelf: the next number is started.
6561        let failing = Fault::on("open", "s000002", libc::EIO);
6562        let next = store.seal(&text("after the newest"), "").unwrap();
6563        assert!(dir.join("s000003").join(archive_name(&next.id)).is_file());
6564        drop(failing);
6565        // A slot: the window in it is stored again, on a shelf.
6566        let _failing = Fault::on("open", "000.json", libc::EIO);
6567        let again = store.seal(&text("in a slot"), "").unwrap();
6568        assert!(dir.join("s000003").join(archive_name(&again.id)).is_file());
6569    }
6570
6571    /// A shelf that was made and then could not be opened is the one the
6572    /// next seal uses. Failed attempts do not leave one shelf each.
6573    #[test]
6574    fn a_failed_attempt_leaves_at_most_one_empty_shelf_and_the_next_seal_uses_it() {
6575        let (tmp, store) = setup();
6576        let dir = directory(&tmp, &store);
6577        store.seal(&text("a lock to begin with"), "").unwrap();
6578        // The newest shelf name is not a shelf, so a seal starts the next
6579        // one; out of descriptors, it makes the directory and cannot open it.
6580        std::fs::write(dir.join("s000002"), b"not a directory").unwrap();
6581        let shelves = || {
6582            let mut names: Vec<_> = std::fs::read_dir(&dir).unwrap().collect();
6583            names.retain(|entry| entry.as_ref().unwrap().file_name() != "lock");
6584            names.len()
6585        };
6586        let failing = Fault::on("open", "s000003", libc::EMFILE);
6587        for _ in 0..3 {
6588            let error = store.seal(&text("after the trouble"), "").unwrap_err();
6589            assert_eq!(error.raw_os_error(), Some(libc::EMFILE));
6590            assert_eq!(shelves(), 3, "s000001, the file s000002 and the empty s000003");
6591        }
6592        assert_eq!(std::fs::read_dir(dir.join("s000003")).unwrap().count(), 0);
6593        drop(failing);
6594        let sealed = store.seal(&text("after the trouble"), "").unwrap();
6595        assert!(dir.join("s000003").join(archive_name(&sealed.id)).is_file());
6596        assert_eq!(shelves(), 3);
6597    }
6598
6599    #[test]
6600    fn multi_mib_context_roundtrips_without_truncation() {
6601        let (_tmp, store) = setup();
6602        let text = "eligible exact evidence\n".repeat(400_000);
6603        assert!(text.len() > 8 * 1024 * 1024);
6604        let messages = vec![msg("user", json!(text))];
6605        let reference = store.seal(&messages, "large source window").unwrap();
6606        assert_eq!(store.fetch(&reference.id, 0, 1).unwrap(), messages);
6607        assert_eq!(store.seal(&messages, "retry").unwrap(), reference);
6608    }
6609
6610    #[test]
6611    fn private_reasoning_disguised_as_assistant_text_is_omitted() {
6612        let (_tmp, store) = setup();
6613        let messages = vec![
6614            Arc::new(json!({"role":"assistant","channel":"analysis","content":"private analysis"})),
6615            Arc::new(
6616                json!({"role":"assistant","content_class":"private_reasoning","content":"private reasoning"}),
6617            ),
6618            Arc::new(json!({"role":"assistant","private":true,"content":"private text"})),
6619            Arc::new(json!({"role":"assistant","channel":"final","content":"public evidence"})),
6620        ];
6621        let reference = store.seal(&messages, "").unwrap();
6622        assert_eq!(reference.message_count, 1);
6623        let fetched = store.fetch_with_provenance(&reference.id, 0, 1).unwrap();
6624        assert_eq!(fetched[0].source_index, 3);
6625        assert_eq!(fetched[0].message["content"], "public evidence");
6626    }
6627
6628    #[test]
6629    fn after_redaction_requires_host_redactor_including_parent_class() {
6630        let (_tmp, store) = setup();
6631        let messages = vec![Arc::new(json!({
6632            "role":"assistant", "disclosure":"model_visible_after_redaction",
6633            "content":[{"type":"text", "text":"sensitive host classified material"}]
6634        }))];
6635        let reference = store.seal(&messages, "").unwrap();
6636        let fetched = store.fetch(&reference.id, 0, 1).unwrap();
6637        assert_eq!(fetched[0]["content"][0]["text"], WITHHELD);
6638    }
6639
6640    #[test]
6641    fn bounded_search_utf8() {
6642        let (_tmp, store) = setup();
6643        for index in 0..MAX_SEARCH_RESULTS + 8 {
6644            let words = format!("{index} {}", "é".repeat(200));
6645            store.seal(&text(&words), "").unwrap();
6646        }
6647        let descriptors = store.search("", MAX_SEARCH_RESULTS).unwrap();
6648        assert_eq!(descriptors.len(), MAX_SEARCH_RESULTS);
6649        assert!(descriptors.iter().all(|d| d.snippet.len() <= 256));
6650    }
6651
6652    #[test]
6653    fn seal_129_succeed() {
6654        let (tmp, store) = setup();
6655        let dir = directory(&tmp, &store);
6656        let seal = |index: usize| store.seal(&text(&format!("archive {index}")), "").unwrap();
6657        let mut ids = vec![seal(0).id];
6658        let first = only_archive_file(&dir);
6659        let bytes = std::fs::read(&first).unwrap();
6660        ids.extend((1..=SHELF_FILES).map(|index| seal(index).id));
6661        assert_eq!(ids.len(), 129);
6662        assert_eq!(first, dir.join("s000001").join(archive_name(&ids[0])));
6663        for (index, id) in ids.iter().enumerate() {
6664            assert_eq!(
6665                store.fetch(id, 0, 1).unwrap(),
6666                text(&format!("archive {index}"))
6667            );
6668        }
6669        assert_eq!(std::fs::read(&first).unwrap(), bytes);
6670        // The 129th starts the second shelf; nothing was put in a legacy slot.
6671        let files = shelf_files(&dir);
6672        assert_eq!(files.len(), 129);
6673        assert_eq!(files[128], dir.join("s000002").join(archive_name(&ids[128])));
6674        assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 3, "lock and two shelves");
6675        // Forgetting does not make room in a full shelf, and none is needed.
6676        store.forget(&ids[0]).unwrap();
6677        let next = seal(129);
6678        assert_eq!(shelf_files(&dir).len(), 130);
6679        assert!(dir.join("s000002").join(archive_name(&next.id)).is_file());
6680    }
6681
6682    #[test]
6683    #[ignore = "seals 10,000 archives (30,000 fsyncs): 4 s on tmpfs, far longer on a real disk"]
6684    fn seal_10000_succeed() {
6685        let (tmp, store) = setup();
6686        let dir = directory(&tmp, &store);
6687        let seal = |index: usize| store.seal(&text(&format!("archive {index}")), "").unwrap();
6688        let ids: Vec<_> = (0..10_000).map(|index| seal(index).id).collect();
6689        let shelves = store.shelves().unwrap();
6690        assert_eq!(shelves.len(), 79);
6691        assert_eq!(shelves[0], "s000079");
6692        assert_eq!(shelf_files(&dir).len(), 10_000);
6693        for index in [0, 9_999] {
6694            assert_eq!(
6695                store.fetch(&ids[index], 0, 1).unwrap(),
6696                text(&format!("archive {index}"))
6697            );
6698        }
6699        let started = Instant::now();
6700        store.fetch(&ids[0], 0, 1).unwrap();
6701        eprintln!("fetch of the oldest of 10,000: {:?}", started.elapsed());
6702    }
6703
6704    #[test]
6705    fn no_seal_replaces_a_file() {
6706        let (tmp, store) = setup();
6707        let dir = directory(&tmp, &store);
6708        seal_legacy_slot(&store, 0, &text("legacy kept"), "");
6709        let forgotten = seal_legacy_slot(&store, 1, &text("legacy forgotten"), "");
6710        store.forget(&forgotten.id).unwrap();
6711        let mut before = snapshot(&dir);
6712        assert_eq!(before.len(), 4, "lock, two legacy slots and the forget's tombstone on a shelf");
6713        for index in 0..300 {
6714            store.seal(&text(&format!("archive {index}")), "").unwrap();
6715            let after = snapshot(&dir);
6716            for (path, hash) in &before {
6717                assert_eq!(after.get(path), Some(hash), "seal {index} touched {path:?}");
6718            }
6719            assert_eq!(after.len(), before.len() + 1);
6720            before = after;
6721        }
6722        // Neither does sealing something again, forgotten or not.
6723        store.seal(&text("archive 7"), "changed note").unwrap();
6724        assert!(store.seal(&text("legacy forgotten"), "").is_err());
6725        assert_eq!(snapshot(&dir), before);
6726    }
6727
6728    #[test]
6729    #[ignore = "writes and re-reads about 280 MiB: about 55 s in a debug build"]
6730    fn byte_cap_is_gone() {
6731        let (tmp, store) = setup();
6732        let large = |index: usize| {
6733            let filler = "x".repeat(15 * 1024 * 1024 + 512 * 1024);
6734            text(&format!("archive-{index:02} {filler}"))
6735        };
6736        // 18 segments of 15.5 MiB: 279 MiB, where the store used to stop at 256.
6737        let ids: Vec<_> = (0..18)
6738            .map(|index| store.seal(&large(index), "").unwrap().id)
6739            .collect();
6740        let files = shelf_files(&directory(&tmp, &store));
6741        let stored: u64 = files.iter().map(|f| f.metadata().unwrap().len()).sum();
6742        assert!(stored > SEARCH_SCAN_BYTES as u64 + MAX_SEGMENT_BYTES as u64);
6743        for (index, id) in ids.iter().enumerate() {
6744            let fetched = store.fetch(id, 0, 1).unwrap();
6745            let content = fetched[0]["content"].as_str().unwrap();
6746            assert!(content.starts_with(&format!("archive-{index:02} ")));
6747            assert_eq!(content.len(), large(index)[0]["content"].as_str().unwrap().len());
6748        }
6749        // The search budget is bytes as well as files: 17 files pass 256 MiB.
6750        let (rows, stopped) = store.search_scan("archive-00", 1).unwrap();
6751        assert!(rows.is_empty());
6752        assert_eq!(stopped, Some(Unscanned { stopped_after: Some(17), unread: None }));
6753        let (rows, stopped) = store.search_scan("archive-17", 1).unwrap();
6754        assert_eq!((rows[0].id.as_str(), stopped), (ids[17].as_str(), None));
6755    }
6756
6757    #[test]
6758    fn legacy_slots_read_by_new_code() {
6759        let (tmp, store) = setup();
6760        let dir = directory(&tmp, &store);
6761        let a = seal_legacy_slot(&store, 0, &text("legacy alpha"), "note a");
6762        let b = seal_legacy_slot(&store, 1, &text("legacy beta"), "note b");
6763        let c = seal_legacy_slot(&store, 2, &text("legacy gamma"), "note c");
6764        store.forget(&c.id).unwrap();
6765        let slots = ["000.json", "001.json", "002.json"].map(|name| dir.join(name));
6766        assert!(std::fs::read_to_string(&slots[2]).unwrap().contains("tombstone"));
6767        let kept = std::fs::read(&slots[1]).unwrap();
6768        assert_eq!(store.fetch(&a.id, 0, 1).unwrap(), text("legacy alpha"));
6769        assert_eq!(store.fetch_note(&b.id).unwrap(), "note b");
6770        assert_eq!(
6771            store.fetch(&c.id, 0, 1).unwrap_err().kind(),
6772            io::ErrorKind::NotFound
6773        );
6774        let found = |query: &str| -> Vec<String> {
6775            let rows = store.search(query, 10).unwrap();
6776            rows.into_iter().map(|row| row.id).collect()
6777        };
6778        assert_eq!(found("legacy"), [b.id.as_str(), a.id.as_str()]);
6779        assert_eq!(
6780            archive_file(tmp.path(), "synthetic-project", &b.id).unwrap(),
6781            Some(slots[1].clone())
6782        );
6783        store.forget(&a.id).unwrap();
6784        store.forget(&a.id).unwrap();
6785        assert!(std::fs::read_to_string(&slots[0]).unwrap().contains("tombstone"));
6786        assert_eq!(found(""), [b.id.as_str()]);
6787        // On the shelf so far: the tombstone each forget left where a seal
6788        // of that window looks, and no archive.
6789        let shelf = dir.join("s000001");
6790        let guards = ["legacy alpha", "legacy gamma"].map(|words| {
6791            let digest = digest_of(words);
6792            let guard = shelf.join(archive_name(&digest[..32]));
6793            assert!(is_tombstone_of(&store, &guard, &digest), "{words}");
6794            guard
6795        });
6796        let mut guards = guards.to_vec();
6797        guards.sort();
6798        assert_eq!(shelf_files(&dir), guards);
6799        // Only what is sealed from now on goes to a shelf, under its digest id.
6800        let new = store.seal(&text("after shelves"), "").unwrap();
6801        assert!(shelf.join(archive_name(&new.id)).is_file());
6802        assert_eq!(shelf_files(&dir).len(), 3);
6803        assert_eq!(found(""), [new.id.as_str(), b.id.as_str()]);
6804        assert_eq!(std::fs::read(&slots[1]).unwrap(), kept);
6805        assert!(slots.iter().all(|slot| slot.is_file()));
6806        assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 5);
6807    }
6808
6809    #[test]
6810    fn legacy_full_store_accepts_seal() {
6811        let (tmp, store) = setup();
6812        let dir = directory(&tmp, &store);
6813        let legacy: Vec<_> = (0..MAX_SEGMENTS)
6814            .map(|slot| seal_legacy_slot(&store, slot, &text(&format!("legacy {slot}")), ""))
6815            .collect();
6816        let before = snapshot(&dir);
6817        let new = store.seal(&text("the 129th"), "").unwrap();
6818        assert_eq!(store.fetch(&new.id, 0, 1).unwrap(), text("the 129th"));
6819        assert_eq!(
6820            only_archive_file(&dir),
6821            dir.join("s000001").join(archive_name(&new.id))
6822        );
6823        for slot in [0, MAX_SEGMENTS - 1] {
6824            assert_eq!(
6825                store.fetch(&legacy[slot].id, 0, 1).unwrap(),
6826                text(&format!("legacy {slot}"))
6827            );
6828        }
6829        let after = snapshot(&dir);
6830        assert!(before.iter().all(|(path, hash)| after.get(path) == Some(hash)));
6831        // Newest first: the shelf, then the highest slot.
6832        let rows = store.search("", 2).unwrap();
6833        assert_eq!(rows[0].id, new.id);
6834        assert_eq!(rows[1].id, legacy[MAX_SEGMENTS - 1].id);
6835    }
6836
6837    #[test]
6838    fn legacy_duplicate_and_tombstone_still_honoured() {
6839        let (tmp, store) = setup();
6840        let dir = directory(&tmp, &store);
6841        let live = seal_legacy_slot(&store, 0, &text("sealed long ago"), "first note");
6842        let gone = seal_legacy_slot(&store, 1, &text("forgotten long ago"), "");
6843        store.forget(&gone.id).unwrap();
6844        let (again, note) = store
6845            .seal_with_note(&text("sealed long ago"), "second note")
6846            .unwrap();
6847        assert_eq!((again, note.as_str()), (live, "first note"));
6848        assert_eq!(
6849            store.seal(&text("forgotten long ago"), "").unwrap_err().kind(),
6850            io::ErrorKind::PermissionDenied
6851        );
6852        // Neither seal wrote anything. The one file on a shelf is the
6853        // tombstone the forget left where a seal looks.
6854        let guard = only_archive_file(&dir);
6855        assert!(is_tombstone_of(&store, &guard, &digest_of("forgotten long ago")));
6856        assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 4, "lock, two slots, one shelf");
6857    }
6858
6859    /// Legacy slots are still read under the lock, and only they are.
6860    #[test]
6861    fn legacy_fetch_waits_for_the_lock() {
6862        let (_tmp, mut store) = setup();
6863        let legacy = seal_legacy_slot(&store, 0, &text("sealed long ago"), "old note");
6864        let shelved = store.seal(&text("sealed today"), "").unwrap();
6865        let hold = Duration::from_millis(300);
6866        let started = Instant::now();
6867        let foreign = store.fs.lock().unwrap();
6868        let holder = std::thread::spawn(move || {
6869            std::thread::sleep(hold);
6870            drop(foreign);
6871        });
6872        assert_eq!(store.fetch(&legacy.id, 0, 1).unwrap(), text("sealed long ago"));
6873        assert!(started.elapsed() >= hold);
6874        holder.join().unwrap();
6875
6876        // Held past the deadline: busy, for what has to read a slot.
6877        store.process_wait = Duration::from_millis(100);
6878        let _foreign = store.fs.lock().unwrap();
6879        let started = Instant::now();
6880        for result in [
6881            store.fetch(&legacy.id, 0, 1).map(drop),
6882            store.fetch_note(&legacy.id).map(drop),
6883            // On no shelf, so it may be in a slot.
6884            store.fetch(&"a".repeat(32), 0, 1).map(drop),
6885            // The shelves do not fill this page.
6886            store.search("", 2).map(drop),
6887        ] {
6888            let error = result.unwrap_err();
6889            assert_eq!(error.kind(), io::ErrorKind::WouldBlock);
6890            assert_eq!(error.to_string(), BUSY);
6891        }
6892        assert!(started.elapsed() >= store.process_wait * 4);
6893        // What the shelves answer never gets as far as the slots.
6894        assert_eq!(store.fetch(&shelved.id, 0, 1).unwrap(), text("sealed today"));
6895        assert_eq!(store.search("", 1).unwrap()[0].id, shelved.id);
6896    }
6897
6898    #[test]
6899    fn duplicate_seal_returns_same_id_across_shelves() {
6900        let (tmp, store) = setup();
6901        let dir = directory(&tmp, &store);
6902        let seal = |index: usize, note: &str| {
6903            let words = format!("archive {index}");
6904            store.seal_with_note(&text(&words), note).unwrap()
6905        };
6906        let first = seal(0, "first note");
6907        for index in 1..=SHELF_FILES {
6908            seal(index, "");
6909        }
6910        let before = snapshot(&dir);
6911        assert_eq!(shelf_files(&dir).len(), 129);
6912        // The newest shelf is the second; the first projection is on the first.
6913        assert_eq!(seal(0, "another note"), first);
6914        assert_eq!(seal(SHELF_FILES, "another note").1, "");
6915        assert_eq!(snapshot(&dir), before);
6916        assert_eq!(first.0.id.len(), 32);
6917        let digest = project_archive("synthetic-logical", &text("archive 0"), "", None)
6918            .unwrap()
6919            .digest;
6920        assert_eq!(first.0.id, digest[..32]);
6921    }
6922
6923    #[test]
6924    fn forgotten_projection_cannot_be_resealed() {
6925        let (tmp, store) = setup();
6926        let dir = directory(&tmp, &store);
6927        let reference = store.seal(&text("forget me"), "note").unwrap();
6928        store.forget(&reference.id).unwrap();
6929        let before = snapshot(&dir);
6930        for note in ["note", "another note"] {
6931            assert_eq!(
6932                store.seal(&text("forget me"), note).unwrap_err().kind(),
6933                io::ErrorKind::PermissionDenied
6934            );
6935        }
6936        assert_eq!(snapshot(&dir), before);
6937        // Another conversation's identical words are another projection.
6938        let other = ArchiveStore::new(tmp.path(), "synthetic-project", "other-logical").unwrap();
6939        assert!(other.seal(&text("forget me"), "").is_ok());
6940        assert!(store.seal(&text("remember me"), "").is_ok());
6941    }
6942
6943    #[test]
6944    fn forget_is_in_place_and_idempotent() {
6945        let (tmp, store) = setup();
6946        let dir = directory(&tmp, &store);
6947        let kept = store.seal(&text("keep me"), "").unwrap();
6948        let reference = store.seal(&text("forget me"), "note").unwrap();
6949        let file = dir.join("s000001").join(archive_name(&reference.id));
6950        let neighbour = dir.join("s000001").join(archive_name(&kept.id));
6951        let untouched = snapshot(&dir)[&neighbour].clone();
6952        store.forget(&reference.id).unwrap();
6953        assert_eq!(shelf_files(&dir).len(), 2, "same name, same shelf");
6954        let tombstone = std::fs::read_to_string(&file).unwrap();
6955        assert!(tombstone.contains("tombstone") && !tombstone.contains("forget me"));
6956        let written = file.metadata().unwrap().modified().unwrap();
6957        // Again: nothing is rewritten.
6958        store.forget(&reference.id).unwrap();
6959        assert_eq!(std::fs::read_to_string(&file).unwrap(), tombstone);
6960        assert_eq!(file.metadata().unwrap().modified().unwrap(), written);
6961        assert!(temporary_files(&dir).is_empty());
6962        assert_eq!(std::fs::read_dir(dir.join("s000001")).unwrap().count(), 2);
6963        assert_eq!(snapshot(&dir)[&neighbour], untouched);
6964        assert_eq!(store.fetch(&kept.id, 0, 1).unwrap(), text("keep me"));
6965        assert_eq!(
6966            store.forget(&"a".repeat(32)).unwrap_err().kind(),
6967            io::ErrorKind::NotFound
6968        );
6969    }
6970
6971    #[test]
6972    fn fetch_reads_one_file() {
6973        let (tmp, store) = setup();
6974        let dir = directory(&tmp, &store);
6975        let ids: Vec<_> = (0..300)
6976            .map(|index| store.seal(&text(&format!("archive {index}")), "note").unwrap().id)
6977            .collect();
6978        // On the oldest shelf, behind two newer ones.
6979        let target = dir.join("s000001").join(archive_name(&ids[5]));
6980        for file in shelf_files(&dir) {
6981            if file != target {
6982                std::fs::write(file, b"corrupt").unwrap();
6983            }
6984        }
6985        assert_eq!(store.fetch(&ids[5], 0, 1).unwrap(), text("archive 5"));
6986        assert_eq!(store.fetch_note(&ids[5]).unwrap(), "note");
6987        assert_eq!(store.seal(&text("archive 5"), "").unwrap().id, ids[5]);
6988        assert!(store.seal(&text("archive 300"), "").is_ok());
6989        store.forget(&ids[5]).unwrap();
6990        assert_eq!(
6991            store.fetch(&ids[6], 0, 1).unwrap_err().kind(),
6992            io::ErrorKind::InvalidData
6993        );
6994    }
6995
6996    #[test]
6997    fn search_is_newest_first_and_budgeted() {
6998        let (tmp, store) = setup();
6999        let dir = directory(&tmp, &store);
7000        let epoch = std::time::UNIX_EPOCH + Duration::from_secs(1_000_000_000);
7001        // Inside a shelf the order is by modification time, which a fast
7002        // filesystem clock does not tell apart: give every file its own.
7003        let seal = |index: usize| {
7004            let words = match index {
7005                0 => "needle oldest-only".to_owned(),
7006                _ => format!("needle {index}"),
7007            };
7008            let id = store.seal(&text(&words), "").unwrap().id;
7009            let shelf = dir.join(shelf_name((index / SHELF_FILES + 1) as u64));
7010            let file = std::fs::File::options()
7011                .write(true)
7012                .open(shelf.join(archive_name(&id)))
7013                .unwrap();
7014            file.set_modified(epoch + Duration::from_secs(index as u64))
7015                .unwrap();
7016            id
7017        };
7018        let mut ids: Vec<_> = (0..100).map(&seal).collect();
7019        let (rows, stopped) = store.search_scan("oldest-only", 32).unwrap();
7020        assert_eq!((rows.len(), rows[0].id.as_str(), stopped), (1, ids[0].as_str(), None));
7021
7022        ids.extend((100..300).map(&seal));
7023        let rows = store.search("needle", 32).unwrap();
7024        let newest: Vec<_> = rows.iter().map(|row| row.id.as_str()).collect();
7025        let expected: Vec<_> = ids.iter().rev().take(32).map(String::as_str).collect();
7026        assert_eq!(newest, expected);
7027        // A full page is not a budget stop.
7028        assert_eq!(store.search_scan("needle", 32).unwrap().1, None);
7029
7030        // Exactly the budget: every file is read, so nothing is left unread.
7031        ids.extend((300..SEARCH_SCAN_FILES).map(&seal));
7032        let (rows, stopped) = store.search_scan("oldest-only", 32).unwrap();
7033        assert_eq!((rows.len(), stopped), (1, None));
7034        // One more, and the oldest is beyond it.
7035        ids.push(seal(SEARCH_SCAN_FILES));
7036        let (rows, stopped) = store.search_scan("oldest-only", 32).unwrap();
7037        assert!(rows.is_empty());
7038        let budget = Unscanned { stopped_after: Some(SEARCH_SCAN_FILES), unread: None };
7039        assert_eq!(stopped, Some(budget));
7040        assert_eq!(store.search("needle", 1).unwrap()[0].id, ids[SEARCH_SCAN_FILES]);
7041        assert_eq!(store.fetch(&ids[0], 0, 1).unwrap(), text("needle oldest-only"));
7042    }
7043
7044    /// Inside a shelf a search reads newest first by modification time,
7045    /// which a coarse filesystem clock gives to a whole burst of seals at
7046    /// once. Files with the same time are read in the order of their names,
7047    /// so the order, and with it what a short page holds, is the same every
7048    /// time and does not depend on how the directory lists them.
7049    #[test]
7050    fn search_reads_files_with_the_same_modification_time_in_name_order() {
7051        let (tmp, store) = setup();
7052        let shelf = directory(&tmp, &store).join("s000001");
7053        let ids: Vec<_> = (0..8)
7054            .map(|index| store.seal(&text(&format!("tied {index}")), "").unwrap().id)
7055            .collect();
7056        let epoch = std::time::UNIX_EPOCH + Duration::from_secs(1_000_000_000);
7057        let written = |id: &str, seconds: u64| {
7058            let path = shelf.join(archive_name(id));
7059            let file = std::fs::File::options().write(true).open(path).unwrap();
7060            file.set_modified(epoch + Duration::from_secs(seconds)).unwrap();
7061        };
7062        // Six in the same instant, one before it and one after.
7063        for id in &ids[2..] {
7064            written(id, 100);
7065        }
7066        written(&ids[0], 50);
7067        written(&ids[1], 200);
7068        let mut tied = ids[2..].to_vec();
7069        tied.sort();
7070        assert_ne!(tied, ids[2..], "the order of sealing is not the order of names");
7071        let mut expected = vec![ids[1].clone()];
7072        expected.extend(tied);
7073        expected.push(ids[0].clone());
7074        let order = |limit: usize| -> Vec<String> {
7075            let rows = store.search("tied", limit).unwrap();
7076            rows.into_iter().map(|row| row.id).collect()
7077        };
7078        assert_eq!(order(8), expected);
7079        // A shorter page is the beginning of the same order, every time.
7080        for limit in 1..8 {
7081            assert_eq!(order(limit), expected[..limit]);
7082            assert_eq!(order(limit), expected[..limit]);
7083        }
7084        // A file added in the same instant takes its place by name; the
7085        // others keep theirs.
7086        let late = store.seal(&text("tied late"), "").unwrap().id;
7087        written(&late, 100);
7088        expected.insert(1 + expected[1..7].partition_point(|id| *id < late), late);
7089        assert_eq!(order(9), expected);
7090    }
7091
7092    #[test]
7093    fn search_skips_a_corrupt_file() {
7094        let (tmp, store) = setup();
7095        let dir = directory(&tmp, &store);
7096        let ids: Vec<_> = ["first", "second", "third"]
7097            .iter()
7098            .map(|words| store.seal(&text(words), "").unwrap().id)
7099            .collect();
7100        let legacy = seal_legacy_slot(&store, 0, &text("legacy first"), "");
7101        seal_legacy_slot(&store, 1, &text("legacy second"), "");
7102        std::fs::write(dir.join("s000001").join(archive_name(&ids[1])), b"corrupt").unwrap();
7103        std::fs::write(dir.join("001.json"), b"{}").unwrap();
7104        // Larger than any segment: not even read.
7105        let huge = std::fs::File::create(dir.join("s000001").join(archive_name(&"f".repeat(32))));
7106        huge.unwrap().set_len(MAX_SEGMENT_BYTES as u64 + 1).unwrap();
7107        // Not segment names: never opened.
7108        std::fs::write(dir.join("s000001").join("notes.json"), b"stray").unwrap();
7109        std::fs::write(dir.join("s0000001"), b"stray").unwrap();
7110        let (rows, unscanned) = store.search_scan("", 10).unwrap();
7111        let mut found: Vec<_> = rows.into_iter().map(|row| row.id).collect();
7112        found.sort();
7113        let mut expected = vec![ids[0].clone(), ids[2].clone(), legacy.id];
7114        expected.sort();
7115        assert_eq!(found, expected);
7116        // The budget did not stop it, and it says what it skipped: the
7117        // corrupt file, the one too large to read and the slot.
7118        let unscanned = unscanned.unwrap();
7119        assert_eq!(unscanned.stopped_after, None);
7120        let unread = unscanned.unread.unwrap();
7121        assert!(unread.starts_with("3 place(s) could not be read, first s000001/"), "{unread}");
7122        assert_eq!(store.search("second", 10).unwrap(), []);
7123    }
7124
7125    /// One place that cannot be read hides only itself from a search, and
7126    /// the search says that its rows are not all the project holds.
7127    #[test]
7128    fn a_search_passes_over_what_it_cannot_read_and_says_so() {
7129        let (tmp, store) = setup();
7130        let dir = directory(&tmp, &store);
7131        let old = store.seal(&text("on the oldest shelf"), "").unwrap();
7132        let slot = seal_legacy_slot(&store, 0, &text("in a slot"), "");
7133        let mut all = vec![old.id.clone(), slot.id.clone()];
7134        // What the search still finds, and what it says it did not read.
7135        let searched = |all: &[String], limit: usize| {
7136            let (rows, unscanned) = store.search_scan("", limit).unwrap();
7137            let mut found: Vec<_> = rows.into_iter().map(|row| row.id).collect();
7138            found.sort();
7139            let mut all = all.to_vec();
7140            all.sort();
7141            if limit >= all.len() {
7142                assert_eq!(found, all);
7143            } else {
7144                assert_eq!(found.len(), limit);
7145            }
7146            match unscanned {
7147                Some(unscanned) => {
7148                    assert_eq!(unscanned.stopped_after, None);
7149                    unscanned.unread.unwrap()
7150                }
7151                None => String::new(),
7152            }
7153        };
7154        assert_eq!(searched(&all, 8), "");
7155
7156        // A shelf name that is a file. The seal after it takes the next number.
7157        std::fs::write(dir.join("s000002"), b"not a directory").unwrap();
7158        let unread = searched(&all, 8);
7159        assert!(unread.starts_with("1 place(s) could not be read, first s000002: "), "{unread}");
7160        let new = store.seal(&text("on the newest shelf"), "").unwrap();
7161        let newest = dir.join("s000003");
7162        assert!(newest.join(archive_name(&new.id)).is_file());
7163        all.push(new.id.clone());
7164        // The same with a link to a directory under that name.
7165        std::fs::remove_file(dir.join("s000002")).unwrap();
7166        symlink(&newest, dir.join("s000002")).unwrap();
7167        let unread = searched(&all, 8);
7168        assert!(unread.starts_with("1 place(s) could not be read, first s000002: "), "{unread}");
7169
7170        // Where a segment belongs: a link, a FIFO, a file with a second
7171        // name and a file that is not a record.
7172        let victim = tmp.path().join("victim");
7173        std::fs::write(&victim, b"do not change").unwrap();
7174        let odd = |letter: &str| newest.join(archive_name(&letter.repeat(32)));
7175        symlink(&victim, odd("a")).unwrap();
7176        let fifo = std::ffi::CString::new(odd("b").to_str().unwrap()).unwrap();
7177        assert_eq!(unsafe { libc::mkfifo(fifo.as_ptr(), 0o600) }, 0);
7178        std::fs::write(odd("c"), record_bytes(&store, &"c".repeat(32), &text("two names"), |_| {})).unwrap();
7179        std::fs::hard_link(odd("c"), tmp.path().join("a-second-name")).unwrap();
7180        std::fs::write(odd("d"), b"corrupt").unwrap();
7181        let unread = searched(&all, 8);
7182        assert!(unread.starts_with("5 place(s) could not be read, first s000003/"), "{unread}");
7183        // A legacy slot that is a link.
7184        symlink(&victim, dir.join("001.json")).unwrap();
7185        let unread = searched(&all, 8);
7186        assert!(unread.starts_with("6 place(s) could not be read, first s000003/"), "{unread}");
7187        // A shelf that cannot be opened for an error that will pass: the
7188        // search is a read, so the rest is still an answer.
7189        let rest = [old.id.clone(), slot.id.clone()];
7190        let failing = Fault::on("open", "s000003", libc::EMFILE);
7191        let unread = searched(&rest, 8);
7192        assert!(unread.starts_with("3 place(s) could not be read, first s000003: "), "{unread}");
7193        // A page that fills is still not all there was to read on the way.
7194        let unread = searched(&rest, 1);
7195        assert!(unread.starts_with("2 place(s) could not be read, first s000003: "), "{unread}");
7196        drop(failing);
7197        // And one whose mode refuses it.
7198        if unsafe { libc::geteuid() } != 0 {
7199            let _reopen = Reopen(vec![newest.clone()]);
7200            std::fs::set_permissions(&newest, std::fs::Permissions::from_mode(0o000)).unwrap();
7201            let unread = searched(&rest, 8);
7202            assert!(unread.starts_with("3 place(s) could not be read, first s000003: "), "{unread}");
7203        }
7204        // Nothing was followed, read into or changed.
7205        assert_eq!(std::fs::read(&victim).unwrap(), b"do not change");
7206        assert!(std::fs::symlink_metadata(odd("a")).unwrap().file_type().is_symlink());
7207        assert_eq!(std::fs::read(odd("d")).unwrap(), b"corrupt");
7208        // The plain search gives the same rows and no account of the rest.
7209        assert_eq!(store.search("", 8).unwrap().len(), 3);
7210    }
7211
7212    #[test]
7213    fn file_name_must_match_record_id() {
7214        let (tmp, store) = setup();
7215        let dir = directory(&tmp, &store);
7216        let a = store.seal(&text("the original"), "").unwrap();
7217        let b = "b".repeat(32);
7218        let copy = dir.join("s000001").join(archive_name(&b));
7219        std::fs::copy(only_archive_file(&dir), &copy).unwrap();
7220        for result in [
7221            store.fetch(&b, 0, 1).map(drop),
7222            store.fetch_note(&b).map(drop),
7223            store.forget(&b).map(drop),
7224            archive_file(tmp.path(), "synthetic-project", &b).map(drop),
7225        ] {
7226            assert_eq!(result.unwrap_err().kind(), io::ErrorKind::InvalidData);
7227        }
7228        let original = dir.join("s000001").join(archive_name(&a.id));
7229        assert_eq!(std::fs::read(&copy).unwrap(), std::fs::read(original).unwrap());
7230        // Search lists the original once and passes over the copy.
7231        let rows = store.search("", 10).unwrap();
7232        assert_eq!(rows.len(), 1);
7233        assert_eq!(rows[0].id, a.id);
7234        assert_eq!(store.fetch(&a.id, 0, 1).unwrap(), text("the original"));
7235    }
7236
7237    #[test]
7238    fn shelf_gap_is_tolerated() {
7239        let (tmp, store) = setup();
7240        let dir = directory(&tmp, &store);
7241        let seal = |index: usize| store.seal(&text(&format!("archive {index}")), "").unwrap().id;
7242        let ids: Vec<_> = (0..2 * SHELF_FILES + 1).map(seal).collect();
7243        assert_eq!(store.shelves().unwrap(), ["s000003", "s000002", "s000001"]);
7244        std::fs::remove_dir_all(dir.join("s000002")).unwrap();
7245        assert_eq!(store.fetch(&ids[256], 0, 1).unwrap(), text("archive 256"));
7246        assert_eq!(store.fetch(&ids[0], 0, 1).unwrap(), text("archive 0"));
7247        assert_eq!(
7248            store.fetch(&ids[200], 0, 1).unwrap_err().kind(),
7249            io::ErrorKind::NotFound
7250        );
7251        // The next seal goes to the newest shelf; the gap is not filled in.
7252        let next = seal(1_000);
7253        assert!(dir.join("s000003").join(archive_name(&next)).is_file());
7254        assert_eq!(store.shelves().unwrap(), ["s000003", "s000001"]);
7255        assert_eq!(store.search("", 1).unwrap().len(), 1);
7256    }
7257
7258    #[test]
7259    fn crash_leftovers() {
7260        let (tmp, store) = setup();
7261        let dir = directory(&tmp, &store);
7262        let first = store.seal(&text("before the crash"), "").unwrap();
7263        // A crash after mkdir and after writing the temporary file, before
7264        // the rename: an empty newer shelf, and in it a temporary file of
7265        // this version and one of the version before, both from long ago.
7266        let empty = dir.join("s000002");
7267        std::fs::create_dir(&empty).unwrap();
7268        let stale = [empty.join(temp_name()), empty.join("pending.tmp")];
7269        // Written a moment ago: perhaps by a write that is still running.
7270        // And one a minute short of the age at which no write can be.
7271        let fresh = [empty.join(temp_name()), empty.join(temp_name())];
7272        for path in stale.iter().chain(&fresh) {
7273            std::fs::write(path, b"unpublished partial").unwrap();
7274        }
7275        let minute = Duration::from_secs(60);
7276        written_ago(&stale[0], STALE_TEMP + minute);
7277        written_ago(&stale[1], STALE_TEMP * 24);
7278        written_ago(&fresh[1], STALE_TEMP - minute);
7279        // No reader sees any of them, before or after.
7280        assert_eq!(store.search("", 10).unwrap().len(), 1);
7281        assert_eq!(temporary_files(&dir).len(), 4);
7282        let next = store.seal(&text("after the crash"), "").unwrap();
7283        assert!(empty.join(archive_name(&next.id)).is_file());
7284        // The seal removed what no write can still be using, and only that.
7285        assert!(stale.iter().all(|path| !path.exists()));
7286        for path in &fresh {
7287            assert_eq!(std::fs::read(path).unwrap(), b"unpublished partial");
7288        }
7289        assert_eq!(temporary_files(&dir).len(), 2);
7290        assert_eq!(store.fetch(&first.id, 0, 1).unwrap(), text("before the crash"));
7291        assert_eq!(store.fetch(&next.id, 0, 1).unwrap(), text("after the crash"));
7292        assert_eq!(store.search("", 10).unwrap().len(), 2);
7293        // A forget writes beside them too, and leaves nothing of its own.
7294        store.forget(&next.id).unwrap();
7295        assert_eq!(temporary_files(&dir).len(), 2);
7296    }
7297
7298    /// The one temporary name every write used to share, taken by something
7299    /// no write made. Each used to stop every seal into that directory (a
7300    /// directory cannot be unlinked) or was removed without a look. Now no
7301    /// write uses the name, and the sweep removes plain files only.
7302    #[test]
7303    fn a_temporary_name_that_is_a_directory_a_fifo_or_a_link_is_left_alone() {
7304        use std::os::unix::fs::FileTypeExt;
7305        /// As `written_ago`, for a path that cannot be opened for writing;
7306        /// the time of a link itself, not of what it points to.
7307        fn aged(path: &Path) {
7308            let then = std::time::SystemTime::now() - STALE_TEMP * 2;
7309            let since = then.duration_since(std::time::UNIX_EPOCH).unwrap();
7310            let mut time: libc::timespec = unsafe { std::mem::zeroed() };
7311            time.tv_sec = since.as_secs() as libc::time_t;
7312            let name = std::ffi::CString::new(path.to_str().unwrap()).unwrap();
7313            let (times, flags) = ([time; 2], libc::AT_SYMLINK_NOFOLLOW);
7314            let set = unsafe { libc::utimensat(libc::AT_FDCWD, name.as_ptr(), times.as_ptr(), flags) };
7315            assert_eq!(set, 0);
7316        }
7317        let (tmp, store) = setup();
7318        let dir = directory(&tmp, &store);
7319        let first = store.seal(&text("first"), "").unwrap();
7320        let legacy = seal_legacy_slot(&store, 0, &text("legacy"), "");
7321        let victim = tmp.path().join("synthetic-victim");
7322        std::fs::write(&victim, "do not change").unwrap();
7323        written_ago(&victim, STALE_TEMP * 2);
7324        let unchanged = snapshot(tmp.path());
7325        // In the shelf a seal writes to, and in the project directory, where
7326        // a forget of a legacy slot writes.
7327        let places = [dir.join("s000001").join("pending.tmp"), dir.join("pending.tmp")];
7328        let kind = |path: &Path| std::fs::symlink_metadata(path).unwrap().file_type();
7329        for shape in ["directory", "FIFO", "symlink", "hard link"] {
7330            for path in &places {
7331                match shape {
7332                    "directory" => std::fs::create_dir(path).unwrap(),
7333                    "FIFO" => {
7334                        let name = std::ffi::CString::new(path.to_str().unwrap()).unwrap();
7335                        assert_eq!(unsafe { libc::mkfifo(name.as_ptr(), 0o600) }, 0);
7336                    }
7337                    "symlink" => symlink(&victim, path).unwrap(),
7338                    _ => std::fs::hard_link(&victim, path).unwrap(),
7339                }
7340                aged(path);
7341            }
7342            let words = format!("sealed beside a {shape}");
7343            let sealed = store.seal(&text(&words), "").unwrap();
7344            assert_eq!(store.fetch(&sealed.id, 0, 1).unwrap(), text(&words), "{shape}");
7345            assert_eq!(store.fetch(&first.id, 0, 1).unwrap(), text("first"), "{shape}");
7346            assert_eq!(store.search(&words, 2).unwrap().len(), 1, "{shape}");
7347            store.forget(&sealed.id).unwrap();
7348            // A legacy slot is tombstoned in the project directory itself.
7349            let old = seal_legacy_slot(&store, 1, &text(&words), "");
7350            store.forget(&old.id).unwrap();
7351            assert_eq!(store.fetch(&legacy.id, 0, 1).unwrap(), text("legacy"), "{shape}");
7352            std::fs::remove_file(dir.join("001.json")).unwrap();
7353            for path in &places {
7354                let found = kind(path);
7355                match shape {
7356                    "directory" => assert!(found.is_dir()),
7357                    "FIFO" => assert!(found.is_fifo()),
7358                    "symlink" => assert!(found.is_symlink()),
7359                    // A second name of a file that is not the store's alone
7360                    // is not a leftover of the store's either.
7361                    _ => assert_eq!(identity(path), identity(&victim)),
7362                }
7363                if found.is_dir() {
7364                    std::fs::remove_dir(path).unwrap();
7365                } else {
7366                    std::fs::remove_file(path).unwrap();
7367                }
7368            }
7369            assert_eq!(std::fs::read_to_string(&victim).unwrap(), "do not change");
7370        }
7371        // Nothing but the four tombstones was added, changed or removed.
7372        let after = snapshot(tmp.path());
7373        assert!(unchanged.iter().all(|(path, file)| after.get(path) == Some(file)));
7374        assert_eq!(after.len(), unchanged.len() + 4);
7375        assert!(temporary_files(&dir).is_empty());
7376    }
7377
7378    /// `create_new` is how a seal publishes. It never replaces what is under
7379    /// the name, whatever that is and even when the bytes would be the same.
7380    #[test]
7381    fn publish_create_new_never_replaces() {
7382        let (tmp, store) = setup();
7383        let shelf_path = directory(&tmp, &store).join("s000001");
7384        let shelf = store.fs.child("s000001", true).unwrap();
7385        let name = archive_name(&"a".repeat(32));
7386        let path = shelf_path.join(&name);
7387        let victim = tmp.path().join("synthetic-victim");
7388        std::fs::write(&victim, "do not change").unwrap();
7389        shelf.publish(&name, b"first", true).unwrap();
7390        let first = identity(&path);
7391        let refused = |bytes: &[u8]| {
7392            let error = shelf.publish(&name, bytes, true).unwrap_err();
7393            assert_eq!(error.kind(), io::ErrorKind::AlreadyExists);
7394            assert_eq!(std::fs::read_dir(&shelf_path).unwrap().count(), 1, "no temporary file");
7395        };
7396        for bytes in [&b"first"[..], b"second"] {
7397            refused(bytes);
7398            assert_eq!(std::fs::read(&path).unwrap(), b"first");
7399            assert_eq!(identity(&path), first, "replaced by a file with the same bytes");
7400        }
7401        // Nor a link (which is not followed), a directory or a FIFO.
7402        std::fs::remove_file(&path).unwrap();
7403        for target in [victim.clone(), tmp.path().join("missing")] {
7404            symlink(&target, &path).unwrap();
7405            let link = identity(&path);
7406            refused(b"through the link");
7407            assert_eq!(identity(&path), link);
7408            assert!(!tmp.path().join("missing").exists());
7409            std::fs::remove_file(&path).unwrap();
7410        }
7411        assert_eq!(std::fs::read_to_string(&victim).unwrap(), "do not change");
7412        std::fs::create_dir(&path).unwrap();
7413        refused(b"over the directory");
7414        assert!(path.is_dir());
7415        std::fs::remove_dir(&path).unwrap();
7416        // The two steps taken where the kernel or the filesystem has no
7417        // rename that cannot replace: the same answers, under the lock.
7418        let from = c_name(&temp_name()).unwrap();
7419        let to = c_name(&name).unwrap();
7420        let temporary = shelf_path.join(from.to_str().unwrap());
7421        std::fs::write(&temporary, b"probed").unwrap();
7422        let written = identity(&temporary);
7423        shelf.rename_probed(&from, &to).unwrap();
7424        assert_eq!((identity(&path), temporary.exists()), (written, false));
7425        std::fs::write(&temporary, b"probed again").unwrap();
7426        let error = shelf.rename_probed(&from, &to).unwrap_err();
7427        assert_eq!(error.kind(), io::ErrorKind::AlreadyExists);
7428        assert_eq!(identity(&path), written);
7429        assert_eq!(std::fs::read(&temporary).unwrap(), b"probed again");
7430        // Forget is the one caller that replaces, and it does.
7431        shelf.publish(&name, b"tombstone", false).unwrap();
7432        assert_ne!(identity(&path), written);
7433        assert_eq!(std::fs::read(&path).unwrap(), b"tombstone");
7434    }
7435
7436    #[test]
7437    fn archive_file_returns_path_and_creates_nothing() {
7438        let tmp = tempfile::tempdir().unwrap();
7439        let unknown = "a".repeat(32);
7440        for base in [tmp.path().join("missing"), tmp.path().to_path_buf()] {
7441            assert_eq!(archive_file(&base, "synthetic-project", &unknown).unwrap(), None);
7442        }
7443        assert_eq!(std::fs::read_dir(tmp.path()).unwrap().count(), 0);
7444        let store =
7445            ArchiveStore::new(tmp.path(), "synthetic-project", "synthetic-logical").unwrap();
7446        let dir = directory(&tmp, &store);
7447        let reference = store.seal(&text("where am I"), "").unwrap();
7448        let file = only_archive_file(&dir);
7449        let locate = |project: &str, id: &str| archive_file(tmp.path(), project, id);
7450        // No lock is taken or made, and a held one is not waited for.
7451        let held = store.fs.lock().unwrap();
7452        assert_eq!(locate("synthetic-project", &reference.id).unwrap(), Some(file.clone()));
7453        drop(held);
7454        std::fs::remove_file(dir.join("lock")).unwrap();
7455        let before = snapshot(tmp.path());
7456        assert_eq!(locate("synthetic-project", &reference.id).unwrap(), Some(file.clone()));
7457        assert_eq!(locate("synthetic-project", &unknown).unwrap(), None);
7458        assert_eq!(locate("another-project", &reference.id).unwrap(), None);
7459        assert_eq!(
7460            locate("synthetic-project", "../s000001/x").unwrap_err().kind(),
7461            io::ErrorKind::InvalidInput
7462        );
7463        assert_eq!(snapshot(tmp.path()), before);
7464        assert_eq!(std::fs::read_dir(tmp.path().join("context-archives")).unwrap().count(), 1);
7465        // A forgotten segment still has a file: its tombstone.
7466        store.forget(&reference.id).unwrap();
7467        assert_eq!(locate("synthetic-project", &reference.id).unwrap(), Some(file));
7468    }
7469
7470    /// What a window holds however it is cut: the projection of each source
7471    /// message by itself, under its index in the window.
7472    fn exact(store: &ArchiveStore, messages: &[SharedMessage]) -> Vec<ArchivedMessage> {
7473        let mut expected = Vec::new();
7474        for (index, message) in messages.iter().enumerate() {
7475            let one = std::slice::from_ref(message);
7476            let redactor = store.redactor.clone();
7477            let alone = project_hashed(&store.logical, one, "", redactor, Reduce::None).unwrap();
7478            expected.extend(alone.messages.into_iter().map(|mut archived| {
7479                archived.source_index = index;
7480                archived
7481            }));
7482        }
7483        expected
7484    }
7485    /// A sealed window of `len` messages read back and put together again
7486    /// from its part list alone: the parts are contiguous and cover it, and
7487    /// each part's messages follow under their index in the window.
7488    fn reassemble(
7489        store: &ArchiveStore,
7490        window: &SealedWindow,
7491        len: usize,
7492    ) -> Vec<ArchivedMessage> {
7493        let mut messages = Vec::new();
7494        let mut next = 0;
7495        for part in &window.parts {
7496            let ArchiveRef {
7497                id,
7498                message_count,
7499                source_message_count,
7500            } = &part.archive;
7501            assert_eq!(part.first_source_index, next);
7502            assert!(*source_message_count <= MAX_MESSAGES);
7503            next += source_message_count;
7504            let mut start = 0;
7505            while start < *message_count {
7506                let page = store
7507                    .fetch_with_provenance(id, start, MAX_FETCH_MESSAGES)
7508                    .unwrap();
7509                assert!(!page.is_empty());
7510                start += page.len();
7511                messages.extend(page.into_iter().map(|mut archived| {
7512                    assert!(archived.source_index < *source_message_count);
7513                    archived.source_index += part.first_source_index;
7514                    archived
7515                }));
7516            }
7517            assert_eq!(start, *message_count);
7518        }
7519        assert_eq!(next, len, "the parts cover the window");
7520        messages
7521    }
7522    /// `exact` for a window of plain text that is all eligible: each message
7523    /// is its own projection.
7524    fn assert_whole(read: &[ArchivedMessage], messages: &[SharedMessage]) {
7525        assert_eq!(read.len(), messages.len());
7526        for (index, archived) in read.iter().enumerate() {
7527            assert_eq!(archived.source_index, index);
7528            assert!(archived.message == messages[index], "message {index} differs");
7529        }
7530    }
7531    fn lines(len: usize) -> Vec<SharedMessage> {
7532        (0..len)
7533            .map(|index| msg("user", json!(format!("line {index} of {len}"))))
7534            .collect()
7535    }
7536    /// A tool input nested deeper than one segment admits.
7537    fn deep_input() -> Value {
7538        let mut deep = json!("leaf");
7539        for _ in 0..40 {
7540            deep = json!([deep]);
7541        }
7542        deep
7543    }
7544    const OVER_LIMIT: &str = "over the per-archive limit]";
7545
7546    #[test]
7547    fn window_within_caps_is_one_part_identical_to_seal() {
7548        let messages = vec![
7549            msg("system", json!("not eligible")),
7550            msg("user", json!("a question")),
7551            msg("assistant", json!([{"type":"text","text":"an answer"}])),
7552        ];
7553        let (strict_tmp, strict) = setup();
7554        let (tmp, store) = setup();
7555        let (reference, note) = strict.seal_with_note(&messages, "the note").unwrap();
7556        let window = store.seal_window(&messages, "the note").unwrap();
7557        let part = ArchivePart {
7558            archive: reference.clone(),
7559            first_source_index: 0,
7560            reduced_messages: 0,
7561        };
7562        let expected = SealedWindow {
7563            parts: vec![part],
7564            note,
7565        };
7566        assert_eq!(window, expected);
7567        // The same record, but for when each was written.
7568        let undated = |dir: PathBuf| {
7569            let mut record: Value = serde_json::from_slice(&std::fs::read(dir).unwrap()).unwrap();
7570            assert!(record["created_ms"].take().is_u64());
7571            record
7572        };
7573        assert_eq!(
7574            undated(only_archive_file(&directory(&tmp, &store))),
7575            undated(only_archive_file(&directory(&strict_tmp, &strict)))
7576        );
7577        // In one store the two calls find each other's segment.
7578        assert_eq!(strict.seal_window(&messages, "another note").unwrap(), window);
7579        assert_eq!(
7580            store.seal_with_note(&messages, "another note").unwrap(),
7581            (reference, "the note".to_owned())
7582        );
7583        // An empty window is one empty part, as it is one empty segment.
7584        let empty = store.seal_window(&[], "").unwrap();
7585        assert_eq!(empty.parts.len(), 1);
7586        assert_eq!(empty.parts[0].archive, store.seal(&[], "").unwrap());
7587    }
7588
7589    #[test]
7590    fn window_over_message_cap_splits() {
7591        let (tmp, store) = setup();
7592        for (len, parts) in [(MAX_MESSAGES + 1, 2), (10_000, 3)] {
7593            // Every seventh message is not eligible: source indices have gaps.
7594            let messages: Vec<_> = (0..len)
7595                .map(|index| {
7596                    let role = ["user", "assistant", "user", "system", "assistant"][index % 7 % 5];
7597                    msg(role, json!(format!("window of {len}, line {index}")))
7598                })
7599                .collect();
7600            assert!(store.seal(&messages, "").is_err());
7601            let window = store.seal_window(&messages, "").unwrap();
7602            assert_eq!(window.parts.len(), parts);
7603            assert!(window.parts.iter().all(|part| part.reduced_messages == 0));
7604            let read = reassemble(&store, &window, len);
7605            assert_eq!(read.len(), len - (len + 3) / 7);
7606            assert!(read == exact(&store, &messages));
7607            // Each part is full before the next is started.
7608            let starts: Vec<_> = window.parts.iter().map(|part| part.first_source_index).collect();
7609            let full: Vec<_> = (0..parts).map(|part| part * MAX_MESSAGES).collect();
7610            assert_eq!(starts, full);
7611        }
7612        assert_eq!(shelf_files(&directory(&tmp, &store)).len(), 5);
7613    }
7614
7615    #[test]
7616    fn window_over_byte_cap_splits() {
7617        let (_tmp, store) = setup();
7618        // 17 MiB of eligible text, in messages far below any other cap.
7619        let messages: Vec<_> = (0..34)
7620            .map(|index| msg("user", json!(format!("{index:02} {}", "x".repeat(512 * 1024)))))
7621            .collect();
7622        let error = store.seal(&messages, "").unwrap_err();
7623        assert!(error.to_string().contains("input byte budget"));
7624        let window = store.seal_window(&messages, "").unwrap();
7625        assert_eq!(window.parts.len(), 2);
7626        assert_whole(&reassemble(&store, &window, messages.len()), &messages);
7627    }
7628
7629    #[test]
7630    fn window_over_node_cap_splits() {
7631        let (_tmp, store) = setup();
7632        let store = store.with_redactor(Arc::new(str::to_owned));
7633        // 3,000 tool calls of 40 arguments: 48 visited nodes each, 144,000
7634        // in all, where one segment admits 65,536.
7635        let input: serde_json::Map<String, Value> = (0..40)
7636            .map(|key| (format!("k{key:02}"), json!(key)))
7637            .collect();
7638        let messages: Vec<_> = (0..3000)
7639            .map(|index| {
7640                let id = format!("call-{index}");
7641                msg(
7642                    "assistant",
7643                    json!([{"type":"tool_use","id":id,"name":"plot","input":input}]),
7644                )
7645            })
7646            .collect();
7647        let error = store.seal(&messages, "").unwrap_err();
7648        assert!(error.to_string().contains("structural budget"));
7649        let window = store.seal_window(&messages, "").unwrap();
7650        // 1,365 calls are 65,520 nodes; one more would be over.
7651        let starts: Vec<_> = window.parts.iter().map(|part| part.first_source_index).collect();
7652        assert_eq!(starts, [0, 1365, 2730]);
7653        assert!(window.parts.iter().all(|part| part.reduced_messages == 0));
7654        let read = reassemble(&store, &window, messages.len());
7655        assert_eq!(read[2999].message, messages[2999]);
7656        assert!(read == exact(&store, &messages));
7657    }
7658
7659    #[test]
7660    fn serialized_overflow_after_projection_splits() {
7661        let (_tmp, store) = setup();
7662        // 3 MiB of control characters passes the input budget, and is 18 MiB
7663        // once each is written as "\u0001".
7664        let messages: Vec<_> = (0..4)
7665            .map(|index| msg("user", json!(format!("{index}{}", "\u{1}".repeat(768 * 1024)))))
7666            .collect();
7667        let error = store.seal(&messages, "").unwrap_err();
7668        assert!(error.to_string().contains("serialization failed or exceeded budget"));
7669        let window = store.seal_window(&messages, "").unwrap();
7670        assert_eq!(window.parts.len(), 2);
7671        assert_whole(&reassemble(&store, &window, messages.len()), &messages);
7672    }
7673
7674    /// The depth and node limits are about the shape of a tool input. As one
7675    /// string it has none, so an input that is only too deep is all there.
7676    #[test]
7677    fn single_deep_input_is_stored_as_its_json_text_not_left_out() {
7678        let (_tmp, store) = setup();
7679        let store = store.with_redactor(Arc::new(str::to_owned));
7680        let messages = vec![
7681            msg("user", json!("please run both")),
7682            msg(
7683                "assistant",
7684                json!([
7685                    {"type":"text","text":"running both"},
7686                    {"type":"tool_use","id":"t1","name":"nest","input":deep_input()},
7687                    {"type":"tool_use","id":"t2","name":"read","input":{"path":"src/lib.rs"}}
7688                ]),
7689            ),
7690            msg(
7691                "user",
7692                json!([
7693                    {"type":"tool_result","tool_use_id":"t1","content":"nested"},
7694                    {"type":"tool_result","tool_use_id":"t2","content":"fn main() {}"}
7695                ]),
7696            ),
7697            msg("assistant", json!("both ran")),
7698        ];
7699        assert!(store.seal(&messages, "").is_err());
7700        let window = store.seal_window(&messages, "").unwrap();
7701        // The message that does not fit by itself is a part of its own, and
7702        // the only one that is not exact.
7703        let reduced: Vec<_> = window
7704            .parts
7705            .iter()
7706            .map(|part| (part.first_source_index, part.reduced_messages))
7707            .collect();
7708        assert_eq!(reduced, [(0, 0), (1, 1), (2, 0)]);
7709        let read = reassemble(&store, &window, messages.len());
7710        for index in [0, 2, 3] {
7711            assert_eq!(read[index].source_index, index);
7712            assert_eq!(read[index].message, messages[index]);
7713        }
7714        // Of that message only the one input that is too deep is not stored
7715        // as it was, and nothing of it is missing: it is one string. The
7716        // text and the other call's input are exact.
7717        let as_text = serde_json::to_string(&deep_input()).unwrap();
7718        let expected = json!({
7719            "role":"assistant",
7720            "_archive_reduced":"this message alone is over the per-archive limit; it is stored \
7721                with 1 tool input(s) as JSON text, each marked in place",
7722            "content":[
7723                {"type":"text","text":"running both"},
7724                {"type":"tool_use","id":"t1","name":"nest","input":{"_archive_json":as_text}},
7725                {"type":"tool_use","id":"t2","name":"read","input":{"path":"src/lib.rs"}}
7726            ]
7727        });
7728        assert_eq!(*read[1].message, expected);
7729        assert_eq!(read[1].block_indices, [0, 1, 2]);
7730        // What was stored is the input: it reads back equal.
7731        let stored = read[1].message["content"][1]["input"][INPUT_JSON_KEY].as_str().unwrap();
7732        assert_eq!(serde_json::from_str::<Value>(stored).unwrap(), deep_input());
7733        // However it is read back it says so first: the key sorts before
7734        // `content` and `role`.
7735        let found = store.search("running both", 1).unwrap();
7736        assert!(found[0].snippet.starts_with("{\"_archive_reduced\":\"this message alone"));
7737    }
7738
7739    /// One message with three tool calls, sealed alone and read back.
7740    fn sealed_calls(store: &ArchiveStore, text: &str, inputs: [&Value; 3]) -> SharedMessage {
7741        let mut blocks = vec![json!({"type":"text","text":text})];
7742        for (index, input) in inputs.into_iter().enumerate() {
7743            let id = format!("t{}", index + 1);
7744            blocks.push(json!({"type":"tool_use","id":id,"name":"plot","input":input}));
7745        }
7746        let window = store.seal_window(&[msg("assistant", json!(blocks))], "").unwrap();
7747        assert_eq!(window.parts.len(), 1);
7748        assert_eq!(window.parts[0].reduced_messages, 1);
7749        store.fetch(&window.parts[0].archive.id, 0, 1).unwrap().remove(0)
7750    }
7751
7752    #[test]
7753    fn inputs_with_too_many_nodes_together_are_all_stored_as_text() {
7754        let (_tmp, store) = setup();
7755        let store = store.with_redactor(Arc::new(str::to_owned));
7756        // 30,001 visited nodes each: every input fits by itself, the three
7757        // together do not. As three strings they are three nodes.
7758        let wide = json!(vec![0; 30_000]);
7759        let long = "exact words ".repeat(1000);
7760        let stored = sealed_calls(&store, &long, [&wide; 3]);
7761        assert_eq!(stored["content"][0]["text"], long);
7762        let as_text = serde_json::to_string(&wide).unwrap();
7763        for block in 1..=3 {
7764            assert_eq!(stored["content"][block]["input"], json!({"_archive_json":as_text}));
7765        }
7766        assert_eq!(
7767            stored[REDUCED_KEY],
7768            "this message alone is over the per-archive limit; it is stored with 3 tool input(s) \
7769             as JSON text, each marked in place"
7770        );
7771    }
7772
7773    #[test]
7774    fn inputs_too_large_together_are_all_omitted_and_text_stays_exact() {
7775        let (_tmp, store) = setup();
7776        let store = store.with_redactor(Arc::new(str::to_owned));
7777        // 6 MiB each: every input fits by itself, the three together are
7778        // over the byte budget, as values and as text alike.
7779        let large = json!("x".repeat(6 * 1024 * 1024));
7780        let long = "exact words ".repeat(1000);
7781        let stored = sealed_calls(&store, &long, [&large; 3]);
7782        assert_eq!(stored["content"][0]["text"], long);
7783        let bytes = serde_json::to_string(&large).unwrap().len();
7784        let omitted = format!("[archive: tool input of {bytes} bytes omitted, {OVER_LIMIT}");
7785        for block in 1..=3 {
7786            assert_eq!(stored["content"][block]["input"], json!({"_archive_omitted":omitted}));
7787        }
7788        assert!(stored[REDUCED_KEY].as_str().unwrap().ends_with("with 3 omission(s), each marked in place"));
7789    }
7790
7791    /// Text is tried for each input by itself. One that is over the byte
7792    /// budget as text too is left out; one the screen would not store is
7793    /// withheld whole, as it would be if it were stored exact.
7794    #[test]
7795    fn an_input_over_the_byte_budget_is_left_out_and_a_withheld_one_stays_withheld() {
7796        let (_tmp, store) = setup();
7797        let store = store.with_redactor(Arc::new(str::to_owned));
7798        let nested = |leaf: Value| (0..40).fold(leaf, |deep, _| json!([deep]));
7799        // Too deep and too large; too deep around a word the screen
7800        // withholds; too deep around an object marked as not to be stored.
7801        let large = nested(json!("x".repeat(MAX_INPUT_BYTES + 1)));
7802        let credential = nested(json!("the password is example-only"));
7803        let private = nested(json!({"private":true,"value":"kept out"}));
7804        let stored = sealed_calls(&store, "three calls", [&large, &credential, &private]);
7805        let bytes = serde_json::to_string(&large).unwrap().len();
7806        let omitted = format!("[archive: tool input of {bytes} bytes omitted, {OVER_LIMIT}");
7807        assert_eq!(stored["content"][1]["input"], json!({"_archive_omitted":omitted}));
7808        for block in [2, 3] {
7809            assert_eq!(stored["content"][block]["input"], json!({"_archive_withheld":true}));
7810        }
7811        assert!(stored[REDUCED_KEY].as_str().unwrap().ends_with("with 1 omission(s), each marked in place"));
7812        // And both in one message: one input left out, one as text.
7813        let deep = deep_input();
7814        let small = json!({"path":"src/lib.rs"});
7815        let stored = sealed_calls(&store, "three calls", [&large, &deep, &small]);
7816        assert_eq!(stored["content"][1]["input"], json!({"_archive_omitted":omitted}));
7817        let as_text = serde_json::to_string(&deep).unwrap();
7818        assert_eq!(stored["content"][2]["input"], json!({"_archive_json":as_text}));
7819        assert_eq!(stored["content"][3]["input"], small);
7820        assert_eq!(
7821            stored[REDUCED_KEY],
7822            "this message alone is over the per-archive limit; it is stored with 1 omission(s) \
7823             and 1 tool input(s) as JSON text, each marked in place"
7824        );
7825    }
7826
7827    #[test]
7828    fn single_huge_message_is_reduced_with_markers() {
7829        let (_tmp, store) = setup();
7830        // One byte over the input cap, with a two-byte character across the
7831        // 4,096th byte.
7832        let huge = format!("a{}", "é".repeat(MAX_INPUT_BYTES / 2));
7833        assert_eq!(huge.len(), MAX_INPUT_BYTES + 1);
7834        let messages = vec![
7835            msg("user", json!("before")),
7836            msg("user", json!(huge)),
7837            msg("assistant", json!("after")),
7838        ];
7839        assert!(store.seal(&messages, "").is_err());
7840        let window = store.seal_window(&messages, "").unwrap();
7841        let reduced: Vec<_> = window.parts.iter().map(|part| part.reduced_messages).collect();
7842        assert_eq!(reduced, [0, 1, 0]);
7843        let read = reassemble(&store, &window, messages.len());
7844        assert_eq!(read[0].message, messages[0]);
7845        assert_eq!(read[2].message, messages[2]);
7846        let kept = &huge[..4095];
7847        let omitted = huge.len() - 4095;
7848        let stored = read[1].message["content"].as_str().unwrap();
7849        assert_eq!(stored, format!("{kept}\n[archive: {omitted} bytes omitted, {OVER_LIMIT}"));
7850        assert!(read[1].message[REDUCED_KEY].as_str().unwrap().contains("with 1 omission(s)"));
7851    }
7852
7853    #[test]
7854    fn reduced_wide_message_keeps_its_first_blocks_and_counts_the_rest() {
7855        let (_tmp, store) = setup();
7856        let store = store.with_redactor(Arc::new(str::to_owned));
7857        // 70,000 small blocks are more nodes than one segment admits, with
7858        // or without tool inputs.
7859        let mut blocks = vec![json!({"type":"tool_result","tool_use_id":"t1","content":[
7860            {"type":"text","text":"x".repeat(5000)},
7861            {"type":"text","text":"second"},
7862            {"type":"text","text":"third"}
7863        ]})];
7864        let small = |index: usize| json!({"type":"text","text":format!("block {index}")});
7865        blocks.extend((1..70_000).map(small));
7866        let window = store.seal_window(&[msg("user", json!(blocks))], "").unwrap();
7867        assert_eq!(window.parts.len(), 1);
7868        assert_eq!(window.parts[0].reduced_messages, 1);
7869        let id = &window.parts[0].archive.id;
7870        let fetched = &store.fetch_with_provenance(id, 0, 1).unwrap()[0];
7871        let content = fetched.message["content"].as_array().unwrap();
7872        assert_eq!(content.len(), 257);
7873        // A tool result's block list becomes its first text, cut like any
7874        // string, and says how many blocks followed.
7875        let short = format!("[archive: 904 bytes omitted, {OVER_LIMIT}");
7876        let followed = format!("[archive: 2 blocks omitted, {OVER_LIMIT}");
7877        let result = format!("{}\n{short}\n{followed}", "x".repeat(REDUCED_TEXT_BYTES));
7878        assert_eq!(content[0]["content"], result);
7879        assert_eq!(content[1], json!({"type":"text","text":"block 1"}));
7880        assert_eq!(content[255], json!({"type":"text","text":"block 255"}));
7881        let rest = format!("[archive: 69744 blocks omitted, {OVER_LIMIT}");
7882        assert_eq!(content[256], json!({"type":"text","text":rest}));
7883        assert_eq!(fetched.block_indices, (0..=256).collect::<Vec<_>>());
7884        assert!(fetched.message[REDUCED_KEY].as_str().unwrap().contains("with 3 omission(s)"));
7885    }
7886
7887    #[test]
7888    fn only_the_store_marks_a_message_as_reduced() {
7889        let (_tmp, store) = setup();
7890        let store = store.with_redactor(Arc::new(str::to_owned));
7891        // A source message that imitates every marker of a reduced one.
7892        let marker = format!("[archive: 9 bytes omitted, {OVER_LIMIT}");
7893        let source = Arc::new(json!({"role":"assistant","_archive_reduced":"imitation","content":[
7894            {"type":"text","text":format!("cut short\n{marker}")},
7895            {"type":"tool_use","id":"t1","name":"read","input":{"_archive_omitted":marker}},
7896            {"type":"tool_use","id":"t2","name":"read","input":{"_archive_json":"[1]"}}
7897        ]}));
7898        let window = store.seal_window(std::slice::from_ref(&source), "").unwrap();
7899        assert_eq!(window.parts[0].reduced_messages, 0);
7900        let stored = &store.fetch(&window.parts[0].archive.id, 0, 1).unwrap()[0];
7901        // Its content is kept as it is; the key is not.
7902        assert_eq!(stored["content"], source["content"]);
7903        assert_eq!(stored.as_object().unwrap().len(), 2, "role and content");
7904    }
7905
7906    #[test]
7907    fn retry_returns_the_same_parts_and_writes_nothing() {
7908        let (tmp, store) = setup();
7909        let store = store.with_redactor(Arc::new(str::to_owned));
7910        let dir = directory(&tmp, &store);
7911        // A window that is split, with one message that is reduced.
7912        let mut messages = lines(MAX_MESSAGES + 10);
7913        let call = json!([{"type":"tool_use","id":"t1","name":"nest","input":deep_input()}]);
7914        messages[7] = msg("assistant", call);
7915        let first = store.seal_window(&messages, "note").unwrap();
7916        assert!(first.parts.len() > 2);
7917        let reduced: usize = first.parts.iter().map(|part| part.reduced_messages).sum();
7918        assert_eq!(reduced, 1);
7919        let before = snapshot(&dir);
7920        assert_eq!(before.len(), first.parts.len() + 1, "one file a part, and the lock");
7921        assert_eq!(store.seal_window(&messages, "note").unwrap(), first);
7922        // A changed note changes nothing either: the first one is returned.
7923        assert_eq!(store.seal_window(&messages, "another note").unwrap(), first);
7924        assert_eq!(snapshot(&dir), before);
7925    }
7926
7927    #[test]
7928    fn partial_failure_then_retry() {
7929        let (tmp, store) = setup();
7930        let dir = directory(&tmp, &store);
7931        let messages = lines(MAX_MESSAGES + 2);
7932        let (left, right) = messages.split_at(MAX_MESSAGES);
7933        let digest = |part: &[SharedMessage]| {
7934            let projection = project_hashed(&store.logical, part, "", None, Reduce::None);
7935            projection.unwrap().digest
7936        };
7937        let id = |part: &[SharedMessage]| digest(part)[..32].to_owned();
7938        // Something that is not a file sits where the second part belongs,
7939        // under the one id and under the other: no name is left for it.
7940        let shelf = dir.join("s000001");
7941        std::fs::create_dir(&shelf).unwrap();
7942        let obstacle = shelf.join(archive_name(&id(right)));
7943        let other = shelf.join(archive_name(&digest(right)[32..]));
7944        std::fs::create_dir(&obstacle).unwrap();
7945        std::fs::create_dir(&other).unwrap();
7946        let error = store.seal_window(&messages, "note").unwrap_err();
7947        assert!(!over_budget(&error));
7948        std::fs::remove_dir(&other).unwrap();
7949        // The first part is on disk, an ordinary segment.
7950        let first = shelf.join(archive_name(&id(left)));
7951        let bytes = std::fs::read(&first).unwrap();
7952        assert_eq!(store.fetch(&id(left), 0, 1).unwrap(), messages[..1]);
7953        std::fs::remove_dir(&obstacle).unwrap();
7954        let window = store.seal_window(&messages, "note").unwrap();
7955        let ids: Vec<_> = window.parts.iter().map(|part| part.archive.id.clone()).collect();
7956        assert_eq!(ids, [id(left), id(right)]);
7957        assert_eq!(std::fs::read(&first).unwrap(), bytes);
7958        assert_eq!(window.note, "note");
7959        assert!(reassemble(&store, &window, messages.len()) == exact(&store, &messages));
7960    }
7961
7962    #[test]
7963    fn note_is_stored_on_the_last_part_only() {
7964        let (_tmp, store) = setup();
7965        let messages = lines(2 * MAX_MESSAGES + 1);
7966        let window = store.seal_window(&messages, "the working note").unwrap();
7967        assert_eq!(window.note, "the working note");
7968        let notes: Vec<_> = window
7969            .parts
7970            .iter()
7971            .map(|part| store.fetch_note(&part.archive.id).unwrap())
7972            .collect();
7973        assert_eq!(notes, ["", "", "the working note"]);
7974        // Sealed again, the window has the note it was first sealed with.
7975        let again = store.seal_window(&messages, "a later note").unwrap();
7976        assert_eq!(again.note, "the working note");
7977    }
7978
7979    #[test]
7980    fn identical_halves_share_one_id() {
7981        let (tmp, store) = setup();
7982        let messages = vec![msg("user", json!("again")); 2 * MAX_MESSAGES];
7983        let window = store.seal_window(&messages, "the working note").unwrap();
7984        assert_eq!(window.parts.len(), 2);
7985        assert_eq!(window.parts[0].archive, window.parts[1].archive);
7986        assert_eq!(window.parts[0].first_source_index, 0);
7987        assert_eq!(window.parts[1].first_source_index, MAX_MESSAGES);
7988        assert_eq!(shelf_files(&directory(&tmp, &store)).len(), 1);
7989        assert!(reassemble(&store, &window, messages.len()) == exact(&store, &messages));
7990        // The one segment was stored for the first part, which has no note.
7991        // The window's note is not lost with it.
7992        assert_eq!(store.fetch_note(&window.parts[1].archive.id).unwrap(), "");
7993        assert_eq!(window.note, "the working note");
7994    }
7995
7996    #[test]
7997    fn oversized_note_is_not_treated_as_splittable() {
7998        let (tmp, store) = setup();
7999        let window = lines(MAX_MESSAGES + 1);
8000        let note = "n".repeat(9 * 1024);
8001        for messages in [&window[..1], &window[..]] {
8002            let error = store.seal_window(messages, &note).unwrap_err();
8003            assert_eq!(error.to_string(), "archive message/note budget exceeded");
8004            assert!(!over_budget(&error));
8005        }
8006        // So is a note that only the redactor makes too long.
8007        let tripling = ArchiveStore::new(tmp.path(), "synthetic-project", "synthetic-logical")
8008            .unwrap()
8009            .with_redactor(Arc::new(|text: &str| text.repeat(3)));
8010        let error = tripling.seal_window(&window, &"n".repeat(4096)).unwrap_err();
8011        assert_eq!(error.to_string(), "redacted note exceeds archive budget");
8012        assert!(shelf_files(&directory(&tmp, &store)).is_empty());
8013    }
8014
8015    #[test]
8016    fn strict_seal_still_refuses() {
8017        let (tmp, store) = setup();
8018        let messages = lines(MAX_MESSAGES + 1);
8019        for error in [
8020            store.seal(&messages, "").unwrap_err(),
8021            store.seal_with_note(&messages, "").unwrap_err(),
8022            project_archive("synthetic-logical", &messages, "", None).err().unwrap(),
8023        ] {
8024            assert_eq!(error.kind(), io::ErrorKind::InvalidInput);
8025            assert_eq!(error.to_string(), "archive message/note budget exceeded");
8026            assert!(over_budget(&error));
8027        }
8028        assert!(shelf_files(&directory(&tmp, &store)).is_empty());
8029        assert_eq!(store.seal_window(&messages, "").unwrap().parts.len(), 2);
8030    }
8031
8032    #[test]
8033    fn a_part_does_not_end_between_a_call_and_its_results_where_it_can() {
8034        let call = msg("assistant", json!([{"type":"tool_use","id":"t","name":"read","input":{}}]));
8035        let result = msg("user", json!([{"type":"tool_result","tool_use_id":"t","content":"ok"}]));
8036        let words = msg("user", json!("words"));
8037        let window = [words.clone(), call.clone(), result.clone(), words.clone()];
8038        assert_eq!(before_results(&window, 0, 2), 1, "moved back to before the call");
8039        assert_eq!(before_results(&window, 0, 3), 3, "the next part starts with words");
8040        assert_eq!(before_results(&window, 1, 2), 2, "it cannot: the part would be empty");
8041        assert_eq!(before_results(&window, 0, 4), 4, "the end of the window");
8042        let results = [words.clone(), call.clone(), result.clone(), result.clone(), words.clone()];
8043        assert_eq!(before_results(&results, 0, 3), 1, "back over every result of the call");
8044        assert_eq!(before_results(&results, 1, 3), 3, "nothing but results back to the start");
8045
8046        // Through a seal: the message limit falls between a call and its
8047        // result, and the part ends one message earlier.
8048        let (_tmp, store) = setup();
8049        let mut messages = numbered(0..MAX_MESSAGES + 5);
8050        messages[MAX_MESSAGES - 1] = call;
8051        messages[MAX_MESSAGES] = result;
8052        let window = store.seal_window(&messages, "").unwrap();
8053        let starts: Vec<_> = window.parts.iter().map(|part| part.first_source_index).collect();
8054        assert_eq!(starts, [0, MAX_MESSAGES - 1]);
8055    }
8056
8057    /// Plain messages whose text does not say how many there are.
8058    fn numbered(range: std::ops::Range<usize>) -> Vec<SharedMessage> {
8059        range.map(|index| msg("user", json!(format!("message {index}")))).collect()
8060    }
8061    fn starts_and_ids(window: &SealedWindow) -> (Vec<usize>, Vec<String>) {
8062        let parts = window.parts.iter();
8063        parts.map(|part| (part.first_source_index, part.archive.id.clone())).unzip()
8064    }
8065
8066    /// A part ends where the next message would not fit, so what is added
8067    /// to the end of a window changes its last part only. A retry after a
8068    /// failed turn finds one more message in the window: it writes one file.
8069    /// Cut at the midpoint of its length, every part was a new one.
8070    #[test]
8071    fn parts_do_not_change_when_the_window_grows() {
8072        let (tmp, store) = setup();
8073        let dir = directory(&tmp, &store);
8074        let mut messages = numbered(0..2 * MAX_MESSAGES + 10);
8075        let (starts, first) = starts_and_ids(&store.seal_window(&messages, "note").unwrap());
8076        assert_eq!(starts, [0, MAX_MESSAGES, 2 * MAX_MESSAGES]);
8077        assert_eq!(shelf_files(&dir).len(), 3);
8078
8079        // One more message, as after a failed turn.
8080        messages.extend(numbered(messages.len()..messages.len() + 1));
8081        let (starts, second) = starts_and_ids(&store.seal_window(&messages, "note").unwrap());
8082        assert_eq!(starts, [0, MAX_MESSAGES, 2 * MAX_MESSAGES]);
8083        assert_eq!(second[..2], first[..2]);
8084        assert_ne!(second[2], first[2]);
8085        assert_eq!(shelf_files(&dir).len(), 4, "one file more: the new last part");
8086
8087        // Enough more to fill the last part and start another.
8088        messages.extend(numbered(messages.len()..3 * MAX_MESSAGES + 5));
8089        let window = store.seal_window(&messages, "note").unwrap();
8090        let (starts, third) = starts_and_ids(&window);
8091        assert_eq!(starts, [0, MAX_MESSAGES, 2 * MAX_MESSAGES, 3 * MAX_MESSAGES]);
8092        assert_eq!(third[..2], first[..2]);
8093        assert_eq!(shelf_files(&dir).len(), 6, "the part that is full now, and the last");
8094        assert_whole(&reassemble(&store, &window, messages.len()), &messages);
8095        assert_eq!(window.note, "note");
8096    }
8097
8098    /// The same when a part was cut because of its size once written, which
8099    /// the fill cannot know: it is cut to a number of messages that does not
8100    /// depend on how many follow.
8101    #[test]
8102    fn a_part_cut_for_its_written_size_does_not_change_when_the_window_grows() {
8103        let (_tmp, store) = setup();
8104        // 768 KiB of control characters is 4.5 MiB once each is "\u0001":
8105        // three such messages fit one segment, four do not.
8106        let large = |index: usize| msg("user", json!(format!("{index}{}", "\u{1}".repeat(768 * 1024))));
8107        let mut cuts = Vec::new();
8108        // Halved, four were cut after two and six after three.
8109        for len in [4, 6] {
8110            let messages: Vec<_> = (0..len).map(large).collect();
8111            let window = store.seal_window(&messages, "").unwrap();
8112            let (starts, ids) = starts_and_ids(&window);
8113            assert_eq!(starts[..2], [0, 2], "{len} messages");
8114            cuts.push(ids[0].clone());
8115        }
8116        cuts.dedup();
8117        assert_eq!(cuts.len(), 1, "the first part is the same segment every time");
8118    }
8119
8120    /// A message that is over a limit by itself costs the window two parts:
8121    /// the one that ends before it and itself. Halving cost one part for
8122    /// each time the range around it was halved.
8123    #[test]
8124    fn an_oversized_message_costs_two_parts_more() {
8125        let (_tmp, store) = setup();
8126        let store = store.with_redactor(Arc::new(str::to_owned));
8127        let deep = || msg("assistant", json!([{"type":"tool_use","id":"t1","name":"nest","input":deep_input()}]));
8128        let cut = |len: usize, oversized: &[usize]| {
8129            let mut messages = numbered(0..len);
8130            for &index in oversized {
8131                messages[index] = deep();
8132            }
8133            let window = store.seal_window(&messages, "").unwrap();
8134            let parts = window.parts.iter();
8135            parts.map(|part| (part.first_source_index, part.reduced_messages)).collect::<Vec<_>>()
8136        };
8137        // One at index 7 of 4,106: thirteen parts when the window was halved.
8138        let after = 8 + MAX_MESSAGES;
8139        assert_eq!(cut(MAX_MESSAGES + 10, &[7]), [(0, 0), (7, 1), (8, 0), (after, 0)]);
8140        // Three, evenly, in 4,000: about 34 then.
8141        let seven = [(0, 0), (1000, 1), (1001, 0), (2000, 1), (2001, 0), (3000, 1), (3001, 0)];
8142        assert_eq!(cut(4000, &[1000, 2000, 3000]), seven);
8143        // First, last and next to each other: no part is empty.
8144        assert_eq!(cut(4, &[0, 1, 3]), [(0, 1), (1, 1), (2, 0), (3, 1)]);
8145    }
8146
8147    fn now_ms() -> u64 {
8148        let since = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH);
8149        since.unwrap().as_millis() as u64
8150    }
8151    fn json_of(path: &Path) -> Value {
8152        serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap()
8153    }
8154    fn top_level(record: &Value) -> Vec<&str> {
8155        record.as_object().unwrap().keys().map(String::as_str).collect()
8156    }
8157
8158    /// A seal writes when it wrote. The time is not part of the digest, so
8159    /// the same content sealed later is found, and what is stored keeps the
8160    /// time it was first written with.
8161    #[test]
8162    fn a_seal_records_the_time_of_the_write_and_a_later_seal_leaves_it() {
8163        let (tmp, store) = setup();
8164        let dir = directory(&tmp, &store);
8165        let messages = text("sealed once");
8166        let before = now_ms();
8167        let sealed = store.seal(&messages, "note").unwrap();
8168        let after = now_ms();
8169        let file = only_archive_file(&dir);
8170        let record = json_of(&file);
8171        // serde_json keeps the keys of a map sorted.
8172        let fields = ["body", "created_ms", "digest", "id", "logical", "scope", "version"];
8173        assert_eq!(top_level(&record), fields);
8174        let created = record["created_ms"].as_u64().unwrap();
8175        assert!((before..=after).contains(&created), "{before} <= {created} <= {after}");
8176        // In the file it follows the digest and comes before the body.
8177        let raw = std::fs::read_to_string(&file).unwrap();
8178        let digest = record["digest"].as_str().unwrap();
8179        let expected = format!("\"digest\":\"{digest}\",\"created_ms\":{created},\"body\":{{");
8180        assert!(raw.contains(&expected));
8181
8182        // The digest is what it was before the field existed: the content's.
8183        let projection = project_archive("synthetic-logical", &messages, "note", None).unwrap();
8184        assert_eq!(record["digest"], projection.digest.as_str());
8185        assert_eq!(sealed.id, projection.digest[..32]);
8186
8187        // Sealed again, later: the same segment, and not a byte of it changed.
8188        let snapshot_before = snapshot(&dir);
8189        std::thread::sleep(Duration::from_millis(5));
8190        assert_eq!(store.seal(&messages, "another note").unwrap(), sealed);
8191        assert_eq!(store.seal_window(&messages, "").unwrap().parts[0].archive, sealed);
8192        assert_eq!(snapshot(&dir), snapshot_before);
8193        assert_eq!(json_of(&file)["created_ms"], created);
8194    }
8195
8196    /// `seal_projection` does not trust the projection it is handed: one of
8197    /// another chat, or with a digest that is not its content's, is refused,
8198    /// no digest panics, and a refusal writes nothing.
8199    #[test]
8200    fn a_projection_of_another_chat_or_with_a_wrong_digest_is_refused_and_nothing_is_written() {
8201        let tmp = tempfile::tempdir().unwrap();
8202        // Every path under the base, directories too, and every file's bytes.
8203        let state = || {
8204            let mut paths = Vec::new();
8205            let mut pending = vec![tmp.path().to_path_buf()];
8206            while let Some(directory) = pending.pop() {
8207                for entry in std::fs::read_dir(&directory).unwrap() {
8208                    let path = entry.unwrap().path();
8209                    if std::fs::symlink_metadata(&path).unwrap().is_dir() {
8210                        pending.push(path.clone());
8211                    }
8212                    paths.push(path);
8213                }
8214            }
8215            paths.sort();
8216            (paths, snapshot(tmp.path()))
8217        };
8218        let store = setup_in(tmp.path(), "synthetic-project");
8219        let words = "handed to the store";
8220        let project = |logical: &str, words: &str| {
8221            project_hashed(logical, &text(words), "", None, Reduce::None).unwrap()
8222        };
8223        let good = || project(&store.logical, words);
8224        let with_digest = |digest: &str| ArchiveProjection {
8225            digest: digest.to_owned(),
8226            ..good()
8227        };
8228        let other_chat = digest_parts(&[b"another-chat"]);
8229        assert_ne!(other_chat, store.logical);
8230        let half_way = format!("{}é{}", "a".repeat(31), "a".repeat(31));
8231        assert!(half_way.len() == 64 && !half_way.is_char_boundary(32));
8232        let refused = [
8233            // Whole and consistent, of another chat.
8234            ("another chat", project(&other_chat, words)),
8235            // This chat's name on another chat's content and digest.
8236            ("renamed", ArchiveProjection {
8237                logical_id: store.logical.clone(),
8238                ..project(&other_chat, words)
8239            }),
8240            ("another digest", with_digest(&project(&store.logical, "other words").digest)),
8241            ("other messages", ArchiveProjection {
8242                messages: project(&store.logical, "other words").messages,
8243                ..good()
8244            }),
8245            ("another count", ArchiveProjection {
8246                source_message_count: 2,
8247                ..good()
8248            }),
8249            ("upper case", with_digest(&good().digest.to_uppercase())),
8250            ("ten characters", with_digest("0123456789")),
8251            ("empty", with_digest("")),
8252            ("not hex", with_digest(&"z".repeat(64))),
8253            ("not ASCII", with_digest(&"é".repeat(32))),
8254            ("not ASCII, cut at the half", with_digest(&half_way)),
8255        ];
8256        // On a store that has written nothing yet, then on one that has.
8257        for sealed_before in [false, true] {
8258            if sealed_before {
8259                store.seal(&text("a shelf to begin with"), "").unwrap();
8260            }
8261            let before = state();
8262            for (what, projection) in &refused {
8263                for written in [None, Some(Written { created_ms: Some(1), modified: None })] {
8264                    let error = store.seal_projection(projection.clone(), written).unwrap_err();
8265                    assert_eq!(error.kind(), io::ErrorKind::InvalidInput, "{what}: {error}");
8266                    assert_eq!(state(), before, "{what}");
8267                }
8268            }
8269        }
8270        // The projection as it was made is stored, as a seal stores it.
8271        let (sealed, _) = store.seal_projection(good(), None).unwrap();
8272        assert_eq!(sealed.id, good().digest[..32]);
8273        assert_eq!(store.fetch(&sealed.id, 0, 1).unwrap(), text(words));
8274        assert_eq!(store.seal(&text(words), "").unwrap(), sealed);
8275    }
8276
8277    /// What an import gives is what is stored: the record's `created_ms` and
8278    /// the time of its file, set before the file has its name. No directory
8279    /// gets that time, and content that is already stored is not touched.
8280    #[test]
8281    fn a_supplied_created_ms_and_file_time_are_stored_as_given() {
8282        let (tmp, store) = setup();
8283        let dir = directory(&tmp, &store);
8284        let project = |words: &str| {
8285            project_hashed(&store.logical, &text(words), "", None, Reduce::None).unwrap()
8286        };
8287        // 9 September 2001, and a file time that is not the same instant.
8288        let created_ms = 1_000_000_000_123;
8289        let modified = std::time::UNIX_EPOCH + Duration::from_secs(1_000_000_500);
8290        let given = Written {
8291            created_ms: Some(created_ms),
8292            modified: Some(modified),
8293        };
8294        let started = std::time::SystemTime::now() - Duration::from_secs(5);
8295        let (sealed, _) = store.seal_projection(project("from another store"), Some(given)).unwrap();
8296        let file = dir.join("s000001").join(archive_name(&sealed.id));
8297        // Before the file is read here, which may move its time of access.
8298        let metadata = std::fs::metadata(&file).unwrap();
8299        assert_eq!(metadata.modified().unwrap(), modified);
8300        assert_eq!(metadata.accessed().unwrap(), modified);
8301        assert_eq!(json_of(&file)["created_ms"], created_ms);
8302        for directory in [dir.clone(), dir.join("s000001")] {
8303            let changed = std::fs::metadata(&directory).unwrap().modified().unwrap();
8304            assert!(changed >= started, "{directory:?} was given the time of a file");
8305        }
8306        assert_eq!(store.fetch(&sealed.id, 0, 1).unwrap(), text("from another store"));
8307
8308        // The same content with another time: found, and left as it is.
8309        let other = Written {
8310            created_ms: Some(5),
8311            modified: None,
8312        };
8313        let before = snapshot(&dir);
8314        let again = store.seal_projection(project("from another store"), Some(other)).unwrap();
8315        assert_eq!(again.0, sealed);
8316        assert_eq!(store.seal(&text("from another store"), "").unwrap(), sealed);
8317        assert_eq!(snapshot(&dir), before);
8318        assert_eq!(std::fs::metadata(&file).unwrap().modified().unwrap(), modified);
8319
8320        // A record that never had a time is stored without one, not with
8321        // the time of the import; its file has the time of the write.
8322        let undated = Written {
8323            created_ms: None,
8324            modified: None,
8325        };
8326        let (plain, _) = store.seal_projection(project("never had a time"), Some(undated)).unwrap();
8327        let file = dir.join("s000001").join(archive_name(&plain.id));
8328        let fields = ["body", "digest", "id", "logical", "scope", "version"];
8329        assert_eq!(top_level(&json_of(&file)), fields);
8330        assert!(std::fs::metadata(&file).unwrap().modified().unwrap() >= started);
8331        assert_eq!(store.fetch(&plain.id, 0, 1).unwrap(), text("never had a time"));
8332    }
8333
8334    /// `created_ms` is the one field a version 1 record may have or not
8335    /// have. With it or without it the record is read; with anything else
8336    /// at the top level, or with a `created_ms` that is not a whole number
8337    /// of milliseconds, it is not a record.
8338    #[test]
8339    fn created_ms_is_optional_and_no_other_field_is_accepted() {
8340        let (tmp, store) = setup();
8341        let shelf = directory(&tmp, &store).join("s000001");
8342        std::fs::create_dir(&shelf).unwrap();
8343        let id = "a".repeat(32);
8344        let file = shelf.join(archive_name(&id));
8345        let words = text("with or without");
8346        let without = record_bytes(&store, &id, &words, |_| {});
8347        assert!(!String::from_utf8_lossy(&without).contains("created_ms"));
8348        let with = |value: Value| {
8349            let mut record: Value = serde_json::from_slice(&without).unwrap();
8350            record["created_ms"] = value;
8351            serde_json::to_vec(&record).unwrap()
8352        };
8353        for valid in [without.clone(), with(json!(0)), with(json!(1_757_000_000_000u64)), with(json!(u64::MAX))] {
8354            std::fs::write(&file, valid).unwrap();
8355            assert_eq!(store.fetch(&id, 0, 1).unwrap(), words);
8356            assert_eq!(store.search("without", 4).unwrap().len(), 1);
8357        }
8358        let mut extra: Value = serde_json::from_slice(&without).unwrap();
8359        extra["created_at"] = json!(5);
8360        let mut nested: Value = serde_json::from_slice(&without).unwrap();
8361        nested["body"]["created_ms"] = json!(5);
8362        let invalid = [
8363            with(Value::Null),
8364            with(json!(-1)),
8365            with(json!(1.5)),
8366            with(json!("1757000000000")),
8367            with(json!(true)),
8368            serde_json::to_vec(&extra).unwrap(),
8369            serde_json::to_vec(&nested).unwrap(),
8370        ];
8371        for (index, bytes) in invalid.into_iter().enumerate() {
8372            std::fs::write(&file, bytes).unwrap();
8373            let error = store.fetch(&id, 0, 1).unwrap_err();
8374            assert_eq!(error.kind(), io::ErrorKind::InvalidData, "case {index}: {error}");
8375        }
8376    }
8377
8378    /// A forget leaves no time behind: the tombstone keeps what names the
8379    /// record and nothing else, whatever the live record had.
8380    #[test]
8381    fn a_tombstone_carries_no_created_ms() {
8382        let (tmp, store) = setup();
8383        let dir = directory(&tmp, &store);
8384        let sealed = store.seal(&text("to be forgotten"), "").unwrap();
8385        let file = only_archive_file(&dir);
8386        assert!(json_of(&file)["created_ms"].is_u64());
8387        store.forget(&sealed.id).unwrap();
8388        let tombstone = json_of(&file);
8389        let fields = ["body", "digest", "id", "logical", "scope", "version"];
8390        assert_eq!(top_level(&tombstone), fields);
8391        assert_eq!(tombstone["body"], json!({"state":"tombstone"}));
8392
8393        // Nor does the tombstone a forget adds for a copy that was stored
8394        // under no name of its digest: here, a legacy slot.
8395        let slot = seal_legacy_slot(&store, 0, &text("forgotten in a slot"), "");
8396        store.forget(&slot.id).unwrap();
8397        for path in shelf_files(&dir).into_iter().chain([dir.join(slot_name(0))]) {
8398            let record = json_of(&path);
8399            assert_eq!(top_level(&record), fields, "{path:?}");
8400            assert_eq!(record["body"]["state"], "tombstone");
8401        }
8402        assert_eq!(shelf_files(&dir).len(), 2);
8403    }
8404
8405    /// A tombstone that has `created_ms` is not a record: nothing is read
8406    /// from it. It still says that its content was forgotten, as any file
8407    /// that names the digest with the state `tombstone` does.
8408    #[test]
8409    fn a_tombstone_with_created_ms_is_not_a_record_and_still_blocks_a_seal() {
8410        let (tmp, store) = setup();
8411        let dir = directory(&tmp, &store);
8412        let words = text("forgotten somewhere else");
8413        let digest = project_hashed(&store.logical, &words, "", None, Reduce::None).unwrap().digest;
8414        let id = &digest[..32];
8415        let shelf = dir.join("s000001");
8416        std::fs::create_dir(&shelf).unwrap();
8417        let file = shelf.join(archive_name(id));
8418        let dated = |created_ms| {
8419            record_bytes(&store, id, &words, |record| {
8420                record.body = Body::Tombstone;
8421                record.created_ms = created_ms;
8422            })
8423        };
8424        // Without the field it is the tombstone a forget writes.
8425        std::fs::write(&file, dated(None)).unwrap();
8426        assert_eq!(store.fetch(id, 0, 1).unwrap_err().kind(), io::ErrorKind::NotFound);
8427        // With it, it is not a record.
8428        let invalid = dated(Some(1_757_000_000_000));
8429        std::fs::write(&file, &invalid).unwrap();
8430        assert_eq!(store.fetch(id, 0, 1).unwrap_err().kind(), io::ErrorKind::InvalidData);
8431        let error = store.seal(&words, "").unwrap_err();
8432        assert_eq!(error.kind(), io::ErrorKind::PermissionDenied, "{error}");
8433        assert_eq!(shelf_files(&dir), std::slice::from_ref(&file));
8434        assert_eq!(std::fs::read(&file).unwrap(), invalid);
8435    }
8436
8437    /// A project directory from before shelves: a live segment in slot 0, a
8438    /// forgotten one in slot 2, and slot 1 between them left to the test.
8439    fn legacy_store() -> (tempfile::TempDir, ArchiveStore, ArchiveRef) {
8440        let (tmp, store) = setup();
8441        let live = seal_legacy_slot(&store, 0, &text("legacy live"), "first note");
8442        let gone = seal_legacy_slot(&store, 2, &text("legacy forgotten"), "");
8443        store.forget(&gone.id).unwrap();
8444        (tmp, store, live)
8445    }
8446    /// With slot 1 unusable a seal works, and still finds what the slots on
8447    /// either side of it hold. So does a read of the segment in slot 0. A
8448    /// read of a segment that is found nowhere says that slot 1 could not be
8449    /// read (and how: `kind`), since it may be the one.
8450    fn assert_slot_1_is_passed_over(
8451        tmp: &tempfile::TempDir,
8452        store: &ArchiveStore,
8453        live: &ArchiveRef,
8454        words: &str,
8455        kind: io::ErrorKind,
8456    ) {
8457        let new = store.seal(&text(words), "").unwrap();
8458        assert_eq!(store.fetch(&new.id, 0, 1).unwrap(), text(words), "{words}");
8459        let again = store.seal_with_note(&text("legacy live"), "second note");
8460        assert_eq!(again.unwrap(), (live.clone(), "first note".to_owned()), "{words}");
8461        let forgotten = store.seal(&text("legacy forgotten"), "").unwrap_err();
8462        assert!(forgotten.to_string().contains("was forgotten"), "{words}");
8463
8464        assert_eq!(store.fetch(&live.id, 0, 1).unwrap(), text("legacy live"), "{words}");
8465        assert_eq!(store.fetch_note(&live.id).unwrap(), "first note", "{words}");
8466        let slot = directory(tmp, store).join("000.json");
8467        let located = archive_file(tmp.path(), "synthetic-project", &live.id);
8468        assert_eq!(located.unwrap(), Some(slot), "{words}");
8469        let rows = store.search("legacy", 2).unwrap();
8470        assert_eq!(rows.len(), 1, "{words}");
8471        assert_eq!(rows[0].id, live.id, "{words}");
8472
8473        let missing = "a".repeat(32);
8474        for result in [
8475            store.fetch(&missing, 0, 1).map(drop),
8476            store.fetch_note(&missing).map(drop),
8477            store.forget(&missing).map(drop),
8478            archive_file(tmp.path(), "synthetic-project", &missing).map(drop),
8479        ] {
8480            let error = result.unwrap_err();
8481            assert_eq!(error.kind(), kind, "{words}: {error}");
8482            assert!(error.to_string().contains("first 001.json"), "{words}: {error}");
8483            assert!(error.to_string().contains("could not be read"), "{words}: {error}");
8484        }
8485    }
8486    /// A forget of the healthy segment in slot 0 works beside slot 1 too.
8487    /// It replaces slot 0, adds the tombstone a seal looks for on the shelf
8488    /// and changes nothing else. Slot 1 may hold a copy of the same window
8489    /// and could not be read, which the forget says. (Slot 1 itself, which
8490    /// may be a directory or a FIFO, is for the caller to look at.)
8491    fn assert_forgets_beside_slot_1(tmp: &tempfile::TempDir, store: &ArchiveStore, live: &ArchiveRef) {
8492        let dir = directory(tmp, store);
8493        let slot = dir.join("000.json");
8494        let guard = dir.join("s000001").join(archive_name(&digest_of("legacy live")[..32]));
8495        let others = || -> Vec<_> {
8496            let mut files = shelf_files(&dir);
8497            files.retain(|file| *file != guard);
8498            files.push(dir.join("002.json"));
8499            files.into_iter().map(|path| (identity(&path), path)).collect()
8500        };
8501        let (before, entries) = (others(), std::fs::read_dir(&dir).unwrap().count());
8502        let live_file = identity(&slot);
8503        assert!(!guard.exists());
8504        let done = store.forget(&live.id).unwrap();
8505        assert_eq!(done.ids, std::slice::from_ref(&live.id));
8506        let unread = done.unread.unwrap();
8507        assert!(unread.starts_with("1 place(s) could not be read, first 001.json: "), "{unread}");
8508        assert_ne!(identity(&slot), live_file);
8509        let tombstone = (identity(&slot), identity(&guard));
8510        assert!(store.forget(&live.id).unwrap().ids.is_empty());
8511        assert_eq!((identity(&slot), identity(&guard)), tombstone, "forgotten twice, written once");
8512        assert!(std::fs::read_to_string(&slot).unwrap().contains("tombstone"));
8513        assert!(is_tombstone_of(store, &guard, &digest_of("legacy live")));
8514        // Found, and forgotten: plainly "not found", with nothing to add.
8515        let error = store.fetch(&live.id, 0, 1).unwrap_err();
8516        assert_eq!(error.kind(), io::ErrorKind::NotFound);
8517        assert_eq!(error.to_string(), not_found().to_string());
8518        let refused = store.seal(&text("legacy live"), "").unwrap_err();
8519        assert!(refused.to_string().contains("was forgotten"));
8520        assert_eq!(others(), before);
8521        assert_eq!(std::fs::read_dir(&dir).unwrap().count(), entries);
8522        assert!(temporary_files(&dir).is_empty());
8523    }
8524
8525    #[test]
8526    fn a_legacy_slot_that_is_not_a_valid_record_hides_only_itself() {
8527        let (tmp, store, live) = legacy_store();
8528        let dir = directory(&tmp, &store);
8529        let slot = dir.join("001.json");
8530        // A valid record of another project, and this project's own live
8531        // record with a word of its content changed.
8532        let foreign =
8533            ArchiveStore::new(tmp.path(), "foreign-project", "synthetic-logical").unwrap();
8534        seal_legacy_slot(&foreign, 0, &text("foreign words"), "");
8535        let foreign = std::fs::read(directory(&tmp, &foreign).join("000.json")).unwrap();
8536        let altered = std::fs::read_to_string(dir.join("000.json")).unwrap();
8537        let altered = altered.replace("legacy live", "legacy LIVE");
8538        let cases = [
8539            ("bad JSON", b"{\"version\":1,".to_vec()),
8540            ("empty", Vec::new()),
8541            ("wrong scope", foreign),
8542            ("digest mismatch", altered.into_bytes()),
8543        ];
8544        let invalid = io::ErrorKind::InvalidData;
8545        for (kind, bytes) in cases {
8546            std::fs::write(&slot, &bytes).unwrap();
8547            assert_slot_1_is_passed_over(&tmp, &store, &live, kind, invalid);
8548            assert_eq!(std::fs::read(&slot).unwrap(), bytes, "{kind}");
8549        }
8550        // Over the size of a segment: refused by its length, never read.
8551        let large = MAX_SEGMENT_BYTES as u64 + 1;
8552        std::fs::File::create(&slot).unwrap().set_len(large).unwrap();
8553        assert_slot_1_is_passed_over(&tmp, &store, &live, "over the size bound", invalid);
8554        assert_eq!(std::fs::metadata(&slot).unwrap().len(), large);
8555        // Five seals, five new files beside the tombstone that the forget
8556        // of slot 2 left on the shelf, and no other change to the directory.
8557        assert_eq!(shelf_files(&dir).len(), 6);
8558        assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 5, "lock, 3 slots, 1 shelf");
8559        assert_forgets_beside_slot_1(&tmp, &store, &live);
8560        assert_eq!(std::fs::metadata(&slot).unwrap().len(), large);
8561    }
8562
8563    #[test]
8564    fn a_legacy_slot_that_is_not_a_private_file_hides_only_itself() {
8565        use std::os::unix::fs::FileTypeExt;
8566        let (tmp, store, live) = legacy_store();
8567        let slot = directory(&tmp, &store).join("001.json");
8568        let kind = || std::fs::symlink_metadata(&slot).unwrap().file_type();
8569        let victim = tmp.path().join("synthetic-victim");
8570        std::fs::write(&victim, "do not change").unwrap();
8571        std::fs::set_permissions(&victim, std::fs::Permissions::from_mode(0o644)).unwrap();
8572        // Nothing was read into, written through or made private by way of
8573        // the slot.
8574        let passed_over = |words: &str, error: io::ErrorKind| {
8575            assert_slot_1_is_passed_over(&tmp, &store, &live, words, error);
8576            assert_eq!(std::fs::read_to_string(&victim).unwrap(), "do not change");
8577            assert_eq!(mode(&victim), 0o644, "{words}");
8578        };
8579        let denied = io::ErrorKind::PermissionDenied;
8580        symlink(&victim, &slot).unwrap();
8581        // The kind of the error that refuses a link has no stable name.
8582        let looped = store.fs.read_with("001.json", MAX_SEGMENT_BYTES, true).unwrap_err().kind();
8583        passed_over("past a symlink", looped);
8584        assert!(kind().is_symlink());
8585        std::fs::remove_file(&slot).unwrap();
8586        std::fs::hard_link(&victim, &slot).unwrap();
8587        passed_over("past a hard link", denied);
8588        std::fs::remove_file(&slot).unwrap();
8589        let fifo = std::ffi::CString::new(slot.to_str().unwrap()).unwrap();
8590        assert_eq!(unsafe { libc::mkfifo(fifo.as_ptr(), 0o600) }, 0);
8591        passed_over("past a FIFO", denied);
8592        assert!(kind().is_fifo());
8593        std::fs::remove_file(&slot).unwrap();
8594        std::fs::create_dir(&slot).unwrap();
8595        passed_over("past a directory", denied);
8596        assert!(kind().is_dir());
8597        assert_forgets_beside_slot_1(&tmp, &store, &live);
8598        assert!(kind().is_dir());
8599        assert_eq!(std::fs::read_to_string(&victim).unwrap(), "do not change");
8600    }
8601
8602    /// A slot that was forgotten by a build from before a forget left a
8603    /// tombstone on a shelf has only itself to say so.
8604    #[test]
8605    fn a_legacy_tombstone_that_cannot_be_read_no_longer_forbids_its_projection() {
8606        let (tmp, store) = setup();
8607        let dir = directory(&tmp, &store);
8608        let slot = dir.join("000.json");
8609        let gone = seal_legacy_slot(&store, 0, &text("forgotten words"), "");
8610        store.forget(&gone.id).unwrap();
8611        let refused = store.seal(&text("forgotten words"), "").unwrap_err();
8612        assert_eq!(refused.kind(), io::ErrorKind::PermissionDenied);
8613        // With the tombstone torn, the one on the shelf still says that
8614        // this projection was forgotten.
8615        let tombstone = std::fs::read(&slot).unwrap();
8616        let torn = &tombstone[..tombstone.len() / 2];
8617        std::fs::write(&slot, torn).unwrap();
8618        let refused = store.seal(&text("forgotten words"), "").unwrap_err();
8619        assert_eq!(refused.kind(), io::ErrorKind::PermissionDenied);
8620        // Without that one, the price of passing over what cannot be read:
8621        // nothing says so any more.
8622        std::fs::remove_file(only_archive_file(&dir)).unwrap();
8623        let again = store.seal(&text("forgotten words"), "").unwrap();
8624        assert_eq!(store.fetch(&again.id, 0, 1).unwrap(), text("forgotten words"));
8625        assert_eq!(only_archive_file(&dir), dir.join("s000001").join(archive_name(&again.id)));
8626        assert_eq!(std::fs::read(&slot).unwrap(), torn);
8627    }
8628
8629    /// `readdir` says that a read failed only through `errno`; its null is
8630    /// also the end of the directory. A listing that did not look would come
8631    /// back short, and a seal would take a full shelf for one with room.
8632    #[test]
8633    fn a_failed_directory_read_is_an_error_not_a_shorter_listing() {
8634        use std::os::fd::{AsRawFd, IntoRawFd};
8635        let (tmp, store) = setup();
8636        store.seal(&text("listed"), "").unwrap();
8637        let shelf = store.fs.open_child("s000001", true).unwrap().unwrap();
8638        assert_eq!(shelf.names().unwrap().len(), 1);
8639        // A stream on the shelf, opened as `names` opens it...
8640        let handle = shelf.open(c".", libc::O_RDONLY | libc::O_DIRECTORY);
8641        let fd = handle.unwrap().into_raw_fd();
8642        let stream = unsafe { libc::fdopendir(fd) };
8643        assert!(!stream.is_null());
8644        // ...whose handle is then one on a plain file: the read cannot work.
8645        let file = std::fs::File::open(only_archive_file(&directory(&tmp, &store))).unwrap();
8646        assert_eq!(unsafe { libc::dup2(file.as_raw_fd(), fd) }, fd);
8647        let listed = shelf.read_names(stream);
8648        unsafe { libc::closedir(stream) };
8649        let error = listed.unwrap_err();
8650        #[cfg(target_os = "linux")]
8651        assert_eq!(error.raw_os_error(), Some(libc::ENOTDIR));
8652        assert_ne!(error.raw_os_error(), Some(0));
8653    }
8654
8655    /// The other half of the same rule: `errno` is cleared before each read,
8656    /// so what an earlier call left there does not fail a listing that worked.
8657    #[test]
8658    fn a_listing_is_not_failed_by_an_error_from_before_it() {
8659        let (_tmp, store) = setup();
8660        store.seal(&text("listed"), "").unwrap();
8661        let shelf = store.fs.open_child("s000001", true).unwrap().unwrap();
8662        assert_eq!(unsafe { libc::close(-1) }, -1);
8663        assert_eq!(io::Error::last_os_error().raw_os_error(), Some(libc::EBADF));
8664        assert_eq!(shelf.names().unwrap().len(), 1);
8665        // The same for a directory with nothing in it.
8666        let empty = store.fs.child("s000002", true).unwrap();
8667        assert_eq!(unsafe { libc::close(-1) }, -1);
8668        assert!(empty.names().unwrap().is_empty());
8669    }
8670
8671    /// A listing opens the directory again, and where that is refused it
8672    /// fails; it does not come back empty.
8673    #[test]
8674    fn a_directory_that_cannot_be_opened_again_is_an_error_not_an_empty_listing() {
8675        if unsafe { libc::geteuid() } == 0 {
8676            return; // nothing is refused to root
8677        }
8678        let (tmp, store) = setup();
8679        store.seal(&text("listed"), "").unwrap();
8680        let path = directory(&tmp, &store).join("s000001");
8681        let shelf = store.fs.open_child("s000001", true).unwrap().unwrap();
8682        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o000)).unwrap();
8683        let listed = shelf.names();
8684        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700)).unwrap();
8685        assert_eq!(listed.unwrap_err().kind(), io::ErrorKind::PermissionDenied);
8686        assert_eq!(shelf.names().unwrap().len(), 1);
8687    }
8688
8689    #[test]
8690    fn names_that_are_not_store_names_are_ignored_whatever_they_look_like() {
8691        use std::os::unix::ffi::OsStringExt;
8692        let (tmp, store) = setup();
8693        let dir = directory(&tmp, &store);
8694        let first = store.seal(&text("first"), "").unwrap();
8695        let legacy = seal_legacy_slot(&store, 0, &text("legacy"), "");
8696        let id = &first.id;
8697        let odd: Vec<std::ffi::OsString> = vec![
8698            // As long as a name can be, with and without the ending of a
8699            // segment; not UTF-8; and what no path component may hold.
8700            "x".repeat(255).into(),
8701            format!("{}.json", "9".repeat(250)).into(),
8702            std::ffi::OsString::from_vec(b"\xff\xfe.json".to_vec()),
8703            "back\\slash.json".into(),
8704            "new\nline.json".into(),
8705            // Nearly a shelf, a slot, a segment, and a temporary file.
8706            "s00001".into(),
8707            "s0000001".into(),
8708            "S000001".into(),
8709            "s000001.json".into(),
8710            "0.json".into(),
8711            "128.json".into(),
8712            "0000.json".into(),
8713            format!("{}.json", &id[1..]).into(),
8714            format!("{}.json", id.to_uppercase()).into(),
8715            format!("{id}.json.tmp").into(),
8716            format!("{id}.JSON").into(),
8717            format!(".{id}.json").into(),
8718            ".json".into(),
8719            "lock.json".into(),
8720        ];
8721        let mut strays = Vec::new();
8722        for parent in [dir.clone(), dir.join("s000001")] {
8723            for name in &odd {
8724                // A filesystem may refuse some of these names.
8725                let path = parent.join(name);
8726                if std::fs::write(&path, b"stray").is_ok() {
8727                    strays.push(path);
8728                }
8729            }
8730        }
8731        #[cfg(target_os = "linux")]
8732        assert_eq!(strays.len(), 2 * odd.len());
8733        assert!(strays.len() >= 2 * 10);
8734
8735        let second = store.seal(&text("second"), "").unwrap();
8736        assert!(dir.join("s000001").join(archive_name(&second.id)).is_file());
8737        assert_eq!(store.seal(&text("first"), "").unwrap(), first);
8738        assert_eq!(store.fetch(&first.id, 0, 1).unwrap(), text("first"));
8739        assert_eq!(store.fetch(&legacy.id, 0, 1).unwrap(), text("legacy"));
8740        let mut found: Vec<_> = store.search("", 32).unwrap().into_iter().map(|row| row.id).collect();
8741        found.sort();
8742        let mut expected = vec![first.id.clone(), second.id.clone(), legacy.id.clone()];
8743        expected.sort();
8744        assert_eq!(found, expected);
8745        store.forget(&second.id).unwrap();
8746        assert_eq!(store.shelves().unwrap(), ["s000001"]);
8747        for stray in strays {
8748            assert_eq!(std::fs::read(&stray).unwrap(), b"stray", "{stray:?}");
8749        }
8750    }
8751
8752    /// A-1: validation says which check a record failed, and a read still
8753    /// says `invalid archive record` for every one of them.
8754    #[test]
8755    fn a_failed_check_has_a_name_and_a_read_does_not_say_it() {
8756        let (_tmp, store) = setup();
8757        let id = "0123456789abcdef0123456789abcdef";
8758        let name = archive_name(id);
8759        let bytes = |change: &dyn Fn(&mut Record)| {
8760            record_bytes(&store, id, &[msg("user", json!("a")), msg("user", json!("b"))], change)
8761        };
8762        let swap = |record: &mut Record| {
8763            if let Body::Live { messages, .. } = &mut record.body {
8764                messages.swap(0, 1);
8765            }
8766        };
8767        let many = |record: &mut Record| {
8768            if let Body::Live { source_message_count, .. } = &mut record.body {
8769                *source_message_count = MAX_MESSAGES + 1;
8770            }
8771        };
8772        let long = |record: &mut Record| {
8773            if let Body::Live { note, .. } = &mut record.body {
8774                *note = "n".repeat(MAX_NOTE_BYTES + 1);
8775            }
8776        };
8777        let cases: Vec<(Vec<u8>, &str)> = vec![
8778            (bytes(&|record| record.version = 2), "version 2 is not supported"),
8779            (bytes(&|record| record.scope = "0".repeat(64)), "scope is not this project's"),
8780            (bytes(&|record| record.id = id.to_uppercase()), "invalid id"),
8781            (bytes(&|record| record.logical = "z".repeat(64)), "invalid logical"),
8782            (bytes(&|record| record.digest.truncate(63)), "invalid digest"),
8783            (bytes(&|record| record.id = "f".repeat(32)), "file name is not the record's id"),
8784            (bytes(&many), "over a per-archive limit"),
8785            (bytes(&long), "over a per-archive limit"),
8786            (bytes(&swap), "source_index out of order"),
8787            (
8788                bytes(&|record| {
8789                    record.body = Body::Tombstone;
8790                    record.created_ms = Some(1);
8791                }),
8792                "a tombstone carries no created_ms",
8793            ),
8794            (b"{\"version\":1".to_vec(), "not an archive record: the JSON ends early (column 12)"),
8795            (b"not json".to_vec(), "not an archive record: not JSON (column 2)"),
8796        ];
8797        for (raw, reason) in cases {
8798            let fault = ArchiveStore::parsed(&store.scope, &name, &raw).err().expect(reason);
8799            assert_eq!(fault.to_string(), reason);
8800            let error = store.record(&name, &raw).err().expect(reason);
8801            assert_eq!(error.kind(), io::ErrorKind::InvalidData, "{reason}");
8802            assert_eq!(error.to_string(), "invalid archive record", "{reason}");
8803        }
8804        // The digests of a mismatch are named, and nothing else of the record.
8805        let other = digest_of("something else");
8806        let raw = bytes(&|record| record.digest = other.clone());
8807        let reason = ArchiveStore::parsed(&store.scope, &name, &raw).err().unwrap().to_string();
8808        let prefix = format!("digest does not match content (record {other}, content ");
8809        assert!(reason.starts_with(&prefix), "{reason}");
8810        assert_eq!(reason.len(), prefix.len() + 64 + 1);
8811        assert_eq!(store.record(&name, &raw).err().unwrap().to_string(), "invalid archive record");
8812        // What serde read is never quoted: an unknown field's name, a value.
8813        let mut unknown: Value = serde_json::from_slice(&bytes(&|_| {})).unwrap();
8814        unknown["private words"] = json!("more private words");
8815        let raw = serde_json::to_vec(&unknown).unwrap();
8816        let reason = ArchiveStore::parsed(&store.scope, &name, &raw).err().unwrap().to_string();
8817        assert!(reason.starts_with("not an archive record: an unknown field (column "), "{reason}");
8818        assert!(!reason.contains("private"), "{reason}");
8819        let mut wrong: Value = serde_json::from_slice(&bytes(&|_| {})).unwrap();
8820        wrong["version"] = json!("private words");
8821        let raw = serde_json::to_vec(&wrong).unwrap();
8822        let reason = ArchiveStore::parsed(&store.scope, &name, &raw).err().unwrap().to_string();
8823        assert!(reason.starts_with("not an archive record: a field of the wrong type"), "{reason}");
8824        assert!(!reason.contains("private"), "{reason}");
8825        // A valid record passes, on a shelf name and in a slot.
8826        assert!(ArchiveStore::parsed(&store.scope, &name, &bytes(&|_| {})).is_ok());
8827        assert!(ArchiveStore::parsed(&store.scope, "000.json", &bytes(&|_| {})).is_ok());
8828    }
8829
8830    /// What a seal is about to write goes through the reader's check first:
8831    /// a projection `admit` lets through (its digest is right, and the note
8832    /// and the indexes are not in the digest's way) that a read would refuse
8833    /// is refused with the name of the check, and nothing is written.
8834    #[test]
8835    fn a_projection_no_read_would_accept_is_refused_before_it_is_written() {
8836        let (tmp, store) = setup();
8837        let dir = directory(&tmp, &store);
8838        // A shelf and a lock file to begin with: a refusal adds nothing.
8839        store.seal(&text("a shelf to begin with"), "").unwrap();
8840        let project = |words: &[&str]| {
8841            let messages: Vec<_> = words.iter().map(|words| msg("user", json!(words))).collect();
8842            project_hashed(&store.logical, &messages, "", None, Reduce::None).unwrap()
8843        };
8844        let mut long_note = project(&["a long note"]);
8845        long_note.note = "n".repeat(9_000);
8846        // The digest is the digest of exactly these messages, in this order.
8847        let mut falling = project(&["first", "second"]);
8848        falling.messages.swap(0, 1);
8849        falling.digest = projection_digest(&store.logical, 2, &falling.messages).unwrap();
8850        let mut beyond = project(&["only"]);
8851        beyond.messages[0].source_index = 1;
8852        beyond.digest = projection_digest(&store.logical, 1, &beyond.messages).unwrap();
8853        let before = snapshot(&dir);
8854        let mut names: Vec<_> = std::fs::read_dir(&dir).unwrap().map(|e| e.unwrap().path()).collect();
8855        names.sort();
8856        for (projection, check) in [
8857            (long_note, "over a per-archive limit"),
8858            (falling, "source_index out of order"),
8859            (beyond, "source_index out of order"),
8860        ] {
8861            store.admit(&projection).expect("admit does not check this");
8862            let error = store.seal_projection(projection, None).unwrap_err();
8863            assert_eq!(error.kind(), io::ErrorKind::InvalidInput, "{check}: {error}");
8864            assert!(error.to_string().ends_with(check), "{check}: {error}");
8865            assert_eq!(snapshot(&dir), before, "{check}");
8866            let mut after: Vec<_> =
8867                std::fs::read_dir(&dir).unwrap().map(|e| e.unwrap().path()).collect();
8868            after.sort();
8869            assert_eq!(after, names, "{check}");
8870        }
8871        // An ordinary seal is what it was.
8872        let sealed = store.seal(&text("ordinary"), "a note").unwrap();
8873        assert_eq!(sealed.id, digest_of("ordinary")[..32]);
8874        assert_eq!(store.fetch(&sealed.id, 0, 1).unwrap(), text("ordinary"));
8875        assert_eq!(store.fetch_note(&sealed.id).unwrap(), "a note");
8876    }
8877
8878    // ---- The import (A-2) ----
8879
8880    fn random_id() -> String {
8881        uuid::Uuid::new_v4().simple().to_string()
8882    }
8883    /// One import of a real run; the machine did not fail.
8884    fn import(store: &ArchiveStore, line: &[u8]) -> Result<Imported, ImportRefused> {
8885        store.import(line, false, false, &mut ImportSeen::default()).unwrap()
8886    }
8887    fn reason(store: &ArchiveStore, line: &[u8]) -> String {
8888        import(store, line).expect_err("refused").reason
8889    }
8890    fn tombstone(record: &mut Record) {
8891        record.body = Body::Tombstone;
8892    }
8893    /// A record line whose messages are given as they are, with the digest
8894    /// the store computes for them: what no projection of a chat produces.
8895    fn raw_line(store: &ArchiveStore, id: &str, messages: Vec<Value>, note: &str) -> Vec<u8> {
8896        let messages: Vec<_> = messages
8897            .into_iter()
8898            .enumerate()
8899            .map(|(source_index, message)| ArchivedMessage {
8900                source_index,
8901                block_indices: Vec::new(),
8902                message: Arc::new(message),
8903            })
8904            .collect();
8905        let record = Record {
8906            version: 1,
8907            scope: store.scope.clone(),
8908            logical: store.logical.clone(),
8909            id: id.to_owned(),
8910            digest: projection_digest(&store.logical, messages.len(), &messages).unwrap(),
8911            created_ms: None,
8912            body: Body::Live {
8913                source_message_count: messages.len(),
8914                messages,
8915                note: note.to_owned(),
8916            },
8917        };
8918        serde_json::to_vec(&record).unwrap()
8919    }
8920    fn entries(dir: &Path) -> Vec<String> {
8921        let mut names: Vec<_> = std::fs::read_dir(dir)
8922            .unwrap()
8923            .map(|entry| entry.unwrap().file_name().into_string().unwrap())
8924            .collect();
8925        names.sort();
8926        names
8927    }
8928    fn at_ms(ms: u64) -> std::time::SystemTime {
8929        std::time::UNIX_EPOCH + Duration::from_millis(ms)
8930    }
8931
8932    /// T-A1: a record sealed in one store and imported into another is the
8933    /// same file, byte for byte, with `created_ms` as its file's time, and
8934    /// reads the same. Imported in any order within a second, records come
8935    /// back from a search in the order of their `created_ms`.
8936    #[test]
8937    fn t_a1_an_imported_record_is_the_file_it_was() {
8938        let (tmp, first) = setup();
8939        let sealed = first.seal_with_note(&text("round trip words"), "a hidden note").unwrap();
8940        let source = only_archive_file(&directory(&tmp, &first));
8941        let line = std::fs::read(&source).unwrap();
8942        let created_ms = json_of(&source)["created_ms"].as_u64().unwrap();
8943
8944        let other = tempfile::tempdir().unwrap();
8945        let second = ArchiveStore::for_scope(other.path(), &first.scope).unwrap();
8946        assert_eq!(second.scope(), first.scope);
8947        assert_eq!(record_scope(&line).unwrap(), first.scope);
8948        let done = import(&second, &line).unwrap();
8949        assert_eq!(
8950            done,
8951            Imported {
8952                outcome: ImportOutcome::Stored,
8953                id: sealed.0.id.clone(),
8954                live: true,
8955                message_count: 1,
8956                shelf: Some("s000001".to_owned()),
8957                same_content_as: None,
8958                unread: None,
8959            }
8960        );
8961        let copy = only_archive_file(&other.path().join("context-archives").join(&first.scope));
8962        // Before the file is read here, which may move its time of access.
8963        let metadata = std::fs::metadata(&copy).unwrap();
8964        assert_eq!(metadata.modified().unwrap(), at_ms(created_ms));
8965        assert_eq!(metadata.accessed().unwrap(), at_ms(created_ms));
8966        assert_eq!(copy.file_name(), source.file_name());
8967        assert_eq!(std::fs::read(&copy).unwrap(), line);
8968        assert_eq!(mode(&copy), 0o600);
8969        let id = &sealed.0.id;
8970        assert_eq!(second.fetch(id, 0, 8).unwrap(), first.fetch(id, 0, 8).unwrap());
8971        assert_eq!(second.fetch_note(id).unwrap(), "a hidden note");
8972        assert_eq!(second.search("round", 8).unwrap(), first.search("round", 8).unwrap());
8973        assert_eq!(second.search("round", 8).unwrap().len(), 1);
8974
8975        // Three records a day apart, not imported in that order.
8976        let day = 24 * 60 * 60 * 1000;
8977        let ids = [random_id(), random_id(), random_id()];
8978        for index in [1, 2, 0] {
8979            let words = format!("daily entry {index}");
8980            let line = record_bytes(&first, &ids[index], &text(&words), |record| {
8981                record.created_ms = Some(1_600_000_000_000 + index as u64 * day);
8982            });
8983            assert_eq!(import(&second, &line).unwrap().outcome, ImportOutcome::Stored);
8984        }
8985        let found = second.search("daily entry", 8).unwrap();
8986        let found: Vec<_> = found.iter().map(|row| row.id.as_str()).collect();
8987        assert_eq!(found, [&ids[2], &ids[1], &ids[0]], "newest first");
8988    }
8989
8990    /// T-A2: a record keeps an id that is not derived from its digest, and
8991    /// one without `created_ms` is stored without it, at the time of the
8992    /// write.
8993    #[test]
8994    fn t_a2_a_foreign_id_is_kept_and_no_time_is_invented() {
8995        let (tmp, store) = setup();
8996        let dir = directory(&tmp, &store);
8997        let id = random_id();
8998        let digest = digest_of("kept under its own id");
8999        assert!(id != digest[..32] && id != digest[32..]);
9000        let line = record_bytes(&store, &id, &text("kept under its own id"), |_| {});
9001        let started = std::time::SystemTime::now() - Duration::from_secs(5);
9002        let done = import(&store, &line).unwrap();
9003        assert_eq!((done.outcome, done.id.as_str()), (ImportOutcome::Stored, id.as_str()));
9004        let file = dir.join("s000001").join(archive_name(&id));
9005        assert_eq!(shelf_files(&dir), std::slice::from_ref(&file));
9006        assert!(std::fs::metadata(&file).unwrap().modified().unwrap() >= started);
9007        assert_eq!(std::fs::read(&file).unwrap(), line);
9008        let fields = ["body", "digest", "id", "logical", "scope", "version"];
9009        assert_eq!(top_level(&json_of(&file)), fields);
9010        assert_eq!(store.fetch(&id, 0, 8).unwrap(), text("kept under its own id"));
9011        let found = store.search("its own id", 8).unwrap();
9012        assert_eq!(found.iter().map(|row| row.id.as_str()).collect::<Vec<_>>(), [id.as_str()]);
9013        // A project store reads it too, and seals nothing.
9014        let project = ArchiveStore::for_scope(tmp.path(), &store.scope).unwrap();
9015        assert_eq!(project.fetch(&id, 0, 8).unwrap(), text("kept under its own id"));
9016        let error = project.seal(&text("a project store has no chat"), "").unwrap_err();
9017        assert_eq!(error.kind(), io::ErrorKind::InvalidInput);
9018        assert_eq!(shelf_files(&dir), [file]);
9019        // The same content sealed by its chat afterwards: stored a second
9020        // time under the digest's id, and the import of the line says so.
9021        let sealed = store.seal(&text("kept under its own id"), "").unwrap();
9022        assert_eq!(sealed.id, digest[..32]);
9023        let other = record_bytes(&store, &random_id(), &text("kept under its own id"), |_| {});
9024        let done = import(&store, &other).unwrap();
9025        assert_eq!(done.outcome, ImportOutcome::Stored);
9026        assert_eq!(done.same_content_as, Some(sealed.id));
9027    }
9028
9029    /// T-A3: the same line a second time changes nothing and says so.
9030    #[test]
9031    fn t_a3_the_same_line_twice_changes_nothing() {
9032        let (tmp, store) = setup();
9033        let dir = directory(&tmp, &store);
9034        let id = random_id();
9035        let line = record_bytes(&store, &id, &text("imported twice"), |record| {
9036            record.created_ms = Some(1_600_000_000_000);
9037        });
9038        assert_eq!(import(&store, &line).unwrap().outcome, ImportOutcome::Stored);
9039        let file = dir.join("s000001").join(archive_name(&id));
9040        let state = || (snapshot(&dir), std::fs::metadata(&file).unwrap().modified().unwrap());
9041        let before = state();
9042        assert_eq!(before.1, at_ms(1_600_000_000_000));
9043        let again = import(&store, &line).unwrap();
9044        assert_eq!(again.outcome, ImportOutcome::Present { note_differs: false });
9045        assert_eq!(again.shelf.as_deref(), Some("s000001"));
9046        assert_eq!(state(), before);
9047        // Another note and another time for the same content: the stored
9048        // ones stay, and the answer says the note differs.
9049        let noted = record_bytes(&store, &id, &text("imported twice"), |record| {
9050            record.created_ms = Some(5);
9051            if let Body::Live { note, .. } = &mut record.body {
9052                *note = "another note".to_owned();
9053            }
9054        });
9055        let again = import(&store, &noted).unwrap();
9056        assert_eq!(again.outcome, ImportOutcome::Present { note_differs: true });
9057        assert_eq!(state(), before);
9058        assert_eq!(store.fetch_note(&id).unwrap(), "");
9059    }
9060
9061    /// T-A4: content that is not what its digest says is refused with both
9062    /// digests, and nothing at all is written.
9063    #[test]
9064    fn t_a4_a_digest_that_is_not_the_contents_is_refused() {
9065        let (tmp, chat) = setup();
9066        let line = record_bytes(&chat, &random_id(), &text("one byte of this changes"), |_| {});
9067        let text_of = String::from_utf8(line.clone()).unwrap();
9068        let changed = text_of.replace("one byte of this changes", "one byte of this chanGes");
9069        assert_ne!(changed, text_of);
9070        let other = tempfile::tempdir().unwrap();
9071        let store = ArchiveStore::for_scope(other.path(), &chat.scope).unwrap();
9072        let dir = other.path().join("context-archives").join(&chat.scope);
9073        let refused = import(&store, changed.as_bytes()).unwrap_err();
9074        let recorded = digest_of("one byte of this changes");
9075        let content = digest_of("one byte of this chanGes");
9076        assert_eq!(
9077            refused.reason,
9078            format!("digest does not match content (record {recorded}, content {content})")
9079        );
9080        assert!(refused.id.is_some());
9081        // No file, no shelf, no temporary file, no lock file.
9082        assert_eq!(entries(&dir), Vec::<String>::new());
9083        // The line as it was is stored.
9084        assert_eq!(import(&store, &line).unwrap().outcome, ImportOutcome::Stored);
9085        drop(tmp);
9086    }
9087
9088    /// T-A5: every refusal has its own reason and writes nothing. What the
9089    /// store's reader accepts is not refused (a `system` role).
9090    #[test]
9091    fn t_a5_each_refusal_has_its_reason_and_writes_nothing() {
9092        let (_tmp, chat) = setup();
9093        let other = tempfile::tempdir().unwrap();
9094        let store = ArchiveStore::for_scope(other.path(), &chat.scope).unwrap();
9095        let dir = other.path().join("context-archives").join(&chat.scope);
9096        let id = random_id();
9097        let two = [msg("user", json!("a")), msg("user", json!("b"))];
9098        let line = |change: &dyn Fn(&mut Record)| record_bytes(&chat, &id, &two, change);
9099        let with = |key: &str, value: Value| {
9100            let mut record: Value = serde_json::from_slice(&line(&|_| {})).unwrap();
9101            record[key] = value;
9102            serde_json::to_vec(&record).unwrap()
9103        };
9104        let note = |length: usize| {
9105            line(&move |record: &mut Record| {
9106                if let Body::Live { note, .. } = &mut record.body {
9107                    *note = "n".repeat(length);
9108                }
9109            })
9110        };
9111        let many = vec![json!({"role": "user", "content": "m"}); MAX_MESSAGES + 1];
9112        // Refused for its size before its digest is looked at.
9113        let mut huge: Value = serde_json::from_slice(&line(&|_| {})).unwrap();
9114        huge["body"]["messages"][0]["message"]["content"] = json!("x".repeat(MAX_SEGMENT_BYTES));
9115        let huge = serde_json::to_vec(&huge).unwrap();
9116        let cases: Vec<(&str, Vec<u8>, &str)> = vec![
9117            ("version", line(&|record| record.version = 2), "version 2 is not supported"),
9118            ("unknown field", with("extra", json!(1)), "not an archive record: an unknown field"),
9119            ("a missing field", b"{\"version\":1}".to_vec(), "not an archive record: a field is"),
9120            ("not JSON", b"not json".to_vec(), "not an archive record: not JSON"),
9121            ("uppercase id", line(&|record| record.id = id.to_uppercase()), "invalid id"),
9122            ("logical", line(&|record| record.logical = "z".repeat(64)), "invalid logical"),
9123            ("digest", line(&|record| record.digest.truncate(60)), "invalid digest"),
9124            (
9125                "an uppercase digest on a tombstone",
9126                line(&|record| {
9127                    record.digest = record.digest.to_uppercase();
9128                    record.body = Body::Tombstone;
9129                }),
9130                "invalid digest",
9131            ),
9132            ("scope shape", line(&|record| record.scope = "Z".repeat(64)), "invalid scope"),
9133            (
9134                "another project",
9135                line(&|record| record.scope = "0".repeat(64)),
9136                "scope is not this project's",
9137            ),
9138            ("4,097 messages", raw_line(&chat, &id, many, ""), "over a per-archive limit"),
9139            ("a note of 8,193 bytes", note(MAX_NOTE_BYTES + 1), "over a per-archive limit"),
9140            (
9141                "source_index out of order",
9142                line(&|record| {
9143                    if let Body::Live { messages, .. } = &mut record.body {
9144                        messages.swap(0, 1);
9145                    }
9146                }),
9147                "source_index out of order",
9148            ),
9149            (
9150                "a message that is a string",
9151                raw_line(&chat, &id, vec![json!("just text")], ""),
9152                "message is not an object",
9153            ),
9154            (
9155                "a tombstone with created_ms",
9156                line(&|record| {
9157                    record.body = Body::Tombstone;
9158                    record.created_ms = Some(1);
9159                }),
9160                "a tombstone carries no created_ms",
9161            ),
9162            (
9163                "over 16 MiB",
9164                huge,
9165                "over the 16 MiB archive size",
9166            ),
9167            (
9168                "a note tombstone line",
9169                b"{\"tombstone\":\"mem-0123\",\"timestamp_ms\":1}".to_vec(),
9170                "not an archive record: an unknown field",
9171            ),
9172        ];
9173        for (what, line, expected) in &cases {
9174            let reason = reason(&store, line);
9175            assert!(reason.starts_with(expected), "{what}: {reason}");
9176            // The same in a dry run.
9177            let mut seen = ImportSeen::default();
9178            let dry = store.import(line, false, true, &mut seen).unwrap().unwrap_err();
9179            assert_eq!(dry.reason, reason, "{what}");
9180            assert_eq!(entries(&dir), Vec::<String>::new(), "{what}");
9181        }
9182        // A note of exactly the limit is not over it.
9183        assert_eq!(import(&store, &note(MAX_NOTE_BYTES)).unwrap().outcome, ImportOutcome::Stored);
9184        // The importer is no stricter than the reader about a role.
9185        let system = random_id();
9186        let line = raw_line(&chat, &system, vec![json!({"role": "system", "content": "s"})], "");
9187        assert_eq!(import(&store, &line).unwrap().outcome, ImportOutcome::Stored);
9188        assert_eq!(store.fetch(&system, 0, 1).unwrap(), [msg("system", json!("s"))]);
9189        // Routing by the line's own scope refuses what has none.
9190        let routed = record_scope(b"{\"tombstone\":\"mem-0123\",\"timestamp_ms\":1}").unwrap_err();
9191        assert_eq!(routed.reason, "not an archive record: a field is missing (column 41)");
9192        let routed = record_scope(&with("scope", json!("../../etc"))).unwrap_err();
9193        assert_eq!(routed.reason, "invalid scope");
9194        for scope in ["../../etc", "", &"A".repeat(64)] {
9195            assert!(ArchiveStore::for_scope(other.path(), scope).is_err());
9196            assert!(ArchiveStore::existing_scope(other.path(), scope).is_err());
9197        }
9198        assert_eq!(project_scope("synthetic-project").unwrap(), chat.scope);
9199    }
9200
9201    /// T-A6: an id that holds other content is refused and left alone; a
9202    /// file under the id that is not a record stops the run.
9203    #[test]
9204    fn t_a6_an_id_that_is_taken_is_never_written_over() {
9205        let (tmp, store) = setup();
9206        let dir = directory(&tmp, &store);
9207        let id = random_id();
9208        assert_eq!(
9209            import(&store, &record_bytes(&store, &id, &text("first content"), |_| {}))
9210                .unwrap()
9211                .outcome,
9212            ImportOutcome::Stored
9213        );
9214        let before = snapshot(&dir);
9215        for change in [(|_: &mut Record| {}) as fn(&mut Record), tombstone] {
9216            let other = record_bytes(&store, &id, &text("second content"), change);
9217            let refused = import(&store, &other).unwrap_err();
9218            assert_eq!(refused.reason, "id already holds different content");
9219            assert_eq!(refused.id.as_deref(), Some(id.as_str()));
9220            assert_eq!(snapshot(&dir), before);
9221        }
9222        assert_eq!(store.fetch(&id, 0, 1).unwrap(), text("first content"));
9223
9224        // Not a record under the id: the lookup cannot say what it is.
9225        let odd = random_id();
9226        let file = dir.join("s000001").join(archive_name(&odd));
9227        std::fs::write(&file, b"{\"cut\": \"short").unwrap();
9228        std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o600)).unwrap();
9229        let before = snapshot(&dir);
9230        let line = record_bytes(&store, &odd, &text("under a damaged name"), |_| {});
9231        for dry_run in [true, false] {
9232            let stopped = store.import(&line, false, dry_run, &mut ImportSeen::default());
9233            let error = stopped.expect_err("the run stops");
9234            assert_eq!(error.kind(), io::ErrorKind::InvalidData, "{error}");
9235            assert_eq!(snapshot(&dir), before);
9236        }
9237        // A shelf that cannot be opened may hold the id: the run stops too.
9238        std::fs::write(dir.join("s000002"), b"not a directory").unwrap();
9239        let line = record_bytes(&store, &random_id(), &text("behind an odd shelf"), |_| {});
9240        let stopped = store.import(&line, false, false, &mut ImportSeen::default());
9241        assert!(stopped.is_err());
9242        assert_eq!(shelf_files(&dir).len(), 2);
9243    }
9244
9245    /// T-A7: a tombstone that comes in forgets, a forget that is here is
9246    /// kept, and a tombstone of nothing that is here is stored.
9247    #[test]
9248    fn t_a7_tombstones_win_in_both_directions() {
9249        let (tmp, store) = setup();
9250        let dir = directory(&tmp, &store);
9251        let id = random_id();
9252        let live = record_bytes(&store, &id, &text("to be forgotten"), |record| {
9253            record.created_ms = Some(1_600_000_000_000);
9254        });
9255        let dead = record_bytes(&store, &id, &text("to be forgotten"), tombstone);
9256        assert_eq!(import(&store, &live).unwrap().outcome, ImportOutcome::Stored);
9257        let done = import(&store, &dead).unwrap();
9258        assert_eq!((done.outcome, done.live, done.message_count), (ImportOutcome::Forgotten, false, 0));
9259        assert_eq!(done.unread, None);
9260        let file = dir.join("s000001").join(archive_name(&id));
9261        assert!(is_tombstone_of(&store, &file, &digest_of("to be forgotten")));
9262        let fields = ["body", "digest", "id", "logical", "scope", "version"];
9263        assert_eq!(top_level(&json_of(&file)), fields);
9264        assert_eq!(store.fetch(&id, 0, 1).unwrap_err().kind(), io::ErrorKind::NotFound);
9265        // The forget is not undone, and a second tombstone changes nothing.
9266        let before = snapshot(&dir);
9267        let kept = import(&store, &live).unwrap();
9268        assert_eq!((kept.outcome, kept.live), (ImportOutcome::KeptForgotten, true));
9269        let again = import(&store, &dead).unwrap();
9270        assert_eq!(again.outcome, ImportOutcome::Present { note_differs: false });
9271        assert_eq!(snapshot(&dir), before);
9272
9273        // Into a store that has nothing of it.
9274        let (tmp, store) = setup();
9275        let dir = directory(&tmp, &store);
9276        let done = import(&store, &dead).unwrap();
9277        assert_eq!((done.outcome, done.shelf.as_deref()), (ImportOutcome::Stored, Some("s000001")));
9278        let file = dir.join("s000001").join(archive_name(&id));
9279        assert_eq!(std::fs::read(&file).unwrap(), dead);
9280        assert_eq!(store.fetch(&id, 0, 1).unwrap_err().kind(), io::ErrorKind::NotFound);
9281        assert_eq!(import(&store, &live).unwrap().outcome, ImportOutcome::KeptForgotten);
9282
9283        // Content forgotten here under the id a seal gave it is not
9284        // brought back under another id.
9285        let sealed = store.seal(&text("sealed and forgotten"), "").unwrap();
9286        store.forget(&sealed.id).unwrap();
9287        let before = snapshot(&dir);
9288        let line = record_bytes(&store, &random_id(), &text("sealed and forgotten"), |_| {});
9289        assert_eq!(reason(&store, &line), "this content was forgotten in this store");
9290        assert_eq!(snapshot(&dir), before);
9291        // A tombstone for content that is live here under another id is
9292        // refused, and names the id.
9293        let sealed = store.seal(&text("live under its own id"), "").unwrap();
9294        let before = snapshot(&dir);
9295        let line = record_bytes(&store, &random_id(), &text("live under its own id"), tombstone);
9296        let refused = reason(&store, &line);
9297        assert!(refused.contains(&sealed.id) && refused.starts_with("a tombstone of content"));
9298        assert_eq!(snapshot(&dir), before);
9299        assert_eq!(store.fetch(&sealed.id, 0, 1).unwrap(), text("live under its own id"));
9300    }
9301
9302    /// The lines of a run that meets every outcome, for a dry run and the
9303    /// real run to agree on.
9304    fn a_run(chat: &ArchiveStore, scope: &str) -> Vec<Vec<u8>> {
9305        let ids: Vec<_> = (0..7).map(|_| random_id()).collect();
9306        let line = |id: &String, words: &str, change: fn(&mut Record)| {
9307            record_bytes(chat, id, &text(words), |record| {
9308                scope.clone_into(&mut record.scope);
9309                change(record);
9310            })
9311        };
9312        let live = |_: &mut Record| {};
9313        vec![
9314            line(&ids[0], "first", live),
9315            line(&ids[0], "first", live),
9316            line(&ids[0], "other content under the first id", live),
9317            line(&ids[1], "second", live),
9318            line(&ids[1], "second", tombstone),
9319            line(&ids[1], "second", live),
9320            line(&ids[2], "third", tombstone),
9321            line(&ids[2], "third", live),
9322            line(&ids[2], "third", tombstone),
9323            line(&ids[3], "fourth", tombstone),
9324            line(&ids[4], "fourth", live),
9325            line(&ids[5], "fifth", live),
9326            line(&ids[6], "fifth", tombstone),
9327            b"not a record".to_vec(),
9328        ]
9329    }
9330
9331    /// T-A8: a record can be filed under another project, and a dry run
9332    /// writes nothing, anywhere, and answers what the real run then does.
9333    #[test]
9334    fn t_a8_rehoming_and_a_dry_run_that_writes_nothing() {
9335        let (tmp, chat) = setup();
9336        let target = project_scope("another-project").unwrap();
9337        assert_ne!(target, chat.scope);
9338        let line = record_bytes(&chat, &random_id(), &text("moved between projects"), |_| {});
9339        let id = serde_json::from_slice::<Value>(&line).unwrap()["id"].as_str().unwrap().to_owned();
9340        let store = ArchiveStore::for_scope(tmp.path(), &target).unwrap();
9341        let mut seen = ImportSeen::default();
9342        let refused = store.import(&line, false, false, &mut seen).unwrap().unwrap_err();
9343        assert_eq!(refused.reason, "scope is not this project's");
9344        let done = store.import(&line, true, false, &mut seen).unwrap().unwrap();
9345        assert_eq!(done.outcome, ImportOutcome::Stored);
9346        let dir = tmp.path().join("context-archives").join(&target);
9347        let file = only_archive_file(&dir);
9348        let mut expected: Value = serde_json::from_slice(&line).unwrap();
9349        expected["scope"] = json!(target);
9350        assert_eq!(json_of(&file), expected, "only the scope changed");
9351        assert_eq!(store.fetch(&id, 0, 1).unwrap(), text("moved between projects"));
9352        assert_eq!(shelf_files(&directory(&tmp, &chat)), Vec::<PathBuf>::new());
9353
9354        // A base that does not exist: nothing is created by the dry run.
9355        let base = tmp.path().join("no such base");
9356        let lines = a_run(&chat, &chat.scope);
9357        assert!(ArchiveStore::existing_scope(&base, &chat.scope).unwrap().is_none());
9358        let mut seen = ImportSeen::default();
9359        let dry: Vec<_> = lines
9360            .iter()
9361            .map(|line| ArchiveStore::import_dry_absent(&chat.scope, line, false, &mut seen).unwrap())
9362            .collect();
9363        assert!(!base.exists());
9364        let store = ArchiveStore::for_scope(&base, &chat.scope).unwrap();
9365        let mut unused = ImportSeen::default();
9366        let real: Vec<_> =
9367            lines.iter().map(|line| store.import(line, false, false, &mut unused).unwrap()).collect();
9368        assert_eq!(dry, real);
9369        let outcomes: Vec<_> = real
9370            .iter()
9371            .map(|done| match done {
9372                Ok(done) => format!("{:?}", done.outcome),
9373                Err(refused) => refused.reason.clone(),
9374            })
9375            .collect();
9376        assert_eq!(
9377            outcomes[..9],
9378            [
9379                "Stored",
9380                "Present { note_differs: false }",
9381                "id already holds different content",
9382                "Stored",
9383                "Forgotten",
9384                "KeptForgotten",
9385                "Stored",
9386                "KeptForgotten",
9387                "Present { note_differs: false }",
9388            ]
9389        );
9390        assert_eq!(outcomes[9], "Stored");
9391        // The tombstone before it forgot the content under any id.
9392        assert_eq!(outcomes[10], "this content was forgotten in this store");
9393        // A tombstone of content that is live under another imported id.
9394        assert_eq!(outcomes[11], "Stored");
9395        assert!(outcomes[12].starts_with("a tombstone of content that is live in this store as"));
9396        assert!(outcomes[13].starts_with("not an archive record"));
9397
9398        // A dry run over a store that exists: the same answers as running
9399        // it for real again, no mode changed, no lock file made, no byte
9400        // or file changed.
9401        let dir = base.join("context-archives").join(&chat.scope);
9402        std::fs::remove_file(dir.join("lock")).unwrap();
9403        let open = std::fs::Permissions::from_mode(0o755);
9404        let files = shelf_files(&dir);
9405        for path in [dir.clone(), dir.join("s000001")] {
9406            std::fs::set_permissions(&path, open.clone()).unwrap();
9407        }
9408        for file in &files {
9409            std::fs::set_permissions(file, std::fs::Permissions::from_mode(0o640)).unwrap();
9410        }
9411        let before = (snapshot(&dir), entries(&dir));
9412        let existing = ArchiveStore::existing_scope(&base, &chat.scope).unwrap().unwrap();
9413        let mut seen = ImportSeen::default();
9414        let dry: Vec<_> =
9415            lines.iter().map(|line| existing.import(line, false, true, &mut seen).unwrap()).collect();
9416        assert_eq!((snapshot(&dir), entries(&dir)), before);
9417        assert!(!dir.join("lock").exists());
9418        assert_eq!(mode(&dir), 0o755);
9419        assert_eq!(mode(&dir.join("s000001")), 0o755);
9420        for file in &files {
9421            assert_eq!(mode(file), 0o640);
9422        }
9423        for file in &files {
9424            std::fs::set_permissions(file, std::fs::Permissions::from_mode(0o600)).unwrap();
9425        }
9426        let real: Vec<_> =
9427            lines.iter().map(|line| store.import(line, false, false, &mut unused).unwrap()).collect();
9428        assert_eq!(dry, real);
9429        assert!(real[..2].iter().all(|done| {
9430            done.as_ref().unwrap().outcome == ImportOutcome::Present { note_differs: false }
9431        }));
9432    }
9433
9434    /// T-A-shelves: an import fills a shelf and goes on in the next, in the
9435    /// order of its lines, and a dry run names the same shelves.
9436    #[test]
9437    fn t_a_shelves_an_import_goes_on_in_the_next_shelf() {
9438        let (tmp, chat) = setup();
9439        let dir = directory(&tmp, &chat);
9440        let store = ArchiveStore::for_scope(tmp.path(), &chat.scope).unwrap();
9441        let total = SHELF_FILES + 2;
9442        let ids: Vec<_> = (0..total).map(|_| random_id()).collect();
9443        let lines: Vec<_> = ids
9444            .iter()
9445            .enumerate()
9446            .map(|(index, id)| {
9447                record_bytes(&chat, id, &text(&format!("archive {index}")), |record| {
9448                    record.created_ms = Some(1_600_000_000_000 + index as u64);
9449                })
9450            })
9451            .collect();
9452        let mut seen = ImportSeen::default();
9453        let run = |dry_run: bool, seen: &mut ImportSeen| -> Vec<String> {
9454            let mut shelves = Vec::new();
9455            for line in &lines {
9456                let done = store.import(line, false, dry_run, seen).unwrap().unwrap();
9457                shelves.push(done.shelf.unwrap());
9458            }
9459            shelves
9460        };
9461        let before = (snapshot(&dir), entries(&dir));
9462        let dry = run(true, &mut seen);
9463        assert_eq!((snapshot(&dir), entries(&dir)), before);
9464        let real = run(false, &mut seen);
9465        assert_eq!(dry, real);
9466        assert!(real[..SHELF_FILES].iter().all(|shelf| shelf == "s000001"));
9467        assert_eq!(real[SHELF_FILES..], ["s000002", "s000002"]);
9468        let on = |shelf: &str| {
9469            let mut names = entries(&dir.join(shelf));
9470            names.retain(|name| name.ends_with(".json"));
9471            names
9472        };
9473        let mut first: Vec<_> = ids[..SHELF_FILES].iter().map(|id| archive_name(id)).collect();
9474        let mut second: Vec<_> = ids[SHELF_FILES..].iter().map(|id| archive_name(id)).collect();
9475        first.sort();
9476        second.sort();
9477        assert_eq!(on("s000001"), first);
9478        assert_eq!(on("s000002"), second);
9479        // Every one of them is read, on either shelf.
9480        for (index, id) in ids.iter().enumerate() {
9481            assert_eq!(store.fetch(id, 0, 1).unwrap(), text(&format!("archive {index}")));
9482        }
9483        // A second run finds them all, and a dry run of it starts counting
9484        // from what the newest shelf holds.
9485        assert!(run(false, &mut seen).iter().zip(&real).all(|(again, first)| again == first));
9486        let more = record_bytes(&chat, &random_id(), &text("one more"), |_| {});
9487        let mut seen = ImportSeen::default();
9488        let planned = store.import(&more, false, true, &mut seen).unwrap().unwrap();
9489        assert_eq!(planned.shelf.as_deref(), Some("s000002"));
9490        // A seal afterwards goes to the newest shelf.
9491        let sealed = chat.seal(&text("sealed after the import"), "").unwrap();
9492        assert!(dir.join("s000002").join(archive_name(&sealed.id)).is_file());
9493        assert_eq!(on("s000002").len(), 3);
9494    }
9495
9496    /// T-A9: a tombstone stays, also for an archive whose id is not derived
9497    /// from its digest. Forgotten, its content is refused to a later seal by
9498    /// a tombstone under the digest's own id, on the newest shelf.
9499    #[test]
9500    fn t_a9_a_forgotten_import_cannot_be_sealed_again() {
9501        let words = "imported and then forgotten";
9502        let digest = digest_of(words);
9503        let refused = |store: &ArchiveStore| {
9504            let error = store.seal(&text(words), "").unwrap_err();
9505            assert_eq!(error.kind(), io::ErrorKind::PermissionDenied, "{error}");
9506            assert!(error.to_string().contains("was forgotten"), "{error}");
9507        };
9508
9509        // A live import, on a shelf that is no longer the newest.
9510        let (tmp, store) = setup();
9511        let dir = directory(&tmp, &store);
9512        let id = random_id();
9513        let line = record_bytes(&store, &id, &text(words), |_| {});
9514        assert_eq!(import(&store, &line).unwrap().outcome, ImportOutcome::Stored);
9515        std::fs::create_dir(dir.join("s000002")).unwrap();
9516        std::fs::set_permissions(dir.join("s000002"), std::fs::Permissions::from_mode(0o700))
9517            .unwrap();
9518        assert_eq!(store.forget(&id).unwrap(), forgotten(&[&id]));
9519        let guard = dir.join("s000002").join(archive_name(&digest[..32]));
9520        let file = dir.join("s000001").join(archive_name(&id));
9521        assert_eq!(shelf_files(&dir), [file.clone(), guard.clone()]);
9522        assert!(is_tombstone_of(&store, &guard, &digest));
9523        assert!(is_tombstone_of(&store, &file, &digest));
9524        refused(&store);
9525        // Importing the line again does not bring it back either.
9526        assert_eq!(import(&store, &line).unwrap().outcome, ImportOutcome::KeptForgotten);
9527        let other = record_bytes(&store, &random_id(), &text(words), |_| {});
9528        assert_eq!(reason(&store, &other), "this content was forgotten in this store");
9529        assert_eq!(shelf_files(&dir).len(), 2);
9530
9531        // A tombstone with a foreign id into an empty store: the one a seal
9532        // finds is written with it, first.
9533        let (tmp, store) = setup();
9534        let dir = directory(&tmp, &store);
9535        let dead = record_bytes(&store, &id, &text(words), tombstone);
9536        let done = import(&store, &dead).unwrap();
9537        assert_eq!((done.outcome, done.shelf.as_deref()), (ImportOutcome::Stored, Some("s000001")));
9538        let guard = dir.join("s000001").join(archive_name(&digest[..32]));
9539        let file = dir.join("s000001").join(archive_name(&id));
9540        let mut both = [file.clone(), guard.clone()];
9541        both.sort();
9542        assert_eq!(shelf_files(&dir), both);
9543        assert!(is_tombstone_of(&store, &guard, &digest));
9544        assert_eq!(std::fs::read(&file).unwrap(), dead);
9545        refused(&store);
9546        assert_eq!(reason(&store, &other), "this content was forgotten in this store");
9547        // The same line again, and a tombstone of it under one more id: no
9548        // second guard.
9549        assert_eq!(import(&store, &dead).unwrap().outcome, ImportOutcome::Present { note_differs: false });
9550        let again = record_bytes(&store, &random_id(), &text(words), tombstone);
9551        assert_eq!(import(&store, &again).unwrap().outcome, ImportOutcome::Stored);
9552        assert_eq!(shelf_files(&dir).len(), 3);
9553        // A tombstone under the digest's own id is the one a seal finds.
9554        let (tmp, store) = setup();
9555        let own = record_bytes(&store, &digest[32..], &text(words), tombstone);
9556        assert_eq!(import(&store, &own).unwrap().outcome, ImportOutcome::Stored);
9557        assert_eq!(shelf_files(&directory(&tmp, &store)).len(), 1);
9558        refused(&store);
9559
9560        // A foreign-id tombstone written by hand, with nothing under either
9561        // derived id: forgetting it again writes the one a seal finds.
9562        let (tmp, store) = setup();
9563        let dir = directory(&tmp, &store);
9564        store.seal(&text("a shelf to begin with"), "").unwrap();
9565        let file = dir.join("s000001").join(archive_name(&id));
9566        std::fs::write(&file, &dead).unwrap();
9567        std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o600)).unwrap();
9568        assert_eq!(store.seal(&text(words), "").unwrap().id, digest[..32], "not seen by a seal");
9569        let (tmp, store) = (drop(tmp), setup()).1;
9570        let dir = directory(&tmp, &store);
9571        store.seal(&text("a shelf to begin with"), "").unwrap();
9572        let file = dir.join("s000001").join(archive_name(&id));
9573        std::fs::write(&file, &dead).unwrap();
9574        std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o600)).unwrap();
9575        assert_eq!(store.forget(&id).unwrap(), forgotten(&[]));
9576        assert!(is_tombstone_of(&store, &dir.join("s000001").join(archive_name(&digest[..32])), &digest));
9577        refused(&store);
9578
9579        // An archive its chat sealed: forgotten, it is its own tombstone.
9580        let (tmp, store) = setup();
9581        let sealed = store.seal(&text(words), "").unwrap();
9582        store.forget(&sealed.id).unwrap();
9583        assert_eq!(shelf_files(&directory(&tmp, &store)).len(), 1);
9584        refused(&store);
9585    }
9586
9587    /// T-A9, the pair an import makes on purpose (`same_content_as`): the
9588    /// content under the id its chat's seal gave it and under an imported
9589    /// id. Forgetting either id forgets both, and writes no third file.
9590    #[test]
9591    fn t_a9_forgetting_either_of_a_native_and_an_imported_copy_forgets_both() {
9592        let words = "sealed here and imported too";
9593        for forget_the_import in [true, false] {
9594            let (tmp, store) = setup();
9595            let dir = directory(&tmp, &store);
9596            let sealed = store.seal(&text(words), "").unwrap();
9597            // The import is on a newer shelf than the native copy, with
9598            // other archives around it.
9599            for index in 0..SHELF_FILES {
9600                store.seal(&text(&format!("filler {index}")), "").unwrap();
9601            }
9602            let id = random_id();
9603            let done = import(&store, &record_bytes(&store, &id, &text(words), |_| {})).unwrap();
9604            assert_eq!(done.same_content_as.as_deref(), Some(sealed.id.as_str()));
9605            assert_eq!(done.shelf.as_deref(), Some("s000002"));
9606            let before = shelf_files(&dir);
9607            let asked = if forget_the_import { &id } else { &sealed.id };
9608            let done = store.forget(asked).unwrap();
9609            let mut ids = done.ids.clone();
9610            ids.sort();
9611            let mut expected = [id.clone(), sealed.id.clone()];
9612            expected.sort();
9613            assert_eq!(ids, expected, "forgetting {asked}");
9614            assert_eq!(done.ids[0], *asked);
9615            assert_eq!(done.unread, None);
9616            assert_eq!(shelf_files(&dir), before, "no third file");
9617            let digest = digest_of(words);
9618            assert!(is_tombstone_of(&store, &dir.join("s000001").join(archive_name(&sealed.id)), &digest));
9619            assert!(is_tombstone_of(&store, &dir.join("s000002").join(archive_name(&id)), &digest));
9620            for gone in [&id, &sealed.id] {
9621                assert_eq!(store.fetch(gone, 0, 1).unwrap_err().kind(), io::ErrorKind::NotFound);
9622            }
9623            assert!(store.search("imported too", 8).unwrap().is_empty());
9624            assert_eq!(
9625                store.seal(&text(words), "").unwrap_err().kind(),
9626                io::ErrorKind::PermissionDenied
9627            );
9628            // Nothing else was touched.
9629            assert_eq!(store.fetch(&store.seal(&text("filler 0"), "").unwrap().id, 0, 1).unwrap(), text("filler 0"));
9630            // Forgetting again: nothing left to do, and it says so.
9631            assert_eq!(store.forget(asked).unwrap(), forgotten(&[]));
9632        }
9633    }
9634
9635    /// A forget reads other files for their digest only: a file under
9636    /// another id that names this content and is not a record makes the
9637    /// forget "not complete"; any other file there is none of its business.
9638    #[test]
9639    fn a_forget_says_when_a_file_under_another_id_names_its_content_and_is_no_record() {
9640        let (tmp, store) = setup();
9641        let dir = directory(&tmp, &store);
9642        let sealed = store.seal(&text("with a damaged twin"), "").unwrap();
9643        let shelf = dir.join("s000001");
9644        let private = std::fs::Permissions::from_mode(0o600);
9645        let stray = shelf.join(archive_name(&random_id()));
9646        std::fs::write(&stray, b"not json at all").unwrap();
9647        std::fs::set_permissions(&stray, private.clone()).unwrap();
9648        let twin = shelf.join(archive_name(&random_id()));
9649        let damaged = format!("{{\"digest\":\"{}\",\"body\":\"cut\"}}", digest_of("with a damaged twin"));
9650        std::fs::write(&twin, damaged).unwrap();
9651        std::fs::set_permissions(&twin, private).unwrap();
9652        let done = store.forget(&sealed.id).unwrap();
9653        assert_eq!(done.ids, std::slice::from_ref(&sealed.id));
9654        let unread = done.unread.expect("not complete");
9655        let name = twin.file_name().unwrap().to_str().unwrap();
9656        assert!(unread.starts_with(&format!("1 place(s) could not be read, first s000001/{name}: ")), "{unread}");
9657        // With the folder the place is in: the file can be gone to.
9658        assert!(unread.ends_with(&format!(" (in {})", dir.display())), "{unread}");
9659        assert_eq!(std::fs::read(&stray).unwrap(), b"not json at all");
9660    }
9661
9662    /// A tombstone line whose id holds nothing is refused while its content
9663    /// is live under any other id, also one a seal does not look under: the
9664    /// scan a forget uses finds it. In either order of the two lines, and
9665    /// the dry run says the same.
9666    #[test]
9667    fn a_tombstone_line_is_refused_while_its_content_is_live_under_another_imported_id() {
9668        let words = "live under an imported id";
9669        let (a, b) = (random_id(), random_id());
9670        let refusal = format!(
9671            "a tombstone of content that is live in this store as {a}: forget that archive \
9672             here, then import again"
9673        );
9674        for dry_first in [false, true] {
9675            let (tmp, store) = setup();
9676            let dir = directory(&tmp, &store);
9677            let live = record_bytes(&store, &a, &text(words), |_| {});
9678            let dead = record_bytes(&store, &b, &text(words), tombstone);
9679            let mut seen = ImportSeen::default();
9680            if dry_first {
9681                // Both lines in one dry run: the live copy is in the plan.
9682                let planned = store.import(&live, false, true, &mut seen).unwrap().unwrap();
9683                assert_eq!(planned.outcome, ImportOutcome::Stored);
9684                let refused = store.import(&dead, false, true, &mut seen).unwrap().unwrap_err();
9685                assert_eq!(refused.reason, refusal);
9686                assert_eq!(shelf_files(&dir), Vec::<PathBuf>::new());
9687            }
9688            assert_eq!(import(&store, &live).unwrap().outcome, ImportOutcome::Stored);
9689            let before = snapshot(&dir);
9690            // The live copy is on disk: a dry run and the real run find it.
9691            for dry_run in [true, false] {
9692                let mut seen = ImportSeen::default();
9693                let refused = store.import(&dead, false, dry_run, &mut seen).unwrap().unwrap_err();
9694                assert_eq!((refused.reason.as_str(), refused.id.as_deref()), (&*refusal, Some(&*b)));
9695                assert_eq!(snapshot(&dir), before);
9696            }
9697            assert_eq!(store.fetch(&a, 0, 1).unwrap(), text(words));
9698            // Also from a shelf that is not the newest.
9699            std::fs::create_dir(dir.join("s000002")).unwrap();
9700            assert_eq!(reason(&store, &dead), refusal);
9701            assert_eq!(store.fetch(&a, 0, 1).unwrap(), text(words));
9702        }
9703        // The other order: the tombstone is stored, the live line is not.
9704        let (_tmp, store) = setup();
9705        let live = record_bytes(&store, &a, &text(words), |_| {});
9706        let dead = record_bytes(&store, &b, &text(words), tombstone);
9707        for dry_run in [true, false] {
9708            let mut seen = ImportSeen::default();
9709            let stored = store.import(&dead, false, dry_run, &mut seen).unwrap().unwrap();
9710            assert_eq!(stored.outcome, ImportOutcome::Stored);
9711            let refused = match dry_run {
9712                true => store.import(&live, false, true, &mut seen).unwrap().unwrap_err(),
9713                false => import(&store, &live).unwrap_err(),
9714            };
9715            assert_eq!(refused.reason, "this content was forgotten in this store");
9716        }
9717        assert_eq!(store.fetch(&a, 0, 1).unwrap_err().kind(), io::ErrorKind::NotFound);
9718    }
9719
9720    /// A file under another id that a forget fails to read may be a live
9721    /// copy: the forget is "not complete" and names it, and an imported
9722    /// tombstone of that content stops. What the file is (a link, closed to
9723    /// its owner) is not such a failure and is passed over.
9724    #[test]
9725    fn a_forget_is_not_complete_over_a_file_under_another_id_it_failed_to_read() {
9726        let words = "a twin that is not read";
9727        let (tmp, store) = setup();
9728        let dir = directory(&tmp, &store);
9729        let sealed = store.seal(&text(words), "").unwrap();
9730        let twin = random_id();
9731        let line = record_bytes(&store, &twin, &text(words), |_| {});
9732        assert_eq!(import(&store, &line).unwrap().outcome, ImportOutcome::Stored);
9733        let name = archive_name(&twin);
9734        let dead = record_bytes(&store, &random_id(), &text("only the twin"), tombstone);
9735        let lone_id = random_id();
9736        let lone = record_bytes(&store, &lone_id, &text("only the twin"), |_| {});
9737        assert_eq!(import(&store, &lone).unwrap().outcome, ImportOutcome::Stored);
9738        for code in [libc::EIO, libc::EMFILE, libc::ENOMEM] {
9739            let failing = Fault::on("open", &twin, code);
9740            let done = store.forget(&sealed.id).unwrap();
9741            let unread = done.unread.expect("not complete");
9742            let start = format!("1 place(s) could not be read, first s000001/{name}: ");
9743            assert!(unread.starts_with(&start), "{unread}");
9744            drop(failing);
9745            assert_eq!(store.fetch(&twin, 0, 1).unwrap(), text(words), "error {code}");
9746            // The import's scan is the same one: it does not answer "none".
9747            let failing = Fault::on("open", &lone_id, code);
9748            let before = snapshot(&dir);
9749            let stopped = store.import(&dead, false, false, &mut ImportSeen::default());
9750            let error = stopped.expect_err("the run stops").to_string();
9751            assert!(error.starts_with("a tombstone is not imported while"), "{error}");
9752            drop(failing);
9753            assert_eq!(snapshot(&dir), before);
9754        }
9755        // Read again, the twin is reached.
9756        let done = store.forget(&sealed.id).unwrap();
9757        assert_eq!((done.ids, done.unread), (vec![twin.clone()], None));
9758        assert_eq!(store.fetch(&twin, 0, 1).unwrap_err().kind(), io::ErrorKind::NotFound);
9759
9760        // A link under another id is no file of the store's.
9761        let (tmp, store) = setup();
9762        let dir = directory(&tmp, &store);
9763        let sealed = store.seal(&text(words), "").unwrap();
9764        let link = dir.join("s000001").join(archive_name(&random_id()));
9765        std::os::unix::fs::symlink(only_archive_file(&dir), &link).unwrap();
9766        // So is one that is closed to its owner (nothing is closed to root).
9767        let closed = dir.join("s000001").join(archive_name(&random_id()));
9768        if unsafe { libc::geteuid() } != 0 {
9769            std::fs::write(&closed, record_bytes(&store, &random_id(), &text("x"), |_| {})).unwrap();
9770            std::fs::set_permissions(&closed, std::fs::Permissions::from_mode(0o000)).unwrap();
9771        }
9772        let done = store.forget(&sealed.id).unwrap();
9773        assert_eq!((done.ids, done.unread), (vec![sealed.id.clone()], None));
9774    }
9775
9776    /// A `created_ms` more than a day after the clock is refused, and
9777    /// nothing is written; one within the day is stored.
9778    #[test]
9779    fn a_created_ms_that_has_not_come_is_refused() {
9780        let (tmp, store) = setup();
9781        let dir = directory(&tmp, &store);
9782        let hour = 60 * 60 * 1000;
9783        let at = |ahead_ms: u64| {
9784            record_bytes(&store, &random_id(), &text("dated ahead"), |record| {
9785                record.created_ms = Some(now_ms() + ahead_ms);
9786            })
9787        };
9788        for line in [at(25 * hour), at(u64::MAX / 2), at(366 * 24 * hour)] {
9789            let refused = import(&store, &line).unwrap_err();
9790            assert_eq!(refused.reason, "created_ms is more than a day after this machine's clock");
9791            let mut seen = ImportSeen::default();
9792            let dry = store.import(&line, false, true, &mut seen).unwrap().unwrap_err();
9793            assert_eq!(dry, refused);
9794        }
9795        assert_eq!(shelf_files(&dir), Vec::<PathBuf>::new());
9796        assert_eq!(import(&store, &at(23 * hour)).unwrap().outcome, ImportOutcome::Stored);
9797    }
9798
9799    /// The old time goes on the file after it has its name. A write stopped
9800    /// between the two leaves the whole record under its id with the time
9801    /// of the write and no temporary file: nothing dated old was ever under
9802    /// a temporary name. The next run finds it there.
9803    #[test]
9804    fn an_imported_file_gets_its_old_time_after_its_name() {
9805        let (tmp, store) = setup();
9806        let dir = directory(&tmp, &store);
9807        let id = random_id();
9808        let line = record_bytes(&store, &id, &text("dated after the rename"), |record| {
9809            record.created_ms = Some(1_600_000_000_000);
9810        });
9811        let started = std::time::SystemTime::now() - Duration::from_secs(5);
9812        let stopping = Fault::on("date", &id, libc::EIO);
9813        let stopped = store.import(&line, false, false, &mut ImportSeen::default());
9814        assert_eq!(stopped.expect_err("stopped").raw_os_error(), Some(libc::EIO));
9815        drop(stopping);
9816        let file = dir.join("s000001").join(archive_name(&id));
9817        assert_eq!(shelf_files(&dir), std::slice::from_ref(&file));
9818        assert_eq!(entries(&dir.join("s000001")), [archive_name(&id)]);
9819        assert!(std::fs::metadata(&file).unwrap().modified().unwrap() >= started);
9820        assert_eq!(std::fs::read(&file).unwrap(), line);
9821        assert_eq!(store.fetch(&id, 0, 1).unwrap(), text("dated after the rename"));
9822        let again = import(&store, &line).unwrap();
9823        assert_eq!(again.outcome, ImportOutcome::Present { note_differs: false });
9824        // Not stopped, the file has the record's time.
9825        let other = random_id();
9826        let line = record_bytes(&store, &other, &text("dated and not stopped"), |record| {
9827            record.created_ms = Some(1_600_000_000_000);
9828        });
9829        assert_eq!(import(&store, &line).unwrap().outcome, ImportOutcome::Stored);
9830        let file = dir.join("s000001").join(archive_name(&other));
9831        assert_eq!(std::fs::metadata(&file).unwrap().modified().unwrap(), at_ms(1_600_000_000_000));
9832    }
9833
9834    /// A real run that answers "already here" syncs the shelf the file is
9835    /// on first: a run before it may have been cut between its rename and
9836    /// the sync. A dry run touches nothing, so it does not.
9837    #[test]
9838    fn an_import_that_finds_its_record_confirms_it_on_disk() {
9839        let (_tmp, store) = setup();
9840        let id = random_id();
9841        let live = record_bytes(&store, &id, &text("found and confirmed"), |_| {});
9842        let gone = random_id();
9843        let dead = record_bytes(&store, &gone, &text("kept forgotten"), tombstone);
9844        let kept = record_bytes(&store, &gone, &text("kept forgotten"), |_| {});
9845        assert_eq!(import(&store, &live).unwrap().outcome, ImportOutcome::Stored);
9846        assert_eq!(import(&store, &dead).unwrap().outcome, ImportOutcome::Stored);
9847        let present = ImportOutcome::Present { note_differs: false };
9848        for (line, outcome) in [(&live, present), (&kept, ImportOutcome::KeptForgotten)] {
9849            let failing = Fault::on("sync", "", libc::EIO);
9850            let mut seen = ImportSeen::default();
9851            let stopped = store.import(line, false, false, &mut seen);
9852            assert_eq!(stopped.expect_err("synced").raw_os_error(), Some(libc::EIO));
9853            let dry = store.import(line, false, true, &mut seen).unwrap().unwrap();
9854            assert_eq!(dry.outcome, outcome);
9855            drop(failing);
9856            assert_eq!(import(&store, line).unwrap().outcome, outcome);
9857        }
9858    }
9859
9860    /// A base that is a link to a folder that is away (a disk that is not
9861    /// mounted) is an error to every read: not a project with no archives.
9862    /// A base that is not there at all is still "no store".
9863    #[test]
9864    fn a_base_that_links_to_a_folder_that_is_away_is_an_error_not_an_empty_store() {
9865        let tmp = tempfile::tempdir().unwrap();
9866        let root = std::fs::canonicalize(tmp.path()).unwrap();
9867        let disk = root.join("disk");
9868        let base = root.join("base");
9869        std::fs::create_dir(&disk).unwrap();
9870        symlink(&disk, &base).unwrap();
9871        let store = setup_in(&base, "synthetic-project");
9872        let sealed = store.seal(&text("on the other disk"), "").unwrap();
9873        drop(store);
9874        std::fs::rename(&disk, root.join("unmounted")).unwrap();
9875        let away = ArchiveStore::open_existing(&base, "synthetic-project", "l").err().expect("away");
9876        assert_ne!(away.kind(), io::ErrorKind::NotFound);
9877        assert!(away.to_string().starts_with("the archive base is a link to a folder that is not there"));
9878        assert!(ArchiveStore::existing_scope(&base, &project_scope("synthetic-project").unwrap()).is_err());
9879        assert!(project_search(&base, "synthetic-project", "", 8).is_err());
9880        let fetched = project_fetch(&base, "synthetic-project", &sealed.id, 0, 1).unwrap_err();
9881        assert_ne!(fetched.kind(), io::ErrorKind::NotFound);
9882        assert!(archive_file(&base, "synthetic-project", &sealed.id).is_err());
9883        // Back again, it reads.
9884        std::fs::rename(root.join("unmounted"), &disk).unwrap();
9885        assert_eq!(project_search(&base, "synthetic-project", "", 8).unwrap().len(), 1);
9886        // No base, and a base below a folder that is not there: no store.
9887        for missing in [root.join("no-base"), root.join("no-folder/base")] {
9888            assert!(ArchiveStore::open_existing(&missing, "synthetic-project", "l").unwrap().is_none());
9889            assert!(project_search(&missing, "synthetic-project", "", 8).unwrap().is_empty());
9890        }
9891    }
9892}
9893
9894#[cfg(all(test, unix))]
9895mod lock_tests {
9896    use super::*;
9897    use std::time::{Duration, Instant};
9898
9899    fn msg(role: &str, content: Value) -> SharedMessage {
9900        Arc::new(json!({"role":role,"content":content}))
9901    }
9902    fn setup() -> (tempfile::TempDir, ArchiveStore) {
9903        let tmp = tempfile::tempdir().unwrap();
9904        let store =
9905            ArchiveStore::new(tmp.path(), "synthetic-project", "synthetic-logical").unwrap();
9906        (tmp, store)
9907    }
9908
9909    /// Wait (bounded) for a condition another thread establishes.
9910    fn wait_until(condition: impl Fn() -> bool) {
9911        let started = Instant::now();
9912        while !condition() {
9913            assert!(started.elapsed() < Duration::from_secs(30), "timed out");
9914            std::thread::sleep(Duration::from_millis(1));
9915        }
9916    }
9917
9918    /// The process-local gate of `store`'s archive directory.
9919    fn gate_of(store: &ArchiveStore) -> Arc<Gate> {
9920        Gate::for_directory(store.fs.identity().unwrap())
9921    }
9922
9923    /// A flock belongs to the open file description. A child forked by another
9924    /// thread holds copies of every descriptor (O_CLOEXEC applies only at
9925    /// exec), so closing our copy alone does not release the lock. The child
9926    /// stays between fork and exec until the parent has sealed again, without
9927    /// waiting: the lock has to be free already.
9928    #[test]
9929    fn a_descriptor_inherited_by_a_forked_child_does_not_keep_the_archive_locked() {
9930        use std::io::{Read, Write};
9931        use std::os::fd::AsRawFd;
9932        use std::os::unix::process::CommandExt;
9933        use std::process::ExitStatus;
9934        use std::thread::JoinHandle;
9935        /// Releases the child and reaps it on every path, unwinding included.
9936        struct Child {
9937            release: Option<io::PipeWriter>,
9938            spawner: Option<JoinHandle<io::Result<ExitStatus>>>,
9939        }
9940        impl Child {
9941            fn release_and_reap(&mut self) -> Option<std::thread::Result<io::Result<ExitStatus>>> {
9942                // The byte, or failing that EOF, ends the child's wait.
9943                if let Some(mut release) = self.release.take() {
9944                    let _ = release.write_all(b"r");
9945                }
9946                // The spawner returns once the child has exited and is reaped.
9947                self.spawner.take().map(JoinHandle::join)
9948            }
9949        }
9950        impl Drop for Child {
9951            fn drop(&mut self) {
9952                let _ = self.release_and_reap();
9953            }
9954        }
9955        let (_tmp, mut store) = setup();
9956        store.process_wait = Duration::ZERO;
9957        let reference = store
9958            .seal(&[msg("user", json!("evidence"))], "note")
9959            .unwrap();
9960        // Close-on-exec, like every descriptor the archive opens.
9961        let (mut forked, signal) = io::pipe().unwrap();
9962        let (hold, release) = io::pipe().unwrap();
9963        let (signal_fd, hold_fd) = (signal.as_raw_fd(), hold.as_raw_fd());
9964        let held = store.fs.lock().unwrap();
9965        let mut child = Child {
9966            release: Some(release),
9967            spawner: Some(std::thread::spawn(move || {
9968                let mut command = std::process::Command::new("true");
9969                // Only async-signal-safe calls between fork and exec.
9970                unsafe {
9971                    command.pre_exec(move || {
9972                        let interrupted =
9973                            || io::Error::last_os_error().raw_os_error() == Some(libc::EINTR);
9974                        loop {
9975                            match libc::write(signal_fd, b"f".as_ptr().cast(), 1) {
9976                                1 => break,
9977                                -1 if interrupted() => {}
9978                                // EOF on the parent's side fails the test.
9979                                _ => libc::_exit(1),
9980                            }
9981                        }
9982                        let mut byte = 0u8;
9983                        while libc::read(hold_fd, (&mut byte as *mut u8).cast(), 1) < 0
9984                            && interrupted()
9985                        {}
9986                        Ok(())
9987                    });
9988                }
9989                let status = command.status();
9990                drop((signal, hold));
9991                status
9992            })),
9993        };
9994        // Bounded: a child that never signals fails the test, and the guard
9995        // releases and reaps it.
9996        let deadline = Instant::now() + Duration::from_secs(30);
9997        loop {
9998            let mut ready = libc::pollfd {
9999                fd: forked.as_raw_fd(),
10000                events: libc::POLLIN,
10001                revents: 0,
10002            };
10003            let left = deadline.saturating_duration_since(Instant::now());
10004            let timeout = left.as_millis().try_into().unwrap_or(i32::MAX);
10005            match unsafe { libc::poll(&mut ready, 1, timeout) } {
10006                1 => break,
10007                0 => panic!("the forked child never signaled"),
10008                _ => assert_eq!(io::Error::last_os_error().raw_os_error(), Some(libc::EINTR)),
10009            }
10010        }
10011        forked.read_exact(&mut [0; 1]).unwrap();
10012        drop(held);
10013        // The child is blocked before exec, holding an inherited copy.
10014        let sealed = store.seal(&[msg("user", json!("more evidence"))], "");
10015        let status = child.release_and_reap().unwrap().unwrap().unwrap();
10016        assert!(status.success());
10017        assert_ne!(sealed.unwrap(), reference);
10018    }
10019
10020    /// After waiting, an operation past its deadline is refused even when the
10021    /// gate has become free by the time it rechecks.
10022    #[test]
10023    fn an_expired_waiter_is_not_admitted_when_the_gate_frees_late() {
10024        let gate = Gate::for_directory((u64::MAX, u64::MAX));
10025        let holder = Gate::enter(gate.clone(), Duration::ZERO, None, BUSY).unwrap();
10026        let wait = Duration::from_millis(50);
10027        let waiter = {
10028            let gate = gate.clone();
10029            std::thread::spawn(move || Gate::enter(gate, wait, None, BUSY).map(drop))
10030        };
10031        wait_until(|| gate.waited.load(Ordering::Acquire) == 1);
10032        {
10033            // Free the gate but keep the waiter from rechecking until its
10034            // deadline has certainly passed.
10035            let mut held = gate.held.lock().unwrap();
10036            *held = false;
10037            std::thread::sleep(wait * 2);
10038        }
10039        let error = waiter.join().unwrap().unwrap_err();
10040        assert_eq!(error.kind(), io::ErrorKind::WouldBlock);
10041        assert_eq!(error.to_string(), BUSY);
10042        drop(holder);
10043        // An uncontended first attempt is still admitted at once.
10044        Gate::enter(gate, Duration::ZERO, None, BUSY).unwrap();
10045    }
10046
10047    #[test]
10048    fn concurrent_operations_in_one_process_wait_instead_of_reporting_busy() {
10049        let tmp = tempfile::tempdir().unwrap();
10050        let workers = 8;
10051        let barrier = Arc::new(std::sync::Barrier::new(workers));
10052        let handles: Vec<_> = (0..workers)
10053            .map(|worker| {
10054                let base = tmp.path().to_path_buf();
10055                let barrier = barrier.clone();
10056                std::thread::spawn(move || {
10057                    let logical = format!("synthetic-logical-{worker}");
10058                    let store = ArchiveStore::new(&base, "synthetic-project", &logical)?;
10059                    barrier.wait();
10060                    for round in 0..8 {
10061                        let text = format!("worker {worker} round {round}");
10062                        let reference = store.seal(&[msg("user", json!(text))], "note")?;
10063                        assert_eq!(store.fetch(&reference.id, 0, 1)?.len(), 1);
10064                        assert_eq!(store.fetch_note(&reference.id)?, "note");
10065                        store.search("worker", 4)?;
10066                    }
10067                    Ok::<_, io::Error>(())
10068                })
10069            })
10070            .collect();
10071        let results: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
10072        for result in results {
10073            result.unwrap();
10074        }
10075        let store = ArchiveStore::new(tmp.path(), "synthetic-project", "synthetic-reader").unwrap();
10076        // Every seal is on a shelf.
10077        let dir = tmp.path().join("context-archives").join(&store.scope);
10078        let segments: usize = std::fs::read_dir(&dir)
10079            .unwrap()
10080            .map(|entry| entry.unwrap().path())
10081            .filter(|path| path.is_dir())
10082            .map(|shelf| std::fs::read_dir(shelf).unwrap().count())
10083            .sum();
10084        assert_eq!(segments, workers * 8);
10085    }
10086
10087    /// A separate open file description is what another process holds. With
10088    /// no time to wait for it, a seal and a forget fail closed at once.
10089    #[test]
10090    fn a_lock_held_outside_this_process_fails_closed_at_once_when_the_wait_is_zero() {
10091        let (_tmp, mut store) = setup();
10092        store.process_wait = Duration::ZERO;
10093        let reference = store
10094            .seal(&[msg("user", json!("evidence"))], "note")
10095            .unwrap();
10096        let foreign = store.fs.lock().unwrap();
10097        let started = Instant::now();
10098        let results = [
10099            store
10100                .seal(&[msg("user", json!("other evidence"))], "")
10101                .map(drop),
10102            store.forget(&reference.id).map(drop),
10103        ];
10104        for result in results {
10105            assert_eq!(result.unwrap_err().kind(), io::ErrorKind::WouldBlock);
10106        }
10107        assert!(started.elapsed() < PROCESS_LOCK_WAIT);
10108        drop(foreign);
10109        assert_eq!(store.fetch(&reference.id, 0, 1).unwrap().len(), 1);
10110    }
10111
10112    fn other(tmp: &tempfile::TempDir) -> ArchiveStore {
10113        ArchiveStore::new(tmp.path(), "synthetic-project", "synthetic-other").unwrap()
10114    }
10115
10116    #[test]
10117    fn an_operation_queues_behind_the_one_in_progress_in_this_process() {
10118        let (tmp, store) = setup();
10119        let held = store.exclusive().unwrap();
10120        let queued = other(&tmp);
10121        let waiter =
10122            std::thread::spawn(move || queued.seal(&[msg("user", json!("queued evidence"))], ""));
10123        let gate = gate_of(&store);
10124        wait_until(|| gate.waited.load(Ordering::Acquire) == 1);
10125        assert!(!waiter.is_finished());
10126        drop(held);
10127        let reference = waiter.join().unwrap().unwrap();
10128        assert_eq!(store.fetch(&reference.id, 0, 1).unwrap().len(), 1);
10129    }
10130
10131    #[test]
10132    fn a_stuck_operation_in_this_process_fails_closed_after_the_bounded_wait() {
10133        let (tmp, store) = setup();
10134        let held = store.exclusive().unwrap();
10135        let mut queued = other(&tmp);
10136        queued.wait = Duration::from_millis(50);
10137        let started = Instant::now();
10138        let evidence = [msg("user", json!("queued evidence"))];
10139        let error = queued.seal(&evidence, "").unwrap_err();
10140        assert_eq!(error.kind(), io::ErrorKind::WouldBlock);
10141        assert_eq!(error.to_string(), BUSY);
10142        assert!(started.elapsed() >= queued.wait);
10143        drop(held);
10144        assert!(queued.search("", 1).unwrap().is_empty());
10145        queued.seal(&evidence, "").unwrap();
10146    }
10147
10148    #[test]
10149    fn an_aborted_operation_never_starts_even_while_queued() {
10150        let (tmp, store) = setup();
10151        let abort = Arc::new(AtomicBool::new(true));
10152        let error = other(&tmp)
10153            .with_abort(abort.clone())
10154            .seal(&[msg("user", json!("abandoned before start"))], "")
10155            .unwrap_err();
10156        assert_eq!(error.kind(), io::ErrorKind::Interrupted);
10157
10158        abort.store(false, Ordering::Release);
10159        let held = store.exclusive().unwrap();
10160        let queued = other(&tmp).with_abort(abort.clone());
10161        let waiter = std::thread::spawn(move || {
10162            queued.seal(&[msg("user", json!("abandoned while queued"))], "")
10163        });
10164        let gate = gate_of(&store);
10165        wait_until(|| gate.waited.load(Ordering::Acquire) == 1);
10166        assert!(!waiter.is_finished());
10167        abort.store(true, Ordering::Release);
10168        // Returns while the earlier operation still holds the archive.
10169        let error = waiter.join().unwrap().unwrap_err();
10170        assert_eq!(error.kind(), io::ErrorKind::Interrupted);
10171        drop(held);
10172        assert!(store.search("", 1).unwrap().is_empty());
10173    }
10174
10175    #[test]
10176    fn seal_with_note_returns_the_note_stored_with_the_segment() {
10177        let (_tmp, store) = setup();
10178        let messages = [msg("user", json!("evidence"))];
10179        let (first, note) = store.seal_with_note(&messages, "first note").unwrap();
10180        assert_eq!(note, "first note");
10181        let (again, note) = store.seal_with_note(&messages, "second note").unwrap();
10182        assert_eq!(again, first);
10183        assert_eq!(note, "first note");
10184        assert_eq!(store.fetch_note(&first.id).unwrap(), "first note");
10185        store.forget(&first.id).unwrap();
10186        let error = store.seal_with_note(&messages, "").unwrap_err();
10187        assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
10188    }
10189
10190    /// The names in `store`'s project directory, sorted.
10191    fn project_entries(tmp: &tempfile::TempDir, store: &ArchiveStore) -> Vec<String> {
10192        let dir = tmp.path().join("context-archives").join(&store.scope);
10193        let entries = std::fs::read_dir(dir).unwrap();
10194        let mut names: Vec<_> = entries
10195            .map(|entry| entry.unwrap().file_name().into_string().unwrap())
10196            .collect();
10197        names.sort();
10198        names
10199    }
10200
10201    /// Hold the archive lock for `hold`, as another process would: through a
10202    /// separate open file description.
10203    fn foreign_lock_for(store: &ArchiveStore, hold: Duration) -> std::thread::JoinHandle<()> {
10204        let foreign = store.fs.lock().unwrap();
10205        std::thread::spawn(move || {
10206            std::thread::sleep(hold);
10207            drop(foreign);
10208        })
10209    }
10210
10211    /// Sets its flag when dropped, so that threads which loop until it is set
10212    /// stop on every path, a failed assertion included.
10213    struct Raise<'a>(&'a AtomicBool);
10214    impl Drop for Raise<'_> {
10215        fn drop(&mut self) {
10216            self.0.store(true, Ordering::Release);
10217        }
10218    }
10219
10220    fn evidence() -> [SharedMessage; 1] {
10221        [msg("user", json!("evidence"))]
10222    }
10223
10224    /// Fetch, note and search of the one segment sealed from `evidence()`.
10225    fn assert_reads(reader: &ArchiveStore, reference: &ArchiveRef) {
10226        assert_eq!(reader.fetch(&reference.id, 0, 8).unwrap(), evidence());
10227        assert_eq!(reader.fetch_note(&reference.id).unwrap(), "note");
10228        let rows = reader.search("evidence", 8).unwrap();
10229        assert_eq!(rows.len(), 1);
10230        assert_eq!(rows[0].id, reference.id);
10231    }
10232
10233    fn assert_busy(result: io::Result<()>) {
10234        let error = result.unwrap_err();
10235        assert_eq!(error.kind(), io::ErrorKind::WouldBlock);
10236        assert_eq!(error.to_string(), BUSY);
10237    }
10238
10239    #[test]
10240    fn seal_waits_for_a_foreign_lock_then_succeeds() {
10241        let (_tmp, store) = setup();
10242        let hold = Duration::from_millis(300);
10243        let started = Instant::now();
10244        let holder = foreign_lock_for(&store, hold);
10245        let reference = store.seal(&evidence(), "note").unwrap();
10246        assert!(started.elapsed() >= hold);
10247        holder.join().unwrap();
10248        assert_reads(&store, &reference);
10249    }
10250
10251    #[test]
10252    fn forget_waits_for_a_foreign_lock_then_succeeds() {
10253        let (_tmp, store) = setup();
10254        let reference = store.seal(&evidence(), "note").unwrap();
10255        let hold = Duration::from_millis(300);
10256        let started = Instant::now();
10257        let holder = foreign_lock_for(&store, hold);
10258        store.forget(&reference.id).unwrap();
10259        assert!(started.elapsed() >= hold);
10260        holder.join().unwrap();
10261        let error = store.fetch(&reference.id, 0, 1).unwrap_err();
10262        assert_eq!(error.kind(), io::ErrorKind::NotFound);
10263    }
10264
10265    #[test]
10266    fn seal_reports_busy_after_the_wait() {
10267        let (tmp, mut store) = setup();
10268        assert_eq!(store.process_wait, PROCESS_LOCK_WAIT);
10269        assert_eq!(PROCESS_LOCK_WAIT, Duration::from_secs(5));
10270        store.process_wait = Duration::from_millis(200);
10271        let foreign = store.fs.lock().unwrap();
10272        let started = Instant::now();
10273        assert_busy(store.seal(&evidence(), "note").map(drop));
10274        assert!(started.elapsed() >= store.process_wait);
10275        // Nothing was written; the caller keeps its source and can retry.
10276        assert_eq!(project_entries(&tmp, &store), ["lock"]);
10277        drop(foreign);
10278        let reference = store.seal(&evidence(), "note").unwrap();
10279        // The same for a forget: busy after the wait, the segment untouched.
10280        let foreign = store.fs.lock().unwrap();
10281        let started = Instant::now();
10282        assert_busy(store.forget(&reference.id).map(drop));
10283        assert!(started.elapsed() >= store.process_wait);
10284        drop(foreign);
10285        assert_reads(&store, &reference);
10286    }
10287
10288    #[test]
10289    fn abandon_while_polling_returns_interrupted() {
10290        let (tmp, store) = setup();
10291        let foreign = store.fs.lock().unwrap();
10292        let abort = Arc::new(AtomicBool::new(false));
10293        let mut abandoned = other(&tmp).with_abort(abort.clone());
10294        // Only the abort can end this wait in time.
10295        abandoned.process_wait = Duration::from_secs(30);
10296        let started = Instant::now();
10297        let waiter = std::thread::spawn(move || abandoned.seal(&evidence(), ""));
10298        let gate = gate_of(&store);
10299        // Through the gate, so trying the lock.
10300        wait_until(|| *gate.held.lock().unwrap());
10301        abort.store(true, Ordering::Release);
10302        let error = waiter.join().unwrap().unwrap_err();
10303        assert_eq!(error.kind(), io::ErrorKind::Interrupted);
10304        assert!(started.elapsed() < Duration::from_secs(30));
10305        // It wrote nothing and did not keep the gate.
10306        assert_eq!(project_entries(&tmp, &store), ["lock"]);
10307        assert!(!*gate.held.lock().unwrap());
10308        drop(foreign);
10309        let reference = store.seal(&evidence(), "note").unwrap();
10310        assert_reads(&store, &reference);
10311    }
10312
10313    #[test]
10314    fn fetch_and_search_ignore_a_foreign_lock() {
10315        let (_tmp, mut store) = setup();
10316        let reference = store.seal(&evidence(), "note").unwrap();
10317        // A read that tried the lock would now fail at once.
10318        store.process_wait = Duration::ZERO;
10319        let _foreign = store.fs.lock().unwrap();
10320        assert_reads(&store, &reference);
10321        assert_busy(store.forget(&reference.id).map(drop));
10322    }
10323
10324    #[test]
10325    fn fetch_and_search_ignore_the_in_process_gate() {
10326        let (tmp, store) = setup();
10327        let reference = store.seal(&evidence(), "note").unwrap();
10328        let _held = store.exclusive().unwrap();
10329        let mut reader = other(&tmp);
10330        // A read that queued behind the gate would now fail at once.
10331        reader.wait = Duration::ZERO;
10332        assert_reads(&reader, &reference);
10333        assert_busy(reader.forget(&reference.id).map(drop));
10334    }
10335
10336    #[test]
10337    fn missing_id_without_legacy_files_is_not_found_without_locking() {
10338        let (tmp, store) = setup();
10339        let mut reader = other(&tmp);
10340        (reader.wait, reader.process_wait) = (Duration::ZERO, Duration::ZERO);
10341        let missing = "a".repeat(32);
10342        // With the gate and the lock both held by someone else.
10343        let absent = |id: &str| {
10344            let _held = store.exclusive().unwrap();
10345            for result in [reader.fetch(id, 0, 1).map(drop), reader.fetch_note(id).map(drop)] {
10346                assert_eq!(result.unwrap_err().kind(), io::ErrorKind::NotFound);
10347            }
10348            assert!(reader.search("", 8).unwrap().is_empty());
10349        };
10350        // No shelf yet, then a shelf without the id, then the id's tombstone.
10351        absent(&missing);
10352        let reference = store.seal(&evidence(), "note").unwrap();
10353        store.forget(&reference.id).unwrap();
10354        absent(&missing);
10355        absent(&reference.id);
10356    }
10357
10358    /// A read holds no lock, so a forget can land anywhere inside it. Whatever
10359    /// the timing, the read is the whole live record or "not found": never a
10360    /// partial record (`InvalidData`), never a refused file
10361    /// (`PermissionDenied`), and never the live record once forget has returned.
10362    #[test]
10363    fn reader_racing_forget_sees_live_or_not_found() {
10364        let (tmp, store) = setup();
10365        let reader = other(&tmp);
10366        for round in 0..100 {
10367            let words = format!("evidence {round};");
10368            let messages = [msg("user", json!(words)), msg("assistant", json!("reply"))];
10369            let id = store.seal(&messages, "note").unwrap().id;
10370            // Whether the segment was there: eight fetches, a note and a search.
10371            let live = |kind: usize| {
10372                let found = match kind {
10373                    8 => reader.fetch_note(&id).map(|note| assert_eq!(note, "note")),
10374                    9 => reader.search(&words, 2).and_then(|rows| match rows.as_slice() {
10375                        [] => Err(not_found()),
10376                        [row] => {
10377                            assert_eq!((row.id.as_str(), row.message_count), (id.as_str(), 2));
10378                            Ok(())
10379                        }
10380                        rows => panic!("one segment, {} rows", rows.len()),
10381                    }),
10382                    _ => reader
10383                        .fetch(&id, 0, MAX_FETCH_MESSAGES)
10384                        .map(|found| assert_eq!(found, messages)),
10385                };
10386                match found {
10387                    Ok(()) => true,
10388                    Err(error) => {
10389                        assert_eq!(error.kind(), io::ErrorKind::NotFound, "{error}");
10390                        false
10391                    }
10392                }
10393            };
10394            let forgotten = AtomicBool::new(false);
10395            let start = std::sync::Barrier::new(12);
10396            std::thread::scope(|scope| {
10397                // The path lookup holds no lock either; the file is always there.
10398                scope.spawn(|| {
10399                    start.wait();
10400                    loop {
10401                        let after = forgotten.load(Ordering::Acquire);
10402                        let located = archive_file(tmp.path(), "synthetic-project", &id);
10403                        assert!(located.unwrap().is_some());
10404                        if after {
10405                            break;
10406                        }
10407                    }
10408                });
10409                for kind in 0..10 {
10410                    let (live, forgotten, start) = (&live, &forgotten, &start);
10411                    scope.spawn(move || {
10412                        start.wait();
10413                        let mut gone = false;
10414                        loop {
10415                            // Read first: set means forget returned before this read began.
10416                            let after = forgotten.load(Ordering::Acquire);
10417                            if live(kind) {
10418                                assert!(!gone && !after, "read after it was forgotten");
10419                            } else if after {
10420                                break;
10421                            } else {
10422                                gone = true;
10423                            }
10424                        }
10425                    });
10426                }
10427                start.wait();
10428                // Raised once forget has returned.
10429                let _returned = Raise(&forgotten);
10430                store.forget(&id).unwrap();
10431            });
10432        }
10433    }
10434
10435    /// The race above without the timing: a read has opened the file, the
10436    /// forget replaces it, and the read then runs to its end on what it holds.
10437    #[test]
10438    fn reader_holding_the_old_file_reads_the_whole_live_record() {
10439        let (tmp, store) = setup();
10440        let reference = store.seal(&evidence(), "note").unwrap();
10441        let name = archive_name(&reference.id);
10442        let project = tmp.path().join("context-archives").join(&store.scope);
10443        let path = project.join("s000001").join(&name);
10444        let original = std::fs::read(&path).unwrap();
10445        // Opened the way `read_with` opens it, once for each kind of read.
10446        let shelf = store.fs.open_child("s000001", true).unwrap().unwrap();
10447        let open = || shelf.open(&c_name(&name).unwrap(), libc::O_RDONLY).unwrap();
10448        let (unlocked, locked) = (open(), open());
10449        store.forget(&reference.id).unwrap();
10450        // The open file has lost its name to the tombstone and has no link
10451        // left. The read that holds no lock still gets all of the record.
10452        let bytes = PrivateDir::read_open(unlocked, MAX_SEGMENT_BYTES, true).unwrap();
10453        assert_eq!(bytes, original);
10454        let Body::Live { messages, note, .. } = store.record(&name, &bytes).unwrap().body else {
10455            panic!("not the live record");
10456        };
10457        let messages: Vec<_> = messages.into_iter().map(|m| m.message).collect();
10458        assert_eq!((messages.as_slice(), note.as_str()), (&evidence()[..], "note"));
10459        // The read made under the lock, which repairs the mode of what it
10460        // reads and where nothing can replace a file, still wants one name.
10461        let error = PrivateDir::read_open(locked, MAX_SEGMENT_BYTES, false).unwrap_err();
10462        assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
10463        // Every read that starts now gets the tombstone.
10464        assert_ne!(std::fs::read(&path).unwrap(), original);
10465        let error = store.fetch(&reference.id, 0, 1).unwrap_err();
10466        assert_eq!(error.kind(), io::ErrorKind::NotFound);
10467        let located = archive_file(tmp.path(), "synthetic-project", &reference.id);
10468        assert_eq!(located.unwrap(), Some(path));
10469    }
10470
10471    /// Readers hold no lock while segments, and the shelves they go to, are
10472    /// added under it. A search never fails, every row is a sealed segment,
10473    /// and a segment whose seal returned before the search began is found. A
10474    /// fetch that asks for a segment from before its seal until it is there
10475    /// gets "not found" or the whole record, never part of one.
10476    #[test]
10477    fn searcher_racing_seals_never_errors() {
10478        let (tmp, store) = setup();
10479        let reader = other(&tmp);
10480        let words = |index: usize| format!("racing seal {index};");
10481        let window = |index: usize| [msg("user", json!(words(index)))];
10482        // An id is the digest of what is sealed, so it is known beforehand.
10483        // Three shelves: the readers also meet shelves being created.
10484        let ids: Vec<String> = (0..300)
10485            .map(|index| {
10486                let projection = project_archive("synthetic-logical", &window(index), "", None);
10487                projection.unwrap().digest[..32].to_owned()
10488            })
10489            .collect();
10490        let sealed = std::sync::atomic::AtomicUsize::new(0);
10491        let done = AtomicBool::new(false);
10492        let (searching, fetching) = (AtomicBool::new(false), AtomicBool::new(false));
10493        std::thread::scope(|scope| {
10494            scope.spawn(|| loop {
10495                let finished = done.load(Ordering::Acquire);
10496                // Seals that returned before this search began.
10497                let count = sealed.load(Ordering::Acquire);
10498                if let Some(newest) = count.checked_sub(1) {
10499                    let (rows, unread) = reader.search_scan(&words(newest), 2).unwrap();
10500                    let found: Vec<_> = rows.iter().map(|row| row.id.as_str()).collect();
10501                    assert_eq!((found, unread), (vec![ids[newest].as_str()], None));
10502                }
10503                let rows = reader.search("racing seal", MAX_SEARCH_RESULTS).unwrap();
10504                assert!(rows.len() >= count.min(MAX_SEARCH_RESULTS));
10505                for (index, row) in rows.iter().enumerate() {
10506                    assert!(ids.contains(&row.id));
10507                    assert_eq!((row.message_count, row.source_message_count), (1, 1));
10508                    assert!(rows[..index].iter().all(|earlier| earlier.id != row.id));
10509                }
10510                searching.store(true, Ordering::Release);
10511                if finished {
10512                    break;
10513                }
10514            });
10515            let fetcher = scope.spawn(|| {
10516                let mut next = 0;
10517                while next < ids.len() {
10518                    let finished = done.load(Ordering::Acquire);
10519                    match reader.fetch(&ids[next], 0, 2) {
10520                        Ok(found) => {
10521                            assert_eq!(found, window(next));
10522                            next += 1;
10523                        }
10524                        Err(error) => {
10525                            assert_eq!(error.kind(), io::ErrorKind::NotFound, "{error}");
10526                            fetching.store(true, Ordering::Release);
10527                            if finished {
10528                                break;
10529                            }
10530                        }
10531                    }
10532                }
10533                next
10534            });
10535            let stop = Raise(&done);
10536            // Both readers are running before the first seal.
10537            wait_until(|| searching.load(Ordering::Acquire) && fetching.load(Ordering::Acquire));
10538            for (index, id) in ids.iter().enumerate() {
10539                assert_eq!(&store.seal(&window(index), "").unwrap().id, id);
10540                sealed.store(index + 1, Ordering::Release);
10541            }
10542            drop(stop);
10543            assert_eq!(fetcher.join().unwrap(), ids.len());
10544        });
10545        assert_eq!(store.shelves().unwrap().len(), 3);
10546    }
10547
10548    /// Every file name in the shelves of `store`'s project directory.
10549    fn shelved(tmp: &tempfile::TempDir, store: &ArchiveStore) -> Vec<String> {
10550        let project = tmp.path().join("context-archives").join(&store.scope);
10551        let mut names = Vec::new();
10552        for shelf in store.shelves().unwrap() {
10553            let entries = std::fs::read_dir(project.join(shelf)).unwrap();
10554            names.extend(entries.map(|entry| entry.unwrap().file_name().into_string().unwrap()));
10555        }
10556        names
10557    }
10558
10559    /// Writers inside `publish` at the same moment with no lock between them:
10560    /// what is left of a seal when the lock file was deleted under it, or on
10561    /// a filesystem that does not lock. Each write is a file of its own and
10562    /// the rename cannot replace, so a writer told `Ok` has its bytes under
10563    /// its name, whole, and a name is never given to two of them.
10564    #[test]
10565    fn writers_inside_publish_at_once_without_the_lock() {
10566        const WRITERS: usize = 4;
10567        const ROUNDS: usize = 150;
10568        let (tmp, store) = setup();
10569        let shelf = store.fs.child("s000001", true).unwrap();
10570        // A writer's bytes are its own from the first to the last.
10571        let bytes = |writer: usize, round: usize| {
10572            format!("writer {writer} round {round};").repeat(4096).into_bytes()
10573        };
10574        let own = |writer: usize, round: usize| format!("{:032x}.json", writer * ROUNDS + round);
10575        let wanted = |round: usize| format!("{:032x}.json", WRITERS * ROUNDS + round);
10576        let start = std::sync::Barrier::new(WRITERS);
10577        let won: Vec<Vec<usize>> = std::thread::scope(|scope| {
10578            let writers: Vec<_> = (0..WRITERS)
10579                .map(|writer| {
10580                    let (shelf, start) = (&shelf, &start);
10581                    scope.spawn(move || {
10582                        start.wait();
10583                        let mut won = Vec::new();
10584                        for round in 0..ROUNDS {
10585                            // A name of its own: always stored.
10586                            let stored = shelf.publish(&own(writer, round), &bytes(writer, round), true);
10587                            stored.unwrap();
10588                            // A name every writer wants: stored by one.
10589                            match shelf.publish(&wanted(round), &bytes(writer, round), true) {
10590                                Ok(()) => won.push(round),
10591                                Err(error) => assert_eq!(error.kind(), io::ErrorKind::AlreadyExists),
10592                            }
10593                        }
10594                        won
10595                    })
10596                })
10597                .collect();
10598            writers.into_iter().map(|writer| writer.join().unwrap()).collect()
10599        });
10600        let read = |name: &str| shelf.read_with(name, MAX_SEGMENT_BYTES, true).unwrap().unwrap();
10601        for round in 0..ROUNDS {
10602            for writer in 0..WRITERS {
10603                assert!(read(&own(writer, round)) == bytes(writer, round), "{writer} {round}");
10604            }
10605            let winners: Vec<_> = (0..WRITERS).filter(|writer| won[*writer].contains(&round)).collect();
10606            assert_eq!(winners.len(), 1, "round {round}");
10607            assert!(read(&wanted(round)) == bytes(winners[0], round), "round {round}");
10608        }
10609        // And no temporary file is left.
10610        assert_eq!(shelved(&tmp, &store).len(), (WRITERS + 1) * ROUNDS);
10611    }
10612
10613    /// The same one level up: what a seal does once it holds the lock, done
10614    /// by writers that hold nothing. Every window is stored, and is what the
10615    /// writer was told it is.
10616    #[test]
10617    fn seals_at_once_without_the_lock_each_store_their_window() {
10618        const WRITERS: usize = 4;
10619        let (tmp, store) = setup();
10620        let window = |words: String| [msg("user", json!(words))];
10621        let project = |messages: &[SharedMessage], note: &str| {
10622            project_hashed(&store.logical, messages, note, None, Reduce::None).unwrap()
10623        };
10624        // `rounds` windows by each writer, at once: the same windows (with a
10625        // note of the writer's own) or windows of its own.
10626        let seal_all = |same: bool, rounds: usize| -> Vec<Vec<(ArchiveRef, String)>> {
10627            let start = std::sync::Barrier::new(WRITERS);
10628            std::thread::scope(|scope| {
10629                let writers: Vec<_> = (0..WRITERS)
10630                    .map(|writer| {
10631                        let (store, start) = (&store, &start);
10632                        scope.spawn(move || {
10633                            start.wait();
10634                            let note = format!("note of writer {writer}");
10635                            let name = if same { "any".to_owned() } else { format!("writer {writer}") };
10636                            let seal = |round| {
10637                                let window = window(format!("{name} window {round};"));
10638                                store.store(project(&window, &note), Written::now())
10639                            };
10640                            (0..rounds).map(|round| seal(round).unwrap()).collect()
10641                        })
10642                    })
10643                    .collect();
10644                writers.into_iter().map(|writer| writer.join().unwrap()).collect()
10645            })
10646        };
10647        // One window sealed by all, within one shelf: one segment. Every
10648        // writer is given its id and the note that is stored with it, which
10649        // is the note of whichever writer's file it is.
10650        let same = seal_all(true, 30);
10651        for (round, (reference, note)) in same[0].iter().enumerate() {
10652            let words = format!("any window {round};");
10653            assert_eq!(store.fetch(&reference.id, 0, 2).unwrap(), window(words));
10654            assert_eq!(&store.fetch_note(&reference.id).unwrap(), note);
10655            assert!(note.starts_with("note of writer "));
10656            for other in &same[1..] {
10657                assert_eq!(other[round], (reference.clone(), note.clone()));
10658            }
10659        }
10660        assert_eq!(shelved(&tmp, &store).len(), 30);
10661        // Windows of their own, enough to start two more shelves while the
10662        // writers race for them.
10663        let own = seal_all(false, 80);
10664        for (writer, sealed) in own.iter().enumerate() {
10665            for (round, (reference, note)) in sealed.iter().enumerate() {
10666                let words = format!("writer {writer} window {round};");
10667                assert_eq!(store.fetch(&reference.id, 0, 2).unwrap(), window(words));
10668                assert_eq!(note, &format!("note of writer {writer}"));
10669            }
10670        }
10671        // One file each, nothing replaced, and no temporary file left.
10672        let mut names = shelved(&tmp, &store);
10673        assert_eq!(names.len(), 30 + WRITERS * 80);
10674        names.sort();
10675        names.dedup();
10676        assert_eq!(names.len(), 30 + WRITERS * 80);
10677        assert!(names.iter().all(|name| archive_id(name).is_some()));
10678        assert!(store.shelves().unwrap().len() >= 3);
10679        // The lock still works afterwards, and finds all of it.
10680        let (reference, note) = store.seal_with_note(&window("any window 0;".into()), "").unwrap();
10681        assert_eq!((&reference, &note), (&same[0][0].0, &same[0][0].1));
10682    }
10683
10684    /// Seals from several threads, each through its own store, the gate and
10685    /// the lock, into shelves that fill up while they run. Whoever crosses
10686    /// from one shelf to the next, each shelf is filled exactly and every
10687    /// window is stored once.
10688    #[test]
10689    fn concurrent_seals_across_shelf_boundaries_fill_each_shelf_exactly() {
10690        const WRITERS: usize = 4;
10691        const EACH: usize = 80;
10692        let (tmp, store) = setup();
10693        let window = |writer: usize, round: usize| {
10694            [msg("user", json!(format!("writer {writer} window {round};")))]
10695        };
10696        let start = std::sync::Barrier::new(WRITERS);
10697        let sealed: Vec<Vec<String>> = std::thread::scope(|scope| {
10698            let writers: Vec<_> = (0..WRITERS)
10699                .map(|writer| {
10700                    let (tmp, start) = (&tmp, &start);
10701                    scope.spawn(move || {
10702                        let name = format!("writer-{writer}");
10703                        let store = ArchiveStore::new(tmp.path(), "synthetic-project", &name);
10704                        let store = store.unwrap();
10705                        start.wait();
10706                        let seal = |round| store.seal(&window(writer, round), "note").unwrap().id;
10707                        (0..EACH).map(seal).collect()
10708                    })
10709                })
10710                .collect();
10711            writers.into_iter().map(|writer| writer.join().unwrap()).collect()
10712        });
10713        assert_eq!(store.shelves().unwrap(), ["s000003", "s000002", "s000001"]);
10714        let project = tmp.path().join("context-archives").join(&store.scope);
10715        let held = |shelf: &str| std::fs::read_dir(project.join(shelf)).unwrap().count();
10716        let rest = WRITERS * EACH - 2 * SHELF_FILES;
10717        assert_eq!(
10718            (held("s000001"), held("s000002"), held("s000003")),
10719            (SHELF_FILES, SHELF_FILES, rest)
10720        );
10721        for (writer, ids) in sealed.iter().enumerate() {
10722            for (round, id) in ids.iter().enumerate() {
10723                assert_eq!(store.fetch(id, 0, 2).unwrap(), window(writer, round));
10724            }
10725        }
10726        let mut names = shelved(&tmp, &store);
10727        names.sort();
10728        names.dedup();
10729        assert_eq!(names.len(), WRITERS * EACH);
10730        assert!(names.iter().all(|name| archive_id(name).is_some()));
10731    }
10732
10733    const SEAL_CHILD: &str = "SYNTHETIC_ARCHIVE_SEAL_CHILD";
10734    const CHILD_SEALS: usize = 100;
10735
10736    /// The other process of `two_processes_seal_concurrently`, which runs this
10737    /// test binary again with the archive base in the environment. Does
10738    /// nothing in an ordinary run.
10739    #[test]
10740    fn two_processes_seal_child() {
10741        let Some(base) = std::env::var_os(SEAL_CHILD).map(PathBuf::from) else {
10742            return;
10743        };
10744        let name = format!("child-{}", std::process::id());
10745        let store = ArchiveStore::new(&base, "synthetic-project", &name).unwrap();
10746        std::fs::write(base.join(format!("ready-{}", std::process::id())), b"").unwrap();
10747        wait_until(|| base.join("go").exists());
10748        for round in 0..CHILD_SEALS {
10749            let words = format!("{name} round {round};");
10750            store.seal(&[msg("user", json!(words))], "note").unwrap();
10751        }
10752    }
10753
10754    /// Two real processes seal into one project at the same moment. Each
10755    /// waits for the other's lock instead of failing as busy.
10756    #[test]
10757    #[serial_test::parallel(process_spawn)]
10758    fn two_processes_seal_concurrently() {
10759        let tmp = tempfile::tempdir().unwrap();
10760        let (_, module) = module_path!().split_once("::").unwrap();
10761        let child = format!("{module}::two_processes_seal_child");
10762        let mut children: Vec<_> = (0..2)
10763            .map(|_| {
10764                std::process::Command::new(std::env::current_exe().unwrap())
10765                    .args(["--exact", &child, "--nocapture"])
10766                    .env(SEAL_CHILD, tmp.path())
10767                    .stdin(std::process::Stdio::null())
10768                    .stdout(std::process::Stdio::null())
10769                    .spawn()
10770                    .unwrap()
10771            })
10772            .collect();
10773        // Both have opened the store: let them seal at once.
10774        let ready = |child: &std::process::Child| format!("ready-{}", child.id());
10775        wait_until(|| children.iter().all(|child| tmp.path().join(ready(child)).exists()));
10776        std::fs::write(tmp.path().join("go"), b"").unwrap();
10777        for child in &mut children {
10778            assert!(child.wait().unwrap().success());
10779        }
10780        let store = ArchiveStore::new(tmp.path(), "synthetic-project", "synthetic-reader").unwrap();
10781        let project = tmp.path().join("context-archives").join(&store.scope);
10782        // Together they seal more than a shelf holds (200), so they cross
10783        // from the first shelf to the second. Whichever of them did, the
10784        // first is exactly full, the rest is in the second, and neither left
10785        // a temporary file.
10786        let held = |shelf: &str| std::fs::read_dir(project.join(shelf)).unwrap().count();
10787        assert_eq!(
10788            (held("s000001"), held("s000002")),
10789            (SHELF_FILES, 2 * CHILD_SEALS - SHELF_FILES)
10790        );
10791        assert_eq!(store.shelves().unwrap().len(), 2);
10792        // Every window of both, under the id its content gives it.
10793        for child in &children {
10794            let name = format!("child-{}", child.id());
10795            for round in 0..CHILD_SEALS {
10796                let window = [msg("user", json!(format!("{name} round {round};")))];
10797                let projection = project_archive(&name, &window, "", None).unwrap();
10798                let found = store.fetch(&projection.digest[..32], 0, 2);
10799                assert_eq!(found.unwrap(), window, "{name} round {round}");
10800            }
10801            for round in [0, CHILD_SEALS - 1] {
10802                let words = format!("{name} round {round};");
10803                assert_eq!(store.search(&words, 2).unwrap().len(), 1, "{words}");
10804            }
10805        }
10806    }
10807}