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 and `WITHOUT ROWID` tables.
17//! (UTF-16 text decoding and WAL frame-checksum verification are implemented.)
18
19#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]
20
21pub mod attribution;
22pub mod rebuild;
23pub mod row_history;
24
25use forensicnomicon::sqlite::{
26    SQLITE_FREELIST_TRUNK_OFFSET, SQLITE_HEADER_SIZE, SQLITE_MAGIC, SQLITE_PAGE_SIZE_OFFSET,
27};
28
29/// Byte offset of the 1-byte "reserved space per page" field in the file header
30/// (file-format §1.3.4). forensicnomicon does not yet expose this; WS-E should
31/// promote it into `forensicnomicon::sqlite`.
32const RESERVED_SPACE_OFFSET: usize = 20;
33
34/// Byte offset of the 4-byte big-endian text-encoding field in the file header
35/// (file-format §1.3.1). forensicnomicon does not yet expose this; promote it
36/// into `forensicnomicon::sqlite` alongside [`RESERVED_SPACE_OFFSET`].
37const TEXT_ENCODING_OFFSET: usize = 56;
38
39/// Byte offset of the in-header database size, in pages (file-format §1.3.6).
40/// 4-byte big-endian. Valid only when it equals the change counter at offset 24
41/// (a "size is valid" sentinel); the file-length fallback covers the rest.
42/// forensicnomicon does not yet expose this — promote it in a later pass.
43const DB_SIZE_IN_PAGES_OFFSET: usize = 28;
44
45/// Byte offset of the freelist page **count** in the file header (file-format
46/// §1.3.5). 4-byte big-endian. The trunk pointer lives at
47/// [`SQLITE_FREELIST_TRUNK_OFFSET`] (32); this count is the next field (36).
48const FREELIST_COUNT_OFFSET: usize = 36;
49
50/// Errors that can arise while reading a `SQLite` database, all recoverable —
51/// the reader never panics on malformed input.
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub enum Error {
54    /// File is shorter than the 100-byte header.
55    TooShort,
56    /// First 16 bytes are not the `SQLite format 3\0` magic.
57    BadMagic,
58    /// Page-size field is not a power of two in `[512, 65536]`.
59    BadPageSize(u32),
60    /// A page number referenced by the b-tree is out of range for the file.
61    PageOutOfRange(u32),
62    /// A b-tree page had an unexpected type byte where a table page was required.
63    NotATablePage(u8),
64    /// A cell pointer or payload ran past the end of its page.
65    TruncatedCell,
66    /// The b-tree was deeper / wider than the safety cap allows.
67    TooManyPages,
68    /// The freelist trunk chain cycled or exceeded the file's page count.
69    MalformedFreelist,
70    /// An overflow-page chain cycled or exceeded the file's page count.
71    MalformedOverflow,
72    /// A rollback-journal page size was not a power of two in `[512, 65536]`.
73    /// Carries the offending value (Show-the-unrecognized-value).
74    BadJournalPageSize(u32),
75    /// A rollback journal was applied to a database opened WAL-applied, or whose
76    /// page size disagrees with the journal's. WAL and rollback-journal modes are
77    /// mutually exclusive timelines and must not be overlaid.
78    JournalModeConflict,
79}
80
81/// A freed overflow-page chain could not be followed to a complete, trustworthy
82/// payload (task #73): a chain page that is not a freelist leaf (live / trunk /
83/// unreachable), a cycle, a premature terminator with bytes still owed, an
84/// out-of-range page, or a declared payload exceeding the freelist's capacity.
85/// Carries no detail by design — any break is a uniform "this chain is not
86/// recoverable as a Tier-1 row", and the candidate degrades to a Tier-2 fragment.
87#[derive(Debug, Clone, Copy, PartialEq, Eq)]
88pub struct ChainBreak;
89
90/// A single decoded column value from a table row. Mirrors `SQLite`'s storage
91/// classes.
92#[derive(Debug, Clone, PartialEq)]
93pub enum Value {
94    Null,
95    Integer(i64),
96    Real(f64),
97    Text(String),
98    Blob(Vec<u8>),
99}
100
101/// One table row: its rowid plus decoded column values, in column order.
102#[derive(Debug, Clone, PartialEq)]
103pub struct Row {
104    pub rowid: i64,
105    pub values: Vec<Value>,
106}
107
108/// A live user table dumped for export: its name, the column header to present,
109/// and every live row in rowid order. Produced by [`Database::live_table_rows`].
110///
111/// `column_names` are the table's **real** column names parsed from its
112/// `CREATE TABLE` when available, falling back to generic `c0..c{N-1}` (sized to
113/// the widest row) when the schema parse was low-confidence — so a header is
114/// always present and never a fabricated guess. `rows` preserves b-tree order,
115/// which for an integer-rowid table is ascending rowid order.
116#[derive(Debug, Clone, PartialEq)]
117pub struct LiveTableDump {
118    /// Table name from `sqlite_master.name`.
119    pub name: String,
120    /// Header column names: real names from the schema, or `c0..c{N-1}`.
121    pub column_names: Vec<String>,
122    /// Every live row (rowid + decoded values), in b-tree (rowid) order.
123    pub rows: Vec<Row>,
124}
125
126/// A record-shaped cell recovered from unallocated / free space by
127/// [`Database::carve_cells`]. Carries the decoded row plus enough provenance for
128/// the analyzer to grade it as a "consistent with a deleted row" observation.
129#[derive(Debug, Clone, PartialEq)]
130pub struct CarvedCell {
131    /// Byte offset of the cell within the page slice that was scanned.
132    pub offset: usize,
133    /// Total bytes the candidate cell occupies (cell header + payload), so the
134    /// scanner can skip past a recovered record.
135    pub byte_len: usize,
136    /// Decoded rowid varint.
137    pub rowid: i64,
138    /// Decoded column values, in column order.
139    pub values: Vec<Value>,
140    /// Heuristic confidence in `(0.0, 1.0]` that these bytes are a real record
141    /// rather than a coincidental match.
142    pub confidence: f32,
143}
144
145/// A **partial** deleted record salvaged from a freed-cell reconstruction that
146/// failed full-row validation: the maximal decodable column prefix at a
147/// structural anchor [`Database::reconstruct_freeblock_records`] already trusts.
148///
149/// Deliberately NOT a [`CarvedCell`]: it has no rowid (clobbered) and an
150/// incomplete value set, so the type system keeps it out of the full-row output
151/// — a fragment can never be silently rendered as a recovered row. Emitted only
152/// at an anchor where full reconstruction failed but at least one *distinctive*
153/// cell (TEXT ≥ 4 bytes of valid UTF-8, or REAL) decoded cleanly, so a lone
154/// coincidental integer pattern never anchors a fragment. Graded
155/// `FRAGMENT_CONFIDENCE` — strictly below every full-row class.
156#[derive(Debug, Clone, PartialEq)]
157pub struct CellFragment {
158    /// Byte offset of the failed cell's anchor within the scanned page slice.
159    pub offset: usize,
160    /// Bytes covered by the decoded prefix (anchor to the last decoded body byte).
161    pub byte_len: usize,
162    /// `(column_index, value)` for each column that decoded cleanly, ascending by
163    /// index. Column indexes come from the page's schema template, so they are
164    /// meaningful against the table's column order.
165    pub surviving: Vec<(usize, Value)>,
166    /// Number of the template's columns that did NOT decode (`column_count` minus
167    /// the number of surviving columns).
168    pub missing: usize,
169    /// Always `FRAGMENT_CONFIDENCE` for now; the field is kept so future
170    /// per-fragment grading does not change the public type.
171    pub confidence: f32,
172}
173
174/// A freed table-leaf cell whose declared payload **spills onto an overflow-page
175/// chain** (task #73). Recognized by `try_carve_spilled_cell_at` from the
176/// cell's intact local prefix; the chain itself is resolved separately
177/// ([`Database::read_freed_overflow_chain`]) because that needs whole-database
178/// access. A `SpilledCell` is deliberately NOT a [`CarvedCell`]: until its chain
179/// is walked and validated it cannot masquerade as a recovered row (secure by
180/// design — the type system keeps an unresolved spill out of the full-row output).
181#[derive(Debug, Clone, PartialEq)]
182pub struct SpilledCell {
183    /// Byte offset of the cell within the scanned slice.
184    pub offset: usize,
185    /// On-page footprint of the cell prefix: `n1 + n2 + local_len + 4`.
186    pub byte_len: usize,
187    /// Declared total payload length `P` (header + full body).
188    pub payload_len: usize,
189    /// Decoded rowid varint (intact-prefix anchors); `0` when the prefix was
190    /// clobbered and the rowid is unrecoverable (template path).
191    pub rowid: i64,
192    /// Full serial-type array, decoded from the local record header.
193    pub serials: Vec<i64>,
194    /// Local payload bytes kept on the leaf page (`local_payload_len(P, usable)`).
195    pub local_len: usize,
196    /// Offset, within the scanned slice, at which the local payload begins.
197    pub local_payload_off: usize,
198    /// First overflow-page number (big-endian u32 at `local_payload_off + local_len`).
199    pub first_overflow: u32,
200}
201
202/// Database text encoding (file-format §1.3, header byte 56). Determines how
203/// `TEXT` column bytes are decoded; a fixed property set at database creation.
204#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
205pub enum TextEncoding {
206    /// `1` (and `0`, an unwritten database): UTF-8.
207    #[default]
208    Utf8,
209    /// `2`: UTF-16 little-endian.
210    Utf16Le,
211    /// `3`: UTF-16 big-endian.
212    Utf16Be,
213}
214
215impl TextEncoding {
216    /// Decode a `TEXT` value's raw bytes per this encoding. Lossy so a corrupt
217    /// byte sequence yields U+FFFD rather than a panic or an error.
218    fn decode(self, bytes: &[u8]) -> String {
219        match self {
220            Self::Utf8 => String::from_utf8_lossy(bytes).into_owned(),
221            Self::Utf16Le => Self::decode_utf16(bytes, u16::from_le_bytes),
222            Self::Utf16Be => Self::decode_utf16(bytes, u16::from_be_bytes),
223        }
224    }
225
226    fn decode_utf16(bytes: &[u8], conv: fn([u8; 2]) -> u16) -> String {
227        // A trailing odd byte (truncated UTF-16) is dropped by chunks_exact.
228        let units: Vec<u16> = bytes.chunks_exact(2).map(|c| conv([c[0], c[1]])).collect();
229        String::from_utf16_lossy(&units)
230    }
231}
232
233/// Parsed 100-byte `SQLite` file header.
234#[derive(Debug, Clone, Copy, PartialEq, Eq)]
235pub struct Header {
236    /// Logical page size in bytes (512..=65536).
237    pub page_size: u32,
238    /// Reserved bytes at the end of each page (usually 0).
239    pub reserved: u8,
240    /// Text encoding for `TEXT` columns (header byte 56).
241    pub text_encoding: TextEncoding,
242}
243
244impl Header {
245    /// Usable bytes per page = `page_size` − reserved (file-format §1.3.4).
246    #[must_use]
247    pub fn usable_size(self) -> u32 {
248        self.page_size.saturating_sub(u32::from(self.reserved))
249    }
250}
251
252/// A read-only view over the raw bytes of a `SQLite` database file.
253///
254/// Holds the whole file in memory — adequate for the spike and for browser
255/// evidence DBs (tens of MB). A `Read + Seek` / mmap backend is a later
256/// refinement and does not change the parsing logic proven here.
257pub struct Database {
258    bytes: Vec<u8>,
259    header: Header,
260    /// Read-only WAL overlay: newest committed page versions from a `-wal`
261    /// sidecar, applied without checkpointing (never mutates `bytes`).
262    /// `None` when opened without a WAL.
263    wal: Option<WalOverlay>,
264}
265
266/// The newest committed version of each WAL page, materialized into owned bytes.
267///
268/// Built once at open; `page_slice` consults it before the main file so a table
269/// walk transparently sees the WAL-applied view. Read-only: building it copies
270/// frame data out of the `-wal` sidecar and never writes back to either file.
271struct WalOverlay {
272    /// page number (1-based) → that page's newest committed contents.
273    pages: std::collections::BTreeMap<u32, Vec<u8>>,
274    /// Every committed frame's page image, in file order, with provenance. Unlike
275    /// `pages` (newest version per page, the consistent view), this keeps EACH
276    /// committed frame so the carver can recover deleted residue that a later
277    /// frame for the same page superseded in `pages` but that still survives in an
278    /// earlier frame's slack — the genuinely-different records an on-disk-only
279    /// carve cannot see.
280    frames: Vec<WalFramePage>,
281    /// The original `-wal` sidecar bytes, retained so [`Database::wal_timeline`]
282    /// can re-parse them into the richer segmented temporal model without the
283    /// caller re-supplying the file. Held read-only; never mutated.
284    raw: Vec<u8>,
285}
286
287/// One committed WAL frame's full page image plus its provenance, exposed by
288/// [`Database::wal_frame_pages`] so the deleted-record carver can scan the
289/// uncheckpointed WAL frames the main file does not yet reflect.
290///
291/// The `(salt1, salt2, frame_index)` triple is the WAL log-sequence identity that
292/// task #55 will formalize: `salt1`/`salt2` pin the checkpoint generation and
293/// `frame_index` the position within it.
294#[derive(Debug, Clone, PartialEq, Eq)]
295pub struct WalFramePage {
296    /// 0-based position of this frame within the `-wal` file (its LSN ordinal).
297    pub frame_index: usize,
298    /// 1-based database page number this frame rewrites.
299    pub page_no: u32,
300    /// WAL header salt-1 (checkpoint generation), shared by every live frame.
301    pub salt1: u32,
302    /// WAL header salt-2 (checkpoint generation), shared by every live frame.
303    pub salt2: u32,
304    /// Whether this is a COMMIT frame (`db_size_after_commit != 0`).
305    pub is_commit: bool,
306    /// The frame's full page image (`page_size` bytes).
307    pub page: Vec<u8>,
308}
309
310/// Hard cap on b-tree pages visited in one table walk, to bound work on a
311/// crafted file with cyclic interior pointers.
312const MAX_PAGES_PER_WALK: usize = 1_000_000;
313
314/// Minimum column count accepted when **inferring** a record's width during
315/// dropped-table carving. A coincidental byte run can look like a self-consistent
316/// 1-column record far too easily; requiring at least two columns (the smallest a
317/// real rowid table with a non-rowid column has) suppresses that false-positive
318/// class without losing real records.
319const MIN_INFERRED_COLUMNS: usize = 2;
320
321/// Confidence multiplier applied to records carved from an allocated page's
322/// in-page free space. Such residue is more often partially overwritten (its
323/// freeblock may have been reused) than whole-page freelist recovery, so it is
324/// graded a notch lower even when it parses cleanly.
325const IN_PAGE_CONFIDENCE_FACTOR: f32 = 0.8;
326
327/// Confidence multiplier applied to a **chain-reassembled overflow** full row
328/// (task #73, [`Database::carve_overflow_records`]). Overflow Tier-1 is NOT part
329/// of the structural 0-false-positive guarantee (Codex ruling #1): a freelist
330/// *leaf* page can be stale — allocated, overwritten, freed, now a leaf holding
331/// unrelated bytes that happen to decode. The freelist-leaf requirement plus the
332/// strict-UTF-8 gate make a clean decode strong evidence, but one indirection
333/// weaker than a contiguous in-page span, so it is graded below the in-page
334/// full-row tier (0.9 × this factor). The residual stale-leaf risk is documented
335/// and the row remains a "consistent with a deleted row" observation, never a
336/// verdict.
337const OVERFLOW_CHAIN_CONFIDENCE_FACTOR: f32 = 0.75;
338
339/// Confidence assigned to a record rebuilt by **freeblock reconstruction**
340/// ([`Database::reconstruct_freeblock_records`]). The cell's first four bytes
341/// (payload-length + rowid varints, the record `header_len`, and the leading
342/// serial type) were destroyed by freeblock conversion, so the record is rebuilt
343/// from its surviving serial-type tail plus a schema-derived header template — a
344/// weaker reconstruction than an intact-header carve, hence graded LOW (a
345/// "consistent with a deleted row" lead the examiner weighs, never a certainty).
346const FREEBLOCK_RECONSTRUCT_CONFIDENCE: f32 = 0.4;
347
348/// Confidence assigned to a Tier-2 [`CellFragment`] — a partial recovery whose
349/// full row could not be reconstructed but at least one distinctive cell survived.
350/// Flat 0.2 = the `MinConfidence::Low` threshold, one notch below freeblock
351/// reconstruction's 0.4 (= Medium): a fragment is the weakest lead in the ladder,
352/// "consistent with a partial deleted row", never a recovered row.
353const FRAGMENT_CONFIDENCE: f32 = 0.2;
354
355/// Upper bound on the number of freeblocks walked on a single page, to cap work
356/// on a crafted file whose freeblock `next` pointers form a long or cyclic chain.
357/// Real pages hold at most a few hundred cells.
358const MAX_FREEBLOCKS_PER_PAGE: usize = 4096;
359
360/// WAL magic, big-endian variant (native byte order in the page checksums; the
361/// little-endian variant `0x377f_0683` differs only in checksum endianness,
362/// which the overlay does not verify). file-format §4.1.
363const WAL_MAGIC_BE: u32 = 0x377f_0682;
364/// WAL magic, little-endian-checksum variant.
365const WAL_MAGIC_LE: u32 = 0x377f_0683;
366
367/// Byte order in which the WAL checksum reads its 32-bit words (file-format
368/// §4.2). NOT the same as the constant names above: per the spec, magic
369/// `0x377f0683` selects **big-endian** words and `0x377f0682` **little-endian**
370/// words. (The legacy `WAL_MAGIC_*` constant names predate this checksum work
371/// and are used only as a "valid magic" set; this enum is the spec-faithful
372/// source of truth for checksum endianness.)
373#[derive(Debug, Clone, Copy, PartialEq, Eq)]
374enum WalChecksumEndian {
375    Big,
376    Little,
377}
378
379impl WalChecksumEndian {
380    /// The checksum word order selected by the WAL header magic (offset 0), or
381    /// `None` for a magic that is neither WAL variant (file-format §4.2).
382    fn from_magic(magic: u32) -> Option<Self> {
383        match magic {
384            0x377f_0683 => Some(Self::Big),
385            0x377f_0682 => Some(Self::Little),
386            _ => None,
387        }
388    }
389
390    /// Read one 32-bit word from `b` (exactly 4 bytes) in this endianness.
391    fn read_word(self, b: [u8; 4]) -> u32 {
392        match self {
393            Self::Big => u32::from_be_bytes(b),
394            Self::Little => u32::from_le_bytes(b),
395        }
396    }
397}
398
399/// Advance the cumulative WAL checksum `(s0, s1)` over `data` (file-format
400/// §4.2). `data` is interpreted as 32-bit words in the given endianness and
401/// consumed 8 bytes (two words) at a time via the Fibonacci-weighted recurrence
402///   `s0 += x[i] + s1;  s1 += x[i+1] + s0;`
403/// using wrapping (u32) arithmetic. A trailing partial group (< 8 bytes) is
404/// ignored — the spec defines the checksum only over an even number of words,
405/// and every real WAL input (24-byte header prefix, 8-byte frame-header prefix,
406/// page data) is a multiple of 8 bytes.
407fn wal_checksum(endian: WalChecksumEndian, mut s0: u32, mut s1: u32, data: &[u8]) -> (u32, u32) {
408    let mut chunks = data.chunks_exact(8);
409    for c in &mut chunks {
410        let x0 = endian.read_word([c[0], c[1], c[2], c[3]]);
411        let x1 = endian.read_word([c[4], c[5], c[6], c[7]]);
412        s0 = s0.wrapping_add(x0).wrapping_add(s1);
413        s1 = s1.wrapping_add(x1).wrapping_add(s0);
414    }
415    (s0, s1)
416}
417
418impl Database {
419    /// Parse the file header and validate magic + page size. No WAL overlay.
420    pub fn open(bytes: Vec<u8>) -> Result<Self, Error> {
421        let header = parse_header(&bytes)?;
422        Ok(Self {
423            bytes,
424            header,
425            wal: None,
426        })
427    }
428
429    /// Parse the main database plus a `-wal` sidecar, overlaying the newest
430    /// **committed** page versions from the WAL on top of the main file.
431    ///
432    /// This is the forensic-safe alternative to libsqlite checkpointing: neither
433    /// file is mutated. The resulting [`Database`] answers `read_table` with the
434    /// WAL-applied view (use [`Database::open`] for the main-only view). Frames
435    /// past the last commit frame, or whose salt does not match the WAL header,
436    /// are ignored — they are uncommitted / superseded and not part of the
437    /// consistent snapshot.
438    pub fn open_with_wal(bytes: Vec<u8>, wal: &[u8]) -> Result<Self, Error> {
439        let header = parse_header(&bytes)?;
440        let overlay = WalOverlay::parse(wal, header.page_size)?;
441        Ok(Self {
442            bytes,
443            header,
444            wal: overlay,
445        })
446    }
447
448    /// Materialize the single pre-transaction state from a rollback `-journal`,
449    /// binding it to THIS database (design §5). The journal's page images (the
450    /// bytes BEFORE the last transaction) are overlaid on the live pages, yielding
451    /// a [`PriorSnapshot`] — a DISTINCT read-only view, never a [`Database`], so a
452    /// prior/deleted row can never be read as "live" (secure-by-design).
453    ///
454    /// The main db's page size is authoritative (a PERSIST journal has a zeroed
455    /// header). **Errors with [`Error::JournalModeConflict`]** when `self` was
456    /// opened WAL-applied ([`Database::open_with_wal`]): WAL and rollback-journal
457    /// modes are mutually exclusive timelines and must not be overlaid.
458    ///
459    /// Robust and panic-free: a malformed/truncated journal yields a prior
460    /// snapshot with fewer overlaid pages (degrading toward the live image), never
461    /// a panic; a non-power-of-two page size is a typed
462    /// [`Error::BadJournalPageSize`].
463    pub fn rollback_prior(&self, journal: &[u8]) -> Result<PriorSnapshot, Error> {
464        if self.wal_applied() {
465            return Err(Error::JournalModeConflict);
466        }
467        let page_size = self.header.page_size;
468        let parsed = RollbackJournal::parse(journal, page_size)?;
469
470        // Start from the live main pages, then overlay the journal's prior images.
471        let main_pages = self.file_page_count();
472        let mut overlaid: std::collections::BTreeMap<u32, Vec<u8>> =
473            std::collections::BTreeMap::new();
474        for pgno in 1..=main_pages {
475            if let Some(slice) = self.raw_page(pgno) {
476                overlaid.insert(pgno, slice.to_vec());
477            }
478        }
479        let mut grew_db = false;
480        for img in parsed.page_images() {
481            if img.pgno > main_pages {
482                grew_db = true;
483            }
484            overlaid.insert(img.pgno, img.bytes.clone());
485        }
486
487        // Usable bytes per page from the PRIOR page-1 header (reserved byte @ 20),
488        // so a reserved-space change in the last txn is honored. Fall back to the
489        // live header when page 1 is not in the snapshot.
490        let reserved = overlaid
491            .get(&1)
492            .and_then(|p| p.get(RESERVED_SPACE_OFFSET).copied())
493            .unwrap_or(self.header.reserved);
494        let usable = page_size.saturating_sub(u32::from(reserved));
495        let page_bound = overlaid.keys().copied().next_back().unwrap_or(main_pages);
496
497        Ok(PriorSnapshot {
498            overlaid,
499            usable,
500            page_bound,
501            grew_db,
502        })
503    }
504
505    /// Whether a non-empty WAL overlay is in effect (at least one committed
506    /// frame was applied on top of the main file).
507    #[must_use]
508    pub fn wal_applied(&self) -> bool {
509        self.wal.as_ref().is_some_and(|w| !w.pages.is_empty())
510    }
511
512    /// Every committed `-wal` frame's page image, in file order, with provenance.
513    ///
514    /// Empty when the database was opened without a WAL (or the WAL held no
515    /// committed frames). The carver scans these page images for deleted-cell
516    /// residue that lives ONLY in the uncheckpointed WAL — the genuinely-different
517    /// records the on-disk pages do not hold — tagging each with the
518    /// `(salt1, salt2, frame_index)` log-sequence identity.
519    #[must_use]
520    pub fn wal_frame_pages(&self) -> &[WalFramePage] {
521        self.wal.as_ref().map_or(&[], |w| w.frames.as_slice())
522    }
523
524    /// Build the bespoke, format-exact [`WalTimeline`] for this database's `-wal`
525    /// sidecar, if one was supplied to [`Database::open_with_wal`].
526    ///
527    /// Returns `None` when the database was opened without a WAL, or the WAL held
528    /// no committed frame (no materializable state). The timeline enumerates the
529    /// segment's [`CommitSnapshot`]s — the only materializable database states —
530    /// each addressable by [`CommitId`]; see [`WalTimeline`].
531    ///
532    /// This consults the original `-wal` bytes retained at open time, re-parsing
533    /// them into the richer temporal model (the on-open `WalOverlay` keeps only
534    /// the consistent-view pages; the timeline keeps every segment, snapshot, and
535    /// residue tail). A page-size mismatch or malformed header surfaces as `None`
536    /// here — use [`Database::wal_timeline_from`] when you need the typed
537    /// [`WalValidationError`].
538    #[must_use]
539    pub fn wal_timeline(&self) -> Option<WalTimeline> {
540        let raw = self.wal.as_ref()?.raw.as_slice();
541        WalTimeline::parse(&self.bytes, raw, self.header.page_size).ok()
542    }
543
544    /// Parse a main database + `-wal` sidecar directly into a [`WalTimeline`],
545    /// surfacing the typed [`WalValidationError`] when the WAL is malformed.
546    ///
547    /// This is the validation-tier entry point: a page-size mismatch between the DB
548    /// header and the WAL header is a HARD STOP ([`WalValidationError::PageSizeMismatch`]),
549    /// not a silently mis-sliced overlay; a bad magic / unparsable header is
550    /// [`WalValidationError::BadMagic`]. Both are caught at the physical-validation
551    /// tier before any replay.
552    pub fn wal_timeline_from(bytes: &[u8], wal: &[u8]) -> Result<WalTimeline, WalValidationError> {
553        let header = parse_header(bytes).map_err(WalValidationError::Header)?;
554        WalTimeline::parse(bytes, wal, header.page_size)
555    }
556
557    #[must_use]
558    pub fn header(&self) -> Header {
559        self.header
560    }
561
562    /// Number of pages in the database file.
563    ///
564    /// Prefers the in-header DB size (offset 28) when it is a valid, non-zero
565    /// value that is consistent with the file length; otherwise falls back to
566    /// `file_len / page_size`. A mismatch between the two is itself a forensic
567    /// signal (see [`Database::header_page_count`] / [`Database::file_page_count`]).
568    #[must_use]
569    pub fn page_count(&self) -> u32 {
570        let header = self.header_page_count();
571        let file = self.file_page_count();
572        if header != 0 && header == file {
573            header
574        } else {
575            file
576        }
577    }
578
579    /// The page count recorded in the file header (offset 28). May be 0 (legacy
580    /// "size not valid" sentinel) or disagree with the file length after an
581    /// out-of-band truncation/extension.
582    #[must_use]
583    pub fn header_page_count(&self) -> u32 {
584        be_u32(&self.bytes, DB_SIZE_IN_PAGES_OFFSET)
585    }
586
587    /// The page count implied by the raw file length (`file_len / page_size`).
588    #[must_use]
589    pub fn file_page_count(&self) -> u32 {
590        let ps = self.header.page_size as usize;
591        u32::try_from(self.bytes.len() / ps).unwrap_or(u32::MAX)
592    }
593
594    /// The freelist page **count** recorded in the file header (offset 36).
595    #[must_use]
596    pub fn freelist_count(&self) -> u32 {
597        be_u32(&self.bytes, FREELIST_COUNT_OFFSET)
598    }
599
600    /// Walk the freelist trunk/leaf chain and return every free (unallocated)
601    /// page number, in trunk order. Free pages retain the bytes of whatever they
602    /// last held — on a `secure_delete=OFF` database that includes deleted
603    /// records, which the analyzer can carve.
604    ///
605    /// Bounded against crafted cyclic trunk chains: a page already visited, an
606    /// out-of-range page, or a leaf-pointer count larger than a trunk page can
607    /// hold aborts with [`Error::MalformedFreelist`] rather than looping.
608    pub fn freelist_pages(&self) -> Result<Vec<u32>, Error> {
609        let (leaves, trunks) = self.freelist_pages_split()?;
610        // Preserve the historical order: each trunk's leaves, then the trunk.
611        // The split sets are ordered, which is sufficient for every caller (they
612        // treat the result as a set), and keeps a single source of truth.
613        let mut free: Vec<u32> = leaves.into_iter().collect();
614        free.extend(trunks);
615        Ok(free)
616    }
617
618    /// Walk the freelist and return its **leaf** and **trunk** page numbers
619    /// separately (task #73). The distinction is load-bearing for chain-aware
620    /// overflow recovery: a freed page that became a freelist *leaf* keeps its
621    /// former content byte-for-byte, while a *trunk* page has its head
622    /// (next-trunk pointer + leaf count + leaf-number array) written over the
623    /// former content (file-format §"The Freelist"). Only leaves are
624    /// content-preserving, so [`Database::read_freed_overflow_chain`] accepts a
625    /// chain page only when it is a leaf.
626    ///
627    /// Bounded identically to [`Database::freelist_pages`]: a cyclic trunk chain,
628    /// an out-of-range page, or an over-large leaf count aborts with
629    /// [`Error::MalformedFreelist`] rather than looping.
630    pub fn freelist_pages_split(
631        &self,
632    ) -> Result<
633        (
634            std::collections::BTreeSet<u32>,
635            std::collections::BTreeSet<u32>,
636        ),
637        Error,
638    > {
639        let mut leaves = std::collections::BTreeSet::new();
640        let mut trunks = std::collections::BTreeSet::new();
641        let mut trunk = be_u32(&self.bytes, SQLITE_FREELIST_TRUNK_OFFSET);
642        let total_pages = self.file_page_count();
643        // Each trunk page holds at most (page_size/4 - 2) leaf pointers.
644        let max_leaves = (self.header.page_size as usize / 4).saturating_sub(2);
645        let mut visited = 0usize;
646        let cap = total_pages as usize + 1;
647
648        while trunk != 0 {
649            visited += 1;
650            if visited > cap {
651                return Err(Error::MalformedFreelist);
652            }
653            if trunk > total_pages {
654                return Err(Error::MalformedFreelist);
655            }
656            let slice = self.page_slice(trunk)?;
657            let next = be_u32(slice, 0);
658            let leaf_count = be_u32(slice, 4) as usize;
659            if leaf_count > max_leaves {
660                return Err(Error::MalformedFreelist);
661            }
662            for i in 0..leaf_count {
663                let leaf = be_u32(slice, 8 + i * 4);
664                if leaf == 0 || leaf > total_pages {
665                    return Err(Error::MalformedFreelist);
666                }
667                leaves.insert(leaf);
668            }
669            trunks.insert(trunk);
670            trunk = next;
671        }
672        Ok((leaves, trunks))
673    }
674
675    /// Follow a **freed** overflow-page chain starting at `first`, reading raw
676    /// main-file pages only (carving wants on-disk residue, not the WAL view),
677    /// and assemble up to `remaining` content bytes (task #73). The carve-side
678    /// dual of `Database::read_overflow_chain`, with one extra discipline that
679    /// makes it the 0-FP-relevant guard: **every chain page must be a freelist
680    /// leaf** (`freed_leaves`). A page that is not a leaf is live, a trunk, or
681    /// unreachable — following its pointer would risk reading reused or clobbered
682    /// content, so it is a [`ChainBreak`] (Codex ruling #2: the leaf requirement,
683    /// not the UTF-8 gate, is what rejects a destroyed chain).
684    ///
685    /// Returns the assembled content and the ordered list of chain pages on
686    /// success. Robustness (Paranoid Gatekeeper, design §4.2): the anti-bomb cap
687    /// rejects upfront any `remaining` above what the freelist leaves can deliver
688    /// (`(usable - 4) × freed_leaves.len()`), so an attacker-declared huge
689    /// payload dies before any allocation; cycles are caught by a visited set;
690    /// a premature `next == 0` with bytes still wanted, an out-of-range page, or
691    /// page 0 mid-chain all break. Never panics — every read is bounds-checked.
692    pub fn read_freed_overflow_chain(
693        &self,
694        first: u32,
695        remaining: usize,
696        usable: usize,
697        freed_leaves: &std::collections::BTreeSet<u32>,
698    ) -> Result<(Vec<u8>, Vec<u32>), ChainBreak> {
699        let per_page = usable.checked_sub(4).filter(|&p| p > 0).ok_or(ChainBreak)?;
700        // Anti-bomb cap: the chain can deliver at most this many bytes. Reject an
701        // absurd declared payload before allocating (design §4.2).
702        let max_deliverable = per_page.checked_mul(freed_leaves.len()).ok_or(ChainBreak)?;
703        if remaining > max_deliverable {
704            return Err(ChainBreak);
705        }
706        let total_pages = self.file_page_count();
707        let mut content = Vec::with_capacity(remaining);
708        let mut chain = Vec::new();
709        let mut visited = std::collections::BTreeSet::new();
710        let mut page = first;
711        let mut left = remaining;
712        while left > 0 {
713            if page == 0 || page > total_pages {
714                return Err(ChainBreak);
715            }
716            // The load-bearing guard: a chain page must be a freelist LEAF.
717            if !freed_leaves.contains(&page) {
718                return Err(ChainBreak);
719            }
720            if !visited.insert(page) {
721                return Err(ChainBreak); // cycle
722            }
723            let slice = self.raw_page(page).ok_or(ChainBreak)?;
724            let next = be_u32(slice, 0);
725            let take = left.min(per_page);
726            let chunk = slice.get(4..4 + take).ok_or(ChainBreak)?;
727            content.extend_from_slice(chunk);
728            chain.push(page);
729            left -= take;
730            page = next;
731        }
732        Ok((content, chain))
733    }
734
735    /// Raw bytes of the 1-based `page` from the **main file only**, ignoring any
736    /// WAL overlay. Carving wants the on-disk page (where deleted residue lives),
737    /// not the WAL-applied view. Returns `None` for page 0 or out-of-range pages.
738    #[must_use]
739    pub fn raw_page(&self, page: u32) -> Option<&[u8]> {
740        if page == 0 {
741            return None;
742        }
743        let ps = self.header.page_size as usize;
744        let start = (page as usize - 1).checked_mul(ps)?;
745        let end = start.checked_add(ps)?;
746        self.bytes.get(start..end)
747    }
748
749    /// Scan a slice of page bytes for record-shaped table-leaf cells of exactly
750    /// `column_count` columns, recovering each as a [`CarvedCell`].
751    ///
752    /// This is the carving primitive the forensic analyzer drives over free /
753    /// unallocated regions: at every byte offset it speculatively parses a
754    /// `payload_len` varint, a `rowid` varint, and a record header, accepting the
755    /// candidate only when the serial-type count matches `column_count`, the
756    /// declared lengths stay within the slice, and every value decodes. Strict
757    /// validation keeps the false-positive rate low; `confidence` reflects how
758    /// strongly the bytes are record-shaped. Bounded: each offset does O(record)
759    /// work and the scan is linear in the slice length.
760    #[must_use]
761    pub fn carve_cells(&self, page_bytes: &[u8], column_count: usize) -> Vec<CarvedCell> {
762        let mut out = Vec::new();
763        if column_count == 0 {
764            return out;
765        }
766        let mut off = 0usize;
767        while off < page_bytes.len() {
768            if let Some(cell) = try_carve_cell_at(
769                page_bytes,
770                off,
771                Some(column_count),
772                self.header.text_encoding,
773            ) {
774                // Skip past this record to avoid re-reporting sub-slices of it.
775                off += cell.byte_len.max(1);
776                out.push(cell);
777            } else {
778                off += 1;
779            }
780        }
781        out
782    }
783
784    /// Carve record-shaped cells from a page slice **inferring** each record's
785    /// column count from its own serial-type array, instead of requiring a fixed
786    /// count. This is what makes **dropped-table / schema-gone** recovery
787    /// possible: the page's table was `DROP`ped, so `sqlite_master` no longer
788    /// records a column count, but each record still self-describes its columns.
789    ///
790    /// Inferring the count removes one validity check, so the remaining
791    /// self-consistency checks are kept strict to hold the false-positive rate
792    /// down: `header_len + body_len == payload_len`, every serial type legal,
793    /// `rowid > 0`, the payload fully in-bounds, and at least
794    /// `MIN_INFERRED_COLUMNS` columns. Records carved this way are graded a
795    /// notch lower in confidence than fixed-count carving.
796    #[must_use]
797    pub fn carve_cells_inferred(&self, page_bytes: &[u8]) -> Vec<CarvedCell> {
798        let mut out = Vec::new();
799        let mut off = 0usize;
800        while off < page_bytes.len() {
801            if let Some(cell) = try_carve_cell_at(page_bytes, off, None, self.header.text_encoding)
802            {
803                off += cell.byte_len.max(1);
804                out.push(cell);
805            } else {
806                off += 1;
807            }
808        }
809        out
810    }
811
812    /// Decode **every cell present in a table-leaf page image** (type `0x0D`) by
813    /// walking its cell-pointer array, inferring each record's column count from
814    /// its own serial-type array. Unlike [`Database::carve_free_regions`] (which
815    /// scans only free space and excludes live cells), this returns the cells the
816    /// page itself records as allocated.
817    ///
818    /// This is the primitive WAL-frame recovery needs: a `-wal` frame is a full
819    /// page snapshot at one point in time, so a cell that is allocated in an
820    /// EARLIER frame's image but absent from the final WAL-applied view is a row
821    /// that was deleted later and survives ONLY in that superseded frame. The
822    /// caller filters the returned cells against the final live view to isolate
823    /// exactly those genuinely-deleted rows (so a still-live row is never
824    /// re-surfaced — the filter is the caller's responsibility, mirroring the
825    /// freeblock-reconstruction discipline).
826    ///
827    /// Bounded and panic-free: a malformed cell pointer or record simply yields
828    /// fewer cells. Non-leaf pages yield nothing.
829    #[must_use]
830    pub fn carve_leaf_cells(&self, page_bytes: &[u8]) -> Vec<CarvedCell> {
831        let hdr_off = if page_bytes.starts_with(SQLITE_MAGIC) {
832            SQLITE_HEADER_SIZE
833        } else {
834            0
835        };
836        let Some(&page_type) = page_bytes.get(hdr_off) else {
837            return Vec::new();
838        };
839        if page_type != 0x0d {
840            return Vec::new(); // only table-leaf pages hold decodable cells here
841        }
842        let cell_count = be_u16(page_bytes, hdr_off + 3) as usize;
843        let cell_ptr_array = hdr_off + 8; // leaf b-tree header is 8 bytes
844        let mut out = Vec::new();
845        for i in 0..cell_count {
846            let cell_off = be_u16(page_bytes, cell_ptr_array + i * 2) as usize;
847            if cell_off == 0 || cell_off >= page_bytes.len() {
848                continue; // cov:unreachable: a valid leaf points cells within page
849            }
850            if let Some(cell) =
851                try_carve_cell_at(page_bytes, cell_off, None, self.header.text_encoding)
852            {
853                out.push(cell);
854            }
855        }
856        out
857    }
858
859    /// Carve deleted records from the **free (unallocated) regions** of an
860    /// allocated table-leaf page (type `0x0D`), never re-surfacing a live cell.
861    ///
862    /// On an allocated leaf, deleted-cell residue survives in two places: the
863    /// unallocated gap between the cell-pointer array and the cell-content area,
864    /// and the slack between/after live cells (a former freeblock whose chain
865    /// pointer may already be gone). This method computes the exact byte ranges
866    /// occupied by **live** cells and carves only the complement — so a live
867    /// (allocated) cell can never be returned as a deleted record. That is the
868    /// 0-false-positive guarantee, enforced structurally rather than by a filter.
869    ///
870    /// `page_bytes` is one whole page. `column_count_hint`, when non-zero, is the
871    /// table's known column count (matched exactly); pass 0 to infer the count
872    /// per record (for a page whose schema is gone). Non-leaf pages yield nothing.
873    #[must_use]
874    pub fn carve_free_regions(
875        &self,
876        page_bytes: &[u8],
877        column_count_hint: usize,
878    ) -> Vec<CarvedCell> {
879        // Page 1 carries the 100-byte file header before its b-tree header; for a
880        // standalone page slice we assume hdr_off 0 unless it starts with the
881        // file magic (page 1 passed whole).
882        let hdr_off = if page_bytes.starts_with(SQLITE_MAGIC) {
883            SQLITE_HEADER_SIZE
884        } else {
885            0
886        };
887        let Some(&page_type) = page_bytes.get(hdr_off) else {
888            return Vec::new();
889        };
890        if page_type != 0x0d {
891            return Vec::new(); // only table-leaf pages have carvable cell residue
892        }
893        // Carve each maximal free region (complement of the live cell extents),
894        // within the cell-content area only — so no allocated cell is ever
895        // re-surfaced (the 0-false-positive guarantee, enforced structurally).
896        let mut out = Vec::new();
897        let regions = self.free_regions_of_leaf(page_bytes, hdr_off);
898        for (lo, hi) in regions {
899            let Some(region) = page_bytes.get(lo..hi) else {
900                continue; // cov:unreachable: free_regions yields in-bounds spans
901            };
902            let cells = if column_count_hint == 0 {
903                self.carve_cells_inferred(region)
904            } else {
905                self.carve_cells(region, column_count_hint)
906            };
907            for mut cell in cells {
908                // Translate the offset from region-local to page-local, and grade
909                // in-page recovery a notch lower (residue here is more often
910                // partially overwritten than freed-page recovery).
911                cell.offset += lo;
912                cell.confidence *= IN_PAGE_CONFIDENCE_FACTOR;
913                out.push(cell);
914            }
915        }
916        out
917    }
918
919    /// Recover **spilled** deleted records on a table-leaf page whose payload
920    /// continued onto a freed overflow-page chain (task #73). Scans the page's
921    /// free regions (the complement of the live cells — same discipline as
922    /// [`Database::carve_free_regions`], so a live cell is never re-surfaced) for
923    /// a [`SpilledCell`], then resolves each chain through freelist **leaf** pages
924    /// only and assembles the full payload.
925    ///
926    /// A resolved record is returned only when ALL hold (design §5):
927    /// 1. the chain is intact through freelist leaves (Codex ruling #2: the leaf
928    ///    requirement is the load-bearing 0-FP guard — a trunk/live/off-freelist
929    ///    chain page is rejected);
930    /// 2. the assembled bytes total exactly the declared `P` and decode cleanly;
931    /// 3. **strict UTF-8 on chain-resident TEXT** — an EXTRA reject signal, not a
932    ///    correctness proof (Codex ruling #2: a clobbered chain can still be valid
933    ///    UTF-8, so this cannot prove integrity; it only catches the cases where
934    ///    the lossy decoder would otherwise mask an overwrite as `U+FFFD`).
935    ///
936    /// Each returned tuple is `(cell, chain)` where `chain` is the ordered list of
937    /// overflow pages the bytes came from (for provenance). Confidence is graded
938    /// BELOW the in-page full-row tier (Codex ruling #1: overflow Tier-1 is a
939    /// graded recovery, NOT part of the structural 0-FP guarantee — a freelist
940    /// leaf can be stale, holding unrelated bytes that happen to decode). Bounded
941    /// and panic-free; a malformed page or chain simply yields fewer records.
942    #[must_use]
943    pub fn carve_overflow_records(&self, page_bytes: &[u8]) -> Vec<(CarvedCell, Vec<u32>)> {
944        let hdr_off = if page_bytes.starts_with(SQLITE_MAGIC) {
945            SQLITE_HEADER_SIZE
946        } else {
947            0
948        };
949        let Some(&page_type) = page_bytes.get(hdr_off) else {
950            return Vec::new();
951        };
952        if page_type != 0x0d {
953            return Vec::new(); // only table-leaf pages carry spilled-cell residue
954        }
955        let Ok((freed_leaves, _trunks)) = self.freelist_pages_split() else {
956            return Vec::new();
957        };
958        let usable = self.header.usable_size() as usize;
959
960        let mut out = Vec::new();
961        let regions = self.free_regions_of_leaf(page_bytes, hdr_off);
962        for (lo, hi) in regions {
963            let Some(region) = page_bytes.get(lo..hi) else {
964                continue; // cov:unreachable: free_regions yields in-bounds spans
965            };
966            // Scan every offset for a spilled cell (recognizer abstains on in-page
967            // payloads, so the two carve classes never overlap).
968            let mut off = 0usize;
969            while off < region.len() {
970                let Some(sc) = try_carve_spilled_cell_at(region, off, usable, None) else {
971                    off += 1;
972                    continue;
973                };
974                if let Some((mut cell, chain)) =
975                    self.resolve_spilled(region, &sc, usable, &freed_leaves)
976                {
977                    // Translate the region-local offset to page-local.
978                    cell.offset = lo + sc.offset;
979                    out.push((cell, chain));
980                    off += sc.byte_len.max(1);
981                } else {
982                    off += 1;
983                }
984            }
985        }
986        out
987    }
988
989    /// Resolve a recognized [`SpilledCell`] to a full [`CarvedCell`] by walking
990    /// its freed overflow chain and decoding the assembled payload, applying the
991    /// strict-UTF-8 chain gate. Returns `Some((cell, chain))` on a fully-validated
992    /// recovery, `None` on any chain break or gate failure (the candidate then
993    /// degrades to a Tier-2 fragment elsewhere).
994    fn resolve_spilled(
995        &self,
996        region: &[u8],
997        sc: &SpilledCell,
998        usable: usize,
999        freed_leaves: &std::collections::BTreeSet<u32>,
1000    ) -> Option<(CarvedCell, Vec<u32>)> {
1001        let remaining = sc.payload_len.checked_sub(sc.local_len)?;
1002        let local_payload =
1003            region.get(sc.local_payload_off..sc.local_payload_off + sc.local_len)?;
1004        let (chain_content, chain) = self
1005            .read_freed_overflow_chain(sc.first_overflow, remaining, usable, freed_leaves)
1006            .ok()?;
1007        let mut payload = Vec::with_capacity(sc.payload_len);
1008        payload.extend_from_slice(local_payload);
1009        payload.extend_from_slice(&chain_content);
1010        if payload.len() != sc.payload_len {
1011            return None; // cov:unreachable: chain delivers exactly `remaining` bytes
1012        }
1013
1014        let values = decode_record(
1015            &payload,
1016            sc.serials.len(),
1017            sc.rowid,
1018            self.header.text_encoding,
1019        )
1020        .ok()?;
1021        if values.len() != sc.serials.len() {
1022            return None; // cov:unreachable: decode_record yields one value per serial
1023        }
1024        // Strict-UTF-8 gate on chain-resident TEXT (extra reject signal): the
1025        // lossy decoder turns a clobbered byte into U+FFFD, so any replacement
1026        // char in a decoded TEXT value means the chain-supplied bytes did not
1027        // decode cleanly — reject. NOT a proof of integrity (a stale leaf can hold
1028        // valid UTF-8); the freelist-leaf requirement is the load-bearing guard.
1029        let any_replacement = values.iter().any(|v| match v {
1030            Value::Text(t) => t.contains('\u{FFFD}'),
1031            _ => false,
1032        });
1033        if any_replacement {
1034            return None;
1035        }
1036        // Require at least one distinctive column so a coincidental decode of stale
1037        // bytes does not anchor a full row (the same identity bar as fragments).
1038        if !values.iter().any(is_distinctive) {
1039            return None; // cov:unreachable: the spilled corpus rows carry distinctive TEXT
1040        }
1041
1042        let cell = CarvedCell {
1043            offset: sc.offset,
1044            byte_len: sc.byte_len,
1045            rowid: sc.rowid,
1046            values,
1047            // Graded below the in-page full-row tier (0.9): an overflow chain adds
1048            // one indirection of stale-leaf exposure (Codex ruling #1).
1049            confidence: 0.9 * OVERFLOW_CHAIN_CONFIDENCE_FACTOR,
1050        };
1051        Some((cell, chain))
1052    }
1053
1054    /// Reconstruct **freeblock-clobbered spilled** cells (task #73, design §2.2 /
1055    /// Codex ruling #5). When a freed cell whose payload spilled is also
1056    /// freeblock-clobbered, its declared `P` is destroyed but **re-derivable** from
1057    /// the surviving structure: `P = header_len + Σ serial_body_len` over the full
1058    /// (template + surviving) serial array. When that `P` exceeds `usable - 35` the
1059    /// record is spilled by construction, so we read the 4-byte first-overflow
1060    /// pointer that follows the local payload and resolve the chain through
1061    /// freelist leaves, exactly as the intact-prefix path does — but with
1062    /// `rowid = 0` (the prefix's rowid varint was clobbered, never invented).
1063    ///
1064    /// UNPROVEN-BY-CORPUS (Codex ruling #5): no real Nemetz `0E` cell is *both*
1065    /// freeblock-clobbered *and* spilled — every measured spilled cell kept an
1066    /// intact prefix in the unallocated gap. This path is therefore validated
1067    /// against a **synthetic** fixture only; it is the general solution the
1068    /// no-special-case rule requires (it applies the same spill formula to the
1069    /// clobbered class), but its real-data behavior is not yet observed.
1070    ///
1071    /// Returns `(cell, chain)` per fully-resolved record. Bounded and panic-free.
1072    #[must_use]
1073    pub fn carve_overflow_template_records(
1074        &self,
1075        page_bytes: &[u8],
1076    ) -> Vec<(CarvedCell, Vec<u32>)> {
1077        let hdr_off = if page_bytes.starts_with(SQLITE_MAGIC) {
1078            SQLITE_HEADER_SIZE
1079        } else {
1080            0
1081        };
1082        if page_bytes.get(hdr_off) != Some(&0x0d) {
1083            return Vec::new();
1084        }
1085        let Some(template) = freeblock_template(page_bytes, hdr_off, self.header.text_encoding)
1086        else {
1087            return Vec::new();
1088        };
1089        let Ok((freed_leaves, _trunks)) = self.freelist_pages_split() else {
1090            return Vec::new();
1091        };
1092        let usable = self.header.usable_size() as usize;
1093
1094        let mut out = Vec::new();
1095        // Walk the freeblock chain; at each freeblock head, try a clobbered-spill
1096        // reconstruction (the chain pass reaches the clobbered prefix the
1097        // intact-prefix recognizer cannot read).
1098        let first_freeblock = be_u16(page_bytes, hdr_off + 1) as usize;
1099        let mut fb = first_freeblock;
1100        let mut walked = 0usize;
1101        let mut visited = std::collections::BTreeSet::new();
1102        while fb != 0 && walked < MAX_FREEBLOCKS_PER_PAGE {
1103            walked += 1;
1104            if !visited.insert(fb) {
1105                break; // cyclic next pointer
1106            }
1107            let next = be_u16(page_bytes, fb) as usize;
1108            if let Some((cell, chain)) =
1109                template.reconstruct_spilled(self, page_bytes, fb, usable, &freed_leaves)
1110            {
1111                out.push((cell, chain));
1112            }
1113            fb = next;
1114        }
1115        out
1116    }
1117
1118    /// Tier-2 salvage for **spilled** cells whose overflow chain is broken (task
1119    /// #73, Codex ruling #4): when [`Database::carve_overflow_records`] rejects a
1120    /// recognized spilled cell because its chain failed (a trunk-clobbered or
1121    /// reused chain page), the cell's intact LOCAL prefix still holds the columns
1122    /// whose bodies fit entirely on the leaf page. Those are salvaged as a
1123    /// [`CellFragment`] — the same Tier-2 surface freeblock reconstruction uses.
1124    ///
1125    /// Only columns whose body lies wholly within the local payload are kept; the
1126    /// chain-resident columns are lost (untrusted by definition — the chain that
1127    /// would supply them is the thing that failed). A fragment is emitted only
1128    /// when the salvaged prefix carries ≥ 1 distinctive cell (TEXT ≥ 4 bytes of
1129    /// valid UTF-8, or REAL — the §3.1 gate), so a lone integer prefix never
1130    /// anchors one. Bounded and panic-free.
1131    #[must_use]
1132    pub fn carve_overflow_fragments(&self, page_bytes: &[u8]) -> Vec<CellFragment> {
1133        let hdr_off = if page_bytes.starts_with(SQLITE_MAGIC) {
1134            SQLITE_HEADER_SIZE
1135        } else {
1136            0
1137        };
1138        let Some(&page_type) = page_bytes.get(hdr_off) else {
1139            return Vec::new();
1140        };
1141        if page_type != 0x0d {
1142            return Vec::new();
1143        }
1144        let Ok((freed_leaves, _trunks)) = self.freelist_pages_split() else {
1145            return Vec::new();
1146        };
1147        let usable = self.header.usable_size() as usize;
1148
1149        let mut out = Vec::new();
1150        let regions = self.free_regions_of_leaf(page_bytes, hdr_off);
1151        for (lo, hi) in regions {
1152            let Some(region) = page_bytes.get(lo..hi) else {
1153                continue; // cov:unreachable: free_regions yields in-bounds spans
1154            };
1155            let mut off = 0usize;
1156            while off < region.len() {
1157                let Some(sc) = try_carve_spilled_cell_at(region, off, usable, None) else {
1158                    off += 1;
1159                    continue;
1160                };
1161                // Only broken chains degrade to a fragment — an intact chain is a
1162                // Tier-1 row (handled by carve_overflow_records), never both.
1163                let remaining = sc.payload_len.saturating_sub(sc.local_len);
1164                let chain_ok = self
1165                    .read_freed_overflow_chain(sc.first_overflow, remaining, usable, &freed_leaves)
1166                    .is_ok();
1167                if !chain_ok {
1168                    if let Some(mut frag) =
1169                        salvage_local_prefix(region, &sc, self.header.text_encoding)
1170                    {
1171                        frag.offset += lo;
1172                        out.push(frag);
1173                    }
1174                }
1175                off += sc.byte_len.max(1);
1176            }
1177        }
1178        out
1179    }
1180
1181    /// Reconstruct deleted records from the **freeblock chain** of an allocated
1182    /// table-leaf page (type `0x0d`) — the records a forward parse cannot recover
1183    /// because their first four bytes were destroyed by freeblock conversion.
1184    ///
1185    /// When SQLite frees an in-page cell it converts it into a **freeblock**
1186    /// (file-format §1.6): the cell's first two bytes become the next-freeblock
1187    /// offset and the next two the freeblock size, **overwriting the cell's
1188    /// payload-length + rowid varints, the record `header_len` varint, and the
1189    /// leading serial type(s)**. The record's surviving serial-type tail and its
1190    /// whole value body remain intact *after* those four bytes.
1191    ///
1192    /// This method rebuilds each freed cell from that surviving tail plus a
1193    /// **schema template** derived from a LIVE cell on the same page (the table's
1194    /// column count, header length, and the serial types of the leading columns
1195    /// that fall inside the clobbered prefix). The destroyed rowid is surfaced as
1196    /// unknown (`0`) — never invented — and the record is graded LOW.
1197    ///
1198    /// Precision discipline (task #56): a candidate is emitted only when its body
1199    /// decodes cleanly with every serial type legal AND the record fits within
1200    /// the freeblock's `[offset, offset + size)` bounds. Implausible or
1201    /// out-of-bounds candidates are rejected, so reconstruction does not
1202    /// manufacture phantom rows. (The forensic layer additionally drops any
1203    /// reconstruction whose values match a live row, so a live row is never
1204    /// re-surfaced.)
1205    ///
1206    /// Bounded and panic-free: every freeblock pointer, size, and serial length
1207    /// is range-checked against the page before use, and the chain walk is capped
1208    /// at `MAX_FREEBLOCKS_PER_PAGE` to defeat a crafted cyclic `next` chain.
1209    /// Non-leaf pages, pages with no freeblock chain, and pages with no usable
1210    /// schema template yield an empty result.
1211    #[must_use]
1212    pub fn reconstruct_freeblock_records(&self, page_bytes: &[u8]) -> Vec<CarvedCell> {
1213        // Tier-1 cells are the `.0` of the shared two-tier walker, so the full-row
1214        // output and the fragment output ([`Database::reconstruct_freeblock_fragments`])
1215        // can never diverge. The walk (freeblock-chain pass + unallocated-gap pass)
1216        // and its precision discipline live in [`reconstruct_freeblock_inner`].
1217        let _ = self;
1218        reconstruct_freeblock_inner(page_bytes, self.header.text_encoding).0
1219    }
1220
1221    /// Tier-2 partial salvage: the [`CellFragment`]s abandoned by
1222    /// [`Database::reconstruct_freeblock_records`] on this page.
1223    ///
1224    /// At every anchor where full reconstruction failed — an illegal serial in
1225    /// the surviving tail, a tail that overruns the span, or a body that does not
1226    /// fit — the columns that DID decode cleanly before the failure are salvaged
1227    /// as the maximal decodable prefix. A fragment is emitted only when that
1228    /// prefix contains at least one *distinctive* cell (TEXT ≥ 4 bytes of valid
1229    /// UTF-8, or REAL): a lone surviving integer pattern is coincidence-prone and
1230    /// never anchors a fragment.
1231    ///
1232    /// Mutually exclusive with the full reconstructions of
1233    /// [`Database::reconstruct_freeblock_records`] **by construction**: an anchor
1234    /// yields a cell or a fragment, never both. Inherits the same anchor
1235    /// discipline — no sliding scan, no strings-style hunt — so Tier-2 carries
1236    /// Tier-1's precision architecture. Bounded and panic-free identically.
1237    #[must_use]
1238    pub fn reconstruct_freeblock_fragments(&self, page_bytes: &[u8]) -> Vec<CellFragment> {
1239        let _ = self;
1240        reconstruct_freeblock_inner(page_bytes, self.header.text_encoding).1
1241    }
1242
1243    /// The maximal FREE (unallocated) byte ranges of a table-leaf page — the
1244    /// complement of its live cells within the cell-content area. Shared by
1245    /// [`Database::carve_free_regions`] and
1246    /// [`Database::reconstruct_freeblock_records`] so both scan exactly the same
1247    /// ranges and never touch a live cell. Returns empty for a non-leaf page.
1248    fn free_regions_of_leaf(&self, page_bytes: &[u8], hdr_off: usize) -> Vec<(usize, usize)> {
1249        if page_bytes.get(hdr_off) != Some(&0x0d) {
1250            return Vec::new(); // cov:unreachable: callers gate on page_type == 0x0d
1251        }
1252        let cell_count = be_u16(page_bytes, hdr_off + 3) as usize;
1253        let cell_ptr_array = hdr_off + 8; // leaf header is 8 bytes
1254        let usable = self.header.usable_size() as usize;
1255        let mut live: Vec<(usize, usize)> = Vec::with_capacity(cell_count);
1256        for i in 0..cell_count {
1257            let cell_off = be_u16(page_bytes, cell_ptr_array + i * 2) as usize;
1258            if cell_off == 0 || cell_off >= page_bytes.len() {
1259                continue; // cov:unreachable: a valid leaf points cells within page
1260            }
1261            if let Some(len) = live_cell_len(page_bytes, cell_off, usable) {
1262                live.push((cell_off, cell_off.saturating_add(len)));
1263            }
1264        }
1265        live.sort_unstable_by_key(|&(s, _)| s);
1266        let content_lo = cell_ptr_array + cell_count * 2;
1267        free_regions(&live, content_lo, page_bytes.len())
1268    }
1269
1270    /// Whether `sqlite_master` (the schema table rooted at page 1) lists at least
1271    /// one **user** table — i.e. a `type='table'` row whose name is not an
1272    /// internal `sqlite_*` table. A database where every table was `DROP`ped (or
1273    /// that never had one) returns `false`; the forensic carver uses this to label
1274    /// freed content as dropped-table residue. Errors (unreadable schema) are
1275    /// treated as "no user table" so the carver degrades safely.
1276    #[must_use]
1277    pub fn has_user_table(&self) -> bool {
1278        // sqlite_master is a 5-column table: (type, name, tbl_name, rootpage, sql).
1279        let Ok(rows) = self.read_table(1, 5) else {
1280            return false; // cov:unreachable: a validly-opened DB has a readable page-1 schema
1281        };
1282        rows.iter().any(|row| {
1283            let is_table = matches!(row.values.first(), Some(Value::Text(t)) if t == "table");
1284            let user = matches!(
1285                row.values.get(1),
1286                Some(Value::Text(n)) if !n.starts_with("sqlite_")
1287            );
1288            is_table && user
1289        })
1290    }
1291
1292    /// Collect the rowids of every **currently-live** row across all user table
1293    /// b-trees (the roots listed in `sqlite_master`). The forensic carver uses
1294    /// this to drop any carved "deleted" record whose rowid is in fact still live
1295    /// — a stale copy of a live row can linger in free space after a b-tree
1296    /// rebalance moved the row to another page, and reporting it as deleted would
1297    /// be a false positive. Rowid collection ignores the column count (the rowid
1298    /// is in the cell prefix), so it works even when a schema row is malformed.
1299    ///
1300    /// Bounded and panic-free: unreadable schema or a malformed b-tree yields a
1301    /// partial (possibly empty) set rather than an error.
1302    #[must_use]
1303    pub fn live_rowids(&self) -> std::collections::BTreeSet<i64> {
1304        let mut ids = std::collections::BTreeSet::new();
1305        let Ok(schema) = self.read_table(1, 5) else {
1306            return ids; // cov:unreachable: a validly-opened DB has a readable page-1 schema
1307        };
1308        for row in schema {
1309            // sqlite_master row: (type, name, tbl_name, rootpage, sql).
1310            let is_table = matches!(row.values.first(), Some(Value::Text(t)) if t == "table");
1311            if !is_table {
1312                continue; // cov:unreachable: the test fixtures' schemas hold only table rows
1313            }
1314            let Some(Value::Integer(root)) = row.values.get(3) else {
1315                continue; // cov:unreachable: a 'table' schema row always has an integer rootpage
1316            };
1317            let Ok(root) = u32::try_from(*root) else {
1318                continue; // cov:unreachable: a real rootpage is a small positive page number
1319            };
1320            let mut seen = std::collections::BTreeSet::new();
1321            self.collect_rowids(root, &mut ids, &mut seen);
1322        }
1323        ids
1324    }
1325
1326    /// Collect every **currently-live** row's decoded column values, keyed by
1327    /// rowid, across all user table b-trees. This is the value-aware companion to
1328    /// [`Database::live_rowids`]: the forensic carver uses it to tell a stale
1329    /// rebalance copy (same rowid AND same values → drop) from a deleted prior
1330    /// version (same rowid but DIFFERENT values → recover, e.g. an edited message
1331    /// or a changed amount).
1332    ///
1333    /// Column values are decoded by inferring the column count from each live
1334    /// cell's own serial-type array (the same self-describing record format the
1335    /// carver uses), so no schema column count is required. Best-effort,
1336    /// bounded, and panic-free: a malformed b-tree yields a partial map.
1337    #[must_use]
1338    pub fn live_rows(&self) -> std::collections::BTreeMap<i64, Vec<Value>> {
1339        let mut rows = std::collections::BTreeMap::new();
1340        let Ok(schema) = self.read_table(1, 5) else {
1341            return rows; // cov:unreachable: a validly-opened DB has a readable page-1 schema
1342        };
1343        for row in schema {
1344            let is_table = matches!(row.values.first(), Some(Value::Text(t)) if t == "table");
1345            if !is_table {
1346                continue; // cov:unreachable: the test fixtures' schemas hold only table rows
1347            }
1348            let Some(Value::Integer(root)) = row.values.get(3) else {
1349                continue; // cov:unreachable: a 'table' schema row always has an integer rootpage
1350            };
1351            let Ok(root) = u32::try_from(*root) else {
1352                continue; // cov:unreachable: a real rootpage is a small positive page number
1353            };
1354            let mut seen = std::collections::BTreeSet::new();
1355            self.collect_rows(root, &mut rows, &mut seen);
1356        }
1357        rows
1358    }
1359
1360    /// Decode every **currently-live** `sqlite_master` row (the schema table
1361    /// rooted at page 1) into its column values: `(type, name, tbl_name,
1362    /// rootpage, sql)`. This is the schema-table companion to
1363    /// [`Database::live_rows`], which collects only USER-table b-trees and so
1364    /// never sees the schema rows themselves.
1365    ///
1366    /// The forensic carver folds these into the same value-based live set it uses
1367    /// to drop stale copies of live user rows: a record carved from a materialized
1368    /// page 1 whose values equal a CURRENT schema row is the live schema entry
1369    /// re-surfaced (drop it), whereas a genuinely-deleted PRIOR schema version has
1370    /// different values (e.g. an old `CREATE TABLE`) and is still recovered.
1371    ///
1372    /// Best-effort, bounded, and panic-free: an unreadable schema yields an empty
1373    /// vector rather than an error.
1374    #[must_use]
1375    pub fn live_schema_rows(&self) -> Vec<Vec<Value>> {
1376        match self.read_table(1, 5) {
1377            Ok(rows) => rows.into_iter().map(|row| row.values).collect(),
1378            Err(_) => Vec::new(), // cov:unreachable: a validly-opened DB has a readable page-1 schema
1379        }
1380    }
1381
1382    /// Every live (schema-present) **user** table, as [`attribution::LiveTable`]:
1383    /// name, rootpage, parsed column names (or `None` when low-confidence), and
1384    /// declared column affinities. Internal `sqlite_*` tables are excluded.
1385    ///
1386    /// The forensic attribution step uses this to know each table's real column
1387    /// names (Tier-1) and its shape signature (Tier-2). Best-effort, bounded,
1388    /// panic-free: an unreadable schema yields an empty vector.
1389    #[must_use]
1390    pub fn live_tables(&self) -> Vec<attribution::LiveTable> {
1391        let mut tables = Vec::new();
1392        let Ok(schema) = self.read_table(1, 5) else {
1393            return tables; // cov:unreachable: a validly-opened DB has a readable page-1 schema
1394        };
1395        for row in schema {
1396            // sqlite_master row: (type, name, tbl_name, rootpage, sql).
1397            let is_table = matches!(row.values.first(), Some(Value::Text(t)) if t == "table");
1398            if !is_table {
1399                continue;
1400            }
1401            let Some(Value::Text(name)) = row.values.get(1) else {
1402                continue; // cov:unreachable: a 'table' schema row always has a TEXT name
1403            };
1404            if name.starts_with("sqlite_") {
1405                continue;
1406            }
1407            let Some(Value::Integer(root)) = row.values.get(3) else {
1408                continue; // cov:unreachable: a 'table' schema row always has an integer rootpage
1409            };
1410            let Ok(rootpage) = u32::try_from(*root) else {
1411                continue; // cov:unreachable: a real rootpage is a small positive page number
1412            };
1413            // The CREATE TABLE statement (column 5). A non-TEXT/absent sql is
1414            // possible on a damaged schema — degrade to no parsed columns.
1415            let sql = match row.values.get(4) {
1416                Some(Value::Text(s)) => s.as_str(),
1417                _ => "", // cov:unreachable: a 'table' schema row carries its CREATE TABLE sql
1418            };
1419            let defs = attribution::column_defs(sql);
1420            let affinities = defs.as_ref().map_or_else(Vec::new, |d| {
1421                d.iter()
1422                    .map(|(_, ty)| attribution::column_affinity(ty))
1423                    .collect()
1424            });
1425            // Only trust parsed names; if parsing failed, the caller uses c0..cN.
1426            let column_names = defs.map(|d| d.into_iter().map(|(n, _)| n).collect());
1427            tables.push(attribution::LiveTable {
1428                name: name.clone(),
1429                rootpage,
1430                column_names,
1431                affinities,
1432                create_sql: sql.to_string(),
1433            });
1434        }
1435        tables
1436    }
1437
1438    /// The live `sqlite_master` as a `name -> CREATE SQL` map for every **user**
1439    /// table (internal `sqlite_*` tables excluded) — the CURRENT-schema half of
1440    /// the Detector-B sidecar schema-change comparison
1441    /// (`docs/design/drop-recreate-attribution.md`).
1442    ///
1443    /// Reads the same page-1 schema b-tree as [`Self::live_tables`] but keeps the
1444    /// raw CREATE SQL text (not just parsed columns), so a caller can compare the
1445    /// verbatim schema against a sidecar's prior `sqlite_master`. Best-effort,
1446    /// bounded, panic-free: an unreadable schema yields an empty map.
1447    #[must_use]
1448    pub fn schema_sql(&self) -> std::collections::BTreeMap<String, String> {
1449        let mut out = std::collections::BTreeMap::new();
1450        let Ok(schema) = self.read_table(1, 5) else {
1451            return out; // cov:unreachable: a validly-opened DB has a readable page-1 schema
1452        };
1453        for row in schema {
1454            schema_sql_insert(&mut out, &row.values);
1455        }
1456        out
1457    }
1458
1459    /// Per-table, per-rowid VERSION HISTORY reconstructed from this database's WAL
1460    /// temporal model (or just the live view when no `-wal` is present).
1461    ///
1462    /// See [`row_history`] for the full model. Walks each salt epoch's commit
1463    /// snapshots in commit order, then the final live view, and emits — per rowid
1464    /// — the sequence of distinct record values it held (insert / update / delete /
1465    /// reinsert), with evidence-based [`row_history::ViewState`] and NO timestamps.
1466    /// Degrades cleanly to live-only history when [`Database::wal_timeline`] is
1467    /// `None`. `WITHOUT ROWID` tables are recorded with `without_rowid = true` and
1468    /// no versions (they have no rowid to key a history on).
1469    #[must_use]
1470    pub fn row_histories(&self) -> Vec<row_history::TableHistory> {
1471        use row_history::{RowView, VersionOrigin};
1472
1473        // Live tables: name, header columns, live rows, and a WITHOUT ROWID flag
1474        // read from the live schema (a WITHOUT ROWID table has no rowid history).
1475        let live_dumps = self.live_table_rows();
1476        let without_rowid = self.live_without_rowid_map();
1477
1478        // Per table, build the chronological views: each WAL commit snapshot (in
1479        // epoch order, commit_seq = per-epoch ordinal) then the final live view.
1480        let mut histories = Vec::with_capacity(live_dumps.len());
1481        for dump in live_dumps {
1482            let wr = without_rowid.get(&dump.name).copied().unwrap_or(false);
1483            let mut views: Vec<RowView> = Vec::new();
1484
1485            // Historical views from the WAL timeline, if any.
1486            if let Some(timeline) = self.wal_timeline() {
1487                // commit_seq is monotonic WITHIN a salt epoch only — count per
1488                // segment, never one global sequence spanning a salt reset.
1489                let mut seq_in_segment: std::collections::BTreeMap<WalSegmentId, u32> =
1490                    std::collections::BTreeMap::new();
1491                for snapshot in timeline.commit_snapshots() {
1492                    let seg = snapshot.id().segment;
1493                    let seq = seq_in_segment.entry(seg).or_insert(0);
1494                    let commit_seq = *seq;
1495                    *seq += 1;
1496
1497                    // Resolve THIS table from the snapshot's OWN schema (a rootpage
1498                    // can be reused by a different table across commits).
1499                    let snap_tables = snapshot.tables();
1500                    let Some(st) = snap_tables.iter().find(|t| t.name == dump.name) else {
1501                        continue; // table did not exist at this commit
1502                    };
1503                    if st.without_rowid {
1504                        continue; // no rowid history for a WITHOUT ROWID table
1505                    }
1506                    // schema_known: the snapshot's CREATE TABLE parsed to columns.
1507                    let schema_known = !st.columns.is_empty();
1508                    let rows = match snapshot.read_table(st.rootpage, st.columns.len()) {
1509                        Ok(rows) => rows.into_iter().collect(),
1510                        // An unreadable historical b-tree contributes no rows but
1511                        // must not abort the whole history.
1512                        Err(_) => std::collections::BTreeMap::new(),
1513                    };
1514                    views.push(RowView {
1515                        commit_seq: Some(commit_seq),
1516                        is_final: false,
1517                        checksum_valid: snapshot.checksum_valid(),
1518                        schema_known,
1519                        origin: VersionOrigin::Commit(snapshot.id()),
1520                        rows,
1521                    });
1522                }
1523            }
1524
1525            // The final live view (current on-disk ⊕ WAL state).
1526            let live_rows: std::collections::BTreeMap<i64, Vec<Value>> = dump
1527                .rows
1528                .iter()
1529                .map(|r| (r.rowid, r.values.clone()))
1530                .collect();
1531            views.push(RowView {
1532                commit_seq: None,
1533                is_final: true,
1534                checksum_valid: true,
1535                schema_known: true,
1536                origin: VersionOrigin::Live,
1537                rows: live_rows,
1538            });
1539
1540            histories.push(row_history::table_history(
1541                dump.name,
1542                dump.column_names,
1543                wr,
1544                &views,
1545            ));
1546        }
1547        histories
1548    }
1549
1550    /// Map each live user table's name to whether it is a `WITHOUT ROWID` table,
1551    /// read from the live `sqlite_master` schema. Best-effort and panic-free.
1552    fn live_without_rowid_map(&self) -> std::collections::BTreeMap<String, bool> {
1553        let mut map = std::collections::BTreeMap::new();
1554        let Ok(schema) = self.read_table(1, 5) else {
1555            return map; // cov:unreachable: a validly-opened DB has a readable page-1 schema
1556        };
1557        for row in schema {
1558            let is_table = matches!(row.values.first(), Some(Value::Text(t)) if t == "table");
1559            if !is_table {
1560                continue;
1561            }
1562            let Some(Value::Text(name)) = row.values.get(1) else {
1563                continue; // cov:unreachable: a 'table' schema row has a TEXT name
1564            };
1565            if name.starts_with("sqlite_") {
1566                continue;
1567            }
1568            let sql = match row.values.get(4) {
1569                Some(Value::Text(s)) => s.as_str(),
1570                _ => "", // cov:unreachable: a 'table' schema row carries its CREATE TABLE sql
1571            };
1572            map.insert(name.clone(), without_rowid_sql(sql));
1573        }
1574        map
1575    }
1576
1577    /// The `sqlite_sequence` table `SQLite` maintains for `AUTOINCREMENT` tables,
1578    /// as `name → seq` — `seq` being the highest rowid ever assigned to that table
1579    /// (its monotonic INSERT high-water mark).
1580    ///
1581    /// `sqlite_sequence` exists **only** once at least one `AUTOINCREMENT` table
1582    /// has been created; a database with none returns an **empty** map (never a
1583    /// fabricated `seq = 0`), so a caller can distinguish "no high-water mark" from
1584    /// "high-water mark of 0". Best-effort, bounded, panic-free: an unreadable
1585    /// `sqlite_sequence` b-tree, or a malformed row, is omitted rather than
1586    /// erroring. Note `sqlite_sequence` is a mutable user table — `seq` tracks the
1587    /// INSERT high-water mark, not live rowid assignment — so this is a forensic
1588    /// HINT input, not proof of any row's provenance.
1589    #[must_use]
1590    pub fn sqlite_sequence(&self) -> std::collections::BTreeMap<String, i64> {
1591        let mut map = std::collections::BTreeMap::new();
1592        let Ok(schema) = self.read_table(1, 5) else {
1593            return map; // cov:unreachable: a validly-opened DB has a readable page-1 schema
1594        };
1595        // Locate the sqlite_sequence table's rootpage from the schema.
1596        let mut rootpage: Option<u32> = None;
1597        for row in &schema {
1598            let is_table = matches!(row.values.first(), Some(Value::Text(t)) if t == "table");
1599            if !is_table {
1600                continue;
1601            }
1602            if !matches!(row.values.get(1), Some(Value::Text(n)) if n == "sqlite_sequence") {
1603                continue;
1604            }
1605            if let Some(Value::Integer(root)) = row.values.get(3) {
1606                rootpage = u32::try_from(*root).ok();
1607            }
1608            break;
1609        }
1610        let Some(root) = rootpage else {
1611            return map; // no AUTOINCREMENT table ⟹ no sqlite_sequence ⟹ empty
1612        };
1613        let Ok(rows) = self.read_table(root, 2) else {
1614            return map; // cov:unreachable: a present sqlite_sequence has a readable b-tree
1615        };
1616        for row in rows {
1617            // sqlite_sequence row: (name TEXT, seq INTEGER). A malformed row (wrong
1618            // types) is skipped — never a fabricated entry.
1619            let (Some(Value::Text(name)), Some(Value::Integer(seq))) =
1620                (row.values.first(), row.values.get(1))
1621            else {
1622                continue;
1623            };
1624            map.insert(name.clone(), *seq);
1625        }
1626        map
1627    }
1628
1629    /// Dump every live user table for export: name, header columns, and all live
1630    /// rows in rowid order. The base layer the combined live + recovered workbook
1631    /// is built over.
1632    ///
1633    /// For each [`Database::live_tables`] entry, the b-tree is read via
1634    /// [`Database::read_table`] (so rows arrive in ascending-rowid b-tree order).
1635    /// The header is the table's **real** column names when the schema parse was
1636    /// confident, otherwise generic `c0..c{N-1}` sized to the widest row — a
1637    /// header is always present and never a fabricated name. Best-effort and
1638    /// panic-free: a table whose b-tree is unreadable contributes an empty row set
1639    /// rather than erroring.
1640    #[must_use]
1641    pub fn live_table_rows(&self) -> Vec<LiveTableDump> {
1642        self.live_tables()
1643            .into_iter()
1644            .map(|table| {
1645                // `read_table`'s column_count drives only the INTEGER PRIMARY KEY
1646                // rowid-alias rule; use the declared arity when known, else 0
1647                // (no alias substitution) so a low-confidence schema still dumps.
1648                let declared = table.column_names.as_ref().map_or(0, Vec::len);
1649                let rows = self
1650                    .read_table(table.rootpage, declared)
1651                    .unwrap_or_default();
1652                let widest = rows.iter().map(|r| r.values.len()).max().unwrap_or(0);
1653                let column_names = match table.column_names {
1654                    // Confident schema parse: use the table's real column names.
1655                    // Live rows legitimately omit trailing NULLs, so `widest` may
1656                    // be < declared — the real header still governs (a recovered
1657                    // row pads/truncates to it).
1658                    Some(names) => names,
1659                    // Low-confidence parse (malformed/unparseable CREATE TABLE):
1660                    // generic header sized to the widest row, never a fabricated
1661                    // real name. This is the schema-damage robustness guard.
1662                    None => (0..widest).map(|i| format!("c{i}")).collect(),
1663                };
1664                LiveTableDump {
1665                    name: table.name,
1666                    column_names,
1667                    rows,
1668                }
1669            })
1670            .collect()
1671    }
1672
1673    /// A map from each **allocated** page that belongs to a live table's b-tree
1674    /// to that table's name. Built by walking every live table's b-tree page set
1675    /// from its rootpage (interior + leaf pages). A page carved as Tier-1
1676    /// in-page residue resolves to its owning table through this map.
1677    ///
1678    /// Best-effort and bounded, mirroring `live_rowids`'s b-tree walk: a
1679    /// malformed b-tree contributes fewer entries rather than erroring.
1680    #[must_use]
1681    pub fn page_to_table_map(&self) -> std::collections::BTreeMap<u32, String> {
1682        let mut map = std::collections::BTreeMap::new();
1683        for table in self.live_tables() {
1684            let mut pages = std::collections::BTreeSet::new();
1685            let mut visited = 0usize;
1686            self.collect_pages(table.rootpage, &mut pages, &mut visited);
1687            for page in pages {
1688                map.insert(page, table.name.clone());
1689            }
1690        }
1691        map
1692    }
1693
1694    /// Walk the table b-tree rooted at `page`, inserting every page it visits
1695    /// (interior + leaf) into `pages`. Best-effort and bounded, mirroring
1696    /// `collect_rowids`.
1697    fn collect_pages(
1698        &self,
1699        page: u32,
1700        pages: &mut std::collections::BTreeSet<u32>,
1701        visited: &mut usize,
1702    ) {
1703        *visited += 1;
1704        if *visited > MAX_PAGES_PER_WALK {
1705            return; // cov:unreachable: test b-trees are far below the 1M-page cap
1706        }
1707        if page == 0 || !pages.insert(page) {
1708            return; // page 0 sentinel, or already visited (cycle guard)
1709        }
1710        let Ok(slice) = self.page_slice(page) else {
1711            return; // cov:unreachable: schema rootpages and their children are in range
1712        };
1713        let hdr_off = if page == 1 { SQLITE_HEADER_SIZE } else { 0 };
1714        let Some(&page_type) = slice.get(hdr_off) else {
1715            return; // cov:unreachable: a full page slice always has its header byte
1716        };
1717        if page_type != 0x05 {
1718            return; // leaf (0x0d) or non-interior: no children to descend
1719        }
1720        let cell_count = be_u16(slice, hdr_off + 3) as usize;
1721        let cell_ptr_array = hdr_off + 12;
1722        for i in 0..cell_count {
1723            let cell_off = be_u16(slice, cell_ptr_array + i * 2) as usize;
1724            let child = be_u32(slice, cell_off);
1725            self.collect_pages(child, pages, visited);
1726        }
1727        let right = be_u32(slice, hdr_off + 8);
1728        self.collect_pages(right, pages, visited);
1729    }
1730
1731    /// Walk the table b-tree rooted at `page`, decoding every live leaf cell's
1732    /// values (column count inferred per cell) into `rows` keyed by rowid.
1733    /// Best-effort and bounded, mirroring [`Database::collect_rowids`].
1734    fn collect_rows(
1735        &self,
1736        page: u32,
1737        rows: &mut std::collections::BTreeMap<i64, Vec<Value>>,
1738        seen: &mut std::collections::BTreeSet<u32>,
1739    ) {
1740        // Visit each page at most once. A manipulated interior left-child or
1741        // right-most pointer (anti-forensic corpus category 12) can point back
1742        // into an already-visited page, and a counter-only guard would still
1743        // recurse a million frames deep before stopping — a stack overflow. The
1744        // visited-set bounds recursion DEPTH to the number of distinct pages,
1745        // mirroring `collect_pages`'s cycle guard.
1746        if page == 0 || seen.len() > MAX_PAGES_PER_WALK || !seen.insert(page) {
1747            return;
1748        }
1749        let Ok(slice) = self.page_slice(page) else {
1750            return; // cov:unreachable: schema rootpages and their children are in range
1751        };
1752        let hdr_off = if page == 1 { SQLITE_HEADER_SIZE } else { 0 };
1753        let Some(&page_type) = slice.get(hdr_off) else {
1754            return; // cov:unreachable: a full page slice always has its header byte
1755        };
1756        let cell_count = be_u16(slice, hdr_off + 3) as usize;
1757        match page_type {
1758            0x0d => {
1759                let cell_ptr_array = hdr_off + 8;
1760                for i in 0..cell_count {
1761                    let cell_off = be_u16(slice, cell_ptr_array + i * 2) as usize;
1762                    // Decode the live cell with an inferred column count; on any
1763                    // parse hiccup (e.g. a table narrower than MIN_INFERRED_COLUMNS),
1764                    // fall back to the rowid alone (empty values) so the row is
1765                    // still known to be live.
1766                    if let Some(cell) =
1767                        try_carve_cell_at(slice, cell_off, None, self.header.text_encoding)
1768                    {
1769                        rows.insert(cell.rowid, cell.values);
1770                    } else if let Some(rowid) = live_cell_rowid(slice, cell_off) {
1771                        rows.entry(rowid).or_default(); // cov:unreachable: a >=2-col live cell always decodes above
1772                    }
1773                }
1774            }
1775            0x05 => {
1776                let cell_ptr_array = hdr_off + 12;
1777                for i in 0..cell_count {
1778                    let cell_off = be_u16(slice, cell_ptr_array + i * 2) as usize;
1779                    let child = be_u32(slice, cell_off);
1780                    self.collect_rows(child, rows, seen);
1781                }
1782                let right = be_u32(slice, hdr_off + 8);
1783                self.collect_rows(right, rows, seen);
1784            }
1785            _ => {} // cov:unreachable: a table b-tree root/child is leaf (0x0d) or interior (0x05)
1786        }
1787    }
1788
1789    /// Walk the table b-tree rooted at `page`, inserting every live leaf cell's
1790    /// rowid into `ids`. Best-effort and bounded: a malformed/cyclic structure
1791    /// stops the walk rather than erroring or looping.
1792    fn collect_rowids(
1793        &self,
1794        page: u32,
1795        ids: &mut std::collections::BTreeSet<i64>,
1796        seen: &mut std::collections::BTreeSet<u32>,
1797    ) {
1798        // Visit each page at most once (see `collect_rows` for the rationale): a
1799        // manipulated child pointer that revisits a page must not recurse
1800        // unboundedly. The visited-set bounds recursion depth to distinct pages.
1801        if page == 0 || seen.len() > MAX_PAGES_PER_WALK || !seen.insert(page) {
1802            return;
1803        }
1804        let Ok(slice) = self.page_slice(page) else {
1805            return; // cov:unreachable: schema rootpages and their children are in range
1806        };
1807        let hdr_off = if page == 1 { SQLITE_HEADER_SIZE } else { 0 };
1808        let Some(&page_type) = slice.get(hdr_off) else {
1809            return; // cov:unreachable: a full page slice always has its header byte
1810        };
1811        let cell_count = be_u16(slice, hdr_off + 3) as usize;
1812        match page_type {
1813            0x0d => {
1814                let cell_ptr_array = hdr_off + 8;
1815                for i in 0..cell_count {
1816                    let cell_off = be_u16(slice, cell_ptr_array + i * 2) as usize;
1817                    if let Some(rowid) = live_cell_rowid(slice, cell_off) {
1818                        ids.insert(rowid);
1819                    }
1820                }
1821            }
1822            0x05 => {
1823                let cell_ptr_array = hdr_off + 12;
1824                for i in 0..cell_count {
1825                    let cell_off = be_u16(slice, cell_ptr_array + i * 2) as usize;
1826                    let child = be_u32(slice, cell_off);
1827                    self.collect_rowids(child, ids, seen);
1828                }
1829                let right = be_u32(slice, hdr_off + 8);
1830                self.collect_rowids(right, ids, seen);
1831            }
1832            _ => {} // cov:unreachable: a table b-tree root/child is leaf (0x0d) or interior (0x05)
1833        }
1834    }
1835
1836    /// Walk a single table b-tree rooted at `root_page` (1-based) and collect
1837    /// every leaf row as typed values. `column_count` is the table's declared
1838    /// column count, used to apply the `INTEGER PRIMARY KEY` rowid-alias rule.
1839    ///
1840    /// Shares ONE b-tree/overflow walk with the snapshot-scoped read
1841    /// ([`CommitSnapshot::read_table`]) via an internal page-source abstraction, so
1842    /// the live and historical paths can never diverge.
1843    pub fn read_table(&self, root_page: u32, column_count: usize) -> Result<Vec<Row>, Error> {
1844        read_table_via(self, root_page, column_count)
1845    }
1846
1847    /// Bytes of the 1-based `page` number, or `PageOutOfRange`.
1848    ///
1849    /// When a WAL overlay is in effect and holds a committed version of this
1850    /// page, the overlaid bytes are returned in preference to the main file —
1851    /// this is what makes a table walk see the WAL-applied view. The main file
1852    /// is never mutated.
1853    fn page_slice(&self, page: u32) -> Result<&[u8], Error> {
1854        if page == 0 {
1855            return Err(Error::PageOutOfRange(0));
1856        }
1857        if let Some(wal) = &self.wal {
1858            if let Some(overlaid) = wal.pages.get(&page) {
1859                return Ok(overlaid.as_slice());
1860            }
1861        }
1862        let ps = self.header.page_size as usize;
1863        let start = (page as usize - 1) * ps;
1864        let end = start.checked_add(ps).ok_or(Error::PageOutOfRange(page))?;
1865        self.bytes
1866            .get(start..end)
1867            .ok_or(Error::PageOutOfRange(page))
1868    }
1869}
1870
1871/// A source of page images for the shared b-tree / overflow walk — the seam that
1872/// lets the live [`Database`] (main file ⊕ WAL overlay) and a historical
1873/// [`CommitSnapshot`] (materialized commit pages) share ONE table-read
1874/// implementation instead of forking parallel copies.
1875///
1876/// All page numbers are 1-based. Implementations resolve page 1 with the
1877/// 100-byte file header in place (so the walk reads the b-tree header at offset
1878/// `SQLITE_HEADER_SIZE` for page 1, 0 otherwise).
1879trait PageSource {
1880    /// The 1-based `page`'s full image, or `None` for page 0 / out of range.
1881    fn page(&self, page: u32) -> Option<&[u8]>;
1882    /// Usable bytes per page (`page_size` − reserved-space), for the overflow and
1883    /// local-payload computations.
1884    fn usable(&self) -> usize;
1885    /// The highest valid 1-based page number (the cycle/over-range bound).
1886    fn page_bound(&self) -> u32;
1887    /// The database text encoding, for decoding TEXT values.
1888    fn encoding(&self) -> TextEncoding;
1889}
1890
1891impl PageSource for Database {
1892    fn page(&self, page: u32) -> Option<&[u8]> {
1893        self.page_slice(page).ok()
1894    }
1895    fn usable(&self) -> usize {
1896        self.header.usable_size() as usize
1897    }
1898    fn page_bound(&self) -> u32 {
1899        self.file_page_count()
1900    }
1901    fn encoding(&self) -> TextEncoding {
1902        self.header.text_encoding
1903    }
1904}
1905
1906impl PageSource for CommitSnapshot {
1907    fn page(&self, page: u32) -> Option<&[u8]> {
1908        self.overlaid.get(&page).map(Vec::as_slice)
1909    }
1910    fn usable(&self) -> usize {
1911        self.usable as usize
1912    }
1913    fn page_bound(&self) -> u32 {
1914        // The committed page count at this snapshot — the cycle/over-range bound
1915        // for an overflow walk over the snapshot's materialized pages.
1916        self.id.db_size_after_commit
1917    }
1918    fn encoding(&self) -> TextEncoding {
1919        // Text encoding from the snapshot's OWN page-1 header (byte 56), so a
1920        // historical read decodes TEXT per the encoding as of this commit.
1921        self.overlaid
1922            .get(&1)
1923            .map(|p| match be_u32(p, TEXT_ENCODING_OFFSET) {
1924                2 => TextEncoding::Utf16Le,
1925                3 => TextEncoding::Utf16Be,
1926                _ => TextEncoding::Utf8,
1927            })
1928            .unwrap_or_default()
1929    }
1930}
1931
1932/// Walk a single table b-tree rooted at `root_page` over any [`PageSource`],
1933/// collecting every leaf row as typed values. The one implementation shared by
1934/// the live and snapshot-scoped reads.
1935/// Insert a `sqlite_master` row's `name -> CREATE SQL` into `out` when the row is
1936/// a **user** table (`type='table'`, name not `sqlite_*`). Shared by
1937/// [`Database::schema_sql`] and [`PriorSnapshot::schema_sql`] so the live and
1938/// prior reads classify schema rows identically. A row that is not a user-table
1939/// row (an index/view/trigger, an internal table, or a malformed row) is skipped.
1940fn schema_sql_insert(out: &mut std::collections::BTreeMap<String, String>, values: &[Value]) {
1941    // sqlite_master row: (type, name, tbl_name, rootpage, sql).
1942    let is_table = matches!(values.first(), Some(Value::Text(t)) if t == "table");
1943    if !is_table {
1944        return;
1945    }
1946    let Some(Value::Text(name)) = values.get(1) else {
1947        return; // cov:unreachable: a 'table' schema row has a TEXT name
1948    };
1949    if name.starts_with("sqlite_") {
1950        return;
1951    }
1952    let sql = match values.get(4) {
1953        Some(Value::Text(s)) => s.clone(),
1954        _ => String::new(), // cov:unreachable: a 'table' schema row carries its CREATE TABLE sql
1955    };
1956    out.insert(name.clone(), sql);
1957}
1958
1959fn read_table_via(
1960    src: &dyn PageSource,
1961    root_page: u32,
1962    column_count: usize,
1963) -> Result<Vec<Row>, Error> {
1964    let mut rows = Vec::new();
1965    let mut seen = std::collections::BTreeSet::new();
1966    walk_table_page(src, root_page, column_count, &mut rows, &mut seen)?;
1967    Ok(rows)
1968}
1969
1970fn walk_table_page(
1971    src: &dyn PageSource,
1972    page: u32,
1973    column_count: usize,
1974    rows: &mut Vec<Row>,
1975    seen: &mut std::collections::BTreeSet<u32>,
1976) -> Result<(), Error> {
1977    // Visit each page at most once. A manipulated interior child pointer
1978    // (anti-forensic corpus category 12) can revisit an already-walked page; a
1979    // counter-only guard still recurses up to the cap deep before stopping,
1980    // overflowing the stack. The visited-set bounds recursion DEPTH to the
1981    // number of distinct pages. A revisited page is silently skipped (Ok) so a
1982    // crafted cycle yields the partial-but-valid rows already collected rather
1983    // than an error.
1984    if seen.len() > MAX_PAGES_PER_WALK {
1985        return Err(Error::TooManyPages);
1986    }
1987    if !seen.insert(page) {
1988        return Ok(());
1989    }
1990    let slice = src.page(page).ok_or(Error::PageOutOfRange(page))?;
1991
1992    // Page 1 carries the 100-byte file header before its b-tree header.
1993    let hdr_off = if page == 1 { SQLITE_HEADER_SIZE } else { 0 };
1994
1995    let page_type = *slice.get(hdr_off).ok_or(Error::TruncatedCell)?;
1996    let cell_count = be_u16(slice, hdr_off + 3) as usize;
1997
1998    match page_type {
1999        0x0d => read_leaf_cells(src, slice, hdr_off, cell_count, column_count, rows),
2000        0x05 => {
2001            // Interior table page: 12-byte header; cell = 4-byte child ptr +
2002            // varint key. Recurse into every child plus the right-most ptr.
2003            let cell_ptr_array = hdr_off + 12;
2004            for i in 0..cell_count {
2005                let p = cell_ptr_array + i * 2;
2006                let cell_off = be_u16(slice, p) as usize;
2007                let child = be_u32(slice, cell_off);
2008                walk_table_page(src, child, column_count, rows, seen)?;
2009            }
2010            let right = be_u32(slice, hdr_off + 8);
2011            walk_table_page(src, right, column_count, rows, seen)
2012        }
2013        other => Err(Error::NotATablePage(other)),
2014    }
2015}
2016
2017fn read_leaf_cells(
2018    src: &dyn PageSource,
2019    slice: &[u8],
2020    hdr_off: usize,
2021    cell_count: usize,
2022    column_count: usize,
2023    rows: &mut Vec<Row>,
2024) -> Result<(), Error> {
2025    let cell_ptr_array = hdr_off + 8; // leaf b-tree header is 8 bytes
2026    for i in 0..cell_count {
2027        let p = cell_ptr_array + i * 2;
2028        let cell_off = be_u16(slice, p) as usize;
2029        let row = decode_leaf_cell(src, slice, cell_off, column_count)?;
2030        rows.push(row);
2031    }
2032    Ok(())
2033}
2034
2035/// Decode one table-leaf cell at `off` into a [`Row`], reassembling the payload
2036/// from its overflow-page chain (resolved through the SAME [`PageSource`]) when
2037/// it spills past the leaf page.
2038fn decode_leaf_cell(
2039    src: &dyn PageSource,
2040    slice: &[u8],
2041    off: usize,
2042    column_count: usize,
2043) -> Result<Row, Error> {
2044    let (payload_len, n1) = read_varint(slice, off)?;
2045    let (rowid, n2) = read_varint(slice, off + n1)?;
2046    let payload_start = off + n1 + n2;
2047    let total = usize::try_from(payload_len).map_err(|_| Error::TruncatedCell)?;
2048
2049    let usable = src.usable();
2050    let local = local_payload_len(total, usable);
2051
2052    let payload = if local >= total {
2053        // Whole payload is on the leaf page (no spill).
2054        slice
2055            .get(payload_start..payload_start + total)
2056            .ok_or(Error::TruncatedCell)?
2057            .to_vec()
2058    } else {
2059        // Spilled: `local` bytes on the leaf, then a 4-byte overflow page
2060        // pointer, then the remainder follows the overflow chain.
2061        let head = slice
2062            .get(payload_start..payload_start + local)
2063            .ok_or(Error::TruncatedCell)?;
2064        let first_overflow = be_u32(slice, payload_start + local);
2065        let mut buf = Vec::with_capacity(total);
2066        buf.extend_from_slice(head);
2067        read_overflow_chain(src, first_overflow, total - local, &mut buf)?;
2068        buf
2069    };
2070
2071    let values = decode_record(&payload, column_count, rowid, src.encoding())?;
2072    Ok(Row { rowid, values })
2073}
2074
2075/// Follow an overflow-page chain starting at `first` (1-based page number) over
2076/// a [`PageSource`], appending up to `remaining` payload bytes to `buf`. Each
2077/// overflow page is a 4-byte big-endian "next page" pointer (0 ends the chain)
2078/// followed by up to `usable - 4` content bytes.
2079///
2080/// Bounded against cyclic/over-long chains via [`Error::MalformedOverflow`].
2081fn read_overflow_chain(
2082    src: &dyn PageSource,
2083    first: u32,
2084    mut remaining: usize,
2085    buf: &mut Vec<u8>,
2086) -> Result<(), Error> {
2087    let usable = src.usable();
2088    let per_page = usable.saturating_sub(4);
2089    if per_page == 0 {
2090        return Err(Error::MalformedOverflow);
2091    }
2092    let total_pages = src.page_bound();
2093    let cap = total_pages as usize + 1;
2094
2095    let mut page = first;
2096    let mut visited = 0usize;
2097    while remaining > 0 {
2098        if page == 0 || page > total_pages {
2099            return Err(Error::MalformedOverflow);
2100        }
2101        visited += 1;
2102        if visited > cap {
2103            return Err(Error::MalformedOverflow);
2104        }
2105        let slice = src.page(page).ok_or(Error::PageOutOfRange(page))?;
2106        let next = be_u32(slice, 0);
2107        let take = remaining.min(per_page);
2108        let chunk = slice.get(4..4 + take).ok_or(Error::TruncatedCell)?;
2109        buf.extend_from_slice(chunk);
2110        remaining -= take;
2111        page = next;
2112    }
2113    Ok(())
2114}
2115
2116/// Number of payload bytes stored locally on a table-leaf page for a record of
2117/// `total` bytes, given the page's `usable` size (file-format §1.6 overflow
2118/// rule). When the return value equals `total`, the record does not spill.
2119pub(crate) fn local_payload_len(total: usize, usable: usize) -> usize {
2120    let max_local = usable - 35; // X: largest payload kept entirely local
2121    if total <= max_local {
2122        return total;
2123    }
2124    let min_local = (usable - 12) * 32 / 255 - 23; // M
2125    let k = min_local + (total - min_local) % (usable - 4);
2126    if k <= max_local {
2127        k
2128    } else {
2129        min_local
2130    }
2131}
2132
2133impl WalOverlay {
2134    /// Parse a `-wal` sidecar into the newest committed page versions.
2135    ///
2136    /// Returns `Ok(None)` when `wal` is absent of a usable header / has no
2137    /// frames (a no-op overlay). Iterates frames in file order, accumulating the
2138    /// page data of each frame whose salt matches the WAL header; on reaching a
2139    /// COMMIT frame (`db_size_after_commit != 0`) the accumulated pages are
2140    /// promoted into the committed snapshot. Frames after the last commit are
2141    /// uncommitted and dropped. Bounds-checked and breadth-capped against a
2142    /// crafted WAL (a frame whose declared page data runs past the file ends the
2143    /// scan rather than panicking).
2144    fn parse(wal: &[u8], page_size: u32) -> Result<Option<Self>, Error> {
2145        use forensicnomicon::sqlite::{SQLITE_WAL_FRAME_HEADER_SIZE, SQLITE_WAL_HEADER_SIZE};
2146
2147        // No header → no overlay (treat a too-short WAL as empty, not an error:
2148        // a missing/zero-length sidecar is normal and must not fail the open).
2149        let Some(hdr) = wal.get(..SQLITE_WAL_HEADER_SIZE) else {
2150            return Ok(None);
2151        };
2152        let magic = be_u32(hdr, 0);
2153        if magic != WAL_MAGIC_BE && magic != WAL_MAGIC_LE {
2154            return Ok(None);
2155        }
2156        // The WAL records its own page size (offset 8); trust the DB header's
2157        // page size but require agreement to avoid mis-slicing frames.
2158        let wal_page_size = be_u32(hdr, 8);
2159        if wal_page_size != page_size {
2160            return Ok(None);
2161        }
2162        // WAL header layout (file-format §4.1): salt-1 at offset 16, salt-2 at
2163        // offset 20 (the two checksum words follow at 24 and 28).
2164        let salt1 = be_u32(hdr, 16);
2165        let salt2 = be_u32(hdr, 20);
2166
2167        let ps = page_size as usize;
2168        let frame_stride = SQLITE_WAL_FRAME_HEADER_SIZE + ps;
2169
2170        let mut committed: std::collections::BTreeMap<u32, Vec<u8>> =
2171            std::collections::BTreeMap::new();
2172        let mut pending: std::collections::BTreeMap<u32, Vec<u8>> =
2173            std::collections::BTreeMap::new();
2174        // Every committed frame's page image (file order), and the pending frames
2175        // not yet promoted by a COMMIT. Mirrors the page promotion above so
2176        // uncommitted trailing frames are dropped from BOTH the view and the carve.
2177        let mut frames: Vec<WalFramePage> = Vec::new();
2178        let mut pending_frames: Vec<WalFramePage> = Vec::new();
2179
2180        let mut off = SQLITE_WAL_HEADER_SIZE;
2181        // One frame per page in the file is the natural breadth cap; allow a
2182        // generous multiple for repeated rewrites, but keep it bounded.
2183        let max_frames = wal.len() / frame_stride + 1;
2184        let mut frame_no = 0usize;
2185
2186        while let Some(frame) = wal.get(off..off + frame_stride) {
2187            frame_no += 1;
2188            if frame_no > max_frames {
2189                break; // cov:unreachable: the slice walk already bounds frame_no
2190            }
2191            let page_no = be_u32(frame, 0);
2192            let db_size = be_u32(frame, 4);
2193            let fsalt1 = be_u32(frame, 8);
2194            let fsalt2 = be_u32(frame, 12);
2195            // A frame from a different checkpoint generation (salt mismatch) is
2196            // stale residue, not part of this WAL's live content — stop here.
2197            if fsalt1 != salt1 || fsalt2 != salt2 {
2198                break;
2199            }
2200            if page_no == 0 {
2201                break; // malformed frame; stop rather than mis-index
2202            }
2203            let data = frame
2204                .get(SQLITE_WAL_FRAME_HEADER_SIZE..)
2205                .ok_or(Error::TruncatedCell)?;
2206            pending.insert(page_no, data.to_vec());
2207            let is_commit = db_size != 0;
2208            pending_frames.push(WalFramePage {
2209                frame_index: frame_no - 1, // 0-based file order
2210                page_no,
2211                salt1,
2212                salt2,
2213                is_commit,
2214                page: data.to_vec(),
2215            });
2216
2217            if is_commit {
2218                // COMMIT frame: promote everything pending into the snapshot AND
2219                // into the committed frame list (keeping every frame, not just the
2220                // newest version of each page).
2221                for (p, d) in std::mem::take(&mut pending) {
2222                    committed.insert(p, d);
2223                }
2224                frames.append(&mut pending_frames);
2225            }
2226            off += frame_stride;
2227        }
2228
2229        if committed.is_empty() {
2230            Ok(None)
2231        } else {
2232            Ok(Some(WalOverlay {
2233                pages: committed,
2234                frames,
2235                raw: wal.to_vec(),
2236            }))
2237        }
2238    }
2239}
2240
2241// ===========================================================================
2242// Bespoke, format-exact WAL temporal model (task #55)
2243// ===========================================================================
2244//
2245// A `-wal` sidecar is NOT an open-ended event log. It is a BOUNDED SEGMENT under a
2246// single salt epoch: every live frame shares the WAL header's (salt1, salt2). A
2247// checkpoint reset renumbers frames and rolls the salts — a DISCONTINUITY, not a
2248// continuation. The only materializable database states are the COMMIT snapshots:
2249// the replay of all valid frames up to a commit frame. A frame BETWEEN commits is
2250// not independently materializable, so it is never surfaced as a snapshot. Tails
2251// past the last commit, or after a salt reset, are WAL residue — forensic leads,
2252// never committed history.
2253//
2254// This model is self-contained in sqlite-core. The future state-history-forensic
2255// [H] adapter attaches at the seam exposed here (WalLsn + CohortTopology +
2256// `checksums_are_tamper_evident`), but sqlite-core does NOT depend on it.
2257
2258/// Cap on the number of salt segments and frames the timeline parser will walk on a
2259/// crafted `-wal`, bounding work against an attacker-supplied file. A real WAL holds
2260/// one segment with at most a few frames per database page.
2261const MAX_WAL_SEGMENTS: usize = 1024;
2262
2263/// Identity of one salt epoch within a `-wal` file: its 0-based segment ordinal.
2264/// A fresh segment begins at file start and after every checkpoint salt reset.
2265#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
2266pub struct WalSegmentId(pub usize);
2267
2268/// One salt epoch of a `-wal` file — a single bounded segment.
2269///
2270/// A `-wal` is a bounded segment, not an open-ended log: every live frame here shares
2271/// `(salt1, salt2)`. A checkpoint reset (salt change + frame renumber) starts a NEW
2272/// `WalSegment`; it is a discontinuity, never another epoch of the same segment.
2273#[derive(Debug, Clone, PartialEq, Eq)]
2274pub struct WalSegment {
2275    /// This segment's ordinal within the WAL (0 = the segment at file start).
2276    pub id: WalSegmentId,
2277    /// WAL salt-1 (checkpoint generation), shared by every frame in the segment.
2278    pub salt1: u32,
2279    /// WAL salt-2 (checkpoint generation), shared by every frame in the segment.
2280    pub salt2: u32,
2281    /// Page size declared by the segment's frames (bytes).
2282    pub page_size: u32,
2283    /// Number of frames belonging to this segment.
2284    pub frame_count: usize,
2285    /// The checkpoint sequence number recorded in the WAL header (offset 12). For a
2286    /// segment discovered after a reset within the same file this is the header's
2287    /// value; per-segment sequence is otherwise not separately recorded.
2288    pub checkpoint_seq: u32,
2289}
2290
2291/// Address of a materializable database state: the replay of all valid frames up to
2292/// a COMMIT frame. `CommitId = (segment, commit_frame_index, db_size_after_commit)`.
2293#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
2294pub struct CommitId {
2295    /// The salt segment this commit belongs to.
2296    pub segment: WalSegmentId,
2297    /// 0-based file-order index of the COMMIT frame within the segment.
2298    pub commit_frame_index: usize,
2299    /// `db_size_after_commit` recorded in the COMMIT frame header — the database's
2300    /// page count once this commit is materialized.
2301    pub db_size_after_commit: u32,
2302}
2303
2304/// The salt-qualified log-sequence identity of a WAL position — the seam the future
2305/// `state-history-forensic` `[H]` adapter maps onto `LsnKind::SqliteWal`.
2306///
2307/// A bare `frame_index` is meaningless across checkpoint resets (frames renumber), so
2308/// ordering is ALWAYS qualified by `(salt1, salt2)`. The adapter must reconstruct
2309/// `LsnKind::SqliteWal { salt1, salt2, frame_index }` from exactly this triple — never
2310/// from a bare index.
2311#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
2312pub struct WalLsn {
2313    /// Salt-1 of the owning segment (checkpoint generation).
2314    pub salt1: u32,
2315    /// Salt-2 of the owning segment (checkpoint generation).
2316    pub salt2: u32,
2317    /// 0-based frame index within that segment.
2318    pub frame_index: usize,
2319}
2320
2321/// Topology of the temporal cohort the WAL exposes — the shape the `[H]` adapter maps
2322/// to `state-history-forensic::CohortTopology`.
2323#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2324pub enum CohortTopology {
2325    /// A single salt epoch: the commit snapshots form one linearly-ordered chain.
2326    LinearSegment,
2327    /// Multiple salt epochs (checkpoint resets) with no replay continuity between
2328    /// them — each segment is linear internally but the segments are disconnected.
2329    Disconnected,
2330}
2331
2332/// One page's image at a particular [`CommitSnapshot`].
2333#[derive(Debug, Clone, PartialEq, Eq)]
2334pub struct CommittedPageVersion {
2335    /// 1-based database page number.
2336    pub page_no: u32,
2337    /// The page's full image (`page_size` bytes) as of this commit.
2338    pub bytes: Vec<u8>,
2339}
2340
2341/// A materializable database state: the replay of all valid frames up to a COMMIT.
2342///
2343/// This is the ONLY independently-materializable WAL state. `page_version` resolves a
2344/// page to its image as of this commit (the newest frame ≤ this commit that rewrote
2345/// the page, else the acquired base image). A frame between commits is never a
2346/// snapshot.
2347#[derive(Debug, Clone, PartialEq, Eq)]
2348pub struct CommitSnapshot {
2349    id: CommitId,
2350    /// Salt-1 of the owning segment, carried so [`CommitSnapshot::lsn`] is
2351    /// self-contained without a back-reference to the segment.
2352    salt1: u32,
2353    /// Salt-2 of the owning segment.
2354    salt2: u32,
2355    /// The materialized page images at this commit: base image overlaid with every
2356    /// committed frame up to and including this commit (newest version per page),
2357    /// capped to `db_size_after_commit` pages. `page_version` reads from this map.
2358    overlaid: std::collections::BTreeMap<u32, Vec<u8>>,
2359    /// Whether the whole frame chain up to and including this commit's COMMIT frame
2360    /// passed the WAL cumulative checksum (file-format §4.2). `false` marks a commit
2361    /// the salt+commit-marker admission would otherwise accept but whose checksum
2362    /// chain is broken (post-reset residue, tampering, or corruption) — kept, not
2363    /// dropped, so the forensic layer can label it.
2364    checksum_valid: bool,
2365    /// Usable bytes per page (`page_size` − reserved), parsed from the snapshot's
2366    /// OWN page-1 header, so a snapshot-scoped read uses the reserved-space value
2367    /// as of this commit rather than the live database's.
2368    usable: u32,
2369}
2370
2371/// One user table as of a [`CommitSnapshot`] — its schema parsed from the
2372/// snapshot's OWN materialized page 1, NOT from the live database. A rootpage can
2373/// be dropped and reused by a different table across commits, so reading the
2374/// schema from the snapshot is the only correct way to interpret its b-trees.
2375#[derive(Debug, Clone, PartialEq, Eq)]
2376pub struct SnapshotTable {
2377    /// The table's `sqlite_master.name`.
2378    pub name: String,
2379    /// 1-based root page of the table's b-tree as of this commit.
2380    pub rootpage: u32,
2381    /// Parsed column names from the table's `CREATE TABLE`, in declared order.
2382    /// Empty when the schema SQL could not be parsed with confidence.
2383    pub columns: Vec<String>,
2384    /// Whether this is a `WITHOUT ROWID` table (file-format §2.4). Such a table
2385    /// uses an INDEX b-tree with no rowid key, so the rowid-based snapshot read
2386    /// does not apply — flagged so a caller never mis-reads it as a rowid table.
2387    pub without_rowid: bool,
2388}
2389
2390/// Whether a `CREATE TABLE` statement declares a `WITHOUT ROWID` table
2391/// (file-format §2.4). Detection keys off the trailing `WITHOUT ROWID` clause,
2392/// case-insensitively and tolerant of internal whitespace, while ignoring any
2393/// occurrence inside a quoted identifier/string so a column literally named
2394/// "without rowid" is not a false positive.
2395/// A `CREATE TABLE` statement with quoted spans removed and whitespace collapsed,
2396/// uppercased — so a clause search sees only unquoted SQL tokens. Strips
2397/// `'...'` / `"..."` / `` `...` `` / `[...]` spans (the four `SQLite` identifier /
2398/// string quotings) exactly as the clause detectors require, so the keyword
2399/// appearing inside a quoted identifier or string literal can never false-match.
2400fn normalized_unquoted_sql(create_sql: &str) -> String {
2401    let bytes = create_sql.as_bytes();
2402    let mut unquoted = String::with_capacity(create_sql.len());
2403    let mut quote: Option<u8> = None;
2404    for &c in bytes {
2405        match quote {
2406            Some(q) => {
2407                if c == q {
2408                    quote = None;
2409                }
2410            }
2411            None => match c {
2412                b'\'' | b'"' | b'`' => quote = Some(c),
2413                b'[' => quote = Some(b']'),
2414                _ => unquoted.push(c as char),
2415            },
2416        }
2417    }
2418    unquoted
2419        .split_whitespace()
2420        .collect::<Vec<_>>()
2421        .join(" ")
2422        .to_ascii_uppercase()
2423}
2424
2425fn without_rowid_sql(create_sql: &str) -> bool {
2426    // Look for the clause as a discrete token sequence, ignoring quoted spans and
2427    // case/whitespace (file-format §2.4).
2428    normalized_unquoted_sql(create_sql).contains("WITHOUT ROWID")
2429}
2430
2431/// Whether `create_sql` declares an ordinary rowid table with an
2432/// `INTEGER PRIMARY KEY AUTOINCREMENT` column — the only form for which `SQLite`
2433/// maintains a monotonic `sqlite_sequence` high-water mark.
2434///
2435/// Per the file format, `AUTOINCREMENT` is valid **only** immediately after
2436/// `INTEGER PRIMARY KEY`, and **never** on a `WITHOUT ROWID` table (which has no
2437/// rowid to auto-increment). So this is true iff the normalized, unquoted CREATE
2438/// text contains the exact token run `INTEGER PRIMARY KEY AUTOINCREMENT` and does
2439/// NOT carry the `WITHOUT ROWID` clause. Quoted identifiers / string literals /
2440/// comments are stripped first (mirroring `without_rowid_sql`), so a column
2441/// merely named `"autoincrement"`, or the keyword inside a string, never matches.
2442///
2443/// This is a HINT input only: a true result means the table has an AUTOINCREMENT
2444/// high-water mark the forensic layer can reconcile against, not that any
2445/// particular row predates the current instance.
2446#[must_use]
2447pub fn is_autoincrement(create_sql: &str) -> bool {
2448    let normalized = normalized_unquoted_sql(create_sql);
2449    normalized.contains("INTEGER PRIMARY KEY AUTOINCREMENT")
2450        && !normalized.contains("WITHOUT ROWID")
2451}
2452
2453impl CommitSnapshot {
2454    /// This snapshot's [`CommitId`].
2455    #[must_use]
2456    pub fn id(&self) -> CommitId {
2457        self.id
2458    }
2459
2460    /// The database page count once this commit is materialized.
2461    #[must_use]
2462    pub fn db_size_after_commit(&self) -> u32 {
2463        self.id.db_size_after_commit
2464    }
2465
2466    /// Whether the WAL frame chain up to and including this commit's COMMIT frame
2467    /// validated against the cumulative WAL checksum (file-format §4.2).
2468    ///
2469    /// `true` is the spec-conformant case: every frame's stored `(checksum1,
2470    /// checksum2)` equalled the running checksum advanced over the frame's first
2471    /// 8 header bytes plus its full page data, seeded from the WAL header
2472    /// checksum. `false` means the chain broke at or before this commit — the
2473    /// salt + commit-marker admission accepted it, but it is residue (post-reset
2474    /// leftover, tampering, or corruption). Such a commit is deliberately KEPT
2475    /// (not dropped) so the forensic layer can mark it; a consumer that wants only
2476    /// trustworthy state filters on this flag.
2477    #[must_use]
2478    pub fn checksum_valid(&self) -> bool {
2479        self.checksum_valid
2480    }
2481
2482    /// The salt-qualified [`WalLsn`] of this commit (the `[H]` adapter seam).
2483    #[must_use]
2484    pub fn lsn(&self) -> WalLsn {
2485        WalLsn {
2486            salt1: self.salt1,
2487            salt2: self.salt2,
2488            frame_index: self.id.commit_frame_index,
2489        }
2490    }
2491
2492    /// The 1-based page numbers this commit materialized (base ∪ committed frames
2493    /// up to this commit, capped to `db_size_after_commit`), ascending.
2494    ///
2495    /// The carve-at-snapshot primitive iterates these to drive the carving
2496    /// primitives over each page image, WITHOUT assuming the pages form a
2497    /// contiguous `1..=db_size` range (a truncating commit or a sparse base image
2498    /// can leave gaps). Every returned page resolves via [`Self::page_version`].
2499    #[must_use]
2500    pub fn page_numbers(&self) -> Vec<u32> {
2501        self.overlaid.keys().copied().collect()
2502    }
2503
2504    /// The image of `page_no` as of this commit, or `None` for a page beyond the
2505    /// committed database size that the WAL never rewrote.
2506    #[must_use]
2507    pub fn page_version(&self, page_no: u32) -> Option<CommittedPageVersion> {
2508        let bytes = self.overlaid.get(&page_no)?.clone();
2509        Some(CommittedPageVersion { page_no, bytes })
2510    }
2511
2512    /// The user tables AS OF this commit, parsed from the snapshot's OWN page 1
2513    /// (the `sqlite_master` b-tree), NOT from the live database.
2514    ///
2515    /// A rootpage can be dropped and reused by a different table across commits,
2516    /// so the schema MUST come from the snapshot itself — reading today's live
2517    /// schema would mis-attribute a historical b-tree. Returns one
2518    /// [`SnapshotTable`] per `type='table'` row whose name is not an internal
2519    /// `sqlite_*` table, carrying its rootpage, parsed column names, and a
2520    /// `WITHOUT ROWID` flag (file-format §2.4). Best-effort and panic-free: an
2521    /// unreadable page-1 schema yields an empty vector.
2522    #[must_use]
2523    pub fn tables(&self) -> Vec<SnapshotTable> {
2524        // sqlite_master is a 5-column table rooted at page 1:
2525        // (type, name, tbl_name, rootpage, sql). Walk it through THIS snapshot's
2526        // pages via the shared b-tree reader.
2527        let Ok(schema) = read_table_via(self, 1, 5) else {
2528            return Vec::new(); // cov:unreachable: a committed snapshot has a readable page 1
2529        };
2530        let mut out = Vec::new();
2531        for row in schema {
2532            let is_table = matches!(row.values.first(), Some(Value::Text(t)) if t == "table");
2533            if !is_table {
2534                continue;
2535            }
2536            let Some(Value::Text(name)) = row.values.get(1) else {
2537                continue; // cov:unreachable: a 'table' schema row has a TEXT name
2538            };
2539            if name.starts_with("sqlite_") {
2540                continue;
2541            }
2542            let Some(Value::Integer(root)) = row.values.get(3) else {
2543                continue; // cov:unreachable: a 'table' schema row has an integer rootpage
2544            };
2545            let Ok(rootpage) = u32::try_from(*root) else {
2546                continue; // cov:unreachable: a real rootpage is a small positive page number
2547            };
2548            let sql = match row.values.get(4) {
2549                Some(Value::Text(s)) => s.as_str(),
2550                _ => "", // cov:unreachable: a 'table' schema row carries its CREATE TABLE sql
2551            };
2552            let columns = attribution::column_names(sql).unwrap_or_default();
2553            out.push(SnapshotTable {
2554                name: name.clone(),
2555                rootpage,
2556                columns,
2557                without_rowid: without_rowid_sql(sql),
2558            });
2559        }
2560        out
2561    }
2562
2563    /// Read every row of the table b-tree rooted at `rootpage` AS OF this commit,
2564    /// resolving overflow chains through the snapshot's OWN materialized pages, in
2565    /// rowid order.
2566    ///
2567    /// This is the snapshot-scoped counterpart to [`Database::read_table`]: it
2568    /// shares the SAME b-tree/overflow walk via an internal page-source
2569    /// abstraction, so a large row
2570    /// decodes with the page content as of this commit (not stale/future content
2571    /// the live view would supply). `column_count` drives only the
2572    /// `INTEGER PRIMARY KEY` rowid-alias rule (pass the table's declared arity,
2573    /// e.g. `SnapshotTable::columns.len()`). Returns `(rowid, values)` per row.
2574    ///
2575    /// Bounded and panic-free on hostile input, exactly as the live path: a
2576    /// cyclic/over-deep b-tree or overflow chain surfaces a typed [`Error`] rather
2577    /// than looping or panicking.
2578    pub fn read_table(
2579        &self,
2580        rootpage: u32,
2581        column_count: usize,
2582    ) -> Result<Vec<(i64, Vec<Value>)>, Error> {
2583        let rows = read_table_via(self, rootpage, column_count)?;
2584        Ok(rows.into_iter().map(|r| (r.rowid, r.values)).collect())
2585    }
2586}
2587
2588/// A page-level delta between two materialized states.
2589#[derive(Debug, Clone, PartialEq, Eq)]
2590pub struct WalDiff {
2591    changed: Vec<u32>,
2592}
2593
2594impl WalDiff {
2595    /// The 1-based page numbers whose bytes differ between the two states, ascending.
2596    #[must_use]
2597    pub fn changed_pages(&self) -> &[u32] {
2598        &self.changed
2599    }
2600}
2601
2602/// A stale WAL tail surfaced for forensics — NOT committed history.
2603///
2604/// Frames past the last COMMIT of a segment, frames after a salt reset that cannot be
2605/// replayed into the current segment, or a header/page-size break: all are residue.
2606/// The examiner weighs them; they are never part of a consistent snapshot.
2607#[derive(Debug, Clone, PartialEq, Eq)]
2608pub struct WalResidue {
2609    /// The segment the residue trails (the segment whose last commit it follows).
2610    pub segment: WalSegmentId,
2611    /// 0-based frame index (within the file) of the first residual frame.
2612    pub first_frame_index: usize,
2613    /// Number of residual frames.
2614    pub frame_count: usize,
2615    /// Why these frames are residue rather than committed history.
2616    pub reason: ResidueReason,
2617}
2618
2619/// Why a WAL tail is [`WalResidue`] (an invalidated-frame candidate), not history.
2620#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2621pub enum ResidueReason {
2622    /// Frames written after the segment's last COMMIT (uncommitted tail).
2623    BeyondLastCommit,
2624    /// Frames whose salt no longer matches the segment header (post-reset residue).
2625    SaltReset,
2626}
2627
2628/// Validation tier a WAL has cleared — strictly increasing assurance.
2629///
2630/// `PhysicalValidation` < `CommitValidation` < `ReplaySafe`. The timeline reports the
2631/// highest tier reached; a page-size mismatch never even produces a timeline (it is a
2632/// hard stop at parse, surfaced as [`WalValidationError::PageSizeMismatch`]).
2633#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
2634pub enum MaterializationSafety {
2635    /// Header magic / format / page-size / salts / frame boundaries are well-formed,
2636    /// but no committed snapshot was found (nothing to replay).
2637    PhysicalValidated,
2638    /// A last valid commit and committed frame ranges were established, but the
2639    /// read-only replay overlay was not (or could not be) built.
2640    CommitValidated,
2641    /// A read-only replay overlay to the last commit is available — safe to
2642    /// materialize without mutating either file.
2643    ReplaySafe,
2644}
2645
2646/// A WAL that cannot be admitted to the timeline at all (physical-validation hard
2647/// stops). Distinct from "no committed snapshot", which is a valid empty timeline.
2648#[derive(Debug, Clone, PartialEq, Eq)]
2649pub enum WalValidationError {
2650    /// The `-wal` is shorter than its 32-byte header, or carries the wrong magic.
2651    BadMagic,
2652    /// The WAL header's page size disagrees with the DB header's — a HARD STOP, since
2653    /// every frame would be mis-sliced. `db` and `wal` are the two declared sizes.
2654    PageSizeMismatch { db: u32, wal: u32 },
2655    /// The main database header itself failed to parse.
2656    Header(Error),
2657}
2658
2659/// The bespoke, format-exact temporal model of a `-wal` sidecar.
2660///
2661/// Enumerates the salt segments, the materializable [`CommitSnapshot`]s within them
2662/// (CommitId-addressable), and the [`WalResidue`] tails. Materialize a snapshot's page
2663/// images via [`CommitSnapshot::page_version`]; diff the acquired base against the last
2664/// valid commit via [`WalTimeline::diff_base_to_last_commit`].
2665#[derive(Debug, Clone, PartialEq, Eq)]
2666pub struct WalTimeline {
2667    page_size: u32,
2668    base_pages: std::collections::BTreeMap<u32, Vec<u8>>,
2669    segments: Vec<WalSegment>,
2670    snapshots: Vec<CommitSnapshot>,
2671    residue: Vec<WalResidue>,
2672    safety: MaterializationSafety,
2673}
2674
2675impl WalTimeline {
2676    /// Physical-validation tier: header magic + format check.
2677    ///
2678    /// Parses `bytes` (the acquired main DB) and `wal` (the `-wal` sidecar) into the
2679    /// segmented temporal model. A page-size mismatch between the DB header and the
2680    /// WAL header is a HARD STOP; a bad/short header is [`WalValidationError::BadMagic`].
2681    fn parse(bytes: &[u8], wal: &[u8], page_size: u32) -> Result<Self, WalValidationError> {
2682        use forensicnomicon::sqlite::{SQLITE_WAL_FRAME_HEADER_SIZE, SQLITE_WAL_HEADER_SIZE};
2683
2684        // --- PhysicalValidation: header magic / format / page-size / salts -------
2685        let hdr = wal
2686            .get(..SQLITE_WAL_HEADER_SIZE)
2687            .ok_or(WalValidationError::BadMagic)?;
2688        let magic = be_u32(hdr, 0);
2689        if magic != WAL_MAGIC_BE && magic != WAL_MAGIC_LE {
2690            return Err(WalValidationError::BadMagic);
2691        }
2692        let wal_page_size = be_u32(hdr, 8);
2693        if wal_page_size != page_size {
2694            return Err(WalValidationError::PageSizeMismatch {
2695                db: page_size,
2696                wal: wal_page_size,
2697            });
2698        }
2699        let checkpoint_seq = be_u32(hdr, 12);
2700        let mut salt1 = be_u32(hdr, 16);
2701        let mut salt2 = be_u32(hdr, 20);
2702
2703        // Checksum chain seed (file-format §4.2): the running (s0, s1) starts from
2704        // the WAL header's stored checksum (bytes 24..32, always big-endian),
2705        // which is itself the checksum over the first 24 header bytes. The word
2706        // endianness for advancing over frames comes from the magic. `from_magic`
2707        // cannot return None here — the magic was admitted above.
2708        let endian = WalChecksumEndian::from_magic(magic).unwrap_or(WalChecksumEndian::Big);
2709        let header_s0 = be_u32(hdr, 24);
2710        let header_s1 = be_u32(hdr, 28);
2711        // Per-segment running checksum state and whether the chain is still valid.
2712        let mut run_s0 = header_s0;
2713        let mut run_s1 = header_s1;
2714        let mut chain_valid = true;
2715
2716        let ps = page_size as usize;
2717        let frame_stride = SQLITE_WAL_FRAME_HEADER_SIZE + ps;
2718
2719        // The acquired main DB image: the pre-WAL base for replay within the current
2720        // validated segment (NOT "epoch 0" — just the base each commit overlays onto).
2721        let mut base_pages: std::collections::BTreeMap<u32, Vec<u8>> =
2722            std::collections::BTreeMap::new();
2723        // `chunks_exact` yields only whole pages (infallible by construction — no
2724        // out-of-bounds slice to guard); cap at `u32::MAX` pages so the 1-based page
2725        // number never overflows on a pathologically large image.
2726        for (idx, page) in bytes
2727            .chunks_exact(ps)
2728            .take(u32::MAX as usize - 1)
2729            .enumerate()
2730        {
2731            let pno = idx as u32 + 1; // 1-based page number
2732            base_pages.insert(pno, page.to_vec());
2733        }
2734
2735        let mut segments: Vec<WalSegment> = Vec::new();
2736        let mut snapshots: Vec<CommitSnapshot> = Vec::new();
2737        let mut residue: Vec<WalResidue> = Vec::new();
2738
2739        // Per-segment running state.
2740        let mut seg_ordinal = 0usize;
2741        let mut seg_frame_count = 0usize;
2742        // Cumulative newest-page map across all COMMITTED frames of the segment, so a
2743        // snapshot's `overlaid` is base ∪ committed-up-to-this-commit.
2744        let mut committed_pages: std::collections::BTreeMap<u32, Vec<u8>> = base_pages.clone();
2745        let mut pending: std::collections::BTreeMap<u32, Vec<u8>> =
2746            std::collections::BTreeMap::new();
2747        let mut last_commit_global_frame: Option<usize> = None;
2748        let mut uncommitted_tail_start: Option<usize> = None;
2749
2750        let mut off = SQLITE_WAL_HEADER_SIZE;
2751        let max_frames = wal.len() / frame_stride + 1;
2752        let mut frame_no = 0usize;
2753
2754        while let Some(frame) = wal.get(off..off + frame_stride) {
2755            if frame_no >= max_frames {
2756                break; // cov:unreachable: the slice walk already bounds frame_no
2757            }
2758            let page_no = be_u32(frame, 0);
2759            let db_size = be_u32(frame, 4);
2760            let fsalt1 = be_u32(frame, 8);
2761            let fsalt2 = be_u32(frame, 12);
2762
2763            // A salt change opens a NEW segment (checkpoint reset = discontinuity).
2764            // Anything between the prior segment's last commit and here is residue.
2765            if fsalt1 != salt1 || fsalt2 != salt2 {
2766                if segments.len() >= MAX_WAL_SEGMENTS {
2767                    break; // cov:unreachable: real WALs hold far fewer than 1024 salt epochs
2768                }
2769                // Close the current segment, recording its residue tail (if any).
2770                Self::close_segment(
2771                    &mut segments,
2772                    &mut residue,
2773                    WalSegmentId(seg_ordinal),
2774                    salt1,
2775                    salt2,
2776                    page_size,
2777                    checkpoint_seq,
2778                    seg_frame_count,
2779                    uncommitted_tail_start,
2780                );
2781                // Begin the next segment under the new salts. Its base for replay is
2782                // the prior committed view (a checkpoint would have flushed it, but on
2783                // a forensic image we keep what we can replay).
2784                seg_ordinal += 1;
2785                salt1 = fsalt1;
2786                salt2 = fsalt2;
2787                seg_frame_count = 0;
2788                pending.clear();
2789                uncommitted_tail_start = None;
2790                // The post-reset frames replay onto the latest committed view.
2791                // committed_pages carries forward.
2792                // The checksum chain for a post-reset segment threads from a WAL
2793                // header we do NOT hold (the new generation's own 32-byte header
2794                // was overwritten), so its frames cannot be validated against our
2795                // seed. Mark the chain broken for this segment: its commits are
2796                // checksum-residue, surfaced for forensics but not trusted.
2797                chain_valid = false;
2798            }
2799
2800            if page_no == 0 {
2801                break; // malformed frame; stop rather than mis-index
2802            }
2803            let data = match frame.get(SQLITE_WAL_FRAME_HEADER_SIZE..) {
2804                Some(d) => d.to_vec(),
2805                None => break, // cov:unreachable: frame slice is exactly frame_stride
2806            };
2807
2808            // Advance the cumulative checksum over this frame (file-format §4.2):
2809            // the first 8 bytes of the frame header (page-no ++ db-size) followed
2810            // by the full page data — NOT the salt/checksum bytes (frame[8..24]).
2811            // Then compare against the frame's stored checksum (frame[16..24], big-
2812            // endian). A mismatch breaks the chain for the rest of the segment.
2813            // Only advance while the chain is still intact (a post-reset segment is
2814            // pre-marked broken and is not re-seedable from our header).
2815            if chain_valid {
2816                let (n0, n1) = wal_checksum(endian, run_s0, run_s1, &frame[0..8]);
2817                let (n0, n1) = wal_checksum(endian, n0, n1, &data);
2818                run_s0 = n0;
2819                run_s1 = n1;
2820                let stored0 = be_u32(frame, 16);
2821                let stored1 = be_u32(frame, 20);
2822                if stored0 != run_s0 || stored1 != run_s1 {
2823                    chain_valid = false;
2824                }
2825            }
2826
2827            let frame_index_in_seg = seg_frame_count;
2828            seg_frame_count += 1;
2829            pending.insert(page_no, data);
2830            let is_commit = db_size != 0;
2831
2832            if is_commit {
2833                for (p, d) in std::mem::take(&mut pending) {
2834                    committed_pages.insert(p, d);
2835                }
2836                // Drop base/committed pages beyond the committed size so a snapshot
2837                // reflects the database's page count at that commit. `db_size` is
2838                // non-zero here (that is what makes this a COMMIT frame).
2839                committed_pages.retain(|&p, _| p <= db_size);
2840                let id = CommitId {
2841                    segment: WalSegmentId(seg_ordinal),
2842                    commit_frame_index: frame_index_in_seg,
2843                    db_size_after_commit: db_size,
2844                };
2845                let overlaid = committed_pages.clone();
2846                // Usable bytes per page from the snapshot's OWN page-1 header
2847                // (reserved-space byte at offset 20), so a snapshot-scoped read
2848                // honors the reserved value as of this commit. Page 1 is always
2849                // materialized; a missing/short page-1 image degrades to 0 reserved.
2850                let reserved = overlaid
2851                    .get(&1)
2852                    .and_then(|p| p.get(RESERVED_SPACE_OFFSET).copied())
2853                    .unwrap_or(0);
2854                let usable = page_size.saturating_sub(u32::from(reserved));
2855                snapshots.push(CommitSnapshot {
2856                    id,
2857                    overlaid,
2858                    salt1,
2859                    salt2,
2860                    checksum_valid: chain_valid,
2861                    usable,
2862                });
2863                last_commit_global_frame = Some(frame_no);
2864                uncommitted_tail_start = None;
2865            } else if uncommitted_tail_start.is_none() {
2866                uncommitted_tail_start = Some(frame_index_in_seg);
2867            }
2868
2869            frame_no += 1;
2870            off += frame_stride;
2871        }
2872
2873        // Close the final segment (it may have an uncommitted tail).
2874        Self::close_segment(
2875            &mut segments,
2876            &mut residue,
2877            WalSegmentId(seg_ordinal),
2878            salt1,
2879            salt2,
2880            page_size,
2881            checkpoint_seq,
2882            seg_frame_count,
2883            uncommitted_tail_start,
2884        );
2885
2886        let safety = if snapshots.is_empty() {
2887            MaterializationSafety::PhysicalValidated
2888        } else if last_commit_global_frame.is_some() {
2889            MaterializationSafety::ReplaySafe
2890        } else {
2891            MaterializationSafety::CommitValidated // cov:unreachable: a snapshot implies a commit
2892        };
2893
2894        Ok(Self {
2895            page_size,
2896            base_pages,
2897            segments,
2898            snapshots,
2899            residue,
2900            safety,
2901        })
2902    }
2903
2904    #[allow(clippy::too_many_arguments)]
2905    fn close_segment(
2906        segments: &mut Vec<WalSegment>,
2907        residue: &mut Vec<WalResidue>,
2908        id: WalSegmentId,
2909        salt1: u32,
2910        salt2: u32,
2911        page_size: u32,
2912        checkpoint_seq: u32,
2913        frame_count: usize,
2914        uncommitted_tail_start: Option<usize>,
2915    ) {
2916        if frame_count == 0 {
2917            return;
2918        }
2919        segments.push(WalSegment {
2920            id,
2921            salt1,
2922            salt2,
2923            page_size,
2924            frame_count,
2925            checkpoint_seq,
2926        });
2927        if let Some(start) = uncommitted_tail_start {
2928            residue.push(WalResidue {
2929                segment: id,
2930                first_frame_index: start,
2931                frame_count: frame_count - start,
2932                reason: ResidueReason::BeyondLastCommit,
2933            });
2934        }
2935    }
2936
2937    /// The salt segments of this WAL, in file order (one per salt epoch).
2938    #[must_use]
2939    pub fn segments(&self) -> &[WalSegment] {
2940        &self.segments
2941    }
2942
2943    /// Every materializable [`CommitSnapshot`] across all segments, in commit order.
2944    #[must_use]
2945    pub fn commit_snapshots(&self) -> &[CommitSnapshot] {
2946        &self.snapshots
2947    }
2948
2949    /// The stale WAL tails surfaced for forensics (not committed history).
2950    #[must_use]
2951    pub fn residue(&self) -> &[WalResidue] {
2952        &self.residue
2953    }
2954
2955    /// Resolve a [`CommitId`] back to its [`CommitSnapshot`].
2956    #[must_use]
2957    pub fn snapshot_at(&self, id: CommitId) -> Option<&CommitSnapshot> {
2958        self.snapshots.iter().find(|s| s.id == id)
2959    }
2960
2961    /// The highest validation tier this WAL cleared (see [`MaterializationSafety`]).
2962    #[must_use]
2963    pub fn safety(&self) -> MaterializationSafety {
2964        self.safety
2965    }
2966
2967    /// The temporal-cohort topology — `LinearSegment` for one salt epoch, else
2968    /// `Disconnected` across checkpoint resets. The `[H]` adapter maps this onto
2969    /// `state-history-forensic::CohortTopology`.
2970    #[must_use]
2971    pub fn topology(&self) -> CohortTopology {
2972        if self.segments.len() <= 1 {
2973            CohortTopology::LinearSegment
2974        } else {
2975            CohortTopology::Disconnected
2976        }
2977    }
2978
2979    /// Whether the WAL's integrity checks are tamper-EVIDENT. Always `false`: WAL
2980    /// frame checksums are non-cryptographic (corruption detection, not tamper proof),
2981    /// so the `[H]` adapter must record `tamper_resistance = LOW`.
2982    #[must_use]
2983    pub fn checksums_are_tamper_evident(&self) -> bool {
2984        false
2985    }
2986
2987    /// Diff the acquired base image against the last valid commit snapshot, returning
2988    /// the page numbers whose bytes changed. `None` when there is no committed snapshot.
2989    #[must_use]
2990    pub fn diff_base_to_last_commit(&self) -> Option<WalDiff> {
2991        let last = self.snapshots.last()?;
2992        let mut changed = Vec::new();
2993        let mut pages: std::collections::BTreeSet<u32> = std::collections::BTreeSet::new();
2994        pages.extend(self.base_pages.keys().copied());
2995        pages.extend(last.overlaid.keys().copied());
2996        for p in pages {
2997            let base = self.base_pages.get(&p);
2998            let now = last.overlaid.get(&p);
2999            if base != now {
3000                changed.push(p);
3001            }
3002        }
3003        Some(WalDiff { changed })
3004    }
3005
3006    /// The page size (bytes) common to the base image and the WAL frames.
3007    #[must_use]
3008    pub fn page_size(&self) -> u32 {
3009        self.page_size
3010    }
3011
3012    /// Map this WAL timeline onto the canonical `forensicnomicon::history` cohort
3013    /// vocabulary — the `[H]` adapter (#43 / WS-F).
3014    ///
3015    /// Each materializable [`CommitSnapshot`] becomes one `TemporalState<CommitId>`:
3016    /// - **ordering key** — a salt-qualified `LsnKind::SqliteWalFrame` (`frame_seq` is the
3017    ///   COMMIT frame index; `commit_seq` is the 0-based commit ordinal within the salt
3018    ///   segment). The `(salt1, salt2)` pair keeps the key meaningful across a checkpoint
3019    ///   reset, which renumbers frames and rolls the salts.
3020    /// - **clock + safety** — the canonical SQLite-WAL profile, single-sourced from
3021    ///   [`forensicnomicon::history::profiles`], so no consumer re-asserts the four
3022    ///   classifications locally.
3023    /// - **handle** — the snapshot's [`CommitId`]; resolve it back via [`Self::snapshot_at`].
3024    ///
3025    /// The topology is uniformly `SubJournalCommits`: every state is a committed
3026    /// transaction, and a checkpoint reset is visible as a salt change *inside* the
3027    /// ordering key — there is no separate "disconnected" topology to special-case. The
3028    /// cohort is `PathStable` (a `-wal` belongs to exactly one database path), so the
3029    /// caller supplies the path identity via `artifact`.
3030    #[must_use]
3031    pub fn to_temporal_cohort(
3032        &self,
3033        artifact: forensicnomicon::history::identity::ArtifactRef,
3034    ) -> forensicnomicon::history::cohort::TemporalCohort<CommitId> {
3035        use forensicnomicon::history::cohort::{TemporalCohort, TemporalState};
3036        use forensicnomicon::history::epoch::{CohortTopology, EpochTag, LsnKind};
3037        use forensicnomicon::history::identity::IdentityDiscipline;
3038        use forensicnomicon::history::profiles;
3039
3040        // One canonical profile drives every state's clock + safety — read from
3041        // forensicnomicon, never re-asserted here, so the fleet cannot drift.
3042        let profile = profiles::SourceTemporalProfile::sqlite_wal();
3043        let mut commit_seq_in_segment: std::collections::HashMap<WalSegmentId, u32> =
3044            std::collections::HashMap::new();
3045
3046        let states = self
3047            .snapshots
3048            .iter()
3049            .map(|snap| {
3050                let id = snap.id();
3051                let lsn = snap.lsn();
3052                let seq = commit_seq_in_segment.entry(id.segment).or_insert(0);
3053                let commit_seq = *seq;
3054                *seq += 1;
3055
3056                // Deterministic and collision-free within a cohort: the
3057                // (salt1, salt2, commit_frame_index, db_size_after_commit) quadruple is
3058                // unique per commit state. Packed big-endian into the leading 16 bytes.
3059                let mut tag = [0u8; 32];
3060                tag[0..4].copy_from_slice(&lsn.salt1.to_be_bytes());
3061                tag[4..8].copy_from_slice(&lsn.salt2.to_be_bytes());
3062                tag[8..12].copy_from_slice(&(id.commit_frame_index as u32).to_be_bytes());
3063                tag[12..16].copy_from_slice(&id.db_size_after_commit.to_be_bytes());
3064
3065                TemporalState {
3066                    epoch: EpochTag::from_bytes(tag),
3067                    ordering_key: Some(LsnKind::SqliteWalFrame {
3068                        salt1: lsn.salt1,
3069                        salt2: lsn.salt2,
3070                        frame_seq: lsn.frame_index as u32,
3071                        commit_seq,
3072                    }),
3073                    wall_time: None,
3074                    clock: profile.clock.clone(),
3075                    safety: profile.safety.clone(),
3076                    handle: id,
3077                }
3078            })
3079            .collect();
3080
3081        TemporalCohort {
3082            artifact,
3083            discipline: IdentityDiscipline::PathStable,
3084            topology: CohortTopology::SubJournalCommits,
3085            states,
3086        }
3087    }
3088}
3089
3090/// Whether a decoded [`Value`] is **distinctive** enough to anchor a Tier-2
3091/// fragment emission (the §3.1 gate): TEXT of ≥ 4 bytes of valid UTF-8 (no
3092/// replacement char), or a REAL. Bare integers (1–8-byte serial patterns),
3093/// NULL, and BLOBs are NOT distinctive alone — a short integer byte-pattern
3094/// coincides far too often in a 4 `KiB` page to serve as identity, so it can ride
3095/// along inside a fragment but never justify emitting one.
3096fn is_distinctive(value: &Value) -> bool {
3097    match value {
3098        Value::Text(t) => t.len() >= 4 && !t.contains('\u{FFFD}'),
3099        Value::Real(_) => true,
3100        Value::Null | Value::Integer(_) | Value::Blob(_) => false,
3101    }
3102}
3103
3104/// The body byte-width of a serial type (file-format §2.1), or `None` for a
3105/// serial value that cannot legally appear in a record body.
3106fn serial_body_len(serial: i64) -> Option<usize> {
3107    match serial {
3108        0 | 8 | 9 | 10 | 11 => Some(0),
3109        1 => Some(1),
3110        2 => Some(2),
3111        3 => Some(3),
3112        4 => Some(4),
3113        5 => Some(6),
3114        6 | 7 => Some(8),
3115        n if n >= 12 => Some(((n - 12) / 2) as usize),
3116        _ => None, // negative serial: impossible
3117    }
3118}
3119
3120/// Byte length of a **live** table-leaf cell at `off`, for computing the byte
3121/// extent the cell occupies (so [`Database::carve_free_regions`] can exclude it).
3122/// Returns `None` if the cell header does not parse in bounds.
3123///
3124/// Mirrors the live cell layout: payload-length varint, rowid varint, then the
3125/// local payload (capped at the spill threshold) plus a 4-byte overflow pointer
3126/// when the payload spills. We only need the on-page footprint, so for a spilled
3127/// cell that is `local + 4` bytes, not the full reassembled payload.
3128fn live_cell_len(buf: &[u8], off: usize, usable: usize) -> Option<usize> {
3129    let (payload_len, n1) = read_varint(buf, off).ok()?;
3130    let (_rowid, n2) = read_varint(buf, off + n1).ok()?;
3131    let total = usize::try_from(payload_len).ok()?;
3132    let local = local_payload_len(total, usable);
3133    let on_page = if local >= total {
3134        n1 + n2 + total
3135    } else {
3136        n1 + n2 + local + 4 // 4-byte first-overflow-page pointer
3137    };
3138    Some(on_page)
3139}
3140
3141/// The rowid of a table-leaf cell at `off` — its 2nd varint (after the
3142/// payload-length varint). `None` if either varint is out of bounds. Used to
3143/// identify a live row even when its full record cannot be decoded.
3144fn live_cell_rowid(buf: &[u8], off: usize) -> Option<i64> {
3145    let (_payload_len, n1) = read_varint(buf, off).ok()?;
3146    let (rowid, _) = read_varint(buf, off + n1).ok()?;
3147    Some(rowid)
3148}
3149
3150/// Given the sorted byte extents of live cells, return the maximal **free**
3151/// (unallocated) spans within `[lo, hi)` — the complement of the live extents.
3152/// These are the only ranges [`Database::carve_free_regions`] scans, so a live
3153/// cell can never be re-surfaced.
3154fn free_regions(live: &[(usize, usize)], lo: usize, hi: usize) -> Vec<(usize, usize)> {
3155    let mut regions = Vec::new();
3156    let mut cursor = lo;
3157    for &(s, e) in live {
3158        let s = s.clamp(lo, hi);
3159        let e = e.clamp(lo, hi);
3160        if s > cursor {
3161            regions.push((cursor, s));
3162        }
3163        if e > cursor {
3164            cursor = e;
3165        }
3166    }
3167    if cursor < hi {
3168        regions.push((cursor, hi));
3169    }
3170    regions
3171}
3172
3173/// Derive a [`FreeblockTemplate`] from the first live cell on a table-leaf page:
3174/// the record's header length, its serial-type array, and the byte width of the
3175/// cell prefix (payload-length + rowid varints) that the freeblock header
3176/// overwrites. Returns `None` when no live cell parses or the prefix is wider
3177/// than the 4 bytes a freeblock header clobbers (the simple template cannot then
3178/// place the surviving serial tail).
3179/// Shared internal walker producing BOTH recovery tiers in one pass so the cell
3180/// and fragment outputs can never diverge: `(full_cells, fragments)`.
3181/// [`Database::reconstruct_freeblock_records`] takes `.0`,
3182/// [`Database::reconstruct_freeblock_fragments`] takes `.1`. A free function (it
3183/// needs no `Database` state — only the page bytes and the page-derived
3184/// template), keeping the two public entry points a thin projection of one walk.
3185fn reconstruct_freeblock_inner(
3186    page_bytes: &[u8],
3187    enc: TextEncoding,
3188) -> (Vec<CarvedCell>, Vec<CellFragment>) {
3189    let mut cells = Vec::new();
3190    let mut frags = Vec::new();
3191    let hdr_off = if page_bytes.starts_with(SQLITE_MAGIC) {
3192        SQLITE_HEADER_SIZE
3193    } else {
3194        0
3195    };
3196    let Some(&page_type) = page_bytes.get(hdr_off) else {
3197        return (cells, frags);
3198    };
3199    if page_type != 0x0d {
3200        return (cells, frags); // only table-leaf pages have freeblock residue
3201    }
3202    let Some(template) = freeblock_template(page_bytes, hdr_off, enc) else {
3203        return (cells, frags);
3204    };
3205
3206    let first_freeblock = be_u16(page_bytes, hdr_off + 1) as usize;
3207    let mut fb = first_freeblock;
3208    let mut walked = 0usize;
3209    let mut visited = std::collections::BTreeSet::new();
3210    while fb != 0 && walked < MAX_FREEBLOCKS_PER_PAGE {
3211        walked += 1;
3212        if !visited.insert(fb) {
3213            break; // cyclic next pointer
3214        }
3215        let next = be_u16(page_bytes, fb) as usize;
3216        let size = be_u16(page_bytes, fb + 2) as usize;
3217        let Some(fb_end) = fb.checked_add(size) else {
3218            break; // cov:unreachable: usize add of two u16-range values
3219        };
3220        if size >= 4 && fb_end <= page_bytes.len() {
3221            if template.known_lead_serials.is_empty() {
3222                // Empty-lead (2-byte-rowid) page: each freeblock is a single freed
3223                // cell whose serial array fully survives. Reconstruct it ONLY if
3224                // the record tiles the freeblock exactly — the precision gate that
3225                // rejects the misaligned runs a loose walk would manufacture.
3226                cells.extend(template.reconstruct_span_exact(page_bytes, fb, fb_end));
3227            } else {
3228                template
3229                    .reconstruct_span_tiered(page_bytes, fb, fb_end, false, &mut cells, &mut frags);
3230            }
3231        }
3232        fb = next;
3233    }
3234
3235    let cell_count = be_u16(page_bytes, hdr_off + 3) as usize;
3236    let cptr_end = hdr_off + 8 + cell_count * 2;
3237    let cca = be_u16(page_bytes, hdr_off + 5) as usize;
3238    // The unallocated-gap pass anchors off a surviving forward cell and a known
3239    // leading serial; it is meaningful only for the (non-empty-lead) span-walk
3240    // templates. Empty-lead pages recover solely through the exact-tile chain pass.
3241    if !template.known_lead_serials.is_empty() && cca > cptr_end && cca <= page_bytes.len() {
3242        for anchor_off in cptr_end..cca {
3243            let Some(anchor) =
3244                try_carve_cell_at(page_bytes, anchor_off, Some(template.column_count), enc)
3245            else {
3246                continue;
3247            };
3248            let has_text = anchor
3249                .values
3250                .iter()
3251                .any(|v| matches!(v, Value::Text(t) if !t.is_empty() && !t.contains('\u{FFFD}')));
3252            if !has_text {
3253                continue;
3254            }
3255            let tail_start = anchor.offset + anchor.byte_len;
3256            template
3257                .reconstruct_span_tiered(page_bytes, tail_start, cca, true, &mut cells, &mut frags);
3258            break; // one anchored run per page — the contiguous freed tail
3259        }
3260    }
3261    (cells, frags)
3262}
3263
3264fn freeblock_template(
3265    page_bytes: &[u8],
3266    hdr_off: usize,
3267    enc: TextEncoding,
3268) -> Option<FreeblockTemplate> {
3269    let cell_count = be_u16(page_bytes, hdr_off + 3) as usize;
3270    let cell_ptr_array = hdr_off + 8;
3271    for i in 0..cell_count {
3272        let cell_off = be_u16(page_bytes, cell_ptr_array + i * 2) as usize;
3273        if cell_off == 0 || cell_off >= page_bytes.len() {
3274            continue;
3275        }
3276        // Prefix: payload-length varint, rowid varint.
3277        let Ok((_payload_len, n1)) = read_varint(page_bytes, cell_off) else {
3278            continue; // cov:unreachable: a live cell-pointer addresses an in-bounds prefix
3279        };
3280        let Ok((_rowid, n2)) = read_varint(page_bytes, cell_off + n1) else {
3281            continue; // cov:unreachable: the rowid varint follows the payload-len varint in-page
3282        };
3283        let prefix_len = n1 + n2;
3284        // The freeblock header overwrites exactly 4 bytes. If the prefix alone is
3285        // wider, no record-header byte is clobbered in a way this simple template
3286        // handles — skip (those tables keep an intact header tail the forward
3287        // carver already reaches).
3288        if prefix_len > 4 {
3289            continue; // cov:unreachable: the corpus tables all encode a <=4-byte cell prefix
3290        }
3291        let payload_start = cell_off + n1 + n2;
3292        let Ok((header_len, hn)) = read_varint(page_bytes, payload_start) else {
3293            continue; // cov:unreachable: a live cell's record header follows its prefix in-page
3294        };
3295        let header_len = usize::try_from(header_len).ok()?;
3296        if header_len < hn {
3297            continue; // cov:unreachable: a live record's header_len covers its own varint
3298        }
3299        // Read the template's serial-type array, recording each serial's byte
3300        // offset within the header so we can split clobbered vs surviving.
3301        let mut serials = Vec::new();
3302        let mut hpos = hn;
3303        let mut ok = true;
3304        while hpos < header_len {
3305            let Ok((s, used)) = read_varint(page_bytes, payload_start + hpos) else {
3306                ok = false; // cov:unreachable: header_len bounds the serial array within the page
3307                break; // cov:unreachable: paired with the read failure above
3308            };
3309            serials.push((s, hpos, used));
3310            hpos += used;
3311        }
3312        if !ok || hpos != header_len || serials.len() < MIN_INFERRED_COLUMNS {
3313            continue; // cov:unreachable: a live cell's header parses cleanly with >= 2 columns
3314        }
3315        return FreeblockTemplate::build(prefix_len, header_len, hn, &serials, enc);
3316    }
3317    None
3318}
3319
3320/// A record-header template derived from a live cell on a table-leaf page, used
3321/// to rebuild freeblock-clobbered records (see
3322/// [`Database::reconstruct_freeblock_records`]).
3323///
3324/// Freeblock conversion overwrites the freed cell's first four bytes — the
3325/// payload-length + rowid varints, the record `header_len`, and the leading
3326/// serial type(s). The surviving serial-type tail and the value body remain. The
3327/// template supplies what was destroyed: the total column count, the serial types
3328/// of the leading (clobbered) columns, and the page offset, relative to the
3329/// freeblock start, at which the surviving serial tail begins.
3330struct FreeblockTemplate {
3331    /// Total number of columns in a record of this table.
3332    column_count: usize,
3333    /// Serial types of the leading columns whose header bytes the freeblock
3334    /// header clobbered (taken from the template; e.g. the fixed-width `id`).
3335    known_lead_serials: Vec<i64>,
3336    /// Offset, relative to the freeblock start, at which the **surviving** serial
3337    /// tail begins (== `prefix_len + first_surviving_serial_header_offset`).
3338    surviving_serials_off: usize,
3339    /// Text encoding of the owning database, so reconstructed text decodes per
3340    /// the header (UTF-8 / UTF-16) rather than assuming UTF-8.
3341    text_encoding: TextEncoding,
3342}
3343
3344impl FreeblockTemplate {
3345    /// Build a template from a parsed live-cell header. `serials` is the list of
3346    /// `(serial_type, header_offset, varint_width)` tuples for every column.
3347    /// Returns `None` when the 4-byte freeblock clobber boundary cannot be
3348    /// resolved to a clean split between leading and surviving serials.
3349    fn build(
3350        prefix_len: usize,
3351        _header_len: usize,
3352        _hn: usize,
3353        serials: &[(i64, usize, usize)],
3354        enc: TextEncoding,
3355    ) -> Option<FreeblockTemplate> {
3356        // Bytes of the record header the 4-byte freeblock header destroys.
3357        let clobbered_header_bytes = 4usize.checked_sub(prefix_len)?;
3358        // The first column whose header bytes survive intact is the first serial
3359        // whose header offset is at or beyond the clobber boundary. Everything
3360        // before it is supplied from the template.
3361        let mut known_lead = Vec::new();
3362        let mut surviving_serials_off = None;
3363        for &(serial, hpos, _used) in serials {
3364            if hpos >= clobbered_header_bytes {
3365                surviving_serials_off = Some(prefix_len + hpos);
3366                break;
3367            }
3368            known_lead.push(serial);
3369        }
3370        // At least one serial must survive to anchor the reconstruction. The
3371        // leading (clobbered) serial list MAY be empty: a 2-byte-or-wider rowid
3372        // varint (rowid >= 128) widens the cell prefix so the 4-byte freeblock
3373        // clobber stops at `header_len`, destroying NO serial type — the whole
3374        // serial array survives. Such pages reconstruct via the exact-tile
3375        // single-cell path (`reconstruct_freeblock_inner` routes on
3376        // `known_lead_serials.is_empty()`), which requires each freed cell to fill
3377        // its freeblock exactly; that precision check keeps the empty-lead case
3378        // phantom-free where a loose span walk would mis-align columns.
3379        let surviving_serials_off = surviving_serials_off?;
3380        Some(FreeblockTemplate {
3381            column_count: serials.len(),
3382            known_lead_serials: known_lead,
3383            surviving_serials_off,
3384            text_encoding: enc,
3385        })
3386    }
3387
3388    /// Reconstruct **every** clobbered cell coalesced into the free span
3389    /// `[lo, hi)` — a chained freeblock or a page's unallocated gap — and append
3390    /// each to `out`.
3391    ///
3392    /// When SQLite frees adjacent cells it coalesces them into one freeblock whose
3393    /// interior still holds the freed cells back-to-back, **each** prefixed by a
3394    /// stale 4-byte freeblock header (`next`/`size`) that clobbers that cell's
3395    /// payload-length + rowid varints and leading serial(s). A single-shot
3396    /// reconstruction at `lo` recovers only the span's first cell; the trailing
3397    /// cells are intact records sitting at the previous record's end. This walks
3398    /// the template across the span: reconstruct at `lo`, advance to that record's
3399    /// end, repeat to `hi`. Every value is derived from the span bounds and the
3400    /// page's own schema template — no per-cell or per-database constant.
3401    ///
3402    /// Each candidate is validated identically to the single-cell case (legal
3403    /// serial types, record fits within `[cell_start, hi)`). The walk is
3404    /// **structural, not a sliding scan**: SQLite coalesces freed cells exactly
3405    /// back-to-back (each freed record's end abuts the next freed cell's clobbered
3406    /// 4-byte prefix), so the next cell begins precisely at the previous record's
3407    /// end. The walk therefore reconstructs at `lo`, advances to that record's
3408    /// end, and repeats — and STOPS the moment a position does not reconstruct
3409    /// cleanly. It never slides forward byte-by-byte hunting for the next cell:
3410    /// that fallback would synthesize a record from any run of bytes that happens
3411    /// to satisfy the legal-serial + fits-in-span checks, manufacturing phantoms
3412    /// in non-cell free space. Anchoring every cell at the prior record's exact
3413    /// end is what keeps the broader span-walk at single-cell precision. Bounded:
3414    /// the walk strictly advances (a record is non-empty) and is capped at
3415    /// [`MAX_FREEBLOCKS_PER_PAGE`] reconstructions per span.
3416    ///
3417    /// Follower precision (the coalesced-freeblock signature): the span's FIRST
3418    /// cell at `lo` is reconstructed unconditionally — `lo` is a real boundary (a
3419    /// freeblock-chain entry, or the gap anchor's first follower). Every SUBSEQUENT
3420    /// follower must carry the structural mark of a freed-and-coalesced cell: its
3421    /// clobbered 4-byte prefix is a stale freeblock header whose 2-byte `next`
3422    /// field is `0x0000` (a terminal/orphaned freeblock — what SQLite leaves when
3423    /// it coalesces freed cells back-to-back). A position whose leading two bytes
3424    /// are non-zero is a byte-shifted remnant, not a coalesced cell, so the run
3425    /// ends there. This is the check that separates a true coalesced tail (0D-06's
3426    /// `00 00 NN NN`-prefixed followers) from a misaligned fragment (0B-02's
3427    /// `24 09 …` remnant), keeping the gap pass phantom-free.
3428    ///
3429    /// `enforce_follower_mark` is `true` for the unallocated-gap pass, where the
3430    /// span is bounded only by `cellContentArea` (not by a page-recorded freeblock
3431    /// size) and so a byte-shifted remnant could otherwise be mistaken for a
3432    /// follower: there EVERY position must carry the `next == 0` mark. It is `false`
3433    /// for the freeblock-chain pass, whose span bounds are the page-recorded
3434    /// `[fb, fb + size)` — a strong boundary that already pins the coalesced run, so
3435    /// the interior followers (whose clobbered bytes are the original record's own
3436    /// varints, not necessarily `00 00 …`) are accepted on the fit-in-span check
3437    /// alone.
3438    ///
3439    /// Tiered walk: it pushes each reconstructed full cell into `cells`, and at the
3440    /// anchor where `reconstruct_one` would `break` it salvages the maximal
3441    /// decodable column prefix into `frags` as a [`CellFragment`] (when the §3.1
3442    /// distinctiveness gate passes) before stopping. Fragment salvage does NOT
3443    /// extend the walk — it stops at exactly the position the full walk does,
3444    /// preserving Tier-1's phantom discipline. Callers that want only the full
3445    /// cells (the Tier-1 [`Database::reconstruct_freeblock_records`]) discard
3446    /// `frags`; both tiers therefore come from one walk and can never diverge.
3447    fn reconstruct_span_tiered(
3448        &self,
3449        page: &[u8],
3450        lo: usize,
3451        hi: usize,
3452        enforce_follower_mark: bool,
3453        cells: &mut Vec<CarvedCell>,
3454        frags: &mut Vec<CellFragment>,
3455    ) {
3456        let mut cell_start = lo;
3457        let mut built = 0usize;
3458        while cell_start < hi && built < MAX_FREEBLOCKS_PER_PAGE {
3459            if enforce_follower_mark && be_u16(page, cell_start) != 0 {
3460                break; // not a coalesced freeblock follower — the contiguous run ends
3461            }
3462            let Some((cell, record_end)) = self.reconstruct_one(page, cell_start, hi) else {
3463                // Full reconstruction failed at this anchor; try to salvage the
3464                // decodable prefix as a fragment, then stop (do not extend the
3465                // walk past the failed anchor).
3466                if let Some(frag) = self.salvage_fragment(page, cell_start, hi) {
3467                    frags.push(frag);
3468                }
3469                break;
3470            };
3471            cells.push(cell);
3472            built += 1;
3473            cell_start = record_end;
3474        }
3475    }
3476
3477    /// Salvage the maximal decodable column prefix at `cell_start` (bounded by
3478    /// `span_end`) when full reconstruction failed there. Walks the template +
3479    /// surviving serial array forward, decoding each column's body while it fits
3480    /// in the span; the first illegal serial, out-of-bounds read, or body that
3481    /// overruns the span ends the prefix. Returns a [`CellFragment`] **only** when
3482    /// the salvaged prefix contains at least one distinctive cell (TEXT ≥ 4 bytes
3483    /// of valid UTF-8, or REAL) — the §3.1 emission gate — otherwise `None`.
3484    fn salvage_fragment(
3485        &self,
3486        page: &[u8],
3487        cell_start: usize,
3488        span_end: usize,
3489    ) -> Option<CellFragment> {
3490        let surviving_count = self.column_count - self.known_lead_serials.len();
3491        let tail_start = cell_start.checked_add(self.surviving_serials_off)?;
3492
3493        // Read as many legal surviving serials as decode in-bounds within the span.
3494        // The template's leading serials are always legal (they came from a live
3495        // cell), so the full serial array is `known_lead ++ legal_surviving`.
3496        let mut serials = self.known_lead_serials.clone();
3497        let mut pos = tail_start;
3498        for _ in 0..surviving_count {
3499            let Ok((s, used)) = read_varint(page, pos) else {
3500                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
3501            };
3502            if serial_body_len(s).is_none() {
3503                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
3504            }
3505            let Some(next) = pos.checked_add(used) else {
3506                break; // cov:unreachable: usize add of an in-page varint width
3507            };
3508            if next > span_end {
3509                break; // serial tail overran the span
3510            }
3511            serials.push(s);
3512            pos = next;
3513        }
3514
3515        // Decode column bodies left-to-right, keeping each whose body ends within
3516        // the span. The body begins right after the surviving serial tail.
3517        let body_start = pos;
3518        let mut surviving: Vec<(usize, Value)> = Vec::new();
3519        let mut bpos = body_start;
3520        for (idx, &s) in serials.iter().enumerate() {
3521            let Some(blen) = serial_body_len(s) else {
3522                break; // cov:unreachable: only legal serials were pushed above
3523            };
3524            let Some(body_end) = bpos.checked_add(blen) else {
3525                break; // cov:unreachable: usize add of an in-page body length
3526            };
3527            if body_end > span_end {
3528                break; // this column's body overruns the span — prefix ends here
3529            }
3530            let Some(body) = page.get(bpos..body_end) else {
3531                break; // cov:unreachable: body_end <= span_end <= page.len()
3532            };
3533            let Ok((val, _)) = decode_value(body, 0, s, self.text_encoding) else {
3534                break; // cov:unreachable: serial_body_len-legal serials decode in-bounds
3535            };
3536            surviving.push((idx, val));
3537            bpos = body_end;
3538        }
3539
3540        // Emission gate: at least one distinctive cell (TEXT >= 4 UTF-8 bytes, or
3541        // REAL). A lone integer/NULL/blob prefix is coincidence-prone — no fragment.
3542        if !surviving.iter().any(|(_, v)| is_distinctive(v)) {
3543            return None;
3544        }
3545        let last_body_end = bpos;
3546        Some(CellFragment {
3547            offset: cell_start,
3548            byte_len: last_body_end.saturating_sub(cell_start),
3549            missing: self.column_count - surviving.len(),
3550            surviving,
3551            confidence: FRAGMENT_CONFIDENCE,
3552        })
3553    }
3554
3555    /// Rebuild the single record whose clobbered cell begins at `cell_start`,
3556    /// bounded by the enclosing span end `span_end`: read the surviving serial
3557    /// tail, prepend the template's leading serials, decode the body, and validate
3558    /// the whole record fits within `[cell_start, span_end)`. Returns the carved
3559    /// cell and the record's end offset (the next coalesced cell's start), or
3560    /// `None` on any out-of-bounds or implausible parse.
3561    fn reconstruct_one(
3562        &self,
3563        page: &[u8],
3564        cell_start: usize,
3565        span_end: usize,
3566    ) -> Option<(CarvedCell, usize)> {
3567        let surviving_count = self.column_count - self.known_lead_serials.len();
3568        let tail_start = cell_start.checked_add(self.surviving_serials_off)?;
3569
3570        // Read the surviving serial tail from the freeblock.
3571        let mut serials = self.known_lead_serials.clone();
3572        let mut pos = tail_start;
3573        for _ in 0..surviving_count {
3574            let (s, used) = read_varint(page, pos).ok()?;
3575            // A serial type must be legal; reject the candidate otherwise.
3576            serial_body_len(s)?;
3577            serials.push(s);
3578            pos = pos.checked_add(used)?;
3579            if pos > span_end {
3580                return None;
3581            }
3582        }
3583
3584        // The body begins right after the surviving serial tail. Compute its
3585        // length from the full (template + surviving) serial array.
3586        let mut body_len = 0usize;
3587        for &s in &serials {
3588            body_len = body_len.checked_add(serial_body_len(s)?)?;
3589        }
3590        let body_start = pos;
3591        let record_end = body_start.checked_add(body_len)?;
3592        // The reconstructed record MUST fit within the enclosing span — the core
3593        // precision check that rejects coincidental/garbage reconstructions.
3594        if record_end > span_end {
3595            return None;
3596        }
3597
3598        // Synthesize a record payload (header + body) for the shared decoder so
3599        // values are decoded with the same storage-class fidelity as live rows.
3600        // The rowid is destroyed; pass 0 so a serial-0 column reads as NULL rather
3601        // than a fabricated rowid.
3602        let body = page.get(body_start..record_end)?;
3603        let values = decode_synthetic_record(&serials, body, self.text_encoding)?;
3604        if values.len() != self.column_count {
3605            return None; // cov:unreachable: one value per serial by construction
3606        }
3607
3608        Some((
3609            CarvedCell {
3610                offset: cell_start,
3611                byte_len: record_end - cell_start,
3612                rowid: 0, // destroyed by freeblock conversion — surfaced as unknown
3613                values,
3614                confidence: FREEBLOCK_RECONSTRUCT_CONFIDENCE,
3615            },
3616            record_end,
3617        ))
3618    }
3619
3620    /// Reconstruct ONE freeblock-clobbered empty-leading-serial cell at
3621    /// `cell_start` — a 2-byte-or-wider rowid, so the 4-byte clobber destroyed no
3622    /// serial type and the whole serial array survives at
3623    /// `cell_start + surviving_serials_off`. Returns the carved cell (rowid
3624    /// destroyed → 0) **and the record's end offset**, or `None` on any
3625    /// out-of-bounds parse or a record that overruns `span_end`. Does NOT enforce
3626    /// an exact tile — the span walker [`Self::reconstruct_span_exact`] does.
3627    fn reconstruct_cell_empty_lead(
3628        &self,
3629        page: &[u8],
3630        cell_start: usize,
3631        span_end: usize,
3632    ) -> Option<(CarvedCell, usize)> {
3633        let tail_start = cell_start.checked_add(self.surviving_serials_off)?;
3634        // The whole serial array survives (no clobbered leading serial); read all
3635        // `column_count` serials from the freeblock.
3636        let mut serials = Vec::with_capacity(self.column_count);
3637        let mut pos = tail_start;
3638        for _ in 0..self.column_count {
3639            let (s, used) = read_varint(page, pos).ok()?;
3640            serial_body_len(s)?;
3641            serials.push(s);
3642            pos = pos.checked_add(used)?;
3643            if pos > span_end {
3644                return None;
3645            }
3646        }
3647        let mut body_len = 0usize;
3648        for &s in &serials {
3649            body_len = body_len.checked_add(serial_body_len(s)?)?;
3650        }
3651        let body_start = pos;
3652        let record_end = body_start.checked_add(body_len)?;
3653        if record_end > span_end {
3654            return None;
3655        }
3656        let body = page.get(body_start..record_end)?;
3657        let values = decode_synthetic_record(&serials, body, self.text_encoding)?;
3658        if values.len() != self.column_count {
3659            return None; // cov:unreachable: one value per serial by construction
3660        }
3661        Some((
3662            CarvedCell {
3663                offset: cell_start,
3664                byte_len: record_end - cell_start,
3665                rowid: 0, // destroyed by freeblock conversion — surfaced as unknown
3666                values,
3667                confidence: FREEBLOCK_RECONSTRUCT_CONFIDENCE,
3668            },
3669            record_end,
3670        ))
3671    }
3672
3673    /// Reconstruct every empty-leading-serial cell coalesced into the freeblock
3674    /// `[lo, hi)`, returned ONLY when they tile the freeblock **exactly** (the
3675    /// walk reaches `hi` with no leftover bytes).
3676    ///
3677    /// A single freed cell fills its freeblock exactly; adjacent deletions
3678    /// coalesce into one freeblock whose interior holds the freed cells
3679    /// back-to-back, each clobbered in its first 4 bytes. Walking cell-to-cell and
3680    /// requiring the run to land precisely on `hi` is the precision gate: a
3681    /// misaligned read (a deleted cell whose destroyed rowid width differs from the
3682    /// template's) fails to reach `hi` exactly, so the whole span is rejected
3683    /// rather than emitted as column-shifted phantoms. Bounded by
3684    /// [`MAX_FREEBLOCKS_PER_PAGE`]; a record always advances `cell_start`.
3685    fn reconstruct_span_exact(&self, page: &[u8], lo: usize, hi: usize) -> Vec<CarvedCell> {
3686        let mut cells = Vec::new();
3687        let mut cell_start = lo;
3688        let mut guard = 0usize;
3689        while cell_start < hi && guard < MAX_FREEBLOCKS_PER_PAGE {
3690            guard += 1;
3691            let Some((cell, record_end)) = self.reconstruct_cell_empty_lead(page, cell_start, hi)
3692            else {
3693                return Vec::new(); // a cell did not reconstruct → not a clean tiling
3694            };
3695            if record_end <= cell_start {
3696                return Vec::new(); // cov:unreachable: a non-empty record advances cell_start
3697            }
3698            cells.push(cell);
3699            cell_start = record_end;
3700        }
3701        // Exact tile: leftover bytes (or a walk stopped by the bound) mean a
3702        // misaligned run — emit nothing.
3703        if cell_start == hi {
3704            cells
3705        } else {
3706            Vec::new()
3707        }
3708    }
3709
3710    /// Reconstruct a freeblock-clobbered **spilled** cell at `cell_start` (task
3711    /// #73, design §2.2). A spilled cell always carries a multi-byte
3712    /// `payload_len` varint, so the 4-byte freeblock clobber destroys the
3713    /// `payload_len` + `rowid` varints and the record's `header_len` varint —
3714    /// **but not the serial-type array**, which survives intact immediately after
3715    /// the clobber. We therefore read the full serial array directly from
3716    /// `cell_start + CLOBBER` (using the template only for the column count),
3717    /// re-derive `header_len` and `P = header_len + Σ serial_body_len`, and — when
3718    /// `P > usable - 35` — resolve the spill: `local_payload_len(P, usable)` bytes
3719    /// of payload sit locally (the destroyed header counted within them), the
3720    /// 4-byte first-overflow pointer follows, and the chain is resolved through
3721    /// freelist leaves. Returns `(cell, chain)` with `rowid = 0`, or `None`.
3722    ///
3723    /// UNPROVEN-BY-CORPUS (Codex ruling #5): synthetic-fixture validation only.
3724    /// No real Nemetz cell is both freeblock-clobbered and spilled.
3725    fn reconstruct_spilled(
3726        &self,
3727        db: &Database,
3728        page: &[u8],
3729        cell_start: usize,
3730        usable: usize,
3731        freed_leaves: &std::collections::BTreeSet<u32>,
3732    ) -> Option<(CarvedCell, Vec<u32>)> {
3733        // The freeblock header clobbers exactly 4 bytes. For a spilled cell those
3734        // 4 bytes are payload_len(>=2) + rowid(>=1) + header_len(>=1) varints, so
3735        // the serial array begins right after the clobber.
3736        const CLOBBER: usize = 4;
3737        let serials_start = cell_start.checked_add(CLOBBER)?;
3738        let mut serials = Vec::with_capacity(self.column_count);
3739        let mut pos = serials_start;
3740        for _ in 0..self.column_count {
3741            let (s, used) = read_varint(page, pos).ok()?;
3742            serial_body_len(s)?;
3743            serials.push(s);
3744            pos = pos.checked_add(used)?;
3745        }
3746
3747        // Re-derive the record header bytes that were destroyed: header_len is a
3748        // varint counting itself plus the serial array.
3749        let mut serial_bytes_len = 0usize;
3750        for &s in &serials {
3751            serial_bytes_len += varint_len(s);
3752        }
3753        let mut header_len = serial_bytes_len + 1;
3754        while varint_len(header_len as i64) + serial_bytes_len != header_len {
3755            header_len += 1;
3756        }
3757        // The clobber removed `header_len`'s own varint plus the prefix; verify the
3758        // surviving serial array aligns with the reconstructed header (the bytes
3759        // from serials_start to `pos` are the serial array, length serial_bytes_len).
3760        if pos.checked_sub(serials_start)? != serial_bytes_len {
3761            return None; // cov:unreachable: read_varint widths sum to serial_bytes_len
3762        }
3763        let mut body_len = 0usize;
3764        for &s in &serials {
3765            body_len = body_len.checked_add(serial_body_len(s)?)?;
3766        }
3767        let payload_len = header_len.checked_add(body_len)?;
3768        // Only the spilled class — an in-page payload is the existing template path.
3769        if payload_len <= usable.checked_sub(35)? {
3770            return None;
3771        }
3772        let local_len = local_payload_len(payload_len, usable);
3773
3774        // The body starts right after the surviving serial array. The local payload
3775        // spans `local_len` bytes of (header ++ body); the destroyed header is
3776        // `header_len` of those, so `local_len - header_len` body bytes are present
3777        // locally before the 4-byte first-overflow pointer.
3778        let body_start = pos;
3779        let local_body = local_len.checked_sub(header_len)?;
3780        let local_body_end = body_start.checked_add(local_body)?;
3781        let ptr_off = local_body_end;
3782        let ptr_slice = page.get(ptr_off..ptr_off + 4)?;
3783        let first_overflow =
3784            u32::from_be_bytes([ptr_slice[0], ptr_slice[1], ptr_slice[2], ptr_slice[3]]);
3785        let local_body_bytes = page.get(body_start..local_body_end)?;
3786
3787        let remaining = payload_len - local_len;
3788        let (chain_content, chain) = db
3789            .read_freed_overflow_chain(first_overflow, remaining, usable, freed_leaves)
3790            .ok()?;
3791
3792        // Assemble the full payload: reconstructed header ++ local body ++ chain.
3793        let mut header = enc_varint_into(header_len);
3794        for &s in &serials {
3795            header.extend(enc_varint_into(usize::try_from(s).ok()?));
3796        }
3797        if header.len() != header_len {
3798            return None; // cov:unreachable: header_len was solved to this width
3799        }
3800        let mut payload = Vec::with_capacity(payload_len);
3801        payload.extend_from_slice(&header);
3802        payload.extend_from_slice(local_body_bytes);
3803        payload.extend_from_slice(&chain_content);
3804        if payload.len() != payload_len {
3805            return None; // cov:unreachable: local_body + chain == body_len by construction
3806        }
3807
3808        let values = decode_record(&payload, self.column_count, 0, db.header.text_encoding).ok()?;
3809        if values.len() != self.column_count {
3810            return None; // cov:unreachable: one value per serial
3811        }
3812        let any_replacement = values.iter().any(|v| match v {
3813            Value::Text(t) => t.contains('\u{FFFD}'),
3814            _ => false,
3815        });
3816        if any_replacement {
3817            return None;
3818        }
3819        if !values.iter().any(is_distinctive) {
3820            return None;
3821        }
3822
3823        Some((
3824            CarvedCell {
3825                offset: cell_start,
3826                byte_len: ptr_off + 4 - cell_start,
3827                rowid: 0,
3828                values,
3829                confidence: FREEBLOCK_RECONSTRUCT_CONFIDENCE * OVERFLOW_CHAIN_CONFIDENCE_FACTOR,
3830            },
3831            chain,
3832        ))
3833    }
3834}
3835
3836/// Decode a record body given an explicit serial-type array (the freeblock
3837/// reconstructor supplies the array; the on-disk `header_len` + leading serials
3838/// were destroyed). Mirrors [`decode_record`]'s body pass. Returns `None` on any
3839/// out-of-bounds read so a malformed reconstruction is rejected, never panics.
3840fn decode_synthetic_record(serials: &[i64], body: &[u8], enc: TextEncoding) -> Option<Vec<Value>> {
3841    let mut values = Vec::with_capacity(serials.len());
3842    let mut bpos = 0usize;
3843    for &serial in serials {
3844        let (val, size) = decode_value(body, bpos, serial, enc).ok()?;
3845        values.push(val);
3846        bpos = bpos.checked_add(size)?;
3847    }
3848    Some(values)
3849}
3850
3851/// Attempt to recognize a table-leaf cell at `off` in `buf` as a record.
3852///
3853/// `expected_columns` is `Some(n)` to require exactly `n` columns (fixed-schema
3854/// carving), or `None` to **infer** the column count from the record's own
3855/// serial-type array (dropped-table / schema-gone carving). Returns a
3856/// [`CarvedCell`] only when the bytes are self-consistently record-shaped;
3857/// otherwise `None`. Never panics — every access is bounds-checked.
3858fn try_carve_cell_at(
3859    buf: &[u8],
3860    off: usize,
3861    expected_columns: Option<usize>,
3862    enc: TextEncoding,
3863) -> Option<CarvedCell> {
3864    // Cell prefix: payload_len varint, rowid varint.
3865    let (payload_len, n1) = read_varint(buf, off).ok()?;
3866    let payload_len = usize::try_from(payload_len).ok()?;
3867    if payload_len == 0 {
3868        return None;
3869    }
3870    let (rowid, n2) = read_varint(buf, off + n1).ok()?;
3871    // A negative rowid is legal but vanishingly rare for browser tables; treat a
3872    // non-positive rowid as a non-match to suppress coincidental hits.
3873    if rowid <= 0 {
3874        return None;
3875    }
3876    let payload_start = off + n1 + n2;
3877    let payload = buf.get(payload_start..payload_start + payload_len)?;
3878
3879    // Record header: header_len varint, then one serial type per column.
3880    let (header_len, hn) = read_varint(payload, 0).ok()?;
3881    let header_len = usize::try_from(header_len).ok()?;
3882    if header_len > payload.len() || header_len < hn {
3883        return None;
3884    }
3885    let cap = expected_columns.unwrap_or(0);
3886    let mut serials = Vec::with_capacity(cap);
3887    let mut hpos = hn;
3888    while hpos < header_len {
3889        let (s, used) = read_varint(payload, hpos).ok()?;
3890        serials.push(s);
3891        hpos += used;
3892    }
3893    // The header must consume cleanly, and match the expected column count when
3894    // one was given. When inferring, require a minimum plausible column count to
3895    // suppress coincidental 1-column matches.
3896    if hpos != header_len {
3897        return None;
3898    }
3899    match expected_columns {
3900        Some(n) if serials.len() != n => return None,
3901        None if serials.len() < MIN_INFERRED_COLUMNS => return None,
3902        _ => {}
3903    }
3904    let column_count = serials.len();
3905
3906    // Body length implied by the serial types must equal payload_len - header_len
3907    // — a strong self-consistency check that rejects coincidental matches.
3908    let mut body_len = 0usize;
3909    for &s in &serials {
3910        body_len += serial_body_len(s)?;
3911    }
3912    if header_len + body_len != payload_len {
3913        return None;
3914    }
3915
3916    // Decode the record (reusing the live decoder for storage-class fidelity).
3917    let values = decode_record(payload, column_count, rowid, enc).ok()?;
3918    if values.len() != column_count {
3919        return None; // cov:unreachable: decode_record yields one value per serial
3920    }
3921
3922    // Confidence: a fully self-consistent record already passed strong checks;
3923    // raise confidence when at least one column is a non-empty, valid-UTF-8 TEXT
3924    // (record-shaped *and* human-meaningful), which coincidental byte runs rarely
3925    // satisfy.
3926    let has_real_text = values.iter().any(|v| match v {
3927        Value::Text(t) => !t.is_empty() && !t.contains('\u{FFFD}'),
3928        _ => false,
3929    });
3930    let confidence = if has_real_text { 0.9 } else { 0.6 };
3931
3932    Some(CarvedCell {
3933        offset: off,
3934        byte_len: (payload_start + payload_len) - off,
3935        rowid,
3936        values,
3937        confidence,
3938    })
3939}
3940
3941/// Recognize a freed **spilled** table-leaf cell at `off` whose payload exceeds
3942/// the in-page threshold (`usable - 35`) and therefore continues on an
3943/// overflow-page chain (task #73). The sibling of [`try_carve_cell_at`] for the
3944/// overflow class: the two partition the candidate space by the spec spill
3945/// threshold, so a cell is recognized by exactly one of them.
3946///
3947/// `expected_columns` is `Some(n)` to require exactly `n` columns, or `None` to
3948/// infer the count (≥ [`MIN_INFERRED_COLUMNS`]). Returns a [`SpilledCell`]
3949/// (recognition only — the chain is resolved later) when the local prefix is
3950/// self-consistent: header fits in the local payload, the serial array consumes
3951/// the header cleanly, `header_len + Σ serial_body_len == P` (length closure
3952/// over the *declared* P), and the local payload plus its 4-byte overflow
3953/// pointer are in-bounds. Never panics — every access is bounds-checked.
3954fn try_carve_spilled_cell_at(
3955    buf: &[u8],
3956    off: usize,
3957    usable: usize,
3958    expected_columns: Option<usize>,
3959) -> Option<SpilledCell> {
3960    let (payload_len, n1) = read_varint(buf, off).ok()?;
3961    let payload_len = usize::try_from(payload_len).ok()?;
3962    // Only the overflow class — in-page payloads belong to `try_carve_cell_at`.
3963    if payload_len <= usable.checked_sub(35)? {
3964        return None;
3965    }
3966    let (rowid, n2) = read_varint(buf, off + n1).ok()?;
3967    if rowid <= 0 {
3968        return None;
3969    }
3970    let payload_start = off + n1 + n2;
3971    let local_len = local_payload_len(payload_len, usable);
3972    // The local payload prefix plus the 4-byte first-overflow pointer must be in
3973    // bounds of the scanned slice.
3974    let prefix = buf.get(payload_start..payload_start + local_len + 4)?;
3975
3976    // The record header must fit entirely within the local prefix — otherwise the
3977    // serial array is not addressable locally and we abstain rather than guess.
3978    let (header_len, hn) = read_varint(prefix, 0).ok()?;
3979    let header_len = usize::try_from(header_len).ok()?;
3980    if header_len > local_len || header_len < hn {
3981        return None;
3982    }
3983    let mut serials = Vec::new();
3984    let mut hpos = hn;
3985    while hpos < header_len {
3986        let (s, used) = read_varint(prefix, hpos).ok()?;
3987        serials.push(s);
3988        hpos += used;
3989    }
3990    if hpos != header_len {
3991        return None;
3992    }
3993    match expected_columns {
3994        Some(n) if serials.len() != n => return None,
3995        None if serials.len() < MIN_INFERRED_COLUMNS => return None,
3996        _ => {}
3997    }
3998
3999    // Length closure over the DECLARED payload: header + body must equal P.
4000    let mut body_len = 0usize;
4001    for &s in &serials {
4002        body_len += serial_body_len(s)?;
4003    }
4004    if header_len + body_len != payload_len {
4005        return None;
4006    }
4007
4008    let first_overflow = be_u32(prefix, local_len);
4009    Some(SpilledCell {
4010        offset: off,
4011        byte_len: n1 + n2 + local_len + 4,
4012        payload_len,
4013        rowid,
4014        serials,
4015        local_len,
4016        local_payload_off: payload_start,
4017        first_overflow,
4018    })
4019}
4020
4021/// Salvage the columns of a recognized [`SpilledCell`] whose bodies lie wholly
4022/// within the local payload (task #73, Codex ruling #4): the chain-resident
4023/// columns are dropped (the chain that would supply them failed), and the
4024/// surviving local columns become a [`CellFragment`]. Returns `None` unless the
4025/// salvaged prefix carries ≥ 1 distinctive cell (the §3.1 emission gate). The
4026/// returned fragment's `offset` is region-local; the caller translates it.
4027fn salvage_local_prefix(
4028    region: &[u8],
4029    sc: &SpilledCell,
4030    enc: TextEncoding,
4031) -> Option<CellFragment> {
4032    // The body begins right after the local header; decode each column while its
4033    // body ends within the local payload bytes (`local_payload_off + local_len`).
4034    let local_end = sc.local_payload_off.checked_add(sc.local_len)?;
4035    // Recompute the record header length to find where the body starts.
4036    let (header_len, _hn) = read_varint(region, sc.local_payload_off).ok()?;
4037    let header_len = usize::try_from(header_len).ok()?;
4038    let mut bpos = sc.local_payload_off.checked_add(header_len)?;
4039
4040    let mut surviving: Vec<(usize, Value)> = Vec::new();
4041    for (idx, &serial) in sc.serials.iter().enumerate() {
4042        let Some(blen) = serial_body_len(serial) else {
4043            break; // cov:unreachable: recognizer accepted only legal serials
4044        };
4045        let Some(body_end) = bpos.checked_add(blen) else {
4046            break; // cov:unreachable: usize add of an in-page body length
4047        };
4048        if body_end > local_end {
4049            break; // this column's body spills into the chain — local prefix ends
4050        }
4051        let Some(body) = region.get(bpos..body_end) else {
4052            break; // cov:unreachable: body_end <= local_end <= region.len()
4053        };
4054        // Column 0 of a rowid-alias table reads as the rowid when serial 0; here a
4055        // spilled cell's id column is a stored integer, so decode it directly.
4056        let Ok((val, _)) = decode_value(body, 0, serial, enc) else {
4057            break; // cov:unreachable: legal serials decode in-bounds
4058        };
4059        surviving.push((idx, val));
4060        bpos = body_end;
4061    }
4062
4063    if !surviving.iter().any(|(_, v)| is_distinctive(v)) {
4064        return None;
4065    }
4066    Some(CellFragment {
4067        offset: sc.offset,
4068        byte_len: bpos.saturating_sub(sc.local_payload_off),
4069        missing: sc.serials.len() - surviving.len(),
4070        surviving,
4071        confidence: FRAGMENT_CONFIDENCE,
4072    })
4073}
4074
4075/// Parse + validate the 100-byte file header.
4076fn parse_header(bytes: &[u8]) -> Result<Header, Error> {
4077    let head = bytes.get(..SQLITE_HEADER_SIZE).ok_or(Error::TooShort)?;
4078    if !head.starts_with(SQLITE_MAGIC) {
4079        return Err(Error::BadMagic);
4080    }
4081    let raw = be_u16(head, SQLITE_PAGE_SIZE_OFFSET);
4082    let page_size: u32 = if raw == 1 { 65536 } else { u32::from(raw) };
4083    let valid = (512..=65536).contains(&page_size) && page_size.is_power_of_two();
4084    if !valid {
4085        return Err(Error::BadPageSize(page_size));
4086    }
4087    let reserved = *head.get(RESERVED_SPACE_OFFSET).ok_or(Error::TooShort)?;
4088    // Header byte 56 (BE u32): 1/0 = UTF-8, 2 = UTF-16LE, 3 = UTF-16BE
4089    // (file-format §1.3.1). Tolerant: an unexpected value degrades to UTF-8
4090    // rather than rejecting the database.
4091    let text_encoding = match be_u32(head, TEXT_ENCODING_OFFSET) {
4092        2 => TextEncoding::Utf16Le,
4093        3 => TextEncoding::Utf16Be,
4094        _ => TextEncoding::Utf8,
4095    };
4096    Ok(Header {
4097        page_size,
4098        reserved,
4099        text_encoding,
4100    })
4101}
4102
4103/// Decode a record (payload) into values. Serial type 0 on the first column of
4104/// a rowid table is the `INTEGER PRIMARY KEY` alias → the cell's rowid.
4105fn decode_record(
4106    payload: &[u8],
4107    _column_count: usize,
4108    rowid: i64,
4109    enc: TextEncoding,
4110) -> Result<Vec<Value>, Error> {
4111    let (header_len, n) = read_varint(payload, 0)?;
4112    let header_len = header_len as usize;
4113    if header_len > payload.len() {
4114        return Err(Error::TruncatedCell);
4115    }
4116    // Pass 1: read serial types from the record header.
4117    let mut serials = Vec::new();
4118    let mut hpos = n;
4119    while hpos < header_len {
4120        let (s, used) = read_varint(payload, hpos)?;
4121        serials.push(s);
4122        hpos += used;
4123    }
4124    // Pass 2: read the body, one value per serial type.
4125    let mut values = Vec::with_capacity(serials.len());
4126    let mut bpos = header_len;
4127    for (idx, &serial) in serials.iter().enumerate() {
4128        let (val, size) = decode_value(payload, bpos, serial, enc)?;
4129        let val = if idx == 0 && serial == 0 {
4130            // INTEGER PRIMARY KEY alias: NULL in column 0 reads the rowid.
4131            Value::Integer(rowid)
4132        } else {
4133            val
4134        };
4135        values.push(val);
4136        bpos += size;
4137    }
4138    Ok(values)
4139}
4140
4141/// Decode a single value of the given serial type at `off`. Returns the value
4142/// and the number of body bytes it consumed.
4143fn decode_value(
4144    buf: &[u8],
4145    off: usize,
4146    serial: i64,
4147    enc: TextEncoding,
4148) -> Result<(Value, usize), Error> {
4149    Ok(match serial {
4150        // 0 = NULL; 10/11 are reserved for internal use and surfaced as NULL.
4151        0 | 10 | 11 => (Value::Null, 0),
4152        1 => (
4153            Value::Integer(i64::from(read_be_u64(buf, off, 1)? as i8)),
4154            1,
4155        ),
4156        2 => (
4157            Value::Integer(i64::from(read_be_u64(buf, off, 2)? as i16)),
4158            2,
4159        ),
4160        3 => (Value::Integer(sign_extend(read_be_u64(buf, off, 3)?, 3)), 3),
4161        4 => (
4162            Value::Integer(i64::from(read_be_u64(buf, off, 4)? as i32)),
4163            4,
4164        ),
4165        5 => (Value::Integer(sign_extend(read_be_u64(buf, off, 6)?, 6)), 6),
4166        6 => (Value::Integer(read_be_u64(buf, off, 8)? as i64), 8),
4167        7 => {
4168            let bits = read_be_u64(buf, off, 8)?;
4169            (Value::Real(f64::from_bits(bits)), 8)
4170        }
4171        8 => (Value::Integer(0), 0),
4172        9 => (Value::Integer(1), 0),
4173        n if n >= 12 && n % 2 == 0 => {
4174            let len = ((n - 12) / 2) as usize;
4175            let bytes = buf.get(off..off + len).ok_or(Error::TruncatedCell)?;
4176            (Value::Blob(bytes.to_vec()), len)
4177        }
4178        n => {
4179            // odd, >= 13: text, decoded per the database's text encoding
4180            // (UTF-8 / UTF-16LE / UTF-16BE). Lossy so a corrupt byte can't panic.
4181            let len = ((n - 13) / 2) as usize;
4182            let bytes = buf.get(off..off + len).ok_or(Error::TruncatedCell)?;
4183            (Value::Text(enc.decode(bytes)), len)
4184        }
4185    })
4186}
4187
4188/// Read `width` (1..=8) big-endian bytes into a raw u64 (no sign extension).
4189fn read_be_u64(buf: &[u8], off: usize, width: usize) -> Result<u64, Error> {
4190    let bytes = buf.get(off..off + width).ok_or(Error::TruncatedCell)?;
4191    let mut acc: u64 = 0;
4192    for &b in bytes {
4193        acc = (acc << 8) | u64::from(b);
4194    }
4195    Ok(acc)
4196}
4197
4198/// Sign-extend a `width`-byte (3 or 6) value held in the low bits of `raw`.
4199fn sign_extend(raw: u64, width: usize) -> i64 {
4200    let bits = width * 8;
4201    let shift = 64 - bits;
4202    ((raw as i64) << shift) >> shift
4203}
4204
4205/// Read a `SQLite` varint (1..=9 bytes) at `off`. Returns value + bytes consumed.
4206fn read_varint(buf: &[u8], off: usize) -> Result<(i64, usize), Error> {
4207    let mut result: u64 = 0;
4208    for i in 0..8 {
4209        let b = *buf.get(off + i).ok_or(Error::TruncatedCell)?;
4210        result = (result << 7) | u64::from(b & 0x7f);
4211        if b & 0x80 == 0 {
4212            return Ok((result as i64, i + 1));
4213        }
4214    }
4215    // 9th byte contributes all 8 bits.
4216    let b = *buf.get(off + 8).ok_or(Error::TruncatedCell)?;
4217    result = (result << 8) | u64::from(b);
4218    Ok((result as i64, 9))
4219}
4220
4221/// Bounds-checked big-endian u16; out-of-range yields 0 (never panics).
4222fn be_u16(buf: &[u8], off: usize) -> u16 {
4223    let mut b = [0u8; 2];
4224    if let Some(s) = buf.get(off..off + 2) {
4225        b.copy_from_slice(s);
4226    }
4227    u16::from_be_bytes(b)
4228}
4229
4230/// Byte width of the minimal `SQLite` varint encoding of a non-negative `value`
4231/// (task #73, used to re-derive a clobbered record's `header_len`). Mirrors the
4232/// 7-bit big-endian grouping of [`enc_varint_into`]; a value needing more than 8
4233/// groups uses the 9-byte form. Negative inputs (illegal serial types) are
4234/// treated as a single byte and rejected upstream by `serial_body_len`.
4235fn varint_len(value: i64) -> usize {
4236    if value < 0 {
4237        return 1; // cov:unreachable: callers pass only non-negative serials/lengths
4238    }
4239    enc_varint_into(value as usize).len()
4240}
4241
4242/// Minimal `SQLite` varint encoding of a non-negative `value` (task #73). 7-bit
4243/// big-endian groups, high bit set on every group but the last (file-format §2).
4244pub(crate) fn enc_varint_into(value: usize) -> Vec<u8> {
4245    if value == 0 {
4246        return vec![0];
4247    }
4248    let mut groups = Vec::new();
4249    let mut n = value as u64;
4250    while n > 0 {
4251        groups.push((n & 0x7f) as u8);
4252        n >>= 7;
4253    }
4254    groups.reverse();
4255    let last = groups.len() - 1;
4256    for (i, g) in groups.iter_mut().enumerate() {
4257        if i != last {
4258            *g |= 0x80;
4259        }
4260    }
4261    groups
4262}
4263
4264/// The 8-byte rollback-journal segment magic (`pager.c` `aJournalMagic`).
4265const JOURNAL_MAGIC: [u8; 8] = [0xd9, 0xd5, 0x05, 0xf9, 0x20, 0xa1, 0x63, 0xd7];
4266
4267/// Hard cap on page records walked in one journal segment, to bound work on a
4268/// crafted/garbage journal whose stride scan would otherwise run the file length.
4269const MAX_JOURNAL_RECORDS: usize = 1_000_000;
4270
4271/// Sector-size candidates probed when reconstructing a zeroed (PERSIST) journal
4272/// header. Real VFS sector sizes exceed 512, so 512 is a candidate, not an
4273/// assumption; the page size is also tried (file-format §"Rollback Journal").
4274const SECTOR_CANDIDATES: [u32; 3] = [512, 4096, 0]; // 0 = "use page_size"
4275
4276/// Parsed (or reconstructed) rollback-journal header (design §5).
4277///
4278/// `Valid` is a header whose magic is intact (Tier A — hot journal / crash
4279/// residue): every parameter, including the checksum `nonce`, is authoritative.
4280/// `ReconstructedZeroed` is the PERSIST post-commit case (Tier B): the first
4281/// sector was zeroed on commit, so the page size comes from the main database
4282/// and the sector size from candidate scoring — the nonce is gone, so page
4283/// checksums cannot be verified.
4284#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4285pub enum JournalHeader {
4286    /// Tier A: header magic present; all fields trusted (`pager.c` offsets).
4287    Valid {
4288        /// Page records declared in this segment (`0xFFFFFFFF`/`0` ⇒ walk to EOF).
4289        n_rec: u32,
4290        /// Database page count at transaction start (`dbOrigSize`).
4291        mx_page: u32,
4292        /// Checksum initializer (`cksumInit`), offset 12.
4293        nonce: u32,
4294        /// VFS sector size the header is padded to.
4295        sector_size: u32,
4296        /// Database page size at transaction start.
4297        page_size: u32,
4298    },
4299    /// Tier B: header zeroed (PERSIST post-commit); parameters reconstructed.
4300    ReconstructedZeroed {
4301        /// Page size taken from the main database header (authoritative).
4302        page_size: u32,
4303        /// Sector size selected by candidate scoring (record offset stride).
4304        sector_size: u32,
4305    },
4306}
4307
4308/// One pre-transaction page image recovered from a rollback journal (design §5).
4309#[derive(Debug, Clone, PartialEq, Eq)]
4310pub struct JournalPageImage {
4311    /// 1-based database page number this image restores.
4312    pub pgno: u32,
4313    /// 0-based segment index this record came from.
4314    pub segment: usize,
4315    /// The original page content (`page_size` bytes).
4316    pub bytes: Vec<u8>,
4317    /// `Some(true/false)` in Tier A (nonce known) — whether the stored checksum
4318    /// matched; `None` in Tier B (nonce zeroed, unverifiable).
4319    pub checksum_valid: Option<bool>,
4320}
4321
4322/// A parsed rollback journal: its header tier plus the ordered, first-wins
4323/// page images (design §3/§5). The temporal inverse of the WAL overlay —
4324/// these images are the database as it was BEFORE the last transaction.
4325#[derive(Debug, Clone, PartialEq, Eq)]
4326pub struct RollbackJournal {
4327    header: JournalHeader,
4328    images: Vec<JournalPageImage>,
4329    /// Page numbers that appeared more than once (first occurrence kept), each
4330    /// listed once in first-seen order. Empty for a well-formed journal.
4331    duplicate_pgnos: Vec<u32>,
4332}
4333
4334/// The journal page checksum (`pager.c` `pager_cksum`): `nonce` plus every-200th
4335/// byte from the tail, starting at `page_size - 200` and stepping down by 200
4336/// while the index is positive, using wrapping u32 arithmetic. It detects torn
4337/// page writes; it is not a cryptographic integrity guarantee.
4338fn journal_cksum(nonce: u32, page: &[u8]) -> u32 {
4339    let mut sum = nonce;
4340    let mut x = page.len() as i64 - 200;
4341    while x > 0 {
4342        // x is in (0, page.len()) by the loop bound, so indexing is in-range.
4343        if let Some(&b) = page.get(x as usize) {
4344            sum = sum.wrapping_add(u32::from(b));
4345        }
4346        x -= 200;
4347    }
4348    sum
4349}
4350
4351/// Walk page records of `page_size` bytes from `start`, with the checksum
4352/// `nonce` (`None` ⇒ Tier B, unverifiable), stopping at EOF or after `limit`
4353/// records. Returns the images in file order; a partial trailing record is
4354/// dropped (truncation tolerance). Bounded by [`MAX_JOURNAL_RECORDS`].
4355fn walk_journal_records(
4356    bytes: &[u8],
4357    start: usize,
4358    page_size: usize,
4359    nonce: Option<u32>,
4360    segment: usize,
4361    limit: usize,
4362) -> Vec<JournalPageImage> {
4363    let stride = 4usize.saturating_add(page_size).saturating_add(4);
4364    let mut out = Vec::new();
4365    let mut off = start;
4366    let cap = limit.min(MAX_JOURNAL_RECORDS);
4367    while out.len() < cap {
4368        let Some(rec) = bytes.get(off..off.saturating_add(stride)) else {
4369            break; // EOF or partial trailing record: stop (truncation tolerant).
4370        };
4371        let pgno = u32::from_be_bytes([rec[0], rec[1], rec[2], rec[3]]);
4372        if pgno == 0 {
4373            break; // page 0 is not a valid record; treat as end-of-segment.
4374        }
4375        let page = &rec[4..4 + page_size];
4376        let stored = u32::from_be_bytes([
4377            rec[4 + page_size],
4378            rec[5 + page_size],
4379            rec[6 + page_size],
4380            rec[7 + page_size],
4381        ]);
4382        let checksum_valid = nonce.map(|n| journal_cksum(n, page) == stored);
4383        out.push(JournalPageImage {
4384            pgno,
4385            segment,
4386            bytes: page.to_vec(),
4387            checksum_valid,
4388        });
4389        off = off.saturating_add(stride);
4390    }
4391    out
4392}
4393
4394/// Score a candidate record walk for the Tier-B sector reconstruction: more
4395/// records and all page numbers within `1..=page_bound` rank higher; a record
4396/// count of zero scores zero so an off-stride candidate never wins.
4397fn score_journal_candidate(images: &[JournalPageImage], page_bound: u32) -> usize {
4398    if images.is_empty() {
4399        return 0;
4400    }
4401    let in_range = images
4402        .iter()
4403        .filter(|i| i.pgno >= 1 && i.pgno <= page_bound)
4404        .count();
4405    // All-in-range walks are strongly preferred; weight the in-range fraction so
4406    // a candidate that mostly decodes to impossible page numbers loses to one
4407    // that decodes cleanly even with fewer records.
4408    if in_range == images.len() {
4409        1000 + images.len()
4410    } else {
4411        in_range
4412    }
4413}
4414
4415impl RollbackJournal {
4416    /// LOWER-LEVEL, UNAUTHENTICATED parse (design §5): interpret `bytes` as a
4417    /// rollback journal given an externally-supplied `page_size`. Does NOT bind
4418    /// the journal to a particular database — prefer [`Database::rollback_prior`],
4419    /// which supplies the authoritative page size from the main db.
4420    ///
4421    /// Tier A (magic present) trusts the header and verifies each checksum. Tier B
4422    /// (magic absent — PERSIST post-commit) reconstructs the sector size by
4423    /// candidate scoring and walks records (checksums unverifiable). Robust: a
4424    /// malformed/truncated journal yields fewer images, never a panic; a page size
4425    /// that is not a power of two in `[512, 65536]` is a typed
4426    /// [`Error::BadJournalPageSize`] carrying the offending value.
4427    pub fn parse(bytes: &[u8], page_size: u32) -> Result<Self, Error> {
4428        if !(512..=65536).contains(&page_size) || !page_size.is_power_of_two() {
4429            return Err(Error::BadJournalPageSize(page_size));
4430        }
4431        let ps = page_size as usize;
4432        let page_bound = u32::try_from(bytes.len() / ps.max(1)).unwrap_or(u32::MAX);
4433
4434        let header_valid = bytes.len() >= 28 && bytes.starts_with(&JOURNAL_MAGIC);
4435        if header_valid {
4436            // Tier A: trust the header.
4437            let n_rec = be_u32(bytes, 8);
4438            let nonce = be_u32(bytes, 12);
4439            let mx_page = be_u32(bytes, 16);
4440            let sector_size = be_u32(bytes, 20);
4441            let hdr_page_size = be_u32(bytes, 24);
4442            // nRec ∈ {0, 0xFFFFFFFF} ⇒ walk to EOF; else exactly n_rec records.
4443            let limit = if n_rec == 0 || n_rec == u32::MAX {
4444                MAX_JOURNAL_RECORDS
4445            } else {
4446                n_rec as usize
4447            };
4448            let start = sector_size.max(1) as usize;
4449            let imgs = walk_journal_records(bytes, start, ps, Some(nonce), 0, limit);
4450            let header = JournalHeader::Valid {
4451                n_rec,
4452                mx_page,
4453                nonce,
4454                sector_size,
4455                // The journal's pages are images of THIS db, so the externally
4456                // supplied page size is authoritative; expose it even if the
4457                // header field disagrees (a tampered/mismatched header field).
4458                page_size: if hdr_page_size == page_size {
4459                    hdr_page_size
4460                } else {
4461                    page_size
4462                },
4463            };
4464            return Ok(Self::from_walk(header, imgs));
4465        }
4466
4467        // Tier B: header zeroed/absent (PERSIST post-commit). Score sector
4468        // candidates and pick the best; checksums are unverifiable (nonce gone).
4469        let mut best: Option<(usize, u32, Vec<JournalPageImage>)> = None;
4470        for cand in SECTOR_CANDIDATES {
4471            let sector = if cand == 0 { page_size } else { cand };
4472            let imgs =
4473                walk_journal_records(bytes, sector as usize, ps, None, 0, MAX_JOURNAL_RECORDS);
4474            let score = score_journal_candidate(&imgs, page_bound);
4475            // `map_or(true, …)` not `is_none_or` to keep the library MSRV at 1.80
4476            // (`Option::is_none_or` stabilised in 1.82); clippy is MSRV-aware.
4477            let better = best.as_ref().map_or(true, |(bs, _, _)| score > *bs);
4478            if better && score > 0 {
4479                best = Some((score, sector, imgs));
4480            }
4481        }
4482        // No candidate decoded a single in-range record (garbage, or a journal too
4483        // short for one record): an empty Tier-B journal, sector size unknown →
4484        // page size. Degrade gracefully rather than erroring.
4485        let (sector_size, imgs) = best
4486            .map(|(_, s, i)| (s, i))
4487            .unwrap_or((page_size, Vec::new()));
4488        let header = JournalHeader::ReconstructedZeroed {
4489            page_size,
4490            sector_size,
4491        };
4492        Ok(Self::from_walk(header, imgs))
4493    }
4494
4495    /// Apply first-wins dedup to a walked record set, recording whether any
4496    /// `pgno` repeated (the duplicate-page anomaly, design §3).
4497    fn from_walk(header: JournalHeader, walked: Vec<JournalPageImage>) -> Self {
4498        let mut seen = std::collections::BTreeSet::new();
4499        let mut images = Vec::with_capacity(walked.len());
4500        let mut duplicate_pgnos: Vec<u32> = Vec::new();
4501        for img in walked {
4502            if seen.insert(img.pgno) {
4503                images.push(img);
4504            } else if !duplicate_pgnos.contains(&img.pgno) {
4505                // Keep the FIRST occurrence as the truest pre-transaction image;
4506                // record WHICH page repeated (once) rather than a bare flag, so the
4507                // anomaly can name the offending page number.
4508                duplicate_pgnos.push(img.pgno);
4509            }
4510        }
4511        Self {
4512            header,
4513            images,
4514            duplicate_pgnos,
4515        }
4516    }
4517
4518    /// The parsed (or reconstructed) header.
4519    #[must_use]
4520    pub fn header(&self) -> &JournalHeader {
4521        &self.header
4522    }
4523
4524    /// The ordered, first-wins pre-transaction page images.
4525    #[must_use]
4526    pub fn page_images(&self) -> &[JournalPageImage] {
4527        &self.images
4528    }
4529
4530    /// Whether a `pgno` appeared more than once across the parsed segments — the
4531    /// spec says a page is journaled at most once, so a repeat is consistent with
4532    /// corruption, a savepoint/super-journal artifact, or tampering (design §3).
4533    #[must_use]
4534    pub fn has_duplicate_pgno(&self) -> bool {
4535        !self.duplicate_pgnos.is_empty()
4536    }
4537
4538    /// The page numbers that appeared more than once (first occurrence kept), each
4539    /// listed once in first-seen order — the offending values behind
4540    /// [`Self::has_duplicate_pgno`]. Empty for a well-formed journal.
4541    #[must_use]
4542    pub fn duplicate_pgnos(&self) -> &[u32] {
4543        &self.duplicate_pgnos
4544    }
4545}
4546
4547/// A read-only, page-addressable image of the database AS IT WAS BEFORE the last
4548/// transaction (design §4/§5). The temporal inverse of [`CommitSnapshot`]:
4549/// `prior[pgno]` is the rollback-journal image where present, else the live main
4550/// page. Diffing this against the current database yields the last transaction's
4551/// deletions (rowid present here, absent now) and modifications (present in both,
4552/// values differ — the journal carries the OLD value).
4553///
4554/// Returned by [`Database::rollback_prior`] as a DISTINCT type, never a
4555/// [`Database`], so prior/deleted rows can never be read as "live"
4556/// (secure-by-design). Shares ONE b-tree/overflow walk with the live and
4557/// commit-snapshot reads via the internal `PageSource` seam.
4558#[derive(Debug, Clone, PartialEq, Eq)]
4559pub struct PriorSnapshot {
4560    /// The pre-transaction page images: journal-where-present overlaid on the main
4561    /// db. Materializes EVERY valid journal page type (interior, leaf, overflow,
4562    /// page 1, freelist trunk, pointer-map) so a prior table can be walked through
4563    /// its interior pages and overflow chains reassembled.
4564    overlaid: std::collections::BTreeMap<u32, Vec<u8>>,
4565    /// Usable bytes per page, parsed from the prior snapshot's OWN page-1 header
4566    /// (so a reserved-space change in the last txn is honored).
4567    usable: u32,
4568    /// The 1-based page count bound (max overlaid page), for cycle/over-range
4569    /// guards in the b-tree / overflow walk.
4570    page_bound: u32,
4571    /// Whether any journal page image's number exceeded the current main-db page
4572    /// count — diagnostic only (the txn grew the db).
4573    grew_db: bool,
4574}
4575
4576impl PageSource for PriorSnapshot {
4577    fn page(&self, page: u32) -> Option<&[u8]> {
4578        self.overlaid.get(&page).map(Vec::as_slice)
4579    }
4580    fn usable(&self) -> usize {
4581        self.usable as usize
4582    }
4583    fn page_bound(&self) -> u32 {
4584        self.page_bound
4585    }
4586    fn encoding(&self) -> TextEncoding {
4587        // Encoding from the prior snapshot's OWN page-1 header (byte 56), so a
4588        // historical read decodes TEXT per the encoding as of the prior state.
4589        self.overlaid
4590            .get(&1)
4591            .map(|p| match be_u32(p, TEXT_ENCODING_OFFSET) {
4592                2 => TextEncoding::Utf16Le,
4593                3 => TextEncoding::Utf16Be,
4594                _ => TextEncoding::Utf8,
4595            })
4596            .unwrap_or_default()
4597    }
4598}
4599
4600impl PriorSnapshot {
4601    /// The user tables AS OF the prior state, parsed from the snapshot's OWN page 1
4602    /// (the prior `sqlite_master`), NOT the live database — so a DROP/CREATE in the
4603    /// last transaction is interpreted against the prior schema. Best-effort and
4604    /// panic-free: an unreadable page-1 schema yields an empty vector.
4605    #[must_use]
4606    pub fn tables(&self) -> Vec<SnapshotTable> {
4607        let Ok(schema) = read_table_via(self, 1, 5) else {
4608            return Vec::new(); // cov:unreachable: the prior snapshot has a readable page 1
4609        };
4610        let mut out = Vec::new();
4611        for row in schema {
4612            let is_table = matches!(row.values.first(), Some(Value::Text(t)) if t == "table");
4613            if !is_table {
4614                continue;
4615            }
4616            let Some(Value::Text(name)) = row.values.get(1) else {
4617                continue; // cov:unreachable: a 'table' schema row has a TEXT name
4618            };
4619            if name.starts_with("sqlite_") {
4620                continue;
4621            }
4622            let Some(Value::Integer(root)) = row.values.get(3) else {
4623                continue; // cov:unreachable: a 'table' schema row has an integer rootpage
4624            };
4625            let Ok(rootpage) = u32::try_from(*root) else {
4626                continue; // cov:unreachable: a real rootpage is a small positive page number
4627            };
4628            let sql = match row.values.get(4) {
4629                Some(Value::Text(s)) => s.as_str(),
4630                _ => "", // cov:unreachable: a 'table' schema row carries its CREATE TABLE sql
4631            };
4632            let columns = attribution::column_names(sql).unwrap_or_default();
4633            out.push(SnapshotTable {
4634                name: name.clone(),
4635                rootpage,
4636                columns,
4637                without_rowid: without_rowid_sql(sql),
4638            });
4639        }
4640        out
4641    }
4642
4643    /// The PRIOR `sqlite_master` as a `name -> CREATE SQL` map for every **user**
4644    /// table, parsed from the snapshot's OWN page 1 — the prior-schema half of the
4645    /// Detector-B sidecar schema-change comparison
4646    /// (`docs/design/drop-recreate-attribution.md`).
4647    ///
4648    /// The counterpart to [`Database::schema_sql`] read against the pre-transaction
4649    /// state the `-journal` preserves, so a DROP/CREATE/ALTER in the last
4650    /// transaction is interpreted against the prior schema. Best-effort and
4651    /// panic-free: an unreadable prior page-1 schema yields an empty map.
4652    #[must_use]
4653    pub fn schema_sql(&self) -> std::collections::BTreeMap<String, String> {
4654        let mut out = std::collections::BTreeMap::new();
4655        let Ok(schema) = read_table_via(self, 1, 5) else {
4656            return out; // cov:unreachable: the prior snapshot has a readable page 1
4657        };
4658        for row in schema {
4659            schema_sql_insert(&mut out, &row.values);
4660        }
4661        out
4662    }
4663
4664    /// Read every row of the table b-tree rooted at `rootpage` AS OF the prior
4665    /// state, in rowid order, resolving overflow chains through the snapshot's OWN
4666    /// pages. The snapshot-scoped counterpart to [`Database::read_table`]: a typed
4667    /// [`Error`] (never a panic) on a cyclic/over-deep b-tree or overflow chain.
4668    pub fn read_table(
4669        &self,
4670        rootpage: u32,
4671        column_count: usize,
4672    ) -> Result<Vec<(i64, Vec<Value>)>, Error> {
4673        let rows = read_table_via(self, rootpage, column_count)?;
4674        Ok(rows.into_iter().map(|r| (r.rowid, r.values)).collect())
4675    }
4676
4677    /// Whether the last transaction GREW the database (a journal page number
4678    /// exceeded the current main-db page count). Pages beyond the prior size are
4679    /// new — their pre-images were not journaled — which bounds what rolls back.
4680    #[must_use]
4681    pub fn grew_db(&self) -> bool {
4682        self.grew_db
4683    }
4684
4685    /// Read the table rooted at `rootpage` AS OF the prior state, returning each
4686    /// row's rowid, values, AND the 1-based LEAF page it was decoded from — the
4687    /// per-row page provenance the forensic diff attaches to a recovered prior
4688    /// row. Shares `decode_leaf_cell` with the standard read; a typed [`Error`]
4689    /// (never a panic) on a cyclic/over-deep b-tree.
4690    pub fn read_table_with_pages(
4691        &self,
4692        rootpage: u32,
4693        column_count: usize,
4694    ) -> Result<Vec<(i64, Vec<Value>, u32)>, Error> {
4695        let mut out = Vec::new();
4696        let mut seen = std::collections::BTreeSet::new();
4697        walk_table_page_with_leaf(self, rootpage, column_count, &mut out, &mut seen)?;
4698        Ok(out)
4699    }
4700}
4701
4702/// Walk a table b-tree like [`walk_table_page`] but record each row's LEAF page,
4703/// for the rollback-journal per-row provenance. Bounded identically (visited-set
4704/// caps recursion depth; a revisited page is silently skipped).
4705fn walk_table_page_with_leaf(
4706    src: &dyn PageSource,
4707    page: u32,
4708    column_count: usize,
4709    out: &mut Vec<(i64, Vec<Value>, u32)>,
4710    seen: &mut std::collections::BTreeSet<u32>,
4711) -> Result<(), Error> {
4712    if seen.len() > MAX_PAGES_PER_WALK {
4713        return Err(Error::TooManyPages);
4714    }
4715    if !seen.insert(page) {
4716        return Ok(());
4717    }
4718    let slice = src.page(page).ok_or(Error::PageOutOfRange(page))?;
4719    let hdr_off = if page == 1 { SQLITE_HEADER_SIZE } else { 0 };
4720    let page_type = *slice.get(hdr_off).ok_or(Error::TruncatedCell)?;
4721    let cell_count = be_u16(slice, hdr_off + 3) as usize;
4722    match page_type {
4723        0x0d => {
4724            let cell_ptr_array = hdr_off + 8;
4725            for i in 0..cell_count {
4726                let p = cell_ptr_array + i * 2;
4727                let cell_off = be_u16(slice, p) as usize;
4728                let row = decode_leaf_cell(src, slice, cell_off, column_count)?;
4729                out.push((row.rowid, row.values, page));
4730            }
4731            Ok(())
4732        }
4733        0x05 => {
4734            let cell_ptr_array = hdr_off + 12;
4735            for i in 0..cell_count {
4736                let p = cell_ptr_array + i * 2;
4737                let cell_off = be_u16(slice, p) as usize;
4738                let child = be_u32(slice, cell_off);
4739                walk_table_page_with_leaf(src, child, column_count, out, seen)?;
4740            }
4741            let right = be_u32(slice, hdr_off + 8);
4742            walk_table_page_with_leaf(src, right, column_count, out, seen)
4743        }
4744        other => Err(Error::NotATablePage(other)),
4745    }
4746}
4747
4748/// Bounds-checked big-endian u32; out-of-range yields 0 (never panics).
4749fn be_u32(buf: &[u8], off: usize) -> u32 {
4750    let mut b = [0u8; 4];
4751    if let Some(s) = buf.get(off..off + 4) {
4752        b.copy_from_slice(s);
4753    }
4754    u32::from_be_bytes(b)
4755}
4756
4757#[cfg(test)]
4758mod tests {
4759    use super::*;
4760
4761    #[test]
4762    fn varint_single_byte() {
4763        assert_eq!(read_varint(&[0x05], 0).unwrap(), (5, 1));
4764    }
4765
4766    #[test]
4767    fn varint_two_bytes() {
4768        // 0x81 0x00 => (1<<7) = 128
4769        assert_eq!(read_varint(&[0x81, 0x00], 0).unwrap(), (128, 2));
4770    }
4771
4772    #[test]
4773    fn varint_truncated_is_err() {
4774        assert_eq!(read_varint(&[0x81], 0), Err(Error::TruncatedCell));
4775    }
4776
4777    #[test]
4778    fn sign_extend_three_byte_negative() {
4779        // 0xFFFFFF as 3-byte => -1
4780        assert_eq!(sign_extend(0x00FF_FFFF, 3), -1);
4781    }
4782
4783    #[test]
4784    fn decode_value_text_and_blob() {
4785        let (v, n) = decode_value(b"hi", 0, 17, TextEncoding::Utf8).unwrap(); // 17 => text len (17-13)/2 =2
4786        assert_eq!(v, Value::Text("hi".into()));
4787        assert_eq!(n, 2);
4788        let (v, n) = decode_value(&[0xAA, 0xBB], 0, 16, TextEncoding::Utf8).unwrap(); // 16 => blob len 2
4789        assert_eq!(v, Value::Blob(vec![0xAA, 0xBB]));
4790        assert_eq!(n, 2);
4791    }
4792
4793    #[test]
4794    fn decode_value_text_utf16_le_and_be() {
4795        // The TEXT decode path honors the database encoding (file-format §1.3.1):
4796        // the same code points must round-trip from both byte orders. This drives
4797        // `decode_utf16` deterministically, without depending on an external
4798        // `sqlite3`-minted fixture (the integration tests skip when absent).
4799        // Serial 21 => text byte length (21-13)/2 = 4 = two UTF-16 code units.
4800        let le = [b'h', 0x00, b'i', 0x00];
4801        let (v, n) = decode_value(&le, 0, 21, TextEncoding::Utf16Le).unwrap();
4802        assert_eq!(v, Value::Text("hi".into()));
4803        assert_eq!(n, 4);
4804        let be = [0x00, b'h', 0x00, b'i'];
4805        let (v, n) = decode_value(&be, 0, 21, TextEncoding::Utf16Be).unwrap();
4806        assert_eq!(v, Value::Text("hi".into()));
4807        assert_eq!(n, 4);
4808    }
4809
4810    #[test]
4811    fn decode_value_int_literals() {
4812        assert_eq!(
4813            decode_value(&[], 0, 8, TextEncoding::Utf8).unwrap(),
4814            (Value::Integer(0), 0)
4815        );
4816        assert_eq!(
4817            decode_value(&[], 0, 9, TextEncoding::Utf8).unwrap(),
4818            (Value::Integer(1), 0)
4819        );
4820    }
4821
4822    #[test]
4823    fn bad_magic_rejected() {
4824        let mut b = vec![0u8; 100];
4825        b[..16].copy_from_slice(b"NOT SQLITE 3\0\0\0\0");
4826        assert_eq!(parse_header(&b), Err(Error::BadMagic));
4827    }
4828
4829    #[test]
4830    fn too_short_rejected() {
4831        assert_eq!(parse_header(&[0u8; 10]), Err(Error::TooShort));
4832    }
4833
4834    /// The deleted-record carving fixture (see `docs/corpus-catalog.md`).
4835    const DELETED_DB: &[u8] = include_bytes!("../../tests/data/deleted_places.db");
4836    /// A clean DB with one live `moz_places` table and no deletions.
4837    const CLEAN_DB: &[u8] = include_bytes!("../../tests/data/places.db");
4838
4839    #[test]
4840    fn free_regions_is_complement_of_live_extents() {
4841        // Live cells [10,20) and [30,40) within content area [5, 50).
4842        let live = [(10, 20), (30, 40)];
4843        let regions = free_regions(&live, 5, 50);
4844        assert_eq!(regions, vec![(5, 10), (20, 30), (40, 50)]);
4845        // No live cells -> the whole span is free.
4846        assert_eq!(free_regions(&[], 5, 50), vec![(5, 50)]);
4847        // Live cell covering the whole span -> no free region.
4848        assert!(free_regions(&[(0, 100)], 5, 50).is_empty());
4849    }
4850
4851    #[test]
4852    fn live_cell_len_reads_on_page_footprint() {
4853        // Cell: payload_len=3 (varint 0x03), rowid=1 (varint 0x01), 3 payload bytes.
4854        let buf = [0x03, 0x01, 0xAA, 0xBB, 0xCC];
4855        let usable = 4096;
4856        assert_eq!(live_cell_len(&buf, 0, usable), Some(1 + 1 + 3));
4857        // Truncated prefix -> None, never panics.
4858        assert_eq!(live_cell_len(&[0x81], 0, usable), None);
4859    }
4860
4861    #[test]
4862    fn carve_free_regions_recovers_in_page_remnant() {
4863        let db = Database::open(DELETED_DB.to_vec()).unwrap();
4864        // Page 8 is an allocated leaf (live ids 181..=200) whose free gap holds
4865        // deleted-row residue including rowid 237.
4866        let page = db.raw_page(8).unwrap();
4867        let carved = db.carve_free_regions(page, 6);
4868        assert!(carved.iter().any(|c| c.rowid == 237));
4869        // 0-FP: never a live (id<=200) rowid.
4870        assert!(carved.iter().all(|c| c.rowid > 200));
4871        // A non-leaf page yields nothing.
4872        assert!(db.carve_free_regions(&[0x05u8; 4096], 6).is_empty());
4873        // An empty / too-short slice yields nothing (no panic).
4874        assert!(db.carve_free_regions(&[], 6).is_empty());
4875    }
4876
4877    #[test]
4878    fn carve_leaf_cells_reads_allocated_cells_and_rejects_non_leaf() {
4879        let db = Database::open(DELETED_DB.to_vec()).unwrap();
4880        // Page 8 is an allocated table-leaf (live ids 181..=200); carve_leaf_cells
4881        // decodes every cell the page records as allocated, so the live ids appear
4882        // (unlike carve_free_regions, which excludes them).
4883        let page = db.raw_page(8).unwrap();
4884        let cells = db.carve_leaf_cells(page);
4885        assert!(
4886            cells.iter().any(|c| c.rowid == 181),
4887            "must read the allocated cells of the leaf"
4888        );
4889        // Page 1 is passed whole (starts with the file magic) → header read at 100.
4890        let _ = db.carve_leaf_cells(db.raw_page(1).unwrap());
4891        // A non-leaf page (interior 0x05) and an empty/too-short slice yield nothing
4892        // (no panic) — the same defensive arms carve_free_regions guards.
4893        assert!(db.carve_leaf_cells(&[0x05u8; 4096]).is_empty());
4894        assert!(db.carve_leaf_cells(&[]).is_empty());
4895    }
4896
4897    #[test]
4898    fn carve_free_regions_handles_page_one_and_inferred() {
4899        let db = Database::open(DELETED_DB.to_vec()).unwrap();
4900        // Page 1 is passed whole (starts with the file magic) -> the b-tree header
4901        // is read at offset 100, exercising the page-1 branch.
4902        let page1 = db.raw_page(1).unwrap();
4903        let _ = db.carve_free_regions(page1, 6);
4904        // With column_count_hint = 0, the inferred path runs over the free regions.
4905        let page8 = db.raw_page(8).unwrap();
4906        let inferred = db.carve_free_regions(page8, 0);
4907        assert!(inferred.iter().any(|c| c.rowid == 237));
4908    }
4909
4910    #[test]
4911    fn live_cell_len_accounts_for_overflow_pointer() {
4912        let usable = 4096usize;
4913        // Non-spilling cell: payload_len small -> footprint = prefix + payload.
4914        // varint 0x03 (payload_len=3), 0x01 (rowid=1), 3 payload bytes.
4915        assert_eq!(live_cell_len(&[0x03, 0x01, 0, 0, 0], 0, usable), Some(5));
4916
4917        // Spilling cell: a payload_len far above the local threshold takes the
4918        // overflow branch -> footprint = prefix + local + 4 (overflow pointer).
4919        // Encode payload_len = 5000 as a 2-byte varint (0xA7 0x08), rowid = 1.
4920        let mut buf = vec![0xA7, 0x08, 0x01];
4921        buf.extend(std::iter::repeat_n(0u8, 5000));
4922        let total = 5000usize;
4923        let local = local_payload_len(total, usable);
4924        assert!(local < total, "this payload must spill");
4925        assert_eq!(live_cell_len(&buf, 0, usable), Some(2 + 1 + local + 4));
4926    }
4927
4928    #[test]
4929    fn carve_cells_inferred_matches_fixed_count() {
4930        let db = Database::open(DELETED_DB.to_vec()).unwrap();
4931        // A freed leaf page body carves the same rows whether the column count is
4932        // fixed at 6 or inferred.
4933        let page = db.raw_page(10).unwrap();
4934        let fixed = db.carve_cells(page, 6);
4935        let inferred = db.carve_cells_inferred(page);
4936        assert!(!fixed.is_empty());
4937        let fixed_ids: std::collections::BTreeSet<i64> = fixed.iter().map(|c| c.rowid).collect();
4938        let inf_ids: std::collections::BTreeSet<i64> = inferred.iter().map(|c| c.rowid).collect();
4939        assert!(fixed_ids.is_subset(&inf_ids));
4940    }
4941
4942    #[test]
4943    fn has_user_table_distinguishes_live_and_dropped() {
4944        let live = Database::open(CLEAN_DB.to_vec()).unwrap();
4945        assert!(live.has_user_table());
4946        let with_deletions = Database::open(DELETED_DB.to_vec()).unwrap();
4947        assert!(with_deletions.has_user_table());
4948    }
4949
4950    #[test]
4951    fn live_rowids_collects_live_rows_only() {
4952        let db = Database::open(CLEAN_DB.to_vec()).unwrap();
4953        let ids = db.live_rowids();
4954        // places.db has 5 live rows, rowids 1..=5.
4955        assert_eq!(ids.len(), 5);
4956        assert!(ids.contains(&1) && ids.contains(&5));
4957
4958        // On the deletions fixture, live rowids are 1..=200; none of the deleted
4959        // 201..=400 appear.
4960        let del = Database::open(DELETED_DB.to_vec()).unwrap();
4961        let live = del.live_rowids();
4962        assert!(live.contains(&1) && live.contains(&200));
4963        assert!(!live.contains(&201) && !live.contains(&400));
4964    }
4965
4966    #[test]
4967    fn live_rows_decodes_current_values() {
4968        let db = Database::open(CLEAN_DB.to_vec()).unwrap();
4969        let rows = db.live_rows();
4970        // places.db has 5 live rows keyed by rowid 1..=5, each decoded to values.
4971        assert_eq!(rows.len(), 5);
4972        // Row 1's url column (index 1) is the rust-lang URL (cross-checks that
4973        // values are decoded, not just rowids collected).
4974        let r1 = rows.get(&1).expect("row 1 present");
4975        assert!(
4976            matches!(r1.get(1), Some(Value::Text(t)) if t.contains("rust-lang")),
4977            "row 1 values must be decoded: {r1:?}"
4978        );
4979        // The value map and the rowid set agree on which rows are live.
4980        let ids = db.live_rowids();
4981        assert_eq!(
4982            rows.keys().copied().collect::<Vec<_>>(),
4983            ids.into_iter().collect::<Vec<_>>()
4984        );
4985
4986        // The deletions fixture's table b-tree has an INTERIOR root page (0x05),
4987        // so this exercises the interior-walk branch of collect_rows and confirms
4988        // values are decoded for all 200 live rows.
4989        let del = Database::open(DELETED_DB.to_vec()).unwrap();
4990        let del_rows = del.live_rows();
4991        assert_eq!(del_rows.len(), 200);
4992        let r1 = del_rows.get(&1).expect("live row 1");
4993        assert!(
4994            matches!(r1.get(1), Some(Value::Text(t)) if t.contains("site-1.example")),
4995            "interior-walked live row 1 must decode its url: {r1:?}"
4996        );
4997    }
4998
4999    #[test]
5000    fn live_table_rows_dumps_each_user_table_in_rowid_order() {
5001        let db = Database::open(CLEAN_DB.to_vec()).unwrap();
5002        let dumps = db.live_table_rows();
5003        // places.db has exactly one user table (moz_places); sqlite_* excluded.
5004        assert_eq!(dumps.len(), 1, "one user-table dump expected: {dumps:?}");
5005        let t = &dumps[0];
5006        assert_eq!(t.name, "moz_places");
5007        // Real column names come from the CREATE TABLE, not generic c0..cN.
5008        assert!(
5009            t.column_names.iter().any(|c| c == "url"),
5010            "real column names expected: {:?}",
5011            t.column_names
5012        );
5013        // The rowids must be the live set, in ascending order.
5014        let rowids: Vec<i64> = t.rows.iter().map(|r| r.rowid).collect();
5015        assert_eq!(rowids, vec![1, 2, 3, 4, 5], "rowid order: {rowids:?}");
5016        // The url cell of row 1 decodes (cross-check values are real).
5017        assert!(
5018            matches!(t.rows[0].values.get(1), Some(Value::Text(s)) if s.contains("rust-lang")),
5019            "row 1 url must decode: {:?}",
5020            t.rows[0].values
5021        );
5022    }
5023
5024    #[test]
5025    fn live_table_rows_excludes_internal_tables_and_handles_interior_btree() {
5026        // The deletions fixture has an INTERIOR root page; all 200 live rows dump
5027        // in ascending rowid order, and no sqlite_* table appears.
5028        let db = Database::open(DELETED_DB.to_vec()).unwrap();
5029        let dumps = db.live_table_rows();
5030        assert!(
5031            dumps.iter().all(|t| !t.name.starts_with("sqlite_")),
5032            "internal tables excluded: {:?}",
5033            dumps.iter().map(|t| &t.name).collect::<Vec<_>>()
5034        );
5035        let places = dumps
5036            .iter()
5037            .find(|t| t.name == "moz_places")
5038            .expect("moz_places dump");
5039        assert_eq!(places.rows.len(), 200, "all live rows dumped");
5040        let ids: Vec<i64> = places.rows.iter().map(|r| r.rowid).collect();
5041        assert!(
5042            ids.windows(2).all(|w| w[0] < w[1]),
5043            "rows in ascending rowid order"
5044        );
5045        assert_eq!(*ids.first().unwrap(), 1);
5046        assert_eq!(*ids.last().unwrap(), 200);
5047    }
5048
5049    #[test]
5050    fn live_table_rows_falls_back_to_generic_columns_on_unparseable_schema() {
5051        // Robustness: a damaged CREATE TABLE whose column list cannot be parsed
5052        // must dump the table with generic c0..cN columns (never a fabricated
5053        // real header), while its rows still read. Mint a valid db, then blank out
5054        // the `( ... )` column list in the stored schema SQL in place (same byte
5055        // length), so column_defs yields None for that table.
5056        use crate::rebuild::{build_recovered_db_tables, RecoveredTable as RT};
5057        let seed = vec![RT {
5058            name: "people".to_string(),
5059            columns: vec!["id".to_string(), "name".to_string()],
5060            rows: vec![vec![Value::Integer(1), Value::Text("alice".into())]],
5061        }];
5062        let mut bytes = build_recovered_db_tables(&seed);
5063
5064        // Find the stored `CREATE TABLE "people" (...)` text and overwrite from the
5065        // first '(' through the matching ')' with spaces, leaving `CREATE TABLE
5066        // "people"` (no column list) — unparseable to column_defs.
5067        let needle = b"CREATE TABLE \"people\"";
5068        let start = bytes
5069            .windows(needle.len())
5070            .position(|w| w == needle)
5071            .expect("schema SQL present");
5072        let open = bytes[start..]
5073            .iter()
5074            .position(|&b| b == b'(')
5075            .map(|p| start + p)
5076            .expect("column list open paren");
5077        let close = bytes[open..]
5078            .iter()
5079            .position(|&b| b == b')')
5080            .map(|p| open + p)
5081            .expect("column list close paren");
5082        for b in &mut bytes[open..=close] {
5083            *b = b' ';
5084        }
5085
5086        let db = Database::open(bytes).expect("corrupted-schema db still opens");
5087        let dumps = db.live_table_rows();
5088        let people = dumps
5089            .iter()
5090            .find(|t| t.name == "people")
5091            .expect("people dump present");
5092        // Generic columns sized to the row width (2), never the real id/name.
5093        assert_eq!(
5094            people.column_names,
5095            vec!["c0".to_string(), "c1".to_string()]
5096        );
5097        // The row still decoded despite the schema damage.
5098        assert_eq!(people.rows.len(), 1);
5099        assert_eq!(people.rows[0].values.first(), Some(&Value::Integer(1)));
5100    }
5101
5102    /// Real-corpus freeblock reconstruction: 0C-01 page 2 has six freeblock-head
5103    /// cells the forward parser cannot reach; reconstruction recovers them
5104    /// (including the destroyed-rowid `id` column) from the surviving serial tail.
5105    const NEMETZ_0C_01: &[u8] = include_bytes!("../../tests/data/nemetz/0C/0C-01.db");
5106
5107    #[test]
5108    fn reconstruct_freeblock_records_recovers_clobbered_rows() {
5109        let db = Database::open(NEMETZ_0C_01.to_vec()).unwrap();
5110        let page = db.raw_page(2).unwrap();
5111        let recovered = db.reconstruct_freeblock_records(page);
5112        // Row 20005 is a freeblock-head cell only reconstruction can recover.
5113        assert!(recovered.iter().any(|c| c.values
5114            == vec![
5115                Value::Integer(20005),
5116                Value::Integer(3_780_322_152),
5117                Value::Integer(3_909_007_646),
5118                Value::Integer(120_462_986),
5119                Value::Integer(1_290_558_629),
5120            ]));
5121        assert!(recovered
5122            .iter()
5123            .all(|c| c.rowid == 0 && c.confidence <= 0.5));
5124    }
5125
5126    /// Real-corpus span-walking reconstruction (task #66): 0D-07 page 3 coalesces
5127    /// three deleted cells into a single freeblock `[0xf79,0xfe0)` —
5128    /// `Luca|Schumacher` (the head), then `Kurt|Schubert`, then `Georg|Schulz`,
5129    /// each prefixed by a stale `00 00 00 NN` freeblock header that clobbers its
5130    /// leading four bytes. A single-shot head reconstruction recovers only the
5131    /// first; walking the template across the whole span recovers all three.
5132    const NEMETZ_0D_07: &[u8] = include_bytes!("../../tests/data/nemetz/0D/0D-07.db");
5133
5134    #[test]
5135    fn reconstruct_freeblock_records_walks_coalesced_cells() {
5136        let db = Database::open(NEMETZ_0D_07.to_vec()).unwrap();
5137        let page = db.raw_page(3).unwrap();
5138        let recovered = db.reconstruct_freeblock_records(page);
5139        let has = |name: &str, surname: &str| {
5140            recovered.iter().any(|c| {
5141                matches!(c.values.get(1), Some(Value::Text(t)) if t == name)
5142                    && matches!(c.values.get(2), Some(Value::Text(t)) if t == surname)
5143            })
5144        };
5145        // The span-head cell a single-shot reconstruction already reached.
5146        assert!(has("Luca", "Schumacher"), "head cell must be recovered");
5147        // The two trailing cells deeper inside the same freeblock — only a
5148        // span-walk reaches these.
5149        assert!(
5150            has("Kurt", "Schubert"),
5151            "second coalesced cell must be recovered"
5152        );
5153        assert!(
5154            has("Georg", "Schulz"),
5155            "third coalesced cell must be recovered"
5156        );
5157        // Every reconstruction carries a destroyed rowid and low confidence.
5158        assert!(recovered
5159            .iter()
5160            .all(|c| c.rowid == 0 && c.confidence <= 0.5));
5161    }
5162
5163    /// Helper: a real opened DB to call the page-slice methods against crafted
5164    /// page byte slices (the methods take `page_bytes` explicitly).
5165    fn opened() -> Database {
5166        Database::open(NEMETZ_0C_01.to_vec()).unwrap()
5167    }
5168
5169    /// A leaf page advertising a freeblock chain but whose cells do not parse
5170    /// yields no template, so reconstruction returns empty (covers the
5171    /// `freeblock_template` rejection arms and the final `None`).
5172    #[test]
5173    fn reconstruct_freeblock_records_without_template_is_empty() {
5174        let db = opened();
5175        let mut page = vec![0u8; 256];
5176        page[0] = 0x0d; // table-leaf
5177        page[1] = 0x00;
5178        page[2] = 0x40; // first freeblock at offset 64
5179        page[3] = 0x00;
5180        page[4] = 0x01; // cell_count = 1
5181                        // The single cell pointer (offset 8) points at 0 -> cell_off == 0 -> skipped,
5182                        // so no template can be derived.
5183        page[8] = 0x00;
5184        page[9] = 0x00;
5185        // A freeblock at 64: next=0, size=8 (in-bounds), but no template anyway.
5186        page[64] = 0x00;
5187        page[65] = 0x00;
5188        page[66] = 0x00;
5189        page[67] = 0x08;
5190        assert!(db.reconstruct_freeblock_records(&page).is_empty());
5191    }
5192
5193    /// A cyclic freeblock `next` chain terminates (covers the cycle-break guard)
5194    /// and a freeblock whose size runs past the page is skipped — all without a
5195    /// panic.
5196    #[test]
5197    fn reconstruct_freeblock_records_breaks_cyclic_chain() {
5198        let db = opened();
5199        // Build a page WITH a usable template by copying 0C-01 page 2's header +
5200        // first live cell, then point the freeblock chain at itself.
5201        let src = db.raw_page(2).unwrap().to_vec();
5202        let mut page = src.clone();
5203        // Repoint first-freeblock to a self-cycle at offset 100: next -> 100.
5204        page[1] = 0x00;
5205        page[2] = 100;
5206        page[100] = 0x00;
5207        page[101] = 100; // next = 100 (points to itself)
5208        page[102] = 0xff;
5209        page[103] = 0xff; // size huge -> runs past page -> skipped
5210                          // Must not panic and must terminate.
5211        let _ = db.reconstruct_freeblock_records(&page);
5212    }
5213
5214    // ---- Tier-2 fragment salvage (task #72) --------------------------------
5215
5216    #[test]
5217    fn is_distinctive_classifies_every_storage_class() {
5218        // TEXT >= 4 UTF-8 bytes and REAL are distinctive; everything else is not.
5219        assert!(is_distinctive(&Value::Text("Anja".into())));
5220        assert!(is_distinctive(&Value::Text("\u{00e4}\u{00f6}".into()))); // 4 UTF-8 bytes
5221        assert!(is_distinctive(&Value::Real(3.5)));
5222        assert!(!is_distinctive(&Value::Text("abc".into()))); // 3 bytes
5223        assert!(!is_distinctive(&Value::Text(String::new())));
5224        assert!(!is_distinctive(&Value::Text("ab\u{fffd}x".into()))); // replacement char
5225        assert!(!is_distinctive(&Value::Integer(20004)));
5226        assert!(!is_distinctive(&Value::Null));
5227        assert!(!is_distinctive(&Value::Blob(vec![1, 2, 3, 4, 5])));
5228    }
5229
5230    /// Build a synthetic 256-byte table-leaf (0x0d) page for the fragment tests.
5231    ///
5232    /// Schema implied by the template live cell: 3 columns
5233    /// `(c0: 1-byte int, c1: TEXT-4, c2: TEXT-4)` → serials `[1, 21, 21]`,
5234    /// `header_len = 4`. The live cell (the freeblock template source) is placed
5235    /// at `live_off`. A single freeblock spanning `[fb, fb + fb_size)` holds the
5236    /// freed-cell payload `freed`, whose leading 4 bytes are the stale freeblock
5237    /// header (`next`, `size`) — exactly what freeblock conversion clobbers.
5238    fn synth_frag_page(live_off: usize, fb: usize, fb_size: usize, freed: &[u8]) -> Vec<u8> {
5239        let mut page = vec![0u8; 256];
5240        page[0] = 0x0d; // table-leaf
5241        page[1] = (fb >> 8) as u8;
5242        page[2] = (fb & 0xff) as u8;
5243        page[3] = 0x00;
5244        page[4] = 0x01; // cell_count = 1
5245        page[5] = (live_off >> 8) as u8;
5246        page[6] = (live_off & 0xff) as u8; // cellContentArea = live_off
5247        page[8] = (live_off >> 8) as u8;
5248        page[9] = (live_off & 0xff) as u8; // cell pointer -> live_off
5249
5250        // Live template cell: payload_len=13, rowid=5, header_len=4, serials
5251        // [int1, text4, text4], body 1+4+4.
5252        let live = [
5253            13u8, 5u8, 0x04, 0x01, 0x15, 0x15, 0x09, b'L', b'i', b'v', b'e', b'R', b'o', b'w', b'!',
5254        ];
5255        page[live_off..live_off + live.len()].copy_from_slice(&live);
5256
5257        // Lay the freed-cell bytes first, then stamp the stale freeblock header
5258        // (next=0, size=fb_size) over its first 4 bytes — exactly what freeblock
5259        // conversion does (the header clobbers the freed cell's leading 4 bytes).
5260        page[fb..fb + freed.len()].copy_from_slice(freed);
5261        page[fb] = 0x00;
5262        page[fb + 1] = 0x00;
5263        page[fb + 2] = (fb_size >> 8) as u8;
5264        page[fb + 3] = (fb_size & 0xff) as u8;
5265        page
5266    }
5267
5268    /// (a) Truncated tail: the freed cell's body overruns the freeblock span, so
5269    /// full reconstruction fails — salvage emits the decodable column prefix
5270    /// (incl. a distinctive TEXT cell) with correct `missing`/confidence, while
5271    /// `reconstruct_freeblock_records` recovers nothing from that anchor.
5272    #[test]
5273    fn fragment_salvage_truncated_tail() {
5274        let db = opened();
5275        // surviving serials [21,21] at fb+4,fb+5; body c0(1)+c1(4)+c2(4) at fb+6.
5276        // A full record needs fb+15. Span size 12 ends at fb+12: c0,c1 fit, c2
5277        // overruns → salvage keeps [c0, c1].
5278        let mut freed = vec![0u8; 16];
5279        freed[4] = 0x15;
5280        freed[5] = 0x15;
5281        freed[6] = 0x07;
5282        freed[7..11].copy_from_slice(b"Anja");
5283        freed[11..15].copy_from_slice(b"Frnk");
5284        let page = synth_frag_page(96, 64, 12, &freed);
5285
5286        let frags = db.reconstruct_freeblock_fragments(&page);
5287        assert_eq!(frags.len(), 1, "exactly one fragment salvaged");
5288        let f = &frags[0];
5289        assert_eq!(f.offset, 64);
5290        assert_eq!(
5291            f.surviving,
5292            vec![(0, Value::Integer(7)), (1, Value::Text("Anja".into()))]
5293        );
5294        assert_eq!(f.missing, 1, "c2 did not decode");
5295        assert!((f.confidence - 0.2).abs() < f32::EPSILON);
5296        let cells = db.reconstruct_freeblock_records(&page);
5297        // The page's only freeblock anchor is the truncated one at offset 64, and
5298        // full reconstruction recovers nothing from it — so the full-record set is
5299        // empty. Asserting emptiness is the precise, deterministic intent.
5300        assert!(
5301            cells.is_empty(),
5302            "the truncated anchor yields no full record, got {}",
5303            cells.len()
5304        );
5305    }
5306
5307    /// (b) A surviving column whose body cannot fit ends the prefix early —
5308    /// salvage keeps the columns decoded before the failure.
5309    #[test]
5310    fn fragment_salvage_partial_tail() {
5311        let db = opened();
5312        let mut freed = vec![0u8; 16];
5313        freed[4] = 0x15;
5314        freed[5] = 0x15;
5315        freed[6] = 0x07;
5316        freed[7..11].copy_from_slice(b"Lena");
5317        let page = synth_frag_page(96, 64, 11, &freed); // c1 fits, c2 overruns
5318        let frags = db.reconstruct_freeblock_fragments(&page);
5319        assert_eq!(frags.len(), 1);
5320        assert_eq!(
5321            frags[0].surviving,
5322            vec![(0, Value::Integer(7)), (1, Value::Text("Lena".into()))]
5323        );
5324    }
5325
5326    /// (c) A fully reconstructable freeblock yields NO fragment (mutual exclusion).
5327    #[test]
5328    fn fragment_salvage_full_record_yields_no_fragment() {
5329        let db = opened();
5330        let mut freed = vec![0u8; 16];
5331        freed[4] = 0x15;
5332        freed[5] = 0x15;
5333        freed[6] = 0x07;
5334        freed[7..11].copy_from_slice(b"Whol");
5335        freed[11..15].copy_from_slice(b"Erow");
5336        let page = synth_frag_page(96, 64, 15, &freed);
5337        let cells = db.reconstruct_freeblock_records(&page);
5338        assert!(
5339            cells.iter().any(|c| c.offset == 64),
5340            "full record recovered"
5341        );
5342        assert!(
5343            db.reconstruct_freeblock_fragments(&page).is_empty(),
5344            "no fragment when the full record is recoverable"
5345        );
5346    }
5347
5348    /// (d) Salvage yielding only non-distinctive (INTEGER) cells emits NO fragment.
5349    #[test]
5350    fn fragment_salvage_integer_only_is_rejected() {
5351        let db = opened();
5352        let mut freed = vec![0u8; 12];
5353        freed[4] = 0x01; // surviving 1-byte int
5354        freed[5] = 0x01; // surviving 1-byte int
5355        freed[6] = 0x07;
5356        freed[7] = 0x08;
5357        let page = synth_frag_page(96, 64, 8, &freed); // c2 overruns; only ints decode
5358        assert!(
5359            db.reconstruct_freeblock_fragments(&page).is_empty(),
5360            "integer-only prefix is not distinctive — no fragment"
5361        );
5362    }
5363
5364    /// (e) Fragment salvage does NOT extend the span walk: a failed head stops
5365    /// the walk, emitting at most one fragment, never sliding forward.
5366    #[test]
5367    fn fragment_salvage_does_not_extend_walk() {
5368        let db = opened();
5369        let mut freed = vec![0u8; 16];
5370        freed[4] = 0x15;
5371        freed[5] = 0x15;
5372        freed[6] = 0x07;
5373        freed[7..11].copy_from_slice(b"Stop");
5374        freed[11..15].copy_from_slice(b"Here");
5375        let page = synth_frag_page(96, 64, 12, &freed);
5376        assert_eq!(db.reconstruct_freeblock_fragments(&page).len(), 1);
5377    }
5378
5379    /// (Step 2) Real-artifact validation: 0D-01 page 2 salvages the genuine
5380    /// partial deleted row for id 20004 — `Text("Anja")`/`Text("Frank")` survive
5381    /// in a freeblock whose full-row reconstruction fails. Full pass unchanged.
5382    const NEMETZ_0D_01: &[u8] = include_bytes!("../../tests/data/nemetz/0D/0D-01.db");
5383
5384    #[test]
5385    fn fragment_salvage_recovers_anja_on_0d01() {
5386        let db = Database::open(NEMETZ_0D_01.to_vec()).unwrap();
5387        let page = db.raw_page(2).unwrap();
5388        let frags = db.reconstruct_freeblock_fragments(page);
5389        let f = frags
5390            .iter()
5391            .find(|f| {
5392                f.surviving
5393                    .iter()
5394                    .any(|(_, v)| matches!(v, Value::Text(t) if t == "Anja"))
5395            })
5396            .expect("0D-01 page 2 must salvage the Anja fragment");
5397        assert!(f
5398            .surviving
5399            .iter()
5400            .any(|(_, v)| matches!(v, Value::Text(t) if t == "Frank")));
5401        assert!((f.confidence - 0.2).abs() < f32::EPSILON);
5402        let cells = db.reconstruct_freeblock_records(page);
5403        assert!(cells.iter().all(|c| !c
5404            .values
5405            .iter()
5406            .any(|v| matches!(v, Value::Text(t) if t == "Anja"))));
5407    }
5408
5409    // ---- task #73: chain-aware overflow recovery — spilled-cell recognition ----
5410
5411    /// Encode a SQLite varint (minimal big-endian 7-bit groups).
5412    fn enc_varint(mut n: u64) -> Vec<u8> {
5413        if n == 0 {
5414            return vec![0];
5415        }
5416        let mut groups = Vec::new();
5417        while n > 0 {
5418            groups.push((n & 0x7f) as u8);
5419            n >>= 7;
5420        }
5421        groups.reverse();
5422        let last = groups.len() - 1;
5423        for (i, g) in groups.iter_mut().enumerate() {
5424            if i != last {
5425                *g |= 0x80;
5426            }
5427        }
5428        groups
5429    }
5430
5431    /// Build the **local prefix** bytes of a freed spilled table-leaf cell:
5432    /// `payload_len varint, rowid varint, record header, local payload bytes,
5433    /// 4-byte big-endian first-overflow pointer`. Returns `(bytes, P, local,
5434    /// serials)`. The record is `(id INTEGER, name TEXT, code TEXT)` with `code`
5435    /// large enough to force a spill past `usable - 35`.
5436    fn synth_spilled_prefix(
5437        rowid: i64,
5438        id: i64,
5439        name: &str,
5440        code_len: usize,
5441        usable: usize,
5442        first_overflow: u32,
5443    ) -> (Vec<u8>, usize, usize, Vec<i64>) {
5444        let id_serial = 1i64; // 1-byte integer
5445        let name_serial = 13 + 2 * name.len() as i64; // TEXT
5446        let code_serial = 13 + 2 * code_len as i64; // TEXT
5447        let serials = vec![id_serial, name_serial, code_serial];
5448        let mut serial_bytes = Vec::new();
5449        for &s in &serials {
5450            serial_bytes.extend(enc_varint(s as u64));
5451        }
5452        // header_len varint counts itself — solve the fixed point.
5453        let mut header_len = serial_bytes.len() + 1;
5454        while enc_varint(header_len as u64).len() + serial_bytes.len() != header_len {
5455            header_len += 1;
5456        }
5457        let mut header = enc_varint(header_len as u64);
5458        header.extend(&serial_bytes);
5459        let body_len = 1 + name.len() + code_len;
5460        let payload_len = header.len() + body_len;
5461        let local = local_payload_len(payload_len, usable);
5462
5463        // Full payload = header ++ id-body ++ name-body ++ code-body.
5464        let mut payload = header.clone();
5465        payload.push(id as u8); // 1-byte id
5466        payload.extend(name.as_bytes());
5467        payload.extend(std::iter::repeat_n(b'C', code_len));
5468        assert_eq!(payload.len(), payload_len);
5469
5470        // Cell = prefix varints ++ local payload prefix ++ 4-byte overflow ptr.
5471        let mut cell = enc_varint(payload_len as u64);
5472        cell.extend(enc_varint(rowid as u64));
5473        cell.extend(&payload[..local]);
5474        cell.extend(first_overflow.to_be_bytes());
5475        (cell, payload_len, local, serials)
5476    }
5477
5478    #[test]
5479    fn spilled_recognizer_reads_intact_prefix() {
5480        let usable = 4096usize;
5481        let (cell, p, local, serials) = synth_spilled_prefix(20012, 42, "Ella", 4200, usable, 13);
5482        assert!(p > usable - 35, "this record must spill");
5483        // Place the cell inside a larger scanned slice at a nonzero offset.
5484        let off = 50usize;
5485        let mut buf = vec![0u8; off];
5486        buf.extend(&cell);
5487        let sc = try_carve_spilled_cell_at(&buf, off, usable, Some(3))
5488            .expect("must recognize the intact-prefix spilled cell");
5489        assert_eq!(sc.payload_len, p);
5490        assert_eq!(sc.local_len, local);
5491        assert_eq!(sc.rowid, 20012);
5492        assert_eq!(sc.first_overflow, 13);
5493        assert_eq!(sc.serials, serials);
5494        assert_eq!(sc.offset, off);
5495    }
5496
5497    #[test]
5498    fn spilled_recognizer_abstains_for_in_page_payload() {
5499        let usable = 4096usize;
5500        // A small (in-page) payload: the existing carve path owns it.
5501        // header (3 serials) + body for a tiny code -> P <= usable-35.
5502        let (cell, p, _local, _s) = synth_spilled_prefix(7, 1, "Bob", 10, usable, 9);
5503        assert!(p <= usable - 35, "this record must NOT spill");
5504        assert!(try_carve_spilled_cell_at(&cell, 0, usable, Some(3)).is_none());
5505    }
5506
5507    #[test]
5508    fn spilled_recognizer_abstains_on_truncated_pointer() {
5509        let usable = 4096usize;
5510        let (cell, _p, _local, _s) = synth_spilled_prefix(20012, 42, "Ella", 4200, usable, 13);
5511        // Drop the final 2 bytes so the 4-byte overflow pointer is out of bounds.
5512        let truncated = &cell[..cell.len() - 2];
5513        assert!(try_carve_spilled_cell_at(truncated, 0, usable, Some(3)).is_none());
5514    }
5515
5516    #[test]
5517    fn spilled_recognizer_abstains_on_column_mismatch() {
5518        let usable = 4096usize;
5519        let (cell, _p, _local, _s) = synth_spilled_prefix(20012, 42, "Ella", 4200, usable, 13);
5520        // Expect 5 columns but the record has 3.
5521        assert!(try_carve_spilled_cell_at(&cell, 0, usable, Some(5)).is_none());
5522        // Inferred (None) still recognizes it.
5523        assert!(try_carve_spilled_cell_at(&cell, 0, usable, None).is_some());
5524    }
5525
5526    #[test]
5527    fn spilled_recognizer_abstains_on_nonpositive_rowid() {
5528        let usable = 4096usize;
5529        let (cell, _p, _local, _s) = synth_spilled_prefix(0, 42, "Ella", 4000, usable, 13);
5530        assert!(try_carve_spilled_cell_at(&cell, 0, usable, Some(3)).is_none());
5531    }
5532
5533    // ---- task #73: freed overflow-chain walk + freelist leaf/trunk split ----
5534
5535    /// Build a minimal multi-page `SQLite` DB image with `page_count` pages of
5536    /// `page_size` bytes. Page 1 carries a valid 100-byte header (so
5537    /// `Database::open` succeeds) with the given freelist trunk pointer and count
5538    /// at offsets 32/36. All pages are zero-filled; the caller writes overflow /
5539    /// trunk content afterwards. Returns the byte vector.
5540    fn synth_db(page_size: usize, page_count: usize, trunk: u32, fl_count: u32) -> Vec<u8> {
5541        let mut b = vec![0u8; page_size * page_count];
5542        b[..16].copy_from_slice(SQLITE_MAGIC);
5543        b[16..18].copy_from_slice(&(page_size as u16).to_be_bytes());
5544        b[18] = 1; // file format write version
5545        b[19] = 1; // file format read version
5546        b[20] = 0; // reserved space
5547        b[21] = 64;
5548        b[22] = 32;
5549        b[23] = 32;
5550        b[32..36].copy_from_slice(&trunk.to_be_bytes());
5551        b[36..40].copy_from_slice(&fl_count.to_be_bytes());
5552        // A minimal table-leaf page-1 body (type 0x0d, 0 cells) so header parsing
5553        // and page-count helpers behave.
5554        b[100] = 0x0d;
5555        b
5556    }
5557
5558    /// Write a freelist trunk page at `page` listing `leaves` and chaining to
5559    /// `next_trunk` (0 = end).
5560    fn write_trunk(b: &mut [u8], page_size: usize, page: u32, next_trunk: u32, leaves: &[u32]) {
5561        let base = (page as usize - 1) * page_size;
5562        b[base..base + 4].copy_from_slice(&next_trunk.to_be_bytes());
5563        b[base + 4..base + 8].copy_from_slice(&(leaves.len() as u32).to_be_bytes());
5564        for (i, &lf) in leaves.iter().enumerate() {
5565            b[base + 8 + i * 4..base + 12 + i * 4].copy_from_slice(&lf.to_be_bytes());
5566        }
5567    }
5568
5569    /// Write an overflow page at `page`: 4-byte big-endian `next` then `content`.
5570    fn write_overflow(b: &mut [u8], page_size: usize, page: u32, next: u32, content: &[u8]) {
5571        let base = (page as usize - 1) * page_size;
5572        b[base..base + 4].copy_from_slice(&next.to_be_bytes());
5573        b[base + 4..base + 4 + content.len()].copy_from_slice(content);
5574    }
5575
5576    #[test]
5577    fn freelist_split_separates_leaves_and_trunks() {
5578        let ps = 512usize;
5579        // Pages: 1 header, 2 trunk, leaves 3,4,5.
5580        let mut b = synth_db(ps, 6, 2, 4);
5581        write_trunk(&mut b, ps, 2, 0, &[3, 4, 5]);
5582        let db = Database::open(b).unwrap();
5583        let (leaves, trunks) = db.freelist_pages_split().unwrap();
5584        assert_eq!(leaves, [3u32, 4, 5].into_iter().collect());
5585        assert_eq!(trunks, [2u32].into_iter().collect());
5586        // The legacy combined accessor still returns leaves ++ trunk.
5587        let all: std::collections::BTreeSet<u32> =
5588            db.freelist_pages().unwrap().into_iter().collect();
5589        assert_eq!(all, [2u32, 3, 4, 5].into_iter().collect());
5590    }
5591
5592    #[test]
5593    fn freed_chain_assembles_single_leaf_page() {
5594        let ps = 512usize;
5595        let usable = ps; // reserved 0
5596        let mut b = synth_db(ps, 6, 2, 4);
5597        write_trunk(&mut b, ps, 2, 0, &[3, 4, 5]);
5598        // Chain content on leaf page 3: a single page holds `remaining` bytes.
5599        let remaining = 100usize;
5600        let content: Vec<u8> = (0..remaining).map(|i| (i % 251) as u8).collect();
5601        write_overflow(&mut b, ps, 3, 0, &content);
5602        let db = Database::open(b).unwrap();
5603        let (leaves, _trunks) = db.freelist_pages_split().unwrap();
5604        let (bytes, chain) = db
5605            .read_freed_overflow_chain(3, remaining, usable, &leaves)
5606            .expect("intact single-leaf chain must assemble");
5607        assert_eq!(bytes, content);
5608        assert_eq!(chain, vec![3]);
5609    }
5610
5611    #[test]
5612    fn freed_chain_assembles_multi_leaf_pages() {
5613        let ps = 512usize;
5614        let usable = ps;
5615        let per_page = usable - 4;
5616        let mut b = synth_db(ps, 8, 2, 5);
5617        write_trunk(&mut b, ps, 2, 0, &[3, 4, 5, 6]);
5618        // 2-page chain: page 3 -> page 4. remaining spans into page 4.
5619        let remaining = per_page + 50;
5620        let content: Vec<u8> = (0..remaining).map(|i| (i % 251) as u8).collect();
5621        write_overflow(&mut b, ps, 3, 4, &content[..per_page]);
5622        write_overflow(&mut b, ps, 4, 0, &content[per_page..]);
5623        let db = Database::open(b).unwrap();
5624        let (leaves, _t) = db.freelist_pages_split().unwrap();
5625        let (bytes, chain) = db
5626            .read_freed_overflow_chain(3, remaining, usable, &leaves)
5627            .expect("intact 2-leaf chain must assemble");
5628        assert_eq!(bytes, content);
5629        assert_eq!(chain, vec![3, 4]);
5630    }
5631
5632    #[test]
5633    fn freed_chain_breaks_on_non_freelist_page() {
5634        let ps = 512usize;
5635        let usable = ps;
5636        let mut b = synth_db(ps, 6, 2, 2);
5637        write_trunk(&mut b, ps, 2, 0, &[3]); // only page 3 is a leaf
5638        let content = vec![7u8; 100];
5639        // The pointer targets page 4, which is NOT on the freelist.
5640        write_overflow(&mut b, ps, 4, 0, &content);
5641        let db = Database::open(b).unwrap();
5642        let (leaves, _t) = db.freelist_pages_split().unwrap();
5643        assert!(db
5644            .read_freed_overflow_chain(4, 100, usable, &leaves)
5645            .is_err());
5646    }
5647
5648    #[test]
5649    fn freed_chain_breaks_on_trunk_page() {
5650        let ps = 512usize;
5651        let usable = ps;
5652        let mut b = synth_db(ps, 6, 2, 2);
5653        write_trunk(&mut b, ps, 2, 0, &[3]);
5654        let db = Database::open(b).unwrap();
5655        let (leaves, _t) = db.freelist_pages_split().unwrap();
5656        // Page 2 is the trunk — a chain page that is a trunk must break.
5657        assert!(db
5658            .read_freed_overflow_chain(2, 100, usable, &leaves)
5659            .is_err());
5660    }
5661
5662    #[test]
5663    fn freed_chain_breaks_on_cycle() {
5664        let ps = 512usize;
5665        let usable = ps;
5666        let per_page = usable - 4;
5667        let mut b = synth_db(ps, 6, 2, 3);
5668        write_trunk(&mut b, ps, 2, 0, &[3, 4]);
5669        // 3 -> 4 -> 3 cycle; remaining never satisfied.
5670        write_overflow(&mut b, ps, 3, 4, &vec![1u8; per_page]);
5671        write_overflow(&mut b, ps, 4, 3, &vec![2u8; per_page]);
5672        let db = Database::open(b).unwrap();
5673        let (leaves, _t) = db.freelist_pages_split().unwrap();
5674        assert!(db
5675            .read_freed_overflow_chain(3, per_page * 10, usable, &leaves)
5676            .is_err());
5677    }
5678
5679    #[test]
5680    fn freed_chain_breaks_on_premature_zero_pointer() {
5681        let ps = 512usize;
5682        let usable = ps;
5683        let per_page = usable - 4;
5684        let mut b = synth_db(ps, 6, 2, 2);
5685        write_trunk(&mut b, ps, 2, 0, &[3]);
5686        // Page 3 ends the chain (next=0) but `remaining` still wants more bytes.
5687        write_overflow(&mut b, ps, 3, 0, &vec![9u8; per_page]);
5688        let db = Database::open(b).unwrap();
5689        let (leaves, _t) = db.freelist_pages_split().unwrap();
5690        assert!(db
5691            .read_freed_overflow_chain(3, per_page + 10, usable, &leaves)
5692            .is_err());
5693    }
5694
5695    #[test]
5696    fn freed_chain_breaks_on_capacity_overflow() {
5697        let ps = 512usize;
5698        let usable = ps;
5699        let mut b = synth_db(ps, 6, 2, 2);
5700        write_trunk(&mut b, ps, 2, 0, &[3]);
5701        write_overflow(&mut b, ps, 3, 0, &vec![1u8; usable - 4]);
5702        let db = Database::open(b).unwrap();
5703        let (leaves, _t) = db.freelist_pages_split().unwrap();
5704        // remaining far exceeds what one leaf page can deliver — rejected upfront,
5705        // never allocating an attacker-declared payload.
5706        let absurd = (usable - 4) * leaves.len() + 1;
5707        assert!(db
5708            .read_freed_overflow_chain(3, absurd, usable, &leaves)
5709            .is_err());
5710    }
5711
5712    // ---- task #73 step 5: freeblock-clobbered spilled cell (SYNTHETIC ONLY) ----
5713    // Codex ruling #5: there is NO corpus instance for a freeblock-clobbered
5714    // *spilled* cell — this path is validated against a synthetic fixture only
5715    // and is marked unproven-by-corpus in the production code + docs.
5716
5717    /// Build a synthetic 4096-byte-page DB with an allocated table-leaf page 2
5718    /// holding (a) a LIVE template cell of the `(id INTEGER 1-byte, name TEXT,
5719    /// code TEXT)` schema and (b) a freeblock-clobbered SPILLED cell whose 4-byte
5720    /// prefix is overwritten by a stale freeblock header, with its overflow chain
5721    /// on a freed leaf page. Returns the bytes. `break_chain` routes the chain
5722    /// pointer at the freelist trunk instead of a leaf to exercise the rejection.
5723    fn synth_clobbered_spill_db(break_chain: bool) -> Vec<u8> {
5724        let ps = 4096usize;
5725        let usable = ps;
5726        // Pages: 1 header, 2 allocated leaf, 3 trunk, 4 leaf (chain), 5 leaf spare.
5727        let mut b = synth_db(ps, 6, 3, 2);
5728        write_trunk(&mut b, ps, 3, 0, &[4, 5]);
5729
5730        // Record geometry: id=7 (1-byte), name="Zoe", code 4200×'C'.
5731        let name = b"Zoe";
5732        let code_len = 4200usize;
5733        let serials: [i64; 3] = [1, 13 + 2 * name.len() as i64, 13 + 2 * code_len as i64];
5734        let mut serial_bytes = Vec::new();
5735        for &s in &serials {
5736            serial_bytes.extend(enc_varint(s as u64));
5737        }
5738        let mut header_len = serial_bytes.len() + 1;
5739        while enc_varint(header_len as u64).len() + serial_bytes.len() != header_len {
5740            header_len += 1;
5741        }
5742        let mut header = enc_varint(header_len as u64);
5743        header.extend(&serial_bytes);
5744        let mut full_payload = header.clone();
5745        full_payload.push(7u8); // id body
5746        full_payload.extend(name);
5747        full_payload.extend(std::iter::repeat_n(b'C', code_len));
5748        let payload_len = full_payload.len();
5749        let local = local_payload_len(payload_len, usable);
5750        let remaining = payload_len - local;
5751
5752        // --- LIVE template cell at offset 200 on page 2 (a small non-spilling row
5753        //     of the SAME schema so freeblock_template derives the column layout).
5754        let base2 = ps; // page 2 starts at byte 4096
5755        let tmpl_name = b"Al";
5756        let tmpl_code = b"xy";
5757        let tser: [i64; 3] = [
5758            1,
5759            13 + 2 * tmpl_name.len() as i64,
5760            13 + 2 * tmpl_code.len() as i64,
5761        ];
5762        let mut tsb = Vec::new();
5763        for &s in &tser {
5764            tsb.extend(enc_varint(s as u64));
5765        }
5766        let mut thl = tsb.len() + 1;
5767        while enc_varint(thl as u64).len() + tsb.len() != thl {
5768            thl += 1;
5769        }
5770        let mut tpayload = enc_varint(thl as u64);
5771        tpayload.extend(&tsb);
5772        tpayload.push(1u8);
5773        tpayload.extend(tmpl_name);
5774        tpayload.extend(tmpl_code);
5775        let live_off = 200usize;
5776        let mut live_cell = enc_varint(tpayload.len() as u64);
5777        live_cell.extend(enc_varint(1u64)); // rowid 1
5778        live_cell.extend(&tpayload);
5779        b[base2 + live_off..base2 + live_off + live_cell.len()].copy_from_slice(&live_cell);
5780
5781        // Page-2 leaf header (type 0x0d), 1 live cell, freeblock at 0x100, content
5782        // area covering both the live cell and the clobbered spilled cell.
5783        b[base2] = 0x0d;
5784        // first freeblock pointer (offset 1) -> the clobbered spilled cell at 1000.
5785        b[base2 + 1..base2 + 3].copy_from_slice(&1000u16.to_be_bytes());
5786        // cell count (offset 3) = 1
5787        b[base2 + 3..base2 + 5].copy_from_slice(&1u16.to_be_bytes());
5788        // cell content area start (offset 5) — low so both regions are "content".
5789        b[base2 + 5..base2 + 7].copy_from_slice(&100u16.to_be_bytes());
5790        // cell pointer array (1 entry) at offset 8 -> live cell offset.
5791        b[base2 + 8..base2 + 10].copy_from_slice(&(live_off as u16).to_be_bytes());
5792
5793        // --- Clobbered SPILLED cell at offset 1000 on page 2. Lay down the FULL
5794        //     prefix (payload_len varint, rowid varint, header, local payload,
5795        //     overflow ptr), then OVERWRITE the first 4 bytes with a stale
5796        //     freeblock header (next=0x0000, size) to simulate freeblock clobber.
5797        let spill_off = 1000usize;
5798        let mut spill_cell = enc_varint(payload_len as u64);
5799        spill_cell.extend(enc_varint(1u64)); // rowid (will be clobbered)
5800        let prefix_len = spill_cell.len();
5801        spill_cell.extend(&full_payload[..local]);
5802        let chain_first = if break_chain { 3u32 } else { 4u32 };
5803        spill_cell.extend(chain_first.to_be_bytes());
5804        b[base2 + spill_off..base2 + spill_off + spill_cell.len()].copy_from_slice(&spill_cell);
5805        // Clobber the first 4 bytes with a freeblock header: next=0, size=4.
5806        b[base2 + spill_off] = 0;
5807        b[base2 + spill_off + 1] = 0;
5808        b[base2 + spill_off + 2..base2 + spill_off + 4].copy_from_slice(&4u16.to_be_bytes());
5809
5810        // --- The overflow chain content on freed leaf page 4 (next=0).
5811        write_overflow(&mut b, ps, 4, 0, &full_payload[local..local + remaining]);
5812
5813        let _ = prefix_len;
5814        b
5815    }
5816
5817    #[test]
5818    fn clobbered_spilled_cell_reconstructs_with_unknown_rowid() {
5819        let db = Database::open(synth_clobbered_spill_db(false)).unwrap();
5820        let page2 = db.raw_page(2).unwrap();
5821        let recovered = db.carve_overflow_template_records(page2);
5822        let (cell, chain) = recovered
5823            .iter()
5824            .find(|(c, _)| matches!(c.values.get(1), Some(Value::Text(t)) if t == "Zoe"))
5825            .expect("synthetic clobbered spilled cell must reconstruct");
5826        // rowid destroyed by the freeblock clobber -> surfaced as 0.
5827        assert_eq!(cell.rowid, 0);
5828        // code fully reassembled across the chain.
5829        assert!(matches!(cell.values.get(2), Some(Value::Text(t)) if t.len() == 4200));
5830        assert_eq!(chain, &vec![4u32]);
5831    }
5832
5833    #[test]
5834    fn clobbered_spilled_broken_chain_yields_no_full_row() {
5835        // Chain pointer routed at the freelist TRUNK (page 3) -> rejected.
5836        let db = Database::open(synth_clobbered_spill_db(true)).unwrap();
5837        let page2 = db.raw_page(2).unwrap();
5838        let recovered = db.carve_overflow_template_records(page2);
5839        // A chain routed through the freelist trunk is rejected outright, so the
5840        // template carve recovers no full row at all (not merely no "Zoe" row).
5841        assert!(
5842            recovered.is_empty(),
5843            "a trunk-routed broken chain must yield no full row, got {} rows",
5844            recovered.len()
5845        );
5846    }
5847
5848    #[test]
5849    fn enc_varint_into_round_trips_zero_and_multibyte() {
5850        // Zero -> single 0 byte (the NULL-serial / empty-header path).
5851        assert_eq!(enc_varint_into(0), vec![0]);
5852        assert_eq!(varint_len(0), 1);
5853        // Multi-byte: 8413 -> 2-byte varint; round-trips via read_varint.
5854        let v = enc_varint_into(8413);
5855        assert_eq!(varint_len(8413), v.len());
5856        assert_eq!(read_varint(&v, 0).unwrap(), (8413, v.len()));
5857        // Negative input (illegal serial) treated as 1 byte (defensive).
5858        assert_eq!(varint_len(-1), 1);
5859    }
5860
5861    /// Build a 4096-byte-page DB with an allocated table-leaf page 2 holding an
5862    /// **intact-prefix** spilled cell in its unallocated gap, with the overflow
5863    /// chain on a freed leaf page (page 4). Mirrors the real 0E geometry so
5864    /// `carve_overflow_records` (and its fragment dual) can be unit-covered without
5865    /// the corpus. `break_chain` routes the pointer at the freelist trunk.
5866    fn synth_gap_spill_db(break_chain: bool, code_len: usize, name: &str) -> Vec<u8> {
5867        let ps = 4096usize;
5868        let usable = ps;
5869        let mut b = synth_db(ps, 6, 3, 2);
5870        write_trunk(&mut b, ps, 3, 0, &[4, 5]);
5871        let base2 = ps;
5872
5873        // Record: (id INTEGER 1-byte, name TEXT, code TEXT) spilled.
5874        let serials: [i64; 3] = [1, 13 + 2 * name.len() as i64, 13 + 2 * code_len as i64];
5875        let mut serial_bytes = Vec::new();
5876        for &s in &serials {
5877            serial_bytes.extend(enc_varint(s as u64));
5878        }
5879        let mut header_len = serial_bytes.len() + 1;
5880        while enc_varint(header_len as u64).len() + serial_bytes.len() != header_len {
5881            header_len += 1;
5882        }
5883        let mut payload = enc_varint(header_len as u64);
5884        payload.extend(&serial_bytes);
5885        payload.push(9u8); // id body
5886        payload.extend(name.as_bytes());
5887        payload.extend(std::iter::repeat_n(b'C', code_len));
5888        let payload_len = payload.len();
5889        let local = local_payload_len(payload_len, usable);
5890        let remaining = payload_len - local;
5891
5892        // Spilled cell at gap offset 1500 on page 2 (intact prefix).
5893        let spill_off = 1500usize;
5894        let mut cell = enc_varint(payload_len as u64);
5895        cell.extend(enc_varint(5u64)); // rowid 5
5896        cell.extend(&payload[..local]);
5897        let first = if break_chain { 3u32 } else { 4u32 };
5898        cell.extend(first.to_be_bytes());
5899        b[base2 + spill_off..base2 + spill_off + cell.len()].copy_from_slice(&cell);
5900
5901        // Page-2 leaf header: 0 live cells, content area at 100 so the gap [8,100..]
5902        // is scanned. No live cells keeps free_regions = the whole content area.
5903        b[base2] = 0x0d;
5904        b[base2 + 1] = 0; // first freeblock = 0
5905        b[base2 + 2] = 0;
5906        b[base2 + 3..base2 + 5].copy_from_slice(&0u16.to_be_bytes()); // 0 cells
5907        b[base2 + 5..base2 + 7].copy_from_slice(&8u16.to_be_bytes()); // cca low
5908
5909        // Chain content on freed leaf page 4.
5910        write_overflow(&mut b, ps, 4, 0, &payload[local..local + remaining]);
5911        b
5912    }
5913
5914    #[test]
5915    fn carve_overflow_records_resolves_gap_spill() {
5916        let db = Database::open(synth_gap_spill_db(false, 4200, "Nora")).unwrap();
5917        let page2 = db.raw_page(2).unwrap();
5918        let recovered = db.carve_overflow_records(page2);
5919        let (cell, chain) = recovered
5920            .iter()
5921            .find(|(c, _)| matches!(c.values.get(1), Some(Value::Text(t)) if t == "Nora"))
5922            .expect("gap-resident spilled cell must resolve to a full row");
5923        assert_eq!(cell.rowid, 5);
5924        assert!(matches!(cell.values.get(2), Some(Value::Text(t)) if t.len() == 4200));
5925        assert_eq!(chain, &vec![4u32]);
5926        // Graded below the in-page full-row tier (0.9 * factor).
5927        assert!(cell.confidence < 0.72);
5928        // Non-leaf page yields nothing; empty slice yields nothing.
5929        assert!(db.carve_overflow_records(&[0x05u8; 4096]).is_empty());
5930        assert!(db.carve_overflow_records(&[]).is_empty());
5931    }
5932
5933    #[test]
5934    fn carve_overflow_records_rejects_trunk_chain() {
5935        let db = Database::open(synth_gap_spill_db(true, 4200, "Nora")).unwrap();
5936        let page2 = db.raw_page(2).unwrap();
5937        // Chain routed at the trunk -> no full row recovered at all.
5938        let recovered = db.carve_overflow_records(page2);
5939        assert!(
5940            recovered.is_empty(),
5941            "a trunk-routed chain must yield no full overflow row, got {} rows",
5942            recovered.len()
5943        );
5944    }
5945
5946    #[test]
5947    fn stale_leaf_chain_with_invalid_utf8_is_rejected() {
5948        // NEGATIVE test (the stale-leaf residual): a chain page that IS a freelist
5949        // leaf and assembles to the exact declared length, but whose content is
5950        // unrelated bytes (invalid UTF-8 in the TEXT column). The freelist-leaf
5951        // requirement passes; the strict-UTF-8 extra-signal gate rejects it from
5952        // Tier-1. This documents the design's limit (Codex ruling #2): the leaf
5953        // requirement cannot prove the bytes are the record — only the UTF-8 gate
5954        // catches the cases the lossy decoder would otherwise mask.
5955        let ps = 4096usize;
5956        let usable = ps;
5957        let mut b = synth_db(ps, 6, 3, 2);
5958        write_trunk(&mut b, ps, 3, 0, &[4, 5]);
5959        let base2 = ps;
5960        let name = "Stale";
5961        let code_len = 4200usize;
5962        let serials: [i64; 3] = [1, 13 + 2 * name.len() as i64, 13 + 2 * code_len as i64];
5963        let mut serial_bytes = Vec::new();
5964        for &s in &serials {
5965            serial_bytes.extend(enc_varint(s as u64));
5966        }
5967        let mut header_len = serial_bytes.len() + 1;
5968        while enc_varint(header_len as u64).len() + serial_bytes.len() != header_len {
5969            header_len += 1;
5970        }
5971        let mut payload = enc_varint(header_len as u64);
5972        payload.extend(&serial_bytes);
5973        payload.push(9u8);
5974        payload.extend(name.as_bytes());
5975        payload.extend(std::iter::repeat_n(b'C', code_len));
5976        let payload_len = payload.len();
5977        let local = local_payload_len(payload_len, usable);
5978        let remaining = payload_len - local;
5979
5980        let spill_off = 1500usize;
5981        let mut cell = enc_varint(payload_len as u64);
5982        cell.extend(enc_varint(5u64));
5983        cell.extend(&payload[..local]);
5984        cell.extend(4u32.to_be_bytes());
5985        b[base2 + spill_off..base2 + spill_off + cell.len()].copy_from_slice(&cell);
5986        b[base2] = 0x0d;
5987        b[base2 + 3..base2 + 5].copy_from_slice(&0u16.to_be_bytes());
5988        b[base2 + 5..base2 + 7].copy_from_slice(&8u16.to_be_bytes());
5989
5990        // Stale leaf content: invalid UTF-8 (0xff bytes) where the TEXT body lands.
5991        let stale = vec![0xffu8; remaining];
5992        write_overflow(&mut b, ps, 4, 0, &stale);
5993
5994        let db = Database::open(b).unwrap();
5995        let page2 = db.raw_page(2).unwrap();
5996        // Decodes mechanically (the leaf assembles exactly), but the strict-UTF-8
5997        // gate rejects it -> NOT a Tier-1 full row.
5998        assert!(db.carve_overflow_records(page2).is_empty());
5999    }
6000
6001    #[test]
6002    fn carve_overflow_fragments_salvages_broken_gap_spill() {
6003        // Broken chain (trunk) -> the local prefix (id + name) salvages as a fragment.
6004        let db = Database::open(synth_gap_spill_db(true, 4200, "Nora")).unwrap();
6005        let page2 = db.raw_page(2).unwrap();
6006        let frags = db.carve_overflow_fragments(page2);
6007        let f = frags
6008            .iter()
6009            .find(|f| {
6010                f.surviving
6011                    .iter()
6012                    .any(|(_, v)| matches!(v, Value::Text(t) if t == "Nora"))
6013            })
6014            .expect("broken-chain gap spill must salvage a fragment");
6015        // id (col 0) survives locally too.
6016        assert!(f
6017            .surviving
6018            .iter()
6019            .any(|(i, v)| *i == 0 && matches!(v, Value::Integer(9))));
6020        // An intact chain produces NO fragment (it is a full row instead), so the
6021        // fragment set is empty — assert that directly rather than over a vacuous
6022        // per-fragment predicate.
6023        let ok = Database::open(synth_gap_spill_db(false, 4200, "Nora")).unwrap();
6024        let ok_page = ok.raw_page(2).unwrap();
6025        assert!(
6026            ok.carve_overflow_fragments(ok_page).is_empty(),
6027            "an intact chain yields a full row, not a fragment"
6028        );
6029        // Non-leaf / empty inputs yield nothing.
6030        assert!(db.carve_overflow_fragments(&[0x05u8; 4096]).is_empty());
6031        assert!(db.carve_overflow_fragments(&[]).is_empty());
6032    }
6033
6034    // --- WAL frame checksum (file-format §4.2) -------------------------------
6035
6036    #[test]
6037    fn wal_checksum_known_vector_both_endiannesses() {
6038        // The §4.2 algorithm over a hand-constructed 8-byte input, from a zero
6039        // seed. Input is two 32-bit words x0, x1; the recurrence is
6040        //   s0 += x0 + s1;  s1 += x1 + s0;
6041        // From (s0,s1)=(0,0): s0 = x0; s1 = x1 + x0.
6042        //
6043        // BIG-ENDIAN words (magic 0x377f0683 per the spec): bytes
6044        // [00 00 00 02][00 00 00 03] -> x0=2, x1=3 -> s0=2, s1=5.
6045        let data_be = [0, 0, 0, 2, 0, 0, 0, 3];
6046        assert_eq!(wal_checksum(WalChecksumEndian::Big, 0, 0, &data_be), (2, 5));
6047
6048        // LITTLE-ENDIAN words (magic 0x377f0682): the SAME bytes read LE give
6049        // x0=0x02000000, x1=0x03000000 -> s0=0x02000000,
6050        // s1 = 0x03000000 + 0x02000000 = 0x05000000 (wrapping u32).
6051        assert_eq!(
6052            wal_checksum(WalChecksumEndian::Little, 0, 0, &data_be),
6053            (0x0200_0000, 0x0500_0000)
6054        );
6055
6056        // Seed carries forward: from (s0,s1)=(2,5) over the same BE input ->
6057        // s0 = 2 + (2 + 5) = 9; s1 = 5 + (3 + 9) = 17.
6058        assert_eq!(
6059            wal_checksum(WalChecksumEndian::Big, 2, 5, &data_be),
6060            (9, 17)
6061        );
6062
6063        // Wrapping arithmetic must not panic on overflow (u32 wrap, not i32).
6064        let big = [0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff];
6065        let _ = wal_checksum(WalChecksumEndian::Big, u32::MAX, u32::MAX, &big);
6066    }
6067
6068    #[test]
6069    fn wal_checksum_endian_from_magic_matches_spec() {
6070        // file-format §4.2: 0x377f0683 = BIG-endian words, 0x377f0682 = LITTLE.
6071        assert_eq!(
6072            WalChecksumEndian::from_magic(0x377f_0683),
6073            Some(WalChecksumEndian::Big)
6074        );
6075        assert_eq!(
6076            WalChecksumEndian::from_magic(0x377f_0682),
6077            Some(WalChecksumEndian::Little)
6078        );
6079        assert_eq!(WalChecksumEndian::from_magic(0xdead_beef), None);
6080    }
6081
6082    // --- per-commit schema (CommitSnapshot::tables) -------------------------
6083
6084    /// Wrap a minted main-db image into a `(main, wal)` pair whose WAL commits a
6085    /// full rewrite of every page in ONE commit, with correct §4.2 checksums (so
6086    /// the snapshot is checksum-valid). The snapshot then materializes exactly the
6087    /// minted db, with its real page-1 `sqlite_master` b-tree — the no-sqlite3 way
6088    /// to drive `CommitSnapshot::tables` / snapshot reads against a genuine schema.
6089    fn wrap_db_in_wal(main: &[u8], page_size: u32) -> Vec<u8> {
6090        let ps = page_size as usize;
6091        let n_pages = main.len() / ps;
6092        let endian = WalChecksumEndian::Little; // arbitrary; matches magic below.
6093        let (salt1, salt2) = (0x1234_5678u32, 0x9abc_def0u32);
6094
6095        let mut wal = vec![0u8; 32];
6096        wal[0..4].copy_from_slice(&0x377f_0682u32.to_be_bytes()); // little-endian magic
6097        wal[4..8].copy_from_slice(&3_007_000u32.to_be_bytes());
6098        wal[8..12].copy_from_slice(&page_size.to_be_bytes());
6099        wal[12..16].copy_from_slice(&1u32.to_be_bytes());
6100        wal[16..20].copy_from_slice(&salt1.to_be_bytes());
6101        wal[20..24].copy_from_slice(&salt2.to_be_bytes());
6102        // Header checksum over the first 24 bytes (the seed for the frame chain).
6103        let (mut s0, mut s1) = wal_checksum(endian, 0, 0, &wal[0..24]);
6104        wal[24..28].copy_from_slice(&s0.to_be_bytes());
6105        wal[28..32].copy_from_slice(&s1.to_be_bytes());
6106
6107        for i in 0..n_pages {
6108            let page_no = (i + 1) as u32;
6109            let db_size = if i + 1 == n_pages { n_pages as u32 } else { 0 };
6110            let mut fh = [0u8; 24];
6111            fh[0..4].copy_from_slice(&page_no.to_be_bytes());
6112            fh[4..8].copy_from_slice(&db_size.to_be_bytes());
6113            fh[8..12].copy_from_slice(&salt1.to_be_bytes());
6114            fh[12..16].copy_from_slice(&salt2.to_be_bytes());
6115            let data = &main[i * ps..(i + 1) * ps];
6116            let (n0, n1) = wal_checksum(endian, s0, s1, &fh[0..8]);
6117            let (n0, n1) = wal_checksum(endian, n0, n1, data);
6118            s0 = n0;
6119            s1 = n1;
6120            fh[16..20].copy_from_slice(&s0.to_be_bytes());
6121            fh[20..24].copy_from_slice(&s1.to_be_bytes());
6122            wal.extend_from_slice(&fh);
6123            wal.extend_from_slice(data);
6124        }
6125        wal
6126    }
6127
6128    #[test]
6129    fn snapshot_tables_reads_schema_from_its_own_page_one() {
6130        use crate::rebuild::{build_recovered_db_tables, RecoveredTable as RT};
6131        let seed = vec![RT {
6132            name: "people".to_string(),
6133            columns: vec!["id".to_string(), "name".to_string()],
6134            rows: vec![
6135                vec![Value::Integer(1), Value::Text("alice".into())],
6136                vec![Value::Integer(2), Value::Text("bob".into())],
6137            ],
6138        }];
6139        let main = build_recovered_db_tables(&seed);
6140        let ps = parse_header(&main).unwrap().page_size;
6141        let wal = wrap_db_in_wal(&main, ps);
6142
6143        let db = Database::open_with_wal(main, &wal).unwrap();
6144        let tl = db.wal_timeline().unwrap();
6145        let snap = tl.commit_snapshots().last().unwrap();
6146        assert!(snap.checksum_valid(), "minted WAL must be checksum-valid");
6147
6148        let tables = snap.tables();
6149        let people = tables
6150            .iter()
6151            .find(|t| t.name == "people")
6152            .expect("table 'people' present in snapshot schema");
6153        assert!(people.rootpage >= 2, "rootpage points past page 1");
6154        assert_eq!(people.columns, vec!["id".to_string(), "name".to_string()]);
6155        assert!(!people.without_rowid, "an ordinary rowid table");
6156        // Internal sqlite_* tables are excluded.
6157        assert!(tables.iter().all(|t| !t.name.starts_with("sqlite_")));
6158    }
6159
6160    #[test]
6161    fn snapshot_read_resolves_overflow_through_snapshot_pages_not_live_view() {
6162        // The DEFINING property of the snapshot-scoped read: a spilled (overflow)
6163        // row must decode from the snapshot's OWN pages, even when the live view
6164        // would supply different overflow content. Build a db whose table `t` holds
6165        // one large-blob row (forcing an overflow chain), capture it as the
6166        // snapshot, then CLOBBER the overflow pages in the live main-file image.
6167        // The snapshot read still returns the original blob; a live read sees the
6168        // clobbered bytes — proving the snapshot path does not consult the live view.
6169        use crate::rebuild::{build_recovered_db_tables, RecoveredTable as RT};
6170        let blob: Vec<u8> = (0..9000u32).map(|i| (i % 251) as u8).collect();
6171        let seed = vec![RT {
6172            name: "t".to_string(),
6173            columns: vec!["id".to_string(), "big".to_string()],
6174            rows: vec![vec![Value::Integer(1), Value::Blob(blob.clone())]],
6175        }];
6176        let minted = build_recovered_db_tables(&seed);
6177        let ps = parse_header(&minted).unwrap().page_size;
6178        // The WAL commits the TRUE pages; the snapshot materializes them.
6179        let wal = wrap_db_in_wal(&minted, ps);
6180
6181        // Now clobber the live main image's overflow pages (every page after the
6182        // first two: page 1 schema, page 2 table-leaf, page 3+ overflow) to a
6183        // distinct byte so a live read would mis-decode the blob.
6184        let mut clobbered_main = minted.clone();
6185        for p in clobbered_main.iter_mut().skip(2 * ps as usize) {
6186            *p = 0xEE;
6187        }
6188
6189        let db = Database::open_with_wal(clobbered_main, &wal).unwrap();
6190        let tl = db.wal_timeline().unwrap();
6191        let snap = tl.commit_snapshots().last().unwrap();
6192        let t = snap
6193            .tables()
6194            .into_iter()
6195            .find(|t| t.name == "t")
6196            .expect("table t in snapshot");
6197
6198        let rows = snap.read_table(t.rootpage, t.columns.len()).unwrap();
6199        assert_eq!(rows.len(), 1, "one row at this commit");
6200        let (rowid, values) = &rows[0];
6201        assert_eq!(*rowid, 1);
6202        // The 9000-byte blob reassembles from the SNAPSHOT's overflow pages, intact.
6203        assert_eq!(
6204            values.get(1),
6205            Some(&Value::Blob(blob)),
6206            "overflow blob must reassemble from the snapshot's pages, not the clobbered live view"
6207        );
6208    }
6209
6210    #[test]
6211    fn snapshot_read_walks_interior_btree_in_rowid_order() {
6212        // Many rows force an interior (0x05) table b-tree; the snapshot read must
6213        // descend it and return rows in ascending rowid order — exercising the
6214        // shared walk's interior branch through the snapshot page source.
6215        use crate::rebuild::{build_recovered_db_tables, RecoveredTable as RT};
6216        let rows_seed: Vec<Vec<Value>> = (1..=500i64)
6217            .map(|i| vec![Value::Integer(i), Value::Text(format!("name-{i}"))])
6218            .collect();
6219        let seed = vec![RT {
6220            name: "big".to_string(),
6221            columns: vec!["id".to_string(), "name".to_string()],
6222            rows: rows_seed,
6223        }];
6224        let minted = build_recovered_db_tables(&seed);
6225        let ps = parse_header(&minted).unwrap().page_size;
6226        let wal = wrap_db_in_wal(&minted, ps);
6227
6228        let db = Database::open_with_wal(minted, &wal).unwrap();
6229        let tl = db.wal_timeline().unwrap();
6230        let snap = tl.commit_snapshots().last().unwrap();
6231        let t = snap
6232            .tables()
6233            .into_iter()
6234            .find(|t| t.name == "big")
6235            .expect("table big");
6236        let rows = snap.read_table(t.rootpage, t.columns.len()).unwrap();
6237        assert_eq!(rows.len(), 500, "all rows across the interior b-tree");
6238        let ids: Vec<i64> = rows.iter().map(|(r, _)| *r).collect();
6239        assert!(ids.windows(2).all(|w| w[0] < w[1]), "ascending rowid order");
6240        assert_eq!(*ids.first().unwrap(), 1);
6241        assert_eq!(*ids.last().unwrap(), 500);
6242    }
6243
6244    #[test]
6245    fn without_rowid_sql_detects_the_clause() {
6246        // The WITHOUT ROWID detector keys off the CREATE TABLE tail, tolerant of
6247        // case and whitespace, and does NOT misfire on the literal appearing inside
6248        // a quoted string / column name (file-format §2.4). A WITHOUT ROWID b-tree
6249        // has no rowid key, so this flag gates the snapshot-scoped rowid read.
6250        assert!(without_rowid_sql(
6251            "CREATE TABLE kv(k TEXT PRIMARY KEY, v TEXT) WITHOUT ROWID"
6252        ));
6253        assert!(without_rowid_sql(
6254            "CREATE TABLE kv(k TEXT PRIMARY KEY, v TEXT)  without   rowid"
6255        ));
6256        // Ordinary tables are NOT flagged.
6257        assert!(!without_rowid_sql(
6258            "CREATE TABLE t(id INTEGER PRIMARY KEY, n TEXT)"
6259        ));
6260        // A column literally named with the words, but not the trailing clause, is
6261        // not a false positive.
6262        assert!(!without_rowid_sql(
6263            "CREATE TABLE t(\"without rowid\" TEXT, x INT)"
6264        ));
6265    }
6266
6267    #[test]
6268    fn is_autoincrement_detects_only_the_real_clause() {
6269        // Positive: an ordinary rowid table declaring INTEGER PRIMARY KEY
6270        // AUTOINCREMENT — case-insensitive and whitespace-tolerant.
6271        assert!(is_autoincrement(
6272            "CREATE TABLE students(id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)"
6273        ));
6274        assert!(is_autoincrement(
6275            "create table t(  id   integer   primary key   autoincrement )"
6276        ));
6277        // Negative: a plain INTEGER PRIMARY KEY is NOT autoincrement.
6278        assert!(!is_autoincrement(
6279            "CREATE TABLE students(id INTEGER PRIMARY KEY, name TEXT)"
6280        ));
6281        // Negative: a WITHOUT ROWID table cannot be AUTOINCREMENT (no rowid).
6282        assert!(!is_autoincrement(
6283            "CREATE TABLE kv(k INTEGER PRIMARY KEY AUTOINCREMENT, v TEXT) WITHOUT ROWID"
6284        ));
6285        // Negative: a column merely NAMED autoincrement is not the clause.
6286        assert!(!is_autoincrement(
6287            "CREATE TABLE t(\"autoincrement\" INTEGER PRIMARY KEY, x INT)"
6288        ));
6289        // Negative: the keyword inside a quoted string / comment does not qualify.
6290        assert!(!is_autoincrement(
6291            "CREATE TABLE t(id INTEGER PRIMARY KEY, note TEXT DEFAULT 'autoincrement')"
6292        ));
6293        // Negative: AUTOINCREMENT without INTEGER PRIMARY KEY is not a valid clause.
6294        assert!(!is_autoincrement(
6295            "CREATE TABLE t(id INTEGER AUTOINCREMENT, name TEXT)"
6296        ));
6297    }
6298
6299    #[test]
6300    fn sqlite_sequence_reads_present_absent_and_multi() {
6301        // A db with no AUTOINCREMENT table has no sqlite_sequence: empty map
6302        // (NOT seq=0), so callers never invent a high-water mark.
6303        let plain = Database::open(crate::rebuild::build_recovered_db_tables(&[
6304            crate::rebuild::RecoveredTable {
6305                name: "plain".to_string(),
6306                columns: vec!["c0".to_string()],
6307                rows: vec![vec![Value::Integer(1)]],
6308            },
6309        ]))
6310        .expect("minted db opens");
6311        assert!(
6312            plain.sqlite_sequence().is_empty(),
6313            "no AUTOINCREMENT table ⟹ empty sqlite_sequence map"
6314        );
6315
6316        // The b_autoinc fixture maintains sqlite_sequence(students)=5.
6317        let auto =
6318            Database::open(include_bytes!("../../tests/data/drop_recreate/b_autoinc.db").to_vec())
6319                .expect("open b_autoinc.db");
6320        let seq = auto.sqlite_sequence();
6321        assert_eq!(seq.get("students"), Some(&5), "students high-water = 5");
6322
6323        // The upd_autoinc fixture: a single AUTOINCREMENT table t at seq=5.
6324        let upd = Database::open(
6325            include_bytes!("../../tests/data/drop_recreate/upd_autoinc.db").to_vec(),
6326        )
6327        .expect("open upd_autoinc.db");
6328        assert_eq!(upd.sqlite_sequence().get("t"), Some(&5), "t high-water = 5");
6329    }
6330
6331    #[test]
6332    fn schema_sql_reads_current_name_to_create_sql() {
6333        // The live `name -> CREATE SQL` map mirrors live_tables, keyed by name.
6334        let auto =
6335            Database::open(include_bytes!("../../tests/data/drop_recreate/b_autoinc.db").to_vec())
6336                .expect("open b_autoinc.db");
6337        let schema = auto.schema_sql();
6338        let sql = schema.get("students").expect("students present");
6339        assert!(
6340            sql.contains("AUTOINCREMENT"),
6341            "current CREATE SQL carried verbatim: {sql}"
6342        );
6343    }
6344
6345    #[test]
6346    fn prior_snapshot_schema_sql_reads_prior_create_sql() {
6347        // b_journal_altered: the prior (-journal) schema for `students` has NO
6348        // `extra` column, the current schema does → the CREATE SQL texts differ.
6349        let main = include_bytes!("../../tests/data/drop_recreate/b_journal_altered.db").to_vec();
6350        let journal = include_bytes!("../../tests/data/drop_recreate/b_journal_altered.db-journal");
6351        let db = Database::open(main).expect("open b_journal_altered.db");
6352        let prior = db
6353            .rollback_prior(journal)
6354            .expect("rollback_prior parses the PERSIST journal");
6355        let prior_sql = prior.schema_sql();
6356        let prior_students = prior_sql.get("students").expect("prior students present");
6357        assert!(
6358            !prior_students.contains("extra"),
6359            "prior CREATE SQL lacks the ALTER-added column: {prior_students}"
6360        );
6361        let current = db.schema_sql();
6362        assert_ne!(
6363            current.get("students"),
6364            prior_sql.get("students"),
6365            "prior vs current CREATE SQL differ (the ALTER)"
6366        );
6367    }
6368
6369    #[test]
6370    fn prior_snapshot_schema_sql_dml_only_matches_current() {
6371        // b_journal_dml: the last transaction is DML only, so the prior (-journal)
6372        // CREATE SQL for `students` EQUALS the current schema (anti-FP ground truth).
6373        let main = include_bytes!("../../tests/data/drop_recreate/b_journal_dml.db").to_vec();
6374        let journal = include_bytes!("../../tests/data/drop_recreate/b_journal_dml.db-journal");
6375        let db = Database::open(main).expect("open b_journal_dml.db");
6376        let prior = db
6377            .rollback_prior(journal)
6378            .expect("rollback_prior parses the PERSIST journal");
6379        assert_eq!(
6380            db.schema_sql().get("students"),
6381            prior.schema_sql().get("students"),
6382            "DML-only ⟹ prior and current CREATE SQL are identical"
6383        );
6384    }
6385}