use super::types::{
UpdateCheck, UpdateError, current_release_archive_extension, current_release_targets,
current_target,
};
use super::verify::{verify_checksum, verify_checksum_and_signature};
use crate::ui::OutputContext;
use std::path::{Component, Path, PathBuf};
use std::time::Duration;
use tokio::io::AsyncWriteExt;
#[allow(dead_code)]
pub type DownloadProgress = Box<dyn Fn(u64, u64) + Send>;
#[allow(dead_code)]
pub struct DownloadedRelease {
pub archive_path: PathBuf,
pub checksum_verified: bool,
pub signature_verified: Option<bool>,
pub version: String,
_download_dir: UpdateDownloadDir,
}
struct UpdateDownloadDir {
path: PathBuf,
}
impl UpdateDownloadDir {
fn new() -> Self {
Self {
path: std::env::temp_dir().join(format!("rch-update-{}", uuid::Uuid::new_v4())),
}
}
fn path(&self) -> &Path {
&self.path
}
}
impl Drop for UpdateDownloadDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.path);
}
}
fn should_enforce_signature(has_bundle: bool, skip_verify: bool, cosign_available: bool) -> bool {
has_bundle && !skip_verify && cosign_available
}
pub async fn download_release(
ctx: &OutputContext,
update: &UpdateCheck,
skip_verify: bool,
) -> Result<DownloadedRelease, UpdateError> {
let archive_asset = find_archive_asset(&update.assets, &update.latest_version.to_string())?;
let checksum_asset = find_checksum_asset(&update.assets, archive_asset);
if !ctx.is_json() {
println!(
"Downloading {} ({} bytes)...",
archive_asset.name, archive_asset.size
);
}
let temp_dir = UpdateDownloadDir::new();
tokio::fs::create_dir(temp_dir.path())
.await
.map_err(|e| UpdateError::DownloadFailed(format!("Failed to create temp dir: {}", e)))?;
let archive_path = asset_temp_path(temp_dir.path(), &archive_asset.name)?;
download_with_retry(
&archive_asset.browser_download_url,
&archive_path,
&archive_asset.name,
MAX_DOWNLOAD_ATTEMPTS,
)
.await?;
let (checksum_verified, signature_verified) = if let Some(checksum_asset) = checksum_asset {
if !ctx.is_json() {
println!("Verifying checksum...");
}
let checksum_path = asset_temp_path(temp_dir.path(), &checksum_asset.name)?;
download_with_retry(
&checksum_asset.browser_download_url,
&checksum_path,
&checksum_asset.name,
MAX_DOWNLOAD_ATTEMPTS,
)
.await?;
let expected_checksum = extract_checksum(&checksum_path, &archive_asset.name).await?;
let minisign_asset = update
.assets
.iter()
.find(|a| a.name == format!("{}.minisig", archive_asset.name));
let minisign_verified = match minisign_asset {
Some(sig_asset) if !skip_verify => {
if !ctx.is_json() {
println!("Verifying signature (minisign)...");
}
let sig_path = asset_temp_path(temp_dir.path(), &sig_asset.name)?;
download_with_retry(
&sig_asset.browser_download_url,
&sig_path,
&sig_asset.name,
MAX_DOWNLOAD_ATTEMPTS,
)
.await?;
let signature = tokio::fs::read_to_string(&sig_path).await.map_err(|e| {
UpdateError::SignatureVerificationFailed(format!(
"cannot read {}: {e}",
sig_asset.name
))
})?;
super::verify::verify_minisign(&archive_path, &signature)?;
Some(true)
}
Some(_) => {
if !ctx.is_json() {
println!(
"Warning: skipping minisign verification (--skip-verify); checksum still enforced"
);
}
None
}
None => None,
};
let signature_bundle_asset = update
.assets
.iter()
.find(|a| a.name == format!("{}.sigstore.json", archive_asset.name));
let cosign_available = which::which("cosign").is_ok();
let signature_bundle_path = match signature_bundle_asset {
Some(sig_asset) if should_enforce_signature(true, skip_verify, cosign_available) => {
if !ctx.is_json() {
println!("Verifying signature (sigstore bundle)...");
}
let sig_path = asset_temp_path(temp_dir.path(), &sig_asset.name)?;
download_with_retry(
&sig_asset.browser_download_url,
&sig_path,
&sig_asset.name,
MAX_DOWNLOAD_ATTEMPTS,
)
.await?;
Some(sig_path)
}
Some(_) => {
if !ctx.is_json() {
if skip_verify {
println!(
"Warning: skipping signature verification (--skip-verify); checksum still enforced"
);
} else {
println!(
"Warning: cosign not found in PATH; skipping signature verification (checksum still enforced)"
);
}
}
None
}
None => {
if !ctx.is_json() && minisign_verified.is_none() {
println!(
"Warning: No signature (minisign or sigstore) available; checksum only"
);
}
None
}
};
let verification = if let Some(bundle_path) = signature_bundle_path.as_deref() {
verify_checksum_and_signature(&archive_path, &expected_checksum, Some(bundle_path))
.await?
} else {
verify_checksum(&archive_path, &expected_checksum).await?
};
(
verification.checksum_valid,
verification.signature_valid.or(minisign_verified),
)
} else {
if !ctx.is_json() {
println!("Warning: No checksum file available for this release asset");
}
(false, None)
};
Ok(DownloadedRelease {
archive_path,
checksum_verified,
signature_verified,
version: update.latest_version.to_string(),
_download_dir: temp_dir,
})
}
const MAX_DOWNLOAD_ATTEMPTS: u32 = 4;
const DOWNLOAD_BACKOFF: [Duration; 3] = [
Duration::from_secs(2),
Duration::from_secs(5),
Duration::from_secs(15),
];
async fn download_with_retry(
url: &str,
dest: &PathBuf,
asset_name: &str,
max_attempts: u32,
) -> Result<(), UpdateError> {
let mut last_err: Option<UpdateError> = None;
for attempt in 1..=max_attempts {
match download_file(url, dest).await {
Ok(()) => return Ok(()),
Err(e) if is_transient_error(&e) && attempt < max_attempts => {
let wait = DOWNLOAD_BACKOFF
.get((attempt - 1) as usize)
.copied()
.unwrap_or_else(|| DOWNLOAD_BACKOFF[DOWNLOAD_BACKOFF.len() - 1]);
tracing::warn!(
"Download of {asset_name} attempt {attempt}/{max_attempts} failed ({e}); \
retrying in {wait:?}"
);
last_err = Some(e);
tokio::time::sleep(wait).await;
}
Err(e) => return Err(e),
}
}
Err(last_err.unwrap_or_else(|| {
UpdateError::DownloadFailed(format!(
"Failed to download {asset_name} after {max_attempts} attempts"
))
}))
}
async fn download_file(url: &str, dest: &PathBuf) -> Result<(), UpdateError> {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(300)) .connect_timeout(Duration::from_secs(30))
.build()
.map_err(|e| UpdateError::NetworkError(e.to_string()))?;
let response = client
.get(url)
.header("User-Agent", format!("rch/{}", env!("CARGO_PKG_VERSION")))
.send()
.await
.map_err(|e| UpdateError::NetworkError(e.to_string()))?;
let status = response.status();
if !status.is_success() {
return Err(classify_http_status(status));
}
let mut file = tokio::fs::File::create(dest)
.await
.map_err(|e| UpdateError::DownloadFailed(format!("Failed to create file: {}", e)))?;
let bytes = response
.bytes()
.await
.map_err(|e| UpdateError::NetworkError(e.to_string()))?;
file.write_all(&bytes)
.await
.map_err(|e| UpdateError::DownloadFailed(format!("Failed to write file: {}", e)))?;
Ok(())
}
fn classify_http_status(status: reqwest::StatusCode) -> UpdateError {
if is_retryable_status(status) {
UpdateError::NetworkError(format!("Server returned {status} (transient)"))
} else {
UpdateError::DownloadFailed(format!("Server returned {status}"))
}
}
fn is_retryable_status(status: reqwest::StatusCode) -> bool {
status.is_server_error() || status == reqwest::StatusCode::TOO_MANY_REQUESTS
}
fn is_transient_error(e: &UpdateError) -> bool {
matches!(e, UpdateError::NetworkError(_))
}
fn asset_temp_path(temp_dir: &Path, asset_name: &str) -> Result<PathBuf, UpdateError> {
if !is_safe_asset_name(asset_name) {
return Err(UpdateError::DownloadFailed(format!(
"Unsafe release asset name: {}",
asset_name.escape_debug()
)));
}
Ok(temp_dir.join(asset_name))
}
fn is_safe_asset_name(asset_name: &str) -> bool {
!asset_name.is_empty()
&& !asset_name.contains('/')
&& !asset_name.contains('\\')
&& !asset_name.contains('\0')
&& Path::new(asset_name)
.components()
.all(|component| matches!(component, Component::Normal(_)))
}
fn archive_asset_candidates(version: &str) -> Vec<String> {
let version = version.trim_start_matches('v');
let version_tag = format!("v{version}");
let extension = current_release_archive_extension();
let mut candidates = Vec::new();
for target in current_release_targets() {
candidates.push(format!("rch-{version_tag}-{target}{extension}"));
candidates.push(format!("rch-{target}{extension}"));
}
candidates.dedup();
candidates
}
fn find_archive_asset<'a>(
assets: &'a [super::types::ReleaseAsset],
version: &str,
) -> Result<&'a super::types::ReleaseAsset, UpdateError> {
for candidate in archive_asset_candidates(version) {
if let Some(asset) = assets.iter().find(|asset| asset.name == candidate) {
return Ok(asset);
}
}
let extension = current_release_archive_extension();
let release_targets = current_release_targets();
if let Some(asset) = assets.iter().find(|asset| {
asset.name.ends_with(extension)
&& release_targets
.iter()
.any(|target| asset.name.contains(target))
}) {
return Ok(asset);
}
Err(UpdateError::UnsupportedPlatform(format!(
"{} (release assets: {})",
current_target(),
release_targets.join(", ")
)))
}
fn find_checksum_asset<'a>(
assets: &'a [super::types::ReleaseAsset],
archive_asset: &super::types::ReleaseAsset,
) -> Option<&'a super::types::ReleaseAsset> {
let checksum_candidates = [
format!("{}.sha256", archive_asset.name),
"SHA256SUMS".to_string(),
"checksums.txt".to_string(),
];
checksum_candidates
.iter()
.find_map(|name| assets.iter().find(|asset| asset.name == *name))
}
async fn extract_checksum(checksum_file: &Path, filename: &str) -> Result<String, UpdateError> {
let content = tokio::fs::read_to_string(checksum_file)
.await
.map_err(|e| UpdateError::DownloadFailed(format!("Failed to read checksum file: {}", e)))?;
let mut non_empty_lines = content
.lines()
.map(str::trim)
.filter(|line| !line.is_empty());
if let (Some(line), None) = (non_empty_lines.next(), non_empty_lines.next()) {
let mut parts = line.split_whitespace();
if let (Some(checksum), None) = (parts.next(), parts.next()) {
return Ok(checksum.to_string());
}
}
for line in content
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
{
let mut parts = line.split_whitespace();
let Some(checksum) = parts.next() else {
continue;
};
let Some(file) = parts.next_back() else {
continue;
};
let file_basename = Path::new(file)
.file_name()
.and_then(|s| s.to_str())
.unwrap_or(file);
let win_basename = file.rsplit_once('\\').map(|(_, tail)| tail).unwrap_or(file);
if file == filename || file_basename == filename || win_basename == filename {
return Ok(checksum.to_string());
}
}
Err(UpdateError::DownloadFailed(format!(
"Checksum not found for {}",
filename
)))
}
#[cfg(test)]
mod tests {
use super::super::types::ReleaseAsset;
use super::*;
use tempfile::TempDir;
#[test]
fn signature_enforced_only_with_bundle_cosign_and_no_skip() {
assert!(should_enforce_signature(true, false, true));
assert!(!should_enforce_signature(false, false, true));
assert!(!should_enforce_signature(true, true, true));
assert!(!should_enforce_signature(true, false, false));
}
fn test_asset(name: &str) -> ReleaseAsset {
ReleaseAsset {
name: name.to_string(),
browser_download_url: format!("https://example.invalid/{name}"),
size: 1,
content_type: "application/octet-stream".to_string(),
}
}
#[tokio::test]
async fn test_extract_checksum_standard_format() {
let temp = TempDir::new().unwrap();
let checksum_file = temp.path().join("checksums.txt");
std::fs::write(
&checksum_file,
"abc123 rch-v0.1.0-linux.tar.gz\ndef456 rch-v0.1.0-darwin.tar.gz",
)
.unwrap();
let result =
extract_checksum(&checksum_file.to_path_buf(), "rch-v0.1.0-linux.tar.gz").await;
assert_eq!(result.unwrap(), "abc123");
}
#[tokio::test]
async fn test_extract_checksum_not_found() {
let temp = TempDir::new().unwrap();
let checksum_file = temp.path().join("checksums.txt");
std::fs::write(&checksum_file, "abc123 other-file.tar.gz").unwrap();
let result =
extract_checksum(&checksum_file.to_path_buf(), "rch-v0.1.0-linux.tar.gz").await;
assert!(result.is_err());
}
#[test]
fn test_is_transient_error() {
assert!(is_transient_error(&UpdateError::NetworkError(
"timeout".to_string()
)));
assert!(!is_transient_error(&UpdateError::ChecksumMismatch {
expected: "a".to_string(),
actual: "b".to_string()
}));
}
#[test]
fn test_is_retryable_status_classifier() {
use reqwest::StatusCode;
assert!(is_retryable_status(StatusCode::BAD_GATEWAY)); assert!(is_retryable_status(StatusCode::SERVICE_UNAVAILABLE)); assert!(is_retryable_status(StatusCode::GATEWAY_TIMEOUT)); assert!(is_retryable_status(StatusCode::INTERNAL_SERVER_ERROR)); assert!(is_retryable_status(StatusCode::TOO_MANY_REQUESTS));
assert!(!is_retryable_status(StatusCode::NOT_FOUND)); assert!(!is_retryable_status(StatusCode::FORBIDDEN)); assert!(!is_retryable_status(StatusCode::BAD_REQUEST)); assert!(!is_retryable_status(StatusCode::UNAUTHORIZED)); }
#[test]
fn test_classify_http_status_maps_to_retryable_variant() {
use reqwest::StatusCode;
let e = classify_http_status(StatusCode::GATEWAY_TIMEOUT);
assert!(
matches!(e, UpdateError::NetworkError(_)),
"504 should be a retryable NetworkError, got {e:?}"
);
assert!(is_transient_error(&e));
let e = classify_http_status(StatusCode::TOO_MANY_REQUESTS);
assert!(is_transient_error(&e));
let e = classify_http_status(StatusCode::NOT_FOUND);
assert!(
matches!(e, UpdateError::DownloadFailed(_)),
"404 should be a terminal DownloadFailed, got {e:?}"
);
assert!(!is_transient_error(&e));
}
#[test]
fn test_checksum_mismatch_is_not_transient() {
let mismatch = UpdateError::ChecksumMismatch {
expected: "aaaa".to_string(),
actual: "bbbb".to_string(),
};
assert!(!is_transient_error(&mismatch));
let fetch_504 = classify_http_status(reqwest::StatusCode::GATEWAY_TIMEOUT);
assert!(is_transient_error(&fetch_504));
}
#[test]
fn test_download_backoff_is_bounded() {
let total: Duration = DOWNLOAD_BACKOFF.iter().copied().sum();
assert_eq!(DOWNLOAD_BACKOFF.len() as u32, MAX_DOWNLOAD_ATTEMPTS - 1);
assert!(
total <= Duration::from_secs(45),
"cumulative backoff {total:?} exceeds the ~45s budget"
);
}
#[tokio::test]
async fn test_download_with_retry_does_not_retry_terminal_error() {
let terminal = UpdateError::DownloadFailed("Server returned 404 Not Found".to_string());
assert!(!is_transient_error(&terminal));
}
#[test]
fn test_asset_temp_path_accepts_plain_filename() {
let temp = TempDir::new().unwrap();
let name = "rch-v1.0.0-x86_64-unknown-linux-musl.tar.gz";
let path = asset_temp_path(temp.path(), name).unwrap();
assert_eq!(path, temp.path().join(name));
}
#[test]
fn test_asset_temp_path_rejects_path_like_names() {
let temp = TempDir::new().unwrap();
for name in [
"",
".",
"..",
"../rch.tar.gz",
"release/rch.tar.gz",
"release\\rch.tar.gz",
"/tmp/rch.tar.gz",
"rch.tar.gz\0.sha256",
] {
let result = asset_temp_path(temp.path(), name);
assert!(
matches!(result, Err(UpdateError::DownloadFailed(_))),
"expected {name:?} to be rejected"
);
}
}
#[test]
fn test_update_download_dir_is_unique_and_scoped_to_temp() {
let first = UpdateDownloadDir::new();
let second = UpdateDownloadDir::new();
assert_ne!(first.path(), second.path());
assert!(first.path().starts_with(std::env::temp_dir()));
assert!(second.path().starts_with(std::env::temp_dir()));
assert!(
first
.path()
.file_name()
.unwrap()
.to_string_lossy()
.starts_with("rch-update-")
);
}
#[test]
fn test_update_download_dir_cleans_on_drop() {
let path = {
let dir = UpdateDownloadDir::new();
std::fs::create_dir(dir.path()).unwrap();
std::fs::write(dir.path().join("archive.tar.gz"), "partial download").unwrap();
dir.path().to_path_buf()
};
assert!(
!path.exists(),
"download temp directory should be removed when release handle is dropped"
);
}
#[tokio::test]
async fn test_extract_checksum_single_file() {
let temp = TempDir::new().unwrap();
let checksum_file = temp.path().join("rch.tar.gz.sha256");
std::fs::write(&checksum_file, "abc123def456\n").unwrap();
let result = extract_checksum(&checksum_file.to_path_buf(), "rch.tar.gz").await;
assert_eq!(result.unwrap(), "abc123def456");
}
#[tokio::test]
async fn test_extract_checksum_with_path_prefix() {
let temp = TempDir::new().unwrap();
let checksum_file = temp.path().join("checksums.txt");
std::fs::write(
&checksum_file,
"abc123 ./release/rch-v0.1.0-linux.tar.gz\ndef456 ./release/rch-v0.1.0-darwin.tar.gz",
)
.unwrap();
let result =
extract_checksum(&checksum_file.to_path_buf(), "rch-v0.1.0-linux.tar.gz").await;
assert_eq!(result.unwrap(), "abc123");
}
#[tokio::test]
async fn test_extract_checksum_rejects_nondelimited_suffix() {
let temp = TempDir::new().unwrap();
let checksum_file = temp.path().join("checksums.txt");
std::fs::write(
&checksum_file,
"ATTACKER_HASH pfxrch-v0.1.0-linux.tar.gz\n\
LEGIT_HASH rch-v0.1.0-linux.tar.gz\n",
)
.unwrap();
let result =
extract_checksum(&checksum_file.to_path_buf(), "rch-v0.1.0-linux.tar.gz").await;
assert_eq!(
result.unwrap(),
"LEGIT_HASH",
"non-delimited suffix must not satisfy basename match"
);
}
#[tokio::test]
async fn test_extract_checksum_windows_backslash_basename() {
let temp = TempDir::new().unwrap();
let checksum_file = temp.path().join("checksums.txt");
std::fs::write(
&checksum_file,
"winhash release\\rch-v0.1.0-x86_64-pc-windows-msvc.zip\n",
)
.unwrap();
let result = extract_checksum(
&checksum_file.to_path_buf(),
"rch-v0.1.0-x86_64-pc-windows-msvc.zip",
)
.await;
assert_eq!(result.unwrap(), "winhash");
}
#[test]
fn test_is_transient_error_comprehensive() {
assert!(is_transient_error(&UpdateError::NetworkError(
"connection reset".to_string()
)));
assert!(is_transient_error(&UpdateError::NetworkError(
"timeout".to_string()
)));
assert!(!is_transient_error(&UpdateError::DownloadFailed(
"404".to_string()
)));
assert!(!is_transient_error(&UpdateError::InstallFailed(
"permission denied".to_string()
)));
assert!(!is_transient_error(&UpdateError::LockHeld));
assert!(!is_transient_error(&UpdateError::NoBackupAvailable));
assert!(!is_transient_error(&UpdateError::InvalidVersion(
"bad".to_string()
)));
assert!(!is_transient_error(&UpdateError::UnsupportedPlatform(
"unknown".to_string()
)));
}
#[tokio::test]
async fn test_extract_checksum_multiline_format() {
let temp = TempDir::new().unwrap();
let checksum_file = temp.path().join("SHA256SUMS");
std::fs::write(
&checksum_file,
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 rch-linux-amd64.tar.gz\n\
d7a8fbb307d7809469ca9abcb0082e4f8d5651e46d3cdb762d02d0bf37c9e592 rch-darwin-amd64.tar.gz\n\
9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08 rch-linux-arm64.tar.gz",
)
.unwrap();
let result =
extract_checksum(&checksum_file.to_path_buf(), "rch-darwin-amd64.tar.gz").await;
assert_eq!(
result.unwrap(),
"d7a8fbb307d7809469ca9abcb0082e4f8d5651e46d3cdb762d02d0bf37c9e592"
);
}
#[tokio::test]
async fn test_extract_checksum_no_false_positive_suffix_match() {
let temp = TempDir::new().unwrap();
let checksum_file = temp.path().join("checksums.txt");
std::fs::write(&checksum_file, "abc123 foo-rch.tar.gz\ndef456 rch.tar.gz").unwrap();
let result = extract_checksum(&checksum_file.to_path_buf(), "rch.tar.gz").await;
assert_eq!(result.unwrap(), "def456");
}
#[tokio::test]
async fn test_extract_checksum_path_prefix_with_slash() {
let temp = TempDir::new().unwrap();
let checksum_file = temp.path().join("checksums.txt");
std::fs::write(
&checksum_file,
"abc123 release/rch.tar.gz\ndef456 other/foo-rch.tar.gz",
)
.unwrap();
let result = extract_checksum(&checksum_file.to_path_buf(), "rch.tar.gz").await;
assert_eq!(result.unwrap(), "abc123");
}
#[tokio::test]
async fn test_extract_checksum_suffix_only_should_fail() {
let temp = TempDir::new().unwrap();
let checksum_file = temp.path().join("checksums.txt");
std::fs::write(&checksum_file, "abc123 foo-rch.tar.gz").unwrap();
let result = extract_checksum(&checksum_file.to_path_buf(), "rch.tar.gz").await;
assert!(result.is_err());
}
#[test]
fn test_archive_asset_candidates_include_static_linux_release_names() {
let candidates = archive_asset_candidates("1.0.13");
if std::env::consts::OS == "linux" && std::env::consts::ARCH == "x86_64" {
assert!(
candidates.contains(&"rch-v1.0.13-x86_64-unknown-linux-musl.tar.gz".to_string())
);
assert!(candidates.contains(&"rch-linux-x86_64.tar.gz".to_string()));
}
}
#[test]
fn test_find_archive_asset_matches_musl_release_for_linux_gnu_host() {
if !(std::env::consts::OS == "linux" && std::env::consts::ARCH == "x86_64") {
return;
}
let assets = vec![
test_asset("rch-v1.0.13-x86_64-unknown-linux-musl.tar.gz"),
test_asset("rch-v1.0.13-aarch64-apple-darwin.tar.gz"),
];
let archive = find_archive_asset(&assets, "1.0.13").unwrap();
assert_eq!(archive.name, "rch-v1.0.13-x86_64-unknown-linux-musl.tar.gz");
}
#[test]
fn test_find_archive_asset_falls_back_to_unversioned_alias() {
let assets = vec![test_asset(&format!(
"rch-{}{}",
current_release_targets().last().unwrap(),
current_release_archive_extension()
))];
let archive = find_archive_asset(&assets, "1.0.13").unwrap();
assert_eq!(archive.name, assets[0].name);
}
#[test]
fn test_find_checksum_asset_prefers_per_asset_then_manifest() {
let archive = test_asset("rch-v1.0.13-x86_64-unknown-linux-musl.tar.gz");
let per_asset_checksum = test_asset("rch-v1.0.13-x86_64-unknown-linux-musl.tar.gz.sha256");
let manifest = test_asset("SHA256SUMS");
let assets = vec![
archive.clone(),
manifest.clone(),
per_asset_checksum.clone(),
];
let selected = find_checksum_asset(&assets, &archive).unwrap();
assert_eq!(selected.name, per_asset_checksum.name);
let assets = vec![archive.clone(), manifest.clone()];
let selected = find_checksum_asset(&assets, &archive).unwrap();
assert_eq!(selected.name, manifest.name);
}
}