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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user