ktstr/cli/kernel_cmd.rs
1//! `kernel` subcommand definition and help-text constants.
2//!
3//! Holds the `KernelCommand` enum (shared by `ktstr` and
4//! `cargo-ktstr`), the `--help` text constants for kernel-related
5//! flags (`--kernel`, `--cpu-cap`, `--extra-kconfig`, `--disk`),
6//! and the legend / footer helpers that flow through `kernel list`'s
7//! tag-emission gates.
8
9use std::path::{Path, PathBuf};
10
11use clap::Subcommand;
12
13/// Shared `kernel` subcommand tree used by both `ktstr` and
14/// `cargo ktstr`. The two binaries embed this as
15/// `ktstr kernel <subcmd>` / `cargo ktstr kernel <subcmd>` and
16/// dispatch identically; defining the variants once means a new
17/// `kernel` subcommand (or a flag change) lands in both surfaces by
18/// construction.
19#[derive(Subcommand, Debug)]
20pub enum KernelCommand {
21 /// List cached kernel images, or preview a range expansion
22 /// without downloading or building.
23 ///
24 /// Default mode (no `--kernel`): walks the local cache and
25 /// reports every cached kernel image. `--kernel START..END`
26 /// switches to PREVIEW mode: fetches kernel.org's
27 /// `releases.json`, expands the inclusive range against the
28 /// `stable` / `longterm` releases, and prints the resulting
29 /// version list. Preview mode performs no downloads or builds
30 /// and ignores the local cache — operators can use it to
31 /// answer "what does `--kernel 6.12..6.16` actually cover?"
32 /// before paying the network or cache-store cost of a real
33 /// resolve.
34 #[command(long_about = KERNEL_LIST_LONG_ABOUT)]
35 List {
36 /// Output in JSON format for CI scripting.
37 #[arg(long)]
38 json: bool,
39 /// Range to PREVIEW. When supplied, switches the subcommand
40 /// from "list cached kernels" to "fetch releases.json and
41 /// print the versions a `START..END` range expands to."
42 /// Format: `MAJOR.MINOR[.PATCH][-rcN]..MAJOR.MINOR[.PATCH][-rcN]`,
43 /// matching [`crate::kernel_path::KernelId::Range`].
44 /// Example: `--kernel 6.12..6.14` → every stable/longterm
45 /// release in `[6.12, 6.14]` inclusive. A non-range
46 /// `--kernel` (a single version, path, or git source) is
47 /// rejected — preview mode expands ranges only; to inspect a
48 /// cached single kernel run `kernel list` without `--kernel`.
49 ///
50 /// In preview mode the subcommand performs no cache
51 /// reads or kernel.org tarball downloads — only the
52 /// single `releases.json` fetch that
53 /// [`crate::cli::expand_kernel_range`] already runs for
54 /// real range resolves. `--json` (when also supplied)
55 /// emits a JSON object with the literal range string and
56 /// the expanded version array; without `--json` the
57 /// versions are written one per line to stdout for shell
58 /// pipelines.
59 #[arg(long)]
60 kernel: Option<String>,
61 /// See [`INCLUDE_EOL_HELP`]. Only affects a `--kernel`
62 /// range preview; ignored in the default cache-listing mode
63 /// (which reads no releases.json and enumerates no series).
64 #[arg(long, help = INCLUDE_EOL_HELP)]
65 include_eol: bool,
66 },
67 /// Download, build, and cache a kernel image.
68 Build {
69 /// Kernel to build (the unified `--kernel` grammar). Omitted,
70 /// builds the latest stable release. See [`KERNEL_HELP_BUILD`]
71 /// for the accepted shapes.
72 #[arg(long, help = KERNEL_HELP_BUILD)]
73 kernel: Option<String>,
74 /// Rebuild even if a cached image exists.
75 #[arg(long)]
76 force: bool,
77 /// Run `make mrproper` before configuring. Only meaningful for
78 /// a `--kernel <path>` source tree: version / range / git
79 /// sources build from a fresh tarball or clone, so this flag
80 /// prints a notice and is ignored for them.
81 #[arg(long)]
82 clean: bool,
83 #[arg(long, help = CPU_CAP_HELP)]
84 cpu_cap: Option<usize>,
85 /// Path to an additional kconfig fragment merged on top of
86 /// the baked-in `ktstr.kconfig`.
87 ///
88 /// # Format
89 ///
90 /// One declaration per line, same shapes the kernel itself
91 /// uses:
92 ///
93 /// ```text
94 /// # comment lines start with `#` and a space
95 /// CONFIG_FOO=y # boolean enable
96 /// CONFIG_FOO=m # build as module
97 /// CONFIG_FOO=n # disable (equivalent to is-not-set)
98 /// CONFIG_BAR="some value" # string
99 /// CONFIG_BAR=42 # integer / hex
100 /// # CONFIG_FOO is not set # explicit disable directive
101 /// ```
102 ///
103 /// The baked-in fragment lives at `ktstr.kconfig` in the
104 /// ktstr repository root. See [`EMBEDDED_KCONFIG`] for the
105 /// const that loads it at compile time.
106 ///
107 /// # Conflict resolution
108 ///
109 /// User values win on conflict — kbuild's `.config` parser
110 /// (`scripts/kconfig/confdata.c::conf_read_simple`) emits
111 /// "override: reassigning to symbol X" and keeps the
112 /// last-occurring assignment, so appending the user fragment
113 /// AFTER the baked-in fragment makes user values take
114 /// precedence. Non-conflicting user lines combine with the
115 /// baked-in set verbatim.
116 ///
117 /// Override warnings: `kernel build` emits one
118 /// `tracing::warn!` per user line that overrides a baked-in
119 /// symbol (format: "--extra-kconfig overrides baked-in
120 /// CONFIG_FOO (was =y, now =n)"). The build proceeds; the
121 /// warning lets the operator see they are shadowing a
122 /// baked-in setting before make olddefconfig runs.
123 ///
124 /// # Dependency resolution
125 ///
126 /// `make olddefconfig` runs after the merge to resolve any
127 /// added symbols' dependencies. Options whose deps are not
128 /// met land as `# CONFIG_X is not set` in the final
129 /// `.config`; those silent drops surface as `tracing::warn!`
130 /// lines (not errors) so the operator sees the diagnostic
131 /// without the build failing.
132 ///
133 /// # Critical-symbol protection
134 ///
135 /// After build, [`super::validate_kernel_config`] rejects entries
136 /// that disabled symbols required by ktstr (CONFIG_BPF_SYSCALL,
137 /// CONFIG_DEBUG_INFO_BTF, CONFIG_FTRACE,
138 /// CONFIG_SCHED_CLASS_EXT, etc.). The error names
139 /// `--extra-kconfig` as the likely cause when extras were
140 /// supplied. So a fragment with
141 /// `# CONFIG_BPF is not set` will fail
142 /// `validate_kernel_config` post-build with an actionable
143 /// message — the override warning fires pre-build and the
144 /// validation error fires post-build, giving the operator
145 /// two chances to catch a fatal override.
146 ///
147 /// # Caching
148 ///
149 /// The cache key suffix grows from `kc{baked}` to
150 /// `kc{baked}-xkc{extra}` when extras are present (see
151 /// [`crate::cache_key_suffix_with_extra`]). Two builds
152 /// with distinct extra-kconfig content land at distinct
153 /// cache entries (different content = cache miss; same
154 /// content = cache hit on re-run). Builds with NO
155 /// `--extra-kconfig` keep using the bare `kc{baked}` suffix,
156 /// so existing cached kernels are not orphaned. An
157 /// `--extra-kconfig`-built kernel is only addressable by a
158 /// matching `--extra-kconfig` invocation or by an explicit
159 /// `--kernel <path>` / `KTSTR_KERNEL` path — `cargo ktstr test
160 /// --kernel 6.14.2` (which doesn't take `--extra-kconfig`)
161 /// will not surface the extra-built artifact.
162 ///
163 /// `kernel list` tags entries built with extras as
164 /// `(extra kconfig)` so an operator can spot which cached
165 /// kernels carry user modifications.
166 #[arg(long = "extra-kconfig", value_name = "PATH", help = EXTRA_KCONFIG_HELP)]
167 extra_kconfig: Option<PathBuf>,
168 /// Skip SHA-256 verification of downloaded stable tarballs.
169 /// Useful when cdn.kernel.org updates a tarball in-place (new
170 /// point release reusing the same URL) and the
171 /// sha256sums.asc manifest is stale or mismatched. Only affects
172 /// a version / range `--kernel` (the tarball download): no
173 /// effect on a `--kernel <path>` source tree (no download), a
174 /// `--kernel git+…` source (no manifest), or RC tarballs
175 /// (git.kernel.org dynamically generates RC archives and
176 /// publishes no upstream manifest, so RC downloads always run
177 /// unverified regardless of this flag). Bypassing verification
178 /// is security-sensitive: a single `--skip-sha256: bypassing
179 /// checksum verification` warning fires per affected download
180 /// so the lost guarantee is visible alongside the
181 /// verification-success line that would otherwise appear.
182 #[arg(long)]
183 skip_sha256: bool,
184 /// See [`INCLUDE_EOL_HELP`]. Only affects a `--kernel START..END`
185 /// range (e.g. `kernel build --kernel 6.11..6.14`); ignored for
186 /// a single version, path, or git source, none of which expand
187 /// a range.
188 #[arg(long, help = INCLUDE_EOL_HELP)]
189 include_eol: bool,
190 },
191 /// Remove cached kernel images.
192 Clean {
193 /// Keep the N most recent VALID cached kernels. When absent,
194 /// removes every valid entry. Corrupt entries are always
195 /// candidates for removal regardless of this value — they
196 /// waste disk space and serve no build — so a corrupt entry
197 /// never consumes a keep slot.
198 #[arg(long)]
199 keep: Option<usize>,
200 /// Skip the y/N confirmation prompt before deleting. Always
201 /// required in non-interactive contexts: without `--force`
202 /// the command bails on a non-tty stdin rather than hang
203 /// waiting for input. In an interactive shell, omit
204 /// `--force` to be prompted.
205 #[arg(long)]
206 force: bool,
207 /// Remove only corrupt cache entries (metadata missing or
208 /// unparseable, image file absent). Valid entries are left
209 /// untouched regardless of `--force`. Useful for clearing
210 /// broken entries after an interrupted build without
211 /// risking the curated set of good kernels. Mutually
212 /// exclusive with `--keep`: `--corrupt-only` never touches
213 /// valid entries, so a keep budget would silently be
214 /// ignored; rejecting at parse time surfaces the
215 /// misunderstanding instead.
216 #[arg(long, conflicts_with = "keep")]
217 corrupt_only: bool,
218 },
219}
220
221/// Help text for `--kernel` in contexts that reject raw image files:
222/// `cargo ktstr test`, `cargo ktstr coverage`, `cargo ktstr llvm-cov`,
223/// and `ktstr shell`. Matches
224/// `KernelResolvePolicy { accept_raw_image: false, .. }`.
225///
226/// Raw images are rejected here because these commands depend on a
227/// matching `vmlinux` and the cached kconfig fragment alongside the
228/// image (test/coverage need BTF, `ktstr shell` reuses the cache
229/// entry for kconfig discovery). A bare `bzImage`/`Image` passed
230/// directly carries neither, so silently accepting it would produce
231/// hard-to-diagnose mid-run failures.
232/// `cargo ktstr shell` accepts raw images because its flow does not
233/// need that companion metadata; see [`KERNEL_HELP_RAW_OK`].
234pub const KERNEL_HELP_NO_RAW: &str = "Kernel identifier: a source directory \
235 path (e.g. `../linux`), a version (`6.14.2`, or major.minor prefix \
236 `6.14` for latest patch), a cache key (see `kernel list`), a \
237 version range (`6.12..6.14`), or a git source \
238 (`git+URL#tag=NAME`, `git+URL#branch=NAME`, or `git+URL#sha=<40-hex>`). Raw \
239 image files are rejected. Source directories auto-build (can be slow \
240 on a fresh tree); versions auto-download from kernel.org on cache \
241 miss. The flag is REPEATABLE on `test`, `coverage`, and `llvm-cov` \
242 — passing multiple `--kernel` flags fans the gauntlet across every \
243 resolved kernel; each (test × scenario × topology × kernel) \
244 tuple becomes a distinct nextest test case so nextest's parallelism, \
245 retries, and `-E` filtering work natively. Ranges expand to every \
246 `stable` and `longterm` release inside `[START, END]` inclusive \
247 (mainline / linux-next dropped). Git sources are fetched at the \
248 given ref (GitHub via a codeload snapshot, other hosts via a \
249 shallow clone) and built once. In contrast, `ktstr shell` accepts a single \
250 kernel only — pass exactly one `--kernel`.";
251
252/// Help text for `--kernel` in contexts that accept raw image files:
253/// `cargo ktstr shell`. Matches
254/// `KernelResolvePolicy { accept_raw_image: true, .. }`. See
255/// [`KERNEL_HELP_NO_RAW`] for the converse and the rationale for
256/// the asymmetry.
257pub const KERNEL_HELP_RAW_OK: &str = "Kernel identifier: a source directory \
258 path (e.g. `../linux`), a raw image file (`bzImage` / `Image`), a \
259 version (`6.14.2`, or major.minor prefix `6.14` for latest patch), \
260 or a cache key (see `kernel list`). Source directories auto-build \
261 (can be slow on a fresh tree); versions auto-download from kernel.org \
262 on cache miss. When absent, resolves via cache then filesystem, \
263 falling back to downloading the latest stable kernel. Ranges \
264 (`START..END`) and git sources (`git+URL#tag=NAME`) are not supported \
265 in this context; pass a single kernel.";
266
267/// Help text for `--kernel` on `kernel build` (`ktstr` and
268/// `cargo ktstr`). Unlike [`KERNEL_HELP_NO_RAW`], this surface builds a
269/// kernel rather than resolving one for a test run: it is NOT repeatable
270/// (one kernel or one range per invocation), and a cache key is rejected
271/// (a cache key names an already-built entry, so there is nothing to
272/// build). A source-directory path auto-builds; a version / range
273/// auto-downloads; a git source is fetched and built. Omitting `--kernel`
274/// builds the latest stable release.
275pub const KERNEL_HELP_BUILD: &str = "Kernel to build: a version (`6.14.2`, \
276 a `MAJOR.MINOR` prefix `6.14` for the latest patch in that series, or a \
277 bare `MAJOR` prefix `6` for the latest patch across all `6.x`), a version range \
278 (`6.11..6.14` — builds every stable/longterm release in the range), a \
279 source directory path (`./linux`, `~/linux`, or an absolute path; a \
280 bare relative name like `linux` is read as a cache key, so prefix a \
281 relative source dir with `./`), or a git source (`git+URL#tag=NAME`, \
282 `git+URL#branch=NAME`, or `git+URL#sha=<40-hex>`). Omitted, the latest \
283 stable release is built. A cache key (an already-built entry from \
284 `kernel list`) is rejected — it names a built kernel, so there is \
285 nothing to build. Build one kernel (or one range) per invocation.";
286
287/// Help text for the `--include-eol` flag, shared by every command
288/// that expands a `START..END` kernel range (`cargo ktstr test`,
289/// `coverage`, `llvm-cov`, `verifier`, `kernel list`, and
290/// `kernel build` when the `--kernel` value is a range). One const so
291/// the wording stays identical across every surface the flag appears on.
292///
293/// Describes what the code DOES: [`crate::cli::expand_kernel_range`]
294/// draws only from kernel.org's `releases.json` by default (active
295/// `stable`/`longterm` series); under this flag it unions in the
296/// `vX.Y.Z` tags of the gregkh linux-stable mirror
297/// (`crate::fetch::cached_stable_tags`) and takes the latest patch
298/// per `(major, minor)` series across both sources, so a series that
299/// has aged out of `releases.json` still contributes its highest
300/// point release. If the mirror tag list cannot be fetched, expansion
301/// proceeds with a warning against the active-release set alone.
302pub const INCLUDE_EOL_HELP: &str = "Include end-of-life stable series when \
303 expanding a `START..END` kernel range. Default range resolution draws \
304 only from kernel.org's active releases (releases.json), which omits \
305 series no longer maintained; with this flag the range additionally \
306 covers every `vX.Y.Z` tag in the gregkh linux-stable mirror, so an EOL \
307 series inside [START, END] still contributes its highest point release. \
308 Has no effect on a single version, path, cache key, or git source — \
309 only range expansion consults the EOL set. When the mirror tag list \
310 cannot be fetched, expansion proceeds with a warning against the \
311 active-release set alone.";
312
313/// Help text for the `--cpu-cap N` flag. Shared across `ktstr kernel build`,
314/// `cargo ktstr kernel build`, and `ktstr shell` so the operator-facing
315/// wording is identical regardless of entry point.
316///
317/// This flag is the resource-budget contract: the operator promises
318/// (and the framework enforces) that the build or no-perf-mode shell
319/// VM will stay within N CPUs' worth of reservation and the NUMA
320/// nodes hosting them. Setting `--cpu-cap N` flips several internal
321/// defaults on this run: the LLC discovery walks whole LLCs in
322/// consolidation- and NUMA-aware order until the CPU budget is met;
323/// make's `-jN` parallelism matches the plan's CPU count so gcc
324/// can't fan out beyond the budget; a cgroup v2 sandbox binds make +
325/// gcc's cpuset to the plan's CPUs and `cpuset.mems` to the plan's
326/// NUMA nodes, so any degradation is fatal under the flag rather
327/// than a silent warning.
328pub const CPU_CAP_HELP: &str = "Reserve exactly N host CPUs for the build or \
329 no-perf-mode shell. Integer ≥ 1; must be ≤ the calling process's \
330 sched_getaffinity cpuset size (the allowed CPU count, NOT the \
331 host's total online CPUs — under a cgroup-restricted runner the \
332 allowed set is typically smaller). When absent, 30% of the \
333 allowed CPUs are reserved (minimum 1). The planner walks whole \
334 LLCs in consolidation- and NUMA-aware order, filtered to the \
335 allowed cpuset, partial-taking the last LLC so `plan.cpus.len() \
336 == N` exactly. The flock set may cover more LLCs than strictly \
337 required (flock coordination is per-LLC even when the last LLC \
338 is only partially used for the CPU budget). Run `ktstr locks \
339 --watch 1s` to observe NUMA placement live. Under --cpu-cap, \
340 make's `-jN` parallelism matches the reserved CPU count and the \
341 kernel build runs inside a cgroup v2 sandbox that pins gcc/ld \
342 to the reserved CPUs + NUMA nodes; if the sandbox cannot be \
343 installed (missing cgroup v2, missing cpuset controller, \
344 permission denied), the build aborts rather than running \
345 without enforcement. Mutually exclusive with \
346 KTSTR_BYPASS_LLC_LOCKS=1. On `ktstr shell`, requires \
347 --no-perf-mode (perf-mode already holds every LLC exclusively). \
348 Also settable via KTSTR_CPU_CAP env var (CLI flag wins when both \
349 are present).";
350
351/// Short clap-help for `--extra-kconfig`. Mirrors the [`CPU_CAP_HELP`]
352/// pattern: terse first sentence on the clap surface, full rustdoc
353/// on the [`KernelCommand::Build`] variant for `--help` long-about.
354///
355/// The full rustdoc covers: accepted line shapes, kbuild last-wins
356/// rule, `make olddefconfig` dependency resolution, post-build
357/// `validate_kernel_config` interaction, two-segment cache key,
358/// override warnings, and the unaddressable-from-other-flags
359/// rationale.
360pub const EXTRA_KCONFIG_HELP: &str = "Additional kconfig fragment merged on top of \
361 the baked-in `ktstr.kconfig`. Same line shapes the kernel uses: \
362 `CONFIG_FOO=y`, `CONFIG_FOO=m`, `CONFIG_FOO=\"value\"`, and \
363 `# CONFIG_FOO is not set`. User values win on conflict; \
364 `make olddefconfig` resolves dependencies. Each unique fragment \
365 produces a distinct cache slot via the `kc{baked}-xkc{extra}` \
366 key suffix. After build, `validate_kernel_config` rejects \
367 entries that disabled critical baked-in symbols \
368 (CONFIG_SCHED_CLASS_EXT, CONFIG_DEBUG_INFO_BTF, CONFIG_BPF_SYSCALL, \
369 CONFIG_FTRACE, CONFIG_KPROBE_EVENTS, CONFIG_BPF_EVENTS). \
370 The baked-in fragment lives at `ktstr.kconfig` in the ktstr \
371 repository root.";
372
373/// Literal text of the `(EOL)` tag explanation. Lives inside a macro
374/// (instead of a `pub const`) so that downstream `concat!` callers
375/// — specifically [`KERNEL_LIST_LONG_ABOUT`] — can embed the bytes at
376/// compile time without duplicating the string. `concat!` requires
377/// each argument to be a string literal at expansion, and a macro
378/// call that expands to a literal satisfies that requirement while
379/// a `&'static str` reference does not. Expansion order: the inner
380/// macro is expanded first, `concat!` then sees a literal.
381macro_rules! eol_explanation_literal {
382 () => {
383 "(EOL) marks entries whose major.minor series is absent from \
384 kernel.org's current active releases. Suppressed when the \
385 active-release list cannot be fetched."
386 };
387}
388
389/// Explanation of the `(EOL)` tag, shared between the text-output
390/// legend printed after `kernel list` and the `kernel list --help`
391/// long description (via [`KERNEL_LIST_LONG_ABOUT`], which embeds this
392/// exact byte sequence at its head through the shared
393/// `eol_explanation_literal!` macro). One literal → one source of
394/// truth, so a wording drift cannot put the two surfaces out of
395/// sync. `pub` matches the visibility of the sibling
396/// `KERNEL_HELP_*` constants so downstream consumers (e.g.
397/// documentation generators) can reference the exact text the CLI
398/// prints.
399pub const EOL_EXPLANATION: &str = eol_explanation_literal!();
400
401/// `long_about` for `kernel list --help`. Embeds [`EOL_EXPLANATION`]
402/// verbatim (via `eol_explanation_literal!`) so the tag legend
403/// cannot drift between the post-table output and the help copy,
404/// then appends a plain-text rendering of the `--json` output
405/// schema so scripted consumers can discover the contract from the
406/// terminal without running `cargo doc`. The schema wording
407/// mirrors the Rust-doc schema on [fn@super::kernel_list]; keeping both
408/// surfaces terse makes a drift obvious on review. A plain-text
409/// (not JSON/markdown) rendering is used because clap applies no
410/// JSON/markdown formatting pass, so the schema reads as plain
411/// text. Clap does apply terminal-width wrapping, so the embedded
412/// EOL sentence re-flows to the width of the host terminal; the
413/// schema block's explicit `\n` line breaks survive wrapping and
414/// preserve the column-aligned field table.
415pub const KERNEL_LIST_LONG_ABOUT: &str = concat!(
416 eol_explanation_literal!(),
417 "\n\n",
418 "--json emits one JSON object with three top-level fields:\n",
419 "\n",
420 " current_ktstr_kconfig_hash hex digest of the kconfig fragment the\n",
421 " running binary was built with, for\n",
422 " stale-entry detection.\n",
423 " active_prefixes_fetch_error null on success; error string on\n",
424 " active-series fetch failure. When\n",
425 " non-null, every entry's `eol` is false\n",
426 " regardless of actual support status —\n",
427 " check this field before trusting `eol`.\n",
428 " entries array of per-entry objects. Each\n",
429 " element is either a VALID entry (full\n",
430 " field set) or a CORRUPT entry\n",
431 " (`key`, `path`, `error`,\n",
432 " `error_kind`). `error` is the\n",
433 " human-readable reason; `error_kind`\n",
434 " the machine-readable classifier.\n",
435 " Detect\n",
436 " corruption by the presence of `error`.\n",
437 "\n",
438 "Valid entry fields: key, path, version (nullable), source, arch,\n",
439 "built_at, ktstr_kconfig_hash (nullable), extra_kconfig_hash\n",
440 "(nullable), kconfig_status, eol, config_hash (nullable),\n",
441 "image_name, image_path, has_vmlinux, vmlinux_stripped.\n",
442 "\n",
443 " path absolute path to the cache entry DIRECTORY.\n",
444 " image_path absolute path to the boot image file INSIDE\n",
445 " that directory. `path` points at the dir, not\n",
446 " the image — scripts that want the kernel\n",
447 " artifact to pass to qemu/vm-loaders should\n",
448 " read `image_path`, not join `path` with a\n",
449 " hardcoded filename.\n",
450 " kconfig_status one of \"matches\", \"stale\", \"untracked\"\n",
451 " (Display form of cache::KconfigStatus).\n",
452 " source internally-tagged on \"type\":\n",
453 " {\"type\": \"tarball\"}\n",
454 " {\"type\": \"git\", \"git_hash\": ?, \"ref\": ?}\n",
455 " {\"type\": \"local\", \"source_tree_path\": ?,\n",
456 " \"git_hash\": ?}\n",
457 " Dispatch on \"type\" before reading variant\n",
458 " fields.\n",
459 " eol true iff the entry's major.minor series is absent\n",
460 " from the active-prefix list. Meaningful only when\n",
461 " active_prefixes_fetch_error is null. Also false\n",
462 " whenever version is null (the missing-version\n",
463 " short-circuit in `entry_is_eol`).\n",
464 " has_vmlinux true iff the uncompressed vmlinux is cached\n",
465 " alongside the compressed image (required for\n",
466 " DWARF-driven probes).\n",
467 " vmlinux_stripped true iff the cached vmlinux came from a\n",
468 " successful strip pass. false marks the\n",
469 " raw-fallback path — a larger on-disk payload\n",
470 " indicating the strip pipeline errored on this\n",
471 " kernel; the entry is still usable but the\n",
472 " fallback is a signal to investigate. Meaningful\n",
473 " only when has_vmlinux is true (false otherwise).\n",
474 " config_hash CRC32 of the final merged .config; distinct\n",
475 " from ktstr_kconfig_hash which covers only the\n",
476 " ktstr fragment.\n",
477 " extra_kconfig_hash\n",
478 " CRC32 of the user `--extra-kconfig` fragment\n",
479 " (raw bytes, no canonicalization), or null when\n",
480 " the entry was built without --extra-kconfig.\n",
481 " The cache key suffix grows from `kc{baked}` to\n",
482 " `kc{baked}-xkc{extra}` when extras are present,\n",
483 " and this field stores the `xkc` segment so\n",
484 " `kernel list` is self-describing for entries\n",
485 " that carry user modifications.\n",
486 "\n",
487 "When --kernel is a range, the subcommand SWITCHES to range-preview\n",
488 "mode and emits a structurally different JSON shape — the cache\n",
489 "is not walked at all, only kernel.org's releases.json is fetched\n",
490 "to expand the inclusive range. The --json output is one object\n",
491 "with four top-level fields:\n",
492 "\n",
493 " range literal range string supplied to --kernel\n",
494 " (e.g. \"6.12..6.14\").\n",
495 " start parsed start endpoint\n",
496 " (MAJOR.MINOR[.PATCH][-rcN]).\n",
497 " end parsed end endpoint, same shape as start.\n",
498 " versions array of resolved version strings inside\n",
499 " [start, end] inclusive, ascending by\n",
500 " (major, minor, patch, rc) tuple. Stable and\n",
501 " longterm releases only — mainline / linux-next\n",
502 " are excluded by the moniker filter.\n",
503 "\n",
504 "Range-mode output never carries cache metadata\n",
505 "(no current_ktstr_kconfig_hash, no entries) — to inspect cached\n",
506 "kernels for one of the resolved versions, run `kernel list`\n",
507 "without --kernel. Consumers should dispatch on the presence of\n",
508 "the `range` key (range mode) versus `entries` key (list mode)\n",
509 "to branch the parse."
510);
511
512/// Emitted by `kernel build` when a local source tree has
513/// uncommitted index/worktree changes. Caching would key the built
514/// artifact on a git hash that does not describe the actual tree,
515/// so the build completes but the result is not archived. The
516/// hint names the two remediation paths (commit or stash) so an
517/// operator re-running the build after cleaning the tree benefits
518/// from the cache. Extracted from the call site so a wording drift
519/// between what's printed and what's documented elsewhere is
520/// impossible by construction; pinned by
521/// `dirty_tree_cache_skip_hint_shape` below.
522pub const DIRTY_TREE_CACHE_SKIP_HINT: &str = "skipping cache — working tree has uncommitted changes; \
523 commit or stash to enable caching";
524
525/// Hint shown in place of [`DIRTY_TREE_CACHE_SKIP_HINT`] when the
526/// source tree is not a git repository at all. `commit` / `stash`
527/// are not actionable remediations in that case — the operator's
528/// only path to caching is to put the source under git (or use a
529/// kernel-source fetch mode that produces a git-tracked tree).
530/// Pinned by `non_git_tree_cache_skip_hint_shape` below so a
531/// wording drift is caught in unit tests.
532pub const NON_GIT_TREE_CACHE_SKIP_HINT: &str = "skipping cache — source tree is not a git repository so dirty \
533 state cannot be detected; put the source under git, or replace the \
534 `--kernel <path>` source with one of the content-keyed fetch modes \
535 that does not need dirty-state detection — `kernel build --kernel \
536 <version>` (downloads the tarball from kernel.org) or `kernel build \
537 --kernel git+URL#branch=NAME` (shallow-clones the given ref) — to \
538 enable caching";
539
540/// Decide whether to emit the `(EOL)` legend under the `kernel list`
541/// table. Returns `Some(EOL_EXPLANATION)` iff at least one rendered
542/// row carried the tag, else `None`. Splitting the conditional out
543/// of `kernel_list` lets both branches be pinned in unit tests
544/// without capturing stderr.
545pub(crate) fn eol_legend_if_any(any_eol: bool) -> Option<&'static str> {
546 if any_eol { Some(EOL_EXPLANATION) } else { None }
547}
548
549/// Explanation of the `(untracked kconfig)` tag. Consumer-facing
550/// wording mirrors `EOL_EXPLANATION`'s "one-const, one-surface"
551/// pattern so a doc-drift between the tag word and the legend
552/// cannot silently slip. Mirrors [`STALE_KCONFIG_EXPLANATION`] so
553/// the kconfig tag pair shares one shape.
554///
555/// The `(corrupt)` tag is deliberately not in this legend family —
556/// its remediation is operational, not informational. See
557/// `format_corrupt_footer` for the full rationale.
558pub const UNTRACKED_KCONFIG_EXPLANATION: &str = "(untracked kconfig) marks entries with no recorded ktstr.kconfig hash \
559 (pre-dates kconfig hash tracking). Rebuild with: kernel build --force --kernel VERSION \
560 (add --extra-kconfig PATH if the original entry was built with a user fragment).";
561
562/// Decide whether to emit the `(untracked kconfig)` legend under the
563/// `kernel list` table. Parallels [`eol_legend_if_any`] so both
564/// branches are unit-testable without stderr capture.
565pub(crate) fn untracked_legend_if_any(any_untracked: bool) -> Option<&'static str> {
566 if any_untracked {
567 Some(UNTRACKED_KCONFIG_EXPLANATION)
568 } else {
569 None
570 }
571}
572
573/// Explanation of the `(stale kconfig)` tag. Mirrors
574/// [`UNTRACKED_KCONFIG_EXPLANATION`] so the kconfig tag pair
575/// shares one shape — every kconfig-status legend in the
576/// informational trio (EOL / UNTRACKED / STALE) is now a const
577/// surfaced via a `*_legend_if_any` helper. Verbatim wording
578/// preserved from the prior inline `eprintln!` in `kernel_list`
579/// so existing operators see no behavioural change.
580pub const STALE_KCONFIG_EXPLANATION: &str = "warning: entries marked (stale kconfig) were built against a different ktstr.kconfig. \
581 Rebuild with: kernel build --force --kernel <entry version> \
582 (add --extra-kconfig PATH if the entry also carries the (extra kconfig) tag).";
583
584/// Decide whether to emit the `(stale kconfig)` legend under the
585/// `kernel list` table. Mirrors [`eol_legend_if_any`] and
586/// [`untracked_legend_if_any`] so all three informational legends
587/// share one shape (boolean in, `Option<&'static str>` out) and
588/// every branch is unit-testable without stderr capture.
589pub(crate) fn stale_legend_if_any(any_stale: bool) -> Option<&'static str> {
590 if any_stale {
591 Some(STALE_KCONFIG_EXPLANATION)
592 } else {
593 None
594 }
595}
596
597/// Footer emitted by `kernel_list` when at least one entry is
598/// corrupt. Pure function of the cache-root path so tests pin the
599/// exact same string the production path prints — not a hand-copied
600/// duplicate. Extracted alongside [`eol_legend_if_any`] so the
601/// three actionable elements (the `(corrupt)` tag label, the
602/// `kernel clean` variants, and the cache-root path) are enforced
603/// by one source of truth.
604///
605/// Scope-safe wording: callers inspecting the footer in isolation
606/// must not be able to misread `kernel clean --force` as surgical.
607/// The text explicitly spells out "ALL cached entries" and
608/// surfaces `--corrupt-only --force` (the surgical form that leaves
609/// valid entries intact) ahead of the broader `--force` and
610/// `--keep N --force` escalation paths, so an operator with valid
611/// alongside corrupt entries reaches for the safe option first
612/// rather than blowing them all away in a single command.
613///
614/// Design decision: `(corrupt)` is deliberately NOT promoted to a
615/// one-line tag-explanation const in the [`EOL_EXPLANATION`] /
616/// [`UNTRACKED_KCONFIG_EXPLANATION`] / [`STALE_KCONFIG_EXPLANATION`]
617/// legend family. Two constraints drive the decision:
618///
619/// 1. **Runtime cache-root path.** The remediation must surface
620/// the actual cache-root directory so operators know where to
621/// inspect, and a `&'static str` cannot interpolate a runtime
622/// value. [`UNTRACKED_KCONFIG_EXPLANATION`] fits on one line
623/// precisely because its remediation (`kernel build --force
624/// VERSION`) is a literal string with no runtime context;
625/// corrupt's is not, and splitting definition from remediation
626/// is only a fallback — not a solution — since the runtime
627/// path still has to land somewhere adjacent to the tag.
628///
629/// 2. **Duplication avoidance.** The footer's first sentence
630/// already IS the legend — it names the tag, states the
631/// unusable meaning, and enumerates the three corruption modes
632/// (missing metadata, malformed metadata, missing image). A
633/// separate `CORRUPT_EXPLANATION` const would duplicate that
634/// content at two surfaces (const + footer), create drift risk
635/// as either wording is edited, and pay for nothing: a reader
636/// who sees `(corrupt)` in a row and scrolls to the footer
637/// already hits the definition in the first line. Test
638/// `corrupt_footer_is_self_documenting` pins that invariant.
639///
640/// Consistency note: the informational trio (EOL / UNTRACKED /
641/// STALE) all share the const + `*_legend_if_any` shape;
642/// `(corrupt)` is the sole tag whose remediation requires runtime
643/// state (the cache-root path), which is why it stays in the
644/// footer family rather than joining the informational trio.
645///
646/// Command ordering inside the footer: `--corrupt-only --force`
647/// is listed FIRST because it is the zero-risk surgical option
648/// for the common case (a cache with both valid and corrupt
649/// entries — leaves valid alone). The broader `--force` (removes
650/// ALL) and `--keep N --force` (preserves N newest) variants
651/// follow as escalation paths for operators who want to expand
652/// scope beyond corrupt entries alone.
653pub(crate) fn format_corrupt_footer(cache_root: &Path) -> String {
654 format!(
655 "warning: entries marked (corrupt) cannot be used — cached metadata is \
656 missing, malformed, or references a missing image. Inspect the entry \
657 directory under {} to remove it manually, or run \
658 `kernel clean --corrupt-only --force` which removes ONLY corrupt \
659 entries and leaves valid ones intact. For broader cleanup, \
660 `kernel clean --force` removes ALL cached entries (valid and corrupt \
661 alike); `kernel clean --keep N --force` preserves the N newest \
662 cached entries while removing the rest.",
663 cache_root.display(),
664 )
665}
666
667/// Decide whether to emit the corrupt-entry footer under the
668/// `kernel list` table. Mirrors [`eol_legend_if_any`] and
669/// [`untracked_legend_if_any`] so the three "tag → footer" gates
670/// share one shape (count in, `Option<String>` out) and every
671/// branch is unit-testable without stderr capture. The
672/// unconditional emission-only-when-tag-rendered invariant is the
673/// signal that keeps the normal no-corrupt case noise-free; a
674/// regression that unconditionally emitted the footer would show
675/// up as a red test on the `corrupt_count == 0` branch here.
676///
677/// Prepends a `"N corrupt entr{y|ies}. Run `cargo ktstr kernel
678/// clean --corrupt-only` to remove.\n"` summary line before the
679/// full [`format_corrupt_footer`] body so an operator sees the
680/// count and the short remediation FIRST, with the multi-option
681/// escalation detail following. The pluralized form ("entry" vs
682/// "entries") matches the count, making the line read naturally
683/// at both the 1-entry and N>1-entry boundaries.
684pub(crate) fn corrupt_footer_if_any(corrupt_count: usize, cache_root: &Path) -> Option<String> {
685 if corrupt_count == 0 {
686 return None;
687 }
688 let noun = if corrupt_count == 1 {
689 "entry"
690 } else {
691 "entries"
692 };
693 let summary = format!(
694 "{corrupt_count} corrupt {noun}. \
695 Run `cargo ktstr kernel clean --corrupt-only` to remove.",
696 );
697 let detail = format_corrupt_footer(cache_root);
698 Some(format!("{summary}\n{detail}"))
699}
700
701/// ktstr.kconfig embedded at compile time.
702pub const EMBEDDED_KCONFIG: &str = crate::EMBEDDED_KCONFIG;
703
704/// Compute CRC32 of the embedded ktstr.kconfig fragment.
705pub fn embedded_kconfig_hash() -> String {
706 crate::kconfig_hash()
707}
708
709#[cfg(test)]
710mod tests {
711 use super::*;
712
713 /// `eol_legend_if_any` is the sole gate on whether the text
714 /// output under `kernel list` emits the `(EOL)` legend.
715 #[test]
716 fn eol_legend_if_any_branches() {
717 assert_eq!(eol_legend_if_any(true), Some(EOL_EXPLANATION));
718 assert_eq!(eol_legend_if_any(false), None);
719 }
720
721 /// `untracked_legend_if_any` mirrors `eol_legend_if_any`.
722 #[test]
723 fn untracked_legend_if_any_branches() {
724 assert_eq!(
725 untracked_legend_if_any(true),
726 Some(UNTRACKED_KCONFIG_EXPLANATION),
727 );
728 assert_eq!(untracked_legend_if_any(false), None);
729 }
730
731 /// `stale_legend_if_any` completes the kconfig legend pair.
732 #[test]
733 fn stale_legend_if_any_branches() {
734 assert_eq!(stale_legend_if_any(true), Some(STALE_KCONFIG_EXPLANATION));
735 assert_eq!(stale_legend_if_any(false), None);
736 }
737
738 /// `STALE_KCONFIG_EXPLANATION` shape pin.
739 #[test]
740 fn stale_kconfig_explanation_shape() {
741 assert!(STALE_KCONFIG_EXPLANATION.starts_with("warning"));
742 assert!(STALE_KCONFIG_EXPLANATION.contains("(stale kconfig)"));
743 assert!(STALE_KCONFIG_EXPLANATION.contains("different ktstr.kconfig"));
744 assert!(
745 STALE_KCONFIG_EXPLANATION.contains("kernel build --force --kernel <entry version>")
746 );
747 }
748
749 /// `corrupt_footer_if_any` branches.
750 #[test]
751 fn corrupt_footer_if_any_branches() {
752 let root = std::path::Path::new("/tmp/ktstr-cache-test-root");
753 assert_eq!(corrupt_footer_if_any(0, root), None);
754 let one = corrupt_footer_if_any(1, root).expect("positive count must yield Some(footer)");
755 assert!(one.contains("1 corrupt entry."));
756 assert!(one.contains("cargo ktstr kernel clean --corrupt-only"));
757 assert!(one.contains(&format_corrupt_footer(root)));
758 let many = corrupt_footer_if_any(3, root).expect("positive count must yield Some(footer)");
759 assert!(many.contains("3 corrupt entries."));
760 }
761
762 /// Pin design decision: `(corrupt)` first sentence IS the
763 /// legend; the footer carries it AND the operational
764 /// remediation block.
765 #[test]
766 fn corrupt_footer_is_self_documenting() {
767 let root = std::path::Path::new("/tmp/ktstr-cache-test-root");
768 let footer = format_corrupt_footer(root);
769 let first_sentence = footer
770 .split_once(". ")
771 .map(|(head, _)| head)
772 .expect("footer must terminate legend sentence with period-space");
773 assert!(first_sentence.contains("(corrupt)"));
774 assert!(first_sentence.contains("cannot be used"));
775 for reason_token in ["metadata is missing", "malformed", "missing image"] {
776 assert!(
777 first_sentence.contains(reason_token),
778 "legend sentence must enumerate corruption modes; \
779 expected `{reason_token}`, got: {first_sentence:?}",
780 );
781 }
782 assert!(footer.contains(&root.display().to_string()));
783 assert!(footer.contains("kernel clean --corrupt-only --force"));
784 assert!(footer.contains("kernel clean --force"));
785 assert!(footer.contains("kernel clean --keep N --force"));
786 assert!(footer.contains("ALL cached entries"));
787 let pos_corrupt_only = footer
788 .find("kernel clean --corrupt-only --force")
789 .expect("--corrupt-only must appear");
790 let pos_force = footer
791 .find("kernel clean --force")
792 .expect("--force must appear");
793 let pos_keep = footer
794 .find("kernel clean --keep N --force")
795 .expect("--keep must appear");
796 assert!(pos_corrupt_only < pos_force);
797 assert!(pos_force < pos_keep);
798 }
799
800 /// `DIRTY_TREE_CACHE_SKIP_HINT` shape pin.
801 #[test]
802 fn dirty_tree_cache_skip_hint_shape() {
803 assert!(DIRTY_TREE_CACHE_SKIP_HINT.contains("skipping cache"));
804 assert!(DIRTY_TREE_CACHE_SKIP_HINT.contains("uncommitted changes"));
805 assert!(
806 DIRTY_TREE_CACHE_SKIP_HINT.contains("commit")
807 && DIRTY_TREE_CACHE_SKIP_HINT.contains("stash")
808 );
809 }
810
811 /// `NON_GIT_TREE_CACHE_SKIP_HINT` shape pin.
812 #[test]
813 fn non_git_tree_cache_skip_hint_shape() {
814 assert!(NON_GIT_TREE_CACHE_SKIP_HINT.starts_with("skipping cache"));
815 assert!(NON_GIT_TREE_CACHE_SKIP_HINT.contains("not a git repository"));
816 assert!(NON_GIT_TREE_CACHE_SKIP_HINT.contains("put the source under git"));
817 assert!(NON_GIT_TREE_CACHE_SKIP_HINT.contains("kernel build --kernel <version>"));
818 assert!(NON_GIT_TREE_CACHE_SKIP_HINT.contains("kernel build --kernel git+URL#branch=NAME"));
819 assert!(!NON_GIT_TREE_CACHE_SKIP_HINT.contains("stash"));
820 assert!(!NON_GIT_TREE_CACHE_SKIP_HINT.contains("commit"));
821 }
822
823 /// `untracked_legend_names_the_tag_word` — legend mentions tag.
824 #[test]
825 fn untracked_legend_names_the_tag_word() {
826 assert!(UNTRACKED_KCONFIG_EXPLANATION.contains("(untracked kconfig)"));
827 }
828
829 /// kernel_clean rejects `--keep` together with `--corrupt-only`.
830 #[test]
831 fn kernel_clean_rejects_corrupt_only_with_keep() {
832 use clap::Parser as _;
833 #[derive(clap::Parser, Debug)]
834 struct TestCli {
835 #[command(subcommand)]
836 cmd: KernelCommand,
837 }
838 let err = TestCli::try_parse_from(["prog", "clean", "--keep", "2", "--corrupt-only"])
839 .expect_err("--keep together with --corrupt-only must fail parsing");
840 let msg = err.to_string();
841 assert!(
842 msg.to_ascii_lowercase().contains("cannot be used with")
843 || msg.to_ascii_lowercase().contains("conflict"),
844 "clap error must surface the conflict between --keep and --corrupt-only, got: {msg}",
845 );
846 }
847
848 #[test]
849 fn kernel_clean_accepts_corrupt_only_alone() {
850 use clap::Parser as _;
851 #[derive(clap::Parser, Debug)]
852 struct TestCli {
853 #[command(subcommand)]
854 cmd: KernelCommand,
855 }
856 let parsed = TestCli::try_parse_from(["prog", "clean", "--corrupt-only"])
857 .expect("--corrupt-only without --keep must parse cleanly");
858 match parsed.cmd {
859 KernelCommand::Clean {
860 keep,
861 force,
862 corrupt_only,
863 } => {
864 assert_eq!(keep, None);
865 assert!(!force);
866 assert!(corrupt_only);
867 }
868 other => panic!("expected KernelCommand::Clean, got {other:?}"),
869 }
870 }
871
872 /// `kernel build --cpu-cap N` parses to `Build { cpu_cap: Some(N) }`.
873 #[test]
874 fn kernel_build_parses_cpu_cap_without_extra_flags() {
875 use clap::Parser as _;
876 #[derive(clap::Parser, Debug)]
877 struct TestCli {
878 #[command(subcommand)]
879 cmd: KernelCommand,
880 }
881 let parsed =
882 TestCli::try_parse_from(["prog", "build", "--kernel", "6.14.2", "--cpu-cap", "4"])
883 .expect("kernel build --cpu-cap N must parse");
884 match parsed.cmd {
885 KernelCommand::Build {
886 cpu_cap, kernel, ..
887 } => {
888 assert_eq!(cpu_cap, Some(4));
889 assert_eq!(kernel.as_deref(), Some("6.14.2"));
890 }
891 other => panic!("expected KernelCommand::Build, got {other:?}"),
892 }
893 }
894
895 /// `kernel build` without `--cpu-cap` parses with cpu_cap: None.
896 #[test]
897 fn kernel_build_without_cpu_cap_defaults_to_none() {
898 use clap::Parser as _;
899 #[derive(clap::Parser, Debug)]
900 struct TestCli {
901 #[command(subcommand)]
902 cmd: KernelCommand,
903 }
904 let parsed = TestCli::try_parse_from(["prog", "build", "--kernel", "6.14.2"])
905 .expect("kernel build without --cpu-cap must parse");
906 match parsed.cmd {
907 KernelCommand::Build { cpu_cap, .. } => {
908 assert_eq!(cpu_cap, None, "no --cpu-cap must produce None, not Some(0)");
909 }
910 other => panic!("expected KernelCommand::Build, got {other:?}"),
911 }
912 }
913
914 /// Bare `kernel build` (no `--kernel`) parses to `Build { kernel:
915 /// None }` — the latest-stable default. Pins the None default at the
916 /// parse layer so the dispatch's `None => latest stable` arm stays
917 /// reachable.
918 #[test]
919 fn kernel_build_without_kernel_defaults_to_none() {
920 use clap::Parser as _;
921 #[derive(clap::Parser, Debug)]
922 struct TestCli {
923 #[command(subcommand)]
924 cmd: KernelCommand,
925 }
926 let parsed =
927 TestCli::try_parse_from(["prog", "build"]).expect("bare kernel build must parse");
928 match parsed.cmd {
929 KernelCommand::Build { kernel, .. } => {
930 assert_eq!(
931 kernel, None,
932 "no --kernel must produce None (the latest-stable default)"
933 );
934 }
935 other => panic!("expected KernelCommand::Build, got {other:?}"),
936 }
937 }
938
939 /// `kernel build --cpu-cap 0` passes clap (validation runs at runtime).
940 #[test]
941 fn kernel_build_cpu_cap_zero_passes_clap() {
942 use clap::Parser as _;
943 #[derive(clap::Parser, Debug)]
944 struct TestCli {
945 #[command(subcommand)]
946 cmd: KernelCommand,
947 }
948 let parsed =
949 TestCli::try_parse_from(["prog", "build", "--kernel", "6.14.2", "--cpu-cap", "0"])
950 .expect("clap-level parse must accept 0; runtime validation rejects");
951 match parsed.cmd {
952 KernelCommand::Build { cpu_cap, .. } => {
953 assert_eq!(cpu_cap, Some(0));
954 }
955 other => panic!("expected KernelCommand::Build, got {other:?}"),
956 }
957 }
958
959 /// `kernel build --skip-sha256` parses to
960 /// `Build { skip_sha256: true, .. }`. Pins the bypass flag's
961 /// wire path: a regression that dropped the field would surface
962 /// as a parse rejection.
963 #[test]
964 fn kernel_build_parses_skip_sha256() {
965 use clap::Parser as _;
966 #[derive(clap::Parser, Debug)]
967 struct TestCli {
968 #[command(subcommand)]
969 cmd: KernelCommand,
970 }
971 let parsed =
972 TestCli::try_parse_from(["prog", "build", "--kernel", "6.14.2", "--skip-sha256"])
973 .expect("kernel build --skip-sha256 must parse");
974 match parsed.cmd {
975 KernelCommand::Build { skip_sha256, .. } => {
976 assert!(
977 skip_sha256,
978 "--skip-sha256 must round-trip as true so the \
979 downstream download path bypasses sha256sums.asc"
980 );
981 }
982 other => panic!("expected KernelCommand::Build, got {other:?}"),
983 }
984 }
985
986 /// `kernel build` without `--skip-sha256` parses with the safe
987 /// default `skip_sha256: false`. Pins the no-flag path so a
988 /// regression that flipped the default to true (silently
989 /// disabling checksum verification on every download) surfaces
990 /// as a test failure.
991 #[test]
992 fn kernel_build_without_skip_sha256_defaults_to_false() {
993 use clap::Parser as _;
994 #[derive(clap::Parser, Debug)]
995 struct TestCli {
996 #[command(subcommand)]
997 cmd: KernelCommand,
998 }
999 let parsed = TestCli::try_parse_from(["prog", "build", "--kernel", "6.14.2"])
1000 .expect("kernel build without --skip-sha256 must parse");
1001 match parsed.cmd {
1002 KernelCommand::Build { skip_sha256, .. } => {
1003 assert!(
1004 !skip_sha256,
1005 "no --skip-sha256 must produce false (verification \
1006 enabled by default); got skip_sha256={skip_sha256}"
1007 );
1008 }
1009 other => panic!("expected KernelCommand::Build, got {other:?}"),
1010 }
1011 }
1012
1013 /// `KERNEL_LIST_LONG_ABOUT` drives `kernel list --help` and must
1014 /// expose the `--json` output contract so scripted consumers can
1015 /// discover the schema from the terminal alone. Pins:
1016 /// 1. the `(EOL)` legend text appears verbatim at the head;
1017 /// 2. every top-level wrapper field appears;
1018 /// 3. every valid-entry field appears;
1019 /// 4. each `Option<T>` field carries a `(nullable)` tag;
1020 /// 5. each `KernelSource` variant tag and `kconfig_status`
1021 /// enum value is documented.
1022 #[test]
1023 fn kernel_list_long_about_exposes_json_schema() {
1024 assert!(
1025 KERNEL_LIST_LONG_ABOUT.starts_with(EOL_EXPLANATION),
1026 "KERNEL_LIST_LONG_ABOUT must embed EOL_EXPLANATION verbatim at its \
1027 head so the --help and post-table legend share one source of \
1028 truth; got: {KERNEL_LIST_LONG_ABOUT:?}",
1029 );
1030
1031 for wrapper_field in [
1032 "current_ktstr_kconfig_hash",
1033 "active_prefixes_fetch_error",
1034 "entries",
1035 ] {
1036 assert!(
1037 KERNEL_LIST_LONG_ABOUT.contains(wrapper_field),
1038 "KERNEL_LIST_LONG_ABOUT must mention top-level wrapper field \
1039 `{wrapper_field}` so scripted consumers discover the \
1040 schema without `cargo doc`",
1041 );
1042 }
1043
1044 for valid_entry_field in [
1045 "key",
1046 "path",
1047 "version",
1048 "source",
1049 "arch",
1050 "built_at",
1051 "ktstr_kconfig_hash",
1052 "kconfig_status",
1053 "eol",
1054 "config_hash",
1055 "image_name",
1056 "image_path",
1057 "has_vmlinux",
1058 "vmlinux_stripped",
1059 "git_hash",
1060 "\"ref\"",
1061 "source_tree_path",
1062 ] {
1063 assert!(
1064 KERNEL_LIST_LONG_ABOUT.contains(valid_entry_field),
1065 "KERNEL_LIST_LONG_ABOUT must mention valid-entry JSON \
1066 field `{valid_entry_field}`",
1067 );
1068 }
1069
1070 assert!(
1071 KERNEL_LIST_LONG_ABOUT.contains("error"),
1072 "KERNEL_LIST_LONG_ABOUT must mention corrupt-entry JSON \
1073 field `error` so consumers know the corrupt-entry shape",
1074 );
1075
1076 for nullable_field in ["version", "ktstr_kconfig_hash", "config_hash"] {
1077 let marker = format!("{nullable_field} (nullable)");
1078 assert!(
1079 KERNEL_LIST_LONG_ABOUT.contains(&marker),
1080 "KERNEL_LIST_LONG_ABOUT must mark `{nullable_field}` \
1081 as `(nullable)` (expected substring `{marker}`)",
1082 );
1083 }
1084
1085 for source_variant_tag in ["\"tarball\"", "\"git\"", "\"local\""] {
1086 assert!(
1087 KERNEL_LIST_LONG_ABOUT.contains(source_variant_tag),
1088 "KERNEL_LIST_LONG_ABOUT must list source variant tag \
1089 `{source_variant_tag}`",
1090 );
1091 }
1092
1093 for status_variant in ["\"matches\"", "\"stale\"", "\"untracked\""] {
1094 assert!(
1095 KERNEL_LIST_LONG_ABOUT.contains(status_variant),
1096 "KERNEL_LIST_LONG_ABOUT must list kconfig_status variant \
1097 `{status_variant}`",
1098 );
1099 }
1100 }
1101
1102 /// Pin that the `#[command(long_about = KERNEL_LIST_LONG_ABOUT)]`
1103 /// attribute on `KernelCommand::List` is wired through clap.
1104 #[test]
1105 fn kernel_list_long_about_wired_via_clap() {
1106 use clap::CommandFactory as _;
1107 #[derive(clap::Parser, Debug)]
1108 struct TestCli {
1109 #[command(subcommand)]
1110 cmd: KernelCommand,
1111 }
1112 let cmd = TestCli::command();
1113 let list = cmd
1114 .find_subcommand("list")
1115 .expect("clap must register a `list` subcommand on KernelCommand");
1116 let long_about = list
1117 .get_long_about()
1118 .expect("`list` subcommand must have a long_about set")
1119 .to_string();
1120 assert_eq!(long_about, KERNEL_LIST_LONG_ABOUT);
1121 }
1122
1123 /// `kernel build --help` must surface the `--skip-sha256` flag
1124 /// with documentation covering the no-op semantics on
1125 /// `--kernel <path>` / git+ / RC tarballs and the
1126 /// security-sensitive bypass-warning contract. Without these
1127 /// hints an operator setting the flag would have no way to
1128 /// know whether their fetch actually bypassed verification.
1129 #[test]
1130 fn kernel_build_help_documents_skip_sha256_no_op_semantics() {
1131 use clap::CommandFactory as _;
1132 #[derive(clap::Parser, Debug)]
1133 struct TestCli {
1134 #[command(subcommand)]
1135 cmd: KernelCommand,
1136 }
1137 let cmd = TestCli::command();
1138 let build = cmd
1139 .find_subcommand("build")
1140 .expect("clap must register a `build` subcommand on KernelCommand");
1141 // Locate the --skip-sha256 arg and read its long help.
1142 let arg = build
1143 .get_arguments()
1144 .find(|a| a.get_long() == Some("skip-sha256"))
1145 .expect("kernel build must register --skip-sha256 with clap");
1146 let help = arg
1147 .get_long_help()
1148 .or_else(|| arg.get_help())
1149 .map(|s| s.to_string())
1150 .expect("--skip-sha256 must carry help text");
1151 // Pins the user-facing semantics so a future doc rewrite
1152 // cannot drop the no-op clauses or the warning contract.
1153 assert!(
1154 help.contains("--kernel <path>"),
1155 "--skip-sha256 help must call out the --kernel <path> no-op so \
1156 operators don't expect bypass on local-source builds: {help}"
1157 );
1158 assert!(
1159 help.contains("git+"),
1160 "--skip-sha256 help must call out the git-source no-op so \
1161 operators don't expect bypass on git-source builds: {help}"
1162 );
1163 assert!(
1164 help.contains("RC"),
1165 "--skip-sha256 help must call out the RC-tarball no-op \
1166 (RC archives have no upstream manifest, so the flag is \
1167 a no-op there): {help}"
1168 );
1169 assert!(
1170 help.contains("warning"),
1171 "--skip-sha256 help must mention the bypass warning so \
1172 ops know the lost guarantee surfaces in the same channel \
1173 as verification-success lines: {help}"
1174 );
1175 }
1176}