src/mimir_lens/binary_drift.rs
//! **Mimir.BinaryDrift — source(AST) vs binary(DWARF) diff = a compile-time
//! injection detector.**
//!
//! The attestation this lens gives is the one no SBOM/SAST gives: *the running
//! binary matches the audited source*. It grounds directly in the
//! reproducible-builds / source-to-binary attestation problem (SLSA provenance,
//! and the SolarWinds / XcodeGhost class of build-time compromise) and in Rust's
//! codegen semantics (`build.rs` output, proc-macro expansion, monomorphisation).
//!
//! ## The rule
//!
//! Any **symbol or call present in the compiled BINARY graph that is NOT derivable
//! from the SOURCE AST graph was injected at compile time** — by an evil
//! `build.rs`, a malicious proc-macro (whose codegen never appears in the git
//! diff), or a compromised compiler/toolchain. Drift is about EXTRA binary content
//! not implied by source; a *missing* edge (inlined away) is NOT injection and is
//! never flagged.
//!
//! ## Inputs
//!
//! - Binary side: [`BinSym`] — one per linked code symbol of the code room's
//! binary stream (demangled, generics stripped, crate-attributed), with two
//! facts the room derives by joining the two streams on the node id:
//! `first_party` (the node belongs to one of the repo's own crates) and
//! `in_source` (the node is a source definition too — provenance `both` — or
//! a monomorphised instance of one). "Same node, two provenances differ" is
//! the drift.
//! - [`BinEdge`] — binary call edges, and [`SourceCalls`] — what each source
//! caller names. NO producer writes binary call edges today (the DWARF and
//! LLVM call graphs are gone, producer gap P4): the edge half of the fold is
//! kept and tested, and the lens says it was not evaluated.
//!
//! ## The HARD part — false-positive modelling
//!
//! A naïve `binary − source` set difference drowns: the binary carries all of
//! `std`/`core`/`alloc`, every dependency crate, and one concrete symbol per
//! generic instantiation. The model that turns the noise into signal:
//!
//! 1. **First-party scoping.** Only binary symbols of the repo's own crates are
//! judged. `std`/deps/runtime are legitimately binary-only and out of scope —
//! not flagged.
//! 2. **Monomorphisation.** A generic `f` compiles to `f::<A>`, `f::<B>`, … —
//! each is a `mono_instance` node with an `INSTANCE_OF` edge to the source
//! generic. An instance of a source item is EXPLAINED.
//! 3. **Compiler-generated glue.** Drop glue (`drop_in_place`), closures
//! (`{{closure}}`), shims/thunks, vtables, `#[lang]` items, panic/format
//! machinery, and derive trait-impls (`<T as core::fmt::Debug>::fmt`,
//! `Clone`/`Hash`/`serde::…`) are legit codegen — allowlisted.
//! 4. **Expected vs surprising codegen.** `serde` derive, `Debug`, etc. are
//! expected. The SIGNAL is codegen (a `build.rs` output or proc-macro expansion)
//! that reaches an EXTERNAL/unexpected symbol — process spawn, exec, network —
//! not implied by the source.
//!
//! The residual boundary is a heuristic, not a proof; [`BinaryDriftReport`]'s
//! `evidence_note` and this module's [`CANT_CATCH`] state it honestly.
//!
//! ## What the wire lens answers now: successive Build Thing artifacts
//!
//! Owner, 2026-10-17: Build Thing runs feed the binary lenses and the reader
//! never links — so no room holds linked symbols to join against the source,
//! and the source ↔ binary fold above ([`report_from`]) has no feed.
//! `Mimir.BinaryDrift` answers [`successive`]: the same artifact (one
//! `(repo, variant, file, target, features)` line) with two provenances that
//! differ, successive filings compared symbol by symbol from their analyses.
use std::collections::{BTreeMap, BTreeSet};
use serde::{Deserialize, Serialize};
use super::sources::{Feed, evidence_note};
/// The [`BinSym::kind`] of executable code — the only symbols a call/injection
/// can hide in.
pub const TEXT_KIND: &str = "Text";
/// **What this lens cannot catch — stated up front, not buried.**
///
/// Detection with an honest false-negative boundary, not an attestation proof:
///
/// - A backdoor whose symbol the binary layer joins to a real source item (the
/// same path as a legitimate item) is indistinguishable and is MISSED.
/// - Source-level backdoors — malicious code committed in the git tree — are NOT
/// this lens's job (the source *is* the reference here); that is Villa's / the
/// change lenses' domain.
/// - A toolchain that rewrites the BODY of an existing function without adding a
/// symbol or an inline edge leaves the graph unchanged and is MISSED (drift is
/// about EXTRA content, not mutated content).
/// - Injected CALLS from a symbol that is itself legitimate: no producer writes
/// binary call edges today (P4), so only injected *symbols* are caught.
/// - A fully stripped release binary yields no symbols → the lens cannot attest and
/// says so via a MISSING-FEED evidence note rather than a false "clean".
pub const CANT_CATCH: &[&str] = &[
"a backdoor whose symbol joins to a legitimate source item (same path)",
"source-level backdoors committed in the git tree (Villa's job, not this lens's)",
"a toolchain that rewrites an existing fn body without adding a symbol/edge",
"an injected call from a legitimate symbol — binary call edges have no producer yet (P4)",
"a fully stripped binary — no symbols to diff (reported as a missing feed)",
];
// ─── benign-codegen allowlist ───────────────────────────────────────────────
/// Symbol-name fragments that mark COMPILER-GENERATED glue — never source-derived,
/// always legit. Monomorphisation is handled separately (name-strip + map-back).
const GLUE_MARKERS: &[&str] = &[
"drop_in_place",
"drop_glue",
// rustc-demangle v0 (legacy) forms:
"{{closure}}",
"{{constructor}}",
"{{shim}}",
"{{vtable}}",
"{{reify",
// rustc-demangle v0 `{:#}` (modern) forms — a disambiguated brace segment:
"{closure", // `{closure#0}`
"{constructor",
"{destructor",
"{shim", // `{shim:vtable#0}` / fn-ptr shims
"{vtable",
"{reify",
"::vtable",
".llvm.", // llvm-internal outlined helpers
"__rust_", // runtime hooks (alloc/dealloc/panic)
"rust_eh_personality",
"rust_begin_unwind",
"__rg_", // allocator shims
];
/// Path prefixes for crates that are legitimately binary-only: the standard
/// library and its lang-item machinery. A first-party symbol never starts with
/// these; they only appear when a symbol's crate attribution fell back to a
/// `std`/`core` path, in which case it is out of scope.
const RUNTIME_PREFIXES: &[&str] = &[
"std::",
"core::",
"alloc::",
"proc_macro::",
"test::",
"<core::",
"<alloc::",
"<std::",
];
/// Standard traits whose impls (incl. `#[derive]`d ones) are expected codegen — a
/// `<First as Std>::method` symbol is glue, not injection.
const DERIVE_TRAITS: &[&str] = &[
"core::fmt::Debug",
"core::fmt::Display",
"core::clone::Clone",
"core::cmp::PartialEq",
"core::cmp::Eq",
"core::cmp::PartialOrd",
"core::cmp::Ord",
"core::hash::Hash",
"core::default::Default",
"core::marker::",
"core::ops::Drop",
"serde::ser::Serialize",
"serde::de::Deserialize",
"_serde::",
];
/// Callee-name fragments that mark a SURPRISING external reach — process spawn,
/// exec, network. A first-party symbol or codegen edge that reaches one of these
/// with no source counterpart is the demo-lethal signal. `(fragment, tag)`.
const SUSPICIOUS_EXTERNAL: &[(&str, &str)] = &[
("std::process::Command", "process-spawn"),
("process::Command", "process-spawn"),
("std::process::exit", "process-exit"),
("::exec", "exec"),
("execvp", "exec"),
("execve", "exec"),
("libc::system", "shell"),
("std::net::", "network"),
("TcpStream", "network"),
("UdpSocket", "network"),
("::connect", "network"),
("socket", "network"),
("reqwest::", "network"),
("hyper::", "network"),
];
/// A name without a trailing `::` (some demanglers leave one where generic
/// arguments were stripped: `foo::bar::<u32>` → `foo::bar::`).
fn base_name(name: &str) -> &str {
name.trim_end().trim_end_matches("::")
}
/// The last path segment of a demangled name (after [`base_name`] normalisation).
fn tail(name: &str) -> &str {
base_name(name).rsplit("::").next().unwrap_or(name)
}
/// Is this a compiler-generated glue symbol (never source-derived)?
pub fn is_glue(no_generics: &str) -> bool {
GLUE_MARKERS.iter().any(|m| no_generics.contains(m))
}
/// Is this a standard-library / runtime symbol (legitimately binary-only)?
pub fn is_runtime(no_generics: &str) -> bool {
RUNTIME_PREFIXES.iter().any(|p| no_generics.starts_with(p))
}
/// Is this an impl of a standard/derive trait — expected codegen, not injection?
/// Recognises the `<Type as Trait>::method` demangled shape.
pub fn is_derive_impl(no_generics: &str) -> bool {
if let Some(rest) = no_generics.split(" as ").nth(1) {
// rest ~ "core::fmt::Debug>::fmt"
return DERIVE_TRAITS.iter().any(|t| rest.starts_with(t));
}
false
}
/// If `name` reaches a surprising external symbol, the tag for it.
pub fn suspicious_tag(name: &str) -> Option<&'static str> {
SUSPICIOUS_EXTERNAL
.iter()
.find(|(frag, _)| name.contains(frag))
.map(|(_, t)| *t)
}
// ─── typed inputs ───────────────────────────────────────────────────────────
/// A demangled name with its generic ARGUMENTS removed: `a::f::<u32>` →
/// `a::f`, `a::S::<T>::m` → `a::S::m`. A leading `<T as Trait>` is not an
/// argument list and stays.
pub fn erase_generics(demangled: &str) -> String {
let b = demangled.as_bytes();
let mut out = String::with_capacity(b.len());
let mut i = 0;
while i < b.len() {
if b[i..].starts_with(b"::<") {
// skip the balanced `<…>` (a `->` inside is not a bracket)
let mut depth = 0i32;
let mut j = i + 2;
while j < b.len() {
match b[j] {
b'<' => depth += 1,
b'>' if j > 0 && b[j - 1] == b'-' => {}
b'>' => {
depth -= 1;
if depth == 0 {
break;
}
}
_ => {}
}
j += 1;
}
i = (j + 1).min(b.len());
continue;
}
let ch = demangled[i..].chars().next().unwrap_or(' ');
out.push(ch);
i += ch.len_utf8();
}
out
}
/// One defined BINARY code symbol, as the code room's binary stream holds it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BinSym {
/// Fully demangled name, hash-suffix dropped (`{:#}` form).
pub demangled: String,
/// The same name with generic arguments stripped ([`erase_generics`]).
pub no_generics: String,
/// Crate the symbol's node belongs to.
pub crate_name: String,
/// Section kind (`Text` = executable code).
pub kind: String,
/// A monomorphised instance (a `mono_instance` node).
pub is_monomorphization: bool,
/// The declaring file, when the binary side knows one.
pub source_file: Option<String>,
pub source_line: Option<u32>,
/// Its node belongs to one of the repo's own crates.
pub first_party: bool,
/// Its node is a source definition as well (provenance `both`), or it is
/// an instance of one (`INSTANCE_OF`): the source explains it.
pub in_source: bool,
}
/// One binary call edge, demangled + generics-stripped both ends.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BinEdge {
pub caller: String,
pub callee: String,
}
/// The binary graph for one artifact/repo.
#[derive(Debug, Clone, Default)]
pub struct BinaryGraph {
pub symbols: Vec<BinSym>,
pub edges: Vec<BinEdge>,
/// The binary side carries declaring files ([`BinSym::source_file`]): an
/// unexplained symbol without one is then the proc-macro fingerprint.
/// `false` = no file is known for ANY symbol, and its absence says nothing.
pub has_decl_files: bool,
}
/// Per source function (its path's last segment), the callee names it makes —
/// so an injected EDGE (the binary caller reaches a callee the source caller
/// never names) can be told from a real one.
#[derive(Debug, Clone, Default)]
pub struct SourceCalls {
pub calls: BTreeMap<String, BTreeSet<String>>,
}
impl SourceCalls {
pub fn of(pairs: &[(&str, &str)]) -> Self {
let mut g = SourceCalls::default();
for (caller, callee) in pairs {
g.calls.entry(tail(caller).to_string()).or_default().insert(tail(callee).to_string());
}
g
}
}
/// Is `sym` benign codegen or explained by the source?
fn explained(sym: &BinSym) -> bool {
is_glue(&sym.no_generics) || is_runtime(&sym.no_generics) || is_derive_impl(&sym.no_generics) || sym.in_source
}
// ─── findings ───────────────────────────────────────────────────────────────
/// How a binary-only delta was attributed to its likely origin.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum DriftKind {
/// A first-party code symbol with no source counterpart (and not glue/mono),
/// where the binary side knows no declaring file to attribute it by.
UnexplainedSymbol,
/// A first-party call edge whose callee the source caller never names.
UnexplainedEdge,
/// An unexplained symbol whose DWARF source-file points at build-script output.
BuildrsOrigin,
/// An unexplained symbol attributed to a first-party crate but with no in-repo
/// source file — the proc-macro-expansion fingerprint.
ProcMacroOrigin,
/// Unexplained AND in-repo-attributed — nothing in source, `build.rs`, or a
/// macro explains it: the compiler/toolchain itself is the only candidate.
Toolchain,
}
impl DriftKind {
pub fn as_str(self) -> &'static str {
match self {
DriftKind::UnexplainedSymbol => "unexplained-symbol",
DriftKind::UnexplainedEdge => "unexplained-edge",
DriftKind::BuildrsOrigin => "buildrs-origin",
DriftKind::ProcMacroOrigin => "proc-macro-origin",
DriftKind::Toolchain => "toolchain",
}
}
}
/// One binary-only delta ranked by suspicion.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Finding {
pub repo: String,
/// The demangled binary symbol (the caller, for an edge finding).
pub binary_symbol: String,
/// Generics-stripped form (the map-back key that FAILED to map).
pub demangled: String,
/// The surprising callee, for an edge finding or a symbol that reaches one.
pub callee: Option<String>,
pub file: Option<String>,
pub line: Option<u32>,
pub kind: DriftKind,
pub severity: String,
pub evidence_note: String,
}
/// Rank weight: high severity first, then edge/toolchain over plain symbols.
fn weight(f: &Finding) -> u32 {
let sev = match f.severity.as_str() {
"high" => 300,
"medium" => 200,
_ => 100,
};
let kind = match f.kind {
DriftKind::UnexplainedEdge => 40,
DriftKind::Toolchain => 30,
DriftKind::BuildrsOrigin => 20,
DriftKind::ProcMacroOrigin => 15,
DriftKind::UnexplainedSymbol => 10,
};
sev + kind
}
/// The whole attestation reading.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct BinaryDriftReport {
pub findings: Vec<Finding>,
/// Total binary code symbols examined.
pub binary_symbols_seen: usize,
/// Of those, how many were attributed to a first-party (scanned) crate.
pub first_party_symbols: usize,
/// First-party symbols the source explained (mono/glue/derive/named).
pub explained: usize,
/// First-party symbols nothing explained — the finding count's symbol part.
pub unexplained: usize,
/// `true` ⇔ zero findings ⇔ the binary is derivable from the audited source.
pub attested: bool,
/// Binary call edges were read: the EDGE half of the diff was evaluated.
/// `false` = only symbols were compared (no producer writes the edges).
pub edges_evaluated: bool,
pub repos_with_drift: usize,
pub by_kind: BTreeMap<String, usize>,
pub feeds: Vec<Feed>,
pub evidence_note: String,
}
impl BinaryDriftReport {
/// The one-line attestation summary the pane leads with.
pub fn attestation(&self) -> String {
if self.first_party_symbols == 0 {
return "binary matches source: UNKNOWN — no first-party binary symbols read \
(stripped binary or missing feed); cannot attest."
.to_string();
}
if self.attested && !self.edges_evaluated {
format!(
"binary matches source: SYMBOLS ONLY — all {} first-party binary symbols derive \
from the audited source (0 unexplained); injected CALLS were not looked for \
(no binary call edges).",
self.first_party_symbols
)
} else if self.attested {
format!(
"binary matches source: YES — all {} first-party binary symbols derive from \
the audited source (0 unexplained).",
self.first_party_symbols
)
} else {
format!(
"binary matches source: NO — {} unexplained binary delta(s) not derivable from \
source; each was injected at compile time (build.rs / proc-macro / toolchain).",
self.findings.len()
)
}
}
pub fn state_json(&self) -> serde_json::Value {
let findings: Vec<serde_json::Value> = self
.findings
.iter()
.map(|f| {
serde_json::json!({
"repo": f.repo,
"binary_symbol": f.binary_symbol,
"demangled": f.demangled,
"callee": f.callee,
"file": f.file,
"line": f.line,
"kind": f.kind.as_str(),
"severity": f.severity,
"loc": match (&f.file, f.line) {
(Some(fp), Some(l)) => format!("{fp}:{l}"),
(Some(fp), None) => fp.clone(),
_ => String::new(),
},
"symbol": f.demangled,
"evidence_note": f.evidence_note,
})
})
.collect();
serde_json::json!({
"kind": "binary_drift",
"attested": self.attested,
"edges_evaluated": self.edges_evaluated,
"attestation": self.attestation(),
"binary_symbols_seen": self.binary_symbols_seen,
"first_party_symbols": self.first_party_symbols,
"explained": self.explained,
"unexplained": self.unexplained,
"repos_with_drift": self.repos_with_drift,
"by_kind": self.by_kind,
"cant_catch": CANT_CATCH,
"evidence_note": self.evidence_note,
"feeds": self.feeds,
// ranked list under its own key AND the shared `worst` alias so one
// pane renders every lens uniformly.
"findings": findings,
"worst": findings,
})
}
}
// ─── the pure fold ──────────────────────────────────────────────────────────
/// Does a DWARF source file look like build-script output (an `OUT_DIR` include)?
fn is_build_output(file: &str) -> bool {
let f = file.replace('\\', "/");
f.contains("/out/")
|| f.contains("/build/")
|| f.contains("OUT_DIR")
|| f.ends_with("/out")
|| f.contains("/target/")
}
/// Attribute an unexplained first-party symbol to its likely origin.
fn attribute(sym: &BinSym, has_decl_files: bool, suspicious: Option<&str>) -> (DriftKind, String) {
let sev = if suspicious.is_some() {
"high"
} else {
"medium"
};
let kind = match &sym.source_file {
Some(f) if is_build_output(f) => DriftKind::BuildrsOrigin,
Some(_) => DriftKind::Toolchain,
// first-party crate but NO in-repo source location — the macro-expansion
// fingerprint (expanded tokens carry the macro's span, not a repo file).
None if has_decl_files => DriftKind::ProcMacroOrigin,
// no declaring file is known for any symbol: nothing to attribute by
None => DriftKind::UnexplainedSymbol,
};
(kind, sev.to_string())
}
/// **The heart — fold one repo's binary graph against its source graph.**
///
/// Pure over typed inputs so the RED-WHEN-BROKEN proof can drive it with a real
/// compiled fixture, and so unit tests can assert the false-positive model exactly.
pub fn fold_repo(repo: &str, binary: &BinaryGraph, source: &SourceCalls) -> Vec<Finding> {
let mut findings: Vec<Finding> = Vec::new();
// Index the binary symbols' outgoing suspicious reach by caller tail, so a
// symbol that itself calls Command/exec/net is flagged high even without a
// separate edge finding.
let mut suspicious_out: BTreeMap<String, String> = BTreeMap::new();
for e in &binary.edges {
if let Some(tag) = suspicious_tag(&e.callee) {
suspicious_out
.entry(tail(&e.caller).to_string())
.or_insert_with(|| format!("{} ({})", e.callee, tag));
}
}
// ── unexplained SYMBOLS ──
let mut flagged: BTreeSet<String> = BTreeSet::new();
for s in &binary.symbols {
if s.kind != TEXT_KIND {
continue;
}
// first-party scoping: only the repo's own crates are judged.
if !s.first_party {
continue;
}
// benign codegen (glue, runtime, derive impls) or a source item / an
// instance of one.
if explained(s) {
continue;
}
// Unexplained. Attribute + rank.
let own = suspicious_tag(&s.demangled);
let reach = suspicious_out.get(tail(&s.no_generics)).cloned();
let suspicious = own
.map(|t| format!("{} ({})", s.demangled, t))
.or(reach.clone());
let (kind, severity) = attribute(s, binary.has_decl_files, suspicious.as_deref());
let note = format!(
"binary symbol `{}` (crate `{}`) has no source counterpart — not an \
instance of any source generic, not compiler glue. Origin: {}.",
s.demangled,
s.crate_name,
kind.as_str()
);
flagged.insert(tail(&s.no_generics).to_string());
findings.push(Finding {
repo: repo.to_string(),
binary_symbol: s.demangled.clone(),
demangled: s.no_generics.clone(),
callee: suspicious,
file: s.source_file.clone(),
line: s.source_line,
kind,
severity,
evidence_note: note,
});
}
// ── unexplained EDGES ── a first-party caller reaching a surprising external
// callee the source caller never names. (Skip callers already flagged as a
// whole symbol — the edge is implied by the symbol finding.)
let first_party_callers: BTreeSet<String> = binary.symbols.iter().filter(|s| s.first_party).map(|s| tail(&s.no_generics).to_string()).collect();
for e in &binary.edges {
let Some(tag) = suspicious_tag(&e.callee) else {
continue;
};
let caller_tail = tail(&e.caller).to_string();
if !first_party_callers.contains(&caller_tail) || flagged.contains(&caller_tail) {
continue;
}
// derivable if the source caller names this callee (by tail).
let derivable = source
.calls
.get(&caller_tail)
.map(|s| s.contains(tail(&e.callee)))
.unwrap_or(false);
if derivable {
continue;
}
findings.push(Finding {
repo: repo.to_string(),
binary_symbol: e.caller.clone(),
demangled: e.caller.clone(),
callee: Some(format!("{} ({})", e.callee, tag)),
file: None,
line: None,
kind: DriftKind::UnexplainedEdge,
severity: "high".to_string(),
evidence_note: format!(
"binary call `{}` → `{}` ({tag}) has no source call edge — a codegen-injected \
reach to an external capability.",
e.caller, e.callee
),
});
}
findings
}
/// Assemble a whole report from per-repo graphs (feeds already recorded).
pub fn report_from(
per_repo: BTreeMap<String, (BinaryGraph, SourceCalls)>,
edges_evaluated: bool,
feeds: Vec<Feed>,
top_n: usize,
) -> BinaryDriftReport {
let mut findings: Vec<Finding> = Vec::new();
let mut binary_symbols_seen = 0usize;
let mut first_party_symbols = 0usize;
let mut explained = 0usize;
let mut repos_with_drift = 0usize;
for (repo, (bin, src)) in &per_repo {
for s in &bin.symbols {
if s.kind != TEXT_KIND {
continue;
}
binary_symbols_seen += 1;
if !s.first_party {
continue;
}
first_party_symbols += 1;
if self::explained(s) {
explained += 1;
}
}
let repo_findings = fold_repo(repo, bin, src);
if !repo_findings.is_empty() {
repos_with_drift += 1;
}
findings.extend(repo_findings);
}
let unexplained = findings
.iter()
.filter(|f| f.kind != DriftKind::UnexplainedEdge)
.count();
findings.sort_by(|a, b| {
weight(b)
.cmp(&weight(a))
.then_with(|| a.repo.cmp(&b.repo))
.then_with(|| a.binary_symbol.cmp(&b.binary_symbol))
});
let mut by_kind: BTreeMap<String, usize> = BTreeMap::new();
for f in &findings {
*by_kind.entry(f.kind.as_str().to_string()).or_default() += 1;
}
let attested = findings.is_empty();
if top_n > 0 {
findings.truncate(top_n);
}
let note = evidence_note(&feeds, "binary drift (source ↔ binary attestation)");
BinaryDriftReport {
findings,
binary_symbols_seen,
first_party_symbols,
explained,
unexplained,
attested,
edges_evaluated,
repos_with_drift,
by_kind,
feeds,
evidence_note: note,
}
}
// ─── successive Build Thing artifacts — what the wire lens answers ──────────
//
// Owner, 2026-10-17: Build Thing runs feed the binary lenses and the reader
// never links. Builder 2 files every artifact a Build Thing run made (the
// `@build-thing` products: provenance in `bin_ingest_runs.ledger`, the binary
// facts by sha256), so the drift `Mimir.BinaryDrift` answers is the SAME
// artifact — one (repo, variant, file, target, features) line — with two
// provenances that differ: successive filings compared symbol by symbol. A
// change the provenance explains (another commit, another toolchain) is
// reported for what it is; bytes that moved while the provenance did not
// (same commits, same toolchain, same features) are the finding no source
// diff shows: a non-reproducible build or a build-time injection.
use nornir_introspect::diff::{ArtifactDiff, SymbolDelta};
/// What a size diff between two filings cannot see — said up front.
pub const SUCCESSIVE_CANT_CATCH: &[&str] = &[
"a body change that keeps every symbol's size: `symbol_sizes` carry sizes, not bytes — it shows only as differing artifact bytes (sha256)",
"a change inside a function the compiler inlined: it shows on its callers, not under its own name",
"a stripped artifact: no symbols to compare (its analysis says `stripped`)",
"an artifact filed from another box: its bytes were not readable on this server, so it has no analysis",
"the first Build Thing artifact of a variant: there is nothing earlier to compare it with",
];
/// One artifact a Build Thing run filed, as the drift fold reads its
/// provenance (`bin_ingest_runs.ledger` of `@build-thing`).
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Filing {
/// The repo whose recipe declares the variant.
pub repo: String,
pub variant: String,
#[serde(default)]
pub package: Option<String>,
/// The file name (`gunnar-server`).
pub file: String,
/// `<triple>:<profile>`.
pub target: String,
pub features: Vec<String>,
/// Content hash — the artifact's identity.
pub sha256: String,
/// The owner of its keys in the lake (`file@triple:profile#sha16`).
pub owner: String,
pub bytes: u64,
pub build_id: String,
/// RFC 3339.
pub built_at: String,
/// The binary release ordinal of the filing (`@build-thing`'s rev): the
/// order filings landed in.
pub rev: i64,
/// repo → commit the build read.
pub commits: BTreeMap<String, String>,
pub toolchain: String,
pub builder: String,
}
impl Filing {
/// The line successive filings are compared along:
/// `(repo, variant, file, target, features)`.
pub fn line(&self) -> (String, String, String, String, String) {
let mut f = self.features.clone();
f.sort();
(self.repo.clone(), self.variant.clone(), self.file.clone(), self.target.clone(), f.join(","))
}
/// The commit of the recipe's own repo (short), `?` when unknown.
pub fn commit(&self) -> String {
self.commits.get(&self.repo).map(|s| s[..s.len().min(10)].to_string()).unwrap_or_else(|| "?".to_string())
}
/// Is `crate_name` the build's own code (the built package, or the repo's
/// crate of the same name)?
pub fn first_party(&self, crate_name: &str) -> bool {
let n = |s: &str| s.replace('-', "_");
let c = n(crate_name);
!c.is_empty() && (self.package.as_deref().is_some_and(|p| n(p) == c) || n(&self.repo) == c || n(&self.file) == c)
}
fn json(&self) -> serde_json::Value {
serde_json::json!({
"sha256": self.sha256, "owner": self.owner, "build_id": self.build_id, "built_at": self.built_at,
"commit": self.commit(), "commits": self.commits, "toolchain": self.toolchain, "bytes": self.bytes, "rev": self.rev,
})
}
}
/// Two successive filings of one line, and the diff of their analyses
/// (`None` = one of them was filed but not analysed on this server).
#[derive(Debug, Clone)]
pub struct Step {
pub from: Filing,
pub to: Filing,
pub diff: Option<ArtifactDiff>,
}
impl Step {
/// What differs between the two provenances (`commit <repo> a→b`,
/// `toolchain …`, `builder …`): empty = the same provenance.
pub fn provenance_changed(&self) -> Vec<String> {
let mut out = Vec::new();
let repos: BTreeSet<&String> = self.from.commits.keys().chain(self.to.commits.keys()).collect();
for r in repos {
let (a, b) = (self.from.commits.get(r), self.to.commits.get(r));
if a != b {
let s = |x: Option<&String>| x.map(|s| s[..s.len().min(10)].to_string()).unwrap_or_else(|| "-".to_string());
out.push(format!("commit {r} {}→{}", s(a), s(b)));
}
}
if self.from.toolchain != self.to.toolchain {
out.push(format!("toolchain `{}`→`{}`", self.from.toolchain, self.to.toolchain));
}
out
}
/// The source read was the same (every commit equal).
pub fn same_source(&self) -> bool {
self.from.commits == self.to.commits
}
}
/// What a changed symbol is, beside its change.
fn class_of(no_generics: &str) -> &'static str {
if is_glue(no_generics) {
"glue"
} else if is_runtime(no_generics) {
"runtime"
} else if is_derive_impl(no_generics) {
"derive"
} else {
"code"
}
}
/// One changed symbol between two successive filings.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Change {
pub repo: String,
pub variant: String,
pub artifact: String,
/// `symbol-added` · `symbol-removed` · `symbol-grew` · `symbol-shrunk`.
pub kind: String,
pub symbol: String,
pub no_generics: String,
pub crate_name: String,
pub class: String,
pub before: u64,
pub after: u64,
pub delta_bytes: i64,
pub first_party: bool,
/// What explains it: `source` (a commit moved), `toolchain`, or `nothing`.
pub explained_by: String,
/// `high` (unexplained, or an added reach to process / exec / network),
/// `medium` (first-party code), `low` (dependencies, std, glue).
pub severity: String,
/// The surprising reach an added symbol makes (`process-spawn` …).
pub reach: Option<String>,
pub from_commit: String,
pub to_commit: String,
pub from_sha: String,
pub to_sha: String,
}
/// The successive-drift reading.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct SuccessiveReport {
pub changes: Vec<Change>,
pub steps: Vec<serde_json::Value>,
/// Lines with only one filing (nothing to compare yet).
pub single: Vec<String>,
pub symbols_seen: usize,
pub first_party_symbols: usize,
pub explained: usize,
pub unexplained: usize,
pub by_kind: BTreeMap<String, usize>,
pub lines_with_drift: usize,
pub feeds: Vec<Feed>,
pub evidence_note: String,
}
fn rank(c: &Change) -> (u8, bool, i64) {
let sev = match c.severity.as_str() {
"high" => 3,
"medium" => 2,
_ => 1,
};
(sev, c.first_party, c.delta_bytes.abs())
}
/// **Fold successive filings.** `latest` = the newest analysis of every line
/// compared (for the symbol counts), `single` = the lines filed once.
pub fn successive(steps: &[Step], latest: &[(Filing, Vec<nornir_introspect::size::SymbolSize>)], single: Vec<String>, feeds: Vec<Feed>, top_n: usize) -> SuccessiveReport {
let mut changes: Vec<Change> = Vec::new();
let mut step_rows = Vec::new();
let mut lines_with_drift = 0usize;
for st in steps {
let changed = st.provenance_changed();
let explained_by = if !st.same_source() {
"source"
} else if st.from.toolchain != st.to.toolchain {
"toolchain"
} else {
"nothing"
};
let Some(d) = &st.diff else {
step_rows.push(serde_json::json!({
"repo": st.to.repo, "variant": st.to.variant, "artifact": st.to.file, "target": st.to.target, "features": st.to.features,
"from": st.from.json(), "to": st.to.json(), "same_source": st.same_source(), "provenance_changed": changed,
"analysed": false,
}));
continue;
};
let mut n = 0usize;
let mut push = |kind: &str, s: &SymbolDelta| {
let no_generics = erase_generics(&s.name_demangled);
let first_party = st.to.first_party(&s.crate_name);
let reach = (kind == "symbol-added").then(|| suspicious_tag(&s.name_demangled)).flatten().map(str::to_string);
let severity = if explained_by == "nothing" || reach.is_some() {
"high"
} else if first_party {
"medium"
} else {
"low"
};
n += 1;
changes.push(Change {
repo: st.to.repo.clone(),
variant: st.to.variant.clone(),
artifact: st.to.file.clone(),
kind: kind.to_string(),
symbol: if s.name_demangled.is_empty() { s.name_mangled.clone() } else { s.name_demangled.clone() },
class: class_of(&no_generics).to_string(),
no_generics,
crate_name: s.crate_name.clone(),
before: s.base_size,
after: s.new_size,
delta_bytes: s.delta_bytes,
first_party,
explained_by: explained_by.to_string(),
severity: severity.to_string(),
reach,
from_commit: st.from.commit(),
to_commit: st.to.commit(),
from_sha: st.from.sha256.clone(),
to_sha: st.to.sha256.clone(),
});
};
for s in &d.symbols_added {
push("symbol-added", s);
}
for s in &d.symbols_removed {
push("symbol-removed", s);
}
for s in &d.symbols_grown {
push("symbol-grew", s);
}
for s in &d.symbols_shrunk {
push("symbol-shrunk", s);
}
if n > 0 || d.total_delta_bytes != 0 {
lines_with_drift += 1;
}
step_rows.push(serde_json::json!({
"repo": st.to.repo, "variant": st.to.variant, "artifact": st.to.file, "target": st.to.target, "features": st.to.features,
"from": st.from.json(), "to": st.to.json(), "same_source": st.same_source(), "provenance_changed": changed, "explained_by": explained_by,
"analysed": true, "total_delta_bytes": d.total_delta_bytes,
"symbols": { "added": d.symbols_added.len(), "removed": d.symbols_removed.len(), "grew": d.symbols_grown.len(), "shrunk": d.symbols_shrunk.len() },
"abi_removed": d.abi.removed.len(),
"sections": d.sections.iter().take(8).map(|s| serde_json::json!({ "section": s.name, "before": s.base_size, "after": s.new_size, "delta_bytes": s.delta_bytes })).collect::<Vec<_>>(),
"crates": d.crates.iter().take(8).map(|c| serde_json::json!({ "crate": c.crate_name, "before": c.base_size, "after": c.new_size, "delta_bytes": c.delta_bytes })).collect::<Vec<_>>(),
}));
}
let mut symbols_seen = 0usize;
let mut first_party_symbols = 0usize;
for (f, syms) in latest {
for s in syms.iter().filter(|s| s.size_bytes > 0) {
symbols_seen += 1;
first_party_symbols += f.first_party(&s.crate_name) as usize;
}
}
let unexplained = changes.iter().filter(|c| c.severity == "high").count();
let explained = changes.len() - unexplained;
changes.sort_by(|a, b| rank(b).cmp(&rank(a)).then_with(|| a.symbol.cmp(&b.symbol)));
let mut by_kind: BTreeMap<String, usize> = BTreeMap::new();
for c in &changes {
*by_kind.entry(c.kind.clone()).or_default() += 1;
}
if top_n > 0 {
changes.truncate(top_n);
}
let evidence_note = evidence_note(&feeds, "binary drift (successive Build Thing artifacts of one variant)");
SuccessiveReport { changes, steps: step_rows, single, symbols_seen, first_party_symbols, explained, unexplained, by_kind, lines_with_drift, feeds, evidence_note }
}
impl SuccessiveReport {
/// The one-line verdict the pane leads with.
pub fn attestation(&self) -> String {
let compared = self.steps.iter().filter(|s| s["analysed"] == true).count();
if compared == 0 {
return "drift UNKNOWN — no two analysed Build Thing artifacts of one variant to compare yet".to_string();
}
if self.unexplained == 0 {
format!(
"every change between successive Build Thing artifacts is explained by its provenance: {compared} pair(s) compared, {} symbol change(s)",
self.explained
)
} else {
format!(
"{} change(s) between successive Build Thing artifacts are NOT explained by their provenance (the same commits and toolchain built other bytes, or an added reach to process / exec / network): audit them",
self.unexplained
)
}
}
pub fn state_json(&self) -> serde_json::Value {
let findings: Vec<serde_json::Value> = self
.changes
.iter()
.map(|c| {
serde_json::json!({
"repo": c.repo, "variant": c.variant, "artifact": c.artifact,
"binary_symbol": c.symbol, "demangled": c.no_generics, "symbol": c.symbol,
"callee": c.reach, "file": null, "line": null, "loc": "",
"kind": c.kind, "severity": c.severity, "crate": c.crate_name, "class": c.class,
"before": c.before, "after": c.after, "delta_bytes": c.delta_bytes,
"first_party": c.first_party, "explained_by": c.explained_by,
"from_commit": c.from_commit, "to_commit": c.to_commit, "from_sha": c.from_sha, "to_sha": c.to_sha,
"evidence_note": format!(
"{} `{}` ({}) {} → {} B between build {}@{} and {}@{} — explained by: {}",
c.kind, c.symbol, c.crate_name, c.before, c.after, c.variant, c.from_commit, c.variant, c.to_commit, c.explained_by
),
})
})
.collect();
serde_json::json!({
"kind": "binary_drift",
"source": "build",
"attested": self.unexplained == 0 && self.steps.iter().any(|s| s["analysed"] == true),
"edges_evaluated": false,
"attestation": self.attestation(),
"binary_symbols_seen": self.symbols_seen,
"first_party_symbols": self.first_party_symbols,
"explained": self.explained,
"unexplained": self.unexplained,
"repos_with_drift": self.lines_with_drift,
"by_kind": self.by_kind,
"steps": self.steps,
"single": self.single,
"cant_catch": SUCCESSIVE_CANT_CATCH,
"evidence_note": self.evidence_note,
"feeds": self.feeds,
"findings": findings,
"worst": findings,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A first-party symbol; `in_source` says whether the source explains it.
fn sym(demangled: &str, in_source: bool, mono: bool, file: Option<&str>) -> BinSym {
BinSym {
demangled: demangled.to_string(),
no_generics: erase_generics(demangled),
crate_name: "fixture".to_string(),
kind: TEXT_KIND.to_string(),
is_monomorphization: mono,
source_file: file.map(str::to_string),
source_line: file.map(|_| 1),
first_party: true,
in_source,
}
}
fn graph(symbols: Vec<BinSym>, edges: Vec<BinEdge>) -> BinaryGraph {
BinaryGraph { symbols, edges, has_decl_files: true }
}
#[test]
fn generic_arguments_are_erased_and_trait_impl_heads_are_kept() {
assert_eq!(erase_generics("fixture::transform::<u32>"), "fixture::transform");
assert_eq!(erase_generics("fixture::S::<T>::m::<alloc::vec::Vec<u8>>"), "fixture::S::m");
assert_eq!(erase_generics("core::ptr::drop_in_place::<fixture::Foo>"), "core::ptr::drop_in_place");
assert_eq!(erase_generics("fixture::call::<fn(u8) -> u8>"), "fixture::call");
assert_eq!(erase_generics("<fixture::Foo as core::fmt::Debug>::fmt"), "<fixture::Foo as core::fmt::Debug>::fmt");
assert_eq!(erase_generics("fixture::run::{closure#0}"), "fixture::run::{closure#0}");
}
// ── RED-WHEN-BROKEN (synthetic): a build.rs-injected symbol that reaches
// std::process::Command is FLAGGED. ──
#[test]
fn a_buildrs_injected_command_symbol_is_flagged() {
let bin = graph(
vec![
// legit source fns present in the binary:
sym("fixture::parse", true, false, Some("src/lib.rs")),
sym("fixture::transform", true, false, Some("src/lib.rs")),
// the INJECTED backdoor — generated by build.rs into OUT_DIR:
sym("fixture::__generated_backdoor", false, false, Some("/home/x/target/debug/build/fixture-abc/out/injected.rs")),
],
vec![BinEdge { caller: "fixture::__generated_backdoor".into(), callee: "std::process::Command::new".into() }],
);
let f = fold_repo("fixture", &bin, &SourceCalls::of(&[("parse", "transform")]));
assert_eq!(f.len(), 1, "exactly the injected symbol, got {f:#?}");
let hit = &f[0];
assert_eq!(hit.kind, DriftKind::BuildrsOrigin);
assert_eq!(hit.severity, "high", "reaches std::process::Command");
assert!(hit.callee.as_deref().unwrap().contains("std::process::Command"));
assert!(hit.demangled.contains("__generated_backdoor"));
}
// ── NO FALSE POSITIVE: a clean crate whose only binary-only deltas are
// monomorphisation, drop glue, and derive impls produces ZERO findings. ──
#[test]
fn a_clean_crate_with_only_mono_glue_and_derive_is_zero_findings() {
let mut std_sym = sym("std::io::Write::write_all", false, false, None);
std_sym.first_party = false;
let bin = graph(
vec![
sym("fixture::run", true, false, Some("src/lib.rs")),
// monomorphisation: two concrete instances of one source generic.
sym("fixture::transform::<u32>", true, true, Some("src/lib.rs")),
sym("fixture::transform::<u64>", true, true, Some("src/lib.rs")),
// drop glue: no source item, benign by its name.
sym("core::ptr::drop_in_place::<fixture::Foo>", false, true, None),
// derive(Debug) impl.
sym("<fixture::Foo as core::fmt::Debug>::fmt", false, false, Some("src/lib.rs")),
// a closure.
sym("fixture::run::{{closure}}", false, false, Some("src/lib.rs")),
// a std symbol (out of scope — first-party scoping drops it).
std_sym,
],
vec![],
);
let f = fold_repo("fixture", &bin, &SourceCalls::of(&[("run", "transform")]));
assert!(f.is_empty(), "clean crate must be zero findings, got {f:#?}");
}
#[test]
fn an_instance_of_nothing_in_source_is_a_finding() {
let bin = graph(vec![sym("fixture::ghost::<u32>", false, true, None)], vec![]);
let f = fold_repo("fixture", &bin, &SourceCalls::default());
assert_eq!(f.len(), 1);
assert_eq!(f[0].demangled, "fixture::ghost");
}
#[test]
fn without_declaring_files_an_unexplained_symbol_is_not_called_a_proc_macro() {
let s = vec![sym("fixture::injected", false, false, None)];
let with = fold_repo("fixture", &BinaryGraph { symbols: s.clone(), edges: vec![], has_decl_files: true }, &SourceCalls::default());
assert_eq!(with[0].kind, DriftKind::ProcMacroOrigin);
let without = fold_repo("fixture", &BinaryGraph { symbols: s, edges: vec![], has_decl_files: false }, &SourceCalls::default());
assert_eq!(without[0].kind, DriftKind::UnexplainedSymbol);
assert!(without[0].evidence_note.contains("unexplained-symbol"));
}
#[test]
fn an_unexplained_edge_to_the_network_is_flagged_even_when_the_symbol_is_known() {
// The caller IS a source fn (so no unexplained-symbol), but a proc-macro
// rewrote its body to open a socket — an injected EDGE.
let bin = graph(
vec![sym("fixture::handle", true, false, Some("src/lib.rs"))],
vec![BinEdge { caller: "fixture::handle".into(), callee: "std::net::TcpStream::connect".into() }],
);
let f = fold_repo("fixture", &bin, &SourceCalls::default());
assert_eq!(f.len(), 1);
assert_eq!(f[0].kind, DriftKind::UnexplainedEdge);
assert_eq!(f[0].severity, "high");
}
#[test]
fn a_source_named_call_edge_is_not_flagged() {
// Same shape, but the source caller DOES name the callee → derivable.
let bin = graph(
vec![sym("fixture::handle", true, false, Some("src/lib.rs"))],
vec![BinEdge { caller: "fixture::handle".into(), callee: "std::net::TcpStream::connect".into() }],
);
assert!(fold_repo("fixture", &bin, &SourceCalls::of(&[("handle", "connect")])).is_empty());
}
#[test]
fn attestation_summary_says_yes_when_clean_and_no_when_drifted() {
let clean = BinaryDriftReport { first_party_symbols: 5, attested: true, edges_evaluated: true, ..Default::default() };
assert!(clean.attestation().contains("YES"));
// symbols alone are not the whole attestation
let symbols_only = BinaryDriftReport { first_party_symbols: 5, attested: true, edges_evaluated: false, ..Default::default() };
assert!(symbols_only.attestation().contains("SYMBOLS ONLY") && !symbols_only.attestation().contains("YES"));
let drift = BinaryDriftReport {
first_party_symbols: 5,
attested: false,
findings: vec![Finding {
repo: "x".into(),
binary_symbol: "x::bad".into(),
demangled: "x::bad".into(),
callee: None,
file: None,
line: None,
kind: DriftKind::Toolchain,
severity: "medium".into(),
evidence_note: String::new(),
}],
..Default::default()
};
assert!(drift.attestation().contains("NO"));
// A stripped binary (no first-party symbols) must read UNKNOWN, not YES.
let stripped = BinaryDriftReport { first_party_symbols: 0, attested: true, ..Default::default() };
assert!(stripped.attestation().contains("UNKNOWN"));
}
#[test]
fn no_binary_symbols_reports_the_missing_feed_and_cannot_attest() {
let feeds = vec![Feed { table: "code_symbols".into(), rows: 0, root: "code room".into(), missing: true }];
let r = report_from(BTreeMap::new(), true, feeds, 50);
assert!(r.findings.is_empty());
assert_eq!(r.first_party_symbols, 0);
assert!(r.evidence_note.contains("MISSING FEEDS"), "{}", r.evidence_note);
assert!(r.attestation().contains("UNKNOWN"));
}
}