use super::{PROBE_SCHEMA_VERSION, ProbeRequest, ResolvedConfig};
use std::fs::Metadata;
use std::io::Write;
use std::path::{Path, PathBuf};
const PROBE_SUBDIR: &str = "probes";
pub fn probe_key(prober_id: &str, req: &ProbeRequest<'_>) -> Option<String> {
let resolved = resolve_program(req.compiler)?;
let meta = std::fs::metadata(&resolved).ok()?;
let fingerprint = compiler_fingerprint(&meta);
let env_fp = env_fingerprint();
let mut h = blake3::Hasher::new();
h.update(b"probe_schema:");
h.update(PROBE_SCHEMA_VERSION.to_string().as_bytes());
h.update(b"\nprober:");
h.update(prober_id.as_bytes());
h.update(b"\ncompiler_path:");
h.update(resolved.to_string_lossy().as_bytes());
h.update(b"\ncompiler_stat:");
h.update(fingerprint.as_bytes());
h.update(b"\nkey_args:");
for arg in req.key_args {
h.update(arg.as_bytes());
h.update(b"\x1f");
}
h.update(b"\nenv:");
h.update(env_fp.as_bytes());
Some(h.finalize().to_hex().to_string())
}
fn env_fingerprint() -> String {
fingerprint_env(std::env::vars())
}
fn fingerprint_env(vars: impl Iterator<Item = (String, String)>) -> String {
let mut vars: Vec<(String, String)> = vars.filter(|(k, _)| !is_volatile_env_name(k)).collect();
vars.sort();
let mut h = blake3::Hasher::new();
for (k, v) in vars {
h.update(k.as_bytes());
h.update(b"=");
h.update(v.as_bytes());
h.update(b"\n");
}
h.finalize().to_hex().to_string()
}
fn is_volatile_env_name(name: &str) -> bool {
name.starts_with('=')
}
pub fn load(cache_dir: &Path, key: &str) -> Option<ResolvedConfig> {
let bytes = std::fs::read(record_path(cache_dir, key)).ok()?;
let config: ResolvedConfig = serde_json::from_slice(&bytes).ok()?;
(config.schema_version == PROBE_SCHEMA_VERSION).then_some(config)
}
pub fn store(cache_dir: &Path, key: &str, config: &ResolvedConfig) {
let dir = cache_dir.join(PROBE_SUBDIR);
if std::fs::create_dir_all(&dir).is_err() {
return;
}
let Ok(json) = serde_json::to_vec_pretty(config) else {
return;
};
let Ok(mut tmp) = tempfile::NamedTempFile::new_in(&dir) else {
return;
};
if tmp.write_all(&json).is_err() {
return;
}
let _ = tmp.persist(record_path(cache_dir, key));
}
fn record_path(cache_dir: &Path, key: &str) -> PathBuf {
cache_dir.join(PROBE_SUBDIR).join(format!("{key}.json"))
}
fn compiler_fingerprint(meta: &Metadata) -> String {
let mut parts = vec![format!("len={}", meta.len())];
if let Ok(mtime) = meta.modified()
&& let Ok(d) = mtime.duration_since(std::time::UNIX_EPOCH)
{
parts.push(format!("mtime={}", d.as_nanos()));
}
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
parts.push(format!("ctime={}.{}", meta.ctime(), meta.ctime_nsec()));
parts.push(format!("ino={}", meta.ino()));
}
parts.join(",")
}
fn resolve_program(program: &str) -> Option<PathBuf> {
let p = Path::new(program);
let has_separator = p.parent().is_some_and(|par| !par.as_os_str().is_empty());
if has_separator {
return with_exe_suffix(p.to_path_buf(), std::env::consts::EXE_SUFFIX)
.into_iter()
.find_map(|candidate| candidate.canonicalize().ok());
}
let path = std::env::var_os("PATH")?;
let dirs: Vec<PathBuf> = std::env::split_paths(&path).collect();
find_program_in_dirs(program, &dirs, std::env::consts::EXE_SUFFIX)
.and_then(|candidate| candidate.canonicalize().ok())
}
fn with_exe_suffix(path: PathBuf, exe_suffix: &str) -> Vec<PathBuf> {
let already = path
.extension()
.and_then(|e| e.to_str())
.is_some_and(|ext| ext == exe_suffix.trim_start_matches('.'));
if exe_suffix.is_empty() || already {
return vec![path];
}
let mut suffixed = path.clone().into_os_string();
suffixed.push(exe_suffix);
vec![path, PathBuf::from(suffixed)]
}
fn find_program_in_dirs(program: &str, dirs: &[PathBuf], exe_suffix: &str) -> Option<PathBuf> {
dirs.iter().find_map(|dir| {
with_exe_suffix(dir.join(program), exe_suffix)
.into_iter()
.find(|candidate| candidate.is_file())
})
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::{NamedTempFile, TempDir};
fn sample(version: &str) -> ResolvedConfig {
ResolvedConfig {
schema_version: PROBE_SCHEMA_VERSION,
prober: "cc".to_string(),
compiler_name: "clang".to_string(),
version_line: version.to_string(),
resolved_tokens: None,
}
}
fn req(compiler: &str) -> ProbeRequest<'_> {
ProbeRequest {
compiler,
args: &[],
key_args: &[],
windows_aware: true,
}
}
#[test]
fn fingerprint_env_ignores_windows_hidden_pseudo_vars() {
let real = [
("PATH".to_string(), "/usr/bin".to_string()),
("SDKROOT".to_string(), "/sdk".to_string()),
];
let with_hidden = [
("PATH".to_string(), "/usr/bin".to_string()),
("SDKROOT".to_string(), "/sdk".to_string()),
("=C:".to_string(), r"C:\work\cold".to_string()),
("=ExitCode".to_string(), "00000000".to_string()),
];
assert_eq!(
fingerprint_env(real.iter().cloned()),
fingerprint_env(with_hidden.iter().cloned()),
"`=`-prefixed Windows pseudo-vars must not affect the probe key"
);
}
#[test]
fn fingerprint_env_still_reflects_real_vars() {
let a = [("SDKROOT".to_string(), "/sdk/a".to_string())];
let b = [("SDKROOT".to_string(), "/sdk/b".to_string())];
assert_ne!(
fingerprint_env(a.iter().cloned()),
fingerprint_env(b.iter().cloned()),
"a real env change must still change the fingerprint"
);
}
#[test]
fn is_volatile_env_name_flags_only_equals_prefixed() {
assert!(is_volatile_env_name("=C:"));
assert!(is_volatile_env_name("=ExitCode"));
assert!(!is_volatile_env_name("PATH"));
assert!(!is_volatile_env_name("SDKROOT"));
assert!(!is_volatile_env_name("A=B"));
}
#[test]
fn probe_key_is_stable_for_the_same_binary() {
let compiler = NamedTempFile::new().unwrap();
let request = req(compiler.path().to_str().unwrap());
let k1 = probe_key("cc", &request).unwrap();
let k2 = probe_key("cc", &request).unwrap();
assert_eq!(k1, k2);
}
#[test]
fn probe_key_changes_when_the_binary_changes() {
let mut compiler = NamedTempFile::new().unwrap();
let path = compiler.path().to_path_buf();
let path_str = path.to_str().unwrap();
let k1 = probe_key("cc", &req(path_str)).unwrap();
compiler.write_all(b"new compiler bytes").unwrap();
compiler.flush().unwrap();
let k2 = probe_key("cc", &req(path_str)).unwrap();
assert_ne!(k1, k2, "a changed compiler binary must change the key");
}
#[test]
fn probe_key_is_none_for_a_missing_compiler() {
assert!(probe_key("cc", &req("/nonexistent/kache-cc-xyz")).is_none());
}
#[test]
fn with_exe_suffix_appends_when_set_and_absent() {
assert_eq!(
with_exe_suffix(PathBuf::from("/p/cc"), ".exe"),
vec![PathBuf::from("/p/cc"), PathBuf::from("/p/cc.exe")]
);
}
#[test]
fn with_exe_suffix_is_single_for_empty_suffix_or_already_present() {
assert_eq!(
with_exe_suffix(PathBuf::from("/p/cc"), ""),
vec![PathBuf::from("/p/cc")]
);
assert_eq!(
with_exe_suffix(PathBuf::from("/p/cc.exe"), ".exe"),
vec![PathBuf::from("/p/cc.exe")]
);
}
#[test]
fn find_program_in_dirs_matches_suffixed_executable() {
let dir = TempDir::new().unwrap();
std::fs::write(dir.path().join("cc.exe"), b"MZ").unwrap();
let dirs = [dir.path().to_path_buf()];
assert_eq!(
find_program_in_dirs("cc", &dirs, ".exe"),
Some(dir.path().join("cc.exe"))
);
assert!(find_program_in_dirs("cc", &dirs, "").is_none());
}
#[test]
fn find_program_in_dirs_prefers_unsuffixed_when_present() {
let dir = TempDir::new().unwrap();
std::fs::write(dir.path().join("cc"), b"#!/bin/sh\n").unwrap();
let dirs = [dir.path().to_path_buf()];
assert_eq!(
find_program_in_dirs("cc", &dirs, ".exe"),
Some(dir.path().join("cc"))
);
}
#[test]
fn probe_key_changes_when_key_args_change() {
let compiler = NamedTempFile::new().unwrap();
let path = compiler.path().to_str().unwrap();
let plain = probe_key("cc", &req(path)).unwrap();
let flags = ["-O2".to_string()];
let with_flags = probe_key(
"cc",
&ProbeRequest {
compiler: path,
args: &[],
key_args: &flags,
windows_aware: true,
},
)
.unwrap();
assert_ne!(plain, with_flags, "key_args must be part of the probe key");
}
#[test]
fn probe_key_differs_per_prober_id() {
let compiler = NamedTempFile::new().unwrap();
let path_str = compiler.path().to_str().unwrap();
let a = probe_key("cc", &req(path_str)).unwrap();
let b = probe_key("rustc", &req(path_str)).unwrap();
assert_ne!(a, b, "prober id must be part of the key");
}
#[test]
fn store_then_load_roundtrips() {
let cache = TempDir::new().unwrap();
let cfg = sample("clang 17.0.0");
store(cache.path(), "deadbeef", &cfg);
assert_eq!(load(cache.path(), "deadbeef"), Some(cfg));
}
#[test]
fn store_creates_the_probe_subdir() {
let cache = TempDir::new().unwrap();
store(cache.path(), "k", &sample("gcc 13"));
assert!(cache.path().join(PROBE_SUBDIR).join("k.json").is_file());
}
#[test]
fn load_is_none_when_record_absent() {
let cache = TempDir::new().unwrap();
assert_eq!(load(cache.path(), "missing"), None);
}
#[test]
fn load_is_none_for_a_corrupt_record() {
let cache = TempDir::new().unwrap();
let dir = cache.path().join(PROBE_SUBDIR);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("bad.json"), b"{ not json").unwrap();
assert_eq!(load(cache.path(), "bad"), None);
}
#[test]
fn load_is_none_on_schema_mismatch() {
let cache = TempDir::new().unwrap();
let dir = cache.path().join(PROBE_SUBDIR);
std::fs::create_dir_all(&dir).unwrap();
let mut cfg = sample("clang 17");
cfg.schema_version = PROBE_SCHEMA_VERSION + 1;
std::fs::write(dir.join("future.json"), serde_json::to_vec(&cfg).unwrap()).unwrap();
assert_eq!(load(cache.path(), "future"), None);
}
}