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, sha256, into, &mut run)
163}
164
165fn 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 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
233fn 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
245fn 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 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 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 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 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 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 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 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 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 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 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 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 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}