#![allow(clippy::unwrap_used, clippy::expect_used, clippy::pedantic)]
mod nemetz_support;
use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use std::process::Command;
use nemetz_support::manifest;
use sqlite_core::{Database, Value};
use sqlite_forensic::carve_all_deleted_records;
type RowId = (String, String);
const FLOAT_KEY_EXCLUSIONS: &[&str] = &["0C-06", "0C-07"];
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 bring2lite_cmd() -> Option<PathBuf> {
std::env::var_os("BRING2LITE_CMD").map(PathBuf::from)
}
fn sqlite_dissect_cmd() -> Option<PathBuf> {
std::env::var_os("SQLITE_DISSECT_CMD").map(PathBuf::from)
}
#[derive(Default, Clone)]
struct ToolMatrix {
tp: usize,
tp_recoverable: usize,
live_reread: usize,
fp: usize,
carved: usize,
}
impl ToolMatrix {
fn add(&mut self, o: &ToolMatrix) {
self.tp += o.tp;
self.tp_recoverable += o.tp_recoverable;
self.live_reread += o.live_reread;
self.fp += o.fp;
self.carved += o.carved;
}
}
struct GroundTruth {
deleted: BTreeSet<RowId>,
recoverable: BTreeSet<RowId>,
alive: BTreeSet<RowId>,
d_deleted: usize,
d_recoverable: usize,
}
fn ground_truth(nid: &str) -> GroundTruth {
let mut deleted = BTreeSet::new();
let mut recoverable = BTreeSet::new();
let mut alive = BTreeSet::new();
let mut d_deleted = 0usize;
let mut d_recoverable = 0usize;
for el in manifest().db(nid).elements() {
for row in el.deleted() {
d_deleted += 1;
let c = row.cells();
if c.len() >= 3 {
let key = (c[1].clone(), c[2].clone());
deleted.insert(key.clone());
if row.substrate_recoverable() {
d_recoverable += 1;
recoverable.insert(key);
}
}
}
for row in el.alive() {
if row.len() >= 3 {
alive.insert((row[1].clone(), row[2].clone()));
}
}
}
GroundTruth {
deleted,
recoverable,
alive,
d_deleted,
d_recoverable,
}
}
fn score(recovered: &BTreeSet<RowId>, gt: &GroundTruth) -> ToolMatrix {
let tp = recovered.iter().filter(|k| gt.deleted.contains(*k)).count();
let tp_recoverable = recovered
.iter()
.filter(|k| gt.recoverable.contains(*k))
.count();
let live_reread = recovered
.iter()
.filter(|k| !gt.deleted.contains(*k) && gt.alive.contains(*k))
.count();
let fp = recovered
.iter()
.filter(|k| !gt.deleted.contains(*k) && !gt.alive.contains(*k))
.count();
ToolMatrix {
tp,
tp_recoverable,
live_reread,
fp,
carved: recovered.len(),
}
}
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 cell(v: Option<&Value>) -> String {
match v {
Some(Value::Null) | None => String::new(),
Some(Value::Integer(n)) => n.to_string(),
Some(Value::Real(r)) => format!("{r:.5}"),
Some(Value::Text(t)) => t.clone(),
Some(Value::Blob(_)) => "\u{0}<blob>".to_string(),
}
}
fn ours_recover(db: &Database) -> BTreeSet<RowId> {
carve_all_deleted_records(db)
.iter()
.map(|r| (cell(r.values.get(1)), cell(r.values.get(2))))
.collect()
}
fn undark_recover(undark: &Path, db: &Path) -> BTreeSet<RowId> {
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 = BTreeSet::new();
for line in text.lines() {
if line.trim().is_empty() {
continue;
}
let f = split_csv(line);
if f.len() >= 4 {
set.insert((unquote(&f[2]), unquote(&f[3])));
}
}
set
}
fn fqlite_recover(tap: &Path, db: &Path) -> BTreeSet<RowId> {
let out = Command::new(tap)
.arg(db)
.output()
.expect("fqlite tap must execute");
let text = String::from_utf8_lossy(&out.stdout);
let mut set = BTreeSet::new();
for line in text.lines() {
if line.trim().is_empty() {
continue;
}
let f = split_csv(line);
if f.len() >= 5 && matches!(f[4].as_str(), "table" | "index" | "trigger" | "view") {
continue;
}
if f.len() >= 5 {
set.insert((unquote(&f[3]), unquote(&f[4])));
}
}
set
}
fn bring2lite_recover(cmd: &Path, db: &Path) -> BTreeSet<RowId> {
let out = Command::new(cmd)
.arg(db)
.output()
.expect("bring2lite wrapper must execute");
let text = String::from_utf8_lossy(&out.stdout);
let mut set = BTreeSet::new();
for line in text.lines() {
if line.trim().is_empty() {
continue;
}
let f = split_csv(line);
if f.len() >= 3 {
set.insert((unquote(&f[1]), unquote(&f[2])));
}
}
set
}
fn parse_sqlite_dissect(text: &str) -> BTreeSet<RowId> {
let mut set = BTreeSet::new();
for line in text.lines() {
if line.trim().is_empty() {
continue;
}
let f = split_csv(line);
if f.first().is_some_and(|s| s.eq_ignore_ascii_case("rowid")) {
continue;
}
if f.len() >= 3 {
set.insert((unquote(&f[1]), unquote(&f[2])));
}
}
set
}
fn sqlite_dissect_recover(cmd: &Path, db: &Path) -> BTreeSet<RowId> {
let out = Command::new(cmd)
.arg(db)
.output()
.expect("sqlite_dissect wrapper must execute");
parse_sqlite_dissect(&String::from_utf8_lossy(&out.stdout))
}
#[test]
fn sqlite_dissect_output_parses_col1_col2() {
let sample = "rowid,name,city,zip\n\
1,Alice,New York,10001\n\
\n\
2,Bob,Los Angeles,90001\n";
let got = parse_sqlite_dissect(sample);
let want: BTreeSet<RowId> = [
("Alice".to_string(), "New York".to_string()),
("Bob".to_string(), "Los Angeles".to_string()),
]
.into_iter()
.collect();
assert_eq!(got, want);
}
fn in_scope() -> Vec<(String, String)> {
let mut v: Vec<(String, String)> = manifest()
.databases()
.into_iter()
.filter(|(nid, cat)| {
matches!(cat.as_str(), "0C" | "0D" | "0E")
&& !FLOAT_KEY_EXCLUSIONS.contains(&nid.as_str())
})
.filter(|(nid, cat)| {
Path::new(&format!(
"{}/../tests/data/nemetz/{cat}/{nid}.db",
env!("CARGO_MANIFEST_DIR")
))
.exists()
})
.collect();
v.sort();
v
}
fn db_path(nid: &str, cat: &str) -> PathBuf {
PathBuf::from(format!(
"{}/../tests/data/nemetz/{cat}/{nid}.db",
env!("CARGO_MANIFEST_DIR")
))
}
struct CatTotals {
matrix: ToolMatrix,
d_deleted: usize,
d_recoverable: usize,
}
fn recall_substrate(m: &ToolMatrix, d_recoverable: usize) -> f64 {
if d_recoverable == 0 {
1.0
} else {
m.tp_recoverable as f64 / d_recoverable as f64
}
}
fn recall_e2e(m: &ToolMatrix, d_deleted: usize) -> f64 {
if d_deleted == 0 {
1.0
} else {
m.tp as f64 / d_deleted as f64
}
}
fn precision(m: &ToolMatrix) -> f64 {
let denom = m.tp + m.fp;
if denom == 0 {
1.0
} else {
m.tp as f64 / denom as f64
}
}
fn f_beta(p: f64, r: f64, beta: f64) -> f64 {
let b2 = beta * beta;
let denom = b2 * p + r;
if denom == 0.0 {
0.0
} else {
(1.0 + b2) * p * r / denom
}
}
fn f1(p: f64, r: f64) -> f64 {
f_beta(p, r, 1.0)
}
fn f0_5(p: f64, r: f64) -> f64 {
f_beta(p, r, 0.5)
}
#[derive(Default, Clone, Copy)]
struct Oracles<'a> {
undark: Option<&'a Path>,
fqlite: Option<&'a Path>,
bring2lite: Option<&'a Path>,
sqlite_dissect: Option<&'a Path>,
}
struct CategoryRun {
ours: CatTotals,
undark: Option<CatTotals>,
fqlite: Option<CatTotals>,
bring2lite: Option<CatTotals>,
sqlite_dissect: Option<CatTotals>,
}
fn empty_totals() -> CatTotals {
CatTotals {
matrix: ToolMatrix::default(),
d_deleted: 0,
d_recoverable: 0,
}
}
fn category_totals(cat: &str, oracles: Oracles) -> CategoryRun {
let mut o = empty_totals();
let mut u = oracles.undark.map(|_| empty_totals());
let mut f = oracles.fqlite.map(|_| empty_totals());
let mut b = oracles.bring2lite.map(|_| empty_totals());
let mut sd = oracles.sqlite_dissect.map(|_| empty_totals());
for (nid, c) in in_scope().into_iter().filter(|(_, c)| c == cat) {
let path = db_path(&nid, &c);
let gt = ground_truth(&nid);
let db = Database::open(std::fs::read(&path).unwrap()).unwrap();
o.matrix.add(&score(&ours_recover(&db), >));
o.d_deleted += gt.d_deleted;
o.d_recoverable += gt.d_recoverable;
let accumulate = |run: Option<&mut CatTotals>, recovered: BTreeSet<RowId>| {
if let Some(tot) = run {
tot.matrix.add(&score(&recovered, >));
tot.d_deleted += gt.d_deleted;
tot.d_recoverable += gt.d_recoverable;
}
};
if let Some(bin) = oracles.undark {
accumulate(u.as_mut(), undark_recover(bin, &path));
}
if let Some(tap) = oracles.fqlite {
accumulate(f.as_mut(), fqlite_recover(tap, &path));
}
if let Some(cmd) = oracles.bring2lite {
accumulate(b.as_mut(), bring2lite_recover(cmd, &path));
}
if let Some(cmd) = oracles.sqlite_dissect {
accumulate(sd.as_mut(), sqlite_dissect_recover(cmd, &path));
}
}
CategoryRun {
ours: o,
undark: u,
fqlite: f,
bring2lite: b,
sqlite_dissect: sd,
}
}
#[test]
fn emit_tool_comparison() {
let undark = undark_bin();
let fqlite = fqlite_tap();
let bring2lite = bring2lite_cmd();
let sqlite_dissect = sqlite_dissect_cmd();
if undark.is_none() {
eprintln!("NOTE undark column omitted: set UNDARK_BIN to include it");
}
if fqlite.is_none() {
eprintln!("NOTE fqlite column omitted: set FQLITE_TAP to include it");
}
if bring2lite.is_none() {
eprintln!("NOTE bring2lite column omitted: set BRING2LITE_CMD to include it");
}
if sqlite_dissect.is_none() {
eprintln!("NOTE sqlite_dissect column omitted: set SQLITE_DISSECT_CMD to include it");
}
let oracles = Oracles {
undark: undark.as_deref(),
fqlite: fqlite.as_deref(),
bring2lite: bring2lite.as_deref(),
sqlite_dissect: sqlite_dissect.as_deref(),
};
println!(
"\n{:<3} {:<10} {:>4} {:>5} {:>3} {:>3} {:>3} {:>4} {:>8} {:>8} {:>5}",
"cat", "tool", "Ddel", "Drec", "TP", "FP", "FN", "live", "rec_sub", "rec_e2e", "prec"
);
let print_row = |cat: &str, tool: &str, t: &CatTotals| {
let m = &t.matrix;
let fn_ = t.d_recoverable.saturating_sub(m.tp_recoverable);
println!(
"{:<3} {:<10} {:>4} {:>5} {:>3} {:>3} {:>3} {:>4} {:>8.3} {:>8.3} {:>5.3}",
cat,
tool,
t.d_deleted,
t.d_recoverable,
m.tp,
m.fp,
fn_,
m.live_reread,
recall_substrate(m, t.d_recoverable),
recall_e2e(m, t.d_deleted),
precision(m),
);
};
let mut csv_rows: Vec<String> = Vec::new();
let mut push_csv = |cat: &str, tool: &str, t: &CatTotals| {
let r = recall_substrate(&t.matrix, t.d_recoverable);
let p = precision(&t.matrix);
csv_rows.push(format!(
"{cat},{tool},{r:.6},{p:.6},{:.6},{:.6}",
f1(p, r),
f0_5(p, r)
));
};
for cat in ["0C", "0D", "0E"] {
let run = category_totals(cat, oracles);
print_row(cat, "ours", &run.ours);
push_csv(cat, "ours", &run.ours);
for (tool, totals) in [
("undark", &run.undark),
("fqlite", &run.fqlite),
("bring2lite", &run.bring2lite),
("sqlite_dissect", &run.sqlite_dissect),
] {
if let Some(t) = totals {
print_row(cat, tool, t);
push_csv(cat, tool, t);
}
}
}
println!("\nExcluded (FLOAT key columns, no cross-tool identity): {FLOAT_KEY_EXCLUSIONS:?}");
if undark.is_some() && fqlite.is_some() {
let csv_path = format!(
"{}/../docs/img/comparison_metrics.csv",
env!("CARGO_MANIFEST_DIR")
);
let mut body = String::from("category,tool,recall_substrate,precision,f1,f0_5\n");
for row in &csv_rows {
body.push_str(row);
body.push('\n');
}
std::fs::write(&csv_path, body).expect("write comparison_metrics.csv");
eprintln!("WROTE {csv_path}");
} else {
eprintln!("NOTE comparison_metrics.csv not rewritten: set both UNDARK_BIN and FQLITE_TAP");
}
}
#[test]
fn f_beta_family_matches_known_values() {
let (p, r) = (0.8_f64, 0.5_f64);
assert!((f1(p, r) - 0.8 / 1.3).abs() < 1e-9, "F1 = {}", f1(p, r));
assert!(
(f0_5(p, r) - 0.5 / 0.7).abs() < 1e-9,
"F0.5 = {}",
f0_5(p, r)
);
assert!(f0_5(p, r) > f1(p, r), "F0.5 must exceed F1 when P > R");
assert_eq!(f1(0.0, 0.0), 0.0);
assert_eq!(f0_5(0.0, 0.0), 0.0);
assert!((f1(1.0, 1.0) - 1.0).abs() < 1e-12);
assert!((f0_5(1.0, 1.0) - 1.0).abs() < 1e-12);
}
#[test]
fn ours_leads_on_0c_inpage_recall() {
let Some(tap) = fqlite_tap() else {
eprintln!("SKIP ours_leads_on_0c_inpage_recall: set FQLITE_TAP");
return;
};
let run = category_totals(
"0C",
Oracles {
fqlite: Some(tap.as_path()),
..Oracles::default()
},
);
let (ours, f) = (run.ours, run.fqlite.expect("fqlite requested"));
assert!(
ours.matrix.tp >= 68,
"our 0C true positives {} fell below the measured floor 68",
ours.matrix.tp
);
assert!(
ours.matrix.tp > f.matrix.tp,
"ours ({}) must lead fqlite ({}) on 0C in-page recall",
ours.matrix.tp,
f.matrix.tp
);
}
#[test]
fn undark_rereads_live_rows_on_0d() {
let Some(bin) = undark_bin() else {
eprintln!("SKIP undark_rereads_live_rows_on_0d: set UNDARK_BIN");
return;
};
let run = category_totals(
"0D",
Oracles {
undark: Some(bin.as_path()),
..Oracles::default()
},
);
let (ours, u) = (run.ours, run.undark.expect("undark requested"));
assert!(
u.matrix.live_reread >= 20,
"undark 0D live-re-reads {} fell below the measured floor 20",
u.matrix.live_reread
);
assert_eq!(
ours.matrix.live_reread, 0,
"our carver must never re-surface a live 0D row (got {})",
ours.matrix.live_reread
);
}
#[test]
fn ours_leads_bring2lite_on_0c_recall() {
let Some(cmd) = bring2lite_cmd() else {
eprintln!("SKIP ours_leads_bring2lite_on_0c_recall: set BRING2LITE_CMD");
return;
};
let run = category_totals(
"0C",
Oracles {
bring2lite: Some(cmd.as_path()),
..Oracles::default()
},
);
let (ours, b) = (run.ours, run.bring2lite.expect("bring2lite requested"));
assert!(
b.matrix.tp >= 30,
"bring2lite 0C true positives {} fell below the measured floor 30",
b.matrix.tp
);
assert!(
ours.matrix.tp > b.matrix.tp,
"ours ({}) must lead bring2lite ({}) on 0C in-page recall",
ours.matrix.tp,
b.matrix.tp
);
}
#[test]
fn ours_never_rereads_a_live_row() {
let mut live = 0usize;
for (nid, cat) in in_scope() {
let db = Database::open(std::fs::read(db_path(&nid, &cat)).unwrap()).unwrap();
let gt = ground_truth(&nid);
live += score(&ours_recover(&db), >).live_reread;
}
assert_eq!(
live, 0,
"our carver re-surfaced {live} live row(s) as deleted across the in-scope corpus"
);
}
fn live_schema_identities(db: &Database) -> BTreeSet<(String, String, String)> {
db.live_schema_rows()
.iter()
.map(|row| (cell(row.first()), cell(row.get(1)), cell(row.get(2))))
.collect()
}
fn ours_schema_rereads(db: &Database) -> usize {
let live = live_schema_identities(db);
carve_all_deleted_records(db)
.iter()
.filter(|r| {
live.contains(&(
cell(r.values.first()),
cell(r.values.get(1)),
cell(r.values.get(2)),
))
})
.count()
}
fn undark_schema_rereads(
undark: &Path,
db_file: &Path,
live: &BTreeSet<(String, String, String)>,
) -> usize {
let out = Command::new(undark)
.arg("-i")
.arg(db_file)
.output()
.expect("undark must execute");
let text = String::from_utf8_lossy(&out.stdout);
let mut n = 0;
for line in text.lines() {
if line.trim().is_empty() {
continue;
}
let f = split_csv(line);
if f.len() >= 4 && live.contains(&(unquote(&f[1]), unquote(&f[2]), unquote(&f[3]))) {
n += 1;
}
}
n
}
fn fqlite_schema_rereads(
tap: &Path,
db_file: &Path,
live: &BTreeSet<(String, String, String)>,
) -> usize {
let out = Command::new(tap)
.arg(db_file)
.output()
.expect("fqlite tap must execute");
let text = String::from_utf8_lossy(&out.stdout);
let mut n = 0;
for line in text.lines() {
if line.trim().is_empty() {
continue;
}
let f = split_csv(line);
if f.len() >= 7 && live.contains(&(unquote(&f[4]), unquote(&f[5]), unquote(&f[6]))) {
n += 1;
}
}
n
}
#[test]
fn live_sqlite_master_rereads_per_tool() {
let undark = undark_bin();
let fqlite = fqlite_tap();
let mut ours_total = 0usize;
let mut undark_total = 0usize;
let mut fqlite_total = 0usize;
for (nid, cat) in in_scope() {
let path = db_path(&nid, &cat);
let db = Database::open(std::fs::read(&path).unwrap()).unwrap();
let live = live_schema_identities(&db);
assert!(
!live.is_empty(),
"{nid}: a live (non-dropped) table DB must carry a live sqlite_master row to guard against"
);
ours_total += ours_schema_rereads(&db);
if let Some(bin) = &undark {
undark_total += undark_schema_rereads(bin, &path, &live);
}
if let Some(tap) = &fqlite {
fqlite_total += fqlite_schema_rereads(tap, &path, &live);
}
}
assert_eq!(
ours_total, 0,
"our carver re-read the live sqlite_master row {ours_total} time(s) across 0C/0D/0E"
);
if undark.is_some() {
assert_eq!(
undark_total, 0,
"undark live sqlite_master re-reads {undark_total} (expected 0 — undark does not reconstruct the schema row)"
);
} else {
eprintln!("SKIP undark schema-reread leg: set UNDARK_BIN");
}
if fqlite.is_some() {
assert_eq!(
fqlite_total, NEMETZ_FQLITE_SCHEMA_REREADS,
"fqlite live sqlite_master re-reads {fqlite_total} (expected {NEMETZ_FQLITE_SCHEMA_REREADS})"
);
} else {
eprintln!("SKIP fqlite schema-reread leg: set FQLITE_TAP");
}
}
const NEMETZ_FQLITE_SCHEMA_REREADS: usize = 25;