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 and
4//! `spec/cross-compile/02-the-goal.md` claim 5 for the rest.
5//!
6//! # Two jobs, one file
7//!
8//! Claim 5 asks for byte identical output from two hosts for the same target. Checking that over a
9//! sysroot means comparing several thousand files, and the first thing anybody does when the
10//! comparison fails is ask which file and where it came from. A manifest answers both, and comparing
11//! two manifests is a diff of a few hundred lines rather than of a directory tree.
12//!
13//! The other job is the licence wall. Section 8.6 says the macOS SDK and the Windows SDK cannot be
14//! redistributed, and the way that rule gets enforced rather than remembered is that every input
15//! carries its licence and [`Manifest::redistributable`] is a function anything that publishes an
16//! artifact can call. A rule in a document is a rule somebody breaks in eighteen months.
17//!
18//! # The format
19//!
20//! Tab separated lines, sorted by path, with a two line header. Not JSON, because the thing this is
21//! optimized for is a person reading a diff between two of them, and not TOML, because it has no
22//! nesting and a parser for it is thirty lines. Sorted because the order files come out of a
23//! directory walk is a property of the filesystem, and a manifest whose line order depended on that
24//! would report a difference between two identical sysroots.
25//!
26//! ```
27//! use rucc_sysroot::{Input, Licence, Manifest};
28//! use rucc_tuple::TargetTuple;
29//!
30//! let target: TargetTuple = "aarch64-linux-musl".parse().unwrap();
31//! let mut manifest = Manifest::new(target);
32//! manifest.push(Input {
33//! path: "include/generic/stdio.h".into(),
34//! source: "musl-1.2.5".into(),
35//! sha256: "0".repeat(64),
36//! licence: Licence::Mit,
37//! });
38//!
39//! let text = manifest.render();
40//! assert_eq!(Manifest::parse(&text).unwrap(), manifest);
41//! assert!(manifest.redistributable());
42//! ```
43
44use std::fmt;
45use std::str::FromStr;
46
47use rucc_tuple::TargetTuple;
48
49/// The licence an input arrives under.
50///
51/// The list is section 8.2's table with one variant per row, rather than a free text field, because
52/// the question [`Licence::redistributable`] answers has to have an answer for every input and a
53/// string does not.
54#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
55pub enum Licence {
56 /// musl, which is MIT and the reason it goes first.
57 Mit,
58 /// glibc, which is LGPL. Redistributable, with obligations that
59 /// `spec/cross-compile/13-distribution.md` owns.
60 Lgpl,
61 /// The BSD libcs, which are permissive.
62 Bsd,
63 /// mingw-w64, which is a mix of permissive licences and public domain headers.
64 MingwPermissive,
65 /// Ours. The compiler's own headers and its runtime.
66 Apache2,
67 /// The macOS SDK, restricted by the Xcode agreement to Apple-branded hardware. Never shipped,
68 /// only ever pointed at.
69 AppleSdk,
70 /// The Windows SDK and the universal CRT, which are not redistributable.
71 MicrosoftSdk,
72}
73
74impl Licence {
75 /// Whether an artifact containing this input can be published.
76 ///
77 /// Two of the seven answer false, and they are the two section 8.6 calls legal walls rather
78 /// than engineering. A sysroot containing either is a local thing on the machine of somebody
79 /// who accepted the licence themselves.
80 #[must_use]
81 pub const fn redistributable(self) -> bool {
82 !matches!(self, Licence::AppleSdk | Licence::MicrosoftSdk)
83 }
84
85 /// The spelling in a manifest file.
86 #[must_use]
87 pub const fn as_str(self) -> &'static str {
88 match self {
89 Licence::Mit => "mit",
90 Licence::Lgpl => "lgpl",
91 Licence::Bsd => "bsd",
92 Licence::MingwPermissive => "mingw-permissive",
93 Licence::Apache2 => "apache-2.0",
94 Licence::AppleSdk => "apple-sdk",
95 Licence::MicrosoftSdk => "microsoft-sdk",
96 }
97 }
98}
99
100impl fmt::Display for Licence {
101 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
102 f.write_str(self.as_str())
103 }
104}
105
106impl FromStr for Licence {
107 type Err = ManifestError;
108
109 fn from_str(s: &str) -> Result<Self, Self::Err> {
110 match s {
111 "mit" => Ok(Licence::Mit),
112 "lgpl" => Ok(Licence::Lgpl),
113 "bsd" => Ok(Licence::Bsd),
114 "mingw-permissive" => Ok(Licence::MingwPermissive),
115 "apache-2.0" => Ok(Licence::Apache2),
116 "apple-sdk" => Ok(Licence::AppleSdk),
117 "microsoft-sdk" => Ok(Licence::MicrosoftSdk),
118 other => Err(ManifestError::UnknownLicence(other.to_string())),
119 }
120 }
121}
122
123/// One file in a sysroot, and everything that has to be true of it.
124#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
125pub struct Input {
126 /// Where it sits, relative to the sysroot root. Relative because an absolute path is a fact
127 /// about the machine that built it, and two hosts have different ones.
128 pub path: String,
129 /// What it came out of, named so that the same manifest can be produced again. A release name
130 /// and version rather than a URL, because a URL moves and a release does not.
131 pub source: String,
132 /// The hash of the file, lowercase hex.
133 pub sha256: String,
134 /// What it may be done with.
135 pub licence: Licence,
136}
137
138/// The record of one produced sysroot.
139#[derive(Debug, Clone, PartialEq, Eq)]
140pub struct Manifest {
141 target: TargetTuple,
142 inputs: Vec<Input>,
143}
144
145/// What went wrong reading a manifest.
146#[derive(Debug, Clone, PartialEq, Eq)]
147pub enum ManifestError {
148 /// The first line was not the one this format starts with.
149 NotAManifest,
150 /// The version in the header is one this build does not read.
151 UnknownVersion(String),
152 /// The second line did not name a target, or named one that does not parse.
153 BadTarget(String),
154 /// A line did not have the four fields an input has.
155 BadInput {
156 /// Which line, counting from one.
157 line: usize,
158 /// How many tab separated fields it had.
159 fields: usize,
160 },
161 /// A hash that is not sixty four lowercase hex characters.
162 BadHash {
163 /// Which line, counting from one.
164 line: usize,
165 /// What was there instead.
166 found: String,
167 },
168 /// A licence spelling nothing here knows.
169 UnknownLicence(String),
170}
171
172impl fmt::Display for ManifestError {
173 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
174 match self {
175 ManifestError::NotAManifest => write!(f, "this does not start like a sysroot manifest"),
176 ManifestError::UnknownVersion(v) => {
177 write!(f, "manifest format version {v}, which this build does not read")
178 }
179 ManifestError::BadTarget(t) => write!(f, "`{t}` is not a target this understands"),
180 ManifestError::BadInput { line, fields } => {
181 write!(f, "line {line} has {fields} fields where an input has four")
182 }
183 ManifestError::BadHash { line, found } => {
184 write!(f, "line {line} has `{found}` where a sha256 belongs")
185 }
186 ManifestError::UnknownLicence(l) => write!(f, "`{l}` is not a licence this knows"),
187 }
188 }
189}
190
191impl std::error::Error for ManifestError {}
192
193/// The first line of every manifest, which is also how one is recognized.
194const HEADER: &str = "rucc sysroot manifest 1";
195
196impl Manifest {
197 /// An empty manifest for this target.
198 #[must_use]
199 pub const fn new(target: TargetTuple) -> Self {
200 Manifest { target, inputs: Vec::new() }
201 }
202
203 /// The target this sysroot is for.
204 #[must_use]
205 pub const fn target(&self) -> TargetTuple {
206 self.target
207 }
208
209 /// Every input, in the order they were added.
210 #[must_use]
211 pub fn inputs(&self) -> &[Input] {
212 &self.inputs
213 }
214
215 /// Record one input.
216 pub fn push(&mut self, input: Input) {
217 self.inputs.push(input);
218 }
219
220 /// Whether an artifact containing this whole sysroot can be published.
221 ///
222 /// One input under a licence that says no makes the answer no, which is the only reading of a
223 /// licence wall that is worth anything.
224 #[must_use]
225 pub fn redistributable(&self) -> bool {
226 self.inputs.iter().all(|input| input.licence.redistributable())
227 }
228
229 /// Every distinct source in the manifest, sorted.
230 ///
231 /// What a person asks first when two manifests differ, and what a licence notice is generated
232 /// from.
233 #[must_use]
234 pub fn sources(&self) -> Vec<&str> {
235 let mut sources: Vec<&str> =
236 self.inputs.iter().map(|input| input.source.as_str()).collect();
237 sources.sort_unstable();
238 sources.dedup();
239 sources
240 }
241
242 /// The manifest as text, sorted by path.
243 ///
244 /// The sort is what makes two runs comparable. A directory walk returns files in whatever order
245 /// the filesystem keeps them, which differs between ext4 and APFS and sometimes between two
246 /// runs on one of them, and a manifest that carried that order would report a difference
247 /// between two identical sysroots.
248 #[must_use]
249 pub fn render(&self) -> String {
250 let mut sorted = self.inputs.clone();
251 sorted.sort();
252
253 let mut text = String::new();
254 text.push_str(HEADER);
255 text.push('\n');
256 text.push_str("target\t");
257 text.push_str(&self.target.to_canonical_string());
258 text.push('\n');
259 for input in &sorted {
260 text.push_str(&input.path);
261 text.push('\t');
262 text.push_str(&input.source);
263 text.push('\t');
264 text.push_str(&input.sha256);
265 text.push('\t');
266 text.push_str(input.licence.as_str());
267 text.push('\n');
268 }
269 text
270 }
271
272 /// Read a manifest back.
273 ///
274 /// # Errors
275 ///
276 /// Returns which line was wrong and what was wrong with it. A manifest that fails to parse is
277 /// a cache entry somebody has to decide about, and "invalid manifest" is not enough to decide
278 /// with.
279 pub fn parse(text: &str) -> Result<Self, ManifestError> {
280 let mut lines = text.lines().enumerate();
281
282 let (_, first) = lines.next().ok_or(ManifestError::NotAManifest)?;
283 if first != HEADER {
284 let Some(version) = first.strip_prefix("rucc sysroot manifest ") else {
285 return Err(ManifestError::NotAManifest);
286 };
287 return Err(ManifestError::UnknownVersion(version.to_string()));
288 }
289
290 let (_, second) = lines.next().ok_or(ManifestError::NotAManifest)?;
291 let spelling = second
292 .strip_prefix("target\t")
293 .ok_or_else(|| ManifestError::BadTarget(second.into()))?;
294 let target = TargetTuple::from_str(spelling)
295 .map_err(|_| ManifestError::BadTarget(spelling.to_string()))?;
296
297 let mut manifest = Manifest::new(target);
298 for (index, line) in lines {
299 if line.is_empty() {
300 continue;
301 }
302 let number = index + 1;
303 let fields: Vec<&str> = line.split('\t').collect();
304 let [path, source, sha256, licence] = fields.as_slice() else {
305 return Err(ManifestError::BadInput { line: number, fields: fields.len() });
306 };
307 if !is_sha256(sha256) {
308 return Err(ManifestError::BadHash { line: number, found: (*sha256).to_string() });
309 }
310 manifest.push(Input {
311 path: (*path).to_string(),
312 source: (*source).to_string(),
313 sha256: (*sha256).to_string(),
314 licence: licence.parse()?,
315 });
316 }
317 Ok(manifest)
318 }
319}
320
321/// Whether this is sixty four lowercase hex characters.
322///
323/// Checked on the way in rather than assumed, because a manifest with a truncated hash in it is a
324/// manifest that verifies nothing while looking like it does.
325fn is_sha256(s: &str) -> bool {
326 s.len() == 64 && s.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
327}