Skip to main content

ossctl_core/contract/
spdx.rs

1//! Vendored SPDX license-expression grammar + id check.
2//!
3//! Validates the contract's `license` field against a bundled SPDX id set so
4//! license validity is deterministic and offline (no network, no pip). A direct
5//! port of `check-oss-release.py`'s `spdx_valid` / `_valid_license_id`: a real
6//! grammar check, not a string match.
7//!
8//! Grammar: `expr := term ((AND|OR) term)*` ; `term := '(' expr ')' | id
9//! ['WITH' exception]`. Operators are matched case-insensitively. Exception ids
10//! after `WITH` are accepted on shape (the exception list is not vendored).
11
12/// Vendored minimal SPDX license id set — a curated mainstream OSS subset, not
13/// the full 600+ list, so the membership test needs no dependency. All lowercase
14/// (the check lowercases its input). Deprecated short forms (`gpl-3.0`,
15/// `lgpl-2.1`, …) are included because they remain widespread in the wild.
16const SPDX_LICENSE_IDS: &[&str] = &[
17    "0bsd",
18    "afl-3.0",
19    "agpl-3.0",
20    "agpl-3.0-only",
21    "agpl-3.0-or-later",
22    "apache-2.0",
23    "artistic-2.0",
24    "blueoak-1.0.0",
25    "bsd-2-clause",
26    "bsd-2-clause-patent",
27    "bsd-3-clause",
28    "bsd-3-clause-clear",
29    "bsl-1.0",
30    "cc-by-4.0",
31    "cc-by-sa-4.0",
32    "cc0-1.0",
33    "cecill-2.1",
34    "ecl-2.0",
35    "epl-1.0",
36    "epl-2.0",
37    "eupl-1.1",
38    "eupl-1.2",
39    "gpl-2.0",
40    "gpl-2.0-only",
41    "gpl-2.0-or-later",
42    "gpl-3.0",
43    "gpl-3.0-only",
44    "gpl-3.0-or-later",
45    "isc",
46    "lgpl-2.1",
47    "lgpl-2.1-only",
48    "lgpl-2.1-or-later",
49    "lgpl-3.0",
50    "lgpl-3.0-only",
51    "lgpl-3.0-or-later",
52    "mit",
53    "mit-0",
54    "mpl-2.0",
55    "ms-pl",
56    "ms-rl",
57    "ncsa",
58    "ofl-1.1",
59    "osl-3.0",
60    "postgresql",
61    "python-2.0",
62    "ruby",
63    "unlicense",
64    "upl-1.0",
65    "vim",
66    "wtfpl",
67    "zlib",
68    "zpl-2.1",
69];
70
71/// A single SPDX license id: a `LicenseRef-*`/`DocumentRef-*` custom id, or a
72/// vendored id (case-insensitive), each optionally with a trailing `+`
73/// ("or later").
74fn valid_license_id(tok: &str) -> bool {
75    let base = tok.strip_suffix('+').unwrap_or(tok);
76    if base.starts_with("LicenseRef-") || base.starts_with("DocumentRef-") {
77        // Custom ref: shape-only check (no membership). Non-empty, and every
78        // char is alphanumeric or one of `.`/`-`/`:` (the Python regex).
79        return !base.is_empty()
80            && base
81                .chars()
82                .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | ':'));
83    }
84    let lower = base.to_ascii_lowercase();
85    SPDX_LICENSE_IDS.contains(&lower.as_str())
86}
87
88/// Whether `expr` is a syntactically valid SPDX license expression whose license
89/// ids are all recognized.
90#[must_use]
91pub fn spdx_valid(expr: &str) -> bool {
92    if expr.trim().is_empty() {
93        return false;
94    }
95    // Tokenize: parenthesess split off as their own tokens, then whitespace-split.
96    let spaced = expr.replace('(', " ( ").replace(')', " ) ");
97    let toks: Vec<&str> = spaced.split_whitespace().collect();
98    let mut parser = Parser {
99        toks: &toks,
100        pos: 0,
101    };
102    parser.parse_expr() && parser.pos == parser.toks.len()
103}
104
105struct Parser<'a> {
106    toks: &'a [&'a str],
107    pos: usize,
108}
109
110impl<'a> Parser<'a> {
111    fn peek(&self) -> Option<&'a str> {
112        self.toks.get(self.pos).copied()
113    }
114
115    fn is_op(tok: Option<&str>) -> bool {
116        matches!(tok, Some(t) if t.eq_ignore_ascii_case("AND") || t.eq_ignore_ascii_case("OR"))
117    }
118
119    fn parse_expr(&mut self) -> bool {
120        if !self.parse_term() {
121            return false;
122        }
123        while Self::is_op(self.peek()) {
124            self.pos += 1;
125            if !self.parse_term() {
126                return false;
127            }
128        }
129        true
130    }
131
132    fn parse_term(&mut self) -> bool {
133        let Some(t) = self.peek() else {
134            return false;
135        };
136        if t == "(" {
137            self.pos += 1;
138            if !self.parse_expr() {
139                return false;
140            }
141            if self.peek() != Some(")") {
142                return false;
143            }
144            self.pos += 1;
145            return true;
146        }
147        // A term must start with a license id: not a paren, operator, or WITH.
148        if t == "(" || t == ")" || Self::is_op(Some(t)) || t.eq_ignore_ascii_case("WITH") {
149            return false;
150        }
151        self.pos += 1; // consume the license id
152        if !valid_license_id(t) {
153            return false;
154        }
155        if matches!(self.peek(), Some(w) if w.eq_ignore_ascii_case("WITH")) {
156            self.pos += 1;
157            // The exception id is accepted on shape (not vendored).
158            match self.peek() {
159                Some(exc)
160                    if exc != "("
161                        && exc != ")"
162                        && !Self::is_op(Some(exc))
163                        && !exc.eq_ignore_ascii_case("WITH") =>
164                {
165                    self.pos += 1;
166                }
167                _ => return false,
168            }
169        }
170        true
171    }
172}
173
174#[cfg(test)]
175mod tests {
176    use super::spdx_valid;
177
178    #[test]
179    fn simple_ids() {
180        assert!(spdx_valid("MIT"));
181        assert!(spdx_valid("Apache-2.0"));
182        assert!(spdx_valid("mit")); // case-insensitive
183        assert!(spdx_valid("GPL-3.0-only"));
184    }
185
186    #[test]
187    fn or_later_suffix() {
188        assert!(spdx_valid("GPL-3.0+"));
189        assert!(spdx_valid("Apache-2.0+"));
190    }
191
192    #[test]
193    fn compound_expressions() {
194        assert!(spdx_valid("MIT OR Apache-2.0"));
195        assert!(spdx_valid("MIT AND Apache-2.0"));
196        assert!(spdx_valid("(MIT OR Apache-2.0) AND ISC"));
197        assert!(spdx_valid("mit or apache-2.0")); // operators case-insensitive
198    }
199
200    #[test]
201    fn with_exception() {
202        assert!(spdx_valid("GPL-3.0-only WITH Classpath-exception-2.0"));
203        assert!(!spdx_valid("GPL-3.0-only WITH")); // dangling WITH
204    }
205
206    #[test]
207    fn custom_refs() {
208        assert!(spdx_valid("LicenseRef-Acme-Proprietary"));
209        assert!(spdx_valid("DocumentRef-x:LicenseRef-y"));
210    }
211
212    #[test]
213    fn rejects_unknown_and_malformed() {
214        assert!(!spdx_valid(""));
215        assert!(!spdx_valid("   "));
216        assert!(!spdx_valid("Proprietary-Acme")); // unknown id
217        assert!(!spdx_valid("MIT AND")); // dangling operator
218        assert!(!spdx_valid("(MIT")); // unbalanced paren
219        assert!(!spdx_valid("MIT OR OR Apache-2.0")); // double operator
220        assert!(!spdx_valid("MIT Apache-2.0")); // missing operator
221        assert!(!spdx_valid("()")); // empty group
222    }
223}