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, §ions, &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, §ions, &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"
);
}
}