Skip to main content

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