#![allow(clippy::unwrap_used, clippy::expect_used, clippy::pedantic)]
use std::path::{Path, PathBuf};
use forensicnomicon::report::{Observation, Source};
use sqlite_core::Database;
use sqlite_forensic::{audit_journal, audit_journal_findings};
fn cfreds(name: &str) -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../tests/data/cfreds")
.join(name)
}
const JOURNAL_MAGIC: [u8; 8] = [0xd9, 0xd5, 0x05, 0xf9, 0x20, 0xa1, 0x63, 0xd7];
fn journal_cksum(nonce: u32, page: &[u8]) -> u32 {
let mut sum = nonce;
let mut x = page.len() as i64 - 200;
while x > 0 {
if let Some(&b) = page.get(x as usize) {
sum = sum.wrapping_add(u32::from(b));
}
x -= 200;
}
sum
}
fn build_tier_a_journal(
page_size: usize,
nonce: u32,
mx_page: u32,
records: &[(u32, Vec<u8>, bool)],
) -> Vec<u8> {
let sector_size: u32 = 512;
let mut j = vec![0u8; sector_size as usize];
j[0..8].copy_from_slice(&JOURNAL_MAGIC);
j[8..12].copy_from_slice(&(records.len() as u32).to_be_bytes());
j[12..16].copy_from_slice(&nonce.to_be_bytes());
j[16..20].copy_from_slice(&mx_page.to_be_bytes());
j[20..24].copy_from_slice(§or_size.to_be_bytes());
j[24..28].copy_from_slice(&(page_size as u32).to_be_bytes());
for (pgno, page, good) in records {
assert_eq!(page.len(), page_size, "page must be page_size bytes");
j.extend_from_slice(&pgno.to_be_bytes());
j.extend_from_slice(page);
let mut ck = journal_cksum(nonce, page);
if !good {
ck = ck.wrapping_add(1); }
j.extend_from_slice(&ck.to_be_bytes());
}
j
}
fn tiny_db_512(pages: u32) -> Database {
let ps = 512usize;
let mut bytes = vec![0u8; ps * pages as usize];
bytes[0..16].copy_from_slice(b"SQLite format 3\0");
bytes[16..18].copy_from_slice(&512u16.to_be_bytes()); bytes[18] = 1; bytes[19] = 1; bytes[21] = 64; bytes[22] = 32; bytes[23] = 32; bytes[28..32].copy_from_slice(&pages.to_be_bytes()); bytes[44..48].copy_from_slice(&1u32.to_be_bytes()); bytes[48..52].copy_from_slice(&0u32.to_be_bytes());
bytes[56..60].copy_from_slice(&1u32.to_be_bytes()); bytes[100] = 0x0d; bytes[103..105].copy_from_slice(&0u16.to_be_bytes()); bytes[105..107].copy_from_slice(&512u16.to_be_bytes()); Database::open(bytes).expect("tiny 512 db opens")
}
fn codes_of(db: &Database, journal: &[u8]) -> Vec<&'static str> {
audit_journal(db, journal).iter().map(|a| a.code).collect()
}
#[test]
fn persist_artifact_triggers_recoverable_only() {
for platform in ["ios", "android"] {
let main = std::fs::read(cfreds(&format!("SFT-03_PERSIST_{platform}.sqlite"))).unwrap();
let journal =
std::fs::read(cfreds(&format!("SFT-03_PERSIST_{platform}.sqlite-journal"))).unwrap();
let db = Database::open(main).expect("open main");
let anomalies = audit_journal(&db, &journal);
let codes: Vec<&str> = anomalies.iter().map(|a| a.code).collect();
assert!(
codes.contains(&"SQLITE-JOURNAL-RECOVERABLE"),
"{platform}: PERSIST post-commit journal with page images is recoverable; got {codes:?}"
);
assert!(
!codes.contains(&"SQLITE-JOURNAL-SCHEMA-CHANGE"),
"{platform}: DML-only PERSIST (cookie unchanged) must NOT raise SCHEMA-CHANGE; got {codes:?}"
);
assert!(
!codes.contains(&"SQLITE-JOURNAL-HOT"),
"{platform}: zeroed header is NOT a hot journal"
);
assert!(!codes.contains(&"SQLITE-JOURNAL-CHECKSUM-MISMATCH"));
assert!(!codes.contains(&"SQLITE-JOURNAL-DUPLICATE-PAGE"));
assert!(!codes.contains(&"SQLITE-JOURNAL-DBSIZE-DELTA"));
let recoverable = anomalies
.iter()
.find(|a| a.code == "SQLITE-JOURNAL-RECOVERABLE")
.unwrap();
assert!(
recoverable.note.contains("14"),
"{platform}: recoverable note shows image count; note = {:?}",
recoverable.note
);
}
}
#[test]
fn tier_a_journal_triggers_hot_checksum_duplicate_dbsize_shrink() {
let db = tiny_db_512(1); let ps = 512usize;
let page = vec![0xABu8; ps];
let records = vec![
(1u32, page.clone(), true),
(2u32, page.clone(), false),
(2u32, page.clone(), true),
];
let journal = build_tier_a_journal(ps, 0x1234_5678, 9, &records);
let anomalies = audit_journal(&db, &journal);
let codes: Vec<&str> = anomalies.iter().map(|a| a.code).collect();
assert!(
codes.contains(&"SQLITE-JOURNAL-HOT"),
"valid-magic header is a hot journal; got {codes:?}"
);
assert!(
codes.contains(&"SQLITE-JOURNAL-CHECKSUM-MISMATCH"),
"one record has a deliberately wrong checksum; got {codes:?}"
);
assert!(
codes.contains(&"SQLITE-JOURNAL-DUPLICATE-PAGE"),
"pgno 2 appears twice; got {codes:?}"
);
assert!(
codes.contains(&"SQLITE-JOURNAL-DBSIZE-DELTA"),
"mx_page (9) != db.page_count (1); got {codes:?}"
);
let cksum = anomalies
.iter()
.find(|a| a.code == "SQLITE-JOURNAL-CHECKSUM-MISMATCH")
.unwrap();
assert!(
cksum.note.contains('2'),
"checksum note shows offending pgno; note = {:?}",
cksum.note
);
let dbsize = anomalies
.iter()
.find(|a| a.code == "SQLITE-JOURNAL-DBSIZE-DELTA")
.unwrap();
assert!(
dbsize.note.contains('9') && dbsize.note.contains('1'),
"dbsize note shows mx_page and current; note = {:?}",
dbsize.note
);
assert!(
dbsize.note.contains("vacuum") || dbsize.note.contains("truncation"),
"shrink (mx_page > current) names vacuum/truncation; note = {:?}",
dbsize.note
);
let ck_ev = cksum.evidence();
assert!(
ck_ev
.iter()
.any(|e| e.field == "checksum_mismatch_pgno" && e.value == "2"),
"checksum evidence names the offending pgno; evidence = {ck_ev:?}"
);
assert!(
ck_ev.iter().any(|e| e.field == "page_records"),
"checksum evidence carries the record-count denominator; evidence = {ck_ev:?}"
);
let db_ev = dbsize.evidence();
assert!(
db_ev.iter().any(|e| e.field == "mx_page" && e.value == "9"),
"size-delta evidence carries mx_page; evidence = {db_ev:?}"
);
assert!(
db_ev
.iter()
.any(|e| e.field == "current_pages" && e.value == "1"),
"size-delta evidence carries the current page count; evidence = {db_ev:?}"
);
}
#[test]
fn tier_a_growth_reports_dbsize_delta_growth() {
let db = tiny_db_512(3); let ps = 512usize;
let page = vec![0u8; ps];
let journal = build_tier_a_journal(ps, 1, 1, &[(2u32, page, true)]);
let anomalies = audit_journal(&db, &journal);
let dbsize = anomalies
.iter()
.find(|a| a.code == "SQLITE-JOURNAL-DBSIZE-DELTA")
.expect("mx_page (1) < page_count (3) → growth delta");
assert!(
dbsize.note.contains('1') && dbsize.note.contains('3'),
"growth note shows both numbers; note = {:?}",
dbsize.note
);
assert!(
dbsize.note.contains("grew")
|| dbsize.note.contains("growth")
|| dbsize.note.contains("INSERT"),
"growth (mx_page < current) names growth/INSERT; note = {:?}",
dbsize.note
);
}
#[test]
fn tier_a_no_delta_when_size_unchanged_or_unknown() {
let db = tiny_db_512(1); let ps = 512usize;
let page = vec![0u8; ps];
let equal = build_tier_a_journal(ps, 1, 1, &[(2u32, page.clone(), true)]);
assert!(
!codes_of(&db, &equal).contains(&"SQLITE-JOURNAL-DBSIZE-DELTA"),
"mx_page == current → no size delta"
);
let zero = build_tier_a_journal(ps, 1, 0, &[(2u32, page, true)]);
assert!(
!codes_of(&db, &zero).contains(&"SQLITE-JOURNAL-DBSIZE-DELTA"),
"mx_page == 0 sentinel → no size delta"
);
}
#[test]
fn journal_findings_carry_source_and_code() {
let main = std::fs::read(cfreds("SFT-03_PERSIST_ios.sqlite")).unwrap();
let journal = std::fs::read(cfreds("SFT-03_PERSIST_ios.sqlite-journal")).unwrap();
let db = Database::open(main).expect("open main");
let source = Source {
analyzer: "sqlite-forensic".to_string(),
scope: "SFT-03_PERSIST_ios.sqlite-journal".to_string(),
version: None,
};
let findings = audit_journal_findings(&db, &journal, &source);
let codes: Vec<String> = findings.iter().map(|f| f.code.to_string()).collect();
assert!(codes.iter().any(|c| c == "SQLITE-JOURNAL-RECOVERABLE"));
assert!(findings
.iter()
.all(|f| f.source.analyzer == "sqlite-forensic"));
}
#[test]
fn malformed_journal_yields_no_anomalies() {
let db = tiny_db_512(1);
let anomalies = audit_journal(&db, &[0xFFu8; 16]);
assert!(
anomalies.is_empty(),
"garbage journal yields no anomalies; got {anomalies:?}"
);
}