rucc_sysroot/distribution.rs
1//! What a release brings onto a machine, per target, as one file an auditor can read.
2//!
3//! Design: `spec/cross-compile/13-distribution.md` section 13.5, whose last line asks for the same
4//! provenance as `-print-sysroot-provenance` gives, for every target, shipped beside the binary so
5//! that it can be audited without running the compiler.
6//!
7//! # What it can honestly say today
8//!
9//! Not what a produced sysroot is made of, because no release ships one yet. [`crate::Manifest`] is
10//! that record and it is written by the producer that writes the files, which means it exists on a
11//! machine that has the tree rather than in a release that has not got one. What a release can say
12//! is what it will bring: for every target in the table, whether the sysroot for it is in the archive
13//! already, is pinned as a download with a URL and a hash, is behind a licence wall and therefore
14//! will never be either, or is ours to ship and nobody has published it yet.
15//!
16//! That is four states and they are the four sentences a person gets from the compiler when they ask
17//! for a target, which is the property worth having: the file says in advance what `--fetch` and a
18//! cross compile will say, so somebody deciding whether this compiler can be used inside their
19//! organization does not have to run it once per target to find out.
20//!
21//! # Why a file in the repository rather than something the release writes
22//!
23//! Because the release builds a binary per host and two of those hosts cannot run what they built,
24//! so a file produced by running the compiler would be produced on three machines and absent on two.
25//! Generated and committed instead, the way `docs/TARGETS.md` and `tests/link-lines` already are,
26//! with `cargo xtask provenance --check` in CI holding it to the tables it came from. So the file
27//! is auditable in the repository as well as in the archive, and a release whose file drifted from
28//! its own tables does not get built, because the check runs before the tag is packaged.
29//!
30//! # Why there is no parser here
31//!
32//! Nothing of ours reads it. The sysroot manifest has [`crate::Manifest::parse`] because an install
33//! checks a tree against the one inside it, and this file has no such consumer: it is written for
34//! whoever is asking what the release contains, and they have `cut` and `grep`. The format is lines
35//! of tab separated fields under a version so that writing the parser they do want is ten minutes.
36
37use std::fmt::Write as _;
38
39use rucc_tuple::{Os, TARGETS, TargetTuple};
40
41use crate::artifact::Pinned;
42use crate::manifest::Licence;
43use crate::wall::Wall;
44
45/// The first line, which says what the file is and which version of it this is.
46const HEADER: &str = "rucc distribution manifest 1";
47
48/// Where the trees that are not in the release come from, named once in the header rather than on
49/// every line that has not got a URL yet.
50const PRODUCER: &str = "https://github.com/tamnd/rucc-cross";
51
52/// How the sysroot for one target reaches the machine that compiles for it.
53///
54/// Four answers, and the reason this is an enum rather than an optional URL is that three of them
55/// have no URL and mean entirely different things. A target nobody has published a tree for yet gets
56/// one in a later release. A target behind a licence wall does not, ever, and telling the two apart
57/// is the whole of `spec/cross-compile/13-distribution.md` section 13.4.
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub enum Arrival {
60 /// Nothing has to arrive. The compiler's own headers are inside the binary and a freestanding
61 /// target links none of a library, so the archive is the whole of what this target needs.
62 Included,
63 /// A sysroot artifact this release pins, by URL and by hash.
64 Pinned(&'static Pinned),
65 /// Behind one of the two licence walls, so there is nothing to ship and nothing to fetch, and
66 /// what a person does instead is name a path they already have a licence for.
67 Wall(Wall),
68 /// Ours to ship, and not yet published. A later release pins it.
69 Unpublished,
70}
71
72impl Arrival {
73 /// How the sysroot for this target arrives.
74 #[must_use]
75 pub fn of(target: TargetTuple) -> Arrival {
76 if let Some(wall) = Wall::of(target) {
77 return Arrival::Wall(wall);
78 }
79 if let Some(pinned) = crate::artifact::pinned_for(&target.to_canonical_string()) {
80 return Arrival::Pinned(pinned);
81 }
82 // Freestanding is the one row that needs no sysroot at all rather than one nobody has built.
83 // `spec/cross-compile/08-sysroots.md` section 8.2 gives it nine compiler headers and no link
84 // inputs, and those headers are compiled into the binary.
85 if target.os() == Os::None {
86 return Arrival::Included;
87 }
88 Arrival::Unpublished
89 }
90
91 /// The word this state is spelled with in the file.
92 #[must_use]
93 pub const fn as_str(self) -> &'static str {
94 match self {
95 Arrival::Included => "included",
96 Arrival::Pinned(_) => "pinned",
97 Arrival::Wall(_) => "wall",
98 Arrival::Unpublished => "unpublished",
99 }
100 }
101}
102
103/// The whole file, for a release named by `version`.
104///
105/// The version is an argument rather than this crate's own, because the number that belongs in a
106/// release manifest is the release's and a crate that read its own would be right only for as long
107/// as nobody ever published one of these crates on its own.
108///
109/// Sorted by target, which is the order the table is already in and is checked below, because a file
110/// two releases apart should diff as what changed between them.
111#[must_use]
112pub fn render(version: &str) -> String {
113 let mut out = String::new();
114 let _ = writeln!(out, "{HEADER}");
115 let _ = writeln!(out, "release\t{version}");
116 let _ = writeln!(out, "producer\t{PRODUCER}");
117 // The one payload there is. Everything else in the archive is this file, the licence, the
118 // changelog and the readme, which are documents about the release rather than inputs to a
119 // compile, and the compiler's own headers are inside the binary because they are compiled into
120 // it with `include_str!`.
121 let _ = writeln!(out, "payload\trucc\t{}", Licence::Apache2);
122 let _ = writeln!(out, "targets\t{}", TARGETS.len());
123 for entry in TARGETS {
124 let target = match entry.parse() {
125 Ok(target) => target,
126 // A row that does not parse is a bug in the table that its own tests catch. This file
127 // says so rather than leaving the row out, because a manifest with a target missing
128 // reads as a release that has nothing to say about it.
129 Err(_) => {
130 let _ = writeln!(out, "target\t{}\tunparsed", entry.tuple);
131 continue;
132 }
133 };
134 let arrival = Arrival::of(target);
135 let _ = write!(out, "target\t{}\t{}", entry.tuple, arrival.as_str());
136 match arrival {
137 Arrival::Pinned(pinned) => {
138 let _ = write!(out, "\t{}\t{}", pinned.url, pinned.sha256);
139 }
140 Arrival::Wall(wall) => {
141 let _ = write!(out, "\t{}", wall.under());
142 }
143 Arrival::Included | Arrival::Unpublished => {}
144 }
145 out.push('\n');
146 }
147 out
148}
149
150#[cfg(test)]
151mod tests {
152 use super::*;
153 use crate::artifact::PINNED;
154
155 /// The file for a release, split into lines, for a test to read.
156 fn lines() -> Vec<String> {
157 render("0.0.0").lines().map(str::to_owned).collect()
158 }
159
160 /// The fields of the one line about this target.
161 fn row(tuple: &str) -> Vec<String> {
162 let wanted = format!("target\t{tuple}\t");
163 let text = render("0.0.0");
164 let line = text
165 .lines()
166 .find(|line| line.starts_with(&wanted))
167 .unwrap_or_else(|| panic!("no line for {tuple}"));
168 line.split('\t').map(str::to_owned).collect()
169 }
170
171 #[test]
172 fn the_header_says_what_the_file_is_and_which_release_it_describes() {
173 let lines = lines();
174 assert_eq!(lines[0], HEADER);
175 assert_eq!(lines[1], "release\t0.0.0");
176 assert_eq!(lines[2], format!("producer\t{PRODUCER}"));
177 assert_eq!(lines[3], "payload\trucc\tapache-2.0");
178 }
179
180 /// Every target in the table, and a count above them so that a truncated file is not a shorter
181 /// table.
182 #[test]
183 fn there_is_a_line_for_every_target_and_the_count_says_how_many() {
184 let lines = lines();
185 assert_eq!(lines[4], format!("targets\t{}", TARGETS.len()));
186 let rows: Vec<&String> = lines.iter().filter(|line| line.starts_with("target\t")).collect();
187 assert_eq!(rows.len(), TARGETS.len());
188 for (row, entry) in rows.iter().zip(TARGETS) {
189 assert!(row.starts_with(&format!("target\t{}\t", entry.tuple)), "{row}");
190 }
191 }
192
193 #[test]
194 fn a_target_behind_a_licence_wall_says_which_licence_put_it_there() {
195 // The two walls and not one word for both, because a person who may use one of them cannot
196 // necessarily use the other and the licence is what decides.
197 assert_eq!(row("aarch64-macos"), ["target", "aarch64-macos", "wall", "apple-sdk"]);
198 assert_eq!(
199 row("x86_64-windows-msvc"),
200 ["target", "x86_64-windows-msvc", "wall", "microsoft-sdk"]
201 );
202 // And the Windows target that is ours to ship is not behind either of them, which is the
203 // distinction the file exists to carry.
204 assert_eq!(row("x86_64-windows-gnu"), ["target", "x86_64-windows-gnu", "unpublished"]);
205 }
206
207 #[test]
208 fn a_freestanding_target_needs_nothing_and_says_so_rather_than_saying_nobody_built_it() {
209 assert_eq!(row("armv7m-none-eabi")[2], "included");
210 assert_eq!(row("x86_64-linux-gnu")[2], "unpublished");
211 }
212
213 /// What a row looks like once the producer has published something, which is the state the file
214 /// is written for and the one no row is in today.
215 #[test]
216 fn a_pinned_target_carries_the_url_and_the_hash_it_is_held_to() {
217 static PINNED_ROW: Pinned = Pinned {
218 tuple: "x86_64-linux-musl",
219 url: "https://example.invalid/rucc-sysroot-x86_64-linux-musl.tar.gz",
220 sha256: "3333333333333333333333333333333333333333333333333333333333333333",
221 };
222 let pinned = &PINNED_ROW;
223 let arrival = Arrival::Pinned(pinned);
224 assert_eq!(arrival.as_str(), "pinned");
225 // The same three fields the table holds, in the order a person checking a download wants
226 // them: what it is, where it came from, what it has to hash to.
227 let mut line = format!("target\t{}\t{}", pinned.tuple, arrival.as_str());
228 line.push_str(&format!("\t{}\t{}", pinned.url, pinned.sha256));
229 let fields: Vec<&str> = line.split('\t').collect();
230 assert_eq!(fields.len(), 5);
231 assert_eq!(fields[3], pinned.url);
232 assert_eq!(fields[4], pinned.sha256);
233 }
234
235 /// Today's table has no rows, so every target that is ours to ship is unpublished and the file
236 /// says that rather than going quiet.
237 #[test]
238 fn nothing_is_pinned_yet_and_the_file_does_not_pretend_otherwise() {
239 assert!(PINNED.is_empty(), "a row landed, so this test is the one to update");
240 let text = render("0.0.0");
241 assert!(!text.contains("\tpinned"), "{text}");
242 assert!(text.contains("\tunpublished\n"), "{text}");
243 }
244
245 #[test]
246 fn every_line_has_a_kind_and_no_field_is_empty() {
247 for line in lines() {
248 let fields: Vec<&str> = line.split('\t').collect();
249 if line == HEADER {
250 continue;
251 }
252 assert!(
253 matches!(fields[0], "release" | "producer" | "payload" | "targets" | "target"),
254 "{line}"
255 );
256 for field in fields {
257 assert!(!field.is_empty(), "an empty field in `{line}`");
258 }
259 }
260 }
261}