use crate::model::{LicenseExpression, LicenseFamily, NormalizedSbom};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct LicensePolicyConfig {
#[serde(default)]
pub allow: Vec<String>,
#[serde(default)]
pub deny: Vec<String>,
#[serde(default)]
pub review: Vec<String>,
#[serde(default = "default_true")]
pub fail_on_conflict: bool,
}
fn default_true() -> bool {
true
}
impl LicensePolicyConfig {
#[must_use]
pub fn permissive() -> Self {
Self::default()
}
#[must_use]
pub fn strict_permissive() -> Self {
Self {
allow: vec![
"MIT".to_string(),
"Apache-2.0".to_string(),
"BSD-2-Clause".to_string(),
"BSD-3-Clause".to_string(),
"ISC".to_string(),
"0BSD".to_string(),
"Unlicense".to_string(),
"CC0-1.0".to_string(),
],
deny: vec![
"AGPL-*".to_string(),
"SSPL-*".to_string(),
"BSL-*".to_string(),
],
review: vec![
"GPL-*".to_string(),
"LGPL-*".to_string(),
"MPL-*".to_string(),
"EPL-*".to_string(),
"CDDL-*".to_string(),
],
fail_on_conflict: true,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum PolicyDecision {
Allowed,
Denied,
NeedsReview,
Unspecified,
Undeclared,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LicensePolicyViolation {
pub component: String,
pub version: Option<String>,
pub license: String,
pub decision: PolicyDecision,
pub family: LicenseFamily,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LicensePolicyResult {
pub total_components: usize,
pub allowed_count: usize,
pub denied_count: usize,
pub review_count: usize,
pub undeclared_count: usize,
pub passed: bool,
pub violations: Vec<LicensePolicyViolation>,
}
fn matches_pattern(license_id: &str, pattern: &str) -> bool {
if let Some(prefix) = pattern.strip_suffix('*') {
license_id
.get(..prefix.len())
.is_some_and(|head| head.eq_ignore_ascii_case(prefix))
} else {
license_id.eq_ignore_ascii_case(pattern)
}
}
fn matches_any(license_id: &str, patterns: &[String]) -> bool {
patterns
.iter()
.any(|pattern| matches_pattern(license_id, pattern))
}
fn req_license_id(req: &spdx::LicenseReq) -> String {
req.license.to_string()
}
fn evaluate_license_id(license_id: &str, config: &LicensePolicyConfig) -> PolicyDecision {
if matches_any(license_id, &config.deny) {
return PolicyDecision::Denied;
}
if matches_any(license_id, &config.review) {
return PolicyDecision::NeedsReview;
}
if config.allow.is_empty() {
return PolicyDecision::Unspecified;
}
if matches_any(license_id, &config.allow) {
return PolicyDecision::Allowed;
}
PolicyDecision::NeedsReview
}
fn evaluate_expression(expr: &LicenseExpression, config: &LicensePolicyConfig) -> PolicyDecision {
let Ok(parsed) = spdx::Expression::parse_mode(&expr.expression, spdx::ParseMode::LAX) else {
return evaluate_license_id(&expr.expression, config);
};
if !parsed.evaluate(|req| !matches_any(&req_license_id(req), &config.deny)) {
return PolicyDecision::Denied;
}
if config.allow.is_empty() {
let clean = parsed.evaluate(|req| {
let id = req_license_id(req);
!matches_any(&id, &config.deny) && !matches_any(&id, &config.review)
});
if clean {
PolicyDecision::Unspecified
} else {
PolicyDecision::NeedsReview
}
} else {
let allowed = parsed.evaluate(|req| {
let id = req_license_id(req);
!matches_any(&id, &config.deny) && matches_any(&id, &config.allow)
});
if allowed {
PolicyDecision::Allowed
} else {
PolicyDecision::NeedsReview
}
}
}
#[must_use]
pub fn evaluate_license_policy(
sbom: &NormalizedSbom,
config: &LicensePolicyConfig,
) -> LicensePolicyResult {
let mut allowed_count = 0;
let mut denied_count = 0;
let mut review_count = 0;
let mut undeclared_count = 0;
let mut violations = Vec::new();
for comp in sbom.components.values() {
if comp.licenses.declared.is_empty() && comp.licenses.concluded.is_none() {
undeclared_count += 1;
violations.push(LicensePolicyViolation {
component: comp.name.clone(),
version: comp.version.clone(),
license: "(undeclared)".to_string(),
decision: PolicyDecision::Undeclared,
family: LicenseFamily::Other,
});
continue;
}
let mut component_denied = false;
let mut component_review = false;
for license in comp.licenses.all_licenses() {
let decision = evaluate_expression(license, config);
match decision {
PolicyDecision::Denied => {
component_denied = true;
violations.push(LicensePolicyViolation {
component: comp.name.clone(),
version: comp.version.clone(),
license: license.expression.clone(),
decision: PolicyDecision::Denied,
family: license.family(),
});
}
PolicyDecision::NeedsReview => {
component_review = true;
violations.push(LicensePolicyViolation {
component: comp.name.clone(),
version: comp.version.clone(),
license: license.expression.clone(),
decision: PolicyDecision::NeedsReview,
family: license.family(),
});
}
PolicyDecision::Allowed | PolicyDecision::Unspecified => {}
PolicyDecision::Undeclared => {}
}
}
if config.fail_on_conflict && comp.licenses.has_conflicts() {
component_denied = true;
let license_str = comp
.licenses
.all_licenses()
.iter()
.map(|l| l.expression.as_str())
.collect::<Vec<_>>()
.join(" + ");
violations.push(LicensePolicyViolation {
component: comp.name.clone(),
version: comp.version.clone(),
license: format!("CONFLICT: {license_str}"),
decision: PolicyDecision::Denied,
family: LicenseFamily::Other,
});
}
if component_denied {
denied_count += 1;
} else if component_review {
review_count += 1;
} else {
allowed_count += 1;
}
}
let passed = denied_count == 0;
LicensePolicyResult {
total_components: sbom.components.len(),
allowed_count,
denied_count,
review_count,
undeclared_count,
passed,
violations,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::Component;
fn make_sbom_with_licenses(licenses: &[&str]) -> NormalizedSbom {
let mut sbom = NormalizedSbom::default();
for (i, lic) in licenses.iter().enumerate() {
let mut comp = Component::new(format!("comp-{i}"), format!("id-{i}"));
if !lic.is_empty() {
comp.licenses
.add_declared(LicenseExpression::new(lic.to_string()));
}
sbom.components.insert(comp.canonical_id.clone(), comp);
}
sbom
}
fn make_sbom_with_component(declared: &[&str], concluded: Option<&str>) -> NormalizedSbom {
let mut sbom = NormalizedSbom::default();
let mut comp = Component::new("comp-0".to_string(), "id-0".to_string());
for lic in declared {
comp.licenses
.add_declared(LicenseExpression::new((*lic).to_string()));
}
comp.licenses.concluded = concluded.map(|c| LicenseExpression::new(c.to_string()));
sbom.components.insert(comp.canonical_id.clone(), comp);
sbom
}
#[test]
fn permissive_policy_allows_all() {
let sbom = make_sbom_with_licenses(&["MIT", "Apache-2.0", "GPL-3.0-only"]);
let config = LicensePolicyConfig::permissive();
let result = evaluate_license_policy(&sbom, &config);
assert!(result.passed);
assert_eq!(result.denied_count, 0);
}
#[test]
fn strict_policy_denies_agpl() {
let sbom = make_sbom_with_licenses(&["MIT", "AGPL-3.0-only"]);
let config = LicensePolicyConfig::strict_permissive();
let result = evaluate_license_policy(&sbom, &config);
assert!(!result.passed);
assert_eq!(result.denied_count, 1);
}
#[test]
fn strict_policy_flags_gpl_for_review() {
let sbom = make_sbom_with_licenses(&["MIT", "GPL-3.0-only"]);
let config = LicensePolicyConfig::strict_permissive();
let result = evaluate_license_policy(&sbom, &config);
assert!(result.passed); assert_eq!(result.review_count, 1);
}
#[test]
fn undeclared_licenses_flagged() {
let sbom = make_sbom_with_licenses(&["MIT", ""]);
let config = LicensePolicyConfig::strict_permissive();
let result = evaluate_license_policy(&sbom, &config);
assert_eq!(result.undeclared_count, 1);
}
#[test]
fn glob_pattern_matching() {
assert!(matches_pattern("BSD-2-Clause", "BSD-*"));
assert!(matches_pattern("AGPL-3.0-only", "AGPL-*"));
assert!(matches_pattern("agpl-3.0-only", "AGPL-*")); assert!(!matches_pattern("MIT", "BSD-*"));
assert!(matches_pattern("MIT", "MIT"));
assert!(matches_pattern("mit", "MIT")); }
#[test]
fn conflict_fails_policy() {
let sbom = make_sbom_with_component(&["GPL-3.0-only", "Proprietary"], None);
let config = LicensePolicyConfig {
fail_on_conflict: true,
..Default::default()
};
let result = evaluate_license_policy(&sbom, &config);
assert!(!result.passed);
assert_eq!(result.denied_count, 1);
assert!(result.violations.iter().any(|v| {
v.license.starts_with("CONFLICT:") && v.decision == PolicyDecision::Denied
}));
}
#[test]
fn fail_on_conflict_false_skips() {
let sbom = make_sbom_with_component(&["GPL-3.0-only", "Proprietary"], None);
let config = LicensePolicyConfig {
fail_on_conflict: false,
..Default::default()
};
let result = evaluate_license_policy(&sbom, &config);
assert!(result.passed);
assert_eq!(result.denied_count, 0);
}
#[test]
fn concluded_only_license_evaluated() {
let sbom = make_sbom_with_component(&[], Some("AGPL-3.0-only"));
let config = LicensePolicyConfig::strict_permissive();
let result = evaluate_license_policy(&sbom, &config);
assert!(!result.passed);
assert_eq!(result.denied_count, 1);
assert_eq!(result.undeclared_count, 0);
}
#[test]
fn or_expression_denied_only_if_all_alternatives_denied() {
let config = LicensePolicyConfig {
deny: vec!["GPL-*".to_string()],
..Default::default()
};
let choice = make_sbom_with_component(&["MIT OR GPL-3.0-only"], None);
let result = evaluate_license_policy(&choice, &config);
assert!(result.passed);
assert_eq!(result.denied_count, 0);
let no_choice = make_sbom_with_component(&["GPL-2.0-only OR GPL-3.0-only"], None);
let result = evaluate_license_policy(&no_choice, &config);
assert!(!result.passed);
assert_eq!(result.denied_count, 1);
}
#[test]
fn and_expression_denied_if_any_operand_denied() {
let config = LicensePolicyConfig {
deny: vec!["GPL-*".to_string()],
..Default::default()
};
let sbom = make_sbom_with_component(&["MIT AND GPL-3.0-only"], None);
let result = evaluate_license_policy(&sbom, &config);
assert!(!result.passed);
assert_eq!(result.denied_count, 1);
}
#[test]
fn or_with_allow_list_chooses_allowed_branch() {
let config = LicensePolicyConfig {
allow: vec!["MIT".to_string()],
review: vec!["GPL-*".to_string()],
..Default::default()
};
let sbom = make_sbom_with_component(&["MIT OR GPL-3.0-only"], None);
let result = evaluate_license_policy(&sbom, &config);
assert!(result.passed);
assert_eq!(result.allowed_count, 1);
assert_eq!(result.review_count, 0);
}
#[test]
fn with_exception_matches_base_id() {
let sbom = make_sbom_with_component(&["Apache-2.0 WITH LLVM-exception"], None);
let allow_config = LicensePolicyConfig {
allow: vec!["Apache-2.0".to_string()],
..Default::default()
};
let result = evaluate_license_policy(&sbom, &allow_config);
assert_eq!(result.allowed_count, 1);
let deny_config = LicensePolicyConfig {
deny: vec!["Apache-2.0".to_string()],
..Default::default()
};
let result = evaluate_license_policy(&sbom, &deny_config);
assert_eq!(result.denied_count, 1);
}
#[test]
fn non_spdx_falls_back_to_string_match() {
let config = LicensePolicyConfig {
deny: vec!["Commercial*".to_string()],
..Default::default()
};
let sbom = make_sbom_with_component(&["Commercial EULA v2"], None);
let result = evaluate_license_policy(&sbom, &config);
assert!(!result.passed);
assert_eq!(result.denied_count, 1);
}
#[test]
fn allow_list_requires_match() {
let sbom = make_sbom_with_licenses(&["MIT", "Artistic-2.0"]);
let config = LicensePolicyConfig {
allow: vec!["MIT".to_string()],
..Default::default()
};
let result = evaluate_license_policy(&sbom, &config);
assert_eq!(result.review_count, 1); assert_eq!(result.allowed_count, 1);
}
}