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

nordisk/nornir

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"));
    }
}