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

nordisk/facett

facett-security/src/lib.rs

//! **facett-security** — a software-supply-chain **security report** view: the
//! **verdict** (Pass / Warn / Block), the **findings** list (id · severity ·
//! package · advisory · fix) with **severity / EPSS / KEV** columns, and a
//! secondary **CVE / advisory** reference table. It is the UI *twin* of
//! [modgunn](https://git.gunnar.rs/mikronova/modgunn)'s scan output — the gate that
//! decides whether an artifact may be served / promoted.
//!
//! ## Generic on purpose (no modgunn dependency)
//! facett MUST NOT couple to the modgunn crate. So this crate carries its **own**
//! plain, serializable structs — [`Verdict`], [`Severity`], [`Finding`],
//! [`Artifact`], [`SecurityReport`] — whose shape **mirrors `modgunn::core`** so a
//! consumer (nornir-viz / holger-ui) maps the two **1:1** with no glue:
//!
//! | facett-security        | modgunn::core                                   |
//! |------------------------|-------------------------------------------------|
//! | [`Verdict`] (Pass/Warn/Block) | `Decision` (Pass/Warn/Block) — same `Ord`  |
//! | [`Severity`] (Info…Critical)  | `Severity` (Info…Critical) — same `Ord`    |
//! | [`Finding`] `{id,severity,…}` | `Finding` `{id,severity,summary}` (superset) |
//! | [`Artifact`]                  | `ArtifactRef`                              |
//! | [`SecurityReport`]            | `Verdict` (the whole cacheable result)     |
//!
//! [`Finding`] is a **superset**: modgunn's three fields map straight in, and the
//! richer scan columns the UI wants (`package`, `advisory`, `fix`, `epss`, `kev`)
//! are optional, so a 1:1 mapper fills what it has and leaves the rest defaulted.
//!
//! ## FC compliance (the component contract)
//! - **FC-1 / FC-3** — all observable state lives in one serializable
//!   [`SecurityState`] (`Serialize`/`Deserialize`/`Clone`/`PartialEq`), read back
//!   via [`SecurityView::state`].
//! - **FC-2 / FC-9** — every input is a [`Msg`]; mutation only ever happens in
//!   [`SecurityView::update`]. The [`render`](SecurityView::render) pass is a pure
//!   function of `&self` that paints and *returns* the [`Msg`]s it produced; the
//!   immediate-mode [`Facet::ui`] just applies them. The view holds no hidden
//!   state.
//! - **FC-4** — the painted verdict pill emits an AccessKit node; every finding
//!   row / CVE row is a selectable, labelled, value-carrying node.
//! - **FC-5** — rows are keyed off the finding **id** (a stable domain key), never
//!   a loop index.
//! - **FC-6** — pure egui + painter, renders headlessly (the deck robot path).
//! - **FC-7** — no ambient clock / RNG / animation: the scan time is an *injected*
//!   host-stamped string ([`SecurityReport::scanned_at`]).
//! - **FC-8** — the view does no I/O; [`SecurityView::is_idle`] is always true and
//!   [`SecurityView::update`] returns an (empty) `Vec<Effect>`.

use facett_core::{Facet, FacetCaps, Semantics, theme};
use serde::{Deserialize, Serialize};

/// The warm red used across facett for failure/blocking (the base palette has no
/// semantic red) — shared by the Block verdict + the Critical severity ramp.
pub const RED: egui::Color32 = facett_core::look::RED;
/// The amber used for the Warn verdict + the High severity band.
pub const AMBER: egui::Color32 = egui::Color32::from_rgb(230, 170, 60);

// ───────────────────────────────────────────────────────────────────────────
// Verdict — mirrors modgunn::core::Decision
// ───────────────────────────────────────────────────────────────────────────

/// What a gate should DO with the artifact, given the findings. Declaration
/// order is the strictness order — `Pass < Warn < Block` — so folding many
/// findings into one verdict is their [`Ord::max`] (the same contract
/// `modgunn::core::Decision` relies on).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Verdict {
    /// Serve / promote freely. The neutral default (the identity for `max()`).
    #[default]
    Pass,
    /// Serve, but surface the findings (advisory).
    Warn,
    /// Refuse to serve / promote.
    Block,
}

impl Verdict {
    /// The short, greppable chip tag.
    pub fn tag(self) -> &'static str {
        match self {
            Verdict::Pass => "PASS",
            Verdict::Warn => "WARN",
            Verdict::Block => "BLOCK",
        }
    }

    /// Parse a free-text decision (case-insensitive) onto the closed vocabulary;
    /// `None` for an unrecognised word.
    pub fn parse(s: &str) -> Option<Verdict> {
        match s.trim().to_ascii_lowercase().as_str() {
            "pass" | "passed" | "ok" | "allow" => Some(Verdict::Pass),
            "warn" | "warning" | "advisory" => Some(Verdict::Warn),
            "block" | "blocked" | "deny" | "fail" | "failed" => Some(Verdict::Block),
            _ => None,
        }
    }

    /// The verdict chip colour on the active theme.
    pub fn color(self, th: &facett_core::theme::Theme) -> egui::Color32 {
        match self {
            Verdict::Pass => th.point,
            Verdict::Warn => AMBER,
            Verdict::Block => RED,
        }
    }
}

// ───────────────────────────────────────────────────────────────────────────
// Severity — mirrors modgunn::core::Severity
// ───────────────────────────────────────────────────────────────────────────

/// Finding severity (declaration order = ascending, so the worst finding is the
/// `max()`). Mirrors `modgunn::core::Severity`.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Severity {
    Info,
    Low,
    Medium,
    High,
    Critical,
}

impl Severity {
    /// The short, greppable tag.
    pub fn tag(self) -> &'static str {
        match self {
            Severity::Info => "INFO",
            Severity::Low => "LOW",
            Severity::Medium => "MED",
            Severity::High => "HIGH",
            Severity::Critical => "CRIT",
        }
    }

    /// Parse a free-text severity (case-insensitive); `None` if unknown.
    pub fn parse(s: &str) -> Option<Severity> {
        match s.trim().to_ascii_lowercase().as_str() {
            "info" | "informational" | "none" => Some(Severity::Info),
            "low" => Some(Severity::Low),
            "medium" | "med" | "moderate" => Some(Severity::Medium),
            "high" => Some(Severity::High),
            "critical" | "crit" => Some(Severity::Critical),
            _ => None,
        }
    }

    /// A stable 0..=4 rank (carried into the AccessKit `numeric_value` so a robot
    /// driver reads the severity back as a number).
    pub fn rank(self) -> u8 {
        match self {
            Severity::Info => 0,
            Severity::Low => 1,
            Severity::Medium => 2,
            Severity::High => 3,
            Severity::Critical => 4,
        }
    }

    /// The severity colour on the active theme (Critical/High are the warm
    /// red/amber; the rest pick palette accents so the table re-skins per theme).
    pub fn color(self, th: &facett_core::theme::Theme) -> egui::Color32 {
        match self {
            Severity::Critical => RED,
            Severity::High => AMBER,
            Severity::Medium => th.accent,
            Severity::Low => th.point,
            Severity::Info => th.text_dim,
        }
    }
}

// ───────────────────────────────────────────────────────────────────────────
// Finding / Artifact / SecurityReport — the plain, host-supplied data
// ───────────────────────────────────────────────────────────────────────────

/// One thing the scanner found. `id`, `severity`, `summary` map straight from
/// `modgunn::core::Finding`; the rest are the richer scan columns the UI shows
/// (all optional, so a 1:1 mapper fills what it has).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Finding {
    /// Stable id (a CVE / RUSTSEC / GHSA advisory id, or a tripwire id). Also the
    /// row's stable identity (FC-5).
    pub id: String,
    pub severity: Severity,
    /// The affected package (`name` or `name@version`).
    pub package: String,
    /// The advisory summary / title.
    pub summary: String,
    /// An advisory reference (a URL or db ref), if any.
    #[serde(default)]
    pub advisory: Option<String>,
    /// The remediation — the fixed version / upgrade target, if any.
    #[serde(default)]
    pub fix: Option<String>,
    /// EPSS exploit-probability in `0.0..=1.0`, if scored.
    #[serde(default)]
    pub epss: Option<f64>,
    /// Whether the id is on CISA's Known-Exploited-Vulnerabilities catalog.
    #[serde(default)]
    pub kev: bool,
}

impl Finding {
    /// A minimal finding (the three modgunn fields); builders add the rest.
    pub fn new(id: impl Into<String>, severity: Severity, package: impl Into<String>, summary: impl Into<String>) -> Self {
        Self {
            id: id.into(),
            severity,
            package: package.into(),
            summary: summary.into(),
            advisory: None,
            fix: None,
            epss: None,
            kev: false,
        }
    }
    /// Builder: set the advisory reference.
    pub fn with_advisory(mut self, advisory: impl Into<String>) -> Self {
        self.advisory = Some(advisory.into());
        self
    }
    /// Builder: set the fix / remediation.
    pub fn with_fix(mut self, fix: impl Into<String>) -> Self {
        self.fix = Some(fix.into());
        self
    }
    /// Builder: set the EPSS probability (clamped to `0.0..=1.0`).
    pub fn with_epss(mut self, epss: f64) -> Self {
        self.epss = Some(epss.clamp(0.0, 1.0));
        self
    }
    /// Builder: mark the finding as KEV-listed.
    pub fn with_kev(mut self, kev: bool) -> Self {
        self.kev = kev;
        self
    }
}

/// The artifact a verdict is about (content-addressed). Mirrors
/// `modgunn::core::ArtifactRef`.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Artifact {
    /// Ecosystem (e.g. `"cargo"`, `"maven"`, `"npm"`).
    pub ecosystem: String,
    pub name: String,
    pub version: String,
    /// The content hash the verdict is keyed by.
    pub blob_sha256: String,
}

impl Artifact {
    pub fn new(ecosystem: impl Into<String>, name: impl Into<String>, version: impl Into<String>, blob_sha256: impl Into<String>) -> Self {
        Self { ecosystem: ecosystem.into(), name: name.into(), version: version.into(), blob_sha256: blob_sha256.into() }
    }
    /// `name@version` for display.
    pub fn coords(&self) -> String {
        format!("{}@{}", self.name, self.version)
    }
}

/// The cacheable scan result: the verdict + its findings + the keys it was
/// computed against. The plain data a host hands the view; mirrors the whole of
/// `modgunn::core::Verdict`.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct SecurityReport {
    pub artifact: Artifact,
    pub verdict: Verdict,
    pub findings: Vec<Finding>,
    /// Cache key component (policy the verdict was computed under).
    pub policy_version: String,
    /// Cache key component (advisory-db revision the verdict was computed under).
    pub advisory_db_rev: String,
    /// **Injected clock (FC-7).** A host-stamped scan time (e.g. an RFC-3339 or
    /// `HH:MM:SS` string). The view never reads a clock, so it renders identically
    /// headless.
    pub scanned_at: String,
}

impl SecurityReport {
    /// An empty/clean report for `artifact`, stamped `scanned_at`.
    pub fn clean(artifact: Artifact, scanned_at: impl Into<String>) -> Self {
        Self {
            artifact,
            verdict: Verdict::Pass,
            findings: Vec::new(),
            policy_version: String::new(),
            advisory_db_rev: String::new(),
            scanned_at: scanned_at.into(),
        }
    }

    /// The strongest severity among the findings (`None` ⇒ clean). Mirrors
    /// `modgunn::core::Verdict::max_severity`.
    pub fn max_severity(&self) -> Option<Severity> {
        self.findings.iter().map(|f| f.severity).max()
    }

    /// Per-severity finding counts `[info, low, medium, high, critical]`.
    pub fn severity_counts(&self) -> [usize; 5] {
        let mut c = [0usize; 5];
        for f in &self.findings {
            c[f.severity.rank() as usize] += 1;
        }
        c
    }

    /// How many findings are on the KEV catalog.
    pub fn kev_count(&self) -> usize {
        self.findings.iter().filter(|f| f.kev).count()
    }
}

// ───────────────────────────────────────────────────────────────────────────
// State / Msg / Effect — FC-1..FC-3, FC-8, FC-9
// ───────────────────────────────────────────────────────────────────────────

/// **All** observable state for the view (FC-1 / FC-3): the report plus the UI
/// state (the drilled-in finding, the severity floor filter, and the zoom).
/// Fully serializable so a caller reads it back at any frame boundary.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct SecurityState {
    pub report: SecurityReport,
    /// The drilled-in finding id (its CVE row highlights), if any.
    #[serde(default)]
    pub selected: Option<String>,
    /// Severity floor — only findings `>= min_severity` show (`None` = all).
    #[serde(default)]
    pub min_severity: Option<Severity>,
    /// Uniform zoom (1.0 = native), driving the text size.
    #[serde(default = "one")]
    pub scale: f32,
}

fn one() -> f32 {
    1.0
}

impl SecurityState {
    /// Wrap a report in fresh UI state (no selection, no filter, native scale).
    pub fn new(report: SecurityReport) -> Self {
        Self { report, selected: None, min_severity: None, scale: 1.0 }
    }

    /// The findings that pass the [`min_severity`](Self::min_severity) filter,
    /// **sorted worst-first** (severity desc, then id) — the order the table and
    /// the CVE list paint, so a driver reads rows in the same order it sees them.
    pub fn visible_findings(&self) -> Vec<&Finding> {
        let mut v: Vec<&Finding> = self
            .report
            .findings
            .iter()
            .filter(|f| self.min_severity.is_none_or(|m| f.severity >= m))
            .collect();
        v.sort_by(|a, b| b.severity.cmp(&a.severity).then_with(|| a.id.cmp(&b.id)));
        v
    }
}

/// Every input the view accepts (FC-2). The **only** legal way to mutate
/// [`SecurityState`]; derives `Debug` + `Clone`.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Msg {
    /// Drill into a finding by id (`None` clears the selection).
    Select(Option<String>),
    /// Set the severity floor filter (`None` shows all).
    SetMinSeverity(Option<Severity>),
    /// Set the zoom (clamped to `0.25..=4.0`).
    SetScale(f32),
}

/// Side work as data (FC-8). The view does no I/O, so this is uninhabited — it
/// exists to make the "no pending effects" contract explicit and type-checked.
#[derive(Clone, Debug, PartialEq)]
pub enum Effect {}

// ───────────────────────────────────────────────────────────────────────────
// SecurityView — the Facet driver
// ───────────────────────────────────────────────────────────────────────────

/// The [`Facet`] over a [`SecurityReport`]: renders the verdict banner, the
/// findings table (severity / EPSS / KEV columns), and the CVE / advisory table.
pub struct SecurityView {
    title: String,
    state: SecurityState,
}

impl SecurityView {
    /// New view titled `title` over `report`.
    pub fn new(title: impl Into<String>, report: SecurityReport) -> Self {
        Self { title: title.into(), state: SecurityState::new(report) }
    }

    /// Re-title (the deck keys facets off [`Facet::title`]).
    pub fn set_title(&mut self, title: impl Into<String>) {
        self.title = title.into();
    }

    /// **FC-3** — read the complete observable state at any frame boundary.
    pub fn state(&self) -> &SecurityState {
        &self.state
    }

    /// The live report.
    pub fn report(&self) -> &SecurityReport {
        &self.state.report
    }

    /// The current verdict.
    pub fn verdict(&self) -> Verdict {
        self.state.report.verdict
    }

    /// The drilled-in finding id, if any.
    pub fn selected(&self) -> Option<&str> {
        self.state.selected.as_deref()
    }

    /// **FC-8** — the view does no I/O, so it is always idle.
    pub fn is_idle(&self) -> bool {
        true
    }

    /// **FC-2** — the single mutation path. Apply one [`Msg`]; returns the
    /// (always empty) effects the caller should run.
    pub fn update(&mut self, msg: Msg) -> Vec<Effect> {
        match msg {
            Msg::Select(id) => {
                // Toggle: re-selecting the open finding clears it.
                self.state.selected = if self.state.selected == id { None } else { id };
            }
            Msg::SetMinSeverity(s) => self.state.min_severity = s,
            Msg::SetScale(s) => self.state.scale = s.clamp(0.25, 4.0),
        }
        Vec::new()
    }

    /// **Discovery-contract `local()`** — a realistic **Block** report (a Critical
    /// KEV CVE + a High + a Medium + an Info advisory) so the pane renders real,
    /// non-trivial rows with no host attached.
    pub fn local() -> Self {
        let report = SecurityReport {
            artifact: Artifact::new("cargo", "leftpad-rs", "2.1.0", "9f86d081884c7d65"),
            verdict: Verdict::Block,
            findings: vec![
                Finding::new("CVE-2024-31337", Severity::Critical, "openssl-sys@0.9.90", "Heap buffer overflow in ASN.1 parsing")
                    .with_advisory("https://nvd.nist.gov/vuln/detail/CVE-2024-31337")
                    .with_fix("0.9.92")
                    .with_epss(0.94)
                    .with_kev(true),
                Finding::new("RUSTSEC-2023-0071", Severity::High, "rsa@0.9.2", "Marvin timing side-channel (key recovery)")
                    .with_advisory("https://rustsec.org/advisories/RUSTSEC-2023-0071")
                    .with_fix("0.9.6")
                    .with_epss(0.41),
                Finding::new("GHSA-wxr5-93ch-7f92", Severity::Medium, "regex@1.5.4", "ReDoS via crafted pattern")
                    .with_advisory("https://github.com/advisories/GHSA-wxr5-93ch-7f92")
                    .with_fix("1.5.5")
                    .with_epss(0.07),
                Finding::new("RUSTSEC-2024-0006", Severity::Info, "leftpad-rs@2.1.0", "Crate is unmaintained")
                    .with_advisory("https://rustsec.org/advisories/RUSTSEC-2024-0006"),
            ],
            policy_version: "nordisk-policy-7".into(),
            advisory_db_rev: "advdb-2024.06.30".into(),
            scanned_at: "2026-06-30T12:00:00Z".into(),
        };
        Self::new("security", report)
    }

    /// **Discovery-contract `remote()`** — same surface, re-titled.
    pub fn remote() -> Self {
        let mut v = Self::local();
        v.title = "security (remote)".into();
        v
    }

    // ── render (FC-9): a PURE function of `&self` that paints and RETURNS the
    //    `Msg`s it produced. `Facet::ui` applies them through `update`. ──────────
    fn render(&self, ui: &mut egui::Ui) -> Vec<Msg> {
        let mut out = Vec::new();
        let th = theme(ui);
        let st = &self.state;
        let rep = &st.report;

        // Scale the text styles for the whole pane (restored at the end).
        let prev = ui.style().text_styles.clone();
        if (st.scale - 1.0).abs() > f32::EPSILON {
            ui.style_mut().text_styles.iter_mut().for_each(|(_, f)| f.size *= st.scale);
        }

        // ── verdict banner: a painter-drawn pill + an explicit AccessKit node ──
        verdict_pill(ui, rep.verdict, rep.findings.len(), &th);
        ui.add_space(2.0);
        ui.horizontal_wrapped(|ui| {
            ui.label(egui::RichText::new(rep.artifact.coords()).color(th.text).strong().monospace());
            ui.label(egui::RichText::new(format!("· {}", rep.artifact.ecosystem)).color(th.text_dim));
            ui.label(egui::RichText::new(format!("· sha256:{}…", short_sha(&rep.artifact.blob_sha256))).color(th.text_dim).monospace());
        });
        ui.label(
            egui::RichText::new(format!(
                "policy {} · advisory-db {} · scanned {}",
                rep.policy_version, rep.advisory_db_rev, rep.scanned_at
            ))
            .color(th.text_dim)
            .small(),
        );

        // ── severity summary strip: per-severity chips + a facett-stat severity
        //    histogram (the [info,low,med,high,crit] distribution drawn through the
        //    SHARED stat sparkline lane — `facett_stat::sparkline` — so the count
        //    shape rides the same primitive the stat grid uses). ──
        let counts = rep.severity_counts();
        ui.horizontal_wrapped(|ui| {
            for sev in [Severity::Critical, Severity::High, Severity::Medium, Severity::Low, Severity::Info] {
                let n = counts[sev.rank() as usize];
                ui.label(egui::RichText::new(format!("{} {n}", sev.tag())).color(sev.color(&th)).monospace());
            }
            if rep.kev_count() > 0 {
                ui.label(egui::RichText::new(format!("KEV {}", rep.kev_count())).color(RED).strong());
            }
        });
        severity_histogram(ui, &counts, &th);
        ui.separator();

        let visible = st.visible_findings();

        // ── findings table: id · severity · package · EPSS · KEV · fix ──
        ui.label(egui::RichText::new(format!("Findings ({})", visible.len())).color(th.text).strong());
        if visible.is_empty() {
            ui.label(egui::RichText::new("no findings").color(th.point));
        } else {
            let base = ui.id().with("findings");
            egui::Grid::new("facett_security_findings")
                .num_columns(6)
                .striped(true)
                .spacing([14.0, 4.0])
                .show(ui, |ui| {
                    for h in ["ID", "SEV", "PACKAGE", "EPSS", "KEV", "FIX"] {
                        ui.label(egui::RichText::new(h).color(th.text_dim).small().strong());
                    }
                    ui.end_row();
                    for f in &visible {
                        let selected = st.selected.as_deref() == Some(f.id.as_str());
                        // The ID cell is the selectable, value-carrying node, keyed
                        // on the finding id (stable domain key — FC-5).
                        let id_resp = ui
                            .push_id(facett_core::stable_id(base, &f.id), |ui| {
                                ui.selectable_label(
                                    selected,
                                    egui::RichText::new(&f.id).color(th.accent).monospace(),
                                )
                            })
                            .inner;
                        // Carry the severity rank as the node's numeric_value so a
                        // robot driver reads the severity back as a number (FC-4).
                        {
                            let (id, sev, sel) = (f.id.clone(), f.severity, selected);
                            id_resp.widget_info(move || {
                                Semantics::list_item(format!("{id}: {}", sev.tag()), sel)
                                    .value(sev.rank() as f64)
                                    .widget_info()
                            });
                        }
                        if id_resp.clicked() {
                            out.push(Msg::Select(Some(f.id.clone())));
                        }
                        ui.label(egui::RichText::new(f.severity.tag()).color(f.severity.color(&th)).strong());
                        ui.label(egui::RichText::new(&f.package).color(th.text).monospace());
                        match f.epss {
                            Some(p) => ui.label(egui::RichText::new(format!("{:.0}%", p * 100.0)).color(epss_color(p, &th)).monospace()),
                            None => ui.label(egui::RichText::new("—").color(th.text_dim)),
                        };
                        if f.kev {
                            ui.label(egui::RichText::new("KEV").color(RED).strong());
                        } else {
                            ui.label(egui::RichText::new("—").color(th.text_dim));
                        }
                        match &f.fix {
                            Some(fix) => ui.label(egui::RichText::new(fix).color(th.point).monospace()),
                            None => ui.label(egui::RichText::new("none").color(th.text_dim)),
                        };
                        ui.end_row();
                    }
                });

            // ── CVE / advisory reference table: id · advisory · summary ──
            ui.add_space(6.0);
            ui.label(egui::RichText::new("CVE / advisories").color(th.text).strong());
            let abase = ui.id().with("advisories");
            egui::Grid::new("facett_security_advisories")
                .num_columns(3)
                .striped(true)
                .spacing([14.0, 4.0])
                .show(ui, |ui| {
                    for h in ["ID", "ADVISORY", "SUMMARY"] {
                        ui.label(egui::RichText::new(h).color(th.text_dim).small().strong());
                    }
                    ui.end_row();
                    for f in &visible {
                        ui.push_id(facett_core::stable_id(abase, &f.id), |ui| {
                            ui.label(egui::RichText::new(&f.id).color(th.text_dim).monospace());
                        });
                        match &f.advisory {
                            Some(url) => {
                                ui.hyperlink_to(egui::RichText::new("link").color(th.accent), url);
                            }
                            None => {
                                ui.label(egui::RichText::new("—").color(th.text_dim));
                            }
                        }
                        ui.label(egui::RichText::new(&f.summary).color(th.text));
                        ui.end_row();
                    }
                });
        }

        ui.style_mut().text_styles = prev;

        #[cfg(feature = "testmatrix")]
        facett_core::testmatrix::emit(
            "facett-security::SecurityView::ui",
            "ui_render",
            true,
            &format!("verdict={} findings={} visible={}", rep.verdict.tag(), rep.findings.len(), visible.len()),
        );

        out
    }
}

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

    fn ui(&mut self, ui: &mut egui::Ui) {
        facett_core::navgraph::declare_own_draw_order(ui); // atoms follow the data order (navgraph §9)
        // FC-9: the view is pure (paints + emits Msgs); FC-2: mutation only via
        // `update`. The immediate-mode trait just bridges the two.
        let msgs = self.render(ui);
        for m in msgs {
            self.update(m);
        }
    }

    /// The robot / headless-drive seam (FC-2): parse one serde-tagged [`Msg`] and
    /// apply it through the single [`update`](SecurityView::update) mutation path.
    /// Malformed / unknown JSON is ignored (never a panic), so a headless robot or
    /// the shared `probe_and_emit` harness drives the finding drill-in / severity
    /// floor / zoom without the concrete `Msg` type at the call site.
    fn update_json(&mut self, msg_json: &str) {
        if let Ok(msg) = serde_json::from_str::<Msg>(msg_json) {
            let _ = self.update(msg);
        }
    }

    fn state_json(&self) -> serde_json::Value {
        let st = &self.state;
        let rep = &st.report;
        let counts = rep.severity_counts();
        serde_json::json!({
            "title": self.title,
            "verdict": rep.verdict.tag(),
            "scale": st.scale,
            "selected": st.selected,
            "min_severity": st.min_severity.map(|s| s.tag()),
            "artifact": {
                "ecosystem": rep.artifact.ecosystem,
                "name": rep.artifact.name,
                "version": rep.artifact.version,
                "coords": rep.artifact.coords(),
                "blob_sha256": rep.artifact.blob_sha256,
            },
            "policy_version": rep.policy_version,
            "advisory_db_rev": rep.advisory_db_rev,
            "scanned_at": rep.scanned_at,
            "max_severity": rep.max_severity().map(|s| s.tag()),
            "kev_count": rep.kev_count(),
            "severity_counts": {
                "info": counts[0], "low": counts[1], "medium": counts[2], "high": counts[3], "critical": counts[4],
            },
            "findings_total": rep.findings.len(),
            "findings_visible": st.visible_findings().len(),
            // The visible findings, in the SAME worst-first order the table paints.
            "findings": st.visible_findings().iter().map(|f| serde_json::json!({
                "id": f.id,
                "severity": f.severity.tag(),
                "severity_rank": f.severity.rank(),
                "package": f.package,
                "summary": f.summary,
                "advisory": f.advisory,
                "fix": f.fix,
                "epss": f.epss,
                "kev": f.kev,
            })).collect::<Vec<_>>(),
        })
    }

    fn caps(&self) -> FacetCaps {
        FacetCaps::NONE.selectable().copyable().themeable().scalable().resizable()
    }

    fn scale(&self) -> f32 {
        self.state.scale
    }

    fn set_scale(&mut self, scale: f32) {
        self.update(Msg::SetScale(scale));
    }

    fn selection_json(&self) -> serde_json::Value {
        match self.state.selected.as_deref() {
            Some(id) => match self.state.report.findings.iter().find(|f| f.id == id) {
                Some(f) => serde_json::json!({
                    "id": f.id, "severity": f.severity.tag(), "package": f.package, "summary": f.summary,
                    "advisory": f.advisory, "fix": f.fix, "epss": f.epss, "kev": f.kev,
                }),
                None => serde_json::Value::Null,
            },
            None => serde_json::Value::Null,
        }
    }

    /// Facet-level copy: the whole report (the live state) as pretty JSON.
    fn copy(&mut self) -> Option<String> {
        serde_json::to_string_pretty(&self.state.report).ok().filter(|s| !s.is_empty())
    }
}

// ── FC-2/FC-9 canonical trait: SecurityView IS the reference `Elm` component ──
//
// `SecurityView` already hand-rolls the Model/Msg/Effect/pure-view split, so it
// implements `facett_core::Elm` verbatim by delegating to its existing inherent
// methods — no behavior change. This is the proof the shared infra fits the worked
// reference. NOTE: the hand-written `impl Facet` above is intentionally KEPT (not
// replaced by `impl_facet_via_elm!`) because this view publishes a **richer**
// `state_json` (verdict tag, severity counts, worst-first findings) than the
// macro's plain `serde(state())` default; swapping it would change that observable
// dump and break the `state_json` contract tests. Downstream components with no
// custom `state_json` use the macro instead.
impl facett_core::Elm for SecurityView {
    type Model = SecurityState;
    type Msg = Msg;
    type Effect = Effect;

    fn title(&self) -> &str {
        &self.title
    }
    fn state(&self) -> &SecurityState {
        &self.state
    }
    fn update(&mut self, msg: Msg) -> Vec<Effect> {
        SecurityView::update(self, msg)
    }
    fn view(&self, ui: &mut egui::Ui) -> Vec<Msg> {
        self.render(ui)
    }
}

// ── painter helpers ─────────────────────────────────────────────────────────

/// The verdict banner pill (painter-drawn) + its AccessKit node (FC-4). The node
/// carries the verdict tag as its label and the finding count as numeric_value.
fn verdict_pill(ui: &mut egui::Ui, verdict: Verdict, findings: usize, th: &facett_core::theme::Theme) {
    let col = verdict.color(th);
    let text = format!("{}  ·  {findings} finding{}", verdict.tag(), if findings == 1 { "" } else { "s" });
    let galley = ui.painter().layout_no_wrap(text.clone(), egui::FontId::proportional(18.0), col);
    let pad = egui::vec2(14.0, 7.0);
    let (rect, _) = ui.allocate_exact_size(galley.size() + pad * 2.0, egui::Sense::hover());
    let fill = egui::Color32::from_rgba_unmultiplied(col.r(), col.g(), col.b(), 38);
    let cr = egui::CornerRadius::same(8);
    ui.painter().rect_filled(rect, cr, fill);
    ui.painter().rect_stroke(rect, cr, egui::Stroke::new(2.0_f32, col), egui::StrokeKind::Inside);
    ui.painter().galley(rect.min + pad, galley, col);

    // The value-bearing node over the painted pill: label = "verdict: BLOCK",
    // numeric_value = finding count. Stable key (not a loop index — FC-5).
    let base = ui.id();
    let sem = Semantics::new(egui::WidgetType::Label, format!("verdict: {}", verdict.tag())).value(findings as f64);
    let _ = facett_core::a11y::node(ui, base, ("security-verdict", verdict.tag()), egui::Sense::hover(), rect, sem);
}

/// The severity histogram — the `[info, low, medium, high, critical]` finding
/// counts drawn through facett's **shared** stat sparkline lane
/// ([`facett_stat::sparkline`]), so the severity distribution rides the same
/// primitive the stat grid uses (FC: reuse over re-paint). A value-bearing
/// AccessKit node carries the total finding count so a robot reads the histogram
/// back as a number (FC-4); the rank with the most findings is its label.
fn severity_histogram(ui: &mut egui::Ui, counts: &[usize; 5], th: &facett_core::theme::Theme) {
    let total: usize = counts.iter().sum();
    if total == 0 {
        return;
    }
    let series: Vec<f64> = counts.iter().map(|&c| c as f64).collect();
    let (rect, _) = ui.allocate_exact_size(egui::vec2(120.0, 16.0), egui::Sense::hover());
    facett_stat::sparkline(ui.painter(), th, &series, rect.shrink(2.0));
    // The histogram's AccessKit node: label names the modal severity, value = the
    // total finding count. Stable key (FC-5).
    let peak = (0..5).max_by_key(|&i| counts[i]).unwrap_or(0) as u8;
    let modal = match peak {
        4 => "critical",
        3 => "high",
        2 => "medium",
        1 => "low",
        _ => "info",
    };
    let sem = Semantics::new(egui::WidgetType::Image, format!("severity histogram · {total} findings · modal {modal}")).value(total as f64);
    let _ = facett_core::a11y::node(ui, ui.id(), "security-severity-histogram", egui::Sense::hover(), rect, sem);
}

/// EPSS colour ramp: high exploit-probability is the warm red, mid is amber.
fn epss_color(p: f64, th: &facett_core::theme::Theme) -> egui::Color32 {
    if p >= 0.5 {
        RED
    } else if p >= 0.1 {
        AMBER
    } else {
        th.text
    }
}

/// First 12 hex chars of a sha, for the compact artifact line.
fn short_sha(sha: &str) -> &str {
    let n = sha.len().min(12);
    &sha[..n]
}

#[cfg(test)]
mod tests;