extern crate self as ktstr;
#[allow(
clippy::all,
dead_code,
non_camel_case_types,
non_snake_case,
non_upper_case_globals
)]
mod bpf_skel;
#[cfg(test)]
#[macro_use]
mod test_macros;
#[doc(hidden)]
pub mod non_exhaustive {}
pub mod cache;
pub mod cgroup;
pub mod flock;
pub(crate) fn errno_name(errno: i32) -> Option<&'static str> {
let e = nix::errno::Errno::from_raw(errno);
if matches!(e, nix::errno::Errno::UnknownErrno) {
return None;
}
Some(match e {
nix::errno::Errno::EPERM => "EPERM",
nix::errno::Errno::ENOENT => "ENOENT",
nix::errno::Errno::ESRCH => "ESRCH",
nix::errno::Errno::EINTR => "EINTR",
nix::errno::Errno::EIO => "EIO",
nix::errno::Errno::ENXIO => "ENXIO",
nix::errno::Errno::E2BIG => "E2BIG",
nix::errno::Errno::ENOEXEC => "ENOEXEC",
nix::errno::Errno::EBADF => "EBADF",
nix::errno::Errno::ECHILD => "ECHILD",
nix::errno::Errno::EAGAIN => "EAGAIN",
nix::errno::Errno::ENOMEM => "ENOMEM",
nix::errno::Errno::EACCES => "EACCES",
nix::errno::Errno::EFAULT => "EFAULT",
nix::errno::Errno::EBUSY => "EBUSY",
nix::errno::Errno::EEXIST => "EEXIST",
nix::errno::Errno::ENODEV => "ENODEV",
nix::errno::Errno::ENOTDIR => "ENOTDIR",
nix::errno::Errno::EISDIR => "EISDIR",
nix::errno::Errno::EINVAL => "EINVAL",
nix::errno::Errno::ENFILE => "ENFILE",
nix::errno::Errno::EMFILE => "EMFILE",
nix::errno::Errno::ENOSPC => "ENOSPC",
nix::errno::Errno::ESPIPE => "ESPIPE",
nix::errno::Errno::EROFS => "EROFS",
nix::errno::Errno::EPIPE => "EPIPE",
nix::errno::Errno::EDOM => "EDOM",
nix::errno::Errno::ERANGE => "ERANGE",
nix::errno::Errno::EDEADLK => "EDEADLK",
nix::errno::Errno::ENAMETOOLONG => "ENAMETOOLONG",
nix::errno::Errno::ENOSYS => "ENOSYS",
nix::errno::Errno::ENOTEMPTY => "ENOTEMPTY",
nix::errno::Errno::ELOOP => "ELOOP",
nix::errno::Errno::ENOTSUP => "ENOTSUP",
nix::errno::Errno::EADDRINUSE => "EADDRINUSE",
nix::errno::Errno::ECONNREFUSED => "ECONNREFUSED",
nix::errno::Errno::ETIMEDOUT => "ETIMEDOUT",
_ => return None,
})
}
pub fn read_kmsg() -> String {
match rmesg::log_entries(rmesg::Backend::Default, false) {
Ok(entries) => entries
.iter()
.map(|e| e.message.as_str())
.collect::<Vec<_>>()
.join("\n"),
Err(_) => String::new(),
}
}
pub mod assert;
pub(crate) mod budget;
pub mod cli;
pub mod cpu_util;
pub mod ctprof;
pub mod ctprof_compare;
pub(crate) mod elf_strip;
pub mod export;
pub mod fetch;
pub mod fun;
pub mod host_context;
pub mod host_heap;
pub(crate) mod host_thread_probe;
pub mod kernel_path;
pub mod metric_types;
pub(crate) mod monitor;
pub(crate) mod probe;
pub(crate) mod report;
pub mod scenario;
pub(crate) mod stats;
pub(crate) mod taskstats;
pub mod test_support;
pub(crate) mod timeline;
pub mod topology;
pub mod live_host {
pub use crate::monitor::bpf_map::{
BPF_MAP_TYPE_ARENA, BPF_MAP_TYPE_ARRAY, BPF_MAP_TYPE_HASH, BPF_MAP_TYPE_PERCPU_ARRAY,
BpfMapAccessor, BpfMapInfo,
};
pub use crate::monitor::bpf_syscall::BpfSyscallAccessor;
pub use crate::monitor::debug_capture::{
AffinityHint, CgroupHint, CtprofSampleRef, DEBUG_CAPTURE_SCHEMA, DebugCapture,
SchedPolicyHint, WorkTypeHint, WorkloadFingerprint, WorkloadGroupHint, project_fingerprint,
};
pub use crate::monitor::dmesg_scx::{
ScxExitEvent, ScxExitKind, StackSymbol, extract_stack_symbols, parse_kmsg_window,
};
pub use crate::monitor::live_host_kernel::{KallsymsTable, LiveHostKernelEnv, uname_release};
pub use crate::monitor::reproducer_gen::{
ReproducerNote, ReproducerSpec, generate_spec, render_ktstr_test_source,
render_run_file_source,
};
pub use crate::monitor::timeline::{
DEFAULT_SNAPSHOT_RING_DEPTH, IncrementalCapture, IncrementalSnapshot, SnapshotRing,
TimelineCapture, TimelineEvent, TimelineEventRaw, parse_timeline_buf,
parse_timeline_record, tl_evt,
};
}
pub mod remote_cache;
pub(crate) mod sync;
pub(crate) mod tar_util;
pub mod verifier;
pub(crate) mod vm;
pub(crate) mod vmm;
pub mod worker_ready;
pub mod worker_ready_wait;
pub mod workload;
pub(crate) const BUSYBOX: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/busybox"));
pub const EMBEDDED_KCONFIG: &str = include_str!("../ktstr.kconfig");
pub fn kconfig_hash() -> String {
format!("{:08x}", crc32fast::hash(EMBEDDED_KCONFIG.as_bytes()))
}
pub fn extra_kconfig_hash(extra: &str) -> String {
format!("{:08x}", crc32fast::hash(extra.as_bytes()))
}
pub fn cache_key_suffix() -> String {
kconfig_hash()
}
pub fn cache_key_suffix_with_extra(extra: Option<&str>) -> String {
match extra {
None => kconfig_hash(),
Some(content) => format!("{}-xkc{}", kconfig_hash(), extra_kconfig_hash(content)),
}
}
pub fn merge_kconfig_fragments<'a>(
baked: &'a str,
extra: Option<&str>,
) -> std::borrow::Cow<'a, str> {
match extra {
None => std::borrow::Cow::Borrowed(baked),
Some(content) => std::borrow::Cow::Owned(format!("{baked}\n{content}")),
}
}
pub use ktstr_macros::Claim;
pub use ktstr_macros::Payload;
pub use ktstr_macros::Scheduler;
pub use ktstr_macros::ktstr_test;
#[doc(hidden)]
pub mod __private {
pub use ctor;
pub use linkme;
pub use serde_json;
}
#[cfg(feature = "integration")]
pub use crate::probe::process::resolve_func_ip;
pub mod prelude {
pub use anyhow::Result;
pub use crate::assert::{
Assert, AssertDetail, AssertResult, ClaimBuilder, DetailKind, NoteValue, SchedulerBaseline,
SeqClaim, SetClaim, Verdict, assert_baseline, assert_scx_events_clean,
};
pub use crate::cgroup::CgroupManager;
pub use crate::claim;
pub use crate::host_context::HostContext;
pub use crate::host_heap::HostHeapState;
pub use crate::ktstr_test;
pub use crate::scenario::backdrop::Backdrop;
pub use crate::scenario::flags::FlagDecl;
pub use crate::scenario::ops::{
CgroupDef, CpusetSpec, HoldSpec, Op, Setup, Step, execute_defs, execute_scenario,
execute_scenario_with, execute_steps, execute_steps_with,
};
pub use crate::scenario::payload_run::{PayloadHandle, PayloadRun};
pub use crate::scenario::scenarios;
pub use crate::monitor::btf_render::{RenderedMember, RenderedValue};
pub use crate::monitor::dump::{
FailureDumpEntry, FailureDumpMap, FailureDumpPercpuEntry, FailureDumpPercpuHashEntry,
FailureDumpReport, SCHEMA_DUAL, SCHEMA_SINGLE,
};
pub use crate::scenario::snapshot::{
BridgeGuard, CaptureCallback, MAX_WATCH_SNAPSHOTS, Snapshot, SnapshotBridge, SnapshotEntry,
SnapshotError, SnapshotField, SnapshotMap, SnapshotResult, WatchRegisterCallback,
};
pub use crate::scenario::{CgroupGroup, Ctx, collect_all, spawn_diverse};
pub use crate::test_support::{
BpfMapWrite, CgroupPath, MemSideCache, Metric, MetricBounds, MetricCheck, MetricHint,
MetricSource, NumaDistance, NumaNode, OutputFormat, Payload, PayloadKind, PayloadMetrics,
Polarity, Scheduler, SchedulerSpec, SidecarResult, Sysctl, Topology, extract_metrics,
};
pub use crate::{Payload, Scheduler};
pub use crate::topology::{LlcInfo, NodeMemInfo, TestTopology};
pub use crate::vmm::VirtioBlkCounters;
pub use crate::vmm::VirtioNetCounters;
pub use crate::vmm::VmResult;
pub use crate::vmm::disk_config::{
DiskConfig, DiskThrottle, DiskThrottleValidationError, Filesystem, ThrottleDimension,
};
pub use crate::vmm::net_config::NetConfig;
pub use crate::workload::{
AffinityIntent, AluWidth, CloneMode, MemPolicy, Migration, MpolFlags, Phase,
ResolvedAffinity, SchedPolicy, WorkSpec, WorkType, WorkTypeValidationError, WorkerReport,
WorkloadConfig, WorkloadHandle, build_nodemask,
};
}
pub const KTSTR_KERNEL_ENV: &str = "KTSTR_KERNEL";
pub const KTSTR_KERNEL_LIST_ENV: &str = "KTSTR_KERNEL_LIST";
pub const KTSTR_KERNEL_PARALLELISM_ENV: &str = "KTSTR_KERNEL_PARALLELISM";
pub const KTSTR_KERNEL_HINT: &str = "set KTSTR_KERNEL to a kernel source directory, \
a version (e.g. `6.14.2`), or a cache key (see `cargo ktstr kernel list`), or run \
`cargo ktstr kernel build` to populate the cache";
pub fn ktstr_kernel_env() -> Option<String> {
std::env::var(KTSTR_KERNEL_ENV)
.ok()
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty())
}
pub fn find_kernel() -> anyhow::Result<Option<std::path::PathBuf>> {
use kernel_path::KernelId;
let release = rustix::system::uname()
.release()
.to_str()
.ok()
.map(str::to_owned);
let release_ref = release.as_deref();
let mut skip_cache_scan = false;
if let Some(val) = ktstr_kernel_env() {
match KernelId::parse(&val) {
KernelId::Path(ref p) => {
let Some(s) = p.to_str() else {
anyhow::bail!(
"KTSTR_KERNEL={val} expands to a non-UTF-8 path. \
{KTSTR_KERNEL_HINT}"
);
};
match kernel_path::find_image(Some(s), release_ref) {
Some(found) => return Ok(Some(found)),
None => anyhow::bail!(
"KTSTR_KERNEL={val} does not contain a kernel image. {KTSTR_KERNEL_HINT}"
),
}
}
KernelId::Version(ref ver) => {
let cache = cache::CacheDir::new().map_err(|e| {
anyhow::anyhow!(
"KTSTR_KERNEL={val} requires cache access, \
but cache directory could not be opened: {e}"
)
})?;
let arch = std::env::consts::ARCH;
let key = format!("{ver}-tarball-{arch}-kc{}", cache_key_suffix());
if let Some(entry) = cache.lookup(&key) {
return Ok(Some(entry.image_path()));
}
skip_cache_scan = true;
}
KernelId::CacheKey(ref key) => {
let cache = cache::CacheDir::new().map_err(|e| {
anyhow::anyhow!(
"KTSTR_KERNEL={val} requires cache access, \
but cache directory could not be opened: {e}"
)
})?;
if let Some(entry) = cache.lookup(key) {
return Ok(Some(entry.image_path()));
}
skip_cache_scan = true;
}
id @ (KernelId::Range { .. } | KernelId::Git { .. }) => {
if let Err(e) = id.validate() {
anyhow::bail!("KTSTR_KERNEL={val}: {e}");
}
anyhow::bail!(
"KTSTR_KERNEL={val}: multi-kernel specs (ranges, \
git sources) are not supported in env-var form. \
Use --kernel on the test/coverage/verifier \
subcommands, or set KTSTR_KERNEL to a single \
version, cache key, or path."
);
}
}
}
if !skip_cache_scan
&& let Ok(cache) = cache::CacheDir::new()
&& let Ok(entries) = cache.list()
{
let kc_hash = kconfig_hash();
for listed in &entries {
let cache::ListedEntry::Valid(entry) = listed else {
continue;
};
if entry.kconfig_status(&kc_hash).is_stale() {
continue;
}
let image = entry.image_path();
if !image.exists() {
continue;
}
if let Some(vmlinux) = entry.vmlinux_path()
&& let Err(e) = monitor::symbols::KernelSymbols::from_vmlinux(&vmlinux)
{
tracing::warn!(
entry = %entry.path.display(),
error = %e,
"skipping cached kernel with unusable vmlinux"
);
continue;
}
return Ok(Some(image));
}
}
Ok(kernel_path::find_image(None, release_ref))
}
pub fn build_and_find_binary(package: &str) -> anyhow::Result<std::path::PathBuf> {
let output = std::process::Command::new("cargo")
.args(["build", "-p", package, "--message-format=json"])
.current_dir(env!("CARGO_MANIFEST_DIR"))
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.output()
.map_err(|e| anyhow::anyhow!("cargo build: {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("cargo build -p {package} failed:\n{stderr}");
}
let stdout = String::from_utf8_lossy(&output.stdout);
for line in stdout.lines() {
if let Ok(msg) = serde_json::from_str::<serde_json::Value>(line)
&& msg.get("reason").and_then(|r| r.as_str()) == Some("compiler-artifact")
&& msg
.get("profile")
.and_then(|p| p.get("test"))
.and_then(|t| t.as_bool())
== Some(false)
&& msg
.get("target")
.and_then(|t| t.get("kind"))
.and_then(|k| k.as_array())
.is_some_and(|kinds| kinds.iter().any(|k| k.as_str() == Some("bin")))
&& let Some(filenames) = msg.get("filenames").and_then(|f| f.as_array())
&& let Some(path) = filenames.first().and_then(|f| f.as_str())
{
return Ok(std::path::PathBuf::from(path));
}
}
anyhow::bail!("no binary artifact found for package '{package}'")
}
pub(crate) fn resolve_current_exe() -> anyhow::Result<std::path::PathBuf> {
use anyhow::Context;
let exe = std::env::current_exe().context("resolve current exe")?;
if exe.exists() {
return Ok(exe);
}
let proc_exe = std::path::PathBuf::from("/proc/self/exe");
anyhow::ensure!(
proc_exe.exists(),
"current exe not found: {}",
exe.display()
);
Ok(proc_exe)
}
#[allow(clippy::too_many_arguments)]
pub fn run_shell(
kernel: std::path::PathBuf,
numa_nodes: u32,
llcs: u32,
cores: u32,
threads: u32,
include_files: &[(&str, &std::path::Path)],
memory_mb: Option<u32>,
dmesg: bool,
exec: Option<&str>,
disk: Option<vmm::disk_config::DiskConfig>,
) -> anyhow::Result<()> {
let payload = resolve_current_exe()?;
let owned_includes: Vec<(String, std::path::PathBuf)> = include_files
.iter()
.map(|(a, p)| (a.to_string(), p.to_path_buf()))
.collect();
let mut cmdline = format!("KTSTR_MODE=shell KTSTR_TOPO={numa_nodes},{llcs},{cores},{threads}");
if dmesg {
cmdline.push_str(" loglevel=7");
}
if let Ok(val) = std::env::var("RUST_LOG") {
cmdline.push_str(&format!(" RUST_LOG={val}"));
}
if let Ok(term) = std::env::var("TERM") {
cmdline.push_str(&format!(" KTSTR_TERM={term}"));
}
if let Ok(ct) = std::env::var("COLORTERM") {
cmdline.push_str(&format!(" KTSTR_COLORTERM={ct}"));
}
unsafe {
let mut ws: libc::winsize = std::mem::zeroed();
if libc::ioctl(libc::STDIN_FILENO, libc::TIOCGWINSZ, &mut ws) == 0
&& ws.ws_col > 0
&& ws.ws_row > 0
{
cmdline.push_str(&format!(
" KTSTR_COLS={} KTSTR_ROWS={}",
ws.ws_col, ws.ws_row
));
}
}
let no_perf_mode = std::env::var("KTSTR_NO_PERF_MODE").is_ok();
let mut builder = vmm::KtstrVm::builder()
.kernel(&kernel)
.init_binary(&payload)
.topology(numa_nodes, llcs, cores, threads)
.cmdline(&cmdline)
.include_files(owned_includes)
.busybox(true)
.dmesg(dmesg)
.no_perf_mode(no_perf_mode);
if let Some(cmd) = exec {
builder = builder.exec_cmd(cmd.to_string());
}
if let Some(d) = disk {
builder = builder.disk(d);
}
builder = match memory_mb {
Some(mb) => builder.memory_mb(mb),
None => builder.memory_deferred(),
};
let vm = builder.build()?;
vm.run_interactive()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resolve_current_exe_happy_path() {
let exe = resolve_current_exe().unwrap();
let std_exe = std::env::current_exe().unwrap();
if std_exe.exists() {
assert_eq!(exe, std_exe);
} else {
assert_eq!(exe, std::path::PathBuf::from("/proc/self/exe"));
}
}
#[test]
fn errno_name_known_values() {
assert_eq!(errno_name(libc::EPERM), Some("EPERM"));
assert_eq!(errno_name(libc::ENOENT), Some("ENOENT"));
assert_eq!(errno_name(libc::EINVAL), Some("EINVAL"));
assert_eq!(errno_name(libc::ENOMEM), Some("ENOMEM"));
assert_eq!(errno_name(libc::EBUSY), Some("EBUSY"));
assert_eq!(errno_name(libc::EACCES), Some("EACCES"));
assert_eq!(errno_name(libc::EAGAIN), Some("EAGAIN"));
assert_eq!(errno_name(libc::ENOSYS), Some("ENOSYS"));
assert_eq!(errno_name(libc::ETIMEDOUT), Some("ETIMEDOUT"));
}
#[test]
fn errno_name_unknown() {
assert_eq!(errno_name(9999), None);
assert_eq!(errno_name(0), None);
assert_eq!(errno_name(-1), None);
}
#[test]
fn find_kernel_preserves_untracked_cache_entries() {
use crate::cache::{CacheArtifacts, CacheDir, KernelMetadata, KernelSource};
use crate::test_support::test_helpers::{EnvVarGuard, lock_env};
let _env_lock = lock_env();
let _kernel_guard = EnvVarGuard::remove("KTSTR_KERNEL");
let tmp = tempfile::TempDir::new().unwrap();
let cache_root = tmp.path().join("cache");
let _cache_guard = EnvVarGuard::set("KTSTR_CACHE_DIR", &cache_root);
let cache = CacheDir::with_root(cache_root.clone());
let src_dir = tempfile::TempDir::new().unwrap();
let image = src_dir.path().join("bzImage");
std::fs::write(&image, b"fake kernel image").unwrap();
let meta = KernelMetadata::new(
KernelSource::Tarball,
"x86_64".to_string(),
"bzImage".to_string(),
"2026-04-12T10:00:00Z".to_string(),
)
.with_version(Some("6.14.2".to_string()));
assert!(
meta.ktstr_kconfig_hash.is_none(),
"test fixture must have no recorded kconfig hash to exercise the \
Untracked branch of kconfig_status"
);
let entry = cache
.store("untracked-entry", &CacheArtifacts::new(&image), &meta)
.unwrap();
let expected_image = entry.image_path();
assert!(
expected_image.exists(),
"fixture image must exist on disk so find_kernel's image.exists() \
check passes — got {expected_image:?}"
);
let resolved = find_kernel().unwrap();
assert_eq!(
resolved,
Some(expected_image),
"find_kernel dropped an Untracked cache entry — the kconfig-hash \
filter at lib.rs must treat `Untracked` as keep, not stale"
);
}
#[test]
fn find_kernel_skips_stale_cache_entry() {
use crate::cache::{CacheArtifacts, CacheDir, KernelMetadata, KernelSource};
use crate::test_support::test_helpers::{EnvVarGuard, lock_env};
let _env_lock = lock_env();
let _kernel_guard = EnvVarGuard::remove("KTSTR_KERNEL");
let tmp = tempfile::TempDir::new().unwrap();
let cache_root = tmp.path().join("cache");
let _cache_guard = EnvVarGuard::set("KTSTR_CACHE_DIR", &cache_root);
let current_hash = crate::kconfig_hash();
let stale_hash = format!("{current_hash}-stale");
let cache = CacheDir::with_root(cache_root.clone());
let src_dir = tempfile::TempDir::new().unwrap();
let current_image = src_dir.path().join("current.bzImage");
std::fs::write(¤t_image, b"current kernel image").unwrap();
let current_meta = KernelMetadata::new(
KernelSource::Tarball,
"x86_64".to_string(),
"current.bzImage".to_string(),
"2026-04-01T00:00:00Z".to_string(),
)
.with_version(Some("6.14.2".to_string()))
.with_ktstr_kconfig_hash(Some(current_hash.clone()));
let current_entry = cache
.store(
"current-entry",
&CacheArtifacts::new(¤t_image),
¤t_meta,
)
.unwrap();
let stale_image = src_dir.path().join("stale.bzImage");
std::fs::write(&stale_image, b"stale kernel image").unwrap();
let stale_meta = KernelMetadata::new(
KernelSource::Tarball,
"x86_64".to_string(),
"stale.bzImage".to_string(),
"2026-04-20T00:00:00Z".to_string(),
)
.with_version(Some("6.14.3".to_string()))
.with_ktstr_kconfig_hash(Some(stale_hash));
cache
.store(
"stale-entry",
&CacheArtifacts::new(&stale_image),
&stale_meta,
)
.unwrap();
let resolved = find_kernel().unwrap();
assert_eq!(
resolved,
Some(current_entry.image_path()),
"find_kernel must skip the newer stale entry and return the \
current-hash entry — regression of the KconfigStatus::Stale \
skip branch in find_kernel's cache-scan loop",
);
}
#[test]
fn worker_ready_marker_path_format_is_stable() {
use crate::worker_ready::{WORKER_READY_MARKER_PREFIX, worker_ready_marker_path};
assert_eq!(WORKER_READY_MARKER_PREFIX, "/tmp/ktstr-worker-ready-");
assert_eq!(worker_ready_marker_path(0), "/tmp/ktstr-worker-ready-0");
assert_eq!(
worker_ready_marker_path(12345),
"/tmp/ktstr-worker-ready-12345"
);
assert_eq!(
worker_ready_marker_path(u32::MAX),
"/tmp/ktstr-worker-ready-4294967295"
);
}
#[test]
fn ktstr_kernel_env_round_trips_absolute_path() {
use crate::test_support::test_helpers::{EnvVarGuard, lock_env};
let _lock = lock_env();
let tmp = tempfile::TempDir::new().unwrap();
let canonical = std::fs::canonicalize(tmp.path()).unwrap();
let _guard = EnvVarGuard::set(KTSTR_KERNEL_ENV, &canonical);
let read_back = ktstr_kernel_env().expect("env is set");
assert_eq!(
std::path::PathBuf::from(&read_back),
canonical,
"writer-reader round-trip must preserve the exact path; \
drift between parent's canonicalize output and child's \
ktstr_kernel_env read breaks every downstream resolver",
);
}
#[test]
fn ktstr_kernel_env_unset_is_none() {
use crate::test_support::test_helpers::{EnvVarGuard, lock_env};
let _lock = lock_env();
let _guard = EnvVarGuard::remove(KTSTR_KERNEL_ENV);
assert!(
ktstr_kernel_env().is_none(),
"unset KTSTR_KERNEL must read as None so fallback resolvers activate",
);
}
#[test]
fn ktstr_kernel_env_empty_is_none() {
use crate::test_support::test_helpers::{EnvVarGuard, lock_env};
let _lock = lock_env();
let _guard = EnvVarGuard::set(KTSTR_KERNEL_ENV, "");
assert!(
ktstr_kernel_env().is_none(),
"empty KTSTR_KERNEL must collapse to None; CI flows routinely \
pass empty strings for unused variables",
);
}
#[test]
fn ktstr_kernel_env_whitespace_is_none() {
use crate::test_support::test_helpers::{EnvVarGuard, lock_env};
let _lock = lock_env();
let _guard = EnvVarGuard::set(KTSTR_KERNEL_ENV, " \t\n ");
assert!(
ktstr_kernel_env().is_none(),
"whitespace-only KTSTR_KERNEL must collapse to None via trim \
+ empty-filter; no caller parses a whitespace-only value \
meaningfully",
);
}
#[test]
fn ktstr_kernel_env_trims_surrounding_whitespace() {
use crate::test_support::test_helpers::{EnvVarGuard, lock_env};
let _lock = lock_env();
let _guard = EnvVarGuard::set(KTSTR_KERNEL_ENV, " ../linux ");
let read_back = ktstr_kernel_env().expect("env is set");
assert_eq!(
read_back, "../linux",
"surrounding whitespace must be trimmed but the interior \
preserved verbatim",
);
}
#[test]
fn find_kernel_inverted_range_env_surfaces_swap_diagnostic() {
use crate::test_support::test_helpers::{EnvVarGuard, lock_env};
let _env_lock = lock_env();
let tmp = tempfile::TempDir::new().unwrap();
let cache_root = tmp.path().join("cache");
let _cache_guard = EnvVarGuard::set("KTSTR_CACHE_DIR", &cache_root);
let _kernel_guard = EnvVarGuard::set(KTSTR_KERNEL_ENV, "6.16..6.12");
let err = find_kernel().expect_err("inverted range must error");
let msg = format!("{err:#}");
assert!(
msg.contains("inverted kernel range"),
"validate() diagnostic must surface ahead of the generic \
env-form bail; got: {msg}",
);
assert!(
msg.contains("6.12..6.16"),
"swap suggestion must appear in the error; got: {msg}",
);
assert!(
!msg.contains("not supported in env-var form"),
"validate() must short-circuit before the generic bail; got: {msg}",
);
}
#[test]
fn ktstr_kernel_env_constant_is_literal() {
assert_eq!(KTSTR_KERNEL_ENV, "KTSTR_KERNEL");
}
#[test]
fn cache_key_suffix_with_extra_none_matches_bare_suffix() {
assert_eq!(cache_key_suffix_with_extra(None), cache_key_suffix());
}
#[test]
fn cache_key_suffix_with_extra_some_has_two_segment_shape() {
let suffix = cache_key_suffix_with_extra(Some("CONFIG_FOO=y\n"));
let baked = kconfig_hash();
assert!(
suffix.starts_with(&baked),
"Some suffix must start with bare baked-in hash {baked:?}, got {suffix:?}"
);
let after = &suffix[baked.len()..];
assert!(
after.starts_with("-xkc"),
"after the baked-in segment, the next bytes must be `-xkc`, got {after:?}"
);
let extra_segment = &after["-xkc".len()..];
assert_eq!(
extra_segment.len(),
8,
"extra-hash segment must be 8 hex chars, got {extra_segment:?}"
);
assert!(
extra_segment.chars().all(|c| c.is_ascii_hexdigit()),
"extra-hash segment must be lowercase hex, got {extra_segment:?}"
);
}
#[test]
fn cache_key_suffix_with_extra_some_differs_from_bare_suffix() {
let suffix = cache_key_suffix_with_extra(Some("CONFIG_FOO=y\n"));
assert_ne!(suffix, cache_key_suffix());
let baked = kconfig_hash();
let after = &suffix[baked.len()..];
assert_eq!(
after.len(),
"-xkc".len() + 8,
"suffix tail must be `-xkc{{8 hex chars}}`, got {after:?}"
);
}
#[test]
fn cache_key_suffix_with_extra_matches_production_format_string() {
let extra = "CONFIG_FOO=y\n";
let baked = kconfig_hash();
let extra_h = extra_kconfig_hash(extra);
let helper = cache_key_suffix_with_extra(Some(extra));
let expected = format!("{baked}-xkc{extra_h}");
assert_eq!(
helper, expected,
"helper output must match production format `{{baked}}-xkc{{extra}}` \
(cargo-ktstr.rs builds the tarball cache key with the same shape \
via `{{ver}}-tarball-{{arch}}-kc{{cache_key_suffix_with_extra(...)}}`)"
);
}
#[test]
fn cache_key_suffix_with_extra_empty_differs_from_none() {
let with_empty = cache_key_suffix_with_extra(Some(""));
let without = cache_key_suffix_with_extra(None);
assert_ne!(with_empty, without);
}
#[test]
fn cache_key_suffix_with_extra_same_content_same_suffix() {
let extra = "CONFIG_FOO=y\nCONFIG_BAR=n\n";
let a = cache_key_suffix_with_extra(Some(extra));
let b = cache_key_suffix_with_extra(Some(extra));
assert_eq!(a, b, "same fragment must produce same suffix");
}
#[test]
fn cache_key_suffix_with_extra_different_content_different_suffix() {
let a = cache_key_suffix_with_extra(Some("CONFIG_FOO=y\n"));
let b = cache_key_suffix_with_extra(Some("CONFIG_FOO=n\n"));
assert_ne!(a, b, "distinct fragments must produce distinct suffixes");
let baked = kconfig_hash();
assert!(a.starts_with(&baked) && b.starts_with(&baked));
}
#[test]
fn extra_kconfig_hash_is_8_hex_chars() {
for content in ["", "CONFIG_X=y\n", "# CONFIG_BPF is not set\n"] {
let h = extra_kconfig_hash(content);
assert_eq!(h.len(), 8, "expected 8 hex chars, got {h}");
assert!(
h.chars().all(|c| c.is_ascii_hexdigit()),
"expected lowercase hex, got {h}",
);
}
}
#[test]
fn extra_kconfig_hash_is_byte_sensitive() {
let lf = "CONFIG_FOO=y\n";
let crlf = "CONFIG_FOO=y\r\n";
assert_ne!(
extra_kconfig_hash(lf),
extra_kconfig_hash(crlf),
"CRLF and LF must hash differently per ruling D2"
);
let with_comment = "# user note\nCONFIG_FOO=y\n";
let without_comment = "CONFIG_FOO=y\n";
assert_ne!(
extra_kconfig_hash(with_comment),
extra_kconfig_hash(without_comment),
"comments must affect the hash per ruling D1"
);
}
#[test]
fn cache_key_suffix_with_extra_crlf_differs_from_lf() {
let lf = "CONFIG_FOO=y\n";
let crlf = "CONFIG_FOO=y\r\n";
let lf_suffix = cache_key_suffix_with_extra(Some(lf));
let crlf_suffix = cache_key_suffix_with_extra(Some(crlf));
assert_ne!(
lf_suffix, crlf_suffix,
"LF and CRLF user fragments must produce distinct cache \
keys per ruling D2 (no CRLF canonicalization). A Windows \
operator and a Unix operator who supplied 'the same' \
fragment land at distinct cache slots; this is the \
documented byte-deterministic cache contract."
);
}
#[test]
fn legacy_bare_suffix_is_proper_prefix_of_extras_suffix() {
let bare = cache_key_suffix();
let extras = cache_key_suffix_with_extra(Some("CONFIG_FOO=y\n"));
assert!(
extras.starts_with(&bare),
"extras suffix must extend bare suffix — bare={bare:?} extras={extras:?}",
);
assert!(
extras.len() > bare.len(),
"extras suffix must be strictly longer than bare suffix",
);
assert_ne!(
bare, extras,
"structural distinction: legacy entries (key ending in `kc{{bare}}`) cannot \
collide with extras entries (key ending in `kc{{bare}}-xkc{{...}}`) on \
exact-match cache lookup."
);
}
#[test]
fn extra_kconfig_hash_empty_is_crc32_zero_not_sentinel() {
let empty = extra_kconfig_hash("");
assert_eq!(
empty, "00000000",
"CRC32 of zero bytes is 0x00000000 by spec. This value is \
a legitimate hash output, not a sentinel — readers that \
want to detect 'no extras' must check the metadata's \
`extra_kconfig_hash: Option<String>` for None, not for \
this string."
);
}
#[test]
fn merge_user_extra_appears_after_baked_in_for_conflict_resolution() {
let user = "# CONFIG_BPF is not set\n";
let merged = merge_kconfig_fragments(EMBEDDED_KCONFIG, Some(user));
let baked_pos = merged
.find("CONFIG_BPF=y")
.expect("baked-in CONFIG_BPF=y must be present in merged fragment");
let user_pos = merged
.find("# CONFIG_BPF is not set")
.expect("user override must be present in merged fragment");
assert!(
baked_pos < user_pos,
"baked-in line must appear BEFORE user override so kbuild's \
last-wins rule (confdata.c::conf_read_simple) keeps the user \
value (baked_pos={baked_pos}, user_pos={user_pos})",
);
let mut last = None;
for line in merged.lines() {
let trimmed = line.trim();
if trimmed == "CONFIG_BPF=y" || trimmed == "# CONFIG_BPF is not set" {
last = Some(trimmed.to_string());
}
}
assert_eq!(
last.as_deref(),
Some("# CONFIG_BPF is not set"),
"the LAST occurrence of CONFIG_BPF in the merged content must \
be the user override; kbuild's `conf_read_simple` walks lines \
top-to-bottom and keeps the last assignment, so the user line \
determines the final config value",
);
}
#[test]
fn merge_user_extra_combines_with_baked_in_for_disjoint_symbols() {
let novel = "CONFIG_KTSTR_TEST_NOVEL_SYMBOL_FOR_MERGE_TEST=y\n";
assert!(
!EMBEDDED_KCONFIG.contains("CONFIG_KTSTR_TEST_NOVEL_SYMBOL_FOR_MERGE_TEST"),
"test fixture must use a symbol absent from EMBEDDED_KCONFIG"
);
let merged = merge_kconfig_fragments(EMBEDDED_KCONFIG, Some(novel));
assert!(
merged.contains("CONFIG_KTSTR_TEST_NOVEL_SYMBOL_FOR_MERGE_TEST=y"),
"user-novel line must appear in merged fragment",
);
assert!(
merged.contains("CONFIG_BPF=y"),
"baked-in CONFIG_BPF=y must still appear in merged fragment",
);
}
#[test]
fn merge_kconfig_fragments_none_returns_baked_unchanged() {
let merged = merge_kconfig_fragments(EMBEDDED_KCONFIG, None);
assert_eq!(
merged, EMBEDDED_KCONFIG,
"merge with None must return the baked fragment unchanged"
);
}
#[test]
fn merge_kconfig_fragments_some_empty_appends_separator_newline() {
let merged = merge_kconfig_fragments(EMBEDDED_KCONFIG, Some(""));
let expected = format!("{EMBEDDED_KCONFIG}\n");
assert_eq!(merged, expected);
}
#[test]
fn merge_kconfig_fragments_user_line_appears_after_baked_for_overrides() {
let baked = "CONFIG_FOO=y\nCONFIG_BAR=m\n";
let user = "CONFIG_FOO=n\n";
let merged = merge_kconfig_fragments(baked, Some(user)).into_owned();
let baked_idx = merged
.find("CONFIG_FOO=y")
.expect("baked CONFIG_FOO=y must be present");
let user_idx = merged
.find("CONFIG_FOO=n")
.expect("user CONFIG_FOO=n override must be present");
assert!(
baked_idx < user_idx,
"baked-in CONFIG_FOO=y must precede user override CONFIG_FOO=n so \
kbuild's last-wins rule picks the user value: {merged}"
);
}
#[test]
fn merge_kconfig_fragments_disjoint_symbols_both_present() {
let baked = "CONFIG_FOO=y\n";
let user = "CONFIG_DISJOINT_TEST_SYMBOL=m\n";
let merged = merge_kconfig_fragments(baked, Some(user)).into_owned();
assert!(
merged.contains("CONFIG_FOO=y"),
"baked symbol must survive merge: {merged}"
);
assert!(
merged.contains("CONFIG_DISJOINT_TEST_SYMBOL=m"),
"user-added disjoint symbol must survive merge: {merged}"
);
}
#[test]
fn cache_lookup_same_extras_hits_planted_entry() {
use crate::cache::{CacheArtifacts, CacheDir, KernelMetadata, KernelSource};
use crate::test_support::test_helpers::{EnvVarGuard, lock_env};
let _env_lock = lock_env();
let _kernel_guard = EnvVarGuard::remove("KTSTR_KERNEL");
let tmp = tempfile::TempDir::new().unwrap();
let cache_root = tmp.path().join("cache");
let _cache_guard = EnvVarGuard::set("KTSTR_CACHE_DIR", &cache_root);
let extra = "CONFIG_KTSTR_CACHE_ROUNDTRIP_TEST_A=y\n";
let extra_hash = extra_kconfig_hash(extra);
let cache_key = format!("test-roundtrip-{}-xkc{}", kconfig_hash(), extra_hash);
let cache = CacheDir::with_root(cache_root.clone());
let src_dir = tempfile::TempDir::new().unwrap();
let image = src_dir.path().join("bzImage");
std::fs::write(&image, b"fake kernel image").unwrap();
let meta = KernelMetadata::new(
KernelSource::Tarball,
"x86_64".to_string(),
"bzImage".to_string(),
"2026-04-12T10:00:00Z".to_string(),
)
.with_extra_kconfig_hash(Some(extra_hash.clone()));
cache
.store(&cache_key, &CacheArtifacts::new(&image), &meta)
.unwrap();
let hit = cache.lookup(&cache_key);
assert!(
hit.is_some(),
"cache lookup with same extras must return planted entry; \
cache_key={cache_key}"
);
assert_eq!(
hit.as_ref().unwrap().metadata.extra_kconfig_hash.as_deref(),
Some(extra_hash.as_str()),
"retrieved entry must carry the planted extra_kconfig_hash"
);
}
#[test]
fn cache_lookup_different_extras_misses_planted_entry() {
use crate::cache::{CacheArtifacts, CacheDir, KernelMetadata, KernelSource};
use crate::test_support::test_helpers::{EnvVarGuard, lock_env};
let _env_lock = lock_env();
let _kernel_guard = EnvVarGuard::remove("KTSTR_KERNEL");
let tmp = tempfile::TempDir::new().unwrap();
let cache_root = tmp.path().join("cache");
let _cache_guard = EnvVarGuard::set("KTSTR_CACHE_DIR", &cache_root);
let extra_a = "CONFIG_KTSTR_CACHE_DISCRIMINATE_A=y\n";
let extra_b = "CONFIG_KTSTR_CACHE_DISCRIMINATE_B=y\n";
let key_a = format!(
"test-disc-{}-xkc{}",
kconfig_hash(),
extra_kconfig_hash(extra_a)
);
let key_b = format!(
"test-disc-{}-xkc{}",
kconfig_hash(),
extra_kconfig_hash(extra_b)
);
assert_ne!(
key_a, key_b,
"extras A and B must produce distinct cache keys (precondition)"
);
let cache = CacheDir::with_root(cache_root.clone());
let src_dir = tempfile::TempDir::new().unwrap();
let image = src_dir.path().join("bzImage");
std::fs::write(&image, b"fake kernel image A").unwrap();
let meta = KernelMetadata::new(
KernelSource::Tarball,
"x86_64".to_string(),
"bzImage".to_string(),
"2026-04-12T10:00:00Z".to_string(),
)
.with_extra_kconfig_hash(Some(extra_kconfig_hash(extra_a)));
cache
.store(&key_a, &CacheArtifacts::new(&image), &meta)
.unwrap();
let hit_b = cache.lookup(&key_b);
assert!(
hit_b.is_none(),
"lookup with extras=B's key must miss when only extras=A is planted; \
key_a={key_a} key_b={key_b}"
);
assert!(
cache.lookup(&key_a).is_some(),
"planted entry must be reachable via its own key"
);
}
#[test]
fn cache_lookup_bare_and_extras_keys_segregated() {
use crate::cache::{CacheArtifacts, CacheDir, KernelMetadata, KernelSource};
use crate::test_support::test_helpers::{EnvVarGuard, lock_env};
let _env_lock = lock_env();
let _kernel_guard = EnvVarGuard::remove("KTSTR_KERNEL");
let tmp = tempfile::TempDir::new().unwrap();
let cache_root = tmp.path().join("cache");
let _cache_guard = EnvVarGuard::set("KTSTR_CACHE_DIR", &cache_root);
let baked = kconfig_hash();
let extra = "CONFIG_KTSTR_CACHE_SEGREGATE=y\n";
let bare_key = format!("test-seg-{baked}");
let extras_key = format!("test-seg-{baked}-xkc{}", extra_kconfig_hash(extra));
assert_ne!(
bare_key, extras_key,
"bare and extras-suffix keys must be distinct (precondition)"
);
let cache = CacheDir::with_root(cache_root.clone());
let src_dir = tempfile::TempDir::new().unwrap();
let image = src_dir.path().join("bzImage");
std::fs::write(&image, b"bare kernel").unwrap();
let bare_meta = KernelMetadata::new(
KernelSource::Tarball,
"x86_64".to_string(),
"bzImage".to_string(),
"2026-04-12T10:00:00Z".to_string(),
);
assert!(
bare_meta.extra_kconfig_hash.is_none(),
"bare entry fixture must not carry extras hash"
);
cache
.store(&bare_key, &CacheArtifacts::new(&image), &bare_meta)
.unwrap();
assert!(
cache.lookup(&extras_key).is_none(),
"extras lookup must NOT serve the bare entry — operator built with \
--extra-kconfig and would silently get a kernel without their \
user symbols if this regressed"
);
let extras_image = src_dir.path().join("bzImage-extras");
std::fs::write(&extras_image, b"extras kernel").unwrap();
let extras_meta = KernelMetadata::new(
KernelSource::Tarball,
"x86_64".to_string(),
"bzImage".to_string(),
"2026-04-13T10:00:00Z".to_string(),
)
.with_extra_kconfig_hash(Some(extra_kconfig_hash(extra)));
cache
.store(
&extras_key,
&CacheArtifacts::new(&extras_image),
&extras_meta,
)
.unwrap();
let bare_hit = cache.lookup(&bare_key).expect("bare entry");
let extras_hit = cache.lookup(&extras_key).expect("extras entry");
assert!(
bare_hit.metadata.extra_kconfig_hash.is_none(),
"bare entry must report None extras hash"
);
assert!(
extras_hit.metadata.extra_kconfig_hash.is_some(),
"extras entry must report Some(hash)"
);
}
#[test]
fn cache_entry_has_extra_kconfig_reflects_metadata() {
use crate::cache::{CacheArtifacts, CacheDir, KernelMetadata, KernelSource};
use crate::test_support::test_helpers::{EnvVarGuard, lock_env};
let _env_lock = lock_env();
let _kernel_guard = EnvVarGuard::remove("KTSTR_KERNEL");
let tmp = tempfile::TempDir::new().unwrap();
let cache_root = tmp.path().join("cache");
let _cache_guard = EnvVarGuard::set("KTSTR_CACHE_DIR", &cache_root);
let cache = CacheDir::with_root(cache_root.clone());
let src_dir = tempfile::TempDir::new().unwrap();
let image = src_dir.path().join("bzImage");
std::fs::write(&image, b"img").unwrap();
let bare_meta = KernelMetadata::new(
KernelSource::Tarball,
"x86_64".to_string(),
"bzImage".to_string(),
"2026-04-12T10:00:00Z".to_string(),
);
let bare = cache
.store("test-has-bare", &CacheArtifacts::new(&image), &bare_meta)
.unwrap();
assert!(
!bare.has_extra_kconfig(),
"bare entry (extra_kconfig_hash = None) must report has_extra_kconfig() = false"
);
let extras_meta = KernelMetadata::new(
KernelSource::Tarball,
"x86_64".to_string(),
"bzImage".to_string(),
"2026-04-13T10:00:00Z".to_string(),
)
.with_extra_kconfig_hash(Some("deadbeef".to_string()));
let extras = cache
.store(
"test-has-extras",
&CacheArtifacts::new(&image),
&extras_meta,
)
.unwrap();
assert!(
extras.has_extra_kconfig(),
"entry with extra_kconfig_hash = Some(...) must report has_extra_kconfig() = true"
);
}
}