ktstr/cli/resolve.rs
1//! Kernel-resolution dispatch: cache lookup, version download,
2//! source-tree auto-build, range expansion, git fetch.
3//!
4//! Holds the entry points that turn a `--kernel` spec into a
5//! bootable image path or cache-entry directory:
6//! [`resolve_kernel_image`], [`resolve_kernel_dir`],
7//! [`resolve_kernel_dir_to_entry`], [`resolve_cached_kernel`].
8//! Range and git wrappers live alongside ([`expand_kernel_range`],
9//! [`resolve_git_kernel`]). `--include-files` resolution
10//! ([`resolve_include_files`]) and the rayon-pool sizing helper
11//! ([`resolve_kernel_parallelism`]) share the module because both
12//! are dispatch-time helpers.
13
14use std::path::Path;
15
16use anyhow::{Result, anyhow, bail};
17
18use super::kernel_build::kernel_build_pipeline;
19use super::util::{Spinner, status, success};
20
21/// Pre-flight check for /dev/kvm availability and permissions.
22pub fn check_kvm() -> Result<()> {
23 use std::path::Path;
24 if !Path::new("/dev/kvm").exists() {
25 bail!(
26 "/dev/kvm not found. KVM requires:\n \
27 - Linux kernel with KVM support (CONFIG_KVM)\n \
28 - Access to /dev/kvm (check permissions or add user to 'kvm' group)\n \
29 - Hardware virtualization enabled in BIOS (VT-x/AMD-V)"
30 );
31 }
32 if let Err(e) = std::fs::File::open("/dev/kvm") {
33 if e.kind() == std::io::ErrorKind::PermissionDenied {
34 bail!(
35 "/dev/kvm: permission denied. Add your user to the 'kvm' group:\n \
36 sudo usermod -aG kvm $USER\n \
37 then log out and back in."
38 );
39 }
40 bail!("/dev/kvm: {e}");
41 }
42 Ok(())
43}
44
45/// Pre-flight check: verify that all required external tools are
46/// present in `$PATH` before starting work. Collects every missing
47/// tool into a single error so the user sees the full list at once.
48pub fn check_tools(required: &[&str]) -> Result<()> {
49 let missing: Vec<&str> = required
50 .iter()
51 .copied()
52 .filter(|name| resolve_in_path(std::path::Path::new(name)).is_none())
53 .collect();
54 if missing.is_empty() {
55 return Ok(());
56 }
57 let list = missing
58 .iter()
59 .map(|t| format!(" - {t}"))
60 .collect::<Vec<_>>()
61 .join("\n");
62 bail!(
63 "missing required tool(s):\n{list}\n\n\
64 Install them and ensure they are on $PATH before retrying."
65 );
66}
67
68/// Resolve the rayon pool width for `cargo ktstr`'s
69/// `resolve_kernel_set` per-spec fan-out.
70///
71/// Reads [`crate::KTSTR_KERNEL_PARALLELISM_ENV`] first; if the env
72/// var is set to a non-zero, parseable `usize`, that value wins.
73/// Otherwise falls back to [`std::thread::available_parallelism`]
74/// — the host's logical CPU count, the right ceiling for
75/// download-bound work that should not outnumber the threads the
76/// host can drive without thrashing the local network. Final
77/// fallback is `1` if `available_parallelism` errors (a sandboxed
78/// or container-limited host), preserving forward progress.
79///
80/// Sentinel handling: `0` and unparseable values fall through
81/// (`from_str` errs on non-digits, and the explicit `n > 0`
82/// guard rejects the parsed-zero case). A typoed export
83/// (`KTSTR_KERNEL_PARALLELISM=abc` or `=0`) degrades to the
84/// host-CPU default rather than disabling parallelism — a
85/// disabled-pool resolve would serialize multi-spec invocations
86/// with no observable signal that the env var was the cause.
87/// The fall-through path emits a `tracing::warn!` carrying the
88/// raw value so the operator sees their typo'd export was
89/// ignored; the default still applies so forward progress is
90/// preserved. Leading/trailing whitespace is trimmed before
91/// parsing so a shell-quoted `=" 8 "` behaves the same as the
92/// unquoted form.
93///
94/// Extracted from cargo-ktstr's `resolve_kernel_set` so the
95/// parsing rules live in one place; the cargo-ktstr binary
96/// invokes this and feeds the result into
97/// [`rayon::ThreadPoolBuilder::num_threads`]. Lives in the
98/// `cli` module rather than in the binary so it's reachable
99/// from rustdoc and from the lib's unit-test harness.
100pub fn resolve_kernel_parallelism() -> usize {
101 if let Ok(raw) = std::env::var(crate::KTSTR_KERNEL_PARALLELISM_ENV) {
102 let trimmed = raw.trim();
103 match trimmed.parse::<usize>() {
104 Ok(n) if n > 0 => return n,
105 _ => {
106 tracing::warn!(
107 env_var = crate::KTSTR_KERNEL_PARALLELISM_ENV,
108 value = %raw,
109 "KTSTR_KERNEL_PARALLELISM={raw:?} failed to parse, using default",
110 );
111 }
112 }
113 }
114 std::thread::available_parallelism()
115 .map(|n| n.get())
116 .unwrap_or(1)
117}
118
119/// Search PATH for a bare executable name.
120fn resolve_in_path(name: &std::path::Path) -> Option<std::path::PathBuf> {
121 use std::os::unix::fs::PermissionsExt;
122 let path_var = std::env::var_os("PATH")?;
123 for dir in std::env::split_paths(&path_var) {
124 let candidate = dir.join(name);
125 if let Ok(meta) = std::fs::metadata(&candidate)
126 && meta.is_file()
127 && meta.permissions().mode() & 0o111 != 0
128 {
129 return Some(candidate);
130 }
131 }
132 None
133}
134
135/// Resolve `--include-files` arguments into `(archive_path, host_path)` pairs.
136///
137/// Each path is resolved as follows:
138/// - Explicit paths (starting with `/`, `.`, `..`, or containing `/`): must exist.
139/// - Bare names: searched in PATH.
140/// - Directories: walked recursively via `walkdir`, following symlinks.
141/// The directory's basename becomes the root under `include-files/`.
142/// Non-regular files (sockets, pipes, device nodes) are skipped.
143/// Empty directories produce a warning to stderr.
144/// - Regular files: included directly as `include-files/<filename>`.
145pub fn resolve_include_files(
146 paths: &[std::path::PathBuf],
147) -> Result<Vec<(String, std::path::PathBuf)>> {
148 use std::path::{Component, PathBuf};
149
150 let mut resolved_includes: Vec<(String, PathBuf)> = Vec::new();
151 for path in paths {
152 let is_explicit_path = {
153 matches!(
154 path.components().next(),
155 Some(Component::RootDir | Component::CurDir | Component::ParentDir)
156 ) || path.components().count() > 1
157 };
158 let resolved = if is_explicit_path {
159 anyhow::ensure!(
160 path.exists(),
161 "--include-files path not found: {}",
162 path.display()
163 );
164 path.clone()
165 } else {
166 // Bare name: search PATH.
167 if path.exists() {
168 path.clone()
169 } else {
170 resolve_in_path(path).ok_or_else(|| {
171 anyhow::anyhow!("-i {}: not found in filesystem or PATH", path.display())
172 })?
173 }
174 };
175 if resolved.is_dir() {
176 let dir_name = resolved
177 .file_name()
178 .ok_or_else(|| {
179 anyhow::anyhow!("include directory has no name: {}", resolved.display())
180 })?
181 .to_string_lossy()
182 .to_string();
183 let prefix = format!("include-files/{dir_name}");
184 let mut count = 0usize;
185 for entry in walkdir::WalkDir::new(&resolved).follow_links(true) {
186 let entry = entry.map_err(|e| anyhow::anyhow!("-i {}: {e}", resolved.display()))?;
187 if !entry.file_type().is_file() {
188 continue;
189 }
190 let rel = entry
191 .path()
192 .strip_prefix(&resolved)
193 .expect("walkdir entry is under root");
194 let archive_path = format!("{prefix}/{}", rel.display());
195 resolved_includes.push((archive_path, entry.into_path()));
196 count += 1;
197 }
198 if count == 0 {
199 eprintln!(
200 "warning: -i {}: directory contains no regular files",
201 resolved.display()
202 );
203 }
204 } else {
205 let file_name = resolved
206 .file_name()
207 .ok_or_else(|| {
208 anyhow::anyhow!("include file has no filename: {}", resolved.display())
209 })?
210 .to_string_lossy();
211 let archive_path = format!("include-files/{file_name}");
212 resolved_includes.push((archive_path, resolved));
213 }
214 }
215
216 // Detect duplicate archive paths (e.g. `-i ./a/dir -i ./b/dir` both
217 // containing the same relative file). The cpio format silently
218 // overwrites earlier entries, so duplicates must be caught here.
219 let mut seen = std::collections::HashMap::<&str, &std::path::Path>::new();
220 for (archive_path, host_path) in &resolved_includes {
221 if let Some(prev) = seen.insert(archive_path.as_str(), host_path.as_path()) {
222 anyhow::bail!(
223 "duplicate include path '{}': provided by both {} and {}",
224 archive_path,
225 prev.display(),
226 host_path.display(),
227 );
228 }
229 }
230
231 Ok(resolved_includes)
232}
233
234/// Look up a cache key, checking local first, then remote (if enabled).
235///
236/// `cli_label` prefixes diagnostic output (e.g. `"ktstr"` or
237/// `"cargo ktstr"`).
238pub fn cache_lookup(
239 cache: &crate::cache::CacheDir,
240 cache_key: &str,
241 cli_label: &str,
242) -> Option<crate::cache::CacheEntry> {
243 // `CacheDir::lookup` emits the per-lookup "unstripped vmlinux"
244 // warning on any local hit whose entry was stored via the
245 // strip-fallback path. The remote-lookup path here funnels
246 // downloads through `CacheDir::store`, which runs its own strip
247 // pipeline and reports via `tracing::warn!` at store time — so the
248 // warning coverage is uniform across local and remote cache
249 // hits without an additional check here.
250 if let Some(entry) = cache.lookup(cache_key) {
251 return Some(entry);
252 }
253
254 if crate::remote_cache::is_enabled() {
255 return crate::remote_cache::remote_lookup(cache, cache_key, cli_label);
256 }
257
258 None
259}
260
261/// Resolve a Version or CacheKey identifier to a cache entry directory.
262///
263/// Lookup order: local cache, then the remote GHA cache when
264/// `remote_cache::is_enabled()` returns true. Miss behavior differs
265/// by variant:
266/// - **Version**: major.minor prefixes (e.g. `"6.14"`) resolve to
267/// the latest patch via [`crate::fetch::fetch_version_for_prefix`]
268/// first. On full miss, downloads the kernel from kernel.org,
269/// builds it, and stores it in the cache via
270/// [`download_and_cache_version`].
271/// - **CacheKey**: errors on miss — cache keys are content-hashes
272/// and not downloadable. The error hint suggests running
273/// `{cli_label} kernel list`.
274///
275/// `cli_label` is the human-facing command name (`"ktstr"` or
276/// `"cargo ktstr"`) threaded into status output and error messages.
277pub fn resolve_cached_kernel(
278 id: &crate::kernel_path::KernelId,
279 cli_label: &str,
280 mp: Option<&crate::cli::FetchProgress>,
281) -> Result<std::path::PathBuf> {
282 use crate::kernel_path::KernelId;
283 match id {
284 KernelId::Version(ver) => {
285 // Major.minor prefix (e.g. "6.14") → resolve to latest patch.
286 let resolved = if crate::fetch::is_major_minor_prefix(ver) {
287 crate::fetch::fetch_version_for_prefix(
288 crate::fetch::shared_client(),
289 ver,
290 cli_label,
291 )?
292 } else {
293 ver.clone()
294 };
295 let cache = crate::cache::CacheDir::new()?;
296 let (arch, _) = crate::fetch::arch_info();
297 let cache_key = format!("{resolved}-tarball-{arch}-kc{}", crate::cache_key_suffix());
298 if let Some(entry) = cache_lookup(&cache, &cache_key, cli_label) {
299 // lookup() returns Some only for valid-metadata entries.
300 return Ok(entry.path);
301 }
302 // Cache miss: download and build the requested version.
303 // cpu_cap is None here — resolve_cached_kernel is reached
304 // from test/coverage/shell/run/verifier (via
305 // resolve_kernel_image), and --cpu-cap is scoped to the
306 // explicit `kernel build` / `shell --no-perf-mode` paths
307 // only; the auto-build-on-miss codepath is outside that
308 // scope by design.
309 download_and_cache_version(&resolved, cli_label, None, mp)
310 }
311 KernelId::CacheKey(key) => {
312 let cache = crate::cache::CacheDir::new()?;
313 if let Some(entry) = cache_lookup(&cache, key, cli_label) {
314 return Ok(entry.path);
315 }
316 bail!(
317 "cache key {key} not found. \
318 Run `{cli_label} kernel list` to see available entries."
319 )
320 }
321 KernelId::Path(_) => bail!("resolve_cached_kernel called with Path variant"),
322 // Multi-kernel specs cannot resolve to a single cache entry.
323 // This function returns one path; range/git fan-out belongs
324 // upstream in the dispatch loop that iterates kernels. Bail
325 // with an actionable redirect that cites the value the user
326 // wrote — `KernelId::Display` renders Range as `start..=end`
327 // (or `start..end`) and Git as `git+URL#tag=NAME` /
328 // `#branch=NAME` / `#sha=HASH` (a bare `git+URL#REF` only for
329 // the rejected Unknown kind), matching the sibling cache-key
330 // bail above that cites `{key}`.
331 //
332 // Run `validate()` first so an inverted range surfaces the
333 // specific "swap the endpoints" diagnostic before the
334 // generic "not yet supported" redirect masks it. Operators
335 // with a typo see the actionable fix; valid-but-unsupported
336 // specs get the redirect.
337 KernelId::Range { .. } | KernelId::Git { .. } => {
338 id.validate()
339 .map_err(|e| anyhow::anyhow!("--kernel {id}: {e}"))?;
340 bail!(
341 "--kernel {id}: kernel ranges and git sources are not \
342 yet supported in this context — use a single kernel \
343 version, cache key, or path"
344 )
345 }
346 }
347}
348
349/// Policy controlling `resolve_kernel_image` behavior across binaries.
350///
351/// The resolution pipeline — directory auto-build, version
352/// auto-download, cache lookup — is shared. `KernelResolvePolicy`
353/// carries the per-binary knobs documented on each field.
354pub struct KernelResolvePolicy<'a> {
355 /// Accept raw kernel image files (e.g. `bzImage`, `Image`) passed
356 /// as `--kernel`. `ktstr` uses `false` (rejects); `cargo ktstr`
357 /// uses `true` (accepts).
358 pub accept_raw_image: bool,
359 /// CLI label for diagnostic status messages (e.g. `"ktstr"`,
360 /// `"cargo ktstr"`), threaded into auto-build and auto-download
361 /// status output.
362 pub cli_label: &'a str,
363}
364
365/// Resolve a kernel identifier to a bootable image path.
366///
367/// Handles `KernelId` variants: directory (auto-build), version
368/// string, and cache key. Raw image file acceptance is controlled by
369/// `policy.accept_raw_image`. The `None` case resolves automatically
370/// via cache then filesystem, falling back to auto-download.
371pub fn resolve_kernel_image(
372 kernel: Option<&str>,
373 policy: &KernelResolvePolicy<'_>,
374) -> Result<std::path::PathBuf> {
375 use crate::kernel_path::KernelId;
376
377 if let Some(val) = kernel {
378 match KernelId::parse(val) {
379 KernelId::Path(p) => {
380 let path = std::path::PathBuf::from(&p);
381 if path.is_dir() {
382 // `None` for cpu_cap: resolve_kernel_image is
383 // called by test/coverage/shell/run/verifier —
384 // subcommands where --cpu-cap is not exposed.
385 // The two kernel-build entry points
386 // (ktstr/cargo-ktstr `kernel build`) call
387 // resolve_kernel_dir directly with their flag-
388 // derived cap and do NOT go through
389 // resolve_kernel_image.
390 resolve_kernel_dir(&path, policy.cli_label, None)
391 } else if path.is_file() {
392 if policy.accept_raw_image {
393 Ok(path)
394 } else {
395 // Raw kernel image file — reject. Use a source
396 // directory or version string so kconfig validation
397 // and caching work correctly.
398 bail!(
399 "--kernel {}: raw image files are not supported. \
400 Pass a source directory, version, or cache key.",
401 path.display()
402 )
403 }
404 } else {
405 bail!("kernel path not found: {}", path.display())
406 }
407 }
408 id @ (KernelId::Version(_) | KernelId::CacheKey(_)) => {
409 let cache_dir = resolve_cached_kernel(&id, policy.cli_label, None)?;
410 crate::kernel_path::find_image_in_dir(&cache_dir).ok_or_else(|| {
411 anyhow::anyhow!("no kernel image found in {}", cache_dir.display())
412 })
413 }
414 // Multi-kernel specs cannot resolve to a single image
415 // here. The dispatch loop that fans out range expansion
416 // and git fetch lives one level up at the test/coverage/
417 // verifier subcommand entry; this resolver is the
418 // single-kernel leaf. Bail with an actionable redirect
419 // so the user knows the spec is recognised but the
420 // calling subcommand hasn't wired up the multi-kernel
421 // pipeline yet.
422 //
423 // Run `validate()` first so an inverted range surfaces
424 // the specific "swap the endpoints" diagnostic before
425 // the generic "not yet supported" redirect masks it.
426 id @ (KernelId::Range { .. } | KernelId::Git { .. }) => {
427 id.validate()
428 .map_err(|e| anyhow::anyhow!("--kernel {val}: {e}"))?;
429 bail!(
430 "--kernel {val}: kernel ranges and git sources are not \
431 yet supported in this context — use a single kernel \
432 version, cache key, or path"
433 )
434 }
435 }
436 } else {
437 match crate::find_kernel()? {
438 Some(image) => Ok(image),
439 None => auto_download_kernel(policy.cli_label),
440 }
441 }
442}
443
444/// Auto-download, build, and cache the latest stable kernel.
445///
446/// Called when no --kernel is specified and no kernel is found via
447/// cache or filesystem. Resolves the latest stable version and
448/// delegates to [`download_and_cache_version`]. `cli_label` prefixes
449/// status output (e.g. `"ktstr"`, `"cargo ktstr"`).
450pub fn auto_download_kernel(cli_label: &str) -> Result<std::path::PathBuf> {
451 status(&format!(
452 "{cli_label}: no kernel found, downloading latest stable"
453 ));
454
455 let sp = Spinner::start("Fetching latest kernel version...");
456 let ver = crate::fetch::fetch_latest_stable_version(crate::fetch::shared_client(), cli_label)?;
457 sp.finish(format!("Latest stable: {ver}"));
458
459 let cache_dir = download_and_cache_version(&ver, cli_label, None, None)?;
460 let (_, image_name) = crate::fetch::arch_info();
461 Ok(cache_dir.join(image_name))
462}
463
464/// Download a specific kernel version, build it, and store in the
465/// cache. Returns the cache entry directory path (NOT the image path).
466///
467/// Checks the cache one more time with the resolved version to cover
468/// races and prefix-resolved entries. Delegates to
469/// [`kernel_build_pipeline`] for configure/build/validate/cache.
470///
471/// `cpu_cap` forwards the resource-budget cap to the pipeline so
472/// the LLC flock + cgroup sandbox phases honour it. `None` means
473/// "reserve 30% of the allowed-CPU set" (see
474/// [`CpuCap::resolve`](crate::vmm::host_topology::CpuCap::resolve)).
475///
476/// `mp` is the progress group spanning BOTH the download and the build:
477/// the parallel resolve's shared group, or `None` for a single-shot
478/// caller (shell auto-fetch, monitor, auto-download), in which case a
479/// local group is created here and passed through to
480/// `kernel_build_pipeline`. The build renders through this same group
481/// (as `step_bar` spinners), so the group stays live across the build
482/// with no standalone `Spinner` to collide with.
483pub fn download_and_cache_version(
484 version: &str,
485 cli_label: &str,
486 cpu_cap: Option<crate::vmm::host_topology::CpuCap>,
487 mp: Option<&crate::cli::FetchProgress>,
488) -> Result<std::path::PathBuf> {
489 let (arch, _) = crate::fetch::arch_info();
490 let cache_key = format!("{version}-tarball-{arch}-kc{}", crate::cache_key_suffix());
491
492 // Check cache one more time with the resolved version.
493 if let Ok(cache) = crate::cache::CacheDir::new()
494 && let Some(entry) = cache_lookup(&cache, &cache_key, cli_label)
495 {
496 return Ok(entry.path);
497 }
498
499 let tmp_dir = tempfile::TempDir::new()?;
500
501 // One progress group spans BOTH the download and the build: the
502 // parallel resolve's shared group via `mp`, or a local group for
503 // single-shot callers (`mp == None`). The build now renders through
504 // this same group rather than a standalone Spinner, so the group
505 // legitimately stays live across the build with no Spinner to
506 // collide with.
507 //
508 // `skip_sha256 = false`: the auto-resolve path (test/coverage
509 // /llvm-cov / shell auto-fetch) never bypasses checksum
510 // verification. The bypass is reachable only via the explicit
511 // `kernel build --skip-sha256` flag — auto-resolution must keep
512 // the strong manifest guarantee since the operator has not
513 // opted into the unverified fallback.
514 let owned_group;
515 let group = match mp {
516 Some(fp) => fp,
517 None => {
518 owned_group = crate::cli::FetchProgress::new();
519 &owned_group
520 }
521 };
522 let acquired = crate::fetch::download_tarball(
523 crate::fetch::shared_client(),
524 version,
525 tmp_dir.path(),
526 cli_label,
527 false,
528 Some(group),
529 )?;
530
531 let cache = crate::cache::CacheDir::new()?;
532 let result = kernel_build_pipeline(
533 &acquired,
534 &cache,
535 cli_label,
536 false,
537 false,
538 cpu_cap,
539 None,
540 Some(group),
541 )?;
542
543 match result.entry {
544 Some(entry) => Ok(entry.path),
545 None => bail!(
546 "kernel built but cache store failed — cannot return image from temporary directory"
547 ),
548 }
549}
550
551/// Expand a kernel-version range to the list of stable / longterm
552/// releases that fall inside `[start, end]` inclusive.
553///
554/// Fetches kernel.org's `releases.json` once via
555/// `crate::fetch::cached_releases`, filters to rows whose `moniker`
556/// is `stable` or `longterm` (matching the policy
557/// [`crate::fetch::fetch_latest_stable_version`] uses for "is this a
558/// production release we want to test against"), drops any version
559/// outside the inclusive interval, and returns the surviving versions
560/// sorted ascending by `(major, minor, patch, rc)` tuple. Empty result
561/// is a hard error — an empty range either reflects a typo (start/end
562/// don't bracket any active series) or releases.json missing rows
563/// the operator expected, and silently iterating over zero kernels
564/// would mask both. The `KernelId::Range` doc comment promises "every
565/// release in the range" which a quiet no-op contradicts.
566///
567/// Range endpoints are NOT required to appear in releases.json — the
568/// interval is half-the-numeric, half-presence: `6.10..6.16` selects
569/// every stable release inside that span, regardless of whether `6.10`
570/// and `6.16` themselves are still listed (e.g. one has been pruned
571/// from active maintenance). This matches the inclusive-numeric-comparison
572/// semantics in [`crate::kernel_path::KernelId::validate`] and lets a
573/// range from an EOL series survive even after the endpoint version
574/// itself becomes unavailable.
575///
576/// Endpoint granularity is series-inclusive on BOTH ends: a 2-component
577/// `MAJOR.MINOR` endpoint names the whole series, so `6.11..6.14`
578/// includes every `6.14.N` (not just `6.14.0`). START is series-inclusive
579/// because its `.0` is the series floor; END is widened to the series
580/// ceiling by `range_bounds`. An explicit-patch endpoint (`6.14.2`) is
581/// an exact bound — `6.11..6.14.2` stops at `6.14.2`.
582///
583/// `cli_label` prefixes the kernel.org-fetch status line so the
584/// diagnostic matches the binary that triggered the lookup
585/// (`"ktstr"` vs `"cargo ktstr"`).
586///
587/// When `include_eol` is set, the maintained set from `releases.json`
588/// is unioned with EOL `stable` series enumerated from the gregkh
589/// linux-stable mirror's tags (`crate::fetch::cached_stable_tags`),
590/// then the highest patch of each `(major, minor)` series is taken
591/// across both sources (`select_series_latest_in_range`). Without it
592/// a series dropped from `releases.json` is silently absent —
593/// `6.11..6.14` collapses to just the 6.11 series if 6.12/6.13 are
594/// EOL. If the mirror tag list cannot be fetched, expansion proceeds
595/// against the active-release set alone with a warning; the
596/// empty-result error hints `--include-eol` only when it was not
597/// already set.
598///
599/// Pre-release filter: `mainline` and `linux-next` rows are
600/// excluded by the moniker filter; rc tags carrying a stable
601/// moniker would also be excluded but kernel.org publishes rcs
602/// under `mainline`, so the filter is double-coverage in practice.
603/// Operators who want to test against an rc spell it out as a
604/// single `--kernel 6.16-rc3` rather than expecting the range
605/// expansion to surface it.
606pub fn expand_kernel_range(
607 start: &str,
608 end: &str,
609 cli_label: &str,
610 include_eol: bool,
611) -> Result<Vec<String>> {
612 let (start_key, end_key) = range_bounds(start, end)?;
613
614 eprintln!(
615 "{cli_label}: expanding kernel range {start}..{end}{eol}",
616 eol = if include_eol {
617 " (including EOL series)"
618 } else {
619 ""
620 },
621 );
622 // Cached fetch: peer Range specs running in parallel under
623 // `cargo ktstr`'s `resolve_kernel_set` rayon pipeline share
624 // one network round-trip. The first Range to reach this
625 // helper populates [`crate::fetch::RELEASES_CACHE`]; every
626 // subsequent `--kernel A..B` call clones the cached vector
627 // and skips the kernel.org GET. A transient outage on the
628 // first call returns Err and leaves the cache un-populated,
629 // so the next caller re-attempts the network — failures
630 // never poison the cache.
631 let releases = crate::fetch::cached_releases()?;
632 let maintained = filter_and_sort_range(&releases, start_key, end_key);
633
634 // Under `--include-eol`, also enumerate the gregkh stable mirror's
635 // tags. `None` = the ls-remote failed; warn and fall back to the
636 // maintained set alone. The fetch is cached
637 // (`crate::fetch::cached_stable_tags`) so peer ranges share one
638 // ls-remote.
639 let mirror_tags = if include_eol {
640 let tags = crate::fetch::cached_stable_tags();
641 if tags.is_none() {
642 eprintln!(
643 "{cli_label}: --include-eol: could not enumerate release tags \
644 from the stable mirror (ls-remote failed); EOL series may be \
645 missing from this range"
646 );
647 }
648 tags
649 } else {
650 None
651 };
652 let versions = combine_range_versions(maintained, mirror_tags, start_key, end_key, include_eol);
653
654 if versions.is_empty() {
655 bail!(
656 "kernel range {start}..{end} expanded to 0 stable releases.{hint} \
657 Verify the endpoints, or use a single `--kernel <version>` if you \
658 want a pre-release or archived version.",
659 hint = empty_range_hint(include_eol),
660 );
661 }
662
663 eprintln!(
664 "{cli_label}: range expanded to {n} kernel(s): {list}",
665 n = versions.len(),
666 list = versions.join(", "),
667 );
668 Ok(versions)
669}
670
671/// Version-comparison key: `(major, minor, patch, rc)`, ordered so a
672/// release (rc slot `u64::MAX`) sorts above its own release candidates.
673type VersionKey = (u64, u64, u64, u64);
674
675/// Decompose a range's `start` / `end` endpoints into comparison keys,
676/// widening a 2-component `MAJOR.MINOR` END to cover its whole series.
677///
678/// [`decompose_version_for_compare`](crate::kernel_path::decompose_version_for_compare)
679/// maps a missing patch to 0, so a 2-component END like `6.14` becomes
680/// `(6, 14, 0, u64::MAX)` — which as an inclusive upper bound would
681/// exclude every `6.14.N` (N >= 1), since `(6,14,N,MAX) > (6,14,0,MAX)`.
682/// But a `MAJOR.MINOR` END names the whole END series, not just its `.0`
683/// release, so its patch and rc slots are widened to `u64::MAX` to make
684/// the END series-inclusive. This matches START, whose 2-component form
685/// is already series-inclusive because `.0` is the series floor. An
686/// explicit-patch END (`6.14.2`) or an `-rc` END keeps its exact bound.
687///
688/// The END widening is delegated to
689/// [`crate::kernel_path::range_end_key`], which
690/// [`crate::kernel_path::KernelId::validate`] ALSO uses for its
691/// inversion check — so the pre-expansion validation and the expansion
692/// agree on where a range ends (a valid same-series spelling like
693/// `6.14.5..6.14` is not falsely rejected as inverted).
694///
695/// Pure — no I/O — so the endpoint-widening is unit-testable. Errors
696/// name the offending endpoint with the accepted grammar.
697fn range_bounds(start: &str, end: &str) -> Result<(VersionKey, VersionKey)> {
698 use crate::kernel_path::{decompose_version_for_compare, range_end_key};
699
700 let start_key = decompose_version_for_compare(start).ok_or_else(|| {
701 anyhow!(
702 "kernel range start `{start}` is not a parseable version. \
703 Endpoints must match `MAJOR.MINOR[.PATCH][-rcN]`."
704 )
705 })?;
706 let end_key = range_end_key(end).ok_or_else(|| {
707 anyhow!(
708 "kernel range end `{end}` is not a parseable version. \
709 Endpoints must match `MAJOR.MINOR[.PATCH][-rcN]`."
710 )
711 })?;
712 Ok((start_key, end_key))
713}
714
715/// The `--include-eol` hint appended to the empty-range error, present
716/// only when the flag was NOT set — setting it already consulted the
717/// EOL mirror, so suggesting it again would be misleading. Split out so
718/// the conditional is unit-testable without a network round-trip.
719fn empty_range_hint(include_eol: bool) -> &'static str {
720 if include_eol {
721 ""
722 } else {
723 " Pass --include-eol to also expand EOL series absent from releases.json."
724 }
725}
726
727/// Combine the maintained (releases.json) versions with the optional
728/// EOL mirror tags into the final expanded set.
729///
730/// Pure — the I/O-free core of [`expand_kernel_range`]'s selection,
731/// split out so the union / best-data-wins / EOL-fallback behavior is
732/// unit-testable without the network. When `include_eol` is false the
733/// maintained set is returned verbatim. When true, the maintained set is
734/// unioned with `mirror_tags` (`None` = the mirror ls-remote failed, so
735/// fall back to the maintained set alone) and
736/// [`select_series_latest_in_range`] re-selects the highest patch per
737/// `(major, minor)` across BOTH sources — so a mirror patch newer than
738/// releases.json wins, and a stale mirror never regresses a maintained
739/// version.
740fn combine_range_versions(
741 maintained: Vec<String>,
742 mirror_tags: Option<&[String]>,
743 start_key: VersionKey,
744 end_key: VersionKey,
745 include_eol: bool,
746) -> Vec<String> {
747 if !include_eol {
748 return maintained;
749 }
750 let Some(tags) = mirror_tags else {
751 return maintained;
752 };
753 let mut all = maintained;
754 all.extend(select_series_latest_in_range(tags, start_key, end_key));
755 select_series_latest_in_range(&all, start_key, end_key)
756}
757
758/// Filter [`Release`](crate::fetch::Release) rows to stable+longterm
759/// versions inside `[start_key, end_key]` and return them sorted
760/// ascending by version tuple.
761///
762/// Separated from [`expand_kernel_range`] so the pure filter+sort
763/// logic — moniker rejection, version-tuple bounds check, sort
764/// order — is testable without hitting the network. The wrapper is
765/// a thin adapter that fetches `releases.json` and reports the
766/// outcome to stderr; this helper carries no I/O. Mirrors the
767/// `active_prefixes_from_releases` split applied above.
768fn filter_and_sort_range(
769 releases: &[crate::fetch::Release],
770 start_key: VersionKey,
771 end_key: VersionKey,
772) -> Vec<String> {
773 use crate::kernel_path::decompose_version_for_compare;
774
775 let mut selected: Vec<(String, VersionKey)> = Vec::new();
776 for r in releases {
777 if r.moniker != "stable" && r.moniker != "longterm" {
778 continue;
779 }
780 let Some(key) = decompose_version_for_compare(&r.version) else {
781 continue;
782 };
783 if key < start_key || key > end_key {
784 continue;
785 }
786 selected.push((r.version.clone(), key));
787 }
788 selected.sort_by_key(|s| s.1);
789 selected.into_iter().map(|(v, _)| v).collect()
790}
791
792/// From release version strings (`X.Y.Z`), take the latest patch of each
793/// `(major, minor)` series inside `[start_key, end_key]`, dropping
794/// `-rc*` pre-releases, sorted ascending by version tuple.
795///
796/// Pure — the `--include-eol` counterpart to [`filter_and_sort_range`],
797/// run over the gregkh stable-mirror tags
798/// ([`crate::fetch::cached_stable_tags`]) unioned with the releases.json
799/// versions so an EOL series (dropped from releases.json) still
800/// contributes its highest point release. Uses the SAME
801/// `[start_key, end_key]` tuple bounds as `filter_and_sort_range`, so
802/// maintained and EOL series expand identically.
803fn select_series_latest_in_range(
804 versions: &[String],
805 start_key: VersionKey,
806 end_key: VersionKey,
807) -> Vec<String> {
808 use crate::kernel_path::decompose_version_for_compare;
809 use std::collections::HashMap;
810
811 // (major, minor) -> (patch, version string) of the highest release.
812 let mut best: HashMap<(u64, u64), (u64, String)> = HashMap::new();
813 for v in versions {
814 let Some(key) = decompose_version_for_compare(v) else {
815 continue;
816 };
817 // Drop pre-releases: a release maps its rc slot to u64::MAX (see
818 // decompose_version_for_compare), so `key.3 != MAX` marks an
819 // `-rcN` tag.
820 if key.3 != u64::MAX {
821 continue;
822 }
823 if key < start_key || key > end_key {
824 continue;
825 }
826 let series = (key.0, key.1);
827 let patch = key.2;
828 match best.get(&series) {
829 Some((p, _)) if *p >= patch => {}
830 _ => {
831 best.insert(series, (patch, v.clone()));
832 }
833 }
834 }
835 let mut out: Vec<(VersionKey, String)> = best
836 .into_values()
837 .map(|(_, v)| {
838 let key = decompose_version_for_compare(&v).expect("re-parses: it parsed once above");
839 (key, v)
840 })
841 .collect();
842 out.sort_by_key(|(k, _)| *k);
843 out.into_iter().map(|(_, v)| v).collect()
844}
845
846/// Resolve a `git+URL#tag=/#branch=/#sha=` kernel spec to a cache-entry
847/// directory.
848///
849/// Mirrors [`download_and_cache_version`] for the git source path. To
850/// avoid an unconditional fetch, it FIRST resolves `git_ref` to its
851/// commit via a KIND-directed ls-remote (a `Sha` resolves offline; a
852/// `Tag`/`Branch` matches its fully-qualified ref so a tag never aliases
853/// a same-named branch — see `crate::fetch::resolve_ref_commit`) and
854/// probes the cache for the key that resolution produces
855/// (`crate::fetch::git_cache_key`); on a hit it returns the cached
856/// entry WITHOUT any download.
857///
858/// On a cache miss the fetch is routed by source:
859/// - **GitHub** (`github.com/OWNER/REPO`, via
860/// `crate::fetch::github_archive_url`): a codeload `tar.gz` snapshot
861/// of the RESOLVED COMMIT (`crate::fetch::download_github_archive`) —
862/// no clone, and the exact-commit snapshot matches the cache key even
863/// if a branch tip advances between the ls-remote probe and the GET.
864/// - **Non-GitHub, or a ref whose ls-remote resolution failed**: a
865/// kind-directed shallow clone into a temp directory
866/// (`crate::fetch::git_clone_kinded` → `git_clone_tag` for a tag,
867/// `git_clone` for a branch; a `sha` is unsupported off GitHub). The
868/// clone resolves the tip authoritatively, catching a hit the
869/// ls-remote probe missed.
870///
871/// On a final miss it delegates to [`kernel_build_pipeline`] for
872/// configure/build/validate/cache. Returns the cache entry directory
873/// path — the same shape `download_and_cache_version` returns and
874/// callers feed into the [`crate::KTSTR_KERNEL_ENV`] export.
875///
876/// The cache key embeds `git_ref` verbatim (`/` and `..` sanitized —
877/// see `crate::fetch::git_cache_key`) alongside the resolved commit's
878/// full 40-hex hash, so two invocations with
879/// identical-sha-different-`git_ref` spellings remain distinct cache
880/// entries (collapsing those to one is separate future work — see
881/// [`crate::kernel_path::KernelId::Git`]).
882///
883/// `cli_label` matches the contract the sibling helpers
884/// (`download_and_cache_version`, `resolve_kernel_dir`) use:
885/// it prefixes diagnostic status output and is threaded into
886/// [`kernel_build_pipeline`].
887///
888/// `mp` is the progress group the download/clone bar is added to (the
889/// parallel resolve's shared group). Forwarded to the codeload download
890/// and the clone; `None` disables the bar.
891///
892/// Build flags shared with `cargo ktstr kernel build --kernel git+…`:
893/// `force` skips both cache probes so the ref is refetched and rebuilt;
894/// `clean`, `cpu_cap`, and `extra_kconfig` thread into
895/// [`kernel_build_pipeline`] (an `extra_kconfig` fragment also appends
896/// its `-xkc{hash}` suffix to the cache key so an extras build lands a
897/// distinct slot). The auto-discovery test path passes `false` / `None`
898/// for all four; only `kernel build` populates them.
899#[allow(clippy::too_many_arguments)]
900pub fn resolve_git_kernel(
901 url: &str,
902 git_ref: &str,
903 ref_kind: crate::kernel_path::GitRefKind,
904 cli_label: &str,
905 mp: Option<&crate::cli::FetchProgress>,
906 force: bool,
907 clean: bool,
908 cpu_cap: Option<crate::cli::CpuCap>,
909 extra_kconfig: Option<&str>,
910) -> Result<std::path::PathBuf> {
911 // Open the cache once: reused by the pre-fetch ls-remote probe, the
912 // post-fetch lookup, and the build pipeline below.
913 let cache = crate::cache::CacheDir::new()?;
914
915 // Resolve `git_ref` to its commit BEFORE the expensive fetch, using
916 // a KIND-directed ls-remote (a tag never aliases a same-named
917 // branch). With `git_ref` this keys the same entry the fetch would
918 // write, so a cache hit returns without any download. `Sha` resolves
919 // offline; a `Tag`/`Branch` ls-remote failure is non-fatal for the
920 // clone path (the clone resolves the tip authoritatively) but is
921 // fatal for the codeload path (below), which has no `.git` to read
922 // the commit back from.
923 let commit = crate::fetch::resolve_ref_commit(url, git_ref, ref_kind);
924 // `--force` skips the pre-fetch probe so the ref is always refetched
925 // and rebuilt. The `-xkc{hash}` suffix folds `--extra-kconfig` into
926 // the key so an extras build probes its own slot, not the baked-only
927 // one.
928 if !force && let Some(commit_hash) = &commit {
929 let mut cache_key = crate::fetch::git_cache_key(git_ref, commit_hash);
930 crate::cli::append_extra_kconfig_suffix(&mut cache_key, extra_kconfig);
931 if let Some(entry) = cache_lookup(&cache, &cache_key, cli_label) {
932 // Full hash keys the entry; show the familiar 7-hex prefix.
933 let short = &commit_hash[..7.min(commit_hash.len())];
934 let msg = format!("{cli_label}: git+{url} -> {short} cached; skipping fetch");
935 match mp {
936 Some(fp) => fp.println(&msg),
937 None => eprintln!("{msg}"),
938 }
939 return Ok(entry.path);
940 }
941 }
942
943 let tmp_dir = tempfile::TempDir::new()?;
944 // GitHub sources download the RESOLVED COMMIT's codeload `tar.gz`
945 // snapshot — no clone, and the exact-commit snapshot matches the
946 // cache key even if a branch tip advances mid-resolve.
947 // `github_archive_url` returns None for a non-GitHub URL. A ref that
948 // did not resolve to a commit (a `Tag`/`Branch` ls-remote failure —
949 // a `Sha` resolves offline, so it is never `None` here) has no
950 // codeload URL and falls through to the kind-directed clone, which
951 // resolves the tip authoritatively.
952 let github_archive = commit
953 .as_deref()
954 .and_then(|c| crate::fetch::github_archive_url(url, c));
955 let mut acquired = if let Some(archive_url) = github_archive {
956 let commit_hash = commit
957 .as_deref()
958 .expect("commit present — the archive URL was built from it");
959 crate::fetch::download_github_archive(
960 crate::fetch::shared_client(),
961 &archive_url,
962 git_ref,
963 commit_hash,
964 tmp_dir.path(),
965 cli_label,
966 mp,
967 )?
968 } else {
969 crate::fetch::git_clone_kinded(url, git_ref, ref_kind, tmp_dir.path(), cli_label, mp)?
970 };
971 // Fold --extra-kconfig into the fetched key so the post-fetch probe
972 // and the pipeline's cache store target the extras-aware slot
973 // (mirrors kernel_build_one). A no-op when extra_kconfig is None.
974 crate::cli::append_extra_kconfig_suffix(&mut acquired.cache_key, extra_kconfig);
975
976 // Re-check the cache post-fetch (skipped under --force): the clone
977 // resolves the tip authoritatively, catching a hit the ls-remote
978 // probe missed (a ref-name resolution difference, or a probe that
979 // failed) so an unchanged tip still skips the rebuild. (The codeload
980 // path's key is already the probe key, so this is a no-op there.)
981 if !force && let Some(entry) = cache_lookup(&cache, &acquired.cache_key, cli_label) {
982 return Ok(entry.path);
983 }
984
985 // `--force` fail-fast: if tests are holding the cache-entry lock,
986 // bail with the PID list rather than silently waiting to stomp the
987 // in-use entry (mirrors kernel_build_one). The guard drops before
988 // the pipeline runs.
989 if force {
990 let _force_check = cache.try_acquire_exclusive_lock(&acquired.cache_key)?;
991 }
992
993 // is_local_source = false: a freshly cloned tree is treated the same
994 // as a tarball download — no `make mrproper` skip-warning, no
995 // compile_commands.json generation (acquired.is_temp gates that
996 // inside the pipeline). `clean`, `cpu_cap`, and `extra_kconfig` come
997 // from `cargo ktstr kernel build`; the auto-discovery test path
998 // passes false / None.
999 let result = kernel_build_pipeline(
1000 &acquired,
1001 &cache,
1002 cli_label,
1003 clean,
1004 false,
1005 cpu_cap,
1006 extra_kconfig,
1007 mp,
1008 )?;
1009
1010 match result.entry {
1011 Some(entry) => Ok(entry.path),
1012 None => bail!(
1013 "kernel built from git+{url}#{git_ref} but cache store failed — \
1014 cannot return image from temporary directory"
1015 ),
1016 }
1017}
1018
1019/// Cache-hit signal returned from [`resolve_kernel_dir_to_entry`]
1020/// when a clean source tree's cache entry was found and reused
1021/// without invoking [`kernel_build_pipeline`].
1022///
1023/// Carries the cache key and the persisted `built_at` ISO-8601
1024/// timestamp so callers can render a user-facing line that names
1025/// both the cache identity and the build age. `None`
1026/// ([`KernelDirOutcome::cache_hit`] returns `None`) means the build
1027/// pipeline ran — either to populate the cache (clean-tree cache
1028/// miss) or to build directly without storing (dirty-tree path).
1029#[derive(Debug, Clone)]
1030pub struct KernelDirCacheHit {
1031 /// Cache key that resolved to this entry, e.g.
1032 /// `local-abc1234-x86_64-kc{suffix}` or
1033 /// `local-abc1234-x86_64-cfgdeadbeef-kc{suffix}` when the source
1034 /// tree carried a user `.config`.
1035 pub cache_key: String,
1036 /// ISO-8601 timestamp recorded in the entry's `metadata.json`
1037 /// at store time. Suitable for `humantime::parse_rfc3339`.
1038 pub built_at: String,
1039}
1040
1041/// Result bundle from [`resolve_kernel_dir_to_entry`].
1042///
1043/// Bundles the resolved boot-image directory, the cache-hit
1044/// signal, and the dirty-tree flag so callers do not have to
1045/// re-run `gix::open` to learn whether the build was reproducible.
1046/// The dirty flag is the single source of truth for downstream
1047/// label decoration ([`crate::test_support::sanitize_kernel_label`]'s
1048/// upstream caller appends `_dirty` so test reports show the run
1049/// used a non-reproducible build).
1050///
1051/// `non_exhaustive` so a future field (e.g. cache miss vs cache
1052/// store-failed distinction) can land without breaking external
1053/// destructuring. Construction goes through field literals at the
1054/// definition site only — every external consumer reads via the
1055/// public field accessors.
1056#[derive(Debug, Clone)]
1057#[non_exhaustive]
1058pub struct KernelDirOutcome {
1059 /// Directory that holds the resolved boot image.
1060 ///
1061 /// - Clean tree: cache entry directory under one of the
1062 /// `local-{hash7}-{arch}[-cfg{user_config}]-kc{suffix}` or
1063 /// `local-unknown-{path_hash}-{arch}-kc{suffix}` shapes (see
1064 /// [`crate::fetch::compose_local_cache_key`]); boot image at
1065 /// `<dir>/<image_name>`.
1066 /// - Dirty tree: canonical source-tree directory, boot image at
1067 /// `<dir>/arch/x86/boot/bzImage` (x86_64) or
1068 /// `<dir>/arch/arm64/boot/Image` (aarch64) — the kernel
1069 /// build-tree arch subdir (`x86`/`arm64`), not the
1070 /// arch_info() name (`x86_64`/`aarch64`).
1071 ///
1072 /// Both shapes are valid inputs to
1073 /// [`crate::kernel_path::find_image_in_dir`].
1074 pub dir: std::path::PathBuf,
1075 /// `Some` when the resolution short-circuited on a cache hit —
1076 /// the build pipeline did not run. `None` when the build
1077 /// pipeline ran (clean-tree miss-then-build OR dirty-tree
1078 /// build-without-store). [`is_dirty`](Self::is_dirty)
1079 /// distinguishes the two `None` cases.
1080 pub cache_hit: Option<KernelDirCacheHit>,
1081 /// Whether the source tree was non-reproducible. Union of two
1082 /// signals:
1083 ///
1084 /// - Acquire-time inspection by
1085 /// [`crate::fetch::local_source`] (uncommitted modifications
1086 /// before the build started, OR a non-git tree that has no
1087 /// commit hash to record).
1088 /// - Post-build re-check from
1089 /// [`crate::fetch::inspect_local_source_state`] (worktree
1090 /// edited, branch flipped, or commit landed during `make`).
1091 ///
1092 /// Either signal flips this to `true`. Always `false` on a cache
1093 /// hit — the cache lookup gate requires a clean tree at acquire
1094 /// time and the build pipeline does not run.
1095 pub is_dirty: bool,
1096}
1097
1098/// Resolve a source-tree path through the local-kernel cache,
1099/// returning a [`KernelDirOutcome`] that carries the boot-image
1100/// directory, the cache-hit signal, and the dirty-tree flag.
1101///
1102/// For a clean source tree:
1103/// - Cache hit → `outcome.dir` is the cache entry directory,
1104/// `outcome.cache_hit` is `Some(KernelDirCacheHit)`,
1105/// `outcome.is_dirty` is `false`. The build pipeline does not
1106/// run.
1107/// - Cache miss, no mid-build mutation → runs
1108/// [`kernel_build_pipeline`] which builds in the source tree and
1109/// stores a stripped vmlinux + boot image under the cache entry;
1110/// `outcome.dir` is the cache entry directory,
1111/// `outcome.cache_hit` is `None`, `outcome.is_dirty` is `false`.
1112/// - Cache miss, mid-build mutation observed by the pipeline's
1113/// post-build re-check → the cache store is skipped to avoid
1114/// recording a stale identity, `outcome.dir` is the canonical
1115/// source-tree directory, `outcome.cache_hit` is `None`,
1116/// `outcome.is_dirty` is `true`.
1117///
1118/// For a dirty source tree:
1119/// - [`kernel_build_pipeline`] skips the cache store
1120/// (`is_dirty` short-circuit at the cache-store boundary) and
1121/// returns the source-tree image. `outcome.dir` is the
1122/// canonical source-tree directory (boot image at
1123/// `<source>/arch/x86/boot/bzImage` (x86_64) or
1124/// `<source>/arch/arm64/boot/Image` (aarch64) — the kernel
1125/// build-tree arch subdir (`x86`/`arm64`), not the
1126/// arch_info() name),
1127/// `outcome.cache_hit` is `None`, `outcome.is_dirty` is
1128/// `true`. Callers use the dirty flag to mark the run as
1129/// non-reproducible in test reports — e.g. `cargo-ktstr`'s
1130/// Path-spec resolver appends `_dirty` to the kernel label
1131/// so a `path_linux_a3b1c2_dirty` row in the gauntlet output
1132/// surfaces the divergence from the cache-stored
1133/// `path_linux_a3b1c2` clean variant.
1134///
1135/// Both directory return shapes are valid inputs to
1136/// [`crate::kernel_path::find_image_in_dir`], which probes both
1137/// layouts. Callers that need the boot-image FILE path (not the
1138/// directory) should use [`resolve_kernel_dir`] instead — that
1139/// function applies the same pipeline but returns the image path.
1140///
1141/// Used by `cargo-ktstr`'s Path-spec resolver to wire `--kernel
1142/// PATH` invocations through the same cache pipeline that
1143/// Version/CacheKey/Git specs use, so a clean source-tree rebuild
1144/// hits the cache instead of re-running `make`.
1145///
1146/// `cli_label` prefixes status output and is threaded into
1147/// [`kernel_build_pipeline`]'s diagnostic surface. `cpu_cap`
1148/// forwards the resource-budget cap; `None` keeps the
1149/// 30%-of-allowed default. See [`resolve_kernel_dir`] for the
1150/// matching image-returning sibling's `cpu_cap` rationale —
1151/// identical here because both functions reach the same pipeline.
1152pub fn resolve_kernel_dir_to_entry(
1153 path: &std::path::Path,
1154 cli_label: &str,
1155 cpu_cap: Option<crate::vmm::host_topology::CpuCap>,
1156) -> Result<KernelDirOutcome> {
1157 let acquired = acquire_local_source_tree(path)?;
1158 let cache_key = acquired.cache_key.clone();
1159 let is_dirty = acquired.is_dirty;
1160 // Open the cache once and reuse for both the clean-tree
1161 // lookup and the post-build store. Both legs need the same
1162 // root resolution; opening twice is wasted work and risks
1163 // a TOCTOU split if `KTSTR_CACHE_DIR` changes between calls.
1164 // A failure here is fatal — we cannot proceed without a cache
1165 // root for either lookup or store.
1166 let cache = crate::cache::CacheDir::new()?;
1167
1168 // Clean trees: cache lookup before build.
1169 if !is_dirty && let Some(entry) = cache_lookup(&cache, &cache_key, cli_label) {
1170 // `entry.path` is the cache entry directory; the boot
1171 // image lives at `<entry.path>/<image_name>`. Verify the
1172 // image is actually present before returning, so a
1173 // partially-corrupt entry doesn't bypass the
1174 // build-and-restore path.
1175 if entry.image_path().exists() {
1176 let hit = KernelDirCacheHit {
1177 cache_key: cache_key.clone(),
1178 built_at: entry.metadata.built_at.clone(),
1179 };
1180 return Ok(KernelDirOutcome {
1181 dir: entry.path,
1182 cache_hit: Some(hit),
1183 // Cache-hit gate already required clean tree —
1184 // restate the invariant in the outcome instead of
1185 // reading `is_dirty` again, so the bit cannot drift
1186 // if the gate condition above evolves.
1187 is_dirty: false,
1188 });
1189 }
1190 }
1191
1192 // extra_kconfig = None: this path serves cargo ktstr
1193 // test/coverage/llvm-cov / shell / verifier resolution, none of
1194 // which expose `--extra-kconfig`. The flag is `cargo ktstr
1195 // kernel build`-only and feeds extras directly through that
1196 // dispatch.
1197 let result = kernel_build_pipeline(
1198 &acquired, &cache, cli_label, false, true, cpu_cap, None, None,
1199 )?;
1200
1201 // Prefer the cached entry directory (stable across rebuilds).
1202 // For dirty trees, `entry` is `None` — fall back to the
1203 // canonical source directory, which `local_source` already
1204 // resolved into `acquired.source_dir`.
1205 let dir = match result.entry {
1206 Some(entry) => entry.path,
1207 None => acquired.source_dir,
1208 };
1209 // The pipeline observes the dirty signal twice: once at acquire
1210 // time (captured in `is_dirty` above) and once via the post-build
1211 // re-check that detects mid-build mutations. Either source
1212 // flipping the bit means the run is non-reproducible — surface
1213 // the union here so the kernel-label downstream gets the `_dirty`
1214 // suffix even when the tree was clean at acquire and only
1215 // dirtied during `make`.
1216 Ok(KernelDirOutcome {
1217 dir,
1218 cache_hit: None,
1219 is_dirty: is_dirty || result.post_build_is_dirty,
1220 })
1221}
1222
1223/// Resolve a kernel directory: auto-build from source tree.
1224///
1225/// Requires Makefile + Kconfig. Checks cache for clean trees,
1226/// delegates to [`kernel_build_pipeline`] on miss. `cli_label`
1227/// prefixes status output and is passed through to
1228/// [`kernel_build_pipeline`] as the diagnostic label.
1229///
1230/// `cpu_cap` forwards the resource-budget cap to the pipeline.
1231/// `None` is the default for non-kernel-build callers
1232/// (test/coverage/shell auto-build paths) — `--cpu-cap` lives on
1233/// the explicit kernel-build entrypoint, not test-running
1234/// commands, because the auto-build-on-miss path already runs
1235/// inside a test invocation where perf-mode constraints dominate.
1236pub fn resolve_kernel_dir(
1237 path: &std::path::Path,
1238 cli_label: &str,
1239 cpu_cap: Option<crate::vmm::host_topology::CpuCap>,
1240) -> Result<std::path::PathBuf> {
1241 let acquired = acquire_local_source_tree(path)?;
1242 let cache_key = acquired.cache_key.clone();
1243 // Open the cache once and reuse for both the clean-tree
1244 // lookup and the post-build store. Both legs need the same
1245 // root resolution; opening twice is wasted work and risks
1246 // a TOCTOU split if `KTSTR_CACHE_DIR` changes between calls.
1247 // A failure here is fatal — we cannot proceed without a cache
1248 // root for either lookup or store. Mirrors the same hoist
1249 // applied in [`resolve_kernel_dir_to_entry`].
1250 let cache = crate::cache::CacheDir::new()?;
1251
1252 // Clean trees: cache lookup before build.
1253 // Dirty trees: skip cache, always build.
1254 if !acquired.is_dirty
1255 && let Some(entry) = cache_lookup(&cache, &cache_key, cli_label)
1256 {
1257 let image = entry.image_path();
1258 if image.exists() {
1259 success(&format!("{cli_label}: using cached kernel {cache_key}"));
1260 return Ok(image);
1261 }
1262 }
1263
1264 // extra_kconfig = None: matches the sibling
1265 // `resolve_kernel_dir_to_entry` rationale — `--extra-kconfig` is
1266 // a `cargo ktstr kernel build`-only flag.
1267 let result = kernel_build_pipeline(
1268 &acquired, &cache, cli_label, false, true, cpu_cap, None, None,
1269 )?;
1270
1271 // Prefer the cached image path (stable across rebuilds).
1272 match result.entry {
1273 Some(entry) => Ok(entry.image_path()),
1274 None => Ok(result.image_path),
1275 }
1276}
1277
1278/// Validate `path` is a kernel source tree (Makefile + Kconfig at
1279/// the root) and return the [`AcquiredSource`](crate::fetch::AcquiredSource)
1280/// computed by [`crate::fetch::local_source`].
1281///
1282/// Shared across [`resolve_kernel_dir`] and
1283/// [`resolve_kernel_dir_to_entry`] so the validation diagnostic
1284/// and `local_source` error stringification live in one place.
1285fn acquire_local_source_tree(path: &Path) -> Result<crate::fetch::AcquiredSource> {
1286 let is_source_tree = path.join("Makefile").exists() && path.join("Kconfig").exists();
1287 if !is_source_tree {
1288 bail!(
1289 "no kernel image found in {} (not a kernel source tree — \
1290 missing Makefile or Kconfig)",
1291 path.display()
1292 );
1293 }
1294 crate::fetch::local_source(path).map_err(|e| anyhow::anyhow!("{e}"))
1295}
1296
1297#[cfg(test)]
1298mod tests {
1299 use super::*;
1300
1301 /// Unset env: returns the host-CPU fallback, never zero.
1302 #[test]
1303 fn resolve_kernel_parallelism_unset_returns_host_default() {
1304 use crate::test_support::test_helpers::{EnvVarGuard, lock_env};
1305 let _lock = lock_env();
1306 let _guard = EnvVarGuard::remove(crate::KTSTR_KERNEL_PARALLELISM_ENV);
1307 let n = resolve_kernel_parallelism();
1308 assert!(
1309 n >= 1,
1310 "fallback must yield at least 1; got {n} which would defeat \
1311 ThreadPoolBuilder::num_threads",
1312 );
1313 }
1314
1315 /// Valid usize override: env-supplied value wins.
1316 #[test]
1317 fn resolve_kernel_parallelism_valid_override_wins() {
1318 use crate::test_support::test_helpers::{EnvVarGuard, lock_env};
1319 let _lock = lock_env();
1320 let _guard = EnvVarGuard::set(crate::KTSTR_KERNEL_PARALLELISM_ENV, "4");
1321 assert_eq!(
1322 resolve_kernel_parallelism(),
1323 4,
1324 "valid usize env value must override the host-CPU default",
1325 );
1326 }
1327
1328 /// Zero is sentinel — falls through to default.
1329 #[test]
1330 fn resolve_kernel_parallelism_zero_falls_through_to_default() {
1331 use crate::test_support::test_helpers::{EnvVarGuard, lock_env};
1332 let _lock = lock_env();
1333 let _guard = EnvVarGuard::set(crate::KTSTR_KERNEL_PARALLELISM_ENV, "0");
1334 let n = resolve_kernel_parallelism();
1335 assert!(
1336 n >= 1,
1337 "zero env value must fall through to host-CPU default; got {n}",
1338 );
1339 }
1340
1341 /// Unparseable falls through.
1342 #[test]
1343 fn resolve_kernel_parallelism_unparseable_falls_through_to_default() {
1344 use crate::test_support::test_helpers::{EnvVarGuard, lock_env};
1345 let _lock = lock_env();
1346 let _guard = EnvVarGuard::set(crate::KTSTR_KERNEL_PARALLELISM_ENV, "abc");
1347 let n = resolve_kernel_parallelism();
1348 assert!(n >= 1);
1349 }
1350
1351 /// Negative falls through.
1352 #[test]
1353 fn resolve_kernel_parallelism_negative_falls_through_to_default() {
1354 use crate::test_support::test_helpers::{EnvVarGuard, lock_env};
1355 let _lock = lock_env();
1356 let _guard = EnvVarGuard::set(crate::KTSTR_KERNEL_PARALLELISM_ENV, "-1");
1357 let n = resolve_kernel_parallelism();
1358 assert!(n >= 1);
1359 }
1360
1361 /// Trims whitespace.
1362 #[test]
1363 fn resolve_kernel_parallelism_trims_surrounding_whitespace() {
1364 use crate::test_support::test_helpers::{EnvVarGuard, lock_env};
1365 let _lock = lock_env();
1366 let _guard = EnvVarGuard::set(crate::KTSTR_KERNEL_PARALLELISM_ENV, " 8 ");
1367 assert_eq!(resolve_kernel_parallelism(), 8);
1368 }
1369
1370 /// Pin env-var name literal.
1371 #[test]
1372 fn ktstr_kernel_parallelism_env_const_matches_literal() {
1373 assert_eq!(
1374 crate::KTSTR_KERNEL_PARALLELISM_ENV,
1375 "KTSTR_KERNEL_PARALLELISM",
1376 );
1377 }
1378
1379 #[test]
1380 fn resolve_in_path_finds_sh() {
1381 let result = resolve_in_path(std::path::Path::new("sh"));
1382 assert!(result.is_some(), "sh should be in PATH");
1383 assert!(result.unwrap().exists());
1384 }
1385
1386 #[test]
1387 fn resolve_in_path_nonexistent() {
1388 let result = resolve_in_path(std::path::Path::new("nonexistent_binary_xyz_12345"));
1389 assert!(result.is_none());
1390 }
1391
1392 #[test]
1393 fn resolve_include_files_single_file() {
1394 let dir = tempfile::TempDir::new().unwrap();
1395 let file = dir.path().join("test.txt");
1396 std::fs::write(&file, "hello").unwrap();
1397 let result = resolve_include_files(&[file]).unwrap();
1398 assert_eq!(result.len(), 1);
1399 assert!(result[0].0.contains("test.txt"));
1400 }
1401
1402 #[test]
1403 fn resolve_include_files_nonexistent() {
1404 let result = resolve_include_files(&[std::path::PathBuf::from("/nonexistent/file.txt")]);
1405 assert!(result.is_err());
1406 }
1407
1408 #[test]
1409 fn resolve_include_files_bare_name_in_path() {
1410 let result = resolve_include_files(&[std::path::PathBuf::from("sh")]);
1411 assert!(result.is_ok());
1412 let entries = result.unwrap();
1413 assert_eq!(entries.len(), 1);
1414 assert!(entries[0].0.contains("sh"));
1415 }
1416
1417 /// Inverted-range diagnostic must surface ahead of the generic
1418 /// "not yet supported" bail when resolve_cached_kernel sees a Range.
1419 #[test]
1420 fn resolve_cached_kernel_surfaces_inverted_range_diagnostic() {
1421 let id = crate::kernel_path::KernelId::Range {
1422 start: "6.16".to_string(),
1423 end: "6.12".to_string(),
1424 syntax_inclusive: false,
1425 };
1426 let err =
1427 resolve_cached_kernel(&id, "ktstr-test", None).expect_err("inverted range must error");
1428 let msg = format!("{err:#}");
1429 assert!(
1430 msg.contains("inverted kernel range"),
1431 "validate() diagnostic must surface ahead of the generic \
1432 'not yet supported' bail; got: {msg}",
1433 );
1434 assert!(msg.contains("6.12..6.16"));
1435 assert!(!msg.contains("not yet supported in this context"));
1436 }
1437
1438 /// Same wiring guarantee for resolve_kernel_image.
1439 #[test]
1440 fn resolve_kernel_image_surfaces_inverted_range_diagnostic() {
1441 let policy = KernelResolvePolicy {
1442 cli_label: "ktstr-test",
1443 accept_raw_image: false,
1444 };
1445 let err = resolve_kernel_image(Some("6.16..6.12"), &policy)
1446 .expect_err("inverted range must error");
1447 let msg = format!("{err:#}");
1448 assert!(
1449 msg.contains("inverted kernel range"),
1450 "validate() diagnostic must surface ahead of the generic bail; got: {msg}",
1451 );
1452 assert!(msg.contains("6.12..6.16"));
1453 assert!(!msg.contains("not yet supported in this context"));
1454 }
1455
1456 fn release(moniker: &str, version: &str) -> crate::fetch::Release {
1457 crate::fetch::Release {
1458 moniker: moniker.to_string(),
1459 version: version.to_string(),
1460 }
1461 }
1462
1463 /// Stable+longterm rows inside the interval are kept; mainline,
1464 /// linux-next, and rows outside the interval are dropped.
1465 #[test]
1466 fn filter_and_sort_range_basic() {
1467 use crate::kernel_path::decompose_version_for_compare;
1468 let releases = vec![
1469 release("mainline", "6.18-rc2"),
1470 release("stable", "6.16.5"),
1471 release("longterm", "6.12.40"),
1472 release("linux-next", "6.18-rc2-next-20260420"),
1473 release("longterm", "6.6.99"),
1474 release("stable", "6.14.10"),
1475 release("stable", "6.10.0"),
1476 ];
1477 let start_key = decompose_version_for_compare("6.12").unwrap();
1478 let end_key = decompose_version_for_compare("6.16.5").unwrap();
1479 let out = filter_and_sort_range(&releases, start_key, end_key);
1480 assert_eq!(
1481 out,
1482 vec![
1483 "6.12.40".to_string(),
1484 "6.14.10".to_string(),
1485 "6.16.5".to_string(),
1486 ],
1487 );
1488 }
1489
1490 /// Endpoints absent from releases.json still bracket correctly.
1491 #[test]
1492 fn filter_and_sort_range_endpoints_absent_from_releases() {
1493 use crate::kernel_path::decompose_version_for_compare;
1494 let releases = vec![
1495 release("stable", "6.12.5"),
1496 release("stable", "6.14.2"),
1497 release("stable", "6.15.0"),
1498 ];
1499 let start_key = decompose_version_for_compare("6.10").unwrap();
1500 let end_key = decompose_version_for_compare("6.16").unwrap();
1501 let out = filter_and_sort_range(&releases, start_key, end_key);
1502 assert_eq!(
1503 out,
1504 vec![
1505 "6.12.5".to_string(),
1506 "6.14.2".to_string(),
1507 "6.15.0".to_string(),
1508 ],
1509 );
1510 }
1511
1512 /// Inclusive at both endpoints.
1513 #[test]
1514 fn filter_and_sort_range_inclusive_both_endpoints() {
1515 use crate::kernel_path::decompose_version_for_compare;
1516 let releases = vec![
1517 release("stable", "6.12.5"),
1518 release("stable", "6.13.0"),
1519 release("stable", "6.14.2"),
1520 ];
1521 let start_key = decompose_version_for_compare("6.12.5").unwrap();
1522 let end_key = decompose_version_for_compare("6.14.2").unwrap();
1523 let out = filter_and_sort_range(&releases, start_key, end_key);
1524 assert_eq!(
1525 out,
1526 vec![
1527 "6.12.5".to_string(),
1528 "6.13.0".to_string(),
1529 "6.14.2".to_string(),
1530 ],
1531 );
1532 }
1533
1534 /// rc-as-MAX ordering for rc tags under stable moniker.
1535 #[test]
1536 fn filter_and_sort_range_rc_under_stable_moniker_orders_after_release() {
1537 use crate::kernel_path::decompose_version_for_compare;
1538 let releases = vec![
1539 release("stable", "6.14.0-rc3"),
1540 release("stable", "6.14.0"),
1541 release("stable", "6.13.0"),
1542 ];
1543 let start_key = decompose_version_for_compare("6.13").unwrap();
1544 let end_key = decompose_version_for_compare("6.15").unwrap();
1545 let out = filter_and_sort_range(&releases, start_key, end_key);
1546 assert_eq!(
1547 out,
1548 vec![
1549 "6.13.0".to_string(),
1550 "6.14.0-rc3".to_string(),
1551 "6.14.0".to_string(),
1552 ],
1553 );
1554 }
1555
1556 /// Empty interval returns empty vec.
1557 #[test]
1558 fn filter_and_sort_range_empty_when_no_overlap() {
1559 use crate::kernel_path::decompose_version_for_compare;
1560 let releases = vec![release("stable", "5.10.0"), release("stable", "5.15.0")];
1561 let start_key = decompose_version_for_compare("6.10").unwrap();
1562 let end_key = decompose_version_for_compare("6.16").unwrap();
1563 let out = filter_and_sort_range(&releases, start_key, end_key);
1564 assert!(out.is_empty(), "no overlap → empty result, got {out:?}");
1565 }
1566
1567 /// Mainline/linux-next monikers are dropped even when in interval.
1568 #[test]
1569 fn filter_and_sort_range_drops_non_stable_monikers() {
1570 use crate::kernel_path::decompose_version_for_compare;
1571 let releases = vec![
1572 release("mainline", "6.14.0"),
1573 release("linux-next", "6.14.0-next-20260420"),
1574 release("stable", "6.14.5"),
1575 ];
1576 let start_key = decompose_version_for_compare("6.14").unwrap();
1577 let end_key = decompose_version_for_compare("6.15").unwrap();
1578 let out = filter_and_sort_range(&releases, start_key, end_key);
1579 assert_eq!(out, vec!["6.14.5".to_string()]);
1580 }
1581
1582 /// Unparseable versions silently dropped.
1583 #[test]
1584 fn filter_and_sort_range_drops_unparseable_versions() {
1585 use crate::kernel_path::decompose_version_for_compare;
1586 let releases = vec![
1587 release("stable", "6.14.0"),
1588 release("stable", "embargoed-cve-tag"),
1589 release("stable", "6.14.5"),
1590 ];
1591 let start_key = decompose_version_for_compare("6.14").unwrap();
1592 let end_key = decompose_version_for_compare("6.15").unwrap();
1593 let out = filter_and_sort_range(&releases, start_key, end_key);
1594 assert_eq!(out, vec!["6.14.0".to_string(), "6.14.5".to_string()]);
1595 }
1596
1597 /// `select_series_latest_in_range` keeps only the highest patch of
1598 /// each `(major, minor)` series — the `--include-eol` path collapses
1599 /// a mirror's full per-series tag history to one release per series.
1600 #[test]
1601 fn select_series_latest_in_range_picks_highest_patch_per_series() {
1602 use crate::kernel_path::decompose_version_for_compare;
1603 let versions = vec![
1604 "6.11.0".to_string(),
1605 "6.11.5".to_string(),
1606 "6.11.2".to_string(),
1607 "6.12.0".to_string(),
1608 "6.12.10".to_string(),
1609 "6.13.1".to_string(),
1610 ];
1611 let start_key = decompose_version_for_compare("6.10").unwrap();
1612 let end_key = decompose_version_for_compare("6.16").unwrap();
1613 let out = select_series_latest_in_range(&versions, start_key, end_key);
1614 assert_eq!(
1615 out,
1616 vec![
1617 "6.11.5".to_string(),
1618 "6.12.10".to_string(),
1619 "6.13.1".to_string()
1620 ],
1621 "one release per series, highest patch, ascending",
1622 );
1623 }
1624
1625 /// `-rc` pre-releases are dropped: a release maps its rc slot to
1626 /// `u64::MAX`, so `key.3 != MAX` marks an rc, which the selector
1627 /// skips (mirrors the moniker filter on the non-EOL path).
1628 #[test]
1629 fn select_series_latest_in_range_drops_rc() {
1630 use crate::kernel_path::decompose_version_for_compare;
1631 let versions = vec![
1632 "6.12.5".to_string(),
1633 "6.16-rc1".to_string(),
1634 "6.13.2".to_string(),
1635 ];
1636 let start_key = decompose_version_for_compare("6.10").unwrap();
1637 let end_key = decompose_version_for_compare("6.20").unwrap();
1638 let out = select_series_latest_in_range(&versions, start_key, end_key);
1639 assert_eq!(
1640 out,
1641 vec!["6.12.5".to_string(), "6.13.2".to_string()],
1642 "rc tag must be excluded",
1643 );
1644 }
1645
1646 /// Bounds are honored: series below `start_key` or above `end_key`
1647 /// are dropped, matching `filter_and_sort_range`'s interval so the
1648 /// EOL and maintained sets expand over the same span.
1649 #[test]
1650 fn select_series_latest_in_range_respects_bounds() {
1651 use crate::kernel_path::decompose_version_for_compare;
1652 let versions = vec![
1653 "6.9.9".to_string(),
1654 "6.12.5".to_string(),
1655 "6.20.0".to_string(),
1656 ];
1657 let start_key = decompose_version_for_compare("6.10").unwrap();
1658 let end_key = decompose_version_for_compare("6.16").unwrap();
1659 let out = select_series_latest_in_range(&versions, start_key, end_key);
1660 assert_eq!(out, vec!["6.12.5".to_string()]);
1661 }
1662
1663 /// Unparseable tag strings are dropped rather than aborting the
1664 /// whole expansion (a mirror may carry non-version tags).
1665 #[test]
1666 fn select_series_latest_in_range_drops_unparseable() {
1667 use crate::kernel_path::decompose_version_for_compare;
1668 let versions = vec![
1669 "6.12.5".to_string(),
1670 "embargoed-cve-tag".to_string(),
1671 "6.13.0".to_string(),
1672 ];
1673 let start_key = decompose_version_for_compare("6.10").unwrap();
1674 let end_key = decompose_version_for_compare("6.16").unwrap();
1675 let out = select_series_latest_in_range(&versions, start_key, end_key);
1676 assert_eq!(out, vec!["6.12.5".to_string(), "6.13.0".to_string()]);
1677 }
1678
1679 /// `range_bounds` widens a 2-component `MAJOR.MINOR` END to the
1680 /// whole series (patch/rc → u64::MAX) while START keeps its natural
1681 /// `.0` floor — the task-that-makes-`6.11..6.14`-cover-`6.14.x` pin.
1682 #[test]
1683 fn range_bounds_widens_two_component_end() {
1684 let (start_key, end_key) = range_bounds("6.11", "6.14").expect("valid endpoints");
1685 assert_eq!(start_key, (6, 11, 0, u64::MAX), "START floor is .0");
1686 assert_eq!(
1687 end_key,
1688 (6, 14, u64::MAX, u64::MAX),
1689 "2-component END widened to series ceiling",
1690 );
1691 }
1692
1693 /// An explicit-patch END is an exact bound, NOT widened.
1694 #[test]
1695 fn range_bounds_three_component_end_is_exact() {
1696 let (_, end_key) = range_bounds("6.11", "6.14.2").expect("valid endpoints");
1697 assert_eq!(end_key, (6, 14, 2, u64::MAX), "explicit patch is exact");
1698 }
1699
1700 /// An `-rc` END keeps its exact rc bound (not widened).
1701 #[test]
1702 fn range_bounds_rc_end_is_exact() {
1703 let (_, end_key) = range_bounds("6.11", "6.16-rc3").expect("valid endpoints");
1704 assert_eq!(end_key, (6, 16, 0, 3), "rc END keeps its rc slot");
1705 }
1706
1707 /// `range_bounds` rejects unparseable endpoints (both sides).
1708 #[test]
1709 fn range_bounds_rejects_unparseable() {
1710 assert!(range_bounds("garbage", "6.14").is_err());
1711 assert!(range_bounds("6.10", "garbage").is_err());
1712 }
1713
1714 /// End-to-end of the widening: with `range_bounds("6.11","6.14")`,
1715 /// `select_series_latest_in_range` keeps the HIGHEST 6.14 patch, not
1716 /// just `6.14.0`. This is the regression pin for the END series being
1717 /// series-inclusive (the `6.11..6.14 → whole 6.14 series` fix).
1718 #[test]
1719 fn range_bounds_end_series_covers_high_patch() {
1720 let (start_key, end_key) = range_bounds("6.11", "6.14").expect("valid endpoints");
1721 let versions = vec![
1722 "6.11.0".to_string(),
1723 "6.14.0".to_string(),
1724 "6.14.9".to_string(),
1725 ];
1726 let out = select_series_latest_in_range(&versions, start_key, end_key);
1727 assert_eq!(
1728 out,
1729 vec!["6.11.0".to_string(), "6.14.9".to_string()],
1730 "END series contributes its HIGHEST patch, not just .0",
1731 );
1732 }
1733
1734 /// `combine_range_versions` with `include_eol=false` returns the
1735 /// maintained set verbatim, ignoring any mirror tags.
1736 #[test]
1737 fn combine_range_versions_ignores_mirror_when_eol_off() {
1738 let (start_key, end_key) = range_bounds("6.10", "6.20").unwrap();
1739 let maintained = vec!["6.11.5".to_string()];
1740 let tags = vec!["6.12.10".to_string()];
1741 let out = combine_range_versions(
1742 maintained.clone(),
1743 Some(tags.as_slice()),
1744 start_key,
1745 end_key,
1746 false,
1747 );
1748 assert_eq!(out, maintained, "eol off must ignore mirror tags");
1749 }
1750
1751 /// `--include-eol` union fills a releases.json gap with EOL series
1752 /// from the mirror.
1753 #[test]
1754 fn combine_range_versions_union_fills_gap() {
1755 let (start_key, end_key) = range_bounds("6.10", "6.14").unwrap();
1756 let maintained = vec!["6.11.5".to_string()];
1757 let tags = vec!["6.12.10".to_string(), "6.13.2".to_string()];
1758 let out =
1759 combine_range_versions(maintained, Some(tags.as_slice()), start_key, end_key, true);
1760 assert_eq!(
1761 out,
1762 vec![
1763 "6.11.5".to_string(),
1764 "6.12.10".to_string(),
1765 "6.13.2".to_string()
1766 ],
1767 );
1768 }
1769
1770 /// Best-data-wins: the higher patch is kept regardless of which
1771 /// source carries it (mirror newer than releases.json, and vice
1772 /// versa — a stale mirror never regresses a maintained version).
1773 #[test]
1774 fn combine_range_versions_best_data_wins() {
1775 let (start_key, end_key) = range_bounds("6.10", "6.20").unwrap();
1776 // Mirror newer than releases.json.
1777 let out = combine_range_versions(
1778 vec!["6.14.5".to_string()],
1779 Some(["6.14.9".to_string()].as_slice()),
1780 start_key,
1781 end_key,
1782 true,
1783 );
1784 assert_eq!(out, vec!["6.14.9".to_string()], "mirror-newer wins");
1785 // Stale mirror must not regress the maintained version.
1786 let out = combine_range_versions(
1787 vec!["6.14.9".to_string()],
1788 Some(["6.14.5".to_string()].as_slice()),
1789 start_key,
1790 end_key,
1791 true,
1792 );
1793 assert_eq!(
1794 out,
1795 vec!["6.14.9".to_string()],
1796 "stale mirror never regresses"
1797 );
1798 }
1799
1800 /// Mirror-fetch failure (`None`) with `include_eol=true` falls back
1801 /// to the maintained set alone.
1802 #[test]
1803 fn combine_range_versions_mirror_failure_falls_back() {
1804 let (start_key, end_key) = range_bounds("6.10", "6.20").unwrap();
1805 let maintained = vec!["6.11.5".to_string()];
1806 let out = combine_range_versions(maintained.clone(), None, start_key, end_key, true);
1807 assert_eq!(out, maintained, "mirror failure falls back to maintained");
1808 }
1809
1810 /// The empty-range `--include-eol` hint fires only when the flag was
1811 /// NOT set (setting it already consulted the mirror).
1812 #[test]
1813 fn empty_range_hint_only_when_flag_unset() {
1814 assert!(empty_range_hint(false).contains("--include-eol"));
1815 assert_eq!(empty_range_hint(true), "");
1816 }
1817
1818 /// expand_kernel_range rejects unparseable start endpoint.
1819 #[test]
1820 fn expand_kernel_range_rejects_unparseable_start() {
1821 let err = expand_kernel_range("garbage", "6.14", "ktstr-test", false)
1822 .expect_err("unparseable start must error");
1823 let msg = format!("{err:#}");
1824 assert!(msg.contains("kernel range start `garbage`"));
1825 }
1826
1827 #[test]
1828 fn expand_kernel_range_rejects_unparseable_end() {
1829 let err = expand_kernel_range("6.10", "garbage", "ktstr-test", false)
1830 .expect_err("unparseable end must error");
1831 let msg = format!("{err:#}");
1832 assert!(msg.contains("kernel range end `garbage`"));
1833 }
1834
1835 // ---------------------------------------------------------------
1836 // resolve_kernel_dir_to_entry — success-path tests
1837 // ---------------------------------------------------------------
1838 //
1839 // Error paths (nonexistent path, not-a-source-tree) live next to
1840 // [`resolve_path_kernel`] in `bin/cargo_ktstr/kernel/mod.rs`. The success
1841 // paths exercise the full resolve → cache-lookup → outcome
1842 // pipeline with a real Makefile / Kconfig fixture, an
1843 // isolated `KTSTR_CACHE_DIR`, and a pre-populated cache entry
1844 // for the cache-hit case. The cache-miss + dirty-tree branches
1845 // are exercised through their predicate (`is_dirty=true` ⇒
1846 // skip cache lookup) without actually invoking
1847 // `kernel_build_pipeline`'s `make` subprocess — that would
1848 // require a real kernel toolchain and exceed unit-test scope.
1849 // The `is_dirty` branch is exercised by mutating the worktree
1850 // after commit and asserting the cache lookup is skipped (the
1851 // pre-populated entry is still present, so a successful lookup
1852 // would land it as the outcome — failing to do so proves the
1853 // dirty short-circuit fires).
1854
1855 /// Initialise a git repo with one committed file, mirroring
1856 /// the helper in `fetch.rs`. Inlined here so the
1857 /// `resolve_kernel_dir_to_entry` tests are self-contained
1858 /// rather than reaching across the test-module boundary.
1859 /// `dir` MUST exist; the helper does not create it.
1860 fn init_repo_with_commit_for_resolve_test(dir: &std::path::Path) {
1861 use std::process::Command;
1862 let run = |args: &[&str]| {
1863 let out = Command::new("git")
1864 .args(args)
1865 .current_dir(dir)
1866 .env("GIT_CONFIG_GLOBAL", "/dev/null")
1867 .env("GIT_CONFIG_SYSTEM", "/dev/null")
1868 .env("GIT_AUTHOR_NAME", "ktstr-test")
1869 .env("GIT_AUTHOR_EMAIL", "ktstr-test@localhost")
1870 .env("GIT_COMMITTER_NAME", "ktstr-test")
1871 .env("GIT_COMMITTER_EMAIL", "ktstr-test@localhost")
1872 .output()
1873 .expect("spawn git");
1874 assert!(
1875 out.status.success(),
1876 "git {:?} failed: {}",
1877 args,
1878 String::from_utf8_lossy(&out.stderr)
1879 );
1880 };
1881 run(&["init", "-q", "-b", "main"]);
1882 std::fs::write(dir.join("Makefile"), "# kernel makefile fixture\n").unwrap();
1883 std::fs::write(dir.join("Kconfig"), "# kernel kconfig fixture\n").unwrap();
1884 std::fs::write(dir.join("README"), "fixture\n").unwrap();
1885 run(&["add", "Makefile", "Kconfig", "README"]);
1886 run(&[
1887 "-c",
1888 "commit.gpgsign=false",
1889 "commit",
1890 "-q",
1891 "-m",
1892 "initial",
1893 ]);
1894 }
1895
1896 /// Pre-populate a cache entry under `cache_root/{cache_key}/`
1897 /// containing a synthetic boot image and a `metadata.json`
1898 /// marking the entry as a [`crate::cache::KernelSource::Local`]
1899 /// build. Returns the entry path. The metadata's
1900 /// `source_tree_path` is NOT pinned to the test's source tree
1901 /// — `resolve_kernel_dir_to_entry`'s lookup gates only on
1902 /// cache key match, so any persisted metadata that round-trips
1903 /// is sufficient for the cache-hit assertion.
1904 fn populate_cache_entry_for_resolve_test(
1905 cache_root: &std::path::Path,
1906 cache_key: &str,
1907 ) -> std::path::PathBuf {
1908 let cache = crate::cache::CacheDir::with_root(cache_root.to_path_buf());
1909 let (arch, image_name) = crate::fetch::arch_info();
1910 let staging = tempfile::TempDir::new().expect("staging tempdir");
1911 let fake_image = staging.path().join(image_name);
1912 std::fs::write(&fake_image, b"fake kernel image bytes").expect("write fake image");
1913 let metadata = crate::cache::KernelMetadata::new(
1914 crate::cache::KernelSource::Local {
1915 source_tree_path: None,
1916 git_hash: None,
1917 },
1918 arch,
1919 image_name,
1920 "2026-04-12T10:00:00Z",
1921 );
1922 let artifacts = crate::cache::CacheArtifacts::new(&fake_image);
1923 let entry = cache
1924 .store(cache_key, &artifacts, &metadata)
1925 .expect("pre-populate cache entry");
1926 entry.path
1927 }
1928
1929 /// Cache hit — clean tree whose `local_source` cache key
1930 /// resolves to a pre-populated entry must short-circuit the
1931 /// build pipeline and surface `KernelDirOutcome` with
1932 /// `cache_hit = Some(...)`, `is_dirty = false`, and `dir`
1933 /// pointing at the cache entry directory (NOT the source
1934 /// tree).
1935 #[test]
1936 fn resolve_kernel_dir_to_entry_clean_tree_cache_hit() {
1937 if std::process::Command::new("git")
1938 .arg("--version")
1939 .output()
1940 .is_err()
1941 {
1942 skip!("git CLI unavailable");
1943 }
1944 let _lock = crate::test_support::test_helpers::lock_env();
1945 let cache_tmp = tempfile::TempDir::new().expect("cache tempdir");
1946 let _cache_env = crate::test_support::test_helpers::EnvVarGuard::set(
1947 crate::KTSTR_CACHE_DIR_ENV,
1948 cache_tmp.path(),
1949 );
1950 let src_tmp = tempfile::TempDir::new().expect("src tempdir");
1951 init_repo_with_commit_for_resolve_test(src_tmp.path());
1952
1953 let acquired =
1954 crate::fetch::local_source(src_tmp.path()).expect("local_source must succeed");
1955 assert!(!acquired.is_dirty, "fixture must be clean before lookup");
1956 let cache_key = acquired.cache_key.clone();
1957
1958 let entry_path = populate_cache_entry_for_resolve_test(cache_tmp.path(), &cache_key);
1959
1960 let outcome = resolve_kernel_dir_to_entry(src_tmp.path(), "test", None)
1961 .expect("resolve must succeed on cache hit");
1962 assert_eq!(
1963 outcome.dir, entry_path,
1964 "cache-hit path must return the cache entry directory, NOT the source tree"
1965 );
1966 let hit = outcome
1967 .cache_hit
1968 .expect("cache hit must produce KernelDirCacheHit");
1969 assert_eq!(
1970 hit.cache_key, cache_key,
1971 "cache hit must report the resolved key"
1972 );
1973 assert_eq!(
1974 hit.built_at, "2026-04-12T10:00:00Z",
1975 "cache hit must surface the persisted built_at timestamp",
1976 );
1977 assert!(
1978 !outcome.is_dirty,
1979 "cache-hit gate requires a clean tree; outcome.is_dirty must be false",
1980 );
1981 }
1982
1983 /// Dirty-tree resolve must short-circuit the cache lookup
1984 /// even when an entry under the dirty tree's would-be key
1985 /// already exists. `is_dirty=true` flips `outcome.is_dirty`
1986 /// so the caller (cargo-ktstr) appends `_dirty` to the
1987 /// kernel label and the test report distinguishes the
1988 /// non-reproducible run from a subsequent clean rebuild.
1989 #[test]
1990 fn resolve_kernel_dir_to_entry_dirty_tree_skips_cache_lookup() {
1991 if std::process::Command::new("git")
1992 .arg("--version")
1993 .output()
1994 .is_err()
1995 {
1996 skip!("git CLI unavailable");
1997 }
1998 if std::process::Command::new("make")
1999 .arg("--version")
2000 .output()
2001 .is_err()
2002 {
2003 skip!("make not in PATH");
2004 }
2005 let _lock = crate::test_support::test_helpers::lock_env();
2006 let cache_tmp = tempfile::TempDir::new().expect("cache tempdir");
2007 let _cache_env = crate::test_support::test_helpers::EnvVarGuard::set(
2008 crate::KTSTR_CACHE_DIR_ENV,
2009 cache_tmp.path(),
2010 );
2011 let _bypass_env = crate::test_support::test_helpers::EnvVarGuard::set(
2012 crate::KTSTR_BYPASS_LLC_LOCKS_ENV,
2013 "1",
2014 );
2015 let src_tmp = tempfile::TempDir::new().expect("src tempdir");
2016 init_repo_with_commit_for_resolve_test(src_tmp.path());
2017
2018 std::fs::write(src_tmp.path().join("README"), "modified\n").expect("dirty README");
2019 let dirty_acquired = crate::fetch::local_source(src_tmp.path())
2020 .expect("local_source on dirty tree must succeed");
2021 assert!(
2022 dirty_acquired.is_dirty,
2023 "post-mutation tree must be dirty for the test to be meaningful"
2024 );
2025 populate_cache_entry_for_resolve_test(cache_tmp.path(), &dirty_acquired.cache_key);
2026
2027 let result = resolve_kernel_dir_to_entry(src_tmp.path(), "test", None);
2028 match result {
2029 Ok(outcome) => panic!(
2030 "dirty tree must skip the cache lookup, but resolve returned \
2031 Ok with dir={:?}, cache_hit={:?}, is_dirty={}",
2032 outcome.dir, outcome.cache_hit, outcome.is_dirty,
2033 ),
2034 Err(_) => {
2035 let entry_dir = cache_tmp.path().join(&dirty_acquired.cache_key);
2036 assert!(
2037 entry_dir.is_dir(),
2038 "pre-populated entry must still be present after the \
2039 dirty resolve; the gate proved short-circuit by NOT \
2040 returning this directory as the outcome.dir",
2041 );
2042 }
2043 }
2044 }
2045
2046 /// Cache miss on a clean tree must surface as a build attempt
2047 /// rather than as a successful shortcut. Pinned through the
2048 /// build's eventual failure on a fixture without a real kernel
2049 /// toolchain — same shape as the dirty-tree test, but proves
2050 /// the cache MISS path also fans out to the pipeline (rather
2051 /// than a silent no-op that would erase the per-tree
2052 /// invariant).
2053 #[test]
2054 fn resolve_kernel_dir_to_entry_clean_tree_cache_miss_attempts_build() {
2055 if std::process::Command::new("git")
2056 .arg("--version")
2057 .output()
2058 .is_err()
2059 {
2060 skip!("git CLI unavailable");
2061 }
2062 if std::process::Command::new("make")
2063 .arg("--version")
2064 .output()
2065 .is_err()
2066 {
2067 skip!("make not in PATH");
2068 }
2069 let _lock = crate::test_support::test_helpers::lock_env();
2070 let cache_tmp = tempfile::TempDir::new().expect("cache tempdir");
2071 let _cache_env = crate::test_support::test_helpers::EnvVarGuard::set(
2072 crate::KTSTR_CACHE_DIR_ENV,
2073 cache_tmp.path(),
2074 );
2075 let _bypass_env = crate::test_support::test_helpers::EnvVarGuard::set(
2076 crate::KTSTR_BYPASS_LLC_LOCKS_ENV,
2077 "1",
2078 );
2079 let src_tmp = tempfile::TempDir::new().expect("src tempdir");
2080 init_repo_with_commit_for_resolve_test(src_tmp.path());
2081
2082 let acquired =
2083 crate::fetch::local_source(src_tmp.path()).expect("local_source must succeed");
2084 assert!(!acquired.is_dirty, "fixture must be clean before resolve");
2085
2086 let result = resolve_kernel_dir_to_entry(src_tmp.path(), "test", None);
2087 assert!(
2088 result.is_err(),
2089 "cache miss without a real kernel toolchain must surface the build failure, \
2090 got Ok({:?})",
2091 result.as_ref().ok().map(|o| &o.dir),
2092 );
2093 }
2094}