Skip to content

Plan 005: Verify a checksum before replacing the binary during self-update #20

Description

@duyetbot

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

  1. 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.
  2. 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.
  3. Compute with sha2::{Digest, Sha256} streaming over the temp file (read in
    64 KiB chunks; do not load whole binary into memory).
  4. 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:

  • grep -n "Sha256\|sha256" src/upgrade.rs finds the streaming hash usage.
  • Checksum-mismatch path removes the temp file: code review shows remove before returning Err.
  • Legacy-release path (404 on checksums.txt) proceeds with warning — covered by a unit test on the decision helper OR clearly factored function with its own test.
  • Both workflow YAMLs parse and include a checksums generation step (grep -n checksums .github/workflows/*.yml ≥ 2 files).
  • cargo test --locked --all-targets exits 0.
  • Binary size still within budget locally: 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:

  • 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions