nornir 0.4.20

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, RichText};
use serde_json::Value;

use super::facett_theme::{Theme, AMBER, GREEN, RED};
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>>,
    theme: Theme,
}

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

    /// Re-skin this pane with a facett palette (broadcast from the picker).
    pub fn set_palette(&mut self, t: Theme) {
        self.theme = t;
    }

    /// Re-scope to a newly picked workspace: swap in its checkout root + repo
    /// set, and drop any scan from the previous workspace so the buttons (and a
    /// lingering result) don't show the old workspace's repos. Mirrors
    /// [`KnowledgeTab::set_workspace`]; called from `App::switch_workspace`.
    pub fn set_workspace(&mut self, workspace_root: PathBuf, repos: Vec<String>) {
        self.workspace_root = workspace_root;
        self.repos = repos;
        self.scanning = None;
        self.pending = None;
        self.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 theme = self.theme;
        let busy = self.pending.is_some();
        ui.horizontal_wrapped(|ui| {
            ui.label("scan a workspace repo:");
            if self.repos.is_empty() {
                ui.colored_label(theme.text_dim, "(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(RED, format!("scan failed: {e}"));
            }
            Some(Ok(snap)) => {
                ui.horizontal(|ui| {
                    ui.heading(RichText::new(format!("🛡 {}", snap.repo)).color(theme.accent));
                    ui.label(format!("· {} crates (deep)", snap.components));
                    let nv: usize = snap.vulns.iter().map(|(_, _, ids)| ids.len()).sum();
                    let c = if nv == 0 { GREEN } else { RED };
                    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(theme.text_dim).small());
                }
                ui.separator();
                egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
                    if snap.vulns.is_empty() {
                        ui.colored_label(GREEN, "✓ 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")) {
                                    AMBER
                                } else {
                                    theme.accent
                                };
                                ui.label(RichText::new("").color(sev));
                                ui.monospace(format!("{name} {ver}"));
                                ui.colored_label(theme.text_dim, 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(theme.text_dim)
                                    .monospace(),
                            );
                            ui.separator();
                        }
                    });
                });
            }
        }
    }

    /// 🛡 Security tab's slice of `state_json` (LAW #6): the configured repos
    /// (the scan buttons), which repo (if any) is currently scanning, and the
    /// rendered scan result — component count + per-crate vuln findings +
    /// license tally + the warehouse-cache hit/miss counters.
    pub fn state_json(&self) -> serde_json::Value {
        serde_json::json!({
            "palette": self.theme.name,
            "repos": self.repos,
            "scanning": self.scanning,
            "busy": self.pending.is_some(),
            "result": match &self.result {
                None => serde_json::json!({ "ran": false }),
                Some(Err(e)) => serde_json::json!({ "ran": true, "ok": false, "error": e }),
                Some(Ok(snap)) => serde_json::json!({
                    "ran": true, "ok": true,
                    "repo": snap.repo,
                    "components": snap.components,
                    "vuln_crates": snap.vulns.len(),
                    "vulns": snap.vulns.iter().map(|(c, v, ids)| serde_json::json!({
                        "crate": c, "version": v, "ids": ids,
                    })).collect::<Vec<_>>(),
                    "licenses": snap.licenses.iter().map(|(l, n)| serde_json::json!([l, n])).collect::<Vec<_>>(),
                    "cache_hits": snap.cache_hits,
                    "cache_misses": snap.cache_misses,
                }),
            },
        })
    }
}

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

    /// The propagation bug (fixed): switching workspace must re-scope the
    /// Security tab's repo buttons. Inject workspace A's repos, switch to B,
    /// assert the rendered state shows ONLY B's repos and root — never A's.
    #[test]
    fn set_workspace_rescopes_repos_and_root() {
        let mut tab = SecurityTab::new(
            PathBuf::from("/ws/holger"),
            vec!["holger".into(), "holger-cli".into()],
        );
        // Sanity: it starts on workspace A's repos.
        let a = tab.state_json();
        assert_eq!(a["repos"], serde_json::json!(["holger", "holger-cli"]));

        // Pick workspace B.
        tab.set_workspace(PathBuf::from("/ws/knut"), vec!["knut".into(), "knut-pipelines".into()]);

        let b = tab.state_json();
        assert_eq!(
            b["repos"],
            serde_json::json!(["knut", "knut-pipelines"]),
            "Security repos must follow the picked workspace, not stay on the previous one"
        );
        assert_eq!(tab.workspace_root, PathBuf::from("/ws/knut"));
        // A stale scan from workspace A must be cleared, not shown under B.
        assert_eq!(b["busy"], serde_json::json!(false));
        assert_eq!(b["scanning"], serde_json::Value::Null);
        assert_eq!(b["result"], serde_json::json!({ "ran": false }));
        // None of A's repos may leak through.
        assert!(!b["repos"].to_string().contains("holger"));
    }
}