#[cfg(feature = "audit")]
mod audit;
mod config;
mod harvest;
mod output;
mod policy;
use cargo_metadata::{DependencyKind, MetadataCommand, Package, PackageId, TargetKind};
use config::{Accept, Extra, apply_deny, clarify_expr, load_settings, parse_accept, policy_matches, warn_unknown_ids};
use harvest::{Extras, harvest_extras};
use output::{
Resolution, io, is_stale_license, is_stale_notice, render_cyclonedx, render_json, render_manifest, render_text,
stale_outputs,
};
use policy::{canonical_text, choose, exceptions_for, license_name};
use spdx::expression::ExprNode;
use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::path::{Path, PathBuf};
use std::process::ExitCode;
const HELP: &str = "\
cargo-tribute -- REUSE-style third-party license attribution from a Cargo tree
USAGE:
cargo tribute [OPTIONS]
cargo tribute init scaffold a commented tribute.toml at the workspace root
OPTIONS:
--check verify the output is current; do not write (exit 2 if stale)
--audit compare declared licenses against the license files the
crates actually ship (advisory report; never fails)
-p, --package <NAME> attribute only this workspace member's dependencies
(repeatable; default: all members)
--from-deny <PATH> take the accepted list and per-crate exceptions from a
cargo-deny deny.toml [licenses] section
--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>)
--json shorthand for --format json
--format <F> print the resolved attribution as F instead of writing
files: json, text (flat list + full texts, for an
\"open source licenses\" screen), or cyclonedx
(CycloneDX 1.6 SBOM carrying the license texts)
-q, --quiet suppress the success summary
-h, --help print this help
-V, --version print version
EXIT CODES:
1 license policy failed, 2 output out of date (--check), 3 anything else
CONFIG (tribute.toml in the project root, all optional):
accepted = [\"MIT\", \"Apache-2.0\", ...] # allowed licenses, or \"A WITH B\" pairings;
# also the OR preference order
include-dev = false # also attribute dev-dependencies
include-build = false # also attribute build-dependencies
skip-private = false # skip path/git/non-crates.io dependencies
skip-proc-macros = false # skip proc-macro crates (compile-time only)
manifest = \"THIRD-PARTY.md\" # attribution manifest path
licenses-dir = \"LICENSES\" # folder for the canonical license texts
notices-dir = \"NOTICES\" # folder for NOTICE files shipped by dependencies
[[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\"
[[exception]] # allow extra licenses for one crate only
name = \"unicode-ident\"
allow = [\"Unicode-DFS-2016\"]
[[extra]] # attribute non-crate code (vendored C, ...)
name = \"zlib (bundled in libz-sys)\"
expression = \"Zlib\"
url = \"https://zlib.net\" # optional, like copyright = \"...\"
[[license-text]] # local text for a LicenseRef-* id
id = \"LicenseRef-weird\"
file = \"licenses-extra/weird.txt\"
";
enum Format {
Json,
Text,
CycloneDx,
}
enum Output {
Report(String),
Summary(String),
}
enum Failure {
Policy(String), Stale(String), Other(String), }
impl From<String> for Failure {
fn from(s: String) -> Self {
Failure::Other(s)
}
}
impl From<&str> for Failure {
fn from(s: &str) -> Self {
Failure::Other(s.into())
}
}
struct Cli {
check: bool,
quiet: bool,
init: bool,
audit: bool,
format: Option<Format>,
packages: Vec<String>,
from_deny: Option<String>,
manifest_path: Option<String>,
cargo_flags: Vec<String>,
}
fn main() -> ExitCode {
let mut cli = Cli {
check: false,
quiet: false,
init: false,
audit: false,
format: None,
packages: Vec::new(),
from_deny: None,
manifest_path: None,
cargo_flags: 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() {
"init" => cli.init = true,
"--check" => cli.check = true,
"--audit" => cli.audit = true,
"-q" | "--quiet" => cli.quiet = true,
"--json" => cli.format = Some(Format::Json),
"--format" => match args.next().as_deref() {
Some("json") => cli.format = Some(Format::Json),
Some("text") => cli.format = Some(Format::Text),
Some("cyclonedx") => cli.format = Some(Format::CycloneDx),
Some(v) => {
eprintln!("cargo-tribute: unknown format '{v}' (expected json, text, or cyclonedx)");
return ExitCode::FAILURE;
}
None => {
eprintln!("cargo-tribute: --format needs a value");
return ExitCode::FAILURE;
}
},
"-p" | "--package" => match args.next() {
Some(p) => cli.packages.push(p),
None => {
eprintln!("cargo-tribute: {a} needs a value");
return ExitCode::FAILURE;
}
},
"--from-deny" => match args.next() {
Some(p) => cli.from_deny = Some(p),
None => {
eprintln!("cargo-tribute: --from-deny needs a value");
return ExitCode::FAILURE;
}
},
"--manifest-path" => match args.next() {
Some(p) => cli.manifest_path = Some(p),
None => {
eprintln!("cargo-tribute: --manifest-path needs a value");
return ExitCode::FAILURE;
}
},
"--locked" | "--offline" | "--frozen" | "--all-features" | "--no-default-features" => {
cli.cargo_flags.push(a)
}
"--features" | "--filter-platform" => match args.next() {
Some(v) => cli.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=") => cli.cargo_flags.push(a),
other => {
eprintln!("cargo-tribute: unknown argument '{other}' (try --help)");
return ExitCode::FAILURE;
}
}
}
let quiet = cli.quiet;
match run(cli) {
Ok(Output::Report(s)) => {
println!("{s}");
ExitCode::SUCCESS
}
Ok(Output::Summary(s)) => {
if !quiet {
println!("{s}");
}
ExitCode::SUCCESS
}
Err(f) => {
let (msg, code) = match f {
Failure::Policy(m) => (m, 1),
Failure::Stale(m) => (m, 2),
Failure::Other(m) => (m, 3),
};
eprintln!("cargo-tribute: {msg}");
ExitCode::from(code)
}
}
}
fn run_init(manifest_path: Option<String>) -> Result<Output, Failure> {
let mut cmd = MetadataCommand::new();
if let Some(p) = manifest_path {
cmd.manifest_path(PathBuf::from(p));
}
let meta = cmd.exec().map_err(|e| e.to_string())?;
let path = meta.workspace_root.join("tribute.toml");
if path.as_std_path().exists() {
return Err(Failure::Other(format!("{path} already exists")));
}
io(path.as_std_path(), fs::write(&path, INIT_TEMPLATE))?;
Ok(Output::Summary(format!("wrote {path}; uncomment what you need (CI: cargo tribute --locked --check)")))
}
const INIT_TEMPLATE: &str = "\
# cargo-tribute configuration; every field is optional, these are the defaults.
# allowed licenses, also the OR preference order; \"A WITH B\" pairings work too.
# accepted = [\"MIT\", \"Apache-2.0\", \"BSD-2-Clause\", \"BSD-3-Clause\", \"ISC\", \"0BSD\", \"Zlib\", \"Unlicense\", \"Unicode-3.0\"]
# include-dev = false # also attribute dev-dependencies
# include-build = false # also attribute build-dependencies
# skip-private = false # skip path/git/non-crates.io dependencies
# skip-proc-macros = false # skip proc-macro crates (compile-time only)
# manifest = \"THIRD-PARTY.md\"
# licenses-dir = \"LICENSES\"
# notices-dir = \"NOTICES\"
# override a crate's license (missing/wrong/non-SPDX):
# [[clarify]]
# name = \"ring\"
# version = \"0.17.8\"
# expression = \"MIT AND ISC AND OpenSSL\"
# allow extra licenses for one crate only:
# [[exception]]
# name = \"unicode-ident\"
# allow = [\"Unicode-DFS-2016\"]
# attribute non-crate code (vendored C, bundled assets):
# [[extra]]
# name = \"zlib (bundled in libz-sys)\"
# expression = \"Zlib\"
# url = \"https://zlib.net\"
# local text for a LicenseRef-* id:
# [[license-text]]
# id = \"LicenseRef-weird\"
# file = \"licenses-extra/weird.txt\"
";
fn run(cli: Cli) -> Result<Output, Failure> {
if cli.init {
return run_init(cli.manifest_path);
}
let mut cmd = MetadataCommand::new();
if let Some(p) = &cli.manifest_path {
cmd.manifest_path(PathBuf::from(p));
}
if !cli.cargo_flags.is_empty() {
cmd.other_options(cli.cargo_flags.clone());
}
let meta = cmd.exec().map_err(|e| e.to_string())?;
let mut set = load_settings(&meta.workspace_root)?;
if let Some(p) = &cli.from_deny {
apply_deny(&mut set, Path::new(p))?;
}
let set = set;
for a in &set.accepted {
warn_unknown_ids("accepted", a);
}
for x in &set.exception {
for a in x.allow.iter().map(|s| parse_accept(s)) {
warn_unknown_ids("exception-allowed", &a);
}
}
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 stack: Vec<&PackageId> = if cli.packages.is_empty() {
meta.workspace_members.iter().collect()
} else {
let mut roots = Vec::new();
for want in &cli.packages {
let matched: Vec<&PackageId> = meta
.workspace_members
.iter()
.filter(|id| pkg_of.get(id).is_some_and(|p| p.name.as_ref() == want.as_str()))
.collect();
if matched.is_empty() {
return Err(Failure::Other(format!("-p '{want}' matches no workspace member")));
}
roots.extend(matched);
}
roots
};
let mut seen = BTreeSet::new();
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 {
let wanted = dep.dep_kinds.iter().any(|k| match k.kind {
DependencyKind::Normal => true,
DependencyKind::Development => set.include_dev,
DependencyKind::Build => set.include_build,
_ => false,
});
if !wanted {
continue;
}
let pkg = pkg_of.get(&dep.pkg);
if set.skip_proc_macros
&& pkg.is_some_and(|p| p.targets.iter().any(|t| t.kind.contains(&TargetKind::ProcMacro)))
{
continue;
}
let private = set.skip_private && pkg.is_some_and(|p| !p.source.as_ref().is_some_and(|s| s.is_crates_io()));
if !workspace.contains(&dep.pkg) && !private {
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 chosen_of: BTreeMap<&PackageId, BTreeSet<String>> = BTreeMap::new();
let mut used_exceptions: BTreeSet<String> = BTreeSet::new();
let mut encountered: BTreeSet<(String, Option<String>)> = BTreeSet::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;
}
};
for node in expr.iter() {
if let ExprNode::Req(r) = node {
let ex = r.req.addition.as_ref().and_then(|a| a.id()).map(|e| e.name.to_string());
encountered.insert((license_name(&r.req), ex));
}
}
let extra: Vec<Accept> = set
.exception
.iter()
.filter(|x| policy_matches(&x.name, x.version.as_deref(), pkg.name.as_ref(), &pkg.version))
.flat_map(|x| x.allow.iter().map(|s| parse_accept(s)))
.collect();
let chosen = if extra.is_empty() {
choose(&expr, &set.accepted)
} else {
let mut acc = set.accepted.clone();
acc.extend(extra);
choose(&expr, &acc)
};
match chosen {
Some(chosen) => {
for ex in exceptions_for(&expr, &chosen) {
used_exceptions.insert(ex);
}
for lic in &chosen {
by_license.entry(lic.clone()).or_default().push(pkg);
}
chosen_of.insert(*id, chosen);
}
None => failures.push(format!("{name}: license '{expr_str}' not in the accepted set")),
}
}
let mut extra_by_license: BTreeMap<String, Vec<&Extra>> = BTreeMap::new();
let mut extra_chosen: Vec<(&Extra, BTreeSet<String>)> = Vec::new();
for x in &set.extra {
let expr = match spdx::Expression::parse_mode(&x.expression, spdx::ParseMode::LAX) {
Ok(e) => e,
Err(e) => {
failures.push(format!("[[extra]] {}: unparsable SPDX '{}' ({e})", x.name, x.expression));
continue;
}
};
for node in expr.iter() {
if let ExprNode::Req(r) = node {
let ex = r.req.addition.as_ref().and_then(|a| a.id()).map(|e| e.name.to_string());
encountered.insert((license_name(&r.req), ex));
}
}
match choose(&expr, &set.accepted) {
Some(chosen) => {
for ex in exceptions_for(&expr, &chosen) {
used_exceptions.insert(ex);
}
for lic in &chosen {
extra_by_license.entry(lic.clone()).or_default().push(x);
}
extra_chosen.push((x, chosen));
}
None => failures.push(format!("[[extra]] {}: license '{}' not in the accepted set", x.name, x.expression)),
}
}
let no_match = |name: &str, version: Option<&str>| {
!deps
.iter()
.any(|id| pkg_of.get(id).is_some_and(|p| policy_matches(name, version, p.name.as_ref(), &p.version)))
};
for c in &set.clarify {
if no_match(&c.name, c.version.as_deref()) {
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);
}
}
for x in &set.exception {
if no_match(&x.name, x.version.as_deref()) {
let ver = x.version.as_deref().map(|v| format!(" {v}")).unwrap_or_default();
eprintln!("cargo-tribute: warning: exception for '{}{ver}' matched no dependency", x.name);
}
}
if set.accepted_explicit {
for a in &set.accepted {
if !encountered.iter().any(|(l, e)| a.allows(l, e.as_deref())) {
eprintln!("cargo-tribute: warning: accepted license '{}' matched no dependency", a.raw);
}
}
}
if cli.audit {
#[cfg(feature = "audit")]
return Ok(Output::Report(audit::run_audit(&deps, &pkg_of, &effective)));
#[cfg(not(feature = "audit"))]
return Err(Failure::Other(
"this build has no --audit; install with `cargo install cargo-tribute --features audit`".into(),
));
}
if !failures.is_empty() {
return Err(Failure::Policy(format!("license policy failed:\n {}", failures.join("\n "))));
}
let mut extras: BTreeMap<&PackageId, Extras> = BTreeMap::new();
let mut notices: BTreeMap<String, String> = BTreeMap::new();
for id in &deps {
let Some(&pkg) = pkg_of.get(id) else { continue };
let x = harvest_extras(pkg);
if let Some(n) = &x.notice {
notices.insert(format!("{}-{}", pkg.name, pkg.version), n.clone());
}
extras.insert(*id, x);
}
let mut custom: BTreeMap<&str, String> = BTreeMap::new();
for t in &set.license_text {
let p = meta.workspace_root.join(&t.file);
custom.insert(&t.id, io(p.as_std_path(), fs::read_to_string(&p))?.replace("\r\n", "\n"));
}
let mut texts: BTreeMap<&str, String> = BTreeMap::new();
for id in by_license
.keys()
.chain(extra_by_license.keys())
.map(String::as_str)
.chain(used_exceptions.iter().map(String::as_str))
{
let text = canonical_text(id)
.map(str::to_string)
.or_else(|| custom.get(id).cloned())
.ok_or_else(|| format!("no text for '{id}' (add a [[license-text]] entry to tribute.toml)"))?;
texts.insert(id, text);
}
for t in &set.license_text {
if !texts.contains_key(t.id.as_str()) {
eprintln!("cargo-tribute: warning: license-text for '{}' matched no dependency", t.id);
}
}
let res = Resolution {
deps: &deps,
pkg_of: &pkg_of,
effective: &effective,
chosen_of: &chosen_of,
by_license: &by_license,
extra_by_license: &extra_by_license,
extra_chosen: &extra_chosen,
used_exceptions: &used_exceptions,
extras: &extras,
};
if let Some(f) = cli.format {
return match f {
Format::Json => Ok(Output::Report(render_json(&res)?)),
Format::Text => Ok(Output::Report(render_text(&res, &texts))),
Format::CycloneDx => Ok(Output::Report(render_cyclonedx(&res, &texts)?)),
};
}
let manifest = render_manifest(&res, &set.licenses_link, &set.notices_link);
if cli.check {
let stale = stale_outputs(&set.licenses_dir, &texts, &set.notices_dir, ¬ices, &set.manifest, &manifest);
if !stale.is_empty() {
return Err(Failure::Stale(format!("out of date (run `cargo tribute`):\n {}", stale.join("\n "))));
}
let n = if notices.is_empty() { String::new() } else { format!(", {} notices", notices.len()) };
let e = if extra_chosen.is_empty() { String::new() } else { format!(", {} extras", extra_chosen.len()) };
Ok(Output::Summary(format!("up to date: {} license texts{n}, {} crates{e}", 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 Ok(entries) = fs::read_dir(&set.notices_dir) {
for e in entries.flatten() {
let p = e.path();
if is_stale_notice(&p, ¬ices) {
let _ = fs::remove_file(p);
}
}
}
if notices.is_empty() {
let _ = fs::remove_dir(&set.notices_dir); } else {
io(&set.notices_dir, fs::create_dir_all(&set.notices_dir))?;
for (stem, text) in ¬ices {
let p = set.notices_dir.join(format!("{stem}.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))?;
let n = if notices.is_empty() {
String::new()
} else {
format!(", {}/ ({} notices)", set.notices_link, notices.len())
};
let e = if extra_chosen.is_empty() { String::new() } else { format!(", {} extras", extra_chosen.len()) };
Ok(Output::Summary(format!(
"wrote {}/ ({} license texts){n} and {} ({} crates{e})",
set.licenses_link,
texts.len(),
set.manifest_link,
deps.len()
)))
}
}