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}