ref:main
use crate::config::Config;
use crate::output;
use clap::Args;
use sha2::{Digest, Sha256};
use std::io::Write;
const VERSION: &str = include_str!(concat!(env!("OUT_DIR"), "/version.txt"));
#[derive(Args)]
pub struct UpdateArgs {
/// Check for an update without installing.
#[arg(long)]
pub check: bool,
/// Reinstall even if the current version matches the latest.
#[arg(long)]
pub force: bool,
/// Skip SHA256 verification of the downloaded binary (not recommended).
#[arg(long)]
pub no_verify: bool,
}
pub async fn run(args: UpdateArgs) -> Result<(), Box<dyn std::error::Error>> {
let config = Config::load()?;
let server = config.server_url()?.trim_end_matches('/').to_string();
let current = current_version();
let latest: VersionResponse = reqwest::get(format!("{server}/runner/version"))
.await?
.json()
.await?;
output::detail("Current", &current);
output::detail("Latest", &latest.version);
if !args.force && current == latest.version {
output::success("Already up to date.");
return Ok(());
}
if args.check {
output::info(&format!("Update available: {current} → {}", latest.version));
return Ok(());
}
let (os, arch) = detect_platform()?;
let target = platform_target(os, arch)?;
output::info(&format!("Downloading anvil for {os}/{arch}..."));
let bytes = download_binary(&server, os, arch).await?;
if !args.no_verify {
output::info("Verifying SHA256...");
let expected = fetch_expected_checksum(&server, target, &latest.version).await?;
verify_sha256(&bytes, &expected)?;
}
let exe_path = std::env::current_exe()?;
swap_executable(&exe_path, &bytes)?;
output::success(&format!(
"Updated to {} at {}",
latest.version,
exe_path.display()
));
Ok(())
}
#[derive(Debug, serde::Deserialize)]
struct VersionResponse {
version: String,
#[allow(dead_code)]
platforms: Option<Vec<String>>,
}
fn current_version() -> String {
VERSION.trim().to_string()
}
/// Map Rust's compile-time os/arch constants to the values the server's
/// `/runner/download` endpoint expects (which are derived from `uname -s` /
/// `uname -m` on the host).
fn detect_platform() -> Result<(&'static str, &'static str), Box<dyn std::error::Error>> {
let os = match std::env::consts::OS {
"linux" => "Linux",
"macos" => "Darwin",
other => return Err(format!("unsupported OS: {other}").into()),
};
let arch = match std::env::consts::ARCH {
"x86_64" => "x86_64",
"aarch64" => "aarch64",
other => return Err(format!("unsupported architecture: {other}").into()),
};
Ok((os, arch))
}
/// Server's release-asset naming convention: `anvil_{target}_{version}` where
/// target is one of `linux_amd64`, `linux_arm64`, `macos_amd64`, `macos_arm64`.
fn platform_target(os: &str, arch: &str) -> Result<&'static str, Box<dyn std::error::Error>> {
match (os, arch) {
("Linux", "x86_64") => Ok("linux_amd64"),
("Linux", "aarch64") => Ok("linux_arm64"),
("Darwin", "x86_64") => Ok("macos_amd64"),
("Darwin", "aarch64") => Ok("macos_arm64"),
_ => Err(format!("unsupported platform: {os}/{arch}").into()),
}
}
async fn download_binary(
server: &str,
os: &str,
arch: &str,
) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
let resp = reqwest::get(format!("{server}/runner/download?os={os}&arch={arch}")).await?;
if !resp.status().is_success() {
return Err(format!("download failed: HTTP {}", resp.status()).into());
}
Ok(resp.bytes().await?.to_vec())
}
async fn fetch_expected_checksum(
server: &str,
target: &str,
version: &str,
) -> Result<String, Box<dyn std::error::Error>> {
let body = reqwest::get(format!("{server}/runner/checksums"))
.await?
.error_for_status()
.map_err(|e| format!("checksums fetch failed: {e}"))?
.text()
.await?;
let filename = format!("anvil_{target}_{version}");
parse_checksum_line(&body, &filename)
.ok_or_else(|| format!("no SHA256 entry for {filename} in checksums file").into())
}
/// Parse a single SHA256SUMS entry. Lines look like `<hash> <filename>` —
/// standard `sha256sum` output. Returns the hash for the matching filename.
fn parse_checksum_line(body: &str, filename: &str) -> Option<String> {
for line in body.lines() {
let mut parts = line.split_whitespace();
let hash = parts.next()?;
let name = parts.next()?;
if name == filename {
return Some(hash.to_string());
}
}
None
}
fn verify_sha256(bytes: &[u8], expected_hex: &str) -> Result<(), Box<dyn std::error::Error>> {
let mut hasher = Sha256::new();
hasher.update(bytes);
let got = format!("{:x}", hasher.finalize());
if got.eq_ignore_ascii_case(expected_hex) {
Ok(())
} else {
Err(format!("SHA256 mismatch: expected {expected_hex}, got {got}").into())
}
}
/// Write the new binary to a sibling temp path, chmod +x, then atomically
/// rename over the current executable. On Linux, replacing a running
/// executable works: the kernel keeps the old inode alive for the running
/// process while new invocations pick up the new file.
fn swap_executable(
exe_path: &std::path::Path,
bytes: &[u8],
) -> Result<(), Box<dyn std::error::Error>> {
let parent = exe_path
.parent()
.ok_or("could not determine install directory")?;
let file_name = exe_path
.file_name()
.ok_or("could not determine executable name")?
.to_string_lossy()
.to_string();
let tmp_path = parent.join(format!(".{file_name}.update"));
{
let mut f = std::fs::File::create(&tmp_path).map_err(|e| {
format!(
"could not write to {} — try running with sudo or reinstall via the package manager: {e}",
tmp_path.display()
)
})?;
f.write_all(bytes)?;
f.sync_all()?;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(&tmp_path)?.permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&tmp_path, perms)?;
}
std::fs::rename(&tmp_path, exe_path).map_err(|e| {
// Best-effort cleanup; if rename failed, the temp file is left around.
let _ = std::fs::remove_file(&tmp_path);
format!("could not replace {}: {e}", exe_path.display())
})?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_checksum_line_matches_filename() {
let body = "abc123 anvil_linux_amd64_2026.05.6\n\
deadbeef anvil_linux_arm64_2026.05.6\n\
cafebabe SHA256SUMS_2026.05.6\n";
assert_eq!(
parse_checksum_line(body, "anvil_linux_arm64_2026.05.6").as_deref(),
Some("deadbeef")
);
}
#[test]
fn parse_checksum_line_missing_returns_none() {
let body = "abc anvil_linux_amd64_1.0.0\n";
assert!(parse_checksum_line(body, "anvil_linux_arm64_1.0.0").is_none());
}
#[test]
fn parse_checksum_tolerates_extra_whitespace() {
let body = " abc123 anvil_linux_amd64_1.0.0 \n";
assert_eq!(
parse_checksum_line(body, "anvil_linux_amd64_1.0.0").as_deref(),
Some("abc123")
);
}
#[test]
fn platform_target_maps_known_platforms() {
assert_eq!(platform_target("Linux", "x86_64").unwrap(), "linux_amd64");
assert_eq!(platform_target("Linux", "aarch64").unwrap(), "linux_arm64");
assert_eq!(platform_target("Darwin", "x86_64").unwrap(), "macos_amd64");
assert_eq!(platform_target("Darwin", "aarch64").unwrap(), "macos_arm64");
}
#[test]
fn platform_target_rejects_unknown() {
assert!(platform_target("BeOS", "x86_64").is_err());
assert!(platform_target("Linux", "riscv64").is_err());
}
#[test]
fn verify_sha256_accepts_correct_hash() {
let bytes = b"hello world";
let expected = "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9";
assert!(verify_sha256(bytes, expected).is_ok());
assert!(verify_sha256(bytes, &expected.to_uppercase()).is_ok());
}
#[test]
fn verify_sha256_rejects_wrong_hash() {
let err = verify_sha256(b"hello", "deadbeef").unwrap_err().to_string();
assert!(err.contains("SHA256 mismatch"));
}
}