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

nordisk/nornir

src/security.rs

//! SBOM + vulnerability + license producer — pure Rust, no external tools, no C
//! deps. `cargo metadata` gives the full deep dependency tree; the OSV.dev batch
//! API gives vulnerabilities; the manifests give licenses. Emits a **CycloneDX
//! 1.5** SBOM. The standards-based feed for the warehouse
//! (`sbom_components` / `vuln_findings` / `license_facts`) and the viz Security tab.

use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::process::Command;

use anyhow::{Context, Result};
use serde_json::{Value, json};

#[derive(Clone, Debug)]
pub struct Component {
    pub name: String,
    pub version: String,
    pub license: String,
}

#[derive(Clone)]
pub struct Vuln {
    pub crate_name: String,
    pub version: String,
    pub ids: Vec<String>,
    pub summary: String,
    /// `Some(kind)` when EVERY matched advisory for this crate is a rustsec
    /// **informational** advisory (`unmaintained` / `unsound` / `notice`) rather
    /// than a real vulnerability. Such advisories must Warn, not Block (bug #15).
    /// `None` for a real vuln (or the online OSV path, which carries no kind).
    pub informational: Option<String>,
}

pub struct SecurityReport {
    pub repo: String,
    pub components: Vec<Component>,
    pub vulns: Vec<Vuln>,
    /// The advisory source revision the `vulns` were matched against (the rustsec
    /// advisory-db git HEAD sha, or an OSV snapshot marker). This — NOT the repo
    /// name — is the verdict-cache key component, so a new advisory invalidates a
    /// stale Pass and the cache can be shared across repos (bug #16).
    pub advisory_db_rev: String,
}

impl SecurityReport {
    /// License → count, most-common first.
    pub fn license_tally(&self) -> Vec<(String, usize)> {
        let mut m: BTreeMap<String, usize> = BTreeMap::new();
        for c in &self.components {
            *m.entry(c.license.clone()).or_default() += 1;
        }
        let mut v: Vec<_> = m.into_iter().collect();
        v.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
        v
    }

    pub fn vuln_count(&self) -> usize {
        self.vulns.iter().map(|v| v.ids.len()).sum()
    }

    /// CycloneDX 1.5 SBOM (components + vulnerabilities). Emitted by modgunn's
    /// shared `sbom` module so the serialization lives in one place (multi-ecosystem
    /// purls); nornir maps its cargo `Component`/`Vuln` into the modgunn shape. The
    /// cargo output is byte-identical to nornir's prior in-place literal.
    pub fn to_cyclonedx(&self) -> Value {
        use modgunn::sbom::{SbomComponent, SbomInput, SbomVuln};
        let input = SbomInput {
            subject: self.repo.clone(),
            components: self
                .components
                .iter()
                .map(|c| SbomComponent {
                    ecosystem: "cargo".into(),
                    name: c.name.clone(),
                    version: c.version.clone(),
                    license: Some(c.license.clone()),
                })
                .collect(),
            vulns: self
                .vulns
                .iter()
                .flat_map(|v| {
                    // One SbomVuln per crate@version carrying all its ids — the
                    // emitter fans each id into its own `vulnerabilities` entry,
                    // matching the prior per-id output.
                    Some(SbomVuln {
                        ecosystem: "cargo".into(),
                        name: v.crate_name.clone(),
                        version: v.version.clone(),
                        ids: v.ids.clone(),
                        summary: v.summary.clone(),
                    })
                })
                .collect(),
        };
        modgunn::sbom::to_cyclonedx(&input)
    }
}

/// The deep component list (the full transitive tree). No network. Returns the
/// repo display name + components — the SBOM half of a scan, split out so the
/// server can pair it with a warehouse `vuln_findings` cache.
///
/// Tries `cargo metadata` (rich: includes licenses); if that can't run — a
/// transient spawn failure, or a path-dep checkout whose siblings aren't cloned
/// beside it server-side — falls back to **`Cargo.lock`**, which pins every
/// resolved dep (name+version, enough for vuln matching; licenses unavailable).
pub fn components(repo: &Path) -> Result<(String, Vec<Component>)> {
    let repo_name = repo
        .file_name()
        .map(|s| s.to_string_lossy().into_owned())
        .unwrap_or_default();
    // Guard: a missing checkout dir otherwise surfaces as a cryptic "spawn cargo
    // generate-lockfile" (Command current_dir doesn't exist). Say it plainly —
    // usually a repo-name case mismatch (e.g. `Njord` vs the `njord` checkout).
    if !repo.is_dir() {
        anyhow::bail!(
            "no checkout for `{repo_name}` at {} — is the repo name/case right and the workspace synced?",
            repo.display()
        );
    }
    // Tier 1: cargo metadata (rich — includes licenses).
    let meta_err = match cargo_metadata_components(repo) {
        Ok(c) => return Ok((repo_name, c)),
        Err(e) => e,
    };
    eprintln!("nornir-security: cargo metadata unavailable ({meta_err:#}); using Cargo.lock");
    // Tier 2: a committed Cargo.lock.
    let lock = repo.join("Cargo.lock");
    if lock.is_file() {
        return Ok((repo_name, cargo_lock_components(repo)?));
    }
    // Tier 3: no Cargo.lock in the checkout — the norm for *library* crates,
    // which gitignore it (nornir itself does). Synthesize one with
    // `cargo generate-lockfile` (resolves versions, no build), then parse it.
    if cargo_available() {
        let r#gen = Command::new("cargo")
            .args(["generate-lockfile"])
            .current_dir(repo)
            .output()
            .context("spawn cargo generate-lockfile")?;
        if r#gen.status.success() && lock.is_file() {
            return Ok((repo_name, cargo_lock_components(repo)?));
        }
        // cargo is present but couldn't resolve (offline + cold registry, a
        // path-dep whose sibling isn't checked out, …): surface WHY, clearly.
        anyhow::bail!(
            "could not resolve dependencies for `{repo_name}`: cargo metadata failed ({meta_err}) \
             and `cargo generate-lockfile` could not produce a lockfile:\n{}",
            String::from_utf8_lossy(&r#gen.stderr)
        );
    }
    // Tier 4: cargo is not on PATH at all (e.g. the server service user has no
    // toolchain). Without cargo AND without a committed Cargo.lock there is no
    // SBOM source — say exactly that instead of a cryptic "read …/Cargo.lock".
    anyhow::bail!(
        "no SBOM source for `{repo_name}`: `cargo` is not on PATH (so cargo metadata / \
         generate-lockfile can't run) and no Cargo.lock is committed (library crates gitignore it). \
         Install a cargo toolchain for the scan host, or commit a Cargo.lock."
    )
}

/// Is `cargo` runnable on PATH? Distinguishes "toolchain missing" (tier 4) from
/// "cargo present but resolve failed" (tier 3) so the scan error is actionable.
fn cargo_available() -> bool {
    Command::new("cargo")
        .arg("--version")
        .output()
        .map(|o| o.status.success())
        .unwrap_or(false)
}

fn cargo_metadata_components(repo: &Path) -> Result<Vec<Component>> {
    let out = Command::new("cargo")
        .args(["metadata", "--format-version", "1"])
        .current_dir(repo)
        .output()
        .context("spawn cargo metadata")?;
    if !out.status.success() {
        anyhow::bail!(
            "cargo metadata failed:\n{}",
            String::from_utf8_lossy(&out.stderr)
        );
    }
    let md: Value = serde_json::from_slice(&out.stdout).context("parse cargo metadata")?;
    Ok(md["packages"]
        .as_array()
        .map(|a| {
            a.iter()
                .map(|p| Component {
                    name: p["name"].as_str().unwrap_or_default().to_string(),
                    version: p["version"].as_str().unwrap_or_default().to_string(),
                    license: p["license"].as_str().unwrap_or("NOASSERTION").to_string(),
                })
                .collect()
        })
        .unwrap_or_default())
}

/// Parse `Cargo.lock` `[[package]]` entries → (name, version). Works with no
/// network and no sibling path-dep checkouts.
fn cargo_lock_components(repo: &Path) -> Result<Vec<Component>> {
    let path = repo.join("Cargo.lock");
    let text =
        std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
    let doc: toml::Value = toml::from_str(&text).context("parse Cargo.lock")?;
    Ok(doc
        .get("package")
        .and_then(|p| p.as_array())
        .map(|pkgs| {
            pkgs.iter()
                .filter_map(|p| {
                    Some(Component {
                        name: p.get("name")?.as_str()?.to_string(),
                        version: p.get("version")?.as_str()?.to_string(),
                        license: "NOASSERTION".to_string(),
                    })
                })
                .collect()
        })
        .unwrap_or_default())
}

/// Components, **warehouse-first**: if a deep-scan / metadata sweep of this
/// repo's dependency closure has already been captured (see [`warm`]), build the
/// SBOM component list from that warehouse snapshot — zero `cargo metadata`, zero
/// network. Falls back to live [`components`] (cargo metadata → Cargo.lock) when
/// the warehouse has no capture yet.
///
/// `repo_name` is taken from the directory name so it matches what [`warm`]
/// filed the components under.
pub fn components_warehouse_first(
    wh: &crate::warehouse::iceberg::IcebergWarehouse,
    repo: &Path,
) -> Result<(String, Vec<Component>)> {
    let repo_name = repo
        .file_name()
        .map(|s| s.to_string_lossy().into_owned())
        .unwrap_or_default();
    match wh.query_sbom_components(&repo_name) {
        Ok(Some(rows)) if !rows.is_empty() => {
            let comps = rows
                .into_iter()
                .map(|r| Component {
                    name: r.name,
                    version: r.version,
                    license: r.license,
                })
                .collect();
            Ok((repo_name, comps))
        }
        // No capture (or an empty/failed read) — resolve live.
        _ => components(repo),
    }
}

/// Pre-warm the warehouse SBOM cache for `repo`: resolve the component closure
/// **once** (live `cargo metadata`, materializing path-dep siblings first so a
/// headless server-side resolve succeeds for repos like znippy/knut) and persist
/// it under the repo name. After this, [`components_warehouse_first`] serves the
/// SBOM with no cargo/network. Idempotent enough for republish — each call files
/// a fresh capture snapshot and the latest wins.
///
/// Returns the `(repo_name, components)` it captured. `scan_root`, when given, is
/// where sibling path-dep checkouts are looked for / linked (typically the `git/`
/// dir holding all monitored member checkouts).
pub fn warm(
    wh: &crate::warehouse::iceberg::IcebergWarehouse,
    repo: &Path,
    scan_root: Option<&Path>,
) -> Result<(String, Vec<Component>)> {
    if let Some(root) = scan_root {
        warm_prepare(repo, root);
    }
    let (repo_name, comps) = warm_resolve(repo)?;
    persist_sbom(wh, &repo_name, &comps)?;
    Ok((repo_name, comps))
}

/// The SERIAL, filesystem-mutating prelude of [`warm`]: clone any missing
/// path-dep siblings (feature `net-scan`) then link present ones under
/// `scan_root`, so a subsequent `cargo metadata` resolve can read them. Best-
/// effort — a miss just degrades to the Cargo.lock fallback inside [`components`];
/// prep never fails. Default nordisk URL resolution (override via
/// `NORNIR_DEP_CLONE_BASE`).
///
/// Kept SEPARATE from [`warm_resolve`] precisely so a multi-member warm can run
/// this serially (concurrent git clones / symlink creations into the shared
/// `scan_root` would otherwise race) and then fan the read-only resolve.
pub fn warm_prepare(repo: &Path, scan_root: &Path) {
    let (cloned, linked) = prepare_path_deps(repo, scan_root, &|_| None);
    if cloned + linked > 0 {
        eprintln!(
            "nornir-security: prepared path-deps for {} ({cloned} cloned, {linked} linked)",
            repo.display()
        );
    }
}

/// The parallel-safe, read-only HALF of [`warm`]: resolve `repo`'s SBOM component
/// closure (live `cargo metadata`). Touches NO warehouse and mutates no shared
/// filesystem, so many members resolve concurrently. Persist the result with
/// [`persist_sbom`] (the serial single-writer half). Run [`warm_prepare`] first
/// when path-dep siblings need materializing.
pub fn warm_resolve(repo: &Path) -> Result<(String, Vec<Component>)> {
    components(repo)
}

/// Persist a resolved SBOM closure under `repo_name` — the SERIAL single-writer
/// half of [`warm`] (the warehouse is single-writer; this does the redb/iceberg
/// append, no compute). Pair with [`warm_resolve`] when warming many members.
pub fn persist_sbom(
    wh: &crate::warehouse::iceberg::IcebergWarehouse,
    repo_name: &str,
    comps: &[Component],
) -> Result<()> {
    let rows: Vec<crate::warehouse::iceberg::SbomComponentRow> = comps
        .iter()
        .map(|c| crate::warehouse::iceberg::SbomComponentRow {
            name: c.name.clone(),
            version: c.version.clone(),
            license: c.license.clone(),
            derived_from: "source".into(),
        })
        .collect();
    wh.append_sbom_components(repo_name, uuid::Uuid::new_v4(), &rows)
        .with_context(|| format!("persist sbom components for {repo_name}"))?;
    Ok(())
}

/// Materialize sibling **path-dep** checkouts next to `repo` under `scan_root`,
/// so a headless server-side `cargo metadata` (which needs the path-dep sources
/// on disk to resolve + read their licenses) succeeds for repos that depend on
/// sibling crates by relative path (e.g. znippy → zoomies, knut → skade).
///
/// For every `dep = { path = "..." }` in the repo's `Cargo.toml`, resolve the
/// target's crate/repo directory name; if it isn't already reachable at the path
/// the manifest expects but *is* present under `scan_root`, drop a symlink so the
/// relative path resolves. Pure-filesystem, no cargo, no network. Best-effort and
/// idempotent: already-resolvable deps and already-present links are left alone.
/// Returns the number of links created.
pub fn materialize_path_dep_siblings(repo: &Path, scan_root: &Path) -> Result<usize> {
    let manifest = repo.join("Cargo.toml");
    let text = match std::fs::read_to_string(&manifest) {
        Ok(t) => t,
        Err(_) => return Ok(0), // no manifest → nothing to do
    };
    let doc: toml::Value = toml::from_str(&text).context("parse Cargo.toml")?;
    let mut linked = 0usize;
    for table_key in ["dependencies", "dev-dependencies", "build-dependencies"] {
        let Some(deps) = doc.get(table_key).and_then(|d| d.as_table()) else {
            continue;
        };
        for (dep_name, spec) in deps {
            let Some(path) = spec
                .as_table()
                .and_then(|t| t.get("path"))
                .and_then(|p| p.as_str())
            else {
                continue;
            };
            // The location the manifest's relative `path` points at.
            let target = repo.join(path);
            if target.join("Cargo.toml").exists() {
                continue; // already resolvable in place
            }
            // The directory name the dep expects (last segment of its path).
            let leaf = Path::new(path)
                .file_name()
                .map(|s| s.to_string_lossy().into_owned())
                .unwrap_or_else(|| dep_name.clone());
            // Look for a sibling checkout under scan_root by that leaf name, then
            // by the dep's own name (repo dir often == crate name).
            let candidate = [leaf.as_str(), dep_name.as_str()]
                .into_iter()
                .map(|n| scan_root.join(n))
                .find(|c| c.join("Cargo.toml").exists());
            let Some(src) = candidate else { continue };
            if let Some(parent) = target.parent() {
                let _ = std::fs::create_dir_all(parent);
            }
            if symlink_dir(&src, &target).is_ok() {
                linked += 1;
            }
        }
    }
    Ok(linked)
}

/// List the **path-dep siblings of `repo` that are still unresolved** — i.e. the
/// manifest declares `dep = { path = "..." }` but neither the path itself nor a
/// `scan_root` sibling carries a `Cargo.toml`, so a headless `cargo metadata`
/// would fail to load them. Call this *after* [`prepare_path_deps`] to turn the
/// opaque `MetadataCommand::exec` failure into a clear, named diagnostic (DR2).
///
/// Returns `(dep_name, declared_path)` pairs. Empty ⇒ every path-dep resolves
/// (or the repo has no manifest / no path-deps). Pure filesystem, never errors
/// on a missing/garbled manifest (those degrade to "nothing unresolved" so the
/// caller's real cargo-metadata error still surfaces).
pub fn unresolved_path_dep_siblings(repo: &Path, scan_root: &Path) -> Vec<(String, String)> {
    let Ok(text) = std::fs::read_to_string(repo.join("Cargo.toml")) else {
        return Vec::new();
    };
    let Ok(doc) = toml::from_str::<toml::Value>(&text) else {
        return Vec::new();
    };
    let mut unresolved = Vec::new();
    for table_key in ["dependencies", "dev-dependencies", "build-dependencies"] {
        let Some(deps) = doc.get(table_key).and_then(|d| d.as_table()) else {
            continue;
        };
        for (dep_name, spec) in deps {
            let Some(path) = spec
                .as_table()
                .and_then(|t| t.get("path"))
                .and_then(|p| p.as_str())
            else {
                continue;
            };
            // Resolvable in place (path or, post-prep, via the symlink we dropped)?
            if repo.join(path).join("Cargo.toml").exists() {
                continue;
            }
            // Still resolvable from a scan_root sibling we could have linked?
            let leaf = Path::new(path)
                .file_name()
                .map(|s| s.to_string_lossy().into_owned())
                .unwrap_or_else(|| dep_name.clone());
            let present = [leaf.as_str(), dep_name.as_str()]
                .into_iter()
                .any(|n| scan_root.join(n).join("Cargo.toml").exists());
            if !present {
                unresolved.push((dep_name.clone(), path.to_string()));
            }
        }
    }
    unresolved
}

/// Default base URL for cloning a missing path-dep sibling whose git URL the
/// caller didn't supply. Override with `NORNIR_DEP_CLONE_BASE`.
pub fn dep_clone_base() -> String {
    // SSH-first (deploy key, no credential prompt) — our sibling repos are
    // private. scp-like base so `<base>/<repo>` is a valid SSH remote that
    // `gitio::clone_or_fetch` routes straight through the key. Override with
    // NORNIR_DEP_CLONE_BASE (an https base still works — it gets SSH-first'd).
    std::env::var("NORNIR_DEP_CLONE_BASE")
        .unwrap_or_else(|_| "ssh://git@git.gunnar.rs:2222/nordisk".to_string())
}

/// The sibling **repo dir name** a relative path-dep points at: the first
/// `Normal` component of the path (`../skade/skade` → `skade`, `../zoomies` →
/// `zoomies`). `None` for a path with no normal component.
fn sibling_repo_name(path: &str) -> Option<String> {
    Path::new(path).components().find_map(|c| match c {
        std::path::Component::Normal(s) => Some(s.to_string_lossy().into_owned()),
        _ => None,
    })
}

/// Clone **missing** sibling repos that `repo`'s path-deps point at, so a
/// headless `cargo metadata` can resolve them. The complement of
/// [`materialize_path_dep_siblings`] (which only links siblings already on
/// disk): this *fetches* the ones that aren't checked out at all — the exact
/// gap that made "press Security" fail on `nornir` (its `skade`/`skade-katalog`
/// path-deps were never cloned beside it in the server's single-repo checkout).
///
/// `url_for(repo_name)` supplies the git URL for a sibling repo dir name (e.g.
/// the workspace descriptor's `git`); when it returns `None` we fall back to
/// `<NORNIR_DEP_CLONE_BASE>/<repo_name>` (default `ssh://git@git.gunnar.rs:2222/nordisk`).
///
/// **Network**: gated behind the `net-scan` feature. Without it this is a no-op
/// (returns `Ok(0)`) so an airgapped build never reaches out. Best-effort: a
/// clone failure is logged and skipped, never fatal — the scan then degrades to
/// the Cargo.lock fallback in [`components`]. Returns the number of repos cloned.
#[cfg(feature = "net-scan")]
pub fn clone_missing_path_dep_siblings(
    repo: &Path,
    scan_root: &Path,
    url_for: &dyn Fn(&str) -> Option<String>,
) -> Result<usize> {
    let manifest = repo.join("Cargo.toml");
    let Ok(text) = std::fs::read_to_string(&manifest) else {
        return Ok(0);
    };
    let doc: toml::Value = toml::from_str(&text).context("parse Cargo.toml")?;
    let mut cloned = 0usize;
    let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
    for table_key in ["dependencies", "dev-dependencies", "build-dependencies"] {
        let Some(deps) = doc.get(table_key).and_then(|d| d.as_table()) else {
            continue;
        };
        for (_dep_name, spec) in deps {
            let Some(path) = spec
                .as_table()
                .and_then(|t| t.get("path"))
                .and_then(|p| p.as_str())
            else {
                continue;
            };
            // Already resolvable in place → nothing to fetch.
            if repo.join(path).join("Cargo.toml").exists() {
                continue;
            }
            let Some(repo_name) = sibling_repo_name(path) else {
                continue;
            };
            if !seen.insert(repo_name.clone()) {
                continue; // one clone covers every path-dep into the same sibling repo
            }
            let dest = scan_root.join(&repo_name);
            // Present already (full repo, or a virtual-manifest workspace root)?
            // Leave it for `materialize_path_dep_siblings` to link if needed.
            if dest.join("Cargo.toml").exists() {
                continue;
            }
            let url = url_for(&repo_name)
                .unwrap_or_else(|| format!("{}/{}", dep_clone_base(), repo_name));
            match crate::procreap::clone_or_fetch(
                &url,
                &dest,
                crate::gitio::nornir_ssh_key_path().as_deref(),
            ) {
                Ok(sha) => {
                    eprintln!(
                        "nornir-security: cloned path-dep sibling `{repo_name}` from {url} @ {sha}"
                    );
                    cloned += 1;
                }
                Err(e) => eprintln!(
                    "nornir-security: clone path-dep sibling `{repo_name}` from {url} failed \
                     (scan will degrade to Cargo.lock): {e:#}"
                ),
            }
        }
    }
    Ok(cloned)
}

/// Airgapped build (`net-scan` off): never fetch. The scan resolves only what's
/// already on disk, then falls back to the committed Cargo.lock.
#[cfg(not(feature = "net-scan"))]
pub fn clone_missing_path_dep_siblings(
    _repo: &Path,
    _scan_root: &Path,
    _url_for: &dyn Fn(&str) -> Option<String>,
) -> Result<usize> {
    Ok(0)
}

/// The docs `depgraph` section's injected network half: exactly the closure
/// `docs::sections::render_depgraph` used to hard-code before the assembly half
/// moved to the `nornir-docgen` leaf — clone missing sibling path-dep repos
/// (default URL base, no workspace override) and never fail the render over it.
/// Every nornir `docs::Ctx` construction passes this through
/// [`Ctx::with_path_dep_cloner`](nornir_docgen::sections::Ctx::with_path_dep_cloner)
/// so CLI/server/gate behaviour is identical to the pre-extraction code; the
/// leaf itself defaults to no cloning (pure symlink materialization).
pub fn docs_path_dep_cloner(repo: &Path, scan_root: &Path) -> usize {
    clone_missing_path_dep_siblings(repo, scan_root, &|_| None).unwrap_or(0)
}

/// Make `repo`'s sibling path-deps resolvable for a headless `cargo metadata`:
/// **clone** the missing ones (feature `net-scan`), then **symlink** any
/// present-but-misplaced ones. Best-effort and idempotent; errors are logged,
/// not propagated (a failed prep just degrades the scan to the Cargo.lock
/// fallback). Returns `(cloned, linked)`. This is the single entry point both
/// [`warm`] and the server's `Mimir.SecurityScan` handler call before
/// [`components`].
pub fn prepare_path_deps(
    repo: &Path,
    scan_root: &Path,
    url_for: &dyn Fn(&str) -> Option<String>,
) -> (usize, usize) {
    let cloned = clone_missing_path_dep_siblings(repo, scan_root, url_for).unwrap_or_else(|e| {
        eprintln!(
            "nornir-security: clone path-dep siblings for {} skipped: {e:#}",
            repo.display()
        );
        0
    });
    let linked = materialize_path_dep_siblings(repo, scan_root).unwrap_or_else(|e| {
        eprintln!(
            "nornir-security: link path-dep siblings for {} skipped: {e:#}",
            repo.display()
        );
        0
    });
    (cloned, linked)
}

/// Create a directory symlink `link → src`. No-op if `link` already exists.
#[cfg(unix)]
fn symlink_dir(src: &Path, link: &Path) -> std::io::Result<()> {
    if link.exists() {
        return Ok(());
    }
    std::os::unix::fs::symlink(src, link)
}

#[cfg(not(unix))]
fn symlink_dir(src: &Path, link: &Path) -> std::io::Result<()> {
    if link.exists() {
        return Ok(());
    }
    std::os::windows::fs::symlink_dir(src, link)
}

/// Scan `repo`: deep dependency tree (`cargo metadata`) + OSV vulnerabilities.
/// The uncached path — for the warehouse-first path see the server's
/// `Mimir.SecurityScan` (cache lookup + `osv_query` of misses only).
pub fn scan(repo: &Path) -> Result<SecurityReport> {
    let (repo_name, components) = components(repo)?;
    let vulns = query_vulns(&components)?;
    Ok(SecurityReport {
        repo: repo_name,
        components,
        vulns,
        advisory_db_rev: advisory_db_rev(),
    })
}

/// The advisory source revision (bug #16): the rustsec advisory-db mirror's git
/// HEAD sha when seeded offline, else an OSV online marker. Used as the
/// verdict-cache key component so a new advisory invalidates a stale Pass.
pub fn advisory_db_rev() -> String {
    match advisory_db_path() {
        Some(db) => crate::gitio::head_sha(&db)
            .map(|sha| format!("rustsec:{sha}"))
            .unwrap_or_else(|_| "rustsec:unknown".to_string()),
        None => "osv:online".to_string(),
    }
}

/// Look up vulnerabilities for `components`, **airgap-first**: the local
/// rustsec/advisory-db mirror (offline) when seeded, else OSV.dev.
pub fn query_vulns(components: &[Component]) -> Result<Vec<Vuln>> {
    if components.is_empty() {
        return Ok(Vec::new());
    }
    match advisory_db_path() {
        Some(db) => advisory_db_query(components, &db),
        None => osv_query(components),
    }
}

/// The local rustsec/advisory-db mirror path (`NORNIR_ADVISORY_DB`, else
/// `/opt/nornir/advisory-db`) — `Some` only once seeded (has a `crates/` dir).
pub fn advisory_db_path() -> Option<PathBuf> {
    let p = std::env::var_os("NORNIR_ADVISORY_DB")
        .map(PathBuf::from)
        .unwrap_or_else(|| PathBuf::from("/opt/nornir/advisory-db"));
    p.join("crates").is_dir().then_some(p)
}

/// Clone or update the advisory-db mirror. Airgap: point `NORNIR_ADVISORY_DB_URL`
/// at holger (it serves the repo). Pure Rust (gix, no git2). Returns the path.
pub fn update_advisory_db() -> Result<PathBuf> {
    let dest = std::env::var_os("NORNIR_ADVISORY_DB")
        .map(PathBuf::from)
        .unwrap_or_else(|| PathBuf::from("/opt/nornir/advisory-db"));
    let url = std::env::var("NORNIR_ADVISORY_DB_URL")
        .unwrap_or_else(|_| "https://github.com/rustsec/advisory-db".to_string());
    crate::procreap::clone_or_fetch(&url, &dest, None)
        .with_context(|| format!("clone/fetch advisory-db from {url}"))?;
    Ok(dest)
}

/// Walk the seeded rustsec advisory-db mirror (`crates/<crate>/*.md`) into modgunn
/// advisories via [`modgunn::osv::parse_rustsec_md`] (the patched/unaffected → range
/// inversion). The **offline** half of the advisory corpus; empty when no mirror is
/// seeded ([`advisory_db_path`] is `None`). Fully offline, no network.
pub fn gather_rustsec_advisories() -> Vec<modgunn::scan::Advisory> {
    let Some(db) = advisory_db_path() else {
        return Vec::new(); // no seeded mirror ⇒ nothing to gather
    };
    let crates = db.join("crates");
    let mut advisories = Vec::new();
    for crate_dir in std::fs::read_dir(&crates).into_iter().flatten().flatten() {
        let cdir = crate_dir.path();
        if !cdir.is_dir() {
            continue;
        }
        for entry in std::fs::read_dir(&cdir).into_iter().flatten().flatten() {
            let p = entry.path();
            if p.extension().and_then(|e| e.to_str()) != Some("md") {
                continue;
            }
            let Ok(text) = std::fs::read_to_string(&p) else {
                continue;
            };
            if let Some(adv) = modgunn::osv::parse_rustsec_md(&text) {
                advisories.push(adv);
            }
        }
    }
    advisories
}

/// Fetch one full OSV record (`GET /v1/vulns/{id}`) as a JSON string. querybatch
/// returns only ids — the severity/EPSS/ranges/aliases the verdict needs live in the
/// full record, so the importer fetches each id here. `None` on any network/HTTP
/// error: one unreachable record never sinks the whole import (airgap-safe).
fn fetch_osv_record(id: &str) -> Option<String> {
    let url = format!("https://api.osv.dev/v1/vulns/{id}");
    match crate::http_agent().get(&url).call() {
        Ok(resp) => resp.into_body().read_to_string().ok(),
        Err(e) => {
            eprintln!("nornir-security: OSV.dev fetch failed for {id} ({e}); skipping");
            None
        }
    }
}

/// Live-OSV importer feed: full OSV advisories for `components`. querybatch
/// ([`osv_query`]) resolves *which* advisory ids affect the fleet; each id's full
/// record is then fetched ([`fetch_osv_record`]) and folded across feeds by alias
/// ([`modgunn::osv::parse_and_merge`]) into warehouse-ready advisories with **real**
/// severity/EPSS/ranges. The **online** half of the advisory corpus — network stays
/// behind the same graceful-degradation contract as [`osv_query`] (an unreachable
/// OSV.dev or empty input ⇒ an empty set, never an error), so an airgap import simply
/// yields the offline rustsec half. Both feeds land on modgunn's `"cargo"` ecosystem
/// key (OSV `crates.io` is normalized), so the verdict's `load_advisory_db(Some("cargo"))`
/// sees them.
pub fn gather_osv_advisories(components: &[Component]) -> Vec<modgunn::scan::Advisory> {
    if components.is_empty() {
        return Vec::new();
    }
    // Which advisory ids touch the fleet (querybatch → ids only).
    let ids: std::collections::BTreeSet<String> = match osv_query(components) {
        Ok(vulns) => vulns.into_iter().flat_map(|v| v.ids).collect(),
        Err(_) => return Vec::new(),
    };
    // Fetch each id's FULL record (severity/ranges live here, not in querybatch),
    // then normalize+dedup across feeds by alias.
    let docs: Vec<String> = ids.iter().filter_map(|id| fetch_osv_record(id)).collect();
    modgunn::osv::parse_and_merge(docs)
}

/// Import BOTH advisory feeds into modgunn's shared `cve_advisories` warehouse table
/// under a **single** `db_rev` — the offline rustsec mirror
/// ([`gather_rustsec_advisories`]) plus, when `components` are supplied and OSV.dev is
/// reachable, live OSV records for them ([`gather_osv_advisories`]). Folded across
/// feeds by alias. This is the single advisory MATCH source the warehouse-sourced
/// verdict reads back with **real** severity — the completion of the de-duplication:
/// OSV stops being a second live matcher and becomes an importer that feeds this table.
///
/// **One rev is required, not cosmetic:** [`modgunn::warehouse::Warehouse::load_advisory_db`]
/// returns only the rows of the greatest `db_rev`, so splitting the two feeds across
/// two revs would make one silently shadow the other. Returns the count imported
/// (`0` when neither feed yields anything).
///
/// Opens its own modgunn warehouse handle, so it must not run while another writer
/// (a live `nornir-server`) holds the same `catalog.redb` — the §8 single-writer
/// constraint; callers treat an open failure as non-fatal.
pub fn import_advisories_into_warehouse(root: &Path, components: &[Component]) -> Result<usize> {
    let mut advisories = gather_rustsec_advisories();
    advisories.extend(gather_osv_advisories(components));
    import_advisories(root, advisories)
}

/// The importer proper, with the two feeds already gathered — merge by alias, fold
/// under one `db_rev`, append.
///
/// Split out as a dependency-injected seam so the import can be tested against a
/// STATED corpus. Before this, the only entry point gathered the feeds itself, and
/// "an empty corpus imports nothing" could only be asserted by having no advisory
/// mirror on the machine: `advisory_db_path()` falls back to the absolute
/// `/opt/nornir/advisory-db`, so the test passed on a bare box and failed on any box
/// where the mirror is actually seeded (this one has 852 crates → 1096 advisories).
/// The test was reading the developer's filesystem, not the function.
pub fn import_advisories(root: &Path, advisories: Vec<modgunn::scan::Advisory>) -> Result<usize> {
    if advisories.is_empty() {
        return Ok(0); // neither feed yielded anything ⇒ nothing to import
    }
    // Fold a CVE arriving under several ids / from several feeds into one row.
    let advisories = modgunn::scan::merge_by_alias(advisories);
    let n = advisories.len();
    let rev = advisory_db_rev();
    let wh = modgunn::warehouse::Warehouse::open(root)
        .context("open modgunn warehouse for advisory import")?;
    wh.ensure_modgunn_tables()
        .context("ensure modgunn advisory tables")?;
    wh.append_advisories(&rev, &advisories)
        .context("append advisories to cve_advisories")?;
    Ok(n)
}

/// Rustsec-only import (back-compat wrapper): [`import_advisories_into_warehouse`]
/// with no components, so the live-OSV feed is skipped and only the offline mirror is
/// imported. Fully offline. Returns the count imported (`0` when no mirror is seeded).
pub fn import_advisory_db_into_warehouse(root: &Path) -> Result<usize> {
    import_advisories_into_warehouse(root, &[])
}

/// The fleet component closure the warehouse already knows — `(name, version)` for
/// every captured SBOM row — as the live-OSV importer's query set
/// ([`gather_osv_advisories`]). Best-effort: an empty, unreadable, or absent warehouse
/// yields an empty set (⇒ a rustsec-only import). The read handle is dropped before the
/// importer re-opens for the write (§8 single-writer).
pub fn fleet_components(root: &Path) -> Vec<Component> {
    match modgunn::warehouse::Warehouse::open(root).and_then(|wh| wh.read_all_sbom_components()) {
        Ok(rows) => rows
            .into_iter()
            .map(|(_repo, name, version)| Component {
                name,
                version,
                license: String::new(),
            })
            .collect(),
        Err(_) => Vec::new(),
    }
}

fn req_matches(reqs: &[String], v: &semver::Version) -> bool {
    // Also test the pre-release-stripped version: semver excludes pre-releases
    // (`>= 0.10` won't match `0.11.0-rc.4`), which would wrongly flag an rc as
    // unpatched. Treating `0.11.0-rc.4` as `0.11.0` for the patched/unaffected
    // check matches OSV's behaviour.
    let mut bare = v.clone();
    bare.pre = semver::Prerelease::EMPTY;
    reqs.iter().any(|r| {
        semver::VersionReq::parse(r)
            .map(|req| req.matches(v) || req.matches(&bare))
            .unwrap_or(false)
    })
}

/// Extract the ```toml fenced front-matter from a rustsec advisory `.md`.
fn extract_toml_front_matter(text: &str) -> Option<&str> {
    let after = &text[text.find("```toml")? + "```toml".len()..];
    let end = after.find("```")?;
    Some(after[..end].trim_matches('\n'))
}

fn string_list(v: Option<&toml::Value>) -> Vec<String> {
    v.and_then(|v| v.as_array())
        .map(|a| {
            a.iter()
                .filter_map(|x| x.as_str().map(String::from))
                .collect()
        })
        .unwrap_or_default()
}

/// Match `components` against a local advisory-db (`crates/<crate>/*.toml`) —
/// cargo-audit's logic: a version is affected when no `patched` requirement
/// matches it and it isn't listed `unaffected`. Fully offline.
pub fn advisory_db_query(components: &[Component], db: &Path) -> Result<Vec<Vuln>> {
    let crates = db.join("crates");
    let mut out = Vec::new();
    for c in components {
        let dir = crates.join(&c.name);
        if !dir.is_dir() {
            continue;
        }
        let Ok(version) = semver::Version::parse(&c.version) else {
            continue;
        };
        let mut ids = Vec::new();
        let mut summary = String::new();
        // Track whether any matched advisory is a REAL vuln (vs informational):
        // a crate that has even one real advisory must Block; a crate matched
        // ONLY by informational advisories (unmaintained/unsound/notice) Warns.
        let mut has_real = false;
        let mut info_kind: Option<String> = None;
        for entry in std::fs::read_dir(&dir).into_iter().flatten().flatten() {
            let p = entry.path();
            if p.extension().and_then(|e| e.to_str()) != Some("md") {
                continue;
            }
            let Ok(text) = std::fs::read_to_string(&p) else {
                continue;
            };
            // Each advisory is Markdown with a ```toml front-matter block.
            let Some(front) = extract_toml_front_matter(&text) else {
                continue;
            };
            let Ok(doc) = toml::from_str::<toml::Value>(front) else {
                continue;
            };
            let adv = doc.get("advisory");
            let versions = doc.get("versions");
            let patched = string_list(versions.and_then(|v| v.get("patched")));
            let unaffected = string_list(versions.and_then(|v| v.get("unaffected")));
            if !req_matches(&patched, &version) && !req_matches(&unaffected, &version) {
                if summary.is_empty() {
                    summary = adv
                        .and_then(|a| a.get("title"))
                        .and_then(|v| v.as_str())
                        .unwrap_or_default()
                        .to_string();
                }
                if let Some(id) = adv.and_then(|a| a.get("id")).and_then(|v| v.as_str()) {
                    ids.push(id.to_string());
                }
                match adv
                    .and_then(|a| a.get("informational"))
                    .and_then(|v| v.as_str())
                {
                    Some(kind) => info_kind.get_or_insert_with(|| kind.to_string()),
                    None => {
                        has_real = true;
                        continue;
                    }
                };
            }
        }
        if !ids.is_empty() {
            // Informational ONLY when no real advisory matched the crate.
            let informational = if has_real { None } else { info_kind };
            out.push(Vuln {
                crate_name: c.name.clone(),
                version: c.version.clone(),
                ids,
                summary,
                informational,
            });
        }
    }
    Ok(out)
}

/// OSV.dev batch query (one POST, no `ureq` json feature needed: serialise +
/// parse with `serde_json` ourselves). Empty input → no network.
pub fn osv_query(components: &[Component]) -> Result<Vec<Vuln>> {
    if components.is_empty() {
        return Ok(Vec::new());
    }
    // OSV.dev caps querybatch at 1000 queries per request — chunk and concatenate,
    // each chunk parsed against its own slice so the results[] index alignment is
    // preserved per chunk (bug #18).
    const MAX_QUERIES: usize = 1000;
    let mut vulns = Vec::new();
    for chunk in components.chunks(MAX_QUERIES) {
        let queries: Vec<Value> = chunk
            .iter()
            .map(|c| json!({ "package": { "ecosystem": "crates.io", "name": c.name }, "version": c.version }))
            .collect();
        let body = serde_json::to_string(&json!({ "queries": queries }))?;
        let resp = match crate::http_agent().post("https://api.osv.dev/v1/querybatch")
            .header("Content-Type", "application/json")
            .send(&body)
        {
            Ok(r) => r,
            // Airgap / offline: degrade gracefully rather than failing the scan.
            // Seed NORNIR_ADVISORY_DB (a local rustsec/advisory-db mirror) for offline
            // matching instead.
            Err(e) => {
                eprintln!(
                    "nornir-security: OSV.dev unreachable ({e}); skipping vuln lookup (seed NORNIR_ADVISORY_DB for offline)"
                );
                return Ok(Vec::new());
            }
        };
        let resp_str = resp.into_body().read_to_string().context("read OSV response")?;
        vulns.extend(parse_osv_batch(&resp_str, chunk).context("parse OSV response")?);
    }
    Ok(vulns)
}

/// Pure parse of an OSV `/v1/querybatch` response against the chunk it was issued
/// for (index-aligned: `results[i]` ↔ `chunk[i]`). querybatch returns only
/// `id` (+ `modified`) per vuln — NOT `summary` — so the online path leaves the
/// summary empty (enrich via `/v1/vulns/{id}` if a description is ever needed).
/// Extracted so the alignment + id extraction is unit-testable without network.
pub fn parse_osv_batch(resp_str: &str, chunk: &[Component]) -> Result<Vec<Vuln>> {
    let resp: Value = serde_json::from_str(resp_str)?;
    let mut vulns = Vec::new();
    if let Some(results) = resp["results"].as_array() {
        for (i, r) in results.iter().enumerate() {
            let Some(vs) = r["vulns"].as_array() else {
                continue;
            };
            if vs.is_empty() || i >= chunk.len() {
                continue;
            }
            let c = &chunk[i];
            vulns.push(Vuln {
                crate_name: c.name.clone(),
                version: c.version.clone(),
                ids: vs
                    .iter()
                    .filter_map(|v| v["id"].as_str().map(String::from))
                    .collect(),
                // querybatch does not return summary; the online path leaves it blank.
                summary: String::new(),
                // The online querybatch path carries no advisory kind.
                informational: None,
            });
        }
    }
    Ok(vulns)
}

/// `security scan` rendered as the uniform [`crate::cli_outcome::CommandOutcome`]
/// — the shared face for BOTH the fat (`nornir::security::scan`) and thin
/// (`Mimir.SecurityScan` RPC) CLI paths, so they emit the SAME shape (CLI⟺UI
/// parity with the viz Security tab, which reads the same `sbom_components` /
/// `vuln_findings`). The inputs are ALREADY computed by the chosen data path —
/// this only SHAPES + renders.
///
/// **`ok`-semantics for a scan/audit report:** `ok ⟺ the scan RAN and resolved a
/// real component closure`. *Finding vulnerabilities is still a true (sannr)
/// result* — a populated SBOM with vulns is a successful, meaningful scan, not a
/// RED state. Only a scan that resolved **zero components** (couldn't read the
/// dep tree — empty SBOM) is RED, exactly the RAGNARÖK "empty pane is red" rule.
/// The exit-code-as-vuln-count CI gate is orthogonal and stays in the handler.
pub fn scan_outcome(
    repo: &str,
    component_count: usize,
    vulns: &[Vuln],
    license_top: &[(String, usize)],
    cache: Option<(u64, u64)>,
) -> crate::cli_outcome::CommandOutcome {
    use crate::cli_outcome::CommandOutcome;
    // Empty component closure ⇒ the scan produced nothing readable ⇒ RED.
    if component_count == 0 {
        return CommandOutcome::fail(
            "security scan",
            format!(
                "{repo}: scan resolved 0 components — no SBOM source (cargo metadata / \
                 Cargo.lock both unavailable)"
            ),
        );
    }
    let vuln_total: usize = vulns.iter().map(|v| v.ids.len()).sum();
    let vulns_json: Vec<Value> = vulns
        .iter()
        .map(|v| {
            json!({
                "crate": v.crate_name,
                "version": v.version,
                "ids": v.ids,
                "summary": v.summary,
            })
        })
        .collect();
    let licenses_json: Vec<Value> = license_top
        .iter()
        .map(|(k, n)| json!({ "license": k, "count": n }))
        .collect();
    let mut data = json!({
        "repo": repo,
        "components": component_count,
        "vulnerabilities": vuln_total,
        "vulnerable_crates": vulns.len(),
        "vulns": vulns_json,
        "licenses": licenses_json,
    });
    if let Some((hits, misses)) = cache {
        data["cache_hits"] = json!(hits);
        data["cache_misses"] = json!(misses);
    }

    let mut human = format!("repo            {repo}\n");
    human.push_str(&format!(
        "components      {component_count} crates (deep, incl. transitive)\n"
    ));
    if let Some((hits, misses)) = cache {
        human.push_str(&format!("cache           {hits} hit / {misses} miss\n"));
    }
    human.push_str(&format!(
        "vulnerabilities {vuln_total} across {} crate(s)",
        vulns.len()
    ));
    for v in vulns {
        human.push_str(&format!(
            "\n   ⚠ {} {}: {}",
            v.crate_name,
            v.version,
            v.ids.join(", ")
        ));
    }
    let top: Vec<String> = license_top
        .iter()
        .take(6)
        .map(|(k, n)| format!("{k}×{n}"))
        .collect();
    if !top.is_empty() {
        human.push_str(&format!("\nlicenses        {}", top.join(", ")));
    }
    CommandOutcome::ok("security scan", data, human)
}

// ── modgunn verdict bridge (Engine A) ───────────────────────────────────────
//
// The security DOMAIN — folding CVE / license / provenance into one
// Pass/Warn/Block decision — is modgunn's (the leaf both nornir and holger share,
// per `.nornir/modgunn-security-scanner.md` + the master-plan §6 cutover). nornir
// keeps its BUILD-orchestration half (cargo-metadata / Cargo.lock component
// extraction, sibling-repo cloning, the network OSV / offline advisory-db query,
// the warehouse SBOM cache) — none of which modgunn owns — and feeds the resolved
// data INTO modgunn to obtain a `core::Verdict`. This bridge is the seam: it maps
// a nornir [`SecurityReport`] / [`Component`] / [`Vuln`] into modgunn's
// `scan::Artifact` + `scan::AdvisoryDb`, runs `scan::OsvScanner`, and returns the
// same `core::Verdict` shape holger's gate consumes — so the verdict logic lives
// in exactly one place.

/// Re-export of modgunn's shared verdict contract so consumers can name the types
/// through `nornir::security::{Verdict, Decision, …}` (a single import path; the
/// definitions live in modgunn, the leaf that breaks the nornir↔holger cycle).
pub use modgunn::core::{ArtifactRef, Decision, Finding, Severity, Verdict};
/// Re-export of modgunn's Engine-A scan surface (the verdict producer + policy).
pub use modgunn::scan::{
    Advisory, AdvisoryDb, Artifact, OsvScanner, Policy, Provenance, ScanEngine,
};

pub mod verdict {
    //! Map nornir's resolved [`Component`]s into modgunn's Engine-A inputs and
    //! produce `core::Verdict`s. The advisory set is sourced from modgunn's shared
    //! `cve_advisories` warehouse table (real per-advisory **severity / EPSS /
    //! KEV**), so the decision reflects the true severity — not the historical
    //! all-High fallback, and the match happens **once**, in modgunn (no more
    //! nornir-side OSV + rustsec double-match). nornir resolves the component
    //! closure; modgunn owns the match and the verdict, in one place for both nornir
    //! and holger (master-plan §6 cutover).

    use std::path::Path;

    use anyhow::{Context, Result};

    use super::Component;
    use modgunn::core::{ArtifactRef, Verdict};
    use modgunn::scan::{AdvisoryDb, Artifact, OsvScanner, Policy, Provenance, ScanEngine};

    /// The cargo purl used as the content key stand-in (nornir's SBOM path is
    /// `name@version`-keyed, not content-addressed). Mirrors `super::purl`.
    fn purl(name: &str, version: &str) -> String {
        format!("pkg:cargo/{name}@{version}")
    }

    /// A nornir [`Component`] → modgunn [`Artifact`] (ecosystem `cargo`). License
    /// `NOASSERTION`/empty ⇒ unknown (`None`); provenance unsigned (nornir's SBOM
    /// path carries no signature).
    pub fn artifact(c: &Component) -> Artifact {
        let license = match c.license.trim() {
            "" | "NOASSERTION" => None,
            lic => Some(lic.to_string()),
        };
        Artifact::new(
            ArtifactRef {
                ecosystem: "cargo".to_string(),
                name: c.name.clone(),
                version: c.version.clone(),
                blob_sha256: purl(&c.name, &c.version),
            },
            license,
            Provenance::unsigned(),
        )
    }

    /// One [`Verdict`] per component, scanned against `db` under `policy`. `db` is
    /// modgunn's real advisory set (warehouse-sourced, or seeded in tests) — its
    /// per-advisory severity / EPSS / KEV drives the decision, and its `rev` keys
    /// the verdict cache. This is the seam a caller holding a live warehouse handle
    /// uses: load `db` once, then scan, so the catalog is opened a single time
    /// (the §8 single-`catalog.redb`-open constraint).
    pub fn verdicts_with(db: AdvisoryDb, components: &[Component], policy: Policy) -> Vec<Verdict> {
        let scanner = OsvScanner::new(policy, db);
        components
            .iter()
            .map(|c| scanner.scan(&artifact(c)))
            .collect()
    }

    /// Per-component verdicts sourced from the shared `cve_advisories` table at the
    /// warehouse `root` nornir already uses. Loads the latest advisory revision
    /// (real severity) and scans each component. Uses modgunn's **lock-tolerant**
    /// `open_read_only`, so it works even inside the live `nornir-server` (which
    /// holds the `catalog.redb` write lock): a contended open transparently reads a
    /// copied-aside snapshot rather than failing (§8 single-writer).
    pub fn verdicts_from_warehouse(
        root: &Path,
        components: &[Component],
        policy: Policy,
    ) -> Result<Vec<Verdict>> {
        let wh = modgunn::warehouse::Warehouse::open_read_only(root)
            .context("open modgunn warehouse (read-only) for verdict")?;
        let db = wh
            .load_advisory_db(Some("cargo"))
            .context("load cve_advisories")?;
        Ok(verdicts_with(db, components, policy))
    }
}

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

    #[test]
    fn scan_outcome_with_components_is_sannr_even_with_vulns() {
        // A scan that resolved real components is a TRUE (sannr) result — finding
        // vulnerabilities does NOT make it RED (it's still a meaningful scan).
        let vulns = vec![Vuln {
            crate_name: "openssl".into(),
            version: "0.1.0".into(),
            ids: vec!["RUSTSEC-2024-0001".into(), "RUSTSEC-2024-0002".into()],
            summary: "use after free".into(),
            informational: None,
        }];
        let licenses = vec![("MIT".to_string(), 12usize), ("Apache-2.0".to_string(), 3)];
        let o = scan_outcome("nornir", 42, &vulns, &licenses, Some((40, 2)));
        assert!(
            o.is_sannr(),
            "a populated scan (even with vulns) is sannr/true"
        );
        assert_eq!(o.command, "security scan");
        assert_eq!(o.data["repo"], json!("nornir"));
        assert_eq!(o.data["components"], json!(42));
        assert_eq!(
            o.data["vulnerabilities"],
            json!(2),
            "2 advisory ids across 1 crate"
        );
        assert_eq!(o.data["vulnerable_crates"], json!(1));
        assert_eq!(o.data["cache_hits"], json!(40));
        assert_eq!(
            o.data["vulns"].as_array().unwrap()[0]["crate"],
            json!("openssl")
        );
        assert_eq!(
            o.data["licenses"].as_array().unwrap()[0]["license"],
            json!("MIT")
        );
    }

    #[test]
    fn scan_outcome_clean_scan_is_sannr() {
        // No vulnerabilities found but real components resolved ⇒ still TRUE: the
        // scan ran and the SBOM is real. (A clean repo is a success, not RED.)
        let o = scan_outcome("holger", 7, &[], &[("MIT".into(), 7)], None);
        assert!(
            o.is_sannr(),
            "a clean scan with real components is a true result"
        );
        assert_eq!(o.data["vulnerabilities"], json!(0));
        assert_eq!(o.data["vulns"].as_array().unwrap().len(), 0);
        assert!(
            o.data.get("cache_hits").is_none(),
            "no cache stats in fat mode"
        );
    }

    #[test]
    fn scan_outcome_empty_component_closure_is_red() {
        // RAGNARÖK: 0 components ⇒ the scan produced nothing ⇒ RED, regardless of
        // process exit. The reason names the missing SBOM source.
        let o = scan_outcome("ghost", 0, &[], &[], None);
        assert!(!o.is_sannr(), "an empty SBOM (0 components) is RED");
        assert_eq!(o.command, "security scan");
        assert_eq!(o.data, Value::Null);
        assert!(
            o.human.contains("0 components"),
            "human names the empty closure"
        );
    }

    #[test]
    fn extract_toml_front_matter_pulls_the_advisory_block() {
        let md = "```toml\n[advisory]\nid = \"RUSTSEC-2024-0001\"\n[versions]\npatched = [\">= 1.0.0\"]\n```\n\n# Title\n\nbody";
        let front = extract_toml_front_matter(md).expect("front matter");
        let doc: toml::Value = toml::from_str(front).unwrap();
        assert_eq!(doc["advisory"]["id"].as_str(), Some("RUSTSEC-2024-0001"));
        assert_eq!(
            string_list(doc.get("versions").and_then(|v| v.get("patched"))),
            vec![
                "\
>= 1.0.0"
                    .trim_start()
            ]
        );
    }

    #[test]
    fn req_matches_handles_prerelease_versions() {
        // `>= 0.10` must consider a later pre-release patched (semver excludes it).
        let rc = semver::Version::parse("0.11.0-rc.4").unwrap();
        assert!(
            req_matches(&[">= 0.10.0".into()], &rc),
            "0.11.0-rc.4 should satisfy >= 0.10.0"
        );
        // an earlier version is genuinely unpatched
        let old = semver::Version::parse("0.9.0").unwrap();
        assert!(
            !req_matches(&[">= 0.10.0".into()], &old),
            "0.9.0 must NOT satisfy >= 0.10.0"
        );
    }

    #[test]
    fn components_warehouse_first_uses_capture_then_falls_back() {
        use crate::warehouse::iceberg::{IcebergWarehouse, SbomComponentRow};
        let whdir = tempfile::tempdir().unwrap();
        let wh = IcebergWarehouse::open(whdir.path()).unwrap();

        // A repo dir named "myrepo" whose Cargo.lock provides the live fallback.
        let repodir = tempfile::tempdir().unwrap();
        let repo = repodir.path().join("myrepo");
        std::fs::create_dir_all(&repo).unwrap();
        std::fs::write(
            repo.join("Cargo.lock"),
            "[[package]]\nname = \"fallbackdep\"\nversion = \"9.9.9\"\n",
        )
        .unwrap();
        // No Cargo.toml manifest → cargo metadata fails → Cargo.lock fallback.

        // No warehouse capture yet → warehouse_first delegates to live components,
        // which (no manifest) lands on the Cargo.lock fallback.
        let (name, comps) = components_warehouse_first(&wh, &repo).unwrap();
        assert_eq!(name, "myrepo");
        assert!(
            comps
                .iter()
                .any(|c| c.name == "fallbackdep" && c.version == "9.9.9")
        );

        // After a warehouse capture, warehouse_first serves THAT (not the lock).
        wh.append_sbom_components(
            "myrepo",
            uuid::Uuid::new_v4(),
            &[SbomComponentRow {
                name: "cached".into(),
                version: "1.2.3".into(),
                license: "MIT".into(),
                derived_from: "source".into(),
            }],
        )
        .unwrap();
        let (name, comps) = components_warehouse_first(&wh, &repo).unwrap();
        assert_eq!(name, "myrepo");
        assert_eq!(comps.len(), 1);
        assert_eq!(comps[0].name, "cached");
        assert_eq!(comps[0].license, "MIT");
    }

    #[test]
    fn materialize_path_dep_siblings_links_missing_sibling() {
        // repo `znippy` depends on `../zoomies` by path, but that relative path
        // doesn't exist next to znippy in the headless scan layout — the sibling
        // checkout instead lives flat under scan_root. We should link it in.
        let tmp = tempfile::tempdir().unwrap();
        let root = tmp.path();
        // scan_root/znippy/Cargo.toml with a path dep `../zoomies`
        let znippy = root.join("znippy");
        std::fs::create_dir_all(&znippy).unwrap();
        std::fs::write(
            znippy.join("Cargo.toml"),
            "[package]\nname=\"znippy\"\nversion=\"0.1.0\"\n\n[dependencies]\nzoomies = { path = \"../zoomies\" }\n",
        )
        .unwrap();
        // scan_root/zoomies exists (a sibling), but ../zoomies relative to znippy
        // is scan_root/zoomies too here — so to actually exercise the link path,
        // point the dep one level deeper where it is NOT present.
        std::fs::write(
            znippy.join("Cargo.toml"),
            "[package]\nname=\"znippy\"\nversion=\"0.1.0\"\n\n[dependencies]\nzoomies = { path = \"vendor/zoomies\" }\n",
        )
        .unwrap();
        let zoomies = root.join("zoomies");
        std::fs::create_dir_all(&zoomies).unwrap();
        std::fs::write(
            zoomies.join("Cargo.toml"),
            "[package]\nname=\"zoomies\"\nversion=\"0.1.0\"\n",
        )
        .unwrap();

        // Before: vendor/zoomies/Cargo.toml is absent.
        assert!(!znippy.join("vendor/zoomies/Cargo.toml").exists());
        let n = materialize_path_dep_siblings(&znippy, root).unwrap();
        assert_eq!(n, 1, "one sibling link created");
        // After: the path dep resolves (through the symlink) to the sibling.
        assert!(znippy.join("vendor/zoomies/Cargo.toml").exists());

        // Idempotent: a second run links nothing new.
        let n2 = materialize_path_dep_siblings(&znippy, root).unwrap();
        assert_eq!(n2, 0, "already-linked sibling is not re-linked");
    }

    #[test]
    fn materialize_is_noop_when_path_dep_already_resolves() {
        let tmp = tempfile::tempdir().unwrap();
        let root = tmp.path();
        let a = root.join("a");
        std::fs::create_dir_all(&a).unwrap();
        let b = root.join("b");
        std::fs::create_dir_all(&b).unwrap();
        std::fs::write(
            b.join("Cargo.toml"),
            "[package]\nname=\"b\"\nversion=\"0.1.0\"\n",
        )
        .unwrap();
        std::fs::write(
            a.join("Cargo.toml"),
            "[package]\nname=\"a\"\nversion=\"0.1.0\"\n\n[dependencies]\nb = { path = \"../b\" }\n",
        )
        .unwrap();
        // `../b` already resolves → nothing to link.
        let n = materialize_path_dep_siblings(&a, root).unwrap();
        assert_eq!(n, 0);
    }

    /// The live "press Security on nornir" fix: a repo whose path-dep siblings
    /// are MISSING from the scan root must have them CLONED in, so `cargo
    /// metadata` can then resolve. Uses a local git repo as the "remote" (a
    /// `file://` URL via the `url_for` closure) — real clone, no network.
    #[cfg(feature = "net-scan")]
    #[test]
    fn clone_missing_sibling_makes_path_dep_resolve() {
        let tmp = tempfile::tempdir().unwrap();
        // The "remote": a git repo named `skade` that mirrors nornir's real
        // layout — a repo root crate (`../skade`) PLUS a `skade/` member crate
        // (`../skade/skade`), so both of nornir's path-deps are satisfiable.
        let remote = tmp.path().join("remote");
        let skade_src = remote.join("skade");
        std::fs::create_dir_all(skade_src.join("skade")).unwrap();
        std::fs::write(
            skade_src.join("Cargo.toml"),
            "[package]\nname=\"skade-katalog\"\nversion=\"0.1.0\"\nedition=\"2021\"\n",
        )
        .unwrap();
        std::fs::write(
            skade_src.join("skade").join("Cargo.toml"),
            "[package]\nname=\"skade\"\nversion=\"0.4.0\"\nedition=\"2021\"\n",
        )
        .unwrap();
        crate::gitio::init(&skade_src).unwrap();
        crate::gitio::commit_all(&skade_src, "seed skade").unwrap();

        // The scan root: only `nornir` is checked out, with the two path-deps
        // pointing at siblings that are NOT present.
        let scan_root = tmp.path().join("git");
        let nornir = scan_root.join("nornir");
        std::fs::create_dir_all(&nornir).unwrap();
        std::fs::write(
            nornir.join("Cargo.toml"),
            "[package]\nname=\"nornir\"\nversion=\"0.4.0\"\nedition=\"2021\"\n\n\
             [dependencies]\n\
             skade = { path = \"../skade/skade\" }\n\
             skade-katalog = { path = \"../skade\" }\n",
        )
        .unwrap();

        // BEFORE: neither path-dep resolves.
        assert!(!nornir.join("../skade/skade/Cargo.toml").exists());

        // `url_for` hands back the local `file://` remote for the `skade` repo.
        let remote_str = remote.to_string_lossy().into_owned();
        let url_for = move |name: &str| Some(format!("file://{remote_str}/{name}"));
        let cloned = clone_missing_path_dep_siblings(&nornir, &scan_root, &url_for).unwrap();
        assert_eq!(cloned, 1, "the single missing `skade` repo is cloned once");

        // AFTER: BOTH path-deps now resolve to real manifests.
        assert!(
            nornir.join("../skade/skade").join("Cargo.toml").exists(),
            "`../skade/skade` (the skade crate) resolves after clone"
        );
        assert!(
            nornir.join("../skade").join("Cargo.toml").exists(),
            "`../skade` (the skade-katalog root crate) resolves after clone"
        );

        // Idempotent: a second prep clones nothing (sibling already present).
        let again = clone_missing_path_dep_siblings(&nornir, &scan_root, &url_for).unwrap();
        assert_eq!(again, 0, "already-present sibling is not re-cloned");
    }

    #[test]
    fn verdict_bridge_blocks_vulnerable_component_and_passes_clean() {
        use modgunn::core::Severity;
        use modgunn::scan::{Advisory, AdvisoryDb};
        // Warehouse-sourced advisory db (real severity): one High advisory for
        // openssl@0.1.0. The vulnerable component Blocks; the clean one Passes.
        let components = vec![
            Component {
                name: "openssl".into(),
                version: "0.1.0".into(),
                license: "MIT".into(),
            },
            Component {
                name: "serde".into(),
                version: "1.0.0".into(),
                license: "MIT".into(),
            },
        ];
        let db = AdvisoryDb::new(
            "db-rev-1",
            vec![Advisory::basic(
                "RUSTSEC-2024-0001",
                "cargo",
                "openssl",
                vec!["0.1.0".into()],
                Severity::High,
                "use-after-free",
            )],
        );
        let vs = verdict::verdicts_with(db, &components, Policy::default());
        assert_eq!(vs.len(), 2);
        let openssl = vs.iter().find(|v| v.artifact.name == "openssl").unwrap();
        assert_eq!(openssl.decision, Decision::Block, "a High vuln blocks");
        assert_eq!(openssl.findings[0].id, "RUSTSEC-2024-0001");
        assert_eq!(
            openssl.advisory_db_rev, "db-rev-1",
            "the advisory SOURCE rev (the warehouse db rev) keys the cache"
        );
        let serde = vs.iter().find(|v| v.artifact.name == "serde").unwrap();
        assert_eq!(serde.decision, Decision::Pass, "a clean component passes");
        assert!(serde.findings.is_empty());
    }

    /// Bug #18: parse_osv_batch aligns results[] to the chunk by index and
    /// extracts ids (querybatch carries no summary → blank).
    #[test]
    fn parse_osv_batch_aligns_results_to_chunk() {
        let chunk = vec![
            Component {
                name: "a".into(),
                version: "1.0.0".into(),
                license: "MIT".into(),
            },
            Component {
                name: "b".into(),
                version: "2.0.0".into(),
                license: "MIT".into(),
            },
            Component {
                name: "c".into(),
                version: "3.0.0".into(),
                license: "MIT".into(),
            },
        ];
        // b (index 1) has two advisories; a and c have none.
        let resp = r#"{"results":[
            {},
            {"vulns":[{"id":"RUSTSEC-2024-0001","modified":"x"},{"id":"GHSA-xxxx","modified":"y"}]},
            {"vulns":[]}
        ]}"#;
        let vulns = parse_osv_batch(resp, &chunk).unwrap();
        assert_eq!(vulns.len(), 1, "only b has vulns");
        assert_eq!(vulns[0].crate_name, "b");
        assert_eq!(vulns[0].version, "2.0.0");
        assert_eq!(vulns[0].ids, vec!["RUSTSEC-2024-0001", "GHSA-xxxx"]);
        assert!(vulns[0].summary.is_empty(), "querybatch carries no summary");
        assert!(vulns[0].informational.is_none());
    }

    /// The real-severity fix (was the all-High bug): a **Low** advisory (e.g. an
    /// informational unmaintained/unsound/notice, which the importer maps to Low)
    /// Warns, while a **High** advisory Blocks — the decision follows the warehouse
    /// severity, not a blanket High.
    #[test]
    fn verdict_warns_on_low_severity_blocks_on_high() {
        use modgunn::core::Severity;
        use modgunn::scan::{Advisory, AdvisoryDb};
        let components = vec![
            Component {
                name: "stale".into(),
                version: "1.0.0".into(),
                license: "MIT".into(),
            },
            Component {
                name: "boom".into(),
                version: "1.0.0".into(),
                license: "MIT".into(),
            },
        ];
        let db = AdvisoryDb::new(
            "rustsec:abc",
            vec![
                Advisory::basic(
                    "RUSTSEC-2021-0139",
                    "cargo",
                    "stale",
                    vec!["1.0.0".into()],
                    Severity::Low,
                    "unmaintained",
                ),
                Advisory::basic(
                    "RUSTSEC-2024-9999",
                    "cargo",
                    "boom",
                    vec!["1.0.0".into()],
                    Severity::High,
                    "RCE",
                ),
            ],
        );
        let vs = verdict::verdicts_with(db, &components, Policy::default());
        let stale = vs.iter().find(|v| v.artifact.name == "stale").unwrap();
        assert_eq!(
            stale.decision,
            Decision::Warn,
            "a Low advisory warns, not blocks"
        );
        let boom = vs.iter().find(|v| v.artifact.name == "boom").unwrap();
        assert_eq!(boom.decision, Decision::Block, "a High advisory blocks");
    }

    /// End-to-end: append advisories to a real modgunn warehouse (the importer's
    /// write shape) then source the verdict back from it — the Low advisory Warns
    /// (real severity survives the round-trip), the High Blocks, the clean Passes.
    #[test]
    fn warehouse_sourced_verdict_uses_real_severity() {
        use modgunn::core::Severity;
        use modgunn::scan::Advisory;
        let tmp = tempfile::tempdir().unwrap();
        {
            let wh = modgunn::warehouse::Warehouse::open(tmp.path()).unwrap();
            wh.ensure_modgunn_tables().unwrap();
            wh.append_advisories(
                "rev-1",
                &[
                    Advisory::basic(
                        "RUSTSEC-X",
                        "cargo",
                        "lowcrate",
                        vec!["1.0.0".into()],
                        Severity::Low,
                        "note",
                    ),
                    Advisory::basic(
                        "RUSTSEC-Y",
                        "cargo",
                        "highcrate",
                        vec!["1.0.0".into()],
                        Severity::High,
                        "rce",
                    ),
                ],
            )
            .unwrap();
        } // drop the write handle before re-opening below (single catalog.redb open)
        let components = vec![
            Component {
                name: "lowcrate".into(),
                version: "1.0.0".into(),
                license: "MIT".into(),
            },
            Component {
                name: "highcrate".into(),
                version: "1.0.0".into(),
                license: "MIT".into(),
            },
            Component {
                name: "cleancrate".into(),
                version: "2.0.0".into(),
                license: "MIT".into(),
            },
        ];
        let vs =
            verdict::verdicts_from_warehouse(tmp.path(), &components, Policy::default()).unwrap();
        let dec = |n: &str| vs.iter().find(|v| v.artifact.name == n).unwrap().decision;
        assert_eq!(
            dec("lowcrate"),
            Decision::Warn,
            "Low ⇒ Warn (real severity from warehouse)"
        );
        assert_eq!(dec("highcrate"), Decision::Block, "High ⇒ Block");
        assert_eq!(dec("cleancrate"), Decision::Pass, "no advisory ⇒ Pass");
    }

    /// The live-OSV importer feed is airgap-safe by construction: an empty component
    /// set short-circuits BEFORE any network call, so it can never fail or hang an
    /// import that has nothing to query. (A populated set that can't reach OSV.dev
    /// degrades the same way — [`osv_query`] returns empty on a network error.)
    #[test]
    fn gather_osv_advisories_empty_input_is_offline_safe() {
        assert!(gather_osv_advisories(&[]).is_empty());
    }

    /// With no seeded rustsec mirror (the test env) and no fleet components, the
    /// combined importer touches neither feed and reports nothing imported — the
    /// offline, empty-fleet bootstrap case — never erroring.
    #[test]
    fn import_advisories_empty_corpus_imports_nothing() {
        // The premise is STATED (an empty corpus), not inherited from the machine.
        // This used to call the gathering entry point and assert 0, which only held
        // where no advisory mirror existed — `advisory_db_path()` defaults to the
        // absolute `/opt/nornir/advisory-db`, so on a box with the mirror seeded the
        // test read 1096 real advisories and failed. It was asserting a property of
        // the filesystem, not of the importer.
        let tmp = tempfile::tempdir().unwrap();
        let n = import_advisories(tmp.path(), Vec::new()).unwrap();
        assert_eq!(n, 0, "an empty corpus imports nothing");
        // …and nothing was created: an empty import must not even open a warehouse.
        assert!(
            std::fs::read_dir(tmp.path()).unwrap().next().is_none(),
            "an empty import must leave the root untouched"
        );
    }

    /// The counterpart, so the empty case above cannot pass vacuously: a NON-empty
    /// corpus really does import, through the same seam.
    #[test]
    fn import_advisories_with_a_corpus_imports_it() {
        use modgunn::core::Severity;
        use modgunn::scan::Advisory;
        let tmp = tempfile::tempdir().unwrap();
        let corpus = vec![
            Advisory::basic(
                "RUSTSEC-A",
                "cargo",
                "acrate",
                vec!["1.0.0".into()],
                Severity::High,
                "a",
            ),
            Advisory::basic(
                "CVE-B",
                "cargo",
                "bcrate",
                vec!["2.0.0".into()],
                Severity::Low,
                "b",
            ),
        ];
        let n = import_advisories(tmp.path(), corpus).unwrap();
        assert_eq!(n, 2, "both advisories import");
    }

    /// Why the importer folds BOTH feeds under ONE `db_rev`: the warehouse's
    /// `load_advisory_db` returns only the rows of the greatest rev, so two feeds under
    /// two revs make one silently shadow the other — but under a single rev both
    /// survive. Locks the load-time contract the combined importer relies on (if it
    /// ever regressed, OSV or rustsec would vanish from every verdict).
    #[test]
    fn both_feeds_survive_only_under_one_rev() {
        use modgunn::core::Severity;
        use modgunn::scan::Advisory;
        let rustsec = Advisory::basic(
            "RUSTSEC-1",
            "cargo",
            "acrate",
            vec!["1.0.0".into()],
            Severity::High,
            "a",
        );
        let osv = Advisory::basic(
            "CVE-2",
            "cargo",
            "bcrate",
            vec!["1.0.0".into()],
            Severity::Low,
            "b",
        );

        // Two revs ⇒ only the greatest rev's feed loads (the shadowing this avoids:
        // "rustsec:2" > "osv:1" lexically, so the OSV feed is dropped).
        let split = tempfile::tempdir().unwrap();
        {
            let wh = modgunn::warehouse::Warehouse::open(split.path()).unwrap();
            wh.ensure_modgunn_tables().unwrap();
            wh.append_advisories("osv:1", std::slice::from_ref(&osv))
                .unwrap();
            wh.append_advisories("rustsec:2", std::slice::from_ref(&rustsec))
                .unwrap();
        }
        {
            let wh = modgunn::warehouse::Warehouse::open(split.path()).unwrap();
            let pkgs: Vec<String> = wh
                .load_advisory_db(Some("cargo"))
                .unwrap()
                .advisories
                .iter()
                .map(|a| a.package.clone())
                .collect();
            assert!(
                pkgs.iter().any(|p| p == "acrate") && !pkgs.iter().any(|p| p == "bcrate"),
                "two revs ⇒ only the greatest rev survives (OSV shadowed): {pkgs:?}"
            );
        }

        // One rev ⇒ BOTH feeds load — exactly what import_advisories_into_warehouse does.
        let joined = tempfile::tempdir().unwrap();
        {
            let wh = modgunn::warehouse::Warehouse::open(joined.path()).unwrap();
            wh.ensure_modgunn_tables().unwrap();
            wh.append_advisories("rustsec:2", &[rustsec, osv]).unwrap();
        }
        {
            let wh = modgunn::warehouse::Warehouse::open(joined.path()).unwrap();
            let pkgs: Vec<String> = wh
                .load_advisory_db(Some("cargo"))
                .unwrap()
                .advisories
                .iter()
                .map(|a| a.package.clone())
                .collect();
            assert!(
                pkgs.iter().any(|p| p == "acrate") && pkgs.iter().any(|p| p == "bcrate"),
                "one rev ⇒ both feeds coexist: {pkgs:?}"
            );
        }
    }

    /// Test #13: verdicts() under a NON-default Policy (license deny + provenance
    /// Require). A GPL component Blocks via MODGUNN-LICENSE-DENIED; an MIT
    /// component ALSO Blocks via MODGUNN-PROVENANCE-MISSING (artifact() hardcodes
    /// unsigned provenance) — and Passes once provenance is Ignored, isolating the
    /// cause.
    #[test]
    fn verdicts_under_license_deny_and_provenance_require() {
        use modgunn::scan::{AdvisoryDb, LicensePolicy, ProvenancePolicy};
        let components = vec![
            Component {
                name: "gpl".into(),
                version: "1.0.0".into(),
                license: "GPL-3.0".into(),
            },
            Component {
                name: "mit".into(),
                version: "1.0.0".into(),
                license: "MIT".into(),
            },
        ];
        // No vulns — license + provenance policy alone drive the decision.
        let db = || AdvisoryDb::new("rustsec:abc", vec![]);
        let policy = |prov| Policy {
            license: LicensePolicy {
                allow: vec!["MIT".into()],
                deny: vec!["GPL-3.0".into()],
                on_unknown: Decision::Warn,
            },
            provenance: prov,
            ..Policy::default()
        };

        let vs = verdict::verdicts_with(db(), &components, policy(ProvenancePolicy::Require));
        let gpl = vs.iter().find(|v| v.artifact.name == "gpl").unwrap();
        assert_eq!(gpl.decision, Decision::Block);
        assert!(
            gpl.findings
                .iter()
                .any(|f| f.id.contains("MODGUNN-LICENSE-DENIED")),
            "GPL must carry a license-denied finding: {:?}",
            gpl.findings
        );
        let mit = vs.iter().find(|v| v.artifact.name == "mit").unwrap();
        assert_eq!(
            mit.decision,
            Decision::Block,
            "unsigned provenance Required ⇒ Block"
        );
        assert!(
            mit.findings
                .iter()
                .any(|f| f.id == "MODGUNN-PROVENANCE-MISSING")
        );

        // Isolate the cause: with provenance Ignored, the allowed MIT crate Passes.
        let vs2 = verdict::verdicts_with(db(), &components, policy(ProvenancePolicy::Ignore));
        let mit2 = vs2.iter().find(|v| v.artifact.name == "mit").unwrap();
        assert_eq!(
            mit2.decision,
            Decision::Pass,
            "MIT allowed + provenance ignored ⇒ Pass"
        );
    }

    /// Test #15: a Vuln carrying TWO ids yields one verdict with two findings;
    /// empty components → no verdicts; a Vuln for a crate absent from components
    /// contributes nothing.
    #[test]
    fn verdicts_multi_id_and_empty_cases() {
        use modgunn::core::Severity;
        use modgunn::scan::{Advisory, AdvisoryDb};
        // Two advisories matching the same crate@version ⇒ one verdict, two findings.
        let db = || {
            AdvisoryDb::new(
                "rustsec:abc",
                vec![
                    Advisory::basic(
                        "RUSTSEC-2024-0001",
                        "cargo",
                        "x",
                        vec!["1.0.0".into()],
                        Severity::High,
                        "a",
                    ),
                    Advisory::basic(
                        "GHSA-yyyy",
                        "cargo",
                        "x",
                        vec!["1.0.0".into()],
                        Severity::High,
                        "b",
                    ),
                ],
            )
        };
        let x = vec![Component {
            name: "x".into(),
            version: "1.0.0".into(),
            license: "MIT".into(),
        }];
        let vs = verdict::verdicts_with(db(), &x, Policy::default());
        assert_eq!(vs.len(), 1);
        assert_eq!(
            vs[0].findings.len(),
            2,
            "both matching advisories fan out into findings"
        );
        assert_eq!(vs[0].decision, Decision::Block);

        // Empty components → no verdicts.
        assert!(verdict::verdicts_with(db(), &[], Policy::default()).is_empty());

        // An advisory for a crate NOT in components contributes no finding.
        let ghost_db = AdvisoryDb::new(
            "rustsec:abc",
            vec![Advisory::basic(
                "RUSTSEC-2024-0002",
                "cargo",
                "ghost",
                vec!["9.9.9".into()],
                Severity::High,
                "nope",
            )],
        );
        let y = vec![Component {
            name: "y".into(),
            version: "1.0.0".into(),
            license: "MIT".into(),
        }];
        let vs3 = verdict::verdicts_with(ghost_db, &y, Policy::default());
        assert_eq!(vs3.len(), 1, "one verdict for the one component y");
        assert!(
            vs3[0].findings.is_empty(),
            "ghost's advisory does not attach to y"
        );
        assert_eq!(vs3[0].decision, Decision::Pass);
    }

    #[test]
    fn verdict_bridge_maps_noassertion_license_to_unknown() {
        let c = Component {
            name: "x".into(),
            version: "1.0.0".into(),
            license: "NOASSERTION".into(),
        };
        let art = verdict::artifact(&c);
        assert_eq!(art.license, None, "NOASSERTION ⇒ unknown license");
        assert!(!art.provenance.signed, "nornir SBOM path is unsigned");
        assert_eq!(art.artifact.ecosystem, "cargo");
        assert_eq!(art.artifact.blob_sha256, "pkg:cargo/x@1.0.0");
    }

    /// N4: the cargo CycloneDX output stays byte-stable after `to_cyclonedx`
    /// delegates to `modgunn::sbom` (same shape, `pkg:cargo/…` purls).
    #[test]
    fn to_cyclonedx_cargo_output_is_stable() {
        let report = SecurityReport {
            repo: "myrepo".into(),
            advisory_db_rev: "rev".into(),
            components: vec![Component {
                name: "serde".into(),
                version: "1.0.0".into(),
                license: "MIT OR Apache-2.0".into(),
            }],
            vulns: vec![Vuln {
                crate_name: "serde".into(),
                version: "1.0.0".into(),
                ids: vec!["RUSTSEC-2024-0001".into()],
                summary: "x".into(),
                informational: None,
            }],
        };
        let want = json!({
            "bomFormat": "CycloneDX",
            "specVersion": "1.5",
            "version": 1,
            "metadata": { "component": { "type": "application", "name": "myrepo" } },
            "components": [{
                "type": "library", "name": "serde", "version": "1.0.0",
                "purl": "pkg:cargo/serde@1.0.0",
                "licenses": [{ "license": { "name": "MIT OR Apache-2.0" } }],
            }],
            "vulnerabilities": [{
                "id": "RUSTSEC-2024-0001",
                "source": { "name": "OSV", "url": "https://osv.dev/vulnerability/RUSTSEC-2024-0001" },
                "affects": [{ "ref": "pkg:cargo/serde@1.0.0" }],
                "description": "x",
            }],
        });
        assert_eq!(report.to_cyclonedx(), want);
    }

    #[test]
    fn affected_logic_unpatched_between_ranges() {
        // patched >= 0.39, unaffected < 0.15 → 0.20 is affected (the RUSTSEC aws-lc case)
        let v = semver::Version::parse("0.20.0").unwrap();
        let patched = vec![">= 0.39.0".to_string()];
        let unaffected = vec!["< 0.15.0".to_string()];
        let is_affected = !req_matches(&patched, &v) && !req_matches(&unaffected, &v);
        assert!(is_affected);
        // a patched version is not affected
        let v2 = semver::Version::parse("0.40.0").unwrap();
        assert!(req_matches(&patched, &v2));
    }
}