M6: Fail closed on missing/unfetchable SHA256SUMS in the auto-updater #242

Merged
KofTwentyTwo merged 1 commits from story/m6-updater-fail-closed into dev 2026-07-30 17:55:59 +00:00
+168 -23
View File
@@ -40,6 +40,11 @@ pub enum UpdateError {
#[error("checksum file error: {0}")]
ChecksumFileError(String),
/// No checksum could be positively verified for the download, either because the release
/// has no `SHA256SUMS.txt` asset or because that asset could not be fetched.
#[error("checksum unavailable: {0}")]
ChecksumUnavailable(String),
/// Archive unpacking failure (tar.gz or zip).
#[error("archive extraction failed: {0}")]
ArchiveError(String),
@@ -223,10 +228,41 @@ impl UpdateEngine {
}
/// Download release package bytes and verify SHA-256 checksum against `SHA256SUMS.txt`.
///
/// This is the supply-chain trust boundary for the auto-updater: the only way to reach
/// `Ok` is an exact SHA-256 match against a successfully fetched `SHA256SUMS.txt`. Any
/// situation where the checksum cannot be positively verified — no checksum asset on the
/// release, a failed fetch of that asset, a missing filename entry, or a hash mismatch —
/// must fail closed rather than silently returning unverified bytes.
pub async fn download_and_verify(
&self,
release_info: &ReleaseInfo,
) -> Result<Vec<u8>, UpdateError> {
// Refuse before spending bandwidth on the archive if there is no checksum asset to
// verify it against.
let checksum_url = release_info.checksum_url.as_ref().ok_or_else(|| {
UpdateError::ChecksumUnavailable(
"no SHA256SUMS.txt asset found for this release; refusing to apply an unverified update"
.to_string(),
)
})?;
let sums_resp = self.client.get(checksum_url).send().await?;
if !sums_resp.status().is_success() {
return Err(UpdateError::ChecksumUnavailable(format!(
"failed to fetch SHA256SUMS.txt (HTTP status {}); refusing to apply an unverified update",
sums_resp.status()
)));
}
let sums_text = sums_resp.text().await?;
let expected_hash = parse_sha256sums(&sums_text, &release_info.archive_filename)
.ok_or_else(|| {
UpdateError::ChecksumFileError(format!(
"Filename '{}' not found in SHA256SUMS.txt",
release_info.archive_filename
))
})?;
// Download main binary package archive
let archive_resp = self.client.get(&release_info.download_url).send().await?;
if !archive_resp.status().is_success() {
@@ -237,29 +273,13 @@ impl UpdateEngine {
}
let archive_bytes = archive_resp.bytes().await?.to_vec();
// Verify SHA-256 checksum if SHA256SUMS.txt asset is available
if let Some(checksum_url) = &release_info.checksum_url {
let sums_resp = self.client.get(checksum_url).send().await?;
if sums_resp.status().is_success() {
let sums_text = sums_resp.text().await?;
if let Some(expected_hash) =
parse_sha256sums(&sums_text, &release_info.archive_filename)
{
let actual_hash = compute_sha256(&archive_bytes);
if expected_hash.to_lowercase() != actual_hash.to_lowercase() {
return Err(UpdateError::ChecksumMismatch {
filename: release_info.archive_filename.clone(),
expected: expected_hash,
actual: actual_hash,
});
}
} else {
return Err(UpdateError::ChecksumFileError(format!(
"Filename '{}' not found in SHA256SUMS.txt",
release_info.archive_filename
)));
}
}
let actual_hash = compute_sha256(&archive_bytes);
if expected_hash.to_lowercase() != actual_hash.to_lowercase() {
return Err(UpdateError::ChecksumMismatch {
filename: release_info.archive_filename.clone(),
expected: expected_hash,
actual: actual_hash,
});
}
Ok(archive_bytes)
@@ -660,4 +680,129 @@ e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 nuncio-x86_64-
assert_eq!(info.release_notes, "Major security release");
assert!(info.download_url.contains(&archive_filename));
}
fn release_info_for(
mock_server: &MockServer,
checksum_url: Option<String>,
archive_filename: &str,
) -> ReleaseInfo {
ReleaseInfo {
version: "99.0.0".to_string(),
tag_name: "v99.0.0".to_string(),
release_notes: String::new(),
download_url: format!("{}/download/{archive_filename}", mock_server.uri()),
checksum_url,
archive_filename: archive_filename.to_string(),
}
}
#[tokio::test]
async fn download_and_verify_fails_closed_without_checksum_asset() {
let mock_server = MockServer::start().await;
let archive_filename = "nuncio-x86_64-unknown-linux-gnu.tar.gz";
let archive_bytes = b"archive payload".to_vec();
Mock::given(method("GET"))
.and(path(format!("/download/{archive_filename}")))
.respond_with(ResponseTemplate::new(200).set_body_bytes(archive_bytes))
.mount(&mock_server)
.await;
let updater = UpdateEngine::with_base_url(mock_server.uri()).unwrap();
let info = release_info_for(&mock_server, None, archive_filename);
let result = updater.download_and_verify(&info).await;
assert!(matches!(result, Err(UpdateError::ChecksumUnavailable(_))));
}
#[tokio::test]
async fn download_and_verify_fails_closed_on_sums_fetch_error() {
let mock_server = MockServer::start().await;
let archive_filename = "nuncio-x86_64-unknown-linux-gnu.tar.gz";
let archive_bytes = b"archive payload".to_vec();
Mock::given(method("GET"))
.and(path(format!("/download/{archive_filename}")))
.respond_with(ResponseTemplate::new(200).set_body_bytes(archive_bytes))
.mount(&mock_server)
.await;
Mock::given(method("GET"))
.and(path("/download/SHA256SUMS.txt"))
.respond_with(ResponseTemplate::new(404))
.mount(&mock_server)
.await;
let updater = UpdateEngine::with_base_url(mock_server.uri()).unwrap();
let info = release_info_for(
&mock_server,
Some(format!("{}/download/SHA256SUMS.txt", mock_server.uri())),
archive_filename,
);
let result = updater.download_and_verify(&info).await;
assert!(matches!(result, Err(UpdateError::ChecksumUnavailable(_))));
}
#[tokio::test]
async fn download_and_verify_fails_closed_on_hash_mismatch() {
let mock_server = MockServer::start().await;
let archive_filename = "nuncio-x86_64-unknown-linux-gnu.tar.gz";
let archive_bytes = b"archive payload".to_vec();
let wrong_hash = compute_sha256(b"not the archive bytes");
let sums_text = format!("{wrong_hash} {archive_filename}\n");
Mock::given(method("GET"))
.and(path(format!("/download/{archive_filename}")))
.respond_with(ResponseTemplate::new(200).set_body_bytes(archive_bytes))
.mount(&mock_server)
.await;
Mock::given(method("GET"))
.and(path("/download/SHA256SUMS.txt"))
.respond_with(ResponseTemplate::new(200).set_body_string(sums_text))
.mount(&mock_server)
.await;
let updater = UpdateEngine::with_base_url(mock_server.uri()).unwrap();
let info = release_info_for(
&mock_server,
Some(format!("{}/download/SHA256SUMS.txt", mock_server.uri())),
archive_filename,
);
let result = updater.download_and_verify(&info).await;
assert!(matches!(result, Err(UpdateError::ChecksumMismatch { .. })));
}
#[tokio::test]
async fn download_and_verify_succeeds_on_matching_checksum() {
let mock_server = MockServer::start().await;
let archive_filename = "nuncio-x86_64-unknown-linux-gnu.tar.gz";
let archive_bytes = b"archive payload".to_vec();
let correct_hash = compute_sha256(&archive_bytes);
let sums_text = format!("{correct_hash} {archive_filename}\n");
Mock::given(method("GET"))
.and(path(format!("/download/{archive_filename}")))
.respond_with(ResponseTemplate::new(200).set_body_bytes(archive_bytes.clone()))
.mount(&mock_server)
.await;
Mock::given(method("GET"))
.and(path("/download/SHA256SUMS.txt"))
.respond_with(ResponseTemplate::new(200).set_body_string(sums_text))
.mount(&mock_server)
.await;
let updater = UpdateEngine::with_base_url(mock_server.uri()).unwrap();
let info = release_info_for(
&mock_server,
Some(format!("{}/download/SHA256SUMS.txt", mock_server.uri())),
archive_filename,
);
let result = updater
.download_and_verify(&info)
.await
.expect("checksum matches, download succeeds");
assert_eq!(result, archive_bytes);
}
}