diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-08-07 14:40:14 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-08-07 14:40:14 +0300 |
| commit | 322ab6a5a98db5c230b7c90881b8b4baf4cee624 (patch) | |
| tree | a64ad1b46bc0ef0a0748ccad8409c5e7e97cb7ae /src | |
| parent | 69a07fdf061a35276295189d52d591893c429806 (diff) | |
fix: verify stable release checksums
Diffstat (limited to 'src')
| -rw-r--r-- | src/remote/attach.rs | 44 | ||||
| -rw-r--r-- | src/update.rs | 68 |
2 files changed, 90 insertions, 22 deletions
diff --git a/src/remote/attach.rs b/src/remote/attach.rs index 4121d468..55124217 100644 --- a/src/remote/attach.rs +++ b/src/remote/attach.rs @@ -298,6 +298,8 @@ struct RemoteUpdateManifest { version: String, protocol: Option<u32>, assets: BTreeMap<String, RemoteAssetRef>, + #[serde(default)] + sha256: BTreeMap<String, String>, #[serde(default, deserialize_with = "deserialize_remote_manifest_releases")] releases: BTreeMap<String, RemoteReleaseMetadata>, } @@ -307,6 +309,8 @@ struct RemoteReleaseMetadata { protocol: Option<u32>, #[serde(default)] assets: BTreeMap<String, RemoteAssetRef>, + #[serde(default)] + sha256: BTreeMap<String, String>, } #[derive(Deserialize)] @@ -350,6 +354,7 @@ impl RemoteUpdateManifest { return Some(RemoteManifestReleaseRef { protocol: self.protocol, assets: &self.assets, + sha256: &self.sha256, }); } @@ -357,6 +362,7 @@ impl RemoteUpdateManifest { (!release.assets.is_empty()).then_some(RemoteManifestReleaseRef { protocol: release.protocol, assets: &release.assets, + sha256: &release.sha256, }) }) } @@ -366,6 +372,7 @@ impl RemoteUpdateManifest { struct RemoteManifestReleaseRef<'a> { protocol: Option<u32>, assets: &'a BTreeMap<String, RemoteAssetRef>, + sha256: &'a BTreeMap<String, String>, } fn current_version() -> String { @@ -1531,15 +1538,21 @@ fn remote_release_asset(asset_key: &str) -> io::Result<RemoteReleaseAsset> { ))); } } - release - .assets - .get(asset_key) - .map(remote_asset_info) - .ok_or_else(|| { - io::Error::other(format!( - "no {asset_key} binary in the release manifest for herdr {current_version}" - )) - }) + let asset = release.assets.get(asset_key).ok_or_else(|| { + io::Error::other(format!( + "no {asset_key} binary in the release manifest for herdr {current_version}" + )) + })?; + let mut asset = remote_asset_info(asset); + asset.sha256 = asset + .sha256 + .or_else(|| release.sha256.get(asset_key).cloned()); + if asset.sha256.is_none() { + return Err(io::Error::other(format!( + "release manifest asset {asset_key} is missing a SHA-256 checksum" + ))); + } + Ok(asset) } fn private_download_dir(asset_key: &str) -> io::Result<PathBuf> { @@ -2891,6 +2904,9 @@ mod tests { "assets": { "linux-x86_64": "https://example.com/latest" }, + "sha256": { + "linux-x86_64": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, "releases": { "1.2.3": { "assets": { @@ -2902,13 +2918,15 @@ mod tests { ) .unwrap(); + let release = manifest.release_for_version("1.2.3").unwrap(); assert_eq!( - manifest - .release_for_version("1.2.3") - .and_then(|release| release.assets.get("linux-x86_64")) - .map(RemoteAssetRef::url), + release.assets.get("linux-x86_64").map(RemoteAssetRef::url), Some("https://example.com/latest") ); + assert_eq!( + release.sha256.get("linux-x86_64").map(String::as_str), + Some("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + ); } #[test] diff --git a/src/update.rs b/src/update.rs index 2120fb3c..abc9663f 100644 --- a/src/update.rs +++ b/src/update.rs @@ -169,6 +169,8 @@ struct UpdateManifest { protocol: Option<u32>, notes: String, assets: BTreeMap<String, AssetRef>, + #[serde(default)] + sha256: BTreeMap<String, String>, announcement: Option<serde_json::Value>, #[serde(default, deserialize_with = "deserialize_manifest_releases")] releases: BTreeMap<String, serde_json::Value>, @@ -362,6 +364,13 @@ fn release_info_from_manifest(manifest: &UpdateManifest) -> Result<Option<Releas .get(&asset_key) .ok_or_else(|| format!("no binary for {asset_key} in update manifest"))?; let download_url = asset.url.clone(); + let sha256 = asset + .sha256 + .clone() + .or_else(|| manifest.sha256.get(&asset_key).cloned()) + .ok_or_else(|| { + format!("update manifest asset {asset_key} is missing a SHA-256 checksum") + })?; Ok(Some(ReleaseInfo { identity: latest.to_string(), @@ -372,7 +381,7 @@ fn release_info_from_manifest(manifest: &UpdateManifest) -> Result<Option<Releas #[cfg(not(windows))] target_protocol: manifest.protocol, download_url, - sha256: asset.sha256.clone(), + sha256: Some(sha256), notes_body, })) } @@ -3334,6 +3343,26 @@ mod tests { } #[test] + fn stable_update_requires_asset_checksum() { + let (os, arch) = platform_target(); + let asset_key = format!("{os}-{arch}"); + let json = format!( + r####"{{ + "version": "99.99.99", + "notes": "### Changed\n- One", + "assets": {{ + "{asset_key}": "https://example.com/herdr" + }} + }}"#### + ); + let manifest: UpdateManifest = serde_json::from_str(&json).unwrap(); + + assert!(release_info_from_manifest(&manifest) + .unwrap_err() + .contains("missing a SHA-256 checksum")); + } + + #[test] fn invalid_manifest_announcement_does_not_block_release_info() { let (os, arch) = platform_target(); let asset_key = format!("{os}-{arch}"); @@ -3348,7 +3377,10 @@ mod tests { "body": "### Heads up\n- Defaults changed" }}, "assets": {{ - "{asset_key}": "https://example.com/herdr" + "{asset_key}": {{ + "url": "https://example.com/herdr", + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }} }} }}"#### ); @@ -3428,8 +3460,18 @@ mod tests { #[test] fn checked_in_website_manifest_matches_update_schema() { - let manifest: UpdateManifest = serde_json::from_str(include_str!("../website/latest.json")) - .expect("website/latest.json should match updater schema"); + #[derive(Deserialize)] + struct LegacyUpdateManifest { + assets: BTreeMap<String, String>, + } + + let json = include_str!("../website/latest.json"); + let legacy: LegacyUpdateManifest = serde_json::from_str(json) + .expect("website/latest.json should keep legacy string asset URLs"); + assert_eq!(legacy.assets.len(), 4); + + let manifest: UpdateManifest = + serde_json::from_str(json).expect("website/latest.json should match updater schema"); assert!(!manifest .metadata_for_version(&Version::parse(&manifest.version).unwrap()) @@ -3449,11 +3491,16 @@ mod tests { "macos-x86_64", "macos-aarch64", ] { - let url = &manifest + let asset = manifest .assets .get(target) - .unwrap_or_else(|| panic!("missing asset URL for {target}")) - .url; + .unwrap_or_else(|| panic!("missing asset URL for {target}")); + let url = &asset.url; + assert_eq!( + manifest.sha256.get(target).map(String::len), + Some(64), + "missing SHA-256 checksum for {target}" + ); assert!( url.contains(&format!("/releases/download/v{}/", manifest.version)), "unexpected release URL for {target}: {url}" @@ -3475,10 +3522,13 @@ mod tests { "macos-x86_64", "macos-aarch64", ] { - let url = assets + let asset = assets .get(target) - .and_then(serde_json::Value::as_str) + .cloned() .unwrap_or_else(|| panic!("missing asset URL for {version} {target}")); + let asset: AssetRef = serde_json::from_value(asset) + .unwrap_or_else(|_| panic!("invalid asset for {version} {target}")); + let url = &asset.url; assert!( url.contains(&format!("/releases/download/v{version}/")), "unexpected release URL for {version} {target}: {url}" |
