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}