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//! - [`audit_journal`] grades the rollback-`-journal` design-§6 observations
23//! (hot journal, recoverable PERSIST pre-images, checksum mismatch, journaled
24//! schema page, duplicate page record, db-size delta) — additive to [`audit`].
25//!
26//! Deferred: a full anomaly suite (overflow-chain integrity, schema-format /
27//! text-encoding checks) and a fuzz harness.
28
29#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]
30
31pub mod blob;
32pub mod case_uco;
33pub mod interpret;
34
35use forensicnomicon::report::{
36 Confidence, Evidence, Finding, Location, Observation, Severity, Source,
37};
38use sqlite_core::{CommitId, Database, JournalHeader, RollbackJournal, Value, WalTimeline};
39
40/// The classified `SQLite` forensic anomalies this auditor can grade.
41///
42/// `#[non_exhaustive]` so WS-E can add carving / WAL / freelist variants without
43/// a breaking change; downstream `match` arms must carry a `_` arm.
44#[derive(Debug, Clone, PartialEq, Eq)]
45#[non_exhaustive]
46pub enum AnomalyKind {
47 /// The header's reserved-space-per-page field is non-zero. Standard
48 /// `SQLite` leaves this at 0; a non-zero value is used by page-level
49 /// extensions (e.g. encryption such as SQLCipher/SEE, or checksum VFS) and
50 /// is worth flagging on an evidence database.
51 NonZeroReservedSpace {
52 /// The reserved bytes per page reported by the header.
53 reserved: u8,
54 },
55 /// A record-shaped cell was recovered from unallocated / free space —
56 /// consistent with a deleted row that has not yet been overwritten.
57 DeletedRecordRecovered {
58 /// 1-based page the residue was carved from.
59 page: u32,
60 /// Byte offset of the cell within that page.
61 offset: usize,
62 /// Recovered rowid.
63 rowid: i64,
64 },
65 /// A `sqlite_master` row was recovered from page-1 free space whose
66 /// definition is absent from the live schema — consistent with a **dropped
67 /// (or replaced)** table/index/view/trigger whose `CREATE` statement and
68 /// existence survive the drop under `secure_delete=OFF`.
69 DroppedSchemaRecovered {
70 /// `sqlite_master.type` — `table`, `index`, `view`, or `trigger`.
71 object_type: String,
72 /// Name of the dropped object.
73 name: String,
74 },
75 /// The freelist is non-empty: the database holds free (unallocated) pages.
76 /// Consistent with prior deletions (`DELETE` without `VACUUM`); those pages
77 /// may retain recoverable deleted records.
78 NonEmptyFreelist {
79 /// Number of free pages on the freelist.
80 free_pages: u32,
81 },
82 /// A `-wal` sidecar carried committed-but-unflushed page versions that the
83 /// main database file does not yet reflect. Consistent with an evidence
84 /// database captured while a write transaction was checkpoint-pending; the
85 /// main file alone would under-report the true state.
86 WalUncheckpointedState {
87 /// Number of pages the WAL overlay superseded in the main file.
88 overlaid_pages: u32,
89 },
90 /// The in-header page count disagrees with the page count implied by the
91 /// file length. Consistent with truncation, carving, or out-of-band
92 /// modification of the database file.
93 PageCountMismatch {
94 /// Page count recorded in the file header (offset 28).
95 header_pages: u32,
96 /// Page count implied by `file_len / page_size`.
97 file_pages: u32,
98 },
99 /// A `-journal` with an intact header (valid magic) sits beside the database
100 /// — a *hot journal*. Consistent with an interrupted or in-progress write
101 /// transaction (crash, power loss, process kill, or acquisition captured
102 /// mid-write); `SQLite` would roll it back on next open, so the main db may
103 /// require rollback. The journal holds the pre-interruption state. (Design §6
104 /// item 1 — state the observation; never assert "anti-forensic".)
105 HotJournal {
106 /// Database page count recorded at transaction start (`dbOrigSize`).
107 mx_page: u32,
108 /// Number of pre-transaction page images the journal carries — the scope
109 /// of the interrupted transaction.
110 journaled_pages: u32,
111 },
112 /// A committed PERSIST `-journal` (header zeroed, bodies intact) carries
113 /// recoverable pre-images. Consistent with a normally-committed transaction
114 /// whose deleted/modified rows remain recoverable (design §6 item 2).
115 JournalRecoverable {
116 /// Number of pre-transaction page images the journal carries.
117 images: usize,
118 },
119 /// One or more journal page records failed the `pager.c` checksum (Tier A
120 /// only — Tier B has no nonce to verify against). Consistent with corruption,
121 /// a torn page (power-loss mid-sector), or post-write modification of the
122 /// journal (design §6 item 3).
123 JournalChecksumMismatch {
124 /// The page numbers whose stored checksum did not match, ascending.
125 pgnos: Vec<u32>,
126 /// Total page records the journal carries (the denominator).
127 total: usize,
128 },
129 /// The journal's prior **page-1 image** carries a different schema cookie
130 /// (file-header offset 40, 4-byte BE) than the live database. The cookie
131 /// advances only on `CREATE`/`DROP`/`ALTER`, so a difference is consistent
132 /// with a DDL change in the last transaction; the prior schema is recoverable
133 /// (design §6 item 6). Page 1 alone is NOT sufficient — it is journaled on
134 /// nearly every write (the change-counter / freelist-count / db-size header
135 /// fields update routinely), so the cookie comparison, not page-1 presence,
136 /// is the DDL signal.
137 JournalSchemaChange {
138 /// Schema cookie in the journal's prior page-1 image (offset 40, BE).
139 journal_cookie: u32,
140 /// Schema cookie in the live database's page 1 (offset 40, BE).
141 db_cookie: u32,
142 },
143 /// A `pgno` appeared more than once across the journal's page records. The
144 /// spec journals a page at most once, so a repeat is consistent with
145 /// corruption, a savepoint/super-journal artifact, or tampering (design §6
146 /// item 9).
147 JournalDuplicatePage {
148 /// The page numbers that repeated (each once, first-seen order) — the
149 /// offending values, so the finding can name them.
150 pgnos: Vec<u32>,
151 },
152 /// The database page count recorded at transaction start (`mxPage`, Tier A
153 /// only) differs from the current page count: the last transaction changed
154 /// the db size. `mxPage < current` ⇒ growth (INSERTs); `mxPage > current` ⇒
155 /// shrink, consistent with auto-vacuum/incremental-vacuum or truncation — NOT
156 /// an ordinary `DELETE` (design §6 item 5).
157 JournalDbSizeDelta {
158 /// Database page count at transaction start (`mxPage`, journal header).
159 mx_page: u32,
160 /// Current database page count.
161 current_pages: u32,
162 },
163 /// The free-page count declared in the header (offset 36) disagrees with the
164 /// count obtained by walking the freelist trunk chain — or the chain is
165 /// unwalkable (out-of-range/cyclic trunk pointer). The header field is a
166 /// *claim*; the walk *verifies* it. A mismatch is consistent with freelist
167 /// tampering (a hand-edited count to hide freed pages) or corruption
168 /// (design §2.1 — distrust the header).
169 FreelistCountInconsistent {
170 /// Free-page count declared in the header (offset 36).
171 declared: u32,
172 /// Free-page count obtained by walking the trunk chain, or `None` when
173 /// the chain could not be walked (malformed/cyclic/out-of-range trunk).
174 walked: Option<u32>,
175 },
176 /// Freed freelist *leaf* pages are entirely zero. A leaf page keeps its
177 /// former content byte-for-byte, so an all-zero freed leaf means the deleted
178 /// records it held were overwritten with zeros — residue deliberately
179 /// destroyed. Consistent with `secure_delete=ON` or a manual wipe (design
180 /// §2.1); it is a fingerprint of destruction, never proof of intent.
181 ZeroedFreelistResidue {
182 /// Number of freed leaf pages that are entirely zero.
183 zeroed: u32,
184 /// Total number of freed leaf pages (the denominator).
185 free_leaves: u32,
186 /// The lowest zeroed freed-leaf page number — a pointer to the evidence.
187 first_zeroed: u32,
188 },
189}
190
191impl AnomalyKind {
192 /// Severity, derived from the kind.
193 #[must_use]
194 pub fn severity(&self) -> Severity {
195 match self {
196 AnomalyKind::NonZeroReservedSpace { .. } | AnomalyKind::NonEmptyFreelist { .. } => {
197 Severity::Low
198 }
199 AnomalyKind::DeletedRecordRecovered { .. }
200 | AnomalyKind::DroppedSchemaRecovered { .. }
201 | AnomalyKind::WalUncheckpointedState { .. }
202 | AnomalyKind::JournalRecoverable { .. }
203 | AnomalyKind::JournalSchemaChange { .. }
204 | AnomalyKind::JournalDuplicatePage { .. }
205 | AnomalyKind::ZeroedFreelistResidue { .. } => Severity::Medium,
206 AnomalyKind::PageCountMismatch { .. }
207 | AnomalyKind::HotJournal { .. }
208 | AnomalyKind::JournalChecksumMismatch { .. }
209 | AnomalyKind::FreelistCountInconsistent { .. } => Severity::High,
210 AnomalyKind::JournalDbSizeDelta { .. } => Severity::Low,
211 }
212 }
213
214 /// Stable, scheme-prefixed machine code (a published contract).
215 #[must_use]
216 pub fn code(&self) -> &'static str {
217 match self {
218 AnomalyKind::NonZeroReservedSpace { .. } => "SQLITE-RESERVED-SPACE-NONZERO",
219 AnomalyKind::DeletedRecordRecovered { .. } => "SQLITE-DELETED-RECORD-RECOVERED",
220 AnomalyKind::DroppedSchemaRecovered { .. } => "SQLITE-DROPPED-SCHEMA-RECOVERED",
221 AnomalyKind::NonEmptyFreelist { .. } => "SQLITE-FREELIST-NONEMPTY",
222 AnomalyKind::WalUncheckpointedState { .. } => "SQLITE-WAL-UNCHECKPOINTED",
223 AnomalyKind::PageCountMismatch { .. } => "SQLITE-PAGECOUNT-MISMATCH",
224 AnomalyKind::HotJournal { .. } => "SQLITE-JOURNAL-HOT",
225 AnomalyKind::JournalRecoverable { .. } => "SQLITE-JOURNAL-RECOVERABLE",
226 AnomalyKind::JournalChecksumMismatch { .. } => "SQLITE-JOURNAL-CHECKSUM-MISMATCH",
227 AnomalyKind::JournalSchemaChange { .. } => "SQLITE-JOURNAL-SCHEMA-CHANGE",
228 AnomalyKind::JournalDuplicatePage { .. } => "SQLITE-JOURNAL-DUPLICATE-PAGE",
229 AnomalyKind::JournalDbSizeDelta { .. } => "SQLITE-JOURNAL-DBSIZE-DELTA",
230 AnomalyKind::FreelistCountInconsistent { .. } => "SQLITE-FREELIST-COUNT-INCONSISTENT",
231 AnomalyKind::ZeroedFreelistResidue { .. } => "SQLITE-FREELIST-RESIDUE-ZEROED",
232 }
233 }
234
235 /// Human-readable, "consistent with" note.
236 #[must_use]
237 pub fn note(&self) -> String {
238 match self {
239 AnomalyKind::NonZeroReservedSpace { reserved } => {
240 // Name the scheme the reserved-byte count matches (values measured
241 // against sqlcipher 4.16 and the checksum-VFS spec, cksumvfs.html);
242 // an unmatched value is labelled unrecognized with the raw bytes
243 // shown, never guessed into a named scheme.
244 let scheme = match reserved {
245 80 => {
246 "consistent with SQLCipher 4 (a 16-byte per-page IV plus a \
247 64-byte HMAC-SHA512)"
248 }
249 48 => {
250 "consistent with SQLCipher 1-3 (a 16-byte per-page IV plus a \
251 32-byte HMAC-SHA1)"
252 }
253 16 => {
254 "consistent with a 16-byte per-page IV (an AES-CBC cipher with \
255 per-page HMAC disabled)"
256 }
257 8 => {
258 "consistent with the SQLite checksum VFS (an 8-byte per-page \
259 checksum)"
260 }
261 _ => {
262 "does not match a known page-level scheme's per-page reservation \
263 (an unrecognized extension)"
264 }
265 };
266 format!(
267 "file header reserves {reserved} byte(s) per page (offset 20) — \
268 non-standard; {scheme}. Recovering any deleted records would require \
269 the encryption key (or the corresponding VFS)"
270 )
271 }
272 AnomalyKind::DeletedRecordRecovered {
273 page,
274 offset,
275 rowid,
276 } => format!(
277 "recovered a record-shaped cell (rowid {rowid}) from unallocated \
278 space at page {page} offset {offset} — consistent with a deleted \
279 row not yet overwritten"
280 ),
281 AnomalyKind::DroppedSchemaRecovered { object_type, name } => format!(
282 "recovered a deleted sqlite_master row for {object_type} \"{name}\" \
283 from page-1 free space — consistent with a dropped (or replaced) \
284 {object_type} whose definition survives the drop"
285 ),
286 AnomalyKind::NonEmptyFreelist { free_pages } => format!(
287 "{free_pages} free page(s) on the freelist — consistent with prior \
288 deletions (DELETE without VACUUM); free pages may retain \
289 recoverable deleted records"
290 ),
291 AnomalyKind::WalUncheckpointedState { overlaid_pages } => format!(
292 "the -wal sidecar carries {overlaid_pages} committed page version(s) \
293 the main file does not reflect — consistent with capture while a \
294 write transaction was checkpoint-pending; the main file alone \
295 under-reports the true state; acquire the live -wal before the \
296 application terminates, because a checkpoint (e.g. on its next clean \
297 close) folds the WAL into the main file and discards the \
298 uncheckpointed deleted/superseded residue, which the \
299 post-checkpoint main file alone would no longer contain"
300 ),
301 AnomalyKind::PageCountMismatch {
302 header_pages,
303 file_pages,
304 } => format!(
305 "in-header page count ({header_pages}) disagrees with the file \
306 length ({file_pages} pages) — consistent with truncation, \
307 carving, or out-of-band modification"
308 ),
309 AnomalyKind::HotJournal { .. } => {
310 "a hot rollback journal (valid header magic) sits beside \
311 the database — consistent with an interrupted or in-progress write transaction \
312 (crash, power loss, process kill, or acquisition captured mid-write); SQLite \
313 would roll it back on next open, so the main database may require rollback"
314 .to_string()
315 }
316 AnomalyKind::JournalRecoverable { images } => format!(
317 "the rollback journal carries {images} pre-transaction page \
318 image(s) (PERSIST post-commit) — consistent with a committed \
319 transaction whose pre-images (deleted/modified rows) remain \
320 recoverable"
321 ),
322 AnomalyKind::JournalChecksumMismatch { pgnos, total } => {
323 let list = pgnos
324 .iter()
325 .map(u32::to_string)
326 .collect::<Vec<_>>()
327 .join(", ");
328 format!(
329 "{} of {total} journal page record(s) failed the page \
330 checksum (page(s) {list}) — consistent with corruption, a \
331 torn page write (power-loss mid-sector), or post-write \
332 modification of the journal",
333 pgnos.len()
334 )
335 }
336 AnomalyKind::JournalSchemaChange {
337 journal_cookie,
338 db_cookie,
339 } => format!(
340 "the journal's prior schema cookie ({journal_cookie}) differs from the \
341 database's ({db_cookie}) — consistent with a DDL change (CREATE/DROP/ALTER) \
342 in the last transaction; the prior schema is recoverable"
343 ),
344 AnomalyKind::JournalDuplicatePage { .. } => {
345 "a page number appears more than once across the \
346 journal's page records — the spec journals a page at most once, so this is \
347 consistent with corruption, a savepoint/super-journal artifact, or tampering"
348 .to_string()
349 }
350 AnomalyKind::JournalDbSizeDelta {
351 mx_page,
352 current_pages,
353 } => {
354 let direction = if *mx_page < *current_pages {
355 "the database grew (consistent with INSERTs adding pages)"
356 } else {
357 "the database shrank (consistent with auto-vacuum / \
358 incremental-vacuum or truncation, NOT an ordinary DELETE)"
359 };
360 format!(
361 "the journal records a transaction-start page count \
362 (mxPage = {mx_page}) that differs from the current page \
363 count ({current_pages}) — the last transaction changed the \
364 database size: {direction}"
365 )
366 }
367 AnomalyKind::FreelistCountInconsistent { declared, walked } => match walked {
368 Some(walked) => format!(
369 "the header declares {declared} free page(s) (offset 36) but walking the \
370 freelist trunk chain yields {walked} — consistent with freelist tampering \
371 (an edited count to hide freed pages) or corruption"
372 ),
373 None => format!(
374 "the header declares {declared} free page(s) (offset 36) but the freelist \
375 trunk chain is unwalkable (out-of-range or cyclic trunk pointer) — \
376 consistent with freelist tampering or corruption"
377 ),
378 },
379 AnomalyKind::ZeroedFreelistResidue {
380 zeroed,
381 free_leaves,
382 first_zeroed,
383 } => format!(
384 "{zeroed} of {free_leaves} freed leaf page(s) are entirely zero (first at page \
385 {first_zeroed}) — a freed leaf keeps its former content, so the deleted records \
386 they held were overwritten with zeros; consistent with secure_delete=ON or a \
387 manual wipe"
388 ),
389 }
390 }
391}
392
393/// A `SQLite` forensic anomaly: an observation graded by severity, with a stable
394/// code and note derived from its [`AnomalyKind`] so they cannot drift.
395#[derive(Debug, Clone, PartialEq)]
396pub struct Anomaly {
397 /// Severity, derived from `kind`.
398 pub severity: Severity,
399 /// Stable machine-readable code, derived from `kind`.
400 pub code: &'static str,
401 /// The classified anomaly.
402 pub kind: AnomalyKind,
403 /// Human-readable note, derived from `kind`.
404 pub note: String,
405 /// Heuristic confidence, present for inferential findings (e.g. a carved
406 /// deleted record); `None` for structurally-certain header observations.
407 pub confidence: Option<f32>,
408}
409
410impl Anomaly {
411 /// Build an [`Anomaly`], deriving severity/code/note from `kind`.
412 #[must_use]
413 pub fn new(kind: AnomalyKind) -> Self {
414 Anomaly {
415 severity: kind.severity(),
416 code: kind.code(),
417 note: kind.note(),
418 kind,
419 confidence: None,
420 }
421 }
422
423 /// Attach a heuristic confidence (used for carved deleted records).
424 #[must_use]
425 pub fn with_confidence(mut self, confidence: f32) -> Self {
426 self.confidence = Some(confidence);
427 self
428 }
429}
430
431impl Observation for Anomaly {
432 fn severity(&self) -> Option<Severity> {
433 Some(self.severity)
434 }
435 fn code(&self) -> &'static str {
436 self.code
437 }
438 fn note(&self) -> String {
439 self.note.clone()
440 }
441
442 fn confidence(&self) -> Option<Confidence> {
443 self.confidence.and_then(Confidence::new)
444 }
445
446 fn category(&self) -> forensicnomicon::report::Category {
447 use forensicnomicon::report::Category;
448 match &self.kind {
449 // Deleted-record residue and free (deallocated) pages are recoverability
450 // findings; the code keywords don't trip Category::from_code's Residue
451 // classifier, so classify them explicitly.
452 // Deleted-record residue and free pages are recoverability findings;
453 // so are a recoverable PERSIST journal and a journaled schema page
454 // (the prior schema/rows are recoverable from the journal images).
455 AnomalyKind::DeletedRecordRecovered { .. }
456 | AnomalyKind::DroppedSchemaRecovered { .. }
457 | AnomalyKind::NonEmptyFreelist { .. }
458 | AnomalyKind::JournalRecoverable { .. }
459 | AnomalyKind::JournalSchemaChange { .. }
460 // Zeroed freed leaves are a residue (recoverability) finding: the
461 // residue that WOULD be recoverable was destroyed.
462 | AnomalyKind::ZeroedFreelistResidue { .. } => Category::Residue,
463 // A WAL-only/uncheckpointed state, a header/file page-count mismatch,
464 // and the journal integrity observations (hot journal, checksum /
465 // duplicate corruption, db-size delta) are integrity-of-state.
466 AnomalyKind::WalUncheckpointedState { .. }
467 | AnomalyKind::PageCountMismatch { .. }
468 | AnomalyKind::HotJournal { .. }
469 | AnomalyKind::JournalChecksumMismatch { .. }
470 | AnomalyKind::JournalDuplicatePage { .. }
471 | AnomalyKind::JournalDbSizeDelta { .. }
472 // A header-vs-chain freelist inconsistency is integrity-of-state.
473 | AnomalyKind::FreelistCountInconsistent { .. } => Category::Integrity,
474 other => Category::from_code(other.code()),
475 }
476 }
477
478 fn evidence(&self) -> Vec<Evidence> {
479 match &self.kind {
480 AnomalyKind::DeletedRecordRecovered {
481 page,
482 offset,
483 rowid,
484 } => vec![
485 Evidence {
486 field: "rowid".to_string(),
487 value: rowid.to_string(),
488 location: Some(Location::RecordId(u64::try_from(*rowid).unwrap_or(0))),
489 },
490 Evidence {
491 field: "source_page".to_string(),
492 value: page.to_string(),
493 location: Some(Location::Other {
494 space: "sqlite:page".to_string(),
495 value: u64::from(*page),
496 }),
497 },
498 Evidence {
499 field: "cell_offset".to_string(),
500 value: offset.to_string(),
501 location: Some(Location::ByteOffset(*offset as u64)),
502 },
503 ],
504 AnomalyKind::DroppedSchemaRecovered { object_type, name } => vec![
505 Evidence {
506 field: "object_type".to_string(),
507 value: object_type.clone(),
508 location: None,
509 },
510 Evidence {
511 field: "name".to_string(),
512 value: name.clone(),
513 location: None,
514 },
515 ],
516 AnomalyKind::NonEmptyFreelist { free_pages } => vec![Evidence {
517 field: "free_pages".to_string(),
518 value: free_pages.to_string(),
519 location: None,
520 }],
521 AnomalyKind::WalUncheckpointedState { overlaid_pages } => vec![Evidence {
522 field: "overlaid_pages".to_string(),
523 value: overlaid_pages.to_string(),
524 location: None,
525 }],
526 AnomalyKind::PageCountMismatch {
527 header_pages,
528 file_pages,
529 } => vec![
530 Evidence {
531 field: "header_pages".to_string(),
532 value: header_pages.to_string(),
533 location: Some(Location::Field("in_header_db_size".to_string())),
534 },
535 Evidence {
536 field: "file_pages".to_string(),
537 value: file_pages.to_string(),
538 location: None,
539 },
540 ],
541 AnomalyKind::JournalRecoverable { images } => vec![Evidence {
542 field: "page_images".to_string(),
543 value: images.to_string(),
544 location: None,
545 }],
546 AnomalyKind::JournalChecksumMismatch { pgnos, total } => {
547 let mut ev: Vec<Evidence> = pgnos
548 .iter()
549 .map(|pgno| Evidence {
550 field: "checksum_mismatch_pgno".to_string(),
551 value: pgno.to_string(),
552 location: Some(Location::Other {
553 space: "sqlite:page".to_string(),
554 value: u64::from(*pgno),
555 }),
556 })
557 .collect();
558 ev.push(Evidence {
559 field: "page_records".to_string(),
560 value: total.to_string(),
561 location: None,
562 });
563 ev
564 }
565 AnomalyKind::JournalDbSizeDelta {
566 mx_page,
567 current_pages,
568 } => vec![
569 Evidence {
570 field: "mx_page".to_string(),
571 value: mx_page.to_string(),
572 location: None,
573 },
574 Evidence {
575 field: "current_pages".to_string(),
576 value: current_pages.to_string(),
577 location: None,
578 },
579 ],
580 AnomalyKind::JournalSchemaChange {
581 journal_cookie,
582 db_cookie,
583 } => vec![
584 Evidence {
585 field: "journal_schema_cookie".to_string(),
586 value: journal_cookie.to_string(),
587 location: None,
588 },
589 Evidence {
590 field: "db_schema_cookie".to_string(),
591 value: db_cookie.to_string(),
592 location: None,
593 },
594 ],
595 AnomalyKind::NonZeroReservedSpace { reserved } => vec![Evidence {
596 field: "reserved_bytes_per_page".to_string(),
597 value: reserved.to_string(),
598 // The reserved-space-per-page byte lives at file-header offset 20.
599 location: Some(Location::ByteOffset(20)),
600 }],
601 AnomalyKind::JournalDuplicatePage { pgnos } => pgnos
602 .iter()
603 .map(|pgno| Evidence {
604 field: "duplicate_pgno".to_string(),
605 value: pgno.to_string(),
606 location: Some(Location::Other {
607 space: "sqlite:page".to_string(),
608 value: u64::from(*pgno),
609 }),
610 })
611 .collect(),
612 AnomalyKind::HotJournal {
613 mx_page,
614 journaled_pages,
615 } => vec![
616 Evidence {
617 field: "db_page_count_at_txn_start".to_string(),
618 value: mx_page.to_string(),
619 location: None,
620 },
621 Evidence {
622 field: "journaled_pages".to_string(),
623 value: journaled_pages.to_string(),
624 location: None,
625 },
626 ],
627 AnomalyKind::FreelistCountInconsistent { declared, walked } => vec![
628 Evidence {
629 field: "declared_free_pages".to_string(),
630 value: declared.to_string(),
631 // The free-page count lives at file-header offset 36.
632 location: Some(Location::ByteOffset(36)),
633 },
634 Evidence {
635 field: "walked_free_pages".to_string(),
636 value: walked.map_or_else(|| "malformed".to_string(), |w| w.to_string()),
637 location: None,
638 },
639 ],
640 AnomalyKind::ZeroedFreelistResidue {
641 zeroed,
642 free_leaves,
643 first_zeroed,
644 } => vec![
645 Evidence {
646 field: "zeroed_leaf_pages".to_string(),
647 value: zeroed.to_string(),
648 location: None,
649 },
650 Evidence {
651 field: "free_leaf_pages".to_string(),
652 value: free_leaves.to_string(),
653 location: None,
654 },
655 Evidence {
656 field: "first_zeroed_page".to_string(),
657 value: first_zeroed.to_string(),
658 location: Some(Location::Other {
659 space: "sqlite:page".to_string(),
660 value: u64::from(*first_zeroed),
661 }),
662 },
663 ],
664 }
665 }
666}
667
668/// Which class of free space a deleted record was carved from. Records the
669/// recovery provenance so the examiner can weigh reliability by class.
670#[derive(Debug, Clone, Copy, PartialEq, Eq)]
671#[non_exhaustive]
672pub enum RecoverySource {
673 /// A whole page that was freed onto the freelist (the strongest case: the
674 /// page holds only deallocated content).
675 FreelistPage,
676 /// The in-page free space (unallocated gap / freeblock slack) of a page that
677 /// is still allocated. The record is genuinely deleted but more likely to be
678 /// partially overwritten, so it is graded lower.
679 InPageFreeBlock,
680 /// A page whose table was `DROP`ped — on the freelist with no `sqlite_master`
681 /// schema, so the column count was inferred from the record itself.
682 DroppedTable,
683 /// A **prior version** of a still-live row: an `UPDATE` freed the old version
684 /// of the row into slack while the new version kept the same rowid. The
685 /// recovered values DIFFER from the current live row (e.g. an edited message
686 /// or a changed amount), so it is genuine deleted content — the edit history.
687 PriorVersion,
688 /// A record rebuilt by **freeblock reconstruction**: the freed cell's first
689 /// four bytes (payload-length + rowid varints, `header_len`, and the leading
690 /// serial type) were overwritten by SQLite's freeblock header, so the record
691 /// was rebuilt from its surviving serial-type tail plus a schema template. The
692 /// rowid is destroyed (surfaced as `0`), so this is the weakest in-page class
693 /// — a low-confidence "consistent with a deleted row" lead.
694 FreeblockReconstructed,
695 /// Residue carved from an **uncheckpointed WAL frame's page image** rather
696 /// than the on-disk pages. A `-wal` frame holds a committed page version the
697 /// main file does not yet reflect; deleted cells freed within that version
698 /// survive in the frame's slack and exist NOWHERE on disk. The record carries
699 /// the `(salt1, salt2, frame_index)` log-sequence provenance in
700 /// [`CarvedRecord::wal`].
701 WalFrame,
702 /// Residue carved from a **materialized commit snapshot** — the database state
703 /// replayed up to one COMMIT frame of the `-wal` (base image ∪ committed frames
704 /// to that commit). A row that is a live cell at this commit but deleted by a
705 /// later commit survives ONLY in this snapshot's page images. The record
706 /// carries the commit's `(salt1, salt2, commit_frame_index)` LSN in
707 /// [`CarvedRecord::wal`] — the per-commit temporal coordinate, distinct from a
708 /// raw [`RecoverySource::WalFrame`] residue's frame index.
709 CommitSnapshot,
710 /// A **prior allocated row from the rollback journal** (design §5). The row
711 /// was LIVE in the pre-transaction state the `-journal` preserves and the last
712 /// transaction deleted or modified it — it is NOT a free-space residue, so
713 /// downstream reports must not relabel it as unallocated. Carries the page /
714 /// segment / header-state / checksum provenance in [`RollbackJournalSource`].
715 RollbackJournal(RollbackJournalSource),
716}
717
718/// Whether a rollback-journal header was parsed intact (Tier A) or reconstructed
719/// from a zeroed (PERSIST post-commit) header (Tier B). A reconstructed header
720/// could not verify page checksums (the nonce was zeroed), so its recoveries
721/// rest on cross-record structural consistency — a graded distinction the
722/// examiner weighs.
723#[derive(Debug, Clone, Copy, PartialEq, Eq)]
724pub enum JournalHeaderState {
725 /// Tier A: header magic present; the checksum nonce was known and verified.
726 Valid,
727 /// Tier B: header zeroed (PERSIST post-commit); parameters reconstructed,
728 /// checksums unverifiable.
729 ReconstructedZeroed,
730}
731
732/// Provenance for a record recovered from a rollback journal (design §5):
733/// where in the journal its page image lived and how trustworthy the parse was.
734/// `Copy` (all fields are scalar) so it threads through the existing
735/// attribution → output pipeline exactly like [`WalProvenance`]; the journal's
736/// path is supplied once at the API boundary, not duplicated per row.
737#[derive(Debug, Clone, Copy, PartialEq, Eq)]
738pub struct RollbackJournalSource {
739 /// 0-based journal segment the page image came from.
740 pub segment: usize,
741 /// 1-based database page number the prior image restored.
742 pub pgno: u32,
743 /// Whether the header was intact (Tier A) or reconstructed (Tier B).
744 pub header_state: JournalHeaderState,
745 /// `Some(true/false)` when the page checksum could be verified (Tier A);
746 /// `None` in Tier B (nonce zeroed).
747 pub checksum_valid: Option<bool>,
748}
749
750/// Provenance for a record carved from a `-wal` frame: the
751/// `(salt1, salt2, frame_index)` log-sequence identity of the frame it came from
752/// (the LSN task #55 will formalize).
753#[derive(Debug, Clone, Copy, PartialEq, Eq)]
754pub struct WalProvenance {
755 /// 0-based position of the source frame within the `-wal` file.
756 pub frame_index: usize,
757 /// WAL header salt-1 (checkpoint generation) of the source frame.
758 pub salt1: u32,
759 /// WAL header salt-2 (checkpoint generation) of the source frame.
760 pub salt2: u32,
761}
762
763/// Provenance for a record reassembled across an **overflow-page chain** (task
764/// #73): the pages whose bytes were concatenated to recover the row. An examiner
765/// citing the row as evidence can name exactly where its bytes came from. Present
766/// only on chain-reassembled rows; `None` for every contiguous recovery.
767#[derive(Debug, Clone, PartialEq, Eq)]
768pub struct OverflowProvenance {
769 /// First overflow page of the chain (the page the local-prefix pointer named).
770 pub first_page: u32,
771 /// Ordered overflow pages whose content was assembled into the payload.
772 pub chain: Vec<u32>,
773}
774
775/// A deleted record recovered from unallocated space — the headline capability
776/// rusqlite cannot provide. Carries the decoded row plus provenance so the
777/// examiner can weigh it as a "consistent with a deleted row" observation.
778#[derive(Debug, Clone, PartialEq)]
779pub struct CarvedRecord {
780 /// 1-based page the record was carved from.
781 pub page: u32,
782 /// Byte offset of the cell within that page.
783 pub offset: usize,
784 /// Recovered rowid.
785 pub rowid: i64,
786 /// Decoded column values, in column order.
787 pub values: Vec<Value>,
788 /// Heuristic confidence in `(0.0, 1.0]` that these bytes are a real record.
789 pub confidence: f32,
790 /// Always `false`: a carved record lives in unallocated space, never in the
791 /// live b-tree. Present so callers cannot mistake it for an allocated row.
792 pub allocated: bool,
793 /// Which class of free space this record was recovered from.
794 pub source: RecoverySource,
795 /// WAL log-sequence provenance, present **only** for
796 /// [`RecoverySource::WalFrame`] records (the frame the residue was carved
797 /// from); `None` for every on-disk class.
798 pub wal: Option<WalProvenance>,
799 /// Overflow-chain provenance, present **only** for rows reassembled across a
800 /// freed overflow-page chain (task #73); `None` for every contiguous recovery.
801 pub overflow: Option<OverflowProvenance>,
802}
803
804/// A **Tier-2 partial recovery**: a freed cell whose full row could not be
805/// reconstructed but at least one *distinctive* cell (TEXT ≥ 4 bytes of valid
806/// UTF-8, or REAL) survived at a structural anchor.
807///
808/// Deliberately NOT a [`CarvedRecord`]: it has no rowid (clobbered) and an
809/// incomplete column set, and it does **not** share the full-row
810/// 0-false-positive guarantee — it is a lead-generation surface with an expected
811/// non-zero false-positive rate. The type system keeps it out of the full-row
812/// output so a fragment can never be silently rendered as a recovered row
813/// (secure by design). Returned only by [`carve_with_fragments`] in the opt-in
814/// [`CarveTiers::fragments`] bucket. A fragment is "consistent with a partial
815/// deleted row" — the examiner draws the conclusion.
816#[derive(Debug, Clone, PartialEq)]
817pub struct CarvedFragment {
818 /// 1-based page the fragment was salvaged from.
819 pub page: u32,
820 /// Byte offset of the failed cell's anchor within that page.
821 pub offset: usize,
822 /// `(column_index, value)` for each column that decoded cleanly, ascending by
823 /// index — meaningful against the table's column order.
824 pub surviving: Vec<(usize, Value)>,
825 /// Number of the row's columns that did NOT decode.
826 pub missing: usize,
827 /// Always the flat Tier-2 fragment confidence (0.2) for now — strictly below
828 /// every full-row class.
829 pub confidence: f32,
830 /// Which class of free space the fragment was salvaged from
831 /// ([`RecoverySource::FreeblockReconstructed`] for chain-pass fragments,
832 /// [`RecoverySource::InPageFreeBlock`] for gap-pass fragments).
833 pub source: RecoverySource,
834 /// WAL log-sequence provenance. `None` in v1 (no WAL fragment pass yet).
835 pub wal: Option<WalProvenance>,
836}
837
838/// The two strictly-separated recovery tiers returned by [`carve_with_fragments`].
839///
840/// `full` is **byte-identical** to [`carve_all_deleted_records`] and keeps its
841/// structural 0-false-positive guarantee — the zero-config, high-precision
842/// output. `fragments` is the opt-in Tier-2 lead surface (see [`CarvedFragment`]).
843#[derive(Debug, Clone, PartialEq)]
844pub struct CarveTiers {
845 /// Tier-1 full rows — identical to [`carve_all_deleted_records`].
846 pub full: Vec<CarvedRecord>,
847 /// Tier-2 partial recoveries (opt-in; expected non-zero false-positive rate).
848 pub fragments: Vec<CarvedFragment>,
849}
850
851/// Recover deleted records by carving the database's free (unallocated) pages.
852///
853/// Each free page from [`Database::freelist_pages`] is scanned with
854/// [`Database::carve_cells`] for record-shaped cells of `column_count` columns.
855/// Free pages hold only deallocated content, so the recovered rows are deleted
856/// ones — the carver never re-surfaces a live (allocated) row. Recovered rows
857/// are **confidence-graded observations**, not certainties: a carved record is
858/// "consistent with a deleted row", and the examiner draws the conclusion.
859///
860/// Read-only and panic-free: a malformed freelist simply yields fewer (or no)
861/// carved records rather than an error.
862#[must_use]
863pub fn carve_deleted_records(db: &Database, column_count: usize) -> Vec<CarvedRecord> {
864 let mut out = Vec::new();
865 let Ok(free) = db.freelist_pages() else {
866 return out;
867 };
868 for page in free {
869 let Some(page_bytes) = db.raw_page(page) else {
870 continue; // cov:unreachable: freelist_pages only yields in-range pages
871 };
872 for cell in db.carve_cells(&page_bytes, column_count) {
873 out.push(CarvedRecord {
874 page,
875 offset: cell.offset,
876 rowid: cell.rowid,
877 values: cell.values,
878 confidence: cell.confidence,
879 allocated: false,
880 source: RecoverySource::FreelistPage,
881 wal: None,
882 overflow: None,
883 });
884 }
885 }
886 out
887}
888
889/// Every currently-live row's decoded values, across ALL tables, UNCOLLAPSED.
890///
891/// [`Database::live_rows`] keys live-row identity by a global rowid, so two tables
892/// that share a rowid collapse to a single entry — a live row can then vanish from
893/// the exclusion guard and be re-surfaced as deleted (roadmap §1.1). This walks
894/// `live_table_rows()` (every row, per table) so the live-row guard is complete
895/// even under cross-table rowid collisions. Best-effort and panic-free.
896fn live_value_tuples(db: &Database) -> Vec<Vec<Value>> {
897 db.live_table_rows()
898 .into_iter()
899 .flat_map(|t| t.rows.into_iter().map(|r| r.values))
900 .collect()
901}
902
903/// Recover deleted records across **every** free-space class — the full-coverage
904/// carver. Drives, in order:
905///
906/// 1. **Freelist pages** (whole pages freed onto the freelist), with the column
907/// count inferred per record so it also recovers **dropped-table** pages whose
908/// `sqlite_master` schema is gone (e.g. a `DROP TABLE` left the page on the
909/// freelist with no recorded column count).
910/// 2. **In-page free space** of still-allocated table-leaf pages (the unallocated
911/// gap and inter-cell slack), via [`Database::carve_free_regions`], which
912/// carves only the complement of the live cells — so a live (allocated) row is
913/// **never** re-surfaced (the 0-false-positive guarantee, enforced
914/// structurally).
915///
916/// Records are de-duplicated by `(rowid, values)` keeping the highest-confidence
917/// copy, since a row can survive in more than one place. Every record is graded:
918/// freelist-page recovery highest, dropped-table next, in-page residue lowest.
919///
920/// Read-only and panic-free; a malformed structure yields fewer records, never an
921/// error or panic.
922#[must_use]
923pub fn carve_all_deleted_records(db: &Database) -> Vec<CarvedRecord> {
924 let mut out: Vec<CarvedRecord> = Vec::new();
925
926 // (1) Freelist pages, inferring the column count per record. Inference makes
927 // this recover both normal freed pages AND schema-gone dropped-table pages
928 // (a `DROP TABLE` leaves the page on the freelist with no recorded column
929 // count). When the database has no live user table at all, the freed content
930 // is necessarily from a dropped table, so mark those records accordingly.
931 let dropped_table_db = !db.has_user_table();
932 if let Ok(free) = db.freelist_pages() {
933 for page in free {
934 let Some(page_bytes) = db.raw_page(page) else {
935 continue; // cov:unreachable: freelist_pages yields in-range pages
936 };
937 let source = if dropped_table_db {
938 RecoverySource::DroppedTable
939 } else {
940 RecoverySource::FreelistPage
941 };
942 for cell in db.carve_cells_inferred(&page_bytes) {
943 out.push(CarvedRecord {
944 page,
945 offset: cell.offset,
946 rowid: cell.rowid,
947 values: cell.values,
948 confidence: cell.confidence,
949 allocated: false,
950 source,
951 wal: None,
952 overflow: None,
953 });
954 }
955 }
956 }
957
958 // (2) In-page free space of every still-allocated table-leaf page.
959 let page_count = db.page_count();
960 for page in 1..=page_count {
961 let Some(page_bytes) = db.raw_page(page) else {
962 continue; // cov:unreachable: 1..=page_count is in range
963 };
964 for cell in db.carve_free_regions(&page_bytes, 0) {
965 out.push(CarvedRecord {
966 page,
967 offset: cell.offset,
968 rowid: cell.rowid,
969 values: cell.values,
970 confidence: cell.confidence,
971 allocated: false,
972 source: RecoverySource::InPageFreeBlock,
973 wal: None,
974 overflow: None,
975 });
976 }
977 // (2b) Freeblock reconstruction: the freed cells whose first four bytes
978 // were clobbered by freeblock conversion, rebuilt from their surviving
979 // serial tail plus the page's schema template. These carry an unknown
980 // (destroyed) rowid, so the value-collision pass below — not the
981 // rowid-keyed filter — is what guarantees no live row is re-surfaced.
982 for cell in db.reconstruct_freeblock_records(&page_bytes) {
983 out.push(CarvedRecord {
984 page,
985 offset: cell.offset,
986 rowid: cell.rowid,
987 values: cell.values,
988 confidence: cell.confidence,
989 allocated: false,
990 source: RecoverySource::FreeblockReconstructed,
991 wal: None,
992 overflow: None,
993 });
994 }
995 // (2c) Chain-aware overflow recovery (task #73): a freed cell whose
996 // payload spilled onto an overflow-page chain, reassembled when every
997 // chain page survives as a freelist leaf. Graded below the in-page
998 // full-row tier (NOT part of the structural 0-FP guarantee — Codex
999 // ruling #1); a broken chain (e.g. a trunk-clobbered page) is rejected
1000 // here and degrades to a Tier-2 fragment elsewhere.
1001 for (cell, chain) in db.carve_overflow_records(&page_bytes) {
1002 let first_page = chain.first().copied().unwrap_or(0);
1003 out.push(CarvedRecord {
1004 page,
1005 offset: cell.offset,
1006 rowid: cell.rowid,
1007 values: cell.values,
1008 confidence: cell.confidence,
1009 allocated: false,
1010 source: RecoverySource::InPageFreeBlock,
1011 wal: None,
1012 overflow: Some(OverflowProvenance { first_page, chain }),
1013 });
1014 }
1015 // (2d) Freeblock-clobbered spilled cells (task #73, Codex ruling #5;
1016 // UNPROVEN-BY-CORPUS — synthetic validation only). A spilled cell whose
1017 // prefix was also freeblock-clobbered: P re-derived from the surviving
1018 // serial array, chain resolved through freelist leaves, rowid destroyed.
1019 for (cell, chain) in db.carve_overflow_template_records(&page_bytes) {
1020 let first_page = chain.first().copied().unwrap_or(0);
1021 out.push(CarvedRecord {
1022 page,
1023 offset: cell.offset,
1024 rowid: cell.rowid,
1025 values: cell.values,
1026 confidence: cell.confidence,
1027 allocated: false,
1028 source: RecoverySource::FreeblockReconstructed,
1029 wal: None,
1030 overflow: Some(OverflowProvenance { first_page, chain }),
1031 });
1032 }
1033 }
1034
1035 // (3) WAL-frame carving (additive — runs ONLY when a `-wal` overlay is in
1036 // effect). The on-disk path above reads only the main file's pages, so it
1037 // finds the SAME residue whether or not a WAL was supplied; the genuinely-
1038 // different deleted records live in the uncheckpointed WAL frames.
1039 //
1040 // A `-wal` frame is a FULL page snapshot at one point in the transaction
1041 // history. A row deleted late in that history is still a live cell in an
1042 // EARLIER frame's image; it exists nowhere on disk and in no later frame.
1043 // Three primitives over each committed frame's page image surface it:
1044 //
1045 // * `carve_leaf_cells` — every cell the frame records as allocated. A cell
1046 // that is allocated in a superseded frame but ABSENT from the final
1047 // WAL-applied live view is exactly such a deleted row (recovered as a
1048 // clean, intact record — the strongest WAL case).
1049 // * `carve_free_regions` / `reconstruct_freeblock_records` — residue freed
1050 // WITHIN a frame (e.g. the DELETE-commit frame's own freeblocks), matching
1051 // the on-disk in-page classes.
1052 //
1053 // Every candidate is tagged `WalFrame` with the (salt1, salt2, frame_index)
1054 // LSN provenance, and the shared live-row precision filter below drops any
1055 // whose values match a currently-live row — so a surviving row is never
1056 // re-surfaced (the 0-false-positive guarantee, against the WAL-applied view).
1057 for frame in db.wal_frame_pages() {
1058 let prov = WalProvenance {
1059 frame_index: frame.frame_index,
1060 salt1: frame.salt1,
1061 salt2: frame.salt2,
1062 };
1063 let cells = db
1064 .carve_leaf_cells(&frame.page)
1065 .into_iter()
1066 .chain(db.carve_free_regions(&frame.page, 0))
1067 .chain(db.reconstruct_freeblock_records(&frame.page));
1068 for cell in cells {
1069 out.push(CarvedRecord {
1070 page: frame.page_no,
1071 offset: cell.offset,
1072 rowid: cell.rowid,
1073 values: cell.values,
1074 confidence: cell.confidence,
1075 allocated: false,
1076 source: RecoverySource::WalFrame,
1077 wal: Some(prov),
1078 overflow: None,
1079 });
1080 }
1081 }
1082
1083 // VALUE-AWARE classification for carved records whose rowid is currently
1084 // LIVE. Two very different cases share a live rowid:
1085 //
1086 // * Stale rebalance copy — a b-tree rebalance moved a still-live row to
1087 // another page, leaving a byte-identical copy in the old page's slack.
1088 // SAME rowid, SAME values → NOT deleted → drop (the 0-false-positive
1089 // precision win we must preserve).
1090 // * Prior version — an UPDATE freed the OLD version of the row into slack
1091 // while the new version kept the same rowid. SAME rowid, DIFFERENT values
1092 // → genuinely-deleted content (the edited message / changed amount, often
1093 // THE evidence) → recover, tagged `PriorVersion`.
1094 //
1095 // A rowid-only filter cannot tell these apart and drops both (a false
1096 // negative on prior versions). Comparing decoded values does.
1097 // Structural-noise rejection (precision, not the live-row guarantee): drop
1098 // any carved record that carries no content — a rowid echo followed by an
1099 // all-NULL tail, the inferred over-read of free-space zero bytes. Applied to
1100 // every tier uniformly (no per-source special-case).
1101 out.retain(|rec| !is_structural_noise(&rec.values));
1102
1103 let live = db.live_rows();
1104 // COMPLETE value-level identity of every live row, across ALL tables and
1105 // UNCOLLAPSED. `live_rows()` keys by a global rowid, so two tables sharing a
1106 // rowid collapse to one entry and a live row can vanish from the guard; build
1107 // the guard from every live row (roadmap §1.1) plus the CURRENT `sqlite_master`
1108 // rows (a record carved from a materialized page 1 whose values equal a live
1109 // schema entry is that live row re-surfaced, not deleted residue — value-based,
1110 // so a genuinely-deleted PRIOR schema version is still recovered).
1111 let live_values = live_value_tuples(db);
1112 let live_value_keys: std::collections::HashSet<String> = live_values
1113 .iter()
1114 .chain(db.live_schema_rows().iter())
1115 .map(|v| format!("{v:?}"))
1116 .collect();
1117 out.retain_mut(|rec| {
1118 // Exclusion invariant (EVERY source): a record whose decoded values equal
1119 // ANY currently-live row is that live row re-surfaced — never report it as
1120 // deleted. This guards the cross-table rowid-collision case the rowid-keyed
1121 // branch below cannot, since the global live map collapses shared rowids.
1122 if live_value_keys.contains(&format!("{:?}", rec.values)) {
1123 return false;
1124 }
1125 // Freeblock reconstructions (destroyed rowid) and WAL-frame residue carry no
1126 // rowid usable by the branch below; the value guard above already protected
1127 // them, so keep them with their source tag intact.
1128 if rec.source == RecoverySource::FreeblockReconstructed
1129 || rec.source == RecoverySource::WalFrame
1130 {
1131 return true;
1132 }
1133 // A live rowid with DIFFERING values (exact matches were dropped above) is a
1134 // deleted prior version of that row; an absent rowid is an ordinary deletion.
1135 // Either way the record is kept — only its classification changes.
1136 if live.contains_key(&rec.rowid) {
1137 rec.source = RecoverySource::PriorVersion;
1138 }
1139 true
1140 });
1141
1142 dedup_keep_best(out, &db.page_to_table_map())
1143}
1144
1145/// A carved full record is **structural noise** — not an information-bearing
1146/// deleted row — when every column past the first is NULL. The inferred carver,
1147/// lacking a column hint, can read a run of free-space zero bytes as a long
1148/// serial-type-0 (NULL) array; that surfaces as a rowid echo (the INTEGER-PK
1149/// column) followed by an all-NULL tail, carrying no recoverable content. A
1150/// genuine row — live, dropped-table, or overflow — has a non-NULL value in some
1151/// non-leading column.
1152///
1153/// The column count is deliberately NOT bounded against the live tables: a
1154/// dropped table can legitimately be wider than every surviving one, so a
1155/// width cap would discard genuine unattributed records. Requiring length >= 2
1156/// keeps a real single-column row from being mistaken for noise.
1157fn is_structural_noise(values: &[Value]) -> bool {
1158 values.len() >= 2 && values.iter().skip(1).all(|v| matches!(v, Value::Null))
1159}
1160
1161/// A schema-table (`sqlite_master`) row recovered from free space — a **dropped
1162/// or replaced** table/index/view/trigger whose definition no longer appears in
1163/// the live schema. Recovering it tells the examiner an object *existed* and its
1164/// structure (the `CREATE` statement), which in turn explains residual data on
1165/// the freelist. A finding, not a certainty: it is "consistent with a dropped
1166/// `<name>`" — the examiner draws the conclusion.
1167#[derive(Debug, Clone, PartialEq, Eq)]
1168pub struct RecoveredSchema {
1169 /// `sqlite_master.type` — `table`, `index`, `view`, or `trigger`.
1170 pub object_type: String,
1171 /// `sqlite_master.name` — the object's name.
1172 pub name: String,
1173 /// `sqlite_master.tbl_name` — the table the object belongs to (== `name` for
1174 /// a table).
1175 pub tbl_name: String,
1176 /// `sqlite_master.rootpage` — the (now-freed) b-tree root, `None` when the
1177 /// recovered cell stored it as NULL (views/triggers).
1178 pub rootpage: Option<i64>,
1179 /// `sqlite_master.sql` — the recovered `CREATE` statement.
1180 pub sql: String,
1181}
1182
1183/// Recover **dropped or replaced** schema definitions from free space: carved
1184/// `sqlite_master`-shaped rows whose `(name, sql)` no longer matches a live
1185/// schema entry. A `DROP TABLE`/`DROP INDEX` deletes the object's `sqlite_master`
1186/// row (its bytes survive in page-1 free space under `secure_delete=OFF`); this
1187/// surfaces that residue as a structured [`RecoveredSchema`] rather than a raw
1188/// 5-column carved record.
1189#[must_use]
1190pub fn recover_dropped_schemas(db: &Database) -> Vec<RecoveredSchema> {
1191 use std::collections::HashSet;
1192 // Live `(name, sql)` pairs — an exactly-live definition carved from page-1
1193 // residue is a re-surfaced live row, never a drop.
1194 let live: HashSet<(String, String)> = db.schema_sql().into_iter().collect();
1195 let mut seen: HashSet<(String, String)> = HashSet::new();
1196 let mut out = Vec::new();
1197 for rec in carve_all_deleted_records(db) {
1198 // A `sqlite_master` row lives only on page 1 (plus its overflow); restrict
1199 // there so a 5-column data record elsewhere is never mistaken for schema.
1200 if rec.page != 1 {
1201 continue;
1202 }
1203 let Some(schema) = as_schema_row(&rec.values) else {
1204 continue;
1205 };
1206 let key = (schema.name.clone(), schema.sql.clone());
1207 if live.contains(&key) {
1208 continue;
1209 }
1210 if seen.insert(key) {
1211 out.push(schema);
1212 }
1213 }
1214 out
1215}
1216
1217/// Interpret a carved record as a `sqlite_master` row
1218/// `(type, name, tbl_name, rootpage, sql)`, or `None` if it does not match that
1219/// shape. `type` must be one of the four schema object kinds, the `name` /
1220/// `tbl_name` / `sql` columns TEXT, and `rootpage` an INTEGER (or NULL for
1221/// views/triggers).
1222fn as_schema_row(values: &[Value]) -> Option<RecoveredSchema> {
1223 let [type_v, name_v, tbl_v, root_v, sql_v] = values else {
1224 return None;
1225 };
1226 let object_type = match type_v {
1227 Value::Text(t) if matches!(t.as_str(), "table" | "index" | "view" | "trigger") => t.clone(),
1228 _ => return None,
1229 };
1230 let (Value::Text(name), Value::Text(tbl_name), Value::Text(sql)) = (name_v, tbl_v, sql_v)
1231 else {
1232 return None;
1233 };
1234 let rootpage = match root_v {
1235 Value::Integer(n) => Some(*n),
1236 Value::Null => None,
1237 _ => return None,
1238 };
1239 Some(RecoveredSchema {
1240 object_type,
1241 name: name.clone(),
1242 tbl_name: tbl_name.clone(),
1243 rootpage,
1244 sql: sql.clone(),
1245 })
1246}
1247
1248/// Two-tier deleted-record recovery: Tier-1 full rows **plus** Tier-2 partial
1249/// fragments, in one pass.
1250///
1251/// [`CarveTiers::full`] is byte-identical to [`carve_all_deleted_records`] (the
1252/// high-precision, structurally-0-false-positive output). [`CarveTiers::fragments`]
1253/// is the opt-in lead surface: at every freeblock/gap anchor where full
1254/// reconstruction failed but ≥ 1 distinctive cell (TEXT ≥ 4 bytes of valid UTF-8,
1255/// or REAL) survived, the maximal decodable column prefix is salvaged as a
1256/// [`CarvedFragment`]. Three suppression layers keep the fragment bucket honest:
1257///
1258/// 1. **By construction** — a fragment is emitted only where full reconstruction
1259/// failed, so an anchor yields a full cell or a fragment, never both.
1260/// 2. **Full-row value suppression** — a fragment whose every surviving
1261/// `(column, value)` matches the corresponding column of a Tier-1 record in
1262/// `full` is dropped (the row was already recovered another way).
1263/// 3. **Live-row suppression** — a fragment whose every surviving `(column, value)`
1264/// matches the corresponding columns of a currently-live row is dropped (never
1265/// re-surface a live row, the fragment analog of the rebalance-copy drop).
1266///
1267/// Read-only and panic-free, identically to [`carve_all_deleted_records`].
1268#[must_use]
1269pub fn carve_with_fragments(db: &Database) -> CarveTiers {
1270 let full = carve_all_deleted_records(db);
1271
1272 // Salvage Tier-2 fragments from every on-disk table-leaf page. The core
1273 // walker emits a fragment only where full reconstruction failed (layer 1).
1274 let mut fragments: Vec<CarvedFragment> = Vec::new();
1275 let page_count = db.page_count();
1276 for page in 1..=page_count {
1277 let Some(page_bytes) = db.raw_page(page) else {
1278 continue; // cov:unreachable: 1..=page_count is in range
1279 };
1280 for frag in db.reconstruct_freeblock_fragments(&page_bytes) {
1281 fragments.push(CarvedFragment {
1282 page,
1283 offset: frag.offset,
1284 surviving: frag.surviving,
1285 missing: frag.missing,
1286 confidence: frag.confidence,
1287 source: RecoverySource::FreeblockReconstructed,
1288 wal: None,
1289 });
1290 }
1291 // Broken-chain overflow fragments (task #73, Codex ruling #4): a spilled
1292 // cell whose overflow chain was destroyed (e.g. a trunk-clobbered chain
1293 // page) still has an intact local prefix; salvage its locally-decodable
1294 // columns. The chain-resident columns are lost (untrusted), so this is a
1295 // Tier-2 lead, never a full row.
1296 for frag in db.carve_overflow_fragments(&page_bytes) {
1297 fragments.push(CarvedFragment {
1298 page,
1299 offset: frag.offset,
1300 surviving: frag.surviving,
1301 missing: frag.missing,
1302 confidence: frag.confidence,
1303 source: RecoverySource::InPageFreeBlock,
1304 wal: None,
1305 });
1306 }
1307 }
1308
1309 // Layer 3: live-row suppression. A fragment whose surviving set matches the
1310 // corresponding columns of a live row is a stale partial copy of a live row.
1311 // Complete, uncollapsed live set (roadmap §1.1): see `live_value_tuples`.
1312 let live_values = live_value_tuples(db);
1313 fragments.retain(|frag| {
1314 !live_values
1315 .iter()
1316 .any(|lv| fragment_matches_columns(frag, lv))
1317 });
1318
1319 // Layer 2: full-row value suppression. A fragment already covered by a
1320 // recovered full row in this carve is a duplicate — drop it.
1321 fragments.retain(|frag| {
1322 !full
1323 .iter()
1324 .any(|rec| fragment_matches_columns(frag, &rec.values))
1325 });
1326
1327 // Fragment dedup: one fragment per (page, offset) anchor by construction,
1328 // then collapse value-level duplicates keeping the copy with more surviving
1329 // columns (mirrors dedup_keep_best for the full tier).
1330 fragments = dedup_fragments(fragments);
1331
1332 CarveTiers { full, fragments }
1333}
1334
1335/// Whether a fragment's every surviving `(column_index, value)` pair equals the
1336/// corresponding column of `row` — the projection test shared by the full-row
1337/// (layer 2) and live-row (layer 3) suppression passes. Equality of the **whole**
1338/// surviving set is required: a fragment that coincides with a row on only some
1339/// cells is kept (it may be genuine residue), mirroring the full-row rule's
1340/// whole-values comparison.
1341fn fragment_matches_columns(frag: &CarvedFragment, row: &[Value]) -> bool {
1342 !frag.surviving.is_empty()
1343 && frag
1344 .surviving
1345 .iter()
1346 .all(|(idx, v)| row.get(*idx) == Some(v))
1347}
1348
1349/// De-duplicate fragments: first by `(page, offset)` anchor (one fragment per
1350/// anchor by construction), then collapse value-level duplicates keeping the copy
1351/// with the most surviving columns. Mirrors [`dedup_keep_best`] for the full tier.
1352fn dedup_fragments(mut frags: Vec<CarvedFragment>) -> Vec<CarvedFragment> {
1353 use std::collections::HashSet;
1354 // More surviving columns first, so the kept copy of each identity is richest.
1355 frags.sort_by_key(|f| std::cmp::Reverse(f.surviving.len()));
1356 let mut seen_anchor: HashSet<(u32, usize)> = HashSet::new();
1357 let mut seen_values: HashSet<String> = HashSet::new();
1358 let mut kept = Vec::new();
1359 for frag in frags {
1360 if !seen_anchor.insert((frag.page, frag.offset)) {
1361 continue;
1362 }
1363 let vkey = format!("{:?}", frag.surviving);
1364 if !seen_values.insert(vkey) {
1365 continue;
1366 }
1367 kept.push(frag);
1368 }
1369 kept
1370}
1371
1372/// Carve the deleted residue of **one materialized commit snapshot** of the
1373/// `-wal`, the per-commit temporal building block of the N-snapshot carve.
1374///
1375/// `id` addresses a [`CommitId`] in `timeline`; the snapshot it resolves to is the
1376/// database state replayed up to that COMMIT (base image ∪ every committed frame to
1377/// that commit, capped to `db_size_after_commit`). This runs the same three
1378/// carving primitives the on-disk path uses — [`Database::carve_leaf_cells`]
1379/// (intact cells the snapshot records as allocated, the strongest case),
1380/// [`Database::carve_free_regions`], and [`Database::reconstruct_freeblock_records`]
1381/// — over each of the snapshot's materialized page images, then applies the SAME
1382/// live-row precision filter: a record whose decoded values match a currently-live
1383/// row (the WAL-applied view) is dropped, so a row that survived to the final state
1384/// is **never** re-surfaced as deleted. Surviving records are tagged
1385/// [`RecoverySource::CommitSnapshot`] and carry the commit's
1386/// `(salt1, salt2, commit_frame_index)` LSN in [`CarvedRecord::wal`].
1387///
1388/// An unknown [`CommitId`] (absent from `timeline`) yields an empty vector, never a
1389/// panic. Read-only throughout.
1390#[must_use]
1391pub fn carve_at_commit(db: &Database, timeline: &WalTimeline, id: CommitId) -> Vec<CarvedRecord> {
1392 let Some(snapshot) = timeline.snapshot_at(id) else {
1393 return Vec::new();
1394 };
1395 let lsn = snapshot.lsn();
1396 let prov = WalProvenance {
1397 frame_index: lsn.frame_index,
1398 salt1: lsn.salt1,
1399 salt2: lsn.salt2,
1400 };
1401
1402 let mut out: Vec<CarvedRecord> = Vec::new();
1403 for page_no in snapshot.page_numbers() {
1404 let Some(image) = snapshot.page_version(page_no) else {
1405 continue; // cov:unreachable: page_numbers only yields materialized pages
1406 };
1407 let cells = db
1408 .carve_leaf_cells(&image.bytes)
1409 .into_iter()
1410 .chain(db.carve_free_regions(&image.bytes, 0))
1411 .chain(db.reconstruct_freeblock_records(&image.bytes));
1412 for cell in cells {
1413 out.push(CarvedRecord {
1414 page: page_no,
1415 offset: cell.offset,
1416 rowid: cell.rowid,
1417 values: cell.values,
1418 confidence: cell.confidence,
1419 allocated: false,
1420 source: RecoverySource::CommitSnapshot,
1421 wal: Some(prov),
1422 overflow: None,
1423 });
1424 }
1425 }
1426
1427 // Live-row precision filter (the WAL-applied view): drop any record whose
1428 // decoded values match a currently-live row, so a row that survives to the
1429 // final state is never re-surfaced as "deleted at an earlier commit". The
1430 // live set includes the CURRENT `sqlite_master` rows, because a snapshot's
1431 // materialized page 1 (the schema table) still holds the live schema cell —
1432 // value-based, so a deleted PRIOR schema version is still recovered.
1433 // Complete, uncollapsed live set (roadmap §1.1): see `live_value_tuples`.
1434 let live_value_keys: std::collections::HashSet<String> = live_value_tuples(db)
1435 .iter()
1436 .chain(db.live_schema_rows().iter())
1437 .map(|v| format!("{v:?}"))
1438 .collect();
1439 out.retain(|rec| !live_value_keys.contains(&format!("{:?}", rec.values)));
1440
1441 dedup_keep_best(out, &db.page_to_table_map())
1442}
1443
1444/// De-duplicate carved records by content identity, keeping the
1445/// highest-confidence copy of each (a row can survive in several free regions).
1446///
1447/// `Value` carries an `f64` (`Real`) so it is not `Hash`/`Eq`; the identity key
1448/// is the record's `rowid` plus a stable `Debug` rendering of its values, which
1449/// is sufficient to collapse byte-identical recoveries.
1450fn dedup_keep_best(
1451 mut records: Vec<CarvedRecord>,
1452 page_table: &std::collections::BTreeMap<u32, String>,
1453) -> Vec<CarvedRecord> {
1454 use std::collections::HashSet;
1455 let content = |r: &CarvedRecord| format!("{}:{:?}", r.rowid, r.values);
1456
1457 // Identities (rowid, values) that have at least one record attributed to a
1458 // LIVE table. A copy of such an identity carved from a freed/unattributed page
1459 // is the SAME deleted row surviving in two places; it is folded into the
1460 // attributed copy rather than double-listed (roadmap §1.2).
1461 let attributed: HashSet<String> = records
1462 .iter()
1463 .filter(|r| page_table.contains_key(&r.page))
1464 .map(&content)
1465 .collect();
1466
1467 let mut seen: HashSet<String> = HashSet::new();
1468 let mut kept: Vec<CarvedRecord> = Vec::new();
1469 // Highest confidence first, so the kept copy of each identity is the best one.
1470 records.sort_by(|a, b| {
1471 b.confidence
1472 .partial_cmp(&a.confidence)
1473 .unwrap_or(std::cmp::Ordering::Equal)
1474 });
1475 for rec in records.drain(..) {
1476 let c = content(&rec);
1477 // Table-aware identity (roadmap §1.2): a record from a page a live table
1478 // owns is keyed by (table, rowid, values) — the same row recovered from
1479 // several free regions of ONE table collapses, but two DIFFERENT live
1480 // tables sharing (rowid, values) stay distinct. An unattributed copy of an
1481 // identity that IS attributed elsewhere is the same deleted row → fold it
1482 // in. Otherwise (no live-table copy at all) keep the plain content identity.
1483 let key = match page_table.get(&rec.page) {
1484 Some(table) => format!("T:{table}:{c}"),
1485 None if attributed.contains(&c) => continue,
1486 None => format!("U:{c}"),
1487 };
1488 if seen.insert(key) {
1489 kept.push(rec);
1490 }
1491 }
1492 kept
1493}
1494
1495/// A row recovered from the prior (pre-transaction) snapshot of a rollback
1496/// journal (design §5). In the prior state it was a LIVE allocated row that the
1497/// last transaction DELETED — so it is recovered at full fidelity and is NOT a
1498/// free-space carve (`allocated` was true in the prior state). Carries full
1499/// journal provenance in [`RollbackJournalSource`].
1500#[derive(Debug, Clone, PartialEq)]
1501pub struct PriorRow {
1502 /// The table the row belonged to (prior schema).
1503 pub table: String,
1504 /// The row's rowid (its INTEGER PRIMARY KEY for a rowid table).
1505 pub rowid: i64,
1506 /// The decoded prior column values, in column order.
1507 pub values: Vec<Value>,
1508 /// Journal provenance: page / segment / header-state / checksum status.
1509 pub source: RecoverySource,
1510}
1511
1512/// A row present in BOTH the prior snapshot and the live db with DIFFERENT values
1513/// — the last transaction modified it (design §4). Carries the PRIOR (pre-edit)
1514/// values and the CURRENT values. `replaced_rowid` is set because a delete +
1515/// insert reusing the same rowid is indistinguishable from an in-place UPDATE
1516/// here, so identity may differ — never asserted as "UPDATE".
1517#[derive(Debug, Clone, PartialEq)]
1518pub struct PriorVersionRecord {
1519 /// The table the row belonged to (prior schema).
1520 pub table: String,
1521 /// The rowid present in both states.
1522 pub rowid: i64,
1523 /// The decoded PRIOR (pre-transaction) column values.
1524 pub prior_values: Vec<Value>,
1525 /// The decoded CURRENT (live) column values.
1526 pub current_values: Vec<Value>,
1527 /// Always `true`: the rowid is shared but identity may differ (delete+insert
1528 /// reusing a rowid is indistinguishable from an UPDATE), so the prior value is
1529 /// edit history, not an asserted in-place update.
1530 pub replaced_rowid: bool,
1531 /// Journal provenance for the prior image.
1532 pub source: RecoverySource,
1533}
1534
1535/// Structured counts of what the rollback-journal recovery found — the NIST
1536/// SFT-03 "number of deleted/modified records" report (design §5/§6.11).
1537#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1538pub struct JournalCounts {
1539 /// Rows deleted by the last transaction (prior \ current by rowid).
1540 pub deleted: usize,
1541 /// Rows modified by the last transaction (present in both, values differ).
1542 pub modified: usize,
1543}
1544
1545/// The result of recovering deletions + modifications from a rollback journal
1546/// (design §5). Deleted rows are full-fidelity prior allocated rows (NOT
1547/// free-space carves); modified rows carry the prior and current values.
1548#[derive(Debug, Clone, PartialEq)]
1549pub struct JournalRecovery {
1550 /// Rows present in the prior snapshot but absent now — deleted by the last
1551 /// transaction, recovered at full fidelity.
1552 pub deleted: Vec<PriorRow>,
1553 /// Rows present in both, values differ — prior (pre-edit) + current values.
1554 pub modified: Vec<PriorVersionRecord>,
1555 /// Structured counts (the NIST source-file report).
1556 pub counts: JournalCounts,
1557}
1558
1559/// Recover the last transaction's **deletions and modifications** from a rollback
1560/// `-journal` by diffing the pre-transaction snapshot against the live database
1561/// (design §4) — the temporal inverse of WAL recovery.
1562///
1563/// For each prior rowid table, the prior rows (read through `PriorSnapshot`)
1564/// are diffed against the current rows by rowid:
1565/// - **deleted** = rowid in prior but not current → a full-fidelity [`PriorRow`];
1566/// - **modified** = rowid in both with different values → a [`PriorVersionRecord`]
1567/// flagged `replaced_rowid` (identity may differ; never asserted as UPDATE);
1568/// - **inserted** (rowid only in current) is a timeline fact, not a recovery target.
1569///
1570/// `WITHOUT ROWID` tables are excluded (no integer rowid key; design §4 limit).
1571/// Read-only and panic-free: a malformed/truncated journal, or a WAL-applied db
1572/// (the modes are exclusive), yields an empty recovery rather than an error or
1573/// panic — the journal source is bound to THIS database via
1574/// [`Database::rollback_prior`].
1575#[must_use]
1576pub fn carve_rollback_journal(db: &Database, journal: &[u8]) -> JournalRecovery {
1577 let mut deleted = Vec::new();
1578 let mut modified = Vec::new();
1579
1580 let Ok(prior) = db.rollback_prior(journal) else {
1581 return JournalRecovery {
1582 deleted,
1583 modified,
1584 counts: JournalCounts::default(),
1585 };
1586 };
1587 // Header state + checksum status are uniform across a single journal; carry
1588 // them on every recovered row's provenance.
1589 let Some(header_state) = prior_header_state(journal, db) else {
1590 // cov:unreachable: rollback_prior above already parsed the same journal,
1591 // so a re-parse here cannot fail; the arm degrades to an empty recovery.
1592 return JournalRecovery {
1593 deleted,
1594 modified,
1595 counts: JournalCounts::default(),
1596 };
1597 };
1598 // Per-row checksum status is not threaded through PriorSnapshot in v1, so the
1599 // verifiable fact recorded here is the journal-level one: Tier A could verify
1600 // checksums (nonce known), Tier B could not (nonce zeroed). A finer per-image
1601 // status is a later enhancement; `header_state` carries the Tier either way.
1602 let checksum_valid = match header_state {
1603 JournalHeaderState::Valid => Some(true),
1604 JournalHeaderState::ReconstructedZeroed => None,
1605 };
1606
1607 // Index the current (live) rows per table by rowid, so the diff is O(rows).
1608 let live = db.live_table_rows();
1609
1610 for ptable in prior.tables() {
1611 if ptable.without_rowid {
1612 continue; // design §4 limit: WITHOUT ROWID excluded from the rowid diff.
1613 }
1614 let col_count = ptable.columns.len();
1615 let Ok(prior_rows) = prior.read_table_with_pages(ptable.rootpage, col_count) else {
1616 continue;
1617 };
1618 // Current rows of the same-named table (live schema), by rowid.
1619 let current: std::collections::BTreeMap<i64, Vec<Value>> = live
1620 .iter()
1621 .find(|d| d.name == ptable.name)
1622 .map(|d| d.rows.iter().map(|r| (r.rowid, r.values.clone())).collect())
1623 .unwrap_or_default();
1624
1625 for (rowid, values, leaf_page) in prior_rows {
1626 let source = RecoverySource::RollbackJournal(RollbackJournalSource {
1627 segment: 0,
1628 pgno: leaf_page,
1629 header_state,
1630 checksum_valid,
1631 });
1632 match current.get(&rowid) {
1633 None => deleted.push(PriorRow {
1634 table: ptable.name.clone(),
1635 rowid,
1636 values,
1637 source,
1638 }),
1639 Some(cur) if *cur != values => modified.push(PriorVersionRecord {
1640 table: ptable.name.clone(),
1641 rowid,
1642 prior_values: values,
1643 current_values: cur.clone(),
1644 replaced_rowid: true,
1645 source,
1646 }),
1647 Some(_) => {} // unchanged: the page was journaled for another row.
1648 }
1649 }
1650 }
1651
1652 let counts = JournalCounts {
1653 deleted: deleted.len(),
1654 modified: modified.len(),
1655 };
1656 JournalRecovery {
1657 deleted,
1658 modified,
1659 counts,
1660 }
1661}
1662
1663/// Determine whether the journal header was intact (Tier A) or zeroed (Tier B),
1664/// by re-parsing it against the db's authoritative page size.
1665fn prior_header_state(journal: &[u8], db: &Database) -> Option<JournalHeaderState> {
1666 let parsed = RollbackJournal::parse(journal, db.header().page_size).ok()?;
1667 Some(match parsed.header() {
1668 JournalHeader::Valid { .. } => JournalHeaderState::Valid,
1669 JournalHeader::ReconstructedZeroed { .. } => JournalHeaderState::ReconstructedZeroed,
1670 })
1671}
1672
1673/// Audit an opened [`Database`] for forensically-notable anomalies.
1674///
1675/// Covers the header reserved-space field, a non-empty freelist (prior
1676/// deletions), an active WAL overlay (uncheckpointed state), and a header/file
1677/// page-count mismatch. Deleted-record recovery is offered separately via
1678/// [`carve_deleted_records`] / [`audit_carved_findings`] because it requires the
1679/// table's column count.
1680#[must_use]
1681pub fn audit(db: &Database) -> Vec<Anomaly> {
1682 let mut out = Vec::new();
1683
1684 let reserved = db.header().reserved;
1685 if reserved != 0 {
1686 out.push(Anomaly::new(AnomalyKind::NonZeroReservedSpace { reserved }));
1687 }
1688
1689 let free_pages = db.freelist_count();
1690 if free_pages != 0 {
1691 out.push(Anomaly::new(AnomalyKind::NonEmptyFreelist { free_pages }));
1692 }
1693
1694 // Freelist structural consistency + residue-destruction fingerprint (§2.1).
1695 // The header count at offset 36 is a *claim*; walking the trunk chain verifies
1696 // it. Freed *leaf* pages are content-preserving, so an all-zero freed leaf is
1697 // the fingerprint of destroyed residue (secure_delete / a wipe).
1698 let declared = free_pages;
1699 match db.freelist_pages_split() {
1700 Ok((leaves, trunks)) => {
1701 let walked = u32::try_from(leaves.len() + trunks.len()).unwrap_or(u32::MAX);
1702 if declared != walked {
1703 out.push(Anomaly::new(AnomalyKind::FreelistCountInconsistent {
1704 declared,
1705 walked: Some(walked),
1706 }));
1707 }
1708 let zeroed: Vec<u32> = leaves
1709 .iter()
1710 .copied()
1711 .filter(|&p| db.raw_page(p).is_some_and(|b| b.iter().all(|&x| x == 0)))
1712 .collect();
1713 if let Some(&first_zeroed) = zeroed.first() {
1714 out.push(Anomaly::new(AnomalyKind::ZeroedFreelistResidue {
1715 zeroed: u32::try_from(zeroed.len()).unwrap_or(u32::MAX),
1716 free_leaves: u32::try_from(leaves.len()).unwrap_or(u32::MAX),
1717 first_zeroed,
1718 }));
1719 }
1720 }
1721 // An unwalkable chain (out-of-range / cyclic trunk pointer) is itself the
1722 // inconsistency: surface it loud rather than silently ignore the freelist.
1723 Err(_) => out.push(Anomaly::new(AnomalyKind::FreelistCountInconsistent {
1724 declared,
1725 walked: None,
1726 })),
1727 }
1728
1729 if db.wal_applied() {
1730 // The overlay supersedes at least one page; report it as uncheckpointed
1731 // state. (The exact page count is not separately exposed; report ≥1.)
1732 out.push(Anomaly::new(AnomalyKind::WalUncheckpointedState {
1733 overlaid_pages: 1,
1734 }));
1735 }
1736
1737 let header_pages = db.header_page_count();
1738 let file_pages = db.file_page_count();
1739 if header_pages != 0 && header_pages != file_pages {
1740 out.push(Anomaly::new(AnomalyKind::PageCountMismatch {
1741 header_pages,
1742 file_pages,
1743 }));
1744 }
1745
1746 // Dropped/replaced schema objects whose sqlite_master row survives in page-1
1747 // free space (a DROP under secure_delete=OFF). Surfaced as findings so a
1748 // dropped table's existence + name is reported, not just buried in the carve.
1749 for schema in recover_dropped_schemas(db) {
1750 out.push(Anomaly::new(AnomalyKind::DroppedSchemaRecovered {
1751 object_type: schema.object_type,
1752 name: schema.name,
1753 }));
1754 }
1755
1756 out
1757}
1758
1759/// Audit an opened [`Database`] and convert each anomaly to the canonical
1760/// [`Finding`] under the supplied [`Source`], ready to merge into a `Report`.
1761#[must_use]
1762pub fn audit_findings(db: &Database, source: &Source) -> Vec<Finding> {
1763 audit(db)
1764 .into_iter()
1765 .map(|a| a.to_finding(source.clone()))
1766 .collect()
1767}
1768
1769/// The database schema cookie: file-header bytes `40..44` as a big-endian `u32`
1770/// (file-format §1.3). Returns `None` when the page is too short to hold the
1771/// field — bounded and panic-free, so a truncated page-1 image degrades to "no
1772/// cookie" rather than indexing out of range.
1773fn schema_cookie(page: &[u8]) -> Option<u32> {
1774 let bytes = page.get(40..44)?;
1775 Some(u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]))
1776}
1777
1778/// Audit a rollback `-journal` (raw bytes) bound to `db` for the design-§6
1779/// observations, additive to [`audit`] (which covers main-db anomalies only).
1780///
1781/// The journal is parsed with the database's authoritative page size and graded
1782/// into observations, each derivable from the parser API and each "consistent
1783/// with …" (the examiner draws the conclusion):
1784/// - a **hot journal** (valid header magic) — an interrupted/in-progress write;
1785/// - a **recoverable** PERSIST journal (zeroed header, page images intact);
1786/// - **checksum mismatch(es)** (Tier A) — corruption / torn page / modification;
1787/// - a **schema-cookie advance** (journal page-1 image vs live db, offset 40) — a
1788/// DDL change, prior schema recoverable;
1789/// - a **duplicate page record** — corruption / savepoint / tampering;
1790/// - a **db-size delta** (Tier A `mxPage` vs current) — growth or shrink.
1791///
1792/// Read-only and panic-free: an unparsable/garbage journal simply yields no
1793/// observations rather than an error or panic.
1794#[must_use]
1795pub fn audit_journal(db: &Database, journal: &[u8]) -> Vec<Anomaly> {
1796 let mut out = Vec::new();
1797
1798 let Ok(parsed) = RollbackJournal::parse(journal, db.header().page_size) else {
1799 // A page size outside [512, 65536] / non-power-of-two is the only parse
1800 // error; a database that opened cannot have one, so this degrades to no
1801 // observations rather than masking a real failure.
1802 return out;
1803 };
1804
1805 let images = parsed.page_images();
1806
1807 match parsed.header() {
1808 JournalHeader::Valid { mx_page, .. } => {
1809 // A valid-magic header is a hot journal (interrupted/in-progress).
1810 out.push(Anomaly::new(AnomalyKind::HotJournal {
1811 mx_page: *mx_page,
1812 journaled_pages: u32::try_from(images.len()).unwrap_or(u32::MAX),
1813 }));
1814
1815 // mxPage (db size at txn start) vs the current page count. 0 is the
1816 // "size unknown" sentinel, so only a non-zero, differing value is a
1817 // reportable size delta.
1818 let current_pages = db.page_count();
1819 if *mx_page != 0 && *mx_page != current_pages {
1820 out.push(Anomaly::new(AnomalyKind::JournalDbSizeDelta {
1821 mx_page: *mx_page,
1822 current_pages,
1823 }));
1824 }
1825 }
1826 JournalHeader::ReconstructedZeroed { .. } => {
1827 // A zeroed (PERSIST post-commit) header with >=1 page image carries
1828 // recoverable pre-images.
1829 if !images.is_empty() {
1830 out.push(Anomaly::new(AnomalyKind::JournalRecoverable {
1831 images: images.len(),
1832 }));
1833 }
1834 }
1835 }
1836
1837 // Checksum mismatches (Tier A only — Tier B's checksum_valid is None).
1838 let bad: Vec<u32> = images
1839 .iter()
1840 .filter(|i| i.checksum_valid == Some(false))
1841 .map(|i| i.pgno)
1842 .collect();
1843 if !bad.is_empty() {
1844 out.push(Anomaly::new(AnomalyKind::JournalChecksumMismatch {
1845 pgnos: bad,
1846 total: images.len(),
1847 }));
1848 }
1849
1850 // A DDL change is signalled by the schema cookie (file-header offset 40, BE)
1851 // advancing — NOT merely by page 1 being journaled. Page 1 carries the
1852 // change-counter / freelist-count / db-size header fields that update on
1853 // nearly every write, so it is present in almost every rollback journal; only
1854 // a cookie that differs between the journal's prior page-1 image and the live
1855 // database indicates CREATE/DROP/ALTER. Read both cookies defensively: if the
1856 // page-1 image is absent or the live page 1 is unreadable, do not fire.
1857 //
1858 // Caveat: for an UNCOMMITTED hot journal the live main-db file may not yet
1859 // carry the new cookie, so a DDL in a hot journal can be undetectable here.
1860 // That is acceptable — the detector never false-positives; it just cannot
1861 // always observe the committed-cookie case mid-flight.
1862 if let (Some(journal_cookie), Some(db_cookie)) = (
1863 images
1864 .iter()
1865 .find(|i| i.pgno == 1)
1866 .and_then(|i| schema_cookie(&i.bytes)),
1867 db.raw_page(1).as_deref().and_then(schema_cookie),
1868 ) {
1869 if journal_cookie != db_cookie {
1870 out.push(Anomaly::new(AnomalyKind::JournalSchemaChange {
1871 journal_cookie,
1872 db_cookie,
1873 }));
1874 }
1875 }
1876
1877 // A page journaled more than once (the spec forbids it).
1878 if parsed.has_duplicate_pgno() {
1879 out.push(Anomaly::new(AnomalyKind::JournalDuplicatePage {
1880 pgnos: parsed.duplicate_pgnos().to_vec(),
1881 }));
1882 }
1883
1884 out
1885}
1886
1887/// Audit a rollback `-journal` and convert each design-§6 observation to the
1888/// canonical [`Finding`] under `source`, ready to merge into a `Report`. The
1889/// additive counterpart to [`audit_findings`] for the journal sidecar.
1890#[must_use]
1891pub fn audit_journal_findings(db: &Database, journal: &[u8], source: &Source) -> Vec<Finding> {
1892 audit_journal(db, journal)
1893 .into_iter()
1894 .map(|a| a.to_finding(source.clone()))
1895 .collect()
1896}
1897
1898/// Carve deleted records and convert each to a canonical [`Finding`] under
1899/// `source`. The per-record confidence is threaded into the finding's context so
1900/// downstream consumers can filter low-confidence recoveries.
1901#[must_use]
1902pub fn audit_carved_findings(db: &Database, column_count: usize, source: &Source) -> Vec<Finding> {
1903 carve_deleted_records(db, column_count)
1904 .into_iter()
1905 .map(|rec| {
1906 Anomaly::new(AnomalyKind::DeletedRecordRecovered {
1907 page: rec.page,
1908 offset: rec.offset,
1909 rowid: rec.rowid,
1910 })
1911 .with_confidence(rec.confidence)
1912 .to_finding(source.clone())
1913 })
1914 .collect()
1915}
1916
1917// ---------------------------------------------------------------------------
1918// Table attribution: reattach a carved deleted row to its source table in three
1919// honest tiers (CERTAIN / INFERRED / UNATTRIBUTED).
1920// ---------------------------------------------------------------------------
1921
1922/// How confidently a carved deleted row has been reattached to a live table.
1923///
1924/// The three tiers encode distinct epistemic strengths (observed fact / forensic
1925/// inference / unattributed), and the renderer keeps them honest: a Tier-2 guess
1926/// is "consistent with" a table, never asserted as the table.
1927#[derive(Debug, Clone, PartialEq, Eq)]
1928pub enum Attribution {
1929 /// **Tier 1 — CERTAIN.** The record was carved from a page still part of a
1930 /// live table's b-tree, so the owning table is known for sure. Carries that
1931 /// table's name.
1932 Known(String),
1933 /// **Tier 2 — INFERRED.** The whole page was freed (linkage cut); the
1934 /// record's *shape* (column count + per-column affinity) matched one or more
1935 /// surviving tables. A forensic inference, never asserted as fact.
1936 Inferred {
1937 /// The candidate table whose shape the record matches.
1938 guess: String,
1939 /// `true` when more than one surviving table matched the shape, so the
1940 /// guess is one of several equally-consistent candidates.
1941 ambiguous: bool,
1942 },
1943 /// **Tier 3 — UNATTRIBUTED.** Dropped-table residue, or a shape matching no
1944 /// surviving table.
1945 Unattributed,
1946}
1947
1948/// Whether a carved value is **storage-compatible** with a column of the given
1949/// declared affinity — the per-cell test the shape matcher applies.
1950///
1951/// Rules (permissive enough to survive `SQLite`'s numeric coercions, strict
1952/// enough to discriminate tables of equal arity but different type layout):
1953/// a NULL is compatible with anything; INTEGER/REAL are mutually compatible and
1954/// also satisfy NUMERIC; TEXT requires a text-leaning affinity (TEXT) or the
1955/// catch-all BLOB/NUMERIC; a BLOB requires BLOB. The BLOB affinity (or a column
1956/// with no declared type) accepts any value, matching SQLite's "no datatype"
1957/// semantics.
1958#[must_use]
1959pub fn value_fits_affinity(value: &Value, affinity: sqlite_core::attribution::Affinity) -> bool {
1960 use sqlite_core::attribution::Affinity;
1961 // BLOB affinity (incl. no-declared-type columns) is SQLite's catch-all: a
1962 // value of any storage class can live there.
1963 if affinity == Affinity::Blob {
1964 return true;
1965 }
1966 match value {
1967 // A NULL fits any column.
1968 Value::Null => true,
1969 Value::Integer(_) | Value::Real(_) => matches!(
1970 affinity,
1971 Affinity::Integer | Affinity::Real | Affinity::Numeric
1972 ),
1973 // NUMERIC accepts text too (SQLite stores a non-numeric string as text in
1974 // a NUMERIC column), so a TEXT value is consistent with TEXT or NUMERIC.
1975 Value::Text(_) => matches!(affinity, Affinity::Text | Affinity::Numeric),
1976 // A BLOB only sits in a BLOB column (handled above) — never a typed one.
1977 Value::Blob(_) => false,
1978 }
1979}
1980
1981/// Whether a carved record's values match a live table's shape: equal column
1982/// count AND every value storage-compatible with the corresponding column
1983/// affinity ([`value_fits_affinity`]).
1984#[must_use]
1985pub fn shape_matches(values: &[Value], table: &sqlite_core::attribution::LiveTable) -> bool {
1986 if values.len() != table.affinities.len() {
1987 return false;
1988 }
1989 values
1990 .iter()
1991 .zip(&table.affinities)
1992 .all(|(v, &aff)| value_fits_affinity(v, aff))
1993}
1994
1995/// The names of every live table whose shape a record's values match, in the
1996/// order the tables were listed. Zero matches → unattributable shape; one →
1997/// a clean guess; more than one → ambiguous.
1998#[must_use]
1999pub fn matching_tables(
2000 values: &[Value],
2001 tables: &[sqlite_core::attribution::LiveTable],
2002) -> Vec<String> {
2003 tables
2004 .iter()
2005 .filter(|t| shape_matches(values, t))
2006 .map(|t| t.name.clone())
2007 .collect()
2008}
2009
2010/// Attribute one carved record to a tier given the live tables and the
2011/// page→table map. Pure (no DB access) so it is directly unit-testable.
2012///
2013/// - Tier 1 (CERTAIN): `source` is an in-page class ([`RecoverySource::InPageFreeBlock`]
2014/// or [`RecoverySource::FreeblockReconstructed`]) AND the record's page is in
2015/// `page_table_map` → [`Attribution::Known`].
2016/// - Tier 2 (INFERRED): `source` is [`RecoverySource::FreelistPage`] → match the
2017/// record's shape against `tables`; exactly one match → a guess, more than one
2018/// → ambiguous, none → [`Attribution::Unattributed`].
2019/// - Tier 3 (UNATTRIBUTED): [`RecoverySource::DroppedTable`], any other source,
2020/// or a shape matching no surviving table.
2021#[must_use]
2022pub fn attribute_record(
2023 rec: &CarvedRecord,
2024 tables: &[sqlite_core::attribution::LiveTable],
2025 page_table_map: &std::collections::BTreeMap<u32, String>,
2026) -> Attribution {
2027 // Tier 1 — CERTAIN: an in-page record on a page still owned by a live
2028 // table's b-tree. The page→table map is the hard linkage.
2029 if matches!(
2030 rec.source,
2031 RecoverySource::InPageFreeBlock | RecoverySource::FreeblockReconstructed
2032 ) {
2033 if let Some(name) = page_table_map.get(&rec.page) {
2034 return Attribution::Known(name.clone());
2035 }
2036 // In-page residue on a page that is NOT a live-table page (e.g. the
2037 // owning table itself was freed): no certain linkage, and in-page is not
2038 // the freelist class, so it is unattributed rather than inferred.
2039 return Attribution::Unattributed;
2040 }
2041
2042 // Tier 2 — INFERRED: a whole-page freelist record; the linkage is cut, so
2043 // attribute by shape against the surviving tables.
2044 if rec.source == RecoverySource::FreelistPage {
2045 let mut matches = matching_tables(&rec.values, tables);
2046 return match matches.len() {
2047 0 => Attribution::Unattributed,
2048 1 => Attribution::Inferred {
2049 guess: matches.remove(0),
2050 ambiguous: false,
2051 },
2052 _ => Attribution::Inferred {
2053 guess: matches.remove(0),
2054 ambiguous: true,
2055 },
2056 };
2057 }
2058
2059 // Tier 3 — UNATTRIBUTED: dropped-table residue or any other source.
2060 Attribution::Unattributed
2061}
2062
2063/// Attribute every carved record, reading the live tables and page→table map
2064/// from `db` once. The returned vector is parallel to `records`.
2065#[must_use]
2066pub fn attribute_records(db: &Database, records: &[CarvedRecord]) -> Vec<Attribution> {
2067 let tables = db.live_tables();
2068 let page_table_map = db.page_to_table_map();
2069 records
2070 .iter()
2071 .map(|rec| attribute_record(rec, &tables, &page_table_map))
2072 .collect()
2073}
2074
2075/// A **non-overclaiming diagnostic HINT** about a recovered record's relationship
2076/// to the *instance* of the table it was attributed to — ORTHOGONAL to
2077/// [`Attribution`] (it never changes the tier, the routing, or the table claim).
2078///
2079/// Detector A (`docs/design/drop-recreate-attribution.md`): when a record
2080/// attributed `Known(T)` has a rowid exceeding `T`'s `AUTOINCREMENT`
2081/// `sqlite_sequence` high-water mark, that is *consistent with* residue from a
2082/// prior incarnation of `T` (a drop-recreate reset `sqlite_sequence`) — but it is
2083/// **not proof**: the Codex review confirmed `rowid > seq` is equally reachable by
2084/// an `UPDATE` of the rowid, a manual `sqlite_sequence` edit, or a current-instance
2085/// deletion. So the flag is surfaced as a hint that names its evidence, and the
2086/// examiner draws the conclusion. The `note` never asserts "predecessor".
2087///
2088/// `#[non_exhaustive]` so the documented follow-up `SidecarSchemaChanged` variant
2089/// (Detector B — a sidecar `-wal`/`-journal` DDL boundary) can be added without a
2090/// breaking change.
2091#[derive(Debug, Clone, PartialEq, Eq)]
2092#[non_exhaustive]
2093pub enum TableInstanceRisk {
2094 /// No instance-provenance hint applies to this record.
2095 None,
2096 /// The record was attributed `Known(table)`, `table` is `AUTOINCREMENT`, has a
2097 /// `sqlite_sequence` entry `seq`, and the record's `rowid` exceeds it. The
2098 /// current instance never assigned a rowid above `seq` via `INSERT`, so this is
2099 /// *consistent with* prior-incarnation residue — and equally with an `UPDATE`
2100 /// to the rowid, a `sqlite_sequence` edit, or a current-instance deletion.
2101 RowidExceedsAutoincHighwater {
2102 /// The live table the record was attributed to.
2103 table: String,
2104 /// The record's recovered rowid (the value exceeding the high-water mark).
2105 rowid: i64,
2106 /// The table's `AUTOINCREMENT` high-water mark (`sqlite_sequence.seq`).
2107 seq: i64,
2108 },
2109 /// Detector B (`docs/design/drop-recreate-attribution.md`): the record was
2110 /// attributed `Known(table)`, a `-wal`/`-journal` sidecar is in play, and the
2111 /// sidecar's PRIOR `sqlite_master` for `table` is **absent** OR carries a
2112 /// **different CREATE SQL text** than the current schema — an UNAMBIGUOUS
2113 /// table-level schema change within the captured window. It is *consistent
2114 /// with* a `CREATE`/`ALTER` or a drop+recreate of `table`, so residue
2115 /// attributed to `table` may predate the current schema. It is **table-level**,
2116 /// NOT row-level provenance, and deliberately does NOT fire on a rootpage move
2117 /// with identical CREATE SQL (a `VACUUM`), a schema-cookie advance alone, or a
2118 /// same-schema drop+recreate (indistinguishable from a benign page move).
2119 SidecarSchemaChanged {
2120 /// The live table whose sidecar prior schema differs from the current.
2121 table: String,
2122 },
2123}
2124
2125impl TableInstanceRisk {
2126 /// A human, evidence-bearing note for the examiner — or `""` for
2127 /// [`TableInstanceRisk::None`]. The wording deliberately states what the
2128 /// observation is *consistent with* and lists the equally-consistent benign
2129 /// explanations; it never asserts the row is a predecessor.
2130 #[must_use]
2131 pub fn note(&self) -> String {
2132 match self {
2133 Self::None => String::new(),
2134 Self::RowidExceedsAutoincHighwater { table, rowid, seq } => format!(
2135 "rowid {rowid} exceeds table {table}'s AUTOINCREMENT high-water mark \
2136 (sqlite_sequence={seq}) — consistent with residue from a prior incarnation of \
2137 this table, but also explainable by an UPDATE to the rowid, a sqlite_sequence \
2138 edit, or a current-instance deletion; the examiner should cross-check the RowID \
2139 and any WAL/journal schema history."
2140 ),
2141 Self::SidecarSchemaChanged { table } => format!(
2142 "the schema for table {table} differs between the sidecar's (-wal/-journal) prior \
2143 state and the current database (present-with-different-SQL or absent in the prior) \
2144 — consistent with a CREATE/ALTER or drop+recreate within the captured window; \
2145 residue attributed to {table} may predate the current schema, so reconcile against \
2146 the prior schema. (A same-schema drop+recreate is indistinguishable from a benign \
2147 page move and does NOT raise this.)"
2148 ),
2149 }
2150 }
2151
2152 /// A short, stable provenance token for column/field output (`null`-like for
2153 /// [`TableInstanceRisk::None`] → empty string), carrying the evidence inline:
2154 /// `rowid_exceeds_autoinc_highwater(r=…,seq=…)`.
2155 #[must_use]
2156 pub fn token(&self) -> String {
2157 match self {
2158 Self::None => String::new(),
2159 Self::RowidExceedsAutoincHighwater { rowid, seq, .. } => {
2160 format!("rowid_exceeds_autoinc_highwater(r={rowid},seq={seq})")
2161 }
2162 Self::SidecarSchemaChanged { table } => {
2163 format!("sidecar_schema_changed({table})")
2164 }
2165 }
2166 }
2167}
2168
2169/// Per-record [`TableInstanceRisk`], parallel to `records` and their
2170/// `attributions` — the Detector-A diagnostic pass.
2171///
2172/// A record trips [`TableInstanceRisk::RowidExceedsAutoincHighwater`] **only** when
2173/// ALL hold: it is `Attribution::Known(table)`; `table` is an `AUTOINCREMENT`
2174/// rowid table ([`sqlite_core::is_autoincrement`] on its `CREATE TABLE`); `table`
2175/// has a `sqlite_sequence` entry `seq` ([`Database::sqlite_sequence`]); and
2176/// `rec.rowid > seq`. Every other record is [`TableInstanceRisk::None`]. This is
2177/// orthogonal to attribution — it reads the same inputs but never alters the
2178/// attribution, tier, or routing.
2179#[must_use]
2180pub fn table_instance_risks(
2181 db: &Database,
2182 records: &[CarvedRecord],
2183 attributions: &[Attribution],
2184) -> Vec<TableInstanceRisk> {
2185 // The set of AUTOINCREMENT table names, from the live schema's CREATE sql.
2186 let autoinc: std::collections::BTreeSet<String> = db
2187 .live_tables()
2188 .into_iter()
2189 .filter(|t| sqlite_core::is_autoincrement(&t.create_sql))
2190 .map(|t| t.name)
2191 .collect();
2192 let sequences = db.sqlite_sequence();
2193
2194 records
2195 .iter()
2196 .zip(attributions)
2197 .map(|(rec, attr)| {
2198 let Attribution::Known(table) = attr else {
2199 return TableInstanceRisk::None;
2200 };
2201 if !autoinc.contains(table) {
2202 return TableInstanceRisk::None;
2203 }
2204 match sequences.get(table) {
2205 Some(&seq) if rec.rowid > seq => TableInstanceRisk::RowidExceedsAutoincHighwater {
2206 table: table.clone(),
2207 rowid: rec.rowid,
2208 seq,
2209 },
2210 _ => TableInstanceRisk::None,
2211 }
2212 })
2213 .collect()
2214}
2215
2216/// The set of live tables whose CURRENT `CREATE TABLE` SQL differs from a sidecar
2217/// PRIOR `sqlite_master` — Detector B's UNAMBIGUOUS schema-change predicate
2218/// (`docs/design/drop-recreate-attribution.md`).
2219///
2220/// A table `T` is schema-changed when it is present in `current` and the prior
2221/// snapshot either (a) **lacks** `T` (absent in the prior `sqlite_master`) or
2222/// (b) carries `T` with a **different CREATE SQL text**. A table whose prior SQL
2223/// is byte-identical (the DML-only case, and the same-schema drop+recreate the
2224/// design refuses to claim) is NOT included — that is the anti-false-positive
2225/// boundary. An EMPTY `prior_schema` (no sidecar in play) yields the empty set, so
2226/// Detector B is silent unless a sidecar genuinely captured a different schema.
2227fn sidecar_schema_changed_tables(
2228 current: &std::collections::BTreeMap<String, String>,
2229 prior_schema: &std::collections::BTreeMap<String, String>,
2230) -> std::collections::BTreeSet<String> {
2231 // No sidecar (empty prior) ⟹ no Detector-B signal at all. Guarding here keeps
2232 // the "when in doubt, do NOT fire" rule explicit rather than implicit.
2233 if prior_schema.is_empty() {
2234 return std::collections::BTreeSet::new();
2235 }
2236 current
2237 .iter()
2238 .filter(|(name, current_sql)| match prior_schema.get(*name) {
2239 // Present in the prior with a DIFFERENT CREATE SQL ⟹ schema changed.
2240 Some(prior_sql) => prior_sql != *current_sql,
2241 // Absent in the prior (the table did not exist) ⟹ created since.
2242 None => true,
2243 })
2244 .map(|(name, _)| name.clone())
2245 .collect()
2246}
2247
2248/// Per-record [`TableInstanceRisk`] over BOTH Detector A (bare AUTOINCREMENT
2249/// high-water) AND Detector B (sidecar schema-change), the sidecar-aware pass the
2250/// CLI composes when a `-wal`/`-journal` is in play
2251/// (`docs/design/drop-recreate-attribution.md`).
2252///
2253/// `prior_schema` is the sidecar's PRIOR `sqlite_master` as `name -> CREATE SQL`
2254/// ([`sqlite_core::PriorSnapshot::schema_sql`] for a `-journal`;
2255/// [`Database::schema_sql`] of the pre-WAL base bytes for a `-wal`). Pass an EMPTY
2256/// map to run Detector A only (the no-sidecar path), so this is a strict superset
2257/// of [`table_instance_risks`].
2258///
2259/// Detector B trips [`TableInstanceRisk::SidecarSchemaChanged`] for a record that
2260/// is `Attribution::Known(table)` whose `table` has a sidecar schema change —
2261/// prior absent, or prior CREATE SQL differs from current.
2262/// **Precedence:** Detector A wins where it fires — its rowid+seq evidence is more
2263/// specific than B's table-level boundary — so a record qualifying for both is
2264/// surfaced as `RowidExceedsAutoincHighwater`; Detector B fills only records A left
2265/// as [`TableInstanceRisk::None`]. Neither alters attribution, tier, or routing.
2266#[must_use]
2267pub fn table_instance_risks_with_sidecar(
2268 db: &Database,
2269 records: &[CarvedRecord],
2270 attributions: &[Attribution],
2271 prior_schema: &std::collections::BTreeMap<String, String>,
2272) -> Vec<TableInstanceRisk> {
2273 let mut risks = table_instance_risks(db, records, attributions);
2274 let changed = sidecar_schema_changed_tables(&db.schema_sql(), prior_schema);
2275 if changed.is_empty() {
2276 return risks; // no sidecar schema-change signal ⟹ Detector A result unchanged.
2277 }
2278 for (risk, attr) in risks.iter_mut().zip(attributions) {
2279 // Detector A takes precedence: only fill records A left as None.
2280 if *risk != TableInstanceRisk::None {
2281 continue;
2282 }
2283 let Attribution::Known(table) = attr else {
2284 continue;
2285 };
2286 if changed.contains(table) {
2287 *risk = TableInstanceRisk::SidecarSchemaChanged {
2288 table: table.clone(),
2289 };
2290 }
2291 }
2292 risks
2293}
2294
2295/// The core per-rowid VERSION HISTORY ([`Database::row_histories`]) augmented with
2296/// free-space CARVED RESIDUE — the forensic-layer completion of Phase 1.
2297///
2298/// Carved residue is ORDER-UNKNOWN: a freeblock persists across commits, so
2299/// [`carve_at_commit`] / [`carve_with_fragments`] tag where residue was OBSERVED,
2300/// not where the row was deleted. Each carved record is therefore emitted as a
2301/// [`CarvedResidue`](sqlite_core::row_history::VersionOrigin::CarvedResidue) /
2302/// [`CarvedResidue`](sqlite_core::row_history::ViewState::CarvedResidue) version with
2303/// `commit_seq: None` (never a fabricated commit position), `is_deleted: true`,
2304/// and `is_guessed` set when its [`Attribution`] is `Inferred`. Residue is
2305/// attributed to a table via [`attribute_records`] and DEDUPED against any WAL
2306/// `AbsentInFinalView` version of the same rowid + values (the WAL holds it at
2307/// higher fidelity), so a row is never double-listed.
2308#[must_use]
2309pub fn row_histories_with_residue(db: &Database) -> Vec<sqlite_core::row_history::TableHistory> {
2310 use sqlite_core::row_history::{RowVersion, VersionOrigin, ViewState};
2311
2312 let mut histories = db.row_histories();
2313
2314 // Gather every carved residue record: the on-disk free space (Tier-1 full
2315 // rows) plus each materialized commit snapshot of the WAL. carve_at_commit /
2316 // carve_with_fragments already de-duplicate within their own pass; we collect
2317 // across passes and de-duplicate by content identity below.
2318 let mut records: Vec<CarvedRecord> = carve_with_fragments(db).full;
2319 if let Some(timeline) = db.wal_timeline() {
2320 for snapshot in timeline.commit_snapshots() {
2321 records.extend(carve_at_commit(db, &timeline, snapshot.id()));
2322 }
2323 }
2324 // Collapse byte-identical carves (a row can recur across commits / regions).
2325 let records = dedup_keep_best(records, &db.page_to_table_map());
2326
2327 // Attribute each carved record to a table. The strongest signal is page
2328 // linkage: a record carved from a page still owned by a live table's b-tree
2329 // (e.g. a PRIOR-version remnant in that table's slack) belongs to THAT table
2330 // for certain — stronger than, and independent of, the source-class tiering in
2331 // `attribute_record`. We consult the page→table map first, then fall back to
2332 // the shape-based `attribute_records` for off-table (freelist-page) residue.
2333 let page_table_map = db.page_to_table_map();
2334 let attributions = attribute_records(db, &records);
2335
2336 // A residue carve is deduped against a WAL `AbsentInFinalView` version of the
2337 // SAME rowid + values already in that table's history (the WAL holds it at
2338 // higher fidelity). Build the set of those (table, rowid, values) keys.
2339 let absent_keys: std::collections::HashSet<String> = histories
2340 .iter()
2341 .flat_map(|h| {
2342 h.versions
2343 .iter()
2344 .filter(|v| v.view_state == ViewState::AbsentInFinalView)
2345 .map(move |v| residue_key(&h.table, v.rowid, &v.values))
2346 })
2347 .collect();
2348
2349 // Group new residue versions by target table name.
2350 let mut to_add: std::collections::BTreeMap<String, Vec<RowVersion>> =
2351 std::collections::BTreeMap::new();
2352 for (rec, attr) in records.iter().zip(attributions.iter()) {
2353 // Page linkage (CERTAIN) wins; else the shape-based tier.
2354 let (table, is_guessed) = if let Some(name) = page_table_map.get(&rec.page) {
2355 (name.clone(), false)
2356 } else {
2357 match attr {
2358 Attribution::Known(name) => (name.clone(), false),
2359 Attribution::Inferred { guess, .. } => (guess.clone(), true),
2360 // No surviving table to attach the residue to — skip rather than
2361 // invent a home for it (no fabricated attribution).
2362 Attribution::Unattributed => continue,
2363 }
2364 };
2365 // A clobbered rowid (carving surfaces it as 0) is genuinely unknown.
2366 let rowid = if rec.rowid == 0 {
2367 None
2368 } else {
2369 Some(rec.rowid)
2370 };
2371 let key = residue_key(&table, rowid, &rec.values);
2372 if absent_keys.contains(&key) {
2373 continue; // already listed as a higher-fidelity WAL AbsentInFinalView
2374 }
2375 to_add.entry(table).or_default().push(RowVersion {
2376 rowid,
2377 values: rec.values.clone(),
2378 origin: VersionOrigin::CarvedResidue,
2379 // Order-unknown: a freeblock persists across commits, so a carve has
2380 // no trustworthy commit position. NEVER fabricate one.
2381 commit_seq: None,
2382 view_state: ViewState::CarvedResidue,
2383 is_deleted: true,
2384 is_guessed,
2385 rowid_reused: false,
2386 // An inferred (shape-matched) attribution is uncertain by nature.
2387 attribution_uncertain: is_guessed,
2388 // Carved residue is order-unknown; a delete+reinsert gap is a WAL-view
2389 // signal only, which a carve cannot witness.
2390 reinserted_after_gap: false,
2391 });
2392 }
2393
2394 // Merge the residue versions into their tables, de-duplicating residue that is
2395 // byte-identical to a version already present, then re-sort.
2396 for h in &mut histories {
2397 if let Some(extra) = to_add.remove(&h.table) {
2398 let existing: std::collections::HashSet<String> = h
2399 .versions
2400 .iter()
2401 .map(|v| residue_key(&h.table, v.rowid, &v.values))
2402 .collect();
2403 for v in extra {
2404 if existing.contains(&residue_key(&h.table, v.rowid, &v.values)) {
2405 continue;
2406 }
2407 h.versions.push(v);
2408 }
2409 sqlite_core::row_history::sort_table_versions(&mut h.versions);
2410 }
2411 }
2412 histories
2413}
2414
2415/// A content-identity key for residue de-duplication: table + rowid + a stable
2416/// `Debug` rendering of the values (`Value` carries an `f64`, so it is not
2417/// `Hash`/`Eq` directly).
2418fn residue_key(table: &str, rowid: Option<i64>, values: &[Value]) -> String {
2419 format!("{table}:{rowid:?}:{values:?}")
2420}
2421
2422#[cfg(test)]
2423mod structural_noise_tests {
2424 use super::is_structural_noise;
2425 use sqlite_core::Value;
2426
2427 #[test]
2428 fn rejects_rowid_echo_with_all_null_tail() {
2429 // The inferred over-read: a rowid echoed as the INTEGER-PK first column
2430 // followed by a long serial-type-0 (NULL) tail — no recoverable content.
2431 let mut overwide = vec![Value::Null; 102];
2432 overwide[0] = Value::Integer(14);
2433 assert!(is_structural_noise(&overwide));
2434 }
2435
2436 #[test]
2437 fn rejects_all_null_record() {
2438 assert!(is_structural_noise(&[
2439 Value::Null,
2440 Value::Null,
2441 Value::Null
2442 ]));
2443 }
2444
2445 #[test]
2446 fn keeps_ordinary_recovered_row() {
2447 let good = vec![
2448 Value::Integer(3),
2449 Value::Text("Bowl".into()),
2450 Value::Real(11.23),
2451 Value::Null,
2452 ];
2453 assert!(!is_structural_noise(&good));
2454 }
2455
2456 #[test]
2457 fn keeps_dropped_table_row_wider_than_any_live_table() {
2458 // A dropped table can be wider than every surviving live table; such an
2459 // unattributed record carries real values and MUST be kept (a width cap
2460 // would discard it — the regression this guards).
2461 let wide_real = vec![
2462 Value::Integer(7),
2463 Value::Text("secret".into()),
2464 Value::Integer(42),
2465 Value::Text("more".into()),
2466 ];
2467 assert!(!is_structural_noise(&wide_real));
2468 }
2469
2470 #[test]
2471 fn keeps_single_column_row() {
2472 // A genuine one-column row is not noise (length < 2 short-circuits).
2473 assert!(!is_structural_noise(&[Value::Text("solo".into())]));
2474 }
2475}
2476
2477#[cfg(test)]
2478mod attribution_tests {
2479 use super::{
2480 attribute_record, attribute_records, matching_tables, shape_matches, value_fits_affinity,
2481 Attribution, CarvedRecord, RecoverySource,
2482 };
2483 use sqlite_core::attribution::{Affinity, LiveTable};
2484 use sqlite_core::Value;
2485
2486 fn table(name: &str, root: u32, affinities: Vec<Affinity>) -> LiveTable {
2487 LiveTable {
2488 name: name.to_string(),
2489 rootpage: root,
2490 column_names: None,
2491 affinities,
2492 create_sql: String::new(),
2493 }
2494 }
2495
2496 fn rec(page: u32, source: RecoverySource, values: Vec<Value>) -> CarvedRecord {
2497 CarvedRecord {
2498 page,
2499 offset: 0,
2500 rowid: 1,
2501 values,
2502 confidence: 0.9,
2503 allocated: false,
2504 source,
2505 wal: None,
2506 overflow: None,
2507 }
2508 }
2509
2510 #[test]
2511 fn value_affinity_compatibility() {
2512 assert!(value_fits_affinity(&Value::Null, Affinity::Integer));
2513 assert!(value_fits_affinity(&Value::Integer(1), Affinity::Integer));
2514 assert!(value_fits_affinity(&Value::Integer(1), Affinity::Real));
2515 assert!(value_fits_affinity(&Value::Real(1.0), Affinity::Numeric));
2516 assert!(value_fits_affinity(
2517 &Value::Text("x".into()),
2518 Affinity::Text
2519 ));
2520 assert!(value_fits_affinity(&Value::Blob(vec![1]), Affinity::Blob));
2521 // A text value does NOT fit an integer column.
2522 assert!(!value_fits_affinity(
2523 &Value::Text("x".into()),
2524 Affinity::Integer
2525 ));
2526 // A blob does not fit a text column.
2527 assert!(!value_fits_affinity(&Value::Blob(vec![1]), Affinity::Text));
2528 // BLOB (or no-type) affinity is the catch-all: any value fits.
2529 assert!(value_fits_affinity(
2530 &Value::Text("x".into()),
2531 Affinity::Blob
2532 ));
2533 assert!(value_fits_affinity(&Value::Integer(1), Affinity::Blob));
2534 }
2535
2536 #[test]
2537 fn shape_match_requires_equal_arity_and_compatible_cells() {
2538 let t = table("people", 2, vec![Affinity::Integer, Affinity::Text]);
2539 assert!(shape_matches(
2540 &[Value::Integer(1), Value::Text("a".into())],
2541 &t
2542 ));
2543 // Wrong arity.
2544 assert!(!shape_matches(&[Value::Integer(1)], &t));
2545 // Incompatible cell (text in an integer column).
2546 assert!(!shape_matches(
2547 &[Value::Text("a".into()), Value::Text("b".into())],
2548 &t
2549 ));
2550 }
2551
2552 #[test]
2553 fn clean_single_match() {
2554 let tables = vec![
2555 table("people", 2, vec![Affinity::Integer, Affinity::Text]),
2556 table("amounts", 3, vec![Affinity::Real, Affinity::Real]),
2557 ];
2558 let m = matching_tables(&[Value::Integer(1), Value::Text("a".into())], &tables);
2559 assert_eq!(m, vec!["people"]);
2560 }
2561
2562 #[test]
2563 fn ambiguous_two_tables_same_shape() {
2564 let tables = vec![
2565 table("a", 2, vec![Affinity::Integer, Affinity::Text]),
2566 table("b", 3, vec![Affinity::Integer, Affinity::Text]),
2567 ];
2568 let m = matching_tables(&[Value::Integer(1), Value::Text("x".into())], &tables);
2569 assert_eq!(m, vec!["a", "b"]);
2570 }
2571
2572 #[test]
2573 fn no_match_is_unattributed_shape() {
2574 let tables = vec![table("a", 2, vec![Affinity::Integer, Affinity::Text])];
2575 let m = matching_tables(&[Value::Blob(vec![1]), Value::Blob(vec![2])], &tables);
2576 assert!(m.is_empty());
2577 }
2578
2579 #[test]
2580 fn tier1_inpage_page_in_map_is_known() {
2581 let tables = vec![table("people", 2, vec![Affinity::Integer, Affinity::Text])];
2582 let mut map = std::collections::BTreeMap::new();
2583 map.insert(5u32, "people".to_string());
2584 let r = rec(
2585 5,
2586 RecoverySource::InPageFreeBlock,
2587 vec![Value::Integer(1), Value::Text("a".into())],
2588 );
2589 assert_eq!(
2590 attribute_record(&r, &tables, &map),
2591 Attribution::Known("people".to_string())
2592 );
2593 }
2594
2595 #[test]
2596 fn tier1_freeblock_reconstructed_in_map_is_known() {
2597 let tables = vec![table("people", 2, vec![Affinity::Integer, Affinity::Text])];
2598 let mut map = std::collections::BTreeMap::new();
2599 map.insert(7u32, "people".to_string());
2600 let r = rec(7, RecoverySource::FreeblockReconstructed, vec![Value::Null]);
2601 assert_eq!(
2602 attribute_record(&r, &tables, &map),
2603 Attribution::Known("people".to_string())
2604 );
2605 }
2606
2607 #[test]
2608 fn tier2_freelist_single_match_is_inferred_unambiguous() {
2609 let tables = vec![
2610 table("people", 2, vec![Affinity::Integer, Affinity::Text]),
2611 table("amounts", 3, vec![Affinity::Real, Affinity::Real]),
2612 ];
2613 let map = std::collections::BTreeMap::new();
2614 let r = rec(
2615 9,
2616 RecoverySource::FreelistPage,
2617 vec![Value::Integer(1), Value::Text("a".into())],
2618 );
2619 assert_eq!(
2620 attribute_record(&r, &tables, &map),
2621 Attribution::Inferred {
2622 guess: "people".to_string(),
2623 ambiguous: false
2624 }
2625 );
2626 }
2627
2628 #[test]
2629 fn tier2_freelist_multi_match_is_ambiguous() {
2630 let tables = vec![
2631 table("a", 2, vec![Affinity::Integer, Affinity::Text]),
2632 table("b", 3, vec![Affinity::Integer, Affinity::Text]),
2633 ];
2634 let map = std::collections::BTreeMap::new();
2635 let r = rec(
2636 9,
2637 RecoverySource::FreelistPage,
2638 vec![Value::Integer(1), Value::Text("x".into())],
2639 );
2640 assert_eq!(
2641 attribute_record(&r, &tables, &map),
2642 Attribution::Inferred {
2643 guess: "a".to_string(),
2644 ambiguous: true
2645 }
2646 );
2647 }
2648
2649 #[test]
2650 fn tier2_freelist_no_match_is_unattributed() {
2651 let tables = vec![table("a", 2, vec![Affinity::Integer, Affinity::Text])];
2652 let map = std::collections::BTreeMap::new();
2653 let r = rec(
2654 9,
2655 RecoverySource::FreelistPage,
2656 vec![Value::Blob(vec![1]), Value::Blob(vec![2])],
2657 );
2658 assert_eq!(
2659 attribute_record(&r, &tables, &map),
2660 Attribution::Unattributed
2661 );
2662 }
2663
2664 #[test]
2665 fn tier3_dropped_table_is_unattributed() {
2666 let tables = vec![table("a", 2, vec![Affinity::Integer, Affinity::Text])];
2667 let map = std::collections::BTreeMap::new();
2668 let r = rec(
2669 9,
2670 RecoverySource::DroppedTable,
2671 vec![Value::Integer(1), Value::Text("x".into())],
2672 );
2673 assert_eq!(
2674 attribute_record(&r, &tables, &map),
2675 Attribution::Unattributed
2676 );
2677 }
2678
2679 #[test]
2680 fn tier1_inpage_page_not_in_map_falls_through_to_unattributed() {
2681 // An in-page record on a page that is NOT a live-table page (e.g. its
2682 // table was the one freed): no Tier-1 certainty, and in-page is not the
2683 // freelist class, so it is unattributed rather than inferred.
2684 let tables = vec![table("people", 2, vec![Affinity::Integer, Affinity::Text])];
2685 let map = std::collections::BTreeMap::new();
2686 let r = rec(
2687 42,
2688 RecoverySource::InPageFreeBlock,
2689 vec![Value::Integer(1), Value::Text("a".into())],
2690 );
2691 assert_eq!(
2692 attribute_record(&r, &tables, &map),
2693 Attribution::Unattributed
2694 );
2695 }
2696
2697 #[test]
2698 fn attribute_records_reads_schema_from_a_real_database() {
2699 // Mint a tiny valid db via the writer (no sqlite3 needed), open it, and
2700 // drive the db-backed attribute_records wrapper end to end.
2701 use sqlite_core::rebuild::{build_recovered_db_tables, RecoveredTable};
2702 use sqlite_core::Database;
2703 let seed = vec![RecoveredTable {
2704 name: "people".to_string(),
2705 columns: vec!["id".to_string(), "name".to_string()],
2706 rows: vec![vec![Value::Integer(1), Value::Text("a".into())]],
2707 }];
2708 let db = Database::open(build_recovered_db_tables(&seed)).expect("minted db opens");
2709 let records = vec![super::CarvedRecord {
2710 page: 99,
2711 offset: 0,
2712 rowid: 0,
2713 values: vec![Value::Integer(2), Value::Text("b".into())],
2714 confidence: 0.9,
2715 allocated: false,
2716 source: RecoverySource::FreelistPage,
2717 wal: None,
2718 overflow: None,
2719 }];
2720 let attrs = attribute_records(&db, &records);
2721 assert_eq!(attrs.len(), 1);
2722 // (INTEGER, TEXT) freelist row matches the people table's shape.
2723 assert!(matches!(attrs[0], Attribution::Inferred { .. }));
2724 }
2725}
2726
2727#[cfg(test)]
2728mod tests {
2729 use super::{dedup_fragments, CarvedFragment, RecoverySource};
2730 use sqlite_core::Value;
2731
2732 fn frag(page: u32, offset: usize, surviving: Vec<(usize, Value)>) -> CarvedFragment {
2733 let missing = 3usize.saturating_sub(surviving.len());
2734 CarvedFragment {
2735 page,
2736 offset,
2737 surviving,
2738 missing,
2739 confidence: 0.2,
2740 source: RecoverySource::InPageFreeBlock,
2741 wal: None,
2742 }
2743 }
2744
2745 /// `dedup_fragments` orders by survivor count (richest kept), drops a repeated
2746 /// `(page, offset)` anchor, and collapses a value-level duplicate at a fresh
2747 /// anchor — exercising both dedup arms and the survivor-count sort over a
2748 /// multi-element input (a single-element vector never invokes the comparator).
2749 #[test]
2750 fn dedup_keeps_richest_and_drops_duplicates() {
2751 let rich = frag(
2752 1,
2753 10,
2754 vec![(0, Value::Integer(1)), (1, Value::Text("a".into()))],
2755 );
2756 let poor_same_anchor = frag(1, 10, vec![(0, Value::Integer(1))]);
2757 let value_dup_new_anchor = frag(
2758 2,
2759 20,
2760 vec![(0, Value::Integer(1)), (1, Value::Text("a".into()))],
2761 );
2762
2763 let out = dedup_fragments(vec![poor_same_anchor, rich, value_dup_new_anchor]);
2764
2765 // The richest copy of the shared identity survives; the poorer same-anchor
2766 // copy and the value-identical fresh-anchor copy are both dropped.
2767 assert_eq!(
2768 out.len(),
2769 1,
2770 "duplicates collapse to the richest single copy"
2771 );
2772 assert_eq!(
2773 out[0].surviving.len(),
2774 2,
2775 "the 2-column copy is the one kept"
2776 );
2777 }
2778}