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