#![allow(clippy::unwrap_used, clippy::expect_used, clippy::pedantic)]
use std::path::{Path, PathBuf};
use forensicnomicon::report::Source;
use sqlite_core::Database;
use sqlite_forensic::{audit_journal, audit_journal_findings};
fn journal_dir(name: &str) -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../tests/data/journal")
.join(name)
}
fn cfreds(name: &str) -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../tests/data/cfreds")
.join(name)
}
fn codes_of(db: &Database, journal: &[u8]) -> Vec<&'static str> {
audit_journal(db, journal).iter().map(|a| a.code).collect()
}
const MX_PAGE_OFFSET: usize = 16;
#[test]
fn real_hot_journal_fires_hot_but_not_schema_change() {
let db = Database::open(std::fs::read(journal_dir("hot.db")).unwrap()).expect("open hot.db");
let journal = std::fs::read(journal_dir("hot.db-journal")).unwrap();
let codes = codes_of(&db, &journal);
assert!(
codes.contains(&"SQLITE-JOURNAL-HOT"),
"real valid-magic journal is a hot journal; got {codes:?}"
);
assert!(
!codes.contains(&"SQLITE-JOURNAL-SCHEMA-CHANGE"),
"DML-only hot journal must NOT raise SCHEMA-CHANGE (cookie unchanged); got {codes:?}"
);
}
#[test]
fn real_hot_journal_checksums_all_verify() {
use sqlite_core::RollbackJournal;
let db = Database::open(std::fs::read(journal_dir("hot.db")).unwrap()).expect("open hot.db");
let journal = std::fs::read(journal_dir("hot.db-journal")).unwrap();
let parsed =
RollbackJournal::parse(&journal, db.header().page_size).expect("parse hot journal");
let images = parsed.page_images();
let good = images
.iter()
.filter(|i| i.checksum_valid == Some(true))
.count();
let bad = images
.iter()
.filter(|i| i.checksum_valid == Some(false))
.count();
assert!(
good >= 1,
"real hot journal has >=1 checksum-valid page image; good={good}"
);
assert_eq!(
bad, 0,
"real hot journal has ZERO checksum-invalid page images; bad={bad}"
);
}
#[test]
fn real_ddl_persist_journal_fires_schema_change() {
let db = Database::open(std::fs::read(journal_dir("ddl_persist.db")).unwrap())
.expect("open ddl_persist.db");
let journal = std::fs::read(journal_dir("ddl_persist.db-journal")).unwrap();
let anomalies = audit_journal(&db, &journal);
let codes: Vec<&str> = anomalies.iter().map(|a| a.code).collect();
assert!(
codes.contains(&"SQLITE-JOURNAL-SCHEMA-CHANGE"),
"committed-DDL PERSIST journal: cookie advanced (1 -> 2), SCHEMA-CHANGE must fire; got {codes:?}"
);
let sc = anomalies
.iter()
.find(|a| a.code == "SQLITE-JOURNAL-SCHEMA-CHANGE")
.unwrap();
assert!(
sc.note.contains('1') && sc.note.contains('2'),
"SCHEMA-CHANGE note shows both cookie values; note = {:?}",
sc.note
);
}
#[test]
fn nist_persist_does_not_fire_schema_change() {
for platform in ["ios", "android"] {
let db = Database::open(
std::fs::read(cfreds(&format!("SFT-03_PERSIST_{platform}.sqlite"))).unwrap(),
)
.expect("open main");
let journal =
std::fs::read(cfreds(&format!("SFT-03_PERSIST_{platform}.sqlite-journal"))).unwrap();
let codes = codes_of(&db, &journal);
assert!(
!codes.contains(&"SQLITE-JOURNAL-SCHEMA-CHANGE"),
"{platform}: NIST PERSIST is DML-only (cookie unchanged); SCHEMA-CHANGE must NOT fire; got {codes:?}"
);
assert!(
codes.contains(&"SQLITE-JOURNAL-RECOVERABLE"),
"{platform}: PERSIST post-commit journal carries recoverable images; got {codes:?}"
);
}
}
#[test]
fn real_hot_journal_byte_flip_fires_checksum_mismatch() {
let db = Database::open(std::fs::read(journal_dir("hot.db")).unwrap()).expect("open hot.db");
let mut journal = std::fs::read(journal_dir("hot.db-journal")).unwrap();
let ps = db.header().page_size as usize;
let sector = 512usize; let flip_at = sector + 4 + (ps - 200);
journal[flip_at] ^= 0xFF;
let anomalies = audit_journal(&db, &journal);
let cksum = anomalies
.iter()
.find(|a| a.code == "SQLITE-JOURNAL-CHECKSUM-MISMATCH")
.expect("a flipped payload byte breaks the page checksum");
assert!(
cksum.note.contains('3'),
"checksum-mismatch note names the affected page; note = {:?}",
cksum.note
);
}
#[test]
fn real_hot_journal_appended_duplicate_fires_duplicate_page() {
let db = Database::open(std::fs::read(journal_dir("hot.db")).unwrap()).expect("open hot.db");
let original = std::fs::read(journal_dir("hot.db-journal")).unwrap();
let ps = db.header().page_size as usize;
let sector = 512usize;
let rec_len = 4 + ps + 4;
let first_record = original[sector..sector + rec_len].to_vec();
let n_rec = u32::from_be_bytes([original[8], original[9], original[10], original[11]]);
let next_slot = sector + (n_rec as usize) * rec_len;
let mut journal = original[..next_slot.min(original.len())].to_vec();
journal[8..12].copy_from_slice(&(n_rec + 1).to_be_bytes());
journal.extend_from_slice(&first_record);
let codes = codes_of(&db, &journal);
assert!(
codes.contains(&"SQLITE-JOURNAL-DUPLICATE-PAGE"),
"an appended duplicate real page record raises DUPLICATE-PAGE; got {codes:?}"
);
}
#[test]
fn real_hot_journal_duplicate_page_finding_names_the_page() {
let db = Database::open(std::fs::read(journal_dir("hot.db")).unwrap()).expect("open hot.db");
let original = std::fs::read(journal_dir("hot.db-journal")).unwrap();
let ps = db.header().page_size as usize;
let sector = 512usize;
let rec_len = 4 + ps + 4;
let first_record = original[sector..sector + rec_len].to_vec();
let dup_pgno = u32::from_be_bytes([
first_record[0],
first_record[1],
first_record[2],
first_record[3],
]);
let n_rec = u32::from_be_bytes([original[8], original[9], original[10], original[11]]);
let next_slot = sector + (n_rec as usize) * rec_len;
let mut journal = original[..next_slot.min(original.len())].to_vec();
journal[8..12].copy_from_slice(&(n_rec + 1).to_be_bytes());
journal.extend_from_slice(&first_record);
let source = Source {
analyzer: "sqlite-forensic".to_string(),
scope: "hot.db".to_string(),
version: None,
};
let findings = audit_journal_findings(&db, &journal, &source);
let dup = findings
.iter()
.find(|f| f.code == "SQLITE-JOURNAL-DUPLICATE-PAGE")
.expect("DUPLICATE-PAGE finding present");
assert!(
!dup.evidence.is_empty(),
"DUPLICATE-PAGE must carry the offending page number, not be evidence-less"
);
assert!(
dup.evidence.iter().any(|e| e.value == dup_pgno.to_string()),
"evidence must include the duplicated pgno {dup_pgno}: {:?}",
dup.evidence
);
}
#[test]
fn real_hot_journal_finding_carries_scope_evidence() {
let db = Database::open(std::fs::read(journal_dir("hot.db")).unwrap()).expect("open hot.db");
let journal = std::fs::read(journal_dir("hot.db-journal")).unwrap();
let source = Source {
analyzer: "sqlite-forensic".to_string(),
scope: "hot.db".to_string(),
version: None,
};
let findings = audit_journal_findings(&db, &journal, &source);
let hot = findings
.iter()
.find(|f| f.code == "SQLITE-JOURNAL-HOT")
.expect("HOT-JOURNAL finding present");
assert!(
!hot.evidence.is_empty(),
"HOT-JOURNAL must carry the journal's scope as evidence, not be evidence-less"
);
assert!(
hot.evidence
.iter()
.any(|e| e.field == "journaled_pages" && e.value == "5"),
"evidence must include the journaled-page count (5): {:?}",
hot.evidence
);
}
#[test]
fn real_hot_journal_edited_mxpage_fires_dbsize_delta() {
let db = Database::open(std::fs::read(journal_dir("hot.db")).unwrap()).expect("open hot.db");
let mut journal = std::fs::read(journal_dir("hot.db-journal")).unwrap();
let current = db.page_count();
let forged = current + 7;
journal[MX_PAGE_OFFSET..MX_PAGE_OFFSET + 4].copy_from_slice(&forged.to_be_bytes());
let anomalies = audit_journal(&db, &journal);
let dbsize = anomalies
.iter()
.find(|a| a.code == "SQLITE-JOURNAL-DBSIZE-DELTA")
.expect("forged mx_page != current page count → DBSIZE-DELTA");
assert!(
dbsize.note.contains(&forged.to_string()) && dbsize.note.contains(¤t.to_string()),
"dbsize note shows forged mx_page ({forged}) and current ({current}); note = {:?}",
dbsize.note
);
}