crates/nornir-introspect/src/diff.rs
//! `diff` — artifact-to-artifact **binary diff** (task #65 slice 2). Given two
//! already-analysed artifacts ([`BinaryReport`]s from [`crate::binscan::analyze`]),
//! compute the size waterfall (total + per-section + per-crate deltas), the
//! per-symbol churn (added / removed / grown / shrunk), and a coarse **ABI-surface
//! delta** (exported — global/weak — symbols added / removed). The binary twin of
//! the source-side `semver_diffs`; the release gate cross-checks the two agree.
//!
//! ROOT-LAW #0: the per-symbol set diff over the (potentially huge) union of both
//! symbol tables rides znippy-zoomies' gatling fork-join pool (index-ordered
//! results), the same primitive [`crate::size`] / [`crate::artifact`] use — never
//! rayon. Everything else (sections, crates) is small and stays serial.
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use crate::binscan::BinaryReport;
use crate::size::{SectionSize, SymbolSize};
/// One changed symbol between the base and new artifact. For an *added* symbol
/// `base_size == 0`; for a *removed* symbol `new_size == 0`; for grown/shrunk
/// both are non-zero and `delta_bytes = new - base`.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SymbolDelta {
/// Raw (mangled) symbol name — the join key across the two tables.
pub name_mangled: String,
/// Rustc-demangled name (with generics), for display.
pub name_demangled: String,
/// Owning crate (DWARF `krate` else demangled path root else empty).
pub crate_name: String,
/// Aggregated size in the base artifact, bytes (0 if absent).
pub base_size: u64,
/// Aggregated size in the new artifact, bytes (0 if absent).
pub new_size: u64,
/// `new_size - base_size`.
pub delta_bytes: i64,
/// `global` / `weak` / `local` (from whichever side defines it).
pub binding: String,
/// Part of the exported ABI surface (global/weak binding).
pub exported: bool,
}
/// One section's size delta between the two artifacts (the size-waterfall row).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SectionDelta {
/// Section name, e.g. `.text`, `.rodata`, `.debug_info`.
pub name: String,
/// Section size in the base artifact (0 if the section is new).
pub base_size: u64,
/// Section size in the new artifact (0 if the section was dropped).
pub new_size: u64,
/// `new_size - base_size`.
pub delta_bytes: i64,
}
/// One crate's aggregated size delta between the two artifacts.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct CrateDelta {
/// Crate name (empty = unattributed symbols, lumped together).
pub crate_name: String,
/// Crate size in the base artifact (0 if the crate is new).
pub base_size: u64,
/// Crate size in the new artifact (0 if the crate vanished).
pub new_size: u64,
/// `new_size - base_size`.
pub delta_bytes: i64,
}
/// The coarse ABI-surface delta: exported (global/weak) symbols added / removed.
/// A removed export is the binary counterpart of an `abi_break`; the release gate
/// cross-checks it against the `semver_diffs` `suggested_bump`.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct AbiDelta {
/// Exported symbols present in the new artifact but not the base.
pub added: Vec<SymbolDelta>,
/// Exported symbols present in the base artifact but not the new (breaks).
pub removed: Vec<SymbolDelta>,
}
/// The full artifact-to-artifact diff — one [`ArtifactDiff`] per (base, new) pair.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ArtifactDiff {
/// Total artifact size (on-disk bytes) of the base.
pub base_total_bytes: u64,
/// Total artifact size (on-disk bytes) of the new.
pub new_total_bytes: u64,
/// `new_total_bytes - base_total_bytes` — the headline size delta.
pub total_delta_bytes: i64,
/// Per-section size deltas (only sections whose size changed), size-sorted.
pub sections: Vec<SectionDelta>,
/// Per-crate size deltas (only crates whose size changed), size-sorted.
pub crates: Vec<CrateDelta>,
/// Symbols present in the new artifact but not the base.
pub symbols_added: Vec<SymbolDelta>,
/// Symbols present in the base artifact but not the new.
pub symbols_removed: Vec<SymbolDelta>,
/// Symbols present in both, larger in the new (`delta > 0`).
pub symbols_grown: Vec<SymbolDelta>,
/// Symbols present in both, smaller in the new (`delta < 0`).
pub symbols_shrunk: Vec<SymbolDelta>,
/// The coarse exported-symbol (ABI surface) delta.
pub abi: AbiDelta,
}
/// A symbol aggregated across duplicate mangled-name rows within one report:
/// summed size (duplicate local symbols do occur), first-seen demangled/crate/
/// binding, and the exported flag OR-ed across duplicates.
#[derive(Clone)]
struct AggSym {
name_demangled: String,
crate_name: String,
binding: String,
size: u64,
exported: bool,
}
fn is_exported(binding: &str) -> bool {
binding == "global" || binding == "weak"
}
/// Aggregate a report's symbols by mangled name → summed size + attribution.
fn aggregate(symbols: &[SymbolSize]) -> HashMap<String, AggSym> {
let mut map: HashMap<String, AggSym> = HashMap::with_capacity(symbols.len());
for s in symbols {
let exported = is_exported(&s.binding);
map.entry(s.name_mangled.clone())
.and_modify(|a| {
a.size += s.size_bytes;
a.exported |= exported;
})
.or_insert_with(|| AggSym {
name_demangled: s.name_demangled.clone(),
crate_name: s.crate_name.clone(),
binding: s.binding.clone(),
size: s.size_bytes,
exported,
});
}
map
}
/// Per-key classification produced by the gatling fan-out.
enum SymClass {
Added(SymbolDelta),
Removed(SymbolDelta),
Grew(SymbolDelta),
Shrunk(SymbolDelta),
Unchanged,
}
/// Diff two analysed artifacts. `base` is the prior/reference report, `new` the
/// candidate. Pure; gatling-parallel over the union of both symbol tables.
pub fn diff_reports(base: &BinaryReport, new: &BinaryReport) -> ArtifactDiff {
// ── Headline total ──
let base_total = base.header.total_bytes;
let new_total = new.header.total_bytes;
// ── Per-section waterfall (union of section names) ──
let sections = diff_named(&base.sections, &new.sections, |s: &SectionSize| {
(s.name.clone(), s.size_bytes)
})
.into_iter()
.map(|(name, base_size, new_size)| SectionDelta {
name,
base_size,
new_size,
delta_bytes: new_size as i64 - base_size as i64,
})
.collect();
// ── Per-crate waterfall (union of crate names) ──
let crates = diff_named(&base.crates, &new.crates, |c: &crate::size::CrateSize| {
(c.crate_name.clone(), c.size_bytes)
})
.into_iter()
.map(|(crate_name, base_size, new_size)| CrateDelta {
crate_name,
base_size,
new_size,
delta_bytes: new_size as i64 - base_size as i64,
})
.collect();
// ── Per-symbol churn — the big set diff, gatling-parallel (ROOT-LAW #0) ──
let base_map = aggregate(&base.symbols);
let new_map = aggregate(&new.symbols);
// Union of mangled names, deterministic order (sorted) so the parallel
// classification merges byte-identically to a serial walk.
let mut keys: Vec<&String> = base_map.keys().chain(new_map.keys()).collect();
keys.sort_unstable();
keys.dedup();
let classes = map_keys_parallel(&keys, &base_map, &new_map);
let mut symbols_added = Vec::new();
let mut symbols_removed = Vec::new();
let mut symbols_grown = Vec::new();
let mut symbols_shrunk = Vec::new();
for c in classes {
match c {
SymClass::Added(d) => symbols_added.push(d),
SymClass::Removed(d) => symbols_removed.push(d),
SymClass::Grew(d) => symbols_grown.push(d),
SymClass::Shrunk(d) => symbols_shrunk.push(d),
SymClass::Unchanged => {}
}
}
// Largest churn first within each bucket.
symbols_added.sort_by(|a, b| {
b.new_size
.cmp(&a.new_size)
.then(a.name_mangled.cmp(&b.name_mangled))
});
symbols_removed.sort_by(|a, b| {
b.base_size
.cmp(&a.base_size)
.then(a.name_mangled.cmp(&b.name_mangled))
});
symbols_grown.sort_by(|a, b| {
b.delta_bytes
.cmp(&a.delta_bytes)
.then(a.name_mangled.cmp(&b.name_mangled))
});
symbols_shrunk.sort_by(|a, b| {
a.delta_bytes
.cmp(&b.delta_bytes)
.then(a.name_mangled.cmp(&b.name_mangled))
});
// ── ABI-surface delta: the exported subset of add/remove ──
let abi = AbiDelta {
added: symbols_added
.iter()
.filter(|d| d.exported)
.cloned()
.collect(),
removed: symbols_removed
.iter()
.filter(|d| d.exported)
.cloned()
.collect(),
};
ArtifactDiff {
base_total_bytes: base_total,
new_total_bytes: new_total,
total_delta_bytes: new_total as i64 - base_total as i64,
sections,
crates,
symbols_added,
symbols_removed,
symbols_grown,
symbols_shrunk,
abi,
}
}
/// Fan the per-key symbol classification across cores, results in `keys` order
/// (deterministic). ROOT-LAW #0: znippy-zoomies gatling, never rayon.
fn map_keys_parallel(
keys: &[&String],
base_map: &HashMap<String, AggSym>,
new_map: &HashMap<String, AggSym>,
) -> Vec<SymClass> {
if keys.is_empty() {
return Vec::new();
}
let indices: Vec<usize> = (0..keys.len()).collect();
znippy_zoomies::gatling_forkjoin::gatling_map_balanced(
&indices,
0,
1,
|_| 1,
|_, &i| classify(keys[i], base_map, new_map),
)
}
/// Classify one mangled-name key into added / removed / grown / shrunk / unchanged.
fn classify(
mangled: &str,
base_map: &HashMap<String, AggSym>,
new_map: &HashMap<String, AggSym>,
) -> SymClass {
match (base_map.get(mangled), new_map.get(mangled)) {
(None, Some(n)) => SymClass::Added(SymbolDelta {
name_mangled: mangled.to_string(),
name_demangled: n.name_demangled.clone(),
crate_name: n.crate_name.clone(),
base_size: 0,
new_size: n.size,
delta_bytes: n.size as i64,
binding: n.binding.clone(),
exported: n.exported,
}),
(Some(b), None) => SymClass::Removed(SymbolDelta {
name_mangled: mangled.to_string(),
name_demangled: b.name_demangled.clone(),
crate_name: b.crate_name.clone(),
base_size: b.size,
new_size: 0,
delta_bytes: -(b.size as i64),
binding: b.binding.clone(),
exported: b.exported,
}),
(Some(b), Some(n)) => {
let delta = n.size as i64 - b.size as i64;
let d = SymbolDelta {
name_mangled: mangled.to_string(),
name_demangled: n.name_demangled.clone(),
crate_name: n.crate_name.clone(),
base_size: b.size,
new_size: n.size,
delta_bytes: delta,
binding: n.binding.clone(),
exported: n.exported || b.exported,
};
match delta {
d0 if d0 > 0 => SymClass::Grew(d),
d0 if d0 < 0 => SymClass::Shrunk(d),
_ => SymClass::Unchanged,
}
}
(None, None) => SymClass::Unchanged, // unreachable (key came from a map)
}
}
/// Union two named-size lists (sections / crates) into `(name, base, new)`
/// triples, keeping only entries whose size actually changed, size-sorted by the
/// larger of the two sides. Small inputs — serial.
fn diff_named<T>(
base: &[T],
new: &[T],
key: impl Fn(&T) -> (String, u64),
) -> Vec<(String, u64, u64)> {
let bmap: HashMap<String, u64> = base.iter().map(&key).collect();
let nmap: HashMap<String, u64> = new.iter().map(&key).collect();
let mut names: Vec<&String> = bmap.keys().chain(nmap.keys()).collect();
names.sort_unstable();
names.dedup();
let mut out: Vec<(String, u64, u64)> = names
.into_iter()
.filter_map(|n| {
let b = bmap.get(n).copied().unwrap_or(0);
let a = nmap.get(n).copied().unwrap_or(0);
(b != a).then(|| (n.clone(), b, a))
})
.collect();
out.sort_by(|x, y| y.2.max(y.1).cmp(&x.2.max(x.1)).then(x.0.cmp(&y.0)));
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::hardening::Hardening;
use crate::size::{CrateSize, SymbolSize};
fn hdr(total: u64) -> Hardening {
Hardening {
format: "elf".into(),
arch: "X86_64".into(),
class: "exe".into(),
total_bytes: total,
stripped: false,
has_debug_info: true,
pie: true,
relro: "full".into(),
nx: true,
stack_canary: true,
fortify: false,
rpath: false,
runpath: false,
build_id: None,
n_sections: 1,
n_sym_defined: 0,
n_sym_undefined: 0,
n_dyn_deps: 0,
}
}
fn sym(mangled: &str, size: u64, binding: &str, krate: &str) -> SymbolSize {
SymbolSize {
name_mangled: mangled.into(),
name_demangled: mangled.into(),
name_demangled_no_generics: mangled.into(),
crate_name: krate.into(),
section: ".text".into(),
size_bytes: size,
kind: "Text".into(),
binding: binding.into(),
is_monomorphization: false,
source_file: None,
source_line: None,
}
}
fn report(total: u64, syms: Vec<SymbolSize>, sections: Vec<SectionSize>) -> BinaryReport {
// Roll crates up from the symbols so the crate waterfall has input.
let mut by: HashMap<String, u64> = HashMap::new();
for s in &syms {
*by.entry(s.crate_name.clone()).or_default() += s.size_bytes;
}
let crates = by
.into_iter()
.map(|(crate_name, size_bytes)| CrateSize {
crate_name,
size_bytes,
symbol_count: 1,
})
.collect();
BinaryReport {
header: hdr(total),
sections,
symbols: syms,
crates,
}
}
fn sec(name: &str, size: u64) -> SectionSize {
SectionSize {
name: name.into(),
size_bytes: size,
vaddr: 0,
file_offset: 0,
alloc: true,
exec: name == ".text",
write: false,
kind: "Text".into(),
}
}
/// Red-when-broken unit proof of the diff engine over crafted reports:
/// a symbol is kept-but-grown, one added, one removed, an exported symbol
/// dropped (ABI break), and the totals/sections move. Exercises the gatling
/// set-diff path and every bucket.
#[test]
fn diff_detects_every_bucket() {
let base = report(
1000,
vec![
sym("_ZN4kept3fooE", 100, "local", "kept"),
sym("_ZN7shrinks3barE", 200, "local", "shrinks"),
sym("_ZN4gone3apiE", 300, "global", "gone"), // exported → removed
],
vec![sec(".text", 600), sec(".rodata", 400)],
);
let new = report(
1500,
vec![
sym("_ZN4kept3fooE", 180, "local", "kept"), // grew +80
sym("_ZN7shrinks3barE", 150, "local", "shrinks"), // shrunk -50
sym("_ZN3new3apiE", 500, "global", "new"), // added exported
],
vec![sec(".text", 900), sec(".rodata", 400), sec(".data", 20)],
);
let d = diff_reports(&base, &new);
assert_eq!(d.total_delta_bytes, 500, "headline total delta");
// Added / removed detected.
assert!(
d.symbols_added
.iter()
.any(|s| s.name_mangled == "_ZN3new3apiE"),
"added symbol"
);
assert!(
d.symbols_removed
.iter()
.any(|s| s.name_mangled == "_ZN4gone3apiE"),
"removed symbol"
);
// Grown / shrunk detected with correct deltas.
let grew = d
.symbols_grown
.iter()
.find(|s| s.name_mangled == "_ZN4kept3fooE")
.expect("grew");
assert_eq!(grew.delta_bytes, 80, "grew delta");
let shr = d
.symbols_shrunk
.iter()
.find(|s| s.name_mangled == "_ZN7shrinks3barE")
.expect("shrunk");
assert_eq!(shr.delta_bytes, -50, "shrunk delta");
// ABI surface: exported drop is a break, exported add is a surface add.
assert!(
d.abi
.removed
.iter()
.any(|s| s.name_mangled == "_ZN4gone3apiE"),
"ABI break (removed export)"
);
assert!(
d.abi.added.iter().any(|s| s.name_mangled == "_ZN3new3apiE"),
"ABI add (new export)"
);
// A local (non-exported) symbol never lands in the ABI delta.
assert!(
!d.abi.added.iter().any(|s| s.name_mangled.contains("kept")),
"locals excluded from ABI"
);
// Section waterfall: .text grew +300, .data is new (+20), .rodata unchanged (omitted).
let text = d
.sections
.iter()
.find(|s| s.name == ".text")
.expect(".text delta");
assert_eq!(text.delta_bytes, 300, ".text grew");
assert!(
d.sections
.iter()
.any(|s| s.name == ".data" && s.delta_bytes == 20),
".data added"
);
assert!(
!d.sections.iter().any(|s| s.name == ".rodata"),
"unchanged section omitted"
);
}
/// Diffing a report against itself yields an empty diff — the identity guard.
#[test]
fn diff_identity_is_empty() {
let r = report(
500,
vec![sym("_ZN1a1bE", 100, "global", "a")],
vec![sec(".text", 100)],
);
let d = diff_reports(&r, &r);
assert_eq!(d.total_delta_bytes, 0);
assert!(d.symbols_added.is_empty() && d.symbols_removed.is_empty());
assert!(d.symbols_grown.is_empty() && d.symbols_shrunk.is_empty());
assert!(d.sections.is_empty() && d.crates.is_empty());
assert!(d.abi.added.is_empty() && d.abi.removed.is_empty());
}
}