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(sha256) = sha256
238                    && let Err(why) = verify(&partial, sha256)
239                {
240                    let _ = fs::remove_file(&partial);
241                    return Err(err(format!(
242                        "the download of {url} was deleted rather than kept: {}",
243                        why.message
244                    )));
245                }
246                fs::rename(&partial, into)
247                    .map_err(|why| err(format!("{}: {why}", into.display())))?;
248                return Ok(Fetched::Downloaded(downloader));
249            }
250        }
251    }
252
253    let tried: Vec<&str> = absent.iter().map(|downloader| downloader.program()).collect();
254    let check = match sha256 {
255        Some(sha256) => format!("check that its sha256 is {sha256}, "),
256        // Nothing to check it against, which is the whole of what [`trusted`] gives up.
257        None => String::new(),
258    };
259    Err(err(format!(
260        "none of {} can be run on this machine and rucc has no downloader of its own, so \
261         download {url}, {check}put it at {}, and run this again, which carries on from the check",
262        tried.join(", "),
263        into.display()
264    )))
265}
266
267/// Where a download is written before it has been checked.
268///
269/// Beside the file it will become, so the rename at the end is on one filesystem, and under a name
270/// nothing else will pick, because two builds fetching one artifact at the same time is the
271/// ordinary case rather than the unlucky one.
272fn partial(into: &Path) -> PathBuf {
273    let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default();
274    let mut name = OsString::from(into.file_name().unwrap_or_default());
275    name.push(format!(".part.{}.{}", std::process::id(), now.as_nanos()));
276    into.with_file_name(name)
277}
278
279/// Run one downloader and say what happened.
280fn run(downloader: Downloader, _partial: &Path, argv: &[String]) -> Ran {
281    let output = Command::new(downloader.program()).args(argv).output();
282    let output = match output {
283        Ok(output) => output,
284        // The one error that means try the next one. Everything else is a machine that has the
285        // program and could not start it, which the next program will not fix either.
286        Err(why) if why.kind() == io::ErrorKind::NotFound => return Ran::Absent,
287        Err(why) => return Ran::Failed(why.to_string()),
288    };
289    if output.status.success() {
290        return Ran::Worked;
291    }
292    let said = String::from_utf8_lossy(&output.stderr);
293    Ran::Failed(said.trim().to_owned())
294}
295
296#[cfg(test)]
297mod tests {
298    use super::{Downloader, Fetched, Ran, fetch_with, partial};
299    use rucc_sysroot::sha256;
300    use std::cell::RefCell;
301    use std::path::{Path, PathBuf};
302
303    /// A directory that goes away with the test.
304    struct Tree(PathBuf);
305
306    impl Drop for Tree {
307        fn drop(&mut self) {
308            let _ = std::fs::remove_dir_all(&self.0);
309        }
310    }
311
312    impl Tree {
313        fn new(name: &str) -> Tree {
314            let dir =
315                std::env::temp_dir().join(format!("rucc-fetch-{}-{name}", std::process::id()));
316            let _ = std::fs::remove_dir_all(&dir);
317            std::fs::create_dir_all(&dir).expect("a temporary directory should be writable");
318            Tree(dir)
319        }
320    }
321
322    const URL: &str = "https://musl.libc.org/releases/musl-1.2.5.tar.gz";
323    const BYTES: &[u8] = b"what the release holds\n";
324
325    fn hash() -> String {
326        sha256::hex(BYTES)
327    }
328
329    #[test]
330    fn the_three_command_lines() {
331        let into = Path::new("/cache/downloads/musl.tar.gz");
332
333        let curl = Downloader::Curl.argv(URL, into);
334        assert_eq!(Downloader::Curl.program(), "curl");
335        // `--fail` or an HTTP error page is a successful download, and `--location` or a release URL
336        // that redirects is a failure.
337        assert!(curl.contains(&"--fail".to_owned()));
338        assert!(curl.contains(&"--location".to_owned()));
339        assert_eq!(curl.last().expect("the url goes last"), URL);
340        assert!(curl.contains(&"/cache/downloads/musl.tar.gz".to_owned()));
341
342        let wget = Downloader::Wget.argv(URL, into);
343        assert_eq!(Downloader::Wget.program(), "wget");
344        assert_eq!(
345            wget,
346            vec![
347                "--quiet".to_owned(),
348                "--output-document".to_owned(),
349                "/cache/downloads/musl.tar.gz".to_owned(),
350                URL.to_owned(),
351            ]
352        );
353
354        // The script is one argument and not several, because PowerShell joins what follows
355        // `-Command` and parses it again, and a path with a space in it would not survive that.
356        let shell = Downloader::PowerShell.argv(URL, Path::new(r"C:\Program Files\a.tar.gz"));
357        assert_eq!(Downloader::PowerShell.program(), "powershell");
358        let script = shell.last().expect("the script is the last argument");
359        assert!(script.contains("Invoke-WebRequest"), "{script}");
360        assert!(script.contains(r"-OutFile 'C:\Program Files\a.tar.gz'"), "{script}");
361        assert!(script.contains(&format!("-Uri '{URL}'")), "{script}");
362        assert_eq!(shell.len(), 4);
363    }
364
365    #[test]
366    fn a_url_with_a_quote_in_it_does_not_end_the_powershell_string() {
367        // Nobody pins a URL like this. The escaping is here because the alternative is a file name
368        // deciding where a command ends.
369        let argv = Downloader::PowerShell.argv("https://h/it's.tar.gz", Path::new("/tmp/a"));
370        let script = argv.last().expect("the script");
371        assert!(script.contains("-Uri 'https://h/it''s.tar.gz'"), "{script}");
372    }
373
374    #[test]
375    fn the_order_is_tried_until_one_of_them_runs() {
376        let tree = Tree::new("order");
377        let into = tree.0.join("musl.tar.gz");
378        let tried = RefCell::new(Vec::new());
379
380        let done = fetch_with(URL, Some(&hash()), &into, &mut |downloader, partial, _| {
381            tried.borrow_mut().push(downloader);
382            if downloader == Downloader::Curl {
383                return Ran::Absent;
384            }
385            std::fs::write(partial, BYTES).expect("a downloader writes the file");
386            Ran::Worked
387        })
388        .expect("wget should have been enough");
389
390        assert_eq!(done, Fetched::Downloaded(Downloader::Wget));
391        assert_eq!(tried.into_inner(), vec![Downloader::Curl, Downloader::Wget]);
392        assert_eq!(std::fs::read(&into).expect("the file"), BYTES);
393    }
394
395    #[test]
396    fn a_downloader_that_ran_and_failed_is_the_end_of_it() {
397        // A server that said no is not a reason to ask it again with a different client.
398        let tree = Tree::new("failed");
399        let into = tree.0.join("musl.tar.gz");
400        let tried = RefCell::new(Vec::new());
401
402        let why = fetch_with(URL, Some(&hash()), &into, &mut |downloader, _, _| {
403            tried.borrow_mut().push(downloader);
404            Ran::Failed("curl: (22) The requested URL returned error: 404".to_owned())
405        })
406        .expect_err("a 404 is a failure");
407
408        assert!(why.message.contains("`curl` could not download"), "{}", why.message);
409        assert!(why.message.contains("404"), "{}", why.message);
410        assert_eq!(tried.into_inner(), vec![Downloader::Curl]);
411        assert!(!into.exists(), "nothing should have been left under the artifact's name");
412    }
413
414    #[test]
415    fn a_machine_with_none_of_them_is_told_what_to_do_by_hand() {
416        let tree = Tree::new("none");
417        let into = tree.0.join("musl.tar.gz");
418        let tried = RefCell::new(Vec::new());
419
420        let why = fetch_with(URL, Some(&hash()), &into, &mut |downloader, _, _| {
421            tried.borrow_mut().push(downloader);
422            Ran::Absent
423        })
424        .expect_err("there is nothing to download with");
425
426        // The three things somebody needs, and no more than that: where it is, what it has to hash
427        // to, and where to put it.
428        assert!(why.message.contains(URL), "{}", why.message);
429        assert!(why.message.contains(&hash()), "{}", why.message);
430        assert!(why.message.contains(&into.display().to_string()), "{}", why.message);
431        assert!(why.message.contains("curl, wget, powershell"), "{}", why.message);
432        assert_eq!(tried.into_inner(), Downloader::ORDER.to_vec());
433    }
434
435    #[test]
436    fn bytes_that_do_not_match_are_deleted_rather_than_installed() {
437        // The division of trust: the downloader authenticated the connection and said it worked,
438        // and what the bytes are is still ours to decide.
439        let tree = Tree::new("corrupt");
440        let into = tree.0.join("musl.tar.gz");
441        let written = RefCell::new(PathBuf::new());
442
443        let why = fetch_with(URL, Some(&hash()), &into, &mut |_, partial, _| {
444            *written.borrow_mut() = partial.to_path_buf();
445            std::fs::write(partial, b"half of it\n").expect("a downloader writes the file");
446            Ran::Worked
447        })
448        .expect_err("these are not the bytes");
449
450        assert!(why.message.contains("where this release pins"), "{}", why.message);
451        assert!(why.message.contains("deleted rather than kept"), "{}", why.message);
452        assert!(!into.exists(), "nothing should be under the artifact's name");
453        assert!(!written.into_inner().exists(), "the partial file should be gone");
454    }
455
456    #[test]
457    fn a_file_that_is_already_there_and_matches_is_left_alone() {
458        // Which is a second fetch of one release, and is also the machine that was handed the file
459        // by hand and is running this again to carry on from the check.
460        let tree = Tree::new("again");
461        let into = tree.0.join("musl.tar.gz");
462        std::fs::write(&into, BYTES).expect("the file");
463
464        let done = fetch_with(URL, Some(&hash()), &into, &mut |_, _, _| {
465            panic!("nothing should have been run");
466        })
467        .expect("it is already here");
468        assert_eq!(done, Fetched::AlreadyThere);
469        assert_eq!(std::fs::read(&into).expect("the file"), BYTES);
470    }
471
472    #[test]
473    fn a_file_that_is_already_there_and_does_not_match_is_refused_rather_than_replaced() {
474        // Somebody put a file there, so they are told it is the wrong one. Deleting it and
475        // downloading over the top would answer a question they did not ask.
476        let tree = Tree::new("wrong");
477        let into = tree.0.join("musl.tar.gz");
478        std::fs::write(&into, b"something else\n").expect("the file");
479
480        let why = fetch_with(URL, Some(&hash()), &into, &mut |_, _, _| {
481            panic!("nothing should have been run");
482        })
483        .expect_err("that is not the artifact");
484        assert!(why.message.contains("where this release pins"), "{}", why.message);
485        assert!(into.exists(), "a file somebody placed should still be there");
486    }
487
488    #[test]
489    fn with_no_hash_a_file_that_is_already_there_is_downloaded_over_rather_than_believed() {
490        // Which is the whole of what `trusted` gives up. There is nothing to ask about the file
491        // sitting there, so the question is not asked and the answer is not guessed at either.
492        let tree = Tree::new("trusted-again");
493        let into = tree.0.join("VisualStudio.vsman");
494        std::fs::write(&into, b"half a manifest from a run that was interrupted\n").expect("it");
495        let ran = RefCell::new(0);
496
497        let done = fetch_with(URL, None, &into, &mut |_, partial, _| {
498            *ran.borrow_mut() += 1;
499            std::fs::write(partial, BYTES).expect("a downloader writes the file");
500            Ran::Worked
501        })
502        .expect("nothing was held against it");
503
504        assert_eq!(done, Fetched::Downloaded(Downloader::Curl));
505        assert_eq!(ran.into_inner(), 1);
506        assert_eq!(std::fs::read(&into).expect("the file"), BYTES);
507    }
508
509    #[test]
510    fn with_no_hash_a_machine_with_no_downloader_is_not_told_to_check_one() {
511        // The message is the same instruction minus the half of it that cannot be given.
512        let tree = Tree::new("trusted-none");
513        let into = tree.0.join("VisualStudio.vsman");
514
515        let why = fetch_with(URL, None, &into, &mut |_, _, _| Ran::Absent)
516            .expect_err("there is nothing to download with");
517
518        assert!(why.message.contains(URL), "{}", why.message);
519        assert!(!why.message.contains("sha256"), "{}", why.message);
520        assert!(why.message.contains(&into.display().to_string()), "{}", why.message);
521    }
522
523    #[test]
524    fn a_download_in_progress_is_not_under_the_name_of_the_artifact() {
525        // Which is what lets the already there check above be a check of one path rather than a
526        // question about whether a previous run finished.
527        let into = Path::new("/cache/downloads/musl-1.2.5.tar.gz");
528        let partial = partial(into);
529        assert_eq!(partial.parent(), into.parent());
530        assert_ne!(partial, into);
531        let name = partial.file_name().expect("a name").to_string_lossy().into_owned();
532        assert!(name.starts_with("musl-1.2.5.tar.gz.part."), "{name}");
533    }
534
535    #[test]
536    fn the_parent_directory_is_made_if_it_is_not_there() {
537        // The downloads directory does not exist on a machine that has never fetched anything, and
538        // a downloader told to write into a directory that is not there fails in its own words.
539        let tree = Tree::new("parent");
540        let into = tree.0.join("downloads").join("musl.tar.gz");
541
542        let done = fetch_with(URL, Some(&hash()), &into, &mut |_, partial, _| {
543            assert!(partial.parent().expect("a parent").is_dir(), "the directory should be there");
544            std::fs::write(partial, BYTES).expect("a downloader writes the file");
545            Ran::Worked
546        })
547        .expect("this should work");
548        assert_eq!(done, Fetched::Downloaded(Downloader::Curl));
549    }
550}