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(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 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
267fn 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
279fn 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}