From 4fbf79c23e412373a6383b6ca4bec78a4c2578a7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 28 Aug 2026 05:14:18 +0000 Subject: [PATCH] feat(security): verify sha256 of downloaded release assets before replace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Publish checksums.txt (sha256sum anyr-*) with stable and beta release assets. anyr update fetches the sibling checksums.txt after download, streams Sha256 over the temp file in 64 KiB chunks, and refuses to replace on mismatch (temp removed; hashes/paths only). HTTP 404 is treated as a legacy release: warn on stderr and proceed. Adds sha2 0.10 (~30–50 KiB); stays within the 4 MiB linux x86_64 budget. Closes #20 Co-authored-by: duyet --- .github/workflows/ci.yml | 2 + .github/workflows/release-binaries.yml | 14 +- Cargo.lock | 12 ++ Cargo.toml | 1 + src/channel.rs | 44 ++++++ src/upgrade.rs | 195 ++++++++++++++++++++++++- 6 files changed, 263 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9ccc3cc..145b570 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -270,7 +270,9 @@ jobs: TAG="v${VER}" mkdir -p dist find artifacts -type f \( -name 'anyr-*' -o -name 'anyr.exe' \) ! -name '*.json' -exec cp -v {} dist/ \; + ( cd dist && sha256sum anyr-* > checksums.txt ) ls -la dist + ls -la dist/checksums.txt test -n "$(ls -A dist)" || { echo "no binaries in artifacts"; exit 1; } SHORT="$(echo "${GITHUB_SHA}" | cut -c1-7)" printf 'Beta from main (%s).\n\ncurl -fsSL https://anyrouter.dev/setup.sh | bash -s -- --channel beta\nchannel: beta\n' "$SHORT" > notes.md diff --git a/.github/workflows/release-binaries.yml b/.github/workflows/release-binaries.yml index 920e8f7..76bda80 100644 --- a/.github/workflows/release-binaries.yml +++ b/.github/workflows/release-binaries.yml @@ -151,6 +151,18 @@ jobs: with: python-version: "3.12" + - name: generate checksums + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.event.release.tag_name }} + run: | + set -euo pipefail + mkdir -p dist + gh release download "${RELEASE_TAG}" --dir dist --pattern 'anyr-*' + ( cd dist && sha256sum anyr-* > checksums.txt ) + ls -la dist/checksums.txt + - name: write changelog + bench to release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -165,4 +177,4 @@ jobs: --out notes.md gh release edit "${RELEASE_TAG}" --notes-file notes.md gh release upload "${RELEASE_TAG}" \ - bench-report.md bench-report.json --clobber + bench-report.md bench-report.json dist/checksums.txt --clobber diff --git a/Cargo.lock b/Cargo.lock index 0492dc2..034c5c2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -22,6 +22,7 @@ dependencies = [ "libc", "ratatui", "serde_json", + "sha2", "tungstenite", "ureq", "wasm-bindgen", @@ -819,6 +820,17 @@ dependencies = [ "digest", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "shlex" version = "2.0.1" diff --git a/Cargo.toml b/Cargo.toml index b05b3fe..73f81d1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,6 +25,7 @@ native = ["dep:ureq", "dep:ratatui", "dep:crossterm", "dep:tungstenite"] [dependencies] serde_json = "1" libc = "0.2" +sha2 = "0.10" ureq = { version = "2.12", optional = true } ratatui = { version = "=0.29.0", default-features = false, features = ["crossterm"], optional = true } crossterm = { version = "=0.28.1", optional = true } diff --git a/src/channel.rs b/src/channel.rs index bcb5a72..6964972 100644 --- a/src/channel.rs +++ b/src/channel.rs @@ -449,6 +449,19 @@ pub fn latest_stable_download_url(os: &str, arch: &str) -> String { format!("{GITHUB_LATEST_DOWNLOAD}/{}", asset_name(os, arch)) } +/// One line per asset: `"<64 lowercase hex> "` (sha256sum format). +pub fn parse_checksums(body: &str) -> BTreeMap { + body.lines() + .filter_map(|line| { + let (hex, name) = line.split_once(" ")?; + let hex = hex.trim(); + let name = name.trim(); + (hex.len() == 64 && hex.chars().all(|c| c.is_ascii_hexdigit())) + .then(|| (name.to_string(), hex.to_ascii_lowercase())) + }) + .collect() +} + #[cfg(test)] mod tests { use super::*; @@ -494,6 +507,37 @@ mod tests { assert!(!url.contains("duyet/anyrouter")); } + #[test] + fn parse_checksums_valid_line() { + let map = parse_checksums( + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef anyr-linux-x86_64\n", + ); + assert_eq!( + map.get("anyr-linux-x86_64").map(String::as_str), + Some("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef") + ); + } + + #[test] + fn parse_checksums_skips_garbage() { + let map = parse_checksums( + "# comment\nnot a checksum\n0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef anyr-linux-x86_64\nshort name\n", + ); + assert_eq!(map.len(), 1); + assert!(map.contains_key("anyr-linux-x86_64")); + } + + #[test] + fn parse_checksums_normalizes_uppercase_hex() { + let map = parse_checksums( + "0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF anyr-linux-x86_64\n", + ); + assert_eq!( + map.get("anyr-linux-x86_64").map(String::as_str), + Some("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef") + ); + } + #[test] fn from_env_defaults_stable() { let env = BTreeMap::new(); diff --git a/src/upgrade.rs b/src/upgrade.rs index 23c3e5a..15b92a1 100644 --- a/src/upgrade.rs +++ b/src/upgrade.rs @@ -6,14 +6,17 @@ use std::fs; use std::fs::OpenOptions; #[cfg(feature = "native")] use std::io; -use std::io::Write; +use std::io::{Read, Write}; use std::path::{Path, PathBuf}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use sha2::{Digest, Sha256}; + use crate::channel::{ - asset_name, current_arch, current_os, github_token, merge_expanded_assets, parse_releases, - parse_releases_html, release_asset_url, releases_http_error, select_latest_release_with_asset, - Channel, Release, GITHUB_EXPANDED_ASSETS_PREFIX, GITHUB_RELEASES_API, GITHUB_RELEASES_HTML, + asset_name, current_arch, current_os, github_token, merge_expanded_assets, parse_checksums, + parse_releases, parse_releases_html, release_asset_url, releases_http_error, + select_latest_release_with_asset, Channel, Release, GITHUB_EXPANDED_ASSETS_PREFIX, + GITHUB_RELEASES_API, GITHUB_RELEASES_HTML, }; use crate::config::{resolve_config_path, write_config, Config}; use crate::http::{http_get_github, http_get_web}; @@ -290,6 +293,9 @@ fn replace_current_binary(url: &str) -> Result { .unwrap_or_else(|| "anyr".into()) )); download_binary(url, &tmp)?; + if let Err(e) = verify_downloaded_asset(url, &tmp) { + return abort_download(&tmp, e); + } #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; @@ -306,6 +312,122 @@ fn replace_current_binary(url: &str) -> Result { Ok(dest) } +/// Sibling `checksums.txt` in the same release-asset directory as `asset_url`. +fn checksums_url(asset_url: &str) -> String { + match asset_url.rsplit_once('/') { + Some((dir, _)) => format!("{dir}/checksums.txt"), + None => "checksums.txt".into(), + } +} + +fn asset_name_from_url(url: &str) -> &str { + url.rsplit('/') + .next() + .filter(|s| !s.is_empty()) + .unwrap_or("anyr") +} + +/// HTTP 404 on checksums.txt means a legacy release; skip verification. +fn checksums_missing_is_legacy(status: u16) -> bool { + status == 404 +} + +fn verify_checksum_file( + map: &BTreeMap, + asset_name: &str, + actual_hex: &str, +) -> Result<(), String> { + let Some(expected) = map.get(asset_name) else { + return Err(format!( + "checksums.txt has no entry for {asset_name}; download aborted" + )); + }; + let expected = expected.trim().to_ascii_lowercase(); + let actual = actual_hex.trim().to_ascii_lowercase(); + if expected == actual { + Ok(()) + } else { + Err(format!( + "checksum mismatch for {asset_name}: expected {expected}, got {actual}; download aborted" + )) + } +} + +fn abort_download(tmp: &Path, err: String) -> Result { + let _ = fs::remove_file(tmp); + Err(err) +} + +fn sha256_hex_file(path: &Path) -> Result { + let mut file = + fs::File::open(path).map_err(|e| format!("could not read {}: {e}", path.display()))?; + let mut hasher = Sha256::new(); + let mut buf = [0u8; 64 * 1024]; + loop { + let n = file + .read(&mut buf) + .map_err(|e| format!("could not hash {}: {e}", path.display()))?; + if n == 0 { + break; + } + hasher.update(&buf[..n]); + } + Ok(format!("{:x}", hasher.finalize())) +} + +#[cfg(not(feature = "native"))] +fn fetch_checksums_body(_url: &str) -> Result, String> { + Err("download is not available in the browser demo".into()) +} + +#[cfg(feature = "native")] +fn fetch_checksums_body(url: &str) -> Result, String> { + let agent = ureq::AgentBuilder::new() + .timeout(std::time::Duration::from_secs(60)) + .user_agent(&format!("anyr-cli/{VERSION}")) + .build(); + match agent.get(url).call() { + Ok(resp) => { + let status = resp.status(); + if checksums_missing_is_legacy(status) { + return Ok(None); + } + if !(200..300).contains(&status) { + return Err(format!("checksums download HTTP {status} from {url}")); + } + resp.into_string() + .map(Some) + .map_err(|e| format!("could not read checksums.txt: {e}")) + } + Err(ureq::Error::Status(code, resp)) => { + let _ = resp.into_string(); + if checksums_missing_is_legacy(code) { + Ok(None) + } else { + Err(format!("checksums download HTTP {code} from {url}")) + } + } + Err(err) => Err(format!("checksums download failed: {err}")), + } +} + +/// Verify `tmp` against sibling `checksums.txt`. 404 → warn and skip (legacy). +fn verify_downloaded_asset(url: &str, tmp: &Path) -> Result<(), String> { + let checksums = checksums_url(url); + match fetch_checksums_body(&checksums)? { + None => { + eprintln!("warning: release has no checksums.txt — skipping verification"); + Ok(()) + } + Some(body) => { + let map = parse_checksums(&body); + let asset = asset_name_from_url(url); + let actual = sha256_hex_file(tmp)?; + verify_checksum_file(&map, asset, &actual) + } + } +} + fn env_flag(env: &BTreeMap, key: &str) -> Option { let raw = env.get(key)?.trim().to_ascii_lowercase(); match raw.as_str() { @@ -1007,4 +1129,69 @@ mod tests { assert_eq!(beta.tag_name, "v0.1.12-beta.98"); let _ = fs::remove_dir_all(&dir); } + + #[test] + fn checksums_url_is_sibling_of_asset() { + assert_eq!( + checksums_url( + "https://github.com/anyrouter-dev/cli/releases/download/v0.1.11/anyr-linux-x86_64" + ), + "https://github.com/anyrouter-dev/cli/releases/download/v0.1.11/checksums.txt" + ); + } + + #[test] + fn checksums_missing_is_legacy_on_404() { + assert!(checksums_missing_is_legacy(404)); + assert!(!checksums_missing_is_legacy(200)); + assert!(!checksums_missing_is_legacy(500)); + } + + #[test] + fn verify_checksum_file_match() { + let mut map = BTreeMap::new(); + map.insert("anyr-linux-x86_64".into(), "AbCdef".into()); + assert!(verify_checksum_file(&map, "anyr-linux-x86_64", "abcdef").is_ok()); + } + + #[test] + fn verify_checksum_file_mismatch_mentions_both_hashes() { + let mut map = BTreeMap::new(); + map.insert("anyr-linux-x86_64".into(), "expectedhash".into()); + let err = verify_checksum_file(&map, "anyr-linux-x86_64", "actualhash").unwrap_err(); + assert!(err.contains("expectedhash"), "{err}"); + assert!(err.contains("actualhash"), "{err}"); + assert!(err.contains("anyr-linux-x86_64"), "{err}"); + assert!( + !err.contains('\0') && !err.contains("payload"), + "mismatch must not dump downloaded bytes: {err}" + ); + } + + #[test] + fn verify_checksum_file_missing_entry_names_asset() { + let map = BTreeMap::new(); + let err = verify_checksum_file(&map, "anyr-linux-x86_64", "abc").unwrap_err(); + assert!(err.contains("anyr-linux-x86_64"), "{err}"); + } + + #[test] + fn abort_download_removes_temp_and_keeps_hashes_only() { + let (_env, dir) = isolated_home(); + let tmp = dir.join(".anyr.new"); + fs::write(&tmp, b"downloaded-bytes-must-not-appear-in-error").unwrap(); + let err = abort_download( + &tmp, + "checksum mismatch for anyr-linux-x86_64: expected aaa, got bbb; download aborted" + .into(), + ) + .unwrap_err(); + assert!(!tmp.exists(), "temp file must be removed on checksum abort"); + assert!(err.contains("aaa") && err.contains("bbb"), "{err}"); + assert!( + !err.contains("downloaded-bytes-must-not-appear-in-error"), + "mismatch must not dump downloaded bytes: {err}" + ); + let _ = fs::remove_dir_all(&dir); + } }