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

nordisk/nornir

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