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

nordisk/holger

server/lib/src/security.rs

//! Móðguðr (modgunn) security integration — Engine A verdicts for holger.
//!
//! modgunn is the **single home** of the verdict logic (shared with nornir to
//! break the dependency cycle). This module is only the holger↔modgunn *adapter*:
//!
//! - map a holger artifact (a file inside a repository's znippy archive, or an
//!   explicit coord) → a [`modgunn::scan::Artifact`];
//! - run the lean `OsvScanner` verdict (CVE / license / provenance) over a
//!   listing;
//! - aggregate the per-artifact [`Verdict`]s into a [`ScanReport`] the Tools#3
//!   Security pane (UI) and the `holger-server scan` CLI verb both surface.
//!
//! Only modgunn's DEFAULT `scan` feature (Engine A) is pulled — never the heavy
//! `scip`/`warehouse` engines. The advisory DB + policy here are a representative
//! **offline** set for the demo + tests; a real deployment swaps in an imported
//! OSV/GHSA snapshot via [`modgunn::osv`].

use serde_json::{json, Value};
use traits::ArtifactFormat;

pub use modgunn::core::{ArtifactRef, Decision, Finding, Severity, Verdict};
pub use modgunn::scan::{
    Advisory, AdvisoryDb, Artifact, LicensePolicy, OsvScanner, Policy, Provenance,
    ProvenancePolicy, ScanEngine,
};

/// The modgunn ecosystem label for a holger repository type / format string.
/// Stable, lowercase; used consistently for BOTH the advisory DB and the
/// artifacts so [`Advisory::matches`] keys line up. Falls back to the raw lower
/// of an unknown string so a custom format still scans (license/provenance
/// checks still apply even with no advisories for that ecosystem).
pub fn ecosystem_for(repo_type: &str) -> String {
    match ArtifactFormat::from_format_str(repo_type) {
        Some(fmt) => ecosystem_of(fmt).to_string(),
        None => repo_type.to_ascii_lowercase(),
    }
}

/// The canonical modgunn ecosystem label for a known [`ArtifactFormat`]. Shared
/// by [`ecosystem_for`] (string path) and [`ecosystem_for_format`] (the
/// promotion gate, which has a repository backend's `format()` directly).
fn ecosystem_of(fmt: ArtifactFormat) -> &'static str {
    match fmt {
        ArtifactFormat::Rust => "cargo",
        ArtifactFormat::Pip => "pip",
        ArtifactFormat::Maven3 => "maven",
        ArtifactFormat::Go => "go",
        ArtifactFormat::Nuget => "nuget",
        ArtifactFormat::Npm => "npm",
        ArtifactFormat::Gem => "gem",
        ArtifactFormat::Deb => "deb",
        ArtifactFormat::Rpm => "rpm",
        ArtifactFormat::Helm => "helm",
        ArtifactFormat::Docker => "docker",
        ArtifactFormat::Conda => "conda",
        ArtifactFormat::Composer => "composer",
        ArtifactFormat::Znippy => "znippy",
        ArtifactFormat::Raw => "raw",
    }
}

/// The modgunn ecosystem label for a repository backend's [`ArtifactFormat`] —
/// the seam the promotion policy gate uses to key its scan (the source repo's
/// `format()` is known directly, so no repo-type string round-trip is needed).
pub fn ecosystem_for_format(fmt: ArtifactFormat) -> String {
    ecosystem_of(fmt).to_string()
}

/// Archive entries that are index/metadata, not artifacts — skipped by the
/// scanner (they carry no coords to key a verdict by).
fn is_metadata_path(path: &str) -> bool {
    let lower = path.to_ascii_lowercase();
    let base = lower.rsplit('/').next().unwrap_or(&lower);
    lower.contains("/index/")
        || lower.starts_with("index/")
        || base.ends_with(".json")
        || base.ends_with(".xml")
        || base == "packages"
        || base == "packages.gz"
        || base == "release"
        || base == "release.gz"
        || base.starts_with("repomd")
        || base.starts_with("repodata")
        || base == "config.json"
}

/// Parse `(name, version)` from an artifact filename/path across ecosystems.
///
/// Heuristic but real: takes the last path segment, strips a known archive
/// extension, then splits at the first `-` that is immediately followed by a
/// digit (the name↔version boundary that holds for cargo `.crate`, npm `.tgz`,
/// Python wheels/sdists, Maven `.jar`/`.pom`, `.gem`, `.nupkg`, …). The version
/// is the run up to the next `-` (so a wheel's `-py3-none-any` build tags are
/// dropped). Returns `None` for metadata files or anything without a coord.
pub fn parse_coords(path: &str) -> Option<(String, String)> {
    if is_metadata_path(path) {
        return None;
    }
    let base = path.rsplit('/').next().unwrap_or(path);
    // Strip the longest matching known extension (handles multi-part ones).
    let exts = [
        ".tar.gz", ".tar.zst", ".tar.bz2", ".crate", ".whl", ".tgz", ".jar", ".pom", ".war",
        ".gem", ".deb", ".rpm", ".nupkg", ".conda", ".zip", ".tar", ".gz", ".tbz",
    ];
    let stem = exts
        .iter()
        .find_map(|e| base.strip_suffix(e))
        .unwrap_or(base);
    if stem.is_empty() {
        return None;
    }
    let bytes = stem.as_bytes();
    let mut split = None;
    for i in 0..bytes.len().saturating_sub(1) {
        if bytes[i] == b'-' && bytes[i + 1].is_ascii_digit() {
            split = Some(i);
            break;
        }
    }
    let idx = split?;
    let name = &stem[..idx];
    let rest = &stem[idx + 1..];
    // Version = run up to the next '-' (drops wheel build tags etc.).
    let version = rest.split('-').next().unwrap_or(rest);
    if name.is_empty() || version.is_empty() {
        return None;
    }
    Some((name.to_string(), version.to_string()))
}

/// Build a modgunn [`Artifact`] for a file in `ecosystem`. Provenance is taken
/// from `signed` (a holger znippy archive that passed signature verification can
/// pass `true`); the declared `license` is optional (filename listings carry
/// none — `None`). `None` when the path has no parseable coords.
pub fn artifact_for(
    ecosystem: &str,
    path: &str,
    license: Option<String>,
    signed: bool,
) -> Option<Artifact> {
    let (name, version) = parse_coords(path)?;
    let provenance = if signed {
        Provenance::signed_by("holger-archive")
    } else {
        Provenance::unsigned()
    };
    Some(Artifact::new(
        ArtifactRef {
            ecosystem: ecosystem.to_string(),
            name,
            version,
            blob_sha256: String::new(),
        },
        license,
        provenance,
    ))
}

/// Build a modgunn [`Artifact`] from EXPLICIT coordinates (`name`/`version`) in
/// `ecosystem`. This is the promotion path: the coords are known directly from
/// the [`traits::ArtifactId`] crossing the boundary, so there is no filename to
/// parse. `signed` sets provenance (a signature-verified znippy archive passes
/// `true`). This is the REAL scan seam the promotion policy gate feeds — it uses
/// the caller's configured scanner, never `demo_*`.
///
/// ★ **This overload hands modgunn an EMPTY digest, and that is a measured
/// defect, not a simplification.** `modgunn::core::Verdict::cache_key()` is
/// `(blob_sha256, policy_version, advisory_db_rev)` — CONTENT-ADDRESSED BY
/// DESIGN — and `blob_sha256: String::new()` collapses every artifact in a
/// promotion onto the cache key `("", policy, db)`. Two consequences follow, and
/// both are visible today:
///
/// * the verdict can only ever be a `name@version` advisory lookup, so it is a
///   verdict about a COORDINATE and not about bytes — exactly the binding defect
///   `crate::attest` exists to close one layer up; and
/// * a verdict cache keyed on that tuple cannot tell two artifacts apart, so a
///   clean result for one would answer for all of them.
///
/// Kept unchanged because callers depend on this signature (`promote/**` is
/// another owner's file this slice must not touch). Every caller that HOLDS the
/// bytes must move to [`artifact_from_coords_with_digest`]; this one now names
/// the hole instead of hiding it.
/// ★ **The PACKAGE-reading scan seam** (B2): the same artifact as
/// [`artifact_from_coords_with_digest`], plus the licence the package's own
/// metadata declares ([`license_from_package`]). This is what the promotion gate
/// calls, because it holds the bytes. Before it existed every gate subject carried
/// `license: None`, so `deny_licenses` could never block a real package.
pub fn artifact_from_package(
    ecosystem: &str,
    name: &str,
    version: &str,
    bytes: &[u8],
    blob_sha256: &str,
    signed: bool,
) -> Artifact {
    let mut art = artifact_from_coords_with_digest(ecosystem, name, version, blob_sha256, signed);
    art.license = license_from_package(ecosystem, bytes);
    art
}

/// The SPDX licence expression a package declares in its OWN metadata.
///
/// This is the licence scanner's reader ([`crate::scanner::license::detect`]);
/// there is no second one. Wheel METADATA (`License-Expression`, then `License`,
/// then the classifiers), sdist PKG-INFO, npm package.json, the `.crate`'s
/// Cargo.toml, the nuspec, the Maven pom, the gemspec, a `.deb`'s DEP-5
/// copyright and a helm chart's annotation are read; see that module.
///
/// `None` for bytes that are not a readable package, for a package that states
/// no licence, and for one whose licence does not parse as SPDX. `None` is
/// decided by [`UnknownLicense`], never passed silently by default.
pub fn license_from_package(ecosystem: &str, bytes: &[u8]) -> Option<String> {
    crate::scanner::license::detect(ecosystem, bytes).and_then(|d| d.expression)
}

/// **What the licence policy does with a package whose licence is unknown**.
/// It lives with the licence scanner, which is the one code path that applies it.
pub use crate::scanner::license::UnknownLicense;

/// The finding id modgunn gives a package that declares no licence.
pub const LICENSE_UNKNOWN_FINDING: &str = "MODGUNN-LICENSE-UNKNOWN";

pub fn artifact_from_coords(ecosystem: &str, name: &str, version: &str, signed: bool) -> Artifact {
    artifact_from_coords_with_digest(ecosystem, name, version, "", signed)
}

/// ★ The DIGEST-CARRYING scan seam — the same artifact, keyed by its bytes.
///
/// `blob_sha256` is the artifact's lowercase-hex SHA-256. Pass it and
/// `Verdict::cache_key()` becomes what modgunn designed it to be: a key that
/// distinguishes two artifacts, survives a re-upload under the same coordinate,
/// and can be cross-referenced with an attestation in
/// [`crate::attest::AttestStore`] — which is keyed by the SAME digest, so a
/// verdict and the evidence behind it can finally be joined.
///
/// An empty `blob_sha256` is accepted rather than refused: this is a shared seam
/// and a caller genuinely scanning a filename listing has no bytes to hash (see
/// [`artifact_for`], which is the listing path). The refusal belongs at the
/// promotion boundary, which knows whether a digest was AVAILABLE — a refusal
/// here would only force that caller to invent one.
pub fn artifact_from_coords_with_digest(
    ecosystem: &str,
    name: &str,
    version: &str,
    blob_sha256: &str,
    signed: bool,
) -> Artifact {
    let provenance = if signed {
        Provenance::signed_by("holger-archive")
    } else {
        Provenance::unsigned()
    };
    Artifact::new(
        ArtifactRef {
            ecosystem: ecosystem.to_string(),
            name: name.to_string(),
            version: version.to_string(),
            blob_sha256: blob_sha256.to_string(),
        },
        None,
        provenance,
    )
}

/// The SHA-256 hex of an artifact's bytes, in the exact spelling
/// `modgunn::core::ArtifactRef::blob_sha256` and `crate::attest::ArtifactDigest`
/// both use. One hashing call site so a verdict and its attestation can never be
/// keyed by two different spellings of the same bytes — the failure mode that
/// looks like "no evidence for this artifact" and is really "evidence filed under
/// the other capitalisation".
pub fn blob_digest_hex(bytes: &[u8]) -> String {
    nornir_hash::sha256_hex(bytes)
}

/// A representative **offline** advisory DB for the demo + tests — a handful of
/// well-known advisories across ecosystems so a real corpus surfaces a mix of
/// Pass / Warn / Block. A real deployment replaces this with an imported OSV/
/// GHSA/RUSTSEC snapshot (`modgunn::osv`).
pub fn demo_advisory_db() -> AdvisoryDb {
    let mut log4shell = Advisory::basic(
        "CVE-2021-44228",
        "maven",
        "log4j-core",
        vec!["2.14.1".into(), "2.14.0".into(), "2.15.0".into()],
        Severity::Critical,
        "Log4Shell: JNDI RCE in Apache Log4j2",
    );
    log4shell.kev = true; // confirmed exploited in the wild
    AdvisoryDb::new(
        "holger-demo-db-v1",
        vec![
            Advisory::basic(
                "RUSTSEC-2023-0044",
                "cargo",
                "openssl",
                vec!["0.10.55".into()],
                Severity::High,
                "use-after-free in openssl::x509",
            ),
            Advisory::basic(
                "GHSA-pad-left",
                "npm",
                "left-pad",
                vec![], // every version
                Severity::Low,
                "left-pad unpublish incident",
            ),
            Advisory::basic(
                "CVE-2018-18074",
                "pip",
                "requests",
                vec!["2.19.0".into()],
                Severity::Medium,
                "requests leaks Authorization header on redirect",
            ),
            log4shell,
        ],
    )
}

/// The demo [`Policy`]: deny strong copyleft, allow anything else (an empty
/// allow-list ⇒ no "unreviewed" noise), and treat an unstated license as Pass
/// (filename listings carry no license, so unknown must NOT flood Warn).
/// Provenance is `Ignore` for the demo (synthetic artifacts are unsigned).
pub fn demo_policy() -> Policy {
    Policy {
        version: "holger-demo-v1".into(),
        license: LicensePolicy {
            allow: Vec::new(),
            deny: vec!["GPL-3.0".into(), "AGPL-3.0".into()],
            on_unknown: Decision::Pass,
        },
        provenance: ProvenancePolicy::Ignore,
        ..Default::default()
    }
}

/// The default holger demo scanner (demo policy + demo advisory DB).
pub fn demo_scanner() -> OsvScanner {
    OsvScanner::new(demo_policy(), demo_advisory_db())
}

/// The aggregated result of scanning a repository listing — the per-artifact
/// [`Verdict`]s plus Pass/Warn/Block tallies and the overall (strongest)
/// decision. Surfaced as `state_json` for the Tools#3 pane + asserted headlessly.
#[derive(Debug, Clone, Default)]
pub struct ScanReport {
    pub ecosystem: String,
    pub verdicts: Vec<Verdict>,
    pub pass: usize,
    pub warn: usize,
    pub block: usize,
    /// Files that had no parseable coords (index/metadata), recorded for honesty.
    pub skipped: usize,
}

impl ScanReport {
    /// The overall decision — the strongest across all verdicts (Block > Warn >
    /// Pass); Pass for an empty/clean report.
    pub fn decision(&self) -> Decision {
        self.verdicts
            .iter()
            .map(|v| v.decision)
            .max()
            .unwrap_or(Decision::Pass)
    }

    /// Observable state for the robot/headless tests: counts, overall decision,
    /// and the findings (id + severity + summary + coords) — never raw bytes.
    pub fn state_json(&self) -> Value {
        let decision = match self.decision() {
            Decision::Pass => "pass",
            Decision::Warn => "warn",
            Decision::Block => "block",
        };
        let findings: Vec<Value> = self
            .verdicts
            .iter()
            .filter(|v| !v.findings.is_empty())
            .map(|v| {
                json!({
                    "name": v.artifact.name,
                    "version": v.artifact.version,
                    "decision": match v.decision {
                        Decision::Pass => "pass",
                        Decision::Warn => "warn",
                        Decision::Block => "block",
                    },
                    "findings": v.findings.iter().map(|f| json!({
                        "id": f.id,
                        "severity": format!("{:?}", f.severity),
                        "summary": f.summary,
                    })).collect::<Vec<_>>(),
                })
            })
            .collect();
        json!({
            "ecosystem": self.ecosystem,
            "scanned": self.verdicts.len(),
            "skipped": self.skipped,
            "pass": self.pass,
            "warn": self.warn,
            "block": self.block,
            "decision": decision,
            "findings": findings,
        })
    }
}

/// Scan a repository `files` listing in `ecosystem` with `scanner`, folding the
/// per-artifact verdicts into a [`ScanReport`]. Files without parseable coords
/// (index/metadata) are counted as `skipped`, not scanned. The signature carries
/// over to the modgunn verdict so the report is fully reproducible.
pub fn scan_listing<'a, I>(ecosystem: &str, files: I, scanner: &OsvScanner) -> ScanReport
where
    I: IntoIterator<Item = &'a str>,
{
    let mut report = ScanReport {
        ecosystem: ecosystem.to_string(),
        ..Default::default()
    };
    for path in files {
        match artifact_for(ecosystem, path, None, false) {
            Some(art) => {
                let v = scanner.scan(&art);
                match v.decision {
                    Decision::Pass => report.pass += 1,
                    Decision::Warn => report.warn += 1,
                    Decision::Block => report.block += 1,
                }
                report.verdicts.push(v);
            }
            None => report.skipped += 1,
        }
    }
    report
}

/// Import an offline OSV / GHSA / RUSTSEC advisory snapshot from a single JSON
/// file into an [`AdvisoryDb`] — the REAL feed the promotion policy gate scans
/// against (the airgap-served advisory mirror the design names, §4 v2 / §13-10),
/// replacing the `demo_*` stub on the enforcement path. Uses modgunn's `osv`
/// importer (one advisory per vuln×affected-package). A range-only entry (no
/// explicit `versions`) imports as "all versions" per the importer's contract.
pub fn advisory_db_from_osv_file(path: &str) -> anyhow::Result<AdvisoryDb> {
    let text = std::fs::read_to_string(path)
        .map_err(|e| anyhow::anyhow!("reading OSV advisory file '{path}': {e}"))?;
    let advisories = modgunn::osv::parse_doc(&text)
        .map_err(|e| anyhow::anyhow!("parsing OSV advisory file '{path}': {e}"))?;
    Ok(AdvisoryDb::new(format!("osv-file:{path}"), advisories))
}

/// A REAL (non-demo) promotion [`Policy`]: deny the given SPDX license ids, HOLD
/// an unknown license for review ([`UnknownLicense::default`]; it was `Pass`
/// until B2, which made `deny_licenses` inert), and ignore provenance (znippy signature verification is a
/// separate gate). The CVE verdict is driven by the imported advisory DB.
/// ★ `provenance: ProvenancePolicy::Ignore` is a FAIL-OPEN, and it is left in
/// place here on purpose — changing it would change the verdict of every existing
/// deployment's promotion gate, and that gate lives in `promote/**`, which this
/// slice does not own. It is called out rather than silently kept:
///
/// * an `Ignore` provenance policy means an UNSIGNED artifact and a
///   signature-verified one score identically. The gate then enforces CVEs and
///   licences only, and the word "provenance" in its report means nothing.
/// * the stated justification is that "znippy signature verification is a
///   separate gate" — true for a znippy-backed repo with `ron_trusted_roots`
///   configured, and vacuous for everything else, because nothing else on the
///   promote path verifies anything.
/// * it compounds with the empty digest above: with `blob_sha256` empty AND
///   provenance ignored, the verdict is a pure `name@version` advisory lookup.
///
/// Use [`promotion_policy_with_provenance`] to require provenance without
/// touching this function's callers. See this module's `a_provenance_ignoring_
/// policy_passes_an_unsigned_artifact` test, which states the consequence as an
/// executable fact rather than a claim.
pub fn promotion_policy(deny_licenses: Vec<String>) -> Policy {
    promotion_policy_with_provenance(deny_licenses, ProvenancePolicy::Ignore)
}

/// The promotion [`Policy`] with an EXPLICIT provenance stance — the same policy
/// [`promotion_policy`] builds, except the caller says what an unsigned artifact
/// is worth instead of inheriting `Ignore`.
///
/// Additive: no existing caller changes behaviour, and a deployment that wants
/// provenance enforced at the gate can now ask for it without a second policy
/// type. The `version` string still reads `holger-promote-v1` because it is part
/// of `Verdict::cache_key()` and re-pointing it would invalidate every cached
/// verdict for a change that may not be in force.
pub fn promotion_policy_with_provenance(
    deny_licenses: Vec<String>,
    provenance: ProvenancePolicy,
) -> Policy {
    promotion_policy_for(deny_licenses, UnknownLicense::default(), provenance)
}

/// The promotion [`Policy`] with every stance stated: the deny list, what an
/// unknown licence gets, and what an unsigned artifact is worth.
pub fn promotion_policy_for(
    deny_licenses: Vec<String>,
    unknown_license: UnknownLicense,
    provenance: ProvenancePolicy,
) -> Policy {
    Policy {
        version: "holger-promote-v1".into(),
        license: LicensePolicy {
            allow: Vec::new(),
            deny: deny_licenses,
            on_unknown: unknown_license.decision(),
        },
        provenance,
        ..Default::default()
    }
}

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

    #[test]
    fn ecosystem_mapping_is_canonical() {
        assert_eq!(ecosystem_for("rust"), "cargo");
        assert_eq!(ecosystem_for("cargo"), "cargo");
        assert_eq!(ecosystem_for("maven2"), "maven");
        assert_eq!(ecosystem_for("pypi"), "pip");
        assert_eq!(ecosystem_for("npm"), "npm");
        // Unknown format still yields a usable lowercase label.
        assert_eq!(ecosystem_for("WeirdFmt"), "weirdfmt");
    }

    #[test]
    fn parse_coords_across_ecosystems() {
        assert_eq!(
            parse_coords("crates/serde-1.0.0.crate"),
            Some(("serde".into(), "1.0.0".into()))
        );
        assert_eq!(
            parse_coords("left-pad-1.3.0.tgz"),
            Some(("left-pad".into(), "1.3.0".into()))
        );
        assert_eq!(
            parse_coords("requests-2.19.0-py3-none-any.whl"),
            Some(("requests".into(), "2.19.0".into()))
        );
        assert_eq!(
            parse_coords("org/apache/logging/log4j/log4j-core/2.14.1/log4j-core-2.14.1.jar"),
            Some(("log4j-core".into(), "2.14.1".into()))
        );
        assert_eq!(
            parse_coords("openssl-0.10.55.crate"),
            Some(("openssl".into(), "0.10.55".into()))
        );
    }

    #[test]
    fn parse_coords_skips_metadata() {
        assert_eq!(parse_coords("index/config.json"), None);
        assert_eq!(parse_coords("repodata.json"), None);
        assert_eq!(parse_coords("dists/stable/Packages"), None);
        assert_eq!(parse_coords("repomd.xml"), None);
    }

    #[test]
    fn clean_listing_passes() {
        let s = demo_scanner();
        let report = scan_listing("cargo", ["serde-1.0.0.crate", "tokio-1.40.0.crate"], &s);
        assert_eq!(report.decision(), Decision::Pass);
        assert_eq!(report.pass, 2);
        assert_eq!(report.warn, 0);
        assert_eq!(report.block, 0);
    }

    #[test]
    fn known_high_vuln_blocks() {
        let s = demo_scanner();
        let report = scan_listing("cargo", ["openssl-0.10.55.crate", "serde-1.0.0.crate"], &s);
        assert_eq!(report.decision(), Decision::Block);
        assert_eq!(report.block, 1);
        assert_eq!(report.pass, 1);
        let blocked = report
            .verdicts
            .iter()
            .find(|v| v.decision == Decision::Block)
            .unwrap();
        assert_eq!(blocked.findings[0].id, "RUSTSEC-2023-0044");
    }

    #[test]
    fn low_npm_advisory_warns_all_versions() {
        let s = demo_scanner();
        let report = scan_listing("npm", ["left-pad-1.3.0.tgz"], &s);
        assert_eq!(report.decision(), Decision::Warn);
        assert_eq!(report.warn, 1);
    }

    #[test]
    fn log4shell_kev_blocks_as_critical() {
        let s = demo_scanner();
        let report = scan_listing("maven", ["log4j-core-2.14.1.jar"], &s);
        assert_eq!(report.decision(), Decision::Block);
        let v = &report.verdicts[0];
        assert_eq!(v.findings[0].id, "CVE-2021-44228");
        assert_eq!(v.findings[0].severity, Severity::Critical);
        assert!(v.findings[0].summary.contains("[KEV]"));
    }

    #[test]
    fn metadata_files_are_skipped_not_scanned() {
        let s = demo_scanner();
        let report = scan_listing(
            "cargo",
            [
                "serde-1.0.0.crate",
                "index/config.json",
                "crates.io-index/cfg",
            ],
            &s,
        );
        assert_eq!(report.verdicts.len(), 1);
        assert!(report.skipped >= 1);
    }

    /// Every holger repository type maps to a non-empty, stable modgunn ecosystem
    /// label, and a clean filename in that ecosystem scans to Pass — the
    /// 14-ecosystem coverage sweep (the modgunn↔holger integration table).
    #[test]
    fn all_repository_types_map_and_scan_clean() {
        let cases: &[(&str, &str)] = &[
            ("rust", "serde-1.0.0.crate"),
            ("pip", "flask-3.0.0-py3-none-any.whl"),
            ("maven3", "com/foo/bar/1.0/bar-1.0.jar"),
            ("go", "golang.org/x/text-0.14.0.zip"),
            ("nuget", "Newtonsoft.Json-13.0.3.nupkg"),
            ("npm", "express-4.18.2.tgz"),
            ("gem", "rails-7.1.0.gem"),
            ("deb", "nginx-1.24.0.deb"),
            ("rpm", "httpd-2.4.57.rpm"),
            ("helm", "mychart-1.2.3.tgz"),
            ("docker", "alpine-3.20.tar"),
            ("conda", "numpy-1.26.0.conda"),
            ("composer", "monolog-2.9.0.zip"),
            ("znippy", "bundle-1.0.0.crate"),
        ];
        let s = demo_scanner();
        for (repo_type, file) in cases {
            let eco = ecosystem_for(repo_type);
            assert!(!eco.is_empty(), "{repo_type} has no ecosystem label");
            let report = scan_listing(&eco, [*file], &s);
            assert_eq!(
                report.decision(),
                Decision::Pass,
                "clean {repo_type} artifact {file} (eco {eco}) should Pass: {report:?}"
            );
        }
    }

    /// The three verdict states are reachable and ordered: a clean dep Passes, a
    /// Low advisory Warns, a High/Critical advisory Blocks — and the overall
    /// report decision is the strongest across a mixed listing.
    #[test]
    fn verdict_states_pass_warn_block_and_strongest_wins() {
        let s = demo_scanner();
        assert_eq!(
            scan_listing("cargo", ["serde-1.0.0.crate"], &s).decision(),
            Decision::Pass
        );
        assert_eq!(
            scan_listing("npm", ["left-pad-1.3.0.tgz"], &s).decision(),
            Decision::Warn
        );
        assert_eq!(
            scan_listing("cargo", ["openssl-0.10.55.crate"], &s).decision(),
            Decision::Block
        );
        // Mixed listing: Pass + Warn + Block ⇒ overall Block.
        let mixed = scan_listing("cargo", ["serde-1.0.0.crate", "openssl-0.10.55.crate"], &s);
        assert_eq!(mixed.decision(), Decision::Block);
        assert_eq!(mixed.pass, 1);
        assert_eq!(mixed.block, 1);
    }

    #[test]
    fn report_state_json_carries_counts_and_findings() {
        let s = demo_scanner();
        let report = scan_listing("cargo", ["openssl-0.10.55.crate", "serde-1.0.0.crate"], &s);
        let j = report.state_json();
        assert_eq!(j["ecosystem"], "cargo");
        assert_eq!(j["scanned"], 2);
        assert_eq!(j["block"], 1);
        assert_eq!(j["decision"], "block");
        assert!(j["findings"]
            .as_array()
            .unwrap()
            .iter()
            .any(|f| f["name"] == "openssl"));
    }

    // ── The content-addressing seam (the measured defect, and its fix) ────────

    /// ★ RED-when-broken, and it states the DEFECT as an executable fact: the
    /// coordinate-only seam hands modgunn an EMPTY `blob_sha256`, so two DIFFERENT
    /// artifacts collapse onto the SAME `Verdict::cache_key()`. modgunn's cache key
    /// is `(blob_sha256, policy_version, advisory_db_rev)` — content-addressed by
    /// design — and an empty digest is the one value that defeats it.
    #[test]
    fn the_coordinate_only_seam_collapses_two_artifacts_onto_one_cache_key() {
        let s = demo_scanner();
        let a = s.scan(&artifact_from_coords("cargo", "serde", "1.0.0", false));
        let b = s.scan(&artifact_from_coords("cargo", "tokio", "1.40.0", false));
        assert_eq!(
            a.cache_key().0,
            "",
            "the coordinate-only seam still carries no digest"
        );
        assert_eq!(
            a.cache_key(),
            b.cache_key(),
            "two different artifacts share one verdict cache key — this is the defect"
        );
    }

    /// …and the digest-carrying seam separates them. Same coords, same policy, same
    /// advisory DB: only the bytes differ, and only the bytes need to.
    #[test]
    fn the_digest_carrying_seam_keys_the_verdict_by_bytes() {
        let s = demo_scanner();
        let one = blob_digest_hex(b"the real serde 1.0.0 bytes");
        let two = blob_digest_hex(b"something else entirely");
        assert_ne!(one, two);

        let a = s.scan(&artifact_from_coords_with_digest(
            "cargo", "serde", "1.0.0", &one, false,
        ));
        // The SAME coordinate, different bytes — the substitution a coordinate-keyed
        // store cannot see.
        let b = s.scan(&artifact_from_coords_with_digest(
            "cargo", "serde", "1.0.0", &two, false,
        ));
        assert_eq!(a.cache_key().0, one);
        assert_ne!(
            a.cache_key(),
            b.cache_key(),
            "different bytes ⇒ different verdict key"
        );
        // …and the policy/db halves of the key are untouched.
        assert_eq!(a.cache_key().1, b.cache_key().1);
        assert_eq!(a.cache_key().2, b.cache_key().2);
    }

    /// The digest spelling is the ONE spelling: lowercase hex SHA-256, identical to
    /// what `crate::attest::ArtifactDigest` keys by. A verdict and its attestation
    /// must be joinable, and they cannot be if one side capitalises.
    #[test]
    fn the_digest_spelling_matches_the_attestation_stores_key() {
        let bytes = b"artifact bytes";
        let hexstr = blob_digest_hex(bytes);
        assert_eq!(hexstr.len(), 64);
        assert!(hexstr
            .bytes()
            .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)));
        assert_eq!(
            crate::attest::ArtifactDigest::of_sha256(bytes).hex(),
            hexstr,
            "the scan seam and the attestation store must hash to the same string"
        );
        // And it is a digest the attestation store will actually key by.
        assert!(crate::attest::ArtifactDigest::sha256(&hexstr).is_ok());
    }

    /// ★ The consequence of `provenance: ProvenancePolicy::Ignore`, stated rather
    /// than asserted: an UNSIGNED artifact passes the promotion policy exactly as a
    /// signed one does. The line is left in `promotion_policy` (its gate lives in
    /// `promote/**`), so this test is what stops it being forgotten.
    #[test]
    fn a_provenance_ignoring_policy_passes_an_unsigned_artifact() {
        // Both subjects declare a licence, so the only variable is provenance
        // (an unknown licence is held for review since B2).
        let licensed = |mut a: Artifact| {
            a.license = Some("MIT".into());
            a
        };
        let unsigned = licensed(artifact_from_coords("cargo", "serde", "1.0.0", false));

        let ignoring = OsvScanner::new(promotion_policy(vec![]), demo_advisory_db());
        assert_eq!(
            ignoring.scan(&unsigned).decision,
            Decision::Pass,
            "Ignore ⇒ an unsigned artifact is indistinguishable from a signed one"
        );

        // With the stance made explicit, the same artifact is refused.
        let requiring = OsvScanner::new(
            promotion_policy_with_provenance(vec![], ProvenancePolicy::Require),
            demo_advisory_db(),
        );
        assert_eq!(requiring.scan(&unsigned).decision, Decision::Block);
        let signed = licensed(artifact_from_coords("cargo", "serde", "1.0.0", true));
        assert_eq!(requiring.scan(&signed).decision, Decision::Pass);
    }

    /// `promotion_policy` is what it always was except for ONE deliberate change
    /// (B2, 2026-10-19): an unknown licence is no longer `Pass` but `Warn`, which
    /// the promotion gate turns into "held for review".
    #[test]
    fn the_existing_promotion_policy_is_unchanged() {
        let p = promotion_policy(vec!["GPL-3.0".into()]);
        assert_eq!(p.version, "holger-promote-v1");
        assert_eq!(p.license.deny, vec!["GPL-3.0".to_string()]);
        assert!(p.license.allow.is_empty());
        assert_eq!(
            p.license.on_unknown,
            Decision::Warn,
            "unknown is HELD, never passed"
        );
        assert_eq!(p.provenance, ProvenancePolicy::Ignore);
    }

    /// Each stated stance maps to its own decision, and the default is Hold.
    #[test]
    fn the_unknown_licence_stance_is_stated_and_defaults_to_hold() {
        assert_eq!(UnknownLicense::default(), UnknownLicense::Hold);
        for (stance, want) in [
            (UnknownLicense::Hold, Decision::Warn),
            (UnknownLicense::Block, Decision::Block),
            (UnknownLicense::Pass, Decision::Pass),
        ] {
            let p = promotion_policy_for(vec![], stance, ProvenancePolicy::Ignore);
            assert_eq!(p.license.on_unknown, want, "{stance:?}");
        }
    }

    fn wheel_with_metadata(meta: &str) -> Vec<u8> {
        zip_entry::fixture::zip(&[
            ("thing/__init__.py", b"".as_slice()),
            ("thing-1.0.0.dist-info/METADATA", meta.as_bytes()),
        ])
    }

    #[test]
    fn a_wheel_licence_is_read_from_its_metadata_header_only() {
        let w = wheel_with_metadata(
            "Name: thing\nVersion: 1.0.0\nLicense-Expression: MIT OR Apache-2.0\n\nbody\n",
        );
        assert_eq!(
            license_from_package("pip", &w).as_deref(),
            Some("MIT OR Apache-2.0")
        );
        // A field in the long description is not a header.
        let w = wheel_with_metadata("Name: thing\nVersion: 1.0.0\n\nLicense-Expression: MIT\n");
        assert_eq!(license_from_package("pip", &w), None);
        // A free-text License field that is not an SPDX expression is unknown, not "declared".
        let w = wheel_with_metadata(
            "Name: thing\nVersion: 1.0.0\nLicense: GNU General Public License v3\n",
        );
        assert_eq!(license_from_package("pip", &w), None);
        // A METADATA deeper in the tree is not the wheel's own.
        let nested = zip_entry::fixture::zip(&[(
            "vendor/x-1.0.dist-info/METADATA",
            b"License-Expression: MIT\n".as_slice(),
        )]);
        assert_eq!(license_from_package("pip", &nested), None);
        // Bytes that are not a package at all read as unknown.
        assert_eq!(license_from_package("pip", b"not a zip"), None);
        assert_eq!(license_from_package("cargo", b"not a crate"), None);
    }
}