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.
This commit is contained in:
2026-09-04 16:31:02 +09:00
parent e3026bdfcf
commit c24a285cf5
6 changed files with 135 additions and 14 deletions
+10 -2
View File
@@ -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
+2 -1
View File
@@ -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
+4 -1
View File
@@ -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
+4 -1
View File
@@ -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
+14 -8
View File
@@ -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"
+101 -1
View File
@@ -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<String> {
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"
);
}
}