1use crate::model::{LicenseExpression, LicenseFamily, NormalizedSbom};
18use serde::{Deserialize, Serialize};
19
20#[derive(Debug, Clone, Default, Serialize, Deserialize)]
22pub struct LicensePolicyConfig {
23 #[serde(default)]
25 pub allow: Vec<String>,
26 #[serde(default)]
28 pub deny: Vec<String>,
29 #[serde(default)]
31 pub review: Vec<String>,
32 #[serde(default = "default_true")]
34 pub fail_on_conflict: bool,
35}
36
37fn default_true() -> bool {
38 true
39}
40
41impl LicensePolicyConfig {
42 #[must_use]
44 pub fn permissive() -> Self {
45 Self::default()
46 }
47
48 #[must_use]
50 pub fn strict_permissive() -> Self {
51 Self {
52 allow: vec![
53 "MIT".to_string(),
54 "Apache-2.0".to_string(),
55 "BSD-2-Clause".to_string(),
56 "BSD-3-Clause".to_string(),
57 "ISC".to_string(),
58 "0BSD".to_string(),
59 "Unlicense".to_string(),
60 "CC0-1.0".to_string(),
61 ],
62 deny: vec![
63 "AGPL-*".to_string(),
64 "SSPL-*".to_string(),
65 "BSL-*".to_string(),
66 ],
67 review: vec![
68 "GPL-*".to_string(),
69 "LGPL-*".to_string(),
70 "MPL-*".to_string(),
71 "EPL-*".to_string(),
72 "CDDL-*".to_string(),
73 ],
74 fail_on_conflict: true,
75 }
76 }
77}
78
79#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
81pub enum PolicyDecision {
82 Allowed,
84 Denied,
86 NeedsReview,
88 Unspecified,
90 Undeclared,
92}
93
94#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct LicensePolicyViolation {
97 pub component: String,
99 pub version: Option<String>,
101 pub license: String,
103 pub decision: PolicyDecision,
105 pub family: LicenseFamily,
107}
108
109#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct LicensePolicyResult {
112 pub total_components: usize,
114 pub allowed_count: usize,
116 pub denied_count: usize,
118 pub review_count: usize,
120 pub undeclared_count: usize,
122 pub passed: bool,
124 pub violations: Vec<LicensePolicyViolation>,
126}
127
128fn matches_pattern(license_id: &str, pattern: &str) -> bool {
130 if let Some(prefix) = pattern.strip_suffix('*') {
131 license_id
132 .get(..prefix.len())
133 .is_some_and(|head| head.eq_ignore_ascii_case(prefix))
134 } else {
135 license_id.eq_ignore_ascii_case(pattern)
136 }
137}
138
139fn matches_any(license_id: &str, patterns: &[String]) -> bool {
141 patterns
142 .iter()
143 .any(|pattern| matches_pattern(license_id, pattern))
144}
145
146fn req_license_id(req: &spdx::LicenseReq) -> String {
148 req.license.to_string()
149}
150
151fn evaluate_license_id(license_id: &str, config: &LicensePolicyConfig) -> PolicyDecision {
153 if matches_any(license_id, &config.deny) {
154 return PolicyDecision::Denied;
155 }
156
157 if matches_any(license_id, &config.review) {
158 return PolicyDecision::NeedsReview;
159 }
160
161 if config.allow.is_empty() {
162 return PolicyDecision::Unspecified;
164 }
165
166 if matches_any(license_id, &config.allow) {
167 return PolicyDecision::Allowed;
168 }
169
170 PolicyDecision::NeedsReview
172}
173
174fn evaluate_expression(expr: &LicenseExpression, config: &LicensePolicyConfig) -> PolicyDecision {
176 let Ok(parsed) = spdx::Expression::parse_mode(&expr.expression, spdx::ParseMode::LAX) else {
177 return evaluate_license_id(&expr.expression, config);
178 };
179
180 if !parsed.evaluate(|req| !matches_any(&req_license_id(req), &config.deny)) {
183 return PolicyDecision::Denied;
184 }
185
186 if config.allow.is_empty() {
187 let clean = parsed.evaluate(|req| {
188 let id = req_license_id(req);
189 !matches_any(&id, &config.deny) && !matches_any(&id, &config.review)
190 });
191 if clean {
192 PolicyDecision::Unspecified
193 } else {
194 PolicyDecision::NeedsReview
195 }
196 } else {
197 let allowed = parsed.evaluate(|req| {
198 let id = req_license_id(req);
199 !matches_any(&id, &config.deny) && matches_any(&id, &config.allow)
200 });
201 if allowed {
202 PolicyDecision::Allowed
203 } else {
204 PolicyDecision::NeedsReview
205 }
206 }
207}
208
209#[must_use]
211pub fn evaluate_license_policy(
212 sbom: &NormalizedSbom,
213 config: &LicensePolicyConfig,
214) -> LicensePolicyResult {
215 let mut allowed_count = 0;
216 let mut denied_count = 0;
217 let mut review_count = 0;
218 let mut undeclared_count = 0;
219 let mut violations = Vec::new();
220
221 for comp in sbom.components.values() {
222 if comp.licenses.declared.is_empty() && comp.licenses.concluded.is_none() {
223 undeclared_count += 1;
224 violations.push(LicensePolicyViolation {
225 component: comp.name.clone(),
226 version: comp.version.clone(),
227 license: "(undeclared)".to_string(),
228 decision: PolicyDecision::Undeclared,
229 family: LicenseFamily::Other,
230 });
231 continue;
232 }
233
234 let mut component_denied = false;
235 let mut component_review = false;
236
237 for license in comp.licenses.all_licenses() {
238 let decision = evaluate_expression(license, config);
239 match decision {
240 PolicyDecision::Denied => {
241 component_denied = true;
242 violations.push(LicensePolicyViolation {
243 component: comp.name.clone(),
244 version: comp.version.clone(),
245 license: license.expression.clone(),
246 decision: PolicyDecision::Denied,
247 family: license.family(),
248 });
249 }
250 PolicyDecision::NeedsReview => {
251 component_review = true;
252 violations.push(LicensePolicyViolation {
253 component: comp.name.clone(),
254 version: comp.version.clone(),
255 license: license.expression.clone(),
256 decision: PolicyDecision::NeedsReview,
257 family: license.family(),
258 });
259 }
260 PolicyDecision::Allowed | PolicyDecision::Unspecified => {}
261 PolicyDecision::Undeclared => {}
262 }
263 }
264
265 if config.fail_on_conflict && comp.licenses.has_conflicts() {
267 component_denied = true;
268 let license_str = comp
269 .licenses
270 .all_licenses()
271 .iter()
272 .map(|l| l.expression.as_str())
273 .collect::<Vec<_>>()
274 .join(" + ");
275 violations.push(LicensePolicyViolation {
276 component: comp.name.clone(),
277 version: comp.version.clone(),
278 license: format!("CONFLICT: {license_str}"),
279 decision: PolicyDecision::Denied,
280 family: LicenseFamily::Other,
281 });
282 }
283
284 if component_denied {
285 denied_count += 1;
286 } else if component_review {
287 review_count += 1;
288 } else {
289 allowed_count += 1;
290 }
291 }
292
293 let passed = denied_count == 0;
294
295 LicensePolicyResult {
296 total_components: sbom.components.len(),
297 allowed_count,
298 denied_count,
299 review_count,
300 undeclared_count,
301 passed,
302 violations,
303 }
304}
305
306#[cfg(test)]
307mod tests {
308 use super::*;
309 use crate::model::Component;
310
311 fn make_sbom_with_licenses(licenses: &[&str]) -> NormalizedSbom {
312 let mut sbom = NormalizedSbom::default();
313 for (i, lic) in licenses.iter().enumerate() {
314 let mut comp = Component::new(format!("comp-{i}"), format!("id-{i}"));
315 if !lic.is_empty() {
316 comp.licenses
317 .add_declared(LicenseExpression::new(lic.to_string()));
318 }
319 sbom.components.insert(comp.canonical_id.clone(), comp);
320 }
321 sbom
322 }
323
324 fn make_sbom_with_component(declared: &[&str], concluded: Option<&str>) -> NormalizedSbom {
325 let mut sbom = NormalizedSbom::default();
326 let mut comp = Component::new("comp-0".to_string(), "id-0".to_string());
327 for lic in declared {
328 comp.licenses
329 .add_declared(LicenseExpression::new((*lic).to_string()));
330 }
331 comp.licenses.concluded = concluded.map(|c| LicenseExpression::new(c.to_string()));
332 sbom.components.insert(comp.canonical_id.clone(), comp);
333 sbom
334 }
335
336 #[test]
337 fn permissive_policy_allows_all() {
338 let sbom = make_sbom_with_licenses(&["MIT", "Apache-2.0", "GPL-3.0-only"]);
339 let config = LicensePolicyConfig::permissive();
340 let result = evaluate_license_policy(&sbom, &config);
341 assert!(result.passed);
342 assert_eq!(result.denied_count, 0);
343 }
344
345 #[test]
346 fn strict_policy_denies_agpl() {
347 let sbom = make_sbom_with_licenses(&["MIT", "AGPL-3.0-only"]);
348 let config = LicensePolicyConfig::strict_permissive();
349 let result = evaluate_license_policy(&sbom, &config);
350 assert!(!result.passed);
351 assert_eq!(result.denied_count, 1);
352 }
353
354 #[test]
355 fn strict_policy_flags_gpl_for_review() {
356 let sbom = make_sbom_with_licenses(&["MIT", "GPL-3.0-only"]);
357 let config = LicensePolicyConfig::strict_permissive();
358 let result = evaluate_license_policy(&sbom, &config);
359 assert!(result.passed); assert_eq!(result.review_count, 1);
361 }
362
363 #[test]
364 fn undeclared_licenses_flagged() {
365 let sbom = make_sbom_with_licenses(&["MIT", ""]);
366 let config = LicensePolicyConfig::strict_permissive();
367 let result = evaluate_license_policy(&sbom, &config);
368 assert_eq!(result.undeclared_count, 1);
369 }
370
371 #[test]
372 fn glob_pattern_matching() {
373 assert!(matches_pattern("BSD-2-Clause", "BSD-*"));
374 assert!(matches_pattern("AGPL-3.0-only", "AGPL-*"));
375 assert!(matches_pattern("agpl-3.0-only", "AGPL-*")); assert!(!matches_pattern("MIT", "BSD-*"));
377 assert!(matches_pattern("MIT", "MIT"));
378 assert!(matches_pattern("mit", "MIT")); }
380
381 #[test]
382 fn conflict_fails_policy() {
383 let sbom = make_sbom_with_component(&["GPL-3.0-only", "Proprietary"], None);
384 let config = LicensePolicyConfig {
385 fail_on_conflict: true,
386 ..Default::default()
387 };
388 let result = evaluate_license_policy(&sbom, &config);
389 assert!(!result.passed);
390 assert_eq!(result.denied_count, 1);
391 assert!(result.violations.iter().any(|v| {
392 v.license.starts_with("CONFLICT:") && v.decision == PolicyDecision::Denied
393 }));
394 }
395
396 #[test]
397 fn fail_on_conflict_false_skips() {
398 let sbom = make_sbom_with_component(&["GPL-3.0-only", "Proprietary"], None);
399 let config = LicensePolicyConfig {
400 fail_on_conflict: false,
401 ..Default::default()
402 };
403 let result = evaluate_license_policy(&sbom, &config);
404 assert!(result.passed);
405 assert_eq!(result.denied_count, 0);
406 }
407
408 #[test]
409 fn concluded_only_license_evaluated() {
410 let sbom = make_sbom_with_component(&[], Some("AGPL-3.0-only"));
411 let config = LicensePolicyConfig::strict_permissive();
412 let result = evaluate_license_policy(&sbom, &config);
413 assert!(!result.passed);
414 assert_eq!(result.denied_count, 1);
415 assert_eq!(result.undeclared_count, 0);
416 }
417
418 #[test]
419 fn or_expression_denied_only_if_all_alternatives_denied() {
420 let config = LicensePolicyConfig {
421 deny: vec!["GPL-*".to_string()],
422 ..Default::default()
423 };
424
425 let choice = make_sbom_with_component(&["MIT OR GPL-3.0-only"], None);
426 let result = evaluate_license_policy(&choice, &config);
427 assert!(result.passed);
428 assert_eq!(result.denied_count, 0);
429
430 let no_choice = make_sbom_with_component(&["GPL-2.0-only OR GPL-3.0-only"], None);
431 let result = evaluate_license_policy(&no_choice, &config);
432 assert!(!result.passed);
433 assert_eq!(result.denied_count, 1);
434 }
435
436 #[test]
437 fn and_expression_denied_if_any_operand_denied() {
438 let config = LicensePolicyConfig {
439 deny: vec!["GPL-*".to_string()],
440 ..Default::default()
441 };
442 let sbom = make_sbom_with_component(&["MIT AND GPL-3.0-only"], None);
443 let result = evaluate_license_policy(&sbom, &config);
444 assert!(!result.passed);
445 assert_eq!(result.denied_count, 1);
446 }
447
448 #[test]
449 fn or_with_allow_list_chooses_allowed_branch() {
450 let config = LicensePolicyConfig {
451 allow: vec!["MIT".to_string()],
452 review: vec!["GPL-*".to_string()],
453 ..Default::default()
454 };
455 let sbom = make_sbom_with_component(&["MIT OR GPL-3.0-only"], None);
456 let result = evaluate_license_policy(&sbom, &config);
457 assert!(result.passed);
458 assert_eq!(result.allowed_count, 1);
459 assert_eq!(result.review_count, 0);
460 }
461
462 #[test]
463 fn with_exception_matches_base_id() {
464 let sbom = make_sbom_with_component(&["Apache-2.0 WITH LLVM-exception"], None);
465
466 let allow_config = LicensePolicyConfig {
467 allow: vec!["Apache-2.0".to_string()],
468 ..Default::default()
469 };
470 let result = evaluate_license_policy(&sbom, &allow_config);
471 assert_eq!(result.allowed_count, 1);
472
473 let deny_config = LicensePolicyConfig {
474 deny: vec!["Apache-2.0".to_string()],
475 ..Default::default()
476 };
477 let result = evaluate_license_policy(&sbom, &deny_config);
478 assert_eq!(result.denied_count, 1);
479 }
480
481 #[test]
482 fn non_spdx_falls_back_to_string_match() {
483 let config = LicensePolicyConfig {
484 deny: vec!["Commercial*".to_string()],
485 ..Default::default()
486 };
487 let sbom = make_sbom_with_component(&["Commercial EULA v2"], None);
488 let result = evaluate_license_policy(&sbom, &config);
489 assert!(!result.passed);
490 assert_eq!(result.denied_count, 1);
491 }
492
493 #[test]
494 fn allow_list_requires_match() {
495 let sbom = make_sbom_with_licenses(&["MIT", "Artistic-2.0"]);
496 let config = LicensePolicyConfig {
497 allow: vec!["MIT".to_string()],
498 ..Default::default()
499 };
500 let result = evaluate_license_policy(&sbom, &config);
501 assert_eq!(result.review_count, 1); assert_eq!(result.allowed_count, 1);
503 }
504}