zeroclaw/src/commands/update.rs
Argenis 0520380495
fix(update): diagnose arch mismatch in validate_binary before execution (#4379)
When a downloaded binary has the wrong architecture, the previous code
attempted to execute it and surfaced a raw "Exec format error (os error 8)".
Now validate_binary reads the ELF/Mach-O header first, compares the binary
architecture against the host, and reports a clear diagnostic like:
"architecture mismatch: downloaded binary is aarch64 but this host is x86_64".

Closes #4291
2026-03-24 15:34:57 +03:00

476 lines
15 KiB
Rust

//! `zeroclaw update` — self-update pipeline with rollback.
use anyhow::{bail, Context, Result};
use std::path::Path;
use tracing::{info, warn};
const GITHUB_RELEASES_LATEST_URL: &str =
"https://api.github.com/repos/zeroclaw-labs/zeroclaw/releases/latest";
const GITHUB_RELEASES_TAG_URL: &str =
"https://api.github.com/repos/zeroclaw-labs/zeroclaw/releases/tags";
#[derive(Debug)]
pub struct UpdateInfo {
pub current_version: String,
pub latest_version: String,
pub download_url: Option<String>,
pub is_newer: bool,
}
/// Check for available updates without downloading.
///
/// If `target_version` is `Some`, fetch that specific release tag instead of latest.
pub async fn check(target_version: Option<&str>) -> Result<UpdateInfo> {
let current = env!("CARGO_PKG_VERSION").to_string();
let client = reqwest::Client::builder()
.user_agent(format!("zeroclaw/{current}"))
.timeout(std::time::Duration::from_secs(15))
.build()?;
let url = match target_version {
Some(v) => {
let tag = if v.starts_with('v') {
v.to_string()
} else {
format!("v{v}")
};
format!("{GITHUB_RELEASES_TAG_URL}/{tag}")
}
None => GITHUB_RELEASES_LATEST_URL.to_string(),
};
let resp = client
.get(&url)
.send()
.await
.context("failed to reach GitHub releases API")?;
if !resp.status().is_success() {
bail!("GitHub API returned {}", resp.status());
}
let release: serde_json::Value = resp.json().await?;
let tag = release["tag_name"]
.as_str()
.unwrap_or("unknown")
.trim_start_matches('v')
.to_string();
let download_url = find_asset_url(&release);
let is_newer = version_is_newer(&current, &tag);
Ok(UpdateInfo {
current_version: current,
latest_version: tag,
download_url,
is_newer,
})
}
/// Run the full 6-phase update pipeline.
///
/// If `target_version` is `Some`, fetch that specific version instead of latest.
pub async fn run(target_version: Option<&str>) -> Result<()> {
// Phase 1: Preflight
info!("Phase 1/6: Preflight checks...");
let update_info = check(target_version).await?;
if !update_info.is_newer {
println!("Already up to date (v{}).", update_info.current_version);
return Ok(());
}
println!(
"Update available: v{} -> v{}",
update_info.current_version, update_info.latest_version
);
let download_url = update_info
.download_url
.context("no suitable binary found for this platform")?;
let current_exe =
std::env::current_exe().context("cannot determine current executable path")?;
// Phase 2: Download
info!("Phase 2/6: Downloading...");
let temp_dir = tempfile::tempdir().context("failed to create temp dir")?;
let download_path = temp_dir.path().join("zeroclaw_new");
download_binary(&download_url, &download_path).await?;
// Phase 3: Backup
info!("Phase 3/6: Creating backup...");
let backup_path = current_exe.with_extension("bak");
tokio::fs::copy(&current_exe, &backup_path)
.await
.context("failed to backup current binary")?;
// Phase 4: Validate
info!("Phase 4/6: Validating download...");
validate_binary(&download_path).await?;
// Phase 5: Swap
info!("Phase 5/6: Swapping binary...");
if let Err(e) = swap_binary(&download_path, &current_exe).await {
// Rollback
warn!("Swap failed, rolling back: {e}");
if let Err(rollback_err) = tokio::fs::copy(&backup_path, &current_exe).await {
eprintln!("CRITICAL: Rollback also failed: {rollback_err}");
eprintln!(
"Manual recovery: cp {} {}",
backup_path.display(),
current_exe.display()
);
}
bail!("Update failed during swap: {e}");
}
// Phase 6: Smoke test
info!("Phase 6/6: Smoke test...");
match smoke_test(&current_exe).await {
Ok(()) => {
// Cleanup backup on success
let _ = tokio::fs::remove_file(&backup_path).await;
println!("Successfully updated to v{}!", update_info.latest_version);
Ok(())
}
Err(e) => {
warn!("Smoke test failed, rolling back: {e}");
tokio::fs::copy(&backup_path, &current_exe)
.await
.context("rollback after smoke test failure")?;
bail!("Update rolled back — smoke test failed: {e}");
}
}
}
fn find_asset_url(release: &serde_json::Value) -> Option<String> {
let target = current_target_triple();
release["assets"]
.as_array()?
.iter()
.find(|asset| {
asset["name"]
.as_str()
.map(|name| name.contains(target))
.unwrap_or(false)
})
.and_then(|asset| asset["browser_download_url"].as_str().map(String::from))
}
/// Return the exact Rust target triple for the current platform.
///
/// Using full triples (e.g. `aarch64-unknown-linux-gnu` instead of the
/// shorter `aarch64-unknown-linux`) prevents substring matches from
/// selecting the wrong asset (e.g. an Android binary on a GNU/Linux host).
fn current_target_triple() -> &'static str {
if cfg!(target_os = "macos") {
if cfg!(target_arch = "aarch64") {
"aarch64-apple-darwin"
} else {
"x86_64-apple-darwin"
}
} else if cfg!(target_os = "linux") {
if cfg!(target_arch = "aarch64") {
"aarch64-unknown-linux-gnu"
} else {
"x86_64-unknown-linux-gnu"
}
} else {
"unknown"
}
}
fn version_is_newer(current: &str, candidate: &str) -> bool {
let parse = |v: &str| -> Vec<u32> { v.split('.').filter_map(|p| p.parse().ok()).collect() };
let cur = parse(current);
let cand = parse(candidate);
cand > cur
}
async fn download_binary(url: &str, dest: &Path) -> Result<()> {
let client = reqwest::Client::builder()
.user_agent(format!("zeroclaw/{}", env!("CARGO_PKG_VERSION")))
.timeout(std::time::Duration::from_secs(300))
.build()?;
let resp = client
.get(url)
.send()
.await
.context("download request failed")?;
if !resp.status().is_success() {
bail!("download returned {}", resp.status());
}
let bytes = resp.bytes().await.context("failed to read download body")?;
tokio::fs::write(dest, &bytes)
.await
.context("failed to write downloaded binary")?;
// Make executable on Unix
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::Permissions::from_mode(0o755);
tokio::fs::set_permissions(dest, perms).await?;
}
Ok(())
}
async fn validate_binary(path: &Path) -> Result<()> {
let meta = tokio::fs::metadata(path).await?;
if meta.len() < 1_000_000 {
bail!(
"downloaded binary too small ({} bytes), likely corrupt",
meta.len()
);
}
// Check binary architecture before attempting execution so we can give
// a clear diagnostic instead of the opaque "Exec format error (os error 8)".
check_binary_arch(path).await?;
// Quick check: try running --version
let output = tokio::process::Command::new(path)
.arg("--version")
.output()
.await
.context("cannot execute downloaded binary")?;
if !output.status.success() {
bail!("downloaded binary --version check failed");
}
let stdout = String::from_utf8_lossy(&output.stdout);
if !stdout.contains("zeroclaw") {
bail!("downloaded binary does not appear to be zeroclaw");
}
Ok(())
}
/// Read the binary header and verify its architecture matches the host.
///
/// On Linux/FreeBSD this reads the ELF header; on macOS the Mach-O header.
/// If the binary is for a different architecture, returns a descriptive error
/// instead of the opaque "Exec format error (os error 8)".
async fn check_binary_arch(path: &Path) -> Result<()> {
let header = tokio::fs::read(path)
.await
.map(|bytes| bytes.into_iter().take(32).collect::<Vec<u8>>())
.context("failed to read binary header")?;
if header.len() < 20 {
bail!("downloaded file too small to be a valid binary");
}
let binary_arch = detect_arch_from_header(&header);
let host_arch = host_architecture();
if let (Some(bin), Some(host)) = (binary_arch, host_arch) {
if bin != host {
bail!(
"architecture mismatch: downloaded binary is {bin} but this host is {host} — \
the release asset may be mispackaged"
);
}
}
Ok(())
}
/// Detect the CPU architecture from an ELF or Mach-O binary header.
fn detect_arch_from_header(header: &[u8]) -> Option<&'static str> {
// ELF magic: 0x7f 'E' 'L' 'F'
if header.len() >= 20 && header[0..4] == [0x7f, b'E', b'L', b'F'] {
// e_machine is at offset 18 (2 bytes, little-endian for LE binaries)
let e_machine = u16::from_le_bytes([header[18], header[19]]);
return Some(match e_machine {
0x3E => "x86_64",
0xB7 => "aarch64",
0x03 => "x86",
0x28 => "arm",
0xF3 => "riscv",
_ => "unknown-elf",
});
}
// Mach-O magic (64-bit little-endian): 0xFEEDFACF
if header.len() >= 8 && header[0..4] == [0xCF, 0xFA, 0xED, 0xFE] {
let cputype = u32::from_le_bytes([header[4], header[5], header[6], header[7]]);
return Some(match cputype {
0x0100_0007 => "x86_64",
0x0100_000C => "aarch64",
_ => "unknown-macho",
});
}
None
}
/// Return the host CPU architecture as a human-readable string.
fn host_architecture() -> Option<&'static str> {
if cfg!(target_arch = "x86_64") {
Some("x86_64")
} else if cfg!(target_arch = "aarch64") {
Some("aarch64")
} else if cfg!(target_arch = "x86") {
Some("x86")
} else if cfg!(target_arch = "arm") {
Some("arm")
} else {
None
}
}
async fn swap_binary(new: &Path, target: &Path) -> Result<()> {
tokio::fs::copy(new, target)
.await
.context("failed to overwrite binary")?;
Ok(())
}
async fn smoke_test(binary: &Path) -> Result<()> {
let output = tokio::process::Command::new(binary)
.arg("--version")
.output()
.await
.context("smoke test: cannot execute updated binary")?;
if !output.status.success() {
bail!("smoke test: updated binary returned non-zero exit code");
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_version_comparison() {
assert!(version_is_newer("0.4.3", "0.5.0"));
assert!(version_is_newer("0.4.3", "0.4.4"));
assert!(!version_is_newer("0.5.0", "0.4.3"));
assert!(!version_is_newer("0.4.3", "0.4.3"));
assert!(version_is_newer("1.0.0", "2.0.0"));
}
#[test]
fn current_target_triple_is_not_empty() {
let triple = current_target_triple();
assert_ne!(triple, "unknown", "unsupported platform");
// The triple must contain at least two hyphens (arch-vendor-os or arch-vendor-os-env)
assert!(
triple.matches('-').count() >= 2,
"triple should have at least two hyphens: {triple}"
);
}
fn make_release(assets: &[&str]) -> serde_json::Value {
let assets: Vec<serde_json::Value> = assets
.iter()
.map(|name| {
serde_json::json!({
"name": name,
"browser_download_url": format!("https://example.com/{name}")
})
})
.collect();
serde_json::json!({ "assets": assets })
}
#[test]
fn find_asset_url_picks_correct_gnu_over_android() {
let release = make_release(&[
"zeroclaw-aarch64-linux-android.tar.gz",
"zeroclaw-aarch64-unknown-linux-gnu.tar.gz",
"zeroclaw-x86_64-unknown-linux-gnu.tar.gz",
"zeroclaw-x86_64-apple-darwin.tar.gz",
"zeroclaw-aarch64-apple-darwin.tar.gz",
]);
let url = find_asset_url(&release);
assert!(url.is_some(), "should find an asset");
let url = url.unwrap();
// Must NOT match the android binary
assert!(
!url.contains("android"),
"should not select android binary, got: {url}"
);
}
#[test]
fn find_asset_url_returns_none_for_empty_assets() {
let release = serde_json::json!({ "assets": [] });
assert!(find_asset_url(&release).is_none());
}
#[test]
fn find_asset_url_returns_none_for_missing_assets() {
let release = serde_json::json!({});
assert!(find_asset_url(&release).is_none());
}
#[test]
fn detect_arch_elf_x86_64() {
// Minimal ELF header with e_machine = 0x3E (x86_64)
let mut header = vec![0u8; 20];
header[0..4].copy_from_slice(&[0x7f, b'E', b'L', b'F']);
header[18] = 0x3E;
header[19] = 0x00;
assert_eq!(detect_arch_from_header(&header), Some("x86_64"));
}
#[test]
fn detect_arch_elf_aarch64() {
let mut header = vec![0u8; 20];
header[0..4].copy_from_slice(&[0x7f, b'E', b'L', b'F']);
header[18] = 0xB7;
header[19] = 0x00;
assert_eq!(detect_arch_from_header(&header), Some("aarch64"));
}
#[test]
fn detect_arch_macho_x86_64() {
// Mach-O 64-bit LE magic + cputype 0x01000007 (x86_64)
let mut header = vec![0u8; 8];
header[0..4].copy_from_slice(&[0xCF, 0xFA, 0xED, 0xFE]);
header[4..8].copy_from_slice(&0x0100_0007u32.to_le_bytes());
assert_eq!(detect_arch_from_header(&header), Some("x86_64"));
}
#[test]
fn detect_arch_macho_aarch64() {
let mut header = vec![0u8; 8];
header[0..4].copy_from_slice(&[0xCF, 0xFA, 0xED, 0xFE]);
header[4..8].copy_from_slice(&0x0100_000Cu32.to_le_bytes());
assert_eq!(detect_arch_from_header(&header), Some("aarch64"));
}
#[test]
fn detect_arch_unknown_format() {
let header = vec![0u8; 20]; // all zeros — not ELF or Mach-O
assert_eq!(detect_arch_from_header(&header), None);
}
#[test]
fn detect_arch_too_short() {
let header = vec![0x7f, b'E', b'L', b'F']; // only 4 bytes
assert_eq!(detect_arch_from_header(&header), None);
}
#[test]
fn host_architecture_is_known() {
assert!(
host_architecture().is_some(),
"host architecture should be detected on CI platforms"
);
}
}