use std::fmt;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase", tag = "type")]
#[non_exhaustive]
pub enum KernelSource {
Tarball,
Git {
git_hash: Option<String>,
#[serde(rename = "ref")]
git_ref: Option<String>,
},
Local {
source_tree_path: Option<PathBuf>,
git_hash: Option<String>,
},
}
impl fmt::Display for KernelSource {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
KernelSource::Tarball => f.write_str("tarball"),
KernelSource::Git { .. } => f.write_str("git"),
KernelSource::Local { .. } => f.write_str("local"),
}
}
}
impl KernelSource {
pub fn as_local_git_hash(&self) -> Option<&str> {
match self {
KernelSource::Local { git_hash, .. } => git_hash.as_deref(),
_ => None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct KernelMetadata {
pub version: Option<String>,
pub source: KernelSource,
pub arch: String,
pub image_name: String,
pub config_hash: Option<String>,
pub built_at: String,
pub ktstr_kconfig_hash: Option<String>,
pub extra_kconfig_hash: Option<String>,
pub(crate) has_vmlinux: bool,
pub(crate) vmlinux_stripped: bool,
pub source_vmlinux_size: Option<u64>,
pub source_vmlinux_mtime_secs: Option<i64>,
}
impl KernelMetadata {
pub fn new(source: KernelSource, arch: String, image_name: String, built_at: String) -> Self {
KernelMetadata {
version: None,
source,
arch,
image_name,
config_hash: None,
built_at,
ktstr_kconfig_hash: None,
extra_kconfig_hash: None,
has_vmlinux: false,
vmlinux_stripped: false,
source_vmlinux_size: None,
source_vmlinux_mtime_secs: None,
}
}
pub fn with_source_vmlinux_stat(mut self, size: u64, mtime_secs: i64) -> Self {
self.source_vmlinux_size = Some(size);
self.source_vmlinux_mtime_secs = Some(mtime_secs);
self
}
pub fn with_version(mut self, version: Option<String>) -> Self {
self.version = version;
self
}
pub fn with_config_hash(mut self, hash: Option<String>) -> Self {
self.config_hash = hash;
self
}
pub fn with_ktstr_kconfig_hash(mut self, hash: Option<String>) -> Self {
self.ktstr_kconfig_hash = hash;
self
}
pub fn with_extra_kconfig_hash(mut self, hash: Option<String>) -> Self {
self.extra_kconfig_hash = hash;
self
}
pub fn has_vmlinux(&self) -> bool {
self.has_vmlinux
}
pub(crate) fn set_has_vmlinux(&mut self, value: bool) {
self.has_vmlinux = value;
}
pub fn vmlinux_stripped(&self) -> bool {
self.vmlinux_stripped
}
pub(crate) fn set_vmlinux_stripped(&mut self, value: bool) {
self.vmlinux_stripped = value;
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct CacheArtifacts<'a> {
pub image: &'a Path,
pub vmlinux: Option<&'a Path>,
}
impl<'a> CacheArtifacts<'a> {
pub fn new(image: &'a Path) -> Self {
CacheArtifacts {
image,
vmlinux: None,
}
}
pub fn with_vmlinux(mut self, vmlinux: &'a Path) -> Self {
self.vmlinux = Some(vmlinux);
self
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum KconfigStatus {
Matches,
Stale {
cached: String,
current: String,
},
Untracked,
}
impl KconfigStatus {
pub fn is_stale(&self) -> bool {
matches!(self, Self::Stale { .. })
}
pub fn is_untracked(&self) -> bool {
matches!(self, Self::Untracked)
}
}
impl fmt::Display for KconfigStatus {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
KconfigStatus::Matches => f.write_str("matches"),
KconfigStatus::Stale { .. } => f.write_str("stale"),
KconfigStatus::Untracked => f.write_str("untracked"),
}
}
}
#[derive(Debug)]
#[non_exhaustive]
pub struct CacheEntry {
pub key: String,
pub path: PathBuf,
pub metadata: KernelMetadata,
}
impl CacheEntry {
pub fn image_path(&self) -> PathBuf {
self.path.join(&self.metadata.image_name)
}
pub fn vmlinux_path(&self) -> Option<PathBuf> {
self.metadata.has_vmlinux.then(|| self.path.join("vmlinux"))
}
pub fn disk_template_path(&self) -> PathBuf {
self.path.join("disk-template.img")
}
pub fn kconfig_status(&self, current_hash: &str) -> KconfigStatus {
match self.metadata.ktstr_kconfig_hash.as_deref() {
None => KconfigStatus::Untracked,
Some(h) if h == current_hash => KconfigStatus::Matches,
Some(h) => KconfigStatus::Stale {
cached: h.to_string(),
current: current_hash.to_string(),
},
}
}
pub fn has_extra_kconfig(&self) -> bool {
self.metadata.extra_kconfig_hash.is_some()
}
}
#[derive(Debug)]
#[non_exhaustive]
pub enum ListedEntry {
Valid(Box<CacheEntry>),
Corrupt {
key: String,
path: PathBuf,
reason: String,
},
}
impl ListedEntry {
pub fn key(&self) -> &str {
match self {
ListedEntry::Valid(e) => &e.key,
ListedEntry::Corrupt { key, .. } => key,
}
}
pub fn path(&self) -> &Path {
match self {
ListedEntry::Valid(e) => &e.path,
ListedEntry::Corrupt { path, .. } => path,
}
}
pub fn as_valid(&self) -> Option<&CacheEntry> {
match self {
ListedEntry::Valid(e) => Some(e.as_ref()),
ListedEntry::Corrupt { .. } => None,
}
}
pub fn error_kind(&self) -> Option<&'static str> {
match self {
ListedEntry::Valid(_) => None,
ListedEntry::Corrupt { reason, .. } => Some(classify_corrupt_reason(reason)),
}
}
}
pub(crate) const IMAGE_MISSING_SUFFIX: &str = " missing from entry directory";
pub(crate) const IMAGE_MISSING_PREFIX: &str = "image file ";
pub(crate) fn format_image_missing_reason(image_name: &str) -> String {
format!("{IMAGE_MISSING_PREFIX}{image_name}{IMAGE_MISSING_SUFFIX}")
}
pub(crate) fn classify_corrupt_reason(reason: &str) -> &'static str {
if reason == "metadata.json missing" {
"missing"
} else if reason.starts_with("metadata.json unreadable: ") {
"unreadable"
} else if reason.starts_with("metadata.json schema drift: ") {
"schema_drift"
} else if reason.starts_with("metadata.json malformed: ") {
"malformed"
} else if reason.starts_with("metadata.json truncated: ") {
"truncated"
} else if reason.starts_with("metadata.json parse error: ") {
"parse_error"
} else if reason.starts_with(IMAGE_MISSING_PREFIX)
&& reason.ends_with(IMAGE_MISSING_SUFFIX)
&& reason.len() > IMAGE_MISSING_PREFIX.len() + IMAGE_MISSING_SUFFIX.len()
{
"image_missing"
} else {
"unknown"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cache::shared_test_helpers::{create_fake_image, test_metadata};
use crate::cache::{CacheArtifacts, CacheDir};
use std::fs;
use std::path::PathBuf;
use tempfile::TempDir;
#[test]
fn cache_metadata_serde_roundtrip() {
let meta = test_metadata("6.14.2");
let json = serde_json::to_string_pretty(&meta).unwrap();
let parsed: KernelMetadata = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.version.as_deref(), Some("6.14.2"));
assert_eq!(parsed.source, KernelSource::Tarball);
assert_eq!(parsed.arch, "x86_64");
assert_eq!(parsed.image_name, "bzImage");
assert_eq!(parsed.config_hash.as_deref(), Some("abc123"));
assert_eq!(parsed.built_at, "2026-04-12T10:00:00Z");
assert_eq!(parsed.ktstr_kconfig_hash.as_deref(), Some("def456"));
assert!(!parsed.has_vmlinux);
assert!(!parsed.vmlinux_stripped);
}
#[test]
fn cache_metadata_serde_git_with_payload() {
let meta = KernelMetadata {
version: Some("6.15-rc3".to_string()),
source: KernelSource::Git {
git_hash: Some("a1b2c3d".to_string()),
git_ref: Some("v6.15-rc3".to_string()),
},
arch: "aarch64".to_string(),
image_name: "Image".to_string(),
config_hash: None,
built_at: "2026-04-12T12:00:00Z".to_string(),
ktstr_kconfig_hash: None,
extra_kconfig_hash: None,
has_vmlinux: false,
vmlinux_stripped: false,
source_vmlinux_size: None,
source_vmlinux_mtime_secs: None,
};
let json = serde_json::to_string(&meta).unwrap();
let parsed: KernelMetadata = serde_json::from_str(&json).unwrap();
assert!(matches!(
parsed.source,
KernelSource::Git {
git_hash: Some(ref h),
git_ref: Some(ref r),
}
if h == "a1b2c3d" && r == "v6.15-rc3"
));
}
#[test]
fn kernel_metadata_option_fields_serialize_as_explicit_null() {
let meta = KernelMetadata::new(
KernelSource::Tarball,
"x86_64".to_string(),
"bzImage".to_string(),
"2026-04-12T10:00:00Z".to_string(),
);
let json = serde_json::to_string(&meta).unwrap();
for null_key in [
r#""version":null"#,
r#""config_hash":null"#,
r#""ktstr_kconfig_hash":null"#,
r#""extra_kconfig_hash":null"#,
r#""source_vmlinux_size":null"#,
r#""source_vmlinux_mtime_secs":null"#,
] {
assert!(
json.contains(null_key),
"serialized JSON must contain explicit `{null_key}` \
— None must round-trip as null, not as an absent \
key. Got: {json}",
);
}
let parsed: KernelMetadata = serde_json::from_str(&json).unwrap();
assert!(parsed.version.is_none(), "version must round-trip None");
assert!(
parsed.config_hash.is_none(),
"config_hash must round-trip None"
);
assert!(
parsed.ktstr_kconfig_hash.is_none(),
"ktstr_kconfig_hash must round-trip None"
);
assert!(
parsed.extra_kconfig_hash.is_none(),
"extra_kconfig_hash must round-trip None"
);
assert!(
parsed.source_vmlinux_size.is_none(),
"source_vmlinux_size must round-trip None"
);
assert!(
parsed.source_vmlinux_mtime_secs.is_none(),
"source_vmlinux_mtime_secs must round-trip None"
);
}
#[test]
fn kernel_metadata_all_option_fields_populated_roundtrip() {
let meta = KernelMetadata::new(
KernelSource::Tarball,
"x86_64".to_string(),
"bzImage".to_string(),
"2026-04-12T10:00:00Z".to_string(),
)
.with_version(Some("6.14.2".to_string()))
.with_config_hash(Some("cfg-hash".to_string()))
.with_ktstr_kconfig_hash(Some("ktstr-hash".to_string()))
.with_extra_kconfig_hash(Some("extra-hash".to_string()))
.with_source_vmlinux_stat(123_456_789, 1_700_000_000);
let json = serde_json::to_string(&meta).unwrap();
let parsed: KernelMetadata = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.version.as_deref(), Some("6.14.2"));
assert_eq!(parsed.config_hash.as_deref(), Some("cfg-hash"));
assert_eq!(parsed.ktstr_kconfig_hash.as_deref(), Some("ktstr-hash"));
assert_eq!(parsed.extra_kconfig_hash.as_deref(), Some("extra-hash"));
assert_eq!(parsed.source_vmlinux_size, Some(123_456_789));
assert_eq!(parsed.source_vmlinux_mtime_secs, Some(1_700_000_000));
}
#[test]
fn cache_metadata_serde_local_with_source_tree() {
let meta = KernelMetadata {
version: Some("6.14.0".to_string()),
source: KernelSource::Local {
source_tree_path: Some(PathBuf::from("/tmp/linux")),
git_hash: Some("deadbee".to_string()),
},
arch: "x86_64".to_string(),
image_name: "bzImage".to_string(),
config_hash: Some("fff000".to_string()),
built_at: "2026-04-12T14:00:00Z".to_string(),
ktstr_kconfig_hash: Some("aaa111".to_string()),
extra_kconfig_hash: None,
has_vmlinux: true,
vmlinux_stripped: true,
source_vmlinux_size: None,
source_vmlinux_mtime_secs: None,
};
let json = serde_json::to_string(&meta).unwrap();
let parsed: KernelMetadata = serde_json::from_str(&json).unwrap();
assert!(matches!(
parsed.source,
KernelSource::Local {
source_tree_path: Some(ref p),
git_hash: Some(ref h),
}
if p == &PathBuf::from("/tmp/linux") && h == "deadbee"
));
assert!(parsed.has_vmlinux);
assert!(parsed.vmlinux_stripped);
}
#[test]
fn kernel_source_local_git_hash_serde_round_trip_none() {
let src = KernelSource::Local {
source_tree_path: Some(PathBuf::from("/tmp/linux")),
git_hash: None,
};
let json = serde_json::to_string(&src).unwrap();
assert!(
json.contains(r#""git_hash":null"#),
"git_hash=None must round-trip as explicit null, got {json}"
);
let parsed: KernelSource = serde_json::from_str(&json).unwrap();
assert!(matches!(parsed, KernelSource::Local { git_hash: None, .. }));
}
#[test]
fn kernel_source_option_fields_serialize_as_explicit_null() {
let local = KernelSource::Local {
source_tree_path: None,
git_hash: None,
};
let local_json = serde_json::to_string(&local).unwrap();
assert!(
local_json.contains(r#""source_tree_path":null"#),
"Local.source_tree_path=None must serialize as explicit null, got {local_json}"
);
assert!(
local_json.contains(r#""git_hash":null"#),
"Local.git_hash=None must serialize as explicit null, got {local_json}"
);
let git = KernelSource::Git {
git_hash: None,
git_ref: None,
};
let git_json = serde_json::to_string(&git).unwrap();
assert!(
git_json.contains(r#""git_hash":null"#),
"Git.git_hash=None must serialize as explicit null, got {git_json}"
);
assert!(
git_json.contains(r#""ref":null"#),
"Git.git_ref=None must serialize as explicit null under the `ref` key, got {git_json}"
);
}
#[test]
fn kernel_source_absent_option_keys_deserialize_as_none() {
let git_bare: KernelSource = serde_json::from_str(r#"{"type":"git"}"#)
.expect("Git with absent Option keys must deserialize");
assert!(matches!(
git_bare,
KernelSource::Git {
git_hash: None,
git_ref: None,
}
));
let git_hash_only: KernelSource =
serde_json::from_str(r#"{"type":"git","git_hash":"abc"}"#)
.expect("Git with only git_hash must deserialize");
assert!(matches!(
git_hash_only,
KernelSource::Git {
git_hash: Some(ref h),
git_ref: None,
} if h == "abc"
));
let git_ref_only: KernelSource = serde_json::from_str(r#"{"type":"git","ref":"main"}"#)
.expect("Git with only ref must deserialize");
assert!(matches!(
git_ref_only,
KernelSource::Git {
git_hash: None,
git_ref: Some(ref r),
} if r == "main"
));
let local_bare: KernelSource = serde_json::from_str(r#"{"type":"local"}"#)
.expect("Local with absent Option keys must deserialize");
assert!(matches!(
local_bare,
KernelSource::Local {
source_tree_path: None,
git_hash: None,
}
));
let local_path_only: KernelSource =
serde_json::from_str(r#"{"type":"local","source_tree_path":"/tmp/linux"}"#)
.expect("Local with only source_tree_path must deserialize");
assert!(matches!(
local_path_only,
KernelSource::Local {
source_tree_path: Some(ref p),
git_hash: None,
} if p.to_str() == Some("/tmp/linux")
));
let local_hash_only: KernelSource =
serde_json::from_str(r#"{"type":"local","git_hash":"deadbeef"}"#)
.expect("Local with only git_hash must deserialize");
assert!(matches!(
local_hash_only,
KernelSource::Local {
source_tree_path: None,
git_hash: Some(ref h),
} if h == "deadbeef"
));
}
#[test]
fn kernel_source_serde_tagged_representation() {
let t = serde_json::to_string(&KernelSource::Tarball).unwrap();
assert_eq!(t, r#"{"type":"tarball"}"#);
let g = serde_json::to_string(&KernelSource::Git {
git_hash: Some("abc".to_string()),
git_ref: Some("main".to_string()),
})
.unwrap();
assert!(g.contains(r#""type":"git""#));
assert!(g.contains(r#""git_hash":"abc""#));
assert!(g.contains(r#""ref":"main""#));
let l = serde_json::to_string(&KernelSource::Local {
source_tree_path: Some(PathBuf::from("/tmp/linux")),
git_hash: Some("a1b2c3d".to_string()),
})
.unwrap();
assert!(l.contains(r#""type":"local""#));
assert!(l.contains(r#""source_tree_path":"/tmp/linux""#));
assert!(l.contains(r#""git_hash":"a1b2c3d""#));
}
#[test]
fn classify_corrupt_reason_covers_every_documented_prefix() {
let cases: &[(&str, &str)] = &[
("metadata.json missing", "missing"),
(
"metadata.json unreadable: Is a directory (os error 21)",
"unreadable",
),
(
"metadata.json schema drift: missing field `source` at line 1 column 21",
"schema_drift",
),
(
"metadata.json malformed: expected value at line 1 column 1",
"malformed",
),
(
"metadata.json truncated: EOF while parsing a value at line 1 column 10",
"truncated",
),
(
"metadata.json parse error: something unexpected",
"parse_error",
),
(
"image file bzImage missing from entry directory",
"image_missing",
),
("some future prefix nobody wrote yet", "unknown"),
];
for (reason, expected) in cases {
assert_eq!(
classify_corrupt_reason(reason),
*expected,
"reason `{reason}` should classify as `{expected}`",
);
}
}
#[test]
fn classify_corrupt_reason_image_missing_requires_exact_suffix() {
assert_eq!(
classify_corrupt_reason("image file bzImage missing checksum field"),
"unknown",
"non-canonical 'image file … missing X' reason must NOT \
classify as `image_missing` — only the exact \
format_image_missing_reason() form is accepted",
);
assert_eq!(
classify_corrupt_reason("foo bzImage missing from entry directory"),
"unknown",
"suffix-only match without the canonical prefix must classify as unknown",
);
assert_eq!(
classify_corrupt_reason("image file missing from entry directory"),
"unknown",
"prefix+suffix with no image_name in between must classify as unknown",
);
}
#[test]
fn classify_corrupt_reason_accepts_every_format_image_missing_output() {
for image_name in [
"bzImage",
"Image",
"vmlinuz-6.14.2",
"kernel.bin",
"image_with_underscores",
"name-with-many-dashes",
] {
let reason = format_image_missing_reason(image_name);
assert_eq!(
classify_corrupt_reason(&reason),
"image_missing",
"every produced reason (image_name={image_name:?}) must \
classify as image_missing — got reason {reason:?}",
);
}
}
#[test]
fn listed_entry_error_kind_dispatches_on_variant() {
let tmp = TempDir::new().unwrap();
let cache = CacheDir::with_root(tmp.path().join("cache"));
let src_dir = TempDir::new().unwrap();
let image = create_fake_image(src_dir.path());
let meta = test_metadata("6.14.2");
cache
.store("valid-ek", &CacheArtifacts::new(&image), &meta)
.unwrap();
let bad_dir = tmp.path().join("cache").join("corrupt-ek");
fs::create_dir_all(&bad_dir).unwrap();
let entries = cache.list().unwrap();
assert_eq!(entries.len(), 2);
let valid = entries
.iter()
.find(|e| e.key() == "valid-ek")
.expect("valid entry must be listed");
let corrupt = entries
.iter()
.find(|e| e.key() == "corrupt-ek")
.expect("corrupt entry must be listed");
assert_eq!(
valid.error_kind(),
None,
"Valid entries must report no error_kind",
);
assert_eq!(
corrupt.error_kind(),
Some("missing"),
"missing-metadata Corrupt entry must classify as `missing`",
);
}
#[test]
fn kconfig_status_display_matches_renders_lowercase_word() {
assert_eq!(KconfigStatus::Matches.to_string(), "matches");
}
#[test]
fn kconfig_status_display_stale_renders_lowercase_word_without_hashes() {
let s = KconfigStatus::Stale {
cached: "deadbeef".to_string(),
current: "cafebabe".to_string(),
}
.to_string();
assert_eq!(
s, "stale",
"Display elides the cached/current hashes; callers that need them must match on the variant directly"
);
}
#[test]
fn kconfig_status_display_untracked_renders_lowercase_word() {
assert_eq!(KconfigStatus::Untracked.to_string(), "untracked");
}
#[test]
fn kconfig_status_matches_is_neither_stale_nor_untracked() {
let s = KconfigStatus::Matches;
assert!(!s.is_stale(), "Matches must not be stale");
assert!(!s.is_untracked(), "Matches must not be untracked");
}
#[test]
fn kconfig_status_stale_is_stale_only() {
let s = KconfigStatus::Stale {
cached: "old".to_string(),
current: "new".to_string(),
};
assert!(s.is_stale(), "Stale variant must report is_stale=true");
assert!(
!s.is_untracked(),
"Stale must NOT report is_untracked — the two predicates \
discriminate distinct variants",
);
}
#[test]
fn kconfig_status_untracked_is_untracked_only() {
let s = KconfigStatus::Untracked;
assert!(
s.is_untracked(),
"Untracked variant must report is_untracked=true"
);
assert!(
!s.is_stale(),
"Untracked must NOT report is_stale — pre-tracking-format \
entries are unknown, not stale; treating them as stale \
would force a rebuild on every old cache hit",
);
}
#[test]
fn as_local_git_hash_returns_local_hash() {
let src = KernelSource::Local {
source_tree_path: Some(PathBuf::from("/tmp/linux")),
git_hash: Some("deadbee".to_string()),
};
assert_eq!(
src.as_local_git_hash(),
Some("deadbee"),
"Local with git_hash=Some must surface the inner str",
);
}
#[test]
fn as_local_git_hash_returns_none_when_local_has_no_hash() {
let src = KernelSource::Local {
source_tree_path: Some(PathBuf::from("/tmp/linux")),
git_hash: None,
};
assert_eq!(
src.as_local_git_hash(),
None,
"Local with git_hash=None must surface None — the dirty \
check has no anchor on a non-git or dirty tree",
);
}
#[test]
fn as_local_git_hash_returns_none_for_tarball() {
let src = KernelSource::Tarball;
assert_eq!(
src.as_local_git_hash(),
None,
"Tarball variant has no git_hash and must surface None",
);
}
#[test]
fn as_local_git_hash_returns_none_for_git_even_with_hash_field() {
let src = KernelSource::Git {
git_hash: Some("a1b2c3d".to_string()),
git_ref: Some("main".to_string()),
};
assert_eq!(
src.as_local_git_hash(),
None,
"Git variant has its own git_hash field but the \
accessor is named as_LOCAL_git_hash — it MUST NOT \
surface the Git variant's hash, since the Git hash \
describes the cloned commit (acquire-time) and the \
Local hash describes the operator's working tree HEAD \
(a different role with different semantics)",
);
}
#[test]
fn format_image_missing_reason_uses_canonical_prefix_and_suffix() {
let reason = format_image_missing_reason("bzImage");
assert_eq!(
reason, "image file bzImage missing from entry directory",
"the produced reason MUST be the exact prefix + image_name + \
suffix concatenation — any drift breaks the classifier's \
exact-match predicate",
);
assert!(
reason.starts_with(IMAGE_MISSING_PREFIX),
"produced reason must start with IMAGE_MISSING_PREFIX",
);
assert!(
reason.ends_with(IMAGE_MISSING_SUFFIX),
"produced reason must end with IMAGE_MISSING_SUFFIX",
);
}
#[test]
fn format_image_missing_reason_with_empty_image_name() {
let reason = format_image_missing_reason("");
assert_eq!(
reason, "image file missing from entry directory",
"empty image_name must produce a verbatim concatenation \
(prefix trailing-space + suffix leading-space → two \
consecutive spaces between `file` and `missing`); the \
producer does not validate (validation is at \
validate_filename time), so the degenerate concatenation \
is the documented behaviour",
);
assert_eq!(
classify_corrupt_reason(&reason),
"unknown",
"the classifier's length-guard MUST reject the \
prefix+suffix-with-empty-image-name form — a regression \
that loosened the guard would let a future bug emit \
format_image_missing_reason(\"\") and silently classify \
it as image_missing, hiding the real defect (empty \
image_name in metadata)",
);
}
#[test]
fn cache_entry_disk_template_path_joins_canonical_filename() {
let tmp = TempDir::new().unwrap();
let cache = CacheDir::with_root(tmp.path().join("cache"));
let src_dir = TempDir::new().unwrap();
let image = create_fake_image(src_dir.path());
let entry = cache
.store(
"disk-tmpl-key",
&CacheArtifacts::new(&image),
&test_metadata("6.14.2"),
)
.unwrap();
assert_eq!(
entry.disk_template_path(),
entry.path.join("disk-template.img"),
"disk_template_path() MUST resolve to <entry>/disk-template.img — \
the literal `disk-template.img` is the canonical filename the \
VMM disk_template module writes/reads",
);
assert!(
!entry.disk_template_path().exists(),
"disk_template_path() must be path-only — store() does not \
create the file; absence is the expected post-store state",
);
}
#[test]
fn cache_entry_has_extra_kconfig_true_when_hash_present() {
let tmp = TempDir::new().unwrap();
let cache = CacheDir::with_root(tmp.path().join("cache"));
let src_dir = TempDir::new().unwrap();
let image = create_fake_image(src_dir.path());
let meta =
test_metadata("6.14.2").with_extra_kconfig_hash(Some("user-fragment-hash".to_string()));
let entry = cache
.store("with-extra", &CacheArtifacts::new(&image), &meta)
.unwrap();
assert!(
entry.has_extra_kconfig(),
"extra_kconfig_hash=Some MUST report has_extra_kconfig()=true — \
a regression that flipped polarity would invert the \
rebuild-on-fragment-change policy",
);
}
#[test]
fn cache_entry_has_extra_kconfig_false_when_hash_absent() {
let tmp = TempDir::new().unwrap();
let cache = CacheDir::with_root(tmp.path().join("cache"));
let src_dir = TempDir::new().unwrap();
let image = create_fake_image(src_dir.path());
let meta = test_metadata("6.14.2");
assert!(
meta.extra_kconfig_hash.is_none(),
"test_metadata default must keep extra_kconfig_hash=None \
so this test exercises the false branch",
);
let entry = cache
.store("no-extra", &CacheArtifacts::new(&image), &meta)
.unwrap();
assert!(
!entry.has_extra_kconfig(),
"extra_kconfig_hash=None MUST report has_extra_kconfig()=false",
);
}
#[test]
fn listed_entry_path_accessor_returns_valid_entry_path() {
let tmp = TempDir::new().unwrap();
let cache = CacheDir::with_root(tmp.path().join("cache"));
let src_dir = TempDir::new().unwrap();
let image = create_fake_image(src_dir.path());
let entry = cache
.store(
"valid-path-test",
&CacheArtifacts::new(&image),
&test_metadata("6.14.2"),
)
.unwrap();
let expected_path = entry.path.clone();
let entries = cache.list().unwrap();
let listed = entries
.iter()
.find(|e| e.key() == "valid-path-test")
.expect("the just-stored entry must be in the list");
assert!(
matches!(listed, ListedEntry::Valid(_)),
"precondition: stored entry must surface as Valid",
);
assert_eq!(
listed.path(),
expected_path,
"ListedEntry::path() on Valid must return the inner \
CacheEntry's path — accessor MUST forward, not synthesize",
);
}
#[test]
fn listed_entry_path_accessor_returns_corrupt_entry_path() {
let tmp = TempDir::new().unwrap();
let cache = CacheDir::with_root(tmp.path().join("cache"));
let entry_dir = tmp.path().join("cache").join("corrupt-path-test");
std::fs::create_dir_all(&entry_dir).unwrap();
let entries = cache.list().unwrap();
let listed = entries
.iter()
.find(|e| e.key() == "corrupt-path-test")
.expect("corrupt-shaped entry must be in the list");
assert!(
matches!(listed, ListedEntry::Corrupt { .. }),
"precondition: missing-metadata entry must surface as Corrupt",
);
assert_eq!(
listed.path(),
entry_dir,
"ListedEntry::path() on Corrupt must return the variant's \
`path` field — accessor MUST forward, not synthesize",
);
}
}