ktstr/cache/
resolve.rs

1//! Cache root resolution and source-tree path helpers.
2//!
3//! Two responsibilities live here:
4//!
5//! 1. **Cache-root resolution.** [`resolve_cache_root_with_suffix`]
6//!    runs the env cascade (`KTSTR_CACHE_DIR` → `$XDG_CACHE_HOME` →
7//!    `$HOME/.cache`) that turns a per-cache `suffix` (e.g. `kernels`,
8//!    `disk_templates`) into an absolute cache directory path. HOME validation
9//!    ([`validate_home_for_cache`]) gates the third fallback so a
10//!    suid-stripped or root-but-no-HOME process produces a clear error
11//!    rather than writing into `/root/.cache/...` by accident.
12//!    [`path_inside_cache_root`] is the cache-membership predicate used
13//!    by callers (notably the BTF probe) to decide
14//!    whether a path on disk is "ours" before applying cache-aware
15//!    invalidation.
16//!
17//! 2. **Source-tree DWARF re-routing.** [`prefer_source_tree_for_dwarf`]
18//!    short-circuits the cache when a cache entry's persisted source
19//!    tree still holds a vmlinux matching the cached one's identity
20//!    (size + mtime). Combined with [`recover_local_source_tree`]
21//!    (which reads `metadata.json` to find the canonical source path
22//!    for a cached entry), this lets `cargo ktstr test` reuse the
23//!    operator's tree directly and avoid stripping debug info from a
24//!    perfectly good local vmlinux.
25//!
26//! Tests cover the env-cascade arms, HOME-rejection cases, the
27//! cache-membership predicate's symlink-resolution semantics, the
28//! DWARF preference policy, and metadata-driven source-tree recovery.
29
30use std::fs;
31use std::path::{Path, PathBuf};
32
33use super::housekeeping::read_metadata;
34use super::metadata::KernelSource;
35
36/// Resolve the cache root directory path with a per-cache `suffix`
37/// (`"kernels"` for the kernel cache, `"disk_templates"` for disk-template images).
38///
39/// Resolution cascade:
40/// 1. `KTSTR_CACHE_DIR` (with non-UTF-8 bail). The override returns
41///    the path verbatim — no `suffix` is appended.
42/// 2. `XDG_CACHE_HOME/ktstr/{suffix}` when set and non-empty.
43/// 3. `$HOME/.cache/ktstr/{suffix}` after HOME validation.
44///
45/// Does not create the directory.
46pub(crate) fn resolve_cache_root_with_suffix(suffix: &str) -> anyhow::Result<PathBuf> {
47    match std::env::var(crate::KTSTR_CACHE_DIR_ENV) {
48        Ok(dir) if !dir.is_empty() => return Ok(PathBuf::from(dir)),
49        Ok(_) => { /* empty string -> fall through to fallbacks */ }
50        Err(std::env::VarError::NotPresent) => { /* unset -> fall through */ }
51        Err(std::env::VarError::NotUnicode(raw)) => {
52            anyhow::bail!(
53                "KTSTR_CACHE_DIR contains non-UTF-8 bytes ({} bytes): {:?}. \
54                 ktstr requires a UTF-8 cache path — set KTSTR_CACHE_DIR \
55                 to an ASCII/UTF-8 directory (e.g. `/tmp/ktstr-cache`) or \
56                 unset it to fall back to $XDG_CACHE_HOME/$HOME.",
57                raw.len(),
58                raw,
59            );
60        }
61    }
62    if let Ok(xdg) = std::env::var("XDG_CACHE_HOME")
63        && !xdg.is_empty()
64    {
65        return Ok(PathBuf::from(xdg).join("ktstr").join(suffix));
66    }
67    let home = validate_home_for_cache()?;
68    Ok(home.join(".cache").join("ktstr").join(suffix))
69}
70
71/// Read `HOME` from the environment, reject values that produce a
72/// guaranteed-junk cache path, and return the validated `PathBuf`.
73///
74/// The function exists because the `$HOME/.cache/ktstr/...` fallback
75/// is the LAST stage of [`resolve_cache_root_with_suffix`]'s cascade
76/// — by the time we get here, neither `KTSTR_CACHE_DIR` nor
77/// `XDG_CACHE_HOME` was set, so `HOME` is the only remaining input
78/// that can name a cache root. A bad `HOME` here means the operator
79/// has no working cache location at all, so we fail loudly with a
80/// remediation hint rather than silently writing into `/.cache/...`
81/// or a relative-to-cwd directory that breaks every subsequent
82/// invocation from a different cwd.
83///
84/// # Rejection cases
85///
86/// 1. **HOME missing** (unset OR empty string). Both arms of the
87///    `std::env::var("HOME")` match — `Err(NotPresent)` and
88///    `Ok("")` — bail with the same family of remediation. Empty
89///    is just as broken as unset because every PathBuf join gives
90///    a relative path. Common cause: a Dockerfile / login init
91///    that emits `export HOME=` or `ENV HOME=` with no value.
92/// 2. **HOME = `/`.** Joining `/.cache/ktstr/<suffix>` aliases a
93///    kernel-root path rather than a per-user cache. Common cause:
94///    a root login that never set HOME, leaving it at the libc
95///    default. Bail with a suggestion to use `KTSTR_CACHE_DIR` or
96///    `XDG_CACHE_HOME`.
97/// 3. **HOME is relative.** Anything that doesn't start with `/`.
98///    A relative HOME would resolve against the operator's cwd
99///    at every invocation, so the cache root would silently move
100///    around — every test run would miss the previous build's
101///    output. Bail with the same env-override hint.
102///
103/// On success, returns the validated absolute path as a `PathBuf`
104/// for the caller to extend with `.cache/ktstr/<suffix>`.
105pub(crate) fn validate_home_for_cache() -> anyhow::Result<PathBuf> {
106    let home = match std::env::var("HOME") {
107        Ok(v) if !v.is_empty() => v,
108        Ok(_) => {
109            anyhow::bail!(
110                "HOME is set to the empty string; cannot resolve cache directory. \
111                 An empty HOME usually means a Dockerfile or shell rc has \
112                 `export HOME=` or `ENV HOME=` with no value. Either set HOME \
113                 to a real absolute path, or set KTSTR_CACHE_DIR to an absolute \
114                 path (e.g. /tmp/ktstr-cache) or XDG_CACHE_HOME to specify a \
115                 cache location explicitly."
116            );
117        }
118        Err(_) => {
119            anyhow::bail!(
120                "HOME is unset; cannot resolve cache directory. \
121                 The container init or login shell did not assign HOME — set \
122                 it to an absolute path, or set KTSTR_CACHE_DIR to an absolute \
123                 path (e.g. /tmp/ktstr-cache) or XDG_CACHE_HOME to specify a \
124                 cache location explicitly."
125            );
126        }
127    };
128    if home == "/" {
129        anyhow::bail!(
130            "HOME is `/`; the resulting cache path /.cache/ktstr would alias the \
131             root filesystem rather than naming a user cache. This usually means \
132             the process inherited HOME from a container init or root login that \
133             did not set a real home. Set KTSTR_CACHE_DIR to an absolute path \
134             (e.g. /tmp/ktstr-cache) or XDG_CACHE_HOME to bypass HOME entirely."
135        );
136    }
137    if !home.starts_with('/') {
138        anyhow::bail!(
139            "HOME={home:?} is not an absolute path; ktstr requires HOME to start \
140             with `/` so the cache root resolves consistently regardless of the \
141             current working directory. Set HOME to an absolute path, or set \
142             KTSTR_CACHE_DIR / XDG_CACHE_HOME to a specific cache location."
143        );
144    }
145    Ok(PathBuf::from(home))
146}
147
148/// Resolve the kernel cache root directory path.
149pub(crate) fn resolve_cache_root() -> anyhow::Result<PathBuf> {
150    resolve_cache_root_with_suffix("kernels")
151}
152
153/// Resolve the directory for LLC and per-CPU lock files.
154///
155/// Resolution: `KTSTR_LOCK_DIR` when set and non-empty, otherwise
156/// `/tmp`. The fallback matches the historical default.
157pub(crate) fn resolve_lock_dir() -> PathBuf {
158    match std::env::var(crate::KTSTR_LOCK_DIR_ENV) {
159        Ok(dir) if !dir.is_empty() => PathBuf::from(dir),
160        _ => PathBuf::from("/tmp"),
161    }
162}
163
164/// Re-route a cache-entry directory to its original source tree when
165/// blazesym DWARF access is required. Validates the source-tree
166/// vmlinux's current size and mtime against the values captured at
167/// cache-store time.
168pub fn prefer_source_tree_for_dwarf(dir: &Path) -> Option<PathBuf> {
169    let metadata = read_metadata(dir).ok()?;
170    let want_size = metadata.source_vmlinux_size?;
171    let want_mtime = metadata.source_vmlinux_mtime_secs?;
172    let KernelSource::Local {
173        source_tree_path, ..
174    } = metadata.source
175    else {
176        return None;
177    };
178    let src_path = source_tree_path?;
179    let vmlinux = src_path.join("vmlinux");
180    let stat = std::fs::metadata(&vmlinux).ok()?;
181    if !stat.is_file() {
182        return None;
183    }
184    if stat.len() != want_size {
185        return None;
186    }
187    let cur_mtime = stat.modified().ok().and_then(|t| {
188        t.duration_since(std::time::UNIX_EPOCH)
189            .map(|d| d.as_secs() as i64)
190            .ok()
191            .or_else(|| {
192                std::time::UNIX_EPOCH
193                    .duration_since(t)
194                    .ok()
195                    .map(|d| -(d.as_secs() as i64))
196            })
197    })?;
198    if cur_mtime != want_mtime {
199        return None;
200    }
201    Some(src_path)
202}
203
204/// Read `dir/metadata.json` and return the persisted source-tree
205/// path when the entry was built from a local source tree.
206pub fn recover_local_source_tree(dir: &Path) -> Option<PathBuf> {
207    let metadata = read_metadata(dir).ok()?;
208    if let KernelSource::Local {
209        source_tree_path: Some(p),
210        ..
211    } = metadata.source
212    {
213        return Some(p);
214    }
215    None
216}
217
218/// Is `p` (a file path) located inside the kernel cache root?
219pub(crate) fn path_inside_cache_root(p: &Path) -> bool {
220    let root = match resolve_cache_root() {
221        Ok(r) => r,
222        Err(e) => {
223            tracing::debug!(
224                err = %e,
225                "cache root unresolvable; treating path as outside cache",
226            );
227            return false;
228        }
229    };
230    let canon_root = match fs::canonicalize(&root) {
231        Ok(r) => r,
232        Err(e) => {
233            tracing::debug!(
234                root = %root.display(),
235                err = %e,
236                "cache root canonicalize failed; treating path as outside cache",
237            );
238            return false;
239        }
240    };
241    let parent = match p.parent() {
242        Some(p) if !p.as_os_str().is_empty() => p,
243        _ => return false,
244    };
245    let canon_parent = match fs::canonicalize(parent) {
246        Ok(p) => p,
247        Err(e) => {
248            tracing::debug!(
249                parent = %parent.display(),
250                err = %e,
251                "input path parent canonicalize failed; treating as outside cache",
252            );
253            return false;
254        }
255    };
256    canon_parent.starts_with(&canon_root)
257}
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262    use crate::test_support::test_helpers::{EnvVarGuard, lock_env};
263    use std::fs;
264    use tempfile::TempDir;
265
266    // -- resolve_cache_root --
267
268    #[test]
269    fn cache_resolve_root_ktstr_cache_dir() {
270        let _lock = lock_env();
271        let tmp = TempDir::new().unwrap();
272        let dir = tmp.path().join("custom-cache");
273        let _guard = EnvVarGuard::set(crate::KTSTR_CACHE_DIR_ENV, &dir);
274        let root = resolve_cache_root().unwrap();
275        assert_eq!(root, dir);
276    }
277
278    #[test]
279    fn cache_resolve_root_xdg_cache_home() {
280        let _lock = lock_env();
281        let tmp = TempDir::new().unwrap();
282        let _guard1 = EnvVarGuard::remove(crate::KTSTR_CACHE_DIR_ENV);
283        let _guard2 = EnvVarGuard::set("XDG_CACHE_HOME", tmp.path());
284        let root = resolve_cache_root().unwrap();
285        assert_eq!(root, tmp.path().join("ktstr").join("kernels"));
286    }
287
288    #[test]
289    fn cache_resolve_root_empty_ktstr_cache_dir_falls_through() {
290        let _lock = lock_env();
291        let tmp = TempDir::new().unwrap();
292        let _guard1 = EnvVarGuard::set(crate::KTSTR_CACHE_DIR_ENV, "");
293        let _guard2 = EnvVarGuard::set("XDG_CACHE_HOME", tmp.path());
294        let root = resolve_cache_root().unwrap();
295        assert_eq!(root, tmp.path().join("ktstr").join("kernels"));
296    }
297
298    #[test]
299    fn cache_resolve_root_empty_xdg_falls_to_home() {
300        let _lock = lock_env();
301        let tmp = TempDir::new().unwrap();
302        let _guard1 = EnvVarGuard::remove(crate::KTSTR_CACHE_DIR_ENV);
303        let _guard2 = EnvVarGuard::set("XDG_CACHE_HOME", "");
304        let _guard3 = EnvVarGuard::set("HOME", tmp.path());
305        let root = resolve_cache_root().unwrap();
306        assert_eq!(
307            root,
308            tmp.path().join(".cache").join("ktstr").join("kernels")
309        );
310    }
311
312    // -- resolve_cache_root error paths --
313
314    #[test]
315    fn cache_resolve_root_home_unset_error() {
316        let _lock = lock_env();
317        let _guard1 = EnvVarGuard::remove(crate::KTSTR_CACHE_DIR_ENV);
318        let _guard2 = EnvVarGuard::remove("XDG_CACHE_HOME");
319        let _guard3 = EnvVarGuard::remove("HOME");
320        let err = resolve_cache_root().unwrap_err();
321        let msg = err.to_string();
322        assert!(
323            msg.contains("HOME is unset"),
324            "expected HOME-unset error, got: {msg}"
325        );
326        assert!(
327            !msg.contains("HOME is set to the empty string"),
328            "unset HOME must NOT use the empty-string diagnostic — the two \
329             cases are distinct now (NotPresent vs Ok(\"\")), got: {msg}",
330        );
331        assert!(
332            msg.contains("KTSTR_CACHE_DIR"),
333            "error should suggest KTSTR_CACHE_DIR, got: {msg}"
334        );
335    }
336
337    #[test]
338    fn cache_resolve_root_home_root_slash_error() {
339        let _lock = lock_env();
340        let _guard1 = EnvVarGuard::remove(crate::KTSTR_CACHE_DIR_ENV);
341        let _guard2 = EnvVarGuard::remove("XDG_CACHE_HOME");
342        let _guard3 = EnvVarGuard::set("HOME", "/");
343        let err = resolve_cache_root().unwrap_err();
344        let msg = err.to_string();
345        assert!(
346            msg.contains("HOME is `/`"),
347            "expected HOME=/ specific error, got: {msg}"
348        );
349        assert!(
350            msg.contains("/.cache/ktstr"),
351            "diagnostic must cite the offending cache path, got: {msg}"
352        );
353        assert!(
354            msg.contains("KTSTR_CACHE_DIR"),
355            "error should suggest KTSTR_CACHE_DIR, got: {msg}"
356        );
357    }
358
359    #[test]
360    fn cache_resolve_root_home_empty_error() {
361        let _lock = lock_env();
362        let _guard1 = EnvVarGuard::remove(crate::KTSTR_CACHE_DIR_ENV);
363        let _guard2 = EnvVarGuard::remove("XDG_CACHE_HOME");
364        let _guard3 = EnvVarGuard::set("HOME", "");
365        let err = resolve_cache_root().unwrap_err();
366        let msg = err.to_string();
367        assert!(
368            msg.contains("HOME is set to the empty string"),
369            "empty-HOME bail must use the empty-string diagnostic, got: {msg}",
370        );
371        assert!(
372            !msg.contains("HOME is unset"),
373            "empty-HOME must NOT use the unset diagnostic — the two \
374             cases are distinct now, got: {msg}",
375        );
376    }
377
378    #[test]
379    fn cache_resolve_root_home_relative_path_error() {
380        let _lock = lock_env();
381        let _guard1 = EnvVarGuard::remove(crate::KTSTR_CACHE_DIR_ENV);
382        let _guard2 = EnvVarGuard::remove("XDG_CACHE_HOME");
383        let _guard3 = EnvVarGuard::set("HOME", "relative/dir");
384        let err = resolve_cache_root().unwrap_err();
385        let msg = err.to_string();
386        assert!(
387            msg.contains("not an absolute path"),
388            "expected relative-path-specific error, got: {msg}"
389        );
390        assert!(
391            msg.contains("relative/dir"),
392            "diagnostic must cite the offending HOME value, got: {msg}"
393        );
394        assert!(
395            msg.contains("KTSTR_CACHE_DIR"),
396            "error should suggest KTSTR_CACHE_DIR, got: {msg}"
397        );
398    }
399
400    #[test]
401    fn cache_resolve_root_home_bare_name_relative_error() {
402        let _lock = lock_env();
403        let _guard1 = EnvVarGuard::remove(crate::KTSTR_CACHE_DIR_ENV);
404        let _guard2 = EnvVarGuard::remove("XDG_CACHE_HOME");
405        let _guard3 = EnvVarGuard::set("HOME", "tmp");
406        let err = resolve_cache_root().unwrap_err();
407        let msg = err.to_string();
408        assert!(
409            msg.contains("not an absolute path"),
410            "expected relative-path-specific error, got: {msg}"
411        );
412        assert!(
413            msg.contains("\"tmp\""),
414            "diagnostic must cite the offending HOME value via its Debug \
415             representation, got: {msg}"
416        );
417    }
418
419    #[test]
420    fn cache_resolve_root_home_absolute_passes() {
421        let _lock = lock_env();
422        let _guard1 = EnvVarGuard::remove(crate::KTSTR_CACHE_DIR_ENV);
423        let _guard2 = EnvVarGuard::remove("XDG_CACHE_HOME");
424        let tmp = TempDir::new().expect("tempdir");
425        let _guard3 = EnvVarGuard::set("HOME", tmp.path());
426        let resolved = resolve_cache_root().expect("absolute HOME must resolve");
427        let expected = tmp.path().join(".cache").join("ktstr").join("kernels");
428        assert_eq!(
429            resolved, expected,
430            "absolute HOME must produce $HOME/.cache/ktstr/kernels",
431        );
432    }
433
434    #[test]
435    #[cfg(unix)]
436    fn cache_resolve_root_non_utf8_ktstr_cache_dir_bails() {
437        let _lock = lock_env();
438        use std::ffi::OsStr;
439        use std::os::unix::ffi::OsStrExt;
440        let bytes: &[u8] = b"/tmp/ktstr-\xFFcache";
441        let value = OsStr::from_bytes(bytes);
442        let _guard = EnvVarGuard::set(crate::KTSTR_CACHE_DIR_ENV, value);
443        let err = resolve_cache_root()
444            .expect_err("non-UTF-8 KTSTR_CACHE_DIR must bail, not silently fall through");
445        let msg = err.to_string();
446        assert!(
447            msg.contains("KTSTR_CACHE_DIR"),
448            "error must name the offending variable, got: {msg}",
449        );
450        assert!(
451            msg.contains("non-UTF-8"),
452            "error must mention non-UTF-8 so the operator knows the encoding, \
453             got: {msg}",
454        );
455        assert!(
456            msg.contains("UTF-8") || msg.contains("unset") || msg.contains("ASCII"),
457            "error must name a remediation (UTF-8 replacement or unset), \
458             got: {msg}",
459        );
460    }
461
462    // -- path_inside_cache_root direct unit tests --
463
464    #[test]
465    fn path_inside_cache_root_accepts_path_inside() {
466        let _lock = lock_env();
467        let tmp = TempDir::new().unwrap();
468        let _guard = EnvVarGuard::set(crate::KTSTR_CACHE_DIR_ENV, tmp.path());
469        let entry = tmp.path().join("kentry");
470        std::fs::create_dir_all(&entry).unwrap();
471        let vmlinux = entry.join("vmlinux");
472        std::fs::write(&vmlinux, b"placeholder").unwrap();
473        assert!(
474            path_inside_cache_root(&vmlinux),
475            "vmlinux directly under cache root must be classified as in-cache",
476        );
477    }
478
479    #[test]
480    fn path_inside_cache_root_rejects_path_outside() {
481        let _lock = lock_env();
482        let cache_root = TempDir::new().unwrap();
483        let _guard = EnvVarGuard::set(crate::KTSTR_CACHE_DIR_ENV, cache_root.path());
484        let source_tree = TempDir::new().unwrap();
485        let vmlinux = source_tree.path().join("vmlinux");
486        std::fs::write(&vmlinux, b"placeholder").unwrap();
487        assert!(
488            !path_inside_cache_root(&vmlinux),
489            "vmlinux in a sibling tempdir must NOT be classified as in-cache",
490        );
491    }
492
493    #[test]
494    fn path_inside_cache_root_rejects_bare_filename() {
495        let _lock = lock_env();
496        let tmp = TempDir::new().unwrap();
497        let _guard = EnvVarGuard::set(crate::KTSTR_CACHE_DIR_ENV, tmp.path());
498        let bare = std::path::Path::new("vmlinux");
499        assert!(
500            !path_inside_cache_root(bare),
501            "bare filename (no parent) must short-circuit to false",
502        );
503    }
504
505    #[test]
506    fn path_inside_cache_root_false_when_unresolvable() {
507        let _lock = lock_env();
508        let _g1 = EnvVarGuard::remove(crate::KTSTR_CACHE_DIR_ENV);
509        let _g2 = EnvVarGuard::remove("XDG_CACHE_HOME");
510        let _g3 = EnvVarGuard::remove("HOME");
511        let dir = TempDir::new().unwrap();
512        let f = dir.path().join("vmlinux");
513        std::fs::write(&f, b"x").unwrap();
514        assert!(
515            !path_inside_cache_root(&f),
516            "unresolvable cache root must classify as outside-cache (false)",
517        );
518    }
519
520    #[test]
521    fn path_inside_cache_root_false_when_parent_canonicalize_fails() {
522        let _lock = lock_env();
523        let tmp = TempDir::new().unwrap();
524        let _guard = EnvVarGuard::set(crate::KTSTR_CACHE_DIR_ENV, tmp.path());
525        let nonexistent = std::path::Path::new("/this/parent/should/not/exist/vmlinux");
526        assert!(
527            !nonexistent.parent().unwrap().exists(),
528            "precondition: parent must not exist for the canonicalize \
529             failure path to be exercised",
530        );
531        assert!(
532            !path_inside_cache_root(nonexistent),
533            "nonexistent parent must surface as outside-cache, not panic",
534        );
535    }
536
537    #[test]
538    #[cfg(unix)]
539    fn path_inside_cache_root_follows_symlink_into_cache() {
540        let _lock = lock_env();
541        let cache_root = TempDir::new().unwrap();
542        let _guard = EnvVarGuard::set(crate::KTSTR_CACHE_DIR_ENV, cache_root.path());
543        let entry = cache_root.path().join("kentry");
544        std::fs::create_dir_all(&entry).unwrap();
545        let real = entry.join("vmlinux");
546        std::fs::write(&real, b"placeholder").unwrap();
547        let outside = TempDir::new().unwrap();
548        let alias_parent = outside.path().join("alias");
549        std::os::unix::fs::symlink(&entry, &alias_parent).unwrap();
550        let through_alias = alias_parent.join("vmlinux");
551        assert!(
552            through_alias.exists(),
553            "precondition: path through symlinked parent must be reachable",
554        );
555        assert!(
556            path_inside_cache_root(&through_alias),
557            "path whose parent symlink resolves into cache must classify as in-cache",
558        );
559    }
560
561    #[test]
562    #[cfg(unix)]
563    fn path_inside_cache_root_follows_symlink_out_of_cache() {
564        let _lock = lock_env();
565        let cache_root = TempDir::new().unwrap();
566        let _guard = EnvVarGuard::set(crate::KTSTR_CACHE_DIR_ENV, cache_root.path());
567        let outside = TempDir::new().unwrap();
568        let real = outside.path().join("vmlinux");
569        std::fs::write(&real, b"placeholder").unwrap();
570        let alias_parent = cache_root.path().join("alias");
571        std::os::unix::fs::symlink(outside.path(), &alias_parent).unwrap();
572        let through_alias = alias_parent.join("vmlinux");
573        assert!(
574            through_alias.exists(),
575            "precondition: path through symlinked parent must be reachable",
576        );
577        assert!(
578            !path_inside_cache_root(&through_alias),
579            "path whose parent symlink resolves OUT of cache must classify as outside-cache",
580        );
581    }
582
583    #[test]
584    fn path_inside_cache_root_empty_ktstr_cache_dir_falls_through() {
585        let _lock = lock_env();
586        let tmp = TempDir::new().unwrap();
587        let _g1 = EnvVarGuard::set(crate::KTSTR_CACHE_DIR_ENV, "");
588        let _g2 = EnvVarGuard::set("XDG_CACHE_HOME", tmp.path());
589        let resolved = tmp.path().join("ktstr").join("kernels");
590        let entry = resolved.join("kentry");
591        std::fs::create_dir_all(&entry).unwrap();
592        let vmlinux = entry.join("vmlinux");
593        std::fs::write(&vmlinux, b"placeholder").unwrap();
594        assert!(
595            path_inside_cache_root(&vmlinux),
596            "with empty KTSTR_CACHE_DIR, the cascade must resolve via \
597             XDG_CACHE_HOME and accept paths inside that resolved root",
598        );
599    }
600
601    #[test]
602    fn path_inside_cache_root_fresh_resolution_per_call() {
603        let _lock = lock_env();
604        let cache_a = TempDir::new().unwrap();
605        let cache_b = TempDir::new().unwrap();
606        let entry_a = cache_a.path().join("kentry");
607        std::fs::create_dir_all(&entry_a).unwrap();
608        let vmlinux_a = entry_a.join("vmlinux");
609        std::fs::write(&vmlinux_a, b"placeholder").unwrap();
610        {
611            let _guard = EnvVarGuard::set(crate::KTSTR_CACHE_DIR_ENV, cache_a.path());
612            assert!(
613                path_inside_cache_root(&vmlinux_a),
614                "first call: vmlinux is inside cache_a (the active root)",
615            );
616        }
617        {
618            let _guard = EnvVarGuard::set(crate::KTSTR_CACHE_DIR_ENV, cache_b.path());
619            assert!(
620                !path_inside_cache_root(&vmlinux_a),
621                "second call: KTSTR_CACHE_DIR has moved to cache_b, so the \
622                 vmlinux (still under cache_a) must be classified outside",
623            );
624        }
625    }
626
627    // -- prefer_source_tree_for_dwarf --
628
629    #[test]
630    fn prefer_source_tree_local_with_vmlinux() {
631        let tmp = TempDir::new().unwrap();
632        let cache_entry = tmp.path().join("cache");
633        let src_tree = tmp.path().join("src");
634        fs::create_dir_all(&cache_entry).unwrap();
635        fs::create_dir_all(&src_tree).unwrap();
636        let vmlinux = src_tree.join("vmlinux");
637        fs::write(&vmlinux, b"fake-elf").unwrap();
638        let stat = fs::metadata(&vmlinux).unwrap();
639        let mtime_secs = stat
640            .modified()
641            .unwrap()
642            .duration_since(std::time::UNIX_EPOCH)
643            .unwrap()
644            .as_secs() as i64;
645
646        let meta = crate::cache::KernelMetadata {
647            version: Some("6.14.2".to_string()),
648            source: KernelSource::Local {
649                source_tree_path: Some(src_tree.clone()),
650                git_hash: None,
651            },
652            arch: "x86_64".to_string(),
653            image_name: "bzImage".to_string(),
654            config_hash: None,
655            built_at: "2026-04-18T10:00:00Z".to_string(),
656            ktstr_kconfig_hash: None,
657            extra_kconfig_hash: None,
658            has_vmlinux: true,
659            vmlinux_stripped: true,
660            source_vmlinux_size: Some(stat.len()),
661            source_vmlinux_mtime_secs: Some(mtime_secs),
662        };
663        fs::write(
664            cache_entry.join("metadata.json"),
665            serde_json::to_string(&meta).unwrap(),
666        )
667        .unwrap();
668
669        assert_eq!(prefer_source_tree_for_dwarf(&cache_entry), Some(src_tree));
670    }
671
672    #[test]
673    fn prefer_source_tree_local_without_vmlinux_in_tree() {
674        let tmp = TempDir::new().unwrap();
675        let cache_entry = tmp.path().join("cache");
676        let src_tree = tmp.path().join("src");
677        fs::create_dir_all(&cache_entry).unwrap();
678        fs::create_dir_all(&src_tree).unwrap();
679
680        let meta = crate::cache::KernelMetadata {
681            version: None,
682            source: KernelSource::Local {
683                source_tree_path: Some(src_tree),
684                git_hash: None,
685            },
686            arch: "x86_64".to_string(),
687            image_name: "bzImage".to_string(),
688            config_hash: None,
689            built_at: "2026-04-18T10:00:00Z".to_string(),
690            ktstr_kconfig_hash: None,
691            extra_kconfig_hash: None,
692            has_vmlinux: false,
693            vmlinux_stripped: false,
694            source_vmlinux_size: Some(42),
695            source_vmlinux_mtime_secs: Some(1_700_000_000),
696        };
697        fs::write(
698            cache_entry.join("metadata.json"),
699            serde_json::to_string(&meta).unwrap(),
700        )
701        .unwrap();
702
703        assert_eq!(prefer_source_tree_for_dwarf(&cache_entry), None);
704    }
705
706    #[test]
707    fn prefer_source_tree_tarball_source_returns_none() {
708        let tmp = TempDir::new().unwrap();
709        let cache_entry = tmp.path().join("cache");
710        fs::create_dir_all(&cache_entry).unwrap();
711
712        let meta = crate::cache::KernelMetadata {
713            version: Some("6.14.2".to_string()),
714            source: KernelSource::Tarball,
715            arch: "x86_64".to_string(),
716            image_name: "bzImage".to_string(),
717            config_hash: None,
718            built_at: "2026-04-18T10:00:00Z".to_string(),
719            ktstr_kconfig_hash: None,
720            extra_kconfig_hash: None,
721            has_vmlinux: true,
722            vmlinux_stripped: true,
723            source_vmlinux_size: None,
724            source_vmlinux_mtime_secs: None,
725        };
726        fs::write(
727            cache_entry.join("metadata.json"),
728            serde_json::to_string(&meta).unwrap(),
729        )
730        .unwrap();
731
732        assert_eq!(prefer_source_tree_for_dwarf(&cache_entry), None);
733    }
734
735    #[test]
736    fn prefer_source_tree_no_metadata_returns_none() {
737        let tmp = TempDir::new().unwrap();
738        assert_eq!(prefer_source_tree_for_dwarf(tmp.path()), None);
739    }
740
741    #[test]
742    fn prefer_source_tree_metadata_parse_failure_returns_none() {
743        let tmp = TempDir::new().unwrap();
744        let cache_entry = tmp.path().join("cache");
745        fs::create_dir_all(&cache_entry).unwrap();
746        fs::write(
747            cache_entry.join("metadata.json"),
748            br#"{"not_kernel_metadata": true}"#,
749        )
750        .unwrap();
751
752        assert_eq!(
753            prefer_source_tree_for_dwarf(&cache_entry),
754            None,
755            "malformed metadata.json must short-circuit to None, not bail",
756        );
757
758        let other_entry = tmp.path().join("other");
759        fs::create_dir_all(&other_entry).unwrap();
760        fs::write(other_entry.join("metadata.json"), b"not json at all {{{").unwrap();
761        assert_eq!(
762            prefer_source_tree_for_dwarf(&other_entry),
763            None,
764            "unparseable metadata.json must short-circuit to None, not bail",
765        );
766    }
767
768    #[test]
769    fn prefer_source_tree_local_with_none_source_tree_path_returns_none() {
770        let tmp = TempDir::new().unwrap();
771        let cache_entry = tmp.path().join("cache");
772        fs::create_dir_all(&cache_entry).unwrap();
773
774        let meta = crate::cache::KernelMetadata {
775            version: Some("6.14.2".to_string()),
776            source: KernelSource::Local {
777                source_tree_path: None,
778                git_hash: Some("abc123".to_string()),
779            },
780            arch: "x86_64".to_string(),
781            image_name: "bzImage".to_string(),
782            config_hash: None,
783            built_at: "2026-04-18T10:00:00Z".to_string(),
784            ktstr_kconfig_hash: None,
785            extra_kconfig_hash: None,
786            has_vmlinux: true,
787            vmlinux_stripped: true,
788            source_vmlinux_size: Some(42),
789            source_vmlinux_mtime_secs: Some(1_700_000_000),
790        };
791        fs::write(
792            cache_entry.join("metadata.json"),
793            serde_json::to_string(&meta).unwrap(),
794        )
795        .unwrap();
796
797        assert_eq!(
798            prefer_source_tree_for_dwarf(&cache_entry),
799            None,
800            "Local entry with source_tree_path=None must short-circuit \
801             to None at the `let src_path = source_tree_path?;` line",
802        );
803    }
804
805    #[test]
806    fn prefer_source_tree_validates_matching_vmlinux_stat_and_returns_path() {
807        let tmp = TempDir::new().unwrap();
808        let cache_entry = tmp.path().join("cache");
809        let src_tree = tmp.path().join("src");
810        fs::create_dir_all(&cache_entry).unwrap();
811        fs::create_dir_all(&src_tree).unwrap();
812        let vmlinux = src_tree.join("vmlinux");
813        fs::write(&vmlinux, b"fake-elf-bytes").unwrap();
814        let stat = fs::metadata(&vmlinux).unwrap();
815        let mtime_secs = stat
816            .modified()
817            .unwrap()
818            .duration_since(std::time::UNIX_EPOCH)
819            .unwrap()
820            .as_secs() as i64;
821
822        let meta = crate::cache::KernelMetadata {
823            version: None,
824            source: KernelSource::Local {
825                source_tree_path: Some(src_tree.clone()),
826                git_hash: None,
827            },
828            arch: "x86_64".to_string(),
829            image_name: "bzImage".to_string(),
830            config_hash: None,
831            built_at: "2026-04-18T10:00:00Z".to_string(),
832            ktstr_kconfig_hash: None,
833            extra_kconfig_hash: None,
834            has_vmlinux: true,
835            vmlinux_stripped: true,
836            source_vmlinux_size: Some(stat.len()),
837            source_vmlinux_mtime_secs: Some(mtime_secs),
838        };
839        fs::write(
840            cache_entry.join("metadata.json"),
841            serde_json::to_string(&meta).unwrap(),
842        )
843        .unwrap();
844
845        assert_eq!(
846            prefer_source_tree_for_dwarf(&cache_entry),
847            Some(src_tree),
848            "matching size + mtime must pass the validation gate"
849        );
850    }
851
852    #[test]
853    fn prefer_source_tree_size_mismatch_returns_none() {
854        let tmp = TempDir::new().unwrap();
855        let cache_entry = tmp.path().join("cache");
856        let src_tree = tmp.path().join("src");
857        fs::create_dir_all(&cache_entry).unwrap();
858        fs::create_dir_all(&src_tree).unwrap();
859        let vmlinux = src_tree.join("vmlinux");
860        fs::write(&vmlinux, b"fake-elf-bytes").unwrap();
861        let stat = fs::metadata(&vmlinux).unwrap();
862        let mtime_secs = stat
863            .modified()
864            .unwrap()
865            .duration_since(std::time::UNIX_EPOCH)
866            .unwrap()
867            .as_secs() as i64;
868
869        let meta = crate::cache::KernelMetadata {
870            version: None,
871            source: KernelSource::Local {
872                source_tree_path: Some(src_tree),
873                git_hash: None,
874            },
875            arch: "x86_64".to_string(),
876            image_name: "bzImage".to_string(),
877            config_hash: None,
878            built_at: "2026-04-18T10:00:00Z".to_string(),
879            ktstr_kconfig_hash: None,
880            extra_kconfig_hash: None,
881            has_vmlinux: true,
882            vmlinux_stripped: true,
883            source_vmlinux_size: Some(stat.len() + 1),
884            source_vmlinux_mtime_secs: Some(mtime_secs),
885        };
886        fs::write(
887            cache_entry.join("metadata.json"),
888            serde_json::to_string(&meta).unwrap(),
889        )
890        .unwrap();
891
892        assert_eq!(
893            prefer_source_tree_for_dwarf(&cache_entry),
894            None,
895            "size mismatch must drop validation and return None"
896        );
897    }
898
899    #[test]
900    fn prefer_source_tree_mtime_mismatch_returns_none() {
901        let tmp = TempDir::new().unwrap();
902        let cache_entry = tmp.path().join("cache");
903        let src_tree = tmp.path().join("src");
904        fs::create_dir_all(&cache_entry).unwrap();
905        fs::create_dir_all(&src_tree).unwrap();
906        let vmlinux = src_tree.join("vmlinux");
907        fs::write(&vmlinux, b"fake-elf-bytes").unwrap();
908        let stat = fs::metadata(&vmlinux).unwrap();
909        let mtime_secs = stat
910            .modified()
911            .unwrap()
912            .duration_since(std::time::UNIX_EPOCH)
913            .unwrap()
914            .as_secs() as i64;
915
916        let meta = crate::cache::KernelMetadata {
917            version: None,
918            source: KernelSource::Local {
919                source_tree_path: Some(src_tree),
920                git_hash: None,
921            },
922            arch: "x86_64".to_string(),
923            image_name: "bzImage".to_string(),
924            config_hash: None,
925            built_at: "2026-04-18T10:00:00Z".to_string(),
926            ktstr_kconfig_hash: None,
927            extra_kconfig_hash: None,
928            has_vmlinux: true,
929            vmlinux_stripped: true,
930            source_vmlinux_size: Some(stat.len()),
931            source_vmlinux_mtime_secs: Some(mtime_secs - 3600),
932        };
933        fs::write(
934            cache_entry.join("metadata.json"),
935            serde_json::to_string(&meta).unwrap(),
936        )
937        .unwrap();
938
939        assert_eq!(
940            prefer_source_tree_for_dwarf(&cache_entry),
941            None,
942            "mtime mismatch must drop validation and return None"
943        );
944    }
945
946    // -- recover_local_source_tree --
947
948    #[test]
949    fn recover_local_source_tree_local_with_path_returns_source_tree() {
950        let tmp = TempDir::new().unwrap();
951        let cache_entry = tmp.path().join("cache");
952        let src_tree = tmp.path().join("src");
953        fs::create_dir_all(&cache_entry).unwrap();
954        fs::create_dir_all(&src_tree).unwrap();
955
956        let meta = crate::cache::KernelMetadata {
957            version: Some("6.14.2".to_string()),
958            source: KernelSource::Local {
959                source_tree_path: Some(src_tree.clone()),
960                git_hash: Some("abc1234".to_string()),
961            },
962            arch: "x86_64".to_string(),
963            image_name: "bzImage".to_string(),
964            config_hash: None,
965            built_at: "2026-04-18T10:00:00Z".to_string(),
966            ktstr_kconfig_hash: None,
967            extra_kconfig_hash: None,
968            has_vmlinux: false,
969            vmlinux_stripped: false,
970            source_vmlinux_size: None,
971            source_vmlinux_mtime_secs: None,
972        };
973        fs::write(
974            cache_entry.join("metadata.json"),
975            serde_json::to_string(&meta).unwrap(),
976        )
977        .unwrap();
978
979        assert_eq!(recover_local_source_tree(&cache_entry), Some(src_tree));
980    }
981
982    #[test]
983    fn recover_local_source_tree_no_metadata_returns_none() {
984        let tmp = TempDir::new().unwrap();
985        assert_eq!(recover_local_source_tree(tmp.path()), None);
986    }
987
988    #[test]
989    fn recover_local_source_tree_tarball_source_returns_none() {
990        let tmp = TempDir::new().unwrap();
991        let cache_entry = tmp.path().join("cache");
992        fs::create_dir_all(&cache_entry).unwrap();
993
994        let meta = crate::cache::KernelMetadata {
995            version: Some("6.14.2".to_string()),
996            source: KernelSource::Tarball,
997            arch: "x86_64".to_string(),
998            image_name: "bzImage".to_string(),
999            config_hash: None,
1000            built_at: "2026-04-18T10:00:00Z".to_string(),
1001            ktstr_kconfig_hash: None,
1002            extra_kconfig_hash: None,
1003            has_vmlinux: true,
1004            vmlinux_stripped: true,
1005            source_vmlinux_size: None,
1006            source_vmlinux_mtime_secs: None,
1007        };
1008        fs::write(
1009            cache_entry.join("metadata.json"),
1010            serde_json::to_string(&meta).unwrap(),
1011        )
1012        .unwrap();
1013
1014        assert_eq!(recover_local_source_tree(&cache_entry), None);
1015    }
1016
1017    #[test]
1018    fn recover_local_source_tree_local_with_none_path_returns_none() {
1019        let tmp = TempDir::new().unwrap();
1020        let cache_entry = tmp.path().join("cache");
1021        fs::create_dir_all(&cache_entry).unwrap();
1022
1023        let meta = crate::cache::KernelMetadata {
1024            version: Some("6.14.2".to_string()),
1025            source: KernelSource::Local {
1026                source_tree_path: None,
1027                git_hash: Some("abc1234".to_string()),
1028            },
1029            arch: "x86_64".to_string(),
1030            image_name: "bzImage".to_string(),
1031            config_hash: None,
1032            built_at: "2026-04-18T10:00:00Z".to_string(),
1033            ktstr_kconfig_hash: None,
1034            extra_kconfig_hash: None,
1035            has_vmlinux: true,
1036            vmlinux_stripped: true,
1037            source_vmlinux_size: None,
1038            source_vmlinux_mtime_secs: None,
1039        };
1040        fs::write(
1041            cache_entry.join("metadata.json"),
1042            serde_json::to_string(&meta).unwrap(),
1043        )
1044        .unwrap();
1045
1046        assert_eq!(recover_local_source_tree(&cache_entry), None);
1047    }
1048
1049    #[test]
1050    fn recover_local_source_tree_malformed_metadata_returns_none() {
1051        let tmp = TempDir::new().unwrap();
1052        let cache_entry = tmp.path().join("cache");
1053        fs::create_dir_all(&cache_entry).unwrap();
1054        fs::write(
1055            cache_entry.join("metadata.json"),
1056            br#"{"not_kernel_metadata": true}"#,
1057        )
1058        .unwrap();
1059        assert_eq!(recover_local_source_tree(&cache_entry), None);
1060    }
1061
1062    // -- validate_home_for_cache direct unit tests --
1063
1064    #[test]
1065    fn validate_home_for_cache_rejects_unset() {
1066        let _env_lock = lock_env();
1067        let _home = EnvVarGuard::remove("HOME");
1068        let err = validate_home_for_cache().expect_err("unset HOME must be rejected");
1069        let msg = format!("{err:#}");
1070        assert!(
1071            msg.contains("HOME is unset"),
1072            "diagnostic must call out the unset case specifically: {msg}",
1073        );
1074        assert!(
1075            !msg.contains("HOME is set to the empty string"),
1076            "unset HOME must NOT use the empty-string diagnostic — the two \
1077             cases are distinct now (NotPresent vs Ok(\"\")): {msg}",
1078        );
1079    }
1080
1081    #[test]
1082    fn validate_home_for_cache_rejects_empty() {
1083        let _env_lock = lock_env();
1084        let _home = EnvVarGuard::set("HOME", "");
1085        let err = validate_home_for_cache().expect_err("empty HOME must be rejected");
1086        let msg = format!("{err:#}");
1087        assert!(
1088            msg.contains("HOME is set to the empty string"),
1089            "diagnostic must call out the empty-string case specifically: {msg}",
1090        );
1091        assert!(
1092            !msg.contains("HOME is unset"),
1093            "empty HOME must NOT use the unset diagnostic — the two \
1094             cases are distinct now: {msg}",
1095        );
1096    }
1097
1098    #[test]
1099    fn validate_home_for_cache_rejects_root_slash() {
1100        let _env_lock = lock_env();
1101        let _home = EnvVarGuard::set("HOME", "/");
1102        let err = validate_home_for_cache().expect_err("HOME=/ must be rejected");
1103        let msg = format!("{err:#}");
1104        assert!(
1105            msg.contains("HOME is `/`"),
1106            "diagnostic must call out the root-slash case specifically: {msg}",
1107        );
1108        assert!(
1109            msg.contains("/.cache/ktstr"),
1110            "diagnostic must explain why (/.cache/ktstr aliases root fs): {msg}",
1111        );
1112    }
1113
1114    #[test]
1115    fn validate_home_for_cache_rejects_relative_path() {
1116        let _env_lock = lock_env();
1117        for rel in ["relative", "./relative", "home/user", "."] {
1118            let _home = EnvVarGuard::set("HOME", rel);
1119            let err = validate_home_for_cache()
1120                .expect_err(&format!("relative path '{rel}' must be rejected"));
1121            let msg = format!("{err:#}");
1122            assert!(
1123                msg.contains("not an absolute path"),
1124                "[rel={rel:?}] diagnostic must call out non-absolute: {msg}",
1125            );
1126            assert!(
1127                msg.contains(&format!("{rel:?}")),
1128                "[rel={rel:?}] diagnostic must echo the offending value verbatim: {msg}",
1129            );
1130        }
1131    }
1132
1133    #[test]
1134    fn validate_home_for_cache_accepts_absolute_paths() {
1135        let _env_lock = lock_env();
1136        for ok in [
1137            "/home/user",
1138            "/var/empty",
1139            "/root",
1140            "/a",
1141            "/home/user with spaces",
1142            "/home/user/.local/share",
1143        ] {
1144            let _home = EnvVarGuard::set("HOME", ok);
1145            let got = validate_home_for_cache()
1146                .unwrap_or_else(|e| panic!("absolute path {ok:?} must be accepted; got: {e:#}"));
1147            assert_eq!(
1148                got,
1149                std::path::PathBuf::from(ok),
1150                "returned PathBuf must equal the HOME value verbatim — \
1151                 helper does not append the cache suffix or canonicalize",
1152            );
1153        }
1154    }
1155
1156    #[test]
1157    fn validate_home_for_cache_does_not_canonicalize_dots_and_doubles() {
1158        let _env_lock = lock_env();
1159        for not_normalized in ["//", "/./", "/.", "/foo//bar", "/./home"] {
1160            let _home = EnvVarGuard::set("HOME", not_normalized);
1161            validate_home_for_cache().unwrap_or_else(|e| {
1162                panic!(
1163                    "non-normalized but absolute path {not_normalized:?} must \
1164                     pass the helper (downstream OS surfaces the diagnostic); \
1165                     got: {e:#}",
1166                )
1167            });
1168        }
1169    }
1170
1171    // -- resolve_lock_dir --
1172
1173    #[test]
1174    fn lock_dir_uses_ktstr_lock_dir_when_set() {
1175        let _lock = lock_env();
1176        let _guard = EnvVarGuard::set(crate::KTSTR_LOCK_DIR_ENV, "/var/run/ktstr");
1177        assert_eq!(resolve_lock_dir(), PathBuf::from("/var/run/ktstr"));
1178    }
1179
1180    #[test]
1181    fn lock_dir_falls_back_to_tmp_when_unset() {
1182        let _lock = lock_env();
1183        let _guard = EnvVarGuard::remove(crate::KTSTR_LOCK_DIR_ENV);
1184        assert_eq!(resolve_lock_dir(), PathBuf::from("/tmp"));
1185    }
1186
1187    #[test]
1188    fn lock_dir_falls_back_to_tmp_when_empty() {
1189        let _lock = lock_env();
1190        let _guard = EnvVarGuard::set(crate::KTSTR_LOCK_DIR_ENV, "");
1191        assert_eq!(resolve_lock_dir(), PathBuf::from("/tmp"));
1192    }
1193}