use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use crate::cli::UpdateArgs;
const REPO: &str = "allenhori/zhao-cli";
const EXIT_OK: u8 = 0;
pub fn run(args: &UpdateArgs) -> ExitCode {
let tag = if args.nightly {
"nightly".to_string()
} else if let Some(version) = &args.version {
version.clone()
} else {
"latest".to_string()
};
match update(&tag) {
Ok(installed_path) => {
println!(
"Updated {} to {tag} -- run `zhao --version` to confirm.",
installed_path.display()
);
ExitCode::from(EXIT_OK)
}
Err(message) => crate::engine::fail(&message),
}
}
fn update(tag: &str) -> Result<PathBuf, String> {
let target = platform_target()?;
let archive_name = archive_name(&target);
let url = download_url(tag, &archive_name);
let archive_bytes = download(&url).map_err(|err| {
format!(
"could not download {url}: {err} -- check that {tag:?} is a real release tag at \
https://github.com/{REPO}/releases"
)
})?;
let binary_bytes = extract_binary(&archive_bytes, &target)?;
let current_exe = std::env::current_exe()
.map_err(|err| format!("could not determine the current executable's path: {err}"))?;
replace_binary(¤t_exe, &binary_bytes)?;
Ok(current_exe)
}
fn platform_target() -> Result<String, String> {
let os = std::env::consts::OS;
let arch = std::env::consts::ARCH;
match (os, arch) {
("macos", "aarch64") => Ok("aarch64-apple-darwin".to_string()),
("macos", "x86_64") => Ok("x86_64-apple-darwin".to_string()),
("linux", "x86_64") => Ok("x86_64-unknown-linux-gnu".to_string()),
("windows", "x86_64") => Ok("x86_64-pc-windows-msvc".to_string()),
_ => Err(format!(
"no released binary for {os}/{arch} -- build from source instead: \
cargo install --git https://github.com/{REPO}"
)),
}
}
fn archive_name(target: &str) -> String {
if cfg!(windows) {
format!("zhao-{target}.zip")
} else {
format!("zhao-{target}.tar.gz")
}
}
fn download_url(tag: &str, archive_name: &str) -> String {
if tag == "latest" {
format!("https://github.com/{REPO}/releases/latest/download/{archive_name}")
} else {
format!("https://github.com/{REPO}/releases/download/{tag}/{archive_name}")
}
}
fn download(url: &str) -> Result<Vec<u8>, String> {
let response = ureq::get(url).call().map_err(|err| err.to_string())?;
let mut bytes = Vec::new();
response
.into_reader()
.read_to_end(&mut bytes)
.map_err(|err| err.to_string())?;
Ok(bytes)
}
#[cfg(not(windows))]
fn extract_binary(archive_bytes: &[u8], target: &str) -> Result<Vec<u8>, String> {
let decoder = flate2::read::GzDecoder::new(archive_bytes);
let mut archive = tar::Archive::new(decoder);
let entries = archive
.entries()
.map_err(|err| format!("could not read {target}'s release archive: {err}"))?;
for entry in entries {
let mut entry =
entry.map_err(|err| format!("could not read a release archive entry: {err}"))?;
let path = entry
.path()
.map_err(|err| format!("could not read a release archive entry's path: {err}"))?;
if path.file_name().and_then(|name| name.to_str()) == Some("zhao") {
let mut bytes = Vec::new();
entry
.read_to_end(&mut bytes)
.map_err(|err| format!("could not read the zhao binary from the archive: {err}"))?;
return Ok(bytes);
}
}
Err(format!(
"the {target} release archive doesn't contain a `zhao` binary"
))
}
#[cfg(windows)]
fn extract_binary(archive_bytes: &[u8], target: &str) -> Result<Vec<u8>, String> {
let reader = std::io::Cursor::new(archive_bytes);
let mut archive = zip::ZipArchive::new(reader)
.map_err(|err| format!("could not read {target}'s release archive: {err}"))?;
for i in 0..archive.len() {
let mut file = archive
.by_index(i)
.map_err(|err| format!("could not read a release archive entry: {err}"))?;
if file.name() == "zhao.exe" {
let mut bytes = Vec::new();
file.read_to_end(&mut bytes)
.map_err(|err| format!("could not read the zhao binary from the archive: {err}"))?;
return Ok(bytes);
}
}
Err(format!(
"the {target} release archive doesn't contain a zhao.exe binary"
))
}
fn replace_binary(current_exe: &Path, new_binary_bytes: &[u8]) -> Result<(), String> {
let dir = current_exe.parent().ok_or_else(|| {
format!(
"could not determine the directory containing {}",
current_exe.display()
)
})?;
let mut temp = tempfile::NamedTempFile::new_in(dir)
.map_err(|err| format!("could not create a temp file in {}: {err}", dir.display()))?;
temp.write_all(new_binary_bytes)
.map_err(|err| format!("could not write the downloaded binary to disk: {err}"))?;
temp.flush()
.map_err(|err| format!("could not write the downloaded binary to disk: {err}"))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = temp
.as_file()
.metadata()
.map_err(|err| format!("could not read the downloaded binary's metadata: {err}"))?
.permissions();
perms.set_mode(0o755);
temp.as_file()
.set_permissions(perms)
.map_err(|err| format!("could not make the downloaded binary executable: {err}"))?;
}
temp.persist(current_exe).map_err(|err| {
format!(
"could not replace {}: {err} -- the previous binary is still in place, untouched",
current_exe.display()
)
})?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn download_url_for_latest_uses_the_latest_download_alias() {
assert_eq!(
download_url("latest", "zhao-x86_64-apple-darwin.tar.gz"),
"https://github.com/allenhori/zhao-cli/releases/latest/download/\
zhao-x86_64-apple-darwin.tar.gz"
);
}
#[test]
fn download_url_for_a_specific_tag_uses_the_tagged_download_url() {
assert_eq!(
download_url("v0.1.1", "zhao-x86_64-apple-darwin.tar.gz"),
"https://github.com/allenhori/zhao-cli/releases/download/v0.1.1/\
zhao-x86_64-apple-darwin.tar.gz"
);
}
#[test]
fn download_url_for_nightly_is_just_the_nightly_tag() {
assert_eq!(
download_url("nightly", "zhao-x86_64-unknown-linux-gnu.tar.gz"),
"https://github.com/allenhori/zhao-cli/releases/download/nightly/\
zhao-x86_64-unknown-linux-gnu.tar.gz"
);
}
#[test]
fn archive_name_matches_the_release_pipelines_naming() {
let expected_ext = if cfg!(windows) { "zip" } else { "tar.gz" };
assert_eq!(
archive_name("x86_64-unknown-linux-gnu"),
format!("zhao-x86_64-unknown-linux-gnu.{expected_ext}")
);
}
#[test]
fn platform_target_recognizes_a_supported_platform() {
if matches!(
(std::env::consts::OS, std::env::consts::ARCH),
("macos", "aarch64" | "x86_64") | ("linux", "x86_64") | ("windows", "x86_64")
) {
assert!(platform_target().is_ok());
}
}
#[cfg(not(windows))]
#[test]
fn extract_tar_gz_finds_the_zhao_binary_by_name() {
use std::io::Write;
let mut tar_bytes = Vec::new();
{
let mut builder = tar::Builder::new(&mut tar_bytes);
let contents = b"pretend binary contents";
let mut header = tar::Header::new_gnu();
header.set_size(contents.len() as u64);
header.set_mode(0o755);
header.set_cksum();
builder
.append_data(&mut header, "zhao", &contents[..])
.expect("should append zhao entry");
builder.finish().expect("should finish tar");
}
let mut gz_bytes = Vec::new();
{
let mut encoder =
flate2::write::GzEncoder::new(&mut gz_bytes, flate2::Compression::default());
encoder.write_all(&tar_bytes).expect("should gzip");
encoder.finish().expect("should finish gzip");
}
let extracted =
extract_binary(&gz_bytes, "x86_64-unknown-linux-gnu").expect("should extract");
assert_eq!(extracted, b"pretend binary contents");
}
#[cfg(not(windows))]
#[test]
fn extract_tar_gz_produces_a_clear_error_when_no_zhao_entry_exists() {
use std::io::Write;
let mut tar_bytes = Vec::new();
{
let mut builder = tar::Builder::new(&mut tar_bytes);
let contents = b"unrelated";
let mut header = tar::Header::new_gnu();
header.set_size(contents.len() as u64);
header.set_cksum();
builder
.append_data(&mut header, "README.md", &contents[..])
.expect("should append entry");
builder.finish().expect("should finish tar");
}
let mut gz_bytes = Vec::new();
{
let mut encoder =
flate2::write::GzEncoder::new(&mut gz_bytes, flate2::Compression::default());
encoder.write_all(&tar_bytes).expect("should gzip");
encoder.finish().expect("should finish gzip");
}
let result = extract_binary(&gz_bytes, "x86_64-unknown-linux-gnu");
assert!(result.is_err(), "expected an error, got {result:?}");
}
#[test]
fn replace_binary_fails_cleanly_when_the_target_directory_does_not_exist() {
let fake_exe = std::path::Path::new("/definitely/does/not/exist/zhao");
let result = replace_binary(fake_exe, b"new binary");
assert!(result.is_err());
}
#[test]
fn replace_binary_actually_replaces_the_files_contents() {
let dir = tempfile::tempdir().expect("should create temp dir");
let exe_path = dir.path().join("zhao");
std::fs::write(&exe_path, b"old binary").expect("should write initial binary");
replace_binary(&exe_path, b"new binary").expect("should replace");
let contents = std::fs::read(&exe_path).expect("should read replaced binary");
assert_eq!(contents, b"new binary");
}
#[cfg(unix)]
#[test]
fn replace_binary_makes_the_new_file_executable() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().expect("should create temp dir");
let exe_path = dir.path().join("zhao");
std::fs::write(&exe_path, b"old binary").expect("should write initial binary");
replace_binary(&exe_path, b"new binary").expect("should replace");
let mode = std::fs::metadata(&exe_path)
.expect("should stat replaced binary")
.permissions()
.mode();
assert_eq!(mode & 0o111, 0o111, "expected the file to be executable");
}
}