rucc_sysroot/manifest.rs
1//! What a produced sysroot carries: every input, where it came from, its hash and its licence.
2//!
3//! Design: `spec/cross-compile/08-sysroots.md` section 8.6 for the licence half,
4//! `spec/cross-compile/13-distribution.md` section 13.5 for the provenance half, and
5//! `spec/cross-compile/02-the-goal.md` claim 5 for the rest.
6//!
7//! # Three jobs, one file
8//!
9//! Claim 5 asks for byte identical output from two hosts for the same target. Checking that over a
10//! sysroot means comparing several thousand files, and the first thing anybody does when the
11//! comparison fails is ask which file and where it came from. A manifest answers both, and comparing
12//! two manifests is a diff of a few hundred lines rather than of a directory tree.
13//! [`Manifest::digest`] is the same comparison in one line, for when the answer wanted is yes or no
14//! rather than which file.
15//!
16//! The second job is the licence wall. Section 8.6 says the macOS SDK and the Windows SDK cannot be
17//! redistributed, and the way that rule gets enforced rather than remembered is that every input
18//! carries its licence and [`Manifest::redistributable`] is a function anything that publishes an
19//! artifact can call. A rule in a document is a rule somebody breaks in eighteen months.
20//!
21//! The third is provenance. Section 13.5 asks the compiler to emit, for every input that is not its
22//! own code, the name, the upstream project, the version, the source URL, the content hash, the
23//! licence and whether the input was bundled, generated or fetched. That is this file's line with
24//! two fields added, so `-print-sysroot-provenance` prints a manifest rather than a second format
25//! saying the same things in a different order.
26//!
27//! # The format
28//!
29//! Tab separated lines, sorted by path, under a header that is two lines and sometimes three: the
30//! format version, the target, and the Linux release the kernel headers came out of when the sysroot
31//! has kernel headers in it. Not JSON, because the thing this is optimized for is a person reading a
32//! diff between two of them, and not TOML, because it has no nesting and a parser for it is thirty
33//! lines. Sorted because the order files come out of a directory walk is a property of the
34//! filesystem, and a manifest whose line order depended on that would report a difference between
35//! two identical sysroots.
36//!
37//! ```
38//! use rucc_sysroot::{Input, Licence, Manifest, Provenance};
39//! use rucc_tuple::{TargetTuple, Version};
40//!
41//! let target: TargetTuple = "aarch64-linux-musl".parse().unwrap();
42//! let mut manifest = Manifest::new(target);
43//! manifest.set_kernel(Version::new(6, 12));
44//! manifest.push(Input {
45//! path: "include/generic/stdio.h".into(),
46//! source: "musl-1.2.5".into(),
47//! url: "https://musl.libc.org/releases/musl-1.2.5.tar.gz".into(),
48//! sha256: "0".repeat(64),
49//! licence: Licence::Mit,
50//! provenance: Provenance::Bundled,
51//! });
52//!
53//! let text = manifest.render();
54//! assert_eq!(Manifest::parse(&text).unwrap(), manifest);
55//! assert!(manifest.redistributable());
56//! ```
57
58use std::fmt;
59use std::str::FromStr;
60
61use rucc_tuple::{TargetTuple, Version};
62
63/// The licence an input arrives under.
64///
65/// The list is section 8.2's table with one variant per row, plus the kernel headers, which every
66/// Linux row needs and which no row is about. A closed list rather than a free text field, because
67/// the question [`Licence::redistributable`] answers has to have an answer for every input and a
68/// string does not.
69#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
70pub enum Licence {
71 /// musl, which is MIT and the reason it goes first.
72 Mit,
73 /// glibc, which is LGPL. Redistributable, with obligations that
74 /// `spec/cross-compile/13-distribution.md` owns.
75 Lgpl,
76 /// The BSD libcs, which are permissive.
77 Bsd,
78 /// The Linux uapi headers, which are GPL-2.0 with the syscall note.
79 ///
80 /// The note is the whole reason this is a separate variant and not a refusal: it says that
81 /// using the headers to make a system call does not put the calling program under the GPL,
82 /// which is what every libc and every cross toolchain relies on. Redistributing the headers
83 /// themselves carries the GPL's own obligation, and the pinned source URL in the manifest is
84 /// how it is met, the same way it is for glibc.
85 LinuxUapi,
86 /// mingw-w64, which is a mix of permissive licences and public domain headers.
87 MingwPermissive,
88 /// Ours. The compiler's own headers and its runtime.
89 Apache2,
90 /// The macOS SDK, restricted by the Xcode agreement to Apple-branded hardware. Never shipped,
91 /// only ever pointed at.
92 AppleSdk,
93 /// The Windows SDK and the universal CRT, which are not redistributable.
94 MicrosoftSdk,
95}
96
97impl Licence {
98 /// Whether an artifact containing this input can be published.
99 ///
100 /// Two of the eight answer false, and they are the two section 8.6 calls legal walls rather
101 /// than engineering. A sysroot containing either is a local thing on the machine of somebody
102 /// who accepted the licence themselves.
103 #[must_use]
104 pub const fn redistributable(self) -> bool {
105 !matches!(self, Licence::AppleSdk | Licence::MicrosoftSdk)
106 }
107
108 /// The spelling in a manifest file.
109 #[must_use]
110 pub const fn as_str(self) -> &'static str {
111 match self {
112 Licence::Mit => "mit",
113 Licence::Lgpl => "lgpl",
114 Licence::Bsd => "bsd",
115 Licence::LinuxUapi => "gpl-2.0-with-linux-syscall-note",
116 Licence::MingwPermissive => "mingw-permissive",
117 Licence::Apache2 => "apache-2.0",
118 Licence::AppleSdk => "apple-sdk",
119 Licence::MicrosoftSdk => "microsoft-sdk",
120 }
121 }
122}
123
124impl fmt::Display for Licence {
125 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
126 f.write_str(self.as_str())
127 }
128}
129
130impl FromStr for Licence {
131 type Err = ManifestError;
132
133 fn from_str(s: &str) -> Result<Self, Self::Err> {
134 match s {
135 "mit" => Ok(Licence::Mit),
136 "lgpl" => Ok(Licence::Lgpl),
137 "bsd" => Ok(Licence::Bsd),
138 "gpl-2.0-with-linux-syscall-note" => Ok(Licence::LinuxUapi),
139 "mingw-permissive" => Ok(Licence::MingwPermissive),
140 "apache-2.0" => Ok(Licence::Apache2),
141 "apple-sdk" => Ok(Licence::AppleSdk),
142 "microsoft-sdk" => Ok(Licence::MicrosoftSdk),
143 other => Err(ManifestError::UnknownLicence(other.to_string())),
144 }
145 }
146}
147
148/// How an input got to where it is.
149///
150/// Section 13.5 asks for this field and does not say what the three answers mean, which makes the
151/// meaning a decision rather than a transcription. The question each answer has to settle is what a
152/// person reproducing a file has to have: an upstream release is enough for one of them, our own
153/// generator and its version is needed for the second, and the third did not exist on any machine
154/// until somebody's network fetched it.
155///
156/// The test to apply is whether the bytes can be found in the upstream release. A header we unpack
157/// and ship unchanged can be, so it is [`Provenance::Bundled`]. A stub shared object, a merged header
158/// tree or a linker script cannot be, because this compiler wrote it, so it is
159/// [`Provenance::Generated`] even though what it was derived from is upstream's. Anything that arrived
160/// over the network after the release was built is [`Provenance::Fetched`], whoever wrote it.
161#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
162pub enum Provenance {
163 /// Shipped inside the distribution, byte for byte as upstream released it. Reproducing it needs
164 /// the release named in [`Input::source`] and nothing of ours.
165 Bundled,
166 /// Written by this compiler from something upstream describes, which is the stubs of
167 /// `spec/cross-compile/09-libc-stubs.md` and the merged header trees of section 8.3.
168 /// Reproducing it needs our generator at the version that wrote it as well as the release.
169 Generated,
170 /// Downloaded onto this machine, which for the two licence walls of section 13.4 is the only way
171 /// the input can legally arrive at all. The record says so because a sysroot holding one is not
172 /// the same artifact as a sysroot that shipped complete.
173 Fetched,
174}
175
176impl Provenance {
177 /// The spelling in a manifest file.
178 #[must_use]
179 pub const fn as_str(self) -> &'static str {
180 match self {
181 Provenance::Bundled => "bundled",
182 Provenance::Generated => "generated",
183 Provenance::Fetched => "fetched",
184 }
185 }
186}
187
188impl fmt::Display for Provenance {
189 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
190 f.write_str(self.as_str())
191 }
192}
193
194impl FromStr for Provenance {
195 type Err = ManifestError;
196
197 fn from_str(s: &str) -> Result<Self, Self::Err> {
198 match s {
199 "bundled" => Ok(Provenance::Bundled),
200 "generated" => Ok(Provenance::Generated),
201 "fetched" => Ok(Provenance::Fetched),
202 other => Err(ManifestError::UnknownProvenance(other.to_string())),
203 }
204 }
205}
206
207/// One file in a sysroot, and everything that has to be true of it.
208#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
209pub struct Input {
210 /// Where it sits, relative to the sysroot root. Relative because an absolute path is a fact
211 /// about the machine that built it, and two hosts have different ones.
212 pub path: String,
213 /// What it came out of, named so that the same manifest can be produced again. A release name
214 /// and version rather than a URL, because a URL moves and a release does not.
215 pub source: String,
216 /// Where that release was fetched from, which is the field section 13.5 asks for by name.
217 ///
218 /// The URL of the release rather than of the file, because what anybody checking this does is
219 /// download the release and look inside it, and because a per file URL would be a claim about
220 /// somebody else's directory layout. It is here in spite of a URL moving and a release not,
221 /// which is the reason [`Input::source`] exists and is not replaced by this: the two fields
222 /// answer what it is and where it was got, and only the first of those is still true in ten
223 /// years.
224 pub url: String,
225 /// The hash of the file, lowercase hex.
226 pub sha256: String,
227 /// What it may be done with.
228 pub licence: Licence,
229 /// Whether it was bundled, generated or fetched.
230 pub provenance: Provenance,
231}
232
233/// The record of one produced sysroot.
234#[derive(Debug, Clone, PartialEq, Eq)]
235pub struct Manifest {
236 target: TargetTuple,
237 kernel: Option<Version>,
238 inputs: Vec<Input>,
239}
240
241/// What went wrong reading a manifest.
242#[derive(Debug, Clone, PartialEq, Eq)]
243pub enum ManifestError {
244 /// The first line was not the one this format starts with.
245 NotAManifest,
246 /// The version in the header is one this build does not read.
247 UnknownVersion(String),
248 /// The second line did not name a target, or named one that does not parse.
249 BadTarget(String),
250 /// The `kernel` line was there and what followed it is not a Linux release.
251 BadKernel(String),
252 /// A line did not have the six fields an input has.
253 BadInput {
254 /// Which line, counting from one.
255 line: usize,
256 /// How many tab separated fields it had.
257 fields: usize,
258 },
259 /// A hash that is not sixty four lowercase hex characters.
260 BadHash {
261 /// Which line, counting from one.
262 line: usize,
263 /// What was there instead.
264 found: String,
265 },
266 /// A licence spelling nothing here knows.
267 UnknownLicence(String),
268 /// A provenance spelling nothing here knows.
269 UnknownProvenance(String),
270 /// An empty field where a source, a URL or a path belongs. A record with a hole in it is worse
271 /// than no record, because it reads as an answer.
272 EmptyField {
273 /// Which line, counting from one.
274 line: usize,
275 /// Which field was empty, spelled the way this module names it.
276 field: &'static str,
277 },
278}
279
280impl fmt::Display for ManifestError {
281 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
282 match self {
283 ManifestError::NotAManifest => write!(f, "this does not start like a sysroot manifest"),
284 ManifestError::UnknownVersion(v) => {
285 write!(f, "manifest format version {v}, which this build does not read")
286 }
287 ManifestError::BadTarget(t) => write!(f, "`{t}` is not a target this understands"),
288 ManifestError::BadKernel(k) => {
289 write!(f, "`{k}` is not a Linux release, which is what a kernel line carries")
290 }
291 ManifestError::BadInput { line, fields } => {
292 write!(f, "line {line} has {fields} fields where an input has six")
293 }
294 ManifestError::BadHash { line, found } => {
295 write!(f, "line {line} has `{found}` where a sha256 belongs")
296 }
297 ManifestError::UnknownLicence(l) => write!(f, "`{l}` is not a licence this knows"),
298 ManifestError::UnknownProvenance(o) => {
299 write!(f, "`{o}` is not bundled, generated or fetched")
300 }
301 ManifestError::EmptyField { line, field } => {
302 write!(f, "line {line} has nothing where its {field} belongs")
303 }
304 }
305 }
306}
307
308impl std::error::Error for ManifestError {}
309
310/// The first line of every manifest, which is also how one is recognized.
311///
312/// Version 2 is version 1 with the source URL and the provenance on every line, which is what
313/// section 13.5 asks a record of an input to carry. The number went up rather than the two fields
314/// being optional, because a reader that accepted both would have to decide what a missing
315/// provenance means and there is no honest answer to that: an input nobody wrote a provenance for is
316/// an input whose provenance nobody knows. Nothing has written a version 1 file to a place that
317/// outlives a build, so the only cost of the bump is this sentence.
318///
319/// Version 3 adds the optional `kernel` line. The number went up even though the line is optional,
320/// because the whole point of a format version is that a reader can say it does not read a file, and
321/// a version 2 reader handed a file with a `kernel` line in it would report the line as an input
322/// with two fields rather than as a format it does not know.
323const HEADER: &str = "rucc sysroot manifest 3";
324
325impl Manifest {
326 /// An empty manifest for this target.
327 #[must_use]
328 pub const fn new(target: TargetTuple) -> Self {
329 Manifest { target, kernel: None, inputs: Vec::new() }
330 }
331
332 /// The target this sysroot is for.
333 #[must_use]
334 pub const fn target(&self) -> TargetTuple {
335 self.target
336 }
337
338 /// The Linux release the kernel headers in this sysroot came out of, when one was recorded.
339 ///
340 /// Absent has one meaning and it is not "nobody knows": it is that this sysroot has no kernel
341 /// headers in it. Every target that is not Linux is in that case, and so is a Linux sysroot
342 /// produced before a kernel tree was installed beside it, which is a state the producer allows
343 /// because the two trees are two commands. That is the one thing an optional line can mean here
344 /// and it is why this one is optional where the provenance field is not: a file with no kernel
345 /// headers in it has no kernel version, and an input always came from somewhere.
346 ///
347 /// What it is for is the question somebody asks after a cross build read a header nobody
348 /// expected. `-print-sysroot` answers where and the manifest answers what, and a sysroot whose
349 /// record names the release its `linux/` headers came out of makes a stale pairing visible
350 /// instead of leaving it to be guessed at. Nothing here checks the version against the headers
351 /// themselves, which is tamnd/rucc#925's argument applied to the kernel tree rather than to
352 /// glibc.
353 #[must_use]
354 pub const fn kernel(&self) -> Option<Version> {
355 self.kernel
356 }
357
358 /// Record which Linux release the kernel headers came out of.
359 ///
360 /// Infallible, and in particular it does not refuse a target that has no kernel headers. The
361 /// property this type owes its callers is that [`Manifest::parse`] reads back what
362 /// [`Manifest::render`] wrote, so the reader accepts every manifest a producer can build and a
363 /// `kernel` line on a Windows sysroot is a bug in the producer rather than a corrupt file.
364 pub const fn set_kernel(&mut self, version: Version) {
365 self.kernel = Some(version);
366 }
367
368 /// Every input, in the order they were added.
369 #[must_use]
370 pub fn inputs(&self) -> &[Input] {
371 &self.inputs
372 }
373
374 /// Record one input.
375 pub fn push(&mut self, input: Input) {
376 self.inputs.push(input);
377 }
378
379 /// Whether an artifact containing this whole sysroot can be published.
380 ///
381 /// One input under a licence that says no makes the answer no, which is the only reading of a
382 /// licence wall that is worth anything.
383 #[must_use]
384 pub fn redistributable(&self) -> bool {
385 self.inputs.iter().all(|input| input.licence.redistributable())
386 }
387
388 /// Every distinct source in the manifest, sorted.
389 ///
390 /// What a person asks first when two manifests differ, and what a licence notice is generated
391 /// from.
392 #[must_use]
393 pub fn sources(&self) -> Vec<&str> {
394 let mut sources: Vec<&str> =
395 self.inputs.iter().map(|input| input.source.as_str()).collect();
396 sources.sort_unstable();
397 sources.dedup();
398 sources
399 }
400
401 /// The manifest as text, sorted by path.
402 ///
403 /// The sort is what makes two runs comparable. A directory walk returns files in whatever order
404 /// the filesystem keeps them, which differs between ext4 and APFS and sometimes between two
405 /// runs on one of them, and a manifest that carried that order would report a difference
406 /// between two identical sysroots.
407 #[must_use]
408 pub fn render(&self) -> String {
409 let mut sorted = self.inputs.clone();
410 sorted.sort();
411
412 let mut text = String::new();
413 text.push_str(HEADER);
414 text.push('\n');
415 text.push_str("target\t");
416 text.push_str(&self.target.to_canonical_string());
417 text.push('\n');
418 if let Some(kernel) = self.kernel {
419 text.push_str("kernel\t");
420 text.push_str(&kernel.to_string());
421 text.push('\n');
422 }
423 for input in &sorted {
424 text.push_str(&input.path);
425 text.push('\t');
426 text.push_str(&input.source);
427 text.push('\t');
428 text.push_str(&input.url);
429 text.push('\t');
430 text.push_str(&input.sha256);
431 text.push('\t');
432 text.push_str(input.licence.as_str());
433 text.push('\t');
434 text.push_str(input.provenance.as_str());
435 text.push('\n');
436 }
437 text
438 }
439
440 /// One number naming everything this sysroot is made of: the sha256 of [`Manifest::render`],
441 /// as sixty four lowercase hex characters.
442 ///
443 /// The same number `sha256sum` prints for the manifest file itself, which is the property worth
444 /// having. Whoever is handed a digest can check it with a tool they already have, and a digest
445 /// that only our own code could compute would be a claim nobody can audit.
446 ///
447 /// # What it is for
448 ///
449 /// `spec/cross-compile/13-distribution.md` section 13.2 asks for the hash of a cache directory's
450 /// contents in the directory's name, and a name cannot carry one: the path has to be computable
451 /// before anything has been read, by the producer that is about to write the files and by the
452 /// compiler that is about to read them, and neither of them has the contents when it asks. What
453 /// the rule wanted is a way to say in one line what is under a directory, and this is that line.
454 /// Two hosts producing a sysroot for one target compare a digest instead of a few thousand
455 /// files, and a digest published with a release can be held against a directory on a machine.
456 ///
457 /// # What it covers
458 ///
459 /// What the manifest covers, which is every file in the sysroot and the Linux release its kernel
460 /// headers came out of. Not the kernel tree's own files, because they are not in the sysroot:
461 /// one tree serves every Linux target, so it sits in the cache beside the sysroots and
462 /// [`crate::Manifest::kernel`] is what a sysroot says about it.
463 ///
464 /// It is a fact about the target and its inputs rather than about the host, because the render
465 /// is sorted, holds no absolute path and holds no timestamp. That is the same argument
466 /// `spec/cross-compile/02-the-goal.md` claim 5 rests on, applied to the record rather than to
467 /// the output.
468 #[must_use]
469 pub fn digest(&self) -> String {
470 crate::sha256::hex(self.render().as_bytes())
471 }
472
473 /// Read a manifest back.
474 ///
475 /// # Errors
476 ///
477 /// Returns which line was wrong and what was wrong with it. A manifest that fails to parse is
478 /// a cache entry somebody has to decide about, and "invalid manifest" is not enough to decide
479 /// with.
480 pub fn parse(text: &str) -> Result<Self, ManifestError> {
481 let mut lines = text.lines().enumerate().peekable();
482
483 let (_, first) = lines.next().ok_or(ManifestError::NotAManifest)?;
484 if first != HEADER {
485 let Some(version) = first.strip_prefix("rucc sysroot manifest ") else {
486 return Err(ManifestError::NotAManifest);
487 };
488 return Err(ManifestError::UnknownVersion(version.to_string()));
489 }
490
491 let (_, second) = lines.next().ok_or(ManifestError::NotAManifest)?;
492 let spelling = second
493 .strip_prefix("target\t")
494 .ok_or_else(|| ManifestError::BadTarget(second.into()))?;
495 let target = TargetTuple::from_str(spelling)
496 .map_err(|_| ManifestError::BadTarget(spelling.to_string()))?;
497
498 let mut manifest = Manifest::new(target);
499
500 // The kernel line is read only here, immediately after the target, rather than wherever it
501 // turns up. The render order is what makes two manifests comparable with `diff`, and a
502 // reader that took the line anywhere would accept files that do not compare.
503 if let Some(spelling) = lines.peek().and_then(|(_, line)| line.strip_prefix("kernel\t")) {
504 let version = Version::parse(spelling)
505 .ok_or_else(|| ManifestError::BadKernel(spelling.into()))?;
506 manifest.set_kernel(version);
507 lines.next();
508 }
509
510 for (index, line) in lines {
511 if line.is_empty() {
512 continue;
513 }
514 let number = index + 1;
515 let fields: Vec<&str> = line.split('\t').collect();
516 let [path, source, url, sha256, licence, provenance] = fields.as_slice() else {
517 return Err(ManifestError::BadInput { line: number, fields: fields.len() });
518 };
519 if !is_sha256(sha256) {
520 return Err(ManifestError::BadHash { line: number, found: (*sha256).to_string() });
521 }
522 for (value, field) in [(path, "path"), (source, "source"), (url, "url")] {
523 if value.is_empty() {
524 return Err(ManifestError::EmptyField { line: number, field });
525 }
526 }
527 manifest.push(Input {
528 path: (*path).to_string(),
529 source: (*source).to_string(),
530 url: (*url).to_string(),
531 sha256: (*sha256).to_string(),
532 licence: licence.parse()?,
533 provenance: provenance.parse()?,
534 });
535 }
536 Ok(manifest)
537 }
538}
539
540/// Whether this is sixty four lowercase hex characters.
541///
542/// Checked on the way in rather than assumed, because a manifest with a truncated hash in it is a
543/// manifest that verifies nothing while looking like it does.
544fn is_sha256(s: &str) -> bool {
545 s.len() == 64 && s.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
546}