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, sha256, into, &mut run)
163}
164
165/// The same, from a function that says what running a downloader did.
166///
167/// Split out for the reason [`crate::cache`] splits out its environment lookup: the cases worth
168/// testing are a machine with no `curl`, a server that answered with a 404 and a download that
169/// arrived corrupted, and a test cannot arrange any of the three on the machine it runs on. The
170/// temporary path is passed as well as the command line so that a test can write the bytes a
171/// downloader would have written.
172fn fetch_with(
173    url: &str,
174    sha256: &str,
175    into: &Path,
176    run: &mut dyn FnMut(Downloader, &Path, &[String]) -> Ran,
177) -> Result<Fetched, CliError> {
178    if into.exists() {
179        verify(into, sha256)?;
180        return Ok(Fetched::AlreadyThere);
181    }
182
183    let parent = into
184        .parent()
185        .ok_or_else(|| err(format!("{} is not a path a file can be written to", into.display())))?;
186    fs::create_dir_all(parent).map_err(|why| err(format!("{}: {why}", parent.display())))?;
187
188    let partial = partial(into);
189    let mut absent = Vec::new();
190    for downloader in Downloader::ORDER {
191        let argv = downloader.argv(url, &partial);
192        match run(downloader, &partial, &argv) {
193            Ran::Absent => {
194                absent.push(downloader);
195                continue;
196            }
197            Ran::Failed(said) => {
198                let _ = fs::remove_file(&partial);
199                let detail = if said.is_empty() { String::new() } else { format!(": {said}") };
200                return Err(err(format!(
201                    "`{}` could not download {url}{detail}",
202                    downloader.program()
203                )));
204            }
205            Ran::Worked => {
206                // What a downloader reports is that the transfer finished, and what has to be true
207                // is that the bytes are the artifact. Those are different claims and only the
208                // second one is ours.
209                if let Err(why) = verify(&partial, sha256) {
210                    let _ = fs::remove_file(&partial);
211                    return Err(err(format!(
212                        "the download of {url} was deleted rather than kept: {}",
213                        why.message
214                    )));
215                }
216                fs::rename(&partial, into)
217                    .map_err(|why| err(format!("{}: {why}", into.display())))?;
218                return Ok(Fetched::Downloaded(downloader));
219            }
220        }
221    }
222
223    let tried: Vec<&str> = absent.iter().map(|downloader| downloader.program()).collect();
224    Err(err(format!(
225        "none of {} can be run on this machine and rucc has no downloader of its own, so \
226         download {url}, check that its sha256 is {sha256}, put it at {}, and run this again, \
227         which carries on from the check",
228        tried.join(", "),
229        into.display()
230    )))
231}
232
233/// Where a download is written before it has been checked.
234///
235/// Beside the file it will become, so the rename at the end is on one filesystem, and under a name
236/// nothing else will pick, because two builds fetching one artifact at the same time is the
237/// ordinary case rather than the unlucky one.
238fn partial(into: &Path) -> PathBuf {
239    let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default();
240    let mut name = OsString::from(into.file_name().unwrap_or_default());
241    name.push(format!(".part.{}.{}", std::process::id(), now.as_nanos()));
242    into.with_file_name(name)
243}
244
245/// Run one downloader and say what happened.
246fn run(downloader: Downloader, _partial: &Path, argv: &[String]) -> Ran {
247    let output = Command::new(downloader.program()).args(argv).output();
248    let output = match output {
249        Ok(output) => output,
250        // The one error that means try the next one. Everything else is a machine that has the
251        // program and could not start it, which the next program will not fix either.
252        Err(why) if why.kind() == io::ErrorKind::NotFound => return Ran::Absent,
253        Err(why) => return Ran::Failed(why.to_string()),
254    };
255    if output.status.success() {
256        return Ran::Worked;
257    }
258    let said = String::from_utf8_lossy(&output.stderr);
259    Ran::Failed(said.trim().to_owned())
260}
261
262#[cfg(test)]
263mod tests {
264    use super::{Downloader, Fetched, Ran, fetch_with, partial};
265    use rucc_sysroot::sha256;
266    use std::cell::RefCell;
267    use std::path::{Path, PathBuf};
268
269    /// A directory that goes away with the test.
270    struct Tree(PathBuf);
271
272    impl Drop for Tree {
273        fn drop(&mut self) {
274            let _ = std::fs::remove_dir_all(&self.0);
275        }
276    }
277
278    impl Tree {
279        fn new(name: &str) -> Tree {
280            let dir =
281                std::env::temp_dir().join(format!("rucc-fetch-{}-{name}", std::process::id()));
282            let _ = std::fs::remove_dir_all(&dir);
283            std::fs::create_dir_all(&dir).expect("a temporary directory should be writable");
284            Tree(dir)
285        }
286    }
287
288    const URL: &str = "https://musl.libc.org/releases/musl-1.2.5.tar.gz";
289    const BYTES: &[u8] = b"what the release holds\n";
290
291    fn hash() -> String {
292        sha256::hex(BYTES)
293    }
294
295    #[test]
296    fn the_three_command_lines() {
297        let into = Path::new("/cache/downloads/musl.tar.gz");
298
299        let curl = Downloader::Curl.argv(URL, into);
300        assert_eq!(Downloader::Curl.program(), "curl");
301        // `--fail` or an HTTP error page is a successful download, and `--location` or a release URL
302        // that redirects is a failure.
303        assert!(curl.contains(&"--fail".to_owned()));
304        assert!(curl.contains(&"--location".to_owned()));
305        assert_eq!(curl.last().expect("the url goes last"), URL);
306        assert!(curl.contains(&"/cache/downloads/musl.tar.gz".to_owned()));
307
308        let wget = Downloader::Wget.argv(URL, into);
309        assert_eq!(Downloader::Wget.program(), "wget");
310        assert_eq!(
311            wget,
312            vec![
313                "--quiet".to_owned(),
314                "--output-document".to_owned(),
315                "/cache/downloads/musl.tar.gz".to_owned(),
316                URL.to_owned(),
317            ]
318        );
319
320        // The script is one argument and not several, because PowerShell joins what follows
321        // `-Command` and parses it again, and a path with a space in it would not survive that.
322        let shell = Downloader::PowerShell.argv(URL, Path::new(r"C:\Program Files\a.tar.gz"));
323        assert_eq!(Downloader::PowerShell.program(), "powershell");
324        let script = shell.last().expect("the script is the last argument");
325        assert!(script.contains("Invoke-WebRequest"), "{script}");
326        assert!(script.contains(r"-OutFile 'C:\Program Files\a.tar.gz'"), "{script}");
327        assert!(script.contains(&format!("-Uri '{URL}'")), "{script}");
328        assert_eq!(shell.len(), 4);
329    }
330
331    #[test]
332    fn a_url_with_a_quote_in_it_does_not_end_the_powershell_string() {
333        // Nobody pins a URL like this. The escaping is here because the alternative is a file name
334        // deciding where a command ends.
335        let argv = Downloader::PowerShell.argv("https://h/it's.tar.gz", Path::new("/tmp/a"));
336        let script = argv.last().expect("the script");
337        assert!(script.contains("-Uri 'https://h/it''s.tar.gz'"), "{script}");
338    }
339
340    #[test]
341    fn the_order_is_tried_until_one_of_them_runs() {
342        let tree = Tree::new("order");
343        let into = tree.0.join("musl.tar.gz");
344        let tried = RefCell::new(Vec::new());
345
346        let done = fetch_with(URL, &hash(), &into, &mut |downloader, partial, _| {
347            tried.borrow_mut().push(downloader);
348            if downloader == Downloader::Curl {
349                return Ran::Absent;
350            }
351            std::fs::write(partial, BYTES).expect("a downloader writes the file");
352            Ran::Worked
353        })
354        .expect("wget should have been enough");
355
356        assert_eq!(done, Fetched::Downloaded(Downloader::Wget));
357        assert_eq!(tried.into_inner(), vec![Downloader::Curl, Downloader::Wget]);
358        assert_eq!(std::fs::read(&into).expect("the file"), BYTES);
359    }
360
361    #[test]
362    fn a_downloader_that_ran_and_failed_is_the_end_of_it() {
363        // A server that said no is not a reason to ask it again with a different client.
364        let tree = Tree::new("failed");
365        let into = tree.0.join("musl.tar.gz");
366        let tried = RefCell::new(Vec::new());
367
368        let why = fetch_with(URL, &hash(), &into, &mut |downloader, _, _| {
369            tried.borrow_mut().push(downloader);
370            Ran::Failed("curl: (22) The requested URL returned error: 404".to_owned())
371        })
372        .expect_err("a 404 is a failure");
373
374        assert!(why.message.contains("`curl` could not download"), "{}", why.message);
375        assert!(why.message.contains("404"), "{}", why.message);
376        assert_eq!(tried.into_inner(), vec![Downloader::Curl]);
377        assert!(!into.exists(), "nothing should have been left under the artifact's name");
378    }
379
380    #[test]
381    fn a_machine_with_none_of_them_is_told_what_to_do_by_hand() {
382        let tree = Tree::new("none");
383        let into = tree.0.join("musl.tar.gz");
384        let tried = RefCell::new(Vec::new());
385
386        let why = fetch_with(URL, &hash(), &into, &mut |downloader, _, _| {
387            tried.borrow_mut().push(downloader);
388            Ran::Absent
389        })
390        .expect_err("there is nothing to download with");
391
392        // The three things somebody needs, and no more than that: where it is, what it has to hash
393        // to, and where to put it.
394        assert!(why.message.contains(URL), "{}", why.message);
395        assert!(why.message.contains(&hash()), "{}", why.message);
396        assert!(why.message.contains(&into.display().to_string()), "{}", why.message);
397        assert!(why.message.contains("curl, wget, powershell"), "{}", why.message);
398        assert_eq!(tried.into_inner(), Downloader::ORDER.to_vec());
399    }
400
401    #[test]
402    fn bytes_that_do_not_match_are_deleted_rather_than_installed() {
403        // The division of trust: the downloader authenticated the connection and said it worked,
404        // and what the bytes are is still ours to decide.
405        let tree = Tree::new("corrupt");
406        let into = tree.0.join("musl.tar.gz");
407        let written = RefCell::new(PathBuf::new());
408
409        let why = fetch_with(URL, &hash(), &into, &mut |_, partial, _| {
410            *written.borrow_mut() = partial.to_path_buf();
411            std::fs::write(partial, b"half of it\n").expect("a downloader writes the file");
412            Ran::Worked
413        })
414        .expect_err("these are not the bytes");
415
416        assert!(why.message.contains("where this release pins"), "{}", why.message);
417        assert!(why.message.contains("deleted rather than kept"), "{}", why.message);
418        assert!(!into.exists(), "nothing should be under the artifact's name");
419        assert!(!written.into_inner().exists(), "the partial file should be gone");
420    }
421
422    #[test]
423    fn a_file_that_is_already_there_and_matches_is_left_alone() {
424        // Which is a second fetch of one release, and is also the machine that was handed the file
425        // by hand and is running this again to carry on from the check.
426        let tree = Tree::new("again");
427        let into = tree.0.join("musl.tar.gz");
428        std::fs::write(&into, BYTES).expect("the file");
429
430        let done = fetch_with(URL, &hash(), &into, &mut |_, _, _| {
431            panic!("nothing should have been run");
432        })
433        .expect("it is already here");
434        assert_eq!(done, Fetched::AlreadyThere);
435        assert_eq!(std::fs::read(&into).expect("the file"), BYTES);
436    }
437
438    #[test]
439    fn a_file_that_is_already_there_and_does_not_match_is_refused_rather_than_replaced() {
440        // Somebody put a file there, so they are told it is the wrong one. Deleting it and
441        // downloading over the top would answer a question they did not ask.
442        let tree = Tree::new("wrong");
443        let into = tree.0.join("musl.tar.gz");
444        std::fs::write(&into, b"something else\n").expect("the file");
445
446        let why = fetch_with(URL, &hash(), &into, &mut |_, _, _| {
447            panic!("nothing should have been run");
448        })
449        .expect_err("that is not the artifact");
450        assert!(why.message.contains("where this release pins"), "{}", why.message);
451        assert!(into.exists(), "a file somebody placed should still be there");
452    }
453
454    #[test]
455    fn a_download_in_progress_is_not_under_the_name_of_the_artifact() {
456        // Which is what lets the already there check above be a check of one path rather than a
457        // question about whether a previous run finished.
458        let into = Path::new("/cache/downloads/musl-1.2.5.tar.gz");
459        let partial = partial(into);
460        assert_eq!(partial.parent(), into.parent());
461        assert_ne!(partial, into);
462        let name = partial.file_name().expect("a name").to_string_lossy().into_owned();
463        assert!(name.starts_with("musl-1.2.5.tar.gz.part."), "{name}");
464    }
465
466    #[test]
467    fn the_parent_directory_is_made_if_it_is_not_there() {
468        // The downloads directory does not exist on a machine that has never fetched anything, and
469        // a downloader told to write into a directory that is not there fails in its own words.
470        let tree = Tree::new("parent");
471        let into = tree.0.join("downloads").join("musl.tar.gz");
472
473        let done = fetch_with(URL, &hash(), &into, &mut |_, partial, _| {
474            assert!(partial.parent().expect("a parent").is_dir(), "the directory should be there");
475            std::fs::write(partial, BYTES).expect("a downloader writes the file");
476            Ran::Worked
477        })
478        .expect("this should work");
479        assert_eq!(done, Fetched::Downloaded(Downloader::Curl));
480    }
481}