use cargo_metadata::camino::Utf8Path;
use cargo_metadata::semver::{Version, VersionReq};
use serde::Deserialize;
use std::fs;
use std::path::{Path, PathBuf};
pub const DEFAULT_ACCEPTED: &[&str] =
&["MIT", "Apache-2.0", "BSD-2-Clause", "BSD-3-Clause", "ISC", "0BSD", "Zlib", "Unlicense", "Unicode-3.0"];
#[derive(Deserialize, Default)]
#[serde(deny_unknown_fields)]
struct Config {
accepted: Option<Vec<String>>,
#[serde(rename = "include-dev")]
include_dev: Option<bool>,
#[serde(rename = "include-build")]
include_build: Option<bool>,
#[serde(rename = "skip-private")]
skip_private: Option<bool>,
#[serde(rename = "skip-proc-macros")]
skip_proc_macros: Option<bool>,
manifest: Option<String>,
#[serde(rename = "licenses-dir")]
licenses_dir: Option<String>,
#[serde(rename = "notices-dir")]
notices_dir: Option<String>,
clarify: Option<Vec<Clarify>>,
exception: Option<Vec<Exception>>,
extra: Option<Vec<Extra>>,
#[serde(rename = "license-text")]
license_text: Option<Vec<LicenseText>>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Clarify {
pub name: String,
pub version: Option<String>,
pub expression: String,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Exception {
pub name: String,
pub version: Option<String>,
pub allow: Vec<String>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Extra {
pub name: String,
pub expression: String,
pub url: Option<String>,
pub copyright: Option<String>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub struct LicenseText {
pub id: String,
pub file: String,
}
#[derive(Clone)]
pub struct Accept {
pub raw: String,
pub license: String,
pub exception: Option<String>,
}
fn find_with(s: &str) -> Option<usize> {
let b = s.as_bytes();
(0..b.len().saturating_sub(5)).find(|&i| b[i..i + 6].eq_ignore_ascii_case(b" with "))
}
pub fn parse_accept(s: &str) -> Accept {
match find_with(s) {
Some(i) => Accept { raw: s.into(), license: s[..i].trim().into(), exception: Some(s[i + 6..].trim().into()) },
None => Accept { raw: s.into(), license: s.trim().into(), exception: None },
}
}
impl Accept {
pub fn allows(&self, license: &str, exception: Option<&str>) -> bool {
self.license == license && self.exception.as_deref().is_none_or(|e| exception == Some(e))
}
}
pub struct Settings {
pub accepted: Vec<Accept>,
pub accepted_explicit: bool, pub include_dev: bool,
pub include_build: bool,
pub skip_private: bool, pub skip_proc_macros: bool, pub clarify: Vec<Clarify>,
pub exception: Vec<Exception>,
pub extra: Vec<Extra>,
pub license_text: Vec<LicenseText>,
pub manifest: PathBuf, pub manifest_link: String, pub licenses_dir: PathBuf, pub licenses_link: String, pub notices_dir: PathBuf, pub notices_link: String, }
pub 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());
let notices_link = cfg.notices_dir.unwrap_or_else(|| "NOTICES".into());
relative_inside("manifest", &manifest_link)?;
relative_inside("licenses-dir", &licenses_link)?;
relative_inside("notices-dir", ¬ices_link)?;
for t in cfg.license_text.as_deref().unwrap_or_default() {
relative_inside("license-text file", &t.file)?;
}
let accepted_explicit = cfg.accepted.is_some();
let accepted = cfg
.accepted
.unwrap_or_else(|| DEFAULT_ACCEPTED.iter().map(|s| s.to_string()).collect())
.iter()
.map(|s| parse_accept(s))
.collect();
Ok(Settings {
accepted,
accepted_explicit,
include_dev: cfg.include_dev.unwrap_or(false),
include_build: cfg.include_build.unwrap_or(false),
skip_private: cfg.skip_private.unwrap_or(false),
skip_proc_macros: cfg.skip_proc_macros.unwrap_or(false),
clarify: cfg.clarify.unwrap_or_default(),
exception: cfg.exception.unwrap_or_default(),
extra: cfg.extra.unwrap_or_default(),
license_text: cfg.license_text.unwrap_or_default(),
manifest: root.join(&manifest_link).into(),
licenses_dir: root.join(&licenses_link).into(),
notices_dir: root.join(¬ices_link).into(),
manifest_link,
licenses_link,
notices_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(())
}
pub fn policy_matches(name: &str, version_req: Option<&str>, pkg: &str, version: &Version) -> bool {
name == pkg && version_req.is_none_or(|v| VersionReq::parse(v).is_ok_and(|req| req.matches(version)))
}
fn clarify_matches(c: &Clarify, name: &str, version: &Version) -> bool {
policy_matches(&c.name, c.version.as_deref(), name, version)
}
pub fn warn_unknown_ids(kind: &str, a: &Accept) {
if !a.license.starts_with("LicenseRef-") && spdx::license_id(&a.license).is_none() {
eprintln!("cargo-tribute: warning: {kind} license '{}' is not a known SPDX id", a.license);
}
if let Some(e) = &a.exception
&& spdx::exception_id(e).is_none()
{
eprintln!("cargo-tribute: warning: {kind} exception '{e}' is not a known SPDX id");
}
}
pub 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())
}
#[derive(Deserialize)]
struct DenyConfig {
licenses: Option<DenyLicenses>,
}
#[derive(Deserialize)]
struct DenyLicenses {
allow: Option<Vec<String>>,
exceptions: Option<Vec<DenyException>>,
}
#[derive(Deserialize)]
struct DenyException {
#[serde(rename = "crate", alias = "name")]
name: String,
allow: Vec<String>,
version: Option<String>,
}
pub fn apply_deny(set: &mut Settings, path: &Path) -> Result<(), String> {
if set.accepted_explicit {
return Err("tribute.toml sets `accepted` and --from-deny is given; keep one source".into());
}
let s = fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?;
let cfg: DenyConfig = toml::from_str(&s).map_err(|e| format!("{}: {e}", path.display()))?;
let lic = cfg.licenses.ok_or_else(|| format!("{}: no [licenses] section", path.display()))?;
let allow = lic.allow.ok_or_else(|| format!("{}: no [licenses] allow list", path.display()))?;
set.accepted = allow.iter().map(|s| parse_accept(s)).collect();
set.accepted_explicit = true;
for e in lic.exceptions.unwrap_or_default() {
set.exception.push(Exception { name: e.name, version: e.version, allow: e.allow });
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[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 parse_accept_splits_with_either_case() {
let a = parse_accept("GPL-2.0-only WITH Classpath-exception-2.0");
assert_eq!((a.license.as_str(), a.exception.as_deref()), ("GPL-2.0-only", Some("Classpath-exception-2.0")));
let b = parse_accept("GPL-2.0-only with Classpath-exception-2.0");
assert_eq!((b.license.as_str(), b.exception.as_deref()), ("GPL-2.0-only", Some("Classpath-exception-2.0")));
let c = parse_accept("MIT");
assert_eq!((c.license.as_str(), c.exception.as_deref()), ("MIT", 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();
}
}