From c24a285cf567027ca9806b16d243e8095b0a7b64 Mon Sep 17 00:00:00 2001 From: Yoshihiro OKUMURA Date: Fri, 4 Sep 2026 16:31:02 +0900 Subject: [PATCH] fix(selfupdate): verify the archive before replacing the binary `mdrs selfupdate` replaced the running binary with whatever the release endpoint returned, checking only that the transport succeeded. Nothing proved the archive was the one the release publishes. - Compare the downloaded archive against the release's `.sha256` asset and abort the update on a mismatch. - Report a release that publishes no checksum as unverified, rather than letting its absence pass for a verified download. - Exclude `.sha256` assets when matching the archive for the build target: those assets carry the target name too. - Write and upload a checksum beside every archive, from the Gitea release workflow and the three local build scripts. --- .gitea/workflows/release.yml | 12 +++- README.md | 3 +- scripts/build-release-linux.sh | 5 +- scripts/build-release-macos.sh | 5 +- scripts/build-release-windows.ps1 | 22 ++++--- src/commands/selfupdate.rs | 102 +++++++++++++++++++++++++++++- 6 files changed, 135 insertions(+), 14 deletions(-) diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index bd23e5a..ecbc6dd 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -26,12 +26,16 @@ jobs: TARGET=x86_64-unknown-linux-musl ARCHIVE="mdrs-${VERSION}-${TARGET}.tar.gz" tar -czf "${ARCHIVE}" -C target/${TARGET}/release mdrs + # Published alongside the archive so `mdrs selfupdate` can check what it fetched. + sha256sum "${ARCHIVE}" > "${ARCHIVE}.sha256" echo "ARCHIVE=${ARCHIVE}" >> "$GITHUB_ENV" - name: Create release and upload asset uses: akkuman/gitea-release-action@v1 with: token: ${{ github.token }} - files: ${{ env.ARCHIVE }} + files: | + ${{ env.ARCHIVE }} + ${{ env.ARCHIVE }}.sha256 build-linux-aarch64: runs-on: ubuntu-latest @@ -55,9 +59,13 @@ jobs: TARGET=aarch64-unknown-linux-musl ARCHIVE="mdrs-${VERSION}-${TARGET}.tar.gz" tar -czf "${ARCHIVE}" -C target/${TARGET}/release mdrs + # Published alongside the archive so `mdrs selfupdate` can check what it fetched. + sha256sum "${ARCHIVE}" > "${ARCHIVE}.sha256" echo "ARCHIVE=${ARCHIVE}" >> "$GITHUB_ENV" - name: Create release and upload asset uses: akkuman/gitea-release-action@v1 with: token: ${{ github.token }} - files: ${{ env.ARCHIVE }} + files: | + ${{ env.ARCHIVE }} + ${{ env.ARCHIVE }}.sha256 diff --git a/README.md b/README.md index 909bc26..116aa3d 100644 --- a/README.md +++ b/README.md @@ -224,7 +224,8 @@ mdrs version ### selfupdate Update the current `mdrs` binary to the latest published release for -the same build target. +the same build target. The downloaded archive is checked against the `.sha256` +asset the release publishes; a release without one is reported as unverified. ```shell mdrs selfupdate diff --git a/scripts/build-release-linux.sh b/scripts/build-release-linux.sh index 45dd90d..44e2e97 100755 --- a/scripts/build-release-linux.sh +++ b/scripts/build-release-linux.sh @@ -58,7 +58,10 @@ for TARGET in "${TARGETS[@]}"; do ARCHIVE="mdrs-${VERSION}-${TARGET}.tar.gz" tar -czf "${ARCHIVE}" -C "target/${TARGET}/release" mdrs - ARCHIVES+=("${ARCHIVE}") + # Uploaded alongside the archive so `mdrs selfupdate` can check what it fetched. + sha256sum "${ARCHIVE}" > "${ARCHIVE}.sha256" 2>/dev/null \ + || shasum -a 256 "${ARCHIVE}" > "${ARCHIVE}.sha256" + ARCHIVES+=("${ARCHIVE}" "${ARCHIVE}.sha256") echo " Created: ${ARCHIVE}" done diff --git a/scripts/build-release-macos.sh b/scripts/build-release-macos.sh index 51f222c..932dcc3 100755 --- a/scripts/build-release-macos.sh +++ b/scripts/build-release-macos.sh @@ -46,7 +46,10 @@ for TARGET in "${TARGETS[@]}"; do ARCHIVE="mdrs-${VERSION}-${TARGET}.tar.gz" tar -czf "${ARCHIVE}" -C "target/${TARGET}/release" mdrs - ARCHIVES+=("${ARCHIVE}") + # Uploaded alongside the archive so `mdrs selfupdate` can check what it fetched. + sha256sum "${ARCHIVE}" > "${ARCHIVE}.sha256" 2>/dev/null \ + || shasum -a 256 "${ARCHIVE}" > "${ARCHIVE}.sha256" + ARCHIVES+=("${ARCHIVE}" "${ARCHIVE}.sha256") echo " Created: ${ARCHIVE}" done diff --git a/scripts/build-release-windows.ps1 b/scripts/build-release-windows.ps1 index cf8ca98..fb6ed64 100644 --- a/scripts/build-release-windows.ps1 +++ b/scripts/build-release-windows.ps1 @@ -55,6 +55,10 @@ cargo build --release --target $Target $Archive = "mdrs-$Version-$Target.zip" Compress-Archive -Force -Path "target\$Target\release\mdrs.exe" -DestinationPath $Archive +# Written alongside the archive so `mdrs selfupdate` can check what it fetched. +$Checksum = "$Archive.sha256" +$Hash = (Get-FileHash -Algorithm SHA256 -Path $Archive).Hash.ToLower() +"$Hash $Archive" | Set-Content -NoNewline -Encoding ascii $Checksum Write-Host " Created: $Archive" # Upload to Gitea if token is provided @@ -87,14 +91,16 @@ $Release = Invoke-RestMethod -Method Get -Uri "$ServerUrl/api/v1/repos/$Reposito -Headers @{ Authorization = "Bearer $GiteaToken" } $ReleaseId = $Release.id -Write-Host "==> Uploading $Archive (release id: $ReleaseId) ..." -$ArchivePath = Join-Path $RepoRoot $Archive -& curl.exe -sf -X POST ` - -H "Authorization: Bearer $GiteaToken" ` - -F "attachment=@$ArchivePath" ` - "$ServerUrl/api/v1/repos/$Repository/releases/$ReleaseId/assets" | Out-Null -if ($LASTEXITCODE -ne 0) { Write-Error "Upload failed (exit code $LASTEXITCODE)"; exit 1 } -Write-Host " Done." +foreach ($Name in @($Archive, $Checksum)) { + Write-Host "==> Uploading $Name (release id: $ReleaseId) ..." + $AssetPath = Join-Path $RepoRoot $Name + & curl.exe -sf -X POST ` + -H "Authorization: Bearer $GiteaToken" ` + -F "attachment=@$AssetPath" ` + "$ServerUrl/api/v1/repos/$Repository/releases/$ReleaseId/assets" | Out-Null + if ($LASTEXITCODE -ne 0) { Write-Error "Upload failed (exit code $LASTEXITCODE)"; exit 1 } + Write-Host " Done." +} Write-Host "" Write-Host "==> Upload complete: $ServerUrl/$Repository/releases/tag/$Tag" diff --git a/src/commands/selfupdate.rs b/src/commands/selfupdate.rs index 1e41135..50c4212 100644 --- a/src/commands/selfupdate.rs +++ b/src/commands/selfupdate.rs @@ -48,6 +48,25 @@ fn is_newer(current: &str, latest: &str) -> bool { false } +/// Read the digest out of a `sha256sum` line: the hex digest, then the file it covers. +fn parse_sha256_line(text: &str) -> Option { + let digest = text.split_whitespace().next()?.to_ascii_lowercase(); + let is_hex = digest.len() == 64 && digest.chars().all(|c| c.is_ascii_hexdigit()); + is_hex.then_some(digest) +} + +/// Hex SHA-256 of a slice, in the form `sha256sum` prints. +fn sha256_hex(bytes: &[u8]) -> String { + use sha2::{Digest, Sha256}; + let mut hasher = Sha256::new(); + hasher.update(bytes); + hasher + .finalize() + .iter() + .map(|b| format!("{:02x}", b)) + .collect() +} + /// Extract the binary named `bin_name` from a `.tar.gz` archive at `archive_path` /// and write it to `dest_path`. fn extract_from_tar_gz( @@ -138,10 +157,11 @@ pub async fn selfupdate(yes: bool) -> anyhow::Result<()> { println!("New version available: {latest_version}"); // Find the asset matching the current build target. + // The checksum asset carries the target in its name too, so say which one is wanted. let asset = release .assets .iter() - .find(|a| a.name.contains(BUILD_TARGET)) + .find(|a| a.name.contains(BUILD_TARGET) && !a.name.ends_with(".sha256")) .ok_or_else(|| { let names: Vec<&str> = release.assets.iter().map(|a| a.name.as_str()).collect(); anyhow!( @@ -186,6 +206,54 @@ pub async fn selfupdate(yes: bool) -> anyhow::Result<()> { } let bytes = download_resp.bytes().await?; + + // This binary is about to replace the one the user is running, so what arrived has to + // be what the release says it is. Transport already proves who served it; the digest + // proves the archive was not swapped or truncated on the way. + match release + .assets + .iter() + .find(|a| a.name == format!("{}.sha256", asset.name)) + { + Some(checksum_asset) => { + let mut checksum_req = client + .get(&checksum_asset.browser_download_url) + .header(USER_AGENT, format!("mdrs/{current_version}")); + if let Ok(token) = env::var("GITEA_TOKEN") { + checksum_req = checksum_req.header(AUTHORIZATION, format!("Bearer {token}")); + } + let checksum_resp = checksum_req.send().await?; + if !checksum_resp.status().is_success() { + bail!( + "Failed to download checksum: HTTP {}", + checksum_resp.status() + ); + } + let expected = parse_sha256_line(&checksum_resp.text().await?) + .ok_or_else(|| anyhow!("Checksum asset '{}' is malformed", checksum_asset.name))?; + let actual = sha256_hex(&bytes); + if actual != expected { + bail!( + "Checksum mismatch for '{}': expected {}, got {}. The download was not what \ + the release publishes, so this binary has not been replaced.", + asset.name, + expected, + actual + ); + } + println!("Checksum verified."); + } + None => { + // Releases built before the workflow published checksums have none to check + // against. Say so rather than letting the absence pass for a verified download. + eprintln!( + "Warning: release {latest_version} publishes no checksum for '{}', \ + so the download could not be verified.", + asset.name + ); + } + } + std::fs::write(&archive_path, &bytes)?; // Extract the binary from the archive. @@ -216,3 +284,35 @@ pub async fn selfupdate(yes: bool) -> anyhow::Result<()> { println!("Successfully updated to version {latest_version}."); Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_sha256sum_line_yields_its_digest() { + let digest = "a".repeat(64); + assert_eq!( + parse_sha256_line(&format!( + "{digest} mdrs-2.0.1-x86_64-unknown-linux-musl.tar.gz\n" + )), + Some(digest) + ); + } + + #[test] + fn a_line_that_is_not_a_digest_is_refused() { + for text in ["", "not-a-digest file", "abc123 file"] { + assert_eq!(parse_sha256_line(text), None, "{text:?} should be refused"); + } + } + + #[test] + fn the_digest_matches_what_sha256sum_prints() { + // Verified against `printf mdrs | sha256sum`. + assert_eq!( + sha256_hex(b"mdrs"), + "bd9845248df877e411ec8bef10b823bd4c0900a530157eedfdfbfb63ccbde2fa" + ); + } +}