Skip to main content

sbom_tools/model/
license.rs

1//! License data structures and SPDX expression handling.
2//!
3//! Uses the `spdx` crate for proper SPDX expression parsing and license
4//! classification, with substring-based fallback for non-standard expressions.
5
6use serde::{Deserialize, Serialize};
7use std::fmt;
8
9/// License expression following SPDX license expression syntax
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct LicenseExpression {
12    /// The raw license expression string
13    pub expression: String,
14    /// Whether this is a valid SPDX expression
15    pub is_valid_spdx: bool,
16    /// Human-readable name resolved from the document's license
17    /// definitions (e.g. SPDX hasExtractedLicensingInfos for a bare
18    /// `LicenseRef-*` expression). Display metadata only: excluded from
19    /// equality/hashing below so identical expressions stay equal (and
20    /// pre-existing serialized SBOMs stay diff-identical) whether or not
21    /// resolution ran.
22    #[serde(default, skip_serializing_if = "Option::is_none")]
23    pub resolved_name: Option<String>,
24}
25
26/// Equality/hashing deliberately ignore `resolved_name`: the raw
27/// expression is the license identity.
28impl PartialEq for LicenseExpression {
29    fn eq(&self, other: &Self) -> bool {
30        self.expression == other.expression && self.is_valid_spdx == other.is_valid_spdx
31    }
32}
33impl Eq for LicenseExpression {}
34impl std::hash::Hash for LicenseExpression {
35    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
36        self.expression.hash(state);
37        self.is_valid_spdx.hash(state);
38    }
39}
40
41impl LicenseExpression {
42    /// Create a new license expression
43    #[must_use]
44    pub fn new(expression: String) -> Self {
45        let is_valid_spdx = Self::validate_spdx(&expression);
46        Self {
47            expression,
48            is_valid_spdx,
49            resolved_name: None,
50        }
51    }
52
53    /// Human-readable display form: the name resolved from the document's
54    /// license definitions (e.g. SPDX `hasExtractedLicensingInfos` for a bare
55    /// `LicenseRef-*`) when present, otherwise the raw expression. For
56    /// rendering/emit only — equality and identity stay on `expression`.
57    #[must_use]
58    pub fn display_name(&self) -> &str {
59        self.resolved_name.as_deref().unwrap_or(&self.expression)
60    }
61
62    /// Create from an SPDX license ID
63    #[must_use]
64    pub fn from_spdx_id(id: &str) -> Self {
65        // Validate rather than trusting the caller: scoring now relies on
66        // `is_valid_spdx`, so a hardcoded `true` would be a footgun.
67        Self::new(id.to_string())
68    }
69
70    /// Validate an SPDX expression using the spdx crate.
71    ///
72    /// Uses lax parsing mode to accept common non-standard expressions
73    /// (e.g., "Apache2" instead of "Apache-2.0", "/" instead of "OR").
74    fn validate_spdx(expr: &str) -> bool {
75        // Reject expressions with a NOASSERTION/NONE clause (no license
76        // information), matching whole tokens so that legitimate ids like
77        // `LicenseRef-NONEXCLUSIVE` are not caught by a substring test.
78        let has_no_info_token = expr
79            .split(|c: char| c.is_whitespace() || c == '(' || c == ')')
80            .any(|tok| tok == "NOASSERTION" || tok == "NONE");
81        if expr.is_empty() || has_no_info_token {
82            return false;
83        }
84        spdx::Expression::parse_mode(expr, spdx::ParseMode::LAX).is_ok()
85    }
86
87    /// Check if this expression includes a permissive license option.
88    ///
89    /// For OR expressions (e.g., "MIT OR GPL-2.0"), returns true if at least
90    /// one branch is permissive (the licensee can choose the permissive option).
91    /// Falls back to substring matching for non-parseable expressions.
92    #[must_use]
93    pub fn is_permissive(&self) -> bool {
94        spdx::Expression::parse_mode(&self.expression, spdx::ParseMode::LAX).map_or_else(
95            |_| {
96                // Fallback for non-standard expressions
97                let expr_lower = self.expression.to_lowercase();
98                expr_lower.contains("mit")
99                    || expr_lower.contains("apache")
100                    || expr_lower.contains("bsd")
101                    || expr_lower.contains("isc")
102                    || expr_lower.contains("unlicense")
103            },
104            |expr| {
105                expr.requirements().any(|req| {
106                    if let spdx::LicenseItem::Spdx { id, .. } = req.req.license {
107                        !id.is_copyleft() && (id.is_osi_approved() || id.is_fsf_free_libre())
108                    } else {
109                        false
110                    }
111                })
112            },
113        )
114    }
115
116    /// Check if this expression requires copyleft compliance.
117    ///
118    /// Returns true if any license term in the expression is copyleft.
119    /// Falls back to substring matching for non-parseable expressions.
120    #[must_use]
121    pub fn is_copyleft(&self) -> bool {
122        spdx::Expression::parse_mode(&self.expression, spdx::ParseMode::LAX).map_or_else(
123            |_| {
124                let expr_lower = self.expression.to_lowercase();
125                expr_lower.contains("gpl")
126                    || expr_lower.contains("agpl")
127                    || expr_lower.contains("lgpl")
128                    || expr_lower.contains("mpl")
129            },
130            |expr| {
131                expr.requirements().any(|req| {
132                    if let spdx::LicenseItem::Spdx { id, .. } = req.req.license {
133                        id.is_copyleft()
134                    } else {
135                        false
136                    }
137                })
138            },
139        )
140    }
141
142    /// Get the license family classification.
143    ///
144    /// For compound expressions:
145    /// - OR: returns the most permissive option (licensee can choose)
146    /// - AND: returns the most restrictive requirement
147    ///   Falls back to substring matching for non-parseable expressions.
148    #[must_use]
149    pub fn family(&self) -> LicenseFamily {
150        if let Ok(expr) = spdx::Expression::parse_mode(&self.expression, spdx::ParseMode::LAX) {
151            let mut has_copyleft = false;
152            let mut has_weak_copyleft = false;
153            let mut has_permissive = false;
154            let mut has_or = false;
155
156            for node in expr.iter() {
157                match node {
158                    spdx::expression::ExprNode::Op(spdx::expression::Operator::Or) => {
159                        has_or = true;
160                    }
161                    spdx::expression::ExprNode::Req(req) => {
162                        if let spdx::LicenseItem::Spdx { id, .. } = req.req.license {
163                            match classify_spdx_license(id) {
164                                LicenseFamily::Copyleft => has_copyleft = true,
165                                LicenseFamily::WeakCopyleft => has_weak_copyleft = true,
166                                LicenseFamily::Permissive | LicenseFamily::PublicDomain => {
167                                    has_permissive = true;
168                                }
169                                _ => {}
170                            }
171                        }
172                    }
173                    spdx::expression::ExprNode::Op(_) => {}
174                }
175            }
176
177            // OR: licensee can choose the most permissive option
178            if has_or && has_permissive {
179                return LicenseFamily::Permissive;
180            }
181
182            // AND or single license: return the most restrictive
183            if has_copyleft {
184                LicenseFamily::Copyleft
185            } else if has_weak_copyleft {
186                LicenseFamily::WeakCopyleft
187            } else if has_permissive {
188                LicenseFamily::Permissive
189            } else {
190                LicenseFamily::Other
191            }
192        } else {
193            // Fallback for non-parseable expressions
194            self.family_from_substring()
195        }
196    }
197
198    /// Substring-based fallback for license family classification.
199    fn family_from_substring(&self) -> LicenseFamily {
200        let expr_lower = self.expression.to_lowercase();
201        if expr_lower.contains("mit")
202            || expr_lower.contains("apache")
203            || expr_lower.contains("bsd")
204            || expr_lower.contains("isc")
205            || expr_lower.contains("unlicense")
206        {
207            LicenseFamily::Permissive
208        } else if expr_lower.contains("gpl")
209            || expr_lower.contains("agpl")
210            || expr_lower.contains("lgpl")
211            || expr_lower.contains("mpl")
212        {
213            LicenseFamily::Copyleft
214        } else if expr_lower.contains("proprietary") {
215            LicenseFamily::Proprietary
216        } else {
217            LicenseFamily::Other
218        }
219    }
220}
221
222/// Rank a license family by restrictiveness (higher is more restrictive).
223fn family_restrictiveness(family: &LicenseFamily) -> u8 {
224    match family {
225        LicenseFamily::Proprietary => 5,
226        LicenseFamily::Copyleft => 4,
227        LicenseFamily::WeakCopyleft => 3,
228        LicenseFamily::Permissive => 2,
229        LicenseFamily::PublicDomain => 1,
230        LicenseFamily::Other => 0,
231    }
232}
233
234/// Classify an SPDX license ID into a license family.
235fn classify_spdx_license(id: spdx::LicenseId) -> LicenseFamily {
236    let name = id.name;
237
238    // Check for public domain dedications
239    if name == "CC0-1.0" || name == "Unlicense" || name == "0BSD" {
240        return LicenseFamily::PublicDomain;
241    }
242
243    if id.is_copyleft() {
244        // Distinguish weak copyleft (LGPL, MPL, EPL, CDDL) from strong copyleft (GPL, AGPL)
245        let name_upper = name.to_uppercase();
246        if name_upper.contains("LGPL")
247            || name_upper.starts_with("MPL")
248            || name_upper.starts_with("EPL")
249            || name_upper.starts_with("CDDL")
250            || name_upper.starts_with("EUPL")
251            || name_upper.starts_with("OSL")
252        {
253            LicenseFamily::WeakCopyleft
254        } else {
255            LicenseFamily::Copyleft
256        }
257    } else if id.is_osi_approved() || id.is_fsf_free_libre() {
258        LicenseFamily::Permissive
259    } else {
260        LicenseFamily::Other
261    }
262}
263
264impl fmt::Display for LicenseExpression {
265    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
266        write!(f, "{}", self.expression)
267    }
268}
269
270impl Default for LicenseExpression {
271    fn default() -> Self {
272        Self {
273            expression: "NOASSERTION".to_string(),
274            is_valid_spdx: false,
275            resolved_name: None,
276        }
277    }
278}
279
280/// License family classification
281#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
282pub enum LicenseFamily {
283    Permissive,
284    Copyleft,
285    WeakCopyleft,
286    Proprietary,
287    PublicDomain,
288    Other,
289}
290
291impl fmt::Display for LicenseFamily {
292    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
293        match self {
294            Self::Permissive => write!(f, "Permissive"),
295            Self::Copyleft => write!(f, "Copyleft"),
296            Self::WeakCopyleft => write!(f, "Weak Copyleft"),
297            Self::Proprietary => write!(f, "Proprietary"),
298            Self::PublicDomain => write!(f, "Public Domain"),
299            Self::Other => write!(f, "Other"),
300        }
301    }
302}
303
304/// License information for a component
305#[derive(Debug, Clone, Default, Serialize, Deserialize)]
306pub struct LicenseInfo {
307    /// Declared licenses from the component metadata
308    pub declared: Vec<LicenseExpression>,
309    /// Concluded license after analysis
310    pub concluded: Option<LicenseExpression>,
311    /// License evidence from scanning
312    pub evidence: Vec<LicenseEvidence>,
313}
314
315impl LicenseInfo {
316    /// Create new empty license info
317    #[must_use]
318    pub fn new() -> Self {
319        Self::default()
320    }
321
322    /// Add a declared license
323    pub fn add_declared(&mut self, license: LicenseExpression) {
324        self.declared.push(license);
325    }
326
327    /// Get all unique license expressions
328    #[must_use]
329    pub fn all_licenses(&self) -> Vec<&LicenseExpression> {
330        let mut licenses: Vec<&LicenseExpression> = self.declared.iter().collect();
331        if let Some(concluded) = &self.concluded {
332            licenses.push(concluded);
333        }
334        licenses
335    }
336
337    /// Get the effective license family across all expressions.
338    ///
339    /// Per-expression OR-choice is already resolved inside
340    /// [`LicenseExpression::family`]; multiple expressions (declared and
341    /// concluded) are treated conjunctively (conservative), so the most
342    /// restrictive family wins:
343    /// Proprietary > Copyleft > `WeakCopyleft` > Permissive > `PublicDomain` > Other.
344    /// Returns [`LicenseFamily::Other`] when no licenses are present.
345    #[must_use]
346    pub fn effective_family(&self) -> LicenseFamily {
347        self.all_licenses()
348            .into_iter()
349            .map(LicenseExpression::family)
350            .max_by_key(family_restrictiveness)
351            .unwrap_or(LicenseFamily::Other)
352    }
353
354    /// Check if there are potential license conflicts across license expressions
355    /// (declared and concluded).
356    ///
357    /// A conflict exists when one expression requires copyleft compliance
358    /// and another declares proprietary terms. Note that a single expression like
359    /// "MIT OR GPL-2.0" is NOT a conflict — it offers a choice.
360    pub fn has_conflicts(&self) -> bool {
361        let families: Vec<LicenseFamily> = self
362            .all_licenses()
363            .into_iter()
364            .map(LicenseExpression::family)
365            .collect();
366
367        let has_copyleft = families.contains(&LicenseFamily::Copyleft);
368        let has_proprietary = families.contains(&LicenseFamily::Proprietary);
369
370        has_copyleft && has_proprietary
371    }
372}
373
374/// License evidence from source scanning
375#[derive(Debug, Clone, Serialize, Deserialize)]
376pub struct LicenseEvidence {
377    /// The detected license
378    pub license: LicenseExpression,
379    /// Confidence score (0.0 - 1.0)
380    pub confidence: f64,
381    /// File path where detected
382    pub file_path: Option<String>,
383    /// Line number in the file
384    pub line_number: Option<u32>,
385}
386
387impl LicenseEvidence {
388    /// Create new license evidence
389    #[must_use]
390    pub const fn new(license: LicenseExpression, confidence: f64) -> Self {
391        Self {
392            license,
393            confidence,
394            file_path: None,
395            line_number: None,
396        }
397    }
398}
399
400#[cfg(test)]
401mod tests {
402    use super::*;
403
404    fn info(declared: &[&str], concluded: Option<&str>) -> LicenseInfo {
405        let mut info = LicenseInfo::new();
406        for lic in declared {
407            info.add_declared(LicenseExpression::new((*lic).to_string()));
408        }
409        info.concluded = concluded.map(|c| LicenseExpression::new(c.to_string()));
410        info
411    }
412
413    #[test]
414    fn effective_family_precedence() {
415        assert_eq!(info(&[], None).effective_family(), LicenseFamily::Other);
416        assert_eq!(
417            info(&["MIT"], None).effective_family(),
418            LicenseFamily::Permissive
419        );
420        assert_eq!(
421            info(&["MIT", "GPL-3.0-only"], None).effective_family(),
422            LicenseFamily::Copyleft
423        );
424        assert_eq!(
425            info(&["MIT", "LGPL-3.0-only"], None).effective_family(),
426            LicenseFamily::WeakCopyleft
427        );
428        assert_eq!(
429            info(&["GPL-3.0-only", "Proprietary"], None).effective_family(),
430            LicenseFamily::Proprietary
431        );
432        assert_eq!(
433            info(&["MIT"], Some("GPL-3.0-only")).effective_family(),
434            LicenseFamily::Copyleft
435        );
436    }
437
438    #[test]
439    fn display_name_prefers_resolved_name() {
440        let mut lic = LicenseExpression::new("LicenseRef-foo".to_string());
441        assert_eq!(lic.display_name(), "LicenseRef-foo");
442
443        lic.resolved_name = Some("Foo Proprietary License".to_string());
444        assert_eq!(lic.display_name(), "Foo Proprietary License");
445        // Identity (equality) still ignores the resolved display name.
446        assert_eq!(lic, LicenseExpression::new("LicenseRef-foo".to_string()));
447    }
448
449    #[test]
450    fn has_conflicts_includes_concluded() {
451        let conflicted = info(&["Proprietary"], Some("GPL-3.0-only"));
452        assert!(conflicted.has_conflicts());
453
454        let declared_only = info(&["GPL-3.0-only", "Proprietary"], None);
455        assert!(declared_only.has_conflicts());
456
457        let no_conflict = info(&["MIT"], Some("GPL-3.0-only"));
458        assert!(!no_conflict.has_conflicts());
459    }
460}