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

nordisk/nornir

src/mimir_lens/verdict_svg.rs

//! **Red-marked CPG render — the verdict SVG (baddie-detection b5, first cut).**
//!
//! The brief's "first cut = a skade-style SVG of the refuse set": a static,
//! dependency-free SVG that paints every actionable node by its worst verdict —
//! **refuse 🔴 / hold 🟠 / act 🟢** — reusing the shared verdict palette the Trust and
//! ReachableVuln lenses both speak. Any lens that produces `(label, verdict,
//! weakest_hop)` triples feeds this renderer; today Trust supplies REAL verdicts and
//! ReachableVuln supplies them the moment `vuln_findings` is fed.
//!
//! Pure string SVG — no typst, no `docs-export`, no JS — so it renders in a terminal
//! preview, a markdown viewer, or the dwarves UI identically. A layered/zoomable graph
//! is the later cut; this first cut is the ranked refuse→hold card grid that names the
//! culprit + its weakest hop, which is the actionable 90 %.

use serde::{Deserialize, Serialize};

/// One node to paint: its label, the verdict that colours it, and the weakest hop
/// (the reason the verdict is capped — the culprit to fix).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct VerdictNode {
    /// The node identity, e.g. `korp::resolve_token` or `time@0.1.45`.
    pub label: String,
    /// `"refuse"` | `"hold"` | `"act"` — the shared trichotomy.
    pub verdict: String,
    /// The weakest hop / culprit that caps the verdict (may be empty).
    pub weakest_hop: String,
}

/// Palette (skade-style): refuse red, hold amber, act green, on a dark canvas.
fn fill_of(verdict: &str) -> &'static str {
    match verdict {
        "refuse" => "#d1344e",
        "hold" => "#e0973a",
        _ => "#3aa76d",
    }
}

/// Minimal XML-escape for text nodes/attributes (the labels are code identifiers +
/// crate names, but a `<`/`&` in a reason string must not break the document).
fn esc(s: &str) -> String {
    s.replace('&', "&amp;")
        .replace('<', "&lt;")
        .replace('>', "&gt;")
        .replace('"', "&quot;")
}

/// Truncate a label to `n` chars with an ellipsis so a long fqn stays inside its card.
fn clip(s: &str, n: usize) -> String {
    if s.chars().count() <= n {
        s.to_string()
    } else {
        let mut out: String = s.chars().take(n.saturating_sub(1)).collect();
        out.push('…');
        out
    }
}

/// **Render a skade-style refuse-set SVG.** `nodes` are painted in the order given
/// (callers rank refuse-before-hold); at most `cap` cards are drawn and the overflow
/// is noted in the header. Returns a standalone `<svg>…</svg>` string.
pub fn render_refuse_map(title: &str, nodes: &[VerdictNode], cap: usize) -> String {
    // ── layout geometry ──────────────────────────────────────────────────────────
    let cols = 3usize;
    let card_w = 300i64;
    let card_h = 64i64;
    let gap = 14i64;
    let pad = 24i64;
    let header_h = 92i64;

    let shown = nodes.len().min(cap);
    let overflow = nodes.len().saturating_sub(shown);
    let rows = shown.div_ceil(cols).max(1);
    let width = pad * 2 + card_w * cols as i64 + gap * (cols as i64 - 1);
    let height = header_h + pad + rows as i64 * (card_h + gap);

    let refuse = nodes.iter().filter(|n| n.verdict == "refuse").count();
    let hold = nodes.iter().filter(|n| n.verdict == "hold").count();

    let mut s = String::new();
    s.push_str(&format!(
        r##"<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" viewBox="0 0 {width} {height}" font-family="ui-monospace, Menlo, monospace">"##
    ));
    s.push_str(&format!(
        r##"<rect width="{width}" height="{height}" fill="#14161b"/>"##
    ));

    // ── header: title + counts + legend ──────────────────────────────────────────
    s.push_str(&format!(
        r##"<text x="{pad}" y="40" fill="#e6e6e6" font-size="22" font-weight="700">{}</text>"##,
        esc(title)
    ));
    s.push_str(&format!(
        r##"<text x="{pad}" y="66" fill="#9aa0aa" font-size="14">{refuse} refuse · {hold} hold{}</text>"##,
        if overflow > 0 { format!(" · +{overflow} more") } else { String::new() }
    ));
    // legend swatches
    let mut lx = width - pad - 220;
    for (v, name) in [("refuse", "refuse"), ("hold", "hold"), ("act", "act")] {
        s.push_str(&format!(
            r##"<rect x="{lx}" y="26" width="14" height="14" rx="3" fill="{}"/><text x="{}" y="38" fill="#9aa0aa" font-size="13">{name}</text>"##,
            fill_of(v),
            lx + 20
        ));
        lx += 72;
    }

    // ── the cards ─────────────────────────────────────────────────────────────────
    for (i, node) in nodes.iter().take(shown).enumerate() {
        let col = (i % cols) as i64;
        let row = (i / cols) as i64;
        let x = pad + col * (card_w + gap);
        let y = header_h + pad + row * (card_h + gap);
        let fill = fill_of(&node.verdict);
        s.push_str(&format!(
            r##"<rect x="{x}" y="{y}" width="{card_w}" height="{card_h}" rx="8" fill="{fill}" fill-opacity="0.18" stroke="{fill}" stroke-width="1.5"/>"##
        ));
        // a verdict dot
        s.push_str(&format!(
            r##"<circle cx="{}" cy="{}" r="5" fill="{fill}"/>"##,
            x + 16,
            y + 22
        ));
        s.push_str(&format!(
            r##"<text x="{}" y="{}" fill="#e6e6e6" font-size="14" font-weight="600">{}</text>"##,
            x + 30,
            y + 26,
            esc(&clip(&node.label, 30))
        ));
        if !node.weakest_hop.is_empty() {
            s.push_str(&format!(
                r##"<text x="{}" y="{}" fill="#9aa0aa" font-size="12">↯ {}</text>"##,
                x + 30,
                y + 47,
                esc(&clip(&node.weakest_hop, 34))
            ));
        }
    }

    s.push_str("</svg>");
    s
}

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

    fn n(label: &str, verdict: &str, hop: &str) -> VerdictNode {
        VerdictNode {
            label: label.into(),
            verdict: verdict.into(),
            weakest_hop: hop.into(),
        }
    }

    /// **Red-when-reverted core.** A refuse node is painted with the refuse red and a
    /// hold node with the hold amber — the whole point of a "red-marked" graph. Swap
    /// the palette and these colour asserts break. The SVG is well-formed and names
    /// the culprit + its weakest hop.
    #[test]
    fn refuse_is_red_hold_is_amber_and_labels_render() {
        let nodes = &[
            n(
                "nornir::spawn_shell",
                "refuse",
                "impure hop — shell_out (Command::new)",
            ),
            n("korp::load_cfg", "hold", "untested"),
        ];
        let svg = render_refuse_map("Trust — refuse set", nodes, 50);
        assert!(svg.starts_with("<svg") && svg.ends_with("</svg>"));
        // the refuse red + hold amber are both present
        assert!(svg.contains("#d1344e"), "refuse red must be painted");
        assert!(svg.contains("#e0973a"), "hold amber must be painted");
        // culprit + weakest hop rendered
        assert!(svg.contains("nornir::spawn_shell"));
        assert!(
            svg.contains("shell_out"),
            "the weakest hop names the culprit"
        );
        // header counts
        assert!(svg.contains("1 refuse · 1 hold"));
    }

    /// XML-unsafe characters in a reason string must be escaped, not break the doc.
    #[test]
    fn labels_are_xml_escaped() {
        let nodes = &[n("a::f", "refuse", "cmp <T> & raw <ptr>")];
        let svg = render_refuse_map("t", nodes, 50);
        assert!(svg.contains("&lt;T&gt; &amp; raw &lt;ptr&gt;"), "{svg}");
        assert!(
            !svg.contains("<ptr>"),
            "raw angle brackets must not survive"
        );
    }

    /// The `cap` bounds the cards drawn and the overflow is honestly reported (never a
    /// silent truncation).
    #[test]
    fn cap_bounds_cards_and_reports_overflow() {
        let nodes: Vec<VerdictNode> = (0..10)
            .map(|i| n(&format!("r::f{i}"), "refuse", ""))
            .collect();
        let svg = render_refuse_map("t", &nodes, 4);
        assert!(svg.contains("+6 more"), "overflow reported: {svg}");
        assert!(svg.contains("r::f0") && svg.contains("r::f3"));
        assert!(!svg.contains("r::f4"), "capped cards are not drawn");
    }
}