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); + } }