#[derive(Debug, Clone, PartialEq, Eq)]
pub enum KernelId {
Path(std::path::PathBuf),
Version(String),
CacheKey(String),
Range {
start: String,
end: String,
},
Git {
url: String,
git_ref: String,
},
}
impl KernelId {
pub fn parse(s: &str) -> Self {
if let Some(rest) = s.strip_prefix("git+")
&& let Some((url, git_ref)) = rest.rsplit_once('#')
&& !url.is_empty()
&& !git_ref.is_empty()
{
return KernelId::Git {
url: url.to_string(),
git_ref: git_ref.to_string(),
};
}
if let Some((start, end)) = s.split_once("..")
&& _is_version_string(start)
&& _is_version_string(end)
{
return KernelId::Range {
start: start.to_string(),
end: end.to_string(),
};
}
if s.contains('/') || s.starts_with('.') || s.starts_with('~') {
return KernelId::Path(expand_tilde(s));
}
if _is_version_string(s) {
return KernelId::Version(s.to_string());
}
KernelId::CacheKey(s.to_string())
}
pub fn parse_list(s: &str) -> Vec<KernelId> {
s.split(',')
.map(str::trim)
.filter(|seg| !seg.is_empty())
.map(KernelId::parse)
.collect()
}
pub fn validate(&self) -> Result<(), String> {
match self {
KernelId::Range { start, end } => {
let start_key = decompose_version_for_compare(start).ok_or_else(|| {
format!(
"kernel range start `{start}` is not a parseable version \
(version components must fit u64). Direct callers that \
construct `KernelId::Range` outside `KernelId::parse` are \
responsible for endpoint validity; the parser admits a \
strict subset.",
)
})?;
let end_key = decompose_version_for_compare(end).ok_or_else(|| {
format!(
"kernel range end `{end}` is not a parseable version \
(version components must fit u64). Direct callers that \
construct `KernelId::Range` outside `KernelId::parse` are \
responsible for endpoint validity; the parser admits a \
strict subset.",
)
})?;
if start_key > end_key {
return Err(format!(
"inverted kernel range `{start}..{end}`: start version is greater \
than end version. Swap the endpoints (`{end}..{start}`) or omit \
the range to pass a single version.",
));
}
Ok(())
}
KernelId::Path(_)
| KernelId::Version(_)
| KernelId::CacheKey(_)
| KernelId::Git { .. } => Ok(()),
}
}
}
impl std::fmt::Display for KernelId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
KernelId::Path(p) => write!(f, "{}", p.display()),
KernelId::Version(v) => write!(f, "{v}"),
KernelId::CacheKey(k) => write!(f, "{k}"),
KernelId::Range { start, end } => write!(f, "{start}..{end}"),
KernelId::Git { url, git_ref } => write!(f, "git+{url}#{git_ref}"),
}
}
}
fn _is_version_string(s: &str) -> bool {
let (version_part, rc_part) = match s.split_once("-rc") {
Some((v, rc)) => (v, Some(rc)),
None => (s, None),
};
if let Some(rc) = rc_part
&& (rc.is_empty() || !rc.bytes().all(|b| b.is_ascii_digit()))
{
return false;
}
let mut parts = version_part.split('.');
match parts.next() {
Some(p) if !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit()) => {}
_ => return false,
}
match parts.next() {
Some(p) if !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit()) => {}
_ => return false,
}
if let Some(patch) = parts.next()
&& (patch.is_empty() || !patch.bytes().all(|b| b.is_ascii_digit()))
{
return false;
}
parts.next().is_none()
}
pub(crate) fn decompose_version_for_compare(s: &str) -> Option<(u64, u64, u64, u64)> {
let (version_part, rc_part) = match s.split_once("-rc") {
Some((v, rc)) => (v, Some(rc)),
None => (s, None),
};
let rc: u64 = match rc_part {
Some(rc) if rc.is_empty() || !rc.bytes().all(|b| b.is_ascii_digit()) => return None,
Some(rc) => rc.parse().ok()?,
None => u64::MAX,
};
let mut parts = version_part.split('.');
let major: u64 = parts.next()?.parse().ok()?;
let minor: u64 = parts.next()?.parse().ok()?;
let patch: u64 = match parts.next() {
Some("") => return None,
Some(p) => p.parse().ok()?,
None => 0,
};
if parts.next().is_some() {
return None;
}
Some((major, minor, patch, rc))
}
fn expand_tilde(s: &str) -> std::path::PathBuf {
if s != "~" && !s.starts_with("~/") {
return std::path::PathBuf::from(s);
}
let home = match std::env::var("HOME") {
Ok(h) if !h.is_empty() => h,
_ => return std::path::PathBuf::from(s),
};
if s == "~" {
return std::path::PathBuf::from(home);
}
let mut rest = &s[2..]; while let Some(stripped) = rest.strip_prefix('/') {
rest = stripped;
}
let mut p = std::path::PathBuf::from(home);
if !rest.is_empty() {
p.push(rest);
}
p
}
fn kernel_release_from_procfs() -> Option<String> {
std::fs::read_to_string("/proc/sys/kernel/osrelease")
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
#[allow(dead_code)]
pub fn resolve_kernel(kernel_dir: Option<&str>) -> Option<std::path::PathBuf> {
if let Some(dir) = kernel_dir {
let p = std::path::PathBuf::from(dir);
if p.is_dir() {
return Some(p);
}
}
for rel in &["./linux", "../linux"] {
let p = std::path::PathBuf::from(rel);
if p.is_dir() && has_kernel_artifacts(&p) {
return Some(p);
}
}
if let Some(rel) = kernel_release_from_procfs() {
let p = std::path::PathBuf::from(format!("/lib/modules/{rel}/build"));
if p.is_dir() {
return Some(p);
}
}
None
}
#[allow(dead_code)]
pub fn derive_kernel_dir(image: &std::path::Path) -> Option<std::path::PathBuf> {
let canon = std::fs::canonicalize(image).ok()?;
#[cfg(target_arch = "x86_64")]
let build_suffix = "/arch/x86/boot/bzImage";
#[cfg(target_arch = "aarch64")]
let build_suffix = "/arch/arm64/boot/Image";
if let Some(canon_str) = canon.to_str()
&& let Some(root) = canon_str.strip_suffix(build_suffix)
{
return Some(std::path::PathBuf::from(root));
}
let parent = canon.parent()?;
if parent.join("vmlinux").is_file() {
return Some(parent.to_path_buf());
}
None
}
#[allow(dead_code)]
pub fn find_image_in_dir(dir: &std::path::Path) -> Option<std::path::PathBuf> {
#[cfg(target_arch = "x86_64")]
{
let p = dir.join("arch/x86/boot/bzImage");
if p.exists() {
return Some(p);
}
}
#[cfg(target_arch = "aarch64")]
{
let p = dir.join("arch/arm64/boot/Image");
if p.exists() {
return Some(p);
}
}
#[cfg(target_arch = "x86_64")]
{
let p = dir.join("bzImage");
if p.exists() {
return Some(p);
}
}
#[cfg(target_arch = "aarch64")]
{
let p = dir.join("Image");
if p.exists() {
return Some(p);
}
}
None
}
#[allow(dead_code)]
pub fn find_image(kernel_dir: Option<&str>, release: Option<&str>) -> Option<std::path::PathBuf> {
if let Some(dir_str) = kernel_dir {
let dir = std::path::PathBuf::from(dir_str);
if !dir.is_dir() {
return None;
}
return find_image_in_dir(&dir);
}
if let Some(dir) = resolve_kernel(None)
&& let Some(img) = find_image_in_dir(&dir)
{
return Some(img);
}
let owned_release;
let rel = match release {
Some(r) => Some(r),
None => {
owned_release = kernel_release_from_procfs();
owned_release.as_deref()
}
};
if let Some(rel) = rel {
let p = std::path::PathBuf::from(format!("/lib/modules/{rel}/vmlinuz"));
if std::fs::File::open(&p).is_ok() {
return Some(p);
}
let p = std::path::PathBuf::from(format!("/boot/vmlinuz-{rel}"));
if std::fs::File::open(&p).is_ok() {
return Some(p);
}
}
let p = std::path::PathBuf::from("/boot/vmlinuz");
if std::fs::File::open(&p).is_ok() {
return Some(p);
}
None
}
#[allow(dead_code)]
pub fn resolve_btf(kernel_dir: Option<&str>) -> Option<std::path::PathBuf> {
if let Some(dir) = resolve_kernel(kernel_dir) {
let vmlinux = dir.join("vmlinux");
if vmlinux.exists() {
return Some(vmlinux);
}
}
let sysfs = std::path::Path::new("/sys/kernel/btf/vmlinux");
if sysfs.exists() {
return Some(sysfs.to_path_buf());
}
None
}
fn has_kernel_artifacts(dir: &std::path::Path) -> bool {
if dir.join("vmlinux").exists() {
return true;
}
#[cfg(target_arch = "x86_64")]
if dir.join("arch/x86/boot/bzImage").exists() || dir.join("bzImage").exists() {
return true;
}
#[cfg(target_arch = "aarch64")]
if dir.join("arch/arm64/boot/Image").exists() || dir.join("Image").exists() {
return true;
}
false
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
use tempfile::TempDir;
#[test]
fn kernel_path_resolve_explicit_dir_exists() {
let tmp = TempDir::new().unwrap();
let result = resolve_kernel(Some(tmp.path().to_str().unwrap()));
assert_eq!(result, Some(tmp.path().to_path_buf()));
}
#[test]
fn kernel_path_resolve_explicit_dir_not_exists() {
let result = resolve_kernel(Some("/nonexistent/kernel/dir/that/does/not/exist"));
assert_ne!(
result,
Some(PathBuf::from("/nonexistent/kernel/dir/that/does/not/exist"))
);
}
#[test]
fn kernel_path_resolve_none_falls_through() {
let _ = resolve_kernel(None);
}
#[test]
fn kernel_path_resolve_none_returns_osrelease_build_dir_when_present() {
let release = std::fs::read_to_string("/proc/sys/kernel/osrelease")
.expect("host /proc/sys/kernel/osrelease must be readable for this test")
.trim()
.to_string();
let expected = std::path::PathBuf::from(format!("/lib/modules/{release}/build"));
if !expected.is_dir() {
return;
}
let resolved = resolve_kernel(None).unwrap_or_else(|| {
panic!(
"resolve_kernel(None) must return Some when {} exists",
expected.display(),
)
});
assert!(
resolved.is_dir(),
"resolved path must be a directory, got {}",
resolved.display(),
);
let local_shadowed = std::path::PathBuf::from("./linux").is_dir()
|| std::path::PathBuf::from("../linux").is_dir();
if !local_shadowed {
assert_eq!(
resolved, expected,
"with no local trees, resolve_kernel(None) must return the osrelease build dir",
);
}
}
#[test]
fn kernel_path_resolve_empty_string() {
let result = resolve_kernel(Some(""));
assert_ne!(result, Some(PathBuf::from("")));
}
#[test]
fn kernel_path_has_artifacts_vmlinux() {
let tmp = TempDir::new().unwrap();
std::fs::write(tmp.path().join("vmlinux"), b"fake").unwrap();
assert!(has_kernel_artifacts(tmp.path()));
}
#[cfg(target_arch = "x86_64")]
#[test]
fn kernel_path_has_artifacts_bzimage() {
let tmp = TempDir::new().unwrap();
let boot = tmp.path().join("arch/x86/boot");
std::fs::create_dir_all(&boot).unwrap();
std::fs::write(boot.join("bzImage"), b"fake").unwrap();
assert!(has_kernel_artifacts(tmp.path()));
}
#[cfg(target_arch = "aarch64")]
#[test]
fn kernel_path_has_artifacts_image() {
let tmp = TempDir::new().unwrap();
let boot = tmp.path().join("arch/arm64/boot");
std::fs::create_dir_all(&boot).unwrap();
std::fs::write(boot.join("Image"), b"fake").unwrap();
assert!(has_kernel_artifacts(tmp.path()));
}
#[test]
fn kernel_path_has_artifacts_empty_dir() {
let tmp = TempDir::new().unwrap();
assert!(!has_kernel_artifacts(tmp.path()));
}
#[cfg(target_arch = "x86_64")]
#[test]
fn kernel_path_find_image_in_dir_bzimage() {
let tmp = TempDir::new().unwrap();
let boot = tmp.path().join("arch/x86/boot");
std::fs::create_dir_all(&boot).unwrap();
std::fs::write(boot.join("bzImage"), b"fake").unwrap();
let result = find_image_in_dir(tmp.path());
assert_eq!(result, Some(boot.join("bzImage")));
}
#[cfg(target_arch = "aarch64")]
#[test]
fn kernel_path_find_image_in_dir_image() {
let tmp = TempDir::new().unwrap();
let boot = tmp.path().join("arch/arm64/boot");
std::fs::create_dir_all(&boot).unwrap();
std::fs::write(boot.join("Image"), b"fake").unwrap();
let result = find_image_in_dir(tmp.path());
assert_eq!(result, Some(boot.join("Image")));
}
#[test]
fn kernel_path_find_image_in_dir_empty() {
let tmp = TempDir::new().unwrap();
assert!(find_image_in_dir(tmp.path()).is_none());
}
#[cfg(target_arch = "x86_64")]
#[test]
fn kernel_path_find_image_in_dir_cache_layout() {
let tmp = TempDir::new().unwrap();
std::fs::write(tmp.path().join("bzImage"), b"fake").unwrap();
let result = find_image_in_dir(tmp.path());
assert_eq!(result, Some(tmp.path().join("bzImage")));
}
#[cfg(target_arch = "aarch64")]
#[test]
fn kernel_path_find_image_in_dir_cache_layout_image() {
let tmp = TempDir::new().unwrap();
std::fs::write(tmp.path().join("Image"), b"fake").unwrap();
let result = find_image_in_dir(tmp.path());
assert_eq!(result, Some(tmp.path().join("Image")));
}
#[cfg(target_arch = "x86_64")]
#[test]
fn kernel_path_find_image_in_dir_prefers_build_tree() {
let tmp = TempDir::new().unwrap();
let boot = tmp.path().join("arch/x86/boot");
std::fs::create_dir_all(&boot).unwrap();
std::fs::write(boot.join("bzImage"), b"build-tree").unwrap();
std::fs::write(tmp.path().join("bzImage"), b"root-level").unwrap();
let result = find_image_in_dir(tmp.path());
assert_eq!(result, Some(boot.join("bzImage")));
}
#[cfg(target_arch = "aarch64")]
#[test]
fn kernel_path_find_image_in_dir_prefers_build_tree_image() {
let tmp = TempDir::new().unwrap();
let boot = tmp.path().join("arch/arm64/boot");
std::fs::create_dir_all(&boot).unwrap();
std::fs::write(boot.join("Image"), b"build-tree").unwrap();
std::fs::write(tmp.path().join("Image"), b"root-level").unwrap();
let result = find_image_in_dir(tmp.path());
assert_eq!(result, Some(boot.join("Image")));
}
#[cfg(target_arch = "x86_64")]
#[test]
fn kernel_path_has_artifacts_root_bzimage() {
let tmp = TempDir::new().unwrap();
std::fs::write(tmp.path().join("bzImage"), b"fake").unwrap();
assert!(has_kernel_artifacts(tmp.path()));
}
#[cfg(target_arch = "aarch64")]
#[test]
fn kernel_path_has_artifacts_root_image() {
let tmp = TempDir::new().unwrap();
std::fs::write(tmp.path().join("Image"), b"fake").unwrap();
assert!(has_kernel_artifacts(tmp.path()));
}
#[cfg(target_arch = "x86_64")]
#[test]
fn derive_kernel_dir_build_tree_x86() {
let tmp = TempDir::new().unwrap();
let boot = tmp.path().join("arch/x86/boot");
std::fs::create_dir_all(&boot).unwrap();
let image = boot.join("bzImage");
std::fs::write(&image, b"fake").unwrap();
let canon_root = std::fs::canonicalize(tmp.path()).unwrap();
assert_eq!(derive_kernel_dir(&image), Some(canon_root));
}
#[cfg(target_arch = "aarch64")]
#[test]
fn derive_kernel_dir_build_tree_aarch64() {
let tmp = TempDir::new().unwrap();
let boot = tmp.path().join("arch/arm64/boot");
std::fs::create_dir_all(&boot).unwrap();
let image = boot.join("Image");
std::fs::write(&image, b"fake").unwrap();
let canon_root = std::fs::canonicalize(tmp.path()).unwrap();
assert_eq!(derive_kernel_dir(&image), Some(canon_root));
}
#[cfg(target_arch = "x86_64")]
#[test]
fn derive_kernel_dir_cache_entry_x86_with_vmlinux() {
let tmp = TempDir::new().unwrap();
let image = tmp.path().join("bzImage");
std::fs::write(&image, b"fake").unwrap();
std::fs::write(tmp.path().join("vmlinux"), b"fake-elf").unwrap();
let canon = std::fs::canonicalize(tmp.path()).unwrap();
assert_eq!(derive_kernel_dir(&image), Some(canon));
}
#[cfg(target_arch = "aarch64")]
#[test]
fn derive_kernel_dir_cache_entry_aarch64_with_vmlinux() {
let tmp = TempDir::new().unwrap();
let image = tmp.path().join("Image");
std::fs::write(&image, b"fake").unwrap();
std::fs::write(tmp.path().join("vmlinux"), b"fake-elf").unwrap();
let canon = std::fs::canonicalize(tmp.path()).unwrap();
assert_eq!(derive_kernel_dir(&image), Some(canon));
}
#[cfg(target_arch = "x86_64")]
#[test]
fn derive_kernel_dir_cache_entry_without_vmlinux() {
let tmp = TempDir::new().unwrap();
let image = tmp.path().join("bzImage");
std::fs::write(&image, b"fake").unwrap();
assert_eq!(derive_kernel_dir(&image), None);
}
#[cfg(target_arch = "aarch64")]
#[test]
fn derive_kernel_dir_cache_entry_without_vmlinux_aarch64() {
let tmp = TempDir::new().unwrap();
let image = tmp.path().join("Image");
std::fs::write(&image, b"fake").unwrap();
assert_eq!(derive_kernel_dir(&image), None);
}
#[test]
fn derive_kernel_dir_nonexistent_path() {
let p = std::path::Path::new("/nonexistent/kernel/bzImage");
assert_eq!(derive_kernel_dir(p), None);
}
#[cfg(target_arch = "x86_64")]
#[test]
fn derive_kernel_dir_arbitrary_image_no_vmlinux_sibling() {
let tmp = TempDir::new().unwrap();
let sub = tmp.path().join("somewhere/else");
std::fs::create_dir_all(&sub).unwrap();
let image = sub.join("bzImage");
std::fs::write(&image, b"fake").unwrap();
assert_eq!(derive_kernel_dir(&image), None);
}
#[cfg(target_arch = "aarch64")]
#[test]
fn derive_kernel_dir_arbitrary_image_no_vmlinux_sibling_aarch64() {
let tmp = TempDir::new().unwrap();
let sub = tmp.path().join("somewhere/else");
std::fs::create_dir_all(&sub).unwrap();
let image = sub.join("Image");
std::fs::write(&image, b"fake").unwrap();
assert_eq!(derive_kernel_dir(&image), None);
}
#[test]
fn kernel_path_resolve_btf_with_vmlinux_in_dir() {
let tmp = TempDir::new().unwrap();
std::fs::write(tmp.path().join("vmlinux"), b"fake").unwrap();
let result = resolve_btf(Some(tmp.path().to_str().unwrap()));
assert_eq!(result, Some(tmp.path().join("vmlinux")));
}
#[test]
fn kernel_path_resolve_btf_dir_without_vmlinux() {
let tmp = TempDir::new().unwrap();
let result = resolve_btf(Some(tmp.path().to_str().unwrap()));
if let Some(ref p) = result {
assert!(p.exists());
}
}
#[test]
fn kernel_path_resolve_btf_nonexistent_dir() {
let result = resolve_btf(Some("/nonexistent/btf/dir/xyz"));
if let Some(ref p) = result {
assert!(p.exists());
}
}
#[cfg(target_arch = "x86_64")]
#[test]
fn kernel_path_find_image_explicit_dir_with_bzimage() {
let tmp = TempDir::new().unwrap();
let boot = tmp.path().join("arch/x86/boot");
std::fs::create_dir_all(&boot).unwrap();
std::fs::write(boot.join("bzImage"), b"fake").unwrap();
let result = find_image(Some(tmp.path().to_str().unwrap()), None);
assert_eq!(result, Some(boot.join("bzImage")));
}
#[cfg(target_arch = "aarch64")]
#[test]
fn kernel_path_find_image_explicit_dir_with_image() {
let tmp = TempDir::new().unwrap();
let boot = tmp.path().join("arch/arm64/boot");
std::fs::create_dir_all(&boot).unwrap();
std::fs::write(boot.join("Image"), b"fake").unwrap();
let result = find_image(Some(tmp.path().to_str().unwrap()), None);
assert_eq!(result, Some(boot.join("Image")));
}
#[test]
fn kernel_path_find_image_nonexistent_dir() {
let _ = find_image(Some("/nonexistent/image/dir/xyz"), None);
}
#[test]
fn kernel_path_find_image_release_none_matches_osrelease() {
let host_release = std::fs::read_to_string("/proc/sys/kernel/osrelease")
.expect("host /proc/sys/kernel/osrelease must be readable for this test")
.trim()
.to_string();
assert!(
!host_release.is_empty(),
"/proc/sys/kernel/osrelease must be non-empty for this test",
);
let derived = find_image(None, None);
let explicit = find_image(None, Some(&host_release));
assert_eq!(
derived, explicit,
"find_image(None, None) must equal find_image(None, Some(osrelease)); fallback diverged",
);
}
#[test]
fn kernel_id_parse_path_with_slash() {
assert_eq!(
KernelId::parse("../linux"),
KernelId::Path(PathBuf::from("../linux"))
);
assert_eq!(
KernelId::parse("/boot/vmlinuz"),
KernelId::Path(PathBuf::from("/boot/vmlinuz"))
);
}
#[test]
fn kernel_id_parse_path_dot_prefix() {
assert_eq!(
KernelId::parse("./linux"),
KernelId::Path(PathBuf::from("./linux"))
);
assert_eq!(KernelId::parse("."), KernelId::Path(PathBuf::from(".")));
}
#[test]
fn kernel_id_parse_path_tilde_prefix_expands() {
let _lock = crate::test_support::test_helpers::lock_env();
let _home_guard =
crate::test_support::test_helpers::EnvVarGuard::set("HOME", "/home/fixture-user");
assert_eq!(
KernelId::parse("~/linux"),
KernelId::Path(PathBuf::from("/home/fixture-user/linux")),
);
}
#[test]
fn kernel_id_parse_path_bare_tilde_expands() {
let _lock = crate::test_support::test_helpers::lock_env();
let _home_guard =
crate::test_support::test_helpers::EnvVarGuard::set("HOME", "/home/fixture-user");
assert_eq!(
KernelId::parse("~"),
KernelId::Path(PathBuf::from("/home/fixture-user")),
);
}
#[test]
fn kernel_id_parse_path_tilde_with_home_unset_passes_through() {
let _lock = crate::test_support::test_helpers::lock_env();
let _home_guard = crate::test_support::test_helpers::EnvVarGuard::remove("HOME");
assert_eq!(
KernelId::parse("~/linux"),
KernelId::Path(PathBuf::from("~/linux")),
);
}
#[test]
fn kernel_id_parse_path_tilde_user_passes_through() {
let _lock = crate::test_support::test_helpers::lock_env();
let _home_guard =
crate::test_support::test_helpers::EnvVarGuard::set("HOME", "/home/fixture-user");
assert_eq!(
KernelId::parse("~peer/linux"),
KernelId::Path(PathBuf::from("~peer/linux")),
);
}
#[test]
fn kernel_id_parse_version_stable() {
assert_eq!(
KernelId::parse("6.14.2"),
KernelId::Version("6.14.2".to_string())
);
assert_eq!(
KernelId::parse("6.14"),
KernelId::Version("6.14".to_string())
);
}
#[test]
fn kernel_id_parse_version_rc() {
assert_eq!(
KernelId::parse("6.15-rc3"),
KernelId::Version("6.15-rc3".to_string())
);
}
#[test]
fn kernel_id_parse_version_patch_rc() {
assert_eq!(
KernelId::parse("6.14.2-rc1"),
KernelId::Version("6.14.2-rc1".to_string())
);
}
#[test]
fn kernel_id_parse_cache_key() {
assert_eq!(
KernelId::parse("6.14.2-tarball-x86_64"),
KernelId::CacheKey("6.14.2-tarball-x86_64".to_string())
);
assert_eq!(
KernelId::parse("local-deadbeef-x86_64"),
KernelId::CacheKey("local-deadbeef-x86_64".to_string())
);
}
#[test]
fn kernel_id_parse_v_prefix_not_version() {
assert_eq!(
KernelId::parse("v6.14"),
KernelId::CacheKey("v6.14".to_string())
);
}
#[test]
fn kernel_id_parse_bare_major_not_version() {
assert_eq!(KernelId::parse("6"), KernelId::CacheKey("6".to_string()));
}
#[test]
fn kernel_id_display() {
assert_eq!(
KernelId::Version("6.14.2".to_string()).to_string(),
"6.14.2"
);
assert_eq!(
KernelId::Path(PathBuf::from("../linux")).to_string(),
"../linux"
);
assert_eq!(
KernelId::CacheKey("my-key".to_string()).to_string(),
"my-key"
);
assert_eq!(
KernelId::Range {
start: "6.10".to_string(),
end: "6.13".to_string(),
}
.to_string(),
"6.10..6.13",
);
assert_eq!(
KernelId::Git {
url: "https://example.com/r.git".to_string(),
git_ref: "main".to_string(),
}
.to_string(),
"git+https://example.com/r.git#main",
);
}
#[test]
fn kernel_id_parse_range_versions() {
assert_eq!(
KernelId::parse("6.10..6.15"),
KernelId::Range {
start: "6.10".to_string(),
end: "6.15".to_string(),
},
);
}
#[test]
fn kernel_id_parse_range_patch_versions() {
assert_eq!(
KernelId::parse("6.10.5..6.10.10"),
KernelId::Range {
start: "6.10.5".to_string(),
end: "6.10.10".to_string(),
},
);
}
#[test]
fn kernel_id_parse_range_rc() {
assert_eq!(
KernelId::parse("6.10..6.10-rc3"),
KernelId::Range {
start: "6.10".to_string(),
end: "6.10-rc3".to_string(),
},
);
}
#[test]
fn kernel_id_parse_range_non_version_falls_through() {
assert_eq!(
KernelId::parse("foo..bar"),
KernelId::CacheKey("foo..bar".to_string()),
);
}
#[test]
fn kernel_id_parse_range_one_non_version() {
assert_eq!(
KernelId::parse("6.10..foo"),
KernelId::CacheKey("6.10..foo".to_string()),
);
}
#[test]
fn kernel_id_parse_range_empty_endpoint() {
assert_eq!(
KernelId::parse("6.10.."),
KernelId::CacheKey("6.10..".to_string()),
);
}
#[test]
fn kernel_id_parse_git_branch() {
assert_eq!(
KernelId::parse("git+https://example.com/r.git#main"),
KernelId::Git {
url: "https://example.com/r.git".to_string(),
git_ref: "main".to_string(),
},
);
}
#[test]
fn kernel_id_parse_git_sha() {
assert_eq!(
KernelId::parse("git+https://example.com/r.git#abc1234"),
KernelId::Git {
url: "https://example.com/r.git".to_string(),
git_ref: "abc1234".to_string(),
},
);
}
#[test]
fn kernel_id_parse_git_multi_hash_url() {
assert_eq!(
KernelId::parse("git+https://x#frag#main"),
KernelId::Git {
url: "https://x#frag".to_string(),
git_ref: "main".to_string(),
},
);
}
#[test]
fn kernel_id_parse_git_empty_ref_falls_through() {
assert_eq!(
KernelId::parse("git+https://example.com/r.git#"),
KernelId::Path(PathBuf::from("git+https://example.com/r.git#")),
);
}
#[test]
fn kernel_id_parse_git_empty_url_falls_through() {
assert_eq!(
KernelId::parse("git+#main"),
KernelId::CacheKey("git+#main".to_string()),
);
}
#[test]
fn kernel_id_parse_git_beats_path() {
assert_eq!(
KernelId::parse("git+/local/repo#v1"),
KernelId::Git {
url: "/local/repo".to_string(),
git_ref: "v1".to_string(),
},
);
}
#[test]
fn kernel_id_parse_list_basic() {
let list = KernelId::parse_list("6.10,6.13");
assert_eq!(
list,
vec![
KernelId::Version("6.10".to_string()),
KernelId::Version("6.13".to_string()),
],
);
}
#[test]
fn kernel_id_parse_list_mixed() {
let list = KernelId::parse_list("6.10,git+url#main,/srv/linux");
assert_eq!(list.len(), 3, "expected 3 entries, got {list:?}");
assert!(matches!(list[0], KernelId::Version(ref v) if v == "6.10"));
assert!(matches!(
list[1],
KernelId::Git { ref url, ref git_ref } if url == "url" && git_ref == "main"
));
assert!(matches!(list[2], KernelId::Path(ref p) if p == &PathBuf::from("/srv/linux")));
}
#[test]
fn kernel_id_parse_list_empty() {
assert_eq!(KernelId::parse_list(""), Vec::<KernelId>::new());
}
#[test]
fn kernel_id_parse_list_trailing_comma() {
assert_eq!(
KernelId::parse_list(",6.10,,"),
vec![KernelId::Version("6.10".to_string())],
);
}
#[test]
fn kernel_id_parse_list_whitespace() {
assert_eq!(
KernelId::parse_list("6.10 , 6.13"),
vec![
KernelId::Version("6.10".to_string()),
KernelId::Version("6.13".to_string()),
],
);
}
#[test]
fn kernel_id_parse_list_single() {
assert_eq!(
KernelId::parse_list("6.10"),
vec![KernelId::Version("6.10".to_string())],
);
}
#[test]
fn kernel_id_parse_list_preserves_dups() {
let list = KernelId::parse_list("6.10,6.10,6.13");
assert_eq!(list.len(), 3, "expected 3 entries, got {list:?}");
assert_eq!(list[0], KernelId::Version("6.10".to_string()));
assert_eq!(list[1], KernelId::Version("6.10".to_string()));
assert_eq!(list[2], KernelId::Version("6.13".to_string()));
}
#[test]
fn kernel_id_validate_range_forward_ok() {
let id = KernelId::parse("6.10..6.13");
assert!(id.validate().is_ok(), "forward range must validate: {id:?}");
}
#[test]
fn kernel_id_validate_range_equal_endpoints_ok() {
let id = KernelId::parse("6.10..6.10");
assert!(
id.validate().is_ok(),
"equal endpoints must validate: {id:?}"
);
}
#[test]
fn kernel_id_validate_range_inverted_minor() {
let id = KernelId::parse("6.16..6.12");
let err = id.validate().unwrap_err();
assert!(
err.contains("inverted kernel range"),
"error must say 'inverted kernel range', got: {err}",
);
assert!(
err.contains("6.16..6.12"),
"error must cite the spec, got: {err}"
);
assert!(
err.contains("6.12..6.16"),
"error must suggest the swapped form, got: {err}",
);
}
#[test]
fn kernel_id_validate_range_inverted_major() {
let id = KernelId::parse("7.0..6.99");
assert!(id.validate().is_err(), "inverted major must reject: {id:?}");
}
#[test]
fn kernel_id_validate_range_inverted_patch() {
let id = KernelId::parse("6.10.5..6.10.3");
assert!(id.validate().is_err(), "inverted patch must reject: {id:?}");
}
#[test]
fn kernel_id_validate_range_inverted_rc_below_release() {
let id = KernelId::parse("6.10..6.10-rc3");
assert!(
id.validate().is_err(),
"release > rc — `6.10..6.10-rc3` must reject: {id:?}",
);
}
#[test]
fn kernel_id_validate_range_rc_below_release_forward_ok() {
let id = KernelId::parse("6.10-rc3..6.10");
assert!(
id.validate().is_ok(),
"rc < release — `6.10-rc3..6.10` must validate: {id:?}",
);
}
#[test]
fn kernel_id_validate_range_inverted_rc_to_rc() {
let id = KernelId::parse("6.10-rc3..6.10-rc1");
assert!(id.validate().is_err(), "rc3..rc1 must reject: {id:?}");
}
#[test]
fn kernel_id_validate_range_missing_patch_treated_as_zero() {
let id = KernelId::parse("6.10..6.10.5");
assert!(
id.validate().is_ok(),
"missing patch defaults to 0, so `6.10..6.10.5` is forward: {id:?}",
);
}
#[test]
fn kernel_id_validate_non_range_variants_ok() {
assert!(KernelId::Version("6.14.2".to_string()).validate().is_ok());
assert!(KernelId::CacheKey("my-key".to_string()).validate().is_ok());
assert!(KernelId::Path(PathBuf::from("../linux")).validate().is_ok(),);
assert!(
KernelId::Git {
url: "https://example.com/r.git".to_string(),
git_ref: "main".to_string(),
}
.validate()
.is_ok(),
);
}
#[test]
fn kernel_id_validate_range_unparseable_start() {
let id = KernelId::Range {
start: "garbage".to_string(),
end: "6.10".to_string(),
};
let err = id.validate().unwrap_err();
assert!(
err.contains("not a parseable version"),
"error must say 'not a parseable version', got: {err}",
);
assert!(
err.contains("garbage"),
"error must cite the bad endpoint, got: {err}"
);
}
#[test]
fn kernel_id_validate_range_unparseable_end() {
let id = KernelId::Range {
start: "6.10".to_string(),
end: "garbage".to_string(),
};
let err = id.validate().unwrap_err();
assert!(
err.contains("not a parseable version"),
"error must say 'not a parseable version', got: {err}",
);
assert!(
err.contains("garbage"),
"error must cite the bad endpoint, got: {err}"
);
}
#[test]
fn kernel_id_is_version_string_valid() {
assert!(_is_version_string("6.14"));
assert!(_is_version_string("6.14.2"));
assert!(_is_version_string("6.15-rc3"));
assert!(_is_version_string("6.14.0-rc1"));
assert!(_is_version_string("5.0"));
assert!(_is_version_string("5.0.0"));
assert!(_is_version_string("5.4.0"));
}
#[test]
fn kernel_id_is_version_string_invalid() {
assert!(!_is_version_string("6"));
assert!(!_is_version_string("v6.14"));
assert!(!_is_version_string(""));
assert!(!_is_version_string("6.14.2-tarball-x86_64"));
assert!(!_is_version_string("6.14.2.3"));
assert!(!_is_version_string("6.14-rc"));
assert!(!_is_version_string("6.14-rcX"));
assert!(!_is_version_string("6.14-rc3-tarball-x86_64"));
assert!(!_is_version_string("abc"));
assert!(!_is_version_string(".14"));
assert!(!_is_version_string("6."));
assert!(!_is_version_string("linux"));
assert!(!_is_version_string(".6"));
}
use proptest::prop_assert;
proptest::proptest! {
#[test]
fn prop_kernel_id_parse_never_panics(s in "\\PC{0,120}") {
let _env_lock = crate::test_support::test_helpers::lock_env();
match KernelId::parse(&s) {
KernelId::Path(p) => {
let expected = expand_tilde(&s);
prop_assert!(
p == expected,
"Path payload drift for {s:?}: got {p:?}, expected {expected:?}",
);
}
KernelId::Version(v) => prop_assert!(v == s, "Version payload drift for {s:?}"),
KernelId::CacheKey(k) => prop_assert!(k == s, "CacheKey payload drift for {s:?}"),
KernelId::Range { start, end } => {
prop_assert!(
format!("{start}..{end}") == s,
"Range payload drift for {s:?}",
);
}
KernelId::Git { url, git_ref } => {
prop_assert!(
format!("git+{url}#{git_ref}") == s,
"Git payload drift for {s:?}",
);
}
}
}
#[test]
fn prop_kernel_id_path_on_slash(
prefix in "[a-z]{1,5}",
suffix in "[a-z]{1,5}",
) {
let s = format!("{prefix}/{suffix}");
assert!(matches!(KernelId::parse(&s), KernelId::Path(_)));
}
#[test]
fn prop_kernel_id_path_on_dot_prefix(s in "\\.[a-z]{1,10}") {
assert!(matches!(KernelId::parse(&s), KernelId::Path(_)));
}
#[test]
fn prop_kernel_id_version_roundtrip(
major in 1u32..20,
minor in 0u32..50,
patch in 0u32..100,
) {
let v = format!("{major}.{minor}.{patch}");
assert_eq!(KernelId::parse(&v), KernelId::Version(v.clone()));
}
#[test]
fn prop_kernel_id_version_rc(major in 1u32..20, minor in 0u32..50, rc in 1u32..10) {
let v = format!("{major}.{minor}-rc{rc}");
assert_eq!(KernelId::parse(&v), KernelId::Version(v.clone()));
}
}
}