1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum Downloader {
51 Curl,
53 Wget,
55 PowerShell,
58}
59
60impl Downloader {
61 pub const ORDER: [Downloader; 3] = [Downloader::Curl, Downloader::Wget, Downloader::PowerShell];
63
64 #[must_use]
66 pub const fn program(self) -> &'static str {
67 match self {
68 Downloader::Curl => "curl",
69 Downloader::Wget => "wget",
70 Downloader::PowerShell => "powershell",
74 }
75 }
76
77 #[must_use]
83 pub fn argv(self, url: &str, into: &Path) -> Vec<String> {
84 let path = into.display().to_string();
85 match self {
86 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 Downloader::Wget => {
100 vec!["--quiet".to_owned(), "--output-document".to_owned(), path, url.to_owned()]
101 }
102 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
122fn quote(text: &str) -> String {
125 text.replace('\'', "''")
126}
127
128#[derive(Debug, Clone, Copy, PartialEq, Eq)]
130pub enum Fetched {
131 AlreadyThere,
134 Downloaded(Downloader),
136}
137
138#[derive(Debug, Clone, PartialEq, Eq)]
140enum Ran {
141 Worked,
143 Failed(String),
145 Absent,
147}
148
149pub fn fetch(url: &str, sha256: &str, into: &Path) -> Result<Fetched, CliError> {
162 fetch_with(url, Some(sha256), into, &mut run)
163}
164
165pub fn trusted(url: &str, into: &Path) -> Result<Fetched, CliError> {
188 fetch_with(url, None, into, &mut run)
189}
190
191fn 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 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 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 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
265fn 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
277fn 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}