Skip to main content

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}