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}