Skip to main content

sqlite_forensic/
lib.rs

1//! `sqlite-forensic` — Tier-2 anomaly auditor over [`sqlite_core`].
2//!
3//! WS-C skeleton. The reader (`sqlite-core`) answers "what does this database
4//! header show?"; this crate grades the forensically-notable observations into
5//! severity-ranked [`forensicnomicon::report::Finding`]s, so a `SQLite`
6//! evidence database's anomalies aggregate uniformly with the partition /
7//! container / filesystem layers.
8//!
9//! Each anomaly is an *observation* ("consistent with …"); the examiner draws
10//! the conclusions.
11//!
12//! # Capabilities
13//!
14//! - [`carve_deleted_records`] — recover deleted rows from free (unallocated)
15//!   pages, the headline capability rusqlite structurally cannot provide. Each
16//!   recovered row is confidence-graded, flagged `allocated: false`, and carries
17//!   page/offset/rowid provenance.
18//! - [`audit`] grades header reserved-space, a non-empty freelist (prior
19//!   deletions), an active WAL overlay (uncheckpointed state), and a header/file
20//!   page-count mismatch into severity-ranked
21//!   [`forensicnomicon::report::Finding`]s.
22//!
23//! Deferred: a full anomaly suite (overflow-chain integrity, schema-format /
24//! text-encoding checks) and a fuzz harness.
25
26#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]
27
28use forensicnomicon::report::{
29    Confidence, Evidence, Finding, Location, Observation, Severity, Source,
30};
31use sqlite_core::{CommitId, Database, Value, WalTimeline};
32
33/// The classified `SQLite` forensic anomalies this auditor can grade.
34///
35/// `#[non_exhaustive]` so WS-E can add carving / WAL / freelist variants without
36/// a breaking change; downstream `match` arms must carry a `_` arm.
37#[derive(Debug, Clone, PartialEq, Eq)]
38#[non_exhaustive]
39pub enum AnomalyKind {
40    /// The header's reserved-space-per-page field is non-zero. Standard
41    /// `SQLite` leaves this at 0; a non-zero value is used by page-level
42    /// extensions (e.g. encryption such as SQLCipher/SEE, or checksum VFS) and
43    /// is worth flagging on an evidence database.
44    NonZeroReservedSpace {
45        /// The reserved bytes per page reported by the header.
46        reserved: u8,
47    },
48    /// A record-shaped cell was recovered from unallocated / free space —
49    /// consistent with a deleted row that has not yet been overwritten.
50    DeletedRecordRecovered {
51        /// 1-based page the residue was carved from.
52        page: u32,
53        /// Byte offset of the cell within that page.
54        offset: usize,
55        /// Recovered rowid.
56        rowid: i64,
57    },
58    /// The freelist is non-empty: the database holds free (unallocated) pages.
59    /// Consistent with prior deletions (`DELETE` without `VACUUM`); those pages
60    /// may retain recoverable deleted records.
61    NonEmptyFreelist {
62        /// Number of free pages on the freelist.
63        free_pages: u32,
64    },
65    /// A `-wal` sidecar carried committed-but-unflushed page versions that the
66    /// main database file does not yet reflect. Consistent with an evidence
67    /// database captured while a write transaction was checkpoint-pending; the
68    /// main file alone would under-report the true state.
69    WalUncheckpointedState {
70        /// Number of pages the WAL overlay superseded in the main file.
71        overlaid_pages: u32,
72    },
73    /// The in-header page count disagrees with the page count implied by the
74    /// file length. Consistent with truncation, carving, or out-of-band
75    /// modification of the database file.
76    PageCountMismatch {
77        /// Page count recorded in the file header (offset 28).
78        header_pages: u32,
79        /// Page count implied by `file_len / page_size`.
80        file_pages: u32,
81    },
82}
83
84impl AnomalyKind {
85    /// Severity, derived from the kind.
86    #[must_use]
87    pub fn severity(&self) -> Severity {
88        match self {
89            AnomalyKind::NonZeroReservedSpace { .. } | AnomalyKind::NonEmptyFreelist { .. } => {
90                Severity::Low
91            }
92            AnomalyKind::DeletedRecordRecovered { .. }
93            | AnomalyKind::WalUncheckpointedState { .. } => Severity::Medium,
94            AnomalyKind::PageCountMismatch { .. } => Severity::High,
95        }
96    }
97
98    /// Stable, scheme-prefixed machine code (a published contract).
99    #[must_use]
100    pub fn code(&self) -> &'static str {
101        match self {
102            AnomalyKind::NonZeroReservedSpace { .. } => "SQLITE-RESERVED-SPACE-NONZERO",
103            AnomalyKind::DeletedRecordRecovered { .. } => "SQLITE-DELETED-RECORD-RECOVERED",
104            AnomalyKind::NonEmptyFreelist { .. } => "SQLITE-FREELIST-NONEMPTY",
105            AnomalyKind::WalUncheckpointedState { .. } => "SQLITE-WAL-UNCHECKPOINTED",
106            AnomalyKind::PageCountMismatch { .. } => "SQLITE-PAGECOUNT-MISMATCH",
107        }
108    }
109
110    /// Human-readable, "consistent with" note.
111    #[must_use]
112    pub fn note(&self) -> String {
113        match self {
114            AnomalyKind::NonZeroReservedSpace { reserved } => format!(
115                "file header reserves {reserved} byte(s) per page — non-standard; \
116                 consistent with a page-level extension such as encryption \
117                 (SQLCipher/SEE) or a checksum VFS"
118            ),
119            AnomalyKind::DeletedRecordRecovered {
120                page,
121                offset,
122                rowid,
123            } => format!(
124                "recovered a record-shaped cell (rowid {rowid}) from unallocated \
125                 space at page {page} offset {offset} — consistent with a deleted \
126                 row not yet overwritten"
127            ),
128            AnomalyKind::NonEmptyFreelist { free_pages } => format!(
129                "{free_pages} free page(s) on the freelist — consistent with prior \
130                 deletions (DELETE without VACUUM); free pages may retain \
131                 recoverable deleted records"
132            ),
133            AnomalyKind::WalUncheckpointedState { overlaid_pages } => format!(
134                "the -wal sidecar carries {overlaid_pages} committed page version(s) \
135                 the main file does not reflect — consistent with capture while a \
136                 write transaction was checkpoint-pending; the main file alone \
137                 under-reports the true state"
138            ),
139            AnomalyKind::PageCountMismatch {
140                header_pages,
141                file_pages,
142            } => format!(
143                "in-header page count ({header_pages}) disagrees with the file \
144                 length ({file_pages} pages) — consistent with truncation, \
145                 carving, or out-of-band modification"
146            ),
147        }
148    }
149}
150
151/// A `SQLite` forensic anomaly: an observation graded by severity, with a stable
152/// code and note derived from its [`AnomalyKind`] so they cannot drift.
153#[derive(Debug, Clone, PartialEq)]
154pub struct Anomaly {
155    /// Severity, derived from `kind`.
156    pub severity: Severity,
157    /// Stable machine-readable code, derived from `kind`.
158    pub code: &'static str,
159    /// The classified anomaly.
160    pub kind: AnomalyKind,
161    /// Human-readable note, derived from `kind`.
162    pub note: String,
163    /// Heuristic confidence, present for inferential findings (e.g. a carved
164    /// deleted record); `None` for structurally-certain header observations.
165    pub confidence: Option<f32>,
166}
167
168impl Anomaly {
169    /// Build an [`Anomaly`], deriving severity/code/note from `kind`.
170    #[must_use]
171    pub fn new(kind: AnomalyKind) -> Self {
172        Anomaly {
173            severity: kind.severity(),
174            code: kind.code(),
175            note: kind.note(),
176            kind,
177            confidence: None,
178        }
179    }
180
181    /// Attach a heuristic confidence (used for carved deleted records).
182    #[must_use]
183    pub fn with_confidence(mut self, confidence: f32) -> Self {
184        self.confidence = Some(confidence);
185        self
186    }
187}
188
189impl Observation for Anomaly {
190    fn severity(&self) -> Option<Severity> {
191        Some(self.severity)
192    }
193    fn code(&self) -> &'static str {
194        self.code
195    }
196    fn note(&self) -> String {
197        self.note.clone()
198    }
199
200    fn confidence(&self) -> Option<Confidence> {
201        self.confidence.and_then(Confidence::new)
202    }
203
204    fn category(&self) -> forensicnomicon::report::Category {
205        use forensicnomicon::report::Category;
206        match &self.kind {
207            // Deleted-record residue and free (deallocated) pages are recoverability
208            // findings; the code keywords don't trip Category::from_code's Residue
209            // classifier, so classify them explicitly.
210            AnomalyKind::DeletedRecordRecovered { .. } | AnomalyKind::NonEmptyFreelist { .. } => {
211                Category::Residue
212            }
213            // A WAL-only/uncheckpointed state and a header/file page-count mismatch
214            // are integrity-of-state observations.
215            AnomalyKind::WalUncheckpointedState { .. } | AnomalyKind::PageCountMismatch { .. } => {
216                Category::Integrity
217            }
218            other => Category::from_code(other.code()),
219        }
220    }
221
222    fn evidence(&self) -> Vec<Evidence> {
223        match &self.kind {
224            AnomalyKind::DeletedRecordRecovered {
225                page,
226                offset,
227                rowid,
228            } => vec![
229                Evidence {
230                    field: "rowid".to_string(),
231                    value: rowid.to_string(),
232                    location: Some(Location::RecordId(u64::try_from(*rowid).unwrap_or(0))),
233                },
234                Evidence {
235                    field: "source_page".to_string(),
236                    value: page.to_string(),
237                    location: Some(Location::Other {
238                        space: "sqlite:page".to_string(),
239                        value: u64::from(*page),
240                    }),
241                },
242                Evidence {
243                    field: "cell_offset".to_string(),
244                    value: offset.to_string(),
245                    location: Some(Location::ByteOffset(*offset as u64)),
246                },
247            ],
248            AnomalyKind::NonEmptyFreelist { free_pages } => vec![Evidence {
249                field: "free_pages".to_string(),
250                value: free_pages.to_string(),
251                location: None,
252            }],
253            AnomalyKind::WalUncheckpointedState { overlaid_pages } => vec![Evidence {
254                field: "overlaid_pages".to_string(),
255                value: overlaid_pages.to_string(),
256                location: None,
257            }],
258            AnomalyKind::PageCountMismatch {
259                header_pages,
260                file_pages,
261            } => vec![
262                Evidence {
263                    field: "header_pages".to_string(),
264                    value: header_pages.to_string(),
265                    location: Some(Location::Field("in_header_db_size".to_string())),
266                },
267                Evidence {
268                    field: "file_pages".to_string(),
269                    value: file_pages.to_string(),
270                    location: None,
271                },
272            ],
273            AnomalyKind::NonZeroReservedSpace { .. } => Vec::new(),
274        }
275    }
276}
277
278/// Which class of free space a deleted record was carved from. Records the
279/// recovery provenance so the examiner can weigh reliability by class.
280#[derive(Debug, Clone, Copy, PartialEq, Eq)]
281#[non_exhaustive]
282pub enum RecoverySource {
283    /// A whole page that was freed onto the freelist (the strongest case: the
284    /// page holds only deallocated content).
285    FreelistPage,
286    /// The in-page free space (unallocated gap / freeblock slack) of a page that
287    /// is still allocated. The record is genuinely deleted but more likely to be
288    /// partially overwritten, so it is graded lower.
289    InPageFreeBlock,
290    /// A page whose table was `DROP`ped — on the freelist with no `sqlite_master`
291    /// schema, so the column count was inferred from the record itself.
292    DroppedTable,
293    /// A **prior version** of a still-live row: an `UPDATE` freed the old version
294    /// of the row into slack while the new version kept the same rowid. The
295    /// recovered values DIFFER from the current live row (e.g. an edited message
296    /// or a changed amount), so it is genuine deleted content — the edit history.
297    PriorVersion,
298    /// A record rebuilt by **freeblock reconstruction**: the freed cell's first
299    /// four bytes (payload-length + rowid varints, `header_len`, and the leading
300    /// serial type) were overwritten by SQLite's freeblock header, so the record
301    /// was rebuilt from its surviving serial-type tail plus a schema template. The
302    /// rowid is destroyed (surfaced as `0`), so this is the weakest in-page class
303    /// — a low-confidence "consistent with a deleted row" lead.
304    FreeblockReconstructed,
305    /// Residue carved from an **uncheckpointed WAL frame's page image** rather
306    /// than the on-disk pages. A `-wal` frame holds a committed page version the
307    /// main file does not yet reflect; deleted cells freed within that version
308    /// survive in the frame's slack and exist NOWHERE on disk. The record carries
309    /// the `(salt1, salt2, frame_index)` log-sequence provenance in
310    /// [`CarvedRecord::wal`].
311    WalFrame,
312    /// Residue carved from a **materialized commit snapshot** — the database state
313    /// replayed up to one COMMIT frame of the `-wal` (base image ∪ committed frames
314    /// to that commit). A row that is a live cell at this commit but deleted by a
315    /// later commit survives ONLY in this snapshot's page images. The record
316    /// carries the commit's `(salt1, salt2, commit_frame_index)` LSN in
317    /// [`CarvedRecord::wal`] — the per-commit temporal coordinate, distinct from a
318    /// raw [`RecoverySource::WalFrame`] residue's frame index.
319    CommitSnapshot,
320}
321
322/// Provenance for a record carved from a `-wal` frame: the
323/// `(salt1, salt2, frame_index)` log-sequence identity of the frame it came from
324/// (the LSN task #55 will formalize).
325#[derive(Debug, Clone, Copy, PartialEq, Eq)]
326pub struct WalProvenance {
327    /// 0-based position of the source frame within the `-wal` file.
328    pub frame_index: usize,
329    /// WAL header salt-1 (checkpoint generation) of the source frame.
330    pub salt1: u32,
331    /// WAL header salt-2 (checkpoint generation) of the source frame.
332    pub salt2: u32,
333}
334
335/// Provenance for a record reassembled across an **overflow-page chain** (task
336/// #73): the pages whose bytes were concatenated to recover the row. An examiner
337/// citing the row as evidence can name exactly where its bytes came from. Present
338/// only on chain-reassembled rows; `None` for every contiguous recovery.
339#[derive(Debug, Clone, PartialEq, Eq)]
340pub struct OverflowProvenance {
341    /// First overflow page of the chain (the page the local-prefix pointer named).
342    pub first_page: u32,
343    /// Ordered overflow pages whose content was assembled into the payload.
344    pub chain: Vec<u32>,
345}
346
347/// A deleted record recovered from unallocated space — the headline capability
348/// rusqlite cannot provide. Carries the decoded row plus provenance so the
349/// examiner can weigh it as a "consistent with a deleted row" observation.
350#[derive(Debug, Clone, PartialEq)]
351pub struct CarvedRecord {
352    /// 1-based page the record was carved from.
353    pub page: u32,
354    /// Byte offset of the cell within that page.
355    pub offset: usize,
356    /// Recovered rowid.
357    pub rowid: i64,
358    /// Decoded column values, in column order.
359    pub values: Vec<Value>,
360    /// Heuristic confidence in `(0.0, 1.0]` that these bytes are a real record.
361    pub confidence: f32,
362    /// Always `false`: a carved record lives in unallocated space, never in the
363    /// live b-tree. Present so callers cannot mistake it for an allocated row.
364    pub allocated: bool,
365    /// Which class of free space this record was recovered from.
366    pub source: RecoverySource,
367    /// WAL log-sequence provenance, present **only** for
368    /// [`RecoverySource::WalFrame`] records (the frame the residue was carved
369    /// from); `None` for every on-disk class.
370    pub wal: Option<WalProvenance>,
371    /// Overflow-chain provenance, present **only** for rows reassembled across a
372    /// freed overflow-page chain (task #73); `None` for every contiguous recovery.
373    pub overflow: Option<OverflowProvenance>,
374}
375
376/// A **Tier-2 partial recovery**: a freed cell whose full row could not be
377/// reconstructed but at least one *distinctive* cell (TEXT ≥ 4 bytes of valid
378/// UTF-8, or REAL) survived at a structural anchor.
379///
380/// Deliberately NOT a [`CarvedRecord`]: it has no rowid (clobbered) and an
381/// incomplete column set, and it does **not** share the full-row
382/// 0-false-positive guarantee — it is a lead-generation surface with an expected
383/// non-zero false-positive rate. The type system keeps it out of the full-row
384/// output so a fragment can never be silently rendered as a recovered row
385/// (secure by design). Returned only by [`carve_with_fragments`] in the opt-in
386/// [`CarveTiers::fragments`] bucket. A fragment is "consistent with a partial
387/// deleted row" — the examiner draws the conclusion.
388#[derive(Debug, Clone, PartialEq)]
389pub struct CarvedFragment {
390    /// 1-based page the fragment was salvaged from.
391    pub page: u32,
392    /// Byte offset of the failed cell's anchor within that page.
393    pub offset: usize,
394    /// `(column_index, value)` for each column that decoded cleanly, ascending by
395    /// index — meaningful against the table's column order.
396    pub surviving: Vec<(usize, Value)>,
397    /// Number of the row's columns that did NOT decode.
398    pub missing: usize,
399    /// Always the flat Tier-2 fragment confidence (0.2) for now — strictly below
400    /// every full-row class.
401    pub confidence: f32,
402    /// Which class of free space the fragment was salvaged from
403    /// ([`RecoverySource::FreeblockReconstructed`] for chain-pass fragments,
404    /// [`RecoverySource::InPageFreeBlock`] for gap-pass fragments).
405    pub source: RecoverySource,
406    /// WAL log-sequence provenance. `None` in v1 (no WAL fragment pass yet).
407    pub wal: Option<WalProvenance>,
408}
409
410/// The two strictly-separated recovery tiers returned by [`carve_with_fragments`].
411///
412/// `full` is **byte-identical** to [`carve_all_deleted_records`] and keeps its
413/// structural 0-false-positive guarantee — the zero-config, high-precision
414/// output. `fragments` is the opt-in Tier-2 lead surface (see [`CarvedFragment`]).
415#[derive(Debug, Clone, PartialEq)]
416pub struct CarveTiers {
417    /// Tier-1 full rows — identical to [`carve_all_deleted_records`].
418    pub full: Vec<CarvedRecord>,
419    /// Tier-2 partial recoveries (opt-in; expected non-zero false-positive rate).
420    pub fragments: Vec<CarvedFragment>,
421}
422
423/// Recover deleted records by carving the database's free (unallocated) pages.
424///
425/// Each free page from [`Database::freelist_pages`] is scanned with
426/// [`Database::carve_cells`] for record-shaped cells of `column_count` columns.
427/// Free pages hold only deallocated content, so the recovered rows are deleted
428/// ones — the carver never re-surfaces a live (allocated) row. Recovered rows
429/// are **confidence-graded observations**, not certainties: a carved record is
430/// "consistent with a deleted row", and the examiner draws the conclusion.
431///
432/// Read-only and panic-free: a malformed freelist simply yields fewer (or no)
433/// carved records rather than an error.
434#[must_use]
435pub fn carve_deleted_records(db: &Database, column_count: usize) -> Vec<CarvedRecord> {
436    let mut out = Vec::new();
437    let Ok(free) = db.freelist_pages() else {
438        return out;
439    };
440    for page in free {
441        let Some(page_bytes) = db.raw_page(page) else {
442            continue; // cov:unreachable: freelist_pages only yields in-range pages
443        };
444        for cell in db.carve_cells(page_bytes, column_count) {
445            out.push(CarvedRecord {
446                page,
447                offset: cell.offset,
448                rowid: cell.rowid,
449                values: cell.values,
450                confidence: cell.confidence,
451                allocated: false,
452                source: RecoverySource::FreelistPage,
453                wal: None,
454                overflow: None,
455            });
456        }
457    }
458    out
459}
460
461/// Recover deleted records across **every** free-space class — the full-coverage
462/// carver. Drives, in order:
463///
464/// 1. **Freelist pages** (whole pages freed onto the freelist), with the column
465///    count inferred per record so it also recovers **dropped-table** pages whose
466///    `sqlite_master` schema is gone (e.g. a `DROP TABLE` left the page on the
467///    freelist with no recorded column count).
468/// 2. **In-page free space** of still-allocated table-leaf pages (the unallocated
469///    gap and inter-cell slack), via [`Database::carve_free_regions`], which
470///    carves only the complement of the live cells — so a live (allocated) row is
471///    **never** re-surfaced (the 0-false-positive guarantee, enforced
472///    structurally).
473///
474/// Records are de-duplicated by `(rowid, values)` keeping the highest-confidence
475/// copy, since a row can survive in more than one place. Every record is graded:
476/// freelist-page recovery highest, dropped-table next, in-page residue lowest.
477///
478/// Read-only and panic-free; a malformed structure yields fewer records, never an
479/// error or panic.
480#[must_use]
481pub fn carve_all_deleted_records(db: &Database) -> Vec<CarvedRecord> {
482    let mut out: Vec<CarvedRecord> = Vec::new();
483
484    // (1) Freelist pages, inferring the column count per record. Inference makes
485    // this recover both normal freed pages AND schema-gone dropped-table pages
486    // (a `DROP TABLE` leaves the page on the freelist with no recorded column
487    // count). When the database has no live user table at all, the freed content
488    // is necessarily from a dropped table, so mark those records accordingly.
489    let dropped_table_db = !db.has_user_table();
490    if let Ok(free) = db.freelist_pages() {
491        for page in free {
492            let Some(page_bytes) = db.raw_page(page) else {
493                continue; // cov:unreachable: freelist_pages yields in-range pages
494            };
495            let source = if dropped_table_db {
496                RecoverySource::DroppedTable
497            } else {
498                RecoverySource::FreelistPage
499            };
500            for cell in db.carve_cells_inferred(page_bytes) {
501                out.push(CarvedRecord {
502                    page,
503                    offset: cell.offset,
504                    rowid: cell.rowid,
505                    values: cell.values,
506                    confidence: cell.confidence,
507                    allocated: false,
508                    source,
509                    wal: None,
510                    overflow: None,
511                });
512            }
513        }
514    }
515
516    // (2) In-page free space of every still-allocated table-leaf page.
517    let page_count = db.page_count();
518    for page in 1..=page_count {
519        let Some(page_bytes) = db.raw_page(page) else {
520            continue; // cov:unreachable: 1..=page_count is in range
521        };
522        for cell in db.carve_free_regions(page_bytes, 0) {
523            out.push(CarvedRecord {
524                page,
525                offset: cell.offset,
526                rowid: cell.rowid,
527                values: cell.values,
528                confidence: cell.confidence,
529                allocated: false,
530                source: RecoverySource::InPageFreeBlock,
531                wal: None,
532                overflow: None,
533            });
534        }
535        // (2b) Freeblock reconstruction: the freed cells whose first four bytes
536        // were clobbered by freeblock conversion, rebuilt from their surviving
537        // serial tail plus the page's schema template. These carry an unknown
538        // (destroyed) rowid, so the value-collision pass below — not the
539        // rowid-keyed filter — is what guarantees no live row is re-surfaced.
540        for cell in db.reconstruct_freeblock_records(page_bytes) {
541            out.push(CarvedRecord {
542                page,
543                offset: cell.offset,
544                rowid: cell.rowid,
545                values: cell.values,
546                confidence: cell.confidence,
547                allocated: false,
548                source: RecoverySource::FreeblockReconstructed,
549                wal: None,
550                overflow: None,
551            });
552        }
553        // (2c) Chain-aware overflow recovery (task #73): a freed cell whose
554        // payload spilled onto an overflow-page chain, reassembled when every
555        // chain page survives as a freelist leaf. Graded below the in-page
556        // full-row tier (NOT part of the structural 0-FP guarantee — Codex
557        // ruling #1); a broken chain (e.g. a trunk-clobbered page) is rejected
558        // here and degrades to a Tier-2 fragment elsewhere.
559        for (cell, chain) in db.carve_overflow_records(page_bytes) {
560            let first_page = chain.first().copied().unwrap_or(0);
561            out.push(CarvedRecord {
562                page,
563                offset: cell.offset,
564                rowid: cell.rowid,
565                values: cell.values,
566                confidence: cell.confidence,
567                allocated: false,
568                source: RecoverySource::InPageFreeBlock,
569                wal: None,
570                overflow: Some(OverflowProvenance { first_page, chain }),
571            });
572        }
573        // (2d) Freeblock-clobbered spilled cells (task #73, Codex ruling #5;
574        // UNPROVEN-BY-CORPUS — synthetic validation only). A spilled cell whose
575        // prefix was also freeblock-clobbered: P re-derived from the surviving
576        // serial array, chain resolved through freelist leaves, rowid destroyed.
577        for (cell, chain) in db.carve_overflow_template_records(page_bytes) {
578            let first_page = chain.first().copied().unwrap_or(0);
579            out.push(CarvedRecord {
580                page,
581                offset: cell.offset,
582                rowid: cell.rowid,
583                values: cell.values,
584                confidence: cell.confidence,
585                allocated: false,
586                source: RecoverySource::FreeblockReconstructed,
587                wal: None,
588                overflow: Some(OverflowProvenance { first_page, chain }),
589            });
590        }
591    }
592
593    // (3) WAL-frame carving (additive — runs ONLY when a `-wal` overlay is in
594    // effect). The on-disk path above reads only the main file's pages, so it
595    // finds the SAME residue whether or not a WAL was supplied; the genuinely-
596    // different deleted records live in the uncheckpointed WAL frames.
597    //
598    // A `-wal` frame is a FULL page snapshot at one point in the transaction
599    // history. A row deleted late in that history is still a live cell in an
600    // EARLIER frame's image; it exists nowhere on disk and in no later frame.
601    // Three primitives over each committed frame's page image surface it:
602    //
603    //   * `carve_leaf_cells` — every cell the frame records as allocated. A cell
604    //     that is allocated in a superseded frame but ABSENT from the final
605    //     WAL-applied live view is exactly such a deleted row (recovered as a
606    //     clean, intact record — the strongest WAL case).
607    //   * `carve_free_regions` / `reconstruct_freeblock_records` — residue freed
608    //     WITHIN a frame (e.g. the DELETE-commit frame's own freeblocks), matching
609    //     the on-disk in-page classes.
610    //
611    // Every candidate is tagged `WalFrame` with the (salt1, salt2, frame_index)
612    // LSN provenance, and the shared live-row precision filter below drops any
613    // whose values match a currently-live row — so a surviving row is never
614    // re-surfaced (the 0-false-positive guarantee, against the WAL-applied view).
615    for frame in db.wal_frame_pages() {
616        let prov = WalProvenance {
617            frame_index: frame.frame_index,
618            salt1: frame.salt1,
619            salt2: frame.salt2,
620        };
621        let cells = db
622            .carve_leaf_cells(&frame.page)
623            .into_iter()
624            .chain(db.carve_free_regions(&frame.page, 0))
625            .chain(db.reconstruct_freeblock_records(&frame.page));
626        for cell in cells {
627            out.push(CarvedRecord {
628                page: frame.page_no,
629                offset: cell.offset,
630                rowid: cell.rowid,
631                values: cell.values,
632                confidence: cell.confidence,
633                allocated: false,
634                source: RecoverySource::WalFrame,
635                wal: Some(prov),
636                overflow: None,
637            });
638        }
639    }
640
641    // VALUE-AWARE classification for carved records whose rowid is currently
642    // LIVE. Two very different cases share a live rowid:
643    //
644    //   * Stale rebalance copy — a b-tree rebalance moved a still-live row to
645    //     another page, leaving a byte-identical copy in the old page's slack.
646    //     SAME rowid, SAME values → NOT deleted → drop (the 0-false-positive
647    //     precision win we must preserve).
648    //   * Prior version — an UPDATE freed the OLD version of the row into slack
649    //     while the new version kept the same rowid. SAME rowid, DIFFERENT values
650    //     → genuinely-deleted content (the edited message / changed amount, often
651    //     THE evidence) → recover, tagged `PriorVersion`.
652    //
653    // A rowid-only filter cannot tell these apart and drops both (a false
654    // negative on prior versions). Comparing decoded values does.
655    let live = db.live_rows();
656    // Value-level identity of every live row, for collision-checking records whose
657    // rowid is unknown (freeblock reconstructions have a destroyed rowid, so the
658    // rowid-keyed filter below cannot protect against re-surfacing a live row).
659    // The live set also includes the CURRENT `sqlite_master` rows: a record carved
660    // from a materialized page 1 (the schema table) whose values equal a live
661    // schema entry is that live row re-surfaced, not deleted residue — drop it.
662    // (Value-based, so a genuinely-deleted PRIOR schema version is still recovered.)
663    let live_value_keys: std::collections::HashSet<String> = live
664        .values()
665        .chain(db.live_schema_rows().iter())
666        .map(|v| format!("{v:?}"))
667        .collect();
668    out.retain_mut(|rec| {
669        // Freeblock reconstructions carry an unknown rowid → guard by value: drop
670        // any whose decoded values match a currently-live row (never re-surface a
671        // live row), even though we cannot key it by rowid.
672        if rec.source == RecoverySource::FreeblockReconstructed {
673            return !live_value_keys.contains(&format!("{:?}", rec.values));
674        }
675        // WAL-frame residue is guarded the same way and KEEPS its WalFrame tag:
676        // drop any record whose values match a currently-live row (the WAL-applied
677        // view's live set), so a row that survived the deletion is never
678        // re-surfaced; a genuinely-deleted WAL row has no live match and is kept
679        // with its frame provenance intact (not reclassified to PriorVersion).
680        if rec.source == RecoverySource::WalFrame {
681            return !live_value_keys.contains(&format!("{:?}", rec.values));
682        }
683        match live.get(&rec.rowid) {
684            // rowid not live → an ordinary deleted record (keep, source unchanged).
685            None => true,
686            // Same rowid: a byte-identical copy is a stale rebalance artifact (drop);
687            // differing values are a deleted prior version (keep, reclassified).
688            Some(live_values) => {
689                if &rec.values == live_values {
690                    false
691                } else {
692                    rec.source = RecoverySource::PriorVersion;
693                    true
694                }
695            }
696        }
697    });
698
699    dedup_keep_best(out)
700}
701
702/// Two-tier deleted-record recovery: Tier-1 full rows **plus** Tier-2 partial
703/// fragments, in one pass.
704///
705/// [`CarveTiers::full`] is byte-identical to [`carve_all_deleted_records`] (the
706/// high-precision, structurally-0-false-positive output). [`CarveTiers::fragments`]
707/// is the opt-in lead surface: at every freeblock/gap anchor where full
708/// reconstruction failed but ≥ 1 distinctive cell (TEXT ≥ 4 bytes of valid UTF-8,
709/// or REAL) survived, the maximal decodable column prefix is salvaged as a
710/// [`CarvedFragment`]. Three suppression layers keep the fragment bucket honest:
711///
712/// 1. **By construction** — a fragment is emitted only where full reconstruction
713///    failed, so an anchor yields a full cell or a fragment, never both.
714/// 2. **Full-row value suppression** — a fragment whose every surviving
715///    `(column, value)` matches the corresponding column of a Tier-1 record in
716///    `full` is dropped (the row was already recovered another way).
717/// 3. **Live-row suppression** — a fragment whose every surviving `(column, value)`
718///    matches the corresponding columns of a currently-live row is dropped (never
719///    re-surface a live row, the fragment analog of the rebalance-copy drop).
720///
721/// Read-only and panic-free, identically to [`carve_all_deleted_records`].
722#[must_use]
723pub fn carve_with_fragments(db: &Database) -> CarveTiers {
724    let full = carve_all_deleted_records(db);
725
726    // Salvage Tier-2 fragments from every on-disk table-leaf page. The core
727    // walker emits a fragment only where full reconstruction failed (layer 1).
728    let mut fragments: Vec<CarvedFragment> = Vec::new();
729    let page_count = db.page_count();
730    for page in 1..=page_count {
731        let Some(page_bytes) = db.raw_page(page) else {
732            continue; // cov:unreachable: 1..=page_count is in range
733        };
734        for frag in db.reconstruct_freeblock_fragments(page_bytes) {
735            fragments.push(CarvedFragment {
736                page,
737                offset: frag.offset,
738                surviving: frag.surviving,
739                missing: frag.missing,
740                confidence: frag.confidence,
741                source: RecoverySource::FreeblockReconstructed,
742                wal: None,
743            });
744        }
745        // Broken-chain overflow fragments (task #73, Codex ruling #4): a spilled
746        // cell whose overflow chain was destroyed (e.g. a trunk-clobbered chain
747        // page) still has an intact local prefix; salvage its locally-decodable
748        // columns. The chain-resident columns are lost (untrusted), so this is a
749        // Tier-2 lead, never a full row.
750        for frag in db.carve_overflow_fragments(page_bytes) {
751            fragments.push(CarvedFragment {
752                page,
753                offset: frag.offset,
754                surviving: frag.surviving,
755                missing: frag.missing,
756                confidence: frag.confidence,
757                source: RecoverySource::InPageFreeBlock,
758                wal: None,
759            });
760        }
761    }
762
763    // Layer 3: live-row suppression. A fragment whose surviving set matches the
764    // corresponding columns of a live row is a stale partial copy of a live row.
765    let live = db.live_rows();
766    fragments.retain(|frag| !live.values().any(|lv| fragment_matches_columns(frag, lv)));
767
768    // Layer 2: full-row value suppression. A fragment already covered by a
769    // recovered full row in this carve is a duplicate — drop it.
770    fragments.retain(|frag| {
771        !full
772            .iter()
773            .any(|rec| fragment_matches_columns(frag, &rec.values))
774    });
775
776    // Fragment dedup: one fragment per (page, offset) anchor by construction,
777    // then collapse value-level duplicates keeping the copy with more surviving
778    // columns (mirrors dedup_keep_best for the full tier).
779    fragments = dedup_fragments(fragments);
780
781    CarveTiers { full, fragments }
782}
783
784/// Whether a fragment's every surviving `(column_index, value)` pair equals the
785/// corresponding column of `row` — the projection test shared by the full-row
786/// (layer 2) and live-row (layer 3) suppression passes. Equality of the **whole**
787/// surviving set is required: a fragment that coincides with a row on only some
788/// cells is kept (it may be genuine residue), mirroring the full-row rule's
789/// whole-values comparison.
790fn fragment_matches_columns(frag: &CarvedFragment, row: &[Value]) -> bool {
791    !frag.surviving.is_empty()
792        && frag
793            .surviving
794            .iter()
795            .all(|(idx, v)| row.get(*idx) == Some(v))
796}
797
798/// De-duplicate fragments: first by `(page, offset)` anchor (one fragment per
799/// anchor by construction), then collapse value-level duplicates keeping the copy
800/// with the most surviving columns. Mirrors [`dedup_keep_best`] for the full tier.
801fn dedup_fragments(mut frags: Vec<CarvedFragment>) -> Vec<CarvedFragment> {
802    use std::collections::HashSet;
803    // More surviving columns first, so the kept copy of each identity is richest.
804    frags.sort_by(|a, b| b.surviving.len().cmp(&a.surviving.len()));
805    let mut seen_anchor: HashSet<(u32, usize)> = HashSet::new();
806    let mut seen_values: HashSet<String> = HashSet::new();
807    let mut kept = Vec::new();
808    for frag in frags {
809        if !seen_anchor.insert((frag.page, frag.offset)) {
810            continue;
811        }
812        let vkey = format!("{:?}", frag.surviving);
813        if !seen_values.insert(vkey) {
814            continue;
815        }
816        kept.push(frag);
817    }
818    kept
819}
820
821/// Carve the deleted residue of **one materialized commit snapshot** of the
822/// `-wal`, the per-commit temporal building block of the N-snapshot carve.
823///
824/// `id` addresses a [`CommitId`] in `timeline`; the snapshot it resolves to is the
825/// database state replayed up to that COMMIT (base image ∪ every committed frame to
826/// that commit, capped to `db_size_after_commit`). This runs the same three
827/// carving primitives the on-disk path uses — [`Database::carve_leaf_cells`]
828/// (intact cells the snapshot records as allocated, the strongest case),
829/// [`Database::carve_free_regions`], and [`Database::reconstruct_freeblock_records`]
830/// — over each of the snapshot's materialized page images, then applies the SAME
831/// live-row precision filter: a record whose decoded values match a currently-live
832/// row (the WAL-applied view) is dropped, so a row that survived to the final state
833/// is **never** re-surfaced as deleted. Surviving records are tagged
834/// [`RecoverySource::CommitSnapshot`] and carry the commit's
835/// `(salt1, salt2, commit_frame_index)` LSN in [`CarvedRecord::wal`].
836///
837/// An unknown [`CommitId`] (absent from `timeline`) yields an empty vector, never a
838/// panic. Read-only throughout.
839#[must_use]
840pub fn carve_at_commit(db: &Database, timeline: &WalTimeline, id: CommitId) -> Vec<CarvedRecord> {
841    let Some(snapshot) = timeline.snapshot_at(id) else {
842        return Vec::new();
843    };
844    let lsn = snapshot.lsn();
845    let prov = WalProvenance {
846        frame_index: lsn.frame_index,
847        salt1: lsn.salt1,
848        salt2: lsn.salt2,
849    };
850
851    let mut out: Vec<CarvedRecord> = Vec::new();
852    for page_no in snapshot.page_numbers() {
853        let Some(image) = snapshot.page_version(page_no) else {
854            continue; // cov:unreachable: page_numbers only yields materialized pages
855        };
856        let cells = db
857            .carve_leaf_cells(&image.bytes)
858            .into_iter()
859            .chain(db.carve_free_regions(&image.bytes, 0))
860            .chain(db.reconstruct_freeblock_records(&image.bytes));
861        for cell in cells {
862            out.push(CarvedRecord {
863                page: page_no,
864                offset: cell.offset,
865                rowid: cell.rowid,
866                values: cell.values,
867                confidence: cell.confidence,
868                allocated: false,
869                source: RecoverySource::CommitSnapshot,
870                wal: Some(prov),
871                overflow: None,
872            });
873        }
874    }
875
876    // Live-row precision filter (the WAL-applied view): drop any record whose
877    // decoded values match a currently-live row, so a row that survives to the
878    // final state is never re-surfaced as "deleted at an earlier commit". The
879    // live set includes the CURRENT `sqlite_master` rows, because a snapshot's
880    // materialized page 1 (the schema table) still holds the live schema cell —
881    // value-based, so a deleted PRIOR schema version is still recovered.
882    let live = db.live_rows();
883    let live_value_keys: std::collections::HashSet<String> = live
884        .values()
885        .chain(db.live_schema_rows().iter())
886        .map(|v| format!("{v:?}"))
887        .collect();
888    out.retain(|rec| !live_value_keys.contains(&format!("{:?}", rec.values)));
889
890    dedup_keep_best(out)
891}
892
893/// De-duplicate carved records by content identity, keeping the
894/// highest-confidence copy of each (a row can survive in several free regions).
895///
896/// `Value` carries an `f64` (`Real`) so it is not `Hash`/`Eq`; the identity key
897/// is the record's `rowid` plus a stable `Debug` rendering of its values, which
898/// is sufficient to collapse byte-identical recoveries.
899fn dedup_keep_best(mut records: Vec<CarvedRecord>) -> Vec<CarvedRecord> {
900    use std::collections::HashSet;
901    let mut seen: HashSet<String> = HashSet::new();
902    let mut kept: Vec<CarvedRecord> = Vec::new();
903    // Highest confidence first, so the kept copy of each identity is the best one.
904    records.sort_by(|a, b| {
905        b.confidence
906            .partial_cmp(&a.confidence)
907            .unwrap_or(std::cmp::Ordering::Equal)
908    });
909    for rec in records.drain(..) {
910        let key = format!("{}:{:?}", rec.rowid, rec.values);
911        if seen.insert(key) {
912            kept.push(rec);
913        }
914    }
915    kept
916}
917
918/// Audit an opened [`Database`] for forensically-notable anomalies.
919///
920/// Covers the header reserved-space field, a non-empty freelist (prior
921/// deletions), an active WAL overlay (uncheckpointed state), and a header/file
922/// page-count mismatch. Deleted-record recovery is offered separately via
923/// [`carve_deleted_records`] / [`audit_carved_findings`] because it requires the
924/// table's column count.
925#[must_use]
926pub fn audit(db: &Database) -> Vec<Anomaly> {
927    let mut out = Vec::new();
928
929    let reserved = db.header().reserved;
930    if reserved != 0 {
931        out.push(Anomaly::new(AnomalyKind::NonZeroReservedSpace { reserved }));
932    }
933
934    let free_pages = db.freelist_count();
935    if free_pages != 0 {
936        out.push(Anomaly::new(AnomalyKind::NonEmptyFreelist { free_pages }));
937    }
938
939    if db.wal_applied() {
940        // The overlay supersedes at least one page; report it as uncheckpointed
941        // state. (The exact page count is not separately exposed; report ≥1.)
942        out.push(Anomaly::new(AnomalyKind::WalUncheckpointedState {
943            overlaid_pages: 1,
944        }));
945    }
946
947    let header_pages = db.header_page_count();
948    let file_pages = db.file_page_count();
949    if header_pages != 0 && header_pages != file_pages {
950        out.push(Anomaly::new(AnomalyKind::PageCountMismatch {
951            header_pages,
952            file_pages,
953        }));
954    }
955
956    out
957}
958
959/// Audit an opened [`Database`] and convert each anomaly to the canonical
960/// [`Finding`] under the supplied [`Source`], ready to merge into a `Report`.
961#[must_use]
962pub fn audit_findings(db: &Database, source: &Source) -> Vec<Finding> {
963    audit(db)
964        .into_iter()
965        .map(|a| a.to_finding(source.clone()))
966        .collect()
967}
968
969/// Carve deleted records and convert each to a canonical [`Finding`] under
970/// `source`. The per-record confidence is threaded into the finding's context so
971/// downstream consumers can filter low-confidence recoveries.
972#[must_use]
973pub fn audit_carved_findings(db: &Database, column_count: usize, source: &Source) -> Vec<Finding> {
974    carve_deleted_records(db, column_count)
975        .into_iter()
976        .map(|rec| {
977            Anomaly::new(AnomalyKind::DeletedRecordRecovered {
978                page: rec.page,
979                offset: rec.offset,
980                rowid: rec.rowid,
981            })
982            .with_confidence(rec.confidence)
983            .to_finding(source.clone())
984        })
985        .collect()
986}