use git2::{Repository, Tree};
use std::fmt::Write as _;
pub struct LicenseFile {
pub filename: String,
pub label: &'static str,
pub text: String,
}
const LICENSE_FILENAME_PREFIXES: &[&str] =
&["license", "licence", "copying", "unlicense", "notice"];
const LICENSE_DIRECTORY_NAMES: &[&str] = &["license", "licenses", "licence", "licences"];
pub fn find_license_files(repo: &Repository, tree: &Tree) -> Vec<LicenseFile> {
let mut found = Vec::new();
collect_license_files_in(repo, tree, "", &mut found);
for entry in tree.iter() {
if entry.kind() != Some(git2::ObjectType::Tree) {
continue;
}
let Ok(name) = entry.name() else { continue };
if !LICENSE_DIRECTORY_NAMES.contains(&name.to_lowercase().as_str()) {
continue;
}
let Ok(object) = entry.to_object(repo) else {
continue;
};
let Some(subtree) = object.as_tree() else {
continue;
};
collect_license_files_in(repo, subtree, name, &mut found);
}
found.sort_by(|a, b| a.filename.cmp(&b.filename));
found
}
fn collect_license_files_in(
repo: &Repository,
tree: &Tree,
dir_prefix: &str,
found: &mut Vec<LicenseFile>,
) {
for entry in tree.iter() {
let Ok(name) = entry.name() else { continue };
let lower = name.to_lowercase();
if !LICENSE_FILENAME_PREFIXES
.iter()
.any(|prefix| lower.starts_with(prefix))
{
continue;
}
let Ok(object) = entry.to_object(repo) else {
continue;
};
let Some(blob) = object.as_blob() else {
continue;
};
let text = String::from_utf8_lossy(blob.content()).into_owned();
let label = classify_license(&text);
let filename = if dir_prefix.is_empty() {
name.to_string()
} else {
format!("{dir_prefix}/{name}")
};
found.push(LicenseFile {
filename,
label,
text,
});
}
}
fn classify_license(text: &str) -> &'static str {
let has = |needle: &str| text.contains(needle);
if has("Apache License") && has("Version 2.0") {
"Apache License 2.0"
} else if has("GNU AFFERO GENERAL PUBLIC LICENSE") && has("Version 3") {
"GNU Affero General Public License v3.0"
} else if has("GNU LESSER GENERAL PUBLIC LICENSE") && has("Version 3") {
"GNU Lesser General Public License v3.0"
} else if has("GNU LESSER GENERAL PUBLIC LICENSE") && has("Version 2.1") {
"GNU Lesser General Public License v2.1"
} else if has("GNU LIBRARY GENERAL PUBLIC LICENSE") {
"GNU Library General Public License v2.0 (LGPL predecessor)"
} else if has("GNU GENERAL PUBLIC LICENSE") && has("Version 3") {
"GNU General Public License v3.0"
} else if has("GNU GENERAL PUBLIC LICENSE") && has("Version 2") {
"GNU General Public License v2.0"
} else if has("Mozilla Public License Version 2.0") {
"Mozilla Public License 2.0"
} else if has("Redistributions of source code must retain")
&& has("may be used to endorse or promote")
{
"BSD 3-Clause License"
} else if has("Redistributions of source code must retain") {
"BSD 2-Clause License"
} else if has("Permission is hereby granted, free of charge") {
"MIT License"
} else if has("Permission to use, copy, modify, and/or distribute this software")
&& has("THE SOFTWARE IS PROVIDED \"AS IS\"")
{
"ISC License"
} else if has("This is free and unencumbered software released into the public domain") {
"The Unlicense"
} else if has("Boost Software License") {
"Boost Software License 1.0"
} else if has("CC0 1.0 Universal") || has("Creative Commons Zero") {
"CC0 1.0 Universal"
} else if has("1. The origin of this software must not be misrepresented") {
"zlib License"
} else {
"Unrecognized license text (see linked file)"
}
}
pub fn origin_remote_url(repo: &Repository) -> Option<String> {
repo.find_remote("origin")
.ok()
.and_then(|remote| remote.url().ok().map(str::to_string))
}
fn blob_url(repo_url: &str, commit: &str, path: &str) -> Option<String> {
let without_scheme = repo_url.trim_end_matches('/').split_once("://")?.1;
let (host, rest) = without_scheme.split_once('/')?;
let rest = rest.trim_end_matches(".git");
match host {
"github.com" => Some(format!("https://github.com/{rest}/blob/{commit}/{path}")),
"gitlab.com" => Some(format!("https://gitlab.com/{rest}/-/blob/{commit}/{path}")),
"codeberg.org" => Some(format!(
"https://codeberg.org/{rest}/src/commit/{commit}/{path}"
)),
_ => None,
}
}
pub fn render_readme(
repo_url: Option<&str>,
repository: &str,
commit: &str,
path: &str,
dataset: &str,
license_files: &[LicenseFile],
unverifiable_reason: Option<&str>,
) -> String {
let mut out = String::new();
let _ = writeln!(out, "# Sample provenance");
let _ = writeln!(out);
match repo_url {
Some(url) => {
let _ = writeln!(out, "- **Repository:** {url} (`{repository}`)");
}
None => {
let _ = writeln!(
out,
"- **Repository:** `{repository}` (origin remote URL unavailable)"
);
}
}
let _ = writeln!(out, "- **Commit:** `{commit}`");
let _ = writeln!(out, "- **File:** `{path}`");
let _ = writeln!(out, "- **Research dataset:** {dataset}");
let _ = writeln!(out);
let _ = writeln!(
out,
"`before.*.test`/`after.*.test` in this directory are an unmodified excerpt of the file \
above, copied verbatim from the source repository at the commit above (and its single \
parent) for use as omnidiff test-fixture input. This content is **not** part of \
omnidiff's own codebase and is **not** covered by omnidiff's own AGPL-3.0 license - it \
remains under whatever license the source repository itself applies, linked below \
exactly as it read in that repository at this commit."
);
let _ = writeln!(out);
let _ = writeln!(out, "## License");
let _ = writeln!(out);
if let Some(reason) = unverifiable_reason {
let _ = writeln!(
out,
"**Not verified.** The local checkout couldn't be inspected at this commit ({reason}). \
This is very likely a shallow-clone gap (the commit has aged out of the checkout's \
`--depth` window since this sample was originally promoted), not evidence the \
repository lacks a license. Check the repository above directly before reusing this \
sample outside omnidiff's own test suite."
);
return out;
}
if license_files.is_empty() {
let _ = writeln!(
out,
"No LICENSE/COPYING/NOTICE file was found at the repository root at this commit. \
Licensing terms are unknown from this checkout alone - check the repository above \
directly before reusing this sample outside omnidiff's own test suite."
);
return out;
}
for file in license_files {
match repo_url.and_then(|url| blob_url(url, commit, &file.filename)) {
Some(link) => {
let _ = writeln!(out, "- `{}` - {} ({link})", file.filename, file.label);
}
None => {
let _ = writeln!(out, "- `{}` - {}", file.filename, file.label);
}
}
}
let _ = writeln!(out);
let _ = writeln!(
out,
"License text is not reproduced here - follow the link(s) above (or the repository listed \
at the top of this file, at the commit above) for the full terms."
);
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn classifies_common_license_texts() {
assert_eq!(
classify_license("Apache License\nVersion 2.0, January 2004"),
"Apache License 2.0"
);
assert_eq!(
classify_license("Permission is hereby granted, free of charge, to any person..."),
"MIT License"
);
assert_eq!(
classify_license("GNU GENERAL PUBLIC LICENSE\nVersion 3, 29 June 2007"),
"GNU General Public License v3.0"
);
assert_eq!(
classify_license("GNU LESSER GENERAL PUBLIC LICENSE\nVersion 2.1, February 1999"),
"GNU Lesser General Public License v2.1"
);
assert_eq!(
classify_license("something nobody has ever written before"),
"Unrecognized license text (see linked file)"
);
}
#[test]
fn blob_url_builds_a_commit_pinned_link_per_host() {
assert_eq!(
blob_url("https://github.com/example/repo.git", "abc123", "LICENSE"),
Some("https://github.com/example/repo/blob/abc123/LICENSE".to_string())
);
assert_eq!(
blob_url("https://gitlab.com/example/repo.git", "abc123", "LICENSE"),
Some("https://gitlab.com/example/repo/-/blob/abc123/LICENSE".to_string())
);
assert_eq!(
blob_url("https://codeberg.org/example/repo.git", "abc123", "LICENSE"),
Some("https://codeberg.org/example/repo/src/commit/abc123/LICENSE".to_string())
);
assert_eq!(
blob_url("https://example.com/example/repo.git", "abc123", "LICENSE"),
None
);
}
#[test]
fn render_readme_without_license_files_warns_explicitly() {
let readme = render_readme(
Some("https://github.com/example/repo"),
"example-repo.git",
"abc123",
"src/main.rs",
"full",
&[],
None,
);
assert!(readme.contains("No LICENSE/COPYING/NOTICE file was found"));
assert!(readme.contains("https://github.com/example/repo"));
assert!(readme.contains("`src/main.rs`"));
}
#[test]
fn render_readme_links_to_the_license_file_instead_of_embedding_its_text() {
let files = vec![LicenseFile {
filename: "LICENSE".to_string(),
label: "MIT License",
text: "Permission is hereby granted, free of charge...".to_string(),
}];
let readme = render_readme(
Some("https://github.com/example/repo"),
"example-repo.git",
"abc123",
"src/main.rs",
"small",
&files,
None,
);
assert!(readme.contains(
"- `LICENSE` - MIT License (https://github.com/example/repo/blob/abc123/LICENSE)"
));
assert!(!readme.contains("Permission is hereby granted, free of charge..."));
}
#[test]
fn render_readme_lists_the_filename_without_a_link_for_an_unrecognized_host() {
let files = vec![LicenseFile {
filename: "LICENSE".to_string(),
label: "MIT License",
text: "Permission is hereby granted, free of charge...".to_string(),
}];
let readme = render_readme(
Some("https://example.com/example/repo"),
"example-repo.git",
"abc123",
"src/main.rs",
"small",
&files,
None,
);
assert!(readme.contains("- `LICENSE` - MIT License\n"));
}
#[test]
fn render_readme_with_an_unverifiable_reason_explains_why_instead_of_claiming_no_license() {
let readme = render_readme(
Some("https://github.com/example/repo"),
"example-repo.git",
"abc123",
"src/main.rs",
"small",
&[],
Some("object not found"),
);
assert!(readme.contains("**Not verified.**"));
assert!(readme.contains("object not found"));
assert!(!readme.contains("No LICENSE/COPYING/NOTICE file was found"));
}
}