nornir 0.4.13

Companion to cargo: dependency tracking, release gating, deploy, benchmarks, and documentation assembly. Project-agnostic.
Documentation
//! 🛡 Security tab — **workspace-driven, server-first** SBOM/vuln/license scan.
//! Lists the workspace's configured repos (no free-text path) as buttons. When
//! connected to a server it calls `Mimir.SecurityScan` (the server scans its own
//! `git/<repo>` checkout — the viz host needs no sources); otherwise it scans
//! the local `workspace_root/<repo>` via `crate::security::scan`.
//!
//! NOTE (next): the deep-scanned dependency closure already lives in the
//! warehouse (`repo=deps`); a `sbom_components`/`vuln_findings` cache would make
//! even the server side warehouse-first instead of re-running cargo metadata.

use std::path::PathBuf;
use std::sync::{Arc, Mutex};

use eframe::egui::{self, Color32, RichText};
use serde_json::Value;

use super::ops_tabs::Server;

/// A finished scan's display data (flattened + Send).
struct Snapshot {
    repo: String,
    components: usize,
    vulns: Vec<(String, String, Vec<String>)>, // (crate, version, advisory ids)
    licenses: Vec<(String, usize)>,
    cache_hits: usize,
    cache_misses: usize,
}

type Slot = Arc<Mutex<Option<Result<Snapshot, String>>>>;

fn snap_from_report(rep: crate::security::SecurityReport) -> Snapshot {
    Snapshot {
        repo: rep.repo.clone(),
        components: rep.components.len(),
        vulns: rep
            .vulns
            .iter()
            .map(|v| (v.crate_name.clone(), v.version.clone(), v.ids.clone()))
            .collect(),
        licenses: rep.license_tally(),
        cache_hits: 0,
        cache_misses: rep.components.len(),
    }
}

/// Parse the `Mimir.SecurityScan` JSON ({repo, components, vulns[], licenses[[l,n]]}).
fn parse_report(json: &str) -> Result<Snapshot, String> {
    let v: Value = serde_json::from_str(json).map_err(|e| e.to_string())?;
    let vulns = v["vulns"]
        .as_array()
        .map(|a| {
            a.iter()
                .map(|x| {
                    (
                        x["crate"].as_str().unwrap_or_default().to_string(),
                        x["version"].as_str().unwrap_or_default().to_string(),
                        x["ids"].as_array().map(|i| i.iter().filter_map(|s| s.as_str().map(String::from)).collect()).unwrap_or_default(),
                    )
                })
                .collect()
        })
        .unwrap_or_default();
    let licenses = v["licenses"]
        .as_array()
        .map(|a| {
            a.iter()
                .filter_map(|x| {
                    let pair = x.as_array()?;
                    Some((pair.first()?.as_str()?.to_string(), pair.get(1)?.as_u64()? as usize))
                })
                .collect()
        })
        .unwrap_or_default();
    Ok(Snapshot {
        repo: v["repo"].as_str().unwrap_or_default().to_string(),
        components: v["components"].as_u64().unwrap_or(0) as usize,
        vulns,
        licenses,
        cache_hits: v["cache_hits"].as_u64().unwrap_or(0) as usize,
        cache_misses: v["cache_misses"].as_u64().unwrap_or(0) as usize,
    })
}

pub struct SecurityTab {
    workspace_root: PathBuf,
    repos: Vec<String>,
    scanning: Option<String>,
    pending: Option<Slot>,
    result: Option<Result<Snapshot, String>>,
}

impl SecurityTab {
    pub fn new(workspace_root: PathBuf, repos: Vec<String>) -> Self {
        Self { workspace_root, repos, scanning: None, pending: None, result: None }
    }

    fn start_scan(&mut self, srv: Option<&Server>, workspace: &str, repo: &str) {
        let slot: Slot = Arc::new(Mutex::new(None));
        let sink = slot.clone();
        let repo_s = repo.to_string();
        match srv {
            // Server-first: the server scans its own checkout; no sources needed here.
            Some(s) => {
                let (ep, tok, ws) = (s.endpoint.clone(), s.token.clone(), workspace.to_string());
                std::thread::spawn(move || {
                    let r = super::remote::security_scan(&ep, &tok, &repo_s, &ws)
                        .map_err(|e| format!("{e:#}"))
                        .and_then(|j| parse_report(&j));
                    *sink.lock().unwrap() = Some(r);
                });
            }
            // Local fallback: scan workspace_root/<repo> on disk.
            None => {
                let path = self.workspace_root.join(repo);
                std::thread::spawn(move || {
                    let r = crate::security::scan(&path).map(snap_from_report).map_err(|e| format!("{e:#}"));
                    *sink.lock().unwrap() = Some(r);
                });
            }
        }
        self.scanning = Some(repo.to_string());
        self.pending = Some(slot);
        self.result = None;
    }

    pub fn draw(&mut self, ui: &mut egui::Ui, srv: Option<&Server>, workspace: &str) {
        let busy = self.pending.is_some();
        ui.horizontal_wrapped(|ui| {
            ui.label("scan a workspace repo:");
            if self.repos.is_empty() {
                ui.colored_label(Color32::from_gray(160), "(no repos for this workspace)");
            }
            for repo in self.repos.clone() {
                if ui.add_enabled(!busy, egui::Button::new(format!("🛡 {repo}"))).clicked() {
                    self.start_scan(srv, workspace, &repo);
                }
            }
            ui.weak(if srv.is_some() { "· server-side" } else { "· local" });
        });
        ui.separator();

        // Recover a poisoned lock rather than panicking the UI thread if the
        // background scan thread happened to unwind while holding it.
        let done = self
            .pending
            .as_ref()
            .and_then(|slot| slot.lock().unwrap_or_else(|p| p.into_inner()).take());
        if let Some(d) = done {
            self.result = Some(d);
            self.pending = None;
        }
        if self.pending.is_some() {
            let r = self.scanning.as_deref().unwrap_or("");
            ui.label(format!("⏳ scanning {r} — cargo metadata (deep tree) + OSV.dev …"));
            ui.ctx().request_repaint();
            return;
        }

        match &self.result {
            None => {
                ui.label("Pick a repo above to run a deep SBOM + vulnerability + license scan.");
                ui.label(
                    RichText::new(
                        "cargo metadata → full dependency tree · OSV.dev → CVEs/RUSTSEC · CycloneDX-compatible",
                    )
                    .weak(),
                );
            }
            Some(Err(e)) => {
                ui.colored_label(Color32::from_rgb(220, 90, 90), format!("scan failed: {e}"));
            }
            Some(Ok(snap)) => {
                ui.horizontal(|ui| {
                    ui.heading(RichText::new(format!("🛡 {}", snap.repo)).color(Color32::from_rgb(150, 210, 255)));
                    ui.label(format!("· {} crates (deep)", snap.components));
                    let nv: usize = snap.vulns.iter().map(|(_, _, ids)| ids.len()).sum();
                    let c = if nv == 0 { Color32::from_rgb(90, 200, 130) } else { Color32::from_rgb(230, 110, 80) };
                    ui.label(RichText::new(format!("· {nv} vuln(s) in {} crate(s)", snap.vulns.len())).color(c).strong());
                });
                if snap.cache_hits + snap.cache_misses > 0 {
                    let msg = if snap.cache_misses == 0 {
                        format!("warehouse cache: {} hit / 0 miss — zero network ✓", snap.cache_hits)
                    } else {
                        format!("warehouse cache: {} hit / {} miss (queried OSV)", snap.cache_hits, snap.cache_misses)
                    };
                    ui.label(RichText::new(msg).color(Color32::from_rgb(120, 180, 150)).small());
                }
                ui.separator();
                egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
                    if snap.vulns.is_empty() {
                        ui.colored_label(Color32::from_rgb(90, 200, 130), "✓ no known vulnerabilities");
                    } else {
                        for (name, ver, ids) in &snap.vulns {
                            ui.horizontal(|ui| {
                                let sev = if ids.iter().any(|i| i.starts_with("RUSTSEC") || i.starts_with("CVE")) {
                                    Color32::from_rgb(235, 150, 60)
                                } else {
                                    Color32::from_rgb(120, 190, 220)
                                };
                                ui.label(RichText::new("").color(sev));
                                ui.monospace(format!("{name} {ver}"));
                                ui.colored_label(Color32::from_gray(200), ids.join(", "));
                            });
                        }
                    }
                    ui.separator();
                    ui.label(RichText::new("licenses").strong());
                    ui.horizontal_wrapped(|ui| {
                        for (lic, n) in snap.licenses.iter().take(12) {
                            ui.label(
                                RichText::new(format!("{lic} ×{n}"))
                                    .color(Color32::from_rgb(170, 190, 210))
                                    .monospace(),
                            );
                            ui.separator();
                        }
                    });
                });
            }
        }
    }
}