ktstr/cache/
metadata.rs

1//! Pure data types for the kernel image cache.
2//!
3//! Public shape of cache entries — [`KernelSource`] /
4//! [`KernelMetadata`] / [`CacheArtifacts`] / [`KconfigStatus`] /
5//! [`CacheEntry`] / [`ListedEntry`] — plus the internal
6//! [`classify_corrupt_reason`] dispatcher that routes
7//! `read_metadata`-emitted reason strings into stable `error_kind`
8//! snake_case identifiers surfaced by `kernel list --json`. No I/O,
9//! no syscalls — every entry point is a pure transformation over
10//! already-loaded data.
11
12use std::fmt;
13use std::path::{Path, PathBuf};
14
15use serde::{Deserialize, Serialize};
16
17/// How a cached kernel's source was acquired, with per-variant
18/// payload (git details for `Git`, source-tree path and git hash for
19/// `Local`).
20///
21/// Serialized as `{"type": "tarball"}`, `{"type": "git", "git_hash": ..., "ref": ...}`,
22/// or `{"type": "local", "source_tree_path": ..., "git_hash": ...}`.
23/// Every per-variant payload field is emitted explicitly — `Option`
24/// fields serialize as `null` when `None` rather than being skipped,
25/// so JSON consumers see stable keys across every variant regardless
26/// of which optional payload values are set.
27///
28/// On deserialize, serde_json treats absent `Option` keys as `None`,
29/// so an old `metadata.json` that drops `git_hash`, `ref`, or
30/// `source_tree_path` still round-trips. Cache-integrity enforcement
31/// (truncated `metadata.json` surfacing as [`ListedEntry::Corrupt`]
32/// via [`crate::cache::CacheDir::list`]) rides on the required
33/// non-`Option` fields of [`KernelMetadata`], not on the optional
34/// payloads here.
35#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
36#[serde(rename_all = "lowercase", tag = "type")]
37#[non_exhaustive]
38pub enum KernelSource {
39    /// Downloaded tarball from kernel.org (version / prefix / EOL
40    /// probe paths).
41    Tarball,
42    /// Shallow clone of a git URL at a caller-specified ref.
43    Git {
44        /// Git commit hash of the kernel source (short form).
45        git_hash: Option<String>,
46        /// Git ref used for checkout (branch, tag, or ref spec).
47        #[serde(rename = "ref")]
48        git_ref: Option<String>,
49    },
50    /// Build of a local on-disk kernel source tree.
51    Local {
52        /// Path to the source tree on disk. `None` when the tree has
53        /// been sanitized for remote cache transport or is otherwise
54        /// unavailable.
55        source_tree_path: Option<PathBuf>,
56        /// Git commit hash of the source tree at build time (short
57        /// form). `None` when the tree is not a git repository, the
58        /// hash could not be read, or the worktree is dirty — a
59        /// HEAD hash does not describe a tree with uncommitted
60        /// changes, so identifying it by that hash would mislead a
61        /// reproducer.
62        git_hash: Option<String>,
63    },
64}
65
66impl fmt::Display for KernelSource {
67    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
68        match self {
69            KernelSource::Tarball => f.write_str("tarball"),
70            KernelSource::Git { .. } => f.write_str("git"),
71            KernelSource::Local { .. } => f.write_str("local"),
72        }
73    }
74}
75
76impl KernelSource {
77    /// Borrow the `git_hash` field on a [`KernelSource::Local`]
78    /// variant. Returns `None` for any other variant or when the
79    /// Local variant carries `git_hash: None` (dirty / non-git
80    /// tree at acquire time).
81    ///
82    /// Mainly used by [`crate::cli::kernel_build_pipeline`]'s
83    /// post-build dirty re-check, which compares the post-build
84    /// HEAD hash against the acquire-time hash to detect mid-build
85    /// commits or branch flips. Borrows rather than clones so the
86    /// caller does not pay an allocation when only comparing.
87    pub fn as_local_git_hash(&self) -> Option<&str> {
88        match self {
89            KernelSource::Local { git_hash, .. } => git_hash.as_deref(),
90            _ => None,
91        }
92    }
93
94    /// Construct [`KernelSource::Git`] with both `git_hash` and
95    /// `git_ref` populated. Use struct-literal syntax directly if
96    /// either field should be `None`.
97    pub fn git(git_hash: impl Into<String>, git_ref: impl Into<String>) -> Self {
98        KernelSource::Git {
99            git_hash: Some(git_hash.into()),
100            git_ref: Some(git_ref.into()),
101        }
102    }
103}
104
105/// Metadata stored alongside a cached kernel image.
106///
107/// Required fields (`source`, `arch`, `image_name`, `built_at`,
108/// `has_vmlinux`, `vmlinux_stripped`) must be present in
109/// `metadata.json` during deserialization; a truncated file that
110/// drops any of them surfaces the entry as [`ListedEntry::Corrupt`]
111/// via [`crate::cache::CacheDir::list`] rather than silently
112/// defaulting. Optional fields tolerate absent keys as `None`.
113#[derive(Debug, Clone, Serialize, Deserialize)]
114#[non_exhaustive]
115pub struct KernelMetadata {
116    /// Kernel version string (e.g. "6.14.2", "6.15-rc3").
117    /// `None` when no version could be established — a local or git
118    /// source whose `Makefile` is unreadable/unparsable, or an
119    /// acquisition path that records none.
120    pub version: Option<String>,
121    /// How the kernel source was acquired, with per-source payload.
122    pub source: KernelSource,
123    /// Target architecture (e.g. "x86_64", "aarch64").
124    pub arch: String,
125    /// Boot image filename (e.g. "bzImage", "Image").
126    pub image_name: String,
127    /// CRC32 of the final .config used for the build.
128    pub config_hash: Option<String>,
129    /// ISO 8601 timestamp of when the image was built.
130    pub built_at: String,
131    /// CRC32 of ktstr.kconfig at build time.
132    pub ktstr_kconfig_hash: Option<String>,
133    /// CRC32 of the user-supplied `--extra-kconfig` fragment (raw
134    /// bytes) at build time. `None` for builds without
135    /// `--extra-kconfig`.
136    pub extra_kconfig_hash: Option<String>,
137    /// Whether a vmlinux ELF was cached alongside the image.
138    /// Required in metadata.json.
139    pub(crate) has_vmlinux: bool,
140    /// Whether the cached vmlinux ELF came from a successful strip
141    /// pass (`true`) or the raw-fallback path (`false`).
142    pub(crate) vmlinux_stripped: bool,
143    /// Size in bytes of the SOURCE-TREE vmlinux at cache-store time.
144    pub source_vmlinux_size: Option<u64>,
145    /// Modification time (seconds since UNIX epoch) of the
146    /// SOURCE-TREE vmlinux at cache-store time.
147    pub source_vmlinux_mtime_secs: Option<i64>,
148}
149
150impl KernelMetadata {
151    /// Create a new KernelMetadata with required fields.
152    pub fn new(
153        source: KernelSource,
154        arch: impl Into<String>,
155        image_name: impl Into<String>,
156        built_at: impl Into<String>,
157    ) -> Self {
158        KernelMetadata {
159            version: None,
160            source,
161            arch: arch.into(),
162            image_name: image_name.into(),
163            config_hash: None,
164            built_at: built_at.into(),
165            ktstr_kconfig_hash: None,
166            extra_kconfig_hash: None,
167            has_vmlinux: false,
168            vmlinux_stripped: false,
169            source_vmlinux_size: None,
170            source_vmlinux_mtime_secs: None,
171        }
172    }
173
174    /// Set the source-tree vmlinux size and mtime captured at cache
175    /// store time.
176    pub fn with_source_vmlinux_stat(mut self, size: u64, mtime_secs: i64) -> Self {
177        self.source_vmlinux_size = Some(size);
178        self.source_vmlinux_mtime_secs = Some(mtime_secs);
179        self
180    }
181
182    /// Unset the source-tree vmlinux stat (both size and mtime back
183    /// to the `None` default).
184    pub fn clear_source_vmlinux_stat(mut self) -> Self {
185        self.source_vmlinux_size = None;
186        self.source_vmlinux_mtime_secs = None;
187        self
188    }
189
190    /// Set the kernel version.
191    pub fn with_version(mut self, version: impl Into<String>) -> Self {
192        self.version = Some(version.into());
193        self
194    }
195
196    /// Unset the kernel version (back to the `None` default).
197    pub fn clear_version(mut self) -> Self {
198        self.version = None;
199        self
200    }
201
202    /// Set the .config CRC32 hash.
203    pub fn with_config_hash(mut self, hash: impl Into<String>) -> Self {
204        self.config_hash = Some(hash.into());
205        self
206    }
207
208    /// Unset the .config CRC32 hash (back to the `None` default).
209    pub fn clear_config_hash(mut self) -> Self {
210        self.config_hash = None;
211        self
212    }
213
214    /// Set the ktstr.kconfig CRC32 hash.
215    pub fn with_ktstr_kconfig_hash(mut self, hash: impl Into<String>) -> Self {
216        self.ktstr_kconfig_hash = Some(hash.into());
217        self
218    }
219
220    /// Unset the ktstr.kconfig CRC32 hash (back to the `None` default).
221    pub fn clear_ktstr_kconfig_hash(mut self) -> Self {
222        self.ktstr_kconfig_hash = None;
223        self
224    }
225
226    /// Set the `--extra-kconfig` fragment CRC32 hash.
227    pub fn with_extra_kconfig_hash(mut self, hash: impl Into<String>) -> Self {
228        self.extra_kconfig_hash = Some(hash.into());
229        self
230    }
231
232    /// Unset the `--extra-kconfig` fragment CRC32 hash (back to the `None` default).
233    pub fn clear_extra_kconfig_hash(mut self) -> Self {
234        self.extra_kconfig_hash = None;
235        self
236    }
237
238    /// Whether a vmlinux ELF was cached alongside the image.
239    pub fn has_vmlinux(&self) -> bool {
240        self.has_vmlinux
241    }
242
243    /// Crate-only mutator for `has_vmlinux`.
244    pub(crate) fn set_has_vmlinux(&mut self, value: bool) {
245        self.has_vmlinux = value;
246    }
247
248    /// Whether the cached vmlinux came from a successful strip pass.
249    pub fn vmlinux_stripped(&self) -> bool {
250        self.vmlinux_stripped
251    }
252
253    /// Crate-only mutator for `vmlinux_stripped`.
254    pub(crate) fn set_vmlinux_stripped(&mut self, value: bool) {
255        self.vmlinux_stripped = value;
256    }
257}
258
259/// Bundle of cache artifacts for [`crate::cache::CacheDir::store`].
260///
261/// The vmlinux path points at the raw (unstripped) ELF. `store()`
262/// strips it internally via `crate::cache::strip_vmlinux_debug`
263/// and writes the result.
264#[derive(Debug, Clone)]
265#[non_exhaustive]
266pub struct CacheArtifacts<'a> {
267    /// Path to the kernel boot image (bzImage or Image).
268    pub image: &'a Path,
269    /// Optional path to the raw (unstripped) vmlinux ELF. `store()`
270    /// strips it internally before caching.
271    pub vmlinux: Option<&'a Path>,
272}
273
274impl<'a> CacheArtifacts<'a> {
275    /// Create an artifact bundle with only the required image.
276    pub fn new(image: &'a Path) -> Self {
277        CacheArtifacts {
278            image,
279            vmlinux: None,
280        }
281    }
282
283    /// Attach the raw (unstripped) vmlinux ELF.
284    pub fn with_vmlinux(mut self, vmlinux: &'a Path) -> Self {
285        self.vmlinux = Some(vmlinux);
286        self
287    }
288}
289
290/// Comparison between a cache entry's kconfig hash and a current
291/// reference hash. Returned by [`CacheEntry::kconfig_status`].
292#[derive(Debug, Clone, PartialEq, Eq)]
293#[non_exhaustive]
294pub enum KconfigStatus {
295    /// Entry was built with the current kconfig — nothing to do.
296    Matches,
297    /// Entry was built with a different kconfig.
298    Stale {
299        /// Hash recorded in the cache entry.
300        cached: String,
301        /// Hash the caller compared against.
302        current: String,
303    },
304    /// Entry has no kconfig hash recorded (pre-tracking cache
305    /// format). Treat as unknown; do not assume stale.
306    Untracked,
307}
308
309impl KconfigStatus {
310    /// `true` iff the entry is stale against the current kconfig.
311    pub fn is_stale(&self) -> bool {
312        matches!(self, Self::Stale { .. })
313    }
314
315    /// `true` iff the entry has no recorded kconfig hash.
316    pub fn is_untracked(&self) -> bool {
317        matches!(self, Self::Untracked)
318    }
319}
320
321impl fmt::Display for KconfigStatus {
322    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
323        match self {
324            KconfigStatus::Matches => f.write_str("matches"),
325            KconfigStatus::Stale { .. } => f.write_str("stale"),
326            KconfigStatus::Untracked => f.write_str("untracked"),
327        }
328    }
329}
330
331/// A cached kernel entry returned by
332/// [`crate::cache::CacheDir::lookup`] and
333/// [`crate::cache::CacheDir::store`].
334#[derive(Debug)]
335#[non_exhaustive]
336pub struct CacheEntry {
337    /// Cache key (directory name).
338    pub key: String,
339    /// Path to the cache entry directory.
340    pub path: PathBuf,
341    /// Deserialized metadata. Always present on a [`CacheEntry`].
342    pub metadata: KernelMetadata,
343}
344
345impl CacheEntry {
346    /// Absolute path to the cached boot image.
347    pub fn image_path(&self) -> PathBuf {
348        self.path.join(&self.metadata.image_name)
349    }
350
351    /// Absolute path to the cached stripped vmlinux ELF, when one
352    /// was stored alongside the image.
353    pub fn vmlinux_path(&self) -> Option<PathBuf> {
354        self.metadata.has_vmlinux.then(|| self.path.join("vmlinux"))
355    }
356
357    /// Absolute path to the cached btrfs disk template
358    /// (`<entry>/disk-template.img`).
359    pub fn disk_template_path(&self) -> PathBuf {
360        self.path.join("disk-template.img")
361    }
362
363    /// Compare this entry's kconfig hash against `current_hash`.
364    pub fn kconfig_status(&self, current_hash: &str) -> KconfigStatus {
365        match self.metadata.ktstr_kconfig_hash.as_deref() {
366            None => KconfigStatus::Untracked,
367            Some(h) if h == current_hash => KconfigStatus::Matches,
368            Some(h) => KconfigStatus::Stale {
369                cached: h.to_string(),
370                current: current_hash.to_string(),
371            },
372        }
373    }
374
375    /// Whether this cache entry was built with a user
376    /// `--extra-kconfig` fragment.
377    pub fn has_extra_kconfig(&self) -> bool {
378        self.metadata.extra_kconfig_hash.is_some()
379    }
380}
381
382/// Entry yielded by [`crate::cache::CacheDir::list`]. Distinguishes
383/// valid entries from corrupt ones.
384#[derive(Debug)]
385#[non_exhaustive]
386pub enum ListedEntry {
387    /// Valid cache entry with parsed metadata and an image file
388    /// present on disk at the metadata-declared path.
389    Valid(Box<CacheEntry>),
390    /// Entry directory exists but is unusable.
391    Corrupt {
392        /// Cache key (directory name).
393        key: String,
394        /// Path to the (corrupt) entry directory.
395        path: PathBuf,
396        /// Human-readable explanation of why the entry is classified
397        /// as corrupt.
398        reason: String,
399    },
400}
401
402impl ListedEntry {
403    /// Cache key (directory name) for either variant.
404    pub fn key(&self) -> &str {
405        match self {
406            ListedEntry::Valid(e) => &e.key,
407            ListedEntry::Corrupt { key, .. } => key,
408        }
409    }
410
411    /// Path to the entry directory for either variant.
412    pub fn path(&self) -> &Path {
413        match self {
414            ListedEntry::Valid(e) => &e.path,
415            ListedEntry::Corrupt { path, .. } => path,
416        }
417    }
418
419    /// Borrow the valid [`CacheEntry`] payload, or `None` for
420    /// [`ListedEntry::Corrupt`].
421    pub fn as_valid(&self) -> Option<&CacheEntry> {
422        match self {
423            ListedEntry::Valid(e) => Some(e.as_ref()),
424            ListedEntry::Corrupt { .. } => None,
425        }
426    }
427
428    /// Machine-readable classification of a corrupt entry's failure
429    /// mode. Returns `None` on a `Valid` entry.
430    pub fn error_kind(&self) -> Option<&'static str> {
431        match self {
432            ListedEntry::Valid(_) => None,
433            ListedEntry::Corrupt { reason, .. } => Some(classify_corrupt_reason(reason)),
434        }
435    }
436}
437
438/// Trailing literal of every "image_missing" reason string. Pinned
439/// here so [`format_image_missing_reason`] (the producer) and
440/// [`classify_corrupt_reason`] (the consumer) reference the same
441/// constant — the exact-suffix match in the classifier's
442/// `image_missing` arm cannot drift if a future edit changes the
443/// trailing wording without updating both sites.
444pub(crate) const IMAGE_MISSING_SUFFIX: &str = " missing from entry directory";
445
446/// Leading literal of every "image_missing" reason string. Pinned
447/// alongside [`IMAGE_MISSING_SUFFIX`] so both the producer and the
448/// classifier key on the same constants.
449pub(crate) const IMAGE_MISSING_PREFIX: &str = "image file ";
450
451/// Format the canonical "image_missing" reason string emitted by
452/// [`crate::cache::CacheDir::list`] when an entry's
453/// `metadata.json` is parseable but the boot image it names is
454/// absent from the entry directory.
455///
456/// Centralised here so the producer site (`cache_dir.rs`'s
457/// `list` corrupt-entry arm) and the classifier
458/// [`classify_corrupt_reason`] cannot drift out of lockstep —
459/// the result begins with [`IMAGE_MISSING_PREFIX`] and ends with
460/// [`IMAGE_MISSING_SUFFIX`], so the classifier's exact prefix +
461/// exact suffix predicate matches by construction without
462/// admitting unrelated reasons that merely happen to contain
463/// either literal.
464pub(crate) fn format_image_missing_reason(image_name: &str) -> String {
465    format!("{IMAGE_MISSING_PREFIX}{image_name}{IMAGE_MISSING_SUFFIX}")
466}
467
468/// Shared prefix → `error_kind` classifier.
469///
470/// Each `ListedEntry::Corrupt` carries a free-form `reason` string
471/// produced either by [`super::housekeeping::read_metadata`] (the
472/// six `metadata.json …` reasons) or by
473/// [`format_image_missing_reason`] at list-time (the `image_missing`
474/// reason, emitted from `cache_dir.rs`'s `list` corrupt arm when the
475/// image file is absent). This helper
476/// flattens those strings into a small, stable enum-of-strings the
477/// CLI surfaces in `cargo ktstr kernel list --json` as the
478/// `error_kind` field.
479///
480/// Reason-prefix → kind mapping (matched in this order):
481///
482/// | Reason (prefix or exact)                     | `error_kind`     |
483/// |----------------------------------------------|------------------|
484/// | `"metadata.json missing"` (exact)            | `"missing"`      |
485/// | `"metadata.json unreadable: …"`              | `"unreadable"`   |
486/// | `"metadata.json schema drift: …"`            | `"schema_drift"` |
487/// | `"metadata.json malformed: …"`               | `"malformed"`    |
488/// | `"metadata.json truncated: …"`               | `"truncated"`    |
489/// | `"metadata.json parse error: …"`             | `"parse_error"`  |
490/// | `"image file <name> missing from entry directory"` | `"image_missing"`|
491/// | (anything else)                              | `"unknown"`      |
492///
493/// The `image_missing` arm matches on the exact prefix
494/// [`IMAGE_MISSING_PREFIX`] AND the exact suffix
495/// [`IMAGE_MISSING_SUFFIX`] — both literals are produced verbatim
496/// by [`format_image_missing_reason`]. A loose `contains("missing")`
497/// would also match unrelated future reasons that happen to mention
498/// "missing" inside an `image file …` prefix (e.g. an "image file X
499/// missing checksum field" reason), so the dispatcher pins both ends
500/// of the canonical form.
501///
502/// [`super::housekeeping::read_metadata`] is the authoritative
503/// source of the six `metadata.json …` prefixes;
504/// [`format_image_missing_reason`] is the authoritative source of
505/// the `image_missing` prefix. If a new failure mode is
506/// added there, both this dispatcher and the
507/// `classify_corrupt_reason_covers_every_documented_prefix` test
508/// must be updated in lockstep so the JSON contract stays stable.
509pub(crate) fn classify_corrupt_reason(reason: &str) -> &'static str {
510    if reason == "metadata.json missing" {
511        "missing"
512    } else if reason.starts_with("metadata.json unreadable: ") {
513        "unreadable"
514    } else if reason.starts_with("metadata.json schema drift: ") {
515        "schema_drift"
516    } else if reason.starts_with("metadata.json malformed: ") {
517        "malformed"
518    } else if reason.starts_with("metadata.json truncated: ") {
519        "truncated"
520    } else if reason.starts_with("metadata.json parse error: ") {
521        // Forward-compat slot: no live producer in the current
522        // codebase. `read_metadata` uses `serde_json::from_str`
523        // which cannot return `Category::Io` (the arm is pinned
524        // `unreachable!` — see housekeeping.rs). The classifier
525        // arm is retained so a future producer that switches to
526        // `from_reader` (or any path that genuinely surfaces an
527        // I/O error during JSON decode) can emit the canonical
528        // prefix and have it route to the same stable
529        // `error_kind` value scripted consumers already dispatch
530        // on. Removing the arm now would force such a producer
531        // to either invent a new prefix (breaking the contract)
532        // or land in `unknown` (losing dispatch fidelity).
533        "parse_error"
534    } else if reason.starts_with(IMAGE_MISSING_PREFIX)
535        && reason.ends_with(IMAGE_MISSING_SUFFIX)
536        && reason.len() > IMAGE_MISSING_PREFIX.len() + IMAGE_MISSING_SUFFIX.len()
537    {
538        "image_missing"
539    } else {
540        "unknown"
541    }
542}
543
544#[cfg(test)]
545mod tests {
546    use super::*;
547    use crate::cache::shared_test_helpers::{create_fake_image, test_metadata};
548    use crate::cache::{CacheArtifacts, CacheDir};
549    use std::fs;
550    use std::path::PathBuf;
551    use tempfile::TempDir;
552
553    // -- KernelMetadata serde --
554
555    #[test]
556    fn cache_metadata_serde_roundtrip() {
557        let meta = test_metadata("6.14.2");
558        let json = serde_json::to_string_pretty(&meta).unwrap();
559        let parsed: KernelMetadata = serde_json::from_str(&json).unwrap();
560        assert_eq!(parsed.version.as_deref(), Some("6.14.2"));
561        assert_eq!(parsed.source, KernelSource::Tarball);
562        assert_eq!(parsed.arch, "x86_64");
563        assert_eq!(parsed.image_name, "bzImage");
564        assert_eq!(parsed.config_hash.as_deref(), Some("abc123"));
565        assert_eq!(parsed.built_at, "2026-04-12T10:00:00Z");
566        assert_eq!(parsed.ktstr_kconfig_hash.as_deref(), Some("def456"));
567        assert!(!parsed.has_vmlinux);
568        assert!(!parsed.vmlinux_stripped);
569    }
570
571    #[test]
572    fn cache_metadata_serde_git_with_payload() {
573        let meta = KernelMetadata {
574            version: Some("6.15-rc3".to_string()),
575            source: KernelSource::git("a1b2c3d", "v6.15-rc3"),
576            arch: "aarch64".to_string(),
577            image_name: "Image".to_string(),
578            config_hash: None,
579            built_at: "2026-04-12T12:00:00Z".to_string(),
580            ktstr_kconfig_hash: None,
581            extra_kconfig_hash: None,
582            has_vmlinux: false,
583            vmlinux_stripped: false,
584            source_vmlinux_size: None,
585            source_vmlinux_mtime_secs: None,
586        };
587        let json = serde_json::to_string(&meta).unwrap();
588        let parsed: KernelMetadata = serde_json::from_str(&json).unwrap();
589        assert!(matches!(
590            parsed.source,
591            KernelSource::Git {
592                git_hash: Some(ref h),
593                git_ref: Some(ref r),
594            }
595            if h == "a1b2c3d" && r == "v6.15-rc3"
596        ));
597    }
598
599    /// Every `Option<…>` field on the `KernelMetadata` wrapper struct
600    /// (not on its `KernelSource` payload — that is covered by
601    /// `kernel_source_*` tests) serializes as an explicit `null` when
602    /// `None` and deserializes back as `None`. Pins the post-shim
603    /// wire shape: with `serde(default)` and `skip_serializing_if`
604    /// removed, `None` round-trips through a literal `null` token in
605    /// the JSON, never an absent key.
606    ///
607    /// Built via `KernelMetadata::new` with `KernelSource::Tarball` —
608    /// the constructor sets every `Option` field to `None`, so the
609    /// `is_none()` assertions confirm both that the constructor is
610    /// the canonical path to an all-`None` value and that the wire
611    /// shape preserves it across a round-trip.
612    #[test]
613    fn kernel_metadata_option_fields_serialize_as_explicit_null() {
614        let meta = KernelMetadata::new(
615            KernelSource::Tarball,
616            "x86_64",
617            "bzImage",
618            "2026-04-12T10:00:00Z",
619        );
620        let json = serde_json::to_string(&meta).unwrap();
621        for null_key in [
622            r#""version":null"#,
623            r#""config_hash":null"#,
624            r#""ktstr_kconfig_hash":null"#,
625            r#""extra_kconfig_hash":null"#,
626            r#""source_vmlinux_size":null"#,
627            r#""source_vmlinux_mtime_secs":null"#,
628        ] {
629            assert!(
630                json.contains(null_key),
631                "serialized JSON must contain explicit `{null_key}` \
632                 — None must round-trip as null, not as an absent \
633                 key. Got: {json}",
634            );
635        }
636        // Round-trip back: every Option must still be None.
637        let parsed: KernelMetadata = serde_json::from_str(&json).unwrap();
638        assert!(parsed.version.is_none(), "version must round-trip None");
639        assert!(
640            parsed.config_hash.is_none(),
641            "config_hash must round-trip None"
642        );
643        assert!(
644            parsed.ktstr_kconfig_hash.is_none(),
645            "ktstr_kconfig_hash must round-trip None"
646        );
647        assert!(
648            parsed.extra_kconfig_hash.is_none(),
649            "extra_kconfig_hash must round-trip None"
650        );
651        assert!(
652            parsed.source_vmlinux_size.is_none(),
653            "source_vmlinux_size must round-trip None"
654        );
655        assert!(
656            parsed.source_vmlinux_mtime_secs.is_none(),
657            "source_vmlinux_mtime_secs must round-trip None"
658        );
659    }
660
661    /// Every `Option<…>` wrapper field with a Some(...) value
662    /// round-trips byte-equal through serialize → deserialize.
663    /// Pins the populated branch of every Option, complementing the
664    /// all-None test.
665    ///
666    /// Built via `KernelMetadata::new` followed by every chainable
667    /// setter for the six `Option` fields (`with_version`,
668    /// `with_config_hash`, `with_ktstr_kconfig_hash`,
669    /// `with_extra_kconfig_hash`, `with_source_vmlinux_stat` covers
670    /// both `source_vmlinux_size` and `source_vmlinux_mtime_secs`),
671    /// so this test also pins that the builder API can produce a
672    /// fully-populated value without touching the struct directly.
673    #[test]
674    fn kernel_metadata_all_option_fields_populated_roundtrip() {
675        let meta = KernelMetadata::new(
676            KernelSource::Tarball,
677            "x86_64",
678            "bzImage",
679            "2026-04-12T10:00:00Z",
680        )
681        .with_version("6.14.2")
682        .with_config_hash("cfg-hash")
683        .with_ktstr_kconfig_hash("ktstr-hash")
684        .with_extra_kconfig_hash("extra-hash")
685        .with_source_vmlinux_stat(123_456_789, 1_700_000_000);
686        let json = serde_json::to_string(&meta).unwrap();
687        let parsed: KernelMetadata = serde_json::from_str(&json).unwrap();
688        assert_eq!(parsed.version.as_deref(), Some("6.14.2"));
689        assert_eq!(parsed.config_hash.as_deref(), Some("cfg-hash"));
690        assert_eq!(parsed.ktstr_kconfig_hash.as_deref(), Some("ktstr-hash"));
691        assert_eq!(parsed.extra_kconfig_hash.as_deref(), Some("extra-hash"));
692        assert_eq!(parsed.source_vmlinux_size, Some(123_456_789));
693        assert_eq!(parsed.source_vmlinux_mtime_secs, Some(1_700_000_000));
694    }
695
696    /// Pins the with_X → clear_X round-trip for every Option-bearing
697    /// setter group. Each pair must be symmetric: with_X populates,
698    /// clear_X resets to None. The pair-stat method
699    /// (`with_source_vmlinux_stat` / `clear_source_vmlinux_stat`)
700    /// covers two fields together — both must be Some after with_X
701    /// and both must be None after clear_X.
702    #[test]
703    fn kernel_metadata_clear_setters_round_trip() {
704        let populated = KernelMetadata::new(
705            KernelSource::Tarball,
706            "x86_64",
707            "bzImage",
708            "2026-04-12T10:00:00Z",
709        )
710        .with_version("6.14.2")
711        .with_config_hash("cfg-hash")
712        .with_ktstr_kconfig_hash("ktstr-hash")
713        .with_extra_kconfig_hash("extra-hash")
714        .with_source_vmlinux_stat(123_456_789, 1_700_000_000);
715        assert!(populated.version.is_some());
716        assert!(populated.config_hash.is_some());
717        assert!(populated.ktstr_kconfig_hash.is_some());
718        assert!(populated.extra_kconfig_hash.is_some());
719        assert!(populated.source_vmlinux_size.is_some());
720        assert!(populated.source_vmlinux_mtime_secs.is_some());
721
722        let cleared = populated
723            .clear_version()
724            .clear_config_hash()
725            .clear_ktstr_kconfig_hash()
726            .clear_extra_kconfig_hash()
727            .clear_source_vmlinux_stat();
728        assert!(cleared.version.is_none());
729        assert!(cleared.config_hash.is_none());
730        assert!(cleared.ktstr_kconfig_hash.is_none());
731        assert!(cleared.extra_kconfig_hash.is_none());
732        assert!(cleared.source_vmlinux_size.is_none());
733        assert!(cleared.source_vmlinux_mtime_secs.is_none());
734    }
735
736    #[test]
737    fn cache_metadata_serde_local_with_source_tree() {
738        let meta = KernelMetadata {
739            version: Some("6.14.0".to_string()),
740            source: KernelSource::Local {
741                source_tree_path: Some(PathBuf::from("/tmp/linux")),
742                git_hash: Some("deadbee".to_string()),
743            },
744            arch: "x86_64".to_string(),
745            image_name: "bzImage".to_string(),
746            config_hash: Some("fff000".to_string()),
747            built_at: "2026-04-12T14:00:00Z".to_string(),
748            ktstr_kconfig_hash: Some("aaa111".to_string()),
749            extra_kconfig_hash: None,
750            has_vmlinux: true,
751            vmlinux_stripped: true,
752            source_vmlinux_size: None,
753            source_vmlinux_mtime_secs: None,
754        };
755        let json = serde_json::to_string(&meta).unwrap();
756        let parsed: KernelMetadata = serde_json::from_str(&json).unwrap();
757        assert!(matches!(
758            parsed.source,
759            KernelSource::Local {
760                source_tree_path: Some(ref p),
761                git_hash: Some(ref h),
762            }
763            if p == &PathBuf::from("/tmp/linux") && h == "deadbee"
764        ));
765        assert!(parsed.has_vmlinux);
766        assert!(parsed.vmlinux_stripped);
767    }
768
769    /// git_hash on KernelSource::Local is a plain Option<String> with
770    /// no serde attributes — the compat shims (serde(default) +
771    /// skip_serializing_if) were removed for pre-1.0, so `None`
772    /// serializes as an explicit `null` key and deserialization
773    /// accepts `null` back as `None`. This test pins only the
774    /// None → null → None round trip; the absent-key branch is
775    /// exercised separately by
776    /// [`kernel_source_absent_option_keys_deserialize_as_none`].
777    #[test]
778    fn kernel_source_local_git_hash_serde_round_trip_none() {
779        let src = KernelSource::Local {
780            source_tree_path: Some(PathBuf::from("/tmp/linux")),
781            git_hash: None,
782        };
783        let json = serde_json::to_string(&src).unwrap();
784        assert!(
785            json.contains(r#""git_hash":null"#),
786            "git_hash=None must round-trip as explicit null, got {json}"
787        );
788        let parsed: KernelSource = serde_json::from_str(&json).unwrap();
789        assert!(matches!(parsed, KernelSource::Local { git_hash: None, .. }));
790    }
791
792    /// Pins the post-shim wire format: `Option` payload fields inside
793    /// every [`KernelSource`] variant serialize as explicit `null`
794    /// rather than being omitted.
795    #[test]
796    fn kernel_source_option_fields_serialize_as_explicit_null() {
797        let local = KernelSource::Local {
798            source_tree_path: None,
799            git_hash: None,
800        };
801        let local_json = serde_json::to_string(&local).unwrap();
802        assert!(
803            local_json.contains(r#""source_tree_path":null"#),
804            "Local.source_tree_path=None must serialize as explicit null, got {local_json}"
805        );
806        assert!(
807            local_json.contains(r#""git_hash":null"#),
808            "Local.git_hash=None must serialize as explicit null, got {local_json}"
809        );
810
811        let git = KernelSource::Git {
812            git_hash: None,
813            git_ref: None,
814        };
815        let git_json = serde_json::to_string(&git).unwrap();
816        assert!(
817            git_json.contains(r#""git_hash":null"#),
818            "Git.git_hash=None must serialize as explicit null, got {git_json}"
819        );
820        assert!(
821            git_json.contains(r#""ref":null"#),
822            "Git.git_ref=None must serialize as explicit null under the `ref` key, got {git_json}"
823        );
824    }
825
826    /// Older `metadata.json` files written before `Option` fields
827    /// were emitted as explicit `null` simply omit the keys.
828    #[test]
829    fn kernel_source_absent_option_keys_deserialize_as_none() {
830        let git_bare: KernelSource = serde_json::from_str(r#"{"type":"git"}"#)
831            .expect("Git with absent Option keys must deserialize");
832        assert!(matches!(
833            git_bare,
834            KernelSource::Git {
835                git_hash: None,
836                git_ref: None,
837            }
838        ));
839
840        let git_hash_only: KernelSource =
841            serde_json::from_str(r#"{"type":"git","git_hash":"abc"}"#)
842                .expect("Git with only git_hash must deserialize");
843        assert!(matches!(
844            git_hash_only,
845            KernelSource::Git {
846                git_hash: Some(ref h),
847                git_ref: None,
848            } if h == "abc"
849        ));
850
851        let git_ref_only: KernelSource = serde_json::from_str(r#"{"type":"git","ref":"main"}"#)
852            .expect("Git with only ref must deserialize");
853        assert!(matches!(
854            git_ref_only,
855            KernelSource::Git {
856                git_hash: None,
857                git_ref: Some(ref r),
858            } if r == "main"
859        ));
860
861        let local_bare: KernelSource = serde_json::from_str(r#"{"type":"local"}"#)
862            .expect("Local with absent Option keys must deserialize");
863        assert!(matches!(
864            local_bare,
865            KernelSource::Local {
866                source_tree_path: None,
867                git_hash: None,
868            }
869        ));
870
871        let local_path_only: KernelSource =
872            serde_json::from_str(r#"{"type":"local","source_tree_path":"/tmp/linux"}"#)
873                .expect("Local with only source_tree_path must deserialize");
874        assert!(matches!(
875            local_path_only,
876            KernelSource::Local {
877                source_tree_path: Some(ref p),
878                git_hash: None,
879            } if p.to_str() == Some("/tmp/linux")
880        ));
881
882        let local_hash_only: KernelSource =
883            serde_json::from_str(r#"{"type":"local","git_hash":"deadbeef"}"#)
884                .expect("Local with only git_hash must deserialize");
885        assert!(matches!(
886            local_hash_only,
887            KernelSource::Local {
888                source_tree_path: None,
889                git_hash: Some(ref h),
890            } if h == "deadbeef"
891        ));
892    }
893
894    #[test]
895    fn kernel_source_serde_tagged_representation() {
896        let t = serde_json::to_string(&KernelSource::Tarball).unwrap();
897        assert_eq!(t, r#"{"type":"tarball"}"#);
898        let g = serde_json::to_string(&KernelSource::git("abc", "main")).unwrap();
899        assert!(g.contains(r#""type":"git""#));
900        assert!(g.contains(r#""git_hash":"abc""#));
901        assert!(g.contains(r#""ref":"main""#));
902        let l = serde_json::to_string(&KernelSource::Local {
903            source_tree_path: Some(PathBuf::from("/tmp/linux")),
904            git_hash: Some("a1b2c3d".to_string()),
905        })
906        .unwrap();
907        assert!(l.contains(r#""type":"local""#));
908        assert!(l.contains(r#""source_tree_path":"/tmp/linux""#));
909        assert!(l.contains(r#""git_hash":"a1b2c3d""#));
910    }
911
912    /// Table-drive every prefix → `error_kind` classifier mapping.
913    #[test]
914    fn classify_corrupt_reason_covers_every_documented_prefix() {
915        let cases: &[(&str, &str)] = &[
916            ("metadata.json missing", "missing"),
917            (
918                "metadata.json unreadable: Is a directory (os error 21)",
919                "unreadable",
920            ),
921            (
922                "metadata.json schema drift: missing field `source` at line 1 column 21",
923                "schema_drift",
924            ),
925            (
926                "metadata.json malformed: expected value at line 1 column 1",
927                "malformed",
928            ),
929            (
930                "metadata.json truncated: EOF while parsing a value at line 1 column 10",
931                "truncated",
932            ),
933            (
934                "metadata.json parse error: something unexpected",
935                "parse_error",
936            ),
937            (
938                "image file bzImage missing from entry directory",
939                "image_missing",
940            ),
941            ("some future prefix nobody wrote yet", "unknown"),
942        ];
943        for (reason, expected) in cases {
944            assert_eq!(
945                classify_corrupt_reason(reason),
946                *expected,
947                "reason `{reason}` should classify as `{expected}`",
948            );
949        }
950    }
951
952    /// The `image_missing` arm requires BOTH the canonical prefix
953    /// AND the canonical trailing literal — strings that share only
954    /// one half (or that would have matched the legacy loose
955    /// `starts_with("image file ") && contains("missing")` predicate)
956    /// must drop into `unknown`. Locks the tightening described on
957    /// [`classify_corrupt_reason`].
958    #[test]
959    fn classify_corrupt_reason_image_missing_requires_exact_suffix() {
960        // Loose predicate would match (prefix + the substring
961        // "missing" in the wrong position). The tightened predicate
962        // rejects it because the suffix isn't the canonical
963        // ` missing from entry directory`.
964        assert_eq!(
965            classify_corrupt_reason("image file bzImage missing checksum field"),
966            "unknown",
967            "non-canonical 'image file … missing X' reason must NOT \
968             classify as `image_missing` — only the exact \
969             format_image_missing_reason() form is accepted",
970        );
971        // Suffix matches but prefix doesn't — must not classify.
972        assert_eq!(
973            classify_corrupt_reason("foo bzImage missing from entry directory"),
974            "unknown",
975            "suffix-only match without the canonical prefix must classify as unknown",
976        );
977        // Empty image name produces a degenerate prefix+suffix abut —
978        // the length guard rejects it so a future bug that emits
979        // `format_image_missing_reason("")` cannot silently classify.
980        assert_eq!(
981            classify_corrupt_reason("image file  missing from entry directory"),
982            "unknown",
983            "prefix+suffix with no image_name in between must classify as unknown",
984        );
985    }
986
987    /// Producer→consumer round trip: every reason produced by
988    /// [`format_image_missing_reason`] must classify as
989    /// `image_missing`, regardless of the image_name value
990    /// (alphanumerics, dots, dashes, multi-word names).
991    #[test]
992    fn classify_corrupt_reason_accepts_every_format_image_missing_output() {
993        for image_name in [
994            "bzImage",
995            "Image",
996            "vmlinuz-6.14.2",
997            "kernel.bin",
998            "image_with_underscores",
999            "name-with-many-dashes",
1000        ] {
1001            let reason = format_image_missing_reason(image_name);
1002            assert_eq!(
1003                classify_corrupt_reason(&reason),
1004                "image_missing",
1005                "every produced reason (image_name={image_name:?}) must \
1006                 classify as image_missing — got reason {reason:?}",
1007            );
1008        }
1009    }
1010
1011    /// `ListedEntry::error_kind()` returns `None` on a Valid entry
1012    /// and the classifier result on a Corrupt entry.
1013    #[test]
1014    fn listed_entry_error_kind_dispatches_on_variant() {
1015        let tmp = TempDir::new().unwrap();
1016        let cache = CacheDir::with_root(tmp.path().join("cache"));
1017        let src_dir = TempDir::new().unwrap();
1018        let image = create_fake_image(src_dir.path());
1019        let meta = test_metadata("6.14.2");
1020        cache
1021            .store("valid-ek", &CacheArtifacts::new(&image), &meta)
1022            .unwrap();
1023
1024        let bad_dir = tmp.path().join("cache").join("corrupt-ek");
1025        fs::create_dir_all(&bad_dir).unwrap();
1026
1027        let entries = cache.list().unwrap();
1028        assert_eq!(entries.len(), 2);
1029        let valid = entries
1030            .iter()
1031            .find(|e| e.key() == "valid-ek")
1032            .expect("valid entry must be listed");
1033        let corrupt = entries
1034            .iter()
1035            .find(|e| e.key() == "corrupt-ek")
1036            .expect("corrupt entry must be listed");
1037        assert_eq!(
1038            valid.error_kind(),
1039            None,
1040            "Valid entries must report no error_kind",
1041        );
1042        assert_eq!(
1043            corrupt.error_kind(),
1044            Some("missing"),
1045            "missing-metadata Corrupt entry must classify as `missing`",
1046        );
1047    }
1048
1049    // -- KconfigStatus Display impl --
1050    //
1051    // Pins the three Display strings that flow through `kernel list
1052    // --json` as the `kconfig_status` field. CI scripts consume these
1053    // exact strings, so any rewording is a downstream-visible
1054    // contract change.
1055
1056    #[test]
1057    fn kconfig_status_display_matches_renders_lowercase_word() {
1058        assert_eq!(KconfigStatus::Matches.to_string(), "matches");
1059    }
1060
1061    #[test]
1062    fn kconfig_status_display_stale_renders_lowercase_word_without_hashes() {
1063        let s = KconfigStatus::Stale {
1064            cached: "deadbeef".to_string(),
1065            current: "cafebabe".to_string(),
1066        }
1067        .to_string();
1068        assert_eq!(
1069            s, "stale",
1070            "Display elides the cached/current hashes; callers that need them must match on the variant directly"
1071        );
1072    }
1073
1074    #[test]
1075    fn kconfig_status_display_untracked_renders_lowercase_word() {
1076        assert_eq!(KconfigStatus::Untracked.to_string(), "untracked");
1077    }
1078
1079    // -- KconfigStatus predicates --
1080    //
1081    // `is_stale()` and `is_untracked()` collapse the variant pattern
1082    // match into a bool, which the kernel-build pipeline keys on for
1083    // its rebuild decision. The predicates are independent of the
1084    // Display impl tested above — the pipeline never round-trips
1085    // through Display, it dispatches on the bool. A regression that
1086    // flipped either predicate's polarity (e.g. `Stale` returning
1087    // false from `is_stale`) would invisibly change the rebuild
1088    // policy.
1089
1090    /// `Matches` is neither stale nor untracked.
1091    #[test]
1092    fn kconfig_status_matches_is_neither_stale_nor_untracked() {
1093        let s = KconfigStatus::Matches;
1094        assert!(!s.is_stale(), "Matches must not be stale");
1095        assert!(!s.is_untracked(), "Matches must not be untracked");
1096    }
1097
1098    /// `Stale` is stale and not untracked — the two predicates are
1099    /// mutually exclusive on this variant.
1100    #[test]
1101    fn kconfig_status_stale_is_stale_only() {
1102        let s = KconfigStatus::Stale {
1103            cached: "old".to_string(),
1104            current: "new".to_string(),
1105        };
1106        assert!(s.is_stale(), "Stale variant must report is_stale=true");
1107        assert!(
1108            !s.is_untracked(),
1109            "Stale must NOT report is_untracked — the two predicates \
1110             discriminate distinct variants",
1111        );
1112    }
1113
1114    /// `Untracked` is untracked and not stale — pre-tracking-format
1115    /// entries must NOT trigger a rebuild via the stale-check.
1116    #[test]
1117    fn kconfig_status_untracked_is_untracked_only() {
1118        let s = KconfigStatus::Untracked;
1119        assert!(
1120            s.is_untracked(),
1121            "Untracked variant must report is_untracked=true"
1122        );
1123        assert!(
1124            !s.is_stale(),
1125            "Untracked must NOT report is_stale — pre-tracking-format \
1126             entries are unknown, not stale; treating them as stale \
1127             would force a rebuild on every old cache hit",
1128        );
1129    }
1130
1131    // -- KernelSource::as_local_git_hash --
1132    //
1133    // The accessor exists for kernel_build_pipeline's post-build
1134    // dirty re-check (see cli/kernel_build/build.rs
1135    // compute_mid_wait_state / post_build_dirty_skip, where
1136    // as_local_git_hash is called): it
1137    // compares the post-build HEAD hash against the acquire-time
1138    // hash to detect mid-build commits. The accessor MUST return
1139    // the inner `git_hash` for `Local` variant only and `None` for
1140    // every other variant — a regression that returned `git_hash`
1141    // for `Git` (which has its own `git_hash` field at a different
1142    // role) would silently corrupt the dirty-check.
1143
1144    /// `Local` with `git_hash: Some(...)` returns the borrowed hash.
1145    /// Borrows rather than clones — a regression that owned the
1146    /// returned string would force the caller to allocate.
1147    #[test]
1148    fn as_local_git_hash_returns_local_hash() {
1149        let src = KernelSource::Local {
1150            source_tree_path: Some(PathBuf::from("/tmp/linux")),
1151            git_hash: Some("deadbee".to_string()),
1152        };
1153        assert_eq!(
1154            src.as_local_git_hash(),
1155            Some("deadbee"),
1156            "Local with git_hash=Some must surface the inner str",
1157        );
1158    }
1159
1160    /// `Local` with `git_hash: None` (dirty / non-git tree at acquire
1161    /// time) returns `None`. The caller distinguishes "we have a
1162    /// hash to compare" from "we have no anchor" — collapsing both
1163    /// into `Some("")` would mislead the dirty-check.
1164    #[test]
1165    fn as_local_git_hash_returns_none_when_local_has_no_hash() {
1166        let src = KernelSource::Local {
1167            source_tree_path: Some(PathBuf::from("/tmp/linux")),
1168            git_hash: None,
1169        };
1170        assert_eq!(
1171            src.as_local_git_hash(),
1172            None,
1173            "Local with git_hash=None must surface None — the dirty \
1174             check has no anchor on a non-git or dirty tree",
1175        );
1176    }
1177
1178    /// `Tarball` returns `None` — tarball builds have no git anchor
1179    /// and the accessor must NOT borrow from the wrong variant's
1180    /// payload (Tarball has no payload at all, so a regression
1181    /// reaching for one would produce a compile error rather than a
1182    /// silent bug — but pin the contract anyway).
1183    #[test]
1184    fn as_local_git_hash_returns_none_for_tarball() {
1185        let src = KernelSource::Tarball;
1186        assert_eq!(
1187            src.as_local_git_hash(),
1188            None,
1189            "Tarball variant has no git_hash and must surface None",
1190        );
1191    }
1192
1193    /// `Git` variant returns `None` even though it has its own
1194    /// `git_hash` field — the accessor is `as_local_git_hash`, not
1195    /// `as_git_hash`. Pins that the accessor does NOT cross-wire
1196    /// the Git variant's `git_hash` (which describes the cloned
1197    /// commit, a different role from the Local variant's
1198    /// `git_hash` which describes the acquire-time HEAD).
1199    #[test]
1200    fn as_local_git_hash_returns_none_for_git_even_with_hash_field() {
1201        let src = KernelSource::git("a1b2c3d", "main");
1202        assert_eq!(
1203            src.as_local_git_hash(),
1204            None,
1205            "Git variant has its own git_hash field but the \
1206             accessor is named as_LOCAL_git_hash — it MUST NOT \
1207             surface the Git variant's hash, since the Git hash \
1208             describes the cloned commit (acquire-time) and the \
1209             Local hash describes the operator's working tree HEAD \
1210             (a different role with different semantics)",
1211        );
1212    }
1213
1214    // -- format_image_missing_reason --
1215    //
1216    // The producer pairs with `classify_corrupt_reason`'s
1217    // `image_missing` arm via the [`IMAGE_MISSING_PREFIX`] /
1218    // [`IMAGE_MISSING_SUFFIX`] constants. Direct producer coverage
1219    // pins the canonical literal so a future edit to the format!
1220    // string can't drift the producer side without breaking the
1221    // round-trip test in `classify_corrupt_reason_accepts_every_format_image_missing_output`.
1222
1223    /// Producer composes prefix + image_name + suffix verbatim.
1224    #[test]
1225    fn format_image_missing_reason_uses_canonical_prefix_and_suffix() {
1226        let reason = format_image_missing_reason("bzImage");
1227        assert_eq!(
1228            reason, "image file bzImage missing from entry directory",
1229            "the produced reason MUST be the exact prefix + image_name + \
1230             suffix concatenation — any drift breaks the classifier's \
1231             exact-match predicate",
1232        );
1233        assert!(
1234            reason.starts_with(IMAGE_MISSING_PREFIX),
1235            "produced reason must start with IMAGE_MISSING_PREFIX",
1236        );
1237        assert!(
1238            reason.ends_with(IMAGE_MISSING_SUFFIX),
1239            "produced reason must end with IMAGE_MISSING_SUFFIX",
1240        );
1241    }
1242
1243    /// Empty image_name produces a degenerate prefix+suffix abut
1244    /// (the prefix's trailing space and the suffix's leading space
1245    /// become adjacent, giving `"file  missing"` with two consecutive
1246    /// spaces). The classifier's length-guard rejects the result.
1247    /// The producer itself does NOT validate (image_name is supposed
1248    /// to come from already-validated metadata.image_name), so the
1249    /// empty case still produces the verbatim concatenation — but the
1250    /// classifier MUST reject the result. Pins the contract that
1251    /// degenerate producer output is consumer-rejected, not silently
1252    /// classified as image_missing.
1253    #[test]
1254    fn format_image_missing_reason_with_empty_image_name() {
1255        let reason = format_image_missing_reason("");
1256        assert_eq!(
1257            reason, "image file  missing from entry directory",
1258            "empty image_name must produce a verbatim concatenation \
1259             (prefix trailing-space + suffix leading-space → two \
1260             consecutive spaces between `file` and `missing`); the \
1261             producer does not validate (validation is at \
1262             validate_filename time), so the degenerate concatenation \
1263             is the documented behaviour",
1264        );
1265        // The classifier MUST reject this degenerate form via its
1266        // length-guard arm so the empty case classifies as `unknown`
1267        // rather than `image_missing`.
1268        assert_eq!(
1269            classify_corrupt_reason(&reason),
1270            "unknown",
1271            "the classifier's length-guard MUST reject the \
1272             prefix+suffix-with-empty-image-name form — a regression \
1273             that loosened the guard would let a future bug emit \
1274             format_image_missing_reason(\"\") and silently classify \
1275             it as image_missing, hiding the real defect (empty \
1276             image_name in metadata)",
1277        );
1278    }
1279
1280    // -- CacheEntry::disk_template_path --
1281    //
1282    // The accessor names the canonical filename for the per-entry
1283    // btrfs disk template (`<entry>/disk-template.img`) that
1284    // `vmm/disk_template.rs` writes and reads. The contract is
1285    // path-only: no I/O, no validation. Pin the literal so a
1286    // future rename (`disk-template.img` → `disk.img`) is caught
1287    // by the test rather than only at runtime when the VMM tries
1288    // to read a path that no longer exists.
1289    #[test]
1290    fn cache_entry_disk_template_path_joins_canonical_filename() {
1291        let tmp = TempDir::new().unwrap();
1292        let cache = CacheDir::with_root(tmp.path().join("cache"));
1293        let src_dir = TempDir::new().unwrap();
1294        let image = create_fake_image(src_dir.path());
1295        let entry = cache
1296            .store(
1297                "disk-tmpl-key",
1298                &CacheArtifacts::new(&image),
1299                &test_metadata("6.14.2"),
1300            )
1301            .unwrap();
1302        assert_eq!(
1303            entry.disk_template_path(),
1304            entry.path.join("disk-template.img"),
1305            "disk_template_path() MUST resolve to <entry>/disk-template.img — \
1306             the literal `disk-template.img` is the canonical filename the \
1307             VMM disk_template module writes/reads",
1308        );
1309        // The accessor is path-only; the file does NOT have to
1310        // exist for the path to be returned. Pin that the file
1311        // is absent immediately after store() (since store() does
1312        // not create the disk template — that's a separate
1313        // pipeline step in vmm/disk_template.rs).
1314        assert!(
1315            !entry.disk_template_path().exists(),
1316            "disk_template_path() must be path-only — store() does not \
1317             create the file; absence is the expected post-store state",
1318        );
1319    }
1320
1321    // -- CacheEntry::has_extra_kconfig --
1322    //
1323    // Predicate that wraps `metadata.extra_kconfig_hash.is_some()`.
1324    // Pin both branches so a regression that flipped the polarity
1325    // (returning `is_none()`) would surface here rather than only
1326    // in downstream consumers that select rebuild policies on the
1327    // bool.
1328
1329    /// `extra_kconfig_hash: Some(...)` → predicate returns true.
1330    #[test]
1331    fn cache_entry_has_extra_kconfig_true_when_hash_present() {
1332        let tmp = TempDir::new().unwrap();
1333        let cache = CacheDir::with_root(tmp.path().join("cache"));
1334        let src_dir = TempDir::new().unwrap();
1335        let image = create_fake_image(src_dir.path());
1336        let meta = test_metadata("6.14.2").with_extra_kconfig_hash("user-fragment-hash");
1337        let entry = cache
1338            .store("with-extra", &CacheArtifacts::new(&image), &meta)
1339            .unwrap();
1340        assert!(
1341            entry.has_extra_kconfig(),
1342            "extra_kconfig_hash=Some MUST report has_extra_kconfig()=true — \
1343             a regression that flipped polarity would invert the \
1344             rebuild-on-fragment-change policy",
1345        );
1346    }
1347
1348    /// `extra_kconfig_hash: None` → predicate returns false.
1349    #[test]
1350    fn cache_entry_has_extra_kconfig_false_when_hash_absent() {
1351        let tmp = TempDir::new().unwrap();
1352        let cache = CacheDir::with_root(tmp.path().join("cache"));
1353        let src_dir = TempDir::new().unwrap();
1354        let image = create_fake_image(src_dir.path());
1355        // test_metadata sets extra_kconfig_hash=None by default.
1356        let meta = test_metadata("6.14.2");
1357        assert!(
1358            meta.extra_kconfig_hash.is_none(),
1359            "test_metadata default must keep extra_kconfig_hash=None \
1360             so this test exercises the false branch",
1361        );
1362        let entry = cache
1363            .store("no-extra", &CacheArtifacts::new(&image), &meta)
1364            .unwrap();
1365        assert!(
1366            !entry.has_extra_kconfig(),
1367            "extra_kconfig_hash=None MUST report has_extra_kconfig()=false",
1368        );
1369    }
1370
1371    // -- ListedEntry::path accessor --
1372    //
1373    // The accessor abstracts over the variant — Valid carries the
1374    // path inside its boxed CacheEntry, Corrupt carries it as a
1375    // direct field. Existing list() tests reach into the variant
1376    // and read the field, so the accessor itself is uncovered.
1377    // Pin both branches.
1378
1379    /// `Valid` variant: path() returns the inner CacheEntry's path.
1380    #[test]
1381    fn listed_entry_path_accessor_returns_valid_entry_path() {
1382        let tmp = TempDir::new().unwrap();
1383        let cache = CacheDir::with_root(tmp.path().join("cache"));
1384        let src_dir = TempDir::new().unwrap();
1385        let image = create_fake_image(src_dir.path());
1386        let entry = cache
1387            .store(
1388                "valid-path-test",
1389                &CacheArtifacts::new(&image),
1390                &test_metadata("6.14.2"),
1391            )
1392            .unwrap();
1393        let expected_path = entry.path.clone();
1394        let entries = cache.list().unwrap();
1395        let listed = entries
1396            .iter()
1397            .find(|e| e.key() == "valid-path-test")
1398            .expect("the just-stored entry must be in the list");
1399        assert!(
1400            matches!(listed, ListedEntry::Valid(_)),
1401            "precondition: stored entry must surface as Valid",
1402        );
1403        assert_eq!(
1404            listed.path(),
1405            expected_path,
1406            "ListedEntry::path() on Valid must return the inner \
1407             CacheEntry's path — accessor MUST forward, not synthesize",
1408        );
1409    }
1410
1411    /// `Corrupt` variant: path() returns the per-variant path field.
1412    #[test]
1413    fn listed_entry_path_accessor_returns_corrupt_entry_path() {
1414        let tmp = TempDir::new().unwrap();
1415        let cache = CacheDir::with_root(tmp.path().join("cache"));
1416        // Create a corrupt-shaped entry (directory exists but no
1417        // metadata.json), so list() classifies it as Corrupt.
1418        let entry_dir = tmp.path().join("cache").join("corrupt-path-test");
1419        std::fs::create_dir_all(&entry_dir).unwrap();
1420        let entries = cache.list().unwrap();
1421        let listed = entries
1422            .iter()
1423            .find(|e| e.key() == "corrupt-path-test")
1424            .expect("corrupt-shaped entry must be in the list");
1425        assert!(
1426            matches!(listed, ListedEntry::Corrupt { .. }),
1427            "precondition: missing-metadata entry must surface as Corrupt",
1428        );
1429        assert_eq!(
1430            listed.path(),
1431            entry_dir,
1432            "ListedEntry::path() on Corrupt must return the variant's \
1433             `path` field — accessor MUST forward, not synthesize",
1434        );
1435    }
1436}