Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/nornir

src/binscan.rs

//! Binary-analysis **persistence** — the warehouse projection of
//! [`nornir_introspect::binscan`]'s pure-algorithm [`BinaryReport`] into Mimir's
//! `binary_facts` / `section_sizes` / `symbol_sizes` tables, all keyed by the
//! artifact's `content_sha256` (the `build_info.content_id` join key every
//! binary fact hangs off — no new identity is invented). Mirrors the
//! `introspect/persist.rs` snapshot pattern: one capture `snapshot_id` ties the
//! header + section + symbol rows of a single `analyze` together.
//!
//! This is the nornir-side glue; the size + hardening algorithms live in the
//! dep-light `nornir-introspect` leaf (REUSE-FIRST). Slice 1 of the
//! binary-analysis subsystem (`.nornir/binary-analysis-design.md` §9).

use std::path::Path;

use anyhow::{Context, Result};
use uuid::Uuid;

pub use nornir_introspect::binsbom::{BinComponent, derive_components};
pub use nornir_introspect::binscan::{BinaryReport, analyze};
pub use nornir_introspect::diff::{ArtifactDiff, SymbolDelta, diff_reports};
pub use nornir_introspect::gate::{
    CveFinding, GateFinding, GateStatus, GateThresholds, GateVerdict, evaluate_gate,
};

use crate::warehouse::iceberg::{
    ArtifactDiffRow, BinaryFactRow, IcebergWarehouse, SbomComponentRow, SectionSizeRow,
    SymbolSizeRow,
};

/// Sha256 of an artifact's bytes — the `content_sha256` identity that keys every
/// binary fact (equals `build_info.content_id`). Read straight off disk.
pub fn content_sha256(path: &Path) -> Result<String> {
    let bytes = std::fs::read(path).with_context(|| format!("read {}", path.display()))?;
    let mut h = nornir_hash::Sha256Hasher::new();
    h.update(&bytes);
    Ok(h.finalize_hex())
}

/// Project a [`BinaryReport`] into the three warehouse row types and append them
/// under one fresh capture `snapshot_id`, keyed by `content_sha256`. `repo` /
/// `git_sha` / `artifact_path` are the build context (may be empty when analysing
/// an ad-hoc artifact); the `content_sha256` is the authoritative join key.
/// Returns the capture `snapshot_id`.
pub fn persist_report(
    wh: &IcebergWarehouse,
    report: &BinaryReport,
    repo: &str,
    git_sha: &str,
    content_sha256: &str,
    artifact_path: &str,
) -> Result<Uuid> {
    let snapshot_id = Uuid::new_v4();
    let h = &report.header;
    let header = BinaryFactRow {
        repo: repo.to_string(),
        git_sha: git_sha.to_string(),
        // The ELF build-id note doubles as `build_id` when no build context is
        // supplied — it is the closest stable build identity the bytes carry.
        build_id: h.build_id.clone().unwrap_or_default(),
        content_sha256: content_sha256.to_string(),
        artifact_path: artifact_path.to_string(),
        format: h.format.clone(),
        arch: h.arch.clone(),
        class: h.class.clone(),
        total_bytes: h.total_bytes as i64,
        stripped: h.stripped,
        has_debug_info: h.has_debug_info,
        pie: h.pie,
        relro: h.relro.clone(),
        nx: h.nx,
        stack_canary: h.stack_canary,
        fortify: h.fortify,
        rpath: h.rpath,
        runpath: h.runpath,
        build_id_note: h.build_id.clone().unwrap_or_default(),
        n_sections: h.n_sections as i64,
        n_sym_defined: h.n_sym_defined as i64,
        n_sym_undefined: h.n_sym_undefined as i64,
        n_dyn_deps: h.n_dyn_deps as i64,
    };
    let sections: Vec<SectionSizeRow> = report
        .sections
        .iter()
        .map(|s| SectionSizeRow {
            section_name: s.name.clone(),
            size_bytes: s.size_bytes as i64,
            vaddr: s.vaddr as i64,
            alloc: s.alloc,
            exec: s.exec,
            write: s.write,
            file_offset: s.file_offset as i64,
            kind: s.kind.clone(),
        })
        .collect();
    let symbols: Vec<SymbolSizeRow> = report
        .symbols
        .iter()
        .map(|s| SymbolSizeRow {
            symbol_mangled: s.name_mangled.clone(),
            symbol_demangled: s.name_demangled.clone(),
            symbol_demangled_no_generics: s.name_demangled_no_generics.clone(),
            crate_name: s.crate_name.clone(),
            section: s.section.clone(),
            size_bytes: s.size_bytes as i64,
            kind: s.kind.clone(),
            binding: s.binding.clone(),
            is_monomorphization: s.is_monomorphization,
            source_file: s.source_file.clone(),
            source_line: s.source_line,
        })
        .collect();

    wh.append_binary_analysis(snapshot_id, &header, &sections, &symbols)?;
    Ok(snapshot_id)
}

/// The persisted analysis for `content_sha256` read back from the warehouse: the
/// latest header row + its section + symbol size rows. `None` if that artifact
/// was never analysed.
pub fn load_analysis(
    wh: &IcebergWarehouse,
    content_sha256: &str,
) -> Result<Option<(BinaryFactRow, Vec<SectionSizeRow>, Vec<SymbolSizeRow>)>> {
    let Some(header) = wh.query_binary_facts(content_sha256)? else {
        return Ok(None);
    };
    let sections = wh.query_section_sizes(content_sha256)?;
    let symbols = wh.query_symbol_sizes(content_sha256)?;
    Ok(Some((header, sections, symbols)))
}

/// The persisted analysis of `content_sha256` as the [`BinaryReport`]
/// [`analyze`] made — the inverse of [`persist_report`], so a reader diffs two
/// filed artifacts ([`diff_reports`]) without their files. `None` = never
/// analysed. An artifact analysed more than once (the same bytes filed again)
/// holds identical rows per capture: each distinct row is read once.
pub fn load_report(wh: &IcebergWarehouse, content_sha256: &str) -> Result<Option<BinaryReport>> {
    let Some((h, sections, symbols)) = load_analysis(wh, content_sha256)? else {
        return Ok(None);
    };
    Ok(Some(report_of(&h, &sections, &symbols)))
}

/// A [`BinaryReport`] from its warehouse rows (header + sections + symbols),
/// the per-crate roll-up recomputed from the symbols.
pub fn report_of(h: &BinaryFactRow, sections: &[SectionSizeRow], symbols: &[SymbolSizeRow]) -> BinaryReport {
    use nornir_introspect::hardening::Hardening;
    use nornir_introspect::size::{CrateSize, SectionSize, SymbolSize};
    let mut seen_sec = std::collections::HashSet::new();
    let sections: Vec<SectionSize> = sections
        .iter()
        .filter(|s| seen_sec.insert((s.section_name.clone(), s.size_bytes, s.vaddr, s.file_offset)))
        .map(|s| SectionSize {
            name: s.section_name.clone(),
            size_bytes: s.size_bytes.max(0) as u64,
            vaddr: s.vaddr.max(0) as u64,
            file_offset: s.file_offset.max(0) as u64,
            alloc: s.alloc,
            exec: s.exec,
            write: s.write,
            kind: s.kind.clone(),
        })
        .collect();
    let mut seen_sym = std::collections::HashSet::new();
    let symbols: Vec<SymbolSize> = symbols
        .iter()
        .filter(|s| seen_sym.insert((s.symbol_mangled.clone(), s.section.clone(), s.size_bytes, s.kind.clone(), s.binding.clone(), s.source_line)))
        .map(|s| SymbolSize {
            name_mangled: s.symbol_mangled.clone(),
            name_demangled: s.symbol_demangled.clone(),
            name_demangled_no_generics: s.symbol_demangled_no_generics.clone(),
            crate_name: s.crate_name.clone(),
            section: s.section.clone(),
            size_bytes: s.size_bytes.max(0) as u64,
            kind: s.kind.clone(),
            binding: s.binding.clone(),
            is_monomorphization: s.is_monomorphization,
            source_file: s.source_file.clone(),
            source_line: s.source_line,
        })
        .collect();
    let mut by_crate: std::collections::BTreeMap<&str, (u64, u64)> = std::collections::BTreeMap::new();
    for s in &symbols {
        let e = by_crate.entry(s.crate_name.as_str()).or_default();
        e.0 += s.size_bytes;
        e.1 += 1;
    }
    let mut crates: Vec<CrateSize> = by_crate.into_iter().map(|(c, (size_bytes, symbol_count))| CrateSize { crate_name: c.to_string(), size_bytes, symbol_count }).collect();
    crates.sort_by(|a, b| b.size_bytes.cmp(&a.size_bytes).then(a.crate_name.cmp(&b.crate_name)));
    BinaryReport {
        header: Hardening {
            format: h.format.clone(),
            arch: h.arch.clone(),
            class: h.class.clone(),
            total_bytes: h.total_bytes.max(0) as u64,
            stripped: h.stripped,
            has_debug_info: h.has_debug_info,
            pie: h.pie,
            relro: h.relro.clone(),
            nx: h.nx,
            stack_canary: h.stack_canary,
            fortify: h.fortify,
            rpath: h.rpath,
            runpath: h.runpath,
            build_id: Some(h.build_id_note.clone()).filter(|b| !b.is_empty()),
            n_sections: h.n_sections.max(0) as u64,
            n_sym_defined: h.n_sym_defined.max(0) as u64,
            n_sym_undefined: h.n_sym_undefined.max(0) as u64,
            n_dyn_deps: h.n_dyn_deps.max(0) as u64,
        },
        sections,
        symbols,
        crates,
    }
}

// ── Artifact-to-artifact diff (task #65 slice 2) ─────────────────────────────

/// Analyse two artifacts and diff them (base → new). `source_root` (optional)
/// enables DWARF crate + source attribution on both sides. The pure algorithm
/// lives in [`nornir_introspect::diff`]; this is the nornir-side convenience that
/// pairs `analyze` + `diff_reports`.
pub fn diff_artifacts(base: &Path, new: &Path, source_root: Option<&Path>) -> Result<ArtifactDiff> {
    let base_report =
        analyze(base, source_root).with_context(|| format!("analyze base {}", base.display()))?;
    let new_report =
        analyze(new, source_root).with_context(|| format!("analyze new {}", new.display()))?;
    Ok(diff_reports(&base_report, &new_report))
}

/// Project an [`ArtifactDiff`] into the narrow `artifact_diff` change rows keyed
/// on the pair `(from, to)`. One row per change: the headline `size_delta`, each
/// changed section/crate, each churned symbol, and the exported (ABI-surface)
/// add/remove subset. `severity` is the gate hint (a removed export is `high`).
pub fn diff_to_rows(
    diff: &ArtifactDiff,
    repo: &str,
    release_id: &str,
    from: &str,
    to: &str,
) -> Vec<ArtifactDiffRow> {
    let mk = |kind: &str, name: &str, crate_name: &str, before: u64, after: u64, sev: &str| {
        ArtifactDiffRow {
            release_id: release_id.to_string(),
            from_content_sha256: from.to_string(),
            to_content_sha256: to.to_string(),
            repo: repo.to_string(),
            kind: kind.to_string(),
            name: name.to_string(),
            crate_name: crate_name.to_string(),
            before: before as i64,
            after: after as i64,
            delta_bytes: after as i64 - before as i64,
            severity: sev.to_string(),
        }
    };
    let grow_sev = |delta: i64| if delta > 0 { "warn" } else { "info" };

    let mut rows = Vec::new();
    // Headline total.
    rows.push(mk(
        "size_delta",
        "total_bytes",
        "",
        diff.base_total_bytes,
        diff.new_total_bytes,
        grow_sev(diff.total_delta_bytes),
    ));
    // Section + crate waterfall.
    for s in &diff.sections {
        rows.push(mk(
            "section_delta",
            &s.name,
            "",
            s.base_size,
            s.new_size,
            grow_sev(s.delta_bytes),
        ));
    }
    for c in &diff.crates {
        rows.push(mk(
            "crate_delta",
            &c.crate_name,
            &c.crate_name,
            c.base_size,
            c.new_size,
            grow_sev(c.delta_bytes),
        ));
    }
    // Symbol churn (name = mangled, the stable join key).
    let push_sym = |rows: &mut Vec<ArtifactDiffRow>, kind: &str, d: &SymbolDelta, sev: &str| {
        rows.push(mk(
            kind,
            &d.name_mangled,
            &d.crate_name,
            d.base_size,
            d.new_size,
            sev,
        ));
    };
    for d in &diff.symbols_added {
        push_sym(&mut rows, "symbol_added", d, "info");
    }
    for d in &diff.symbols_removed {
        push_sym(&mut rows, "symbol_removed", d, "info");
    }
    for d in &diff.symbols_grown {
        push_sym(&mut rows, "symbol_grew", d, "info");
    }
    for d in &diff.symbols_shrunk {
        push_sym(&mut rows, "symbol_shrunk", d, "info");
    }
    // ABI surface: a removed export is the binary abi_break the gate reads.
    for d in &diff.abi.added {
        push_sym(&mut rows, "abi_added", d, "info");
    }
    for d in &diff.abi.removed {
        push_sym(&mut rows, "abi_removed", d, "high");
    }
    rows
}

/// Persist an [`ArtifactDiff`] into `artifact_diff` under one fresh capture
/// `snapshot_id`, keyed on the pair `(from, to)` content ids. Returns the capture
/// `snapshot_id`.
pub fn persist_diff(
    wh: &IcebergWarehouse,
    diff: &ArtifactDiff,
    repo: &str,
    release_id: &str,
    from: &str,
    to: &str,
) -> Result<Uuid> {
    let snapshot_id = Uuid::new_v4();
    let rows = diff_to_rows(diff, repo, release_id, from, to);
    wh.append_artifact_diff(snapshot_id, &rows)?;
    Ok(snapshot_id)
}

/// Read back the persisted `artifact_diff` change rows for the pair `(from, to)`
/// — the latest capture. Empty if that pair was never diffed.
pub fn load_diff(wh: &IcebergWarehouse, from: &str, to: &str) -> Result<Vec<ArtifactDiffRow>> {
    wh.query_artifact_diff(from, to)
}

// ── Binary-derived SBOM (task #65 slice 4a) ──────────────────────────────────

/// Persist the **binary-derived** SBOM for one analysed artifact: the components
/// actually present in the shipped bytes ([`derive_components`]), written into
/// `sbom_components` with `derived_from = "binary"` — the twin of `security.rs`'s
/// source-side SBOM (`derived_from = "source"`), not a separate table. Returns the
/// number of components written. Reuses the same warehouse writer; the CVE match
/// (see [`binary_cve_findings`]) reuses `security.rs`'s OSV/RUSTSEC engine.
pub fn persist_binary_sbom(
    wh: &IcebergWarehouse,
    repo: &str,
    report: &BinaryReport,
) -> Result<usize> {
    let comps = derive_components(report);
    let rows: Vec<SbomComponentRow> = comps
        .iter()
        .map(|c| SbomComponentRow {
            name: c.name.clone(),
            version: c.version.clone(),
            license: String::new(), // binary-derived: license unknown from bytes
            derived_from: "binary".to_string(),
        })
        .collect();
    wh.append_sbom_components(repo, Uuid::new_v4(), &rows)
        .with_context(|| format!("persist binary sbom for {repo}"))?;
    Ok(rows.len())
}

/// CVE-match the **binary-derived** SBOM of `report` against the advisory DB,
/// reusing `security.rs`'s `query_vulns` (OSV + RUSTSEC). Best-effort: if the
/// advisory path is unavailable (offline, no DB seeded) it returns an empty list
/// rather than failing — the gate treats "no CVE data" as "no CVE finding". A CVE
/// found here that the source SBOM missed is the high-value catch (design §5.5).
pub fn binary_cve_findings(report: &BinaryReport) -> Vec<CveFinding> {
    let comps: Vec<crate::security::Component> = derive_components(report)
        .into_iter()
        .filter(|c| !c.version.is_empty()) // no version → cannot match a range
        .map(|c| crate::security::Component {
            name: c.name,
            version: c.version,
            license: String::new(),
        })
        .collect();
    if comps.is_empty() {
        return Vec::new();
    }
    let vulns = crate::security::query_vulns(&comps).unwrap_or_default();
    let mut out = Vec::new();
    for v in vulns {
        for id in &v.ids {
            out.push(CveFinding {
                id: id.clone(),
                package: v.crate_name.clone(),
                version: v.version.clone(),
                summary: v.summary.clone(),
            });
        }
    }
    out
}

// ── Release binary-GATE (task #65 slice 3) ───────────────────────────────────

/// Analyse two artifacts, diff them, and render the release binary-gate verdict
/// (base = the prior *released* artifact per `release_lineage`; new = the
/// candidate). CVE findings are matched against the new artifact's binary SBOM
/// when `cve` is true. The pure decision lives in [`nornir_introspect::gate`];
/// this pairs `diff_artifacts` + the CVE reuse into one call. The verdict is
/// agent-callable (serialize to JSON) and exit-coded (`GateVerdict::exit_code`).
pub fn gate_artifacts(
    base: &Path,
    new: &Path,
    source_root: Option<&Path>,
    thresholds: &GateThresholds,
    cve: bool,
) -> Result<GateVerdict> {
    // The pairing itself lives in the dep-light leaf (`nornir_introspect::gate::
    // gate_artifacts`, the seam dwarves' release doctor links); nornir only adds
    // the one thing it alone can: the warehouse CVE match over the new artifact's
    // binary SBOM.
    nornir_introspect::gate::gate_artifacts(base, new, source_root, thresholds, |new_report| {
        if cve { binary_cve_findings(new_report) } else { Vec::new() }
    })
}

#[cfg(test)]
mod tests {
    use super::*;

    /// RED-WHEN-BROKEN end-to-end proof (task #65 slice 1): analyse the **test
    /// binary itself** (the self-exe pattern from `artifact.rs:~430` — always
    /// present, always carries a symtab + DWARF under the `test` profile),
    /// persist the report into a fresh warehouse keyed by its real
    /// `content_sha256`, and read it back. Asserts non-empty section + symbol
    /// sizes, a decoded hardening verdict, and that the rows round-trip keyed by
    /// content_id. Any regression in the analyzer, the schema, or the
    /// warehouse write/read path turns this red.
    #[test]
    fn analyze_persist_readback_selfexe() {
        let bin = std::env::current_exe().expect("current test exe");

        // Analyse the real artifact (root `/` keeps every DWARF symbol).
        let report = analyze(&bin, Some(Path::new("/"))).expect("analyze self-exe");
        assert!(report.header.total_bytes > 0, "total bytes decoded");
        assert!(
            report
                .sections
                .iter()
                .any(|s| s.name == ".text" && s.size_bytes > 0),
            "non-empty .text section"
        );
        assert!(
            report.symbols.iter().any(|s| s.size_bytes > 0),
            "sized symbols"
        );
        assert!(
            matches!(report.header.relro.as_str(), "none" | "partial" | "full"),
            "hardening verdict decoded"
        );

        // Persist into a throwaway warehouse keyed by the artifact's real sha256.
        let root = tempfile::tempdir().expect("tempdir");
        let wh =
            IcebergWarehouse::open(&root.path().join(".nornir/warehouse")).expect("open warehouse");
        let cid = content_sha256(&bin).expect("content sha256");
        let snap = persist_report(
            &wh,
            &report,
            "nornir",
            "testsha",
            &cid,
            &bin.display().to_string(),
        )
        .expect("persist");
        assert_ne!(snap, Uuid::nil(), "a capture snapshot id was assigned");

        // Read back keyed by content_id and prove the rows survived.
        let (header, sections, symbols) = load_analysis(&wh, &cid)
            .expect("load")
            .expect("rows present for content_id");
        assert_eq!(header.content_sha256, cid, "header keyed by content_id");
        assert_eq!(
            header.total_bytes, report.header.total_bytes as i64,
            "total bytes round-trip"
        );
        assert_eq!(
            header.relro, report.header.relro,
            "hardening verdict round-trips"
        );
        assert!(
            sections
                .iter()
                .any(|s| s.section_name == ".text" && s.size_bytes > 0),
            "section rows persisted + read back"
        );
        assert_eq!(
            sections.len(),
            report.sections.len(),
            "all section rows persisted"
        );
        assert!(!symbols.is_empty(), "symbol rows persisted + read back");
        assert_eq!(
            symbols.len(),
            report.symbols.len(),
            "all symbol rows persisted"
        );
        assert!(
            symbols.iter().any(|s| s.source_file.is_some()),
            "≥1 DWARF-attributed symbol round-tripped"
        );

        // A never-analysed artifact reads back as absent.
        assert!(
            load_analysis(
                &wh,
                "0000000000000000000000000000000000000000000000000000000000000000"
            )
            .expect("load miss")
            .is_none(),
            "unknown content_id misses cleanly"
        );
    }

    /// RED-WHEN-BROKEN end-to-end proof (task #65 slice 2): analyse the **test
    /// binary itself** as the base, build a **mutated** report standing in for a
    /// rebuilt artifact (bigger, one symbol dropped, one exported symbol added,
    /// one symbol grown), diff base → new, persist the `artifact_diff` rows keyed
    /// on the (base, new) content-id **pair**, and read them back. Asserts the
    /// headline size delta, the added/removed symbols, and the ABI-surface add are
    /// all detected AND survive the warehouse round-trip keyed on the pair. Any
    /// regression in the diff engine, the schema, or the pair-keyed write/read
    /// path turns this red.
    #[test]
    fn diff_persist_readback_pair() {
        use nornir_introspect::size::SymbolSize;

        let bin = std::env::current_exe().expect("current test exe");
        let base = analyze(&bin, Some(Path::new("/"))).expect("analyze base");
        assert!(!base.symbols.is_empty(), "base has symbols");

        // A mutated copy: +4096 bytes, drop the last symbol, add a NEW exported
        // symbol, and grow the first symbol by 128.
        let mut new = base.clone();
        new.header.total_bytes += 4096;
        let removed = new.symbols.pop().expect("a symbol to drop");
        new.symbols[0].size_bytes += 128;
        let grown_mangled = new.symbols[0].name_mangled.clone();
        new.symbols.push(SymbolSize {
            name_mangled: "_ZN14slice2_fixture9brand_newE".into(),
            name_demangled: "slice2_fixture::brand_new".into(),
            name_demangled_no_generics: "slice2_fixture::brand_new".into(),
            crate_name: "slice2_fixture".into(),
            section: ".text".into(),
            size_bytes: 777,
            kind: "Text".into(),
            binding: "global".into(), // exported → ABI-surface add
            is_monomorphization: false,
            source_file: None,
            source_line: None,
        });

        // Diff the pair and assert the engine caught every effect.
        let diff = diff_reports(&base, &new);
        assert_eq!(diff.total_delta_bytes, 4096, "headline size delta detected");
        assert!(
            diff.symbols_added
                .iter()
                .any(|s| s.name_mangled == "_ZN14slice2_fixture9brand_newE"),
            "added symbol detected"
        );
        assert!(
            diff.symbols_removed
                .iter()
                .any(|s| s.name_mangled == removed.name_mangled),
            "removed symbol detected"
        );
        assert!(
            diff.symbols_grown
                .iter()
                .any(|s| s.name_mangled == grown_mangled && s.delta_bytes == 128),
            "grown symbol detected with delta"
        );
        assert!(
            diff.abi
                .added
                .iter()
                .any(|s| s.name_mangled == "_ZN14slice2_fixture9brand_newE"),
            "ABI-surface add detected"
        );

        // Persist the diff keyed on the (base, new) content-id PAIR, read back.
        let root = tempfile::tempdir().expect("tempdir");
        let wh =
            IcebergWarehouse::open(&root.path().join(".nornir/warehouse")).expect("open warehouse");
        let from = content_sha256(&bin).expect("base sha256");
        // The "new" artifact's content id — a distinct, real sha over the mutated
        // bytes (base bytes + a 4096-byte tail), so the pair key is two real ids.
        let mut mutated = std::fs::read(&bin).expect("read base bytes");
        mutated.extend(std::iter::repeat(0u8).take(4096));
        let to = {
            let mut h = nornir_hash::Sha256Hasher::new();
            h.update(&mutated);
            h.finalize_hex()
        };
        assert_ne!(from, to, "the pair has two distinct content ids");

        let snap = persist_diff(&wh, &diff, "nornir", "", &from, &to).expect("persist diff");
        assert_ne!(snap, Uuid::nil(), "a capture snapshot id was assigned");

        let rows = load_diff(&wh, &from, &to).expect("load diff");
        assert!(!rows.is_empty(), "diff rows round-trip keyed on the pair");
        // Every row carries the pair.
        assert!(
            rows.iter()
                .all(|r| r.from_content_sha256 == from && r.to_content_sha256 == to),
            "all rows keyed on the pair"
        );
        // The headline size delta survived.
        let size_row = rows
            .iter()
            .find(|r| r.kind == "size_delta" && r.name == "total_bytes")
            .expect("size_delta row persisted");
        assert_eq!(size_row.delta_bytes, 4096, "size delta round-trips");
        // Added / removed symbol churn survived.
        assert!(
            rows.iter()
                .any(|r| r.kind == "symbol_added" && r.name == "_ZN14slice2_fixture9brand_newE"),
            "symbol_added row persisted"
        );
        assert!(
            rows.iter()
                .any(|r| r.kind == "symbol_removed" && r.name == removed.name_mangled),
            "symbol_removed row persisted"
        );
        // The ABI-surface add survived as its own kind.
        assert!(
            rows.iter()
                .any(|r| r.kind == "abi_added" && r.name == "_ZN14slice2_fixture9brand_newE"),
            "abi_added row persisted"
        );

        // A never-diffed pair reads back empty.
        assert!(
            load_diff(
                &wh,
                &from,
                "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"
            )
            .expect("load miss")
            .is_empty(),
            "unknown pair misses cleanly"
        );
    }

    /// RED-WHEN-BROKEN end-to-end proof (task #65 slice 4a): derive the
    /// binary-derived SBOM from the **test binary itself**, persist it into a fresh
    /// warehouse, and read it back — asserting the components landed in
    /// `sbom_components` with `derived_from = "binary"` (the column that
    /// distinguishes the shipped-bytes SBOM from the source cargo-metadata one). Any
    /// regression in the derivation, the new column, or the write/read path turns
    /// this red.
    #[test]
    fn binary_sbom_persist_readback_derived_from_binary() {
        let bin = std::env::current_exe().expect("current test exe");
        let report = analyze(&bin, Some(Path::new("/"))).expect("analyze self-exe");

        let comps = derive_components(&report);
        assert!(
            !comps.is_empty(),
            "≥1 binary-derived component from the bytes"
        );

        let root = tempfile::tempdir().expect("tempdir");
        let wh =
            IcebergWarehouse::open(&root.path().join(".nornir/warehouse")).expect("open warehouse");
        let n = persist_binary_sbom(&wh, "nornir-selftest", &report).expect("persist binary sbom");
        assert_eq!(n, comps.len(), "every derived component persisted");

        let back = wh
            .query_sbom_components("nornir-selftest")
            .expect("query")
            .expect("rows present");
        assert_eq!(back.len(), comps.len(), "all rows round-trip");
        assert!(
            back.iter().all(|r| r.derived_from == "binary"),
            "every persisted component is tagged derived_from=binary"
        );
        // The largest attributed crate is present by name (a real shipped component).
        let top = &comps[0].name;
        assert!(
            back.iter().any(|r| &r.name == top),
            "the top binary-derived component ({top}) round-tripped"
        );
    }
}