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.starts_with(prefix)
} else {
license_id.eq_ignore_ascii_case(pattern)
}
}
fn evaluate_expression(expr: &LicenseExpression, config: &LicensePolicyConfig) -> PolicyDecision {
let license_id = &expr.expression;
for pattern in &config.deny {
if matches_pattern(license_id, pattern) {
return PolicyDecision::Denied;
}
}
for pattern in &config.review {
if matches_pattern(license_id, pattern) {
return PolicyDecision::NeedsReview;
}
}
if config.allow.is_empty() {
return PolicyDecision::Unspecified;
}
for pattern in &config.allow {
if matches_pattern(license_id, pattern) {
return PolicyDecision::Allowed;
}
}
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.declared {
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 component_denied {
denied_count += 1;
} else if component_review {
review_count += 1;
} else {
allowed_count += 1;
}
}
if config.fail_on_conflict {
for comp in sbom.components.values() {
if comp.licenses.has_conflicts() {
let license_str = comp
.licenses
.declared
.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,
});
}
}
}
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
}
#[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("MIT", "BSD-*"));
assert!(matches_pattern("MIT", "MIT"));
assert!(matches_pattern("mit", "MIT")); }
#[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);
}
}