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("<", "<")
.replace(">", ">")
.replace(""", "\"")
.replace("'", "'")
.replace("&", "&")
}
/// 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);
}
}