| 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 | |
| 4 | use anyhow::{Context, Result}; |
| 5 | |
| 6 | use xshell::{Cmd, Shell}; |
| 7 | |
| 8 | use std::collections::BTreeMap; |
| 9 | use std::{ffi::OsStr, path::Path, path::PathBuf}; |
| 10 | |
| 11 | fn cmd<I>(sh: &Shell, command: impl AsRef<Path>, args: I) -> Result<Cmd<'_>> |
| 12 | where |
| 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 | |
| 20 | pub fn find_reuse() -> Result<PathBuf> { |
| 21 | which::which(binary_name:"reuse" ).context("Failed to find reuse" ) |
| 22 | } |
| 23 | |
| 24 | pub 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 | |
| 30 | pub 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 | |
| 41 | fn 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, ¤t_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, ¤t_filename, licenses)?; |
| 72 | } |
| 73 | |
| 74 | Ok(result) |
| 75 | } |
| 76 | |
| 77 | fn 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 | |
| 103 | fn 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 | |
| 126 | fn is_symlink(path: &Path) -> bool { |
| 127 | std::fs::symlink_metadata(path).is_ok_and(|m: Metadata| m.file_type().is_symlink()) |
| 128 | } |
| 129 | |
| 130 | fn 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 | |
| 324 | pub 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)] |
| 349 | pub struct ReuseComplianceCheck { |
| 350 | #[arg(long, action)] |
| 351 | fix_symlinks: bool, |
| 352 | #[arg(long, action)] |
| 353 | download_missing_licenses: bool, |
| 354 | } |
| 355 | |
| 356 | impl 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 | |