Skip to main content

rucc_driver/
fetch.rs

1//! Getting a file onto this machine with a program the machine already has.
2//!
3//! Design: `spec/cross-compile/13-distribution.md` section 13.8. That section divides a fetch in
4//! two, and [`crate::install`] is the half that decides whether the result is correct. This is the
5//! other half, the transport, which is the one part of a fetch that is not ours to get right and is
6//! ours to get out of the way of.
7//!
8//! # Why there is no HTTP client here
9//!
10//! Because there is no HTTP client anywhere in rucc. `spec/18-package-layout.md` section 18.3 is a
11//! dependency budget the compiler is held to, an HTTP client brings a TLS stack with it, and a TLS
12//! stack is a thing with a security release schedule attached. So the bytes are moved by `curl`,
13//! `wget` or PowerShell, all three of which are already on the hosts in the support table and all
14//! three of which are somebody else's job to keep current.
15//!
16//! The division of trust that comes out of that is worth saying plainly. The downloader
17//! authenticates the connection and we authenticate the bytes. A downloader that was lied to hands
18//! us a file that does not match the hash this release pins, and that file is deleted rather than
19//! unpacked, so the worst a bad connection can do is stop a fetch.
20//!
21//! # The order, and what a failure means
22//!
23//! `curl`, then `wget`, then PowerShell, which is section 13.8's order. A downloader that cannot be
24//! run is the next one's turn. A downloader that ran and failed is the end of it: a server that
25//! said no is not a reason to ask it again with a different client, and a disk that is full will be
26//! full for the second one too.
27//!
28//! Nothing searches a `PATH` or a `PATHEXT` to find out whether a program is there, because the
29//! question is whether the program can be run and the answer to that is what happens when it is
30//! run.
31//!
32//! # The machine with none of the three
33//!
34//! It is told the URL, the hash and the exact path to put a file at, and a second run carries on
35//! from the check rather than starting again. So a host with no downloader is still a host somebody
36//! can cross compile on, which is what makes the decision above cheap rather than clever.
37
38use std::ffi::OsString;
39use std::fs;
40use std::io;
41use std::path::{Path, PathBuf};
42use std::process::Command;
43use std::time::{SystemTime, UNIX_EPOCH};
44
45use crate::install::verify;
46use crate::{CliError, err};
47
48/// A program that can move bytes off a URL.
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum Downloader {
51    /// `curl`, which is on every Unix we support and on Windows since 1803.
52    Curl,
53    /// `wget`, which is what a minimal Linux image has when it has one of the two.
54    Wget,
55    /// PowerShell's `Invoke-WebRequest`, which is the answer on a Windows that predates the bundled
56    /// `curl` and on one where it was removed.
57    PowerShell,
58}
59
60impl Downloader {
61    /// The order they are tried in, which is section 13.8's order.
62    pub const ORDER: [Downloader; 3] = [Downloader::Curl, Downloader::Wget, Downloader::PowerShell];
63
64    /// The program to run.
65    #[must_use]
66    pub const fn program(self) -> &'static str {
67        match self {
68            Downloader::Curl => "curl",
69            Downloader::Wget => "wget",
70            // Not `pwsh`, which is the cross platform one and is not what a Windows install has
71            // unless somebody put it there. This is the fallback for an old Windows, so it asks for
72            // the shell an old Windows ships.
73            Downloader::PowerShell => "powershell",
74        }
75    }
76
77    /// The command line that downloads `url` to `into`.
78    ///
79    /// Quiet, because a compiler driver that prints a progress bar is printing it into a build log
80    /// that nobody is watching, and loud about failures, because the message a downloader writes is
81    /// the only thing that says whether the URL was wrong or the network was.
82    #[must_use]
83    pub fn argv(self, url: &str, into: &Path) -> Vec<String> {
84        let path = into.display().to_string();
85        match self {
86            // `--fail` because an HTTP error is otherwise a successful download of an error page,
87            // and `--location` because a release URL that redirects is the normal case.
88            Downloader::Curl => vec![
89                "--fail".to_owned(),
90                "--location".to_owned(),
91                "--silent".to_owned(),
92                "--show-error".to_owned(),
93                "--output".to_owned(),
94                path,
95                url.to_owned(),
96            ],
97            // wget fails on an HTTP error by default and follows redirects by default, so the two
98            // flags curl needs have no counterpart here.
99            Downloader::Wget => {
100                vec!["--quiet".to_owned(), "--output-document".to_owned(), path, url.to_owned()]
101            }
102            // One argument holding the whole script, rather than the words of it, because
103            // PowerShell joins what follows `-Command` and parses the result, and a path with a
104            // space in it would not survive that. `$ProgressPreference` is set because the progress
105            // display is slow as well as pointless here, and `-UseBasicParsing` because the other
106            // kind needs a browser engine that a server edition does not have.
107            Downloader::PowerShell => vec![
108                "-NoProfile".to_owned(),
109                "-NonInteractive".to_owned(),
110                "-Command".to_owned(),
111                format!(
112                    "$ProgressPreference='SilentlyContinue'; Invoke-WebRequest -UseBasicParsing \
113                     -Uri '{}' -OutFile '{}'",
114                    quote(url),
115                    quote(&path)
116                ),
117            ],
118        }
119    }
120}
121
122/// A string inside a PowerShell single quoted string, where the only special character is the quote
123/// itself and it is escaped by doubling.
124fn quote(text: &str) -> String {
125    text.replace('\'', "''")
126}
127
128/// How a file got to where it was asked for.
129#[derive(Debug, Clone, Copy, PartialEq, Eq)]
130pub enum Fetched {
131    /// It was already there and it already matched the hash, so nothing ran. This is the ordinary
132    /// case on a second fetch, and it is also the machine that was handed the file by hand.
133    AlreadyThere,
134    /// It was downloaded, by this one of the three.
135    Downloaded(Downloader),
136}
137
138/// What running a downloader did.
139#[derive(Debug, Clone, PartialEq, Eq)]
140enum Ran {
141    /// It ran and said it worked, which is not the same as it having written the right bytes.
142    Worked,
143    /// It ran and failed, with whatever it said about that.
144    Failed(String),
145    /// It could not be run at all, so it is not on this machine.
146    Absent,
147}
148
149/// Get the file at `url` to `into`, and refuse anything that is not the artifact `sha256` names.
150///
151/// The download goes to a temporary path beside `into` and is renamed only after the hash matches,
152/// so nothing that looks for `into` can find a half written file, and a fetch that was interrupted
153/// leaves nothing for the next one to mistake for the artifact.
154///
155/// # Errors
156///
157/// A machine with none of the three downloaders on it, and the message is then the instruction for
158/// doing this by hand. A downloader that ran and failed, with what it said. Bytes that do not match
159/// the hash, and those are deleted rather than kept, because a file under the name of an artifact it
160/// is not would be worse than no file. Anything the filesystem refuses.
161pub fn fetch(url: &str, sha256: &str, into: &Path) -> Result<Fetched, CliError> {
162    fetch_with(url, Some(sha256), into, &mut run)
163}
164
165/// Get the file at `url` to `into` with nothing to hold it against but the connection it came over.
166///
167/// This exists for exactly two files and they are the two documents at the top of
168/// `spec/cross-compile/13-distribution.md` section 13.4's chain. Microsoft's channel manifest is the
169/// root of that chain, so there is nothing above it that could name its hash, and Microsoft's
170/// installer manifest has a hash published for it in the channel that does not match the file served
171/// at the URL the channel names in the same breath, which was measured rather than assumed and is
172/// written down in that section. Every file named by the installer manifest goes through [`fetch`]
173/// above with the hash the manifest gives for it, and those hashes are exact.
174///
175/// So the trust a hash would have carried is carried by the downloader's connection to a Microsoft
176/// host instead, which is a weaker claim than the one [`fetch`] makes and is why this is a second
177/// function with its own name rather than a `None` somebody could pass to the first one by accident.
178///
179/// A file already at `into` is downloaded over rather than trusted, because with no hash there is no
180/// way to ask whether the one sitting there is the file. Nothing is written under `into` until the
181/// download finished, the same as above.
182///
183/// # Errors
184///
185/// The same three as [`fetch`] minus the hash: no downloader, a downloader that ran and failed, or
186/// a filesystem that refused.
187pub fn trusted(url: &str, into: &Path) -> Result<Fetched, CliError> {
188    fetch_with(url, None, into, &mut run)
189}
190
191/// The same, from a function that says what running a downloader did.
192///
193/// Split out for the reason [`crate::cache`] splits out its environment lookup: the cases worth
194/// testing are a machine with no `curl`, a server that answered with a 404 and a download that
195/// arrived corrupted, and a test cannot arrange any of the three on the machine it runs on. The
196/// temporary path is passed as well as the command line so that a test can write the bytes a
197/// downloader would have written.
198fn fetch_with(
199    url: &str,
200    sha256: Option<&str>,
201    into: &Path,
202    run: &mut dyn FnMut(Downloader, &Path, &[String]) -> Ran,
203) -> Result<Fetched, CliError> {
204    // With no hash there is no question to ask about the file that is there, so it is downloaded
205    // over rather than believed. That is [`trusted`] above and its two documents only.
206    if let (true, Some(sha256)) = (into.exists(), sha256) {
207        verify(into, sha256)?;
208        return Ok(Fetched::AlreadyThere);
209    }
210
211    let parent = into
212        .parent()
213        .ok_or_else(|| err(format!("{} is not a path a file can be written to", into.display())))?;
214    fs::create_dir_all(parent).map_err(|why| err(format!("{}: {why}", parent.display())))?;
215
216    let partial = partial(into);
217    let mut absent = Vec::new();
218    for downloader in Downloader::ORDER {
219        let argv = downloader.argv(url, &partial);
220        match run(downloader, &partial, &argv) {
221            Ran::Absent => {
222                absent.push(downloader);
223                continue;
224            }
225            Ran::Failed(said) => {
226                let _ = fs::remove_file(&partial);
227                let detail = if said.is_empty() { String::new() } else { format!(": {said}") };
228                return Err(err(format!(
229                    "`{}` could not download {url}{detail}",
230                    downloader.program()
231                )));
232            }
233            Ran::Worked => {
234                // What a downloader reports is that the transfer finished, and what has to be true
235                // is that the bytes are the artifact. Those are different claims and only the
236                // second one is ours.
237                if let Some(Err(why)) = sha256.map(|sha256| verify(&partial, sha256)) {
238                    let _ = fs::remove_file(&partial);
239                    return Err(err(format!(
240                        "the download of {url} was deleted rather than kept: {}",
241                        why.message
242                    )));
243                }
244                fs::rename(&partial, into)
245                    .map_err(|why| err(format!("{}: {why}", into.display())))?;
246                return Ok(Fetched::Downloaded(downloader));
247            }
248        }
249    }
250
251    let tried: Vec<&str> = absent.iter().map(|downloader| downloader.program()).collect();
252    let check = match sha256 {
253        Some(sha256) => format!("check that its sha256 is {sha256}, "),
254        // Nothing to check it against, which is the whole of what [`trusted`] gives up.
255        None => String::new(),
256    };
257    Err(err(format!(
258        "none of {} can be run on this machine and rucc has no downloader of its own, so \
259         download {url}, {check}put it at {}, and run this again, which carries on from the check",
260        tried.join(", "),
261        into.display()
262    )))
263}
264
265/// Where a download is written before it has been checked.
266///
267/// Beside the file it will become, so the rename at the end is on one filesystem, and under a name
268/// nothing else will pick, because two builds fetching one artifact at the same time is the
269/// ordinary case rather than the unlucky one.
270fn partial(into: &Path) -> PathBuf {
271    let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default();
272    let mut name = OsString::from(into.file_name().unwrap_or_default());
273    name.push(format!(".part.{}.{}", std::process::id(), now.as_nanos()));
274    into.with_file_name(name)
275}
276
277/// Run one downloader and say what happened.
278fn run(downloader: Downloader, _partial: &Path, argv: &[String]) -> Ran {
279    let output = Command::new(downloader.program()).args(argv).output();
280    let output = match output {
281        Ok(output) => output,
282        // The one error that means try the next one. Everything else is a machine that has the
283        // program and could not start it, which the next program will not fix either.
284        Err(why) if why.kind() == io::ErrorKind::NotFound => return Ran::Absent,
285        Err(why) => return Ran::Failed(why.to_string()),
286    };
287    if output.status.success() {
288        return Ran::Worked;
289    }
290    let said = String::from_utf8_lossy(&output.stderr);
291    Ran::Failed(said.trim().to_owned())
292}
293
294#[cfg(test)]
295mod tests {
296    use super::{Downloader, Fetched, Ran, fetch_with, partial};
297    use rucc_sysroot::sha256;
298    use std::cell::RefCell;
299    use std::path::{Path, PathBuf};
300
301    /// A directory that goes away with the test.
302    struct Tree(PathBuf);
303
304    impl Drop for Tree {
305        fn drop(&mut self) {
306            let _ = std::fs::remove_dir_all(&self.0);
307        }
308    }
309
310    impl Tree {
311        fn new(name: &str) -> Tree {
312            let dir =
313                std::env::temp_dir().join(format!("rucc-fetch-{}-{name}", std::process::id()));
314            let _ = std::fs::remove_dir_all(&dir);
315            std::fs::create_dir_all(&dir).expect("a temporary directory should be writable");
316            Tree(dir)
317        }
318    }
319
320    const URL: &str = "https://musl.libc.org/releases/musl-1.2.5.tar.gz";
321    const BYTES: &[u8] = b"what the release holds\n";
322
323    fn hash() -> String {
324        sha256::hex(BYTES)
325    }
326
327    #[test]
328    fn the_three_command_lines() {
329        let into = Path::new("/cache/downloads/musl.tar.gz");
330
331        let curl = Downloader::Curl.argv(URL, into);
332        assert_eq!(Downloader::Curl.program(), "curl");
333        // `--fail` or an HTTP error page is a successful download, and `--location` or a release URL
334        // that redirects is a failure.
335        assert!(curl.contains(&"--fail".to_owned()));
336        assert!(curl.contains(&"--location".to_owned()));
337        assert_eq!(curl.last().expect("the url goes last"), URL);
338        assert!(curl.contains(&"/cache/downloads/musl.tar.gz".to_owned()));
339
340        let wget = Downloader::Wget.argv(URL, into);
341        assert_eq!(Downloader::Wget.program(), "wget");
342        assert_eq!(
343            wget,
344            vec![
345                "--quiet".to_owned(),
346                "--output-document".to_owned(),
347                "/cache/downloads/musl.tar.gz".to_owned(),
348                URL.to_owned(),
349            ]
350        );
351
352        // The script is one argument and not several, because PowerShell joins what follows
353        // `-Command` and parses it again, and a path with a space in it would not survive that.
354        let shell = Downloader::PowerShell.argv(URL, Path::new(r"C:\Program Files\a.tar.gz"));
355        assert_eq!(Downloader::PowerShell.program(), "powershell");
356        let script = shell.last().expect("the script is the last argument");
357        assert!(script.contains("Invoke-WebRequest"), "{script}");
358        assert!(script.contains(r"-OutFile 'C:\Program Files\a.tar.gz'"), "{script}");
359        assert!(script.contains(&format!("-Uri '{URL}'")), "{script}");
360        assert_eq!(shell.len(), 4);
361    }
362
363    #[test]
364    fn a_url_with_a_quote_in_it_does_not_end_the_powershell_string() {
365        // Nobody pins a URL like this. The escaping is here because the alternative is a file name
366        // deciding where a command ends.
367        let argv = Downloader::PowerShell.argv("https://h/it's.tar.gz", Path::new("/tmp/a"));
368        let script = argv.last().expect("the script");
369        assert!(script.contains("-Uri 'https://h/it''s.tar.gz'"), "{script}");
370    }
371
372    #[test]
373    fn the_order_is_tried_until_one_of_them_runs() {
374        let tree = Tree::new("order");
375        let into = tree.0.join("musl.tar.gz");
376        let tried = RefCell::new(Vec::new());
377
378        let done = fetch_with(URL, Some(&hash()), &into, &mut |downloader, partial, _| {
379            tried.borrow_mut().push(downloader);
380            if downloader == Downloader::Curl {
381                return Ran::Absent;
382            }
383            std::fs::write(partial, BYTES).expect("a downloader writes the file");
384            Ran::Worked
385        })
386        .expect("wget should have been enough");
387
388        assert_eq!(done, Fetched::Downloaded(Downloader::Wget));
389        assert_eq!(tried.into_inner(), vec![Downloader::Curl, Downloader::Wget]);
390        assert_eq!(std::fs::read(&into).expect("the file"), BYTES);
391    }
392
393    #[test]
394    fn a_downloader_that_ran_and_failed_is_the_end_of_it() {
395        // A server that said no is not a reason to ask it again with a different client.
396        let tree = Tree::new("failed");
397        let into = tree.0.join("musl.tar.gz");
398        let tried = RefCell::new(Vec::new());
399
400        let why = fetch_with(URL, Some(&hash()), &into, &mut |downloader, _, _| {
401            tried.borrow_mut().push(downloader);
402            Ran::Failed("curl: (22) The requested URL returned error: 404".to_owned())
403        })
404        .expect_err("a 404 is a failure");
405
406        assert!(why.message.contains("`curl` could not download"), "{}", why.message);
407        assert!(why.message.contains("404"), "{}", why.message);
408        assert_eq!(tried.into_inner(), vec![Downloader::Curl]);
409        assert!(!into.exists(), "nothing should have been left under the artifact's name");
410    }
411
412    #[test]
413    fn a_machine_with_none_of_them_is_told_what_to_do_by_hand() {
414        let tree = Tree::new("none");
415        let into = tree.0.join("musl.tar.gz");
416        let tried = RefCell::new(Vec::new());
417
418        let why = fetch_with(URL, Some(&hash()), &into, &mut |downloader, _, _| {
419            tried.borrow_mut().push(downloader);
420            Ran::Absent
421        })
422        .expect_err("there is nothing to download with");
423
424        // The three things somebody needs, and no more than that: where it is, what it has to hash
425        // to, and where to put it.
426        assert!(why.message.contains(URL), "{}", why.message);
427        assert!(why.message.contains(&hash()), "{}", why.message);
428        assert!(why.message.contains(&into.display().to_string()), "{}", why.message);
429        assert!(why.message.contains("curl, wget, powershell"), "{}", why.message);
430        assert_eq!(tried.into_inner(), Downloader::ORDER.to_vec());
431    }
432
433    #[test]
434    fn bytes_that_do_not_match_are_deleted_rather_than_installed() {
435        // The division of trust: the downloader authenticated the connection and said it worked,
436        // and what the bytes are is still ours to decide.
437        let tree = Tree::new("corrupt");
438        let into = tree.0.join("musl.tar.gz");
439        let written = RefCell::new(PathBuf::new());
440
441        let why = fetch_with(URL, Some(&hash()), &into, &mut |_, partial, _| {
442            *written.borrow_mut() = partial.to_path_buf();
443            std::fs::write(partial, b"half of it\n").expect("a downloader writes the file");
444            Ran::Worked
445        })
446        .expect_err("these are not the bytes");
447
448        assert!(why.message.contains("where this release pins"), "{}", why.message);
449        assert!(why.message.contains("deleted rather than kept"), "{}", why.message);
450        assert!(!into.exists(), "nothing should be under the artifact's name");
451        assert!(!written.into_inner().exists(), "the partial file should be gone");
452    }
453
454    #[test]
455    fn a_file_that_is_already_there_and_matches_is_left_alone() {
456        // Which is a second fetch of one release, and is also the machine that was handed the file
457        // by hand and is running this again to carry on from the check.
458        let tree = Tree::new("again");
459        let into = tree.0.join("musl.tar.gz");
460        std::fs::write(&into, BYTES).expect("the file");
461
462        let done = fetch_with(URL, Some(&hash()), &into, &mut |_, _, _| {
463            panic!("nothing should have been run");
464        })
465        .expect("it is already here");
466        assert_eq!(done, Fetched::AlreadyThere);
467        assert_eq!(std::fs::read(&into).expect("the file"), BYTES);
468    }
469
470    #[test]
471    fn a_file_that_is_already_there_and_does_not_match_is_refused_rather_than_replaced() {
472        // Somebody put a file there, so they are told it is the wrong one. Deleting it and
473        // downloading over the top would answer a question they did not ask.
474        let tree = Tree::new("wrong");
475        let into = tree.0.join("musl.tar.gz");
476        std::fs::write(&into, b"something else\n").expect("the file");
477
478        let why = fetch_with(URL, Some(&hash()), &into, &mut |_, _, _| {
479            panic!("nothing should have been run");
480        })
481        .expect_err("that is not the artifact");
482        assert!(why.message.contains("where this release pins"), "{}", why.message);
483        assert!(into.exists(), "a file somebody placed should still be there");
484    }
485
486    #[test]
487    fn with_no_hash_a_file_that_is_already_there_is_downloaded_over_rather_than_believed() {
488        // Which is the whole of what `trusted` gives up. There is nothing to ask about the file
489        // sitting there, so the question is not asked and the answer is not guessed at either.
490        let tree = Tree::new("trusted-again");
491        let into = tree.0.join("VisualStudio.vsman");
492        std::fs::write(&into, b"half a manifest from a run that was interrupted\n").expect("it");
493        let ran = RefCell::new(0);
494
495        let done = fetch_with(URL, None, &into, &mut |_, partial, _| {
496            *ran.borrow_mut() += 1;
497            std::fs::write(partial, BYTES).expect("a downloader writes the file");
498            Ran::Worked
499        })
500        .expect("nothing was held against it");
501
502        assert_eq!(done, Fetched::Downloaded(Downloader::Curl));
503        assert_eq!(ran.into_inner(), 1);
504        assert_eq!(std::fs::read(&into).expect("the file"), BYTES);
505    }
506
507    #[test]
508    fn with_no_hash_a_machine_with_no_downloader_is_not_told_to_check_one() {
509        // The message is the same instruction minus the half of it that cannot be given.
510        let tree = Tree::new("trusted-none");
511        let into = tree.0.join("VisualStudio.vsman");
512
513        let why = fetch_with(URL, None, &into, &mut |_, _, _| Ran::Absent)
514            .expect_err("there is nothing to download with");
515
516        assert!(why.message.contains(URL), "{}", why.message);
517        assert!(!why.message.contains("sha256"), "{}", why.message);
518        assert!(why.message.contains(&into.display().to_string()), "{}", why.message);
519    }
520
521    #[test]
522    fn a_download_in_progress_is_not_under_the_name_of_the_artifact() {
523        // Which is what lets the already there check above be a check of one path rather than a
524        // question about whether a previous run finished.
525        let into = Path::new("/cache/downloads/musl-1.2.5.tar.gz");
526        let partial = partial(into);
527        assert_eq!(partial.parent(), into.parent());
528        assert_ne!(partial, into);
529        let name = partial.file_name().expect("a name").to_string_lossy().into_owned();
530        assert!(name.starts_with("musl-1.2.5.tar.gz.part."), "{name}");
531    }
532
533    #[test]
534    fn the_parent_directory_is_made_if_it_is_not_there() {
535        // The downloads directory does not exist on a machine that has never fetched anything, and
536        // a downloader told to write into a directory that is not there fails in its own words.
537        let tree = Tree::new("parent");
538        let into = tree.0.join("downloads").join("musl.tar.gz");
539
540        let done = fetch_with(URL, Some(&hash()), &into, &mut |_, partial, _| {
541            assert!(partial.parent().expect("a parent").is_dir(), "the directory should be there");
542            std::fs::write(partial, BYTES).expect("a downloader writes the file");
543            Ran::Worked
544        })
545        .expect("this should work");
546        assert_eq!(done, Fetched::Downloaded(Downloader::Curl));
547    }
548}