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    pub fn new(expression: String) -> Self {
21        let is_valid_spdx = Self::validate_spdx(&expression);
22        Self {
23            expression,
24            is_valid_spdx,
25        }
26    }
27
28    /// Create from an SPDX license ID
29    pub fn from_spdx_id(id: &str) -> Self {
30        Self {
31            expression: id.to_string(),
32            is_valid_spdx: true,
33        }
34    }
35
36    /// Validate an SPDX expression using the spdx crate.
37    ///
38    /// Uses lax parsing mode to accept common non-standard expressions
39    /// (e.g., "Apache2" instead of "Apache-2.0", "/" instead of "OR").
40    fn validate_spdx(expr: &str) -> bool {
41        if expr.is_empty() || expr.contains("NOASSERTION") || expr.contains("NONE") {
42            return false;
43        }
44        spdx::Expression::parse_mode(expr, spdx::ParseMode::LAX).is_ok()
45    }
46
47    /// Check if this expression includes a permissive license option.
48    ///
49    /// For OR expressions (e.g., "MIT OR GPL-2.0"), returns true if at least
50    /// one branch is permissive (the licensee can choose the permissive option).
51    /// Falls back to substring matching for non-parseable expressions.
52    pub fn is_permissive(&self) -> bool {
53        if let Ok(expr) = spdx::Expression::parse_mode(&self.expression, spdx::ParseMode::LAX) {
54            expr.requirements().any(|req| {
55                if let spdx::LicenseItem::Spdx { id, .. } = req.req.license {
56                    !id.is_copyleft() && (id.is_osi_approved() || id.is_fsf_free_libre())
57                } else {
58                    false
59                }
60            })
61        } else {
62            // Fallback for non-standard expressions
63            let expr_lower = self.expression.to_lowercase();
64            expr_lower.contains("mit")
65                || expr_lower.contains("apache")
66                || expr_lower.contains("bsd")
67                || expr_lower.contains("isc")
68                || expr_lower.contains("unlicense")
69        }
70    }
71
72    /// Check if this expression requires copyleft compliance.
73    ///
74    /// Returns true if any license term in the expression is copyleft.
75    /// Falls back to substring matching for non-parseable expressions.
76    pub fn is_copyleft(&self) -> bool {
77        if let Ok(expr) = spdx::Expression::parse_mode(&self.expression, spdx::ParseMode::LAX) {
78            expr.requirements().any(|req| {
79                if let spdx::LicenseItem::Spdx { id, .. } = req.req.license {
80                    id.is_copyleft()
81                } else {
82                    false
83                }
84            })
85        } else {
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    }
93
94    /// Get the license family classification.
95    ///
96    /// For compound expressions:
97    /// - OR: returns the most permissive option (licensee can choose)
98    /// - AND: returns the most restrictive requirement
99    ///   Falls back to substring matching for non-parseable expressions.
100    pub fn family(&self) -> LicenseFamily {
101        if let Ok(expr) = spdx::Expression::parse_mode(&self.expression, spdx::ParseMode::LAX) {
102            let mut has_copyleft = false;
103            let mut has_weak_copyleft = false;
104            let mut has_permissive = false;
105            let mut has_or = false;
106
107            for node in expr.iter() {
108                match node {
109                    spdx::expression::ExprNode::Op(spdx::expression::Operator::Or) => {
110                        has_or = true;
111                    }
112                    spdx::expression::ExprNode::Req(req) => {
113                        if let spdx::LicenseItem::Spdx { id, .. } = req.req.license {
114                            match classify_spdx_license(id) {
115                                LicenseFamily::Copyleft => has_copyleft = true,
116                                LicenseFamily::WeakCopyleft => has_weak_copyleft = true,
117                                LicenseFamily::Permissive | LicenseFamily::PublicDomain => {
118                                    has_permissive = true;
119                                }
120                                _ => {}
121                            }
122                        }
123                    }
124                    _ => {}
125                }
126            }
127
128            // OR: licensee can choose the most permissive option
129            if has_or && has_permissive {
130                return LicenseFamily::Permissive;
131            }
132
133            // AND or single license: return the most restrictive
134            if has_copyleft {
135                LicenseFamily::Copyleft
136            } else if has_weak_copyleft {
137                LicenseFamily::WeakCopyleft
138            } else if has_permissive {
139                LicenseFamily::Permissive
140            } else {
141                LicenseFamily::Other
142            }
143        } else {
144            // Fallback for non-parseable expressions
145            self.family_from_substring()
146        }
147    }
148
149    /// Substring-based fallback for license family classification.
150    fn family_from_substring(&self) -> LicenseFamily {
151        let expr_lower = self.expression.to_lowercase();
152        if expr_lower.contains("mit")
153            || expr_lower.contains("apache")
154            || expr_lower.contains("bsd")
155            || expr_lower.contains("isc")
156            || expr_lower.contains("unlicense")
157        {
158            LicenseFamily::Permissive
159        } else if expr_lower.contains("gpl")
160            || expr_lower.contains("agpl")
161            || expr_lower.contains("lgpl")
162            || expr_lower.contains("mpl")
163        {
164            LicenseFamily::Copyleft
165        } else if expr_lower.contains("proprietary") {
166            LicenseFamily::Proprietary
167        } else {
168            LicenseFamily::Other
169        }
170    }
171}
172
173/// Classify an SPDX license ID into a license family.
174fn classify_spdx_license(id: spdx::LicenseId) -> LicenseFamily {
175    let name = id.name;
176
177    // Check for public domain dedications
178    if name == "CC0-1.0" || name == "Unlicense" || name == "0BSD" {
179        return LicenseFamily::PublicDomain;
180    }
181
182    if id.is_copyleft() {
183        // Distinguish weak copyleft (LGPL, MPL, EPL, CDDL) from strong copyleft (GPL, AGPL)
184        let name_upper = name.to_uppercase();
185        if name_upper.contains("LGPL")
186            || name_upper.starts_with("MPL")
187            || name_upper.starts_with("EPL")
188            || name_upper.starts_with("CDDL")
189            || name_upper.starts_with("EUPL")
190            || name_upper.starts_with("OSL")
191        {
192            LicenseFamily::WeakCopyleft
193        } else {
194            LicenseFamily::Copyleft
195        }
196    } else if id.is_osi_approved() || id.is_fsf_free_libre() {
197        LicenseFamily::Permissive
198    } else {
199        LicenseFamily::Other
200    }
201}
202
203impl fmt::Display for LicenseExpression {
204    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
205        write!(f, "{}", self.expression)
206    }
207}
208
209impl Default for LicenseExpression {
210    fn default() -> Self {
211        Self {
212            expression: "NOASSERTION".to_string(),
213            is_valid_spdx: false,
214        }
215    }
216}
217
218/// License family classification
219#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
220pub enum LicenseFamily {
221    Permissive,
222    Copyleft,
223    WeakCopyleft,
224    Proprietary,
225    PublicDomain,
226    Other,
227}
228
229impl fmt::Display for LicenseFamily {
230    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
231        match self {
232            LicenseFamily::Permissive => write!(f, "Permissive"),
233            LicenseFamily::Copyleft => write!(f, "Copyleft"),
234            LicenseFamily::WeakCopyleft => write!(f, "Weak Copyleft"),
235            LicenseFamily::Proprietary => write!(f, "Proprietary"),
236            LicenseFamily::PublicDomain => write!(f, "Public Domain"),
237            LicenseFamily::Other => write!(f, "Other"),
238        }
239    }
240}
241
242/// License information for a component
243#[derive(Debug, Clone, Default, Serialize, Deserialize)]
244pub struct LicenseInfo {
245    /// Declared licenses from the component metadata
246    pub declared: Vec<LicenseExpression>,
247    /// Concluded license after analysis
248    pub concluded: Option<LicenseExpression>,
249    /// License evidence from scanning
250    pub evidence: Vec<LicenseEvidence>,
251}
252
253impl LicenseInfo {
254    /// Create new empty license info
255    pub fn new() -> Self {
256        Self::default()
257    }
258
259    /// Add a declared license
260    pub fn add_declared(&mut self, license: LicenseExpression) {
261        self.declared.push(license);
262    }
263
264    /// Get all unique license expressions
265    pub fn all_licenses(&self) -> Vec<&LicenseExpression> {
266        let mut licenses: Vec<&LicenseExpression> = self.declared.iter().collect();
267        if let Some(concluded) = &self.concluded {
268            licenses.push(concluded);
269        }
270        licenses
271    }
272
273    /// Check if there are potential license conflicts across declared expressions.
274    ///
275    /// A conflict exists when one declared expression requires copyleft compliance
276    /// and another declares proprietary terms. Note that a single expression like
277    /// "MIT OR GPL-2.0" is NOT a conflict — it offers a choice.
278    pub fn has_conflicts(&self) -> bool {
279        let families: Vec<LicenseFamily> = self.declared.iter().map(|l| l.family()).collect();
280
281        let has_copyleft = families.contains(&LicenseFamily::Copyleft);
282        let has_proprietary = families.contains(&LicenseFamily::Proprietary);
283
284        has_copyleft && has_proprietary
285    }
286}
287
288/// License evidence from source scanning
289#[derive(Debug, Clone, Serialize, Deserialize)]
290pub struct LicenseEvidence {
291    /// The detected license
292    pub license: LicenseExpression,
293    /// Confidence score (0.0 - 1.0)
294    pub confidence: f64,
295    /// File path where detected
296    pub file_path: Option<String>,
297    /// Line number in the file
298    pub line_number: Option<u32>,
299}
300
301impl LicenseEvidence {
302    /// Create new license evidence
303    pub fn new(license: LicenseExpression, confidence: f64) -> Self {
304        Self {
305            license,
306            confidence,
307            file_path: None,
308            line_number: None,
309        }
310    }
311}