1use std::fs;
31use std::path::{Path, PathBuf};
32
33use super::housekeeping::read_metadata;
34use super::metadata::KernelSource;
35
36pub(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(_) => { }
50 Err(std::env::VarError::NotPresent) => { }
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
71pub(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
148pub(crate) fn resolve_cache_root() -> anyhow::Result<PathBuf> {
150 resolve_cache_root_with_suffix("kernels")
151}
152
153pub(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
164pub 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
204pub 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
218pub(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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}