#![allow(clippy::unwrap_used, clippy::expect_used)]
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::process::Command;
use sqlite_core::{Database, Value};
use sqlite_forensic::carve_all_deleted_records;
type RowSet = BTreeMap<i64, (String, String)>;
fn undark_bin() -> Option<PathBuf> {
std::env::var_os("UNDARK_BIN").map(PathBuf::from)
}
fn fqlite_tap() -> Option<PathBuf> {
std::env::var_os("FQLITE_TAP").map(PathBuf::from)
}
fn fqlite_recover(tap: &Path, db: &Path) -> BTreeMap<String, (i64, String, String)> {
let out = Command::new(tap)
.arg(db)
.output()
.expect("fqlite tap must execute");
let text = String::from_utf8_lossy(&out.stdout);
let mut set = BTreeMap::new();
for line in text.lines() {
if line.trim().is_empty() {
continue;
}
let fields = split_csv(line);
let rowid = fields
.first()
.and_then(|f| f.trim().parse::<i64>().ok())
.unwrap_or(-1);
let url = fields.get(1).map(|s| unquote(s)).unwrap_or_default();
let title = fields.get(2).map(|s| unquote(s)).unwrap_or_default();
if url.is_empty() {
continue;
}
set.entry(url.clone())
.and_modify(|e: &mut (i64, String, String)| {
if e.0 == -1 && rowid != -1 {
e.0 = rowid;
}
})
.or_insert((rowid, url, title));
}
set
}
fn ours_recover_by_url(db: &Database, _cols: usize) -> BTreeMap<String, (i64, String, String)> {
let mut set = BTreeMap::new();
for rec in carve_all_deleted_records(db) {
let url = match rec.values.get(1) {
Some(Value::Text(s)) => s.clone(),
_ => String::new(),
};
let title = match rec.values.get(2) {
Some(Value::Text(s)) => s.clone(),
_ => String::new(),
};
if url.is_empty() {
continue;
}
set.insert(url.clone(), (rec.rowid, url, title));
}
set
}
fn undark_recover(undark: &Path, db: &Path) -> RowSet {
let out = Command::new(undark)
.arg("-i")
.arg(db)
.output()
.expect("undark must execute");
let text = String::from_utf8_lossy(&out.stdout);
let mut set = RowSet::new();
for line in text.lines() {
if line.trim().is_empty() {
continue;
}
let fields = split_csv(line);
let Some(rowid) = fields.first().and_then(|f| f.parse::<i64>().ok()) else {
continue;
};
let c1 = fields.get(2).cloned().unwrap_or_default();
let c2 = fields.get(3).cloned().unwrap_or_default();
set.insert(rowid, (unquote(&c1), unquote(&c2)));
}
set
}
fn split_csv(line: &str) -> Vec<String> {
let mut fields = Vec::new();
let mut cur = String::new();
let mut in_q = false;
for ch in line.chars() {
match ch {
'"' => in_q = !in_q,
',' if !in_q => {
fields.push(std::mem::take(&mut cur));
}
_ => cur.push(ch),
}
}
fields.push(cur);
fields
}
fn unquote(s: &str) -> String {
s.trim().trim_matches('"').to_string()
}
fn ours_recover(db: &Database, _cols: usize) -> RowSet {
let mut set = RowSet::new();
for rec in carve_all_deleted_records(db) {
let t1 = match rec.values.get(1) {
Some(Value::Text(s)) => s.clone(),
_ => String::new(),
};
let t2 = match rec.values.get(2) {
Some(Value::Text(s)) => s.clone(),
_ => String::new(),
};
set.insert(rec.rowid, (t1, t2));
}
set
}
fn corpus_db(name: &str) -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../tests-oracle-corpus/dc3-sqlite-dissect")
.join(name)
}
#[test]
fn our_fixture_agrees_with_undark() {
let Some(undark) = undark_bin() else {
eprintln!("SKIP our_fixture_agrees_with_undark: set UNDARK_BIN to the undark binary");
return;
};
let db_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("../tests/data/deleted_places.db");
let bytes = std::fs::read(&db_path).unwrap();
let db = Database::open(bytes).unwrap();
let ours = ours_recover(&db, 6);
let oracle = undark_recover(&undark, &db_path);
let in_del = |k: &i64| (201..=400).contains(k);
let oracle_deleted: RowSet = oracle
.iter()
.filter(|(k, _)| in_del(k))
.map(|(k, v)| (*k, v.clone()))
.collect();
let ours_deleted: RowSet = ours
.iter()
.filter(|(k, _)| in_del(k))
.map(|(k, v)| (*k, v.clone()))
.collect();
for rowid in ours_deleted.keys() {
assert!(
oracle_deleted.contains_key(rowid),
"carved rowid {rowid} is not corroborated by the undark oracle (possible false positive)"
);
}
for (rowid, ours_val) in &ours_deleted {
let oracle_val = &oracle_deleted[rowid];
assert_eq!(
ours_val, oracle_val,
"content mismatch for rowid {rowid}: ours {ours_val:?} vs undark {oracle_val:?}"
);
}
assert_eq!(
ours_deleted.keys().collect::<Vec<_>>(),
oracle_deleted.keys().collect::<Vec<_>>(),
"ours and undark must recover the identical deleted-row set post-#49"
);
assert_eq!(
ours_deleted.get(&300),
Some(&(
"https://site-300.example.com/path/page".to_string(),
"Title for record number 300 SECRETMARKER".to_string()
)),
"row 300 must be recovered verbatim and agree with undark"
);
}
const FQLITE_IN_PAGE_DIVERGENCES: &[u32] = &[235];
#[test]
fn our_fixture_agrees_with_fqlite() {
let Some(tap) = fqlite_tap() else {
eprintln!("SKIP our_fixture_agrees_with_fqlite: set FQLITE_TAP to tools/fqlite/run-tap.sh");
return;
};
let db_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("../tests/data/deleted_places.db");
let db = Database::open(std::fs::read(&db_path).unwrap()).unwrap();
let ours = ours_recover_by_url(&db, 6);
let oracle = fqlite_recover(&tap, &db_path);
let site_id = |url: &str| -> Option<u32> {
url.strip_prefix("https://site-")
.and_then(|s| s.split('.').next())
.and_then(|n| n.parse::<u32>().ok())
.filter(|n| (201..=400).contains(n))
};
let ours_del: BTreeMap<u32, &(i64, String, String)> = ours
.iter()
.filter_map(|(u, v)| site_id(u).map(|n| (n, v)))
.collect();
let oracle_del: BTreeMap<u32, &(i64, String, String)> = oracle
.iter()
.filter_map(|(u, v)| site_id(u).map(|n| (n, v)))
.collect();
let in_page: std::collections::BTreeSet<u32> =
FQLITE_IN_PAGE_DIVERGENCES.iter().copied().collect();
for (n, ours_val) in &ours_del {
if let Some(oracle_val) = oracle_del.get(n) {
assert_eq!(
(&ours_val.1, &ours_val.2),
(&oracle_val.1, &oracle_val.2),
"site-{n}: content mismatch ours {ours_val:?} vs fqlite {oracle_val:?}"
);
}
}
let mut fqlite_only: Vec<u32> = oracle_del
.keys()
.filter(|n| !ours_del.contains_key(n) && !in_page.contains(n))
.copied()
.collect();
fqlite_only.sort_unstable();
assert!(
fqlite_only.is_empty(),
"fqlite recovered deleted rows we miss with no documented reason: {fqlite_only:?}"
);
assert!(
ours_del.contains_key(&237),
"post-#49 our carver must recover the in-page remnant site-237"
);
if let Some(v) = oracle_del.get(&300) {
assert_eq!(
(&v.1, &v.2),
(
&"https://site-300.example.com/path/page".to_string(),
&"Title for record number 300 SECRETMARKER".to_string()
),
"row 300 content must agree with fqlite"
);
}
}
#[derive(Clone, Copy, PartialEq)]
enum Dc3Class {
DroppedTable,
NoGenuineDeletion,
}
#[test]
fn dc3_corpus_agrees_with_undark() {
let Some(undark) = undark_bin() else {
eprintln!("SKIP dc3_corpus_agrees_with_undark: set UNDARK_BIN to the undark binary");
return;
};
struct Case {
name: &'static str,
class: Dc3Class,
}
let cases = [
Case {
name: "corpus_01-01.db",
class: Dc3Class::NoGenuineDeletion,
},
Case {
name: "corpus_01-02.db",
class: Dc3Class::NoGenuineDeletion,
},
Case {
name: "corpus_03-02.db",
class: Dc3Class::NoGenuineDeletion,
},
Case {
name: "corpus_07-01.db",
class: Dc3Class::NoGenuineDeletion,
},
Case {
name: "corpus_0A-01.db",
class: Dc3Class::DroppedTable,
},
Case {
name: "corpus_0A-02.db",
class: Dc3Class::DroppedTable,
},
];
let mut ran = 0usize;
for case in &cases {
let path = corpus_db(case.name);
if !path.exists() {
eprintln!(
"SKIP {}: DC3 corpus DB absent (gitignored — see tests-oracle-corpus/README.md)",
case.name
);
continue;
}
ran += 1;
let name = case.name;
let db = Database::open(std::fs::read(&path).unwrap()).unwrap();
let ours = ours_recover(&db, 0);
let oracle = undark_recover(&undark, &path);
match case.class {
Dc3Class::DroppedTable => {
let ours_data_rows = ours.len();
assert!(
ours_data_rows >= oracle.len(),
"{name}: dropped-table recovery must match or exceed undark \
(ours {ours_data_rows} vs undark {})",
oracle.len()
);
}
Dc3Class::NoGenuineDeletion => {
assert!(
ours.len() < oracle.len(),
"{name}: we must not over-report like undark (ours {} vs undark {})",
ours.len(),
oracle.len()
);
}
}
}
assert!(ran > 0, "no DC3 corpus DB was available to test");
}
fn tool_marker_lines(out: &str, marker: &str) -> usize {
out.lines().filter(|l| l.contains(marker)).count()
}
#[test]
fn prior_version_reconciled_with_oracles() {
let db_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("../tests/data/updated_messages.db");
let db = Database::open(std::fs::read(&db_path).unwrap()).unwrap();
let carved = carve_all_deleted_records(&db);
let prior_count = carved
.iter()
.filter(
|c| matches!(c.values.get(2), Some(Value::Text(t)) if t.starts_with("PRIORVERSION")),
)
.count();
assert_eq!(
prior_count, 1,
"ours must recover the single genuine prior version"
);
assert!(
!carved
.iter()
.any(|c| matches!(c.values.get(2), Some(Value::Text(t)) if t.starts_with("EDITED"))),
"ours must NOT re-surface the live (edited) row — 0 false positives"
);
if let Some(undark) = undark_bin() {
let out = Command::new(&undark)
.arg("-i")
.arg(&db_path)
.output()
.expect("undark must execute");
let text = String::from_utf8_lossy(&out.stdout);
assert_eq!(
tool_marker_lines(&text, "PRIORVERSION"),
0,
"undark does not recover the prior version from this fixture (we exceed it)"
);
} else {
eprintln!("SKIP undark leg: set UNDARK_BIN");
}
if let Some(tap) = fqlite_tap() {
let out = Command::new(&tap)
.arg(&db_path)
.output()
.expect("fqlite tap must execute");
let text = String::from_utf8_lossy(&out.stdout);
assert!(
tool_marker_lines(&text, "PRIORVERSION") >= 1,
"fqlite must also recover the genuine prior version (we match it)"
);
} else {
eprintln!("SKIP fqlite leg: set FQLITE_TAP");
}
}
fn sqlite3_bin() -> Option<String> {
let bin = std::env::var("SQLITE3_BIN").unwrap_or_else(|_| "sqlite3".to_string());
Command::new(&bin)
.arg("--version")
.output()
.ok()
.filter(|o| o.status.success())
.map(|_| bin)
}
fn deleted_site_id(url: &str) -> Option<u32> {
url.strip_prefix("https://site-")
.and_then(|s| s.split('.').next())
.and_then(|n| n.parse::<u32>().ok())
.filter(|n| (201..=400).contains(n))
}
fn sqlite3_recover(bin: &str, db: &Path) -> BTreeMap<u32, (String, String)> {
use std::io::Write;
let mut script = Command::new(bin)
.arg(db)
.arg(".recover")
.output()
.expect("sqlite3 .recover must execute")
.stdout;
script.extend_from_slice(
b"\n.mode list\n.separator |\nSELECT c1, c2 FROM lost_and_found \
WHERE c1 LIKE 'https://site-%';\n",
);
let mut child = Command::new(bin)
.arg(":memory:")
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.spawn()
.expect("sqlite3 :memory: must spawn");
child
.stdin
.take()
.unwrap()
.write_all(&script)
.expect("write recovered SQL to sqlite3 stdin");
let out = child
.wait_with_output()
.expect("sqlite3 :memory: must finish");
let text = String::from_utf8_lossy(&out.stdout);
let mut set = BTreeMap::new();
for line in text.lines() {
let mut it = line.splitn(2, '|');
let (Some(url), Some(title)) = (it.next(), it.next()) else {
continue;
};
if let Some(n) = deleted_site_id(url) {
set.insert(n, (url.to_string(), title.to_string()));
}
}
set
}
#[test]
fn live_read_matches_sqlite3() {
let Some(bin) = sqlite3_bin() else {
eprintln!("SKIP live_read_matches_sqlite3: no sqlite3 on PATH (set SQLITE3_BIN)");
return;
};
let db_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("../tests/data/deleted_places.db");
let db = Database::open(std::fs::read(&db_path).unwrap()).unwrap();
let ours: BTreeMap<i64, (String, String)> = db
.live_rows()
.into_iter()
.map(|(id, vals)| {
let txt = |i: usize| match vals.get(i) {
Some(Value::Text(s)) => s.clone(),
_ => String::new(),
};
(id, (txt(1), txt(2)))
})
.collect();
let out = Command::new(&bin)
.arg(&db_path)
.arg("-cmd")
.arg(".mode list")
.arg("-cmd")
.arg(".separator |")
.arg("SELECT id, url, title FROM moz_places;")
.output()
.expect("sqlite3 SELECT must execute");
let text = String::from_utf8_lossy(&out.stdout);
let theirs: BTreeMap<i64, (String, String)> = text
.lines()
.filter_map(|l| {
let f: Vec<&str> = l.splitn(3, '|').collect();
if f.len() < 3 {
return None;
}
f[0].parse::<i64>()
.ok()
.map(|id| (id, (f[1].to_string(), f[2].to_string())))
})
.collect();
assert!(!theirs.is_empty(), "sqlite3 returned no live rows");
assert_eq!(
ours, theirs,
"live-read parity: our base parser disagrees with sqlite3 SELECT"
);
assert_eq!(ours.len(), 200, "expected 200 live rows (ids 1..=200)");
assert!(
ours.keys().all(|&id| (1..=200).contains(&id)),
"live set must be ids 1..=200 with no deleted-row leakage"
);
}
#[test]
fn our_fixture_agrees_with_sqlite3_recover() {
let Some(bin) = sqlite3_bin() else {
eprintln!("SKIP our_fixture_agrees_with_sqlite3_recover: no sqlite3 on PATH");
return;
};
let db_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("../tests/data/deleted_places.db");
let db = Database::open(std::fs::read(&db_path).unwrap()).unwrap();
let ours = ours_recover_by_url(&db, 6);
let ours_del: BTreeMap<u32, &(i64, String, String)> = ours
.iter()
.filter_map(|(u, v)| deleted_site_id(u).map(|n| (n, v)))
.collect();
let oracle = sqlite3_recover(&bin, &db_path);
if oracle.is_empty() {
eprintln!(
"SKIP our_fixture_agrees_with_sqlite3_recover: this sqlite3 build's .recover \
surfaced no freelist-leaf rows (no oracle baseline to diff)"
);
return;
}
for (n, (_url, title)) in &oracle {
if let Some(ours_val) = ours_del.get(n) {
assert_eq!(
&ours_val.2, title,
"site-{n}: title mismatch ours {:?} vs sqlite3 .recover {:?}",
ours_val.2, title
);
}
}
let mut recover_only: Vec<u32> = oracle
.keys()
.filter(|n| !ours_del.contains_key(n))
.copied()
.collect();
recover_only.sort_unstable();
assert!(
recover_only.is_empty(),
"sqlite3 .recover recovered deleted rows we miss: {recover_only:?}"
);
assert!(
oracle.keys().all(|&n| (201..=400).contains(&n)),
".recover oracle set must contain only deleted ids"
);
}