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

nordisk/facett

facett-binview/src/lib.rs

//! # facett-binview — the shared **binary-analysis** pane
//!
//! The ONE robot-testable egui [`Facet`](facett_core::Facet) that renders a built
//! artifact's **size + hardening** story, so every suite surface that wants to show
//! "what's in this binary and how did it grow" (dwarves' build view · nornir's
//! warehouse deck) is **one pane, not a per-app twin** (LAW #5 — DRY). It renders
//! four layers the nordisk binary-analysis subsystem produces:
//!
//! 1. **Bloat treemap** — nested rects sized by per-crate/-symbol bytes, coloured by
//!    crate (a squarified treemap; the biggest crate gets the biggest rect).
//! 2. **Checksec badges** — a green/red chip per hardening protection
//!    (RELRO / PIE / NX / stack-canary / stripped / FORTIFY).
//! 3. **Diff waterfall** — before→after size, each section's delta a floating bar
//!    (growth vs shrink coloured), anchored by the before/after totals.
//! 4. **Size sparkline** — the artifact's byte size across recent builds.
//!
//! ## Layering — the pane is WAREHOUSE-AGNOSTIC (important)
//! facett carries **no** nornir / warehouse / dwarves dependency. The pane is
//! **presentational + host-driven**: the HOST implements the [`BinaryDataSeam`]
//! trait (feeding rows from the warehouse's `binary_facts` / `section_sizes` /
//! `symbol_sizes` / `artifact_diff`), and [`BinaryPane`] only ever **renders the
//! rows the seam surfaces**. This mirrors how the Release tab takes a
//! `ReleaseConvergence` in and the auth pane takes a `LoginState` in — the pane
//! never reaches for a backend.
//!
//! ## What it renders (per seam call)
//! * [`BinaryDataSeam::checksec`] → the hardening badges.
//! * [`BinaryDataSeam::crate_sizes`] (+ [`symbol_sizes`](BinaryDataSeam::symbol_sizes)
//!   as leaves) → the bloat treemap.
//! * [`BinaryDataSeam::diff`] → the before→after waterfall (skipped when `None`).
//! * [`BinaryDataSeam::size_history`] → the sparkline.
//!
//! A network-free [`StubBinaryData`] drives the shipped demo + the robot test.

use egui::{Color32, FontId, Pos2, Rect, Sense, Stroke, Vec2};
use facett_core::look::oklch::Oklch;
use serde::{Deserialize, Serialize};

pub use facett_core::Facet;

// ─────────────────────────────────────────────────────────────────────────────
// Data rows — the neutral shapes the HOST feeds over the seam. These mirror the
// binary-analysis subsystem's warehouse tables (section_sizes / symbol_sizes /
// binary_facts / artifact_diff) but carry NO warehouse type dependency.
// ─────────────────────────────────────────────────────────────────────────────

/// One ELF/Mach-O section's on-disk size (`.text`, `.rodata`, `.data`, …).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SectionSize {
    /// Section name (e.g. `".text"`).
    pub name: String,
    /// Byte size of the section.
    pub bytes: u64,
}

impl SectionSize {
    /// A `name`/`bytes` section row.
    pub fn new(name: impl Into<String>, bytes: u64) -> Self {
        Self { name: name.into(), bytes }
    }
}

/// A per-crate bloat aggregate (the sum of the crate's symbols) — the treemap's
/// top-level tiles.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CrateSize {
    /// Crate name (e.g. `"nornir"`, `"std"`, `"[Unknown]"`).
    pub crate_name: String,
    /// Total bytes attributed to this crate.
    pub bytes: u64,
}

impl CrateSize {
    /// A `crate_name`/`bytes` aggregate row.
    pub fn new(crate_name: impl Into<String>, bytes: u64) -> Self {
        Self { crate_name: crate_name.into(), bytes }
    }
}

/// A single symbol's size, attributed to its crate — the treemap's leaves and the
/// per-symbol bloat detail.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SymbolSize {
    /// The owning crate (may be `"[Unknown]"`).
    pub crate_name: String,
    /// The (possibly demangled) symbol name.
    pub symbol: String,
    /// Byte size of the symbol.
    pub bytes: u64,
}

impl SymbolSize {
    /// A `crate`/`symbol`/`bytes` leaf row.
    pub fn new(crate_name: impl Into<String>, symbol: impl Into<String>, bytes: u64) -> Self {
        Self { crate_name: crate_name.into(), symbol: symbol.into(), bytes }
    }
}

/// RELRO hardening level (checksec's tri-state).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum Relro {
    /// Full RELRO — the GOT is read-only after startup (best).
    Full,
    /// Partial RELRO — some relocation sections read-only.
    Partial,
    /// No RELRO.
    None,
}

impl Relro {
    /// A short label for the badge / JSON.
    pub fn label(self) -> &'static str {
        match self {
            Relro::Full => "full",
            Relro::Partial => "partial",
            Relro::None => "none",
        }
    }
    /// Only **Full** counts as green; partial/none read as not-ok.
    pub fn is_ok(self) -> bool {
        matches!(self, Relro::Full)
    }
}

/// The `checksec`-style hardening verdict from `binary_facts`.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Checksec {
    /// RELRO level.
    pub relro: Relro,
    /// Position-independent executable.
    pub pie: bool,
    /// Non-executable stack (NX / DEP).
    pub nx: bool,
    /// Stack canary present.
    pub stack_canary: bool,
    /// Symbol table stripped.
    pub stripped: bool,
    /// FORTIFY_SOURCE present.
    pub fortify: bool,
}

impl Default for Checksec {
    fn default() -> Self {
        Self {
            relro: Relro::None,
            pie: false,
            nx: false,
            stack_canary: false,
            stripped: false,
            fortify: false,
        }
    }
}

/// One rendered hardening badge — a label + a green(ok)/red(not-ok) verdict + a
/// short detail. The pane paints these as coloured chips; the JSON carries them
/// verbatim (the RED-when-broken hook for the robot test).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CheckBadge {
    /// The protection's short name (e.g. `"PIE"`).
    pub label: &'static str,
    /// `true` = protection present / hardened (green), `false` = absent (red).
    pub ok: bool,
    /// A short human detail (e.g. `"enabled"`, `"none"`).
    pub detail: String,
}

impl Checksec {
    /// The badges in a stable order — the exact chips the pane paints.
    pub fn badges(&self) -> Vec<CheckBadge> {
        let flag = |label, ok: bool| CheckBadge {
            label,
            ok,
            detail: if ok { "enabled" } else { "disabled" }.to_string(),
        };
        vec![
            CheckBadge { label: "RELRO", ok: self.relro.is_ok(), detail: self.relro.label().to_string() },
            flag("PIE", self.pie),
            flag("NX", self.nx),
            flag("CANARY", self.stack_canary),
            // Stripped is a size/hygiene win here (symbols removed) → green when true.
            CheckBadge {
                label: "STRIPPED",
                ok: self.stripped,
                detail: if self.stripped { "stripped" } else { "symbols" }.to_string(),
            },
            flag("FORTIFY", self.fortify),
        ]
    }
}

/// One section's before→after size change — a step in the diff waterfall.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SectionDelta {
    /// Section name.
    pub name: String,
    /// Bytes before.
    pub before: u64,
    /// Bytes after.
    pub after: u64,
}

impl SectionDelta {
    /// A `name`/`before`/`after` step.
    pub fn new(name: impl Into<String>, before: u64, after: u64) -> Self {
        Self { name: name.into(), before, after }
    }
    /// Signed byte delta (`after - before`).
    pub fn delta(&self) -> i64 {
        self.after as i64 - self.before as i64
    }
}

/// One symbol's before→after change (grown or shrunk).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SymbolDelta {
    /// The symbol name.
    pub symbol: String,
    /// Bytes before.
    pub before: u64,
    /// Bytes after.
    pub after: u64,
}

impl SymbolDelta {
    /// A `symbol`/`before`/`after` change.
    pub fn new(symbol: impl Into<String>, before: u64, after: u64) -> Self {
        Self { symbol: symbol.into(), before, after }
    }
    /// Signed byte delta (`after - before`).
    pub fn delta(&self) -> i64 {
        self.after as i64 - self.before as i64
    }
}

/// A before→after artifact diff (from the warehouse's `artifact_diff`): total size
/// change, per-section deltas (the waterfall steps), the symbol churn buckets, and
/// an ABI-changed flag.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ArtifactDiff {
    /// Total artifact bytes before.
    pub before_bytes: u64,
    /// Total artifact bytes after.
    pub after_bytes: u64,
    /// Per-section deltas — the waterfall steps.
    pub sections: Vec<SectionDelta>,
    /// Symbols present only after (added).
    pub added: Vec<String>,
    /// Symbols present only before (removed).
    pub removed: Vec<String>,
    /// Symbols that grew.
    pub grown: Vec<SymbolDelta>,
    /// Symbols that shrank.
    pub shrunk: Vec<SymbolDelta>,
    /// Whether the public ABI changed.
    pub abi_changed: bool,
    /// A short human note about the ABI verdict.
    pub abi_note: String,
}

impl ArtifactDiff {
    /// Signed total byte delta (`after - before`).
    pub fn total_delta(&self) -> i64 {
        self.after_bytes as i64 - self.before_bytes as i64
    }
}

// ─────────────────────────────────────────────────────────────────────────────
// The seam — implemented by the HOST (nornir/dwarves), which feeds rows from the
// warehouse. No method reaches for a backend from the pane's side.
// ─────────────────────────────────────────────────────────────────────────────

/// The binary-analysis **data seam** the [`BinaryPane`] renders — implemented by the
/// **HOST** (nornir/dwarves), which feeds the rows from the warehouse's
/// `binary_facts` / `section_sizes` / `symbol_sizes` / `artifact_diff`. The pane
/// never computes any of these; it only paints whatever the seam returns.
pub trait BinaryDataSeam {
    /// An honest banner for *where* the analysis came from, e.g.
    /// `"nornir warehouse"` or `"stub (demo)"`.
    fn source(&self) -> &str {
        "binary-analysis"
    }

    /// The analysed artifact's name (e.g. `"nornir"`).
    fn artifact_name(&self) -> String;

    /// The hardening verdict (`binary_facts`) → the checksec badges.
    fn checksec(&self) -> Checksec;

    /// Is [`checksec`](Self::checksec) a MEASUREMENT? A source that does not grade the
    /// ELF — the Mímir v2 binary layer joins linked symbols, it does not read RELRO /
    /// PIE / NX — answers `false`: the pane then draws NO badge (every flag off is not
    /// "unhardened", it is "unmeasured") and its `state_json` carries `checksec: null`.
    /// Defaulted `true`, so every existing seam is unchanged.
    fn checksec_measured(&self) -> bool {
        true
    }

    /// Per-crate bloat aggregates → the treemap's top-level tiles. The pane sorts
    /// and lays these out; the host may return them in any order.
    fn crate_sizes(&self) -> Vec<CrateSize>;

    /// Per-symbol sizes → the treemap leaves / bloat detail. Defaulted empty (the
    /// crate tiles alone make a valid treemap).
    fn symbol_sizes(&self) -> Vec<SymbolSize> {
        Vec::new()
    }

    /// The before→after diff, if a baseline artifact exists. `None` ⇒ the waterfall
    /// layer is skipped.
    fn diff(&self) -> Option<ArtifactDiff> {
        None
    }

    /// The artifact's size across recent builds (oldest→newest bytes) → the
    /// sparkline. Defaulted empty (the sparkline layer is then skipped).
    fn size_history(&self) -> Vec<u64> {
        Vec::new()
    }
}

// ─────────────────────────────────────────────────────────────────────────────
// Squarified treemap layout — a small self-contained helper (no facett rect/layout
// helper exists to reuse). Pure geometry, unit-testable, no `ui`.
// ─────────────────────────────────────────────────────────────────────────────

/// A laid-out treemap tile: which crate, its bytes, its rect, and its colour.
#[derive(Clone, Debug, PartialEq)]
pub struct CrateTile {
    /// The crate this tile represents.
    pub crate_name: String,
    /// Bytes attributed to the crate.
    pub bytes: u64,
    /// The tile's rectangle inside the treemap area.
    pub rect: Rect,
    /// The tile's fill colour (stable per crate name).
    pub color: Color32,
}

/// The worst (largest) aspect ratio in a candidate row laid along a strip of
/// length `length` — the squarify cost function (Bruls et al.).
fn worst_ratio(row: &[f64], length: f64) -> f64 {
    if row.is_empty() || length <= 0.0 {
        return f64::INFINITY;
    }
    let s: f64 = row.iter().sum();
    if s <= 0.0 {
        return f64::INFINITY;
    }
    let rmax = row.iter().cloned().fold(f64::MIN, f64::max);
    let rmin = row.iter().cloned().fold(f64::MAX, f64::min);
    let ll = length * length;
    let ss = s * s;
    (ll * rmax / ss).max(ss / (ll * rmin))
}

/// **Squarified treemap** — lay out `weights` (bytes) into `area`, returning one
/// rect per weight in the SAME order. Bigger weights get proportionally bigger
/// rects with near-square aspect ratios. Zero/negative weights collapse to
/// [`Rect::NOTHING`]; an empty/degenerate area returns all-`NOTHING`.
pub fn squarified_treemap(weights: &[f64], area: Rect) -> Vec<Rect> {
    let n = weights.len();
    let mut out = vec![Rect::NOTHING; n];
    if n == 0 || area.width() <= 0.0 || area.height() <= 0.0 {
        return out;
    }
    // Only positive weights participate; keep them ordered largest-first for a good
    // squarification, but write results back to the caller's original index.
    let mut order: Vec<usize> = (0..n).filter(|&i| weights[i] > 0.0).collect();
    if order.is_empty() {
        return out;
    }
    order.sort_by(|&a, &b| weights[b].partial_cmp(&weights[a]).unwrap_or(std::cmp::Ordering::Equal));
    let total: f64 = order.iter().map(|&i| weights[i]).sum();
    let area_px = area.width() as f64 * area.height() as f64;
    // Pixel-area target for each participating weight, largest-first.
    let areas: Vec<f64> = order.iter().map(|&i| weights[i] / total * area_px).collect();

    // The shrinking free rectangle.
    let mut fx = area.min.x as f64;
    let mut fy = area.min.y as f64;
    let mut fw = area.width() as f64;
    let mut fh = area.height() as f64;

    let mut k = 0usize;
    while k < order.len() {
        let side = fw.min(fh);
        // Grow the row while the worst aspect ratio keeps improving.
        let mut row_area = vec![areas[k]];
        let mut row_len = 1usize;
        let mut j = k + 1;
        while j < order.len() {
            let cur = worst_ratio(&row_area, side);
            row_area.push(areas[j]);
            let with = worst_ratio(&row_area, side);
            if with <= cur {
                row_len += 1;
                j += 1;
            } else {
                row_area.pop();
                break;
            }
        }
        // Lay the finished row along the shorter side.
        let row_sum: f64 = row_area.iter().sum();
        let thickness = if side > 0.0 { row_sum / side } else { 0.0 };
        if fw >= fh {
            // Vertical column of width `thickness` at the left; items stacked in y.
            let mut oy = fy;
            for m in 0..row_len {
                let cell_h = if thickness > 0.0 { row_area[m] / thickness } else { 0.0 };
                out[order[k + m]] = Rect::from_min_size(
                    Pos2::new(fx as f32, oy as f32),
                    Vec2::new(thickness as f32, cell_h as f32),
                );
                oy += cell_h;
            }
            fx += thickness;
            fw -= thickness;
        } else {
            // Horizontal row of height `thickness` at the top; items along x.
            let mut ox = fx;
            for m in 0..row_len {
                let cell_w = if thickness > 0.0 { row_area[m] / thickness } else { 0.0 };
                out[order[k + m]] = Rect::from_min_size(
                    Pos2::new(ox as f32, fy as f32),
                    Vec2::new(cell_w as f32, thickness as f32),
                );
                ox += cell_w;
            }
            fy += thickness;
            fh -= thickness;
        }
        k += row_len;
    }
    out
}

/// A stable fill colour for a crate name — reuses [`Oklch`] (no twinned colour
/// maths): a golden-ratio hue hash at a fixed lightness/chroma so a crate always
/// paints the same hue and adjacent tiles read apart.
pub fn crate_color(name: &str) -> Color32 {
    // FNV-1a over the bytes → a stable hue in [0, 360).
    let mut h: u64 = 0xcbf29ce484222325;
    for b in name.bytes() {
        h ^= b as u64;
        h = h.wrapping_mul(0x100000001b3);
    }
    let hue = (h % 360) as f32;
    Oklch::new(0.68, 0.14, hue).to_color32()
}

// ─────────────────────────────────────────────────────────────────────────────
// The pane.
// ─────────────────────────────────────────────────────────────────────────────

/// Fill colour for a growth (size increase) in the waterfall — a warm red.
const GROW_COLOR: Color32 = Color32::from_rgb(0xd9, 0x53, 0x4f);
/// Fill colour for a shrink (size decrease) in the waterfall — a cool green.
const SHRINK_COLOR: Color32 = Color32::from_rgb(0x3f, 0xa6, 0x60);
/// Anchor colour for the before/after totals — a neutral blue.
const ANCHOR_COLOR: Color32 = Color32::from_rgb(0x4f, 0x83, 0xd9);

/// The shared **binary-analysis pane** — a [`Facet`](facett_core::Facet) the host
/// mounts, driving its host-supplied [`BinaryDataSeam`]. Presentational: it caches a
/// snapshot of the seam's rows and paints the four layers; [`refresh`](Self::refresh)
/// re-pulls from the seam.
pub struct BinaryPane {
    seam: Box<dyn BinaryDataSeam>,
    title: String,
    // Cached snapshot of the seam's rows (re-pulled on `refresh`).
    artifact: String,
    checksec: Checksec,
    /// The seam's [`BinaryDataSeam::checksec_measured`]: `false` ⇒ no badge is drawn.
    checksec_measured: bool,
    crates: Vec<CrateSize>,
    symbols: Vec<SymbolSize>,
    diff: Option<ArtifactDiff>,
    history: Vec<u64>,
}

impl BinaryPane {
    /// Build the pane over a host-supplied [`BinaryDataSeam`], pulling an initial
    /// snapshot of its rows.
    pub fn new(seam: Box<dyn BinaryDataSeam>) -> Self {
        let mut pane = Self {
            seam,
            title: "Binary analysis".to_string(),
            artifact: String::new(),
            checksec: Checksec::default(),
            checksec_measured: true,
            crates: Vec::new(),
            symbols: Vec::new(),
            diff: None,
            history: Vec::new(),
        };
        pane.refresh();
        pane
    }

    /// Override the pane heading (default `"Binary analysis"`).
    pub fn with_title(mut self, title: impl Into<String>) -> Self {
        self.title = title.into();
        self
    }

    /// Re-pull the whole snapshot from the seam (call when the host has fresh
    /// warehouse rows).
    pub fn refresh(&mut self) {
        self.artifact = self.seam.artifact_name();
        self.checksec = self.seam.checksec();
        self.checksec_measured = self.seam.checksec_measured();
        self.crates = self.seam.crate_sizes();
        // Largest crate first — the treemap and the JSON both read this order.
        self.crates.sort_by_key(|c| std::cmp::Reverse(c.bytes));
        self.symbols = self.seam.symbol_sizes();
        self.diff = self.seam.diff();
        self.history = self.seam.size_history();
    }

    /// The cached checksec verdict (read-only).
    pub fn checksec(&self) -> &Checksec {
        &self.checksec
    }

    /// The cached per-crate aggregates, largest-first (read-only).
    pub fn crate_sizes(&self) -> &[CrateSize] {
        &self.crates
    }

    /// The cached diff, if any (read-only).
    pub fn diff(&self) -> Option<&ArtifactDiff> {
        self.diff.as_ref()
    }

    /// **Lay out the bloat treemap** into `area` — the exact tiles the pane paints
    /// (same helper, so the robot test can assert the painted layout as data). Tiles
    /// come back largest-first; empty when there are no positive crate sizes.
    pub fn crate_treemap(&self, area: Rect) -> Vec<CrateTile> {
        let weights: Vec<f64> = self.crates.iter().map(|c| c.bytes as f64).collect();
        let rects = squarified_treemap(&weights, area);
        self.crates
            .iter()
            .zip(rects)
            .filter(|(_, r)| r.is_finite() && r.width() > 0.0 && r.height() > 0.0)
            .map(|(c, rect)| CrateTile {
                crate_name: c.crate_name.clone(),
                bytes: c.bytes,
                rect,
                color: crate_color(&c.crate_name),
            })
            .collect()
    }

    // ── painters (pure egui, no wgpu) ──────────────────────────────────────────

    fn paint_treemap(&self, ui: &mut egui::Ui) {
        let tiles = {
            // Allocate a square-ish area for the treemap.
            let w = ui.available_width().min(460.0);
            let (rect, _resp) = ui.allocate_exact_size(Vec2::new(w, 200.0), Sense::hover());
            let tiles = self.crate_treemap(rect);
            let painter = ui.painter();
            painter.rect_filled(rect, 2.0, Color32::from_gray(20));
            for t in &tiles {
                painter.rect_filled(t.rect, 1.0, t.color);
                painter.rect_stroke(
                    t.rect,
                    1.0,
                    Stroke::new(1.0_f32, Color32::from_black_alpha(120)),
                    egui::StrokeKind::Inside,
                );
                // Label the tile if it is roomy enough to read.
                if t.rect.width() > 44.0 && t.rect.height() > 16.0 {
                    painter.text(
                        t.rect.left_top() + Vec2::new(4.0, 3.0),
                        egui::Align2::LEFT_TOP,
                        &t.crate_name,
                        FontId::proportional(11.0),
                        Color32::from_black_alpha(200),
                    );
                }
            }
            tiles
        };
        // A textual legend under the map — real widgets so a robot/screen-reader sees
        // each crate + its bytes (the treemap rects themselves are painter-only).
        ui.horizontal_wrapped(|ui| {
            for t in &tiles {
                let (chip, _) = ui.allocate_exact_size(Vec2::splat(10.0), Sense::hover());
                ui.painter().rect_filled(chip, 2.0, t.color);
                ui.label(format!("{} · {}", t.crate_name, human_bytes(t.bytes)));
            }
        });
    }

    fn paint_checksec(&self, ui: &mut egui::Ui) {
        if !self.checksec_measured {
            ui.weak(format!("not measured — {} does not grade the ELF (RELRO · PIE · NX · canary)", self.seam.source()));
            return;
        }
        ui.horizontal_wrapped(|ui| {
            for b in self.checksec.badges() {
                let (fg, bg) = if b.ok {
                    (Color32::from_rgb(0x10, 0x2a, 0x18), SHRINK_COLOR)
                } else {
                    (Color32::from_rgb(0x2a, 0x10, 0x10), GROW_COLOR)
                };
                let text = format!("{} {}", b.label, if b.ok { "✓" } else { "✗" });
                // A real, coloured widget so the badge text is in the a11y tree.
                let resp = ui.add(
                    egui::Label::new(egui::RichText::new(text).color(fg).background_color(bg))
                        .sense(Sense::hover()),
                );
                resp.on_hover_text(format!("{}: {}", b.label, b.detail));
            }
        });
    }

    fn paint_sparkline(&self, ui: &mut egui::Ui) {
        if self.history.len() < 2 {
            return;
        }
        let (rect, _resp) =
            ui.allocate_exact_size(Vec2::new(ui.available_width().min(460.0), 40.0), Sense::hover());
        let painter = ui.painter();
        painter.rect_filled(rect, 2.0, Color32::from_gray(18));
        let lo = *self.history.iter().min().unwrap() as f32;
        let hi = *self.history.iter().max().unwrap() as f32;
        let span = (hi - lo).max(1.0);
        let n = self.history.len();
        let pts: Vec<Pos2> = self
            .history
            .iter()
            .enumerate()
            .map(|(i, &v)| {
                let x = rect.left() + rect.width() * (i as f32 / (n - 1) as f32);
                let y = rect.bottom() - (rect.height() - 6.0) * ((v as f32 - lo) / span) - 3.0;
                Pos2::new(x, y)
            })
            .collect();
        painter.add(egui::Shape::line(pts.clone(), Stroke::new(1.6_f32, ANCHOR_COLOR)));
        if let Some(&last) = pts.last() {
            painter.circle_filled(last, 2.5, ANCHOR_COLOR);
        }
    }

    fn paint_waterfall(&self, ui: &mut egui::Ui, diff: &ArtifactDiff) {
        let (rect, _resp) =
            ui.allocate_exact_size(Vec2::new(ui.available_width().min(460.0), 150.0), Sense::hover());
        let painter = ui.painter();
        painter.rect_filled(rect, 2.0, Color32::from_gray(18));

        // Bars: [before] + one per section delta (floating) + [after].
        let n_bars = diff.sections.len() + 2;
        if n_bars == 0 {
            return;
        }
        // Vertical scale: the tallest cumulative level we reach.
        let mut level = diff.before_bytes as f64;
        let mut peak = diff.before_bytes.max(diff.after_bytes) as f64;
        for s in &diff.sections {
            level += s.delta() as f64;
            peak = peak.max(level);
        }
        let peak = (peak * 1.08).max(1.0);
        let base_y = rect.bottom() - 4.0;
        let top_y = rect.top() + 4.0;
        let usable_h = (base_y - top_y).max(1.0);
        let y_of = |bytes: f64| base_y - (bytes / peak) as f32 * usable_h;

        let slot_w = rect.width() / n_bars as f32;
        let bar_w = (slot_w * 0.7).max(2.0);
        let bar_x = |i: usize| rect.left() + slot_w * (i as f32 + 0.5) - bar_w / 2.0;

        // before anchor (full height from base).
        let before_rect =
            Rect::from_min_max(Pos2::new(bar_x(0), y_of(diff.before_bytes as f64)), Pos2::new(bar_x(0) + bar_w, base_y));
        painter.rect_filled(before_rect, 1.0, ANCHOR_COLOR);

        // floating section deltas.
        let mut running = diff.before_bytes as f64;
        for (i, s) in diff.sections.iter().enumerate() {
            let d = s.delta() as f64;
            let start = running;
            let end = running + d;
            running = end;
            let (top, bot) = if end >= start { (end, start) } else { (start, end) };
            let x = bar_x(i + 1);
            let seg = Rect::from_min_max(Pos2::new(x, y_of(top)), Pos2::new(x + bar_w, y_of(bot)));
            // grow = red (up), shrink = green (down); guarantee a visible sliver.
            let seg = if seg.height() < 2.0 {
                Rect::from_min_max(seg.left_top(), Pos2::new(seg.right(), seg.top() + 2.0))
            } else {
                seg
            };
            let col = if d >= 0.0 { GROW_COLOR } else { SHRINK_COLOR };
            painter.rect_filled(seg, 1.0, col);
        }

        // after anchor.
        let after_rect = Rect::from_min_max(
            Pos2::new(bar_x(n_bars - 1), y_of(diff.after_bytes as f64)),
            Pos2::new(bar_x(n_bars - 1) + bar_w, base_y),
        );
        painter.rect_filled(after_rect, 1.0, ANCHOR_COLOR);

        // A textual summary line (real widgets so the delta is a11y-observable).
        let total = diff.total_delta();
        let sign = if total >= 0 { "+" } else { "−" };
        let color = if total >= 0 { GROW_COLOR } else { SHRINK_COLOR };
        ui.horizontal(|ui| {
            ui.label(format!(
                "{} → {}",
                human_bytes(diff.before_bytes),
                human_bytes(diff.after_bytes)
            ));
            ui.colored_label(color, format!("{sign}{}", human_bytes(total.unsigned_abs())));
            if diff.abi_changed {
                ui.colored_label(GROW_COLOR, "ABI changed");
            } else {
                ui.colored_label(SHRINK_COLOR, "ABI stable");
            }
        });
        ui.weak(format!(
            "symbols: +{} added, −{} removed, ▲{} grown, ▼{} shrunk",
            diff.added.len(),
            diff.removed.len(),
            diff.grown.len(),
            diff.shrunk.len()
        ));
    }
}

impl Facet for BinaryPane {
    fn title(&self) -> &str {
        &self.title
    }

    fn ui(&mut self, ui: &mut egui::Ui) {
        ui.heading(&self.title);
        ui.horizontal(|ui| {
            ui.strong(&self.artifact);
            ui.weak(format!("via {}", self.seam.source()));
        });
        ui.separator();

        ui.label("Hardening");
        self.paint_checksec(ui);
        ui.add_space(6.0);

        ui.label("Size history");
        self.paint_sparkline(ui);
        ui.add_space(6.0);

        ui.label("Bloat by crate");
        self.paint_treemap(ui);
        ui.add_space(6.0);

        if let Some(diff) = self.diff.clone() {
            ui.separator();
            ui.label("Size diff vs baseline");
            self.paint_waterfall(ui, &diff);
        }
    }

    fn state_json(&self) -> serde_json::Value {
        let tiles = self.crate_treemap(Rect::from_min_size(Pos2::ZERO, Vec2::new(400.0, 200.0)));
        let mut o = serde_json::Map::new();
        o.insert("kind".into(), serde_json::json!("binview"));
        o.insert("source".into(), serde_json::json!(self.seam.source()));
        o.insert("artifact".into(), serde_json::json!(self.artifact));

        // Checksec as badges (label/ok/detail) — the render truth.
        let badges: Vec<serde_json::Value> = self
            .checksec
            .badges()
            .into_iter()
            .map(|b| serde_json::json!({"label": b.label, "ok": b.ok, "detail": b.detail}))
            .collect();
        // An unmeasured verdict is `null`, never a row of red badges.
        o.insert("checksec".into(), if self.checksec_measured { serde_json::json!(badges) } else { serde_json::Value::Null });

        // The treemap tiles as data (crate/bytes/area) — largest-first.
        let tj: Vec<serde_json::Value> = tiles
            .iter()
            .map(|t| {
                serde_json::json!({
                    "crate": t.crate_name,
                    "bytes": t.bytes,
                    "area": (t.rect.width() * t.rect.height()) as f64,
                })
            })
            .collect();
        o.insert("treemap".into(), serde_json::json!(tj));
        o.insert("crate_count".into(), serde_json::json!(self.crates.len()));

        o.insert("history".into(), serde_json::json!(self.history));

        if let Some(d) = &self.diff {
            o.insert(
                "diff".into(),
                serde_json::json!({
                    "before_bytes": d.before_bytes,
                    "after_bytes": d.after_bytes,
                    "total_delta": d.total_delta(),
                    "sections": d.sections.iter().map(|s| serde_json::json!({
                        "name": s.name, "before": s.before, "after": s.after, "delta": s.delta(),
                    })).collect::<Vec<_>>(),
                    "added": d.added,
                    "removed": d.removed,
                    "grown": d.grown.iter().map(|s| serde_json::json!({
                        "symbol": s.symbol, "delta": s.delta(),
                    })).collect::<Vec<_>>(),
                    "shrunk": d.shrunk.iter().map(|s| serde_json::json!({
                        "symbol": s.symbol, "delta": s.delta(),
                    })).collect::<Vec<_>>(),
                    "abi_changed": d.abi_changed,
                    "abi_note": d.abi_note,
                }),
            );
        }
        serde_json::Value::Object(o)
    }

    fn update_json(&mut self, msg_json: &str) {
        if let Ok(v) = serde_json::from_str::<serde_json::Value>(msg_json)
            && v.get("refresh").and_then(|t| t.as_bool()) == Some(true)
        {
            self.refresh();
        }
    }

    fn kind(&self) -> &'static str {
        "binview"
    }
}

/// Format a byte count for a compact label (`12.3 KB`, `4.1 MB`).
pub fn human_bytes(bytes: u64) -> String {
    const U: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
    let mut v = bytes as f64;
    let mut i = 0;
    while v >= 1024.0 && i < U.len() - 1 {
        v /= 1024.0;
        i += 1;
    }
    if i == 0 {
        format!("{bytes} {}", U[0])
    } else {
        format!("{v:.1} {}", U[i])
    }
}

// ─────────────────────────────────────────────────────────────────────────────
// A network-free stub seam for the demo + the robot test.
// ─────────────────────────────────────────────────────────────────────────────

/// A **stub** [`BinaryDataSeam`] with a realistic-looking snapshot (a bloated
/// `nornir` binary) for the shipped demo + the robot test. Deterministic: the
/// biggest crate is `nornir`, the checksec verdict mixes green/red, and the diff
/// carries both a growth and a shrink.
pub struct StubBinaryData;

impl BinaryDataSeam for StubBinaryData {
    fn source(&self) -> &str {
        "stub (demo)"
    }

    fn artifact_name(&self) -> String {
        "nornir".to_string()
    }

    fn checksec(&self) -> Checksec {
        Checksec {
            relro: Relro::Full,     // green
            pie: true,              // green
            nx: true,               // green
            stack_canary: false,    // red
            stripped: true,         // green (size win)
            fortify: false,         // red
        }
    }

    fn crate_sizes(&self) -> Vec<CrateSize> {
        vec![
            CrateSize::new("nornir", 1_940_000),
            CrateSize::new("std", 820_000),
            CrateSize::new("arrow", 610_000),
            CrateSize::new("serde", 240_000),
            CrateSize::new("tokio", 180_000),
            CrateSize::new("[Unknown]", 96_000),
        ]
    }

    fn symbol_sizes(&self) -> Vec<SymbolSize> {
        vec![
            SymbolSize::new("nornir", "nornir::warehouse::Mimir::query", 84_200),
            SymbolSize::new("nornir", "nornir::release::topo_sort", 51_100),
            SymbolSize::new("arrow", "arrow::compute::kernels::sort", 42_800),
            SymbolSize::new("std", "core::fmt::write", 12_400),
        ]
    }

    fn diff(&self) -> Option<ArtifactDiff> {
        Some(ArtifactDiff {
            before_bytes: 3_620_000,
            after_bytes: 3_886_000,
            sections: vec![
                SectionDelta::new(".text", 2_400_000, 2_610_000), // +210k grown
                SectionDelta::new(".rodata", 720_000, 690_000),   // −30k shrunk
                SectionDelta::new(".data", 180_000, 186_000),     // +6k grown
                SectionDelta::new(".bss", 320_000, 400_000),      // +80k grown
            ],
            added: vec![
                "nornir::binview::render".to_string(),
                "nornir::warehouse::binary_facts".to_string(),
            ],
            removed: vec!["nornir::legacy::old_report".to_string()],
            grown: vec![
                SymbolDelta::new("nornir::warehouse::Mimir::query", 62_000, 84_200),
                SymbolDelta::new("nornir::release::topo_sort", 40_000, 51_100),
            ],
            shrunk: vec![SymbolDelta::new("arrow::compute::kernels::sort", 58_000, 42_800)],
            abi_changed: true,
            abi_note: "added 2 pub fns to nornir::binview".to_string(),
        })
    }

    fn size_history(&self) -> Vec<u64> {
        vec![3_120_000, 3_300_000, 3_620_000, 3_540_000, 3_886_000]
    }
}

// ── testmatrix emit shim (real row emitter with `--features testmatrix`, a
//    compiled-out no-op otherwise — the facett-auth-ui idiom) ──────────────────
/// Emit one functional-status row (no-op unless built `--features testmatrix`).
#[cfg(feature = "testmatrix")]
pub fn emit(component: &str, check: &str, ok: bool, detail: &str) {
    nornir_testmatrix::functional_status(component, check, ok, detail);
}
/// No-op shape compiled when `testmatrix` is OFF (the release path).
#[cfg(not(feature = "testmatrix"))]
#[inline(always)]
pub fn emit(_component: &str, _check: &str, _ok: bool, _detail: &str) {}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn stub_snapshot_reads_through_state_json() {
        let pane = BinaryPane::new(Box::new(StubBinaryData));
        let s = pane.state_json();
        assert_eq!(s["kind"], "binview");
        assert_eq!(s["artifact"], "nornir");

        // Treemap: largest-first, nornir the biggest tile, all areas positive.
        let tm = s["treemap"].as_array().unwrap();
        assert!(tm.len() >= 2);
        assert_eq!(tm[0]["crate"], "nornir");
        let a0 = tm[0]["area"].as_f64().unwrap();
        let a1 = tm[1]["area"].as_f64().unwrap();
        assert!(a0 > 0.0 && a1 > 0.0 && a0 >= a1, "biggest crate has the biggest area");

        // Checksec: a green and a red both present.
        let badges = s["checksec"].as_array().unwrap();
        assert!(badges.iter().any(|b| b["label"] == "PIE" && b["ok"] == true));
        assert!(badges.iter().any(|b| b["label"] == "CANARY" && b["ok"] == false));

        // Diff: a growth and a shrink section both present.
        let secs = s["diff"]["sections"].as_array().unwrap();
        assert!(secs.iter().any(|d| d["delta"].as_i64().unwrap() > 0), "a grown section");
        assert!(secs.iter().any(|d| d["delta"].as_i64().unwrap() < 0), "a shrunk section");
        assert_eq!(s["diff"]["abi_changed"], true);
    }

    #[test]
    fn treemap_biggest_crate_gets_biggest_rect() {
        let pane = BinaryPane::new(Box::new(StubBinaryData));
        let tiles = pane.crate_treemap(Rect::from_min_size(Pos2::ZERO, Vec2::new(400.0, 200.0)));
        assert!(!tiles.is_empty());
        // Coverage is non-blank: the tiles fill most of the area.
        let area: f32 = tiles.iter().map(|t| t.rect.width() * t.rect.height()).sum();
        assert!(area > 400.0 * 200.0 * 0.7, "treemap covers the area (non-blank)");
        // The biggest-bytes crate has the biggest rect.
        let biggest = tiles.iter().max_by_key(|t| t.bytes).unwrap();
        let widest = tiles
            .iter()
            .max_by(|a, b| {
                (a.rect.width() * a.rect.height())
                    .partial_cmp(&(b.rect.width() * b.rect.height()))
                    .unwrap()
            })
            .unwrap();
        assert_eq!(biggest.crate_name, widest.crate_name);
        assert_eq!(biggest.crate_name, "nornir");
    }

    #[test]
    fn squarified_treemap_is_proportional_and_bounded() {
        let area = Rect::from_min_size(Pos2::ZERO, Vec2::new(300.0, 200.0));
        let rects = squarified_treemap(&[100.0, 50.0, 25.0, 25.0], area);
        // Every rect stays inside the area.
        for r in &rects {
            assert!(area.contains_rect(r.shrink(0.01)), "tile within bounds: {r:?}");
        }
        // The 100-weight rect is bigger than the 50-weight rect.
        let a: f32 = rects[0].width() * rects[0].height();
        let b: f32 = rects[1].width() * rects[1].height();
        assert!(a > b);
        // Zero weights collapse to NOTHING.
        let z = squarified_treemap(&[1.0, 0.0], area);
        assert!(!z[1].is_finite() || z[1].area() == 0.0);
    }

    /// A seam that does not grade the ELF draws no badge: `checksec` is `null` in the
    /// state, not six red ✗ (all flags off is "unmeasured", not "unhardened").
    #[test]
    fn an_unmeasured_checksec_is_null_not_red_badges() {
        struct Symbols;
        impl BinaryDataSeam for Symbols {
            fn artifact_name(&self) -> String {
                "a".to_string()
            }
            fn checksec(&self) -> Checksec {
                Checksec::default()
            }
            fn checksec_measured(&self) -> bool {
                false
            }
            fn crate_sizes(&self) -> Vec<CrateSize> {
                vec![CrateSize { crate_name: "a".into(), bytes: 10 }]
            }
        }
        let s = BinaryPane::new(Box::new(Symbols)).state_json();
        assert!(s["checksec"].is_null(), "{s}");
        assert_eq!(s["crate_count"], 1);
        // and a seam that does not say keeps its badges
        assert!(BinaryPane::new(Box::new(StubBinaryData)).state_json()["checksec"].is_array());
    }

    #[test]
    fn empty_seam_does_not_panic() {
        struct Empty;
        impl BinaryDataSeam for Empty {
            fn artifact_name(&self) -> String {
                "empty".to_string()
            }
            fn checksec(&self) -> Checksec {
                Checksec::default()
            }
            fn crate_sizes(&self) -> Vec<CrateSize> {
                Vec::new()
            }
        }
        let pane = BinaryPane::new(Box::new(Empty));
        let s = pane.state_json();
        assert_eq!(s["crate_count"], 0);
        assert!(pane.crate_treemap(Rect::from_min_size(Pos2::ZERO, Vec2::new(10.0, 10.0))).is_empty());
        assert!(pane.diff().is_none());
    }
}