ktstr/
kernel_path.rs

1// Shared kernel directory resolution.
2//
3// This file lives in two places: `src/kernel_path.rs` (the canonical
4// source in the parent ktstr crate, included into its `build.rs` via
5// `include!("src/kernel_path.rs")` and exposed to its lib via
6// `pub mod kernel_path`) and `ktstr-macros/src/kernel_path.rs` (the
7// bundled mirror, declared `mod kernel_path` in the macros crate's
8// `src/lib.rs` and verified byte-identical to the canonical by
9// `ktstr-macros/build.rs`'s drift check). The `include!` in the
10// parent's build.rs is deliberate — build.rs runs before the crate
11// compiles, so it cannot `use ktstr::...`. Duplicating the resolution
12// logic across the three consumers (parent build.rs, parent lib,
13// macros crate) would drift between build-time BTF discovery
14// (vmlinux.h generation), run-time kernel selection, and the macros
15// crate's parse-time KernelId construction.
16//
17// Constraints that every edit to this file must satisfy — breaking
18// any of them surfaces as either a cryptic build-script error or a
19// runtime/build-time behaviour mismatch:
20//
21// 1. **No non-std imports.** build.rs has its own dependency graph
22//    (`libbpf-cargo`, `tempfile`, etc.). A `use foo::bar` here would
23//    compile inside lib.rs (via the `pub mod` path) but fail inside
24//    build.rs because build.rs hasn't declared `foo` as a build-dep.
25// 2. **No `pub(crate)` items.** `pub(crate)` is meaningless inside
26//    an `include!`'d fragment — build.rs isn't a crate, so the item
27//    resolves at crate-root visibility there. Use `pub` for items
28//    build.rs needs, `fn` (private) for items lib.rs alone uses.
29// 3. **`#[cfg(test)]` blocks may use non-std test helpers freely.**
30//    Cargo does not set `cfg(test)` when compiling build scripts, so
31//    `#[cfg(test)]` items inside this file are simply elided from the
32//    build.rs view of the fragment — `tempfile`, `proptest`, etc. are
33//    safe to import inside `#[cfg(test)] mod tests { ... }`. The
34//    std-only rule (#1 above) applies to non-`cfg(test)` items only.
35// 4. **All functions are pure.** Callers supply inputs and handle
36//    caching — no global state, no `std::env::set_var`, no FS
37//    writes outside the caller-provided paths. Pure is what makes
38//    the double-consumer (build + runtime) safe.
39// 5. **Bundled into ktstr-macros.** Everything before the first
40//    `#[cfg(test)]` is mirrored verbatim between
41//    `src/kernel_path.rs` (the canonical source) and
42//    `ktstr-macros/src/kernel_path.rs` (the bundled mirror);
43//    `ktstr-macros/build.rs` panics on any drift. Edits to
44//    non-test items must update both copies in lock-step. The
45//    `#[cfg(test)]` portion lives in `src/kernel_path.rs` only.
46
47/// Human-readable enumeration of every form `KernelId::parse` accepts.
48/// The macro-time rejection in `declare_scheduler!(kernels = […])`
49/// cites this const verbatim. The runtime cache-lookup bails in
50/// `ktstr` / `cargo-ktstr` cite the `KTSTR_KERNEL_HINT` const, a manual
51/// mirror of this same wording — const composition cannot `concat!` a
52/// `const &str`, only literals — so keep the two in sync when either
53/// changes.
54pub const KERNEL_ID_GRAMMAR: &str = "exact version (`6.14`), inclusive range (`6.14..7.0` or \
55     `6.14..=7.0`), git source (`git+URL#tag=NAME`, `git+URL#branch=NAME`, or \
56     `git+URL#sha=<40-hex>`), absolute or `~`-prefixed path, or cache key";
57
58/// Kernel identifier: filesystem path, version string, cache key,
59/// stable-release range, or git source.
60///
61/// Parsing heuristic (see [`KernelId::parse`]):
62/// - Contains `/` (without a `git+` prefix) or starts with `.` or `~`:
63///   [`KernelId::Path`]
64/// - Starts with `git+`: [`KernelId::Git`] (form `git+URL#tag=NAME` /
65///   `git+URL#branch=NAME` / `git+URL#sha=<40-hex>`)
66/// - Contains `..` between two version-shaped tokens:
67///   [`KernelId::Range`] (inclusive on both endpoints)
68/// - Matches `MAJOR.MINOR[.PATCH][-rcN]`: [`KernelId::Version`]
69/// - Otherwise: [`KernelId::CacheKey`]
70#[derive(Debug, Clone, PartialEq, Eq)]
71pub enum KernelId {
72    /// Filesystem path to kernel source/build directory.
73    Path(std::path::PathBuf),
74    /// Kernel version string (e.g. "6.14.2", "6.15-rc3").
75    Version(String),
76    /// Cache key (e.g. "6.14.2-tarball-x86_64-kc...").
77    CacheKey(String),
78    /// Inclusive range of stable kernel versions, expanded against
79    /// kernel.org's release index at resolve time. `start` and `end`
80    /// are both [`KernelId::Version`]-shaped strings (e.g. "6.10",
81    /// "6.13"); the resolver fans this out to every release in
82    /// [start, end] inclusive on both endpoints regardless of whether
83    /// the parser saw `..` or `..=`. A version present in the range
84    /// but missing from the upstream index is a hard error before any
85    /// boot — partial expansions are not silently dropped. The
86    /// `syntax_inclusive` flag preserves the original separator for
87    /// round-trip [`std::fmt::Display`] and operator-facing error
88    /// messages; it does not change resolution semantics.
89    Range {
90        /// Inclusive lower bound, version-shaped.
91        start: String,
92        /// Inclusive upper bound, version-shaped.
93        end: String,
94        /// `true` when the parser saw `..=` (or the construction site
95        /// asked for it); `false` for the `..` form. Both are
96        /// resolved as inclusive ranges; the flag exists so
97        /// [`std::fmt::Display`] and the inverted-range error
98        /// message round-trip the operator's typed form.
99        syntax_inclusive: bool,
100    },
101    /// Git source: acquire the source at `git_ref` per `ref_kind`
102    /// (tag / branch / sha), chosen explicitly by the operator's
103    /// `#tag=` / `#branch=` / `#sha=` fragment — no DWIM. Stored
104    /// verbatim by `KernelId::parse` with no remote contact. At
105    /// cache-resolution time `resolve_git_kernel` resolves `git_ref`
106    /// to its full commit hash (a kind-directed ls-remote) and probes
107    /// the cache before fetching, so a re-run against an unchanged tip
108    /// skips the download.
109    ///
110    /// Acquisition is routed by host (see `resolve_git_kernel` /
111    /// `crate::fetch`):
112    /// - GitHub (`github.com/OWNER/REPO`): a codeload `tar.gz` snapshot
113    ///   of the RESOLVED COMMIT (the ls-remote-resolved commit for a
114    ///   tag/branch, the sha itself for a sha) — no clone; the
115    ///   exact-commit snapshot matches the cache key even if a branch
116    ///   tip moves mid-resolve. A tag/branch whose ls-remote resolution
117    ///   fails falls back to the clone path below (like a non-GitHub
118    ///   source).
119    /// - Non-GitHub: a kind-directed shallow clone — `Tag` fetches
120    ///   `refs/tags/{git_ref}` (annotated tags peel to the commit),
121    ///   `Branch` fetches `refs/heads/{git_ref}`. `Sha` is unsupported off
122    ///   GitHub (gix cannot fetch a bare commit and the remote lacks
123    ///   allow-sha-in-want) and errors.
124    Git {
125        /// Remote URL (https or git@). GitHub sources are fetched from
126        /// codeload; non-GitHub sources are shallow-cloned from here.
127        url: String,
128        /// The ref value after `kind=` (verbatim, no `refs/` prefix) for
129        /// `Tag` / `Branch` / `Sha`; the whole unrecognized fragment for
130        /// `Unknown`. For `ref_kind == Sha` this is the 40-hex commit id.
131        git_ref: String,
132        /// Which git namespace `git_ref` names, from the explicit
133        /// `#tag=` / `#branch=` / `#sha=` selector. `Unknown` marks a
134        /// bare `#REF` or unrecognized selector that `validate` rejects.
135        ref_kind: GitRefKind,
136    },
137}
138
139/// Which git ref namespace a [`KernelId::Git`]'s `git_ref` names,
140/// chosen explicitly by the operator via the `#tag=` / `#branch=` /
141/// `#sha=` fragment — never DWIM-inferred.
142#[derive(Debug, Clone, Copy, PartialEq, Eq)]
143pub enum GitRefKind {
144    /// `#tag=v6.14` → an annotated or lightweight tag (`refs/tags/`).
145    Tag,
146    /// `#branch=for-next` → a branch head (`refs/heads/`).
147    Branch,
148    /// `#sha=<40-hex>` → a raw commit id.
149    Sha,
150    /// A bare `#REF` fragment (no `kind=`) or an unrecognized selector;
151    /// carried so [`KernelId::validate`] can emit an actionable error.
152    /// Never resolved.
153    Unknown,
154}
155
156impl KernelId {
157    /// Parse a string into a kernel identifier.
158    ///
159    /// Recognizes (in order):
160    /// - `git+`-prefixed → [`KernelId::Git`]. ANY `git+…` string is a
161    ///   Git source (the `git+` prefix takes precedence over the range
162    ///   and `/`-contains tests below), so a typo such as a missing
163    ///   `#fragment` never silently becomes a `Path`. The fragment
164    ///   selects the ref kind: `#tag=NAME` / `#branch=NAME` /
165    ///   `#sha=<40-hex>`; a missing/empty fragment or unrecognized
166    ///   selector yields `GitRefKind::Unknown`, and an empty URL an
167    ///   empty `url` — both of which [`KernelId::validate`] rejects
168    ///   with an actionable error rather than the resolver later
169    ///   reporting a confusing "path not found".
170    /// - `START..=END` or `START..END` where both endpoints are
171    ///   version-shaped → [`KernelId::Range`]. The endpoints are
172    ///   ALWAYS inclusive — both `..` and `..=` spellings produce a
173    ///   closed range, regardless of Rust's exclusive-`..` /
174    ///   inclusive-`..=` distinction. Both forms are accepted so test
175    ///   authors and CLI users can write whichever feels natural.
176    /// - `/`-containing or `.`/`~`-prefixed → [`KernelId::Path`].
177    /// - Version-shaped → [`KernelId::Version`].
178    /// - Anything else → [`KernelId::CacheKey`].
179    pub fn parse(s: &str) -> Self {
180        if let Some(rest) = s.strip_prefix("git+") {
181            // ANY `git+…` string is a Git source — split off the
182            // `#fragment` (a missing one leaves an empty fragment) and
183            // NEVER fall through to Path/CacheKey, so a typo such as
184            // `git+URL` (no fragment) surfaces the actionable
185            // git-grammar error from `validate` rather than a confusing
186            // "path not found". `parse` returns `Self`, not `Result`, so
187            // all structural rejection (Unknown kind, empty url/ref, bad
188            // sha) is deferred to `validate`.
189            let (url, frag) = match rest.rsplit_once('#') {
190                Some((url, frag)) => (url, frag),
191                None => (rest, ""),
192            };
193            // Explicit ref-kind grammar: `#tag=NAME` / `#branch=NAME` /
194            // `#sha=<40-hex>`. A bare `#REF` (no `kind=`), an empty
195            // fragment, or an unrecognized selector parses as `Unknown`.
196            let (ref_kind, git_ref) = match frag.split_once('=') {
197                Some(("tag", v)) => (GitRefKind::Tag, v.to_string()),
198                Some(("branch", v)) => (GitRefKind::Branch, v.to_string()),
199                Some(("sha", v)) => (GitRefKind::Sha, v.to_string()),
200                _ => (GitRefKind::Unknown, frag.to_string()),
201            };
202            return KernelId::Git {
203                url: url.to_string(),
204                git_ref,
205                ref_kind,
206            };
207        }
208        if let Some((start, end)) = s.split_once("..=")
209            && _is_version_string(start)
210            && _is_version_string(end)
211        {
212            return KernelId::Range {
213                start: start.to_string(),
214                end: end.to_string(),
215                syntax_inclusive: true,
216            };
217        }
218        if let Some((start, end)) = s.split_once("..")
219            && _is_version_string(start)
220            && _is_version_string(end)
221        {
222            return KernelId::Range {
223                start: start.to_string(),
224                end: end.to_string(),
225                syntax_inclusive: false,
226            };
227        }
228        if s.contains('/') || s.starts_with('.') || s.starts_with('~') {
229            return KernelId::Path(expand_tilde(s));
230        }
231        if _is_version_string(s) {
232            return KernelId::Version(s.to_string());
233        }
234        KernelId::CacheKey(s.to_string())
235    }
236
237    /// Parse a comma-separated list of kernel specs into a vector of
238    /// identifiers. Empty entries are silently skipped (so trailing
239    /// commas or repeated separators are forgiving). Each non-empty
240    /// segment is fed through [`KernelId::parse`] verbatim — so
241    /// `parse_list("6.10,git+URL#branch=main,/srv/linux")` returns three
242    /// distinct variants. Deduplication is the resolver's
243    /// responsibility (after canonicalization to a cache key); this
244    /// function preserves order and duplicates as written.
245    pub fn parse_list(s: &str) -> Vec<KernelId> {
246        s.split(',')
247            .map(str::trim)
248            .filter(|seg| !seg.is_empty())
249            .map(KernelId::parse)
250            .collect()
251    }
252
253    /// Validate a parsed `KernelId` for resolve-time legality. Returns
254    /// `Err(message)` when the identifier carries a structural problem
255    /// the parser couldn't catch on its own — currently:
256    ///
257    /// - [`KernelId::Range`] with `start > end` after numeric
258    ///   component-wise comparison. The parser cannot reject this at
259    ///   parse time because both endpoints are valid version strings
260    ///   in isolation; the inversion only surfaces when the two are
261    ///   compared.
262    ///
263    /// All other variants always return `Ok(())` — this is a hook for
264    /// future per-variant invariants, not a general-purpose validator.
265    /// Use `Result<(), String>` rather than `anyhow::Result` because
266    /// this file is included from `build.rs` (see file header rule
267    /// #1, no non-std imports outside `cfg(test)`).
268    ///
269    /// Comparison semantics: each endpoint decomposes to a
270    /// `(major, minor, patch, rc)` tuple where missing patch maps to
271    /// `0` and missing `-rc` maps to `u64::MAX` so a release
272    /// (`6.10`) sorts strictly above any pre-release (`6.10-rc3`) of
273    /// the same major.minor.patch. Inverted ranges include
274    /// `7.0..6.99`, `6.10..6.5`, `6.10..6.10-rc3` (release > rc), and
275    /// `6.10-rc3..6.10-rc1`. Equal endpoints (`6.10..6.10`) pass
276    /// validation as a single-element range.
277    pub fn validate(&self) -> Result<(), String> {
278        match self {
279            KernelId::Range {
280                start,
281                end,
282                syntax_inclusive,
283            } => {
284                let start_key = decompose_version_for_compare(start).ok_or_else(|| {
285                    format!(
286                        "kernel range start `{start}` is not a parseable version. \
287                         Expected `MAJOR.MINOR[.PATCH][-rc<num>]` (e.g. \"6.10\", \
288                         \"6.14.2\", \"6.15-rc3\"). Range examples: `6.10..6.15`, \
289                         `6.10-rc1..=6.10`.",
290                    )
291                })?;
292                // END is series-inclusive: a 2-component `MAJOR.MINOR`
293                // END names the whole series (see `range_end_key`), so
294                // the inversion check must use the SAME widened bound the
295                // expansion does — otherwise a valid same-series range
296                // like `6.14.5..6.14` (= 6.14.5 .. end of the 6.14
297                // series) is falsely rejected as inverted.
298                let end_key = range_end_key(end).ok_or_else(|| {
299                    format!(
300                        "kernel range end `{end}` is not a parseable version. \
301                         Expected `MAJOR.MINOR[.PATCH][-rc<num>]` (e.g. \"6.10\", \
302                         \"6.14.2\", \"6.15-rc3\"). Range examples: `6.10..6.15`, \
303                         `6.10-rc1..=6.10`.",
304                    )
305                })?;
306                if start_key > end_key {
307                    let sep = if *syntax_inclusive { "..=" } else { ".." };
308                    return Err(format!(
309                        "inverted kernel range `{start}{sep}{end}`: start version is greater \
310                         than end version. Swap the endpoints (`{end}{sep}{start}`) or use \
311                         a single version (no range) to test just one release.",
312                    ));
313                }
314                Ok(())
315            }
316            KernelId::Git {
317                url,
318                git_ref,
319                ref_kind,
320            } => {
321                if url.is_empty() {
322                    return Err(format!(
323                        "git source `git+#{git_ref}`: empty URL — write \
324                         `git+<url>#tag=<name>` (or `#branch=` / `#sha=`)."
325                    ));
326                }
327                if *ref_kind == GitRefKind::Unknown {
328                    return Err(format!(
329                        "git source `git+{url}#{git_ref}`: the ref kind must be \
330                         explicit — write `#tag=<name>`, `#branch=<name>`, or \
331                         `#sha=<40-hex>` (a bare `#REF` is no longer accepted).",
332                    ));
333                }
334                if git_ref.is_empty() {
335                    return Err(format!(
336                        "git source `git+{url}#...`: empty ref value — write \
337                         `#tag=<name>`, `#branch=<name>`, or `#sha=<40-hex>`.",
338                    ));
339                }
340                if *ref_kind == GitRefKind::Sha
341                    && (git_ref.len() != 40 || !git_ref.bytes().all(|b| b.is_ascii_hexdigit()))
342                {
343                    return Err(format!(
344                        "git source `git+{url}#sha={git_ref}`: a sha must be the full \
345                         40-hex commit id (abbreviated shas can't be fetched); use \
346                         `#tag=` or `#branch=` for a name.",
347                    ));
348                }
349                Ok(())
350            }
351            KernelId::Path(_) | KernelId::Version(_) | KernelId::CacheKey(_) => Ok(()),
352        }
353    }
354}
355
356impl std::fmt::Display for KernelId {
357    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
358        match self {
359            KernelId::Path(p) => write!(f, "{}", p.display()),
360            KernelId::Version(v) => write!(f, "{v}"),
361            KernelId::CacheKey(k) => write!(f, "{k}"),
362            KernelId::Range {
363                start,
364                end,
365                syntax_inclusive,
366            } => {
367                let sep = if *syntax_inclusive { "..=" } else { ".." };
368                write!(f, "{start}{sep}{end}")
369            }
370            KernelId::Git {
371                url,
372                git_ref,
373                ref_kind,
374            } => match ref_kind {
375                GitRefKind::Tag => write!(f, "git+{url}#tag={git_ref}"),
376                GitRefKind::Branch => write!(f, "git+{url}#branch={git_ref}"),
377                GitRefKind::Sha => write!(f, "git+{url}#sha={git_ref}"),
378                // Round-trip a rejected bare fragment verbatim so
379                // `parse(Display(x)) == x` still holds for the Unknown case.
380                GitRefKind::Unknown => write!(f, "git+{url}#{git_ref}"),
381            },
382        }
383    }
384}
385
386/// Check if a string matches a kernel version pattern.
387///
388/// Matches: `6` (bare major prefix), `6.14`, `6.14.2`, `6.15-rc3`,
389/// `6.14.2-rc1`. Does not match: `v6.14` (git tag prefix), `6.`
390/// (trailing dot), `6.14.2-tarball-x86_64-kc...` (cache key with
391/// extra segments).
392fn _is_version_string(s: &str) -> bool {
393    let (version_part, rc_part) = match s.split_once("-rc") {
394        Some((v, rc)) => (v, Some(rc)),
395        None => (s, None),
396    };
397
398    // The part after -rc must be a non-empty digit string.
399    if let Some(rc) = rc_part
400        && (rc.is_empty() || !rc.bytes().all(|b| b.is_ascii_digit()))
401    {
402        return false;
403    }
404
405    let mut parts = version_part.split('.');
406
407    // Major: required, non-empty digits.
408    match parts.next() {
409        Some(p) if !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit()) => {}
410        _ => return false,
411    }
412    // Minor: OPTIONAL — a bare MAJOR (`6`) is a valid version prefix
413    // that resolves to the highest patch across all minors (see
414    // `crate::fetch::fetch_version_for_prefix`). If present it must be
415    // non-empty digits; `6.` (trailing dot) is rejected as an empty
416    // component.
417    match parts.next() {
418        None => {}
419        Some(p) if !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit()) => {}
420        _ => return false,
421    }
422    // Patch: optional, non-empty digits.
423    if let Some(patch) = parts.next()
424        && (patch.is_empty() || !patch.bytes().all(|b| b.is_ascii_digit()))
425    {
426        return false;
427    }
428    // No more segments allowed (rejects `1.2.3.4`).
429    parts.next().is_none()
430}
431
432/// Decompose a version-shaped string into a `(major, minor, patch,
433/// rc)` tuple suitable for `Ord` comparison. Returns `None` when the
434/// input doesn't match the kernel-version grammar — same predicate as
435/// [`_is_version_string`] but extracting numeric components rather
436/// than just yes/no.
437///
438/// Comparison semantics:
439/// - Missing patch defaults to `0` so `6.10` and `6.10.0` compare
440///   equal.
441/// - Missing `-rcN` defaults to `u64::MAX` so a release
442///   (`6.10`, `6.10.5`) sorts strictly above any pre-release
443///   (`6.10-rc3`, `6.10.5-rc1`) of the same `major.minor.patch`. A
444///   future major/minor/patch bump still dominates because the tuple
445///   is compared in declaration order — the rc-as-MAX trick only
446///   resolves ties on the leading three components.
447///
448/// Used by [`KernelId::validate`] to detect inverted ranges
449/// (`6.16..6.12`, `6.10..6.10-rc3`, `7.0..6.99`), and by
450/// the `cli` module's range-expansion helper to filter and sort
451/// kernel.org release rows that fall inside a `start..end` interval.
452pub(crate) fn decompose_version_for_compare(s: &str) -> Option<(u64, u64, u64, u64)> {
453    let (version_part, rc_part) = match s.split_once("-rc") {
454        Some((v, rc)) => (v, Some(rc)),
455        None => (s, None),
456    };
457    // rc must be a non-empty digit string when present.
458    let rc: u64 = match rc_part {
459        Some(rc) if rc.is_empty() || !rc.bytes().all(|b| b.is_ascii_digit()) => return None,
460        Some(rc) => rc.parse().ok()?,
461        None => u64::MAX,
462    };
463    let mut parts = version_part.split('.');
464    let major: u64 = parts.next()?.parse().ok()?;
465    // Minor optional: a bare MAJOR (`6`) decomposes to `(major, 0, 0,
466    // ..)` so it behaves as a series-floor prefix, consistent with
467    // `_is_version_string` accepting a bare major.
468    let minor: u64 = match parts.next() {
469        None => 0,
470        Some("") => return None,
471        Some(m) => m.parse().ok()?,
472    };
473    let patch: u64 = match parts.next() {
474        Some("") => return None,
475        Some(p) => p.parse().ok()?,
476        None => 0,
477    };
478    // Reject `1.2.3.4` and similar — only major.minor[.patch] is grammar.
479    if parts.next().is_some() {
480        return None;
481    }
482    Some((major, minor, patch, rc))
483}
484
485/// The upper-bound comparison key for a range's END endpoint,
486/// series-inclusive: a 2-component `MAJOR.MINOR` (or bare-major) END
487/// with no `-rc` names the WHOLE series, so its patch and rc slots are
488/// widened to `u64::MAX`. [`decompose_version_for_compare`] alone maps a
489/// missing patch to 0, which as an inclusive upper bound would exclude
490/// every `6.14.N` (N >= 1) from an END of `6.14`. An explicit-patch END
491/// (`6.14.2`) or an `-rc` END keeps its exact key. Shared by
492/// [`KernelId::validate`]'s inversion check and the `cli` module's
493/// range expansion (`range_bounds`) so the two agree on where a range
494/// ends. START needs no such widening — its `.0` is the series floor.
495pub(crate) fn range_end_key(end: &str) -> Option<(u64, u64, u64, u64)> {
496    let key = decompose_version_for_compare(end)?;
497    // Same predicate as `crate::fetch::is_major_minor_prefix`, inlined
498    // because this file forbids non-std imports (file-header rule #1):
499    // a 2-component (or bare-major) endpoint with no `-rc` is a whole
500    // series and widens to its ceiling.
501    if end.matches('.').count() < 2 && !end.contains("-rc") {
502        Some((key.0, key.1, u64::MAX, u64::MAX))
503    } else {
504        Some(key)
505    }
506}
507
508/// Expand a leading `~` or `~/...` in `s` against `$HOME` and
509/// return the resulting [`std::path::PathBuf`]. Any other shape (no leading
510/// `~`, `~user/...` for a different user, `$HOME` unset or empty)
511/// passes through verbatim — the caller's downstream `is_dir()`
512/// surfaces a regular "no such directory" error instead of being
513/// silently rewritten.
514///
515/// Cases handled:
516/// - `"~"` → `$HOME`
517/// - `"~/"` → `$HOME` (same as bare `"~"`; the empty suffix
518///   after `~/` yields no trailing separator)
519/// - `"~/linux"` → `$HOME/linux`
520/// - `"~user/..."` → unchanged (std has no `getpwnam`; a
521///   different-user expansion would require shelling out, which
522///   the file's "no non-std imports outside cfg(test)" rule
523///   forbids; the operator who wants a peer's home dir can spell
524///   it absolutely)
525/// - any input not starting with `~` → unchanged
526/// - `~`-prefix with `$HOME` unset / empty → unchanged (the
527///   downstream `is_dir()` failure is the clearest error path
528///   we can produce without a logging dep)
529///
530/// Pure with respect to filesystem writes; reads `$HOME` once. Env
531/// reads are consistent with the existing
532/// [`kernel_release_from_procfs`] pattern (FS read at resolve time)
533/// and explicitly outside the file-header `std::env::set_var` ban.
534///
535/// Called from [`KernelId::parse`]'s Path arm so the Path variant
536/// stores an absolute (or filesystem-resolvable) path. Without this,
537/// `KernelId::parse("~/linux")` stores the literal `"~/linux"`,
538/// which `is_dir()` rejects unconditionally — there is no shell to
539/// perform the standard tilde expansion on the operator's behalf at
540/// CLI invocation time.
541fn expand_tilde(s: &str) -> std::path::PathBuf {
542    // Bare `~` and `~/...` are the only shapes we expand. Anything
543    // else falls through verbatim.
544    if s != "~" && !s.starts_with("~/") {
545        return std::path::PathBuf::from(s);
546    }
547    // `$HOME` empty or unset is treated identically to "no
548    // expansion possible" — the caller's `is_dir()` check will
549    // surface the missing-path error normally. We do NOT panic
550    // here because `KernelId::parse` is `pub` and on a hot CLI
551    // path; failing to expand a single arg is not a fatal
552    // condition for the whole CLI.
553    let home = match std::env::var("HOME") {
554        Ok(h) if !h.is_empty() => h,
555        _ => return std::path::PathBuf::from(s),
556    };
557    if s == "~" {
558        return std::path::PathBuf::from(home);
559    }
560    // s starts with "~/", so the suffix we want to splice on is
561    // the slice starting AFTER the `/` separator. Joining `home`
562    // with `&s[1..]` would land an absolute path inside `home`
563    // (PathBuf::push of an absolute path RESETS the buffer to that
564    // absolute path), so we strip the leading `/` first. Doubled
565    // separators in the rest portion (`~//foo` → `s[2..] = "/foo"`)
566    // would also reset the buffer; loop the strip so any run of
567    // leading `/`s is consumed before the push.
568    let mut rest = &s[2..]; // skip "~/"
569    while let Some(stripped) = rest.strip_prefix('/') {
570        rest = stripped;
571    }
572    let mut p = std::path::PathBuf::from(home);
573    if !rest.is_empty() {
574        p.push(rest);
575    }
576    p
577}
578
579/// Read the running kernel release from `/proc/sys/kernel/osrelease`.
580///
581/// Returns `None` if the procfs entry is unreadable, empty, or missing.
582/// Callers that need the release string for `/lib/modules/{release}/…`
583/// fallbacks use this rather than shelling out to `uname -r`: the
584/// procfs entry exposes the raw utsname release (served by
585/// `proc_do_uts_string` in linux/kernel/utsname_sysctl.c), the same
586/// field uname(2) reads — modulo the UNAME26 personality shim
587/// `override_release` applies on the uname(2) path only — and only
588/// costs a small read.
589fn kernel_release_from_procfs() -> Option<String> {
590    std::fs::read_to_string("/proc/sys/kernel/osrelease")
591        .ok()
592        .map(|s| s.trim().to_string())
593        .filter(|s| !s.is_empty())
594}
595
596/// Resolve a kernel source/build directory.
597///
598/// `kernel_dir`: value of `KTSTR_KERNEL` env var (if set).
599///
600/// Search order:
601/// 1. `kernel_dir` parameter (from env var)
602/// 2. `./linux` (workspace-local build tree)
603/// 3. `../linux` (sibling directory)
604/// 4. `/lib/modules/{release}/build` (installed kernel headers)
605///
606/// Returns the directory path if a kernel tree is found.
607#[allow(dead_code)]
608pub fn resolve_kernel(kernel_dir: Option<&str>) -> Option<std::path::PathBuf> {
609    // 1. Explicit directory.
610    if let Some(dir) = kernel_dir {
611        let p = std::path::PathBuf::from(dir);
612        if p.is_dir() {
613            return Some(p);
614        }
615    }
616
617    // 2-3. Local build trees.
618    for rel in &["./linux", "../linux"] {
619        let p = std::path::PathBuf::from(rel);
620        if p.is_dir() && has_kernel_artifacts(&p) {
621            return Some(p);
622        }
623    }
624
625    // 4. Installed kernel build dir — use the running release from
626    // procfs to locate `/lib/modules/{release}/build`.
627    if let Some(rel) = kernel_release_from_procfs() {
628        let p = std::path::PathBuf::from(format!("/lib/modules/{rel}/build"));
629        if p.is_dir() {
630            return Some(p);
631        }
632    }
633
634    None
635}
636
637/// Derive the kernel directory (holding `vmlinux` and related build
638/// artifacts) from a kernel image path.
639///
640/// Recognizes two layouts:
641///
642/// - **Build tree**: `<root>/arch/x86/boot/bzImage` (or
643///   `arch/arm64/boot/Image`) → `<root>`. Suffix match on the
644///   canonical path.
645/// - **Cache entry**: `<cache_dir>/bzImage` (or `Image`) with a
646///   sibling `vmlinux` → `<cache_dir>`. Lets probe source-location
647///   resolution walk a cached kernel's stripped ELF.
648///
649/// Returns `None` when neither layout matches or the input path
650/// doesn't canonicalize.
651///
652/// Cache entries carry stripped vmlinux (no DWARF) — `strip_vmlinux_debug`
653/// drops `.debug_*` on every cache entry regardless of source type.
654/// file:line resolution works only for build-tree paths where the
655/// unstripped vmlinux is still present, or when the caller layers
656/// `cache::prefer_source_tree_for_dwarf` on top to re-route
657/// `cache::KernelSource::Local` entries at their original source tree.
658#[allow(dead_code)]
659pub fn derive_kernel_dir(image: &std::path::Path) -> Option<std::path::PathBuf> {
660    let canon = std::fs::canonicalize(image).ok()?;
661
662    #[cfg(target_arch = "x86_64")]
663    let build_suffix = "/arch/x86/boot/bzImage";
664    #[cfg(target_arch = "aarch64")]
665    let build_suffix = "/arch/arm64/boot/Image";
666
667    if let Some(canon_str) = canon.to_str()
668        && let Some(root) = canon_str.strip_suffix(build_suffix)
669    {
670        return Some(std::path::PathBuf::from(root));
671    }
672
673    let parent = canon.parent()?;
674    // is_file (not exists) matches cache::prefer_source_tree_for_dwarf's
675    // sibling probe, so a `vmlinux` directory or symlink-to-directory
676    // cannot satisfy either check.
677    if parent.join("vmlinux").is_file() {
678        return Some(parent.to_path_buf());
679    }
680
681    None
682}
683
684/// Find a bootable kernel image within a directory.
685///
686/// Checks the arch-specific build tree path first (`arch/x86/boot/bzImage`
687/// or `arch/arm64/boot/Image`), then falls back to the directory root
688/// (for cache entries that store the boot image directly).
689#[allow(dead_code)]
690pub fn find_image_in_dir(dir: &std::path::Path) -> Option<std::path::PathBuf> {
691    // Build tree layout: arch-specific subdirectory.
692    #[cfg(target_arch = "x86_64")]
693    {
694        let p = dir.join("arch/x86/boot/bzImage");
695        if p.exists() {
696            return Some(p);
697        }
698    }
699    #[cfg(target_arch = "aarch64")]
700    {
701        let p = dir.join("arch/arm64/boot/Image");
702        if p.exists() {
703            return Some(p);
704        }
705    }
706    // Cache entry layout: boot image at directory root.
707    #[cfg(target_arch = "x86_64")]
708    {
709        let p = dir.join("bzImage");
710        if p.exists() {
711            return Some(p);
712        }
713    }
714    #[cfg(target_arch = "aarch64")]
715    {
716        let p = dir.join("Image");
717        if p.exists() {
718            return Some(p);
719        }
720    }
721    None
722}
723
724/// Find a bootable kernel image on the host.
725///
726/// `kernel_dir`: explicit kernel directory (e.g. from `KTSTR_KERNEL`).
727/// When set, only that directory is searched — no fallback to local
728/// build trees or host paths.
729///
730/// `release`: kernel release string (e.g. from `uname -r`). When
731/// `None`, falls back to reading `/proc/sys/kernel/osrelease` — the
732/// same value the kernel exposes via the `uname(2)` syscall, without
733/// the shell-out cost.
734///
735/// Without `kernel_dir`, searches local build trees (`./linux`,
736/// `../linux`), `/lib/modules/{release}/build`, then host paths
737/// (`/lib/modules/{release}/vmlinuz`, `/boot/vmlinuz-{release}`,
738/// `/boot/vmlinuz`).
739#[allow(dead_code)]
740pub fn find_image(kernel_dir: Option<&str>, release: Option<&str>) -> Option<std::path::PathBuf> {
741    // When kernel_dir is explicit, only check that directory.
742    if let Some(dir_str) = kernel_dir {
743        let dir = std::path::PathBuf::from(dir_str);
744        if !dir.is_dir() {
745            return None;
746        }
747        return find_image_in_dir(&dir);
748    }
749
750    // No explicit dir: search local build trees via resolve_kernel.
751    if let Some(dir) = resolve_kernel(None)
752        && let Some(img) = find_image_in_dir(&dir)
753    {
754        return Some(img);
755    }
756
757    // Host fallback paths. When `release` is not supplied, pull the
758    // running kernel release from procfs via
759    // [`kernel_release_from_procfs`].
760    let owned_release;
761    let rel = match release {
762        Some(r) => Some(r),
763        None => {
764            owned_release = kernel_release_from_procfs();
765            owned_release.as_deref()
766        }
767    };
768
769    if let Some(rel) = rel {
770        let p = std::path::PathBuf::from(format!("/lib/modules/{rel}/vmlinuz"));
771        if std::fs::File::open(&p).is_ok() {
772            return Some(p);
773        }
774        let p = std::path::PathBuf::from(format!("/boot/vmlinuz-{rel}"));
775        if std::fs::File::open(&p).is_ok() {
776            return Some(p);
777        }
778    }
779
780    let p = std::path::PathBuf::from("/boot/vmlinuz");
781    if std::fs::File::open(&p).is_ok() {
782        return Some(p);
783    }
784
785    None
786}
787
788/// Resolve the BTF source file for vmlinux.h generation.
789///
790/// `kernel_dir`: explicit kernel directory (e.g. from `KTSTR_KERNEL`).
791///
792/// Prefers `{resolved_dir}/vmlinux`, then `/sys/kernel/btf/vmlinux`.
793#[allow(dead_code)]
794pub fn resolve_btf(kernel_dir: Option<&str>) -> Option<std::path::PathBuf> {
795    if let Some(dir) = resolve_kernel(kernel_dir) {
796        let vmlinux = dir.join("vmlinux");
797        if vmlinux.exists() {
798            return Some(vmlinux);
799        }
800    }
801    let sysfs = std::path::Path::new("/sys/kernel/btf/vmlinux");
802    if sysfs.exists() {
803        return Some(sysfs.to_path_buf());
804    }
805    None
806}
807
808/// Check if a directory contains kernel build artifacts.
809///
810/// Checks both build tree layout (arch subdirectories) and cache
811/// entry layout (boot image at directory root).
812fn has_kernel_artifacts(dir: &std::path::Path) -> bool {
813    if dir.join("vmlinux").exists() {
814        return true;
815    }
816    #[cfg(target_arch = "x86_64")]
817    if dir.join("arch/x86/boot/bzImage").exists() || dir.join("bzImage").exists() {
818        return true;
819    }
820    #[cfg(target_arch = "aarch64")]
821    if dir.join("arch/arm64/boot/Image").exists() || dir.join("Image").exists() {
822        return true;
823    }
824    false
825}
826#[cfg(test)]
827mod tests {
828    use super::*;
829    use std::path::PathBuf;
830    use tempfile::TempDir;
831
832    // -- resolve_kernel --
833
834    #[test]
835    fn kernel_path_resolve_explicit_dir_exists() {
836        let tmp = TempDir::new().unwrap();
837        let result = resolve_kernel(Some(tmp.path().to_str().unwrap()));
838        assert_eq!(result, Some(tmp.path().to_path_buf()));
839    }
840
841    #[test]
842    fn kernel_path_resolve_explicit_dir_not_exists() {
843        let result = resolve_kernel(Some("/nonexistent/kernel/dir/that/does/not/exist"));
844        // The explicit dir doesn't exist, so resolve_kernel skips it.
845        // It may still find a kernel via fallback paths (./linux, ../linux,
846        // /lib/modules). The key invariant: the nonexistent path must never
847        // be returned.
848        assert_ne!(
849            result,
850            Some(PathBuf::from("/nonexistent/kernel/dir/that/does/not/exist"))
851        );
852    }
853
854    #[test]
855    fn kernel_path_resolve_none_falls_through() {
856        // With None, resolve_kernel skips the explicit branch and tries
857        // ./linux, ../linux, then /lib/modules. The result depends on
858        // the host, but the function must not panic.
859        let _ = resolve_kernel(None);
860    }
861
862    #[test]
863    fn kernel_path_resolve_none_returns_osrelease_build_dir_when_present() {
864        // resolve_kernel(None) reads `/proc/sys/kernel/osrelease` and
865        // checks `/lib/modules/{rel}/build` as its last fallback. The
866        // earlier branches (`./linux`, `../linux`) cannot be controlled
867        // from a parallel-safe unit test (`set_current_dir` is process-
868        // wide), so this test is strong only when those local trees are
869        // absent. When `/lib/modules/{rel}/build` is absent on the host
870        // (typical CI without installed kernel headers), skip via early
871        // return — the panic-free contract is already covered by
872        // `kernel_path_resolve_none_falls_through`.
873        let release = std::fs::read_to_string("/proc/sys/kernel/osrelease")
874            .expect("host /proc/sys/kernel/osrelease must be readable for this test")
875            .trim()
876            .to_string();
877        let expected = std::path::PathBuf::from(format!("/lib/modules/{release}/build"));
878        if !expected.is_dir() {
879            return;
880        }
881
882        let resolved = resolve_kernel(None).unwrap_or_else(|| {
883            panic!(
884                "resolve_kernel(None) must return Some when {} exists",
885                expected.display(),
886            )
887        });
888        assert!(
889            resolved.is_dir(),
890            "resolved path must be a directory, got {}",
891            resolved.display(),
892        );
893        // Strong pin only when no earlier branch (`./linux`, `../linux`)
894        // shadowed the osrelease path. When an earlier branch matched,
895        // the panic-free + valid-dir contract above is what we get.
896        let local_shadowed = std::path::PathBuf::from("./linux").is_dir()
897            || std::path::PathBuf::from("../linux").is_dir();
898        if !local_shadowed {
899            assert_eq!(
900                resolved, expected,
901                "with no local trees, resolve_kernel(None) must return the osrelease build dir",
902            );
903        }
904    }
905
906    #[test]
907    fn kernel_path_resolve_empty_string() {
908        // Empty string creates a PathBuf("") which is_dir() returns false,
909        // so it falls through to search paths.
910        let result = resolve_kernel(Some(""));
911        // "" is not a directory, so it must not be returned as the explicit path.
912        assert_ne!(result, Some(PathBuf::from("")));
913    }
914
915    // -- has_kernel_artifacts --
916
917    #[test]
918    fn kernel_path_has_artifacts_vmlinux() {
919        let tmp = TempDir::new().unwrap();
920        std::fs::write(tmp.path().join("vmlinux"), b"fake").unwrap();
921        assert!(has_kernel_artifacts(tmp.path()));
922    }
923
924    #[cfg(target_arch = "x86_64")]
925    #[test]
926    fn kernel_path_has_artifacts_bzimage() {
927        let tmp = TempDir::new().unwrap();
928        let boot = tmp.path().join("arch/x86/boot");
929        std::fs::create_dir_all(&boot).unwrap();
930        std::fs::write(boot.join("bzImage"), b"fake").unwrap();
931        assert!(has_kernel_artifacts(tmp.path()));
932    }
933
934    #[cfg(target_arch = "aarch64")]
935    #[test]
936    fn kernel_path_has_artifacts_image() {
937        // aarch64 build tree layout: arch/arm64/boot/Image.
938        let tmp = TempDir::new().unwrap();
939        let boot = tmp.path().join("arch/arm64/boot");
940        std::fs::create_dir_all(&boot).unwrap();
941        std::fs::write(boot.join("Image"), b"fake").unwrap();
942        assert!(has_kernel_artifacts(tmp.path()));
943    }
944
945    #[test]
946    fn kernel_path_has_artifacts_empty_dir() {
947        let tmp = TempDir::new().unwrap();
948        assert!(!has_kernel_artifacts(tmp.path()));
949    }
950
951    // -- find_image_in_dir --
952
953    #[cfg(target_arch = "x86_64")]
954    #[test]
955    fn kernel_path_find_image_in_dir_bzimage() {
956        let tmp = TempDir::new().unwrap();
957        let boot = tmp.path().join("arch/x86/boot");
958        std::fs::create_dir_all(&boot).unwrap();
959        std::fs::write(boot.join("bzImage"), b"fake").unwrap();
960        let result = find_image_in_dir(tmp.path());
961        assert_eq!(result, Some(boot.join("bzImage")));
962    }
963
964    #[cfg(target_arch = "aarch64")]
965    #[test]
966    fn kernel_path_find_image_in_dir_image() {
967        // aarch64 build tree layout: find_image_in_dir returns
968        // arch/arm64/boot/Image.
969        let tmp = TempDir::new().unwrap();
970        let boot = tmp.path().join("arch/arm64/boot");
971        std::fs::create_dir_all(&boot).unwrap();
972        std::fs::write(boot.join("Image"), b"fake").unwrap();
973        let result = find_image_in_dir(tmp.path());
974        assert_eq!(result, Some(boot.join("Image")));
975    }
976
977    #[test]
978    fn kernel_path_find_image_in_dir_empty() {
979        let tmp = TempDir::new().unwrap();
980        assert!(find_image_in_dir(tmp.path()).is_none());
981    }
982
983    #[cfg(target_arch = "x86_64")]
984    #[test]
985    fn kernel_path_find_image_in_dir_cache_layout() {
986        // Cache entries store bzImage at directory root (no arch/ subdir).
987        let tmp = TempDir::new().unwrap();
988        std::fs::write(tmp.path().join("bzImage"), b"fake").unwrap();
989        let result = find_image_in_dir(tmp.path());
990        assert_eq!(result, Some(tmp.path().join("bzImage")));
991    }
992
993    #[cfg(target_arch = "aarch64")]
994    #[test]
995    fn kernel_path_find_image_in_dir_cache_layout_image() {
996        // Cache entries store Image at directory root on aarch64.
997        let tmp = TempDir::new().unwrap();
998        std::fs::write(tmp.path().join("Image"), b"fake").unwrap();
999        let result = find_image_in_dir(tmp.path());
1000        assert_eq!(result, Some(tmp.path().join("Image")));
1001    }
1002
1003    #[cfg(target_arch = "x86_64")]
1004    #[test]
1005    fn kernel_path_find_image_in_dir_prefers_build_tree() {
1006        // When both arch/ and root-level bzImage exist, prefer arch/.
1007        let tmp = TempDir::new().unwrap();
1008        let boot = tmp.path().join("arch/x86/boot");
1009        std::fs::create_dir_all(&boot).unwrap();
1010        std::fs::write(boot.join("bzImage"), b"build-tree").unwrap();
1011        std::fs::write(tmp.path().join("bzImage"), b"root-level").unwrap();
1012        let result = find_image_in_dir(tmp.path());
1013        assert_eq!(result, Some(boot.join("bzImage")));
1014    }
1015
1016    #[cfg(target_arch = "aarch64")]
1017    #[test]
1018    fn kernel_path_find_image_in_dir_prefers_build_tree_image() {
1019        // When both arch/arm64/boot/Image and root-level Image exist,
1020        // prefer the build-tree path. Pins the same precedence on
1021        // aarch64 as the x86_64 sibling — the build-tree branch in
1022        // `find_image_in_dir` runs before the cache-entry branch on
1023        // either arch.
1024        let tmp = TempDir::new().unwrap();
1025        let boot = tmp.path().join("arch/arm64/boot");
1026        std::fs::create_dir_all(&boot).unwrap();
1027        std::fs::write(boot.join("Image"), b"build-tree").unwrap();
1028        std::fs::write(tmp.path().join("Image"), b"root-level").unwrap();
1029        let result = find_image_in_dir(tmp.path());
1030        assert_eq!(result, Some(boot.join("Image")));
1031    }
1032
1033    #[cfg(target_arch = "x86_64")]
1034    #[test]
1035    fn kernel_path_has_artifacts_root_bzimage() {
1036        // Cache entry layout: bzImage at directory root.
1037        let tmp = TempDir::new().unwrap();
1038        std::fs::write(tmp.path().join("bzImage"), b"fake").unwrap();
1039        assert!(has_kernel_artifacts(tmp.path()));
1040    }
1041
1042    #[cfg(target_arch = "aarch64")]
1043    #[test]
1044    fn kernel_path_has_artifacts_root_image() {
1045        // Cache entry layout on aarch64: Image at directory root.
1046        let tmp = TempDir::new().unwrap();
1047        std::fs::write(tmp.path().join("Image"), b"fake").unwrap();
1048        assert!(has_kernel_artifacts(tmp.path()));
1049    }
1050
1051    // -- derive_kernel_dir --
1052
1053    #[cfg(target_arch = "x86_64")]
1054    #[test]
1055    fn derive_kernel_dir_build_tree_x86() {
1056        let tmp = TempDir::new().unwrap();
1057        let boot = tmp.path().join("arch/x86/boot");
1058        std::fs::create_dir_all(&boot).unwrap();
1059        let image = boot.join("bzImage");
1060        std::fs::write(&image, b"fake").unwrap();
1061
1062        let canon_root = std::fs::canonicalize(tmp.path()).unwrap();
1063        assert_eq!(derive_kernel_dir(&image), Some(canon_root));
1064    }
1065
1066    #[cfg(target_arch = "aarch64")]
1067    #[test]
1068    fn derive_kernel_dir_build_tree_aarch64() {
1069        let tmp = TempDir::new().unwrap();
1070        let boot = tmp.path().join("arch/arm64/boot");
1071        std::fs::create_dir_all(&boot).unwrap();
1072        let image = boot.join("Image");
1073        std::fs::write(&image, b"fake").unwrap();
1074
1075        let canon_root = std::fs::canonicalize(tmp.path()).unwrap();
1076        assert_eq!(derive_kernel_dir(&image), Some(canon_root));
1077    }
1078
1079    #[cfg(target_arch = "x86_64")]
1080    #[test]
1081    fn derive_kernel_dir_cache_entry_x86_with_vmlinux() {
1082        let tmp = TempDir::new().unwrap();
1083        let image = tmp.path().join("bzImage");
1084        std::fs::write(&image, b"fake").unwrap();
1085        std::fs::write(tmp.path().join("vmlinux"), b"fake-elf").unwrap();
1086
1087        let canon = std::fs::canonicalize(tmp.path()).unwrap();
1088        assert_eq!(derive_kernel_dir(&image), Some(canon));
1089    }
1090
1091    #[cfg(target_arch = "aarch64")]
1092    #[test]
1093    fn derive_kernel_dir_cache_entry_aarch64_with_vmlinux() {
1094        let tmp = TempDir::new().unwrap();
1095        let image = tmp.path().join("Image");
1096        std::fs::write(&image, b"fake").unwrap();
1097        std::fs::write(tmp.path().join("vmlinux"), b"fake-elf").unwrap();
1098
1099        let canon = std::fs::canonicalize(tmp.path()).unwrap();
1100        assert_eq!(derive_kernel_dir(&image), Some(canon));
1101    }
1102
1103    #[cfg(target_arch = "x86_64")]
1104    #[test]
1105    fn derive_kernel_dir_cache_entry_without_vmlinux() {
1106        // bzImage at root with no vmlinux sibling — neither layout
1107        // applies, return None.
1108        let tmp = TempDir::new().unwrap();
1109        let image = tmp.path().join("bzImage");
1110        std::fs::write(&image, b"fake").unwrap();
1111        assert_eq!(derive_kernel_dir(&image), None);
1112    }
1113
1114    #[cfg(target_arch = "aarch64")]
1115    #[test]
1116    fn derive_kernel_dir_cache_entry_without_vmlinux_aarch64() {
1117        // Image at root with no vmlinux sibling — neither the build-
1118        // tree suffix nor the cache-entry vmlinux probe applies,
1119        // return None. Mirror of the x86_64 sibling so a future
1120        // refactor that loosened the predicate on either arch trips
1121        // a test rather than silently mapping arbitrary
1122        // `Image`-named files to their parent directories.
1123        let tmp = TempDir::new().unwrap();
1124        let image = tmp.path().join("Image");
1125        std::fs::write(&image, b"fake").unwrap();
1126        assert_eq!(derive_kernel_dir(&image), None);
1127    }
1128
1129    #[test]
1130    fn derive_kernel_dir_nonexistent_path() {
1131        // canonicalize fails on a nonexistent path.
1132        let p = std::path::Path::new("/nonexistent/kernel/bzImage");
1133        assert_eq!(derive_kernel_dir(p), None);
1134    }
1135
1136    #[cfg(target_arch = "x86_64")]
1137    #[test]
1138    fn derive_kernel_dir_arbitrary_image_no_vmlinux_sibling() {
1139        // A file named bzImage but in a dir without a vmlinux sibling
1140        // and not under arch/x86/boot — no match.
1141        let tmp = TempDir::new().unwrap();
1142        let sub = tmp.path().join("somewhere/else");
1143        std::fs::create_dir_all(&sub).unwrap();
1144        let image = sub.join("bzImage");
1145        std::fs::write(&image, b"fake").unwrap();
1146        assert_eq!(derive_kernel_dir(&image), None);
1147    }
1148
1149    #[cfg(target_arch = "aarch64")]
1150    #[test]
1151    fn derive_kernel_dir_arbitrary_image_no_vmlinux_sibling_aarch64() {
1152        // A file named Image but neither under arch/arm64/boot nor
1153        // alongside a vmlinux sibling — no match. Mirror of the
1154        // x86_64 sibling.
1155        let tmp = TempDir::new().unwrap();
1156        let sub = tmp.path().join("somewhere/else");
1157        std::fs::create_dir_all(&sub).unwrap();
1158        let image = sub.join("Image");
1159        std::fs::write(&image, b"fake").unwrap();
1160        assert_eq!(derive_kernel_dir(&image), None);
1161    }
1162
1163    // -- resolve_btf --
1164
1165    #[test]
1166    fn kernel_path_resolve_btf_with_vmlinux_in_dir() {
1167        let tmp = TempDir::new().unwrap();
1168        std::fs::write(tmp.path().join("vmlinux"), b"fake").unwrap();
1169        let result = resolve_btf(Some(tmp.path().to_str().unwrap()));
1170        assert_eq!(result, Some(tmp.path().join("vmlinux")));
1171    }
1172
1173    #[test]
1174    fn kernel_path_resolve_btf_dir_without_vmlinux() {
1175        let tmp = TempDir::new().unwrap();
1176        // No vmlinux in dir; falls through to /sys/kernel/btf/vmlinux check.
1177        let result = resolve_btf(Some(tmp.path().to_str().unwrap()));
1178        // Result depends on host: either /sys/kernel/btf/vmlinux exists or None.
1179        if let Some(ref p) = result {
1180            assert!(p.exists());
1181        }
1182    }
1183
1184    #[test]
1185    fn kernel_path_resolve_btf_nonexistent_dir() {
1186        let result = resolve_btf(Some("/nonexistent/btf/dir/xyz"));
1187        // Dir doesn't exist so resolve_kernel returns None; falls to sysfs.
1188        if let Some(ref p) = result {
1189            assert!(p.exists());
1190        }
1191    }
1192
1193    // -- find_image --
1194
1195    #[cfg(target_arch = "x86_64")]
1196    #[test]
1197    fn kernel_path_find_image_explicit_dir_with_bzimage() {
1198        let tmp = TempDir::new().unwrap();
1199        let boot = tmp.path().join("arch/x86/boot");
1200        std::fs::create_dir_all(&boot).unwrap();
1201        std::fs::write(boot.join("bzImage"), b"fake").unwrap();
1202        let result = find_image(Some(tmp.path().to_str().unwrap()), None);
1203        assert_eq!(result, Some(boot.join("bzImage")));
1204    }
1205
1206    #[cfg(target_arch = "aarch64")]
1207    #[test]
1208    fn kernel_path_find_image_explicit_dir_with_image() {
1209        // `find_image(Some(dir), _)` must short-circuit to the
1210        // directory-local search and return the build-tree
1211        // arch/arm64/boot/Image without falling through to host
1212        // fallback paths. Mirror of the x86_64 sibling.
1213        let tmp = TempDir::new().unwrap();
1214        let boot = tmp.path().join("arch/arm64/boot");
1215        std::fs::create_dir_all(&boot).unwrap();
1216        std::fs::write(boot.join("Image"), b"fake").unwrap();
1217        let result = find_image(Some(tmp.path().to_str().unwrap()), None);
1218        assert_eq!(result, Some(boot.join("Image")));
1219    }
1220
1221    #[test]
1222    fn kernel_path_find_image_nonexistent_dir() {
1223        // Nonexistent explicit dir: `!dir.is_dir()` in the explicit-dir
1224        // branch hits `return None` (find_image ~line 592) with no
1225        // fallthrough to resolve_kernel / host fallback paths. The
1226        // return value None — not merely "did not panic" — is the
1227        // documented short-circuit contract, so pin it. A regression
1228        // that fell through to local/host search instead of
1229        // short-circuiting would be caught here.
1230        assert_eq!(
1231            find_image(Some("/nonexistent/image/dir/xyz"), None),
1232            None,
1233            "nonexistent explicit dir must short-circuit to None with no fallthrough",
1234        );
1235    }
1236
1237    #[test]
1238    fn kernel_path_find_image_release_none_matches_osrelease() {
1239        // The `/proc/sys/kernel/osrelease` path is hardcoded in
1240        // find_image and cannot be mocked, so the fallback can only
1241        // be verified by equivalence: read osrelease the way the
1242        // function does, then assert that find_image(None, None)
1243        // equals find_image(None, Some(<that value>)). Identical
1244        // post-`rel` logic in both calls means equal outputs prove
1245        // the None branch derived `rel` from osrelease (or both
1246        // short-circuited via resolve_kernel(None), which is also
1247        // a contract — no panic, no divergence).
1248        let host_release = std::fs::read_to_string("/proc/sys/kernel/osrelease")
1249            .expect("host /proc/sys/kernel/osrelease must be readable for this test")
1250            .trim()
1251            .to_string();
1252        assert!(
1253            !host_release.is_empty(),
1254            "/proc/sys/kernel/osrelease must be non-empty for this test",
1255        );
1256
1257        let derived = find_image(None, None);
1258        let explicit = find_image(None, Some(&host_release));
1259        assert_eq!(
1260            derived, explicit,
1261            "find_image(None, None) must equal find_image(None, Some(osrelease)); fallback diverged",
1262        );
1263    }
1264
1265    // -- KernelId parsing --
1266
1267    #[test]
1268    fn kernel_id_parse_path_with_slash() {
1269        assert_eq!(
1270            KernelId::parse("../linux"),
1271            KernelId::Path(PathBuf::from("../linux"))
1272        );
1273        assert_eq!(
1274            KernelId::parse("/boot/vmlinuz"),
1275            KernelId::Path(PathBuf::from("/boot/vmlinuz"))
1276        );
1277    }
1278
1279    #[test]
1280    fn kernel_id_parse_path_dot_prefix() {
1281        assert_eq!(
1282            KernelId::parse("./linux"),
1283            KernelId::Path(PathBuf::from("./linux"))
1284        );
1285        assert_eq!(KernelId::parse("."), KernelId::Path(PathBuf::from(".")));
1286    }
1287
1288    /// `~`-prefixed paths are expanded against `$HOME` at parse
1289    /// time so a downstream `is_dir()` sees the resolved path
1290    /// rather than the literal `~/...` (which the libc / Rust
1291    /// path APIs do not interpret — only shells do). Pins the
1292    /// expansion against an explicit `HOME` value so the test is
1293    /// independent of the running operator's environment.
1294    ///
1295    /// Uses the env-mutation `lock_env` helper to serialise the
1296    /// `HOME` write against any sibling test that reads
1297    /// `$HOME` — see `crate::test_support::test_helpers::lock_env`
1298    /// for the locking rationale. `EnvVarGuard::set` restores the
1299    /// prior value when the guard drops so the test does not leak
1300    /// the override into peers.
1301    #[test]
1302    fn kernel_id_parse_path_tilde_prefix_expands() {
1303        let _lock = crate::test_support::test_helpers::lock_env();
1304        let _home_guard =
1305            crate::test_support::test_helpers::EnvVarGuard::set("HOME", "/home/fixture-user");
1306        assert_eq!(
1307            KernelId::parse("~/linux"),
1308            KernelId::Path(PathBuf::from("/home/fixture-user/linux")),
1309        );
1310    }
1311
1312    /// Bare `~` (no slash) expands to `$HOME` exactly. Pins the
1313    /// degenerate single-character case so a future regression
1314    /// that special-cased "must contain `/` after `~`" lands
1315    /// here instead of silently leaving the literal `~`.
1316    #[test]
1317    fn kernel_id_parse_path_bare_tilde_expands() {
1318        let _lock = crate::test_support::test_helpers::lock_env();
1319        let _home_guard =
1320            crate::test_support::test_helpers::EnvVarGuard::set("HOME", "/home/fixture-user");
1321        assert_eq!(
1322            KernelId::parse("~"),
1323            KernelId::Path(PathBuf::from("/home/fixture-user")),
1324        );
1325    }
1326
1327    /// `$HOME` unset → no expansion possible. The literal `~/...`
1328    /// passes through verbatim. Downstream `is_dir()` will reject
1329    /// it normally, surfacing the missing-directory error rather
1330    /// than panicking inside the parser.
1331    #[test]
1332    fn kernel_id_parse_path_tilde_with_home_unset_passes_through() {
1333        let _lock = crate::test_support::test_helpers::lock_env();
1334        let _home_guard = crate::test_support::test_helpers::EnvVarGuard::remove("HOME");
1335        assert_eq!(
1336            KernelId::parse("~/linux"),
1337            KernelId::Path(PathBuf::from("~/linux")),
1338        );
1339    }
1340
1341    /// `~user/...` (different user) is NOT expanded because std
1342    /// has no `getpwnam`. Operator who wants a peer's home dir
1343    /// can spell it absolutely. Pin the no-op behavior so a
1344    /// future "shell out to getpwnam" addition has to update
1345    /// this test deliberately.
1346    #[test]
1347    fn kernel_id_parse_path_tilde_user_passes_through() {
1348        let _lock = crate::test_support::test_helpers::lock_env();
1349        let _home_guard =
1350            crate::test_support::test_helpers::EnvVarGuard::set("HOME", "/home/fixture-user");
1351        assert_eq!(
1352            KernelId::parse("~peer/linux"),
1353            KernelId::Path(PathBuf::from("~peer/linux")),
1354        );
1355    }
1356
1357    #[test]
1358    fn kernel_id_parse_version_stable() {
1359        assert_eq!(
1360            KernelId::parse("6.14.2"),
1361            KernelId::Version("6.14.2".to_string())
1362        );
1363        assert_eq!(
1364            KernelId::parse("6.14"),
1365            KernelId::Version("6.14".to_string())
1366        );
1367    }
1368
1369    #[test]
1370    fn kernel_id_parse_version_rc() {
1371        assert_eq!(
1372            KernelId::parse("6.15-rc3"),
1373            KernelId::Version("6.15-rc3".to_string())
1374        );
1375    }
1376
1377    #[test]
1378    fn kernel_id_parse_version_patch_rc() {
1379        assert_eq!(
1380            KernelId::parse("6.14.2-rc1"),
1381            KernelId::Version("6.14.2-rc1".to_string())
1382        );
1383    }
1384
1385    #[test]
1386    fn kernel_id_parse_cache_key() {
1387        assert_eq!(
1388            KernelId::parse("6.14.2-tarball-x86_64"),
1389            KernelId::CacheKey("6.14.2-tarball-x86_64".to_string())
1390        );
1391        assert_eq!(
1392            KernelId::parse("local-deadbeef-x86_64"),
1393            KernelId::CacheKey("local-deadbeef-x86_64".to_string())
1394        );
1395    }
1396
1397    #[test]
1398    fn kernel_id_parse_v_prefix_not_version() {
1399        // "v6.14" starts with 'v', not a digit -- cache key.
1400        assert_eq!(
1401            KernelId::parse("v6.14"),
1402            KernelId::CacheKey("v6.14".to_string())
1403        );
1404    }
1405
1406    #[test]
1407    fn kernel_id_parse_bare_major_is_version() {
1408        // "6" is a bare-major version prefix (resolves to the highest
1409        // 6.x.y patch via fetch_version_for_prefix), NOT a cache key.
1410        assert_eq!(KernelId::parse("6"), KernelId::Version("6".to_string()));
1411    }
1412
1413    #[test]
1414    fn kernel_id_display() {
1415        assert_eq!(
1416            KernelId::Version("6.14.2".to_string()).to_string(),
1417            "6.14.2"
1418        );
1419        assert_eq!(
1420            KernelId::Path(PathBuf::from("../linux")).to_string(),
1421            "../linux"
1422        );
1423        assert_eq!(
1424            KernelId::CacheKey("my-key".to_string()).to_string(),
1425            "my-key"
1426        );
1427        assert_eq!(
1428            KernelId::Range {
1429                start: "6.10".to_string(),
1430                end: "6.13".to_string(),
1431                syntax_inclusive: false,
1432            }
1433            .to_string(),
1434            "6.10..6.13",
1435        );
1436        assert_eq!(
1437            KernelId::Range {
1438                start: "6.10".to_string(),
1439                end: "6.13".to_string(),
1440                syntax_inclusive: true,
1441            }
1442            .to_string(),
1443            "6.10..=6.13",
1444        );
1445        assert_eq!(
1446            KernelId::Git {
1447                url: "https://example.com/r.git".to_string(),
1448                git_ref: "for-next".to_string(),
1449                ref_kind: GitRefKind::Branch,
1450            }
1451            .to_string(),
1452            "git+https://example.com/r.git#branch=for-next",
1453        );
1454        assert_eq!(
1455            KernelId::Git {
1456                url: "https://example.com/r.git".to_string(),
1457                git_ref: "v6.14".to_string(),
1458                ref_kind: GitRefKind::Tag,
1459            }
1460            .to_string(),
1461            "git+https://example.com/r.git#tag=v6.14",
1462        );
1463        assert_eq!(
1464            KernelId::Git {
1465                url: "https://example.com/r.git".to_string(),
1466                git_ref: "abc123".to_string(),
1467                ref_kind: GitRefKind::Sha,
1468            }
1469            .to_string(),
1470            "git+https://example.com/r.git#sha=abc123",
1471        );
1472        // Unknown (a bare `#REF`) round-trips verbatim so
1473        // parse(Display(x)) == x still holds for the reject case.
1474        assert_eq!(
1475            KernelId::Git {
1476                url: "https://example.com/r.git".to_string(),
1477                git_ref: "main".to_string(),
1478                ref_kind: GitRefKind::Unknown,
1479            }
1480            .to_string(),
1481            "git+https://example.com/r.git#main",
1482        );
1483    }
1484
1485    // -- KernelId::parse — Range arm --
1486
1487    #[test]
1488    fn kernel_id_parse_range_versions() {
1489        assert_eq!(
1490            KernelId::parse("6.10..6.15"),
1491            KernelId::Range {
1492                start: "6.10".to_string(),
1493                end: "6.15".to_string(),
1494                syntax_inclusive: false,
1495            },
1496        );
1497    }
1498
1499    /// `..=` (inclusive Rust syntax) and `..` both produce a Range
1500    /// with the SAME endpoints (and the same closed-range resolution
1501    /// semantics — both endpoints inclusive); they differ only in
1502    /// the `syntax_inclusive` flag, which round-trips through
1503    /// [`std::fmt::Display`] and the inverted-range error message
1504    /// so the operator-typed form is preserved verbatim. The `..=`
1505    /// arm is checked first in `parse`, so even though `..=` contains
1506    /// `..` as a substring the version-shaped split lands on `6.10`
1507    /// / `6.15`, not on `6.10` / `=6.15`.
1508    #[test]
1509    fn kernel_id_parse_range_inclusive_eq_syntax() {
1510        assert_eq!(
1511            KernelId::parse("6.10..=6.15"),
1512            KernelId::Range {
1513                start: "6.10".to_string(),
1514                end: "6.15".to_string(),
1515                syntax_inclusive: true,
1516            },
1517        );
1518    }
1519
1520    #[test]
1521    fn kernel_id_parse_range_patch_versions() {
1522        assert_eq!(
1523            KernelId::parse("6.10.5..6.10.10"),
1524            KernelId::Range {
1525                start: "6.10.5".to_string(),
1526                end: "6.10.10".to_string(),
1527                syntax_inclusive: false,
1528            },
1529        );
1530    }
1531
1532    #[test]
1533    fn kernel_id_parse_range_rc() {
1534        assert_eq!(
1535            KernelId::parse("6.10..6.10-rc3"),
1536            KernelId::Range {
1537                start: "6.10".to_string(),
1538                end: "6.10-rc3".to_string(),
1539                syntax_inclusive: false,
1540            },
1541        );
1542    }
1543
1544    /// Both endpoints non-version: not a Range. The `/`-contains
1545    /// test fails too, so this falls to the version-shaped check
1546    /// (also fails on the `..`) and lands as CacheKey.
1547    #[test]
1548    fn kernel_id_parse_range_non_version_falls_through() {
1549        assert_eq!(
1550            KernelId::parse("foo..bar"),
1551            KernelId::CacheKey("foo..bar".to_string()),
1552        );
1553    }
1554
1555    /// One endpoint version-shaped, the other not: the Range arm
1556    /// requires BOTH endpoints to pass `_is_version_string`, so
1557    /// `6.10..foo` falls through to CacheKey.
1558    #[test]
1559    fn kernel_id_parse_range_one_non_version() {
1560        assert_eq!(
1561            KernelId::parse("6.10..foo"),
1562            KernelId::CacheKey("6.10..foo".to_string()),
1563        );
1564    }
1565
1566    /// Trailing `..` with no second endpoint: `_is_version_string("")`
1567    /// is false, so the Range arm doesn't fire. Falls to CacheKey
1568    /// (the version-shaped check also fails because the trailing `..`
1569    /// means a parts-iter sees an empty patch component).
1570    #[test]
1571    fn kernel_id_parse_range_empty_endpoint() {
1572        assert_eq!(
1573            KernelId::parse("6.10.."),
1574            KernelId::CacheKey("6.10..".to_string()),
1575        );
1576    }
1577
1578    // -- KernelId::parse — Git arm (explicit #tag= / #branch= / #sha=) --
1579
1580    #[test]
1581    fn kernel_id_parse_git_branch() {
1582        assert_eq!(
1583            KernelId::parse("git+https://example.com/r.git#branch=main"),
1584            KernelId::Git {
1585                url: "https://example.com/r.git".to_string(),
1586                git_ref: "main".to_string(),
1587                ref_kind: GitRefKind::Branch,
1588            },
1589        );
1590    }
1591
1592    #[test]
1593    fn kernel_id_parse_git_tag() {
1594        assert_eq!(
1595            KernelId::parse("git+https://example.com/r.git#tag=v6.14"),
1596            KernelId::Git {
1597                url: "https://example.com/r.git".to_string(),
1598                git_ref: "v6.14".to_string(),
1599                ref_kind: GitRefKind::Tag,
1600            },
1601        );
1602    }
1603
1604    #[test]
1605    fn kernel_id_parse_git_sha() {
1606        let sha = "0123456789abcdef0123456789abcdef01234567";
1607        assert_eq!(
1608            KernelId::parse(&format!("git+https://example.com/r.git#sha={sha}")),
1609            KernelId::Git {
1610                url: "https://example.com/r.git".to_string(),
1611                git_ref: sha.to_string(),
1612                ref_kind: GitRefKind::Sha,
1613            },
1614        );
1615    }
1616
1617    /// A bare `#REF` (no `kind=`) parses to `GitRefKind::Unknown` so
1618    /// `validate` — not `parse` — surfaces the actionable error; `parse`
1619    /// returns `Self`, never `Result`. An unrecognized `#foo=` selector
1620    /// lands the same way (the whole fragment is kept as the ref).
1621    #[test]
1622    fn kernel_id_parse_git_bare_ref_is_unknown() {
1623        assert_eq!(
1624            KernelId::parse("git+https://example.com/r.git#main"),
1625            KernelId::Git {
1626                url: "https://example.com/r.git".to_string(),
1627                git_ref: "main".to_string(),
1628                ref_kind: GitRefKind::Unknown,
1629            },
1630        );
1631        assert_eq!(
1632            KernelId::parse("git+https://example.com/r.git#foo=bar"),
1633            KernelId::Git {
1634                url: "https://example.com/r.git".to_string(),
1635                git_ref: "foo=bar".to_string(),
1636                ref_kind: GitRefKind::Unknown,
1637            },
1638        );
1639    }
1640
1641    /// The Git arm splits the URL/fragment on the LAST `#` (so a `#`
1642    /// inside the URL survives), then the fragment on the FIRST `=`
1643    /// into kind/value — `git+https://x#frag#branch=main` parses as
1644    /// url=`https://x#frag`, branch=`main`. A regression to
1645    /// `split_once('#')` would flip the URL/ref.
1646    #[test]
1647    fn kernel_id_parse_git_multi_hash_url() {
1648        assert_eq!(
1649            KernelId::parse("git+https://x#frag#branch=main"),
1650            KernelId::Git {
1651                url: "https://x#frag".to_string(),
1652                git_ref: "main".to_string(),
1653                ref_kind: GitRefKind::Branch,
1654            },
1655        );
1656    }
1657
1658    /// Empty fragment after the `#` (`git+URL#`): the Git arm now
1659    /// claims ANY `git+…` string, so this parses to a Git with an empty
1660    /// `git_ref` and `GitRefKind::Unknown` rather than falling through
1661    /// to Path. That routes the typo into `validate`, which surfaces the
1662    /// actionable "ref kind must be explicit" error instead of a
1663    /// confusing filesystem "path not found".
1664    #[test]
1665    fn kernel_id_parse_git_empty_ref_is_git_unknown() {
1666        let id = KernelId::parse("git+https://example.com/r.git#");
1667        assert_eq!(
1668            id,
1669            KernelId::Git {
1670                url: "https://example.com/r.git".to_string(),
1671                git_ref: String::new(),
1672                ref_kind: GitRefKind::Unknown,
1673            },
1674        );
1675        assert!(
1676            id.validate().is_err(),
1677            "an empty/kind-less git fragment must be rejected by validate",
1678        );
1679    }
1680
1681    /// Empty URL before the `#`: the Git arm claims it (git+ takes
1682    /// precedence), producing a Git with an empty `url`. A valid `#tag=`
1683    /// selector isolates the empty-URL `validate` check (a kind-less
1684    /// fragment would also trip the Unknown check), pinning that the
1685    /// empty URL — not a silent CacheKey miss — is what gets rejected.
1686    #[test]
1687    fn kernel_id_parse_git_empty_url_is_rejected() {
1688        let id = KernelId::parse("git+#tag=x");
1689        assert_eq!(
1690            id,
1691            KernelId::Git {
1692                url: String::new(),
1693                git_ref: "x".to_string(),
1694                ref_kind: GitRefKind::Tag,
1695            },
1696        );
1697        let err = id
1698            .validate()
1699            .expect_err("an empty git URL must be rejected by validate");
1700        assert!(
1701            err.contains("empty URL"),
1702            "empty-URL git spec must surface the empty-URL diagnostic, got: {err}",
1703        );
1704    }
1705
1706    /// `git+` prefix takes precedence over the `/`-contains Path
1707    /// test. A user pointing at a local clone via `git+/local/repo#v1`
1708    /// should get a Git, not a Path. This pins the parse-arm
1709    /// ordering — flipping the Path check above the Git check would
1710    /// land here as KernelId::Path("git+/local/repo#v1").
1711    #[test]
1712    fn kernel_id_parse_git_beats_path() {
1713        assert_eq!(
1714            KernelId::parse("git+/local/repo#tag=v1"),
1715            KernelId::Git {
1716                url: "/local/repo".to_string(),
1717                git_ref: "v1".to_string(),
1718                ref_kind: GitRefKind::Tag,
1719            },
1720        );
1721    }
1722
1723    // -- KernelId::parse_list --
1724
1725    #[test]
1726    fn kernel_id_parse_list_basic() {
1727        let list = KernelId::parse_list("6.10,6.13");
1728        assert_eq!(
1729            list,
1730            vec![
1731                KernelId::Version("6.10".to_string()),
1732                KernelId::Version("6.13".to_string()),
1733            ],
1734        );
1735    }
1736
1737    #[test]
1738    fn kernel_id_parse_list_mixed() {
1739        let list = KernelId::parse_list("6.10,git+url#branch=main,/srv/linux");
1740        assert_eq!(list.len(), 3, "expected 3 entries, got {list:?}");
1741        assert!(matches!(list[0], KernelId::Version(ref v) if v == "6.10"));
1742        assert!(matches!(
1743            list[1],
1744            KernelId::Git { ref url, ref git_ref, ref_kind: GitRefKind::Branch }
1745                if url == "url" && git_ref == "main"
1746        ));
1747        assert!(matches!(list[2], KernelId::Path(ref p) if p == &PathBuf::from("/srv/linux")));
1748    }
1749
1750    #[test]
1751    fn kernel_id_parse_list_empty() {
1752        assert_eq!(KernelId::parse_list(""), Vec::<KernelId>::new());
1753    }
1754
1755    /// Trailing / leading / repeated commas are forgiving — empty
1756    /// segments are silently dropped so `,6.10,,` yields just one
1757    /// entry. Spec says: defer dedup to the resolver but do not
1758    /// inject empty Cache-key entries from an operator typo.
1759    #[test]
1760    fn kernel_id_parse_list_trailing_comma() {
1761        assert_eq!(
1762            KernelId::parse_list(",6.10,,"),
1763            vec![KernelId::Version("6.10".to_string())],
1764        );
1765    }
1766
1767    /// Whitespace around comma-separated entries gets trimmed before
1768    /// `parse` runs so `"6.10 , 6.13"` produces clean Version variants
1769    /// rather than CacheKey entries with embedded spaces.
1770    #[test]
1771    fn kernel_id_parse_list_whitespace() {
1772        assert_eq!(
1773            KernelId::parse_list("6.10 , 6.13"),
1774            vec![
1775                KernelId::Version("6.10".to_string()),
1776                KernelId::Version("6.13".to_string()),
1777            ],
1778        );
1779    }
1780
1781    /// A single-entry list with no commas falls through `split(',')`
1782    /// as one segment and produces the same Variant `parse` would
1783    /// have produced directly. Pins the parse_list/parse equivalence
1784    /// for the trivial case so a future regression that special-cased
1785    /// "must contain comma" lands here.
1786    #[test]
1787    fn kernel_id_parse_list_single() {
1788        assert_eq!(
1789            KernelId::parse_list("6.10"),
1790            vec![KernelId::Version("6.10".to_string())],
1791        );
1792    }
1793
1794    /// Duplicate entries are PRESERVED at parse time — `parse_list`
1795    /// is a pure splitter, and dedup is the resolver's job (after
1796    /// canonicalization to a cache key, since `6.10` and `v6.10` and
1797    /// a tag pointing at the same sha all collapse). Pin the count
1798    /// AND the index of each occurrence so a future regression that
1799    /// added an early dedup at parse time (which would silently
1800    /// collapse `6.10,6.10` to one entry and lose the operator's
1801    /// "run twice" intent if they later added that semantic) lands
1802    /// here.
1803    #[test]
1804    fn kernel_id_parse_list_preserves_dups() {
1805        let list = KernelId::parse_list("6.10,6.10,6.13");
1806        assert_eq!(list.len(), 3, "expected 3 entries, got {list:?}");
1807        assert_eq!(list[0], KernelId::Version("6.10".to_string()));
1808        assert_eq!(list[1], KernelId::Version("6.10".to_string()));
1809        assert_eq!(list[2], KernelId::Version("6.13".to_string()));
1810    }
1811
1812    // -- KernelId::validate — inverted-range rejection --
1813
1814    /// Forward range `6.10..6.13` validates fine — the most common
1815    /// happy-path case, here as a baseline for the failure tests
1816    /// below.
1817    #[test]
1818    fn kernel_id_validate_range_forward_ok() {
1819        let id = KernelId::parse("6.10..6.13");
1820        assert!(id.validate().is_ok(), "forward range must validate: {id:?}");
1821    }
1822
1823    /// Equal endpoints `6.10..6.10` validate fine — degenerate
1824    /// single-element range, not inverted.
1825    #[test]
1826    fn kernel_id_validate_range_equal_endpoints_ok() {
1827        let id = KernelId::parse("6.10..6.10");
1828        assert!(
1829            id.validate().is_ok(),
1830            "equal endpoints must validate: {id:?}"
1831        );
1832    }
1833
1834    /// `6.16..6.12` — same major, minor decreases. Reject. The error
1835    /// message must name both endpoints AND suggest the swapped
1836    /// spelling so the operator can fix the typo without re-reading
1837    /// the help.
1838    #[test]
1839    fn kernel_id_validate_range_inverted_minor() {
1840        let id = KernelId::parse("6.16..6.12");
1841        let err = id.validate().unwrap_err();
1842        assert!(
1843            err.contains("inverted kernel range"),
1844            "error must say 'inverted kernel range', got: {err}",
1845        );
1846        assert!(
1847            err.contains("6.16..6.12"),
1848            "error must cite the spec, got: {err}"
1849        );
1850        assert!(
1851            err.contains("6.12..6.16"),
1852            "error must suggest the swapped form, got: {err}",
1853        );
1854        // Load-bearing negative: the operator typed `..`, so the error
1855        // must NOT silently substitute `..=`. Pins the
1856        // `syntax_inclusive: false` branch of validate's separator
1857        // selection — a regression that always emitted `..=`
1858        // (regardless of the typed form) would still pass the
1859        // positive substring checks above, but trips this assertion.
1860        assert!(
1861            !err.contains("..="),
1862            "operator typed `..`, error must not silently switch to `..=`: {err}",
1863        );
1864    }
1865
1866    /// Mirror of `kernel_id_validate_range_inverted_minor` for the
1867    /// `..=` typed form. Pins the `syntax_inclusive: true` branch of
1868    /// validate's separator selection — the inverted-range error must
1869    /// preserve the operator's `..=` separator in both the as-typed
1870    /// citation and the swap suggestion, and must NOT silently
1871    /// substitute `..` (load-bearing negative). A regression that
1872    /// flipped the ternary in only one direction (Display correct,
1873    /// validate wrong, or vice versa) trips one of the four
1874    /// assertions.
1875    #[test]
1876    fn kernel_id_validate_range_inverted_minor_inclusive_eq_syntax() {
1877        let id = KernelId::parse("6.16..=6.12");
1878        let err = id.validate().unwrap_err();
1879        assert!(
1880            err.contains("inverted kernel range"),
1881            "error must say 'inverted kernel range', got: {err}",
1882        );
1883        assert!(
1884            err.contains("6.16..=6.12"),
1885            "error must cite the spec verbatim (typed `..=`), got: {err}",
1886        );
1887        assert!(
1888            err.contains("6.12..=6.16"),
1889            "error must suggest the swapped form preserving `..=`, got: {err}",
1890        );
1891        // Load-bearing negative: operator typed `..=`, so the error
1892        // must NOT contain a bare `..` separator between version-shaped
1893        // tokens. The "bare `..` between digits" pattern only appears
1894        // when the separator is wrong; `..=` substrings always have
1895        // the trailing `=`, so checking for any `..` not followed by
1896        // `=` catches the bug. Use a substring check on both renderings
1897        // we expect to be inclusive.
1898        assert!(
1899            !err.contains("6.16..6.12"),
1900            "operator typed `..=`, error must not switch the cited spec to `..`: {err}",
1901        );
1902        assert!(
1903            !err.contains("6.12..6.16"),
1904            "operator typed `..=`, error must not switch the swap suggestion to `..`: {err}",
1905        );
1906    }
1907
1908    /// Direct (non-parse) construction with `syntax_inclusive: true`
1909    /// still produces a `..=`-embedded error. Catches the gap where a
1910    /// caller bypasses [`KernelId::parse`] — e.g. config-file
1911    /// deserialization or a future builder API — and sets the flag
1912    /// directly. Validates that the flag drives the error format
1913    /// regardless of construction path.
1914    #[test]
1915    fn kernel_id_validate_range_inverted_inclusive_direct_construction() {
1916        let id = KernelId::Range {
1917            start: "6.16".to_string(),
1918            end: "6.12".to_string(),
1919            syntax_inclusive: true,
1920        };
1921        let err = id.validate().unwrap_err();
1922        assert!(
1923            err.contains("6.16..=6.12"),
1924            "direct-construction Range with syntax_inclusive=true must emit `..=` cite in error, got: {err}",
1925        );
1926        assert!(
1927            err.contains("6.12..=6.16"),
1928            "direct-construction Range with syntax_inclusive=true must emit `..=` swap in error, got: {err}",
1929        );
1930    }
1931
1932    /// End-to-end round-trip: parse → Display round-trips the typed
1933    /// form verbatim, AND validate's error message embeds the typed
1934    /// form in both the as-typed cite and the swap suggestion.
1935    /// Parameterized over both separator spellings so a regression
1936    /// that broke ONE direction trips here. Integration test for the
1937    /// `syntax_inclusive` contract across parse + Display + validate.
1938    #[test]
1939    fn kernel_id_display_inverted_range_round_trips_syntax_through_validate() {
1940        for (input, sep) in [("6.16..6.12", ".."), ("6.16..=6.12", "..=")] {
1941            let id = KernelId::parse(input);
1942            assert_eq!(
1943                id.to_string(),
1944                input,
1945                "Display must round-trip input verbatim for {input:?}",
1946            );
1947            let err = id.validate().unwrap_err();
1948            let cite = format!("6.16{sep}6.12");
1949            let suggest = format!("6.12{sep}6.16");
1950            assert!(
1951                err.contains(&cite),
1952                "validate error must cite `{cite}` for input {input:?}, got: {err}",
1953            );
1954            assert!(
1955                err.contains(&suggest),
1956                "validate error must suggest swap `{suggest}` for input {input:?}, got: {err}",
1957            );
1958        }
1959    }
1960
1961    /// `7.0..6.99` — major decreases. Reject.
1962    #[test]
1963    fn kernel_id_validate_range_inverted_major() {
1964        let id = KernelId::parse("7.0..6.99");
1965        assert!(id.validate().is_err(), "inverted major must reject: {id:?}");
1966    }
1967
1968    /// `6.10.5..6.10.3` — same major.minor, patch decreases. Reject.
1969    #[test]
1970    fn kernel_id_validate_range_inverted_patch() {
1971        let id = KernelId::parse("6.10.5..6.10.3");
1972        assert!(id.validate().is_err(), "inverted patch must reject: {id:?}");
1973    }
1974
1975    /// `6.10..6.10-rc3` — release > rc per the rc-as-MAX rule, so
1976    /// pre-release on the upper end is inverted. Reject. Catches the
1977    /// common operator mistake of "I want 6.10 latest stable up
1978    /// through the rc series" written in reverse order.
1979    #[test]
1980    fn kernel_id_validate_range_inverted_rc_below_release() {
1981        let id = KernelId::parse("6.10..6.10-rc3");
1982        assert!(
1983            id.validate().is_err(),
1984            "release > rc — `6.10..6.10-rc3` must reject: {id:?}",
1985        );
1986    }
1987
1988    /// `6.10-rc3..6.10` — pre-release < release. Forward direction;
1989    /// validate passes. The companion to `inverted_rc_below_release`.
1990    #[test]
1991    fn kernel_id_validate_range_rc_below_release_forward_ok() {
1992        let id = KernelId::parse("6.10-rc3..6.10");
1993        assert!(
1994            id.validate().is_ok(),
1995            "rc < release — `6.10-rc3..6.10` must validate: {id:?}",
1996        );
1997    }
1998
1999    /// `6.14.5..6.14` — an explicit-patch START into a 2-component END
2000    /// that names the WHOLE 6.14 series. `range_end_key` widens the END
2001    /// to the series ceiling (6,14,MAX,MAX), so this is the valid range
2002    /// "6.14.5 through the end of the 6.14 series", NOT an inversion.
2003    /// Regression pin: before END-series-inclusivity, validate used the
2004    /// un-widened end (6,14,0,MAX) and falsely rejected this while the
2005    /// expansion accepted it — the two disagreed; now they must agree.
2006    #[test]
2007    fn kernel_id_validate_range_explicit_start_patch_into_series_end_ok() {
2008        let id = KernelId::parse("6.14.5..6.14");
2009        assert!(
2010            id.validate().is_ok(),
2011            "explicit-patch start into a series END must validate: {id:?}",
2012        );
2013    }
2014
2015    /// `6.15..6.14` — a higher START minor than the END series must
2016    /// still reject even with the END widened to (6,14,MAX,MAX): the
2017    /// widening must not mask a real inversion.
2018    #[test]
2019    fn kernel_id_validate_range_series_end_still_rejects_higher_start() {
2020        let id = KernelId::parse("6.15..6.14");
2021        assert!(
2022            id.validate().is_err(),
2023            "higher start minor must still reject as inverted: {id:?}",
2024        );
2025    }
2026
2027    /// `6.10-rc3..6.10-rc1` — same major.minor.patch but rc decreases.
2028    /// Reject. Pre-release ordering must follow numeric rcN order.
2029    #[test]
2030    fn kernel_id_validate_range_inverted_rc_to_rc() {
2031        let id = KernelId::parse("6.10-rc3..6.10-rc1");
2032        assert!(id.validate().is_err(), "rc3..rc1 must reject: {id:?}");
2033    }
2034
2035    /// `6.10..6.10.5` — `6.10` decomposes to (6,10,0,MAX), `6.10.5`
2036    /// to (6,10,5,MAX). Forward direction. Validates.
2037    #[test]
2038    fn kernel_id_validate_range_missing_patch_treated_as_zero() {
2039        let id = KernelId::parse("6.10..6.10.5");
2040        assert!(
2041            id.validate().is_ok(),
2042            "missing patch defaults to 0, so `6.10..6.10.5` is forward: {id:?}",
2043        );
2044    }
2045
2046    /// All non-Range variants validate trivially — Path, Version,
2047    /// CacheKey, Git all return Ok. Pins the "validate is currently
2048    /// only meaningful for Range" contract: a future field with its
2049    /// own resolve-time invariant should add an arm here, not slip
2050    /// through silently.
2051    #[test]
2052    fn kernel_id_validate_non_range_variants_ok() {
2053        assert!(KernelId::Version("6.14.2".to_string()).validate().is_ok());
2054        assert!(KernelId::CacheKey("my-key".to_string()).validate().is_ok());
2055        assert!(KernelId::Path(PathBuf::from("../linux")).validate().is_ok(),);
2056        assert!(
2057            KernelId::Git {
2058                url: "https://example.com/r.git".to_string(),
2059                git_ref: "main".to_string(),
2060                ref_kind: GitRefKind::Branch,
2061            }
2062            .validate()
2063            .is_ok(),
2064        );
2065    }
2066
2067    /// The explicit git ref grammar rejects, at `validate`, a bare
2068    /// `#REF` (Unknown kind), an empty ref value, and a `#sha=` that
2069    /// is not a full 40-hex commit id. `parse` returns the variant;
2070    /// `validate` is the error channel (there is no `KernelId::Invalid`).
2071    #[test]
2072    fn kernel_id_validate_git_rejects_malformed_ref() {
2073        // Bare `#main` → Unknown → rejected with the "use #tag=/…" hint.
2074        let bare = KernelId::parse("git+https://example.com/r.git#main");
2075        let err = bare.validate().expect_err("bare #REF must be rejected");
2076        assert!(
2077            err.contains("#tag=") && err.contains("#branch=") && err.contains("#sha="),
2078            "error must name the accepted grammar: {err}"
2079        );
2080        // Empty value after a valid kind.
2081        assert!(
2082            KernelId::parse("git+https://example.com/r.git#tag=")
2083                .validate()
2084                .is_err(),
2085            "empty #tag= must be rejected"
2086        );
2087        // `#sha=` that is not 40 hex.
2088        assert!(
2089            KernelId::parse("git+https://example.com/r.git#sha=abc123")
2090                .validate()
2091                .is_err(),
2092            "short (non-40-hex) #sha= must be rejected"
2093        );
2094        let full = "0123456789abcdef0123456789abcdef01234567";
2095        assert!(
2096            KernelId::parse(&format!("git+https://example.com/r.git#sha={full}"))
2097                .validate()
2098                .is_ok(),
2099            "a full 40-hex #sha= must validate"
2100        );
2101    }
2102
2103    /// Direct construction with an unparseable `start` endpoint
2104    /// (callers that build `KernelId::Range` outside `KernelId::parse`
2105    /// can put any string in either slot — the Display round-trip
2106    /// gives them the spelling back, but `validate()` is the safety
2107    /// net for resolve-time legality). Asserts the error names the
2108    /// "not a parseable version" condition so a downstream tool can
2109    /// distinguish this from the inverted-range message above.
2110    #[test]
2111    fn kernel_id_validate_range_unparseable_start() {
2112        let id = KernelId::Range {
2113            start: "garbage".to_string(),
2114            end: "6.10".to_string(),
2115            syntax_inclusive: false,
2116        };
2117        let err = id.validate().unwrap_err();
2118        assert!(
2119            err.contains("not a parseable version"),
2120            "error must say 'not a parseable version', got: {err}",
2121        );
2122        assert!(
2123            err.contains("garbage"),
2124            "error must cite the bad endpoint, got: {err}"
2125        );
2126    }
2127
2128    /// Companion to `unparseable_start` for the `end` slot.
2129    #[test]
2130    fn kernel_id_validate_range_unparseable_end() {
2131        let id = KernelId::Range {
2132            start: "6.10".to_string(),
2133            end: "garbage".to_string(),
2134            syntax_inclusive: false,
2135        };
2136        let err = id.validate().unwrap_err();
2137        assert!(
2138            err.contains("not a parseable version"),
2139            "error must say 'not a parseable version', got: {err}",
2140        );
2141        assert!(
2142            err.contains("garbage"),
2143            "error must cite the bad endpoint, got: {err}"
2144        );
2145    }
2146
2147    // -- _is_version_string --
2148
2149    #[test]
2150    fn kernel_id_is_version_string_valid() {
2151        assert!(_is_version_string("6"), "bare major is a version prefix");
2152        assert!(_is_version_string("6.14"));
2153        assert!(_is_version_string("6.14.2"));
2154        assert!(_is_version_string("6.15-rc3"));
2155        assert!(_is_version_string("6.14.0-rc1"));
2156        assert!(_is_version_string("5.0"));
2157        assert!(_is_version_string("5.0.0"));
2158        assert!(_is_version_string("5.4.0"));
2159    }
2160
2161    #[test]
2162    fn kernel_id_is_version_string_invalid() {
2163        // Bare major `6` is now VALID (optional minor), but a non-digit
2164        // minor must still reject — pins the optional-minor boundary.
2165        assert!(!_is_version_string("6.x"));
2166        assert!(!_is_version_string("v6.14"));
2167        assert!(!_is_version_string(""));
2168        assert!(!_is_version_string("6.14.2-tarball-x86_64"));
2169        assert!(!_is_version_string("6.14.2.3"));
2170        assert!(!_is_version_string("6.14-rc"));
2171        assert!(!_is_version_string("6.14-rcX"));
2172        // rc_part contains non-digits after splitting on "-rc".
2173        assert!(!_is_version_string("6.14-rc3-tarball-x86_64"));
2174        assert!(!_is_version_string("abc"));
2175        assert!(!_is_version_string(".14"));
2176        assert!(!_is_version_string("6."));
2177        assert!(!_is_version_string("linux"));
2178        assert!(!_is_version_string(".6"));
2179    }
2180
2181    // -- proptest --
2182
2183    use proptest::prop_assert;
2184
2185    proptest::proptest! {
2186        /// Arbitrary input must parse into a `KernelId` variant whose
2187        /// payload round-trips to the original string where a
2188        /// round-trip is defined (Path / Version / CacheKey). Bumped
2189        /// the input range from 30 to 120 characters to exercise long
2190        /// paths and pathological multi-dot strings.
2191        ///
2192        /// Path payload round-trip is conditional on the leading
2193        /// `~`-expansion path: a `~`- or `~/...`-prefixed input
2194        /// that resolves against `$HOME` lands in `Path` with the
2195        /// expanded form, NOT the literal `~`-prefix the input
2196        /// carried. The proptest detects that case and asserts the
2197        /// expanded equivalence instead of the literal one. Other
2198        /// `~`-prefix shapes (`~user/...`, or any prefix when
2199        /// `$HOME` is empty/unset) pass through verbatim and the
2200        /// strict round-trip assertion still holds — see
2201        /// [`super::expand_tilde`] for the case table.
2202        #[test]
2203        fn prop_kernel_id_parse_never_panics(s in "\\PC{0,120}") {
2204            // Hold the env lock for each proptest iteration so a
2205            // parallel test that mutates `HOME` cannot race the
2206            // two `expand_tilde` reads (one inside `parse`, one
2207            // for the expected value below) and produce a
2208            // false-positive payload-drift failure on a
2209            // `~`-prefixed input.
2210            let _env_lock = crate::test_support::test_helpers::lock_env();
2211            match KernelId::parse(&s) {
2212                KernelId::Path(p) => {
2213                    let expected = expand_tilde(&s);
2214                    prop_assert!(
2215                        p == expected,
2216                        "Path payload drift for {s:?}: got {p:?}, expected {expected:?}",
2217                    );
2218                }
2219                KernelId::Version(v) => prop_assert!(v == s, "Version payload drift for {s:?}"),
2220                KernelId::CacheKey(k) => prop_assert!(k == s, "CacheKey payload drift for {s:?}"),
2221                KernelId::Range { start, end, syntax_inclusive } => {
2222                    // Range is constructed only when both endpoints
2223                    // are version-shaped, so the payload round-trips
2224                    // through the `start..end` (or `start..=end`)
2225                    // rendering. Display emits the same separator
2226                    // the parser consumed, tracked via
2227                    // `syntax_inclusive`.
2228                    let sep = if syntax_inclusive { "..=" } else { ".." };
2229                    prop_assert!(
2230                        format!("{start}{sep}{end}") == s,
2231                        "Range payload drift for {s:?}",
2232                    );
2233                }
2234                KernelId::Git { .. } => {
2235                    // Any `git+…` string parses to a Git value. A STRING
2236                    // round-trip cannot hold for every input: `git+URL`
2237                    // and `git+URL#` both parse to the same value
2238                    // (`Unknown` kind, empty ref), and `Display` always
2239                    // renders a `#`, so `git+URL` != Display(parse("git+URL")).
2240                    // Assert the SEMANTIC round-trip instead — Display
2241                    // then re-parse yields an equal id — which holds for
2242                    // every kind including the `#`-free / empty-fragment
2243                    // spellings.
2244                    let id = KernelId::parse(&s);
2245                    let reparsed = KernelId::parse(&id.to_string());
2246                    prop_assert!(
2247                        reparsed == id,
2248                        "Git semantic round-trip drift for {s:?}: \
2249                         {id:?} -> {:?} -> {reparsed:?}",
2250                        id.to_string(),
2251                    );
2252                }
2253            }
2254        }
2255
2256        #[test]
2257        fn prop_kernel_id_path_on_slash(
2258            prefix in "[a-z]{1,5}",
2259            suffix in "[a-z]{1,5}",
2260        ) {
2261            let s = format!("{prefix}/{suffix}");
2262            assert!(matches!(KernelId::parse(&s), KernelId::Path(_)));
2263        }
2264
2265        #[test]
2266        fn prop_kernel_id_path_on_dot_prefix(s in "\\.[a-z]{1,10}") {
2267            assert!(matches!(KernelId::parse(&s), KernelId::Path(_)));
2268        }
2269
2270        #[test]
2271        fn prop_kernel_id_version_roundtrip(
2272            major in 1u32..20,
2273            minor in 0u32..50,
2274            patch in 0u32..100,
2275        ) {
2276            let v = format!("{major}.{minor}.{patch}");
2277            assert_eq!(KernelId::parse(&v), KernelId::Version(v.clone()));
2278        }
2279
2280        #[test]
2281        fn prop_kernel_id_version_rc(major in 1u32..20, minor in 0u32..50, rc in 1u32..10) {
2282            let v = format!("{major}.{minor}-rc{rc}");
2283            assert_eq!(KernelId::parse(&v), KernelId::Version(v.clone()));
2284        }
2285    }
2286}