Skip to main content

macrame/temporal/
snapshot.rs

1use std::fs;
2use std::io::{Read, Write};
3use std::path::{Path, PathBuf};
4
5use bincode::Options;
6
7use crate::error::{DbError, Result};
8use crate::temporal::replay::MaterializedState;
9use crate::util::crc32::Crc32;
10
11/// Header magic. Also the marker that separates a 0.5.5 snapshot from the
12/// headerless files 0.5.4 and earlier wrote, whose first bytes are zstd's own
13/// magic (`28 B5 2F FD`) and therefore never match this.
14const SNAP_MAGIC: [u8; 4] = *b"MACR";
15
16/// On-disk layout version for the snapshot container (D-043).
17///
18/// Bumped whenever the *shape* of [`MaterializedState`] changes, independently
19/// of the database schema. `bincode` is not self-describing: adding a field
20/// does not make an old file fail to parse, it makes it parse into the wrong
21/// values — and a snapshot is the first thing a restart reaches for, so the
22/// wrong values arrive labelled as the newest state anyone believed.
23///
24/// * **v2 (0.5.5)** adds the snapshot's own instant to the header (D-054).
25/// * **v3 (0.13.12)** adds both lengths and a checksum (W8.2, D-185).
26///
27/// A v2 file meets a v3 build as [`DbError::SnapshotIncompatible`], which is
28/// the case this versioned container was built for: the scan skips it and
29/// folds from the log. No migration, because there is nothing to migrate —
30/// a snapshot is a cache.
31const SNAP_FORMAT_VERSION: u16 = 3;
32
33/// The v3 container header, little-endian throughout:
34///
35/// ```text
36/// offset  0      4    6      10               18            26           34     38
37///         MACR | fmt | schema | taken_at_micros | payload_len | plain_len | crc32 |
38///         (4)    (2)   (4)      (8)               (8)           (8)         (4)
39/// ```
40///
41/// `payload_len` is the compressed byte count that follows this header,
42/// `plain_len` what it decompresses to, and `crc32` covers the first 34 bytes
43/// of the header **and** the payload — so the two lengths are themselves under
44/// the checksum and a reader can trust them before acting on them (W8.2,
45/// D-185).
46const SNAP_HEADER_LEN: usize = 38;
47
48/// Where the checksum sits: everything before it is covered by it.
49const SNAP_CRC_OFFSET: usize = SNAP_HEADER_LEN - 4;
50
51/// Microseconds since the Unix epoch, from the snapshot's own `timestamp`.
52///
53/// The instant is already in the payload — this is a *copy* in the header, which
54/// is the kind of second description this codebase usually refuses. It earns the
55/// exception by what reads it: retention has to bucket every snapshot by day, and
56/// the alternative is decompressing and deserializing a full `MaterializedState`
57/// per file on every pass, which would make the cadence's own maintenance cost
58/// more than the work it exists to save. Eighteen bytes read without touching
59/// zstd is the whole point of having a header at all (D-043).
60///
61/// It cannot drift from the payload because both are written from the same value
62/// in the same statement, and nothing rewrites a snapshot in place.
63fn taken_at_micros(state: &MaterializedState) -> u64 {
64    crate::util::timestamp::parse(&state.timestamp)
65        .ok()
66        .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
67        .map(|d| d.as_micros() as u64)
68        .unwrap_or(0)
69}
70
71/// Build the header for a payload, checksum included.
72///
73/// Takes the payload rather than a precomputed checksum so that there is one
74/// place where the covered bytes are decided. A checksum passed in as a `u32`
75/// would let a caller compute it over the wrong range, and the failure mode of
76/// that is a file that verifies against itself and nothing else.
77fn snapshot_header(
78    schema_version: u32,
79    taken_at: u64,
80    payload: &[u8],
81    plain_len: u64,
82) -> [u8; SNAP_HEADER_LEN] {
83    let mut h = [0u8; SNAP_HEADER_LEN];
84    h[0..4].copy_from_slice(&SNAP_MAGIC);
85    h[4..6].copy_from_slice(&SNAP_FORMAT_VERSION.to_le_bytes());
86    h[6..10].copy_from_slice(&schema_version.to_le_bytes());
87    h[10..18].copy_from_slice(&taken_at.to_le_bytes());
88    h[18..26].copy_from_slice(&(payload.len() as u64).to_le_bytes());
89    h[26..34].copy_from_slice(&plain_len.to_le_bytes());
90
91    let mut crc = Crc32::new();
92    crc.update(&h[..SNAP_CRC_OFFSET]);
93    crc.update(payload);
94    h[SNAP_CRC_OFFSET..].copy_from_slice(&crc.finish().to_le_bytes());
95    h
96}
97
98/// The instant a snapshot reflects, read from its header alone.
99///
100/// `None` for anything this build would refuse to load anyway — a foreign file,
101/// an older container, a truncated one. Retention treats that as "no date" and
102/// falls back to the newest-N rule for it rather than guessing.
103fn header_taken_at(path: &Path) -> Option<u64> {
104    let mut file = fs::File::open(path).ok()?;
105    let mut head = [0u8; SNAP_HEADER_LEN];
106    file.read_exact(&mut head).ok()?;
107    if head[0..4] != SNAP_MAGIC {
108        return None;
109    }
110    if u16::from_le_bytes([head[4], head[5]]) != SNAP_FORMAT_VERSION {
111        return None;
112    }
113    let micros = u64::from_le_bytes(head[10..18].try_into().ok()?);
114    (micros > 0).then_some(micros)
115}
116
117/// Zero-padding width for the `seq_id` in a snapshot filename.
118///
119/// `seq_id` is an `INTEGER PRIMARY KEY AUTOINCREMENT`, so its ceiling is
120/// `i64::MAX` — 19 digits. The previous `{:08}` produced names that stopped
121/// sorting in `seq_id` order the moment the ledger passed 10^8 entries, which is
122/// the same fixed-width failure D-029 describes, deferred rather than avoided.
123/// Retention no longer *depends* on this (see [`cleanup_expired_snapshots`]),
124/// but a directory listing should still read in order.
125const SEQ_WIDTH: usize = 19;
126
127/// The snapshot file for a given anchor.
128fn snapshot_filename(seq_anchor: i64) -> String {
129    format!("{seq_anchor:0SEQ_WIDTH$}.snap.zst")
130}
131
132/// Recover the anchor a snapshot filename encodes.
133pub(crate) fn seq_from_filename(path: &Path) -> Option<i64> {
134    path.file_name()?
135        .to_str()?
136        .strip_suffix(".snap.zst")?
137        .parse()
138        .ok()
139}
140
141/// Make the *directory entry* durable, on the platforms that have a way to say
142/// so (0.13.13, W8.3,
143/// [D-186](../../docs/architecture/s13-decision-register.md#d-186)).
144///
145/// `fs::rename` is atomic, and atomic is not durable. The rename decides
146/// *which* file is at the final name — a crash across it leaves the old
147/// snapshot or the new one, never a splice — but the name itself lives in the
148/// directory, and a directory's own metadata reaches the disk when the
149/// filesystem feels like it. The window is a real one and its shape is
150/// unhelpful: the file's bytes are already `fsync`ed, so what a power loss
151/// takes is the *pointer*, leaving a perfectly good snapshot under a name
152/// nothing looks for while the newest name still resolves to an older file.
153///
154/// This is the standard POSIX gap, and the crash it matters on is precisely the
155/// crash a snapshot exists for.
156#[cfg(unix)]
157fn sync_directory(dir: &Path) -> std::io::Result<()> {
158    // Read-only is enough and is also all that is on offer: `fsync` on a
159    // directory descriptor flushes that directory's metadata, and a directory
160    // cannot be opened for writing.
161    fs::File::open(dir)?.sync_all()
162}
163
164/// Windows and anything else: nothing, deliberately and by name (0.13.13, W8.3,
165/// [D-186](../../docs/architecture/s13-decision-register.md#d-186)).
166///
167/// There is no directory `fsync` on Windows. A directory *handle* can be opened
168/// with `FILE_FLAG_BACKUP_SEMANTICS`, but `FlushFileBuffers` needs write access
169/// on the handle and a directory does not grant it; the call that does cover
170/// directory metadata takes a volume handle, requires administrative
171/// privileges, and flushes every open file on the volume — which is not a thing
172/// a library may do to its host process's machine.
173///
174/// What stands in for it is NTFS's own metadata journal: the rename is a
175/// logged transaction, so a completed rename is recovered by the filesystem
176/// rather than by anything this crate arranges. That is a genuinely weaker
177/// statement than the `unix` branch makes — it rests on the filesystem being
178/// NTFS or ReFS, and says nothing about FAT32 or a network share — and it is
179/// written down rather than assumed, because a silent no-op is how a durability
180/// gap survives being closed.
181#[cfg(not(unix))]
182fn sync_directory(_dir: &Path) -> std::io::Result<()> {
183    Ok(())
184}
185
186/// Save a bincode-serialized, zstd-compressed snapshot file (.snap.zst) (§5.5).
187///
188/// Written to a temporary file, flushed to disk, renamed into place, and the
189/// directory flushed after the rename. A snapshot is read back with no
190/// integrity check beyond the container's own checksum, so a half-written file
191/// at the final name is a file that looks loadable and is not — and it would be
192/// the *newest* one, which is exactly the one a restart reaches for. Rename
193/// within a directory is atomic, so a crash leaves either the old snapshot or
194/// the new one, never a splice; `sync_directory` is what makes the winner of
195/// that race survive the power loss that caused it (0.13.13, W8.3).
196///
197/// # This blocks, and it is not a small block (0.13.11, W8.1)
198///
199/// bincode over the whole state, zstd over the result, a file write and an
200/// `fsync` — CPU and disk, both unbounded in the size of the graph, and none of
201/// it yielding. Called from an async task it stalls that runtime worker for the
202/// whole duration, which at 100K edges is the two seconds §9 budgets for it.
203/// Every async caller inside the crate goes through `save_and_prune`; a
204/// caller outside it wants `tokio::task::spawn_blocking` around this, and the
205/// signature stays synchronous so that they can have it.
206pub fn save_snapshot(snapshots_dir: &Path, state: &MaterializedState) -> Result<PathBuf> {
207    let fail = |what: &str, e: std::io::Error| DbError::ReplayCorrupt {
208        seq: state.seq_anchor,
209        reason: format!("{what}: {e}"),
210    };
211
212    fs::create_dir_all(snapshots_dir)
213        .map_err(|e| fail("failed to create snapshot directory", e))?;
214
215    let path = snapshots_dir.join(snapshot_filename(state.seq_anchor));
216    let tmp_path = path.with_extension("tmp");
217
218    let serialized = bincode::serialize(state).map_err(|e| DbError::ReplayCorrupt {
219        seq: state.seq_anchor,
220        reason: format!("failed to serialize snapshot: {e}"),
221    })?;
222
223    let compressed =
224        zstd::encode_all(&serialized[..], 3).map_err(|e| fail("failed to compress snapshot", e))?;
225
226    let mut file =
227        fs::File::create(&tmp_path).map_err(|e| fail("failed to create snapshot temp file", e))?;
228    // Header first, uncompressed: it has to be readable without committing to
229    // decompressing a payload this build may not understand (D-043). Since v3
230    // it also carries the checksum over the payload that follows it, which is
231    // why it is built after the compression rather than before (W8.2, D-185).
232    file.write_all(&snapshot_header(
233        crate::schema::migrations::SCHEMA_VERSION,
234        taken_at_micros(state),
235        &compressed,
236        serialized.len() as u64,
237    ))
238    .map_err(|e| fail("failed to write snapshot header", e))?;
239    file.write_all(&compressed)
240        .map_err(|e| fail("failed to write snapshot bytes", e))?;
241    // Before the rename, or the rename can land ahead of the data.
242    file.sync_all()
243        .map_err(|e| fail("failed to flush snapshot to disk", e))?;
244    drop(file);
245
246    fs::rename(&tmp_path, &path).map_err(|e| {
247        let _ = fs::remove_file(&tmp_path);
248        fail("failed to publish snapshot", e)
249    })?;
250
251    // After the rename, because it is the rename that has to survive. The file
252    // is already at its final name when this runs, so a failure here does not
253    // mean the snapshot is missing or damaged — it means this function cannot
254    // promise the name outlives a power loss, which is the whole of what it
255    // promises past `sync_all` above, and so it is reported rather than logged
256    // (W8.3, D-186).
257    sync_directory(snapshots_dir)
258        .map_err(|e| fail("failed to make the snapshot's directory entry durable", e))?;
259
260    Ok(path)
261}
262
263/// Load a snapshot, refusing anything this build cannot read (§5.5, D-043).
264///
265/// The header is checked *before* the payload is decompressed, and a mismatch
266/// is [`DbError::SnapshotIncompatible`] rather than a corruption error, because
267/// the two want opposite responses: corruption is a fault to report, an
268/// incompatible snapshot is an ordinary consequence of upgrading and the right
269/// answer is to discard it and cold-fold. Distinguishing them is the whole
270/// point of the header — `bincode` is not self-describing, so without one an
271/// old file does not reliably fail to parse, it parses into wrong values.
272///
273/// Headerless files written by 0.5.4 and earlier are rejected by the same path:
274/// their first four bytes are zstd's magic, which is not `MACR`.
275///
276/// # Damage is a third answer, and it is bounded (0.13.12, W8.2, D-185)
277///
278/// [`DbError::SnapshotCorrupt`] is not [`DbError::SnapshotIncompatible`] and
279/// not [`DbError::ReplayCorrupt`]: the file is damaged, the ledger is not, and
280/// the repair is to delete the file. Every failure below used to be
281/// `ReplayCorrupt { seq: 0 }`, which said the log was damaged and carried a
282/// sequence number that cannot exist.
283///
284/// The checks run in the order that lets the cheapest one fire first, and each
285/// is a named error rather than a symptom further down:
286///
287/// 1. **Declared payload length** against the bytes actually present. Catches
288///    truncation and trailing junk without hashing anything.
289/// 2. **Checksum** over the header and the payload, before zstd is handed a
290///    single byte. This is the check that closes §3.3: a corrupt stream is
291///    refused *as* a corrupt stream, rather than being walked to exhaustion by
292///    a deserializer trying to make sense of it.
293/// 3. **Declared plaintext length**, enforced during decompression rather than
294///    checked after it — the reader is bounded to `plain_len + 1` bytes, so a
295///    frame that expands further stops at the bound instead of allocating.
296/// 4. **A bincode limit** equal to the buffer's own size, replacing the
297///    `Infinite` limit `bincode::deserialize` carries.
298///
299/// Steps 3 and 4 are redundant with step 2 for every file this crate wrote,
300/// and that is the point of having them: they hold when the checksum has
301/// already been satisfied by something that computed it deliberately.
302///
303/// # This blocks (0.13.11, W8.1)
304///
305/// Read, decompress, deserialize, all synchronous — see [`save_snapshot`] for
306/// the argument. The crate's one async reader is `snapshot_anchor`, which
307/// offloads the whole scan rather than each file.
308pub fn load_snapshot(path: &Path) -> Result<MaterializedState> {
309    let label = path.display().to_string();
310    let raw = fs::read(path).map_err(|e| DbError::SnapshotCorrupt {
311        path: label.clone(),
312        reason: format!("could not be read: {e}"),
313    })?;
314    parse_snapshot(&label, &raw)
315}
316
317/// The half of [`load_snapshot`] that is a parser (0.13.14, W8.4, D-187).
318///
319/// Split out because a parser that can only be reached through a filesystem
320/// path is a parser that can only be fuzzed through the filesystem: a syscall
321/// round trip per case, on the one axis where cases per second is the entire
322/// measure of the tool. `load_snapshot` is now *read the file* and this is
323/// *understand the bytes*, which is also the honest description of what the
324/// two halves were already doing.
325///
326/// `label` is what the error carries as `path`. It is a `&str` rather than a
327/// `&Path` because the caller that is not a file — the fuzz harness — does not
328/// have one, and inventing a fake path so the signature could keep its type
329/// would be putting a lie in every error message it produced.
330pub(crate) fn parse_snapshot(label: &str, raw: &[u8]) -> Result<MaterializedState> {
331    let damaged = |reason: String| DbError::SnapshotCorrupt {
332        path: label.to_string(),
333        reason,
334    };
335    let foreign = |reason: String| DbError::SnapshotIncompatible {
336        path: label.to_string(),
337        reason,
338    };
339
340    if raw.len() < SNAP_HEADER_LEN || raw[0..4] != SNAP_MAGIC {
341        return Err(foreign(
342            "not a macrame snapshot, or written before the versioned container \
343             existed (0.5.4 and earlier)"
344                .to_string(),
345        ));
346    }
347
348    let format = u16::from_le_bytes([raw[4], raw[5]]);
349    let schema = u32::from_le_bytes([raw[6], raw[7], raw[8], raw[9]]);
350    let expected_schema = crate::schema::migrations::SCHEMA_VERSION;
351    if format != SNAP_FORMAT_VERSION || schema != expected_schema {
352        return Err(foreign(format!(
353            "snapshot is format v{format}/schema v{schema}; this build reads \
354             format v{SNAP_FORMAT_VERSION}/schema v{expected_schema}"
355        )));
356    }
357
358    // Unwraps: the slices are fixed ranges of a buffer already checked to be at
359    // least SNAP_HEADER_LEN long, so `try_into` on each cannot fail.
360    let payload_len = u64::from_le_bytes(raw[18..26].try_into().unwrap());
361    let plain_len = u64::from_le_bytes(raw[26..34].try_into().unwrap());
362    let declared_crc =
363        u32::from_le_bytes(raw[SNAP_CRC_OFFSET..SNAP_HEADER_LEN].try_into().unwrap());
364
365    let payload = &raw[SNAP_HEADER_LEN..];
366    if payload.len() as u64 != payload_len {
367        return Err(damaged(format!(
368            "the header declares {payload_len} payload bytes and the file \
369             carries {}: truncated, or something was appended",
370            payload.len()
371        )));
372    }
373
374    let mut crc = Crc32::new();
375    crc.update(&raw[..SNAP_CRC_OFFSET]);
376    crc.update(payload);
377    let actual_crc = crc.finish();
378    if actual_crc != declared_crc {
379        return Err(damaged(format!(
380            "checksum mismatch: the header declares {declared_crc:#010x} and \
381             the bytes hash to {actual_crc:#010x}"
382        )));
383    }
384
385    // Bounded at `plain_len + 1` so that a frame claiming to be larger than it
386    // said stops one byte over the line rather than at whatever it decides to
387    // expand to. `saturating_add` because `plain_len` is a number off a disk.
388    let mut decoder =
389        zstd::Decoder::new(payload).map_err(|e| damaged(format!("zstd rejected it: {e}")))?;
390    let mut plain = Vec::new();
391    decoder
392        .by_ref()
393        .take(plain_len.saturating_add(1))
394        .read_to_end(&mut plain)
395        .map_err(|e| damaged(format!("could not be decompressed: {e}")))?;
396    if plain.len() as u64 != plain_len {
397        return Err(damaged(format!(
398            "the header declares {plain_len} plaintext bytes and the payload \
399             decompressed to {}",
400            plain.len()
401        )));
402    }
403
404    // `bincode::deserialize`'s own options, plus a limit: the default is
405    // `Infinite`, and the buffer's length is the only honest bound available
406    // once the bytes are in hand.
407    let state: MaterializedState = bincode::DefaultOptions::new()
408        .with_fixint_encoding()
409        .allow_trailing_bytes()
410        .with_limit(plain.len() as u64)
411        .deserialize(&plain)
412        .map_err(|e| damaged(format!("could not be deserialized: {e}")))?;
413
414    Ok(state)
415}
416
417/// [`save_snapshot`] then [`cleanup_expired_snapshots`], on a blocking thread
418/// (0.13.11, W8.1, D-184).
419///
420/// The whole write side of the snapshot in one hop, because the two run back to
421/// back and a second `spawn_blocking` between them would buy a scheduling point
422/// nobody is waiting at. Order and error behaviour are exactly what they were
423/// inline: a failed save is returned and the prune does not run, a failed prune
424/// is returned even though the snapshot is already on disk.
425///
426/// # Losing the thread is an error and not a shrug
427///
428/// A `spawn_blocking` task cannot be cancelled once it has started, so the only
429/// way `await` yields a [`tokio::task::JoinError`] here is that the closure
430/// panicked. That closure is the code that writes the file `close()` promises
431/// to have written, so the panic becomes [`DbError::ReplayCorrupt`] carrying
432/// the anchor — the same class every other failure of `save_snapshot` reports,
433/// which is the point: a caller handling "the snapshot did not get written"
434/// should not need a second arm for the case where it failed by panicking.
435///
436/// This is the opposite call from the read side, where a failed load costs
437/// speed and nothing else — see `snapshot_anchor`.
438async fn save_and_prune(snapshots_dir: PathBuf, state: MaterializedState) -> Result<PathBuf> {
439    let seq = state.seq_anchor;
440    tokio::task::spawn_blocking(move || {
441        let path = save_snapshot(&snapshots_dir, &state)?;
442        cleanup_expired_snapshots(&snapshots_dir)?;
443        Ok(path)
444    })
445    .await
446    .unwrap_or_else(|e| {
447        Err(DbError::ReplayCorrupt {
448            seq,
449            reason: format!("the thread writing the snapshot did not finish: {e}"),
450        })
451    })
452}
453
454/// Write the final snapshot on clean shutdown (§5.1.7).
455///
456/// Called after the Write Actor has stopped, so the state it folds is quiescent
457/// — nothing can commit between the fold and the write. Returns the snapshot's
458/// path so a caller can log or verify it.
459///
460/// This was a `Ok(())` stub that `close()` never called, which meant every
461/// restart replayed the log from whatever snapshot happened to be lying around
462/// rather than from the shutdown anchor.
463///
464/// The fold is async and the write is not, so the write goes to a blocking
465/// thread (`save_and_prune`, 0.13.11, W8.1). Both halves used to run on the
466/// caller's worker, and the second half is the expensive one.
467pub async fn write_final(
468    conn: &libsql::Connection,
469    snapshots_dir: &Path,
470    ts: &str,
471    archive_path: Option<&Path>,
472) -> Result<PathBuf> {
473    let state =
474        crate::temporal::replay::reconstruct(conn, ts, archive_path, Some(snapshots_dir)).await?;
475    save_and_prune(snapshots_dir.to_path_buf(), state).await
476}
477
478/// Snapshots kept unconditionally, newest first, by [`cleanup_expired_snapshots`] (§5.5).
479const RETAIN: usize = 5;
480
481/// Days for which one snapshot each is kept beyond [`RETAIN`] (§5.5, D-054).
482const RETAIN_DAYS: i64 = 30;
483
484const MICROS_PER_DAY: u64 = 86_400_000_000;
485
486/// Retention: the newest `RETAIN`, **plus one per day for `RETAIN_DAYS`**
487/// (§5.5, D-054).
488///
489/// **Why the daily tier exists, and why it did not matter until now.** Through
490/// 0.5.4 a snapshot was written once per clean shutdown, so "newest five" was
491/// five shutdowns — days or weeks of coverage, and the daily rule §5.5 specifies
492/// bought nothing. The cadence ([D-053](../../docs/architecture/s13-decision-register.md))
493/// writes one every 10,000 log entries, so under load five anchors can span
494/// minutes: every instant older than that falls back to folding the whole log,
495/// which is the cost snapshots exist to avoid. The flat rule went from harmless
496/// to actively defeating the feature that had just been added.
497///
498/// Ordered by the `seq_id` parsed out of each filename, not by the filename
499/// itself. A lexicographic sort over names is only `seq_id` order while every
500/// name is the same width, and "delete the oldest" reading from a mis-sorted
501/// list deletes the wrong files — quietly, and preferentially the newest ones.
502/// Parsing removes the dependency on `SEQ_WIDTH` entirely.
503///
504/// A snapshot whose header carries no readable instant survives only under the
505/// newest-`RETAIN` rule. That is deliberate: it is a file this build would
506/// refuse to *load* anyway, so keeping it for its date would be keeping it for a
507/// date nothing will ever use.
508pub fn cleanup_expired_snapshots(snapshots_dir: &Path) -> Result<usize> {
509    if !snapshots_dir.exists() {
510        return Ok(0);
511    }
512
513    let read_dir = fs::read_dir(snapshots_dir).map_err(|e| DbError::ReplayCorrupt {
514        seq: 0,
515        reason: format!("failed to read snapshot dir: {e}"),
516    })?;
517
518    // (seq_id, path, day since epoch — None when the header carries no instant)
519    let mut snapshots: Vec<(i64, PathBuf, Option<i64>)> = Vec::new();
520    for entry in read_dir.flatten() {
521        let path = entry.path();
522        match path.extension().and_then(|e| e.to_str()) {
523            // A leftover from an interrupted save. It was never renamed into
524            // place, so nothing can be reading it, and left alone these
525            // accumulate forever.
526            Some("tmp") => {
527                let _ = fs::remove_file(&path);
528            }
529            Some("zst") => match seq_from_filename(&path) {
530                Some(seq) => {
531                    let day = header_taken_at(&path).map(|micros| (micros / MICROS_PER_DAY) as i64);
532                    snapshots.push((seq, path, day));
533                }
534                // Not ours, or a name we cannot order. Deleting on a guess is
535                // how retention turns into data loss.
536                None => tracing::warn!("snapshot cleanup: unparseable filename {path:?}, skipping"),
537            },
538            _ => {}
539        }
540    }
541
542    snapshots.sort_by_key(|(seq, _, _)| *seq);
543
544    let mut keep: std::collections::HashSet<&PathBuf> = snapshots
545        .iter()
546        .rev()
547        .take(RETAIN)
548        .map(|(_, path, _)| path)
549        .collect();
550
551    // One per day, for the last RETAIN_DAYS days. "Today" is the newest
552    // snapshot's own day rather than the wall clock: retention is then a
553    // function of the directory's contents and nothing else, so it is
554    // deterministic and testable — and a database left untouched for a year does
555    // not have its entire history deleted by the first write after it wakes up.
556    if let Some(today) = snapshots.iter().filter_map(|(_, _, day)| *day).max() {
557        let horizon = today - (RETAIN_DAYS - 1);
558        let mut newest_of_day: std::collections::BTreeMap<i64, &PathBuf> =
559            std::collections::BTreeMap::new();
560        // Ascending by seq, so the last write for a day wins its slot.
561        for (_, path, day) in &snapshots {
562            if let Some(day) = *day {
563                if day >= horizon {
564                    newest_of_day.insert(day, path);
565                }
566            }
567        }
568        keep.extend(newest_of_day.into_values());
569    }
570
571    let doomed: Vec<PathBuf> = snapshots
572        .iter()
573        .filter(|(_, path, _)| !keep.contains(path))
574        .map(|(_, path, _)| path.clone())
575        .collect();
576
577    // No directory sync after these (W8.3, D-186), and the asymmetry is the
578    // point: a deletion that a crash undoes resurrects a *valid* snapshot,
579    // which the next pass deletes again. A creation that a crash undoes loses
580    // the anchor. Durability is owed to the name that has to be there, not to
581    // the name that has to be gone.
582    let mut removed = 0;
583    for path in doomed {
584        if let Err(e) = fs::remove_file(&path) {
585            tracing::warn!("failed to remove expired snapshot {path:?}: {e}");
586        } else {
587            removed += 1;
588        }
589    }
590
591    Ok(removed)
592}
593
594// ---------------------------------------------------------------------------
595// The maintenance cadence (§5.5, D-053)
596// ---------------------------------------------------------------------------
597
598/// How often the maintenance task writes an anchor (§5.5).
599///
600/// §5.5 specifies "every 10,000 log entries", which is a *distance* rather than
601/// a schedule — the point is to bound how much delta a reconstruction has to
602/// fold, and delta is measured in log entries, not seconds. An idle database
603/// therefore writes nothing at all, however long it stays open.
604///
605/// `poll_interval` is how often that distance is checked, and it is the part
606/// §5.5 does not specify because it is an implementation cost rather than a
607/// property: the check is `SELECT MAX(seq_id)`, an index lookup on an integer
608/// primary key, so the interval trades a negligible read against how promptly a
609/// burst of writes is noticed.
610#[derive(Debug, Clone, Copy, PartialEq, Eq)]
611pub struct SnapshotCadence {
612    /// Write an anchor once the log has grown this many entries past the last.
613    pub every_entries: i64,
614    /// How often to compare the log's head against the last anchor.
615    pub poll_interval: std::time::Duration,
616}
617
618impl Default for SnapshotCadence {
619    fn default() -> Self {
620        Self {
621            every_entries: 10_000,
622            poll_interval: std::time::Duration::from_secs(5),
623        }
624    }
625}
626
627/// The newest anchor already on disk, as a `seq_id`, or 0 if there is none.
628///
629/// Read from the filenames rather than remembered across runs: a process that
630/// starts against a database someone else has been writing should not re-anchor
631/// immediately, and the files are the only record of what has been anchored.
632///
633/// Left on the caller's worker where [`save_and_prune`] was moved off it
634/// (0.13.11, W8.1): one `read_dir` over a directory retention holds to about
635/// `RETAIN + RETAIN_DAYS` entries, no file opened and nothing decompressed,
636/// run once when the cadence starts. `spawn_blocking` is not free, and paying
637/// it to move a bounded directory listing would be cargo-culting the fix.
638fn newest_anchor_on_disk(snapshots_dir: &Path) -> i64 {
639    let Ok(entries) = fs::read_dir(snapshots_dir) else {
640        return 0;
641    };
642    entries
643        .flatten()
644        .map(|e| e.path())
645        .filter_map(|p| seq_from_filename(&p))
646        .max()
647        .unwrap_or(0)
648}
649
650async fn log_head(conn: &libsql::Connection) -> Result<Option<(i64, String)>> {
651    let mut rows = conn
652        .query(
653            "SELECT MAX(seq_id), MAX(recorded_at) FROM transaction_log",
654            (),
655        )
656        .await?;
657    let Some(row) = rows.next().await? else {
658        return Ok(None);
659    };
660    match (row.get::<i64>(0), row.get::<String>(1)) {
661        (Ok(seq), Ok(ts)) => Ok(Some((seq, ts))),
662        // An empty log yields one row of NULLs, not zero rows.
663        _ => Ok(None),
664    }
665}
666
667/// The read-side maintenance task §5.5 specifies (D-053).
668///
669/// Everything it does is a read plus a file write, so it never touches the write
670/// connection and cannot lengthen the actor's loop — which is the whole reason
671/// §5.5 puts snapshotting on the read side, since §5.1.5's latency bound is a
672/// property of how long that loop can take.
673///
674/// It anchors at `MAX(recorded_at)` rather than at the clock's `now()`. The two
675/// differ by however long it has been since the last write, and anchoring at a
676/// timestamp *after* the newest entry would produce a snapshot whose contents
677/// are identical but whose name and header claim a later instant than anything
678/// it reflects. Anchoring at the newest belief keeps the file honest about what
679/// it is a snapshot *of*.
680///
681/// Failures are logged and retried on the next tick rather than ending the task.
682/// A snapshot is a cache: failing to write one costs a slower reconstruction and
683/// nothing else, and a maintenance task that exits on its first transient error
684/// is indistinguishable from one that was never spawned.
685pub(crate) async fn run_cadence(
686    conn: libsql::Connection,
687    snapshots_dir: PathBuf,
688    archive_path: PathBuf,
689    cadence: SnapshotCadence,
690    mut stop: tokio::sync::watch::Receiver<bool>,
691) {
692    let mut anchored = newest_anchor_on_disk(&snapshots_dir);
693
694    loop {
695        tokio::select! {
696            biased;
697            // Dropped sender counts as a stop, so a `Database` that is dropped
698            // rather than closed does not leave this running against a
699            // connection whose database is going away.
700            _ = stop.changed() => return,
701            _ = tokio::time::sleep(cadence.poll_interval) => {}
702        }
703
704        let head = match log_head(&conn).await {
705            Ok(Some(head)) => head,
706            Ok(None) => continue,
707            Err(e) => {
708                tracing::warn!("snapshot cadence: could not read the log head: {e}");
709                continue;
710            }
711        };
712        let (max_seq, ts) = head;
713
714        if max_seq - anchored < cadence.every_entries {
715            continue;
716        }
717
718        let archive = archive_path.exists().then_some(archive_path.as_path());
719        match write_final(&conn, &snapshots_dir, &ts, archive).await {
720            Ok(path) => {
721                anchored = seq_from_filename(&path).unwrap_or(max_seq);
722                tracing::debug!("snapshot cadence: anchored at seq {anchored} ({path:?})");
723            }
724            Err(e) => {
725                // Deliberately does not advance `anchored`: the next tick
726                // retries rather than waiting another whole interval's worth of
727                // entries after a failure.
728                tracing::warn!("snapshot cadence: failed to write an anchor: {e}");
729            }
730        }
731    }
732}
733
734/// Wrap arbitrary plaintext in a container that passes every check the checksum
735/// guards (0.13.14, W8.4,
736/// [D-187](../../docs/architecture/s13-decision-register.md#d-187)).
737///
738/// **A checksummed format is fuzz-hostile, and this is the answer to that.**
739/// Coverage-guided mutation finds a four-byte magic quickly; it does not find a
740/// CRC-32 that has to agree with 34 header bytes *and* the whole payload. A
741/// fuzzer pointed at the container as a whole therefore spends its budget being
742/// turned away at step two and never reaches zstd or bincode — the two
743/// components W8.2 bounded, and the two where a real defect would live. This
744/// builds the container the way [`save_snapshot`] builds it, around whatever
745/// bytes it is handed, which puts every input past the gate.
746///
747/// It is the same move the W8.2 unit tests make when they forge a *valid*
748/// checksum on purpose: what is under test is the reader once integrity has
749/// been satisfied by something that computed it deliberately, because that is
750/// the case the bounds after the checksum exist for.
751#[cfg(any(test, feature = "fuzzing"))]
752pub(crate) fn wrap_plaintext(plain: &[u8]) -> Vec<u8> {
753    // Level 3, as `save_snapshot` uses. A caller mutating the plaintext is
754    // mutating what the deserializer sees, which is the point; the compression
755    // in between is not what is being explored.
756    let compressed = zstd::encode_all(plain, 3).expect("in-memory zstd encode");
757    wrap_payload(&compressed, plain.len() as u64)
758}
759
760/// [`wrap_plaintext`] one layer lower: a valid container around bytes that do
761/// not have to be a zstd frame, under a plaintext length that does not have to
762/// be true (0.13.14, W8.4, D-187).
763///
764/// This is the shape that reaches step 3 of the reader — decompression bounded
765/// by a *declared* length — with the checksum already satisfied. It is how a
766/// decompression bomb is expressed: a frame that expands to far more than the
767/// header admits to, signed correctly, which is the case
768/// [D-185](../../docs/architecture/s13-decision-register.md#d-185) argues the
769/// bound must survive because the checksum cannot help with it.
770#[cfg(any(test, feature = "fuzzing"))]
771pub(crate) fn wrap_payload(payload: &[u8], plain_len: u64) -> Vec<u8> {
772    let header = snapshot_header(
773        crate::schema::migrations::SCHEMA_VERSION,
774        0,
775        payload,
776        plain_len,
777    );
778    let mut out = Vec::with_capacity(header.len() + payload.len());
779    out.extend_from_slice(&header);
780    out.extend_from_slice(payload);
781    out
782}
783
784// ---------------------------------------------------------------------------
785// Doors for `fuzz/`, and for nothing else (0.13.14, W8.4, D-187)
786// ---------------------------------------------------------------------------
787
788/// Reachable only with `--features fuzzing`, which nothing but `fuzz/` turns on
789/// (0.13.14, W8.4,
790/// [D-187](../../docs/architecture/s13-decision-register.md#d-187)).
791///
792/// `#[doc(hidden)]` and feature-gated rather than public: these are not an API,
793/// they are the two places a fuzz harness has to reach that a caller has no
794/// business reaching. The default build does not compile this module at all, so
795/// the crate's public surface is unchanged by its existence.
796#[cfg(feature = "fuzzing")]
797#[doc(hidden)]
798pub mod fuzzing {
799    use super::*;
800
801    /// Exactly what [`load_snapshot`] does once it has the bytes.
802    ///
803    /// The fuzz target for the container as a whole. Every input is a candidate
804    /// file; the property is that it comes back as a state or as a **named**
805    /// error, and never as a panic.
806    pub fn parse(raw: &[u8]) -> Result<MaterializedState> {
807        parse_snapshot("<fuzz>", raw)
808    }
809
810    /// `super::wrap_plaintext`, which the in-suite mutation tests also use —
811    /// one construction, so the fuzzer and the deterministic tests are
812    /// exercising the same container and not two descriptions of one.
813    ///
814    /// The input is the **plaintext**, so what a fuzzer explores through this
815    /// door is `bincode`'s decoder: zstd always sees a frame this function just
816    /// produced.
817    pub fn wrap_plaintext(plain: &[u8]) -> Vec<u8> {
818        super::wrap_plaintext(plain)
819    }
820
821    /// `super::wrap_payload`: a correct container around bytes that need not
822    /// be a zstd frame, under a plaintext length that need not be true.
823    ///
824    /// The door for the layer between the other two. What a fuzzer explores
825    /// through this one is **zstd** and the declared-length bound — including
826    /// the decompression bomb, which is the one input in this format whose
827    /// checksum can be perfectly correct and whose reader still has to refuse
828    /// it.
829    pub fn wrap_payload(payload: &[u8], plain_len: u64) -> Vec<u8> {
830        super::wrap_payload(payload, plain_len)
831    }
832
833    /// Take a real snapshot apart, so a corpus for the two inner targets can be
834    /// derived from a file `save_snapshot` actually wrote.
835    ///
836    /// This exists so that seeds are never *transcribed*. A seed generator that
837    /// built its own plaintext would be a second description of what the writer
838    /// produces, drifting the first time either end changes — and a corpus that
839    /// has drifted still looks like a corpus, so nothing would say so. Reading
840    /// the payload out of a genuine container cannot be wrong about the format
841    /// while the format is what this build writes.
842    ///
843    /// `None` for anything that is not a container this build recognises.
844    pub fn payload_of(container: &[u8]) -> Option<(&[u8], u64)> {
845        if container.len() < SNAP_HEADER_LEN || container[0..4] != SNAP_MAGIC {
846            return None;
847        }
848        let plain_len = u64::from_le_bytes(container[26..34].try_into().ok()?);
849        Some((&container[SNAP_HEADER_LEN..], plain_len))
850    }
851}
852
853#[cfg(test)]
854mod tests {
855    use super::*;
856    use crate::temporal::as_of::NodeAttributes;
857    use std::collections::HashMap;
858    use std::sync::atomic::{AtomicU64, Ordering};
859    use std::sync::Arc;
860
861    const TS: &str = "2026-08-24T12:00:00.000000Z";
862
863    /// A state big enough that serializing and compressing it is measurable
864    /// work rather than a few microseconds.
865    ///
866    /// The size is the test: a state small enough to compress instantly would
867    /// pass whether the work was offloaded or not, because the current-thread
868    /// executor would never get a turn either way.
869    fn bulky_state(seq: i64) -> MaterializedState {
870        let mut concepts = HashMap::new();
871        for i in 0..20_000u32 {
872            concepts.insert(
873                format!("c{i}"),
874                NodeAttributes {
875                    id: format!("c{i}"),
876                    title: format!("concept number {i}"),
877                    content: format!("{i} ").repeat(40),
878                    embedding_model: None,
879                },
880            );
881        }
882        MaterializedState {
883            seq_anchor: seq,
884            timestamp: TS.to_string(),
885            concepts,
886            edges: Vec::new(),
887            predates_recorded_history: false,
888        }
889    }
890
891    /// §2.4, and the property W8.1 exists for.
892    ///
893    /// On a **current-thread** runtime there is exactly one worker, so "does
894    /// this block the runtime" stops being a question about load and becomes a
895    /// question about whether any other task runs at all. Inline, the whole
896    /// serialize-compress-write-fsync sequence sits between two scheduling
897    /// points and the ticker gets zero turns; offloaded, awaiting the join
898    /// handle yields and the ticker runs for the duration.
899    ///
900    /// The single-worker runtime is also what makes this a regression test
901    /// against the wrong fix: `block_in_place` would move the work off the
902    /// *async* path but panics outside a multi-threaded runtime, so a rewrite
903    /// that reached for it would fail here rather than in production.
904    #[test]
905    fn the_snapshot_write_does_not_hold_the_runtime() {
906        let dir = tempfile::tempdir().unwrap();
907        let rt = tokio::runtime::Builder::new_current_thread()
908            .enable_all()
909            .build()
910            .unwrap();
911
912        let ticks = rt.block_on(async {
913            let ticks = Arc::new(AtomicU64::new(0));
914            let counter = Arc::clone(&ticks);
915            let ticker = tokio::spawn(async move {
916                loop {
917                    counter.fetch_add(1, Ordering::Relaxed);
918                    tokio::task::yield_now().await;
919                }
920            });
921
922            save_and_prune(dir.path().to_path_buf(), bulky_state(1))
923                .await
924                .expect("the snapshot must still be written");
925
926            ticker.abort();
927            ticks.load(Ordering::Relaxed)
928        });
929
930        assert!(
931            ticks > 0,
932            "no other task ran while the snapshot was being written: the \
933             serialisation is back on the runtime worker (§2.4, W8.1)"
934        );
935    }
936
937    /// Moving the work to another thread must not change what lands on disk.
938    ///
939    /// The offload is a scheduling change and nothing else, so the file it
940    /// produces has to be the file [`save_snapshot`] produced when the same
941    /// call ran inline — same name, same header, same state coming back out.
942    #[test]
943    fn a_snapshot_written_off_thread_reads_back_unchanged() {
944        let dir = tempfile::tempdir().unwrap();
945        let rt = tokio::runtime::Builder::new_current_thread()
946            .enable_all()
947            .build()
948            .unwrap();
949
950        let state = bulky_state(77);
951        let path = rt
952            .block_on(save_and_prune(dir.path().to_path_buf(), state.clone()))
953            .unwrap();
954
955        assert_eq!(seq_from_filename(&path), Some(77));
956        let loaded = load_snapshot(&path).unwrap();
957        assert_eq!(loaded.seq_anchor, state.seq_anchor);
958        assert_eq!(loaded.timestamp, state.timestamp);
959        assert_eq!(loaded.concepts.len(), state.concepts.len());
960        assert_eq!(loaded.concepts["c19999"], state.concepts["c19999"]);
961    }
962
963    /// Rewrite a saved snapshot's header with a doctored `plain_len`, checksum
964    /// and all.
965    ///
966    /// The checksum is *recomputed*, which is the point: these tests are about
967    /// what the reader does when the integrity field has already been
968    /// satisfied. CRC-32 is detection, not authentication, and anything with
969    /// write access to the directory can produce a file that verifies — so the
970    /// bounds below have to hold on their own.
971    fn forge_plain_len(path: &Path, plain_len: u64) {
972        let mut raw = fs::read(path).unwrap();
973        raw[26..34].copy_from_slice(&plain_len.to_le_bytes());
974        let mut crc = Crc32::new();
975        crc.update(&raw[..SNAP_CRC_OFFSET]);
976        crc.update(&raw[SNAP_HEADER_LEN..]);
977        let checksum = crc.finish().to_le_bytes();
978        raw[SNAP_CRC_OFFSET..SNAP_HEADER_LEN].copy_from_slice(&checksum);
979        fs::write(path, &raw).unwrap();
980    }
981
982    /// §3.3, stated as a bound rather than as a hope.
983    ///
984    /// The header says ten plaintext bytes; the payload is a real zstd frame
985    /// holding a whole state. The reader is bounded to `plain_len + 1`, so it
986    /// stops eleven bytes in — it does not decompress the frame to find out how
987    /// wrong the header was, which is the behaviour that made an unbounded
988    /// loader a denial-of-service surface rather than a bug.
989    #[test]
990    fn a_payload_larger_than_its_declared_length_stops_at_the_bound() {
991        let dir = tempfile::tempdir().unwrap();
992        let path = save_snapshot(dir.path(), &bulky_state(3)).unwrap();
993        forge_plain_len(&path, 10);
994
995        match load_snapshot(&path).unwrap_err() {
996            DbError::SnapshotCorrupt { reason, .. } => {
997                assert!(
998                    reason.contains("10 plaintext bytes") && reason.contains("11"),
999                    "the bound must be what stopped it, and it must say so: {reason}"
1000                );
1001            }
1002            other => panic!("expected SnapshotCorrupt, got {other:?}"),
1003        }
1004    }
1005
1006    /// The other direction, and the reason the check is an equality.
1007    ///
1008    /// A header claiming *more* than the frame holds cannot exhaust anything —
1009    /// the frame ends and the reader stops. Rejecting it anyway is what keeps
1010    /// the declared length a fact about the file rather than a ceiling: a
1011    /// reader that accepted a short frame under a large declaration would be
1012    /// accepting a truncated payload that happened to end on a frame boundary.
1013    #[test]
1014    fn a_payload_smaller_than_its_declared_length_is_refused_too() {
1015        let dir = tempfile::tempdir().unwrap();
1016        let path = save_snapshot(dir.path(), &bulky_state(4)).unwrap();
1017        forge_plain_len(&path, u32::MAX as u64);
1018
1019        match load_snapshot(&path).unwrap_err() {
1020            DbError::SnapshotCorrupt { reason, .. } => {
1021                assert!(reason.contains("plaintext bytes"), "{reason}");
1022            }
1023            other => panic!("expected SnapshotCorrupt, got {other:?}"),
1024        }
1025    }
1026
1027    /// A declared length no allocator would survive must not reach an
1028    /// allocator.
1029    ///
1030    /// `u64::MAX` is the number a corrupt or hostile header reaches for, and
1031    /// the `saturating_add` in the reader is what keeps `plain_len + 1` from
1032    /// wrapping to zero and reading nothing at all. What bounds the work here
1033    /// is the frame itself, which ends where it ends — the failure is the
1034    /// length check afterwards, not an allocation.
1035    #[test]
1036    fn a_declared_length_of_u64_max_neither_wraps_nor_allocates() {
1037        let dir = tempfile::tempdir().unwrap();
1038        let path = save_snapshot(dir.path(), &bulky_state(5)).unwrap();
1039        forge_plain_len(&path, u64::MAX);
1040
1041        match load_snapshot(&path).unwrap_err() {
1042            DbError::SnapshotCorrupt { reason, .. } => {
1043                assert!(
1044                    reason.contains(&format!("{} plaintext bytes", u64::MAX)),
1045                    "{reason}"
1046                );
1047            }
1048            other => panic!("expected SnapshotCorrupt, got {other:?}"),
1049        }
1050    }
1051
1052    /// The checksum covers the header, so the forgery helper above has to be a
1053    /// forgery — if it did not recompute the field, every test using it would
1054    /// be passing for the wrong reason.
1055    #[test]
1056    fn doctoring_the_header_without_the_checksum_fails_earlier() {
1057        let dir = tempfile::tempdir().unwrap();
1058        let path = save_snapshot(dir.path(), &bulky_state(6)).unwrap();
1059
1060        let mut raw = fs::read(&path).unwrap();
1061        raw[26..34].copy_from_slice(&10u64.to_le_bytes());
1062        fs::write(&path, &raw).unwrap();
1063
1064        match load_snapshot(&path).unwrap_err() {
1065            DbError::SnapshotCorrupt { reason, .. } => {
1066                assert!(reason.contains("checksum mismatch"), "{reason}");
1067            }
1068            other => panic!("expected SnapshotCorrupt, got {other:?}"),
1069        }
1070    }
1071
1072    /// The portability fact the `unix` branch rests on, asserted directly
1073    /// rather than through a snapshot write (0.13.13, W8.3).
1074    ///
1075    /// POSIX permits `fsync` on a directory descriptor to fail with `EINVAL`,
1076    /// and some filesystems take it up on that. If this platform were one of
1077    /// them, *every* `save_snapshot` would now fail — a large consequence for a
1078    /// call whose whole purpose is invisible when it works — so the question
1079    /// gets its own test with its own name.
1080    #[cfg(unix)]
1081    #[test]
1082    fn a_directory_handle_can_be_synced() {
1083        let dir = tempfile::tempdir().unwrap();
1084        sync_directory(dir.path()).expect("fsync on a directory descriptor");
1085    }
1086
1087    /// Off unix this does nothing, and *nothing* is the behaviour under test
1088    /// (0.13.13, W8.3).
1089    ///
1090    /// A path that does not exist would be an error from any implementation
1091    /// that touched the filesystem, so a green here says the branch really is
1092    /// inert — which is what the docs claim, and a claim about a no-op is the
1093    /// kind that rots quietly if nobody writes it down as an assertion.
1094    #[cfg(not(unix))]
1095    #[test]
1096    fn the_directory_sync_is_inert_off_unix() {
1097        let dir = tempfile::tempdir().unwrap();
1098        sync_directory(&dir.path().join("no-such-directory"))
1099            .expect("the non-unix branch has nothing that can fail");
1100    }
1101
1102    /// The publish step is a rename, not a copy: a `.tmp` surviving a
1103    /// successful save would mean the file at the final name got there some
1104    /// other way, and the atomicity W8.3 makes durable would be gone with it.
1105    #[test]
1106    fn a_completed_save_leaves_no_temporary_behind() {
1107        let dir = tempfile::tempdir().unwrap();
1108        let path = save_snapshot(dir.path(), &bulky_state(7)).unwrap();
1109        assert!(path.exists(), "the snapshot is at its final name");
1110
1111        let leftovers: Vec<PathBuf> = fs::read_dir(dir.path())
1112            .unwrap()
1113            .flatten()
1114            .map(|e| e.path())
1115            .filter(|p| p.extension().and_then(|e| e.to_str()) == Some("tmp"))
1116            .collect();
1117        assert!(leftovers.is_empty(), "left behind {leftovers:?}");
1118    }
1119
1120    // -----------------------------------------------------------------------
1121    // The deterministic half of W8.4 (0.13.14, D-187)
1122    //
1123    // `cargo-fuzz` needs nightly and libFuzzer and does not run on Windows, so
1124    // it runs in CI and nowhere else. These do the same job on every platform
1125    // and in every `cargo test`, exhaustively rather than randomly, and they
1126    // are what a finding from the fuzzer would be pinned as.
1127    // -----------------------------------------------------------------------
1128
1129    /// Small enough that flipping every bit of the container is cheap, and not
1130    /// so small that the payload is a single zstd literal block.
1131    fn modest_state(seq: i64) -> MaterializedState {
1132        let mut concepts = HashMap::new();
1133        for i in 0..8u32 {
1134            concepts.insert(
1135                format!("c{i}"),
1136                NodeAttributes {
1137                    id: format!("c{i}"),
1138                    title: format!("concept {i}"),
1139                    content: format!("some content for {i} ").repeat(3),
1140                    embedding_model: (i % 2 == 0).then(|| "model-a".to_string()),
1141                },
1142            );
1143        }
1144        MaterializedState {
1145            seq_anchor: seq,
1146            timestamp: TS.to_string(),
1147            concepts,
1148            edges: vec![(
1149                "c0".to_string(),
1150                "c1".to_string(),
1151                "relates_to".to_string(),
1152                TS.to_string(),
1153                "A".to_string(),
1154            )],
1155            predates_recorded_history: false,
1156        }
1157    }
1158
1159    /// Every failure a reader may report about a *file*. Anything else — a
1160    /// panic, or an error naming the ledger — is the finding.
1161    fn assert_named_refusal(what: &str, err: DbError) {
1162        match err {
1163            DbError::SnapshotCorrupt { .. } | DbError::SnapshotIncompatible { .. } => {}
1164            other => panic!("{what}: expected a named snapshot error, got {other:?}"),
1165        }
1166    }
1167
1168    /// The container's whole promise, asserted exhaustively rather than
1169    /// sampled: **change any bit of a snapshot and it is refused.**
1170    ///
1171    /// That this holds is not luck. Bytes 0..34 and the payload are under the
1172    /// checksum, bytes 34..38 *are* the checksum, and CRC-32 detects every
1173    /// single-bit error by construction — so there is no byte of the file where
1174    /// a flip can go unnoticed, and the test says so for all of them instead of
1175    /// asserting it for the three a hand-written case would have picked.
1176    ///
1177    /// The clean parse first is deliberate. A fixture that does not load makes
1178    /// every assertion below pass for the wrong reason, which is exactly how
1179    /// [D-054](../../docs/architecture/s13-decision-register.md#d-054)'s
1180    /// retention tests spent a release exercising a path they did not name.
1181    #[test]
1182    fn every_single_bit_flip_in_a_snapshot_is_refused() {
1183        let dir = tempfile::tempdir().unwrap();
1184        let path = save_snapshot(dir.path(), &modest_state(1)).unwrap();
1185        let clean = fs::read(&path).unwrap();
1186
1187        parse_snapshot("clean", &clean)
1188            .expect("the fixture must load, or nothing below means anything");
1189
1190        let mut refused = 0usize;
1191        for byte in 0..clean.len() {
1192            for bit in 0..8u8 {
1193                let mut damaged = clean.clone();
1194                damaged[byte] ^= 1 << bit;
1195                match parse_snapshot("damaged", &damaged) {
1196                    Ok(_) => panic!("bit {bit} of byte {byte} changed and the file still loaded"),
1197                    Err(e) => {
1198                        assert_named_refusal(&format!("bit {bit} of byte {byte}"), e);
1199                        refused += 1;
1200                    }
1201                }
1202            }
1203        }
1204        assert_eq!(refused, clean.len() * 8, "every bit of the file was tried");
1205    }
1206
1207    /// Every prefix of a snapshot is refused, and so is every snapshot with
1208    /// anything appended to it.
1209    ///
1210    /// Truncation is the shape an atomic rename was supposed to make
1211    /// impossible ([D-043](../../docs/architecture/s13-decision-register.md#d-043))
1212    /// and a filesystem that loses the tail of a file it acknowledged can still
1213    /// produce. Trailing bytes are the shape a partially-overwritten file
1214    /// takes. Both are caught by the declared length before anything is
1215    /// hashed, which is why they are cheap enough to test for every length.
1216    #[test]
1217    fn every_truncation_and_every_extension_is_refused() {
1218        let dir = tempfile::tempdir().unwrap();
1219        let path = save_snapshot(dir.path(), &modest_state(2)).unwrap();
1220        let clean = fs::read(&path).unwrap();
1221
1222        for cut in 0..clean.len() {
1223            match parse_snapshot("cut", &clean[..cut]) {
1224                Ok(_) => panic!("a {cut}-byte prefix loaded as a whole snapshot"),
1225                Err(e) => assert_named_refusal(&format!("{cut}-byte prefix"), e),
1226            }
1227        }
1228
1229        for extra in [1usize, 7, 64, 4096] {
1230            let mut grown = clean.clone();
1231            grown.extend(std::iter::repeat_n(0u8, extra));
1232            match parse_snapshot("grown", &grown) {
1233                Ok(_) => panic!("{extra} appended bytes went unnoticed"),
1234                Err(e) => assert_named_refusal(&format!("{extra} appended bytes"), e),
1235            }
1236        }
1237    }
1238
1239    /// The half a fuzzer cannot reach on its own: **arbitrary bytes behind a
1240    /// checksum that agrees with them.**
1241    ///
1242    /// `wrap_plaintext` recomputes the header and the CRC, so every case here
1243    /// clears steps 1–3 of the reader and lands squarely on zstd and bincode,
1244    /// which is where W8.2's bounds live and where a panic would be a real
1245    /// defect. Some of these deserialize into a perfectly valid — and quite
1246    /// wrong — `MaterializedState`, which is not a failure: nothing in this
1247    /// format claims to detect damage that arrives with a correct checksum, and
1248    /// [D-185](../../docs/architecture/s13-decision-register.md#d-185) says so
1249    /// in as many words. The property is that the reader answers rather than
1250    /// dies.
1251    #[test]
1252    fn arbitrary_plaintext_behind_a_valid_checksum_never_panics() {
1253        let plain = bincode::serialize(&modest_state(3)).unwrap();
1254
1255        let mut answered = 0usize;
1256        for byte in 0..plain.len() {
1257            for bit in [0u8, 3, 7] {
1258                let mut mutated = plain.clone();
1259                mutated[byte] ^= 1 << bit;
1260                match parse_snapshot("wrapped", &wrap_plaintext(&mutated)) {
1261                    Ok(_) => answered += 1,
1262                    Err(e) => {
1263                        assert_named_refusal(&format!("bit {bit} of plaintext byte {byte}"), e);
1264                        answered += 1;
1265                    }
1266                }
1267            }
1268        }
1269        assert_eq!(answered, plain.len() * 3);
1270
1271        // Shapes a bit flip cannot produce: nothing at all, a run of zeros, and
1272        // a plaintext far longer than any state this fixture describes.
1273        for odd in [vec![], vec![0u8; 1], vec![0u8; 4096], vec![0xFFu8; 64]] {
1274            match parse_snapshot("odd", &wrap_plaintext(&odd)) {
1275                Ok(_) => {}
1276                Err(e) => assert_named_refusal("an odd plaintext", e),
1277            }
1278        }
1279    }
1280
1281    /// A decompression bomb with a **correct** checksum, which is the one
1282    /// damaged input this format cannot detect by hashing and has to refuse by
1283    /// arithmetic (0.13.14, W8.4).
1284    ///
1285    /// 64 MiB of zeros compresses to a few hundred bytes. The container built
1286    /// around it here is entirely well-formed — magic, versions, both lengths
1287    /// and a CRC that agrees with every byte — and it declares a plaintext of
1288    /// 1,024 bytes. A reader that decompressed first and checked afterwards
1289    /// would allocate the full 64 MiB to discover that; the `take(plain_len +
1290    /// 1)` bound stops it 65,535 KiB short, which is what the reported length
1291    /// in the error proves.
1292    ///
1293    /// This is the "never an allocation storm" half of W8.4 asserted where a
1294    /// deterministic test can assert it. The other half — arbitrary frames,
1295    /// arbitrary declared lengths — is `fuzz_targets/snapshot_frame.rs` under
1296    /// libFuzzer's own `-malloc_limit_mb`, which is the tool built for it.
1297    #[test]
1298    fn a_decompression_bomb_with_a_valid_checksum_stops_at_the_declared_length() {
1299        let bomb = zstd::encode_all(&vec![0u8; 64 * 1024 * 1024][..], 3).unwrap();
1300        assert!(
1301            bomb.len() < 64 * 1024,
1302            "the fixture must actually be a bomb"
1303        );
1304
1305        let container = wrap_payload(&bomb, 1024);
1306        match parse_snapshot("bomb", &container).unwrap_err() {
1307            DbError::SnapshotCorrupt { reason, .. } => {
1308                assert!(
1309                    reason.contains("1024 plaintext bytes") && reason.contains("1025"),
1310                    "the reader should stop one byte past the declared length: {reason}"
1311                );
1312            }
1313            other => panic!("expected SnapshotCorrupt, got {other:?}"),
1314        }
1315    }
1316}