Writes the licence notices of every crates.io crate linked into each
shipped artifact, from the crate archives buck fetched: THIRD-PARTY
for the extension (postjevsql.so) and THIRD-PARTY-sidecar for the
sidecar CLI (postjevsql-sidecar). Each package installs only its own
file, since each binary links its own set.
The crate lists are not kept by hand: :notices passes, per artifact,
the extracted archives of its target deps (buck's query_outputs), so
they follow third-party/BUCK. For each crate this reads
package.name, version, license and license-file from its
Cargo.toml, every top-level LICENSE / LICENCE / COPYING / NOTICE
file, and the files in a REUSE LICENSES/ directory.
A crate with no licence expression or no licence text fails the build:
a binary must carry its notices, so a missing one is not skipped. A
crate whose archive ships no text gets it from texts/<name>-<version>/,
fetched from its upstream at that version and named in the directory's
SOURCE. A text there for a crate linked into no artifact, or that
ships its own, fails the build too, so the directory cannot go stale.
One texts/ serves every artifact, which is why they are generated in
one run.
Identical texts are written once per file, naming every crate that carries them.
Usage: third-party-notices generate <texts-dir> (-- <out-file> <package> <binary> <crate-dir>...)... third-party-notices write (<generated-file> <name>)... (copies each to <name> in the checkout holding the cwd)
35type Res<T> = Result<T, Box<dyn Error>>; 36 37const USAGE: &str = "usage: third-party-notices generate <texts-dir> (-- <out-file> <package> <binary> <crate-dir>...)... \ 38 | third-party-notices write (<generated-file> <name>)..."; 39 40struct Crate { 41 name: String, 42 version: String, 43 license: String,
The package that installs the file, e.g. postjevsql-sidecar.
52 package: String,
What the crates are linked into, e.g. postjevsql.so.
58fn main() { 59 let args: Vec<String> = std::env::args().skip(1).collect(); 60 let result = match args.first().map(String::as_str) { 61 Some("generate") if args.len() >= 2 => parse_artifacts(&args[2..]) 62 .and_then(|artifacts| generate(Path::new(&args[1]), artifacts)), 63 Some("write") if args.len() >= 3 && args.len() % 2 == 1 => { 64 args[1..].chunks(2).try_for_each(|p| write(Path::new(&p[0]), &p[1])) 65 } 66 _ => Err(USAGE.into()), 67 }; 68 if let Err(e) = result { 69 eprintln!("third-party-notices: {e}"); 70 std::process::exit(1); 71 } 72} 73 74fn parse_artifacts(args: &[String]) -> Res<Vec<Artifact>> { 75 if args.first().map(String::as_str) != Some("--") { 76 return Err(USAGE.into()); 77 } 78 let mut artifacts = Vec::new(); 79 for group in args[1..].split(|a| a == "--") { 80 let [out, package, binary, dirs @ ..] = group else { 81 return Err(USAGE.into()); 82 }; 83 // A crate configured twice (e.g. for two platforms) is one crate. 84 let mut crates = BTreeMap::new(); 85 for dir in dirs { 86 let c = read_crate(Path::new(dir))?; 87 crates.insert((c.name.clone(), c.version.clone()), c); 88 } 89 if crates.is_empty() { 90 return Err(format!("{binary}: no crates given").into()); 91 } 92 artifacts.push(Artifact { 93 out: out.clone(), 94 package: package.clone(), 95 binary: binary.clone(), 96 crates, 97 }); 98 } 99 Ok(artifacts) 100} 101 102fn generate(supplements: &Path, mut artifacts: Vec<Artifact>) -> Res<()> { 103 for entry in std::fs::read_dir(supplements)? { 104 let dir = entry?.path(); 105 let id = dir.file_name().unwrap_or_default().to_string_lossy().into_owned(); 106 let mut holders: Vec<&mut Crate> = artifacts 107 .iter_mut() 108 .filter_map(|a| a.crates.values_mut().find(|c| format!("{}-{}", c.name, c.version) == id)) 109 .collect(); 110 if holders.is_empty() { 111 return Err(format!("texts/{id}: not a crate linked into any artifact (remove it)").into()); 112 } 113 if holders.iter().any(|c| !c.texts.is_empty()) { 114 return Err(format!("texts/{id}: the crate ships its own licence text (remove it)").into()); 115 } 116 if !dir.join("SOURCE").is_file() { 117 return Err(format!("texts/{id}: no SOURCE naming where the text came from").into()); 118 } 119 let mut texts = Vec::new(); 120 for f in std::fs::read_dir(&dir)? { 121 let f = f?.file_name().to_string_lossy().into_owned(); 122 if f != "SOURCE" { 123 let text = std::fs::read_to_string(dir.join(&f))?; 124 texts.push((format!("{f}, from upstream"), normalize(&text))); 125 } 126 } 127 texts.sort(); 128 for c in &mut holders { 129 c.texts = texts.clone(); 130 } 131 } 132 for a in &artifacts { 133 write_notices(a)?; 134 } 135 Ok(()) 136} 137 138fn write_notices(a: &Artifact) -> Res<()> { 139 let missing: Vec<String> = a 140 .crates 141 .values() 142 .filter(|c| c.texts.is_empty()) 143 .map(|c| format!("{} {} ({})", c.name, c.version, c.license)) 144 .collect(); 145 if !missing.is_empty() { 146 return Err(format!("{}: no licence text in: {}", a.binary, missing.join(", ")).into()); 147 } 148 149 let mut s = String::new(); 150 writeln!(s, "Third-party licence notices for {}\n", a.package)?; 151 writeln!(s, "@generated by tools/third-party-notices from the crates linked into")?; 152 writeln!(s, "{}. Do not edit: change the dependencies and run", a.binary)?; 153 writeln!(s, " buck2 run //tools/third-party-notices:update")?; 154 writeln!(s, "The drift test (//tools/third-party-notices:drift) fails until you do.\n")?; 155 writeln!(s, "{} itself is MIT OR Apache-2.0 (LICENSE-MIT, LICENSE-APACHE).", a.package)?; 156 writeln!(s, "The crates below are linked into {} under their own terms.", a.binary)?; 157 writeln!(s, "\nCrate, version, licence (SPDX, as the crate declares it):\n")?; 158 for c in a.crates.values() { 159 writeln!(s, " {} {} {}", c.name, c.version, c.license)?; 160 } 161 162 // Each distinct text once, with the crates that carry it. 163 let mut by_text: BTreeMap<&str, BTreeSet<String>> = BTreeMap::new(); 164 for c in a.crates.values() { 165 for (file, text) in &c.texts { 166 by_text 167 .entry(text.as_str()) 168 .or_default() 169 .insert(format!("{} {} ({file})", c.name, c.version)); 170 } 171 } 172 let mut blocks: Vec<(String, &str)> = by_text 173 .into_iter() 174 .map(|(text, users)| (users.into_iter().collect::<Vec<_>>().join(", "), text)) 175 .collect(); 176 blocks.sort(); 177 for (users, text) in blocks { 178 writeln!(s, "\n{}\n{users}\n{}\n\n{text}", "=".repeat(72), "=".repeat(72))?; 179 } 180 Ok(std::fs::write(&a.out, s)?) 181} 182 183fn read_crate(dir: &Path) -> Res<Crate> { 184 let manifest: toml::Table = std::fs::read_to_string(dir.join("Cargo.toml"))?.parse()?; 185 let pkg = manifest 186 .get("package") 187 .and_then(|p| p.as_table()) 188 .ok_or_else(|| format!("{}: no [package]", dir.display()))?; 189 let field = |k: &str| pkg.get(k).and_then(|v| v.as_str()).map(str::to_owned); 190 let name = field("name").ok_or_else(|| format!("{}: no package.name", dir.display()))?; 191 let version = field("version").ok_or_else(|| format!("{name}: no package.version"))?; 192 let license = field("license").ok_or_else(|| format!("{name} {version}: no package.license"))?; 193 194 let mut files = BTreeSet::new(); 195 for entry in std::fs::read_dir(dir)? { 196 let entry = entry?; 197 let file = entry.file_name().to_string_lossy().into_owned(); 198 let upper = file.to_ascii_uppercase(); 199 if entry.file_type()?.is_file() 200 && ["LICENSE", "LICENCE", "COPYING", "NOTICE"].iter().any(|p| upper.starts_with(p)) 201 { 202 files.insert(file); 203 } 204 } 205 let reuse = dir.join("LICENSES"); 206 if reuse.is_dir() { 207 for entry in std::fs::read_dir(&reuse)? { 208 files.insert(format!("LICENSES/{}", entry?.file_name().to_string_lossy())); 209 } 210 } 211 if let Some(f) = field("license-file") { 212 files.insert(f); 213 } 214 let texts = files 215 .into_iter() 216 .map(|f| { 217 let text = std::fs::read_to_string(dir.join(&f)) 218 .map_err(|e| format!("{name} {version}: {f}: {e}"))?; 219 Ok((f, normalize(&text))) 220 }) 221 .collect::<Res<_>>()?; 222 Ok(Crate { name, version, license, texts }) 223}
Line endings and trailing blanks differ between otherwise equal copies.
Copies a generated file to name in the checkout holding the cwd,
found by its .buckroot.
233fn write(generated: &Path, name: &str) -> Res<()> { 234 let cwd = std::env::current_dir()?; 235 let root = cwd 236 .ancestors() 237 .find(|d| d.join(".buckroot").exists()) 238 .ok_or("not inside a buck checkout (no .buckroot above the cwd)")?; 239 let dest = root.join(name); 240 std::fs::copy(generated, &dest)?; 241 println!("wrote {}", dest.display()); 242 Ok(()) 243}