use crate::error::ScopeError;
use async_trait::async_trait;
use serde::Deserialize;
use sha2::{Digest, Sha256};
use std::ffi::OsStr;
use std::io::Read;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::Duration;
pub const LATEST_RELEASE_URL: &str =
"https://api.github.com/repos/robot-accomplice/ghola/releases/latest";
pub const RELEASES_PAGE: &str = "https://github.com/robot-accomplice/ghola/releases";
pub const GO_PACKAGE: &str = "github.com/robot-accomplice/ghola/cmd/ghola@latest";
pub const GO_INSTALL: &str = "go install github.com/robot-accomplice/ghola/cmd/ghola@latest";
const ALLOWED_HOSTS: &[&str] = &[
"api.github.com",
"github.com",
"objects.githubusercontent.com",
"release-assets.githubusercontent.com",
];
const MAX_DOWNLOAD_BYTES: usize = 100 * 1024 * 1024;
const MAX_BINARY_BYTES: u64 = 200 * 1024 * 1024;
const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(120);
const CHECKSUMS: &str = "checksums.txt";
#[async_trait]
pub trait ReleaseSource: Send + Sync {
async fn fetch(&self, url: &str) -> Result<Vec<u8>, ScopeError>;
}
pub struct GithubReleases {
client: reqwest::Client,
allowed: fn(&str) -> bool,
}
impl GithubReleases {
pub fn new() -> Result<Self, ScopeError> {
Self::with_policy(allowed_url, true)
}
fn with_policy(allowed: fn(&str) -> bool, https_only: bool) -> Result<Self, ScopeError> {
let redirects = reqwest::redirect::Policy::custom(move |attempt| {
if attempt.previous().len() >= 10 {
attempt.error("too many redirects")
} else if allowed(attempt.url().as_str()) {
attempt.follow()
} else {
let to = attempt.url().to_string();
attempt.error(format!("redirect to {to} is not a GitHub HTTPS URL"))
}
});
let client = reqwest::Client::builder()
.https_only(https_only)
.redirect(redirects)
.timeout(DOWNLOAD_TIMEOUT)
.user_agent(concat!("scope-bca/", env!("CARGO_PKG_VERSION")))
.build()
.map_err(|e| {
ScopeError::Network(format!("failed to build the download client: {e}"))
})?;
Ok(Self { client, allowed })
}
}
#[async_trait]
impl ReleaseSource for GithubReleases {
async fn fetch(&self, url: &str) -> Result<Vec<u8>, ScopeError> {
if !(self.allowed)(url) {
return Err(ScopeError::Network(format!(
"refusing to download {url}: not a GitHub HTTPS URL"
)));
}
let resp = self
.client
.get(url)
.send()
.await
.map_err(|e| ScopeError::Network(format!("download of {url} failed: {e}")))?;
let status = resp.status();
if !status.is_success() {
return Err(ScopeError::Network(format!(
"download of {url} failed: HTTP {status}"
)));
}
if resp
.content_length()
.is_some_and(|n| n > MAX_DOWNLOAD_BYTES as u64)
{
return Err(too_large(url));
}
let body = resp
.bytes()
.await
.map_err(|e| ScopeError::Network(format!("download of {url} failed: {e}")))?;
if body.len() > MAX_DOWNLOAD_BYTES {
return Err(too_large(url));
}
Ok(body.to_vec())
}
}
fn too_large(url: &str) -> ScopeError {
ScopeError::Network(format!(
"download of {url} is larger than {MAX_DOWNLOAD_BYTES} bytes"
))
}
pub fn allowed_url(url: &str) -> bool {
reqwest::Url::parse(url).is_ok_and(|u| {
u.scheme() == "https" && u.host_str().is_some_and(|h| ALLOWED_HOSTS.contains(&h))
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Target {
pub os: &'static str,
pub arch: &'static str,
}
impl Target {
pub fn current() -> Option<Self> {
Self::from_rust(std::env::consts::OS, std::env::consts::ARCH)
}
pub fn from_rust(os: &str, arch: &str) -> Option<Self> {
let os = match os {
"linux" => "linux",
"macos" => "darwin",
"windows" => "windows",
_ => return None,
};
let arch = match arch {
"x86_64" => "amd64",
"aarch64" => "arm64",
_ => return None,
};
Some(Self { os, arch })
}
fn is_zip(self) -> bool {
self.os == "windows"
}
pub fn asset_name(self, version: &str) -> String {
let ext = if self.is_zip() { "zip" } else { "tar.gz" };
format!("ghola_{version}_{}_{}.{ext}", self.os, self.arch)
}
pub fn binary_name(self) -> &'static str {
if self.is_zip() { "ghola.exe" } else { "ghola" }
}
}
#[derive(Deserialize)]
struct Release {
tag_name: String,
#[serde(default)]
assets: Vec<Asset>,
}
#[derive(Deserialize)]
struct Asset {
name: String,
browser_download_url: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Outcome {
Installed { path: PathBuf, version: String },
NoAsset { version: String, asset: String },
}
pub async fn install_latest(
source: &dyn ReleaseSource,
target: Target,
dir: &Path,
) -> Result<Outcome, ScopeError> {
let body = source.fetch(LATEST_RELEASE_URL).await?;
let release: Release = serde_json::from_slice(&body)
.map_err(|e| ScopeError::Network(format!("unreadable ghola release: {e}")))?;
let version = release.tag_name.trim_start_matches('v').to_string();
let name = target.asset_name(&version);
let Some(archive) = release.assets.iter().find(|a| a.name == name) else {
return Ok(Outcome::NoAsset {
version,
asset: name,
});
};
let sums = release
.assets
.iter()
.find(|a| a.name == CHECKSUMS)
.ok_or_else(|| {
ScopeError::Network(format!(
"ghola v{version} has no {CHECKSUMS}; not installing an unchecked file"
))
})?;
let sums = source.fetch(&sums.browser_download_url).await?;
let want = expected_sha256(&String::from_utf8_lossy(&sums), &name).ok_or_else(|| {
ScopeError::Network(format!(
"{CHECKSUMS} of ghola v{version} does not list {name}"
))
})?;
let bytes = source.fetch(&archive.browser_download_url).await?;
let got: [u8; 32] = Sha256::digest(&bytes).into();
if got != want {
return Err(ScopeError::Network(format!(
"{name} does not match its SHA-256 in {CHECKSUMS} (expected {}, got {}); not installing it",
hex::encode(want),
hex::encode(got)
)));
}
let binary = extract_binary(&bytes, target)?;
let path = write_executable(dir, target.binary_name(), &binary)?;
Ok(Outcome::Installed { path, version })
}
pub fn expected_sha256(sums: &str, file: &str) -> Option<[u8; 32]> {
sums.lines().find_map(|line| {
let mut parts = line.split_whitespace();
let hash = parts.next()?;
let name = parts.next()?.trim_start_matches('*');
if name != file || parts.next().is_some() {
return None;
}
hex::decode(hash).ok()?.try_into().ok()
})
}
fn extract_binary(archive: &[u8], target: Target) -> Result<Vec<u8>, ScopeError> {
let name = target.binary_name();
let found = if target.is_zip() {
extract_from_zip(archive, name)?
} else {
extract_from_tar_gz(archive, name)?
};
found.ok_or_else(|| ScopeError::Network(format!("the ghola archive has no {name} at its root")))
}
fn archive_error(e: impl std::fmt::Display) -> ScopeError {
ScopeError::Network(format!("unreadable ghola archive: {e}"))
}
fn read_limited(entry: impl Read) -> Result<Vec<u8>, ScopeError> {
let mut buf = Vec::new();
entry
.take(MAX_BINARY_BYTES + 1)
.read_to_end(&mut buf)
.map_err(archive_error)?;
if buf.len() as u64 > MAX_BINARY_BYTES {
return Err(archive_error("the binary is too large"));
}
Ok(buf)
}
fn extract_from_tar_gz(archive: &[u8], name: &str) -> Result<Option<Vec<u8>>, ScopeError> {
let mut tar = tar::Archive::new(flate2::read::GzDecoder::new(archive));
for entry in tar.entries().map_err(archive_error)? {
let entry = entry.map_err(archive_error)?;
if !entry.header().entry_type().is_file() {
continue;
}
let path = entry.path().map_err(archive_error)?;
if path.strip_prefix(".").unwrap_or(&path) == Path::new(name) {
return read_limited(entry).map(Some);
}
}
Ok(None)
}
fn extract_from_zip(archive: &[u8], name: &str) -> Result<Option<Vec<u8>>, ScopeError> {
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(archive)).map_err(archive_error)?;
for i in 0..zip.len() {
let file = zip.by_index(i).map_err(archive_error)?;
if file.is_file() && file.name().is_ok_and(|n| n == name) {
return read_limited(file).map(Some);
}
}
Ok(None)
}
fn write_executable(dir: &Path, name: &str, bytes: &[u8]) -> Result<PathBuf, ScopeError> {
let context =
|e| ScopeError::io_context(e, format!("cannot install ghola in {}", dir.display()));
std::fs::create_dir_all(dir).map_err(context)?;
let tmp = dir.join(format!(".{name}.{}.part", std::process::id()));
let dest = dir.join(name);
let written = std::fs::write(&tmp, bytes)
.and_then(|()| make_executable(&tmp))
.and_then(|()| std::fs::rename(&tmp, &dest));
if let Err(e) = written {
let _ = std::fs::remove_file(&tmp);
return Err(context(e));
}
Ok(dest)
}
#[cfg(unix)]
fn make_executable(path: &Path) -> std::io::Result<()> {
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755))
}
#[cfg(not(unix))]
fn make_executable(_: &Path) -> std::io::Result<()> {
Ok(())
}
pub fn install_dir() -> PathBuf {
crate::paths::home().join("bin")
}
pub fn find() -> Option<PathBuf> {
locate(std::env::var_os("PATH").as_deref(), &install_dir())
}
pub fn locate(path: Option<&OsStr>, dir: &Path) -> Option<PathBuf> {
path.and_then(|p| in_path(p, "ghola")).or_else(|| {
let file = dir.join(format!("ghola{}", std::env::consts::EXE_SUFFIX));
file.is_file().then_some(file)
})
}
pub fn in_path(path: &OsStr, name: &str) -> Option<PathBuf> {
let file = format!("{name}{}", std::env::consts::EXE_SUFFIX);
std::env::split_paths(path)
.map(|dir| dir.join(&file))
.find(|p| p.is_file())
}
pub fn find_go() -> Option<PathBuf> {
std::env::var_os("PATH").and_then(|p| in_path(&p, "go"))
}
pub fn go_install(go: &Path, dir: &Path) -> Result<PathBuf, ScopeError> {
std::fs::create_dir_all(dir)
.map_err(|e| ScopeError::io_context(e, format!("cannot create {}", dir.display())))?;
let status = Command::new(go)
.args(["install", GO_PACKAGE])
.env("GOBIN", dir)
.status()
.map_err(|e| ScopeError::io_context(e, format!("cannot run {}", go.display())))?;
if !status.success() {
return Err(ScopeError::Other(format!(
"`{GO_INSTALL}` failed ({status})"
)));
}
let path = dir.join(format!("ghola{}", std::env::consts::EXE_SUFFIX));
if path.is_file() {
Ok(path)
} else {
Err(ScopeError::Other(format!(
"`{GO_INSTALL}` did not write {}",
path.display()
)))
}
}
pub fn manual_steps() -> Vec<String> {
vec![
"Run `scope setup`: it offers to download ghola from its GitHub releases.".into(),
format!(
"Or download the archive for this system from {RELEASES_PAGE} and put ghola in {}",
install_dir().display()
),
format!("Or build it: {GO_INSTALL}"),
]
}
#[cfg(any(test, feature = "test-support"))]
pub mod test_support {
use super::{CHECKSUMS, LATEST_RELEASE_URL, ReleaseSource, Target};
use crate::error::ScopeError;
use async_trait::async_trait;
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::io::Write;
use std::sync::Mutex;
pub const BINARY: &[u8] = b"\x7fELF fake ghola";
pub struct FakeRelease {
pub files: HashMap<String, Vec<u8>>,
pub fetched: Mutex<Vec<String>>,
}
#[async_trait]
impl ReleaseSource for FakeRelease {
async fn fetch(&self, url: &str) -> Result<Vec<u8>, ScopeError> {
self.fetched.lock().unwrap().push(url.to_string());
self.files
.get(url)
.cloned()
.ok_or_else(|| ScopeError::Network(format!("HTTP 404 for {url}")))
}
}
pub fn url(file: &str) -> String {
format!("https://github.com/robot-accomplice/ghola/releases/download/v0.6.2/{file}")
}
pub fn tar_gz(entries: &[(&str, &[u8])]) -> Vec<u8> {
let gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
let mut tar = tar::Builder::new(gz);
for (name, data) in entries {
let mut header = tar::Header::new_gnu();
header.set_size(data.len() as u64);
header.set_mode(0o755);
header.set_cksum();
tar.append_data(&mut header, name, *data).unwrap();
}
tar.into_inner().unwrap().finish().unwrap()
}
pub fn zip_of(entries: &[(&str, &[u8])]) -> Vec<u8> {
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for (name, data) in entries {
zip.start_file(*name, zip::write::SimpleFileOptions::default())
.unwrap();
zip.write_all(data).unwrap();
}
zip.finish().unwrap().into_inner()
}
pub fn release(target: Target, archive: Vec<u8>, listed: Option<String>) -> FakeRelease {
let name = target.asset_name("0.6.2");
let hash = listed.unwrap_or_else(|| hex::encode(Sha256::digest(&archive)));
let sums = format!("{} other.deb\n{hash} {name}\n", "0".repeat(64));
let json = serde_json::json!({
"tag_name": "v0.6.2",
"assets": [
{"name": CHECKSUMS, "browser_download_url": url(CHECKSUMS)},
{"name": name, "browser_download_url": url(&name)},
]
});
let files = HashMap::from([
(
LATEST_RELEASE_URL.to_string(),
json.to_string().into_bytes(),
),
(url(CHECKSUMS), sums.into_bytes()),
(url(&name), archive),
]);
FakeRelease {
files,
fetched: Mutex::default(),
}
}
}
#[cfg(test)]
mod tests {
use super::test_support::*;
use super::*;
const LINUX: Target = Target {
os: "linux",
arch: "amd64",
};
const WINDOWS: Target = Target {
os: "windows",
arch: "amd64",
};
#[test]
fn test_target_names_match_the_release_assets() {
let mac = Target::from_rust("macos", "aarch64").unwrap();
assert_eq!(mac.asset_name("0.6.2"), "ghola_0.6.2_darwin_arm64.tar.gz");
assert_eq!(mac.binary_name(), "ghola");
let win = Target::from_rust("windows", "x86_64").unwrap();
assert_eq!(win.asset_name("0.6.2"), "ghola_0.6.2_windows_amd64.zip");
assert_eq!(win.binary_name(), "ghola.exe");
assert_eq!(Target::from_rust("freebsd", "x86_64"), None);
assert_eq!(Target::from_rust("linux", "riscv64"), None);
}
#[test]
fn test_only_github_https_urls_are_allowed() {
assert!(allowed_url(LATEST_RELEASE_URL));
assert!(allowed_url(&url("checksums.txt")));
assert!(allowed_url(
"https://release-assets.githubusercontent.com/x?sig=1"
));
assert!(!allowed_url("http://github.com/robot-accomplice/ghola"));
assert!(!allowed_url("https://github.com.evil.example/x"));
assert!(!allowed_url("https://example.com/ghola.tar.gz"));
assert!(!allowed_url("not a url"));
}
#[test]
fn test_expected_sha256_reads_the_named_line() {
let a = "ab".repeat(32);
let sums = format!("{} ghola_x.zip\n{a} ghola_y.tar.gz\n", "cd".repeat(32));
assert_eq!(expected_sha256(&sums, "ghola_y.tar.gz"), Some([0xab; 32]));
assert_eq!(
expected_sha256(&format!("{a} *f.zip"), "f.zip"),
Some([0xab; 32])
);
assert_eq!(expected_sha256(&sums, "missing.zip"), None);
assert_eq!(expected_sha256("zz f.zip", "f.zip"), None);
}
#[tokio::test]
async fn test_install_tar_gz_writes_only_the_binary() {
let dir = tempfile::tempdir().unwrap();
let archive = tar_gz(&[("LICENSE", b"MIT"), ("ghola", BINARY)]);
let source = release(LINUX, archive, None);
let outcome = install_latest(&source, LINUX, dir.path()).await.unwrap();
let path = dir.path().join("ghola");
assert_eq!(
outcome,
Outcome::Installed {
path: path.clone(),
version: "0.6.2".into()
}
);
assert_eq!(std::fs::read(&path).unwrap(), BINARY);
let names: Vec<_> = std::fs::read_dir(dir.path())
.unwrap()
.map(|e| e.unwrap().file_name())
.collect();
assert_eq!(names, vec![std::ffi::OsString::from("ghola")]);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(&path).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o755);
}
}
#[tokio::test]
async fn test_install_zip_on_windows() {
let dir = tempfile::tempdir().unwrap();
let archive = zip_of(&[("README.md", b"hi"), ("ghola.exe", BINARY)]);
let source = release(WINDOWS, archive, None);
let outcome = install_latest(&source, WINDOWS, dir.path()).await.unwrap();
let path = dir.path().join("ghola.exe");
assert!(matches!(outcome, Outcome::Installed { path: p, .. } if p == path));
assert_eq!(std::fs::read(&path).unwrap(), BINARY);
}
#[tokio::test]
async fn test_a_bad_checksum_installs_nothing() {
let dir = tempfile::tempdir().unwrap();
let archive = tar_gz(&[("ghola", BINARY)]);
let source = release(LINUX, archive, Some("11".repeat(32)));
let err = install_latest(&source, LINUX, dir.path())
.await
.unwrap_err();
assert!(err.to_string().contains("SHA-256"), "{err}");
assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 0);
}
#[tokio::test]
async fn test_an_unlisted_archive_installs_nothing() {
let dir = tempfile::tempdir().unwrap();
let mut source = release(LINUX, tar_gz(&[("ghola", BINARY)]), None);
source
.files
.insert(url(CHECKSUMS), b"00 something-else.tar.gz\n".to_vec());
let err = install_latest(&source, LINUX, dir.path())
.await
.unwrap_err();
assert!(err.to_string().contains("does not list"), "{err}");
let fetched = source.fetched.lock().unwrap();
assert!(
!fetched.iter().any(|u| u.ends_with(".tar.gz")),
"{fetched:?}"
);
}
#[tokio::test]
async fn test_a_release_without_checksums_installs_nothing() {
let dir = tempfile::tempdir().unwrap();
let mut source = release(LINUX, tar_gz(&[("ghola", BINARY)]), None);
let json = serde_json::json!({
"tag_name": "v0.6.2",
"assets": [{"name": LINUX.asset_name("0.6.2"),
"browser_download_url": url(&LINUX.asset_name("0.6.2"))}]
});
source
.files
.insert(LATEST_RELEASE_URL.into(), json.to_string().into_bytes());
let err = install_latest(&source, LINUX, dir.path())
.await
.unwrap_err();
assert!(err.to_string().contains(CHECKSUMS), "{err}");
assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 0);
}
#[tokio::test]
async fn test_no_asset_for_the_target_is_reported() {
let dir = tempfile::tempdir().unwrap();
let source = release(LINUX, tar_gz(&[("ghola", BINARY)]), None);
let mac = Target::from_rust("macos", "x86_64").unwrap();
let outcome = install_latest(&source, mac, dir.path()).await.unwrap();
assert_eq!(
outcome,
Outcome::NoAsset {
version: "0.6.2".into(),
asset: "ghola_0.6.2_darwin_amd64.tar.gz".into()
}
);
}
#[tokio::test]
async fn test_a_binary_below_the_root_is_not_taken() {
let dir = tempfile::tempdir().unwrap();
let archive = tar_gz(&[("bin/ghola", BINARY)]);
let source = release(LINUX, archive, None);
let err = install_latest(&source, LINUX, dir.path())
.await
.unwrap_err();
assert!(err.to_string().contains("no ghola"), "{err}");
}
#[test]
fn test_locate_prefers_path_then_the_install_dir() {
let file = format!("ghola{}", std::env::consts::EXE_SUFFIX);
let on_path = tempfile::tempdir().unwrap();
let installed = tempfile::tempdir().unwrap();
let empty = tempfile::tempdir().unwrap();
let path = std::env::join_paths([empty.path(), on_path.path()]).unwrap();
assert_eq!(locate(Some(&path), installed.path()), None);
std::fs::write(installed.path().join(&file), b"x").unwrap();
assert_eq!(
locate(Some(&path), installed.path()),
Some(installed.path().join(&file))
);
std::fs::write(on_path.path().join(&file), b"x").unwrap();
assert_eq!(
locate(Some(&path), installed.path()),
Some(on_path.path().join(&file))
);
assert_eq!(
locate(None, installed.path()),
Some(installed.path().join(&file))
);
}
#[test]
fn test_in_path_finds_a_program_in_any_path_folder() {
let empty = tempfile::tempdir().unwrap();
let with = tempfile::tempdir().unwrap();
let program = format!("ghola{}", std::env::consts::EXE_SUFFIX);
std::fs::write(with.path().join(&program), b"").unwrap();
let path = std::env::join_paths([empty.path(), with.path()]).unwrap();
assert_eq!(in_path(&path, "ghola"), Some(with.path().join(&program)));
let only_empty = std::env::join_paths([empty.path()]).unwrap();
assert_eq!(in_path(&only_empty, "ghola"), None);
}
#[test]
fn test_manual_steps_use_the_cmd_package() {
let steps = manual_steps().join("\n");
assert!(steps.contains("/cmd/ghola@latest"), "{steps}");
assert!(steps.contains(RELEASES_PAGE), "{steps}");
}
#[tokio::test]
async fn test_github_releases_refuses_other_urls_before_any_request() {
let source = GithubReleases::new().unwrap();
for url in [
"https://example.com/ghola.tar.gz",
"http://github.com/robot-accomplice/ghola",
] {
let err = source.fetch(url).await.unwrap_err();
assert!(err.to_string().contains("not a GitHub HTTPS URL"), "{err}");
}
}
fn loopback(url: &str) -> bool {
reqwest::Url::parse(url).is_ok_and(|u| u.host_str() == Some("127.0.0.1"))
}
fn local_source() -> GithubReleases {
GithubReleases::with_policy(loopback, false).unwrap()
}
#[tokio::test]
async fn test_fetch_returns_the_body() {
let mut server = mockito::Server::new_async().await;
let mock = server
.mock("GET", "/checksums.txt")
.with_body("abc ghola.tar.gz\n")
.create_async()
.await;
let body = local_source()
.fetch(&format!("{}/checksums.txt", server.url()))
.await
.unwrap();
assert_eq!(body, b"abc ghola.tar.gz\n");
mock.assert_async().await;
}
#[tokio::test]
async fn test_fetch_reports_an_http_error() {
let mut server = mockito::Server::new_async().await;
server
.mock("GET", "/missing")
.with_status(404)
.create_async()
.await;
let err = local_source()
.fetch(&format!("{}/missing", server.url()))
.await
.unwrap_err();
assert!(err.to_string().contains("HTTP 404"), "{err}");
}
#[tokio::test]
async fn test_fetch_follows_an_allowed_redirect() {
let mut server = mockito::Server::new_async().await;
server
.mock("GET", "/download")
.with_status(302)
.with_header("location", &format!("{}/asset", server.url()))
.create_async()
.await;
let asset = server
.mock("GET", "/asset")
.with_body("archive")
.create_async()
.await;
let body = local_source()
.fetch(&format!("{}/download", server.url()))
.await
.unwrap();
assert_eq!(body, b"archive");
asset.assert_async().await;
}
#[tokio::test]
async fn test_fetch_refuses_a_redirect_off_the_allow_list() {
let mut server = mockito::Server::new_async().await;
server
.mock("GET", "/download")
.with_status(302)
.with_header("location", "http://example.invalid/ghola.tar.gz")
.create_async()
.await;
let err = local_source()
.fetch(&format!("{}/download", server.url()))
.await
.unwrap_err();
assert!(err.to_string().contains("redirect"), "{err}");
}
#[tokio::test]
async fn test_fetch_stops_a_redirect_loop() {
let mut server = mockito::Server::new_async().await;
let looped = server
.mock("GET", "/loop")
.with_status(302)
.with_header("location", &format!("{}/loop", server.url()))
.expect(10)
.create_async()
.await;
let err = local_source()
.fetch(&format!("{}/loop", server.url()))
.await
.unwrap_err();
assert!(err.to_string().contains("redirect"), "{err}");
looped.assert_async().await;
}
#[tokio::test]
async fn test_fetch_reports_an_unreachable_host() {
let err = local_source()
.fetch("http://127.0.0.1:1/ghola.tar.gz")
.await
.unwrap_err();
assert!(err.to_string().contains("failed"), "{err}");
}
async fn serve_once(response: &'static [u8]) -> String {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.unwrap();
let mut request = [0u8; 1024];
let _ = stream.read(&mut request).await;
let _ = stream.write_all(response).await;
});
format!("http://{addr}/ghola.tar.gz")
}
#[tokio::test]
async fn test_fetch_refuses_an_announced_oversize_body() {
let url = serve_once(b"HTTP/1.1 200 OK\r\ncontent-length: 200000000\r\n\r\npartial").await;
let err = local_source().fetch(&url).await.unwrap_err();
assert!(err.to_string().contains("larger than"), "{err}");
}
#[tokio::test]
async fn test_fetch_reports_a_truncated_body() {
let url = serve_once(b"HTTP/1.1 200 OK\r\ncontent-length: 100\r\n\r\nshort").await;
let err = local_source().fetch(&url).await.unwrap_err();
assert!(err.to_string().contains("failed"), "{err}");
}
#[tokio::test]
async fn test_a_corrupt_archive_installs_nothing() {
for target in [LINUX, WINDOWS] {
let dir = tempfile::tempdir().unwrap();
let source = release(target, b"not an archive".to_vec(), None);
let err = install_latest(&source, target, dir.path())
.await
.unwrap_err();
assert!(
err.to_string().contains("unreadable ghola archive"),
"{err}"
);
assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 0);
}
}
#[tokio::test]
async fn test_a_zip_without_ghola_exe_installs_nothing() {
let dir = tempfile::tempdir().unwrap();
let source = release(WINDOWS, zip_of(&[("ghola", BINARY)]), None);
let err = install_latest(&source, WINDOWS, dir.path())
.await
.unwrap_err();
assert!(err.to_string().contains("no ghola.exe"), "{err}");
assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 0);
}
#[tokio::test]
async fn test_a_failed_write_leaves_no_temporary_file() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir(dir.path().join("ghola")).unwrap();
std::fs::write(dir.path().join("ghola").join("keep"), b"x").unwrap();
let source = release(LINUX, tar_gz(&[("ghola", BINARY)]), None);
let err = install_latest(&source, LINUX, dir.path())
.await
.unwrap_err();
assert!(err.to_string().contains("cannot install ghola"), "{err}");
let names: Vec<_> = std::fs::read_dir(dir.path())
.unwrap()
.map(|e| e.unwrap().file_name())
.collect();
assert_eq!(names, vec![std::ffi::OsString::from("ghola")]);
}
#[test]
fn test_find_go_returns_a_go_program() {
if let Some(go) = find_go() {
assert!(go.is_file(), "{}", go.display());
assert_eq!(go.file_stem(), Some(OsStr::new("go")));
}
}
#[test]
fn test_go_install_reports_a_missing_go() {
let dir = tempfile::tempdir().unwrap();
let err = go_install(&dir.path().join("no-go"), &dir.path().join("bin")).unwrap_err();
assert!(err.to_string().contains("cannot run"), "{err}");
}
#[test]
fn test_go_install_reports_a_folder_it_cannot_create() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("file");
std::fs::write(&file, b"x").unwrap();
let err = go_install(&dir.path().join("go"), &file.join("bin")).unwrap_err();
assert!(err.to_string().contains("cannot create"), "{err}");
}
#[cfg(unix)]
fn script(dir: &Path, name: &str, body: &str) -> PathBuf {
use std::os::unix::fs::PermissionsExt;
let path = dir.join(name);
std::fs::write(&path, format!("#!/bin/sh\n{body}\n")).unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
path
}
#[cfg(unix)]
#[test]
fn test_go_install_builds_the_cmd_package_into_the_folder() {
let dir = tempfile::tempdir().unwrap();
let check = format!("[ \"$1 $2\" = \"install {GO_PACKAGE}\" ] || exit 9");
let go = script(
dir.path(),
"go",
&format!("{check}\nprintf built > \"$GOBIN/ghola\""),
);
let bin = dir.path().join("bin");
let path = go_install(&go, &bin).unwrap();
assert_eq!(path, bin.join("ghola"));
assert_eq!(std::fs::read(&path).unwrap(), b"built");
}
#[cfg(unix)]
#[test]
fn test_go_install_reports_a_failed_build() {
let dir = tempfile::tempdir().unwrap();
let go = script(dir.path(), "go", "exit 3");
let err = go_install(&go, &dir.path().join("bin")).unwrap_err();
assert!(err.to_string().contains("failed"), "{err}");
assert!(err.to_string().contains(GO_INSTALL), "{err}");
}
#[cfg(unix)]
#[test]
fn test_go_install_reports_a_build_that_wrote_nothing() {
let dir = tempfile::tempdir().unwrap();
let go = script(dir.path(), "go", "exit 0");
let err = go_install(&go, &dir.path().join("bin")).unwrap_err();
assert!(err.to_string().contains("did not write"), "{err}");
}
}