use cargo_metadata::camino::Utf8Path;
use cargo_metadata::semver::{Version, VersionReq};
use cargo_metadata::{DependencyKind, MetadataCommand, Package, PackageId};
use serde::Deserialize;
use spdx::expression::{ExprNode, Operator};
use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::path::{Path, PathBuf};
use std::process::ExitCode;
const DEFAULT_ACCEPTED: &[&str] =
&["MIT", "Apache-2.0", "BSD-2-Clause", "BSD-3-Clause", "ISC", "0BSD", "Zlib", "Unlicense", "Unicode-3.0"];
fn canonical_text(id: &str) -> Option<&'static str> {
Some(match id {
"MIT" => include_str!("../assets/licenses/MIT.txt"),
"Apache-2.0" => include_str!("../assets/licenses/Apache-2.0.txt"),
"BSD-2-Clause" => include_str!("../assets/licenses/BSD-2-Clause.txt"),
"BSD-3-Clause" => include_str!("../assets/licenses/BSD-3-Clause.txt"),
"ISC" => include_str!("../assets/licenses/ISC.txt"),
"0BSD" => include_str!("../assets/licenses/0BSD.txt"),
"Zlib" => include_str!("../assets/licenses/Zlib.txt"),
"Unlicense" => include_str!("../assets/licenses/Unlicense.txt"),
"Unicode-3.0" => include_str!("../assets/licenses/Unicode-3.0.txt"),
_ => return None,
})
}
#[derive(Deserialize, Default)]
#[serde(deny_unknown_fields)]
struct Config {
accepted: Option<Vec<String>>,
manifest: Option<String>,
#[serde(rename = "licenses-dir")]
licenses_dir: Option<String>,
clarify: Option<Vec<Clarify>>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Clarify {
name: String,
version: Option<String>,
expression: String,
}
struct Settings {
accepted: Vec<String>,
clarify: Vec<Clarify>,
manifest: PathBuf, manifest_link: String, licenses_dir: PathBuf, licenses_link: String, }
fn load_settings(root: &Utf8Path) -> Result<Settings, String> {
let path = root.join("tribute.toml");
let cfg: Config = match fs::read_to_string(&path) {
Ok(s) => toml::from_str(&s).map_err(|e| format!("tribute.toml: {e}"))?,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Config::default(),
Err(e) => return Err(format!("{path}: {e}")),
};
let manifest_link = cfg.manifest.unwrap_or_else(|| "THIRD-PARTY.md".into());
let licenses_link = cfg.licenses_dir.unwrap_or_else(|| "LICENSES".into());
relative_inside("manifest", &manifest_link)?;
relative_inside("licenses-dir", &licenses_link)?;
Ok(Settings {
accepted: cfg.accepted.unwrap_or_else(|| DEFAULT_ACCEPTED.iter().map(|s| s.to_string()).collect()),
clarify: cfg.clarify.unwrap_or_default(),
manifest: root.join(&manifest_link).into(),
licenses_dir: root.join(&licenses_link).into(),
manifest_link,
licenses_link,
})
}
fn relative_inside(field: &str, link: &str) -> Result<(), String> {
use std::path::Component;
let p = Path::new(link);
let escapes = p.is_absolute() || p.components().any(|c| c == Component::ParentDir);
let has_target = p.components().any(|c| matches!(c, Component::Normal(_)));
if escapes || !has_target {
return Err(format!("tribute.toml: {field} must be a relative path inside the project (got '{link}')"));
}
Ok(())
}
fn io<T>(path: &Path, r: std::io::Result<T>) -> Result<T, String> {
r.map_err(|e| format!("{}: {e}", path.display()))
}
fn is_stale_license(path: &Path, texts: &BTreeMap<&str, &'static str>) -> bool {
path.extension().is_some_and(|x| x == "txt")
&& path
.file_stem()
.and_then(|s| s.to_str())
.is_some_and(|s| canonical_text(s).is_some() && !texts.contains_key(s))
}
const HELP: &str = "\
cargo-tribute -- REUSE-style third-party license attribution from a Cargo tree
USAGE:
cargo tribute [OPTIONS]
OPTIONS:
--check verify the output is current; do not write (exit 1 if stale)
--manifest-path <P> path to Cargo.toml (default: auto-detect from the cwd)
--locked forwarded to `cargo metadata` (also --offline, --frozen)
--features <F> forwarded to `cargo metadata`, to attribute feature-gated
deps (also --all-features, --no-default-features,
--filter-platform <T>)
-h, --help print this help
-V, --version print version
CONFIG (tribute.toml in the project root, all optional):
accepted = [\"MIT\", \"Apache-2.0\", ...] # allowed licenses; also the OR preference order
manifest = \"THIRD-PARTY.md\" # attribution manifest path
licenses-dir = \"LICENSES\" # folder for the canonical license texts
[[clarify]] # override a crate's license (missing/wrong/non-SPDX)
name = \"ring\"
version = \"0.17.8\" # optional semver req; omit to match any version
expression = \"MIT AND ISC AND OpenSSL\"
";
fn main() -> ExitCode {
let mut check = false;
let mut manifest_path = None;
let mut cargo_flags: Vec<String> = Vec::new();
let mut args = std::env::args().skip(1).peekable();
if args.peek().map(String::as_str) == Some("tribute") {
args.next(); }
while let Some(a) = args.next() {
match a.as_str() {
"--check" => check = true,
"--manifest-path" => match args.next() {
Some(p) => manifest_path = Some(p),
None => {
eprintln!("cargo-tribute: --manifest-path needs a value");
return ExitCode::FAILURE;
}
},
"--locked" | "--offline" | "--frozen" | "--all-features" | "--no-default-features" => cargo_flags.push(a),
"--features" | "--filter-platform" => match args.next() {
Some(v) => cargo_flags.extend([a, v]),
None => {
eprintln!("cargo-tribute: {a} needs a value");
return ExitCode::FAILURE;
}
},
"-h" | "--help" => {
print!("{HELP}");
return ExitCode::SUCCESS;
}
"-V" | "--version" => {
println!("cargo-tribute {}", env!("CARGO_PKG_VERSION"));
return ExitCode::SUCCESS;
}
_ if a.starts_with("--features=") || a.starts_with("--filter-platform=") => cargo_flags.push(a),
other => {
eprintln!("cargo-tribute: unknown argument '{other}' (try --help)");
return ExitCode::FAILURE;
}
}
}
match run(check, manifest_path, cargo_flags) {
Ok(msg) => {
println!("{msg}");
ExitCode::SUCCESS
}
Err(e) => {
eprintln!("cargo-tribute: {e}");
ExitCode::FAILURE
}
}
}
fn run(check: bool, manifest_path: Option<String>, cargo_flags: Vec<String>) -> Result<String, String> {
let mut cmd = MetadataCommand::new();
if let Some(p) = manifest_path {
cmd.manifest_path(PathBuf::from(p));
}
if !cargo_flags.is_empty() {
cmd.other_options(cargo_flags);
}
let meta = cmd.exec().map_err(|e| e.to_string())?;
let set = load_settings(&meta.workspace_root)?;
let resolve = meta.resolve.as_ref().ok_or("no dependency resolution (need a Cargo.toml)")?;
let node_of: BTreeMap<&PackageId, _> = resolve.nodes.iter().map(|n| (&n.id, n)).collect();
let pkg_of: BTreeMap<&PackageId, &Package> = meta.packages.iter().map(|p| (&p.id, p)).collect();
let workspace: BTreeSet<&PackageId> = meta.workspace_members.iter().collect();
let mut seen = BTreeSet::new();
let mut stack: Vec<&PackageId> = meta.workspace_members.iter().collect();
let mut deps = BTreeSet::new();
while let Some(id) = stack.pop() {
if !seen.insert(id) {
continue;
}
let Some(node) = node_of.get(id) else { continue };
for dep in &node.deps {
if !dep.dep_kinds.iter().any(|k| k.kind == DependencyKind::Normal) {
continue;
}
if !workspace.contains(&dep.pkg) {
deps.insert(&dep.pkg);
}
stack.push(&dep.pkg);
}
}
let mut by_license: BTreeMap<String, Vec<&Package>> = BTreeMap::new();
let mut effective: BTreeMap<&PackageId, &str> = BTreeMap::new();
let mut failures = Vec::new();
for id in &deps {
let Some(&pkg) = pkg_of.get(id) else {
failures.push(format!("{id:?}: no package metadata (internal)"));
continue;
};
let name = format!("{} {}", pkg.name, pkg.version);
let clarified = clarify_expr(&set.clarify, pkg.name.as_ref(), &pkg.version);
let Some(expr_str) = clarified.or(pkg.license.as_deref()) else {
failures.push(format!("{name}: no license field (add a [[clarify]] entry to tribute.toml)"));
continue;
};
effective.insert(*id, expr_str);
let expr = match spdx::Expression::parse_mode(expr_str, spdx::ParseMode::LAX) {
Ok(e) => e,
Err(e) => {
failures.push(format!("{name}: unparsable SPDX '{expr_str}' ({e})"));
continue;
}
};
match choose(&expr, &set.accepted) {
Some(chosen) => {
for lic in chosen {
by_license.entry(lic).or_default().push(pkg);
}
}
None => failures.push(format!("{name}: license '{expr_str}' not in the accepted set")),
}
}
for c in &set.clarify {
let matched =
deps.iter().any(|id| pkg_of.get(id).is_some_and(|p| clarify_matches(c, p.name.as_ref(), &p.version)));
if !matched {
let ver = c.version.as_deref().map(|v| format!(" {v}")).unwrap_or_default();
eprintln!("cargo-tribute: warning: clarify for '{}{ver}' matched no dependency", c.name);
}
}
if !failures.is_empty() {
return Err(format!("license policy failed:\n {}", failures.join("\n ")));
}
let mut texts: BTreeMap<&str, &'static str> = BTreeMap::new();
for id in by_license.keys() {
let text = canonical_text(id)
.ok_or_else(|| format!("no canonical text bundled for '{id}' (add assets/licenses/{id}.txt)"))?;
texts.insert(id, text);
}
let manifest = render_manifest(&by_license, &effective, &set.licenses_link);
if check {
let stale = stale_outputs(&set.licenses_dir, &texts, &set.manifest, &manifest);
if !stale.is_empty() {
return Err(format!("out of date (run `cargo tribute`):\n {}", stale.join("\n ")));
}
Ok(format!("up to date: {} licenses, {} crates", texts.len(), deps.len()))
} else {
io(&set.licenses_dir, fs::create_dir_all(&set.licenses_dir))?;
if let Ok(entries) = fs::read_dir(&set.licenses_dir) {
for e in entries.flatten() {
let p = e.path();
if is_stale_license(&p, &texts) {
let _ = fs::remove_file(p);
}
}
}
for (id, text) in &texts {
let p = set.licenses_dir.join(format!("{id}.txt"));
io(&p, fs::write(&p, text))?;
}
if let Some(parent) = set.manifest.parent() {
io(parent, fs::create_dir_all(parent))?;
}
io(&set.manifest, fs::write(&set.manifest, &manifest))?;
Ok(format!(
"wrote {}/ ({} licenses) and {} ({} crates)",
set.licenses_link,
texts.len(),
set.manifest_link,
deps.len()
))
}
}
fn clarify_matches(c: &Clarify, name: &str, version: &Version) -> bool {
c.name == name && c.version.as_deref().is_none_or(|v| VersionReq::parse(v).is_ok_and(|req| req.matches(version)))
}
fn clarify_expr<'a>(clarify: &'a [Clarify], name: &str, version: &Version) -> Option<&'a str> {
clarify.iter().find(|c| clarify_matches(c, name, version)).map(|c| c.expression.as_str())
}
fn choose(expr: &spdx::Expression, accepted: &[String]) -> Option<BTreeSet<String>> {
let mut stack: Vec<Option<BTreeSet<String>>> = Vec::new();
for node in expr.iter() {
match node {
ExprNode::Req(req) => {
let leaf =
req.req.license.id().map(|id| id.name).filter(|n| accepted.iter().any(|a| a == n)).map(|n| {
let mut s = BTreeSet::new();
s.insert(n.to_string());
s
});
stack.push(leaf);
}
ExprNode::Op(op) => {
let b = stack.pop()?;
let a = stack.pop()?;
stack.push(combine(*op, a, b, accepted));
}
}
}
stack.pop().flatten()
}
fn combine(
op: Operator,
a: Option<BTreeSet<String>>,
b: Option<BTreeSet<String>>,
accepted: &[String],
) -> Option<BTreeSet<String>> {
match op {
Operator::And => match (a, b) {
(Some(mut x), Some(y)) => {
x.extend(y);
Some(x)
}
_ => None,
},
Operator::Or => match (a, b) {
(Some(x), Some(y)) => Some(if best(&x, accepted) <= best(&y, accepted) { x } else { y }),
(Some(x), None) | (None, Some(x)) => Some(x),
(None, None) => None,
},
}
}
fn best(set: &BTreeSet<String>, accepted: &[String]) -> usize {
set.iter().map(|l| accepted.iter().position(|p| p == l).unwrap_or(usize::MAX)).min().unwrap_or(usize::MAX)
}
fn matches_output(disk: Option<String>, want: &str) -> bool {
disk.is_some_and(|d| d.replace("\r\n", "\n") == want)
}
fn stale_outputs(
licenses_dir: &Path,
texts: &BTreeMap<&str, &'static str>,
manifest_path: &Path,
manifest: &str,
) -> Vec<String> {
let mut stale = Vec::new();
for (id, want) in texts {
let path = licenses_dir.join(format!("{id}.txt"));
if !matches_output(fs::read_to_string(&path).ok(), want) {
stale.push(path.display().to_string());
}
}
if let Ok(entries) = fs::read_dir(licenses_dir) {
for e in entries.flatten() {
let p = e.path();
if is_stale_license(&p, texts) {
stale.push(p.display().to_string());
}
}
}
if !matches_output(fs::read_to_string(manifest_path).ok(), manifest) {
stale.push(manifest_path.display().to_string());
}
stale
}
fn render_manifest(
by_license: &BTreeMap<String, Vec<&Package>>,
effective: &BTreeMap<&PackageId, &str>,
licenses_dir: &str,
) -> String {
let mut out = String::from(
"# Third-party licenses\n\nDependencies linked into this crate, grouped by license; full texts are in \
[`",
);
out.push_str(licenses_dir);
out.push_str("/`](");
out.push_str(licenses_dir);
out.push_str("). Generated by `cargo tribute`; do not edit.\n\n");
for (id, pkgs) in by_license {
let mut ps: Vec<&Package> = pkgs.clone();
ps.sort_by(|a, b| (&*a.name, &a.version).cmp(&(&*b.name, &b.version)));
ps.dedup_by(|a, b| a.id == b.id);
out.push_str(&format!("## {id}\n\nText: [`{licenses_dir}/{id}.txt`]({licenses_dir}/{id}.txt)\n\n"));
for p in ps {
let url = p.repository.clone().unwrap_or_else(|| format!("https://crates.io/crates/{}", p.name));
match effective.get(&p.id).copied().filter(|e| *e != id.as_str()) {
Some(expr) => out.push_str(&format!("- [{} {}]({url}) -- `{expr}`\n", p.name, p.version)),
None => out.push_str(&format!("- [{} {}]({url})\n", p.name, p.version)),
}
}
out.push('\n');
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn pick(s: &str) -> Option<Vec<String>> {
let accepted: Vec<String> = DEFAULT_ACCEPTED.iter().map(|s| s.to_string()).collect();
let e = spdx::Expression::parse_mode(s, spdx::ParseMode::LAX).unwrap();
choose(&e, &accepted).map(|set| set.into_iter().collect())
}
#[test]
fn or_picks_preferred() {
assert_eq!(pick("MIT OR Apache-2.0"), Some(vec!["MIT".into()]));
assert_eq!(pick("Apache-2.0 OR MIT"), Some(vec!["MIT".into()]));
assert_eq!(pick("Zlib OR Apache-2.0 OR MIT"), Some(vec!["MIT".into()]));
}
#[test]
fn and_unions_both() {
assert_eq!(pick("(MIT OR Apache-2.0) AND Unicode-3.0"), Some(vec!["MIT".into(), "Unicode-3.0".into()]));
}
#[test]
fn legacy_slash_is_or() {
assert_eq!(pick("MIT/Apache-2.0"), Some(vec!["MIT".into()]));
assert_eq!(pick("Unlicense/MIT"), Some(vec!["MIT".into()]));
}
#[test]
fn rejects_unaccepted() {
assert_eq!(pick("GPL-3.0-only"), None);
assert_eq!(pick("MIT AND GPL-3.0-only"), None);
}
#[test]
fn clarify_matches_name_and_version() {
let v = |s: &str| -> Version { s.parse().unwrap() };
let c = vec![
Clarify { name: "ring".into(), version: None, expression: "MIT AND ISC".into() },
Clarify { name: "foo".into(), version: Some("1.0".into()), expression: "BSD-3-Clause".into() },
];
assert_eq!(clarify_expr(&c, "ring", &v("0.17.8")), Some("MIT AND ISC")); assert_eq!(clarify_expr(&c, "foo", &v("1.0.0")), Some("BSD-3-Clause")); assert_eq!(clarify_expr(&c, "foo", &v("1.4.0")), Some("BSD-3-Clause")); assert_eq!(clarify_expr(&c, "foo", &v("2.0.0")), None); assert_eq!(clarify_expr(&c, "bar", &v("1.0.0")), None); }
#[test]
fn stale_detects_missing_and_orphan() {
let dir = std::env::temp_dir().join(format!("tribute-test-{}", std::process::id()));
let lic = dir.join("LICENSES");
fs::create_dir_all(&lic).unwrap();
let manifest_path = dir.join("THIRD-PARTY.md");
let mut texts: BTreeMap<&str, &'static str> = BTreeMap::new();
texts.insert("MIT", "MIT TEXT");
let stale = stale_outputs(&lic, &texts, &manifest_path, "MANIFEST");
assert!(stale.iter().any(|s| s.contains("MIT.txt")));
assert!(stale.iter().any(|s| s.contains("THIRD-PARTY.md")));
fs::write(lic.join("MIT.txt"), "MIT TEXT").unwrap();
fs::write(&manifest_path, "MANIFEST").unwrap();
assert!(stale_outputs(&lic, &texts, &manifest_path, "MANIFEST").is_empty());
fs::write(lic.join("Apache-2.0.txt"), "x").unwrap();
let stale = stale_outputs(&lic, &texts, &manifest_path, "MANIFEST");
assert!(stale.iter().any(|s| s.contains("Apache-2.0.txt")));
fs::write(lic.join("OpenSSL.txt"), "x").unwrap();
let stale = stale_outputs(&lic, &texts, &manifest_path, "MANIFEST");
assert!(!stale.iter().any(|s| s.contains("OpenSSL.txt")));
fs::remove_dir_all(&dir).ok();
}
#[test]
fn with_exception_attributes_the_base_license() {
assert_eq!(pick("Apache-2.0 WITH LLVM-exception"), Some(vec!["Apache-2.0".into()]));
assert_eq!(pick("GPL-3.0-only WITH Classpath-exception-2.0"), None); }
#[test]
fn relative_inside_rejects_escapes_and_rootlike() {
assert!(relative_inside("manifest", "THIRD-PARTY.md").is_ok());
assert!(relative_inside("licenses-dir", "docs/LICENSES").is_ok());
assert!(relative_inside("manifest", "").is_err()); assert!(relative_inside("licenses-dir", ".").is_err()); assert!(relative_inside("manifest", "../escape.md").is_err());
assert!(relative_inside("manifest", "/etc/passwd").is_err());
}
#[test]
fn unreadable_config_errors_instead_of_defaulting() {
use cargo_metadata::camino::Utf8PathBuf;
let dir = std::env::temp_dir().join(format!("tribute-cfg-{}", std::process::id()));
fs::create_dir_all(&dir).unwrap();
let root = Utf8PathBuf::from_path_buf(dir.clone()).unwrap();
fs::write(dir.join("tribute.toml"), [0xff, 0xfe, 0x41, 0x00]).unwrap();
assert!(load_settings(&root).is_err());
fs::remove_file(dir.join("tribute.toml")).unwrap();
assert!(load_settings(&root).is_ok());
fs::remove_dir_all(&dir).ok();
}
#[test]
fn matches_output_ignores_crlf() {
assert!(matches_output(Some("a\r\nb\r\n".into()), "a\nb\n"));
assert!(matches_output(Some("a\nb\n".into()), "a\nb\n"));
assert!(!matches_output(Some("a\nb\n".into()), "a\nDIFFERENT\n"));
assert!(!matches_output(None, "x")); }
}