use std::path::{Path, PathBuf};
pub(crate) fn path_kernel_label(dir: &Path) -> String {
let basename = dir.file_name().and_then(|n| n.to_str()).unwrap_or("kernel");
let hash = crc32fast::hash(dir.display().to_string().as_bytes());
format!("path_{basename}_{:06x}", hash & 0x00ff_ffff)
}
pub(crate) fn decorate_path_label_for_dirty(base_label: &str, is_dirty: bool) -> String {
if is_dirty {
format!("{base_label}_dirty")
} else {
base_label.to_string()
}
}
pub(crate) fn cache_key_to_version_label(key: &str) -> std::borrow::Cow<'_, str> {
use std::borrow::Cow;
if key == "local" {
return Cow::Borrowed("local");
}
if let Some(rest) = key.strip_prefix("local-") {
let discriminator = rest.split('-').next().unwrap_or("");
if discriminator.is_empty() {
return Cow::Borrowed("local");
}
let suffix: String = if discriminator == "unknown" {
"unknown".to_string()
} else {
discriminator.chars().take(6).collect::<String>()
};
return Cow::Owned(format!("local_{suffix}"));
}
for tag in &["-tarball-", "-git-"] {
if let Some(prefix_end) = key.find(tag) {
return Cow::Borrowed(&key[..prefix_end]);
}
}
Cow::Borrowed(key)
}
pub(crate) fn git_kernel_label(url: &str, git_ref: &str) -> String {
let after_scheme = url.split_once("://").map(|(_, rest)| rest).unwrap_or(url);
let path = after_scheme
.split_once('/')
.map(|(_, rest)| rest)
.unwrap_or(after_scheme);
let trimmed = path.trim_start_matches('/').trim_end_matches('/');
let trimmed = trimmed.strip_suffix(".git").unwrap_or(trimmed);
let mut segments: Vec<&str> = trimmed.split('/').filter(|s| !s.is_empty()).collect();
let repo = segments.pop().unwrap_or("repo");
let owner = segments.pop().unwrap_or("");
if owner.is_empty() {
format!("git_{repo}_{git_ref}")
} else {
format!("git_{owner}_{repo}_{git_ref}")
}
}
pub(crate) fn encode_kernel_list(resolved: &[(String, PathBuf)]) -> Result<String, String> {
for (label, _) in resolved {
if label.contains(';') {
return Err(format!(
"kernel label {label:?} contains a `;`; \
KTSTR_KERNEL_LIST uses `;` as the entry separator. \
The label-emission path must produce `;`-free identifiers — \
if a producer is emitting this label, fix the producer to \
sanitize/strip `;` from its output."
));
}
if label.contains('=') {
return Err(format!(
"kernel label {label:?} contains a `=`; \
KTSTR_KERNEL_LIST uses `=` to separate label from path within an entry. \
The label-emission path must produce `=`-free identifiers — \
if a producer is emitting this label, fix the producer to \
sanitize/strip `=` from its output."
));
}
}
for (label, dir) in resolved {
let path = dir.display().to_string();
if path.contains(';') {
return Err(format!(
"kernel directory path for {label:?} contains a `;` ({path:?}); \
KTSTR_KERNEL_LIST uses `;` as the entry separator and cannot encode \
such paths. Move or symlink the kernel cache to a path without `;`."
));
}
}
let mut out = String::new();
for (i, (label, dir)) in resolved.iter().enumerate() {
if i > 0 {
out.push(';');
}
out.push_str(label);
out.push('=');
out.push_str(&dir.display().to_string());
}
Ok(out)
}
pub(crate) fn preflight_collision_check(specs: &[String]) -> Result<(), String> {
use ktstr::kernel_path::KernelId;
let mut preflight: std::collections::HashMap<String, String> = std::collections::HashMap::new();
for raw in specs {
let trimmed = raw.trim();
if trimmed.is_empty() {
continue;
}
let id = KernelId::parse(trimmed);
if let Err(e) = id.validate() {
return Err(format!("--kernel {id}: {e}"));
}
let label: Option<String> = match &id {
KernelId::Version(v) => Some(v.clone()),
KernelId::CacheKey(k) => Some(cache_key_to_version_label(k).to_string()),
KernelId::Git { url, git_ref } => Some(git_kernel_label(url, git_ref)),
KernelId::Path(_) | KernelId::Range { .. } => None,
};
if let Some(label) = label {
let sanitized = ktstr::test_support::sanitize_kernel_label(&label);
if let Some(prior) = preflight.insert(sanitized.clone(), label.clone())
&& prior != label
{
return Err(format!(
"--kernel: pre-flight check found collision before any \
download or build started — labels {prior:?} and {label:?} \
both sanitize to {sanitized:?}, which the nextest \
test-name suffix cannot disambiguate. Spell each \
--kernel value distinctly so its sanitized form is \
unique. (Path and Range specs are checked post-resolve.)"
));
}
}
}
Ok(())
}
pub(crate) fn dedupe_resolved(resolved: Vec<(String, PathBuf)>) -> Vec<(String, PathBuf)> {
let mut seen: std::collections::HashSet<(String, PathBuf)> =
std::collections::HashSet::with_capacity(resolved.len());
let mut deduped: Vec<(String, PathBuf)> = Vec::with_capacity(resolved.len());
for entry in resolved {
if seen.insert(entry.clone()) {
deduped.push(entry);
}
}
deduped
}
pub(crate) fn detect_label_collisions(resolved: &[(String, PathBuf)]) -> Result<(), String> {
let mut seen: std::collections::HashMap<String, &str> =
std::collections::HashMap::with_capacity(resolved.len());
for (label, _) in resolved {
let sanitized = ktstr::test_support::sanitize_kernel_label(label);
if let Some(prior) = seen.insert(sanitized.clone(), label.as_str()) {
return Err(format!(
"--kernel: labels {prior:?} and {label:?} both sanitize to {sanitized:?} — \
the nextest test-name suffix cannot disambiguate them. \
Spell each --kernel value distinctly so its sanitized form is unique."
));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cache_key_to_version_label_tarball() {
assert_eq!(
cache_key_to_version_label("6.14.2-tarball-x86_64-kcabc1234"),
"6.14.2",
);
}
#[test]
fn cache_key_to_version_label_rc_tarball() {
assert_eq!(
cache_key_to_version_label("6.15-rc3-tarball-x86_64-kcabc"),
"6.15-rc3",
);
}
#[test]
fn cache_key_to_version_label_git() {
assert_eq!(
cache_key_to_version_label("for-next-git-deadbee-x86_64-kcabc"),
"for-next",
);
}
#[test]
fn cache_key_to_version_label_local_emits_hash6_disambiguator() {
assert_eq!(
cache_key_to_version_label("local-deadbee-x86_64-kcabc"),
"local_deadbe",
"must emit `local_{{first 6 chars of discriminator}}` so \
distinct local trees do not collide on label",
);
}
#[test]
fn cache_key_to_version_label_local_distinct_hashes_render_distinct_labels() {
let a = cache_key_to_version_label("local-aaaaaa1-x86_64-kcabc");
let b = cache_key_to_version_label("local-bbbbbb2-x86_64-kcabc");
assert_ne!(
a, b,
"distinct local discriminators must render distinct labels"
);
assert_eq!(a, "local_aaaaaa");
assert_eq!(b, "local_bbbbbb");
}
#[test]
fn cache_key_to_version_label_local_unknown_renders_local_unknown() {
assert_eq!(
cache_key_to_version_label("local-unknown-x86_64-kcabc"),
"local_unknown",
);
}
#[test]
fn cache_key_to_version_label_local_bare_yields_bare_local() {
assert_eq!(cache_key_to_version_label("local"), "local");
}
#[test]
fn cache_key_to_version_label_unknown_tag_falls_through() {
assert_eq!(
cache_key_to_version_label("6.14.2-novel-tag-kcabc"),
"6.14.2-novel-tag-kcabc",
);
}
#[test]
fn git_kernel_label_github_https() {
assert_eq!(
git_kernel_label("https://github.com/tj/sched_ext", "for-next"),
"git_tj_sched_ext_for-next",
);
}
#[test]
fn git_kernel_label_github_https_with_dot_git() {
assert_eq!(
git_kernel_label("https://github.com/tj/sched_ext.git", "for-next"),
"git_tj_sched_ext_for-next",
);
}
#[test]
fn git_kernel_label_gitlab_with_ref_tag() {
assert_eq!(
git_kernel_label("https://gitlab.com/foo/bar.git", "v6.14"),
"git_foo_bar_v6.14",
);
}
#[test]
fn git_kernel_label_local_mirror_two_segment_path() {
assert_eq!(
git_kernel_label("file:///srv/linux.git", "v6.14"),
"git_srv_linux_v6.14",
);
}
#[test]
fn git_kernel_label_truly_single_segment_path() {
assert_eq!(
git_kernel_label("file://linux.git", "v6.14"),
"git_linux_v6.14",
);
}
#[test]
fn git_kernel_label_ssh_style_url() {
assert_eq!(
git_kernel_label("ssh://git@github.com/tj/sched_ext", "main"),
"git_tj_sched_ext_main",
);
}
#[test]
fn path_kernel_label_includes_basename_and_hash() {
let p = std::path::Path::new("/tmp/somewhere/linux");
let label = path_kernel_label(p);
assert!(
label.starts_with("path_linux_"),
"label must start with `path_<basename>_`, got: {label}"
);
let hash_part = label.strip_prefix("path_linux_").unwrap();
assert_eq!(hash_part.len(), 6, "hash suffix must be 6 chars: {label}");
assert!(
hash_part.chars().all(|c| c.is_ascii_hexdigit()),
"hash suffix must be hex: {label}"
);
}
#[test]
fn path_kernel_label_distinguishes_paths_sharing_basename() {
let a = std::path::Path::new("/srv/a/linux");
let b = std::path::Path::new("/srv/b/linux");
assert_ne!(
path_kernel_label(a),
path_kernel_label(b),
"distinct path parents must produce distinct labels",
);
}
#[test]
fn decorate_path_label_for_dirty_clean_tree_passthrough() {
let base = "path_linux_a3b1c2";
assert_eq!(
decorate_path_label_for_dirty(base, false),
base,
"clean trees must not append a `_dirty` suffix",
);
}
#[test]
fn decorate_path_label_for_dirty_dirty_tree_appends_suffix() {
let base = "path_linux_a3b1c2";
assert_eq!(
decorate_path_label_for_dirty(base, true),
"path_linux_a3b1c2_dirty",
"dirty trees must append `_dirty` to the base label",
);
}
#[test]
fn decorate_path_label_for_dirty_survives_sanitize() {
let dirty_label = decorate_path_label_for_dirty("path_linux_a3b1c2", true);
let sanitized = ktstr::test_support::sanitize_kernel_label(&dirty_label);
assert_eq!(
sanitized, "kernel_path_linux_a3b1c2_dirty",
"`_dirty` must survive sanitize verbatim so the test report \
distinguishes dirty runs from clean runs in the nextest suffix",
);
}
#[test]
fn decorate_path_label_for_dirty_clean_dirty_sanitize_to_distinct_ids() {
let base = "path_linux_a3b1c2";
let clean =
ktstr::test_support::sanitize_kernel_label(&decorate_path_label_for_dirty(base, false));
let dirty =
ktstr::test_support::sanitize_kernel_label(&decorate_path_label_for_dirty(base, true));
assert_ne!(
clean, dirty,
"clean ({clean:?}) and dirty ({dirty:?}) sanitized labels must \
produce distinct nextest identifiers so test reports do not \
collapse non-reproducible runs into the cache-stored row",
);
}
#[test]
fn encode_kernel_list_empty_input_returns_empty_string() {
let encoded = encode_kernel_list(&[]).expect("empty input must succeed");
assert!(
encoded.is_empty(),
"empty resolved list must encode to empty string, got {encoded:?}",
);
}
#[test]
fn encode_kernel_list_single_entry_has_no_separator() {
let resolved = vec![("6.14.2".to_string(), PathBuf::from("/cache/foo"))];
let encoded = encode_kernel_list(&resolved).expect("single entry must succeed");
assert_eq!(
encoded, "6.14.2=/cache/foo",
"single-entry encoding must be `label=path` with no trailing separator",
);
}
#[test]
fn encode_kernel_list_two_entries_uses_semicolon_separator() {
let resolved = vec![
("6.14.2".to_string(), PathBuf::from("/cache/a")),
("6.15.0".to_string(), PathBuf::from("/cache/b")),
];
let encoded = encode_kernel_list(&resolved).expect("two entries must succeed");
assert_eq!(
encoded, "6.14.2=/cache/a;6.15.0=/cache/b",
"two-entry encoding must be `label=path;label=path`",
);
}
#[test]
fn encode_kernel_list_three_entries_preserves_order() {
let resolved = vec![
("z-late".to_string(), PathBuf::from("/cache/z")),
("a-early".to_string(), PathBuf::from("/cache/a")),
("m-mid".to_string(), PathBuf::from("/cache/m")),
];
let encoded = encode_kernel_list(&resolved).expect("three entries must succeed");
assert_eq!(
encoded, "z-late=/cache/z;a-early=/cache/a;m-mid=/cache/m",
"encoder must preserve input order; sorting would change test-name suffix order",
);
}
#[test]
fn encode_kernel_list_rejects_semicolon_in_path() {
let resolved = vec![("6.14.2".to_string(), PathBuf::from("/cache/has;semicolon"))];
let err = encode_kernel_list(&resolved)
.expect_err("path containing `;` must be rejected by encoder");
assert!(
err.contains("`;`"),
"error must reference the offending separator: {err}",
);
assert!(
err.contains("6.14.2"),
"error must name the offending label so the operator can locate the entry: {err}",
);
assert!(
err.contains("/cache/has;semicolon"),
"error must include the offending path: {err}",
);
}
#[test]
fn encode_kernel_list_rejects_semicolon_in_label() {
let resolved = vec![("evil;label".to_string(), PathBuf::from("/cache/clean"))];
let err = encode_kernel_list(&resolved)
.expect_err("label containing `;` must be rejected by encoder");
assert!(
err.contains("`;`"),
"error must reference the offending separator: {err}",
);
assert!(
err.contains("evil;label"),
"error must name the offending label so the operator \
can locate the producer that emitted it: {err}",
);
assert!(
err.contains("kernel label"),
"error must classify the violation as a label problem (not \
a path problem) so an operator reading the diagnostic \
knows which side of the wire format is at fault: {err}",
);
}
#[test]
fn encode_kernel_list_rejects_equals_in_label() {
let resolved = vec![("evil=label".to_string(), PathBuf::from("/cache/clean"))];
let err = encode_kernel_list(&resolved)
.expect_err("label containing `=` must be rejected by encoder");
assert!(
err.contains("`=`"),
"error must reference the offending separator: {err}",
);
assert!(
err.contains("evil=label"),
"error must name the offending label so the operator \
can locate the producer that emitted it: {err}",
);
assert!(
err.contains("kernel label"),
"error must classify the violation as a label problem: {err}",
);
}
#[test]
fn encode_kernel_list_first_entry_with_semicolon_rejected_before_emit() {
let resolved = vec![
("first".to_string(), PathBuf::from("/cache/has;semicolon")),
("second".to_string(), PathBuf::from("/cache/clean")),
];
let err = encode_kernel_list(&resolved)
.expect_err("path containing `;` must be rejected even when other entries are clean");
assert!(err.contains("first"));
}
#[test]
fn encode_kernel_list_later_entry_with_semicolon_still_rejected() {
let resolved = vec![
("first".to_string(), PathBuf::from("/cache/clean")),
("second".to_string(), PathBuf::from("/cache/has;semicolon")),
];
let err = encode_kernel_list(&resolved)
.expect_err("`;` anywhere in any path must abort the encode");
assert!(err.contains("second"));
}
#[test]
fn detect_label_collisions_empty_input_succeeds() {
let resolved: Vec<(String, PathBuf)> = Vec::new();
detect_label_collisions(&resolved).expect("empty input must succeed");
}
#[test]
fn detect_label_collisions_unique_labels_succeed() {
let resolved = vec![
("6.14.2".to_string(), PathBuf::from("/cache/a")),
("6.15.0".to_string(), PathBuf::from("/cache/b")),
];
detect_label_collisions(&resolved).expect("distinct sanitized identifiers must succeed");
}
#[test]
fn detect_label_collisions_period_vs_dash_collides() {
let resolved = vec![
("6.14.2".to_string(), PathBuf::from("/cache/a")),
("6-14-2".to_string(), PathBuf::from("/cache/b")),
];
let err = detect_label_collisions(&resolved)
.expect_err("colliding sanitized identifiers must surface an error");
assert!(
err.contains("6.14.2"),
"error must name first colliding label: {err}",
);
assert!(
err.contains("6-14-2"),
"error must name second colliding label: {err}",
);
assert!(
err.contains("kernel_6_14_2"),
"error must include the shared sanitized identifier: {err}",
);
assert!(
err.contains("Spell each --kernel value distinctly"),
"error must include the actionable remediation hint: {err}",
);
}
#[test]
fn detect_label_collisions_uppercase_vs_lowercase_collides() {
let resolved = vec![
("ABC".to_string(), PathBuf::from("/cache/a")),
("abc".to_string(), PathBuf::from("/cache/b")),
];
let err = detect_label_collisions(&resolved)
.expect_err("uppercase vs lowercase labels must collide post-sanitize");
assert!(err.contains("kernel_abc"));
}
#[test]
fn detect_label_collisions_identical_labels_collide() {
let resolved = vec![
("6.14.2".to_string(), PathBuf::from("/cache/a")),
("6.14.2".to_string(), PathBuf::from("/cache/b")),
];
let err = detect_label_collisions(&resolved)
.expect_err("two identical labels must surface as a collision");
assert!(err.contains("6.14.2"));
assert!(err.contains("kernel_6_14_2"));
}
#[test]
fn detect_label_collisions_three_entries_two_collide_one_unique() {
let resolved = vec![
("6.14.2".to_string(), PathBuf::from("/cache/a")),
("6-14-2".to_string(), PathBuf::from("/cache/b")),
("7.0.0".to_string(), PathBuf::from("/cache/c")),
];
let err = detect_label_collisions(&resolved)
.expect_err("collision in the first two entries must surface");
assert!(err.contains("6.14.2"));
assert!(err.contains("6-14-2"));
assert!(
!err.contains("7.0.0"),
"non-conflicting label should not appear in the collision diagnostic: {err}",
);
}
#[test]
fn detect_label_collisions_first_two_unique_third_collides_with_first() {
let resolved = vec![
("6.14.2".to_string(), PathBuf::from("/cache/a")),
("7.0.0".to_string(), PathBuf::from("/cache/b")),
("6-14-2".to_string(), PathBuf::from("/cache/c")),
];
let err = detect_label_collisions(&resolved)
.expect_err("late-arriving collision against an earlier entry must surface");
assert!(err.contains("6.14.2"), "earlier (prior) label must appear");
assert!(err.contains("6-14-2"), "later label must appear");
assert!(err.contains("kernel_6_14_2"));
}
#[test]
fn preflight_collision_check_empty_input_succeeds() {
preflight_collision_check(&[]).expect("empty input must succeed");
}
#[test]
fn preflight_collision_check_unique_versions_succeed() {
let specs = vec!["6.14.2".to_string(), "6.15.0".to_string()];
preflight_collision_check(&specs)
.expect("distinct sanitized identifiers must succeed at pre-flight");
}
#[test]
fn preflight_collision_check_period_vs_dash_collides() {
let specs = vec!["6.14.2".to_string(), "6-14-2".to_string()];
let err = preflight_collision_check(&specs)
.expect_err("colliding labels must surface a pre-flight error");
assert!(err.contains("6.14.2"), "error must name first label: {err}");
assert!(
err.contains("6-14-2"),
"error must name second label: {err}"
);
assert!(
err.contains("kernel_6_14_2"),
"error must include the shared sanitized identifier: {err}",
);
assert!(
err.contains("pre-flight check found collision"),
"error must be the pre-flight diagnostic, not the post-resolve one: {err}",
);
}
#[test]
fn preflight_collision_check_identical_versions_succeed() {
let specs = vec!["6.14.2".to_string(), "6.14.2".to_string()];
preflight_collision_check(&specs)
.expect("identical specs must NOT bail at pre-flight (handled by dedupe post-resolve)");
}
#[test]
fn preflight_collision_check_skips_path_and_range_specs() {
let specs = vec![
"/tmp/kernel-a".to_string(),
"/tmp/kernel-b".to_string(),
"6.14.2..6.14.4".to_string(),
];
preflight_collision_check(&specs).expect(
"Path and Range specs must skip pre-flight — their labels are deferred to post-resolve",
);
}
#[test]
fn preflight_collision_check_skips_empty_and_whitespace_specs() {
let specs = vec!["".to_string(), " ".to_string(), "6.14.2".to_string()];
preflight_collision_check(&specs)
.expect("blank / whitespace-only specs must be silently skipped");
}
#[test]
fn preflight_collision_check_inverted_range_fails_validation() {
let specs = vec!["6.15..6.14".to_string()];
let err = preflight_collision_check(&specs)
.expect_err("inverted range must fail pre-flight validation");
assert!(
err.contains("inverted kernel range") || err.contains("--kernel"),
"error must surface the inversion diagnostic with --kernel framing: {err}",
);
}
#[test]
fn preflight_collision_check_git_url_collision() {
let specs = vec![
"git+ssh://host/foo/bar#v6.14".to_string(),
"git+ssh://host/foo/bar#v6-14".to_string(),
];
let err = preflight_collision_check(&specs)
.expect_err("colliding git refs must surface a pre-flight error");
assert!(err.contains("git_foo_bar_v6.14") || err.contains("git_foo_bar_v6-14"));
assert!(err.contains("kernel_git_foo_bar_v6_14"));
}
#[test]
fn dedupe_resolved_empty_input_returns_empty() {
let resolved: Vec<(String, PathBuf)> = Vec::new();
let deduped = dedupe_resolved(resolved);
assert!(deduped.is_empty());
}
#[test]
fn dedupe_resolved_unique_inputs_pass_through() {
let resolved = vec![
("a".to_string(), PathBuf::from("/cache/a")),
("b".to_string(), PathBuf::from("/cache/b")),
("c".to_string(), PathBuf::from("/cache/c")),
];
let deduped = dedupe_resolved(resolved.clone());
assert_eq!(deduped, resolved);
}
#[test]
fn dedupe_resolved_two_identical_tuples_collapse_to_one() {
let resolved = vec![
("6.14.2".to_string(), PathBuf::from("/cache/v")),
("6.14.2".to_string(), PathBuf::from("/cache/v")),
];
let deduped = dedupe_resolved(resolved);
assert_eq!(
deduped.len(),
1,
"identical tuples must collapse to one entry"
);
assert_eq!(deduped[0].0, "6.14.2");
assert_eq!(deduped[0].1, PathBuf::from("/cache/v"));
}
#[test]
fn dedupe_resolved_same_label_different_paths_both_survive() {
let resolved = vec![
("6.14.2".to_string(), PathBuf::from("/cache/a")),
("6.14.2".to_string(), PathBuf::from("/cache/b")),
];
let deduped = dedupe_resolved(resolved);
assert_eq!(
deduped.len(),
2,
"same label + different paths must NOT dedupe — \
this is a real cache-key collision that detect_label_collisions \
must still catch downstream",
);
}
#[test]
fn dedupe_resolved_preserves_input_order() {
let resolved = vec![
("a".to_string(), PathBuf::from("/cache/a")),
("b".to_string(), PathBuf::from("/cache/b")),
("a".to_string(), PathBuf::from("/cache/a")),
("c".to_string(), PathBuf::from("/cache/c")),
];
let deduped = dedupe_resolved(resolved);
assert_eq!(
deduped,
vec![
("a".to_string(), PathBuf::from("/cache/a")),
("b".to_string(), PathBuf::from("/cache/b")),
("c".to_string(), PathBuf::from("/cache/c")),
],
);
}
#[test]
fn dedupe_resolved_three_identical_tuples_collapse_to_one() {
let resolved = vec![
("v".to_string(), PathBuf::from("/cache/v")),
("v".to_string(), PathBuf::from("/cache/v")),
("v".to_string(), PathBuf::from("/cache/v")),
];
let deduped = dedupe_resolved(resolved);
assert_eq!(deduped.len(), 1);
}
}