aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-08-07 14:40:14 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-08-07 14:40:14 +0300
commit322ab6a5a98db5c230b7c90881b8b4baf4cee624 (patch)
treea64ad1b46bc0ef0a0748ccad8409c5e7e97cb7ae /src
parent69a07fdf061a35276295189d52d591893c429806 (diff)
fix: verify stable release checksums
Diffstat (limited to 'src')
-rw-r--r--src/remote/attach.rs44
-rw-r--r--src/update.rs68
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}"