#![cfg(test)]
use super::*;
#[test]
fn fetch_arch_info_returns_known_arch() {
let (arch, image) = arch_info();
assert!(
(arch == "x86_64" && image == "bzImage") || (arch == "aarch64" && image == "Image"),
"unexpected arch/image: {arch}/{image}"
);
}
#[test]
fn is_major_minor_prefix_accepts_two_segment() {
assert!(is_major_minor_prefix("6.14"));
assert!(is_major_minor_prefix("7.0"));
}
#[test]
fn is_major_minor_prefix_rejects_patch_version() {
assert!(!is_major_minor_prefix("6.14.2"));
assert!(!is_major_minor_prefix("5.4.0"));
}
#[test]
fn is_major_minor_prefix_rejects_rc_tag() {
assert!(!is_major_minor_prefix("6.15-rc3"));
assert!(!is_major_minor_prefix("6.14-rc1"));
}
#[test]
fn is_major_minor_prefix_historical_edge_cases() {
assert!(is_major_minor_prefix("7"));
assert!(is_major_minor_prefix(""));
}
#[test]
fn fetch_major_version_stable() {
assert_eq!(major_version("6.14.2").unwrap(), 6);
}
#[test]
fn fetch_major_version_rc() {
assert_eq!(major_version("6.15-rc3").unwrap(), 6);
}
#[test]
fn fetch_major_version_two_part() {
assert_eq!(major_version("5.4").unwrap(), 5);
}
#[test]
fn fetch_major_version_invalid() {
assert!(major_version("abc").is_err());
}
#[test]
fn fetch_is_rc_true() {
assert!(is_rc("6.15-rc3"));
assert!(is_rc("6.14.2-rc1"));
}
#[test]
fn fetch_is_rc_false() {
assert!(!is_rc("6.14.2"));
assert!(!is_rc("6.14"));
}
fn stable_tarball_url(version: &str) -> Result<String> {
let major = major_version(version)?;
Ok(format!(
"https://cdn.kernel.org/pub/linux/kernel/v{major}.x/linux-{version}.tar.xz"
))
}
fn rc_tarball_url(version: &str) -> String {
format!("https://git.kernel.org/torvalds/t/linux-{version}.tar.gz")
}
#[test]
fn fetch_stable_url_construction() {
let url = stable_tarball_url("6.14.2").unwrap();
assert_eq!(
url,
"https://cdn.kernel.org/pub/linux/kernel/v6.x/linux-6.14.2.tar.xz"
);
}
#[test]
fn fetch_stable_url_v5() {
let url = stable_tarball_url("5.4.0").unwrap();
assert_eq!(
url,
"https://cdn.kernel.org/pub/linux/kernel/v5.x/linux-5.4.0.tar.xz"
);
}
#[test]
fn fetch_rc_url_construction() {
let url = rc_tarball_url("6.15-rc3");
assert_eq!(
url,
"https://git.kernel.org/torvalds/t/linux-6.15-rc3.tar.gz"
);
}
#[test]
fn promote_staged_renames_well_formed_archive() {
let dest = tempfile::TempDir::new().unwrap();
let staging = tempfile::TempDir::new_in(dest.path()).unwrap();
std::fs::create_dir(staging.path().join("linux-6.14.2")).unwrap();
std::fs::write(
staging.path().join("linux-6.14.2").join("Makefile"),
b"# fake",
)
.unwrap();
let source_dir = promote_staged_kernel_tree(&staging, dest.path(), "6.14.2").unwrap();
assert_eq!(source_dir, dest.path().join("linux-6.14.2"));
assert!(source_dir.is_dir());
assert!(source_dir.join("Makefile").is_file());
assert!(!staging.path().join("linux-6.14.2").exists());
}
#[test]
fn promote_staged_rejects_stray_top_level_entry() {
let dest = tempfile::TempDir::new().unwrap();
let staging = tempfile::TempDir::new_in(dest.path()).unwrap();
std::fs::create_dir(staging.path().join("linux-6.14.2")).unwrap();
std::fs::write(staging.path().join("evil"), b"backdoor").unwrap();
let err = promote_staged_kernel_tree(&staging, dest.path(), "6.14.2").unwrap_err();
let msg = format!("{err:#}");
assert!(
msg.contains("unexpected top-level entry"),
"diagnostic must cite stray entry: {msg}"
);
assert!(!dest.path().join("linux-6.14.2").exists());
}
#[test]
fn promote_staged_bails_on_missing_inner_dir() {
let dest = tempfile::TempDir::new().unwrap();
let staging = tempfile::TempDir::new_in(dest.path()).unwrap();
std::fs::create_dir(staging.path().join("linux-6.14.3")).unwrap();
let err = promote_staged_kernel_tree(&staging, dest.path(), "6.14.2").unwrap_err();
let msg = format!("{err:#}");
assert!(
msg.contains("unexpected top-level entry"),
"wrong-version dir surfaces as stray: {msg}"
);
assert!(!dest.path().join("linux-6.14.2").exists());
}
#[test]
fn promote_staged_bails_on_empty_staging() {
let dest = tempfile::TempDir::new().unwrap();
let staging = tempfile::TempDir::new_in(dest.path()).unwrap();
let err = promote_staged_kernel_tree(&staging, dest.path(), "6.14.2").unwrap_err();
let msg = format!("{err:#}");
assert!(
msg.contains("expected directory linux-6.14.2"),
"empty staging surfaces as missing-dir: {msg}"
);
}
#[test]
fn fetch_patch_level_three_part() {
assert_eq!(patch_level("6.12.8"), Some(8));
}
#[test]
fn fetch_patch_level_two_part() {
assert_eq!(patch_level("7.0"), Some(0));
}
#[test]
fn fetch_patch_level_single_part() {
assert_eq!(patch_level("6"), None);
}
#[test]
fn fetch_patch_level_four_part() {
assert_eq!(patch_level("6.1.2.3"), None);
}
#[test]
fn fetch_patch_level_non_numeric_patch() {
assert_eq!(patch_level("6.1.rc3"), None);
}
#[test]
fn fetch_patch_level_zero() {
assert_eq!(patch_level("6.14.0"), Some(0));
}
#[test]
fn fetch_patch_level_large() {
assert_eq!(patch_level("6.12.99"), Some(99));
}
fn init_repo_with_commit(dir: &Path) {
use std::process::Command;
let run = |args: &[&str]| {
let out = Command::new("git")
.args(args)
.current_dir(dir)
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_SYSTEM", "/dev/null")
.env("GIT_AUTHOR_NAME", "ktstr-test")
.env("GIT_AUTHOR_EMAIL", "ktstr-test@localhost")
.env("GIT_COMMITTER_NAME", "ktstr-test")
.env("GIT_COMMITTER_EMAIL", "ktstr-test@localhost")
.output()
.expect("spawn git");
assert!(
out.status.success(),
"git {:?} failed: {}",
args,
String::from_utf8_lossy(&out.stderr)
);
};
run(&["init", "-q", "-b", "main"]);
std::fs::write(dir.join("file.txt"), "original\n").unwrap();
run(&["add", "file.txt"]);
run(&[
"-c",
"commit.gpgsign=false",
"commit",
"-q",
"-m",
"initial",
]);
}
#[test]
fn local_source_clean_repo_populates_hash() {
if std::process::Command::new("git")
.arg("--version")
.output()
.is_err()
{
skip!("git CLI unavailable");
}
let tmp = tempfile::TempDir::new().unwrap();
init_repo_with_commit(tmp.path());
let acquired = local_source(tmp.path()).expect("local_source ok");
assert!(!acquired.is_dirty, "clean tree must not be dirty");
let git_hash = match &acquired.kernel_source {
crate::cache::KernelSource::Local { git_hash, .. } => git_hash.clone(),
other => panic!("expected KernelSource::Local, got {other:?}"),
};
let hash = git_hash.expect("clean repo must carry a git_hash");
assert_eq!(hash.len(), 7, "short hash must be 7 chars, got {hash:?}");
assert!(
hash.chars().all(|c| c.is_ascii_hexdigit()),
"hash must be hex, got {hash:?}"
);
assert!(
acquired.cache_key.contains(&hash),
"clean cache_key must embed the short hash, got {}",
acquired.cache_key
);
}
#[test]
fn local_source_dirty_tracked_file_clears_hash() {
if std::process::Command::new("git")
.arg("--version")
.output()
.is_err()
{
skip!("git CLI unavailable");
}
let tmp = tempfile::TempDir::new().unwrap();
init_repo_with_commit(tmp.path());
std::fs::write(tmp.path().join("file.txt"), "modified\n").unwrap();
let acquired = local_source(tmp.path()).expect("local_source ok");
assert!(acquired.is_dirty, "worktree mutation must mark dirty");
match &acquired.kernel_source {
crate::cache::KernelSource::Local { git_hash, .. } => {
assert!(
git_hash.is_none(),
"dirty tree must not publish git_hash, got {git_hash:?}"
);
}
other => panic!("expected KernelSource::Local, got {other:?}"),
}
assert!(
acquired.cache_key.starts_with("local-unknown-"),
"dirty cache_key must use local-unknown prefix, got {}",
acquired.cache_key
);
}
#[test]
fn local_source_dirty_staged_only_clears_hash() {
if std::process::Command::new("git")
.arg("--version")
.output()
.is_err()
{
skip!("git CLI unavailable");
}
let tmp = tempfile::TempDir::new().unwrap();
init_repo_with_commit(tmp.path());
std::fs::write(tmp.path().join("file.txt"), "staged\n").unwrap();
let status = std::process::Command::new("git")
.args(["add", "file.txt"])
.current_dir(tmp.path())
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_SYSTEM", "/dev/null")
.status()
.expect("git add");
assert!(status.success());
let acquired = local_source(tmp.path()).expect("local_source ok");
assert!(acquired.is_dirty, "staged-only change must mark dirty");
match &acquired.kernel_source {
crate::cache::KernelSource::Local { git_hash, .. } => {
assert!(
git_hash.is_none(),
"dirty (staged) tree must not publish git_hash, got {git_hash:?}"
);
}
other => panic!("expected KernelSource::Local, got {other:?}"),
}
}
#[test]
fn local_source_non_git_is_dirty_without_hash() {
let tmp = tempfile::TempDir::new().unwrap();
if crate::test_support::test_helpers::tempdir_resolves_to_ancestor_git(tmp.path()) {
skip!(
"tempdir {} resolves to an ancestor git repo; cannot pin non-git \
path semantics in this environment",
tmp.path().display()
);
}
std::fs::write(tmp.path().join("file.txt"), "no git here\n").unwrap();
let acquired = local_source(tmp.path()).expect("local_source ok");
assert!(acquired.is_dirty, "non-git tree must mark dirty");
match &acquired.kernel_source {
crate::cache::KernelSource::Local { git_hash, .. } => {
assert!(
git_hash.is_none(),
"non-git tree must not publish git_hash, got {git_hash:?}"
);
}
other => panic!("expected KernelSource::Local, got {other:?}"),
}
assert!(
acquired.cache_key.starts_with("local-unknown-"),
"non-git cache_key must use local-unknown prefix, got {}",
acquired.cache_key
);
}
#[test]
fn local_unknown_keys_carry_distinct_per_path_salt() {
let tmp_a = tempfile::TempDir::new().unwrap();
let tmp_b = tempfile::TempDir::new().unwrap();
if crate::test_support::test_helpers::tempdir_resolves_to_ancestor_git(tmp_a.path())
|| crate::test_support::test_helpers::tempdir_resolves_to_ancestor_git(tmp_b.path())
{
skip!(
"tempdir(s) {} / {} resolve to ancestor git repo; cannot pin \
non-git salt semantics in this environment",
tmp_a.path().display(),
tmp_b.path().display(),
);
}
std::fs::write(tmp_a.path().join("file"), b"a").unwrap();
std::fs::write(tmp_b.path().join("file"), b"b").unwrap();
let key_a = local_source(tmp_a.path()).unwrap().cache_key;
let key_b = local_source(tmp_b.path()).unwrap().cache_key;
assert!(
key_a.starts_with("local-unknown-"),
"tree-a key shape: {key_a}"
);
assert!(
key_b.starts_with("local-unknown-"),
"tree-b key shape: {key_b}"
);
assert_ne!(
key_a, key_b,
"distinct paths must produce distinct local-unknown keys; \
without per-path salt they would collide and parallel \
builds could stomp each other's cache content"
);
}
#[test]
fn local_unknown_key_stable_across_repeated_calls_on_same_path() {
let tmp = tempfile::TempDir::new().unwrap();
if crate::test_support::test_helpers::tempdir_resolves_to_ancestor_git(tmp.path()) {
skip!(
"tempdir {} resolves to an ancestor git repo; cannot pin \
deterministic non-git salt in this environment",
tmp.path().display()
);
}
std::fs::write(tmp.path().join("file"), b"x").unwrap();
let k1 = local_source(tmp.path()).unwrap().cache_key;
let k2 = local_source(tmp.path()).unwrap().cache_key;
assert_eq!(
k1, k2,
"salt must be deterministic across repeated calls on the same path"
);
}
#[test]
fn compose_local_cache_key_with_user_config_inserts_cfg_segment() {
use std::path::PathBuf;
let key = compose_local_cache_key(
"x86_64",
&Some("abc1234".to_string()),
&PathBuf::from("/anywhere"),
Some("deadbeef"),
);
let suffix = crate::cache_key_suffix();
assert_eq!(
key,
format!("local-abc1234-x86_64-cfgdeadbeef-kc{suffix}"),
"user-config segment must sit between hash and kc tail"
);
}
#[test]
fn compose_local_cache_key_without_user_config_keeps_legacy_shape() {
use std::path::PathBuf;
let key = compose_local_cache_key(
"x86_64",
&Some("abc1234".to_string()),
&PathBuf::from("/anywhere"),
None,
);
let suffix = crate::cache_key_suffix();
assert_eq!(
key,
format!("local-abc1234-x86_64-kc{suffix}"),
"absent user config must keep the legacy hash-only shape"
);
}
#[test]
fn compose_local_cache_key_unknown_uses_path_hash_only() {
use std::path::PathBuf;
let key = compose_local_cache_key(
"x86_64",
&None,
&PathBuf::from("/some/path"),
Some("ignored"),
);
let suffix = crate::cache_key_suffix();
assert!(
key.starts_with("local-unknown-") && key.ends_with(&format!("-x86_64-kc{suffix}")),
"unknown shape must skip cfg segment; got {key}"
);
let path_hash = key
.strip_prefix("local-unknown-")
.and_then(|s| s.strip_suffix(&format!("-x86_64-kc{suffix}")))
.expect("key shape mismatch");
assert_eq!(
path_hash.len(),
8,
"path-hash salt must be 8 chars (full CRC32); got {path_hash}"
);
assert!(
path_hash.chars().all(|c| c.is_ascii_hexdigit()),
"path-hash salt must be hex; got {path_hash}"
);
}
#[test]
fn inspect_local_source_state_clean_repo_stable_across_calls() {
if std::process::Command::new("git")
.arg("--version")
.output()
.is_err()
{
skip!("git CLI unavailable");
}
let tmp = tempfile::TempDir::new().unwrap();
init_repo_with_commit(tmp.path());
let canonical = tmp.path().canonicalize().unwrap();
let pre = inspect_local_source_state(&canonical).unwrap();
let post = inspect_local_source_state(&canonical).unwrap();
assert_eq!(pre.is_dirty, post.is_dirty);
assert_eq!(pre.is_git, post.is_git);
assert_eq!(pre.short_hash, post.short_hash);
}
#[test]
fn inspect_local_source_state_detects_mid_build_modification() {
if std::process::Command::new("git")
.arg("--version")
.output()
.is_err()
{
skip!("git CLI unavailable");
}
let tmp = tempfile::TempDir::new().unwrap();
init_repo_with_commit(tmp.path());
let canonical = tmp.path().canonicalize().unwrap();
let pre = inspect_local_source_state(&canonical).unwrap();
assert!(!pre.is_dirty, "acquire-time state must be clean");
std::fs::write(canonical.join("file.txt"), b"edited mid-build").unwrap();
let post = inspect_local_source_state(&canonical).unwrap();
assert!(
post.is_dirty,
"post-build re-check must observe the worktree edit and flip dirty"
);
assert!(
post.short_hash.is_none(),
"dirty post-build state must drop short_hash, mirroring acquire-time semantics"
);
}
#[test]
fn cached_releases_routing_singleton_path() {
let synthetic = vec![
Release {
moniker: "stable".to_string(),
version: "6.14.2".to_string(),
},
Release {
moniker: "longterm".to_string(),
version: "6.12.81".to_string(),
},
Release {
moniker: "mainline".to_string(),
version: "6.16-rc3".to_string(),
},
];
let _ = super::RELEASES_CACHE.set(synthetic.clone());
let in_cache = super::RELEASES_CACHE.get().expect(
"RELEASES_CACHE must be populated after `set` — either this \
test or its bypass-branch peer wins the race; both use the \
same synthetic so contents are byte-equal regardless of \
order",
);
assert_releases_eq(in_cache, &synthetic, "cache populate sanity");
let result = super::cached_releases().expect(
"cache hit must return Ok — a network attempt indicates \
the OnceLock fast-path is bypassed",
);
assert_releases_eq(&result, &synthetic, "cache hit result");
let second = super::cached_releases().expect(
"second cache hit must also return Ok — a regression that \
cleared the cache between calls would surface here",
);
assert_releases_eq(&second, &synthetic, "cache idempotency");
let latest = super::fetch_latest_stable_version(super::shared_client(), "test")
.expect("public-fn singleton path must reach cache");
assert_eq!(
latest, "6.12.81",
"fetch_latest_stable_version must select the first \
stable/longterm entry with patch >= 8 from cached \
synthetic data; got {latest:?}",
);
}
#[test]
fn cached_releases_with_non_singleton_bypasses_cache() {
let synthetic = vec![
Release {
moniker: "stable".to_string(),
version: "6.14.2".to_string(),
},
Release {
moniker: "longterm".to_string(),
version: "6.12.81".to_string(),
},
Release {
moniker: "mainline".to_string(),
version: "6.16-rc3".to_string(),
},
];
let _ = super::RELEASES_CACHE.set(synthetic.clone());
let in_cache = super::RELEASES_CACHE.get().expect(
"RELEASES_CACHE must be populated after `set` — either this \
test or `cached_releases_routing_singleton_path` wins the \
race; both use the same synthetic so contents are \
byte-equal regardless of order",
);
assert_releases_eq(in_cache, &synthetic, "cache populate sanity");
let mock_body = r#"{
"releases": [
{ "moniker": "stable", "version": "9.99.99" },
{ "moniker": "longterm", "version": "9.98.50" }
]
}"#;
let (_server, mock_url, _mock) = mock_releases(200, mock_body);
let non_singleton = test_client();
assert!(
!super::is_shared_client(&non_singleton),
"test precondition: non-singleton client MUST NOT compare \
equal to the shared_client() singleton — the bypass-branch \
proof relies on `cached_releases_with_url` taking the \
non-singleton path",
);
let result = super::cached_releases_with_url(&non_singleton, &mock_url);
let mock_payload = vec![
Release {
moniker: "stable".to_string(),
version: "9.99.99".to_string(),
},
Release {
moniker: "longterm".to_string(),
version: "9.98.50".to_string(),
},
];
match result {
Ok(data) => {
assert_releases_eq(
&data,
&mock_payload,
"bypass branch must return the mock-served payload",
);
let same_as_cache = data.len() == synthetic.len()
&& data
.iter()
.zip(synthetic.iter())
.all(|(got, want)| got.moniker == want.moniker && got.version == want.version);
assert!(
!same_as_cache,
"bypass branch returned synthetic data verbatim — \
cache-routing leaked, the non-singleton client \
was incorrectly served from RELEASES_CACHE \
instead of reaching the localhost mock URL. \
Synthetic was {synthetic:?}; got identical {data:?}",
);
}
Err(_) => {
}
}
let post = super::RELEASES_CACHE.get().expect(
"RELEASES_CACHE must remain populated after the bypass call — \
a regression that cleared the cache between setup and now \
would surface here",
);
assert_releases_eq(
post,
&synthetic,
"cache must remain unchanged after bypass call",
);
}
fn mock_releases(status: usize, body: &str) -> (mockito::ServerGuard, String, mockito::Mock) {
let mut server = mockito::Server::new();
let mock = server
.mock("GET", "/releases.json")
.with_status(status)
.with_body(body)
.create();
let url = format!("{}/releases.json", server.url());
(server, url, mock)
}
#[test]
fn fetch_releases_against_localhost_mock_returns_parsed() {
let mock_body = r#"{
"releases": [
{ "moniker": "stable", "version": "9.99.99" },
{ "moniker": "longterm", "version": "9.98.50" }
]
}"#;
let releases = super::parse_releases_body(mock_body).expect("parse_releases_body must succeed");
assert_eq!(
releases.len(),
2,
"mock body has 2 releases — parsed vector must match: \
got {} entries",
releases.len(),
);
assert_eq!(releases[0].moniker, "stable");
assert_eq!(releases[0].version, "9.99.99");
assert_eq!(releases[1].moniker, "longterm");
assert_eq!(releases[1].version, "9.98.50");
}
fn test_client() -> reqwest::blocking::Client {
reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(5))
.build()
.expect("build test client")
}
fn assert_releases_eq(got: &[Release], want: &[Release], context: &str) {
assert_eq!(
got.len(),
want.len(),
"{context}: length mismatch — got {} entries, want {}",
got.len(),
want.len(),
);
for (i, (g, w)) in got.iter().zip(want.iter()).enumerate() {
assert_eq!(
g.moniker, w.moniker,
"{context}: row {i} moniker mismatch — got {:?}, want {:?}",
g.moniker, w.moniker,
);
assert_eq!(
g.version, w.version,
"{context}: row {i} version mismatch — got {:?}, want {:?}",
g.version, w.version,
);
}
}
#[test]
fn fetch_releases_http_500_surfaces_status_in_error() {
let url = "https://example.com/releases.json";
let msg = format!(
"fetch {url}: HTTP {}",
reqwest::StatusCode::INTERNAL_SERVER_ERROR
);
assert!(
msg.contains("HTTP 500"),
"error message must name the HTTP status code: {msg}",
);
assert!(
msg.contains(url),
"error message must include the URL: {msg}",
);
}
#[test]
fn fetch_releases_malformed_json_surfaces_parse_error() {
let err = super::parse_releases_body("this is not JSON {")
.expect_err("malformed JSON must surface as Err");
let msg = format!("{err:#}");
assert!(
msg.contains("parse releases.json"),
"error must carry the `parse releases.json` context so \
an operator distinguishes parse failures from network \
or status failures: {msg}",
);
}
#[test]
fn fetch_releases_missing_releases_array_surfaces_error() {
let err = super::parse_releases_body("{}")
.expect_err("body without `releases` key must surface as Err");
let msg = format!("{err:#}");
assert!(
msg.contains("missing releases array"),
"error must say `missing releases array` so an operator \
distinguishes schema drift from parse failure: {msg}",
);
}
#[test]
fn fetch_releases_row_missing_moniker_drops_row() {
let body = r#"{
"releases": [
{ "moniker": "stable", "version": "9.99.99" },
{ "version": "9.98.99" },
{ "moniker": "longterm", "version": "9.97.50" }
]
}"#;
let releases =
super::parse_releases_body(body).expect("partial-row corruption must NOT abort the fetch");
assert_eq!(
releases.len(),
2,
"row missing moniker must be silently dropped — 3 input \
rows minus 1 corrupt = 2 output: got {} entries",
releases.len(),
);
assert_eq!(releases[0].moniker, "stable");
assert_eq!(releases[0].version, "9.99.99");
assert_eq!(releases[1].moniker, "longterm");
assert_eq!(releases[1].version, "9.97.50");
}
#[test]
fn fetch_releases_row_missing_version_drops_row() {
let body = r#"{
"releases": [
{ "moniker": "stable", "version": "9.99.99" },
{ "moniker": "linux-next" },
{ "moniker": "longterm", "version": "9.97.50" }
]
}"#;
let releases =
super::parse_releases_body(body).expect("row missing version must NOT abort the fetch");
assert_eq!(
releases.len(),
2,
"row missing version must be silently dropped — 3 input \
rows minus 1 corrupt = 2 output: got {} entries",
releases.len(),
);
assert_eq!(releases[0].moniker, "stable");
assert_eq!(releases[0].version, "9.99.99");
assert_eq!(releases[1].moniker, "longterm");
assert_eq!(releases[1].version, "9.97.50");
}
#[test]
fn fetch_releases_row_numeric_moniker_drops_row() {
let body = r#"{
"releases": [
{ "moniker": "stable", "version": "9.99.99" },
{ "moniker": 42, "version": "9.98.99" },
{ "moniker": "longterm", "version": "9.97.50" }
]
}"#;
let releases = super::parse_releases_body(body)
.expect("row with numeric moniker must NOT abort the fetch");
assert_eq!(
releases.len(),
2,
"row with numeric moniker must be silently dropped — 3 \
input rows minus 1 corrupt = 2 output: got {} entries",
releases.len(),
);
assert_eq!(releases[0].moniker, "stable");
assert_eq!(releases[0].version, "9.99.99");
assert_eq!(releases[1].moniker, "longterm");
assert_eq!(releases[1].version, "9.97.50");
}
#[test]
fn fetch_releases_row_null_version_drops_row() {
let body = r#"{
"releases": [
{ "moniker": "stable", "version": "9.99.99" },
{ "moniker": "mainline", "version": null },
{ "moniker": "longterm", "version": "9.97.50" }
]
}"#;
let releases =
super::parse_releases_body(body).expect("row with null version must NOT abort the fetch");
assert_eq!(
releases.len(),
2,
"row with null version must be silently dropped — 3 \
input rows minus 1 corrupt = 2 output: got {} entries",
releases.len(),
);
assert_eq!(releases[0].moniker, "stable");
assert_eq!(releases[0].version, "9.99.99");
assert_eq!(releases[1].moniker, "longterm");
assert_eq!(releases[1].version, "9.97.50");
}
#[test]
fn fetch_releases_empty_array_returns_empty_vec_ok() {
let releases = super::parse_releases_body(r#"{"releases": []}"#)
.expect("empty releases array must be Ok, not Err");
assert!(
releases.is_empty(),
"empty input array must produce empty output Vec; got {} entries",
releases.len(),
);
}
#[test]
fn fetch_releases_extra_unknown_fields_tolerated() {
let body = r#"{
"released_at": "2026-04-26T00:00:00Z",
"schema_version": 47,
"releases": [
{
"moniker": "stable",
"version": "9.99.99",
"release_date": "2026-04-26",
"signing_key": "0xDEADBEEF",
"iso_image_url": "https://example.invalid/9.99.99.iso"
}
],
"trailing_meta": ["a", "b"]
}"#;
let releases = super::parse_releases_body(body)
.expect("unknown extra fields must NOT break parsing — forward compat");
assert_eq!(
releases.len(),
1,
"extra fields must not affect row count: {} entries",
releases.len(),
);
assert_eq!(releases[0].moniker, "stable");
assert_eq!(releases[0].version, "9.99.99");
}
#[test]
fn fetch_releases_connection_refused_surfaces_url_context() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind localhost listener");
let addr = listener.local_addr().expect("read addr");
drop(listener);
let url = format!("http://{addr}/releases.json");
let client = test_client();
let err =
super::fetch_releases(&client, &url).expect_err("connection refused must surface as Err");
let msg = format!("{err:#}");
assert!(
msg.contains("fetch "),
"error must carry the `fetch` context (added via \
with_context) so an operator distinguishes network \
failures from parse failures: {msg}",
);
assert!(
msg.contains(&url),
"error must include the URL so an operator can trace \
which endpoint failed: {msg}",
);
}
#[test]
fn is_shared_client_recognizes_process_singleton() {
let client = super::shared_client();
assert!(
super::is_shared_client(client),
"shared_client() must satisfy is_shared_client; without \
this, cached_releases_with would route the production \
singleton through the bypass branch and never populate \
the cache",
);
assert!(
super::is_shared_client(super::shared_client()),
"shared_client() must return a stable pointer across \
repeated calls; the OnceLock contract guarantees this",
);
}
#[test]
fn is_shared_client_rejects_test_constructed_clients() {
let _ = super::shared_client();
let local = reqwest::blocking::Client::new();
assert!(
!super::is_shared_client(&local),
"a freshly-constructed Client must NOT compare equal to \
the shared_client() singleton — the cache-routing gate \
relies on this to send fault-injected traffic to the \
bypass branch",
);
let configured = reqwest::blocking::Client::builder()
.connect_timeout(std::time::Duration::from_millis(100))
.build()
.expect("build local Client");
assert!(
!super::is_shared_client(&configured),
"a builder-configured Client must also bypass the cache; \
the predicate keys on raw pointer address, not on \
internal client state",
);
let cloned = super::shared_client().clone();
assert!(
!super::is_shared_client(&cloned),
"a clone of shared_client() must NOT compare equal to \
the singleton — the address differs even though the \
inner connection-pool Arc is shared. Always pass \
shared_client() directly when cache routing is desired.",
);
}
#[test]
#[ignore]
fn is_shared_client_returns_false_uninit_subprocess_helper() {
assert!(
super::SHARED_CLIENT.get().is_none(),
"subprocess pre-condition violated: SHARED_CLIENT \
was already initialized before is_shared_client \
was called — the None-branch test cannot prove its \
contract under that state",
);
let local = reqwest::blocking::Client::new();
assert!(
!super::is_shared_client(&local),
"is_shared_client must return false when SHARED_CLIENT \
is uninitialized — no client can equal a not-yet-\
allocated singleton",
);
assert!(
super::SHARED_CLIENT.get().is_none(),
"is_shared_client's None branch must NOT initialize \
SHARED_CLIENT — the singleton optimization relies on \
skipping `get_or_init` when no shared client has \
been requested yet",
);
}
#[test]
fn is_shared_client_returns_false_when_uninit() {
let exe = std::env::current_exe().expect("current_exe must resolve for subprocess invocation");
let helper_name = "fetch::tests::is_shared_client_returns_false_uninit_subprocess_helper";
let output = std::process::Command::new(&exe)
.arg("--ignored")
.arg("--exact")
.arg("--color=never")
.arg(helper_name)
.output()
.expect("spawn subprocess helper");
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
output.status.success(),
"subprocess helper failed (exit status {}): \n\
stdout: {}\n\
stderr: {}",
output.status,
stdout,
stderr,
);
assert!(
stdout.contains("1 passed"),
"subprocess must run exactly 1 test (helper rename or \
missing #[ignore] attribute would surface here): \n\
stdout: {stdout}\n\
stderr: {stderr}",
);
}
#[test]
fn download_stream_finalizes_sha256_over_streamed_bytes() {
let payload: Vec<u8> = (0..32 * 1024).map(|i| (i % 251) as u8).collect();
let mut stream = super::DownloadStream::new(std::io::Cursor::new(payload.clone()));
let mut sink: Vec<u8> = Vec::new();
std::io::copy(&mut stream, &mut sink).expect("copy must drain Cursor");
assert_eq!(
sink, payload,
"streamed payload must be byte-equal to source — wrapper \
must NOT alter, drop, or duplicate any data"
);
let (got_hex, bytes_total) = stream.finalize();
assert_eq!(
bytes_total as usize,
payload.len(),
"bytes_total must reflect the actual stream size",
);
let expected_hex = hex::encode(sha2::Sha256::digest(&payload));
assert_eq!(
got_hex, expected_hex,
"streaming SHA-256 must match the one-shot digest over \
the same bytes",
);
}
#[test]
fn download_stream_errors_on_no_progress_timeout() {
let mut stream = super::DownloadStream {
inner: std::io::Cursor::new(vec![0u8; 1024]),
hasher: sha2::Sha256::new(),
bytes_total: 0,
last_progress: std::time::Instant::now() - std::time::Duration::from_secs(3600),
no_progress_timeout: std::time::Duration::from_millis(1),
};
let mut buf = [0u8; 16];
let err = stream
.read(&mut buf)
.expect_err("expired no-progress window must surface TimedOut");
assert_eq!(
err.kind(),
std::io::ErrorKind::TimedOut,
"watchdog error must carry ErrorKind::TimedOut so \
upstream `?` chains can route on it: got {:?}",
err.kind(),
);
let msg = format!("{err}");
assert!(
msg.contains("no body bytes"),
"watchdog error message must explain the cause: {msg}",
);
}
#[test]
fn download_stream_resets_progress_clock_on_byte_producing_read() {
let payload = vec![42u8; 8];
let mut stream = super::DownloadStream {
inner: std::io::Cursor::new(payload.clone()),
hasher: sha2::Sha256::new(),
bytes_total: 0,
last_progress: std::time::Instant::now() - std::time::Duration::from_secs(30),
no_progress_timeout: std::time::Duration::from_secs(60),
};
let mut buf = [0u8; 16];
let n = stream.read(&mut buf).expect("first read must succeed");
assert_eq!(n, payload.len());
assert!(
stream.last_progress.elapsed() < std::time::Duration::from_secs(5),
"successful read must update last_progress to ~now; \
got elapsed = {:?}",
stream.last_progress.elapsed(),
);
}
#[test]
fn download_stream_eof_does_not_reset_progress_clock() {
let mut stream = super::DownloadStream {
inner: std::io::Cursor::new(Vec::<u8>::new()), hasher: sha2::Sha256::new(),
bytes_total: 0,
last_progress: std::time::Instant::now() - std::time::Duration::from_secs(1800),
no_progress_timeout: std::time::Duration::from_secs(7200),
};
let pre_progress = stream.last_progress;
let mut buf = [0u8; 16];
let n = stream.read(&mut buf).expect("EOF must return Ok(0)");
assert_eq!(n, 0, "empty Cursor must report EOF");
assert_eq!(
stream.last_progress, pre_progress,
"Ok(0) must NOT update last_progress — only byte-\
producing reads count as progress",
);
}
#[test]
fn parse_sha256_for_file_extracts_matching_entry() {
let manifest = "\
-----BEGIN PGP SIGNED MESSAGE-----
Hash: SHA256
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa linux-6.14.1.tar.xz
bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb linux-6.14.2.tar.xz
cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc linux-6.14.3.tar.xz
-----BEGIN PGP SIGNATURE-----
... signature payload ...
-----END PGP SIGNATURE-----
";
let got = super::parse_sha256_for_file(manifest, "linux-6.14.2.tar.xz")
.expect("matching entry must be found");
assert_eq!(
got, "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"must extract the digest paired with the requested \
filename, lowercase",
);
}
#[test]
fn parse_sha256_for_file_returns_none_when_file_absent() {
let manifest = "\
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa linux-6.14.1.tar.xz
bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb linux-6.14.2.tar.xz
";
let got = super::parse_sha256_for_file(manifest, "linux-9.99.99.tar.xz");
assert!(
got.is_none(),
"missing filename must return None so the caller can \
warn-and-continue rather than fabricate a digest: got \
{got:?}",
);
}
#[test]
fn parse_sha256_for_file_skips_malformed_hash_lines() {
let manifest = "\
zz linux-6.14.1.tar.xz
zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzgg linux-6.14.2.tar.xz
cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc linux-6.14.3.tar.xz
";
assert_eq!(
super::parse_sha256_for_file(manifest, "linux-6.14.1.tar.xz"),
None,
"2-char hash must be skipped via the length check",
);
assert_eq!(
super::parse_sha256_for_file(manifest, "linux-6.14.2.tar.xz"),
None,
"64-char-but-non-hex hash must be skipped via the \
ascii-hexdigit check",
);
assert_eq!(
super::parse_sha256_for_file(manifest, "linux-6.14.3.tar.xz")
.expect("valid entry must parse"),
"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
);
}
#[test]
fn parse_sha256_for_file_ignores_post_signature_content() {
let manifest = "\
-----BEGIN PGP SIGNATURE-----
ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff linux-6.14.99.tar.xz
-----END PGP SIGNATURE-----
";
assert!(
super::parse_sha256_for_file(manifest, "linux-6.14.99.tar.xz").is_none(),
"lines after the signature marker must be invisible to \
the parser",
);
}
#[test]
fn resolve_expected_sha256_skip_returns_none_without_network() {
let client = test_client();
let got = super::resolve_expected_sha256(&client, 6, "linux-6.14.2.tar.xz", true);
assert!(
got.is_none(),
"skip_sha256 = true must produce None (verification \
skipped); got {got:?}"
);
}
#[test]
fn resolve_expected_sha256_no_skip_does_not_panic_on_invalid_major() {
let client = reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_millis(1))
.connect_timeout(std::time::Duration::from_millis(1))
.build()
.expect("build test client with tight timeouts");
let _ = super::resolve_expected_sha256(&client, 999, "linux-999.0.0.tar.xz", false);
}
#[test]
fn verify_sha256_accepts_case_insensitive_match() {
super::verify_sha256(
"ABCDEF1234567890ABCDEF1234567890ABCDEF1234567890ABCDEF1234567890",
"abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890",
"https://example.invalid/x.tar.xz",
)
.expect("case-insensitive equal must verify");
}
#[test]
fn verify_sha256_rejects_mismatch_with_both_digests_in_message() {
let url = "https://example.invalid/x.tar.xz";
let err = super::verify_sha256(
"0000000000000000000000000000000000000000000000000000000000000000",
"1111111111111111111111111111111111111111111111111111111111111111",
url,
)
.expect_err("mismatch must surface as Err");
let msg = format!("{err:#}");
assert!(msg.contains(url), "error must name the URL: {msg}");
assert!(
msg.contains("0000000000000000"),
"error must include the actual digest: {msg}",
);
assert!(
msg.contains("1111111111111111"),
"error must include the expected digest: {msg}",
);
assert!(
msg.contains("--skip-sha256"),
"mismatch error must name --skip-sha256 as the recovery \
flag for the in-place-tarball-update case: {msg}",
);
}
use proptest::prop_assert;
proptest::proptest! {
#[test]
fn prop_major_version_never_panics(s in "\\PC{0,100}") {
if let Ok(major) = major_version(&s) {
let first = s.split('.').next().unwrap_or("");
prop_assert!(first.parse::<u32>().ok() == Some(major));
}
}
#[test]
fn prop_is_rc_contains_dash_rc(s in "\\PC{0,20}") {
assert_eq!(is_rc(&s), s.contains("-rc"));
}
#[test]
fn prop_patch_level_valid_three_part(
major in 1u32..100,
minor in 0u32..100,
patch in 0u32..100,
) {
let v = format!("{major}.{minor}.{patch}");
assert_eq!(patch_level(&v), Some(patch));
}
#[test]
fn prop_patch_level_valid_two_part(major in 1u32..100, minor in 0u32..100) {
let v = format!("{major}.{minor}");
assert_eq!(patch_level(&v), Some(0));
}
#[test]
fn prop_major_version_valid(major in 1u32..100, minor in 0u32..100) {
let v = format!("{major}.{minor}");
assert_eq!(major_version(&v).unwrap(), major);
}
}