use super::*;
use std::time::SystemTime;
#[test]
fn current_version_is_valid_semver() {
let v = current_version().expect("CARGO_PKG_VERSION should be valid semver");
assert_eq!(
v.to_string(),
env!("CARGO_PKG_VERSION"),
"parsed version should round-trip to the compiled version string"
);
assert!(
v.major > 0 || v.minor > 0 || v.patch > 0,
"version should be non-zero: {v}"
);
}
#[test]
fn parse_checksums_basic() {
let content =
"abc123 cfgd-0.2.0-linux-x86_64.tar.gz\ndef456 cfgd-0.2.0-darwin-aarch64.tar.gz\n";
let map = parse_checksums(content);
assert_eq!(map.len(), 2);
assert_eq!(
map.get("cfgd-0.2.0-linux-x86_64.tar.gz"),
Some(&"abc123".to_string())
);
assert_eq!(
map.get("cfgd-0.2.0-darwin-aarch64.tar.gz"),
Some(&"def456".to_string())
);
}
#[test]
fn parse_checksums_empty_lines() {
let content = "\nabc123 foo.tar.gz\n\n";
let map = parse_checksums(content);
assert_eq!(map.len(), 1);
}
#[test]
fn parse_release_json_valid() {
let json = r#"{
"tag_name": "v0.2.0",
"assets": [
{
"name": "cfgd-0.2.0-linux-x86_64.tar.gz",
"browser_download_url": "https://example.com/cfgd-0.2.0-linux-x86_64.tar.gz",
"size": 1024
},
{
"name": "cfgd-0.2.0-checksums.txt",
"browser_download_url": "https://example.com/cfgd-0.2.0-checksums.txt",
"size": 256
}
]
}"#;
let release = parse_release_json(json).expect("should parse");
assert_eq!(release.tag, "v0.2.0");
assert_eq!(release.version, Version::new(0, 2, 0));
assert_eq!(release.assets.len(), 2);
assert_eq!(release.assets[0].name, "cfgd-0.2.0-linux-x86_64.tar.gz");
}
#[test]
fn parse_release_json_no_v_prefix() {
let json = r#"{
"tag_name": "0.3.0",
"assets": []
}"#;
let release = parse_release_json(json).expect("should parse");
assert_eq!(release.version, Version::new(0, 3, 0));
}
#[test]
fn parse_release_json_missing_tag() {
let json = r#"{"assets": []}"#;
let err = parse_release_json(json).unwrap_err().to_string();
assert!(
err.contains("missing tag_name"),
"error should mention missing tag_name: {err}"
);
}
#[test]
fn find_asset_matches_current_platform() {
let os = std::env::consts::OS;
let arch = std::env::consts::ARCH;
let archive_os = if os == "macos" { "darwin" } else { os };
#[cfg(unix)]
let suffix = ".tar.gz";
#[cfg(windows)]
let suffix = ".zip";
let expected_name = format!("cfgd-0.2.0-{}-{}{}", archive_os, arch, suffix);
let release = ReleaseInfo {
tag: "v0.2.0".into(),
version: Version::new(0, 2, 0),
assets: vec![
ReleaseAsset {
name: expected_name.clone(),
download_url: "https://example.com/match".into(),
size: 1024,
},
ReleaseAsset {
name: "cfgd-0.2.0-freebsd-riscv64.tar.gz".into(),
download_url: "https://example.com/other".into(),
size: 1024,
},
],
};
let asset = find_asset_for_platform(&release).expect("should find platform asset");
assert_eq!(asset.name, expected_name);
assert_eq!(asset.download_url, "https://example.com/match");
}
#[test]
fn find_asset_returns_error_when_missing() {
let release = ReleaseInfo {
tag: "v0.2.0".into(),
version: Version::new(0, 2, 0),
assets: vec![ReleaseAsset {
name: "cfgd-0.2.0-freebsd-riscv64.tar.gz".into(),
download_url: "https://example.com/other".into(),
size: 1024,
}],
};
let err = find_asset_for_platform(&release).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains(std::env::consts::OS.replace("macos", "darwin").as_str())
|| msg.contains(std::env::consts::ARCH),
"error should mention the current platform: {msg}"
);
}
#[test]
fn sha256_file_computes_hash() {
let tmp = tempfile::NamedTempFile::new().expect("temp file");
fs::write(tmp.path(), b"hello world").expect("write");
let hash = sha256_file(tmp.path()).expect("hash");
assert_eq!(
hash,
"b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
);
}
const HELLO_WORLD_SHA256: &str = "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9";
fn write_temp_archive(content: &[u8]) -> tempfile::NamedTempFile {
let tmp = tempfile::NamedTempFile::new().expect("temp file");
fs::write(tmp.path(), content).expect("write");
tmp
}
#[test]
fn verify_archive_checksum_accepts_matching_sha() {
let archive = write_temp_archive(b"hello world");
let checksums = format!("{HELLO_WORLD_SHA256} cfgd_x86_64-unknown-linux-gnu.tar.gz\n");
super::verify_archive_checksum(
archive.path(),
&checksums,
"cfgd_x86_64-unknown-linux-gnu.tar.gz",
)
.expect("matching SHA must succeed");
}
#[test]
fn verify_archive_checksum_rejects_empty_checksums_with_dedicated_variant() {
let archive = write_temp_archive(b"hello world");
let err = super::verify_archive_checksum(archive.path(), "", "cfgd.tar.gz").unwrap_err();
assert!(
matches!(err, crate::errors::UpgradeError::ChecksumsEmpty),
"expected ChecksumsEmpty, got: {err:?}"
);
}
#[test]
fn verify_archive_checksum_rejects_whitespace_only_checksums() {
let archive = write_temp_archive(b"hello world");
let err =
super::verify_archive_checksum(archive.path(), " \n\t\n", "cfgd.tar.gz").unwrap_err();
assert!(matches!(err, crate::errors::UpgradeError::ChecksumsEmpty));
}
#[test]
fn verify_archive_checksum_returns_missing_when_asset_not_in_list() {
let archive = write_temp_archive(b"hello world");
let checksums = format!("{HELLO_WORLD_SHA256} some-other-asset.tar.gz\n");
let err = super::verify_archive_checksum(archive.path(), &checksums, "cfgd_linux.tar.gz")
.unwrap_err();
match err {
crate::errors::UpgradeError::ChecksumMissing { file } => {
assert_eq!(file, "cfgd_linux.tar.gz");
}
other => panic!("expected ChecksumMissing, got: {other:?}"),
}
}
#[test]
fn verify_archive_checksum_returns_mismatch_when_sha_differs() {
let archive = write_temp_archive(b"tampered content");
let checksums = format!("{HELLO_WORLD_SHA256} cfgd_linux.tar.gz\n");
let err = super::verify_archive_checksum(archive.path(), &checksums, "cfgd_linux.tar.gz")
.unwrap_err();
match err {
crate::errors::UpgradeError::ChecksumMismatch { file } => {
assert_eq!(file, "cfgd_linux.tar.gz");
}
other => panic!("expected ChecksumMismatch, got: {other:?}"),
}
}
#[test]
fn verify_archive_checksum_propagates_read_failure_as_download_failed() {
let checksums = format!("{HELLO_WORLD_SHA256} cfgd_linux.tar.gz\n");
let err = super::verify_archive_checksum(
std::path::Path::new("/nonexistent/path/to/archive.tar.gz"),
&checksums,
"cfgd_linux.tar.gz",
)
.unwrap_err();
match err {
crate::errors::UpgradeError::DownloadFailed { message } => {
assert!(message.contains("/nonexistent/path"), "msg: {message}");
}
other => panic!("expected DownloadFailed, got: {other:?}"),
}
}
#[test]
fn verify_archive_checksum_is_case_insensitive_on_hex() {
let archive = write_temp_archive(b"hello world");
let upper = HELLO_WORLD_SHA256.to_uppercase();
let checksums = format!("{upper} cfgd_linux.tar.gz\n");
super::verify_archive_checksum(archive.path(), &checksums, "cfgd_linux.tar.gz")
.expect("uppercase hex must still match after lowercase normalization");
}
#[test]
fn verify_archive_checksum_picks_correct_entry_when_multiple_assets_listed() {
let archive = write_temp_archive(b"hello world");
let checksums = format!(
"{HELLO_WORLD_SHA256} cfgd_linux.tar.gz\n\
deadbeef00000000000000000000000000000000000000000000000000000000 cfgd_macos.tar.gz\n\
cafebabe00000000000000000000000000000000000000000000000000000000 cfgd_windows.zip\n"
);
super::verify_archive_checksum(archive.path(), &checksums, "cfgd_linux.tar.gz")
.expect("must match the linux entry");
let err = super::verify_archive_checksum(archive.path(), &checksums, "cfgd_macos.tar.gz")
.unwrap_err();
assert!(
matches!(err, crate::errors::UpgradeError::ChecksumMismatch { ref file } if file == "cfgd_macos.tar.gz"),
"wrong asset must surface as mismatch on the macos entry: {err:?}"
);
}
#[test]
fn atomic_replace_overwrites_target() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("source");
let tgt = dir.path().join("target");
std::fs::write(&src, "new content").unwrap();
std::fs::write(&tgt, "old content").unwrap();
atomic_replace(&src, &tgt).unwrap();
assert_eq!(std::fs::read_to_string(&tgt).unwrap(), "new content");
}
#[test]
fn atomic_replace_creates_target() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("source");
let tgt = dir.path().join("target");
std::fs::write(&src, "data").unwrap();
atomic_replace(&src, &tgt).unwrap();
assert_eq!(std::fs::read_to_string(&tgt).unwrap(), "data");
}
#[test]
fn version_cache_disk_persistence_camel_case() {
let cache = VersionCache {
checked_at_secs: 1711800000,
latest_tag: "v0.5.0".into(),
latest_version: "0.5.0".into(),
current_version: "0.4.0".into(),
};
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("version-check.json");
let json = serde_json::to_string(&cache).expect("serialize");
fs::write(&path, &json).expect("write");
let raw = fs::read_to_string(&path).expect("read");
assert!(
raw.contains("checkedAtSecs"),
"expected camelCase key 'checkedAtSecs', got: {}",
raw
);
assert!(
raw.contains("latestTag"),
"expected camelCase key 'latestTag', got: {}",
raw
);
assert!(
raw.contains("latestVersion"),
"expected camelCase key 'latestVersion', got: {}",
raw
);
assert!(
raw.contains("currentVersion"),
"expected camelCase key 'currentVersion', got: {}",
raw
);
assert!(
!raw.contains("checked_at_secs"),
"should not contain snake_case key 'checked_at_secs'"
);
let restored: VersionCache = serde_json::from_str(&raw).expect("deserialize from disk");
assert_eq!(restored.checked_at_secs, 1711800000);
assert_eq!(restored.latest_tag, "v0.5.0");
assert_eq!(restored.latest_version, "0.5.0");
assert_eq!(restored.current_version, "0.4.0");
}
#[test]
fn find_asset_wrong_platform_returns_error() {
let release = ReleaseInfo {
tag: "v1.0.0".into(),
version: Version::new(1, 0, 0),
assets: vec![
ReleaseAsset {
name: "cfgd-1.0.0-fakeos-fakearch.tar.gz".into(),
download_url: "https://example.com/fake".into(),
size: 2048,
},
ReleaseAsset {
name: "cfgd-1.0.0-anotheros-anotherarch.zip".into(),
download_url: "https://example.com/another".into(),
size: 4096,
},
],
};
let result = find_asset_for_platform(&release);
assert!(result.is_err(), "should fail for fake platform assets");
let err = result.unwrap_err();
let err_msg = format!("{}", err);
assert!(
err_msg.contains("no release found for"),
"error should mention missing platform: {}",
err_msg
);
}
#[test]
fn cache_ttl_fresh_cache_is_valid() {
let now_secs = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap()
.as_secs();
let cache = VersionCache {
checked_at_secs: now_secs, latest_tag: "v0.3.0".into(),
latest_version: "0.3.0".into(),
current_version: "0.2.0".into(),
};
let elapsed = now_secs.saturating_sub(cache.checked_at_secs);
assert!(
elapsed < CACHE_TTL_SECS,
"fresh cache should be within TTL: elapsed={}, ttl={}",
elapsed,
CACHE_TTL_SECS
);
let cached_version = Version::parse(&cache.latest_version).expect("parse cached version");
let current = Version::parse(&cache.current_version).expect("parse current version");
assert!(cached_version > current, "0.3.0 > 0.2.0");
}
#[test]
fn cache_ttl_expired_cache_is_stale() {
let now_secs = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap()
.as_secs();
let twenty_five_hours_ago = now_secs - (25 * 3600);
let cache = VersionCache {
checked_at_secs: twenty_five_hours_ago,
latest_tag: "v0.3.0".into(),
latest_version: "0.3.0".into(),
current_version: "0.2.0".into(),
};
let elapsed = now_secs.saturating_sub(cache.checked_at_secs);
assert!(
elapsed >= CACHE_TTL_SECS,
"25h-old cache should exceed TTL: elapsed={}, ttl={}",
elapsed,
CACHE_TTL_SECS
);
}
#[test]
fn cache_ttl_boundary_just_expired() {
let now_secs = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap()
.as_secs();
let just_past_ttl = now_secs - CACHE_TTL_SECS - 1;
let cache = VersionCache {
checked_at_secs: just_past_ttl,
latest_tag: "v0.3.0".into(),
latest_version: "0.3.0".into(),
current_version: "0.2.0".into(),
};
let elapsed = now_secs.saturating_sub(cache.checked_at_secs);
assert!(
elapsed >= CACHE_TTL_SECS,
"cache at TTL+1s should be expired"
);
let at_boundary = now_secs - CACHE_TTL_SECS + 1;
let boundary_elapsed = now_secs.saturating_sub(at_boundary);
assert!(
boundary_elapsed < CACHE_TTL_SECS,
"cache at TTL-1s should still be valid"
);
}
#[test]
fn version_cache_deserialization_from_known_json() {
let json = r#"{"checkedAtSecs":1700000000,"latestTag":"v1.2.3","latestVersion":"1.2.3","currentVersion":"1.0.0"}"#;
let cache: VersionCache = serde_json::from_str(json).expect("deserialize known JSON");
assert_eq!(cache.checked_at_secs, 1700000000);
assert_eq!(cache.latest_tag, "v1.2.3");
assert_eq!(cache.latest_version, "1.2.3");
assert_eq!(cache.current_version, "1.0.0");
}
#[test]
#[cfg(unix)]
fn extract_tarball_valid() {
use flate2::Compression;
use flate2::write::GzEncoder;
let dir = tempfile::tempdir().unwrap();
let archive_path = dir.path().join("test.tar.gz");
let dest = dir.path().join("out");
std::fs::create_dir_all(&dest).unwrap();
{
let file = std::fs::File::create(&archive_path).unwrap();
let enc = GzEncoder::new(file, Compression::default());
let mut tar_builder = tar::Builder::new(enc);
let content = b"hello from tarball";
let mut header = tar::Header::new_gnu();
header.set_size(content.len() as u64);
header.set_mode(0o644);
header.set_cksum();
tar_builder
.append_data(&mut header, "test.txt", &content[..])
.unwrap();
tar_builder.finish().unwrap();
}
extract_tarball(&archive_path, &dest).unwrap();
let extracted = std::fs::read_to_string(dest.join("test.txt")).unwrap();
assert_eq!(extracted, "hello from tarball");
}
#[test]
fn download_and_install_checksum_mismatch_detection() {
let dir = tempfile::tempdir().unwrap();
let tar_dir = dir.path().join("tar_src");
std::fs::create_dir_all(&tar_dir).unwrap();
std::fs::write(tar_dir.join("cfgd"), b"#!/bin/sh\necho fake binary").unwrap();
let tarball_path = dir.path().join("cfgd-test.tar.gz");
{
let tar_file = std::fs::File::create(&tarball_path).unwrap();
let enc = flate2::write::GzEncoder::new(tar_file, flate2::Compression::default());
let mut tar_builder = tar::Builder::new(enc);
tar_builder.append_dir_all(".", &tar_dir).unwrap();
tar_builder.finish().unwrap();
}
let checksums =
"deadbeef00000000000000000000000000000000000000000000000000000000 cfgd-test.tar.gz\n";
let parsed = parse_checksums(checksums);
assert_eq!(
parsed.get("cfgd-test.tar.gz").unwrap(),
"deadbeef00000000000000000000000000000000000000000000000000000000"
);
let actual_hash = sha256_file(&tarball_path).unwrap();
assert_ne!(
actual_hash, "deadbeef00000000000000000000000000000000000000000000000000000000",
"real hash should differ from fake"
);
}
#[test]
fn version_cache_disk_persistence() {
let dir = tempfile::tempdir().unwrap();
let cache = VersionCache {
checked_at_secs: 1711234567,
latest_tag: "v1.2.3".into(),
latest_version: "1.2.3".into(),
current_version: "1.0.0".into(),
};
let json = serde_json::to_string(&cache).unwrap();
let path = dir.path().join("version-cache.json");
std::fs::write(&path, &json).unwrap();
let content = std::fs::read_to_string(&path).unwrap();
let restored: VersionCache = serde_json::from_str(&content).unwrap();
assert_eq!(restored.checked_at_secs, 1711234567);
assert_eq!(restored.latest_tag, "v1.2.3");
assert_eq!(restored.latest_version, "1.2.3");
assert_eq!(restored.current_version, "1.0.0");
assert!(json.contains("checkedAtSecs"));
assert!(json.contains("latestTag"));
}
#[test]
fn find_asset_multiple_platforms_picks_current() {
let os = std::env::consts::OS;
let arch = std::env::consts::ARCH;
let archive_os = if os == "macos" { "darwin" } else { os };
#[cfg(unix)]
let suffix = ".tar.gz";
#[cfg(windows)]
let suffix = ".zip";
let release = ReleaseInfo {
tag: "v0.5.0".into(),
version: Version::new(0, 5, 0),
assets: vec![
ReleaseAsset {
name: format!("cfgd-0.5.0-{}-{}{}", archive_os, arch, suffix),
download_url: "https://example.com/current".into(),
size: 5000,
},
ReleaseAsset {
name: "cfgd-0.5.0-freebsd-riscv64.tar.gz".into(),
download_url: "https://example.com/other".into(),
size: 4000,
},
],
};
let result = find_asset_for_platform(&release);
assert!(result.is_ok());
let asset = result.unwrap();
assert_eq!(asset.download_url, "https://example.com/current");
}
#[test]
fn find_asset_no_matching_platform() {
let release = ReleaseInfo {
tag: "v0.5.0".into(),
version: Version::new(0, 5, 0),
assets: vec![ReleaseAsset {
name: "cfgd-0.5.0-mips-unknown-linux.tar.gz".into(),
download_url: "https://example.com/mips".into(),
size: 3000,
}],
};
let result = find_asset_for_platform(&release);
if std::env::consts::ARCH != "mips" {
let err = result.unwrap_err();
let msg = err.to_string();
assert!(
msg.contains(std::env::consts::ARCH),
"error should mention the current arch: {msg}"
);
}
}
#[test]
fn parse_checksums_with_multiple_entries() {
let content = "abc123 file1.tar.gz\ndef456 file2.tar.gz\n";
let parsed = parse_checksums(content);
assert_eq!(parsed.get("file1.tar.gz").unwrap(), "abc123");
assert_eq!(parsed.get("file2.tar.gz").unwrap(), "def456");
}
#[test]
fn parse_checksums_ignores_malformed_lines() {
let content = "abc123 good.tar.gz\nbadline\n \nabc456 another.tar.gz\n";
let parsed = parse_checksums(content);
assert_eq!(parsed.len(), 2);
assert_eq!(parsed.get("good.tar.gz").unwrap(), "abc123");
assert_eq!(parsed.get("another.tar.gz").unwrap(), "abc456");
}
#[test]
fn parse_checksums_normalizes_to_lowercase() {
let content = "ABCDEF123456 mixed-case.tar.gz\n";
let parsed = parse_checksums(content);
assert_eq!(parsed.get("mixed-case.tar.gz").unwrap(), "abcdef123456");
}
#[test]
fn find_checksums_asset_finds_by_suffix() {
let release = ReleaseInfo {
tag: "v0.5.0".into(),
version: Version::new(0, 5, 0),
assets: vec![
ReleaseAsset {
name: "cfgd-0.5.0-linux-x86_64.tar.gz".into(),
download_url: "https://example.com/binary".into(),
size: 5000,
},
ReleaseAsset {
name: "cfgd-0.5.0-checksums.txt".into(),
download_url: "https://example.com/checksums".into(),
size: 256,
},
],
};
let asset = find_checksums_asset(&release);
assert!(asset.is_some());
assert_eq!(asset.unwrap().name, "cfgd-0.5.0-checksums.txt");
}
#[test]
fn find_checksums_asset_none_when_missing() {
let release = ReleaseInfo {
tag: "v0.5.0".into(),
version: Version::new(0, 5, 0),
assets: vec![ReleaseAsset {
name: "cfgd-0.5.0-linux-x86_64.tar.gz".into(),
download_url: "https://example.com/binary".into(),
size: 5000,
}],
};
let asset = find_checksums_asset(&release);
assert!(asset.is_none());
}
#[test]
fn version_check_interval_matches_cache_ttl() {
let interval = version_check_interval();
assert_eq!(interval, Duration::from_secs(CACHE_TTL_SECS));
}
#[test]
#[cfg(unix)]
fn extract_tarball_multiple_files_and_dirs() {
use flate2::Compression;
use flate2::write::GzEncoder;
let dir = tempfile::tempdir().unwrap();
let archive_path = dir.path().join("multi.tar.gz");
let dest = dir.path().join("extracted");
std::fs::create_dir_all(&dest).unwrap();
{
let file = std::fs::File::create(&archive_path).unwrap();
let enc = GzEncoder::new(file, Compression::default());
let mut tar_builder = tar::Builder::new(enc);
let content_a = b"file A content";
let mut header_a = tar::Header::new_gnu();
header_a.set_size(content_a.len() as u64);
header_a.set_mode(0o644);
header_a.set_cksum();
tar_builder
.append_data(&mut header_a, "file_a.txt", &content_a[..])
.unwrap();
let content_b = b"nested file B";
let mut header_b = tar::Header::new_gnu();
header_b.set_size(content_b.len() as u64);
header_b.set_mode(0o755);
header_b.set_cksum();
tar_builder
.append_data(&mut header_b, "subdir/file_b.txt", &content_b[..])
.unwrap();
let mut header_c = tar::Header::new_gnu();
header_c.set_size(0);
header_c.set_mode(0o644);
header_c.set_cksum();
tar_builder
.append_data(&mut header_c, "empty.txt", &[][..])
.unwrap();
tar_builder.finish().unwrap();
}
extract_tarball(&archive_path, &dest).unwrap();
let a_content = std::fs::read_to_string(dest.join("file_a.txt")).unwrap();
assert_eq!(a_content, "file A content");
let b_content = std::fs::read_to_string(dest.join("subdir/file_b.txt")).unwrap();
assert_eq!(b_content, "nested file B");
let c_content = std::fs::read_to_string(dest.join("empty.txt")).unwrap();
assert!(c_content.is_empty(), "empty file should have no content");
}
#[test]
#[cfg(unix)]
fn extract_tarball_nonexistent_archive_fails() {
let dir = tempfile::tempdir().unwrap();
let dest = dir.path().join("out");
std::fs::create_dir_all(&dest).unwrap();
let result = extract_tarball(&dir.path().join("does-not-exist.tar.gz"), &dest);
assert!(result.is_err(), "should fail for nonexistent archive");
}
#[test]
#[cfg(unix)]
fn extract_tarball_invalid_gz_fails() {
let dir = tempfile::tempdir().unwrap();
let archive_path = dir.path().join("bad.tar.gz");
let dest = dir.path().join("out");
std::fs::create_dir_all(&dest).unwrap();
std::fs::write(&archive_path, b"this is not a gzip file").unwrap();
let result = extract_tarball(&archive_path, &dest);
assert!(result.is_err(), "should fail for invalid gzip data");
}
#[test]
#[cfg(unix)]
fn extract_tarball_skips_symlink_entries_without_failing() {
use flate2::Compression;
use flate2::write::GzEncoder;
let dir = tempfile::tempdir().unwrap();
let archive_path = dir.path().join("with-symlink.tar.gz");
let dest = dir.path().join("out");
std::fs::create_dir_all(&dest).unwrap();
{
let file = std::fs::File::create(&archive_path).unwrap();
let enc = GzEncoder::new(file, Compression::default());
let mut tar_builder = tar::Builder::new(enc);
let body = b"real file";
let mut header = tar::Header::new_gnu();
header.set_size(body.len() as u64);
header.set_mode(0o644);
header.set_cksum();
tar_builder
.append_data(&mut header, "real.txt", &body[..])
.unwrap();
let mut sym_header = tar::Header::new_gnu();
sym_header.set_size(0);
sym_header.set_mode(0o644);
sym_header.set_entry_type(tar::EntryType::Symlink);
sym_header.set_link_name("real.txt").unwrap();
sym_header.set_cksum();
tar_builder
.append_data(&mut sym_header, "link.txt", &[][..])
.unwrap();
tar_builder.finish().unwrap();
}
extract_tarball(&archive_path, &dest).expect("symlink in tarball must not fail extraction");
assert!(
dest.join("real.txt").exists(),
"regular file must still be unpacked"
);
assert!(
!dest.join("link.txt").exists() && !dest.join("link.txt").is_symlink(),
"symlink entry must be skipped — guards against escape via crafted link target"
);
}
#[test]
fn check_with_cache_returns_error_when_cached_version_is_unparseable() {
let home = tempfile::tempdir().unwrap();
let _guard = crate::with_test_home_guard(home.path());
let now = std::time::SystemTime::now()
.duration_since(std::time::SystemTime::UNIX_EPOCH)
.unwrap()
.as_secs();
write_version_cache(&VersionCache {
checked_at_secs: now,
latest_tag: "vBOGUS".into(),
latest_version: "not-a-semver".into(),
current_version: env!("CARGO_PKG_VERSION").into(),
})
.expect("cache seed");
let err = check_with_cache(Some("does/not/matter"), None)
.expect_err("unparseable cached version must surface as Err, not silent fallthrough");
let msg = err.to_string();
assert!(
msg.contains("cached version") && msg.contains("parse"),
"error must point at the cache file's version field so triage looks there first: {msg}"
);
}
#[test]
fn find_checksums_asset_picks_checksums_txt_over_other_assets() {
let release = ReleaseInfo {
tag: "v1.0.0".into(),
version: Version::new(1, 0, 0),
assets: vec![
ReleaseAsset {
name: "cfgd-1.0.0-linux-x86_64.tar.gz".into(),
download_url: "https://example.com/binary".into(),
size: 10000,
},
ReleaseAsset {
name: "SHA256SUMS".into(),
download_url: "https://example.com/sha256sums".into(),
size: 512,
},
ReleaseAsset {
name: "cfgd-1.0.0-checksums.txt".into(),
download_url: "https://example.com/checksums".into(),
size: 256,
},
],
};
let asset = find_checksums_asset(&release);
assert!(asset.is_some());
assert_eq!(asset.unwrap().name, "cfgd-1.0.0-checksums.txt");
assert_eq!(asset.unwrap().download_url, "https://example.com/checksums");
}
#[test]
fn find_checksums_asset_returns_none_for_non_matching_names() {
let release = ReleaseInfo {
tag: "v2.0.0".into(),
version: Version::new(2, 0, 0),
assets: vec![
ReleaseAsset {
name: "cfgd-2.0.0-linux-x86_64.tar.gz".into(),
download_url: "https://example.com/binary".into(),
size: 10000,
},
ReleaseAsset {
name: "SHA256SUMS".into(),
download_url: "https://example.com/sha256sums".into(),
size: 512,
},
],
};
let asset = find_checksums_asset(&release);
assert!(
asset.is_none(),
"SHA256SUMS does not end with -checksums.txt, so should not match"
);
}
#[test]
fn find_checksums_asset_empty_assets() {
let release = ReleaseInfo {
tag: "v1.0.0".into(),
version: Version::new(1, 0, 0),
assets: vec![],
};
assert!(find_checksums_asset(&release).is_none());
}
#[test]
fn invalidate_cache_removes_file_if_present() {
let home = tempfile::tempdir().unwrap();
let _guard = crate::with_test_home_guard(home.path());
let dir = home.path().join(".cache").join("cfgd");
fs::create_dir_all(&dir).unwrap();
let cache_path = dir.join(CACHE_FILENAME);
let data =
r#"{"checkedAtSecs":0,"latestTag":"v0","latestVersion":"0.0.0","currentVersion":"0.0.0"}"#;
fs::write(&cache_path, data).unwrap();
assert!(cache_path.exists(), "test setup: cache file must exist");
invalidate_cache();
assert!(
!cache_path.exists(),
"cache file should be removed after invalidation"
);
}
#[test]
fn invalidate_cache_no_panic_when_no_file() {
let home = tempfile::tempdir().unwrap();
let _guard = crate::with_test_home_guard(home.path());
invalidate_cache();
invalidate_cache(); }
#[test]
fn restart_daemon_if_running_returns_false_when_no_daemon() {
let result = restart_daemon_if_running();
assert!(
!result,
"restart_daemon_if_running should return false when no daemon is running"
);
}
#[test]
fn update_check_fields_are_coherent() {
let check = UpdateCheck {
current: Version::new(0, 1, 0),
latest: Version::new(0, 2, 0),
update_available: true,
release: None,
};
assert!(check.update_available);
assert!(check.latest > check.current);
assert!(check.release.is_none());
let no_update = UpdateCheck {
current: Version::new(0, 2, 0),
latest: Version::new(0, 2, 0),
update_available: false,
release: None,
};
assert!(!no_update.update_available);
assert_eq!(no_update.current, no_update.latest);
}
#[test]
fn version_cache_write_and_read_roundtrip() {
let home = tempfile::tempdir().unwrap();
let _guard = crate::with_test_home_guard(home.path());
let cache = VersionCache {
checked_at_secs: SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap()
.as_secs(),
latest_tag: "v99.99.99".into(),
latest_version: "99.99.99".into(),
current_version: env!("CARGO_PKG_VERSION").into(),
};
write_version_cache(&cache).expect("write into tempdir cache should succeed");
let read = read_version_cache().expect("should be able to read back written cache");
assert_eq!(read.latest_tag, "v99.99.99");
assert_eq!(read.latest_version, "99.99.99");
assert_eq!(read.current_version, env!("CARGO_PKG_VERSION"));
assert_eq!(read.checked_at_secs, cache.checked_at_secs);
}
#[test]
fn read_version_cache_returns_none_after_invalidation() {
let home = tempfile::tempdir().unwrap();
let _guard = crate::with_test_home_guard(home.path());
let cache = VersionCache {
checked_at_secs: 1,
latest_tag: "v0".into(),
latest_version: "0.0.0".into(),
current_version: "0.0.0".into(),
};
write_version_cache(&cache).expect("seed cache write must succeed");
assert!(
read_version_cache().is_some(),
"test precondition: cache must be readable before invalidation"
);
invalidate_cache();
assert!(
read_version_cache().is_none(),
"read_version_cache should return None after invalidation"
);
}
#[test]
fn cleanup_old_binary_does_not_panic() {
cleanup_old_binary();
}
#[test]
fn fetch_latest_release_from_parses_github_response() {
let mut server = mockito::Server::new();
let mock = server
.mock("GET", "/repos/test/repo/releases/latest")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{
"tag_name": "v1.2.3",
"assets": [
{
"name": "cfgd-1.2.3-linux-x86_64.tar.gz",
"browser_download_url": "https://example.com/download/cfgd-1.2.3-linux-x86_64.tar.gz",
"size": 5000000
},
{
"name": "checksums.txt",
"browser_download_url": "https://example.com/download/checksums.txt",
"size": 512
}
]
}"#,
)
.create();
let result = fetch_latest_release_from(&server.url(), "test/repo", None);
mock.assert();
let release = result.unwrap();
assert_eq!(release.tag, "v1.2.3");
assert_eq!(release.version, Version::new(1, 2, 3));
assert_eq!(release.assets.len(), 2);
assert_eq!(release.assets[0].name, "cfgd-1.2.3-linux-x86_64.tar.gz");
assert_eq!(release.assets[0].size, 5000000);
assert_eq!(release.assets[1].name, "checksums.txt");
}
#[test]
fn fetch_latest_release_from_handles_api_error() {
let mut server = mockito::Server::new();
let mock = server
.mock("GET", "/repos/test/repo/releases/latest")
.with_status(404)
.with_body(r#"{"message": "Not Found"}"#)
.create();
let result = fetch_latest_release_from(&server.url(), "test/repo", None);
mock.assert();
assert!(result.is_err());
let err = result.unwrap_err();
let err_str = err.to_string();
assert!(
err_str.contains("404") || err_str.contains("Not Found") || err_str.contains("status code"),
"error should indicate API failure: {}",
err_str
);
}
#[test]
fn fetch_latest_release_from_handles_invalid_json() {
let mut server = mockito::Server::new();
let mock = server
.mock("GET", "/repos/test/repo/releases/latest")
.with_status(200)
.with_body("this is not json")
.create();
let result = fetch_latest_release_from(&server.url(), "test/repo", None);
mock.assert();
assert!(result.is_err());
}
#[test]
fn fetch_latest_release_from_handles_missing_tag_name() {
let mut server = mockito::Server::new();
let mock = server
.mock("GET", "/repos/test/repo/releases/latest")
.with_status(200)
.with_body(r#"{"name": "Release", "assets": []}"#)
.create();
let result = fetch_latest_release_from(&server.url(), "test/repo", None);
mock.assert();
assert!(result.is_err());
}
#[test]
fn fetch_latest_release_from_handles_no_assets() {
let mut server = mockito::Server::new();
let mock = server
.mock("GET", "/repos/test/repo/releases/latest")
.with_status(200)
.with_body(r#"{"tag_name": "v2.0.0"}"#)
.create();
let result = fetch_latest_release_from(&server.url(), "test/repo", None);
mock.assert();
let release = result.unwrap();
assert_eq!(release.version, Version::new(2, 0, 0));
assert!(release.assets.is_empty());
}
#[test]
fn fetch_latest_release_from_handles_tag_without_v_prefix() {
let mut server = mockito::Server::new();
let mock = server
.mock("GET", "/repos/test/repo/releases/latest")
.with_status(200)
.with_body(r#"{"tag_name": "3.0.1", "assets": []}"#)
.create();
let result = fetch_latest_release_from(&server.url(), "test/repo", None);
mock.assert();
let release = result.unwrap();
assert_eq!(release.tag, "3.0.1");
assert_eq!(release.version, Version::new(3, 0, 1));
}
#[test]
fn fetch_latest_release_from_handles_prerelease_version() {
let mut server = mockito::Server::new();
let mock = server
.mock("GET", "/repos/test/repo/releases/latest")
.with_status(200)
.with_body(r#"{"tag_name": "v4.0.0-beta.1", "assets": []}"#)
.create();
let result = fetch_latest_release_from(&server.url(), "test/repo", None);
mock.assert();
let release = result.unwrap();
assert_eq!(release.version, Version::parse("4.0.0-beta.1").unwrap());
}
#[test]
fn download_to_file_writes_content_to_path() {
let mut server = mockito::Server::new();
let mock = server
.mock("GET", "/download/test-file")
.with_status(200)
.with_body(b"file content here")
.create();
let dir = tempfile::tempdir().unwrap();
let dest = dir.path().join("downloaded.bin");
let url = format!("{}/download/test-file", server.url());
let result = download_to_file(&url, &dest, None);
mock.assert();
assert!(result.is_ok());
assert_eq!(std::fs::read_to_string(&dest).unwrap(), "file content here");
}
#[test]
fn download_to_file_returns_error_on_http_failure() {
let mut server = mockito::Server::new();
let mock = server
.mock("GET", "/download/missing")
.with_status(404)
.create();
let dir = tempfile::tempdir().unwrap();
let dest = dir.path().join("should-not-exist.bin");
let url = format!("{}/download/missing", server.url());
let result = download_to_file(&url, &dest, None);
mock.assert();
assert!(result.is_err());
assert!(!dest.exists(), "file should not be created on failure");
}
#[test]
fn download_to_file_returns_error_on_5xx_response() {
let mut server = mockito::Server::new();
let mock = server
.mock("GET", "/download/flaky")
.with_status(503)
.create();
let dir = tempfile::tempdir().unwrap();
let dest = dir.path().join("should-not-exist.bin");
let url = format!("{}/download/flaky", server.url());
let result = download_to_file(&url, &dest, None);
mock.assert();
assert!(result.is_err(), "5xx must surface as Err");
assert!(!dest.exists(), "file must not be created on 5xx");
}
#[test]
fn download_to_file_drives_progress_bar_branch_with_printer_and_content_length() {
let mut server = mockito::Server::new();
let body = b"download body for progress bar branch";
let mock = server
.mock("GET", "/download/sized")
.with_status(200)
.with_header("content-length", &body.len().to_string())
.with_body(body)
.create();
let dir = tempfile::tempdir().unwrap();
let dest = dir.path().join("sized.bin");
let url = format!("{}/download/sized", server.url());
let printer = crate::test_helpers::test_printer();
let result = download_to_file(&url, &dest, Some(&printer));
mock.assert();
assert!(result.is_ok(), "happy path with printer must succeed");
assert_eq!(std::fs::read(&dest).unwrap(), body);
}
#[test]
fn download_to_file_drives_spinner_branch_when_printer_present_without_content_length() {
let mut server = mockito::Server::new();
let body: &[u8] = b"chunked body for spinner branch";
let mock = server
.mock("GET", "/download/chunked")
.with_status(200)
.with_chunked_body(move |w| w.write_all(body))
.create();
let dir = tempfile::tempdir().unwrap();
let dest = dir.path().join("chunked.bin");
let url = format!("{}/download/chunked", server.url());
let printer = crate::test_helpers::test_printer();
let result = download_to_file(&url, &dest, Some(&printer));
mock.assert();
assert!(result.is_ok(), "spinner branch must succeed");
assert_eq!(std::fs::read(&dest).unwrap(), body);
}
#[test]
fn fetch_latest_release_from_with_printer_drives_spinner_branch() {
let mut server = mockito::Server::new();
let mock = server
.mock("GET", "/repos/test/repo/releases/latest")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{
"tag_name": "v7.0.0",
"assets": [
{
"name": "cfgd-7.0.0-linux-x86_64.tar.gz",
"browser_download_url": "https://example.com/download",
"size": 1024
}
]
}"#,
)
.create();
let printer = crate::test_helpers::test_printer();
let result = fetch_latest_release_from(&server.url(), "test/repo", Some(&printer));
mock.assert();
let release = result.expect("should parse with printer present");
assert_eq!(release.version, Version::new(7, 0, 0));
assert_eq!(release.assets.len(), 1);
}
#[test]
fn fetch_latest_release_from_with_printer_on_error_still_returns_err() {
let mut server = mockito::Server::new();
let mock = server
.mock("GET", "/repos/test/repo/releases/latest")
.with_status(502)
.with_body("Bad Gateway")
.create();
let printer = crate::test_helpers::test_printer();
let result = fetch_latest_release_from(&server.url(), "test/repo", Some(&printer));
mock.assert();
assert!(
result.is_err(),
"502 with printer must still surface as Err"
);
}
#[test]
fn parse_release_json_assets_missing_fields_skipped() {
let json = r#"{
"tag_name": "v1.0.0",
"assets": [
{
"name": "valid.tar.gz",
"browser_download_url": "https://example.com/valid.tar.gz",
"size": 1024
},
{
"browser_download_url": "https://example.com/noname.tar.gz",
"size": 512
},
{
"name": "nourl.tar.gz",
"size": 256
}
]
}"#;
let release = parse_release_json(json).unwrap();
assert_eq!(
release.assets.len(),
1,
"only the valid asset should be included"
);
assert_eq!(release.assets[0].name, "valid.tar.gz");
}
#[test]
fn parse_release_json_asset_size_defaults_to_zero() {
let json = r#"{
"tag_name": "v1.0.0",
"assets": [
{
"name": "nosize.tar.gz",
"browser_download_url": "https://example.com/nosize.tar.gz"
}
]
}"#;
let release = parse_release_json(json).unwrap();
assert_eq!(release.assets.len(), 1);
assert_eq!(
release.assets[0].size, 0,
"missing size should default to 0"
);
}
#[test]
fn parse_release_json_prerelease_tag() {
let json = r#"{
"tag_name": "v2.0.0-rc.1",
"assets": []
}"#;
let release = parse_release_json(json).unwrap();
assert_eq!(release.tag, "v2.0.0-rc.1");
assert_eq!(release.version, Version::parse("2.0.0-rc.1").unwrap());
}
#[test]
fn parse_release_json_build_metadata() {
let json = r#"{
"tag_name": "v1.0.0+build.123",
"assets": []
}"#;
let release = parse_release_json(json).unwrap();
assert_eq!(release.version.major, 1);
assert_eq!(release.version.minor, 0);
assert_eq!(release.version.patch, 0);
}
#[test]
fn parse_release_json_invalid_version_tag() {
let json = r#"{
"tag_name": "not-semver",
"assets": []
}"#;
let result = parse_release_json(json);
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("cannot parse release version"),
"should mention version parse error: {msg}"
);
}
#[test]
fn parse_release_json_null_assets_treated_as_empty() {
let json = r#"{
"tag_name": "v1.0.0",
"assets": null
}"#;
let release = parse_release_json(json).unwrap();
assert!(release.assets.is_empty());
}
#[test]
fn parse_release_json_no_assets_field() {
let json = r#"{"tag_name": "v1.0.0"}"#;
let release = parse_release_json(json).unwrap();
assert!(release.assets.is_empty());
}
#[test]
fn find_asset_empty_assets_returns_error() {
let release = ReleaseInfo {
tag: "v1.0.0".into(),
version: Version::new(1, 0, 0),
assets: vec![],
};
assert!(find_asset_for_platform(&release).is_err());
}
#[test]
fn find_checksums_asset_matches_version_prefixed() {
let release = ReleaseInfo {
tag: "v3.0.0".into(),
version: Version::new(3, 0, 0),
assets: vec![
ReleaseAsset {
name: "cfgd-3.0.0-linux-x86_64.tar.gz".into(),
download_url: "https://example.com/bin".into(),
size: 5000,
},
ReleaseAsset {
name: "cfgd-3.0.0-checksums.txt".into(),
download_url: "https://example.com/sums".into(),
size: 128,
},
],
};
let asset = find_checksums_asset(&release).unwrap();
assert_eq!(asset.name, "cfgd-3.0.0-checksums.txt");
assert_eq!(asset.download_url, "https://example.com/sums");
}
#[test]
#[cfg(unix)]
fn extract_tarball_empty_archive() {
use flate2::Compression;
use flate2::write::GzEncoder;
let dir = tempfile::tempdir().unwrap();
let archive_path = dir.path().join("empty.tar.gz");
let dest = dir.path().join("out");
std::fs::create_dir_all(&dest).unwrap();
{
let file = std::fs::File::create(&archive_path).unwrap();
let enc = GzEncoder::new(file, Compression::default());
let mut tar_builder = tar::Builder::new(enc);
tar_builder.finish().unwrap();
}
extract_tarball(&archive_path, &dest).unwrap();
let entries: Vec<_> = std::fs::read_dir(&dest).unwrap().collect();
assert!(
entries.is_empty(),
"empty tarball should extract to empty dir"
);
}
#[test]
#[cfg(unix)]
fn extract_tarball_preserves_binary_content() {
use flate2::Compression;
use flate2::write::GzEncoder;
let dir = tempfile::tempdir().unwrap();
let archive_path = dir.path().join("binary.tar.gz");
let dest = dir.path().join("out");
std::fs::create_dir_all(&dest).unwrap();
let binary_data: Vec<u8> = (0..=255).collect();
{
let file = std::fs::File::create(&archive_path).unwrap();
let enc = GzEncoder::new(file, Compression::default());
let mut tar_builder = tar::Builder::new(enc);
let mut header = tar::Header::new_gnu();
header.set_size(binary_data.len() as u64);
header.set_mode(0o755);
header.set_cksum();
tar_builder
.append_data(&mut header, "binary.bin", &binary_data[..])
.unwrap();
tar_builder.finish().unwrap();
}
extract_tarball(&archive_path, &dest).unwrap();
let extracted = std::fs::read(dest.join("binary.bin")).unwrap();
assert_eq!(
extracted, binary_data,
"binary data should be preserved exactly"
);
}
#[test]
#[cfg(unix)]
fn extract_tarball_skips_hardlink_entries_without_failing() {
use flate2::Compression;
use flate2::write::GzEncoder;
let dir = tempfile::tempdir().unwrap();
let archive_path = dir.path().join("with-hardlink.tar.gz");
let dest = dir.path().join("out");
std::fs::create_dir_all(&dest).unwrap();
{
let file = std::fs::File::create(&archive_path).unwrap();
let enc = GzEncoder::new(file, Compression::default());
let mut tar_builder = tar::Builder::new(enc);
let body = b"real file content";
let mut header = tar::Header::new_gnu();
header.set_size(body.len() as u64);
header.set_mode(0o644);
header.set_cksum();
tar_builder
.append_data(&mut header, "real.txt", &body[..])
.unwrap();
let mut hl_header = tar::Header::new_gnu();
hl_header.set_size(0);
hl_header.set_mode(0o644);
hl_header.set_entry_type(tar::EntryType::Link);
hl_header.set_link_name("real.txt").unwrap();
hl_header.set_cksum();
tar_builder
.append_data(&mut hl_header, "hardlink.txt", &[][..])
.unwrap();
tar_builder.finish().unwrap();
}
extract_tarball(&archive_path, &dest).expect("hardlink in tarball must not fail extraction");
assert!(
dest.join("real.txt").exists(),
"regular file must still be unpacked"
);
assert!(
!dest.join("hardlink.txt").exists(),
"hardlink entry must be skipped — guards against escape via crafted link target"
);
}
#[test]
#[cfg(unix)]
fn extract_tarball_skips_mixed_symlink_and_hardlink_entries() {
use flate2::Compression;
use flate2::write::GzEncoder;
let dir = tempfile::tempdir().unwrap();
let archive_path = dir.path().join("mixed-links.tar.gz");
let dest = dir.path().join("out");
std::fs::create_dir_all(&dest).unwrap();
{
let file = std::fs::File::create(&archive_path).unwrap();
let enc = GzEncoder::new(file, Compression::default());
let mut tar_builder = tar::Builder::new(enc);
let body = b"cfgd binary content";
let mut header = tar::Header::new_gnu();
header.set_size(body.len() as u64);
header.set_mode(0o755);
header.set_cksum();
tar_builder
.append_data(&mut header, "cfgd", &body[..])
.unwrap();
let mut sym_header = tar::Header::new_gnu();
sym_header.set_size(0);
sym_header.set_mode(0o777);
sym_header.set_entry_type(tar::EntryType::Symlink);
sym_header.set_link_name("/etc/passwd").unwrap();
sym_header.set_cksum();
tar_builder
.append_data(&mut sym_header, "evil_symlink", &[][..])
.unwrap();
let mut hl_header = tar::Header::new_gnu();
hl_header.set_size(0);
hl_header.set_mode(0o644);
hl_header.set_entry_type(tar::EntryType::Link);
hl_header.set_link_name("cfgd").unwrap();
hl_header.set_cksum();
tar_builder
.append_data(&mut hl_header, "evil_hardlink", &[][..])
.unwrap();
tar_builder.finish().unwrap();
}
extract_tarball(&archive_path, &dest).expect("mixed link types must not fail extraction");
assert!(dest.join("cfgd").exists(), "binary must be extracted");
assert!(
!dest.join("evil_symlink").exists() && !dest.join("evil_symlink").is_symlink(),
"symlink entry must be skipped"
);
assert!(
!dest.join("evil_hardlink").exists(),
"hardlink entry must be skipped"
);
let content = std::fs::read(dest.join("cfgd")).unwrap();
assert_eq!(content, b"cfgd binary content");
}
#[test]
fn atomic_replace_with_large_content() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("source");
let tgt = dir.path().join("target");
let large_content: Vec<u8> = vec![0xAB; 1024 * 1024];
std::fs::write(&src, &large_content).unwrap();
std::fs::write(&tgt, b"old small content").unwrap();
atomic_replace(&src, &tgt).unwrap();
let result = std::fs::read(&tgt).unwrap();
assert_eq!(result.len(), large_content.len());
assert_eq!(result, large_content);
}
#[test]
fn atomic_replace_target_parent_must_exist() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("source");
std::fs::write(&src, "content").unwrap();
let tgt = dir.path().join("nonexistent").join("subdir").join("target");
let result = atomic_replace(&src, &tgt);
assert!(
result.is_err(),
"should fail when target parent doesn't exist"
);
}
#[test]
fn version_cache_with_prerelease() {
let cache = VersionCache {
checked_at_secs: 1700000000,
latest_tag: "v2.0.0-beta.3".into(),
latest_version: "2.0.0-beta.3".into(),
current_version: "1.9.0".into(),
};
let json = serde_json::to_string(&cache).unwrap();
let restored: VersionCache = serde_json::from_str(&json).unwrap();
assert_eq!(restored.latest_tag, "v2.0.0-beta.3");
assert_eq!(restored.latest_version, "2.0.0-beta.3");
let latest = Version::parse(&restored.latest_version).unwrap();
let current = Version::parse(&restored.current_version).unwrap();
assert!(latest > current, "2.0.0-beta.3 > 1.9.0");
}
#[test]
fn version_cache_tolerates_extra_json_fields() {
let json = r#"{"checkedAtSecs":100,"latestTag":"v1","latestVersion":"1.0.0","currentVersion":"0.9.0","extraField":"ignored"}"#;
let cache: VersionCache = serde_json::from_str(json).unwrap();
assert_eq!(cache.checked_at_secs, 100);
assert_eq!(cache.latest_version, "1.0.0");
}
#[test]
fn cache_ttl_zero_seconds_ago_is_fresh() {
let now_secs = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap()
.as_secs();
let elapsed = now_secs.saturating_sub(now_secs);
assert!(
elapsed < CACHE_TTL_SECS,
"zero-elapsed cache should be fresh"
);
}
#[test]
fn cache_ttl_exactly_at_boundary_is_fresh() {
let now_secs = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap()
.as_secs();
let at_boundary = now_secs - CACHE_TTL_SECS;
let elapsed = now_secs.saturating_sub(at_boundary);
assert!(
elapsed >= CACHE_TTL_SECS,
"cache exactly at TTL boundary should be expired (uses strict <)"
);
}
#[test]
fn strip_tag_prefix_with_v() {
assert_eq!(strip_tag_prefix("v1.2.3"), "1.2.3");
}
#[test]
fn strip_tag_prefix_without_v() {
assert_eq!(strip_tag_prefix("1.2.3"), "1.2.3");
}
#[test]
fn strip_tag_prefix_empty() {
assert_eq!(strip_tag_prefix(""), "");
}
#[test]
fn strip_tag_prefix_only_v() {
assert_eq!(strip_tag_prefix("v"), "");
}
#[test]
fn strip_tag_prefix_double_v() {
assert_eq!(strip_tag_prefix("vv1.0.0"), "v1.0.0");
}
#[test]
fn parse_checksums_extra_whitespace_between_fields() {
let content = "abc123 file.tar.gz\n";
let map = parse_checksums(content);
assert_eq!(map.len(), 1);
assert_eq!(map.get("file.tar.gz").unwrap(), "abc123");
}
#[test]
fn parse_checksums_tab_separated() {
let content = "abc123\tfile.tar.gz\n";
let map = parse_checksums(content);
assert_eq!(map.len(), 1);
assert_eq!(map.get("file.tar.gz").unwrap(), "abc123");
}
#[test]
fn parse_checksums_duplicate_filename_last_wins() {
let content = "first_hash file.tar.gz\nsecond_hash file.tar.gz\n";
let map = parse_checksums(content);
assert_eq!(map.len(), 1);
assert_eq!(
map.get("file.tar.gz").unwrap(),
"second_hash",
"last occurrence should win in HashMap"
);
}
#[test]
fn download_to_file_with_content_length() {
let mut server = mockito::Server::new();
let body = "known length content";
let mock = server
.mock("GET", "/sized-file")
.with_status(200)
.with_header("content-length", &body.len().to_string())
.with_body(body)
.create();
let dir = tempfile::tempdir().unwrap();
let dest = dir.path().join("sized.bin");
let url = format!("{}/sized-file", server.url());
download_to_file(&url, &dest, None).unwrap();
mock.assert();
let content = std::fs::read_to_string(&dest).unwrap();
assert_eq!(content, "known length content");
}
#[test]
fn download_to_file_binary_content() {
let mut server = mockito::Server::new();
let binary_data: Vec<u8> = (0..=127).collect();
let mock = server
.mock("GET", "/binary")
.with_status(200)
.with_body(&binary_data)
.create();
let dir = tempfile::tempdir().unwrap();
let dest = dir.path().join("binary.bin");
let url = format!("{}/binary", server.url());
download_to_file(&url, &dest, None).unwrap();
mock.assert();
let content = std::fs::read(&dest).unwrap();
assert_eq!(content, binary_data);
}
#[test]
fn sha256_file_empty_file() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let hash = sha256_file(tmp.path()).unwrap();
assert_eq!(
hash,
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
);
}
#[test]
fn sha256_file_nonexistent_returns_error() {
let dir = tempfile::tempdir().unwrap();
let result = sha256_file(&dir.path().join("does-not-exist"));
assert!(result.is_err(), "nonexistent file should error");
}
#[test]
fn fetch_latest_release_from_handles_server_error() {
let mut server = mockito::Server::new();
let mock = server
.mock("GET", "/repos/test/repo/releases/latest")
.with_status(500)
.with_body("Internal Server Error")
.create();
let result = fetch_latest_release_from(&server.url(), "test/repo", None);
mock.assert();
assert!(result.is_err());
}
#[test]
fn fetch_latest_release_from_with_many_assets() {
let mut server = mockito::Server::new();
let mock = server
.mock("GET", "/repos/test/repo/releases/latest")
.with_status(200)
.with_body(
r#"{
"tag_name": "v5.0.0",
"assets": [
{"name": "cfgd-5.0.0-linux-x86_64.tar.gz", "browser_download_url": "https://dl/linux-x64", "size": 10000},
{"name": "cfgd-5.0.0-linux-aarch64.tar.gz", "browser_download_url": "https://dl/linux-arm64", "size": 9000},
{"name": "cfgd-5.0.0-darwin-x86_64.tar.gz", "browser_download_url": "https://dl/darwin-x64", "size": 11000},
{"name": "cfgd-5.0.0-darwin-aarch64.tar.gz", "browser_download_url": "https://dl/darwin-arm64", "size": 10500},
{"name": "cfgd-5.0.0-windows-x86_64.zip", "browser_download_url": "https://dl/windows-x64", "size": 12000},
{"name": "cfgd-5.0.0-checksums.txt", "browser_download_url": "https://dl/checksums", "size": 512}
]
}"#,
)
.create();
let result = fetch_latest_release_from(&server.url(), "test/repo", None);
mock.assert();
let release = result.unwrap();
assert_eq!(release.version, Version::new(5, 0, 0));
assert_eq!(release.assets.len(), 6, "should parse all 6 assets");
let checksums = release.assets.iter().find(|a| a.name.contains("checksums"));
assert!(checksums.is_some());
assert_eq!(checksums.unwrap().size, 512);
}
fn release_with_assets(names: &[&str]) -> ReleaseInfo {
ReleaseInfo {
tag: "v1.0.0".into(),
version: Version::new(1, 0, 0),
assets: names
.iter()
.map(|n| ReleaseAsset {
name: (*n).to_string(),
download_url: format!("https://example.com/{}", n),
size: 100,
})
.collect(),
}
}
#[test]
fn find_cosign_bundle_asset_locates_by_suffix() {
let release = release_with_assets(&[
"cfgd-1.0.0-linux-x86_64.tar.gz",
"cfgd-1.0.0-checksums.txt",
"cfgd-1.0.0-checksums.txt.cosign.bundle",
]);
let found = find_cosign_bundle_asset(&release).expect("bundle must be located by suffix");
assert_eq!(found.name, "cfgd-1.0.0-checksums.txt.cosign.bundle");
}
#[test]
fn find_cosign_bundle_asset_returns_none_when_no_bundle() {
let release =
release_with_assets(&["cfgd-1.0.0-linux-x86_64.tar.gz", "cfgd-1.0.0-checksums.txt"]);
assert!(find_cosign_bundle_asset(&release).is_none());
}
#[test]
fn find_cosign_bundle_asset_ignores_lookalike_names() {
let release = release_with_assets(&[
"cosign.bundle.txt",
"cfgd-1.0.0-checksums.txt.cosign.bundle.bak",
]);
assert!(
find_cosign_bundle_asset(&release).is_none(),
"non-matching suffix must not be selected"
);
}
#[test]
fn find_cosign_bundle_asset_empty_release_yields_none() {
let release = release_with_assets(&[]);
assert!(find_cosign_bundle_asset(&release).is_none());
}
#[test]
fn find_cosign_public_key_asset_matches_bare_cosign_pub() {
let release = release_with_assets(&[
"cfgd-1.0.0-linux-x86_64.tar.gz",
"cosign.pub",
"cfgd-1.0.0-checksums.txt",
]);
let found = find_cosign_public_key_asset(&release).expect("bare cosign.pub must be located");
assert_eq!(found.name, "cosign.pub");
}
#[test]
fn find_cosign_public_key_asset_matches_versioned_cosign_pub() {
let release = release_with_assets(&["cfgd-1.0.0-cosign.pub"]);
let found = find_cosign_public_key_asset(&release)
.expect("versioned -cosign.pub variant must match the suffix branch");
assert_eq!(found.name, "cfgd-1.0.0-cosign.pub");
}
#[test]
fn find_cosign_public_key_asset_returns_none_when_missing() {
let release = release_with_assets(&[
"cfgd-1.0.0-linux-x86_64.tar.gz",
"cfgd-1.0.0-checksums.txt",
"cfgd-1.0.0-checksums.txt.cosign.bundle",
]);
assert!(
find_cosign_public_key_asset(&release).is_none(),
"no key → cosign verify is skipped (caller falls back to SHA256-only)"
);
}
#[test]
fn find_cosign_public_key_asset_does_not_match_pub_anywhere() {
let release = release_with_assets(&["cosign.publickey", "another.pub.bak"]);
assert!(find_cosign_public_key_asset(&release).is_none());
}
#[test]
fn check_with_cache_falls_back_to_api_on_cache_miss() {
let home = tempfile::tempdir().unwrap();
let _guard = crate::with_test_home_guard(home.path());
let mut server = mockito::Server::new();
let mock = server
.mock("GET", "/repos/test/repo/releases/latest")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{
"tag_name": "v99.0.0",
"assets": []
}"#,
)
.create();
let result = fetch_latest_release_from(&server.url(), "test/repo", None);
mock.assert();
let release = result.expect("mock release must parse");
assert_eq!(release.tag, "v99.0.0");
assert_eq!(release.version, Version::new(99, 0, 0));
}
#[test]
fn check_with_cache_returns_cached_when_within_ttl() {
let home = tempfile::tempdir().unwrap();
let _guard = crate::with_test_home_guard(home.path());
let now = std::time::SystemTime::now()
.duration_since(std::time::SystemTime::UNIX_EPOCH)
.unwrap()
.as_secs();
let cached = VersionCache {
checked_at_secs: now,
latest_tag: "v123.0.0".into(),
latest_version: "123.0.0".into(),
current_version: env!("CARGO_PKG_VERSION").into(),
};
write_version_cache(&cached).expect("cache seed must succeed in tempdir");
let result = check_with_cache(Some("does/not/matter"), None)
.expect("cache hit must short-circuit to local data, never touch the network");
assert_eq!(
result.latest,
Version::new(123, 0, 0),
"latest must come from the cache, not a remote call"
);
assert!(
result.release.is_none(),
"cache hit returns just the version summary, no full ReleaseInfo"
);
}
#[test]
fn check_with_cache_ignores_expired_entry() {
let home = tempfile::tempdir().unwrap();
let _guard = crate::with_test_home_guard(home.path());
let stale_secs = std::time::SystemTime::now()
.duration_since(std::time::SystemTime::UNIX_EPOCH)
.unwrap()
.as_secs()
.saturating_sub(CACHE_TTL_SECS + 60);
let stale = VersionCache {
checked_at_secs: stale_secs,
latest_tag: "v0.0.1".into(),
latest_version: "0.0.1".into(),
current_version: env!("CARGO_PKG_VERSION").into(),
};
write_version_cache(&stale).expect("seed stale cache");
let read = read_version_cache().expect("seeded entry must be readable");
assert_eq!(read.latest_tag, "v0.0.1");
let now = std::time::SystemTime::now()
.duration_since(std::time::SystemTime::UNIX_EPOCH)
.unwrap()
.as_secs();
assert!(
now.saturating_sub(read.checked_at_secs) >= CACHE_TTL_SECS,
"test setup: stale entry must be older than CACHE_TTL_SECS"
);
}
#[cfg(unix)]
mod cosign_verify_blob {
use super::*;
use crate::test_helpers::CosignTestShim;
use serial_test::serial;
fn dummy_paths() -> (
tempfile::TempDir,
std::path::PathBuf,
std::path::PathBuf,
std::path::PathBuf,
) {
let dir = tempfile::TempDir::new().expect("tempdir");
let checksums = dir.path().join("checksums.txt");
let bundle = dir.path().join("bundle.json");
let pub_key = dir.path().join("cosign.pub");
std::fs::write(&checksums, "deadbeef some.tar.gz\n").unwrap();
std::fs::write(&bundle, "{}").unwrap();
std::fs::write(&pub_key, "key").unwrap();
(dir, checksums, bundle, pub_key)
}
#[test]
#[serial]
fn run_cosign_verify_blob_passes_key_bundle_and_checksums_paths() {
let shim = CosignTestShim::install();
let (_dir, checksums, bundle, pub_key) = dummy_paths();
run_cosign_verify_blob(&checksums, &bundle, &pub_key).expect("happy path → Ok");
let argv = shim.argv_log();
assert!(
argv.contains("verify-blob"),
"argv must use verify-blob subcommand: {argv}"
);
assert!(
argv.contains(&format!("--key={}", pub_key.display())),
"argv must include --key=<pub_key path>: {argv}"
);
assert!(
argv.contains(&format!("--bundle={}", bundle.display())),
"argv must include --bundle=<bundle path>: {argv}"
);
assert!(
argv.contains(" -- "),
"argv must include `--` terminator: {argv}"
);
}
#[test]
#[serial]
fn run_cosign_verify_blob_propagates_failure_with_stderr_message() {
let _shim = CosignTestShim::builder()
.with_exit(1)
.with_stderr("signature does not match")
.install();
let (_dir, checksums, bundle, pub_key) = dummy_paths();
let err =
run_cosign_verify_blob(&checksums, &bundle, &pub_key).expect_err("non-zero exit → Err");
let msg = format!("{err}");
assert!(
msg.contains("signature does not match"),
"stderr surfaced in error message: {msg}"
);
assert!(
msg.contains("cosign verify-blob failed"),
"error prefixes with verify-blob context: {msg}"
);
}
#[test]
#[serial]
fn run_cosign_verify_blob_surfaces_invocation_failure_when_binary_missing() {
unsafe {
std::env::set_var("CFGD_COSIGN_BIN", "/no/such/cosign/binary");
std::env::remove_var("CFGD_FAKE_COSIGN_LOG");
}
let (_dir, checksums, bundle, pub_key) = dummy_paths();
let err = run_cosign_verify_blob(&checksums, &bundle, &pub_key)
.expect_err("missing binary → Err");
let msg = format!("{err}");
assert!(
msg.contains("cosign invocation failed"),
"error prefixes with invocation context: {msg}"
);
unsafe {
std::env::remove_var("CFGD_COSIGN_BIN");
}
}
}
#[cfg(unix)]
mod download_and_install_to {
use super::*;
use crate::test_helpers::CosignTestShim;
use serial_test::serial;
use std::os::unix::fs::PermissionsExt;
fn build_tarball(binary_content: &[u8]) -> Vec<u8> {
let dir = tempfile::tempdir().expect("tempdir");
let bin_path = dir.path().join("cfgd");
std::fs::write(&bin_path, binary_content).unwrap();
let mut perms = std::fs::metadata(&bin_path).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&bin_path, perms).unwrap();
let mut buf: Vec<u8> = Vec::new();
{
let enc = flate2::write::GzEncoder::new(&mut buf, flate2::Compression::default());
let mut tar_builder = tar::Builder::new(enc);
tar_builder
.append_path_with_name(&bin_path, "cfgd")
.unwrap();
tar_builder.finish().unwrap();
}
buf
}
fn build_tarball_without_binary() -> Vec<u8> {
let dir = tempfile::tempdir().expect("tempdir");
let other = dir.path().join("README");
std::fs::write(&other, b"not the binary").unwrap();
let mut buf: Vec<u8> = Vec::new();
{
let enc = flate2::write::GzEncoder::new(&mut buf, flate2::Compression::default());
let mut tar_builder = tar::Builder::new(enc);
tar_builder.append_path_with_name(&other, "README").unwrap();
tar_builder.finish().unwrap();
}
buf
}
fn checksums_line(sha: &str, name: &str) -> String {
format!("{sha} {name}\n")
}
fn release_with_full_signature_chain(server_url: &str) -> ReleaseInfo {
ReleaseInfo {
tag: "v9.9.9".into(),
version: Version::new(9, 9, 9),
assets: vec![
ReleaseAsset {
name: "cfgd-9.9.9-linux-x86_64.tar.gz".into(),
download_url: format!("{server_url}/download/cfgd.tar.gz"),
size: 0,
},
ReleaseAsset {
name: "cfgd-9.9.9-checksums.txt".into(),
download_url: format!("{server_url}/download/checksums.txt"),
size: 0,
},
ReleaseAsset {
name: "cfgd-9.9.9-checksums.txt.cosign.bundle".into(),
download_url: format!("{server_url}/download/cosign.bundle"),
size: 0,
},
ReleaseAsset {
name: "cosign.pub".into(),
download_url: format!("{server_url}/download/cosign.pub"),
size: 0,
},
],
}
}
#[test]
#[serial]
fn happy_path_installs_extracted_binary_to_target() {
let _shim = CosignTestShim::builder().with_argv_logging(false).install();
let binary_content = b"#!/bin/sh\necho fake cfgd binary\n";
let tarball = build_tarball(binary_content);
let sha = crate::sha256_hex(&tarball);
let asset_name = "cfgd-9.9.9-linux-x86_64.tar.gz";
let checksums = checksums_line(&sha, asset_name);
let mut server = mockito::Server::new();
let _m_archive = server
.mock("GET", "/download/cfgd.tar.gz")
.with_status(200)
.with_body(&tarball)
.create();
let _m_checksums = server
.mock("GET", "/download/checksums.txt")
.with_status(200)
.with_body(checksums)
.create();
let _m_bundle = server
.mock("GET", "/download/cosign.bundle")
.with_status(200)
.with_body("{}")
.create();
let _m_pubkey = server
.mock("GET", "/download/cosign.pub")
.with_status(200)
.with_body("dummy-key")
.create();
let release = release_with_full_signature_chain(&server.url());
let asset = release.assets[0].clone();
let target_dir = tempfile::tempdir().unwrap();
let target = target_dir.path().join("cfgd");
std::fs::write(&target, b"old binary").unwrap();
let installed = download_and_install_to(&release, &asset, &target, false, None)
.expect("happy path → Ok");
assert_eq!(
installed.installed_path, target,
"returned path matches install target"
);
assert_eq!(
installed.verification_mode,
VerificationMode::Cosign,
"happy path with bundle + key + shim → full cosign verification"
);
let installed_bytes = std::fs::read(&target).unwrap();
assert_eq!(
installed_bytes, binary_content,
"target now holds the extracted binary content"
);
}
#[test]
#[serial]
fn happy_path_with_printer_drives_spinner_branches() {
let _shim = CosignTestShim::builder().with_argv_logging(false).install();
let binary_content = b"#!/bin/sh\necho printer cfgd binary\n";
let tarball = build_tarball(binary_content);
let sha = crate::sha256_hex(&tarball);
let asset_name = "cfgd-9.9.9-linux-x86_64.tar.gz";
let checksums = checksums_line(&sha, asset_name);
let mut server = mockito::Server::new();
let _m_archive = server
.mock("GET", "/download/cfgd.tar.gz")
.with_status(200)
.with_body(&tarball)
.create();
let _m_checksums = server
.mock("GET", "/download/checksums.txt")
.with_status(200)
.with_body(checksums)
.create();
let _m_bundle = server
.mock("GET", "/download/cosign.bundle")
.with_status(200)
.with_body("{}")
.create();
let _m_pubkey = server
.mock("GET", "/download/cosign.pub")
.with_status(200)
.with_body("dummy-key")
.create();
let release = release_with_full_signature_chain(&server.url());
let asset = release.assets[0].clone();
let target_dir = tempfile::tempdir().unwrap();
let target = target_dir.path().join("cfgd");
std::fs::write(&target, b"old binary").unwrap();
let printer = crate::test_helpers::test_printer();
let installed = download_and_install_to(&release, &asset, &target, false, Some(&printer))
.expect("happy path with printer → Ok");
assert_eq!(installed.installed_path, target);
assert_eq!(std::fs::read(&target).unwrap(), binary_content);
}
#[test]
#[serial]
fn returns_download_failed_when_archive_url_returns_5xx() {
let _shim = CosignTestShim::builder().with_argv_logging(false).install();
let mut server = mockito::Server::new();
let _m = server
.mock("GET", "/download/cfgd.tar.gz")
.with_status(503)
.create();
let release = release_with_full_signature_chain(&server.url());
let asset = release.assets[0].clone();
let target_dir = tempfile::tempdir().unwrap();
let target = target_dir.path().join("cfgd");
let err = download_and_install_to(&release, &asset, &target, false, None)
.expect_err("5xx on archive → Err");
let msg = err.to_string();
assert!(
msg.to_ascii_lowercase().contains("download")
|| msg.contains("503")
|| msg.contains("status"),
"error mentions download failure: {msg}"
);
assert!(!target.exists(), "target must not be created on failure");
}
#[test]
#[serial]
fn returns_download_failed_when_checksums_url_returns_404() {
let _shim = CosignTestShim::builder().with_argv_logging(false).install();
let tarball = build_tarball(b"binary");
let mut server = mockito::Server::new();
let _m_archive = server
.mock("GET", "/download/cfgd.tar.gz")
.with_status(200)
.with_body(&tarball)
.create();
let _m_checksums = server
.mock("GET", "/download/checksums.txt")
.with_status(404)
.create();
let release = release_with_full_signature_chain(&server.url());
let asset = release.assets[0].clone();
let target_dir = tempfile::tempdir().unwrap();
let target = target_dir.path().join("cfgd");
let err = download_and_install_to(&release, &asset, &target, false, None)
.expect_err("404 on checksums → Err");
let msg = err.to_string();
assert!(
msg.to_ascii_lowercase().contains("download") || msg.contains("404"),
"error mentions download failure for checksums: {msg}"
);
}
#[test]
#[serial]
fn propagates_cosign_failure_when_signature_verification_fails() {
let _shim = CosignTestShim::builder()
.with_argv_logging(false)
.with_exit(1)
.with_stderr("tampered checksums file")
.install();
let tarball = build_tarball(b"binary");
let sha = crate::sha256_hex(&tarball);
let asset_name = "cfgd-9.9.9-linux-x86_64.tar.gz";
let checksums = checksums_line(&sha, asset_name);
let mut server = mockito::Server::new();
let _m_archive = server
.mock("GET", "/download/cfgd.tar.gz")
.with_status(200)
.with_body(&tarball)
.create();
let _m_checksums = server
.mock("GET", "/download/checksums.txt")
.with_status(200)
.with_body(checksums)
.create();
let _m_bundle = server
.mock("GET", "/download/cosign.bundle")
.with_status(200)
.with_body("{}")
.create();
let _m_pubkey = server
.mock("GET", "/download/cosign.pub")
.with_status(200)
.with_body("dummy-key")
.create();
let release = release_with_full_signature_chain(&server.url());
let asset = release.assets[0].clone();
let target_dir = tempfile::tempdir().unwrap();
let target = target_dir.path().join("cfgd");
let err = download_and_install_to(&release, &asset, &target, false, None)
.expect_err("cosign exit 1 → Err");
let msg = err.to_string();
assert!(
msg.contains("cosign verify-blob failed") && msg.contains("tampered checksums file"),
"error surfaces cosign-verify failure with stderr message: {msg}"
);
assert!(!target.exists(), "target must not be created on failure");
}
#[test]
#[serial]
fn returns_checksum_mismatch_when_sha_differs_over_the_wire() {
let _shim = CosignTestShim::builder().with_argv_logging(false).install();
let tarball = build_tarball(b"actual-binary");
let asset_name = "cfgd-9.9.9-linux-x86_64.tar.gz";
let bogus_sha = "0".repeat(64);
let checksums = checksums_line(&bogus_sha, asset_name);
let mut server = mockito::Server::new();
let _m_archive = server
.mock("GET", "/download/cfgd.tar.gz")
.with_status(200)
.with_body(&tarball)
.create();
let _m_checksums = server
.mock("GET", "/download/checksums.txt")
.with_status(200)
.with_body(checksums)
.create();
let _m_bundle = server
.mock("GET", "/download/cosign.bundle")
.with_status(200)
.with_body("{}")
.create();
let _m_pubkey = server
.mock("GET", "/download/cosign.pub")
.with_status(200)
.with_body("dummy-key")
.create();
let release = release_with_full_signature_chain(&server.url());
let asset = release.assets[0].clone();
let target_dir = tempfile::tempdir().unwrap();
let target = target_dir.path().join("cfgd");
let err = download_and_install_to(&release, &asset, &target, false, None)
.expect_err("checksum mismatch → Err");
let msg = err.to_string();
assert!(
msg.to_ascii_lowercase().contains("checksum") && msg.contains(asset_name),
"error names asset and surfaces checksum mismatch: {msg}"
);
}
#[test]
#[serial]
fn returns_checksum_missing_when_release_has_no_checksums_asset() {
let _shim = CosignTestShim::builder().with_argv_logging(false).install();
let tarball = build_tarball(b"binary");
let mut server = mockito::Server::new();
let _m_archive = server
.mock("GET", "/download/cfgd.tar.gz")
.with_status(200)
.with_body(&tarball)
.create();
let release = ReleaseInfo {
tag: "v9.9.9".into(),
version: Version::new(9, 9, 9),
assets: vec![ReleaseAsset {
name: "cfgd-9.9.9-linux-x86_64.tar.gz".into(),
download_url: format!("{}/download/cfgd.tar.gz", server.url()),
size: 0,
}],
};
let asset = release.assets[0].clone();
let target_dir = tempfile::tempdir().unwrap();
let target = target_dir.path().join("cfgd");
let err = download_and_install_to(&release, &asset, &target, false, None)
.expect_err("no checksums asset → Err");
let msg = err.to_string();
assert!(
msg.to_ascii_lowercase().contains("missing") || msg.contains(&asset.name),
"error reports missing checksums for asset: {msg}"
);
}
#[test]
#[serial]
fn returns_install_failed_when_archive_lacks_cfgd_binary() {
let _shim = CosignTestShim::builder().with_argv_logging(false).install();
let tarball = build_tarball_without_binary();
let sha = crate::sha256_hex(&tarball);
let asset_name = "cfgd-9.9.9-linux-x86_64.tar.gz";
let checksums = checksums_line(&sha, asset_name);
let mut server = mockito::Server::new();
let _m_archive = server
.mock("GET", "/download/cfgd.tar.gz")
.with_status(200)
.with_body(&tarball)
.create();
let _m_checksums = server
.mock("GET", "/download/checksums.txt")
.with_status(200)
.with_body(checksums)
.create();
let _m_bundle = server
.mock("GET", "/download/cosign.bundle")
.with_status(200)
.with_body("{}")
.create();
let _m_pubkey = server
.mock("GET", "/download/cosign.pub")
.with_status(200)
.with_body("dummy-key")
.create();
let release = release_with_full_signature_chain(&server.url());
let asset = release.assets[0].clone();
let target_dir = tempfile::tempdir().unwrap();
let target = target_dir.path().join("cfgd");
let err = download_and_install_to(&release, &asset, &target, false, None)
.expect_err("missing cfgd in tar → Err");
let msg = err.to_string();
assert!(
msg.contains("does not contain") && msg.contains("cfgd"),
"error reports missing cfgd binary in extracted archive: {msg}"
);
}
#[test]
#[serial]
fn returns_checksum_missing_when_asset_not_listed_in_checksums_body() {
let _shim = CosignTestShim::builder().with_argv_logging(false).install();
let tarball = build_tarball(b"binary");
let sha = crate::sha256_hex(&tarball);
let checksums = checksums_line(&sha, "some-other-asset.tar.gz");
let mut server = mockito::Server::new();
let _m_archive = server
.mock("GET", "/download/cfgd.tar.gz")
.with_status(200)
.with_body(&tarball)
.create();
let _m_checksums = server
.mock("GET", "/download/checksums.txt")
.with_status(200)
.with_body(checksums)
.create();
let _m_bundle = server
.mock("GET", "/download/cosign.bundle")
.with_status(200)
.with_body("{}")
.create();
let _m_pubkey = server
.mock("GET", "/download/cosign.pub")
.with_status(200)
.with_body("dummy-key")
.create();
let release = release_with_full_signature_chain(&server.url());
let asset = release.assets[0].clone();
let target_dir = tempfile::tempdir().unwrap();
let target = target_dir.path().join("cfgd");
let err = download_and_install_to(&release, &asset, &target, false, None)
.expect_err("asset name not in checksums → Err");
let msg = err.to_string();
assert!(
msg.contains(&asset.name) && msg.contains("not listed"),
"error names the asset whose checksum entry is missing: {msg}"
);
}
#[test]
#[serial]
fn skips_cosign_verification_when_release_has_no_bundle_asset() {
let _shim = CosignTestShim::builder()
.with_argv_logging(false)
.with_exit(99)
.with_stderr("should not be invoked")
.install();
let binary_content = b"binary";
let tarball = build_tarball(binary_content);
let sha = crate::sha256_hex(&tarball);
let asset_name = "cfgd-9.9.9-linux-x86_64.tar.gz";
let checksums = checksums_line(&sha, asset_name);
let mut server = mockito::Server::new();
let _m_archive = server
.mock("GET", "/download/cfgd.tar.gz")
.with_status(200)
.with_body(&tarball)
.create();
let _m_checksums = server
.mock("GET", "/download/checksums.txt")
.with_status(200)
.with_body(checksums)
.create();
let release = ReleaseInfo {
tag: "v9.9.9".into(),
version: Version::new(9, 9, 9),
assets: vec![
ReleaseAsset {
name: asset_name.into(),
download_url: format!("{}/download/cfgd.tar.gz", server.url()),
size: 0,
},
ReleaseAsset {
name: "cfgd-9.9.9-checksums.txt".into(),
download_url: format!("{}/download/checksums.txt", server.url()),
size: 0,
},
],
};
let asset = release.assets[0].clone();
let target_dir = tempfile::tempdir().unwrap();
let target = target_dir.path().join("cfgd");
let installed = download_and_install_to(&release, &asset, &target, false, None)
.expect("no cosign bundle → SHA-only install should succeed");
assert_eq!(installed.installed_path, target);
assert_eq!(
installed.verification_mode,
VerificationMode::Sha256Only,
"no bundle + non-strict → SHA256-only mode recorded in report"
);
assert_eq!(std::fs::read(&target).unwrap(), binary_content);
}
#[test]
#[serial]
fn verify_cosign_bundle_emits_warning_when_no_bundle_attached() {
let _shim = CosignTestShim::builder().with_argv_logging(false).install();
let release = release_with_assets(&["cfgd-9.9.9-linux-x86_64.tar.gz"]);
let tmp = tempfile::tempdir().unwrap();
let checksums_path = tmp.path().join("checksums.txt");
std::fs::write(&checksums_path, "").unwrap();
let (printer, buf) = crate::output::Printer::for_test_at(crate::output::Verbosity::Normal);
let outcome =
verify_cosign_bundle(&checksums_path, &release, tmp.path(), false, Some(&printer))
.expect("missing bundle is graceful-degrade, not Err");
assert_eq!(
outcome,
VerificationMode::Sha256Only,
"no bundle → Sha256Only so caller falls back to SHA256-only"
);
let captured = buf.lock().unwrap().clone();
assert!(
captured.contains("no cosign bundle attached"),
"warning text must surface so operators see the trust downgrade: {captured}"
);
}
#[test]
#[serial]
fn verify_cosign_bundle_emits_warning_when_no_public_key_attached() {
let _shim = CosignTestShim::builder().with_argv_logging(false).install();
let release = release_with_assets(&[
"cfgd-9.9.9-linux-x86_64.tar.gz",
"cfgd-9.9.9-checksums.txt.cosign.bundle",
]);
let tmp = tempfile::tempdir().unwrap();
let checksums_path = tmp.path().join("checksums.txt");
std::fs::write(&checksums_path, "").unwrap();
let (printer, buf) = crate::output::Printer::for_test_at(crate::output::Verbosity::Normal);
let outcome =
verify_cosign_bundle(&checksums_path, &release, tmp.path(), false, Some(&printer))
.expect("missing pubkey is graceful-degrade, not Err");
assert_eq!(outcome, VerificationMode::Sha256Only);
let captured = buf.lock().unwrap().clone();
assert!(
captured.contains("no public key attached") && captured.contains("cosign.pub"),
"warning must name the missing cosign.pub asset: {captured}"
);
}
#[test]
#[serial]
fn verify_cosign_bundle_emits_warning_when_cosign_cli_missing() {
struct MissingCosignGuard;
impl Drop for MissingCosignGuard {
fn drop(&mut self) {
unsafe {
std::env::remove_var("CFGD_COSIGN_BIN");
}
}
}
unsafe {
std::env::set_var(
"CFGD_COSIGN_BIN",
"/nonexistent/cfgd-test-cosign-shim-does-not-exist",
);
}
let _guard = MissingCosignGuard;
let release = release_with_assets(&[
"cfgd-9.9.9-linux-x86_64.tar.gz",
"cfgd-9.9.9-checksums.txt.cosign.bundle",
"cosign.pub",
]);
let tmp = tempfile::tempdir().unwrap();
let checksums_path = tmp.path().join("checksums.txt");
std::fs::write(&checksums_path, "").unwrap();
let (printer, buf) = crate::output::Printer::for_test_at(crate::output::Verbosity::Normal);
let outcome =
verify_cosign_bundle(&checksums_path, &release, tmp.path(), false, Some(&printer))
.expect("missing cosign CLI is graceful-degrade, not Err");
assert_eq!(outcome, VerificationMode::Sha256Only);
let captured = buf.lock().unwrap().clone();
assert!(
captured.contains("cosign CLI is not installed"),
"warning must point operators at the install hint: {captured}"
);
}
#[test]
#[serial]
fn verify_cosign_bundle_strict_fails_when_no_bundle_in_release() {
let _shim = CosignTestShim::builder().with_argv_logging(false).install();
let release = release_with_assets(&["cfgd-9.9.9-linux-x86_64.tar.gz"]);
let tmp = tempfile::tempdir().unwrap();
let checksums_path = tmp.path().join("checksums.txt");
std::fs::write(&checksums_path, "").unwrap();
let err = verify_cosign_bundle(&checksums_path, &release, tmp.path(), true, None)
.expect_err("strict mode + missing bundle must Err, not graceful-degrade");
assert!(
matches!(err, crate::errors::UpgradeError::CosignRequired { .. }),
"expected CosignRequired variant, got: {err:?}"
);
let msg = err.to_string();
assert!(
msg.contains("no cosign bundle"),
"error message must name the specific missing piece (bundle): {msg}"
);
}
#[test]
#[serial]
fn verify_cosign_bundle_strict_fails_when_no_pubkey_attached() {
let _shim = CosignTestShim::builder().with_argv_logging(false).install();
let release = release_with_assets(&[
"cfgd-9.9.9-linux-x86_64.tar.gz",
"cfgd-9.9.9-checksums.txt.cosign.bundle",
]);
let tmp = tempfile::tempdir().unwrap();
let checksums_path = tmp.path().join("checksums.txt");
std::fs::write(&checksums_path, "").unwrap();
let err = verify_cosign_bundle(&checksums_path, &release, tmp.path(), true, None)
.expect_err("strict mode + missing pubkey must Err");
assert!(
matches!(err, crate::errors::UpgradeError::CosignRequired { .. }),
"expected CosignRequired variant, got: {err:?}"
);
let msg = err.to_string();
assert!(
msg.contains("cosign.pub"),
"error message must name the missing cosign.pub: {msg}"
);
}
#[test]
#[serial]
fn verify_cosign_bundle_strict_fails_when_cosign_missing() {
struct MissingCosignGuard;
impl Drop for MissingCosignGuard {
fn drop(&mut self) {
unsafe {
std::env::remove_var("CFGD_COSIGN_BIN");
}
}
}
unsafe {
std::env::set_var(
"CFGD_COSIGN_BIN",
"/nonexistent/cfgd-test-strict-cosign-shim-does-not-exist",
);
}
let _guard = MissingCosignGuard;
let release = release_with_assets(&[
"cfgd-9.9.9-linux-x86_64.tar.gz",
"cfgd-9.9.9-checksums.txt.cosign.bundle",
"cosign.pub",
]);
let tmp = tempfile::tempdir().unwrap();
let checksums_path = tmp.path().join("checksums.txt");
std::fs::write(&checksums_path, "").unwrap();
let err = verify_cosign_bundle(&checksums_path, &release, tmp.path(), true, None)
.expect_err("strict mode + missing cosign CLI must Err");
assert!(
matches!(err, crate::errors::UpgradeError::CosignRequired { .. }),
"expected CosignRequired variant, got: {err:?}"
);
let msg = err.to_string();
assert!(
msg.contains("cosign CLI is not installed"),
"error message must name the missing CLI: {msg}"
);
}
#[test]
#[serial]
fn verify_cosign_bundle_non_strict_falls_back_silently() {
let _shim = CosignTestShim::builder().with_argv_logging(false).install();
let release = release_with_assets(&["cfgd-9.9.9-linux-x86_64.tar.gz"]);
let tmp = tempfile::tempdir().unwrap();
let checksums_path = tmp.path().join("checksums.txt");
std::fs::write(&checksums_path, "").unwrap();
let outcome = verify_cosign_bundle(&checksums_path, &release, tmp.path(), false, None)
.expect("non-strict mode + missing bundle must return Sha256Only, not Err");
assert_eq!(
outcome,
VerificationMode::Sha256Only,
"non-strict default contract: missing bundle → SHA256-only fallback"
);
}
#[test]
#[serial]
fn verify_cosign_bundle_strict_records_strict_cosign_required_on_success() {
let _shim = CosignTestShim::builder()
.with_argv_logging(false)
.with_exit(0)
.install();
let mut server = mockito::Server::new();
let _m_bundle = server
.mock("GET", "/strict/bundle")
.with_status(200)
.with_body("{}")
.create();
let _m_pubkey = server
.mock("GET", "/strict/pubkey")
.with_status(200)
.with_body("dummy-key")
.create();
let release = ReleaseInfo {
tag: "v9.9.9".into(),
version: Version::new(9, 9, 9),
assets: vec![
ReleaseAsset {
name: "cfgd-9.9.9-linux-x86_64.tar.gz".into(),
download_url: "https://example.com/binary".into(),
size: 0,
},
ReleaseAsset {
name: "cfgd-9.9.9-checksums.txt.cosign.bundle".into(),
download_url: format!("{}/strict/bundle", server.url()),
size: 0,
},
ReleaseAsset {
name: "cosign.pub".into(),
download_url: format!("{}/strict/pubkey", server.url()),
size: 0,
},
],
};
let tmp = tempfile::tempdir().unwrap();
let checksums_path = tmp.path().join("checksums.txt");
std::fs::write(&checksums_path, "deadbeef some.tar.gz\n").unwrap();
let outcome = verify_cosign_bundle(&checksums_path, &release, tmp.path(), true, None)
.expect("strict + all pieces present + cosign exit 0 → Ok");
assert_eq!(
outcome,
VerificationMode::StrictCosignRequired,
"successful strict-mode verification records StrictCosignRequired"
);
}
#[test]
#[serial]
fn download_and_install_strict_mode_blocks_when_bundle_missing() {
let _shim = CosignTestShim::builder()
.with_argv_logging(false)
.with_exit(99)
.install();
let binary_content = b"#!/bin/sh\necho strict\n";
let tarball = build_tarball(binary_content);
let sha = crate::sha256_hex(&tarball);
let asset_name = "cfgd-9.9.9-linux-x86_64.tar.gz";
let checksums = checksums_line(&sha, asset_name);
let mut server = mockito::Server::new();
let _m_archive = server
.mock("GET", "/download/cfgd.tar.gz")
.with_status(200)
.with_body(&tarball)
.create();
let _m_checksums = server
.mock("GET", "/download/checksums.txt")
.with_status(200)
.with_body(checksums)
.create();
let release = ReleaseInfo {
tag: "v9.9.9".into(),
version: Version::new(9, 9, 9),
assets: vec![
ReleaseAsset {
name: asset_name.into(),
download_url: format!("{}/download/cfgd.tar.gz", server.url()),
size: 0,
},
ReleaseAsset {
name: "cfgd-9.9.9-checksums.txt".into(),
download_url: format!("{}/download/checksums.txt", server.url()),
size: 0,
},
],
};
let asset = release.assets[0].clone();
let target_dir = tempfile::tempdir().unwrap();
let target = target_dir.path().join("cfgd");
let err = download_and_install_to(&release, &asset, &target, true, None)
.expect_err("strict + missing bundle must Err out of the install chain");
assert!(
matches!(
err,
crate::errors::CfgdError::Upgrade(
crate::errors::UpgradeError::CosignRequired { .. }
)
),
"expected CosignRequired surfaced through the CfgdError boundary, got: {err:?}"
);
assert!(
!target.exists(),
"target must NOT be written when strict cosign verification fails"
);
}
#[test]
#[serial]
fn download_and_install_records_cosign_mode_on_full_chain_happy_path() {
let _shim = CosignTestShim::builder()
.with_argv_logging(false)
.with_exit(0)
.install();
let binary_content = b"#!/bin/sh\necho mode\n";
let tarball = build_tarball(binary_content);
let sha = crate::sha256_hex(&tarball);
let asset_name = "cfgd-9.9.9-linux-x86_64.tar.gz";
let checksums = checksums_line(&sha, asset_name);
let mut server = mockito::Server::new();
let _m_archive = server
.mock("GET", "/download/cfgd.tar.gz")
.with_status(200)
.with_body(&tarball)
.create();
let _m_checksums = server
.mock("GET", "/download/checksums.txt")
.with_status(200)
.with_body(&checksums)
.create();
let _m_bundle = server
.mock("GET", "/download/cosign.bundle")
.with_status(200)
.with_body("{}")
.create();
let _m_pubkey = server
.mock("GET", "/download/cosign.pub")
.with_status(200)
.with_body("dummy-key")
.create();
let release = release_with_full_signature_chain(&server.url());
let asset = release.assets[0].clone();
let target_dir = tempfile::tempdir().unwrap();
let target = target_dir.path().join("cfgd");
std::fs::write(&target, b"old binary").unwrap();
let report = download_and_install_to(&release, &asset, &target, false, None)
.expect("full signature chain + non-strict → Ok");
assert_eq!(
report.verification_mode,
VerificationMode::Cosign,
"structured payload records full cosign verification"
);
}
#[test]
#[serial]
fn happy_path_with_printer_and_content_length_drives_progress_bar() {
let _shim = CosignTestShim::builder().with_argv_logging(false).install();
let binary_content = b"#!/bin/sh\necho progress-bar cfgd\n";
let tarball = build_tarball(binary_content);
let sha = crate::sha256_hex(&tarball);
let asset_name = "cfgd-9.9.9-linux-x86_64.tar.gz";
let checksums = checksums_line(&sha, asset_name);
let mut server = mockito::Server::new();
let _m_archive = server
.mock("GET", "/download/cfgd.tar.gz")
.with_status(200)
.with_header("content-length", &tarball.len().to_string())
.with_body(&tarball)
.create();
let _m_checksums = server
.mock("GET", "/download/checksums.txt")
.with_status(200)
.with_header("content-length", &checksums.len().to_string())
.with_body(&checksums)
.create();
let _m_bundle = server
.mock("GET", "/download/cosign.bundle")
.with_status(200)
.with_header("content-length", "2")
.with_body("{}")
.create();
let _m_pubkey = server
.mock("GET", "/download/cosign.pub")
.with_status(200)
.with_header("content-length", "9")
.with_body("dummy-key")
.create();
let release = release_with_full_signature_chain(&server.url());
let asset = release.assets[0].clone();
let target_dir = tempfile::tempdir().unwrap();
let target = target_dir.path().join("cfgd");
std::fs::write(&target, b"old binary").unwrap();
let printer = crate::test_helpers::test_printer();
let installed = download_and_install_to(&release, &asset, &target, false, Some(&printer))
.expect("progress-bar path with content-length must succeed");
assert_eq!(installed.installed_path, target);
assert_eq!(std::fs::read(&target).unwrap(), binary_content);
}
#[test]
#[serial]
fn checksum_mismatch_with_printer_surfaces_finish_fail_branch() {
let _shim = CosignTestShim::builder().with_argv_logging(false).install();
let tarball = build_tarball(b"binary");
let asset_name = "cfgd-9.9.9-linux-x86_64.tar.gz";
let bogus_sha = "f".repeat(64);
let checksums = checksums_line(&bogus_sha, asset_name);
let mut server = mockito::Server::new();
let _m_archive = server
.mock("GET", "/download/cfgd.tar.gz")
.with_status(200)
.with_body(&tarball)
.create();
let _m_checksums = server
.mock("GET", "/download/checksums.txt")
.with_status(200)
.with_body(&checksums)
.create();
let _m_bundle = server
.mock("GET", "/download/cosign.bundle")
.with_status(200)
.with_body("{}")
.create();
let _m_pubkey = server
.mock("GET", "/download/cosign.pub")
.with_status(200)
.with_body("dummy-key")
.create();
let release = release_with_full_signature_chain(&server.url());
let asset = release.assets[0].clone();
let target_dir = tempfile::tempdir().unwrap();
let target = target_dir.path().join("cfgd");
let printer = crate::test_helpers::test_printer();
let err = download_and_install_to(&release, &asset, &target, false, Some(&printer))
.expect_err("checksum mismatch with printer → Err");
let msg = err.to_string();
assert!(
msg.to_ascii_lowercase().contains("checksum"),
"error mentions checksum: {msg}"
);
}
}
#[test]
fn fetch_latest_release_api_error_on_http_500() {
let mut server = mockito::Server::new();
let _m = server
.mock("GET", "/repos/tj-smith47/cfgd/releases/latest")
.with_status(500)
.with_body("upstream blew up")
.create();
let err = fetch_latest_release_from(&server.url(), "tj-smith47/cfgd", None).unwrap_err();
let inner = match err {
crate::errors::CfgdError::Upgrade(u) => u,
other => panic!("expected CfgdError::Upgrade, got: {other:?}"),
};
match inner {
UpgradeError::ApiError { message } => {
assert!(message.contains("500"), "message: {message}");
}
other => panic!("expected ApiError, got: {other:?}"),
}
}
#[test]
fn fetch_latest_release_api_error_on_invalid_json_body() {
let mut server = mockito::Server::new();
let _m = server
.mock("GET", "/repos/tj-smith47/cfgd/releases/latest")
.with_status(200)
.with_body("not json at all { [ }")
.create();
let err = fetch_latest_release_from(&server.url(), "tj-smith47/cfgd", None).unwrap_err();
let inner = match err {
crate::errors::CfgdError::Upgrade(u) => u,
other => panic!("expected CfgdError::Upgrade, got: {other:?}"),
};
match inner {
UpgradeError::ApiError { message } => {
assert!(
message.contains("json") || message.contains("parse") || message.contains("JSON"),
"expected JSON parse error, got: {message}"
);
}
other => panic!("expected ApiError, got: {other:?}"),
}
}
#[test]
fn write_version_cache_creates_dir_and_writes_file() {
let home = tempfile::tempdir().unwrap();
let _guard = crate::with_test_home_guard(home.path());
let cache = VersionCache {
checked_at_secs: 1234567890,
latest_tag: "v5.0.0".into(),
latest_version: "5.0.0".into(),
current_version: "4.0.0".into(),
};
write_version_cache(&cache).expect("write_version_cache should create dir and file");
let cache_path = home.path().join(".cache").join("cfgd").join(CACHE_FILENAME);
assert!(cache_path.exists(), "cache file must exist after write");
let content = fs::read_to_string(&cache_path).unwrap();
let restored: VersionCache = serde_json::from_str(&content).unwrap();
assert_eq!(restored.checked_at_secs, 1234567890);
assert_eq!(restored.latest_version, "5.0.0");
}
#[test]
fn write_version_cache_overwrites_existing_file() {
let home = tempfile::tempdir().unwrap();
let _guard = crate::with_test_home_guard(home.path());
let first = VersionCache {
checked_at_secs: 100,
latest_tag: "v1.0.0".into(),
latest_version: "1.0.0".into(),
current_version: "0.9.0".into(),
};
write_version_cache(&first).unwrap();
let second = VersionCache {
checked_at_secs: 200,
latest_tag: "v2.0.0".into(),
latest_version: "2.0.0".into(),
current_version: "1.0.0".into(),
};
write_version_cache(&second).unwrap();
let read = read_version_cache().expect("should read back second write");
assert_eq!(read.checked_at_secs, 200);
assert_eq!(read.latest_version, "2.0.0");
}
#[test]
fn read_version_cache_returns_none_for_empty_file() {
let home = tempfile::tempdir().unwrap();
let _guard = crate::with_test_home_guard(home.path());
let dir = home.path().join(".cache").join("cfgd");
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join(CACHE_FILENAME), "").unwrap();
assert!(
read_version_cache().is_none(),
"empty file should fail JSON parse and return None"
);
}
#[test]
fn read_version_cache_returns_none_for_invalid_json() {
let home = tempfile::tempdir().unwrap();
let _guard = crate::with_test_home_guard(home.path());
let dir = home.path().join(".cache").join("cfgd");
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join(CACHE_FILENAME), "not valid json {{{").unwrap();
assert!(
read_version_cache().is_none(),
"invalid JSON should return None gracefully"
);
}
#[test]
fn cache_dir_returns_test_home_scoped_path() {
let home = tempfile::tempdir().unwrap();
let _guard = crate::with_test_home_guard(home.path());
let dir = cache_dir().expect("cache_dir with test_home must return Some");
assert!(
dir.starts_with(home.path()),
"cache dir must be under test home: {dir:?}"
);
assert!(
dir.ends_with("cfgd"),
"cache dir must end with 'cfgd': {dir:?}"
);
}
#[test]
fn download_to_file_large_content_streams_correctly() {
let mut server = mockito::Server::new();
let large_body: Vec<u8> = vec![0xAB; 32 * 1024];
let mock = server
.mock("GET", "/download/large")
.with_status(200)
.with_header("content-length", &large_body.len().to_string())
.with_body(&large_body)
.create();
let dir = tempfile::tempdir().unwrap();
let dest = dir.path().join("large.bin");
let url = format!("{}/download/large", server.url());
download_to_file(&url, &dest, None).unwrap();
mock.assert();
let content = std::fs::read(&dest).unwrap();
assert_eq!(content.len(), large_body.len());
assert_eq!(content, large_body);
}
#[test]
fn download_to_file_with_printer_and_content_length_drives_progress_bar() {
let mut server = mockito::Server::new();
let body: Vec<u8> = vec![0xCD; 16 * 1024];
let mock = server
.mock("GET", "/download/progress")
.with_status(200)
.with_header("content-length", &body.len().to_string())
.with_body(&body)
.create();
let dir = tempfile::tempdir().unwrap();
let dest = dir.path().join("progress.bin");
let url = format!("{}/download/progress", server.url());
let printer = crate::test_helpers::test_printer();
download_to_file(&url, &dest, Some(&printer)).unwrap();
mock.assert();
assert_eq!(std::fs::read(&dest).unwrap(), body);
}
#[test]
fn parse_release_json_version_parse_error_includes_tag_in_message() {
let json = r#"{"tag_name": "vnot-a-version", "assets": []}"#;
let err = parse_release_json(json).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("vnot-a-version"),
"error must include the problematic tag for triage: {msg}"
);
assert!(
msg.contains("cannot parse release version"),
"error must include context prefix: {msg}"
);
}
#[test]
fn parse_release_json_empty_tag_name_fails_version_parse() {
let json = r#"{"tag_name": "", "assets": []}"#;
let err = parse_release_json(json).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("cannot parse"),
"empty tag must fail version parse: {msg}"
);
}
mod api_base_env_shim {
use super::*;
use crate::test_helpers::EnvVarGuard;
use serial_test::serial;
fn mock_release_response(server: &mut mockito::ServerGuard) -> mockito::Mock {
server
.mock("GET", "/repos/test/repo/releases/latest")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{
"tag_name": "v999.0.0",
"assets": []
}"#,
)
.create()
}
#[test]
#[serial]
fn check_latest_uses_env_shim_to_redirect_api_call() {
let mut server = mockito::Server::new();
let mock = mock_release_response(&mut server);
let _env = EnvVarGuard::set(GITHUB_API_BASE_ENV, &server.url());
let result = check_latest(Some("test/repo"), None)
.expect("env-shim redirect should make the call succeed against mockito");
mock.assert();
assert_eq!(result.latest, Version::new(999, 0, 0));
assert!(
result.update_available,
"999.0.0 must register as a newer version than current"
);
assert!(
result.release.is_some(),
"check_latest always carries the full ReleaseInfo on success"
);
}
#[test]
#[serial]
fn check_with_cache_falls_through_to_api_and_writes_fresh_cache_entry() {
let home = tempfile::tempdir().unwrap();
let _home_guard = crate::with_test_home_guard(home.path());
let mut server = mockito::Server::new();
let mock = mock_release_response(&mut server);
let _env = EnvVarGuard::set(GITHUB_API_BASE_ENV, &server.url());
let result = check_with_cache(Some("test/repo"), None)
.expect("cache miss + env-shim redirect should succeed");
mock.assert();
assert_eq!(result.latest, Version::new(999, 0, 0));
let cache_path = home.path().join(".cache").join("cfgd").join(CACHE_FILENAME);
assert!(
cache_path.exists(),
"fresh cache must be written to {cache_path:?} after API success"
);
let cache = read_version_cache().expect("written cache must parse back");
assert_eq!(cache.latest_version, "999.0.0");
assert_eq!(cache.latest_tag, "v999.0.0");
}
#[test]
#[serial]
fn check_with_cache_expired_entry_falls_through_to_api_and_refreshes() {
let home = tempfile::tempdir().unwrap();
let _home_guard = crate::with_test_home_guard(home.path());
let now = std::time::SystemTime::now()
.duration_since(std::time::SystemTime::UNIX_EPOCH)
.unwrap()
.as_secs();
let expired = VersionCache {
checked_at_secs: now.saturating_sub(CACHE_TTL_SECS + 3600),
latest_tag: "v0.0.1".into(),
latest_version: "0.0.1".into(),
current_version: env!("CARGO_PKG_VERSION").into(),
};
write_version_cache(&expired).expect("seed stale cache");
let mut server = mockito::Server::new();
let mock = server
.mock("GET", "/repos/test/repo/releases/latest")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
r#"{
"tag_name": "v888.0.0",
"assets": []
}"#,
)
.create();
let _env = EnvVarGuard::set(GITHUB_API_BASE_ENV, &server.url());
let result = check_with_cache(Some("test/repo"), None)
.expect("expired cache + API success should succeed");
mock.assert();
assert_eq!(
result.latest,
Version::new(888, 0, 0),
"must return fresh API data, not stale cache"
);
let refreshed = read_version_cache().expect("cache must be refreshed after API");
assert_eq!(refreshed.latest_version, "888.0.0");
assert_eq!(refreshed.latest_tag, "v888.0.0");
assert!(
refreshed.checked_at_secs >= now,
"cache timestamp must be updated to ~now"
);
}
#[test]
#[serial]
fn check_latest_with_none_repo_uses_default() {
let mut server = mockito::Server::new();
let mock = server
.mock("GET", "/repos/tj-smith47/cfgd/releases/latest")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"tag_name": "v777.0.0", "assets": []}"#)
.create();
let _env = EnvVarGuard::set(GITHUB_API_BASE_ENV, &server.url());
let result =
check_latest(None, None).expect("None repo should use default and hit mockito");
mock.assert();
assert_eq!(result.latest, Version::new(777, 0, 0));
}
#[test]
#[serial]
fn check_with_cache_none_repo_uses_default() {
let home = tempfile::tempdir().unwrap();
let _home_guard = crate::with_test_home_guard(home.path());
let mut server = mockito::Server::new();
let mock = server
.mock("GET", "/repos/tj-smith47/cfgd/releases/latest")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"tag_name": "v666.0.0", "assets": []}"#)
.create();
let _env = EnvVarGuard::set(GITHUB_API_BASE_ENV, &server.url());
let result =
check_with_cache(None, None).expect("None repo should use default and hit mockito");
mock.assert();
assert_eq!(result.latest, Version::new(666, 0, 0));
}
#[test]
#[serial]
fn fetch_latest_release_uses_env_shim() {
let mut server = mockito::Server::new();
let mock = server
.mock("GET", "/repos/tj-smith47/cfgd/releases/latest")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"tag_name": "v555.0.0", "assets": []}"#)
.create();
let _env = EnvVarGuard::set(GITHUB_API_BASE_ENV, &server.url());
let result = fetch_latest_release("tj-smith47/cfgd", None)
.expect("env shim should redirect to mockito");
mock.assert();
assert_eq!(result.version, Version::new(555, 0, 0));
}
}
#[test]
fn verification_mode_as_wire_str_cosign() {
assert_eq!(VerificationMode::Cosign.as_wire_str(), "cosign");
}
#[test]
fn verification_mode_as_wire_str_sha256_only() {
assert_eq!(VerificationMode::Sha256Only.as_wire_str(), "sha256-only");
}
#[test]
fn verification_mode_as_wire_str_strict_cosign_required() {
assert_eq!(
VerificationMode::StrictCosignRequired.as_wire_str(),
"strict-cosign-required"
);
}
#[test]
fn verification_mode_serializes_to_kebab_case_wire_values() {
assert_eq!(
serde_json::to_value(VerificationMode::Cosign).unwrap(),
serde_json::Value::String("cosign".into())
);
assert_eq!(
serde_json::to_value(VerificationMode::Sha256Only).unwrap(),
serde_json::Value::String("sha256-only".into())
);
assert_eq!(
serde_json::to_value(VerificationMode::StrictCosignRequired).unwrap(),
serde_json::Value::String("strict-cosign-required".into())
);
}
#[test]
fn verification_mode_deserializes_from_kebab_case_wire_values() {
let cosign: VerificationMode = serde_json::from_str(r#""cosign""#).unwrap();
assert_eq!(cosign, VerificationMode::Cosign);
let sha: VerificationMode = serde_json::from_str(r#""sha256-only""#).unwrap();
assert_eq!(sha, VerificationMode::Sha256Only);
let strict: VerificationMode = serde_json::from_str(r#""strict-cosign-required""#).unwrap();
assert_eq!(strict, VerificationMode::StrictCosignRequired);
}
#[test]
fn verification_mode_round_trip_via_serde() {
for mode in [
VerificationMode::Cosign,
VerificationMode::Sha256Only,
VerificationMode::StrictCosignRequired,
] {
let json = serde_json::to_string(&mode).unwrap();
let back: VerificationMode = serde_json::from_str(&json).unwrap();
assert_eq!(back, mode, "round trip drift for {mode:?}");
}
}
#[test]
fn verification_mode_rejects_unknown_wire_value() {
let err = serde_json::from_str::<VerificationMode>(r#""unknown-mode""#)
.expect_err("unknown variant must not silently coerce");
let msg = err.to_string();
assert!(
msg.contains("variant") || msg.contains("unknown"),
"error should mention the unknown variant: {msg}"
);
}
#[test]
fn install_report_carries_path_and_verification_mode() {
let target = std::path::PathBuf::from("/tmp/some-cfgd-binary");
let report = InstallReport {
installed_path: target.clone(),
verification_mode: VerificationMode::StrictCosignRequired,
};
assert_eq!(report.installed_path, target);
assert_eq!(
report.verification_mode,
VerificationMode::StrictCosignRequired
);
}
#[test]
#[serial_test::serial]
fn github_api_base_falls_back_to_production_constant_when_unset() {
use crate::test_helpers::EnvVarGuard;
let _guard = EnvVarGuard::unset(GITHUB_API_BASE_ENV);
assert_eq!(github_api_base(), "https://api.github.com");
}
#[test]
#[serial_test::serial]
fn github_api_base_honors_env_override() {
use crate::test_helpers::EnvVarGuard;
let _guard = EnvVarGuard::set(GITHUB_API_BASE_ENV, "https://custom-api.example.com");
assert_eq!(github_api_base(), "https://custom-api.example.com");
}
#[test]
fn strip_tag_prefix_uppercase_v_is_preserved() {
assert_eq!(strip_tag_prefix("V1.0.0"), "V1.0.0");
}
#[test]
fn strip_tag_prefix_with_whitespace_around_v_is_not_stripped() {
assert_eq!(strip_tag_prefix(" v1.0.0"), " v1.0.0");
assert_eq!(strip_tag_prefix("v 1.0.0"), " 1.0.0");
}
#[test]
#[serial_test::serial]
fn download_to_file_falls_back_to_cwd_when_dest_has_no_parent() {
let work = tempfile::tempdir().unwrap();
let prior = std::env::current_dir().unwrap();
std::env::set_current_dir(work.path()).unwrap();
struct CwdGuard(std::path::PathBuf);
impl Drop for CwdGuard {
fn drop(&mut self) {
let _ = std::env::set_current_dir(&self.0);
}
}
let _g = CwdGuard(prior);
let mut server = mockito::Server::new();
let body = b"contents";
let _m = server
.mock("GET", "/payload")
.with_status(200)
.with_body(&body[..])
.create();
let url = format!("{}/payload", server.url());
let dest = std::path::PathBuf::from("relative-dest.bin");
download_to_file(&url, &dest, None).expect("download with no-parent dest must succeed");
let written = std::fs::read(&dest).expect("file must exist at relative dest");
assert_eq!(written, body);
let _ = std::fs::remove_file(&dest);
}