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}