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/promote/gate.rs

//! # The promotion gate seam
//!
//! ## What was here before, measured at `542e296`
//!
//! Policy was a concrete struct, `PolicyGate { scanner: OsvScanner, enforcement }`
//! (`promote/mod.rs:66`), with four properties that each cost something:
//!
//! 1. **Configured from environment variables** (`PolicyGate::from_env`,
//!    `promote/mod.rs:88`): `HOLGER_PROMOTE_ENFORCE`,
//!    `HOLGER_PROMOTE_ADVISORY_DB`, `HOLGER_PROMOTE_DENY_LICENSES`. holger ships
//!    as an appliance with no OS and no shell, so an environment variable is a
//!    configuration surface the operator cannot reach.
//! 2. **Unset means pass-through.** `from_env` returned `Ok(None)` and
//!    `gate_check` then returned `Ok(())` (`:522`). A promotion with no gate and
//!    a promotion that passed every gate were indistinguishable in the log.
//! 3. **No bytes.** `PolicyGate::evaluate` (`:122`) called
//!    `security::artifact_from_coords(ecosystem, name, version, false)`, the
//!    coordinate-only seam, which builds an `ArtifactRef` with
//!    `blob_sha256: String::new()`. modgunn's `Verdict::cache_key()` is
//!    `(blob_sha256, policy_version, advisory_db_rev)` and is content-addressed
//!    BY DESIGN, so every artifact in every promotion collapsed onto
//!    `("", policy, db)`. The digest-carrying seam
//!    `security::artifact_from_coords_with_digest` was added by `fa9f5e6`
//!    (`security.rs:207`) and had ZERO callers.
//! 4. **One artifact at a time.** There was no call that saw the promoted set,
//!    although `.nornir/SUITES.md` requires analysis "per package AND over the
//!    promoted set as a whole".
//!
//! There was also no way to say "this rung must be gated" and have the absence
//! of a working gate refuse the hop.
//!
//! ## What is here now
//!
//! A [`Gate`] trait, a [`PromotionContext`] that carries the principal, the hop
//! and the whole promoted set, and a [`PromotedArtifact`] whose `digest` and
//! `bytes` are REQUIRED fields rather than optional ones. The shape is gunnar's
//! (`gunnar/crates/gunnar-policy/src/lib.rs:173`), and the verdict vocabulary is
//! literally gunnar's, through `nornir_gate` in edda — so gunnar and holger
//! share the types and neither depends on the other.
//!
//! ### HOL-GUN-007
//!
//! gunnar's GUN-007 says an unreadable policy configuration refuses everything.
//! It was written because, until 2026-09-11, an unreadable policy fell back to
//! `PolicyConfig::default()` and silently turned branch protection off
//! (`gunnar/crates/gunnar-transport/src/transport.rs:2570`).
//!
//! holger's form: **a rung that names a gate this build does not carry refuses
//! the hop.** [`GateCatalog::resolve`] returns an error naming the gate and
//! listing what the build does carry, and [`Holger::promotion_engine`] turns
//! that error into a boot failure — the same rule `ron_handler` already applies
//! to repository handlers. If a gate is resolved but returns `Err` at run time,
//! `nornir_gate::evaluate` records it as `undecided: true` and the hop is
//! refused.
//!
//! The refusal costs promotions and nothing else. Reads, fetches and serving are
//! untouched, which is what makes failing closed affordable.
//!
//! ### The plugin seam
//!
//! [`Gate`] is object-safe and takes `&self`, so a gate can hold any handle:
//! `struct WasmGate(Arc<dyn MeterRuntime>)` with `impl Gate for WasmGate` is the
//! whole join needed to enforce a quota cap from a loaded plugin. Nothing here
//! forecloses it and nothing here requires it.
//!
//! ### The two questions this module used to leave open — ANSWERED 2026-09-21
//!
//! Both are closed by W17 (plan §4 "W17 — a signature that means something").
//! The original text is kept because it records what was true and for how long.
//!
//! * ~~*"modgunn's `provenance-verify` is on by default while holger pins
//!   `features = ["scan"]`, and holger's own `attest.rs` is behind a non-default
//!   `sign` feature — so in a shipped holger neither provenance gate runs. This
//!   module adds a NAMED gate (`provenance`) whose absence from the catalog is
//!   reported by name instead of being silent, which is the smallest honest
//!   thing to do without ruling the question."*~~ — Two corrections. `sign` is
//!   pinned on all five server rows and `attest.rs` was always compiled (?10).
//!   And this module did NOT add a named `provenance` gate: it added a named
//!   REFUSAL for one, and no build carried the gate itself — the unit test below
//!   USED `"provenance"` as its example of a name nothing carries, which is how
//!   you can tell. [`ProvenanceGate`] is now registered on every row by
//!   [`catalog_from_config`], steered by [`ProvenancePolicy`]; that test now
//!   probes a name that will never be a gate, and a sibling asserts the real
//!   catalog carries `provenance`.
//! * ~~*"`nornir --seal` signs with `ZnippySealer::dev()`, which mints an
//!   in-memory CA and discards the root. Nothing here signs anything, so nothing
//!   here depends on that being fixed."*~~ — ?9 fixed the producer. The
//!   CONSUMER's half was [`crate::attest::AttestTrust::from_der_root_files`],
//!   which pushed each declared root's bytes without checking they were a
//!   certificate: an estate could verify against fewer roots than it declared
//!   and nothing said so. `holger-attest-10` now refuses the whole declaration.
//!   This gate depends on that being fixed, because "filed in the evidence
//!   plane" is only worth anything if the roots it was verified against are real.

use std::collections::BTreeMap;
use std::sync::Arc;

use holger_errcode as ec;
use nornir_gate::{Decision, Verdict};
use traits::ArtifactId;

use crate::attest::{ArtifactDigest, Statement};
#[cfg(feature = "scanners")]
use crate::scanner::license::{LicenseDecision, LicensePolicy, LicenseScanner};
#[cfg(feature = "scanners")]
use crate::security::{self, OsvScanner, ScanEngine};

use super::stages::Hop;
use super::Enforcement;

/// One member of a promotion, with the evidence a gate is entitled to see.
///
/// `digest` and `bytes` are required. That is the whole difference from the
/// struct this replaces: a gate that is handed a name and a version cannot tell
/// two uploads of the same coordinate apart, and modgunn's verdict cache is
/// keyed on the digest, so a coordinate-only call made the cache a single
/// bucket.
pub struct PromotedArtifact<'a> {
    /// The coordinate.
    pub id: &'a ArtifactId,
    /// The ecosystem string the scanner scores against
    /// (`security::ecosystem_for_format`).
    pub ecosystem: &'a str,
    /// The content digest of `bytes`. Required.
    pub digest: ArtifactDigest,
    /// The bytes being promoted. Required — a gate CAN look at them.
    pub bytes: &'a [u8],
    /// What the evidence plane already holds for this digest. Empty when the
    /// attestation store is not configured; a gate that requires evidence must
    /// say so in its refusal rather than assume.
    pub attestations: &'a [Statement],
}

impl PromotedArtifact<'_> {
    /// `namespace/name@version`, the spelling a refusal names.
    pub fn label(&self) -> String {
        super::coord_key(self.id)
    }
}

/// Everything a gate is allowed to know about one promotion.
pub struct PromotionContext<'a> {
    /// The authenticated principal performing the promotion, or `"anonymous"`.
    pub principal: &'a str,
    /// Source repository.
    pub from_repo: &'a str,
    /// Destination repository.
    pub to_repo: &'a str,
    /// The resolved rung-to-rung hop, when a ladder is configured.
    pub hop: Option<&'a Hop>,
    /// **Every** member of this promotion. A per-artifact gate iterates inside;
    /// a set-scope gate looks at the whole slice.
    pub set: &'a [PromotedArtifact<'a>],
}

impl<'a> PromotionContext<'a> {
    /// The context for an **INGEST** — a PUT, judged before the bytes are
    /// handed to the backend.
    ///
    /// Promotion is not the first boundary an artifact crosses; upload is. A
    /// gate that only runs on promotion means a vulnerable artifact is already
    /// in the DEV repository and already being served from it, and the gate's
    /// job becomes stopping it from moving rather than stopping it from
    /// arriving.
    ///
    /// `from_repo` is empty, because there is no source repository: the bytes
    /// came off the wire. `hop` is `None`: there is no rung. Everything a gate
    /// actually reads — the principal, the destination, the digest, the bytes —
    /// is present and identical to the promotion case, which is why this is a
    /// constructor and not a second context type.
    pub fn ingest(
        principal: &'a str,
        to_repo: &'a str,
        set: &'a [PromotedArtifact<'a>],
    ) -> PromotionContext<'a> {
        PromotionContext {
            principal,
            from_repo: "",
            to_repo,
            hop: None,
            set,
        }
    }

    /// Whether this context is an ingest rather than a promotion. A gate that
    /// must behave differently at the two boundaries can ask; most do not care.
    pub fn is_ingest(&self) -> bool {
        self.from_repo.is_empty() && self.hop.is_none()
    }
}

/// One check that runs at the promotion boundary, before anything is sealed.
///
/// A gate cannot write, cannot seal, and cannot decide half a promotion.
/// Returning `Err` means *the gate could not decide*, which
/// [`GateSet::evaluate`] treats as a refusal and never as an allow.
pub trait Gate: Send + Sync {
    /// A stable identifier. It appears in refusals, in the audit detail, and in
    /// [`GateSet::names`], which exists so that a server whose gate
    /// configuration failed to load does not look like one that has no gates.
    fn name(&self) -> &str;

    /// Decide.
    fn check(&self, ctx: &PromotionContext<'_>) -> anyhow::Result<Verdict>;
}

impl<T: Gate + ?Sized> Gate for Arc<T> {
    fn name(&self) -> &str {
        (**self).name()
    }
    fn check(&self, ctx: &PromotionContext<'_>) -> anyhow::Result<Verdict> {
        (**self).check(ctx)
    }
}

/// The CVE / licence / provenance scan, as a gate.
///
/// This is the old `PolicyGate`'s scanner with two corrections: it is reached
/// through the trait, and it calls the DIGEST-carrying seam, so
/// `modgunn::Verdict::cache_key()` distinguishes two artifacts and can be joined
/// with the evidence in [`crate::attest::AttestStore`], which is keyed by the
/// same digest.
///
/// The licence half is the licence SCANNER's ([`LicenseScanner::judge`]), the
/// same code the intake's licence slot runs, so the gate and the evidence can
/// never disagree about a package. modgunn judges the advisories only.
#[cfg(feature = "scanners")]
pub struct OsvGate {
    /// The modgunn scanner with its advisories indexed once (T10): the same
    /// verdicts as `OsvScanner::scan`, without a linear scan per lookup.
    scanner: crate::advisory_index::IndexedOsvScanner,
    license: LicenseScanner,
    /// S7: when set, the advisories come from the local intel database at check
    /// time (the scanner's own DB is unused), with its staleness folded in.
    intel: Option<Arc<crate::intel::scan::IntelSource>>,
    /// W5: the intake chain's scan pipeline, once it is built. With it, a FRESH
    /// stored vulnerability record about the artifact's bytes decides the
    /// advisory verdict and the advisories are not scanned again.
    evidence: Option<EvidenceCell>,
    /// SANDBOX-SCAN: the scan worker the licence is read in, once the chain is
    /// built. Without one, a release build refuses to read the licence here.
    worker: Option<WorkerCell>,
}

/// The intake scan pipeline a gate reads stored evidence through, filled once
/// the chain is built (the gate catalog is built first).
pub type EvidenceCell = Arc<std::sync::OnceLock<Arc<crate::scanner::ScanPipeline>>>;

/// The scan worker a gate reads a package's licence through (SANDBOX-SCAN),
/// filled once the chain is built.
#[cfg(feature = "scanners")]
pub type WorkerCell = Arc<std::sync::OnceLock<Arc<crate::sandbox::ScanWorker>>>;

#[cfg(feature = "scanners")]
impl OsvGate {
    /// The name a rung writes in its `gates` list.
    pub const NAME: &'static str = "osv";

    /// The gate over a modgunn scanner. The licence policy the scanner states
    /// (allow, deny, `on_unknown`) moves to the licence scanner, and modgunn's
    /// own licence check is left neutral, so there is one licence code path.
    pub fn new(mut scanner: OsvScanner) -> Self {
        let license = LicenseScanner::new(LicensePolicy::from_modgunn(&scanner.policy.license));
        scanner.policy.license = security::LicensePolicy {
            allow: Vec::new(),
            deny: Vec::new(),
            on_unknown: security::Decision::Pass,
        };
        let scanner = crate::advisory_index::IndexedOsvScanner::new(scanner);
        Self {
            scanner,
            license,
            intel: None,
            evidence: None,
            worker: None,
        }
    }

    /// The licence policy this gate applies.
    pub fn license_policy(&self) -> &LicensePolicy {
        self.license.policy()
    }

    /// Read the stored scan evidence first (W5). A fresh vulnerability record
    /// about the same bytes is reused, and the reuse is audited; a missing or
    /// stale one means the advisories are scanned as before.
    pub fn with_evidence(mut self, cell: EvidenceCell) -> Self {
        self.evidence = Some(cell);
        self
    }

    /// Read the package's licence in this scan worker (SANDBOX-SCAN).
    pub fn with_worker(mut self, cell: WorkerCell) -> Self {
        self.worker = Some(cell);
        self
    }

    /// **The package's licence, judged**: read by the scan worker (the
    /// package is unpacked there, never here); in-process only in a test
    /// build; refused by name otherwise.
    fn license_of(
        &self,
        art: &PromotedArtifact<'_>,
    ) -> Result<crate::scanner::license::LicenseJudgement, String> {
        if let Some(w) = self.worker.as_ref().and_then(|c| c.get()) {
            let file = art.id.name.rsplit('/').next().unwrap_or(&art.id.name);
            let mut subject = crate::scanner::ScanSubject::new(file, art.ecosystem, art.bytes);
            if !art.id.version.is_empty() {
                subject = subject.with_coords(&art.id.name, &art.id.version);
            }
            return w
                .report(&subject)
                .map(|r| {
                    crate::scanner::license::judge_declared(
                        self.license.policy(),
                        r.license.clone(),
                    )
                })
                .map_err(|e| e.to_string());
        }
        if crate::sandbox::IN_PROCESS_ALLOWED {
            return Ok(self.license.judge(art.ecosystem, art.bytes));
        }
        Err(holger_sandbox::Refusal::NoEngine(
            "the osv gate has no scan sandbox to read the licence in".into(),
        )
        .to_string())
    }

    /// The fresh stored evidence about `digest`, when the gate reads it and the
    /// record holds a real vulnerability scan.
    fn fresh_evidence(
        &self,
        sha256_hex: &str,
    ) -> Option<(
        Arc<crate::scanner::ScanPipeline>,
        String,
        crate::scanner::Evidence,
    )> {
        use crate::scanner::{ScannerKind, Stored};
        let pipeline = self.evidence.as_ref()?.get()?.clone();
        match pipeline.stored(sha256_hex) {
            Ok(Stored::Fresh { id, evidence })
                if evidence
                    .records_of(ScannerKind::Vulnerability)
                    .any(|r| !r.is_not_configured()) =>
            {
                Some((pipeline, id, evidence))
            }
            Ok(Stored::Stale { why, .. }) => {
                log::info!(
                    "osv gate: stored evidence for sha256 {sha256_hex} is stale ({why}); scanning"
                );
                None
            }
            Ok(_) => None,
            Err(e) => {
                log::warn!("osv gate: the stored evidence for sha256 {sha256_hex} could not be read ({}); scanning", e.tagged());
                None
            }
        }
    }

    /// Read the advisories from the local intel database instead of the
    /// scanner's fixed set. The scanner's policy still decides.
    pub fn with_intel(mut self, source: Arc<crate::intel::scan::IntelSource>) -> Self {
        self.intel = Some(source);
        self
    }

    fn verdict(&self, subject: &security::Artifact) -> anyhow::Result<security::Verdict> {
        match &self.intel {
            None => Ok(self.scanner.scan(subject)),
            Some(src) => {
                let (db, evidence) = src.current()?;
                if evidence.finding().is_some() {
                    log::warn!("intel: {}", evidence.describe());
                }
                Ok(holger_intel::scan_against(
                    &self.scanner.scanner().policy,
                    &db,
                    &evidence,
                    subject,
                ))
            }
        }
    }
}

#[cfg(feature = "scanners")]
impl Gate for OsvGate {
    fn name(&self) -> &str {
        Self::NAME
    }

    fn check(&self, ctx: &PromotionContext<'_>) -> anyhow::Result<Verdict> {
        for art in ctx.set {
            // B2: the licence comes from the package's own metadata, so the
            // licence policy judges what was uploaded and not `None`. SANDBOX-SCAN:
            // the metadata is read in the scan worker.
            let license = match self.license_of(art) {
                Ok(l) => l,
                Err(why) => {
                    return Ok(Verdict::reject(
                        art.label(),
                        format!("the licence could not be read ({}): {why}", art.digest),
                    ))
                }
            };
            let mut subject = security::artifact_from_coords_with_digest(
                art.ecosystem,
                &art.id.name,
                &art.id.version,
                art.digest.hex(),
                false,
            );
            subject.license = license.declared.as_ref().and_then(|d| d.expression.clone());
            let verdict = match self.fresh_evidence(art.digest.hex()) {
                Some((pipeline, id, evidence)) => {
                    use crate::scanner::{ScanVerdict, ScannerKind};
                    pipeline
                        .store()
                        .record_reuse(ctx.principal, ctx.to_repo, &id, &evidence, Self::NAME)
                        .map_err(|e| anyhow::anyhow!(e.tagged()))?;
                    let blocking: Vec<String> = evidence
                        .records_of(ScannerKind::Vulnerability)
                        .filter(|r| {
                            r.verdict == ScanVerdict::Block
                                || (r.verdict == ScanVerdict::Error && r.required)
                        })
                        .flat_map(|r| {
                            let f: Vec<String> = r
                                .findings
                                .iter()
                                .map(|f| format!("{} [{:?}] {}", f.id, f.severity, f.summary))
                                .collect();
                            if f.is_empty() {
                                vec![r.detail.clone()]
                            } else {
                                f
                            }
                        })
                        .collect();
                    if !blocking.is_empty() {
                        return Ok(Verdict::reject(
                            art.label(),
                            format!(
                                "blocked by the stored scan evidence {id} ({}): {}",
                                art.digest,
                                blocking.join("; ")
                            ),
                        ));
                    }
                    // The advisories are decided; the licence policy still reads
                    // the package itself, with no advisory scan.
                    OsvScanner::new(
                        self.scanner.scanner().policy.clone(),
                        security::AdvisoryDb::default(),
                    )
                    .scan(&subject)
                }
                None => self.verdict(&subject)?,
            };
            if license.decision == LicenseDecision::Block {
                return Ok(Verdict::reject(
                    art.label(),
                    format!(
                        "blocked by the licence policy ({}): {}",
                        art.digest, license.detail
                    ),
                ));
            }
            if verdict.decision == security::Decision::Block {
                let reasons: Vec<String> = verdict
                    .findings
                    .iter()
                    .map(|f| format!("{} [{:?}] {}", f.id, f.severity, f.summary))
                    .collect();
                return Ok(Verdict::reject(
                    art.label(),
                    format!(
                        "blocked by the advisory/licence scan ({}{}): {}",
                        art.digest,
                        // S7: the advisories' own revision — for the intel database
                        // its bundle sequence and date, so a stale DB is visible.
                        if self.intel.is_some() {
                            format!("; advisories {}", verdict.advisory_db_rev)
                        } else {
                            String::new()
                        },
                        if reasons.is_empty() {
                            "no finding detail".to_string()
                        } else {
                            reasons.join("; ")
                        }
                    ),
                ));
            }
            // An unknown licence under `UnknownLicense::Hold` is a hold. A hold
            // is not a pass here: the package is held until someone reviews it.
            if license.decision == LicenseDecision::Hold {
                return Ok(Verdict::reject(
                    art.label(),
                    format!(
                        "held for review ({}): {}. An unknown licence never passes silently. \
                         Review it, or state `promotion.unknown_license: Pass` to let unknown \
                         licences through",
                        art.digest, license.detail
                    ),
                ));
            }
        }
        Ok(Verdict::Allow)
    }
}

/// Require that every member of the promotion already carries at least one
/// accepted attestation of `predicate_type` in the evidence plane.
///
/// This is the gate that makes `attest.rs` load-bearing rather than decorative:
/// with it on a rung, an artifact cannot reach that rung unless a DSSE statement
/// about ITS digest was verified and filed first.
pub struct AttestedGate {
    name: String,
    predicate_type: String,
}

impl AttestedGate {
    /// The name a rung writes for the built-in instance.
    pub const NAME: &'static str = "attested";

    pub fn new(name: impl Into<String>, predicate_type: impl Into<String>) -> Self {
        Self {
            name: name.into(),
            predicate_type: predicate_type.into(),
        }
    }
}

impl Gate for AttestedGate {
    fn name(&self) -> &str {
        &self.name
    }

    fn check(&self, ctx: &PromotionContext<'_>) -> anyhow::Result<Verdict> {
        for art in ctx.set {
            let has = art
                .attestations
                .iter()
                .any(|s| s.predicate_type == self.predicate_type && s.attests(&art.digest));
            if !has {
                return Ok(Verdict::reject(
                    art.label(),
                    format!(
                        "no accepted '{}' attestation for {} in the evidence plane. The evidence \
                         plane is keyed by DIGEST, so this says nothing about other builds of the \
                         same coordinate — it says these BYTES have no such statement filed.",
                        self.predicate_type, art.digest
                    ),
                ));
            }
        }
        Ok(Verdict::Allow)
    }
}

// ── The provenance gate (plan §3 W17) ────────────────────────────────────────

/// **What this estate demands of provenance at the promotion boundary.**
///
/// Declared once, in the RON `promotion:` block, as `provenance: Require`,
/// `provenance: Warn` or `provenance: Off`.
///
/// # Why the default is `Warn`, and why it is not the other two
///
/// MEASURED 2026-09-21: no shipped holger row carried a gate named `provenance`
/// at all. `catalog_from_config` registered `osv`, and `attested` only when
/// `promotion.require_attestation` named a predicate type — so a config that
/// wrote `gates: ["provenance"]` was a BOOT FAILURE on every one of the five
/// server rows (HOL-GUN-007, and the unit test that proves it literally used
/// `"provenance"` as its example of a name no build carries).
///
/// * **`Require` cannot be the default.** Every existing estate has filed no
///   evidence and most declare no `attestation:` block. Defaulting to `Require`
///   would mean a box that upgraded its binary, and changed not one byte of its
///   config, came up refusing every promotion. Every other absent key in this
///   file means what the file always meant — `role:` absent is `Primary`,
///   `mirror:` absent is no mirror — and this one must too.
/// * **`Off` cannot be the default either**, and this is the load-bearing half.
///   `Off` is a sentence an operator WROTE: *I have decided not to check.* An
///   absent key is an omission. If they printed the same, an estate that never
///   got round to signing anything would be indistinguishable from one that
///   considered the question and declined — which is the defect this whole module
///   was written against: *"a promotion with no gate looks exactly like one that
///   passed every gate."*
/// * **`Warn` is what is left, and it is honest.** It changes no promotion
///   outcome on any existing estate, and it makes the absence LOUD: every hop
///   logs, by name and by frozen code, either `holger-attest-11` (this server
///   declares no trust root, so it verified nothing) or `holger-promote-10`
///   (these bytes have no provenance under the root it does declare). An operator
///   reading their log learns the truth without a promotion being refused for a
///   policy they never chose.
///
/// Moving to `Require` is then one word in the config, and the log has already
/// told them exactly which artifacts will stop.
#[derive(serde::Serialize, serde::Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum ProvenancePolicy {
    /// An artifact with no verified provenance statement about its own digest is
    /// REFUSED, by name. With no declared trust root, every hop is refused with
    /// `holger-attest-11` — a `Require` that cannot verify is never a `Require`
    /// that passes.
    Require,
    /// The same finding, recorded and logged with the same frozen code, and the
    /// hop proceeds. **The default.** See the type docs for why.
    #[default]
    Warn,
    /// The operator has declared that this estate does not check provenance. The
    /// gate allows and says in its posture line that it was TOLD to.
    Off,
}

impl std::fmt::Display for ProvenancePolicy {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(match self {
            ProvenancePolicy::Require => "Require",
            ProvenancePolicy::Warn => "Warn",
            ProvenancePolicy::Off => "Off",
        })
    }
}

/// ★ **The gate that makes the declared trust root load-bearing.**
///
/// It asks one question of every member of a promotion: *is there, filed on this
/// server, an accepted provenance statement whose subject digest is THESE bytes?*
///
/// Everything in [`crate::attest::AttestStore`] got there through
/// [`crate::attest::AttestStore::accept`], which verifies the DSSE signature
/// against the operator-declared roots (`attestation.trust_roots`) and against
/// the enrolled signer before a byte is written. So "filed" already means
/// "chained to the declared root" — this gate does not re-run the cryptography,
/// it enforces the CONSEQUENCE, which is the half that had no caller.
///
/// # The case that matters most is the empty one
///
/// `declared_roots == 0` — no `attestation:` block, or a block with no roots —
/// means nothing could ever have been filed, so *every* artifact would fail the
/// question for a reason that has nothing to do with the artifact. Reporting that
/// as `holger-promote-10` per artifact would blame the wrong thing, so it is its
/// own code, `holger-attest-11`, raised once for the whole hop and naming the
/// config key to set. **It is never an allow under `Require`.**
pub struct ProvenanceGate {
    policy: ProvenancePolicy,
    declared_roots: usize,
    predicate_types: Vec<String>,
}

impl ProvenanceGate {
    /// The name a rung writes in its `gates` list.
    pub const NAME: &'static str = "provenance";

    /// The predicate types that count as provenance when the operator names none.
    /// SLSA v1 is what `nornir-build-thing` produces and what `attest.rs` already
    /// accepts by default, so this set and the store's default policy agree.
    pub fn default_predicate_types() -> Vec<String> {
        vec![crate::attest::SLSA_PROVENANCE_V1.to_string()]
    }

    pub fn new(policy: ProvenancePolicy, declared_roots: usize) -> Self {
        Self {
            policy,
            declared_roots,
            predicate_types: Self::default_predicate_types(),
        }
    }

    /// Override the predicate types this gate will accept as provenance.
    pub fn with_predicate_types(mut self, pts: Vec<String>) -> Self {
        if !pts.is_empty() {
            self.predicate_types = pts;
        }
        self
    }

    pub fn policy(&self) -> ProvenancePolicy {
        self.policy
    }

    pub fn declared_roots(&self) -> usize {
        self.declared_roots
    }

    /// **One sentence saying what this gate is actually doing on this box**, for
    /// the boot log and for the `Warn` path.
    ///
    /// A gate that is standing and verifying nothing must not print the same
    /// sentence as a gate that is standing and verifying. This is the sentence
    /// that tells them apart, and it carries the frozen code.
    pub fn posture(&self) -> String {
        match (self.policy, self.declared_roots) {
            (ProvenancePolicy::Off, _) => {
                "provenance gate: Off — this estate has DECLARED that it does not check \
                 provenance at the promotion boundary. This is a decision on the record, not \
                 an omission."
                    .to_string()
            }
            (p, 0) => format!(
                "{} provenance gate '{}' is standing and this server declares NO attestation \
                 trust root (`attestation.trust_roots` is absent or empty), so no statement \
                 can ever have been verified and the evidence plane is empty by construction. \
                 Policy is {p}: {}",
                ec::ATTEST_NO_TRUST_ROOT_DECLARED.bracketed(),
                Self::NAME,
                match p {
                    ProvenancePolicy::Require =>
                        "every promotion is REFUSED until a root is declared.",
                    _ => "promotions proceed and this line is the whole of the checking.",
                }
            ),
            (p, n) => format!(
                "provenance gate '{}' is standing against {n} declared trust root(s), policy \
                 {p}; accepted predicate type(s): {}",
                Self::NAME,
                self.predicate_types.join(", ")
            ),
        }
    }

    /// The refusals this gate WOULD raise for `ctx`, whatever the policy.
    ///
    /// Split out from [`Gate::check`] so that `Warn` records exactly what
    /// `Require` would have refused — the same sentences, in the same order, with
    /// the same codes. A warning that is not the refusal it stands in for is a
    /// warning nobody can act on.
    pub fn findings(&self, ctx: &PromotionContext<'_>) -> Vec<String> {
        if self.policy == ProvenancePolicy::Off {
            return Vec::new();
        }
        if self.declared_roots == 0 {
            return vec![format!(
                "{} {}",
                ec::ATTEST_NO_TRUST_ROOT_DECLARED.bracketed(),
                self.posture()
            )];
        }
        let mut out = Vec::new();
        for art in ctx.set {
            let has = art.attestations.iter().any(|s| {
                self.predicate_types.contains(&s.predicate_type) && s.attests(&art.digest)
            });
            if !has {
                out.push(format!(
                    "{} {}: no verified provenance statement about {} is filed on this \
                     server. The evidence plane is keyed by DIGEST and everything in it \
                     chained to a declared trust root before it was written, so this says \
                     these BYTES have no provenance here — not that the coordinate is \
                     unknown. Accepted predicate type(s): {}",
                    ec::PROMOTE_PROVENANCE_UNVERIFIED.bracketed(),
                    art.label(),
                    art.digest,
                    self.predicate_types.join(", ")
                ));
            }
        }
        out
    }
}

impl Gate for ProvenanceGate {
    fn name(&self) -> &str {
        Self::NAME
    }

    fn check(&self, ctx: &PromotionContext<'_>) -> anyhow::Result<Verdict> {
        let findings = self.findings(ctx);
        if findings.is_empty() {
            return Ok(Verdict::Allow);
        }
        match self.policy {
            ProvenancePolicy::Off => Ok(Verdict::Allow),
            ProvenancePolicy::Warn => {
                // The hop proceeds. It does NOT proceed quietly: every sentence
                // `Require` would have refused with is on the record, by code.
                for f in &findings {
                    log::warn!("provenance (Warn, not refusing): {f}");
                }
                Ok(Verdict::Allow)
            }
            ProvenancePolicy::Require => {
                if self.declared_roots == 0 {
                    // Not attributable to any one artifact: the server itself is
                    // unable to verify. Blaming an artifact here would send the
                    // operator to re-sign a build when the fix is one config key.
                    Ok(Verdict::reject_all(findings.join(" | ")))
                } else {
                    // Name the FIRST offending subject so a per-item report puts
                    // the reason on the right line; the rest ride in the reason.
                    let subject = ctx
                        .set
                        .iter()
                        .find(|a| {
                            !a.attestations.iter().any(|s| {
                                self.predicate_types.contains(&s.predicate_type)
                                    && s.attests(&a.digest)
                            })
                        })
                        .map(|a| a.label())
                        .unwrap_or_else(|| ctx.to_repo.to_string());
                    Ok(Verdict::reject(subject, findings.join(" | ")))
                }
            }
        }
    }
}

/// The gates THIS BUILD carries, by name.
///
/// A catalog is built once from the configuration and the compiled features. It
/// is what makes an unknown gate name a boot error that can say what was
/// available, rather than a silent no-op.
#[derive(Default, Clone)]
pub struct GateCatalog {
    gates: BTreeMap<String, Arc<dyn Gate>>,
}

impl GateCatalog {
    pub fn new() -> Self {
        Self::default()
    }

    /// Register a gate under its own name.
    pub fn with(mut self, gate: Arc<dyn Gate>) -> Self {
        self.gates.insert(gate.name().to_string(), gate);
        self
    }

    /// Every gate name this build carries, sorted.
    pub fn available(&self) -> Vec<&str> {
        self.gates.keys().map(String::as_str).collect()
    }

    /// Resolve a configured list of gate names.
    ///
    /// **HOL-GUN-007.** An unknown name is an error, never a skip. The message
    /// names the gate and lists what this build carries, because the operator's
    /// next question is always "then what could I have written".
    pub fn resolve(&self, names: &[String]) -> Result<GateSet, GateConfigError> {
        let mut gates = Vec::with_capacity(names.len());
        for n in names {
            match self.gates.get(n) {
                Some(g) => gates.push(g.clone()),
                None => {
                    return Err(GateConfigError {
                        named: n.clone(),
                        available: self.available().iter().map(|s| s.to_string()).collect(),
                    })
                }
            }
        }
        Ok(GateSet {
            gates,
            enforcement: Enforcement::Block,
        })
    }
}

impl std::fmt::Debug for GateCatalog {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("GateCatalog")
            .field("available", &self.available())
            .finish()
    }
}

/// A gate name the configuration asked for and this build does not carry.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GateConfigError {
    pub named: String,
    pub available: Vec<String>,
}

impl std::fmt::Display for GateConfigError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(
            f,
            "promotion gate '{}' is named by the configuration and this build carries no gate by \
             that name. A rung that names a gate the build does not have would otherwise run with \
             NO gate, and a promotion with no gate looks exactly like one that passed every gate. \
             This build carries: {}",
            self.named,
            if self.available.is_empty() {
                "no gates at all".to_string()
            } else {
                self.available.join(", ")
            }
        )
    }
}

impl std::error::Error for GateConfigError {}

/// The resolved gates for one rung, and how hard they bite.
#[derive(Clone)]
pub struct GateSet {
    gates: Vec<Arc<dyn Gate>>,
    enforcement: Enforcement,
}

impl Default for GateSet {
    fn default() -> Self {
        Self {
            gates: Vec::new(),
            enforcement: Enforcement::Block,
        }
    }
}

impl GateSet {
    /// No gates. Distinct from "gates that all allowed", and
    /// [`Decision::summary`] says so.
    pub fn empty() -> Self {
        Self::default()
    }

    /// **Add one gate that was not named in the configuration.**
    ///
    /// The one caller is the meter plane: a meter guest is not a gate an
    /// operator spells in a `gates:` list, it is a module they installed and
    /// granted, and that installation IS the decision. A loaded meter that is
    /// never consulted is a meter that is not standing — so it is appended here
    /// rather than waiting to be named.
    ///
    /// Appended, never substituted: the configured gates keep their order and
    /// their meaning, and `names()` shows the meter beside them.
    pub fn with(mut self, gate: Arc<dyn Gate>) -> Self {
        self.gates.push(gate);
        self
    }

    /// `Warn` records the decision and permits the hop; `Block` refuses.
    pub fn with_enforcement(mut self, e: Enforcement) -> Self {
        self.enforcement = e;
        self
    }

    pub fn enforcement(&self) -> Enforcement {
        self.enforcement
    }

    pub fn is_empty(&self) -> bool {
        self.gates.is_empty()
    }

    /// The gate names, in order. Exposed so a surface can answer "what is
    /// guarding this rung" — a server whose gates failed to load must not look
    /// like one that has none.
    pub fn names(&self) -> Vec<&str> {
        self.gates.iter().map(|g| g.name()).collect()
    }

    /// Run every gate over the whole promoted set and collect one [`Decision`].
    ///
    /// Every gate runs even after one refuses, and an `Err` from a gate becomes
    /// a refusal marked `undecided`. Both rules live in `nornir_gate::evaluate`,
    /// shared with gunnar.
    pub fn evaluate(&self, ctx: &PromotionContext<'_>) -> Decision {
        nornir_gate::evaluate(self.gates.iter().map(|g| {
            (
                g.name().to_string(),
                g.check(ctx).map_err(|e| format!("{e:#}")),
            )
        }))
    }
}

impl std::fmt::Debug for GateSet {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("GateSet")
            .field("gates", &self.names())
            .field("enforcement", &self.enforcement)
            .finish()
    }
}

/// The modgunn scanner an [`OsvGate`] is built over, stating the whole licence
/// policy (the gate hands it on to the licence scanner) and ignoring
/// provenance (its own gate).
#[cfg(feature = "scanners")]
pub fn osv_scanner_for(license: LicensePolicy, advisory_db: security::AdvisoryDb) -> OsvScanner {
    let mut policy = security::promotion_policy_for(
        license.deny,
        license.unknown,
        security::ProvenancePolicy::Ignore,
    );
    policy.license.allow = license.allow;
    OsvScanner::new(policy, advisory_db)
}

/// Build the catalog from a promotion configuration.
///
/// `deny_licenses` and `advisory_db` are what the old three environment
/// variables carried; they now arrive from the RON `promotion` block, which is a
/// surface an appliance operator can actually reach.
/// ★ `provenance` is registered **unconditionally**, on every row, whatever the
/// policy. That is the point of W17. Registering it only when it was configured
/// would reproduce the defect it exists to close: a rung that named `provenance`
/// would be a boot failure on a build that could have carried it, and an operator
/// reading `available()` would be told the gate does not exist rather than that
/// it is standing and set to `Warn`.
#[cfg(feature = "scanners")]
pub fn catalog_from_config(
    advisory_db: security::AdvisoryDb,
    license: LicensePolicy,
    attested_predicate: Option<String>,
    provenance: ProvenancePolicy,
    declared_trust_roots: usize,
) -> GateCatalog {
    let scanner = osv_scanner_for(license, advisory_db);
    let mut cat = GateCatalog::new()
        .with(Arc::new(OsvGate::new(scanner)))
        .with(Arc::new(ProvenanceGate::new(
            provenance,
            declared_trust_roots,
        )));
    if let Some(pt) = attested_predicate {
        cat = cat.with(Arc::new(AttestedGate::new(AttestedGate::NAME, pt)));
    }
    cat
}

/// The catalog of a build WITHOUT scanners (the downstream, H3): no `osv` gate,
/// since nothing here matches advisories; the provenance gate and the attested
/// gate stand as on every row.
#[cfg(not(feature = "scanners"))]
pub fn catalog_from_config(
    attested_predicate: Option<String>,
    provenance: ProvenancePolicy,
    declared_trust_roots: usize,
) -> GateCatalog {
    let mut cat = GateCatalog::new().with(Arc::new(ProvenanceGate::new(
        provenance,
        declared_trust_roots,
    )));
    if let Some(pt) = attested_predicate {
        cat = cat.with(Arc::new(AttestedGate::new(AttestedGate::NAME, pt)));
    }
    cat
}

/// The scan worker cell of a build without scanners: never filled.
#[cfg(not(feature = "scanners"))]
pub type WorkerCell = Arc<std::sync::OnceLock<()>>;

#[cfg(all(test, feature = "scanners"))]
mod tests {
    use super::*;

    fn id() -> ArtifactId {
        ArtifactId {
            namespace: None,
            name: "serde".into(),
            version: "1.0.0".into(),
        }
    }

    struct Always(&'static str, bool);
    impl Gate for Always {
        fn name(&self) -> &str {
            self.0
        }
        fn check(&self, _: &PromotionContext<'_>) -> anyhow::Result<Verdict> {
            if self.1 {
                Ok(Verdict::Allow)
            } else {
                Ok(Verdict::reject("x", "no"))
            }
        }
    }
    struct Broken;
    impl Gate for Broken {
        fn name(&self) -> &str {
            "broken"
        }
        fn check(&self, _: &PromotionContext<'_>) -> anyhow::Result<Verdict> {
            anyhow::bail!("the advisory database could not be read")
        }
    }

    fn ctx<'a>(id: &'a ArtifactId, set: &'a [PromotedArtifact<'a>]) -> PromotionContext<'a> {
        let _ = id;
        PromotionContext {
            principal: "tester",
            from_repo: "dev",
            to_repo: "prod",
            hop: None,
            set,
        }
    }

    fn one<'a>(id: &'a ArtifactId, bytes: &'a [u8], att: &'a [Statement]) -> PromotedArtifact<'a> {
        PromotedArtifact {
            id,
            ecosystem: "crates.io",
            digest: ArtifactDigest::of_sha256(bytes),
            bytes,
            attestations: att,
        }
    }

    /// HOL-GUN-007: a rung naming a gate the build does not carry is an error,
    /// and the error says what the build DOES carry.
    #[test]
    fn an_unknown_gate_name_is_refused_and_lists_what_exists() {
        let cat = GateCatalog::new().with(Arc::new(Always("osv", true)));
        // The probe used to be "provenance", back when no build carried such a
        // gate. It does now, so the example moved to a name that is not a gate
        // and is not going to become one — the assertion is about the REFUSAL,
        // not about which particular name is missing.
        let err = cat.resolve(&["sorcery".to_string()]).unwrap_err();
        assert_eq!(err.named, "sorcery");
        let msg = err.to_string();
        assert!(msg.contains("osv"), "{msg}");
        assert!(msg.contains("looks exactly like one that passed"), "{msg}");
    }

    /// ★ The other direction, and the one W17 is about: the catalog a real
    /// configuration produces CARRIES `provenance`, so a rung may name it on any
    /// shipped row. Before 2026-09-21 this assertion was false on all of them.
    #[test]
    fn the_configured_catalog_carries_the_provenance_gate() {
        let cat = catalog_from_config(
            security::AdvisoryDb::default(),
            LicensePolicy::default(),
            None,
            ProvenancePolicy::default(),
            0,
        );
        assert!(
            cat.available().contains(&ProvenanceGate::NAME),
            "catalog_from_config must register the provenance gate unconditionally; it \
             carries {:?}",
            cat.available()
        );
        // And it resolves, which is the thing an operator's `gates:` list does.
        let set = cat
            .resolve(&[ProvenanceGate::NAME.to_string()])
            .expect("a rung may name `provenance` on any build");
        assert_eq!(set.names(), vec![ProvenanceGate::NAME]);
        // Registered even with NO attestation block and NO predicate configured:
        // the gate's presence is a property of the build, its verdict a property
        // of the configuration. Those are two different questions.
        assert!(!cat.available().contains(&AttestedGate::NAME));
    }

    /// A gate that cannot decide refuses, and is marked undecided so an operator
    /// can tell a broken server from a violated rule.
    #[test]
    fn a_gate_that_errors_refuses_and_is_undecided() {
        let cat = GateCatalog::new().with(Arc::new(Broken));
        let set = cat.resolve(&["broken".to_string()]).unwrap();
        let a = id();
        let arts = [one(&a, b"bytes", &[])];
        let d = set.evaluate(&ctx(&a, &arts));
        assert!(!d.is_allowed());
        assert!(d.undecided());
    }

    /// Every gate runs; one refusal does not hide the next.
    #[test]
    fn every_gate_runs_and_the_names_are_recorded() {
        let cat = GateCatalog::new()
            .with(Arc::new(Always("a", false)))
            .with(Arc::new(Always("b", false)));
        let set = cat.resolve(&["a".to_string(), "b".to_string()]).unwrap();
        let a = id();
        let arts = [one(&a, b"bytes", &[])];
        let d = set.evaluate(&ctx(&a, &arts));
        assert_eq!(d.rejections.len(), 2);
        assert_eq!(d.gates_run, vec!["a", "b"]);
        assert_eq!(set.names(), vec!["a", "b"]);
    }

    /// A gate set judges an INGEST with the same context and the same rules:
    /// the bytes are present, the digest is present, and there is no rung.
    #[test]
    fn a_gate_runs_on_ingest_as_well_as_on_promotion() {
        let cat = GateCatalog::new().with(Arc::new(Always("a", false)));
        let set = cat.resolve(&["a".to_string()]).unwrap();
        let a = id();
        let arts = [one(&a, b"bytes", &[])];
        let ctx = PromotionContext::ingest("uploader", "rust-dev", &arts);
        assert!(ctx.is_ingest());
        let d = set.evaluate(&ctx);
        assert!(
            !d.is_allowed(),
            "an ingest gate must be able to refuse an upload"
        );
    }

    /// No gates is reported as no gates, not as a pass.
    #[test]
    fn an_empty_gate_set_says_none_configured() {
        let set = GateSet::empty();
        let a = id();
        let arts = [one(&a, b"bytes", &[])];
        let d = set.evaluate(&ctx(&a, &arts));
        assert!(d.is_allowed());
        assert!(d.summary().contains("none configured"), "{}", d.summary());
    }

    /// The digest reaches the scan seam. Two different byte strings under the
    /// SAME coordinate must produce two different subjects, which is exactly
    /// what the coordinate-only call could not do.
    #[test]
    fn two_uploads_of_one_coordinate_are_two_subjects() {
        let a = id();
        let x = one(&a, b"one", &[]);
        let y = one(&a, b"two", &[]);
        assert_ne!(x.digest.hex(), y.digest.hex());
        let sx = security::artifact_from_coords_with_digest(
            x.ecosystem,
            &a.name,
            &a.version,
            x.digest.hex(),
            false,
        );
        let sy = security::artifact_from_coords_with_digest(
            y.ecosystem,
            &a.name,
            &a.version,
            y.digest.hex(),
            false,
        );
        assert_ne!(sx.artifact.blob_sha256, sy.artifact.blob_sha256);
        assert!(!sx.artifact.blob_sha256.is_empty());
    }

    /// The evidence gate refuses when the evidence plane holds nothing for THESE
    /// bytes, and says that is what it means.
    #[test]
    fn the_attested_gate_refuses_an_artifact_with_no_evidence() {
        let cat = GateCatalog::new().with(Arc::new(AttestedGate::new(
            "attested",
            "https://slsa.dev/provenance/v1",
        )));
        let set = cat.resolve(&["attested".to_string()]).unwrap();
        let a = id();
        let arts = [one(&a, b"bytes", &[])];
        let d = set.evaluate(&ctx(&a, &arts));
        assert!(!d.is_allowed());
        assert!(d.summary().contains("evidence plane"), "{}", d.summary());
    }

    // ── B2: the licence the PACKAGE declares is what the policy judges ───────
    //
    // Before the fix the gate built its subject with `license: None` for every
    // artifact, and the promotion policy passed an unknown licence. So
    // `deny_licenses` could never block a real package: a GPL-3.0 wheel and a
    // GPL-3.0 nupkg both promoted clean.

    fn wheel(license_expression: Option<&str>) -> Vec<u8> {
        let mut meta = String::from("Metadata-Version: 2.4\nName: thing\nVersion: 1.0.0\n");
        if let Some(l) = license_expression {
            meta.push_str(&format!("License-Expression: {l}\n"));
        }
        meta.push_str("\nThe long description, which is not a header.\nLicense-Expression: MIT\n");
        zip_entry::fixture::zip(&[
            ("thing/__init__.py", b"".as_slice()),
            ("thing-1.0.0.dist-info/METADATA", meta.as_bytes()),
            (
                "thing-1.0.0.dist-info/WHEEL",
                b"Wheel-Version: 1.0\n".as_slice(),
            ),
        ])
    }

    fn nupkg(license_expression: &str) -> Vec<u8> {
        let xml = format!(
            "<?xml version=\"1.0\"?><package><metadata><id>Thing</id><version>1.0.0</version>\
             <authors>a</authors><description>d</description>\
             <license type=\"expression\">{license_expression}</license></metadata></package>"
        );
        zip_entry::fixture::zip(&[
            ("Thing.nuspec", xml.as_bytes()),
            ("lib/net8.0/Thing.dll", b"MZ".as_slice()),
        ])
    }

    fn osv_denying(deny: &[&str]) -> GateSet {
        let scanner = OsvScanner::new(
            security::promotion_policy(deny.iter().map(|s| s.to_string()).collect()),
            security::AdvisoryDb::default(),
        );
        GateCatalog::new()
            .with(Arc::new(OsvGate::new(scanner)))
            .resolve(&[OsvGate::NAME.to_string()])
            .unwrap()
    }

    fn judge(set: &GateSet, ecosystem: &str, bytes: &[u8]) -> nornir_gate::Decision {
        let a = id();
        let arts = [PromotedArtifact {
            id: &a,
            ecosystem,
            digest: ArtifactDigest::of_sha256(bytes),
            bytes,
            attestations: &[],
        }];
        set.evaluate(&ctx(&a, &arts))
    }

    fn reasons(d: &nornir_gate::Decision) -> String {
        format!("{d:?}")
    }

    #[test]
    fn a_denied_licence_in_wheel_metadata_blocks_the_promotion() {
        let d = judge(&osv_denying(&["GPL-3.0"]), "pip", &wheel(Some("GPL-3.0")));
        assert!(
            !d.is_allowed(),
            "a GPL-3.0 wheel promoted past deny_licenses: {d:?}"
        );
        assert!(
            reasons(&d).contains("GPL-3.0"),
            "the refusal must name the licence: {d:?}"
        );
    }

    #[test]
    fn a_denied_licence_hidden_in_an_and_expression_blocks_too() {
        let d = judge(
            &osv_denying(&["GPL-3.0"]),
            "pip",
            &wheel(Some("MIT AND GPL-3.0")),
        );
        assert!(!d.is_allowed(), "{d:?}");
    }

    #[test]
    fn a_denied_licence_in_a_nuspec_blocks_the_promotion() {
        let d = judge(&osv_denying(&["GPL-3.0"]), "nuget", &nupkg("GPL-3.0"));
        assert!(
            !d.is_allowed(),
            "a GPL-3.0 nupkg promoted past deny_licenses: {d:?}"
        );
        assert!(reasons(&d).contains("GPL-3.0"), "{d:?}");
    }

    #[test]
    fn an_allowed_declared_licence_promotes() {
        let set = osv_denying(&["GPL-3.0"]);
        assert!(judge(&set, "pip", &wheel(Some("MIT"))).is_allowed());
        assert!(judge(&set, "nuget", &nupkg("Apache-2.0")).is_allowed());
    }

    /// S6: the gate applies the licence SCANNER's policy, allow list included,
    /// so the configured catalog judges a package exactly as the licence slot
    /// does.
    #[test]
    fn the_configured_gate_applies_the_allow_and_deny_lists() {
        let set = catalog_from_config(
            security::AdvisoryDb::default(),
            LicensePolicy::new(
                vec!["MIT".into()],
                vec!["GPL-3.0".into()],
                security::UnknownLicense::default(),
            ),
            None,
            ProvenancePolicy::default(),
            0,
        )
        .resolve(&[OsvGate::NAME.to_string()])
        .unwrap();
        assert!(
            judge(&set, "pip", &wheel(Some("MIT OR GPL-3.0"))).is_allowed(),
            "an allowed OR branch passes"
        );
        let d = judge(&set, "pip", &wheel(Some("MIT AND GPL-3.0")));
        assert!(!d.is_allowed() && reasons(&d).contains("GPL-3.0"), "{d:?}");
        let d = judge(&set, "pip", &wheel(Some("Apache-2.0")));
        assert!(
            !d.is_allowed() && reasons(&d).contains("allow list"),
            "outside the allow list: {d:?}"
        );
        let d = judge(&set, "pip", &wheel(Some("Not An SPDX Id")));
        assert!(
            !d.is_allowed() && reasons(&d).contains("held for review"),
            "{d:?}"
        );
    }

    /// The default for a package that declares no licence is to HOLD it for
    /// review: the hop is refused and the refusal says why. It never passes
    /// silently, with or without a deny list.
    #[test]
    fn an_unknown_licence_is_held_for_review_by_default() {
        for deny in [&["GPL-3.0"][..], &[][..]] {
            let set = osv_denying(deny);
            for (eco, bytes) in [
                ("pip", wheel(None)),
                ("cargo", b"not an archive at all".to_vec()),
            ] {
                let d = judge(&set, eco, &bytes);
                assert!(
                    !d.is_allowed(),
                    "{eco}: an unknown licence passed silently: {d:?}"
                );
                let r = reasons(&d);
                assert!(
                    r.contains("held for review") && r.contains("licen"),
                    "{eco}: {r}"
                );
            }
        }
    }

    // ── W5: the `osv` gate reads the stored evidence back ──

    /// A vulnerability scanner with a fixed verdict that counts its calls.
    struct Counted(ScanVerdictFixed, Arc<std::sync::atomic::AtomicUsize>);
    #[derive(Clone, Copy)]
    struct ScanVerdictFixed(crate::scanner::ScanVerdict);

    impl crate::scanner::Scanner for Counted {
        fn id(&self) -> &str {
            "counted"
        }
        fn version(&self) -> String {
            "1".into()
        }
        fn kind(&self) -> crate::scanner::ScannerKind {
            crate::scanner::ScannerKind::Vulnerability
        }
        fn scan(
            &self,
            _: &crate::scanner::ScanSubject<'_>,
        ) -> anyhow::Result<crate::scanner::ScanOutcome> {
            use crate::scanner::{FindingSeverity, ScanFinding, ScanOutcome, ScanVerdict};
            self.1.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
            Ok(match self.0 .0 {
                ScanVerdict::Block => ScanOutcome::block(
                    vec![ScanFinding::new(
                        "RUSTSEC-0000-0001",
                        FindingSeverity::Critical,
                        "stored finding",
                    )],
                    "blocked",
                ),
                _ => ScanOutcome::pass("clean"),
            })
        }
    }

    #[test]
    fn the_osv_gate_reuses_fresh_stored_evidence_and_audits_it() {
        use crate::scanner::{EvidenceStore, ScanPipeline, ScanSubject, ScanVerdict, ScannerSet};
        let tmp = tempfile::tempdir().unwrap();
        let audit = Arc::new(crate::audit::MemoryAuditLog::new());
        let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
        let set = ScannerSet::new().with(Arc::new(Counted(
            ScanVerdictFixed(ScanVerdict::Block),
            calls.clone(),
        )));
        let pipeline = Arc::new(ScanPipeline::new(
            set,
            EvidenceStore::new(tmp.path(), audit.clone()),
        ));
        let bad = b"bytes a scanner blocked".to_vec();
        pipeline
            .scan(
                "importer",
                "incoming",
                &ScanSubject::new("serde-1.0.0.crate", "crates.io", &bad),
            )
            .unwrap();
        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);

        let cell: EvidenceCell = Arc::new(std::sync::OnceLock::new());
        let _ = cell.set(pipeline);
        // The gate's own advisory database is EMPTY: only the stored record can block.
        let gate = OsvGate::new(OsvScanner::new(
            security::promotion_policy_for(
                Vec::new(),
                security::UnknownLicense::Pass,
                security::ProvenancePolicy::Ignore,
            ),
            security::AdvisoryDb::default(),
        ))
        .with_evidence(cell);
        let id = id();
        let set = [one(&id, &bad, &[])];
        let v = gate.check(&ctx(&id, &set)).unwrap();
        let reason = format!("{v:?}");
        assert!(
            !matches!(v, Verdict::Allow),
            "the stored block decides: {reason}"
        );
        assert!(
            reason.contains("stored scan evidence") && reason.contains("RUSTSEC-0000-0001"),
            "{reason}"
        );
        assert_eq!(
            calls.load(std::sync::atomic::Ordering::SeqCst),
            1,
            "no scanner ran for the reuse"
        );
        let reused: Vec<_> = audit
            .events()
            .into_iter()
            .filter(|e| e.detail.contains("reused=true"))
            .collect();
        assert_eq!(reused.len(), 1, "the reuse is on the trail");
        assert!(reused[0].detail.contains("by=osv"), "{}", reused[0].detail);

        // Bytes with no stored record: the gate scans as before (and allows here).
        let other = b"never scanned".to_vec();
        let set = [one(&id, &other, &[])];
        assert!(matches!(
            gate.check(&ctx(&id, &set)).unwrap(),
            Verdict::Allow
        ));
        assert_eq!(
            audit
                .events()
                .iter()
                .filter(|e| e.detail.contains("reused=true"))
                .count(),
            1
        );
    }
}