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 b966df38..7ec9f51d 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, 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 f1db2fcf..7ce41869 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 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 ### Added diff --git a/README.md b/README.md index 60665d18..7a8204fc 100644 --- a/README.md +++ b/README.md @@ -281,13 +281,17 @@ 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 # 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` 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 @@ -348,6 +352,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 +382,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/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/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..465e5e99 --- /dev/null +++ b/src/cmd/self_update.rs @@ -0,0 +1,1225 @@ +//! 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}; + +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"); + +/// 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 { + 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 --locked --force 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) +} + +fn require_https(url: &str, what: &str) -> Result<()> { + if !url.starts_with("https://") { + bail!("{what} URL must use https (got '{url}')"); + } + 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, + 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<()> { + let exe = std::env::current_exe().context("Failed to resolve current numan executable path")?; + execute_with_client( + &HttpReleaseClient, + &exe, + check, + verbose, + CURRENT_VERSION, + RELEASE_SUMS_PUBLIC_KEY_B64, + ) +} + +/// 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); + + 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() + ); + 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)?; + + 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(()); + } + + // 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) + .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 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 + .download(asset_url, &archive_path) + .with_context(|| format!("Failed to download {asset_name}"))?; + 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_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)?; + + 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()); + 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(unix)] + { + replace_binary_unix(dest, new_bytes) + } +} + +#[cfg(unix)] +fn replace_binary_unix(dest: &Path, new_bytes: &[u8]) -> Result<()> { + 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; + // 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(|| { + format!( + "Failed to move running numan aside to '{}'", + 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!( + "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() + )), + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Mutex; + + const STANDALONE_EXE: &str = "/usr/local/bin/numan"; + + #[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(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 { + 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 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(); + 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()); + } + + #[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)>, + 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(()) + } + } + + fn platform_asset_name(version: &str) -> Option { + 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 { + 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, + Path::new(STANDALONE_EXE), + true, + false, + "0.2.0", + "unused", + ) + .unwrap(); + assert!( + client.downloads.lock().unwrap().is_empty(), + "check must not download" + ); + } + + #[test] + fn check_up_to_date_skips_download() { + 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", + "unused", + ) + .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", + "unused", + ) + .unwrap(); + 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 { + // 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", + "unused", + ) + .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", + "unused", + ) + .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", + "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(), 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, + }; + 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", &pub_b64) + .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 (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").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); + 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 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", &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 { + 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()), + ( + "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", + "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}"); + } +} diff --git a/src/cmd/update.rs b/src/cmd/update.rs index 5bda4785..62ad15a5 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)?; } @@ -626,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 { diff --git a/src/core/integrity.rs b/src/core/integrity.rs index 5e492a71..1cc34213 100644 --- a/src/core/integrity.rs +++ b/src/core/integrity.rs @@ -1,11 +1,31 @@ -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) + .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).with_context(|| { + format!( + "Failed to read '{}' while computing SHA-256", + path.display() + ) + })?; + 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 +41,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} diff --git a/src/install/download.rs b/src/install/download.rs index 4147a41f..1df4b9da 100644 --- a/src/install/download.rs +++ b/src/install/download.rs @@ -36,6 +36,14 @@ 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()); }