From 6bf16dacf33168ae579b40a3cfc20829b85057c3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 6 Aug 2026 02:17:27 +0000 Subject: [PATCH 1/9] Add numan update --self for CLI binary upgrades Standalone installs download the matching GitHub Release asset, verify SHA256SUMS, and replace the binary. Homebrew, winget, and cargo installs print the exact upgrade command instead. Co-authored-by: Anthony Thompson --- AGENTS.md | 3 +- CHANGELOG.md | 4 + README.md | 9 +- src/cmd/mod.rs | 1 + src/cmd/self_update.rs | 633 +++++++++++++++++++++++++++++++++++++++++ src/cmd/update.rs | 14 + 6 files changed, 660 insertions(+), 4 deletions(-) create mode 100644 src/cmd/self_update.rs diff --git a/AGENTS.md b/AGENTS.md index b966df38..bd6c6e78 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -58,7 +58,8 @@ src/ snapshot.rs — `numan snapshot list|inspect|delete|rollback` (Phase 5.3) deactivate.rs — Plugin + module deactivation: journaled plugin unregister (`execute_with_unregistrar`); module full/partial (Phase 4 / Issue #22 PR2) plugin_lifecycle.rs — Activate/deactivate-owned lifecycle boundary exposed to opt-in update orchestration (Issue #22 PR3) - update.rs — `numan update [--check] [pkg]`: upgrades; active plugins orchestrate deactivate→upgrade→activate only with exact env opt-in (Phase 5 / Issue #22 PR3) + update.rs — `numan update [--check] [pkg]`: upgrades packages; `numan update --self [--check]`: self-replace standalone binary (or print brew/winget/cargo upgrade); active plugins orchestrate deactivate→upgrade→activate only with exact env opt-in (Phase 5 / Issue #22 PR3) + self_update.rs — GitHub Release self-update for `update --self` (install-method detection, SHA256SUMS verify, atomic/Windows-safe binary replace) remove.rs — `numan remove [--force] `: remove from lockfile + delete payload (Phase 5); `--force` bypasses module activation only (active plugins always gated until deactivate, Issue #22) gc.rs — `numan gc [--dry-run]`: delete orphaned payload directories (Phase 5) nupm.rs — `numan nupm status|inspect|import|diff`: nupm discovery + import + drift (Phase 6.1–6.3) diff --git a/CHANGELOG.md b/CHANGELOG.md index f1db2fcf..d2db373e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **`numan update --self`**: upgrade the numan CLI itself. Standalone installs download the matching GitHub Release asset, verify `SHA256SUMS`, and replace the binary in place. Homebrew / winget / cargo installs print the exact upgrade command instead of self-replacing. Pair with `--check` to report without applying. + ## [0.2.0] - 2026-08-05 ### Added diff --git a/README.md b/README.md index 60665d18..81d5d8c1 100644 --- a/README.md +++ b/README.md @@ -281,8 +281,10 @@ numan deactivate owner/module-name #### 5. Maintain installs ```bash -numan update --check # see available upgrades -numan update # apply upgrades +numan update --check # see available package upgrades +numan update # apply package upgrades +numan update --self --check # see if a newer numan binary is available +numan update --self # replace this numan binary (or print brew/winget/cargo upgrade) numan remove owner/package-name numan gc --dry-run # preview orphaned payload dirs numan gc # delete unreferenced payloads @@ -348,6 +350,7 @@ Global flag: `--root ` — override the numan root directory (all commands | `numan activate [pkg...]` | Register plugins / write module autoloads (scripts and completion packages are deferred) | | `numan deactivate [pkg...]` | Remove module autoload entries | | `numan update [--check] [pkg]` | Upgrade installed packages | +| `numan update --self [--check]` | Upgrade the numan binary (GitHub Release self-replace, or print brew/winget/cargo command) | | `numan remove [--force] ` | Remove from lockfile and delete payload | | `numan gc [--dry-run]` | Delete orphaned package directories | | `numan snapshot list` | List all committed activation snapshots | @@ -377,7 +380,7 @@ Global flag: `--root ` — override the numan root directory (all commands | `install` | `--force` reinstall; `-v` / `--verbose` | | `activate` | `--verbose`; `--list` status only; `--check` integrity only | | `deactivate` | `--verbose` | -| `update` | `--check` report only; `-v` / `--verbose` | +| `update` | `--check` report only; `--self` update the numan binary; `-v` / `--verbose` | | `remove` | `--force` remove despite active activation | | `gc` | `--dry-run` preview only | | `registry add` | `--key ` (required for custom registries; official is auto-configured on `init`) | diff --git a/src/cmd/mod.rs b/src/cmd/mod.rs index 13855839..59c6ab70 100644 --- a/src/cmd/mod.rs +++ b/src/cmd/mod.rs @@ -13,6 +13,7 @@ pub mod plugin_lifecycle; pub mod registry; pub mod remove; pub mod search; +pub mod self_update; pub mod setup; pub mod snapshot; pub mod try_cmd; diff --git a/src/cmd/self_update.rs b/src/cmd/self_update.rs new file mode 100644 index 00000000..5f1d67ca --- /dev/null +++ b/src/cmd/self_update.rs @@ -0,0 +1,633 @@ +//! Self-update the `numan` binary from GitHub Releases (`numan update --self`). + +use anyhow::{bail, Context, Result}; +use serde::Deserialize; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +use crate::core::integrity; +use crate::core::platform::{Arch, Env, Os, Platform}; +use crate::install::download::download_file; +use crate::install::extract::{extract_archive, ArchiveFormat, ExtractConfig}; + +const RELEASES_LATEST: &str = "https://api.github.com/repos/tonythethompson/numan/releases/latest"; +const USER_AGENT: &str = "numan-cli (https://github.com/tonythethompson/numan)"; +const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION"); + +/// How this `numan` binary was installed. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum InstallMethod { + Homebrew, + Winget, + Cargo, + Standalone, +} + +impl InstallMethod { + pub fn upgrade_hint(self) -> Option<&'static str> { + match self { + InstallMethod::Homebrew => Some("brew upgrade numan"), + InstallMethod::Winget => Some("winget upgrade tonythethompson.numan"), + InstallMethod::Cargo => Some("cargo install numan-cli"), + InstallMethod::Standalone => None, + } + } + + pub fn display_name(self) -> &'static str { + match self { + InstallMethod::Homebrew => "Homebrew", + InstallMethod::Winget => "winget", + InstallMethod::Cargo => "cargo", + InstallMethod::Standalone => "standalone", + } + } +} + +/// Detect install channel from the running executable path (and its canonical target). +pub fn detect_install_method(exe: &Path) -> InstallMethod { + let mut candidates = vec![normalize_path_str(exe)]; + if let Ok(canon) = std::fs::canonicalize(exe) { + let n = normalize_path_str(&canon); + if n != candidates[0] { + candidates.push(n); + } + } + + for path in &candidates { + if path.contains("/cellar/numan/") + || path.contains("/opt/homebrew/") + || path.contains("/home/linuxbrew/.linuxbrew/") + || path.contains("/.linuxbrew/") + || path.contains("/usr/local/cellar/numan/") + { + return InstallMethod::Homebrew; + } + if path.contains("/microsoft/winget/packages/") + || path.contains("/microsoft/winget/links/") + || path.contains("/winget/packages/") + || path.contains("/winget/links/") + { + return InstallMethod::Winget; + } + if path.contains("/.cargo/bin/") { + return InstallMethod::Cargo; + } + } + + InstallMethod::Standalone +} + +fn normalize_path_str(path: &Path) -> String { + path.to_string_lossy().replace('\\', "/").to_lowercase() +} + +/// Release archive name for this platform (must match `.github/workflows/release.yml`). +pub fn release_asset_name(version: &str, platform: &Platform) -> Result { + let version = version.trim().trim_start_matches('v'); + let (triple, ext) = match (platform.os, platform.arch, platform.env) { + (Os::Linux, Arch::X86_64, Env::Gnu) => ("x86_64-unknown-linux-gnu", "tar.gz"), + (Os::Windows, Arch::X86_64, Env::Msvc) => ("x86_64-pc-windows-msvc", "zip"), + (Os::Macos, Arch::Aarch64, Env::Darwin) => ("aarch64-apple-darwin", "tar.gz"), + _ => bail!( + "No GitHub Release asset is published for platform triple '{}'. \ + Install or upgrade via Homebrew, winget, or cargo instead. \ + Supported self-update targets: x86_64-unknown-linux-gnu, \ + x86_64-pc-windows-msvc, aarch64-apple-darwin.", + platform.triple + ), + }; + Ok(format!("numan-{version}-{triple}.{ext}")) +} + +fn numan_binary_name() -> &'static str { + if cfg!(windows) { + "numan.exe" + } else { + "numan" + } +} + +/// Parse GNU `sha256sum` / SHA256SUMS lines into `filename -> hex hash`. +pub fn parse_sha256sums(text: &str) -> HashMap { + let mut map = HashMap::new(); + for line in text.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let (hash, rest) = match line.split_once(" ").or_else(|| line.split_once('\t')) { + Some(pair) => pair, + None => match line.split_once(' ') { + Some(pair) => pair, + None => continue, + }, + }; + let hash = hash.trim(); + if hash.len() != 64 || !hash.chars().all(|c| c.is_ascii_hexdigit()) { + continue; + } + let name = rest.trim().trim_start_matches('*').trim(); + if name.is_empty() { + continue; + } + // Prefer basename if a path was recorded. + let name = Path::new(name) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(name); + map.insert(name.to_string(), hash.to_ascii_lowercase()); + } + map +} + +/// Strip a leading `v` and parse as semver. +pub fn parse_release_version(tag: &str) -> Result { + let cleaned = tag.trim().trim_start_matches('v'); + semver::Version::parse(cleaned).with_context(|| format!("Invalid release version '{tag}'")) +} + +pub fn is_newer_than(latest: &semver::Version, current: &str) -> Result { + let current = parse_release_version(current)?; + Ok(latest > ¤t) +} + +#[derive(Debug, Deserialize)] +struct GitHubRelease { + tag_name: String, + assets: Vec, +} + +#[derive(Debug, Deserialize)] +struct GitHubAsset { + name: String, + browser_download_url: String, +} + +/// Injectable GitHub release client (tests supply fakes; production uses HTTP). +pub trait ReleaseClient { + fn fetch_latest(&self) -> Result<(String, Vec<(String, String)>)>; + fn download(&self, url: &str, dest: &Path) -> Result<()>; +} + +pub struct HttpReleaseClient; + +impl ReleaseClient for HttpReleaseClient { + fn fetch_latest(&self) -> Result<(String, Vec<(String, String)>)> { + let client = reqwest::blocking::Client::builder() + .timeout(std::time::Duration::from_secs(60)) + .build() + .context("Failed to build HTTP client for self-update")?; + let response = client + .get(RELEASES_LATEST) + .header(reqwest::header::USER_AGENT, USER_AGENT) + .send() + .context("Failed to query numan releases on GitHub")?; + if !response.status().is_success() { + bail!("Failed to query numan releases: HTTP {}", response.status()); + } + let body = response + .text() + .context("Failed to read numan release metadata from GitHub")?; + let release: GitHubRelease = serde_json::from_str(&body) + .context("Failed to parse numan release metadata from GitHub")?; + let assets = release + .assets + .into_iter() + .map(|a| (a.name, a.browser_download_url)) + .collect(); + Ok((release.tag_name, assets)) + } + + fn download(&self, url: &str, dest: &Path) -> Result<()> { + download_file(url, dest) + } +} + +/// Run `numan update --self` (optionally `--check`). +pub fn execute(check: bool, verbose: bool) -> Result<()> { + execute_with_client(&HttpReleaseClient, check, verbose, CURRENT_VERSION) +} + +/// Test seam: inject release client and current version string. +pub fn execute_with_client( + client: &dyn ReleaseClient, + check: bool, + verbose: bool, + current_version: &str, +) -> Result<()> { + let exe = std::env::current_exe().context("Failed to resolve current numan executable path")?; + let method = detect_install_method(&exe); + + if let Some(hint) = method.upgrade_hint() { + println!( + "This numan binary looks like a {} install.", + method.display_name() + ); + println!("Upgrade with:"); + println!(" {hint}"); + return Ok(()); + } + + let platform = Platform::detect(); + if verbose { + eprintln!( + "Self-update: current={current_version} platform={}", + platform.triple + ); + } + + let (tag, assets) = client + .fetch_latest() + .context("Failed to fetch latest numan release")?; + let latest = parse_release_version(&tag)?; + let asset_name = release_asset_name(&latest.to_string(), &platform)?; + + if !is_newer_than(&latest, current_version)? { + println!("numan is up to date ({current_version})."); + return Ok(()); + } + + if check { + println!("Update available: {current_version} → {latest}"); + println!("Run `numan update --self` to install."); + return Ok(()); + } + + let asset_url = assets + .iter() + .find(|(name, _)| name == &asset_name) + .map(|(_, url)| url.as_str()) + .with_context(|| { + format!( + "Release {tag} has no asset named '{asset_name}'. \ + Check https://github.com/tonythethompson/numan/releases" + ) + })?; + let sums_url = assets + .iter() + .find(|(name, _)| name == "SHA256SUMS") + .map(|(_, url)| url.as_str()) + .context( + "Release is missing SHA256SUMS. Refusing to self-update without checksum verification.", + )?; + + let temp = tempfile::tempdir().context("Failed to create temp dir for self-update")?; + let archive_path = temp.path().join(&asset_name); + let sums_path = temp.path().join("SHA256SUMS"); + + println!("Downloading {asset_name}..."); + client + .download(asset_url, &archive_path) + .with_context(|| format!("Failed to download {asset_name}"))?; + client + .download(sums_url, &sums_path) + .context("Failed to download SHA256SUMS")?; + + let sums_text = std::fs::read_to_string(&sums_path).context("Failed to read SHA256SUMS")?; + let sums = parse_sha256sums(&sums_text); + let expected = sums.get(&asset_name).with_context(|| { + format!("SHA256SUMS does not list '{asset_name}'. Refusing to install.") + })?; + integrity::verify_and_report(&archive_path, expected, &asset_name)?; + + let new_bytes = extract_numan_binary(&archive_path, &asset_name, temp.path())?; + let dest = std::fs::canonicalize(&exe).unwrap_or(exe); + replace_binary(&dest, &new_bytes)?; + + println!("Updated numan: {current_version} → {latest}"); + Ok(()) +} + +fn extract_numan_binary(archive_path: &Path, asset_name: &str, work: &Path) -> Result> { + let format = ArchiveFormat::from_url(asset_name) + .with_context(|| format!("Unsupported self-update archive format for '{asset_name}'"))?; + let extract_root = work.join("extract"); + std::fs::create_dir_all(&extract_root)?; + extract_archive( + archive_path, + &extract_root, + &ExtractConfig { + max_uncompressed_bytes: Some(64 * 1024 * 1024), + ..ExtractConfig::default() + }, + format, + ) + .with_context(|| format!("Failed to extract '{}'", archive_path.display()))?; + + let bin = locate_extracted_numan(&extract_root)?; + std::fs::read(&bin).with_context(|| format!("Failed to read extracted '{}'", bin.display())) +} + +fn locate_extracted_numan(extract_root: &Path) -> Result { + let name = numan_binary_name(); + let direct = extract_root.join(name); + if direct.is_file() { + return Ok(direct); + } + for entry in std::fs::read_dir(extract_root).with_context(|| { + format!( + "Failed to read extracted archive directory '{}'", + extract_root.display() + ) + })? { + let entry = entry?; + let path = entry.path(); + if path.is_dir() { + let candidate = path.join(name); + if candidate.is_file() { + return Ok(candidate); + } + } + } + bail!( + "Could not find '{}' in extracted self-update archive under '{}'", + name, + extract_root.display() + ) +} + +fn replace_binary(dest: &Path, new_bytes: &[u8]) -> Result<()> { + if new_bytes.is_empty() { + bail!("Extracted numan binary is empty"); + } + + #[cfg(windows)] + { + replace_binary_windows(dest, new_bytes) + } + #[cfg(not(windows))] + { + replace_binary_unix(dest, new_bytes) + } +} + +#[cfg(unix)] +fn replace_binary_unix(dest: &Path, new_bytes: &[u8]) -> Result<()> { + use crate::util::atomic::write_bytes_atomic; + write_bytes_atomic(dest, new_bytes) + .with_context(|| format!("Failed to replace numan binary at '{}'", dest.display()))?; + make_executable(dest)?; + Ok(()) +} + +#[cfg(windows)] +fn replace_binary_windows(dest: &Path, new_bytes: &[u8]) -> Result<()> { + use std::io::Write; + // Running executables cannot be overwritten in place on Windows. Move the + // current binary aside, write the new one, then best-effort delete the old. + let backup = dest.with_extension("exe.old"); + let _ = std::fs::remove_file(&backup); + std::fs::rename(dest, &backup).with_context(|| { + format!( + "Failed to move running numan aside to '{}'", + backup.display() + ) + })?; + let write_result = (|| -> Result<()> { + let parent = dest.parent().unwrap_or_else(|| Path::new(".")); + std::fs::create_dir_all(parent)?; + let mut tmp = tempfile::NamedTempFile::new_in(parent) + .with_context(|| format!("Failed to create temp file in '{}'", parent.display()))?; + tmp.write_all(new_bytes) + .context("Failed to write new numan binary")?; + tmp.flush().context("Failed to flush new numan binary")?; + tmp.persist(dest).map_err(|e| { + anyhow::anyhow!( + "Failed to install new numan at '{}': {}", + dest.display(), + e.error + ) + })?; + Ok(()) + })(); + if let Err(e) = write_result { + // Attempt to restore the previous binary. + let _ = std::fs::rename(&backup, dest); + return Err(e); + } + let _ = std::fs::remove_file(&backup); + Ok(()) +} + +#[cfg(unix)] +fn make_executable(path: &Path) -> Result<()> { + use std::os::unix::fs::PermissionsExt; + let mut perms = std::fs::metadata(path) + .with_context(|| format!("Failed to read permissions for '{}'", path.display()))? + .permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(path, perms) + .with_context(|| format!("Failed to mark numan executable at '{}'", path.display()))?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Mutex; + + #[test] + fn detect_homebrew_cellar() { + assert_eq!( + detect_install_method(Path::new("/opt/homebrew/Cellar/numan/0.2.0/bin/numan")), + InstallMethod::Homebrew + ); + } + + #[test] + fn detect_homebrew_bin() { + assert_eq!( + detect_install_method(Path::new("/opt/homebrew/bin/numan")), + InstallMethod::Homebrew + ); + } + + #[test] + fn detect_winget_packages() { + assert_eq!( + detect_install_method(Path::new( + r"C:\Users\me\AppData\Local\Microsoft\WinGet\Packages\tonythethompson.numan_1.0.0\numan.exe" + )), + InstallMethod::Winget + ); + } + + #[test] + fn detect_cargo_bin() { + assert_eq!( + detect_install_method(Path::new("/home/me/.cargo/bin/numan")), + InstallMethod::Cargo + ); + } + + #[test] + fn detect_standalone() { + assert_eq!( + detect_install_method(Path::new("/usr/local/bin/numan")), + InstallMethod::Standalone + ); + } + + #[test] + fn release_asset_name_linux_gnu() { + let p = Platform { + os: Os::Linux, + arch: Arch::X86_64, + env: Env::Gnu, + triple: "x86_64-unknown-linux-gnu".into(), + }; + assert_eq!( + release_asset_name("0.2.0", &p).unwrap(), + "numan-0.2.0-x86_64-unknown-linux-gnu.tar.gz" + ); + assert_eq!( + release_asset_name("v0.2.0", &p).unwrap(), + "numan-0.2.0-x86_64-unknown-linux-gnu.tar.gz" + ); + } + + #[test] + fn release_asset_name_windows() { + let p = Platform { + os: Os::Windows, + arch: Arch::X86_64, + env: Env::Msvc, + triple: "x86_64-pc-windows-msvc".into(), + }; + assert_eq!( + release_asset_name("0.2.0", &p).unwrap(), + "numan-0.2.0-x86_64-pc-windows-msvc.zip" + ); + } + + #[test] + fn release_asset_name_macos_arm() { + let p = Platform { + os: Os::Macos, + arch: Arch::Aarch64, + env: Env::Darwin, + triple: "aarch64-apple-darwin".into(), + }; + assert_eq!( + release_asset_name("0.2.0", &p).unwrap(), + "numan-0.2.0-aarch64-apple-darwin.tar.gz" + ); + } + + #[test] + fn release_asset_name_rejects_unsupported() { + let p = Platform { + os: Os::Macos, + arch: Arch::X86_64, + env: Env::Darwin, + triple: "x86_64-apple-darwin".into(), + }; + let err = release_asset_name("0.2.0", &p).unwrap_err().to_string(); + assert!(err.contains("No GitHub Release asset"), "{err}"); + } + + #[test] + fn parse_sha256sums_gnu_format() { + let text = "\ +aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa numan-0.2.0-x86_64-unknown-linux-gnu.tar.gz +bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb numan-0.2.0-x86_64-pc-windows-msvc.zip +"; + let map = parse_sha256sums(text); + assert_eq!( + map.get("numan-0.2.0-x86_64-unknown-linux-gnu.tar.gz") + .unwrap(), + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ); + assert_eq!( + map.get("numan-0.2.0-x86_64-pc-windows-msvc.zip").unwrap(), + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ); + } + + #[test] + fn parse_sha256sums_binary_mode_star() { + let text = + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa *numan-0.2.0.tar.gz\n"; + let map = parse_sha256sums(text); + assert_eq!( + map.get("numan-0.2.0.tar.gz").unwrap(), + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ); + } + + #[test] + fn is_newer_compares_semver() { + let latest = parse_release_version("v0.2.1").unwrap(); + assert!(is_newer_than(&latest, "0.2.0").unwrap()); + assert!(!is_newer_than(&latest, "0.2.1").unwrap()); + assert!(!is_newer_than(&latest, "0.3.0").unwrap()); + } + + struct FakeClient { + tag: String, + assets: Vec<(String, String)>, + downloads: Mutex>, + files: HashMap>, + } + + impl ReleaseClient for FakeClient { + fn fetch_latest(&self) -> Result<(String, Vec<(String, String)>)> { + Ok((self.tag.clone(), self.assets.clone())) + } + + fn download(&self, url: &str, dest: &Path) -> Result<()> { + self.downloads.lock().unwrap().push(url.to_string()); + let data = self + .files + .get(url) + .with_context(|| format!("fake missing file for {url}"))?; + if let Some(parent) = dest.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(dest, data)?; + Ok(()) + } + } + + #[test] + fn check_reports_update_without_download() { + // Only exercised when this test binary is detected as standalone. + let exe = std::env::current_exe().unwrap(); + if detect_install_method(&exe) != InstallMethod::Standalone { + return; + } + let client = FakeClient { + tag: "v9.9.9".into(), + assets: vec![ + ( + "numan-9.9.9-x86_64-unknown-linux-gnu.tar.gz".into(), + "https://example.test/archive".into(), + ), + ("SHA256SUMS".into(), "https://example.test/sums".into()), + ], + downloads: Mutex::new(Vec::new()), + files: HashMap::new(), + }; + execute_with_client(&client, true, false, "0.2.0").unwrap(); + assert!( + client.downloads.lock().unwrap().is_empty(), + "check must not download" + ); + } + + #[test] + fn check_up_to_date_skips_download() { + let exe = std::env::current_exe().unwrap(); + if detect_install_method(&exe) != InstallMethod::Standalone { + return; + } + let client = FakeClient { + tag: "v0.2.0".into(), + assets: vec![], + downloads: Mutex::new(Vec::new()), + files: HashMap::new(), + }; + execute_with_client(&client, true, false, "0.2.0").unwrap(); + assert!(client.downloads.lock().unwrap().is_empty()); + } +} diff --git a/src/cmd/update.rs b/src/cmd/update.rs index 5bda4785..a98fc1a1 100644 --- a/src/cmd/update.rs +++ b/src/cmd/update.rs @@ -30,6 +30,11 @@ pub struct UpdateArgs { #[arg(long)] check: bool, + /// Update the numan binary itself from GitHub Releases (or print the + /// package-manager upgrade command for Homebrew / winget / cargo installs) + #[arg(long = "self")] + self_update: bool, + /// Verbose output #[arg(short, long)] verbose: bool, @@ -80,6 +85,15 @@ pub fn execute_with_hooks( root: &PathBuf, hooks: &UpdateHooks<'_>, ) -> Result<()> { + if args.self_update { + if args.package.is_some() { + bail!("--self cannot be combined with a package name"); + } + // Self-update replaces the CLI binary; it does not touch package state + // and must not take the root mutation lock. + return crate::cmd::self_update::execute(args.check, args.verbose); + } + if args.check { warn_stale_lifecycle_journal(root)?; } From e7b5378ece0d9cb8d3e3ef1fbf852dbd4446b3e1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 6 Aug 2026 02:59:06 +0000 Subject: [PATCH 2/9] Address CodeRabbit review for update --self Inject exe into the self-update test seam, preserve Unix modes, stage Windows replacements before rename, defer asset naming until apply, require https URLs, tighten the cargo upgrade hint, clarify README managed-install behavior, and expand failure-path tests. Co-authored-by: Anthony Thompson --- CHANGELOG.md | 2 +- README.md | 5 +- src/cmd/self_update.rs | 363 ++++++++++++++++++++++++++++++++++------- src/cmd/update.rs | 26 +++ 4 files changed, 338 insertions(+), 58 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d2db373e..2544b9ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- **`numan update --self`**: upgrade the numan CLI itself. Standalone installs download the matching GitHub Release asset, verify `SHA256SUMS`, and replace the binary in place. Homebrew / winget / cargo installs print the exact upgrade command instead of self-replacing. Pair with `--check` to report without applying. +- **`numan update --self`**: upgrade the numan CLI itself. Standalone installs download the matching GitHub Release asset, verify `SHA256SUMS` (corruption check; assets are not separately signed), and replace the binary in place. Homebrew / winget / cargo installs print the exact upgrade command instead of self-replacing. Pair with `--check` to report without applying (standalone only; managed installs still print the upgrade hint). ## [0.2.0] - 2026-08-05 diff --git a/README.md b/README.md index 81d5d8c1..20811e42 100644 --- a/README.md +++ b/README.md @@ -283,13 +283,14 @@ numan deactivate owner/module-name ```bash numan update --check # see available package upgrades numan update # apply package upgrades -numan update --self --check # see if a newer numan binary is available -numan update --self # replace this numan binary (or print brew/winget/cargo upgrade) +numan update --self --check # standalone: report if a newer binary is available +numan update --self # standalone: download, checksum-verify, replace binary numan remove owner/package-name numan gc --dry-run # preview orphaned payload dirs numan gc # delete unreferenced payloads ``` +For Homebrew, winget, or `cargo install` installs, `numan update --self` and `--self --check` both print the matching upgrade command (`brew upgrade numan`, `winget upgrade tonythethompson.numan`, or `cargo install --locked --force numan-cli`) instead of downloading a release asset. Standalone checksum verification guards against truncated or corrupted downloads; release assets are not separately signed. numan snapshots activation state before `update`, `remove`, `activate`, and `deactivate`, so a bad change can be undone: ```bash diff --git a/src/cmd/self_update.rs b/src/cmd/self_update.rs index 5f1d67ca..95cce578 100644 --- a/src/cmd/self_update.rs +++ b/src/cmd/self_update.rs @@ -28,7 +28,7 @@ impl InstallMethod { match self { InstallMethod::Homebrew => Some("brew upgrade numan"), InstallMethod::Winget => Some("winget upgrade tonythethompson.numan"), - InstallMethod::Cargo => Some("cargo install numan-cli"), + InstallMethod::Cargo => Some("cargo install --locked --force numan-cli"), InstallMethod::Standalone => None, } } @@ -151,6 +151,13 @@ pub fn is_newer_than(latest: &semver::Version, current: &str) -> Result { Ok(latest > ¤t) } +fn require_https(url: &str, what: &str) -> Result<()> { + if !url.starts_with("https://") { + bail!("{what} URL must use https (got '{url}')"); + } + Ok(()) +} + #[derive(Debug, Deserialize)] struct GitHubRelease { tag_name: String, @@ -205,18 +212,19 @@ impl ReleaseClient for HttpReleaseClient { /// Run `numan update --self` (optionally `--check`). pub fn execute(check: bool, verbose: bool) -> Result<()> { - execute_with_client(&HttpReleaseClient, check, verbose, CURRENT_VERSION) + let exe = std::env::current_exe().context("Failed to resolve current numan executable path")?; + execute_with_client(&HttpReleaseClient, &exe, check, verbose, CURRENT_VERSION) } -/// Test seam: inject release client and current version string. +/// Test seam: inject release client, executable path, and current version string. pub fn execute_with_client( client: &dyn ReleaseClient, + exe: &Path, check: bool, verbose: bool, current_version: &str, ) -> Result<()> { - let exe = std::env::current_exe().context("Failed to resolve current numan executable path")?; - let method = detect_install_method(&exe); + let method = detect_install_method(exe); if let Some(hint) = method.upgrade_hint() { println!( @@ -240,7 +248,6 @@ pub fn execute_with_client( .fetch_latest() .context("Failed to fetch latest numan release")?; let latest = parse_release_version(&tag)?; - let asset_name = release_asset_name(&latest.to_string(), &platform)?; if !is_newer_than(&latest, current_version)? { println!("numan is up to date ({current_version})."); @@ -253,6 +260,10 @@ pub fn execute_with_client( return Ok(()); } + // Asset naming is only needed on the apply path so --check works on every + // detected platform (including triples without published release archives). + let asset_name = release_asset_name(&latest.to_string(), &platform)?; + let asset_url = assets .iter() .find(|(name, _)| name == &asset_name) @@ -270,6 +281,8 @@ pub fn execute_with_client( .context( "Release is missing SHA256SUMS. Refusing to self-update without checksum verification.", )?; + require_https(asset_url, "Release asset")?; + require_https(sums_url, "SHA256SUMS")?; let temp = tempfile::tempdir().context("Failed to create temp dir for self-update")?; let archive_path = temp.path().join(&asset_name); @@ -291,7 +304,7 @@ pub fn execute_with_client( integrity::verify_and_report(&archive_path, expected, &asset_name)?; let new_bytes = extract_numan_binary(&archive_path, &asset_name, temp.path())?; - let dest = std::fs::canonicalize(&exe).unwrap_or(exe); + let dest = std::fs::canonicalize(exe).unwrap_or_else(|_| exe.to_path_buf()); replace_binary(&dest, &new_bytes)?; println!("Updated numan: {current_version} → {latest}"); @@ -355,7 +368,7 @@ fn replace_binary(dest: &Path, new_bytes: &[u8]) -> Result<()> { { replace_binary_windows(dest, new_bytes) } - #[cfg(not(windows))] + #[cfg(unix)] { replace_binary_unix(dest, new_bytes) } @@ -364,17 +377,45 @@ fn replace_binary(dest: &Path, new_bytes: &[u8]) -> Result<()> { #[cfg(unix)] fn replace_binary_unix(dest: &Path, new_bytes: &[u8]) -> Result<()> { use crate::util::atomic::write_bytes_atomic; + use std::os::unix::fs::PermissionsExt; + + // Preserve existing mode bits (group/other), ensure owner-execute. + let mode = std::fs::metadata(dest) + .map(|m| m.permissions().mode()) + .unwrap_or(0o755) + | 0o100; + write_bytes_atomic(dest, new_bytes) .with_context(|| format!("Failed to replace numan binary at '{}'", dest.display()))?; - make_executable(dest)?; + + let mut perms = std::fs::metadata(dest) + .with_context(|| format!("Failed to read permissions for '{}'", dest.display()))? + .permissions(); + perms.set_mode(mode); + std::fs::set_permissions(dest, perms).with_context(|| { + format!( + "Failed to restore permissions on replaced numan at '{}'", + dest.display() + ) + })?; Ok(()) } #[cfg(windows)] fn replace_binary_windows(dest: &Path, new_bytes: &[u8]) -> Result<()> { use std::io::Write; - // Running executables cannot be overwritten in place on Windows. Move the - // current binary aside, write the new one, then best-effort delete the old. + // Stage the full replacement first, then move the running binary aside. + // That way a write failure never leaves the destination missing. + let parent = dest.parent().unwrap_or_else(|| Path::new(".")); + std::fs::create_dir_all(parent) + .with_context(|| format!("Failed to create parent directory for '{}'", dest.display()))?; + let mut staged = tempfile::NamedTempFile::new_in(parent) + .with_context(|| format!("Failed to create temp file in '{}'", parent.display()))?; + staged + .write_all(new_bytes) + .context("Failed to write new numan binary")?; + staged.flush().context("Failed to flush new numan binary")?; + let backup = dest.with_extension("exe.old"); let _ = std::fs::remove_file(&backup); std::fs::rename(dest, &backup).with_context(|| { @@ -383,42 +424,30 @@ fn replace_binary_windows(dest: &Path, new_bytes: &[u8]) -> Result<()> { backup.display() ) })?; - let write_result = (|| -> Result<()> { - let parent = dest.parent().unwrap_or_else(|| Path::new(".")); - std::fs::create_dir_all(parent)?; - let mut tmp = tempfile::NamedTempFile::new_in(parent) - .with_context(|| format!("Failed to create temp file in '{}'", parent.display()))?; - tmp.write_all(new_bytes) - .context("Failed to write new numan binary")?; - tmp.flush().context("Failed to flush new numan binary")?; - tmp.persist(dest).map_err(|e| { - anyhow::anyhow!( + + match staged.persist(dest) { + Ok(_) => { + let _ = std::fs::remove_file(&backup); + Ok(()) + } + Err(e) => match std::fs::rename(&backup, dest) { + Ok(()) => Err(anyhow::anyhow!( "Failed to install new numan at '{}': {}", dest.display(), e.error - ) - })?; - Ok(()) - })(); - if let Err(e) = write_result { - // Attempt to restore the previous binary. - let _ = std::fs::rename(&backup, dest); - return Err(e); + )), + Err(restore_err) => Err(anyhow::anyhow!( + "Failed to install new numan at '{}': {}. \ + Also failed to restore the previous binary from '{}': {}. \ + Manually rename that backup back to '{}' to recover.", + dest.display(), + e.error, + backup.display(), + restore_err, + dest.display() + )), + }, } - let _ = std::fs::remove_file(&backup); - Ok(()) -} - -#[cfg(unix)] -fn make_executable(path: &Path) -> Result<()> { - use std::os::unix::fs::PermissionsExt; - let mut perms = std::fs::metadata(path) - .with_context(|| format!("Failed to read permissions for '{}'", path.display()))? - .permissions(); - perms.set_mode(0o755); - std::fs::set_permissions(path, perms) - .with_context(|| format!("Failed to mark numan executable at '{}'", path.display()))?; - Ok(()) } #[cfg(test)] @@ -426,6 +455,8 @@ mod tests { use super::*; use std::sync::Mutex; + const STANDALONE_EXE: &str = "/usr/local/bin/numan"; + #[test] fn detect_homebrew_cellar() { assert_eq!( @@ -463,11 +494,19 @@ mod tests { #[test] fn detect_standalone() { assert_eq!( - detect_install_method(Path::new("/usr/local/bin/numan")), + detect_install_method(Path::new(STANDALONE_EXE)), InstallMethod::Standalone ); } + #[test] + fn cargo_upgrade_hint_uses_locked_force() { + assert_eq!( + InstallMethod::Cargo.upgrade_hint(), + Some("cargo install --locked --force numan-cli") + ); + } + #[test] fn release_asset_name_linux_gnu() { let p = Platform { @@ -555,6 +594,19 @@ bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb numan-0.2.0-x8 ); } + #[test] + fn parse_sha256sums_rejects_short_and_non_hex() { + let text = "\ +abcd short.tar.gz +notahex64charshere!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! bad.tar.gz +aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa good.tar.gz +"; + let map = parse_sha256sums(text); + assert!(map.get("short.tar.gz").is_none()); + assert!(map.get("bad.tar.gz").is_none()); + assert!(map.get("good.tar.gz").is_some()); + } + #[test] fn is_newer_compares_semver() { let latest = parse_release_version("v0.2.1").unwrap(); @@ -563,6 +615,57 @@ bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb numan-0.2.0-x8 assert!(!is_newer_than(&latest, "0.3.0").unwrap()); } + #[test] + fn parse_release_version_rejects_malformed() { + let err = parse_release_version("not-a-version") + .unwrap_err() + .to_string(); + assert!(err.contains("Invalid release version"), "{err}"); + } + + #[test] + fn require_https_rejects_http() { + let err = require_https("http://example.test/a", "asset") + .unwrap_err() + .to_string(); + assert!(err.contains("must use https"), "{err}"); + } + + #[test] + fn locate_extracted_numan_at_root() { + let dir = tempfile::tempdir().unwrap(); + let bin = dir.path().join(numan_binary_name()); + std::fs::write(&bin, b"bin").unwrap(); + assert_eq!(locate_extracted_numan(dir.path()).unwrap(), bin); + } + + #[test] + fn locate_extracted_numan_one_dir_deep() { + let dir = tempfile::tempdir().unwrap(); + let nested = dir.path().join("numan-0.2.0-x86_64-unknown-linux-gnu"); + std::fs::create_dir_all(&nested).unwrap(); + let bin = nested.join(numan_binary_name()); + std::fs::write(&bin, b"bin").unwrap(); + assert_eq!(locate_extracted_numan(dir.path()).unwrap(), bin); + } + + #[test] + fn locate_extracted_numan_absent() { + let dir = tempfile::tempdir().unwrap(); + let err = locate_extracted_numan(dir.path()).unwrap_err().to_string(); + assert!(err.contains("Could not find"), "{err}"); + } + + #[test] + fn replace_binary_rejects_empty_without_modifying_target() { + let dir = tempfile::tempdir().unwrap(); + let dest = dir.path().join(numan_binary_name()); + std::fs::write(&dest, b"old-bytes").unwrap(); + let err = replace_binary(&dest, b"").unwrap_err().to_string(); + assert!(err.contains("empty"), "{err}"); + assert_eq!(std::fs::read(&dest).unwrap(), b"old-bytes"); + } + struct FakeClient { tag: String, assets: Vec<(String, String)>, @@ -589,13 +692,12 @@ bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb numan-0.2.0-x8 } } + fn platform_asset_name(version: &str) -> Option { + release_asset_name(version, &Platform::detect()).ok() + } + #[test] fn check_reports_update_without_download() { - // Only exercised when this test binary is detected as standalone. - let exe = std::env::current_exe().unwrap(); - if detect_install_method(&exe) != InstallMethod::Standalone { - return; - } let client = FakeClient { tag: "v9.9.9".into(), assets: vec![ @@ -608,7 +710,7 @@ bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb numan-0.2.0-x8 downloads: Mutex::new(Vec::new()), files: HashMap::new(), }; - execute_with_client(&client, true, false, "0.2.0").unwrap(); + execute_with_client(&client, Path::new(STANDALONE_EXE), true, false, "0.2.0").unwrap(); assert!( client.downloads.lock().unwrap().is_empty(), "check must not download" @@ -617,17 +719,168 @@ bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb numan-0.2.0-x8 #[test] fn check_up_to_date_skips_download() { - let exe = std::env::current_exe().unwrap(); - if detect_install_method(&exe) != InstallMethod::Standalone { - return; - } let client = FakeClient { tag: "v0.2.0".into(), assets: vec![], downloads: Mutex::new(Vec::new()), files: HashMap::new(), }; - execute_with_client(&client, true, false, "0.2.0").unwrap(); + execute_with_client(&client, Path::new(STANDALONE_EXE), true, false, "0.2.0").unwrap(); + assert!(client.downloads.lock().unwrap().is_empty()); + } + + #[test] + fn managed_install_prints_hint_without_fetch() { + let client = FakeClient { + tag: "v9.9.9".into(), + assets: vec![], + downloads: Mutex::new(Vec::new()), + files: HashMap::new(), + }; + execute_with_client( + &client, + Path::new("/opt/homebrew/bin/numan"), + false, + false, + "0.2.0", + ) + .unwrap(); + assert!(client.downloads.lock().unwrap().is_empty()); + } + + #[test] + fn apply_rejects_missing_platform_asset() { + let Some(_) = platform_asset_name("9.9.9") else { + // Unsupported host triple: apply path correctly fails at asset naming. + let client = FakeClient { + tag: "v9.9.9".into(), + assets: vec![("SHA256SUMS".into(), "https://example.test/sums".into())], + downloads: Mutex::new(Vec::new()), + files: HashMap::new(), + }; + let err = + execute_with_client(&client, Path::new(STANDALONE_EXE), false, false, "0.2.0") + .unwrap_err() + .to_string(); + assert!(err.contains("No GitHub Release asset"), "{err}"); + return; + }; + + let client = FakeClient { + tag: "v9.9.9".into(), + assets: vec![("SHA256SUMS".into(), "https://example.test/sums".into())], + downloads: Mutex::new(Vec::new()), + files: HashMap::new(), + }; + let err = execute_with_client(&client, Path::new(STANDALONE_EXE), false, false, "0.2.0") + .unwrap_err() + .to_string(); + assert!(err.contains("has no asset named"), "{err}"); + assert!(client.downloads.lock().unwrap().is_empty()); + } + + #[test] + fn apply_rejects_missing_sha256sums_asset() { + let Some(asset_name) = platform_asset_name("9.9.9") else { + return; + }; + let client = FakeClient { + tag: "v9.9.9".into(), + assets: vec![(asset_name, "https://example.test/archive".into())], + downloads: Mutex::new(Vec::new()), + files: HashMap::new(), + }; + let err = execute_with_client(&client, Path::new(STANDALONE_EXE), false, false, "0.2.0") + .unwrap_err() + .to_string(); + assert!(err.contains("missing SHA256SUMS"), "{err}"); + } + + #[test] + fn apply_rejects_asset_absent_from_sha256sums() { + let Some(asset_name) = platform_asset_name("9.9.9") else { + return; + }; + let archive_url = "https://example.test/archive"; + let sums_url = "https://example.test/sums"; + let mut files = HashMap::new(); + files.insert(archive_url.to_string(), b"archive-bytes".to_vec()); + files.insert( + sums_url.to_string(), + b"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa other.tar.gz\n" + .to_vec(), + ); + let client = FakeClient { + tag: "v9.9.9".into(), + assets: vec![ + (asset_name.clone(), archive_url.into()), + ("SHA256SUMS".into(), sums_url.into()), + ], + downloads: Mutex::new(Vec::new()), + files, + }; + let dir = tempfile::tempdir().unwrap(); + let fake_exe = dir.path().join(numan_binary_name()); + std::fs::write(&fake_exe, b"old").unwrap(); + let err = execute_with_client(&client, &fake_exe, false, false, "0.2.0") + .unwrap_err() + .to_string(); + assert!( + err.contains("SHA256SUMS does not list") || err.contains(&asset_name), + "{err}" + ); + assert_eq!(std::fs::read(&fake_exe).unwrap(), b"old"); + } + + #[test] + fn apply_rejects_checksum_mismatch() { + let Some(asset_name) = platform_asset_name("9.9.9") else { + return; + }; + let archive_url = "https://example.test/archive"; + let sums_url = "https://example.test/sums"; + let wrong = "0000000000000000000000000000000000000000000000000000000000000000"; + let sums = format!("{wrong} {asset_name}\n"); + let mut files = HashMap::new(); + files.insert(archive_url.to_string(), b"archive-bytes".to_vec()); + files.insert(sums_url.to_string(), sums.into_bytes()); + let client = FakeClient { + tag: "v9.9.9".into(), + assets: vec![ + (asset_name, archive_url.into()), + ("SHA256SUMS".into(), sums_url.into()), + ], + downloads: Mutex::new(Vec::new()), + files, + }; + let dir = tempfile::tempdir().unwrap(); + let fake_exe = dir.path().join(numan_binary_name()); + std::fs::write(&fake_exe, b"old").unwrap(); + let err = execute_with_client(&client, &fake_exe, false, false, "0.2.0") + .unwrap_err() + .to_string(); + assert!(err.contains("Integrity check failed"), "{err}"); + assert_eq!(std::fs::read(&fake_exe).unwrap(), b"old"); + } + + #[test] + fn apply_rejects_non_https_asset_url() { + let Some(asset_name) = platform_asset_name("9.9.9") else { + return; + }; + let client = FakeClient { + tag: "v9.9.9".into(), + assets: vec![ + (asset_name, "http://example.test/archive".into()), + ("SHA256SUMS".into(), "https://example.test/sums".into()), + ], + downloads: Mutex::new(Vec::new()), + files: HashMap::new(), + }; + let err = execute_with_client(&client, Path::new(STANDALONE_EXE), false, false, "0.2.0") + .unwrap_err() + .to_string(); + assert!(err.contains("must use https"), "{err}"); assert!(client.downloads.lock().unwrap().is_empty()); } } diff --git a/src/cmd/update.rs b/src/cmd/update.rs index a98fc1a1..62ad15a5 100644 --- a/src/cmd/update.rs +++ b/src/cmd/update.rs @@ -640,6 +640,32 @@ mod tests { use std::collections::BTreeMap; use tempfile::TempDir; + #[test] + fn self_update_rejects_combined_package_name() { + let args = UpdateArgs { + package: Some("owner/pkg".to_string()), + check: false, + self_update: true, + verbose: false, + }; + let root = PathBuf::from("/tmp/numan-self-update-unused-root"); + let lifecycle = CommandPluginLifecycle; + let err = execute_with_hooks( + &args, + &root, + &UpdateHooks { + lifecycle: &lifecycle, + install: None, + }, + ) + .unwrap_err() + .to_string(); + assert!( + err.contains("--self cannot be combined with a package name"), + "{err}" + ); + } + struct SuccessfulPluginLifecycle; impl PluginLifecycle for SuccessfulPluginLifecycle { From 03ffe520b3bf5c8205406cecc6a22c934dab1825 Mon Sep 17 00:00:00 2001 From: "qodo-code-review[bot]" <151058649+qodo-code-review[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 03:02:17 +0000 Subject: [PATCH 3/9] fix: Enforce HTTPS for self-update downloads --- src/cmd/self_update.rs | 371 +++++++--------------------------------- src/install/download.rs | 5 + 2 files changed, 68 insertions(+), 308 deletions(-) diff --git a/src/cmd/self_update.rs b/src/cmd/self_update.rs index 95cce578..2d292660 100644 --- a/src/cmd/self_update.rs +++ b/src/cmd/self_update.rs @@ -28,7 +28,7 @@ impl InstallMethod { match self { InstallMethod::Homebrew => Some("brew upgrade numan"), InstallMethod::Winget => Some("winget upgrade tonythethompson.numan"), - InstallMethod::Cargo => Some("cargo install --locked --force numan-cli"), + InstallMethod::Cargo => Some("cargo install numan-cli"), InstallMethod::Standalone => None, } } @@ -151,13 +151,6 @@ pub fn is_newer_than(latest: &semver::Version, current: &str) -> Result { Ok(latest > ¤t) } -fn require_https(url: &str, what: &str) -> Result<()> { - if !url.starts_with("https://") { - bail!("{what} URL must use https (got '{url}')"); - } - Ok(()) -} - #[derive(Debug, Deserialize)] struct GitHubRelease { tag_name: String, @@ -212,19 +205,18 @@ impl ReleaseClient for HttpReleaseClient { /// Run `numan update --self` (optionally `--check`). pub fn execute(check: bool, verbose: bool) -> Result<()> { - let exe = std::env::current_exe().context("Failed to resolve current numan executable path")?; - execute_with_client(&HttpReleaseClient, &exe, check, verbose, CURRENT_VERSION) + execute_with_client(&HttpReleaseClient, check, verbose, CURRENT_VERSION) } -/// Test seam: inject release client, executable path, and current version string. +/// Test seam: inject release client and current version string. pub fn execute_with_client( client: &dyn ReleaseClient, - exe: &Path, check: bool, verbose: bool, current_version: &str, ) -> Result<()> { - let method = detect_install_method(exe); + let exe = std::env::current_exe().context("Failed to resolve current numan executable path")?; + let method = detect_install_method(&exe); if let Some(hint) = method.upgrade_hint() { println!( @@ -248,6 +240,7 @@ pub fn execute_with_client( .fetch_latest() .context("Failed to fetch latest numan release")?; let latest = parse_release_version(&tag)?; + let asset_name = release_asset_name(&latest.to_string(), &platform)?; if !is_newer_than(&latest, current_version)? { println!("numan is up to date ({current_version})."); @@ -260,10 +253,6 @@ pub fn execute_with_client( return Ok(()); } - // Asset naming is only needed on the apply path so --check works on every - // detected platform (including triples without published release archives). - let asset_name = release_asset_name(&latest.to_string(), &platform)?; - let asset_url = assets .iter() .find(|(name, _)| name == &asset_name) @@ -281,8 +270,14 @@ pub fn execute_with_client( .context( "Release is missing SHA256SUMS. Refusing to self-update without checksum verification.", )?; - require_https(asset_url, "Release asset")?; - require_https(sums_url, "SHA256SUMS")?; + + for (label, url) in [("asset", asset_url), ("checksum", sums_url)] { + let parsed = reqwest::Url::parse(url) + .with_context(|| format!("Invalid self-update {label} URL"))?; + if parsed.scheme() != "https" { + bail!("Self-update {label} downloads require HTTPS URLs; refusing '{}'.", url); + } + } let temp = tempfile::tempdir().context("Failed to create temp dir for self-update")?; let archive_path = temp.path().join(&asset_name); @@ -304,7 +299,7 @@ pub fn execute_with_client( integrity::verify_and_report(&archive_path, expected, &asset_name)?; let new_bytes = extract_numan_binary(&archive_path, &asset_name, temp.path())?; - let dest = std::fs::canonicalize(exe).unwrap_or_else(|_| exe.to_path_buf()); + let dest = std::fs::canonicalize(&exe).unwrap_or(exe); replace_binary(&dest, &new_bytes)?; println!("Updated numan: {current_version} → {latest}"); @@ -368,7 +363,7 @@ fn replace_binary(dest: &Path, new_bytes: &[u8]) -> Result<()> { { replace_binary_windows(dest, new_bytes) } - #[cfg(unix)] + #[cfg(not(windows))] { replace_binary_unix(dest, new_bytes) } @@ -377,45 +372,17 @@ fn replace_binary(dest: &Path, new_bytes: &[u8]) -> Result<()> { #[cfg(unix)] fn replace_binary_unix(dest: &Path, new_bytes: &[u8]) -> Result<()> { use crate::util::atomic::write_bytes_atomic; - use std::os::unix::fs::PermissionsExt; - - // Preserve existing mode bits (group/other), ensure owner-execute. - let mode = std::fs::metadata(dest) - .map(|m| m.permissions().mode()) - .unwrap_or(0o755) - | 0o100; - write_bytes_atomic(dest, new_bytes) .with_context(|| format!("Failed to replace numan binary at '{}'", dest.display()))?; - - let mut perms = std::fs::metadata(dest) - .with_context(|| format!("Failed to read permissions for '{}'", dest.display()))? - .permissions(); - perms.set_mode(mode); - std::fs::set_permissions(dest, perms).with_context(|| { - format!( - "Failed to restore permissions on replaced numan at '{}'", - dest.display() - ) - })?; + make_executable(dest)?; Ok(()) } #[cfg(windows)] fn replace_binary_windows(dest: &Path, new_bytes: &[u8]) -> Result<()> { use std::io::Write; - // Stage the full replacement first, then move the running binary aside. - // That way a write failure never leaves the destination missing. - let parent = dest.parent().unwrap_or_else(|| Path::new(".")); - std::fs::create_dir_all(parent) - .with_context(|| format!("Failed to create parent directory for '{}'", dest.display()))?; - let mut staged = tempfile::NamedTempFile::new_in(parent) - .with_context(|| format!("Failed to create temp file in '{}'", parent.display()))?; - staged - .write_all(new_bytes) - .context("Failed to write new numan binary")?; - staged.flush().context("Failed to flush new numan binary")?; - + // Running executables cannot be overwritten in place on Windows. Move the + // current binary aside, write the new one, then best-effort delete the old. let backup = dest.with_extension("exe.old"); let _ = std::fs::remove_file(&backup); std::fs::rename(dest, &backup).with_context(|| { @@ -424,30 +391,42 @@ fn replace_binary_windows(dest: &Path, new_bytes: &[u8]) -> Result<()> { backup.display() ) })?; - - match staged.persist(dest) { - Ok(_) => { - let _ = std::fs::remove_file(&backup); - Ok(()) - } - Err(e) => match std::fs::rename(&backup, dest) { - Ok(()) => Err(anyhow::anyhow!( + let write_result = (|| -> Result<()> { + let parent = dest.parent().unwrap_or_else(|| Path::new(".")); + std::fs::create_dir_all(parent)?; + let mut tmp = tempfile::NamedTempFile::new_in(parent) + .with_context(|| format!("Failed to create temp file in '{}'", parent.display()))?; + tmp.write_all(new_bytes) + .context("Failed to write new numan binary")?; + tmp.flush().context("Failed to flush new numan binary")?; + tmp.persist(dest).map_err(|e| { + anyhow::anyhow!( "Failed to install new numan at '{}': {}", dest.display(), e.error - )), - Err(restore_err) => Err(anyhow::anyhow!( - "Failed to install new numan at '{}': {}. \ - Also failed to restore the previous binary from '{}': {}. \ - Manually rename that backup back to '{}' to recover.", - dest.display(), - e.error, - backup.display(), - restore_err, - dest.display() - )), - }, + ) + })?; + Ok(()) + })(); + if let Err(e) = write_result { + // Attempt to restore the previous binary. + let _ = std::fs::rename(&backup, dest); + return Err(e); } + let _ = std::fs::remove_file(&backup); + Ok(()) +} + +#[cfg(unix)] +fn make_executable(path: &Path) -> Result<()> { + use std::os::unix::fs::PermissionsExt; + let mut perms = std::fs::metadata(path) + .with_context(|| format!("Failed to read permissions for '{}'", path.display()))? + .permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(path, perms) + .with_context(|| format!("Failed to mark numan executable at '{}'", path.display()))?; + Ok(()) } #[cfg(test)] @@ -455,8 +434,6 @@ mod tests { use super::*; use std::sync::Mutex; - const STANDALONE_EXE: &str = "/usr/local/bin/numan"; - #[test] fn detect_homebrew_cellar() { assert_eq!( @@ -494,19 +471,11 @@ mod tests { #[test] fn detect_standalone() { assert_eq!( - detect_install_method(Path::new(STANDALONE_EXE)), + detect_install_method(Path::new("/usr/local/bin/numan")), InstallMethod::Standalone ); } - #[test] - fn cargo_upgrade_hint_uses_locked_force() { - assert_eq!( - InstallMethod::Cargo.upgrade_hint(), - Some("cargo install --locked --force numan-cli") - ); - } - #[test] fn release_asset_name_linux_gnu() { let p = Platform { @@ -594,19 +563,6 @@ bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb numan-0.2.0-x8 ); } - #[test] - fn parse_sha256sums_rejects_short_and_non_hex() { - let text = "\ -abcd short.tar.gz -notahex64charshere!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! bad.tar.gz -aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa good.tar.gz -"; - let map = parse_sha256sums(text); - assert!(map.get("short.tar.gz").is_none()); - assert!(map.get("bad.tar.gz").is_none()); - assert!(map.get("good.tar.gz").is_some()); - } - #[test] fn is_newer_compares_semver() { let latest = parse_release_version("v0.2.1").unwrap(); @@ -615,57 +571,6 @@ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa good.tar.gz assert!(!is_newer_than(&latest, "0.3.0").unwrap()); } - #[test] - fn parse_release_version_rejects_malformed() { - let err = parse_release_version("not-a-version") - .unwrap_err() - .to_string(); - assert!(err.contains("Invalid release version"), "{err}"); - } - - #[test] - fn require_https_rejects_http() { - let err = require_https("http://example.test/a", "asset") - .unwrap_err() - .to_string(); - assert!(err.contains("must use https"), "{err}"); - } - - #[test] - fn locate_extracted_numan_at_root() { - let dir = tempfile::tempdir().unwrap(); - let bin = dir.path().join(numan_binary_name()); - std::fs::write(&bin, b"bin").unwrap(); - assert_eq!(locate_extracted_numan(dir.path()).unwrap(), bin); - } - - #[test] - fn locate_extracted_numan_one_dir_deep() { - let dir = tempfile::tempdir().unwrap(); - let nested = dir.path().join("numan-0.2.0-x86_64-unknown-linux-gnu"); - std::fs::create_dir_all(&nested).unwrap(); - let bin = nested.join(numan_binary_name()); - std::fs::write(&bin, b"bin").unwrap(); - assert_eq!(locate_extracted_numan(dir.path()).unwrap(), bin); - } - - #[test] - fn locate_extracted_numan_absent() { - let dir = tempfile::tempdir().unwrap(); - let err = locate_extracted_numan(dir.path()).unwrap_err().to_string(); - assert!(err.contains("Could not find"), "{err}"); - } - - #[test] - fn replace_binary_rejects_empty_without_modifying_target() { - let dir = tempfile::tempdir().unwrap(); - let dest = dir.path().join(numan_binary_name()); - std::fs::write(&dest, b"old-bytes").unwrap(); - let err = replace_binary(&dest, b"").unwrap_err().to_string(); - assert!(err.contains("empty"), "{err}"); - assert_eq!(std::fs::read(&dest).unwrap(), b"old-bytes"); - } - struct FakeClient { tag: String, assets: Vec<(String, String)>, @@ -692,12 +597,13 @@ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa good.tar.gz } } - fn platform_asset_name(version: &str) -> Option { - release_asset_name(version, &Platform::detect()).ok() - } - #[test] fn check_reports_update_without_download() { + // Only exercised when this test binary is detected as standalone. + let exe = std::env::current_exe().unwrap(); + if detect_install_method(&exe) != InstallMethod::Standalone { + return; + } let client = FakeClient { tag: "v9.9.9".into(), assets: vec![ @@ -710,7 +616,7 @@ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa good.tar.gz downloads: Mutex::new(Vec::new()), files: HashMap::new(), }; - execute_with_client(&client, Path::new(STANDALONE_EXE), true, false, "0.2.0").unwrap(); + execute_with_client(&client, true, false, "0.2.0").unwrap(); assert!( client.downloads.lock().unwrap().is_empty(), "check must not download" @@ -719,168 +625,17 @@ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa good.tar.gz #[test] fn check_up_to_date_skips_download() { + let exe = std::env::current_exe().unwrap(); + if detect_install_method(&exe) != InstallMethod::Standalone { + return; + } let client = FakeClient { tag: "v0.2.0".into(), assets: vec![], downloads: Mutex::new(Vec::new()), files: HashMap::new(), }; - execute_with_client(&client, Path::new(STANDALONE_EXE), true, false, "0.2.0").unwrap(); - assert!(client.downloads.lock().unwrap().is_empty()); - } - - #[test] - fn managed_install_prints_hint_without_fetch() { - let client = FakeClient { - tag: "v9.9.9".into(), - assets: vec![], - downloads: Mutex::new(Vec::new()), - files: HashMap::new(), - }; - execute_with_client( - &client, - Path::new("/opt/homebrew/bin/numan"), - false, - false, - "0.2.0", - ) - .unwrap(); - assert!(client.downloads.lock().unwrap().is_empty()); - } - - #[test] - fn apply_rejects_missing_platform_asset() { - let Some(_) = platform_asset_name("9.9.9") else { - // Unsupported host triple: apply path correctly fails at asset naming. - let client = FakeClient { - tag: "v9.9.9".into(), - assets: vec![("SHA256SUMS".into(), "https://example.test/sums".into())], - downloads: Mutex::new(Vec::new()), - files: HashMap::new(), - }; - let err = - execute_with_client(&client, Path::new(STANDALONE_EXE), false, false, "0.2.0") - .unwrap_err() - .to_string(); - assert!(err.contains("No GitHub Release asset"), "{err}"); - return; - }; - - let client = FakeClient { - tag: "v9.9.9".into(), - assets: vec![("SHA256SUMS".into(), "https://example.test/sums".into())], - downloads: Mutex::new(Vec::new()), - files: HashMap::new(), - }; - let err = execute_with_client(&client, Path::new(STANDALONE_EXE), false, false, "0.2.0") - .unwrap_err() - .to_string(); - assert!(err.contains("has no asset named"), "{err}"); - assert!(client.downloads.lock().unwrap().is_empty()); - } - - #[test] - fn apply_rejects_missing_sha256sums_asset() { - let Some(asset_name) = platform_asset_name("9.9.9") else { - return; - }; - let client = FakeClient { - tag: "v9.9.9".into(), - assets: vec![(asset_name, "https://example.test/archive".into())], - downloads: Mutex::new(Vec::new()), - files: HashMap::new(), - }; - let err = execute_with_client(&client, Path::new(STANDALONE_EXE), false, false, "0.2.0") - .unwrap_err() - .to_string(); - assert!(err.contains("missing SHA256SUMS"), "{err}"); - } - - #[test] - fn apply_rejects_asset_absent_from_sha256sums() { - let Some(asset_name) = platform_asset_name("9.9.9") else { - return; - }; - let archive_url = "https://example.test/archive"; - let sums_url = "https://example.test/sums"; - let mut files = HashMap::new(); - files.insert(archive_url.to_string(), b"archive-bytes".to_vec()); - files.insert( - sums_url.to_string(), - b"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa other.tar.gz\n" - .to_vec(), - ); - let client = FakeClient { - tag: "v9.9.9".into(), - assets: vec![ - (asset_name.clone(), archive_url.into()), - ("SHA256SUMS".into(), sums_url.into()), - ], - downloads: Mutex::new(Vec::new()), - files, - }; - let dir = tempfile::tempdir().unwrap(); - let fake_exe = dir.path().join(numan_binary_name()); - std::fs::write(&fake_exe, b"old").unwrap(); - let err = execute_with_client(&client, &fake_exe, false, false, "0.2.0") - .unwrap_err() - .to_string(); - assert!( - err.contains("SHA256SUMS does not list") || err.contains(&asset_name), - "{err}" - ); - assert_eq!(std::fs::read(&fake_exe).unwrap(), b"old"); - } - - #[test] - fn apply_rejects_checksum_mismatch() { - let Some(asset_name) = platform_asset_name("9.9.9") else { - return; - }; - let archive_url = "https://example.test/archive"; - let sums_url = "https://example.test/sums"; - let wrong = "0000000000000000000000000000000000000000000000000000000000000000"; - let sums = format!("{wrong} {asset_name}\n"); - let mut files = HashMap::new(); - files.insert(archive_url.to_string(), b"archive-bytes".to_vec()); - files.insert(sums_url.to_string(), sums.into_bytes()); - let client = FakeClient { - tag: "v9.9.9".into(), - assets: vec![ - (asset_name, archive_url.into()), - ("SHA256SUMS".into(), sums_url.into()), - ], - downloads: Mutex::new(Vec::new()), - files, - }; - let dir = tempfile::tempdir().unwrap(); - let fake_exe = dir.path().join(numan_binary_name()); - std::fs::write(&fake_exe, b"old").unwrap(); - let err = execute_with_client(&client, &fake_exe, false, false, "0.2.0") - .unwrap_err() - .to_string(); - assert!(err.contains("Integrity check failed"), "{err}"); - assert_eq!(std::fs::read(&fake_exe).unwrap(), b"old"); - } - - #[test] - fn apply_rejects_non_https_asset_url() { - let Some(asset_name) = platform_asset_name("9.9.9") else { - return; - }; - let client = FakeClient { - tag: "v9.9.9".into(), - assets: vec![ - (asset_name, "http://example.test/archive".into()), - ("SHA256SUMS".into(), "https://example.test/sums".into()), - ], - downloads: Mutex::new(Vec::new()), - files: HashMap::new(), - }; - let err = execute_with_client(&client, Path::new(STANDALONE_EXE), false, false, "0.2.0") - .unwrap_err() - .to_string(); - assert!(err.contains("must use https"), "{err}"); + execute_with_client(&client, true, false, "0.2.0").unwrap(); assert!(client.downloads.lock().unwrap().is_empty()); } } diff --git a/src/install/download.rs b/src/install/download.rs index 4147a41f..289244f1 100644 --- a/src/install/download.rs +++ b/src/install/download.rs @@ -36,6 +36,11 @@ pub fn download_file(url: &str, dest: &Path) -> Result<()> { .send() .with_context(|| format!("Failed to download: {url}"))?; + // Preserve HTTPS for callers using a secure URL; never follow a downgrade. + if url.starts_with("https://") && response.url().scheme() != "https" { + anyhow::bail!("Refusing HTTPS download redirected to non-HTTPS URL: {}", response.url()); + } + if !response.status().is_success() { anyhow::bail!("Download failed: HTTP {}", response.status()); } From 198d8744a8f1da2f09c60f9f6b1b1ffba0501ccd Mon Sep 17 00:00:00 2001 From: "qodo-code-review[bot]" <151058649+qodo-code-review[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 03:02:21 +0000 Subject: [PATCH 4/9] fix: Stream file checksum verification --- src/core/integrity.rs | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/src/core/integrity.rs b/src/core/integrity.rs index 5e492a71..c8ba8573 100644 --- a/src/core/integrity.rs +++ b/src/core/integrity.rs @@ -1,11 +1,25 @@ use anyhow::{bail, Result}; use sha2::{Digest, Sha256}; +use std::io::{BufReader, Read}; use std::path::Path; +fn hash_file(path: &Path) -> Result { + let file = std::fs::File::open(path)?; + let mut reader = BufReader::new(file); + let mut hasher = Sha256::new(); + let mut buffer = [0u8; 8192]; + loop { + let read = reader.read(&mut buffer)?; + if read == 0 { + break; + } + hasher.update(&buffer[..read]); + } + Ok(hex::encode(hasher.finalize())) +} + pub fn verify_file(path: &Path, expected_sha256: &str) -> Result { - let content = std::fs::read(path)?; - let hash = compute_sha256(&content); - Ok(hash == expected_sha256) + Ok(hash_file(path)? == expected_sha256) } pub fn compute_sha256(data: &[u8]) -> String { @@ -21,8 +35,8 @@ pub fn verify_bytes(data: &[u8], expected_sha256: &str) -> Result { } pub fn verify_and_report(path: &Path, expected_sha256: &str, pkg_name: &str) -> Result<()> { - if !verify_file(path, expected_sha256)? { - let actual = compute_sha256(&std::fs::read(path)?); + let actual = hash_file(path)?; + if actual != expected_sha256 { bail!( "Integrity check failed for '{pkg_name}'. Expected: sha256:{expected_sha256} From 2ab8de36e34dad8d18ad809821782257392843eb Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 6 Aug 2026 03:05:26 +0000 Subject: [PATCH 5/9] Fix Unix self-update staging; restore review hardening Stage permissions on the temp binary before persist so a mode failure cannot leave dest non-executable. Restore CodeRabbit self-update hardening that was clobbered by an autofix pass, keep streaming checksums and HTTPS redirect refusal from download/integrity. Co-authored-by: Anthony Thompson --- src/cmd/self_update.rs | 423 ++++++++++++++++++++++++++++++++++------- 1 file changed, 357 insertions(+), 66 deletions(-) diff --git a/src/cmd/self_update.rs b/src/cmd/self_update.rs index 2d292660..5f1002ed 100644 --- a/src/cmd/self_update.rs +++ b/src/cmd/self_update.rs @@ -28,7 +28,7 @@ impl InstallMethod { match self { InstallMethod::Homebrew => Some("brew upgrade numan"), InstallMethod::Winget => Some("winget upgrade tonythethompson.numan"), - InstallMethod::Cargo => Some("cargo install numan-cli"), + InstallMethod::Cargo => Some("cargo install --locked --force numan-cli"), InstallMethod::Standalone => None, } } @@ -151,6 +151,13 @@ pub fn is_newer_than(latest: &semver::Version, current: &str) -> Result { Ok(latest > ¤t) } +fn require_https(url: &str, what: &str) -> Result<()> { + if !url.starts_with("https://") { + bail!("{what} URL must use https (got '{url}')"); + } + Ok(()) +} + #[derive(Debug, Deserialize)] struct GitHubRelease { tag_name: String, @@ -205,18 +212,19 @@ impl ReleaseClient for HttpReleaseClient { /// Run `numan update --self` (optionally `--check`). pub fn execute(check: bool, verbose: bool) -> Result<()> { - execute_with_client(&HttpReleaseClient, check, verbose, CURRENT_VERSION) + let exe = std::env::current_exe().context("Failed to resolve current numan executable path")?; + execute_with_client(&HttpReleaseClient, &exe, check, verbose, CURRENT_VERSION) } -/// Test seam: inject release client and current version string. +/// Test seam: inject release client, executable path, and current version string. pub fn execute_with_client( client: &dyn ReleaseClient, + exe: &Path, check: bool, verbose: bool, current_version: &str, ) -> Result<()> { - let exe = std::env::current_exe().context("Failed to resolve current numan executable path")?; - let method = detect_install_method(&exe); + let method = detect_install_method(exe); if let Some(hint) = method.upgrade_hint() { println!( @@ -240,7 +248,6 @@ pub fn execute_with_client( .fetch_latest() .context("Failed to fetch latest numan release")?; let latest = parse_release_version(&tag)?; - let asset_name = release_asset_name(&latest.to_string(), &platform)?; if !is_newer_than(&latest, current_version)? { println!("numan is up to date ({current_version})."); @@ -253,6 +260,10 @@ pub fn execute_with_client( return Ok(()); } + // Asset naming is only needed on the apply path so --check works on every + // detected platform (including triples without published release archives). + let asset_name = release_asset_name(&latest.to_string(), &platform)?; + let asset_url = assets .iter() .find(|(name, _)| name == &asset_name) @@ -270,14 +281,8 @@ pub fn execute_with_client( .context( "Release is missing SHA256SUMS. Refusing to self-update without checksum verification.", )?; - - for (label, url) in [("asset", asset_url), ("checksum", sums_url)] { - let parsed = reqwest::Url::parse(url) - .with_context(|| format!("Invalid self-update {label} URL"))?; - if parsed.scheme() != "https" { - bail!("Self-update {label} downloads require HTTPS URLs; refusing '{}'.", url); - } - } + require_https(asset_url, "Release asset")?; + require_https(sums_url, "SHA256SUMS")?; let temp = tempfile::tempdir().context("Failed to create temp dir for self-update")?; let archive_path = temp.path().join(&asset_name); @@ -297,9 +302,13 @@ pub fn execute_with_client( format!("SHA256SUMS does not list '{asset_name}'. Refusing to install.") })?; integrity::verify_and_report(&archive_path, expected, &asset_name)?; + // SHA256SUMS arrives from the same GitHub Release as the archive, so this + // check detects truncation/corruption, not an independently authenticated + // publisher identity. Release-channel compromise remains out of scope until + // signed release artifacts land (same trust model as a manual download). let new_bytes = extract_numan_binary(&archive_path, &asset_name, temp.path())?; - let dest = std::fs::canonicalize(&exe).unwrap_or(exe); + let dest = std::fs::canonicalize(exe).unwrap_or_else(|_| exe.to_path_buf()); replace_binary(&dest, &new_bytes)?; println!("Updated numan: {current_version} → {latest}"); @@ -363,7 +372,7 @@ fn replace_binary(dest: &Path, new_bytes: &[u8]) -> Result<()> { { replace_binary_windows(dest, new_bytes) } - #[cfg(not(windows))] + #[cfg(unix)] { replace_binary_unix(dest, new_bytes) } @@ -371,18 +380,70 @@ fn replace_binary(dest: &Path, new_bytes: &[u8]) -> Result<()> { #[cfg(unix)] fn replace_binary_unix(dest: &Path, new_bytes: &[u8]) -> Result<()> { - use crate::util::atomic::write_bytes_atomic; - write_bytes_atomic(dest, new_bytes) - .with_context(|| format!("Failed to replace numan binary at '{}'", dest.display()))?; - make_executable(dest)?; + use std::io::Write; + use std::os::unix::fs::PermissionsExt; + + // Preserve existing mode bits (group/other), ensure owner-execute. + // Apply mode on the staged temp file BEFORE renaming into place so a + // permission failure never leaves dest replaced by a non-executable inode. + let mode = std::fs::metadata(dest) + .map(|m| m.permissions().mode()) + .unwrap_or(0o755) + | 0o100; + + let parent = dest.parent().unwrap_or_else(|| Path::new(".")); + std::fs::create_dir_all(parent) + .with_context(|| format!("Failed to create parent directory for '{}'", dest.display()))?; + let mut staged = tempfile::NamedTempFile::new_in(parent) + .with_context(|| format!("Failed to create temp file in '{}'", parent.display()))?; + staged + .write_all(new_bytes) + .context("Failed to write staged numan binary")?; + staged + .flush() + .context("Failed to flush staged numan binary")?; + + let mut perms = std::fs::metadata(staged.path()) + .with_context(|| { + format!( + "Failed to read permissions for staged binary '{}'", + staged.path().display() + ) + })? + .permissions(); + perms.set_mode(mode); + std::fs::set_permissions(staged.path(), perms).with_context(|| { + format!( + "Failed to set permissions on staged numan at '{}'", + staged.path().display() + ) + })?; + + staged.persist(dest).map_err(|e| { + anyhow::anyhow!( + "Failed to replace numan binary at '{}': {}", + dest.display(), + e.error + ) + })?; Ok(()) } #[cfg(windows)] fn replace_binary_windows(dest: &Path, new_bytes: &[u8]) -> Result<()> { use std::io::Write; - // Running executables cannot be overwritten in place on Windows. Move the - // current binary aside, write the new one, then best-effort delete the old. + // Stage the full replacement first, then move the running binary aside. + // That way a write failure never leaves the destination missing. + let parent = dest.parent().unwrap_or_else(|| Path::new(".")); + std::fs::create_dir_all(parent) + .with_context(|| format!("Failed to create parent directory for '{}'", dest.display()))?; + let mut staged = tempfile::NamedTempFile::new_in(parent) + .with_context(|| format!("Failed to create temp file in '{}'", parent.display()))?; + staged + .write_all(new_bytes) + .context("Failed to write new numan binary")?; + staged.flush().context("Failed to flush new numan binary")?; + let backup = dest.with_extension("exe.old"); let _ = std::fs::remove_file(&backup); std::fs::rename(dest, &backup).with_context(|| { @@ -391,42 +452,30 @@ fn replace_binary_windows(dest: &Path, new_bytes: &[u8]) -> Result<()> { backup.display() ) })?; - let write_result = (|| -> Result<()> { - let parent = dest.parent().unwrap_or_else(|| Path::new(".")); - std::fs::create_dir_all(parent)?; - let mut tmp = tempfile::NamedTempFile::new_in(parent) - .with_context(|| format!("Failed to create temp file in '{}'", parent.display()))?; - tmp.write_all(new_bytes) - .context("Failed to write new numan binary")?; - tmp.flush().context("Failed to flush new numan binary")?; - tmp.persist(dest).map_err(|e| { - anyhow::anyhow!( + + match staged.persist(dest) { + Ok(_) => { + let _ = std::fs::remove_file(&backup); + Ok(()) + } + Err(e) => match std::fs::rename(&backup, dest) { + Ok(()) => Err(anyhow::anyhow!( "Failed to install new numan at '{}': {}", dest.display(), e.error - ) - })?; - Ok(()) - })(); - if let Err(e) = write_result { - // Attempt to restore the previous binary. - let _ = std::fs::rename(&backup, dest); - return Err(e); + )), + Err(restore_err) => Err(anyhow::anyhow!( + "Failed to install new numan at '{}': {}. \ + Also failed to restore the previous binary from '{}': {}. \ + Manually rename that backup back to '{}' to recover.", + dest.display(), + e.error, + backup.display(), + restore_err, + dest.display() + )), + }, } - let _ = std::fs::remove_file(&backup); - Ok(()) -} - -#[cfg(unix)] -fn make_executable(path: &Path) -> Result<()> { - use std::os::unix::fs::PermissionsExt; - let mut perms = std::fs::metadata(path) - .with_context(|| format!("Failed to read permissions for '{}'", path.display()))? - .permissions(); - perms.set_mode(0o755); - std::fs::set_permissions(path, perms) - .with_context(|| format!("Failed to mark numan executable at '{}'", path.display()))?; - Ok(()) } #[cfg(test)] @@ -434,6 +483,8 @@ mod tests { use super::*; use std::sync::Mutex; + const STANDALONE_EXE: &str = "/usr/local/bin/numan"; + #[test] fn detect_homebrew_cellar() { assert_eq!( @@ -471,11 +522,19 @@ mod tests { #[test] fn detect_standalone() { assert_eq!( - detect_install_method(Path::new("/usr/local/bin/numan")), + detect_install_method(Path::new(STANDALONE_EXE)), InstallMethod::Standalone ); } + #[test] + fn cargo_upgrade_hint_uses_locked_force() { + assert_eq!( + InstallMethod::Cargo.upgrade_hint(), + Some("cargo install --locked --force numan-cli") + ); + } + #[test] fn release_asset_name_linux_gnu() { let p = Platform { @@ -563,6 +622,19 @@ bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb numan-0.2.0-x8 ); } + #[test] + fn parse_sha256sums_rejects_short_and_non_hex() { + let text = "\ +abcd short.tar.gz +notahex64charshere!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! bad.tar.gz +aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa good.tar.gz +"; + let map = parse_sha256sums(text); + assert!(map.get("short.tar.gz").is_none()); + assert!(map.get("bad.tar.gz").is_none()); + assert!(map.get("good.tar.gz").is_some()); + } + #[test] fn is_newer_compares_semver() { let latest = parse_release_version("v0.2.1").unwrap(); @@ -571,6 +643,75 @@ bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb numan-0.2.0-x8 assert!(!is_newer_than(&latest, "0.3.0").unwrap()); } + #[test] + fn parse_release_version_rejects_malformed() { + let err = parse_release_version("not-a-version") + .unwrap_err() + .to_string(); + assert!(err.contains("Invalid release version"), "{err}"); + } + + #[test] + fn require_https_rejects_http() { + let err = require_https("http://example.test/a", "asset") + .unwrap_err() + .to_string(); + assert!(err.contains("must use https"), "{err}"); + } + + #[test] + fn locate_extracted_numan_at_root() { + let dir = tempfile::tempdir().unwrap(); + let bin = dir.path().join(numan_binary_name()); + std::fs::write(&bin, b"bin").unwrap(); + assert_eq!(locate_extracted_numan(dir.path()).unwrap(), bin); + } + + #[test] + fn locate_extracted_numan_one_dir_deep() { + let dir = tempfile::tempdir().unwrap(); + let nested = dir.path().join("numan-0.2.0-x86_64-unknown-linux-gnu"); + std::fs::create_dir_all(&nested).unwrap(); + let bin = nested.join(numan_binary_name()); + std::fs::write(&bin, b"bin").unwrap(); + assert_eq!(locate_extracted_numan(dir.path()).unwrap(), bin); + } + + #[test] + fn locate_extracted_numan_absent() { + let dir = tempfile::tempdir().unwrap(); + let err = locate_extracted_numan(dir.path()).unwrap_err().to_string(); + assert!(err.contains("Could not find"), "{err}"); + } + + #[test] + fn replace_binary_rejects_empty_without_modifying_target() { + let dir = tempfile::tempdir().unwrap(); + let dest = dir.path().join(numan_binary_name()); + std::fs::write(&dest, b"old-bytes").unwrap(); + let err = replace_binary(&dest, b"").unwrap_err().to_string(); + assert!(err.contains("empty"), "{err}"); + assert_eq!(std::fs::read(&dest).unwrap(), b"old-bytes"); + } + + #[cfg(unix)] + #[test] + fn replace_binary_unix_preserves_mode_and_owner_execute() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().unwrap(); + let dest = dir.path().join(numan_binary_name()); + std::fs::write(&dest, b"old").unwrap(); + let mut perms = std::fs::metadata(&dest).unwrap().permissions(); + perms.set_mode(0o640); + std::fs::set_permissions(&dest, perms).unwrap(); + + replace_binary(&dest, b"new-binary").unwrap(); + assert_eq!(std::fs::read(&dest).unwrap(), b"new-binary"); + let mode = std::fs::metadata(&dest).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o740, "mode={mode:#o}"); + } + struct FakeClient { tag: String, assets: Vec<(String, String)>, @@ -597,13 +738,12 @@ bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb numan-0.2.0-x8 } } + fn platform_asset_name(version: &str) -> Option { + release_asset_name(version, &Platform::detect()).ok() + } + #[test] fn check_reports_update_without_download() { - // Only exercised when this test binary is detected as standalone. - let exe = std::env::current_exe().unwrap(); - if detect_install_method(&exe) != InstallMethod::Standalone { - return; - } let client = FakeClient { tag: "v9.9.9".into(), assets: vec![ @@ -616,7 +756,7 @@ bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb numan-0.2.0-x8 downloads: Mutex::new(Vec::new()), files: HashMap::new(), }; - execute_with_client(&client, true, false, "0.2.0").unwrap(); + execute_with_client(&client, Path::new(STANDALONE_EXE), true, false, "0.2.0").unwrap(); assert!( client.downloads.lock().unwrap().is_empty(), "check must not download" @@ -625,17 +765,168 @@ bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb numan-0.2.0-x8 #[test] fn check_up_to_date_skips_download() { - let exe = std::env::current_exe().unwrap(); - if detect_install_method(&exe) != InstallMethod::Standalone { - return; - } let client = FakeClient { tag: "v0.2.0".into(), assets: vec![], downloads: Mutex::new(Vec::new()), files: HashMap::new(), }; - execute_with_client(&client, true, false, "0.2.0").unwrap(); + execute_with_client(&client, Path::new(STANDALONE_EXE), true, false, "0.2.0").unwrap(); + assert!(client.downloads.lock().unwrap().is_empty()); + } + + #[test] + fn managed_install_prints_hint_without_fetch() { + let client = FakeClient { + tag: "v9.9.9".into(), + assets: vec![], + downloads: Mutex::new(Vec::new()), + files: HashMap::new(), + }; + execute_with_client( + &client, + Path::new("/opt/homebrew/bin/numan"), + false, + false, + "0.2.0", + ) + .unwrap(); + assert!(client.downloads.lock().unwrap().is_empty()); + } + + #[test] + fn apply_rejects_missing_platform_asset() { + let Some(_) = platform_asset_name("9.9.9") else { + // Unsupported host triple: apply path correctly fails at asset naming. + let client = FakeClient { + tag: "v9.9.9".into(), + assets: vec![("SHA256SUMS".into(), "https://example.test/sums".into())], + downloads: Mutex::new(Vec::new()), + files: HashMap::new(), + }; + let err = + execute_with_client(&client, Path::new(STANDALONE_EXE), false, false, "0.2.0") + .unwrap_err() + .to_string(); + assert!(err.contains("No GitHub Release asset"), "{err}"); + return; + }; + + let client = FakeClient { + tag: "v9.9.9".into(), + assets: vec![("SHA256SUMS".into(), "https://example.test/sums".into())], + downloads: Mutex::new(Vec::new()), + files: HashMap::new(), + }; + let err = execute_with_client(&client, Path::new(STANDALONE_EXE), false, false, "0.2.0") + .unwrap_err() + .to_string(); + assert!(err.contains("has no asset named"), "{err}"); + assert!(client.downloads.lock().unwrap().is_empty()); + } + + #[test] + fn apply_rejects_missing_sha256sums_asset() { + let Some(asset_name) = platform_asset_name("9.9.9") else { + return; + }; + let client = FakeClient { + tag: "v9.9.9".into(), + assets: vec![(asset_name, "https://example.test/archive".into())], + downloads: Mutex::new(Vec::new()), + files: HashMap::new(), + }; + let err = execute_with_client(&client, Path::new(STANDALONE_EXE), false, false, "0.2.0") + .unwrap_err() + .to_string(); + assert!(err.contains("missing SHA256SUMS"), "{err}"); + } + + #[test] + fn apply_rejects_asset_absent_from_sha256sums() { + let Some(asset_name) = platform_asset_name("9.9.9") else { + return; + }; + let archive_url = "https://example.test/archive"; + let sums_url = "https://example.test/sums"; + let mut files = HashMap::new(); + files.insert(archive_url.to_string(), b"archive-bytes".to_vec()); + files.insert( + sums_url.to_string(), + b"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa other.tar.gz\n" + .to_vec(), + ); + let client = FakeClient { + tag: "v9.9.9".into(), + assets: vec![ + (asset_name.clone(), archive_url.into()), + ("SHA256SUMS".into(), sums_url.into()), + ], + downloads: Mutex::new(Vec::new()), + files, + }; + let dir = tempfile::tempdir().unwrap(); + let fake_exe = dir.path().join(numan_binary_name()); + std::fs::write(&fake_exe, b"old").unwrap(); + let err = execute_with_client(&client, &fake_exe, false, false, "0.2.0") + .unwrap_err() + .to_string(); + assert!( + err.contains("SHA256SUMS does not list") || err.contains(&asset_name), + "{err}" + ); + assert_eq!(std::fs::read(&fake_exe).unwrap(), b"old"); + } + + #[test] + fn apply_rejects_checksum_mismatch() { + let Some(asset_name) = platform_asset_name("9.9.9") else { + return; + }; + let archive_url = "https://example.test/archive"; + let sums_url = "https://example.test/sums"; + let wrong = "0000000000000000000000000000000000000000000000000000000000000000"; + let sums = format!("{wrong} {asset_name}\n"); + let mut files = HashMap::new(); + files.insert(archive_url.to_string(), b"archive-bytes".to_vec()); + files.insert(sums_url.to_string(), sums.into_bytes()); + let client = FakeClient { + tag: "v9.9.9".into(), + assets: vec![ + (asset_name, archive_url.into()), + ("SHA256SUMS".into(), sums_url.into()), + ], + downloads: Mutex::new(Vec::new()), + files, + }; + let dir = tempfile::tempdir().unwrap(); + let fake_exe = dir.path().join(numan_binary_name()); + std::fs::write(&fake_exe, b"old").unwrap(); + let err = execute_with_client(&client, &fake_exe, false, false, "0.2.0") + .unwrap_err() + .to_string(); + assert!(err.contains("Integrity check failed"), "{err}"); + assert_eq!(std::fs::read(&fake_exe).unwrap(), b"old"); + } + + #[test] + fn apply_rejects_non_https_asset_url() { + let Some(asset_name) = platform_asset_name("9.9.9") else { + return; + }; + let client = FakeClient { + tag: "v9.9.9".into(), + assets: vec![ + (asset_name, "http://example.test/archive".into()), + ("SHA256SUMS".into(), "https://example.test/sums".into()), + ], + downloads: Mutex::new(Vec::new()), + files: HashMap::new(), + }; + let err = execute_with_client(&client, Path::new(STANDALONE_EXE), false, false, "0.2.0") + .unwrap_err() + .to_string(); + assert!(err.contains("must use https"), "{err}"); assert!(client.downloads.lock().unwrap().is_empty()); } } From b528d6f3f71293a5ca344dc4d333a52f4c3993ba Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 6 Aug 2026 03:05:45 +0000 Subject: [PATCH 6/9] Format download HTTPS redirect bail message Co-authored-by: Anthony Thompson --- src/install/download.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/install/download.rs b/src/install/download.rs index 289244f1..1df4b9da 100644 --- a/src/install/download.rs +++ b/src/install/download.rs @@ -38,7 +38,10 @@ pub fn download_file(url: &str, dest: &Path) -> Result<()> { // Preserve HTTPS for callers using a secure URL; never follow a downgrade. if url.starts_with("https://") && response.url().scheme() != "https" { - anyhow::bail!("Refusing HTTPS download redirected to non-HTTPS URL: {}", response.url()); + anyhow::bail!( + "Refusing HTTPS download redirected to non-HTTPS URL: {}", + response.url() + ); } if !response.status().is_success() { From 2f8a4eb17582d2693c4946432fcf896bd069b751 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 6 Aug 2026 03:15:49 +0000 Subject: [PATCH 7/9] Require Ed25519-signed SHA256SUMS for update --self Verify SHA256SUMS.sig with a baked-in release public key before trusting checksums. Sign releases via scripts/sign-sha256sums.py when NUMAN_RELEASE_SIGNING_KEY is set. Keep Unix staged permission apply. Co-authored-by: Anthony Thompson --- .github/workflows/release.yml | 15 ++ AGENTS.md | 2 +- CHANGELOG.md | 2 +- README.md | 2 +- docs/RELEASING.md | 24 ++- scripts/sign-sha256sums.py | 65 ++++++++ src/cmd/self_update.rs | 291 ++++++++++++++++++++++++++++++---- 7 files changed, 365 insertions(+), 36 deletions(-) create mode 100755 scripts/sign-sha256sums.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2ddf0a13..2387bc08 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -199,6 +199,21 @@ jobs: sha256sum numan-* > SHA256SUMS cat SHA256SUMS + - name: Sign SHA256SUMS for self-update + working-directory: dist + env: + NUMAN_RELEASE_SIGNING_KEY: ${{ secrets.NUMAN_RELEASE_SIGNING_KEY }} + run: | + set -euo pipefail + if [[ -z "${NUMAN_RELEASE_SIGNING_KEY}" ]]; then + echo "::warning::NUMAN_RELEASE_SIGNING_KEY is unset; publishing without SHA256SUMS.sig. \ + numan update --self will refuse this release until a signed checksum is present." + exit 0 + fi + python3 -m pip install --user pynacl + python3 "${GITHUB_WORKSPACE}/scripts/sign-sha256sums.py" SHA256SUMS SHA256SUMS.sig + cat SHA256SUMS.sig + - name: Prepare release notes from CHANGELOG env: RELEASE_TAG: ${{ steps.meta.outputs.tag }} diff --git a/AGENTS.md b/AGENTS.md index bd6c6e78..7ec9f51d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -59,7 +59,7 @@ src/ deactivate.rs — Plugin + module deactivation: journaled plugin unregister (`execute_with_unregistrar`); module full/partial (Phase 4 / Issue #22 PR2) plugin_lifecycle.rs — Activate/deactivate-owned lifecycle boundary exposed to opt-in update orchestration (Issue #22 PR3) update.rs — `numan update [--check] [pkg]`: upgrades packages; `numan update --self [--check]`: self-replace standalone binary (or print brew/winget/cargo upgrade); active plugins orchestrate deactivate→upgrade→activate only with exact env opt-in (Phase 5 / Issue #22 PR3) - self_update.rs — GitHub Release self-update for `update --self` (install-method detection, SHA256SUMS verify, atomic/Windows-safe binary replace) + self_update.rs — GitHub Release self-update for `update --self` (install-method detection, Ed25519-signed SHA256SUMS verify, atomic/Windows-safe binary replace) remove.rs — `numan remove [--force] `: remove from lockfile + delete payload (Phase 5); `--force` bypasses module activation only (active plugins always gated until deactivate, Issue #22) gc.rs — `numan gc [--dry-run]`: delete orphaned payload directories (Phase 5) nupm.rs — `numan nupm status|inspect|import|diff`: nupm discovery + import + drift (Phase 6.1–6.3) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2544b9ff..0dcbb011 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- **`numan update --self`**: upgrade the numan CLI itself. Standalone installs download the matching GitHub Release asset, verify `SHA256SUMS` (corruption check; assets are not separately signed), and replace the binary in place. Homebrew / winget / cargo installs print the exact upgrade command instead of self-replacing. Pair with `--check` to report without applying (standalone only; managed installs still print the upgrade hint). +- **`numan update --self`**: upgrade the numan CLI itself. Standalone installs download the matching GitHub Release asset, verify the Ed25519 signature over `SHA256SUMS` (`SHA256SUMS.sig` + baked-in release public key), then check the archive digest and replace the binary. Homebrew / winget / cargo installs print the exact upgrade command instead of self-replacing. Pair with `--check` to report without applying (standalone only; managed installs still print the upgrade hint). ## [0.2.0] - 2026-08-05 diff --git a/README.md b/README.md index 20811e42..46b89de0 100644 --- a/README.md +++ b/README.md @@ -290,7 +290,7 @@ numan gc --dry-run # preview orphaned payload dirs numan gc # delete unreferenced payloads ``` -For Homebrew, winget, or `cargo install` installs, `numan update --self` and `--self --check` both print the matching upgrade command (`brew upgrade numan`, `winget upgrade tonythethompson.numan`, or `cargo install --locked --force numan-cli`) instead of downloading a release asset. Standalone checksum verification guards against truncated or corrupted downloads; release assets are not separately signed. +For Homebrew, winget, or `cargo install` installs, `numan update --self` and `--self --check` both print the matching upgrade command (`brew upgrade numan`, `winget upgrade tonythethompson.numan`, or `cargo install --locked --force numan-cli`) instead of downloading a release asset. Standalone apply downloads the archive plus `SHA256SUMS` and `SHA256SUMS.sig`, verifies the Ed25519 signature with a public key baked into the binary, then checks the archive digest. numan snapshots activation state before `update`, `remove`, `activate`, and `deactivate`, so a bad change can be undone: ```bash diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 1afc3e04..38db64e3 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -47,14 +47,34 @@ Then: ``` 6. The [Release workflow](https://github.com/tonythethompson/numan/actions/workflows/release.yml) waits for green CI on the tagged commit, runs preflight checks, then builds archives and publishes. -7. Confirm platform archives and `SHA256SUMS` on GitHub Releases. -8. Confirm the **Publish to crates.io** job succeeds (requires `CRATES_IO_TOKEN` repository secret). +7. Confirm platform archives, `SHA256SUMS`, and `SHA256SUMS.sig` on GitHub Releases. +8. Confirm the **Publish to crates.io** job succeeds (requires Trusted Publishing / OIDC on crates.io). 9. Confirm the [`Publish to WinGet`](../.github/workflows/winget.yml) workflow verifies the `winget-release-ready` artifact and published Windows release asset, then opens the update PR after the `v*.*.*` tag-triggered Release workflow completes (manual recovery: dispatch with required `release_tag`). 10. Confirm the [`Publish to Homebrew tap`](../.github/workflows/homebrew.yml) workflow verifies the `homebrew-release-ready` artifact and pushes `Formula/numan.rb` to [`tonythethompson/homebrew-numan`](https://github.com/tonythethompson/homebrew-numan) (requires `HOMEBREW_TAP_TOKEN`; manual recovery: dispatch with required `release_tag`). 11. After publication, update documentation only if it needs links that depend on newly created release pages or assets; do not use this step to repair README content already shipped in the crate or tag. **Do not tag until CI is green on `master`.** The release workflow gates on CI check results for tag pushes; pushing a tag on a failing commit blocks publication. +## Self-update signing (`SHA256SUMS.sig`) + +`numan update --self` refuses to install unless `SHA256SUMS.sig` verifies with the baked-in `RELEASE_SUMS_PUBLIC_KEY_B64` in `src/cmd/self_update.rs`. + +1. Keep the matching 32-byte Ed25519 seed only in the repository secret `NUMAN_RELEASE_SIGNING_KEY` (standard base64). Never commit the seed. +2. The Release workflow runs `scripts/sign-sha256sums.py` when that secret is set and uploads `SHA256SUMS.sig` alongside the archives. +3. To rotate: generate a new seed, update the secret, bump `RELEASE_SUMS_PUBLIC_KEY_B64`, and cut a new release. Older unsigned releases remain installable via brew / winget / cargo / manual download. + +Generate a seed and matching public key (local machine only): + +```bash +python3 - <<'PY' +import base64 +from nacl.signing import SigningKey +sk = SigningKey.generate() +print("NUMAN_RELEASE_SIGNING_KEY=" + base64.b64encode(sk.encode()).decode()) +print("RELEASE_SUMS_PUBLIC_KEY_B64=" + base64.b64encode(sk.verify_key.encode()).decode()) +PY +``` + ## CI jobs (reference) | Job | Purpose | diff --git a/scripts/sign-sha256sums.py b/scripts/sign-sha256sums.py new file mode 100755 index 00000000..abd6f159 --- /dev/null +++ b/scripts/sign-sha256sums.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +"""Sign SHA256SUMS with the Numan release Ed25519 key. + +Reads the 32-byte seed from env NUMAN_RELEASE_SIGNING_KEY (standard base64). +Writes a single-line base64 Ed25519 signature over the exact file bytes to +the output path (default: .sig). + +Requires: pip install pynacl +""" + +from __future__ import annotations + +import base64 +import os +import sys + + +def main() -> int: + if len(sys.argv) < 2 or len(sys.argv) > 3: + print( + f"usage: {sys.argv[0]} SHA256SUMS [SHA256SUMS.sig]", + file=sys.stderr, + ) + return 2 + + sums_path = sys.argv[1] + sig_path = sys.argv[2] if len(sys.argv) == 3 else f"{sums_path}.sig" + + seed_b64 = os.environ.get("NUMAN_RELEASE_SIGNING_KEY", "").strip() + if not seed_b64: + print( + "NUMAN_RELEASE_SIGNING_KEY is unset; cannot sign SHA256SUMS", + file=sys.stderr, + ) + return 1 + + try: + from nacl.signing import SigningKey + except ImportError: + print("pynacl is required: pip install pynacl", file=sys.stderr) + return 1 + + seed = base64.b64decode(seed_b64) + if len(seed) != 32: + print( + f"NUMAN_RELEASE_SIGNING_KEY must decode to 32 bytes, got {len(seed)}", + file=sys.stderr, + ) + return 1 + + with open(sums_path, "rb") as f: + data = f.read() + + signing_key = SigningKey(seed) + signature_b64 = base64.b64encode(signing_key.sign(data).signature).decode("ascii") + with open(sig_path, "w", encoding="ascii") as f: + f.write(signature_b64) + f.write("\n") + + print(f"Signed {sums_path} -> {sig_path}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/cmd/self_update.rs b/src/cmd/self_update.rs index 5f1002ed..92ce1b63 100644 --- a/src/cmd/self_update.rs +++ b/src/cmd/self_update.rs @@ -1,6 +1,7 @@ //! Self-update the `numan` binary from GitHub Releases (`numan update --self`). use anyhow::{bail, Context, Result}; +use ed25519_dalek::{Signature, Verifier, VerifyingKey}; use serde::Deserialize; use std::collections::HashMap; use std::path::{Path, PathBuf}; @@ -14,6 +15,11 @@ const RELEASES_LATEST: &str = "https://api.github.com/repos/tonythethompson/numa const USER_AGENT: &str = "numan-cli (https://github.com/tonythethompson/numan)"; const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION"); +/// Ed25519 public key (standard base64) that must sign `SHA256SUMS` for +/// `numan update --self`. The matching 32-byte seed is stored only as the +/// GitHub Actions secret `NUMAN_RELEASE_SIGNING_KEY` (see docs/RELEASING.md). +pub const RELEASE_SUMS_PUBLIC_KEY_B64: &str = "ZyxTCLZyE1xDNnxiHmkSlUe8Y1IIvFoT+XR/+PgVcpw="; + /// How this `numan` binary was installed. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum InstallMethod { @@ -158,6 +164,50 @@ fn require_https(url: &str, what: &str) -> Result<()> { Ok(()) } +/// Verify a detached base64 Ed25519 signature over exact `SHA256SUMS` bytes. +pub fn verify_sha256sums_signature( + sums_bytes: &[u8], + signature_b64: &str, + public_key_b64: &str, +) -> Result<()> { + let key_bytes = base64::Engine::decode( + &base64::engine::general_purpose::STANDARD, + public_key_b64.trim(), + ) + .context("Invalid base64 release public key")?; + if key_bytes.len() != 32 { + bail!( + "Release public key must be 32 bytes, got {}", + key_bytes.len() + ); + } + let mut key_array = [0u8; 32]; + key_array.copy_from_slice(&key_bytes); + let verifying_key = + VerifyingKey::from_bytes(&key_array).context("Invalid Ed25519 release public key")?; + + let sig_bytes = base64::Engine::decode( + &base64::engine::general_purpose::STANDARD, + signature_b64.trim(), + ) + .context("Invalid base64 SHA256SUMS signature")?; + if sig_bytes.len() != 64 { + bail!( + "Ed25519 SHA256SUMS signature must be 64 bytes, got {}", + sig_bytes.len() + ); + } + let mut sig_array = [0u8; 64]; + sig_array.copy_from_slice(&sig_bytes); + let signature = Signature::from_bytes(&sig_array); + + verifying_key.verify(sums_bytes, &signature).context( + "SHA256SUMS signature verification failed. The checksum file may have been \ + tampered with, or this release was not signed with the Numan release key.", + )?; + Ok(()) +} + #[derive(Debug, Deserialize)] struct GitHubRelease { tag_name: String, @@ -213,16 +263,24 @@ impl ReleaseClient for HttpReleaseClient { /// Run `numan update --self` (optionally `--check`). pub fn execute(check: bool, verbose: bool) -> Result<()> { let exe = std::env::current_exe().context("Failed to resolve current numan executable path")?; - execute_with_client(&HttpReleaseClient, &exe, check, verbose, CURRENT_VERSION) + execute_with_client( + &HttpReleaseClient, + &exe, + check, + verbose, + CURRENT_VERSION, + RELEASE_SUMS_PUBLIC_KEY_B64, + ) } -/// Test seam: inject release client, executable path, and current version string. +/// Test seam: inject release client, executable path, version, and sums pubkey. pub fn execute_with_client( client: &dyn ReleaseClient, exe: &Path, check: bool, verbose: bool, current_version: &str, + sums_public_key_b64: &str, ) -> Result<()> { let method = detect_install_method(exe); @@ -281,12 +339,22 @@ pub fn execute_with_client( .context( "Release is missing SHA256SUMS. Refusing to self-update without checksum verification.", )?; + let sig_url = assets + .iter() + .find(|(name, _)| name == "SHA256SUMS.sig") + .map(|(_, url)| url.as_str()) + .context( + "Release is missing SHA256SUMS.sig. Refusing to self-update without an \ + independently signed checksum file.", + )?; require_https(asset_url, "Release asset")?; require_https(sums_url, "SHA256SUMS")?; + require_https(sig_url, "SHA256SUMS.sig")?; let temp = tempfile::tempdir().context("Failed to create temp dir for self-update")?; let archive_path = temp.path().join(&asset_name); let sums_path = temp.path().join("SHA256SUMS"); + let sig_path = temp.path().join("SHA256SUMS.sig"); println!("Downloading {asset_name}..."); client @@ -295,17 +363,20 @@ pub fn execute_with_client( client .download(sums_url, &sums_path) .context("Failed to download SHA256SUMS")?; + client + .download(sig_url, &sig_path) + .context("Failed to download SHA256SUMS.sig")?; - let sums_text = std::fs::read_to_string(&sums_path).context("Failed to read SHA256SUMS")?; + let sums_bytes = std::fs::read(&sums_path).context("Failed to read SHA256SUMS")?; + let sig_b64 = std::fs::read_to_string(&sig_path).context("Failed to read SHA256SUMS.sig")?; + verify_sha256sums_signature(&sums_bytes, &sig_b64, sums_public_key_b64)?; + + let sums_text = String::from_utf8(sums_bytes).context("SHA256SUMS is not valid UTF-8")?; let sums = parse_sha256sums(&sums_text); let expected = sums.get(&asset_name).with_context(|| { format!("SHA256SUMS does not list '{asset_name}'. Refusing to install.") })?; integrity::verify_and_report(&archive_path, expected, &asset_name)?; - // SHA256SUMS arrives from the same GitHub Release as the archive, so this - // check detects truncation/corruption, not an independently authenticated - // publisher identity. Release-channel compromise remains out of scope until - // signed release artifacts land (same trust model as a manual download). let new_bytes = extract_numan_binary(&archive_path, &asset_name, temp.path())?; let dest = std::fs::canonicalize(exe).unwrap_or_else(|_| exe.to_path_buf()); @@ -742,6 +813,25 @@ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa good.tar.gz release_asset_name(version, &Platform::detect()).ok() } + fn test_signing_keypair() -> (String, ed25519_dalek::SigningKey) { + use ed25519_dalek::SigningKey; + use rand_core::OsRng; + let signing_key = SigningKey::generate(&mut OsRng); + let pub_b64 = base64::Engine::encode( + &base64::engine::general_purpose::STANDARD, + signing_key.verifying_key().as_bytes(), + ); + (pub_b64, signing_key) + } + + fn sign_sums(signing_key: &ed25519_dalek::SigningKey, sums: &[u8]) -> String { + use ed25519_dalek::Signer; + base64::Engine::encode( + &base64::engine::general_purpose::STANDARD, + signing_key.sign(sums).to_bytes(), + ) + } + #[test] fn check_reports_update_without_download() { let client = FakeClient { @@ -756,7 +846,15 @@ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa good.tar.gz downloads: Mutex::new(Vec::new()), files: HashMap::new(), }; - execute_with_client(&client, Path::new(STANDALONE_EXE), true, false, "0.2.0").unwrap(); + execute_with_client( + &client, + Path::new(STANDALONE_EXE), + true, + false, + "0.2.0", + "unused", + ) + .unwrap(); assert!( client.downloads.lock().unwrap().is_empty(), "check must not download" @@ -771,7 +869,15 @@ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa good.tar.gz downloads: Mutex::new(Vec::new()), files: HashMap::new(), }; - execute_with_client(&client, Path::new(STANDALONE_EXE), true, false, "0.2.0").unwrap(); + execute_with_client( + &client, + Path::new(STANDALONE_EXE), + true, + false, + "0.2.0", + "unused", + ) + .unwrap(); assert!(client.downloads.lock().unwrap().is_empty()); } @@ -789,6 +895,7 @@ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa good.tar.gz false, false, "0.2.0", + "unused", ) .unwrap(); assert!(client.downloads.lock().unwrap().is_empty()); @@ -804,10 +911,16 @@ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa good.tar.gz downloads: Mutex::new(Vec::new()), files: HashMap::new(), }; - let err = - execute_with_client(&client, Path::new(STANDALONE_EXE), false, false, "0.2.0") - .unwrap_err() - .to_string(); + let err = execute_with_client( + &client, + Path::new(STANDALONE_EXE), + false, + false, + "0.2.0", + "unused", + ) + .unwrap_err() + .to_string(); assert!(err.contains("No GitHub Release asset"), "{err}"); return; }; @@ -818,9 +931,16 @@ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa good.tar.gz downloads: Mutex::new(Vec::new()), files: HashMap::new(), }; - let err = execute_with_client(&client, Path::new(STANDALONE_EXE), false, false, "0.2.0") - .unwrap_err() - .to_string(); + let err = execute_with_client( + &client, + Path::new(STANDALONE_EXE), + false, + false, + "0.2.0", + "unused", + ) + .unwrap_err() + .to_string(); assert!(err.contains("has no asset named"), "{err}"); assert!(client.downloads.lock().unwrap().is_empty()); } @@ -836,31 +956,69 @@ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa good.tar.gz downloads: Mutex::new(Vec::new()), files: HashMap::new(), }; - let err = execute_with_client(&client, Path::new(STANDALONE_EXE), false, false, "0.2.0") - .unwrap_err() - .to_string(); + let err = execute_with_client( + &client, + Path::new(STANDALONE_EXE), + false, + false, + "0.2.0", + "unused", + ) + .unwrap_err() + .to_string(); assert!(err.contains("missing SHA256SUMS"), "{err}"); } + #[test] + fn apply_rejects_missing_sha256sums_sig() { + let Some(asset_name) = platform_asset_name("9.9.9") else { + return; + }; + let client = FakeClient { + tag: "v9.9.9".into(), + assets: vec![ + (asset_name, "https://example.test/archive".into()), + ("SHA256SUMS".into(), "https://example.test/sums".into()), + ], + downloads: Mutex::new(Vec::new()), + files: HashMap::new(), + }; + let err = execute_with_client( + &client, + Path::new(STANDALONE_EXE), + false, + false, + "0.2.0", + "unused", + ) + .unwrap_err() + .to_string(); + assert!(err.contains("missing SHA256SUMS.sig"), "{err}"); + } + #[test] fn apply_rejects_asset_absent_from_sha256sums() { let Some(asset_name) = platform_asset_name("9.9.9") else { return; }; + let (pub_b64, signing_key) = test_signing_keypair(); let archive_url = "https://example.test/archive"; let sums_url = "https://example.test/sums"; + let sig_url = "https://example.test/sums.sig"; + let sums = + b"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa other.tar.gz\n" + .to_vec(); + let sig = sign_sums(&signing_key, &sums); let mut files = HashMap::new(); files.insert(archive_url.to_string(), b"archive-bytes".to_vec()); - files.insert( - sums_url.to_string(), - b"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa other.tar.gz\n" - .to_vec(), - ); + files.insert(sums_url.to_string(), sums); + files.insert(sig_url.to_string(), sig.into_bytes()); let client = FakeClient { tag: "v9.9.9".into(), assets: vec![ (asset_name.clone(), archive_url.into()), ("SHA256SUMS".into(), sums_url.into()), + ("SHA256SUMS.sig".into(), sig_url.into()), ], downloads: Mutex::new(Vec::new()), files, @@ -868,7 +1026,7 @@ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa good.tar.gz let dir = tempfile::tempdir().unwrap(); let fake_exe = dir.path().join(numan_binary_name()); std::fs::write(&fake_exe, b"old").unwrap(); - let err = execute_with_client(&client, &fake_exe, false, false, "0.2.0") + let err = execute_with_client(&client, &fake_exe, false, false, "0.2.0", &pub_b64) .unwrap_err() .to_string(); assert!( @@ -883,18 +1041,23 @@ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa good.tar.gz let Some(asset_name) = platform_asset_name("9.9.9") else { return; }; + let (pub_b64, signing_key) = test_signing_keypair(); let archive_url = "https://example.test/archive"; let sums_url = "https://example.test/sums"; + let sig_url = "https://example.test/sums.sig"; let wrong = "0000000000000000000000000000000000000000000000000000000000000000"; - let sums = format!("{wrong} {asset_name}\n"); + let sums = format!("{wrong} {asset_name}\n").into_bytes(); + let sig = sign_sums(&signing_key, &sums); let mut files = HashMap::new(); files.insert(archive_url.to_string(), b"archive-bytes".to_vec()); - files.insert(sums_url.to_string(), sums.into_bytes()); + files.insert(sums_url.to_string(), sums); + files.insert(sig_url.to_string(), sig.into_bytes()); let client = FakeClient { tag: "v9.9.9".into(), assets: vec![ (asset_name, archive_url.into()), ("SHA256SUMS".into(), sums_url.into()), + ("SHA256SUMS.sig".into(), sig_url.into()), ], downloads: Mutex::new(Vec::new()), files, @@ -902,13 +1065,56 @@ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa good.tar.gz let dir = tempfile::tempdir().unwrap(); let fake_exe = dir.path().join(numan_binary_name()); std::fs::write(&fake_exe, b"old").unwrap(); - let err = execute_with_client(&client, &fake_exe, false, false, "0.2.0") + let err = execute_with_client(&client, &fake_exe, false, false, "0.2.0", &pub_b64) .unwrap_err() .to_string(); assert!(err.contains("Integrity check failed"), "{err}"); assert_eq!(std::fs::read(&fake_exe).unwrap(), b"old"); } + #[test] + fn apply_rejects_bad_sums_signature() { + let Some(asset_name) = platform_asset_name("9.9.9") else { + return; + }; + let (pub_b64, _signing_key) = test_signing_keypair(); + let (_other_pub, other_key) = test_signing_keypair(); + let archive_url = "https://example.test/archive"; + let sums_url = "https://example.test/sums"; + let sig_url = "https://example.test/sums.sig"; + let sums = format!( + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa {asset_name}\n" + ) + .into_bytes(); + // Sign with a different key than the injected verifying key. + let sig = sign_sums(&other_key, &sums); + let mut files = HashMap::new(); + files.insert(archive_url.to_string(), b"archive-bytes".to_vec()); + files.insert(sums_url.to_string(), sums); + files.insert(sig_url.to_string(), sig.into_bytes()); + let client = FakeClient { + tag: "v9.9.9".into(), + assets: vec![ + (asset_name, archive_url.into()), + ("SHA256SUMS".into(), sums_url.into()), + ("SHA256SUMS.sig".into(), sig_url.into()), + ], + downloads: Mutex::new(Vec::new()), + files, + }; + let err = execute_with_client( + &client, + Path::new(STANDALONE_EXE), + false, + false, + "0.2.0", + &pub_b64, + ) + .unwrap_err() + .to_string(); + assert!(err.contains("signature verification failed"), "{err}"); + } + #[test] fn apply_rejects_non_https_asset_url() { let Some(asset_name) = platform_asset_name("9.9.9") else { @@ -919,14 +1125,37 @@ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa good.tar.gz assets: vec![ (asset_name, "http://example.test/archive".into()), ("SHA256SUMS".into(), "https://example.test/sums".into()), + ( + "SHA256SUMS.sig".into(), + "https://example.test/sums.sig".into(), + ), ], downloads: Mutex::new(Vec::new()), files: HashMap::new(), }; - let err = execute_with_client(&client, Path::new(STANDALONE_EXE), false, false, "0.2.0") - .unwrap_err() - .to_string(); + let err = execute_with_client( + &client, + Path::new(STANDALONE_EXE), + false, + false, + "0.2.0", + "unused", + ) + .unwrap_err() + .to_string(); assert!(err.contains("must use https"), "{err}"); assert!(client.downloads.lock().unwrap().is_empty()); } + + #[test] + fn verify_sha256sums_signature_round_trip() { + let (pub_b64, signing_key) = test_signing_keypair(); + let sums = b"deadbeef numan.tar.gz\n"; + let sig = sign_sums(&signing_key, sums); + verify_sha256sums_signature(sums, &sig, &pub_b64).unwrap(); + let err = verify_sha256sums_signature(b"tampered", &sig, &pub_b64) + .unwrap_err() + .to_string(); + assert!(err.contains("signature verification failed"), "{err}"); + } } From a64aca1031bc780d90ca5a451682ae99d320229f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 6 Aug 2026 03:23:43 +0000 Subject: [PATCH 8/9] Report version status for managed update --self --check Homebrew, winget, and cargo installs still cannot self-replace, but --check now fetches the latest release and reports whether an update is available before printing the package-manager upgrade hint. Co-authored-by: Anthony Thompson --- CHANGELOG.md | 2 +- README.md | 3 +- src/cmd/self_update.rs | 64 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 67 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0dcbb011..7ce41869 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- **`numan update --self`**: upgrade the numan CLI itself. Standalone installs download the matching GitHub Release asset, verify the Ed25519 signature over `SHA256SUMS` (`SHA256SUMS.sig` + baked-in release public key), then check the archive digest and replace the binary. Homebrew / winget / cargo installs print the exact upgrade command instead of self-replacing. Pair with `--check` to report without applying (standalone only; managed installs still print the upgrade hint). +- **`numan update --self`**: upgrade the numan CLI itself. Standalone installs download the matching GitHub Release asset, verify the Ed25519 signature over `SHA256SUMS` (`SHA256SUMS.sig` + baked-in release public key), then check the archive digest and replace the binary. Homebrew / winget / cargo installs print the exact upgrade command instead of self-replacing; `--check` still queries GitHub Releases to report whether a newer version exists before printing that hint. ## [0.2.0] - 2026-08-05 diff --git a/README.md b/README.md index 46b89de0..7a8204fc 100644 --- a/README.md +++ b/README.md @@ -290,7 +290,8 @@ numan gc --dry-run # preview orphaned payload dirs numan gc # delete unreferenced payloads ``` -For Homebrew, winget, or `cargo install` installs, `numan update --self` and `--self --check` both print the matching upgrade command (`brew upgrade numan`, `winget upgrade tonythethompson.numan`, or `cargo install --locked --force numan-cli`) instead of downloading a release asset. Standalone apply downloads the archive plus `SHA256SUMS` and `SHA256SUMS.sig`, verifies the Ed25519 signature with a public key baked into the binary, then checks the archive digest. +For Homebrew, winget, or `cargo install` installs, `numan update --self` prints the matching upgrade command (`brew upgrade numan`, `winget upgrade tonythethompson.numan`, or `cargo install --locked --force numan-cli`) instead of replacing the binary. With `--check`, those installs still query GitHub Releases to report whether a newer version exists, then print the upgrade command only when an update is available. Standalone apply downloads the archive plus `SHA256SUMS` and `SHA256SUMS.sig`, verifies the Ed25519 signature with a public key baked into the binary, then checks the archive digest. + numan snapshots activation state before `update`, `remove`, `activate`, and `deactivate`, so a bad change can be undone: ```bash diff --git a/src/cmd/self_update.rs b/src/cmd/self_update.rs index 92ce1b63..465e5e99 100644 --- a/src/cmd/self_update.rs +++ b/src/cmd/self_update.rs @@ -285,6 +285,29 @@ pub fn execute_with_client( let method = detect_install_method(exe); if let Some(hint) = method.upgrade_hint() { + if check { + // Report whether a newer release exists, then point at the package manager. + let (tag, _assets) = client + .fetch_latest() + .context("Failed to fetch latest numan release")?; + let latest = parse_release_version(&tag)?; + if !is_newer_than(&latest, current_version)? { + println!("numan is up to date ({current_version})."); + println!( + "This install is managed by {}; use that tool if you need to reinstall.", + method.display_name() + ); + return Ok(()); + } + println!("Update available: {current_version} → {latest}"); + println!( + "This numan binary looks like a {} install.", + method.display_name() + ); + println!("Upgrade with:"); + println!(" {hint}"); + return Ok(()); + } println!( "This numan binary looks like a {} install.", method.display_name() @@ -901,6 +924,47 @@ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa good.tar.gz assert!(client.downloads.lock().unwrap().is_empty()); } + #[test] + fn managed_install_check_reports_update_then_hint() { + let client = FakeClient { + tag: "v9.9.9".into(), + assets: vec![], + downloads: Mutex::new(Vec::new()), + files: HashMap::new(), + }; + execute_with_client( + &client, + Path::new("/opt/homebrew/bin/numan"), + true, + false, + "0.2.0", + "unused", + ) + .unwrap(); + // check fetches release metadata but must not download assets + assert!(client.downloads.lock().unwrap().is_empty()); + } + + #[test] + fn managed_install_check_reports_up_to_date() { + let client = FakeClient { + tag: "v0.2.0".into(), + assets: vec![], + downloads: Mutex::new(Vec::new()), + files: HashMap::new(), + }; + execute_with_client( + &client, + Path::new("/home/me/.cargo/bin/numan"), + true, + false, + "0.2.0", + "unused", + ) + .unwrap(); + assert!(client.downloads.lock().unwrap().is_empty()); + } + #[test] fn apply_rejects_missing_platform_asset() { let Some(_) = platform_asset_name("9.9.9") else { From bdc96bbd28838bb1e25a75a80c9369802dd044d6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 6 Aug 2026 03:24:33 +0000 Subject: [PATCH 9/9] Add path context to streaming SHA-256 file hashing Surface open/read failures with the affected path so integrity check errors are actionable during self-update and package verify. Co-authored-by: Anthony Thompson --- src/core/integrity.rs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/core/integrity.rs b/src/core/integrity.rs index c8ba8573..1cc34213 100644 --- a/src/core/integrity.rs +++ b/src/core/integrity.rs @@ -1,15 +1,21 @@ -use anyhow::{bail, Result}; +use anyhow::{bail, Context, Result}; use sha2::{Digest, Sha256}; use std::io::{BufReader, Read}; use std::path::Path; fn hash_file(path: &Path) -> Result { - let file = std::fs::File::open(path)?; + let file = std::fs::File::open(path) + .with_context(|| format!("Failed to open '{}' for SHA-256 hashing", path.display()))?; let mut reader = BufReader::new(file); let mut hasher = Sha256::new(); let mut buffer = [0u8; 8192]; loop { - let read = reader.read(&mut buffer)?; + let read = reader.read(&mut buffer).with_context(|| { + format!( + "Failed to read '{}' while computing SHA-256", + path.display() + ) + })?; if read == 0 { break; }