Skip to content

g1t/services/repos/src/license.rs

220 lines11,760 bytesCodeBlame
1//! Which license a repository's LICENSE file holds: an `SPDX-License-Identifier`
2//! line when it has one, else the phrases each common license is known by.
3//! A text none of them match is "Other": the file is still linked.
4
5use g1t_contracts::about::License;
6
7/// The names a license file goes by, at the root, best first.
8const PREFERRED: &[&str] = &["license", "license.md", "license.txt", "licence", "licence.md", "licence.txt", "copying", "copying.md", "copying.txt", "unlicense"];
9
10/// Whether a root file is a license file: `LICENSE`, `LICENCE.md`,
11/// `COPYING`, `LICENSE-MIT` and the like.
12pub fn is_license_file(name: &str) -> bool {
13 let lower = name.to_ascii_lowercase();
14 ["license", "licence", "copying", "unlicense"].iter().any(|start| lower.starts_with(start))
15 && !lower.ends_with(".html")
16 && lower.len() <= 40
17}
18
19/// The root files to read for a license, best first.
20pub fn candidates<'a>(names: impl IntoIterator<Item = &'a str>) -> Vec<&'a str> {
21 let mut found: Vec<&str> = names.into_iter().filter(|name| is_license_file(name)).collect();
22 found.sort_by_key(|name| {
23 let lower = name.to_ascii_lowercase();
24 (PREFERRED.iter().position(|preferred| *preferred == lower).unwrap_or(PREFERRED.len()), lower)
25 });
26 found
27}
28
29/// One license: its SPDX id, its name, and phrases its text holds (all of
30/// `all`, none of `none`), lowercase with spaces collapsed.
31struct Known {
32 id: &'static str,
33 name: &'static str,
34 all: &'static [&'static str],
35 none: &'static [&'static str],
36}
37
38/// Most specific first: AGPL and LGPL before GPL, MIT-0 before MIT.
39const KNOWN: &[Known] = &[
40 Known { id: "AGPL-3.0", name: "GNU Affero General Public License v3.0", all: &["gnu affero general public license"], none: &[] },
41 Known { id: "LGPL-3.0", name: "GNU Lesser General Public License v3.0", all: &["gnu lesser general public license", "version 3"], none: &[] },
42 Known { id: "LGPL-2.1", name: "GNU Lesser General Public License v2.1", all: &["gnu lesser general public license", "version 2.1"], none: &[] },
43 Known { id: "GPL-3.0", name: "GNU General Public License v3.0", all: &["gnu general public license", "version 3"], none: &[] },
44 Known { id: "GPL-2.0", name: "GNU General Public License v2.0", all: &["gnu general public license", "version 2"], none: &[] },
45 Known { id: "Apache-2.0", name: "Apache License 2.0", all: &["apache license", "version 2.0"], none: &[] },
46 Known { id: "MPL-2.0", name: "Mozilla Public License 2.0", all: &["mozilla public license", "2.0"], none: &[] },
47 Known { id: "EPL-2.0", name: "Eclipse Public License 2.0", all: &["eclipse public license", "2.0"], none: &[] },
48 Known { id: "EPL-1.0", name: "Eclipse Public License 1.0", all: &["eclipse public license", "1.0"], none: &[] },
49 Known { id: "BSL-1.0", name: "Boost Software License 1.0", all: &["boost software license"], none: &[] },
50 Known { id: "BUSL-1.1", name: "Business Source License 1.1", all: &["business source license"], none: &[] },
51 Known { id: "Elastic-2.0", name: "Elastic License 2.0", all: &["elastic license 2.0"], none: &[] },
52 Known { id: "SSPL-1.0", name: "Server Side Public License v1", all: &["server side public license"], none: &[] },
53 Known { id: "Unlicense", name: "The Unlicense", all: &["this is free and unencumbered software released into the public domain"], none: &[] },
54 Known { id: "CC0-1.0", name: "Creative Commons Zero v1.0 Universal", all: &["cc0 1.0 universal"], none: &[] },
55 Known { id: "CC-BY-4.0", name: "Creative Commons Attribution 4.0 International", all: &["attribution 4.0 international"], none: &["sharealike", "noncommercial"] },
56 Known { id: "WTFPL", name: "Do What The F*ck You Want To Public License", all: &["do what the fuck you want to public license"], none: &[] },
57 Known {
58 id: "MIT-0",
59 name: "MIT No Attribution",
60 all: &["permission is hereby granted, free of charge, to any person obtaining a copy"],
61 none: &["the above copyright notice and this permission notice shall be included"],
62 },
63 Known {
64 id: "MIT",
65 name: "MIT License",
66 all: &["permission is hereby granted, free of charge, to any person obtaining a copy", "the above copyright notice and this permission notice shall be included"],
67 none: &[],
68 },
69 Known {
70 id: "ISC",
71 name: "ISC License",
72 all: &["permission to use, copy, modify, and/or distribute this software for any purpose", "the above copyright notice and this permission notice appear in all copies"],
73 none: &[],
74 },
75 Known {
76 id: "0BSD",
77 name: "BSD Zero Clause License",
78 all: &["permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted"],
79 none: &["the above copyright notice and this permission notice appear in all copies"],
80 },
81 Known {
82 id: "BSD-4-Clause",
83 name: "BSD 4-Clause \"Original\" License",
84 all: &["redistribution and use in source and binary forms", "all advertising materials mentioning features"],
85 none: &[],
86 },
87 Known {
88 id: "BSD-3-Clause",
89 name: "BSD 3-Clause \"New\" or \"Revised\" License",
90 all: &["redistribution and use in source and binary forms", "neither the name of"],
91 none: &[],
92 },
93 Known {
94 id: "BSD-3-Clause",
95 name: "BSD 3-Clause \"New\" or \"Revised\" License",
96 all: &["redistribution and use in source and binary forms", "may be used to endorse or promote products derived from this software without specific prior written permission"],
97 none: &[],
98 },
99 Known {
100 id: "BSD-2-Clause",
101 name: "BSD 2-Clause \"Simplified\" License",
102 all: &["redistribution and use in source and binary forms"],
103 none: &[],
104 },
105 Known {
106 id: "Zlib",
107 name: "zlib License",
108 all: &["altered source versions must be plainly marked as such"],
109 none: &[],
110 },
111];
112
113/// Lowercase, quotes made plain, and whitespace collapsed to single spaces.
114fn normalize(text: &str) -> String {
115 let mut out = String::with_capacity(text.len());
116 let mut space = false;
117 for c in text.chars() {
118 let c = match c {
119 '\u{2018}' | '\u{2019}' => '\'',
120 '\u{201c}' | '\u{201d}' => '"',
121 c => c,
122 };
123 if c.is_whitespace() || c == '*' || c == '#' {
124 // Comment markers of a header copied from source count as space.
125 space = !out.is_empty();
126 continue;
127 }
128 if space {
129 out.push(' ');
130 space = false;
131 }
132 out.extend(c.to_lowercase());
133 }
134 out
135}
136
137/// An `SPDX-License-Identifier:` line's expression.
138fn spdx_line(text: &str) -> Option<String> {
139 text.lines().take(20).find_map(|line| {
140 let (_, rest) = line.split_once("SPDX-License-Identifier:")?;
141 let id = rest.trim().trim_end_matches("*/").trim();
142 (!id.is_empty() && id.len() <= 100).then(|| id.to_owned())
143 })
144}
145
146/// The license in a license file's text; "Other" when it is not one g1t
147/// recognizes.
148pub fn detect(path: &str, text: &str) -> License {
149 // Enough of the text to find each license's phrases: they come early.
150 let head: String = text.chars().take(24_000).collect();
151 if let Some(id) = spdx_line(&head) {
152 let name = KNOWN.iter().find(|known| known.id.eq_ignore_ascii_case(&id)).map_or_else(|| id.clone(), |known| known.name.to_owned());
153 return License { spdx_id: Some(id), name, path: path.to_owned() };
154 }
155 let normal = normalize(&head);
156 match KNOWN.iter().find(|known| known.all.iter().all(|phrase| normal.contains(phrase)) && !known.none.iter().any(|phrase| normal.contains(phrase))) {
157 Some(known) => License { spdx_id: Some(known.id.to_owned()), name: known.name.to_owned(), path: path.to_owned() },
158 None => License { spdx_id: None, name: "Other".to_owned(), path: path.to_owned() },
159 }
160}
161
162#[cfg(test)]
163mod tests {
164 use super::*;
165
166 const MIT: &str = "MIT License\n\nCopyright (c) 2026 Flagon, Inc.\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND.";
167
168 fn id(text: &str) -> Option<String> {
169 detect("LICENSE", text).spdx_id
170 }
171
172 #[test]
173 fn common_licenses_are_recognized() {
174 assert_eq!(id(MIT).as_deref(), Some("MIT"));
175 assert_eq!(detect("LICENSE", MIT).name, "MIT License");
176 assert_eq!(id(" Apache License\n Version 2.0, January 2004\n").as_deref(), Some("Apache-2.0"));
177 assert_eq!(id("GNU GENERAL PUBLIC LICENSE\nVersion 3, 29 June 2007").as_deref(), Some("GPL-3.0"));
178 assert_eq!(id("GNU GENERAL PUBLIC LICENSE\nVersion 2, June 1991").as_deref(), Some("GPL-2.0"));
179 assert_eq!(id("GNU AFFERO GENERAL PUBLIC LICENSE\nVersion 3, 19 November 2007").as_deref(), Some("AGPL-3.0"));
180 assert_eq!(id("GNU LESSER GENERAL PUBLIC LICENSE\nVersion 2.1, February 1999").as_deref(), Some("LGPL-2.1"));
181 assert_eq!(id("Mozilla Public License Version 2.0\n==================================").as_deref(), Some("MPL-2.0"));
182 assert_eq!(id("This is free and unencumbered software released into the public domain.").as_deref(), Some("Unlicense"));
183 assert_eq!(
184 id("Redistribution and use in source and binary forms, with or without\nmodification, are permitted provided that ...\n3. Neither the name of the copyright holder nor ...").as_deref(),
185 Some("BSD-3-Clause")
186 );
187 assert_eq!(id("Redistribution and use in source and binary forms, with or without modification").as_deref(), Some("BSD-2-Clause"));
188 assert_eq!(
189 id("Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies.").as_deref(),
190 Some("ISC")
191 );
192 }
193
194 #[test]
195 fn mit_without_its_notice_clause_is_mit_0() {
196 let text = MIT.replace("The above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.", "");
197 assert_eq!(id(&text).as_deref(), Some("MIT-0"));
198 }
199
200 #[test]
201 fn an_spdx_line_says_it_outright() {
202 assert_eq!(id("// SPDX-License-Identifier: Apache-2.0 OR MIT\n").as_deref(), Some("Apache-2.0 OR MIT"));
203 assert_eq!(detect("LICENSE", "SPDX-License-Identifier: MIT").name, "MIT License");
204 }
205
206 #[test]
207 fn anything_else_is_other() {
208 let other = detect("COPYING", "All rights reserved. Do not copy.");
209 assert_eq!(other.spdx_id, None);
210 assert_eq!(other.name, "Other");
211 assert_eq!(other.path, "COPYING");
212 }
213
214 #[test]
215 fn license_files_are_found_best_first() {
216 let names = ["README.md", "LICENSE-MIT", "COPYING", "LICENSE.md", "license.html", "src"];
217 assert_eq!(candidates(names), vec!["LICENSE.md", "COPYING", "LICENSE-MIT"]);
218 assert!(candidates(["Cargo.toml"]).is_empty());
219 }
220}