Plan 005: Verify a checksum before replacing the binary during self-update
Executor instructions: Follow this plan step by step. Run every
verification command and confirm the expected result before moving to the
next step. If anything in the "STOP conditions" section occurs, stop and
report — do not improvise. When done, update the status row for this plan
in plans/README.md — unless a reviewer dispatched you and told you they
maintain the index.
Drift check (run first): git diff --stat 61ee3c7..HEAD -- src/upgrade.rs src/channel.rs .github/workflows/release-binaries.yml .github/workflows/ci.yml scripts/bench.py tests/fixtures/releases.json
If any in-scope file changed since this plan was written, compare the
"Current state" excerpts against the live code before proceeding; on a
mismatch, treat it as a STOP condition.
Status
- Priority: P2
- Effort: M
- Risk: MED (touches release packaging + self-replace path; must stay backward-compatible with releases already published without checksum files)
- Depends on: none (but coordinate review with whoever owns the release workflows)
- Category: security
- Planned at: commit
61ee3c7, 2026-08-26
Why this matters
anyr update downloads a machine-executable from GitHub Releases and renames
it over the running binary. Today TLS is the only integrity check between the
release pipeline and users' $PATH. A compromised runner, a bad asset upload,
or a partial download all become arbitrary-code-execution or a bricked CLI.
Verifying a SHA-256 checksum published alongside the assets closes the
partial-download hole completely and raises the pipeline-compromise bar to
"must also forge the checksum file in the same release". The change must be
graceful for older releases that have no checksum file.
Current state
src/upgrade.rs:
download_binary(url, dest) (lines 176–199) streams the response body
into dest via io::copy. No hashing, no size cap.
replace_current_binary(url) (lines 201–234) downloads to a .anyr.new
temp next to the target, chmods 0o755, then fs::rename over the live exe.
- Callers construct the URL with
channel::release_asset_url(&latest, current_os(), current_arch()) (run_auto line 395, run line 510).
- Fixture-based test seam:
load_releases_json (line 57) reads
--fixture <path> / ANYR_RELEASES_JSON env instead of the network —
existing upgrade tests rely on it (see tests/cli.rs:758 area).
src/channel.rs: Release { tag_name, prerelease, assets: Vec<ReleaseAsset{name, browser_download_url}> }; parse_releases filters drafts and keeps only assets having both name and url. GITHUB_RELEASES_API = "https://api.github.com/repos/anyrouter-dev/cli/releases" — hardcoded, trusted.
- Release side:
.github/workflows/release-binaries.yml uploads binaries;
.github/workflows/ci.yml beta job uploads dist/* from build artifacts.
No checksum artifact exists today anywhere.
Repo conventions: error messages are full sentences with the failing subject
named; pure parsing lives in channel.rs with fixture JSON in
tests/fixtures/releases.json; inline mod tests.
Commands you will need
| Purpose |
Command |
Expected on success |
| Unit tests |
cargo test --locked --lib upgrade |
all pass incl. new tests |
| Full suite |
cargo test --locked --all-targets |
all pass |
| Hash helper sanity |
sha256sum tests/fixtures/releases.json |
prints hex digest |
Rust has no std SHA-256. Add sha2 = "0.10" (pure Rust, tiny, no transitive
deps beyond cfg-if) under [dependencies] in Cargo.toml. This adds ~30–50 KiB
to the stripped binary — acceptable within the 4 MiB budget (current headroom
per scripts/bench.py history is ample); note it in the commit message.
Scope
In scope:
Cargo.toml, Cargo.lock (add sha2)
src/upgrade.rs
src/channel.rs (only if you add a checksum-url helper there — allowed but optional)
.github/workflows/ci.yml (beta job: generate + upload checksums)
.github/workflows/release-binaries.yml (same)
tests/fixtures/releases.json (add a checksums entry so tests can exercise the happy path)
Out of scope:
- Signature verification (minisign/cosign) — explicitly deferred; checksum is the 0.1.x step.
- setup.sh / npm wrapper changes (they install fresh from GitHub; same trust model, separate follow-up).
- The Windows
download_and_replace path shape (it shares replace_current_binary; nothing OS-specific needed).
Git workflow
- Branch:
advisor/005-upgrade-checksum
- Commit style: conventional commits, e.g.
feat(security): verify sha256 of downloaded release assets before replace
- Do NOT push or open a PR.
Steps
Step 1: Emit checksums in both release workflows
In .github/workflows/release-binaries.yml, after artifacts are collected in
the release-upload step (find where the asset files are gathered into one
directory), add:
- name: generate checksums
shell: bash
run: |
set -euo pipefail
cd <asset-dir> # match however the step above stages assets
sha256sum anyr-* > checksums.txt
and include checksums.txt in the upload list. In .github/workflows/ci.yml,
in the beta job after cp -v {} dist/ \;, add:
( cd dist && sha256sum anyr-* > checksums.txt )
ls -la dist/checksums.txt
(sha256sum exists on ubuntu runners by default; macOS/windows legs don't run
this step.)
Verify: python3 -c "import yaml; [yaml.safe_load(open(f)) for f in ['.github/workflows/ci.yml','.github/workflows/release-binaries.yml']]" → exit 0.
Step 2: Parse the checksum file in channel.rs
Add a small parser + lookup (pure, unit-testable):
/// 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()
}
Plus unit tests in channel.rs mod tests: valid line parses; garbage lines
skipped; uppercase hex normalized.
Step 3: Verify in upgrade.rs before rename
- In
replace_current_binary, derive the expected checksum URL from the
binary URL's directory: same release tag directory, file checksums.txt.
Simplest correct construction: take url, split off the last / segment,
append checksums.txt.
- After
download_binary(url, &tmp) succeeds, fetch the checksum file with
the same agent settings (60 s timeout). On HTTP 404 → treat as "legacy
release without checksums": print a one-line warning to stderr
(warning: release has no checksums.txt — skipping verification) and
proceed ONLY if the user passed --yes OR make it a hard error? DECISION:
hard-fail with guidance EXCEPT when the resolved release predates the
feature. Since we can't date releases here, implement: 404 → proceed with
stderr warning (graceful for all pre-existing releases); checksum file
present → verification is MANDATORY, mismatch aborts with the temp file
removed and an error like checksum mismatch for <asset>: expected <x>, got <y>; download aborted.
- Compute with
sha2::{Digest, Sha256} streaming over the temp file (read in
64 KiB chunks; do not load whole binary into memory).
- Only after a successful verify (or sanctioned legacy skip) continue into
chmod/rename.
Keep replace_current_binary's signature (url: &str) -> Result<PathBuf, String>.
Verify: cargo build --locked → exit 0.
Step 4: Tests
In upgrade.rs mod tests:
parse_checksums behavior lives in channel.rs (Step 2 tests).
- Add a pure helper
verify_checksum_file(map: &BTreeMap<String,String>, asset_name: &str, actual_hex: &str) -> Result<(), String> and test: matching hex passes; wrong hex errors mentioning both hashes; missing entry errors naming the asset.
- Wire it:
download_binary currently returns (); extend replace_current_binary internals only — do not change its callers.
Fixture: add "checksums.txt" handling to tests/fixtures/releases.json ONLY if the fixture drives URL selection (it lists assets); adding an extra asset entry named checksums.txt with a plausible browser_download_url is enough for parser-level tests. Do not invent new release entries.
Step 5: Full suite + clippy
Verify: cargo test --locked --all-targets → exit 0; cargo clippy --locked --all-targets → no new warnings beyond the 13 baseline.
Test plan
- New:
parse_checksums (3 cases), verify_checksum_file (3 cases), plus one
integration-style assertion that anyr upgrade --check still works against
the existing fixture (existing tests cover this — ensure they stay green).
- Pattern reference: existing upgrade tests at src/upgrade.rs:590+ use
isolated_home() and ParsedArgs builders.
Done criteria
ALL must hold:
STOP conditions
Stop and report if:
- Adding sha2 pushes the stripped linux x86_64 binary past 4.0 MiB (report measured size).
- The release workflows' asset staging differs structurally from the description such that the checksum step cannot sit next to it.
replace_current_binary's callers have changed shape since the excerpt.
- Any existing upgrade test breaks because it now expects a checksum fetch against the fixture (report which).
Maintenance notes
- Next maturity step is signed releases (cosign/minisign); keep the verify
call behind one function (verify_downloaded_asset) so signatures slot in.
- The beta job regenerates checksums every push; stale
checksums.txt from
artifacts of different runs would be caught by CI ordering (generate inside
the publish step from the same dist dir).
- Reviewer: confirm the mismatch error does NOT print the full downloaded
bytes or anything beyond hashes/paths.
Plan 005: Verify a checksum before replacing the binary during self-update
Status
61ee3c7, 2026-08-26Why this matters
anyr updatedownloads a machine-executable from GitHub Releases and renamesit over the running binary. Today TLS is the only integrity check between the
release pipeline and users'
$PATH. A compromised runner, a bad asset upload,or a partial download all become arbitrary-code-execution or a bricked CLI.
Verifying a SHA-256 checksum published alongside the assets closes the
partial-download hole completely and raises the pipeline-compromise bar to
"must also forge the checksum file in the same release". The change must be
graceful for older releases that have no checksum file.
Current state
src/upgrade.rs:download_binary(url, dest)(lines 176–199) streams the response bodyinto
destviaio::copy. No hashing, no size cap.replace_current_binary(url)(lines 201–234) downloads to a.anyr.newtemp next to the target, chmods 0o755, then
fs::renameover the live exe.channel::release_asset_url(&latest, current_os(), current_arch())(run_autoline 395,runline 510).load_releases_json(line 57) reads--fixture <path>/ANYR_RELEASES_JSONenv instead of the network —existing upgrade tests rely on it (see
tests/cli.rs:758area).src/channel.rs:Release { tag_name, prerelease, assets: Vec<ReleaseAsset{name, browser_download_url}> };parse_releasesfilters drafts and keeps only assets having both name and url.GITHUB_RELEASES_API = "https://api.github.com/repos/anyrouter-dev/cli/releases"— hardcoded, trusted..github/workflows/release-binaries.ymluploads binaries;.github/workflows/ci.ymlbeta job uploadsdist/*from build artifacts.No checksum artifact exists today anywhere.
Repo conventions: error messages are full sentences with the failing subject
named; pure parsing lives in channel.rs with fixture JSON in
tests/fixtures/releases.json; inlinemod tests.Commands you will need
cargo test --locked --lib upgradecargo test --locked --all-targetssha256sum tests/fixtures/releases.jsonRust has no std SHA-256. Add
sha2 = "0.10"(pure Rust, tiny, no transitivedeps beyond cfg-if) under
[dependencies]in Cargo.toml. This adds ~30–50 KiBto the stripped binary — acceptable within the 4 MiB budget (current headroom
per scripts/bench.py history is ample); note it in the commit message.
Scope
In scope:
Cargo.toml,Cargo.lock(add sha2)src/upgrade.rssrc/channel.rs(only if you add a checksum-url helper there — allowed but optional).github/workflows/ci.yml(beta job: generate + upload checksums).github/workflows/release-binaries.yml(same)tests/fixtures/releases.json(add a checksums entry so tests can exercise the happy path)Out of scope:
download_and_replacepath shape (it sharesreplace_current_binary; nothing OS-specific needed).Git workflow
advisor/005-upgrade-checksumfeat(security): verify sha256 of downloaded release assets before replaceSteps
Step 1: Emit checksums in both release workflows
In
.github/workflows/release-binaries.yml, after artifacts are collected inthe release-upload step (find where the asset files are gathered into one
directory), add:
and include
checksums.txtin the upload list. In.github/workflows/ci.yml,in the
betajob aftercp -v {} dist/ \;, add:(sha256sum exists on ubuntu runners by default; macOS/windows legs don't run
this step.)
Verify:
python3 -c "import yaml; [yaml.safe_load(open(f)) for f in ['.github/workflows/ci.yml','.github/workflows/release-binaries.yml']]"→ exit 0.Step 2: Parse the checksum file in channel.rs
Add a small parser + lookup (pure, unit-testable):
Plus unit tests in
channel.rs mod tests: valid line parses; garbage linesskipped; uppercase hex normalized.
Step 3: Verify in upgrade.rs before rename
replace_current_binary, derive the expected checksum URL from thebinary URL's directory: same release tag directory, file
checksums.txt.Simplest correct construction: take
url, split off the last/segment,append
checksums.txt.download_binary(url, &tmp)succeeds, fetch the checksum file withthe same agent settings (60 s timeout). On HTTP 404 → treat as "legacy
release without checksums": print a one-line warning to stderr
(
warning: release has no checksums.txt — skipping verification) andproceed ONLY if the user passed
--yesOR make it a hard error? DECISION:hard-fail with guidance EXCEPT when the resolved release predates the
feature. Since we can't date releases here, implement: 404 → proceed with
stderr warning (graceful for all pre-existing releases); checksum file
present → verification is MANDATORY, mismatch aborts with the temp file
removed and an error like
checksum mismatch for <asset>: expected <x>, got <y>; download aborted.sha2::{Digest, Sha256}streaming over the temp file (read in64 KiB chunks; do not load whole binary into memory).
chmod/rename.
Keep
replace_current_binary's signature(url: &str) -> Result<PathBuf, String>.Verify:
cargo build --locked→ exit 0.Step 4: Tests
In
upgrade.rs mod tests:parse_checksumsbehavior lives in channel.rs (Step 2 tests).verify_checksum_file(map: &BTreeMap<String,String>, asset_name: &str, actual_hex: &str) -> Result<(), String>and test: matching hex passes; wrong hex errors mentioning both hashes; missing entry errors naming the asset.download_binarycurrently returns(); extendreplace_current_binaryinternals only — do not change its callers.Fixture: add
"checksums.txt"handling totests/fixtures/releases.jsonONLY if the fixture drives URL selection (it lists assets); adding an extra asset entry namedchecksums.txtwith a plausible browser_download_url is enough for parser-level tests. Do not invent new release entries.Step 5: Full suite + clippy
Verify:
cargo test --locked --all-targets→ exit 0;cargo clippy --locked --all-targets→ no new warnings beyond the 13 baseline.Test plan
parse_checksums(3 cases),verify_checksum_file(3 cases), plus oneintegration-style assertion that
anyr upgrade --checkstill works againstthe existing fixture (existing tests cover this — ensure they stay green).
isolated_home()and ParsedArgs builders.Done criteria
ALL must hold:
grep -n "Sha256\|sha256" src/upgrade.rsfinds the streaming hash usage.grep -n checksums .github/workflows/*.yml≥ 2 files).cargo test --locked --all-targetsexits 0.cargo build --release && strip -s target/release/anyr && python3 scripts/bench.py measure --bin target/release/anyr --asset anyr-linux-x86_64 --kind native --out /tmp/bench.json && grep -o '"size_bytes":[0-9]*' /tmp/bench.json→ ≤ 4194304.STOP conditions
Stop and report if:
replace_current_binary's callers have changed shape since the excerpt.Maintenance notes
call behind one function (
verify_downloaded_asset) so signatures slot in.checksums.txtfromartifacts of different runs would be caught by CI ordering (generate inside
the publish step from the same dist dir).
bytes or anything beyond hashes/paths.