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 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,
}
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())
}
#[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 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_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 modified_input_guard: bool,
pub input_predictions: bool,
pub record_sessions: bool,
pub local_hit_daemon: bool,
pub windows_hardlink: bool,
pub shared_hardlink_restores: bool,
pub deferred_discovery: bool,
pub auto_gc: bool,
pub storage_layout_advice: bool,
pub heartbeat_secs: u64,
pub explain_miss: bool,
pub scheduler: bool,
}
#[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),
}
#[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",
}
}
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()),
};
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) 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) local_hit_daemon: Option<bool>,
pub(crate) windows_hardlink: Option<bool>,
pub(crate) shared_hardlink_restores: Option<bool>,
pub(crate) deferred_discovery: Option<bool>,
pub(crate) auto_gc: 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) 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_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) 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>,
}
#[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>,
}
#[allow(dead_code)]
pub(crate) struct EnvOverrides {
pub(crate) disabled: bool,
pub(crate) cache_dir: bool,
pub(crate) max_size: bool,
pub(crate) cache_executables: bool,
pub(crate) cache_cc_links: 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,
pub(crate) cc_extra_allowlist_flags: bool,
pub(crate) local_only: bool,
pub(crate) remote_readonly: bool,
}
impl EnvOverrides {
pub(crate) fn detect() -> Self {
let ignore_env = Config::ignore_env_enabled(&Config::load_file_config());
Self {
disabled: std::env::var("KACHE_DISABLED").is_ok(),
local_only: env_or_ignored("KACHE_LOCAL_ONLY", ignore_env).is_ok(),
remote_readonly: env_or_ignored("KACHE_REMOTE_READONLY", ignore_env).is_ok(),
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(),
cache_cc_links: env_or_ignored("KACHE_CACHE_CC_LINKS", 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(),
cc_extra_allowlist_flags: env_or_ignored("KACHE_CC_EXTRA_ALLOWLIST_FLAGS", 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
}
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_MAX_SIZE",
"KACHE_CACHE_EXECUTABLES",
"KACHE_CACHE_CC_LINKS",
"KACHE_CLEAN_INCREMENTAL",
"KACHE_PRESERVE_INCREMENTAL",
"KACHE_ADAPTIVE_INCREMENTAL",
"KACHE_COMPRESSION_LEVEL",
"KACHE_S3_CONCURRENCY",
"KACHE_PREFETCH_ENABLED",
"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_LOCAL_HIT_DAEMON",
"KACHE_WINDOWS_HARDLINK",
"KACHE_SHARED_HARDLINK_RESTORES",
"KACHE_DEFERRED_DISCOVERY",
"KACHE_AUTO_GC",
"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_MAX_SIZE", "cache.local_max_size"),
("KACHE_CACHE_EXECUTABLES", "cache.cache_executables"),
("KACHE_CACHE_CC_LINKS", "cache.cache_cc_links"),
("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_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_LOCAL_HIT_DAEMON", "cache.local_hit_daemon"),
("KACHE_WINDOWS_HARDLINK", "cache.windows_hardlink"),
(
"KACHE_SHARED_HARDLINK_RESTORES",
"cache.shared_hardlink_restores",
),
("KACHE_DEFERRED_DISCOVERY", "cache.deferred_discovery"),
("KACHE_AUTO_GC", "cache.auto_gc"),
("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 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 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 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"))
})
.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 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(10 * 1024 * 1024);
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 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 = 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(),
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(),
};
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 local_hit_daemon = Self::local_hit_daemon_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 auto_gc = Self::auto_gc_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);
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 (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))
}
}
};
Ok(Config {
cache_dir,
runtime_dir,
socket_path_override,
max_size,
remote,
remote_error,
disabled,
local_only,
remote_readonly,
modified_input_guard,
input_predictions,
record_sessions,
local_hit_daemon,
windows_hardlink,
shared_hardlink_restores,
deferred_discovery,
auto_gc,
gc_evict_shared,
storage_layout_advice,
volume_stores,
heartbeat_secs,
explain_miss,
scheduler,
cache_executables,
cache_cc_links,
clean_incremental,
preserve_incremental,
adaptive_incremental,
event_log_max_size,
event_log_keep_lines,
compression_level,
s3_concurrency,
prefetch_enabled,
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_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()))
});
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\" and \"filesystem\""
);
}
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(false)
}
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 local_hit_daemon_enabled(file_config: &Result<FileConfig>) -> bool {
let ignore_env = Self::ignore_env_enabled(file_config);
if let Ok(v) = env_or_ignored("KACHE_LOCAL_HIT_DAEMON", 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_hit_daemon)
.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 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 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
}
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>) -> 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),
})
})
.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 {
self.cache_dir.join("upload-queue")
}
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 {
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"))
}
pub fn source_excluded(source_path: &Path, roots: &[PathBuf]) -> bool {
let patterns = Self::load_exclude_patterns();
source_excluded_by_patterns(&patterns, source_path, roots)
}
fn load_exclude_patterns() -> Vec<String> {
Self::load_rule_list(|c| c.exclude)
}
fn load_rule_list(pick: impl FnOnce(CacheFileConfig) -> Option<Vec<String>>) -> Vec<String> {
Self::load_file_config()
.ok()
.and_then(|c| c.cache)
.and_then(pick)
.unwrap_or_default()
.into_iter()
.map(|p| p.trim().to_string())
.filter(|p| !p.is_empty())
.collect()
}
pub fn user_bypass_reason(crate_name: &str, argv: &[String]) -> Option<String> {
Self::user_bypass_reason_with(
crate_name,
argv,
&Self::load_rule_list(|c| c.bypass_crates),
&Self::load_rule_list(|c| c.bypass_argv),
&Self::load_rule_list(|c| c.bypass_env),
|name| std::env::var(name).ok(),
)
}
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
}
}
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
}
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 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,
}
}
}
#[cfg(test)]
pub(crate) use tests::config_path_lock;
#[cfg(test)]
pub(crate) mod tests {
use super::*;
pub(crate) use crate::test_support::process_state_test_lock as config_path_lock;
use std::ffi::OsString;
#[test]
fn cache_executables_defaults_on_for_linux_and_macos() {
assert_eq!(
default_cache_executables(),
cfg!(target_os = "linux") || cfg!(target_os = "macos"),
"executables default on for Linux and macOS, off for Windows (see #319)"
);
}
#[test]
fn trust_domain_rejects_path_components_and_separators() {
assert_eq!(
sanitize_trust_domain("github.kunobi-ninja"),
Some("github.kunobi-ninja")
);
assert_eq!(sanitize_trust_domain(" ok_1 "), Some("ok_1"));
for bad in [
"",
" ",
".",
"..",
"a/b",
"a\\b",
"../x",
"x/..",
"has space",
"unicode-é",
] {
assert_eq!(sanitize_trust_domain(bad), None, "{bad:?}");
}
let at_max = "a".repeat(TRUST_DOMAIN_MAX_LEN);
assert_eq!(sanitize_trust_domain(&at_max), Some(at_max.as_str()));
let too_long = "a".repeat(TRUST_DOMAIN_MAX_LEN + 1);
assert_eq!(sanitize_trust_domain(&too_long), None);
}
#[test]
fn trust_domain_isolates_stores_and_fails_open() {
let dir = tempfile::tempdir().unwrap();
let base = dir.path().to_path_buf();
let trusted = isolate_cache_dir_for_trust_domain(base.clone(), Some("trusted"));
let public = isolate_cache_dir_for_trust_domain(base.clone(), Some("public"));
assert_ne!(trusted, public);
assert_eq!(trusted, base.join("trusted"));
assert_eq!(public, base.join("public"));
assert!(trusted.is_dir());
assert!(public.is_dir());
assert_eq!(
isolate_cache_dir_for_trust_domain(base.clone(), None),
base,
"unset domain must not rewrite the store"
);
assert_eq!(
isolate_cache_dir_for_trust_domain(base.clone(), Some("../escape")),
base,
"unsafe labels fail open"
);
assert_eq!(
isolate_cache_dir_for_trust_domain(base.clone(), Some(" ")),
base,
"whitespace-only labels fail open"
);
let file = dir.path().join("not-a-dir");
std::fs::write(&file, b"x").unwrap();
assert_eq!(
isolate_cache_dir_for_trust_domain(file.clone(), Some("trusted")),
file,
"an unusable parent must fail open"
);
}
#[test]
fn cache_cc_links_env_one_and_true_enable_zero_does_not() {
for on in ["1", "true", "TRUE"] {
assert!(env_flag_one_or_true(on), "{on} must enable cache_cc_links");
}
for off in ["0", "false", "no", ""] {
assert!(
!env_flag_one_or_true(off),
"{off:?} must not enable cache_cc_links"
);
}
}
struct TestEnvGuard {
key: &'static str,
previous: Option<OsString>,
}
impl Drop for TestEnvGuard {
fn drop(&mut self) {
unsafe {
match self.previous.as_ref() {
Some(value) => std::env::set_var(self.key, value),
None => std::env::remove_var(self.key),
}
}
}
}
fn set_env_for_test(key: &'static str, value: Option<&std::ffi::OsStr>) -> TestEnvGuard {
let previous = std::env::var_os(key);
unsafe {
match value {
Some(value) => std::env::set_var(key, value),
None => std::env::remove_var(key),
}
}
TestEnvGuard { key, previous }
}
#[test]
fn input_predictions_resolve_env_over_file_and_default_off() {
let _lock = config_path_lock();
let file_says = |value: Option<bool>| -> Result<FileConfig> {
Ok(FileConfig {
cache: Some(CacheFileConfig {
input_predictions: value,
..Default::default()
}),
..Default::default()
})
};
{
let _env = set_env_for_test("KACHE_INPUT_PREDICTIONS", None);
assert!(
!Config::input_predictions_enabled(&file_says(None)),
"off unless something asks for it"
);
assert!(Config::input_predictions_enabled(&file_says(Some(true))));
assert!(!Config::input_predictions_enabled(&file_says(Some(false))));
}
for on in ["1", "true", "TRUE"] {
let _env = set_env_for_test("KACHE_INPUT_PREDICTIONS", Some(on.as_ref()));
assert!(
Config::input_predictions_enabled(&file_says(Some(false))),
"{on} in the environment must override the file"
);
}
for off in ["0", "false", "no", ""] {
let _env = set_env_for_test("KACHE_INPUT_PREDICTIONS", Some(off.as_ref()));
assert!(
!Config::input_predictions_enabled(&file_says(Some(true))),
"{off:?} in the environment must override the file"
);
}
}
#[test]
fn record_sessions_resolve_env_over_file_and_default_off() {
let _lock = config_path_lock();
let file_says = |value: Option<bool>| -> Result<FileConfig> {
Ok(FileConfig {
cache: Some(CacheFileConfig {
record_sessions: value,
..Default::default()
}),
..Default::default()
})
};
{
let _env = set_env_for_test("KACHE_RECORD_SESSIONS", None);
assert!(
!Config::record_sessions_enabled(&file_says(None)),
"off unless something asks for it"
);
assert!(Config::record_sessions_enabled(&file_says(Some(true))));
}
for on in ["1", "true", "TRUE"] {
let _env = set_env_for_test("KACHE_RECORD_SESSIONS", Some(on.as_ref()));
assert!(
Config::record_sessions_enabled(&file_says(Some(false))),
"{on} in the environment must override the file"
);
}
for off in ["0", "false", ""] {
let _env = set_env_for_test("KACHE_RECORD_SESSIONS", Some(off.as_ref()));
assert!(
!Config::record_sessions_enabled(&file_says(Some(true))),
"{off:?} in the environment must override the file"
);
}
}
fn set_kache_config_for_test(path: &std::path::Path) -> TestEnvGuard {
set_env_for_test("KACHE_CONFIG", Some(path.as_os_str()))
}
struct NamedEnvGuard {
name: &'static str,
previous: Option<OsString>,
}
impl NamedEnvGuard {
fn set(name: &'static str, value: &str) -> Self {
let previous = std::env::var_os(name);
unsafe { std::env::set_var(name, value) };
Self { name, previous }
}
fn remove(name: &'static str) -> Self {
let previous = std::env::var_os(name);
unsafe { std::env::remove_var(name) };
Self { name, previous }
}
}
impl Drop for NamedEnvGuard {
fn drop(&mut self) {
unsafe {
match self.previous.as_ref() {
Some(value) => std::env::set_var(self.name, value),
None => std::env::remove_var(self.name),
}
}
}
}
#[test]
fn test_default_cache_dir() {
let dir = default_cache_dir();
assert!(dir.to_string_lossy().contains("kache"));
}
#[test]
fn daemon_idle_timeout_defaults_to_disabled() {
let _lock = config_path_lock();
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("config.toml");
let _config = set_kache_config_for_test(&config_path);
let _timeout = NamedEnvGuard::remove("KACHE_DAEMON_IDLE_TIMEOUT");
assert_eq!(Config::load().unwrap().daemon_idle_timeout_secs, 0);
}
#[test]
fn gc_max_age_is_opt_in_and_obeys_env_precedence() {
let _lock = config_path_lock();
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("config.toml");
let _config = set_kache_config_for_test(&config_path);
let _age_missing = NamedEnvGuard::remove("KACHE_GC_MAX_AGE_HOURS");
assert_eq!(Config::load().unwrap().gc_max_age_hours, 0);
std::fs::write(&config_path, "[cache]\ngc_max_age_hours = 72\n").unwrap();
assert_eq!(Config::load().unwrap().gc_max_age_hours, 72);
let _age_override = NamedEnvGuard::set("KACHE_GC_MAX_AGE_HOURS", "96");
assert_eq!(Config::load().unwrap().gc_max_age_hours, 96);
std::fs::write(
&config_path,
"[cache]\nignore_env = true\ngc_max_age_hours = 72\n",
)
.unwrap();
assert_eq!(Config::load().unwrap().gc_max_age_hours, 72);
}
#[test]
fn gc_evict_shared_is_opt_in_and_obeys_env_precedence() {
let _lock = config_path_lock();
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("config.toml");
let _config = set_kache_config_for_test(&config_path);
let _missing = NamedEnvGuard::remove("KACHE_GC_EVICT_SHARED");
assert!(!Config::load().unwrap().gc_evict_shared);
std::fs::write(&config_path, "[cache]\ngc_evict_shared = true\n").unwrap();
assert!(Config::load().unwrap().gc_evict_shared);
let _override = NamedEnvGuard::set("KACHE_GC_EVICT_SHARED", "0");
assert!(!Config::load().unwrap().gc_evict_shared);
drop(_override);
let _true_override = NamedEnvGuard::set("KACHE_GC_EVICT_SHARED", "1");
assert!(Config::load().unwrap().gc_evict_shared);
std::fs::write(
&config_path,
"[cache]\nignore_env = true\ngc_evict_shared = true\n",
)
.unwrap();
assert!(Config::load().unwrap().gc_evict_shared);
}
#[test]
fn min_store_compile_is_opt_in_and_obeys_env_precedence() {
let _lock = config_path_lock();
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("config.toml");
let _config = set_kache_config_for_test(&config_path);
let _missing = NamedEnvGuard::remove("KACHE_MIN_STORE_COMPILE_MS");
assert_eq!(Config::load().unwrap().min_store_compile_ms, 0);
std::fs::write(&config_path, "[cache]\nmin_store_compile_ms = 750\n").unwrap();
assert_eq!(Config::load().unwrap().min_store_compile_ms, 750);
let _override = NamedEnvGuard::set("KACHE_MIN_STORE_COMPILE_MS", "1200");
assert_eq!(Config::load().unwrap().min_store_compile_ms, 1200);
std::fs::write(
&config_path,
"[cache]\nignore_env = true\nmin_store_compile_ms = 750\n",
)
.unwrap();
assert_eq!(Config::load().unwrap().min_store_compile_ms, 750);
}
#[test]
fn prefetch_enabled_env_value_truth_table() {
assert!(!prefetch_enabled_from_env("0"));
assert!(!prefetch_enabled_from_env("false"));
assert!(!prefetch_enabled_from_env("FALSE"));
assert!(prefetch_enabled_from_env("1"));
assert!(prefetch_enabled_from_env("true"));
assert!(prefetch_enabled_from_env("yes"));
assert!(prefetch_enabled_from_env(""));
}
#[test]
fn prefetch_controls_default_and_follow_file_then_env_precedence() {
let _lock = config_path_lock();
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("config.toml");
let _config = set_kache_config_for_test(&config_path);
std::fs::write(
&config_path,
"[cache]
",
)
.unwrap();
{
let _enabled = NamedEnvGuard::remove("KACHE_PREFETCH_ENABLED");
let _refresh = NamedEnvGuard::remove("KACHE_REMOTE_KEY_CACHE_REFRESH_SECS");
let config = Config::load().unwrap();
assert!(config.prefetch_enabled);
assert_eq!(config.remote_key_cache_refresh_secs, 60);
}
std::fs::write(
&config_path,
"[cache]
prefetch_enabled = false
remote_key_cache_refresh_secs = 900
",
)
.unwrap();
{
let _enabled = NamedEnvGuard::remove("KACHE_PREFETCH_ENABLED");
let _refresh = NamedEnvGuard::remove("KACHE_REMOTE_KEY_CACHE_REFRESH_SECS");
let config = Config::load().unwrap();
assert!(!config.prefetch_enabled);
assert_eq!(config.remote_key_cache_refresh_secs, 900);
}
{
let _enabled = NamedEnvGuard::set("KACHE_PREFETCH_ENABLED", "true");
let _refresh = NamedEnvGuard::set("KACHE_REMOTE_KEY_CACHE_REFRESH_SECS", "0");
let config = Config::load().unwrap();
assert!(config.prefetch_enabled);
assert_eq!(config.remote_key_cache_refresh_secs, 0);
}
}
#[test]
fn remote_resilience_knobs_default_and_follow_file_then_env_precedence() {
let _lock = config_path_lock();
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("config.toml");
std::fs::write(&config_path, "[cache]\n").unwrap();
let _config = set_kache_config_for_test(&config_path);
{
let _restore = NamedEnvGuard::remove("KACHE_REMOTE_RESTORE_TIMEOUT_SECS");
let _negative = NamedEnvGuard::remove("KACHE_REMOTE_NEGATIVE_TTL_SECS");
let config = Config::load().unwrap();
assert_eq!(
config.remote_restore_timeout_secs,
DEFAULT_REMOTE_RESTORE_TIMEOUT_SECS
);
assert_eq!(
config.remote_negative_ttl_secs,
DEFAULT_REMOTE_NEGATIVE_TTL_SECS
);
}
std::fs::write(
&config_path,
"[cache]
remote_restore_timeout_secs = 42
remote_negative_ttl_secs = 90
",
)
.unwrap();
{
let _restore = NamedEnvGuard::remove("KACHE_REMOTE_RESTORE_TIMEOUT_SECS");
let _negative = NamedEnvGuard::remove("KACHE_REMOTE_NEGATIVE_TTL_SECS");
let config = Config::load().unwrap();
assert_eq!(config.remote_restore_timeout_secs, 42);
assert_eq!(config.remote_negative_ttl_secs, 90);
}
{
let _restore = NamedEnvGuard::set("KACHE_REMOTE_RESTORE_TIMEOUT_SECS", "0");
let _negative = NamedEnvGuard::set("KACHE_REMOTE_NEGATIVE_TTL_SECS", "0");
let config = Config::load().unwrap();
assert_eq!(config.remote_restore_timeout_secs, 0);
assert_eq!(config.remote_negative_ttl_secs, 0);
}
std::fs::write(
&config_path,
"[cache]
ignore_env = true
remote_restore_timeout_secs = 42
remote_negative_ttl_secs = 90
",
)
.unwrap();
{
let _restore = NamedEnvGuard::set("KACHE_REMOTE_RESTORE_TIMEOUT_SECS", "7");
let _negative = NamedEnvGuard::set("KACHE_REMOTE_NEGATIVE_TTL_SECS", "8");
let config = Config::load().unwrap();
assert_eq!(config.remote_restore_timeout_secs, 42);
assert_eq!(config.remote_negative_ttl_secs, 90);
}
assert!(IGNORE_ENV_GATED_VARS.contains(&"KACHE_REMOTE_RESTORE_TIMEOUT_SECS"));
assert!(IGNORE_ENV_GATED_VARS.contains(&"KACHE_REMOTE_NEGATIVE_TTL_SECS"));
}
#[test]
fn ignore_env_pins_prefetch_controls_to_the_file() {
let _lock = config_path_lock();
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("config.toml");
std::fs::write(
&config_path,
"[cache]
ignore_env = true
prefetch_enabled = false
remote_key_cache_refresh_secs = 900
",
)
.unwrap();
let _config = set_kache_config_for_test(&config_path);
let _enabled = NamedEnvGuard::set("KACHE_PREFETCH_ENABLED", "true");
let _refresh = NamedEnvGuard::set("KACHE_REMOTE_KEY_CACHE_REFRESH_SECS", "1");
let config = Config::load().unwrap();
assert!(!config.prefetch_enabled);
assert_eq!(config.remote_key_cache_refresh_secs, 900);
}
#[test]
fn remote_prefix_is_backend_neutral() {
assert_eq!(
normalize_remote_prefix("artifacts/team").unwrap(),
"artifacts/team"
);
for (legacy, expected) in [
("/artifacts", "artifacts"),
("artifacts/", "artifacts"),
("artifacts//team", "artifacts/team"),
(" artifacts/team ", "artifacts/team"),
("/", ""),
("", ""),
] {
assert_eq!(
normalize_remote_prefix(legacy).unwrap(),
expected,
"{legacy:?} must normalize, not fail"
);
}
for invalid in [r"artifacts\team", r"..\escape", "artifacts/../team", ".."] {
assert!(
normalize_remote_prefix(invalid).is_err(),
"{invalid:?} must be rejected"
);
}
}
#[test]
fn test_shellexpand() {
let expanded = shellexpand("~/foo");
assert!(!expanded.to_string_lossy().starts_with("~/"));
}
#[test]
fn test_parse_size() {
assert_eq!(parse_size("50GiB"), Some(50 * 1024 * 1024 * 1024));
assert_eq!(parse_size("1MiB"), Some(1024 * 1024));
assert!(parse_size("invalid").is_none());
}
#[test]
fn base_dirs_validate_and_normalize_host_independent_absolute_syntax() {
let normalized = normalize_base_dirs([
"/var//lib/./flatpak/".to_string(),
r"C:\Build\Root\.".to_string(),
r"\\server\share\app".to_string(),
"/snap".to_string(),
r"/work/a\b/./root".to_string(),
])
.unwrap();
assert_eq!(
normalized,
vec![
"//server/share/app",
"/snap",
"/var/lib/flatpak",
r"/work/a\b/root",
"C:/Build/Root",
]
);
}
#[test]
fn base_dirs_reject_relative_and_parent_traversal_entries() {
let relative = normalize_base_dirs(["build/root".to_string()]).unwrap_err();
assert!(relative.to_string().contains("must be absolute"));
let parent = normalize_base_dirs(["/work/../other".to_string()]).unwrap_err();
assert!(parent.to_string().contains("must not contain `..`"));
let windows_parent = normalize_base_dirs([r"C:\work\..\other".to_string()]).unwrap_err();
assert!(windows_parent.to_string().contains("must not contain `..`"));
for root in [
"/",
"C:/",
r"C:\",
"//server/share",
"//server/share/",
r"\\server\share",
r"\\server\share\",
] {
let error = normalize_base_dirs([root.to_string()]).unwrap_err();
assert!(
error
.to_string()
.contains("narrower than a filesystem root")
);
}
for verbatim in [r"\\?\C:\work", "//?/C:/work"] {
let error = normalize_base_dirs([verbatim.to_string()]).unwrap_err();
assert!(error.to_string().contains("Windows verbatim prefix"));
}
}
#[test]
fn config_load_integrates_paths_base_dirs() {
let _lock = config_path_lock();
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("config.toml");
let _guard = set_kache_config_for_test(&config_path);
std::fs::write(
&config_path,
"[paths]\nbase_dirs = [\"/var/lib/flatpak\", \"/snap\"]\n",
)
.unwrap();
assert_eq!(
Config::load().unwrap().base_dirs,
vec!["/snap".to_string(), "/var/lib/flatpak".to_string()]
);
std::fs::write(&config_path, "[paths]\nbase_dirs = [\"relative/root\"]\n").unwrap();
assert!(Config::load().is_err());
}
#[test]
fn parse_size_checked_rejects_malformed_and_mirrors_parse_size() {
for bad in ["100 gigs", "1_000", "abc", ""] {
assert!(parse_size(bad).is_none(), "expected {bad:?} to be invalid");
assert!(parse_size_checked(bad, "KACHE_MAX_SIZE").is_none());
}
assert_eq!(
parse_size_checked("2GiB", "KACHE_MAX_SIZE"),
Some(2 * 1024 * 1024 * 1024)
);
}
#[test]
fn disk_share_budget_floors_caps_and_rounds() {
const GIB: u64 = 1 << 30;
assert_eq!(disk_share_budget(None), 50 << 30);
assert_eq!(disk_share_budget(Some(0)), 50 << 30);
assert_eq!(disk_share_budget(Some(10 * GIB)), 5 << 30);
assert_eq!(disk_share_budget(Some(200 * GIB)), 10 * GIB);
assert_eq!(disk_share_budget(Some(256 * GIB)), 13 * GIB);
assert_eq!(disk_share_budget(Some(4 * 1024 * GIB)), 100 << 30);
assert_eq!(disk_share_budget(Some(2000 * GIB)), 100 << 30);
}
#[test]
fn parse_local_max_size_rejects_none_without_unbounding() {
assert_eq!(parse_local_max_size("none", "KACHE_MAX_SIZE"), None);
assert_eq!(parse_local_max_size("None", "[cache] local_max_size"), None);
assert_eq!(
parse_local_max_size("2GiB", "KACHE_MAX_SIZE"),
Some(2 * 1024 * 1024 * 1024)
);
}
#[test]
fn describe_max_size_names_the_disk_share_when_derived() {
const GIB: u64 = 1024 * 1024 * 1024;
let disk = Some(200 * GIB);
let derived = disk_share_budget(disk);
let text = describe_max_size(derived, disk);
assert!(text.contains("5%"), "{text}");
assert!(text.contains(&ByteSize(200 * GIB).to_string()), "{text}");
assert_eq!(
describe_max_size(2 * GIB, disk),
ByteSize(2 * GIB).to_string()
);
assert!(
describe_max_size(DISK_SHARE_FALLBACK, None).contains("disk size unknown"),
"{}",
describe_max_size(DISK_SHARE_FALLBACK, None)
);
assert!(
describe_max_size(DISK_SHARE_FALLBACK, Some(0)).contains("disk size unknown"),
"a zero-byte probe is unknown, not 5% of 0: {}",
describe_max_size(DISK_SHARE_FALLBACK, Some(0))
);
}
#[test]
fn load_uses_disk_share_budget_when_max_size_is_unset() {
let _lock = config_path_lock();
let dir = tempfile::tempdir().unwrap();
let cfg_path = dir.path().join("config.toml");
std::fs::write(&cfg_path, "[cache]\n").unwrap();
let cache_dir = dir.path().join("store");
std::fs::create_dir(&cache_dir).unwrap();
let _cfg = set_kache_config_for_test(&cfg_path);
let _cache = set_env_for_test("KACHE_CACHE_DIR", Some(cache_dir.as_os_str()));
let _max = set_env_for_test("KACHE_MAX_SIZE", None);
let loaded = Config::load().unwrap();
let expected = disk_share_budget(crate::cache_fs::probe(&cache_dir).total_bytes);
assert_eq!(loaded.max_size, expected);
}
#[test]
fn load_explicit_max_size_wins_over_disk_share() {
let _lock = config_path_lock();
let dir = tempfile::tempdir().unwrap();
let cfg_path = dir.path().join("config.toml");
std::fs::write(&cfg_path, "[cache]\n").unwrap();
let cache_dir = dir.path().join("store");
std::fs::create_dir(&cache_dir).unwrap();
let _cfg = set_kache_config_for_test(&cfg_path);
let _cache = set_env_for_test("KACHE_CACHE_DIR", Some(cache_dir.as_os_str()));
let _max = set_env_for_test("KACHE_MAX_SIZE", Some(std::ffi::OsStr::new("2GiB")));
let loaded = Config::load().unwrap();
assert_eq!(loaded.max_size, 2 * 1024 * 1024 * 1024);
}
#[test]
fn load_none_max_size_does_not_unbound_the_store() {
let _lock = config_path_lock();
let dir = tempfile::tempdir().unwrap();
let cfg_path = dir.path().join("config.toml");
std::fs::write(&cfg_path, "[cache]\nlocal_max_size = \"none\"\n").unwrap();
let cache_dir = dir.path().join("store");
std::fs::create_dir(&cache_dir).unwrap();
let _cfg = set_kache_config_for_test(&cfg_path);
let _cache = set_env_for_test("KACHE_CACHE_DIR", Some(cache_dir.as_os_str()));
let _max = set_env_for_test("KACHE_MAX_SIZE", None);
let loaded = Config::load().unwrap();
let expected = disk_share_budget(crate::cache_fs::probe(&cache_dir).total_bytes);
assert_eq!(loaded.max_size, expected);
assert_ne!(loaded.max_size, 0);
}
#[test]
fn ignore_env_makes_file_win_over_env() {
let _lock = config_path_lock();
let dir = tempfile::tempdir().unwrap();
let cfg = dir.path().join("config.toml");
struct SaltGuard(Option<OsString>);
impl Drop for SaltGuard {
fn drop(&mut self) {
unsafe {
match self.0.as_ref() {
Some(v) => std::env::set_var("KACHE_KEY_SALT", v),
None => std::env::remove_var("KACHE_KEY_SALT"),
}
}
}
}
let _salt = SaltGuard(std::env::var_os("KACHE_KEY_SALT"));
unsafe { std::env::set_var("KACHE_KEY_SALT", "from-env") };
let _g = set_kache_config_for_test(&cfg);
std::fs::write(
&cfg,
"[cache]\nignore_env = true\nkey_salt = \"from-file\"\n",
)
.unwrap();
let loaded = Config::load().unwrap();
assert_eq!(loaded.key_salt.as_deref(), Some("from-file"));
std::fs::write(&cfg, "[cache]\nkey_salt = \"from-file\"\n").unwrap();
let loaded = Config::load().unwrap();
assert_eq!(loaded.key_salt.as_deref(), Some("from-env"));
}
#[test]
fn config_file_fingerprint_tracks_content_and_presence() {
let _lock = config_path_lock();
let dir = tempfile::tempdir().unwrap();
let cfg = dir.path().join("config.toml");
let _g = set_kache_config_for_test(&cfg);
let absent = config_file_fingerprint();
assert_eq!(absent, config_file_fingerprint(), "absent must be stable");
std::fs::write(
&cfg,
"[cache]\nignore_env = true\nlocal_max_size = \"10GiB\"\n",
)
.unwrap();
let v10 = config_file_fingerprint();
assert_ne!(absent, v10, "present must differ from absent");
assert_eq!(
v10,
config_file_fingerprint(),
"same content must be stable"
);
let (loaded, loaded_provenance) = Config::load_with_provenance().unwrap();
assert_eq!(loaded.max_size, 10 * 1024 * 1024 * 1024);
assert_eq!(loaded_provenance.path, std::path::absolute(&cfg).unwrap());
assert_eq!(loaded_provenance.fingerprint, v10);
std::fs::write(
&cfg,
"[cache]\nignore_env = true\nlocal_max_size = \"20GiB\"\n",
)
.unwrap();
assert_ne!(v10, config_file_fingerprint(), "content change must re-key");
assert_ne!(
loaded_provenance.fingerprint,
config_file_provenance_at(loaded_provenance.path.clone()).fingerprint,
"the watcher baseline must remain the exact snapshot parsed above"
);
assert!(
config_file_has_changed(&loaded_provenance),
"the first watcher poll must notice an edit made after config load"
);
}
#[test]
fn config_provenance_distinguishes_absent_and_unreadable_paths() {
let dir = tempfile::tempdir().unwrap();
let missing = dir.path().join("missing.toml");
let (loaded, absent) = Config::load_file_config_with_provenance(missing.clone());
assert!(loaded.is_ok(), "a missing config file means defaults");
let expected_absent = ConfigFileProvenance::from_snapshot(
missing.clone(),
ConfigFileState::Absent,
&[],
None,
);
assert_eq!(absent, expected_absent);
assert_eq!(config_file_provenance_at(missing), absent);
assert!(!config_file_has_changed(&absent));
let unreadable_path = dir.path().to_path_buf();
let (loaded, unreadable) =
Config::load_file_config_with_provenance(unreadable_path.clone());
assert!(loaded.is_err(), "an unreadable config must stay an error");
let expected_unreadable = ConfigFileProvenance::from_snapshot(
unreadable_path.clone(),
ConfigFileState::Unreadable,
&[],
None,
);
assert_eq!(unreadable, expected_unreadable);
assert_eq!(config_file_provenance_at(unreadable_path), unreadable);
assert!(!config_file_has_changed(&unreadable));
}
fn write_host_and_chosen(
dir: &std::path::Path,
host: &str,
chosen: Option<&str>,
) -> (PathBuf, PathBuf) {
let host_path = dir.join("host.toml");
std::fs::write(&host_path, host).unwrap();
let chosen_path = dir.join("chosen.toml");
if let Some(chosen) = chosen {
std::fs::write(&chosen_path, chosen).unwrap();
}
(host_path, chosen_path)
}
#[test]
fn host_layer_is_off_in_unit_tests_and_when_the_variable_is_empty() {
let _lock = config_path_lock();
{
let _host = set_env_for_test("KACHE_HOST_CONFIG", None);
assert_eq!(host_config_path(), None, "unit tests never read /etc");
}
{
let _host = set_env_for_test("KACHE_HOST_CONFIG", Some(std::ffi::OsStr::new("")));
assert_eq!(host_config_path(), None, "an empty value turns it off");
}
let _host = set_env_for_test(
"KACHE_HOST_CONFIG",
Some(std::ffi::OsStr::new("/srv/kache.toml")),
);
assert_eq!(host_config_path(), Some(PathBuf::from("/srv/kache.toml")));
assert_eq!(HOST_CONFIG_PATH, "/etc/kache/config.toml");
}
#[test]
fn a_missing_host_file_changes_nothing() {
let _lock = config_path_lock();
let dir = tempfile::tempdir().unwrap();
let chosen = dir.path().join("chosen.toml");
let content = "[cache]\nlocal_max_size = \"10GiB\"\n";
std::fs::write(&chosen, content).unwrap();
let _host = set_host_config_for_test(&dir.path().join("absent.toml"));
let (loaded, provenance) = Config::load_file_config_with_provenance(chosen.clone());
assert_eq!(
loaded.unwrap().cache.unwrap().local_max_size.as_deref(),
Some("10GiB")
);
assert_eq!(
provenance,
ConfigFileProvenance::from_snapshot(
chosen,
ConfigFileState::Present,
content.as_bytes(),
None,
),
"no host file must fingerprint exactly as before"
);
}
#[test]
fn host_keys_apply_under_a_kache_config_that_does_not_set_them() {
let _lock = config_path_lock();
let dir = tempfile::tempdir().unwrap();
let (host, chosen) = write_host_and_chosen(
dir.path(),
"[cache]\ninput_predictions = true\nlocal_max_size = \"50GiB\"\n",
Some("[cache.remote]\ntype = \"s3\"\nbucket = \"ci\"\n"),
);
let _host = set_host_config_for_test(&host);
let _chosen = set_kache_config_for_test(&chosen);
let cache = Config::load_file_config().unwrap().cache.unwrap();
assert_eq!(cache.input_predictions, Some(true));
assert_eq!(cache.local_max_size.as_deref(), Some("50GiB"));
assert_eq!(
cache.remote.unwrap().bucket.as_deref(),
Some("ci"),
"the chosen file's nested table survives the merge"
);
}
#[test]
fn the_chosen_file_beats_the_host_key_by_key() {
let _lock = config_path_lock();
let dir = tempfile::tempdir().unwrap();
let (host, chosen) = write_host_and_chosen(
dir.path(),
"[cache]\ninput_predictions = true\nlocal_max_size = \"50GiB\"\n",
Some("[cache]\nlocal_max_size = \"10GiB\"\n"),
);
let _host = set_host_config_for_test(&host);
let _chosen = set_kache_config_for_test(&chosen);
let cache = Config::load_file_config().unwrap().cache.unwrap();
assert_eq!(cache.local_max_size.as_deref(), Some("10GiB"));
assert_eq!(
cache.input_predictions,
Some(true),
"a key the chosen file leaves unset still comes from the host"
);
}
#[test]
fn the_environment_beats_both_files() {
let _lock = config_path_lock();
let dir = tempfile::tempdir().unwrap();
let (host, chosen) = write_host_and_chosen(
dir.path(),
"[cache]\ninput_predictions = true\n",
Some("[cache]\nlocal_max_size = \"10GiB\"\n"),
);
let _host = set_host_config_for_test(&host);
let _chosen = set_kache_config_for_test(&chosen);
let file = Config::load_file_config();
{
let _env = set_env_for_test("KACHE_INPUT_PREDICTIONS", None);
assert!(Config::input_predictions_enabled(&file), "host value");
}
let _env = set_env_for_test("KACHE_INPUT_PREDICTIONS", Some(std::ffi::OsStr::new("0")));
assert!(
!Config::input_predictions_enabled(&file),
"the environment overrides the host file"
);
}
#[test]
fn record_sessions_comes_from_the_host_layer() {
let _lock = config_path_lock();
let dir = tempfile::tempdir().unwrap();
let (host, chosen) = write_host_and_chosen(
dir.path(),
"[cache]\nrecord_sessions = true\n",
Some("[cache.remote]\ntype = \"s3\"\nbucket = \"ci\"\n"),
);
let _host = set_host_config_for_test(&host);
let _chosen = set_kache_config_for_test(&chosen);
let file = Config::load_file_config();
{
let _env = set_env_for_test("KACHE_RECORD_SESSIONS", None);
assert!(Config::record_sessions_enabled(&file), "host value");
}
let _env = set_env_for_test("KACHE_RECORD_SESSIONS", Some(std::ffi::OsStr::new("0")));
assert!(
!Config::record_sessions_enabled(&file),
"the environment overrides the host file"
);
}
#[test]
fn a_chosen_remote_replaces_the_host_remote_whole() {
let _lock = config_path_lock();
let dir = tempfile::tempdir().unwrap();
let (host, chosen) = write_host_and_chosen(
dir.path(),
"[cache.remote]\ntype = \"filesystem\"\npath = \"/mnt/kache\"\n",
Some("[cache.remote]\ntype = \"s3\"\nbucket = \"ci\"\n"),
);
let _host = set_host_config_for_test(&host);
let _chosen = set_kache_config_for_test(&chosen);
let remote = Config::load_file_config()
.unwrap()
.cache
.unwrap()
.remote
.unwrap();
assert_eq!(remote._type.as_deref(), Some("s3"));
assert_eq!(remote.bucket.as_deref(), Some("ci"));
assert_eq!(remote.path, None, "no host remote key may leak in");
}
#[test]
fn a_chosen_volume_set_replaces_the_host_volumes_whole() {
let _lock = config_path_lock();
let dir = tempfile::tempdir().unwrap();
let (host, chosen) = write_host_and_chosen(
dir.path(),
"[cache.volumes]\n\"/mnt/host\" = \"/mnt/host/kache\"\n",
Some("[cache.volumes]\n\"/mnt/project\" = \"/mnt/project/kache\"\n"),
);
let _host = set_host_config_for_test(&host);
let _chosen = set_kache_config_for_test(&chosen);
let volumes = Config::load_file_config()
.unwrap()
.cache
.unwrap()
.volumes
.unwrap();
assert_eq!(
volumes,
HashMap::from([("/mnt/project".to_string(), "/mnt/project/kache".to_string())])
);
}
#[test]
fn an_environment_planner_never_gets_the_host_planner_token() {
let _lock = config_path_lock();
let _unset: Vec<_> = PLANNER_ENV_VARS
.iter()
.chain(["KACHE_LOCAL_ONLY"].iter())
.map(|name| set_env_for_test(name, None))
.collect();
let host_planner = "[cache.planner]\nendpoint = \"http://host-planner\"\n\
token = \"host-secret\"\ntimeout_ms = 1234\n";
let job_endpoint = std::ffi::OsStr::new("http://job-planner");
for chosen_content in [Some("[cache]\n"), None] {
let dir = tempfile::tempdir().unwrap();
let (host, chosen) = write_host_and_chosen(dir.path(), host_planner, chosen_content);
let _host = set_host_config_for_test(&host);
let _chosen = set_kache_config_for_test(&chosen);
let planner = Config::load_planner_config().expect("the host planner applies");
assert_eq!(planner.token.as_deref(), Some("host-secret"));
assert_eq!(planner.timeout_ms, 1234);
let _endpoint = set_env_for_test("KACHE_PLANNER_ENDPOINT", Some(job_endpoint));
let planner = Config::load_planner_config().expect("the environment's planner");
assert_eq!(planner.endpoint, "http://job-planner");
assert_eq!(
planner.token, None,
"the host token must not reach a job-chosen endpoint ({chosen_content:?})"
);
assert_eq!(planner.timeout_ms, DEFAULT_PLANNER_TIMEOUT_MS);
}
let dir = tempfile::tempdir().unwrap();
let (host, chosen) = write_host_and_chosen(
dir.path(),
host_planner,
Some("[cache.planner]\nendpoint = \"http://project\"\ntoken = \"project-secret\"\n"),
);
let _host = set_host_config_for_test(&host);
let _chosen = set_kache_config_for_test(&chosen);
let _endpoint = set_env_for_test("KACHE_PLANNER_ENDPOINT", Some(job_endpoint));
let planner = Config::load_planner_config().expect("the chosen planner");
assert_eq!(planner.endpoint, "http://job-planner");
assert_eq!(planner.token.as_deref(), Some("project-secret"));
}
#[test]
fn an_environment_remote_beats_a_host_remote() {
let _lock = config_path_lock();
let _unset: Vec<_> = REMOTE_ENV_VARS
.iter()
.map(|name| set_env_for_test(name, None))
.collect();
for chosen_content in [Some("[cache]\nlocal_max_size = \"10GiB\"\n"), None] {
let dir = tempfile::tempdir().unwrap();
let host_content = format!(
"[cache.remote]\ntype = \"filesystem\"\npath = {:?}\n",
dir.path().join("remote")
);
let (host, chosen) = write_host_and_chosen(dir.path(), &host_content, chosen_content);
let _host = set_host_config_for_test(&host);
let _chosen = set_kache_config_for_test(&chosen);
let backend = || {
Config::load_remote_config(&Config::load_file_config())
.unwrap()
.expect("a remote is configured")
.backend
};
assert!(
matches!(backend(), RemoteBackendConfig::Filesystem(_)),
"with no S3 environment the host remote applies ({chosen_content:?})"
);
let _bucket = set_env_for_test("KACHE_S3_BUCKET", Some(std::ffi::OsStr::new("ci")));
let RemoteBackendConfig::S3(s3) = backend() else {
panic!("the environment's S3 remote must win ({chosen_content:?})");
};
assert_eq!(s3.bucket, "ci");
}
}
#[test]
fn an_unreadable_host_path_is_reported_not_treated_as_absent() {
let _lock = config_path_lock();
let dir = tempfile::tempdir().unwrap();
let host = dir.path().join("host.toml");
std::fs::create_dir(&host).unwrap();
let chosen = dir.path().join("chosen.toml");
std::fs::write(&chosen, "[cache]\nlocal_max_size = \"10GiB\"\n").unwrap();
let _host = set_host_config_for_test(&host);
let _chosen = set_kache_config_for_test(&chosen);
let status = host_config_status();
assert!(
matches!(&status, HostConfigStatus::Invalid { path, .. } if *path == host),
"{status:?}"
);
assert_eq!(
Config::load_file_config()
.unwrap()
.cache
.unwrap()
.local_max_size
.as_deref(),
Some("10GiB"),
"the chosen file still loads"
);
}
#[test]
fn a_malformed_host_file_is_ignored_and_reported() {
let _lock = config_path_lock();
for host_content in [
"[cache\nthis is not toml",
"[cache]\nlocal_max_size = 5\n", ] {
let dir = tempfile::tempdir().unwrap();
let (host, chosen) = write_host_and_chosen(
dir.path(),
host_content,
Some("[cache]\nlocal_max_size = \"10GiB\"\n"),
);
let _host = set_host_config_for_test(&host);
let _chosen = set_kache_config_for_test(&chosen);
let cache = Config::load_file_config()
.expect("a bad host file must not break the chosen one")
.cache
.unwrap();
assert_eq!(cache.local_max_size.as_deref(), Some("10GiB"));
assert!(
matches!(host_config_status(), HostConfigStatus::Invalid { .. }),
"{host_content:?} must be reported"
);
}
}
#[test]
fn the_host_layer_never_contributes_workspace_declarations() {
let _lock = config_path_lock();
let dir = tempfile::tempdir().unwrap();
let (host, chosen) = write_host_and_chosen(
dir.path(),
"[workspace]\nextra = 1\n\n[cache]\ninput_predictions = true\n",
None,
);
let _host = set_host_config_for_test(&host);
let _chosen = set_kache_config_for_test(&chosen);
let file = Config::load_file_config().unwrap();
assert!(file.workspace.is_none());
assert_eq!(
file.cache.unwrap().input_predictions,
Some(true),
"with no chosen file, the host layer is the whole file config"
);
}
#[test]
fn a_host_ignore_env_cannot_silence_the_environment() {
let _lock = config_path_lock();
let dir = tempfile::tempdir().unwrap();
let (host, chosen) = write_host_and_chosen(
dir.path(),
"[cache]\nignore_env = true\ninput_predictions = true\n",
Some("[cache]\nlocal_max_size = \"10GiB\"\n"),
);
let _host = set_host_config_for_test(&host);
let _chosen = set_kache_config_for_test(&chosen);
let _env = set_env_for_test("KACHE_INPUT_PREDICTIONS", Some(std::ffi::OsStr::new("0")));
let file = Config::load_file_config();
let cache = file.as_ref().unwrap().cache.as_ref().unwrap();
assert_eq!(cache.ignore_env, None, "the host key is dropped");
assert_eq!(
cache.input_predictions,
Some(true),
"the rest still applies"
);
assert!(
!Config::input_predictions_enabled(&file),
"the environment overrides the host file"
);
let HostConfigStatus::Present { keys, .. } = host_config_status() else {
panic!("the host file must be present");
};
assert!(
keys.iter().all(|entry| entry.key != "cache.ignore_env"),
"doctor must not list a key that has no effect: {keys:?}"
);
}
#[test]
fn editing_the_host_file_changes_the_fingerprint() {
let _lock = config_path_lock();
let dir = tempfile::tempdir().unwrap();
let content = "[cache]\nlocal_max_size = \"10GiB\"\n";
let (host, chosen) = write_host_and_chosen(
dir.path(),
"[cache]\ninput_predictions = true\n",
Some(content),
);
let _host = set_host_config_for_test(&host);
let _chosen = set_kache_config_for_test(&chosen);
let (_, provenance) = Config::load_with_provenance().unwrap();
assert!(!config_file_has_changed(&provenance));
std::fs::write(&host, "[cache]\ninput_predictions = false\n").unwrap();
assert!(config_file_has_changed(&provenance), "host edit");
std::fs::remove_file(&host).unwrap();
assert_eq!(
config_file_provenance_at(provenance.path.clone()).fingerprint,
ConfigFileProvenance::from_snapshot(
provenance.path.clone(),
ConfigFileState::Present,
content.as_bytes(),
None,
)
.fingerprint
);
}
#[test]
fn host_status_names_what_overrides_each_key() {
let _lock = config_path_lock();
let dir = tempfile::tempdir().unwrap();
let (host, chosen) = write_host_and_chosen(
dir.path(),
"[cache]\ninput_predictions = true\nlocal_max_size = \"50GiB\"\nlocal_only = true\n\n\
[cache.remote]\ntype = \"s3\"\nbucket = \"host\"\n",
Some("[cache]\nlocal_max_size = \"10GiB\"\n"),
);
let _host = set_host_config_for_test(&host);
let _chosen = set_kache_config_for_test(&chosen);
let _predictions =
set_env_for_test("KACHE_INPUT_PREDICTIONS", Some(std::ffi::OsStr::new("1")));
let _local_only = set_env_for_test("KACHE_LOCAL_ONLY", None);
let _remote: Vec<_> = REMOTE_ENV_VARS
.iter()
.map(|name| set_env_for_test(name, None))
.collect();
let HostConfigStatus::Present { keys, .. } = host_config_status() else {
panic!("the host file must be present");
};
let source = |key: &str| {
keys.iter()
.find(|entry| entry.key == key)
.unwrap_or_else(|| panic!("{key} missing from {keys:?}"))
.source
.clone()
};
assert_eq!(
source("cache.input_predictions"),
HostKeySource::Env("KACHE_INPUT_PREDICTIONS")
);
assert_eq!(
source("cache.local_max_size"),
HostKeySource::ChosenFile(normalize_config_path(chosen))
);
assert_eq!(source("cache.local_only"), HostKeySource::Host);
assert_eq!(source("cache.remote"), HostKeySource::Host);
assert_eq!(keys.len(), 4, "a replaced-whole table is listed once");
}
#[test]
fn host_status_names_the_variable_config_reads() {
let _lock = config_path_lock();
let dir = tempfile::tempdir().unwrap();
let (host, chosen) = write_host_and_chosen(
dir.path(),
"[cache]\nlocal_max_size = \"50GiB\"\nlocal_store = \"/srv/kache\"\n\
daemon_idle_timeout_secs = 60\n\n\
[cc]\nextra_allowlist_flags = [\"-x\"]\n\n\
[cache.planner]\nendpoint = \"http://planner\"\n\n\
[cache.remote]\ntype = \"s3\"\nbucket = \"host\"\n",
Some("[cache]\n"),
);
let _host = set_host_config_for_test(&host);
let _chosen = set_kache_config_for_test(&chosen);
let expected = [
("cache.local_max_size", "KACHE_MAX_SIZE"),
("cache.local_store", "KACHE_CACHE_DIR"),
(
"cache.daemon_idle_timeout_secs",
"KACHE_DAEMON_IDLE_TIMEOUT",
),
("cc.extra_allowlist_flags", "KACHE_CC_EXTRA_ALLOWLIST_FLAGS"),
("cache.planner", "KACHE_PLANNER_TOKEN"),
("cache.remote", "KACHE_S3_REGION"),
];
let _unset: Vec<_> = ENV_FILE_KEYS
.iter()
.map(|(var, _)| set_env_for_test(var, None))
.collect();
let _set: Vec<_> = expected
.iter()
.map(|(_, var)| set_env_for_test(var, Some(std::ffi::OsStr::new("1"))))
.collect();
let HostConfigStatus::Present { keys, .. } = host_config_status() else {
panic!("the host file must be present");
};
for (key, var) in expected {
let entry = keys
.iter()
.find(|entry| entry.key == key)
.unwrap_or_else(|| panic!("{key} missing from {keys:?}"));
assert_eq!(entry.source, HostKeySource::Env(var), "{key}");
}
}
#[test]
fn every_env_var_config_reads_maps_to_a_config_key() {
const OPERATIONAL: &[&str] = &[
"KACHE_CONFIG",
"KACHE_HOST_CONFIG",
"KACHE_DISABLED",
"KACHE_SOCKET_PATH",
"KACHE_TRUST_DOMAIN",
];
let source = include_str!("config.rs");
let source = &source[..source
.find("\npub(crate) mod tests {")
.expect("the tests module")];
let read: std::collections::BTreeSet<&str> = source
.match_indices("\"KACHE_")
.map(|(start, _)| {
let name = &source[start + 1..];
let end = name
.find(|c: char| !(c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_'))
.unwrap_or(name.len());
&name[..end]
})
.collect();
let mapped: std::collections::BTreeSet<&str> =
ENV_FILE_KEYS.iter().map(|(var, _)| *var).collect();
assert_eq!(
mapped.len(),
ENV_FILE_KEYS.len(),
"a variable is listed twice"
);
for name in &read {
assert!(
mapped.contains(name) || OPERATIONAL.contains(name),
"{name} is read but has no ENV_FILE_KEYS entry"
);
}
for var in &mapped {
assert!(read.contains(var), "{var} is listed but never read");
}
let gated: std::collections::BTreeSet<&str> =
IGNORE_ENV_GATED_VARS.iter().copied().collect();
assert_eq!(
gated, mapped,
"ignore_env gates exactly the file-backed variables"
);
let remote: Vec<&str> = ENV_FILE_KEYS
.iter()
.filter(|(_, key)| key.starts_with("cache.remote."))
.map(|(var, _)| *var)
.collect();
assert_eq!(remote, REMOTE_ENV_VARS);
let planner: Vec<&str> = ENV_FILE_KEYS
.iter()
.filter(|(_, key)| key.starts_with("cache.planner."))
.map(|(var, _)| *var)
.collect();
assert_eq!(planner, PLANNER_ENV_VARS);
}
#[test]
fn every_env_file_key_is_a_config_file_key() {
let candidates = [
toml::Value::String("1".into()),
toml::Value::Integer(1),
toml::Value::Boolean(true),
toml::Value::Array(vec![toml::Value::String("x".into())]),
];
for (var, key) in ENV_FILE_KEYS {
let path: Vec<&str> = key.split('.').collect();
let survives = candidates.iter().any(|candidate| {
let mut value = candidate.clone();
for segment in path.iter().rev() {
value =
toml::Value::Table(toml::Table::from_iter([(segment.to_string(), value)]));
}
let Ok(file) = value.try_into::<FileConfig>() else {
return false;
};
let Ok(back) = toml::Value::try_from(&file) else {
return false;
};
path.iter()
.try_fold(&back, |value, segment| value.get(*segment))
.is_some()
});
assert!(survives, "{var} maps to {key}, which the config file lacks");
}
}
#[test]
fn config_provenance_makes_an_explicit_path_absolute_without_resolving_it() {
let _lock = config_path_lock();
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir(dir.path().join("nested")).unwrap();
let cfg = dir.path().join("nested/../config.toml");
std::fs::write(dir.path().join("config.toml"), "[cache]\n").unwrap();
let _g = set_kache_config_for_test(&cfg);
let (_, provenance) = Config::load_with_provenance().unwrap();
assert!(provenance.path.is_absolute());
assert_eq!(provenance.path, std::path::absolute(&cfg).unwrap());
}
#[test]
fn relative_config_paths_are_bound_to_the_loading_working_directory() {
let dir = tempfile::tempdir().unwrap();
let first = dir.path().join("first");
let second = dir.path().join("second");
std::fs::create_dir_all(&first).unwrap();
std::fs::create_dir_all(&second).unwrap();
let relative = PathBuf::from("config.toml");
let first_path = normalize_config_path_from(relative.clone(), Some(&first));
let second_path = normalize_config_path_from(relative, Some(&second));
assert_eq!(first_path, first.join("config.toml"));
assert_eq!(second_path, second.join("config.toml"));
assert_ne!(first_path, second_path);
}
#[test]
#[cfg(unix)]
fn config_provenance_keeps_symlink_identity_and_detects_retarget() {
use std::os::unix::fs::symlink;
let _lock = config_path_lock();
let dir = tempfile::tempdir().unwrap();
let first = dir.path().join("first.toml");
let second = dir.path().join("second.toml");
let active = dir.path().join("active.toml");
std::fs::write(&first, "[cache]\nlocal_max_size = \"1GiB\"\n").unwrap();
std::fs::write(&second, "[cache]\nlocal_max_size = \"2GiB\"\n").unwrap();
symlink(&first, &active).unwrap();
let _g = set_kache_config_for_test(&active);
let (_, loaded) = Config::load_with_provenance().unwrap();
assert_eq!(
loaded.path, active,
"provenance must retain the symlink path"
);
std::fs::remove_file(&active).unwrap();
symlink(&second, &active).unwrap();
assert_ne!(
loaded.fingerprint,
config_file_provenance_at(loaded.path.clone()).fingerprint,
"retargeting the configured symlink must trip the watcher"
);
assert!(config_file_has_changed(&loaded));
}
#[test]
fn normalize_volume_root_canonicalizes_roots() {
assert_eq!(Config::normalize_volume_root(""), None);
assert_eq!(Config::normalize_volume_root(" "), None);
if cfg!(windows) {
assert_eq!(Config::normalize_volume_root("D:").as_deref(), Some("D:\\"));
assert_eq!(
Config::normalize_volume_root("d:/").as_deref(),
Some("D:\\")
);
assert_eq!(
Config::normalize_volume_root("\\\\srv\\share").as_deref(),
Some("\\\\SRV\\SHARE\\")
);
} else {
assert_eq!(Config::normalize_volume_root("/").as_deref(), Some("/"));
assert_eq!(
Config::normalize_volume_root("/mnt/biglake/").as_deref(),
Some("/mnt/biglake/")
);
assert_eq!(Config::normalize_volume_root("relative/dir"), None);
assert_eq!(Config::normalize_volume_root("D:"), None);
}
}
#[test]
fn ensure_trailing_sep_appends_only_when_missing() {
assert_eq!(
Config::ensure_trailing_sep("D:\\".to_string(), '\\'),
"D:\\"
);
assert_eq!(Config::ensure_trailing_sep("D:".to_string(), '\\'), "D:\\");
assert_eq!(
Config::ensure_trailing_sep("/mnt".to_string(), '/'),
"/mnt/"
);
assert_eq!(Config::ensure_trailing_sep(String::new(), '/'), "/");
}
#[test]
fn load_volume_stores_dedupes_duplicate_roots_deterministically() {
let file_config: Result<FileConfig> = Ok(FileConfig {
cache: Some(CacheFileConfig {
volumes: Some(
[
("/mnt/".to_string(), "b-store".to_string()),
("/mnt".to_string(), "a-store".to_string()),
("/other/".to_string(), "c-store".to_string()),
]
.into_iter()
.collect(),
),
..Default::default()
}),
..Default::default()
});
let shards = Config::load_volume_stores(&file_config);
let kept: Vec<(&str, &str)> = shards
.iter()
.map(|shard| (shard.volume.as_str(), shard.store.to_str().unwrap()))
.collect();
if cfg!(windows) {
assert_eq!(kept.len(), 2);
} else {
assert_eq!(
kept,
vec![("/mnt/", "a-store"), ("/other/", "c-store"),],
"duplicates collapse to the first store, distinct roots survive"
);
}
}
#[test]
fn match_volume_store_prefers_the_tightest_root() {
let shards = |loose: &str, tight: &str| {
vec![
VolumeStore {
volume: Config::normalize_volume_root(loose).unwrap(),
store: PathBuf::from("loose-store"),
},
VolumeStore {
volume: Config::normalize_volume_root(tight).unwrap(),
store: PathBuf::from("tight-store"),
},
]
};
#[cfg(windows)]
{
let shards = shards("C:\\", "C:\\work\\");
assert_eq!(
Config::match_volume_store(&shards, Path::new("C:\\work\\a.rlib")),
Some(Path::new("tight-store")),
"overlapping roots resolve to the longest match"
);
assert_eq!(
Config::match_volume_store(&shards, Path::new("C:\\other\\a.rlib")),
Some(Path::new("loose-store"))
);
assert_eq!(
Config::match_volume_store(&shards, Path::new("D:\\a.rlib")),
None
);
}
#[cfg(not(windows))]
{
let shards = shards("/mnt/", "/mnt/biglake/");
assert_eq!(
Config::match_volume_store(&shards, Path::new("/mnt/biglake/work/a.rlib")),
Some(Path::new("tight-store")),
"overlapping roots resolve to the longest match"
);
assert_eq!(
Config::match_volume_store(&shards, Path::new("/mnt/other/a.rlib")),
Some(Path::new("loose-store"))
);
assert_eq!(
Config::match_volume_store(&shards, Path::new("/home/u/a.rlib")),
None
);
}
assert!(Config::match_volume_store(&[], Path::new("x")).is_none());
}
#[test]
fn load_volume_stores_skips_invalid_entries() {
let file_config: Result<FileConfig> = Ok(FileConfig {
cache: Some(CacheFileConfig {
volumes: Some(
[
("D:".to_string(), "D:/kache-store".to_string()),
("".to_string(), "D:/nowhere".to_string()),
("E:".to_string(), " ".to_string()),
]
.into_iter()
.collect(),
),
..Default::default()
}),
..Default::default()
});
let shards = Config::load_volume_stores(&file_config);
if cfg!(windows) {
assert_eq!(shards.len(), 1);
assert_eq!(shards[0].volume, "D:\\");
} else {
assert!(shards.is_empty());
}
}
#[test]
fn volume_store_for_routes_by_toml_config() {
let _guard = config_path_lock();
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("kache/config.toml");
let _env_guard = set_kache_config_for_test(&config_path);
std::fs::create_dir_all(config_path.parent().unwrap()).unwrap();
#[cfg(windows)]
std::fs::write(
&config_path,
"[cache]\nlocal_store = \"C:/kache-test-cache\"\n[cache.volumes]\n\"D:\" = \"D:/kache-test-store\"\n",
)
.unwrap();
#[cfg(not(windows))]
std::fs::write(
&config_path,
"[cache]\nlocal_store = \"/tmp/kache-test-cache\"\n[cache.volumes]\n\"/mnt/biglake\" = \"/mnt/biglake/kache-test-store\"\n",
)
.unwrap();
let config = Config::load().unwrap();
assert_eq!(config.volume_stores.len(), 1);
#[cfg(windows)]
{
assert_eq!(
config.volume_store_for(Path::new("D:/work/a.rlib")),
Some(Path::new("D:/kache-test-store"))
);
assert_eq!(config.volume_store_for(Path::new("C:/work/a.rlib")), None);
let routed = config.routed_for_path(Path::new("D:/work/a.rlib"));
assert_eq!(routed.cache_dir, PathBuf::from("D:/kache-test-store"));
assert_eq!(routed.runtime_dir, config.runtime_dir);
let unmapped = config.routed_for_path(Path::new("C:/work/a.rlib"));
assert_eq!(unmapped.cache_dir, config.cache_dir);
}
#[cfg(not(windows))]
{
assert_eq!(
config.volume_store_for(Path::new("/mnt/biglake/work/a.rlib")),
Some(Path::new("/mnt/biglake/kache-test-store"))
);
assert_eq!(config.volume_store_for(Path::new("/home/u/a.rlib")), None);
let routed = config.routed_for_path(Path::new("/mnt/biglake/work/a.rlib"));
assert_eq!(
routed.cache_dir,
PathBuf::from("/mnt/biglake/kache-test-store")
);
assert_eq!(routed.runtime_dir, config.runtime_dir);
let unmapped = config.routed_for_path(Path::new("/home/u/a.rlib"));
assert_eq!(unmapped.cache_dir, config.cache_dir);
}
}
#[test]
fn absolutize_volume_path_keeps_absolute_and_joins_relative() {
#[cfg(windows)]
let abs = Path::new(r"C:\kache-vol-abs");
#[cfg(not(windows))]
let abs = Path::new("/tmp/kache-vol-abs");
assert_eq!(absolutize_volume_path(abs), abs);
let rel = absolutize_volume_path(Path::new("kache-vol-rel"));
assert!(rel.is_absolute(), "relative paths must join onto cwd");
assert!(rel.ends_with("kache-vol-rel"));
assert_ne!(rel, PathBuf::new());
}
#[test]
fn deferred_discovery_is_on_unless_switched_off() {
let _lock = config_path_lock();
let none: Result<FileConfig> = Err(anyhow::anyhow!("no file"));
let off: Result<FileConfig> =
Ok(toml::from_str("[cache]\ndeferred_discovery = false\n").unwrap());
let on: Result<FileConfig> =
Ok(toml::from_str("[cache]\ndeferred_discovery = true\n").unwrap());
unsafe { std::env::remove_var("KACHE_DEFERRED_DISCOVERY") };
assert!(Config::deferred_discovery_enabled(&none));
assert!(!Config::deferred_discovery_enabled(&off));
assert!(Config::deferred_discovery_enabled(&on));
for (value, expected) in [
("0", false),
("false", false),
("FALSE", false),
("1", true),
("true", true),
("yes", true),
("", true),
] {
unsafe { std::env::set_var("KACHE_DEFERRED_DISCOVERY", value) };
assert_eq!(
Config::deferred_discovery_enabled(&on),
expected,
"{value:?}"
);
assert_eq!(
Config::deferred_discovery_enabled(&off),
expected,
"{value:?} overrides the file"
);
}
unsafe { std::env::remove_var("KACHE_DEFERRED_DISCOVERY") };
}
#[test]
fn shared_hardlink_restores_are_off_unless_switched_on() {
let _lock = config_path_lock();
let none: Result<FileConfig> = Err(anyhow::anyhow!("no file"));
let off: Result<FileConfig> =
Ok(toml::from_str("[cache]\nshared_hardlink_restores = false\n").unwrap());
let on: Result<FileConfig> =
Ok(toml::from_str("[cache]\nshared_hardlink_restores = true\n").unwrap());
unsafe { std::env::remove_var("KACHE_SHARED_HARDLINK_RESTORES") };
assert!(!Config::shared_hardlink_restores_enabled(&none));
assert!(!Config::shared_hardlink_restores_enabled(&off));
assert!(Config::shared_hardlink_restores_enabled(&on));
for (value, expected) in [
("1", true),
("true", true),
("TRUE", true),
("0", false),
("false", false),
("yes", false),
("", false),
] {
unsafe { std::env::set_var("KACHE_SHARED_HARDLINK_RESTORES", value) };
assert_eq!(
Config::shared_hardlink_restores_enabled(&off),
expected,
"{value:?}"
);
assert_eq!(
Config::shared_hardlink_restores_enabled(&on),
expected,
"{value:?} overrides the file"
);
}
unsafe { std::env::remove_var("KACHE_SHARED_HARDLINK_RESTORES") };
}
#[test]
fn probe_memos_live_under_the_configured_cache_dir() {
let _lock = config_path_lock();
let dir = tempfile::tempdir().unwrap();
unsafe { std::env::set_var("KACHE_CACHE_DIR", dir.path()) };
let probes = probe_memo_dir();
unsafe { std::env::remove_var("KACHE_CACHE_DIR") };
assert_eq!(probes, dir.path().join("probes"));
let default = probe_memo_dir();
assert_eq!(default, default_cache_dir().join("probes"));
assert!(default.is_absolute());
}
#[test]
fn test_file_config_roundtrip() {
let config = FileConfig {
cc: None,
paths: None,
workspace: None,
cache: Some(CacheFileConfig {
bypass_env: None,
bypass_argv: None,
bypass_crates: None,
local_only: None,
remote_readonly: None,
volumes: None,
modified_input_guard: None,
input_predictions: None,
record_sessions: None,
local_hit_daemon: None,
windows_hardlink: None,
shared_hardlink_restores: None,
deferred_discovery: None,
auto_gc: None,
gc_evict_shared: None,
storage_layout_advice: None,
heartbeat_secs: None,
explain_miss: None,
ignore_env: None,
fallback: None,
key_salt: None,
path_only_env_vars: None,
incremental_crates: None,
key_env_vars: Some(vec!["BOLTFFI_*".to_string()]),
local_store: Some("~/my/cache".to_string()),
runtime_dir: Some("~/my/runtime".to_string()),
local_max_size: Some("50GiB".to_string()),
planner: None,
cache_executables: Some(true),
cache_cc_links: None,
clean_incremental: Some(false),
preserve_incremental: Some(true),
adaptive_incremental: Some(false),
exclude: Some(vec!["vendor/problem/**".to_string()]),
event_log_max_size: Some("10MiB".to_string()),
event_log_keep_lines: Some(500),
compression_level: Some(3),
s3_concurrency: Some(8),
prefetch_enabled: None,
remote_key_cache_refresh_secs: None,
prefetch_max_keys: None,
prefetch_max_bytes: None,
prefetch_deadline_secs: None,
min_store_compile_ms: None,
gc_max_age_hours: None,
daemon_idle_timeout_secs: None,
s3_pool_idle_secs: None,
remote_restore_timeout_secs: None,
remote_negative_ttl_secs: None,
remote: Some(RemoteFileConfig {
_type: Some("s3".to_string()),
bucket: Some("my-bucket".to_string()),
endpoint: Some("https://s3.example.com".to_string()),
region: Some("eu-west-1".to_string()),
prefix: Some("my-prefix".to_string()),
profile: None,
user_agent: None,
path: None,
atomic_write_dir: None,
}),
scheduler: None,
}),
};
let serialized = toml::to_string_pretty(&config).unwrap();
let deserialized: FileConfig = toml::from_str(&serialized).unwrap();
assert_eq!(
deserialized.cache.as_ref().unwrap().local_store.as_deref(),
Some("~/my/cache")
);
assert_eq!(
deserialized.cache.as_ref().unwrap().runtime_dir.as_deref(),
Some("~/my/runtime")
);
assert_eq!(
deserialized.cache.as_ref().unwrap().exclude.as_deref(),
Some(&["vendor/problem/**".to_string()][..])
);
assert_eq!(
deserialized.cache.as_ref().unwrap().key_env_vars.as_deref(),
Some(&["BOLTFFI_*".to_string()][..])
);
assert_eq!(
deserialized.cache.as_ref().unwrap().preserve_incremental,
Some(true)
);
assert_eq!(
deserialized.cache.as_ref().unwrap().adaptive_incremental,
Some(false)
);
assert_eq!(
deserialized
.cache
.as_ref()
.unwrap()
.remote
.as_ref()
.unwrap()
.bucket
.as_deref(),
Some("my-bucket")
);
}
#[test]
fn test_file_config_empty_remote_omitted() {
let config = FileConfig {
cc: None,
paths: None,
workspace: None,
cache: Some(CacheFileConfig {
local_store: Some("~/cache".to_string()),
remote: Some(RemoteFileConfig::default()),
..Default::default()
}),
};
let serialized = toml::to_string_pretty(&config).unwrap();
assert!(!serialized.contains("bucket"));
assert!(!serialized.contains("endpoint"));
}
#[test]
fn test_key_salt_file_env_precedence() {
let _guard = config_path_lock();
let prev_salt = std::env::var_os("KACHE_KEY_SALT");
let restore_salt = |v: &Option<OsString>| unsafe {
match v {
Some(val) => std::env::set_var("KACHE_KEY_SALT", val),
None => std::env::remove_var("KACHE_KEY_SALT"),
}
};
restore_salt(&None);
let dir = tempfile::tempdir().unwrap();
let cfg_path = dir.path().join("config.toml");
std::fs::write(&cfg_path, "[cache]\nkey_salt = \"from-file\"\n").unwrap();
let _cfg_guard = set_kache_config_for_test(&cfg_path);
assert_eq!(
Config::load().unwrap().key_salt.as_deref(),
Some("from-file")
);
unsafe { std::env::set_var("KACHE_KEY_SALT", "from-env") };
assert_eq!(
Config::load().unwrap().key_salt.as_deref(),
Some("from-env")
);
unsafe { std::env::set_var("KACHE_KEY_SALT", " ") };
assert_eq!(Config::load().unwrap().key_salt, None);
restore_salt(&prev_salt);
}
#[test]
fn test_preserve_incremental_file_env_precedence() {
let _guard = config_path_lock();
let previous = std::env::var_os("KACHE_PRESERVE_INCREMENTAL");
let restore = |value: &Option<OsString>| unsafe {
match value {
Some(value) => std::env::set_var("KACHE_PRESERVE_INCREMENTAL", value),
None => std::env::remove_var("KACHE_PRESERVE_INCREMENTAL"),
}
};
restore(&None);
let dir = tempfile::tempdir().unwrap();
let cfg_path = dir.path().join("config.toml");
std::fs::write(&cfg_path, "[cache]\n").unwrap();
let _cfg_guard = set_kache_config_for_test(&cfg_path);
assert!(!Config::load().unwrap().preserve_incremental);
std::fs::write(&cfg_path, "[cache]\npreserve_incremental = true\n").unwrap();
assert!(Config::load().unwrap().preserve_incremental);
unsafe { std::env::set_var("KACHE_PRESERVE_INCREMENTAL", "TRUE") };
assert!(Config::load().unwrap().preserve_incremental);
unsafe { std::env::set_var("KACHE_PRESERVE_INCREMENTAL", "false") };
assert!(!Config::load().unwrap().preserve_incremental);
std::fs::write(
&cfg_path,
"[cache]\nignore_env = true\npreserve_incremental = true\n",
)
.unwrap();
assert!(Config::load().unwrap().preserve_incremental);
assert!(IGNORE_ENV_GATED_VARS.contains(&"KACHE_PRESERVE_INCREMENTAL"));
restore(&previous);
}
#[test]
fn test_adaptive_incremental_default_file_env_precedence() {
let _guard = config_path_lock();
let dir = tempfile::tempdir().unwrap();
let cfg_path = dir.path().join("config.toml");
let _cfg_guard = set_kache_config_for_test(&cfg_path);
std::fs::write(&cfg_path, "[cache]\n").unwrap();
{
let _adaptive = NamedEnvGuard::remove("KACHE_ADAPTIVE_INCREMENTAL");
assert!(Config::load().unwrap().adaptive_incremental);
}
std::fs::write(&cfg_path, "[cache]\nadaptive_incremental = false\n").unwrap();
{
let _adaptive = NamedEnvGuard::remove("KACHE_ADAPTIVE_INCREMENTAL");
assert!(!Config::load().unwrap().adaptive_incremental);
}
{
let _adaptive = NamedEnvGuard::set("KACHE_ADAPTIVE_INCREMENTAL", "true");
assert!(Config::load().unwrap().adaptive_incremental);
}
std::fs::write(&cfg_path, "[cache]\nadaptive_incremental = true\n").unwrap();
{
let _adaptive = NamedEnvGuard::set("KACHE_ADAPTIVE_INCREMENTAL", "false");
assert!(!Config::load().unwrap().adaptive_incremental);
}
std::fs::write(
&cfg_path,
"[cache]\nignore_env = true\nadaptive_incremental = false\n",
)
.unwrap();
{
let _adaptive = NamedEnvGuard::set("KACHE_ADAPTIVE_INCREMENTAL", "true");
assert!(!Config::load().unwrap().adaptive_incremental);
assert!(!EnvOverrides::detect().adaptive_incremental);
}
assert!(IGNORE_ENV_GATED_VARS.contains(&"KACHE_ADAPTIVE_INCREMENTAL"));
}
#[test]
fn test_incremental_crates_default_file_env_precedence() {
let _guard = config_path_lock();
let dir = tempfile::tempdir().unwrap();
let cfg_path = dir.path().join("config.toml");
let _cfg_guard = set_kache_config_for_test(&cfg_path);
std::fs::write(&cfg_path, "[cache]\n").unwrap();
{
let _crates = NamedEnvGuard::remove("KACHE_INCREMENTAL_CRATES");
assert!(Config::load().unwrap().incremental_crates.is_empty());
}
std::fs::write(
&cfg_path,
"[cache]\nincremental_crates = [\"tap-lib\", \"other\"]\n",
)
.unwrap();
{
let _crates = NamedEnvGuard::remove("KACHE_INCREMENTAL_CRATES");
assert_eq!(
Config::load().unwrap().incremental_crates,
vec!["other".to_string(), "tap_lib".to_string()]
);
}
{
let _crates =
NamedEnvGuard::set("KACHE_INCREMENTAL_CRATES", "tap_lib, tap-lib\tzeta ,");
assert_eq!(
Config::load().unwrap().incremental_crates,
vec!["tap_lib".to_string(), "zeta".to_string()]
);
}
std::fs::write(
&cfg_path,
"[cache]\nignore_env = true\nincremental_crates = [\"from_file\"]\n",
)
.unwrap();
{
let _crates = NamedEnvGuard::set("KACHE_INCREMENTAL_CRATES", "from_env");
assert_eq!(
Config::load().unwrap().incremental_crates,
vec!["from_file".to_string()]
);
}
assert!(IGNORE_ENV_GATED_VARS.contains(&"KACHE_INCREMENTAL_CRATES"));
}
#[test]
fn test_incremental_crate_forced_matches_normalized_names_only() {
let list = normalize_incremental_crates(["tap-lib".to_string()]);
assert!(incremental_crate_forced_in(&list, "tap_lib"));
assert!(incremental_crate_forced_in(&list, "tap-lib"));
assert!(!incremental_crate_forced_in(&list, "tap_lib_extra"));
assert!(!incremental_crate_forced_in(&list, "tap"));
assert!(!incremental_crate_forced_in(&list, "other"));
assert!(!incremental_crate_forced_in(&[], "tap_lib"));
assert!(!incremental_crate_forced_in(&[], ""));
assert_eq!(
normalize_incremental_crates(
[" b-crate ", "", "a_crate", "b_crate"]
.into_iter()
.map(str::to_string)
),
vec!["a_crate".to_string(), "b_crate".to_string()]
);
}
#[test]
fn test_cc_extra_allowlist_flags_file_env_precedence() {
let _guard = config_path_lock();
let prev = std::env::var_os("KACHE_CC_EXTRA_ALLOWLIST_FLAGS");
let restore = |v: &Option<OsString>| unsafe {
match v {
Some(val) => std::env::set_var("KACHE_CC_EXTRA_ALLOWLIST_FLAGS", val),
None => std::env::remove_var("KACHE_CC_EXTRA_ALLOWLIST_FLAGS"),
}
};
restore(&None);
let dir = tempfile::tempdir().unwrap();
let cfg_path = dir.path().join("config.toml");
std::fs::write(
&cfg_path,
"[cc]\nextra_allowlist_flags = [\"-ffunction-sections\", \"-fdata-sections\"]\n",
)
.unwrap();
let _cfg_guard = set_kache_config_for_test(&cfg_path);
assert_eq!(
Config::load().unwrap().cc_extra_allowlist_flags,
vec![
"-ffunction-sections".to_string(),
"-fdata-sections".to_string()
]
);
unsafe {
std::env::set_var(
"KACHE_CC_EXTRA_ALLOWLIST_FLAGS",
" -fno-rtti -fno-rtti -fbravo ",
)
};
assert_eq!(
Config::load().unwrap().cc_extra_allowlist_flags,
vec!["-fno-rtti".to_string(), "-fbravo".to_string()]
);
unsafe { std::env::set_var("KACHE_CC_EXTRA_ALLOWLIST_FLAGS", " ") };
assert!(Config::load().unwrap().cc_extra_allowlist_flags.is_empty());
restore(&prev);
}
#[test]
fn test_key_env_vars_file_env_precedence() {
let _guard = config_path_lock();
let prev = std::env::var_os("KACHE_KEY_ENV_VARS");
let restore = |v: &Option<OsString>| unsafe {
match v {
Some(val) => std::env::set_var("KACHE_KEY_ENV_VARS", val),
None => std::env::remove_var("KACHE_KEY_ENV_VARS"),
}
};
restore(&None);
let dir = tempfile::tempdir().unwrap();
let cfg_path = dir.path().join("config.toml");
std::fs::write(
&cfg_path,
"[cache]\nkey_env_vars = [\"BOLTFFI_*\", \"APP_MODE\"]\n",
)
.unwrap();
let _cfg_guard = set_kache_config_for_test(&cfg_path);
assert_eq!(
Config::load().unwrap().key_env_vars,
vec!["APP_MODE".to_string(), "BOLTFFI_*".to_string()]
);
unsafe { std::env::set_var("KACHE_KEY_ENV_VARS", " ZULU, ALPHA ,ALPHA,, BRAVO ") };
assert_eq!(
Config::load().unwrap().key_env_vars,
vec!["ALPHA".to_string(), "BRAVO".to_string(), "ZULU".to_string()]
);
unsafe { std::env::set_var("KACHE_KEY_ENV_VARS", " ") };
assert!(Config::load().unwrap().key_env_vars.is_empty());
restore(&prev);
}
#[test]
fn test_key_env_vars_ignore_env_makes_file_win() {
let _guard = config_path_lock();
let prev = std::env::var_os("KACHE_KEY_ENV_VARS");
let restore = |v: &Option<OsString>| unsafe {
match v {
Some(val) => std::env::set_var("KACHE_KEY_ENV_VARS", val),
None => std::env::remove_var("KACHE_KEY_ENV_VARS"),
}
};
let dir = tempfile::tempdir().unwrap();
let cfg_path = dir.path().join("config.toml");
std::fs::write(
&cfg_path,
"[cache]\nignore_env = true\nkey_env_vars = [\"APP_MODE\"]\n",
)
.unwrap();
let _cfg_guard = set_kache_config_for_test(&cfg_path);
unsafe { std::env::set_var("KACHE_KEY_ENV_VARS", "SOMETHING_ELSE") };
assert_eq!(
Config::load().unwrap().key_env_vars,
vec!["APP_MODE".to_string()]
);
assert!(IGNORE_ENV_GATED_VARS.contains(&"KACHE_KEY_ENV_VARS"));
restore(&prev);
}
#[test]
fn test_normalize_key_env_vars_keeps_interior_star_pattern() {
assert_eq!(
normalize_key_env_vars(["A*B".to_string(), " ".to_string()], "test"),
vec!["A*B".to_string()]
);
}
#[test]
fn test_env_overrides_detect() {
let overrides = EnvOverrides::detect();
let _ = overrides.disabled;
let _ = overrides.cache_dir;
}
#[test]
fn test_config_store_dir() {
let config = Config {
fallback: None,
key_salt: None,
cc_extra_allowlist_flags: Vec::new(),
local_only: false,
remote_readonly: false,
modified_input_guard: false,
input_predictions: false,
record_sessions: false,
volume_stores: Vec::new(),
local_hit_daemon: false,
windows_hardlink: false,
shared_hardlink_restores: false,
deferred_discovery: true,
auto_gc: true,
gc_evict_shared: false,
storage_layout_advice: true,
heartbeat_secs: 30,
explain_miss: false,
scheduler: true,
path_only_env_vars: Vec::new(),
incremental_crates: Vec::new(),
key_env_vars: Vec::new(),
base_dirs: Vec::new(),
cache_dir: PathBuf::from("/tmp/kache"),
runtime_dir: PathBuf::from("/tmp/kache"),
socket_path_override: None,
max_size: 1024,
remote: None,
remote_error: None,
disabled: false,
cache_executables: false,
cache_cc_links: false,
clean_incremental: true,
preserve_incremental: false,
adaptive_incremental: true,
event_log_max_size: 1024,
event_log_keep_lines: 100,
compression_level: 3,
s3_concurrency: 16,
prefetch_enabled: DEFAULT_PREFETCH_ENABLED,
remote_key_cache_refresh_secs: DEFAULT_REMOTE_KEY_CACHE_REFRESH_SECS,
prefetch_max_keys: DEFAULT_PREFETCH_MAX_KEYS,
prefetch_max_bytes: DEFAULT_PREFETCH_MAX_BYTES,
prefetch_deadline_secs: DEFAULT_PREFETCH_DEADLINE_SECS,
min_store_compile_ms: DEFAULT_MIN_STORE_COMPILE_MS,
gc_max_age_hours: DEFAULT_GC_MAX_AGE_HOURS,
daemon_idle_timeout_secs: DEFAULT_DAEMON_IDLE_TIMEOUT_SECS,
s3_pool_idle_secs: DEFAULT_S3_POOL_IDLE_SECS,
remote_restore_timeout_secs: DEFAULT_REMOTE_RESTORE_TIMEOUT_SECS,
remote_negative_ttl_secs: DEFAULT_REMOTE_NEGATIVE_TTL_SECS,
};
assert_eq!(config.store_dir(), PathBuf::from("/tmp/kache/store"));
assert_eq!(
config.upload_spool_dir(),
PathBuf::from("/tmp/kache/upload-queue")
);
}
#[test]
fn test_config_index_db_path() {
let config = Config {
fallback: None,
key_salt: None,
cc_extra_allowlist_flags: Vec::new(),
local_only: false,
remote_readonly: false,
modified_input_guard: false,
input_predictions: false,
record_sessions: false,
volume_stores: Vec::new(),
local_hit_daemon: false,
windows_hardlink: false,
shared_hardlink_restores: false,
deferred_discovery: true,
auto_gc: true,
gc_evict_shared: false,
storage_layout_advice: true,
heartbeat_secs: 30,
explain_miss: false,
scheduler: true,
path_only_env_vars: Vec::new(),
incremental_crates: Vec::new(),
key_env_vars: Vec::new(),
base_dirs: Vec::new(),
cache_dir: PathBuf::from("/tmp/kache"),
runtime_dir: PathBuf::from("/tmp/kache"),
socket_path_override: None,
max_size: 1024,
remote: None,
remote_error: None,
disabled: false,
cache_executables: false,
cache_cc_links: false,
clean_incremental: true,
preserve_incremental: false,
adaptive_incremental: true,
event_log_max_size: 1024,
event_log_keep_lines: 100,
compression_level: 3,
s3_concurrency: 16,
prefetch_enabled: DEFAULT_PREFETCH_ENABLED,
remote_key_cache_refresh_secs: DEFAULT_REMOTE_KEY_CACHE_REFRESH_SECS,
prefetch_max_keys: DEFAULT_PREFETCH_MAX_KEYS,
prefetch_max_bytes: DEFAULT_PREFETCH_MAX_BYTES,
prefetch_deadline_secs: DEFAULT_PREFETCH_DEADLINE_SECS,
min_store_compile_ms: DEFAULT_MIN_STORE_COMPILE_MS,
gc_max_age_hours: DEFAULT_GC_MAX_AGE_HOURS,
daemon_idle_timeout_secs: DEFAULT_DAEMON_IDLE_TIMEOUT_SECS,
s3_pool_idle_secs: DEFAULT_S3_POOL_IDLE_SECS,
remote_restore_timeout_secs: DEFAULT_REMOTE_RESTORE_TIMEOUT_SECS,
remote_negative_ttl_secs: DEFAULT_REMOTE_NEGATIVE_TTL_SECS,
};
assert_eq!(config.index_db_path(), PathBuf::from("/tmp/kache/index.db"));
}
#[test]
fn test_config_event_log_path() {
let config = Config {
fallback: None,
key_salt: None,
cc_extra_allowlist_flags: Vec::new(),
local_only: false,
remote_readonly: false,
modified_input_guard: false,
input_predictions: false,
record_sessions: false,
volume_stores: Vec::new(),
local_hit_daemon: false,
windows_hardlink: false,
shared_hardlink_restores: false,
deferred_discovery: true,
auto_gc: true,
gc_evict_shared: false,
storage_layout_advice: true,
heartbeat_secs: 30,
explain_miss: false,
scheduler: true,
path_only_env_vars: Vec::new(),
incremental_crates: Vec::new(),
key_env_vars: Vec::new(),
base_dirs: Vec::new(),
cache_dir: PathBuf::from("/tmp/kache"),
runtime_dir: PathBuf::from("/tmp/kache-runtime"),
socket_path_override: None,
max_size: 1024,
remote: None,
remote_error: None,
disabled: false,
cache_executables: false,
cache_cc_links: false,
clean_incremental: true,
preserve_incremental: false,
adaptive_incremental: true,
event_log_max_size: 1024,
event_log_keep_lines: 100,
compression_level: 3,
s3_concurrency: 16,
prefetch_enabled: DEFAULT_PREFETCH_ENABLED,
remote_key_cache_refresh_secs: DEFAULT_REMOTE_KEY_CACHE_REFRESH_SECS,
prefetch_max_keys: DEFAULT_PREFETCH_MAX_KEYS,
prefetch_max_bytes: DEFAULT_PREFETCH_MAX_BYTES,
prefetch_deadline_secs: DEFAULT_PREFETCH_DEADLINE_SECS,
min_store_compile_ms: DEFAULT_MIN_STORE_COMPILE_MS,
gc_max_age_hours: DEFAULT_GC_MAX_AGE_HOURS,
daemon_idle_timeout_secs: DEFAULT_DAEMON_IDLE_TIMEOUT_SECS,
s3_pool_idle_secs: DEFAULT_S3_POOL_IDLE_SECS,
remote_restore_timeout_secs: DEFAULT_REMOTE_RESTORE_TIMEOUT_SECS,
remote_negative_ttl_secs: DEFAULT_REMOTE_NEGATIVE_TTL_SECS,
};
assert_eq!(
config.event_log_path(),
PathBuf::from("/tmp/kache-runtime/events.jsonl")
);
assert_eq!(
config.transfer_log_path(),
PathBuf::from("/tmp/kache-runtime/transfers.jsonl")
);
assert_eq!(
config.summary_log_path(),
PathBuf::from("/tmp/kache-runtime/summaries.jsonl")
);
assert_eq!(config.store_dir(), PathBuf::from("/tmp/kache/store"));
assert_eq!(config.index_db_path(), PathBuf::from("/tmp/kache/index.db"));
assert_eq!(
config.upload_spool_dir(),
PathBuf::from("/tmp/kache/upload-queue")
);
}
#[test]
fn test_config_socket_path() {
let _lock = config_path_lock();
let _env_guard = set_env_for_test("KACHE_SOCKET_PATH", None);
let config = Config {
fallback: None,
key_salt: None,
cc_extra_allowlist_flags: Vec::new(),
local_only: false,
remote_readonly: false,
modified_input_guard: false,
input_predictions: false,
record_sessions: false,
volume_stores: Vec::new(),
local_hit_daemon: false,
windows_hardlink: false,
shared_hardlink_restores: false,
deferred_discovery: true,
auto_gc: true,
gc_evict_shared: false,
storage_layout_advice: true,
heartbeat_secs: 30,
explain_miss: false,
scheduler: true,
path_only_env_vars: Vec::new(),
incremental_crates: Vec::new(),
key_env_vars: Vec::new(),
base_dirs: Vec::new(),
cache_dir: PathBuf::from("/tmp/kache"),
runtime_dir: PathBuf::from("/tmp/kache-runtime"),
socket_path_override: None,
max_size: 1024,
remote: None,
remote_error: None,
disabled: false,
cache_executables: false,
cache_cc_links: false,
clean_incremental: true,
preserve_incremental: false,
adaptive_incremental: true,
event_log_max_size: 1024,
event_log_keep_lines: 100,
compression_level: 3,
s3_concurrency: 16,
prefetch_enabled: DEFAULT_PREFETCH_ENABLED,
remote_key_cache_refresh_secs: DEFAULT_REMOTE_KEY_CACHE_REFRESH_SECS,
prefetch_max_keys: DEFAULT_PREFETCH_MAX_KEYS,
prefetch_max_bytes: DEFAULT_PREFETCH_MAX_BYTES,
prefetch_deadline_secs: DEFAULT_PREFETCH_DEADLINE_SECS,
min_store_compile_ms: DEFAULT_MIN_STORE_COMPILE_MS,
gc_max_age_hours: DEFAULT_GC_MAX_AGE_HOURS,
daemon_idle_timeout_secs: DEFAULT_DAEMON_IDLE_TIMEOUT_SECS,
s3_pool_idle_secs: DEFAULT_S3_POOL_IDLE_SECS,
remote_restore_timeout_secs: DEFAULT_REMOTE_RESTORE_TIMEOUT_SECS,
remote_negative_ttl_secs: DEFAULT_REMOTE_NEGATIVE_TTL_SECS,
};
assert_eq!(
config.socket_path(),
PathBuf::from("/tmp/kache-runtime/daemon.sock")
);
let socket_dir = tempfile::tempdir().unwrap();
let socket = socket_dir.path().join("kache.sock");
let overridden = Config {
socket_path_override: Some(socket.clone()),
..config.clone()
};
assert_eq!(overridden.socket_path(), socket);
let regular = socket_dir.path().join("important.txt");
std::fs::write(®ular, b"keep me").unwrap();
for invalid in [PathBuf::from("daemon.sock"), regular] {
let invalid_config = Config {
socket_path_override: Some(invalid),
..config.clone()
};
assert_eq!(
invalid_config.socket_path(),
PathBuf::from("/tmp/kache-runtime/daemon.sock")
);
}
}
#[test]
fn runtime_dir_resolves_env_file_default_and_ignore_env() {
let _lock = config_path_lock();
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("config.toml");
let cache_dir = dir.path().join("cache");
let file_runtime = dir.path().join("file-runtime");
let env_runtime = dir.path().join("env-runtime");
let _config = set_kache_config_for_test(&config_path);
let _cache_env = set_env_for_test("KACHE_CACHE_DIR", None);
let _runtime_env = set_env_for_test("KACHE_RUNTIME_DIR", None);
let _socket_env = set_env_for_test("KACHE_SOCKET_PATH", None);
std::fs::write(
&config_path,
format!(
"[cache]\nlocal_store = {:?}\nruntime_dir = {:?}\n",
cache_dir.to_string_lossy(),
file_runtime.to_string_lossy()
),
)
.unwrap();
let from_file = Config::load().unwrap();
assert_eq!(from_file.cache_dir, cache_dir);
assert_eq!(from_file.runtime_dir, file_runtime);
unsafe { std::env::set_var("KACHE_RUNTIME_DIR", &env_runtime) };
assert_eq!(Config::load().unwrap().runtime_dir, env_runtime);
std::fs::write(
&config_path,
format!(
"[cache]\nlocal_store = {:?}\nruntime_dir = {:?}\nignore_env = true\n",
cache_dir.to_string_lossy(),
file_runtime.to_string_lossy()
),
)
.unwrap();
assert_eq!(Config::load().unwrap().runtime_dir, file_runtime);
unsafe { std::env::remove_var("KACHE_RUNTIME_DIR") };
std::fs::write(
&config_path,
format!("[cache]\nlocal_store = {:?}\n", cache_dir.to_string_lossy()),
)
.unwrap();
let compatible_default = Config::load().unwrap();
assert_eq!(compatible_default.runtime_dir, compatible_default.cache_dir);
}
#[test]
fn concurrent_runtime_dirs_isolate_job_state_while_sharing_store() {
let _lock = config_path_lock();
let dir = tempfile::tempdir().unwrap();
let shared = dir.path().join("shared-cache");
let runtime_a = dir.path().join("job-a");
let runtime_b = dir.path().join("job-b");
let _cache_env = set_env_for_test("KACHE_CACHE_DIR", None);
let _runtime_env = set_env_for_test("KACHE_RUNTIME_DIR", None);
let _socket_env = set_env_for_test("KACHE_SOCKET_PATH", None);
let make = |runtime: &Path| {
Config::load_resolved(Ok(FileConfig {
cache: Some(CacheFileConfig {
local_store: Some(shared.to_string_lossy().into_owned()),
runtime_dir: Some(runtime.to_string_lossy().into_owned()),
..Default::default()
}),
..Default::default()
}))
.unwrap()
};
let a = make(&runtime_a);
let b = make(&runtime_b);
assert_eq!(a.store_dir(), b.store_dir());
assert_eq!(a.index_db_path(), b.index_db_path());
assert_eq!(a.upload_spool_dir(), b.upload_spool_dir());
assert_ne!(a.socket_path(), b.socket_path());
for extension in ["lock", "run.lock", "state.json", "log"] {
let a_path = a.socket_path().with_extension(extension);
let b_path = b.socket_path().with_extension(extension);
assert_ne!(a_path, b_path);
assert!(a_path.starts_with(&runtime_a));
assert!(b_path.starts_with(&runtime_b));
}
assert_ne!(a.event_log_path(), b.event_log_path());
assert_ne!(a.transfer_log_path(), b.transfer_log_path());
assert_ne!(a.summary_log_path(), b.summary_log_path());
let writers = [(a, "job-a"), (b, "job-b")].map(|(config, contents)| {
std::thread::spawn(move || {
std::fs::create_dir_all(&config.runtime_dir).unwrap();
std::fs::write(config.event_log_path(), contents).unwrap();
config
})
});
let [a, b] = writers.map(|writer| writer.join().unwrap());
assert_eq!(
std::fs::read_to_string(a.event_log_path()).unwrap(),
"job-a"
);
assert_eq!(
std::fs::read_to_string(b.event_log_path()).unwrap(),
"job-b"
);
assert!(!shared.join("events.jsonl").exists());
}
#[test]
fn socket_path_override_is_validated_and_snapshotted() {
let _lock = config_path_lock();
let first_dir = tempfile::tempdir().unwrap();
let second_dir = tempfile::tempdir().unwrap();
let first = first_dir.path().join("daemon.sock");
let second = second_dir.path().join("daemon.sock");
let _env_guard = set_env_for_test("KACHE_SOCKET_PATH", Some(first.as_os_str()));
let config = Config::load().unwrap();
unsafe { std::env::set_var("KACHE_SOCKET_PATH", &second) };
assert_eq!(config.socket_path(), first);
for invalid in [OsString::new(), OsString::from("daemon.sock")] {
assert_eq!(resolve_socket_path_override(Some(invalid)), None);
}
let root = if cfg!(windows) {
Path::new(r"C:\")
} else {
Path::new("/")
};
assert_eq!(
resolve_socket_path_override(Some(root.as_os_str().to_owned())),
None
);
assert_eq!(
resolve_socket_path_override(Some(first_dir.path().as_os_str().to_owned())),
None
);
let regular = first_dir.path().join("important.txt");
std::fs::write(®ular, b"keep me").unwrap();
assert_eq!(
resolve_socket_path_override(Some(regular.as_os_str().to_owned())),
None
);
let invalid_os_path = first_dir.path().join(OsString::from("bad\0socket"));
assert_eq!(
resolve_socket_path_override(Some(invalid_os_path.into_os_string())),
None
);
#[cfg(unix)]
{
let link = first_dir.path().join("linked.sock");
std::os::unix::fs::symlink(®ular, &link).unwrap();
assert_eq!(
resolve_socket_path_override(Some(link.as_os_str().to_owned())),
None
);
let stale = first_dir.path().join("stale.sock");
let listener = std::os::unix::net::UnixListener::bind(&stale).unwrap();
drop(listener);
assert_eq!(
resolve_socket_path_override(Some(stale.as_os_str().to_owned())),
Some(stale)
);
}
}
#[test]
fn user_bypass_rules_match_crate_argv_and_env() {
let argv: Vec<String> = ["rustc", "--crate-name", "app", "-Zunpretty=expanded"]
.iter()
.map(|s| s.to_string())
.collect();
let none = |_: &str| None;
let by_crate = Config::user_bypass_reason_with(
"mutants-runner",
&argv,
&["mutants-runner".to_string()],
&[],
&[],
none,
);
assert_eq!(
by_crate.as_deref(),
Some("bypass rule: crate mutants-runner")
);
let by_argv = Config::user_bypass_reason_with(
"app",
&argv,
&[],
&["-Zunpretty".to_string()],
&[],
none,
);
assert_eq!(
by_argv.as_deref(),
Some("bypass rule: argv contains -Zunpretty")
);
let sqlx = |name: &str| (name == "SQLX_OFFLINE").then(|| "false".to_string());
let by_env = Config::user_bypass_reason_with(
"app",
&argv,
&[],
&[],
&["SQLX_OFFLINE=false".to_string()],
sqlx,
);
assert_eq!(
by_env.as_deref(),
Some("bypass rule: env SQLX_OFFLINE=false")
);
let offline = |name: &str| (name == "SQLX_OFFLINE").then(|| "true".to_string());
assert_eq!(
Config::user_bypass_reason_with(
"app",
&argv,
&[],
&[],
&["SQLX_OFFLINE=false".to_string()],
offline,
),
None
);
assert_eq!(
Config::user_bypass_reason_with(
"app",
&argv,
&[],
&[],
&["SQLX_OFFLINE".to_string()],
offline,
)
.as_deref(),
Some("bypass rule: env SQLX_OFFLINE")
);
assert_eq!(
Config::user_bypass_reason_with(
"mutants-runner-support",
&argv,
&["mutants-runner".to_string()],
&[],
&[],
none,
),
None
);
assert_eq!(
Config::user_bypass_reason_with("app", &argv, &[], &[], &[], none),
None
);
}
#[test]
fn user_bypass_reason_reads_rules_from_the_config_file() {
let _lock = config_path_lock();
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("config.toml");
std::fs::write(
&config_path,
"[cache]\n\
bypass_crates = [\"mutants-runner\"]\n\
bypass_argv = [\"-Zunpretty\"]\n\
bypass_env = [\"SQLX_OFFLINE=false\"]\n",
)
.unwrap();
let _config = set_kache_config_for_test(&config_path);
let _offline = NamedEnvGuard::remove("SQLX_OFFLINE");
let plain = vec!["rustc".to_string(), "src/lib.rs".to_string()];
assert_eq!(Config::user_bypass_reason("app", &plain), None);
assert_eq!(
Config::user_bypass_reason("mutants-runner", &plain).as_deref(),
Some("bypass rule: crate mutants-runner")
);
let unpretty = vec!["rustc".to_string(), "-Zunpretty=expanded".to_string()];
assert_eq!(
Config::user_bypass_reason("app", &unpretty).as_deref(),
Some("bypass rule: argv contains -Zunpretty")
);
let _online = NamedEnvGuard::set("SQLX_OFFLINE", "false");
assert_eq!(
Config::user_bypass_reason("app", &plain).as_deref(),
Some("bypass rule: env SQLX_OFFLINE=false")
);
}
#[test]
fn blank_bypass_entries_never_match() {
let argv = vec!["rustc".to_string(), "--crate-name".to_string()];
assert_eq!(
Config::user_bypass_reason_with(
"app",
&argv,
&["".to_string()],
&["".to_string()],
&["".to_string()],
|_| Some(String::new()),
),
None
);
}
#[test]
fn test_source_excluded_matches_relative_pattern_against_root() {
let dir = tempfile::tempdir().unwrap();
let source = dir.path().join("crates/problem/src/lib.rs");
let patterns = vec!["crates/problem/**".to_string()];
assert!(source_excluded_by_patterns(
&patterns,
&source,
&[dir.path().to_path_buf()]
));
}
#[test]
fn test_source_excluded_matches_source_as_passed() {
let patterns = vec!["src/*.c".to_string()];
assert!(source_excluded_by_patterns(
&patterns,
Path::new("src/foo.c"),
&[]
));
assert!(!source_excluded_by_patterns(
&patterns,
Path::new("include/foo.h"),
&[]
));
}
#[test]
fn test_exclude_expands_cargo_home_default_when_unset() {
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("/tmp"));
let cargo_home = home.join(".cargo").to_string_lossy().into_owned();
let (expanded, _) = expand_env_vars_collecting("$CARGO_HOME/registry/src/**", |_| None);
assert_eq!(expanded, format!("{cargo_home}/registry/src/**"));
let (expanded_braced, _) =
expand_env_vars_collecting("${CARGO_HOME}/registry/src/**", |_| None);
assert_eq!(expanded_braced, format!("{cargo_home}/registry/src/**"));
}
#[test]
fn expand_collecting_reports_unset_vars_only_once() {
let (expanded, unset) =
expand_env_vars_collecting("$MISSING/$MISSING/${ALSO_MISSING}/x", |_| None);
assert_eq!(expanded, "$MISSING/$MISSING/${ALSO_MISSING}/x");
assert_eq!(
unset,
vec!["MISSING".to_string(), "ALSO_MISSING".to_string()]
);
}
#[test]
fn expand_collecting_no_unset_when_resolved_or_defaulted() {
let (expanded, unset) =
expand_env_vars_collecting("$FOO/x", |k| (k == "FOO").then(|| "bar".to_string()));
assert_eq!(expanded, "bar/x");
assert!(unset.is_empty());
let (_, unset_default) = expand_env_vars_collecting("$CARGO_HOME/x", |_| None);
assert!(unset_default.is_empty());
}
#[test]
fn test_load_config_reads_exclude_patterns() {
let _guard = config_path_lock();
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("kache/config.toml");
let _env_guard = set_kache_config_for_test(&config_path);
std::fs::create_dir_all(config_path.parent().unwrap()).unwrap();
std::fs::write(
&config_path,
r#"
[cache]
exclude = ["src/generated/**", "vendor/problem/**"]
"#,
)
.unwrap();
assert!(Config::source_excluded(
Path::new("src/generated/lib.rs"),
&[]
));
assert!(Config::source_excluded(
Path::new("vendor/problem/foo.c"),
&[]
));
assert!(!Config::source_excluded(Path::new("src/main.rs"), &[]));
}
#[test]
fn test_config_file_path() {
let path = config_file_path();
assert!(path.to_string_lossy().contains("kache"));
assert!(path.to_string_lossy().ends_with("config.toml"));
}
#[test]
fn test_resolve_config_path_prefers_kache_config() {
let path = resolve_config_path_from(Some(PathBuf::from("/tmp/managed/config.toml")), None);
assert_eq!(path, PathBuf::from("/tmp/managed/config.toml"));
}
#[test]
fn test_load_and_save_raw_file_config_use_resolved_path() {
let _guard = config_path_lock();
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("managed/config.toml");
let _env_guard = set_kache_config_for_test(&config_path);
let config = FileConfig {
cc: None,
paths: None,
workspace: None,
cache: Some(CacheFileConfig {
local_store: Some("/tmp/managed-cache".to_string()),
..Default::default()
}),
};
Config::save_file_config_to(&config, &resolve_config_path()).unwrap();
assert!(config_path.exists());
let (loaded, existed) = Config::load_raw_file_config();
assert!(existed);
assert_eq!(
loaded.cache.as_ref().and_then(|c| c.local_store.as_deref()),
Some("/tmp/managed-cache")
);
}
#[test]
fn test_shellexpand_no_tilde() {
let path = shellexpand("/absolute/path");
assert_eq!(path, PathBuf::from("/absolute/path"));
}
#[test]
fn test_shellexpand_relative() {
let path = shellexpand("relative/path");
assert_eq!(path, PathBuf::from("relative/path"));
}
#[test]
fn test_shellexpand_bare_tilde() {
let path = shellexpand("~");
if let Some(home) = dirs::home_dir() {
assert_eq!(path, home);
} else {
assert_eq!(path, PathBuf::from("~"));
}
}
fn remove_env_var_for_test(key: &'static str) -> GenericEnvGuard {
let previous = std::env::var_os(key);
unsafe {
std::env::remove_var(key);
}
GenericEnvGuard { key, previous }
}
const S3_ENV_VARS: [&str; 6] = [
"KACHE_S3_BUCKET",
"KACHE_S3_ENDPOINT",
"KACHE_S3_REGION",
"KACHE_S3_PREFIX",
"KACHE_S3_PROFILE",
"KACHE_S3_USER_AGENT",
];
fn isolate_s3_env() -> Vec<GenericEnvGuard> {
S3_ENV_VARS
.into_iter()
.map(remove_env_var_for_test)
.collect()
}
struct GenericEnvGuard {
key: &'static str,
previous: Option<OsString>,
}
impl Drop for GenericEnvGuard {
fn drop(&mut self) {
unsafe {
match self.previous.as_ref() {
Some(value) => std::env::set_var(self.key, value),
None => std::env::remove_var(self.key),
}
}
}
}
fn set_env_var_for_test(key: &'static str, value: &str) -> GenericEnvGuard {
let previous = std::env::var_os(key);
unsafe {
std::env::set_var(key, value);
}
GenericEnvGuard { key, previous }
}
#[test]
fn test_kache_cache_dir_env_expands_bare_tilde() {
let _guard = config_path_lock();
if let Some(home) = dirs::home_dir() {
let _env_guard = set_env_var_for_test("KACHE_CACHE_DIR", "~");
let config = Config::load().unwrap();
assert_eq!(config.cache_dir, home);
}
}
#[test]
fn test_kache_config_env_expands_bare_tilde() {
let _guard = config_path_lock();
if let Some(home) = dirs::home_dir() {
let _env_guard = set_env_var_for_test("KACHE_CONFIG", "~");
let resolved = resolve_config_path();
assert_eq!(resolved, home);
}
}
#[test]
fn test_parse_size_various() {
assert_eq!(parse_size("1KiB"), Some(1024));
assert_eq!(parse_size("10GiB"), Some(10 * 1024 * 1024 * 1024));
assert_eq!(parse_size("0B"), Some(0));
assert!(parse_size("").is_none());
assert!(parse_size("abc").is_none());
}
#[test]
fn test_save_and_load_file_config() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("kache/config.toml");
let config = FileConfig {
cc: None,
paths: None,
workspace: None,
cache: Some(CacheFileConfig {
bypass_env: None,
bypass_argv: None,
bypass_crates: None,
local_only: None,
remote_readonly: None,
volumes: None,
modified_input_guard: None,
input_predictions: None,
record_sessions: None,
local_hit_daemon: None,
windows_hardlink: None,
shared_hardlink_restores: None,
deferred_discovery: None,
auto_gc: None,
gc_evict_shared: None,
storage_layout_advice: None,
heartbeat_secs: None,
explain_miss: None,
ignore_env: None,
fallback: None,
key_salt: None,
path_only_env_vars: None,
incremental_crates: None,
key_env_vars: None,
local_store: Some("/tmp/my-cache".to_string()),
runtime_dir: Some("/tmp/my-runtime".to_string()),
local_max_size: Some("10GiB".to_string()),
planner: None,
cache_executables: Some(true),
cache_cc_links: None,
clean_incremental: None,
preserve_incremental: None,
adaptive_incremental: None,
exclude: None,
event_log_max_size: None,
event_log_keep_lines: None,
compression_level: Some(5),
s3_concurrency: None,
prefetch_enabled: None,
remote_key_cache_refresh_secs: None,
prefetch_max_keys: None,
prefetch_max_bytes: None,
prefetch_deadline_secs: None,
min_store_compile_ms: None,
gc_max_age_hours: None,
daemon_idle_timeout_secs: None,
s3_pool_idle_secs: None,
remote_restore_timeout_secs: None,
remote_negative_ttl_secs: None,
remote: None,
scheduler: None,
}),
};
Config::save_file_config_to(&config, &config_path).unwrap();
assert!(config_path.exists());
let (loaded, existed) = Config::load_raw_file_config_from(&config_path);
assert!(existed);
assert_eq!(
loaded.cache.as_ref().unwrap().local_store.as_deref(),
Some("/tmp/my-cache")
);
assert_eq!(loaded.cache.as_ref().unwrap().compression_level, Some(5));
assert_eq!(
loaded.cache.as_ref().unwrap().runtime_dir.as_deref(),
Some("/tmp/my-runtime")
);
}
#[test]
fn test_load_raw_file_config_nonexistent() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("nonexistent/config.toml");
let (config, existed) = Config::load_raw_file_config_from(&config_path);
assert!(!existed);
assert!(config.cache.is_none());
}
#[test]
fn raw_config_roundtrip_preserves_workspace_table() {
let dir = tempfile::tempdir().unwrap();
let source = dir.path().join("source.toml");
let saved = dir.path().join("saved.toml");
std::fs::write(
&source,
"[cache]\nlocal_max_size='1GiB'\n\n[[workspace.extra_inputs]]\ncrates=['macro-provider']\ninputs=['shared/value.txt']\npropagate_to_dependents=true\n",
)
.unwrap();
let (config, existed) = Config::load_raw_file_config_from(&source);
assert!(existed);
Config::save_file_config_to(&config, &saved).unwrap();
let original: toml::Value =
toml::from_str(&std::fs::read_to_string(source).unwrap()).unwrap();
let roundtripped: toml::Value =
toml::from_str(&std::fs::read_to_string(saved).unwrap()).unwrap();
assert_eq!(roundtripped.get("workspace"), original.get("workspace"));
}
#[test]
fn local_only_via_file_suppresses_remote_and_planner() {
let _guard = config_path_lock();
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("kache/config.toml");
let _env_guard = set_kache_config_for_test(&config_path);
let file = FileConfig {
cc: None,
paths: None,
workspace: None,
cache: Some(CacheFileConfig {
local_only: Some(true),
remote: Some(RemoteFileConfig {
bucket: Some("hermetic-bucket".to_string()),
..Default::default()
}),
planner: Some(PlannerFileConfig {
endpoint: Some("https://planner.example.com".to_string()),
..Default::default()
}),
..Default::default()
}),
};
Config::save_file_config_to(&file, &config_path).unwrap();
let config = Config::load().unwrap();
assert!(config.local_only, "local_only must be on");
assert!(
config.remote.is_none(),
"remote must be suppressed under local-only, got {:?}",
config.remote
);
assert!(
Config::load_planner_config().is_none(),
"planner must be suppressed under local-only"
);
}
#[test]
fn pull_request_ci_forces_remote_readonly() {
let _guard = config_path_lock();
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("kache/config.toml");
let _cfg = set_kache_config_for_test(&config_path);
let _gha = NamedEnvGuard::set("GITHUB_ACTIONS", "true");
let _event = NamedEnvGuard::set("GITHUB_EVENT_NAME", "pull_request");
let _ref_type = NamedEnvGuard::set("GITHUB_REF_TYPE", "branch");
let _protected = NamedEnvGuard::set("GITHUB_REF_PROTECTED", "false");
let _gitlab = NamedEnvGuard::remove("GITLAB_CI");
let _explicit = NamedEnvGuard::remove("KACHE_REMOTE_READONLY");
let config = Config::load().unwrap();
assert!(
config.remote_readonly,
"untrusted GitHub Actions must suppress remote writes"
);
}
#[test]
fn remote_readonly_zero_does_not_disable_ci_policy() {
let _guard = config_path_lock();
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("kache/config.toml");
let _cfg = set_kache_config_for_test(&config_path);
let _gha = NamedEnvGuard::set("GITHUB_ACTIONS", "true");
let _event = NamedEnvGuard::set("GITHUB_EVENT_NAME", "pull_request");
let _ref_type = NamedEnvGuard::set("GITHUB_REF_TYPE", "branch");
let _protected = NamedEnvGuard::set("GITHUB_REF_PROTECTED", "false");
let _gitlab = NamedEnvGuard::remove("GITLAB_CI");
let _explicit = NamedEnvGuard::set("KACHE_REMOTE_READONLY", "0");
let config = Config::load().unwrap();
assert!(
config.remote_readonly,
"KACHE_REMOTE_READONLY=0 must not re-enable writes on a pull request"
);
}
#[test]
fn protected_branch_push_keeps_configured_writable() {
let _guard = config_path_lock();
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("kache/config.toml");
let _cfg = set_kache_config_for_test(&config_path);
let _gha = NamedEnvGuard::set("GITHUB_ACTIONS", "true");
let _event = NamedEnvGuard::set("GITHUB_EVENT_NAME", "push");
let _ref_type = NamedEnvGuard::set("GITHUB_REF_TYPE", "branch");
let _protected = NamedEnvGuard::set("GITHUB_REF_PROTECTED", "true");
let _gitlab = NamedEnvGuard::remove("GITLAB_CI");
let _explicit = NamedEnvGuard::remove("KACHE_REMOTE_READONLY");
let config = Config::load().unwrap();
assert!(
!config.remote_readonly,
"a protected-branch push must keep the configured write mode"
);
}
#[test]
fn local_shell_keeps_configured_writable() {
let _guard = config_path_lock();
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("kache/config.toml");
let _cfg = set_kache_config_for_test(&config_path);
let _gha = NamedEnvGuard::remove("GITHUB_ACTIONS");
let _gitlab = NamedEnvGuard::remove("GITLAB_CI");
let _explicit = NamedEnvGuard::remove("KACHE_REMOTE_READONLY");
let config = Config::load().unwrap();
assert!(
!config.remote_readonly,
"a local shell must not be forced read-only"
);
}
#[test]
fn local_only_env_wins_over_file() {
let _guard = config_path_lock();
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("kache/config.toml");
let _env_guard = set_kache_config_for_test(&config_path);
let file = FileConfig {
cc: None,
paths: None,
workspace: None,
cache: Some(CacheFileConfig {
local_only: Some(true),
..Default::default()
}),
};
Config::save_file_config_to(&file, &config_path).unwrap();
let prev = std::env::var_os("KACHE_LOCAL_ONLY");
unsafe { std::env::set_var("KACHE_LOCAL_ONLY", "0") };
let off = Config::load().unwrap().local_only;
unsafe { std::env::set_var("KACHE_LOCAL_ONLY", "1") };
let on = Config::load().unwrap().local_only;
unsafe {
match prev {
Some(v) => std::env::set_var("KACHE_LOCAL_ONLY", v),
None => std::env::remove_var("KACHE_LOCAL_ONLY"),
}
}
assert!(
!off,
"KACHE_LOCAL_ONLY=0 must force local-only OFF despite file=true"
);
assert!(on, "KACHE_LOCAL_ONLY=1 must force local-only ON");
}
#[test]
fn storage_layout_advice_defaults_on_and_file_false_disables() {
let _guard = config_path_lock();
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("kache/config.toml");
let _env_guard = set_kache_config_for_test(&config_path);
assert!(
Config::load().unwrap().storage_layout_advice,
"advice must default ON with no config file"
);
let file = FileConfig {
cc: None,
paths: None,
workspace: None,
cache: Some(CacheFileConfig {
storage_layout_advice: Some(false),
heartbeat_secs: None,
explain_miss: None,
..Default::default()
}),
};
Config::save_file_config_to(&file, &config_path).unwrap();
assert!(
!Config::load().unwrap().storage_layout_advice,
"[cache] storage_layout_advice = false must mute the advisories"
);
}
#[test]
fn storage_layout_advice_env_wins_over_file() {
let _guard = config_path_lock();
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("kache/config.toml");
let _env_guard = set_kache_config_for_test(&config_path);
let file = FileConfig {
cc: None,
paths: None,
workspace: None,
cache: Some(CacheFileConfig {
storage_layout_advice: Some(false),
heartbeat_secs: None,
explain_miss: None,
..Default::default()
}),
};
Config::save_file_config_to(&file, &config_path).unwrap();
let prev = std::env::var_os("KACHE_STORAGE_LAYOUT_ADVICE");
unsafe { std::env::set_var("KACHE_STORAGE_LAYOUT_ADVICE", "1") };
let on = Config::load().unwrap().storage_layout_advice;
unsafe { std::env::set_var("KACHE_STORAGE_LAYOUT_ADVICE", "0") };
let off = Config::load().unwrap().storage_layout_advice;
unsafe {
match prev {
Some(v) => std::env::set_var("KACHE_STORAGE_LAYOUT_ADVICE", v),
None => std::env::remove_var("KACHE_STORAGE_LAYOUT_ADVICE"),
}
}
assert!(
on,
"KACHE_STORAGE_LAYOUT_ADVICE=1 must re-enable despite file=false"
);
assert!(
!off,
"KACHE_STORAGE_LAYOUT_ADVICE=0 must mute the advisories"
);
}
#[test]
fn scheduler_defaults_on_and_file_false_disables() {
let _guard = config_path_lock();
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("kache/config.toml");
let _env_guard = set_kache_config_for_test(&config_path);
let _missing = NamedEnvGuard::remove("KACHE_SCHEDULER");
assert!(
Config::load().unwrap().scheduler,
"scheduler must default ON with no config file"
);
let file = FileConfig {
cc: None,
paths: None,
workspace: None,
cache: Some(CacheFileConfig {
scheduler: Some(false),
..Default::default()
}),
};
Config::save_file_config_to(&file, &config_path).unwrap();
assert!(
!Config::load().unwrap().scheduler,
"[cache] scheduler = false must disable the miss-path scheduler"
);
assert!(IGNORE_ENV_GATED_VARS.contains(&"KACHE_SCHEDULER"));
}
#[test]
fn scheduler_env_wins_over_file() {
let _guard = config_path_lock();
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("kache/config.toml");
let _env_guard = set_kache_config_for_test(&config_path);
let file = FileConfig {
cc: None,
paths: None,
workspace: None,
cache: Some(CacheFileConfig {
scheduler: Some(false),
..Default::default()
}),
};
Config::save_file_config_to(&file, &config_path).unwrap();
let _on = NamedEnvGuard::set("KACHE_SCHEDULER", "1");
assert!(
Config::load().unwrap().scheduler,
"KACHE_SCHEDULER=1 must re-enable despite file=false"
);
drop(_on);
let file_on = FileConfig {
cc: None,
paths: None,
workspace: None,
cache: Some(CacheFileConfig {
scheduler: Some(true),
..Default::default()
}),
};
Config::save_file_config_to(&file_on, &config_path).unwrap();
let _off = NamedEnvGuard::set("KACHE_SCHEDULER", "0");
assert!(
!Config::load().unwrap().scheduler,
"KACHE_SCHEDULER=0 must disable despite file=true"
);
drop(_off);
let _false = NamedEnvGuard::set("KACHE_SCHEDULER", "false");
assert!(
!Config::load().unwrap().scheduler,
"KACHE_SCHEDULER=false must disable the scheduler"
);
std::fs::write(
&config_path,
"[cache]\nignore_env = true\nscheduler = false\n",
)
.unwrap();
let _ignored = NamedEnvGuard::set("KACHE_SCHEDULER", "1");
assert!(
!Config::load().unwrap().scheduler,
"ignore_env must keep [cache] scheduler = false"
);
}
#[test]
fn test_remote_file_config_with_profile() {
let config = FileConfig {
cc: None,
paths: None,
workspace: None,
cache: Some(CacheFileConfig {
planner: None,
remote: Some(RemoteFileConfig {
_type: Some("s3".to_string()),
bucket: Some("mybucket".to_string()),
region: Some("eu-west-1".to_string()),
profile: Some("ceph".to_string()),
..Default::default()
}),
..Default::default()
}),
};
let serialized = toml::to_string_pretty(&config).unwrap();
assert!(serialized.contains("profile = \"ceph\""));
let deserialized: FileConfig = toml::from_str(&serialized).unwrap();
assert_eq!(
deserialized
.cache
.unwrap()
.remote
.unwrap()
.profile
.as_deref(),
Some("ceph")
);
}
#[test]
fn test_load_remote_config_from_file_fields() {
let _guard = config_path_lock();
for v in [
"KACHE_S3_BUCKET",
"KACHE_S3_ENDPOINT",
"KACHE_S3_REGION",
"KACHE_S3_PREFIX",
"KACHE_S3_PROFILE",
"KACHE_S3_USER_AGENT",
] {
unsafe { std::env::remove_var(v) };
}
let file = FileConfig {
cc: None,
paths: None,
workspace: None,
cache: Some(CacheFileConfig {
planner: None,
remote: Some(RemoteFileConfig {
_type: Some("s3".to_string()),
bucket: Some("filebucket".to_string()),
endpoint: Some("https://s3.example.com".to_string()),
region: Some("eu-west-2".to_string()),
prefix: Some("myprefix".to_string()),
profile: Some(" ceph ".to_string()),
..Default::default()
}),
..Default::default()
}),
};
let remote = Config::load_remote_config(&Ok(file))
.expect("valid remote config")
.expect("remote from file");
assert_eq!(remote.prefix, "myprefix");
let RemoteBackendConfig::S3(s3) = remote.backend else {
panic!("expected S3 remote");
};
assert_eq!(s3.bucket, "filebucket");
assert_eq!(s3.endpoint.as_deref(), Some("https://s3.example.com"));
assert_eq!(s3.region, "eu-west-2");
assert_eq!(s3.profile.as_deref(), Some("ceph"));
let empty = FileConfig {
cc: None,
paths: None,
workspace: None,
cache: Some(CacheFileConfig {
planner: None,
remote: None,
..Default::default()
}),
};
assert!(
Config::load_remote_config(&Ok(empty))
.expect("empty config is valid")
.is_none()
);
}
#[test]
fn filesystem_remote_loads_without_a_bucket_and_defaults_atomic_dir() {
let _guard = config_path_lock();
let root = tempfile::tempdir().unwrap();
let file = FileConfig {
cc: None,
paths: None,
workspace: None,
cache: Some(CacheFileConfig {
remote: Some(RemoteFileConfig {
_type: Some("filesystem".to_string()),
path: Some(root.path().to_string_lossy().into_owned()),
prefix: Some("shared".to_string()),
..Default::default()
}),
..Default::default()
}),
};
let remote = Config::load_remote_config(&Ok(file))
.expect("valid filesystem config")
.expect("filesystem remote");
assert_eq!(remote.prefix, "shared");
let RemoteBackendConfig::Filesystem(fs) = remote.backend else {
panic!("expected filesystem remote");
};
assert_eq!(fs.root, root.path());
assert_eq!(fs.atomic_write_dir, root.path().join(".kache-tmp"));
}
#[test]
fn filesystem_remote_ignores_legacy_s3_environment_overrides() {
let _guard = config_path_lock();
let _bucket = set_env_var_for_test("KACHE_S3_BUCKET", "ambient-bucket");
let _prefix = set_env_var_for_test("KACHE_S3_PREFIX", "ambient-prefix");
let root = tempfile::tempdir().unwrap();
let file = FileConfig {
cc: None,
paths: None,
workspace: None,
cache: Some(CacheFileConfig {
remote: Some(RemoteFileConfig {
_type: Some("filesystem".to_string()),
path: Some(root.path().to_string_lossy().into_owned()),
prefix: Some("file-prefix".to_string()),
..Default::default()
}),
..Default::default()
}),
};
let remote = Config::load_remote_config(&Ok(file))
.expect("valid filesystem config")
.expect("filesystem remote");
assert_eq!(remote.prefix, "file-prefix");
assert!(matches!(
remote.backend,
RemoteBackendConfig::Filesystem(FilesystemRemoteConfig { root: loaded, .. })
if loaded == root.path()
));
}
#[test]
fn filesystem_remote_rejects_a_windows_drive_prefix() {
let root = tempfile::tempdir().unwrap();
let file = FileConfig {
cache: Some(CacheFileConfig {
remote: Some(RemoteFileConfig {
_type: Some("filesystem".to_string()),
path: Some(root.path().to_string_lossy().into_owned()),
prefix: Some("C:/escape".to_string()),
..Default::default()
}),
..Default::default()
}),
..Default::default()
};
let error = Config::load_remote_config(&Ok(file))
.expect_err("filesystem drive prefix must be rejected")
.to_string();
assert!(error.contains("cannot contain ':'"), "{error}");
}
#[test]
fn legacy_remote_without_type_still_infers_s3() {
let _guard = config_path_lock();
let file = FileConfig {
cc: None,
paths: None,
workspace: None,
cache: Some(CacheFileConfig {
remote: Some(RemoteFileConfig {
bucket: Some("legacy".to_string()),
..Default::default()
}),
..Default::default()
}),
};
let remote = Config::load_remote_config(&Ok(file))
.expect("legacy config is valid")
.expect("legacy S3 remote");
assert!(matches!(
remote.backend,
RemoteBackendConfig::S3(S3RemoteConfig { bucket, .. }) if bucket == "legacy"
));
}
#[test]
fn explicit_s3_rejects_an_empty_bucket() {
let _guard = config_path_lock();
let _bucket = set_env_var_for_test("KACHE_S3_BUCKET", "");
let file = FileConfig {
cache: Some(CacheFileConfig {
remote: Some(RemoteFileConfig {
_type: Some("s3".to_string()),
bucket: Some(" ".to_string()),
..Default::default()
}),
..Default::default()
}),
..Default::default()
};
let error = Config::load_remote_config(&Ok(file))
.expect_err("empty S3 bucket must be rejected")
.to_string();
assert!(error.contains("non-empty bucket"), "{error}");
}
#[test]
fn legacy_noncanonical_prefixes_normalize_instead_of_failing() {
let _guard = config_path_lock();
let _isolated = isolate_s3_env();
let _bucket = set_env_var_for_test("KACHE_S3_BUCKET", "legacy-bucket");
for (configured, expected) in [("team/", "team"), ("/team", "team"), ("a//b", "a/b")] {
let file = FileConfig {
cache: Some(CacheFileConfig {
remote: Some(RemoteFileConfig {
prefix: Some(configured.to_string()),
..Default::default()
}),
..Default::default()
}),
..Default::default()
};
let remote = Config::load_remote_config(&Ok(file))
.unwrap_or_else(|e| panic!("{configured:?} must not fail: {e:#}"))
.expect("remote");
assert_eq!(remote.prefix, expected, "{configured:?}");
}
}
#[test]
fn legacy_empty_env_prefix_means_the_bucket_root() {
let _guard = config_path_lock();
let _isolated = isolate_s3_env();
let _bucket = set_env_var_for_test("KACHE_S3_BUCKET", "legacy-bucket");
let _prefix = set_env_var_for_test("KACHE_S3_PREFIX", "");
let remote = Config::load_remote_config(&Ok(FileConfig::default()))
.expect("empty prefix must be accepted")
.expect("remote");
assert_eq!(remote.prefix, "");
}
#[test]
fn empty_env_bucket_disables_the_remote_instead_of_falling_back() {
let _guard = config_path_lock();
let _isolated = isolate_s3_env();
let _bucket = set_env_var_for_test("KACHE_S3_BUCKET", "");
let file = FileConfig {
cache: Some(CacheFileConfig {
remote: Some(RemoteFileConfig {
bucket: Some("production-cache".to_string()),
..Default::default()
}),
..Default::default()
}),
..Default::default()
};
assert!(
Config::load_remote_config(&Ok(file))
.expect("empty override is not an error without an explicit type")
.is_none(),
"an empty KACHE_S3_BUCKET must not select the file-configured bucket"
);
}
#[test]
fn unusable_remote_config_degrades_to_local_only() {
let _lock = config_path_lock();
let dir = tempfile::tempdir().unwrap();
let cfg = dir.path().join("config.toml");
let _g = set_kache_config_for_test(&cfg);
let _isolated = isolate_s3_env();
std::fs::write(
&cfg,
"[cache.remote]\ntype = \"s3\"\nbucket = \"b\"\nprefix = \"a/../b\"\n",
)
.unwrap();
let loaded = Config::load().expect("a bad remote must not fail Config::load");
assert!(loaded.remote.is_none(), "remote must be dropped");
let reason = loaded
.remote_error
.as_deref()
.expect("reason must be recorded");
assert!(reason.contains("path segments"), "{reason}");
let error = loaded
.require_remote()
.expect_err("require_remote must fail");
assert!(error.to_string().contains("unusable"), "{error}");
}
#[test]
fn staging_dir_inside_the_object_tree_is_rejected() {
let root = std::path::Path::new("/tmp/kache-remote");
let problem =
filesystem_staging_problem(root, &root.join("artifacts/v3/staging"), "artifacts")
.expect("staging inside the object tree must be rejected");
assert!(problem.contains("inside the object tree"), "{problem}");
assert!(filesystem_staging_problem(root, &root.join(".kache-tmp"), "artifacts").is_none());
assert!(
filesystem_staging_problem(root, &root.join(".kache-tmp"), "").is_none(),
"the default staging dir must be accepted with an empty prefix"
);
assert!(filesystem_staging_problem(root, &root.join("v3/staging"), "").is_some());
}
#[test]
fn filesystem_remote_with_an_empty_prefix_resolves() {
let _guard = config_path_lock();
let _isolated = isolate_s3_env();
let dir = tempfile::tempdir().unwrap();
let file = FileConfig {
cc: None,
paths: None,
workspace: None,
cache: Some(CacheFileConfig {
remote: Some(RemoteFileConfig {
_type: Some("filesystem".to_string()),
path: Some(dir.path().to_string_lossy().to_string()),
prefix: Some(String::new()),
..Default::default()
}),
..Default::default()
}),
};
let remote = Config::load_remote_config(&Ok(file))
.expect("an empty prefix must be usable")
.expect("remote");
assert_eq!(remote.prefix, "");
}
#[test]
fn filesystem_remote_rejects_mixed_s3_fields() {
let file = FileConfig {
cc: None,
paths: None,
workspace: None,
cache: Some(CacheFileConfig {
remote: Some(RemoteFileConfig {
_type: Some("filesystem".to_string()),
path: Some("/tmp/kache-remote".to_string()),
bucket: Some("wrong-backend".to_string()),
..Default::default()
}),
..Default::default()
}),
};
let error = Config::load_remote_config(&Ok(file))
.expect_err("mixed backend fields must be rejected")
.to_string();
assert!(error.contains("cannot include S3"), "{error}");
}
#[test]
fn test_load_planner_config_from_file() {
let _guard = config_path_lock();
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("kache/config.toml");
let _env_guard = set_kache_config_for_test(&config_path);
let config = FileConfig {
cc: None,
paths: None,
workspace: None,
cache: Some(CacheFileConfig {
planner: Some(PlannerFileConfig {
endpoint: Some("https://planner.example.com".to_string()),
timeout_ms: Some(1200),
token: Some("secret".to_string()),
}),
..Default::default()
}),
};
Config::save_file_config_to(&config, &config_path).unwrap();
let loaded = Config::load_planner_config().unwrap();
assert_eq!(loaded.endpoint, "https://planner.example.com");
assert_eq!(loaded.timeout_ms, 1200);
assert_eq!(loaded.token.as_deref(), Some("secret"));
}
#[test]
fn test_load_planner_config_env_overrides_file() {
let _guard = config_path_lock();
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("kache/config.toml");
let _env_guard = set_kache_config_for_test(&config_path);
let config = FileConfig {
cc: None,
paths: None,
workspace: None,
cache: Some(CacheFileConfig {
planner: Some(PlannerFileConfig {
endpoint: Some("https://planner.example.com".to_string()),
timeout_ms: Some(1200),
token: Some("secret".to_string()),
}),
..Default::default()
}),
};
Config::save_file_config_to(&config, &config_path).unwrap();
struct ScopedVar {
key: &'static str,
previous: Option<OsString>,
}
impl ScopedVar {
fn set(key: &'static str, value: &str) -> Self {
let previous = std::env::var_os(key);
unsafe {
std::env::set_var(key, value);
}
Self { key, previous }
}
}
impl Drop for ScopedVar {
fn drop(&mut self) {
match &self.previous {
Some(value) => unsafe {
std::env::set_var(self.key, value);
},
None => unsafe {
std::env::remove_var(self.key);
},
}
}
}
let _endpoint = ScopedVar::set("KACHE_PLANNER_ENDPOINT", "https://env.example.com");
let _timeout = ScopedVar::set("KACHE_PLANNER_TIMEOUT_MS", "400");
let _token = ScopedVar::set("KACHE_PLANNER_TOKEN", "env-token");
let loaded = Config::load_planner_config().unwrap();
assert_eq!(loaded.endpoint, "https://env.example.com");
assert_eq!(loaded.timeout_ms, 400);
assert_eq!(loaded.token.as_deref(), Some("env-token"));
}
#[test]
fn test_resolve_config_path_prefers_project_file() {
let dir = tempfile::tempdir().unwrap();
let project_root = dir.path().join("workspace");
let nested_dir = project_root.join("crate/src");
std::fs::create_dir_all(&nested_dir).unwrap();
let project_config = project_root.join(PROJECT_CONFIG_NAME);
std::fs::write(&project_config, "[cache]\n").unwrap();
let resolved = resolve_config_path_from(None, Some(nested_dir));
assert_eq!(resolved, project_config);
}
#[test]
fn test_resolve_config_path_env_overrides_project_file() {
let dir = tempfile::tempdir().unwrap();
let project_root = dir.path().join("workspace");
std::fs::create_dir_all(&project_root).unwrap();
let project_config = project_root.join(PROJECT_CONFIG_NAME);
let env_config = dir.path().join("explicit-kache.toml");
std::fs::write(&project_config, "[cache]\n").unwrap();
let resolved = resolve_config_path_from(Some(env_config.clone()), Some(project_root));
assert_eq!(resolved, env_config);
}
#[test]
fn test_resolve_config_path_falls_back_to_global_when_no_project_file() {
let dir = tempfile::tempdir().unwrap();
let nested_dir = dir.path().join("workspace/crate");
std::fs::create_dir_all(&nested_dir).unwrap();
let resolved = resolve_config_path_from(None, Some(nested_dir));
assert_eq!(resolved, config_file_path());
}
#[test]
fn test_normalize_cc_flags_trims_dedupes_and_drops_empty() {
let input = [
" -O2 ".to_string(),
"-O2".to_string(), String::new(), " ".to_string(), "-fPIC".to_string(),
" -fPIC".to_string(), ];
assert_eq!(
normalize_cc_flags(input),
vec!["-O2".to_string(), "-fPIC".to_string()]
);
assert!(normalize_cc_flags(Vec::<String>::new()).is_empty());
}
#[test]
fn s3_user_agent_loaded_from_file_and_env() {
let _guard = config_path_lock();
let _env_guard = isolate_s3_env();
let toml_underscore = r#"
[cache.remote]
type = "s3"
bucket = "my-bucket"
user_agent = "custom-agent/1.0"
"#;
let file_cfg: Result<FileConfig> = toml::from_str(toml_underscore).map_err(Into::into);
let loaded = Config::load_remote_config(&file_cfg).unwrap().unwrap();
match loaded.backend {
RemoteBackendConfig::S3(s3) => {
assert_eq!(s3.user_agent.as_deref(), Some("custom-agent/1.0"));
}
_ => panic!("expected S3 backend"),
}
let toml_hyphen = r#"
[cache.remote]
type = "s3"
bucket = "my-bucket"
user-agent = "custom-agent/2.0"
"#;
let file_cfg: Result<FileConfig> = toml::from_str(toml_hyphen).map_err(Into::into);
let loaded = Config::load_remote_config(&file_cfg).unwrap().unwrap();
match loaded.backend {
RemoteBackendConfig::S3(s3) => {
assert_eq!(s3.user_agent.as_deref(), Some("custom-agent/2.0"));
}
_ => panic!("expected S3 backend"),
}
unsafe { std::env::set_var("KACHE_S3_USER_AGENT", "env-agent/3.0") };
let toml_file_val = r#"
[cache.remote]
type = "s3"
bucket = "my-bucket"
user_agent = "file-agent/1.0"
"#;
let file_cfg: Result<FileConfig> = toml::from_str(toml_file_val).map_err(Into::into);
let loaded = Config::load_remote_config(&file_cfg).unwrap().unwrap();
match loaded.backend {
RemoteBackendConfig::S3(s3) => {
assert_eq!(
s3.user_agent.as_deref(),
Some("env-agent/3.0"),
"environment variable override must take precedence over file config"
);
}
_ => panic!("expected S3 backend"),
}
let toml_ignore_env = r#"
[cache]
ignore_env = true
[cache.remote]
type = "s3"
bucket = "my-bucket"
user_agent = "file-agent/1.0"
"#;
let resolved_cfg =
Config::load_resolved(toml::from_str(toml_ignore_env).map_err(Into::into)).unwrap();
match resolved_cfg.remote.unwrap().backend {
RemoteBackendConfig::S3(s3) => {
assert_eq!(
s3.user_agent.as_deref(),
Some("file-agent/1.0"),
"ignore_env = true must ignore KACHE_S3_USER_AGENT in favor of file config"
);
}
_ => panic!("expected S3 backend"),
}
unsafe { std::env::remove_var("KACHE_S3_USER_AGENT") };
let toml_fs = r#"
[cache.remote]
type = "filesystem"
path = "/tmp/cache"
user_agent = "invalid-for-fs"
"#;
let file_cfg: Result<FileConfig> = toml::from_str(toml_fs).map_err(Into::into);
let err = Config::load_remote_config(&file_cfg).unwrap_err();
assert!(
err.to_string()
.contains("cannot include S3 bucket, endpoint, region, profile, or user_agent")
);
}
}