sqlite_core/lib.rs
1//! `sqlite-core` — native, read-only, panic-free `SQLite` file-format reader.
2//!
3//! Parses the 100-byte file header (magic + page size), walks table b-trees
4//! (interior + leaf) yielding rows as typed [`Value`]s, reassembles
5//! overflow-page chains for large payloads, walks the freelist
6//! ([`Database::freelist_pages`]), and applies a read-only `-wal` overlay
7//! ([`Database::open_with_wal`]) — all bounds-checked and panic-free on crafted
8//! input. [`Database::carve_cells`] recognizes record-shaped cells in
9//! free/unallocated space for the analyzer's deleted-record recovery. The bespoke
10//! [`WalTimeline`] ([`Database::wal_timeline`]) models a `-wal` as a salt-bounded
11//! segment of materializable [`CommitSnapshot`]s for "carve all snapshots".
12//!
13//! Format constants are consumed from [`forensicnomicon::sqlite`] (the KNOWLEDGE
14//! leaf) where exposed; a few not-yet-promoted offsets (reserved-space 20,
15//! in-header DB-size 28, freelist-count 36) are held locally and flagged for
16//! promotion. Still out of scope: index b-trees, `WITHOUT ROWID` tables,
17//! UTF-16 text, and WAL frame-checksum verification.
18
19#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]
20
21use forensicnomicon::sqlite::{
22 SQLITE_FREELIST_TRUNK_OFFSET, SQLITE_HEADER_SIZE, SQLITE_MAGIC, SQLITE_PAGE_SIZE_OFFSET,
23};
24
25/// Byte offset of the 1-byte "reserved space per page" field in the file header
26/// (file-format §1.3.4). forensicnomicon does not yet expose this; WS-E should
27/// promote it into `forensicnomicon::sqlite`.
28const RESERVED_SPACE_OFFSET: usize = 20;
29
30/// Byte offset of the 4-byte big-endian text-encoding field in the file header
31/// (file-format §1.3.1). forensicnomicon does not yet expose this; promote it
32/// into `forensicnomicon::sqlite` alongside [`RESERVED_SPACE_OFFSET`].
33const TEXT_ENCODING_OFFSET: usize = 56;
34
35/// Byte offset of the in-header database size, in pages (file-format §1.3.6).
36/// 4-byte big-endian. Valid only when it equals the change counter at offset 24
37/// (a "size is valid" sentinel); the file-length fallback covers the rest.
38/// forensicnomicon does not yet expose this — promote it in a later pass.
39const DB_SIZE_IN_PAGES_OFFSET: usize = 28;
40
41/// Byte offset of the freelist page **count** in the file header (file-format
42/// §1.3.5). 4-byte big-endian. The trunk pointer lives at
43/// [`SQLITE_FREELIST_TRUNK_OFFSET`] (32); this count is the next field (36).
44const FREELIST_COUNT_OFFSET: usize = 36;
45
46/// Errors that can arise while reading a `SQLite` database, all recoverable —
47/// the reader never panics on malformed input.
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub enum Error {
50 /// File is shorter than the 100-byte header.
51 TooShort,
52 /// First 16 bytes are not the `SQLite format 3\0` magic.
53 BadMagic,
54 /// Page-size field is not a power of two in `[512, 65536]`.
55 BadPageSize(u32),
56 /// A page number referenced by the b-tree is out of range for the file.
57 PageOutOfRange(u32),
58 /// A b-tree page had an unexpected type byte where a table page was required.
59 NotATablePage(u8),
60 /// A cell pointer or payload ran past the end of its page.
61 TruncatedCell,
62 /// The b-tree was deeper / wider than the safety cap allows.
63 TooManyPages,
64 /// The freelist trunk chain cycled or exceeded the file's page count.
65 MalformedFreelist,
66 /// An overflow-page chain cycled or exceeded the file's page count.
67 MalformedOverflow,
68}
69
70/// A freed overflow-page chain could not be followed to a complete, trustworthy
71/// payload (task #73): a chain page that is not a freelist leaf (live / trunk /
72/// unreachable), a cycle, a premature terminator with bytes still owed, an
73/// out-of-range page, or a declared payload exceeding the freelist's capacity.
74/// Carries no detail by design — any break is a uniform "this chain is not
75/// recoverable as a Tier-1 row", and the candidate degrades to a Tier-2 fragment.
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub struct ChainBreak;
78
79/// A single decoded column value from a table row. Mirrors `SQLite`'s storage
80/// classes.
81#[derive(Debug, Clone, PartialEq)]
82pub enum Value {
83 Null,
84 Integer(i64),
85 Real(f64),
86 Text(String),
87 Blob(Vec<u8>),
88}
89
90/// One table row: its rowid plus decoded column values, in column order.
91#[derive(Debug, Clone, PartialEq)]
92pub struct Row {
93 pub rowid: i64,
94 pub values: Vec<Value>,
95}
96
97/// A record-shaped cell recovered from unallocated / free space by
98/// [`Database::carve_cells`]. Carries the decoded row plus enough provenance for
99/// the analyzer to grade it as a "consistent with a deleted row" observation.
100#[derive(Debug, Clone, PartialEq)]
101pub struct CarvedCell {
102 /// Byte offset of the cell within the page slice that was scanned.
103 pub offset: usize,
104 /// Total bytes the candidate cell occupies (cell header + payload), so the
105 /// scanner can skip past a recovered record.
106 pub byte_len: usize,
107 /// Decoded rowid varint.
108 pub rowid: i64,
109 /// Decoded column values, in column order.
110 pub values: Vec<Value>,
111 /// Heuristic confidence in `(0.0, 1.0]` that these bytes are a real record
112 /// rather than a coincidental match.
113 pub confidence: f32,
114}
115
116/// A **partial** deleted record salvaged from a freed-cell reconstruction that
117/// failed full-row validation: the maximal decodable column prefix at a
118/// structural anchor [`Database::reconstruct_freeblock_records`] already trusts.
119///
120/// Deliberately NOT a [`CarvedCell`]: it has no rowid (clobbered) and an
121/// incomplete value set, so the type system keeps it out of the full-row output
122/// — a fragment can never be silently rendered as a recovered row. Emitted only
123/// at an anchor where full reconstruction failed but at least one *distinctive*
124/// cell (TEXT ≥ 4 bytes of valid UTF-8, or REAL) decoded cleanly, so a lone
125/// coincidental integer pattern never anchors a fragment. Graded
126/// [`FRAGMENT_CONFIDENCE`] — strictly below every full-row class.
127#[derive(Debug, Clone, PartialEq)]
128pub struct CellFragment {
129 /// Byte offset of the failed cell's anchor within the scanned page slice.
130 pub offset: usize,
131 /// Bytes covered by the decoded prefix (anchor to the last decoded body byte).
132 pub byte_len: usize,
133 /// `(column_index, value)` for each column that decoded cleanly, ascending by
134 /// index. Column indexes come from the page's schema template, so they are
135 /// meaningful against the table's column order.
136 pub surviving: Vec<(usize, Value)>,
137 /// Number of the template's columns that did NOT decode (`column_count` minus
138 /// the number of surviving columns).
139 pub missing: usize,
140 /// Always [`FRAGMENT_CONFIDENCE`] for now; the field is kept so future
141 /// per-fragment grading does not change the public type.
142 pub confidence: f32,
143}
144
145/// A freed table-leaf cell whose declared payload **spills onto an overflow-page
146/// chain** (task #73). Recognized by [`try_carve_spilled_cell_at`] from the
147/// cell's intact local prefix; the chain itself is resolved separately
148/// ([`Database::read_freed_overflow_chain`]) because that needs whole-database
149/// access. A `SpilledCell` is deliberately NOT a [`CarvedCell`]: until its chain
150/// is walked and validated it cannot masquerade as a recovered row (secure by
151/// design — the type system keeps an unresolved spill out of the full-row output).
152#[derive(Debug, Clone, PartialEq)]
153pub struct SpilledCell {
154 /// Byte offset of the cell within the scanned slice.
155 pub offset: usize,
156 /// On-page footprint of the cell prefix: `n1 + n2 + local_len + 4`.
157 pub byte_len: usize,
158 /// Declared total payload length `P` (header + full body).
159 pub payload_len: usize,
160 /// Decoded rowid varint (intact-prefix anchors); `0` when the prefix was
161 /// clobbered and the rowid is unrecoverable (template path).
162 pub rowid: i64,
163 /// Full serial-type array, decoded from the local record header.
164 pub serials: Vec<i64>,
165 /// Local payload bytes kept on the leaf page (`local_payload_len(P, usable)`).
166 pub local_len: usize,
167 /// Offset, within the scanned slice, at which the local payload begins.
168 pub local_payload_off: usize,
169 /// First overflow-page number (big-endian u32 at `local_payload_off + local_len`).
170 pub first_overflow: u32,
171}
172
173/// Database text encoding (file-format §1.3, header byte 56). Determines how
174/// `TEXT` column bytes are decoded; a fixed property set at database creation.
175#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
176pub enum TextEncoding {
177 /// `1` (and `0`, an unwritten database): UTF-8.
178 #[default]
179 Utf8,
180 /// `2`: UTF-16 little-endian.
181 Utf16Le,
182 /// `3`: UTF-16 big-endian.
183 Utf16Be,
184}
185
186impl TextEncoding {
187 /// Decode a `TEXT` value's raw bytes per this encoding. Lossy so a corrupt
188 /// byte sequence yields U+FFFD rather than a panic or an error.
189 fn decode(self, bytes: &[u8]) -> String {
190 match self {
191 Self::Utf8 => String::from_utf8_lossy(bytes).into_owned(),
192 Self::Utf16Le => Self::decode_utf16(bytes, u16::from_le_bytes),
193 Self::Utf16Be => Self::decode_utf16(bytes, u16::from_be_bytes),
194 }
195 }
196
197 fn decode_utf16(bytes: &[u8], conv: fn([u8; 2]) -> u16) -> String {
198 // A trailing odd byte (truncated UTF-16) is dropped by chunks_exact.
199 let units: Vec<u16> = bytes
200 .chunks_exact(2)
201 .map(|c| conv([c[0], c[1]]))
202 .collect();
203 String::from_utf16_lossy(&units)
204 }
205}
206
207/// Parsed 100-byte `SQLite` file header.
208#[derive(Debug, Clone, Copy, PartialEq, Eq)]
209pub struct Header {
210 /// Logical page size in bytes (512..=65536).
211 pub page_size: u32,
212 /// Reserved bytes at the end of each page (usually 0).
213 pub reserved: u8,
214 /// Text encoding for `TEXT` columns (header byte 56).
215 pub text_encoding: TextEncoding,
216}
217
218impl Header {
219 /// Usable bytes per page = `page_size` − reserved (file-format §1.3.4).
220 #[must_use]
221 pub fn usable_size(self) -> u32 {
222 self.page_size.saturating_sub(u32::from(self.reserved))
223 }
224}
225
226/// A read-only view over the raw bytes of a `SQLite` database file.
227///
228/// Holds the whole file in memory — adequate for the spike and for browser
229/// evidence DBs (tens of MB). A `Read + Seek` / mmap backend is a later
230/// refinement and does not change the parsing logic proven here.
231pub struct Database {
232 bytes: Vec<u8>,
233 header: Header,
234 /// Read-only WAL overlay: newest committed page versions from a `-wal`
235 /// sidecar, applied without checkpointing (never mutates `bytes`).
236 /// `None` when opened without a WAL.
237 wal: Option<WalOverlay>,
238}
239
240/// The newest committed version of each WAL page, materialized into owned bytes.
241///
242/// Built once at open; `page_slice` consults it before the main file so a table
243/// walk transparently sees the WAL-applied view. Read-only: building it copies
244/// frame data out of the `-wal` sidecar and never writes back to either file.
245struct WalOverlay {
246 /// page number (1-based) → that page's newest committed contents.
247 pages: std::collections::BTreeMap<u32, Vec<u8>>,
248 /// Every committed frame's page image, in file order, with provenance. Unlike
249 /// `pages` (newest version per page, the consistent view), this keeps EACH
250 /// committed frame so the carver can recover deleted residue that a later
251 /// frame for the same page superseded in `pages` but that still survives in an
252 /// earlier frame's slack — the genuinely-different records an on-disk-only
253 /// carve cannot see.
254 frames: Vec<WalFramePage>,
255 /// The original `-wal` sidecar bytes, retained so [`Database::wal_timeline`]
256 /// can re-parse them into the richer segmented temporal model without the
257 /// caller re-supplying the file. Held read-only; never mutated.
258 raw: Vec<u8>,
259}
260
261/// One committed WAL frame's full page image plus its provenance, exposed by
262/// [`Database::wal_frame_pages`] so the deleted-record carver can scan the
263/// uncheckpointed WAL frames the main file does not yet reflect.
264///
265/// The `(salt1, salt2, frame_index)` triple is the WAL log-sequence identity that
266/// task #55 will formalize: `salt1`/`salt2` pin the checkpoint generation and
267/// `frame_index` the position within it.
268#[derive(Debug, Clone, PartialEq, Eq)]
269pub struct WalFramePage {
270 /// 0-based position of this frame within the `-wal` file (its LSN ordinal).
271 pub frame_index: usize,
272 /// 1-based database page number this frame rewrites.
273 pub page_no: u32,
274 /// WAL header salt-1 (checkpoint generation), shared by every live frame.
275 pub salt1: u32,
276 /// WAL header salt-2 (checkpoint generation), shared by every live frame.
277 pub salt2: u32,
278 /// Whether this is a COMMIT frame (`db_size_after_commit != 0`).
279 pub is_commit: bool,
280 /// The frame's full page image (`page_size` bytes).
281 pub page: Vec<u8>,
282}
283
284/// Hard cap on b-tree pages visited in one table walk, to bound work on a
285/// crafted file with cyclic interior pointers.
286const MAX_PAGES_PER_WALK: usize = 1_000_000;
287
288/// Minimum column count accepted when **inferring** a record's width during
289/// dropped-table carving. A coincidental byte run can look like a self-consistent
290/// 1-column record far too easily; requiring at least two columns (the smallest a
291/// real rowid table with a non-rowid column has) suppresses that false-positive
292/// class without losing real records.
293const MIN_INFERRED_COLUMNS: usize = 2;
294
295/// Confidence multiplier applied to records carved from an allocated page's
296/// in-page free space. Such residue is more often partially overwritten (its
297/// freeblock may have been reused) than whole-page freelist recovery, so it is
298/// graded a notch lower even when it parses cleanly.
299const IN_PAGE_CONFIDENCE_FACTOR: f32 = 0.8;
300
301/// Confidence multiplier applied to a **chain-reassembled overflow** full row
302/// (task #73, [`Database::carve_overflow_records`]). Overflow Tier-1 is NOT part
303/// of the structural 0-false-positive guarantee (Codex ruling #1): a freelist
304/// *leaf* page can be stale — allocated, overwritten, freed, now a leaf holding
305/// unrelated bytes that happen to decode. The freelist-leaf requirement plus the
306/// strict-UTF-8 gate make a clean decode strong evidence, but one indirection
307/// weaker than a contiguous in-page span, so it is graded below the in-page
308/// full-row tier (0.9 × this factor). The residual stale-leaf risk is documented
309/// and the row remains a "consistent with a deleted row" observation, never a
310/// verdict.
311const OVERFLOW_CHAIN_CONFIDENCE_FACTOR: f32 = 0.75;
312
313/// Confidence assigned to a record rebuilt by **freeblock reconstruction**
314/// ([`Database::reconstruct_freeblock_records`]). The cell's first four bytes
315/// (payload-length + rowid varints, the record `header_len`, and the leading
316/// serial type) were destroyed by freeblock conversion, so the record is rebuilt
317/// from its surviving serial-type tail plus a schema-derived header template — a
318/// weaker reconstruction than an intact-header carve, hence graded LOW (a
319/// "consistent with a deleted row" lead the examiner weighs, never a certainty).
320const FREEBLOCK_RECONSTRUCT_CONFIDENCE: f32 = 0.4;
321
322/// Confidence assigned to a Tier-2 [`CellFragment`] — a partial recovery whose
323/// full row could not be reconstructed but at least one distinctive cell survived.
324/// Flat 0.2 = the `MinConfidence::Low` threshold, one notch below freeblock
325/// reconstruction's 0.4 (= Medium): a fragment is the weakest lead in the ladder,
326/// "consistent with a partial deleted row", never a recovered row.
327const FRAGMENT_CONFIDENCE: f32 = 0.2;
328
329/// Upper bound on the number of freeblocks walked on a single page, to cap work
330/// on a crafted file whose freeblock `next` pointers form a long or cyclic chain.
331/// Real pages hold at most a few hundred cells.
332const MAX_FREEBLOCKS_PER_PAGE: usize = 4096;
333
334/// WAL magic, big-endian variant (native byte order in the page checksums; the
335/// little-endian variant `0x377f_0683` differs only in checksum endianness,
336/// which the overlay does not verify). file-format §4.1.
337const WAL_MAGIC_BE: u32 = 0x377f_0682;
338/// WAL magic, little-endian-checksum variant.
339const WAL_MAGIC_LE: u32 = 0x377f_0683;
340
341impl Database {
342 /// Parse the file header and validate magic + page size. No WAL overlay.
343 pub fn open(bytes: Vec<u8>) -> Result<Self, Error> {
344 let header = parse_header(&bytes)?;
345 Ok(Self {
346 bytes,
347 header,
348 wal: None,
349 })
350 }
351
352 /// Parse the main database plus a `-wal` sidecar, overlaying the newest
353 /// **committed** page versions from the WAL on top of the main file.
354 ///
355 /// This is the forensic-safe alternative to libsqlite checkpointing: neither
356 /// file is mutated. The resulting [`Database`] answers `read_table` with the
357 /// WAL-applied view (use [`Database::open`] for the main-only view). Frames
358 /// past the last commit frame, or whose salt does not match the WAL header,
359 /// are ignored — they are uncommitted / superseded and not part of the
360 /// consistent snapshot.
361 pub fn open_with_wal(bytes: Vec<u8>, wal: &[u8]) -> Result<Self, Error> {
362 let header = parse_header(&bytes)?;
363 let overlay = WalOverlay::parse(wal, header.page_size)?;
364 Ok(Self {
365 bytes,
366 header,
367 wal: overlay,
368 })
369 }
370
371 /// Whether a non-empty WAL overlay is in effect (at least one committed
372 /// frame was applied on top of the main file).
373 #[must_use]
374 pub fn wal_applied(&self) -> bool {
375 self.wal.as_ref().is_some_and(|w| !w.pages.is_empty())
376 }
377
378 /// Every committed `-wal` frame's page image, in file order, with provenance.
379 ///
380 /// Empty when the database was opened without a WAL (or the WAL held no
381 /// committed frames). The carver scans these page images for deleted-cell
382 /// residue that lives ONLY in the uncheckpointed WAL — the genuinely-different
383 /// records the on-disk pages do not hold — tagging each with the
384 /// `(salt1, salt2, frame_index)` log-sequence identity.
385 #[must_use]
386 pub fn wal_frame_pages(&self) -> &[WalFramePage] {
387 self.wal.as_ref().map_or(&[], |w| w.frames.as_slice())
388 }
389
390 /// Build the bespoke, format-exact [`WalTimeline`] for this database's `-wal`
391 /// sidecar, if one was supplied to [`Database::open_with_wal`].
392 ///
393 /// Returns `None` when the database was opened without a WAL, or the WAL held
394 /// no committed frame (no materializable state). The timeline enumerates the
395 /// segment's [`CommitSnapshot`]s — the only materializable database states —
396 /// each addressable by [`CommitId`]; see [`WalTimeline`].
397 ///
398 /// This consults the original `-wal` bytes retained at open time, re-parsing
399 /// them into the richer temporal model (the on-open [`WalOverlay`] keeps only
400 /// the consistent-view pages; the timeline keeps every segment, snapshot, and
401 /// residue tail). A page-size mismatch or malformed header surfaces as `None`
402 /// here — use [`Database::wal_timeline_from`] when you need the typed
403 /// [`WalValidationError`].
404 #[must_use]
405 pub fn wal_timeline(&self) -> Option<WalTimeline> {
406 let raw = self.wal.as_ref()?.raw.as_slice();
407 WalTimeline::parse(&self.bytes, raw, self.header.page_size).ok()
408 }
409
410 /// Parse a main database + `-wal` sidecar directly into a [`WalTimeline`],
411 /// surfacing the typed [`WalValidationError`] when the WAL is malformed.
412 ///
413 /// This is the validation-tier entry point: a page-size mismatch between the DB
414 /// header and the WAL header is a HARD STOP ([`WalValidationError::PageSizeMismatch`]),
415 /// not a silently mis-sliced overlay; a bad magic / unparsable header is
416 /// [`WalValidationError::BadMagic`]. Both are caught at the physical-validation
417 /// tier before any replay.
418 pub fn wal_timeline_from(bytes: &[u8], wal: &[u8]) -> Result<WalTimeline, WalValidationError> {
419 let header = parse_header(bytes).map_err(WalValidationError::Header)?;
420 WalTimeline::parse(bytes, wal, header.page_size)
421 }
422
423 #[must_use]
424 pub fn header(&self) -> Header {
425 self.header
426 }
427
428 /// Number of pages in the database file.
429 ///
430 /// Prefers the in-header DB size (offset 28) when it is a valid, non-zero
431 /// value that is consistent with the file length; otherwise falls back to
432 /// `file_len / page_size`. A mismatch between the two is itself a forensic
433 /// signal (see [`Database::header_page_count`] / [`Database::file_page_count`]).
434 #[must_use]
435 pub fn page_count(&self) -> u32 {
436 let header = self.header_page_count();
437 let file = self.file_page_count();
438 if header != 0 && header == file {
439 header
440 } else {
441 file
442 }
443 }
444
445 /// The page count recorded in the file header (offset 28). May be 0 (legacy
446 /// "size not valid" sentinel) or disagree with the file length after an
447 /// out-of-band truncation/extension.
448 #[must_use]
449 pub fn header_page_count(&self) -> u32 {
450 be_u32(&self.bytes, DB_SIZE_IN_PAGES_OFFSET)
451 }
452
453 /// The page count implied by the raw file length (`file_len / page_size`).
454 #[must_use]
455 pub fn file_page_count(&self) -> u32 {
456 let ps = self.header.page_size as usize;
457 u32::try_from(self.bytes.len() / ps).unwrap_or(u32::MAX)
458 }
459
460 /// The freelist page **count** recorded in the file header (offset 36).
461 #[must_use]
462 pub fn freelist_count(&self) -> u32 {
463 be_u32(&self.bytes, FREELIST_COUNT_OFFSET)
464 }
465
466 /// Walk the freelist trunk/leaf chain and return every free (unallocated)
467 /// page number, in trunk order. Free pages retain the bytes of whatever they
468 /// last held — on a `secure_delete=OFF` database that includes deleted
469 /// records, which the analyzer can carve.
470 ///
471 /// Bounded against crafted cyclic trunk chains: a page already visited, an
472 /// out-of-range page, or a leaf-pointer count larger than a trunk page can
473 /// hold aborts with [`Error::MalformedFreelist`] rather than looping.
474 pub fn freelist_pages(&self) -> Result<Vec<u32>, Error> {
475 let (leaves, trunks) = self.freelist_pages_split()?;
476 // Preserve the historical order: each trunk's leaves, then the trunk.
477 // The split sets are ordered, which is sufficient for every caller (they
478 // treat the result as a set), and keeps a single source of truth.
479 let mut free: Vec<u32> = leaves.into_iter().collect();
480 free.extend(trunks);
481 Ok(free)
482 }
483
484 /// Walk the freelist and return its **leaf** and **trunk** page numbers
485 /// separately (task #73). The distinction is load-bearing for chain-aware
486 /// overflow recovery: a freed page that became a freelist *leaf* keeps its
487 /// former content byte-for-byte, while a *trunk* page has its head
488 /// (next-trunk pointer + leaf count + leaf-number array) written over the
489 /// former content (file-format §"The Freelist"). Only leaves are
490 /// content-preserving, so [`Database::read_freed_overflow_chain`] accepts a
491 /// chain page only when it is a leaf.
492 ///
493 /// Bounded identically to [`Database::freelist_pages`]: a cyclic trunk chain,
494 /// an out-of-range page, or an over-large leaf count aborts with
495 /// [`Error::MalformedFreelist`] rather than looping.
496 pub fn freelist_pages_split(
497 &self,
498 ) -> Result<
499 (
500 std::collections::BTreeSet<u32>,
501 std::collections::BTreeSet<u32>,
502 ),
503 Error,
504 > {
505 let mut leaves = std::collections::BTreeSet::new();
506 let mut trunks = std::collections::BTreeSet::new();
507 let mut trunk = be_u32(&self.bytes, SQLITE_FREELIST_TRUNK_OFFSET);
508 let total_pages = self.file_page_count();
509 // Each trunk page holds at most (page_size/4 - 2) leaf pointers.
510 let max_leaves = (self.header.page_size as usize / 4).saturating_sub(2);
511 let mut visited = 0usize;
512 let cap = total_pages as usize + 1;
513
514 while trunk != 0 {
515 visited += 1;
516 if visited > cap {
517 return Err(Error::MalformedFreelist);
518 }
519 if trunk > total_pages {
520 return Err(Error::MalformedFreelist);
521 }
522 let slice = self.page_slice(trunk)?;
523 let next = be_u32(slice, 0);
524 let leaf_count = be_u32(slice, 4) as usize;
525 if leaf_count > max_leaves {
526 return Err(Error::MalformedFreelist);
527 }
528 for i in 0..leaf_count {
529 let leaf = be_u32(slice, 8 + i * 4);
530 if leaf == 0 || leaf > total_pages {
531 return Err(Error::MalformedFreelist);
532 }
533 leaves.insert(leaf);
534 }
535 trunks.insert(trunk);
536 trunk = next;
537 }
538 Ok((leaves, trunks))
539 }
540
541 /// Follow a **freed** overflow-page chain starting at `first`, reading raw
542 /// main-file pages only (carving wants on-disk residue, not the WAL view),
543 /// and assemble up to `remaining` content bytes (task #73). The carve-side
544 /// dual of [`Database::read_overflow_chain`], with one extra discipline that
545 /// makes it the 0-FP-relevant guard: **every chain page must be a freelist
546 /// leaf** (`freed_leaves`). A page that is not a leaf is live, a trunk, or
547 /// unreachable — following its pointer would risk reading reused or clobbered
548 /// content, so it is a [`ChainBreak`] (Codex ruling #2: the leaf requirement,
549 /// not the UTF-8 gate, is what rejects a destroyed chain).
550 ///
551 /// Returns the assembled content and the ordered list of chain pages on
552 /// success. Robustness (Paranoid Gatekeeper, design §4.2): the anti-bomb cap
553 /// rejects upfront any `remaining` above what the freelist leaves can deliver
554 /// (`(usable - 4) × freed_leaves.len()`), so an attacker-declared huge
555 /// payload dies before any allocation; cycles are caught by a visited set;
556 /// a premature `next == 0` with bytes still wanted, an out-of-range page, or
557 /// page 0 mid-chain all break. Never panics — every read is bounds-checked.
558 pub fn read_freed_overflow_chain(
559 &self,
560 first: u32,
561 remaining: usize,
562 usable: usize,
563 freed_leaves: &std::collections::BTreeSet<u32>,
564 ) -> Result<(Vec<u8>, Vec<u32>), ChainBreak> {
565 let per_page = usable.checked_sub(4).filter(|&p| p > 0).ok_or(ChainBreak)?;
566 // Anti-bomb cap: the chain can deliver at most this many bytes. Reject an
567 // absurd declared payload before allocating (design §4.2).
568 let max_deliverable = per_page.checked_mul(freed_leaves.len()).ok_or(ChainBreak)?;
569 if remaining > max_deliverable {
570 return Err(ChainBreak);
571 }
572 let total_pages = self.file_page_count();
573 let mut content = Vec::with_capacity(remaining);
574 let mut chain = Vec::new();
575 let mut visited = std::collections::BTreeSet::new();
576 let mut page = first;
577 let mut left = remaining;
578 while left > 0 {
579 if page == 0 || page > total_pages {
580 return Err(ChainBreak);
581 }
582 // The load-bearing guard: a chain page must be a freelist LEAF.
583 if !freed_leaves.contains(&page) {
584 return Err(ChainBreak);
585 }
586 if !visited.insert(page) {
587 return Err(ChainBreak); // cycle
588 }
589 let slice = self.raw_page(page).ok_or(ChainBreak)?;
590 let next = be_u32(slice, 0);
591 let take = left.min(per_page);
592 let chunk = slice.get(4..4 + take).ok_or(ChainBreak)?;
593 content.extend_from_slice(chunk);
594 chain.push(page);
595 left -= take;
596 page = next;
597 }
598 Ok((content, chain))
599 }
600
601 /// Raw bytes of the 1-based `page` from the **main file only**, ignoring any
602 /// WAL overlay. Carving wants the on-disk page (where deleted residue lives),
603 /// not the WAL-applied view. Returns `None` for page 0 or out-of-range pages.
604 #[must_use]
605 pub fn raw_page(&self, page: u32) -> Option<&[u8]> {
606 if page == 0 {
607 return None;
608 }
609 let ps = self.header.page_size as usize;
610 let start = (page as usize - 1).checked_mul(ps)?;
611 let end = start.checked_add(ps)?;
612 self.bytes.get(start..end)
613 }
614
615 /// Scan a slice of page bytes for record-shaped table-leaf cells of exactly
616 /// `column_count` columns, recovering each as a [`CarvedCell`].
617 ///
618 /// This is the carving primitive the forensic analyzer drives over free /
619 /// unallocated regions: at every byte offset it speculatively parses a
620 /// `payload_len` varint, a `rowid` varint, and a record header, accepting the
621 /// candidate only when the serial-type count matches `column_count`, the
622 /// declared lengths stay within the slice, and every value decodes. Strict
623 /// validation keeps the false-positive rate low; `confidence` reflects how
624 /// strongly the bytes are record-shaped. Bounded: each offset does O(record)
625 /// work and the scan is linear in the slice length.
626 #[must_use]
627 pub fn carve_cells(&self, page_bytes: &[u8], column_count: usize) -> Vec<CarvedCell> {
628 let mut out = Vec::new();
629 if column_count == 0 {
630 return out;
631 }
632 let mut off = 0usize;
633 while off < page_bytes.len() {
634 if let Some(cell) = try_carve_cell_at(page_bytes, off, Some(column_count), self.header.text_encoding) {
635 // Skip past this record to avoid re-reporting sub-slices of it.
636 off += cell.byte_len.max(1);
637 out.push(cell);
638 } else {
639 off += 1;
640 }
641 }
642 out
643 }
644
645 /// Carve record-shaped cells from a page slice **inferring** each record's
646 /// column count from its own serial-type array, instead of requiring a fixed
647 /// count. This is what makes **dropped-table / schema-gone** recovery
648 /// possible: the page's table was `DROP`ped, so `sqlite_master` no longer
649 /// records a column count, but each record still self-describes its columns.
650 ///
651 /// Inferring the count removes one validity check, so the remaining
652 /// self-consistency checks are kept strict to hold the false-positive rate
653 /// down: `header_len + body_len == payload_len`, every serial type legal,
654 /// `rowid > 0`, the payload fully in-bounds, and at least
655 /// [`MIN_INFERRED_COLUMNS`] columns. Records carved this way are graded a
656 /// notch lower in confidence than fixed-count carving.
657 #[must_use]
658 pub fn carve_cells_inferred(&self, page_bytes: &[u8]) -> Vec<CarvedCell> {
659 let mut out = Vec::new();
660 let mut off = 0usize;
661 while off < page_bytes.len() {
662 if let Some(cell) = try_carve_cell_at(page_bytes, off, None, self.header.text_encoding) {
663 off += cell.byte_len.max(1);
664 out.push(cell);
665 } else {
666 off += 1;
667 }
668 }
669 out
670 }
671
672 /// Decode **every cell present in a table-leaf page image** (type `0x0D`) by
673 /// walking its cell-pointer array, inferring each record's column count from
674 /// its own serial-type array. Unlike [`Database::carve_free_regions`] (which
675 /// scans only free space and excludes live cells), this returns the cells the
676 /// page itself records as allocated.
677 ///
678 /// This is the primitive WAL-frame recovery needs: a `-wal` frame is a full
679 /// page snapshot at one point in time, so a cell that is allocated in an
680 /// EARLIER frame's image but absent from the final WAL-applied view is a row
681 /// that was deleted later and survives ONLY in that superseded frame. The
682 /// caller filters the returned cells against the final live view to isolate
683 /// exactly those genuinely-deleted rows (so a still-live row is never
684 /// re-surfaced — the filter is the caller's responsibility, mirroring the
685 /// freeblock-reconstruction discipline).
686 ///
687 /// Bounded and panic-free: a malformed cell pointer or record simply yields
688 /// fewer cells. Non-leaf pages yield nothing.
689 #[must_use]
690 pub fn carve_leaf_cells(&self, page_bytes: &[u8]) -> Vec<CarvedCell> {
691 let hdr_off = if page_bytes.starts_with(SQLITE_MAGIC) {
692 SQLITE_HEADER_SIZE
693 } else {
694 0
695 };
696 let Some(&page_type) = page_bytes.get(hdr_off) else {
697 return Vec::new();
698 };
699 if page_type != 0x0d {
700 return Vec::new(); // only table-leaf pages hold decodable cells here
701 }
702 let cell_count = be_u16(page_bytes, hdr_off + 3) as usize;
703 let cell_ptr_array = hdr_off + 8; // leaf b-tree header is 8 bytes
704 let mut out = Vec::new();
705 for i in 0..cell_count {
706 let cell_off = be_u16(page_bytes, cell_ptr_array + i * 2) as usize;
707 if cell_off == 0 || cell_off >= page_bytes.len() {
708 continue; // cov:unreachable: a valid leaf points cells within page
709 }
710 if let Some(cell) = try_carve_cell_at(page_bytes, cell_off, None, self.header.text_encoding) {
711 out.push(cell);
712 }
713 }
714 out
715 }
716
717 /// Carve deleted records from the **free (unallocated) regions** of an
718 /// allocated table-leaf page (type `0x0D`), never re-surfacing a live cell.
719 ///
720 /// On an allocated leaf, deleted-cell residue survives in two places: the
721 /// unallocated gap between the cell-pointer array and the cell-content area,
722 /// and the slack between/after live cells (a former freeblock whose chain
723 /// pointer may already be gone). This method computes the exact byte ranges
724 /// occupied by **live** cells and carves only the complement — so a live
725 /// (allocated) cell can never be returned as a deleted record. That is the
726 /// 0-false-positive guarantee, enforced structurally rather than by a filter.
727 ///
728 /// `page_bytes` is one whole page. `column_count_hint`, when non-zero, is the
729 /// table's known column count (matched exactly); pass 0 to infer the count
730 /// per record (for a page whose schema is gone). Non-leaf pages yield nothing.
731 #[must_use]
732 pub fn carve_free_regions(
733 &self,
734 page_bytes: &[u8],
735 column_count_hint: usize,
736 ) -> Vec<CarvedCell> {
737 // Page 1 carries the 100-byte file header before its b-tree header; for a
738 // standalone page slice we assume hdr_off 0 unless it starts with the
739 // file magic (page 1 passed whole).
740 let hdr_off = if page_bytes.starts_with(SQLITE_MAGIC) {
741 SQLITE_HEADER_SIZE
742 } else {
743 0
744 };
745 let Some(&page_type) = page_bytes.get(hdr_off) else {
746 return Vec::new();
747 };
748 if page_type != 0x0d {
749 return Vec::new(); // only table-leaf pages have carvable cell residue
750 }
751 // Carve each maximal free region (complement of the live cell extents),
752 // within the cell-content area only — so no allocated cell is ever
753 // re-surfaced (the 0-false-positive guarantee, enforced structurally).
754 let mut out = Vec::new();
755 let regions = self.free_regions_of_leaf(page_bytes, hdr_off);
756 for (lo, hi) in regions {
757 let Some(region) = page_bytes.get(lo..hi) else {
758 continue; // cov:unreachable: free_regions yields in-bounds spans
759 };
760 let cells = if column_count_hint == 0 {
761 self.carve_cells_inferred(region)
762 } else {
763 self.carve_cells(region, column_count_hint)
764 };
765 for mut cell in cells {
766 // Translate the offset from region-local to page-local, and grade
767 // in-page recovery a notch lower (residue here is more often
768 // partially overwritten than freed-page recovery).
769 cell.offset += lo;
770 cell.confidence *= IN_PAGE_CONFIDENCE_FACTOR;
771 out.push(cell);
772 }
773 }
774 out
775 }
776
777 /// Recover **spilled** deleted records on a table-leaf page whose payload
778 /// continued onto a freed overflow-page chain (task #73). Scans the page's
779 /// free regions (the complement of the live cells — same discipline as
780 /// [`Database::carve_free_regions`], so a live cell is never re-surfaced) for
781 /// a [`SpilledCell`], then resolves each chain through freelist **leaf** pages
782 /// only and assembles the full payload.
783 ///
784 /// A resolved record is returned only when ALL hold (design §5):
785 /// 1. the chain is intact through freelist leaves (Codex ruling #2: the leaf
786 /// requirement is the load-bearing 0-FP guard — a trunk/live/off-freelist
787 /// chain page is rejected);
788 /// 2. the assembled bytes total exactly the declared `P` and decode cleanly;
789 /// 3. **strict UTF-8 on chain-resident TEXT** — an EXTRA reject signal, not a
790 /// correctness proof (Codex ruling #2: a clobbered chain can still be valid
791 /// UTF-8, so this cannot prove integrity; it only catches the cases where
792 /// the lossy decoder would otherwise mask an overwrite as `U+FFFD`).
793 ///
794 /// Each returned tuple is `(cell, chain)` where `chain` is the ordered list of
795 /// overflow pages the bytes came from (for provenance). Confidence is graded
796 /// BELOW the in-page full-row tier (Codex ruling #1: overflow Tier-1 is a
797 /// graded recovery, NOT part of the structural 0-FP guarantee — a freelist
798 /// leaf can be stale, holding unrelated bytes that happen to decode). Bounded
799 /// and panic-free; a malformed page or chain simply yields fewer records.
800 #[must_use]
801 pub fn carve_overflow_records(&self, page_bytes: &[u8]) -> Vec<(CarvedCell, Vec<u32>)> {
802 let hdr_off = if page_bytes.starts_with(SQLITE_MAGIC) {
803 SQLITE_HEADER_SIZE
804 } else {
805 0
806 };
807 let Some(&page_type) = page_bytes.get(hdr_off) else {
808 return Vec::new();
809 };
810 if page_type != 0x0d {
811 return Vec::new(); // only table-leaf pages carry spilled-cell residue
812 }
813 let Ok((freed_leaves, _trunks)) = self.freelist_pages_split() else {
814 return Vec::new();
815 };
816 let usable = self.header.usable_size() as usize;
817
818 let mut out = Vec::new();
819 let regions = self.free_regions_of_leaf(page_bytes, hdr_off);
820 for (lo, hi) in regions {
821 let Some(region) = page_bytes.get(lo..hi) else {
822 continue; // cov:unreachable: free_regions yields in-bounds spans
823 };
824 // Scan every offset for a spilled cell (recognizer abstains on in-page
825 // payloads, so the two carve classes never overlap).
826 let mut off = 0usize;
827 while off < region.len() {
828 let Some(sc) = try_carve_spilled_cell_at(region, off, usable, None) else {
829 off += 1;
830 continue;
831 };
832 if let Some((mut cell, chain)) =
833 self.resolve_spilled(region, &sc, usable, &freed_leaves)
834 {
835 // Translate the region-local offset to page-local.
836 cell.offset = lo + sc.offset;
837 out.push((cell, chain));
838 off += sc.byte_len.max(1);
839 } else {
840 off += 1;
841 }
842 }
843 }
844 out
845 }
846
847 /// Resolve a recognized [`SpilledCell`] to a full [`CarvedCell`] by walking
848 /// its freed overflow chain and decoding the assembled payload, applying the
849 /// strict-UTF-8 chain gate. Returns `Some((cell, chain))` on a fully-validated
850 /// recovery, `None` on any chain break or gate failure (the candidate then
851 /// degrades to a Tier-2 fragment elsewhere).
852 fn resolve_spilled(
853 &self,
854 region: &[u8],
855 sc: &SpilledCell,
856 usable: usize,
857 freed_leaves: &std::collections::BTreeSet<u32>,
858 ) -> Option<(CarvedCell, Vec<u32>)> {
859 let remaining = sc.payload_len.checked_sub(sc.local_len)?;
860 let local_payload =
861 region.get(sc.local_payload_off..sc.local_payload_off + sc.local_len)?;
862 let (chain_content, chain) = self
863 .read_freed_overflow_chain(sc.first_overflow, remaining, usable, freed_leaves)
864 .ok()?;
865 let mut payload = Vec::with_capacity(sc.payload_len);
866 payload.extend_from_slice(local_payload);
867 payload.extend_from_slice(&chain_content);
868 if payload.len() != sc.payload_len {
869 return None; // cov:unreachable: chain delivers exactly `remaining` bytes
870 }
871
872 let values = decode_record(&payload, sc.serials.len(), sc.rowid, self.header.text_encoding).ok()?;
873 if values.len() != sc.serials.len() {
874 return None; // cov:unreachable: decode_record yields one value per serial
875 }
876 // Strict-UTF-8 gate on chain-resident TEXT (extra reject signal): the
877 // lossy decoder turns a clobbered byte into U+FFFD, so any replacement
878 // char in a decoded TEXT value means the chain-supplied bytes did not
879 // decode cleanly — reject. NOT a proof of integrity (a stale leaf can hold
880 // valid UTF-8); the freelist-leaf requirement is the load-bearing guard.
881 let any_replacement = values.iter().any(|v| match v {
882 Value::Text(t) => t.contains('\u{FFFD}'),
883 _ => false,
884 });
885 if any_replacement {
886 return None;
887 }
888 // Require at least one distinctive column so a coincidental decode of stale
889 // bytes does not anchor a full row (the same identity bar as fragments).
890 if !values.iter().any(is_distinctive) {
891 return None; // cov:unreachable: the spilled corpus rows carry distinctive TEXT
892 }
893
894 let cell = CarvedCell {
895 offset: sc.offset,
896 byte_len: sc.byte_len,
897 rowid: sc.rowid,
898 values,
899 // Graded below the in-page full-row tier (0.9): an overflow chain adds
900 // one indirection of stale-leaf exposure (Codex ruling #1).
901 confidence: 0.9 * OVERFLOW_CHAIN_CONFIDENCE_FACTOR,
902 };
903 Some((cell, chain))
904 }
905
906 /// Reconstruct **freeblock-clobbered spilled** cells (task #73, design §2.2 /
907 /// Codex ruling #5). When a freed cell whose payload spilled is also
908 /// freeblock-clobbered, its declared `P` is destroyed but **re-derivable** from
909 /// the surviving structure: `P = header_len + Σ serial_body_len` over the full
910 /// (template + surviving) serial array. When that `P` exceeds `usable - 35` the
911 /// record is spilled by construction, so we read the 4-byte first-overflow
912 /// pointer that follows the local payload and resolve the chain through
913 /// freelist leaves, exactly as the intact-prefix path does — but with
914 /// `rowid = 0` (the prefix's rowid varint was clobbered, never invented).
915 ///
916 /// UNPROVEN-BY-CORPUS (Codex ruling #5): no real Nemetz `0E` cell is *both*
917 /// freeblock-clobbered *and* spilled — every measured spilled cell kept an
918 /// intact prefix in the unallocated gap. This path is therefore validated
919 /// against a **synthetic** fixture only; it is the general solution the
920 /// no-special-case rule requires (it applies the same spill formula to the
921 /// clobbered class), but its real-data behavior is not yet observed.
922 ///
923 /// Returns `(cell, chain)` per fully-resolved record. Bounded and panic-free.
924 #[must_use]
925 pub fn carve_overflow_template_records(
926 &self,
927 page_bytes: &[u8],
928 ) -> Vec<(CarvedCell, Vec<u32>)> {
929 let hdr_off = if page_bytes.starts_with(SQLITE_MAGIC) {
930 SQLITE_HEADER_SIZE
931 } else {
932 0
933 };
934 if page_bytes.get(hdr_off) != Some(&0x0d) {
935 return Vec::new();
936 }
937 let Some(template) = freeblock_template(page_bytes, hdr_off, self.header.text_encoding) else {
938 return Vec::new();
939 };
940 let Ok((freed_leaves, _trunks)) = self.freelist_pages_split() else {
941 return Vec::new();
942 };
943 let usable = self.header.usable_size() as usize;
944
945 let mut out = Vec::new();
946 // Walk the freeblock chain; at each freeblock head, try a clobbered-spill
947 // reconstruction (the chain pass reaches the clobbered prefix the
948 // intact-prefix recognizer cannot read).
949 let first_freeblock = be_u16(page_bytes, hdr_off + 1) as usize;
950 let mut fb = first_freeblock;
951 let mut walked = 0usize;
952 let mut visited = std::collections::BTreeSet::new();
953 while fb != 0 && walked < MAX_FREEBLOCKS_PER_PAGE {
954 walked += 1;
955 if !visited.insert(fb) {
956 break; // cyclic next pointer
957 }
958 let next = be_u16(page_bytes, fb) as usize;
959 if let Some((cell, chain)) =
960 template.reconstruct_spilled(self, page_bytes, fb, usable, &freed_leaves)
961 {
962 out.push((cell, chain));
963 }
964 fb = next;
965 }
966 out
967 }
968
969 /// Tier-2 salvage for **spilled** cells whose overflow chain is broken (task
970 /// #73, Codex ruling #4): when [`Database::carve_overflow_records`] rejects a
971 /// recognized spilled cell because its chain failed (a trunk-clobbered or
972 /// reused chain page), the cell's intact LOCAL prefix still holds the columns
973 /// whose bodies fit entirely on the leaf page. Those are salvaged as a
974 /// [`CellFragment`] — the same Tier-2 surface freeblock reconstruction uses.
975 ///
976 /// Only columns whose body lies wholly within the local payload are kept; the
977 /// chain-resident columns are lost (untrusted by definition — the chain that
978 /// would supply them is the thing that failed). A fragment is emitted only
979 /// when the salvaged prefix carries ≥ 1 distinctive cell (TEXT ≥ 4 bytes of
980 /// valid UTF-8, or REAL — the §3.1 gate), so a lone integer prefix never
981 /// anchors one. Bounded and panic-free.
982 #[must_use]
983 pub fn carve_overflow_fragments(&self, page_bytes: &[u8]) -> Vec<CellFragment> {
984 let hdr_off = if page_bytes.starts_with(SQLITE_MAGIC) {
985 SQLITE_HEADER_SIZE
986 } else {
987 0
988 };
989 let Some(&page_type) = page_bytes.get(hdr_off) else {
990 return Vec::new();
991 };
992 if page_type != 0x0d {
993 return Vec::new();
994 }
995 let Ok((freed_leaves, _trunks)) = self.freelist_pages_split() else {
996 return Vec::new();
997 };
998 let usable = self.header.usable_size() as usize;
999
1000 let mut out = Vec::new();
1001 let regions = self.free_regions_of_leaf(page_bytes, hdr_off);
1002 for (lo, hi) in regions {
1003 let Some(region) = page_bytes.get(lo..hi) else {
1004 continue; // cov:unreachable: free_regions yields in-bounds spans
1005 };
1006 let mut off = 0usize;
1007 while off < region.len() {
1008 let Some(sc) = try_carve_spilled_cell_at(region, off, usable, None) else {
1009 off += 1;
1010 continue;
1011 };
1012 // Only broken chains degrade to a fragment — an intact chain is a
1013 // Tier-1 row (handled by carve_overflow_records), never both.
1014 let remaining = sc.payload_len.saturating_sub(sc.local_len);
1015 let chain_ok = self
1016 .read_freed_overflow_chain(sc.first_overflow, remaining, usable, &freed_leaves)
1017 .is_ok();
1018 if !chain_ok {
1019 if let Some(mut frag) = salvage_local_prefix(region, &sc, self.header.text_encoding) {
1020 frag.offset += lo;
1021 out.push(frag);
1022 }
1023 }
1024 off += sc.byte_len.max(1);
1025 }
1026 }
1027 out
1028 }
1029
1030 /// Reconstruct deleted records from the **freeblock chain** of an allocated
1031 /// table-leaf page (type `0x0d`) — the records a forward parse cannot recover
1032 /// because their first four bytes were destroyed by freeblock conversion.
1033 ///
1034 /// When SQLite frees an in-page cell it converts it into a **freeblock**
1035 /// (file-format §1.6): the cell's first two bytes become the next-freeblock
1036 /// offset and the next two the freeblock size, **overwriting the cell's
1037 /// payload-length + rowid varints, the record `header_len` varint, and the
1038 /// leading serial type(s)**. The record's surviving serial-type tail and its
1039 /// whole value body remain intact *after* those four bytes.
1040 ///
1041 /// This method rebuilds each freed cell from that surviving tail plus a
1042 /// **schema template** derived from a LIVE cell on the same page (the table's
1043 /// column count, header length, and the serial types of the leading columns
1044 /// that fall inside the clobbered prefix). The destroyed rowid is surfaced as
1045 /// unknown (`0`) — never invented — and the record is graded LOW.
1046 ///
1047 /// Precision discipline (task #56): a candidate is emitted only when its body
1048 /// decodes cleanly with every serial type legal AND the record fits within
1049 /// the freeblock's `[offset, offset + size)` bounds. Implausible or
1050 /// out-of-bounds candidates are rejected, so reconstruction does not
1051 /// manufacture phantom rows. (The forensic layer additionally drops any
1052 /// reconstruction whose values match a live row, so a live row is never
1053 /// re-surfaced.)
1054 ///
1055 /// Bounded and panic-free: every freeblock pointer, size, and serial length
1056 /// is range-checked against the page before use, and the chain walk is capped
1057 /// at [`MAX_FREEBLOCKS_PER_PAGE`] to defeat a crafted cyclic `next` chain.
1058 /// Non-leaf pages, pages with no freeblock chain, and pages with no usable
1059 /// schema template yield an empty result.
1060 #[must_use]
1061 pub fn reconstruct_freeblock_records(&self, page_bytes: &[u8]) -> Vec<CarvedCell> {
1062 // Tier-1 cells are the `.0` of the shared two-tier walker, so the full-row
1063 // output and the fragment output ([`Database::reconstruct_freeblock_fragments`])
1064 // can never diverge. The walk (freeblock-chain pass + unallocated-gap pass)
1065 // and its precision discipline live in [`reconstruct_freeblock_inner`].
1066 let _ = self;
1067 reconstruct_freeblock_inner(page_bytes, self.header.text_encoding).0
1068 }
1069
1070 /// Tier-2 partial salvage: the [`CellFragment`]s abandoned by
1071 /// [`Database::reconstruct_freeblock_records`] on this page.
1072 ///
1073 /// At every anchor where full reconstruction failed — an illegal serial in
1074 /// the surviving tail, a tail that overruns the span, or a body that does not
1075 /// fit — the columns that DID decode cleanly before the failure are salvaged
1076 /// as the maximal decodable prefix. A fragment is emitted only when that
1077 /// prefix contains at least one *distinctive* cell (TEXT ≥ 4 bytes of valid
1078 /// UTF-8, or REAL): a lone surviving integer pattern is coincidence-prone and
1079 /// never anchors a fragment.
1080 ///
1081 /// Mutually exclusive with the full reconstructions of
1082 /// [`Database::reconstruct_freeblock_records`] **by construction**: an anchor
1083 /// yields a cell or a fragment, never both. Inherits the same anchor
1084 /// discipline — no sliding scan, no strings-style hunt — so Tier-2 carries
1085 /// Tier-1's precision architecture. Bounded and panic-free identically.
1086 #[must_use]
1087 pub fn reconstruct_freeblock_fragments(&self, page_bytes: &[u8]) -> Vec<CellFragment> {
1088 let _ = self;
1089 reconstruct_freeblock_inner(page_bytes, self.header.text_encoding).1
1090 }
1091
1092 /// The maximal FREE (unallocated) byte ranges of a table-leaf page — the
1093 /// complement of its live cells within the cell-content area. Shared by
1094 /// [`Database::carve_free_regions`] and
1095 /// [`Database::reconstruct_freeblock_records`] so both scan exactly the same
1096 /// ranges and never touch a live cell. Returns empty for a non-leaf page.
1097 fn free_regions_of_leaf(&self, page_bytes: &[u8], hdr_off: usize) -> Vec<(usize, usize)> {
1098 if page_bytes.get(hdr_off) != Some(&0x0d) {
1099 return Vec::new(); // cov:unreachable: callers gate on page_type == 0x0d
1100 }
1101 let cell_count = be_u16(page_bytes, hdr_off + 3) as usize;
1102 let cell_ptr_array = hdr_off + 8; // leaf header is 8 bytes
1103 let usable = self.header.usable_size() as usize;
1104 let mut live: Vec<(usize, usize)> = Vec::with_capacity(cell_count);
1105 for i in 0..cell_count {
1106 let cell_off = be_u16(page_bytes, cell_ptr_array + i * 2) as usize;
1107 if cell_off == 0 || cell_off >= page_bytes.len() {
1108 continue; // cov:unreachable: a valid leaf points cells within page
1109 }
1110 if let Some(len) = live_cell_len(page_bytes, cell_off, usable) {
1111 live.push((cell_off, cell_off.saturating_add(len)));
1112 }
1113 }
1114 live.sort_unstable_by_key(|&(s, _)| s);
1115 let content_lo = cell_ptr_array + cell_count * 2;
1116 free_regions(&live, content_lo, page_bytes.len())
1117 }
1118
1119 /// Whether `sqlite_master` (the schema table rooted at page 1) lists at least
1120 /// one **user** table — i.e. a `type='table'` row whose name is not an
1121 /// internal `sqlite_*` table. A database where every table was `DROP`ped (or
1122 /// that never had one) returns `false`; the forensic carver uses this to label
1123 /// freed content as dropped-table residue. Errors (unreadable schema) are
1124 /// treated as "no user table" so the carver degrades safely.
1125 #[must_use]
1126 pub fn has_user_table(&self) -> bool {
1127 // sqlite_master is a 5-column table: (type, name, tbl_name, rootpage, sql).
1128 let Ok(rows) = self.read_table(1, 5) else {
1129 return false; // cov:unreachable: a validly-opened DB has a readable page-1 schema
1130 };
1131 rows.iter().any(|row| {
1132 let is_table = matches!(row.values.first(), Some(Value::Text(t)) if t == "table");
1133 let user = matches!(
1134 row.values.get(1),
1135 Some(Value::Text(n)) if !n.starts_with("sqlite_")
1136 );
1137 is_table && user
1138 })
1139 }
1140
1141 /// Collect the rowids of every **currently-live** row across all user table
1142 /// b-trees (the roots listed in `sqlite_master`). The forensic carver uses
1143 /// this to drop any carved "deleted" record whose rowid is in fact still live
1144 /// — a stale copy of a live row can linger in free space after a b-tree
1145 /// rebalance moved the row to another page, and reporting it as deleted would
1146 /// be a false positive. Rowid collection ignores the column count (the rowid
1147 /// is in the cell prefix), so it works even when a schema row is malformed.
1148 ///
1149 /// Bounded and panic-free: unreadable schema or a malformed b-tree yields a
1150 /// partial (possibly empty) set rather than an error.
1151 #[must_use]
1152 pub fn live_rowids(&self) -> std::collections::BTreeSet<i64> {
1153 let mut ids = std::collections::BTreeSet::new();
1154 let Ok(schema) = self.read_table(1, 5) else {
1155 return ids; // cov:unreachable: a validly-opened DB has a readable page-1 schema
1156 };
1157 for row in schema {
1158 // sqlite_master row: (type, name, tbl_name, rootpage, sql).
1159 let is_table = matches!(row.values.first(), Some(Value::Text(t)) if t == "table");
1160 if !is_table {
1161 continue; // cov:unreachable: the test fixtures' schemas hold only table rows
1162 }
1163 let Some(Value::Integer(root)) = row.values.get(3) else {
1164 continue; // cov:unreachable: a 'table' schema row always has an integer rootpage
1165 };
1166 let Ok(root) = u32::try_from(*root) else {
1167 continue; // cov:unreachable: a real rootpage is a small positive page number
1168 };
1169 let mut visited = 0usize;
1170 self.collect_rowids(root, &mut ids, &mut visited);
1171 }
1172 ids
1173 }
1174
1175 /// Collect every **currently-live** row's decoded column values, keyed by
1176 /// rowid, across all user table b-trees. This is the value-aware companion to
1177 /// [`Database::live_rowids`]: the forensic carver uses it to tell a stale
1178 /// rebalance copy (same rowid AND same values → drop) from a deleted prior
1179 /// version (same rowid but DIFFERENT values → recover, e.g. an edited message
1180 /// or a changed amount).
1181 ///
1182 /// Column values are decoded by inferring the column count from each live
1183 /// cell's own serial-type array (the same self-describing record format the
1184 /// carver uses), so no schema column count is required. Best-effort,
1185 /// bounded, and panic-free: a malformed b-tree yields a partial map.
1186 #[must_use]
1187 pub fn live_rows(&self) -> std::collections::BTreeMap<i64, Vec<Value>> {
1188 let mut rows = std::collections::BTreeMap::new();
1189 let Ok(schema) = self.read_table(1, 5) else {
1190 return rows; // cov:unreachable: a validly-opened DB has a readable page-1 schema
1191 };
1192 for row in schema {
1193 let is_table = matches!(row.values.first(), Some(Value::Text(t)) if t == "table");
1194 if !is_table {
1195 continue; // cov:unreachable: the test fixtures' schemas hold only table rows
1196 }
1197 let Some(Value::Integer(root)) = row.values.get(3) else {
1198 continue; // cov:unreachable: a 'table' schema row always has an integer rootpage
1199 };
1200 let Ok(root) = u32::try_from(*root) else {
1201 continue; // cov:unreachable: a real rootpage is a small positive page number
1202 };
1203 let mut visited = 0usize;
1204 self.collect_rows(root, &mut rows, &mut visited);
1205 }
1206 rows
1207 }
1208
1209 /// Decode every **currently-live** `sqlite_master` row (the schema table
1210 /// rooted at page 1) into its column values: `(type, name, tbl_name,
1211 /// rootpage, sql)`. This is the schema-table companion to
1212 /// [`Database::live_rows`], which collects only USER-table b-trees and so
1213 /// never sees the schema rows themselves.
1214 ///
1215 /// The forensic carver folds these into the same value-based live set it uses
1216 /// to drop stale copies of live user rows: a record carved from a materialized
1217 /// page 1 whose values equal a CURRENT schema row is the live schema entry
1218 /// re-surfaced (drop it), whereas a genuinely-deleted PRIOR schema version has
1219 /// different values (e.g. an old `CREATE TABLE`) and is still recovered.
1220 ///
1221 /// Best-effort, bounded, and panic-free: an unreadable schema yields an empty
1222 /// vector rather than an error.
1223 #[must_use]
1224 pub fn live_schema_rows(&self) -> Vec<Vec<Value>> {
1225 match self.read_table(1, 5) {
1226 Ok(rows) => rows.into_iter().map(|row| row.values).collect(),
1227 Err(_) => Vec::new(), // cov:unreachable: a validly-opened DB has a readable page-1 schema
1228 }
1229 }
1230
1231 /// Walk the table b-tree rooted at `page`, decoding every live leaf cell's
1232 /// values (column count inferred per cell) into `rows` keyed by rowid.
1233 /// Best-effort and bounded, mirroring [`Database::collect_rowids`].
1234 fn collect_rows(
1235 &self,
1236 page: u32,
1237 rows: &mut std::collections::BTreeMap<i64, Vec<Value>>,
1238 visited: &mut usize,
1239 ) {
1240 *visited += 1;
1241 if *visited > MAX_PAGES_PER_WALK {
1242 return; // cov:unreachable: test b-trees are far below the 1M-page cap
1243 }
1244 let Ok(slice) = self.page_slice(page) else {
1245 return; // cov:unreachable: schema rootpages and their children are in range
1246 };
1247 let hdr_off = if page == 1 { SQLITE_HEADER_SIZE } else { 0 };
1248 let Some(&page_type) = slice.get(hdr_off) else {
1249 return; // cov:unreachable: a full page slice always has its header byte
1250 };
1251 let cell_count = be_u16(slice, hdr_off + 3) as usize;
1252 match page_type {
1253 0x0d => {
1254 let cell_ptr_array = hdr_off + 8;
1255 for i in 0..cell_count {
1256 let cell_off = be_u16(slice, cell_ptr_array + i * 2) as usize;
1257 // Decode the live cell with an inferred column count; on any
1258 // parse hiccup (e.g. a table narrower than MIN_INFERRED_COLUMNS),
1259 // fall back to the rowid alone (empty values) so the row is
1260 // still known to be live.
1261 if let Some(cell) = try_carve_cell_at(slice, cell_off, None, self.header.text_encoding) {
1262 rows.insert(cell.rowid, cell.values);
1263 } else if let Some(rowid) = live_cell_rowid(slice, cell_off) {
1264 rows.entry(rowid).or_default(); // cov:unreachable: a >=2-col live cell always decodes above
1265 }
1266 }
1267 }
1268 0x05 => {
1269 let cell_ptr_array = hdr_off + 12;
1270 for i in 0..cell_count {
1271 let cell_off = be_u16(slice, cell_ptr_array + i * 2) as usize;
1272 let child = be_u32(slice, cell_off);
1273 if child != 0 && child != page {
1274 self.collect_rows(child, rows, visited);
1275 }
1276 }
1277 let right = be_u32(slice, hdr_off + 8);
1278 if right != 0 && right != page {
1279 self.collect_rows(right, rows, visited);
1280 }
1281 }
1282 _ => {} // cov:unreachable: a table b-tree root/child is leaf (0x0d) or interior (0x05)
1283 }
1284 }
1285
1286 /// Walk the table b-tree rooted at `page`, inserting every live leaf cell's
1287 /// rowid into `ids`. Best-effort and bounded: a malformed/cyclic structure
1288 /// stops the walk rather than erroring or looping.
1289 fn collect_rowids(
1290 &self,
1291 page: u32,
1292 ids: &mut std::collections::BTreeSet<i64>,
1293 visited: &mut usize,
1294 ) {
1295 *visited += 1;
1296 if *visited > MAX_PAGES_PER_WALK {
1297 return; // cov:unreachable: test b-trees are far below the 1M-page cap
1298 }
1299 let Ok(slice) = self.page_slice(page) else {
1300 return; // cov:unreachable: schema rootpages and their children are in range
1301 };
1302 let hdr_off = if page == 1 { SQLITE_HEADER_SIZE } else { 0 };
1303 let Some(&page_type) = slice.get(hdr_off) else {
1304 return; // cov:unreachable: a full page slice always has its header byte
1305 };
1306 let cell_count = be_u16(slice, hdr_off + 3) as usize;
1307 match page_type {
1308 0x0d => {
1309 let cell_ptr_array = hdr_off + 8;
1310 for i in 0..cell_count {
1311 let cell_off = be_u16(slice, cell_ptr_array + i * 2) as usize;
1312 if let Some(rowid) = live_cell_rowid(slice, cell_off) {
1313 ids.insert(rowid);
1314 }
1315 }
1316 }
1317 0x05 => {
1318 let cell_ptr_array = hdr_off + 12;
1319 for i in 0..cell_count {
1320 let cell_off = be_u16(slice, cell_ptr_array + i * 2) as usize;
1321 let child = be_u32(slice, cell_off);
1322 if child != 0 && child != page {
1323 self.collect_rowids(child, ids, visited);
1324 }
1325 }
1326 let right = be_u32(slice, hdr_off + 8);
1327 if right != 0 && right != page {
1328 self.collect_rowids(right, ids, visited);
1329 }
1330 }
1331 _ => {} // cov:unreachable: a table b-tree root/child is leaf (0x0d) or interior (0x05)
1332 }
1333 }
1334
1335 /// Walk a single table b-tree rooted at `root_page` (1-based) and collect
1336 /// every leaf row as typed values. `column_count` is the table's declared
1337 /// column count, used to apply the `INTEGER PRIMARY KEY` rowid-alias rule.
1338 pub fn read_table(&self, root_page: u32, column_count: usize) -> Result<Vec<Row>, Error> {
1339 let mut rows = Vec::new();
1340 let mut visited = 0usize;
1341 self.walk_table_page(root_page, column_count, &mut rows, &mut visited)?;
1342 Ok(rows)
1343 }
1344
1345 /// Bytes of the 1-based `page` number, or `PageOutOfRange`.
1346 ///
1347 /// When a WAL overlay is in effect and holds a committed version of this
1348 /// page, the overlaid bytes are returned in preference to the main file —
1349 /// this is what makes a table walk see the WAL-applied view. The main file
1350 /// is never mutated.
1351 fn page_slice(&self, page: u32) -> Result<&[u8], Error> {
1352 if page == 0 {
1353 return Err(Error::PageOutOfRange(0));
1354 }
1355 if let Some(wal) = &self.wal {
1356 if let Some(overlaid) = wal.pages.get(&page) {
1357 return Ok(overlaid.as_slice());
1358 }
1359 }
1360 let ps = self.header.page_size as usize;
1361 let start = (page as usize - 1) * ps;
1362 let end = start.checked_add(ps).ok_or(Error::PageOutOfRange(page))?;
1363 self.bytes
1364 .get(start..end)
1365 .ok_or(Error::PageOutOfRange(page))
1366 }
1367
1368 fn walk_table_page(
1369 &self,
1370 page: u32,
1371 column_count: usize,
1372 rows: &mut Vec<Row>,
1373 visited: &mut usize,
1374 ) -> Result<(), Error> {
1375 *visited += 1;
1376 if *visited > MAX_PAGES_PER_WALK {
1377 return Err(Error::TooManyPages);
1378 }
1379 let slice = self.page_slice(page)?;
1380
1381 // Page 1 carries the 100-byte file header before its b-tree header.
1382 let hdr_off = if page == 1 { SQLITE_HEADER_SIZE } else { 0 };
1383
1384 let page_type = *slice.get(hdr_off).ok_or(Error::TruncatedCell)?;
1385 let cell_count = be_u16(slice, hdr_off + 3) as usize;
1386
1387 match page_type {
1388 0x0d => self.read_leaf_cells(slice, hdr_off, cell_count, column_count, rows),
1389 0x05 => {
1390 // Interior table page: 12-byte header; cell = 4-byte child ptr +
1391 // varint key. Recurse into every child plus the right-most ptr.
1392 let cell_ptr_array = hdr_off + 12;
1393 for i in 0..cell_count {
1394 let p = cell_ptr_array + i * 2;
1395 let cell_off = be_u16(slice, p) as usize;
1396 let child = be_u32(slice, cell_off);
1397 self.walk_table_page(child, column_count, rows, visited)?;
1398 }
1399 let right = be_u32(slice, hdr_off + 8);
1400 self.walk_table_page(right, column_count, rows, visited)
1401 }
1402 other => Err(Error::NotATablePage(other)),
1403 }
1404 }
1405
1406 fn read_leaf_cells(
1407 &self,
1408 slice: &[u8],
1409 hdr_off: usize,
1410 cell_count: usize,
1411 column_count: usize,
1412 rows: &mut Vec<Row>,
1413 ) -> Result<(), Error> {
1414 let cell_ptr_array = hdr_off + 8; // leaf b-tree header is 8 bytes
1415 for i in 0..cell_count {
1416 let p = cell_ptr_array + i * 2;
1417 let cell_off = be_u16(slice, p) as usize;
1418 let row = self.decode_leaf_cell(slice, cell_off, column_count)?;
1419 rows.push(row);
1420 }
1421 Ok(())
1422 }
1423
1424 /// Decode one table-leaf cell at `off` into a [`Row`], reassembling the
1425 /// payload from its overflow-page chain when it spills past the leaf page.
1426 fn decode_leaf_cell(
1427 &self,
1428 slice: &[u8],
1429 off: usize,
1430 column_count: usize,
1431 ) -> Result<Row, Error> {
1432 let (payload_len, n1) = read_varint(slice, off)?;
1433 let (rowid, n2) = read_varint(slice, off + n1)?;
1434 let payload_start = off + n1 + n2;
1435 let total = usize::try_from(payload_len).map_err(|_| Error::TruncatedCell)?;
1436
1437 let usable = self.header.usable_size() as usize;
1438 let local = local_payload_len(total, usable);
1439
1440 let payload = if local >= total {
1441 // Whole payload is on the leaf page (no spill).
1442 slice
1443 .get(payload_start..payload_start + total)
1444 .ok_or(Error::TruncatedCell)?
1445 .to_vec()
1446 } else {
1447 // Spilled: `local` bytes on the leaf, then a 4-byte overflow page
1448 // pointer, then the remainder follows the overflow chain.
1449 let head = slice
1450 .get(payload_start..payload_start + local)
1451 .ok_or(Error::TruncatedCell)?;
1452 let first_overflow = be_u32(slice, payload_start + local);
1453 let mut buf = Vec::with_capacity(total);
1454 buf.extend_from_slice(head);
1455 self.read_overflow_chain(first_overflow, total - local, &mut buf)?;
1456 buf
1457 };
1458
1459 let values = decode_record(&payload, column_count, rowid, self.header.text_encoding)?;
1460 Ok(Row { rowid, values })
1461 }
1462
1463 /// Follow an overflow-page chain starting at `first` (1-based page number),
1464 /// appending up to `remaining` payload bytes to `buf`. Each overflow page is
1465 /// a 4-byte big-endian "next page" pointer (0 ends the chain) followed by up
1466 /// to `usable - 4` content bytes.
1467 ///
1468 /// Bounded against cyclic/over-long chains via [`Error::MalformedOverflow`].
1469 fn read_overflow_chain(
1470 &self,
1471 first: u32,
1472 mut remaining: usize,
1473 buf: &mut Vec<u8>,
1474 ) -> Result<(), Error> {
1475 let usable = self.header.usable_size() as usize;
1476 let per_page = usable.saturating_sub(4);
1477 if per_page == 0 {
1478 return Err(Error::MalformedOverflow);
1479 }
1480 let total_pages = self.file_page_count();
1481 let cap = total_pages as usize + 1;
1482
1483 let mut page = first;
1484 let mut visited = 0usize;
1485 while remaining > 0 {
1486 if page == 0 || page > total_pages {
1487 return Err(Error::MalformedOverflow);
1488 }
1489 visited += 1;
1490 if visited > cap {
1491 return Err(Error::MalformedOverflow);
1492 }
1493 let slice = self.page_slice(page)?;
1494 let next = be_u32(slice, 0);
1495 let take = remaining.min(per_page);
1496 let chunk = slice.get(4..4 + take).ok_or(Error::TruncatedCell)?;
1497 buf.extend_from_slice(chunk);
1498 remaining -= take;
1499 page = next;
1500 }
1501 Ok(())
1502 }
1503}
1504
1505/// Number of payload bytes stored locally on a table-leaf page for a record of
1506/// `total` bytes, given the page's `usable` size (file-format §1.6 overflow
1507/// rule). When the return value equals `total`, the record does not spill.
1508fn local_payload_len(total: usize, usable: usize) -> usize {
1509 let max_local = usable - 35; // X: largest payload kept entirely local
1510 if total <= max_local {
1511 return total;
1512 }
1513 let min_local = (usable - 12) * 32 / 255 - 23; // M
1514 let k = min_local + (total - min_local) % (usable - 4);
1515 if k <= max_local {
1516 k
1517 } else {
1518 min_local
1519 }
1520}
1521
1522impl WalOverlay {
1523 /// Parse a `-wal` sidecar into the newest committed page versions.
1524 ///
1525 /// Returns `Ok(None)` when `wal` is absent of a usable header / has no
1526 /// frames (a no-op overlay). Iterates frames in file order, accumulating the
1527 /// page data of each frame whose salt matches the WAL header; on reaching a
1528 /// COMMIT frame (`db_size_after_commit != 0`) the accumulated pages are
1529 /// promoted into the committed snapshot. Frames after the last commit are
1530 /// uncommitted and dropped. Bounds-checked and breadth-capped against a
1531 /// crafted WAL (a frame whose declared page data runs past the file ends the
1532 /// scan rather than panicking).
1533 fn parse(wal: &[u8], page_size: u32) -> Result<Option<Self>, Error> {
1534 use forensicnomicon::sqlite::{SQLITE_WAL_FRAME_HEADER_SIZE, SQLITE_WAL_HEADER_SIZE};
1535
1536 // No header → no overlay (treat a too-short WAL as empty, not an error:
1537 // a missing/zero-length sidecar is normal and must not fail the open).
1538 let Some(hdr) = wal.get(..SQLITE_WAL_HEADER_SIZE) else {
1539 return Ok(None);
1540 };
1541 let magic = be_u32(hdr, 0);
1542 if magic != WAL_MAGIC_BE && magic != WAL_MAGIC_LE {
1543 return Ok(None);
1544 }
1545 // The WAL records its own page size (offset 8); trust the DB header's
1546 // page size but require agreement to avoid mis-slicing frames.
1547 let wal_page_size = be_u32(hdr, 8);
1548 if wal_page_size != page_size {
1549 return Ok(None);
1550 }
1551 // WAL header layout (file-format §4.1): salt-1 at offset 16, salt-2 at
1552 // offset 20 (the two checksum words follow at 24 and 28).
1553 let salt1 = be_u32(hdr, 16);
1554 let salt2 = be_u32(hdr, 20);
1555
1556 let ps = page_size as usize;
1557 let frame_stride = SQLITE_WAL_FRAME_HEADER_SIZE + ps;
1558
1559 let mut committed: std::collections::BTreeMap<u32, Vec<u8>> =
1560 std::collections::BTreeMap::new();
1561 let mut pending: std::collections::BTreeMap<u32, Vec<u8>> =
1562 std::collections::BTreeMap::new();
1563 // Every committed frame's page image (file order), and the pending frames
1564 // not yet promoted by a COMMIT. Mirrors the page promotion above so
1565 // uncommitted trailing frames are dropped from BOTH the view and the carve.
1566 let mut frames: Vec<WalFramePage> = Vec::new();
1567 let mut pending_frames: Vec<WalFramePage> = Vec::new();
1568
1569 let mut off = SQLITE_WAL_HEADER_SIZE;
1570 // One frame per page in the file is the natural breadth cap; allow a
1571 // generous multiple for repeated rewrites, but keep it bounded.
1572 let max_frames = wal.len() / frame_stride + 1;
1573 let mut frame_no = 0usize;
1574
1575 while let Some(frame) = wal.get(off..off + frame_stride) {
1576 frame_no += 1;
1577 if frame_no > max_frames {
1578 break; // cov:unreachable: the slice walk already bounds frame_no
1579 }
1580 let page_no = be_u32(frame, 0);
1581 let db_size = be_u32(frame, 4);
1582 let fsalt1 = be_u32(frame, 8);
1583 let fsalt2 = be_u32(frame, 12);
1584 // A frame from a different checkpoint generation (salt mismatch) is
1585 // stale residue, not part of this WAL's live content — stop here.
1586 if fsalt1 != salt1 || fsalt2 != salt2 {
1587 break;
1588 }
1589 if page_no == 0 {
1590 break; // malformed frame; stop rather than mis-index
1591 }
1592 let data = frame
1593 .get(SQLITE_WAL_FRAME_HEADER_SIZE..)
1594 .ok_or(Error::TruncatedCell)?;
1595 pending.insert(page_no, data.to_vec());
1596 let is_commit = db_size != 0;
1597 pending_frames.push(WalFramePage {
1598 frame_index: frame_no - 1, // 0-based file order
1599 page_no,
1600 salt1,
1601 salt2,
1602 is_commit,
1603 page: data.to_vec(),
1604 });
1605
1606 if is_commit {
1607 // COMMIT frame: promote everything pending into the snapshot AND
1608 // into the committed frame list (keeping every frame, not just the
1609 // newest version of each page).
1610 for (p, d) in std::mem::take(&mut pending) {
1611 committed.insert(p, d);
1612 }
1613 frames.append(&mut pending_frames);
1614 }
1615 off += frame_stride;
1616 }
1617
1618 if committed.is_empty() {
1619 Ok(None)
1620 } else {
1621 Ok(Some(WalOverlay {
1622 pages: committed,
1623 frames,
1624 raw: wal.to_vec(),
1625 }))
1626 }
1627 }
1628}
1629
1630// ===========================================================================
1631// Bespoke, format-exact WAL temporal model (task #55)
1632// ===========================================================================
1633//
1634// A `-wal` sidecar is NOT an open-ended event log. It is a BOUNDED SEGMENT under a
1635// single salt epoch: every live frame shares the WAL header's (salt1, salt2). A
1636// checkpoint reset renumbers frames and rolls the salts — a DISCONTINUITY, not a
1637// continuation. The only materializable database states are the COMMIT snapshots:
1638// the replay of all valid frames up to a commit frame. A frame BETWEEN commits is
1639// not independently materializable, so it is never surfaced as a snapshot. Tails
1640// past the last commit, or after a salt reset, are WAL residue — forensic leads,
1641// never committed history.
1642//
1643// This model is self-contained in sqlite-core. The future state-history-forensic
1644// [H] adapter attaches at the seam exposed here (WalLsn + CohortTopology +
1645// `checksums_are_tamper_evident`), but sqlite-core does NOT depend on it.
1646
1647/// Cap on the number of salt segments and frames the timeline parser will walk on a
1648/// crafted `-wal`, bounding work against an attacker-supplied file. A real WAL holds
1649/// one segment with at most a few frames per database page.
1650const MAX_WAL_SEGMENTS: usize = 1024;
1651
1652/// Identity of one salt epoch within a `-wal` file: its 0-based segment ordinal.
1653/// A fresh segment begins at file start and after every checkpoint salt reset.
1654#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
1655pub struct WalSegmentId(pub usize);
1656
1657/// One salt epoch of a `-wal` file — a single bounded segment.
1658///
1659/// A `-wal` is a bounded segment, not an open-ended log: every live frame here shares
1660/// `(salt1, salt2)`. A checkpoint reset (salt change + frame renumber) starts a NEW
1661/// `WalSegment`; it is a discontinuity, never another epoch of the same segment.
1662#[derive(Debug, Clone, PartialEq, Eq)]
1663pub struct WalSegment {
1664 /// This segment's ordinal within the WAL (0 = the segment at file start).
1665 pub id: WalSegmentId,
1666 /// WAL salt-1 (checkpoint generation), shared by every frame in the segment.
1667 pub salt1: u32,
1668 /// WAL salt-2 (checkpoint generation), shared by every frame in the segment.
1669 pub salt2: u32,
1670 /// Page size declared by the segment's frames (bytes).
1671 pub page_size: u32,
1672 /// Number of frames belonging to this segment.
1673 pub frame_count: usize,
1674 /// The checkpoint sequence number recorded in the WAL header (offset 12). For a
1675 /// segment discovered after a reset within the same file this is the header's
1676 /// value; per-segment sequence is otherwise not separately recorded.
1677 pub checkpoint_seq: u32,
1678}
1679
1680/// Address of a materializable database state: the replay of all valid frames up to
1681/// a COMMIT frame. `CommitId = (segment, commit_frame_index, db_size_after_commit)`.
1682#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
1683pub struct CommitId {
1684 /// The salt segment this commit belongs to.
1685 pub segment: WalSegmentId,
1686 /// 0-based file-order index of the COMMIT frame within the segment.
1687 pub commit_frame_index: usize,
1688 /// `db_size_after_commit` recorded in the COMMIT frame header — the database's
1689 /// page count once this commit is materialized.
1690 pub db_size_after_commit: u32,
1691}
1692
1693/// The salt-qualified log-sequence identity of a WAL position — the seam the future
1694/// `state-history-forensic` `[H]` adapter maps onto `LsnKind::SqliteWal`.
1695///
1696/// A bare `frame_index` is meaningless across checkpoint resets (frames renumber), so
1697/// ordering is ALWAYS qualified by `(salt1, salt2)`. The adapter must reconstruct
1698/// `LsnKind::SqliteWal { salt1, salt2, frame_index }` from exactly this triple — never
1699/// from a bare index.
1700#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
1701pub struct WalLsn {
1702 /// Salt-1 of the owning segment (checkpoint generation).
1703 pub salt1: u32,
1704 /// Salt-2 of the owning segment (checkpoint generation).
1705 pub salt2: u32,
1706 /// 0-based frame index within that segment.
1707 pub frame_index: usize,
1708}
1709
1710/// Topology of the temporal cohort the WAL exposes — the shape the `[H]` adapter maps
1711/// to `state-history-forensic::CohortTopology`.
1712#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1713pub enum CohortTopology {
1714 /// A single salt epoch: the commit snapshots form one linearly-ordered chain.
1715 LinearSegment,
1716 /// Multiple salt epochs (checkpoint resets) with no replay continuity between
1717 /// them — each segment is linear internally but the segments are disconnected.
1718 Disconnected,
1719}
1720
1721/// One page's image at a particular [`CommitSnapshot`].
1722#[derive(Debug, Clone, PartialEq, Eq)]
1723pub struct CommittedPageVersion {
1724 /// 1-based database page number.
1725 pub page_no: u32,
1726 /// The page's full image (`page_size` bytes) as of this commit.
1727 pub bytes: Vec<u8>,
1728}
1729
1730/// A materializable database state: the replay of all valid frames up to a COMMIT.
1731///
1732/// This is the ONLY independently-materializable WAL state. `page_version` resolves a
1733/// page to its image as of this commit (the newest frame ≤ this commit that rewrote
1734/// the page, else the acquired base image). A frame between commits is never a
1735/// snapshot.
1736#[derive(Debug, Clone, PartialEq, Eq)]
1737pub struct CommitSnapshot {
1738 id: CommitId,
1739 /// Salt-1 of the owning segment, carried so [`CommitSnapshot::lsn`] is
1740 /// self-contained without a back-reference to the segment.
1741 salt1: u32,
1742 /// Salt-2 of the owning segment.
1743 salt2: u32,
1744 /// The materialized page images at this commit: base image overlaid with every
1745 /// committed frame up to and including this commit (newest version per page),
1746 /// capped to `db_size_after_commit` pages. `page_version` reads from this map.
1747 overlaid: std::collections::BTreeMap<u32, Vec<u8>>,
1748}
1749
1750impl CommitSnapshot {
1751 /// This snapshot's [`CommitId`].
1752 #[must_use]
1753 pub fn id(&self) -> CommitId {
1754 self.id
1755 }
1756
1757 /// The database page count once this commit is materialized.
1758 #[must_use]
1759 pub fn db_size_after_commit(&self) -> u32 {
1760 self.id.db_size_after_commit
1761 }
1762
1763 /// The salt-qualified [`WalLsn`] of this commit (the `[H]` adapter seam).
1764 #[must_use]
1765 pub fn lsn(&self) -> WalLsn {
1766 WalLsn {
1767 salt1: self.salt1,
1768 salt2: self.salt2,
1769 frame_index: self.id.commit_frame_index,
1770 }
1771 }
1772
1773 /// The 1-based page numbers this commit materialized (base ∪ committed frames
1774 /// up to this commit, capped to `db_size_after_commit`), ascending.
1775 ///
1776 /// The carve-at-snapshot primitive iterates these to drive the carving
1777 /// primitives over each page image, WITHOUT assuming the pages form a
1778 /// contiguous `1..=db_size` range (a truncating commit or a sparse base image
1779 /// can leave gaps). Every returned page resolves via [`Self::page_version`].
1780 #[must_use]
1781 pub fn page_numbers(&self) -> Vec<u32> {
1782 self.overlaid.keys().copied().collect()
1783 }
1784
1785 /// The image of `page_no` as of this commit, or `None` for a page beyond the
1786 /// committed database size that the WAL never rewrote.
1787 #[must_use]
1788 pub fn page_version(&self, page_no: u32) -> Option<CommittedPageVersion> {
1789 let bytes = self.overlaid.get(&page_no)?.clone();
1790 Some(CommittedPageVersion { page_no, bytes })
1791 }
1792}
1793
1794/// A page-level delta between two materialized states.
1795#[derive(Debug, Clone, PartialEq, Eq)]
1796pub struct WalDiff {
1797 changed: Vec<u32>,
1798}
1799
1800impl WalDiff {
1801 /// The 1-based page numbers whose bytes differ between the two states, ascending.
1802 #[must_use]
1803 pub fn changed_pages(&self) -> &[u32] {
1804 &self.changed
1805 }
1806}
1807
1808/// A stale WAL tail surfaced for forensics — NOT committed history.
1809///
1810/// Frames past the last COMMIT of a segment, frames after a salt reset that cannot be
1811/// replayed into the current segment, or a header/page-size break: all are residue.
1812/// The examiner weighs them; they are never part of a consistent snapshot.
1813#[derive(Debug, Clone, PartialEq, Eq)]
1814pub struct WalResidue {
1815 /// The segment the residue trails (the segment whose last commit it follows).
1816 pub segment: WalSegmentId,
1817 /// 0-based frame index (within the file) of the first residual frame.
1818 pub first_frame_index: usize,
1819 /// Number of residual frames.
1820 pub frame_count: usize,
1821 /// Why these frames are residue rather than committed history.
1822 pub reason: ResidueReason,
1823}
1824
1825/// Why a WAL tail is [`WalResidue`] (an invalidated-frame candidate), not history.
1826#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1827pub enum ResidueReason {
1828 /// Frames written after the segment's last COMMIT (uncommitted tail).
1829 BeyondLastCommit,
1830 /// Frames whose salt no longer matches the segment header (post-reset residue).
1831 SaltReset,
1832}
1833
1834/// Validation tier a WAL has cleared — strictly increasing assurance.
1835///
1836/// `PhysicalValidation` < `CommitValidation` < `ReplaySafe`. The timeline reports the
1837/// highest tier reached; a page-size mismatch never even produces a timeline (it is a
1838/// hard stop at parse, surfaced as [`WalValidationError::PageSizeMismatch`]).
1839#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
1840pub enum MaterializationSafety {
1841 /// Header magic / format / page-size / salts / frame boundaries are well-formed,
1842 /// but no committed snapshot was found (nothing to replay).
1843 PhysicalValidated,
1844 /// A last valid commit and committed frame ranges were established, but the
1845 /// read-only replay overlay was not (or could not be) built.
1846 CommitValidated,
1847 /// A read-only replay overlay to the last commit is available — safe to
1848 /// materialize without mutating either file.
1849 ReplaySafe,
1850}
1851
1852/// A WAL that cannot be admitted to the timeline at all (physical-validation hard
1853/// stops). Distinct from "no committed snapshot", which is a valid empty timeline.
1854#[derive(Debug, Clone, PartialEq, Eq)]
1855pub enum WalValidationError {
1856 /// The `-wal` is shorter than its 32-byte header, or carries the wrong magic.
1857 BadMagic,
1858 /// The WAL header's page size disagrees with the DB header's — a HARD STOP, since
1859 /// every frame would be mis-sliced. `db` and `wal` are the two declared sizes.
1860 PageSizeMismatch { db: u32, wal: u32 },
1861 /// The main database header itself failed to parse.
1862 Header(Error),
1863}
1864
1865/// The bespoke, format-exact temporal model of a `-wal` sidecar.
1866///
1867/// Enumerates the salt segments, the materializable [`CommitSnapshot`]s within them
1868/// (CommitId-addressable), and the [`WalResidue`] tails. Materialize a snapshot's page
1869/// images via [`CommitSnapshot::page_version`]; diff the acquired base against the last
1870/// valid commit via [`WalTimeline::diff_base_to_last_commit`].
1871#[derive(Debug, Clone, PartialEq, Eq)]
1872pub struct WalTimeline {
1873 page_size: u32,
1874 base_pages: std::collections::BTreeMap<u32, Vec<u8>>,
1875 segments: Vec<WalSegment>,
1876 snapshots: Vec<CommitSnapshot>,
1877 residue: Vec<WalResidue>,
1878 safety: MaterializationSafety,
1879}
1880
1881impl WalTimeline {
1882 /// Physical-validation tier: header magic + format check.
1883 ///
1884 /// Parses `bytes` (the acquired main DB) and `wal` (the `-wal` sidecar) into the
1885 /// segmented temporal model. A page-size mismatch between the DB header and the
1886 /// WAL header is a HARD STOP; a bad/short header is [`WalValidationError::BadMagic`].
1887 fn parse(bytes: &[u8], wal: &[u8], page_size: u32) -> Result<Self, WalValidationError> {
1888 use forensicnomicon::sqlite::{SQLITE_WAL_FRAME_HEADER_SIZE, SQLITE_WAL_HEADER_SIZE};
1889
1890 // --- PhysicalValidation: header magic / format / page-size / salts -------
1891 let hdr = wal
1892 .get(..SQLITE_WAL_HEADER_SIZE)
1893 .ok_or(WalValidationError::BadMagic)?;
1894 let magic = be_u32(hdr, 0);
1895 if magic != WAL_MAGIC_BE && magic != WAL_MAGIC_LE {
1896 return Err(WalValidationError::BadMagic);
1897 }
1898 let wal_page_size = be_u32(hdr, 8);
1899 if wal_page_size != page_size {
1900 return Err(WalValidationError::PageSizeMismatch {
1901 db: page_size,
1902 wal: wal_page_size,
1903 });
1904 }
1905 let checkpoint_seq = be_u32(hdr, 12);
1906 let mut salt1 = be_u32(hdr, 16);
1907 let mut salt2 = be_u32(hdr, 20);
1908
1909 let ps = page_size as usize;
1910 let frame_stride = SQLITE_WAL_FRAME_HEADER_SIZE + ps;
1911
1912 // The acquired main DB image: the pre-WAL base for replay within the current
1913 // validated segment (NOT "epoch 0" — just the base each commit overlays onto).
1914 let mut base_pages: std::collections::BTreeMap<u32, Vec<u8>> =
1915 std::collections::BTreeMap::new();
1916 // `chunks_exact` yields only whole pages (infallible by construction — no
1917 // out-of-bounds slice to guard); cap at `u32::MAX` pages so the 1-based page
1918 // number never overflows on a pathologically large image.
1919 for (idx, page) in bytes
1920 .chunks_exact(ps)
1921 .take(u32::MAX as usize - 1)
1922 .enumerate()
1923 {
1924 let pno = idx as u32 + 1; // 1-based page number
1925 base_pages.insert(pno, page.to_vec());
1926 }
1927
1928 let mut segments: Vec<WalSegment> = Vec::new();
1929 let mut snapshots: Vec<CommitSnapshot> = Vec::new();
1930 let mut residue: Vec<WalResidue> = Vec::new();
1931
1932 // Per-segment running state.
1933 let mut seg_ordinal = 0usize;
1934 let mut seg_frame_count = 0usize;
1935 // Cumulative newest-page map across all COMMITTED frames of the segment, so a
1936 // snapshot's `overlaid` is base ∪ committed-up-to-this-commit.
1937 let mut committed_pages: std::collections::BTreeMap<u32, Vec<u8>> = base_pages.clone();
1938 let mut pending: std::collections::BTreeMap<u32, Vec<u8>> =
1939 std::collections::BTreeMap::new();
1940 let mut last_commit_global_frame: Option<usize> = None;
1941 let mut uncommitted_tail_start: Option<usize> = None;
1942
1943 let mut off = SQLITE_WAL_HEADER_SIZE;
1944 let max_frames = wal.len() / frame_stride + 1;
1945 let mut frame_no = 0usize;
1946
1947 while let Some(frame) = wal.get(off..off + frame_stride) {
1948 if frame_no >= max_frames {
1949 break; // cov:unreachable: the slice walk already bounds frame_no
1950 }
1951 let page_no = be_u32(frame, 0);
1952 let db_size = be_u32(frame, 4);
1953 let fsalt1 = be_u32(frame, 8);
1954 let fsalt2 = be_u32(frame, 12);
1955
1956 // A salt change opens a NEW segment (checkpoint reset = discontinuity).
1957 // Anything between the prior segment's last commit and here is residue.
1958 if fsalt1 != salt1 || fsalt2 != salt2 {
1959 if segments.len() >= MAX_WAL_SEGMENTS {
1960 break; // cov:unreachable: real WALs hold far fewer than 1024 salt epochs
1961 }
1962 // Close the current segment, recording its residue tail (if any).
1963 Self::close_segment(
1964 &mut segments,
1965 &mut residue,
1966 WalSegmentId(seg_ordinal),
1967 salt1,
1968 salt2,
1969 page_size,
1970 checkpoint_seq,
1971 seg_frame_count,
1972 uncommitted_tail_start,
1973 );
1974 // Begin the next segment under the new salts. Its base for replay is
1975 // the prior committed view (a checkpoint would have flushed it, but on
1976 // a forensic image we keep what we can replay).
1977 seg_ordinal += 1;
1978 salt1 = fsalt1;
1979 salt2 = fsalt2;
1980 seg_frame_count = 0;
1981 pending.clear();
1982 uncommitted_tail_start = None;
1983 // The post-reset frames replay onto the latest committed view.
1984 // committed_pages carries forward.
1985 }
1986
1987 if page_no == 0 {
1988 break; // malformed frame; stop rather than mis-index
1989 }
1990 let data = match frame.get(SQLITE_WAL_FRAME_HEADER_SIZE..) {
1991 Some(d) => d.to_vec(),
1992 None => break, // cov:unreachable: frame slice is exactly frame_stride
1993 };
1994
1995 let frame_index_in_seg = seg_frame_count;
1996 seg_frame_count += 1;
1997 pending.insert(page_no, data);
1998 let is_commit = db_size != 0;
1999
2000 if is_commit {
2001 for (p, d) in std::mem::take(&mut pending) {
2002 committed_pages.insert(p, d);
2003 }
2004 // Drop base/committed pages beyond the committed size so a snapshot
2005 // reflects the database's page count at that commit. `db_size` is
2006 // non-zero here (that is what makes this a COMMIT frame).
2007 committed_pages.retain(|&p, _| p <= db_size);
2008 let id = CommitId {
2009 segment: WalSegmentId(seg_ordinal),
2010 commit_frame_index: frame_index_in_seg,
2011 db_size_after_commit: db_size,
2012 };
2013 snapshots.push(CommitSnapshot {
2014 id,
2015 overlaid: committed_pages.clone(),
2016 salt1,
2017 salt2,
2018 });
2019 last_commit_global_frame = Some(frame_no);
2020 uncommitted_tail_start = None;
2021 } else if uncommitted_tail_start.is_none() {
2022 uncommitted_tail_start = Some(frame_index_in_seg);
2023 }
2024
2025 frame_no += 1;
2026 off += frame_stride;
2027 }
2028
2029 // Close the final segment (it may have an uncommitted tail).
2030 Self::close_segment(
2031 &mut segments,
2032 &mut residue,
2033 WalSegmentId(seg_ordinal),
2034 salt1,
2035 salt2,
2036 page_size,
2037 checkpoint_seq,
2038 seg_frame_count,
2039 uncommitted_tail_start,
2040 );
2041
2042 let safety = if snapshots.is_empty() {
2043 MaterializationSafety::PhysicalValidated
2044 } else if last_commit_global_frame.is_some() {
2045 MaterializationSafety::ReplaySafe
2046 } else {
2047 MaterializationSafety::CommitValidated // cov:unreachable: a snapshot implies a commit
2048 };
2049
2050 Ok(Self {
2051 page_size,
2052 base_pages,
2053 segments,
2054 snapshots,
2055 residue,
2056 safety,
2057 })
2058 }
2059
2060 #[allow(clippy::too_many_arguments)]
2061 fn close_segment(
2062 segments: &mut Vec<WalSegment>,
2063 residue: &mut Vec<WalResidue>,
2064 id: WalSegmentId,
2065 salt1: u32,
2066 salt2: u32,
2067 page_size: u32,
2068 checkpoint_seq: u32,
2069 frame_count: usize,
2070 uncommitted_tail_start: Option<usize>,
2071 ) {
2072 if frame_count == 0 {
2073 return;
2074 }
2075 segments.push(WalSegment {
2076 id,
2077 salt1,
2078 salt2,
2079 page_size,
2080 frame_count,
2081 checkpoint_seq,
2082 });
2083 if let Some(start) = uncommitted_tail_start {
2084 residue.push(WalResidue {
2085 segment: id,
2086 first_frame_index: start,
2087 frame_count: frame_count - start,
2088 reason: ResidueReason::BeyondLastCommit,
2089 });
2090 }
2091 }
2092
2093 /// The salt segments of this WAL, in file order (one per salt epoch).
2094 #[must_use]
2095 pub fn segments(&self) -> &[WalSegment] {
2096 &self.segments
2097 }
2098
2099 /// Every materializable [`CommitSnapshot`] across all segments, in commit order.
2100 #[must_use]
2101 pub fn commit_snapshots(&self) -> &[CommitSnapshot] {
2102 &self.snapshots
2103 }
2104
2105 /// The stale WAL tails surfaced for forensics (not committed history).
2106 #[must_use]
2107 pub fn residue(&self) -> &[WalResidue] {
2108 &self.residue
2109 }
2110
2111 /// Resolve a [`CommitId`] back to its [`CommitSnapshot`].
2112 #[must_use]
2113 pub fn snapshot_at(&self, id: CommitId) -> Option<&CommitSnapshot> {
2114 self.snapshots.iter().find(|s| s.id == id)
2115 }
2116
2117 /// The highest validation tier this WAL cleared (see [`MaterializationSafety`]).
2118 #[must_use]
2119 pub fn safety(&self) -> MaterializationSafety {
2120 self.safety
2121 }
2122
2123 /// The temporal-cohort topology — `LinearSegment` for one salt epoch, else
2124 /// `Disconnected` across checkpoint resets. The `[H]` adapter maps this onto
2125 /// `state-history-forensic::CohortTopology`.
2126 #[must_use]
2127 pub fn topology(&self) -> CohortTopology {
2128 if self.segments.len() <= 1 {
2129 CohortTopology::LinearSegment
2130 } else {
2131 CohortTopology::Disconnected
2132 }
2133 }
2134
2135 /// Whether the WAL's integrity checks are tamper-EVIDENT. Always `false`: WAL
2136 /// frame checksums are non-cryptographic (corruption detection, not tamper proof),
2137 /// so the `[H]` adapter must record `tamper_resistance = LOW`.
2138 #[must_use]
2139 pub fn checksums_are_tamper_evident(&self) -> bool {
2140 false
2141 }
2142
2143 /// Diff the acquired base image against the last valid commit snapshot, returning
2144 /// the page numbers whose bytes changed. `None` when there is no committed snapshot.
2145 #[must_use]
2146 pub fn diff_base_to_last_commit(&self) -> Option<WalDiff> {
2147 let last = self.snapshots.last()?;
2148 let mut changed = Vec::new();
2149 let mut pages: std::collections::BTreeSet<u32> = std::collections::BTreeSet::new();
2150 pages.extend(self.base_pages.keys().copied());
2151 pages.extend(last.overlaid.keys().copied());
2152 for p in pages {
2153 let base = self.base_pages.get(&p);
2154 let now = last.overlaid.get(&p);
2155 if base != now {
2156 changed.push(p);
2157 }
2158 }
2159 Some(WalDiff { changed })
2160 }
2161
2162 /// The page size (bytes) common to the base image and the WAL frames.
2163 #[must_use]
2164 pub fn page_size(&self) -> u32 {
2165 self.page_size
2166 }
2167
2168 /// Map this WAL timeline onto the canonical `forensicnomicon::history` cohort
2169 /// vocabulary — the `[H]` adapter (#43 / WS-F).
2170 ///
2171 /// Each materializable [`CommitSnapshot`] becomes one `TemporalState<CommitId>`:
2172 /// - **ordering key** — a salt-qualified `LsnKind::SqliteWalFrame` (`frame_seq` is the
2173 /// COMMIT frame index; `commit_seq` is the 0-based commit ordinal within the salt
2174 /// segment). The `(salt1, salt2)` pair keeps the key meaningful across a checkpoint
2175 /// reset, which renumbers frames and rolls the salts.
2176 /// - **clock + safety** — the canonical SQLite-WAL profile, single-sourced from
2177 /// [`forensicnomicon::history::profiles`], so no consumer re-asserts the four
2178 /// classifications locally.
2179 /// - **handle** — the snapshot's [`CommitId`]; resolve it back via [`Self::snapshot_at`].
2180 ///
2181 /// The topology is uniformly `SubJournalCommits`: every state is a committed
2182 /// transaction, and a checkpoint reset is visible as a salt change *inside* the
2183 /// ordering key — there is no separate "disconnected" topology to special-case. The
2184 /// cohort is `PathStable` (a `-wal` belongs to exactly one database path), so the
2185 /// caller supplies the path identity via `artifact`.
2186 #[must_use]
2187 pub fn to_temporal_cohort(
2188 &self,
2189 artifact: forensicnomicon::history::identity::ArtifactRef,
2190 ) -> forensicnomicon::history::cohort::TemporalCohort<CommitId> {
2191 use forensicnomicon::history::cohort::{TemporalCohort, TemporalState};
2192 use forensicnomicon::history::epoch::{CohortTopology, EpochTag, LsnKind};
2193 use forensicnomicon::history::identity::IdentityDiscipline;
2194 use forensicnomicon::history::profiles;
2195
2196 // One canonical profile drives every state's clock + safety — read from
2197 // forensicnomicon, never re-asserted here, so the fleet cannot drift.
2198 let profile = profiles::SourceTemporalProfile::sqlite_wal();
2199 let mut commit_seq_in_segment: std::collections::HashMap<WalSegmentId, u32> =
2200 std::collections::HashMap::new();
2201
2202 let states = self
2203 .snapshots
2204 .iter()
2205 .map(|snap| {
2206 let id = snap.id();
2207 let lsn = snap.lsn();
2208 let seq = commit_seq_in_segment.entry(id.segment).or_insert(0);
2209 let commit_seq = *seq;
2210 *seq += 1;
2211
2212 // Deterministic and collision-free within a cohort: the
2213 // (salt1, salt2, commit_frame_index, db_size_after_commit) quadruple is
2214 // unique per commit state. Packed big-endian into the leading 16 bytes.
2215 let mut tag = [0u8; 32];
2216 tag[0..4].copy_from_slice(&lsn.salt1.to_be_bytes());
2217 tag[4..8].copy_from_slice(&lsn.salt2.to_be_bytes());
2218 tag[8..12].copy_from_slice(&(id.commit_frame_index as u32).to_be_bytes());
2219 tag[12..16].copy_from_slice(&id.db_size_after_commit.to_be_bytes());
2220
2221 TemporalState {
2222 epoch: EpochTag::from_bytes(tag),
2223 ordering_key: Some(LsnKind::SqliteWalFrame {
2224 salt1: lsn.salt1,
2225 salt2: lsn.salt2,
2226 frame_seq: lsn.frame_index as u32,
2227 commit_seq,
2228 }),
2229 wall_time: None,
2230 clock: profile.clock.clone(),
2231 safety: profile.safety.clone(),
2232 handle: id,
2233 }
2234 })
2235 .collect();
2236
2237 TemporalCohort {
2238 artifact,
2239 discipline: IdentityDiscipline::PathStable,
2240 topology: CohortTopology::SubJournalCommits,
2241 states,
2242 }
2243 }
2244}
2245
2246/// Whether a decoded [`Value`] is **distinctive** enough to anchor a Tier-2
2247/// fragment emission (the §3.1 gate): TEXT of ≥ 4 bytes of valid UTF-8 (no
2248/// replacement char), or a REAL. Bare integers (1–8-byte serial patterns),
2249/// NULL, and BLOBs are NOT distinctive alone — a short integer byte-pattern
2250/// coincides far too often in a 4 `KiB` page to serve as identity, so it can ride
2251/// along inside a fragment but never justify emitting one.
2252fn is_distinctive(value: &Value) -> bool {
2253 match value {
2254 Value::Text(t) => t.len() >= 4 && !t.contains('\u{FFFD}'),
2255 Value::Real(_) => true,
2256 Value::Null | Value::Integer(_) | Value::Blob(_) => false,
2257 }
2258}
2259
2260/// The body byte-width of a serial type (file-format §2.1), or `None` for a
2261/// serial value that cannot legally appear in a record body.
2262fn serial_body_len(serial: i64) -> Option<usize> {
2263 match serial {
2264 0 | 8 | 9 | 10 | 11 => Some(0),
2265 1 => Some(1),
2266 2 => Some(2),
2267 3 => Some(3),
2268 4 => Some(4),
2269 5 => Some(6),
2270 6 | 7 => Some(8),
2271 n if n >= 12 => Some(((n - 12) / 2) as usize),
2272 _ => None, // negative serial: impossible
2273 }
2274}
2275
2276/// Byte length of a **live** table-leaf cell at `off`, for computing the byte
2277/// extent the cell occupies (so [`Database::carve_free_regions`] can exclude it).
2278/// Returns `None` if the cell header does not parse in bounds.
2279///
2280/// Mirrors the live cell layout: payload-length varint, rowid varint, then the
2281/// local payload (capped at the spill threshold) plus a 4-byte overflow pointer
2282/// when the payload spills. We only need the on-page footprint, so for a spilled
2283/// cell that is `local + 4` bytes, not the full reassembled payload.
2284fn live_cell_len(buf: &[u8], off: usize, usable: usize) -> Option<usize> {
2285 let (payload_len, n1) = read_varint(buf, off).ok()?;
2286 let (_rowid, n2) = read_varint(buf, off + n1).ok()?;
2287 let total = usize::try_from(payload_len).ok()?;
2288 let local = local_payload_len(total, usable);
2289 let on_page = if local >= total {
2290 n1 + n2 + total
2291 } else {
2292 n1 + n2 + local + 4 // 4-byte first-overflow-page pointer
2293 };
2294 Some(on_page)
2295}
2296
2297/// The rowid of a table-leaf cell at `off` — its 2nd varint (after the
2298/// payload-length varint). `None` if either varint is out of bounds. Used to
2299/// identify a live row even when its full record cannot be decoded.
2300fn live_cell_rowid(buf: &[u8], off: usize) -> Option<i64> {
2301 let (_payload_len, n1) = read_varint(buf, off).ok()?;
2302 let (rowid, _) = read_varint(buf, off + n1).ok()?;
2303 Some(rowid)
2304}
2305
2306/// Given the sorted byte extents of live cells, return the maximal **free**
2307/// (unallocated) spans within `[lo, hi)` — the complement of the live extents.
2308/// These are the only ranges [`Database::carve_free_regions`] scans, so a live
2309/// cell can never be re-surfaced.
2310fn free_regions(live: &[(usize, usize)], lo: usize, hi: usize) -> Vec<(usize, usize)> {
2311 let mut regions = Vec::new();
2312 let mut cursor = lo;
2313 for &(s, e) in live {
2314 let s = s.clamp(lo, hi);
2315 let e = e.clamp(lo, hi);
2316 if s > cursor {
2317 regions.push((cursor, s));
2318 }
2319 if e > cursor {
2320 cursor = e;
2321 }
2322 }
2323 if cursor < hi {
2324 regions.push((cursor, hi));
2325 }
2326 regions
2327}
2328
2329/// Derive a [`FreeblockTemplate`] from the first live cell on a table-leaf page:
2330/// the record's header length, its serial-type array, and the byte width of the
2331/// cell prefix (payload-length + rowid varints) that the freeblock header
2332/// overwrites. Returns `None` when no live cell parses or the prefix is wider
2333/// than the 4 bytes a freeblock header clobbers (the simple template cannot then
2334/// place the surviving serial tail).
2335/// Shared internal walker producing BOTH recovery tiers in one pass so the cell
2336/// and fragment outputs can never diverge: `(full_cells, fragments)`.
2337/// [`Database::reconstruct_freeblock_records`] takes `.0`,
2338/// [`Database::reconstruct_freeblock_fragments`] takes `.1`. A free function (it
2339/// needs no `Database` state — only the page bytes and the page-derived
2340/// template), keeping the two public entry points a thin projection of one walk.
2341fn reconstruct_freeblock_inner(
2342 page_bytes: &[u8],
2343 enc: TextEncoding,
2344) -> (Vec<CarvedCell>, Vec<CellFragment>) {
2345 let mut cells = Vec::new();
2346 let mut frags = Vec::new();
2347 let hdr_off = if page_bytes.starts_with(SQLITE_MAGIC) {
2348 SQLITE_HEADER_SIZE
2349 } else {
2350 0
2351 };
2352 let Some(&page_type) = page_bytes.get(hdr_off) else {
2353 return (cells, frags);
2354 };
2355 if page_type != 0x0d {
2356 return (cells, frags); // only table-leaf pages have freeblock residue
2357 }
2358 let Some(template) = freeblock_template(page_bytes, hdr_off, enc) else {
2359 return (cells, frags);
2360 };
2361
2362 let first_freeblock = be_u16(page_bytes, hdr_off + 1) as usize;
2363 let mut fb = first_freeblock;
2364 let mut walked = 0usize;
2365 let mut visited = std::collections::BTreeSet::new();
2366 while fb != 0 && walked < MAX_FREEBLOCKS_PER_PAGE {
2367 walked += 1;
2368 if !visited.insert(fb) {
2369 break; // cyclic next pointer
2370 }
2371 let next = be_u16(page_bytes, fb) as usize;
2372 let size = be_u16(page_bytes, fb + 2) as usize;
2373 let Some(fb_end) = fb.checked_add(size) else {
2374 break; // cov:unreachable: usize add of two u16-range values
2375 };
2376 if size >= 4 && fb_end <= page_bytes.len() {
2377 template.reconstruct_span_tiered(page_bytes, fb, fb_end, false, &mut cells, &mut frags);
2378 }
2379 fb = next;
2380 }
2381
2382 let cell_count = be_u16(page_bytes, hdr_off + 3) as usize;
2383 let cptr_end = hdr_off + 8 + cell_count * 2;
2384 let cca = be_u16(page_bytes, hdr_off + 5) as usize;
2385 if cca > cptr_end && cca <= page_bytes.len() {
2386 for anchor_off in cptr_end..cca {
2387 let Some(anchor) =
2388 try_carve_cell_at(page_bytes, anchor_off, Some(template.column_count), enc)
2389 else {
2390 continue;
2391 };
2392 let has_text = anchor
2393 .values
2394 .iter()
2395 .any(|v| matches!(v, Value::Text(t) if !t.is_empty() && !t.contains('\u{FFFD}')));
2396 if !has_text {
2397 continue;
2398 }
2399 let tail_start = anchor.offset + anchor.byte_len;
2400 template
2401 .reconstruct_span_tiered(page_bytes, tail_start, cca, true, &mut cells, &mut frags);
2402 break; // one anchored run per page — the contiguous freed tail
2403 }
2404 }
2405 (cells, frags)
2406}
2407
2408fn freeblock_template(page_bytes: &[u8], hdr_off: usize, enc: TextEncoding) -> Option<FreeblockTemplate> {
2409 let cell_count = be_u16(page_bytes, hdr_off + 3) as usize;
2410 let cell_ptr_array = hdr_off + 8;
2411 for i in 0..cell_count {
2412 let cell_off = be_u16(page_bytes, cell_ptr_array + i * 2) as usize;
2413 if cell_off == 0 || cell_off >= page_bytes.len() {
2414 continue;
2415 }
2416 // Prefix: payload-length varint, rowid varint.
2417 let Ok((_payload_len, n1)) = read_varint(page_bytes, cell_off) else {
2418 continue; // cov:unreachable: a live cell-pointer addresses an in-bounds prefix
2419 };
2420 let Ok((_rowid, n2)) = read_varint(page_bytes, cell_off + n1) else {
2421 continue; // cov:unreachable: the rowid varint follows the payload-len varint in-page
2422 };
2423 let prefix_len = n1 + n2;
2424 // The freeblock header overwrites exactly 4 bytes. If the prefix alone is
2425 // wider, no record-header byte is clobbered in a way this simple template
2426 // handles — skip (those tables keep an intact header tail the forward
2427 // carver already reaches).
2428 if prefix_len > 4 {
2429 continue; // cov:unreachable: the corpus tables all encode a <=4-byte cell prefix
2430 }
2431 let payload_start = cell_off + n1 + n2;
2432 let Ok((header_len, hn)) = read_varint(page_bytes, payload_start) else {
2433 continue; // cov:unreachable: a live cell's record header follows its prefix in-page
2434 };
2435 let header_len = usize::try_from(header_len).ok()?;
2436 if header_len < hn {
2437 continue; // cov:unreachable: a live record's header_len covers its own varint
2438 }
2439 // Read the template's serial-type array, recording each serial's byte
2440 // offset within the header so we can split clobbered vs surviving.
2441 let mut serials = Vec::new();
2442 let mut hpos = hn;
2443 let mut ok = true;
2444 while hpos < header_len {
2445 let Ok((s, used)) = read_varint(page_bytes, payload_start + hpos) else {
2446 ok = false; // cov:unreachable: header_len bounds the serial array within the page
2447 break; // cov:unreachable: paired with the read failure above
2448 };
2449 serials.push((s, hpos, used));
2450 hpos += used;
2451 }
2452 if !ok || hpos != header_len || serials.len() < MIN_INFERRED_COLUMNS {
2453 continue; // cov:unreachable: a live cell's header parses cleanly with >= 2 columns
2454 }
2455 return FreeblockTemplate::build(prefix_len, header_len, hn, &serials, enc);
2456 }
2457 None
2458}
2459
2460/// A record-header template derived from a live cell on a table-leaf page, used
2461/// to rebuild freeblock-clobbered records (see
2462/// [`Database::reconstruct_freeblock_records`]).
2463///
2464/// Freeblock conversion overwrites the freed cell's first four bytes — the
2465/// payload-length + rowid varints, the record `header_len`, and the leading
2466/// serial type(s). The surviving serial-type tail and the value body remain. The
2467/// template supplies what was destroyed: the total column count, the serial types
2468/// of the leading (clobbered) columns, and the page offset, relative to the
2469/// freeblock start, at which the surviving serial tail begins.
2470struct FreeblockTemplate {
2471 /// Total number of columns in a record of this table.
2472 column_count: usize,
2473 /// Serial types of the leading columns whose header bytes the freeblock
2474 /// header clobbered (taken from the template; e.g. the fixed-width `id`).
2475 known_lead_serials: Vec<i64>,
2476 /// Offset, relative to the freeblock start, at which the **surviving** serial
2477 /// tail begins (== `prefix_len + first_surviving_serial_header_offset`).
2478 surviving_serials_off: usize,
2479 /// Text encoding of the owning database, so reconstructed text decodes per
2480 /// the header (UTF-8 / UTF-16) rather than assuming UTF-8.
2481 text_encoding: TextEncoding,
2482}
2483
2484impl FreeblockTemplate {
2485 /// Build a template from a parsed live-cell header. `serials` is the list of
2486 /// `(serial_type, header_offset, varint_width)` tuples for every column.
2487 /// Returns `None` when the 4-byte freeblock clobber boundary cannot be
2488 /// resolved to a clean split between leading and surviving serials.
2489 fn build(
2490 prefix_len: usize,
2491 _header_len: usize,
2492 _hn: usize,
2493 serials: &[(i64, usize, usize)],
2494 enc: TextEncoding,
2495 ) -> Option<FreeblockTemplate> {
2496 // Bytes of the record header the 4-byte freeblock header destroys.
2497 let clobbered_header_bytes = 4usize.checked_sub(prefix_len)?;
2498 // The first column whose header bytes survive intact is the first serial
2499 // whose header offset is at or beyond the clobber boundary. Everything
2500 // before it is supplied from the template.
2501 let mut known_lead = Vec::new();
2502 let mut surviving_serials_off = None;
2503 for &(serial, hpos, _used) in serials {
2504 if hpos >= clobbered_header_bytes {
2505 surviving_serials_off = Some(prefix_len + hpos);
2506 break;
2507 }
2508 known_lead.push(serial);
2509 }
2510 // Require at least one surviving serial AND at least one clobbered serial
2511 // (otherwise the forward carver already reaches the record, or nothing
2512 // survives to anchor reconstruction).
2513 let surviving_serials_off = surviving_serials_off?;
2514 if known_lead.is_empty() || known_lead.len() >= serials.len() {
2515 return None;
2516 }
2517 Some(FreeblockTemplate {
2518 column_count: serials.len(),
2519 known_lead_serials: known_lead,
2520 surviving_serials_off,
2521 text_encoding: enc,
2522 })
2523 }
2524
2525 /// Reconstruct **every** clobbered cell coalesced into the free span
2526 /// `[lo, hi)` — a chained freeblock or a page's unallocated gap — and append
2527 /// each to `out`.
2528 ///
2529 /// When SQLite frees adjacent cells it coalesces them into one freeblock whose
2530 /// interior still holds the freed cells back-to-back, **each** prefixed by a
2531 /// stale 4-byte freeblock header (`next`/`size`) that clobbers that cell's
2532 /// payload-length + rowid varints and leading serial(s). A single-shot
2533 /// reconstruction at `lo` recovers only the span's first cell; the trailing
2534 /// cells are intact records sitting at the previous record's end. This walks
2535 /// the template across the span: reconstruct at `lo`, advance to that record's
2536 /// end, repeat to `hi`. Every value is derived from the span bounds and the
2537 /// page's own schema template — no per-cell or per-database constant.
2538 ///
2539 /// Each candidate is validated identically to the single-cell case (legal
2540 /// serial types, record fits within `[cell_start, hi)`). The walk is
2541 /// **structural, not a sliding scan**: SQLite coalesces freed cells exactly
2542 /// back-to-back (each freed record's end abuts the next freed cell's clobbered
2543 /// 4-byte prefix), so the next cell begins precisely at the previous record's
2544 /// end. The walk therefore reconstructs at `lo`, advances to that record's
2545 /// end, and repeats — and STOPS the moment a position does not reconstruct
2546 /// cleanly. It never slides forward byte-by-byte hunting for the next cell:
2547 /// that fallback would synthesize a record from any run of bytes that happens
2548 /// to satisfy the legal-serial + fits-in-span checks, manufacturing phantoms
2549 /// in non-cell free space. Anchoring every cell at the prior record's exact
2550 /// end is what keeps the broader span-walk at single-cell precision. Bounded:
2551 /// the walk strictly advances (a record is non-empty) and is capped at
2552 /// [`MAX_FREEBLOCKS_PER_PAGE`] reconstructions per span.
2553 ///
2554 /// Follower precision (the coalesced-freeblock signature): the span's FIRST
2555 /// cell at `lo` is reconstructed unconditionally — `lo` is a real boundary (a
2556 /// freeblock-chain entry, or the gap anchor's first follower). Every SUBSEQUENT
2557 /// follower must carry the structural mark of a freed-and-coalesced cell: its
2558 /// clobbered 4-byte prefix is a stale freeblock header whose 2-byte `next`
2559 /// field is `0x0000` (a terminal/orphaned freeblock — what SQLite leaves when
2560 /// it coalesces freed cells back-to-back). A position whose leading two bytes
2561 /// are non-zero is a byte-shifted remnant, not a coalesced cell, so the run
2562 /// ends there. This is the check that separates a true coalesced tail (0D-06's
2563 /// `00 00 NN NN`-prefixed followers) from a misaligned fragment (0B-02's
2564 /// `24 09 …` remnant), keeping the gap pass phantom-free.
2565 ///
2566 /// `enforce_follower_mark` is `true` for the unallocated-gap pass, where the
2567 /// span is bounded only by `cellContentArea` (not by a page-recorded freeblock
2568 /// size) and so a byte-shifted remnant could otherwise be mistaken for a
2569 /// follower: there EVERY position must carry the `next == 0` mark. It is `false`
2570 /// for the freeblock-chain pass, whose span bounds are the page-recorded
2571 /// `[fb, fb + size)` — a strong boundary that already pins the coalesced run, so
2572 /// the interior followers (whose clobbered bytes are the original record's own
2573 /// varints, not necessarily `00 00 …`) are accepted on the fit-in-span check
2574 /// alone.
2575 ///
2576 /// Tiered walk: it pushes each reconstructed full cell into `cells`, and at the
2577 /// anchor where `reconstruct_one` would `break` it salvages the maximal
2578 /// decodable column prefix into `frags` as a [`CellFragment`] (when the §3.1
2579 /// distinctiveness gate passes) before stopping. Fragment salvage does NOT
2580 /// extend the walk — it stops at exactly the position the full walk does,
2581 /// preserving Tier-1's phantom discipline. Callers that want only the full
2582 /// cells (the Tier-1 [`Database::reconstruct_freeblock_records`]) discard
2583 /// `frags`; both tiers therefore come from one walk and can never diverge.
2584 fn reconstruct_span_tiered(
2585 &self,
2586 page: &[u8],
2587 lo: usize,
2588 hi: usize,
2589 enforce_follower_mark: bool,
2590 cells: &mut Vec<CarvedCell>,
2591 frags: &mut Vec<CellFragment>,
2592 ) {
2593 let mut cell_start = lo;
2594 let mut built = 0usize;
2595 while cell_start < hi && built < MAX_FREEBLOCKS_PER_PAGE {
2596 if enforce_follower_mark && be_u16(page, cell_start) != 0 {
2597 break; // not a coalesced freeblock follower — the contiguous run ends
2598 }
2599 let Some((cell, record_end)) = self.reconstruct_one(page, cell_start, hi) else {
2600 // Full reconstruction failed at this anchor; try to salvage the
2601 // decodable prefix as a fragment, then stop (do not extend the
2602 // walk past the failed anchor).
2603 if let Some(frag) = self.salvage_fragment(page, cell_start, hi) {
2604 frags.push(frag);
2605 }
2606 break;
2607 };
2608 cells.push(cell);
2609 built += 1;
2610 cell_start = record_end;
2611 }
2612 }
2613
2614 /// Salvage the maximal decodable column prefix at `cell_start` (bounded by
2615 /// `span_end`) when full reconstruction failed there. Walks the template +
2616 /// surviving serial array forward, decoding each column's body while it fits
2617 /// in the span; the first illegal serial, out-of-bounds read, or body that
2618 /// overruns the span ends the prefix. Returns a [`CellFragment`] **only** when
2619 /// the salvaged prefix contains at least one distinctive cell (TEXT ≥ 4 bytes
2620 /// of valid UTF-8, or REAL) — the §3.1 emission gate — otherwise `None`.
2621 fn salvage_fragment(
2622 &self,
2623 page: &[u8],
2624 cell_start: usize,
2625 span_end: usize,
2626 ) -> Option<CellFragment> {
2627 let surviving_count = self.column_count - self.known_lead_serials.len();
2628 let tail_start = cell_start.checked_add(self.surviving_serials_off)?;
2629
2630 // Read as many legal surviving serials as decode in-bounds within the span.
2631 // The template's leading serials are always legal (they came from a live
2632 // cell), so the full serial array is `known_lead ++ legal_surviving`.
2633 let mut serials = self.known_lead_serials.clone();
2634 let mut pos = tail_start;
2635 for _ in 0..surviving_count {
2636 let Ok((s, used)) = read_varint(page, pos) else {
2637 break; // cov:unreachable: the surviving serials sit near the cell start, inside the freeblock/gap span the inner walker already bounds to the page; this read mirrors reconstruct_one's bounds guard so a truncated tail ends the prefix rather than panicking
2638 };
2639 if serial_body_len(s).is_none() {
2640 break; // cov:unreachable: serial_body_len is None only for a negative serial, which read_varint yields only from a crafted 9-byte varint; kept as a defence-in-depth guard so a malformed surviving tail ends the prefix rather than mis-decoding
2641 }
2642 let Some(next) = pos.checked_add(used) else {
2643 break; // cov:unreachable: usize add of an in-page varint width
2644 };
2645 if next > span_end {
2646 break; // serial tail overran the span
2647 }
2648 serials.push(s);
2649 pos = next;
2650 }
2651
2652 // Decode column bodies left-to-right, keeping each whose body ends within
2653 // the span. The body begins right after the surviving serial tail.
2654 let body_start = pos;
2655 let mut surviving: Vec<(usize, Value)> = Vec::new();
2656 let mut bpos = body_start;
2657 for (idx, &s) in serials.iter().enumerate() {
2658 let Some(blen) = serial_body_len(s) else {
2659 break; // cov:unreachable: only legal serials were pushed above
2660 };
2661 let Some(body_end) = bpos.checked_add(blen) else {
2662 break; // cov:unreachable: usize add of an in-page body length
2663 };
2664 if body_end > span_end {
2665 break; // this column's body overruns the span — prefix ends here
2666 }
2667 let Some(body) = page.get(bpos..body_end) else {
2668 break; // cov:unreachable: body_end <= span_end <= page.len()
2669 };
2670 let Ok((val, _)) = decode_value(body, 0, s, self.text_encoding) else {
2671 break; // cov:unreachable: serial_body_len-legal serials decode in-bounds
2672 };
2673 surviving.push((idx, val));
2674 bpos = body_end;
2675 }
2676
2677 // Emission gate: at least one distinctive cell (TEXT >= 4 UTF-8 bytes, or
2678 // REAL). A lone integer/NULL/blob prefix is coincidence-prone — no fragment.
2679 if !surviving.iter().any(|(_, v)| is_distinctive(v)) {
2680 return None;
2681 }
2682 let last_body_end = bpos;
2683 Some(CellFragment {
2684 offset: cell_start,
2685 byte_len: last_body_end.saturating_sub(cell_start),
2686 missing: self.column_count - surviving.len(),
2687 surviving,
2688 confidence: FRAGMENT_CONFIDENCE,
2689 })
2690 }
2691
2692 /// Rebuild the single record whose clobbered cell begins at `cell_start`,
2693 /// bounded by the enclosing span end `span_end`: read the surviving serial
2694 /// tail, prepend the template's leading serials, decode the body, and validate
2695 /// the whole record fits within `[cell_start, span_end)`. Returns the carved
2696 /// cell and the record's end offset (the next coalesced cell's start), or
2697 /// `None` on any out-of-bounds or implausible parse.
2698 fn reconstruct_one(
2699 &self,
2700 page: &[u8],
2701 cell_start: usize,
2702 span_end: usize,
2703 ) -> Option<(CarvedCell, usize)> {
2704 let surviving_count = self.column_count - self.known_lead_serials.len();
2705 let tail_start = cell_start.checked_add(self.surviving_serials_off)?;
2706
2707 // Read the surviving serial tail from the freeblock.
2708 let mut serials = self.known_lead_serials.clone();
2709 let mut pos = tail_start;
2710 for _ in 0..surviving_count {
2711 let (s, used) = read_varint(page, pos).ok()?;
2712 // A serial type must be legal; reject the candidate otherwise.
2713 serial_body_len(s)?;
2714 serials.push(s);
2715 pos = pos.checked_add(used)?;
2716 if pos > span_end {
2717 return None;
2718 }
2719 }
2720
2721 // The body begins right after the surviving serial tail. Compute its
2722 // length from the full (template + surviving) serial array.
2723 let mut body_len = 0usize;
2724 for &s in &serials {
2725 body_len = body_len.checked_add(serial_body_len(s)?)?;
2726 }
2727 let body_start = pos;
2728 let record_end = body_start.checked_add(body_len)?;
2729 // The reconstructed record MUST fit within the enclosing span — the core
2730 // precision check that rejects coincidental/garbage reconstructions.
2731 if record_end > span_end {
2732 return None;
2733 }
2734
2735 // Synthesize a record payload (header + body) for the shared decoder so
2736 // values are decoded with the same storage-class fidelity as live rows.
2737 // The rowid is destroyed; pass 0 so a serial-0 column reads as NULL rather
2738 // than a fabricated rowid.
2739 let body = page.get(body_start..record_end)?;
2740 let values = decode_synthetic_record(&serials, body, self.text_encoding)?;
2741 if values.len() != self.column_count {
2742 return None; // cov:unreachable: one value per serial by construction
2743 }
2744
2745 Some((
2746 CarvedCell {
2747 offset: cell_start,
2748 byte_len: record_end - cell_start,
2749 rowid: 0, // destroyed by freeblock conversion — surfaced as unknown
2750 values,
2751 confidence: FREEBLOCK_RECONSTRUCT_CONFIDENCE,
2752 },
2753 record_end,
2754 ))
2755 }
2756
2757 /// Reconstruct a freeblock-clobbered **spilled** cell at `cell_start` (task
2758 /// #73, design §2.2). A spilled cell always carries a multi-byte
2759 /// `payload_len` varint, so the 4-byte freeblock clobber destroys the
2760 /// `payload_len` + `rowid` varints and the record's `header_len` varint —
2761 /// **but not the serial-type array**, which survives intact immediately after
2762 /// the clobber. We therefore read the full serial array directly from
2763 /// `cell_start + CLOBBER` (using the template only for the column count),
2764 /// re-derive `header_len` and `P = header_len + Σ serial_body_len`, and — when
2765 /// `P > usable - 35` — resolve the spill: `local_payload_len(P, usable)` bytes
2766 /// of payload sit locally (the destroyed header counted within them), the
2767 /// 4-byte first-overflow pointer follows, and the chain is resolved through
2768 /// freelist leaves. Returns `(cell, chain)` with `rowid = 0`, or `None`.
2769 ///
2770 /// UNPROVEN-BY-CORPUS (Codex ruling #5): synthetic-fixture validation only.
2771 /// No real Nemetz cell is both freeblock-clobbered and spilled.
2772 fn reconstruct_spilled(
2773 &self,
2774 db: &Database,
2775 page: &[u8],
2776 cell_start: usize,
2777 usable: usize,
2778 freed_leaves: &std::collections::BTreeSet<u32>,
2779 ) -> Option<(CarvedCell, Vec<u32>)> {
2780 // The freeblock header clobbers exactly 4 bytes. For a spilled cell those
2781 // 4 bytes are payload_len(>=2) + rowid(>=1) + header_len(>=1) varints, so
2782 // the serial array begins right after the clobber.
2783 const CLOBBER: usize = 4;
2784 let serials_start = cell_start.checked_add(CLOBBER)?;
2785 let mut serials = Vec::with_capacity(self.column_count);
2786 let mut pos = serials_start;
2787 for _ in 0..self.column_count {
2788 let (s, used) = read_varint(page, pos).ok()?;
2789 serial_body_len(s)?;
2790 serials.push(s);
2791 pos = pos.checked_add(used)?;
2792 }
2793
2794 // Re-derive the record header bytes that were destroyed: header_len is a
2795 // varint counting itself plus the serial array.
2796 let mut serial_bytes_len = 0usize;
2797 for &s in &serials {
2798 serial_bytes_len += varint_len(s);
2799 }
2800 let mut header_len = serial_bytes_len + 1;
2801 while varint_len(header_len as i64) + serial_bytes_len != header_len {
2802 header_len += 1;
2803 }
2804 // The clobber removed `header_len`'s own varint plus the prefix; verify the
2805 // surviving serial array aligns with the reconstructed header (the bytes
2806 // from serials_start to `pos` are the serial array, length serial_bytes_len).
2807 if pos.checked_sub(serials_start)? != serial_bytes_len {
2808 return None; // cov:unreachable: read_varint widths sum to serial_bytes_len
2809 }
2810 let mut body_len = 0usize;
2811 for &s in &serials {
2812 body_len = body_len.checked_add(serial_body_len(s)?)?;
2813 }
2814 let payload_len = header_len.checked_add(body_len)?;
2815 // Only the spilled class — an in-page payload is the existing template path.
2816 if payload_len <= usable.checked_sub(35)? {
2817 return None;
2818 }
2819 let local_len = local_payload_len(payload_len, usable);
2820
2821 // The body starts right after the surviving serial array. The local payload
2822 // spans `local_len` bytes of (header ++ body); the destroyed header is
2823 // `header_len` of those, so `local_len - header_len` body bytes are present
2824 // locally before the 4-byte first-overflow pointer.
2825 let body_start = pos;
2826 let local_body = local_len.checked_sub(header_len)?;
2827 let local_body_end = body_start.checked_add(local_body)?;
2828 let ptr_off = local_body_end;
2829 let ptr_slice = page.get(ptr_off..ptr_off + 4)?;
2830 let first_overflow =
2831 u32::from_be_bytes([ptr_slice[0], ptr_slice[1], ptr_slice[2], ptr_slice[3]]);
2832 let local_body_bytes = page.get(body_start..local_body_end)?;
2833
2834 let remaining = payload_len - local_len;
2835 let (chain_content, chain) = db
2836 .read_freed_overflow_chain(first_overflow, remaining, usable, freed_leaves)
2837 .ok()?;
2838
2839 // Assemble the full payload: reconstructed header ++ local body ++ chain.
2840 let mut header = enc_varint_into(header_len);
2841 for &s in &serials {
2842 header.extend(enc_varint_into(usize::try_from(s).ok()?));
2843 }
2844 if header.len() != header_len {
2845 return None; // cov:unreachable: header_len was solved to this width
2846 }
2847 let mut payload = Vec::with_capacity(payload_len);
2848 payload.extend_from_slice(&header);
2849 payload.extend_from_slice(local_body_bytes);
2850 payload.extend_from_slice(&chain_content);
2851 if payload.len() != payload_len {
2852 return None; // cov:unreachable: local_body + chain == body_len by construction
2853 }
2854
2855 let values = decode_record(&payload, self.column_count, 0, db.header.text_encoding).ok()?;
2856 if values.len() != self.column_count {
2857 return None; // cov:unreachable: one value per serial
2858 }
2859 let any_replacement = values.iter().any(|v| match v {
2860 Value::Text(t) => t.contains('\u{FFFD}'),
2861 _ => false,
2862 });
2863 if any_replacement {
2864 return None;
2865 }
2866 if !values.iter().any(is_distinctive) {
2867 return None;
2868 }
2869
2870 Some((
2871 CarvedCell {
2872 offset: cell_start,
2873 byte_len: ptr_off + 4 - cell_start,
2874 rowid: 0,
2875 values,
2876 confidence: FREEBLOCK_RECONSTRUCT_CONFIDENCE * OVERFLOW_CHAIN_CONFIDENCE_FACTOR,
2877 },
2878 chain,
2879 ))
2880 }
2881}
2882
2883/// Decode a record body given an explicit serial-type array (the freeblock
2884/// reconstructor supplies the array; the on-disk `header_len` + leading serials
2885/// were destroyed). Mirrors [`decode_record`]'s body pass. Returns `None` on any
2886/// out-of-bounds read so a malformed reconstruction is rejected, never panics.
2887fn decode_synthetic_record(serials: &[i64], body: &[u8], enc: TextEncoding) -> Option<Vec<Value>> {
2888 let mut values = Vec::with_capacity(serials.len());
2889 let mut bpos = 0usize;
2890 for &serial in serials {
2891 let (val, size) = decode_value(body, bpos, serial, enc).ok()?;
2892 values.push(val);
2893 bpos = bpos.checked_add(size)?;
2894 }
2895 Some(values)
2896}
2897
2898/// Attempt to recognize a table-leaf cell at `off` in `buf` as a record.
2899///
2900/// `expected_columns` is `Some(n)` to require exactly `n` columns (fixed-schema
2901/// carving), or `None` to **infer** the column count from the record's own
2902/// serial-type array (dropped-table / schema-gone carving). Returns a
2903/// [`CarvedCell`] only when the bytes are self-consistently record-shaped;
2904/// otherwise `None`. Never panics — every access is bounds-checked.
2905fn try_carve_cell_at(
2906 buf: &[u8],
2907 off: usize,
2908 expected_columns: Option<usize>,
2909 enc: TextEncoding,
2910) -> Option<CarvedCell> {
2911 // Cell prefix: payload_len varint, rowid varint.
2912 let (payload_len, n1) = read_varint(buf, off).ok()?;
2913 let payload_len = usize::try_from(payload_len).ok()?;
2914 if payload_len == 0 {
2915 return None;
2916 }
2917 let (rowid, n2) = read_varint(buf, off + n1).ok()?;
2918 // A negative rowid is legal but vanishingly rare for browser tables; treat a
2919 // non-positive rowid as a non-match to suppress coincidental hits.
2920 if rowid <= 0 {
2921 return None;
2922 }
2923 let payload_start = off + n1 + n2;
2924 let payload = buf.get(payload_start..payload_start + payload_len)?;
2925
2926 // Record header: header_len varint, then one serial type per column.
2927 let (header_len, hn) = read_varint(payload, 0).ok()?;
2928 let header_len = usize::try_from(header_len).ok()?;
2929 if header_len > payload.len() || header_len < hn {
2930 return None;
2931 }
2932 let cap = expected_columns.unwrap_or(0);
2933 let mut serials = Vec::with_capacity(cap);
2934 let mut hpos = hn;
2935 while hpos < header_len {
2936 let (s, used) = read_varint(payload, hpos).ok()?;
2937 serials.push(s);
2938 hpos += used;
2939 }
2940 // The header must consume cleanly, and match the expected column count when
2941 // one was given. When inferring, require a minimum plausible column count to
2942 // suppress coincidental 1-column matches.
2943 if hpos != header_len {
2944 return None;
2945 }
2946 match expected_columns {
2947 Some(n) if serials.len() != n => return None,
2948 None if serials.len() < MIN_INFERRED_COLUMNS => return None,
2949 _ => {}
2950 }
2951 let column_count = serials.len();
2952
2953 // Body length implied by the serial types must equal payload_len - header_len
2954 // — a strong self-consistency check that rejects coincidental matches.
2955 let mut body_len = 0usize;
2956 for &s in &serials {
2957 body_len += serial_body_len(s)?;
2958 }
2959 if header_len + body_len != payload_len {
2960 return None;
2961 }
2962
2963 // Decode the record (reusing the live decoder for storage-class fidelity).
2964 let values = decode_record(payload, column_count, rowid, enc).ok()?;
2965 if values.len() != column_count {
2966 return None; // cov:unreachable: decode_record yields one value per serial
2967 }
2968
2969 // Confidence: a fully self-consistent record already passed strong checks;
2970 // raise confidence when at least one column is a non-empty, valid-UTF-8 TEXT
2971 // (record-shaped *and* human-meaningful), which coincidental byte runs rarely
2972 // satisfy.
2973 let has_real_text = values.iter().any(|v| match v {
2974 Value::Text(t) => !t.is_empty() && !t.contains('\u{FFFD}'),
2975 _ => false,
2976 });
2977 let confidence = if has_real_text { 0.9 } else { 0.6 };
2978
2979 Some(CarvedCell {
2980 offset: off,
2981 byte_len: (payload_start + payload_len) - off,
2982 rowid,
2983 values,
2984 confidence,
2985 })
2986}
2987
2988/// Recognize a freed **spilled** table-leaf cell at `off` whose payload exceeds
2989/// the in-page threshold (`usable - 35`) and therefore continues on an
2990/// overflow-page chain (task #73). The sibling of [`try_carve_cell_at`] for the
2991/// overflow class: the two partition the candidate space by the spec spill
2992/// threshold, so a cell is recognized by exactly one of them.
2993///
2994/// `expected_columns` is `Some(n)` to require exactly `n` columns, or `None` to
2995/// infer the count (≥ [`MIN_INFERRED_COLUMNS`]). Returns a [`SpilledCell`]
2996/// (recognition only — the chain is resolved later) when the local prefix is
2997/// self-consistent: header fits in the local payload, the serial array consumes
2998/// the header cleanly, `header_len + Σ serial_body_len == P` (length closure
2999/// over the *declared* P), and the local payload plus its 4-byte overflow
3000/// pointer are in-bounds. Never panics — every access is bounds-checked.
3001fn try_carve_spilled_cell_at(
3002 buf: &[u8],
3003 off: usize,
3004 usable: usize,
3005 expected_columns: Option<usize>,
3006) -> Option<SpilledCell> {
3007 let (payload_len, n1) = read_varint(buf, off).ok()?;
3008 let payload_len = usize::try_from(payload_len).ok()?;
3009 // Only the overflow class — in-page payloads belong to `try_carve_cell_at`.
3010 if payload_len <= usable.checked_sub(35)? {
3011 return None;
3012 }
3013 let (rowid, n2) = read_varint(buf, off + n1).ok()?;
3014 if rowid <= 0 {
3015 return None;
3016 }
3017 let payload_start = off + n1 + n2;
3018 let local_len = local_payload_len(payload_len, usable);
3019 // The local payload prefix plus the 4-byte first-overflow pointer must be in
3020 // bounds of the scanned slice.
3021 let prefix = buf.get(payload_start..payload_start + local_len + 4)?;
3022
3023 // The record header must fit entirely within the local prefix — otherwise the
3024 // serial array is not addressable locally and we abstain rather than guess.
3025 let (header_len, hn) = read_varint(prefix, 0).ok()?;
3026 let header_len = usize::try_from(header_len).ok()?;
3027 if header_len > local_len || header_len < hn {
3028 return None;
3029 }
3030 let mut serials = Vec::new();
3031 let mut hpos = hn;
3032 while hpos < header_len {
3033 let (s, used) = read_varint(prefix, hpos).ok()?;
3034 serials.push(s);
3035 hpos += used;
3036 }
3037 if hpos != header_len {
3038 return None;
3039 }
3040 match expected_columns {
3041 Some(n) if serials.len() != n => return None,
3042 None if serials.len() < MIN_INFERRED_COLUMNS => return None,
3043 _ => {}
3044 }
3045
3046 // Length closure over the DECLARED payload: header + body must equal P.
3047 let mut body_len = 0usize;
3048 for &s in &serials {
3049 body_len += serial_body_len(s)?;
3050 }
3051 if header_len + body_len != payload_len {
3052 return None;
3053 }
3054
3055 let first_overflow = be_u32(prefix, local_len);
3056 Some(SpilledCell {
3057 offset: off,
3058 byte_len: n1 + n2 + local_len + 4,
3059 payload_len,
3060 rowid,
3061 serials,
3062 local_len,
3063 local_payload_off: payload_start,
3064 first_overflow,
3065 })
3066}
3067
3068/// Salvage the columns of a recognized [`SpilledCell`] whose bodies lie wholly
3069/// within the local payload (task #73, Codex ruling #4): the chain-resident
3070/// columns are dropped (the chain that would supply them failed), and the
3071/// surviving local columns become a [`CellFragment`]. Returns `None` unless the
3072/// salvaged prefix carries ≥ 1 distinctive cell (the §3.1 emission gate). The
3073/// returned fragment's `offset` is region-local; the caller translates it.
3074fn salvage_local_prefix(region: &[u8], sc: &SpilledCell, enc: TextEncoding) -> Option<CellFragment> {
3075 // The body begins right after the local header; decode each column while its
3076 // body ends within the local payload bytes (`local_payload_off + local_len`).
3077 let local_end = sc.local_payload_off.checked_add(sc.local_len)?;
3078 // Recompute the record header length to find where the body starts.
3079 let (header_len, _hn) = read_varint(region, sc.local_payload_off).ok()?;
3080 let header_len = usize::try_from(header_len).ok()?;
3081 let mut bpos = sc.local_payload_off.checked_add(header_len)?;
3082
3083 let mut surviving: Vec<(usize, Value)> = Vec::new();
3084 for (idx, &serial) in sc.serials.iter().enumerate() {
3085 let Some(blen) = serial_body_len(serial) else {
3086 break; // cov:unreachable: recognizer accepted only legal serials
3087 };
3088 let Some(body_end) = bpos.checked_add(blen) else {
3089 break; // cov:unreachable: usize add of an in-page body length
3090 };
3091 if body_end > local_end {
3092 break; // this column's body spills into the chain — local prefix ends
3093 }
3094 let Some(body) = region.get(bpos..body_end) else {
3095 break; // cov:unreachable: body_end <= local_end <= region.len()
3096 };
3097 // Column 0 of a rowid-alias table reads as the rowid when serial 0; here a
3098 // spilled cell's id column is a stored integer, so decode it directly.
3099 let Ok((val, _)) = decode_value(body, 0, serial, enc) else {
3100 break; // cov:unreachable: legal serials decode in-bounds
3101 };
3102 surviving.push((idx, val));
3103 bpos = body_end;
3104 }
3105
3106 if !surviving.iter().any(|(_, v)| is_distinctive(v)) {
3107 return None;
3108 }
3109 Some(CellFragment {
3110 offset: sc.offset,
3111 byte_len: bpos.saturating_sub(sc.local_payload_off),
3112 missing: sc.serials.len() - surviving.len(),
3113 surviving,
3114 confidence: FRAGMENT_CONFIDENCE,
3115 })
3116}
3117
3118/// Parse + validate the 100-byte file header.
3119fn parse_header(bytes: &[u8]) -> Result<Header, Error> {
3120 let head = bytes.get(..SQLITE_HEADER_SIZE).ok_or(Error::TooShort)?;
3121 if !head.starts_with(SQLITE_MAGIC) {
3122 return Err(Error::BadMagic);
3123 }
3124 let raw = be_u16(head, SQLITE_PAGE_SIZE_OFFSET);
3125 let page_size: u32 = if raw == 1 { 65536 } else { u32::from(raw) };
3126 let valid = (512..=65536).contains(&page_size) && page_size.is_power_of_two();
3127 if !valid {
3128 return Err(Error::BadPageSize(page_size));
3129 }
3130 let reserved = *head.get(RESERVED_SPACE_OFFSET).ok_or(Error::TooShort)?;
3131 // Header byte 56 (BE u32): 1/0 = UTF-8, 2 = UTF-16LE, 3 = UTF-16BE
3132 // (file-format §1.3.1). Tolerant: an unexpected value degrades to UTF-8
3133 // rather than rejecting the database.
3134 let text_encoding = match be_u32(head, TEXT_ENCODING_OFFSET) {
3135 2 => TextEncoding::Utf16Le,
3136 3 => TextEncoding::Utf16Be,
3137 _ => TextEncoding::Utf8,
3138 };
3139 Ok(Header {
3140 page_size,
3141 reserved,
3142 text_encoding,
3143 })
3144}
3145
3146/// Decode a record (payload) into values. Serial type 0 on the first column of
3147/// a rowid table is the `INTEGER PRIMARY KEY` alias → the cell's rowid.
3148fn decode_record(
3149 payload: &[u8],
3150 _column_count: usize,
3151 rowid: i64,
3152 enc: TextEncoding,
3153) -> Result<Vec<Value>, Error> {
3154 let (header_len, n) = read_varint(payload, 0)?;
3155 let header_len = header_len as usize;
3156 if header_len > payload.len() {
3157 return Err(Error::TruncatedCell);
3158 }
3159 // Pass 1: read serial types from the record header.
3160 let mut serials = Vec::new();
3161 let mut hpos = n;
3162 while hpos < header_len {
3163 let (s, used) = read_varint(payload, hpos)?;
3164 serials.push(s);
3165 hpos += used;
3166 }
3167 // Pass 2: read the body, one value per serial type.
3168 let mut values = Vec::with_capacity(serials.len());
3169 let mut bpos = header_len;
3170 for (idx, &serial) in serials.iter().enumerate() {
3171 let (val, size) = decode_value(payload, bpos, serial, enc)?;
3172 let val = if idx == 0 && serial == 0 {
3173 // INTEGER PRIMARY KEY alias: NULL in column 0 reads the rowid.
3174 Value::Integer(rowid)
3175 } else {
3176 val
3177 };
3178 values.push(val);
3179 bpos += size;
3180 }
3181 Ok(values)
3182}
3183
3184/// Decode a single value of the given serial type at `off`. Returns the value
3185/// and the number of body bytes it consumed.
3186fn decode_value(
3187 buf: &[u8],
3188 off: usize,
3189 serial: i64,
3190 enc: TextEncoding,
3191) -> Result<(Value, usize), Error> {
3192 Ok(match serial {
3193 // 0 = NULL; 10/11 are reserved for internal use and surfaced as NULL.
3194 0 | 10 | 11 => (Value::Null, 0),
3195 1 => (
3196 Value::Integer(i64::from(read_be_u64(buf, off, 1)? as i8)),
3197 1,
3198 ),
3199 2 => (
3200 Value::Integer(i64::from(read_be_u64(buf, off, 2)? as i16)),
3201 2,
3202 ),
3203 3 => (Value::Integer(sign_extend(read_be_u64(buf, off, 3)?, 3)), 3),
3204 4 => (
3205 Value::Integer(i64::from(read_be_u64(buf, off, 4)? as i32)),
3206 4,
3207 ),
3208 5 => (Value::Integer(sign_extend(read_be_u64(buf, off, 6)?, 6)), 6),
3209 6 => (Value::Integer(read_be_u64(buf, off, 8)? as i64), 8),
3210 7 => {
3211 let bits = read_be_u64(buf, off, 8)?;
3212 (Value::Real(f64::from_bits(bits)), 8)
3213 }
3214 8 => (Value::Integer(0), 0),
3215 9 => (Value::Integer(1), 0),
3216 n if n >= 12 && n % 2 == 0 => {
3217 let len = ((n - 12) / 2) as usize;
3218 let bytes = buf.get(off..off + len).ok_or(Error::TruncatedCell)?;
3219 (Value::Blob(bytes.to_vec()), len)
3220 }
3221 n => {
3222 // odd, >= 13: text, decoded per the database's text encoding
3223 // (UTF-8 / UTF-16LE / UTF-16BE). Lossy so a corrupt byte can't panic.
3224 let len = ((n - 13) / 2) as usize;
3225 let bytes = buf.get(off..off + len).ok_or(Error::TruncatedCell)?;
3226 (Value::Text(enc.decode(bytes)), len)
3227 }
3228 })
3229}
3230
3231/// Read `width` (1..=8) big-endian bytes into a raw u64 (no sign extension).
3232fn read_be_u64(buf: &[u8], off: usize, width: usize) -> Result<u64, Error> {
3233 let bytes = buf.get(off..off + width).ok_or(Error::TruncatedCell)?;
3234 let mut acc: u64 = 0;
3235 for &b in bytes {
3236 acc = (acc << 8) | u64::from(b);
3237 }
3238 Ok(acc)
3239}
3240
3241/// Sign-extend a `width`-byte (3 or 6) value held in the low bits of `raw`.
3242fn sign_extend(raw: u64, width: usize) -> i64 {
3243 let bits = width * 8;
3244 let shift = 64 - bits;
3245 ((raw as i64) << shift) >> shift
3246}
3247
3248/// Read a `SQLite` varint (1..=9 bytes) at `off`. Returns value + bytes consumed.
3249fn read_varint(buf: &[u8], off: usize) -> Result<(i64, usize), Error> {
3250 let mut result: u64 = 0;
3251 for i in 0..8 {
3252 let b = *buf.get(off + i).ok_or(Error::TruncatedCell)?;
3253 result = (result << 7) | u64::from(b & 0x7f);
3254 if b & 0x80 == 0 {
3255 return Ok((result as i64, i + 1));
3256 }
3257 }
3258 // 9th byte contributes all 8 bits.
3259 let b = *buf.get(off + 8).ok_or(Error::TruncatedCell)?;
3260 result = (result << 8) | u64::from(b);
3261 Ok((result as i64, 9))
3262}
3263
3264/// Bounds-checked big-endian u16; out-of-range yields 0 (never panics).
3265fn be_u16(buf: &[u8], off: usize) -> u16 {
3266 let mut b = [0u8; 2];
3267 if let Some(s) = buf.get(off..off + 2) {
3268 b.copy_from_slice(s);
3269 }
3270 u16::from_be_bytes(b)
3271}
3272
3273/// Byte width of the minimal `SQLite` varint encoding of a non-negative `value`
3274/// (task #73, used to re-derive a clobbered record's `header_len`). Mirrors the
3275/// 7-bit big-endian grouping of [`enc_varint_into`]; a value needing more than 8
3276/// groups uses the 9-byte form. Negative inputs (illegal serial types) are
3277/// treated as a single byte and rejected upstream by `serial_body_len`.
3278fn varint_len(value: i64) -> usize {
3279 if value < 0 {
3280 return 1; // cov:unreachable: callers pass only non-negative serials/lengths
3281 }
3282 enc_varint_into(value as usize).len()
3283}
3284
3285/// Minimal `SQLite` varint encoding of a non-negative `value` (task #73). 7-bit
3286/// big-endian groups, high bit set on every group but the last (file-format §2).
3287fn enc_varint_into(value: usize) -> Vec<u8> {
3288 if value == 0 {
3289 return vec![0];
3290 }
3291 let mut groups = Vec::new();
3292 let mut n = value as u64;
3293 while n > 0 {
3294 groups.push((n & 0x7f) as u8);
3295 n >>= 7;
3296 }
3297 groups.reverse();
3298 let last = groups.len() - 1;
3299 for (i, g) in groups.iter_mut().enumerate() {
3300 if i != last {
3301 *g |= 0x80;
3302 }
3303 }
3304 groups
3305}
3306
3307/// Bounds-checked big-endian u32; out-of-range yields 0 (never panics).
3308fn be_u32(buf: &[u8], off: usize) -> u32 {
3309 let mut b = [0u8; 4];
3310 if let Some(s) = buf.get(off..off + 4) {
3311 b.copy_from_slice(s);
3312 }
3313 u32::from_be_bytes(b)
3314}
3315
3316#[cfg(test)]
3317mod tests {
3318 use super::*;
3319
3320 #[test]
3321 fn varint_single_byte() {
3322 assert_eq!(read_varint(&[0x05], 0).unwrap(), (5, 1));
3323 }
3324
3325 #[test]
3326 fn varint_two_bytes() {
3327 // 0x81 0x00 => (1<<7) = 128
3328 assert_eq!(read_varint(&[0x81, 0x00], 0).unwrap(), (128, 2));
3329 }
3330
3331 #[test]
3332 fn varint_truncated_is_err() {
3333 assert_eq!(read_varint(&[0x81], 0), Err(Error::TruncatedCell));
3334 }
3335
3336 #[test]
3337 fn sign_extend_three_byte_negative() {
3338 // 0xFFFFFF as 3-byte => -1
3339 assert_eq!(sign_extend(0x00FF_FFFF, 3), -1);
3340 }
3341
3342 #[test]
3343 fn decode_value_text_and_blob() {
3344 let (v, n) = decode_value(b"hi", 0, 17, TextEncoding::Utf8).unwrap(); // 17 => text len (17-13)/2 =2
3345 assert_eq!(v, Value::Text("hi".into()));
3346 assert_eq!(n, 2);
3347 let (v, n) = decode_value(&[0xAA, 0xBB], 0, 16, TextEncoding::Utf8).unwrap(); // 16 => blob len 2
3348 assert_eq!(v, Value::Blob(vec![0xAA, 0xBB]));
3349 assert_eq!(n, 2);
3350 }
3351
3352 #[test]
3353 fn decode_value_int_literals() {
3354 assert_eq!(decode_value(&[], 0, 8, TextEncoding::Utf8).unwrap(), (Value::Integer(0), 0));
3355 assert_eq!(decode_value(&[], 0, 9, TextEncoding::Utf8).unwrap(), (Value::Integer(1), 0));
3356 }
3357
3358 #[test]
3359 fn bad_magic_rejected() {
3360 let mut b = vec![0u8; 100];
3361 b[..16].copy_from_slice(b"NOT SQLITE 3\0\0\0\0");
3362 assert_eq!(parse_header(&b), Err(Error::BadMagic));
3363 }
3364
3365 #[test]
3366 fn too_short_rejected() {
3367 assert_eq!(parse_header(&[0u8; 10]), Err(Error::TooShort));
3368 }
3369
3370 /// The deleted-record carving fixture (see `docs/corpus-catalog.md`).
3371 const DELETED_DB: &[u8] = include_bytes!("../../tests/data/deleted_places.db");
3372 /// A clean DB with one live `moz_places` table and no deletions.
3373 const CLEAN_DB: &[u8] = include_bytes!("../../tests/data/places.db");
3374
3375 #[test]
3376 fn free_regions_is_complement_of_live_extents() {
3377 // Live cells [10,20) and [30,40) within content area [5, 50).
3378 let live = [(10, 20), (30, 40)];
3379 let regions = free_regions(&live, 5, 50);
3380 assert_eq!(regions, vec![(5, 10), (20, 30), (40, 50)]);
3381 // No live cells -> the whole span is free.
3382 assert_eq!(free_regions(&[], 5, 50), vec![(5, 50)]);
3383 // Live cell covering the whole span -> no free region.
3384 assert!(free_regions(&[(0, 100)], 5, 50).is_empty());
3385 }
3386
3387 #[test]
3388 fn live_cell_len_reads_on_page_footprint() {
3389 // Cell: payload_len=3 (varint 0x03), rowid=1 (varint 0x01), 3 payload bytes.
3390 let buf = [0x03, 0x01, 0xAA, 0xBB, 0xCC];
3391 let usable = 4096;
3392 assert_eq!(live_cell_len(&buf, 0, usable), Some(1 + 1 + 3));
3393 // Truncated prefix -> None, never panics.
3394 assert_eq!(live_cell_len(&[0x81], 0, usable), None);
3395 }
3396
3397 #[test]
3398 fn carve_free_regions_recovers_in_page_remnant() {
3399 let db = Database::open(DELETED_DB.to_vec()).unwrap();
3400 // Page 8 is an allocated leaf (live ids 181..=200) whose free gap holds
3401 // deleted-row residue including rowid 237.
3402 let page = db.raw_page(8).unwrap();
3403 let carved = db.carve_free_regions(page, 6);
3404 assert!(carved.iter().any(|c| c.rowid == 237));
3405 // 0-FP: never a live (id<=200) rowid.
3406 assert!(carved.iter().all(|c| c.rowid > 200));
3407 // A non-leaf page yields nothing.
3408 assert!(db.carve_free_regions(&[0x05u8; 4096], 6).is_empty());
3409 // An empty / too-short slice yields nothing (no panic).
3410 assert!(db.carve_free_regions(&[], 6).is_empty());
3411 }
3412
3413 #[test]
3414 fn carve_leaf_cells_reads_allocated_cells_and_rejects_non_leaf() {
3415 let db = Database::open(DELETED_DB.to_vec()).unwrap();
3416 // Page 8 is an allocated table-leaf (live ids 181..=200); carve_leaf_cells
3417 // decodes every cell the page records as allocated, so the live ids appear
3418 // (unlike carve_free_regions, which excludes them).
3419 let page = db.raw_page(8).unwrap();
3420 let cells = db.carve_leaf_cells(page);
3421 assert!(
3422 cells.iter().any(|c| c.rowid == 181),
3423 "must read the allocated cells of the leaf"
3424 );
3425 // Page 1 is passed whole (starts with the file magic) → header read at 100.
3426 let _ = db.carve_leaf_cells(db.raw_page(1).unwrap());
3427 // A non-leaf page (interior 0x05) and an empty/too-short slice yield nothing
3428 // (no panic) — the same defensive arms carve_free_regions guards.
3429 assert!(db.carve_leaf_cells(&[0x05u8; 4096]).is_empty());
3430 assert!(db.carve_leaf_cells(&[]).is_empty());
3431 }
3432
3433 #[test]
3434 fn carve_free_regions_handles_page_one_and_inferred() {
3435 let db = Database::open(DELETED_DB.to_vec()).unwrap();
3436 // Page 1 is passed whole (starts with the file magic) -> the b-tree header
3437 // is read at offset 100, exercising the page-1 branch.
3438 let page1 = db.raw_page(1).unwrap();
3439 let _ = db.carve_free_regions(page1, 6);
3440 // With column_count_hint = 0, the inferred path runs over the free regions.
3441 let page8 = db.raw_page(8).unwrap();
3442 let inferred = db.carve_free_regions(page8, 0);
3443 assert!(inferred.iter().any(|c| c.rowid == 237));
3444 }
3445
3446 #[test]
3447 fn live_cell_len_accounts_for_overflow_pointer() {
3448 let usable = 4096usize;
3449 // Non-spilling cell: payload_len small -> footprint = prefix + payload.
3450 // varint 0x03 (payload_len=3), 0x01 (rowid=1), 3 payload bytes.
3451 assert_eq!(live_cell_len(&[0x03, 0x01, 0, 0, 0], 0, usable), Some(5));
3452
3453 // Spilling cell: a payload_len far above the local threshold takes the
3454 // overflow branch -> footprint = prefix + local + 4 (overflow pointer).
3455 // Encode payload_len = 5000 as a 2-byte varint (0xA7 0x08), rowid = 1.
3456 let mut buf = vec![0xA7, 0x08, 0x01];
3457 buf.extend(std::iter::repeat_n(0u8, 5000));
3458 let total = 5000usize;
3459 let local = local_payload_len(total, usable);
3460 assert!(local < total, "this payload must spill");
3461 assert_eq!(live_cell_len(&buf, 0, usable), Some(2 + 1 + local + 4));
3462 }
3463
3464 #[test]
3465 fn carve_cells_inferred_matches_fixed_count() {
3466 let db = Database::open(DELETED_DB.to_vec()).unwrap();
3467 // A freed leaf page body carves the same rows whether the column count is
3468 // fixed at 6 or inferred.
3469 let page = db.raw_page(10).unwrap();
3470 let fixed = db.carve_cells(page, 6);
3471 let inferred = db.carve_cells_inferred(page);
3472 assert!(!fixed.is_empty());
3473 let fixed_ids: std::collections::BTreeSet<i64> = fixed.iter().map(|c| c.rowid).collect();
3474 let inf_ids: std::collections::BTreeSet<i64> = inferred.iter().map(|c| c.rowid).collect();
3475 assert!(fixed_ids.is_subset(&inf_ids));
3476 }
3477
3478 #[test]
3479 fn has_user_table_distinguishes_live_and_dropped() {
3480 let live = Database::open(CLEAN_DB.to_vec()).unwrap();
3481 assert!(live.has_user_table());
3482 let with_deletions = Database::open(DELETED_DB.to_vec()).unwrap();
3483 assert!(with_deletions.has_user_table());
3484 }
3485
3486 #[test]
3487 fn live_rowids_collects_live_rows_only() {
3488 let db = Database::open(CLEAN_DB.to_vec()).unwrap();
3489 let ids = db.live_rowids();
3490 // places.db has 5 live rows, rowids 1..=5.
3491 assert_eq!(ids.len(), 5);
3492 assert!(ids.contains(&1) && ids.contains(&5));
3493
3494 // On the deletions fixture, live rowids are 1..=200; none of the deleted
3495 // 201..=400 appear.
3496 let del = Database::open(DELETED_DB.to_vec()).unwrap();
3497 let live = del.live_rowids();
3498 assert!(live.contains(&1) && live.contains(&200));
3499 assert!(!live.contains(&201) && !live.contains(&400));
3500 }
3501
3502 #[test]
3503 fn live_rows_decodes_current_values() {
3504 let db = Database::open(CLEAN_DB.to_vec()).unwrap();
3505 let rows = db.live_rows();
3506 // places.db has 5 live rows keyed by rowid 1..=5, each decoded to values.
3507 assert_eq!(rows.len(), 5);
3508 // Row 1's url column (index 1) is the rust-lang URL (cross-checks that
3509 // values are decoded, not just rowids collected).
3510 let r1 = rows.get(&1).expect("row 1 present");
3511 assert!(
3512 matches!(r1.get(1), Some(Value::Text(t)) if t.contains("rust-lang")),
3513 "row 1 values must be decoded: {r1:?}"
3514 );
3515 // The value map and the rowid set agree on which rows are live.
3516 let ids = db.live_rowids();
3517 assert_eq!(
3518 rows.keys().copied().collect::<Vec<_>>(),
3519 ids.into_iter().collect::<Vec<_>>()
3520 );
3521
3522 // The deletions fixture's table b-tree has an INTERIOR root page (0x05),
3523 // so this exercises the interior-walk branch of collect_rows and confirms
3524 // values are decoded for all 200 live rows.
3525 let del = Database::open(DELETED_DB.to_vec()).unwrap();
3526 let del_rows = del.live_rows();
3527 assert_eq!(del_rows.len(), 200);
3528 let r1 = del_rows.get(&1).expect("live row 1");
3529 assert!(
3530 matches!(r1.get(1), Some(Value::Text(t)) if t.contains("site-1.example")),
3531 "interior-walked live row 1 must decode its url: {r1:?}"
3532 );
3533 }
3534
3535 /// Real-corpus freeblock reconstruction: 0C-01 page 2 has six freeblock-head
3536 /// cells the forward parser cannot reach; reconstruction recovers them
3537 /// (including the destroyed-rowid `id` column) from the surviving serial tail.
3538 const NEMETZ_0C_01: &[u8] = include_bytes!("../../tests/data/nemetz/0C/0C-01.db");
3539
3540 #[test]
3541 fn reconstruct_freeblock_records_recovers_clobbered_rows() {
3542 let db = Database::open(NEMETZ_0C_01.to_vec()).unwrap();
3543 let page = db.raw_page(2).unwrap();
3544 let recovered = db.reconstruct_freeblock_records(page);
3545 // Row 20005 is a freeblock-head cell only reconstruction can recover.
3546 assert!(recovered.iter().any(|c| c.values
3547 == vec![
3548 Value::Integer(20005),
3549 Value::Integer(3_780_322_152),
3550 Value::Integer(3_909_007_646),
3551 Value::Integer(120_462_986),
3552 Value::Integer(1_290_558_629),
3553 ]));
3554 assert!(recovered
3555 .iter()
3556 .all(|c| c.rowid == 0 && c.confidence <= 0.5));
3557 }
3558
3559 /// Real-corpus span-walking reconstruction (task #66): 0D-07 page 3 coalesces
3560 /// three deleted cells into a single freeblock `[0xf79,0xfe0)` —
3561 /// `Luca|Schumacher` (the head), then `Kurt|Schubert`, then `Georg|Schulz`,
3562 /// each prefixed by a stale `00 00 00 NN` freeblock header that clobbers its
3563 /// leading four bytes. A single-shot head reconstruction recovers only the
3564 /// first; walking the template across the whole span recovers all three.
3565 const NEMETZ_0D_07: &[u8] = include_bytes!("../../tests/data/nemetz/0D/0D-07.db");
3566
3567 #[test]
3568 fn reconstruct_freeblock_records_walks_coalesced_cells() {
3569 let db = Database::open(NEMETZ_0D_07.to_vec()).unwrap();
3570 let page = db.raw_page(3).unwrap();
3571 let recovered = db.reconstruct_freeblock_records(page);
3572 let has = |name: &str, surname: &str| {
3573 recovered.iter().any(|c| {
3574 matches!(c.values.get(1), Some(Value::Text(t)) if t == name)
3575 && matches!(c.values.get(2), Some(Value::Text(t)) if t == surname)
3576 })
3577 };
3578 // The span-head cell a single-shot reconstruction already reached.
3579 assert!(has("Luca", "Schumacher"), "head cell must be recovered");
3580 // The two trailing cells deeper inside the same freeblock — only a
3581 // span-walk reaches these.
3582 assert!(
3583 has("Kurt", "Schubert"),
3584 "second coalesced cell must be recovered"
3585 );
3586 assert!(
3587 has("Georg", "Schulz"),
3588 "third coalesced cell must be recovered"
3589 );
3590 // Every reconstruction carries a destroyed rowid and low confidence.
3591 assert!(recovered
3592 .iter()
3593 .all(|c| c.rowid == 0 && c.confidence <= 0.5));
3594 }
3595
3596 /// Helper: a real opened DB to call the page-slice methods against crafted
3597 /// page byte slices (the methods take `page_bytes` explicitly).
3598 fn opened() -> Database {
3599 Database::open(NEMETZ_0C_01.to_vec()).unwrap()
3600 }
3601
3602 /// A leaf page advertising a freeblock chain but whose cells do not parse
3603 /// yields no template, so reconstruction returns empty (covers the
3604 /// `freeblock_template` rejection arms and the final `None`).
3605 #[test]
3606 fn reconstruct_freeblock_records_without_template_is_empty() {
3607 let db = opened();
3608 let mut page = vec![0u8; 256];
3609 page[0] = 0x0d; // table-leaf
3610 page[1] = 0x00;
3611 page[2] = 0x40; // first freeblock at offset 64
3612 page[3] = 0x00;
3613 page[4] = 0x01; // cell_count = 1
3614 // The single cell pointer (offset 8) points at 0 -> cell_off == 0 -> skipped,
3615 // so no template can be derived.
3616 page[8] = 0x00;
3617 page[9] = 0x00;
3618 // A freeblock at 64: next=0, size=8 (in-bounds), but no template anyway.
3619 page[64] = 0x00;
3620 page[65] = 0x00;
3621 page[66] = 0x00;
3622 page[67] = 0x08;
3623 assert!(db.reconstruct_freeblock_records(&page).is_empty());
3624 }
3625
3626 /// A cyclic freeblock `next` chain terminates (covers the cycle-break guard)
3627 /// and a freeblock whose size runs past the page is skipped — all without a
3628 /// panic.
3629 #[test]
3630 fn reconstruct_freeblock_records_breaks_cyclic_chain() {
3631 let db = opened();
3632 // Build a page WITH a usable template by copying 0C-01 page 2's header +
3633 // first live cell, then point the freeblock chain at itself.
3634 let src = db.raw_page(2).unwrap().to_vec();
3635 let mut page = src.clone();
3636 // Repoint first-freeblock to a self-cycle at offset 100: next -> 100.
3637 page[1] = 0x00;
3638 page[2] = 100;
3639 page[100] = 0x00;
3640 page[101] = 100; // next = 100 (points to itself)
3641 page[102] = 0xff;
3642 page[103] = 0xff; // size huge -> runs past page -> skipped
3643 // Must not panic and must terminate.
3644 let _ = db.reconstruct_freeblock_records(&page);
3645 }
3646
3647 // ---- Tier-2 fragment salvage (task #72) --------------------------------
3648
3649 #[test]
3650 fn is_distinctive_classifies_every_storage_class() {
3651 // TEXT >= 4 UTF-8 bytes and REAL are distinctive; everything else is not.
3652 assert!(is_distinctive(&Value::Text("Anja".into())));
3653 assert!(is_distinctive(&Value::Text("\u{00e4}\u{00f6}".into()))); // 4 UTF-8 bytes
3654 assert!(is_distinctive(&Value::Real(3.5)));
3655 assert!(!is_distinctive(&Value::Text("abc".into()))); // 3 bytes
3656 assert!(!is_distinctive(&Value::Text(String::new())));
3657 assert!(!is_distinctive(&Value::Text("ab\u{fffd}x".into()))); // replacement char
3658 assert!(!is_distinctive(&Value::Integer(20004)));
3659 assert!(!is_distinctive(&Value::Null));
3660 assert!(!is_distinctive(&Value::Blob(vec![1, 2, 3, 4, 5])));
3661 }
3662
3663 /// Build a synthetic 256-byte table-leaf (0x0d) page for the fragment tests.
3664 ///
3665 /// Schema implied by the template live cell: 3 columns
3666 /// `(c0: 1-byte int, c1: TEXT-4, c2: TEXT-4)` → serials `[1, 21, 21]`,
3667 /// `header_len = 4`. The live cell (the freeblock template source) is placed
3668 /// at `live_off`. A single freeblock spanning `[fb, fb + fb_size)` holds the
3669 /// freed-cell payload `freed`, whose leading 4 bytes are the stale freeblock
3670 /// header (`next`, `size`) — exactly what freeblock conversion clobbers.
3671 fn synth_frag_page(live_off: usize, fb: usize, fb_size: usize, freed: &[u8]) -> Vec<u8> {
3672 let mut page = vec![0u8; 256];
3673 page[0] = 0x0d; // table-leaf
3674 page[1] = (fb >> 8) as u8;
3675 page[2] = (fb & 0xff) as u8;
3676 page[3] = 0x00;
3677 page[4] = 0x01; // cell_count = 1
3678 page[5] = (live_off >> 8) as u8;
3679 page[6] = (live_off & 0xff) as u8; // cellContentArea = live_off
3680 page[8] = (live_off >> 8) as u8;
3681 page[9] = (live_off & 0xff) as u8; // cell pointer -> live_off
3682
3683 // Live template cell: payload_len=13, rowid=5, header_len=4, serials
3684 // [int1, text4, text4], body 1+4+4.
3685 let live = [
3686 13u8, 5u8, 0x04, 0x01, 0x15, 0x15, 0x09, b'L', b'i', b'v', b'e', b'R', b'o', b'w', b'!',
3687 ];
3688 page[live_off..live_off + live.len()].copy_from_slice(&live);
3689
3690 // Lay the freed-cell bytes first, then stamp the stale freeblock header
3691 // (next=0, size=fb_size) over its first 4 bytes — exactly what freeblock
3692 // conversion does (the header clobbers the freed cell's leading 4 bytes).
3693 page[fb..fb + freed.len()].copy_from_slice(freed);
3694 page[fb] = 0x00;
3695 page[fb + 1] = 0x00;
3696 page[fb + 2] = (fb_size >> 8) as u8;
3697 page[fb + 3] = (fb_size & 0xff) as u8;
3698 page
3699 }
3700
3701 /// (a) Truncated tail: the freed cell's body overruns the freeblock span, so
3702 /// full reconstruction fails — salvage emits the decodable column prefix
3703 /// (incl. a distinctive TEXT cell) with correct `missing`/confidence, while
3704 /// `reconstruct_freeblock_records` recovers nothing from that anchor.
3705 #[test]
3706 fn fragment_salvage_truncated_tail() {
3707 let db = opened();
3708 // surviving serials [21,21] at fb+4,fb+5; body c0(1)+c1(4)+c2(4) at fb+6.
3709 // A full record needs fb+15. Span size 12 ends at fb+12: c0,c1 fit, c2
3710 // overruns → salvage keeps [c0, c1].
3711 let mut freed = vec![0u8; 16];
3712 freed[4] = 0x15;
3713 freed[5] = 0x15;
3714 freed[6] = 0x07;
3715 freed[7..11].copy_from_slice(b"Anja");
3716 freed[11..15].copy_from_slice(b"Frnk");
3717 let page = synth_frag_page(96, 64, 12, &freed);
3718
3719 let frags = db.reconstruct_freeblock_fragments(&page);
3720 assert_eq!(frags.len(), 1, "exactly one fragment salvaged");
3721 let f = &frags[0];
3722 assert_eq!(f.offset, 64);
3723 assert_eq!(
3724 f.surviving,
3725 vec![(0, Value::Integer(7)), (1, Value::Text("Anja".into()))]
3726 );
3727 assert_eq!(f.missing, 1, "c2 did not decode");
3728 assert!((f.confidence - 0.2).abs() < f32::EPSILON);
3729 let cells = db.reconstruct_freeblock_records(&page);
3730 assert!(cells.iter().all(|c| c.offset != 64));
3731 }
3732
3733 /// (b) A surviving column whose body cannot fit ends the prefix early —
3734 /// salvage keeps the columns decoded before the failure.
3735 #[test]
3736 fn fragment_salvage_partial_tail() {
3737 let db = opened();
3738 let mut freed = vec![0u8; 16];
3739 freed[4] = 0x15;
3740 freed[5] = 0x15;
3741 freed[6] = 0x07;
3742 freed[7..11].copy_from_slice(b"Lena");
3743 let page = synth_frag_page(96, 64, 11, &freed); // c1 fits, c2 overruns
3744 let frags = db.reconstruct_freeblock_fragments(&page);
3745 assert_eq!(frags.len(), 1);
3746 assert_eq!(
3747 frags[0].surviving,
3748 vec![(0, Value::Integer(7)), (1, Value::Text("Lena".into()))]
3749 );
3750 }
3751
3752 /// (c) A fully reconstructable freeblock yields NO fragment (mutual exclusion).
3753 #[test]
3754 fn fragment_salvage_full_record_yields_no_fragment() {
3755 let db = opened();
3756 let mut freed = vec![0u8; 16];
3757 freed[4] = 0x15;
3758 freed[5] = 0x15;
3759 freed[6] = 0x07;
3760 freed[7..11].copy_from_slice(b"Whol");
3761 freed[11..15].copy_from_slice(b"Erow");
3762 let page = synth_frag_page(96, 64, 15, &freed);
3763 let cells = db.reconstruct_freeblock_records(&page);
3764 assert!(
3765 cells.iter().any(|c| c.offset == 64),
3766 "full record recovered"
3767 );
3768 assert!(
3769 db.reconstruct_freeblock_fragments(&page).is_empty(),
3770 "no fragment when the full record is recoverable"
3771 );
3772 }
3773
3774 /// (d) Salvage yielding only non-distinctive (INTEGER) cells emits NO fragment.
3775 #[test]
3776 fn fragment_salvage_integer_only_is_rejected() {
3777 let db = opened();
3778 let mut freed = vec![0u8; 12];
3779 freed[4] = 0x01; // surviving 1-byte int
3780 freed[5] = 0x01; // surviving 1-byte int
3781 freed[6] = 0x07;
3782 freed[7] = 0x08;
3783 let page = synth_frag_page(96, 64, 8, &freed); // c2 overruns; only ints decode
3784 assert!(
3785 db.reconstruct_freeblock_fragments(&page).is_empty(),
3786 "integer-only prefix is not distinctive — no fragment"
3787 );
3788 }
3789
3790 /// (e) Fragment salvage does NOT extend the span walk: a failed head stops
3791 /// the walk, emitting at most one fragment, never sliding forward.
3792 #[test]
3793 fn fragment_salvage_does_not_extend_walk() {
3794 let db = opened();
3795 let mut freed = vec![0u8; 16];
3796 freed[4] = 0x15;
3797 freed[5] = 0x15;
3798 freed[6] = 0x07;
3799 freed[7..11].copy_from_slice(b"Stop");
3800 freed[11..15].copy_from_slice(b"Here");
3801 let page = synth_frag_page(96, 64, 12, &freed);
3802 assert_eq!(db.reconstruct_freeblock_fragments(&page).len(), 1);
3803 }
3804
3805 /// (Step 2) Real-artifact validation: 0D-01 page 2 salvages the genuine
3806 /// partial deleted row for id 20004 — `Text("Anja")`/`Text("Frank")` survive
3807 /// in a freeblock whose full-row reconstruction fails. Full pass unchanged.
3808 const NEMETZ_0D_01: &[u8] = include_bytes!("../../tests/data/nemetz/0D/0D-01.db");
3809
3810 #[test]
3811 fn fragment_salvage_recovers_anja_on_0d01() {
3812 let db = Database::open(NEMETZ_0D_01.to_vec()).unwrap();
3813 let page = db.raw_page(2).unwrap();
3814 let frags = db.reconstruct_freeblock_fragments(page);
3815 let f = frags
3816 .iter()
3817 .find(|f| {
3818 f.surviving
3819 .iter()
3820 .any(|(_, v)| matches!(v, Value::Text(t) if t == "Anja"))
3821 })
3822 .expect("0D-01 page 2 must salvage the Anja fragment");
3823 assert!(f
3824 .surviving
3825 .iter()
3826 .any(|(_, v)| matches!(v, Value::Text(t) if t == "Frank")));
3827 assert!((f.confidence - 0.2).abs() < f32::EPSILON);
3828 let cells = db.reconstruct_freeblock_records(page);
3829 assert!(cells.iter().all(|c| !c
3830 .values
3831 .iter()
3832 .any(|v| matches!(v, Value::Text(t) if t == "Anja"))));
3833 }
3834
3835 // ---- task #73: chain-aware overflow recovery — spilled-cell recognition ----
3836
3837 /// Encode a SQLite varint (minimal big-endian 7-bit groups).
3838 fn enc_varint(mut n: u64) -> Vec<u8> {
3839 if n == 0 {
3840 return vec![0];
3841 }
3842 let mut groups = Vec::new();
3843 while n > 0 {
3844 groups.push((n & 0x7f) as u8);
3845 n >>= 7;
3846 }
3847 groups.reverse();
3848 let last = groups.len() - 1;
3849 for (i, g) in groups.iter_mut().enumerate() {
3850 if i != last {
3851 *g |= 0x80;
3852 }
3853 }
3854 groups
3855 }
3856
3857 /// Build the **local prefix** bytes of a freed spilled table-leaf cell:
3858 /// `payload_len varint, rowid varint, record header, local payload bytes,
3859 /// 4-byte big-endian first-overflow pointer`. Returns `(bytes, P, local,
3860 /// serials)`. The record is `(id INTEGER, name TEXT, code TEXT)` with `code`
3861 /// large enough to force a spill past `usable - 35`.
3862 fn synth_spilled_prefix(
3863 rowid: i64,
3864 id: i64,
3865 name: &str,
3866 code_len: usize,
3867 usable: usize,
3868 first_overflow: u32,
3869 ) -> (Vec<u8>, usize, usize, Vec<i64>) {
3870 let id_serial = 1i64; // 1-byte integer
3871 let name_serial = 13 + 2 * name.len() as i64; // TEXT
3872 let code_serial = 13 + 2 * code_len as i64; // TEXT
3873 let serials = vec![id_serial, name_serial, code_serial];
3874 let mut serial_bytes = Vec::new();
3875 for &s in &serials {
3876 serial_bytes.extend(enc_varint(s as u64));
3877 }
3878 // header_len varint counts itself — solve the fixed point.
3879 let mut header_len = serial_bytes.len() + 1;
3880 while enc_varint(header_len as u64).len() + serial_bytes.len() != header_len {
3881 header_len += 1;
3882 }
3883 let mut header = enc_varint(header_len as u64);
3884 header.extend(&serial_bytes);
3885 let body_len = 1 + name.len() + code_len;
3886 let payload_len = header.len() + body_len;
3887 let local = local_payload_len(payload_len, usable);
3888
3889 // Full payload = header ++ id-body ++ name-body ++ code-body.
3890 let mut payload = header.clone();
3891 payload.push(id as u8); // 1-byte id
3892 payload.extend(name.as_bytes());
3893 payload.extend(std::iter::repeat_n(b'C', code_len));
3894 assert_eq!(payload.len(), payload_len);
3895
3896 // Cell = prefix varints ++ local payload prefix ++ 4-byte overflow ptr.
3897 let mut cell = enc_varint(payload_len as u64);
3898 cell.extend(enc_varint(rowid as u64));
3899 cell.extend(&payload[..local]);
3900 cell.extend(first_overflow.to_be_bytes());
3901 (cell, payload_len, local, serials)
3902 }
3903
3904 #[test]
3905 fn spilled_recognizer_reads_intact_prefix() {
3906 let usable = 4096usize;
3907 let (cell, p, local, serials) = synth_spilled_prefix(20012, 42, "Ella", 4200, usable, 13);
3908 assert!(p > usable - 35, "this record must spill");
3909 // Place the cell inside a larger scanned slice at a nonzero offset.
3910 let off = 50usize;
3911 let mut buf = vec![0u8; off];
3912 buf.extend(&cell);
3913 let sc = try_carve_spilled_cell_at(&buf, off, usable, Some(3))
3914 .expect("must recognize the intact-prefix spilled cell");
3915 assert_eq!(sc.payload_len, p);
3916 assert_eq!(sc.local_len, local);
3917 assert_eq!(sc.rowid, 20012);
3918 assert_eq!(sc.first_overflow, 13);
3919 assert_eq!(sc.serials, serials);
3920 assert_eq!(sc.offset, off);
3921 }
3922
3923 #[test]
3924 fn spilled_recognizer_abstains_for_in_page_payload() {
3925 let usable = 4096usize;
3926 // A small (in-page) payload: the existing carve path owns it.
3927 // header (3 serials) + body for a tiny code -> P <= usable-35.
3928 let (cell, p, _local, _s) = synth_spilled_prefix(7, 1, "Bob", 10, usable, 9);
3929 assert!(p <= usable - 35, "this record must NOT spill");
3930 assert!(try_carve_spilled_cell_at(&cell, 0, usable, Some(3)).is_none());
3931 }
3932
3933 #[test]
3934 fn spilled_recognizer_abstains_on_truncated_pointer() {
3935 let usable = 4096usize;
3936 let (cell, _p, _local, _s) = synth_spilled_prefix(20012, 42, "Ella", 4200, usable, 13);
3937 // Drop the final 2 bytes so the 4-byte overflow pointer is out of bounds.
3938 let truncated = &cell[..cell.len() - 2];
3939 assert!(try_carve_spilled_cell_at(truncated, 0, usable, Some(3)).is_none());
3940 }
3941
3942 #[test]
3943 fn spilled_recognizer_abstains_on_column_mismatch() {
3944 let usable = 4096usize;
3945 let (cell, _p, _local, _s) = synth_spilled_prefix(20012, 42, "Ella", 4200, usable, 13);
3946 // Expect 5 columns but the record has 3.
3947 assert!(try_carve_spilled_cell_at(&cell, 0, usable, Some(5)).is_none());
3948 // Inferred (None) still recognizes it.
3949 assert!(try_carve_spilled_cell_at(&cell, 0, usable, None).is_some());
3950 }
3951
3952 #[test]
3953 fn spilled_recognizer_abstains_on_nonpositive_rowid() {
3954 let usable = 4096usize;
3955 let (cell, _p, _local, _s) = synth_spilled_prefix(0, 42, "Ella", 4000, usable, 13);
3956 assert!(try_carve_spilled_cell_at(&cell, 0, usable, Some(3)).is_none());
3957 }
3958
3959 // ---- task #73: freed overflow-chain walk + freelist leaf/trunk split ----
3960
3961 /// Build a minimal multi-page `SQLite` DB image with `page_count` pages of
3962 /// `page_size` bytes. Page 1 carries a valid 100-byte header (so
3963 /// `Database::open` succeeds) with the given freelist trunk pointer and count
3964 /// at offsets 32/36. All pages are zero-filled; the caller writes overflow /
3965 /// trunk content afterwards. Returns the byte vector.
3966 fn synth_db(page_size: usize, page_count: usize, trunk: u32, fl_count: u32) -> Vec<u8> {
3967 let mut b = vec![0u8; page_size * page_count];
3968 b[..16].copy_from_slice(SQLITE_MAGIC);
3969 b[16..18].copy_from_slice(&(page_size as u16).to_be_bytes());
3970 b[18] = 1; // file format write version
3971 b[19] = 1; // file format read version
3972 b[20] = 0; // reserved space
3973 b[21] = 64;
3974 b[22] = 32;
3975 b[23] = 32;
3976 b[32..36].copy_from_slice(&trunk.to_be_bytes());
3977 b[36..40].copy_from_slice(&fl_count.to_be_bytes());
3978 // A minimal table-leaf page-1 body (type 0x0d, 0 cells) so header parsing
3979 // and page-count helpers behave.
3980 b[100] = 0x0d;
3981 b
3982 }
3983
3984 /// Write a freelist trunk page at `page` listing `leaves` and chaining to
3985 /// `next_trunk` (0 = end).
3986 fn write_trunk(b: &mut [u8], page_size: usize, page: u32, next_trunk: u32, leaves: &[u32]) {
3987 let base = (page as usize - 1) * page_size;
3988 b[base..base + 4].copy_from_slice(&next_trunk.to_be_bytes());
3989 b[base + 4..base + 8].copy_from_slice(&(leaves.len() as u32).to_be_bytes());
3990 for (i, &lf) in leaves.iter().enumerate() {
3991 b[base + 8 + i * 4..base + 12 + i * 4].copy_from_slice(&lf.to_be_bytes());
3992 }
3993 }
3994
3995 /// Write an overflow page at `page`: 4-byte big-endian `next` then `content`.
3996 fn write_overflow(b: &mut [u8], page_size: usize, page: u32, next: u32, content: &[u8]) {
3997 let base = (page as usize - 1) * page_size;
3998 b[base..base + 4].copy_from_slice(&next.to_be_bytes());
3999 b[base + 4..base + 4 + content.len()].copy_from_slice(content);
4000 }
4001
4002 #[test]
4003 fn freelist_split_separates_leaves_and_trunks() {
4004 let ps = 512usize;
4005 // Pages: 1 header, 2 trunk, leaves 3,4,5.
4006 let mut b = synth_db(ps, 6, 2, 4);
4007 write_trunk(&mut b, ps, 2, 0, &[3, 4, 5]);
4008 let db = Database::open(b).unwrap();
4009 let (leaves, trunks) = db.freelist_pages_split().unwrap();
4010 assert_eq!(leaves, [3u32, 4, 5].into_iter().collect());
4011 assert_eq!(trunks, [2u32].into_iter().collect());
4012 // The legacy combined accessor still returns leaves ++ trunk.
4013 let all: std::collections::BTreeSet<u32> =
4014 db.freelist_pages().unwrap().into_iter().collect();
4015 assert_eq!(all, [2u32, 3, 4, 5].into_iter().collect());
4016 }
4017
4018 #[test]
4019 fn freed_chain_assembles_single_leaf_page() {
4020 let ps = 512usize;
4021 let usable = ps; // reserved 0
4022 let mut b = synth_db(ps, 6, 2, 4);
4023 write_trunk(&mut b, ps, 2, 0, &[3, 4, 5]);
4024 // Chain content on leaf page 3: a single page holds `remaining` bytes.
4025 let remaining = 100usize;
4026 let content: Vec<u8> = (0..remaining).map(|i| (i % 251) as u8).collect();
4027 write_overflow(&mut b, ps, 3, 0, &content);
4028 let db = Database::open(b).unwrap();
4029 let (leaves, _trunks) = db.freelist_pages_split().unwrap();
4030 let (bytes, chain) = db
4031 .read_freed_overflow_chain(3, remaining, usable, &leaves)
4032 .expect("intact single-leaf chain must assemble");
4033 assert_eq!(bytes, content);
4034 assert_eq!(chain, vec![3]);
4035 }
4036
4037 #[test]
4038 fn freed_chain_assembles_multi_leaf_pages() {
4039 let ps = 512usize;
4040 let usable = ps;
4041 let per_page = usable - 4;
4042 let mut b = synth_db(ps, 8, 2, 5);
4043 write_trunk(&mut b, ps, 2, 0, &[3, 4, 5, 6]);
4044 // 2-page chain: page 3 -> page 4. remaining spans into page 4.
4045 let remaining = per_page + 50;
4046 let content: Vec<u8> = (0..remaining).map(|i| (i % 251) as u8).collect();
4047 write_overflow(&mut b, ps, 3, 4, &content[..per_page]);
4048 write_overflow(&mut b, ps, 4, 0, &content[per_page..]);
4049 let db = Database::open(b).unwrap();
4050 let (leaves, _t) = db.freelist_pages_split().unwrap();
4051 let (bytes, chain) = db
4052 .read_freed_overflow_chain(3, remaining, usable, &leaves)
4053 .expect("intact 2-leaf chain must assemble");
4054 assert_eq!(bytes, content);
4055 assert_eq!(chain, vec![3, 4]);
4056 }
4057
4058 #[test]
4059 fn freed_chain_breaks_on_non_freelist_page() {
4060 let ps = 512usize;
4061 let usable = ps;
4062 let mut b = synth_db(ps, 6, 2, 2);
4063 write_trunk(&mut b, ps, 2, 0, &[3]); // only page 3 is a leaf
4064 let content = vec![7u8; 100];
4065 // The pointer targets page 4, which is NOT on the freelist.
4066 write_overflow(&mut b, ps, 4, 0, &content);
4067 let db = Database::open(b).unwrap();
4068 let (leaves, _t) = db.freelist_pages_split().unwrap();
4069 assert!(db
4070 .read_freed_overflow_chain(4, 100, usable, &leaves)
4071 .is_err());
4072 }
4073
4074 #[test]
4075 fn freed_chain_breaks_on_trunk_page() {
4076 let ps = 512usize;
4077 let usable = ps;
4078 let mut b = synth_db(ps, 6, 2, 2);
4079 write_trunk(&mut b, ps, 2, 0, &[3]);
4080 let db = Database::open(b).unwrap();
4081 let (leaves, _t) = db.freelist_pages_split().unwrap();
4082 // Page 2 is the trunk — a chain page that is a trunk must break.
4083 assert!(db
4084 .read_freed_overflow_chain(2, 100, usable, &leaves)
4085 .is_err());
4086 }
4087
4088 #[test]
4089 fn freed_chain_breaks_on_cycle() {
4090 let ps = 512usize;
4091 let usable = ps;
4092 let per_page = usable - 4;
4093 let mut b = synth_db(ps, 6, 2, 3);
4094 write_trunk(&mut b, ps, 2, 0, &[3, 4]);
4095 // 3 -> 4 -> 3 cycle; remaining never satisfied.
4096 write_overflow(&mut b, ps, 3, 4, &vec![1u8; per_page]);
4097 write_overflow(&mut b, ps, 4, 3, &vec![2u8; per_page]);
4098 let db = Database::open(b).unwrap();
4099 let (leaves, _t) = db.freelist_pages_split().unwrap();
4100 assert!(db
4101 .read_freed_overflow_chain(3, per_page * 10, usable, &leaves)
4102 .is_err());
4103 }
4104
4105 #[test]
4106 fn freed_chain_breaks_on_premature_zero_pointer() {
4107 let ps = 512usize;
4108 let usable = ps;
4109 let per_page = usable - 4;
4110 let mut b = synth_db(ps, 6, 2, 2);
4111 write_trunk(&mut b, ps, 2, 0, &[3]);
4112 // Page 3 ends the chain (next=0) but `remaining` still wants more bytes.
4113 write_overflow(&mut b, ps, 3, 0, &vec![9u8; per_page]);
4114 let db = Database::open(b).unwrap();
4115 let (leaves, _t) = db.freelist_pages_split().unwrap();
4116 assert!(db
4117 .read_freed_overflow_chain(3, per_page + 10, usable, &leaves)
4118 .is_err());
4119 }
4120
4121 #[test]
4122 fn freed_chain_breaks_on_capacity_overflow() {
4123 let ps = 512usize;
4124 let usable = ps;
4125 let mut b = synth_db(ps, 6, 2, 2);
4126 write_trunk(&mut b, ps, 2, 0, &[3]);
4127 write_overflow(&mut b, ps, 3, 0, &vec![1u8; usable - 4]);
4128 let db = Database::open(b).unwrap();
4129 let (leaves, _t) = db.freelist_pages_split().unwrap();
4130 // remaining far exceeds what one leaf page can deliver — rejected upfront,
4131 // never allocating an attacker-declared payload.
4132 let absurd = (usable - 4) * leaves.len() + 1;
4133 assert!(db
4134 .read_freed_overflow_chain(3, absurd, usable, &leaves)
4135 .is_err());
4136 }
4137
4138 // ---- task #73 step 5: freeblock-clobbered spilled cell (SYNTHETIC ONLY) ----
4139 // Codex ruling #5: there is NO corpus instance for a freeblock-clobbered
4140 // *spilled* cell — this path is validated against a synthetic fixture only
4141 // and is marked unproven-by-corpus in the production code + docs.
4142
4143 /// Build a synthetic 4096-byte-page DB with an allocated table-leaf page 2
4144 /// holding (a) a LIVE template cell of the `(id INTEGER 1-byte, name TEXT,
4145 /// code TEXT)` schema and (b) a freeblock-clobbered SPILLED cell whose 4-byte
4146 /// prefix is overwritten by a stale freeblock header, with its overflow chain
4147 /// on a freed leaf page. Returns the bytes. `break_chain` routes the chain
4148 /// pointer at the freelist trunk instead of a leaf to exercise the rejection.
4149 fn synth_clobbered_spill_db(break_chain: bool) -> Vec<u8> {
4150 let ps = 4096usize;
4151 let usable = ps;
4152 // Pages: 1 header, 2 allocated leaf, 3 trunk, 4 leaf (chain), 5 leaf spare.
4153 let mut b = synth_db(ps, 6, 3, 2);
4154 write_trunk(&mut b, ps, 3, 0, &[4, 5]);
4155
4156 // Record geometry: id=7 (1-byte), name="Zoe", code 4200×'C'.
4157 let name = b"Zoe";
4158 let code_len = 4200usize;
4159 let serials: [i64; 3] = [1, 13 + 2 * name.len() as i64, 13 + 2 * code_len as i64];
4160 let mut serial_bytes = Vec::new();
4161 for &s in &serials {
4162 serial_bytes.extend(enc_varint(s as u64));
4163 }
4164 let mut header_len = serial_bytes.len() + 1;
4165 while enc_varint(header_len as u64).len() + serial_bytes.len() != header_len {
4166 header_len += 1;
4167 }
4168 let mut header = enc_varint(header_len as u64);
4169 header.extend(&serial_bytes);
4170 let mut full_payload = header.clone();
4171 full_payload.push(7u8); // id body
4172 full_payload.extend(name);
4173 full_payload.extend(std::iter::repeat_n(b'C', code_len));
4174 let payload_len = full_payload.len();
4175 let local = local_payload_len(payload_len, usable);
4176 let remaining = payload_len - local;
4177
4178 // --- LIVE template cell at offset 200 on page 2 (a small non-spilling row
4179 // of the SAME schema so freeblock_template derives the column layout).
4180 let base2 = ps; // page 2 starts at byte 4096
4181 let tmpl_name = b"Al";
4182 let tmpl_code = b"xy";
4183 let tser: [i64; 3] = [
4184 1,
4185 13 + 2 * tmpl_name.len() as i64,
4186 13 + 2 * tmpl_code.len() as i64,
4187 ];
4188 let mut tsb = Vec::new();
4189 for &s in &tser {
4190 tsb.extend(enc_varint(s as u64));
4191 }
4192 let mut thl = tsb.len() + 1;
4193 while enc_varint(thl as u64).len() + tsb.len() != thl {
4194 thl += 1;
4195 }
4196 let mut tpayload = enc_varint(thl as u64);
4197 tpayload.extend(&tsb);
4198 tpayload.push(1u8);
4199 tpayload.extend(tmpl_name);
4200 tpayload.extend(tmpl_code);
4201 let live_off = 200usize;
4202 let mut live_cell = enc_varint(tpayload.len() as u64);
4203 live_cell.extend(enc_varint(1u64)); // rowid 1
4204 live_cell.extend(&tpayload);
4205 b[base2 + live_off..base2 + live_off + live_cell.len()].copy_from_slice(&live_cell);
4206
4207 // Page-2 leaf header (type 0x0d), 1 live cell, freeblock at 0x100, content
4208 // area covering both the live cell and the clobbered spilled cell.
4209 b[base2] = 0x0d;
4210 // first freeblock pointer (offset 1) -> the clobbered spilled cell at 1000.
4211 b[base2 + 1..base2 + 3].copy_from_slice(&1000u16.to_be_bytes());
4212 // cell count (offset 3) = 1
4213 b[base2 + 3..base2 + 5].copy_from_slice(&1u16.to_be_bytes());
4214 // cell content area start (offset 5) — low so both regions are "content".
4215 b[base2 + 5..base2 + 7].copy_from_slice(&100u16.to_be_bytes());
4216 // cell pointer array (1 entry) at offset 8 -> live cell offset.
4217 b[base2 + 8..base2 + 10].copy_from_slice(&(live_off as u16).to_be_bytes());
4218
4219 // --- Clobbered SPILLED cell at offset 1000 on page 2. Lay down the FULL
4220 // prefix (payload_len varint, rowid varint, header, local payload,
4221 // overflow ptr), then OVERWRITE the first 4 bytes with a stale
4222 // freeblock header (next=0x0000, size) to simulate freeblock clobber.
4223 let spill_off = 1000usize;
4224 let mut spill_cell = enc_varint(payload_len as u64);
4225 spill_cell.extend(enc_varint(1u64)); // rowid (will be clobbered)
4226 let prefix_len = spill_cell.len();
4227 spill_cell.extend(&full_payload[..local]);
4228 let chain_first = if break_chain { 3u32 } else { 4u32 };
4229 spill_cell.extend(chain_first.to_be_bytes());
4230 b[base2 + spill_off..base2 + spill_off + spill_cell.len()].copy_from_slice(&spill_cell);
4231 // Clobber the first 4 bytes with a freeblock header: next=0, size=4.
4232 b[base2 + spill_off] = 0;
4233 b[base2 + spill_off + 1] = 0;
4234 b[base2 + spill_off + 2..base2 + spill_off + 4].copy_from_slice(&4u16.to_be_bytes());
4235
4236 // --- The overflow chain content on freed leaf page 4 (next=0).
4237 write_overflow(&mut b, ps, 4, 0, &full_payload[local..local + remaining]);
4238
4239 let _ = prefix_len;
4240 b
4241 }
4242
4243 #[test]
4244 fn clobbered_spilled_cell_reconstructs_with_unknown_rowid() {
4245 let db = Database::open(synth_clobbered_spill_db(false)).unwrap();
4246 let page2 = db.raw_page(2).unwrap();
4247 let recovered = db.carve_overflow_template_records(page2);
4248 let (cell, chain) = recovered
4249 .iter()
4250 .find(|(c, _)| matches!(c.values.get(1), Some(Value::Text(t)) if t == "Zoe"))
4251 .expect("synthetic clobbered spilled cell must reconstruct");
4252 // rowid destroyed by the freeblock clobber -> surfaced as 0.
4253 assert_eq!(cell.rowid, 0);
4254 // code fully reassembled across the chain.
4255 assert!(matches!(cell.values.get(2), Some(Value::Text(t)) if t.len() == 4200));
4256 assert_eq!(chain, &vec![4u32]);
4257 }
4258
4259 #[test]
4260 fn clobbered_spilled_broken_chain_yields_no_full_row() {
4261 // Chain pointer routed at the freelist TRUNK (page 3) -> rejected.
4262 let db = Database::open(synth_clobbered_spill_db(true)).unwrap();
4263 let page2 = db.raw_page(2).unwrap();
4264 let recovered = db.carve_overflow_template_records(page2);
4265 assert!(recovered
4266 .iter()
4267 .all(|(c, _)| !matches!(c.values.get(1), Some(Value::Text(t)) if t == "Zoe")));
4268 }
4269
4270 #[test]
4271 fn enc_varint_into_round_trips_zero_and_multibyte() {
4272 // Zero -> single 0 byte (the NULL-serial / empty-header path).
4273 assert_eq!(enc_varint_into(0), vec![0]);
4274 assert_eq!(varint_len(0), 1);
4275 // Multi-byte: 8413 -> 2-byte varint; round-trips via read_varint.
4276 let v = enc_varint_into(8413);
4277 assert_eq!(varint_len(8413), v.len());
4278 assert_eq!(read_varint(&v, 0).unwrap(), (8413, v.len()));
4279 // Negative input (illegal serial) treated as 1 byte (defensive).
4280 assert_eq!(varint_len(-1), 1);
4281 }
4282
4283 /// Build a 4096-byte-page DB with an allocated table-leaf page 2 holding an
4284 /// **intact-prefix** spilled cell in its unallocated gap, with the overflow
4285 /// chain on a freed leaf page (page 4). Mirrors the real 0E geometry so
4286 /// `carve_overflow_records` (and its fragment dual) can be unit-covered without
4287 /// the corpus. `break_chain` routes the pointer at the freelist trunk.
4288 fn synth_gap_spill_db(break_chain: bool, code_len: usize, name: &str) -> Vec<u8> {
4289 let ps = 4096usize;
4290 let usable = ps;
4291 let mut b = synth_db(ps, 6, 3, 2);
4292 write_trunk(&mut b, ps, 3, 0, &[4, 5]);
4293 let base2 = ps;
4294
4295 // Record: (id INTEGER 1-byte, name TEXT, code TEXT) spilled.
4296 let serials: [i64; 3] = [1, 13 + 2 * name.len() as i64, 13 + 2 * code_len as i64];
4297 let mut serial_bytes = Vec::new();
4298 for &s in &serials {
4299 serial_bytes.extend(enc_varint(s as u64));
4300 }
4301 let mut header_len = serial_bytes.len() + 1;
4302 while enc_varint(header_len as u64).len() + serial_bytes.len() != header_len {
4303 header_len += 1;
4304 }
4305 let mut payload = enc_varint(header_len as u64);
4306 payload.extend(&serial_bytes);
4307 payload.push(9u8); // id body
4308 payload.extend(name.as_bytes());
4309 payload.extend(std::iter::repeat_n(b'C', code_len));
4310 let payload_len = payload.len();
4311 let local = local_payload_len(payload_len, usable);
4312 let remaining = payload_len - local;
4313
4314 // Spilled cell at gap offset 1500 on page 2 (intact prefix).
4315 let spill_off = 1500usize;
4316 let mut cell = enc_varint(payload_len as u64);
4317 cell.extend(enc_varint(5u64)); // rowid 5
4318 cell.extend(&payload[..local]);
4319 let first = if break_chain { 3u32 } else { 4u32 };
4320 cell.extend(first.to_be_bytes());
4321 b[base2 + spill_off..base2 + spill_off + cell.len()].copy_from_slice(&cell);
4322
4323 // Page-2 leaf header: 0 live cells, content area at 100 so the gap [8,100..]
4324 // is scanned. No live cells keeps free_regions = the whole content area.
4325 b[base2] = 0x0d;
4326 b[base2 + 1] = 0; // first freeblock = 0
4327 b[base2 + 2] = 0;
4328 b[base2 + 3..base2 + 5].copy_from_slice(&0u16.to_be_bytes()); // 0 cells
4329 b[base2 + 5..base2 + 7].copy_from_slice(&8u16.to_be_bytes()); // cca low
4330
4331 // Chain content on freed leaf page 4.
4332 write_overflow(&mut b, ps, 4, 0, &payload[local..local + remaining]);
4333 b
4334 }
4335
4336 #[test]
4337 fn carve_overflow_records_resolves_gap_spill() {
4338 let db = Database::open(synth_gap_spill_db(false, 4200, "Nora")).unwrap();
4339 let page2 = db.raw_page(2).unwrap();
4340 let recovered = db.carve_overflow_records(page2);
4341 let (cell, chain) = recovered
4342 .iter()
4343 .find(|(c, _)| matches!(c.values.get(1), Some(Value::Text(t)) if t == "Nora"))
4344 .expect("gap-resident spilled cell must resolve to a full row");
4345 assert_eq!(cell.rowid, 5);
4346 assert!(matches!(cell.values.get(2), Some(Value::Text(t)) if t.len() == 4200));
4347 assert_eq!(chain, &vec![4u32]);
4348 // Graded below the in-page full-row tier (0.9 * factor).
4349 assert!(cell.confidence < 0.72);
4350 // Non-leaf page yields nothing; empty slice yields nothing.
4351 assert!(db.carve_overflow_records(&[0x05u8; 4096]).is_empty());
4352 assert!(db.carve_overflow_records(&[]).is_empty());
4353 }
4354
4355 #[test]
4356 fn carve_overflow_records_rejects_trunk_chain() {
4357 let db = Database::open(synth_gap_spill_db(true, 4200, "Nora")).unwrap();
4358 let page2 = db.raw_page(2).unwrap();
4359 // Chain routed at the trunk -> no full row.
4360 assert!(db
4361 .carve_overflow_records(page2)
4362 .iter()
4363 .all(|(c, _)| !matches!(c.values.get(1), Some(Value::Text(t)) if t == "Nora")));
4364 }
4365
4366 #[test]
4367 fn stale_leaf_chain_with_invalid_utf8_is_rejected() {
4368 // NEGATIVE test (the stale-leaf residual): a chain page that IS a freelist
4369 // leaf and assembles to the exact declared length, but whose content is
4370 // unrelated bytes (invalid UTF-8 in the TEXT column). The freelist-leaf
4371 // requirement passes; the strict-UTF-8 extra-signal gate rejects it from
4372 // Tier-1. This documents the design's limit (Codex ruling #2): the leaf
4373 // requirement cannot prove the bytes are the record — only the UTF-8 gate
4374 // catches the cases the lossy decoder would otherwise mask.
4375 let ps = 4096usize;
4376 let usable = ps;
4377 let mut b = synth_db(ps, 6, 3, 2);
4378 write_trunk(&mut b, ps, 3, 0, &[4, 5]);
4379 let base2 = ps;
4380 let name = "Stale";
4381 let code_len = 4200usize;
4382 let serials: [i64; 3] = [1, 13 + 2 * name.len() as i64, 13 + 2 * code_len as i64];
4383 let mut serial_bytes = Vec::new();
4384 for &s in &serials {
4385 serial_bytes.extend(enc_varint(s as u64));
4386 }
4387 let mut header_len = serial_bytes.len() + 1;
4388 while enc_varint(header_len as u64).len() + serial_bytes.len() != header_len {
4389 header_len += 1;
4390 }
4391 let mut payload = enc_varint(header_len as u64);
4392 payload.extend(&serial_bytes);
4393 payload.push(9u8);
4394 payload.extend(name.as_bytes());
4395 payload.extend(std::iter::repeat_n(b'C', code_len));
4396 let payload_len = payload.len();
4397 let local = local_payload_len(payload_len, usable);
4398 let remaining = payload_len - local;
4399
4400 let spill_off = 1500usize;
4401 let mut cell = enc_varint(payload_len as u64);
4402 cell.extend(enc_varint(5u64));
4403 cell.extend(&payload[..local]);
4404 cell.extend(4u32.to_be_bytes());
4405 b[base2 + spill_off..base2 + spill_off + cell.len()].copy_from_slice(&cell);
4406 b[base2] = 0x0d;
4407 b[base2 + 3..base2 + 5].copy_from_slice(&0u16.to_be_bytes());
4408 b[base2 + 5..base2 + 7].copy_from_slice(&8u16.to_be_bytes());
4409
4410 // Stale leaf content: invalid UTF-8 (0xff bytes) where the TEXT body lands.
4411 let stale = vec![0xffu8; remaining];
4412 write_overflow(&mut b, ps, 4, 0, &stale);
4413
4414 let db = Database::open(b).unwrap();
4415 let page2 = db.raw_page(2).unwrap();
4416 // Decodes mechanically (the leaf assembles exactly), but the strict-UTF-8
4417 // gate rejects it -> NOT a Tier-1 full row.
4418 assert!(db.carve_overflow_records(page2).is_empty());
4419 }
4420
4421 #[test]
4422 fn carve_overflow_fragments_salvages_broken_gap_spill() {
4423 // Broken chain (trunk) -> the local prefix (id + name) salvages as a fragment.
4424 let db = Database::open(synth_gap_spill_db(true, 4200, "Nora")).unwrap();
4425 let page2 = db.raw_page(2).unwrap();
4426 let frags = db.carve_overflow_fragments(page2);
4427 let f = frags
4428 .iter()
4429 .find(|f| {
4430 f.surviving
4431 .iter()
4432 .any(|(_, v)| matches!(v, Value::Text(t) if t == "Nora"))
4433 })
4434 .expect("broken-chain gap spill must salvage a fragment");
4435 // id (col 0) survives locally too.
4436 assert!(f
4437 .surviving
4438 .iter()
4439 .any(|(i, v)| *i == 0 && matches!(v, Value::Integer(9))));
4440 // An intact chain produces NO fragment (it is a full row instead).
4441 let ok = Database::open(synth_gap_spill_db(false, 4200, "Nora")).unwrap();
4442 let ok_page = ok.raw_page(2).unwrap();
4443 assert!(ok.carve_overflow_fragments(ok_page).iter().all(|f| !f
4444 .surviving
4445 .iter()
4446 .any(|(_, v)| matches!(v, Value::Text(t) if t == "Nora"))));
4447 // Non-leaf / empty inputs yield nothing.
4448 assert!(db.carve_overflow_fragments(&[0x05u8; 4096]).is_empty());
4449 assert!(db.carve_overflow_fragments(&[]).is_empty());
4450 }
4451}