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//! Eighteen rows, which are the three windows-gnu targets, four musl ones, eight glibc ones and three WASI ones. For windows-gnu,
36//! `bin/mingw-headers` in `tamnd/rucc-cross` installs mingw-w64 14.0.0's headers, `bin/mingw-runtime`
37//! builds the runtime, the import libraries and a static winpthreads into the `lib` directory beside
38//! them, `bin/artifact` packs the tree, and the release `sysroots-2026-09-29` is where the files are.
39//! They are built for the UCRT, and the same release has a `-msvcrt` archive of each that nothing
40//! here pins. The archives are 15.2 MiB for x86_64, 14.9 MiB for i686 and 12.6 MiB for aarch64,
41//! installing to 141 MB, 135 MB and 119 MB. The header half is the same in all three, because
42//! mingw-w64 has no per architecture split and [`crate::Sysroot::splits_by_arch`] says so, and the
43//! `lib` half is what differs.
44//!
45//! For musl, `bin/sysroot` builds musl 1.2.6 for x86_64, aarch64, riscv64 and armv7 hard float, and
46//! the release `sysroots-2026-10-14` has the four archives, each about 2 MiB and installing to between
47//! 7 and 13 MB. They hold musl's headers and its static libraries and start files, and not the Linux
48//! uapi headers, which are [`KERNEL_HEADERS`] and fetched once for every Linux target. Both runs that
49//! produced them, on two machines, packed the same bytes.
50//!
51//! For glibc, `bin/glibc-sysroot` puts together the merged header tree of every pinned release from
52//! 2.28 to 2.44 with glibc 2.44's start files and `libc_nonshared.a`, for x86_64, i686, aarch64,
53//! armv7 hard float, riscv64, powerpc64le, s390x and loongarch64, and the release
54//! `sysroots-2026-10-14b` has the eight archives, each about 600 KB. The seven whose ABI has a glibc
55//! older than 2.33 also carry `libc_nonshared_stat.a`, the `stat` family those releases kept in
56//! `libc_nonshared.a`, which [`crate::link::LinkLine::glibc`] adds for a pin before 2.33. There is no `libc.so` in them,
57//! because the driver writes the stubs itself, and one archive serves every release of its target,
58//! which is [`pinned_for_target`]. Each family's `gnu/stubs.h` has the `__stub_` names that Debian's
59//! build of each release lists, guarded by release, so `__stub_lchmod` is there for x86_64 before
60//! 2.34 and not after. server3 built the archives, and server1 built the same header trees and the
61//! same `libc_nonshared_stat.a`. Every file but the `gnu/stubs` ones is the same as in
62//! `sysroots-2026-09-24b`, which server2 and server3 built to the same bytes.
63//!
64//! For WASI, `bin/wasi-sysroot` takes the C part of one preview out of the sysroot of wasi-sdk 34, `wasi-sysroot-34.0.tar.gz`, which it fetches by its sha256 `9d813544`. rucc does not build wasi-libc, because the bytes clang users link against are the bytes a rucc object has to link against too. The upstream archive is 119 MB and holds libc++ for four targets and two exception models, so the C headers, the static archives and the three start files of each preview are packed on their own, and the release `sysroots-2026-10-05` has the three archives, each about 1.5 MB. Every file in them is upstream's byte for byte. server2 and server3 produced the same bytes.
65//!
66//! Every other target is still unpublished, which is a statement about producers rather than about
67//! this table: `--fetch` of one says so by name, and the day a tree for it is published is the day a
68//! row for it is added here. The rows that are here are the first thing `--fetch` has ever had
69//! anything to move, so they are also what the fetch and the install are tested against.
70
71use std::path::{Path, PathBuf};
72
73use rucc_tuple::{Env, Os, TargetTuple};
74
75/// One artifact: the sysroot for one target, as this release pins it.
76///
77/// Or the kernel header tree, which is [`KERNEL_HEADERS`] and the one artifact that is not any
78/// target's.
79#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80pub struct Pinned {
81 /// The target it is the sysroot for, in the spelling that names its directory under the cache,
82 /// or `kernel-headers` for the tree every Linux target shares, which is that tree's directory.
83 pub tuple: &'static str,
84 /// Where to get it. Handed to a downloader as it stands, and nothing here builds it out of
85 /// parts.
86 pub url: &'static str,
87 /// The sha256 of the archive, lowercase hex, which is what the bytes that arrive are held to.
88 pub sha256: &'static str,
89}
90
91impl Pinned {
92 /// The name to write the archive under, which is the last component of the URL.
93 ///
94 /// The URL's own name rather than one built out of the tuple, so that the file on disk is the
95 /// file the server served and a person comparing the two is comparing names as well as bytes.
96 #[must_use]
97 pub fn file_name(&self) -> &'static str {
98 self.url.rsplit('/').next().unwrap_or(self.url)
99 }
100
101 /// Where in the cache the archive is kept.
102 ///
103 /// Under the cache rather than in a temporary directory, because a machine with no downloader is
104 /// told this exact path and a second `--fetch` carries on from the check, which is section 13.8's
105 /// answer for a host that cannot reach the network at all. It is kept after the install for the
106 /// same reason and for one more: a fetch of a target that is already installed then moves
107 /// nothing and says so.
108 ///
109 /// The directory in front of the name is the first twelve characters of the hash, and it is
110 /// there because the name alone does not say which artifact this is. Two releases of a sysroot
111 /// for one target have the same file name, so a cache that kept the name alone would hold last
112 /// release's archive under the name this release wants, and a fetch refuses a file that does not
113 /// match rather than downloading over the top of it. That refusal is right for a file somebody
114 /// placed by hand and wrong for one we put there ourselves, so the fix is to stop the collision
115 /// rather than to soften the check. The hash is what has to change when the bytes change, so it
116 /// is the thing that separates them.
117 #[must_use]
118 pub fn archive_in(&self, cache: &Path) -> PathBuf {
119 cache.join("downloads").join(&self.sha256[..12]).join(self.file_name())
120 }
121}
122
123/// Every artifact this release pins, in tuple order.
124///
125/// A row is three strings and the test below says what they have to be. The order is the tuple's
126/// rather than the order they were published in, so that a row is found by reading down the column
127/// and two releases of this file diff as what changed between them.
128pub const PINNED: &[Pinned] = &[
129 Pinned {
130 tuple: "aarch64-linux-gnu",
131 url: "https://github.com/tamnd/rucc-cross/releases/download/sysroots-2026-10-14b/rucc-sysroot-aarch64-linux-gnu.tar.gz",
132 sha256: "a67ed17c461aa8b589a50ece0a2a39ac38f76ea8d67f7c4d049768276c91e26f",
133 },
134 Pinned {
135 tuple: "aarch64-linux-musl",
136 url: "https://github.com/tamnd/rucc-cross/releases/download/sysroots-2026-10-14/rucc-sysroot-aarch64-linux-musl.tar.gz",
137 sha256: "17db6aa2eab617f2ff43577b4afdd0b23457b676329cefe3667b2cc971022e5f",
138 },
139 Pinned {
140 tuple: "aarch64-windows-gnu",
141 url: "https://github.com/tamnd/rucc-cross/releases/download/sysroots-2026-09-29/rucc-sysroot-aarch64-windows-gnu.tar.gz",
142 sha256: "b304bbbe1a11eaf9b41f9be810c27a3cf4965699872e71e2b703a59941db7aaf",
143 },
144 Pinned {
145 tuple: "armv7-linux-gnueabihf",
146 url: "https://github.com/tamnd/rucc-cross/releases/download/sysroots-2026-10-14b/rucc-sysroot-armv7-linux-gnueabihf.tar.gz",
147 sha256: "9d025bcc030d6cdbc1190c61db7b8b14607b4c32fc33e4e1bf34cdc0d5f75a48",
148 },
149 Pinned {
150 tuple: "armv7-linux-musleabihf",
151 url: "https://github.com/tamnd/rucc-cross/releases/download/sysroots-2026-10-14/rucc-sysroot-armv7-linux-musleabihf.tar.gz",
152 sha256: "ba5bbe85a4e4609b06628b89511c020fbcfc33534d8f8a9dcfdd3c86b2cd9f96",
153 },
154 Pinned {
155 tuple: "i686-linux-gnu",
156 url: "https://github.com/tamnd/rucc-cross/releases/download/sysroots-2026-10-14b/rucc-sysroot-i686-linux-gnu.tar.gz",
157 sha256: "03bb24908c6090da01685f52550158564bf9cd71945cae03b0bdd75ebf72238e",
158 },
159 Pinned {
160 tuple: "i686-windows-gnu",
161 url: "https://github.com/tamnd/rucc-cross/releases/download/sysroots-2026-09-29/rucc-sysroot-i686-windows-gnu.tar.gz",
162 sha256: "cea70fbedd27aa9cdc08c335105714747919f09f72b8f33ec5d49c659b3526b1",
163 },
164 Pinned {
165 tuple: "loongarch64-linux-gnu",
166 url: "https://github.com/tamnd/rucc-cross/releases/download/sysroots-2026-10-14b/rucc-sysroot-loongarch64-linux-gnu.tar.gz",
167 sha256: "f8b098fb57259980bca8706feac0397c257372e446808f445c4f4987636a14fc",
168 },
169 Pinned {
170 tuple: "powerpc64le-linux-gnu",
171 url: "https://github.com/tamnd/rucc-cross/releases/download/sysroots-2026-10-14b/rucc-sysroot-powerpc64le-linux-gnu.tar.gz",
172 sha256: "33e37324413cd24e7baaf4728fd85752813e7c037ec8d07d0781e39173338a35",
173 },
174 Pinned {
175 tuple: "riscv64-linux-gnu",
176 url: "https://github.com/tamnd/rucc-cross/releases/download/sysroots-2026-10-14b/rucc-sysroot-riscv64-linux-gnu.tar.gz",
177 sha256: "a7d64d533a0c3fd6ed493b8078e5c1ae7c51dbc1f06eed3bd3df542c68bdf215",
178 },
179 Pinned {
180 tuple: "riscv64-linux-musl",
181 url: "https://github.com/tamnd/rucc-cross/releases/download/sysroots-2026-10-14/rucc-sysroot-riscv64-linux-musl.tar.gz",
182 sha256: "4d138cf2329c88e3e9c46b1c71078ee4f9635a41cbbf88120606a0314953743c",
183 },
184 Pinned {
185 tuple: "s390x-linux-gnu",
186 url: "https://github.com/tamnd/rucc-cross/releases/download/sysroots-2026-10-14b/rucc-sysroot-s390x-linux-gnu.tar.gz",
187 sha256: "7d849a4dffe5a7af1124df940630b0c6abeb9487e6bc5881d8a7c63a4473a311",
188 },
189 Pinned {
190 tuple: "wasm32-wasip1",
191 url: "https://github.com/tamnd/rucc-cross/releases/download/sysroots-2026-10-05/rucc-sysroot-wasm32-wasip1.tar.gz",
192 sha256: "4a477d560f270d965d8fa416cf199e564c16331cf768f5fefc9f45378e51ada9",
193 },
194 Pinned {
195 tuple: "wasm32-wasip2",
196 url: "https://github.com/tamnd/rucc-cross/releases/download/sysroots-2026-10-05/rucc-sysroot-wasm32-wasip2.tar.gz",
197 sha256: "09d580524d79ec6ed5cc1179a5baba40fbfd724798f17e1cb3ad7a61d9f6f159",
198 },
199 Pinned {
200 tuple: "wasm32-wasip3",
201 url: "https://github.com/tamnd/rucc-cross/releases/download/sysroots-2026-10-05/rucc-sysroot-wasm32-wasip3.tar.gz",
202 sha256: "48fc4a3bea00c2c7d625dfa9bc53e153db0f2183924783fa137ffc9f31383378",
203 },
204 Pinned {
205 tuple: "x86_64-linux-gnu",
206 url: "https://github.com/tamnd/rucc-cross/releases/download/sysroots-2026-10-14b/rucc-sysroot-x86_64-linux-gnu.tar.gz",
207 sha256: "3a7dfa6ddac1ca1f4fcf43c5c9cc34ca5a4cdc092addf575b8036e98a1511d98",
208 },
209 Pinned {
210 tuple: "x86_64-linux-musl",
211 url: "https://github.com/tamnd/rucc-cross/releases/download/sysroots-2026-10-14/rucc-sysroot-x86_64-linux-musl.tar.gz",
212 sha256: "e009c42e3bee375682c176af347f98eb42825cbef328e709e65ea9bf3ca82822",
213 },
214 Pinned {
215 tuple: "x86_64-windows-gnu",
216 url: "https://github.com/tamnd/rucc-cross/releases/download/sysroots-2026-09-29/rucc-sysroot-x86_64-windows-gnu.tar.gz",
217 sha256: "9f3919c9bee5447380a9746759439e051087b2364084447d6481fb9425c93758",
218 },
219];
220
221/// The Linux uapi header tree, which every Linux target reads beside its own sysroot.
222///
223/// One archive rather than a part of each Linux sysroot, for the reason [`crate::Kernel`] gives
224/// about the directory: the tree is the same for every Linux row except a small `asm/` per
225/// architecture, so a copy in every sysroot would be the same eleven megabytes once per target.
226/// `bin/kernel-headers` in `tamnd/rucc-cross` produces it and `bin/artifact kernel-headers` packs it,
227/// and `--fetch` of a Linux target installs it after the sysroot when it is not there already.
228pub const KERNEL_HEADERS: Pinned = Pinned {
229 tuple: "kernel-headers",
230 url: "https://github.com/tamnd/rucc-cross/releases/download/sysroots-2026-09-23/rucc-kernel-headers.tar.gz",
231 sha256: "e96934f553df19d6bdbb6804f1b52d079adc4bea10a819d51eda5bc4ec584f2d",
232};
233
234/// The artifact this release pins for `tuple`, if it pins one.
235///
236/// The canonical spelling is what a row is named by, so the caller parses what the user wrote and
237/// asks with the tuple's own text rather than with theirs.
238#[must_use]
239pub fn pinned_for(tuple: &str) -> Option<&'static Pinned> {
240 look(PINNED, tuple)
241}
242
243/// The artifact this release pins for a target, which for a glibc tuple that names a release is the
244/// one for the same tuple without it.
245///
246/// One glibc sysroot serves every release, because the headers in it pick the release with
247/// `__GLIBC_MINOR__` and the libraries a link reads are the stubs the driver writes for the release
248/// asked for. So `x86_64-linux-gnu.2.28` and `x86_64-linux-gnu` fetch the same archive, and it is
249/// installed under the pinned spelling because that is the directory the compiler reads.
250#[must_use]
251pub fn pinned_for_target(target: TargetTuple) -> Option<&'static Pinned> {
252 pinned_for(&target.to_canonical_string())
253 .or_else(|| pinned_for(&glibc_base(target)?.to_canonical_string()))
254}
255
256/// The same glibc target with its release left off, or [`None`] for a target that is not glibc or
257/// names no release.
258///
259/// What [`pinned_for_target`] looks a pinned release up under, and what the install accepts as the
260/// target of an archive fetched for one.
261#[must_use]
262pub fn glibc_base(target: TargetTuple) -> Option<TargetTuple> {
263 if target.os() != Os::Linux || target.env() != Env::Gnu {
264 return None;
265 }
266 target.env_version()?;
267 let mut base = TargetTuple::builder(target.arch(), target.os())
268 .sub_arch(target.sub_arch())
269 .endian(target.endian())
270 .data_model(target.data_model())
271 .env(target.env())
272 .abi(target.abi());
273 if let Some(version) = target.os_version() {
274 base = base.os_version(version);
275 }
276 base.build().ok()
277}
278
279/// Every target this release pins an artifact for, for a message that has to say what there is.
280#[must_use]
281pub fn pinned_targets() -> Vec<&'static str> {
282 PINNED.iter().map(|what| what.tuple).collect()
283}
284
285/// The same lookup over a table that is passed in, so what the lookup does is tested against rows
286/// that are written for it rather than against whatever [`PINNED`] happens to hold this release.
287fn look<'a>(table: &'a [Pinned], tuple: &str) -> Option<&'a Pinned> {
288 table.iter().find(|what| what.tuple == tuple)
289}
290
291#[cfg(test)]
292mod tests {
293 use std::path::PathBuf;
294
295 use super::*;
296
297 #[test]
298 fn a_pinned_glibc_release_shares_the_artifact_of_its_tuple() {
299 let tuple = |spelling: &str| spelling.parse::<TargetTuple>().expect("a tuple");
300 let base = glibc_base(tuple("x86_64-linux-gnu.2.28")).expect("glibc with a release");
301 assert_eq!(base.to_canonical_string(), "x86_64-linux-gnu");
302 let arm = glibc_base(tuple("armv7-linux-gnu.2.31eabihf")).expect("glibc with a release");
303 assert_eq!(arm.to_canonical_string(), "armv7-linux-gnueabihf");
304 // No release, or not glibc, is nothing to fall back to.
305 assert_eq!(glibc_base(tuple("x86_64-linux-gnu")), None);
306 assert_eq!(glibc_base(tuple("x86_64-linux-musl")), None);
307 // And a musl row is still found under its own name.
308 assert_eq!(
309 pinned_for_target(tuple("x86_64-linux-musl")).map(|what| what.tuple),
310 Some("x86_64-linux-musl")
311 );
312 }
313
314 /// A table with rows in it, which is what [`PINNED`] will look like.
315 const TABLE: &[Pinned] = &[
316 Pinned {
317 tuple: "aarch64-linux-musl",
318 url: "https://example.invalid/rucc-sysroot-aarch64-linux-musl.tar.gz",
319 sha256: "1111111111111111111111111111111111111111111111111111111111111111",
320 },
321 Pinned {
322 tuple: "x86_64-linux-musl",
323 url: "https://example.invalid/rucc-sysroot-x86_64-linux-musl.tar.gz",
324 sha256: "2222222222222222222222222222222222222222222222222222222222222222",
325 },
326 ];
327
328 #[test]
329 fn a_target_the_table_names_is_found_and_one_it_does_not_is_not() {
330 let found = look(TABLE, "x86_64-linux-musl").expect("the table has that one");
331 assert_eq!(found.sha256, TABLE[1].sha256);
332 assert_eq!(look(TABLE, "riscv64-linux-gnu"), None);
333 }
334
335 /// A tuple that starts with one the table has is a different target and not a match.
336 #[test]
337 fn a_longer_tuple_is_not_the_row_it_begins_with() {
338 assert_eq!(look(TABLE, "x86_64-linux-musl.1.2.5"), None);
339 assert_eq!(look(TABLE, "x86_64-linux"), None);
340 }
341
342 #[test]
343 fn the_archive_is_named_by_the_url_and_kept_under_the_cache() {
344 let what = TABLE[0];
345 assert_eq!(what.file_name(), "rucc-sysroot-aarch64-linux-musl.tar.gz");
346 assert_eq!(
347 what.archive_in(&PathBuf::from("/tmp/cache")),
348 PathBuf::from(
349 "/tmp/cache/downloads/111111111111/rucc-sysroot-aarch64-linux-musl.tar.gz"
350 )
351 );
352 }
353
354 /// Two releases of the sysroot for one target have the same file name, and the cache has to keep
355 /// them apart, because a fetch refuses a file under the artifact's name that is not the artifact.
356 #[test]
357 fn two_releases_of_one_target_are_not_the_same_path() {
358 let cache = PathBuf::from("/tmp/cache");
359 let old = TABLE[0];
360 let new = Pinned { sha256: TABLE[1].sha256, ..old };
361 assert_eq!(old.file_name(), new.file_name());
362 assert_ne!(old.archive_in(&cache), new.archive_in(&cache));
363 }
364
365 /// What every row of [`PINNED`] has to be.
366 ///
367 /// Left as a test rather than as a comment above the table, because the day somebody adds a row
368 /// is the day the rules stop being obvious, and a pasted hash with a capital letter in it or a
369 /// tuple spelled the way the URL spells it would otherwise be found by a user.
370 #[test]
371 fn every_row_is_a_target_a_url_and_a_hash() {
372 for what in PINNED {
373 let tuple: TargetTuple =
374 what.tuple.parse().unwrap_or_else(|why| panic!("{}: {why}", what.tuple));
375 assert_eq!(
376 tuple.to_canonical_string(),
377 what.tuple,
378 "a row is named by the canonical spelling, because that is what names the \
379 directory the tree is installed at"
380 );
381 assert!(what.url.starts_with("https://"), "{}: {}", what.tuple, what.url);
382 // A query string or a fragment would make the file name something other than the last
383 // component of the URL, which is the one thing the name is read out of.
384 assert!(!what.url.contains('?') && !what.url.contains('#'), "{}", what.url);
385 assert!(!what.file_name().is_empty(), "{} ends with a separator", what.url);
386 assert_eq!(what.sha256.len(), 64, "{}: {}", what.tuple, what.sha256);
387 assert!(
388 what.sha256.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)),
389 "{}: {} is not lowercase hex, and the check compares text",
390 what.tuple,
391 what.sha256
392 );
393 }
394 let mut sorted: Vec<&str> = pinned_targets();
395 sorted.sort_unstable();
396 sorted.dedup();
397 assert_eq!(sorted, pinned_targets(), "the rows are in tuple order and each target once");
398 }
399
400 /// The kernel tree's row is held to the same rules as a target's, except that its name is the
401 /// directory it is installed at rather than a tuple.
402 #[test]
403 fn the_kernel_tree_is_a_url_and_a_hash_and_no_target_is_called_that() {
404 let what = KERNEL_HEADERS;
405 assert!(what.url.starts_with("https://"), "{}", what.url);
406 assert!(!what.url.contains('?') && !what.url.contains('#'), "{}", what.url);
407 assert_eq!(what.file_name(), "rucc-kernel-headers.tar.gz");
408 assert_eq!(what.sha256.len(), 64, "{}", what.sha256);
409 assert!(
410 what.sha256.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)),
411 "{} is not lowercase hex",
412 what.sha256
413 );
414 assert_eq!(pinned_for(what.tuple), None);
415 }
416}