mod wire_format;
use std::path::{Path, PathBuf};
use ktstr::cache::{CacheDir, CacheEntry};
use ktstr::cli;
use ktstr::fetch;
pub(crate) use wire_format::{
cache_key_to_version_label, decorate_path_label_for_dirty, dedupe_resolved,
detect_label_collisions, encode_kernel_list, git_kernel_label, path_kernel_label,
preflight_collision_check,
};
pub(crate) fn resolve_path_kernel(p: &Path, raw_input: &str) -> Result<(PathBuf, bool), String> {
let outcome = cli::resolve_kernel_dir_to_entry(p, "cargo ktstr", None).map_err(|e| {
format!(
"--kernel {raw_input}: {e:#}. {hint}",
hint = ktstr::KTSTR_KERNEL_HINT,
)
})?;
if let Some(hit) = outcome.cache_hit {
tracing::info!(
"cargo ktstr: cache hit for {raw_input} ({key}{age})",
key = hit.cache_key,
age = format_built_age(&hit.built_at),
);
}
Ok((outcome.dir, outcome.is_dirty))
}
pub(crate) fn resolve_kernel_image(kernel: Option<&str>) -> Result<PathBuf, String> {
const KERNEL_POLICY: cli::KernelResolvePolicy<'static> = cli::KernelResolvePolicy {
accept_raw_image: true,
cli_label: "cargo ktstr",
};
cli::resolve_kernel_image(kernel, &KERNEL_POLICY).map_err(|e| format!("{e:#}"))
}
pub(crate) fn format_built_age(built_at: &str) -> String {
let Ok(parsed) = humantime::parse_rfc3339(built_at) else {
return String::new();
};
let Ok(elapsed) = std::time::SystemTime::now().duration_since(parsed) else {
return String::new();
};
let elapsed = std::time::Duration::from_secs(elapsed.as_secs());
format!(", built {} ago", humantime::format_duration(elapsed))
}
pub(crate) fn canonicalize_cache_dir(cache_dir: PathBuf) -> PathBuf {
std::fs::canonicalize(&cache_dir).unwrap_or(cache_dir)
}
pub(crate) fn resolve_one(id: ktstr::kernel_path::KernelId) -> Result<(String, PathBuf), String> {
use ktstr::kernel_path::KernelId;
match id {
KernelId::Path(p) => {
let raw_input = p.display().to_string();
let canon_input = std::fs::canonicalize(&p).map_err(|e| {
format!(
"--kernel {}: path does not exist or cannot be \
canonicalized ({e:#}). {hint}",
p.display(),
hint = ktstr::KTSTR_KERNEL_HINT,
)
})?;
let base_label = path_kernel_label(&canon_input);
let (dir, is_dirty) = resolve_path_kernel(&p, &raw_input)?;
let label = decorate_path_label_for_dirty(&base_label, is_dirty);
Ok((label, dir))
}
KernelId::Version(ref ver) => {
let cache_dir = ktstr::cli::resolve_cached_kernel(&id, "cargo ktstr")
.map_err(|e| format!("{e:#}"))?;
let dir = canonicalize_cache_dir(cache_dir);
Ok((ver.clone(), dir))
}
KernelId::CacheKey(ref key) => {
let cache_dir = ktstr::cli::resolve_cached_kernel(&id, "cargo ktstr")
.map_err(|e| format!("{e:#}"))?;
let dir = canonicalize_cache_dir(cache_dir);
let label = cache_key_to_version_label(key).to_string();
Ok((label, dir))
}
KernelId::Git {
ref url,
ref git_ref,
} => {
let cache_dir = ktstr::cli::resolve_git_kernel(url, git_ref, "cargo ktstr")
.map_err(|e| format!("resolve git+{url}#{git_ref}: {e:#}"))?;
let dir = canonicalize_cache_dir(cache_dir);
let label = git_kernel_label(url, git_ref);
Ok((label, dir))
}
KernelId::Range { start, end } => {
Err(format!(
"internal: resolve_one called with Range {start}..{end}; \
caller must expand Range via `expand_kernel_range` and \
call `resolve_one` per version"
))
}
}
}
pub(crate) fn resolve_kernel_set(specs: &[String]) -> Result<Vec<(String, PathBuf)>, String> {
use ktstr::kernel_path::KernelId;
use rayon::iter::{IntoParallelIterator, ParallelIterator};
preflight_collision_check(specs)?;
let max_threads = ktstr::cli::resolve_kernel_parallelism();
let bounded_pool = rayon::ThreadPoolBuilder::new()
.num_threads(max_threads)
.build()
.ok();
let resolve_one_with_progress = |id: KernelId| -> Result<(String, PathBuf), String> {
let result = resolve_one(id);
if let Ok((label, _)) = &result {
tracing::debug!("cargo ktstr: resolved kernel {label:?}");
}
result
};
let resolve_in_pool = || -> Result<Vec<(String, PathBuf)>, String> {
specs
.into_par_iter()
.filter_map(|raw| {
let trimmed = raw.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
})
.flat_map_iter(|trimmed| {
let id = KernelId::parse(&trimmed);
if let Err(e) = id.validate() {
return vec![Err(format!("--kernel {id}: {e}"))].into_iter();
}
match id {
KernelId::Range { start, end } => {
match ktstr::cli::expand_kernel_range(&start, &end, "cargo ktstr") {
Ok(versions) => versions
.into_iter()
.map(|ver| {
resolve_one_with_progress(KernelId::Version(ver.clone()))
.map_err(|e| format!("resolve kernel {ver}: {e}"))
})
.collect::<Vec<_>>()
.into_iter(),
Err(e) => vec![Err(format!("{e:#}"))].into_iter(),
}
}
other => vec![resolve_one_with_progress(other)].into_iter(),
}
})
.collect::<Result<Vec<_>, _>>()
};
let resolved: Vec<(String, PathBuf)> = match bounded_pool {
Some(pool) => pool.install(resolve_in_pool)?,
None => resolve_in_pool()?,
};
let resolved = dedupe_resolved(resolved);
detect_label_collisions(&resolved)?;
Ok(resolved)
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn kernel_build(
version: Option<String>,
source: Option<PathBuf>,
git: Option<String>,
git_ref: Option<String>,
force: bool,
clean: bool,
cpu_cap: Option<usize>,
extra_kconfig: Option<PathBuf>,
skip_sha256: bool,
) -> Result<(), String> {
ktstr::cli::check_tools(&["make"]).map_err(|e| format!("{e:#}"))?;
let extra_content: Option<String> = match extra_kconfig.as_ref() {
Some(p) => Some(cli::read_extra_kconfig(p, "cargo ktstr")?),
None => None,
};
if source.is_none()
&& git.is_none()
&& let Some(ref v) = version
{
use ktstr::kernel_path::KernelId;
let id = KernelId::parse(v);
id.validate().map_err(|e| format!("--kernel {id}: {e}"))?;
if let KernelId::Range { start, end } = id {
let versions = ktstr::cli::expand_kernel_range(&start, &end, "cargo ktstr")
.map_err(|e| format!("{e:#}"))?;
let total = versions.len();
let mut failures: Vec<(String, String)> = Vec::new();
for (i, ver) in versions.iter().enumerate() {
eprintln!("cargo ktstr: [{}/{total}] kernel build {ver}", i + 1);
if let Err(e) = kernel_build_one(
Some(ver.clone()),
None,
None,
None,
force,
clean,
cpu_cap,
extra_content.as_deref(),
skip_sha256,
) {
eprintln!("cargo ktstr: {ver}: {e}");
failures.push((ver.clone(), e));
}
}
if failures.is_empty() {
Ok(())
} else {
Err(format!(
"kernel build range {start}..{end}: {failed}/{total} \
version(s) failed: {names}",
start = start,
end = end,
failed = failures.len(),
names = failures
.iter()
.map(|(v, _)| v.as_str())
.collect::<Vec<_>>()
.join(", "),
))
}
} else {
kernel_build_one(
version,
source,
git,
git_ref,
force,
clean,
cpu_cap,
extra_content.as_deref(),
skip_sha256,
)
}
} else {
kernel_build_one(
version,
source,
git,
git_ref,
force,
clean,
cpu_cap,
extra_content.as_deref(),
skip_sha256,
)
}
}
#[allow(clippy::too_many_arguments)]
fn kernel_build_one(
version: Option<String>,
source: Option<PathBuf>,
git: Option<String>,
git_ref: Option<String>,
force: bool,
clean: bool,
cpu_cap: Option<usize>,
extra_kconfig: Option<&str>,
skip_sha256: bool,
) -> Result<(), String> {
if cpu_cap.is_some()
&& std::env::var("KTSTR_BYPASS_LLC_LOCKS")
.ok()
.is_some_and(|v| !v.is_empty())
{
return Err(
"--cpu-cap conflicts with KTSTR_BYPASS_LLC_LOCKS=1; unset one of them. \
--cpu-cap is a resource contract; bypass disables the contract entirely."
.to_string(),
);
}
let resolved_cap = cli::CpuCap::resolve(cpu_cap).map_err(|e| format!("{e:#}"))?;
let cache = CacheDir::new().map_err(|e| format!("open cache: {e:#}"))?;
let tmp_dir = tempfile::TempDir::new().map_err(|e| format!("create temp dir: {e:#}"))?;
let client = fetch::shared_client();
let mut acquired = if let Some(ref src_path) = source {
fetch::local_source(src_path).map_err(|e| format!("{e:#}"))?
} else if let Some(ref url) = git {
let ref_name = git_ref.as_deref().expect("clap requires --ref with --git");
fetch::git_clone(url, ref_name, tmp_dir.path(), "cargo ktstr")
.map_err(|e| format!("{e:#}"))?
} else {
let ver = match version {
Some(v) if fetch::is_major_minor_prefix(&v) => {
fetch::fetch_version_for_prefix(client, &v, "cargo ktstr")
.map_err(|e| format!("{e:#}"))?
}
Some(v) => v,
None => fetch::fetch_latest_stable_version(client, "cargo ktstr")
.map_err(|e| format!("{e:#}"))?,
};
let (arch, _) = fetch::arch_info();
let cache_key = format!(
"{ver}-tarball-{arch}-kc{}",
ktstr::cache_key_suffix_with_extra(extra_kconfig),
);
if !force && let Some(entry) = cache_lookup(&cache, &cache_key) {
eprintln!("cargo ktstr: cached kernel found: {}", entry.path.display());
eprintln!("cargo ktstr: use --force to rebuild");
return Ok(());
}
let sp = cli::Spinner::start("Downloading kernel...");
let result =
fetch::download_tarball(client, &ver, tmp_dir.path(), "cargo ktstr", skip_sha256);
drop(sp);
let mut acquired = result.map_err(|e| format!("{e:#}"))?;
acquired.cache_key = cache_key;
acquired
};
if source.is_some() || git.is_some() {
cli::append_extra_kconfig_suffix(&mut acquired.cache_key, extra_kconfig);
}
if !force
&& (source.is_some() || git.is_some())
&& !acquired.is_dirty
&& let Some(entry) = cache_lookup(&cache, &acquired.cache_key)
{
eprintln!("cargo ktstr: cached kernel found: {}", entry.path.display());
eprintln!("cargo ktstr: use --force to rebuild");
return Ok(());
}
if force {
let _force_check = cache
.try_acquire_exclusive_lock(&acquired.cache_key)
.map_err(|e| format!("{e:#}"))?;
}
cli::kernel_build_pipeline(
&acquired,
&cache,
"cargo ktstr",
clean,
source.is_some(),
resolved_cap,
extra_kconfig,
)
.map_err(|e| format!("{e:#}"))?;
Ok(())
}
fn cache_lookup(cache: &CacheDir, cache_key: &str) -> Option<CacheEntry> {
cli::cache_lookup(cache, cache_key, "cargo ktstr")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn format_built_age_unparseable_returns_empty_string() {
assert_eq!(format_built_age("not-a-timestamp"), "");
assert_eq!(format_built_age(""), "");
assert_eq!(format_built_age("2026-01-02T03:04:05"), "");
}
#[test]
fn format_built_age_future_timestamp_returns_empty_string() {
assert_eq!(format_built_age("9999-12-31T23:59:59Z"), "");
}
#[test]
fn format_built_age_past_timestamp_includes_leading_comma_and_seconds() {
let one_hour_ago = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs()
.saturating_sub(3600);
let timestamp = humantime::format_rfc3339(
std::time::UNIX_EPOCH + std::time::Duration::from_secs(one_hour_ago),
)
.to_string();
let age = format_built_age(×tamp);
assert!(
age.starts_with(", built "),
"age suffix must start with the splice prefix `, built `, got {age:?}",
);
assert!(
age.ends_with(" ago"),
"age suffix must end with the relative-past keyword ` ago`, got {age:?}",
);
}
#[test]
fn resolve_path_kernel_nonexistent_returns_actionable_error() {
let raw = "/this/path/should/not/exist/under/test";
let result = resolve_path_kernel(std::path::Path::new(raw), raw);
let err = result.expect_err("nonexistent path must surface as Err");
assert!(
err.contains(&format!("--kernel {raw}")),
"error must lead with `--kernel {{raw_input}}:` so a typo \
names the exact string the user passed. got: {err}",
);
assert!(
err.contains(ktstr::KTSTR_KERNEL_HINT),
"error must end with KTSTR_KERNEL_HINT so the user sees \
the supported `--kernel` shapes. got: {err}",
);
}
#[test]
fn resolve_path_kernel_empty_tempdir_returns_not_a_source_tree_error() {
let tmp = tempfile::TempDir::new().expect("tempdir");
let raw = tmp.path().display().to_string();
let result = resolve_path_kernel(tmp.path(), &raw);
let err = result.expect_err("empty tempdir must surface as Err");
assert!(
err.contains(&format!("--kernel {raw}")),
"error must lead with `--kernel {{raw_input}}:`. got: {err}",
);
assert!(
err.contains("not a kernel source tree"),
"error must include the `not a kernel source tree` phrase \
from `acquire_local_source_tree`'s diagnostic. got: {err}",
);
assert!(
err.contains(ktstr::KTSTR_KERNEL_HINT),
"error must end with KTSTR_KERNEL_HINT. got: {err}",
);
}
}