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}