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;
mod transport;
#[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,
archive_asset.size,
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,
checksum_asset.size,
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,
sig_asset.size,
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,
sig_asset.size,
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: &Path,
asset_name: &str,
expected_size: u64,
max_attempts: u32,
) -> Result<(), UpdateError> {
if max_attempts == 0 {
return Err(UpdateError::DownloadFailed(
"No download attempts allowed".to_owned(),
));
}
let mut download = transport::AssetDownload::new(url, dest, expected_size).await?;
let mut last_err: Option<UpdateError> = None;
for attempt in 1..=max_attempts {
match download.attempt().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 write_downloaded_bytes(
file: &mut tokio::fs::File,
bytes: &[u8],
) -> Result<(), UpdateError> {
file.write_all(bytes)
.await
.map_err(|e| UpdateError::DownloadFailed(format!("Failed to write file: {}", e)))?;
file.flush().await.map_err(|e| {
UpdateError::DownloadFailed(format!("Failed to complete file write: {}", 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;
const SIGNED_PAYLOAD: &[u8] = b"rch minisign test payload v1\n";
const FIXTURE_KEY: &str = "RWTZPaZ0gaEhqMa/pPZEdaALWkxxAm/EtQzB6egPldD4Uh1cCRlw/b0w";
const FIXTURE_SIGNATURE: &str = "untrusted comment: signature from minisign secret key\n\
RUTZPaZ0gaEhqDSXunnChY49+8G9WeMdP7wwnCG9s382LwYf7h09hfwypAG0pKwYrhcX+N1Ujyzv1r4ZpRClO4To6jTL1DR1ewo=\n\
trusted comment: rch test fixture\n\
xfmvGjlaWGXfdNKtFwBNm45V0V6VPZ3lLFhIzfLmQ7xUjPzacD1iHPC0MO2oczUBCt2gAZtoGnuQMxpswMpMCw==\n";
#[test]
fn signature_handoff_waits_for_the_actual_file_write() {
use std::future::Future;
use std::task::Poll;
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.max_blocking_threads(1)
.build()
.unwrap();
runtime.block_on(async {
for complete_write in [false, true] {
let directory = TempDir::new().unwrap();
let path = directory.path().join("release.minisig");
let mut file = tokio::fs::File::from_std(std::fs::File::create(&path).unwrap());
let (release, held) = std::sync::mpsc::channel();
let (ready, started) = tokio::sync::oneshot::channel();
let blocker = tokio::task::spawn_blocking(move || {
ready.send(()).unwrap();
held.recv_timeout(Duration::from_secs(10))
});
started.await.unwrap();
let mut write = Box::pin(async {
if complete_write {
write_downloaded_bytes(&mut file, FIXTURE_SIGNATURE.as_bytes()).await
} else {
file.write_all(FIXTURE_SIGNATURE.as_bytes())
.await
.map_err(|error| UpdateError::DownloadFailed(error.to_string()))
}
});
let polled = std::future::poll_fn(|cx| Poll::Ready(write.as_mut().poll(cx))).await;
let was_pending = polled.is_pending();
let before = std::fs::read(&path).unwrap();
release.send(()).unwrap();
let result = match polled {
Poll::Ready(result) => result,
Poll::Pending => write.as_mut().await,
};
drop(write);
blocker.await.unwrap().unwrap();
file.flush().await.unwrap();
result.unwrap();
assert_eq!(was_pending, complete_write);
assert!(before.is_empty(), "the held write must not have run");
let completed = std::fs::read_to_string(&path).unwrap();
assert_eq!(completed, FIXTURE_SIGNATURE);
let signature = minisign_verify::Signature::decode(&completed).unwrap();
let key = minisign_verify::PublicKey::from_base64(FIXTURE_KEY).unwrap();
key.verify(SIGNED_PAYLOAD, &signature, false).unwrap();
assert!(key.verify(b"modified payload", &signature, false).is_err());
if !complete_write {
assert!(
minisign_verify::Signature::decode(std::str::from_utf8(&before).unwrap())
.is_err()
);
}
}
});
}
#[tokio::test]
async fn delayed_local_write_failure_is_not_success_or_retryable() {
let directory = TempDir::new().unwrap();
let path = directory.path().join("read-only-handle");
std::fs::write(&path, b"preserve existing bytes").unwrap();
let mut file = tokio::fs::File::from_std(std::fs::File::open(&path).unwrap());
let error = write_downloaded_bytes(&mut file, FIXTURE_SIGNATURE.as_bytes())
.await
.unwrap_err();
assert!(matches!(error, UpdateError::DownloadFailed(_)));
assert!(!is_transient_error(&error));
assert_eq!(std::fs::read(&path).unwrap(), b"preserve existing bytes");
}
#[tokio::test]
async fn completed_download_writes_are_immediately_visible_at_buffer_boundaries() {
let directory = TempDir::new().unwrap();
for length in [0, 1, 4095, 4096, 4097, 131_073] {
let path = directory.path().join(format!("asset-{length}"));
let mut file = tokio::fs::File::from_std(std::fs::File::create(&path).unwrap());
file.set_max_buf_size(4096);
let bytes: Vec<u8> = (0..length).map(|index| (index % 251) as u8).collect();
write_downloaded_bytes(&mut file, &bytes).await.unwrap();
assert_eq!(std::fs::read(&path).unwrap(), bytes);
}
}
#[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(reqwest::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);
}
}