1// Copyright © SixtyFPS GmbH <info@slint.dev>
2// SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0
3
4use anyhow::{Context, Result};
5
6use xshell::{Cmd, Shell};
7
8use std::collections::BTreeMap;
9use std::{ffi::OsStr, path::Path, path::PathBuf};
10
11fn cmd<I>(sh: &Shell, command: impl AsRef<Path>, args: I) -> Result<Cmd<'_>>
12where
13 I: IntoIterator,
14 I::Item: AsRef<OsStr>,
15{
16 let home_dir: String = std::env::var(key:"HOME").context("HOME is not set in the environment")?;
17 Ok(sh.cmd(command).args(args).env(key:"PATH", &format!("/bin:/usr/bin:{home_dir}/.local/bin")))
18}
19
20pub fn find_reuse() -> Result<PathBuf> {
21 which::which(binary_name:"reuse").context("Failed to find reuse")
22}
23
24pub fn reuse_download(sh: &Shell, reuse: &Path) -> Result<String> {
25 Ok(cmdResult(sh, command:reuse, &["download", "--all"])?
26 .read()
27 .context("Failed to download missing licenses.")?)
28}
29
30pub fn reuse_lint(sh: &Shell, reuse: &Path) -> Result<()> {
31 let output: Output = cmd(sh, command:reuse, &["lint"])?.ignore_status().output()?;
32
33 if !output.status.success() {
34 let stdout: String = String::from_utf8(vec:output.stdout)?;
35 println!("{}", &stdout);
36 anyhow::bail!("Project is not reuse compliant!");
37 }
38 Ok(())
39}
40
41fn parse_spdx_data(sh: &Shell, reuse: &Path) -> Result<BTreeMap<PathBuf, Vec<String>>> {
42 let output = cmd(sh, reuse, &["spdx"])?.read()?;
43
44 let mut current_filename = String::new();
45 let mut licenses = Vec::new();
46 let mut result = BTreeMap::new();
47
48 fn insert(v: &mut BTreeMap<PathBuf, Vec<String>>, f: &str, l: Vec<String>) -> Result<()> {
49 if l.is_empty() {
50 anyhow::bail!("No license info for \"{}\" available", f);
51 }
52 v.insert(PathBuf::from(f), l);
53 Ok(())
54 }
55
56 for line in output.lines() {
57 if line.starts_with("FileName: ") {
58 if !current_filename.is_empty() {
59 insert(&mut result, &current_filename, licenses)?;
60 licenses = Vec::new();
61 }
62
63 current_filename = line[10..].into();
64 } else if line.starts_with("LicenseInfoInFile: ") {
65 let license = line[19..].into();
66 licenses.push(license);
67 }
68 }
69
70 if !current_filename.is_empty() {
71 insert(&mut result, &current_filename, licenses)?;
72 }
73
74 Ok(result)
75}
76
77fn find_licenses_directories(dir: &Path) -> Result<Vec<PathBuf>> {
78 assert!(dir.is_dir());
79
80 let mut result = Vec::new();
81
82 let licenses_name: Option<&OsStr> = Some(OsStr::new("LICENSES"));
83 let dot_name: &OsStr = OsStr::new(".");
84
85 for d in std::fs::read_dir(dir)?
86 .filter(|d| d.as_ref().is_ok_and(|e| e.file_type().is_ok_and(|f| f.is_dir())))
87 {
88 let path = d?.path();
89 let parent_path = path.parent().expect("This is a subdirectory, so it must have a parent!");
90 if path.file_name() == licenses_name && parent_path != dot_name {
91 let parent_path = parent_path.to_owned();
92 result.push(parent_path);
93 } else {
94 result.append(&mut find_licenses_directories(&path)?)
95 }
96 }
97
98 result.sort();
99
100 Ok(result)
101}
102
103fn populate_license_map(
104 license_map: &mut BTreeMap<PathBuf, Vec<String>>,
105 file_map: BTreeMap<PathBuf, Vec<String>>,
106) {
107 // longer names are sorted after shorter, so look from the back.
108 //
109 // FIXME: This is rather inefficient! Hope it is OK for the use case at hand.
110 for (file: &PathBuf, file_lic: &Vec) in file_map.iter().rev() {
111 for (dir: &PathBuf, dir_lic: &mut Vec) in license_map.iter_mut().rev() {
112 if file.starts_with(base:dir) {
113 // There should not be more than maybe 5 or so licenses applicable to any
114 // directory, so this is probably OK
115 for l: &String in file_lic {
116 if !dir_lic.contains(l) {
117 dir_lic.push(l.clone());
118 }
119 }
120 break;
121 }
122 }
123 }
124}
125
126fn is_symlink(path: &Path) -> bool {
127 std::fs::symlink_metadata(path).is_ok_and(|m: Metadata| m.file_type().is_symlink())
128}
129
130fn validate_license_directory(dir: &Path, licenses: &[String], fix_it: bool) -> Result<()> {
131 let top_dir =
132 PathBuf::from(".").canonicalize().context("Failed to canonicalize the top directory")?;
133 let lic_dir = dir.join("LICENSES").canonicalize().with_context(|| {
134 format!("Failed to canonicalize \"{}\"", dir.join("LICENSES").to_string_lossy())
135 })?;
136
137 if !lic_dir.is_dir() {
138 anyhow::bail!("\"{}\" is not a directory", lic_dir.to_string_lossy());
139 }
140
141 let mut linked_licenses = Vec::new();
142 let mut to_add = Vec::new();
143 let mut to_remove = Vec::new();
144
145 for d in lic_dir
146 .read_dir()
147 .with_context(|| format!("Failed to read \"{}\" directory", lic_dir.to_string_lossy()))?
148 {
149 let child = d
150 .with_context(|| {
151 format!("Failed to read in LICENSES directory \"{}\"", lic_dir.to_string_lossy())
152 })?
153 .path();
154
155 if !is_symlink(&child) {
156 if child.is_file() && fix_it {
157 to_remove.push(child.clone());
158 continue;
159 } else {
160 anyhow::bail!("\"{}\" is a not a symlink!", child.to_string_lossy());
161 }
162 }
163
164 let ext = child.extension().unwrap_or_default();
165 let file_stem = child.file_stem().unwrap_or_default().to_string_lossy().to_string();
166
167 if ext.is_empty() || (ext != "txt" && ext != "md" && ext != "html") {
168 anyhow::bail!("Invalid extension for LICENSE symlink \"{}\"", child.to_string_lossy());
169 }
170
171 if file_stem.is_empty() || !licenses.contains(&file_stem) {
172 if !fix_it {
173 anyhow::bail!("LICENSE symlink \"{}\" is not necessary", child.to_string_lossy());
174 } else {
175 to_remove.push(child.clone());
176 continue;
177 }
178 } else {
179 linked_licenses.push(file_stem.clone());
180 }
181
182 let link_target = std::fs::read_link(&child).with_context(|| {
183 format!("Could not extract link target of \"{}\"", child.to_string_lossy())
184 })?;
185 let link_target_file_stem = link_target.file_stem().unwrap_or_default().to_string_lossy();
186 let link_target_extension = link_target.extension().unwrap_or_default();
187
188 let validated_link_target = lic_dir.join(&link_target).canonicalize().unwrap_or_default();
189 if validated_link_target.as_os_str().is_empty() {
190 if !fix_it {
191 anyhow::bail!(
192 "License symlink \"{}\" does not point to any existing location",
193 child.to_string_lossy()
194 );
195 }
196 // Path validation failed
197 to_remove.push(child.clone());
198 to_add.push(file_stem.clone());
199 continue;
200 }
201 if link_target_extension != ext || link_target_file_stem != file_stem {
202 if !fix_it {
203 anyhow::bail!(
204 "LICENSE symlink \"{}\" renames the license.",
205 child.to_string_lossy()
206 );
207 } else {
208 to_remove.push(child.clone());
209 to_add.push(file_stem.clone());
210 continue;
211 }
212 }
213
214 if !validated_link_target.is_absolute() || !validated_link_target.starts_with(&top_dir) {
215 if !fix_it {
216 let c = child.to_string_lossy();
217 anyhow::bail!("LICENSE symlink \"{}\" points outside the repository", c);
218 } else {
219 to_remove.push(child.clone());
220 to_add.push(file_stem.clone());
221 continue;
222 }
223 }
224
225 if !validated_link_target.starts_with(top_dir.join("LICENSES")) {
226 if !fix_it {
227 let c = child.to_string_lossy();
228 anyhow::bail!(
229 "LICENSE symlink \"{}\" points to a random place in the repository",
230 c
231 );
232 } else {
233 to_remove.push(child.clone());
234 to_add.push(file_stem.clone());
235 continue;
236 }
237 }
238
239 if !validated_link_target.is_file() {
240 if !fix_it {
241 let c = child.to_string_lossy();
242 anyhow::bail!("LICENSE symlink \"{}\" does not point to a file", c);
243 } else {
244 to_remove.push(child.clone());
245 to_add.push(file_stem.clone());
246 continue;
247 }
248 }
249 }
250
251 if !fix_it {
252 return Ok(());
253 }
254
255 // Remove old symlinks
256 for rm in to_remove {
257 println!("Removing symlink \"{}\"...", rm.to_string_lossy());
258 std::fs::remove_file(&rm).with_context(|| {
259 format!("Failed to remove LICENSE symlink \"{}\"", rm.to_string_lossy())
260 })?;
261 }
262
263 for l in licenses {
264 let l = l.to_string();
265 if !linked_licenses.contains(&l) {
266 to_add.push(l);
267 }
268 }
269
270 let mut license_filenames_to_add = Vec::new();
271
272 if !to_add.is_empty() {
273 let top_lic = PathBuf::from("LICENSES");
274 for l in top_lic.read_dir().context("Failed to read the top level LICENSES directory")? {
275 let path =
276 l.context("Failed to read an entry in the top level LICENSES directory")?.path();
277 if path.is_file() {
278 let file_stem = path.file_stem().unwrap_or_default().to_string_lossy().to_string();
279 if to_add.contains(&file_stem) {
280 license_filenames_to_add.push(path.file_name().unwrap_or_default().to_owned());
281 }
282 }
283 }
284 }
285 if license_filenames_to_add.len() != to_add.len() {
286 anyhow::bail!("Not all licenses were found in top level LICENSES directory");
287 }
288
289 for license_file in license_filenames_to_add {
290 // build symlink target path
291 let mut target_link_path = PathBuf::new();
292
293 for _ in 0..lic_dir.components().count() - top_dir.components().count() {
294 target_link_path = target_link_path.join("..");
295 }
296 target_link_path = target_link_path.join("LICENSES");
297 target_link_path = target_link_path.join(&license_file);
298
299 let source_path = lic_dir.join(&license_file);
300
301 println!(
302 "Creating LICENSE symlink {} -> {}",
303 &source_path.to_string_lossy(),
304 &target_link_path.to_string_lossy()
305 );
306
307 #[cfg(unix)]
308 let result = std::os::unix::fs::symlink(&target_link_path, &source_path);
309 #[cfg(windows)]
310 let result = std::os::windows::fs::symlink_file(&target_link_path, &source_path);
311
312 result.with_context(|| {
313 format!(
314 "Failed to create symlink \"{}\" -> \"{}\"",
315 source_path.to_string_lossy(),
316 target_link_path.to_string_lossy()
317 )
318 })?;
319 }
320
321 Ok(())
322}
323
324pub fn scan_symlinks(sh: &Shell, reuse: &Path, fix_it: bool) -> Result<()> {
325 let license_directories: Vec = find_licenses_directoriesResult, Error>(&PathBuf::from("."))
326 .context("Failed to scan for directories containing LICENSES subfolders")?;
327
328 if license_directories.is_empty() {
329 return Ok(());
330 }
331
332 let mut license_map: BTreeMap> = license_directoriesimpl Iterator
333 .iter()
334 .map(|p: &PathBuf| (p.clone(), Vec::<String>::new()))
335 .collect::<BTreeMap<_, _>>();
336
337 let file_data: BTreeMap> = parse_spdx_data(sh, reuse).context("Failed to parse SPDX project data")?;
338
339 populate_license_map(&mut license_map, file_map:file_data);
340
341 for (dir: PathBuf, licenses: Vec) in license_map {
342 validate_license_directory(&dir, &licenses, fix_it)?;
343 }
344
345 Ok(())
346}
347
348#[derive(Debug, clap::Parser)]
349pub struct ReuseComplianceCheck {
350 #[arg(long, action)]
351 fix_symlinks: bool,
352 #[arg(long, action)]
353 download_missing_licenses: bool,
354}
355
356impl ReuseComplianceCheck {
357 pub fn check_reuse_compliance(&self) -> Result<()> {
358 if !std::env::current_dir()
359 .context("Can not access current work directory")?
360 .join("REUSE.toml")
361 .is_file()
362 {
363 anyhow::bail!("No REUSE.toml file found in current directory");
364 }
365
366 let sh = Shell::new()?;
367
368 let reuse = find_reuse().context("Can not find reuse. Please make sure it is installed")?;
369
370 println!("Reuse binary \"{}\".", reuse.to_string_lossy());
371
372 if self.download_missing_licenses {
373 let output = reuse_download(&sh, &reuse)?;
374 println!("{}", &output);
375 }
376
377 reuse_lint(&sh, &reuse)?;
378
379 scan_symlinks(&sh, &reuse, self.fix_symlinks)
380 }
381}
382