use std::path::Path;
use anyhow::{Result, anyhow, bail};
use super::kernel_build::kernel_build_pipeline;
use super::util::{Spinner, status, success};
pub fn check_kvm() -> Result<()> {
use std::path::Path;
if !Path::new("/dev/kvm").exists() {
bail!(
"/dev/kvm not found. KVM requires:\n \
- Linux kernel with KVM support (CONFIG_KVM)\n \
- Access to /dev/kvm (check permissions or add user to 'kvm' group)\n \
- Hardware virtualization enabled in BIOS (VT-x/AMD-V)"
);
}
if let Err(e) = std::fs::File::open("/dev/kvm") {
if e.kind() == std::io::ErrorKind::PermissionDenied {
bail!(
"/dev/kvm: permission denied. Add your user to the 'kvm' group:\n \
sudo usermod -aG kvm $USER\n \
then log out and back in."
);
}
bail!("/dev/kvm: {e}");
}
Ok(())
}
pub fn resolve_kernel_parallelism() -> usize {
if let Ok(raw) = std::env::var(crate::KTSTR_KERNEL_PARALLELISM_ENV) {
let trimmed = raw.trim();
match trimmed.parse::<usize>() {
Ok(n) if n > 0 => return n,
_ => {
tracing::warn!(
env_var = crate::KTSTR_KERNEL_PARALLELISM_ENV,
value = %raw,
"KTSTR_KERNEL_PARALLELISM={raw:?} failed to parse, using default",
);
}
}
}
std::thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(1)
}
fn resolve_in_path(name: &std::path::Path) -> Option<std::path::PathBuf> {
use std::os::unix::fs::PermissionsExt;
let path_var = std::env::var_os("PATH")?;
for dir in std::env::split_paths(&path_var) {
let candidate = dir.join(name);
if let Ok(meta) = std::fs::metadata(&candidate)
&& meta.is_file()
&& meta.permissions().mode() & 0o111 != 0
{
return Some(candidate);
}
}
None
}
pub fn resolve_include_files(
paths: &[std::path::PathBuf],
) -> Result<Vec<(String, std::path::PathBuf)>> {
use std::path::{Component, PathBuf};
let mut resolved_includes: Vec<(String, PathBuf)> = Vec::new();
for path in paths {
let is_explicit_path = {
matches!(
path.components().next(),
Some(Component::RootDir | Component::CurDir | Component::ParentDir)
) || path.components().count() > 1
};
let resolved = if is_explicit_path {
anyhow::ensure!(
path.exists(),
"--include-files path not found: {}",
path.display()
);
path.clone()
} else {
if path.exists() {
path.clone()
} else {
resolve_in_path(path).ok_or_else(|| {
anyhow::anyhow!("-i {}: not found in filesystem or PATH", path.display())
})?
}
};
if resolved.is_dir() {
let dir_name = resolved
.file_name()
.ok_or_else(|| {
anyhow::anyhow!("include directory has no name: {}", resolved.display())
})?
.to_string_lossy()
.to_string();
let prefix = format!("include-files/{dir_name}");
let mut count = 0usize;
for entry in walkdir::WalkDir::new(&resolved).follow_links(true) {
let entry = entry.map_err(|e| anyhow::anyhow!("-i {}: {e}", resolved.display()))?;
if !entry.file_type().is_file() {
continue;
}
let rel = entry
.path()
.strip_prefix(&resolved)
.expect("walkdir entry is under root");
let archive_path = format!("{prefix}/{}", rel.display());
resolved_includes.push((archive_path, entry.into_path()));
count += 1;
}
if count == 0 {
eprintln!(
"warning: -i {}: directory contains no regular files",
resolved.display()
);
}
} else {
let file_name = resolved
.file_name()
.ok_or_else(|| {
anyhow::anyhow!("include file has no filename: {}", resolved.display())
})?
.to_string_lossy();
let archive_path = format!("include-files/{file_name}");
resolved_includes.push((archive_path, resolved));
}
}
let mut seen = std::collections::HashMap::<&str, &std::path::Path>::new();
for (archive_path, host_path) in &resolved_includes {
if let Some(prev) = seen.insert(archive_path.as_str(), host_path.as_path()) {
anyhow::bail!(
"duplicate include path '{}': provided by both {} and {}",
archive_path,
prev.display(),
host_path.display(),
);
}
}
Ok(resolved_includes)
}
pub fn cache_lookup(
cache: &crate::cache::CacheDir,
cache_key: &str,
cli_label: &str,
) -> Option<crate::cache::CacheEntry> {
if let Some(entry) = cache.lookup(cache_key) {
return Some(entry);
}
if crate::remote_cache::is_enabled() {
return crate::remote_cache::remote_lookup(cache, cache_key, cli_label);
}
None
}
pub fn resolve_cached_kernel(
id: &crate::kernel_path::KernelId,
cli_label: &str,
) -> Result<std::path::PathBuf> {
use crate::kernel_path::KernelId;
match id {
KernelId::Version(ver) => {
let resolved = if crate::fetch::is_major_minor_prefix(ver) {
crate::fetch::fetch_version_for_prefix(
crate::fetch::shared_client(),
ver,
cli_label,
)?
} else {
ver.clone()
};
let cache = crate::cache::CacheDir::new()?;
let (arch, _) = crate::fetch::arch_info();
let cache_key = format!("{resolved}-tarball-{arch}-kc{}", crate::cache_key_suffix());
if let Some(entry) = cache_lookup(&cache, &cache_key, cli_label) {
return Ok(entry.path);
}
download_and_cache_version(&resolved, cli_label, None)
}
KernelId::CacheKey(key) => {
let cache = crate::cache::CacheDir::new()?;
if let Some(entry) = cache_lookup(&cache, key, cli_label) {
return Ok(entry.path);
}
bail!(
"cache key {key} not found. \
Run `{cli_label} kernel list` to see available entries."
)
}
KernelId::Path(_) => bail!("resolve_cached_kernel called with Path variant"),
KernelId::Range { .. } | KernelId::Git { .. } => {
id.validate()
.map_err(|e| anyhow::anyhow!("--kernel {id}: {e}"))?;
bail!(
"--kernel {id}: kernel ranges and git sources are not \
yet supported in this context — use a single kernel \
version, cache key, or path"
)
}
}
}
pub struct KernelResolvePolicy<'a> {
pub accept_raw_image: bool,
pub cli_label: &'a str,
}
pub fn resolve_kernel_image(
kernel: Option<&str>,
policy: &KernelResolvePolicy<'_>,
) -> Result<std::path::PathBuf> {
use crate::kernel_path::KernelId;
if let Some(val) = kernel {
match KernelId::parse(val) {
KernelId::Path(p) => {
let path = std::path::PathBuf::from(&p);
if path.is_dir() {
resolve_kernel_dir(&path, policy.cli_label, None)
} else if path.is_file() {
if policy.accept_raw_image {
Ok(path)
} else {
bail!(
"--kernel {}: raw image files are not supported. \
Pass a source directory, version, or cache key.",
path.display()
)
}
} else {
bail!("kernel path not found: {}", path.display())
}
}
id @ (KernelId::Version(_) | KernelId::CacheKey(_)) => {
let cache_dir = resolve_cached_kernel(&id, policy.cli_label)?;
crate::kernel_path::find_image_in_dir(&cache_dir).ok_or_else(|| {
anyhow::anyhow!("no kernel image found in {}", cache_dir.display())
})
}
id @ (KernelId::Range { .. } | KernelId::Git { .. }) => {
id.validate()
.map_err(|e| anyhow::anyhow!("--kernel {val}: {e}"))?;
bail!(
"--kernel {val}: kernel ranges and git sources are not \
yet supported in this context — use a single kernel \
version, cache key, or path"
)
}
}
} else {
match crate::find_kernel()? {
Some(image) => Ok(image),
None => auto_download_kernel(policy.cli_label),
}
}
}
pub fn auto_download_kernel(cli_label: &str) -> Result<std::path::PathBuf> {
status(&format!(
"{cli_label}: no kernel found, downloading latest stable"
));
let sp = Spinner::start("Fetching latest kernel version...");
let ver = crate::fetch::fetch_latest_stable_version(crate::fetch::shared_client(), cli_label)?;
sp.finish(format!("Latest stable: {ver}"));
let cache_dir = download_and_cache_version(&ver, cli_label, None)?;
let (_, image_name) = crate::fetch::arch_info();
Ok(cache_dir.join(image_name))
}
pub fn download_and_cache_version(
version: &str,
cli_label: &str,
cpu_cap: Option<crate::vmm::host_topology::CpuCap>,
) -> Result<std::path::PathBuf> {
let (arch, _) = crate::fetch::arch_info();
let cache_key = format!("{version}-tarball-{arch}-kc{}", crate::cache_key_suffix());
if let Ok(cache) = crate::cache::CacheDir::new()
&& let Some(entry) = cache_lookup(&cache, &cache_key, cli_label)
{
return Ok(entry.path);
}
let tmp_dir = tempfile::TempDir::new()?;
let sp = Spinner::start("Downloading kernel...");
let acquired = crate::fetch::download_tarball(
crate::fetch::shared_client(),
version,
tmp_dir.path(),
cli_label,
false,
)?;
sp.finish("Downloaded");
let cache = crate::cache::CacheDir::new()?;
let result = kernel_build_pipeline(&acquired, &cache, cli_label, false, false, cpu_cap, None)?;
match result.entry {
Some(entry) => Ok(entry.path),
None => bail!(
"kernel built but cache store failed — cannot return image from temporary directory"
),
}
}
pub fn expand_kernel_range(start: &str, end: &str, cli_label: &str) -> Result<Vec<String>> {
use crate::kernel_path::decompose_version_for_compare;
let start_key = decompose_version_for_compare(start).ok_or_else(|| {
anyhow!(
"kernel range start `{start}` is not a parseable version. \
Endpoints must match `MAJOR.MINOR[.PATCH][-rcN]`."
)
})?;
let end_key = decompose_version_for_compare(end).ok_or_else(|| {
anyhow!(
"kernel range end `{end}` is not a parseable version. \
Endpoints must match `MAJOR.MINOR[.PATCH][-rcN]`."
)
})?;
eprintln!("{cli_label}: expanding kernel range {start}..{end}");
let releases = crate::fetch::cached_releases()?;
let versions = filter_and_sort_range(&releases, start_key, end_key);
if versions.is_empty() {
bail!(
"kernel range {start}..{end} expanded to 0 stable releases. \
releases.json has no `stable` or `longterm` rows in this \
interval — verify the endpoints, or use a single \
`--kernel <version>` if you want a pre-release or \
archived version."
);
}
eprintln!(
"{cli_label}: range expanded to {n} kernel(s): {list}",
n = versions.len(),
list = versions.join(", "),
);
Ok(versions)
}
fn filter_and_sort_range(
releases: &[crate::fetch::Release],
start_key: (u64, u64, u64, u64),
end_key: (u64, u64, u64, u64),
) -> Vec<String> {
use crate::kernel_path::decompose_version_for_compare;
let mut selected: Vec<(String, (u64, u64, u64, u64))> = Vec::new();
for r in releases {
if r.moniker != "stable" && r.moniker != "longterm" {
continue;
}
let Some(key) = decompose_version_for_compare(&r.version) else {
continue;
};
if key < start_key || key > end_key {
continue;
}
selected.push((r.version.clone(), key));
}
selected.sort_by_key(|s| s.1);
selected.into_iter().map(|(v, _)| v).collect()
}
pub fn resolve_git_kernel(url: &str, git_ref: &str, cli_label: &str) -> Result<std::path::PathBuf> {
let tmp_dir = tempfile::TempDir::new()?;
let acquired = crate::fetch::git_clone(url, git_ref, tmp_dir.path(), cli_label)?;
let cache = crate::cache::CacheDir::new()?;
if let Some(entry) = cache_lookup(&cache, &acquired.cache_key, cli_label) {
return Ok(entry.path);
}
let result = kernel_build_pipeline(&acquired, &cache, cli_label, false, false, None, None)?;
match result.entry {
Some(entry) => Ok(entry.path),
None => bail!(
"kernel built from git+{url}#{git_ref} but cache store failed — \
cannot return image from temporary directory"
),
}
}
#[derive(Debug, Clone)]
pub struct KernelDirCacheHit {
pub cache_key: String,
pub built_at: String,
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct KernelDirOutcome {
pub dir: std::path::PathBuf,
pub cache_hit: Option<KernelDirCacheHit>,
pub is_dirty: bool,
}
pub fn resolve_kernel_dir_to_entry(
path: &std::path::Path,
cli_label: &str,
cpu_cap: Option<crate::vmm::host_topology::CpuCap>,
) -> Result<KernelDirOutcome> {
let acquired = acquire_local_source_tree(path)?;
let cache_key = acquired.cache_key.clone();
let is_dirty = acquired.is_dirty;
let cache = crate::cache::CacheDir::new()?;
if !is_dirty && let Some(entry) = cache_lookup(&cache, &cache_key, cli_label) {
if entry.image_path().exists() {
let hit = KernelDirCacheHit {
cache_key: cache_key.clone(),
built_at: entry.metadata.built_at.clone(),
};
return Ok(KernelDirOutcome {
dir: entry.path,
cache_hit: Some(hit),
is_dirty: false,
});
}
}
let result = kernel_build_pipeline(&acquired, &cache, cli_label, false, true, cpu_cap, None)?;
let dir = match result.entry {
Some(entry) => entry.path,
None => acquired.source_dir,
};
Ok(KernelDirOutcome {
dir,
cache_hit: None,
is_dirty: is_dirty || result.post_build_is_dirty,
})
}
pub fn resolve_kernel_dir(
path: &std::path::Path,
cli_label: &str,
cpu_cap: Option<crate::vmm::host_topology::CpuCap>,
) -> Result<std::path::PathBuf> {
let acquired = acquire_local_source_tree(path)?;
let cache_key = acquired.cache_key.clone();
let cache = crate::cache::CacheDir::new()?;
if !acquired.is_dirty
&& let Some(entry) = cache_lookup(&cache, &cache_key, cli_label)
{
let image = entry.image_path();
if image.exists() {
success(&format!("{cli_label}: using cached kernel {cache_key}"));
return Ok(image);
}
}
let result = kernel_build_pipeline(&acquired, &cache, cli_label, false, true, cpu_cap, None)?;
match result.entry {
Some(entry) => Ok(entry.image_path()),
None => Ok(result.image_path),
}
}
fn acquire_local_source_tree(path: &Path) -> Result<crate::fetch::AcquiredSource> {
let is_source_tree = path.join("Makefile").exists() && path.join("Kconfig").exists();
if !is_source_tree {
bail!(
"no kernel image found in {} (not a kernel source tree — \
missing Makefile or Kconfig)",
path.display()
);
}
crate::fetch::local_source(path).map_err(|e| anyhow::anyhow!("{e}"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resolve_kernel_parallelism_unset_returns_host_default() {
use crate::test_support::test_helpers::{EnvVarGuard, lock_env};
let _lock = lock_env();
let _guard = EnvVarGuard::remove(crate::KTSTR_KERNEL_PARALLELISM_ENV);
let n = resolve_kernel_parallelism();
assert!(
n >= 1,
"fallback must yield at least 1; got {n} which would defeat \
ThreadPoolBuilder::num_threads",
);
}
#[test]
fn resolve_kernel_parallelism_valid_override_wins() {
use crate::test_support::test_helpers::{EnvVarGuard, lock_env};
let _lock = lock_env();
let _guard = EnvVarGuard::set(crate::KTSTR_KERNEL_PARALLELISM_ENV, "4");
assert_eq!(
resolve_kernel_parallelism(),
4,
"valid usize env value must override the host-CPU default",
);
}
#[test]
fn resolve_kernel_parallelism_zero_falls_through_to_default() {
use crate::test_support::test_helpers::{EnvVarGuard, lock_env};
let _lock = lock_env();
let _guard = EnvVarGuard::set(crate::KTSTR_KERNEL_PARALLELISM_ENV, "0");
let n = resolve_kernel_parallelism();
assert!(
n >= 1,
"zero env value must fall through to host-CPU default; got {n}",
);
}
#[test]
fn resolve_kernel_parallelism_unparseable_falls_through_to_default() {
use crate::test_support::test_helpers::{EnvVarGuard, lock_env};
let _lock = lock_env();
let _guard = EnvVarGuard::set(crate::KTSTR_KERNEL_PARALLELISM_ENV, "abc");
let n = resolve_kernel_parallelism();
assert!(n >= 1);
}
#[test]
fn resolve_kernel_parallelism_negative_falls_through_to_default() {
use crate::test_support::test_helpers::{EnvVarGuard, lock_env};
let _lock = lock_env();
let _guard = EnvVarGuard::set(crate::KTSTR_KERNEL_PARALLELISM_ENV, "-1");
let n = resolve_kernel_parallelism();
assert!(n >= 1);
}
#[test]
fn resolve_kernel_parallelism_trims_surrounding_whitespace() {
use crate::test_support::test_helpers::{EnvVarGuard, lock_env};
let _lock = lock_env();
let _guard = EnvVarGuard::set(crate::KTSTR_KERNEL_PARALLELISM_ENV, " 8 ");
assert_eq!(resolve_kernel_parallelism(), 8);
}
#[test]
fn ktstr_kernel_parallelism_env_const_matches_literal() {
assert_eq!(
crate::KTSTR_KERNEL_PARALLELISM_ENV,
"KTSTR_KERNEL_PARALLELISM",
);
}
#[test]
fn resolve_in_path_finds_sh() {
let result = resolve_in_path(std::path::Path::new("sh"));
assert!(result.is_some(), "sh should be in PATH");
assert!(result.unwrap().exists());
}
#[test]
fn resolve_in_path_nonexistent() {
let result = resolve_in_path(std::path::Path::new("nonexistent_binary_xyz_12345"));
assert!(result.is_none());
}
#[test]
fn resolve_include_files_single_file() {
let dir = tempfile::TempDir::new().unwrap();
let file = dir.path().join("test.txt");
std::fs::write(&file, "hello").unwrap();
let result = resolve_include_files(&[file]).unwrap();
assert_eq!(result.len(), 1);
assert!(result[0].0.contains("test.txt"));
}
#[test]
fn resolve_include_files_nonexistent() {
let result = resolve_include_files(&[std::path::PathBuf::from("/nonexistent/file.txt")]);
assert!(result.is_err());
}
#[test]
fn resolve_include_files_bare_name_in_path() {
let result = resolve_include_files(&[std::path::PathBuf::from("sh")]);
assert!(result.is_ok());
let entries = result.unwrap();
assert_eq!(entries.len(), 1);
assert!(entries[0].0.contains("sh"));
}
#[test]
fn resolve_cached_kernel_surfaces_inverted_range_diagnostic() {
let id = crate::kernel_path::KernelId::Range {
start: "6.16".to_string(),
end: "6.12".to_string(),
};
let err = resolve_cached_kernel(&id, "ktstr-test").expect_err("inverted range must error");
let msg = format!("{err:#}");
assert!(
msg.contains("inverted kernel range"),
"validate() diagnostic must surface ahead of the generic \
'not yet supported' bail; got: {msg}",
);
assert!(msg.contains("6.12..6.16"));
assert!(!msg.contains("not yet supported in this context"));
}
#[test]
fn resolve_kernel_image_surfaces_inverted_range_diagnostic() {
let policy = KernelResolvePolicy {
cli_label: "ktstr-test",
accept_raw_image: false,
};
let err = resolve_kernel_image(Some("6.16..6.12"), &policy)
.expect_err("inverted range must error");
let msg = format!("{err:#}");
assert!(
msg.contains("inverted kernel range"),
"validate() diagnostic must surface ahead of the generic bail; got: {msg}",
);
assert!(msg.contains("6.12..6.16"));
assert!(!msg.contains("not yet supported in this context"));
}
fn release(moniker: &str, version: &str) -> crate::fetch::Release {
crate::fetch::Release {
moniker: moniker.to_string(),
version: version.to_string(),
}
}
#[test]
fn filter_and_sort_range_basic() {
use crate::kernel_path::decompose_version_for_compare;
let releases = vec![
release("mainline", "6.18-rc2"),
release("stable", "6.16.5"),
release("longterm", "6.12.40"),
release("linux-next", "6.18-rc2-next-20260420"),
release("longterm", "6.6.99"),
release("stable", "6.14.10"),
release("stable", "6.10.0"),
];
let start_key = decompose_version_for_compare("6.12").unwrap();
let end_key = decompose_version_for_compare("6.16.5").unwrap();
let out = filter_and_sort_range(&releases, start_key, end_key);
assert_eq!(
out,
vec![
"6.12.40".to_string(),
"6.14.10".to_string(),
"6.16.5".to_string(),
],
);
}
#[test]
fn filter_and_sort_range_endpoints_absent_from_releases() {
use crate::kernel_path::decompose_version_for_compare;
let releases = vec![
release("stable", "6.12.5"),
release("stable", "6.14.2"),
release("stable", "6.15.0"),
];
let start_key = decompose_version_for_compare("6.10").unwrap();
let end_key = decompose_version_for_compare("6.16").unwrap();
let out = filter_and_sort_range(&releases, start_key, end_key);
assert_eq!(
out,
vec![
"6.12.5".to_string(),
"6.14.2".to_string(),
"6.15.0".to_string(),
],
);
}
#[test]
fn filter_and_sort_range_inclusive_both_endpoints() {
use crate::kernel_path::decompose_version_for_compare;
let releases = vec![
release("stable", "6.12.5"),
release("stable", "6.13.0"),
release("stable", "6.14.2"),
];
let start_key = decompose_version_for_compare("6.12.5").unwrap();
let end_key = decompose_version_for_compare("6.14.2").unwrap();
let out = filter_and_sort_range(&releases, start_key, end_key);
assert_eq!(
out,
vec![
"6.12.5".to_string(),
"6.13.0".to_string(),
"6.14.2".to_string(),
],
);
}
#[test]
fn filter_and_sort_range_rc_under_stable_moniker_orders_after_release() {
use crate::kernel_path::decompose_version_for_compare;
let releases = vec![
release("stable", "6.14.0-rc3"),
release("stable", "6.14.0"),
release("stable", "6.13.0"),
];
let start_key = decompose_version_for_compare("6.13").unwrap();
let end_key = decompose_version_for_compare("6.15").unwrap();
let out = filter_and_sort_range(&releases, start_key, end_key);
assert_eq!(
out,
vec![
"6.13.0".to_string(),
"6.14.0-rc3".to_string(),
"6.14.0".to_string(),
],
);
}
#[test]
fn filter_and_sort_range_empty_when_no_overlap() {
use crate::kernel_path::decompose_version_for_compare;
let releases = vec![release("stable", "5.10.0"), release("stable", "5.15.0")];
let start_key = decompose_version_for_compare("6.10").unwrap();
let end_key = decompose_version_for_compare("6.16").unwrap();
let out = filter_and_sort_range(&releases, start_key, end_key);
assert!(out.is_empty(), "no overlap → empty result, got {out:?}");
}
#[test]
fn filter_and_sort_range_drops_non_stable_monikers() {
use crate::kernel_path::decompose_version_for_compare;
let releases = vec![
release("mainline", "6.14.0"),
release("linux-next", "6.14.0-next-20260420"),
release("stable", "6.14.5"),
];
let start_key = decompose_version_for_compare("6.14").unwrap();
let end_key = decompose_version_for_compare("6.15").unwrap();
let out = filter_and_sort_range(&releases, start_key, end_key);
assert_eq!(out, vec!["6.14.5".to_string()]);
}
#[test]
fn filter_and_sort_range_drops_unparseable_versions() {
use crate::kernel_path::decompose_version_for_compare;
let releases = vec![
release("stable", "6.14.0"),
release("stable", "embargoed-cve-tag"),
release("stable", "6.14.5"),
];
let start_key = decompose_version_for_compare("6.14").unwrap();
let end_key = decompose_version_for_compare("6.15").unwrap();
let out = filter_and_sort_range(&releases, start_key, end_key);
assert_eq!(out, vec!["6.14.0".to_string(), "6.14.5".to_string()]);
}
#[test]
fn expand_kernel_range_rejects_unparseable_start() {
let err = expand_kernel_range("garbage", "6.14", "ktstr-test")
.expect_err("unparseable start must error");
let msg = format!("{err:#}");
assert!(msg.contains("kernel range start `garbage`"));
}
#[test]
fn expand_kernel_range_rejects_unparseable_end() {
let err = expand_kernel_range("6.10", "garbage", "ktstr-test")
.expect_err("unparseable end must error");
let msg = format!("{err:#}");
assert!(msg.contains("kernel range end `garbage`"));
}
fn init_repo_with_commit_for_resolve_test(dir: &std::path::Path) {
use std::process::Command;
let run = |args: &[&str]| {
let out = Command::new("git")
.args(args)
.current_dir(dir)
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_SYSTEM", "/dev/null")
.env("GIT_AUTHOR_NAME", "ktstr-test")
.env("GIT_AUTHOR_EMAIL", "ktstr-test@localhost")
.env("GIT_COMMITTER_NAME", "ktstr-test")
.env("GIT_COMMITTER_EMAIL", "ktstr-test@localhost")
.output()
.expect("spawn git");
assert!(
out.status.success(),
"git {:?} failed: {}",
args,
String::from_utf8_lossy(&out.stderr)
);
};
run(&["init", "-q", "-b", "main"]);
std::fs::write(dir.join("Makefile"), "# kernel makefile fixture\n").unwrap();
std::fs::write(dir.join("Kconfig"), "# kernel kconfig fixture\n").unwrap();
std::fs::write(dir.join("README"), "fixture\n").unwrap();
run(&["add", "Makefile", "Kconfig", "README"]);
run(&[
"-c",
"commit.gpgsign=false",
"commit",
"-q",
"-m",
"initial",
]);
}
fn populate_cache_entry_for_resolve_test(
cache_root: &std::path::Path,
cache_key: &str,
) -> std::path::PathBuf {
let cache = crate::cache::CacheDir::with_root(cache_root.to_path_buf());
let (arch, image_name) = crate::fetch::arch_info();
let staging = tempfile::TempDir::new().expect("staging tempdir");
let fake_image = staging.path().join(image_name);
std::fs::write(&fake_image, b"fake kernel image bytes").expect("write fake image");
let metadata = crate::cache::KernelMetadata::new(
crate::cache::KernelSource::Local {
source_tree_path: None,
git_hash: None,
},
arch.to_string(),
image_name.to_string(),
"2026-04-12T10:00:00Z".to_string(),
);
let artifacts = crate::cache::CacheArtifacts::new(&fake_image);
let entry = cache
.store(cache_key, &artifacts, &metadata)
.expect("pre-populate cache entry");
entry.path
}
#[test]
fn resolve_kernel_dir_to_entry_clean_tree_cache_hit() {
if std::process::Command::new("git")
.arg("--version")
.output()
.is_err()
{
skip!("git CLI unavailable");
}
let _lock = crate::test_support::test_helpers::lock_env();
let cache_tmp = tempfile::TempDir::new().expect("cache tempdir");
let _cache_env = crate::test_support::test_helpers::EnvVarGuard::set(
"KTSTR_CACHE_DIR",
cache_tmp.path(),
);
let src_tmp = tempfile::TempDir::new().expect("src tempdir");
init_repo_with_commit_for_resolve_test(src_tmp.path());
let acquired =
crate::fetch::local_source(src_tmp.path()).expect("local_source must succeed");
assert!(!acquired.is_dirty, "fixture must be clean before lookup");
let cache_key = acquired.cache_key.clone();
let entry_path = populate_cache_entry_for_resolve_test(cache_tmp.path(), &cache_key);
let outcome = resolve_kernel_dir_to_entry(src_tmp.path(), "test", None)
.expect("resolve must succeed on cache hit");
assert_eq!(
outcome.dir, entry_path,
"cache-hit path must return the cache entry directory, NOT the source tree"
);
let hit = outcome
.cache_hit
.expect("cache hit must produce KernelDirCacheHit");
assert_eq!(
hit.cache_key, cache_key,
"cache hit must report the resolved key"
);
assert_eq!(
hit.built_at, "2026-04-12T10:00:00Z",
"cache hit must surface the persisted built_at timestamp",
);
assert!(
!outcome.is_dirty,
"cache-hit gate requires a clean tree; outcome.is_dirty must be false",
);
}
#[test]
fn resolve_kernel_dir_to_entry_dirty_tree_skips_cache_lookup() {
if std::process::Command::new("git")
.arg("--version")
.output()
.is_err()
{
skip!("git CLI unavailable");
}
if std::process::Command::new("make")
.arg("--version")
.output()
.is_err()
{
skip!("make not in PATH");
}
let _lock = crate::test_support::test_helpers::lock_env();
let cache_tmp = tempfile::TempDir::new().expect("cache tempdir");
let _cache_env = crate::test_support::test_helpers::EnvVarGuard::set(
"KTSTR_CACHE_DIR",
cache_tmp.path(),
);
let _bypass_env =
crate::test_support::test_helpers::EnvVarGuard::set("KTSTR_BYPASS_LLC_LOCKS", "1");
let src_tmp = tempfile::TempDir::new().expect("src tempdir");
init_repo_with_commit_for_resolve_test(src_tmp.path());
std::fs::write(src_tmp.path().join("README"), "modified\n").expect("dirty README");
let dirty_acquired = crate::fetch::local_source(src_tmp.path())
.expect("local_source on dirty tree must succeed");
assert!(
dirty_acquired.is_dirty,
"post-mutation tree must be dirty for the test to be meaningful"
);
populate_cache_entry_for_resolve_test(cache_tmp.path(), &dirty_acquired.cache_key);
let result = resolve_kernel_dir_to_entry(src_tmp.path(), "test", None);
match result {
Ok(outcome) => panic!(
"dirty tree must skip the cache lookup, but resolve returned \
Ok with dir={:?}, cache_hit={:?}, is_dirty={}",
outcome.dir, outcome.cache_hit, outcome.is_dirty,
),
Err(_) => {
let entry_dir = cache_tmp.path().join(&dirty_acquired.cache_key);
assert!(
entry_dir.is_dir(),
"pre-populated entry must still be present after the \
dirty resolve; the gate proved short-circuit by NOT \
returning this directory as the outcome.dir",
);
}
}
}
#[test]
fn resolve_kernel_dir_to_entry_clean_tree_cache_miss_attempts_build() {
if std::process::Command::new("git")
.arg("--version")
.output()
.is_err()
{
skip!("git CLI unavailable");
}
if std::process::Command::new("make")
.arg("--version")
.output()
.is_err()
{
skip!("make not in PATH");
}
let _lock = crate::test_support::test_helpers::lock_env();
let cache_tmp = tempfile::TempDir::new().expect("cache tempdir");
let _cache_env = crate::test_support::test_helpers::EnvVarGuard::set(
"KTSTR_CACHE_DIR",
cache_tmp.path(),
);
let _bypass_env =
crate::test_support::test_helpers::EnvVarGuard::set("KTSTR_BYPASS_LLC_LOCKS", "1");
let src_tmp = tempfile::TempDir::new().expect("src tempdir");
init_repo_with_commit_for_resolve_test(src_tmp.path());
let acquired =
crate::fetch::local_source(src_tmp.path()).expect("local_source must succeed");
assert!(!acquired.is_dirty, "fixture must be clean before resolve");
let result = resolve_kernel_dir_to_entry(src_tmp.path(), "test", None);
assert!(
result.is_err(),
"cache miss without a real kernel toolchain must surface the build failure, \
got Ok({:?})",
result.as_ref().ok().map(|o| &o.dir),
);
}
}