#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]
use forensicnomicon::report::{
Confidence, Evidence, Finding, Location, Observation, Severity, Source,
};
use sqlite_core::{CommitId, Database, Value, WalTimeline};
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum AnomalyKind {
NonZeroReservedSpace {
reserved: u8,
},
DeletedRecordRecovered {
page: u32,
offset: usize,
rowid: i64,
},
NonEmptyFreelist {
free_pages: u32,
},
WalUncheckpointedState {
overlaid_pages: u32,
},
PageCountMismatch {
header_pages: u32,
file_pages: u32,
},
}
impl AnomalyKind {
#[must_use]
pub fn severity(&self) -> Severity {
match self {
AnomalyKind::NonZeroReservedSpace { .. } | AnomalyKind::NonEmptyFreelist { .. } => {
Severity::Low
}
AnomalyKind::DeletedRecordRecovered { .. }
| AnomalyKind::WalUncheckpointedState { .. } => Severity::Medium,
AnomalyKind::PageCountMismatch { .. } => Severity::High,
}
}
#[must_use]
pub fn code(&self) -> &'static str {
match self {
AnomalyKind::NonZeroReservedSpace { .. } => "SQLITE-RESERVED-SPACE-NONZERO",
AnomalyKind::DeletedRecordRecovered { .. } => "SQLITE-DELETED-RECORD-RECOVERED",
AnomalyKind::NonEmptyFreelist { .. } => "SQLITE-FREELIST-NONEMPTY",
AnomalyKind::WalUncheckpointedState { .. } => "SQLITE-WAL-UNCHECKPOINTED",
AnomalyKind::PageCountMismatch { .. } => "SQLITE-PAGECOUNT-MISMATCH",
}
}
#[must_use]
pub fn note(&self) -> String {
match self {
AnomalyKind::NonZeroReservedSpace { reserved } => format!(
"file header reserves {reserved} byte(s) per page — non-standard; \
consistent with a page-level extension such as encryption \
(SQLCipher/SEE) or a checksum 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::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"
),
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"
),
}
}
}
#[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::NonEmptyFreelist { .. } => {
Category::Residue
}
AnomalyKind::WalUncheckpointedState { .. } | AnomalyKind::PageCountMismatch { .. } => {
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::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::NonZeroReservedSpace { .. } => Vec::new(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum RecoverySource {
FreelistPage,
InPageFreeBlock,
DroppedTable,
PriorVersion,
FreeblockReconstructed,
WalFrame,
CommitSnapshot,
}
#[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
}
#[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,
});
}
}
let live = db.live_rows();
let live_value_keys: std::collections::HashSet<String> = live
.values()
.chain(db.live_schema_rows().iter())
.map(|v| format!("{v:?}"))
.collect();
out.retain_mut(|rec| {
if rec.source == RecoverySource::FreeblockReconstructed {
return !live_value_keys.contains(&format!("{:?}", rec.values));
}
if rec.source == RecoverySource::WalFrame {
return !live_value_keys.contains(&format!("{:?}", rec.values));
}
match live.get(&rec.rowid) {
None => true,
Some(live_values) => {
if &rec.values == live_values {
false
} else {
rec.source = RecoverySource::PriorVersion;
true
}
}
}
});
dedup_keep_best(out)
}
#[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 = db.live_rows();
fragments.retain(|frag| !live.values().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(|a, b| b.surviving.len().cmp(&a.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 = db.live_rows();
let live_value_keys: std::collections::HashSet<String> = live
.values()
.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)
}
fn dedup_keep_best(mut records: Vec<CarvedRecord>) -> Vec<CarvedRecord> {
use std::collections::HashSet;
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 key = format!("{}:{:?}", rec.rowid, rec.values);
if seen.insert(key) {
kept.push(rec);
}
}
kept
}
#[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 }));
}
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,
}));
}
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()
}
#[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()
}