use anyhow::Result;
use std::path::{Path, PathBuf};
use crate::link::LinkStrategy;
pub mod cc;
pub mod flags;
pub mod platform;
pub mod rustc;
pub use platform::Platform;
pub use crate::compile::CompileResult;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct CompilerId(&'static str);
impl CompilerId {
pub const fn new(id: &'static str) -> Self {
Self(id)
}
pub const fn as_str(self) -> &'static str {
self.0
}
}
impl std::fmt::Display for CompilerId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.0)
}
}
#[derive(Debug, Clone, Copy)]
pub struct CompilerAdapter {
id: CompilerId,
display_name: &'static str,
recognizes: fn(&[String]) -> bool,
}
impl CompilerAdapter {
pub const fn new(
id: CompilerId,
display_name: &'static str,
recognizes: fn(&[String]) -> bool,
) -> Self {
Self {
id,
display_name,
recognizes,
}
}
pub const fn id(self) -> CompilerId {
self.id
}
pub const fn display_name(self) -> &'static str {
self.display_name
}
pub fn recognizes(self, args: &[String]) -> bool {
(self.recognizes)(args)
}
}
#[derive(Debug, Clone)]
pub enum RefuseReason {
NotPrimary,
Unsupported(&'static str),
}
impl RefuseReason {
pub fn description(&self) -> &'static str {
match self {
RefuseReason::NotPrimary => "query / probe (--print, -vV)",
RefuseReason::Unsupported(detail) => detail,
}
}
pub fn category(&self) -> &'static str {
match self {
RefuseReason::NotPrimary => "not-a-compile",
RefuseReason::Unsupported(_) => "unsupported",
}
}
}
pub struct KeyCtx<'a, 'db> {
pub file_hasher: &'a crate::cache_key::FileHasher<'db>,
pub path_normalizer: &'a crate::path_normalizer::PathNormalizer,
pub cache_dir: &'a Path,
pub key_salt: Option<&'a str>,
pub key_env_vars: &'a [String],
pub extra_inputs_digest: Option<&'a str>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ArtifactKind {
Library,
DynamicLibrary,
Metadata,
Object,
DepInfo,
Executable,
WasmModule,
DebugSidecar,
DebugBundle,
Other(&'static str),
}
impl ArtifactKind {
pub fn link_strategy(self) -> LinkStrategy {
match self {
ArtifactKind::Executable | ArtifactKind::DynamicLibrary | ArtifactKind::WasmModule => {
LinkStrategy::Copy
}
_ => LinkStrategy::Hardlink,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Artifact {
pub path: PathBuf,
pub store_name: String,
pub kind: ArtifactKind,
pub required: bool,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ArtifactSet {
outputs: Vec<Artifact>,
}
impl ArtifactSet {
pub fn new(outputs: Vec<Artifact>) -> Self {
Self { outputs }
}
pub fn empty() -> Self {
Self::default()
}
pub fn from_output_files(
output_files: Vec<(PathBuf, String)>,
classify: impl Fn(&str) -> ArtifactKind,
) -> Self {
Self::new(
output_files
.into_iter()
.map(|(path, store_name)| {
let kind = classify(&store_name);
Artifact {
path,
store_name,
kind,
required: true,
}
})
.collect(),
)
}
pub fn is_empty(&self) -> bool {
self.outputs.is_empty()
}
pub fn push(&mut self, artifact: Artifact) {
self.outputs.push(artifact);
}
pub fn outputs(&self) -> &[Artifact] {
&self.outputs
}
pub fn total_size(&self) -> u64 {
self.outputs
.iter()
.map(|artifact| {
std::fs::metadata(&artifact.path)
.map(|m| m.len())
.unwrap_or(0)
})
.sum()
}
}
pub fn classify_by_filename(name: &str) -> ArtifactKind {
if name.ends_with(".dsym.tar") {
return ArtifactKind::DebugBundle;
}
let ext = std::path::Path::new(name)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("");
match ext {
"rlib" => ArtifactKind::Library,
"rmeta" => ArtifactKind::Metadata,
"d" | "pp" => ArtifactKind::DepInfo,
"o" | "obj" => ArtifactKind::Object,
"dylib" | "so" | "dll" => ArtifactKind::DynamicLibrary,
"wasm" => ArtifactKind::WasmModule,
"dwo" | "pdb" | "dSYM" => ArtifactKind::DebugSidecar,
"exe" => ArtifactKind::Executable,
"" => ArtifactKind::Other("extensionless"),
_ => ArtifactKind::Other("unknown-ext"),
}
}
pub use kache_format::GATED_EMIT_KINDS;
pub fn emit_kind_for_filename(name: &str) -> Option<&'static str> {
let ext = std::path::Path::new(name)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("");
match ext {
"rlib" | "so" | "dylib" | "dll" | "exe" | "a" | "lib" | "wasm" => Some("link"),
"rmeta" => Some("metadata"),
"o" | "obj" => Some("obj"),
"d" | "pp" => Some("dep-info"),
"s" | "asm" => Some("asm"),
"ll" => Some("llvm-ir"),
"bc" => Some("llvm-bc"),
"mir" => Some("mir"),
"" => Some("link"),
_ => None,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SigningPurpose {
OsLoading,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PostRestoreAction {
ExpandDepInfoPaths,
Sign(SigningPurpose),
UnpackDebugBundle,
}
pub fn plan_post_restore(kind: ArtifactKind) -> Vec<PostRestoreAction> {
let mut plan = Vec::new();
if matches!(kind, ArtifactKind::DepInfo) {
plan.push(PostRestoreAction::ExpandDepInfoPaths);
}
if matches!(
kind,
ArtifactKind::Executable | ArtifactKind::DynamicLibrary
) {
plan.push(PostRestoreAction::Sign(SigningPurpose::OsLoading));
}
if matches!(kind, ArtifactKind::DebugBundle) {
plan.push(PostRestoreAction::UnpackDebugBundle);
}
plan
}
impl PostRestoreAction {
pub fn is_content_transform(self) -> bool {
match self {
PostRestoreAction::ExpandDepInfoPaths => true,
PostRestoreAction::Sign(_) => false,
PostRestoreAction::UnpackDebugBundle => false,
}
}
pub fn transform(self, content: Vec<u8>, anchor: &std::path::Path) -> Vec<u8> {
match self {
PostRestoreAction::ExpandDepInfoPaths => {
match String::from_utf8(content) {
Ok(text) => crate::link::rewrite_depinfo_content(
&text,
anchor,
crate::link::DepInfoMode::Expand,
)
.into_bytes(),
Err(e) => e.into_bytes(),
}
}
PostRestoreAction::Sign(_) => content,
PostRestoreAction::UnpackDebugBundle => content,
}
}
pub fn apply(&self, path: &std::path::Path, platform: &dyn Platform) -> Result<()> {
match self {
PostRestoreAction::Sign(SigningPurpose::OsLoading) => {
platform.ensure_binary_loadable(path)
}
PostRestoreAction::UnpackDebugBundle => unpack_debug_bundle(path),
PostRestoreAction::ExpandDepInfoPaths => {
debug_assert!(
false,
"ExpandDepInfoPaths is a content transform; route it through transform()"
);
Ok(())
}
}
}
}
const MAX_DEBUG_BUNDLE_BYTES: u64 = 2_147_483_648;
fn unpack_debug_bundle(tar_path: &std::path::Path) -> Result<()> {
unpack_debug_bundle_with_cap(tar_path, MAX_DEBUG_BUNDLE_BYTES)
}
fn unpack_debug_bundle_with_cap(tar_path: &std::path::Path, max_bytes: u64) -> Result<()> {
use anyhow::Context as _;
let file_name = tar_path
.file_name()
.and_then(|n| n.to_str())
.with_context(|| {
format!(
"debug bundle has no usable file name: {}",
tar_path.display()
)
})?;
let stem = file_name
.strip_suffix(".dsym.tar")
.with_context(|| format!("debug bundle artifact is not a `.dsym.tar`: {}", file_name))?;
let parent = tar_path
.parent()
.with_context(|| format!("debug bundle has no parent dir: {}", tar_path.display()))?;
let bundle_dir = parent.join(format!("{stem}.dSYM"));
let tmp_dir = tempfile::Builder::new()
.prefix(".kache-dsym-")
.tempdir_in(parent)
.context("creating temp dir for debug bundle unpack")?;
let file = std::fs::File::open(tar_path)
.with_context(|| format!("opening debug bundle {}", tar_path.display()))?;
let mut archive = tar::Archive::new(file);
let mut total_bytes = 0u64;
for entry in archive.entries().context("reading debug bundle tar")? {
let mut entry = entry.context("reading debug bundle tar entry")?;
total_bytes = total_bytes.saturating_add(entry.size());
if total_bytes > max_bytes {
anyhow::bail!(
"debug bundle exceeds the {max_bytes}-byte extraction cap \
(corrupt or hostile archive)"
);
}
let path = entry
.path()
.context("debug bundle entry path")?
.to_path_buf();
if matches!(
path.components().next(),
Some(std::path::Component::RootDir | std::path::Component::Prefix(_))
) {
anyhow::bail!("debug bundle entry has absolute path: {}", path.display());
}
if path
.components()
.any(|c| c == std::path::Component::ParentDir)
{
anyhow::bail!("debug bundle entry has path traversal: {}", path.display());
}
let entry_type = entry.header().entry_type();
if entry_type.is_symlink() || entry_type.is_hard_link() {
anyhow::bail!(
"debug bundle entry is a link (rejected): {}",
path.display()
);
}
let dest = tmp_dir.path().join(&path);
if entry_type.is_dir() {
std::fs::create_dir_all(&dest)
.with_context(|| format!("creating {}", dest.display()))?;
continue;
}
if let Some(dir) = dest.parent() {
std::fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?;
}
entry
.unpack(&dest)
.with_context(|| format!("unpacking debug bundle entry {}", path.display()))?;
}
if bundle_dir.symlink_metadata().is_ok() {
if bundle_dir.is_dir() {
std::fs::remove_dir_all(&bundle_dir)
.with_context(|| format!("removing stale bundle {}", bundle_dir.display()))?;
} else {
std::fs::remove_file(&bundle_dir)
.with_context(|| format!("removing stale bundle {}", bundle_dir.display()))?;
}
}
let tmp_path = tmp_dir.keep();
std::fs::rename(&tmp_path, &bundle_dir).with_context(|| {
format!(
"publishing debug bundle {} -> {}",
tmp_path.display(),
bundle_dir.display()
)
})?;
Ok(())
}
pub trait Compiler {
type Parsed;
fn id(&self) -> CompilerId;
fn parse(&self, args: &[String]) -> Result<Self::Parsed>;
fn refuse_reasons(&self, parsed: &Self::Parsed) -> Vec<RefuseReason>;
fn cache_key(&self, parsed: &Self::Parsed, ctx: &KeyCtx<'_, '_>) -> Result<String>;
fn execute(&self, parsed: &Self::Parsed) -> Result<CompileResult>;
fn classify_output(&self, parsed: &Self::Parsed, name: &str) -> ArtifactKind;
}
pub const COMPILER_ADAPTERS: &[CompilerAdapter] = &[rustc::ADAPTER, cc::ADAPTER];
pub fn detect_compiler(args: &[String]) -> Option<&'static CompilerAdapter> {
COMPILER_ADAPTERS
.iter()
.find(|adapter| adapter.recognizes(args))
}
#[cfg(unix)]
fn is_executable(path: &std::path::Path) -> bool {
use std::os::unix::fs::PermissionsExt;
std::fs::metadata(path)
.map(|metadata| metadata.is_file() && metadata.permissions().mode() & 0o111 != 0)
.unwrap_or(false)
}
#[cfg(not(unix))]
fn is_executable(path: &std::path::Path) -> bool {
path.is_file()
}
pub(crate) fn is_kache_subcommand_or_flag(s: &str) -> bool {
if s.starts_with('-') {
return true;
}
use clap::CommandFactory;
let mut cmd = crate::Cli::command();
cmd.build();
cmd.find_subcommand(s).is_some()
}
pub(crate) fn is_version_or_info_query(args: &[String]) -> bool {
!args.is_empty()
&& args.iter().all(|a| {
matches!(
a.as_str(),
"-vV"
| "-V"
| "--version"
| "-dumpversion"
| "-dumpfullversion"
| "-dumpmachine"
| "-print-search-dirs"
| "--print-search-dirs"
)
})
}
pub(crate) fn resolve_program_on_path(program: &str) -> Option<std::path::PathBuf> {
let path = std::env::var_os("PATH");
let pathext = std::env::var_os("PATHEXT");
resolve_program_on_path_with(program, path.as_deref(), pathext.as_deref())
}
fn resolve_program_on_path_with(
program: &str,
path: Option<&std::ffi::OsStr>,
pathext: Option<&std::ffi::OsStr>,
) -> Option<std::path::PathBuf> {
if program.contains('/') || program.contains('\\') {
return Some(std::path::PathBuf::from(program));
}
let dirs: Vec<std::path::PathBuf> = std::env::split_paths(path?).collect();
let extensions: Vec<String> = if cfg!(windows) {
if let Some(pathext) = pathext {
std::env::split_paths(pathext)
.filter_map(|p| p.to_str().map(|s| s.to_string()))
.collect()
} else {
vec![
".exe".to_string(),
".bat".to_string(),
".cmd".to_string(),
".com".to_string(),
]
}
} else {
vec!["".to_string()]
};
for dir in dirs {
let p = dir.join(program);
if is_executable(&p) {
return Some(p);
}
for ext in &extensions {
if ext.is_empty() {
continue;
}
let mut suffixed = p.clone().into_os_string();
suffixed.push(ext);
let suffixed_path = std::path::PathBuf::from(suffixed);
if is_executable(&suffixed_path) {
return Some(suffixed_path);
}
}
}
None
}
fn is_program_on_path(program: &str) -> bool {
resolve_program_on_path(program).is_some()
}
pub(crate) fn is_passthrough_compiler_invocation(args: &[String]) -> bool {
let rustc = std::env::var_os("RUSTC");
is_passthrough_compiler_invocation_with(args, rustc.as_deref())
}
pub(crate) fn is_passthrough_compiler_invocation_with(
args: &[String],
configured_rustc: Option<&std::ffi::OsStr>,
) -> bool {
let Some(program) = args.first() else {
return false;
};
if is_kache_subcommand_or_flag(program) {
return false;
}
let is_configured_rustc =
configured_rustc.is_some_and(|rustc| rustc == std::ffi::OsStr::new(program.as_str()));
let is_nvcc = command_basename(program)
.map(strip_windows_exe_suffix)
.is_some_and(|name| name.eq_ignore_ascii_case("nvcc"));
is_configured_rustc || is_nvcc
}
pub fn is_workspace_wrapper_chain(args: &[String]) -> bool {
let workspace_wrapper = std::env::var_os("RUSTC_WORKSPACE_WRAPPER");
is_workspace_wrapper_chain_with(args, workspace_wrapper.as_deref(), is_program_on_path)
}
fn is_workspace_wrapper_chain_with(
args: &[String],
workspace_wrapper: Option<&std::ffi::OsStr>,
program_on_path: impl FnOnce(&str) -> bool,
) -> bool {
if args.len() < 2 || !rustc::RustcCompiler::recognizes(&args[1..]) {
return false;
}
if args[0].contains('/') || args[0].contains('\\') {
return true;
}
if workspace_wrapper.is_some_and(|wrapper| wrapper == std::ffi::OsStr::new(&args[0])) {
return true;
}
!is_kache_subcommand_or_flag(&args[0]) && program_on_path(&args[0])
}
pub(crate) fn command_basename(arg0: &str) -> Option<&str> {
arg0.rsplit(['/', '\\'])
.next()
.filter(|name| !name.is_empty())
}
pub(crate) fn strip_windows_exe_suffix(name: &str) -> &str {
let bytes = name.as_bytes();
if bytes.len() >= 4 && bytes[bytes.len() - 4..].eq_ignore_ascii_case(b".exe") {
&name[..bytes.len() - 4]
} else {
name
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn version_or_info_query_needs_every_arg_to_be_a_query_flag() {
let q = |a: &[&str]| {
is_version_or_info_query(&a.iter().map(|s| s.to_string()).collect::<Vec<_>>())
};
assert!(q(&["-vV"]));
assert!(q(&["--version"]));
assert!(q(&["-dumpmachine"]));
assert!(!q(&["-c", "hello.c", "-o", "-V"]));
assert!(!q(&["-MF", "--version"]));
assert!(!q(&["-vV", "hello.c"]));
assert!(!q(&[]));
}
#[test]
fn test_is_kache_subcommand_or_flag() {
assert!(is_kache_subcommand_or_flag("help"));
assert!(is_kache_subcommand_or_flag("-h"));
assert!(is_kache_subcommand_or_flag("--help"));
assert!(is_kache_subcommand_or_flag("-V"));
assert!(is_kache_subcommand_or_flag("--version"));
assert!(is_kache_subcommand_or_flag("gc"));
assert!(is_kache_subcommand_or_flag("list"));
assert!(!is_kache_subcommand_or_flag("not-a-subcommand"));
}
fn s(args: &[&str]) -> Vec<String> {
args.iter().map(|a| a.to_string()).collect()
}
#[test]
fn detect_compiler_returns_none_for_empty_argv() {
assert!(detect_compiler(&[]).is_none());
}
#[test]
fn detect_compiler_recognizes_rustc_paths() {
assert_eq!(
detect_compiler(&s(&["rustc"])).map(|adapter| adapter.id()),
Some(rustc::RUSTC_ID)
);
assert_eq!(
detect_compiler(&s(&["/usr/bin/rustc", "src/lib.rs"])).map(|adapter| adapter.id()),
Some(rustc::RUSTC_ID)
);
assert_eq!(
detect_compiler(&s(&["clippy-driver"])).map(|adapter| adapter.id()),
Some(rustc::RUSTC_ID)
);
assert_eq!(
detect_compiler(&s(&[
r"G:\.rustup\toolchains\nightly-x86_64-pc-windows-msvc\bin\clippy-driver.exe",
"rustc",
"-vV",
]))
.map(|adapter| adapter.id()),
Some(rustc::RUSTC_ID)
);
}
#[test]
fn detect_compiler_recognizes_cc_paths() {
assert_eq!(
detect_compiler(&s(&["cc"])).map(|adapter| adapter.id()),
Some(cc::CC_ID)
);
assert_eq!(
detect_compiler(&s(&["gcc"])).map(|adapter| adapter.id()),
Some(cc::CC_ID)
);
assert_eq!(
detect_compiler(&s(&["clang++"])).map(|adapter| adapter.id()),
Some(cc::CC_ID)
);
assert_eq!(
detect_compiler(&s(&["/usr/bin/cc", "-c", "foo.c"])).map(|adapter| adapter.id()),
Some(cc::CC_ID)
);
assert_eq!(
detect_compiler(&s(&[
"/opt/cross/bin/arm-linux-gnueabihf-gcc",
"-c",
"foo.c",
]))
.map(|adapter| adapter.id()),
Some(cc::CC_ID)
);
assert!(detect_compiler(&s(&["arm-linux-gnueabihf-gcc-ar"])).is_none());
}
#[test]
fn detect_compiler_returns_none_for_cc_probe_shape() {
assert!(detect_compiler(&s(&["-E", "/tmp/probe.c"])).is_none());
assert!(detect_compiler(&s(&["-E", "/tmp/detect_compiler_family.c"])).is_none());
}
#[test]
fn detect_compiler_returns_none_for_unrelated_argv() {
assert!(detect_compiler(&s(&["cargo", "build"])).is_none());
assert!(detect_compiler(&s(&["make"])).is_none());
assert!(detect_compiler(&s(&["ld"])).is_none());
assert!(detect_compiler(&s(&["--crate-name"])).is_none());
}
#[test]
fn workspace_wrapper_chain_detects_unrecognized_drivers() {
assert!(is_workspace_wrapper_chain(&s(&[
"/Users/dev/.dylint_drivers/nightly/dylint-driver",
"rustc",
"--crate-name",
])));
assert!(is_workspace_wrapper_chain(&s(&[
r"C:\tools\custom-driver.exe",
"rustc",
])));
}
#[test]
fn workspace_wrapper_chain_detects_bare_name_via_env() {
let args = s(&["mydriver", "rustc"]);
assert!(is_workspace_wrapper_chain_with(
&args,
Some(std::ffi::OsStr::new("mydriver")),
|_| false,
));
assert!(!is_workspace_wrapper_chain_with(
&args,
Some(std::ffi::OsStr::new("other-driver")),
|_| false,
));
}
#[test]
fn workspace_wrapper_chain_detects_bare_name_via_path() {
use std::fs::File;
let temp_dir = tempfile::TempDir::new().unwrap();
let wrapper_name = "custom-wrapper-test-executable";
#[cfg(windows)]
{
let wrapper_path_exe = temp_dir.path().join(format!("{}.exe", wrapper_name));
File::create(&wrapper_path_exe).unwrap();
}
#[cfg(not(windows))]
{
let wrapper_path = temp_dir.path().join(wrapper_name);
File::create(&wrapper_path).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(&wrapper_path).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&wrapper_path, perms).unwrap();
}
}
let test_path = std::env::join_paths([temp_dir.path()]).unwrap();
assert!(is_workspace_wrapper_chain_with(
&s(&[wrapper_name, "rustc"]),
None,
|program| {
resolve_program_on_path_with(program, Some(test_path.as_os_str()), None).is_some()
},
));
}
#[test]
fn workspace_wrapper_chain_rejects_non_paths() {
for subcommand in ["init", "gc", "doctor", "config", "report"] {
assert!(!is_workspace_wrapper_chain_with(
&s(&[subcommand, "rustc"]),
None,
|_| true,
));
}
assert!(!is_workspace_wrapper_chain_with(
&s(&["nonexistentwrappername12345", "rustc"]),
None,
|_| false,
));
assert!(!is_workspace_wrapper_chain(&s(&["/usr/bin/cc", "file.c"])));
assert!(!is_workspace_wrapper_chain(&s(&["cargo", "build"])));
assert!(!is_workspace_wrapper_chain(&s(&["/usr/bin/rustc"])));
}
#[test]
fn passthrough_compiler_accepts_configured_rustc_and_nvcc() {
assert!(is_passthrough_compiler_invocation_with(
&s(&["/home/user/.kani/kani-0.67.0/bin/kani-compiler", "-vV"]),
Some(std::ffi::OsStr::new(
"/home/user/.kani/kani-0.67.0/bin/kani-compiler",
)),
));
assert!(is_passthrough_compiler_invocation_with(
&s(&["custom-rustc-driver", "--crate-name", "demo"]),
Some(std::ffi::OsStr::new("custom-rustc-driver")),
));
assert!(is_passthrough_compiler_invocation_with(
&s(&[r"C:\CUDA\bin\nvcc.exe", "-c", "kernel.cu"]),
None,
));
assert!(is_passthrough_compiler_invocation_with(
&s(&["nvcc", "-c", "kernel.cu"]),
None,
));
}
#[test]
fn passthrough_compiler_rejects_unrelated_programs() {
assert!(!is_passthrough_compiler_invocation(&s(&["gc"])));
assert!(!is_passthrough_compiler_invocation_with(&[], None));
assert!(!is_passthrough_compiler_invocation_with(
&s(&["stat"]),
None,
));
assert!(!is_passthrough_compiler_invocation_with(
&s(&["/usr/bin/stat"]),
Some(std::ffi::OsStr::new("/usr/bin/other-driver")),
));
assert!(!is_passthrough_compiler_invocation_with(
&s(&["gc"]),
Some(std::ffi::OsStr::new("gc")),
));
}
#[test]
fn command_basename_splits_both_separators() {
assert_eq!(command_basename("rustc"), Some("rustc"));
assert_eq!(command_basename("/usr/bin/rustc"), Some("rustc"));
assert_eq!(
command_basename(r"G:\bin\clippy-driver.exe"),
Some("clippy-driver.exe")
);
assert_eq!(command_basename(r"C:\a/b\c.exe"), Some("c.exe"));
assert_eq!(command_basename("/usr/bin/"), None);
assert_eq!(command_basename(r"C:\bin\"), None);
assert_eq!(command_basename(""), None);
}
#[test]
fn strip_windows_exe_suffix_is_case_insensitive_and_optional() {
assert_eq!(strip_windows_exe_suffix("rustc.exe"), "rustc");
assert_eq!(
strip_windows_exe_suffix("clippy-driver.EXE"),
"clippy-driver"
);
assert_eq!(strip_windows_exe_suffix("rustc"), "rustc");
assert_eq!(strip_windows_exe_suffix("a.exe.b"), "a.exe.b");
assert_eq!(strip_windows_exe_suffix(".ex"), ".ex");
}
#[test]
fn plan_post_restore_dep_info_expands_paths() {
assert_eq!(
plan_post_restore(ArtifactKind::DepInfo),
vec![PostRestoreAction::ExpandDepInfoPaths]
);
}
#[test]
fn plan_post_restore_executable_signs_for_os_loading() {
assert_eq!(
plan_post_restore(ArtifactKind::Executable),
vec![PostRestoreAction::Sign(SigningPurpose::OsLoading)]
);
}
#[test]
fn plan_post_restore_dynamic_library_signs_for_os_loading() {
assert_eq!(
plan_post_restore(ArtifactKind::DynamicLibrary),
vec![PostRestoreAction::Sign(SigningPurpose::OsLoading)]
);
}
#[test]
fn plan_post_restore_object_is_empty() {
assert!(plan_post_restore(ArtifactKind::Object).is_empty());
}
#[test]
fn plan_post_restore_passive_kinds_are_empty() {
for kind in [
ArtifactKind::Library,
ArtifactKind::Metadata,
ArtifactKind::DebugSidecar,
ArtifactKind::WasmModule,
ArtifactKind::Other("test"),
] {
assert!(
plan_post_restore(kind).is_empty(),
"{kind:?} should have no post-restore actions"
);
}
}
#[test]
fn plan_post_restore_debug_bundle_unpacks_exactly() {
assert_eq!(
plan_post_restore(ArtifactKind::DebugBundle),
vec![PostRestoreAction::UnpackDebugBundle]
);
}
#[test]
fn expand_dep_info_paths_is_a_content_transform() {
assert!(PostRestoreAction::ExpandDepInfoPaths.is_content_transform());
assert!(!PostRestoreAction::Sign(SigningPurpose::OsLoading).is_content_transform());
assert!(!PostRestoreAction::UnpackDebugBundle.is_content_transform());
let bytes = b"tar bytes".to_vec();
assert_eq!(
PostRestoreAction::UnpackDebugBundle
.transform(bytes.clone(), std::path::Path::new("/anchor")),
bytes
);
}
#[test]
fn transform_expand_dep_info_paths_roots_relative_paths_at_anchor() {
let blob = b"__kache_root__/target/debug/foo: __kache_root__/src/lib.rs".to_vec();
let anchor = std::path::Path::new("/restored/worktree");
let out = PostRestoreAction::ExpandDepInfoPaths.transform(blob, anchor);
let content = String::from_utf8(out).unwrap();
assert!(
content.contains("/restored/worktree/target/debug/foo"),
"expected anchor-rooted target path, got: {content}"
);
assert!(
content.contains("/restored/worktree/src/lib.rs"),
"expected anchor-rooted source path, got: {content}"
);
assert!(
!content.contains("__kache_root__/"),
"no kache dep-info markers should remain, got: {content}"
);
}
#[test]
fn transform_expand_dep_info_paths_preserves_parent_relative_deps() {
let blob =
b"foo.o: ../../src/foo.cc ../include/foo.h __kache_root__/generated/header.h".to_vec();
let anchor = std::path::Path::new("/restored/worktree/obj");
let out = PostRestoreAction::ExpandDepInfoPaths.transform(blob, anchor);
let content = String::from_utf8(out).unwrap();
assert!(
content.contains("../../src/foo.cc"),
"compiler-emitted parent-relative source paths must survive: {content}"
);
assert!(
content.contains("../include/foo.h"),
"compiler-emitted parent-relative header paths must survive: {content}"
);
assert!(
content.contains("/restored/worktree/obj/generated/header.h"),
"kache sentinel paths should still expand: {content}"
);
}
#[test]
fn transform_expand_dep_info_paths_passes_through_non_utf8() {
let blob = vec![0xff, 0xfe, 0x00, 0x42];
let out = PostRestoreAction::ExpandDepInfoPaths
.transform(blob.clone(), std::path::Path::new("/anchor"));
assert_eq!(out, blob);
}
#[test]
fn apply_sign_os_loading_routes_through_platform() {
use crate::compiler::platform::tests::CountingPlatform;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("not-actually-a-binary");
std::fs::write(&path, b"definitely not Mach-O").unwrap();
let platform = CountingPlatform::new();
PostRestoreAction::Sign(SigningPurpose::OsLoading)
.apply(&path, &platform)
.expect("apply must not error even when the platform impl is a no-op");
assert_eq!(
platform.ensure_calls(),
1,
"Sign(OsLoading) must dispatch to platform.ensure_binary_loadable exactly once"
);
}
fn synthetic_tar(entries: &[(&str, &[u8])]) -> Vec<u8> {
let mut builder = tar::Builder::new(Vec::new());
for (path, content) in entries {
let mut header = tar::Header::new_gnu();
header.set_size(content.len() as u64);
header.set_mode(0o644);
header.set_mtime(0);
header.set_entry_type(tar::EntryType::Regular);
builder
.append_data(&mut header, path, &content[..])
.unwrap();
}
builder.into_inner().unwrap()
}
#[test]
fn apply_unpack_debug_bundle_creates_sibling_dsym_dir() {
use crate::compiler::platform::tests::CountingPlatform;
let dir = tempfile::tempdir().unwrap();
let tar_path = dir.path().join("foo-abc123.dsym.tar");
std::fs::write(
&tar_path,
synthetic_tar(&[
("Contents/Info.plist", b"plist"),
("Contents/Resources/DWARF/foo-abc123", b"dwarf bytes"),
]),
)
.unwrap();
PostRestoreAction::UnpackDebugBundle
.apply(&tar_path, &CountingPlatform::new())
.unwrap();
let bundle = dir.path().join("foo-abc123.dSYM");
assert_eq!(
std::fs::read(bundle.join("Contents/Resources/DWARF/foo-abc123")).unwrap(),
b"dwarf bytes"
);
assert_eq!(
std::fs::read(bundle.join("Contents/Info.plist")).unwrap(),
b"plist"
);
assert!(tar_path.is_file(), "the restored tar must not be deleted");
}
#[test]
fn apply_unpack_debug_bundle_replaces_stale_bundle() {
use crate::compiler::platform::tests::CountingPlatform;
let dir = tempfile::tempdir().unwrap();
let bundle = dir.path().join("foo.dSYM");
std::fs::create_dir_all(bundle.join("Contents")).unwrap();
std::fs::write(bundle.join("Contents/stale"), b"old").unwrap();
let tar_path = dir.path().join("foo.dsym.tar");
std::fs::write(
&tar_path,
synthetic_tar(&[("Contents/Resources/DWARF/foo", b"new dwarf")]),
)
.unwrap();
PostRestoreAction::UnpackDebugBundle
.apply(&tar_path, &CountingPlatform::new())
.unwrap();
assert!(
!bundle.join("Contents/stale").exists(),
"a stale bundle must be replaced wholesale, not merged — lldb \
would otherwise trust leftover files from another build"
);
assert_eq!(
std::fs::read(bundle.join("Contents/Resources/DWARF/foo")).unwrap(),
b"new dwarf"
);
}
fn forged_tar_with_entry_name(name: &[u8]) -> Vec<u8> {
let mut header = tar::Header::new_gnu();
header.set_size(5);
header.set_mode(0o644);
header.set_entry_type(tar::EntryType::Regular);
let mut builder = tar::Builder::new(Vec::new());
builder
.append_data(&mut header, "placeholder", &b"pwned"[..])
.unwrap();
let mut bytes = builder.into_inner().unwrap();
assert!(name.len() < 100, "GNU tar name field is 100 bytes");
bytes[..name.len()].copy_from_slice(name);
bytes[name.len()..100].fill(0);
let mut patched = tar::Header::new_gnu();
patched.as_mut_bytes().copy_from_slice(&bytes[..512]);
patched.set_cksum();
bytes[..512].copy_from_slice(patched.as_bytes());
bytes
}
#[test]
fn apply_unpack_debug_bundle_rejects_path_traversal() {
use crate::compiler::platform::tests::CountingPlatform;
let dir = tempfile::tempdir().unwrap();
let outdir = dir.path().join("deps");
std::fs::create_dir_all(&outdir).unwrap();
let tar_path = outdir.join("evil.dsym.tar");
std::fs::write(&tar_path, forged_tar_with_entry_name(b"../escaped-file")).unwrap();
let err = PostRestoreAction::UnpackDebugBundle
.apply(&tar_path, &CountingPlatform::new())
.unwrap_err()
.to_string();
assert!(
err.contains("path traversal"),
"a `..` entry must be rejected, got: {err}"
);
assert!(
!dir.path().join("escaped-file").exists(),
"nothing may be written outside the temp extraction dir"
);
assert!(
!outdir.join("evil.dSYM").exists(),
"a rejected archive must not publish a bundle"
);
}
#[test]
fn apply_unpack_debug_bundle_rejects_absolute_entry() {
use crate::compiler::platform::tests::CountingPlatform;
let dir = tempfile::tempdir().unwrap();
let tar_path = dir.path().join("abs.dsym.tar");
std::fs::write(
&tar_path,
forged_tar_with_entry_name(b"/tmp/kache-absolute-escape"),
)
.unwrap();
let err = PostRestoreAction::UnpackDebugBundle
.apply(&tar_path, &CountingPlatform::new())
.unwrap_err()
.to_string();
assert!(
err.contains("absolute path"),
"an absolute entry must be rejected, got: {err}"
);
}
#[test]
fn apply_unpack_debug_bundle_rejects_non_dsym_tar_name() {
use crate::compiler::platform::tests::CountingPlatform;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("foo.tar");
std::fs::write(&path, synthetic_tar(&[("Contents/x", b"y")])).unwrap();
let err = PostRestoreAction::UnpackDebugBundle
.apply(&path, &CountingPlatform::new())
.unwrap_err()
.to_string();
assert!(err.contains(".dsym.tar"), "got: {err}");
}
#[test]
fn apply_unpack_debug_bundle_rejects_each_link_kind_alone() {
use crate::compiler::platform::tests::CountingPlatform;
for entry_type in [tar::EntryType::Symlink, tar::EntryType::Link] {
let dir = tempfile::tempdir().unwrap();
let tar_path = dir.path().join("linky.dsym.tar");
let mut header = tar::Header::new_gnu();
header.set_size(0);
header.set_mode(0o644);
header.set_entry_type(entry_type);
let mut builder = tar::Builder::new(Vec::new());
builder
.append_link(&mut header, "Contents/evil", "/etc/passwd")
.unwrap();
std::fs::write(&tar_path, builder.into_inner().unwrap()).unwrap();
let err = PostRestoreAction::UnpackDebugBundle
.apply(&tar_path, &CountingPlatform::new())
.unwrap_err()
.to_string();
assert!(
err.contains("is a link"),
"{entry_type:?} alone must be rejected, got: {err}"
);
}
}
#[test]
fn unpack_debug_bundle_cap_boundary_is_exact() {
let payload = vec![b'x'; 100];
let dir = tempfile::tempdir().unwrap();
let tar_path = dir.path().join("capped.dsym.tar");
std::fs::write(
&tar_path,
synthetic_tar(&[("Contents/blob", payload.as_slice())]),
)
.unwrap();
let err = unpack_debug_bundle_with_cap(&tar_path, 99)
.unwrap_err()
.to_string();
assert!(err.contains("extraction cap"), "got: {err}");
assert!(!dir.path().join("capped.dSYM").exists());
unpack_debug_bundle_with_cap(&tar_path, 100)
.expect("a bundle exactly at the cap is within budget");
assert!(dir.path().join("capped.dSYM/Contents/blob").exists());
}
#[test]
fn artifact_set_push_appends_the_artifact() {
let mut set = ArtifactSet::empty();
set.push(Artifact {
path: std::path::PathBuf::from("/tmp/x.dsym.tar"),
store_name: "x.dsym.tar".to_string(),
kind: ArtifactKind::DebugBundle,
required: false,
});
assert_eq!(set.outputs().len(), 1);
assert_eq!(set.outputs()[0].store_name, "x.dsym.tar");
assert_eq!(set.outputs()[0].kind, ArtifactKind::DebugBundle);
}
#[test]
fn rustc_classify_to_plan_chain_for_typical_lib_build() {
use crate::compiler::rustc::RustcCompiler;
let compiler = RustcCompiler::new();
let lib_args = compiler
.parse(&[
"rustc".into(),
"src/lib.rs".into(),
"--crate-name".into(),
"foo".into(),
"--crate-type".into(),
"lib".into(),
])
.unwrap();
let cases: &[(&str, Vec<PostRestoreAction>)] = &[
("libfoo-abc.rlib", vec![]),
("libfoo-abc.rmeta", vec![]),
("foo-abc.d", vec![PostRestoreAction::ExpandDepInfoPaths]),
("foo-abc.rcgu.o", vec![]),
("foo-abc.dwo", vec![]),
];
for (name, expected) in cases {
let kind = compiler.classify_output(&lib_args, name);
assert_eq!(
&plan_post_restore(kind),
expected,
"for {name}: kind = {kind:?}"
);
}
}
#[test]
fn classify_by_filename_recognizes_known_extensions() {
assert_eq!(
classify_by_filename("libfoo-abc.rlib"),
ArtifactKind::Library
);
assert_eq!(
classify_by_filename("libfoo-abc.rmeta"),
ArtifactKind::Metadata
);
assert_eq!(classify_by_filename("foo-abc.d"), ArtifactKind::DepInfo);
assert_eq!(
classify_by_filename("host_pathsub.o.pp"),
ArtifactKind::DepInfo
);
assert_eq!(classify_by_filename("foo.o"), ArtifactKind::Object);
assert_eq!(
classify_by_filename("foo-abc.123.rcgu.o"),
ArtifactKind::Object
);
assert_eq!(classify_by_filename("foo.obj"), ArtifactKind::Object);
assert_eq!(
classify_by_filename("libfoo.dylib"),
ArtifactKind::DynamicLibrary
);
assert_eq!(
classify_by_filename("libfoo.so"),
ArtifactKind::DynamicLibrary
);
assert_eq!(
classify_by_filename("rococo_runtime.wasm"),
ArtifactKind::WasmModule
);
assert_eq!(
classify_by_filename("foo.dll"),
ArtifactKind::DynamicLibrary
);
assert_eq!(
classify_by_filename("foo-abc.dwo"),
ArtifactKind::DebugSidecar
);
assert_eq!(classify_by_filename("foo.pdb"), ArtifactKind::DebugSidecar);
assert_eq!(classify_by_filename("foo.exe"), ArtifactKind::Executable);
assert_eq!(
classify_by_filename("foo-abc123.dsym.tar"),
ArtifactKind::DebugBundle
);
assert_eq!(
classify_by_filename("foo.tar"),
ArtifactKind::Other("unknown-ext")
);
assert_eq!(
ArtifactKind::DebugBundle.link_strategy(),
LinkStrategy::Hardlink
);
}
#[test]
fn classify_by_filename_distinguishes_extensionless_from_unknown() {
match classify_by_filename("my_bin-abc123") {
ArtifactKind::Other("extensionless") => {}
other => panic!("expected Other(extensionless), got {other:?}"),
}
match classify_by_filename("foo.lock") {
ArtifactKind::Other("unknown-ext") => {}
other => panic!("expected Other(unknown-ext), got {other:?}"),
}
}
#[test]
fn emit_kind_for_filename_maps_outputs() {
let cases = [
("libfoo-abc.rlib", Some("link")),
("libfoo.so", Some("link")),
("libfoo.dylib", Some("link")),
("foo.dll", Some("link")),
("foo.exe", Some("link")),
("rococo_runtime.wasm", Some("link")),
("my_bin-abc123", Some("link")), ("libfoo-abc.rmeta", Some("metadata")),
("foo-abc.123.rcgu.o", Some("obj")),
("foo.obj", Some("obj")),
("foo-abc.d", Some("dep-info")),
("foo.s", Some("asm")),
("foo.ll", Some("llvm-ir")),
("foo.bc", Some("llvm-bc")),
("foo.mir", Some("mir")),
("foo.dwo", None),
("foo.pdb", None),
("foo.lock", None),
("foo-abc.dsym.tar", None),
];
for (name, expected) in cases {
assert_eq!(emit_kind_for_filename(name), expected, "for {name}");
if let Some(kind) = expected {
assert!(
GATED_EMIT_KINDS.contains(&kind),
"{kind} (from {name}) must be in GATED_EMIT_KINDS"
);
}
}
}
#[test]
fn rustc_classify_to_plan_chain_for_typical_bin_build() {
use crate::compiler::rustc::RustcCompiler;
let compiler = RustcCompiler::new();
let bin_args = compiler
.parse(&[
"rustc".into(),
"src/main.rs".into(),
"--crate-name".into(),
"foo".into(),
"--crate-type".into(),
"bin".into(),
])
.unwrap();
let cases: &[(&str, Vec<PostRestoreAction>)] = &[
(
"foo-abc",
vec![PostRestoreAction::Sign(SigningPurpose::OsLoading)],
),
("foo-abc.d", vec![PostRestoreAction::ExpandDepInfoPaths]),
("foo-abc.rcgu.o", vec![]),
("foo-abc.dwo", vec![]),
(
"foo-abc.dsym.tar",
vec![PostRestoreAction::UnpackDebugBundle],
),
];
for (name, expected) in cases {
let kind = compiler.classify_output(&bin_args, name);
assert_eq!(
&plan_post_restore(kind),
expected,
"for {name}: kind = {kind:?}"
);
}
}
}
pub(crate) mod shim {
use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
#[cfg_attr(not(unix), allow(dead_code))]
pub(crate) const SHIM_NAMES: &[&str] = &["cc", "c++", "gcc", "g++", "clang", "clang++"];
pub(crate) fn invoked_as_compiler(arg0: &str) -> bool {
super::cc::CcCompiler::recognizes(std::slice::from_ref(&arg0.to_string()))
}
pub(crate) fn resolve_real_compiler(
name: &str,
path_dirs: &[PathBuf],
self_exe: Option<&Path>,
is_candidate: &dyn Fn(&Path) -> bool,
resolve: &dyn Fn(&Path) -> Option<PathBuf>,
) -> Option<PathBuf> {
let self_real = self_exe.and_then(resolve);
for dir in path_dirs {
let candidate = dir.join(name);
if !is_candidate(&candidate) {
continue;
}
if let (Some(real), Some(mine)) = (resolve(&candidate), self_real.as_deref())
&& real == mine
{
continue;
}
return Some(candidate);
}
None
}
pub(crate) fn resolve_real_compiler_from_env(name: &str) -> Option<PathBuf> {
let path = std::env::var_os("PATH")?;
let dirs: Vec<PathBuf> = std::env::split_paths(&path).collect();
let self_exe = std::env::current_exe().ok();
resolve_real_compiler(
name,
&dirs,
self_exe.as_deref(),
&|candidate| super::is_executable(candidate),
&|path| std::fs::canonicalize(path).ok(),
)
}
pub(crate) fn default_shim_dir() -> PathBuf {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".local/lib/kache/shims")
}
pub(crate) fn system_shim_dir() -> PathBuf {
PathBuf::from("/usr/lib/kache")
}
pub(crate) fn extra_compiler_names(
path_dirs: &[PathBuf],
self_exe: Option<&Path>,
is_candidate: &dyn Fn(&Path) -> bool,
resolve: &dyn Fn(&Path) -> Option<PathBuf>,
) -> Vec<String> {
let self_real = self_exe.and_then(resolve);
let mut names = BTreeSet::new();
for dir in path_dirs {
let Ok(entries) = std::fs::read_dir(dir) else {
continue;
};
for entry in entries.flatten() {
let path = entry.path();
if !is_candidate(&path) {
continue;
}
if let (Some(real), Some(mine)) = (resolve(&path), self_real.as_deref())
&& real == mine
{
continue;
}
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
if SHIM_NAMES.contains(&name) {
continue;
}
if invoked_as_compiler(name) {
names.insert(name.to_string());
}
}
}
names.into_iter().collect()
}
#[cfg_attr(not(unix), allow(dead_code))]
pub(crate) fn extra_compiler_names_from_env() -> Vec<String> {
let path = std::env::var_os("PATH").unwrap_or_default();
let dirs: Vec<PathBuf> = std::env::split_paths(&path).collect();
let self_exe = std::env::current_exe().ok();
extra_compiler_names(
&dirs,
self_exe.as_deref(),
&|candidate| super::is_executable(candidate),
&|path| std::fs::canonicalize(path).ok(),
)
}
pub(crate) struct ShimPathStatus {
pub on_path: bool,
pub detail: String,
pub fix: Option<String>,
}
fn dir_holds_kache_shims(
dir: &Path,
self_real: Option<&Path>,
resolve: &dyn Fn(&Path) -> Option<PathBuf>,
) -> bool {
let Some(mine) = self_real else {
return false;
};
SHIM_NAMES
.iter()
.any(|name| resolve(&dir.join(name)).is_some_and(|real| real == mine))
}
pub(crate) fn shim_path_status(
path_dirs: &[PathBuf],
self_exe: Option<&Path>,
installed_dirs: &[PathBuf],
is_candidate: &dyn Fn(&Path) -> bool,
resolve: &dyn Fn(&Path) -> Option<PathBuf>,
) -> ShimPathStatus {
let self_real = self_exe.and_then(resolve);
for name in SHIM_NAMES {
for dir in path_dirs {
let candidate = dir.join(name);
if !is_candidate(&candidate) {
continue;
}
if let (Some(real), Some(mine)) = (resolve(&candidate), self_real.as_deref())
&& real == mine
{
return ShimPathStatus {
on_path: true,
detail: format!("{name} on PATH is a kache shim ({})", dir.display()),
fix: None,
};
}
break;
}
}
let installed = installed_dirs
.iter()
.find(|dir| dir_holds_kache_shims(dir, self_real.as_deref(), resolve));
if let Some(dir) = installed {
return ShimPathStatus {
on_path: false,
detail: format!("installed at {}, not first on PATH", dir.display()),
fix: Some(format!("export PATH=\"{}:$PATH\"", dir.display())),
};
}
let default = default_shim_dir();
ShimPathStatus {
on_path: false,
detail: "not installed".into(),
fix: Some(format!(
"kache install-shims && export PATH=\"{}:$PATH\"",
default.display()
)),
}
}
pub(crate) fn live_shim_path_status() -> ShimPathStatus {
let path = std::env::var_os("PATH").unwrap_or_default();
let dirs: Vec<PathBuf> = std::env::split_paths(&path).collect();
let self_exe = std::env::current_exe().ok();
let default = default_shim_dir();
let system = system_shim_dir();
let installed = [default, system];
shim_path_status(
&dirs,
self_exe.as_deref(),
&installed,
&|candidate| super::is_executable(candidate),
&|path| std::fs::canonicalize(path).ok(),
)
}
pub(crate) fn wrapper_args(argv: &[String]) -> Option<Result<Vec<String>, String>> {
let arg0 = argv.first()?;
if !invoked_as_compiler(arg0) {
return None;
}
let name = super::command_basename(arg0)?;
let Some(real) = resolve_real_compiler_from_env(name) else {
return Some(Err(format!(
"kache was invoked through a compiler shim named `{name}`, but no real `{name}` \
was found on PATH behind it. Every `{name}` on PATH resolves to kache itself, \
so there is nothing to run. Check that the real toolchain is still on PATH \
after the shim directory."
)));
};
let Some(real) = real.to_str() else {
return Some(Err(format!(
"the real `{name}` behind the shim has a non-UTF-8 path and cannot be wrapped \
safely"
)));
};
let mut rewritten = Vec::with_capacity(argv.len());
rewritten.push(real.to_string());
rewritten.extend_from_slice(&argv[1..]);
Some(Ok(rewritten))
}
}
#[cfg(test)]
mod shim_tests {
use super::shim::*;
use std::path::{Path, PathBuf};
#[test]
fn compiler_shaped_argv0_is_recognized_but_kache_itself_is_not() {
for name in ["cc", "gcc", "g++", "clang++", "/usr/local/bin/gcc"] {
assert!(invoked_as_compiler(name), "{name} should look like a shim");
}
assert!(invoked_as_compiler("x86_64-linux-gnu-gcc"));
assert!(invoked_as_compiler("gcc-13"));
assert!(!invoked_as_compiler("kache"));
assert!(!invoked_as_compiler("/usr/local/bin/kache"));
assert!(!invoked_as_compiler("gcc-ar"));
}
#[test]
fn resolve_skips_every_path_entry_that_is_kache_itself() {
let kache = PathBuf::from("/opt/kache/bin/kache");
let shim_a = PathBuf::from("/shims-a");
let shim_b = PathBuf::from("/shims-b");
let real = PathBuf::from("/usr/bin");
let resolve = |path: &Path| -> Option<PathBuf> {
if path.starts_with("/shims-a") || path.starts_with("/shims-b") {
Some(kache.clone())
} else {
Some(path.to_path_buf())
}
};
let exists = |_: &Path| true;
let found = resolve_real_compiler(
"cc",
&[shim_a, shim_b, real],
Some(&kache),
&exists,
&resolve,
);
assert_eq!(found, Some(PathBuf::from("/usr/bin/cc")));
}
#[test]
fn resolve_returns_none_when_only_shims_are_on_path() {
let kache = PathBuf::from("/opt/kache/bin/kache");
let found = resolve_real_compiler(
"cc",
&[PathBuf::from("/shims")],
Some(&kache),
&|_| true,
&|_| Some(kache.clone()),
);
assert_eq!(found, None, "must report no real compiler, not recurse");
}
#[test]
fn resolve_skips_non_executable_candidates() {
let kache = PathBuf::from("/opt/kache/bin/kache");
let found = resolve_real_compiler(
"cc",
&[PathBuf::from("/not-exec"), PathBuf::from("/usr/bin")],
Some(&kache),
&|path: &Path| path.starts_with("/usr/bin"),
&|path: &Path| Some(path.to_path_buf()),
);
assert_eq!(found, Some(PathBuf::from("/usr/bin/cc")));
}
#[cfg(unix)]
struct PathForTest(Option<std::ffi::OsString>);
#[cfg(unix)]
impl Drop for PathForTest {
fn drop(&mut self) {
match self.0.take() {
Some(previous) => unsafe { std::env::set_var("PATH", previous) },
None => unsafe { std::env::remove_var("PATH") },
}
}
}
#[cfg(unix)]
#[test]
fn live_resolution_finds_the_real_compiler_behind_a_real_shim() {
use std::os::unix::fs::PermissionsExt;
let _lock = crate::config::tests::config_path_lock();
let dir = tempfile::tempdir().unwrap();
let shim_dir = dir.path().join("shims");
let real_dir = dir.path().join("real");
std::fs::create_dir_all(&shim_dir).unwrap();
std::fs::create_dir_all(&real_dir).unwrap();
let real_cc = real_dir.join("cc");
std::fs::write(&real_cc, "#!/bin/sh\nexit 0\n").unwrap();
std::fs::set_permissions(&real_cc, std::fs::Permissions::from_mode(0o755)).unwrap();
let exe = std::env::current_exe().unwrap();
std::os::unix::fs::symlink(&exe, shim_dir.join("cc")).unwrap();
let _path = PathForTest(std::env::var_os("PATH"));
unsafe {
std::env::set_var(
"PATH",
format!("{}:{}", shim_dir.display(), real_dir.display()),
)
};
let found = resolve_real_compiler_from_env("cc").expect("the real cc must be found");
assert_eq!(
std::fs::canonicalize(&found).unwrap(),
std::fs::canonicalize(&real_cc).unwrap(),
"must skip the shim and select the real compiler"
);
let rewritten = wrapper_args(&["cc".to_string(), "foo.c".to_string()])
.expect("a compiler-shaped argv0 is a shim invocation")
.expect("resolution succeeds");
assert_eq!(
std::fs::canonicalize(&rewritten[0]).unwrap(),
std::fs::canonicalize(&real_cc).unwrap(),
"the rewritten argv must run the real compiler"
);
assert_eq!(
&rewritten[1..],
&["foo.c".to_string()],
"the original arguments must be preserved verbatim"
);
}
#[cfg(unix)]
#[test]
fn live_resolution_reports_when_only_the_shim_is_on_path() {
let _lock = crate::config::tests::config_path_lock();
let dir = tempfile::tempdir().unwrap();
let shim_dir = dir.path().join("shims");
std::fs::create_dir_all(&shim_dir).unwrap();
let exe = std::env::current_exe().unwrap();
std::os::unix::fs::symlink(&exe, shim_dir.join("cc")).unwrap();
let _path = PathForTest(std::env::var_os("PATH"));
unsafe { std::env::set_var("PATH", format!("{}", shim_dir.display())) };
assert_eq!(resolve_real_compiler_from_env("cc"), None);
let err = wrapper_args(&["cc".to_string(), "foo.c".to_string()])
.expect("still a shim invocation")
.expect_err("but with no compiler to run");
assert!(err.contains("no real `cc`"), "unexpected message: {err}");
}
#[test]
fn non_shim_argv_is_left_alone() {
assert!(wrapper_args(&["kache".into(), "gcc".into(), "a.c".into()]).is_none());
assert!(wrapper_args(&["kache".into(), "stats".into()]).is_none());
assert!(wrapper_args(&[]).is_none());
}
#[test]
fn shim_path_status_passes_when_the_first_gcc_is_kache() {
let kache = PathBuf::from("/opt/kache/bin/kache");
let shims = PathBuf::from("/shims");
let real = PathBuf::from("/usr/bin");
let resolve = |path: &Path| -> Option<PathBuf> {
if path.starts_with("/shims") {
Some(kache.clone())
} else {
Some(path.to_path_buf())
}
};
let status = shim_path_status(&[shims, real], Some(&kache), &[], &|_| true, &resolve);
assert!(status.on_path, "{}", status.detail);
assert!(status.detail.contains("/shims"), "{}", status.detail);
assert!(status.fix.is_none());
}
#[test]
fn shim_path_status_reports_installed_farm_that_is_not_on_path() {
let kache = PathBuf::from("/opt/kache/bin/kache");
let farm = PathBuf::from("/home/user/.local/lib/kache/shims");
let real = PathBuf::from("/usr/bin");
let resolve = |path: &Path| -> Option<PathBuf> {
if path.starts_with(&farm) {
Some(kache.clone())
} else {
Some(path.to_path_buf())
}
};
let status = shim_path_status(
&[real],
Some(&kache),
std::slice::from_ref(&farm),
&|_| true,
&resolve,
);
assert!(!status.on_path);
assert!(
status.detail.contains("not first on PATH"),
"{}",
status.detail
);
assert_eq!(
status.fix.as_deref(),
Some("export PATH=\"/home/user/.local/lib/kache/shims:$PATH\"")
);
}
#[test]
fn shim_path_status_reports_missing_farm() {
let kache = PathBuf::from("/opt/kache/bin/kache");
let status = shim_path_status(
&[PathBuf::from("/usr/bin")],
Some(&kache),
&[],
&|_| true,
&|path| Some(path.to_path_buf()),
);
assert!(!status.on_path);
assert_eq!(status.detail, "not installed");
let fix = status.fix.expect("missing farm must say how to install");
assert!(fix.contains("kache install-shims"), "{fix}");
assert!(fix.contains("export PATH="), "{fix}");
}
#[test]
fn default_and_system_shim_dirs_are_the_documented_locations() {
let home = default_shim_dir();
assert!(
home.ends_with(".local/lib/kache/shims"),
"user farm must be ~/.local/lib/kache/shims, got {}",
home.display()
);
assert_eq!(system_shim_dir(), PathBuf::from("/usr/lib/kache"));
}
#[test]
fn a_path_that_does_not_hold_kache_is_not_an_installed_farm() {
let kache = PathBuf::from("/opt/kache/bin/kache");
let decoy = PathBuf::from("/opt/other/shims");
let status = shim_path_status(
&[PathBuf::from("/usr/bin")],
Some(&kache),
std::slice::from_ref(&decoy),
&|_| true,
&|path| Some(path.to_path_buf()),
);
assert!(!status.on_path);
assert_eq!(
status.detail, "not installed",
"a decoy directory must not count as the kache farm: {}",
status.detail
);
}
#[cfg(unix)]
#[test]
fn extra_names_on_path_include_versioned_compilers_not_the_farm() {
use std::os::unix::fs::PermissionsExt;
let _lock = crate::config::tests::config_path_lock();
let dir = tempfile::tempdir().unwrap();
let shim_dir = dir.path().join("shims");
let real_dir = dir.path().join("real");
std::fs::create_dir_all(&shim_dir).unwrap();
std::fs::create_dir_all(&real_dir).unwrap();
let exe = std::env::current_exe().unwrap();
std::os::unix::fs::symlink(&exe, shim_dir.join("gcc")).unwrap();
let gcc13 = real_dir.join("gcc-13");
std::fs::write(&gcc13, "#!/bin/sh\nexit 0\n").unwrap();
std::fs::set_permissions(&gcc13, std::fs::Permissions::from_mode(0o755)).unwrap();
let gcc = real_dir.join("gcc");
std::fs::write(&gcc, "#!/bin/sh\nexit 0\n").unwrap();
std::fs::set_permissions(&gcc, std::fs::Permissions::from_mode(0o755)).unwrap();
let _path = PathForTest(std::env::var_os("PATH"));
unsafe {
std::env::set_var(
"PATH",
format!("{}:{}", shim_dir.display(), real_dir.display()),
)
};
let extra = extra_compiler_names_from_env();
assert!(
extra.iter().any(|n| n == "gcc-13"),
"versioned compiler must be wrapped, got {extra:?}"
);
assert!(
!extra.iter().any(|n| n == "gcc"),
"canonical names belong to SHIM_NAMES, got {extra:?}"
);
}
}