Skip to main content

rucc_driver/
msvc.rs

1//! Getting the Windows SDK and the MSVC CRT onto this machine, with the licence said out loud and
2//! accepted first.
3//!
4//! Design: `spec/cross-compile/13-distribution.md` section 13.4. Neither of those two things is
5//! ours to redistribute, and neither ever will be, so there is no artifact a release of this
6//! compiler pins for an MSVC target and `rucc --fetch` of one says so. What Microsoft does publish
7//! is a manifest naming every file its own installer would fetch, and a licence that lets a person
8//! who accepts it fetch them, which is the mechanism `cargo-xwin` uses and the one copied here.
9//!
10//! [`rucc_sysroot::msvc`] is the reading half: it takes the two documents and says which files a
11//! compiler needs out of the nineteen thousand packages in them. This is the half that moves bytes.
12//! It prints the licence, refuses to go on without explicit acceptance, downloads the selection into
13//! the cache with every file held against the hash the manifest gives for it, and then lays the
14//! downloads out as the tree `--sysroot` reads. [`tree`] is the mapping from a file in a package to
15//! its place in that tree, and [`rucc_unpack`] is the four readers a download is four formats deep
16//! of.
17//!
18//! # Why the tree is per target
19//!
20//! Because the record of it is. `spec/cross-compile/13-distribution.md` section 13.5 asks for a
21//! manifest saying where every file came from and what may be done with it, [`Manifest`] is that
22//! record, and it carries one target. A tree that served three architectures would carry a record
23//! that named one of them and said nothing about the other two.
24//!
25//! What that costs is the headers, which are the same for every architecture and are written again
26//! under each target that is fetched. That is 78 MB of the 330 MB a target comes to, measured on the
27//! September 2026 kit, and it is only paid by somebody who fetched more than one architecture on one
28//! machine. The libraries, which are the larger half, were never shared.
29//!
30//! # The two downloads with no hash behind them
31//!
32//! The channel manifest is the root of the chain, so nothing above it could name its hash. The
33//! installer manifest has a hash published for it in the channel and that hash does not match the
34//! file served at the URL the channel names in the same breath, which was measured rather than
35//! assumed and is written down in section 13.4. Both go through [`crate::fetch::trusted`], which
36//! carries that weaker claim in its name. Every file after them goes through [`crate::fetch::fetch`]
37//! with the hash the installer manifest gives for it, and those hashes are exact.
38//!
39//! # Why the manifests are fetched before the licence is accepted
40//!
41//! Because the licence is in them. The channel manifest is where the link to the Build Tools
42//! licence comes from, so a run that printed a licence without fetching it would be printing a URL
43//! this compiler had made up and kept up to date by hand. The two documents are Microsoft's
44//! description of what it publishes rather than the things the licence is about, and what the
45//! acceptance guards is the download of the files themselves, which is what happens after it.
46
47pub mod tree;
48
49use std::collections::BTreeMap;
50use std::path::{Path, PathBuf};
51
52use rucc_sysroot::msvc::{Channel, Chip, Selection, Wanted};
53use rucc_sysroot::{Input, Licence, Manifest, Provenance, Sysroot, sha256};
54use rucc_tuple::TargetTuple;
55use rucc_unpack::cab::File as CabFile;
56use rucc_unpack::{Cab, Cfb, Msi, Zip, under};
57
58use crate::fetch;
59use crate::{CliError, err};
60
61/// The channel manifest, which is where every run starts.
62///
63/// 17 is the Visual Studio version and `release` is the channel, which together are the current
64/// shipping Build Tools. It is a redirector rather than a storage URL on purpose, so that it keeps
65/// naming the current one as builds come and go, which is also why nothing here pins a build.
66pub const CHANNEL: &str = "https://aka.ms/vs/17/release/channel";
67
68/// Where a run keeps what it downloaded.
69///
70/// Under the cache like everything else, and under the build rather than beside it, because two
71/// builds of the Visual Studio installer name different files and a person who fetched one and then
72/// the other should have both rather than a directory that is half of each.
73fn downloads(cache: &Path, build: &str) -> PathBuf {
74    cache.join("downloads").join("msvc").join(build)
75}
76
77/// The file name a payload is stored as.
78///
79/// The SDK's payloads are named `Installers\Windows SDK Desktop Headers x64-x86_en-us.msi`, with a
80/// backslash in them, because the manifest names them the way the installer lays them out on a
81/// Windows machine. A backslash is an ordinary character in a file name on a Unix, so writing that
82/// name to disk unchanged would give one file with a slash-looking name here and a directory there,
83/// and the two hosts would disagree about what is in the cache. So the last component is taken, by
84/// either separator, and the directory it sat in is dropped.
85fn stored_as(name: &str) -> &str {
86    name.rsplit(['\\', '/']).next().unwrap_or(name)
87}
88
89/// A size a person can read, which is what a number this large is for here.
90///
91/// The number is Microsoft's rather than a count of bytes that arrived, and for the CRT half of the
92/// selection it is a little over: the headers package is served 12,754 bytes smaller than the
93/// manifest says and the x86-64 one 745,102 smaller, while every SDK payload sampled is served at
94/// exactly its declared size, and all of their hashes match. Section 13.4 records that. It is a
95/// figure to tell somebody what they are about to download and nothing is held against it, so being
96/// a per cent over on the CRT half of the selection is not worth a second source.
97fn mb(bytes: u64) -> String {
98    format!("{:.1} MB", bytes as f64 / 1_000_000.0)
99}
100
101/// Fetch the SDK for `target`, having printed the licence and been told it is accepted.
102///
103/// The exit code, since this is what an action runs and there is nothing above it to return an
104/// error to. A run that has not been given the acceptance prints the licence and what would be
105/// downloaded and exits non-zero, because it did not do what it was asked to do.
106pub fn fetch_msvc_sdk(target: TargetTuple, accepted: bool, cache: &Path) -> i32 {
107    let tuple = target.to_canonical_string();
108    match run(target, &tuple, accepted, cache) {
109        Ok(code) => code,
110        Err(why) => crate::complain(why),
111    }
112}
113
114/// The same, as something that can fail in the ordinary way.
115fn run(target: TargetTuple, tuple: &str, accepted: bool, cache: &Path) -> Result<i32, CliError> {
116    let say = |line: &str| println!("rucc: {tuple}: {line}");
117
118    // Which targets this is for is `Wall::of` rather than a second list of the ones it covers, the
119    // same as everywhere else the two walls come up. A mingw-w64 target is refused here and is not
120    // an oversight: its headers are ours, they are in the archive or a release pins them, and
121    // `--fetch` is the command that gets them.
122    if rucc_sysroot::Wall::of(target) != Some(rucc_sysroot::Wall::Microsoft) {
123        return Err(err(format!(
124            "--fetch-msvc-sdk gets what is behind Microsoft's licence wall, and {tuple} is not \
125             behind it, so there is nothing here to get for it. `rucc --fetch {tuple}` is the \
126             command that gets a sysroot this release pins"
127        )));
128    }
129    let Some(chip) = Chip::of(target) else {
130        return Err(err(format!(
131            "--fetch-msvc-sdk {tuple}: Microsoft publishes the SDK for x86, x86-64, arm and \
132             arm64, and {tuple} is none of those, so there is nothing in the manifest to get for it"
133        )));
134    };
135
136    let dir = cache.join("downloads").join("msvc");
137    let channel = dir.join("channel.json");
138    // Always downloaded rather than read from the cache. It is the pointer to the current build, so
139    // a cached copy is an answer to a question about the day it was fetched.
140    fetch::trusted(CHANNEL, &channel)?;
141    let text = read(&channel)?;
142    let channel = Channel::parse(&text)
143        .map_err(|why| err(format!("{CHANNEL} is not a channel manifest: {why}")))?;
144    say(&format!("Visual Studio {}, build {}", channel.release, channel.build));
145
146    let dir = downloads(cache, &channel.build);
147    let manifest = dir.join(stored_as(&channel.manifest.name));
148    // Named by the build, so a second run for another architecture reads the one already here
149    // rather than moving eighteen megabytes again. A copy that does not parse is a run that was
150    // interrupted, and it is downloaded again once rather than reported, because there is no hash
151    // to tell a truncated file from a Microsoft that changed its mind.
152    let mut text = if manifest.exists() { read(&manifest).ok() } else { None };
153    if text.as_deref().and_then(|text| Selection::parse(text, &[chip]).ok()).is_none() {
154        fetch::trusted(&channel.manifest.url, &manifest)?;
155        text = Some(read(&manifest)?);
156    }
157    let text = text.unwrap_or_default();
158    let chosen = Selection::parse(&text, &[chip]).map_err(|why| {
159        err(format!("{} is not an installer manifest: {why}", channel.manifest.url))
160    })?;
161    say(&format!("MSVC CRT {} and Windows SDK {}", chosen.crt, chosen.sdk));
162
163    if !accepted {
164        refuse(&channel.licence, &chosen, tuple);
165        return Ok(1);
166    }
167
168    say(&format!("the licence at {} was accepted on the command line", channel.licence));
169    let mut had = 0;
170    for file in &chosen.files {
171        let at = dir.join(&file.payload.sha256[..12]).join(stored_as(&file.payload.name));
172        match fetch::fetch(&file.payload.url, &file.payload.sha256, &at)? {
173            fetch::Fetched::AlreadyThere => had += 1,
174            fetch::Fetched::Downloaded(by) => {
175                say(&format!(
176                    "{} ({}) with {}",
177                    stored_as(&file.payload.name),
178                    mb(file.payload.size),
179                    by.program()
180                ));
181            }
182        }
183    }
184    if had > 0 {
185        say(&format!("{had} of the {} files were already here", chosen.files.len()));
186    }
187    say(&format!(
188        "{} files totalling {} are at {}",
189        chosen.files.len(),
190        mb(chosen.size()),
191        dir.display()
192    ));
193
194    let tree = unpack(target, chip, &chosen, &dir, cache, &say)?;
195    say(&format!("compile for {tuple} with --sysroot={}", tree.display()));
196    Ok(0)
197}
198
199/// Lay the downloaded packages out as the tree `--sysroot` reads, and record what went into it.
200///
201/// The record is written last and is what says the tree is finished, so a run that was interrupted
202/// leaves a directory with no manifest in it and the next run lays it out again from the top. That
203/// is cheaper than it sounds, because the downloads are held and nothing is fetched twice, and it is
204/// the only test available: the files in the tree have no hashes published for them, only the
205/// packages they came out of do, so there is nothing to hold a half written tree against.
206fn unpack(
207    target: TargetTuple,
208    chip: Chip,
209    chosen: &Selection,
210    from: &Path,
211    cache: &Path,
212    say: &dyn Fn(&str),
213) -> Result<PathBuf, CliError> {
214    let version = format!("{}-{}", chosen.crt, chosen.sdk);
215    let root = cache.join("msvc").join(version).join(target.to_canonical_string());
216    let record = Sysroot::at(root.clone(), target).manifest_path();
217    if std::fs::read_to_string(&record).is_ok_and(|text| Manifest::parse(&text).is_ok()) {
218        say(&format!("the tree at {} was laid out already", root.display()));
219        return Ok(root);
220    }
221    if root.exists() {
222        std::fs::remove_dir_all(&root).map_err(|why| err(format!("{}: {why}", root.display())))?;
223    }
224
225    let mut manifest = Manifest::new(target);
226    for file in &chosen.files {
227        let at = from.join(&file.payload.sha256[..12]).join(stored_as(&file.payload.name));
228        let bytes = slurp(&at)?;
229        if stored_as(&file.payload.name).to_ascii_lowercase().ends_with(".msi") {
230            from_msi(&bytes, chip, &root, file, chosen, from, &mut manifest)?;
231        } else {
232            from_vsix(&bytes, chip, &root, file, &mut manifest)?;
233        }
234    }
235
236    let written = manifest.inputs().len();
237    let alike = aliases(&root)?;
238    std::fs::create_dir_all(&root).map_err(|why| err(format!("{}: {why}", root.display())))?;
239    std::fs::write(&record, manifest.render())
240        .map_err(|why| err(format!("{}: {why}", record.display())))?;
241    say(&format!("{written} files and {alike} lowercase names are at {}", root.display()));
242    Ok(root)
243}
244
245/// Lay out the members of one Visual C++ package.
246fn from_vsix(
247    bytes: &[u8],
248    chip: Chip,
249    root: &Path,
250    file: &Wanted,
251    manifest: &mut Manifest,
252) -> Result<(), CliError> {
253    let name = stored_as(&file.payload.name);
254    let zip = Zip::read(bytes).map_err(|why| err(format!("{name}: {why}")))?;
255    for member in zip.members() {
256        if member.is_dir() {
257            continue;
258        }
259        let Some(at) = tree::crt(&member.name, chip) else {
260            continue;
261        };
262        let body = zip.contents(member).map_err(|why| err(format!("{name}: {why}")))?;
263        put(root, &at, &body, file, &file.payload.url, manifest)?;
264    }
265    Ok(())
266}
267
268/// Lay out the files one Windows SDK installer describes, fetching the cabinets they are in.
269///
270/// An installer holds no bytes of its own, so this is two steps rather than one: read the tables to
271/// find out which cabinet every file it describes is in and what that cabinet calls it, then get the
272/// cabinets that hold something this target wants. Most of them hold nothing it wants. The store
273/// apps headers installer names three cabinets and the universal CRT one names eleven, and which of
274/// those are worth 484 MB of downloading is a question only the tables can answer, which is why
275/// [`Selection::cabs`] carries all of them and this picks.
276fn from_msi(
277    bytes: &[u8],
278    chip: Chip,
279    root: &Path,
280    file: &Wanted,
281    chosen: &Selection,
282    from: &Path,
283    manifest: &mut Manifest,
284) -> Result<(), CliError> {
285    let name = stored_as(&file.payload.name);
286    let compound = Cfb::read(bytes).map_err(|why| err(format!("{name}: {why}")))?;
287    let installer = Msi::read(&compound).map_err(|why| err(format!("{name}: {why}")))?;
288    let describes = installer.payload().map_err(|why| err(format!("{name}: {why}")))?;
289
290    let mut wanted: BTreeMap<&str, Vec<(&str, String)>> = BTreeMap::new();
291    for payload in &describes {
292        let Some(at) = tree::sdk(&payload.directory, &payload.name, chip) else {
293            continue;
294        };
295        // A cabinet named with a `#` in front of it is a stream inside the installer rather than a
296        // file beside it. No installer in the selection uses one, which was measured rather than
297        // assumed, and a kit that started to would be losing headers quietly if this skipped it.
298        if payload.cabinet.starts_with('#') || payload.cabinet.is_empty() {
299            return Err(err(format!(
300                "{name} keeps {} in {}, which is inside the installer rather than in a cabinet \
301                 beside it, and this does not read those yet",
302                payload.name,
303                if payload.cabinet.is_empty() { "the media" } else { &payload.cabinet }
304            )));
305        }
306        wanted.entry(&payload.cabinet).or_default().push((&payload.key, at));
307    }
308
309    for (cabinet, files) in wanted {
310        let Some(published) = chosen.cab(cabinet) else {
311            return Err(err(format!(
312                "{name} says its files are in {cabinet}, which is not a file this Windows SDK \
313                 publishes, so there is nowhere to get them from"
314            )));
315        };
316        let at =
317            from.join(&published.payload.sha256[..12]).join(stored_as(&published.payload.name));
318        fetch::fetch(&published.payload.url, &published.payload.sha256, &at)?;
319        let bytes = slurp(&at)?;
320        let cab = Cab::read(&bytes).map_err(|why| err(format!("{}: {why}", at.display())))?;
321        spill(&cab, &files, root, file, &published.payload.url, manifest)
322            .map_err(|why| err(format!("{}: {why}", at.display())))?;
323    }
324    Ok(())
325}
326
327/// Write the files a cabinet holds, given what each one is called there and where it goes.
328///
329/// A folder in a cabinet is one compressed stream with the files laid end to end inside it, so it is
330/// decompressed once and sliced rather than once per file. The SDK puts thousands of headers in a
331/// folder, and asking for them one at a time would decompress the same megabytes thousands of times.
332fn spill(
333    cab: &Cab<'_>,
334    files: &[(&str, String)],
335    root: &Path,
336    file: &Wanted,
337    url: &str,
338    manifest: &mut Manifest,
339) -> Result<(), CliError> {
340    let places: BTreeMap<&str, &str> = files.iter().map(|(key, at)| (*key, at.as_str())).collect();
341    let mut folders: BTreeMap<usize, Vec<&CabFile>> = BTreeMap::new();
342    for member in cab.files() {
343        if places.contains_key(member.name.as_str()) {
344            folders.entry(member.folder).or_default().push(member);
345        }
346    }
347    for members in folders.into_values() {
348        let folder = cab.folder(members[0]).map_err(|why| err(why.to_string()))?;
349        for member in members {
350            let at = usize::try_from(member.at).unwrap_or(usize::MAX);
351            let size = usize::try_from(member.size).unwrap_or(usize::MAX);
352            let body =
353                at.checked_add(size).and_then(|end| folder.get(at..end)).ok_or_else(|| {
354                    err(format!("{} is not where this cabinet's folder says it is", member.name))
355                })?;
356            put(root, places[member.name.as_str()], body, file, url, manifest)?;
357        }
358    }
359    Ok(())
360}
361
362/// Write one file into the tree and record where it came from.
363///
364/// [`rucc_unpack::under`] is what decides whether the name may be written at all. The last component
365/// of every one of these is a string out of somebody else's archive, so it goes through the same
366/// check an unpacker owes its caller rather than being trusted because the directory in front of it
367/// was ours.
368fn put(
369    root: &Path,
370    at: &str,
371    body: &[u8],
372    file: &Wanted,
373    url: &str,
374    manifest: &mut Manifest,
375) -> Result<(), CliError> {
376    let to = under(root, at).ok_or_else(|| {
377        err(format!("{at} is a name out of a Microsoft package that will not be written"))
378    })?;
379    if let Some(parent) = to.parent() {
380        std::fs::create_dir_all(parent)
381            .map_err(|why| err(format!("{}: {why}", parent.display())))?;
382    }
383    std::fs::write(&to, body).map_err(|why| err(format!("{}: {why}", to.display())))?;
384    manifest.push(Input {
385        path: at.to_owned(),
386        source: format!("{} {}", file.package, file.version),
387        url: url.to_owned(),
388        sha256: sha256::hex(body),
389        licence: Licence::MicrosoftSdk,
390        provenance: Provenance::Fetched,
391    });
392    Ok(())
393}
394
395/// Put a lowercase name beside every file and directory in the tree that has a capital in it.
396///
397/// See [`tree::lowercase`] for what this is for. They are not recorded in the manifest, because a
398/// manifest says where files came from and these came from here.
399///
400/// An entry that is already there is left alone rather than reported. That happens on a host whose
401/// filesystem answers to either spelling, where creating the link finds the file itself in the way,
402/// and a compiler that refused to finish on such a host would be refusing over a tree that is
403/// already correct.
404#[cfg(unix)]
405fn aliases(root: &Path) -> Result<usize, CliError> {
406    let mut todo = vec![root.to_path_buf()];
407    let mut made = 0;
408    while let Some(dir) = todo.pop() {
409        let mut here = Vec::new();
410        let listing =
411            std::fs::read_dir(&dir).map_err(|why| err(format!("{}: {why}", dir.display())))?;
412        for entry in listing {
413            let entry = entry.map_err(|why| err(format!("{}: {why}", dir.display())))?;
414            // Not followed, so the links made below are not descended into on the way back up.
415            let kind = entry.file_type().map_err(|why| err(format!("{}: {why}", dir.display())))?;
416            if kind.is_dir() {
417                todo.push(entry.path());
418            }
419            here.push(entry.file_name());
420        }
421        for name in here {
422            let Some(name) = name.to_str() else {
423                continue;
424            };
425            let Some(lower) = tree::lowercase(name) else {
426                continue;
427            };
428            let link = dir.join(&lower);
429            match std::os::unix::fs::symlink(name, &link) {
430                Ok(()) => made += 1,
431                Err(why) if why.kind() == std::io::ErrorKind::AlreadyExists => {}
432                Err(why) => return Err(err(format!("{}: {why}", link.display()))),
433            }
434        }
435    }
436    Ok(made)
437}
438
439/// The same on a host where the question does not arise.
440///
441/// Windows filesystems are not case sensitive, so `windows.h` already finds `Windows.h` and a second
442/// name for it would be a second file rather than a second spelling.
443#[cfg(not(unix))]
444fn aliases(_root: &Path) -> Result<usize, CliError> {
445    Ok(0)
446}
447
448/// Read a file that was downloaded, saying which one when it cannot be read.
449fn slurp(at: &Path) -> Result<Vec<u8>, CliError> {
450    std::fs::read(at).map_err(|why| err(format!("{}: {why}", at.display())))
451}
452
453/// What a run that has not been given the acceptance prints.
454///
455/// The licence first and the list under it, because the list is what the licence is about, and the
456/// exact words to type last, since that is what somebody who has read the licence wants next. It
457/// goes to the output rather than to the error stream: it is what the command was asked for when it
458/// was run without the acceptance, and a person reads it.
459fn refuse(licence: &str, chosen: &Selection, tuple: &str) {
460    println!(
461        "The Windows SDK and the MSVC CRT are not ours to give you. Microsoft publishes them under\n\
462         the Visual Studio Build Tools licence, which is at\n\
463         \n    {licence}\n\
464         \nand which you have to read and accept yourself. This compiler will not accept it for you\n\
465         and will not download anything until you have said that you did.\n"
466    );
467    println!(
468        "What would be downloaded for {tuple}, {} totalling {}:",
469        files(chosen.files.len()),
470        mb(chosen.size())
471    );
472    for file in &chosen.files {
473        println!("  {:>9}  {}", mb(file.payload.size), stored_as(&file.payload.name));
474    }
475    println!(
476        "\nThe Windows SDK installers in that list hold no bytes of their own. Each one is a small\n\
477         database naming the cabinets its headers and libraries are in, and those cabinets are\n\
478         separate files that this total does not count, because which of them a target needs is a\n\
479         question only the installers can answer."
480    );
481    println!(
482        "\nIf you accept that licence, run this again with --accept-licence on the command line.\n\
483         If you would rather not, build for the mingw-w64 environment instead, which is fully\n\
484         redistributable and needs nothing installed."
485    );
486}
487
488/// `1 file` and `14 files`, because a message that says `1 files` was written by a program.
489fn files(count: usize) -> String {
490    if count == 1 { "1 file".to_owned() } else { format!("{count} files") }
491}
492
493/// Read a document that was just downloaded, saying which one when it cannot be read.
494fn read(at: &Path) -> Result<String, CliError> {
495    std::fs::read_to_string(at).map_err(|why| err(format!("{}: {why}", at.display())))
496}
497
498#[cfg(test)]
499mod tests {
500    use super::{aliases, downloads, files, mb, put, stored_as};
501    use rucc_sysroot::{Manifest, Provenance};
502    use std::path::{Path, PathBuf};
503
504    /// A directory of this test's own, since these write files.
505    fn scratch(name: &str) -> PathBuf {
506        let at = std::env::temp_dir().join(format!("rucc-msvc-{name}-{}", std::process::id()));
507        let _ = std::fs::remove_dir_all(&at);
508        std::fs::create_dir_all(&at).expect("a directory to work in");
509        at
510    }
511
512    /// One file out of a package, named the way the selection names one.
513    fn wanted(package: &str) -> rucc_sysroot::msvc::Wanted {
514        rucc_sysroot::msvc::Wanted {
515            package: package.to_owned(),
516            version: "10.0.26100.15".to_owned(),
517            payload: rucc_sysroot::msvc::Payload {
518                name: format!(r"Installers\{package}.msi"),
519                url: "https://example.invalid/thing".to_owned(),
520                sha256: "ab".repeat(32),
521                size: 4,
522            },
523        }
524    }
525
526    #[test]
527    fn a_file_is_written_where_the_tree_says_and_recorded_as_microsofts() {
528        let root = scratch("put");
529        let mut manifest = Manifest::new("x86_64-windows-msvc".parse().expect("a tuple"));
530        let from = wanted("Win11SDK_10.0.26100");
531        put(
532            &root,
533            "sdk/include/um/windows.h",
534            b"#pragma once\n",
535            &from,
536            "https://ms/cab",
537            &mut manifest,
538        )
539        .expect("a file written");
540        assert_eq!(
541            std::fs::read(root.join("sdk/include/um/windows.h")).expect("what was written"),
542            b"#pragma once\n"
543        );
544        let input = &manifest.inputs()[0];
545        assert_eq!(input.path, "sdk/include/um/windows.h");
546        assert_eq!(input.source, "Win11SDK_10.0.26100 10.0.26100.15");
547        // The URL is the cabinet the bytes came out of rather than the installer that named it,
548        // because that is where they were.
549        assert_eq!(input.url, "https://ms/cab");
550        assert_eq!(input.sha256, rucc_sysroot::sha256::hex(b"#pragma once\n"));
551        // Not redistributable, which is the whole reason this command exists.
552        assert!(!input.licence.redistributable());
553        assert_eq!(input.provenance, Provenance::Fetched);
554        let _ = std::fs::remove_dir_all(&root);
555    }
556
557    #[test]
558    fn a_name_out_of_a_package_that_would_leave_the_tree_is_refused() {
559        let root = scratch("escape");
560        let mut manifest = Manifest::new("x86_64-windows-msvc".parse().expect("a tuple"));
561        let from = wanted("Win11SDK_10.0.26100");
562        // Nothing in the mapping produces one of these. It is refused here anyway, because the last
563        // component of every path this writes is a string out of somebody else's archive.
564        let escape = put(&root, "../../etc/passwd", b"no", &from, "https://ms/cab", &mut manifest);
565        assert!(escape.is_err(), "a name that climbs out of the tree is not written");
566        assert!(manifest.inputs().is_empty());
567        let _ = std::fs::remove_dir_all(&root);
568    }
569
570    #[cfg(unix)]
571    #[test]
572    fn every_name_with_a_capital_in_it_gets_a_lowercase_one_beside_it() {
573        let root = scratch("aliases");
574        std::fs::create_dir_all(root.join("sdk/include/um")).expect("a directory");
575        std::fs::create_dir_all(root.join("crt/include/CodeAnalysis")).expect("a directory");
576        std::fs::write(root.join("sdk/include/um/Windows.h"), b"h").expect("a header");
577        std::fs::write(root.join("sdk/include/um/winbase.h"), b"h").expect("a header");
578        std::fs::write(root.join("crt/include/CodeAnalysis/warnings.h"), b"h").expect("a header");
579
580        let made = aliases(&root).expect("the links");
581        // Two on a host whose filesystem tells the spellings apart, and none on a Mac, where the
582        // file already answers to the lowercase name and the link finds itself in the way. Both are
583        // right, and what is worth asserting either way is what a compile goes on to find.
584        assert!(made == 2 || made == 0, "{made} links for two names with a capital in them");
585        assert_eq!(std::fs::read(root.join("sdk/include/um/windows.h")).expect("the link"), b"h");
586        assert_eq!(
587            std::fs::read(root.join("crt/include/codeanalysis/warnings.h")).expect("the link"),
588            b"h"
589        );
590        // Run again over its own output, which is what a second fetch of another architecture into
591        // the same cache would do, and nothing new is made and nothing fails.
592        assert_eq!(aliases(&root).expect("the links again"), 0);
593        let _ = std::fs::remove_dir_all(&root);
594    }
595
596    #[test]
597    fn a_payload_keeps_its_name_and_loses_the_directory_the_manifest_put_it_in() {
598        // The SDK's installers are named with a backslash, which is an ordinary character in a file
599        // name on a Unix, so the two hosts would disagree about the cache if it were written down.
600        assert_eq!(
601            stored_as(r"Installers\Windows SDK Desktop Headers x64-x86_en-us.msi"),
602            "Windows SDK Desktop Headers x64-x86_en-us.msi"
603        );
604        // The CRT's payloads have no directory on them at all.
605        assert_eq!(
606            stored_as("Microsoft.VC.14.44.17.14.CRT.Headers.base.vsix"),
607            "Microsoft.VC.14.44.17.14.CRT.Headers.base.vsix"
608        );
609        // And a forward slash, in case a manifest ever spells one that way.
610        assert_eq!(stored_as("a/b/c.msi"), "c.msi");
611    }
612
613    #[test]
614    fn the_download_directory_is_under_the_build() {
615        // Two builds of the installer name different files, so a person who fetched one and then
616        // the other has both rather than a directory that is half of each.
617        assert_eq!(
618            downloads(Path::new("/cache"), "17.14.37710.0"),
619            Path::new("/cache/downloads/msvc/17.14.37710.0")
620        );
621    }
622
623    #[test]
624    fn sizes_are_readable_and_counts_agree_with_themselves() {
625        assert_eq!(mb(2_128_977), "2.1 MB");
626        assert_eq!(mb(197_673_853), "197.7 MB");
627        assert_eq!(files(1), "1 file");
628        assert_eq!(files(14), "14 files");
629    }
630}