1use crate::model::{LicenseExpression, LicenseFamily, NormalizedSbom};
18use serde::{Deserialize, Serialize};
19
20#[derive(Debug, Clone, Default, Serialize, Deserialize)]
27#[serde(deny_unknown_fields)]
28pub struct LicensePolicyConfig {
29 #[serde(default)]
31 pub allow: Vec<String>,
32 #[serde(default)]
34 pub deny: Vec<String>,
35 #[serde(default)]
37 pub review: Vec<String>,
38 #[serde(default = "default_true")]
40 pub fail_on_conflict: bool,
41}
42
43fn default_true() -> bool {
44 true
45}
46
47impl LicensePolicyConfig {
48 #[must_use]
50 pub fn permissive() -> Self {
51 Self::default()
52 }
53
54 #[must_use]
56 pub fn strict_permissive() -> Self {
57 Self {
58 allow: vec![
59 "MIT".to_string(),
60 "Apache-2.0".to_string(),
61 "BSD-2-Clause".to_string(),
62 "BSD-3-Clause".to_string(),
63 "ISC".to_string(),
64 "0BSD".to_string(),
65 "Unlicense".to_string(),
66 "CC0-1.0".to_string(),
67 ],
68 deny: vec![
69 "AGPL-*".to_string(),
70 "SSPL-*".to_string(),
71 "BSL-*".to_string(),
72 ],
73 review: vec![
74 "GPL-*".to_string(),
75 "LGPL-*".to_string(),
76 "MPL-*".to_string(),
77 "EPL-*".to_string(),
78 "CDDL-*".to_string(),
79 ],
80 fail_on_conflict: true,
81 }
82 }
83}
84
85#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
87pub enum PolicyDecision {
88 Allowed,
90 Denied,
92 NeedsReview,
94 Unspecified,
96 Undeclared,
98}
99
100#[derive(Debug, Clone, Serialize, Deserialize)]
102pub struct LicensePolicyViolation {
103 pub component: String,
105 pub version: Option<String>,
107 pub license: String,
109 pub decision: PolicyDecision,
111 pub family: LicenseFamily,
113}
114
115#[derive(Debug, Clone, Serialize, Deserialize)]
117pub struct LicensePolicyResult {
118 pub total_components: usize,
120 pub allowed_count: usize,
122 pub denied_count: usize,
124 pub review_count: usize,
126 pub undeclared_count: usize,
128 pub passed: bool,
130 pub violations: Vec<LicensePolicyViolation>,
132}
133
134fn matches_pattern(license_id: &str, pattern: &str) -> bool {
136 if let Some(prefix) = pattern.strip_suffix('*') {
137 license_id
138 .get(..prefix.len())
139 .is_some_and(|head| head.eq_ignore_ascii_case(prefix))
140 } else {
141 license_id.eq_ignore_ascii_case(pattern)
142 }
143}
144
145fn matches_any(license_id: &str, patterns: &[String]) -> bool {
147 patterns
148 .iter()
149 .any(|pattern| matches_pattern(license_id, pattern))
150}
151
152fn req_license_id(req: &spdx::LicenseReq) -> String {
154 req.license.to_string()
155}
156
157fn evaluate_license_id(license_id: &str, config: &LicensePolicyConfig) -> PolicyDecision {
159 if matches_any(license_id, &config.deny) {
160 return PolicyDecision::Denied;
161 }
162
163 if matches_any(license_id, &config.review) {
164 return PolicyDecision::NeedsReview;
165 }
166
167 if config.allow.is_empty() {
168 return PolicyDecision::Unspecified;
170 }
171
172 if matches_any(license_id, &config.allow) {
173 return PolicyDecision::Allowed;
174 }
175
176 PolicyDecision::NeedsReview
178}
179
180fn evaluate_expression(expr: &LicenseExpression, config: &LicensePolicyConfig) -> PolicyDecision {
182 let Ok(parsed) = spdx::Expression::parse_mode(&expr.expression, spdx::ParseMode::LAX) else {
183 return evaluate_license_id(&expr.expression, config);
184 };
185
186 if !parsed.evaluate(|req| !matches_any(&req_license_id(req), &config.deny)) {
189 return PolicyDecision::Denied;
190 }
191
192 if config.allow.is_empty() {
193 let clean = parsed.evaluate(|req| {
194 let id = req_license_id(req);
195 !matches_any(&id, &config.deny) && !matches_any(&id, &config.review)
196 });
197 if clean {
198 PolicyDecision::Unspecified
199 } else {
200 PolicyDecision::NeedsReview
201 }
202 } else {
203 let allowed = parsed.evaluate(|req| {
204 let id = req_license_id(req);
205 !matches_any(&id, &config.deny) && matches_any(&id, &config.allow)
206 });
207 if allowed {
208 PolicyDecision::Allowed
209 } else {
210 PolicyDecision::NeedsReview
211 }
212 }
213}
214
215#[must_use]
223pub fn evaluate_license_policy(
224 sbom: &NormalizedSbom,
225 config: &LicensePolicyConfig,
226 strict: bool,
227) -> LicensePolicyResult {
228 let mut allowed_count = 0;
229 let mut denied_count = 0;
230 let mut review_count = 0;
231 let mut undeclared_count = 0;
232 let mut violations = Vec::new();
233
234 for comp in sbom.components.values() {
235 if comp.licenses.declared.is_empty() && comp.licenses.concluded.is_none() {
236 undeclared_count += 1;
237 violations.push(LicensePolicyViolation {
238 component: comp.name.clone(),
239 version: comp.version.clone(),
240 license: "(undeclared)".to_string(),
241 decision: PolicyDecision::Undeclared,
242 family: LicenseFamily::Other,
243 });
244 continue;
245 }
246
247 let mut component_denied = false;
248 let mut component_review = false;
249
250 for license in comp.licenses.all_licenses() {
251 let decision = evaluate_expression(license, config);
252 match decision {
253 PolicyDecision::Denied => {
254 component_denied = true;
255 violations.push(LicensePolicyViolation {
256 component: comp.name.clone(),
257 version: comp.version.clone(),
258 license: license.expression.clone(),
259 decision: PolicyDecision::Denied,
260 family: license.family(),
261 });
262 }
263 PolicyDecision::NeedsReview => {
264 let decision = if strict {
267 component_denied = true;
268 PolicyDecision::Denied
269 } else {
270 component_review = true;
271 PolicyDecision::NeedsReview
272 };
273 violations.push(LicensePolicyViolation {
274 component: comp.name.clone(),
275 version: comp.version.clone(),
276 license: license.expression.clone(),
277 decision,
278 family: license.family(),
279 });
280 }
281 PolicyDecision::Allowed | PolicyDecision::Unspecified => {}
282 PolicyDecision::Undeclared => {}
283 }
284 }
285
286 if config.fail_on_conflict && comp.licenses.has_conflicts() {
288 component_denied = true;
289 let license_str = comp
290 .licenses
291 .all_licenses()
292 .iter()
293 .map(|l| l.expression.as_str())
294 .collect::<Vec<_>>()
295 .join(" + ");
296 violations.push(LicensePolicyViolation {
297 component: comp.name.clone(),
298 version: comp.version.clone(),
299 license: format!("CONFLICT: {license_str}"),
300 decision: PolicyDecision::Denied,
301 family: LicenseFamily::Other,
302 });
303 }
304
305 if component_denied {
306 denied_count += 1;
307 } else if component_review {
308 review_count += 1;
309 } else {
310 allowed_count += 1;
311 }
312 }
313
314 let passed = denied_count == 0;
315
316 LicensePolicyResult {
317 total_components: sbom.components.len(),
318 allowed_count,
319 denied_count,
320 review_count,
321 undeclared_count,
322 passed,
323 violations,
324 }
325}
326
327#[cfg(test)]
328mod tests {
329 use super::*;
330 use crate::model::Component;
331
332 fn make_sbom_with_licenses(licenses: &[&str]) -> NormalizedSbom {
333 let mut sbom = NormalizedSbom::default();
334 for (i, lic) in licenses.iter().enumerate() {
335 let mut comp = Component::new(format!("comp-{i}"), format!("id-{i}"));
336 if !lic.is_empty() {
337 comp.licenses
338 .add_declared(LicenseExpression::new(lic.to_string()));
339 }
340 sbom.components.insert(comp.canonical_id.clone(), comp);
341 }
342 sbom
343 }
344
345 fn make_sbom_with_component(declared: &[&str], concluded: Option<&str>) -> NormalizedSbom {
346 let mut sbom = NormalizedSbom::default();
347 let mut comp = Component::new("comp-0".to_string(), "id-0".to_string());
348 for lic in declared {
349 comp.licenses
350 .add_declared(LicenseExpression::new((*lic).to_string()));
351 }
352 comp.licenses.concluded = concluded.map(|c| LicenseExpression::new(c.to_string()));
353 sbom.components.insert(comp.canonical_id.clone(), comp);
354 sbom
355 }
356
357 #[test]
358 fn permissive_policy_allows_all() {
359 let sbom = make_sbom_with_licenses(&["MIT", "Apache-2.0", "GPL-3.0-only"]);
360 let config = LicensePolicyConfig::permissive();
361 let result = evaluate_license_policy(&sbom, &config, false);
362 assert!(result.passed);
363 assert_eq!(result.denied_count, 0);
364 }
365
366 #[test]
367 fn strict_policy_denies_agpl() {
368 let sbom = make_sbom_with_licenses(&["MIT", "AGPL-3.0-only"]);
369 let config = LicensePolicyConfig::strict_permissive();
370 let result = evaluate_license_policy(&sbom, &config, false);
371 assert!(!result.passed);
372 assert_eq!(result.denied_count, 1);
373 }
374
375 #[test]
376 fn strict_policy_flags_gpl_for_review() {
377 let sbom = make_sbom_with_licenses(&["MIT", "GPL-3.0-only"]);
378 let config = LicensePolicyConfig::strict_permissive();
379 let result = evaluate_license_policy(&sbom, &config, false);
380 assert!(result.passed); assert_eq!(result.review_count, 1);
382 }
383
384 #[test]
385 fn strict_mode_denies_review_licenses() {
386 let sbom = make_sbom_with_licenses(&["MIT", "GPL-3.0-only"]);
387 let config = LicensePolicyConfig::strict_permissive();
388 let result = evaluate_license_policy(&sbom, &config, true);
389 assert!(!result.passed);
390 assert_eq!(result.denied_count, 1);
391 assert_eq!(result.review_count, 0);
392 assert!(
393 result
394 .violations
395 .iter()
396 .any(|v| v.license == "GPL-3.0-only" && v.decision == PolicyDecision::Denied)
397 );
398 }
399
400 #[test]
401 fn strict_mode_denies_off_allow_list_licenses() {
402 let sbom = make_sbom_with_licenses(&["Artistic-2.0"]);
403 let config = LicensePolicyConfig {
404 allow: vec!["MIT".to_string()],
405 ..Default::default()
406 };
407 let result = evaluate_license_policy(&sbom, &config, true);
408 assert!(!result.passed);
409 assert_eq!(result.denied_count, 1);
410 }
411
412 #[test]
413 fn policy_config_rejects_unknown_fields() {
414 let err = serde_json::from_str::<LicensePolicyConfig>(r#"{"denied": ["GPL-*"]}"#)
415 .expect_err("typo'd key must be a hard parse error");
416 let msg = err.to_string();
417 assert!(msg.contains("denied"), "error should name the field: {msg}");
418 assert!(
419 msg.contains("deny"),
420 "error should list valid fields: {msg}"
421 );
422 }
423
424 #[test]
425 fn policy_config_accepts_known_fields() {
426 let config: LicensePolicyConfig = serde_json::from_str(
427 r#"{"allow": ["MIT"], "deny": ["AGPL-*"], "review": ["GPL-*"], "fail_on_conflict": false}"#,
428 )
429 .expect("all documented keys must parse");
430 assert_eq!(config.allow, vec!["MIT"]);
431 assert!(!config.fail_on_conflict);
432 }
433
434 #[test]
435 fn undeclared_licenses_flagged() {
436 let sbom = make_sbom_with_licenses(&["MIT", ""]);
437 let config = LicensePolicyConfig::strict_permissive();
438 let result = evaluate_license_policy(&sbom, &config, false);
439 assert_eq!(result.undeclared_count, 1);
440 }
441
442 #[test]
443 fn glob_pattern_matching() {
444 assert!(matches_pattern("BSD-2-Clause", "BSD-*"));
445 assert!(matches_pattern("AGPL-3.0-only", "AGPL-*"));
446 assert!(matches_pattern("agpl-3.0-only", "AGPL-*")); assert!(!matches_pattern("MIT", "BSD-*"));
448 assert!(matches_pattern("MIT", "MIT"));
449 assert!(matches_pattern("mit", "MIT")); }
451
452 #[test]
453 fn conflict_fails_policy() {
454 let sbom = make_sbom_with_component(&["GPL-3.0-only", "Proprietary"], None);
455 let config = LicensePolicyConfig {
456 fail_on_conflict: true,
457 ..Default::default()
458 };
459 let result = evaluate_license_policy(&sbom, &config, false);
460 assert!(!result.passed);
461 assert_eq!(result.denied_count, 1);
462 assert!(result.violations.iter().any(|v| {
463 v.license.starts_with("CONFLICT:") && v.decision == PolicyDecision::Denied
464 }));
465 }
466
467 #[test]
468 fn fail_on_conflict_false_skips() {
469 let sbom = make_sbom_with_component(&["GPL-3.0-only", "Proprietary"], None);
470 let config = LicensePolicyConfig {
471 fail_on_conflict: false,
472 ..Default::default()
473 };
474 let result = evaluate_license_policy(&sbom, &config, false);
475 assert!(result.passed);
476 assert_eq!(result.denied_count, 0);
477 }
478
479 #[test]
480 fn concluded_only_license_evaluated() {
481 let sbom = make_sbom_with_component(&[], Some("AGPL-3.0-only"));
482 let config = LicensePolicyConfig::strict_permissive();
483 let result = evaluate_license_policy(&sbom, &config, false);
484 assert!(!result.passed);
485 assert_eq!(result.denied_count, 1);
486 assert_eq!(result.undeclared_count, 0);
487 }
488
489 #[test]
490 fn or_expression_denied_only_if_all_alternatives_denied() {
491 let config = LicensePolicyConfig {
492 deny: vec!["GPL-*".to_string()],
493 ..Default::default()
494 };
495
496 let choice = make_sbom_with_component(&["MIT OR GPL-3.0-only"], None);
497 let result = evaluate_license_policy(&choice, &config, false);
498 assert!(result.passed);
499 assert_eq!(result.denied_count, 0);
500
501 let no_choice = make_sbom_with_component(&["GPL-2.0-only OR GPL-3.0-only"], None);
502 let result = evaluate_license_policy(&no_choice, &config, false);
503 assert!(!result.passed);
504 assert_eq!(result.denied_count, 1);
505 }
506
507 #[test]
508 fn and_expression_denied_if_any_operand_denied() {
509 let config = LicensePolicyConfig {
510 deny: vec!["GPL-*".to_string()],
511 ..Default::default()
512 };
513 let sbom = make_sbom_with_component(&["MIT AND GPL-3.0-only"], None);
514 let result = evaluate_license_policy(&sbom, &config, false);
515 assert!(!result.passed);
516 assert_eq!(result.denied_count, 1);
517 }
518
519 #[test]
520 fn or_with_allow_list_chooses_allowed_branch() {
521 let config = LicensePolicyConfig {
522 allow: vec!["MIT".to_string()],
523 review: vec!["GPL-*".to_string()],
524 ..Default::default()
525 };
526 let sbom = make_sbom_with_component(&["MIT OR GPL-3.0-only"], None);
527 let result = evaluate_license_policy(&sbom, &config, false);
528 assert!(result.passed);
529 assert_eq!(result.allowed_count, 1);
530 assert_eq!(result.review_count, 0);
531 }
532
533 #[test]
534 fn with_exception_matches_base_id() {
535 let sbom = make_sbom_with_component(&["Apache-2.0 WITH LLVM-exception"], None);
536
537 let allow_config = LicensePolicyConfig {
538 allow: vec!["Apache-2.0".to_string()],
539 ..Default::default()
540 };
541 let result = evaluate_license_policy(&sbom, &allow_config, false);
542 assert_eq!(result.allowed_count, 1);
543
544 let deny_config = LicensePolicyConfig {
545 deny: vec!["Apache-2.0".to_string()],
546 ..Default::default()
547 };
548 let result = evaluate_license_policy(&sbom, &deny_config, false);
549 assert_eq!(result.denied_count, 1);
550 }
551
552 #[test]
553 fn non_spdx_falls_back_to_string_match() {
554 let config = LicensePolicyConfig {
555 deny: vec!["Commercial*".to_string()],
556 ..Default::default()
557 };
558 let sbom = make_sbom_with_component(&["Commercial EULA v2"], None);
559 let result = evaluate_license_policy(&sbom, &config, false);
560 assert!(!result.passed);
561 assert_eq!(result.denied_count, 1);
562 }
563
564 #[test]
565 fn allow_list_requires_match() {
566 let sbom = make_sbom_with_licenses(&["MIT", "Artistic-2.0"]);
567 let config = LicensePolicyConfig {
568 allow: vec!["MIT".to_string()],
569 ..Default::default()
570 };
571 let result = evaluate_license_policy(&sbom, &config, false);
572 assert_eq!(result.review_count, 1); assert_eq!(result.allowed_count, 1);
574 }
575}