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/// Classify an SPDX license ID into a license family.
185fn classify_spdx_license(id: spdx::LicenseId) -> LicenseFamily {
186    let name = id.name;
187
188    // Check for public domain dedications
189    if name == "CC0-1.0" || name == "Unlicense" || name == "0BSD" {
190        return LicenseFamily::PublicDomain;
191    }
192
193    if id.is_copyleft() {
194        // Distinguish weak copyleft (LGPL, MPL, EPL, CDDL) from strong copyleft (GPL, AGPL)
195        let name_upper = name.to_uppercase();
196        if name_upper.contains("LGPL")
197            || name_upper.starts_with("MPL")
198            || name_upper.starts_with("EPL")
199            || name_upper.starts_with("CDDL")
200            || name_upper.starts_with("EUPL")
201            || name_upper.starts_with("OSL")
202        {
203            LicenseFamily::WeakCopyleft
204        } else {
205            LicenseFamily::Copyleft
206        }
207    } else if id.is_osi_approved() || id.is_fsf_free_libre() {
208        LicenseFamily::Permissive
209    } else {
210        LicenseFamily::Other
211    }
212}
213
214impl fmt::Display for LicenseExpression {
215    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
216        write!(f, "{}", self.expression)
217    }
218}
219
220impl Default for LicenseExpression {
221    fn default() -> Self {
222        Self {
223            expression: "NOASSERTION".to_string(),
224            is_valid_spdx: false,
225        }
226    }
227}
228
229/// License family classification
230#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
231pub enum LicenseFamily {
232    Permissive,
233    Copyleft,
234    WeakCopyleft,
235    Proprietary,
236    PublicDomain,
237    Other,
238}
239
240impl fmt::Display for LicenseFamily {
241    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
242        match self {
243            Self::Permissive => write!(f, "Permissive"),
244            Self::Copyleft => write!(f, "Copyleft"),
245            Self::WeakCopyleft => write!(f, "Weak Copyleft"),
246            Self::Proprietary => write!(f, "Proprietary"),
247            Self::PublicDomain => write!(f, "Public Domain"),
248            Self::Other => write!(f, "Other"),
249        }
250    }
251}
252
253/// License information for a component
254#[derive(Debug, Clone, Default, Serialize, Deserialize)]
255pub struct LicenseInfo {
256    /// Declared licenses from the component metadata
257    pub declared: Vec<LicenseExpression>,
258    /// Concluded license after analysis
259    pub concluded: Option<LicenseExpression>,
260    /// License evidence from scanning
261    pub evidence: Vec<LicenseEvidence>,
262}
263
264impl LicenseInfo {
265    /// Create new empty license info
266    #[must_use]
267    pub fn new() -> Self {
268        Self::default()
269    }
270
271    /// Add a declared license
272    pub fn add_declared(&mut self, license: LicenseExpression) {
273        self.declared.push(license);
274    }
275
276    /// Get all unique license expressions
277    #[must_use]
278    pub fn all_licenses(&self) -> Vec<&LicenseExpression> {
279        let mut licenses: Vec<&LicenseExpression> = self.declared.iter().collect();
280        if let Some(concluded) = &self.concluded {
281            licenses.push(concluded);
282        }
283        licenses
284    }
285
286    /// Check if there are potential license conflicts across declared expressions.
287    ///
288    /// A conflict exists when one declared expression requires copyleft compliance
289    /// and another declares proprietary terms. Note that a single expression like
290    /// "MIT OR GPL-2.0" is NOT a conflict — it offers a choice.
291    pub fn has_conflicts(&self) -> bool {
292        let families: Vec<LicenseFamily> = self
293            .declared
294            .iter()
295            .map(LicenseExpression::family)
296            .collect();
297
298        let has_copyleft = families.contains(&LicenseFamily::Copyleft);
299        let has_proprietary = families.contains(&LicenseFamily::Proprietary);
300
301        has_copyleft && has_proprietary
302    }
303}
304
305/// License evidence from source scanning
306#[derive(Debug, Clone, Serialize, Deserialize)]
307pub struct LicenseEvidence {
308    /// The detected license
309    pub license: LicenseExpression,
310    /// Confidence score (0.0 - 1.0)
311    pub confidence: f64,
312    /// File path where detected
313    pub file_path: Option<String>,
314    /// Line number in the file
315    pub line_number: Option<u32>,
316}
317
318impl LicenseEvidence {
319    /// Create new license evidence
320    #[must_use]
321    pub const fn new(license: LicenseExpression, confidence: f64) -> Self {
322        Self {
323            license,
324            confidence,
325            file_path: None,
326            line_number: None,
327        }
328    }
329}