use crate::args::RustcArgs;
use crate::path_normalizer::{PathNormalizer, check_for_path_leak};
use anyhow::{Context, Result};
pub(crate) use kache_format::{is_valid_cache_key, is_valid_crate_name};
pub(crate) use kache_store::file_hash::*;
use std::borrow::Cow;
use std::cell::{Cell, RefCell};
use std::collections::{BTreeMap, HashMap};
use std::path::{Path, PathBuf};
pub(crate) use kache_format::CACHE_KEY_VERSION;
fn normalize_rustflags(rustflags: &str) -> String {
rustflags.split_whitespace().collect::<Vec<_>>().join(" ")
}
const REMAP_FROM_SENTINEL: &str = "<REMAP_FROM>";
fn scrub_remap_from_prefixes<'a, I>(tokens: I) -> Vec<String>
where
I: IntoIterator<Item = &'a str>,
{
const EQ_FLAGS: [&str; 4] = [
"--remap-path-prefix=",
"-ffile-prefix-map=",
"-fdebug-prefix-map=",
"-fmacro-prefix-map=",
];
let mut out = Vec::new();
let mut iter = tokens.into_iter();
while let Some(tok) = iter.next() {
if let Some(flag) = EQ_FLAGS.iter().find(|f| tok.starts_with(**f)) {
out.push(format!("{flag}{}", scrub_remap_value(&tok[flag.len()..])));
} else if tok == "--remap-path-prefix" {
out.push(tok.to_string());
if let Some(value) = iter.next() {
out.push(scrub_remap_value(value));
}
} else {
out.push(tok.to_string());
}
}
out
}
fn scrub_remap_value(value: &str) -> String {
match value.rsplit_once('=') {
Some((_from, to)) => format!("{REMAP_FROM_SENTINEL}={to}"),
None => value.to_string(),
}
}
fn normalize_direct_remap_value(value: &str, path_normalizer: &PathNormalizer) -> String {
match value.rsplit_once('=') {
Some((from, to)) => format!("{}={to}", path_normalizer.normalize(from)),
None => value.to_string(),
}
}
pub(crate) fn apply_key_salt(base: String, salt: Option<&str>, label: &str) -> String {
match salt {
Some(salt) if !salt.is_empty() => {
let keyed = fold_labeled(base, "key_salt", salt);
tracing::trace!(
"[key:{label}] key_salt={salt:?} -> {}",
&keyed[..keyed.len().min(16)]
);
keyed
}
_ => base,
}
}
pub(crate) fn apply_key_env_vars(base: String, patterns: &[String], label: &str) -> String {
if patterns.is_empty() {
return base;
}
let (matched, matched_names) = matching_key_env_vars(patterns);
let keyed = fold_labeled(base, "key_env_vars", &key_env_digest(patterns, matched));
tracing::trace!(
"[key:{label}] key_env_vars patterns={patterns:?} matched={matched_names:?} -> {}",
&keyed[..keyed.len().min(16)]
);
keyed
}
pub(crate) fn key_env_guard(patterns: &[String]) -> Option<String> {
(!patterns.is_empty()).then(|| {
let (matched, _) = matching_key_env_vars(patterns);
key_env_digest(patterns, matched)
})
}
type RawEnvPair = (Vec<u8>, Vec<u8>);
fn matching_key_env_vars(patterns: &[String]) -> (Vec<RawEnvPair>, Vec<String>) {
let mut matched: Vec<RawEnvPair> = Vec::new();
let mut matched_names: Vec<String> = Vec::new();
for (name, value) in std::env::vars_os() {
let lossy = name.to_string_lossy();
if !key_env_var_matches(patterns, &lossy) {
continue;
}
matched_names.push(lossy.into_owned());
matched.push((env_name_key_bytes(&name), env_os_key_bytes(&value)));
}
(matched, matched_names)
}
fn key_env_digest(patterns: &[String], mut matched: Vec<RawEnvPair>) -> String {
let mut names_seen = std::collections::HashSet::new();
let has_duplicate_names = matched
.iter()
.any(|(name, _)| !names_seen.insert(name.clone()));
if !has_duplicate_names {
matched.sort();
}
let mut hasher = blake3::Hasher::new();
for pattern in patterns {
fold_field(&mut hasher, b"key_env_pattern:", pattern.as_bytes());
}
for (name, value) in &matched {
fold_field(&mut hasher, b"key_env_name:", name);
fold_field(&mut hasher, b"key_env_val:", value);
}
hasher.finalize().to_hex().to_string()
}
fn env_os_key_bytes(value: &std::ffi::OsStr) -> Vec<u8> {
#[cfg(unix)]
{
use std::os::unix::ffi::OsStrExt;
value.as_bytes().to_vec()
}
#[cfg(windows)]
{
use std::os::windows::ffi::OsStrExt;
value.encode_wide().flat_map(u16::to_le_bytes).collect()
}
#[cfg(not(any(unix, windows)))]
{
value.to_string_lossy().into_owned().into_bytes()
}
}
fn env_name_key_bytes(name: &std::ffi::OsStr) -> Vec<u8> {
#[cfg(windows)]
{
use std::os::windows::ffi::OsStrExt;
match name.to_str() {
Some(name) => name
.to_uppercase()
.encode_utf16()
.flat_map(u16::to_le_bytes)
.collect(),
None => name.encode_wide().flat_map(u16::to_le_bytes).collect(),
}
}
#[cfg(not(windows))]
env_os_key_bytes(name)
}
fn env_text_key_bytes(text: &std::ffi::OsStr) -> Vec<u8> {
match text.to_str() {
Some(utf8) => utf8.as_bytes().to_vec(),
None => {
let mut bytes = vec![0xff];
bytes.extend(env_os_key_bytes(text));
bytes
}
}
}
fn cargo_cfg_pairs(
vars: impl Iterator<Item = (std::ffi::OsString, std::ffi::OsString)>,
) -> Vec<(std::ffi::OsString, std::ffi::OsString)> {
let mut pairs: Vec<(std::ffi::OsString, std::ffi::OsString)> = vars
.filter(|(name, _)| name.to_string_lossy().starts_with("CARGO_CFG_"))
.collect();
pairs.sort_by_cached_key(|(name, _)| {
(name.to_string_lossy().into_owned(), env_os_key_bytes(name))
});
pairs
}
fn key_env_var_matches(patterns: &[String], name: &str) -> bool {
let name = name.as_bytes();
patterns
.iter()
.any(|pattern| match pattern.strip_suffix('*') {
Some(prefix) => {
let prefix = prefix.as_bytes();
name.len() >= prefix.len() && name[..prefix.len()].eq_ignore_ascii_case(prefix)
}
None => name.eq_ignore_ascii_case(pattern.as_bytes()),
})
}
pub(crate) fn fold_labeled(base: String, label: &str, value: &str) -> String {
let mut hasher = blake3::Hasher::new();
hasher.update(label.as_bytes());
hasher.update(b":");
hasher.update(value.as_bytes());
hasher.update(b"\x1f");
hasher.update(base.as_bytes());
hasher.finalize().to_hex().to_string()
}
fn fold_field<H: KeyFold>(hasher: &mut H, label: &[u8], value: &[u8]) {
hasher.update(label);
hasher.update(&(value.len() as u64).to_le_bytes());
hasher.update(value);
}
trait KeyFold {
fn update(&mut self, bytes: &[u8]);
}
impl KeyFold for blake3::Hasher {
fn update(&mut self, bytes: &[u8]) {
blake3::Hasher::update(self, bytes);
}
}
impl KeyFold for GroupedHasher {
fn update(&mut self, bytes: &[u8]) {
GroupedHasher::update(self, bytes);
}
}
const KEY_FIELD_HEX: usize = 16;
struct GroupedHasher {
main: blake3::Hasher,
groups: std::collections::BTreeMap<&'static str, blake3::Hasher>,
current: &'static str,
}
impl GroupedHasher {
fn new(initial_group: &'static str) -> Self {
GroupedHasher {
main: blake3::Hasher::new(),
groups: std::collections::BTreeMap::new(),
current: initial_group,
}
}
fn set_group(&mut self, group: &'static str) {
self.current = group;
}
fn update(&mut self, bytes: &[u8]) {
self.main.update(bytes);
self.groups.entry(self.current).or_default().update(bytes);
}
fn finalize_with_fields(self) -> (blake3::Hash, std::collections::BTreeMap<String, String>) {
let fields = self
.groups
.into_iter()
.map(|(group, hasher)| {
(
group.to_string(),
hasher.finalize().to_hex()[..KEY_FIELD_HEX].to_string(),
)
})
.collect();
(self.main.finalize(), fields)
}
}
thread_local! {
static LAST_KEY_FIELDS: std::cell::RefCell<Option<std::collections::BTreeMap<String, String>>> =
const { std::cell::RefCell::new(None) };
}
pub fn peek_last_key_fields() -> Option<std::collections::BTreeMap<String, String>> {
LAST_KEY_FIELDS
.try_with(|stash| stash.borrow().clone())
.ok()
.flatten()
}
pub fn take_last_key_fields() -> Option<std::collections::BTreeMap<String, String>> {
LAST_KEY_FIELDS
.try_with(|stash| stash.borrow_mut().take())
.ok()
.flatten()
}
thread_local! {
static LAST_KEY_EXTERNS: std::cell::RefCell<Option<std::collections::BTreeMap<String, String>>> =
const { std::cell::RefCell::new(None) };
}
pub const EXTERN_UNREADABLE: &str = "(sysroot)";
pub fn take_last_key_externs() -> Option<std::collections::BTreeMap<String, String>> {
LAST_KEY_EXTERNS
.try_with(|stash| stash.borrow_mut().take())
.ok()
.flatten()
}
thread_local! {
static LAST_KEY_EXTERN_UNITS: std::cell::RefCell<
Option<std::collections::BTreeMap<String, String>>,
> = const { std::cell::RefCell::new(None) };
}
pub fn take_last_key_extern_units() -> Option<std::collections::BTreeMap<String, String>> {
LAST_KEY_EXTERN_UNITS
.try_with(|stash| stash.borrow_mut().take())
.ok()
.flatten()
}
thread_local! {
static LAST_KEY_UNIT_ID: std::cell::RefCell<Option<String>> =
const { std::cell::RefCell::new(None) };
}
pub fn take_last_key_unit_id() -> Option<String> {
LAST_KEY_UNIT_ID
.try_with(|stash| stash.borrow_mut().take())
.ok()
.flatten()
}
thread_local! {
static LAST_KEY_DEP_INFO: std::cell::RefCell<Option<DepInfo>> =
const { std::cell::RefCell::new(None) };
}
#[cfg(test)]
pub(crate) fn stash_last_dep_info_for_test(dep_info: DepInfo) {
let _ = LAST_KEY_DEP_INFO.try_with(|stash| *stash.borrow_mut() = Some(dep_info));
}
thread_local! {
static LAST_KEY_USED_PREDICTION: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
}
pub(crate) fn take_last_key_used_prediction() -> bool {
LAST_KEY_USED_PREDICTION
.try_with(|stash| stash.replace(false))
.unwrap_or(false)
}
pub(crate) fn take_last_dep_info() -> Option<DepInfo> {
LAST_KEY_DEP_INFO
.try_with(|stash| stash.borrow_mut().take())
.ok()
.flatten()
}
fn source_path_identity(file: &Path, path_normalizer: &PathNormalizer) -> Result<Vec<u8>> {
if let Some(identity) = path_normalizer.source_path_identity(file) {
return Ok(identity);
}
let mut opaque = blake3::Hasher::new();
opaque.update(b"kache-source-path-v1\0");
opaque.update(&env_os_key_bytes(file.as_os_str()));
Ok(format!("<OPAQUE_PATH>/{}", opaque.finalize().to_hex()).into_bytes())
}
pub(crate) fn rustc_prediction_identity(args: &RustcArgs) -> Option<String> {
rustc_prediction_identity_in_env(args, std::env::vars_os().collect())
}
fn rustc_prediction_identity_in_env(
args: &RustcArgs,
vars: Vec<(std::ffi::OsString, std::ffi::OsString)>,
) -> Option<String> {
let source_file = args.source_file.as_ref()?;
let rustc_version = get_rustc_version(&args.rustc).ok()?;
Some(prediction_identity_in_env(
&PredictionIdentityParts {
rustc_version: &rustc_version,
inner_rustc: args.inner_rustc.as_deref(),
current_dir: std::env::current_dir().ok().as_deref(),
source_file,
closure_args: &closure_shaping_args(source_file, &args.all_args),
skip_path_remap: args.skip_path_remap(),
},
vars,
))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum VerifyPredictions {
Off,
Sampled,
Always,
}
const VERIFY_PREDICTION_RATE: usize = 64;
fn parse_verify_predictions(value: Option<&str>) -> VerifyPredictions {
match value {
Some(v) if v.eq_ignore_ascii_case("sampled") => VerifyPredictions::Sampled,
Some(v)
if v.eq_ignore_ascii_case("always") || v == "1" || v.eq_ignore_ascii_case("true") =>
{
VerifyPredictions::Always
}
_ => VerifyPredictions::Off,
}
}
fn should_verify_this_prediction(mode: VerifyPredictions, identity: &str) -> bool {
match mode {
VerifyPredictions::Off => false,
VerifyPredictions::Always => true,
VerifyPredictions::Sampled => sampled_by_identity(identity, VERIFY_PREDICTION_RATE),
}
}
fn sampled_by_identity(identity: &str, rate: usize) -> bool {
if rate <= 1 {
return true;
}
let digest = blake3::hash(identity.as_bytes());
let bucket = u64::from_le_bytes(digest.as_bytes()[..8].try_into().unwrap_or([0; 8]));
bucket % (rate as u64) == 0
}
fn closures_agree(predicted: &DepInfo, discovered: &DepInfo) -> bool {
let mut a = predicted.source_files.clone();
let mut b = discovered.source_files.clone();
a.sort();
b.sort();
let mut ea = predicted.env_deps.clone();
let mut eb = discovered.env_deps.clone();
ea.sort();
eb.sort();
a == b && ea == eb
}
fn predicted_key_inputs(
args: &RustcArgs,
file_hasher: &FileHasher<'_>,
) -> std::result::Result<(DepInfo, String), Rejection> {
if !file_hasher.uses_input_predictions() {
return Err(Rejection::Disabled);
}
if !prediction_applies(&args.externs) {
return Err(Rejection::NotEligible);
}
let identity = rustc_prediction_identity(args).ok_or(Rejection::Disabled)?;
let record = file_hasher
.input_prediction(&identity)
.ok_or(Rejection::NoRecord)?;
let dep_info = validate_prediction(
&record,
|path| std::fs::metadata(path).ok(),
|path| path.exists(),
|var| std::env::var(var).ok(),
)?;
Ok((dep_info, identity))
}
fn resolve_key_inputs(
args: &RustcArgs,
file_hasher: &FileHasher<'_>,
crate_name: &str,
) -> Result<Option<DepInfo>> {
if args.source_file.is_some() {
match predicted_key_inputs(args, file_hasher) {
Ok((dep_info, identity)) => {
let mode = parse_verify_predictions(
std::env::var("KACHE_VERIFY_INPUT_PREDICTIONS")
.ok()
.as_deref(),
);
if should_verify_this_prediction(mode, &identity) {
let discovered = dep_info_pre_pass(args)?;
if discovered
.as_ref()
.is_some_and(|discovered| closures_agree(&dep_info, discovered))
{
tracing::trace!("[key:{}] inputs=predicted(verified)", crate_name);
} else {
tracing::warn!(
"[key:{}] input prediction disagreed with the dep-info pass; \
using the pass. Please report this with the crate and its \
dependencies (kunobi-ninja/kache).",
crate_name
);
crate::opcounts::record_prediction_mismatch();
}
return Ok(discovered);
}
tracing::trace!("[key:{}] inputs=predicted", crate_name);
let _ = LAST_KEY_USED_PREDICTION.try_with(|stash| stash.set(true));
return Ok(Some(dep_info));
}
Err(reason) => {
tracing::trace!("[key:{}] inputs=dep-info({})", crate_name, reason.as_str())
}
}
}
dep_info_pre_pass(args)
}
fn dep_info_pre_pass(args: &RustcArgs) -> Result<Option<DepInfo>> {
args.source_file
.as_ref()
.map(|source| {
run_dep_info_pass(
&args.rustc,
args.inner_rustc.as_deref(),
source,
&args.all_args,
args.has_expanded_argfiles(),
)
.with_context(|| {
format!(
"dep-info pre-pass failed for {} — refusing to cache from an \
incomplete input set",
source.display()
)
})
})
.transpose()
}
pub fn compute_cache_key(
args: &RustcArgs,
file_hasher: &FileHasher<'_>,
path_normalizer: &PathNormalizer,
) -> Result<String> {
let mut hasher = GroupedHasher::new("compiler");
let crate_name = args.crate_name.as_deref().unwrap_or("unknown");
let _ = LAST_KEY_EXTERNS.try_with(|stash| *stash.borrow_mut() = None);
let _ = LAST_KEY_EXTERN_UNITS.try_with(|stash| *stash.borrow_mut() = None);
let _ = LAST_KEY_UNIT_ID.try_with(|stash| *stash.borrow_mut() = args.unit_id());
let _ = LAST_KEY_DEP_INFO.try_with(|stash| *stash.borrow_mut() = None);
let _ = LAST_KEY_USED_PREDICTION.try_with(|stash| stash.set(false));
hasher.update(b"key_version:");
hasher.update(CACHE_KEY_VERSION.to_string().as_bytes());
hasher.update(b"\n");
tracing::trace!("[key:{}] key_version={}", crate_name, CACHE_KEY_VERSION);
let configured_base_dirs = path_normalizer.configured_base_dir_count();
if configured_base_dirs != 0 {
fold_field(
&mut hasher,
b"configured_base_dirs.v1:",
configured_base_dirs.to_string().as_bytes(),
);
tracing::trace!(
"[key:{}] configured_base_dirs={}",
crate_name,
configured_base_dirs
);
}
let rustc_version = get_rustc_version(&args.rustc)?;
hasher.update(b"rustc_version:");
hasher.update(rustc_version.as_bytes());
hasher.update(b"\n");
tracing::trace!(
"[key:{}] rustc_version={}",
crate_name,
rustc_version.lines().next().unwrap_or("?")
);
let target = args
.target
.as_deref()
.unwrap_or_else(|| host_target_triple());
check_for_path_leak(target, "target");
hasher.update(b"target:");
hasher.update(target.as_bytes());
hasher.update(b"\n");
tracing::trace!("[key:{}] target={}", crate_name, target);
let target_path = Path::new(target);
if target_path.is_file() {
match hash_file(target_path) {
Ok(spec_hash) => {
hasher.update(b"target_spec:");
hasher.update(spec_hash.as_bytes());
hasher.update(b"\n");
tracing::trace!("[key:{}] target_spec={}", crate_name, &spec_hash[..16]);
}
Err(e) => {
tracing::warn!(
"[key:{}] failed to hash target spec {}: {}",
crate_name,
target,
e
);
}
}
}
hasher.set_group("crate");
if let Some(name) = &args.crate_name {
hasher.update(b"crate_name:");
hasher.update(name.as_bytes());
hasher.update(b"\n");
tracing::trace!("[key:{}] crate_name={}", crate_name, name);
}
for ct in &args.crate_types {
hasher.update(b"crate_type:");
hasher.update(ct.as_bytes());
hasher.update(b"\n");
tracing::trace!("[key:{}] crate_type={}", crate_name, ct);
}
if let Some(edition) = &args.edition {
hasher.update(b"edition:");
hasher.update(edition.as_bytes());
hasher.update(b"\n");
tracing::trace!("[key:{}] edition={}", crate_name, edition);
}
hasher.set_group("args");
let mut emit: Vec<&String> = args.emit.iter().collect();
emit.sort();
for kind in &emit {
hasher.update(b"emit:");
hasher.update(kind.as_bytes());
hasher.update(b"\n");
tracing::trace!("[key:{}] emit:{}", crate_name, kind);
}
let mut codegen_opts: Vec<_> = args
.codegen_opts
.iter()
.filter(|(k, _)| {
k != "incremental" && k != "linker"
})
.collect();
codegen_opts.sort_by_key(|(k, _)| k.as_str());
for (key, value) in &codegen_opts {
fold_field(&mut hasher, b"codegen_key:", key.as_bytes());
if let Some(v) = value {
check_for_path_leak(v, &format!("codegen:{key}"));
fold_field(&mut hasher, b"codegen_val:", v.as_bytes());
tracing::trace!("[key:{}] codegen:{}={}", crate_name, key, v);
} else {
tracing::trace!("[key:{}] codegen:{}", crate_name, key);
}
}
for feat in &args.features {
hasher.update(b"feature:");
hasher.update(feat.as_bytes());
hasher.update(b"\n");
tracing::trace!("[key:{}] feature:{}", crate_name, feat);
}
let mut cfgs: Vec<_> = args
.cfgs
.iter()
.filter(|c| !c.starts_with("feature="))
.collect();
cfgs.sort();
for cfg in &cfgs {
check_for_path_leak(cfg, "cfg");
fold_field(&mut hasher, b"cfg:", cfg.as_bytes());
tracing::trace!("[key:{}] cfg:{}", crate_name, cfg);
}
let dep_info = resolve_key_inputs(args, file_hasher, crate_name)?;
let _ = LAST_KEY_DEP_INFO.try_with(|stash| *stash.borrow_mut() = dep_info.clone());
let mut externs: Vec<_> = args.externs.iter().filter(|e| e.path.is_some()).collect();
externs.sort_by_key(|e| &e.name);
let mut hash_paths = Vec::new();
if let Some(dep_info) = &dep_info {
hash_paths.extend(dep_info.source_files.iter().map(|p| p.as_path()));
}
hash_paths.extend(externs.iter().filter_map(|ext| ext.path.as_deref()));
file_hasher.prefetch(&hash_paths);
hasher.set_group("sources");
if let Some(dep_info) = &dep_info {
let mut hashed: Vec<(Vec<u8>, String)> = Vec::with_capacity(dep_info.source_files.len());
for file in &dep_info.source_files {
let file_hash = file_hasher
.hash(file)
.with_context(|| format!("hashing source identity {}", file.display()))?;
let normalized_path = source_path_identity(file, path_normalizer)?;
hashed.push((normalized_path, file_hash));
}
hashed.sort();
for (normalized_path, file_hash) in &hashed {
fold_field(&mut hasher, b"source_path:", normalized_path);
fold_field(&mut hasher, b"source_hash:", file_hash.as_bytes());
tracing::trace!(
"[key:{}] source:{}={}",
crate_name,
String::from_utf8_lossy(normalized_path),
&file_hash[..16]
);
}
hasher.set_group("env_deps");
for (var, val) in &dep_info.env_deps {
let normalized_env_dep = normalize_env_dep_value_with_hasher(
crate_name,
var,
val,
&dep_info.source_files,
file_hasher,
path_normalizer,
);
fold_field(&mut hasher, b"env_dep_var:", var.as_bytes());
fold_field(
&mut hasher,
b"env_dep_val:",
normalized_env_dep.value.as_bytes(),
);
tracing::trace!(
"[key:{}] env_dep:{}={} ({})",
crate_name,
var,
normalized_env_dep.value,
normalized_env_dep.decision.as_str()
);
}
}
hasher.set_group("externs");
let mut extern_digests = std::collections::BTreeMap::new();
let mut extern_units = std::collections::BTreeMap::new();
for ext in &externs {
if let Some(path) = &ext.path {
if let Some(unit) = crate::args::unit_id_from_artifact_path(path) {
extern_units.insert(ext.name.clone(), unit);
}
match file_hasher.hash(path) {
Ok(dep_hash) => {
hasher.update(b"extern:");
hasher.update(ext.name.as_bytes());
hasher.update(b"=");
hasher.update(dep_hash.as_bytes());
hasher.update(b"\n");
extern_digests.insert(
ext.name.clone(),
dep_hash
.get(..KEY_FIELD_HEX)
.unwrap_or(dep_hash.as_str())
.to_string(),
);
tracing::trace!(
"[key:{}] extern:{}={}",
crate_name,
ext.name,
&dep_hash[..16]
);
}
Err(_) => {
hasher.update(b"extern_unreadable:");
hasher.update(ext.name.as_bytes());
hasher.update(b"\n");
extern_digests.insert(ext.name.clone(), EXTERN_UNREADABLE.to_string());
tracing::trace!("[key:{}] extern_unreadable:{}", crate_name, ext.name);
}
}
}
}
let _ = LAST_KEY_EXTERNS.try_with(|stash| *stash.borrow_mut() = Some(extern_digests));
let _ = LAST_KEY_EXTERN_UNITS.try_with(|stash| *stash.borrow_mut() = Some(extern_units));
hasher.set_group("args");
if let Ok(rustflags) = std::env::var("RUSTFLAGS") {
let scrubbed = scrub_remap_from_prefixes(rustflags.split_whitespace()).join(" ");
let normalized = normalize_rustflags(&path_normalizer.normalize(&scrubbed));
hasher.update(b"RUSTFLAGS:");
hasher.update(normalized.as_bytes());
hasher.update(b"\n");
tracing::trace!("[key:{}] RUSTFLAGS={}", crate_name, normalized);
}
if let Ok(flags) = std::env::var("CARGO_ENCODED_RUSTFLAGS") {
let scrubbed = scrub_remap_from_prefixes(flags.split('\x1f')).join("\x1f");
let normalized = path_normalizer.normalize(&scrubbed);
hasher.update(b"CARGO_ENCODED_RUSTFLAGS:");
hasher.update(normalized.as_bytes());
hasher.update(b"\n");
tracing::trace!(
"[key:{}] CARGO_ENCODED_RUSTFLAGS={}",
crate_name,
normalized
);
}
for value in &args.remap_path_prefixes {
let normalized = normalize_direct_remap_value(value, path_normalizer);
fold_field(
&mut hasher,
b"argv_remap_path_prefix.v1:",
normalized.as_bytes(),
);
tracing::trace!(
"[key:{}] argv --remap-path-prefix={}",
crate_name,
normalized
);
}
if let Ok(bootstrap) = std::env::var("RUSTC_BOOTSTRAP")
&& !bootstrap.is_empty()
{
hasher.update(b"RUSTC_BOOTSTRAP:");
hasher.update(bootstrap.as_bytes());
hasher.update(b"\n");
tracing::trace!("[key:{}] RUSTC_BOOTSTRAP={}", crate_name, bootstrap);
}
hasher.set_group("link");
if let Some(sysroot) = &args.sysroot {
let normalized = path_normalizer.normalize(sysroot.to_string_lossy());
hasher.update(b"sysroot:");
hasher.update(normalized.as_bytes());
hasher.update(b"\n");
tracing::trace!("[key:{}] sysroot={}", crate_name, normalized);
}
const KNOWN_L_KINDS: [&str; 5] = ["dependency", "crate", "native", "framework", "all"];
let mut native_search_dirs: Vec<PathBuf> = Vec::new();
for spec in &args.link_search {
let (kind, path) = match spec.split_once('=') {
Some((k, p)) if KNOWN_L_KINDS.contains(&k) => (Some(k), p),
_ => (None, spec.as_str()),
};
if matches!(kind, Some("dependency") | Some("crate")) {
continue;
}
if matches!(kind, None | Some("native") | Some("all")) {
native_search_dirs.push(PathBuf::from(path));
}
let normalized = path_normalizer.normalize(path);
hasher.update(b"link_search:");
if let Some(k) = kind {
hasher.update(k.as_bytes());
hasher.update(b"=");
}
hasher.update(normalized.as_bytes());
hasher.update(b"\n");
tracing::trace!("[key:{}] link_search:{}", crate_name, normalized);
}
if native_linker_side_files_are_unmodeled(args) {
anyhow::bail!("native linker order/map/response side files are not cacheable");
}
for lib in &args.link_libs {
hasher.update(b"link_lib:");
hasher.update(lib.as_bytes());
hasher.update(b"\n");
tracing::trace!("[key:{}] link_lib:{}", crate_name, lib);
if let Some((path, content_hash)) =
resolve_native_static_lib(lib, &native_search_dirs, file_hasher)?
{
hasher.update(b"link_lib_content:");
hasher.update(content_hash.as_bytes());
hasher.update(b"\n");
tracing::trace!(
"[key:{}] link_lib_content:{}={} ({})",
crate_name,
lib,
&content_hash[..content_hash.len().min(16)],
path.display()
);
}
}
hasher.set_group("args");
for z in &args.unstable_flags {
hasher.update(b"unstable:");
hasher.update(z.as_bytes());
hasher.update(b"\n");
tracing::trace!("[key:{}] unstable:{}", crate_name, z);
}
for jobs in &args.frontend_jobs {
fold_field(&mut hasher, b"frontend_jobs.v1:", jobs.as_bytes());
tracing::trace!("[key:{}] frontend_jobs:{}", crate_name, jobs);
}
if !args.residual_args.is_empty() {
let mut residual: Vec<String> = args
.residual_args
.iter()
.map(|tok| path_normalizer.normalize(tok))
.collect();
residual.sort();
for tok in &residual {
check_for_path_leak(tok, "residual_arg");
fold_field(&mut hasher, b"residual_args.v1:", tok.as_bytes());
tracing::trace!("[key:{}] residual_arg:{}", crate_name, tok);
}
let mut raw: Vec<&str> = args.residual_args.iter().map(String::as_str).collect();
raw.sort_unstable();
raw.dedup();
tracing::warn!(
"[key:{}] {} unmodeled rustc flag(s) folded into the cache key \
(kache does not model these; keyed defensively so they cannot cause \
a false hit, but model them for precise keying): {}",
crate_name,
raw.len(),
raw.join(" "),
);
}
if !args.outcome_lint_flags.is_empty() {
hasher.set_group("outcome_lints");
for tok in &args.outcome_lint_flags {
check_for_path_leak(tok, "outcome_lint");
fold_field(&mut hasher, b"outcome_lint.v1:", tok.as_bytes());
tracing::trace!("[key:{}] outcome_lint:{}", crate_name, tok);
}
}
hasher.set_group("env_cfg");
let cargo_cfgs = cargo_cfg_pairs(std::env::vars_os());
tracing::trace!("[key:{}] cargo_cfg_count={}", crate_name, cargo_cfgs.len());
for (key, value) in &cargo_cfgs {
let key_lossy = key.to_string_lossy();
check_for_path_leak(&value.to_string_lossy(), &format!("cargo_cfg:{key_lossy}"));
hasher.update(&env_text_key_bytes(key));
hasher.update(b"=");
hasher.update(&env_text_key_bytes(value));
hasher.update(b"\n");
}
hasher.set_group("link");
let native_windows_msvc = is_native_windows_msvc_link(
args,
&rustc_version,
cfg!(target_os = "windows"),
get_rustc_version,
)?;
fold_generic_linker_identity(&mut hasher, args, native_windows_msvc, get_linker_identity);
fold_native_host_libc_signature(
&mut hasher,
args,
&rustc_version,
cfg!(target_os = "linux"),
probe_linux_libc_signature,
)?;
fold_native_link_runtime_identity(
&mut hasher,
args,
&rustc_version,
cfg!(target_os = "linux"),
cfg!(target_os = "macos"),
crate::native_link_key::probe_linux_crt_objects,
|sdkroot| match crate::native_link_key::sdk_identity_for(sdkroot)? {
Some(identity) => Ok(identity),
None => anyhow::bail!("the macOS SDK could not be identified"),
},
std::env::var("MACOSX_DEPLOYMENT_TARGET").ok(),
)?;
fold_native_windows_msvc_identity(
&mut hasher,
args,
&rustc_version,
cfg!(target_os = "windows"),
|linker, architecture| {
let search_dirs = windows_native_link_search_dirs(args)?;
crate::native_link_key::probe_windows_msvc_identity_with_library_dirs(
linker,
architecture,
&search_dirs.rustc,
&search_dirs.linker,
&args.link_libs,
|path| file_hasher.hash_static_lib(path),
)
.map(|identity| identity.encode())
},
)?;
hasher.set_group("remap");
let remap = if args.skip_path_remap() {
hasher.update(b"remap:none\n");
fold_unremapped_path_identity(&mut hasher, args, path_normalizer);
"none".to_string()
} else {
hasher.update(b"remap:multi-prefix\n");
let remap_args = path_normalizer.remap_args();
let mut targets: Vec<String> = remap_args
.iter()
.filter_map(|a| a.split('=').next_back().map(str::to_string))
.collect();
targets.sort();
targets.dedup();
format!("multi-prefix({})", targets.join(","))
};
tracing::trace!("[key:{}] remap={}", crate_name, remap);
let (hash, fields) = hasher.finalize_with_fields();
let _ = LAST_KEY_FIELDS.try_with(|stash| *stash.borrow_mut() = Some(fields));
let key = hash.to_hex().to_string();
tracing::trace!("[key:{}] final={}", crate_name, &key[..16]);
Ok(key)
}
fn fold_unremapped_path_identity<H: KeyFold>(
hasher: &mut H,
args: &RustcArgs,
path_normalizer: &PathNormalizer,
) {
hasher.update(b"unremapped_path_identity:v1\n");
if let Ok(cwd) = std::env::current_dir() {
fold_field(
hasher,
b"unremapped:cwd:",
cwd.as_os_str().as_encoded_bytes(),
);
}
if let Some(source) = &args.source_file {
fold_field(
hasher,
b"unremapped:source:",
source.as_os_str().as_encoded_bytes(),
);
}
let mut prefixes: Vec<&str> = path_normalizer.raw_prefixes().collect();
prefixes.sort_unstable();
prefixes.dedup();
for prefix in prefixes {
fold_field(hasher, b"unremapped:prefix:", prefix.as_bytes());
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum EnvDepNormalizationDecision {
Unchanged,
NormalizedPathOnly,
KeptAbsoluteRuntimePath,
ForcedPathOnly,
}
impl EnvDepNormalizationDecision {
fn as_str(self) -> &'static str {
match self {
Self::Unchanged => "unchanged",
Self::NormalizedPathOnly => "normalized path-only",
Self::KeptAbsoluteRuntimePath => "kept absolute runtime path",
Self::ForcedPathOnly => "forced path-only (user-asserted)",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct NormalizedEnvDep {
value: String,
decision: EnvDepNormalizationDecision,
}
fn lexically_resolve_path(input: &str) -> String {
let is_sep = |c: char| c == '/' || c == '\\';
let sep = std::path::MAIN_SEPARATOR;
let chars: Vec<char> = input.chars().collect();
let n = chars.len();
let (anchor, start) = if n >= 2 && is_sep(chars[0]) && is_sep(chars[1]) {
let mut root = String::from(r"\\");
let mut i = 2;
let mut taken = 0;
while i < n && taken < 2 {
while i < n && is_sep(chars[i]) {
i += 1;
}
let comp_start = i;
while i < n && !is_sep(chars[i]) {
i += 1;
}
if comp_start == i {
break;
}
if taken == 1 {
root.push(sep);
}
root.extend(&chars[comp_start..i]);
taken += 1;
}
root.push(sep);
(root, i)
} else if n >= 2 && chars[1] == ':' && chars[0].is_ascii_alphabetic() {
let mut root: String = chars[..2].iter().collect();
let mut i = 2;
if i < n && is_sep(chars[i]) {
root.push(sep);
i += 1;
}
(root, i)
} else if n >= 1 && is_sep(chars[0]) {
(String::from(sep), 1) } else {
(String::new(), 0) };
let absolute = anchor.ends_with(sep);
let tail: String = chars[start..].iter().collect();
let mut stack: Vec<&str> = Vec::new();
for comp in tail.split(is_sep).filter(|c| !c.is_empty()) {
match comp {
"." => {}
".." => match stack.last() {
Some(&top) if top != ".." => {
stack.pop();
}
_ if absolute => {} _ => stack.push(".."),
},
other => stack.push(other),
}
}
let joined = stack.join(&sep.to_string());
match (anchor.is_empty(), joined.is_empty()) {
(true, true) => ".".to_string(),
(true, false) => joined,
(false, true) => anchor,
(false, false) => format!("{anchor}{joined}"),
}
}
fn resolve_native_static_lib(
spec: &str,
search_dirs: &[PathBuf],
file_hasher: &FileHasher<'_>,
) -> Result<Option<(PathBuf, String)>> {
let Some(name) = clean_static_lib_name(spec) else {
return Ok(None);
};
let mut found: Option<PathBuf> = None;
for dir in search_dirs {
for filename in [format!("lib{name}.a"), format!("{name}.lib")] {
let candidate = dir.join(&filename);
if candidate.is_file() {
if found.as_ref().is_some_and(|path| path == &candidate) {
continue;
}
if found.is_some() {
anyhow::bail!("ambiguous native static library {name:?}");
}
found = Some(candidate);
}
}
}
let Some(path) = found else {
return Ok(None);
};
let hash = file_hasher.hash_static_lib(&path)?;
Ok(Some((path, hash)))
}
fn native_linker_side_files_are_unmodeled(args: &RustcArgs) -> bool {
let apple_target = match args.target.as_deref() {
Some(target) => is_builtin_apple_target(target),
None => cfg!(target_vendor = "apple"),
};
args.codegen_opts.iter().any(|(key, value)| {
matches!(key.as_str(), "link-arg" | "link-args")
&& value
.as_deref()
.is_some_and(|value| linker_value_has_unmodeled_file(value, apple_target))
})
}
fn is_builtin_apple_target(target: &str) -> bool {
matches!(
target,
"aarch64-apple-darwin"
| "aarch64-apple-ios"
| "aarch64-apple-ios-macabi"
| "aarch64-apple-ios-sim"
| "aarch64-apple-tvos"
| "aarch64-apple-tvos-sim"
| "aarch64-apple-visionos"
| "aarch64-apple-visionos-sim"
| "aarch64-apple-watchos"
| "aarch64-apple-watchos-sim"
| "arm64_32-apple-watchos"
| "arm64e-apple-darwin"
| "arm64e-apple-ios"
| "arm64e-apple-tvos"
| "armv7k-apple-watchos"
| "armv7s-apple-ios"
| "i386-apple-ios"
| "i686-apple-darwin"
| "x86_64-apple-darwin"
| "x86_64-apple-ios"
| "x86_64-apple-ios-macabi"
| "x86_64-apple-tvos"
| "x86_64-apple-watchos-sim"
| "x86_64h-apple-darwin"
)
}
fn linker_value_has_unmodeled_file(value: &str, apple_target: bool) -> bool {
value.split([',', '=', ' ', '\t', '\n', '\r']).any(|token| {
matches!(
token,
"-map"
| "-Map"
| "--Map"
| "-order_file"
| "-sectorder"
| "--symbol-ordering-file"
| "--call-graph-ordering-file"
| "--section-ordering-file"
) || token.eq_ignore_ascii_case("/map")
|| ascii_prefix_eq_ignore_case(token, "/map:")
|| ascii_prefix_eq_ignore_case(token, "/mapinfo:")
|| ascii_prefix_eq_ignore_case(token, "/order:")
|| ascii_prefix_eq_ignore_case(token, "/call-graph-ordering-file:")
|| (token.starts_with('@')
&& !(apple_target
&& (token == "@loader_path"
|| token.starts_with("@loader_path/")
|| token == "@rpath"
|| token.starts_with("@rpath/")
|| token == "@executable_path"
|| token.starts_with("@executable_path/"))))
})
}
fn ascii_prefix_eq_ignore_case(value: &str, prefix: &str) -> bool {
value
.get(..prefix.len())
.is_some_and(|head| head.eq_ignore_ascii_case(prefix))
}
fn clean_static_lib_name(spec: &str) -> Option<&str> {
let (kind, name) = spec.split_once('=')?;
if kind != "static" {
return None;
}
if name.is_empty() || name.contains(':') {
return None;
}
Some(name)
}
fn sentinelized_env_dep_value(resolved: &str, normalized: &str) -> String {
if let Some(rel) = out_dir_relative_suffix(resolved) {
let unit = std::env::var_os("OUT_DIR")
.map(std::path::PathBuf::from)
.as_deref()
.and_then(|p| p.parent().and_then(|d| d.file_name().map(|n| n.to_owned())))
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
if rel.is_empty() {
format!("<OUT_DIR:{unit}>")
} else {
format!("<OUT_DIR:{unit}>/{}", rel.trim_start_matches('/'))
}
} else {
normalized.to_string()
}
}
fn normalize_env_dep_value_with_hasher(
crate_name: &str,
var: &str,
val: &str,
source_files: &[std::path::PathBuf],
file_hasher: &FileHasher<'_>,
path_normalizer: &PathNormalizer,
) -> NormalizedEnvDep {
let resolved = crate::path_normalizer::canonical_string(std::path::Path::new(val))
.unwrap_or_else(|| lexically_resolve_path(val));
let normalized = path_normalizer.normalize(&resolved);
if resolved == val && normalized == val {
return NormalizedEnvDep {
value: val.to_string(),
decision: EnvDepNormalizationDecision::Unchanged,
};
}
let forced = var != "CARGO_MANIFEST_DIR"
&& path_normalizer.path_only_env_vars().iter().any(|entry| {
matches!(entry.split_once(':'), Some((krate, v)) if krate == crate_name && v == var)
});
if forced {
return NormalizedEnvDep {
value: sentinelized_env_dep_value(&resolved, &normalized),
decision: EnvDepNormalizationDecision::ForcedPathOnly,
};
}
if env_dep_is_safe_to_normalize(
var,
&resolved,
source_files,
path_normalizer.path_only_env_vars(),
file_hasher,
) {
return NormalizedEnvDep {
value: sentinelized_env_dep_value(&resolved, &normalized),
decision: EnvDepNormalizationDecision::NormalizedPathOnly,
};
}
NormalizedEnvDep {
value: val.to_string(),
decision: EnvDepNormalizationDecision::KeptAbsoluteRuntimePath,
}
}
#[cfg(test)]
fn normalize_env_dep_value(
crate_name: &str,
var: &str,
val: &str,
source_files: &[std::path::PathBuf],
path_normalizer: &PathNormalizer,
) -> NormalizedEnvDep {
normalize_env_dep_value_with_hasher(
crate_name,
var,
val,
source_files,
&FileHasher::new(),
path_normalizer,
)
}
fn env_dep_is_safe_to_normalize(
var: &str,
val: &str,
source_files: &[std::path::PathBuf],
allowlist: &[String],
file_hasher: &FileHasher<'_>,
) -> bool {
(var == "OUT_DIR" || allowlist.iter().any(|v| v == var) || value_is_under_out_dir(val))
&& path_is_only_used_for_includes(val, source_files)
&& !env_dep_has_runtime_value_use(var, source_files, file_hasher)
}
fn value_is_under_out_dir(val: &str) -> bool {
out_dir_relative_suffix(val).is_some()
}
fn out_dir_relative_suffix(val: &str) -> Option<String> {
let out_dir = std::env::var_os("OUT_DIR")?;
let out_dir = Path::new(&out_dir);
let out_canonical = std::fs::canonicalize(out_dir).ok();
let out_probe = out_canonical.as_deref().unwrap_or(out_dir);
if !out_probe.is_absolute() {
return None;
}
let val_path = Path::new(val);
let val_canonical = std::fs::canonicalize(val_path).ok();
let val_probe = val_canonical.as_deref().unwrap_or(val_path);
val_probe
.strip_prefix(out_probe)
.ok()
.map(|rel| rel.to_string_lossy().replace('\\', "/"))
}
fn path_is_only_used_for_includes(
out_dir_value: &str,
source_files: &[std::path::PathBuf],
) -> bool {
let raw = Path::new(out_dir_value);
let canonical = std::fs::canonicalize(raw).ok();
let probe = canonical.as_deref().unwrap_or(raw);
source_files.iter().any(|f| {
let f_canonical = std::fs::canonicalize(f).ok();
let f_probe = f_canonical.as_deref().unwrap_or(f.as_path());
f_probe.starts_with(probe)
})
}
fn env_dep_has_runtime_value_use(
var: &str,
source_files: &[std::path::PathBuf],
file_hasher: &FileHasher<'_>,
) -> bool {
for file in source_files {
match file_hasher.runtime_env_use(file, var) {
Ok(false) => {}
Ok(true) => return true,
Err(e) => {
tracing::debug!(
"keeping env dep {var} absolute: failed to inspect source {}: {}",
file.display(),
e
);
return true;
}
}
}
false
}
fn source_has_runtime_env_dep_use(source: &str, var: &str) -> bool {
let bytes = source.as_bytes();
let mut i = 0usize;
let mut macro_stack: Vec<String> = Vec::new();
while i < bytes.len() {
match bytes[i] {
b'/' if bytes.get(i + 1) == Some(&b'/') => {
i += 2;
while i < bytes.len() && bytes[i] != b'\n' {
i += 1;
}
}
b'/' if bytes.get(i + 1) == Some(&b'*') => {
i += 2;
while i + 1 < bytes.len() && !(bytes[i] == b'*' && bytes[i + 1] == b'/') {
i += 1;
}
i = (i + 2).min(bytes.len());
}
b'"' => i = skip_quoted_string(bytes, i + 1),
b'\'' => i = skip_char_literal(bytes, i + 1),
b'r' | b'b' if raw_string_starts_at(bytes, i).is_some() => {
i = skip_raw_string(bytes, i);
}
b')' => {
let _ = macro_stack.pop();
i += 1;
}
b if is_ident_start(b) => {
let ident_start = i;
i += 1;
while i < bytes.len() && is_ident_continue(bytes[i]) {
i += 1;
}
let ident = &source[ident_start..i];
if matches!(ident, "env" | "option_env")
&& let Some((env_var, next)) = parse_env_macro_string(source, i)
&& env_var == var
{
if !macro_stack.iter().any(|name| is_include_macro(name)) {
return true;
}
i = next;
continue;
}
if let Some(next) = parse_macro_open(source, i) {
macro_stack.push(ident.to_string());
i = next;
}
}
_ => i += 1,
}
}
false
}
fn is_include_macro(name: &str) -> bool {
matches!(name, "include" | "include_str" | "include_bytes")
}
fn parse_env_macro_string(source: &str, after_ident: usize) -> Option<(&str, usize)> {
let bytes = source.as_bytes();
let mut i = skip_ascii_ws(bytes, after_ident);
if bytes.get(i) != Some(&b'!') {
return None;
}
i = skip_ascii_ws(bytes, i + 1);
if bytes.get(i) != Some(&b'(') {
return None;
}
i = skip_ascii_ws(bytes, i + 1);
if bytes.get(i) != Some(&b'"') {
return None;
}
let value_start = i + 1;
i = value_start;
while i < bytes.len() {
match bytes[i] {
b'\\' => i += 2,
b'"' => return Some((&source[value_start..i], i + 1)),
_ => i += 1,
}
}
None
}
fn parse_macro_open(source: &str, after_ident: usize) -> Option<usize> {
let bytes = source.as_bytes();
let mut i = skip_ascii_ws(bytes, after_ident);
if bytes.get(i) != Some(&b'!') {
return None;
}
i = skip_ascii_ws(bytes, i + 1);
if bytes.get(i) == Some(&b'(') {
Some(i + 1)
} else {
None
}
}
fn skip_ascii_ws(bytes: &[u8], mut i: usize) -> usize {
while i < bytes.len() && bytes[i].is_ascii_whitespace() {
i += 1;
}
i
}
fn skip_quoted_string(bytes: &[u8], mut i: usize) -> usize {
while i < bytes.len() {
match bytes[i] {
b'\\' => i += 2,
b'"' => return i + 1,
_ => i += 1,
}
}
bytes.len()
}
fn skip_char_literal(bytes: &[u8], mut i: usize) -> usize {
while i < bytes.len() {
match bytes[i] {
b'\\' => i += 2,
b'\'' => return i + 1,
_ => i += 1,
}
}
bytes.len()
}
fn raw_string_starts_at(bytes: &[u8], i: usize) -> Option<usize> {
let mut cursor = i;
if bytes.get(cursor) == Some(&b'b') {
cursor += 1;
}
if bytes.get(cursor) != Some(&b'r') {
return None;
}
cursor += 1;
while bytes.get(cursor) == Some(&b'#') {
cursor += 1;
}
if bytes.get(cursor) == Some(&b'"') {
Some(cursor)
} else {
None
}
}
fn skip_raw_string(bytes: &[u8], i: usize) -> usize {
let Some(open_quote) = raw_string_starts_at(bytes, i) else {
return i + 1;
};
let hashes = open_quote - i - usize::from(bytes[i] == b'b') - 1;
let mut cursor = open_quote + 1;
while cursor < bytes.len() {
if bytes[cursor] == b'"'
&& cursor + hashes < bytes.len()
&& bytes[cursor + 1..cursor + 1 + hashes]
.iter()
.all(|b| *b == b'#')
{
return cursor + hashes + 1;
}
cursor += 1;
}
bytes.len()
}
fn is_ident_start(byte: u8) -> bool {
byte == b'_' || byte.is_ascii_alphabetic()
}
fn is_ident_continue(byte: u8) -> bool {
is_ident_start(byte) || byte.is_ascii_digit()
}
fn compute_static_lib_hash(path: &Path) -> Result<String> {
let bytes = std::fs::read(path).with_context(|| format!("reading {}", path.display()))?;
if bytes.starts_with(b"!<thin>\n") {
anyhow::bail!(
"thin static archive {} has external members that are not modeled",
path.display()
);
}
if let Some(portable) = crate::native_archive::portable_static_archive_hash(&bytes) {
return Ok(portable);
}
let absolute = std::path::absolute(path).unwrap_or_else(|_| path.to_path_buf());
let encoded_path = absolute.as_os_str().as_encoded_bytes();
let mut hasher = blake3::Hasher::new();
hasher.update(b"kache.native-ar.path-bound-fallback.v1\0");
hasher.update(&(encoded_path.len() as u64).to_le_bytes());
hasher.update(encoded_path);
hasher.update(&(bytes.len() as u64).to_le_bytes());
hasher.update(&bytes);
Ok(format!("path-ar-v1:{}", hasher.finalize().to_hex()))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DepInfo {
pub source_files: Vec<std::path::PathBuf>,
pub env_deps: Vec<(String, String)>,
}
pub(crate) const PREDICTION_SCHEMA: u32 = 1;
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub(crate) struct InputPrediction {
pub(crate) schema: u32,
pub(crate) sources: Vec<PathBuf>,
pub(crate) env_deps: Vec<(String, String)>,
}
impl InputPrediction {
fn from_dep_info(dep_info: &DepInfo) -> Self {
Self {
schema: PREDICTION_SCHEMA,
sources: dep_info.source_files.clone(),
env_deps: dep_info.env_deps.clone(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Rejection {
Disabled,
NotEligible,
NoRecord,
Missing,
NotRegular,
EnvChanged,
Sibling,
}
impl Rejection {
fn as_str(self) -> &'static str {
match self {
Rejection::Disabled => "disabled",
Rejection::NotEligible => "not-eligible",
Rejection::NoRecord => "no-record",
Rejection::Missing => "missing",
Rejection::NotRegular => "not-regular",
Rejection::EnvChanged => "env-changed",
Rejection::Sibling => "sibling",
}
}
}
pub(crate) fn prediction_applies(externs: &[crate::args::ExternDep]) -> bool {
!externs.iter().any(|ext| {
ext.path.as_deref().is_some_and(|path| {
path.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| {
matches!(
crate::compiler::classify_by_filename(name),
crate::compiler::ArtifactKind::DynamicLibrary
)
})
})
})
}
fn mod_sibling_candidate(file: &Path) -> Option<PathBuf> {
let stem = file.file_stem()?.to_str()?;
let parent = file.parent()?;
if file.extension().and_then(|e| e.to_str()) != Some("rs") {
return None;
}
match stem {
"mod" => Some(
parent
.parent()?
.join(parent.file_name()?)
.with_extension("rs"),
),
"lib" | "main" => None,
stem => Some(parent.join(stem).join("mod.rs")),
}
}
pub(crate) fn validate_prediction(
record: &InputPrediction,
stat: impl Fn(&Path) -> Option<std::fs::Metadata>,
exists: impl Fn(&Path) -> bool,
env_value: impl Fn(&str) -> Option<String>,
) -> std::result::Result<DepInfo, Rejection> {
for file in &record.sources {
let Some(metadata) = stat(file) else {
return Err(Rejection::Missing);
};
if !metadata.is_file() {
return Err(Rejection::NotRegular);
}
if mod_sibling_candidate(file).is_some_and(|sibling| exists(&sibling)) {
return Err(Rejection::Sibling);
}
}
for (var, recorded) in &record.env_deps {
let matches = match env_value(var) {
Some(value) => value == *recorded,
None => recorded.is_empty(),
};
if !matches {
return Err(Rejection::EnvChanged);
}
}
Ok(DepInfo {
source_files: record.sources.clone(),
env_deps: record.env_deps.clone(),
})
}
pub(crate) struct PredictionIdentityParts<'a> {
pub(crate) rustc_version: &'a str,
pub(crate) inner_rustc: Option<&'a Path>,
pub(crate) current_dir: Option<&'a Path>,
pub(crate) source_file: &'a Path,
pub(crate) closure_args: &'a [String],
pub(crate) skip_path_remap: bool,
}
const PREDICTION_ENV: &[&str] = &[
"RUSTFLAGS",
"CARGO_ENCODED_RUSTFLAGS",
"RUSTC_BOOTSTRAP",
"OUT_DIR",
"CARGO_MANIFEST_DIR",
];
fn prediction_identity_in_env(
parts: &PredictionIdentityParts<'_>,
vars: Vec<(std::ffi::OsString, std::ffi::OsString)>,
) -> String {
let mut hasher = blake3::Hasher::new();
hasher.update(b"kache-input-prediction-v1\n");
fold_field(
&mut hasher,
b"prediction_schema:",
PREDICTION_SCHEMA.to_string().as_bytes(),
);
fold_field(
&mut hasher,
b"key_version:",
CACHE_KEY_VERSION.to_string().as_bytes(),
);
fold_field(
&mut hasher,
b"rustc_version:",
parts.rustc_version.as_bytes(),
);
fold_field(
&mut hasher,
b"inner_rustc:",
&parts
.inner_rustc
.map(|path| env_os_key_bytes(path.as_os_str()))
.unwrap_or_default(),
);
fold_field(
&mut hasher,
b"current_dir:",
&parts
.current_dir
.map(|path| env_os_key_bytes(path.as_os_str()))
.unwrap_or_default(),
);
fold_field(
&mut hasher,
b"source_file:",
&env_os_key_bytes(parts.source_file.as_os_str()),
);
fold_field(
&mut hasher,
b"closure_args_len:",
parts.closure_args.len().to_string().as_bytes(),
);
for arg in parts.closure_args {
fold_field(&mut hasher, b"closure_arg:", arg.as_bytes());
}
let by_name: std::collections::BTreeMap<Vec<u8>, &std::ffi::OsString> = vars
.iter()
.map(|(name, value)| (env_text_key_bytes(name), value))
.collect();
for name in PREDICTION_ENV {
fold_field(&mut hasher, b"env_var:", name.as_bytes());
match by_name.get(name.as_bytes()) {
Some(value) => {
fold_field(&mut hasher, b"env_set:", b"1");
fold_field(&mut hasher, b"env_val:", &env_os_key_bytes(value));
}
None => fold_field(&mut hasher, b"env_set:", b"0"),
}
}
for (name, value) in cargo_cfg_pairs(vars.iter().cloned()) {
fold_field(&mut hasher, b"cargo_cfg_name:", &env_text_key_bytes(&name));
fold_field(&mut hasher, b"cargo_cfg_val:", &env_os_key_bytes(&value));
}
fold_field(
&mut hasher,
b"skip_path_remap:",
if parts.skip_path_remap { b"1" } else { b"0" },
);
hasher.finalize().to_hex().to_string()
}
pub struct FileHasher<'db> {
cache: Option<FileHashCache<'db>>,
daemon_socket: Option<PathBuf>,
use_input_predictions: bool,
prefetched: RefCell<HashMap<FileFingerprint, PrefetchedHash>>,
recent_hashes: RefCell<HashMap<PathBuf, RecentHash>>,
runtime_env_uses: RefCell<HashMap<(String, String), bool>>,
stats: FileHashStatsCells,
too_new: TooNewGuard,
guard_inputs: RefCell<Vec<FileFingerprint>>,
}
#[derive(Default)]
struct TooNewGuard {
invocation_start_ns: i64,
margin_ns: i64,
saw_too_new: Cell<bool>,
}
#[derive(Debug, Default, Clone, Copy)]
pub struct FileHashStats {
pub cache_hits: u64,
pub cache_misses: u64,
pub bytes_hashed: u64,
}
#[derive(Default)]
struct FileHashStatsCells {
cache_hits: Cell<u64>,
cache_misses: Cell<u64>,
bytes_hashed: Cell<u64>,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub(crate) struct CcPreprocessMemoInput {
name: String,
#[serde(flatten)]
fingerprint: FileFingerprint,
content: String,
#[serde(default)]
mapped: String,
}
impl CcPreprocessMemoInput {
pub(crate) fn local_path(&self) -> &str {
&self.fingerprint.path
}
}
#[derive(Debug, Clone)]
struct PrefetchedHash {
hash: String,
cache_hit: bool,
bytes_hashed: u64,
}
#[derive(Clone)]
struct RecentHash {
hash: String,
fingerprint: Option<FileFingerprint>,
}
impl FileHasher<'static> {
pub fn new() -> Self {
FileHasher {
cache: None,
daemon_socket: None,
use_input_predictions: false,
prefetched: RefCell::new(HashMap::new()),
recent_hashes: RefCell::new(HashMap::new()),
runtime_env_uses: RefCell::new(HashMap::new()),
stats: FileHashStatsCells::default(),
too_new: TooNewGuard::default(),
guard_inputs: RefCell::new(Vec::new()),
}
}
#[cfg(test)]
pub fn persistent(index_db_path: &Path) -> Self {
match FileHashCache::open(index_db_path) {
Ok(cache) => FileHasher {
cache: Some(cache),
daemon_socket: None,
use_input_predictions: false,
prefetched: RefCell::new(HashMap::new()),
recent_hashes: RefCell::new(HashMap::new()),
runtime_env_uses: RefCell::new(HashMap::new()),
stats: FileHashStatsCells::default(),
too_new: TooNewGuard::default(),
guard_inputs: RefCell::new(Vec::new()),
},
Err(e) => {
tracing::debug!(
"file hash cache disabled for {}: {e}",
index_db_path.display()
);
FileHasher::new()
}
}
}
}
impl<'db> FileHasher<'db> {
pub(crate) fn from_cache(cache: FileHashCache<'db>) -> Self {
FileHasher {
cache: Some(cache),
daemon_socket: None,
use_input_predictions: false,
prefetched: RefCell::new(HashMap::new()),
recent_hashes: RefCell::new(HashMap::new()),
runtime_env_uses: RefCell::new(HashMap::new()),
stats: FileHashStatsCells::default(),
too_new: TooNewGuard::default(),
guard_inputs: RefCell::new(Vec::new()),
}
}
pub(crate) fn with_daemon(mut self, socket_path: PathBuf) -> Self {
self.daemon_socket = Some(socket_path);
self
}
pub(crate) fn with_input_predictions(mut self, enabled: bool) -> Self {
self.use_input_predictions = enabled;
self
}
fn uses_input_predictions(&self) -> bool {
self.use_input_predictions && self.cache.is_some()
}
pub fn arm_too_new_guard(&mut self, invocation_start_ns: i64, margin_ns: i64) {
self.too_new.invocation_start_ns = invocation_start_ns;
self.too_new.margin_ns = margin_ns;
}
pub fn too_new(&self) -> bool {
self.too_new.saw_too_new.get()
}
pub fn take_guarded_inputs(&self) -> Vec<FileFingerprint> {
std::mem::take(&mut *self.guard_inputs.borrow_mut())
}
pub fn guarded_inputs_unchanged_since_hash(inputs: &[FileFingerprint]) -> bool {
if inputs.is_empty() {
return false;
}
inputs.iter().all(|expected| {
expected.inode != 0
&& FileFingerprint::from_path(Path::new(&expected.path))
.is_ok_and(|current| current == *expected)
})
}
fn note_too_new(&self, fingerprint: &FileFingerprint) {
if self.too_new.invocation_start_ns > 0 {
let threshold = self.too_new.invocation_start_ns - self.too_new.margin_ns;
if fingerprint.mtime_ns >= threshold || fingerprint.ctime_ns >= threshold {
self.too_new.saw_too_new.set(true);
}
}
}
pub fn stats(&self) -> FileHashStats {
FileHashStats {
cache_hits: self.stats.cache_hits.get(),
cache_misses: self.stats.cache_misses.get(),
bytes_hashed: self.stats.bytes_hashed.get(),
}
}
pub(crate) fn supports_cc_preprocess_memo(&self) -> bool {
self.cache.is_some()
}
pub(crate) fn supports_input_predictions(&self) -> bool {
self.cache.is_some()
}
pub(crate) fn record_input_prediction(
&self,
identity: &str,
crate_name: Option<&str>,
dep_info: &DepInfo,
) {
let Some(cache) = self.cache.as_ref() else {
return;
};
let record = InputPrediction::from_dep_info(dep_info);
let json = match serde_json::to_string(&record) {
Ok(json) => json,
Err(error) => {
tracing::debug!("input prediction encode failed: {error}");
return;
}
};
if let Err(error) = cache.put_input_prediction(identity, record.schema, crate_name, &json) {
tracing::debug!("input prediction record failed: {error}");
}
}
pub(crate) fn input_prediction(&self, identity: &str) -> Option<InputPrediction> {
let cache = self.cache.as_ref()?;
let (schema, json) = match cache.get_input_prediction(identity) {
Ok(row) => row?,
Err(error) => {
tracing::debug!("input prediction lookup failed: {error}");
return None;
}
};
if schema != PREDICTION_SCHEMA {
return None;
}
match serde_json::from_str::<InputPrediction>(&json) {
Ok(record) if record.schema == PREDICTION_SCHEMA => Some(record),
Ok(_) => None,
Err(error) => {
tracing::debug!("input prediction decode failed: {error}");
None
}
}
}
pub(crate) fn cc_preprocess_memo_lookup(
&self,
memo_key: &str,
resolve: impl Fn(&str) -> Vec<PathBuf>,
mapped_content: &impl Fn(&Path) -> Option<String>,
) -> Option<(String, Vec<PathBuf>)> {
let cache = self.cache.as_ref()?;
let record = match cache.get_cc_preprocess_memo(memo_key) {
Ok(record) => record?,
Err(error) => {
tracing::debug!("cc preprocess memo lookup failed: {error}");
return None;
}
};
if record.0.len() != 64
|| !record
.0
.bytes()
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
{
tracing::debug!("cc preprocess memo hash is invalid");
return None;
}
let inputs: Vec<CcPreprocessMemoInput> = match serde_json::from_str(&record.1) {
Ok(inputs) => inputs,
Err(error) => {
tracing::debug!("cc preprocess memo inputs are invalid: {error}");
return None;
}
};
if inputs.is_empty() {
return None;
}
let mut satisfied = Vec::with_capacity(inputs.len());
for expected in &inputs {
satisfied.push(self.memo_input_is_unchanged(expected, &resolve, mapped_content)?);
}
Some((record.0, satisfied))
}
fn memo_input_is_unchanged(
&self,
expected: &CcPreprocessMemoInput,
resolve: &impl Fn(&str) -> Vec<PathBuf>,
mapped_content: &impl Fn(&Path) -> Option<String>,
) -> Option<PathBuf> {
let candidates = resolve(&expected.name);
for path in &candidates {
let Ok(current) = FileFingerprint::from_path(path) else {
continue;
};
self.note_too_new(¤t);
if current == expected.fingerprint {
return Some(path.clone());
}
if self
.hash(path)
.is_ok_and(|content| content == expected.content)
{
return Some(path.clone());
}
if !expected.mapped.is_empty()
&& mapped_content(path).is_some_and(|mapped| mapped == expected.mapped)
{
return Some(path.clone());
}
}
tracing::debug!(
"cc preprocess memo input {} matched none of {} candidate paths",
expected.name,
candidates.len()
);
None
}
pub(crate) fn cc_preprocess_fingerprints(
&self,
paths: &[(String, PathBuf)],
mapped_content: &impl Fn(&Path) -> Option<String>,
) -> Option<Vec<CcPreprocessMemoInput>> {
if paths.is_empty() {
return None;
}
let mut inputs = Vec::with_capacity(paths.len());
for (name, path) in paths {
let fingerprint = match FileFingerprint::from_path(path) {
Ok(fingerprint) => fingerprint,
Err(error) => {
tracing::debug!(
"cc preprocess memo input {} could not be fingerprinted: {error}",
path.display()
);
return None;
}
};
self.note_too_new(&fingerprint);
let content = match self.hash(path) {
Ok(content) => content,
Err(error) => {
tracing::debug!(
"cc preprocess memo input {} could not be hashed: {error}",
path.display()
);
return None;
}
};
let mapped = mapped_content(path)?;
inputs.push(CcPreprocessMemoInput {
name: name.clone(),
fingerprint,
content,
mapped,
});
}
inputs.sort_by(|a, b| a.name.cmp(&b.name));
inputs.dedup_by(|a, b| a.name == b.name);
Some(inputs)
}
pub(crate) fn cc_preprocess_memo_record_if_unchanged(
&self,
memo_key: &str,
preprocessed_hash: &str,
inputs: &[CcPreprocessMemoInput],
mapped_content: &impl Fn(&Path) -> Option<String>,
) {
let Some(cache) = &self.cache else {
return;
};
if inputs.is_empty() {
return;
}
for expected in inputs {
let recorded_path = PathBuf::from(&expected.fingerprint.path);
if self
.memo_input_is_unchanged(
expected,
&|_: &str| vec![recorded_path.clone()],
mapped_content,
)
.is_none()
{
return;
}
}
let inputs_json = match serde_json::to_string(inputs) {
Ok(inputs_json) => inputs_json,
Err(error) => {
tracing::debug!("cc preprocess memo inputs could not be encoded: {error}");
return;
}
};
if let Err(error) = cache.put_cc_preprocess_memo(memo_key, preprocessed_hash, &inputs_json)
{
tracing::debug!("cc preprocess memo update failed: {error}");
}
}
pub fn prefetch(&self, paths: &[&Path]) {
let Some(socket_path) = &self.daemon_socket else {
return;
};
let mut requests = Vec::new();
for path in paths {
let Ok(fingerprint) = FileFingerprint::from_path(path) else {
continue;
};
if fingerprint.size < MIN_PERSISTED_HASH_BYTES
|| self.prefetched.borrow().contains_key(&fingerprint)
{
continue;
}
requests.push(crate::daemon::HashFileRequest {
path: fingerprint.path,
size: fingerprint.size,
mtime_ns: fingerprint.mtime_ns,
ctime_ns: fingerprint.ctime_ns,
inode: fingerprint.inode,
});
}
if requests.is_empty() {
return;
}
match crate::daemon::send_hash_files_request(socket_path, requests) {
Ok(results) => {
let mut prefetched = self.prefetched.borrow_mut();
for result in results {
let Some(hash) = result.hash else {
continue;
};
prefetched.insert(
FileFingerprint {
path: result.path,
size: result.size,
mtime_ns: result.mtime_ns,
ctime_ns: result.ctime_ns,
inode: result.inode,
},
PrefetchedHash {
hash,
cache_hit: result.cache_hit,
bytes_hashed: result.bytes_hashed,
},
);
}
}
Err(e) => tracing::debug!("daemon file hash prefetch failed: {e}"),
}
}
pub fn hash(&self, path: &Path) -> Result<String> {
let (hash, fingerprint) = self.hash_inner(path)?;
if self.too_new.invocation_start_ns > 0
&& let Some(fingerprint) = &fingerprint
{
self.guard_inputs.borrow_mut().push(fingerprint.clone());
}
self.recent_hashes.borrow_mut().insert(
absolute_path(path),
RecentHash {
hash: hash.clone(),
fingerprint,
},
);
Ok(hash)
}
fn hash_inner(&self, path: &Path) -> Result<(String, Option<FileFingerprint>)> {
let Some(cache) = &self.cache else {
if self.too_new.invocation_start_ns == 0 {
let hash = hash_file(path)?;
return Ok((hash, FileFingerprint::from_path(path).ok()));
}
let before = FileFingerprint::from_path(path).ok();
if let Some(fingerprint) = &before {
self.note_too_new(fingerprint);
}
let hash = hash_file(path)?;
let after = FileFingerprint::from_path(path).ok();
if let Some(fingerprint) = &after {
self.note_too_new(fingerprint);
}
if before != after {
self.too_new.saw_too_new.set(true);
}
return Ok((hash, after));
};
let fingerprint = match FileFingerprint::from_path(path) {
Ok(fingerprint) => fingerprint,
Err(e) => {
tracing::debug!(
"file hash cache metadata lookup failed for {}: {e}",
path.display()
);
return hash_file(path).map(|hash| (hash, None));
}
};
self.note_too_new(&fingerprint);
if fingerprint.size < MIN_PERSISTED_HASH_BYTES {
let hash = hash_file(path)?;
self.record_miss(fingerprint.size);
return Ok((hash, Some(fingerprint)));
}
if let Some(prefetched) = self.prefetched.borrow().get(&fingerprint) {
if prefetched.cache_hit {
self.record_hit();
} else {
self.record_miss_count();
self.record_miss_bytes(prefetched.bytes_hashed);
}
return Ok((prefetched.hash.clone(), Some(fingerprint)));
}
match cache.get(&fingerprint) {
Ok(Some(hash)) => {
self.record_hit();
return Ok((hash, Some(fingerprint)));
}
Ok(None) => {}
Err(e) => {
tracing::debug!("file hash cache lookup failed for {}: {e}", path.display());
}
}
let hash = hash_file(path)?;
self.record_miss(fingerprint.size);
if let Err(e) = cache.put(&fingerprint, &hash) {
tracing::debug!("file hash cache update failed for {}: {e}", path.display());
}
Ok((hash, Some(fingerprint)))
}
fn runtime_env_use(&self, path: &Path, var: &str) -> Result<bool> {
let absolute = absolute_path(path);
let recent = self.recent_hashes.borrow().get(&absolute).cloned();
let recent = match recent {
Some(recent) => recent,
None => {
self.hash(path)?;
self.recent_hashes
.borrow()
.get(&absolute)
.cloned()
.expect("a successful hash records its fingerprint")
}
};
if let Some(expected) = recent.fingerprint {
let current = FileFingerprint::from_path(path)
.with_context(|| format!("revalidating {} before env-use scan", path.display()))?;
if current != expected {
anyhow::bail!(
"source {} changed between content hashing and env-use scan",
path.display()
);
}
return self.runtime_env_use_for_hash(path, var, &recent.hash);
}
self.scan_runtime_env_use(path, var, &recent.hash)
}
fn runtime_env_use_for_hash(&self, path: &Path, var: &str, content_hash: &str) -> Result<bool> {
let key = (content_hash.to_string(), var.to_string());
if let Some(result) = self.runtime_env_uses.borrow().get(&key) {
return Ok(*result);
}
if let Some(cache) = &self.cache {
match cache.get_runtime_env_use(content_hash, var) {
Ok(Some(result)) => {
self.runtime_env_uses.borrow_mut().insert(key, result);
return Ok(result);
}
Ok(None) => {}
Err(error) => {
tracing::debug!("runtime env-use cache lookup failed: {error}");
}
}
}
self.scan_runtime_env_use(path, var, content_hash)
}
fn scan_runtime_env_use(&self, path: &Path, var: &str, content_hash: &str) -> Result<bool> {
let key = (content_hash.to_string(), var.to_string());
let bytes = std::fs::read(path)
.with_context(|| format!("reading {} for env-use scan", path.display()))?;
let observed_hash = blake3::hash(&bytes).to_hex().to_string();
if observed_hash != content_hash {
anyhow::bail!(
"source {} changed between content hashing and env-use scan",
path.display()
);
}
let source = String::from_utf8_lossy(&bytes);
let result = source_has_runtime_env_dep_use(&source, var);
if let Some(cache) = &self.cache
&& let Err(error) = cache.put_runtime_env_use(content_hash, var, result)
{
tracing::debug!("runtime env-use cache update failed: {error}");
}
self.runtime_env_uses.borrow_mut().insert(key, result);
Ok(result)
}
pub fn hash_static_lib(&self, path: &Path) -> Result<String> {
let Some(cache) = &self.cache else {
if let Ok(fingerprint) = FileFingerprint::from_path(path) {
self.note_too_new(&fingerprint);
}
return compute_static_lib_hash(path);
};
let fingerprint = match FileFingerprint::from_path(path) {
Ok(fp) => fp,
Err(e) => {
tracing::debug!(
"static-lib hash metadata lookup failed for {}: {e}",
path.display()
);
return compute_static_lib_hash(path);
}
};
self.note_too_new(&fingerprint);
let size = fingerprint.size;
if size < MIN_PERSISTED_HASH_BYTES {
let hash = compute_static_lib_hash(path)?;
self.record_miss(size);
return Ok(hash);
}
let key = FileFingerprint {
path: format!("static-ar-v5\0{}", fingerprint.path),
size: fingerprint.size,
mtime_ns: fingerprint.mtime_ns,
ctime_ns: fingerprint.ctime_ns,
inode: fingerprint.inode,
};
match cache.get(&key) {
Ok(Some(hash)) => {
self.record_hit();
return Ok(hash);
}
Ok(None) => {}
Err(e) => tracing::debug!("static-lib hash cache lookup failed: {e}"),
}
let hash = compute_static_lib_hash(path)?;
self.record_miss(size);
if let Err(e) = cache.put(&key, &hash) {
tracing::debug!("static-lib hash cache update failed: {e}");
}
Ok(hash)
}
fn record_hit(&self) {
self.stats.cache_hits.set(self.stats.cache_hits.get() + 1);
}
fn record_miss(&self, size: i64) {
self.record_miss_count();
if let Ok(size) = u64::try_from(size) {
self.record_miss_bytes(size);
}
}
fn record_miss_count(&self) {
self.stats
.cache_misses
.set(self.stats.cache_misses.get() + 1);
}
fn record_miss_bytes(&self, bytes: u64) {
self.stats
.bytes_hashed
.set(self.stats.bytes_hashed.get().saturating_add(bytes));
}
}
fn is_extra_filename_option(value: &str) -> bool {
value.starts_with("extra-filename=") || value.starts_with("extra_filename=")
}
fn dep_info_pass_args(source_file: &Path, rustc_args: &[String], dep_file: &Path) -> Vec<String> {
let mut dep_args = closure_shaping_args(source_file, rustc_args);
dep_args.push("--emit".to_string());
dep_args.push("dep-info".to_string());
dep_args.push("-o".to_string());
dep_args.push(dep_file.to_string_lossy().into_owned());
dep_args
}
fn closure_shaping_args(source_file: &Path, rustc_args: &[String]) -> Vec<String> {
let source_str = source_file.to_string_lossy();
let rustc_args = crate::compile::strip_incremental_flags(rustc_args);
let mut dep_args = vec![source_str.to_string()];
let mut remaining = rustc_args.iter().peekable();
while let Some(arg) = remaining.next() {
match arg.as_str() {
"--emit" | "--out-dir" | "-o" => {
remaining.next(); }
"-C" | "--codegen"
if remaining
.peek()
.is_some_and(|value| is_extra_filename_option(value)) =>
{
remaining.next();
}
_ if arg.starts_with("--emit=") || arg.starts_with("--out-dir=") => {}
_ if arg.starts_with("-o") => {}
_ if arg
.strip_prefix("-C")
.or_else(|| arg.strip_prefix("--codegen="))
.is_some_and(is_extra_filename_option) => {}
_ if arg.as_str() == source_str.as_ref() => {}
_ => dep_args.push((*arg).clone()),
}
}
dep_args
}
pub(crate) fn first_rustc_error_line(stderr: &str) -> Option<&str> {
let mut fallback = None;
for line in stderr.lines() {
if line.trim().is_empty() {
continue;
}
if line.contains(r#""level":"error""#)
|| line.starts_with("error:")
|| line.starts_with("error[")
{
return Some(line);
}
fallback.get_or_insert(line);
}
fallback
}
pub fn run_dep_info_pass(
rustc: &Path,
inner_rustc: Option<&Path>,
source_file: &Path,
rustc_args: &[String],
use_response_file: bool,
) -> Result<DepInfo> {
let temp_dir = tempfile::Builder::new()
.prefix("kache-depinfo")
.tempdir()
.context("creating temp dir for dep-info")?;
let dep_file = temp_dir.path().join("deps.d");
let mut cmd = std::process::Command::new(rustc);
if let Some(inner_rustc) = inner_rustc {
cmd.arg(inner_rustc);
}
let dep_args = dep_info_pass_args(source_file, rustc_args, &dep_file);
let response_file = if use_response_file {
let response = crate::compile::RustcResponseFile::new(
dep_args.iter().map(std::string::String::as_str),
)?;
cmd.arg(response.argument());
Some(response)
} else {
cmd.args(&dep_args);
None
};
tracing::trace!("dep-info pre-pass: {:?}", cmd);
let spawned = std::time::Instant::now();
let output = cmd
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped())
.output()
.context("running rustc --emit=dep-info")?;
crate::opcounts::record_dep_info_run(spawned.elapsed());
drop(response_file);
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!(
"dep-info pre-pass failed (exit {}): {}",
output.status.code().unwrap_or(-1),
first_rustc_error_line(&stderr).unwrap_or("(no output)")
);
}
let dep_content = read_dep_info_file(&dep_file)?;
let mut source_files = parse_dep_info(&dep_content);
if source_files.is_empty() {
source_files.push(source_file.to_path_buf());
}
let env_deps = parse_env_dep_info(&dep_content);
tracing::trace!(
"dep-info found {} source files, {} env deps for {}",
source_files.len(),
env_deps.len(),
source_file.display()
);
Ok(DepInfo {
source_files,
env_deps,
})
}
fn read_dep_info_file(dep_file: &Path) -> Result<String> {
let bytes = std::fs::read(dep_file).context("reading dep-info output")?;
String::from_utf8(bytes).context("dep-info output is not valid UTF-8")
}
pub(crate) fn parse_dep_info(dep_info: &str) -> Vec<std::path::PathBuf> {
let line = match dep_info.lines().next() {
Some(l) => l,
None => return vec![],
};
let pos = match line.find(": ") {
Some(p) => p,
None => return vec![],
};
let mut deps = Vec::new();
let mut current = String::new();
let mut chars = line[pos + 2..].chars().peekable();
loop {
match chars.next() {
Some('\\') if chars.peek() == Some(&' ') => {
current.push(' ');
chars.next();
}
Some('\\') => current.push('\\'),
Some(' ') => {
if !current.is_empty() {
deps.push(std::path::PathBuf::from(¤t));
current.clear();
}
}
Some(c) => current.push(c),
None => {
if !current.is_empty() {
deps.push(std::path::PathBuf::from(¤t));
}
break;
}
}
}
deps.sort();
deps
}
fn parse_env_dep_info(dep_info: &str) -> Vec<(String, String)> {
let mut env_deps = Vec::new();
for line in dep_info.lines() {
if let Some(env_dep) = line.strip_prefix("# env-dep:") {
if let Some((var, val)) = env_dep.split_once('=') {
env_deps.push((var.to_string(), unescape_env_dep_value(val)));
} else {
env_deps.push((env_dep.to_string(), String::new()));
}
}
}
env_deps.sort_by(|(a, _), (b, _)| a.cmp(b));
env_deps
}
fn unescape_env_dep_value(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut chars = s.chars();
while let Some(c) = chars.next() {
if c != '\\' {
out.push(c);
continue;
}
match chars.next() {
Some('n') => out.push('\n'),
Some('r') => out.push('\r'),
Some('\\') => out.push('\\'),
Some(other) => {
out.push('\\');
out.push(other);
}
None => out.push('\\'),
}
}
out
}
fn get_rustc_version(rustc: &Path) -> Result<String> {
if let Some(cached) = read_tool_version_cache(rustc, "rustc-ver") {
return Ok(cached);
}
let output = std::process::Command::new(rustc)
.arg("--version")
.arg("--verbose")
.output()
.context("running rustc --version --verbose")?;
let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
write_tool_version_cache(rustc, "rustc-ver", &version);
Ok(version)
}
pub(crate) fn get_rustc_commit_hash(rustc: &Path) -> Option<String> {
let vv = get_rustc_version(rustc).ok()?;
vv.lines()
.find_map(|l| l.strip_prefix("commit-hash:"))
.map(|h| h.trim().to_string())
.filter(|h| !h.is_empty() && h != "unknown")
}
pub(crate) fn get_rustc_sysroot(args: &RustcArgs) -> Option<PathBuf> {
if let Some(sysroot) = &args.sysroot {
return Some(sysroot.clone());
}
let rustc = &args.rustc;
if let Some(cached) = read_tool_version_cache(rustc, "rustc-sysroot") {
return Some(PathBuf::from(cached));
}
let output = std::process::Command::new(rustc)
.arg("--print")
.arg("sysroot")
.output()
.ok()?;
if !output.status.success() {
return None;
}
let sysroot = String::from_utf8_lossy(&output.stdout).trim().to_string();
if sysroot.is_empty() {
return None;
}
write_tool_version_cache(rustc, "rustc-sysroot", &sysroot);
Some(PathBuf::from(sysroot))
}
fn read_tool_version_cache(binary: &Path, prefix: &str) -> Option<String> {
let cache_file = tool_version_cache_path(binary, prefix)?;
std::fs::read_to_string(cache_file)
.ok()
.filter(|s| !s.is_empty())
}
fn write_tool_version_cache(binary: &Path, prefix: &str, version: &str) {
if let Some(cache_file) = tool_version_cache_path(binary, prefix) {
let _ = std::fs::write(cache_file, version);
}
}
fn tool_version_cache_path(binary: &Path, prefix: &str) -> Option<std::path::PathBuf> {
let canon = std::fs::canonicalize(binary).ok()?;
let mtime = std::fs::metadata(&canon)
.ok()?
.modified()
.ok()?
.duration_since(std::time::UNIX_EPOCH)
.ok()?
.as_secs();
let key = format!(
"{}:{}:{}",
canon.display(),
mtime,
toolchain_selector_fingerprint(
std::env::var_os("RUSTUP_TOOLCHAIN").as_deref(),
std::env::current_dir().ok().as_deref(),
rustup_settings_path().as_deref(),
)
);
let hash = blake3::hash(key.as_bytes()).to_hex();
Some(crate::config::default_cache_dir().join(format!("{}-{}.txt", prefix, &hash[..16])))
}
fn toolchain_selector_fingerprint(
rustup_toolchain: Option<&std::ffi::OsStr>,
cwd: Option<&Path>,
rustup_settings: Option<&Path>,
) -> String {
let mut fp = String::new();
if let Some(toolchain) = rustup_toolchain {
fp.push_str("env:");
fp.push_str(&toolchain.to_string_lossy());
}
if let Some(cwd) = cwd {
'search: for dir in cwd.ancestors() {
let mut found = false;
for name in ["rust-toolchain", "rust-toolchain.toml"] {
let candidate = dir.join(name);
if let Some(digest) = file_digest(&candidate) {
fp.push_str(";file:");
fp.push_str(&candidate.to_string_lossy());
fp.push(':');
fp.push_str(&digest);
found = true;
}
}
if found {
break 'search;
}
}
}
if let Some(settings) = rustup_settings
&& let Some(digest) = file_digest(settings)
{
fp.push_str(";default:");
fp.push_str(&digest);
}
fp
}
fn rustup_settings_path() -> Option<std::path::PathBuf> {
let home = std::env::var_os("RUSTUP_HOME")
.map(std::path::PathBuf::from)
.or_else(|| dirs::home_dir().map(|home| home.join(".rustup")))?;
Some(home.join("settings.toml"))
}
fn file_digest(path: &Path) -> Option<String> {
let bytes = std::fs::read(path).ok()?;
Some(blake3::hash(&bytes).to_hex()[..16].to_string())
}
fn host_target_triple() -> &'static str {
option_env!("TARGET").unwrap_or("unknown")
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum LinuxLibcFamily {
Gnu,
Musl,
}
impl LinuxLibcFamily {
fn key_name(self) -> &'static str {
match self {
Self::Gnu => "gnu-libc",
Self::Musl => "musl",
}
}
}
fn rustc_host_triple(rustc_version: &str) -> Option<&str> {
rustc_version.lines().find_map(|line| {
line.strip_prefix("host:")
.map(str::trim)
.filter(|host| !host.is_empty())
})
}
fn linux_libc_family(target: &str) -> Option<LinuxLibcFamily> {
let mut components = target.split('-');
if !components.clone().any(|component| component == "linux") {
return None;
}
components.find_map(|component| {
if component.starts_with("gnu") {
Some(LinuxLibcFamily::Gnu)
} else if component.starts_with("musl") {
Some(LinuxLibcFamily::Musl)
} else {
None
}
})
}
fn native_linux_libc_family(
args: &RustcArgs,
rustc_version: &str,
running_on_linux: bool,
) -> Result<Option<LinuxLibcFamily>> {
let emits_link = args.emit.is_empty() || args.emit.iter().any(|kind| kind == "link");
if !running_on_linux || !args.is_executable_output() || !emits_link {
return Ok(None);
}
let host = rustc_host_triple(rustc_version)
.context("wrapped rustc -vV output has no host triple; cannot key native Linux libc")?;
let effective_target = args.target.as_deref().unwrap_or(host);
if effective_target != host {
return Ok(None);
}
if !host.split('-').any(|component| component == "linux") {
return Ok(None);
}
linux_libc_family(host)
.map(Some)
.with_context(|| format!("unsupported native Linux libc in rustc host triple {host}"))
}
fn rustc_version_for_native_link<'a, F>(
args: &RustcArgs,
outer_rustc_version: &'a str,
load_version: F,
) -> Result<Cow<'a, str>>
where
F: FnOnce(&Path) -> Result<String>,
{
match args.inner_rustc.as_deref() {
Some(inner) => load_version(inner)
.map(Cow::Owned)
.context("reading inner rustc version for native host link key"),
None => Ok(Cow::Borrowed(outer_rustc_version)),
}
}
fn fold_native_host_libc_signature<H: KeyFold, F>(
hasher: &mut H,
args: &RustcArgs,
rustc_version: &str,
running_on_linux: bool,
probe: F,
) -> Result<()>
where
F: FnOnce(LinuxLibcFamily) -> Result<String>,
{
let emits_link = args.emit.is_empty() || args.emit.iter().any(|kind| kind == "link");
if !running_on_linux || !args.is_executable_output() || !emits_link {
return Ok(());
}
let rustc_version = rustc_version_for_native_link(args, rustc_version, get_rustc_version)?;
let Some(family) = native_linux_libc_family(args, &rustc_version, running_on_linux)? else {
return Ok(());
};
let signature = probe(family).with_context(|| {
format!(
"determining native Linux {} signature for cache key",
family.key_name()
)
})?;
fold_field(
hasher,
b"host_libc.v1:",
format!("{}:{signature}", family.key_name()).as_bytes(),
);
tracing::trace!(
"[key:{}] host_libc={}:{signature}",
args.crate_name.as_deref().unwrap_or("unknown"),
family.key_name()
);
Ok(())
}
fn fold_native_link_runtime_identity<H, Crt, Sdk>(
hasher: &mut H,
args: &RustcArgs,
rustc_version: &str,
running_on_linux: bool,
running_on_macos: bool,
crt_probe: Crt,
sdk_probe: Sdk,
deployment_target: Option<String>,
) -> Result<()>
where
H: KeyFold,
Crt: FnOnce(&Path) -> Result<BTreeMap<String, String>>,
Sdk: FnOnce(Option<String>) -> Result<String>,
{
let emits_link = args.emit.is_empty() || args.emit.iter().any(|kind| kind == "link");
if !args.is_executable_output() || !emits_link {
return Ok(());
}
if !running_on_linux && !running_on_macos {
return Ok(());
}
let rustc_version = rustc_version_for_native_link(args, rustc_version, get_rustc_version)?;
let host = rustc_host_triple(&rustc_version)
.context("wrapped rustc -vV output has no host triple; cannot key native link runtime")?;
let effective_target = args.target.as_deref().unwrap_or(host);
if effective_target != host {
return Ok(());
}
if running_on_linux && host.split('-').any(|component| component == "linux") {
let driver = resolve_link_driver(args)
.context("native Linux link has no cc/linker driver; cannot key CRT objects")?;
let objects = crt_probe(&driver).context("determining native Linux CRT/libc identity")?;
let encoded = crate::native_link_key::encode_crt_objects(&objects);
fold_field(hasher, b"host_crt.v1:", encoded.as_bytes());
tracing::trace!(
"[key:{}] host_crt={}",
args.crate_name.as_deref().unwrap_or("unknown"),
encoded.replace('\n', ",")
);
}
if running_on_macos && host.split('-').any(|component| component == "darwin") {
let identity = sdk_probe(std::env::var("SDKROOT").ok())
.context("determining macOS SDK identity for cache key")?;
fold_field(hasher, b"host_sdk.v1:", identity.as_bytes());
tracing::trace!(
"[key:{}] host_sdk={}",
args.crate_name.as_deref().unwrap_or("unknown"),
identity
);
if let Some(target) = deployment_target.filter(|value| !value.is_empty()) {
fold_field(hasher, b"host_deployment_target.v1:", target.as_bytes());
tracing::trace!(
"[key:{}] host_deployment_target={}",
args.crate_name.as_deref().unwrap_or("unknown"),
target
);
}
}
Ok(())
}
#[derive(Debug, PartialEq, Eq)]
struct WindowsNativeLinkSearchDirs {
rustc: Vec<PathBuf>,
linker: Vec<PathBuf>,
}
fn windows_native_link_search_dirs(args: &RustcArgs) -> Result<WindowsNativeLinkSearchDirs> {
const KNOWN_L_KINDS: [&str; 5] = ["dependency", "crate", "native", "framework", "all"];
let mut rustc = Vec::new();
for spec in &args.link_search {
let (kind, path) = match spec.split_once('=') {
Some((kind, path)) if KNOWN_L_KINDS.contains(&kind) => (Some(kind), path),
_ => (None, spec.as_str()),
};
if matches!(kind, Some("dependency") | Some("crate")) {
continue;
}
if matches!(kind, None | Some("native") | Some("all")) {
rustc.push(PathBuf::from(path));
}
}
let mut linker = rustc.clone();
for (key, value) in &args.codegen_opts {
if !matches!(key.as_str(), "link-arg" | "link-args") {
continue;
}
let Some(value) = value.as_deref() else {
continue;
};
if crate::native_link_key::windows_link_argument_has_unmodeled_input(value) {
anyhow::bail!(
"explicit Windows linker input files (.lib/.a/.obj/.o/.res/.def/.exp/.manifest) \
and file-carrying LINK options (/DEF, /DEFAULTLIB, /MANIFESTINPUT, \
/MANIFESTFILE, /PDBSTRIPPED, ...) are not hashed and are not cacheable"
);
}
if let Some(path) = windows_libpath_argument(value)? {
linker.push(PathBuf::from(path));
}
}
Ok(WindowsNativeLinkSearchDirs { rustc, linker })
}
fn windows_libpath_argument(value: &str) -> Result<Option<String>> {
let value = value.trim();
let value = value.strip_prefix("-Wl,").unwrap_or(value);
let upper = value.to_ascii_uppercase();
let marker = ["/LIBPATH:", "/LIBPATH=", "-LIBPATH:", "-LIBPATH="]
.into_iter()
.find(|marker| upper.starts_with(*marker));
let Some(marker) = marker else {
if upper.contains("/LIBPATH") || upper.contains("-LIBPATH") {
anyhow::bail!("ambiguous Windows /LIBPATH linker argument");
}
return Ok(None);
};
let path = value[marker.len()..].trim();
let path = if let Some(quoted) = path.strip_prefix('"') {
let closing = quoted
.find('"')
.context("unterminated quoted Windows /LIBPATH linker argument")?;
if !quoted[closing + 1..].trim().is_empty() {
anyhow::bail!("ambiguous Windows /LIBPATH linker argument");
}
quoted[..closing].trim()
} else {
if path.contains('"') || path.chars().any(char::is_whitespace) {
anyhow::bail!("ambiguous Windows /LIBPATH linker argument");
}
path
};
if path.is_empty() {
anyhow::bail!("empty Windows /LIBPATH linker argument");
}
Ok(Some(path.to_string()))
}
fn fold_generic_linker_identity<H, Get>(
hasher: &mut H,
args: &RustcArgs,
native_windows_msvc: bool,
get_identity: Get,
) where
H: KeyFold,
Get: FnOnce(&RustcArgs) -> Option<String>,
{
if args.is_executable_output()
&& args.emits_link()
&& !native_windows_msvc
&& let Some(linker_id) = get_identity(args)
{
hasher.update(b"linker:");
hasher.update(linker_id.as_bytes());
hasher.update(b"\n");
}
}
fn is_native_windows_msvc_link<Load>(
args: &RustcArgs,
rustc_version: &str,
running_on_windows: bool,
load_version: Load,
) -> Result<bool>
where
Load: FnOnce(&Path) -> Result<String>,
{
if !running_on_windows || !args.is_executable_output() || !args.emits_link() {
return Ok(false);
}
let rustc_version = rustc_version_for_native_link(args, rustc_version, load_version)?;
let host = rustc_host_triple(&rustc_version)
.context("wrapped rustc -vV output has no host triple; cannot key native Windows link")?;
let effective_target = args.target.as_deref().unwrap_or(host);
Ok(effective_target == host && crate::native_link_key::is_windows_msvc_target(host))
}
fn fold_native_windows_msvc_identity<H, Probe>(
hasher: &mut H,
args: &RustcArgs,
rustc_version: &str,
running_on_windows: bool,
probe: Probe,
) -> Result<()>
where
H: KeyFold,
Probe: FnOnce(Option<&Path>, &str) -> Result<String>,
{
if !running_on_windows || !args.is_executable_output() || !args.emits_link() {
return Ok(());
}
let rustc_version = rustc_version_for_native_link(args, rustc_version, get_rustc_version)?;
let host = rustc_host_triple(&rustc_version)
.context("wrapped rustc -vV output has no host triple; cannot key native Windows link")?;
let effective_target = args.target.as_deref().unwrap_or(host);
if effective_target != host {
if crate::native_link_key::is_windows_msvc_target(effective_target) {
anyhow::bail!(
"cross-target Windows MSVC link identity is not modeled; passing through"
);
}
return Ok(());
}
if !crate::native_link_key::is_windows_msvc_target(host) {
return Ok(());
}
let architecture = crate::native_link_key::windows_msvc_architecture(host)
.context("native Windows MSVC host has an unsupported architecture")?;
let linker = args.get_codegen_opt("linker").map(Path::new);
let identity =
probe(linker, architecture).context("determining native Windows MSVC link identity")?;
fold_field(&mut *hasher, b"host_windows_msvc.v1:", identity.as_bytes());
tracing::trace!(
"[key:{}] host_windows_msvc={}",
args.crate_name.as_deref().unwrap_or("unknown"),
identity.replace('\n', ",")
);
Ok(())
}
fn resolve_link_driver(args: &RustcArgs) -> Option<PathBuf> {
let linker = args.get_codegen_opt("linker").unwrap_or("cc");
let linker_path = Path::new(linker);
if linker_path.is_absolute() {
Some(linker_path.to_path_buf())
} else {
resolve_in_path(linker)
}
}
fn is_libc_version(version: &str) -> bool {
let mut parts = version.split('.');
let Some(major) = parts.next() else {
return false;
};
let Some(minor) = parts.next() else {
return false;
};
!major.is_empty()
&& !minor.is_empty()
&& major.bytes().all(|b| b.is_ascii_digit())
&& minor.bytes().all(|b| b.is_ascii_digit())
&& parts.all(|part| !part.is_empty() && part.bytes().all(|b| b.is_ascii_digit()))
}
fn parse_getconf_gnu_libc(stdout: &str) -> Option<String> {
let mut fields = stdout.split_whitespace();
let family = fields.next()?;
let version = fields.next()?;
if family == "glibc" && is_libc_version(version) && fields.next().is_none() {
Some(version.to_string())
} else {
None
}
}
fn parse_ldd_libc(text: &str) -> Option<(LinuxLibcFamily, String)> {
let lower = text.to_ascii_lowercase();
if lower.contains("musl") {
let version = text.lines().find_map(|line| {
let mut fields = line.split_whitespace();
if !fields.next()?.eq_ignore_ascii_case("version") {
return None;
}
let version = fields.next()?;
is_libc_version(version).then(|| version.to_string())
})?;
return Some((LinuxLibcFamily::Musl, version));
}
if lower.contains("glibc") || lower.contains("gnu libc") || lower.contains("gnu c library") {
let first_line = text.lines().find(|line| !line.trim().is_empty())?;
let version = first_line
.split_whitespace()
.rev()
.find(|field| is_libc_version(field))?;
return Some((LinuxLibcFamily::Gnu, version.to_string()));
}
None
}
fn probe_linux_libc_signature(expected: LinuxLibcFamily) -> Result<String> {
if expected == LinuxLibcFamily::Gnu
&& let Ok(output) = std::process::Command::new("getconf")
.arg("GNU_LIBC_VERSION")
.env("LC_ALL", "C")
.env("LANG", "C")
.output()
&& output.status.success()
&& let Some(version) = parse_getconf_gnu_libc(&String::from_utf8_lossy(&output.stdout))
{
return Ok(version);
}
if let Ok(output) = std::process::Command::new("ldd")
.arg("--version")
.env("LC_ALL", "C")
.env("LANG", "C")
.output()
{
let text = format!(
"{}\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
if let Some((family, version)) = parse_ldd_libc(&text)
&& family == expected
{
return Ok(version);
}
}
anyhow::bail!(
"unable to identify native Linux {} (tried getconf/ldd)",
expected.key_name()
)
}
fn get_linker_identity(args: &RustcArgs) -> Option<String> {
let linker = args.get_codegen_opt("linker").unwrap_or("cc");
let linker_path = Path::new(linker);
let resolved = if linker_path.is_absolute() {
linker_path.to_path_buf()
} else {
resolve_in_path(linker)?
};
if let Some(cached) = read_tool_version_cache(&resolved, "linker-ver") {
return Some(cached);
}
let output = std::process::Command::new(linker)
.arg("--version")
.output()
.ok()?;
let version = String::from_utf8_lossy(&output.stdout);
let first_line = version.lines().next()?.to_string();
write_tool_version_cache(&resolved, "linker-ver", &first_line);
Some(first_line)
}
fn resolve_in_path(name: &str) -> Option<std::path::PathBuf> {
let path_var = std::env::var_os("PATH")?;
std::env::split_paths(&path_var)
.map(|dir| dir.join(name))
.find(|p| p.is_file())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::args::RustcArgs;
use crate::test_support::process_state_test_lock;
const GNU_RUSTC_VERSION: &str =
"rustc 1.90.0\nhost: x86_64-unknown-linux-gnu\nrelease: 1.90.0\n";
const DARWIN_RUSTC_VERSION: &str =
"rustc 1.90.0\nhost: aarch64-apple-darwin\nrelease: 1.90.0\n";
#[test]
fn source_identity_uses_a_stable_configured_root() {
let dir = tempfile::tempdir().unwrap();
let checkout = dir.path().join("checkout");
let source = checkout.join("src/lib.rs");
std::fs::create_dir_all(source.parent().unwrap()).unwrap();
std::fs::write(&source, "pub fn value() {}\n").unwrap();
let normalizer =
PathNormalizer::empty().with_base_dirs(&[checkout.to_string_lossy().into_owned()]);
let mut expected = b"<BASE_DIR_0>/".to_vec();
expected.extend_from_slice(
source
.strip_prefix(&checkout)
.unwrap()
.to_string_lossy()
.as_bytes(),
);
assert_eq!(
source_path_identity(&source, &normalizer).unwrap(),
expected
);
}
#[test]
fn source_identity_uses_a_configured_external_root_losslessly() {
let dir = tempfile::tempdir().unwrap();
let external = dir.path().join("external");
let source = external.join("generated/value.rs");
std::fs::create_dir_all(source.parent().unwrap()).unwrap();
std::fs::write(&source, "pub const VALUE: u8 = 1;\n").unwrap();
let normalizer =
PathNormalizer::empty().with_base_dirs(&[external.to_string_lossy().into_owned()]);
let mut expected = b"<BASE_DIR_0>/".to_vec();
expected.extend_from_slice(
source
.strip_prefix(&external)
.unwrap()
.to_string_lossy()
.as_bytes(),
);
assert_eq!(
source_path_identity(&source, &normalizer).unwrap(),
expected
);
}
#[cfg(unix)]
#[test]
fn source_identity_keeps_distinct_symlink_spellings_of_one_inode() {
let dir = tempfile::tempdir().unwrap();
let real = dir.path().join("real.rs");
let alias = dir.path().join("alias.rs");
std::fs::write(&real, "pub const VALUE: u8 = 1;\n").unwrap();
std::os::unix::fs::symlink(&real, &alias).unwrap();
let real_identity = source_path_identity(&real, &PathNormalizer::empty()).unwrap();
let alias_identity = source_path_identity(&alias, &PathNormalizer::empty()).unwrap();
assert_ne!(real_identity, alias_identity);
assert!(real_identity.starts_with(b"<OPAQUE_PATH>/"));
assert!(alias_identity.starts_with(b"<OPAQUE_PATH>/"));
}
#[cfg(target_os = "linux")]
#[test]
fn source_identity_opaque_fallback_preserves_non_utf8_bytes() {
use std::os::unix::ffi::OsStringExt;
let dir = tempfile::tempdir().unwrap();
let external = dir.path().join("external");
std::fs::create_dir_all(&external).unwrap();
let path_a = external.join(std::ffi::OsString::from_vec(vec![b'a', 0x80]));
let path_b = external.join(std::ffi::OsString::from_vec(vec![b'a', 0x81]));
std::fs::write(&path_a, b"same").unwrap();
std::fs::write(&path_b, b"same").unwrap();
let identity_a = source_path_identity(&path_a, &PathNormalizer::empty()).unwrap();
let identity_b = source_path_identity(&path_b, &PathNormalizer::empty()).unwrap();
assert_ne!(identity_a, identity_b);
assert!(identity_a.starts_with(b"<OPAQUE_PATH>/"));
assert!(identity_b.starts_with(b"<OPAQUE_PATH>/"));
}
#[test]
fn grouped_hasher_main_digest_matches_plain_blake3() {
let mut plain = blake3::Hasher::new();
let mut grouped = GroupedHasher::new("compiler");
for (group, chunk) in [
("compiler", b"rustc_version:1.90".as_slice()),
("args", b"emit:link\n"),
("sources", b"source:abc\n"),
("args", b"RUSTFLAGS:-Copt-level=3\n"),
("link", b"linker:ld64\n"),
] {
plain.update(chunk);
grouped.set_group(group);
grouped.update(chunk);
}
let (hash, fields) = grouped.finalize_with_fields();
assert_eq!(hash, plain.finalize(), "grouping must not perturb the key");
assert_eq!(
fields.keys().collect::<Vec<_>>(),
["args", "compiler", "link", "sources"],
"only groups that received bytes appear",
);
assert!(fields.values().all(|v| v.len() == KEY_FIELD_HEX));
}
#[test]
fn grouped_hasher_isolates_changes_to_their_group() {
let build = |rustflags: &[u8]| {
let mut h = GroupedHasher::new("compiler");
h.update(b"rustc_version:1.90\n");
h.set_group("sources");
h.update(b"source:abc\n");
h.set_group("args");
h.update(b"emit:link\n");
h.update(rustflags);
h.finalize_with_fields()
};
let (key_a, fields_a) = build(b"RUSTFLAGS:-Copt-level=3\n");
let (key_b, fields_b) = build(b"RUSTFLAGS:-Copt-level=2\n");
assert_ne!(key_a, key_b);
assert_ne!(fields_a["args"], fields_b["args"], "args group must differ");
assert_eq!(fields_a["compiler"], fields_b["compiler"]);
assert_eq!(fields_a["sources"], fields_b["sources"]);
}
fn parsed_crate_type(crate_type: &str, target: Option<&str>) -> RustcArgs {
let mut argv = vec![
"rustc".to_string(),
"--crate-name".to_string(),
"probe".to_string(),
"--crate-type".to_string(),
crate_type.to_string(),
"src/lib.rs".to_string(),
];
if let Some(target) = target {
argv.push("--target".to_string());
argv.push(target.to_string());
}
RustcArgs::parse(&argv).unwrap()
}
fn libc_fold_key(
args: &RustcArgs,
rustc_version: &str,
running_on_linux: bool,
signature: &str,
) -> Result<String> {
let mut hasher = blake3::Hasher::new();
hasher.update(b"base-key");
fold_native_host_libc_signature(
&mut hasher,
args,
rustc_version,
running_on_linux,
|_| Ok(signature.to_string()),
)?;
Ok(hasher.finalize().to_hex().to_string())
}
#[test]
fn native_linux_linked_outputs_key_host_libc_version() {
for crate_type in ["bin", "dylib", "cdylib", "proc-macro"] {
let args = parsed_crate_type(crate_type, None);
let old = libc_fold_key(&args, GNU_RUSTC_VERSION, true, "2.36").unwrap();
let new = libc_fold_key(&args, GNU_RUSTC_VERSION, true, "2.39").unwrap();
assert_ne!(old, new, "{crate_type} must re-key across libc versions");
}
}
#[test]
fn rlibs_and_cross_targets_do_not_key_host_libc() {
let rlib = parsed_crate_type("rlib", None);
assert_eq!(
libc_fold_key(&rlib, GNU_RUSTC_VERSION, true, "2.36").unwrap(),
libc_fold_key(&rlib, GNU_RUSTC_VERSION, true, "2.39").unwrap(),
"portable rlibs must not be tied to the host libc"
);
let cross = parsed_crate_type("bin", Some("aarch64-unknown-linux-gnu"));
assert_eq!(
libc_fold_key(&cross, GNU_RUSTC_VERSION, true, "2.36").unwrap(),
libc_fold_key(&cross, GNU_RUSTC_VERSION, true, "2.39").unwrap(),
"cross-target output must not be tied to the build host libc"
);
let explicit_native = parsed_crate_type("bin", Some("x86_64-unknown-linux-gnu"));
assert_ne!(
libc_fold_key(&explicit_native, GNU_RUSTC_VERSION, true, "2.36").unwrap(),
libc_fold_key(&explicit_native, GNU_RUSTC_VERSION, true, "2.39").unwrap(),
"an explicit rustc-host target is still a native output"
);
}
#[test]
fn host_libc_probe_is_linux_only_and_fails_closed() {
let bin = parsed_crate_type("bin", None);
assert_eq!(
libc_fold_key(&bin, GNU_RUSTC_VERSION, false, "2.36").unwrap(),
libc_fold_key(&bin, GNU_RUSTC_VERSION, false, "2.39").unwrap(),
"non-Linux hosts must not gain a Linux libc component"
);
let mut hasher = blake3::Hasher::new();
let err =
fold_native_host_libc_signature(&mut hasher, &bin, GNU_RUSTC_VERSION, true, |_| {
anyhow::bail!("probe failed")
})
.unwrap_err();
assert!(
err.to_string()
.contains("determining native Linux gnu-libc")
);
let missing_host = "rustc 1.90.0\nrelease: 1.90.0\n";
let err = libc_fold_key(&bin, missing_host, true, "2.39").unwrap_err();
assert!(err.to_string().contains("no host triple"));
}
#[test]
fn metadata_only_outputs_do_not_probe_or_key_host_libc() {
let mut metadata = parsed_crate_type("bin", None);
metadata.emit = vec!["metadata".to_string()];
let mut hasher = blake3::Hasher::new();
fold_native_host_libc_signature(&mut hasher, &metadata, GNU_RUSTC_VERSION, true, |_| {
panic!("metadata-only output must not probe libc")
})
.unwrap();
let baseline = blake3::Hasher::new().finalize().to_hex().to_string();
assert_eq!(hasher.finalize().to_hex().to_string(), baseline);
}
#[test]
fn double_wrapper_uses_inner_rustc_host_banner() {
let mut bin = parsed_crate_type("bin", None);
bin.inner_rustc = Some(PathBuf::from("/toolchain/bin/rustc"));
let version = rustc_version_for_native_link(&bin, "clippy 0.1.90\n", |path| {
assert_eq!(path, Path::new("/toolchain/bin/rustc"));
Ok(GNU_RUSTC_VERSION.to_string())
})
.unwrap();
assert_eq!(
rustc_host_triple(&version),
Some("x86_64-unknown-linux-gnu")
);
}
fn dummy_absolute_linker() -> String {
std::env::temp_dir()
.join("kache-dummy-cc")
.to_string_lossy()
.into_owned()
}
fn parsed_linked_bin(target: Option<&str>) -> RustcArgs {
let mut argv = vec![
"rustc".to_string(),
"--crate-name".to_string(),
"probe".to_string(),
"--crate-type".to_string(),
"bin".to_string(),
"src/lib.rs".to_string(),
format!("-Clinker={}", dummy_absolute_linker()),
];
if let Some(target) = target {
argv.push("--target".to_string());
argv.push(target.to_string());
}
RustcArgs::parse(&argv).unwrap()
}
fn crt_fold_key(
args: &RustcArgs,
rustc_version: &str,
linux: bool,
macos: bool,
crt: &str,
sdk: &str,
deployment_target: Option<&str>,
) -> Result<String> {
let mut hasher = blake3::Hasher::new();
hasher.update(b"base-key");
fold_native_link_runtime_identity(
&mut hasher,
args,
rustc_version,
linux,
macos,
|_| {
let mut objects = BTreeMap::new();
for pair in crt.split(';').filter(|pair| !pair.is_empty()) {
let (name, digest) = pair.split_once('=').unwrap();
objects.insert(name.to_string(), digest.to_string());
}
Ok(objects)
},
|_| Ok(sdk.to_string()),
deployment_target.map(str::to_string),
)?;
Ok(hasher.finalize().to_hex().to_string())
}
#[test]
fn native_linux_linked_outputs_key_crt_object_hashes() {
let args = parsed_linked_bin(None);
let old = crt_fold_key(
&args,
GNU_RUSTC_VERSION,
true,
false,
"crt1.o=aaa;libc.so.6=bbb",
"",
None,
)
.unwrap();
let new = crt_fold_key(
&args,
GNU_RUSTC_VERSION,
true,
false,
"crt1.o=aaa;libc.so.6=ccc",
"",
None,
)
.unwrap();
assert_ne!(old, new, "libc object bytes must re-key the native link");
}
#[test]
fn rlibs_and_cross_targets_do_not_key_crt_or_sdk() {
let rlib = parsed_crate_type("rlib", None);
assert_eq!(
crt_fold_key(
&rlib,
GNU_RUSTC_VERSION,
true,
true,
"crt1.o=aaa;libc.so.6=bbb",
"14.0 (a)",
Some("11.0"),
)
.unwrap(),
crt_fold_key(
&rlib,
GNU_RUSTC_VERSION,
true,
true,
"crt1.o=zzz;libc.so.6=yyy",
"15.0 (b)",
Some("12.0"),
)
.unwrap(),
"portable rlibs must not be tied to CRT/SDK identity"
);
let cross = parsed_linked_bin(Some("aarch64-unknown-linux-gnu"));
assert_eq!(
crt_fold_key(
&cross,
GNU_RUSTC_VERSION,
true,
false,
"crt1.o=aaa;libc.so.6=bbb",
"",
None,
)
.unwrap(),
crt_fold_key(
&cross,
GNU_RUSTC_VERSION,
true,
false,
"crt1.o=zzz;libc.so.6=yyy",
"",
None,
)
.unwrap(),
"cross-target output must not be tied to the build host CRT"
);
}
#[test]
fn native_linux_crt_probe_fails_closed() {
let bin = parsed_linked_bin(None);
let mut hasher = blake3::Hasher::new();
let err = fold_native_link_runtime_identity(
&mut hasher,
&bin,
GNU_RUSTC_VERSION,
true,
false,
|_| anyhow::bail!("no startup object"),
|_| unreachable!("linux fold must not probe the macOS SDK"),
None,
)
.unwrap_err();
assert!(
err.to_string()
.contains("determining native Linux CRT/libc identity")
);
}
#[test]
fn native_macos_linked_outputs_key_sdk_identity() {
let args = parsed_linked_bin(None);
let old = crt_fold_key(
&args,
DARWIN_RUSTC_VERSION,
false,
true,
"",
"14.0 (23A344)",
None,
)
.unwrap();
let new = crt_fold_key(
&args,
DARWIN_RUSTC_VERSION,
false,
true,
"",
"15.0 (24A348)",
None,
)
.unwrap();
assert_ne!(old, new, "SDK identity must re-key the native macOS link");
let with_dt = crt_fold_key(
&args,
DARWIN_RUSTC_VERSION,
false,
true,
"",
"14.0 (23A344)",
Some("11.0"),
)
.unwrap();
assert_ne!(
old, with_dt,
"MACOSX_DEPLOYMENT_TARGET must re-key when set"
);
}
const WINDOWS_RUSTC_VERSION: &str =
"rustc 1.90.0\nhost: x86_64-pc-windows-msvc\nrelease: 1.90.0\n";
const WINDOWS_GNU_RUSTC_VERSION: &str =
"rustc 1.90.0\nhost: x86_64-pc-windows-gnu\nrelease: 1.90.0\n";
fn windows_fold_key_on_host(
args: &RustcArgs,
version: &str,
identity: &str,
running_on_windows: bool,
) -> Result<String> {
let mut hasher = blake3::Hasher::new();
hasher.update(b"base-key");
fold_native_windows_msvc_identity(
&mut hasher,
args,
version,
running_on_windows,
|_, _| Ok(identity.to_string()),
)?;
Ok(hasher.finalize().to_hex().to_string())
}
fn windows_fold_key(args: &RustcArgs, version: &str, identity: &str) -> Result<String> {
windows_fold_key_on_host(args, version, identity, true)
}
#[test]
fn native_windows_msvc_identity_keys_only_native_link_outputs() {
let bin = parsed_crate_type("bin", None);
let old = windows_fold_key(&bin, WINDOWS_RUSTC_VERSION, "toolset=14.4").unwrap();
let new = windows_fold_key(&bin, WINDOWS_RUSTC_VERSION, "toolset=14.5").unwrap();
assert_ne!(old, new);
let mut metadata = bin.clone();
metadata.emit = vec!["metadata".into()];
assert_eq!(
windows_fold_key(&metadata, WINDOWS_RUSTC_VERSION, "probe must not run").unwrap(),
windows_fold_key(&metadata, WINDOWS_RUSTC_VERSION, "anything").unwrap()
);
let cross = parsed_crate_type("bin", Some("aarch64-pc-windows-msvc"));
assert!(
windows_fold_key(&cross, WINDOWS_RUSTC_VERSION, "probe must not run").is_err(),
"cross-target Windows MSVC links must pass through until target identity is modeled"
);
let gnu = parsed_crate_type("bin", Some("x86_64-pc-windows-gnu"));
assert_eq!(
windows_fold_key(&gnu, WINDOWS_RUSTC_VERSION, "probe must not run").unwrap(),
windows_fold_key(&gnu, WINDOWS_RUSTC_VERSION, "anything").unwrap()
);
let rlib = parsed_crate_type("rlib", None);
assert_eq!(
windows_fold_key(&rlib, WINDOWS_RUSTC_VERSION, "probe must not run").unwrap(),
windows_fold_key(&rlib, WINDOWS_RUSTC_VERSION, "anything").unwrap()
);
assert_eq!(
windows_fold_key_on_host(&bin, WINDOWS_RUSTC_VERSION, "probe must not run", false)
.unwrap(),
windows_fold_key_on_host(&bin, WINDOWS_RUSTC_VERSION, "anything", false).unwrap(),
"a non-Windows host must not probe native MSVC inputs"
);
}
#[test]
fn native_windows_msvc_detection_requires_a_windows_linked_executable() {
let bin = parsed_crate_type("bin", None);
let mut metadata = bin.clone();
metadata.emit = vec!["metadata".into()];
let rlib = parsed_crate_type("rlib", None);
for (args, running_on_windows) in [(&bin, false), (&metadata, true), (&rlib, true)] {
assert!(
!is_native_windows_msvc_link(
args,
WINDOWS_RUSTC_VERSION,
running_on_windows,
|_| unreachable!("the supplied rustc version must be reused"),
)
.unwrap()
);
}
let cross = parsed_crate_type("bin", Some("x86_64-unknown-linux-gnu"));
for (args, version) in [
(&cross, WINDOWS_RUSTC_VERSION),
(&bin, WINDOWS_GNU_RUSTC_VERSION),
] {
assert!(
!is_native_windows_msvc_link(args, version, true, |_| unreachable!(
"the supplied rustc version must be reused"
))
.unwrap(),
"{version:?} must not admit {:?}",
args.target
);
}
}
#[test]
fn native_windows_msvc_ignores_unrelated_generic_cc_identity() {
let bin = parsed_crate_type("bin", None);
assert!(
is_native_windows_msvc_link(&bin, WINDOWS_RUSTC_VERSION, true, |_| unreachable!(
"non-nested rustc must use the supplied version"
),)
.unwrap()
);
let fold = |identity: &str| {
let mut hasher = blake3::Hasher::new();
hasher.update(b"base-key");
fold_generic_linker_identity(&mut hasher, &bin, true, |_| Some(identity.to_string()));
hasher.finalize()
};
assert_eq!(
fold("unrelated MinGW cc"),
fold("no cc installed"),
"native MSVC keys must not depend on an unrelated generic cc probe"
);
let mut generic = blake3::Hasher::new();
generic.update(b"base-key");
fold_generic_linker_identity(&mut generic, &bin, false, |_| {
Some("actual generic linker".into())
});
assert_ne!(generic.finalize(), fold("anything"));
}
#[test]
fn native_windows_msvc_identity_probe_failure_is_cache_failure() {
let bin = parsed_crate_type("bin", None);
let mut hasher = blake3::Hasher::new();
let error = fold_native_windows_msvc_identity(
&mut hasher,
&bin,
WINDOWS_RUSTC_VERSION,
true,
|_, _| anyhow::bail!("ambiguous toolchain"),
)
.unwrap_err();
assert!(
error
.to_string()
.contains("native Windows MSVC link identity")
);
}
#[test]
fn windows_native_link_search_dirs_include_l_and_libpath_and_reject_ambiguity() {
let dir = tempfile::tempdir().unwrap();
let native = dir.path().join("native");
let libpath = dir.path().join("libpath");
std::fs::create_dir_all(&native).unwrap();
std::fs::create_dir_all(&libpath).unwrap();
let args = RustcArgs::parse(&[
"rustc".into(),
"--crate-type=bin".into(),
"-l".into(),
"static=foo".into(),
"-L".into(),
format!("native={}", native.display()),
format!("-Clink-arg=/LIBPATH:{}", libpath.display()),
])
.unwrap();
assert_eq!(args.link_libs, vec!["static=foo".to_string()]);
assert_eq!(
windows_native_link_search_dirs(&args).unwrap(),
WindowsNativeLinkSearchDirs {
rustc: vec![native.clone()],
linker: vec![native, libpath],
}
);
let ambiguous = RustcArgs::parse(&[
"rustc".into(),
"--crate-type=bin".into(),
"-Clink-args=/DEFAULTLIB:foo /LIBPATH".into(),
])
.unwrap();
assert!(windows_native_link_search_dirs(&ambiguous).is_err());
for linker_arg in [
"foo.lib",
"/DEFAULTLIB:foo",
"-defaultlib:foo",
"/DEFAULTLIB:foo.lib",
"app.res",
"APP.RES",
"extra.obj",
"exports.exp",
"/DEF:exports.def",
"-def:exports.def",
"-Wl,/def:exports.def",
"/MANIFESTINPUT:extra.manifest",
"/MANIFESTFILE:app.exe.manifest",
"/PDBSTRIPPED:app.public.pdb",
] {
let args = RustcArgs::parse(&[
"rustc".into(),
"--crate-type=bin".into(),
format!("-Clink-arg={linker_arg}"),
])
.unwrap();
let error = windows_native_link_search_dirs(&args).unwrap_err();
assert!(
error.to_string().contains("not hashed"),
"unmodeled Windows link input must fail closed: {linker_arg}: {error:#}"
);
}
}
#[test]
fn windows_libpath_parser_accepts_one_exact_argument() {
for (argument, expected) in [
(r"/LIBPATH:C:\sdk\lib", r"C:\sdk\lib"),
(r"/libpath=C:\sdk\lib", r"C:\sdk\lib"),
(r"-LIBPATH:C:\lld\lib", r"C:\lld\lib"),
(r"-libpath=C:\lld\lib", r"C:\lld\lib"),
(
r#"/LIBPATH:"C:\Program Files\SDK\lib""#,
r"C:\Program Files\SDK\lib",
),
(
r#"-Wl,/LIBPATH:"C:\Program Files\SDK\lib""#,
r"C:\Program Files\SDK\lib",
),
(
r#"-Wl,-LiBpAtH:"C:\Program Files\LLVM\lib""#,
r"C:\Program Files\LLVM\lib",
),
] {
assert_eq!(
windows_libpath_argument(argument).unwrap().as_deref(),
Some(expected),
"{argument}"
);
}
assert_eq!(windows_libpath_argument("/DEBUG").unwrap(), None);
for argument in [
"/LIBPATH:",
r#"/LIBPATH:"C:\unterminated"#,
r#"/LIBPATH:"C:\sdk\lib" /DEBUG"#,
r"/LIBPATH:C:\Program Files\SDK\lib",
r"/DEBUG /LIBPATH:C:\sdk\lib",
r"/DEBUG -LIBPATH:C:\lld\lib",
r"-Wl,/DEBUG,-LIBPATH:C:\lld\lib",
] {
assert!(
windows_libpath_argument(argument).is_err(),
"ambiguous or empty argument must fail closed: {argument}"
);
}
}
#[test]
fn windows_lld_libpath_preserves_shadowing_order() {
let directory = tempfile::tempdir().unwrap();
let first = directory.path().join("lld-first");
let second = directory.path().join("link-second");
std::fs::create_dir_all(&first).unwrap();
std::fs::create_dir_all(&second).unwrap();
std::fs::write(first.join("shadowed.lib"), b"first").unwrap();
std::fs::write(second.join("shadowed.lib"), b"second").unwrap();
let args = RustcArgs::parse(&[
"rustc".into(),
"--crate-type=bin".into(),
format!("-Clink-arg=-LIBPATH:{}", first.display()),
format!("-Clink-arg=/LIBPATH:{}", second.display()),
])
.unwrap();
assert_eq!(
windows_native_link_search_dirs(&args).unwrap(),
WindowsNativeLinkSearchDirs {
rustc: Vec::new(),
linker: vec![first, second],
}
);
}
#[test]
fn windows_native_link_search_dirs_honor_only_linker_visible_l_kinds() {
let directory = tempfile::tempdir().unwrap();
let native = directory.path().join("native");
let all = directory.path().join("all");
let bare = directory.path().join("bare");
let libpath = directory.path().join("path with spaces");
let unknown = format!("custom={}", directory.path().join("unknown").display());
for path in [&native, &all, &bare, &libpath] {
std::fs::create_dir_all(path).unwrap();
}
let args = RustcArgs::parse(&[
"rustc".into(),
"--crate-type=bin".into(),
"-L".into(),
format!("native={}", native.display()),
format!("-Lall={}", all.display()),
format!("-L{}", bare.display()),
format!("-L{unknown}"),
format!(
"-Ldependency={}",
directory.path().join("dependency").display()
),
format!("-Lcrate={}", directory.path().join("crate").display()),
format!(
"-Lframework={}",
directory.path().join("framework").display()
),
format!(r#"-Clink-arg=/LIBPATH:"{}""#, libpath.display()),
"-Clink-arg=/DEBUG".into(),
])
.unwrap();
assert_eq!(
windows_native_link_search_dirs(&args).unwrap(),
WindowsNativeLinkSearchDirs {
rustc: vec![
native.clone(),
all.clone(),
bare.clone(),
PathBuf::from(unknown.clone())
],
linker: vec![native, all, bare, PathBuf::from(unknown), libpath],
}
);
}
#[test]
fn native_macos_sdk_probe_fails_closed() {
let bin = parsed_linked_bin(None);
let mut hasher = blake3::Hasher::new();
let err = fold_native_link_runtime_identity(
&mut hasher,
&bin,
DARWIN_RUSTC_VERSION,
false,
true,
|_| unreachable!("macOS fold must not probe Linux CRT"),
|_| anyhow::bail!("sdk missing"),
None,
)
.unwrap_err();
assert!(
err.to_string()
.contains("determining macOS SDK identity for cache key")
);
}
#[test]
fn metadata_only_outputs_do_not_probe_crt_or_sdk() {
let mut metadata = parsed_linked_bin(None);
metadata.emit = vec!["metadata".to_string()];
let mut hasher = blake3::Hasher::new();
fold_native_link_runtime_identity(
&mut hasher,
&metadata,
GNU_RUSTC_VERSION,
true,
true,
|_| panic!("metadata-only output must not probe CRT"),
|_| panic!("metadata-only output must not probe SDK"),
None,
)
.unwrap();
}
#[test]
fn crt_fold_requires_linux_os_and_linux_rustc_host() {
let args = parsed_linked_bin(None);
let a = crt_fold_key(
&args,
DARWIN_RUSTC_VERSION,
true,
false,
"crt1.o=aaa;libc.so.6=bbb",
"",
None,
)
.unwrap();
let b = crt_fold_key(
&args,
DARWIN_RUSTC_VERSION,
true,
false,
"crt1.o=zzz;libc.so.6=yyy",
"",
None,
)
.unwrap();
assert_eq!(
a, b,
"a Darwin rustc hosted on Linux must not fold Linux CRT objects"
);
}
#[test]
fn sdk_fold_requires_macos_os_and_darwin_rustc_host() {
let args = parsed_linked_bin(None);
let a = crt_fold_key(&args, GNU_RUSTC_VERSION, false, true, "", "14.0 (a)", None).unwrap();
let b = crt_fold_key(&args, GNU_RUSTC_VERSION, false, true, "", "15.0 (b)", None).unwrap();
assert_eq!(
a, b,
"a GNU rustc hosted on macOS must not fold the Darwin SDK"
);
}
#[test]
fn windows_hosts_do_not_key_linux_crt_or_macos_sdk() {
let bin = parsed_linked_bin(None);
assert_eq!(
crt_fold_key(
&bin,
GNU_RUSTC_VERSION,
false,
false,
"crt1.o=aaa;libc.so.6=bbb",
"14.0 (a)",
Some("11.0"),
)
.unwrap(),
crt_fold_key(
&bin,
DARWIN_RUSTC_VERSION,
false,
false,
"crt1.o=zzz;libc.so.6=yyy",
"15.0 (b)",
Some("12.0"),
)
.unwrap(),
"Windows hosts keep the existing linker --version identity only"
);
}
#[test]
fn libc_probe_output_parsing_is_strict_and_canonical() {
assert_eq!(
parse_getconf_gnu_libc("glibc 2.39\n").as_deref(),
Some("2.39")
);
assert_eq!(parse_getconf_gnu_libc("musl 1.2.5\n"), None);
assert_eq!(parse_getconf_gnu_libc("glibc unknown\n"), None);
assert_eq!(
parse_ldd_libc("ldd (Debian GLIBC 2.36-9) 2.36\nCopyright ..."),
Some((LinuxLibcFamily::Gnu, "2.36".to_string()))
);
assert_eq!(
parse_ldd_libc("musl libc (x86_64)\nVersion 1.2.5\nDynamic Program Loader"),
Some((LinuxLibcFamily::Musl, "1.2.5".to_string()))
);
assert_eq!(parse_ldd_libc("BusyBox ldd\n"), None);
}
#[test]
fn is_valid_cache_key_accepts_real_blake3_hex() {
let key = fold_labeled("seed".into(), "label", "value");
assert_eq!(key.len(), 64);
assert!(is_valid_cache_key(&key));
assert!(is_valid_cache_key(&"a".repeat(64)));
assert!(is_valid_cache_key(&"0123456789abcdef".repeat(4)));
}
#[test]
fn apply_key_salt_no_salt_is_identity() {
let base = "deadbeef".to_string();
assert_eq!(apply_key_salt(base.clone(), None, "crate"), base);
assert_eq!(apply_key_salt(base.clone(), Some(""), "crate"), base);
}
#[test]
fn apply_key_salt_changes_key_and_is_salt_specific() {
let base = "deadbeef".to_string();
let a = apply_key_salt(base.clone(), Some("toolchain-A"), "crate");
let b = apply_key_salt(base.clone(), Some("toolchain-B"), "crate");
assert_ne!(a, base);
assert_ne!(b, base);
assert_ne!(a, b);
assert_eq!(a, apply_key_salt(base, Some("toolchain-A"), "crate"));
}
struct ScopedEnv {
name: &'static str,
previous: Option<std::ffi::OsString>,
}
impl ScopedEnv {
fn set(name: &'static str, value: &str) -> Self {
let previous = std::env::var_os(name);
unsafe { std::env::set_var(name, value) };
Self { name, previous }
}
fn unset(name: &'static str) -> Self {
let previous = std::env::var_os(name);
unsafe { std::env::remove_var(name) };
Self { name, previous }
}
}
impl Drop for ScopedEnv {
fn drop(&mut self) {
unsafe {
match self.previous.take() {
Some(value) => std::env::set_var(self.name, value),
None => std::env::remove_var(self.name),
}
}
}
}
#[test]
fn key_env_var_matches_exact_prefix_and_case() {
let patterns = vec!["BOLTFFI_*".to_string(), "MODE".to_string()];
assert!(key_env_var_matches(&patterns, "BOLTFFI_BINDING_EXPANSION"));
assert!(key_env_var_matches(&patterns, "BOLTFFI_"));
assert!(key_env_var_matches(&patterns, "MODE"));
assert!(!key_env_var_matches(&patterns, "MODE_EXTRA"));
assert!(!key_env_var_matches(&patterns, "BOLTFF"));
assert!(!key_env_var_matches(&patterns, "UNRELATED"));
assert!(key_env_var_matches(&patterns, "mode"));
assert!(key_env_var_matches(&patterns, "boltffi_root"));
}
#[test]
fn key_env_var_matches_treats_interior_star_literally() {
let patterns = vec!["A*B".to_string()];
assert!(!key_env_var_matches(&patterns, "AXB"));
assert!(!key_env_var_matches(&patterns, "AB"));
assert!(key_env_var_matches(&patterns, "A*B"));
}
#[test]
fn apply_key_env_vars_no_patterns_is_identity() {
let base = "deadbeef".to_string();
assert_eq!(apply_key_env_vars(base.clone(), &[], "crate"), base);
assert_eq!(key_env_guard(&[]), None);
}
#[test]
fn adaptive_key_env_guard_changes_with_the_selected_value() {
let _lock = key_test_lock();
let patterns = vec!["KACHE_TEST_ADAPTIVE_ENV".to_string()];
let first = {
let _guard = ScopedEnv::set("KACHE_TEST_ADAPTIVE_ENV", "one");
key_env_guard(&patterns).unwrap()
};
let second = {
let _guard = ScopedEnv::set("KACHE_TEST_ADAPTIVE_ENV", "two");
key_env_guard(&patterns).unwrap()
};
assert_ne!(first, second);
}
#[test]
fn apply_key_env_vars_separates_set_from_unset() {
let _lock = key_test_lock();
let base = "deadbeef".to_string();
let patterns = vec!["KACHE_TEST_EXPANSION".to_string()];
let unset = {
let _guard = ScopedEnv::unset("KACHE_TEST_EXPANSION");
apply_key_env_vars(base.clone(), &patterns, "crate")
};
let set_empty = {
let _guard = ScopedEnv::set("KACHE_TEST_EXPANSION", "");
apply_key_env_vars(base.clone(), &patterns, "crate")
};
let set_one = {
let _guard = ScopedEnv::set("KACHE_TEST_EXPANSION", "1");
apply_key_env_vars(base.clone(), &patterns, "crate")
};
let set_two = {
let _guard = ScopedEnv::set("KACHE_TEST_EXPANSION", "2");
apply_key_env_vars(base.clone(), &patterns, "crate")
};
assert_ne!(unset, set_one);
assert_ne!(set_one, set_two);
assert_ne!(unset, set_empty);
assert_ne!(unset, base);
}
#[test]
fn apply_key_env_vars_matches_by_prefix_glob() {
let _lock = key_test_lock();
let base = "deadbeef".to_string();
let patterns = vec!["KACHE_TEST_PREFIX_*".to_string()];
let none = {
let _a = ScopedEnv::unset("KACHE_TEST_PREFIX_MODE");
apply_key_env_vars(base.clone(), &patterns, "crate")
};
let one = {
let _a = ScopedEnv::set("KACHE_TEST_PREFIX_MODE", "on");
apply_key_env_vars(base.clone(), &patterns, "crate")
};
assert_ne!(none, one);
}
#[test]
fn apply_key_env_vars_is_declaration_order_and_case_independent() {
use crate::config::normalize_key_env_vars;
let _lock = key_test_lock();
let base = "deadbeef".to_string();
let _a = ScopedEnv::set("KACHE_TEST_ORDER_A", "1");
let _b = ScopedEnv::set("KACHE_TEST_ORDER_B", "2");
let canonical = normalize_key_env_vars(
[
"KACHE_TEST_ORDER_A".to_string(),
"KACHE_TEST_ORDER_B".to_string(),
],
"test",
);
let expected = apply_key_env_vars(base.clone(), &canonical, "crate");
for spelling in [
vec![
"KACHE_TEST_ORDER_B".to_string(),
"KACHE_TEST_ORDER_A".to_string(),
],
vec![
"kache_test_order_a".to_string(),
"Kache_Test_Order_B".to_string(),
],
vec![
" KACHE_TEST_ORDER_A ".to_string(),
"KACHE_TEST_ORDER_A".to_string(),
"KACHE_TEST_ORDER_B".to_string(),
],
] {
let normalized = normalize_key_env_vars(spelling.clone(), "test");
assert_eq!(
apply_key_env_vars(base.clone(), &normalized, "crate"),
expected,
"equivalent declaration {spelling:?} must fold identically"
);
}
}
fn pair(name: &str, value: &str) -> (Vec<u8>, Vec<u8>) {
(name.as_bytes().to_vec(), value.as_bytes().to_vec())
}
#[test]
fn key_env_digest_ignores_environ_order() {
let patterns = vec!["X*".to_string()];
let forward = vec![pair("XA", "1"), pair("XB", "2"), pair("XC", "3")];
let shuffled = vec![pair("XC", "3"), pair("XA", "1"), pair("XB", "2")];
assert_eq!(
key_env_digest(&patterns, forward),
key_env_digest(&patterns, shuffled)
);
}
#[test]
fn key_env_digest_preserves_order_when_a_name_repeats() {
let patterns = vec!["X*".to_string()];
let first_wins = vec![pair("XA", "1"), pair("XA", "2")];
let second_wins = vec![pair("XA", "2"), pair("XA", "1")];
assert_ne!(
key_env_digest(&patterns, first_wins),
key_env_digest(&patterns, second_wins)
);
}
#[test]
fn key_env_digest_separates_names_from_values() {
let patterns = vec!["X*".to_string()];
assert_ne!(
key_env_digest(&patterns, vec![pair("XA", "1"), pair("XB", "2")]),
key_env_digest(&patterns, vec![pair("XA", "2"), pair("XB", "1")])
);
}
#[test]
fn env_name_key_bytes_distinguishes_names() {
use std::ffi::OsStr;
let a = env_name_key_bytes(OsStr::new("KACHE_TEST_NAME_A"));
let b = env_name_key_bytes(OsStr::new("KACHE_TEST_NAME_B"));
assert!(!a.is_empty());
assert_ne!(a, b);
}
#[test]
fn env_name_key_bytes_case_policy_follows_the_platform() {
use std::ffi::OsStr;
let upper = env_name_key_bytes(OsStr::new("KACHE_TEST_CASE"));
let mixed = env_name_key_bytes(OsStr::new("Kache_Test_Case"));
if cfg!(windows) {
assert_eq!(upper, mixed);
} else {
assert_ne!(upper, mixed);
}
}
#[test]
fn env_os_key_bytes_distinguishes_values() {
use std::ffi::OsStr;
assert_ne!(
env_os_key_bytes(OsStr::new("expansion")),
env_os_key_bytes(OsStr::new("normal"))
);
assert!(env_os_key_bytes(OsStr::new("")).is_empty());
}
#[test]
fn toolchain_selector_fingerprint_tracks_every_selection_source() {
use std::ffi::OsStr;
let dir = tempfile::tempdir().unwrap();
let project = dir.path().join("workspace").join("member");
std::fs::create_dir_all(&project).unwrap();
let base = toolchain_selector_fingerprint(None, Some(&project), None);
assert_eq!(base, "", "no selection state folds to a stable empty");
let pinned =
toolchain_selector_fingerprint(Some(OsStr::new("1.93.0")), Some(&project), None);
assert_ne!(pinned, base);
assert_ne!(
toolchain_selector_fingerprint(Some(OsStr::new("nightly")), Some(&project), None),
pinned,
"different overrides must fingerprint differently"
);
std::fs::write(dir.path().join("workspace").join("rust-toolchain"), "1.88").unwrap();
std::fs::write(
dir.path().join("workspace").join("rust-toolchain.toml"),
"[toolchain]\nchannel = \"1.90\"\n",
)
.unwrap();
let with_files = toolchain_selector_fingerprint(None, Some(&project), None);
assert_eq!(with_files.matches(";file:").count(), 2, "{with_files}");
std::fs::write(dir.path().join("workspace").join("rust-toolchain"), "1.89").unwrap();
let with_edited = toolchain_selector_fingerprint(None, Some(&project), None);
assert_ne!(
with_edited, with_files,
"editing the bare file must move the fingerprint even though \
the .toml sibling is untouched"
);
let settings = dir.path().join("settings.toml");
std::fs::write(&settings, "default_toolchain = \"stable\"").unwrap();
let with_default = toolchain_selector_fingerprint(None, Some(&project), Some(&settings));
assert!(with_default.contains("default:"), "{with_default}");
assert_ne!(with_default, base);
std::fs::write(&settings, "default_toolchain = \"beta\"").unwrap();
assert_ne!(
toolchain_selector_fingerprint(None, Some(&project), Some(&settings)),
with_default,
"a rustup default switch must move the fingerprint"
);
}
#[test]
fn predictions_need_both_the_request_and_a_table() {
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join("index.db");
assert!(
FileHasher::persistent(&db)
.with_input_predictions(true)
.uses_input_predictions()
);
assert!(
!FileHasher::persistent(&db).uses_input_predictions(),
"a hasher nobody asked must not read records"
);
assert!(
!FileHasher::new()
.with_input_predictions(true)
.uses_input_predictions(),
"asking is not enough without a table to read"
);
assert!(!FileHasher::new().uses_input_predictions());
}
#[test]
fn every_rejection_names_itself_distinctly() {
let all = [
Rejection::Disabled,
Rejection::NotEligible,
Rejection::NoRecord,
Rejection::Missing,
Rejection::NotRegular,
Rejection::EnvChanged,
Rejection::Sibling,
];
let mut names: Vec<&str> = all.iter().map(|r| r.as_str()).collect();
assert!(
names.iter().all(|name| !name.is_empty()),
"a nameless refusal tells a reader nothing: {names:?}"
);
let before = names.len();
names.sort_unstable();
names.dedup();
assert_eq!(before, names.len(), "two refusals share a name: {names:?}");
assert_eq!(Rejection::Disabled.as_str(), "disabled");
assert_eq!(Rejection::Sibling.as_str(), "sibling");
}
#[test]
fn the_prediction_marker_is_taken_once() {
assert!(
!take_last_key_used_prediction(),
"nothing has been predicted on this thread"
);
LAST_KEY_USED_PREDICTION.with(|stash| stash.set(true));
assert!(take_last_key_used_prediction());
assert!(
!take_last_key_used_prediction(),
"taking must clear it, or the next key inherits this one's answer"
);
}
#[test]
fn a_record_is_only_consulted_for_an_eligible_invocation() {
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join("index.db");
let parse = |args: &[&str]| {
RustcArgs::parse(&args.iter().map(|a| (*a).to_string()).collect::<Vec<_>>()).unwrap()
};
let plain = parse(&["rustc", "src/lib.rs", "--edition", "2021"]);
let with_macro = parse(&[
"rustc",
"src/lib.rs",
"--edition",
"2021",
"--extern",
"my_macro=/t/debug/deps/libmy_macro-3.so",
]);
let off = FileHasher::persistent(&db);
assert_eq!(
predicted_key_inputs(&plain, &off),
Err(Rejection::Disabled),
"predictions off must not touch the table"
);
let on = FileHasher::persistent(&db).with_input_predictions(true);
assert_eq!(
predicted_key_inputs(&with_macro, &on),
Err(Rejection::NotEligible),
"a proc-macro dependency is refused before any lookup"
);
if get_rustc_version(Path::new("rustc")).is_ok() {
assert_eq!(
predicted_key_inputs(&plain, &on),
Err(Rejection::NoRecord),
"an eligible invocation with nothing recorded falls back"
);
}
}
#[test]
fn predictions_do_not_apply_to_units_with_a_dynamic_library_dependency() {
let dep = |path: &str| crate::args::ExternDep {
name: "dep".to_string(),
path: Some(PathBuf::from(path)),
};
let plain = vec![
dep("/t/debug/deps/libserde-1.rlib"),
dep("/t/debug/deps/libcore-2.rmeta"),
];
assert!(
prediction_applies(&plain),
"rlib and rmeta dependencies cannot scan the filesystem"
);
assert!(
prediction_applies(&[]),
"a unit with no dependencies is eligible"
);
for macro_lib in [
"/t/debug/deps/libmy_macro-3.so",
"/t/debug/deps/libmy_macro-3.dylib",
"/t/debug/deps/my_macro-3.dll",
] {
let mut with_macro = plain.clone();
with_macro.push(dep(macro_lib));
assert!(
!prediction_applies(&with_macro),
"{macro_lib} may generate includes the record cannot know about"
);
}
assert!(prediction_applies(&[crate::args::ExternDep {
name: "std".to_string(),
path: None,
}]));
}
#[test]
fn mod_sibling_is_the_other_spelling_of_the_same_module() {
assert_eq!(
mod_sibling_candidate(Path::new("src/foo.rs")),
Some(PathBuf::from("src/foo/mod.rs"))
);
assert_eq!(
mod_sibling_candidate(Path::new("src/foo/mod.rs")),
Some(PathBuf::from("src/foo.rs"))
);
for root in ["src/lib.rs", "src/main.rs"] {
assert_eq!(
mod_sibling_candidate(Path::new(root)),
None,
"{root} is named by argv, not by a mod item"
);
}
assert_eq!(
mod_sibling_candidate(Path::new("assets/data.json")),
None,
"an included asset is not a module"
);
}
#[test]
fn verify_predictions_parses_its_three_modes() {
for on in ["always", "ALWAYS", "1", "true", "True"] {
assert_eq!(
parse_verify_predictions(Some(on)),
VerifyPredictions::Always,
"{on} must verify every prediction"
);
}
for sampled in ["sampled", "SAMPLED"] {
assert_eq!(
parse_verify_predictions(Some(sampled)),
VerifyPredictions::Sampled
);
}
for off in [Some("off"), Some("0"), Some("false"), Some(""), None] {
assert_eq!(
parse_verify_predictions(off),
VerifyPredictions::Off,
"{off:?} must not cost a pre-pass"
);
}
assert!(!should_verify_this_prediction(
VerifyPredictions::Off,
"unit"
));
assert!(should_verify_this_prediction(
VerifyPredictions::Always,
"unit"
));
let identities: Vec<String> = (0..VERIFY_PREDICTION_RATE * 20)
.map(|i| format!("identity-{i}"))
.collect();
let chosen = identities
.iter()
.filter(|id| should_verify_this_prediction(VerifyPredictions::Sampled, id))
.count();
assert!(
(5..=40).contains(&chosen),
"about 1 in {VERIFY_PREDICTION_RATE} of {} should be checked, got {chosen}",
identities.len()
);
assert!(
identities
.iter()
.any(|id| !should_verify_this_prediction(VerifyPredictions::Sampled, id)),
"sampling that selects everything is not sampling"
);
let first = identities
.iter()
.find(|id| should_verify_this_prediction(VerifyPredictions::Sampled, id))
.expect("some identity must be selected");
for _ in 0..5 {
assert!(should_verify_this_prediction(
VerifyPredictions::Sampled,
first
));
}
}
#[test]
fn closures_agree_on_content_not_order() {
let dep = |sources: &[&str], env: &[(&str, &str)]| DepInfo {
source_files: sources.iter().map(PathBuf::from).collect(),
env_deps: env
.iter()
.map(|(k, v)| ((*k).to_string(), (*v).to_string()))
.collect(),
};
let a = dep(&["src/lib.rs", "src/helper.rs"], &[("OUT_DIR", "/t")]);
let reordered = dep(&["src/helper.rs", "src/lib.rs"], &[("OUT_DIR", "/t")]);
assert!(closures_agree(&a, &reordered));
assert!(!closures_agree(
&a,
&dep(
&["src/lib.rs", "src/helper.rs", "src/extra.rs"],
&[("OUT_DIR", "/t")]
)
));
assert!(!closures_agree(
&a,
&dep(&["src/lib.rs"], &[("OUT_DIR", "/t")])
));
assert!(!closures_agree(
&a,
&dep(&["src/lib.rs", "src/helper.rs"], &[])
));
assert!(!closures_agree(
&a,
&dep(&["src/lib.rs", "src/helper.rs"], &[("OUT_DIR", "/other")])
));
}
#[test]
fn a_prediction_is_validated_against_the_tree_as_it_is_now() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("src");
std::fs::create_dir_all(&src).unwrap();
let lib = src.join("lib.rs");
let helper = src.join("helper.rs");
std::fs::write(&lib, "mod helper;\n").unwrap();
std::fs::write(&helper, "pub fn n() {}\n").unwrap();
let record = InputPrediction {
schema: PREDICTION_SCHEMA,
sources: vec![lib.clone(), helper.clone()],
env_deps: vec![("OUT_DIR".to_string(), "/t/build/out".to_string())],
};
let stat = |path: &Path| std::fs::metadata(path).ok();
let exists = |path: &Path| path.exists();
let env = |var: &str| (var == "OUT_DIR").then(|| "/t/build/out".to_string());
let accepted = validate_prediction(&record, stat, exists, env)
.expect("an unchanged tree must reuse the recorded closure");
assert_eq!(accepted.source_files, record.sources);
assert_eq!(accepted.env_deps, record.env_deps);
assert_eq!(
validate_prediction(&record, stat, exists, |var| (var == "OUT_DIR")
.then(|| "/t/build/other".to_string())),
Err(Rejection::EnvChanged)
);
assert_eq!(
validate_prediction(&record, stat, exists, |_| None),
Err(Rejection::EnvChanged),
"an unset variable is not the value that was recorded"
);
std::fs::remove_file(&helper).unwrap();
assert_eq!(
validate_prediction(&record, stat, exists, env),
Err(Rejection::Missing)
);
std::fs::create_dir(&helper).unwrap();
assert_eq!(
validate_prediction(&record, stat, exists, env),
Err(Rejection::NotRegular)
);
std::fs::remove_dir(&helper).unwrap();
std::fs::write(&helper, "pub fn n() {}\n").unwrap();
std::fs::create_dir(src.join("helper")).unwrap();
std::fs::write(src.join("helper/mod.rs"), "pub fn n() {}\n").unwrap();
assert_eq!(
validate_prediction(&record, stat, exists, env),
Err(Rejection::Sibling)
);
}
#[test]
fn an_unset_variable_matches_an_empty_record() {
let dir = tempfile::tempdir().unwrap();
let lib = dir.path().join("lib.rs");
std::fs::write(&lib, "pub fn f() {}\n").unwrap();
let stat = |path: &Path| std::fs::metadata(path).ok();
let exists = |path: &Path| path.exists();
let empty = InputPrediction {
schema: PREDICTION_SCHEMA,
sources: vec![lib.clone()],
env_deps: vec![("KACHE_PROBE_UNSET".to_string(), String::new())],
};
assert!(
validate_prediction(&empty, stat, exists, |_| None).is_ok(),
"unset matches empty, exactly as the parser would fold it"
);
let valued = InputPrediction {
schema: PREDICTION_SCHEMA,
sources: vec![lib],
env_deps: vec![("KACHE_PROBE_UNSET".to_string(), "1".to_string())],
};
assert_eq!(
validate_prediction(&valued, stat, exists, |_| None),
Err(Rejection::EnvChanged)
);
}
#[test]
fn prediction_identity_separates_every_part_it_folds() {
let base_args: Vec<String> = ["--edition", "2021", "--crate-name", "demo"]
.iter()
.map(|a| (*a).to_string())
.collect();
let base_env = |extra: &[(&str, &str)]| -> Vec<(std::ffi::OsString, std::ffi::OsString)> {
let mut env: Vec<(std::ffi::OsString, std::ffi::OsString)> = vec![(
std::ffi::OsString::from("CARGO_CFG_TARGET_OS"),
std::ffi::OsString::from("linux"),
)];
env.extend(
extra
.iter()
.map(|(k, v)| (std::ffi::OsString::from(k), std::ffi::OsString::from(v))),
);
env
};
let base_parts = PredictionIdentityParts {
rustc_version: "rustc 1.95.0",
inner_rustc: None,
current_dir: Some(Path::new("/w/one")),
source_file: Path::new("src/lib.rs"),
closure_args: &base_args,
skip_path_remap: false,
};
let base = prediction_identity_in_env(&base_parts, base_env(&[]));
assert_eq!(
prediction_identity_in_env(&base_parts, base_env(&[])),
base,
"the same invocation twice is the same identity"
);
let other_args: Vec<String> = ["--edition", "2024", "--crate-name", "demo"]
.iter()
.map(|a| (*a).to_string())
.collect();
let perturbed: Vec<(&str, PredictionIdentityParts<'_>)> = vec![
(
"a different compiler build",
PredictionIdentityParts {
rustc_version: "rustc 1.96.0",
..base_parts
},
),
(
"a wrapped inner compiler",
PredictionIdentityParts {
inner_rustc: Some(Path::new("/usr/bin/rustc")),
..base_parts
},
),
(
"another working directory, which relative args resolve against",
PredictionIdentityParts {
current_dir: Some(Path::new("/w/two")),
..base_parts
},
),
(
"another crate root",
PredictionIdentityParts {
source_file: Path::new("src/main.rs"),
..base_parts
},
),
(
"an edition change, which changes what resolves",
PredictionIdentityParts {
closure_args: &other_args,
..base_parts
},
),
(
"path remapping turned off",
PredictionIdentityParts {
skip_path_remap: true,
..base_parts
},
),
];
for (what, parts) in perturbed {
assert_ne!(
prediction_identity_in_env(&parts, base_env(&[])),
base,
"{what} must not reuse another invocation's record"
);
}
for var in PREDICTION_ENV {
assert_ne!(
prediction_identity_in_env(&base_parts, base_env(&[(var, "value")])),
base,
"{var} must separate identities"
);
}
assert_ne!(
prediction_identity_in_env(&base_parts, base_env(&[("RUSTFLAGS", "")])),
base,
"an empty RUSTFLAGS is not an absent one"
);
let mut other_cfg = base_env(&[]);
other_cfg[0].1 = std::ffi::OsString::from("windows");
assert_ne!(
prediction_identity_in_env(&base_parts, other_cfg),
base,
"CARGO_CFG_* changes which modules compile"
);
assert_eq!(
prediction_identity_in_env(&base_parts, base_env(&[("PWD", "/somewhere/else")])),
base,
"variables that do not change what rustc reads must not be folded"
);
}
#[test]
fn rustc_prediction_identity_needs_a_crate_root_and_separates_units() {
let _lock = crate::test_support::process_state_test_lock();
let parse = |args: &[&str]| {
RustcArgs::parse(&args.iter().map(|a| (*a).to_string()).collect::<Vec<_>>()).unwrap()
};
assert_eq!(
rustc_prediction_identity(&parse(&["rustc", "--version"])),
None,
"a query invocation compiles nothing and predicts nothing"
);
if get_rustc_version(Path::new("rustc")).is_err() {
return; }
let env: Vec<(std::ffi::OsString, std::ffi::OsString)> = vec![(
std::ffi::OsString::from("CARGO_CFG_TARGET_OS"),
std::ffi::OsString::from("linux"),
)];
let identity = |args: &[&str]| {
rustc_prediction_identity_in_env(&parse(args), env.clone())
.expect("a crate root gives an identity")
};
let lib = identity(&["rustc", "src/lib.rs", "--edition", "2021"]);
assert!(!lib.is_empty());
assert_ne!(
lib,
identity(&["rustc", "src/main.rs", "--edition", "2021"]),
"different crate roots read different files"
);
assert_ne!(
lib,
identity(&["rustc", "src/lib.rs", "--edition", "2024"]),
"a different edition resolves differently"
);
assert_eq!(
lib,
identity(&[
"rustc",
"src/lib.rs",
"--edition",
"2021",
"-C",
"extra-filename=-abc123",
]),
"two units of one crate share a record"
);
}
#[test]
fn prediction_identity_ignores_output_naming_flags() {
let closure = |extra: &[&str]| -> Vec<String> {
let mut args: Vec<String> = ["--edition", "2021"]
.iter()
.map(|a| (*a).to_string())
.collect();
args.extend(extra.iter().map(|a| (*a).to_string()));
closure_shaping_args(Path::new("src/lib.rs"), &args)
};
let plain = closure(&[]);
for naming in [
vec!["-C", "extra-filename=-abc123"],
vec!["--out-dir", "/target/debug/deps"],
vec!["--emit=metadata,link"],
] {
assert_eq!(
closure(&naming),
plain,
"{naming:?} decides where output goes, not what rustc reads"
);
}
assert_ne!(
closure(&["--cfg", "feature=\"extra\""]),
plain,
"a cfg can add a module, so it stays in the identity"
);
}
#[test]
fn input_prediction_round_trips_through_the_database() {
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join("index.db");
let dep_info = DepInfo {
source_files: vec![
PathBuf::from("/w/src/lib.rs"),
PathBuf::from("/w/src/generated.rs"),
],
env_deps: vec![("OUT_DIR".to_string(), "/target/build/out".to_string())],
};
let hasher = FileHasher::persistent(&db);
assert!(hasher.supports_input_predictions());
assert_eq!(
hasher.input_prediction("unit"),
None,
"an identity never recorded has no prediction"
);
hasher.record_input_prediction("unit", Some("demo"), &dep_info);
let record = FileHasher::persistent(&db)
.input_prediction("unit")
.expect("the recorded closure must survive a new process");
assert_eq!(record.schema, PREDICTION_SCHEMA);
assert_eq!(record.sources, dep_info.source_files);
assert_eq!(record.env_deps, dep_info.env_deps);
let narrower = DepInfo {
source_files: vec![PathBuf::from("/w/src/lib.rs")],
env_deps: Vec::new(),
};
FileHasher::persistent(&db).record_input_prediction("unit", Some("demo"), &narrower);
let record = FileHasher::persistent(&db)
.input_prediction("unit")
.unwrap();
assert_eq!(record.sources, narrower.source_files);
assert!(record.env_deps.is_empty());
}
#[test]
fn a_prediction_from_another_schema_is_not_read() {
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join("index.db");
let hasher = FileHasher::persistent(&db);
let cache = hasher.cache.as_ref().unwrap();
cache
.put_input_prediction("future", PREDICTION_SCHEMA + 1, None, "{\"anything\":1}")
.unwrap();
assert_eq!(hasher.input_prediction("future"), None);
cache
.put_input_prediction("corrupt", PREDICTION_SCHEMA, None, "not json")
.unwrap();
assert_eq!(hasher.input_prediction("corrupt"), None);
let stale = format!(
"{{\"schema\":{},\"sources\":[],\"env_deps\":[]}}",
PREDICTION_SCHEMA + 1
);
cache
.put_input_prediction("mismatched", PREDICTION_SCHEMA, None, &stale)
.unwrap();
assert_eq!(hasher.input_prediction("mismatched"), None);
}
#[test]
fn recording_without_a_database_is_a_no_op() {
let hasher = FileHasher::new();
assert!(!hasher.supports_input_predictions());
hasher.record_input_prediction(
"unit",
None,
&DepInfo {
source_files: vec![PathBuf::from("/w/src/lib.rs")],
env_deps: Vec::new(),
},
);
assert_eq!(hasher.input_prediction("unit"), None);
}
#[test]
fn cargo_cfg_pairs_filters_and_sorts() {
use std::ffi::OsString;
let pairs = cargo_cfg_pairs(
[
(OsString::from("CARGO_CFG_ZED"), OsString::from("1")),
(OsString::from("PATH"), OsString::from("/usr/bin")),
(OsString::from("CARGO_CFG_ABI"), OsString::from("eabi")),
(OsString::from("CARGO_PKG_NAME"), OsString::from("x")),
]
.into_iter(),
);
assert_eq!(
pairs,
vec![
(OsString::from("CARGO_CFG_ABI"), OsString::from("eabi")),
(OsString::from("CARGO_CFG_ZED"), OsString::from("1")),
]
);
}
#[cfg(unix)]
#[test]
fn cargo_cfg_pairs_tolerates_non_utf8_environments() {
use std::ffi::OsString;
use std::os::unix::ffi::OsStringExt;
let invalid = OsString::from_vec(vec![b'a', 0xff, b'b']);
let pairs = cargo_cfg_pairs(
[
(OsString::from_vec(vec![0xff, 0xfe]), invalid.clone()),
(OsString::from("CARGO_CFG_RAW"), invalid.clone()),
]
.into_iter(),
);
assert_eq!(pairs, vec![(OsString::from("CARGO_CFG_RAW"), invalid)]);
}
#[test]
fn env_text_key_bytes_preserves_utf8_and_distinguishes_invalid() {
use std::ffi::OsStr;
assert_eq!(
env_text_key_bytes(OsStr::new("target_os=\"linux\"")),
b"target_os=\"linux\"".to_vec()
);
#[cfg(unix)]
{
use std::os::unix::ffi::OsStrExt;
let a = env_text_key_bytes(OsStr::from_bytes(&[b'x', 0xff]));
let b = env_text_key_bytes(OsStr::from_bytes(&[b'x', 0xfe]));
assert_ne!(a, b);
assert_eq!(a[0], 0xff);
}
}
#[test]
fn tool_version_cache_path_is_a_named_file_in_the_cache_dir() {
let _lock = key_test_lock();
let dir = tempfile::tempdir().unwrap();
let binary = dir.path().join("rustc");
std::fs::write(&binary, b"test rustc").unwrap();
let cache_file = tool_version_cache_path(&binary, "rustc-ver")
.expect("a readable binary must produce a cache path");
let cache_dir = crate::config::default_cache_dir();
assert_eq!(cache_file.parent(), Some(cache_dir.as_path()));
let file_name = cache_file
.file_name()
.expect("cache path must name a file")
.to_string_lossy();
let digest = file_name
.strip_prefix("rustc-ver-")
.and_then(|name| name.strip_suffix(".txt"))
.expect("cache file must retain its prefix and extension");
assert_eq!(digest.len(), 16, "cache file uses the short BLAKE3 digest");
assert!(digest.bytes().all(|byte| byte.is_ascii_hexdigit()));
}
#[test]
#[ignore = "spawned by the explicit RUSTUP_HOME regression"]
fn rustup_settings_path_explicit_home_fixture() {
let expected_home = std::env::var_os("KACHE_TEST_RUSTUP_HOME")
.map(std::path::PathBuf::from)
.expect("fixture requires its isolated expected home");
assert_eq!(
rustup_settings_path(),
Some(expected_home.join("settings.toml"))
);
}
#[test]
fn tool_version_cache_uses_settings_under_explicit_rustup_home() {
let dir = tempfile::tempdir().unwrap();
let output = std::process::Command::new(
std::env::current_exe().expect("resolve cache-key test executable"),
)
.args([
"--ignored",
"--exact",
"cache_key::tests::rustup_settings_path_explicit_home_fixture",
"--test-threads=1",
])
.env("RUSTUP_HOME", dir.path())
.env("KACHE_TEST_RUSTUP_HOME", dir.path())
.output()
.expect("spawn isolated RUSTUP_HOME fixture");
assert!(
output.status.success(),
"isolated RUSTUP_HOME fixture failed:\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
#[test]
fn apply_key_env_vars_keeps_distinct_path_values_distinct() {
let _lock = key_test_lock();
let base = "deadbeef".to_string();
let patterns = vec!["KACHE_TEST_ROOT".to_string()];
let alice = {
let _guard = ScopedEnv::set("KACHE_TEST_ROOT", "/home/alice/proj/src");
apply_key_env_vars(base.clone(), &patterns, "crate")
};
let bob = {
let _guard = ScopedEnv::set("KACHE_TEST_ROOT", "/srv/build/bob/checkout/src");
apply_key_env_vars(base.clone(), &patterns, "crate")
};
assert_ne!(alice, bob, "declared env values must be folded exactly");
}
#[cfg(unix)]
#[test]
fn apply_key_env_vars_distinguishes_non_utf8_values() {
use std::os::unix::ffi::OsStrExt;
let _lock = key_test_lock();
let base = "deadbeef".to_string();
let patterns = vec!["KACHE_TEST_RAW".to_string()];
let key_for = |bytes: &[u8]| {
let previous = std::env::var_os("KACHE_TEST_RAW");
unsafe { std::env::set_var("KACHE_TEST_RAW", std::ffi::OsStr::from_bytes(bytes)) };
let key = apply_key_env_vars(base.clone(), &patterns, "crate");
unsafe {
match previous {
Some(value) => std::env::set_var("KACHE_TEST_RAW", value),
None => std::env::remove_var("KACHE_TEST_RAW"),
}
}
key
};
assert_ne!(key_for(&[0xff]), key_for(&[0xfe]));
}
#[test]
fn apply_key_salt_distinguishes_base_keys() {
let salt = Some("nix-rev-abc");
assert_ne!(
apply_key_salt("aaaa".to_string(), salt, "crate"),
apply_key_salt("bbbb".to_string(), salt, "crate"),
);
}
#[test]
fn source_scanner_detects_runtime_env_use_and_skips_literals() {
use super::source_has_runtime_env_dep_use as scan;
assert!(scan(r#"const X: &str = env!("MYVAR");"#, "MYVAR"));
assert!(scan(r#"let v = option_env!("MYVAR");"#, "MYVAR"));
assert!(!scan(r#"env!("OTHER")"#, "MYVAR"));
assert!(!scan(
r#"include!(concat!(env!("MYVAR"), "/gen.rs"));"#,
"MYVAR"
));
assert!(!scan(r#"let s = "env!(\"MYVAR\")";"#, "MYVAR"));
assert!(!scan(r###"let s = r#"env!("MYVAR")"#;"###, "MYVAR"));
assert!(!scan(r#"// env!("MYVAR")"#, "MYVAR"));
assert!(!scan(r#"/* env!("MYVAR") */"#, "MYVAR"));
assert!(scan(r#"let c = '"'; let x = env!("MYVAR");"#, "MYVAR"));
assert!(scan(r###"let r = r#"noise"#; env!("MYVAR")"###, "MYVAR"));
}
#[test]
fn unescape_env_dep_value_undoes_rustc_escaping() {
assert_eq!(
unescape_env_dep_value(r"C:\\actions-runner\\proj\\target\\out"),
r"C:\actions-runner\proj\target\out"
);
assert_eq!(unescape_env_dep_value(r"a\nb\rc"), "a\nb\rc");
assert_eq!(
unescape_env_dep_value("/home/u/proj/out"),
"/home/u/proj/out"
);
assert_eq!(unescape_env_dep_value("plain-value"), "plain-value");
}
#[test]
fn parse_env_dep_info_unescapes_windows_paths() {
let dep = "# env-dep:OUT_DIR=C:\\\\proj\\\\build\\\\out\n";
let deps = parse_env_dep_info(dep);
assert_eq!(
deps,
vec![("OUT_DIR".to_string(), r"C:\proj\build\out".to_string())]
);
}
#[test]
fn test_cache_key_deterministic() {
let _lock = key_test_lock();
let dir = tempfile::tempdir().unwrap();
let source = dir.path().join("lib.rs");
std::fs::write(&source, b"pub fn hello() {}").unwrap();
let args_vec: Vec<String> = vec![
"rustc".to_string(),
"--crate-name".to_string(),
"mylib".to_string(),
source.to_string_lossy().to_string(),
"--crate-type".to_string(),
"lib".to_string(),
"--edition=2021".to_string(),
"-C".to_string(),
"opt-level=2".to_string(),
];
let parsed1 = RustcArgs::parse(&args_vec).unwrap();
let parsed2 = RustcArgs::parse(&args_vec).unwrap();
let fh = FileHasher::new();
let pn = PathNormalizer::empty();
let key1 = compute_cache_key(&parsed1, &fh, &pn).unwrap();
let key2 = compute_cache_key(&parsed2, &fh, &pn).unwrap();
assert_eq!(key1, key2);
}
#[test]
fn cache_key_ignores_linker_path() {
let _lock = key_test_lock();
let dir = tempfile::tempdir().unwrap();
let source = dir.path().join("lib.rs");
std::fs::write(&source, b"pub fn hello() {}").unwrap();
let mk = |linker: &str| -> Vec<String> {
vec![
"rustc".to_string(),
"--crate-name".to_string(),
"mylib".to_string(),
source.to_string_lossy().to_string(),
"--crate-type".to_string(),
"lib".to_string(),
"-C".to_string(),
format!("linker={linker}"),
]
};
let fh = FileHasher::new();
let pn = PathNormalizer::empty();
let a = compute_cache_key(
&RustcArgs::parse(&mk("/Users/alice/clang++")).unwrap(),
&fh,
&pn,
)
.unwrap();
let b = compute_cache_key(
&RustcArgs::parse(&mk("/home/runner/clang++")).unwrap(),
&fh,
&pn,
)
.unwrap();
assert_eq!(a, b, "linker path must not affect the cache key");
}
fn flag_base(source: &Path, extra: &[&str]) -> Vec<String> {
let mut v = vec![
"rustc".to_string(),
"--crate-name".to_string(),
"mylib".to_string(),
source.to_string_lossy().to_string(),
"--crate-type".to_string(),
"cdylib".to_string(),
];
v.extend(extra.iter().map(|s| s.to_string()));
v
}
fn key_of(args: &[String]) -> String {
let fh = FileHasher::new();
let pn = PathNormalizer::empty();
compute_cache_key(&RustcArgs::parse(args).unwrap(), &fh, &pn).unwrap()
}
fn key_of_flags(args: &[String]) -> String {
let fh = FileHasher::new();
let pn = PathNormalizer::empty();
let mut parsed = RustcArgs::parse(args).unwrap();
parsed.source_file = None;
compute_cache_key(&parsed, &fh, &pn).unwrap()
}
#[cfg(not(windows))]
#[test]
fn link_lib_changes_key() {
let _lock = key_test_lock();
let dir = tempfile::tempdir().unwrap();
let source = dir.path().join("lib.rs");
std::fs::write(&source, b"pub fn hello() {}").unwrap();
let none = key_of(&flag_base(&source, &[]));
let ssl = key_of(&flag_base(&source, &["-l", "ssl"]));
let crypto = key_of(&flag_base(&source, &["-l", "crypto"]));
assert_ne!(none, ssl, "adding -l must change the key");
assert_ne!(ssl, crypto, "a different -l lib must change the key");
assert_eq!(ssl, key_of(&flag_base(&source, &["-lssl"])));
}
#[test]
fn clean_static_lib_name_accepts_only_plain_static() {
assert_eq!(clean_static_lib_name("static=foo"), Some("foo"));
assert_eq!(clean_static_lib_name("static:+verbatim=foo"), None);
assert_eq!(clean_static_lib_name("static:-bundle=foo"), None);
assert_eq!(clean_static_lib_name("static=foo:bar"), None);
assert_eq!(clean_static_lib_name("dylib=foo"), None);
assert_eq!(clean_static_lib_name("foo"), None);
assert_eq!(clean_static_lib_name("static="), None);
}
#[test]
fn resolve_native_static_lib_hashes_only_unambiguous_static_archives() {
let fh = FileHasher::new();
let dir = tempfile::tempdir().unwrap();
let lib = dir.path().join("libfoo.a");
std::fs::write(&lib, b"v1 archive bytes").unwrap();
let dirs = vec![dir.path().to_path_buf()];
let (path, h1) = resolve_native_static_lib("static=foo", &dirs, &fh)
.unwrap()
.expect("static lib in a search dir must resolve");
assert_eq!(path, lib);
let duplicate_dirs = vec![dir.path().to_path_buf(), dir.path().to_path_buf()];
let (duplicate_path, _) = resolve_native_static_lib("static=foo", &duplicate_dirs, &fh)
.unwrap()
.expect("duplicate search dirs must not make one archive ambiguous");
assert_eq!(duplicate_path, lib);
std::fs::write(&lib, b"v2 different bytes").unwrap();
let (_, h2) = resolve_native_static_lib("static=foo", &dirs, &fh)
.unwrap()
.unwrap();
assert_ne!(h1, h2, "content change must change the resolved hash");
assert!(
resolve_native_static_lib("dylib=foo", &dirs, &fh)
.unwrap()
.is_none()
);
assert!(
resolve_native_static_lib("foo", &dirs, &fh)
.unwrap()
.is_none()
);
assert!(
resolve_native_static_lib("static:+verbatim=foo", &dirs, &fh)
.unwrap()
.is_none()
);
assert!(
resolve_native_static_lib("static=absent", &dirs, &fh)
.unwrap()
.is_none()
);
let other_dir = tempfile::tempdir().unwrap();
std::fs::write(other_dir.path().join("libfoo.a"), b"different archive").unwrap();
assert!(
resolve_native_static_lib(
"static=foo",
&[dir.path().to_path_buf(), other_dir.path().to_path_buf()],
&fh,
)
.is_err(),
"distinct search-dir matches must fail closed"
);
std::fs::write(dir.path().join("foo.lib"), b"msvc import lib").unwrap();
assert!(
resolve_native_static_lib("static=foo", &dirs, &fh).is_err(),
"ambiguous .a/.lib match must fail closed"
);
}
#[test]
fn native_linker_side_files_fail_closed() {
let parsed = RustcArgs::parse(&[
"rustc".to_string(),
"src/lib.rs".to_string(),
"-C".to_string(),
"link-arg=-Wl,-order_file,/tmp/order.txt".to_string(),
])
.unwrap();
assert!(native_linker_side_files_are_unmodeled(&parsed));
let response_file = RustcArgs::parse(&[
"rustc".to_string(),
"src/lib.rs".to_string(),
"-Clink-arg=@/tmp/ld.rsp".to_string(),
])
.unwrap();
assert!(native_linker_side_files_are_unmodeled(&response_file));
for apple_dynamic_path in [
"link-arg=-Wl,-rpath,@loader_path",
"link-arg=-Wl,-rpath,@loader_path/../lib",
"link-arg=-Wl,-rpath,@rpath",
"link-arg=-Wl,-install_name,@executable_path",
"link-arg=-Wl,-install_name,@executable_path/lib/libfoo.dylib",
] {
let parsed = RustcArgs::parse(&[
"rustc".to_string(),
"src/lib.rs".to_string(),
"-C".to_string(),
apple_dynamic_path.to_string(),
"--target=aarch64-apple-darwin".to_string(),
])
.unwrap();
assert!(
!native_linker_side_files_are_unmodeled(&parsed),
"Apple dynamic path is not a response file: {apple_dynamic_path}"
);
}
let forwarded_response_file = RustcArgs::parse(&[
"rustc".to_string(),
"src/lib.rs".to_string(),
"-C".to_string(),
"link-arg=-Wl,@/tmp/ld.rsp".to_string(),
])
.unwrap();
assert!(native_linker_side_files_are_unmodeled(
&forwarded_response_file
));
for long_codegen_response_file in [
"--codegen=link-arg=@/tmp/ld.rsp",
"--codegen=link-args=-Wl,@/tmp/ld.rsp",
] {
let parsed = RustcArgs::parse(&[
"rustc".to_string(),
"src/lib.rs".to_string(),
long_codegen_response_file.to_string(),
])
.unwrap();
assert!(
native_linker_side_files_are_unmodeled(&parsed),
"long codegen response file must fail closed: {long_codegen_response_file}"
);
}
let non_apple_at_rpath = RustcArgs::parse(&[
"rustc".to_string(),
"src/lib.rs".to_string(),
"-Clink-arg=-Wl,@rpath".to_string(),
"--target=x86_64-unknown-linux-gnu".to_string(),
])
.unwrap();
assert!(
native_linker_side_files_are_unmodeled(&non_apple_at_rpath),
"Apple dynamic-token exemptions must not hide non-Apple response files"
);
let custom_apple_named_target = RustcArgs::parse(&[
"rustc".to_string(),
"src/lib.rs".to_string(),
"-Clink-arg=-Wl,@rpath".to_string(),
"--target=/tmp/aarch64-apple-darwin.json".to_string(),
])
.unwrap();
assert!(
native_linker_side_files_are_unmodeled(&custom_apple_named_target),
"an Apple-looking custom target is not proof of Apple linker semantics"
);
let rust_target_path_name = RustcArgs::parse(&[
"rustc".to_string(),
"src/lib.rs".to_string(),
"-Clink-arg=-Wl,@rpath".to_string(),
"--target=aarch64-apple-custom".to_string(),
])
.unwrap();
assert!(
native_linker_side_files_are_unmodeled(&rust_target_path_name),
"a RUST_TARGET_PATH name is not proof of built-in Apple semantics"
);
let map_file = RustcArgs::parse(&[
"rustc".to_string(),
"src/lib.rs".to_string(),
"-Clink-arg=-Wl,-map,/tmp/link.map".to_string(),
])
.unwrap();
assert!(native_linker_side_files_are_unmodeled(&map_file));
let lld_map_file = RustcArgs::parse(&[
"rustc".to_string(),
"src/lib.rs".to_string(),
"--codegen=link-arg=-Wl,--Map=/tmp/link.map".to_string(),
])
.unwrap();
assert!(native_linker_side_files_are_unmodeled(&lld_map_file));
let coff_map_file = RustcArgs::parse(&[
"rustc".to_string(),
"src/lib.rs".to_string(),
r"--codegen=link-arg=/MAP:C:\tmp\link.map".to_string(),
])
.unwrap();
assert!(native_linker_side_files_are_unmodeled(&coff_map_file));
for ordering_file in [
"--codegen=link-arg=-Wl,--symbol-ordering-file=/tmp/order.txt",
r"--codegen=link-arg=/call-graph-ordering-file:C:\tmp\order.txt",
r"--codegen=link-arg=/ORDER:@C:\tmp\order.txt",
] {
let parsed = RustcArgs::parse(&[
"rustc".to_string(),
"src/lib.rs".to_string(),
ordering_file.to_string(),
])
.unwrap();
assert!(
native_linker_side_files_are_unmodeled(&parsed),
"linker ordering files must fail closed: {ordering_file}"
);
}
let ordinary = RustcArgs::parse(&[
"rustc".to_string(),
"src/lib.rs".to_string(),
"-Copt-level=2".to_string(),
])
.unwrap();
assert!(!native_linker_side_files_are_unmodeled(&ordinary));
let fh = FileHasher::new();
let dir = tempfile::tempdir().unwrap();
let lib = dir.path().join("libfoo.a");
std::fs::write(&lib, gnu_ar_one_object(b"object")).unwrap();
std::fs::write(&lib, gnu_ar_named_object("foo.o", b"same object")).unwrap();
let named_foo = fh.hash_static_lib(&lib).unwrap();
std::fs::write(&lib, gnu_ar_named_object("bar.o", b"same object")).unwrap();
let named_bar = fh.hash_static_lib(&lib).unwrap();
assert_ne!(named_foo, named_bar);
let source = dir.path().join("lib.rs");
std::fs::write(&source, b"pub fn probe() {}").unwrap();
let guarded = RustcArgs::parse(&flag_base(
&source,
&[
"-l",
"static=foo",
"-C",
"link-arg=-Wl,-order_file,/tmp/order.txt",
],
))
.unwrap();
let error = compute_cache_key(&guarded, &fh, &PathNormalizer::empty()).unwrap_err();
assert!(error.to_string().contains("side files are not cacheable"));
}
fn elf64le_relocatable(payload: &[u8]) -> Vec<u8> {
let mut object = vec![0_u8; 64];
object[..4].copy_from_slice(b"\x7fELF");
object[4] = 2; object[5] = 1; object[6] = 1; object[16..18].copy_from_slice(&1_u16.to_le_bytes()); object[18..20].copy_from_slice(&62_u16.to_le_bytes()); object[20..24].copy_from_slice(&1_u32.to_le_bytes());
object[52..54].copy_from_slice(&64_u16.to_le_bytes());
object[58..60].copy_from_slice(&64_u16.to_le_bytes());
object[60..62].copy_from_slice(&3_u16.to_le_bytes());
object[62..64].copy_from_slice(&2_u16.to_le_bytes());
let payload_offset = object.len();
object.extend_from_slice(payload);
let names_offset = object.len();
let names = b"\0.data\0.shstrtab\0";
object.extend_from_slice(names);
while !object.len().is_multiple_of(8) {
object.push(0);
}
let section_offset = object.len();
object.resize(section_offset + 3 * 64, 0);
object[40..48].copy_from_slice(&(section_offset as u64).to_le_bytes());
let payload_header = section_offset + 64;
object[payload_header..payload_header + 4].copy_from_slice(&1_u32.to_le_bytes());
object[payload_header + 4..payload_header + 8].copy_from_slice(&1_u32.to_le_bytes());
object[payload_header + 24..payload_header + 32]
.copy_from_slice(&(payload_offset as u64).to_le_bytes());
object[payload_header + 32..payload_header + 40]
.copy_from_slice(&(payload.len() as u64).to_le_bytes());
object[payload_header + 48..payload_header + 56].copy_from_slice(&1_u64.to_le_bytes());
let names_header = section_offset + 2 * 64;
object[names_header..names_header + 4].copy_from_slice(&7_u32.to_le_bytes());
object[names_header + 4..names_header + 8].copy_from_slice(&3_u32.to_le_bytes());
object[names_header + 24..names_header + 32]
.copy_from_slice(&(names_offset as u64).to_le_bytes());
object[names_header + 32..names_header + 40]
.copy_from_slice(&(names.len() as u64).to_le_bytes());
object[names_header + 48..names_header + 56].copy_from_slice(&1_u64.to_le_bytes());
object
}
fn gnu_ar_raw_named_object(name: &str, object: &[u8]) -> Vec<u8> {
assert!(!name.is_empty() && name.len() <= 15 && !name.contains('/'));
let mut a = b"!<arch>\n".to_vec();
let member_name = format!("{name}/");
let mut h = format!("{member_name:<16}").into_bytes();
h.extend_from_slice(format!("{:<12}", 0).as_bytes()); h.extend_from_slice(format!("{:<6}", 0).as_bytes()); h.extend_from_slice(format!("{:<6}", 0).as_bytes()); h.extend_from_slice(format!("{:<8}", "100644").as_bytes()); h.extend_from_slice(format!("{:<10}", object.len()).as_bytes()); h.extend_from_slice(b"`\n");
assert_eq!(h.len(), 60);
a.extend_from_slice(&h);
a.extend_from_slice(object);
if object.len() % 2 == 1 {
a.push(b'\n');
}
a
}
fn gnu_ar_one_object(payload: &[u8]) -> Vec<u8> {
gnu_ar_named_object("object.o", payload)
}
fn gnu_ar_named_object(name: &str, payload: &[u8]) -> Vec<u8> {
gnu_ar_raw_named_object(name, &elf64le_relocatable(payload))
}
#[test]
fn hash_static_lib_caches_and_is_namespace_isolated() {
let dir = tempfile::tempdir().unwrap();
let fh = FileHasher::persistent(&dir.path().join("index.db"));
let lib = dir.path().join("libbig.a");
std::fs::write(&lib, gnu_ar_one_object(&vec![0x41u8; 70_000])).unwrap();
let portable = fh.hash_static_lib(&lib).unwrap();
assert!(
portable.starts_with("gnu-ar-v2:"),
"a GNU archive gets the structural member digest"
);
assert_eq!(fh.hash_static_lib(&lib).unwrap(), portable);
let whole = fh.hash(&lib).unwrap();
assert!(!whole.starts_with("gnu-ar-v2:"));
assert_ne!(whole, portable);
assert_eq!(fh.hash_static_lib(&lib).unwrap(), portable);
}
#[test]
fn hash_static_lib_ignores_legacy_namespaces() {
let dir = tempfile::tempdir().unwrap();
let fh = FileHasher::persistent(&dir.path().join("index.db"));
let lib = dir.path().join("libbig.a");
std::fs::write(&lib, gnu_ar_one_object(&vec![0x41_u8; 70_000])).unwrap();
let fingerprint = FileFingerprint::from_path(&lib).unwrap();
let legacy_key = FileFingerprint {
path: format!("static-ar-v1\0{}", fingerprint.path),
..fingerprint.clone()
};
let legacy_v2_key = FileFingerprint {
path: format!("static-ar-v2\0{}", fingerprint.path),
..fingerprint.clone()
};
let legacy_v3_key = FileFingerprint {
path: format!("static-ar-v3\0{}", fingerprint.path),
..fingerprint.clone()
};
let legacy_v4_key = FileFingerprint {
path: format!("static-ar-v4\0{}", fingerprint.path),
..fingerprint.clone()
};
let current_key = FileFingerprint {
path: format!("static-ar-v5\0{}", fingerprint.path),
..fingerprint
};
let cache = fh.cache.as_ref().expect("persistent cache opens");
cache
.put(&legacy_key, "legacy-whole-file-sentinel")
.unwrap();
cache.put(&legacy_v2_key, "legacy-member-sentinel").unwrap();
cache
.put(&legacy_v3_key, "legacy-unguarded-object-sentinel")
.unwrap();
cache
.put(&legacy_v4_key, "legacy-unguarded-macho-sentinel")
.unwrap();
let computed = fh.hash_static_lib(&lib).unwrap();
assert!(computed.starts_with("gnu-ar-v2:"));
assert_ne!(computed, "legacy-whole-file-sentinel");
assert_ne!(computed, "legacy-member-sentinel");
assert_ne!(computed, "legacy-unguarded-object-sentinel");
assert_ne!(computed, "legacy-unguarded-macho-sentinel");
assert_eq!(cache.get(¤t_key).unwrap(), Some(computed.clone()));
assert_eq!(fh.hash_static_lib(&lib).unwrap(), computed);
}
#[test]
fn hash_static_lib_v3_memo_cannot_bypass_object_gate() {
let dir = tempfile::tempdir().unwrap();
let fh = FileHasher::persistent(&dir.path().join("index.db"));
let lib = dir.path().join("libbitcode.a");
let mut bitcode = vec![0_u8; 70_000];
bitcode[..4].copy_from_slice(b"BC\xc0\xde");
std::fs::write(&lib, gnu_ar_raw_named_object("bitcode.o", &bitcode)).unwrap();
let fingerprint = FileFingerprint::from_path(&lib).unwrap();
let stale_key = FileFingerprint {
path: format!("static-ar-v3\0{}", fingerprint.path),
..fingerprint
};
let cache = fh.cache.as_ref().expect("persistent cache opens");
cache.put(&stale_key, "gnu-ar-v2:unguarded").unwrap();
let computed = fh.hash_static_lib(&lib).unwrap();
assert!(computed.starts_with("path-ar-v1:"));
assert_ne!(computed, "gnu-ar-v2:unguarded");
}
#[test]
fn static_lib_fallback_binds_lexical_archive_path() {
let dir = tempfile::tempdir().unwrap();
let first_dir = dir.path().join("PerfUtils");
let second_dir = dir.path().join("OtherName");
std::fs::create_dir_all(&first_dir).unwrap();
std::fs::create_dir_all(&second_dir).unwrap();
let first = first_dir.join("libsame.a");
let second = second_dir.join("libsame.a");
std::fs::write(&first, b"unsupported but identical archive bytes").unwrap();
std::fs::write(&second, b"unsupported but identical archive bytes").unwrap();
let fh = FileHasher::new();
let first_hash = fh.hash_static_lib(&first).unwrap();
let second_hash = fh.hash_static_lib(&second).unwrap();
assert!(first_hash.starts_with("path-ar-v1:"));
assert!(second_hash.starts_with("path-ar-v1:"));
assert_ne!(first_hash, second_hash);
}
#[test]
fn thin_static_archive_is_uncacheable() {
let dir = tempfile::tempdir().unwrap();
let archive = dir.path().join("libthin.a");
std::fs::write(&archive, b"!<thin>\n").unwrap();
let error = FileHasher::new().hash_static_lib(&archive).unwrap_err();
assert!(error.to_string().contains("external members"));
}
#[test]
fn native_static_lib_content_change_changes_key() {
let _lock = key_test_lock();
let dir = tempfile::tempdir().unwrap();
let source = dir.path().join("lib.rs");
std::fs::write(&source, b"pub fn hello() {}").unwrap();
let libdir = dir.path().join("out");
std::fs::create_dir_all(&libdir).unwrap();
let lib = libdir.join("libfoo.a");
std::fs::write(&lib, b"v1 archive bytes").unwrap();
let search = format!("native={}", libdir.display());
let flags = ["-L", search.as_str(), "-l", "static=foo"];
let k1 = key_of(&flag_base(&source, &flags));
std::fs::write(&lib, b"v2 archive bytes - DIFFERENT").unwrap();
let k2 = key_of(&flag_base(&source, &flags));
assert_ne!(
k1, k2,
"a native static lib content change must change the key (#421)"
);
std::fs::write(&lib, b"v1 archive bytes").unwrap();
let k3 = key_of(&flag_base(&source, &flags));
assert_eq!(k1, k3, "identical bytes must reproduce the key");
}
#[test]
fn duplicate_native_search_dir_keeps_static_lib_cacheable() {
let _lock = key_test_lock();
let dir = tempfile::tempdir().unwrap();
let source = dir.path().join("lib.rs");
std::fs::write(&source, b"pub fn hello() {}").unwrap();
let libdir = dir.path().join("out");
std::fs::create_dir_all(&libdir).unwrap();
std::fs::write(libdir.join("libfoo.a"), b"archive bytes").unwrap();
let search = format!("native={}", libdir.display());
let flags = [
"-L",
search.as_str(),
"-L",
search.as_str(),
"-l",
"static=foo",
];
assert!(!key_of(&flag_base(&source, &flags)).is_empty());
}
#[cfg(not(windows))]
#[test]
fn native_dylib_content_does_not_change_key() {
let _lock = key_test_lock();
let dir = tempfile::tempdir().unwrap();
let source = dir.path().join("lib.rs");
std::fs::write(&source, b"pub fn hello() {}").unwrap();
let libdir = dir.path().join("out");
std::fs::create_dir_all(&libdir).unwrap();
let lib = libdir.join("libfoo.so");
std::fs::write(&lib, b"so v1").unwrap();
let search = format!("native={}", libdir.display());
let flags = ["-L", search.as_str(), "-l", "dylib=foo"];
let k1 = key_of(&flag_base(&source, &flags));
std::fs::write(&lib, b"so v2 changed").unwrap();
let k2 = key_of(&flag_base(&source, &flags));
assert_eq!(k1, k2, "a dynamic lib's content must not key the consumer");
}
#[test]
fn native_static_lib_in_all_search_dir_is_keyed() {
let _lock = key_test_lock();
let dir = tempfile::tempdir().unwrap();
let source = dir.path().join("lib.rs");
std::fs::write(&source, b"pub fn hello() {}").unwrap();
let libdir = dir.path().join("out");
std::fs::create_dir_all(&libdir).unwrap();
let lib = libdir.join("libfoo.a");
std::fs::write(&lib, b"v1 archive bytes").unwrap();
let search = format!("all={}", libdir.display());
let flags = ["-L", search.as_str(), "-l", "static=foo"];
let k1 = key_of(&flag_base(&source, &flags));
std::fs::write(&lib, b"v2 archive bytes - DIFFERENT").unwrap();
let k2 = key_of(&flag_base(&source, &flags));
assert_ne!(
k1, k2,
"a static lib under `-L all=` must be content-keyed too (#421)"
);
}
#[test]
fn codegen_shorthands_match_explicit_forms() {
let _lock = key_test_lock();
let dir = tempfile::tempdir().unwrap();
let source = dir.path().join("lib.rs");
std::fs::write(&source, b"pub fn hello() {}").unwrap();
let base = key_of_flags(&flag_base(&source, &[]));
let debug = key_of_flags(&flag_base(&source, &["-g"]));
let opt = key_of_flags(&flag_base(&source, &["-O"]));
let explicit_debug = key_of_flags(&flag_base(&source, &["-Cdebuginfo=2"]));
let explicit_opt = key_of_flags(&flag_base(&source, &["-Copt-level=3"]));
let long_debug = key_of_flags(&flag_base(&source, &["--codegen=debuginfo=2"]));
let long_opt = key_of_flags(&flag_base(&source, &["--codegen", "opt-level=3"]));
assert_ne!(base, debug, "`-g` must change the key");
assert_ne!(base, opt, "`-O` must change the key");
assert_ne!(debug, opt, "`-g` and `-O` must produce distinct keys");
assert_eq!(debug, explicit_debug, "`-g` is `-Cdebuginfo=2`");
assert_eq!(opt, explicit_opt, "`-O` is `-Copt-level=3`");
assert_eq!(debug, long_debug, "`--codegen` is the long `-C` alias");
assert_eq!(opt, long_opt, "separated `--codegen` must match `-C`");
}
#[test]
fn codegen_shorthand_override_order_changes_key() {
let _lock = key_test_lock();
let dir = tempfile::tempdir().unwrap();
let source = dir.path().join("lib.rs");
std::fs::write(&source, b"pub fn hello() {}").unwrap();
let shorthand_then_explicit =
key_of_flags(&flag_base(&source, &["-O", "--codegen=opt-level=0"]));
let explicit_then_shorthand =
key_of_flags(&flag_base(&source, &["--codegen", "opt-level=0", "-O"]));
assert_ne!(
shorthand_then_explicit, explicit_then_shorthand,
"rustc applies optimization flags last-wins, so opposite orders must not collide"
);
}
#[test]
fn frontend_jobs_spellings_share_a_key_and_values_remain_ordered() {
let _lock = key_test_lock();
let dir = tempfile::tempdir().unwrap();
let source = dir.path().join("lib.rs");
std::fs::write(&source, b"pub fn hello() {}").unwrap();
let none = key_of_flags(&flag_base(&source, &[]));
let separated = key_of_flags(&flag_base(&source, &["--jobs-frontend", "16"]));
let attached = key_of_flags(&flag_base(&source, &["--jobs-frontend=16"]));
let different = key_of_flags(&flag_base(&source, &["--jobs-frontend=8"]));
let order_4_8 = key_of_flags(&flag_base(
&source,
&["--jobs-frontend=4", "--jobs-frontend=8"],
));
let order_8_4 = key_of_flags(&flag_base(
&source,
&["--jobs-frontend=8", "--jobs-frontend=4"],
));
assert_ne!(none, attached, "frontend jobs must affect the key");
assert_eq!(separated, attached, "both rustc spellings are equivalent");
assert_ne!(attached, different, "worker count must affect the key");
assert_ne!(
order_4_8, order_8_4,
"repeated last-wins values must preserve argv order"
);
}
#[test]
fn response_file_flags_share_inline_key_and_track_contents() {
let _lock = key_test_lock();
let dir = tempfile::tempdir().unwrap();
let source = dir.path().join("lib.rs");
std::fs::write(&source, b"pub fn hello() {}").unwrap();
let response = dir.path().join("rustc.args");
let at_response = format!("@{}", response.display());
std::fs::write(&response, "--cfg\nresponse_v1\n-C\nopt-level=1\n").unwrap();
let inline = key_of_flags(&flag_base(
&source,
&["--cfg", "response_v1", "-C", "opt-level=1"],
));
let response_v1 = key_of_flags(&flag_base(&source, &[&at_response]));
assert_eq!(
response_v1, inline,
"transporting identical flags through @file must not change the key"
);
std::fs::write(&response, "--cfg\nresponse_v2\n-C\nopt-level=2\n").unwrap();
let response_v2 = key_of_flags(&flag_base(&source, &[&at_response]));
assert_ne!(
response_v1, response_v2,
"rewriting the same response-file path must change the effective key"
);
}
#[test]
fn residual_args_are_order_independent() {
let _lock = key_test_lock();
let dir = tempfile::tempdir().unwrap();
let source = dir.path().join("lib.rs");
std::fs::write(&source, b"pub fn hello() {}").unwrap();
let a = key_of_flags(&flag_base(
&source,
&["--unmodeled-a", "alpha", "--unmodeled-b", "beta"],
));
let b = key_of_flags(&flag_base(
&source,
&["--unmodeled-b", "beta", "--unmodeled-a", "alpha"],
));
assert_eq!(a, b, "residual argv order must not change the key");
}
#[test]
fn residual_strips_diagnostic_and_query_flags() {
let _lock = key_test_lock();
let dir = tempfile::tempdir().unwrap();
let source = dir.path().join("lib.rs");
std::fs::write(&source, b"pub fn hello() {}").unwrap();
let base = key_of_flags(&flag_base(&source, &[]));
for extra in [
vec!["--diagnostic-width=80"],
vec!["--json=artifacts"],
vec!["--color", "always"],
vec!["--verbose"],
] {
assert_eq!(
base,
key_of_flags(&flag_base(&source, &extra)),
"diagnostics/query flag {extra:?} must not change the key"
);
}
}
#[test]
fn lexically_resolve_path_collapses_dot_dot() {
let s = std::path::MAIN_SEPARATOR_STR;
let j = |parts: &[&str]| parts.join(s);
assert_eq!(lexically_resolve_path("/a/b/../c"), j(&["", "a", "c"]));
assert_eq!(lexically_resolve_path("/a/./b"), j(&["", "a", "b"]));
assert_eq!(lexically_resolve_path("/../a"), j(&["", "a"]));
assert_eq!(
lexically_resolve_path(r"C:\proj\pkg\..\oot-target\x"),
format!("C:{}", j(&["", "proj", "oot-target", "x"]))
);
assert_eq!(
lexically_resolve_path(r"C:\u\src\../oot-target\rel\deps"),
format!("C:{}", j(&["", "u", "oot-target", "rel", "deps"]))
);
assert_eq!(lexically_resolve_path("../a/b"), j(&["..", "a", "b"]));
assert_eq!(lexically_resolve_path("."), ".");
let resolved = format!("{}home{}u{}oot{}out", s, s, s, s);
assert_eq!(lexically_resolve_path(&resolved), resolved);
}
#[test]
fn lexically_resolve_path_makes_out_of_tree_suffix_converge() {
let cold =
lexically_resolve_path(r"C:\proj\scenario\source\..\oot-target\release\build\x\out");
let reloc = lexically_resolve_path(r"C:\Temp\.tmpAB\..\oot-target\release\build\x\out");
assert!(!cold.contains(".."), "unresolved .. in {cold}");
assert!(!reloc.contains(".."), "unresolved .. in {reloc}");
let from_oot = |p: &str| p[p.find("oot-target").unwrap()..].to_string();
assert_eq!(from_oot(&cold), from_oot(&reloc));
let s = std::path::MAIN_SEPARATOR_STR;
assert_eq!(
from_oot(&cold),
["oot-target", "release", "build", "x", "out"].join(s)
);
}
#[test]
fn out_of_tree_out_dir_env_dep_converges_across_locations() {
let _lock = key_test_lock();
fn normalized(root: &std::path::Path) -> String {
let target = root.join("oot-target");
let out = target
.join("release")
.join("build")
.join("pkg-0000000000000000")
.join("out");
std::fs::create_dir_all(&out).unwrap();
std::fs::create_dir_all(root.join("pkg")).unwrap();
let generated = out.join("generated.rs");
std::fs::write(&generated, b"pub fn marker() -> u8 { 7 }\n").unwrap();
let value = root
.join("pkg")
.join("..")
.join("oot-target")
.join("release")
.join("build")
.join("pkg-0000000000000000")
.join("out")
.to_string_lossy()
.into_owned();
let pn = PathNormalizer::from_env(Some(&target));
normalize_env_dep_value("test_crate", "OUT_DIR", &value, &[generated], &pn).value
}
let cold = tempfile::tempdir().unwrap();
let reloc = tempfile::tempdir().unwrap();
let v_cold = normalized(cold.path());
let v_reloc = normalized(reloc.path());
assert_eq!(
v_cold, v_reloc,
"out-of-tree OUT_DIR must normalize identically across build locations"
);
assert!(
v_cold.contains("<WORKSPACE>"),
"expected the workspace sentinel, got `{v_cold}`"
);
assert!(
!v_cold.contains(".."),
"the `..` must be resolved away, got `{v_cold}`"
);
}
#[cfg(not(windows))]
#[test]
fn link_search_native_keys_but_dependency_does_not() {
let _lock = key_test_lock();
let dir = tempfile::tempdir().unwrap();
let source = dir.path().join("lib.rs");
std::fs::write(&source, b"pub fn hello() {}").unwrap();
let a = key_of(&flag_base(&source, &["-L", "native=/opt/a/lib"]));
let b = key_of(&flag_base(&source, &["-L", "native=/opt/b/lib"]));
assert_ne!(a, b, "a different native -L must change the key");
let dep_x = key_of(&flag_base(&source, &["-L", "dependency=/x/deps"]));
let dep_y = key_of(&flag_base(&source, &["-L", "dependency=/y/deps"]));
assert_eq!(
dep_x, dep_y,
"cargo's -L dependency= must not affect the key"
);
}
#[cfg(unix)]
#[test]
fn bin_output_keys_linker_identity() {
let _lock = key_test_lock();
let dir = tempfile::tempdir().unwrap();
let source = dir.path().join("main.rs");
std::fs::write(&source, b"fn main() {}").unwrap();
let bin = |extra: &[&str]| {
let mut v = vec![
"rustc".to_string(),
"--crate-name".to_string(),
"app".to_string(),
source.to_string_lossy().to_string(),
"--crate-type".to_string(),
"bin".to_string(),
];
v.extend(extra.iter().map(|s| s.to_string()));
v
};
let fh = FileHasher::new();
let pn = PathNormalizer::empty();
let key = |args: Vec<String>| {
let mut parsed = RustcArgs::parse(&args).unwrap();
parsed.source_file = None;
compute_cache_key(&parsed, &fh, &pn)
};
let with_cc = key(bin(&["-Clinker=cc"]));
let with_missing = key(bin(&["-Clinker=/nonexistent/kache-linker-xyz"]));
match (with_cc, with_missing) {
(Ok(cc), Ok(missing)) => {
assert_ne!(cc, missing, "linker choice must affect a bin's cache key");
assert_eq!(cc, key(bin(&["-Clinker=cc"])).unwrap());
}
(Ok(_), Err(_)) => {}
(Err(_), Err(_)) => {}
(Err(err), Ok(_)) => {
panic!("unresolvable linker produced a key while cc failed: {err:#}")
}
}
}
#[test]
fn extern_artifact_content_changes_key() {
let _lock = key_test_lock();
let dir = tempfile::tempdir().unwrap();
let source = dir.path().join("lib.rs");
std::fs::write(&source, b"pub fn hello() {}").unwrap();
let dep = dir.path().join("libdep.rlib");
let extern_arg = format!("foo={}", dep.to_str().unwrap());
std::fs::write(&dep, b"rlib content A").unwrap();
let key_a = key_of_flags(&flag_base(&source, &["--extern", &extern_arg]));
std::fs::write(&dep, b"rlib content B (different)").unwrap();
let key_b = key_of_flags(&flag_base(&source, &["--extern", &extern_arg]));
assert_ne!(
key_a, key_b,
"extern artifact content must change the key (content-hashed)"
);
std::fs::write(&dep, b"rlib content A").unwrap();
let key_a2 = key_of_flags(&flag_base(&source, &["--extern", &extern_arg]));
assert_eq!(key_a, key_a2, "same extern content -> same key");
}
#[test]
fn fold_field_is_unambiguous_across_value_boundaries() {
let h = |parts: &[(&[u8], &[u8])]| {
let mut hasher = blake3::Hasher::new();
for (l, v) in parts {
fold_field(&mut hasher, l, v);
}
hasher.finalize().to_hex().to_string()
};
assert_ne!(
h(&[(b"x:", b"a"), (b"x:", b"bc")]),
h(&[(b"x:", b"ab"), (b"x:", b"c")]),
);
assert_ne!(
h(&[(b"cfg:", b"a\ncfg:b")]),
h(&[(b"cfg:", b"a"), (b"cfg:", b"b")]),
);
}
#[test]
fn unstable_flag_changes_key() {
let _lock = key_test_lock();
let dir = tempfile::tempdir().unwrap();
let source = dir.path().join("lib.rs");
std::fs::write(&source, b"pub fn hello() {}").unwrap();
let none = key_of_flags(&flag_base(&source, &[]));
let san = key_of_flags(&flag_base(&source, &["-Z", "sanitizer=address"]));
assert_ne!(none, san, "a -Z codegen flag must change the key");
assert_eq!(
san,
key_of_flags(&flag_base(&source, &["-Zsanitizer=address"]))
);
}
#[test]
fn custom_target_spec_file_content_changes_key() {
let _lock = key_test_lock();
let dir = tempfile::tempdir().unwrap();
let source = dir.path().join("lib.rs");
std::fs::write(&source, b"pub fn hello() {}").unwrap();
let spec = dir.path().join("my-target.json");
let spec_arg = format!("--target={}", spec.to_str().unwrap());
std::fs::write(&spec, br#"{"llvm-target":"x","data-layout":"e-A"}"#).unwrap();
let key_a = key_of_flags(&flag_base(&source, &[&spec_arg]));
std::fs::write(&spec, br#"{"llvm-target":"x","data-layout":"e-B"}"#).unwrap();
let key_b = key_of_flags(&flag_base(&source, &[&spec_arg]));
assert_ne!(
key_a, key_b,
"custom target spec file content must change the key"
);
std::fs::write(&spec, br#"{"llvm-target":"x","data-layout":"e-A"}"#).unwrap();
let key_a2 = key_of_flags(&flag_base(&source, &[&spec_arg]));
assert_eq!(key_a, key_a2, "same spec content -> same key");
}
#[test]
fn sysroot_changes_key() {
let _lock = key_test_lock();
let dir = tempfile::tempdir().unwrap();
let source = dir.path().join("lib.rs");
std::fs::write(&source, b"pub fn hello() {}").unwrap();
let a = key_of_flags(&flag_base(&source, &["--sysroot", "/opt/std-a"]));
let b = key_of_flags(&flag_base(&source, &["--sysroot", "/opt/std-b"]));
let none = key_of_flags(&flag_base(&source, &[]));
assert_ne!(a, b, "a different --sysroot must change the key");
assert_ne!(none, a, "adding --sysroot must change the key");
assert_eq!(
a,
key_of_flags(&flag_base(&source, &["--sysroot=/opt/std-a"]))
);
}
#[test]
fn target_spec_contents_change_key() {
let _lock = key_test_lock();
let dir = tempfile::tempdir().unwrap();
let source = dir.path().join("lib.rs");
std::fs::write(&source, b"pub fn hello() {}").unwrap();
let spec = dir.path().join("custom.json");
std::fs::write(&spec, br#"{"llvm-target":"x86_64","data-layout":"e-m:e"}"#).unwrap();
let args = flag_base(&source, &["--target", &spec.to_string_lossy()]);
let before = key_of_flags(&args);
std::fs::write(
&spec,
br#"{"llvm-target":"x86_64","data-layout":"DIFFERENT"}"#,
)
.unwrap();
let after = key_of_flags(&args);
assert_ne!(before, after, "editing the target spec must change the key");
}
#[test]
fn test_cache_key_changes_with_source() {
let _lock = key_test_lock();
let dir = tempfile::tempdir().unwrap();
let source = dir.path().join("lib.rs");
std::fs::write(&source, b"pub fn hello() {}").unwrap();
let args_vec: Vec<String> = vec![
"rustc".to_string(),
"--crate-name".to_string(),
"mylib".to_string(),
source.to_string_lossy().to_string(),
"--crate-type".to_string(),
"lib".to_string(),
];
let fh = FileHasher::new();
let pn = PathNormalizer::empty();
let parsed1 = RustcArgs::parse(&args_vec).unwrap();
let key1 = compute_cache_key(&parsed1, &fh, &pn).unwrap();
std::fs::write(&source, b"pub fn hello() { println!(\"hi\"); }").unwrap();
let parsed2 = RustcArgs::parse(&args_vec).unwrap();
let key2 = compute_cache_key(&parsed2, &fh, &pn).unwrap();
assert_ne!(key1, key2);
}
#[test]
fn test_unreadable_dep_produces_stable_key() {
let _lock = key_test_lock();
let dir = tempfile::tempdir().unwrap();
let source = dir.path().join("lib.rs");
std::fs::write(&source, b"pub fn hello() {}").unwrap();
let dep_a =
std::path::PathBuf::from("/home/runner/.rustup/toolchains/stable/lib/libstd.rlib");
let dep_b =
std::path::PathBuf::from("/Users/dev/.rustup/toolchains/stable/lib/libstd.rlib");
let args_vec: Vec<String> = vec![
"rustc".to_string(),
"--crate-name".to_string(),
"mylib".to_string(),
source.to_string_lossy().to_string(),
"--crate-type".to_string(),
"lib".to_string(),
];
let mut parsed_a = RustcArgs::parse(&args_vec).unwrap();
parsed_a.externs.push(crate::args::ExternDep {
name: "std".to_string(),
path: Some(dep_a),
});
let mut parsed_b = RustcArgs::parse(&args_vec).unwrap();
parsed_b.externs.push(crate::args::ExternDep {
name: "std".to_string(),
path: Some(dep_b),
});
let fh = FileHasher::new();
let pn = PathNormalizer::empty();
let key_a = compute_cache_key(&parsed_a, &fh, &pn).unwrap();
let key_b = compute_cache_key(&parsed_b, &fh, &pn).unwrap();
assert_eq!(
key_a, key_b,
"unreadable deps with different paths should produce the same key"
);
}
#[test]
fn path_is_only_used_for_includes_detects_include_pattern() {
let dir = tempfile::tempdir().unwrap();
let out_dir = dir.path().join("build/serde-abc/out");
std::fs::create_dir_all(&out_dir).unwrap();
let included = out_dir.join("private.rs");
std::fs::write(&included, b"// generated").unwrap();
let source_files = vec![std::path::PathBuf::from("/src/lib.rs"), included.clone()];
assert!(
path_is_only_used_for_includes(out_dir.to_str().unwrap(), &source_files),
"OUT_DIR contains an included source file → safe to normalize"
);
}
#[test]
fn path_is_only_used_for_includes_rejects_env_value_pattern() {
let dir = tempfile::tempdir().unwrap();
let out_dir = dir.path().join("build/foo/out");
std::fs::create_dir_all(&out_dir).unwrap();
let source_files = vec![std::path::PathBuf::from("/src/main.rs")];
assert!(
!path_is_only_used_for_includes(out_dir.to_str().unwrap(), &source_files),
"no source under OUT_DIR → unsafe to normalize"
);
}
#[test]
fn path_is_only_used_for_includes_handles_macos_symlink_form() {
if !cfg!(target_os = "macos") {
return;
}
let unique = format!("kache-cache-key-test-{}", std::process::id());
let real_out = std::path::Path::new("/tmp").join(&unique).join("out");
std::fs::create_dir_all(&real_out).unwrap();
let included = real_out.join("private.rs");
std::fs::write(&included, b"// generated").unwrap();
let out_dir_value = format!("/private/tmp/{unique}/out");
let source_files = vec![included];
let result = path_is_only_used_for_includes(&out_dir_value, &source_files);
let _ = std::fs::remove_dir_all(std::path::Path::new("/tmp").join(&unique));
assert!(
result,
"canonical-path comparison must see through the symlink"
);
}
#[test]
fn source_env_dep_use_detector_allows_include_locators() {
let source = r#"
include!(concat!(env!("OUT_DIR"), "/generated.rs"));
include_str!(concat!(env ! ( "OUT_DIR" ), "/template.txt"));
include_bytes!(env!("BLOB_PATH"));
"#;
assert!(!source_has_runtime_env_dep_use(source, "OUT_DIR"));
assert!(!source_has_runtime_env_dep_use(source, "BLOB_PATH"));
}
#[test]
fn source_env_dep_use_detector_rejects_runtime_values() {
let source = r#"
const OUT_DIR: &str = env!("OUT_DIR");
const MAYBE_OUT_DIR: Option<&str> = option_env!("OUT_DIR");
const PATH: &str = concat!(env!("OUT_DIR"), "/data.txt");
"#;
assert!(source_has_runtime_env_dep_use(source, "OUT_DIR"));
}
#[test]
fn source_env_dep_use_detector_rejects_dual_pattern() {
let source = r#"
include!(concat!(env!("OUT_DIR"), "/generated.rs"));
pub const OUT_DIR_AT_COMPILE_TIME: &str = env!("OUT_DIR");
"#;
assert!(source_has_runtime_env_dep_use(source, "OUT_DIR"));
}
#[test]
fn source_env_dep_use_detector_ignores_comments_and_strings() {
let source = r##"
// const X: &str = env!("OUT_DIR");
/* const Y: &str = env!("OUT_DIR"); */
const TEXT: &str = "env!(\"OUT_DIR\")";
const RAW: &str = r#"env!("OUT_DIR")"#;
include!(concat!(env!("OUT_DIR"), "/generated.rs"));
"##;
assert!(!source_has_runtime_env_dep_use(source, "OUT_DIR"));
}
#[test]
fn env_dep_normalization_decision_trace_labels_are_stable() {
for (decision, expected) in [
(EnvDepNormalizationDecision::Unchanged, "unchanged"),
(
EnvDepNormalizationDecision::NormalizedPathOnly,
"normalized path-only",
),
(
EnvDepNormalizationDecision::KeptAbsoluteRuntimePath,
"kept absolute runtime path",
),
(
EnvDepNormalizationDecision::ForcedPathOnly,
"forced path-only (user-asserted)",
),
] {
assert_eq!(decision.as_str(), expected);
}
}
#[test]
fn env_dep_policy_normalizes_out_dir_include_pattern() {
let dir = tempfile::tempdir().unwrap();
let workspace = dir.path().join("workspace");
let src = workspace.join("src");
let out_dir = workspace.join("target/debug/build/pkg/out");
std::fs::create_dir_all(&src).unwrap();
std::fs::create_dir_all(&out_dir).unwrap();
let lib = src.join("lib.rs");
std::fs::write(
&lib,
r#"include!(concat!(env!("OUT_DIR"), "/generated.rs"));"#,
)
.unwrap();
let included = out_dir.join("generated.rs");
std::fs::write(&included, b"pub fn generated() -> u8 { 1 }").unwrap();
let source_files = vec![lib, included];
let path_normalizer = PathNormalizer::from_env(Some(&workspace));
let out_dir_value = out_dir
.canonicalize()
.unwrap()
.to_string_lossy()
.to_string();
let env_dep = normalize_env_dep_value(
"test_crate",
"OUT_DIR",
&out_dir_value,
&source_files,
&path_normalizer,
);
assert_eq!(
env_dep.decision,
EnvDepNormalizationDecision::NormalizedPathOnly
);
assert_ne!(env_dep.value, out_dir_value);
assert!(
env_dep.value.contains("<WORKSPACE>"),
"OUT_DIR include pattern should normalize to the workspace sentinel: {env_dep:?}"
);
}
#[test]
fn env_dep_policy_keeps_out_dir_dual_pattern_absolute() {
let dir = tempfile::tempdir().unwrap();
let workspace = dir.path().join("workspace");
let src = workspace.join("src");
let out_dir = workspace.join("target/debug/build/pkg/out");
std::fs::create_dir_all(&src).unwrap();
std::fs::create_dir_all(&out_dir).unwrap();
let lib = src.join("lib.rs");
std::fs::write(
&lib,
r#"
include!(concat!(env!("OUT_DIR"), "/generated.rs"));
pub const OUT_DIR_AT_COMPILE_TIME: &str = env!("OUT_DIR");
"#,
)
.unwrap();
let included = out_dir.join("generated.rs");
std::fs::write(&included, b"pub fn generated() -> u8 { 1 }").unwrap();
let source_files = vec![lib, included];
let path_normalizer = PathNormalizer::from_env(Some(&workspace));
let out_dir_value = out_dir
.canonicalize()
.unwrap()
.to_string_lossy()
.to_string();
let env_dep = normalize_env_dep_value(
"test_crate",
"OUT_DIR",
&out_dir_value,
&source_files,
&path_normalizer,
);
assert_eq!(
env_dep.decision,
EnvDepNormalizationDecision::KeptAbsoluteRuntimePath
);
assert_eq!(env_dep.value, out_dir_value);
}
#[test]
fn env_dep_policy_normalizes_allowlisted_var_but_not_unlisted() {
let dir = tempfile::tempdir().unwrap();
let workspace = dir.path().join("workspace");
let src = workspace.join("src");
let gen_dir = workspace.join("objdir/build/rust/mozbuild");
std::fs::create_dir_all(&src).unwrap();
std::fs::create_dir_all(&gen_dir).unwrap();
let lib = src.join("lib.rs");
std::fs::write(&lib, r#"include!(env!("BUILDCONFIG_RS"));"#).unwrap();
let included = gen_dir.join("buildconfig.rs");
std::fs::write(&included, b"pub const X: u8 = 1;").unwrap();
let source_files = vec![lib, included.clone()];
let value = included
.canonicalize()
.unwrap()
.to_string_lossy()
.to_string();
let pn_off = PathNormalizer::from_env(Some(&workspace));
let off = normalize_env_dep_value(
"test_crate",
"BUILDCONFIG_RS",
&value,
&source_files,
&pn_off,
);
assert_eq!(
off.decision,
EnvDepNormalizationDecision::KeptAbsoluteRuntimePath
);
assert_eq!(off.value, value);
let pn_on = PathNormalizer::from_env(Some(&workspace))
.with_path_only_env_vars(vec!["BUILDCONFIG_RS".to_string()]);
let on = normalize_env_dep_value(
"test_crate",
"BUILDCONFIG_RS",
&value,
&source_files,
&pn_on,
);
assert_eq!(on.decision, EnvDepNormalizationDecision::NormalizedPathOnly);
assert!(
on.value.contains("<WORKSPACE>"),
"allowlisted include locator should normalize: {on:?}"
);
}
#[test]
fn env_dep_policy_normalizes_rustc_env_var_pointing_under_out_dir() {
let _lock = key_test_lock();
let dir = tempfile::tempdir().unwrap();
let workspace = dir.path().join("workspace");
let out_dir = workspace.join("target/release/build/genlib-abc123/out");
let src = workspace.join("src");
std::fs::create_dir_all(&out_dir).unwrap();
std::fs::create_dir_all(&src).unwrap();
let lib = src.join("lib.rs");
std::fs::write(&lib, r#"include!(env!("GEN_BUILD_CONSTS"));"#).unwrap();
let generated = out_dir.join("consts.rs");
std::fs::write(&generated, b"pub const N: u32 = 42;").unwrap();
let source_files = vec![lib, generated.clone()];
let value = generated
.canonicalize()
.unwrap()
.to_string_lossy()
.to_string();
let path_normalizer = PathNormalizer::from_env(Some(&workspace));
let old_out_dir = std::env::var_os("OUT_DIR");
unsafe { std::env::set_var("OUT_DIR", &out_dir) };
let under = normalize_env_dep_value(
"test_crate",
"GEN_BUILD_CONSTS",
&value,
&source_files,
&path_normalizer,
);
unsafe { std::env::remove_var("OUT_DIR") };
let no_anchor = normalize_env_dep_value(
"test_crate",
"GEN_BUILD_CONSTS",
&value,
&source_files,
&path_normalizer,
);
restore_env_var("OUT_DIR", old_out_dir);
assert_eq!(
under.decision,
EnvDepNormalizationDecision::NormalizedPathOnly,
"a rustc-env var pointing under OUT_DIR, used only as an include locator, \
must normalize: {under:?}"
);
let unit = out_dir
.canonicalize()
.unwrap()
.parent()
.unwrap()
.file_name()
.unwrap()
.to_string_lossy()
.into_owned();
assert_eq!(
under.value,
format!("<OUT_DIR:{unit}>/consts.rs"),
"an OUT_DIR-locator value normalizes relative to OUT_DIR (#330), keeping \
the per-unit component (file!() observability) but not the location: {under:?}"
);
assert_eq!(
no_anchor.decision,
EnvDepNormalizationDecision::KeptAbsoluteRuntimePath,
"without an OUT_DIR anchor the same non-allowlisted var must stay absolute"
);
}
#[test]
fn env_dep_policy_keeps_out_dir_runtime_value_absolute() {
let dir = tempfile::tempdir().unwrap();
let workspace = dir.path().join("workspace");
let out_dir = workspace.join("target/debug/build/pkg/out");
std::fs::create_dir_all(&out_dir).unwrap();
let source_files = vec![workspace.join("src/main.rs")];
let path_normalizer = PathNormalizer::from_env(Some(&workspace));
let out_dir_value = out_dir
.canonicalize()
.unwrap()
.to_string_lossy()
.to_string();
let env_dep = normalize_env_dep_value(
"test_crate",
"OUT_DIR",
&out_dir_value,
&source_files,
&path_normalizer,
);
assert_eq!(
env_dep.decision,
EnvDepNormalizationDecision::KeptAbsoluteRuntimePath
);
assert_eq!(env_dep.value, out_dir_value);
}
#[test]
fn env_dep_policy_force_list_overrides_runtime_value_scan() {
let _lock = key_test_lock();
let dir = tempfile::tempdir().unwrap();
let out_dir = dir.path().join("out");
std::fs::create_dir_all(&out_dir).unwrap();
let src = dir.path().join("lib.rs");
std::fs::write(&src, b"pub fn p() -> &'static str { env!(\"OUT_DIR\") }").unwrap();
let out_dir_value = out_dir.to_string_lossy().to_string();
let source_files = vec![src];
let _out_dir = ScopedEnv::set("OUT_DIR", &out_dir_value);
let pn_plain = PathNormalizer::from_env(Some(dir.path()));
let kept = normalize_env_dep_value(
"cef_dll_sys",
"OUT_DIR",
&out_dir_value,
&source_files,
&pn_plain,
);
let pn_forced = PathNormalizer::from_env(Some(dir.path()))
.with_path_only_env_vars(vec!["cef_dll_sys:OUT_DIR".to_string()]);
let forced = normalize_env_dep_value(
"cef_dll_sys",
"OUT_DIR",
&out_dir_value,
&source_files,
&pn_forced,
);
assert_eq!(
kept.decision,
EnvDepNormalizationDecision::KeptAbsoluteRuntimePath
);
assert_eq!(
forced.decision,
EnvDepNormalizationDecision::ForcedPathOnly,
"a force-listed var must normalize despite the runtime-value scan: {forced:?}"
);
assert!(
forced.value.starts_with("<OUT_DIR:") || forced.value.starts_with("<WORKSPACE>"),
"forced OUT_DIR normalizes to a location-free sentinel form (either the #330 OUT_DIR sentinel or a generic prefix rule): {forced:?}"
);
assert!(
!forced.value.contains(dir.path().to_string_lossy().as_ref()),
"no absolute build location may survive in a forced value: {forced:?}"
);
}
#[test]
fn env_dep_policy_force_list_crate_scope_matches_only_that_crate() {
let _lock = key_test_lock();
let dir = tempfile::tempdir().unwrap();
let out_dir = dir.path().join("out");
std::fs::create_dir_all(&out_dir).unwrap();
let src = dir.path().join("lib.rs");
std::fs::write(&src, b"pub fn p() -> &'static str { env!(\"OUT_DIR\") }").unwrap();
let out_dir_value = out_dir.to_string_lossy().to_string();
let source_files = vec![src];
let _out_dir = ScopedEnv::set("OUT_DIR", &out_dir_value);
let pn = PathNormalizer::from_env(Some(dir.path()))
.with_path_only_env_vars(vec!["cef_dll_sys:OUT_DIR".to_string()]);
let scoped_match =
normalize_env_dep_value("cef_dll_sys", "OUT_DIR", &out_dir_value, &source_files, &pn);
let scoped_other =
normalize_env_dep_value("other_crate", "OUT_DIR", &out_dir_value, &source_files, &pn);
assert_eq!(
scoped_match.decision,
EnvDepNormalizationDecision::ForcedPathOnly
);
assert_eq!(
scoped_other.decision,
EnvDepNormalizationDecision::KeptAbsoluteRuntimePath,
"a crate-scoped force entry must not leak to other crates: {scoped_other:?}"
);
}
#[test]
fn env_dep_policy_refuses_to_force_manifest_dir() {
let dir = tempfile::tempdir().unwrap();
let workspace = dir.path().join("workspace");
let manifest_dir = workspace.join("helper");
let src = manifest_dir.join("src");
std::fs::create_dir_all(&src).unwrap();
let lib = src.join("lib.rs");
std::fs::write(
&lib,
b"pub fn manifest_dir() -> &'static str { env!(\"CARGO_MANIFEST_DIR\") }",
)
.unwrap();
let source_files = vec![lib];
let path_normalizer = PathNormalizer::from_env(Some(&workspace))
.with_path_only_env_vars(vec!["test_crate:CARGO_MANIFEST_DIR".to_string()]);
let manifest_dir_value = manifest_dir
.canonicalize()
.unwrap()
.to_string_lossy()
.to_string();
let env_dep = normalize_env_dep_value(
"test_crate",
"CARGO_MANIFEST_DIR",
&manifest_dir_value,
&source_files,
&path_normalizer,
);
assert_eq!(
env_dep.decision,
EnvDepNormalizationDecision::KeptAbsoluteRuntimePath,
"CARGO_MANIFEST_DIR must stay absolute even when crate-scoped forcing is requested"
);
assert_eq!(env_dep.value, manifest_dir_value);
}
#[test]
fn env_dep_policy_keeps_user_path_env_absolute_when_normalized() {
let dir = tempfile::tempdir().unwrap();
let workspace = dir.path().join("workspace");
let config_dir = workspace.join("config");
std::fs::create_dir_all(&config_dir).unwrap();
let source_files = vec![workspace.join("src/lib.rs")];
let path_normalizer = PathNormalizer::from_env(Some(&workspace));
let config_dir_value = config_dir
.canonicalize()
.unwrap()
.to_string_lossy()
.to_string();
let env_dep = normalize_env_dep_value(
"test_crate",
"CUSTOM_CONFIG_DIR",
&config_dir_value,
&source_files,
&path_normalizer,
);
assert_eq!(
env_dep.decision,
EnvDepNormalizationDecision::KeptAbsoluteRuntimePath
);
assert_eq!(env_dep.value, config_dir_value);
}
#[test]
fn test_cache_key_changes_with_features() {
let _lock = key_test_lock();
let dir = tempfile::tempdir().unwrap();
let source = dir.path().join("lib.rs");
std::fs::write(&source, b"pub fn hello() {}").unwrap();
let args1: Vec<String> = vec![
"rustc".to_string(),
"--crate-name".to_string(),
"mylib".to_string(),
source.to_string_lossy().to_string(),
"--cfg".to_string(),
"feature=\"std\"".to_string(),
];
let mut args2 = args1.clone();
args2.push("--cfg".to_string());
args2.push("feature=\"derive\"".to_string());
let parsed1 = RustcArgs::parse(&args1).unwrap();
let parsed2 = RustcArgs::parse(&args2).unwrap();
let fh = FileHasher::new();
let pn = PathNormalizer::empty();
let key1 = compute_cache_key(&parsed1, &fh, &pn).unwrap();
let key2 = compute_cache_key(&parsed2, &fh, &pn).unwrap();
assert_ne!(key1, key2);
}
#[test]
fn test_cache_key_changes_with_instrument_coverage() {
let _lock = key_test_lock();
let dir = tempfile::tempdir().unwrap();
let source = dir.path().join("lib.rs");
std::fs::write(&source, b"pub fn hello() {}").unwrap();
let args_normal: Vec<String> = vec![
"rustc".to_string(),
"--crate-name".to_string(),
"mylib".to_string(),
source.to_string_lossy().to_string(),
"--crate-type".to_string(),
"lib".to_string(),
];
let mut args_coverage = args_normal.clone();
args_coverage.push("-Cinstrument-coverage".to_string());
let parsed_normal = RustcArgs::parse(&args_normal).unwrap();
let parsed_coverage = RustcArgs::parse(&args_coverage).unwrap();
assert!(!parsed_normal.has_coverage_instrumentation());
assert!(parsed_coverage.has_coverage_instrumentation());
let fh = FileHasher::new();
let pn = PathNormalizer::empty();
let key_normal = compute_cache_key(&parsed_normal, &fh, &pn).unwrap();
let key_coverage = compute_cache_key(&parsed_coverage, &fh, &pn).unwrap();
assert_ne!(
key_normal, key_coverage,
"coverage-instrumented builds must have different cache keys"
);
}
#[test]
fn test_cache_key_changes_with_instrument_coverage_two_arg() {
let _lock = key_test_lock();
let dir = tempfile::tempdir().unwrap();
let source = dir.path().join("lib.rs");
std::fs::write(&source, b"pub fn hello() {}").unwrap();
let args_normal: Vec<String> = vec![
"rustc".to_string(),
"--crate-name".to_string(),
"mylib".to_string(),
source.to_string_lossy().to_string(),
"--crate-type".to_string(),
"lib".to_string(),
];
let mut args_coverage = args_normal.clone();
args_coverage.extend(["-C".to_string(), "instrument-coverage".to_string()]);
let parsed_normal = RustcArgs::parse(&args_normal).unwrap();
let parsed_coverage = RustcArgs::parse(&args_coverage).unwrap();
assert!(parsed_coverage.has_coverage_instrumentation());
let fh = FileHasher::new();
let pn = PathNormalizer::empty();
let key_normal = compute_cache_key(&parsed_normal, &fh, &pn).unwrap();
let key_coverage = compute_cache_key(&parsed_coverage, &fh, &pn).unwrap();
assert_ne!(
key_normal, key_coverage,
"two-arg form -C instrument-coverage must also produce different cache keys"
);
}
#[test]
fn test_cache_key_changes_with_tarpaulin_cfg() {
let _lock = key_test_lock();
let dir = tempfile::tempdir().unwrap();
let source = dir.path().join("lib.rs");
std::fs::write(&source, b"pub fn hello() {}").unwrap();
let args_normal: Vec<String> = vec![
"rustc".to_string(),
"--crate-name".to_string(),
"mylib".to_string(),
source.to_string_lossy().to_string(),
"--crate-type".to_string(),
"lib".to_string(),
];
let mut args_tarpaulin = args_normal.clone();
args_tarpaulin.extend(["--cfg".to_string(), "tarpaulin".to_string()]);
let parsed_normal = RustcArgs::parse(&args_normal).unwrap();
let parsed_tarpaulin = RustcArgs::parse(&args_tarpaulin).unwrap();
let fh = FileHasher::new();
let pn = PathNormalizer::empty();
let key_normal = compute_cache_key(&parsed_normal, &fh, &pn).unwrap();
let key_tarpaulin = compute_cache_key(&parsed_tarpaulin, &fh, &pn).unwrap();
assert_ne!(
key_normal, key_tarpaulin,
"--cfg=tarpaulin must produce a different cache key"
);
}
#[test]
fn test_coverage_keys_consistent_across_remap_forms() {
let _lock = key_test_lock();
let dir = tempfile::tempdir().unwrap();
let source = dir.path().join("lib.rs");
std::fs::write(&source, b"pub fn hello() {}").unwrap();
let args_joined: Vec<String> = vec![
"rustc".to_string(),
"--crate-name".to_string(),
"mylib".to_string(),
source.to_string_lossy().to_string(),
"--crate-type".to_string(),
"lib".to_string(),
"-Cinstrument-coverage".to_string(),
];
let mut args_two = args_joined[..6].to_vec();
args_two.extend(["-C".to_string(), "instrument-coverage".to_string()]);
let parsed_joined = RustcArgs::parse(&args_joined).unwrap();
let parsed_two = RustcArgs::parse(&args_two).unwrap();
let fh = FileHasher::new();
let pn = PathNormalizer::empty();
let key_joined = compute_cache_key(&parsed_joined, &fh, &pn).unwrap();
let key_two = compute_cache_key(&parsed_two, &fh, &pn).unwrap();
assert_eq!(
key_joined, key_two,
"joined and two-arg forms of instrument-coverage should produce identical keys"
);
}
#[test]
fn test_cache_key_version_affects_key() {
let _lock = key_test_lock();
let dir = tempfile::tempdir().unwrap();
let source = dir.path().join("lib.rs");
std::fs::write(&source, b"pub fn hello() {}").unwrap();
let args_vec: Vec<String> = vec![
"rustc".to_string(),
"--crate-name".to_string(),
"mylib".to_string(),
source.to_string_lossy().to_string(),
"--crate-type".to_string(),
"lib".to_string(),
];
let parsed1 = RustcArgs::parse(&args_vec).unwrap();
let parsed2 = RustcArgs::parse(&args_vec).unwrap();
let fh = FileHasher::new();
let pn = PathNormalizer::empty();
let key1 = compute_cache_key(&parsed1, &fh, &pn).unwrap();
let key2 = compute_cache_key(&parsed2, &fh, &pn).unwrap();
assert_eq!(
key1, key2,
"key must be deterministic with version baked in"
);
let payload = b"rustc_version:1.80.0\n";
for (v_a, v_b) in [(1u32, 2u32), (0, 1), (1, 100)] {
let hash = |version: u32| {
let mut h = blake3::Hasher::new();
h.update(b"key_version:");
h.update(version.to_string().as_bytes());
h.update(b"\n");
h.update(payload);
h.finalize().to_hex().to_string()
};
assert_ne!(
hash(v_a),
hash(v_b),
"version {} vs {} must produce different hashes",
v_a,
v_b
);
}
}
#[test]
fn test_parse_dep_info_basic() {
let input = "target.d: src/lib.rs src/server.rs src/utils.rs\n";
let files = parse_dep_info(input);
assert_eq!(files.len(), 3);
assert_eq!(files[0], std::path::PathBuf::from("src/lib.rs"));
assert_eq!(files[1], std::path::PathBuf::from("src/server.rs"));
assert_eq!(files[2], std::path::PathBuf::from("src/utils.rs"));
}
#[test]
fn test_parse_dep_info_escaped_spaces() {
let input = "target.d: src/my\\ file.rs src/lib.rs\n";
let files = parse_dep_info(input);
assert_eq!(files.len(), 2);
assert!(
files
.iter()
.any(|p| p == &std::path::PathBuf::from("src/my file.rs"))
);
assert!(
files
.iter()
.any(|p| p == &std::path::PathBuf::from("src/lib.rs"))
);
}
#[test]
fn test_parse_dep_info_empty() {
assert!(parse_dep_info("").is_empty());
assert!(parse_dep_info("target.d:").is_empty());
assert!(parse_dep_info("no colon here").is_empty());
}
#[test]
fn test_parse_dep_info_single_file() {
let input = "deps.d: src/main.rs\n";
let files = parse_dep_info(input);
assert_eq!(files.len(), 1);
assert_eq!(files[0], std::path::PathBuf::from("src/main.rs"));
}
#[test]
fn test_parse_dep_info_absolute_paths() {
let input = "deps.d: /home/user/project/src/lib.rs /home/user/project/src/mod.rs\n";
let files = parse_dep_info(input);
assert_eq!(files.len(), 2);
assert_eq!(
files[0],
std::path::PathBuf::from("/home/user/project/src/lib.rs")
);
assert_eq!(
files[1],
std::path::PathBuf::from("/home/user/project/src/mod.rs")
);
}
#[test]
fn test_parse_env_deps_basic() {
let input =
"deps.d: src/lib.rs\n# env-dep:CARGO_PKG_VERSION=1.0.0\n# env-dep:OUT_DIR=/tmp/out\n";
let env_deps = parse_env_dep_info(input);
assert_eq!(env_deps.len(), 2);
assert!(
env_deps
.iter()
.any(|(k, v)| k == "CARGO_PKG_VERSION" && v == "1.0.0")
);
assert!(env_deps.iter().any(|(k, _)| k == "OUT_DIR"));
}
#[test]
fn test_parse_env_deps_returns_raw_values() {
let input = "deps.d: src/lib.rs\n# env-dep:OUT_DIR=/some/abs/path/target/debug/build/foo\n";
let env_deps = parse_env_dep_info(input);
assert_eq!(env_deps.len(), 1);
assert_eq!(env_deps[0].0, "OUT_DIR");
assert_eq!(env_deps[0].1, "/some/abs/path/target/debug/build/foo");
}
#[test]
fn test_parse_env_deps_empty() {
let input = "deps.d: src/lib.rs\n";
let env_deps = parse_env_dep_info(input);
assert!(env_deps.is_empty());
}
#[test]
fn test_parse_env_deps_no_value() {
let input = "deps.d: src/lib.rs\n# env-dep:UNSET_VAR\n";
let env_deps = parse_env_dep_info(input);
assert_eq!(env_deps.len(), 1);
assert_eq!(env_deps[0].0, "UNSET_VAR");
}
#[test]
fn test_file_hasher_deterministic() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("test.rs");
std::fs::write(&file, b"fn main() {}").unwrap();
let hasher = FileHasher::new();
let hash1 = hasher.hash(&file).unwrap();
let hash2 = hasher.hash(&file).unwrap();
assert_eq!(hash1, hash2, "FileHasher must be deterministic");
}
#[test]
fn runtime_env_use_memo_reuses_positive_and_negative_scans() {
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join("idx.sqlite");
let source = dir.path().join("lib.rs");
std::fs::write(
&source,
br#"pub const OUT: &str = env!("OUT_DIR"); pub const N: usize = 1;"#,
)
.unwrap();
let hasher = FileHasher::persistent(&db);
let content_hash = hasher.hash(&source).unwrap();
assert!(hasher.runtime_env_use(&source, "OUT_DIR").unwrap());
assert!(!hasher.runtime_env_use(&source, "OTHER_DIR").unwrap());
drop(hasher);
std::fs::remove_file(&source).unwrap();
let fresh = FileHasher::persistent(&db);
assert!(
fresh
.runtime_env_use_for_hash(&source, "OUT_DIR", &content_hash)
.unwrap()
);
assert!(
!fresh
.runtime_env_use_for_hash(&source, "OTHER_DIR", &content_hash)
.unwrap()
);
}
#[test]
fn runtime_env_use_scan_rejects_content_changed_after_hashing() {
let dir = tempfile::tempdir().unwrap();
let source = dir.path().join("lib.rs");
std::fs::write(&source, br#"include!(concat!(env!("OUT_DIR"), "/x.rs"));"#).unwrap();
let hasher = FileHasher::new();
hasher.hash(&source).unwrap();
std::fs::write(&source, br#"pub const OUT: &str = env!("OUT_DIR");"#).unwrap();
let error = hasher.runtime_env_use(&source, "OUT_DIR").unwrap_err();
assert!(
error
.to_string()
.contains("changed between content hashing"),
"unexpected error: {error:#}"
);
}
#[test]
fn cc_preprocess_memo_support_requires_persistent_cache() {
assert!(!FileHasher::new().supports_cc_preprocess_memo());
let dir = tempfile::tempdir().unwrap();
let persistent = FileHasher::persistent(&dir.path().join("idx.sqlite"));
assert!(persistent.supports_cc_preprocess_memo());
}
#[test]
fn cc_preprocess_memo_requires_every_input_fingerprint_to_match() {
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join("idx.sqlite");
let source = dir.path().join("source.c");
let header = dir.path().join("header.h");
std::fs::write(&source, "#include \"header.h\"\n").unwrap();
std::fs::write(&header, "#define VALUE 1\n").unwrap();
let hasher = FileHasher::persistent(&db);
let inputs = hasher
.cc_preprocess_fingerprints(
&[
(source.to_string_lossy().into_owned(), source.clone()),
(header.to_string_lossy().into_owned(), header.clone()),
],
&no_mapping,
)
.unwrap();
let pp_hash = "a".repeat(64);
hasher.cc_preprocess_memo_record_if_unchanged("memo-key", &pp_hash, &inputs, &no_mapping);
assert_eq!(
hasher
.cc_preprocess_memo_lookup("memo-key", no_remap, &no_mapping)
.map(|(hash, _)| hash)
.as_deref(),
Some(pp_hash.as_str())
);
let inputs_json = serde_json::to_string(&inputs).unwrap();
let cache = hasher.cache.as_ref().unwrap();
cache
.put_cc_preprocess_memo("short-hash", "a", &inputs_json)
.unwrap();
cache
.put_cc_preprocess_memo("non-hex-hash", &"z".repeat(64), &inputs_json)
.unwrap();
assert_eq!(
hasher.cc_preprocess_memo_lookup("short-hash", no_remap, &no_mapping),
None
);
assert_eq!(
hasher.cc_preprocess_memo_lookup("non-hex-hash", no_remap, &no_mapping),
None
);
let mut fresh_hasher = FileHasher::persistent(&db);
fresh_hasher.arm_too_new_guard(i64::MAX, 0);
assert_eq!(
fresh_hasher
.cc_preprocess_memo_lookup("memo-key", no_remap, &no_mapping)
.map(|(hash, _)| hash)
.as_deref(),
Some(pp_hash.as_str()),
"unchanged bytes must hit however recently they were written"
);
fresh_hasher.cc_preprocess_memo_record_if_unchanged(
"too-new",
&pp_hash,
&inputs,
&no_mapping,
);
assert!(
fresh_hasher
.cache
.as_ref()
.unwrap()
.get_cc_preprocess_memo("too-new")
.unwrap()
.is_some(),
"a fresh checkout must still be able to publish a memo"
);
std::fs::write(&header, "#define VALUE 12345\n").unwrap();
assert_eq!(
hasher.cc_preprocess_memo_lookup("memo-key", no_remap, &no_mapping),
None,
"a changed transitive header must force preprocessing"
);
hasher.cc_preprocess_memo_record_if_unchanged("changed", &pp_hash, &inputs, &no_mapping);
assert!(
hasher
.cache
.as_ref()
.unwrap()
.get_cc_preprocess_memo("changed")
.unwrap()
.is_none(),
"changed inputs must not publish a memo"
);
}
fn no_mapping(path: &Path) -> Option<String> {
hash_file(path).ok()
}
fn no_remap(name: &str) -> Vec<PathBuf> {
vec![PathBuf::from(name)]
}
#[test]
fn cc_preprocess_memo_compares_contents_as_the_expansion_sees_them() {
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join("idx.sqlite");
let a = dir.path().join("a");
let b = dir.path().join("b");
for tree in [&a, &b] {
std::fs::create_dir_all(tree).unwrap();
}
std::fs::write(a.join("gen.h"), format!("#define P \"{}\"\n", a.display())).unwrap();
std::fs::write(b.join("gen.h"), format!("#define P \"{}\"\n", b.display())).unwrap();
let map_under = |root: &std::path::Path| {
let root = root.to_string_lossy().into_owned();
move |path: &Path| -> Option<String> {
let text = std::fs::read_to_string(path).ok()?;
Some(
blake3::hash(text.replace(&root, "<root>").as_bytes())
.to_hex()
.to_string(),
)
}
};
let hasher = FileHasher::persistent(&db);
let inputs = hasher
.cc_preprocess_fingerprints(
&[("<root>/gen.h".to_string(), a.join("gen.h"))],
&map_under(&a),
)
.unwrap();
assert_ne!(
inputs[0].content, inputs[0].mapped,
"raw and mapped hashes differ for a file that names its own path"
);
let pp_hash = "d".repeat(64);
hasher.cc_preprocess_memo_record_if_unchanged(
"memo-key",
&pp_hash,
&inputs,
&map_under(&a),
);
let resolve_in_b = |name: &str| vec![b.join(name.trim_start_matches("<root>/"))];
assert_eq!(
FileHasher::persistent(&db)
.cc_preprocess_memo_lookup("memo-key", resolve_in_b, &map_under(&b))
.map(|(hash, _)| hash)
.as_deref(),
Some(pp_hash.as_str()),
"the same header under another root must reuse the expansion"
);
std::fs::write(
b.join("gen.h"),
format!("#define P \"{}\"\n#define EXTRA 1\n", b.display()),
)
.unwrap();
assert_eq!(
FileHasher::persistent(&db).cc_preprocess_memo_lookup(
"memo-key",
resolve_in_b,
&map_under(&b)
),
None,
"a header that gained a definition must force a fresh preprocess"
);
}
#[test]
fn cc_preprocess_memo_ignores_the_recording_tree_when_reading_another() {
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join("idx.sqlite");
let original = dir.path().join("a");
let relocated = dir.path().join("b");
for tree in [&original, &relocated] {
std::fs::create_dir_all(tree).unwrap();
}
let source_of = |tree: &std::path::Path| tree.join("source.c");
std::fs::write(source_of(&original), "int v(void) { return 1; }\n").unwrap();
let hasher = FileHasher::persistent(&db);
let inputs = hasher
.cc_preprocess_fingerprints(
&[("<root>/source.c".to_string(), source_of(&original))],
&no_mapping,
)
.unwrap();
let pp_hash = "c".repeat(64);
hasher.cc_preprocess_memo_record_if_unchanged("memo-key", &pp_hash, &inputs, &no_mapping);
std::fs::write(source_of(&relocated), "int v(void) { return 999; }\n").unwrap();
let resolve_in_relocated =
|name: &str| vec![relocated.join(name.trim_start_matches("<root>/"))];
assert_eq!(
FileHasher::persistent(&db).cc_preprocess_memo_lookup(
"memo-key",
resolve_in_relocated,
&no_mapping
),
None,
"the edited copy must miss even though the original is unchanged"
);
std::fs::write(source_of(&relocated), "int v(void) { return 1; }\n").unwrap();
assert_eq!(
FileHasher::persistent(&db)
.cc_preprocess_memo_lookup("memo-key", resolve_in_relocated, &no_mapping)
.map(|(hash, _)| hash)
.as_deref(),
Some(pp_hash.as_str()),
"an identical copy in another tree must still reuse the expansion"
);
}
#[test]
fn cc_preprocess_memo_survives_new_metadata_for_unchanged_bytes() {
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join("idx.sqlite");
let source = dir.path().join("source.c");
let header = dir.path().join("header.h");
let source_bytes = "#include \"header.h\"\nint v(void) { return VALUE; }\n";
let header_bytes = "#define VALUE 1\n";
std::fs::write(&source, source_bytes).unwrap();
std::fs::write(&header, header_bytes).unwrap();
let hasher = FileHasher::persistent(&db);
let inputs = hasher
.cc_preprocess_fingerprints(
&[
(source.to_string_lossy().into_owned(), source.clone()),
(header.to_string_lossy().into_owned(), header.clone()),
],
&no_mapping,
)
.unwrap();
assert!(
inputs.iter().all(|input| input.content.len() == 64),
"every recorded input carries a content hash"
);
let pp_hash = "b".repeat(64);
hasher.cc_preprocess_memo_record_if_unchanged("memo-key", &pp_hash, &inputs, &no_mapping);
for (path, bytes) in [(&source, source_bytes), (&header, header_bytes)] {
std::fs::remove_file(path).unwrap();
std::fs::write(path, bytes).unwrap();
}
let rewritten = FileFingerprint::from_path(&header).unwrap();
assert_ne!(
rewritten, inputs[0].fingerprint,
"the rewrite must actually change the metadata this test is about"
);
let reader = FileHasher::persistent(&db);
assert_eq!(
reader
.cc_preprocess_memo_lookup("memo-key", no_remap, &no_mapping)
.map(|(hash, _)| hash)
.as_deref(),
Some(pp_hash.as_str()),
"identical bytes at new metadata must reuse the expansion"
);
std::fs::write(&header, "#define VALUE 2\n").unwrap();
assert_eq!(
FileHasher::persistent(&db).cc_preprocess_memo_lookup(
"memo-key",
no_remap,
&no_mapping
),
None,
"changed bytes must force a fresh preprocess"
);
}
#[test]
fn too_new_guard_flags_inputs_modified_after_build_start() {
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join("idx.sqlite");
let file = dir.path().join("input.rs");
std::fs::write(&file, b"pub fn x() {}").unwrap();
let now_ns = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos() as i64;
let off = FileHasher::persistent(&db);
off.hash(&file).unwrap();
assert!(!off.too_new(), "guard is off by default");
let mut before = FileHasher::persistent(&db);
before.arm_too_new_guard(now_ns + 60_000_000_000, 0);
before.hash(&file).unwrap();
assert!(
!before.too_new(),
"a file modified before the build started is not too-new"
);
let mut after = FileHasher::persistent(&db);
after.arm_too_new_guard(now_ns - 60_000_000_000, 0);
after.hash(&file).unwrap();
assert!(
after.too_new(),
"a file modified after the build started must be flagged too-new"
);
let mut cacheless = FileHasher::new();
cacheless.arm_too_new_guard(now_ns - 60_000_000_000, 0);
cacheless.hash(&file).unwrap();
assert!(
cacheless.too_new(),
"a cacheless hasher must enforce the same too-new guard"
);
}
#[test]
fn guarded_inputs_record_only_while_armed() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("input.rs");
std::fs::write(&file, b"pub fn x() {}").unwrap();
let disarmed = FileHasher::new();
disarmed.hash(&file).unwrap();
assert!(
disarmed.take_guarded_inputs().is_empty(),
"a disarmed hasher records nothing to verify"
);
let mut armed = FileHasher::new();
armed.arm_too_new_guard(1, 0);
armed.hash(&file).unwrap();
armed.hash(&file).unwrap();
assert_eq!(
armed.take_guarded_inputs().len(),
2,
"every hash while armed is recorded for post-compile verification"
);
assert!(
armed.take_guarded_inputs().is_empty(),
"taking the snapshot drains it"
);
}
#[test]
fn guarded_inputs_empty_set_never_excuses() {
assert!(
!FileHasher::guarded_inputs_unchanged_since_hash(&[]),
"a vacuous check must not waive a tripped guard"
);
}
#[test]
fn guarded_inputs_reject_changed_or_missing_files() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("input.rs");
std::fs::write(&file, b"pub fn x() {}").unwrap();
let recorded = FileFingerprint::from_path(&file).unwrap();
std::fs::write(&file, b"pub fn x() { 1 }").unwrap();
assert!(
!FileHasher::guarded_inputs_unchanged_since_hash(std::slice::from_ref(&recorded)),
"rewritten bytes must fail verification even when the wall clock cannot tell"
);
std::fs::remove_file(&file).unwrap();
assert!(
!FileHasher::guarded_inputs_unchanged_since_hash(std::slice::from_ref(&recorded)),
"a file that vanished mid-build must fail verification"
);
}
#[test]
fn guarded_inputs_reject_weak_identity() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("input.rs");
std::fs::write(&file, b"pub fn x() {}").unwrap();
let mut recorded = FileFingerprint::from_path(&file).unwrap();
recorded.inode = 0;
assert!(
!FileHasher::guarded_inputs_unchanged_since_hash(std::slice::from_ref(&recorded)),
"without an inode a replace-by-rename is invisible, so verification must fail closed"
);
}
#[cfg(unix)]
#[test]
fn guarded_inputs_verify_despite_future_mtimes() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("input.rs");
std::fs::write(&file, b"pub fn x() {}").unwrap();
filetime::set_file_mtime(&file, filetime::FileTime::from_unix_time(2_000_000_000, 0))
.unwrap();
let now_ns = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos() as i64;
let mut hasher = FileHasher::new();
hasher.arm_too_new_guard(now_ns, 0);
hasher.hash(&file).unwrap();
assert!(
hasher.too_new(),
"a future mtime must still trip the wall-clock guard"
);
assert!(
FileHasher::guarded_inputs_unchanged_since_hash(&hasher.take_guarded_inputs()),
"untouched bytes verify despite the skewed clock"
);
}
#[test]
fn test_file_hasher_persistent_cache_invalidates_on_metadata_change() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("index.db");
let file = dir.path().join("large.rlib");
std::fs::write(&file, vec![1u8; 70 * 1024]).unwrap();
let hasher = FileHasher::persistent(&db_path);
let first = hasher.hash(&file).unwrap();
let first_stats = hasher.stats();
assert_eq!(first_stats.cache_hits, 0);
assert_eq!(first_stats.cache_misses, 1);
assert!(first_stats.bytes_hashed > 0);
let second_hasher = FileHasher::persistent(&db_path);
let second = second_hasher.hash(&file).unwrap();
let second_stats = second_hasher.stats();
assert_eq!(first, second);
assert_eq!(second_stats.cache_hits, 1);
assert_eq!(second_stats.cache_misses, 0);
std::fs::write(&file, vec![2u8; 70 * 1024]).unwrap();
let changed = FileHasher::persistent(&db_path).hash(&file).unwrap();
assert_ne!(first, changed);
}
#[test]
fn test_file_hasher_persistent_cache_skips_small_files() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("index.db");
let file = dir.path().join("small.rs");
std::fs::write(&file, b"fn main() {}").unwrap();
let hasher = FileHasher::persistent(&db_path);
let first = hasher.hash(&file).unwrap();
let second = hasher.hash(&file).unwrap();
let stats = hasher.stats();
assert_eq!(first, second);
assert_eq!(stats.cache_hits, 0);
assert_eq!(stats.cache_misses, 2);
}
#[test]
fn test_dep_info_finds_modules() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("src");
std::fs::create_dir_all(&src).unwrap();
std::fs::write(src.join("lib.rs"), b"mod server;\npub fn hello() {}").unwrap();
std::fs::write(src.join("server.rs"), b"pub fn serve() {}").unwrap();
let rustc = std::path::PathBuf::from("rustc");
let source = src.join("lib.rs");
let args = vec![
"--crate-name".to_string(),
"testcrate".to_string(),
"--crate-type".to_string(),
"lib".to_string(),
"--edition".to_string(),
"2021".to_string(),
];
let runs_before = crate::opcounts::dep_info_runs();
let ms_before = crate::opcounts::dep_info_ms();
let dep_info = run_dep_info_pass(&rustc, None, &source, &args, false).unwrap();
assert!(crate::opcounts::dep_info_runs() > runs_before);
assert!(
crate::opcounts::dep_info_ms() > ms_before,
"a rustc spawn takes more than a millisecond"
);
assert!(
dep_info.source_files.len() >= 2,
"expected at least 2 files, got {:?}",
dep_info.source_files
);
assert!(dep_info.source_files.iter().any(|p| p.ends_with("lib.rs")));
assert!(
dep_info
.source_files
.iter()
.any(|p| p.ends_with("server.rs"))
);
}
#[test]
fn run_dep_info_pass_errors_on_compile_failure() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("src");
std::fs::create_dir_all(&src).unwrap();
std::fs::write(src.join("lib.rs"), b"fn broken( { this is not valid rust").unwrap();
let rustc = std::path::PathBuf::from("rustc");
let source = src.join("lib.rs");
let args = vec![
"--crate-name".to_string(),
"testcrate".to_string(),
"--crate-type".to_string(),
"lib".to_string(),
"--edition".to_string(),
"2021".to_string(),
];
let runs_before = crate::opcounts::dep_info_runs();
let Err(err) = run_dep_info_pass(&rustc, None, &source, &args, false) else {
panic!("expected Err on a failing dep-info pass (source has a syntax error)");
};
assert!(
crate::opcounts::dep_info_runs() > runs_before,
"a failed pre-pass still spawned rustc and must be counted"
);
let rendered = format!("{err:#}");
assert!(
rendered.contains("dep-info pre-pass failed (exit"),
"the cause must name the failing exit status: {rendered}"
);
assert!(
rendered.contains("error"),
"the cause must carry rustc's own first stderr line: {rendered}"
);
}
#[test]
fn dep_info_pass_args_drops_output_naming_flags() {
let args: Vec<String> = [
"--crate-name",
"mylib",
"--edition=2021",
"mylib/src/lib.rs",
"--error-format=json",
"--crate-type",
"lib",
"--emit=dep-info,metadata,link",
"-C",
"metadata=1b2c9f1c31209a4a",
"-C",
"extra-filename=-02749d16b52ff8b3",
"--out-dir",
"/w/target/debug/deps",
"-C",
"incremental=/w/target/debug/incremental",
"-L",
"dependency=/w/target/debug/deps",
]
.iter()
.map(|arg| (*arg).to_string())
.collect();
let dep_args = dep_info_pass_args(
Path::new("mylib/src/lib.rs"),
&args,
Path::new("/tmp/kache-depinfo/deps.d"),
);
assert_eq!(
dep_args.first().map(String::as_str),
Some("mylib/src/lib.rs"),
"the source file leads the argv exactly once: {dep_args:?}"
);
assert_eq!(
dep_args.iter().filter(|a| a.contains("lib.rs")).count(),
1,
"cargo's own positional source must not be re-added: {dep_args:?}"
);
assert!(
!dep_args.iter().any(|a| a.contains("extra-filename")),
"-C extra-filename names outputs the pre-pass discards, and rustc \
warns about it as soon as -o is present: {dep_args:?}"
);
assert!(
!dep_args.iter().any(|a| a.contains("incremental")),
"incremental flags go through the canonical filter: {dep_args:?}"
);
assert!(
!dep_args.iter().any(|a| a.starts_with("--emit=")),
"cargo's --emit is superseded by the pre-pass's own: {dep_args:?}"
);
assert!(
!dep_args.iter().any(|a| a == "--out-dir"),
"--out-dir is the other flag rustc reports as ignored due to -o: {dep_args:?}"
);
assert!(
!dep_args.iter().any(|a| a.starts_with("/w/target")),
"--out-dir's value must go with it: {dep_args:?}"
);
for kept in [
"--crate-name",
"mylib",
"--edition=2021",
"--error-format=json",
"--crate-type",
"lib",
"-C",
"metadata=1b2c9f1c31209a4a",
"-L",
"dependency=/w/target/debug/deps",
] {
assert!(
dep_args.iter().any(|a| a == kept),
"{kept} shapes the input set and must survive: {dep_args:?}"
);
}
assert_eq!(
dep_args.iter().filter(|a| a.as_str() == "-C").count(),
1,
"only extra-filename's own -C is dropped, not every -C: {dep_args:?}"
);
let tail = &dep_args[dep_args.len() - 4..];
assert_eq!(
tail,
[
"--emit",
"dep-info",
"-o",
"/tmp/kache-depinfo/deps.d".to_string().as_str()
]
.map(String::from),
"the pre-pass appends exactly one output configuration"
);
}
#[test]
fn dep_info_pass_args_drops_every_extra_filename_spelling() {
for spelling in [
vec!["-C", "extra-filename=-abc123"],
vec!["-Cextra-filename=-abc123"],
vec!["--codegen", "extra-filename=-abc123"],
vec!["--codegen=extra-filename=-abc123"],
vec!["-C", "extra_filename=-abc123"],
vec!["-Cextra_filename=-abc123"],
vec!["--codegen", "extra_filename=-abc123"],
vec!["--codegen=extra_filename=-abc123"],
] {
let mut args: Vec<String> = vec!["--crate-type".into(), "lib".into()];
args.extend(spelling.iter().map(|arg| (*arg).to_string()));
args.push("--crate-name".into());
args.push("mylib".into());
let dep_args =
dep_info_pass_args(Path::new("src/lib.rs"), &args, Path::new("/tmp/deps.d"));
assert!(
!dep_args
.iter()
.any(|a| a.contains("extra-filename") || a.contains("extra_filename")),
"{spelling:?} must be dropped: {dep_args:?}"
);
assert!(
dep_args.iter().any(|a| a == "--crate-name"),
"{spelling:?} must not swallow the following flag: {dep_args:?}"
);
}
}
#[test]
fn dep_info_pass_args_keeps_bare_trailing_codegen_flag() {
let args = vec![
"--crate-type".to_string(),
"lib".to_string(),
"-C".to_string(),
];
let dep_args = dep_info_pass_args(Path::new("src/lib.rs"), &args, Path::new("/tmp/deps.d"));
assert!(
dep_args.iter().any(|a| a == "-C"),
"a valueless -C is not an extra-filename: {dep_args:?}"
);
}
#[test]
fn dep_info_pass_args_drops_joined_output_flag() {
let args: Vec<String> = [
"--crate-name",
"mylib",
"-o/tmp/original.rlib",
"--edition=2021",
"-O",
"--out-dir=/tmp/original-deps",
"-out-dir",
"/tmp/still-positional",
"src/lib.rs",
]
.iter()
.map(|arg| (*arg).to_string())
.collect();
let dep_args = dep_info_pass_args(Path::new("src/lib.rs"), &args, Path::new("/tmp/deps.d"));
let outputs: Vec<&String> = dep_args.iter().filter(|a| a.starts_with("-o")).collect();
assert_eq!(
outputs,
["-o"],
"only the pre-pass's own -o may survive: {dep_args:?}"
);
for dropped in [
"-o/tmp/original.rlib",
"-out-dir",
"--out-dir=/tmp/original-deps",
] {
assert!(
!dep_args.iter().any(|a| a == dropped),
"{dropped} names an output the pre-pass discards: {dep_args:?}"
);
}
assert!(
dep_args.iter().any(|a| a == "/tmp/still-positional"),
"single-dash -out-dir takes no separate value: rustc reads the next \
token as a positional, and the pre-pass must fail on it exactly as \
the real build does: {dep_args:?}"
);
assert!(
dep_args.iter().any(|a| a == "-O"),
"capital -O is opt-level, not output: {dep_args:?}"
);
assert!(
dep_args.iter().any(|a| a == "--edition=2021"),
"the token after a joined -o is a real flag, not its value: {dep_args:?}"
);
let tail = &dep_args[dep_args.len() - 4..];
assert_eq!(
tail,
[
"--emit",
"dep-info",
"-o",
"/tmp/deps.d".to_string().as_str()
]
.map(String::from),
"the pre-pass appends exactly one output configuration"
);
}
#[test]
fn dep_info_pass_args_strips_every_incremental_spelling() {
let args: Vec<String> = [
"--crate-name",
"mylib",
"-Cincremental=/tmp/incr-joined",
"-C",
"incremental=/tmp/incr-split",
"--codegen=incremental=/tmp/incr-long-joined",
"--codegen",
"incremental=/tmp/incr-long-split",
"src/lib.rs",
]
.iter()
.map(|arg| (*arg).to_string())
.collect();
let dep_args = dep_info_pass_args(Path::new("src/lib.rs"), &args, Path::new("/tmp/deps.d"));
assert!(
!dep_args.iter().any(|a| a.contains("incremental")),
"no incremental spelling may reach the pre-pass: {dep_args:?}"
);
assert!(
dep_args.iter().any(|a| a == "mylib"),
"stripping must not swallow neighbouring flags: {dep_args:?}"
);
let bare: Vec<String> = ["--crate-name", "mylib", "-C", "incremental", "src/lib.rs"]
.iter()
.map(|arg| (*arg).to_string())
.collect();
let dep_bare = dep_info_pass_args(Path::new("src/lib.rs"), &bare, Path::new("/tmp/deps.d"));
assert!(
dep_bare.iter().any(|a| a == "incremental"),
"a valueless incremental is rustc's to reject, not the pre-pass's to guess: {dep_bare:?}"
);
}
#[test]
fn dep_info_pass_prepass_succeeds_through_response_file() {
let dir = tempfile::Builder::new()
.prefix("kache depinfo space ")
.tempdir()
.unwrap();
let src = dir.path().join("src");
std::fs::create_dir_all(&src).unwrap();
std::fs::write(src.join("lib.rs"), b"mod server;\npub fn hello() {}").unwrap();
std::fs::write(src.join("server.rs"), b"pub fn serve() {}").unwrap();
let rustc = std::path::PathBuf::from("rustc");
let source = src.join("lib.rs");
let args = vec![
"--crate-name".to_string(),
"testcrate".to_string(),
"--crate-type".to_string(),
"lib".to_string(),
"--edition".to_string(),
"2021".to_string(),
];
let dep_info = run_dep_info_pass(&rustc, None, &source, &args, true)
.expect("the response-file pre-pass must match the direct one");
assert!(
dep_info.source_files.iter().any(|p| p.ends_with("lib.rs")),
"expected the crate root: {:?}",
dep_info.source_files
);
assert!(
dep_info
.source_files
.iter()
.any(|p| p.ends_with("server.rs")),
"an incomplete source list is what makes a crate uncacheable: {:?}",
dep_info.source_files
);
}
#[test]
fn read_dep_info_file_rejects_non_utf8() {
let dir = tempfile::tempdir().unwrap();
let dep_file = dir.path().join("deps.d");
std::fs::write(&dep_file, b"/tmp/x.d: src/lib.rs\n").unwrap();
assert_eq!(
read_dep_info_file(&dep_file).unwrap(),
"/tmp/x.d: src/lib.rs\n"
);
std::fs::write(&dep_file, b"/tmp/x.d: src/\xfflib.rs\n").unwrap();
let err = format!("{:#}", read_dep_info_file(&dep_file).unwrap_err());
assert!(
err.contains("not valid UTF-8"),
"the refusal must name the encoding: {err}"
);
}
#[test]
fn dep_info_pass_prepass_succeeds_with_extra_filename() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("src");
std::fs::create_dir_all(&src).unwrap();
std::fs::write(src.join("lib.rs"), b"mod server;\npub fn hello() {}").unwrap();
std::fs::write(src.join("server.rs"), b"pub fn serve() {}").unwrap();
let rustc = std::path::PathBuf::from("rustc");
let source = src.join("lib.rs");
let out_dir = dir.path().join("deps");
let args: Vec<String> = vec![
"--crate-name".into(),
"testcrate".into(),
"--edition=2021".into(),
source.to_string_lossy().into_owned(),
"--error-format=json".into(),
"--crate-type".into(),
"lib".into(),
"--emit=dep-info,metadata,link".into(),
"-C".into(),
"metadata=92ee3169a2c3b7b0".into(),
"-C".into(),
"extra-filename=-2cb6bc2ef2b88725".into(),
"--out-dir".into(),
out_dir.to_string_lossy().into_owned(),
"-C".into(),
"incremental=/nonexistent/incremental".into(),
];
let dep_info = run_dep_info_pass(&rustc, None, &source, &args, false)
.expect("a cargo-shaped lib argv must not fail the pre-pass");
assert!(
dep_info.source_files.iter().any(|p| p.ends_with("lib.rs")),
"expected the crate root: {:?}",
dep_info.source_files
);
assert!(
dep_info
.source_files
.iter()
.any(|p| p.ends_with("server.rs")),
"an incomplete source list is what makes a crate uncacheable: {:?}",
dep_info.source_files
);
}
#[test]
fn first_rustc_error_line_skips_leading_json_warnings() {
let stderr = concat!(
r#"{"$message_type":"diagnostic","message":"ignoring -C extra-filename flag due to -o flag","level":"warning"}"#,
"\n",
r#"{"$message_type":"diagnostic","message":"cannot find macro `frobnicate`","level":"error"}"#,
"\n",
r#"{"$message_type":"diagnostic","message":"aborting due to 1 previous error","level":"error"}"#,
"\n",
);
let line = first_rustc_error_line(stderr).expect("an error line is present");
assert!(
line.contains("cannot find macro"),
"the first error-level diagnostic wins: {line}"
);
}
#[test]
fn first_rustc_error_line_skips_leading_human_warnings() {
let stderr = "warning: ignoring -C extra-filename flag due to -o flag\n\
\n\
error[E0433]: failed to resolve: use of undeclared crate `nope`\n\
error: aborting due to 1 previous error\n";
let line = first_rustc_error_line(stderr).expect("an error line is present");
assert_eq!(
line,
"error[E0433]: failed to resolve: use of undeclared crate `nope`"
);
}
#[test]
fn first_rustc_error_line_matches_unnumbered_human_errors() {
let stderr = "warning: unused import: `std::io`\nerror: expected one of `!` or `::`\n";
let line = first_rustc_error_line(stderr).expect("an error line is present");
assert_eq!(line, "error: expected one of `!` or `::`");
}
#[test]
fn first_rustc_error_line_falls_back_to_the_first_content_line() {
let line = first_rustc_error_line("\n\nwarning: something odd\nnote: more\n");
assert_eq!(line, Some("warning: something odd"));
assert_eq!(
first_rustc_error_line(" \n \n"),
None,
"blank is no cause"
);
assert_eq!(first_rustc_error_line(""), None);
}
#[test]
fn test_cache_key_changes_with_module_file() {
let _lock = key_test_lock();
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("src");
std::fs::create_dir_all(&src).unwrap();
std::fs::write(src.join("lib.rs"), b"mod utils;\npub fn hello() {}").unwrap();
std::fs::write(src.join("utils.rs"), b"pub fn helper() {}").unwrap();
let args_vec: Vec<String> = vec![
"rustc".to_string(),
"--crate-name".to_string(),
"mylib".to_string(),
src.join("lib.rs").to_string_lossy().to_string(),
"--crate-type".to_string(),
"lib".to_string(),
"--edition=2021".to_string(),
];
let fh = FileHasher::new();
let pn = PathNormalizer::empty();
let parsed1 = RustcArgs::parse(&args_vec).unwrap();
let key1 = compute_cache_key(&parsed1, &fh, &pn).unwrap();
std::fs::write(
src.join("utils.rs"),
b"pub fn helper() { println!(\"changed\"); }",
)
.unwrap();
let parsed2 = RustcArgs::parse(&args_vec).unwrap();
let key2 = compute_cache_key(&parsed2, &fh, &pn).unwrap();
assert_ne!(
key1, key2,
"cache key must change when a module file changes"
);
}
#[test]
fn test_cache_key_stable_with_module_files() {
let _lock = key_test_lock();
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("src");
std::fs::create_dir_all(&src).unwrap();
std::fs::write(src.join("lib.rs"), b"mod a;\nmod b;\npub fn lib_fn() {}").unwrap();
std::fs::write(src.join("a.rs"), b"pub fn a_fn() {}").unwrap();
std::fs::write(src.join("b.rs"), b"pub fn b_fn() {}").unwrap();
let args_vec: Vec<String> = vec![
"rustc".to_string(),
"--crate-name".to_string(),
"testcrate".to_string(),
src.join("lib.rs").to_string_lossy().to_string(),
"--crate-type".to_string(),
"lib".to_string(),
"--edition=2021".to_string(),
];
let fh = FileHasher::new();
let pn = PathNormalizer::empty();
let parsed1 = RustcArgs::parse(&args_vec).unwrap();
let parsed2 = RustcArgs::parse(&args_vec).unwrap();
let key1 = compute_cache_key(&parsed1, &fh, &pn).unwrap();
let key2 = compute_cache_key(&parsed2, &fh, &pn).unwrap();
assert_eq!(
key1, key2,
"cache key must be deterministic with multiple source files"
);
}
fn key_test_lock() -> crate::test_support::ProcessStateTestGuard {
process_state_test_lock()
}
#[test]
fn key_computation_stashes_unit_identity_and_yields_it_once() {
let _lock = key_test_lock();
let args: Vec<String> = [
"rustc",
"--crate-name",
"app",
"src/lib.rs",
"-C",
"extra-filename=-843f02d6a46ebef1",
"--extern",
"foo_old=/w/target/debug/deps/libfoo-0532daf0ee3516f0.rlib",
]
.iter()
.map(|s| s.to_string())
.collect();
let mut parsed = RustcArgs::parse(&args).unwrap();
parsed.source_file = None;
compute_cache_key(&parsed, &FileHasher::new(), &PathNormalizer::empty()).unwrap();
assert_eq!(
take_last_key_unit_id().as_deref(),
Some("843f02d6a46ebef1"),
"the compile's own `-C extra-filename`"
);
assert_eq!(take_last_key_unit_id(), None, "taken, not copied");
let units = take_last_key_extern_units().expect("recorded with the digests");
assert_eq!(
units.get("foo_old").map(String::as_str),
Some("0532daf0ee3516f0")
);
assert_eq!(take_last_key_extern_units(), None, "taken, not copied");
}
#[test]
fn key_stashes_do_not_leak_across_threads() {
let _lock = key_test_lock();
let units = ["aaaaaaaaaaaaaaaa", "bbbbbbbbbbbbbbbb", "cccccccccccccccc"];
std::thread::scope(|scope| {
for unit in units {
scope.spawn(move || {
let extra = format!("extra-filename=-{unit}");
let extern_arg = format!("dep_{unit}=/w/target/debug/deps/libdep-{unit}.rlib");
let args: Vec<String> = [
"rustc",
"--crate-name",
"app",
"src/lib.rs",
"-C",
&extra,
"--extern",
&extern_arg,
]
.iter()
.map(|s| s.to_string())
.collect();
let mut parsed = RustcArgs::parse(&args).unwrap();
parsed.source_file = None;
for _ in 0..16 {
compute_cache_key(&parsed, &FileHasher::new(), &PathNormalizer::empty())
.unwrap();
assert_eq!(
take_last_key_unit_id().as_deref(),
Some(unit),
"each thread sees its own unit id"
);
assert_eq!(take_last_key_unit_id(), None, "taken, not copied");
let recorded =
take_last_key_extern_units().expect("recorded with the digests");
assert_eq!(
recorded.get(&format!("dep_{unit}")).map(String::as_str),
Some(unit),
"each thread sees its own extern units"
);
assert_eq!(take_last_key_extern_units(), None, "taken, not copied");
assert!(
take_last_key_externs().is_some(),
"digests ride the same thread as their identities"
);
assert!(take_last_key_fields().is_some(), "per-group digests too");
}
});
}
});
}
fn rustc_available() -> bool {
std::process::Command::new("rustc")
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
#[cfg(unix)]
fn rustc_exe_name() -> &'static str {
"rustc"
}
#[cfg(unix)]
fn rustc_path_on_path() -> Option<PathBuf> {
let path_var = std::env::var_os("PATH")?;
std::env::split_paths(&path_var)
.map(|dir| dir.join(rustc_exe_name()))
.find(|path| path.is_file())
}
#[cfg(unix)]
fn shell_single_quote(path: &Path) -> String {
format!("'{}'", path.to_string_lossy().replace('\'', "'\\''"))
}
#[cfg(unix)]
fn write_rustc_version_wrapper(root: &Path, subdir: &str, version: &str) -> PathBuf {
let real_rustc = rustc_path_on_path().expect("rustc should be on PATH");
let dir = root.join(subdir);
std::fs::create_dir_all(&dir).unwrap();
let wrapper = dir.join("rustc");
let script = format!(
"#!/bin/sh\n\
if [ \"$1\" = \"--version\" ] && [ \"$2\" = \"--verbose\" ]; then\n\
cat <<'KACHE_RUSTC_VERSION'\n\
{version}\n\
KACHE_RUSTC_VERSION\n\
exit 0\n\
fi\n\
exec {} \"$@\"\n",
shell_single_quote(&real_rustc)
);
std::fs::write(&wrapper, script).unwrap();
let mut perms = std::fs::metadata(&wrapper).unwrap().permissions();
std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o755);
std::fs::set_permissions(&wrapper, perms).unwrap();
wrapper
}
fn base_args(source: &Path) -> Vec<String> {
vec![
"rustc".to_string(),
"--crate-name".to_string(),
"mxcrate".to_string(),
source.to_string_lossy().to_string(),
"--crate-type".to_string(),
"lib".to_string(),
"--edition=2021".to_string(),
]
}
fn key_for(args: &[String]) -> String {
let parsed = RustcArgs::parse(args).unwrap();
let fh = FileHasher::new();
let pn = PathNormalizer::empty();
compute_cache_key(&parsed, &fh, &pn).unwrap()
}
fn restore_env_var(key: &str, old: Option<std::ffi::OsString>) {
match old {
Some(value) => unsafe { std::env::set_var(key, value) },
None => unsafe { std::env::remove_var(key) },
}
}
#[test]
fn opt_out_key_is_path_local_but_default_stays_portable() {
let _lock = key_test_lock();
if !rustc_available() {
return;
}
let old_var = std::env::var_os("KACHE_RUSTC_PATH_NORMALIZE");
let old_cwd = std::env::current_dir().unwrap();
let dir_a = tempfile::tempdir().unwrap();
let dir_b = tempfile::tempdir().unwrap();
std::fs::write(dir_a.path().join("lib.rs"), "pub fn f() {}\n").unwrap();
std::fs::write(dir_b.path().join("lib.rs"), "pub fn f() {}\n").unwrap();
let args = base_args(Path::new("lib.rs"));
unsafe { std::env::set_var("KACHE_RUSTC_PATH_NORMALIZE", "0") };
std::env::set_current_dir(dir_a.path()).unwrap();
let optout_a = key_for(&args);
std::env::set_current_dir(dir_b.path()).unwrap();
let optout_b = key_for(&args);
restore_env_var("KACHE_RUSTC_PATH_NORMALIZE", None);
std::env::set_current_dir(dir_a.path()).unwrap();
let default_a = key_for(&args);
std::env::set_current_dir(dir_b.path()).unwrap();
let default_b = key_for(&args);
std::env::set_current_dir(&old_cwd).unwrap();
restore_env_var("KACHE_RUSTC_PATH_NORMALIZE", old_var);
assert_ne!(
optout_a, optout_b,
"opt-out builds bake real paths, so keys must be cwd-local"
);
assert_eq!(
default_a, default_b,
"default (remapped) builds must stay cwd-portable"
);
assert_ne!(
optout_a, default_a,
"opt-out must be a separate namespace from remapped builds"
);
}
#[test]
fn opt_out_key_folds_all_normalizer_prefixes_not_just_home() {
let _lock = key_test_lock();
if !rustc_available() {
return;
}
let old_var = std::env::var_os("KACHE_RUSTC_PATH_NORMALIZE");
let old_target = std::env::var_os("CARGO_TARGET_DIR");
let ws = tempfile::tempdir().unwrap();
let src = ws.path().join("lib.rs");
std::fs::write(&src, "pub fn f() {}\n").unwrap();
let target_a = tempfile::tempdir().unwrap();
let target_b = tempfile::tempdir().unwrap();
let args = base_args(&src);
let key = || {
let parsed = RustcArgs::parse(&args).unwrap();
let fh = FileHasher::new();
let pn = PathNormalizer::from_env(Some(ws.path()));
compute_cache_key(&parsed, &fh, &pn).unwrap()
};
unsafe { std::env::set_var("KACHE_RUSTC_PATH_NORMALIZE", "0") };
unsafe { std::env::set_var("CARGO_TARGET_DIR", target_a.path()) };
let optout_a = key();
unsafe { std::env::set_var("CARGO_TARGET_DIR", target_b.path()) };
let optout_b = key();
restore_env_var("KACHE_RUSTC_PATH_NORMALIZE", None);
unsafe { std::env::set_var("CARGO_TARGET_DIR", target_a.path()) };
let default_a = key();
unsafe { std::env::set_var("CARGO_TARGET_DIR", target_b.path()) };
let default_b = key();
restore_env_var("KACHE_RUSTC_PATH_NORMALIZE", old_var);
restore_env_var("CARGO_TARGET_DIR", old_target);
assert_ne!(
optout_a, optout_b,
"opt-out key must fold the raw $CARGO_TARGET_DIR prefix (OUT_DIR lives under it)"
);
assert_eq!(
default_a, default_b,
"default build normalizes $CARGO_TARGET_DIR to <TARGET>, so it stays portable"
);
}
#[test]
fn coverage_key_is_path_local() {
let _lock = key_test_lock();
if !rustc_available() {
return;
}
let old_cwd = std::env::current_dir().unwrap();
let old_var = std::env::var_os("KACHE_RUSTC_PATH_NORMALIZE");
restore_env_var("KACHE_RUSTC_PATH_NORMALIZE", None);
let dir_a = tempfile::tempdir().unwrap();
let dir_b = tempfile::tempdir().unwrap();
std::fs::write(dir_a.path().join("lib.rs"), "pub fn f() {}\n").unwrap();
std::fs::write(dir_b.path().join("lib.rs"), "pub fn f() {}\n").unwrap();
let mut args = base_args(Path::new("lib.rs"));
args.push("-Cinstrument-coverage".to_string());
let parsed = RustcArgs::parse(&args).unwrap();
assert!(parsed.has_coverage_instrumentation());
assert!(
!parsed.path_normalize_disabled,
"test must exercise the coverage remap:none path, not the opt-out path"
);
std::env::set_current_dir(dir_a.path()).unwrap();
let cov_a = key_for(&args);
std::env::set_current_dir(dir_b.path()).unwrap();
let cov_b = key_for(&args);
std::env::set_current_dir(&old_cwd).unwrap();
restore_env_var("KACHE_RUSTC_PATH_NORMALIZE", old_var);
assert_ne!(
cov_a, cov_b,
"coverage builds bake real paths, so their keys must be cwd-local"
);
}
#[test]
fn key_rustc_bootstrap_presence_changes_key_but_empty_is_identity() {
let _lock = key_test_lock();
if !rustc_available() {
return;
}
let old = std::env::var_os("RUSTC_BOOTSTRAP");
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("lib.rs");
std::fs::write(&src, "pub fn f() {}\n").unwrap();
let args = base_args(&src);
unsafe {
std::env::remove_var("RUSTC_BOOTSTRAP");
}
let key_unset = key_for(&args);
unsafe {
std::env::set_var("RUSTC_BOOTSTRAP", "");
}
let key_empty = key_for(&args);
unsafe {
std::env::set_var("RUSTC_BOOTSTRAP", "1");
}
let key_set = key_for(&args);
unsafe {
std::env::set_var("RUSTC_BOOTSTRAP", "some_crate");
}
let key_other = key_for(&args);
restore_env_var("RUSTC_BOOTSTRAP", old);
assert_eq!(
key_unset, key_empty,
"empty RUSTC_BOOTSTRAP must equal unset"
);
assert_ne!(key_unset, key_set, "RUSTC_BOOTSTRAP=1 must change the key");
assert_ne!(
key_set, key_other,
"different RUSTC_BOOTSTRAP values must differ"
);
}
#[test]
fn key_cargo_encoded_rustflags_changes_key() {
let _lock = key_test_lock();
if !rustc_available() {
return;
}
let old = std::env::var_os("CARGO_ENCODED_RUSTFLAGS");
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("lib.rs");
std::fs::write(&src, "pub fn f() {}\n").unwrap();
let args = base_args(&src);
unsafe {
std::env::remove_var("CARGO_ENCODED_RUSTFLAGS");
}
let key_unset = key_for(&args);
unsafe {
std::env::set_var("CARGO_ENCODED_RUSTFLAGS", "-C\x1ftarget-cpu=native");
}
let key_set = key_for(&args);
unsafe {
std::env::set_var("CARGO_ENCODED_RUSTFLAGS", "-C\x1ftarget-cpu=x86-64-v3");
}
let key_other = key_for(&args);
restore_env_var("CARGO_ENCODED_RUSTFLAGS", old);
assert_ne!(
key_unset, key_set,
"setting CARGO_ENCODED_RUSTFLAGS must change the key"
);
assert_ne!(
key_set, key_other,
"different encoded rustflags must diverge the key"
);
}
#[test]
fn key_cargo_cfg_env_changes_key() {
let _lock = key_test_lock();
if !rustc_available() {
return;
}
let var = "CARGO_CFG_KACHE_TEST_FLAG";
let old = std::env::var_os(var);
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("lib.rs");
std::fs::write(&src, "pub fn f() {}\n").unwrap();
let args = base_args(&src);
unsafe {
std::env::remove_var(var);
}
let key_unset = key_for(&args);
unsafe {
std::env::set_var(var, "1");
}
let key_set = key_for(&args);
unsafe {
std::env::set_var(var, "2");
}
let key_other = key_for(&args);
restore_env_var(var, old);
assert_ne!(key_unset, key_set, "a CARGO_CFG_* var must change the key");
assert_ne!(
key_set, key_other,
"a different CARGO_CFG_* value must diverge the key"
);
}
#[cfg(unix)]
#[test]
fn key_matrix_rustc_version_changes_key() {
let _lock = key_test_lock();
if !rustc_available() {
return;
}
let dir = tempfile::tempdir().unwrap();
let rustc_a = write_rustc_version_wrapper(
dir.path(),
"toolchain-a",
"rustc 1.95.0-test-a\nbinary: test-a\ncommit-hash: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
);
let rustc_b = write_rustc_version_wrapper(
dir.path(),
"toolchain-b",
"rustc 1.95.0-test-b\nbinary: test-b\ncommit-hash: bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
);
let source = dir.path().join("lib.rs");
std::fs::write(&source, b"pub fn hello() {}").unwrap();
let mut args_a = base_args(&source);
args_a[0] = rustc_a.to_string_lossy().into_owned();
let mut args_b = base_args(&source);
args_b[0] = rustc_b.to_string_lossy().into_owned();
assert_ne!(
key_for(&args_a),
key_for(&args_b),
"`rustc --version --verbose` output must affect the cache key"
);
}
#[test]
fn key_matrix_manifest_dir_runtime_env_path_changes_key_across_workspaces() {
let _lock = key_test_lock();
if !rustc_available() {
return;
}
let old_manifest_dir = std::env::var_os("CARGO_MANIFEST_DIR");
let dir = tempfile::tempdir().unwrap();
let workspace_a = dir.path().join("checkout-a");
let workspace_b = dir.path().join("checkout-b");
fn write_helper(workspace: &Path) -> PathBuf {
let src = workspace.join("helper/src");
std::fs::create_dir_all(&src).unwrap();
let lib = src.join("lib.rs");
std::fs::write(
&lib,
r#"pub fn manifest_dir() -> &'static str {
env!("CARGO_MANIFEST_DIR")
}
"#,
)
.unwrap();
lib
}
let source_a = write_helper(&workspace_a);
let source_b = write_helper(&workspace_b);
let fh = FileHasher::new();
let manifest_a = workspace_a.join("helper").canonicalize().unwrap();
unsafe {
std::env::set_var("CARGO_MANIFEST_DIR", manifest_a);
}
let parsed_a = RustcArgs::parse(&base_args(&source_a)).unwrap();
let pn_a = PathNormalizer::from_env(Some(&workspace_a));
let key_a = compute_cache_key(&parsed_a, &fh, &pn_a).unwrap();
let manifest_b = workspace_b.join("helper").canonicalize().unwrap();
unsafe {
std::env::set_var("CARGO_MANIFEST_DIR", manifest_b);
}
let parsed_b = RustcArgs::parse(&base_args(&source_b)).unwrap();
let pn_b = PathNormalizer::from_env(Some(&workspace_b));
let key_b = compute_cache_key(&parsed_b, &fh, &pn_b).unwrap();
restore_env_var("CARGO_MANIFEST_DIR", old_manifest_dir);
assert_ne!(
key_a, key_b,
"CARGO_MANIFEST_DIR is a runtime env! value and must stay checkout-specific"
);
}
#[test]
fn key_matrix_out_dir_include_pattern_stays_stable_across_workspaces() {
let _lock = key_test_lock();
if !rustc_available() {
return;
}
let old_out_dir = std::env::var_os("OUT_DIR");
let old_manifest_dir = std::env::var_os("CARGO_MANIFEST_DIR");
let dir = tempfile::tempdir().unwrap();
let workspace_a = dir.path().join("checkout-a");
let workspace_b = dir.path().join("checkout-b");
fn write_generated_include_crate(workspace: &Path) -> (PathBuf, PathBuf) {
let src = workspace.join("src");
let out_dir = workspace.join("target/debug/build/include-crate/out");
std::fs::create_dir_all(&src).unwrap();
std::fs::create_dir_all(&out_dir).unwrap();
let generated = out_dir.join("generated.rs");
std::fs::write(&generated, b"pub fn generated() -> u8 { 7 }\n").unwrap();
let lib = src.join("lib.rs");
std::fs::write(
&lib,
r#"include!(concat!(env!("OUT_DIR"), "/generated.rs"));
pub fn value() -> u8 {
generated()
}
"#,
)
.unwrap();
(lib, out_dir)
}
let (source_a, out_a) = write_generated_include_crate(&workspace_a);
let (source_b, out_b) = write_generated_include_crate(&workspace_b);
let fh = FileHasher::new();
let out_a = out_a.canonicalize().unwrap();
unsafe {
std::env::set_var("OUT_DIR", &out_a);
std::env::set_var("CARGO_MANIFEST_DIR", &workspace_a);
}
let parsed_a = RustcArgs::parse(&base_args(&source_a)).unwrap();
let pn_a = PathNormalizer::from_env(Some(&workspace_a));
let key_a = compute_cache_key(&parsed_a, &fh, &pn_a).unwrap();
let out_b = out_b.canonicalize().unwrap();
unsafe {
std::env::set_var("OUT_DIR", &out_b);
std::env::set_var("CARGO_MANIFEST_DIR", &workspace_b);
}
let parsed_b = RustcArgs::parse(&base_args(&source_b)).unwrap();
let pn_b = PathNormalizer::from_env(Some(&workspace_b));
let key_b = compute_cache_key(&parsed_b, &fh, &pn_b).unwrap();
restore_env_var("OUT_DIR", old_out_dir);
restore_env_var("CARGO_MANIFEST_DIR", old_manifest_dir);
assert_eq!(
key_a, key_b,
"OUT_DIR include!() paths should stay portable across workspaces"
);
}
#[test]
fn key_matrix_out_dir_dual_pattern_diverges_across_workspaces() {
let _lock = key_test_lock();
if !rustc_available() {
return;
}
let old_out_dir = std::env::var_os("OUT_DIR");
let dir = tempfile::tempdir().unwrap();
let workspace_a = dir.path().join("checkout-a");
let workspace_b = dir.path().join("checkout-b");
fn write_dual_pattern_crate(workspace: &Path) -> (PathBuf, PathBuf) {
let src = workspace.join("src");
let out_dir = workspace.join("target/debug/build/dual-crate/out");
std::fs::create_dir_all(&src).unwrap();
std::fs::create_dir_all(&out_dir).unwrap();
let generated = out_dir.join("generated.rs");
std::fs::write(&generated, b"pub fn generated() -> u8 { 7 }\n").unwrap();
let lib = src.join("lib.rs");
std::fs::write(
&lib,
r#"include!(concat!(env!("OUT_DIR"), "/generated.rs"));
pub const OUT_DIR_AT_COMPILE_TIME: &str = env!("OUT_DIR");
pub fn value() -> (&'static str, u8) {
(OUT_DIR_AT_COMPILE_TIME, generated())
}
"#,
)
.unwrap();
(lib, out_dir)
}
let (source_a, out_a) = write_dual_pattern_crate(&workspace_a);
let (source_b, out_b) = write_dual_pattern_crate(&workspace_b);
let fh = FileHasher::new();
let out_a = out_a.canonicalize().unwrap();
unsafe {
std::env::set_var("OUT_DIR", &out_a);
}
let parsed_a = RustcArgs::parse(&base_args(&source_a)).unwrap();
let pn_a = PathNormalizer::from_env(Some(&workspace_a));
let key_a = compute_cache_key(&parsed_a, &fh, &pn_a).unwrap();
let out_b = out_b.canonicalize().unwrap();
unsafe {
std::env::set_var("OUT_DIR", &out_b);
}
let parsed_b = RustcArgs::parse(&base_args(&source_b)).unwrap();
let pn_b = PathNormalizer::from_env(Some(&workspace_b));
let key_b = compute_cache_key(&parsed_b, &fh, &pn_b).unwrap();
restore_env_var("OUT_DIR", old_out_dir);
assert_ne!(
key_a, key_b,
"OUT_DIR dual pattern must stay checkout-specific: include!() alone is path-only, \
but env!(\"OUT_DIR\") as a runtime value bakes the absolute path into the artifact"
);
}
#[test]
fn key_matrix_emit_changes_key() {
if !rustc_available() {
return;
}
let _lock = key_test_lock();
let dir = tempfile::tempdir().unwrap();
let source = dir.path().join("lib.rs");
std::fs::write(&source, b"pub fn hello() {}").unwrap();
let mut metadata = base_args(&source);
metadata.push("--emit=metadata".to_string());
let mut link = base_args(&source);
link.push("--emit=link".to_string());
assert_ne!(
key_for(&metadata),
key_for(&link),
"`--emit=metadata` vs `--emit=link` must produce different keys"
);
}
#[test]
fn key_matrix_opt_level_changes_key() {
if !rustc_available() {
return;
}
let _lock = key_test_lock();
let dir = tempfile::tempdir().unwrap();
let source = dir.path().join("lib.rs");
std::fs::write(&source, b"pub fn hello() {}").unwrap();
let mut o0 = base_args(&source);
o0.extend(["-C".to_string(), "opt-level=0".to_string()]);
let mut o3 = base_args(&source);
o3.extend(["-C".to_string(), "opt-level=3".to_string()]);
assert_ne!(
key_for(&o0),
key_for(&o3),
"`-C opt-level` must affect the key"
);
}
#[test]
fn key_matrix_debug_assertions_changes_key() {
if !rustc_available() {
return;
}
let _lock = key_test_lock();
let dir = tempfile::tempdir().unwrap();
let source = dir.path().join("lib.rs");
std::fs::write(&source, b"pub fn hello() {}").unwrap();
let mut on = base_args(&source);
on.extend(["-C".to_string(), "debug-assertions=on".to_string()]);
let mut off = base_args(&source);
off.extend(["-C".to_string(), "debug-assertions=off".to_string()]);
assert_ne!(
key_for(&on),
key_for(&off),
"`-C debug-assertions` must affect the key"
);
}
#[test]
fn key_matrix_cfg_changes_key() {
if !rustc_available() {
return;
}
let _lock = key_test_lock();
let dir = tempfile::tempdir().unwrap();
let source = dir.path().join("lib.rs");
std::fs::write(&source, b"pub fn hello() {}").unwrap();
let base = base_args(&source);
let mut with_cfg = base_args(&source);
with_cfg.extend(["--cfg".to_string(), "extra_feature".to_string()]);
assert_ne!(
key_for(&base),
key_for(&with_cfg),
"a `--cfg` value must affect the key"
);
}
#[test]
fn key_matrix_feature_changes_key() {
if !rustc_available() {
return;
}
let _lock = key_test_lock();
let dir = tempfile::tempdir().unwrap();
let source = dir.path().join("lib.rs");
std::fs::write(&source, b"pub fn hello() {}").unwrap();
let mut std_feat = base_args(&source);
std_feat.extend(["--cfg".to_string(), "feature=\"std\"".to_string()]);
let mut both = std_feat.clone();
both.extend(["--cfg".to_string(), "feature=\"derive\"".to_string()]);
assert_ne!(
key_for(&std_feat),
key_for(&both),
"adding a feature must affect the key"
);
}
#[test]
fn key_matrix_edition_changes_key() {
if !rustc_available() {
return;
}
let _lock = key_test_lock();
let dir = tempfile::tempdir().unwrap();
let source = dir.path().join("lib.rs");
std::fs::write(&source, b"pub fn hello() {}").unwrap();
let mut e2018 = base_args(&source);
e2018.retain(|a| a != "--edition=2021");
e2018.push("--edition=2018".to_string());
let e2021 = base_args(&source);
assert_ne!(
key_for(&e2018),
key_for(&e2021),
"`--edition` must affect the key"
);
}
#[test]
fn key_matrix_target_changes_key() {
if !rustc_available() {
return;
}
let _lock = key_test_lock();
let dir = tempfile::tempdir().unwrap();
let source = dir.path().join("lib.rs");
std::fs::write(&source, b"pub fn hello() {}").unwrap();
let mut t1 = base_args(&source);
t1.push("--target=x86_64-unknown-linux-gnu".to_string());
let mut t2 = base_args(&source);
t2.push("--target=aarch64-apple-darwin".to_string());
assert_ne!(
key_of_flags(&t1),
key_of_flags(&t2),
"`--target` must affect the key"
);
}
#[test]
fn key_matrix_crate_type_changes_key() {
if !rustc_available() {
return;
}
let _lock = key_test_lock();
let dir = tempfile::tempdir().unwrap();
let source = dir.path().join("lib.rs");
std::fs::write(&source, b"pub fn hello() {}").unwrap();
let rlib = base_args(&source); let mut staticlib = base_args(&source);
for a in staticlib.iter_mut() {
if a == "lib" {
*a = "staticlib".to_string();
}
}
assert_ne!(
key_for(&rlib),
key_for(&staticlib),
"`--crate-type` must affect the key"
);
}
#[test]
fn key_matrix_rustflags_env_changes_key() {
if !rustc_available() {
return;
}
let _lock = key_test_lock();
let dir = tempfile::tempdir().unwrap();
let source = dir.path().join("lib.rs");
std::fs::write(&source, b"pub fn hello() {}").unwrap();
let args = base_args(&source);
let saved = std::env::var("RUSTFLAGS").ok();
unsafe { std::env::remove_var("RUSTFLAGS") };
let key_none = key_for(&args);
unsafe { std::env::set_var("RUSTFLAGS", "-C target-cpu=native") };
let key_set = key_for(&args);
match saved {
Some(v) => unsafe { std::env::set_var("RUSTFLAGS", v) },
None => unsafe { std::env::remove_var("RUSTFLAGS") },
}
assert_ne!(key_none, key_set, "`RUSTFLAGS` env var must affect the key");
}
#[test]
fn normalize_rustflags_collapses_whitespace() {
assert_eq!(normalize_rustflags("-C a -C b"), "-C a -C b");
assert_eq!(normalize_rustflags("-C a -C b"), "-C a -C b");
assert_eq!(normalize_rustflags(" -C a -C b "), "-C a -C b");
assert_eq!(normalize_rustflags("-C a\t\t-C b"), "-C a -C b");
assert_eq!(normalize_rustflags("-Cfoo=b -Cfoo=a"), "-Cfoo=b -Cfoo=a");
assert_ne!(
normalize_rustflags("-Cfoo=a -Cfoo=b"),
normalize_rustflags("-Cfoo=b -Cfoo=a")
);
}
#[test]
fn scrub_remap_from_prefixes_collapses_from_keeps_to() {
let scrub = |s: &str| scrub_remap_from_prefixes(s.split_whitespace()).join(" ");
assert_eq!(
scrub("--remap-path-prefix=/abs/clone-a/=/topsrcdir/"),
"--remap-path-prefix=<REMAP_FROM>=/topsrcdir/"
);
assert_eq!(
scrub("--remap-path-prefix=/abs/clone-a/=/topsrcdir/"),
scrub("--remap-path-prefix=/abs/clone-b/=/topsrcdir/"),
"different checkout `from` paths must collapse identically"
);
assert_eq!(
scrub("--remap-path-prefix /abs/clone-a/=/topsrcdir/"),
"--remap-path-prefix <REMAP_FROM>=/topsrcdir/"
);
for flag in [
"-ffile-prefix-map",
"-fdebug-prefix-map",
"-fmacro-prefix-map",
] {
assert_eq!(
scrub(&format!("{flag}=/abs/clone-a/=/virt/")),
format!("{flag}=<REMAP_FROM>=/virt/")
);
}
assert_eq!(
scrub("--remap-path-prefix=/a=b/clone-a/=/topsrcdir/"),
"--remap-path-prefix=<REMAP_FROM>=/topsrcdir/"
);
assert_ne!(
scrub("--remap-path-prefix=/abs/clone-a/=/topsrcdir/"),
scrub("--remap-path-prefix=/abs/clone-a/=/other/")
);
assert_eq!(
scrub("-C opt-level=2 -C debuginfo=2"),
"-C opt-level=2 -C debuginfo=2"
);
assert_eq!(
scrub("--remap-path-prefix=garbage"),
"--remap-path-prefix=garbage"
);
}
#[test]
fn normalize_direct_remap_normalizes_known_from_but_keeps_to() {
let dir = tempfile::tempdir().unwrap();
let workspace = dir.path().join("workspace");
std::fs::create_dir_all(&workspace).unwrap();
let workspace =
PathBuf::from(crate::path_normalizer::canonical_string(&workspace).unwrap());
let normalizer = PathNormalizer::from_env(Some(&workspace));
let from = workspace.join("dir=with=equals");
let to = workspace.join("literal-to");
let value = format!("{}={}", from.display(), to.display());
assert_eq!(
normalize_direct_remap_value(&value, &normalizer),
format!(
"{}={}",
Path::new("<WORKSPACE>").join("dir=with=equals").display(),
to.display()
),
"only FROM is normalized; TO remains verbatim"
);
assert_eq!(
normalize_direct_remap_value("malformed", &normalizer),
"malformed"
);
}
#[test]
fn key_matrix_direct_remap_path_prefix_is_keyed_portably_and_in_order() {
let _lock = key_test_lock();
let dir = tempfile::tempdir().unwrap();
let source = dir.path().join("lib.rs");
std::fs::write(&source, b"pub fn hello() {}").unwrap();
let none = key_of_flags(&flag_base(&source, &[]));
let separated = key_of_flags(&flag_base(
&source,
&["--remap-path-prefix", "/work/clone-a=/virtual/src"],
));
let attached = key_of_flags(&flag_base(
&source,
&["--remap-path-prefix=/work/clone-a=/virtual/src"],
));
let unrelated_from = key_of_flags(&flag_base(
&source,
&["--remap-path-prefix=/work/clone-b=/virtual/src"],
));
let other_target = key_of_flags(&flag_base(
&source,
&["--remap-path-prefix=/work/clone-a=/virtual/other"],
));
let remap_root = format!("--remap-path-prefix={}=/virtual/root", dir.path().display());
let remap_source = format!("--remap-path-prefix={}=/virtual/source", source.display());
let order_ab = key_of_flags(&flag_base(&source, &[&remap_root, &remap_source]));
let order_ba = key_of_flags(&flag_base(&source, &[&remap_source, &remap_root]));
assert_ne!(none, separated, "adding a direct remap must change the key");
assert_eq!(separated, attached, "both rustc spellings are equivalent");
assert_ne!(
separated, unrelated_from,
"without a matching normalization rule, FROM remains semantic"
);
assert_ne!(
separated, other_target,
"the remap target is embedded in artifacts and must remain key-visible"
);
assert_ne!(order_ab, order_ba, "overlapping remap order is semantic");
let clone_a = dir.path().join("clone-a");
let clone_b = dir.path().join("clone-b");
std::fs::create_dir_all(&clone_a).unwrap();
std::fs::create_dir_all(&clone_b).unwrap();
let portable_key = |workspace: &Path| {
let workspace = workspace.canonicalize().unwrap();
let remap = format!("--remap-path-prefix={}=/virtual/src", workspace.display());
let mut parsed = RustcArgs::parse(&flag_base(&source, &[&remap])).unwrap();
parsed.source_file = None;
compute_cache_key(
&parsed,
&FileHasher::new(),
&PathNormalizer::from_env(Some(&workspace)),
)
.unwrap()
};
assert_eq!(
portable_key(&clone_a),
portable_key(&clone_b),
"known workspace prefixes normalize portably across checkouts"
);
}
#[test]
fn key_matrix_rustflags_remap_path_prefix_stable_across_checkouts() {
if !rustc_available() {
return;
}
let _lock = key_test_lock();
let dir = tempfile::tempdir().unwrap();
let source = dir.path().join("lib.rs");
std::fs::write(&source, b"pub fn hello() {}").unwrap();
let args = base_args(&source);
let saved = std::env::var("RUSTFLAGS").ok();
let set = |v: &str| unsafe { std::env::set_var("RUSTFLAGS", v) };
set("--remap-path-prefix=/work/clone-a/=/topsrcdir/");
let key_a = key_for(&args);
set("--remap-path-prefix=/work/clone-b/=/topsrcdir/");
let key_b = key_for(&args);
set("--remap-path-prefix=/work/clone-a/=/elsewhere/");
let key_other_to = key_for(&args);
match saved {
Some(v) => unsafe { std::env::set_var("RUSTFLAGS", v) },
None => unsafe { std::env::remove_var("RUSTFLAGS") },
}
assert_eq!(
key_a, key_b,
"different checkout paths under the same remap target must not change the key"
);
assert_ne!(
key_a, key_other_to,
"changing the remap target (`to`) must still change the key"
);
}
#[test]
fn key_matrix_rustflags_whitespace_does_not_change_key() {
if !rustc_available() {
return;
}
let _lock = key_test_lock();
let dir = tempfile::tempdir().unwrap();
let source = dir.path().join("lib.rs");
std::fs::write(&source, b"pub fn hello() {}").unwrap();
let args = base_args(&source);
let saved = std::env::var("RUSTFLAGS").ok();
unsafe { std::env::set_var("RUSTFLAGS", "-C debuginfo=2 -C codegen-units=1") };
let key_tight = key_for(&args);
unsafe { std::env::set_var("RUSTFLAGS", "-C debuginfo=2 -C codegen-units=1") };
let key_loose = key_for(&args);
unsafe { std::env::set_var("RUSTFLAGS", " -C debuginfo=2 -C codegen-units=1 ") };
let key_padded = key_for(&args);
match saved {
Some(v) => unsafe { std::env::set_var("RUSTFLAGS", v) },
None => unsafe { std::env::remove_var("RUSTFLAGS") },
}
assert_eq!(
key_tight, key_loose,
"RUSTFLAGS extra-whitespace must not change the key"
);
assert_eq!(
key_tight, key_padded,
"RUSTFLAGS leading/trailing whitespace must not change the key"
);
}
#[test]
fn key_matrix_outcome_lint_configuration_changes_key() {
if !rustc_available() {
return;
}
let _lock = key_test_lock();
let dir = tempfile::tempdir().unwrap();
let source = dir.path().join("lib.rs");
std::fs::write(&source, b"pub fn hello() {}").unwrap();
let base = base_args(&source);
let mut with_lint = base_args(&source);
with_lint.extend(["-D".to_string(), "warnings".to_string()]);
let mut with_forbid = base_args(&source);
with_forbid.extend(["--forbid".to_string(), "warnings".to_string()]);
let mut with_cap = base_args(&source);
with_cap.extend(["--cap-lints".to_string(), "allow".to_string()]);
let mut with_attached = base_args(&source);
with_attached.push("-Dwarnings".to_string());
let mut with_warn = base_args(&source);
with_warn.extend(["-W".to_string(), "unused".to_string()]);
let mut with_allow = base_args(&source);
with_allow.extend(["-A".to_string(), "dead_code".to_string()]);
let mut with_check_cfg = base_args(&source);
with_check_cfg.extend(["--check-cfg".to_string(), "cfg(foo)".to_string()]);
let mut with_other_check_cfg = base_args(&source);
with_other_check_cfg.push("--check-cfg=cfg(bar)".to_string());
assert_ne!(
key_for(&base),
key_for(&with_lint),
"an outcome-affecting lint gate (`-D warnings`) changes whether \
the compile fails and MUST change the key"
);
assert_ne!(
key_for(&base),
key_for(&with_forbid),
"`--forbid` is outcome-affecting and must change the key"
);
assert_ne!(
key_for(&base),
key_for(&with_cap),
"`--cap-lints` re-levels every lint and must change the key"
);
assert_ne!(
key_for(&with_lint),
key_for(&with_attached),
"separated (`-D warnings`) and attached (`-Dwarnings`) spellings \
carry different tokens; each keys distinctly by design"
);
assert_ne!(
key_for(&base),
key_for(&with_warn),
"-W can activate a lint that a deny group makes fatal"
);
assert_ne!(
key_for(&base),
key_for(&with_allow),
"-A can relax an otherwise fatal lint"
);
assert_ne!(
key_for(&base),
key_for(&with_check_cfg),
"--check-cfg controls the unexpected_cfgs outcome"
);
assert_ne!(
key_for(&with_check_cfg),
key_for(&with_other_check_cfg),
"different accepted cfg sets must not share a key"
);
}
#[test]
fn check_cfg_values_are_not_path_normalized() {
if !rustc_available() {
return;
}
let _lock = key_test_lock();
let root = tempfile::tempdir().unwrap();
let key_at = |workspace: &Path, with_check_cfg: bool| {
std::fs::create_dir_all(workspace).unwrap();
let source = workspace.join("lib.rs");
std::fs::write(&source, b"pub fn hello() {}").unwrap();
let mut args = base_args(&source);
if with_check_cfg {
let semantic_path = workspace
.join("generated")
.to_string_lossy()
.replace('\\', "/");
args.extend([
"--check-cfg".to_string(),
format!("cfg(build_path, values(\"{semantic_path}\"))"),
]);
}
let parsed = RustcArgs::parse(&args).unwrap();
compute_cache_key(
&parsed,
&FileHasher::new(),
&PathNormalizer::from_env(Some(workspace)),
)
.unwrap()
};
let workspace_a = root.path().join("checkout-a");
let workspace_b = root.path().join("checkout-b");
assert_eq!(
key_at(&workspace_a, false),
key_at(&workspace_b, false),
"the control must prove ordinary workspace paths normalize portably"
);
assert_ne!(
key_at(&workspace_a, true),
key_at(&workspace_b, true),
"path-looking check-cfg values are semantic strings and must stay raw"
);
}
#[test]
fn key_matrix_outcome_lint_gates_key_by_pairing_not_multiset() {
if !rustc_available() {
return;
}
let _lock = key_test_lock();
let dir = tempfile::tempdir().unwrap();
let source = dir.path().join("lib.rs");
std::fs::write(&source, b"pub fn hello() {}").unwrap();
let gated = |gates: &[&str]| {
let mut args = base_args(&source);
args.extend(gates.iter().map(|s| s.to_string()));
key_for(&args)
};
assert_ne!(
gated(&["-D", "unsafe_code", "-F", "warnings"]),
gated(&["-F", "unsafe_code", "-D", "warnings"]),
"swapping which lint is denied and which is forbidden changes \
the outcome and MUST change the key"
);
assert_ne!(
gated(&["-D", "unused_mut", "--force-warn", "deprecated"]),
gated(&["-D", "deprecated", "--force-warn", "unused_mut"]),
"swapping the deny and force-warn targets changes the outcome \
and MUST change the key"
);
assert_ne!(
gated(&["-D", "warnings", "-A", "unused", "-D", "unused"]),
gated(&["-D", "unused", "-A", "unused", "-D", "warnings"]),
"gate order is preserved in the key"
);
}
#[test]
fn key_matrix_error_format_does_not_change_key() {
if !rustc_available() {
return;
}
let _lock = key_test_lock();
let dir = tempfile::tempdir().unwrap();
let source = dir.path().join("lib.rs");
std::fs::write(&source, b"pub fn hello() {}").unwrap();
let base = base_args(&source);
let mut with_fmt = base_args(&source);
with_fmt.push("--error-format=json".to_string());
assert_eq!(
key_for(&base),
key_for(&with_fmt),
"`--error-format` is diagnostics-only and must NOT change \
the key — a change here is over-keying"
);
}
}