use std::sync::atomic::{AtomicBool, Ordering};
use crate::diff::ChangeSet;
use crate::enrich::{LicenseViolation, LicenseViolationKind};
use crate::model::Component;
#[derive(Debug, Clone, Default)]
pub struct Policy {
pub allow: Vec<String>,
pub deny: Vec<String>,
pub allow_ambiguous: bool,
pub allow_exceptions: Vec<String>,
pub deny_exceptions: Vec<String>,
}
impl Policy {
pub fn is_active(&self) -> bool {
!self.allow.is_empty()
|| !self.deny.is_empty()
|| !self.allow_exceptions.is_empty()
|| !self.deny_exceptions.is_empty()
}
}
pub fn enrich(cs: &ChangeSet, policy: &Policy) -> Vec<LicenseViolation> {
if !policy.is_active() {
return Vec::new();
}
if policy.allow_ambiguous {
warn_deprecated_allow_ambiguous_once();
}
let mut out = Vec::new();
for c in &cs.added {
evaluate_component(c, policy, &mut out);
}
for (_before, after) in &cs.version_changed {
evaluate_component(after, policy, &mut out);
}
out
}
fn evaluate_component(c: &Component, policy: &Policy, out: &mut Vec<LicenseViolation>) {
if c.licenses.is_empty() {
if !policy.allow_ambiguous {
out.push(LicenseViolation {
component: c.clone(),
license: "(empty)".to_string(),
matched_rule: "ambiguous: empty license set".to_string(),
kind: LicenseViolationKind::Ambiguous,
});
}
return;
}
for lic in &c.licenses {
if let Some(v) = evaluate_one(c, lic, policy) {
out.push(v);
}
}
}
fn evaluate_one(c: &Component, lic: &str, policy: &Policy) -> Option<LicenseViolation> {
let trimmed = lic.trim();
let upper = trimmed.to_ascii_uppercase();
let is_unknown_marker = matches!(upper.as_str(), "" | "NOASSERTION" | "OTHER");
if is_unknown_marker {
if policy.allow_ambiguous {
return None;
}
return Some(LicenseViolation {
component: c.clone(),
license: trimmed.to_string(),
matched_rule: format!("ambiguous: {trimmed}"),
kind: LicenseViolationKind::Ambiguous,
});
}
match spdx::Expression::parse(trimmed) {
Ok(expr) => evaluate_spdx(c, trimmed, &expr, policy),
Err(_) => evaluate_atomic_fallback(c, trimmed, policy),
}
}
fn evaluate_spdx(
c: &Component,
raw: &str,
expr: &spdx::Expression,
policy: &Policy,
) -> Option<LicenseViolation> {
if !policy.deny.is_empty() {
for req in expr.requirements() {
for cand in canonical_names(&req.req.license) {
if let Some(rule) = matches_any(&cand, &policy.deny) {
return Some(LicenseViolation {
component: c.clone(),
license: raw.to_string(),
matched_rule: format!("deny: {rule}"),
kind: LicenseViolationKind::Deny,
});
}
}
}
}
let exception_policy_active =
!policy.allow_exceptions.is_empty() || !policy.deny_exceptions.is_empty();
let needs_eval = !policy.allow.is_empty() || exception_policy_active;
if !needs_eval {
return None;
}
let ok = expr.evaluate(|req| matches!(eval_leaf(req, policy), LeafOutcome::Permitted));
if ok {
return None;
}
let is_compound = expr.requirements().count() > 1;
let failure = pick_leaf_failure(expr, policy, exception_policy_active);
let (mut matched_rule, kind) = match failure {
Some((rule, kind)) => (rule, kind),
None => (
format!("not in allow list: {raw}"),
LicenseViolationKind::NotAllowed,
),
};
if is_compound && !matched_rule.contains(" (in ") {
matched_rule.push_str(&format!(" (in {raw})"));
}
Some(LicenseViolation {
component: c.clone(),
license: raw.to_string(),
matched_rule,
kind,
})
}
#[derive(Debug, Clone)]
enum LeafOutcome {
Permitted,
DeniedBase(String),
DeniedException(String),
NotInAllowedException(String),
}
fn eval_leaf(req: &spdx::LicenseReq, policy: &Policy) -> LeafOutcome {
if !policy.allow.is_empty() {
let names = canonical_names(&req.license);
let base_allowed = names
.iter()
.any(|cand| matches_any(cand, &policy.allow).is_some());
if !base_allowed {
let cited = names
.into_iter()
.next()
.unwrap_or_else(|| "(unknown)".to_string());
return LeafOutcome::DeniedBase(cited);
}
}
if let Some(exception) = &req.exception {
let ex_name = exception.name;
if policy.deny_exceptions.iter().any(|d| d == ex_name) {
return LeafOutcome::DeniedException(ex_name.to_string());
}
if !policy.allow_exceptions.is_empty()
&& !policy.allow_exceptions.iter().any(|a| a == ex_name)
{
return LeafOutcome::NotInAllowedException(ex_name.to_string());
}
}
LeafOutcome::Permitted
}
fn pick_leaf_failure(
expr: &spdx::Expression,
policy: &Policy,
prefer_exception: bool,
) -> Option<(String, LicenseViolationKind)> {
let outcomes: Vec<LeafOutcome> = expr
.requirements()
.map(|er| eval_leaf(&er.req, policy))
.collect();
if prefer_exception {
for o in &outcomes {
match o {
LeafOutcome::DeniedException(name) => {
return Some((
format!("exception:{name} denied"),
LicenseViolationKind::Deny,
));
}
LeafOutcome::NotInAllowedException(name) => {
return Some((
format!("exception:{name} not in allow list"),
LicenseViolationKind::NotAllowed,
));
}
_ => {}
}
}
}
for o in &outcomes {
match o {
LeafOutcome::DeniedException(name) => {
return Some((
format!("exception:{name} denied"),
LicenseViolationKind::Deny,
));
}
LeafOutcome::NotInAllowedException(name) => {
return Some((
format!("exception:{name} not in allow list"),
LicenseViolationKind::NotAllowed,
));
}
LeafOutcome::DeniedBase(name) => {
return Some((
format!("not in allow list: {name}"),
LicenseViolationKind::NotAllowed,
));
}
LeafOutcome::Permitted => {}
}
}
None
}
fn canonical_names(item: &spdx::LicenseItem) -> Vec<String> {
match item {
spdx::LicenseItem::Spdx { id, or_later } => {
let mut names = vec![id.name.to_string()];
if id.is_gnu() {
if *or_later {
names.push(format!("{}-or-later", id.name));
} else {
names.push(format!("{}-only", id.name));
}
} else if *or_later {
names.push(format!("{}+", id.name));
}
names
}
spdx::LicenseItem::Other { lic_ref, .. } => vec![lic_ref.clone()],
}
}
fn evaluate_atomic_fallback(
c: &Component,
trimmed: &str,
policy: &Policy,
) -> Option<LicenseViolation> {
let is_compound = is_compound_expression(trimmed);
if is_compound {
if policy.allow_ambiguous {
return None;
}
return Some(LicenseViolation {
component: c.clone(),
license: trimmed.to_string(),
matched_rule: format!("ambiguous: {trimmed}"),
kind: LicenseViolationKind::Ambiguous,
});
}
if let Some(rule) = matches_any(trimmed, &policy.deny) {
return Some(LicenseViolation {
component: c.clone(),
license: trimmed.to_string(),
matched_rule: format!("deny: {rule}"),
kind: LicenseViolationKind::Deny,
});
}
if !policy.allow.is_empty() && matches_any(trimmed, &policy.allow).is_none() {
return Some(LicenseViolation {
component: c.clone(),
license: trimmed.to_string(),
matched_rule: format!("not in allow list: {trimmed}"),
kind: LicenseViolationKind::NotAllowed,
});
}
None
}
fn matches_any(lic: &str, patterns: &[String]) -> Option<String> {
for p in patterns {
if matches_pattern(lic, p) {
return Some(p.clone());
}
}
None
}
fn matches_pattern(lic: &str, pattern: &str) -> bool {
if let Some(prefix) = pattern.strip_suffix('*') {
lic.starts_with(prefix)
} else {
lic == pattern
}
}
fn is_compound_expression(s: &str) -> bool {
if s.contains('(') || s.contains(')') {
return true;
}
for token in s.split_whitespace() {
if matches!(token, "AND" | "OR" | "WITH") {
return true;
}
}
false
}
static ALLOW_AMBIGUOUS_WARNED: AtomicBool = AtomicBool::new(false);
fn warn_deprecated_allow_ambiguous_once() {
if ALLOW_AMBIGUOUS_WARNED.swap(true, Ordering::Relaxed) {
return;
}
eprintln!(
"warning: [license] allow_ambiguous is deprecated since v0.9; \
SPDX expressions are now evaluated properly."
);
}
#[cfg(test)]
mod tests {
#![allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::todo,
clippy::unimplemented
)]
use super::*;
use crate::model::{Ecosystem, Relationship};
fn comp(name: &str, licenses: Vec<&str>) -> Component {
Component {
name: name.into(),
version: "1.0.0".into(),
ecosystem: Ecosystem::Npm,
purl: Some(format!("pkg:npm/{name}@1.0.0")),
licenses: licenses.into_iter().map(String::from).collect(),
supplier: None,
hashes: Vec::new(),
relationship: Relationship::Unknown,
source_url: None,
bom_ref: None,
}
}
fn cs_with_added(c: Component) -> ChangeSet {
ChangeSet {
added: vec![c],
..Default::default()
}
}
#[test]
fn allow_pass_no_violation() {
let cs = cs_with_added(comp("foo", vec!["MIT"]));
let policy = Policy {
allow: vec!["MIT".into(), "Apache-2.0".into()],
..Default::default()
};
assert!(enrich(&cs, &policy).is_empty());
}
#[test]
fn deny_fail_violation() {
let cs = cs_with_added(comp("foo", vec!["GPL-3.0-only"]));
let policy = Policy {
deny: vec!["GPL-3.0-only".into()],
..Default::default()
};
let v = enrich(&cs, &policy);
assert_eq!(v.len(), 1);
assert_eq!(v[0].kind, LicenseViolationKind::Deny);
}
#[test]
fn glob_expansion_matches_prefix() {
let cs = cs_with_added(comp("foo", vec!["AGPL-3.0-only"]));
let policy = Policy {
deny: vec!["AGPL-*".into()],
..Default::default()
};
let v = enrich(&cs, &policy);
assert_eq!(v.len(), 1);
assert_eq!(v[0].matched_rule, "deny: AGPL-*");
}
#[test]
fn deny_wins_over_allow_when_both_match() {
let cs = cs_with_added(comp("foo", vec!["GPL-3.0-only"]));
let policy = Policy {
allow: vec!["GPL-3.0-only".into()],
deny: vec!["GPL-3.0-only".into()],
..Default::default()
};
let v = enrich(&cs, &policy);
assert_eq!(v.len(), 1);
assert_eq!(v[0].kind, LicenseViolationKind::Deny);
}
#[test]
fn license_not_in_allow_list_violates() {
let cs = cs_with_added(comp("foo", vec!["BSD-3-Clause"]));
let policy = Policy {
allow: vec!["MIT".into()],
..Default::default()
};
let v = enrich(&cs, &policy);
assert_eq!(v.len(), 1);
assert_eq!(v[0].kind, LicenseViolationKind::NotAllowed);
}
#[test]
fn noassertion_treated_as_ambiguous() {
let cs = cs_with_added(comp("foo", vec!["NOASSERTION"]));
let policy = Policy {
allow: vec!["MIT".into()],
..Default::default()
};
let v = enrich(&cs, &policy);
assert_eq!(v.len(), 1);
assert_eq!(v[0].kind, LicenseViolationKind::Ambiguous);
}
#[test]
fn empty_policy_is_inactive() {
let cs = cs_with_added(comp("foo", vec!["GPL-3.0-only"]));
let policy = Policy::default();
assert!(enrich(&cs, &policy).is_empty());
}
#[test]
fn version_changed_components_evaluated() {
let before = comp("foo", vec!["MIT"]);
let mut after = comp("foo", vec!["GPL-3.0-only"]);
after.version = "2.0.0".into();
let cs = ChangeSet {
version_changed: vec![(before, after)],
..Default::default()
};
let policy = Policy {
deny: vec!["GPL-3.0-only".into()],
..Default::default()
};
let v = enrich(&cs, &policy);
assert_eq!(v.len(), 1);
}
#[test]
fn spdx_or_with_one_allowed_branch_permits() {
let cs = cs_with_added(comp("foo", vec!["(MIT OR Apache-2.0)"]));
let policy = Policy {
allow: vec!["MIT".into()],
..Default::default()
};
assert!(enrich(&cs, &policy).is_empty());
}
#[test]
fn spdx_and_with_one_denied_branch_violates() {
let cs = cs_with_added(comp("foo", vec!["(MIT AND GPL-3.0-only)"]));
let policy = Policy {
deny: vec!["GPL-3.0-only".into()],
..Default::default()
};
let v = enrich(&cs, &policy);
assert_eq!(v.len(), 1);
assert_eq!(v[0].kind, LicenseViolationKind::Deny);
}
#[test]
fn spdx_with_exception_resolves_base_license() {
let cs = cs_with_added(comp("foo", vec!["Apache-2.0 WITH LLVM-exception"]));
let policy = Policy {
allow: vec!["Apache-2.0".into()],
..Default::default()
};
assert!(enrich(&cs, &policy).is_empty());
}
#[test]
fn spdx_compound_denial_wins_over_or_branches() {
let cs = cs_with_added(comp("foo", vec!["(GPL-3.0-only OR MIT) AND BSD-3-Clause"]));
let policy = Policy {
allow: vec!["MIT".into(), "BSD-3-Clause".into()],
deny: vec!["GPL-3.0-only".into()],
..Default::default()
};
let v = enrich(&cs, &policy);
assert_eq!(v.len(), 1);
assert_eq!(v[0].kind, LicenseViolationKind::Deny);
}
#[test]
fn unknown_spdx_id_falls_back_to_atomic_path() {
let cs = cs_with_added(comp("foo", vec!["Custom"]));
let policy = Policy {
allow: vec!["MIT".into()],
..Default::default()
};
let v = enrich(&cs, &policy);
assert_eq!(v.len(), 1);
assert_eq!(v[0].kind, LicenseViolationKind::NotAllowed);
}
#[test]
fn spdx_with_exception_back_compat_when_no_exception_policy() {
let cs = cs_with_added(comp("foo", vec!["Apache-2.0 WITH LLVM-exception"]));
let policy = Policy {
allow: vec!["Apache-2.0".into()],
..Default::default()
};
assert!(enrich(&cs, &policy).is_empty());
}
#[test]
fn spdx_exception_in_deny_list_violates_and_cites_exception() {
let cs = cs_with_added(comp("foo", vec!["Apache-2.0 WITH LLVM-exception"]));
let policy = Policy {
allow: vec!["Apache-2.0".into()],
deny_exceptions: vec!["LLVM-exception".into()],
..Default::default()
};
let v = enrich(&cs, &policy);
assert_eq!(v.len(), 1);
assert_eq!(v[0].kind, LicenseViolationKind::Deny);
assert_eq!(v[0].matched_rule, "exception:LLVM-exception denied");
}
#[test]
fn spdx_exception_not_in_allow_list_fails_closed() {
let cs = cs_with_added(comp("foo", vec!["Apache-2.0 WITH LLVM-exception"]));
let policy = Policy {
allow: vec!["Apache-2.0".into()],
allow_exceptions: vec!["Classpath-exception-2.0".into()],
..Default::default()
};
let v = enrich(&cs, &policy);
assert_eq!(v.len(), 1);
assert_eq!(v[0].kind, LicenseViolationKind::NotAllowed);
assert_eq!(
v[0].matched_rule,
"exception:LLVM-exception not in allow list"
);
}
#[test]
fn spdx_exception_or_branch_permits_when_sibling_path_passes() {
let cs = cs_with_added(comp(
"foo",
vec!["(Apache-2.0 WITH LLVM-exception) OR BSD-3-Clause"],
));
let policy = Policy {
allow: vec!["Apache-2.0".into(), "BSD-3-Clause".into()],
deny_exceptions: vec!["LLVM-exception".into()],
..Default::default()
};
assert!(
enrich(&cs, &policy).is_empty(),
"OR sibling without exception must permit"
);
}
#[test]
fn spdx_exception_in_allow_list_permits() {
let cs = cs_with_added(comp("foo", vec!["Apache-2.0 WITH LLVM-exception"]));
let policy = Policy {
allow: vec!["Apache-2.0".into()],
allow_exceptions: vec!["LLVM-exception".into()],
..Default::default()
};
assert!(enrich(&cs, &policy).is_empty());
}
#[test]
fn spdx_and_with_allowed_exception_permits() {
let cs = cs_with_added(comp(
"foo",
vec!["(Apache-2.0 WITH LLVM-exception) AND BSD-3-Clause"],
));
let policy = Policy {
allow: vec!["Apache-2.0".into(), "BSD-3-Clause".into()],
allow_exceptions: vec!["LLVM-exception".into()],
..Default::default()
};
assert!(enrich(&cs, &policy).is_empty());
}
#[test]
fn spdx_and_with_denied_exception_violates_and_cites_in_compound() {
let raw = "(Apache-2.0 WITH LLVM-exception) AND BSD-3-Clause";
let cs = cs_with_added(comp("foo", vec![raw]));
let policy = Policy {
allow: vec!["Apache-2.0".into(), "BSD-3-Clause".into()],
deny_exceptions: vec!["LLVM-exception".into()],
..Default::default()
};
let v = enrich(&cs, &policy);
assert_eq!(v.len(), 1);
assert_eq!(v[0].kind, LicenseViolationKind::Deny);
assert!(
v[0].matched_rule.contains("LLVM-exception"),
"matched_rule must cite the offending exception: {}",
v[0].matched_rule
);
assert!(
v[0].matched_rule.contains(&format!("(in {raw})")),
"compound matched_rule must append (in <raw>): {}",
v[0].matched_rule
);
}
#[test]
fn spdx_or_with_one_allowed_exception_branch_permits() {
let cs = cs_with_added(comp(
"foo",
vec!["(Apache-2.0 WITH LLVM-exception) OR (Apache-2.0 WITH Classpath-exception-2.0)"],
));
let policy = Policy {
allow: vec!["Apache-2.0".into()],
allow_exceptions: vec!["LLVM-exception".into()],
..Default::default()
};
assert!(
enrich(&cs, &policy).is_empty(),
"OR sibling permits when one branch is fully allowed"
);
}
#[test]
fn spdx_or_with_both_exceptions_denied_violates() {
let raw = "(Apache-2.0 WITH LLVM-exception) OR (Apache-2.0 WITH Classpath-exception-2.0)";
let cs = cs_with_added(comp("foo", vec![raw]));
let policy = Policy {
allow: vec!["Apache-2.0".into()],
deny_exceptions: vec!["LLVM-exception".into(), "Classpath-exception-2.0".into()],
..Default::default()
};
let v = enrich(&cs, &policy);
assert_eq!(v.len(), 1);
assert_eq!(v[0].kind, LicenseViolationKind::Deny);
assert!(
v[0].matched_rule.contains("LLVM-exception")
|| v[0].matched_rule.contains("Classpath-exception-2.0"),
"matched_rule must cite a denied exception: {}",
v[0].matched_rule
);
assert!(
v[0].matched_rule.contains("(in "),
"compound matched_rule must append (in <raw>): {}",
v[0].matched_rule
);
}
#[test]
fn spdx_and_inherits_exception_denial_from_either_side() {
let raw = "MIT AND (Apache-2.0 WITH LLVM-exception)";
let cs = cs_with_added(comp("foo", vec![raw]));
let policy = Policy {
allow: vec!["MIT".into(), "Apache-2.0".into()],
deny_exceptions: vec!["LLVM-exception".into()],
..Default::default()
};
let v = enrich(&cs, &policy);
assert_eq!(v.len(), 1);
assert_eq!(v[0].kind, LicenseViolationKind::Deny);
assert!(
v[0].matched_rule.contains("LLVM-exception"),
"matched_rule must cite LLVM-exception: {}",
v[0].matched_rule
);
}
#[test]
fn spdx_compound_without_exceptions_back_compat() {
let cs = cs_with_added(comp("foo", vec!["MIT OR Apache-2.0"]));
let policy = Policy {
allow: vec!["MIT".into()],
..Default::default()
};
assert!(enrich(&cs, &policy).is_empty());
let cs = cs_with_added(comp("foo", vec!["MIT AND BSD-3-Clause"]));
let policy = Policy {
allow: vec!["MIT".into()],
..Default::default()
};
let v = enrich(&cs, &policy);
assert_eq!(v.len(), 1);
assert_eq!(v[0].kind, LicenseViolationKind::NotAllowed);
assert!(
v[0].matched_rule.contains("BSD-3-Clause"),
"matched_rule must cite the failing leaf: {}",
v[0].matched_rule
);
}
#[test]
fn compound_exception_violation_fingerprint_distinct_from_base_only() {
let raw = "(Apache-2.0 WITH LLVM-exception) AND BSD-3-Clause";
let v_compound = LicenseViolation {
component: comp("foo", vec![raw]),
license: raw.into(),
matched_rule: format!("exception:LLVM-exception denied (in {raw})"),
kind: LicenseViolationKind::Deny,
};
let v_base = LicenseViolation {
component: comp("foo", vec!["Apache-2.0"]),
license: "Apache-2.0".into(),
matched_rule: "deny: Apache-2.0".into(),
kind: LicenseViolationKind::Deny,
};
let id_compound = crate::vex::synthetic_id::license_violation(&v_compound);
let id_base = crate::vex::synthetic_id::license_violation(&v_base);
assert_ne!(
id_compound, id_base,
"compound exception violation must have a distinct synthetic id"
);
let id_compound_again = crate::vex::synthetic_id::license_violation(&v_compound);
assert_eq!(id_compound, id_compound_again);
}
#[test]
fn exception_violation_synthetic_id_round_trips_distinctly() {
let v_exception = LicenseViolation {
component: comp("foo", vec!["Apache-2.0 WITH LLVM-exception"]),
license: "Apache-2.0 WITH LLVM-exception".into(),
matched_rule: "exception:LLVM-exception denied".into(),
kind: LicenseViolationKind::Deny,
};
let v_base = LicenseViolation {
component: comp("foo", vec!["Apache-2.0"]),
license: "Apache-2.0".into(),
matched_rule: "deny: Apache-2.0".into(),
kind: LicenseViolationKind::Deny,
};
let id_exception = crate::vex::synthetic_id::license_violation(&v_exception);
let id_base = crate::vex::synthetic_id::license_violation(&v_base);
assert_ne!(
id_exception, id_base,
"exception-driven violation must have a distinct synthetic id"
);
let parsed = crate::vex::parse_synthetic_id(&id_exception).expect("round-trips");
match parsed {
crate::vex::SyntheticFindingKind::LicenseViolation { license, .. } => {
assert_eq!(license, "Apache-2.0 WITH LLVM-exception");
}
other => panic!("unexpected variant: {other:?}"),
}
}
}