rucc_sysroot/artifact.rs
1//! What this release pins: one sysroot artifact per target, by URL and by hash.
2//!
3//! Design: `spec/cross-compile/13-distribution.md` section 13.2, which says every downloaded
4//! artifact has a hash pinned in the rucc release, checked before use, with a mismatch being a hard
5//! failure and no flag to get past it. Section 13.8 divides the work in three and the other two are
6//! written: `rucc_driver::fetch` moves the bytes with a program the machine already has, and
7//! `rucc_driver::install` decides whether what arrived is the right tree. This is the third, which
8//! is the statement of what the right one is, and it is the half that makes the other two mean
9//! anything.
10//!
11//! # Why it is here rather than with the two halves that use it
12//!
13//! Because it is read by something that cannot depend on the driver. The distribution manifest of
14//! section 13.5 is generated by a build tool, the same way `docs/TARGETS.md` and `tests/link-lines`
15//! are, and what it says about a target is what this table says plus what [`crate::Wall`] says. A
16//! build tool that pulled in the whole driver to read three strings would be a layer violation
17//! dressed up as convenience. It sits well here for a second reason as well: a pin is a fact about
18//! a sysroot, which is what this crate is for, and the fetch and the install are what a driver does
19//! with one.
20//!
21//! # Why the table is in the binary
22//!
23//! Because a hash that travels with the artifact is not a pin, and a hash in a file beside the
24//! compiler is a hash whoever replaces the artifact can replace too. The release is the authority
25//! for what an artifact of that release is, so the table is compiled into the release, which also
26//! means an upgrade can change a URL without anything on the machine having to be told.
27//!
28//! It is a table rather than a computed URL for the same reason. A name built out of a version and
29//! a tuple looks tidier and quietly says that every target's artifact is at a predictable address
30//! forever, which is a promise about somebody else's file server. A row per target costs three
31//! strings and says only what is true.
32//!
33//! # What is in it
34//!
35//! Three rows, which are the three windows-gnu targets. `bin/mingw-headers` in `tamnd/rucc-cross`
36//! installs mingw-w64 14.0.0's headers, `bin/mingw-runtime` builds the runtime and the import
37//! libraries into the `lib` directory beside them, `bin/artifact` packs the tree, and the release
38//! `sysroots-2026-09-21` is where the files are. The archives are 15.2 MiB for x86_64, 14.8 MiB for
39//! i686 and 12.5 MiB for aarch64, installing to 134 MB, 128 MB and 113 MB. The header half is the
40//! same 1702 files in all three, because mingw-w64 has no per architecture split and
41//! [`crate::Sysroot::splits_by_arch`] says so, and the `lib` half is what differs.
42//!
43//! Every other target is still unpublished, which is a statement about producers rather than about
44//! this table: `--fetch` of one says so by name, and the day a tree for it is published is the day a
45//! row for it is added here. The rows that are here are the first thing `--fetch` has ever had
46//! anything to move, so they are also what the fetch and the install are tested against.
47
48use std::path::{Path, PathBuf};
49
50/// One artifact: the sysroot for one target, as this release pins it.
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub struct Pinned {
53 /// The target it is the sysroot for, in the spelling that names its directory under the cache.
54 pub tuple: &'static str,
55 /// Where to get it. Handed to a downloader as it stands, and nothing here builds it out of
56 /// parts.
57 pub url: &'static str,
58 /// The sha256 of the archive, lowercase hex, which is what the bytes that arrive are held to.
59 pub sha256: &'static str,
60}
61
62impl Pinned {
63 /// The name to write the archive under, which is the last component of the URL.
64 ///
65 /// The URL's own name rather than one built out of the tuple, so that the file on disk is the
66 /// file the server served and a person comparing the two is comparing names as well as bytes.
67 #[must_use]
68 pub fn file_name(&self) -> &'static str {
69 self.url.rsplit('/').next().unwrap_or(self.url)
70 }
71
72 /// Where in the cache the archive is kept.
73 ///
74 /// Under the cache rather than in a temporary directory, because a machine with no downloader is
75 /// told this exact path and a second `--fetch` carries on from the check, which is section 13.8's
76 /// answer for a host that cannot reach the network at all. It is kept after the install for the
77 /// same reason and for one more: a fetch of a target that is already installed then moves
78 /// nothing and says so.
79 ///
80 /// The directory in front of the name is the first twelve characters of the hash, and it is
81 /// there because the name alone does not say which artifact this is. Two releases of a sysroot
82 /// for one target have the same file name, so a cache that kept the name alone would hold last
83 /// release's archive under the name this release wants, and a fetch refuses a file that does not
84 /// match rather than downloading over the top of it. That refusal is right for a file somebody
85 /// placed by hand and wrong for one we put there ourselves, so the fix is to stop the collision
86 /// rather than to soften the check. The hash is what has to change when the bytes change, so it
87 /// is the thing that separates them.
88 #[must_use]
89 pub fn archive_in(&self, cache: &Path) -> PathBuf {
90 cache.join("downloads").join(&self.sha256[..12]).join(self.file_name())
91 }
92}
93
94/// Every artifact this release pins, in tuple order.
95///
96/// A row is three strings and the test below says what they have to be. The order is the tuple's
97/// rather than the order they were published in, so that a row is found by reading down the column
98/// and two releases of this file diff as what changed between them.
99pub const PINNED: &[Pinned] = &[
100 Pinned {
101 tuple: "aarch64-windows-gnu",
102 url: "https://github.com/tamnd/rucc-cross/releases/download/sysroots-2026-09-21/rucc-sysroot-aarch64-windows-gnu.tar.gz",
103 sha256: "cc6be4263b09a475895d9054bb4b096d837010b7e5ed25ebc8934accccd5258e",
104 },
105 Pinned {
106 tuple: "i686-windows-gnu",
107 url: "https://github.com/tamnd/rucc-cross/releases/download/sysroots-2026-09-21/rucc-sysroot-i686-windows-gnu.tar.gz",
108 sha256: "296de7554f57d308c00b405c145eb314886e1e28ff8507aa6e7b34e7e4def7f1",
109 },
110 Pinned {
111 tuple: "x86_64-windows-gnu",
112 url: "https://github.com/tamnd/rucc-cross/releases/download/sysroots-2026-09-21/rucc-sysroot-x86_64-windows-gnu.tar.gz",
113 sha256: "2f1e34ea0ad3e1ae8be08c054c61030e0a916053e194916f4045e08dcfd69545",
114 },
115];
116
117/// The artifact this release pins for `tuple`, if it pins one.
118///
119/// The canonical spelling is what a row is named by, so the caller parses what the user wrote and
120/// asks with the tuple's own text rather than with theirs.
121#[must_use]
122pub fn pinned_for(tuple: &str) -> Option<&'static Pinned> {
123 look(PINNED, tuple)
124}
125
126/// Every target this release pins an artifact for, for a message that has to say what there is.
127#[must_use]
128pub fn pinned_targets() -> Vec<&'static str> {
129 PINNED.iter().map(|what| what.tuple).collect()
130}
131
132/// The same lookup over a table that is passed in, so what the lookup does is tested against rows
133/// that are written for it rather than against whatever [`PINNED`] happens to hold this release.
134fn look<'a>(table: &'a [Pinned], tuple: &str) -> Option<&'a Pinned> {
135 table.iter().find(|what| what.tuple == tuple)
136}
137
138#[cfg(test)]
139mod tests {
140 use std::path::PathBuf;
141
142 use rucc_tuple::TargetTuple;
143
144 use super::*;
145
146 /// A table with rows in it, which is what [`PINNED`] will look like.
147 const TABLE: &[Pinned] = &[
148 Pinned {
149 tuple: "aarch64-linux-musl",
150 url: "https://example.invalid/rucc-sysroot-aarch64-linux-musl.tar.gz",
151 sha256: "1111111111111111111111111111111111111111111111111111111111111111",
152 },
153 Pinned {
154 tuple: "x86_64-linux-musl",
155 url: "https://example.invalid/rucc-sysroot-x86_64-linux-musl.tar.gz",
156 sha256: "2222222222222222222222222222222222222222222222222222222222222222",
157 },
158 ];
159
160 #[test]
161 fn a_target_the_table_names_is_found_and_one_it_does_not_is_not() {
162 let found = look(TABLE, "x86_64-linux-musl").expect("the table has that one");
163 assert_eq!(found.sha256, TABLE[1].sha256);
164 assert_eq!(look(TABLE, "riscv64-linux-gnu"), None);
165 }
166
167 /// A tuple that starts with one the table has is a different target and not a match.
168 #[test]
169 fn a_longer_tuple_is_not_the_row_it_begins_with() {
170 assert_eq!(look(TABLE, "x86_64-linux-musl.1.2.5"), None);
171 assert_eq!(look(TABLE, "x86_64-linux"), None);
172 }
173
174 #[test]
175 fn the_archive_is_named_by_the_url_and_kept_under_the_cache() {
176 let what = TABLE[0];
177 assert_eq!(what.file_name(), "rucc-sysroot-aarch64-linux-musl.tar.gz");
178 assert_eq!(
179 what.archive_in(&PathBuf::from("/tmp/cache")),
180 PathBuf::from(
181 "/tmp/cache/downloads/111111111111/rucc-sysroot-aarch64-linux-musl.tar.gz"
182 )
183 );
184 }
185
186 /// Two releases of the sysroot for one target have the same file name, and the cache has to keep
187 /// them apart, because a fetch refuses a file under the artifact's name that is not the artifact.
188 #[test]
189 fn two_releases_of_one_target_are_not_the_same_path() {
190 let cache = PathBuf::from("/tmp/cache");
191 let old = TABLE[0];
192 let new = Pinned { sha256: TABLE[1].sha256, ..old };
193 assert_eq!(old.file_name(), new.file_name());
194 assert_ne!(old.archive_in(&cache), new.archive_in(&cache));
195 }
196
197 /// What every row of [`PINNED`] has to be.
198 ///
199 /// Left as a test rather than as a comment above the table, because the day somebody adds a row
200 /// is the day the rules stop being obvious, and a pasted hash with a capital letter in it or a
201 /// tuple spelled the way the URL spells it would otherwise be found by a user.
202 #[test]
203 fn every_row_is_a_target_a_url_and_a_hash() {
204 for what in PINNED {
205 let tuple: TargetTuple =
206 what.tuple.parse().unwrap_or_else(|why| panic!("{}: {why}", what.tuple));
207 assert_eq!(
208 tuple.to_canonical_string(),
209 what.tuple,
210 "a row is named by the canonical spelling, because that is what names the \
211 directory the tree is installed at"
212 );
213 assert!(what.url.starts_with("https://"), "{}: {}", what.tuple, what.url);
214 // A query string or a fragment would make the file name something other than the last
215 // component of the URL, which is the one thing the name is read out of.
216 assert!(!what.url.contains('?') && !what.url.contains('#'), "{}", what.url);
217 assert!(!what.file_name().is_empty(), "{} ends with a separator", what.url);
218 assert_eq!(what.sha256.len(), 64, "{}: {}", what.tuple, what.sha256);
219 assert!(
220 what.sha256.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)),
221 "{}: {} is not lowercase hex, and the check compares text",
222 what.tuple,
223 what.sha256
224 );
225 }
226 let mut sorted: Vec<&str> = pinned_targets();
227 sorted.sort_unstable();
228 sorted.dedup();
229 assert_eq!(sorted, pinned_targets(), "the rows are in tuple order and each target once");
230 }
231}