use crate::args::RustcArgs;
use crate::build_script::declarations::cargo_declarations;
use crate::build_script::inputs::{TooManyInputs, Walk, state_in};
use crate::cache_key::{FileFingerprint, FileHasher};
use crate::tree_stamp::fold_metadata_stamp;
use anyhow::{Context, Result};
use std::cell::RefCell;
use std::collections::BTreeMap;
use std::ffi::{OsStr, OsString};
use std::path::{Path, PathBuf};
const DIGEST_TAG: &[u8] = b"kache-build-script-inputs-v1";
const MAX_STDOUT_BYTES: u64 = 16 << 20;
pub(crate) const MAX_ENTRIES: usize = crate::cache_key::CRATE_TREE_MAX_ENTRIES;
const DYLIB_PATH: &str = if cfg!(windows) {
"PATH"
} else if cfg!(target_os = "macos") {
"DYLD_FALLBACK_LIBRARY_PATH"
} else if cfg!(target_os = "aix") {
"LIBPATH"
} else {
"LD_LIBRARY_PATH"
};
const SET_BY_CARGO: &[&str] = &[
"CARGO",
"CARGO_BIN_NAME",
"CARGO_CRATE_NAME",
"CARGO_MANIFEST_DIR",
"CARGO_MANIFEST_PATH",
"CARGO_PRIMARY_PACKAGE",
"CARGO_SBOM_PATH",
"CARGO_TARGET_TMPDIR",
"OUT_DIR",
DYLIB_PATH,
];
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Located {
pub(crate) package: String,
pub(crate) manifest_dir: PathBuf,
out_dir: PathBuf,
stdout: PathBuf,
}
pub(crate) fn locate(
args: &RustcArgs,
var: &dyn Fn(&str) -> Option<OsString>,
cwd: Option<&Path>,
alias_active: bool,
) -> Option<Located> {
if alias_active {
return None;
}
let out_dir = PathBuf::from(var("OUT_DIR")?);
let manifest_dir = PathBuf::from(var("CARGO_MANIFEST_DIR")?);
let package = var("CARGO_PKG_NAME")?.into_string().ok()?;
if !out_dir.is_absolute() || !manifest_dir.is_absolute() {
return None;
}
if crate::cache_key::is_registry_package(&manifest_dir)
|| cargo_home(var).is_some_and(|home| under_cargo_home(&home, &manifest_dir))
|| cwd.is_some_and(|cwd| crate::cache_key::vendored_source(args, &manifest_dir, cwd))
{
return None;
}
crate::cargo_layout::out_dir_unit_name(&out_dir, &package)?;
let rustc_out_dir = args.out_dir.as_deref()?;
if rustc_out_dir.starts_with(&out_dir)
|| crate::cargo_layout::profile_dir(rustc_out_dir)?
!= crate::cargo_layout::out_dir_profile(&out_dir)?
{
return None;
}
let stdout = crate::cargo_layout::build_script_stdout(&out_dir)?;
Some(Located {
package,
manifest_dir,
out_dir,
stdout,
})
}
pub(crate) fn cargo_home(var: &dyn Fn(&str) -> Option<OsString>) -> Option<PathBuf> {
let set = |name| var(name).filter(|value| !value.is_empty());
set("CARGO_HOME")
.map(PathBuf::from)
.or_else(|| set(HOME).map(|home| PathBuf::from(home).join(".cargo")))
}
const HOME: &str = if cfg!(windows) { "USERPROFILE" } else { "HOME" };
pub(crate) fn under_cargo_home(home: &Path, path: &Path) -> bool {
path.starts_with(home.join("registry")) || path.starts_with(home.join("git"))
}
const CARGO_HOME_WRITES: [&str; 11] = [
"registry",
"git",
".global-cache",
".global-cache-journal",
".global-cache-wal",
".global-cache-shm",
".package-cache",
".package-cache-mutate",
"bin",
".crates.toml",
".crates2.json",
];
pub(crate) fn cargo_home_writes(home: &Path) -> Vec<PathBuf> {
excluded_roots(None, [home])
.into_iter()
.flat_map(|home| CARGO_HOME_WRITES.map(|name| home.join(name)))
.collect()
}
pub(crate) struct Resolver<'a, 'db> {
pub(crate) file_hasher: &'a FileHasher<'db>,
pub(crate) var: &'a dyn Fn(&str) -> Option<OsString>,
pub(crate) cache_dir: &'a Path,
pub(crate) excluded: &'a [PathBuf],
pub(crate) outside: &'a dyn Fn(&Path) -> Result<Vec<u8>>,
pub(crate) max_entries: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum Resolved {
Folded(Snapshot),
Unrecorded,
PackageUnkeyed(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Snapshot {
pub(crate) digest: String,
pub(crate) package_mode: bool,
pub(crate) paths: usize,
pub(crate) vars: usize,
records: Vec<Option<FileFingerprint>>,
trees: Vec<(PathBuf, String)>,
pub(crate) files: Vec<FileFingerprint>,
}
impl Snapshot {
#[cfg(test)]
pub(crate) fn with_digest(digest: &str) -> Self {
Self {
digest: digest.to_string(),
package_mode: false,
paths: 0,
vars: 0,
records: Vec::new(),
trees: Vec::new(),
files: Vec::new(),
}
}
pub(crate) fn moved_since(&self, now: &Snapshot) -> bool {
self.digest != now.digest
|| self.records != now.records
|| self.trees != now.trees
|| self.files.iter().any(|file| {
FileFingerprint::from_path(Path::new(&file.path))
.ok()
.as_ref()
!= Some(file)
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum EnvState {
Set(Vec<u8>),
Unset,
SetByCargo,
}
pub(crate) fn resolve(located: &Located, resolver: &Resolver<'_, '_>) -> Result<Resolved> {
let root_output = located.stdout.with_file_name("root-output");
let records = vec![fingerprint(&located.stdout), fingerprint(&root_output)];
let Some(stdout) = read_record(&located.stdout)? else {
return Ok(Resolved::Unrecorded);
};
let recorded_out_dir =
read_record(&root_output)?.and_then(|bytes| String::from_utf8(bytes).ok());
let relocated = recorded_out_dir
.as_deref()
.zip(located.out_dir.to_str())
.filter(|(recorded, current)| recorded != current);
let declared = cargo_declarations(&stdout, relocated);
let trees = RefCell::new(Vec::new());
let walk = Walk {
excluded: resolver.excluded,
skip_metadata: true,
other_entries: true,
unreachable_entries: true,
stop_when_too_large: true,
memo: Some((resolver.cache_dir, "build-script-inputs-v1")),
stamps: Some(&trees),
..Walk::default()
};
let roots = Roots::of(located);
let cargo_home = cargo_home(resolver.var);
let mut paths: BTreeMap<Vec<u8>, String> = BTreeMap::new();
let mut budget = resolver.max_entries;
let mut package_mode = declared.paths.is_empty() && declared.env.is_empty();
for printed in &declared.paths {
if printed.is_empty() {
package_mode = true;
continue;
}
let path = located.manifest_dir.join(printed);
let state = if cargo_home
.as_deref()
.is_some_and(|home| under_cargo_home(home, &path))
{
"immutable".to_string()
} else {
state_in(&path, &walk, resolver.file_hasher, &mut budget)
.with_context(|| format!("digesting {}", path.display()))?
};
paths.insert(roots.spell(&path, resolver.outside)?, state);
}
if package_mode {
match package_state(located, resolver, &walk) {
Ok(state) => {
paths.insert(b"package".to_vec(), state);
}
Err(error) if error.is::<TooManyInputs>() => {
return Ok(Resolved::PackageUnkeyed(format!(
"the package holds more than {} entries",
resolver.max_entries
)));
}
Err(error) => {
return Ok(Resolved::PackageUnkeyed(format!(
"kache could not read the package: {error:#}"
)));
}
}
}
let vars: BTreeMap<Vec<u8>, EnvState> = declared
.env
.iter()
.map(|name| {
(
crate::cache_key::env_name_key_bytes(OsStr::new(name)),
env_state(name, resolver.var, located),
)
})
.collect();
let trees = trees
.into_inner()
.into_iter()
.map(|(path, stamp)| {
let own = own_stamp(&path);
(path, format!("{stamp}:{own}"))
})
.collect();
Ok(Resolved::Folded(Snapshot {
digest: digest(&paths, &vars),
package_mode,
paths: paths.len(),
vars: vars.len(),
records,
trees,
files: resolver
.file_hasher
.take_guarded_inputs()
.into_iter()
.map(|observed| observed.fingerprint)
.collect(),
}))
}
fn package_state(
located: &Located,
resolver: &Resolver<'_, '_>,
walk: &Walk<'_>,
) -> Result<String> {
let marker = oversized_marker(
resolver.cache_dir,
&located.manifest_dir,
resolver.max_entries,
);
if crate::cache_key::marked_recently(&marker, std::time::SystemTime::now()) {
return Err(TooManyInputs.into());
}
let walk = Walk {
skip_rust_and_packages: true,
memo: Some((resolver.cache_dir, "build-script-package-v1")),
..*walk
};
let mut budget = resolver.max_entries;
let state = state_in(
&located.manifest_dir,
&walk,
resolver.file_hasher,
&mut budget,
);
if state
.as_ref()
.is_err_and(|error| error.is::<TooManyInputs>())
{
crate::probe_memo::write_atomic(&marker, "");
}
state
}
fn oversized_marker(cache_dir: &Path, manifest_dir: &Path, max_entries: usize) -> PathBuf {
let mut hasher = blake3::Hasher::new();
crate::build_script::fold(&mut hasher, "kind", b"kache-build-script-package-v1");
crate::build_script::fold(
&mut hasher,
"root",
manifest_dir.as_os_str().as_encoded_bytes(),
);
crate::build_script::fold(&mut hasher, "max", &max_entries.to_le_bytes());
cache_dir
.join("probes")
.join("oversized-trees")
.join(&hasher.finalize().to_hex()[..32])
}
pub(crate) fn declares_inputs(located: &Located) -> bool {
match read_record(&located.stdout) {
Ok(Some(stdout)) => {
let declared = cargo_declarations(&stdout, None);
!declared.paths.is_empty() || !declared.env.is_empty()
}
Ok(None) => false,
Err(_) => true,
}
}
fn read_record(path: &Path) -> Result<Option<Vec<u8>>> {
let metadata = match std::fs::metadata(path) {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(error) => return Err(error).with_context(|| format!("reading {}", path.display())),
};
anyhow::ensure!(
metadata.len() <= MAX_STDOUT_BYTES,
"{} is larger than {MAX_STDOUT_BYTES} bytes",
path.display()
);
std::fs::read(path)
.map(Some)
.with_context(|| format!("reading {}", path.display()))
}
fn fingerprint(path: &Path) -> Option<FileFingerprint> {
FileFingerprint::from_path(path).ok()
}
fn own_stamp(path: &Path) -> String {
match std::fs::symlink_metadata(path) {
Ok(metadata) => {
let mut hasher = blake3::Hasher::new();
fold_metadata_stamp(&mut hasher, &metadata);
hasher.finalize().to_hex().to_string()
}
Err(_) => "missing".to_string(),
}
}
struct Roots {
out_dir: Vec<PathBuf>,
package: Vec<PathBuf>,
}
impl Roots {
fn of(located: &Located) -> Self {
let mut out_dir = vec![located.out_dir.clone()];
out_dir.extend(std::fs::read_link(&located.out_dir).ok());
out_dir.extend(std::fs::canonicalize(&located.out_dir).ok());
let mut package = vec![located.manifest_dir.clone()];
package.extend(std::fs::canonicalize(&located.manifest_dir).ok());
Self { out_dir, package }
}
fn spell(&self, path: &Path, outside: &dyn Fn(&Path) -> Result<Vec<u8>>) -> Result<Vec<u8>> {
for (prefix, roots) in [(&b"out:"[..], &self.out_dir), (b"pkg:", &self.package)] {
if let Some(rest) = roots.iter().find_map(|root| path.strip_prefix(root).ok()) {
let mut spelling = prefix.to_vec();
spelling.extend(crate::cache_key::env_os_key_bytes(rest.as_os_str()));
return Ok(spelling);
}
}
let mut spelling = b"path:".to_vec();
spelling.extend(outside(path)?);
Ok(spelling)
}
}
fn env_state(name: &str, var: &dyn Fn(&str) -> Option<OsString>, located: &Located) -> EnvState {
if set_by_cargo(name) {
return EnvState::SetByCargo;
}
if name.is_empty() || name.contains(['=', '\0']) {
return EnvState::Unset;
}
match var(name) {
Some(value) if is_script_out_dir(name, &value, located) => EnvState::SetByCargo,
Some(value) => EnvState::Set(crate::cache_key::env_os_key_bytes(&value)),
None => EnvState::Unset,
}
}
fn set_by_cargo(name: &str) -> bool {
let name = if cfg!(windows) {
name.to_ascii_uppercase()
} else {
name.to_string()
};
SET_BY_CARGO.contains(&name.as_str())
|| name.starts_with("CARGO_PKG_")
|| name.starts_with("CARGO_BIN_EXE_")
}
fn is_script_out_dir(name: &str, value: &OsStr, located: &Located) -> bool {
let suffixed = if cfg!(windows) {
name.to_ascii_uppercase().ends_with("_OUT_DIR")
} else {
name.ends_with("_OUT_DIR")
};
let value = Path::new(value);
suffixed
&& crate::cargo_layout::out_dir_unit_name(value, &located.package).is_some()
&& crate::cargo_layout::out_dir_profile(value)
== crate::cargo_layout::out_dir_profile(&located.out_dir)
}
fn digest(paths: &BTreeMap<Vec<u8>, String>, vars: &BTreeMap<Vec<u8>, EnvState>) -> String {
use crate::build_script::fold;
let mut hasher = blake3::Hasher::new();
fold(&mut hasher, "kind", DIGEST_TAG);
for (spelling, state) in paths {
fold(&mut hasher, "path", spelling);
fold(&mut hasher, "state", state.as_bytes());
}
for (name, state) in vars {
fold(&mut hasher, "env_name", name);
match state {
EnvState::Set(value) => fold(&mut hasher, "env_value", value),
EnvState::Unset => fold(&mut hasher, "env_unset", b""),
EnvState::SetByCargo => fold(&mut hasher, "env_set_by_cargo", b""),
}
}
hasher.finalize().to_hex().to_string()
}
pub(crate) fn excluded_roots<'p>(
target_dir: Option<PathBuf>,
dirs: impl IntoIterator<Item = &'p Path>,
) -> Vec<PathBuf> {
let mut excluded = Vec::new();
for root in target_dir
.into_iter()
.chain(dirs.into_iter().map(Path::to_path_buf))
{
if let Ok(canonical) = std::fs::canonicalize(&root)
&& canonical != root
{
excluded.push(canonical);
}
excluded.push(root);
}
excluded
}
#[cfg(test)]
mod tests;