Skip to main content

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