Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 13 additions & 1 deletion .github/workflows/release-binaries.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand All @@ -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
12 changes: 12 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
44 changes: 44 additions & 0 deletions src/channel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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> <name>"` (sha256sum format).
pub fn parse_checksums(body: &str) -> BTreeMap<String, String> {
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::*;
Expand Down Expand Up @@ -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();
Expand Down
195 changes: 191 additions & 4 deletions src/upgrade.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -290,6 +293,9 @@ fn replace_current_binary(url: &str) -> Result<PathBuf, String> {
.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;
Expand All @@ -306,6 +312,122 @@ fn replace_current_binary(url: &str) -> Result<PathBuf, String> {
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<String, String>,
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<PathBuf, String> {
let _ = fs::remove_file(tmp);
Err(err)
}

fn sha256_hex_file(path: &Path) -> Result<String, String> {
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<Option<String>, String> {
Err("download is not available in the browser demo".into())
}

#[cfg(feature = "native")]
fn fetch_checksums_body(url: &str) -> Result<Option<String>, 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<String, String>, key: &str) -> Option<bool> {
let raw = env.get(key)?.trim().to_ascii_lowercase();
match raw.as_str() {
Expand Down Expand Up @@ -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);
}
}
Loading