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