use anyhow::{Context, Result};
use bytesize::ByteSize;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
pub const DEFAULT_DAEMON_IDLE_TIMEOUT_SECS: u64 = 0;
pub const DEFAULT_HEARTBEAT_SECS: u64 = 30;
pub const DEFAULT_PLANNER_TIMEOUT_MS: u64 = 750;
pub const DEFAULT_S3_POOL_IDLE_SECS: u64 = 300;
pub const DEFAULT_PREFETCH_ENABLED: bool = true;
pub const DEFAULT_REMOTE_KEY_CACHE_REFRESH_SECS: u64 = 60;
pub(crate) fn pull_request_prefix_for(base: &str, configured: &str) -> Result<Option<String>> {
let configured = configured.trim();
if configured.is_empty() {
return Ok(None);
}
let prefix = resolve_remote_prefix(configured)?;
let nested = |outer: &str, inner: &str| {
outer.is_empty() || inner == outer || inner.starts_with(&format!("{outer}/"))
};
anyhow::ensure!(
!prefix.is_empty() && !nested(base, &prefix) && !nested(&prefix, base),
"pull_request_prefix {prefix:?} must differ from the remote prefix {base:?} and \
neither may contain the other"
);
Ok(Some(prefix))
}
pub fn default_remote_key_listing(remote: Option<&RemoteConfig>) -> bool {
matches!(
remote.map(|remote| &remote.backend),
Some(RemoteBackendConfig::Filesystem(_))
)
}
pub const DEFAULT_PREFETCH_MAX_KEYS: u64 = 2000;
pub const DEFAULT_PREFETCH_MAX_BYTES: u64 = 2 * 1024 * 1024 * 1024;
pub const DEFAULT_PREFETCH_DEADLINE_SECS: u64 = 300;
pub const DEFAULT_MIN_STORE_COMPILE_MS: u64 = 0;
pub const DEFAULT_GC_MAX_AGE_HOURS: u64 = 0;
pub const DISK_SHARE_PERCENT: u64 = 5;
pub const DISK_SHARE_FLOOR: u64 = 5 * 1024 * 1024 * 1024;
pub const DISK_SHARE_CAP: u64 = 100 * 1024 * 1024 * 1024;
pub const DISK_SHARE_FALLBACK: u64 = 50 * 1024 * 1024 * 1024;
pub const DEFAULT_REMOTE_RESTORE_TIMEOUT_SECS: u64 = 300;
pub const DEFAULT_REMOTE_NEGATIVE_TTL_SECS: u64 = 60;
pub(crate) const UPLOAD_SPOOL_MAX_JOBS: usize = 65_536;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VolumeStore {
pub volume: String,
pub store: PathBuf,
pub max_size: Option<u64>,
}
fn absolutize_volume_path(path: &Path) -> PathBuf {
if path.is_absolute() {
return path.to_path_buf();
}
std::env::current_dir()
.map(|cwd| cwd.join(path))
.unwrap_or_else(|_| path.to_path_buf())
}
pub(crate) const DEFAULT_EVENT_LOG_MAX_SIZE: u64 = 64 * 1024 * 1024;
pub(crate) const DEFAULT_UNUSED_UNITS_DAYS: u64 = 30;
pub(crate) const DEFAULT_RECOVERY_FLOOR: u64 = 10 << 30;
pub(crate) fn default_recovery_floor(in_ci: bool) -> u64 {
if in_ci { 0 } else { DEFAULT_RECOVERY_FLOOR }
}
#[derive(Debug, Clone)]
pub struct Config {
pub cache_dir: PathBuf,
pub runtime_dir: PathBuf,
pub socket_path_override: Option<PathBuf>,
pub volume_stores: Vec<VolumeStore>,
pub max_size: u64,
pub remote: Option<RemoteConfig>,
pub remote_error: Option<String>,
pub disabled: bool,
pub cache_executables: bool,
pub cache_cc_links: bool,
pub trust_codegen_backends: bool,
pub target_liveness: bool,
pub clean_incremental: bool,
pub preserve_incremental: bool,
pub adaptive_incremental: bool,
pub event_log_max_size: u64,
pub event_log_keep_lines: usize,
pub compression_level: i32,
pub s3_concurrency: u32,
pub prefetch_enabled: bool,
pub remote_key_listing: bool,
pub remote_key_cache_refresh_secs: u64,
pub prefetch_max_keys: u64,
pub prefetch_max_bytes: u64,
pub prefetch_deadline_secs: u64,
pub min_store_compile_ms: u64,
pub gc_max_age_hours: u64,
pub gc_evict_shared: bool,
pub daemon_idle_timeout_secs: u64,
pub s3_pool_idle_secs: u64,
pub remote_restore_timeout_secs: u64,
pub remote_negative_ttl_secs: u64,
pub fallback: Option<String>,
pub key_salt: Option<String>,
pub path_only_env_vars: Vec<String>,
pub incremental_crates: Vec<String>,
pub key_env_vars: Vec<String>,
pub base_dirs: Vec<String>,
pub cc_extra_allowlist_flags: Vec<String>,
pub local_only: bool,
pub remote_readonly: bool,
pub readonly_store: Option<PathBuf>,
pub pull_request_prefix: Option<String>,
pub modified_input_guard: bool,
pub input_predictions: bool,
pub record_sessions: bool,
pub windows_hardlink: bool,
pub shared_hardlink_restores: bool,
pub deferred_discovery: bool,
pub out_dir_alias: bool,
pub deferred_durability: bool,
pub daemon_publish: bool,
pub(crate) project_rules: ProjectRules,
pub auto_gc: bool,
pub index_auto_compact: bool,
pub auto_clean_orphaned_targets: bool,
pub auto_share_target_files: bool,
pub auto_clean_idle_targets_days: u64,
pub auto_recover_min_free_bytes: u64,
pub auto_recover_targets: bool,
pub scheduler_memory_pressure: bool,
pub auto_clean_unused_units_days: u64,
pub seed_new_targets: bool,
pub build_script_hermetic: bool,
pub storage_layout_advice: bool,
pub heartbeat_secs: u64,
pub explain_miss: bool,
pub scheduler: bool,
pub test_lease: Option<PathBuf>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PlannerConfig {
pub endpoint: String,
pub timeout_ms: u64,
pub token: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RemoteConfig {
pub prefix: String,
pub backend: RemoteBackendConfig,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RemoteBackendConfig {
S3(S3RemoteConfig),
Filesystem(FilesystemRemoteConfig),
Gcs(GcsRemoteConfig),
Oci(OciRemoteConfig),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OciRemoteConfig {
pub repository: String,
pub insecure: bool,
}
impl OciRemoteConfig {
pub(crate) fn reference(&self) -> Result<oci_client::Reference> {
let reference: oci_client::Reference =
self.repository.parse().context("invalid OCI repository")?;
let expected = format!("{}:latest", self.repository);
if reference.whole() != expected {
anyhow::bail!(
"OCI repository must include a registry host and repository, without a scheme, tag or digest"
);
}
Ok(reference)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GcsRemoteConfig {
pub bucket: String,
pub endpoint: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct S3RemoteConfig {
pub bucket: String,
pub endpoint: Option<String>,
pub region: String,
pub profile: Option<String>,
pub user_agent: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FilesystemRemoteConfig {
pub root: PathBuf,
pub atomic_write_dir: PathBuf,
}
impl Config {
pub fn require_remote(&self) -> Result<&RemoteConfig> {
if let Some(remote) = &self.remote {
return Ok(remote);
}
if let Some(reason) = &self.remote_error {
anyhow::bail!("remote cache configuration is unusable: {reason}");
}
if self.local_only {
anyhow::bail!("local-only mode is enabled, so no remote cache is available");
}
anyhow::bail!("No remote configured. Run `kache config` to set one up.")
}
pub(crate) fn incremental_crate_forced(&self, crate_name: &str) -> bool {
incremental_crate_forced_in(&self.incremental_crates, crate_name)
}
}
impl RemoteConfig {
pub fn backend_kind(&self) -> &'static str {
match &self.backend {
RemoteBackendConfig::S3(_) => "s3",
RemoteBackendConfig::Filesystem(_) => "filesystem",
RemoteBackendConfig::Gcs(_) => "gcs",
RemoteBackendConfig::Oci(_) => "oci",
}
}
pub fn describe(&self) -> String {
let base = match &self.backend {
RemoteBackendConfig::S3(s3) => format!("s3://{}", s3.bucket),
RemoteBackendConfig::Filesystem(fs) => format!("file://{}", fs.root.display()),
RemoteBackendConfig::Gcs(gcs) => format!("gs://{}", gcs.bucket),
RemoteBackendConfig::Oci(oci) => format!("oci://{}", oci.repository),
};
if self.prefix.is_empty() {
base
} else {
format!("{base}/{}", self.prefix)
}
}
#[cfg(test)]
pub(crate) fn test_s3(bucket: &str, prefix: &str) -> Self {
Self {
prefix: prefix.to_string(),
backend: RemoteBackendConfig::S3(S3RemoteConfig {
bucket: bucket.to_string(),
endpoint: None,
region: "us-east-1".to_string(),
profile: None,
user_agent: None,
}),
}
}
}
pub(crate) const V3_OBJECT_ROOT: &str = "v3";
pub(crate) fn normalize_remote_prefix(prefix: &str) -> Result<String> {
let trimmed = prefix.trim();
if trimmed.contains('\\') {
anyhow::bail!("remote prefix must not contain backslashes: {prefix:?}");
}
let mut segments = Vec::new();
for segment in trimmed.split('/') {
if segment.is_empty() {
continue;
}
if segment == "." || segment == ".." {
anyhow::bail!("remote prefix must not contain '.' or '..' path segments: {prefix:?}");
}
segments.push(segment);
}
Ok(segments.join("/"))
}
fn filesystem_staging_problem(
root: &std::path::Path,
atomic_write_dir: &std::path::Path,
prefix: &str,
) -> Option<String> {
let object_tree = join_remote_key(prefix, V3_OBJECT_ROOT);
let object_tree = root.join(object_tree);
if atomic_write_dir.starts_with(&object_tree) {
return Some(format!(
"[cache.remote] atomic_write_dir {} is inside the object tree {}; staging files would be listed as cached objects. Put it outside it (the default is <path>/.kache-tmp).",
atomic_write_dir.display(),
object_tree.display()
));
}
None
}
pub(crate) fn join_remote_key(prefix: &str, rest: &str) -> String {
if prefix.is_empty() {
rest.to_string()
} else {
format!("{prefix}/{rest}")
}
}
fn resolve_remote_prefix(configured: &str) -> Result<String> {
let normalized = normalize_remote_prefix(configured)?;
if normalized != configured {
tracing::warn!(
configured = %configured,
normalized = %normalized,
"remote prefix is not canonical; using the normalized form. Objects written under \
the previous prefix will not be found, so the remote cache repopulates once."
);
}
Ok(normalized)
}
#[derive(Debug, Deserialize, Serialize, Default, Clone)]
pub(crate) struct FileConfig {
pub(crate) cache: Option<CacheFileConfig>,
pub(crate) cc: Option<CcFileConfig>,
pub(crate) paths: Option<PathsFileConfig>,
pub(crate) workspace: Option<toml::Value>,
}
#[derive(Debug, Deserialize, Serialize, Default, Clone)]
pub(crate) struct PathsFileConfig {
pub(crate) base_dirs: Option<Vec<String>>,
}
#[derive(Debug, Deserialize, Serialize, Default, Clone)]
pub(crate) struct CcFileConfig {
pub(crate) extra_allowlist_flags: Option<Vec<String>>,
}
#[derive(Debug, Deserialize, Serialize, Default, Clone)]
pub(crate) struct CacheFileConfig {
pub(crate) local_store: Option<String>,
pub(crate) runtime_dir: Option<String>,
pub(crate) readonly_store: Option<String>,
pub(crate) local_max_size: Option<String>,
pub(crate) remote: Option<RemoteFileConfig>,
pub(crate) planner: Option<PlannerFileConfig>,
pub(crate) local_only: Option<bool>,
pub(crate) volumes: Option<HashMap<String, String>>,
pub(crate) remote_readonly: Option<bool>,
pub(crate) modified_input_guard: Option<bool>,
pub(crate) input_predictions: Option<bool>,
pub(crate) record_sessions: Option<bool>,
pub(crate) windows_hardlink: Option<bool>,
pub(crate) shared_hardlink_restores: Option<bool>,
pub(crate) deferred_discovery: Option<bool>,
pub(crate) out_dir_alias: Option<bool>,
pub(crate) deferred_durability: Option<bool>,
pub(crate) daemon_publish: Option<bool>,
pub(crate) auto_gc: Option<bool>,
pub(crate) index_auto_compact: Option<bool>,
pub(crate) auto_clean_orphaned_targets: Option<bool>,
pub(crate) auto_share_target_files: Option<bool>,
pub(crate) auto_clean_idle_targets_days: Option<u64>,
pub(crate) auto_recover_min_free_bytes: Option<u64>,
pub(crate) auto_recover_targets: Option<bool>,
pub(crate) scheduler_memory_pressure: Option<bool>,
pub(crate) auto_clean_unused_units_days: Option<u64>,
pub(crate) seed_new_targets: Option<bool>,
pub(crate) build_script_hermetic: Option<bool>,
pub(crate) gc_evict_shared: Option<bool>,
pub(crate) storage_layout_advice: Option<bool>,
pub(crate) heartbeat_secs: Option<u64>,
pub(crate) explain_miss: Option<bool>,
pub(crate) ignore_env: Option<bool>,
pub(crate) cache_executables: Option<bool>,
pub(crate) cache_cc_links: Option<bool>,
pub(crate) trust_codegen_backends: Option<bool>,
pub(crate) target_liveness: Option<bool>,
pub(crate) clean_incremental: Option<bool>,
pub(crate) preserve_incremental: Option<bool>,
pub(crate) adaptive_incremental: Option<bool>,
pub(crate) exclude: Option<Vec<String>>,
pub(crate) bypass_env: Option<Vec<String>>,
pub(crate) bypass_argv: Option<Vec<String>>,
pub(crate) bypass_crates: Option<Vec<String>>,
pub(crate) event_log_max_size: Option<String>,
pub(crate) event_log_keep_lines: Option<usize>,
pub(crate) compression_level: Option<i32>,
pub(crate) s3_concurrency: Option<u32>,
pub(crate) prefetch_enabled: Option<bool>,
pub(crate) remote_key_listing: Option<bool>,
pub(crate) remote_key_cache_refresh_secs: Option<u64>,
pub(crate) prefetch_max_keys: Option<u64>,
pub(crate) prefetch_max_bytes: Option<String>,
pub(crate) prefetch_deadline_secs: Option<u64>,
pub(crate) min_store_compile_ms: Option<u64>,
pub(crate) gc_max_age_hours: Option<u64>,
pub(crate) daemon_idle_timeout_secs: Option<u64>,
pub(crate) s3_pool_idle_secs: Option<u64>,
pub(crate) remote_restore_timeout_secs: Option<u64>,
pub(crate) remote_negative_ttl_secs: Option<u64>,
pub(crate) fallback: Option<String>,
pub(crate) key_salt: Option<String>,
pub(crate) path_only_env_vars: Option<Vec<String>>,
pub(crate) incremental_crates: Option<Vec<String>>,
pub(crate) key_env_vars: Option<Vec<String>>,
pub(crate) scheduler: Option<bool>,
}
#[derive(Debug, Deserialize, Serialize, Default, Clone)]
pub(crate) struct RemoteFileConfig {
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
pub(crate) _type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) repository: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) insecure: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) bucket: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) endpoint: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) region: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) prefix: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) profile: Option<String>,
#[serde(alias = "user-agent", skip_serializing_if = "Option::is_none")]
pub(crate) user_agent: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) atomic_write_dir: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) pull_request_prefix: Option<String>,
}
#[derive(Debug, Deserialize, Serialize, Default, Clone)]
pub(crate) struct PlannerFileConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) endpoint: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) timeout_ms: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) token: Option<String>,
}
pub(crate) struct EnvOverrides {
pub(crate) cache_dir: bool,
pub(crate) max_size: bool,
pub(crate) cache_executables: bool,
pub(crate) clean_incremental: bool,
pub(crate) preserve_incremental: bool,
pub(crate) adaptive_incremental: bool,
pub(crate) s3_bucket: bool,
pub(crate) s3_endpoint: bool,
pub(crate) s3_region: bool,
pub(crate) s3_prefix: bool,
pub(crate) s3_profile: bool,
pub(crate) s3_user_agent: bool,
pub(crate) fallback: bool,
pub(crate) key_salt: bool,
}
impl EnvOverrides {
pub(crate) fn detect() -> Self {
let ignore_env = Config::ignore_env_enabled(&Config::load_file_config());
Self {
cache_dir: env_or_ignored("KACHE_CACHE_DIR", ignore_env).is_ok(),
max_size: env_or_ignored("KACHE_MAX_SIZE", ignore_env).is_ok(),
cache_executables: env_or_ignored("KACHE_CACHE_EXECUTABLES", ignore_env).is_ok(),
clean_incremental: env_or_ignored("KACHE_CLEAN_INCREMENTAL", ignore_env).is_ok(),
preserve_incremental: env_or_ignored("KACHE_PRESERVE_INCREMENTAL", ignore_env).is_ok(),
adaptive_incremental: env_or_ignored("KACHE_ADAPTIVE_INCREMENTAL", ignore_env).is_ok(),
s3_bucket: env_or_ignored("KACHE_S3_BUCKET", ignore_env).is_ok(),
s3_endpoint: env_or_ignored("KACHE_S3_ENDPOINT", ignore_env).is_ok(),
s3_region: env_or_ignored("KACHE_S3_REGION", ignore_env).is_ok(),
s3_prefix: env_or_ignored("KACHE_S3_PREFIX", ignore_env).is_ok(),
s3_profile: env_or_ignored("KACHE_S3_PROFILE", ignore_env).is_ok(),
s3_user_agent: env_or_ignored("KACHE_S3_USER_AGENT", ignore_env).is_ok(),
fallback: env_or_ignored("KACHE_FALLBACK", ignore_env).is_ok(),
key_salt: env_or_ignored("KACHE_KEY_SALT", ignore_env).is_ok(),
}
}
}
fn normalize_cc_flags(raw: impl IntoIterator<Item = String>) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
for flag in raw {
let trimmed = flag.trim();
if trimmed.is_empty() || out.iter().any(|f| f == trimmed) {
continue;
}
out.push(trimmed.to_string());
}
out
}
pub(crate) fn normalize_incremental_crates(raw: impl IntoIterator<Item = String>) -> Vec<String> {
let mut out: Vec<String> = raw
.into_iter()
.map(|entry| entry.trim().replace('-', "_"))
.filter(|entry| !entry.is_empty())
.collect();
out.sort();
out.dedup();
out
}
pub(crate) fn incremental_crate_forced_in(list: &[String], crate_name: &str) -> bool {
!list.is_empty()
&& (list.iter().any(|entry| entry == crate_name)
|| list
.iter()
.any(|entry| *entry == crate_name.replace('-', "_")))
}
pub(crate) fn normalize_key_env_vars(
raw: impl IntoIterator<Item = String>,
source: &str,
) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
for pattern in raw {
let trimmed = pattern.trim();
if trimmed.is_empty() {
continue;
}
if trimmed.trim_end_matches('*').contains('*') {
tracing::warn!(
target: "kache::config",
"{source}: pattern {trimmed:?} contains a `*` that is not the last character; \
only a trailing `*` is a prefix glob, so that earlier `*` is matched as a \
literal character in the variable name"
);
}
out.push(trimmed.to_ascii_uppercase());
}
out.sort();
out.dedup();
out
}
pub(crate) fn warn_inert_path_only_env_vars(entries: &[String], source: &str) -> Vec<String> {
let mut inert = Vec::new();
for entry in entries {
let var = entry.split_once(':').map_or(entry.as_str(), |(_, var)| var);
if crate::cache_key::is_manifest_dir_var(var.trim()) {
tracing::warn!(
target: "kache::config",
"{source}: entry {entry:?} is ignored. CARGO_MANIFEST_DIR is never \
path-only: rustc can embed it in crate metadata, and every crate's \
sources live under it, so normalizing it would serve another \
checkout's artifacts"
);
inert.push(entry.clone());
}
}
inert
}
fn normalize_base_dirs(raw: impl IntoIterator<Item = String>) -> Result<Vec<String>> {
let mut out = Vec::new();
for (index, value) in raw.into_iter().enumerate() {
let value = value.trim();
if value.is_empty() {
anyhow::bail!("[paths].base_dirs[{index}] must not be empty");
}
if value.starts_with(r"\\?\") || value.starts_with("//?/") {
anyhow::bail!(
"[paths].base_dirs[{index}] must not use a Windows verbatim prefix, got \
{value:?}"
);
}
let bytes = value.as_bytes();
let windows_drive = bytes.len() >= 3
&& bytes[0].is_ascii_alphabetic()
&& bytes[1] == b':'
&& matches!(bytes[2], b'/' | b'\\');
let unc = value.starts_with("//") || value.starts_with(r"\\");
let posix = value.starts_with('/');
if !windows_drive && !unc && !posix {
anyhow::bail!("[paths].base_dirs[{index}] must be absolute, got {value:?}");
}
let components: Vec<&str> = if windows_drive || unc {
value
.split(['/', '\\'])
.filter(|component| !component.is_empty() && *component != ".")
.collect()
} else {
value
.split('/')
.filter(|component| !component.is_empty() && *component != ".")
.collect()
};
if components.contains(&"..") {
anyhow::bail!(
"[paths].base_dirs[{index}] must be normalized and must not contain `..`, got \
{value:?}"
);
}
let normalized = if windows_drive {
let drive = value[..2].to_ascii_uppercase();
let tail = components.iter().skip(1).copied().collect::<Vec<_>>();
if tail.is_empty() {
format!("{drive}/")
} else {
format!("{drive}/{}", tail.join("/"))
}
} else if unc {
if components.len() < 2 {
anyhow::bail!(
"[paths].base_dirs[{index}] UNC root must include server and share, got \
{value:?}"
);
}
format!("//{}", components.join("/"))
} else if components.is_empty() {
"/".to_string()
} else {
format!("/{}", components.join("/"))
};
if crate::path_normalizer::is_filesystem_root_prefix(&normalized) {
anyhow::bail!(
"[paths].base_dirs[{index}] must be narrower than a filesystem root, got \
{value:?}"
);
}
out.push(normalized);
}
out.sort();
out.dedup();
Ok(out)
}
const IGNORE_ENV_GATED_VARS: &[&str] = &[
"KACHE_CACHE_DIR",
"KACHE_RUNTIME_DIR",
"KACHE_READONLY_STORE",
"KACHE_MAX_SIZE",
"KACHE_CACHE_EXECUTABLES",
"KACHE_CACHE_CC_LINKS",
"KACHE_TRUST_CODEGEN_BACKENDS",
"KACHE_TARGET_LIVENESS",
"KACHE_CLEAN_INCREMENTAL",
"KACHE_PRESERVE_INCREMENTAL",
"KACHE_ADAPTIVE_INCREMENTAL",
"KACHE_COMPRESSION_LEVEL",
"KACHE_S3_CONCURRENCY",
"KACHE_PREFETCH_ENABLED",
"KACHE_REMOTE_KEY_LISTING",
"KACHE_PULL_REQUEST_PREFIX",
"KACHE_REMOTE_KEY_CACHE_REFRESH_SECS",
"KACHE_REMOTE_RESTORE_TIMEOUT_SECS",
"KACHE_REMOTE_NEGATIVE_TTL_SECS",
"KACHE_MIN_STORE_COMPILE_MS",
"KACHE_GC_MAX_AGE_HOURS",
"KACHE_DAEMON_IDLE_TIMEOUT",
"KACHE_S3_POOL_IDLE_SECS",
"KACHE_FALLBACK",
"KACHE_KEY_SALT",
"KACHE_CC_EXTRA_ALLOWLIST_FLAGS",
"KACHE_PATH_ONLY_ENV_VARS",
"KACHE_INCREMENTAL_CRATES",
"KACHE_KEY_ENV_VARS",
"KACHE_S3_BUCKET",
"KACHE_S3_ENDPOINT",
"KACHE_S3_REGION",
"KACHE_S3_PREFIX",
"KACHE_S3_PROFILE",
"KACHE_S3_USER_AGENT",
"KACHE_LOCAL_ONLY",
"KACHE_REMOTE_READONLY",
"KACHE_MODIFIED_INPUT_GUARD",
"KACHE_INPUT_PREDICTIONS",
"KACHE_RECORD_SESSIONS",
"KACHE_WINDOWS_HARDLINK",
"KACHE_SHARED_HARDLINK_RESTORES",
"KACHE_DEFERRED_DISCOVERY",
"KACHE_DAEMON_PUBLISH",
"KACHE_OUT_DIR_ALIAS",
"KACHE_DEFERRED_DURABILITY",
"KACHE_AUTO_GC",
"KACHE_INDEX_AUTO_COMPACT",
"KACHE_AUTO_CLEAN_ORPHANED_TARGETS",
"KACHE_AUTO_SHARE_TARGET_FILES",
"KACHE_AUTO_CLEAN_IDLE_TARGETS_DAYS",
"KACHE_AUTO_RECOVER_MIN_FREE_BYTES",
"KACHE_AUTO_RECOVER_TARGETS",
"KACHE_SCHEDULER_MEMORY_PRESSURE",
"KACHE_AUTO_CLEAN_UNUSED_UNITS_DAYS",
"KACHE_SEED_NEW_TARGETS",
"KACHE_BUILD_SCRIPT_HERMETIC",
"KACHE_STORAGE_LAYOUT_ADVICE",
"KACHE_HEARTBEAT_SECS",
"KACHE_EXPLAIN_MISS",
"KACHE_SCHEDULER",
"KACHE_PLANNER_ENDPOINT",
"KACHE_PLANNER_TIMEOUT_MS",
"KACHE_PLANNER_TOKEN",
"KACHE_GC_EVICT_SHARED",
"KACHE_PREFETCH_MAX_KEYS",
"KACHE_PREFETCH_MAX_BYTES",
"KACHE_PREFETCH_DEADLINE_SECS",
];
const ENV_FILE_KEYS: &[(&str, &str)] = &[
("KACHE_CACHE_DIR", "cache.local_store"),
("KACHE_RUNTIME_DIR", "cache.runtime_dir"),
("KACHE_READONLY_STORE", "cache.readonly_store"),
("KACHE_MAX_SIZE", "cache.local_max_size"),
("KACHE_CACHE_EXECUTABLES", "cache.cache_executables"),
("KACHE_CACHE_CC_LINKS", "cache.cache_cc_links"),
(
"KACHE_TRUST_CODEGEN_BACKENDS",
"cache.trust_codegen_backends",
),
("KACHE_TARGET_LIVENESS", "cache.target_liveness"),
("KACHE_CLEAN_INCREMENTAL", "cache.clean_incremental"),
("KACHE_PRESERVE_INCREMENTAL", "cache.preserve_incremental"),
("KACHE_ADAPTIVE_INCREMENTAL", "cache.adaptive_incremental"),
("KACHE_COMPRESSION_LEVEL", "cache.compression_level"),
("KACHE_S3_CONCURRENCY", "cache.s3_concurrency"),
("KACHE_PREFETCH_ENABLED", "cache.prefetch_enabled"),
("KACHE_REMOTE_KEY_LISTING", "cache.remote_key_listing"),
(
"KACHE_PULL_REQUEST_PREFIX",
"cache.remote.pull_request_prefix",
),
("KACHE_PREFETCH_MAX_KEYS", "cache.prefetch_max_keys"),
("KACHE_PREFETCH_MAX_BYTES", "cache.prefetch_max_bytes"),
(
"KACHE_PREFETCH_DEADLINE_SECS",
"cache.prefetch_deadline_secs",
),
(
"KACHE_REMOTE_KEY_CACHE_REFRESH_SECS",
"cache.remote_key_cache_refresh_secs",
),
(
"KACHE_REMOTE_RESTORE_TIMEOUT_SECS",
"cache.remote_restore_timeout_secs",
),
(
"KACHE_REMOTE_NEGATIVE_TTL_SECS",
"cache.remote_negative_ttl_secs",
),
("KACHE_MIN_STORE_COMPILE_MS", "cache.min_store_compile_ms"),
("KACHE_GC_MAX_AGE_HOURS", "cache.gc_max_age_hours"),
("KACHE_GC_EVICT_SHARED", "cache.gc_evict_shared"),
(
"KACHE_DAEMON_IDLE_TIMEOUT",
"cache.daemon_idle_timeout_secs",
),
("KACHE_S3_POOL_IDLE_SECS", "cache.s3_pool_idle_secs"),
("KACHE_FALLBACK", "cache.fallback"),
("KACHE_KEY_SALT", "cache.key_salt"),
("KACHE_CC_EXTRA_ALLOWLIST_FLAGS", "cc.extra_allowlist_flags"),
("KACHE_PATH_ONLY_ENV_VARS", "cache.path_only_env_vars"),
("KACHE_INCREMENTAL_CRATES", "cache.incremental_crates"),
("KACHE_KEY_ENV_VARS", "cache.key_env_vars"),
("KACHE_S3_BUCKET", "cache.remote.bucket"),
("KACHE_S3_ENDPOINT", "cache.remote.endpoint"),
("KACHE_S3_REGION", "cache.remote.region"),
("KACHE_S3_PREFIX", "cache.remote.prefix"),
("KACHE_S3_PROFILE", "cache.remote.profile"),
("KACHE_S3_USER_AGENT", "cache.remote.user_agent"),
("KACHE_LOCAL_ONLY", "cache.local_only"),
("KACHE_REMOTE_READONLY", "cache.remote_readonly"),
("KACHE_MODIFIED_INPUT_GUARD", "cache.modified_input_guard"),
("KACHE_INPUT_PREDICTIONS", "cache.input_predictions"),
("KACHE_RECORD_SESSIONS", "cache.record_sessions"),
("KACHE_WINDOWS_HARDLINK", "cache.windows_hardlink"),
(
"KACHE_SHARED_HARDLINK_RESTORES",
"cache.shared_hardlink_restores",
),
("KACHE_DEFERRED_DISCOVERY", "cache.deferred_discovery"),
("KACHE_OUT_DIR_ALIAS", "cache.out_dir_alias"),
("KACHE_DEFERRED_DURABILITY", "cache.deferred_durability"),
("KACHE_DAEMON_PUBLISH", "cache.daemon_publish"),
("KACHE_AUTO_GC", "cache.auto_gc"),
("KACHE_INDEX_AUTO_COMPACT", "cache.index_auto_compact"),
(
"KACHE_AUTO_CLEAN_ORPHANED_TARGETS",
"cache.auto_clean_orphaned_targets",
),
(
"KACHE_AUTO_SHARE_TARGET_FILES",
"cache.auto_share_target_files",
),
(
"KACHE_AUTO_CLEAN_IDLE_TARGETS_DAYS",
"cache.auto_clean_idle_targets_days",
),
(
"KACHE_AUTO_RECOVER_MIN_FREE_BYTES",
"cache.auto_recover_min_free_bytes",
),
("KACHE_AUTO_RECOVER_TARGETS", "cache.auto_recover_targets"),
(
"KACHE_SCHEDULER_MEMORY_PRESSURE",
"cache.scheduler_memory_pressure",
),
(
"KACHE_AUTO_CLEAN_UNUSED_UNITS_DAYS",
"cache.auto_clean_unused_units_days",
),
("KACHE_SEED_NEW_TARGETS", "cache.seed_new_targets"),
("KACHE_BUILD_SCRIPT_HERMETIC", "cache.build_script_hermetic"),
("KACHE_STORAGE_LAYOUT_ADVICE", "cache.storage_layout_advice"),
("KACHE_HEARTBEAT_SECS", "cache.heartbeat_secs"),
("KACHE_EXPLAIN_MISS", "cache.explain_miss"),
("KACHE_SCHEDULER", "cache.scheduler"),
("KACHE_PLANNER_ENDPOINT", "cache.planner.endpoint"),
("KACHE_PLANNER_TIMEOUT_MS", "cache.planner.timeout_ms"),
("KACHE_PLANNER_TOKEN", "cache.planner.token"),
];
fn env_flag_one_or_true(v: &str) -> bool {
v == "1" || v.eq_ignore_ascii_case("true")
}
fn env_or_ignored(name: &str, ignore_env: bool) -> Result<String, std::env::VarError> {
if ignore_env {
Err(std::env::VarError::NotPresent)
} else {
std::env::var(name)
}
}
fn out_dir_alias_setting(env: Option<&str>, file: Option<bool>) -> bool {
match env {
Some(value) => !(value == "0" || value.eq_ignore_ascii_case("false")),
None => file.unwrap_or(true),
}
}
fn prefetch_enabled_from_env(value: &str) -> bool {
value != "0" && !value.eq_ignore_ascii_case("false")
}
fn warn_ignored_env_overrides() {
let present: Vec<&str> = IGNORE_ENV_GATED_VARS
.iter()
.copied()
.filter(|name| std::env::var_os(name).is_some())
.collect();
if !present.is_empty() {
tracing::warn!(
"[cache] ignore_env = true: ignoring set env override(s) {present:?} in favor of the \
config file"
);
}
}
impl Config {
pub fn load() -> Result<Self> {
Self::load_with_provenance().map(|(config, _)| config)
}
pub(crate) fn load_with_provenance() -> Result<(Self, ConfigFileProvenance)> {
let path = normalize_config_path(resolve_config_path());
let (file_config, provenance) = Self::load_file_config_with_provenance(path);
let config = Self::load_resolved(file_config)?;
Ok((config, provenance))
}
fn load_resolved(file_config: Result<FileConfig>) -> Result<Self> {
let ignore_env = Self::ignore_env_enabled(&file_config);
if ignore_env {
warn_ignored_env_overrides();
}
let disabled = std::env::var("KACHE_DISABLED")
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
.unwrap_or(false);
let cache_dir = env_or_ignored("KACHE_CACHE_DIR", ignore_env)
.map(|s| shellexpand(&s))
.or_else(|_| {
file_config
.as_ref()
.ok()
.and_then(|c| c.cache.as_ref())
.and_then(|c| c.local_store.as_ref())
.map(|s| shellexpand(s))
.ok_or(())
})
.unwrap_or_else(|_| default_cache_dir());
let readonly_store = env_or_ignored("KACHE_READONLY_STORE", ignore_env)
.ok()
.or_else(|| {
file_config
.as_ref()
.ok()
.and_then(|c| c.cache.as_ref())
.and_then(|c| c.readonly_store.clone())
})
.filter(|s| !s.is_empty())
.map(|s| shellexpand(&s));
let runtime_dir = env_or_ignored("KACHE_RUNTIME_DIR", ignore_env)
.map(|s| shellexpand(&s))
.or_else(|_| {
file_config
.as_ref()
.ok()
.and_then(|c| c.cache.as_ref())
.and_then(|c| c.runtime_dir.as_ref())
.map(|s| shellexpand(s))
.ok_or(())
})
.unwrap_or_else(|_| cache_dir.clone());
let runtime_follows_cache = runtime_dir == cache_dir;
let trust_domain = std::env::var("KACHE_TRUST_DOMAIN").ok();
let cache_dir = isolate_cache_dir_for_trust_domain(cache_dir, trust_domain.as_deref());
let runtime_dir = if runtime_follows_cache {
cache_dir.clone()
} else {
runtime_dir
};
let socket_path_override =
resolve_socket_path_override(std::env::var_os("KACHE_SOCKET_PATH"));
let explicit_max_size = env_or_ignored("KACHE_MAX_SIZE", ignore_env)
.ok()
.and_then(|s| parse_local_max_size(&s, "KACHE_MAX_SIZE"))
.or_else(|| {
file_config
.as_ref()
.ok()
.and_then(|c| c.cache.as_ref())
.and_then(|c| c.local_max_size.as_ref())
.and_then(|s| parse_local_max_size(s, "[cache] local_max_size"))
});
let max_size = explicit_max_size
.unwrap_or_else(|| disk_share_budget(crate::cache_fs::probe(&cache_dir).total_bytes));
let cache_executables = env_or_ignored("KACHE_CACHE_EXECUTABLES", ignore_env)
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
.unwrap_or_else(|_| {
file_config
.as_ref()
.ok()
.and_then(|c| c.cache.as_ref())
.and_then(|c| c.cache_executables)
.unwrap_or(default_cache_executables())
});
let cache_cc_links = env_or_ignored("KACHE_CACHE_CC_LINKS", ignore_env)
.map(|v| env_flag_one_or_true(&v))
.unwrap_or_else(|_| {
file_config
.as_ref()
.ok()
.and_then(|c| c.cache.as_ref())
.and_then(|c| c.cache_cc_links)
.unwrap_or(false)
});
let trust_codegen_backends = env_or_ignored("KACHE_TRUST_CODEGEN_BACKENDS", ignore_env)
.map(|v| env_flag_one_or_true(&v))
.unwrap_or_else(|_| {
file_config
.as_ref()
.ok()
.and_then(|c| c.cache.as_ref())
.and_then(|c| c.trust_codegen_backends)
.unwrap_or(false)
});
let target_liveness = env_or_ignored("KACHE_TARGET_LIVENESS", ignore_env)
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
.unwrap_or_else(|_| {
file_config
.as_ref()
.ok()
.and_then(|c| c.cache.as_ref())
.and_then(|c| c.target_liveness)
.unwrap_or(false)
});
let clean_incremental = env_or_ignored("KACHE_CLEAN_INCREMENTAL", ignore_env)
.map(|v| v != "0" && !v.eq_ignore_ascii_case("false"))
.unwrap_or_else(|_| {
file_config
.as_ref()
.ok()
.and_then(|c| c.cache.as_ref())
.and_then(|c| c.clean_incremental)
.unwrap_or(true)
});
let preserve_incremental = env_or_ignored("KACHE_PRESERVE_INCREMENTAL", ignore_env)
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
.unwrap_or_else(|_| {
file_config
.as_ref()
.ok()
.and_then(|c| c.cache.as_ref())
.and_then(|c| c.preserve_incremental)
.unwrap_or(false)
});
let adaptive_incremental = env_or_ignored("KACHE_ADAPTIVE_INCREMENTAL", ignore_env)
.map(|v| v != "0" && !v.eq_ignore_ascii_case("false"))
.unwrap_or_else(|_| {
file_config
.as_ref()
.ok()
.and_then(|c| c.cache.as_ref())
.and_then(|c| c.adaptive_incremental)
.unwrap_or(true)
});
let event_log_max_size = file_config
.as_ref()
.ok()
.and_then(|c| c.cache.as_ref())
.and_then(|c| c.event_log_max_size.as_ref())
.and_then(|s| parse_size_checked(s, "[cache] event_log_max_size"))
.unwrap_or(DEFAULT_EVENT_LOG_MAX_SIZE);
let event_log_keep_lines = file_config
.as_ref()
.ok()
.and_then(|c| c.cache.as_ref())
.and_then(|c| c.event_log_keep_lines)
.unwrap_or(1000);
let compression_level = env_or_ignored("KACHE_COMPRESSION_LEVEL", ignore_env)
.ok()
.and_then(|s| s.parse::<i32>().ok())
.or_else(|| {
file_config
.as_ref()
.ok()
.and_then(|c| c.cache.as_ref())
.and_then(|c| c.compression_level)
})
.unwrap_or(3)
.clamp(1, 22);
let prefetch_enabled = env_or_ignored("KACHE_PREFETCH_ENABLED", ignore_env)
.map(|value| prefetch_enabled_from_env(&value))
.unwrap_or_else(|_| {
file_config
.as_ref()
.ok()
.and_then(|config| config.cache.as_ref())
.and_then(|cache| cache.prefetch_enabled)
.unwrap_or(DEFAULT_PREFETCH_ENABLED)
});
let explicit_remote_key_listing = env_or_ignored("KACHE_REMOTE_KEY_LISTING", ignore_env)
.ok()
.map(|value| prefetch_enabled_from_env(&value))
.or_else(|| {
file_config
.as_ref()
.ok()
.and_then(|config| config.cache.as_ref())
.and_then(|cache| cache.remote_key_listing)
});
let remote_key_cache_refresh_secs =
env_or_ignored("KACHE_REMOTE_KEY_CACHE_REFRESH_SECS", ignore_env)
.ok()
.and_then(|value| value.parse::<u64>().ok())
.or_else(|| {
file_config
.as_ref()
.ok()
.and_then(|config| config.cache.as_ref())
.and_then(|cache| cache.remote_key_cache_refresh_secs)
})
.unwrap_or(DEFAULT_REMOTE_KEY_CACHE_REFRESH_SECS);
let prefetch_max_keys = env_or_ignored("KACHE_PREFETCH_MAX_KEYS", ignore_env)
.ok()
.and_then(|s| s.parse::<u64>().ok())
.or_else(|| {
file_config
.as_ref()
.ok()
.and_then(|c| c.cache.as_ref())
.and_then(|c| c.prefetch_max_keys)
})
.unwrap_or(DEFAULT_PREFETCH_MAX_KEYS);
let prefetch_max_bytes = env_or_ignored("KACHE_PREFETCH_MAX_BYTES", ignore_env)
.ok()
.and_then(|s| parse_size_checked(&s, "KACHE_PREFETCH_MAX_BYTES"))
.or_else(|| {
file_config
.as_ref()
.ok()
.and_then(|c| c.cache.as_ref())
.and_then(|c| c.prefetch_max_bytes.as_ref())
.and_then(|s| parse_size_checked(s, "[cache] prefetch_max_bytes"))
})
.unwrap_or(DEFAULT_PREFETCH_MAX_BYTES);
let prefetch_deadline_secs = env_or_ignored("KACHE_PREFETCH_DEADLINE_SECS", ignore_env)
.ok()
.and_then(|s| s.parse::<u64>().ok())
.or_else(|| {
file_config
.as_ref()
.ok()
.and_then(|c| c.cache.as_ref())
.and_then(|c| c.prefetch_deadline_secs)
})
.unwrap_or(DEFAULT_PREFETCH_DEADLINE_SECS);
let min_store_compile_ms = env_or_ignored("KACHE_MIN_STORE_COMPILE_MS", ignore_env)
.ok()
.and_then(|s| s.parse::<u64>().ok())
.or_else(|| {
file_config
.as_ref()
.ok()
.and_then(|c| c.cache.as_ref())
.and_then(|c| c.min_store_compile_ms)
})
.unwrap_or(DEFAULT_MIN_STORE_COMPILE_MS);
let gc_max_age_hours = env_or_ignored("KACHE_GC_MAX_AGE_HOURS", ignore_env)
.ok()
.and_then(|s| s.parse::<u64>().ok())
.or_else(|| {
file_config
.as_ref()
.ok()
.and_then(|c| c.cache.as_ref())
.and_then(|c| c.gc_max_age_hours)
})
.unwrap_or(DEFAULT_GC_MAX_AGE_HOURS);
let s3_concurrency = env_or_ignored("KACHE_S3_CONCURRENCY", ignore_env)
.ok()
.and_then(|s| s.parse::<u32>().ok())
.or_else(|| {
file_config
.as_ref()
.ok()
.and_then(|c| c.cache.as_ref())
.and_then(|c| c.s3_concurrency)
})
.unwrap_or(16);
let daemon_idle_timeout_secs = env_or_ignored("KACHE_DAEMON_IDLE_TIMEOUT", ignore_env)
.ok()
.and_then(|s| s.parse::<u64>().ok())
.or_else(|| {
file_config
.as_ref()
.ok()
.and_then(|c| c.cache.as_ref())
.and_then(|c| c.daemon_idle_timeout_secs)
})
.unwrap_or(DEFAULT_DAEMON_IDLE_TIMEOUT_SECS);
let s3_pool_idle_secs = env_or_ignored("KACHE_S3_POOL_IDLE_SECS", ignore_env)
.ok()
.and_then(|s| s.parse::<u64>().ok())
.or_else(|| {
file_config
.as_ref()
.ok()
.and_then(|c| c.cache.as_ref())
.and_then(|c| c.s3_pool_idle_secs)
})
.unwrap_or(DEFAULT_S3_POOL_IDLE_SECS);
let remote_restore_timeout_secs =
env_or_ignored("KACHE_REMOTE_RESTORE_TIMEOUT_SECS", ignore_env)
.ok()
.and_then(|s| s.parse::<u64>().ok())
.or_else(|| {
file_config
.as_ref()
.ok()
.and_then(|c| c.cache.as_ref())
.and_then(|c| c.remote_restore_timeout_secs)
})
.unwrap_or(DEFAULT_REMOTE_RESTORE_TIMEOUT_SECS);
let remote_negative_ttl_secs = env_or_ignored("KACHE_REMOTE_NEGATIVE_TTL_SECS", ignore_env)
.ok()
.and_then(|s| s.parse::<u64>().ok())
.or_else(|| {
file_config
.as_ref()
.ok()
.and_then(|c| c.cache.as_ref())
.and_then(|c| c.remote_negative_ttl_secs)
})
.unwrap_or(DEFAULT_REMOTE_NEGATIVE_TTL_SECS);
let fallback = env_or_ignored("KACHE_FALLBACK", ignore_env)
.ok()
.or_else(|| {
file_config
.as_ref()
.ok()
.and_then(|c| c.cache.as_ref())
.and_then(|c| c.fallback.clone())
})
.map(|s| s.trim().to_string())
.filter(|s| {
!s.is_empty() && !s.eq_ignore_ascii_case("off") && !s.eq_ignore_ascii_case("none")
});
let key_salt = env_or_ignored("KACHE_KEY_SALT", ignore_env)
.ok()
.or_else(|| {
file_config
.as_ref()
.ok()
.and_then(|c| c.cache.as_ref())
.and_then(|c| c.key_salt.clone())
})
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
let cc_extra_allowlist_flags =
match env_or_ignored("KACHE_CC_EXTRA_ALLOWLIST_FLAGS", ignore_env) {
Ok(val) => normalize_cc_flags(val.split_whitespace().map(str::to_string)),
Err(_) => normalize_cc_flags(
file_config
.as_ref()
.ok()
.and_then(|c| c.cc.as_ref())
.and_then(|c| c.extra_allowlist_flags.clone())
.unwrap_or_default(),
),
};
let (path_only_env_vars, path_only_source) =
match env_or_ignored("KACHE_PATH_ONLY_ENV_VARS", ignore_env) {
Ok(val) => (
val.split([',', ' ', '\t', '\n'])
.filter(|p| !p.is_empty())
.map(str::to_string)
.collect(),
"KACHE_PATH_ONLY_ENV_VARS",
),
Err(_) => (
file_config
.as_ref()
.ok()
.and_then(|c| c.cache.as_ref())
.and_then(|c| c.path_only_env_vars.clone())
.unwrap_or_default(),
"[cache] path_only_env_vars",
),
};
let _inert = warn_inert_path_only_env_vars(&path_only_env_vars, path_only_source);
let incremental_crates = match env_or_ignored("KACHE_INCREMENTAL_CRATES", ignore_env) {
Ok(val) => {
normalize_incremental_crates(val.split([',', ' ', '\t', '\n']).map(str::to_string))
}
Err(_) => normalize_incremental_crates(
file_config
.as_ref()
.ok()
.and_then(|c| c.cache.as_ref())
.and_then(|c| c.incremental_crates.clone())
.unwrap_or_default(),
),
};
let key_env_vars = match env_or_ignored("KACHE_KEY_ENV_VARS", ignore_env) {
Ok(val) => normalize_key_env_vars(
val.split([',', ' ', '\t', '\n']).map(str::to_string),
"KACHE_KEY_ENV_VARS",
),
Err(_) => normalize_key_env_vars(
file_config
.as_ref()
.ok()
.and_then(|c| c.cache.as_ref())
.and_then(|c| c.key_env_vars.clone())
.unwrap_or_default(),
"[cache] key_env_vars",
),
};
let base_dirs = normalize_base_dirs(
file_config
.as_ref()
.ok()
.and_then(|c| c.paths.as_ref())
.and_then(|p| p.base_dirs.clone())
.unwrap_or_default(),
)?;
for (index, path) in base_dirs.iter().enumerate() {
tracing::info!(
target: "kache::config",
"[paths].base_dirs[{index}] {} -> <BASE_DIR_{index}> / \
/kache/base-dir-{index}",
path
);
}
let local_only = Self::local_only_enabled(&file_config);
let mut remote_readonly = Self::remote_readonly_enabled(&file_config);
if let Some(forced) = crate::policy::forced_remote_readonly() {
tracing::debug!(
reason = %forced.reason,
"remote writes suppressed by CI policy"
);
remote_readonly = true;
}
let modified_input_guard = Self::modified_input_guard_enabled(&file_config);
let input_predictions = Self::input_predictions_enabled(&file_config);
let record_sessions = Self::record_sessions_enabled(&file_config);
let windows_hardlink = Self::windows_hardlink_enabled(&file_config);
let shared_hardlink_restores = Self::shared_hardlink_restores_enabled(&file_config);
let deferred_discovery = Self::deferred_discovery_enabled(&file_config);
let out_dir_alias = Self::out_dir_alias_enabled(&file_config);
let deferred_durability = Self::deferred_durability_enabled(&file_config);
let daemon_publish = Self::daemon_publish_enabled(&file_config);
let project_rules = ProjectRules::from_file_config(&file_config);
let auto_gc = Self::auto_gc_enabled(&file_config);
let index_auto_compact = Self::index_auto_compact_enabled(&file_config);
let auto_clean_orphaned_targets = Self::auto_clean_orphaned_targets_enabled(&file_config);
let auto_share_target_files = Self::auto_share_target_files_enabled(&file_config);
let auto_clean_idle_targets_days = Self::auto_clean_idle_targets_days(&file_config);
let auto_recover_min_free_bytes = Self::auto_recover_min_free_bytes(&file_config);
let auto_recover_targets = Self::auto_recover_targets_enabled(&file_config);
let scheduler_memory_pressure = Self::scheduler_memory_pressure_enabled(&file_config);
let auto_clean_unused_units_days = Self::auto_clean_unused_units_days(&file_config);
let seed_new_targets = Self::seed_new_targets_enabled(&file_config);
let build_script_hermetic = Self::build_script_hermetic_enabled(&file_config);
let gc_evict_shared = Self::gc_evict_shared_enabled(&file_config);
let storage_layout_advice = Self::storage_layout_advice_enabled(&file_config);
let volume_stores = Self::load_volume_stores(&file_config, explicit_max_size);
let heartbeat_secs = env_or_ignored("KACHE_HEARTBEAT_SECS", ignore_env)
.ok()
.and_then(|s| s.parse::<u64>().ok())
.or_else(|| {
file_config
.as_ref()
.ok()
.and_then(|c| c.cache.as_ref())
.and_then(|c| c.heartbeat_secs)
})
.unwrap_or(DEFAULT_HEARTBEAT_SECS);
let explain_miss = Self::explain_miss_enabled(&file_config);
let scheduler = Self::scheduler_enabled(&file_config);
let test_lease = std::env::var_os(crate::scheduler::TEST_LEASE_ENV).map(PathBuf::from);
let (remote, remote_error) = if local_only {
(None, None)
} else {
match Self::load_remote_config(&file_config) {
Ok(remote) => (remote, None),
Err(error) => {
let reason = format!("{error:#}");
tracing::warn!(
%reason,
"remote cache configuration is unusable — continuing without a remote \
cache. Run `kache doctor` for details."
);
(None, Some(reason))
}
}
};
let remote_key_listing = explicit_remote_key_listing
.unwrap_or_else(|| default_remote_key_listing(remote.as_ref()));
let pull_request_prefix = if crate::policy::pull_request_job() {
remote.as_ref().and_then(|remote| {
if matches!(remote.backend, RemoteBackendConfig::Oci(_)) {
tracing::warn!(
"OCI pull request jobs stay read-only: tags in one repository share \
permissions, so pull_request_prefix cannot isolate writes"
);
return None;
}
let configured = env_or_ignored("KACHE_PULL_REQUEST_PREFIX", ignore_env)
.ok()
.or_else(|| {
file_config
.as_ref()
.ok()
.and_then(|config| config.cache.as_ref())
.and_then(|cache| cache.remote.as_ref())
.and_then(|remote| remote.pull_request_prefix.clone())
})?;
match pull_request_prefix_for(&remote.prefix, &configured) {
Ok(prefix) => prefix,
Err(error) => {
tracing::warn!("{error:#}; this pull request job stays read-only");
None
}
}
})
} else {
None
};
if pull_request_prefix.is_some() && !Self::remote_readonly_enabled(&file_config) {
remote_readonly = false;
}
Ok(Config {
cache_dir,
runtime_dir,
socket_path_override,
max_size,
remote,
remote_error,
disabled,
local_only,
remote_readonly,
readonly_store,
pull_request_prefix,
modified_input_guard,
input_predictions,
record_sessions,
windows_hardlink,
shared_hardlink_restores,
deferred_discovery,
out_dir_alias,
deferred_durability,
daemon_publish,
project_rules,
auto_gc,
index_auto_compact,
auto_clean_orphaned_targets,
auto_share_target_files,
auto_clean_idle_targets_days,
auto_recover_min_free_bytes,
auto_recover_targets,
scheduler_memory_pressure,
auto_clean_unused_units_days,
seed_new_targets,
build_script_hermetic,
gc_evict_shared,
storage_layout_advice,
volume_stores,
heartbeat_secs,
explain_miss,
scheduler,
test_lease,
cache_executables,
cache_cc_links,
trust_codegen_backends,
target_liveness,
clean_incremental,
preserve_incremental,
adaptive_incremental,
event_log_max_size,
event_log_keep_lines,
compression_level,
s3_concurrency,
prefetch_enabled,
remote_key_listing,
remote_key_cache_refresh_secs,
prefetch_max_keys,
prefetch_max_bytes,
prefetch_deadline_secs,
min_store_compile_ms,
gc_max_age_hours,
daemon_idle_timeout_secs,
s3_pool_idle_secs,
remote_restore_timeout_secs,
remote_negative_ttl_secs,
fallback,
key_salt,
path_only_env_vars,
incremental_crates,
key_env_vars,
base_dirs,
cc_extra_allowlist_flags,
})
}
pub(crate) fn load_raw_file_config() -> (FileConfig, bool) {
Self::load_raw_file_config_from(&resolve_config_path())
}
pub(crate) fn load_raw_file_config_from(config_path: &std::path::Path) -> (FileConfig, bool) {
let existed = config_path.exists();
if !existed {
return (FileConfig::default(), false);
}
match std::fs::read_to_string(config_path) {
Ok(content) => match toml::from_str(&content) {
Ok(cfg) => (cfg, true),
Err(_) => (FileConfig::default(), true),
},
Err(_) => (FileConfig::default(), true),
}
}
pub(crate) fn save_file_config_to(config: &FileConfig, path: &std::path::Path) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).context("creating config directory")?;
}
let content = toml::to_string_pretty(config).context("serializing config")?;
std::fs::write(path, content).context("writing config file")?;
Ok(())
}
fn load_file_config_with_provenance(
config_path: PathBuf,
) -> (Result<FileConfig>, ConfigFileProvenance) {
let host = read_host_config_snapshot();
let host_layer = host_config_layer(host.as_ref());
match std::fs::read(&config_path) {
Ok(bytes) => {
let provenance = ConfigFileProvenance::from_snapshot(
config_path,
ConfigFileState::Present,
&bytes,
host.as_ref(),
);
let parsed = std::str::from_utf8(&bytes)
.context("reading kache config file as UTF-8")
.and_then(|content| parse_layered_file_config(content, host_layer));
(parsed, provenance)
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
let provenance = ConfigFileProvenance::from_snapshot(
config_path,
ConfigFileState::Absent,
&[],
host.as_ref(),
);
let host_only = host_layer
.and_then(|mut table| {
yield_host_tables_to_env(&mut table, None);
toml::Value::Table(table).try_into().ok()
})
.unwrap_or_default();
(Ok(host_only), provenance)
}
Err(error) => {
let provenance = ConfigFileProvenance::from_snapshot(
config_path,
ConfigFileState::Unreadable,
&[],
host.as_ref(),
);
(Err(error).context("reading kache config file"), provenance)
}
}
}
pub(crate) fn load_file_config() -> Result<FileConfig> {
let path = normalize_config_path(resolve_config_path());
Self::load_file_config_with_provenance(path).0
}
fn load_gcs_remote_config(file_remote: Option<&RemoteFileConfig>) -> Result<RemoteConfig> {
let field = |value: Option<&String>| {
value
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
};
let inapplicable = file_remote.is_some_and(|r| {
[
&r.region,
&r.profile,
&r.user_agent,
&r.path,
&r.atomic_write_dir,
]
.into_iter()
.any(|value| field(value.as_ref()).is_some())
});
if inapplicable {
anyhow::bail!(
"[cache.remote] type = \"gcs\" takes bucket, prefix and endpoint only; \
region, profile, user_agent, path and atomic_write_dir do not apply"
);
}
let bucket = field(file_remote.and_then(|r| r.bucket.as_ref()))
.context("[cache.remote] type = \"gcs\" requires a non-empty bucket")?;
let prefix = file_remote
.and_then(|r| r.prefix.clone())
.unwrap_or_else(|| "artifacts".to_string());
Ok(RemoteConfig {
prefix: resolve_remote_prefix(&prefix)?,
backend: RemoteBackendConfig::Gcs(GcsRemoteConfig {
bucket,
endpoint: field(file_remote.and_then(|r| r.endpoint.as_ref())),
}),
})
}
fn load_oci_remote_config(remote: &RemoteFileConfig) -> Result<RemoteConfig> {
if [
&remote.bucket,
&remote.endpoint,
&remote.region,
&remote.profile,
&remote.user_agent,
&remote.path,
&remote.atomic_write_dir,
]
.into_iter()
.any(|field| {
field
.as_deref()
.is_some_and(|value| !value.trim().is_empty())
}) {
anyhow::bail!(
"[cache.remote] type = \"oci\" takes repository, prefix and insecure only"
);
}
let repository = remote
.repository
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.context("[cache.remote] type = \"oci\" requires a non-empty repository")?;
let config = OciRemoteConfig {
repository: repository.to_string(),
insecure: remote.insecure.unwrap_or(false),
};
config.reference()?;
Ok(RemoteConfig {
prefix: resolve_remote_prefix(remote.prefix.as_deref().unwrap_or("artifacts"))?,
backend: RemoteBackendConfig::Oci(config),
})
}
fn load_remote_config(file_config: &Result<FileConfig>) -> Result<Option<RemoteConfig>> {
let ignore_env = Self::ignore_env_enabled(file_config);
let file_remote = file_config
.as_ref()
.ok()
.and_then(|c| c.cache.as_ref())
.and_then(|c| c.remote.as_ref());
let configured_type = file_remote
.and_then(|r| r._type.as_deref())
.map(str::trim)
.filter(|v| !v.is_empty())
.map(str::to_ascii_lowercase);
let file_has_s3_fields = file_remote.is_some_and(|r| {
[&r.bucket, &r.endpoint, &r.region, &r.profile, &r.user_agent]
.into_iter()
.any(|v| v.as_deref().is_some_and(|v| !v.trim().is_empty()))
});
let file_has_filesystem_fields = file_remote.is_some_and(|r| {
[&r.path, &r.atomic_write_dir]
.into_iter()
.any(|v| v.as_deref().is_some_and(|v| !v.trim().is_empty()))
});
if let Some(remote) = file_remote {
if configured_type.as_deref() == Some("oci") {
return Self::load_oci_remote_config(remote).map(Some);
}
if remote.repository.is_some() || remote.insecure.is_some() {
anyhow::bail!("repository and insecure require [cache.remote] type = \"oci\"");
}
}
if configured_type.as_deref() == Some("gcs") {
return Self::load_gcs_remote_config(file_remote).map(Some);
}
let use_filesystem = match configured_type.as_deref() {
Some("filesystem" | "fs") => {
if file_has_s3_fields {
anyhow::bail!(
"[cache.remote] type = \"filesystem\" cannot include S3 bucket, endpoint, region, profile, or user_agent"
);
}
true
}
Some("s3") => {
if file_has_filesystem_fields {
anyhow::bail!(
"[cache.remote] type = \"s3\" cannot include path or atomic_write_dir"
);
}
false
}
Some(other) => {
anyhow::bail!(
"unsupported [cache.remote] type {other:?}; supported types are \"s3\", \"gcs\", \"filesystem\" and \"oci\""
);
}
None if file_has_s3_fields && file_has_filesystem_fields => {
anyhow::bail!(
"[cache.remote] mixes S3 and filesystem fields; set type = \"s3\" or type = \"filesystem\""
);
}
None => file_has_filesystem_fields,
};
if use_filesystem {
let path = file_remote
.and_then(|r| r.path.as_deref())
.map(str::trim)
.filter(|v| !v.is_empty())
.with_context(
|| "[cache.remote] type = \"filesystem\" requires a non-empty path",
)?;
let root = shellexpand(path);
if !root.is_absolute() {
anyhow::bail!(
"[cache.remote] filesystem path must be absolute: {}",
root.display()
);
}
let atomic_write_dir = file_remote
.and_then(|r| r.atomic_write_dir.as_deref())
.map(str::trim)
.filter(|v| !v.is_empty())
.map(shellexpand)
.unwrap_or_else(|| root.join(".kache-tmp"));
if !atomic_write_dir.is_absolute() {
anyhow::bail!(
"[cache.remote] atomic_write_dir must be absolute: {}",
atomic_write_dir.display()
);
}
let prefix = file_remote
.and_then(|r| r.prefix.clone())
.unwrap_or_else(|| "artifacts".to_string());
let prefix = resolve_remote_prefix(&prefix)?;
if prefix.contains(':') {
anyhow::bail!(
"[cache.remote] filesystem prefix cannot contain ':' because it can escape \
the configured root or address an alternate data stream on Windows: {prefix:?}"
);
}
if let Some(problem) = filesystem_staging_problem(&root, &atomic_write_dir, &prefix) {
anyhow::bail!("{problem}");
}
return Ok(Some(RemoteConfig {
prefix,
backend: RemoteBackendConfig::Filesystem(FilesystemRemoteConfig {
root,
atomic_write_dir,
}),
}));
}
let env_bucket = env_or_ignored("KACHE_S3_BUCKET", ignore_env).ok();
let file_bucket_is_usable = file_remote
.and_then(|r| r.bucket.as_deref())
.is_some_and(|bucket| !bucket.trim().is_empty());
if let Some(env_bucket) = &env_bucket
&& env_bucket.trim().is_empty()
&& file_bucket_is_usable
{
tracing::warn!(
"KACHE_S3_BUCKET is set but empty — treating the remote cache as disabled rather \
than falling back to the configured bucket"
);
return Ok(None);
}
let bucket = env_bucket
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.or_else(|| {
file_remote
.and_then(|r| r.bucket.as_deref())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
});
let Some(bucket) = bucket else {
if configured_type.as_deref() == Some("s3") {
anyhow::bail!("[cache.remote] type = \"s3\" requires a non-empty bucket");
}
return Ok(None);
};
let endpoint = env_or_ignored("KACHE_S3_ENDPOINT", ignore_env)
.ok()
.or_else(|| file_remote.and_then(|r| r.endpoint.clone()));
let region = env_or_ignored("KACHE_S3_REGION", ignore_env)
.ok()
.or_else(|| file_remote.and_then(|r| r.region.clone()))
.unwrap_or_else(|| "us-east-1".to_string());
let prefix = env_or_ignored("KACHE_S3_PREFIX", ignore_env)
.ok()
.or_else(|| file_remote.and_then(|r| r.prefix.clone()))
.unwrap_or_else(|| "artifacts".to_string());
let prefix = resolve_remote_prefix(&prefix)?;
let profile = env_or_ignored("KACHE_S3_PROFILE", ignore_env)
.ok()
.or_else(|| file_remote.and_then(|r| r.profile.clone()))
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
let user_agent = env_or_ignored("KACHE_S3_USER_AGENT", ignore_env)
.ok()
.or_else(|| file_remote.and_then(|r| r.user_agent.clone()))
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
Ok(Some(RemoteConfig {
prefix,
backend: RemoteBackendConfig::S3(S3RemoteConfig {
bucket,
endpoint,
region,
profile,
user_agent,
}),
}))
}
fn ignore_env_enabled(file_config: &Result<FileConfig>) -> bool {
file_config
.as_ref()
.ok()
.and_then(|c| c.cache.as_ref())
.and_then(|c| c.ignore_env)
.unwrap_or(false)
}
fn local_only_enabled(file_config: &Result<FileConfig>) -> bool {
let ignore_env = Self::ignore_env_enabled(file_config);
if let Ok(v) = env_or_ignored("KACHE_LOCAL_ONLY", ignore_env) {
return v == "1" || v.eq_ignore_ascii_case("true");
}
file_config
.as_ref()
.ok()
.and_then(|c| c.cache.as_ref())
.and_then(|c| c.local_only)
.unwrap_or(false)
}
fn remote_readonly_enabled(file_config: &Result<FileConfig>) -> bool {
let ignore_env = Self::ignore_env_enabled(file_config);
if let Ok(v) = env_or_ignored("KACHE_REMOTE_READONLY", ignore_env) {
return v == "1" || v.eq_ignore_ascii_case("true");
}
file_config
.as_ref()
.ok()
.and_then(|c| c.cache.as_ref())
.and_then(|c| c.remote_readonly)
.unwrap_or(false)
}
fn modified_input_guard_enabled(file_config: &Result<FileConfig>) -> bool {
let ignore_env = Self::ignore_env_enabled(file_config);
if let Ok(v) = env_or_ignored("KACHE_MODIFIED_INPUT_GUARD", ignore_env) {
return v == "1" || v.eq_ignore_ascii_case("true");
}
file_config
.as_ref()
.ok()
.and_then(|c| c.cache.as_ref())
.and_then(|c| c.modified_input_guard)
.unwrap_or(false)
}
fn input_predictions_enabled(file_config: &Result<FileConfig>) -> bool {
let ignore_env = Self::ignore_env_enabled(file_config);
if let Ok(v) = env_or_ignored("KACHE_INPUT_PREDICTIONS", ignore_env) {
return v == "1" || v.eq_ignore_ascii_case("true");
}
file_config
.as_ref()
.ok()
.and_then(|c| c.cache.as_ref())
.and_then(|c| c.input_predictions)
.unwrap_or(true)
}
fn record_sessions_enabled(file_config: &Result<FileConfig>) -> bool {
let ignore_env = Self::ignore_env_enabled(file_config);
if let Ok(v) = env_or_ignored("KACHE_RECORD_SESSIONS", ignore_env) {
return v == "1" || v.eq_ignore_ascii_case("true");
}
file_config
.as_ref()
.ok()
.and_then(|c| c.cache.as_ref())
.and_then(|c| c.record_sessions)
.unwrap_or(false)
}
fn deferred_discovery_enabled(file_config: &Result<FileConfig>) -> bool {
let ignore_env = Self::ignore_env_enabled(file_config);
if let Ok(v) = env_or_ignored("KACHE_DEFERRED_DISCOVERY", ignore_env) {
return !(v == "0" || v.eq_ignore_ascii_case("false"));
}
file_config
.as_ref()
.ok()
.and_then(|c| c.cache.as_ref())
.and_then(|c| c.deferred_discovery)
.unwrap_or(true)
}
fn daemon_publish_enabled(file_config: &Result<FileConfig>) -> bool {
let ignore_env = Self::ignore_env_enabled(file_config);
if let Ok(v) = env_or_ignored("KACHE_DAEMON_PUBLISH", ignore_env) {
return !(v == "0" || v.eq_ignore_ascii_case("false"));
}
file_config
.as_ref()
.ok()
.and_then(|c| c.cache.as_ref())
.and_then(|c| c.daemon_publish)
.unwrap_or(true)
}
fn out_dir_alias_enabled(file_config: &Result<FileConfig>) -> bool {
let ignore_env = Self::ignore_env_enabled(file_config);
out_dir_alias_setting(
env_or_ignored("KACHE_OUT_DIR_ALIAS", ignore_env)
.ok()
.as_deref(),
file_config
.as_ref()
.ok()
.and_then(|c| c.cache.as_ref())
.and_then(|c| c.out_dir_alias),
)
}
fn deferred_durability_enabled(file_config: &Result<FileConfig>) -> bool {
let ignore_env = Self::ignore_env_enabled(file_config);
if let Ok(v) = env_or_ignored("KACHE_DEFERRED_DURABILITY", ignore_env) {
return !(v == "0" || v.eq_ignore_ascii_case("false"));
}
file_config
.as_ref()
.ok()
.and_then(|c| c.cache.as_ref())
.and_then(|c| c.deferred_durability)
.unwrap_or(true)
}
fn shared_hardlink_restores_enabled(file_config: &Result<FileConfig>) -> bool {
let ignore_env = Self::ignore_env_enabled(file_config);
if let Ok(v) = env_or_ignored("KACHE_SHARED_HARDLINK_RESTORES", ignore_env) {
return v == "1" || v.eq_ignore_ascii_case("true");
}
file_config
.as_ref()
.ok()
.and_then(|c| c.cache.as_ref())
.and_then(|c| c.shared_hardlink_restores)
.unwrap_or(false)
}
fn windows_hardlink_enabled(file_config: &Result<FileConfig>) -> bool {
let ignore_env = Self::ignore_env_enabled(file_config);
if let Ok(v) = env_or_ignored("KACHE_WINDOWS_HARDLINK", ignore_env) {
return v == "1" || v.eq_ignore_ascii_case("true");
}
file_config
.as_ref()
.ok()
.and_then(|c| c.cache.as_ref())
.and_then(|c| c.windows_hardlink)
.unwrap_or(false)
}
fn auto_gc_enabled(file_config: &Result<FileConfig>) -> bool {
let ignore_env = Self::ignore_env_enabled(file_config);
if let Ok(v) = env_or_ignored("KACHE_AUTO_GC", ignore_env) {
return v != "0" && !v.eq_ignore_ascii_case("false");
}
file_config
.as_ref()
.ok()
.and_then(|c| c.cache.as_ref())
.and_then(|c| c.auto_gc)
.unwrap_or(true)
}
fn index_auto_compact_enabled(file_config: &Result<FileConfig>) -> bool {
let ignore_env = Self::ignore_env_enabled(file_config);
if let Ok(v) = env_or_ignored("KACHE_INDEX_AUTO_COMPACT", ignore_env) {
return v != "0" && !v.eq_ignore_ascii_case("false");
}
file_config
.as_ref()
.ok()
.and_then(|c| c.cache.as_ref())
.and_then(|c| c.index_auto_compact)
.unwrap_or(true)
}
fn auto_clean_orphaned_targets_enabled(file_config: &Result<FileConfig>) -> bool {
let ignore_env = Self::ignore_env_enabled(file_config);
if let Ok(v) = env_or_ignored("KACHE_AUTO_CLEAN_ORPHANED_TARGETS", ignore_env) {
return v != "0" && !v.eq_ignore_ascii_case("false");
}
file_config
.as_ref()
.ok()
.and_then(|c| c.cache.as_ref())
.and_then(|c| c.auto_clean_orphaned_targets)
.unwrap_or(true)
}
fn auto_share_target_files_enabled(file_config: &Result<FileConfig>) -> bool {
let ignore_env = Self::ignore_env_enabled(file_config);
if let Ok(v) = env_or_ignored("KACHE_AUTO_SHARE_TARGET_FILES", ignore_env) {
return v != "0" && !v.eq_ignore_ascii_case("false");
}
file_config
.as_ref()
.ok()
.and_then(|c| c.cache.as_ref())
.and_then(|c| c.auto_share_target_files)
.unwrap_or(true)
}
fn seed_new_targets_enabled(file_config: &Result<FileConfig>) -> bool {
let ignore_env = Self::ignore_env_enabled(file_config);
if let Ok(v) = env_or_ignored("KACHE_SEED_NEW_TARGETS", ignore_env) {
return v != "0" && !v.eq_ignore_ascii_case("false");
}
file_config
.as_ref()
.ok()
.and_then(|c| c.cache.as_ref())
.and_then(|c| c.seed_new_targets)
.unwrap_or(true)
}
fn build_script_hermetic_enabled(file_config: &Result<FileConfig>) -> bool {
let ignore_env = Self::ignore_env_enabled(file_config);
if let Ok(v) = env_or_ignored("KACHE_BUILD_SCRIPT_HERMETIC", ignore_env) {
return v == "1" || v == "true";
}
file_config
.as_ref()
.ok()
.and_then(|c| c.cache.as_ref())
.and_then(|c| c.build_script_hermetic)
.unwrap_or(false)
}
fn auto_clean_unused_units_days(file_config: &Result<FileConfig>) -> u64 {
let ignore_env = Self::ignore_env_enabled(file_config);
env_or_ignored("KACHE_AUTO_CLEAN_UNUSED_UNITS_DAYS", ignore_env)
.ok()
.and_then(|s| s.parse::<u64>().ok())
.or_else(|| {
file_config
.as_ref()
.ok()
.and_then(|c| c.cache.as_ref())
.and_then(|c| c.auto_clean_unused_units_days)
})
.unwrap_or(DEFAULT_UNUSED_UNITS_DAYS)
}
fn scheduler_memory_pressure_enabled(file_config: &Result<FileConfig>) -> bool {
let ignore_env = Self::ignore_env_enabled(file_config);
if let Ok(v) = env_or_ignored("KACHE_SCHEDULER_MEMORY_PRESSURE", ignore_env) {
return v != "0" && !v.eq_ignore_ascii_case("false");
}
file_config
.as_ref()
.ok()
.and_then(|c| c.cache.as_ref())
.and_then(|c| c.scheduler_memory_pressure)
.unwrap_or(true)
}
fn auto_clean_idle_targets_days(file_config: &Result<FileConfig>) -> u64 {
let ignore_env = Self::ignore_env_enabled(file_config);
env_or_ignored("KACHE_AUTO_CLEAN_IDLE_TARGETS_DAYS", ignore_env)
.ok()
.and_then(|s| s.parse::<u64>().ok())
.or_else(|| {
file_config
.as_ref()
.ok()
.and_then(|c| c.cache.as_ref())
.and_then(|c| c.auto_clean_idle_targets_days)
})
.unwrap_or(0)
}
fn auto_recover_min_free_bytes(file_config: &Result<FileConfig>) -> u64 {
let ignore_env = Self::ignore_env_enabled(file_config);
env_or_ignored("KACHE_AUTO_RECOVER_MIN_FREE_BYTES", ignore_env)
.ok()
.and_then(|s| s.parse::<u64>().ok())
.or_else(|| {
file_config
.as_ref()
.ok()
.and_then(|c| c.cache.as_ref())
.and_then(|c| c.auto_recover_min_free_bytes)
})
.unwrap_or_else(|| {
default_recovery_floor(crate::policy::running_in_ci_with(|name| {
std::env::var(name).ok()
}))
})
}
fn auto_recover_targets_enabled(file_config: &Result<FileConfig>) -> bool {
let ignore_env = Self::ignore_env_enabled(file_config);
if let Ok(v) = env_or_ignored("KACHE_AUTO_RECOVER_TARGETS", ignore_env) {
return v == "1" || v.eq_ignore_ascii_case("true");
}
file_config
.as_ref()
.ok()
.and_then(|c| c.cache.as_ref())
.and_then(|c| c.auto_recover_targets)
.unwrap_or(false)
}
fn gc_evict_shared_enabled(file_config: &Result<FileConfig>) -> bool {
let ignore_env = Self::ignore_env_enabled(file_config);
if let Ok(v) = env_or_ignored("KACHE_GC_EVICT_SHARED", ignore_env) {
return v == "1" || v.eq_ignore_ascii_case("true");
}
file_config
.as_ref()
.ok()
.and_then(|c| c.cache.as_ref())
.and_then(|c| c.gc_evict_shared)
.unwrap_or(false)
}
fn storage_layout_advice_enabled(file_config: &Result<FileConfig>) -> bool {
let ignore_env = Self::ignore_env_enabled(file_config);
if let Ok(v) = env_or_ignored("KACHE_STORAGE_LAYOUT_ADVICE", ignore_env) {
return v != "0" && !v.eq_ignore_ascii_case("false");
}
file_config
.as_ref()
.ok()
.and_then(|c| c.cache.as_ref())
.and_then(|c| c.storage_layout_advice)
.unwrap_or(true)
}
fn explain_miss_enabled(file_config: &Result<FileConfig>) -> bool {
let ignore_env = Self::ignore_env_enabled(file_config);
if let Ok(v) = env_or_ignored("KACHE_EXPLAIN_MISS", ignore_env) {
return v == "1" || v.eq_ignore_ascii_case("true");
}
file_config
.as_ref()
.ok()
.and_then(|c| c.cache.as_ref())
.and_then(|c| c.explain_miss)
.unwrap_or(false)
}
fn scheduler_enabled(file_config: &Result<FileConfig>) -> bool {
let ignore_env = Self::ignore_env_enabled(file_config);
if let Ok(v) = env_or_ignored("KACHE_SCHEDULER", ignore_env) {
return v != "0" && !v.eq_ignore_ascii_case("false");
}
file_config
.as_ref()
.ok()
.and_then(|c| c.cache.as_ref())
.and_then(|c| c.scheduler)
.unwrap_or(true)
}
pub fn load_planner_config() -> Option<PlannerConfig> {
let file_config = Self::load_file_config();
let ignore_env = Self::ignore_env_enabled(&file_config);
if Self::local_only_enabled(&file_config) {
return None;
}
let endpoint = env_or_ignored("KACHE_PLANNER_ENDPOINT", ignore_env)
.ok()
.or_else(|| {
file_config
.as_ref()
.ok()
.and_then(|c| c.cache.as_ref())
.and_then(|c| c.planner.as_ref())
.and_then(|c| c.endpoint.clone())
})
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())?;
let timeout_ms = env_or_ignored("KACHE_PLANNER_TIMEOUT_MS", ignore_env)
.ok()
.and_then(|s| s.parse::<u64>().ok())
.or_else(|| {
file_config
.as_ref()
.ok()
.and_then(|c| c.cache.as_ref())
.and_then(|c| c.planner.as_ref())
.and_then(|c| c.timeout_ms)
})
.unwrap_or(DEFAULT_PLANNER_TIMEOUT_MS);
let token = env_or_ignored("KACHE_PLANNER_TOKEN", ignore_env)
.ok()
.or_else(|| {
file_config
.as_ref()
.ok()
.and_then(|c| c.cache.as_ref())
.and_then(|c| c.planner.as_ref())
.and_then(|c| c.token.clone())
})
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
Some(PlannerConfig {
endpoint,
timeout_ms,
token,
})
}
pub fn store_dir(&self) -> PathBuf {
self.cache_dir.join("store")
}
pub fn volume_store_for(&self, path: &Path) -> Option<&Path> {
Self::match_volume_store(&self.volume_stores, &absolutize_volume_path(path))
}
pub fn cache_dir_for_path(&self, path: &Path) -> &Path {
self.volume_store_for(path).unwrap_or(&self.cache_dir)
}
pub fn routed_for_path(&self, path: &Path) -> Config {
let dir = self.cache_dir_for_path(path);
if dir == self.cache_dir.as_path() {
return self.clone();
}
let mut routed = self.clone();
routed.cache_dir = dir.to_path_buf();
routed
}
pub(crate) fn for_volume_store(
&self,
shard: &VolumeStore,
filesystem_bytes: impl FnOnce(&Path) -> Option<u64>,
) -> Config {
let mut swept = self.clone();
swept.cache_dir = shard.store.clone();
swept.max_size = shard
.max_size
.unwrap_or_else(|| disk_share_budget(filesystem_bytes(&shard.store)));
swept
}
pub(crate) fn for_store_dir(
&self,
cache_dir: &Path,
filesystem_bytes: impl FnOnce(&Path) -> Option<u64>,
) -> Config {
match self
.volume_stores
.iter()
.find(|shard| shard.store == cache_dir)
{
Some(shard) => self.for_volume_store(shard, filesystem_bytes),
None => self.clone(),
}
}
fn ensure_trailing_sep(mut text: String, sep: char) -> String {
if !text.ends_with(sep) {
text.push(sep);
}
text
}
fn normalize_volume_root(root: &str) -> Option<String> {
let root = root.trim();
if root.is_empty() {
return None;
}
#[cfg(windows)]
{
Some(Self::ensure_trailing_sep(
root.replace('/', "\\").to_uppercase(),
'\\',
))
}
#[cfg(not(windows))]
{
if !root.starts_with('/') {
return None;
}
let trimmed = root.trim_end_matches('/');
if trimmed.is_empty() {
Some("/".to_string())
} else {
Some(Self::ensure_trailing_sep(trimmed.to_string(), '/'))
}
}
}
fn match_volume_store<'a>(shards: &'a [VolumeStore], path: &Path) -> Option<&'a Path> {
let candidate = Self::normalize_volume_root(&path.to_string_lossy())?;
shards
.iter()
.filter(|shard| candidate.starts_with(&shard.volume))
.max_by_key(|shard| shard.volume.len())
.map(|shard| shard.store.as_path())
}
fn load_volume_stores(
file_config: &Result<FileConfig>,
explicit_max_size: Option<u64>,
) -> Vec<VolumeStore> {
let Some(map) = file_config
.as_ref()
.ok()
.and_then(|c| c.cache.as_ref())
.and_then(|c| c.volumes.as_ref())
else {
return Vec::new();
};
let mut shards: Vec<VolumeStore> = map
.iter()
.filter_map(|(volume, store)| {
let volume = Self::normalize_volume_root(volume)?;
if store.trim().is_empty() {
tracing::warn!(
"[cache.volumes] entry for {volume} has an empty store dir; ignoring"
);
return None;
}
Some(VolumeStore {
volume,
store: PathBuf::from(store),
max_size: explicit_max_size,
})
})
.collect();
shards.sort_by(|a, b| (&a.volume, &a.store).cmp(&(&b.volume, &b.store)));
let mut deduped: Vec<VolumeStore> = Vec::with_capacity(shards.len());
for shard in shards {
match deduped.last() {
Some(last) if last.volume == shard.volume => {
tracing::warn!(
"[cache.volumes] volume {} is mapped twice; keeping {}",
shard.volume,
last.store.display()
);
}
_ => deduped.push(shard),
}
}
deduped
}
pub(crate) fn upload_spool_dir(&self) -> PathBuf {
match self.pull_request_scope() {
Some(scope) => self.cache_dir.join(format!("upload-queue-{scope}")),
None => self.cache_dir.join("upload-queue"),
}
}
fn pull_request_scope(&self) -> Option<String> {
self.pull_request_prefix.as_ref().map(|prefix| {
let hash = blake3::hash(prefix.as_bytes()).to_hex();
format!("pr-{}", &hash[..8])
})
}
pub fn index_db_path(&self) -> PathBuf {
self.cache_dir.join("index.db")
}
pub fn event_log_path(&self) -> PathBuf {
self.runtime_dir.join("events.jsonl")
}
pub fn transfer_log_path(&self) -> PathBuf {
self.runtime_dir.join("transfers.jsonl")
}
pub fn summary_log_path(&self) -> PathBuf {
self.runtime_dir.join("summaries.jsonl")
}
pub fn socket_path(&self) -> PathBuf {
let socket = self
.socket_path_override
.as_ref()
.and_then(|path| resolve_socket_path_override(Some(path.as_os_str().to_owned())))
.unwrap_or_else(|| self.runtime_dir.join("daemon.sock"));
match self.pull_request_scope() {
Some(scope) => scoped_socket_path(&socket, &scope),
None => socket,
}
}
pub fn source_excluded(source_path: &Path, roots: &[PathBuf]) -> bool {
ProjectRules::from_file_config(&Self::load_file_config())
.source_excluded(source_path, roots)
}
pub fn user_bypass_reason(crate_name: &str, argv: &[String]) -> Option<String> {
ProjectRules::from_file_config(&Self::load_file_config())
.user_bypass_reason(crate_name, argv)
}
fn user_bypass_reason_with(
crate_name: &str,
argv: &[String],
crates: &[String],
argv_rules: &[String],
env_rules: &[String],
lookup_env: impl Fn(&str) -> Option<String>,
) -> Option<String> {
if let Some(rule) = crates
.iter()
.find(|rule| !rule.is_empty() && *rule == crate_name)
{
return Some(format!("bypass rule: crate {rule}"));
}
if let Some(rule) = argv_rules
.iter()
.filter(|rule| !rule.is_empty())
.find(|rule| argv.iter().any(|arg| arg.contains(rule.as_str())))
{
return Some(format!("bypass rule: argv contains {rule}"));
}
for rule in env_rules.iter().filter(|rule| !rule.is_empty()) {
let fired = match rule.split_once('=') {
Some((name, want)) => lookup_env(name).is_some_and(|got| got == want),
None => lookup_env(rule).is_some(),
};
if fired {
return Some(format!("bypass rule: env {rule}"));
}
}
None
}
}
#[derive(Debug, Clone, Default, PartialEq)]
pub(crate) struct ProjectRules {
pub(crate) exclude: Vec<String>,
pub(crate) bypass_crates: Vec<String>,
pub(crate) bypass_argv: Vec<String>,
pub(crate) bypass_env: Vec<String>,
}
impl ProjectRules {
fn from_file_config(file_config: &Result<FileConfig>) -> Self {
let cache = file_config.as_ref().ok().and_then(|c| c.cache.as_ref());
let list = |pick: fn(&CacheFileConfig) -> Option<&Vec<String>>| -> Vec<String> {
cache
.and_then(pick)
.map(|rules| {
rules
.iter()
.map(|p| p.trim().to_string())
.filter(|p| !p.is_empty())
.collect()
})
.unwrap_or_default()
};
Self {
exclude: list(|c| c.exclude.as_ref()),
bypass_crates: list(|c| c.bypass_crates.as_ref()),
bypass_argv: list(|c| c.bypass_argv.as_ref()),
bypass_env: list(|c| c.bypass_env.as_ref()),
}
}
pub(crate) fn source_excluded(&self, source_path: &Path, roots: &[PathBuf]) -> bool {
source_excluded_by_patterns(&self.exclude, source_path, roots)
}
pub(crate) fn user_bypass_reason(&self, crate_name: &str, argv: &[String]) -> Option<String> {
Config::user_bypass_reason_with(
crate_name,
argv,
&self.bypass_crates,
&self.bypass_argv,
&self.bypass_env,
|name| std::env::var(name).ok(),
)
}
}
fn source_excluded_by_patterns(patterns: &[String], source_path: &Path, roots: &[PathBuf]) -> bool {
if patterns.is_empty() {
return false;
}
let candidates = source_candidates(source_path, roots);
patterns
.iter()
.any(|pattern| exclude_pattern_matches(pattern, &candidates))
}
pub(crate) fn default_cache_executables() -> bool {
cfg!(target_os = "linux") || cfg!(target_os = "macos")
}
pub(crate) fn probe_memo_dir() -> PathBuf {
std::env::var_os("KACHE_CACHE_DIR")
.map(PathBuf::from)
.unwrap_or_else(default_cache_dir)
.join("probes")
}
pub(crate) fn default_cache_dir() -> PathBuf {
dirs::cache_dir()
.unwrap_or_else(|| PathBuf::from("/tmp"))
.join("kache")
}
const PROJECT_CONFIG_NAME: &str = ".kache.toml";
pub(crate) const HOST_CONFIG_PATH: &str = "/etc/kache/config.toml";
const HOST_ATOMIC_TABLES: &[&[&str]] = &[
&["cache", "remote"],
&["cache", "planner"],
&["cache", "volumes"],
];
const HOST_EXCLUDED_KEYS: &[(&[&str], &str)] = &[
(&["workspace"], "only a project's .kache.toml may set it"),
(
&["cache", "ignore_env"],
"environment variables always win over the host file",
),
];
pub(crate) fn host_config_path() -> Option<PathBuf> {
#[cfg(test)]
if let Some(path) = HOST_CONFIG_FOR_TEST.with(|path| path.borrow().clone()) {
return Some(path);
}
match std::env::var("KACHE_HOST_CONFIG") {
Ok(value) if value.is_empty() => None,
Ok(value) => Some(shellexpand(&value)),
#[cfg(test)]
Err(_) => None,
#[cfg(not(test))]
Err(_) => Some(PathBuf::from(HOST_CONFIG_PATH)),
}
}
#[cfg(test)]
thread_local! {
static HOST_CONFIG_FOR_TEST: std::cell::RefCell<Option<PathBuf>> =
const { std::cell::RefCell::new(None) };
}
#[cfg(test)]
pub(crate) struct HostConfigForTest(Option<PathBuf>);
#[cfg(test)]
impl Drop for HostConfigForTest {
fn drop(&mut self) {
let previous = self.0.take();
HOST_CONFIG_FOR_TEST.with(|path| *path.borrow_mut() = previous);
}
}
#[cfg(test)]
pub(crate) fn set_host_config_for_test(path: &std::path::Path) -> HostConfigForTest {
HostConfigForTest(HOST_CONFIG_FOR_TEST.with(|current| current.replace(Some(path.into()))))
}
#[derive(Debug, Clone)]
struct HostConfigSnapshot {
path: PathBuf,
state: ConfigFileState,
bytes: Vec<u8>,
}
fn read_host_config_snapshot() -> Option<HostConfigSnapshot> {
let path = host_config_path()?;
match std::fs::read(&path) {
Ok(bytes) => Some(HostConfigSnapshot {
path,
state: ConfigFileState::Present,
bytes,
}),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
Err(_) => Some(HostConfigSnapshot {
path,
state: ConfigFileState::Unreadable,
bytes: Vec::new(),
}),
}
}
fn parse_host_config(host: &HostConfigSnapshot) -> Result<toml::Table> {
if matches!(host.state, ConfigFileState::Unreadable) {
anyhow::bail!("cannot read {}", host.path.display());
}
let content = std::str::from_utf8(&host.bytes).context("reading host config as UTF-8")?;
let mut table: toml::Table = toml::from_str(content).context("parsing host config")?;
for (key, reason) in HOST_EXCLUDED_KEYS {
if remove_config_key(&mut table, key) {
tracing::warn!(
"host config {}: ignoring {}: {reason}",
host.path.display(),
key.join(".")
);
}
}
toml::Value::Table(table.clone())
.try_into::<FileConfig>()
.context("host config does not match the kache config schema")?;
Ok(table)
}
fn remove_config_key(table: &mut toml::Table, path: &[&str]) -> bool {
match path {
[] => false,
[key] => table.remove(*key).is_some(),
[first, rest @ ..] => match table.get_mut(*first) {
Some(toml::Value::Table(inner)) => remove_config_key(inner, rest),
_ => false,
},
}
}
const REMOTE_ENV_VARS: &[&str] = &[
"KACHE_S3_BUCKET",
"KACHE_S3_ENDPOINT",
"KACHE_S3_REGION",
"KACHE_S3_PREFIX",
"KACHE_S3_PROFILE",
"KACHE_S3_USER_AGENT",
];
const PLANNER_ENV_VARS: &[&str] = &[
"KACHE_PLANNER_ENDPOINT",
"KACHE_PLANNER_TIMEOUT_MS",
"KACHE_PLANNER_TOKEN",
];
const HOST_TABLES_YIELDING_TO_ENV: &[(&str, &[&str])] =
&[("remote", REMOTE_ENV_VARS), ("planner", PLANNER_ENV_VARS)];
fn yield_host_tables_to_env(host: &mut toml::Table, chosen: Option<&toml::Table>) {
let chosen_cache = chosen
.and_then(|table| table.get("cache"))
.and_then(toml::Value::as_table);
if chosen_cache
.and_then(|cache| cache.get("ignore_env"))
.and_then(toml::Value::as_bool)
== Some(true)
{
return;
}
for (table, vars) in HOST_TABLES_YIELDING_TO_ENV {
if chosen_cache.is_some_and(|cache| cache.contains_key(*table)) {
continue;
}
let Some(var) = vars.iter().find(|name| std::env::var_os(name).is_some()) else {
continue;
};
if remove_config_key(host, &["cache", table]) {
tracing::debug!("{var} is set, so the host config's [cache.{table}] does not apply");
}
}
}
fn host_config_layer(host: Option<&HostConfigSnapshot>) -> Option<toml::Table> {
let host = host?;
match parse_host_config(host) {
Ok(table) => Some(table),
Err(error) => {
tracing::warn!("ignoring host config {}: {error:#}", host.path.display());
None
}
}
}
fn is_host_atomic_table(path: &[String]) -> bool {
HOST_ATOMIC_TABLES.iter().any(|table| {
table.len() == path.len() && table.iter().zip(path).all(|(want, key)| *want == key)
})
}
fn merge_config_tables(base: &mut toml::Table, over: toml::Table) {
merge_config_tables_at(base, over, &mut Vec::new());
}
fn merge_config_tables_at(base: &mut toml::Table, over: toml::Table, path: &mut Vec<String>) {
for (key, value) in over {
path.push(key.clone());
let recurse = !is_host_atomic_table(path)
&& matches!(value, toml::Value::Table(_))
&& matches!(base.get(&key), Some(toml::Value::Table(_)));
if recurse {
if let (Some(toml::Value::Table(existing)), toml::Value::Table(incoming)) =
(base.get_mut(&key), value)
{
merge_config_tables_at(existing, incoming, path);
}
} else {
base.insert(key, value);
}
path.pop();
}
}
fn parse_layered_file_config(content: &str, host: Option<toml::Table>) -> Result<FileConfig> {
let Some(mut merged) = host else {
return toml::from_str(content).context("parsing kache config file");
};
let chosen: toml::Table = toml::from_str(content).context("parsing kache config file")?;
yield_host_tables_to_env(&mut merged, Some(&chosen));
merge_config_tables(&mut merged, chosen);
toml::Value::Table(merged)
.try_into()
.context("parsing kache config file")
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum HostConfigStatus {
Disabled,
Absent { path: PathBuf },
Invalid { path: PathBuf, error: String },
Present {
path: PathBuf,
keys: Vec<HostConfigKey>,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct HostConfigKey {
pub key: String,
pub source: HostKeySource,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum HostKeySource {
Host,
ChosenFile(PathBuf),
Env(&'static str),
}
pub(crate) fn host_config_status() -> HostConfigStatus {
let Some(path) = host_config_path() else {
return HostConfigStatus::Disabled;
};
let Some(host) = read_host_config_snapshot() else {
return HostConfigStatus::Absent { path };
};
let table = match parse_host_config(&host) {
Ok(table) => table,
Err(error) => {
return HostConfigStatus::Invalid {
path: host.path,
error: format!("{error:#}"),
};
}
};
let chosen_path = normalize_config_path(resolve_config_path());
let chosen: toml::Table = std::fs::read_to_string(&chosen_path)
.ok()
.and_then(|content| toml::from_str(&content).ok())
.unwrap_or_default();
let ignore_env = Config::ignore_env_enabled(&Config::load_file_config());
let mut keys = Vec::new();
collect_host_keys(
&table,
Some(&chosen),
&mut Vec::new(),
&chosen_path,
ignore_env,
&mut keys,
);
HostConfigStatus::Present {
path: host.path,
keys,
}
}
fn collect_host_keys(
host: &toml::Table,
chosen: Option<&toml::Table>,
path: &mut Vec<String>,
chosen_path: &std::path::Path,
ignore_env: bool,
out: &mut Vec<HostConfigKey>,
) {
for (key, value) in host {
path.push(key.clone());
let chosen_value = chosen.and_then(|table| table.get(key));
match value {
toml::Value::Table(inner) if !is_host_atomic_table(path) => {
let chosen_inner = match chosen_value {
Some(toml::Value::Table(table)) => Some(table),
_ => None,
};
collect_host_keys(inner, chosen_inner, path, chosen_path, ignore_env, out);
}
_ => {
let source = if let Some(var) = env_override_for(path, ignore_env) {
HostKeySource::Env(var)
} else if chosen_value.is_some() {
HostKeySource::ChosenFile(chosen_path.to_path_buf())
} else {
HostKeySource::Host
};
out.push(HostConfigKey {
key: path.join("."),
source,
});
}
}
path.pop();
}
}
fn env_override_for(path: &[String], ignore_env: bool) -> Option<&'static str> {
if ignore_env {
return None;
}
let key = path.join(".");
let inside = format!("{key}.");
let whole_table = is_host_atomic_table(path);
ENV_FILE_KEYS
.iter()
.find(|(var, file_key)| {
(*file_key == key || (whole_table && file_key.starts_with(&inside)))
&& std::env::var_os(var).is_some()
})
.map(|(var, _)| *var)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ConfigFileProvenance {
pub path: PathBuf,
pub fingerprint: String,
}
#[derive(Debug, Clone, Copy)]
enum ConfigFileState {
Absent = 0,
Present = 1,
Unreadable = 2,
}
impl ConfigFileProvenance {
fn from_snapshot(
path: PathBuf,
state: ConfigFileState,
bytes: &[u8],
host: Option<&HostConfigSnapshot>,
) -> Self {
let mut hasher = blake3::Hasher::new();
hasher.update(b"kache.config-file-provenance.v1\0");
hasher.update(path.as_os_str().as_encoded_bytes());
hasher.update(&[0, state as u8]);
hasher.update(&(bytes.len() as u64).to_le_bytes());
hasher.update(bytes);
if let Some(host) = host {
hasher.update(b"\0host\0");
hasher.update(host.path.as_os_str().as_encoded_bytes());
hasher.update(&[0, host.state as u8]);
hasher.update(&(host.bytes.len() as u64).to_le_bytes());
hasher.update(&host.bytes);
}
Self {
path,
fingerprint: hasher.finalize().to_hex().to_string(),
}
}
#[cfg(test)]
pub(crate) fn current() -> Self {
config_file_provenance_at(normalize_config_path(resolve_config_path()))
}
}
fn normalize_config_path(path: PathBuf) -> PathBuf {
let current_dir = std::env::current_dir().ok();
normalize_config_path_from(path, current_dir.as_deref())
}
fn normalize_config_path_from(path: PathBuf, current_dir: Option<&std::path::Path>) -> PathBuf {
let rooted = if path.is_absolute() {
path
} else if let Some(current_dir) = current_dir {
current_dir.join(path)
} else {
path
};
std::path::absolute(&rooted).unwrap_or(rooted)
}
pub(crate) fn config_file_provenance_at(path: PathBuf) -> ConfigFileProvenance {
let host = read_host_config_snapshot();
let host = host.as_ref();
match std::fs::read(&path) {
Ok(bytes) => {
ConfigFileProvenance::from_snapshot(path, ConfigFileState::Present, &bytes, host)
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
ConfigFileProvenance::from_snapshot(path, ConfigFileState::Absent, &[], host)
}
Err(_) => ConfigFileProvenance::from_snapshot(path, ConfigFileState::Unreadable, &[], host),
}
}
pub(crate) fn config_file_has_changed(provenance: &ConfigFileProvenance) -> bool {
config_file_provenance_at(provenance.path.clone()).fingerprint != provenance.fingerprint
}
pub(crate) fn resolve_config_path() -> PathBuf {
resolve_config_path_from(
std::env::var("KACHE_CONFIG").ok().map(|s| shellexpand(&s)),
std::env::current_dir().ok(),
)
}
#[cfg(test)]
pub(crate) fn config_file_fingerprint() -> String {
ConfigFileProvenance::current().fingerprint
}
pub(crate) fn user_config_path() -> PathBuf {
resolve_config_path_from(
std::env::var("KACHE_CONFIG").ok().map(|s| shellexpand(&s)),
None,
)
}
fn resolve_config_path_from(
kache_config: Option<PathBuf>,
current_dir: Option<PathBuf>,
) -> PathBuf {
if let Some(p) = kache_config {
return p;
}
if let Some(path) = nearest_project_config_path(current_dir.as_deref()) {
return path;
}
config_file_path()
}
fn nearest_project_config_path(current_dir: Option<&std::path::Path>) -> Option<PathBuf> {
let current_dir = current_dir?;
for dir in current_dir.ancestors() {
let candidate = dir.join(PROJECT_CONFIG_NAME);
if candidate.exists() {
return Some(candidate);
}
}
None
}
pub(crate) fn config_file_path() -> PathBuf {
let config_base = std::env::var("XDG_CONFIG_HOME")
.map(PathBuf::from)
.unwrap_or_else(|_| {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("/tmp"))
.join(".config")
});
config_base.join("kache").join("config.toml")
}
fn scoped_socket_path(socket: &Path, scope: &str) -> PathBuf {
let mut name = socket.file_stem().unwrap_or_default().to_os_string();
name.push("-");
name.push(scope);
if let Some(extension) = socket.extension() {
name.push(".");
name.push(extension);
}
socket.with_file_name(name)
}
fn resolve_socket_path_override(raw: Option<std::ffi::OsString>) -> Option<PathBuf> {
let raw = raw?;
if raw.is_empty() {
tracing::warn!(
"ignoring empty KACHE_SOCKET_PATH; falling back to <runtime_dir>/daemon.sock"
);
return None;
}
let path = PathBuf::from(raw);
if !path.is_absolute() || !existing_socket_target_is_usable(&path) {
tracing::warn!(
path = %path.display(),
"ignoring unusable KACHE_SOCKET_PATH; use an absolute socket filename in a private directory"
);
return None;
}
Some(path)
}
fn existing_socket_target_is_usable(path: &Path) -> bool {
match std::fs::symlink_metadata(path) {
#[cfg(unix)]
Ok(metadata) => {
use std::os::unix::fs::FileTypeExt;
metadata.file_type().is_socket()
}
#[cfg(not(unix))]
Ok(_) => false,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => true,
Err(_) => false,
}
}
pub(crate) fn shellexpand(s: &str) -> PathBuf {
if let Some(home) = dirs::home_dir() {
if s == "~" {
return home;
}
if let Some(stripped) = s.strip_prefix("~/") {
return home.join(stripped);
}
}
PathBuf::from(s)
}
fn expand_env_vars_collecting<F>(s: &str, lookup: F) -> (String, Vec<String>)
where
F: Fn(&str) -> Option<String>,
{
let mut out = String::with_capacity(s.len());
let mut unset: Vec<String> = Vec::new();
let mut note_unset = |key: &str| {
if !unset.iter().any(|k| k == key) {
unset.push(key.to_string());
}
};
let mut chars = s.chars().peekable();
while let Some(ch) = chars.next() {
if ch != '$' {
out.push(ch);
continue;
}
if chars.peek() == Some(&'{') {
chars.next();
let mut key = String::new();
for c in chars.by_ref() {
if c == '}' {
break;
}
key.push(c);
}
if let Some(value) = lookup(&key).or_else(|| default_env_var_value(&key)) {
out.push_str(&value);
} else {
note_unset(&key);
out.push_str("${");
out.push_str(&key);
out.push('}');
}
continue;
}
let mut key = String::new();
while let Some(c) = chars.peek().copied() {
if c == '_' || c.is_ascii_alphanumeric() {
key.push(c);
chars.next();
} else {
break;
}
}
if key.is_empty() {
out.push('$');
} else if let Some(value) = lookup(&key).or_else(|| default_env_var_value(&key)) {
out.push_str(&value);
} else {
note_unset(&key);
out.push('$');
out.push_str(&key);
}
}
(out, unset)
}
fn default_env_var_value(key: &str) -> Option<String> {
match key {
"CARGO_HOME" => {
dirs::home_dir().map(|home| home.join(".cargo").to_string_lossy().into_owned())
}
_ => None,
}
}
pub(crate) fn expand_exclude_pattern(pattern: &str) -> String {
expand_exclude_pattern_collecting(pattern).0
}
pub(crate) fn expand_exclude_pattern_collecting(pattern: &str) -> (String, Vec<String>) {
let (expanded, unset) = expand_env_vars_collecting(pattern, |key| std::env::var(key).ok());
let s = shellexpand(&expanded).to_string_lossy().into_owned();
(s, unset)
}
fn push_unique(paths: &mut Vec<PathBuf>, path: PathBuf) {
if !paths.iter().any(|p| p == &path) {
paths.push(path);
}
}
fn source_candidates(source_path: &Path, roots: &[PathBuf]) -> Vec<PathBuf> {
let mut candidates = Vec::new();
push_unique(&mut candidates, source_path.to_path_buf());
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
let absolute = if source_path.is_absolute() {
source_path.to_path_buf()
} else {
cwd.join(source_path)
};
push_unique(&mut candidates, absolute.clone());
if let Ok(canonical) = std::fs::canonicalize(&absolute) {
push_unique(&mut candidates, canonical);
}
for root in roots {
let root_abs = if root.is_absolute() {
root.clone()
} else {
cwd.join(root)
};
let root_forms = [
root_abs.clone(),
std::fs::canonicalize(&root_abs).unwrap_or(root_abs),
];
for root_form in root_forms {
if !source_path.is_absolute() {
push_unique(&mut candidates, root_form.join(source_path));
}
if let Ok(rel) = absolute.strip_prefix(&root_form) {
push_unique(&mut candidates, rel.to_path_buf());
}
}
}
candidates
}
fn exclude_pattern_matches(pattern: &str, candidates: &[PathBuf]) -> bool {
let expanded = expand_exclude_pattern(pattern);
let Ok(pattern) = glob::Pattern::new(&expanded) else {
tracing::warn!("ignoring invalid [cache].exclude glob pattern: {expanded}");
return false;
};
candidates
.iter()
.any(|candidate| pattern.matches_path(candidate))
}
pub(crate) fn parse_size(s: &str) -> Option<u64> {
s.parse::<ByteSize>().ok().map(|b| b.as_u64())
}
pub(crate) fn disk_share_budget(filesystem_bytes: Option<u64>) -> u64 {
let Some(total) = filesystem_bytes.filter(|&n| n > 0) else {
return DISK_SHARE_FALLBACK;
};
let raw = total.saturating_mul(DISK_SHARE_PERCENT) / 100;
const GIB: u64 = 1024 * 1024 * 1024;
let rounded = raw.saturating_add(GIB / 2) / GIB * GIB;
rounded.clamp(DISK_SHARE_FLOOR, DISK_SHARE_CAP)
}
pub(crate) fn describe_max_size(max_size: u64, filesystem_bytes: Option<u64>) -> String {
let derived = disk_share_budget(filesystem_bytes);
if max_size != derived {
return ByteSize(max_size).to_string();
}
match filesystem_bytes.filter(|&n| n > 0) {
Some(total) => format!(
"{} (5% of {}, floor 5GiB, cap 100GiB)",
ByteSize(max_size),
ByteSize(total)
),
None => format!("{} (default; disk size unknown)", ByteSize(max_size)),
}
}
fn parse_local_max_size(value: &str, source: &str) -> Option<u64> {
if value.trim().eq_ignore_ascii_case("none") {
tracing::warn!(
"{source}={value:?} is not allowed: the store must stay bounded. \
Ignoring it and using the disk-share default"
);
return None;
}
parse_size_checked(value, source)
}
pub(crate) fn parse_size_checked(value: &str, source: &str) -> Option<u64> {
let parsed = parse_size(value);
if parsed.is_none() {
tracing::warn!(
"ignoring malformed size {value:?} from {source}: expected an integer with an \
optional unit like `50GiB`, `512MiB`, or `1000000`; falling back to the next \
configured source or the default"
);
}
parsed
}
const TRUST_DOMAIN_MAX_LEN: usize = 64;
pub(crate) fn sanitize_trust_domain(raw: &str) -> Option<&str> {
let label = raw.trim();
if label.is_empty() || label.len() > TRUST_DOMAIN_MAX_LEN {
return None;
}
if label == "." || label == ".." {
return None;
}
if label
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-' | b'_'))
{
Some(label)
} else {
None
}
}
fn isolate_cache_dir_for_trust_domain(base: PathBuf, domain: Option<&str>) -> PathBuf {
let Some(raw) = domain else {
return base;
};
let Some(label) = sanitize_trust_domain(raw) else {
tracing::warn!(
domain = %raw,
"KACHE_TRUST_DOMAIN is not a single path-safe label; using the unscoped cache dir"
);
return base;
};
let isolated = base.join(label);
match std::fs::create_dir_all(&isolated) {
Ok(()) => isolated,
Err(error) => {
tracing::warn!(
path = %isolated.display(),
%error,
"node-local trust-domain store is unusable; using the unscoped cache dir"
);
base
}
}
}
impl From<&Config> for kache_store::config::Config {
fn from(config: &Config) -> Self {
Self {
cache_dir: config.cache_dir.clone(),
max_size: config.max_size,
gc_evict_shared: config.gc_evict_shared,
upload_spool_max_jobs: UPLOAD_SPOOL_MAX_JOBS,
deferred_durability: config.deferred_durability,
}
}
}
#[cfg(test)]
pub(crate) use tests::config_path_lock;
#[cfg(test)]
pub(crate) mod tests;