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/scanner/license.rs

//! The licence scanner: SPDX detection from a package's own metadata, and the
//! licence policy (lane S6).
//!
//! This file is the ONE licence code path. The scanner framework runs it in the
//! licence slot ([`LicenseScanner`]), and the promotion gate
//! (`promote::gate::OsvGate`) calls the same [`LicenseScanner::judge`] on the
//! same bytes, so a package can never pass one and fail the other.
//!
//! ## What is read
//!
//! The package is recognised by its bytes (zip, gzip tarball, plain tar, `ar`,
//! XML); the ecosystem only breaks a tie. Every archive is read with bounded
//! members and a bounded total, so a hostile package cannot make the scan
//! inflate without end.
//!
//! | format | where the licence is |
//! |---|---|
//! | wheel | `*.dist-info/METADATA`: `License-Expression`, then `License`, then the `License ::` classifiers |
//! | sdist | `<dir>/PKG-INFO`, the same fields in the same order |
//! | npm | `package/package.json`: `license` (or the legacy `licenses` list) |
//! | cargo | `<name>-<version>/Cargo.toml` inside the `.crate`: `package.license` |
//! | nuget | the root `.nuspec`: `<license type="expression">`, else a `licenses.nuget.org` URL |
//! | maven | the `.pom`, or `META-INF/maven/**/pom.xml` in a jar: `<licenses>` |
//! | gem | `metadata.gz`, the gemspec YAML: `licenses:` |
//! | deb | `usr/share/doc/<pkg>/copyright` in `data.tar.*`, when it is machine-readable (DEP-5). Best effort. |
//! | helm | `<chart>/Chart.yaml`: the `artifacthub.io/license` or `licenses` annotation. Best effort. |
//!
//! Wheel METADATA and sdist PKG-INFO go through [`super::wheel`], the one
//! core-metadata reader.
//!
//! ## What a declaration becomes
//!
//! A declared string must parse as an SPDX expression (`AND`, `OR`, `WITH`,
//! parentheses, `+`) over identifiers on the SPDX licence list or `LicenseRef-`.
//! The parse is the `spdx` crate's, strict except that `/` means `OR`,
//! deprecated ids (`GPL-3.0`) are accepted and an id's case is repaired. The
//! crate's "imprecise name" guessing is OFF: it maps by prefix, which would turn
//! "BSD 3-Clause" into BSD-2-Clause. Free-text names (trove classifiers, Maven
//! `<name>`, DEP-5 short names) go through the exact tables in this file
//! instead. A name that is in no table is unknown.
//!
//! A list of licences is combined with `OR` only where the format says the
//! licensee may choose (Maven). Everywhere else (classifiers, gem `licenses`, the
//! npm legacy list, DEP-5 paragraphs) a list is read conservatively, with `AND`.
//!
//! ## The policy
//!
//! See [`LicensePolicy`]. In short: an `OR` passes when any branch passes, an
//! `AND` needs every branch, a denied id never passes, and an unknown or
//! unparseable licence follows [`UnknownLicense`], which defaults to HOLD.

use std::io::Read;

use serde::{Deserialize, Serialize};

use super::wheel::{self, WheelMetadata};
use super::{FindingSeverity, ScanFinding, ScanOutcome, ScanSubject, Scanner, ScannerKind};

/// The most bytes one metadata member may inflate to.
const MAX_MEMBER_BYTES: usize = wheel::MAX_METADATA_BYTES;
/// The most bytes a tar walk may inflate in total, members it skips included.
const MAX_UNPACKED_BYTES: u64 = 1 << 30;
/// A declaration longer than this is a licence TEXT, not an expression.
const MAX_DECLARED_LEN: usize = 4096;

/// The finding a denied licence produces.
pub const FINDING_DENIED: &str = "holger-license-denied";
/// The finding a licence outside a non-empty allow list produces.
pub const FINDING_NOT_ALLOWED: &str = "holger-license-not-allowed";
/// The finding an unknown or unparseable licence produces.
pub const FINDING_UNKNOWN: &str = "holger-license-unknown";
/// The finding an allowed licence produces (informational).
pub const FINDING_DECLARED: &str = "holger-license";

// ── the stance on an unknown licence ────────────────────────────────────────

/// **What the licence policy does with a package whose licence is unknown**:
/// it declares none, declares one this build cannot read, or declares one that
/// does not parse as SPDX.
///
/// The default is [`UnknownLicense::Hold`]: the evidence warns and holds the
/// package, and the promotion gate refuses the hop as "held for review". An
/// unknown licence never passes silently; passing it is an operator's explicit
/// `unknown_license: Pass`.
#[derive(Serialize, Deserialize, Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum UnknownLicense {
    /// Warn and hold for review. The default.
    #[default]
    Hold,
    /// Block, like a denied licence.
    Block,
    /// Let it through. Only ever an explicit choice.
    Pass,
}

impl UnknownLicense {
    /// The modgunn decision that realises this stance. `Hold` is `Warn`.
    pub fn decision(self) -> crate::security::Decision {
        use crate::security::Decision;
        match self {
            UnknownLicense::Hold => Decision::Warn,
            UnknownLicense::Block => Decision::Block,
            UnknownLicense::Pass => Decision::Pass,
        }
    }

    /// The stance a modgunn `on_unknown` decision stands for.
    pub fn from_decision(d: crate::security::Decision) -> Self {
        use crate::security::Decision;
        match d {
            Decision::Warn => UnknownLicense::Hold,
            Decision::Block => UnknownLicense::Block,
            Decision::Pass => UnknownLicense::Pass,
        }
    }
}

// ── the policy ──────────────────────────────────────────────────────────────

/// ★ The licence policy: allow and deny lists of SPDX ids, and the stance on an
/// unknown licence.
///
/// * A denied id never passes. An id on both lists is denied.
/// * With a non-empty `allow`, an id must be on it; with an empty `allow`, any
///   id that is not denied passes.
/// * `A OR B` passes when either branch passes (the licensee picks it), so
///   `MIT OR GPL-3.0` passes when MIT is allowed, even with GPL-3.0 denied.
/// * `A AND B` needs both, so `MIT AND GPL-3.0` blocks when GPL-3.0 is denied.
/// * `A WITH E` is judged as `A`; a list entry that spells the whole
///   `A WITH E` also matches it.
/// * Ids match without regard to case, and a version suffix does not split a
///   family: `GPL-3.0`, `GPL-3.0-only`, `GPL-3.0-or-later` and `GPL-3.0+` are
///   one entry.
/// * An unknown or unparseable licence follows `unknown`.
///
/// The verdict on the evidence: denied or outside the allow list → `block`;
/// unknown → `warn` plus a hold under `Hold`, `block` under `Block`, `pass`
/// under `Pass`.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct LicensePolicy {
    #[serde(default)]
    pub allow: Vec<String>,
    #[serde(default)]
    pub deny: Vec<String>,
    #[serde(default)]
    pub unknown: UnknownLicense,
}

impl LicensePolicy {
    pub fn new(allow: Vec<String>, deny: Vec<String>, unknown: UnknownLicense) -> Self {
        Self {
            allow,
            deny,
            unknown,
        }
    }

    /// The same policy that a modgunn `LicensePolicy` states.
    pub fn from_modgunn(p: &crate::security::LicensePolicy) -> Self {
        Self::new(
            p.allow.clone(),
            p.deny.clone(),
            UnknownLicense::from_decision(p.on_unknown),
        )
    }

    /// One line naming the policy, carried in the scanner version.
    pub fn summary(&self) -> String {
        format!(
            "allow {:?}, deny {:?}, unknown {:?}",
            self.allow, self.deny, self.unknown
        )
    }
}

/// The comparison key of an id: lowercase, without `+`, `-only` or `-or-later`.
fn family_key(id: &str) -> String {
    let lower = id.trim().to_ascii_lowercase();
    let lower = lower.trim_end_matches('+');
    for suffix in ["-or-later", "-only"] {
        if let Some(base) = lower.strip_suffix(suffix) {
            return base.to_string();
        }
    }
    lower.to_string()
}

fn on_list(list: &[String], req: &spdx::LicenseReq) -> bool {
    let id = family_key(&req.license.to_string());
    let whole = req.to_string();
    list.iter()
        .any(|e| family_key(e) == id || e.trim().eq_ignore_ascii_case(&whole))
}

/// What the policy concluded about one declaration.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case", tag = "verdict", content = "ids")]
pub enum LicenseVerdict {
    /// The expression is satisfiable with allowed ids.
    Allowed,
    /// It is not, and these denied ids are why.
    Denied(Vec<String>),
    /// It is not, and these ids are outside the allow list.
    NotAllowed(Vec<String>),
    /// No licence was declared, or it does not parse.
    Unknown,
}

/// Judge one parsed expression.
pub fn judge_expression(policy: &LicensePolicy, expr: &spdx::Expression) -> LicenseVerdict {
    let denied = |r: &spdx::LicenseReq| on_list(&policy.deny, r);
    let allowed =
        |r: &spdx::LicenseReq| !denied(r) && (policy.allow.is_empty() || on_list(&policy.allow, r));
    if expr.evaluate(allowed) {
        return LicenseVerdict::Allowed;
    }
    let mut d: Vec<String> = Vec::new();
    let mut n: Vec<String> = Vec::new();
    for er in expr.requirements() {
        let name = er.req.to_string();
        let into = if denied(&er.req) {
            &mut d
        } else if !allowed(&er.req) {
            &mut n
        } else {
            continue;
        };
        if !into.contains(&name) {
            into.push(name);
        }
    }
    if d.is_empty() {
        LicenseVerdict::NotAllowed(n)
    } else {
        LicenseVerdict::Denied(d)
    }
}

// ── SPDX parsing ────────────────────────────────────────────────────────────

/// Strict SPDX, plus `/` as `OR`, `+` on the GNU ids and deprecated ids. No
/// imprecise-name guessing, no unknown ids.
const PARSE_MODE: spdx::ParseMode = spdx::ParseMode {
    allow_slash_as_or_operator: true,
    allow_imprecise_license_names: false,
    allow_postfix_plus_on_gpl: true,
    allow_deprecated: true,
    allow_unknown: false,
};

/// Repair the case of every licence id in `s` (`mit` → `MIT`). Operators,
/// parentheses and `LicenseRef-` are left alone.
fn repair_case(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    let mut word = String::new();
    let flush = |word: &mut String, out: &mut String| {
        if !word.is_empty() {
            let (base, plus) = match word.strip_suffix('+') {
                Some(b) => (b, "+"),
                None => (word.as_str(), ""),
            };
            let fixed = spdx::identifiers::LICENSES
                .iter()
                .find(|l| l.name.eq_ignore_ascii_case(base))
                .map(|l| l.name)
                .or_else(|| {
                    spdx::identifiers::EXCEPTIONS
                        .iter()
                        .find(|e| e.name.eq_ignore_ascii_case(base))
                        .map(|e| e.name)
                });
            match fixed {
                Some(name) => {
                    out.push_str(name);
                    out.push_str(plus);
                }
                None => out.push_str(word),
            }
            word.clear();
        }
    };
    for c in s.chars() {
        if c.is_whitespace() || c == '(' || c == ')' || c == '/' {
            flush(&mut word, &mut out);
            out.push(c);
        } else {
            word.push(c);
        }
    }
    flush(&mut word, &mut out);
    out
}

/// Parse a declared string as an SPDX expression, or `None`.
pub fn parse_expression(declared: &str) -> Option<spdx::Expression> {
    let s = declared.trim();
    if s.is_empty() || s.len() > MAX_DECLARED_LEN {
        return None;
    }
    spdx::Expression::parse_mode(s, PARSE_MODE)
        .ok()
        .or_else(|| spdx::Expression::parse_mode(&repair_case(s), PARSE_MODE).ok())
}

/// The SPDX expression a declared string means, as a string, or `None`.
fn spdx_string(declared: &str) -> Option<String> {
    parse_expression(declared).map(|e| e.to_string())
}

/// Free-text licence names that mean exactly one SPDX id: trove classifier
/// names, Maven `<name>`s and Debian DEP-5 short names. Lowercase keys, exact
/// match. A name that is ambiguous ("BSD License", "GNU General Public License
/// (GPL)") is deliberately absent, so it reads as unknown.
const NAMES: &[(&str, &str)] = &[
    // trove classifiers (the last ` :: ` segment)
    ("mit license", "MIT"),
    ("mit no attribution license (mit-0)", "MIT-0"),
    ("apache software license", "Apache-2.0"),
    ("gnu general public license v2 (gplv2)", "GPL-2.0-only"),
    (
        "gnu general public license v2 or later (gplv2+)",
        "GPL-2.0-or-later",
    ),
    ("gnu general public license v3 (gplv3)", "GPL-3.0-only"),
    (
        "gnu general public license v3 or later (gplv3+)",
        "GPL-3.0-or-later",
    ),
    (
        "gnu lesser general public license v2 (lgplv2)",
        "LGPL-2.0-only",
    ),
    (
        "gnu lesser general public license v2 or later (lgplv2+)",
        "LGPL-2.0-or-later",
    ),
    (
        "gnu lesser general public license v3 (lgplv3)",
        "LGPL-3.0-only",
    ),
    (
        "gnu lesser general public license v3 or later (lgplv3+)",
        "LGPL-3.0-or-later",
    ),
    ("gnu affero general public license v3", "AGPL-3.0-only"),
    (
        "gnu affero general public license v3 or later (agplv3+)",
        "AGPL-3.0-or-later",
    ),
    ("mozilla public license 1.1 (mpl 1.1)", "MPL-1.1"),
    ("mozilla public license 2.0 (mpl 2.0)", "MPL-2.0"),
    ("isc license (iscl)", "ISC"),
    ("python software foundation license", "PSF-2.0"),
    ("the unlicense (unlicense)", "Unlicense"),
    ("zlib/libpng license", "Zlib"),
    ("eclipse public license 1.0 (epl-1.0)", "EPL-1.0"),
    ("eclipse public license 2.0 (epl-2.0)", "EPL-2.0"),
    ("european union public licence 1.2 (eupl 1.2)", "EUPL-1.2"),
    ("boost software license 1.0 (bsl-1.0)", "BSL-1.0"),
    ("universal permissive license (upl)", "UPL-1.0"),
    (
        "cc0 1.0 universal (cc0 1.0) public domain dedication",
        "CC0-1.0",
    ),
    // Maven <name>s as they are written in the wild
    ("the apache software license, version 2.0", "Apache-2.0"),
    ("the apache license, version 2.0", "Apache-2.0"),
    ("apache license, version 2.0", "Apache-2.0"),
    ("apache license version 2.0", "Apache-2.0"),
    ("apache license 2.0", "Apache-2.0"),
    ("apache 2.0", "Apache-2.0"),
    ("apache-2.0", "Apache-2.0"),
    ("the mit license", "MIT"),
    ("mit", "MIT"),
    ("the bsd 3-clause license", "BSD-3-Clause"),
    ("bsd 3-clause license", "BSD-3-Clause"),
    ("bsd-3-clause", "BSD-3-Clause"),
    ("new bsd license", "BSD-3-Clause"),
    ("revised bsd license", "BSD-3-Clause"),
    ("bsd 2-clause license", "BSD-2-Clause"),
    ("simplified bsd license", "BSD-2-Clause"),
    ("eclipse public license - v 1.0", "EPL-1.0"),
    ("eclipse public license v1.0", "EPL-1.0"),
    ("eclipse public license - v 2.0", "EPL-2.0"),
    ("eclipse public license v2.0", "EPL-2.0"),
    ("eclipse public license 2.0", "EPL-2.0"),
    ("mozilla public license 2.0", "MPL-2.0"),
    ("mpl 2.0", "MPL-2.0"),
    ("gnu lesser general public license v2.1", "LGPL-2.1-only"),
    ("gnu general public license v3.0", "GPL-3.0-only"),
    (
        "gnu general public license, version 2 with the classpath exception",
        "GPL-2.0-only WITH Classpath-exception-2.0",
    ),
    (
        "cddl + gplv2 with classpath exception",
        "CDDL-1.0 OR GPL-2.0-only WITH Classpath-exception-2.0",
    ),
    ("public domain, per creative commons cc0", "CC0-1.0"),
    // Debian DEP-5 short names
    ("expat", "MIT"),
    ("gpl-1", "GPL-1.0-only"),
    ("gpl-1+", "GPL-1.0-or-later"),
    ("gpl-2", "GPL-2.0-only"),
    ("gpl-2+", "GPL-2.0-or-later"),
    ("gpl-3", "GPL-3.0-only"),
    ("gpl-3+", "GPL-3.0-or-later"),
    ("lgpl-2", "LGPL-2.0-only"),
    ("lgpl-2+", "LGPL-2.0-or-later"),
    ("lgpl-2.1", "LGPL-2.1-only"),
    ("lgpl-2.1+", "LGPL-2.1-or-later"),
    ("lgpl-3", "LGPL-3.0-only"),
    ("lgpl-3+", "LGPL-3.0-or-later"),
    ("agpl-3", "AGPL-3.0-only"),
    ("agpl-3+", "AGPL-3.0-or-later"),
    ("apache-2", "Apache-2.0"),
    ("psf-2", "PSF-2.0"),
    ("boost-1.0", "BSL-1.0"),
];

/// Licence URLs that mean exactly one SPDX id (Maven `<url>`).
const URLS: &[(&str, &str)] = &[
    ("apache.org/licenses/license-2.0", "Apache-2.0"),
    ("opensource.org/licenses/mit", "MIT"),
    ("opensource.org/license/mit", "MIT"),
    ("opensource.org/licenses/bsd-3-clause", "BSD-3-Clause"),
    ("opensource.org/licenses/bsd-2-clause", "BSD-2-Clause"),
    ("eclipse.org/legal/epl-2.0", "EPL-2.0"),
    ("eclipse.org/legal/epl-v10", "EPL-1.0"),
    ("mozilla.org/mpl/2.0", "MPL-2.0"),
    ("gnu.org/licenses/gpl-3.0", "GPL-3.0-only"),
    ("gnu.org/licenses/lgpl-2.1", "LGPL-2.1-only"),
];

/// A free-text licence name as an SPDX expression: an expression already, or a
/// name from [`NAMES`].
fn name_to_spdx(name: &str) -> Option<String> {
    let n = name.trim();
    spdx_string(n).or_else(|| {
        let lower = n.to_ascii_lowercase();
        NAMES
            .iter()
            .find(|(k, _)| *k == lower)
            .map(|(_, v)| v.to_string())
    })
}

/// A licence URL as an SPDX id.
fn url_to_spdx(url: &str) -> Option<String> {
    let lower = url.trim().to_ascii_lowercase();
    let lower = lower
        .trim_start_matches("https://")
        .trim_start_matches("http://")
        .trim_start_matches("www.");
    let lower = lower
        .trim_end_matches('/')
        .trim_end_matches(".txt")
        .trim_end_matches(".html")
        .trim_end_matches(".php");
    URLS.iter()
        .find(|(k, _)| *k == lower)
        .map(|(_, v)| v.to_string())
}

/// Combine several SPDX expressions with `op`, or `None` when any is `None`.
fn combine(parts: Vec<Option<String>>, op: &str) -> Option<String> {
    let parts: Option<Vec<String>> = parts.into_iter().collect();
    let mut parts = parts?;
    parts.dedup();
    match parts.len() {
        0 => None,
        1 => parts.pop(),
        _ => Some(
            parts
                .iter()
                .map(|p| format!("({p})"))
                .collect::<Vec<_>>()
                .join(&format!(" {op} ")),
        ),
    }
}

// ── the declaration ─────────────────────────────────────────────────────────

/// Where a declaration was read.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum LicenseSource {
    /// A wheel's or an sdist's `License-Expression`.
    CoreMetadataLicenseExpression,
    /// A wheel's or an sdist's `License` field.
    CoreMetadataLicense,
    /// A wheel's or an sdist's `License ::` classifiers.
    CoreMetadataClassifier,
    NpmPackageJson,
    CargoToml,
    Nuspec,
    MavenPom,
    Gemspec,
    DebCopyright,
    HelmChart,
}

impl LicenseSource {
    pub fn as_str(self) -> &'static str {
        match self {
            LicenseSource::CoreMetadataLicenseExpression => "License-Expression",
            LicenseSource::CoreMetadataLicense => "License",
            LicenseSource::CoreMetadataClassifier => "License classifiers",
            LicenseSource::NpmPackageJson => "package.json license",
            LicenseSource::CargoToml => "Cargo.toml license",
            LicenseSource::Nuspec => "nuspec license",
            LicenseSource::MavenPom => "pom licenses",
            LicenseSource::Gemspec => "gemspec licenses",
            LicenseSource::DebCopyright => "debian/copyright",
            LicenseSource::HelmChart => "Chart.yaml license annotation",
        }
    }
}

/// What a package says about its licence.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DeclaredLicense {
    /// The file the package was read as (`wheel`, `sdist`, `npm` …).
    pub format: String,
    pub source: LicenseSource,
    /// What the package wrote, verbatim; several entries are joined with `; `.
    pub declared: String,
    /// The SPDX expression it means, or `None` when it does not parse.
    pub expression: Option<String>,
}

impl DeclaredLicense {
    fn new(
        format: &str,
        source: LicenseSource,
        declared: impl Into<String>,
        expression: Option<String>,
    ) -> Self {
        Self {
            format: format.to_string(),
            source,
            declared: declared.into(),
            expression,
        }
    }
}

// ── the readers ─────────────────────────────────────────────────────────────

/// ★ The licence a package declares in its own metadata, or `None` when it
/// declares none this build can read.
///
/// `ecosystem` is the label `security::ecosystem_for` spells (`pip`, `npm`,
/// `cargo` …); it only breaks a tie when the bytes could be more than one
/// format.
pub fn detect(ecosystem: &str, bytes: &[u8]) -> Option<DeclaredLicense> {
    if bytes.starts_with(b"PK\x03\x04") {
        detect_zip(ecosystem, bytes)
    } else if bytes.starts_with(&[0x1f, 0x8b]) {
        detect_tarball(ecosystem, bytes)
    } else if bytes.starts_with(b"!<arch>\n") {
        detect_deb(bytes).ok().flatten()
    } else if bytes.len() > 262 && &bytes[257..262] == b"ustar" {
        detect_gem(bytes).ok().flatten()
    } else if looks_like_xml(bytes) {
        std::str::from_utf8(bytes)
            .ok()
            .and_then(|t| from_pom(t, "maven-pom"))
    } else {
        None
    }
}

fn looks_like_xml(bytes: &[u8]) -> bool {
    let b = bytes.strip_prefix(b"\xef\xbb\xbf").unwrap_or(bytes);
    b.iter().find(|c| !c.is_ascii_whitespace()) == Some(&b'<')
}

/// Wheel METADATA or sdist PKG-INFO: `License-Expression`, then `License` when
/// it parses, then the classifiers.
fn from_core_metadata(m: &WheelMetadata, format: &str) -> Option<DeclaredLicense> {
    if let Some(e) = &m.license_expression {
        // PEP 639: the expression is authoritative; one that does not parse is
        // unknown, not a reason to read the classifiers instead.
        return Some(DeclaredLicense::new(
            format,
            LicenseSource::CoreMetadataLicenseExpression,
            e,
            spdx_string(e),
        ));
    }
    if let Some(l) = &m.license_field {
        if let Some(x) = spdx_string(l) {
            return Some(DeclaredLicense::new(
                format,
                LicenseSource::CoreMetadataLicense,
                l,
                Some(x),
            ));
        }
    }
    if !m.license_classifiers.is_empty() {
        let mapped = m
            .license_classifiers
            .iter()
            .map(|c| c.rsplit(" :: ").next().and_then(name_to_spdx))
            .collect();
        return Some(DeclaredLicense::new(
            format,
            LicenseSource::CoreMetadataClassifier,
            m.license_classifiers.join("; "),
            combine(mapped, "AND"),
        ));
    }
    m.license_field
        .as_ref()
        .map(|l| DeclaredLicense::new(format, LicenseSource::CoreMetadataLicense, l, None))
}

fn depth1(name: &str, file: &str) -> bool {
    let mut parts = name.trim_start_matches("./").split('/');
    matches!((parts.next(), parts.next(), parts.next()), (Some(d), Some(f), None) if !d.is_empty() && f == file)
}

fn detect_zip(ecosystem: &str, bytes: &[u8]) -> Option<DeclaredLicense> {
    type Reader = fn(&[u8]) -> Option<DeclaredLicense>;
    let wheel: Reader = |b| {
        wheel::read_metadata(b)
            .ok()
            .and_then(|m| from_core_metadata(&m, "wheel"))
    };
    let nuget: Reader = from_nupkg;
    let jar: Reader = |b| {
        let pred = |n: &str| n.starts_with("META-INF/maven/") && n.ends_with("/pom.xml");
        let (_, xml) = zip_entry::find_member_capped(b, MAX_MEMBER_BYTES, pred).ok()??;
        from_pom(std::str::from_utf8(&xml).ok()?, "maven-jar")
    };
    let sdist: Reader = |b| {
        let (_, text) =
            zip_entry::find_member_capped(b, MAX_MEMBER_BYTES, |n| depth1(n, "PKG-INFO"))
                .ok()??;
        let m = wheel::parse_metadata(std::str::from_utf8(&text).ok()?).ok()?;
        from_core_metadata(&m, "sdist")
    };
    let order: [Reader; 4] = match ecosystem {
        "nuget" => [nuget, wheel, jar, sdist],
        "maven" => [jar, wheel, nuget, sdist],
        _ => [wheel, sdist, nuget, jar],
    };
    order.iter().find_map(|r| r(bytes))
}

#[cfg(feature = "fmt-nuget")]
fn from_nupkg(bytes: &[u8]) -> Option<DeclaredLicense> {
    use repository_znippy_nuget::nuspec;
    let (_, xml) =
        zip_entry::find_member_capped(bytes, MAX_MEMBER_BYTES, nuspec::is_root_nuspec).ok()??;
    let n = nuspec::parse_nuspec(std::str::from_utf8(&xml).ok()?).ok()?;
    if let Some(e) = n.license_expression.filter(|e| !e.trim().is_empty()) {
        let x = spdx_string(&e);
        return Some(DeclaredLicense::new("nuget", LicenseSource::Nuspec, e, x));
    }
    // The deprecated `<licenseUrl>`: nuget.org's own licence URLs name an
    // expression; any other URL is a document nobody parsed.
    let url = n.license_url.filter(|u| !u.trim().is_empty())?;
    let expr = url
        .trim()
        .strip_prefix("https://licenses.nuget.org/")
        .map(|rest| {
            rest.replace("%20", " ")
                .replace("%28", "(")
                .replace("%29", ")")
        })
        .and_then(|e| spdx_string(&e));
    Some(DeclaredLicense::new(
        "nuget",
        LicenseSource::Nuspec,
        url,
        expr,
    ))
}

/// A build without the nuget backend cannot read a `.nuspec`.
#[cfg(not(feature = "fmt-nuget"))]
fn from_nupkg(_bytes: &[u8]) -> Option<DeclaredLicense> {
    None
}

/// A reader that refuses to inflate past [`MAX_UNPACKED_BYTES`].
struct Capped<R> {
    inner: R,
    left: u64,
}

impl<R: Read> Read for Capped<R> {
    fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
        if self.left == 0 {
            return Err(std::io::Error::other(
                "the archive inflates past the scan limit",
            ));
        }
        let max = buf.len().min(self.left.min(usize::MAX as u64) as usize);
        let n = self.inner.read(&mut buf[..max])?;
        self.left -= n as u64;
        Ok(n)
    }
}

fn capped<R: Read>(inner: R) -> Capped<R> {
    Capped {
        inner,
        left: MAX_UNPACKED_BYTES,
    }
}

fn read_capped(r: &mut dyn Read) -> anyhow::Result<Vec<u8>> {
    let mut out = Vec::new();
    r.take(MAX_MEMBER_BYTES as u64 + 1).read_to_end(&mut out)?;
    anyhow::ensure!(
        out.len() <= MAX_MEMBER_BYTES,
        "a metadata member is larger than {MAX_MEMBER_BYTES} bytes"
    );
    Ok(out)
}

/// Walk a tar stream's regular files. `visit` gets each name; when it asks for
/// the member (returns `true`) it is read, bounded, and handed to `take`, which
/// returns `true` to stop the walk.
fn walk_tar<R: Read>(
    reader: R,
    mut want: impl FnMut(&str) -> bool,
    mut take: impl FnMut(&str, Vec<u8>) -> bool,
) -> anyhow::Result<()> {
    let mut archive = tar::Archive::new(capped(reader));
    for entry in archive.entries()? {
        let mut entry = entry?;
        if !entry.header().entry_type().is_file() {
            continue;
        }
        let name = entry
            .path()?
            .to_string_lossy()
            .trim_start_matches("./")
            .to_string();
        if want(&name) {
            let bytes = read_capped(&mut entry)?;
            if take(&name, bytes) {
                break;
            }
        }
    }
    Ok(())
}

/// npm, cargo, sdist and helm tarballs: one walk collects each candidate.
fn detect_tarball(ecosystem: &str, bytes: &[u8]) -> Option<DeclaredLicense> {
    const FILES: [&str; 4] = ["Cargo.toml", "package.json", "PKG-INFO", "Chart.yaml"];
    let preferred = match ecosystem {
        "cargo" => "Cargo.toml",
        "npm" => "package.json",
        "pip" => "PKG-INFO",
        "helm" => "Chart.yaml",
        _ => "",
    };
    let mut found: Vec<(&'static str, Vec<u8>)> = Vec::new();
    let _ = walk_tar(
        flate2::read::GzDecoder::new(bytes),
        |name| FILES.iter().any(|f| depth1(name, f)),
        |name, data| {
            let file = FILES
                .iter()
                .find(|f| depth1(name, f))
                .copied()
                .unwrap_or("");
            if !found.iter().any(|(f, _)| *f == file) {
                found.push((file, data));
            }
            file == preferred || found.len() == FILES.len()
        },
    );
    let order: Vec<&str> = std::iter::once(preferred)
        .chain(FILES)
        .filter(|f| !f.is_empty())
        .collect();
    order.into_iter().find_map(|file| {
        let (_, data) = found.iter().find(|(f, _)| *f == file)?;
        let text = std::str::from_utf8(data).ok()?;
        match file {
            "Cargo.toml" => from_cargo_toml(text),
            "package.json" => from_package_json(text),
            "PKG-INFO" => wheel::parse_metadata(text)
                .ok()
                .and_then(|m| from_core_metadata(&m, "sdist")),
            _ => from_chart_yaml(text),
        }
    })
}

/// **The packages a raw archive names in its own manifest** (lane HOL-FIELD-2 P16), as
/// `(ecosystem, name, version)`: a gzip tarball's depth-1 `package.json` (npm), `PKG-INFO`
/// (an sdist, `pypi`) and `Cargo.toml` (a crate, `cargo`); a zip's wheel METADATA or depth-1
/// `PKG-INFO` (`pypi`) and its `META-INF/maven/**/pom.properties` (`maven`,
/// `group:artifact`). Every one found, so a decoy manifest beside the real one hides nothing.
/// Empty for anything else. A raw file scanned as `raw` is matched against no advisory, and
/// `npm install <url>` or `pip install <url>` takes such a file from any link.
pub fn packages_of_archive(bytes: &[u8]) -> Vec<(&'static str, String, String)> {
    const FILES: [&str; 3] = ["package.json", "PKG-INFO", "Cargo.toml"];
    let mut out: Vec<(&'static str, String, String)> = Vec::new();
    let mut push = |found: Option<(&'static str, String, String)>| {
        if let Some(f) = found.filter(|(_, n, v)| !n.trim().is_empty() && !v.trim().is_empty()) {
            if !out.contains(&f) {
                out.push(f);
            }
        }
    };
    let pypi = |text: &str| {
        wheel::parse_metadata(text)
            .ok()
            .map(|m| ("pypi", m.name, m.version))
    };
    if bytes.starts_with(&[0x1f, 0x8b]) {
        let _ = walk_tar(
            flate2::read::GzDecoder::new(bytes),
            |name| FILES.iter().any(|f| depth1(name, f)),
            |name, data| {
                let Ok(text) = std::str::from_utf8(&data) else {
                    return false;
                };
                push(if depth1(name, "package.json") {
                    npm_coords(text)
                } else if depth1(name, "PKG-INFO") {
                    pypi(text)
                } else {
                    cargo_coords(text)
                });
                false
            },
        );
    } else if bytes.starts_with(b"PK\x03\x04") {
        if let Ok(m) = wheel::read_metadata(bytes) {
            push(Some(("pypi", m.name, m.version)));
        }
        if let Ok(Some((_, t))) =
            zip_entry::find_member_capped(bytes, MAX_MEMBER_BYTES, |n| depth1(n, "PKG-INFO"))
        {
            push(std::str::from_utf8(&t).ok().and_then(pypi));
        }
        if let Ok(Some((_, t))) = zip_entry::find_member_capped(bytes, MAX_MEMBER_BYTES, |n| {
            n.starts_with("META-INF/maven/") && n.ends_with("/pom.properties")
        }) {
            push(std::str::from_utf8(&t).ok().and_then(maven_coords));
        }
    }
    out
}

fn npm_coords(text: &str) -> Option<(&'static str, String, String)> {
    let v: serde_json::Value = serde_json::from_str(text).ok()?;
    Some((
        "npm",
        v.get("name")?.as_str()?.to_string(),
        v.get("version")?.as_str()?.to_string(),
    ))
}

fn cargo_coords(text: &str) -> Option<(&'static str, String, String)> {
    let table: toml::Table = toml::from_str(text).ok()?;
    let p = table.get("package")?;
    Some((
        "cargo",
        p.get("name")?.as_str()?.to_string(),
        p.get("version")?.as_str()?.to_string(),
    ))
}

fn maven_coords(text: &str) -> Option<(&'static str, String, String)> {
    let field = |k: &str| {
        text.lines().find_map(|l| {
            let (key, v) = l.split_once('=')?;
            (key.trim() == k).then(|| v.trim().to_string())
        })
    };
    Some((
        "maven",
        format!("{}:{}", field("groupId")?, field("artifactId")?),
        field("version")?,
    ))
}

fn from_cargo_toml(text: &str) -> Option<DeclaredLicense> {
    let table: toml::Table = toml::from_str(text).ok()?;
    let lic = table
        .get("package")?
        .get("license")?
        .as_str()?
        .trim()
        .to_string();
    (!lic.is_empty()).then(|| {
        let x = spdx_string(&lic);
        DeclaredLicense::new("cargo", LicenseSource::CargoToml, lic, x)
    })
}

fn from_package_json(text: &str) -> Option<DeclaredLicense> {
    let v: serde_json::Value = serde_json::from_str(text).ok()?;
    let one = |l: &serde_json::Value| -> Option<String> {
        l.as_str()
            .or_else(|| l.get("type").and_then(|t| t.as_str()))
            .map(|s| s.trim().to_string())
    };
    if let Some(l) = v.get("license").and_then(one).filter(|s| !s.is_empty()) {
        let x = spdx_string(&l);
        return Some(DeclaredLicense::new(
            "npm",
            LicenseSource::NpmPackageJson,
            l,
            x,
        ));
    }
    // The legacy list; read with AND (nothing says the licensee may choose).
    let list: Vec<String> = v
        .get("licenses")?
        .as_array()?
        .iter()
        .filter_map(one)
        .filter(|s| !s.is_empty())
        .collect();
    if list.is_empty() {
        return None;
    }
    let x = combine(list.iter().map(|l| spdx_string(l)).collect(), "AND");
    Some(DeclaredLicense::new(
        "npm",
        LicenseSource::NpmPackageJson,
        list.join("; "),
        x,
    ))
}

fn unquote(s: &str) -> &str {
    let s = s.trim();
    for q in ['"', '\''] {
        if let Some(inner) = s.strip_prefix(q).and_then(|r| r.strip_suffix(q)) {
            return inner;
        }
    }
    s
}

/// Helm: the `artifacthub.io/license` or `licenses` annotation (best effort; a
/// line scan, not a YAML parser).
fn from_chart_yaml(text: &str) -> Option<DeclaredLicense> {
    let mut in_annotations = false;
    for line in text.lines() {
        if !line.starts_with([' ', '\t']) {
            in_annotations = line.trim_end() == "annotations:";
            continue;
        }
        if !in_annotations {
            continue;
        }
        let Some((k, v)) = line.trim().split_once(':') else {
            continue;
        };
        if matches!(unquote(k), "artifacthub.io/license" | "licenses") {
            let v = unquote(v).to_string();
            if !v.is_empty() {
                let x = spdx_string(&v);
                return Some(DeclaredLicense::new("helm", LicenseSource::HelmChart, v, x));
            }
        }
    }
    None
}

/// Strip XML comments, then return the text of every `<tag>…</tag>` in `s`.
fn xml_texts<'a>(s: &'a str, tag: &str) -> Vec<&'a str> {
    let (open, close) = (format!("<{tag}>"), format!("</{tag}>"));
    let mut out = Vec::new();
    let mut rest = s;
    while let Some(i) = rest.find(&open) {
        let after = &rest[i + open.len()..];
        let Some(j) = after.find(&close) else { break };
        out.push(&after[..j]);
        rest = &after[j + close.len()..];
    }
    out
}

fn strip_xml_comments(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    let mut rest = s;
    while let Some(i) = rest.find("<!--") {
        out.push_str(&rest[..i]);
        match rest[i..].find("-->") {
            Some(j) => rest = &rest[i + j + 3..],
            None => return out,
        }
    }
    out.push_str(rest);
    out
}

fn xml_unescape(s: &str) -> String {
    s.trim()
        .replace("&lt;", "<")
        .replace("&gt;", ">")
        .replace("&quot;", "\"")
        .replace("&apos;", "'")
        .replace("&amp;", "&")
}

/// Maven: the pom's `<licenses>`. Several licences are a CHOICE (the POM
/// reference says so), so they combine with `OR`.
fn from_pom(xml: &str, format: &str) -> Option<DeclaredLicense> {
    let xml = strip_xml_comments(xml);
    let block = *xml_texts(&xml, "licenses").first()?;
    let licenses = xml_texts(block, "license");
    if licenses.is_empty() {
        return None;
    }
    let mut declared = Vec::new();
    let mut mapped = Vec::new();
    for l in licenses {
        let name = xml_texts(l, "name").first().map(|s| xml_unescape(s));
        let url = xml_texts(l, "url").first().map(|s| xml_unescape(s));
        declared.push(name.clone().or_else(|| url.clone()).unwrap_or_default());
        mapped.push(
            name.as_deref()
                .and_then(name_to_spdx)
                .or_else(|| url.as_deref().and_then(url_to_spdx)),
        );
    }
    Some(DeclaredLicense::new(
        format,
        LicenseSource::MavenPom,
        declared.join("; "),
        combine(mapped, "OR"),
    ))
}

/// A YAML block list or flow list under a top-level `key:` (best effort).
fn yaml_top_list(text: &str, key: &str) -> Option<Vec<String>> {
    let mut lines = text.lines();
    let head = format!("{key}:");
    for line in lines.by_ref() {
        if let Some(rest) = line.strip_prefix(&head) {
            let rest = rest.trim();
            if let Some(flow) = rest.strip_prefix('[').and_then(|r| r.strip_suffix(']')) {
                return Some(
                    flow.split(',')
                        .map(|s| unquote(s).to_string())
                        .filter(|s| !s.is_empty())
                        .collect(),
                );
            }
            if !rest.is_empty() {
                return Some(vec![unquote(rest).to_string()]);
            }
            let mut out = Vec::new();
            for item in lines.by_ref() {
                match item.trim_start().strip_prefix("- ") {
                    Some(v) => out.push(unquote(v).to_string()),
                    None => break,
                }
            }
            return Some(out);
        }
    }
    None
}

/// A `.gem` is a plain tar; `metadata.gz` is the gemspec as gzipped YAML.
fn detect_gem(bytes: &[u8]) -> anyhow::Result<Option<DeclaredLicense>> {
    let mut meta = None;
    walk_tar(
        bytes,
        |n| n == "metadata.gz",
        |_, data| {
            meta = Some(data);
            true
        },
    )?;
    let Some(gz) = meta else { return Ok(None) };
    let yaml = read_capped(&mut capped(flate2::read::GzDecoder::new(gz.as_slice())))?;
    let text = String::from_utf8(yaml)?;
    let Some(list) = yaml_top_list(&text, "licenses").filter(|l| !l.is_empty()) else {
        return Ok(None);
    };
    let x = combine(list.iter().map(|l| spdx_string(l)).collect(), "AND");
    Ok(Some(DeclaredLicense::new(
        "gem",
        LicenseSource::Gemspec,
        list.join("; "),
        x,
    )))
}

/// The members of an `ar` archive, as `(name, bytes)`.
fn ar_members(bytes: &[u8]) -> anyhow::Result<Vec<(String, &[u8])>> {
    let mut out = Vec::new();
    let mut pos = 8;
    while pos + 60 <= bytes.len() {
        let header = &bytes[pos..pos + 60];
        anyhow::ensure!(&header[58..60] == b"`\n", "not an ar member header");
        let name = String::from_utf8_lossy(&header[0..16])
            .trim()
            .trim_end_matches('/')
            .to_string();
        let size: usize = std::str::from_utf8(&header[48..58])?.trim().parse()?;
        let start = pos + 60;
        let end = start
            .checked_add(size)
            .filter(|e| *e <= bytes.len())
            .ok_or_else(|| anyhow::anyhow!("an ar member runs past the end"))?;
        out.push((name, &bytes[start..end]));
        pos = end + (size % 2);
    }
    Ok(out)
}

/// A `.deb`: the machine-readable `usr/share/doc/<pkg>/copyright` in
/// `data.tar.{gz,xz,zst}` (best effort). Every `License:` short name is read,
/// and they combine with `AND`; a `with … exception` clause is dropped, which
/// only ever makes the reading stricter.
fn detect_deb(bytes: &[u8]) -> anyhow::Result<Option<DeclaredLicense>> {
    let members = ar_members(bytes)?;
    let Some((name, data)) = members.iter().find(|(n, _)| n.starts_with("data.tar")) else {
        return Ok(None);
    };
    let is_copyright = |n: &str| {
        let mut p = n.split('/');
        matches!((p.next(), p.next(), p.next(), p.next(), p.next(), p.next()),
            (Some("usr"), Some("share"), Some("doc"), Some(d), Some("copyright"), None) if !d.is_empty())
    };
    let mut copyright = None;
    let take = |_: &str, d: Vec<u8>| {
        copyright = Some(d);
        true
    };
    match name.as_str() {
        "data.tar.gz" => walk_tar(flate2::read::GzDecoder::new(*data), is_copyright, take)?,
        "data.tar.zst" => walk_tar(zstd::stream::read::Decoder::new(*data)?, is_copyright, take)?,
        "data.tar.xz" => {
            let mut out = Vec::new();
            let mut sink = CappedWrite {
                out: &mut out,
                left: MAX_UNPACKED_BYTES as usize,
            };
            lzma_rs::xz_decompress(&mut std::io::BufReader::new(*data), &mut sink)
                .map_err(|e| anyhow::anyhow!("data.tar.xz: {e:?}"))?;
            walk_tar(out.as_slice(), is_copyright, take)?
        }
        "data.tar" => walk_tar(*data, is_copyright, take)?,
        other => anyhow::bail!("{other} is not a compression this scanner reads"),
    }
    let Some(text) = copyright else {
        return Ok(None);
    };
    let text = String::from_utf8_lossy(&text);
    Ok(from_dep5(&text))
}

/// A `Write` that refuses past a limit (the xz decoder writes, it does not read).
struct CappedWrite<'a> {
    out: &'a mut Vec<u8>,
    left: usize,
}

impl std::io::Write for CappedWrite<'_> {
    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
        if buf.len() > self.left {
            return Err(std::io::Error::other(
                "the archive inflates past the scan limit",
            ));
        }
        self.left -= buf.len();
        self.out.extend_from_slice(buf);
        Ok(buf.len())
    }
    fn flush(&mut self) -> std::io::Result<()> {
        Ok(())
    }
}

/// One DEP-5 `License:` short-name line as SPDX: `or`/`and` become operators,
/// a `with … exception` clause is dropped, each name is mapped.
fn dep5_line(line: &str) -> Option<String> {
    let mut out: Vec<String> = Vec::new();
    let mut skipping = false;
    for tok in line.split_whitespace() {
        let lower = tok.trim_end_matches(',').to_ascii_lowercase();
        match lower.as_str() {
            "or" | "and" => {
                skipping = false;
                out.push(lower.to_ascii_uppercase());
            }
            "with" => skipping = true,
            _ if skipping => {}
            name => out.push(
                NAMES
                    .iter()
                    .find(|(k, _)| *k == name)
                    .map(|(_, v)| {
                        if v.contains(' ') {
                            format!("({v})")
                        } else {
                            v.to_string()
                        }
                    })
                    .or_else(|| spdx_string(tok))?,
            ),
        }
    }
    spdx_string(&out.join(" "))
}

fn from_dep5(text: &str) -> Option<DeclaredLicense> {
    if !text.trim_start().starts_with("Format:") {
        return Some(DeclaredLicense::new(
            "deb",
            LicenseSource::DebCopyright,
            "a free-text copyright file (not machine-readable DEP-5)",
            None,
        ));
    }
    let mut lines: Vec<String> = Vec::new();
    for line in text.lines() {
        if let Some(v) = line.strip_prefix("License:") {
            let v = v.trim().to_string();
            if !v.is_empty() && !lines.contains(&v) {
                lines.push(v);
            }
        }
    }
    if lines.is_empty() {
        return None;
    }
    let x = combine(lines.iter().map(|l| dep5_line(l)).collect(), "AND");
    Some(DeclaredLicense::new(
        "deb",
        LicenseSource::DebCopyright,
        lines.join("; "),
        x,
    ))
}

// ── the judgement and the scanner ───────────────────────────────────────────

/// What the policy decides for one package.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum LicenseDecision {
    Pass,
    /// Warn and hold for review.
    Hold,
    Block,
}

/// ★ One package's licence, judged.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LicenseJudgement {
    pub declared: Option<DeclaredLicense>,
    pub verdict: LicenseVerdict,
    pub decision: LicenseDecision,
    /// One sentence: what was read and what the policy made of it.
    pub detail: String,
}

impl LicenseJudgement {
    /// The finding this judgement records.
    pub fn finding(&self) -> ScanFinding {
        let what = self
            .declared
            .as_ref()
            .map(|d| d.expression.clone().unwrap_or_else(|| d.declared.clone()))
            .unwrap_or_default();
        match &self.verdict {
            LicenseVerdict::Allowed => ScanFinding::new(
                format!("{FINDING_DECLARED}:{what}"),
                FindingSeverity::Info,
                self.detail.clone(),
            ),
            LicenseVerdict::Denied(ids) => ScanFinding::new(
                format!("{FINDING_DENIED}:{}", ids.join(",")),
                FindingSeverity::High,
                self.detail.clone(),
            ),
            LicenseVerdict::NotAllowed(ids) => ScanFinding::new(
                format!("{FINDING_NOT_ALLOWED}:{}", ids.join(",")),
                FindingSeverity::Medium,
                self.detail.clone(),
            ),
            LicenseVerdict::Unknown => ScanFinding::new(
                FINDING_UNKNOWN,
                if self.decision == LicenseDecision::Pass {
                    FindingSeverity::Info
                } else {
                    FindingSeverity::Medium
                },
                self.detail.clone(),
            ),
        }
    }

    /// The evidence outcome: denied → block, unknown under Hold → warn + hold.
    pub fn outcome(&self) -> ScanOutcome {
        let f = vec![self.finding()];
        match self.decision {
            LicenseDecision::Pass => ScanOutcome {
                findings: f,
                ..ScanOutcome::pass(self.detail.clone())
            },
            LicenseDecision::Hold => ScanOutcome::hold(f, self.detail.clone()),
            LicenseDecision::Block => ScanOutcome::block(f, self.detail.clone()),
        }
    }
}

/// Judge a declaration (or its absence) against a policy.
pub fn judge_declared(
    policy: &LicensePolicy,
    declared: Option<DeclaredLicense>,
) -> LicenseJudgement {
    let expr = declared
        .as_ref()
        .and_then(|d| d.expression.as_deref())
        .and_then(parse_expression);
    let verdict = match &expr {
        Some(e) => judge_expression(policy, e),
        None => LicenseVerdict::Unknown,
    };
    let decision = match (&verdict, policy.unknown) {
        (LicenseVerdict::Allowed, _) => LicenseDecision::Pass,
        (LicenseVerdict::Denied(_) | LicenseVerdict::NotAllowed(_), _) => LicenseDecision::Block,
        (LicenseVerdict::Unknown, UnknownLicense::Hold) => LicenseDecision::Hold,
        (LicenseVerdict::Unknown, UnknownLicense::Block) => LicenseDecision::Block,
        (LicenseVerdict::Unknown, UnknownLicense::Pass) => LicenseDecision::Pass,
    };
    let read = match &declared {
        Some(d) => format!(
            "the {} declares {:?} ({})",
            d.format,
            d.declared,
            d.source.as_str()
        ),
        None => "the package declares no licence this build can read".to_string(),
    };
    let detail = match (&verdict, decision) {
        (LicenseVerdict::Allowed, _) => format!(
            "{read}; {} is allowed",
            expr.as_ref().map(|e| e.to_string()).unwrap_or_default()
        ),
        (LicenseVerdict::Denied(ids), _) => {
            format!("{read}; licence {} is denied by policy", ids.join(", "))
        }
        (LicenseVerdict::NotAllowed(ids), _) => format!(
            "{read}; licence {} is not on the allow list",
            ids.join(", ")
        ),
        (LicenseVerdict::Unknown, LicenseDecision::Hold) => format!(
            "{read}; the licence is unknown or not SPDX, so the package is held for review \
             (`promotion.unknown_license: Hold`)"
        ),
        (LicenseVerdict::Unknown, LicenseDecision::Block) => {
            format!("{read}; the licence is unknown or not SPDX, and `promotion.unknown_license: Block` blocks it")
        }
        (LicenseVerdict::Unknown, _) => {
            format!("{read}; the licence is unknown or not SPDX, and `promotion.unknown_license: Pass` lets it through")
        }
    };
    LicenseJudgement {
        declared,
        verdict,
        decision,
        detail,
    }
}

/// ★ The licence scanner. It fills the licence slot, and the promotion gate
/// calls [`judge`](Self::judge) directly.
#[derive(Debug, Clone, Default)]
pub struct LicenseScanner {
    policy: LicensePolicy,
}

impl LicenseScanner {
    /// The scanner id in the evidence.
    pub const ID: &'static str = "holger-license";

    pub fn new(policy: LicensePolicy) -> Self {
        Self { policy }
    }

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

    /// ★ Read the licence from the package's bytes and judge it.
    pub fn judge(&self, ecosystem: &str, bytes: &[u8]) -> LicenseJudgement {
        judge_declared(&self.policy, detect(ecosystem, bytes))
    }
}

impl Scanner for LicenseScanner {
    fn id(&self) -> &str {
        Self::ID
    }

    fn version(&self) -> String {
        format!(
            "holger-server-lib {}; SPDX licence list {}; policy {}",
            env!("CARGO_PKG_VERSION"),
            spdx::identifiers::VERSION,
            self.policy.summary()
        )
    }

    fn kind(&self) -> ScannerKind {
        ScannerKind::License
    }

    fn scan(&self, subject: &ScanSubject<'_>) -> anyhow::Result<ScanOutcome> {
        Ok(self.judge(subject.ecosystem, subject.bytes).outcome())
    }
}

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

    // ── fixtures: one real archive per format ──

    fn tar_of(entries: &[(&str, &[u8])]) -> Vec<u8> {
        let mut b = tar::Builder::new(Vec::new());
        for (name, data) in entries {
            let mut h = tar::Header::new_gnu();
            h.set_size(data.len() as u64);
            h.set_mode(0o644);
            h.set_entry_type(tar::EntryType::Regular);
            h.set_cksum();
            b.append_data(&mut h, name, *data).unwrap();
        }
        b.into_inner().unwrap()
    }

    fn gz(data: &[u8]) -> Vec<u8> {
        use std::io::Write;
        let mut e = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast());
        e.write_all(data).unwrap();
        e.finish().unwrap()
    }

    fn tgz(entries: &[(&str, &[u8])]) -> Vec<u8> {
        gz(&tar_of(entries))
    }

    fn ar(members: &[(&str, &[u8])]) -> Vec<u8> {
        let mut out = b"!<arch>\n".to_vec();
        for (name, data) in members {
            out.extend_from_slice(
                format!(
                    "{:<16}{:<12}{:<6}{:<6}{:<8}{:<10}`\n",
                    name,
                    0,
                    0,
                    0,
                    100644,
                    data.len()
                )
                .as_bytes(),
            );
            out.extend_from_slice(data);
            if data.len() % 2 == 1 {
                out.push(b'\n');
            }
        }
        out
    }

    fn wheel_with(headers: &str) -> Vec<u8> {
        let meta = format!("Metadata-Version: 2.4\nName: thing\nVersion: 1.0.0\n{headers}\nThe body.\nLicense-Expression: MIT\n");
        zip_entry::fixture::zip(&[
            ("thing/__init__.py", b"".as_slice()),
            ("thing-1.0.0.dist-info/METADATA", meta.as_bytes()),
        ])
    }

    fn sdist(license: &str) -> Vec<u8> {
        let pkg =
            format!("Metadata-Version: 2.1\nName: thing\nVersion: 1.0.0\nLicense: {license}\n\n");
        tgz(&[
            ("thing-1.0.0/setup.py", b"".as_slice()),
            ("thing-1.0.0/PKG-INFO", pkg.as_bytes()),
        ])
    }

    fn npm(package_json: &str) -> Vec<u8> {
        tgz(&[
            ("package/package.json", package_json.as_bytes()),
            ("package/index.js", b"".as_slice()),
        ])
    }

    fn krate(license: &str) -> Vec<u8> {
        let toml =
            format!("[package]\nname = \"thing\"\nversion = \"1.0.0\"\nlicense = \"{license}\"\n");
        tgz(&[
            (
                "thing-1.0.0/Cargo.toml.orig",
                b"[package]\nlicense = \"GPL-3.0\"\n".as_slice(),
            ),
            ("thing-1.0.0/Cargo.toml", toml.as_bytes()),
            ("thing-1.0.0/src/lib.rs", b"".as_slice()),
        ])
    }

    fn nupkg(license: &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}</license></metadata></package>"
        );
        zip_entry::fixture::zip(&[
            ("Thing.nuspec", xml.as_bytes()),
            ("lib/net8.0/Thing.dll", b"MZ".as_slice()),
        ])
    }

    const POM: &str = "<?xml version=\"1.0\"?>\n<project>\n  <!-- <licenses><license><name>GPL-3.0</name></license></licenses> -->\n  \
        <groupId>dk.thing</groupId><artifactId>thing</artifactId><version>1.0.0</version>\n  <licenses>\n    \
        <license><name>The Apache Software License, Version 2.0</name>\
        <url>https://www.apache.org/licenses/LICENSE-2.0.txt</url></license>\n    \
        <license><name>Some Odd Name</name><url>https://opensource.org/licenses/MIT</url></license>\n  \
        </licenses>\n</project>\n";

    fn gem(licenses_yaml: &str) -> Vec<u8> {
        let spec = format!("--- !ruby/object:Gem::Specification\nname: thing\nversion: !ruby/object:Gem::Version\n  version: 1.0.0\n{licenses_yaml}\nsummary: x\n");
        tar_of(&[
            ("metadata.gz", gz(spec.as_bytes()).as_slice()),
            ("data.tar.gz", tgz(&[("lib/thing.rb", b"")]).as_slice()),
        ])
    }

    const DEP5: &str = "Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/\nUpstream-Name: thing\n\n\
        Files: *\nCopyright: 2026 someone\nLicense: Expat\n\n\
        Files: debian/*\nCopyright: 2026 someone\nLicense: GPL-2+\n\n\
        License: Expat\n Permission is hereby granted ...\n";

    fn deb(copyright: &str, xz: bool) -> Vec<u8> {
        let data = tar_of(&[
            ("./usr/bin/thing", b"#!/bin/sh\n".as_slice()),
            ("./usr/share/doc/thing/copyright", copyright.as_bytes()),
        ]);
        let (name, data) = if xz {
            let mut out = Vec::new();
            lzma_rs::xz_compress(&mut std::io::Cursor::new(data), &mut out).unwrap();
            ("data.tar.xz", out)
        } else {
            ("data.tar.gz", gz(&data))
        };
        ar(&[
            ("debian-binary", b"2.0\n".as_slice()),
            (
                "control.tar.gz",
                tgz(&[("./control", b"Package: thing\n")]).as_slice(),
            ),
            (name, data.as_slice()),
        ])
    }

    fn chart(annotation: &str) -> Vec<u8> {
        let y = format!("apiVersion: v2\nname: thing\nversion: 1.0.0\nannotations:\n  category: Database\n  {annotation}\n");
        tgz(&[
            ("thing/Chart.yaml", y.as_bytes()),
            (
                "thing/charts/sub/Chart.yaml",
                b"annotations:\n  licenses: GPL-3.0\n".as_slice(),
            ),
            ("thing/values.yaml", b"".as_slice()),
        ])
    }

    fn expr(eco: &str, bytes: &[u8]) -> Option<String> {
        detect(eco, bytes).and_then(|d| d.expression)
    }

    fn deny_gpl() -> LicensePolicy {
        LicensePolicy::new(vec![], vec!["GPL-3.0".into()], UnknownLicense::default())
    }

    fn judge_str(policy: &LicensePolicy, declared: &str) -> LicenseJudgement {
        judge_declared(
            policy,
            Some(DeclaredLicense::new(
                "test",
                LicenseSource::CargoToml,
                declared,
                spdx_string(declared),
            )),
        )
    }

    // ── the policy ──

    #[test]
    fn gpl_3_is_denied_and_blocks() {
        let j = judge_str(&deny_gpl(), "GPL-3.0");
        assert_eq!(j.verdict, LicenseVerdict::Denied(vec!["GPL-3.0".into()]));
        assert_eq!(j.decision, LicenseDecision::Block);
        let out = j.outcome();
        assert_eq!(out.verdict, ScanVerdict::Block);
        assert!(
            out.findings[0].id.starts_with(FINDING_DENIED),
            "{:?}",
            out.findings
        );
        // One family, however it is spelled.
        for spelling in ["GPL-3.0-only", "GPL-3.0-or-later", "GPL-3.0+", "gpl-3.0"] {
            assert_eq!(
                judge_str(&deny_gpl(), spelling).decision,
                LicenseDecision::Block,
                "{spelling}"
            );
        }
    }

    #[test]
    fn an_or_passes_when_one_branch_is_allowed() {
        let p = LicensePolicy::new(
            vec!["MIT".into()],
            vec!["GPL-3.0".into()],
            UnknownLicense::Hold,
        );
        let j = judge_str(&p, "MIT OR GPL-3.0");
        assert_eq!(j.verdict, LicenseVerdict::Allowed, "{}", j.detail);
        assert_eq!(j.outcome().verdict, ScanVerdict::Pass);
        assert_eq!(
            judge_str(&p, "MIT/Apache-2.0").decision,
            LicenseDecision::Pass,
            "slash is OR, MIT allowed"
        );
    }

    #[test]
    fn an_and_blocks_when_one_branch_is_denied() {
        let p = LicensePolicy::new(
            vec!["MIT".into()],
            vec!["GPL-3.0".into()],
            UnknownLicense::Hold,
        );
        let j = judge_str(&p, "MIT AND GPL-3.0");
        assert_eq!(j.verdict, LicenseVerdict::Denied(vec!["GPL-3.0".into()]));
        assert_eq!(j.outcome().verdict, ScanVerdict::Block);
        let j = judge_str(&deny_gpl(), "(MIT OR Apache-2.0) AND GPL-3.0");
        assert_eq!(j.decision, LicenseDecision::Block);
    }

    #[test]
    fn deny_always_wins_over_allow() {
        let p = LicensePolicy::new(
            vec!["GPL-3.0".into(), "MIT".into()],
            vec!["GPL-3.0-only".into()],
            UnknownLicense::Pass,
        );
        assert_eq!(
            judge_str(&p, "GPL-3.0-only").decision,
            LicenseDecision::Block
        );
        assert_eq!(
            judge_str(&p, "GPL-3.0 OR GPL-3.0+").decision,
            LicenseDecision::Block
        );
    }

    #[test]
    fn an_id_outside_a_non_empty_allow_list_blocks() {
        let p = LicensePolicy::new(vec!["MIT".into()], vec![], UnknownLicense::Hold);
        let j = judge_str(&p, "Apache-2.0");
        assert_eq!(
            j.verdict,
            LicenseVerdict::NotAllowed(vec!["Apache-2.0".into()])
        );
        assert_eq!(j.decision, LicenseDecision::Block);
        assert_eq!(
            judge_str(&p, "MIT AND Apache-2.0").decision,
            LicenseDecision::Block
        );
        assert_eq!(
            judge_str(&p, "Apache-2.0 OR MIT").decision,
            LicenseDecision::Pass
        );
    }

    #[test]
    fn with_is_judged_by_its_base_licence() {
        let p = LicensePolicy::new(vec![], vec!["GPL-2.0".into()], UnknownLicense::Hold);
        assert_eq!(
            judge_str(&p, "GPL-2.0-only WITH Classpath-exception-2.0").decision,
            LicenseDecision::Block
        );
        let p = LicensePolicy::new(vec!["Apache-2.0".into()], vec![], UnknownLicense::Hold);
        assert_eq!(
            judge_str(&p, "Apache-2.0 WITH LLVM-exception").decision,
            LicenseDecision::Pass
        );
        let p = LicensePolicy::new(
            vec!["GPL-2.0-only WITH Classpath-exception-2.0".into()],
            vec![],
            UnknownLicense::Hold,
        );
        assert_eq!(
            judge_str(&p, "GPL-2.0-only WITH Classpath-exception-2.0").decision,
            LicenseDecision::Pass
        );
        assert_eq!(
            judge_str(&p, "GPL-2.0-only").decision,
            LicenseDecision::Block,
            "the exception was allowed, not bare GPL"
        );
    }

    #[test]
    fn an_unknown_licence_is_held_by_default() {
        let none = judge_declared(&LicensePolicy::default(), None);
        assert_eq!(none.verdict, LicenseVerdict::Unknown);
        assert_eq!(none.decision, LicenseDecision::Hold);
        let out = none.outcome();
        assert_eq!(out.verdict, ScanVerdict::Warn);
        assert!(out.hold, "unknown under Hold is a warn PLUS a hold");
        assert_eq!(out.findings[0].id, FINDING_UNKNOWN);
        // Unparseable is unknown too: free text, an id not on the SPDX list, junk.
        for junk in [
            "GNU General Public License v3",
            "Foo-License-1.0",
            "MIT AND",
            "SEE LICENSE IN LICENSE.md",
            "UNLICENSED",
        ] {
            let j = judge_str(&deny_gpl(), junk);
            assert_eq!(
                (j.verdict.clone(), j.decision),
                (LicenseVerdict::Unknown, LicenseDecision::Hold),
                "{junk}"
            );
        }
        // The other two stances.
        let block = LicensePolicy::new(vec![], vec![], UnknownLicense::Block);
        assert_eq!(
            judge_declared(&block, None).outcome().verdict,
            ScanVerdict::Block
        );
        let pass = LicensePolicy::new(vec![], vec![], UnknownLicense::Pass);
        let out = judge_declared(&pass, None).outcome();
        assert_eq!((out.verdict, out.hold), (ScanVerdict::Pass, false));
    }

    #[test]
    fn the_case_of_an_id_is_repaired_but_names_are_not_guessed() {
        assert_eq!(
            spdx_string("mit OR apache-2.0").as_deref(),
            Some("MIT OR Apache-2.0")
        );
        // The spdx crate's prefix guessing would map this to BSD-2-Clause; it is off.
        assert_eq!(spdx_string("BSD 3-Clause"), None);
        assert_eq!(
            spdx_string(&"MIT OR ".repeat(2000)),
            None,
            "a licence text is not an expression"
        );
    }

    // ── one fixture per format ──

    #[test]
    fn wheel_reads_license_expression_then_license_then_classifiers() {
        assert_eq!(
            expr(
                "pip",
                &wheel_with("License-Expression: MIT OR Apache-2.0\nLicense: GPL-3.0\n")
            )
            .as_deref(),
            Some("MIT OR Apache-2.0")
        );
        assert_eq!(
            expr(
                "pip",
                &wheel_with(
                    "License: BSD-3-Clause\nClassifier: License :: OSI Approved :: MIT License\n"
                )
            )
            .as_deref(),
            Some("BSD-3-Clause")
        );
        let d = detect("pip", &wheel_with("License: GNU GPL, see the file\nClassifier: License :: OSI Approved :: MIT License\n")).unwrap();
        assert_eq!(
            (d.source, d.expression.as_deref()),
            (LicenseSource::CoreMetadataClassifier, Some("MIT"))
        );
        assert_eq!(
            expr("pip", &wheel_with("Classifier: License :: OSI Approved :: MIT License\nClassifier: License :: OSI Approved :: GNU General Public License v3 (GPLv3)\n")).as_deref(),
            Some("(MIT) AND (GPL-3.0-only)"),
            "several classifiers are read with AND"
        );
        assert_eq!(
            expr(
                "pip",
                &wheel_with("Classifier: License :: OSI Approved :: BSD License\n")
            ),
            None,
            "an ambiguous classifier is unknown"
        );
        // An unparseable License-Expression is unknown; the classifiers do not rescue it.
        assert_eq!(expr("pip", &wheel_with("License-Expression: My Own\nClassifier: License :: OSI Approved :: MIT License\n")), None);
        // Only the headers count; the body never does.
        assert!(detect("pip", &wheel_with("")).is_none());
    }

    #[test]
    fn sdist_reads_pkg_info() {
        assert_eq!(
            expr("pip", &sdist("Apache-2.0")).as_deref(),
            Some("Apache-2.0")
        );
        let d = detect("pip", &sdist("Apache-2.0")).unwrap();
        assert_eq!(d.format, "sdist");
    }

    #[test]
    fn npm_reads_package_json() {
        assert_eq!(
            expr(
                "npm",
                &npm(r#"{"name":"thing","version":"1.0.0","license":"(MIT OR Apache-2.0)"}"#)
            )
            .as_deref(),
            Some("(MIT OR Apache-2.0)")
        );
        assert_eq!(
            expr("npm", &npm(r#"{"license":{"type":"ISC"}}"#)).as_deref(),
            Some("ISC")
        );
        assert_eq!(
            expr(
                "npm",
                &npm(r#"{"licenses":[{"type":"MIT"},{"type":"GPL-3.0"}]}"#)
            )
            .as_deref(),
            Some("(MIT) AND (GPL-3.0)")
        );
        let d = detect("npm", &npm(r#"{"license":"SEE LICENSE IN LICENSE"}"#)).unwrap();
        assert_eq!(d.expression, None);
    }

    #[test]
    fn cargo_reads_the_crate_cargo_toml() {
        assert_eq!(
            expr("cargo", &krate("MIT OR Apache-2.0")).as_deref(),
            Some("MIT OR Apache-2.0")
        );
        assert_eq!(
            expr("cargo", &krate("MIT/Apache-2.0")).as_deref(),
            Some("MIT/Apache-2.0")
        );
        assert_eq!(detect("cargo", &krate("MIT")).unwrap().format, "cargo");
    }

    #[cfg(feature = "fmt-nuget")]
    #[test]
    fn nuget_reads_the_nuspec() {
        assert_eq!(expr("nuget", &nupkg("MIT")).as_deref(), Some("MIT"));
        assert_eq!(
            judge_declared(&deny_gpl(), detect("nuget", &nupkg("GPL-3.0-or-later"))).decision,
            LicenseDecision::Block
        );
    }

    #[test]
    fn maven_reads_the_pom_licenses_as_a_choice() {
        let d = detect("maven", POM.as_bytes()).unwrap();
        assert_eq!(
            d.expression.as_deref(),
            Some("(Apache-2.0) OR (MIT)"),
            "{d:?}"
        );
        assert!(d.declared.contains("Some Odd Name"));
        let jar = zip_entry::fixture::zip(&[
            (
                "META-INF/MANIFEST.MF",
                b"Manifest-Version: 1.0\n".as_slice(),
            ),
            ("META-INF/maven/dk.thing/thing/pom.xml", POM.as_bytes()),
        ]);
        assert_eq!(
            expr("maven", &jar).as_deref(),
            Some("(Apache-2.0) OR (MIT)")
        );
        let none = "<project><licenses><license><name>Weird</name></license></licenses></project>";
        assert_eq!(expr("maven", none.as_bytes()), None);
    }

    #[test]
    fn gem_reads_the_gemspec() {
        assert_eq!(
            expr("gem", &gem("licenses:\n- MIT\n- Ruby\n")).as_deref(),
            Some("(MIT) AND (Ruby)")
        );
        assert_eq!(
            expr("gem", &gem("licenses: [\"Apache-2.0\"]\n")).as_deref(),
            Some("Apache-2.0")
        );
        assert!(detect("gem", &gem("licenses: []\n")).is_none());
    }

    #[test]
    fn deb_reads_a_machine_readable_copyright() {
        for xz in [false, true] {
            let d = detect("deb", &deb(DEP5, xz)).unwrap();
            assert_eq!(
                d.expression.as_deref(),
                Some("(MIT) AND (GPL-2.0-or-later)"),
                "xz={xz}: {d:?}"
            );
        }
        let free = detect(
            "deb",
            &deb(
                "This package was debianized by someone.\nIt is GPL.\n",
                false,
            ),
        )
        .unwrap();
        assert_eq!(free.expression, None);
    }

    #[test]
    fn helm_reads_the_chart_annotation() {
        let d = detect("helm", &chart("artifacthub.io/license: Apache-2.0")).unwrap();
        assert_eq!(
            (d.format.as_str(), d.expression.as_deref()),
            ("helm", Some("Apache-2.0"))
        );
        assert_eq!(
            expr("helm", &chart("licenses: \"MIT\"")).as_deref(),
            Some("MIT")
        );
        assert!(
            detect("helm", &chart("category2: x")).is_none(),
            "a sub-chart's annotation is not the chart's"
        );
    }

    #[test]
    fn bytes_that_are_no_package_declare_nothing() {
        for b in [
            &b"not a package"[..],
            b"",
            b"PK\x03\x04broken",
            &[0x1f, 0x8b, 0, 1],
        ] {
            assert!(detect("cargo", b).is_none());
        }
    }

    #[test]
    fn the_scanner_fills_the_license_slot() {
        let s = LicenseScanner::new(deny_gpl());
        assert_eq!(s.kind(), ScannerKind::License);
        assert!(
            s.version().contains("GPL-3.0"),
            "the policy is in the version: {}",
            s.version()
        );
        let bytes = krate("GPL-3.0");
        let out = s
            .scan(&ScanSubject::new("thing-1.0.0.crate", "cargo", &bytes))
            .unwrap();
        assert_eq!(out.verdict, ScanVerdict::Block);
        assert!(out.detail.contains("Cargo.toml"), "{}", out.detail);
    }
}