sqlite-forensic 0.10.3

Forensic anomaly auditor for SQLite databases — header-integrity findings as graded report::Finding, built on sqlite-core (WS-C spike skeleton; WS-E expands carving/WAL/freelist).
Documentation
//! Panic-free robustness over the **full** SQLite Forensic Corpus (Nemetz,
//! Schmitt & Freiling, DFRWS-EU 2018 + the anti-forensic extension; CC0 public
//! domain) — all 141 databases across 23 categories.
//!
//! # Why this test is the real-world proof
//!
//! The parser is `forbid(unsafe)` and panic-free **by design** (typed errors,
//! bounded loops, no `unwrap`/`expect` in non-test code). This harness is the
//! external evidence for that claim: it drives the *entire* pipeline —
//! [`Database::open`] (and [`Database::open_with_wal`] where a `-wal` sidecar
//! exists), [`carve_with_fragments`] (which subsumes
//! [`carve_all_deleted_records`]), [`audit`], [`row_histories_with_residue`], and
//! a rebuild of the carved output back to a valid SQLite image — over every DB in
//! the corpus, including the anti-forensic categories whose page/cell pointers,
//! overflow chains, freeblock structures, freelist trunks and serial types are
//! deliberately manipulated to break naive parsers. The assertion is the same for
//! all of them: **no panic, and a structurally-sane result**.
//!
//! A database that is *itself* malformed (a manipulated-structure fixture) may
//! legitimately fail to **open** — that is a typed [`Err`], not a panic, and is
//! the correct graceful degradation. What must never happen is an abort, an
//! out-of-bounds, an unwrap-on-None, or silent wrong output. Every DB that *does*
//! open must then survive the full analyzer pipeline without panicking.
//!
//! # Coverage floor
//!
//! The harness asserts it walked **≥ 141** databases, so a regression that
//! vendors fewer than the full corpus fails CI rather than silently passing on a
//! subset.

#![allow(clippy::unwrap_used, clippy::expect_used, clippy::pedantic)]

use std::path::{Path, PathBuf};

use sqlite_core::{Database, Value};
use sqlite_forensic::{
    audit, carve_all_deleted_records, carve_with_fragments, row_histories_with_residue,
};

/// The vendored corpus root (`tests/data/nemetz/`), relative to the forensic
/// crate's manifest dir.
fn corpus_root() -> PathBuf {
    Path::new(env!("CARGO_MANIFEST_DIR"))
        .join("..")
        .join("tests")
        .join("data")
        .join("nemetz")
}

/// Every `.db` (or `*_antifor.db`) file under the corpus root, recursively. The
/// answer-key `.xml`, build `.sql`, and the category-11+ `.log`/`.db_recovery`
/// artifacts are skipped — only the database images are exercised here.
fn all_corpus_dbs() -> Vec<PathBuf> {
    let mut out = Vec::new();
    let root = corpus_root();
    let mut stack = vec![root];
    while let Some(dir) = stack.pop() {
        let Ok(entries) = std::fs::read_dir(&dir) else {
            continue;
        };
        for entry in entries.flatten() {
            let path = entry.path();
            if path.is_dir() {
                stack.push(path);
            } else if path.extension().and_then(|e| e.to_str()) == Some("db") {
                out.push(path);
            }
        }
    }
    out.sort();
    out
}

/// A carved record's decoded values must be structurally coherent: a blob never
/// borrows out of bounds, a text value is valid UTF-8 by construction (it is a
/// Rust `String`), and the column vector is finite. We assert the cheap
/// invariants the type system does not already guarantee.
fn assert_record_sane(values: &[Value]) {
    // A record with more columns than any SQLite page could hold is a decode bug;
    // the in-page cell limit caps real records far below this. This is a loose
    // upper bound that still catches a runaway length.
    assert!(
        values.len() <= 100_000,
        "carved record has implausible column count {}",
        values.len()
    );
    for v in values {
        if let Value::Blob(b) = v {
            // The blob bytes are owned; just touch the length so a corrupt
            // length field that survived decode is exercised, not trusted.
            let _ = b.len();
        }
    }
}

/// The headline test: open + full pipeline + rebuild over **every** corpus DB,
/// asserting no panic and a structurally-sane result, and that the harness
/// covered the whole 141-database corpus.
#[test]
fn full_corpus_is_panic_free_end_to_end() {
    let dbs = all_corpus_dbs();
    assert!(
        dbs.len() >= 141,
        "expected the full 141-database Nemetz corpus to be vendored, found only {} .db files under {}",
        dbs.len(),
        corpus_root().display()
    );

    let mut opened = 0usize;
    for path in &dbs {
        let bytes = std::fs::read(path).expect("corpus db readable");

        // A manipulated-structure fixture may legitimately fail to open — that is
        // a typed Err (graceful degradation), never a panic. Skip the pipeline
        // for a DB that does not open; the no-panic contract is already proven by
        // reaching this point without aborting.
        let wal_path = path.with_extension("db-wal");
        let db = if wal_path.exists() {
            let wal = std::fs::read(&wal_path).expect("wal sidecar readable");
            match Database::open_with_wal(bytes, &wal) {
                Ok(db) => db,
                Err(_) => continue,
            }
        } else {
            match Database::open(bytes) {
                Ok(db) => db,
                Err(_) => continue,
            }
        };
        opened += 1;

        // Tier-1 + Tier-2 carving. carve_with_fragments.full is byte-identical to
        // carve_all_deleted_records; assert that load-bearing equivalence holds on
        // every corpus DB rather than calling both blindly.
        let tiers = carve_with_fragments(&db);
        let full = carve_all_deleted_records(&db);
        assert_eq!(
            tiers.full,
            full,
            "{}: carve_with_fragments.full diverged from carve_all_deleted_records",
            path.display()
        );
        for rec in &tiers.full {
            assert_record_sane(&rec.values);
        }

        // Audit must not panic and must return a finite (bounded) anomaly list.
        let anomalies = audit(&db);
        assert!(
            anomalies.len() <= 1_000_000,
            "{}: audit returned an implausible anomaly count {}",
            path.display(),
            anomalies.len()
        );

        // Per-rowid version history with deleted residue.
        let _ = row_histories_with_residue(&db);

        // Rebuild the carved records back to a valid SQLite image and re-open it —
        // an independent reader path over writer output, the strongest no-panic
        // round-trip we can run on real data.
        let rebuild_rows: Vec<sqlite_core::rebuild::RebuildRow> = tiers
            .full
            .iter()
            .map(|r| sqlite_core::rebuild::RebuildRow {
                page: r.page,
                offset: r.offset,
                rowid: Some(r.rowid),
                source: format!("{:?}", r.source),
                confidence: r.confidence,
                cells: r.values.clone(),
            })
            .collect();
        let image = sqlite_core::rebuild::build_recovered_db(&rebuild_rows);
        let reopened = Database::open(image).expect("rebuilt image must re-open");
        // The rebuilt schema is always readable (page 1 sqlite_master leaf).
        let _ = reopened.read_table(1, 5).expect("rebuilt schema readable");
    }

    // At least the well-formed categories must have opened; the count is reported
    // so a sudden drop in openable DBs is visible even when ≥ 141 are present.
    assert!(
        opened > 0,
        "no corpus database opened — the pipeline never ran"
    );
}