use std::fmt;
use std::fs;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SourceType {
Tarball,
Git,
Local,
}
impl fmt::Display for SourceType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
SourceType::Tarball => f.write_str("tarball"),
SourceType::Git => f.write_str("git"),
SourceType::Local => f.write_str("local"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct KernelMetadata {
#[serde(default)]
pub version: Option<String>,
pub source: SourceType,
pub arch: String,
pub image_name: String,
#[serde(default)]
pub config_hash: Option<String>,
pub built_at: String,
#[serde(default)]
pub ktstr_kconfig_hash: Option<String>,
#[serde(default)]
pub git_hash: Option<String>,
#[serde(default)]
pub git_ref: Option<String>,
#[serde(default)]
pub source_tree_path: Option<PathBuf>,
#[serde(default)]
pub vmlinux_name: Option<String>,
#[serde(default)]
pub ktstr_git_hash: Option<String>,
}
impl KernelMetadata {
pub fn new(source: SourceType, arch: String, image_name: String, built_at: String) -> Self {
KernelMetadata {
version: None,
source,
arch,
image_name,
config_hash: None,
built_at,
ktstr_kconfig_hash: None,
git_hash: None,
git_ref: None,
source_tree_path: None,
vmlinux_name: None,
ktstr_git_hash: None,
}
}
pub fn with_version(mut self, version: Option<String>) -> Self {
self.version = version;
self
}
pub fn with_config_hash(mut self, hash: Option<String>) -> Self {
self.config_hash = hash;
self
}
pub fn with_ktstr_kconfig_hash(mut self, hash: Option<String>) -> Self {
self.ktstr_kconfig_hash = hash;
self
}
pub fn with_ktstr_git_hash(mut self, hash: Option<String>) -> Self {
self.ktstr_git_hash = hash;
self
}
pub fn with_git_hash(mut self, hash: Option<String>) -> Self {
self.git_hash = hash;
self
}
pub fn with_git_ref(mut self, git_ref: Option<String>) -> Self {
self.git_ref = git_ref;
self
}
pub fn with_source_tree_path(mut self, path: Option<std::path::PathBuf>) -> Self {
self.source_tree_path = path;
self
}
}
pub use crate::kernel_path::KernelId;
#[derive(Debug)]
#[non_exhaustive]
pub struct CacheEntry {
pub key: String,
pub path: PathBuf,
pub metadata: Option<KernelMetadata>,
}
impl CacheEntry {
pub fn has_stale_kconfig(&self, current_hash: &str) -> bool {
self.metadata
.as_ref()
.and_then(|m| m.ktstr_kconfig_hash.as_deref())
.is_some_and(|h| h != current_hash)
}
pub fn has_stale_ktstr(&self, current_hash: &str) -> bool {
self.metadata
.as_ref()
.and_then(|m| m.ktstr_git_hash.as_deref())
.is_some_and(|h| h != current_hash)
}
}
#[derive(Debug)]
pub struct CacheDir {
root: PathBuf,
}
impl CacheDir {
pub fn new() -> anyhow::Result<Self> {
let root = resolve_cache_root()?;
fs::create_dir_all(&root)?;
Ok(CacheDir { root })
}
pub fn with_root(root: PathBuf) -> anyhow::Result<Self> {
fs::create_dir_all(&root)?;
Ok(CacheDir { root })
}
pub fn root(&self) -> &Path {
&self.root
}
pub fn lookup(&self, cache_key: &str) -> Option<CacheEntry> {
if let Err(e) = validate_cache_key(cache_key) {
tracing::warn!("invalid cache key: {e}");
return None;
}
let entry_dir = self.root.join(cache_key);
if !entry_dir.is_dir() {
return None;
}
let metadata = read_metadata(&entry_dir);
let image_exists = metadata
.as_ref()
.map(|m| entry_dir.join(&m.image_name).exists())
.unwrap_or(false);
if !image_exists {
return None;
}
Some(CacheEntry {
key: cache_key.to_string(),
path: entry_dir,
metadata,
})
}
pub fn list(&self) -> anyhow::Result<Vec<CacheEntry>> {
let mut entries = Vec::new();
let read_dir = match fs::read_dir(&self.root) {
Ok(rd) => rd,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(entries),
Err(e) => return Err(e.into()),
};
for dir_entry in read_dir {
let dir_entry = dir_entry?;
let path = dir_entry.path();
if !path.is_dir() {
continue;
}
let name = match dir_entry.file_name().into_string() {
Ok(n) => n,
Err(_) => continue,
};
if name.starts_with(".tmp-") {
continue;
}
let metadata = read_metadata(&path);
entries.push(CacheEntry {
key: name,
path,
metadata,
});
}
entries.sort_by(|a, b| {
let a_time = a.metadata.as_ref().map(|m| m.built_at.as_str());
let b_time = b.metadata.as_ref().map(|m| m.built_at.as_str());
b_time.cmp(&a_time)
});
Ok(entries)
}
pub fn store(
&self,
cache_key: &str,
image_path: &Path,
vmlinux_path: Option<&Path>,
config_path: Option<&Path>,
metadata: &KernelMetadata,
) -> anyhow::Result<CacheEntry> {
validate_cache_key(cache_key)?;
validate_filename(&metadata.image_name)?;
let final_dir = self.root.join(cache_key);
let tmp_dir = self
.root
.join(format!(".tmp-{}-{}", cache_key, std::process::id()));
if tmp_dir.exists() {
fs::remove_dir_all(&tmp_dir)?;
}
fs::create_dir_all(&tmp_dir)?;
let guard = TmpDirGuard(&tmp_dir);
let image_dest = tmp_dir.join(&metadata.image_name);
fs::copy(image_path, &image_dest)
.map_err(|e| anyhow::anyhow!("copy kernel image to cache: {e}"))?;
let has_vmlinux = if let Some(vmlinux) = vmlinux_path {
fs::copy(vmlinux, tmp_dir.join("vmlinux"))
.map_err(|e| anyhow::anyhow!("copy vmlinux to cache: {e}"))?;
true
} else {
false
};
if let Some(cfg) = config_path {
fs::copy(cfg, tmp_dir.join(".config"))
.map_err(|e| anyhow::anyhow!("copy .config to cache: {e}"))?;
}
let mut meta = metadata.clone();
meta.vmlinux_name = if has_vmlinux {
Some("vmlinux".to_string())
} else {
None
};
let meta_json = serde_json::to_string_pretty(&meta)?;
fs::write(tmp_dir.join("metadata.json"), meta_json)
.map_err(|e| anyhow::anyhow!("write cache metadata: {e}"))?;
match fs::rename(&tmp_dir, &final_dir) {
Ok(()) => {}
Err(e)
if e.raw_os_error() == Some(libc::ENOTEMPTY)
|| e.raw_os_error() == Some(libc::EEXIST) =>
{
fs::remove_dir_all(&final_dir)?;
fs::rename(&tmp_dir, &final_dir)
.map_err(|e2| anyhow::anyhow!("atomic rename cache entry (retry): {e2}"))?;
}
Err(e) => {
return Err(anyhow::anyhow!("atomic rename cache entry: {e}"));
}
}
guard.disarm();
Ok(CacheEntry {
key: cache_key.to_string(),
path: final_dir,
metadata: Some(meta),
})
}
pub fn clean(&self, keep: Option<usize>) -> anyhow::Result<usize> {
let entries = self.list()?;
let skip = keep.unwrap_or(0);
let to_remove = entries.into_iter().skip(skip).collect::<Vec<_>>();
let count = to_remove.len();
for entry in &to_remove {
fs::remove_dir_all(&entry.path)?;
}
Ok(count)
}
}
fn validate_cache_key(key: &str) -> anyhow::Result<()> {
if key.is_empty() || key.trim().is_empty() {
anyhow::bail!("cache key must not be empty or whitespace-only");
}
if key.contains('/') || key.contains('\\') {
anyhow::bail!("cache key must not contain path separators: {key:?}");
}
if key == "." || key == ".." {
anyhow::bail!("cache key must not be a directory reference: {key:?}");
}
if key.contains("..") {
anyhow::bail!("cache key must not contain path traversal: {key:?}");
}
if key.contains('\0') {
anyhow::bail!("cache key must not contain null bytes");
}
if key.starts_with(".tmp-") {
anyhow::bail!("cache key must not start with .tmp- (reserved): {key:?}");
}
Ok(())
}
fn validate_filename(name: &str) -> anyhow::Result<()> {
if name.is_empty() {
anyhow::bail!("image name must not be empty");
}
if name.contains('/') || name.contains('\\') {
anyhow::bail!("image name must not contain path separators: {name:?}");
}
if name.contains("..") {
anyhow::bail!("image name must not contain path traversal: {name:?}");
}
if name.contains('\0') {
anyhow::bail!("image name must not contain null bytes");
}
Ok(())
}
struct TmpDirGuard<'a>(&'a Path);
impl TmpDirGuard<'_> {
fn disarm(self) {
std::mem::forget(self);
}
}
impl Drop for TmpDirGuard<'_> {
fn drop(&mut self) {
let _ = fs::remove_dir_all(self.0);
}
}
fn read_metadata(dir: &Path) -> Option<KernelMetadata> {
let meta_path = dir.join("metadata.json");
let contents = fs::read_to_string(meta_path).ok()?;
serde_json::from_str(&contents).ok()
}
const VMLINUX_KEEP_SECTIONS: &[&[u8]] = &[
b"", b".BTF", b".BTF.ext", b".symtab", b".strtab", b".shstrtab", ];
pub fn strip_vmlinux_debug(vmlinux_path: &Path) -> anyhow::Result<(tempfile::TempDir, PathBuf)> {
let data =
fs::read(vmlinux_path).map_err(|e| anyhow::anyhow!("read vmlinux for stripping: {e}"))?;
let original_size = data.len();
let out = match strip_keep_list(&data) {
Ok(buf) => buf,
Err(e) => {
tracing::warn!("keep-list strip failed ({e:#}), falling back to debug-only strip");
strip_debug_prefix(&data)?
}
};
let stripped_size = out.len();
let saved_mb = (original_size - stripped_size) as f64 / (1024.0 * 1024.0);
tracing::debug!(
original = original_size,
stripped = stripped_size,
saved_mb = format!("{saved_mb:.0}"),
"strip_vmlinux_debug",
);
let tmp_dir = tempfile::TempDir::new()
.map_err(|e| anyhow::anyhow!("create temp dir for stripped vmlinux: {e}"))?;
let stripped_path = tmp_dir.path().join("vmlinux");
fs::write(&stripped_path, &out).map_err(|e| anyhow::anyhow!("write stripped vmlinux: {e}"))?;
Ok((tmp_dir, stripped_path))
}
fn strip_keep_list(data: &[u8]) -> anyhow::Result<Vec<u8>> {
let mut builder = object::build::elf::Builder::read(data)
.map_err(|e| anyhow::anyhow!("parse vmlinux ELF: {e}"))?;
for section in builder.sections.iter_mut() {
if !VMLINUX_KEEP_SECTIONS.contains(§ion.name.as_slice()) {
section.delete = true;
}
}
for symbol in builder.symbols.iter_mut() {
if let Some(section_id) = symbol.section
&& builder.sections.get(section_id).delete
{
symbol.section = None;
symbol.st_shndx = object::elf::SHN_ABS;
}
}
let mut out = Vec::new();
builder
.write(&mut out)
.map_err(|e| anyhow::anyhow!("rewrite stripped vmlinux: {e}"))?;
let elf =
goblin::elf::Elf::parse(&out).map_err(|e| anyhow::anyhow!("verify stripped ELF: {e}"))?;
let named_syms = elf
.syms
.iter()
.filter(|s| s.st_name != 0 && elf.strtab.get_at(s.st_name).is_some_and(|n| !n.is_empty()))
.count();
if named_syms == 0 {
anyhow::bail!("keep-list strip emptied symbol table (0 named symbols)");
}
Ok(out)
}
fn strip_debug_prefix(data: &[u8]) -> anyhow::Result<Vec<u8>> {
let mut builder = object::build::elf::Builder::read(data)
.map_err(|e| anyhow::anyhow!("parse vmlinux ELF (fallback): {e}"))?;
for section in builder.sections.iter_mut() {
let name = section.name.as_slice();
if name.starts_with(b".debug_") || name == b".comment" {
section.delete = true;
}
}
let mut out = Vec::new();
builder
.write(&mut out)
.map_err(|e| anyhow::anyhow!("rewrite stripped vmlinux (fallback): {e}"))?;
Ok(out)
}
fn resolve_cache_root() -> anyhow::Result<PathBuf> {
if let Ok(dir) = std::env::var("KTSTR_CACHE_DIR")
&& !dir.is_empty()
{
return Ok(PathBuf::from(dir));
}
if let Ok(xdg) = std::env::var("XDG_CACHE_HOME")
&& !xdg.is_empty()
{
return Ok(PathBuf::from(xdg).join("ktstr").join("kernels"));
}
let home = std::env::var("HOME").map_err(|_| {
anyhow::anyhow!(
"HOME not set; cannot resolve cache directory. \
Set KTSTR_CACHE_DIR to specify a cache location."
)
})?;
Ok(PathBuf::from(home)
.join(".cache")
.join("ktstr")
.join("kernels"))
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn test_metadata(version: &str) -> KernelMetadata {
KernelMetadata {
version: Some(version.to_string()),
source: SourceType::Tarball,
arch: "x86_64".to_string(),
image_name: "bzImage".to_string(),
config_hash: Some("abc123".to_string()),
built_at: "2026-04-12T10:00:00Z".to_string(),
ktstr_kconfig_hash: Some("def456".to_string()),
git_hash: None,
git_ref: None,
source_tree_path: None,
vmlinux_name: None,
ktstr_git_hash: None,
}
}
fn create_fake_image(dir: &Path) -> PathBuf {
let image = dir.join("bzImage");
fs::write(&image, b"fake kernel image").unwrap();
image
}
#[test]
fn cache_metadata_serde_roundtrip() {
let meta = test_metadata("6.14.2");
let json = serde_json::to_string_pretty(&meta).unwrap();
let parsed: KernelMetadata = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.version.as_deref(), Some("6.14.2"));
assert_eq!(parsed.source, SourceType::Tarball);
assert_eq!(parsed.arch, "x86_64");
assert_eq!(parsed.image_name, "bzImage");
assert_eq!(parsed.config_hash.as_deref(), Some("abc123"));
assert_eq!(parsed.built_at, "2026-04-12T10:00:00Z");
assert_eq!(parsed.ktstr_kconfig_hash.as_deref(), Some("def456"));
assert!(parsed.git_hash.is_none());
assert!(parsed.git_ref.is_none());
assert!(parsed.source_tree_path.is_none());
}
#[test]
fn cache_metadata_serde_with_optional_fields() {
let meta = KernelMetadata {
version: Some("6.15-rc3".to_string()),
source: SourceType::Git,
arch: "aarch64".to_string(),
image_name: "Image".to_string(),
config_hash: None,
built_at: "2026-04-12T12:00:00Z".to_string(),
ktstr_kconfig_hash: None,
git_hash: Some("a1b2c3d".to_string()),
git_ref: Some("v6.15-rc3".to_string()),
source_tree_path: None,
vmlinux_name: None,
ktstr_git_hash: None,
};
let json = serde_json::to_string(&meta).unwrap();
let parsed: KernelMetadata = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.source, SourceType::Git);
assert_eq!(parsed.git_hash.as_deref(), Some("a1b2c3d"));
assert_eq!(parsed.git_ref.as_deref(), Some("v6.15-rc3"));
}
#[test]
fn cache_metadata_serde_local_with_source_tree() {
let meta = KernelMetadata {
version: Some("6.14.0".to_string()),
source: SourceType::Local,
arch: "x86_64".to_string(),
image_name: "bzImage".to_string(),
config_hash: Some("fff000".to_string()),
built_at: "2026-04-12T14:00:00Z".to_string(),
ktstr_kconfig_hash: Some("aaa111".to_string()),
git_hash: Some("deadbeef".to_string()),
git_ref: None,
source_tree_path: Some(PathBuf::from("/tmp/linux")),
vmlinux_name: None,
ktstr_git_hash: None,
};
let json = serde_json::to_string(&meta).unwrap();
let parsed: KernelMetadata = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.source, SourceType::Local);
assert_eq!(parsed.source_tree_path, Some(PathBuf::from("/tmp/linux")));
}
#[test]
fn cache_metadata_deserialize_missing_optional_fields() {
let json = r#"{
"version": "6.14.2",
"source": "tarball",
"arch": "x86_64",
"image_name": "bzImage",
"config_hash": null,
"built_at": "2026-04-12T10:00:00Z",
"ktstr_kconfig_hash": null,
"git_hash": null,
"git_ref": null,
"source_tree_path": null
}"#;
let parsed: KernelMetadata = serde_json::from_str(json).unwrap();
assert_eq!(parsed.version.as_deref(), Some("6.14.2"));
assert!(parsed.config_hash.is_none());
}
#[test]
fn cache_metadata_deserialize_null_version() {
let json = r#"{
"version": null,
"source": "local",
"arch": "x86_64",
"image_name": "bzImage",
"config_hash": null,
"built_at": "2026-04-12T10:00:00Z",
"ktstr_kconfig_hash": null,
"git_hash": null,
"git_ref": null,
"source_tree_path": null
}"#;
let parsed: KernelMetadata = serde_json::from_str(json).unwrap();
assert!(parsed.version.is_none());
assert_eq!(parsed.source, SourceType::Local);
}
#[test]
fn cache_metadata_deserialize_absent_optional_keys() {
let json = r#"{
"source": "tarball",
"arch": "x86_64",
"image_name": "bzImage",
"built_at": "2026-04-12T10:00:00Z"
}"#;
let parsed: KernelMetadata = serde_json::from_str(json).unwrap();
assert!(parsed.version.is_none());
assert!(parsed.config_hash.is_none());
assert!(parsed.ktstr_kconfig_hash.is_none());
assert!(parsed.git_hash.is_none());
assert!(parsed.git_ref.is_none());
assert!(parsed.source_tree_path.is_none());
assert_eq!(parsed.source, SourceType::Tarball);
assert_eq!(parsed.arch, "x86_64");
}
#[test]
fn cache_metadata_source_type_serde() {
let tarball = serde_json::to_string(&SourceType::Tarball).unwrap();
assert_eq!(tarball, "\"tarball\"");
let git = serde_json::to_string(&SourceType::Git).unwrap();
assert_eq!(git, "\"git\"");
let local = serde_json::to_string(&SourceType::Local).unwrap();
assert_eq!(local, "\"local\"");
}
#[test]
fn cache_dir_with_root_creates_dir() {
let tmp = TempDir::new().unwrap();
let root = tmp.path().join("kernels");
assert!(!root.exists());
let cache = CacheDir::with_root(root.clone()).unwrap();
assert!(root.exists());
assert_eq!(cache.root(), root);
}
#[test]
fn cache_dir_list_empty() {
let tmp = TempDir::new().unwrap();
let cache = CacheDir::with_root(tmp.path().to_path_buf()).unwrap();
let entries = cache.list().unwrap();
assert!(entries.is_empty());
}
#[test]
fn cache_dir_store_and_lookup() {
let tmp = TempDir::new().unwrap();
let cache = CacheDir::with_root(tmp.path().join("cache")).unwrap();
let src_dir = TempDir::new().unwrap();
let image = create_fake_image(src_dir.path());
let meta = test_metadata("6.14.2");
let entry = cache
.store("6.14.2-tarball-x86_64", &image, None, None, &meta)
.unwrap();
assert_eq!(entry.key, "6.14.2-tarball-x86_64");
assert!(entry.path.join("bzImage").exists());
assert!(entry.path.join("metadata.json").exists());
let found = cache.lookup("6.14.2-tarball-x86_64");
assert!(found.is_some());
let found = found.unwrap();
assert_eq!(found.key, "6.14.2-tarball-x86_64");
assert!(found.metadata.is_some());
let found_meta = found.metadata.unwrap();
assert_eq!(found_meta.version.as_deref(), Some("6.14.2"));
}
#[test]
fn cache_dir_lookup_missing() {
let tmp = TempDir::new().unwrap();
let cache = CacheDir::with_root(tmp.path().to_path_buf()).unwrap();
assert!(cache.lookup("nonexistent").is_none());
}
#[test]
fn cache_dir_lookup_corrupt_metadata() {
let tmp = TempDir::new().unwrap();
let cache = CacheDir::with_root(tmp.path().to_path_buf()).unwrap();
let entry_dir = tmp.path().join("bad-entry");
fs::create_dir_all(&entry_dir).unwrap();
fs::write(entry_dir.join("bzImage"), b"fake").unwrap();
fs::write(entry_dir.join("metadata.json"), b"not json").unwrap();
let found = cache.lookup("bad-entry");
assert!(found.is_none());
}
#[test]
fn cache_dir_lookup_missing_image() {
let tmp = TempDir::new().unwrap();
let cache = CacheDir::with_root(tmp.path().to_path_buf()).unwrap();
let entry_dir = tmp.path().join("no-image");
fs::create_dir_all(&entry_dir).unwrap();
let meta = test_metadata("6.14.2");
let json = serde_json::to_string(&meta).unwrap();
fs::write(entry_dir.join("metadata.json"), json).unwrap();
let found = cache.lookup("no-image");
assert!(found.is_none());
}
#[test]
fn cache_dir_store_overwrites_existing() {
let tmp = TempDir::new().unwrap();
let cache = CacheDir::with_root(tmp.path().join("cache")).unwrap();
let src_dir = TempDir::new().unwrap();
let image = create_fake_image(src_dir.path());
let meta1 = KernelMetadata {
built_at: "2026-04-12T10:00:00Z".to_string(),
..test_metadata("6.14.2")
};
cache
.store("6.14.2-tarball-x86_64", &image, None, None, &meta1)
.unwrap();
let meta2 = KernelMetadata {
built_at: "2026-04-12T11:00:00Z".to_string(),
..test_metadata("6.14.2")
};
cache
.store("6.14.2-tarball-x86_64", &image, None, None, &meta2)
.unwrap();
let found = cache.lookup("6.14.2-tarball-x86_64").unwrap();
let found_meta = found.metadata.unwrap();
assert_eq!(found_meta.built_at, "2026-04-12T11:00:00Z");
}
#[test]
fn cache_dir_list_sorted_newest_first() {
let tmp = TempDir::new().unwrap();
let cache = CacheDir::with_root(tmp.path().join("cache")).unwrap();
let src_dir = TempDir::new().unwrap();
let image = create_fake_image(src_dir.path());
let meta_old = KernelMetadata {
built_at: "2026-04-10T10:00:00Z".to_string(),
..test_metadata("6.13.0")
};
let meta_new = KernelMetadata {
built_at: "2026-04-12T10:00:00Z".to_string(),
..test_metadata("6.14.2")
};
let meta_mid = KernelMetadata {
built_at: "2026-04-11T10:00:00Z".to_string(),
..test_metadata("6.14.0")
};
cache.store("old", &image, None, None, &meta_old).unwrap();
cache.store("new", &image, None, None, &meta_new).unwrap();
cache.store("mid", &image, None, None, &meta_mid).unwrap();
let entries = cache.list().unwrap();
assert_eq!(entries.len(), 3);
assert_eq!(entries[0].key, "new");
assert_eq!(entries[1].key, "mid");
assert_eq!(entries[2].key, "old");
}
#[test]
fn cache_dir_list_includes_corrupt_entries() {
let tmp = TempDir::new().unwrap();
let cache = CacheDir::with_root(tmp.path().to_path_buf()).unwrap();
let src_dir = TempDir::new().unwrap();
let image = create_fake_image(src_dir.path());
let meta = test_metadata("6.14.2");
cache.store("valid", &image, None, None, &meta).unwrap();
let bad_dir = tmp.path().join("corrupt");
fs::create_dir_all(&bad_dir).unwrap();
let entries = cache.list().unwrap();
assert_eq!(entries.len(), 2);
let valid = entries.iter().find(|e| e.key == "valid").unwrap();
assert!(valid.metadata.is_some());
let corrupt = entries.iter().find(|e| e.key == "corrupt").unwrap();
assert!(corrupt.metadata.is_none());
}
#[test]
fn cache_dir_list_skips_tmp_dirs() {
let tmp = TempDir::new().unwrap();
let cache = CacheDir::with_root(tmp.path().to_path_buf()).unwrap();
let tmp_dir = tmp.path().join(".tmp-in-progress-12345");
fs::create_dir_all(&tmp_dir).unwrap();
let entries = cache.list().unwrap();
assert!(entries.is_empty());
}
#[test]
fn cache_dir_list_skips_regular_files() {
let tmp = TempDir::new().unwrap();
let cache = CacheDir::with_root(tmp.path().to_path_buf()).unwrap();
fs::write(tmp.path().join("stray-file.txt"), b"stray").unwrap();
let entries = cache.list().unwrap();
assert!(entries.is_empty());
}
#[test]
fn cache_dir_clean_all() {
let tmp = TempDir::new().unwrap();
let cache = CacheDir::with_root(tmp.path().join("cache")).unwrap();
let src_dir = TempDir::new().unwrap();
let image = create_fake_image(src_dir.path());
cache
.store("a", &image, None, None, &test_metadata("6.14.0"))
.unwrap();
cache
.store("b", &image, None, None, &test_metadata("6.14.1"))
.unwrap();
cache
.store("c", &image, None, None, &test_metadata("6.14.2"))
.unwrap();
let removed = cache.clean(None).unwrap();
assert_eq!(removed, 3);
assert!(cache.list().unwrap().is_empty());
}
#[test]
fn cache_dir_clean_keep_n() {
let tmp = TempDir::new().unwrap();
let cache = CacheDir::with_root(tmp.path().join("cache")).unwrap();
let src_dir = TempDir::new().unwrap();
let image = create_fake_image(src_dir.path());
let meta_old = KernelMetadata {
built_at: "2026-04-10T10:00:00Z".to_string(),
..test_metadata("6.13.0")
};
let meta_new = KernelMetadata {
built_at: "2026-04-12T10:00:00Z".to_string(),
..test_metadata("6.14.2")
};
let meta_mid = KernelMetadata {
built_at: "2026-04-11T10:00:00Z".to_string(),
..test_metadata("6.14.0")
};
cache.store("old", &image, None, None, &meta_old).unwrap();
cache.store("new", &image, None, None, &meta_new).unwrap();
cache.store("mid", &image, None, None, &meta_mid).unwrap();
let removed = cache.clean(Some(1)).unwrap();
assert_eq!(removed, 2);
let remaining = cache.list().unwrap();
assert_eq!(remaining.len(), 1);
assert_eq!(remaining[0].key, "new");
}
#[test]
fn cache_dir_clean_keep_more_than_exist() {
let tmp = TempDir::new().unwrap();
let cache = CacheDir::with_root(tmp.path().join("cache")).unwrap();
let src_dir = TempDir::new().unwrap();
let image = create_fake_image(src_dir.path());
cache
.store("only", &image, None, None, &test_metadata("6.14.2"))
.unwrap();
let removed = cache.clean(Some(5)).unwrap();
assert_eq!(removed, 0);
assert_eq!(cache.list().unwrap().len(), 1);
}
#[test]
fn cache_dir_clean_empty_cache() {
let tmp = TempDir::new().unwrap();
let cache = CacheDir::with_root(tmp.path().to_path_buf()).unwrap();
let removed = cache.clean(None).unwrap();
assert_eq!(removed, 0);
}
#[test]
fn cache_resolve_root_ktstr_cache_dir() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("custom-cache");
let _guard = EnvVarGuard::set("KTSTR_CACHE_DIR", dir.to_str().unwrap());
let root = resolve_cache_root().unwrap();
assert_eq!(root, dir);
}
#[test]
fn cache_resolve_root_xdg_cache_home() {
let tmp = TempDir::new().unwrap();
let _guard1 = EnvVarGuard::remove("KTSTR_CACHE_DIR");
let _guard2 = EnvVarGuard::set("XDG_CACHE_HOME", tmp.path().to_str().unwrap());
let root = resolve_cache_root().unwrap();
assert_eq!(root, tmp.path().join("ktstr").join("kernels"));
}
#[test]
fn cache_resolve_root_empty_ktstr_cache_dir_falls_through() {
let tmp = TempDir::new().unwrap();
let _guard1 = EnvVarGuard::set("KTSTR_CACHE_DIR", "");
let _guard2 = EnvVarGuard::set("XDG_CACHE_HOME", tmp.path().to_str().unwrap());
let root = resolve_cache_root().unwrap();
assert_eq!(root, tmp.path().join("ktstr").join("kernels"));
}
#[test]
fn cache_resolve_root_empty_xdg_falls_to_home() {
let tmp = TempDir::new().unwrap();
let _guard1 = EnvVarGuard::remove("KTSTR_CACHE_DIR");
let _guard2 = EnvVarGuard::set("XDG_CACHE_HOME", "");
let _guard3 = EnvVarGuard::set("HOME", tmp.path().to_str().unwrap());
let root = resolve_cache_root().unwrap();
assert_eq!(
root,
tmp.path().join(".cache").join("ktstr").join("kernels")
);
}
#[test]
fn cache_resolve_root_home_unset_error() {
let _guard1 = EnvVarGuard::remove("KTSTR_CACHE_DIR");
let _guard2 = EnvVarGuard::remove("XDG_CACHE_HOME");
let _guard3 = EnvVarGuard::remove("HOME");
let err = resolve_cache_root().unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("HOME not set"),
"expected HOME-unset error, got: {msg}"
);
assert!(
msg.contains("KTSTR_CACHE_DIR"),
"error should suggest KTSTR_CACHE_DIR, got: {msg}"
);
}
#[test]
fn cache_validate_key_rejects_empty() {
let err = validate_cache_key("").unwrap_err();
assert!(err.to_string().contains("empty"));
}
#[test]
fn cache_validate_key_rejects_whitespace_only() {
let err = validate_cache_key(" ").unwrap_err();
assert!(err.to_string().contains("empty"));
}
#[test]
fn cache_validate_key_rejects_forward_slash() {
let err = validate_cache_key("a/b").unwrap_err();
assert!(err.to_string().contains("path separator"));
}
#[test]
fn cache_validate_key_rejects_backslash() {
let err = validate_cache_key("a\\b").unwrap_err();
assert!(err.to_string().contains("path separator"));
}
#[test]
fn cache_validate_key_rejects_dotdot() {
let err = validate_cache_key("foo..bar").unwrap_err();
assert!(err.to_string().contains("path traversal"));
}
#[test]
fn cache_validate_key_rejects_null_byte() {
let err = validate_cache_key("key\0evil").unwrap_err();
assert!(err.to_string().contains("null"));
}
#[test]
fn cache_validate_key_rejects_tmp_prefix() {
let err = validate_cache_key(".tmp-in-progress").unwrap_err();
assert!(
err.to_string().contains(".tmp-"),
"expected .tmp- rejection, got: {err}"
);
}
#[test]
fn cache_validate_key_rejects_dot() {
let err = validate_cache_key(".").unwrap_err();
assert!(
err.to_string().contains("directory reference"),
"expected dot rejection, got: {err}"
);
}
#[test]
fn cache_validate_key_rejects_dotdot_bare() {
let err = validate_cache_key("..").unwrap_err();
assert!(
err.to_string().contains("directory reference"),
"expected dotdot rejection, got: {err}"
);
}
#[test]
fn cache_validate_key_accepts_valid() {
assert!(validate_cache_key("6.14.2-tarball-x86_64").is_ok());
assert!(validate_cache_key("local-deadbeef-x86_64").is_ok());
assert!(validate_cache_key("v6.14-git-a1b2c3d-aarch64").is_ok());
}
#[test]
fn cache_validate_filename_rejects_traversal() {
assert!(validate_filename("../etc/passwd").is_err());
assert!(validate_filename("foo/../bar").is_err());
}
#[test]
fn cache_validate_filename_rejects_empty() {
assert!(validate_filename("").is_err());
}
#[test]
fn cache_validate_filename_accepts_valid() {
assert!(validate_filename("bzImage").is_ok());
assert!(validate_filename("Image").is_ok());
}
#[test]
fn cache_dir_store_rejects_image_name_traversal() {
let tmp = TempDir::new().unwrap();
let cache = CacheDir::with_root(tmp.path().join("cache")).unwrap();
let src_dir = TempDir::new().unwrap();
let image = create_fake_image(src_dir.path());
let mut meta = test_metadata("6.14.2");
meta.image_name = "../escape".to_string();
let err = cache
.store("valid-key", &image, None, None, &meta)
.unwrap_err();
assert!(
err.to_string().contains("image name"),
"expected image_name rejection, got: {err}"
);
}
#[test]
fn cache_dir_store_tmp_prefix_key_rejected() {
let tmp = TempDir::new().unwrap();
let cache = CacheDir::with_root(tmp.path().join("cache")).unwrap();
let src_dir = TempDir::new().unwrap();
let image = create_fake_image(src_dir.path());
let meta = test_metadata("6.14.2");
let err = cache
.store(".tmp-sneaky", &image, None, None, &meta)
.unwrap_err();
assert!(
err.to_string().contains(".tmp-"),
"expected .tmp- rejection, got: {err}"
);
}
#[test]
fn cache_dir_lookup_tmp_prefix_returns_none() {
let tmp = TempDir::new().unwrap();
let cache = CacheDir::with_root(tmp.path().to_path_buf()).unwrap();
assert!(cache.lookup(".tmp-sneaky").is_none());
}
#[test]
fn cache_dir_store_empty_key_rejected() {
let tmp = TempDir::new().unwrap();
let cache = CacheDir::with_root(tmp.path().join("cache")).unwrap();
let src_dir = TempDir::new().unwrap();
let image = create_fake_image(src_dir.path());
let meta = test_metadata("6.14.2");
let err = cache.store("", &image, None, None, &meta).unwrap_err();
assert!(
err.to_string().contains("empty"),
"expected empty-key error, got: {err}"
);
}
#[test]
fn cache_dir_lookup_empty_key_returns_none() {
let tmp = TempDir::new().unwrap();
let cache = CacheDir::with_root(tmp.path().to_path_buf()).unwrap();
assert!(cache.lookup("").is_none());
}
#[test]
fn cache_dir_store_path_traversal_rejected() {
let tmp = TempDir::new().unwrap();
let cache = CacheDir::with_root(tmp.path().join("cache")).unwrap();
let src_dir = TempDir::new().unwrap();
let image = create_fake_image(src_dir.path());
let meta = test_metadata("6.14.2");
let err = cache
.store("../escape", &image, None, None, &meta)
.unwrap_err();
assert!(
err.to_string().contains("path"),
"expected path-traversal error, got: {err}"
);
}
#[test]
fn cache_dir_lookup_path_traversal_returns_none() {
let tmp = TempDir::new().unwrap();
let cache = CacheDir::with_root(tmp.path().to_path_buf()).unwrap();
assert!(cache.lookup("../escape").is_none());
assert!(cache.lookup("foo/../bar").is_none());
}
#[test]
fn cache_dir_store_slash_in_key_rejected() {
let tmp = TempDir::new().unwrap();
let cache = CacheDir::with_root(tmp.path().join("cache")).unwrap();
let src_dir = TempDir::new().unwrap();
let image = create_fake_image(src_dir.path());
let meta = test_metadata("6.14.2");
let err = cache.store("a/b", &image, None, None, &meta).unwrap_err();
assert!(
err.to_string().contains("path separator"),
"expected path-separator error, got: {err}"
);
}
#[test]
fn cache_dir_store_whitespace_only_key_rejected() {
let tmp = TempDir::new().unwrap();
let cache = CacheDir::with_root(tmp.path().join("cache")).unwrap();
let src_dir = TempDir::new().unwrap();
let image = create_fake_image(src_dir.path());
let meta = test_metadata("6.14.2");
let err = cache.store(" ", &image, None, None, &meta).unwrap_err();
assert!(
err.to_string().contains("empty"),
"expected empty/whitespace error, got: {err}"
);
}
#[test]
fn cache_dir_clean_keep_n_with_mixed_entries() {
let tmp = TempDir::new().unwrap();
let cache = CacheDir::with_root(tmp.path().join("cache")).unwrap();
let src_dir = TempDir::new().unwrap();
let image = create_fake_image(src_dir.path());
let meta_new = KernelMetadata {
built_at: "2026-04-12T10:00:00Z".to_string(),
..test_metadata("6.14.2")
};
let meta_old = KernelMetadata {
built_at: "2026-04-10T10:00:00Z".to_string(),
..test_metadata("6.13.0")
};
cache.store("new", &image, None, None, &meta_new).unwrap();
cache.store("old", &image, None, None, &meta_old).unwrap();
let corrupt_dir = tmp.path().join("cache").join("corrupt");
fs::create_dir_all(&corrupt_dir).unwrap();
let removed = cache.clean(Some(1)).unwrap();
assert_eq!(removed, 2);
let remaining = cache.list().unwrap();
assert_eq!(remaining.len(), 1);
assert_eq!(remaining[0].key, "new");
}
#[test]
fn cache_dir_store_cleans_stale_tmp() {
let tmp = TempDir::new().unwrap();
let cache_root = tmp.path().join("cache");
let cache = CacheDir::with_root(cache_root.clone()).unwrap();
let stale_tmp = cache_root.join(format!(".tmp-mykey-{}", std::process::id()));
fs::create_dir_all(&stale_tmp).unwrap();
fs::write(stale_tmp.join("junk"), b"leftover").unwrap();
let src_dir = TempDir::new().unwrap();
let image = create_fake_image(src_dir.path());
let meta = test_metadata("6.14.2");
let entry = cache.store("mykey", &image, None, None, &meta).unwrap();
assert!(entry.path.join("bzImage").exists());
assert!(!stale_tmp.exists());
}
#[test]
fn cache_dir_store_with_vmlinux() {
let tmp = TempDir::new().unwrap();
let cache = CacheDir::with_root(tmp.path().join("cache")).unwrap();
let src_dir = TempDir::new().unwrap();
let image = create_fake_image(src_dir.path());
let vmlinux = src_dir.path().join("vmlinux");
fs::write(&vmlinux, b"fake vmlinux ELF").unwrap();
let meta = test_metadata("6.14.2");
let entry = cache
.store("with-vmlinux", &image, Some(&vmlinux), None, &meta)
.unwrap();
assert!(entry.path.join("bzImage").exists());
assert!(entry.path.join("vmlinux").exists());
assert!(entry.path.join("metadata.json").exists());
let entry_meta = entry.metadata.unwrap();
assert_eq!(entry_meta.vmlinux_name.as_deref(), Some("vmlinux"));
assert!(image.exists());
assert!(vmlinux.exists());
}
#[test]
fn cache_dir_store_without_vmlinux() {
let tmp = TempDir::new().unwrap();
let cache = CacheDir::with_root(tmp.path().join("cache")).unwrap();
let src_dir = TempDir::new().unwrap();
let image = create_fake_image(src_dir.path());
let meta = test_metadata("6.14.2");
let entry = cache
.store("no-vmlinux", &image, None, None, &meta)
.unwrap();
assert!(entry.path.join("bzImage").exists());
assert!(!entry.path.join("vmlinux").exists());
assert!(entry.path.join("metadata.json").exists());
let entry_meta = entry.metadata.unwrap();
assert!(entry_meta.vmlinux_name.is_none());
}
#[test]
fn cache_dir_store_with_config() {
let tmp = TempDir::new().unwrap();
let cache = CacheDir::with_root(tmp.path().join("cache")).unwrap();
let src_dir = TempDir::new().unwrap();
let image = create_fake_image(src_dir.path());
let config = src_dir.path().join(".config");
let config_content = b"CONFIG_HZ=1000\nCONFIG_SCHED_CLASS_EXT=y\n";
fs::write(&config, config_content).unwrap();
let meta = test_metadata("6.14.2");
let entry = cache
.store("with-config", &image, None, Some(&config), &meta)
.unwrap();
assert!(entry.path.join("bzImage").exists());
assert!(entry.path.join(".config").exists());
assert!(entry.path.join("metadata.json").exists());
let cached = fs::read(entry.path.join(".config")).unwrap();
assert_eq!(cached, config_content);
assert!(config.exists());
}
#[test]
fn cache_dir_store_without_config() {
let tmp = TempDir::new().unwrap();
let cache = CacheDir::with_root(tmp.path().join("cache")).unwrap();
let src_dir = TempDir::new().unwrap();
let image = create_fake_image(src_dir.path());
let meta = test_metadata("6.14.2");
let entry = cache.store("no-config", &image, None, None, &meta).unwrap();
assert!(entry.path.join("bzImage").exists());
assert!(!entry.path.join(".config").exists());
}
#[test]
fn cache_dir_store_preserves_original_image() {
let tmp = TempDir::new().unwrap();
let cache = CacheDir::with_root(tmp.path().join("cache")).unwrap();
let src_dir = TempDir::new().unwrap();
let image = create_fake_image(src_dir.path());
let meta = test_metadata("6.14.2");
cache.store("key", &image, None, None, &meta).unwrap();
assert!(image.exists());
}
fn create_elf_with_debug(dir: &Path) -> PathBuf {
use object::write;
let mut obj = write::Object::new(
object::BinaryFormat::Elf,
object::Architecture::X86_64,
object::Endianness::Little,
);
let text_id = obj.add_section(Vec::new(), b".text".to_vec(), object::SectionKind::Text);
obj.append_section_data(text_id, &[0xCC; 64], 1);
let sym_id = obj.add_symbol(write::Symbol {
name: b"test_symbol".to_vec(),
value: 0x1000,
size: 8,
kind: object::SymbolKind::Data,
scope: object::SymbolScope::Compilation,
weak: false,
section: write::SymbolSection::Section(text_id),
flags: object::SymbolFlags::None,
});
let _ = sym_id;
let btf_id = obj.add_section(Vec::new(), b".BTF".to_vec(), object::SectionKind::Metadata);
obj.append_section_data(btf_id, &[0xEB; 256], 1);
let debug_id = obj.add_section(
Vec::new(),
b".debug_info".to_vec(),
object::SectionKind::Debug,
);
obj.append_section_data(debug_id, &[0xAA; 4096], 1);
let debug_str_id = obj.add_section(
Vec::new(),
b".debug_str".to_vec(),
object::SectionKind::Debug,
);
obj.append_section_data(debug_str_id, &[0xBB; 2048], 1);
let data = obj.write().unwrap();
let path = dir.join("vmlinux");
fs::write(&path, &data).unwrap();
path
}
#[test]
fn strip_vmlinux_debug_removes_debug_keeps_btf_symtab() {
let src = TempDir::new().unwrap();
let vmlinux = create_elf_with_debug(src.path());
let original_size = fs::metadata(&vmlinux).unwrap().len();
let (_dir, stripped_path) = strip_vmlinux_debug(&vmlinux).unwrap();
let stripped_size = fs::metadata(&stripped_path).unwrap().len();
assert!(
stripped_size < original_size,
"stripped ({stripped_size}) should be smaller than original ({original_size})"
);
let data = fs::read(&stripped_path).unwrap();
let elf = goblin::elf::Elf::parse(&data).unwrap();
let section_names: Vec<&str> = elf
.section_headers
.iter()
.filter_map(|s| elf.shdr_strtab.get_at(s.sh_name))
.collect();
assert!(
!section_names.contains(&".debug_info"),
"should not contain .debug_info"
);
assert!(
!section_names.contains(&".debug_str"),
"should not contain .debug_str"
);
assert!(section_names.contains(&".BTF"), "should preserve .BTF");
assert!(
section_names.contains(&".symtab"),
"should preserve .symtab"
);
assert!(
section_names.contains(&".strtab"),
"should preserve .strtab"
);
}
#[test]
fn strip_vmlinux_debug_symtab_readable() {
let src = TempDir::new().unwrap();
let vmlinux = create_elf_with_debug(src.path());
let (_dir, stripped_path) = strip_vmlinux_debug(&vmlinux).unwrap();
let data = fs::read(&stripped_path).unwrap();
let elf = goblin::elf::Elf::parse(&data).unwrap();
let found = elf
.syms
.iter()
.any(|s| elf.strtab.get_at(s.st_name) == Some("test_symbol"));
assert!(found, "stripped ELF should contain test_symbol in symtab");
}
#[test]
fn strip_vmlinux_debug_nonexistent_file() {
let result = strip_vmlinux_debug(Path::new("/nonexistent/vmlinux"));
assert!(result.is_err());
}
#[test]
fn strip_vmlinux_debug_non_elf_file() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("vmlinux");
fs::write(&path, b"not an ELF file").unwrap();
let result = strip_vmlinux_debug(&path);
assert!(result.is_err());
}
struct EnvVarGuard {
key: String,
original: Option<String>,
}
impl EnvVarGuard {
fn set(key: &str, value: &str) -> Self {
let original = std::env::var(key).ok();
unsafe { std::env::set_var(key, value) };
EnvVarGuard {
key: key.to_string(),
original,
}
}
fn remove(key: &str) -> Self {
let original = std::env::var(key).ok();
unsafe { std::env::remove_var(key) };
EnvVarGuard {
key: key.to_string(),
original,
}
}
}
impl Drop for EnvVarGuard {
fn drop(&mut self) {
match &self.original {
Some(val) => unsafe { std::env::set_var(&self.key, val) },
None => unsafe { std::env::remove_var(&self.key) },
}
}
}
}