main.rsannotatedmain.rssource243 lines · 9.5 KB · raw
1//! Writes the licence notices of every crates.io crate linked into each
2//! shipped artifact, from the crate archives buck fetched: `THIRD-PARTY`
3//! for the extension (`postjevsql.so`) and `THIRD-PARTY-sidecar` for the
4//! sidecar CLI (`postjevsql-sidecar`). Each package installs only its own
5//! file, since each binary links its own set.
6//!
7//! The crate lists are not kept by hand: `:notices` passes, per artifact,
8//! the extracted archives of its target deps (buck's `query_outputs`), so
9//! they follow `third-party/BUCK`. For each crate this reads
10//! `package.name`, `version`, `license` and `license-file` from its
11//! `Cargo.toml`, every top-level LICENSE / LICENCE / COPYING / NOTICE
12//! file, and the files in a REUSE `LICENSES/` directory.
13//!
14//! A crate with no licence expression or no licence text fails the build:
15//! a binary must carry its notices, so a missing one is not skipped. A
16//! crate whose archive ships no text gets it from `texts/<name>-<version>/`,
17//! fetched from its upstream at that version and named in the directory's
18//! `SOURCE`. A text there for a crate linked into no artifact, or that
19//! ships its own, fails the build too, so the directory cannot go stale.
20//! One `texts/` serves every artifact, which is why they are generated in
21//! one run.
22//!
23//! Identical texts are written once per file, naming every crate that
24//! carries them.
25//!
26//! Usage:
27//!   third-party-notices generate <texts-dir> (-- <out-file> <package> <binary> <crate-dir>...)...
28//!   third-party-notices write (<generated-file> <name>)...   (copies each to <name> in the checkout holding the cwd)
29
30use std::collections::{BTreeMap, BTreeSet};
31use std::error::Error;
32use std::fmt::Write as _;
33use std::path::Path;
34
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,
44    /// File name and text of each licence file.
45    texts: Vec<(String, String)>,
46}
47
48/// One shipped binary and the crates linked into it.
49struct Artifact {
50    out: String,
51    /// The package that installs the file, e.g. `postjevsql-sidecar`.
52    package: String,
53    /// What the crates are linked into, e.g. `postjevsql.so`.
54    binary: String,
55    crates: BTreeMap<(String, String), Crate>,
56}
57
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}
224
225/// Line endings and trailing blanks differ between otherwise equal copies.
226fn normalize(text: &str) -> String {
227    let lines: Vec<&str> = text.lines().map(str::trim_end).collect();
228    lines.join("\n").trim_matches('\n').to_owned()
229}
230
231/// Copies a generated file to `name` in the checkout holding the cwd,
232/// 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}