#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]
pub mod blob;
pub mod carve;
pub mod case_uco;
pub mod interpret;
use forensicnomicon::report::{
Confidence, Evidence, Finding, Location, Observation, Severity, Source,
};
use sqlite_core::{CommitId, Database, JournalHeader, RollbackJournal, Value, WalTimeline};
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum AnomalyKind {
NonZeroReservedSpace {
reserved: u8,
},
DeletedRecordRecovered {
page: u32,
offset: usize,
rowid: i64,
},
DroppedSchemaRecovered {
object_type: String,
name: String,
},
NonEmptyFreelist {
free_pages: u32,
},
WalUncheckpointedState {
overlaid_pages: u32,
},
PageCountMismatch {
header_pages: u32,
file_pages: u32,
},
HotJournal {
mx_page: u32,
journaled_pages: u32,
},
JournalRecoverable {
images: usize,
},
JournalChecksumMismatch {
pgnos: Vec<u32>,
total: usize,
},
JournalSchemaChange {
journal_cookie: u32,
db_cookie: u32,
},
JournalDuplicatePage {
pgnos: Vec<u32>,
},
JournalDbSizeDelta {
mx_page: u32,
current_pages: u32,
},
FreelistCountInconsistent {
declared: u32,
walked: Option<u32>,
},
ZeroedFreelistResidue {
zeroed: u32,
free_leaves: u32,
first_zeroed: u32,
},
}
impl AnomalyKind {
#[must_use]
pub fn severity(&self) -> Severity {
match self {
AnomalyKind::NonZeroReservedSpace { .. } | AnomalyKind::NonEmptyFreelist { .. } => {
Severity::Low
}
AnomalyKind::DeletedRecordRecovered { .. }
| AnomalyKind::DroppedSchemaRecovered { .. }
| AnomalyKind::WalUncheckpointedState { .. }
| AnomalyKind::JournalRecoverable { .. }
| AnomalyKind::JournalSchemaChange { .. }
| AnomalyKind::JournalDuplicatePage { .. }
| AnomalyKind::ZeroedFreelistResidue { .. } => Severity::Medium,
AnomalyKind::PageCountMismatch { .. }
| AnomalyKind::HotJournal { .. }
| AnomalyKind::JournalChecksumMismatch { .. }
| AnomalyKind::FreelistCountInconsistent { .. } => Severity::High,
AnomalyKind::JournalDbSizeDelta { .. } => Severity::Low,
}
}
#[must_use]
pub fn code(&self) -> &'static str {
match self {
AnomalyKind::NonZeroReservedSpace { .. } => "SQLITE-RESERVED-SPACE-NONZERO",
AnomalyKind::DeletedRecordRecovered { .. } => "SQLITE-DELETED-RECORD-RECOVERED",
AnomalyKind::DroppedSchemaRecovered { .. } => "SQLITE-DROPPED-SCHEMA-RECOVERED",
AnomalyKind::NonEmptyFreelist { .. } => "SQLITE-FREELIST-NONEMPTY",
AnomalyKind::WalUncheckpointedState { .. } => "SQLITE-WAL-UNCHECKPOINTED",
AnomalyKind::PageCountMismatch { .. } => "SQLITE-PAGECOUNT-MISMATCH",
AnomalyKind::HotJournal { .. } => "SQLITE-JOURNAL-HOT",
AnomalyKind::JournalRecoverable { .. } => "SQLITE-JOURNAL-RECOVERABLE",
AnomalyKind::JournalChecksumMismatch { .. } => "SQLITE-JOURNAL-CHECKSUM-MISMATCH",
AnomalyKind::JournalSchemaChange { .. } => "SQLITE-JOURNAL-SCHEMA-CHANGE",
AnomalyKind::JournalDuplicatePage { .. } => "SQLITE-JOURNAL-DUPLICATE-PAGE",
AnomalyKind::JournalDbSizeDelta { .. } => "SQLITE-JOURNAL-DBSIZE-DELTA",
AnomalyKind::FreelistCountInconsistent { .. } => "SQLITE-FREELIST-COUNT-INCONSISTENT",
AnomalyKind::ZeroedFreelistResidue { .. } => "SQLITE-FREELIST-RESIDUE-ZEROED",
}
}
#[must_use]
pub fn note(&self) -> String {
match self {
AnomalyKind::NonZeroReservedSpace { reserved } => {
let scheme = match reserved {
80 => {
"consistent with SQLCipher 4 (a 16-byte per-page IV plus a \
64-byte HMAC-SHA512)"
}
48 => {
"consistent with SQLCipher 1-3 (a 16-byte per-page IV plus a \
32-byte HMAC-SHA1)"
}
16 => {
"consistent with a 16-byte per-page IV (an AES-CBC cipher with \
per-page HMAC disabled)"
}
8 => {
"consistent with the SQLite checksum VFS (an 8-byte per-page \
checksum)"
}
_ => {
"does not match a known page-level scheme's per-page reservation \
(an unrecognized extension)"
}
};
format!(
"file header reserves {reserved} byte(s) per page (offset 20) — \
non-standard; {scheme}. Recovering any deleted records would require \
the encryption key (or the corresponding VFS)"
)
}
AnomalyKind::DeletedRecordRecovered {
page,
offset,
rowid,
} => format!(
"recovered a record-shaped cell (rowid {rowid}) from unallocated \
space at page {page} offset {offset} — consistent with a deleted \
row not yet overwritten"
),
AnomalyKind::DroppedSchemaRecovered { object_type, name } => format!(
"recovered a deleted sqlite_master row for {object_type} \"{name}\" \
from page-1 free space — consistent with a dropped (or replaced) \
{object_type} whose definition survives the drop"
),
AnomalyKind::NonEmptyFreelist { free_pages } => format!(
"{free_pages} free page(s) on the freelist — consistent with prior \
deletions (DELETE without VACUUM); free pages may retain \
recoverable deleted records"
),
AnomalyKind::WalUncheckpointedState { overlaid_pages } => format!(
"the -wal sidecar carries {overlaid_pages} committed page version(s) \
the main file does not reflect — consistent with capture while a \
write transaction was checkpoint-pending; the main file alone \
under-reports the true state; acquire the live -wal before the \
application terminates, because a checkpoint (e.g. on its next clean \
close) folds the WAL into the main file and discards the \
uncheckpointed deleted/superseded residue, which the \
post-checkpoint main file alone would no longer contain"
),
AnomalyKind::PageCountMismatch {
header_pages,
file_pages,
} => format!(
"in-header page count ({header_pages}) disagrees with the file \
length ({file_pages} pages) — consistent with truncation, \
carving, or out-of-band modification"
),
AnomalyKind::HotJournal { .. } => {
"a hot rollback journal (valid header magic) sits beside \
the database — consistent with an interrupted or in-progress write transaction \
(crash, power loss, process kill, or acquisition captured mid-write); SQLite \
would roll it back on next open, so the main database may require rollback"
.to_string()
}
AnomalyKind::JournalRecoverable { images } => format!(
"the rollback journal carries {images} pre-transaction page \
image(s) (PERSIST post-commit) — consistent with a committed \
transaction whose pre-images (deleted/modified rows) remain \
recoverable"
),
AnomalyKind::JournalChecksumMismatch { pgnos, total } => {
let list = pgnos
.iter()
.map(u32::to_string)
.collect::<Vec<_>>()
.join(", ");
format!(
"{} of {total} journal page record(s) failed the page \
checksum (page(s) {list}) — consistent with corruption, a \
torn page write (power-loss mid-sector), or post-write \
modification of the journal",
pgnos.len()
)
}
AnomalyKind::JournalSchemaChange {
journal_cookie,
db_cookie,
} => format!(
"the journal's prior schema cookie ({journal_cookie}) differs from the \
database's ({db_cookie}) — consistent with a DDL change (CREATE/DROP/ALTER) \
in the last transaction; the prior schema is recoverable"
),
AnomalyKind::JournalDuplicatePage { .. } => {
"a page number appears more than once across the \
journal's page records — the spec journals a page at most once, so this is \
consistent with corruption, a savepoint/super-journal artifact, or tampering"
.to_string()
}
AnomalyKind::JournalDbSizeDelta {
mx_page,
current_pages,
} => {
let direction = if *mx_page < *current_pages {
"the database grew (consistent with INSERTs adding pages)"
} else {
"the database shrank (consistent with auto-vacuum / \
incremental-vacuum or truncation, NOT an ordinary DELETE)"
};
format!(
"the journal records a transaction-start page count \
(mxPage = {mx_page}) that differs from the current page \
count ({current_pages}) — the last transaction changed the \
database size: {direction}"
)
}
AnomalyKind::FreelistCountInconsistent { declared, walked } => match walked {
Some(walked) => format!(
"the header declares {declared} free page(s) (offset 36) but walking the \
freelist trunk chain yields {walked} — consistent with freelist tampering \
(an edited count to hide freed pages) or corruption"
),
None => format!(
"the header declares {declared} free page(s) (offset 36) but the freelist \
trunk chain is unwalkable (out-of-range or cyclic trunk pointer) — \
consistent with freelist tampering or corruption"
),
},
AnomalyKind::ZeroedFreelistResidue {
zeroed,
free_leaves,
first_zeroed,
} => format!(
"{zeroed} of {free_leaves} freed leaf page(s) are entirely zero (first at page \
{first_zeroed}) — a freed leaf keeps its former content, so the deleted records \
they held were overwritten with zeros; consistent with secure_delete=ON or a \
manual wipe"
),
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Anomaly {
pub severity: Severity,
pub code: &'static str,
pub kind: AnomalyKind,
pub note: String,
pub confidence: Option<f32>,
}
impl Anomaly {
#[must_use]
pub fn new(kind: AnomalyKind) -> Self {
Anomaly {
severity: kind.severity(),
code: kind.code(),
note: kind.note(),
kind,
confidence: None,
}
}
#[must_use]
pub fn with_confidence(mut self, confidence: f32) -> Self {
self.confidence = Some(confidence);
self
}
}
impl Observation for Anomaly {
fn severity(&self) -> Option<Severity> {
Some(self.severity)
}
fn code(&self) -> &'static str {
self.code
}
fn note(&self) -> String {
self.note.clone()
}
fn confidence(&self) -> Option<Confidence> {
self.confidence.and_then(Confidence::new)
}
fn category(&self) -> forensicnomicon::report::Category {
use forensicnomicon::report::Category;
match &self.kind {
AnomalyKind::DeletedRecordRecovered { .. }
| AnomalyKind::DroppedSchemaRecovered { .. }
| AnomalyKind::NonEmptyFreelist { .. }
| AnomalyKind::JournalRecoverable { .. }
| AnomalyKind::JournalSchemaChange { .. }
| AnomalyKind::ZeroedFreelistResidue { .. } => Category::Residue,
AnomalyKind::WalUncheckpointedState { .. }
| AnomalyKind::PageCountMismatch { .. }
| AnomalyKind::HotJournal { .. }
| AnomalyKind::JournalChecksumMismatch { .. }
| AnomalyKind::JournalDuplicatePage { .. }
| AnomalyKind::JournalDbSizeDelta { .. }
| AnomalyKind::FreelistCountInconsistent { .. } => Category::Integrity,
other => Category::from_code(other.code()),
}
}
fn evidence(&self) -> Vec<Evidence> {
match &self.kind {
AnomalyKind::DeletedRecordRecovered {
page,
offset,
rowid,
} => vec![
Evidence {
field: "rowid".to_string(),
value: rowid.to_string(),
location: Some(Location::RecordId(u64::try_from(*rowid).unwrap_or(0))),
},
Evidence {
field: "source_page".to_string(),
value: page.to_string(),
location: Some(Location::Other {
space: "sqlite:page".to_string(),
value: u64::from(*page),
}),
},
Evidence {
field: "cell_offset".to_string(),
value: offset.to_string(),
location: Some(Location::ByteOffset(*offset as u64)),
},
],
AnomalyKind::DroppedSchemaRecovered { object_type, name } => vec![
Evidence {
field: "object_type".to_string(),
value: object_type.clone(),
location: None,
},
Evidence {
field: "name".to_string(),
value: name.clone(),
location: None,
},
],
AnomalyKind::NonEmptyFreelist { free_pages } => vec![Evidence {
field: "free_pages".to_string(),
value: free_pages.to_string(),
location: None,
}],
AnomalyKind::WalUncheckpointedState { overlaid_pages } => vec![Evidence {
field: "overlaid_pages".to_string(),
value: overlaid_pages.to_string(),
location: None,
}],
AnomalyKind::PageCountMismatch {
header_pages,
file_pages,
} => vec![
Evidence {
field: "header_pages".to_string(),
value: header_pages.to_string(),
location: Some(Location::Field("in_header_db_size".to_string())),
},
Evidence {
field: "file_pages".to_string(),
value: file_pages.to_string(),
location: None,
},
],
AnomalyKind::JournalRecoverable { images } => vec![Evidence {
field: "page_images".to_string(),
value: images.to_string(),
location: None,
}],
AnomalyKind::JournalChecksumMismatch { pgnos, total } => {
let mut ev: Vec<Evidence> = pgnos
.iter()
.map(|pgno| Evidence {
field: "checksum_mismatch_pgno".to_string(),
value: pgno.to_string(),
location: Some(Location::Other {
space: "sqlite:page".to_string(),
value: u64::from(*pgno),
}),
})
.collect();
ev.push(Evidence {
field: "page_records".to_string(),
value: total.to_string(),
location: None,
});
ev
}
AnomalyKind::JournalDbSizeDelta {
mx_page,
current_pages,
} => vec![
Evidence {
field: "mx_page".to_string(),
value: mx_page.to_string(),
location: None,
},
Evidence {
field: "current_pages".to_string(),
value: current_pages.to_string(),
location: None,
},
],
AnomalyKind::JournalSchemaChange {
journal_cookie,
db_cookie,
} => vec![
Evidence {
field: "journal_schema_cookie".to_string(),
value: journal_cookie.to_string(),
location: None,
},
Evidence {
field: "db_schema_cookie".to_string(),
value: db_cookie.to_string(),
location: None,
},
],
AnomalyKind::NonZeroReservedSpace { reserved } => vec![Evidence {
field: "reserved_bytes_per_page".to_string(),
value: reserved.to_string(),
location: Some(Location::ByteOffset(20)),
}],
AnomalyKind::JournalDuplicatePage { pgnos } => pgnos
.iter()
.map(|pgno| Evidence {
field: "duplicate_pgno".to_string(),
value: pgno.to_string(),
location: Some(Location::Other {
space: "sqlite:page".to_string(),
value: u64::from(*pgno),
}),
})
.collect(),
AnomalyKind::HotJournal {
mx_page,
journaled_pages,
} => vec![
Evidence {
field: "db_page_count_at_txn_start".to_string(),
value: mx_page.to_string(),
location: None,
},
Evidence {
field: "journaled_pages".to_string(),
value: journaled_pages.to_string(),
location: None,
},
],
AnomalyKind::FreelistCountInconsistent { declared, walked } => vec![
Evidence {
field: "declared_free_pages".to_string(),
value: declared.to_string(),
location: Some(Location::ByteOffset(36)),
},
Evidence {
field: "walked_free_pages".to_string(),
value: walked.map_or_else(|| "malformed".to_string(), |w| w.to_string()),
location: None,
},
],
AnomalyKind::ZeroedFreelistResidue {
zeroed,
free_leaves,
first_zeroed,
} => vec![
Evidence {
field: "zeroed_leaf_pages".to_string(),
value: zeroed.to_string(),
location: None,
},
Evidence {
field: "free_leaf_pages".to_string(),
value: free_leaves.to_string(),
location: None,
},
Evidence {
field: "first_zeroed_page".to_string(),
value: first_zeroed.to_string(),
location: Some(Location::Other {
space: "sqlite:page".to_string(),
value: u64::from(*first_zeroed),
}),
},
],
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum RecoverySource {
FreelistPage,
InPageFreeBlock,
DroppedTable,
PriorVersion,
FreeblockReconstructed,
WalFrame,
CommitSnapshot,
RollbackJournal(RollbackJournalSource),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum JournalHeaderState {
Valid,
ReconstructedZeroed,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RollbackJournalSource {
pub segment: usize,
pub pgno: u32,
pub header_state: JournalHeaderState,
pub checksum_valid: Option<bool>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct WalProvenance {
pub frame_index: usize,
pub salt1: u32,
pub salt2: u32,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OverflowProvenance {
pub first_page: u32,
pub chain: Vec<u32>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct CarvedRecord {
pub page: u32,
pub offset: usize,
pub rowid: i64,
pub values: Vec<Value>,
pub confidence: f32,
pub allocated: bool,
pub source: RecoverySource,
pub wal: Option<WalProvenance>,
pub overflow: Option<OverflowProvenance>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct CarvedFragment {
pub page: u32,
pub offset: usize,
pub surviving: Vec<(usize, Value)>,
pub missing: usize,
pub confidence: f32,
pub source: RecoverySource,
pub wal: Option<WalProvenance>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct CarveTiers {
pub full: Vec<CarvedRecord>,
pub fragments: Vec<CarvedFragment>,
}
#[must_use]
pub fn carve_deleted_records(db: &Database, column_count: usize) -> Vec<CarvedRecord> {
let mut out = Vec::new();
let Ok(free) = db.freelist_pages() else {
return out;
};
for page in free {
let Some(page_bytes) = db.raw_page(page) else {
continue; };
for cell in db.carve_cells(&page_bytes, column_count) {
out.push(CarvedRecord {
page,
offset: cell.offset,
rowid: cell.rowid,
values: cell.values,
confidence: cell.confidence,
allocated: false,
source: RecoverySource::FreelistPage,
wal: None,
overflow: None,
});
}
}
out
}
fn live_value_tuples(db: &Database) -> Vec<Vec<Value>> {
db.live_table_rows()
.into_iter()
.flat_map(|t| t.rows.into_iter().map(|r| r.values))
.collect()
}
#[must_use]
pub fn carve_all_deleted_records(db: &Database) -> Vec<CarvedRecord> {
let mut out: Vec<CarvedRecord> = Vec::new();
let dropped_table_db = !db.has_user_table();
if let Ok(free) = db.freelist_pages() {
for page in free {
let Some(page_bytes) = db.raw_page(page) else {
continue; };
let source = if dropped_table_db {
RecoverySource::DroppedTable
} else {
RecoverySource::FreelistPage
};
for cell in db.carve_cells_inferred(&page_bytes) {
out.push(CarvedRecord {
page,
offset: cell.offset,
rowid: cell.rowid,
values: cell.values,
confidence: cell.confidence,
allocated: false,
source,
wal: None,
overflow: None,
});
}
}
}
let page_count = db.page_count();
for page in 1..=page_count {
let Some(page_bytes) = db.raw_page(page) else {
continue; };
for cell in db.carve_free_regions(&page_bytes, 0) {
out.push(CarvedRecord {
page,
offset: cell.offset,
rowid: cell.rowid,
values: cell.values,
confidence: cell.confidence,
allocated: false,
source: RecoverySource::InPageFreeBlock,
wal: None,
overflow: None,
});
}
for cell in db.reconstruct_freeblock_records(&page_bytes) {
out.push(CarvedRecord {
page,
offset: cell.offset,
rowid: cell.rowid,
values: cell.values,
confidence: cell.confidence,
allocated: false,
source: RecoverySource::FreeblockReconstructed,
wal: None,
overflow: None,
});
}
for (cell, chain) in db.carve_overflow_records(&page_bytes) {
let first_page = chain.first().copied().unwrap_or(0);
out.push(CarvedRecord {
page,
offset: cell.offset,
rowid: cell.rowid,
values: cell.values,
confidence: cell.confidence,
allocated: false,
source: RecoverySource::InPageFreeBlock,
wal: None,
overflow: Some(OverflowProvenance { first_page, chain }),
});
}
for (cell, chain) in db.carve_overflow_template_records(&page_bytes) {
let first_page = chain.first().copied().unwrap_or(0);
out.push(CarvedRecord {
page,
offset: cell.offset,
rowid: cell.rowid,
values: cell.values,
confidence: cell.confidence,
allocated: false,
source: RecoverySource::FreeblockReconstructed,
wal: None,
overflow: Some(OverflowProvenance { first_page, chain }),
});
}
}
for frame in db.wal_frame_pages() {
let prov = WalProvenance {
frame_index: frame.frame_index,
salt1: frame.salt1,
salt2: frame.salt2,
};
let cells = db
.carve_leaf_cells(&frame.page)
.into_iter()
.chain(db.carve_free_regions(&frame.page, 0))
.chain(db.reconstruct_freeblock_records(&frame.page));
for cell in cells {
out.push(CarvedRecord {
page: frame.page_no,
offset: cell.offset,
rowid: cell.rowid,
values: cell.values,
confidence: cell.confidence,
allocated: false,
source: RecoverySource::WalFrame,
wal: Some(prov),
overflow: None,
});
}
}
out.retain(|rec| !is_structural_noise(&rec.values));
let live = db.live_rows();
let live_values = live_value_tuples(db);
let live_value_keys: std::collections::HashSet<String> = live_values
.iter()
.chain(db.live_schema_rows().iter())
.map(|v| format!("{v:?}"))
.collect();
out.retain_mut(|rec| {
if live_value_keys.contains(&format!("{:?}", rec.values)) {
return false;
}
if rec.source == RecoverySource::FreeblockReconstructed
|| rec.source == RecoverySource::WalFrame
{
return true;
}
if live.contains_key(&rec.rowid) {
rec.source = RecoverySource::PriorVersion;
}
true
});
dedup_keep_best(out, &db.page_to_table_map())
}
fn is_structural_noise(values: &[Value]) -> bool {
values.len() >= 2 && values.iter().skip(1).all(|v| matches!(v, Value::Null))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RecoveredSchema {
pub object_type: String,
pub name: String,
pub tbl_name: String,
pub rootpage: Option<i64>,
pub sql: String,
}
#[must_use]
pub fn recover_dropped_schemas(db: &Database) -> Vec<RecoveredSchema> {
use std::collections::HashSet;
let live: HashSet<(String, String)> = db.schema_sql().into_iter().collect();
let mut seen: HashSet<(String, String)> = HashSet::new();
let mut out = Vec::new();
for rec in carve_all_deleted_records(db) {
if rec.page != 1 {
continue;
}
let Some(schema) = as_schema_row(&rec.values) else {
continue;
};
let key = (schema.name.clone(), schema.sql.clone());
if live.contains(&key) {
continue;
}
if seen.insert(key) {
out.push(schema);
}
}
out
}
fn as_schema_row(values: &[Value]) -> Option<RecoveredSchema> {
let [type_v, name_v, tbl_v, root_v, sql_v] = values else {
return None;
};
let object_type = match type_v {
Value::Text(t) if matches!(t.as_str(), "table" | "index" | "view" | "trigger") => t.clone(),
_ => return None,
};
let (Value::Text(name), Value::Text(tbl_name), Value::Text(sql)) = (name_v, tbl_v, sql_v)
else {
return None;
};
let rootpage = match root_v {
Value::Integer(n) => Some(*n),
Value::Null => None,
_ => return None,
};
Some(RecoveredSchema {
object_type,
name: name.clone(),
tbl_name: tbl_name.clone(),
rootpage,
sql: sql.clone(),
})
}
#[must_use]
pub fn carve_with_fragments(db: &Database) -> CarveTiers {
let full = carve_all_deleted_records(db);
let mut fragments: Vec<CarvedFragment> = Vec::new();
let page_count = db.page_count();
for page in 1..=page_count {
let Some(page_bytes) = db.raw_page(page) else {
continue; };
for frag in db.reconstruct_freeblock_fragments(&page_bytes) {
fragments.push(CarvedFragment {
page,
offset: frag.offset,
surviving: frag.surviving,
missing: frag.missing,
confidence: frag.confidence,
source: RecoverySource::FreeblockReconstructed,
wal: None,
});
}
for frag in db.carve_overflow_fragments(&page_bytes) {
fragments.push(CarvedFragment {
page,
offset: frag.offset,
surviving: frag.surviving,
missing: frag.missing,
confidence: frag.confidence,
source: RecoverySource::InPageFreeBlock,
wal: None,
});
}
}
let live_values = live_value_tuples(db);
fragments.retain(|frag| {
!live_values
.iter()
.any(|lv| fragment_matches_columns(frag, lv))
});
fragments.retain(|frag| {
!full
.iter()
.any(|rec| fragment_matches_columns(frag, &rec.values))
});
fragments = dedup_fragments(fragments);
CarveTiers { full, fragments }
}
fn fragment_matches_columns(frag: &CarvedFragment, row: &[Value]) -> bool {
!frag.surviving.is_empty()
&& frag
.surviving
.iter()
.all(|(idx, v)| row.get(*idx) == Some(v))
}
fn dedup_fragments(mut frags: Vec<CarvedFragment>) -> Vec<CarvedFragment> {
use std::collections::HashSet;
frags.sort_by_key(|f| std::cmp::Reverse(f.surviving.len()));
let mut seen_anchor: HashSet<(u32, usize)> = HashSet::new();
let mut seen_values: HashSet<String> = HashSet::new();
let mut kept = Vec::new();
for frag in frags {
if !seen_anchor.insert((frag.page, frag.offset)) {
continue;
}
let vkey = format!("{:?}", frag.surviving);
if !seen_values.insert(vkey) {
continue;
}
kept.push(frag);
}
kept
}
#[must_use]
pub fn carve_at_commit(db: &Database, timeline: &WalTimeline, id: CommitId) -> Vec<CarvedRecord> {
let Some(snapshot) = timeline.snapshot_at(id) else {
return Vec::new();
};
let lsn = snapshot.lsn();
let prov = WalProvenance {
frame_index: lsn.frame_index,
salt1: lsn.salt1,
salt2: lsn.salt2,
};
let mut out: Vec<CarvedRecord> = Vec::new();
for page_no in snapshot.page_numbers() {
let Some(image) = snapshot.page_version(page_no) else {
continue; };
let cells = db
.carve_leaf_cells(&image.bytes)
.into_iter()
.chain(db.carve_free_regions(&image.bytes, 0))
.chain(db.reconstruct_freeblock_records(&image.bytes));
for cell in cells {
out.push(CarvedRecord {
page: page_no,
offset: cell.offset,
rowid: cell.rowid,
values: cell.values,
confidence: cell.confidence,
allocated: false,
source: RecoverySource::CommitSnapshot,
wal: Some(prov),
overflow: None,
});
}
}
let live_value_keys: std::collections::HashSet<String> = live_value_tuples(db)
.iter()
.chain(db.live_schema_rows().iter())
.map(|v| format!("{v:?}"))
.collect();
out.retain(|rec| !live_value_keys.contains(&format!("{:?}", rec.values)));
dedup_keep_best(out, &db.page_to_table_map())
}
fn dedup_keep_best(
mut records: Vec<CarvedRecord>,
page_table: &std::collections::BTreeMap<u32, String>,
) -> Vec<CarvedRecord> {
use std::collections::HashSet;
let content = |r: &CarvedRecord| format!("{}:{:?}", r.rowid, r.values);
let attributed: HashSet<String> = records
.iter()
.filter(|r| page_table.contains_key(&r.page))
.map(&content)
.collect();
let mut seen: HashSet<String> = HashSet::new();
let mut kept: Vec<CarvedRecord> = Vec::new();
records.sort_by(|a, b| {
b.confidence
.partial_cmp(&a.confidence)
.unwrap_or(std::cmp::Ordering::Equal)
});
for rec in records.drain(..) {
let c = content(&rec);
let key = match page_table.get(&rec.page) {
Some(table) => format!("T:{table}:{c}"),
None if attributed.contains(&c) => continue,
None => format!("U:{c}"),
};
if seen.insert(key) {
kept.push(rec);
}
}
kept
}
#[derive(Debug, Clone, PartialEq)]
pub struct PriorRow {
pub table: String,
pub rowid: i64,
pub values: Vec<Value>,
pub source: RecoverySource,
}
#[derive(Debug, Clone, PartialEq)]
pub struct PriorVersionRecord {
pub table: String,
pub rowid: i64,
pub prior_values: Vec<Value>,
pub current_values: Vec<Value>,
pub replaced_rowid: bool,
pub source: RecoverySource,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct JournalCounts {
pub deleted: usize,
pub modified: usize,
}
#[derive(Debug, Clone, PartialEq)]
pub struct JournalRecovery {
pub deleted: Vec<PriorRow>,
pub modified: Vec<PriorVersionRecord>,
pub counts: JournalCounts,
}
#[must_use]
pub fn carve_rollback_journal(db: &Database, journal: &[u8]) -> JournalRecovery {
let mut deleted = Vec::new();
let mut modified = Vec::new();
let Ok(prior) = db.rollback_prior(journal) else {
return JournalRecovery {
deleted,
modified,
counts: JournalCounts::default(),
};
};
let Some(header_state) = prior_header_state(journal, db) else {
return JournalRecovery {
deleted,
modified,
counts: JournalCounts::default(),
};
};
let checksum_valid = match header_state {
JournalHeaderState::Valid => Some(true),
JournalHeaderState::ReconstructedZeroed => None,
};
let live = db.live_table_rows();
for ptable in prior.tables() {
if ptable.without_rowid {
continue; }
let col_count = ptable.columns.len();
let Ok(prior_rows) = prior.read_table_with_pages(ptable.rootpage, col_count) else {
continue;
};
let current: std::collections::BTreeMap<i64, Vec<Value>> = live
.iter()
.find(|d| d.name == ptable.name)
.map(|d| d.rows.iter().map(|r| (r.rowid, r.values.clone())).collect())
.unwrap_or_default();
for (rowid, values, leaf_page) in prior_rows {
let source = RecoverySource::RollbackJournal(RollbackJournalSource {
segment: 0,
pgno: leaf_page,
header_state,
checksum_valid,
});
match current.get(&rowid) {
None => deleted.push(PriorRow {
table: ptable.name.clone(),
rowid,
values,
source,
}),
Some(cur) if *cur != values => modified.push(PriorVersionRecord {
table: ptable.name.clone(),
rowid,
prior_values: values,
current_values: cur.clone(),
replaced_rowid: true,
source,
}),
Some(_) => {} }
}
}
let counts = JournalCounts {
deleted: deleted.len(),
modified: modified.len(),
};
JournalRecovery {
deleted,
modified,
counts,
}
}
fn prior_header_state(journal: &[u8], db: &Database) -> Option<JournalHeaderState> {
let parsed = RollbackJournal::parse(journal, db.header().page_size).ok()?;
Some(match parsed.header() {
JournalHeader::Valid { .. } => JournalHeaderState::Valid,
JournalHeader::ReconstructedZeroed { .. } => JournalHeaderState::ReconstructedZeroed,
})
}
#[must_use]
pub fn audit(db: &Database) -> Vec<Anomaly> {
let mut out = Vec::new();
let reserved = db.header().reserved;
if reserved != 0 {
out.push(Anomaly::new(AnomalyKind::NonZeroReservedSpace { reserved }));
}
let free_pages = db.freelist_count();
if free_pages != 0 {
out.push(Anomaly::new(AnomalyKind::NonEmptyFreelist { free_pages }));
}
let declared = free_pages;
match db.freelist_pages_split() {
Ok((leaves, trunks)) => {
let walked = u32::try_from(leaves.len() + trunks.len()).unwrap_or(u32::MAX);
if declared != walked {
out.push(Anomaly::new(AnomalyKind::FreelistCountInconsistent {
declared,
walked: Some(walked),
}));
}
let zeroed: Vec<u32> = leaves
.iter()
.copied()
.filter(|&p| db.raw_page(p).is_some_and(|b| b.iter().all(|&x| x == 0)))
.collect();
if let Some(&first_zeroed) = zeroed.first() {
out.push(Anomaly::new(AnomalyKind::ZeroedFreelistResidue {
zeroed: u32::try_from(zeroed.len()).unwrap_or(u32::MAX),
free_leaves: u32::try_from(leaves.len()).unwrap_or(u32::MAX),
first_zeroed,
}));
}
}
Err(_) => out.push(Anomaly::new(AnomalyKind::FreelistCountInconsistent {
declared,
walked: None,
})),
}
if db.wal_applied() {
out.push(Anomaly::new(AnomalyKind::WalUncheckpointedState {
overlaid_pages: 1,
}));
}
let header_pages = db.header_page_count();
let file_pages = db.file_page_count();
if header_pages != 0 && header_pages != file_pages {
out.push(Anomaly::new(AnomalyKind::PageCountMismatch {
header_pages,
file_pages,
}));
}
for schema in recover_dropped_schemas(db) {
out.push(Anomaly::new(AnomalyKind::DroppedSchemaRecovered {
object_type: schema.object_type,
name: schema.name,
}));
}
out
}
#[must_use]
pub fn audit_findings(db: &Database, source: &Source) -> Vec<Finding> {
audit(db)
.into_iter()
.map(|a| a.to_finding(source.clone()))
.collect()
}
fn schema_cookie(page: &[u8]) -> Option<u32> {
let bytes = page.get(40..44)?;
Some(u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]))
}
#[must_use]
pub fn audit_journal(db: &Database, journal: &[u8]) -> Vec<Anomaly> {
let mut out = Vec::new();
let Ok(parsed) = RollbackJournal::parse(journal, db.header().page_size) else {
return out;
};
let images = parsed.page_images();
match parsed.header() {
JournalHeader::Valid { mx_page, .. } => {
out.push(Anomaly::new(AnomalyKind::HotJournal {
mx_page: *mx_page,
journaled_pages: u32::try_from(images.len()).unwrap_or(u32::MAX),
}));
let current_pages = db.page_count();
if *mx_page != 0 && *mx_page != current_pages {
out.push(Anomaly::new(AnomalyKind::JournalDbSizeDelta {
mx_page: *mx_page,
current_pages,
}));
}
}
JournalHeader::ReconstructedZeroed { .. } => {
if !images.is_empty() {
out.push(Anomaly::new(AnomalyKind::JournalRecoverable {
images: images.len(),
}));
}
}
}
let bad: Vec<u32> = images
.iter()
.filter(|i| i.checksum_valid == Some(false))
.map(|i| i.pgno)
.collect();
if !bad.is_empty() {
out.push(Anomaly::new(AnomalyKind::JournalChecksumMismatch {
pgnos: bad,
total: images.len(),
}));
}
if let (Some(journal_cookie), Some(db_cookie)) = (
images
.iter()
.find(|i| i.pgno == 1)
.and_then(|i| schema_cookie(&i.bytes)),
db.raw_page(1).as_deref().and_then(schema_cookie),
) {
if journal_cookie != db_cookie {
out.push(Anomaly::new(AnomalyKind::JournalSchemaChange {
journal_cookie,
db_cookie,
}));
}
}
if parsed.has_duplicate_pgno() {
out.push(Anomaly::new(AnomalyKind::JournalDuplicatePage {
pgnos: parsed.duplicate_pgnos().to_vec(),
}));
}
out
}
#[must_use]
pub fn audit_journal_findings(db: &Database, journal: &[u8], source: &Source) -> Vec<Finding> {
audit_journal(db, journal)
.into_iter()
.map(|a| a.to_finding(source.clone()))
.collect()
}
#[must_use]
pub fn audit_carved_findings(db: &Database, column_count: usize, source: &Source) -> Vec<Finding> {
carve_deleted_records(db, column_count)
.into_iter()
.map(|rec| {
Anomaly::new(AnomalyKind::DeletedRecordRecovered {
page: rec.page,
offset: rec.offset,
rowid: rec.rowid,
})
.with_confidence(rec.confidence)
.to_finding(source.clone())
})
.collect()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Attribution {
Known(String),
Inferred {
guess: String,
ambiguous: bool,
},
Unattributed,
}
#[must_use]
pub fn value_fits_affinity(value: &Value, affinity: sqlite_core::attribution::Affinity) -> bool {
use sqlite_core::attribution::Affinity;
if affinity == Affinity::Blob {
return true;
}
match value {
Value::Null => true,
Value::Integer(_) | Value::Real(_) => matches!(
affinity,
Affinity::Integer | Affinity::Real | Affinity::Numeric
),
Value::Text(_) => matches!(affinity, Affinity::Text | Affinity::Numeric),
Value::Blob(_) => false,
}
}
#[must_use]
pub fn shape_matches(values: &[Value], table: &sqlite_core::attribution::LiveTable) -> bool {
if values.len() != table.affinities.len() {
return false;
}
values
.iter()
.zip(&table.affinities)
.all(|(v, &aff)| value_fits_affinity(v, aff))
}
#[must_use]
pub fn matching_tables(
values: &[Value],
tables: &[sqlite_core::attribution::LiveTable],
) -> Vec<String> {
tables
.iter()
.filter(|t| shape_matches(values, t))
.map(|t| t.name.clone())
.collect()
}
#[must_use]
pub fn attribute_record(
rec: &CarvedRecord,
tables: &[sqlite_core::attribution::LiveTable],
page_table_map: &std::collections::BTreeMap<u32, String>,
) -> Attribution {
if matches!(
rec.source,
RecoverySource::InPageFreeBlock | RecoverySource::FreeblockReconstructed
) {
if let Some(name) = page_table_map.get(&rec.page) {
return Attribution::Known(name.clone());
}
return Attribution::Unattributed;
}
if rec.source == RecoverySource::FreelistPage {
let mut matches = matching_tables(&rec.values, tables);
return match matches.len() {
0 => Attribution::Unattributed,
1 => Attribution::Inferred {
guess: matches.remove(0),
ambiguous: false,
},
_ => Attribution::Inferred {
guess: matches.remove(0),
ambiguous: true,
},
};
}
Attribution::Unattributed
}
#[must_use]
pub fn attribute_records(db: &Database, records: &[CarvedRecord]) -> Vec<Attribution> {
let tables = db.live_tables();
let page_table_map = db.page_to_table_map();
records
.iter()
.map(|rec| attribute_record(rec, &tables, &page_table_map))
.collect()
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum TableInstanceRisk {
None,
RowidExceedsAutoincHighwater {
table: String,
rowid: i64,
seq: i64,
},
SidecarSchemaChanged {
table: String,
},
}
impl TableInstanceRisk {
#[must_use]
pub fn note(&self) -> String {
match self {
Self::None => String::new(),
Self::RowidExceedsAutoincHighwater { table, rowid, seq } => format!(
"rowid {rowid} exceeds table {table}'s AUTOINCREMENT high-water mark \
(sqlite_sequence={seq}) — consistent with residue from a prior incarnation of \
this table, but also explainable by an UPDATE to the rowid, a sqlite_sequence \
edit, or a current-instance deletion; the examiner should cross-check the RowID \
and any WAL/journal schema history."
),
Self::SidecarSchemaChanged { table } => format!(
"the schema for table {table} differs between the sidecar's (-wal/-journal) prior \
state and the current database (present-with-different-SQL or absent in the prior) \
— consistent with a CREATE/ALTER or drop+recreate within the captured window; \
residue attributed to {table} may predate the current schema, so reconcile against \
the prior schema. (A same-schema drop+recreate is indistinguishable from a benign \
page move and does NOT raise this.)"
),
}
}
#[must_use]
pub fn token(&self) -> String {
match self {
Self::None => String::new(),
Self::RowidExceedsAutoincHighwater { rowid, seq, .. } => {
format!("rowid_exceeds_autoinc_highwater(r={rowid},seq={seq})")
}
Self::SidecarSchemaChanged { table } => {
format!("sidecar_schema_changed({table})")
}
}
}
}
#[must_use]
pub fn table_instance_risks(
db: &Database,
records: &[CarvedRecord],
attributions: &[Attribution],
) -> Vec<TableInstanceRisk> {
let autoinc: std::collections::BTreeSet<String> = db
.live_tables()
.into_iter()
.filter(|t| sqlite_core::is_autoincrement(&t.create_sql))
.map(|t| t.name)
.collect();
let sequences = db.sqlite_sequence();
records
.iter()
.zip(attributions)
.map(|(rec, attr)| {
let Attribution::Known(table) = attr else {
return TableInstanceRisk::None;
};
if !autoinc.contains(table) {
return TableInstanceRisk::None;
}
match sequences.get(table) {
Some(&seq) if rec.rowid > seq => TableInstanceRisk::RowidExceedsAutoincHighwater {
table: table.clone(),
rowid: rec.rowid,
seq,
},
_ => TableInstanceRisk::None,
}
})
.collect()
}
fn sidecar_schema_changed_tables(
current: &std::collections::BTreeMap<String, String>,
prior_schema: &std::collections::BTreeMap<String, String>,
) -> std::collections::BTreeSet<String> {
if prior_schema.is_empty() {
return std::collections::BTreeSet::new();
}
current
.iter()
.filter(|(name, current_sql)| match prior_schema.get(*name) {
Some(prior_sql) => prior_sql != *current_sql,
None => true,
})
.map(|(name, _)| name.clone())
.collect()
}
#[must_use]
pub fn table_instance_risks_with_sidecar(
db: &Database,
records: &[CarvedRecord],
attributions: &[Attribution],
prior_schema: &std::collections::BTreeMap<String, String>,
) -> Vec<TableInstanceRisk> {
let mut risks = table_instance_risks(db, records, attributions);
let changed = sidecar_schema_changed_tables(&db.schema_sql(), prior_schema);
if changed.is_empty() {
return risks; }
for (risk, attr) in risks.iter_mut().zip(attributions) {
if *risk != TableInstanceRisk::None {
continue;
}
let Attribution::Known(table) = attr else {
continue;
};
if changed.contains(table) {
*risk = TableInstanceRisk::SidecarSchemaChanged {
table: table.clone(),
};
}
}
risks
}
#[must_use]
pub fn row_histories_with_residue(db: &Database) -> Vec<sqlite_core::row_history::TableHistory> {
use sqlite_core::row_history::{RowVersion, VersionOrigin, ViewState};
let mut histories = db.row_histories();
let mut records: Vec<CarvedRecord> = carve_with_fragments(db).full;
if let Some(timeline) = db.wal_timeline() {
for snapshot in timeline.commit_snapshots() {
records.extend(carve_at_commit(db, &timeline, snapshot.id()));
}
}
let records = dedup_keep_best(records, &db.page_to_table_map());
let page_table_map = db.page_to_table_map();
let attributions = attribute_records(db, &records);
let absent_keys: std::collections::HashSet<String> = histories
.iter()
.flat_map(|h| {
h.versions
.iter()
.filter(|v| v.view_state == ViewState::AbsentInFinalView)
.map(move |v| residue_key(&h.table, v.rowid, &v.values))
})
.collect();
let mut to_add: std::collections::BTreeMap<String, Vec<RowVersion>> =
std::collections::BTreeMap::new();
for (rec, attr) in records.iter().zip(attributions.iter()) {
let (table, is_guessed) = if let Some(name) = page_table_map.get(&rec.page) {
(name.clone(), false)
} else {
match attr {
Attribution::Known(name) => (name.clone(), false),
Attribution::Inferred { guess, .. } => (guess.clone(), true),
Attribution::Unattributed => continue,
}
};
let rowid = if rec.rowid == 0 {
None
} else {
Some(rec.rowid)
};
let key = residue_key(&table, rowid, &rec.values);
if absent_keys.contains(&key) {
continue; }
to_add.entry(table).or_default().push(RowVersion {
rowid,
values: rec.values.clone(),
origin: VersionOrigin::CarvedResidue,
commit_seq: None,
view_state: ViewState::CarvedResidue,
is_deleted: true,
is_guessed,
rowid_reused: false,
attribution_uncertain: is_guessed,
reinserted_after_gap: false,
});
}
for h in &mut histories {
if let Some(extra) = to_add.remove(&h.table) {
let existing: std::collections::HashSet<String> = h
.versions
.iter()
.map(|v| residue_key(&h.table, v.rowid, &v.values))
.collect();
for v in extra {
if existing.contains(&residue_key(&h.table, v.rowid, &v.values)) {
continue;
}
h.versions.push(v);
}
sqlite_core::row_history::sort_table_versions(&mut h.versions);
}
}
histories
}
fn residue_key(table: &str, rowid: Option<i64>, values: &[Value]) -> String {
format!("{table}:{rowid:?}:{values:?}")
}
#[cfg(test)]
mod structural_noise_tests {
use super::is_structural_noise;
use sqlite_core::Value;
#[test]
fn rejects_rowid_echo_with_all_null_tail() {
let mut overwide = vec![Value::Null; 102];
overwide[0] = Value::Integer(14);
assert!(is_structural_noise(&overwide));
}
#[test]
fn rejects_all_null_record() {
assert!(is_structural_noise(&[
Value::Null,
Value::Null,
Value::Null
]));
}
#[test]
fn keeps_ordinary_recovered_row() {
let good = vec![
Value::Integer(3),
Value::Text("Bowl".into()),
Value::Real(11.23),
Value::Null,
];
assert!(!is_structural_noise(&good));
}
#[test]
fn keeps_dropped_table_row_wider_than_any_live_table() {
let wide_real = vec![
Value::Integer(7),
Value::Text("secret".into()),
Value::Integer(42),
Value::Text("more".into()),
];
assert!(!is_structural_noise(&wide_real));
}
#[test]
fn keeps_single_column_row() {
assert!(!is_structural_noise(&[Value::Text("solo".into())]));
}
}
#[cfg(test)]
mod attribution_tests {
use super::{
attribute_record, attribute_records, matching_tables, shape_matches, value_fits_affinity,
Attribution, CarvedRecord, RecoverySource,
};
use sqlite_core::attribution::{Affinity, LiveTable};
use sqlite_core::Value;
fn table(name: &str, root: u32, affinities: Vec<Affinity>) -> LiveTable {
LiveTable {
name: name.to_string(),
rootpage: root,
column_names: None,
affinities,
create_sql: String::new(),
}
}
fn rec(page: u32, source: RecoverySource, values: Vec<Value>) -> CarvedRecord {
CarvedRecord {
page,
offset: 0,
rowid: 1,
values,
confidence: 0.9,
allocated: false,
source,
wal: None,
overflow: None,
}
}
#[test]
fn value_affinity_compatibility() {
assert!(value_fits_affinity(&Value::Null, Affinity::Integer));
assert!(value_fits_affinity(&Value::Integer(1), Affinity::Integer));
assert!(value_fits_affinity(&Value::Integer(1), Affinity::Real));
assert!(value_fits_affinity(&Value::Real(1.0), Affinity::Numeric));
assert!(value_fits_affinity(
&Value::Text("x".into()),
Affinity::Text
));
assert!(value_fits_affinity(&Value::Blob(vec![1]), Affinity::Blob));
assert!(!value_fits_affinity(
&Value::Text("x".into()),
Affinity::Integer
));
assert!(!value_fits_affinity(&Value::Blob(vec![1]), Affinity::Text));
assert!(value_fits_affinity(
&Value::Text("x".into()),
Affinity::Blob
));
assert!(value_fits_affinity(&Value::Integer(1), Affinity::Blob));
}
#[test]
fn shape_match_requires_equal_arity_and_compatible_cells() {
let t = table("people", 2, vec![Affinity::Integer, Affinity::Text]);
assert!(shape_matches(
&[Value::Integer(1), Value::Text("a".into())],
&t
));
assert!(!shape_matches(&[Value::Integer(1)], &t));
assert!(!shape_matches(
&[Value::Text("a".into()), Value::Text("b".into())],
&t
));
}
#[test]
fn clean_single_match() {
let tables = vec![
table("people", 2, vec![Affinity::Integer, Affinity::Text]),
table("amounts", 3, vec![Affinity::Real, Affinity::Real]),
];
let m = matching_tables(&[Value::Integer(1), Value::Text("a".into())], &tables);
assert_eq!(m, vec!["people"]);
}
#[test]
fn ambiguous_two_tables_same_shape() {
let tables = vec![
table("a", 2, vec![Affinity::Integer, Affinity::Text]),
table("b", 3, vec![Affinity::Integer, Affinity::Text]),
];
let m = matching_tables(&[Value::Integer(1), Value::Text("x".into())], &tables);
assert_eq!(m, vec!["a", "b"]);
}
#[test]
fn no_match_is_unattributed_shape() {
let tables = vec![table("a", 2, vec![Affinity::Integer, Affinity::Text])];
let m = matching_tables(&[Value::Blob(vec![1]), Value::Blob(vec![2])], &tables);
assert!(m.is_empty());
}
#[test]
fn tier1_inpage_page_in_map_is_known() {
let tables = vec![table("people", 2, vec![Affinity::Integer, Affinity::Text])];
let mut map = std::collections::BTreeMap::new();
map.insert(5u32, "people".to_string());
let r = rec(
5,
RecoverySource::InPageFreeBlock,
vec![Value::Integer(1), Value::Text("a".into())],
);
assert_eq!(
attribute_record(&r, &tables, &map),
Attribution::Known("people".to_string())
);
}
#[test]
fn tier1_freeblock_reconstructed_in_map_is_known() {
let tables = vec![table("people", 2, vec![Affinity::Integer, Affinity::Text])];
let mut map = std::collections::BTreeMap::new();
map.insert(7u32, "people".to_string());
let r = rec(7, RecoverySource::FreeblockReconstructed, vec![Value::Null]);
assert_eq!(
attribute_record(&r, &tables, &map),
Attribution::Known("people".to_string())
);
}
#[test]
fn tier2_freelist_single_match_is_inferred_unambiguous() {
let tables = vec![
table("people", 2, vec![Affinity::Integer, Affinity::Text]),
table("amounts", 3, vec![Affinity::Real, Affinity::Real]),
];
let map = std::collections::BTreeMap::new();
let r = rec(
9,
RecoverySource::FreelistPage,
vec![Value::Integer(1), Value::Text("a".into())],
);
assert_eq!(
attribute_record(&r, &tables, &map),
Attribution::Inferred {
guess: "people".to_string(),
ambiguous: false
}
);
}
#[test]
fn tier2_freelist_multi_match_is_ambiguous() {
let tables = vec![
table("a", 2, vec![Affinity::Integer, Affinity::Text]),
table("b", 3, vec![Affinity::Integer, Affinity::Text]),
];
let map = std::collections::BTreeMap::new();
let r = rec(
9,
RecoverySource::FreelistPage,
vec![Value::Integer(1), Value::Text("x".into())],
);
assert_eq!(
attribute_record(&r, &tables, &map),
Attribution::Inferred {
guess: "a".to_string(),
ambiguous: true
}
);
}
#[test]
fn tier2_freelist_no_match_is_unattributed() {
let tables = vec![table("a", 2, vec![Affinity::Integer, Affinity::Text])];
let map = std::collections::BTreeMap::new();
let r = rec(
9,
RecoverySource::FreelistPage,
vec![Value::Blob(vec![1]), Value::Blob(vec![2])],
);
assert_eq!(
attribute_record(&r, &tables, &map),
Attribution::Unattributed
);
}
#[test]
fn tier3_dropped_table_is_unattributed() {
let tables = vec![table("a", 2, vec![Affinity::Integer, Affinity::Text])];
let map = std::collections::BTreeMap::new();
let r = rec(
9,
RecoverySource::DroppedTable,
vec![Value::Integer(1), Value::Text("x".into())],
);
assert_eq!(
attribute_record(&r, &tables, &map),
Attribution::Unattributed
);
}
#[test]
fn tier1_inpage_page_not_in_map_falls_through_to_unattributed() {
let tables = vec![table("people", 2, vec![Affinity::Integer, Affinity::Text])];
let map = std::collections::BTreeMap::new();
let r = rec(
42,
RecoverySource::InPageFreeBlock,
vec![Value::Integer(1), Value::Text("a".into())],
);
assert_eq!(
attribute_record(&r, &tables, &map),
Attribution::Unattributed
);
}
#[test]
fn attribute_records_reads_schema_from_a_real_database() {
use sqlite_core::rebuild::{build_recovered_db_tables, RecoveredTable};
use sqlite_core::Database;
let seed = vec![RecoveredTable {
name: "people".to_string(),
columns: vec!["id".to_string(), "name".to_string()],
rows: vec![vec![Value::Integer(1), Value::Text("a".into())]],
}];
let db = Database::open(build_recovered_db_tables(&seed)).expect("minted db opens");
let records = vec![super::CarvedRecord {
page: 99,
offset: 0,
rowid: 0,
values: vec![Value::Integer(2), Value::Text("b".into())],
confidence: 0.9,
allocated: false,
source: RecoverySource::FreelistPage,
wal: None,
overflow: None,
}];
let attrs = attribute_records(&db, &records);
assert_eq!(attrs.len(), 1);
assert!(matches!(attrs[0], Attribution::Inferred { .. }));
}
}
#[cfg(test)]
mod tests {
use super::{dedup_fragments, CarvedFragment, RecoverySource};
use sqlite_core::Value;
fn frag(page: u32, offset: usize, surviving: Vec<(usize, Value)>) -> CarvedFragment {
let missing = 3usize.saturating_sub(surviving.len());
CarvedFragment {
page,
offset,
surviving,
missing,
confidence: 0.2,
source: RecoverySource::InPageFreeBlock,
wal: None,
}
}
#[test]
fn dedup_keeps_richest_and_drops_duplicates() {
let rich = frag(
1,
10,
vec![(0, Value::Integer(1)), (1, Value::Text("a".into()))],
);
let poor_same_anchor = frag(1, 10, vec![(0, Value::Integer(1))]);
let value_dup_new_anchor = frag(
2,
20,
vec![(0, Value::Integer(1)), (1, Value::Text("a".into()))],
);
let out = dedup_fragments(vec![poor_same_anchor, rich, value_dup_new_anchor]);
assert_eq!(
out.len(),
1,
"duplicates collapse to the richest single copy"
);
assert_eq!(
out[0].surviving.len(),
2,
"the 2-column copy is the one kept"
);
}
}