Skip to main content

sbom_tools/tui/
license_utils.rs

1//! License utilities for TUI display.
2//!
3//! Provides SPDX expression parsing, license compatibility checking,
4//! and risk assessment for the license tab.
5
6use std::collections::{HashMap, HashSet};
7
8/// Parsed SPDX expression
9#[derive(Debug, Clone, PartialEq)]
10pub enum SpdxExpression {
11    /// Single license identifier
12    License(String),
13    /// License with exception (e.g., GPL-2.0 WITH Classpath-exception-2.0)
14    WithException { license: String, exception: String },
15    /// OR expression (choice of licenses)
16    Or(Box<SpdxExpression>, Box<SpdxExpression>),
17    /// AND expression (must comply with all)
18    And(Box<SpdxExpression>, Box<SpdxExpression>),
19}
20
21impl SpdxExpression {
22    /// Parse an SPDX expression string
23    pub fn parse(expr: &str) -> Self {
24        let expr = expr.trim();
25
26        // Handle OR operator (lowest precedence)
27        if let Some(pos) = find_operator(expr, " OR ") {
28            let left = &expr[..pos];
29            let right = &expr[pos + 4..];
30            return SpdxExpression::Or(
31                Box::new(SpdxExpression::parse(left)),
32                Box::new(SpdxExpression::parse(right)),
33            );
34        }
35
36        // Handle AND operator
37        if let Some(pos) = find_operator(expr, " AND ") {
38            let left = &expr[..pos];
39            let right = &expr[pos + 5..];
40            return SpdxExpression::And(
41                Box::new(SpdxExpression::parse(left)),
42                Box::new(SpdxExpression::parse(right)),
43            );
44        }
45
46        // Handle WITH exception
47        if let Some(pos) = expr.to_uppercase().find(" WITH ") {
48            let license = expr[..pos].trim().to_string();
49            let exception = expr[pos + 6..].trim().to_string();
50            return SpdxExpression::WithException { license, exception };
51        }
52
53        // Handle parentheses
54        let expr = expr.trim_start_matches('(').trim_end_matches(')').trim();
55
56        // Single license
57        SpdxExpression::License(expr.to_string())
58    }
59
60    /// Get all license identifiers in the expression
61    pub fn licenses(&self) -> Vec<&str> {
62        match self {
63            SpdxExpression::License(l) => vec![l.as_str()],
64            SpdxExpression::WithException { license, .. } => vec![license.as_str()],
65            SpdxExpression::Or(left, right) | SpdxExpression::And(left, right) => {
66                let mut result = left.licenses();
67                result.extend(right.licenses());
68                result
69            }
70        }
71    }
72
73    /// Check if this is a choice expression (contains OR)
74    pub fn is_choice(&self) -> bool {
75        match self {
76            SpdxExpression::Or(_, _) => true,
77            SpdxExpression::And(left, right) => left.is_choice() || right.is_choice(),
78            _ => false,
79        }
80    }
81
82    /// Get a human-readable description of the expression type
83    pub fn expression_type(&self) -> &'static str {
84        match self {
85            SpdxExpression::License(_) => "Single License",
86            SpdxExpression::WithException { .. } => "License with Exception",
87            SpdxExpression::Or(_, _) => "Dual/Multi License (Choice)",
88            SpdxExpression::And(_, _) => "Combined License (All Apply)",
89        }
90    }
91}
92
93/// Find operator position, respecting parentheses
94fn find_operator(expr: &str, op: &str) -> Option<usize> {
95    let upper = expr.to_uppercase();
96    let mut depth = 0;
97
98    for (i, c) in expr.chars().enumerate() {
99        match c {
100            '(' => depth += 1,
101            ')' => depth -= 1,
102            _ => {}
103        }
104        if depth == 0 && upper[i..].starts_with(op) {
105            return Some(i);
106        }
107    }
108    None
109}
110
111/// License category for classification
112#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
113pub enum LicenseCategory {
114    Permissive,
115    WeakCopyleft,
116    StrongCopyleft,
117    NetworkCopyleft,
118    Proprietary,
119    PublicDomain,
120    Unknown,
121}
122
123impl LicenseCategory {
124    pub fn as_str(&self) -> &'static str {
125        match self {
126            LicenseCategory::Permissive => "Permissive",
127            LicenseCategory::WeakCopyleft => "Weak Copyleft",
128            LicenseCategory::StrongCopyleft => "Copyleft",
129            LicenseCategory::NetworkCopyleft => "Network Copyleft",
130            LicenseCategory::Proprietary => "Proprietary",
131            LicenseCategory::PublicDomain => "Public Domain",
132            LicenseCategory::Unknown => "Unknown",
133        }
134    }
135
136    /// Get the copyleft strength (0 = none, 4 = strongest)
137    pub fn copyleft_strength(&self) -> u8 {
138        match self {
139            LicenseCategory::PublicDomain | LicenseCategory::Permissive => 0,
140            LicenseCategory::WeakCopyleft => 1,
141            LicenseCategory::StrongCopyleft => 2,
142            LicenseCategory::NetworkCopyleft => 3,
143            LicenseCategory::Proprietary => 4, // Most restrictive
144            LicenseCategory::Unknown => 0,
145        }
146    }
147}
148
149/// License risk level
150#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
151pub enum RiskLevel {
152    Low,
153    Medium,
154    High,
155    Critical,
156}
157
158impl RiskLevel {
159    pub fn as_str(&self) -> &'static str {
160        match self {
161            RiskLevel::Low => "Low",
162            RiskLevel::Medium => "Medium",
163            RiskLevel::High => "High",
164            RiskLevel::Critical => "Critical",
165        }
166    }
167}
168
169/// Detailed license information
170#[derive(Debug, Clone)]
171pub struct LicenseInfo {
172    /// SPDX identifier
173    pub spdx_id: String,
174    /// License category
175    pub category: LicenseCategory,
176    /// Risk level for commercial use
177    pub risk_level: RiskLevel,
178    /// Whether attribution is required
179    pub requires_attribution: bool,
180    /// Whether source disclosure is required
181    pub requires_source_disclosure: bool,
182    /// Whether patent grant is included
183    pub patent_grant: bool,
184    /// Whether modifications must be disclosed
185    pub modifications_must_be_disclosed: bool,
186    /// Whether derivatives must use same license
187    pub same_license_for_derivatives: bool,
188    /// Whether network use triggers copyleft
189    pub network_copyleft: bool,
190    /// License family (e.g., "BSD", "GPL", "Apache")
191    pub family: &'static str,
192}
193
194impl LicenseInfo {
195    /// Get detailed info for a known license
196    pub fn from_spdx(spdx_id: &str) -> Self {
197        let lower = spdx_id.to_lowercase();
198
199        // MIT family
200        if lower.contains("mit") {
201            return Self::permissive("MIT", false);
202        }
203
204        // Apache family
205        if lower.contains("apache") {
206            return Self {
207                spdx_id: spdx_id.to_string(),
208                category: LicenseCategory::Permissive,
209                risk_level: RiskLevel::Low,
210                requires_attribution: true,
211                requires_source_disclosure: false,
212                patent_grant: true,
213                modifications_must_be_disclosed: false,
214                same_license_for_derivatives: false,
215                network_copyleft: false,
216                family: "Apache",
217            };
218        }
219
220        // BSD family
221        if lower.contains("bsd") {
222            let has_advertising = lower.contains("4-clause") || lower.contains("original");
223            return Self {
224                spdx_id: spdx_id.to_string(),
225                category: LicenseCategory::Permissive,
226                risk_level: if has_advertising {
227                    RiskLevel::Medium
228                } else {
229                    RiskLevel::Low
230                },
231                requires_attribution: true,
232                requires_source_disclosure: false,
233                patent_grant: false,
234                modifications_must_be_disclosed: false,
235                same_license_for_derivatives: false,
236                network_copyleft: false,
237                family: "BSD",
238            };
239        }
240
241        // ISC, Unlicense, CC0, WTFPL, Zlib
242        if lower.contains("isc")
243            || lower.contains("unlicense")
244            || lower.contains("cc0")
245            || lower.contains("wtfpl")
246            || lower.contains("zlib")
247        {
248            let family = if lower.contains("cc0") {
249                "Creative Commons"
250            } else if lower.contains("zlib") {
251                "Zlib"
252            } else {
253                "Public Domain-like"
254            };
255            return Self {
256                spdx_id: spdx_id.to_string(),
257                category: if lower.contains("cc0") || lower.contains("unlicense") {
258                    LicenseCategory::PublicDomain
259                } else {
260                    LicenseCategory::Permissive
261                },
262                risk_level: RiskLevel::Low,
263                requires_attribution: !lower.contains("cc0") && !lower.contains("unlicense"),
264                requires_source_disclosure: false,
265                patent_grant: false,
266                modifications_must_be_disclosed: false,
267                same_license_for_derivatives: false,
268                network_copyleft: false,
269                family,
270            };
271        }
272
273        // AGPL (network copyleft)
274        if lower.contains("agpl") {
275            return Self {
276                spdx_id: spdx_id.to_string(),
277                category: LicenseCategory::NetworkCopyleft,
278                risk_level: RiskLevel::Critical,
279                requires_attribution: true,
280                requires_source_disclosure: true,
281                patent_grant: lower.contains("3"),
282                modifications_must_be_disclosed: true,
283                same_license_for_derivatives: true,
284                network_copyleft: true,
285                family: "GPL",
286            };
287        }
288
289        // LGPL (weak copyleft)
290        if lower.contains("lgpl") {
291            return Self {
292                spdx_id: spdx_id.to_string(),
293                category: LicenseCategory::WeakCopyleft,
294                risk_level: RiskLevel::Medium,
295                requires_attribution: true,
296                requires_source_disclosure: true,
297                patent_grant: lower.contains("3"),
298                modifications_must_be_disclosed: true,
299                same_license_for_derivatives: true, // Only for library modifications
300                network_copyleft: false,
301                family: "GPL",
302            };
303        }
304
305        // GPL (strong copyleft)
306        if lower.contains("gpl") {
307            return Self {
308                spdx_id: spdx_id.to_string(),
309                category: LicenseCategory::StrongCopyleft,
310                risk_level: RiskLevel::High,
311                requires_attribution: true,
312                requires_source_disclosure: true,
313                patent_grant: lower.contains("3"),
314                modifications_must_be_disclosed: true,
315                same_license_for_derivatives: true,
316                network_copyleft: false,
317                family: "GPL",
318            };
319        }
320
321        // MPL (weak copyleft)
322        if lower.contains("mpl") || lower.contains("mozilla") {
323            return Self {
324                spdx_id: spdx_id.to_string(),
325                category: LicenseCategory::WeakCopyleft,
326                risk_level: RiskLevel::Medium,
327                requires_attribution: true,
328                requires_source_disclosure: true,
329                patent_grant: true,
330                modifications_must_be_disclosed: true,
331                same_license_for_derivatives: false, // File-level copyleft
332                network_copyleft: false,
333                family: "MPL",
334            };
335        }
336
337        // Eclipse (weak copyleft)
338        if lower.contains("eclipse") || lower.contains("epl") {
339            return Self {
340                spdx_id: spdx_id.to_string(),
341                category: LicenseCategory::WeakCopyleft,
342                risk_level: RiskLevel::Medium,
343                requires_attribution: true,
344                requires_source_disclosure: true,
345                patent_grant: true,
346                modifications_must_be_disclosed: true,
347                same_license_for_derivatives: false,
348                network_copyleft: false,
349                family: "Eclipse",
350            };
351        }
352
353        // CDDL
354        if lower.contains("cddl") {
355            return Self {
356                spdx_id: spdx_id.to_string(),
357                category: LicenseCategory::WeakCopyleft,
358                risk_level: RiskLevel::Medium,
359                requires_attribution: true,
360                requires_source_disclosure: true,
361                patent_grant: true,
362                modifications_must_be_disclosed: true,
363                same_license_for_derivatives: false,
364                network_copyleft: false,
365                family: "CDDL",
366            };
367        }
368
369        // Proprietary
370        if lower.contains("proprietary")
371            || lower.contains("commercial")
372            || lower.contains("private")
373        {
374            return Self {
375                spdx_id: spdx_id.to_string(),
376                category: LicenseCategory::Proprietary,
377                risk_level: RiskLevel::Critical,
378                requires_attribution: false,
379                requires_source_disclosure: false,
380                patent_grant: false,
381                modifications_must_be_disclosed: false,
382                same_license_for_derivatives: false,
383                network_copyleft: false,
384                family: "Proprietary",
385            };
386        }
387
388        // Unknown
389        Self {
390            spdx_id: spdx_id.to_string(),
391            category: LicenseCategory::Unknown,
392            risk_level: RiskLevel::Medium, // Conservative default
393            requires_attribution: true,    // Safe assumption
394            requires_source_disclosure: false,
395            patent_grant: false,
396            modifications_must_be_disclosed: false,
397            same_license_for_derivatives: false,
398            network_copyleft: false,
399            family: "Unknown",
400        }
401    }
402
403    fn permissive(family: &'static str, patent_grant: bool) -> Self {
404        Self {
405            spdx_id: family.to_string(),
406            category: LicenseCategory::Permissive,
407            risk_level: RiskLevel::Low,
408            requires_attribution: true,
409            requires_source_disclosure: false,
410            patent_grant,
411            modifications_must_be_disclosed: false,
412            same_license_for_derivatives: false,
413            network_copyleft: false,
414            family,
415        }
416    }
417}
418
419/// License compatibility result
420#[derive(Debug, Clone)]
421pub struct CompatibilityResult {
422    /// Whether the licenses are compatible
423    pub compatible: bool,
424    /// Compatibility level (0-100)
425    pub score: u8,
426    /// Warning messages
427    pub warnings: Vec<String>,
428    /// The resulting license requirements if combined
429    pub resulting_category: LicenseCategory,
430}
431
432/// Check compatibility between two licenses
433pub fn check_compatibility(license_a: &str, license_b: &str) -> CompatibilityResult {
434    let info_a = LicenseInfo::from_spdx(license_a);
435    let info_b = LicenseInfo::from_spdx(license_b);
436
437    let mut warnings = Vec::new();
438    let mut compatible = true;
439    let mut score = 100u8;
440
441    // Proprietary is never compatible with copyleft
442    if (info_a.category == LicenseCategory::Proprietary
443        || info_b.category == LicenseCategory::Proprietary)
444        && info_a.category != info_b.category
445    {
446        compatible = false;
447        score = 0;
448        warnings.push(format!(
449            "Proprietary license '{}' incompatible with '{}'",
450            if info_a.category == LicenseCategory::Proprietary {
451                license_a
452            } else {
453                license_b
454            },
455            if info_a.category == LicenseCategory::Proprietary {
456                license_b
457            } else {
458                license_a
459            }
460        ));
461    }
462
463    // GPL family incompatibilities
464    if info_a.family == "GPL" || info_b.family == "GPL" {
465        // GPL v2 only vs GPL v3
466        let a_lower = license_a.to_lowercase();
467        let b_lower = license_b.to_lowercase();
468
469        if (a_lower.contains("gpl-2.0-only") && b_lower.contains("gpl-3"))
470            || (b_lower.contains("gpl-2.0-only") && a_lower.contains("gpl-3"))
471        {
472            compatible = false;
473            score = 0;
474            warnings.push("GPL-2.0-only is incompatible with GPL-3.0".to_string());
475        }
476
477        // Apache 2.0 with GPL 2.0 is problematic
478        if ((info_a.family == "Apache" && b_lower.contains("gpl-2"))
479            || (info_b.family == "Apache" && a_lower.contains("gpl-2")))
480            && !a_lower.contains("gpl-3")
481            && !b_lower.contains("gpl-3")
482        {
483            warnings.push(
484                "Apache-2.0 has patent clauses incompatible with GPL-2.0".to_string(),
485            );
486            score = score.saturating_sub(30);
487        }
488    }
489
490    // Network copyleft warning
491    if info_a.network_copyleft || info_b.network_copyleft {
492        warnings.push("Network copyleft license (AGPL) requires source disclosure for network use".to_string());
493        score = score.saturating_sub(20);
494    }
495
496    // Mixed copyleft strengths
497    if info_a.category != info_b.category {
498        let strength_diff =
499            (info_a.category.copyleft_strength() as i8 - info_b.category.copyleft_strength() as i8)
500                .unsigned_abs();
501
502        if strength_diff > 1 {
503            warnings.push(format!(
504                "Mixing {} ({}) with {} ({}) may have licensing implications",
505                license_a,
506                info_a.category.as_str(),
507                license_b,
508                info_b.category.as_str()
509            ));
510            score = score.saturating_sub(strength_diff * 10);
511        }
512    }
513
514    // Determine resulting category (most restrictive)
515    let resulting_category =
516        if info_a.category.copyleft_strength() > info_b.category.copyleft_strength() {
517            info_a.category
518        } else {
519            info_b.category
520        };
521
522    CompatibilityResult {
523        compatible,
524        score,
525        warnings,
526        resulting_category,
527    }
528}
529
530/// Analyze all licenses in an SBOM for compatibility issues
531pub fn analyze_license_compatibility(licenses: &[&str]) -> LicenseCompatibilityReport {
532    let mut issues = Vec::new();
533    let mut families: HashMap<&'static str, Vec<String>> = HashMap::new();
534    let mut categories: HashMap<LicenseCategory, Vec<String>> = HashMap::new();
535
536    // Collect license info
537    for license in licenses {
538        let info = LicenseInfo::from_spdx(license);
539        families
540            .entry(info.family)
541            .or_default()
542            .push(license.to_string());
543        categories
544            .entry(info.category)
545            .or_default()
546            .push(license.to_string());
547    }
548
549    // Check pairwise compatibility for problematic combinations
550    let unique: Vec<_> = licenses.iter().collect::<HashSet<_>>().into_iter().collect();
551    for (i, &license_a) in unique.iter().enumerate() {
552        for &license_b in unique.iter().skip(i + 1) {
553            let result = check_compatibility(license_a, license_b);
554            if !result.compatible || result.score < 70 {
555                issues.push(CompatibilityIssue {
556                    license_a: license_a.to_string(),
557                    license_b: license_b.to_string(),
558                    severity: if !result.compatible {
559                        IssueSeverity::Error
560                    } else {
561                        IssueSeverity::Warning
562                    },
563                    message: result.warnings.join("; "),
564                });
565            }
566        }
567    }
568
569    // Calculate overall score
570    let overall_score = if issues.iter().any(|i| i.severity == IssueSeverity::Error) {
571        0
572    } else {
573        let warning_count = issues
574            .iter()
575            .filter(|i| i.severity == IssueSeverity::Warning)
576            .count();
577        100u8.saturating_sub((warning_count * 15) as u8)
578    };
579
580    LicenseCompatibilityReport {
581        overall_score,
582        issues,
583        families,
584        categories,
585    }
586}
587
588/// License compatibility report for an entire SBOM
589#[derive(Debug)]
590pub struct LicenseCompatibilityReport {
591    /// Overall compatibility score (0-100)
592    pub overall_score: u8,
593    /// Specific compatibility issues
594    pub issues: Vec<CompatibilityIssue>,
595    /// Licenses grouped by family
596    pub families: HashMap<&'static str, Vec<String>>,
597    /// Licenses grouped by category
598    pub categories: HashMap<LicenseCategory, Vec<String>>,
599}
600
601/// A specific compatibility issue
602#[derive(Debug, Clone)]
603pub struct CompatibilityIssue {
604    pub license_a: String,
605    pub license_b: String,
606    pub severity: IssueSeverity,
607    pub message: String,
608}
609
610#[derive(Debug, Clone, Copy, PartialEq, Eq)]
611pub enum IssueSeverity {
612    Warning,
613    Error,
614}
615
616/// License statistics for display
617#[derive(Debug, Default)]
618pub struct LicenseStats {
619    pub total_licenses: usize,
620    pub unique_licenses: usize,
621    pub by_category: HashMap<LicenseCategory, usize>,
622    pub by_risk: HashMap<RiskLevel, usize>,
623    pub by_family: HashMap<String, usize>,
624    pub copyleft_count: usize,
625    pub permissive_count: usize,
626}
627
628impl LicenseStats {
629    pub fn from_licenses(licenses: &[&str]) -> Self {
630        let mut stats = LicenseStats {
631            total_licenses: licenses.len(),
632            unique_licenses: 0,
633            by_category: HashMap::new(),
634            by_risk: HashMap::new(),
635            by_family: HashMap::new(),
636            copyleft_count: 0,
637            permissive_count: 0,
638        };
639
640        let unique: HashSet<_> = licenses.iter().collect();
641        stats.unique_licenses = unique.len();
642
643        for license in unique {
644            let info = LicenseInfo::from_spdx(license);
645
646            *stats.by_category.entry(info.category).or_default() += 1;
647            *stats.by_risk.entry(info.risk_level).or_default() += 1;
648            *stats
649                .by_family
650                .entry(info.family.to_string())
651                .or_default() += 1;
652
653            match info.category {
654                LicenseCategory::Permissive | LicenseCategory::PublicDomain => {
655                    stats.permissive_count += 1;
656                }
657                LicenseCategory::WeakCopyleft
658                | LicenseCategory::StrongCopyleft
659                | LicenseCategory::NetworkCopyleft => {
660                    stats.copyleft_count += 1;
661                }
662                _ => {}
663            }
664        }
665
666        stats
667    }
668}
669
670#[cfg(test)]
671mod tests {
672    use super::*;
673
674    #[test]
675    fn test_spdx_parse_simple() {
676        let expr = SpdxExpression::parse("MIT");
677        assert_eq!(expr, SpdxExpression::License("MIT".to_string()));
678    }
679
680    #[test]
681    fn test_spdx_parse_or() {
682        let expr = SpdxExpression::parse("MIT OR Apache-2.0");
683        assert!(matches!(expr, SpdxExpression::Or(_, _)));
684        assert!(expr.is_choice());
685    }
686
687    #[test]
688    fn test_spdx_parse_with() {
689        let expr = SpdxExpression::parse("GPL-2.0 WITH Classpath-exception-2.0");
690        assert!(matches!(expr, SpdxExpression::WithException { .. }));
691    }
692
693    #[test]
694    fn test_license_category() {
695        assert_eq!(
696            LicenseInfo::from_spdx("MIT").category,
697            LicenseCategory::Permissive
698        );
699        assert_eq!(
700            LicenseInfo::from_spdx("GPL-3.0").category,
701            LicenseCategory::StrongCopyleft
702        );
703        assert_eq!(
704            LicenseInfo::from_spdx("LGPL-2.1").category,
705            LicenseCategory::WeakCopyleft
706        );
707        assert_eq!(
708            LicenseInfo::from_spdx("AGPL-3.0").category,
709            LicenseCategory::NetworkCopyleft
710        );
711    }
712
713    #[test]
714    fn test_compatibility_mit_apache() {
715        let result = check_compatibility("MIT", "Apache-2.0");
716        assert!(result.compatible);
717        assert!(result.score > 80);
718    }
719
720    #[test]
721    fn test_compatibility_gpl_proprietary() {
722        let result = check_compatibility("GPL-3.0", "Proprietary");
723        assert!(!result.compatible);
724        assert_eq!(result.score, 0);
725    }
726}