From eb538f6502efb995574c315f8613958b403c6f45 Mon Sep 17 00:00:00 2001 From: Chris O'Neil Date: Wed, 1 Apr 2026 21:27:33 +0100 Subject: [PATCH 1/3] feat: add `ant update` self-update command Checks GitHub Releases for a newer version of the ant binary, downloads it if available, and replaces the current executable in place. Uses the self-replace crate for cross-platform binary replacement (handles Windows exe locking). Supports --force to re-download and --json for structured output. Co-Authored-By: Claude Opus 4.6 (1M context) --- Cargo.lock | 2 + ant-cli/src/cli.rs | 3 + ant-cli/src/commands/mod.rs | 1 + ant-cli/src/commands/update.rs | 97 ++++++++++ ant-cli/src/main.rs | 3 + ant-core/Cargo.toml | 2 + ant-core/src/error.rs | 3 + ant-core/src/lib.rs | 1 + ant-core/src/node/binary.rs | 34 ++-- ant-core/src/update.rs | 336 +++++++++++++++++++++++++++++++++ 10 files changed, 468 insertions(+), 14 deletions(-) create mode 100644 ant-cli/src/commands/update.rs create mode 100644 ant-core/src/update.rs diff --git a/Cargo.lock b/Cargo.lock index 3460d52..f045717 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -868,7 +868,9 @@ dependencies = [ "reqwest 0.12.28", "rmp-serde", "saorsa-pqc 0.5.1", + "self-replace", "self_encryption", + "semver 1.0.27", "serde", "serde_json", "serial_test", diff --git a/ant-cli/src/cli.rs b/ant-cli/src/cli.rs index afd2c62..3656675 100644 --- a/ant-cli/src/cli.rs +++ b/ant-cli/src/cli.rs @@ -4,6 +4,7 @@ use std::path::PathBuf; use crate::commands::data::{ChunkAction, FileAction, WalletAction}; use crate::commands::node::NodeCommand; +use crate::commands::update::UpdateArgs; #[derive(Parser)] #[command(name = "ant", about = "Autonomi network client")] @@ -67,4 +68,6 @@ pub enum Commands { #[command(subcommand)] action: ChunkAction, }, + /// Update the ant binary to the latest version + Update(UpdateArgs), } diff --git a/ant-cli/src/commands/mod.rs b/ant-cli/src/commands/mod.rs index 6d51a0f..dcacfed 100644 --- a/ant-cli/src/commands/mod.rs +++ b/ant-cli/src/commands/mod.rs @@ -1,2 +1,3 @@ pub mod data; pub mod node; +pub mod update; diff --git a/ant-cli/src/commands/update.rs b/ant-cli/src/commands/update.rs new file mode 100644 index 0000000..3364a30 --- /dev/null +++ b/ant-cli/src/commands/update.rs @@ -0,0 +1,97 @@ +use clap::Args; +use colored::Colorize; + +use ant_core::node::binary::NoopProgress; +use ant_core::update; + +/// Progress reporter that prints to the terminal. +struct CliUpdateProgress; + +impl ant_core::node::binary::ProgressReporter for CliUpdateProgress { + fn report_started(&self, message: &str) { + eprintln!("{}", message.dimmed()); + } + + fn report_progress(&self, bytes: u64, total: u64) { + if total > 0 { + let pct = (bytes as f64 / total as f64 * 100.0) as u64; + eprint!("\r{}", format!(" Downloading... {pct}%").dimmed()); + } + } + + fn report_complete(&self, message: &str) { + eprintln!("\r{}", message.green()); + } +} + +#[derive(Args)] +pub struct UpdateArgs { + /// Force re-download even if already on the latest version. + #[arg(long)] + pub force: bool, +} + +impl UpdateArgs { + pub async fn execute(self, json_output: bool) -> anyhow::Result<()> { + let current_version = env!("CARGO_PKG_VERSION"); + + if !json_output { + eprintln!("{}", format!("Current version: {current_version}").dimmed()); + eprintln!("{}", "Checking for updates...".dimmed()); + } + + let mut check = update::check_for_update(current_version).await?; + + if !check.update_available && self.force { + // Force mode: rebuild the download URL even if versions match. + check.update_available = true; + check.download_url = Some(update::build_download_url(&check.latest_version)?); + } + + if !check.update_available { + if json_output { + println!("{}", serde_json::to_string_pretty(&check)?); + } else { + println!( + "{}", + format!("Already up to date (v{}).", check.current_version).green() + ); + } + return Ok(()); + } + + if !json_output { + eprintln!( + "{}", + format!( + "Update available: v{} -> v{}", + check.current_version, check.latest_version + ) + .cyan() + ); + } + + let progress: Box = if json_output { + Box::new(NoopProgress) + } else { + Box::new(CliUpdateProgress) + }; + + let result = update::perform_update(&check, progress.as_ref()).await?; + + if json_output { + println!("{}", serde_json::to_string_pretty(&result)?); + } else { + println!( + "{}", + format!( + "Updated successfully: v{} -> v{}", + result.previous_version, result.new_version + ) + .green() + ); + } + + Ok(()) + } +} diff --git a/ant-cli/src/main.rs b/ant-cli/src/main.rs index 4094c7f..1a39279 100644 --- a/ant-cli/src/main.rs +++ b/ant-cli/src/main.rs @@ -117,6 +117,9 @@ async fn run() -> anyhow::Result<()> { let client = build_data_client(&data_ctx, needs_wallet).await?; action.execute(&client).await?; } + Commands::Update(args) => { + args.execute(json).await?; + } } Ok(()) diff --git a/ant-core/Cargo.toml b/ant-core/Cargo.toml index 512dfd9..07d4375 100644 --- a/ant-core/Cargo.toml +++ b/ant-core/Cargo.toml @@ -11,6 +11,8 @@ flate2 = "1" fs2 = "0.4" futures-core = "0.3" futures-util = "0.3" +self-replace = "1" +semver = "1" serde = { version = "1", features = ["derive"] } serde_json = "1" reqwest = { version = "0.12", features = ["json", "stream"] } diff --git a/ant-core/src/error.rs b/ant-core/src/error.rs index 39ceb0c..b403167 100644 --- a/ant-core/src/error.rs +++ b/ant-core/src/error.rs @@ -56,6 +56,9 @@ pub enum Error { #[error("Could not determine home directory (HOME/USERPROFILE not set)")] HomeDirNotFound, + #[error("Update failed: {0}")] + UpdateFailed(String), + #[error("Failed to parse bootstrap_peers.toml: {0}")] BootstrapConfigParse(String), diff --git a/ant-core/src/lib.rs b/ant-core/src/lib.rs index f4cf94f..edeb4c9 100644 --- a/ant-core/src/lib.rs +++ b/ant-core/src/lib.rs @@ -2,3 +2,4 @@ pub mod config; pub mod data; pub mod error; pub mod node; +pub mod update; diff --git a/ant-core/src/node/binary.rs b/ant-core/src/node/binary.rs index a0db0e9..1a87550 100644 --- a/ant-core/src/node/binary.rs +++ b/ant-core/src/node/binary.rs @@ -180,10 +180,10 @@ async fn download_and_extract( // Extract based on file extension let binary_path = if url.ends_with(".zip") { - extract_zip(&bytes, install_dir)? + extract_zip(&bytes, install_dir, BINARY_NAME)? } else { // Assume .tar.gz - extract_tar_gz(&bytes, install_dir)? + extract_tar_gz(&bytes, install_dir, BINARY_NAME)? }; // Determine the actual version from the binary @@ -211,8 +211,11 @@ async fn download_and_extract( Ok((cached_path, actual_version)) } -/// Extract a .tar.gz archive and return the path to the node binary. -fn extract_tar_gz(data: &[u8], install_dir: &Path) -> Result { +/// Extract a .tar.gz archive and return the path to a named binary. +/// +/// Searches the archive for an entry whose file name matches `binary_name` +/// and writes it to `install_dir/`. +pub fn extract_tar_gz(data: &[u8], install_dir: &Path, binary_name: &str) -> Result { let decoder = flate2::read::GzDecoder::new(data); let mut archive = tar::Archive::new(decoder); @@ -244,8 +247,8 @@ fn extract_tar_gz(data: &[u8], install_dir: &Path) -> Result { .and_then(|n| n.to_str()) .unwrap_or_default(); - if file_name == BINARY_NAME { - let dest = install_dir.join(BINARY_NAME); + if file_name == binary_name { + let dest = install_dir.join(binary_name); let mut file = std::fs::File::create(&dest)?; std::io::copy(&mut entry, &mut file)?; @@ -261,11 +264,14 @@ fn extract_tar_gz(data: &[u8], install_dir: &Path) -> Result { } binary_path - .ok_or_else(|| Error::BinaryResolution(format!("'{BINARY_NAME}' not found in archive"))) + .ok_or_else(|| Error::BinaryResolution(format!("'{binary_name}' not found in archive"))) } -/// Extract a .zip archive and return the path to the node binary. -fn extract_zip(data: &[u8], install_dir: &Path) -> Result { +/// Extract a .zip archive and return the path to a named binary. +/// +/// Searches the archive for an entry whose file name matches `binary_name` +/// (or `binary_name.exe` on Windows) and writes it to `install_dir/`. +pub fn extract_zip(data: &[u8], install_dir: &Path, binary_name: &str) -> Result { let cursor = std::io::Cursor::new(data); let mut archive = zip::ZipArchive::new(cursor) .map_err(|e| Error::BinaryResolution(format!("failed to open zip archive: {e}")))?; @@ -282,7 +288,7 @@ fn extract_zip(data: &[u8], install_dir: &Path) -> Result { .and_then(|p| p.file_name().map(|n| n.to_string_lossy().to_string())) .unwrap_or_default(); - if file_name == BINARY_NAME || file_name == format!("{BINARY_NAME}.exe") { + if file_name == binary_name || file_name == format!("{binary_name}.exe") { let dest = install_dir.join(&file_name); let mut out = std::fs::File::create(&dest)?; std::io::copy(&mut file, &mut out)?; @@ -298,7 +304,7 @@ fn extract_zip(data: &[u8], install_dir: &Path) -> Result { } binary_path - .ok_or_else(|| Error::BinaryResolution(format!("'{BINARY_NAME}' not found in archive"))) + .ok_or_else(|| Error::BinaryResolution(format!("'{binary_name}' not found in archive"))) } /// Extract the version string from a node binary by running ` --version`. @@ -419,7 +425,7 @@ mod tests { std::io::Write::write_all(&mut encoder, &tar_data).unwrap(); let gz_data = encoder.finish().unwrap(); - let result = extract_tar_gz(&gz_data, tmp.path()); + let result = extract_tar_gz(&gz_data, tmp.path(), BINARY_NAME); assert!(result.is_ok()); let path = result.unwrap(); assert!(path.exists()); @@ -436,7 +442,7 @@ mod tests { std::io::Write::write_all(&mut encoder, &tar_data).unwrap(); let gz_data = encoder.finish().unwrap(); - let result = extract_tar_gz(&gz_data, tmp.path()); + let result = extract_tar_gz(&gz_data, tmp.path(), BINARY_NAME); assert!(result.is_err()); } @@ -468,7 +474,7 @@ mod tests { std::io::Write::write_all(&mut encoder, &tar_data).unwrap(); let gz_data = encoder.finish().unwrap(); - let result = extract_tar_gz(&gz_data, tmp.path()); + let result = extract_tar_gz(&gz_data, tmp.path(), BINARY_NAME); assert!(result.is_err()); let err = result.unwrap_err().to_string(); assert!( diff --git a/ant-core/src/update.rs b/ant-core/src/update.rs new file mode 100644 index 0000000..156c22b --- /dev/null +++ b/ant-core/src/update.rs @@ -0,0 +1,336 @@ +use std::path::{Path, PathBuf}; + +use futures_util::StreamExt; +use serde::{Deserialize, Serialize}; + +use crate::error::{Error, Result}; +use crate::node::binary::{extract_tar_gz, extract_zip, ProgressReporter}; + +const GITHUB_REPO: &str = "WithAutonomi/ant-client"; +const CLI_BINARY_NAME: &str = "ant"; + +/// Result of checking whether an update is available. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdateCheck { + pub current_version: String, + pub latest_version: String, + pub update_available: bool, + pub download_url: Option, +} + +/// Result of a completed update. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdateResult { + pub previous_version: String, + pub new_version: String, +} + +/// Options for the update operation. +pub struct UpdateOpts { + /// Force re-download even if already on the latest version. + pub force: bool, +} + +/// Check whether a newer version is available on GitHub Releases. +/// +/// Compares `current_version` against the latest release tag using semantic versioning. +pub async fn check_for_update(current_version: &str) -> Result { + let latest = fetch_latest_cli_version().await?; + let current = parse_version(current_version)?; + let latest_parsed = parse_version(&latest)?; + let update_available = latest_parsed > current; + + let download_url = if update_available { + Some(build_download_url(&latest)?) + } else { + None + }; + + Ok(UpdateCheck { + current_version: current_version.to_string(), + latest_version: latest, + update_available, + download_url, + }) +} + +/// Download and install the update, replacing the current binary. +/// +/// The `current_exe` path is the location of the running binary. The new binary is +/// downloaded to a temporary file beside it, then atomically swapped into place. +pub async fn perform_update( + check: &UpdateCheck, + progress: &dyn ProgressReporter, +) -> Result { + let download_url = check.download_url.as_deref().ok_or_else(|| { + Error::UpdateFailed("no download URL — are you already on the latest version?".to_string()) + })?; + + let current_exe = std::env::current_exe().map_err(|e| { + Error::UpdateFailed(format!("could not determine current executable path: {e}")) + })?; + + let new_binary = download_and_extract_cli(download_url, progress).await?; + replace_binary(¤t_exe, &new_binary)?; + let _ = std::fs::remove_file(&new_binary); + + Ok(UpdateResult { + previous_version: check.current_version.clone(), + new_version: check.latest_version.clone(), + }) +} + +const TAG_PREFIX: &str = "ant-cli-v"; + +/// Fetch the latest stable CLI release version from GitHub. +/// +/// Lists all releases and finds the newest non-draft, non-prerelease one whose +/// tag starts with `ant-cli-v`. +async fn fetch_latest_cli_version() -> Result { + let url = format!("https://api.github.com/repos/{GITHUB_REPO}/releases?per_page=50"); + let client = reqwest::Client::new(); + let resp = client + .get(&url) + .header("User-Agent", "ant-cli") + .header("Accept", "application/vnd.github+json") + .send() + .await + .map_err(|e| Error::UpdateFailed(format!("failed to fetch releases: {e}")))?; + + if !resp.status().is_success() { + return Err(Error::UpdateFailed(format!( + "GitHub API returned status {} when fetching releases", + resp.status() + ))); + } + + let releases: Vec = resp + .json() + .await + .map_err(|e| Error::UpdateFailed(format!("failed to parse releases JSON: {e}")))?; + + // Find the newest stable ant-cli release by semver (skip drafts and pre-releases). + let mut best: Option = None; + for release in &releases { + if release["draft"].as_bool().unwrap_or(false) + || release["prerelease"].as_bool().unwrap_or(false) + { + continue; + } + let tag = release["tag_name"].as_str().unwrap_or_default(); + if let Some(version_str) = tag.strip_prefix(TAG_PREFIX) { + if let Ok(v) = semver::Version::parse(version_str) { + if best.as_ref().is_none_or(|b| v > *b) { + best = Some(v); + } + } + } + } + + best.map(|v| v.to_string()) + .ok_or_else(|| Error::UpdateFailed("no ant-cli release found on GitHub".to_string())) +} + +/// Download the CLI archive and extract the binary to a temp directory. +/// +/// Returns the path to the extracted binary. +async fn download_and_extract_cli(url: &str, progress: &dyn ProgressReporter) -> Result { + progress.report_started(&format!("Downloading {CLI_BINARY_NAME} from {url}")); + + let client = reqwest::Client::new(); + let resp = client + .get(url) + .header("User-Agent", "ant-cli") + .send() + .await + .map_err(|e| Error::UpdateFailed(format!("download request failed: {e}")))?; + + if !resp.status().is_success() { + return Err(Error::UpdateFailed(format!( + "download returned status {}", + resp.status() + ))); + } + + let total_size = resp.content_length().unwrap_or(0); + let mut downloaded: u64 = 0; + + let tmp_dir = std::env::temp_dir().join("ant-update"); + std::fs::create_dir_all(&tmp_dir)?; + let tmp_archive = tmp_dir.join(".download.tmp"); + let mut tmp_file = std::fs::File::create(&tmp_archive) + .map_err(|e| Error::UpdateFailed(format!("failed to create temp file: {e}")))?; + + let mut stream = resp.bytes_stream(); + while let Some(chunk) = stream.next().await { + let chunk = + chunk.map_err(|e| Error::UpdateFailed(format!("download stream error: {e}")))?; + downloaded += chunk.len() as u64; + std::io::Write::write_all(&mut tmp_file, &chunk) + .map_err(|e| Error::UpdateFailed(format!("failed to write temp file: {e}")))?; + progress.report_progress(downloaded, total_size); + } + drop(tmp_file); + + progress.report_started("Extracting archive..."); + + let bytes = std::fs::read(&tmp_archive) + .map_err(|e| Error::UpdateFailed(format!("failed to read temp file: {e}")))?; + let _ = std::fs::remove_file(&tmp_archive); + + let binary_path = if url.ends_with(".zip") { + extract_zip(&bytes, &tmp_dir, CLI_BINARY_NAME)? + } else { + extract_tar_gz(&bytes, &tmp_dir, CLI_BINARY_NAME)? + }; + + progress.report_complete("Download complete"); + + Ok(binary_path) +} + +/// Replace the current executable with the new binary. +/// +/// Uses the `self_replace` crate which handles platform-specific nuances, +/// particularly on Windows where the running executable is locked. +fn replace_binary(_current_exe: &Path, new_binary: &Path) -> Result<()> { + self_replace::self_replace(new_binary) + .map_err(|e| Error::UpdateFailed(format!("failed to replace binary: {e}")))?; + Ok(()) +} + +/// Build the download URL for a given version. +/// +/// Tag format: `ant-cli-v{version}` +/// Asset format: `ant-{version}-{target_triple}.{ext}` +pub fn build_download_url(version: &str) -> Result { + let asset_name = cli_platform_asset_name(version)?; + Ok(format!( + "https://github.com/{GITHUB_REPO}/releases/download/{TAG_PREFIX}{version}/{asset_name}" + )) +} + +/// Returns the platform-specific archive asset name for the CLI binary. +/// +/// Matches the naming convention from CI: `ant-{version}-{target_triple}.{ext}` +fn cli_platform_asset_name(version: &str) -> Result { + let target_triple = if cfg!(all(target_os = "linux", target_arch = "x86_64")) { + "x86_64-unknown-linux-musl" + } else if cfg!(all(target_os = "linux", target_arch = "aarch64")) { + "aarch64-unknown-linux-musl" + } else if cfg!(all(target_os = "macos", target_arch = "x86_64")) { + "x86_64-apple-darwin" + } else if cfg!(all(target_os = "macos", target_arch = "aarch64")) { + "aarch64-apple-darwin" + } else if cfg!(all(target_os = "windows", target_arch = "x86_64")) { + "x86_64-pc-windows-msvc" + } else { + return Err(Error::UpdateFailed(format!( + "unsupported platform: {}-{}", + std::env::consts::OS, + std::env::consts::ARCH + ))); + }; + + let ext = if cfg!(target_os = "windows") { + "zip" + } else { + "tar.gz" + }; + + Ok(format!("ant-{version}-{target_triple}.{ext}")) +} + +fn parse_version(version: &str) -> Result { + let cleaned = version.strip_prefix('v').unwrap_or(version); + semver::Version::parse(cleaned) + .map_err(|e| Error::UpdateFailed(format!("invalid version '{version}': {e}"))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_version_valid() { + assert!(parse_version("1.2.3").is_ok()); + assert!(parse_version("v1.2.3").is_ok()); + assert!(parse_version("0.1.0").is_ok()); + } + + #[test] + fn parse_version_invalid() { + assert!(parse_version("not-a-version").is_err()); + assert!(parse_version("").is_err()); + } + + #[test] + fn version_comparison() { + let v1 = parse_version("0.1.0").unwrap(); + let v2 = parse_version("0.2.0").unwrap(); + assert!(v2 > v1); + + let v3 = parse_version("1.0.0").unwrap(); + assert!(v3 > v2); + + let same = parse_version("0.1.0").unwrap(); + assert_eq!(v1, same); + } + + #[test] + fn check_result_no_update() { + let check = UpdateCheck { + current_version: "1.0.0".to_string(), + latest_version: "1.0.0".to_string(), + update_available: false, + download_url: None, + }; + assert!(!check.update_available); + assert!(check.download_url.is_none()); + } + + #[test] + fn check_result_with_update() { + let check = UpdateCheck { + current_version: "0.1.0".to_string(), + latest_version: "0.2.0".to_string(), + update_available: true, + download_url: Some("https://example.com/ant.tar.gz".to_string()), + }; + assert!(check.update_available); + assert!(check.download_url.is_some()); + } + + #[test] + fn platform_asset_name_format() { + let name = cli_platform_asset_name("1.2.3").unwrap(); + assert!(name.starts_with("ant-1.2.3-")); + assert!( + name.ends_with(".tar.gz") || name.ends_with(".zip"), + "unexpected extension: {name}" + ); + } + + #[test] + fn build_download_url_format() { + let url = build_download_url("1.2.3").unwrap(); + assert!(url.starts_with( + "https://github.com/WithAutonomi/ant-client/releases/download/ant-cli-v1.2.3/ant-1.2.3-" + )); + assert!(url.ends_with(".tar.gz") || url.ends_with(".zip")); + } + + #[test] + fn update_check_serializes() { + let check = UpdateCheck { + current_version: "0.1.0".to_string(), + latest_version: "0.2.0".to_string(), + update_available: true, + download_url: Some("https://example.com/ant.tar.gz".to_string()), + }; + let json = serde_json::to_string(&check).unwrap(); + let deserialized: UpdateCheck = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized.current_version, "0.1.0"); + assert!(deserialized.update_available); + } +} From 767e3c5f1fc9b00ffcd54ebd68f8782fc23656aa Mon Sep 17 00:00:00 2001 From: Chris O'Neil Date: Wed, 1 Apr 2026 21:39:30 +0100 Subject: [PATCH 2/3] feat: add --version flag with project description -V shows the short version (ant 0.1.1), --version shows the full version with project description, repository URL, and licence. Co-Authored-By: Claude Opus 4.6 (1M context) --- ant-cli/src/cli.rs | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/ant-cli/src/cli.rs b/ant-cli/src/cli.rs index 3656675..28f8ac0 100644 --- a/ant-cli/src/cli.rs +++ b/ant-cli/src/cli.rs @@ -6,8 +6,24 @@ use crate::commands::data::{ChunkAction, FileAction, WalletAction}; use crate::commands::node::NodeCommand; use crate::commands::update::UpdateArgs; +fn long_version() -> &'static str { + concat!( + env!("CARGO_PKG_VERSION"), + "\n", + "Autonomi network client: file operations and node management for the Autonomi decentralised network\n", + "\n", + "Repository: https://github.com/WithAutonomi/ant-client\n", + "License: MIT or Apache-2.0", + ) +} + #[derive(Parser)] -#[command(name = "ant", about = "Autonomi network client")] +#[command( + name = "ant", + version, + long_version = long_version(), + about = "Autonomi network client" +)] pub struct Cli { /// Output structured JSON instead of human-readable text #[arg(long, global = true)] From 67fac5f58bd36a535d7c0cc4fb423292e7d28f3d Mon Sep 17 00:00:00 2001 From: Chris O'Neil Date: Thu, 2 Apr 2026 15:03:26 +0100 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20address=20PR=20review=20=E2=80=94=20?= =?UTF-8?q?signature=20verification=20and=20cleanup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add ML-DSA-65 signature verification using the ant-client release signing key and ant-release-v1 context. Download and verify the detached .sig file before extracting the archive. Also: verify binary version post-extraction, remove dead UpdateOpts, encapsulate force logic in UpdateCheck::force(), use tempfile::tempdir(), bump per_page to 100, remove unused _current_exe parameter. Co-Authored-By: Claude Opus 4.6 (1M context) --- ant-cli/src/commands/update.rs | 4 +- ant-core/Cargo.toml | 2 +- ant-core/src/update.rs | 358 ++++++++++++++++++++++++++++++--- 3 files changed, 329 insertions(+), 35 deletions(-) diff --git a/ant-cli/src/commands/update.rs b/ant-cli/src/commands/update.rs index 3364a30..45de66c 100644 --- a/ant-cli/src/commands/update.rs +++ b/ant-cli/src/commands/update.rs @@ -43,9 +43,7 @@ impl UpdateArgs { let mut check = update::check_for_update(current_version).await?; if !check.update_available && self.force { - // Force mode: rebuild the download URL even if versions match. - check.update_available = true; - check.download_url = Some(update::build_download_url(&check.latest_version)?); + check.force()?; } if !check.update_available { diff --git a/ant-core/Cargo.toml b/ant-core/Cargo.toml index 07d4375..ae56603 100644 --- a/ant-core/Cargo.toml +++ b/ant-core/Cargo.toml @@ -17,6 +17,7 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" reqwest = { version = "0.12", features = ["json", "stream"] } tar = "0.4" +tempfile = "3" toml = "0.8" thiserror = "2" tokio = { version = "1", features = ["full"] } @@ -52,7 +53,6 @@ openssl = { version = "0.10", features = ["vendored"] } windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_System_Console", "Win32_System_Threading"] } [dev-dependencies] -tempfile = "3" serial_test = "3" anyhow = "1" alloy = { version = "1.6", features = ["node-bindings"] } diff --git a/ant-core/src/update.rs b/ant-core/src/update.rs index 156c22b..e03b321 100644 --- a/ant-core/src/update.rs +++ b/ant-core/src/update.rs @@ -1,6 +1,7 @@ use std::path::{Path, PathBuf}; use futures_util::StreamExt; +use saorsa_pqc::api::sig::{ml_dsa_65, MlDsaPublicKey, MlDsaSignature, MlDsaVariant}; use serde::{Deserialize, Serialize}; use crate::error::{Error, Result}; @@ -8,6 +9,143 @@ use crate::node::binary::{extract_tar_gz, extract_zip, ProgressReporter}; const GITHUB_REPO: &str = "WithAutonomi/ant-client"; const CLI_BINARY_NAME: &str = "ant"; +const TAG_PREFIX: &str = "ant-cli-v"; + +/// Signing context for domain separation (must match the context used by `ant-keygen sign`). +const SIGNING_CONTEXT: &[u8] = b"ant-release-v1"; + +/// ML-DSA-65 signature size in bytes. +const SIGNATURE_SIZE: usize = 3309; + +/// Embedded release signing public key (ML-DSA-65). +/// +/// This key is used to verify signatures on released ant-client binaries. +/// The corresponding private key is held by authorised release signers. +/// Generated: 2026-03-30 19:49:36 UTC +const RELEASE_SIGNING_KEY: &[u8] = &[ + 0xb4, 0xcf, 0x2a, 0x24, 0x31, 0xd9, 0xb2, 0x3a, 0xab, 0xe9, 0x5e, 0xfc, 0xbc, 0xf3, 0xb1, 0x1f, + 0x4e, 0x50, 0x0a, 0x46, 0xeb, 0x83, 0xfc, 0x6f, 0x0f, 0x89, 0x41, 0x00, 0x1b, 0x52, 0xde, 0xdc, + 0xb5, 0xc9, 0x07, 0xed, 0x72, 0x3a, 0xe1, 0xa9, 0x82, 0xe1, 0xfc, 0xff, 0xab, 0xce, 0x9f, 0x7d, + 0xab, 0xe2, 0x57, 0x92, 0xe0, 0xf2, 0xac, 0xa1, 0x41, 0xd3, 0x74, 0x95, 0x41, 0xd1, 0xac, 0x22, + 0xb1, 0xbb, 0x5c, 0xf4, 0x02, 0x0b, 0x73, 0x85, 0xfd, 0x56, 0x75, 0x0d, 0x5c, 0x38, 0xe1, 0x2d, + 0xe0, 0x15, 0x4f, 0xbf, 0x40, 0xeb, 0xf1, 0x0d, 0x8f, 0x39, 0x32, 0xeb, 0x80, 0xa7, 0x2e, 0x77, + 0x3e, 0x54, 0xe3, 0x3d, 0x62, 0xae, 0xe7, 0x09, 0x5e, 0xfb, 0xdc, 0xaa, 0x07, 0xdc, 0xe1, 0x08, + 0x96, 0xbb, 0x4b, 0xa0, 0x2e, 0x05, 0x2d, 0xee, 0xeb, 0x9a, 0x1a, 0xae, 0xde, 0xe9, 0x2c, 0xf8, + 0x2f, 0x43, 0x6c, 0x78, 0x4b, 0xde, 0xef, 0x91, 0x8b, 0x94, 0x9d, 0x4f, 0x81, 0x05, 0xcc, 0xf0, + 0x98, 0xce, 0xce, 0x67, 0x54, 0xac, 0xde, 0xcd, 0x26, 0x9e, 0x84, 0xf4, 0x88, 0xb2, 0x1a, 0x3e, + 0x93, 0x2e, 0xff, 0xa8, 0x45, 0x95, 0xd1, 0xd0, 0xb1, 0x6c, 0x3c, 0x1e, 0xef, 0x3d, 0xe3, 0xf2, + 0x73, 0xe2, 0xf6, 0xb7, 0xf9, 0x3f, 0x10, 0x0d, 0x3c, 0xde, 0x28, 0x94, 0x07, 0xef, 0x24, 0x70, + 0xc4, 0x5a, 0x0a, 0x67, 0xbb, 0x0f, 0x4f, 0x5c, 0x2b, 0xd8, 0x02, 0x05, 0xa5, 0x98, 0x03, 0x5d, + 0x8f, 0xc0, 0x4a, 0x84, 0xe9, 0xea, 0xac, 0x13, 0xdf, 0x69, 0xfc, 0x1e, 0xcf, 0xb6, 0x88, 0xba, + 0x99, 0x30, 0xbc, 0x7a, 0xb8, 0x9d, 0x3d, 0x62, 0x3b, 0x33, 0x19, 0xbb, 0x3a, 0x2c, 0x2b, 0xa0, + 0x5a, 0xb0, 0x8f, 0x9e, 0x10, 0x81, 0xb9, 0x12, 0x54, 0x81, 0xf8, 0xe2, 0x91, 0xb2, 0xe7, 0xe6, + 0x9c, 0x11, 0xeb, 0x49, 0x64, 0x3a, 0x25, 0xd6, 0x53, 0x2e, 0xdf, 0xfc, 0x14, 0x32, 0x65, 0xcc, + 0x87, 0xdb, 0xfd, 0xbb, 0x81, 0xa8, 0x50, 0xcc, 0xb4, 0x31, 0x0c, 0x70, 0xf3, 0xb6, 0x15, 0x8a, + 0x50, 0x80, 0xad, 0xb1, 0xb0, 0x10, 0x2b, 0x67, 0x33, 0xf5, 0xf6, 0x36, 0x35, 0x5f, 0xa7, 0xd2, + 0x81, 0xd6, 0x75, 0xa1, 0x18, 0x15, 0xbe, 0x1d, 0x5e, 0x33, 0x8e, 0x98, 0xdd, 0x45, 0x0f, 0x0c, + 0x0f, 0x0d, 0x8b, 0x3f, 0x97, 0x11, 0x21, 0x2e, 0xa0, 0x5e, 0xfe, 0x70, 0x09, 0xa7, 0x14, 0x30, + 0xa3, 0x01, 0x2d, 0x18, 0x2b, 0x8f, 0x19, 0x75, 0x54, 0x1f, 0xd8, 0xee, 0x66, 0x06, 0x7b, 0x9d, + 0x7d, 0xb2, 0xae, 0x14, 0xe6, 0x51, 0x19, 0xc2, 0x45, 0x2e, 0x7e, 0x11, 0xd9, 0x7b, 0x16, 0x8e, + 0xae, 0x17, 0xdb, 0x1b, 0x24, 0x90, 0xcd, 0xed, 0x94, 0xf9, 0xf7, 0xba, 0x9f, 0x4c, 0x12, 0xae, + 0x31, 0x7b, 0xd4, 0x7c, 0x04, 0x42, 0x2c, 0x32, 0x16, 0xc1, 0x70, 0x6d, 0x11, 0x6f, 0x3b, 0x44, + 0x62, 0xba, 0xbd, 0xc5, 0x7a, 0xec, 0x55, 0x1b, 0xcd, 0xdb, 0xb6, 0x55, 0x08, 0x86, 0x13, 0x7f, + 0x4e, 0xa9, 0x63, 0xe1, 0x87, 0xa0, 0x7e, 0x49, 0xfb, 0xf4, 0xa3, 0x46, 0xcf, 0x1d, 0xec, 0xf5, + 0xc6, 0x2f, 0xe1, 0x43, 0x02, 0xd0, 0xe4, 0x5f, 0x1b, 0x20, 0x1a, 0xa7, 0x81, 0xbd, 0x31, 0x19, + 0x6a, 0x74, 0xd7, 0x9a, 0x6d, 0x3d, 0xf8, 0xac, 0x4d, 0xbb, 0x01, 0x63, 0xa4, 0x9d, 0x3c, 0xc9, + 0x6c, 0x8a, 0x4f, 0x61, 0xd6, 0x98, 0xf5, 0x40, 0x22, 0xa9, 0x5e, 0x93, 0x5e, 0x13, 0xd3, 0xe0, + 0xdb, 0x54, 0xab, 0x0d, 0xe3, 0x88, 0x85, 0x80, 0x7a, 0x5e, 0x38, 0x64, 0x97, 0xc4, 0xe9, 0xb0, + 0x5d, 0xf7, 0x40, 0x5f, 0x6e, 0x3f, 0xbe, 0x14, 0x9a, 0x7c, 0xa2, 0x7c, 0x74, 0xa5, 0x32, 0x22, + 0x61, 0x31, 0xa5, 0x0d, 0xa5, 0xcc, 0x93, 0xe4, 0xfd, 0xed, 0xbc, 0xe7, 0xf2, 0xe5, 0xdb, 0xc6, + 0x0c, 0xe1, 0xc8, 0x4e, 0xee, 0xe6, 0x76, 0x1c, 0x10, 0x1b, 0xd8, 0x53, 0xd4, 0xe8, 0x07, 0xed, + 0xea, 0x91, 0xd4, 0x1b, 0x91, 0x5c, 0x28, 0x05, 0xca, 0xe2, 0x9c, 0xd1, 0x99, 0x43, 0xed, 0xd8, + 0x6a, 0x2b, 0xd2, 0x64, 0x9b, 0xe1, 0x0c, 0x88, 0x6c, 0x0d, 0xb2, 0x6b, 0x73, 0x85, 0x9d, 0xbf, + 0x79, 0x78, 0xaa, 0x7b, 0x5e, 0xf8, 0xa4, 0x26, 0xdf, 0xb3, 0x9b, 0x24, 0x5a, 0xc8, 0x19, 0x22, + 0xa5, 0xc6, 0xca, 0x00, 0x59, 0x3b, 0xad, 0x45, 0xdf, 0x71, 0x1d, 0x60, 0x60, 0x24, 0x0d, 0xa4, + 0x3d, 0x42, 0x23, 0xb8, 0xfe, 0xac, 0x86, 0x94, 0x79, 0x87, 0x05, 0xae, 0xb8, 0x4d, 0x7e, 0x11, + 0x5b, 0x22, 0x44, 0x15, 0x3d, 0x7f, 0x82, 0x98, 0x65, 0x0a, 0x3c, 0xd3, 0xef, 0x80, 0x0d, 0x75, + 0x03, 0x92, 0xf6, 0x3a, 0x8b, 0xa4, 0xb0, 0x61, 0x2d, 0x2c, 0xcc, 0x1f, 0x01, 0x8e, 0x7a, 0x46, + 0x36, 0x2a, 0x83, 0x21, 0x88, 0x98, 0x13, 0x0a, 0xd5, 0xa1, 0x54, 0x4b, 0x63, 0xe0, 0xe3, 0x1c, + 0x07, 0x5e, 0x32, 0x8c, 0xa4, 0x6b, 0x62, 0xc3, 0x28, 0x95, 0xb8, 0x0a, 0xb9, 0x4f, 0xaf, 0x7f, + 0x49, 0xeb, 0xff, 0xd7, 0xa1, 0x41, 0x43, 0x9a, 0x92, 0x9a, 0x5f, 0xee, 0xbd, 0xb9, 0xbe, 0xb3, + 0x4b, 0x9d, 0x0b, 0xcb, 0x9b, 0x2c, 0x26, 0x8f, 0x0f, 0xb1, 0xfa, 0xc0, 0xe3, 0x3a, 0x7f, 0x2b, + 0x51, 0x79, 0x75, 0x25, 0x5b, 0x23, 0x22, 0xb1, 0x01, 0x27, 0x4e, 0x43, 0xdd, 0x66, 0x7a, 0x33, + 0x4d, 0x32, 0x96, 0x83, 0x59, 0x52, 0xd7, 0x3c, 0xb0, 0xe3, 0x03, 0xd6, 0xb0, 0xc7, 0x99, 0x68, + 0xc6, 0xa4, 0x2d, 0x35, 0x3d, 0xa4, 0x6a, 0x17, 0xd9, 0xf4, 0x0c, 0x26, 0x11, 0xe4, 0xbc, 0x03, + 0x87, 0x25, 0x62, 0xad, 0xa9, 0x7e, 0x96, 0x4d, 0x39, 0x9b, 0x8f, 0x09, 0xdc, 0xd1, 0x28, 0x5e, + 0xf4, 0xe3, 0x94, 0xfd, 0x94, 0x46, 0x30, 0xe2, 0x24, 0x46, 0x30, 0x7f, 0xf4, 0x4c, 0xaa, 0x51, + 0x7e, 0x04, 0x5c, 0xa4, 0x8c, 0xba, 0x4a, 0xb8, 0x61, 0x5e, 0x75, 0x1c, 0xa8, 0x0c, 0xbc, 0x7f, + 0x36, 0x16, 0xa1, 0x72, 0x98, 0x6a, 0x44, 0x39, 0x42, 0x67, 0xb5, 0x4a, 0xac, 0x14, 0x35, 0x8f, + 0xcd, 0x87, 0x3f, 0x9e, 0x2e, 0xa1, 0x53, 0xf1, 0x45, 0x68, 0x26, 0xcb, 0x35, 0x96, 0x57, 0xd5, + 0x3a, 0x24, 0x74, 0xe2, 0xff, 0xe0, 0x70, 0xb1, 0xbd, 0xec, 0x0c, 0xd2, 0x97, 0x9a, 0xe5, 0x9f, + 0xa9, 0xfe, 0x6a, 0x63, 0x17, 0x35, 0xad, 0x64, 0x2f, 0xd9, 0x2e, 0xdb, 0x47, 0xdc, 0x62, 0xdc, + 0xcc, 0xee, 0x7e, 0x23, 0xa6, 0x67, 0x61, 0x7c, 0xd1, 0x03, 0xbd, 0x78, 0xe9, 0x34, 0x05, 0xed, + 0x05, 0x87, 0xef, 0x59, 0xf4, 0x16, 0xd6, 0x8d, 0x85, 0x46, 0x65, 0x2a, 0x08, 0xac, 0x4a, 0x5d, + 0xe6, 0x27, 0x5f, 0x43, 0xdd, 0x51, 0x4e, 0x95, 0x9b, 0xf5, 0x0c, 0x81, 0x24, 0x73, 0x39, 0x77, + 0xe9, 0xc8, 0x35, 0x4a, 0xe2, 0xb8, 0x35, 0x92, 0xde, 0x5c, 0x31, 0x12, 0x36, 0x5c, 0xc7, 0x69, + 0xcd, 0x79, 0xa9, 0xf9, 0xcf, 0x13, 0xa9, 0x12, 0x29, 0x25, 0x5c, 0x6a, 0x34, 0xa4, 0xbf, 0xc5, + 0xb6, 0x2a, 0xc1, 0xba, 0x6a, 0xd3, 0x98, 0x8c, 0x9b, 0x6d, 0x9f, 0xb9, 0x25, 0xa6, 0xd1, 0x97, + 0x80, 0x38, 0x11, 0xdc, 0x73, 0x5c, 0xe7, 0x3a, 0x1f, 0xd2, 0x16, 0xcd, 0x63, 0xfb, 0x41, 0xb0, + 0xba, 0xb0, 0x38, 0x67, 0x48, 0xd2, 0x8a, 0x94, 0x2f, 0x11, 0x81, 0xbf, 0x66, 0x38, 0x68, 0xff, + 0xfe, 0xd1, 0x7c, 0xcd, 0xa3, 0xac, 0xe4, 0xf7, 0x58, 0x19, 0xcd, 0x2a, 0xe3, 0xfa, 0x4d, 0xb0, + 0xbe, 0xac, 0x05, 0x1c, 0xd9, 0x8d, 0xf7, 0x5c, 0xc0, 0xfc, 0xa6, 0xb5, 0x99, 0xb8, 0x8e, 0x2b, + 0x72, 0xf8, 0x19, 0xfc, 0x17, 0x11, 0xf6, 0x2b, 0x08, 0xe4, 0x6e, 0xb0, 0x65, 0xab, 0x78, 0x8a, + 0xfc, 0x7c, 0x09, 0xca, 0x73, 0xcd, 0x35, 0x5d, 0x6c, 0x7a, 0x36, 0xc0, 0x24, 0xba, 0x3f, 0x08, + 0xea, 0x17, 0x09, 0xe1, 0x9d, 0x5d, 0x18, 0x59, 0x8a, 0xd8, 0x6a, 0x6d, 0x85, 0x6a, 0x9e, 0xa9, + 0xe5, 0x4b, 0x45, 0xb2, 0x35, 0x6e, 0x62, 0x24, 0x08, 0x00, 0x1c, 0x06, 0x73, 0x27, 0x5d, 0x11, + 0x4a, 0xc8, 0x51, 0xbd, 0x59, 0xd6, 0x94, 0xce, 0x16, 0x15, 0x17, 0x58, 0x7f, 0x39, 0x9d, 0x4e, + 0x69, 0x1a, 0x64, 0xbb, 0xd4, 0x51, 0xb9, 0xe4, 0x7d, 0x51, 0x3a, 0xff, 0xe5, 0x1f, 0x29, 0xea, + 0x7e, 0xa5, 0x62, 0x63, 0xff, 0x10, 0xf7, 0x54, 0x35, 0xd1, 0xf3, 0x73, 0x1e, 0xab, 0xca, 0x52, + 0x14, 0xc6, 0x7e, 0x51, 0xc2, 0x48, 0x13, 0xcb, 0x30, 0xb2, 0x1a, 0x84, 0x72, 0xe5, 0x44, 0x83, + 0xc9, 0x90, 0xa5, 0x8c, 0xf9, 0xeb, 0x3c, 0x5c, 0xc6, 0xcc, 0x8a, 0x95, 0x8a, 0xfa, 0xeb, 0x37, + 0x9c, 0xde, 0xa2, 0xb1, 0x72, 0x4d, 0xd9, 0x3d, 0xab, 0xfd, 0x0e, 0xbd, 0x32, 0x9d, 0x23, 0xe9, + 0x6f, 0x85, 0x4e, 0xfe, 0xcd, 0x91, 0xfb, 0x82, 0x94, 0xee, 0x8b, 0xdf, 0x6a, 0xd9, 0x01, 0xa1, + 0xc6, 0x22, 0x18, 0x01, 0x8d, 0x10, 0xd5, 0x87, 0x42, 0xd0, 0xbd, 0x23, 0x75, 0x44, 0x53, 0x46, + 0xa5, 0xae, 0x00, 0x4c, 0x0e, 0x88, 0x4a, 0xa8, 0x3d, 0x4a, 0x30, 0xe0, 0x1a, 0xa4, 0xe5, 0x40, + 0xb8, 0xe0, 0x12, 0x9c, 0x44, 0x03, 0xfb, 0x2e, 0x4e, 0xf5, 0x29, 0xdb, 0x09, 0x84, 0x55, 0xc7, + 0x6c, 0xc6, 0x1f, 0xf9, 0xee, 0x0b, 0xa4, 0x91, 0x7d, 0x79, 0x27, 0x59, 0x75, 0x97, 0xec, 0x6a, + 0xa8, 0xf8, 0x55, 0xa8, 0x45, 0xd4, 0xd7, 0xa6, 0xc1, 0xc4, 0x27, 0x35, 0xe8, 0x4f, 0x39, 0x89, + 0x7d, 0x41, 0xf3, 0xf6, 0xd0, 0xb6, 0xf9, 0x91, 0xeb, 0x94, 0xf1, 0xbb, 0x17, 0x46, 0x9c, 0xd5, + 0x5a, 0x53, 0x04, 0x2d, 0x12, 0x7c, 0x17, 0x6a, 0x36, 0xb5, 0xea, 0xf3, 0x5b, 0x96, 0x1b, 0xee, + 0xce, 0xc4, 0xc0, 0x11, 0x5a, 0xbc, 0x0c, 0x29, 0xd0, 0x42, 0x1d, 0x16, 0x63, 0xea, 0x1e, 0x04, + 0x2f, 0xe3, 0x17, 0xed, 0x33, 0xac, 0x56, 0x80, 0x34, 0x41, 0x41, 0x1e, 0x77, 0x80, 0x06, 0x9f, + 0xbc, 0x2e, 0x78, 0xa1, 0x04, 0x00, 0x06, 0x6f, 0x36, 0x2f, 0xb7, 0xa5, 0x95, 0x37, 0x82, 0x9d, + 0xef, 0x41, 0x08, 0x85, 0x3d, 0x53, 0xa7, 0xfb, 0xfe, 0xba, 0x8c, 0xb9, 0xae, 0xc7, 0x89, 0x11, + 0x69, 0x4f, 0x62, 0xe6, 0xb6, 0x08, 0x6b, 0x35, 0x1c, 0x96, 0xb3, 0x7b, 0x40, 0x2d, 0xee, 0x07, + 0x40, 0x52, 0x4f, 0x68, 0x60, 0xf4, 0xb9, 0xc3, 0x54, 0x9f, 0x22, 0x50, 0x88, 0x48, 0x6a, 0x28, + 0x93, 0x46, 0x00, 0xe2, 0x4a, 0x85, 0x41, 0x78, 0x0e, 0x87, 0xc5, 0xeb, 0xfc, 0xd3, 0x5f, 0x4d, + 0x24, 0xe4, 0x9d, 0xeb, 0x1d, 0x00, 0x73, 0x85, 0x25, 0x47, 0x9e, 0x8c, 0x5b, 0x88, 0xf4, 0x3b, + 0x33, 0xf0, 0x3d, 0x3a, 0xa1, 0x28, 0xd3, 0x06, 0xb4, 0x7a, 0x4e, 0x5d, 0x31, 0x1b, 0xca, 0xf4, + 0x3f, 0x70, 0x30, 0x49, 0x44, 0x29, 0x24, 0x14, 0x5e, 0x35, 0xc2, 0x6c, 0x92, 0x7e, 0xf8, 0x97, + 0x0c, 0x51, 0x9d, 0x67, 0xc0, 0x10, 0xa9, 0x35, 0x48, 0x59, 0x6a, 0x33, 0xef, 0x40, 0x4e, 0x53, + 0x10, 0x14, 0x2a, 0x12, 0x38, 0xe6, 0xc4, 0x63, 0x9c, 0x84, 0x85, 0x06, 0xaf, 0x3d, 0x3a, 0x84, + 0x06, 0x60, 0x88, 0x32, 0xda, 0x2c, 0xe5, 0xc6, 0x59, 0xf1, 0xe0, 0x10, 0xe2, 0x3c, 0xe6, 0xbf, + 0x32, 0x7d, 0x32, 0x39, 0x6d, 0xe4, 0xd9, 0xca, 0xe7, 0xf5, 0xf4, 0xa6, 0x5f, 0xb2, 0x33, 0x05, + 0xb5, 0xad, 0x5f, 0xcb, 0x0b, 0x14, 0xaf, 0xeb, 0xc0, 0xec, 0x87, 0x85, 0x9b, 0x13, 0xb5, 0x8a, + 0x98, 0xa9, 0x92, 0x13, 0x1b, 0x74, 0xec, 0xfd, 0xe1, 0xc1, 0x22, 0x06, 0x5d, 0x4f, 0x06, 0xc7, + 0xdd, 0xc6, 0xf0, 0xc4, 0x01, 0x04, 0xad, 0x7f, 0x71, 0xbc, 0x74, 0x4d, 0xfd, 0x18, 0xa3, 0x56, + 0x2c, 0x45, 0x28, 0x2b, 0x2f, 0xbc, 0x9b, 0xb8, 0x4b, 0xe6, 0x51, 0x75, 0x28, 0x0c, 0x27, 0x0e, + 0xf7, 0x92, 0x8c, 0xc9, 0xde, 0x33, 0x1a, 0x65, 0x28, 0xc7, 0x01, 0x32, 0xa2, 0x36, 0x88, 0xb6, + 0x64, 0x10, 0x03, 0xd6, 0xb7, 0x9f, 0x9d, 0x73, 0xe1, 0xa9, 0xc7, 0xdf, 0xe1, 0x0b, 0x39, 0x31, + 0x77, 0xbc, 0x91, 0xf1, 0x45, 0x9a, 0xc5, 0x97, 0x28, 0xc0, 0x61, 0xc5, 0x23, 0x54, 0xad, 0xe3, + 0x23, 0x18, 0x69, 0xf7, 0x27, 0xd0, 0x5b, 0xf2, 0x44, 0x62, 0xdc, 0x97, 0xce, 0x4e, 0x40, 0x76, + 0x00, 0xde, 0xc2, 0xf9, 0x3a, 0x42, 0xfd, 0xd4, 0xd7, 0xe1, 0x85, 0xd8, 0xc9, 0x38, 0x91, 0xc1, + 0x79, 0x87, 0x58, 0xf1, 0x26, 0x1a, 0x29, 0x02, 0xe3, 0x54, 0xde, 0x58, 0x64, 0x9d, 0xe6, 0x8e, + 0x33, 0x70, 0x53, 0x43, 0x47, 0x90, 0xee, 0x6e, 0x0f, 0x8c, 0xb3, 0x9e, 0x47, 0x45, 0xfc, 0xa8, + 0xe3, 0x52, 0x62, 0x74, 0x6d, 0xa2, 0xaf, 0x28, 0x9d, 0xdf, 0x1e, 0x69, 0x1f, 0x56, 0xbc, 0x49, + 0xc1, 0xe5, 0xd6, 0xc4, 0xb5, 0x5c, 0x4d, 0x39, 0x49, 0x4b, 0xb4, 0xec, 0x56, 0x54, 0x9a, 0x15, + 0x94, 0x0a, 0xcb, 0xa9, 0x10, 0x46, 0x03, 0x5c, 0x23, 0x2f, 0x29, 0xed, 0x72, 0xa1, 0x57, 0xfa, + 0x58, 0xef, 0x21, 0x7e, 0xf2, 0x8b, 0xa7, 0x04, 0x51, 0xb4, 0x03, 0x5d, 0xd8, 0x48, 0xc0, 0xe5, + 0x83, 0xb6, 0x7a, 0x6b, 0xcd, 0xfb, 0xda, 0x47, 0xe8, 0xa1, 0xae, 0x57, 0x74, 0x49, 0xc0, 0xf9, + 0x4a, 0x6b, 0x3c, 0xb5, 0xd8, 0x27, 0x3d, 0x1d, 0x96, 0x39, 0x09, 0x65, 0x95, 0xdb, 0x01, 0xa3, + 0x8b, 0x78, 0x8b, 0x07, 0x6d, 0x1c, 0x8b, 0x4b, 0x1d, 0x9d, 0x4a, 0x4f, 0xcb, 0xb8, 0xf6, 0x22, + 0x73, 0x8a, 0x7b, 0xc8, 0xf2, 0x0a, 0xef, 0x03, 0x1e, 0xb7, 0x4d, 0x8f, 0xc0, 0xdf, 0x87, 0x88, + 0x05, 0xe1, 0x0a, 0x30, 0xea, 0xde, 0xf3, 0xc2, 0xb6, 0x00, 0x3c, 0xd6, 0xff, 0x3b, 0xb5, 0x01, + 0xfb, 0xd8, 0xb2, 0x65, 0x26, 0x5d, 0xa0, 0x5a, 0x7c, 0xef, 0x1d, 0x85, 0xbe, 0x51, 0xc2, 0x57, + 0x0b, 0x27, 0x37, 0x71, 0x99, 0xf5, 0x87, 0x83, 0x68, 0x0b, 0x88, 0xed, 0x66, 0x9e, 0x37, 0x59, + 0x84, 0x23, 0x72, 0xc3, 0x80, 0xac, 0xfe, 0x45, 0x5f, 0xdf, 0x31, 0xc4, 0x84, 0x07, 0x5a, 0x17, + 0x28, 0xcd, 0x64, 0xb4, 0xe2, 0xa3, 0x0e, 0x2c, 0x15, 0x60, 0x77, 0xdc, 0x08, 0x45, 0x36, 0x37, + 0x68, 0x50, 0xba, 0x03, 0x85, 0xb7, 0xed, 0xd0, 0x7b, 0xb2, 0xa1, 0x62, 0xbc, 0x70, 0x00, 0x9e, +]; /// Result of checking whether an update is available. #[derive(Debug, Clone, Serialize, Deserialize)] @@ -18,6 +156,17 @@ pub struct UpdateCheck { pub download_url: Option, } +impl UpdateCheck { + /// Force the check to report an update even if already on the latest version. + /// + /// Populates `download_url` using the current `latest_version`. + pub fn force(&mut self) -> Result<()> { + self.update_available = true; + self.download_url = Some(build_download_url(&self.latest_version)?); + Ok(()) + } +} + /// Result of a completed update. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct UpdateResult { @@ -25,12 +174,6 @@ pub struct UpdateResult { pub new_version: String, } -/// Options for the update operation. -pub struct UpdateOpts { - /// Force re-download even if already on the latest version. - pub force: bool, -} - /// Check whether a newer version is available on GitHub Releases. /// /// Compares `current_version` against the latest release tag using semantic versioning. @@ -56,8 +199,9 @@ pub async fn check_for_update(current_version: &str) -> Result { /// Download and install the update, replacing the current binary. /// -/// The `current_exe` path is the location of the running binary. The new binary is -/// downloaded to a temporary file beside it, then atomically swapped into place. +/// Downloads the release archive and its detached `.sig` file, verifies the +/// ML-DSA-65 signature against the embedded release key, extracts the binary, +/// and replaces the running executable. pub async fn perform_update( check: &UpdateCheck, progress: &dyn ProgressReporter, @@ -66,13 +210,43 @@ pub async fn perform_update( Error::UpdateFailed("no download URL — are you already on the latest version?".to_string()) })?; - let current_exe = std::env::current_exe().map_err(|e| { - Error::UpdateFailed(format!("could not determine current executable path: {e}")) - })?; + let tmp_dir = tempfile::tempdir() + .map_err(|e| Error::UpdateFailed(format!("failed to create temp directory: {e}")))?; + + let (archive_path, archive_bytes) = + download_archive(download_url, tmp_dir.path(), progress).await?; + + let sig_url = format!("{download_url}.sig"); + progress.report_started("Downloading signature..."); + let sig_bytes = download_bytes(&sig_url).await?; + + progress.report_started("Verifying ML-DSA signature..."); + verify_signature(&archive_bytes, &sig_bytes)?; + progress.report_complete("Signature verified"); + + progress.report_started("Extracting archive..."); + let binary_path = if download_url.ends_with(".zip") { + extract_zip(&archive_bytes, tmp_dir.path(), CLI_BINARY_NAME)? + } else { + extract_tar_gz(&archive_bytes, tmp_dir.path(), CLI_BINARY_NAME)? + }; + + // Verify the extracted binary reports the expected version. + let actual_version = extract_version(&binary_path).await; + if let Ok(ref v) = actual_version { + if v != &check.latest_version { + return Err(Error::UpdateFailed(format!( + "version mismatch: expected {}, binary reports {v}", + check.latest_version + ))); + } + } + + replace_binary(&binary_path)?; - let new_binary = download_and_extract_cli(download_url, progress).await?; - replace_binary(¤t_exe, &new_binary)?; - let _ = std::fs::remove_file(&new_binary); + // Clean up is handled by tmp_dir Drop, but remove the archive explicitly + // since it can be large. + let _ = std::fs::remove_file(&archive_path); Ok(UpdateResult { previous_version: check.current_version.clone(), @@ -80,14 +254,13 @@ pub async fn perform_update( }) } -const TAG_PREFIX: &str = "ant-cli-v"; - /// Fetch the latest stable CLI release version from GitHub. /// /// Lists all releases and finds the newest non-draft, non-prerelease one whose /// tag starts with `ant-cli-v`. async fn fetch_latest_cli_version() -> Result { - let url = format!("https://api.github.com/repos/{GITHUB_REPO}/releases?per_page=50"); + // per_page=100 is the GitHub API maximum; covers repos with many release tags. + let url = format!("https://api.github.com/repos/{GITHUB_REPO}/releases?per_page=100"); let client = reqwest::Client::new(); let resp = client .get(&url) @@ -131,10 +304,15 @@ async fn fetch_latest_cli_version() -> Result { .ok_or_else(|| Error::UpdateFailed("no ant-cli release found on GitHub".to_string())) } -/// Download the CLI archive and extract the binary to a temp directory. +/// Download a release archive to a temp directory, streaming to disk. /// -/// Returns the path to the extracted binary. -async fn download_and_extract_cli(url: &str, progress: &dyn ProgressReporter) -> Result { +/// Returns the path to the downloaded file and the raw bytes (needed for +/// signature verification before extraction). +async fn download_archive( + url: &str, + tmp_dir: &Path, + progress: &dyn ProgressReporter, +) -> Result<(PathBuf, Vec)> { progress.report_started(&format!("Downloading {CLI_BINARY_NAME} from {url}")); let client = reqwest::Client::new(); @@ -155,8 +333,6 @@ async fn download_and_extract_cli(url: &str, progress: &dyn ProgressReporter) -> let total_size = resp.content_length().unwrap_or(0); let mut downloaded: u64 = 0; - let tmp_dir = std::env::temp_dir().join("ant-update"); - std::fs::create_dir_all(&tmp_dir)?; let tmp_archive = tmp_dir.join(".download.tmp"); let mut tmp_file = std::fs::File::create(&tmp_archive) .map_err(|e| Error::UpdateFailed(format!("failed to create temp file: {e}")))?; @@ -172,28 +348,101 @@ async fn download_and_extract_cli(url: &str, progress: &dyn ProgressReporter) -> } drop(tmp_file); - progress.report_started("Extracting archive..."); + progress.report_complete("Download complete"); let bytes = std::fs::read(&tmp_archive) .map_err(|e| Error::UpdateFailed(format!("failed to read temp file: {e}")))?; - let _ = std::fs::remove_file(&tmp_archive); - let binary_path = if url.ends_with(".zip") { - extract_zip(&bytes, &tmp_dir, CLI_BINARY_NAME)? + Ok((tmp_archive, bytes)) +} + +/// Download a small file (e.g., a `.sig` signature) into memory. +async fn download_bytes(url: &str) -> Result> { + let client = reqwest::Client::new(); + let resp = client + .get(url) + .header("User-Agent", "ant-cli") + .send() + .await + .map_err(|e| Error::UpdateFailed(format!("download request failed: {e}")))?; + + if !resp.status().is_success() { + return Err(Error::UpdateFailed(format!( + "download returned status {} for {url}", + resp.status() + ))); + } + + resp.bytes() + .await + .map(|b| b.to_vec()) + .map_err(|e| Error::UpdateFailed(format!("failed to read response body: {e}"))) +} + +/// Verify the ML-DSA-65 signature on an archive using the embedded release key. +fn verify_signature(archive_bytes: &[u8], signature_bytes: &[u8]) -> Result<()> { + if signature_bytes.len() != SIGNATURE_SIZE { + return Err(Error::UpdateFailed(format!( + "invalid signature size: expected {SIGNATURE_SIZE}, got {}", + signature_bytes.len() + ))); + } + + let public_key = MlDsaPublicKey::from_bytes(MlDsaVariant::MlDsa65, RELEASE_SIGNING_KEY) + .map_err(|e| Error::UpdateFailed(format!("invalid embedded release key: {e}")))?; + + let sig = MlDsaSignature::from_bytes(MlDsaVariant::MlDsa65, signature_bytes) + .map_err(|e| Error::UpdateFailed(format!("invalid signature format: {e}")))?; + + let dsa = ml_dsa_65(); + let valid = dsa + .verify_with_context(&public_key, archive_bytes, &sig, SIGNING_CONTEXT) + .map_err(|e| Error::UpdateFailed(format!("signature verification error: {e}")))?; + + if valid { + Ok(()) } else { - extract_tar_gz(&bytes, &tmp_dir, CLI_BINARY_NAME)? - }; + Err(Error::UpdateFailed( + "signature verification failed: archive may be corrupted or tampered".to_string(), + )) + } +} - progress.report_complete("Download complete"); +/// Extract the version string from a binary by running ` --version`. +async fn extract_version(binary_path: &Path) -> Result { + let output = tokio::process::Command::new(binary_path) + .arg("--version") + .output() + .await + .map_err(|e| { + Error::UpdateFailed(format!( + "failed to run {} --version: {e}", + binary_path.display() + )) + })?; + + if !output.status.success() { + return Err(Error::UpdateFailed(format!( + "{} --version exited with status {}", + binary_path.display(), + output.status + ))); + } - Ok(binary_path) + let stdout = String::from_utf8_lossy(&output.stdout); + // Expect output like "ant 0.3.4" — extract the version part. + Ok(stdout + .split_whitespace() + .last() + .unwrap_or("unknown") + .to_string()) } /// Replace the current executable with the new binary. /// /// Uses the `self_replace` crate which handles platform-specific nuances, /// particularly on Windows where the running executable is locked. -fn replace_binary(_current_exe: &Path, new_binary: &Path) -> Result<()> { +fn replace_binary(new_binary: &Path) -> Result<()> { self_replace::self_replace(new_binary) .map_err(|e| Error::UpdateFailed(format!("failed to replace binary: {e}")))?; Ok(()) @@ -301,6 +550,19 @@ mod tests { assert!(check.download_url.is_some()); } + #[test] + fn force_populates_download_url() { + let mut check = UpdateCheck { + current_version: "1.0.0".to_string(), + latest_version: "1.0.0".to_string(), + update_available: false, + download_url: None, + }; + check.force().unwrap(); + assert!(check.update_available); + assert!(check.download_url.is_some()); + } + #[test] fn platform_asset_name_format() { let name = cli_platform_asset_name("1.2.3").unwrap(); @@ -333,4 +595,38 @@ mod tests { assert_eq!(deserialized.current_version, "0.1.0"); assert!(deserialized.update_available); } + + #[test] + fn verify_signature_rejects_wrong_size() { + let result = verify_signature(b"some archive data", &[0u8; 100]); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("invalid signature size"), "got: {err}"); + } + + #[test] + fn verify_signature_rejects_invalid_signature() { + let invalid_sig = vec![0u8; SIGNATURE_SIZE]; + let result = verify_signature(b"some archive data", &invalid_sig); + assert!(result.is_err()); + } + + #[test] + fn verify_signature_valid_roundtrip() { + let dsa = ml_dsa_65(); + let (public_key, secret_key) = dsa.generate_keypair().unwrap(); + let archive = b"fake archive content for testing"; + + let sig = dsa + .sign_with_context(&secret_key, archive, SIGNING_CONTEXT) + .unwrap(); + + // Use the _with_key variant for testing (can't use embedded key) + let parsed_sig = + MlDsaSignature::from_bytes(MlDsaVariant::MlDsa65, &sig.to_bytes()).unwrap(); + let valid = dsa + .verify_with_context(&public_key, archive, &parsed_sig, SIGNING_CONTEXT) + .unwrap(); + assert!(valid); + } }