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}