use std::ffi::OsString;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{SystemTime, UNIX_EPOCH};
use crate::install::verify;
use crate::{CliError, err};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Downloader {
Curl,
Wget,
PowerShell,
}
impl Downloader {
pub const ORDER: [Downloader; 3] = [Downloader::Curl, Downloader::Wget, Downloader::PowerShell];
#[must_use]
pub const fn program(self) -> &'static str {
match self {
Downloader::Curl => "curl",
Downloader::Wget => "wget",
Downloader::PowerShell => "powershell",
}
}
#[must_use]
pub fn argv(self, url: &str, into: &Path) -> Vec<String> {
let path = into.display().to_string();
match self {
Downloader::Curl => vec![
"--fail".to_owned(),
"--location".to_owned(),
"--silent".to_owned(),
"--show-error".to_owned(),
"--output".to_owned(),
path,
url.to_owned(),
],
Downloader::Wget => {
vec!["--quiet".to_owned(), "--output-document".to_owned(), path, url.to_owned()]
}
Downloader::PowerShell => vec![
"-NoProfile".to_owned(),
"-NonInteractive".to_owned(),
"-Command".to_owned(),
format!(
"$ProgressPreference='SilentlyContinue'; Invoke-WebRequest -UseBasicParsing \
-Uri '{}' -OutFile '{}'",
quote(url),
quote(&path)
),
],
}
}
}
fn quote(text: &str) -> String {
text.replace('\'', "''")
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Fetched {
AlreadyThere,
Downloaded(Downloader),
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum Ran {
Worked,
Failed(String),
Absent,
}
pub fn fetch(url: &str, sha256: &str, into: &Path) -> Result<Fetched, CliError> {
fetch_with(url, sha256, into, &mut run)
}
fn fetch_with(
url: &str,
sha256: &str,
into: &Path,
run: &mut dyn FnMut(Downloader, &Path, &[String]) -> Ran,
) -> Result<Fetched, CliError> {
if into.exists() {
verify(into, sha256)?;
return Ok(Fetched::AlreadyThere);
}
let parent = into
.parent()
.ok_or_else(|| err(format!("{} is not a path a file can be written to", into.display())))?;
fs::create_dir_all(parent).map_err(|why| err(format!("{}: {why}", parent.display())))?;
let partial = partial(into);
let mut absent = Vec::new();
for downloader in Downloader::ORDER {
let argv = downloader.argv(url, &partial);
match run(downloader, &partial, &argv) {
Ran::Absent => {
absent.push(downloader);
continue;
}
Ran::Failed(said) => {
let _ = fs::remove_file(&partial);
let detail = if said.is_empty() { String::new() } else { format!(": {said}") };
return Err(err(format!(
"`{}` could not download {url}{detail}",
downloader.program()
)));
}
Ran::Worked => {
if let Err(why) = verify(&partial, sha256) {
let _ = fs::remove_file(&partial);
return Err(err(format!(
"the download of {url} was deleted rather than kept: {}",
why.message
)));
}
fs::rename(&partial, into)
.map_err(|why| err(format!("{}: {why}", into.display())))?;
return Ok(Fetched::Downloaded(downloader));
}
}
}
let tried: Vec<&str> = absent.iter().map(|downloader| downloader.program()).collect();
Err(err(format!(
"none of {} can be run on this machine and rucc has no downloader of its own, so \
download {url}, check that its sha256 is {sha256}, put it at {}, and run this again, \
which carries on from the check",
tried.join(", "),
into.display()
)))
}
fn partial(into: &Path) -> PathBuf {
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default();
let mut name = OsString::from(into.file_name().unwrap_or_default());
name.push(format!(".part.{}.{}", std::process::id(), now.as_nanos()));
into.with_file_name(name)
}
fn run(downloader: Downloader, _partial: &Path, argv: &[String]) -> Ran {
let output = Command::new(downloader.program()).args(argv).output();
let output = match output {
Ok(output) => output,
Err(why) if why.kind() == io::ErrorKind::NotFound => return Ran::Absent,
Err(why) => return Ran::Failed(why.to_string()),
};
if output.status.success() {
return Ran::Worked;
}
let said = String::from_utf8_lossy(&output.stderr);
Ran::Failed(said.trim().to_owned())
}
#[cfg(test)]
mod tests {
use super::{Downloader, Fetched, Ran, fetch_with, partial};
use rucc_sysroot::sha256;
use std::cell::RefCell;
use std::path::{Path, PathBuf};
struct Tree(PathBuf);
impl Drop for Tree {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
impl Tree {
fn new(name: &str) -> Tree {
let dir =
std::env::temp_dir().join(format!("rucc-fetch-{}-{name}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("a temporary directory should be writable");
Tree(dir)
}
}
const URL: &str = "https://musl.libc.org/releases/musl-1.2.5.tar.gz";
const BYTES: &[u8] = b"what the release holds\n";
fn hash() -> String {
sha256::hex(BYTES)
}
#[test]
fn the_three_command_lines() {
let into = Path::new("/cache/downloads/musl.tar.gz");
let curl = Downloader::Curl.argv(URL, into);
assert_eq!(Downloader::Curl.program(), "curl");
assert!(curl.contains(&"--fail".to_owned()));
assert!(curl.contains(&"--location".to_owned()));
assert_eq!(curl.last().expect("the url goes last"), URL);
assert!(curl.contains(&"/cache/downloads/musl.tar.gz".to_owned()));
let wget = Downloader::Wget.argv(URL, into);
assert_eq!(Downloader::Wget.program(), "wget");
assert_eq!(
wget,
vec![
"--quiet".to_owned(),
"--output-document".to_owned(),
"/cache/downloads/musl.tar.gz".to_owned(),
URL.to_owned(),
]
);
let shell = Downloader::PowerShell.argv(URL, Path::new(r"C:\Program Files\a.tar.gz"));
assert_eq!(Downloader::PowerShell.program(), "powershell");
let script = shell.last().expect("the script is the last argument");
assert!(script.contains("Invoke-WebRequest"), "{script}");
assert!(script.contains(r"-OutFile 'C:\Program Files\a.tar.gz'"), "{script}");
assert!(script.contains(&format!("-Uri '{URL}'")), "{script}");
assert_eq!(shell.len(), 4);
}
#[test]
fn a_url_with_a_quote_in_it_does_not_end_the_powershell_string() {
let argv = Downloader::PowerShell.argv("https://h/it's.tar.gz", Path::new("/tmp/a"));
let script = argv.last().expect("the script");
assert!(script.contains("-Uri 'https://h/it''s.tar.gz'"), "{script}");
}
#[test]
fn the_order_is_tried_until_one_of_them_runs() {
let tree = Tree::new("order");
let into = tree.0.join("musl.tar.gz");
let tried = RefCell::new(Vec::new());
let done = fetch_with(URL, &hash(), &into, &mut |downloader, partial, _| {
tried.borrow_mut().push(downloader);
if downloader == Downloader::Curl {
return Ran::Absent;
}
std::fs::write(partial, BYTES).expect("a downloader writes the file");
Ran::Worked
})
.expect("wget should have been enough");
assert_eq!(done, Fetched::Downloaded(Downloader::Wget));
assert_eq!(tried.into_inner(), vec![Downloader::Curl, Downloader::Wget]);
assert_eq!(std::fs::read(&into).expect("the file"), BYTES);
}
#[test]
fn a_downloader_that_ran_and_failed_is_the_end_of_it() {
let tree = Tree::new("failed");
let into = tree.0.join("musl.tar.gz");
let tried = RefCell::new(Vec::new());
let why = fetch_with(URL, &hash(), &into, &mut |downloader, _, _| {
tried.borrow_mut().push(downloader);
Ran::Failed("curl: (22) The requested URL returned error: 404".to_owned())
})
.expect_err("a 404 is a failure");
assert!(why.message.contains("`curl` could not download"), "{}", why.message);
assert!(why.message.contains("404"), "{}", why.message);
assert_eq!(tried.into_inner(), vec![Downloader::Curl]);
assert!(!into.exists(), "nothing should have been left under the artifact's name");
}
#[test]
fn a_machine_with_none_of_them_is_told_what_to_do_by_hand() {
let tree = Tree::new("none");
let into = tree.0.join("musl.tar.gz");
let tried = RefCell::new(Vec::new());
let why = fetch_with(URL, &hash(), &into, &mut |downloader, _, _| {
tried.borrow_mut().push(downloader);
Ran::Absent
})
.expect_err("there is nothing to download with");
assert!(why.message.contains(URL), "{}", why.message);
assert!(why.message.contains(&hash()), "{}", why.message);
assert!(why.message.contains(&into.display().to_string()), "{}", why.message);
assert!(why.message.contains("curl, wget, powershell"), "{}", why.message);
assert_eq!(tried.into_inner(), Downloader::ORDER.to_vec());
}
#[test]
fn bytes_that_do_not_match_are_deleted_rather_than_installed() {
let tree = Tree::new("corrupt");
let into = tree.0.join("musl.tar.gz");
let written = RefCell::new(PathBuf::new());
let why = fetch_with(URL, &hash(), &into, &mut |_, partial, _| {
*written.borrow_mut() = partial.to_path_buf();
std::fs::write(partial, b"half of it\n").expect("a downloader writes the file");
Ran::Worked
})
.expect_err("these are not the bytes");
assert!(why.message.contains("where this release pins"), "{}", why.message);
assert!(why.message.contains("deleted rather than kept"), "{}", why.message);
assert!(!into.exists(), "nothing should be under the artifact's name");
assert!(!written.into_inner().exists(), "the partial file should be gone");
}
#[test]
fn a_file_that_is_already_there_and_matches_is_left_alone() {
let tree = Tree::new("again");
let into = tree.0.join("musl.tar.gz");
std::fs::write(&into, BYTES).expect("the file");
let done = fetch_with(URL, &hash(), &into, &mut |_, _, _| {
panic!("nothing should have been run");
})
.expect("it is already here");
assert_eq!(done, Fetched::AlreadyThere);
assert_eq!(std::fs::read(&into).expect("the file"), BYTES);
}
#[test]
fn a_file_that_is_already_there_and_does_not_match_is_refused_rather_than_replaced() {
let tree = Tree::new("wrong");
let into = tree.0.join("musl.tar.gz");
std::fs::write(&into, b"something else\n").expect("the file");
let why = fetch_with(URL, &hash(), &into, &mut |_, _, _| {
panic!("nothing should have been run");
})
.expect_err("that is not the artifact");
assert!(why.message.contains("where this release pins"), "{}", why.message);
assert!(into.exists(), "a file somebody placed should still be there");
}
#[test]
fn a_download_in_progress_is_not_under_the_name_of_the_artifact() {
let into = Path::new("/cache/downloads/musl-1.2.5.tar.gz");
let partial = partial(into);
assert_eq!(partial.parent(), into.parent());
assert_ne!(partial, into);
let name = partial.file_name().expect("a name").to_string_lossy().into_owned();
assert!(name.starts_with("musl-1.2.5.tar.gz.part."), "{name}");
}
#[test]
fn the_parent_directory_is_made_if_it_is_not_there() {
let tree = Tree::new("parent");
let into = tree.0.join("downloads").join("musl.tar.gz");
let done = fetch_with(URL, &hash(), &into, &mut |_, partial, _| {
assert!(partial.parent().expect("a parent").is_dir(), "the directory should be there");
std::fs::write(partial, BYTES).expect("a downloader writes the file");
Ran::Worked
})
.expect("this should work");
assert_eq!(done, Fetched::Downloaded(Downloader::Curl));
}
}