use anyhow::{Context, Result};
use rusqlite::{Connection, Error as SqlError, ErrorCode, params};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
use crate::config::Config;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct StorePutResult {
pub output_blobs: u32,
pub duplicate_blobs: u32,
pub new_blobs: u32,
}
impl StorePutResult {
pub fn is_full_dup(self) -> bool {
self.output_blobs > 0 && self.duplicate_blobs == self.output_blobs
}
}
fn set_blob_readonly(blob: &Path) {
let _ = set_blob_readonly_checked(blob);
}
fn set_blob_readonly_checked(blob: &Path) -> std::io::Result<()> {
let meta = fs::metadata(blob)?;
let mut perms = meta.permissions();
perms.set_readonly(true);
fs::set_permissions(blob, perms)
}
fn is_blob_hash_name(name: &str) -> bool {
name.len() == 64
&& name
.bytes()
.all(|b| b.is_ascii_digit() || matches!(b, b'a'..=b'f'))
}
fn unlink_blob(blob: &Path) {
if blob.exists() {
if let Ok(meta) = fs::metadata(blob) {
let mut perms = meta.permissions();
perms.set_readonly(false);
let _ = fs::set_permissions(blob, perms);
}
if fs::remove_file(blob).is_err() && blob.exists() {
set_blob_readonly(blob);
}
}
}
fn hardlink_eligible(store_name: &str, executable: bool) -> bool {
use crate::compiler::{ArtifactKind, classify_by_filename};
if executable {
return false;
}
#[cfg(windows)]
if !crate::link::windows_hardlink_enabled() {
return false;
}
match classify_by_filename(store_name) {
ArtifactKind::DepInfo | ArtifactKind::Other("extensionless") => false,
kind => kind.link_strategy() == crate::link::LinkStrategy::Hardlink,
}
}
#[derive(Clone, Copy)]
enum StoreIngest {
Reflink,
Hardlink,
Copy,
}
fn materialize_blob(source: &Path, blob: &Path, allow_hardlink: bool) -> Result<()> {
if blob.is_file() {
return Ok(());
}
fs::create_dir_all(blob.parent().unwrap()).context("creating blob shard directory")?;
let bytes = fs::metadata(source).map(|m| m.len()).unwrap_or(0);
let ingest = std::cell::Cell::new(StoreIngest::Copy);
let ro_failed = std::cell::Cell::new(false);
let published = match crate::atomic::atomic_write_and_replace_with(
blob,
true,
|tmp| {
if crate::link::try_reflink(source, tmp).is_ok() {
ingest.set(StoreIngest::Reflink);
} else if allow_hardlink
&& fs::symlink_metadata(source).is_ok_and(|m| m.file_type().is_file())
&& fs::hard_link(source, tmp).is_ok()
{
ingest.set(StoreIngest::Hardlink);
} else {
fs::copy(source, tmp)
.with_context(|| format!("copying {} to blob store", source.display()))?;
ingest.set(StoreIngest::Copy);
}
Ok(())
},
|tmp| {
if matches!(ingest.get(), StoreIngest::Hardlink)
&& let Err(e) = set_blob_readonly_checked(tmp)
{
tracing::debug!(
"read-only guard failed on hardlinked blob temp ({e}); \
falling back to copy: {}",
source.display()
);
ro_failed.set(true);
anyhow::bail!("read-only guard failed on hardlinked blob temp");
}
Ok(())
},
) {
Ok(published) => published,
Err(_e) if ro_failed.get() => {
return materialize_blob(source, blob, false);
}
Err(e) => {
if matches!(ingest.get(), StoreIngest::Hardlink) {
if blob.is_file() {
set_blob_readonly(blob);
} else {
restore_source_writable_if_unshared(source, blob);
}
}
return Err(e);
}
};
if published {
match ingest.get() {
StoreIngest::Reflink => crate::opcounts::record_store_reflinked(bytes),
StoreIngest::Hardlink => crate::opcounts::record_store_hardlinked(bytes),
StoreIngest::Copy => crate::opcounts::record_store_copied(bytes),
}
set_blob_readonly(blob);
} else if matches!(ingest.get(), StoreIngest::Hardlink) {
if paths_share_inode(source, blob) {
set_blob_readonly(blob);
} else {
restore_source_writable_if_unshared(source, blob);
}
}
Ok(())
}
fn paths_share_inode(a: &Path, b: &Path) -> bool {
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
match (fs::metadata(a), fs::metadata(b)) {
(Ok(ma), Ok(mb)) => ma.dev() == mb.dev() && ma.ino() == mb.ino(),
_ => false,
}
}
#[cfg(windows)]
{
use std::os::windows::io::AsRawHandle;
use windows_sys::Win32::Storage::FileSystem::{
BY_HANDLE_FILE_INFORMATION, GetFileInformationByHandle,
};
fn identity(path: &Path) -> Option<(u32, u32, u32)> {
let file = fs::File::open(path).ok()?;
let handle = file.as_raw_handle();
let mut info: BY_HANDLE_FILE_INFORMATION = unsafe { std::mem::zeroed() };
let ok = unsafe { GetFileInformationByHandle(handle as _, &mut info) };
if ok != 0 {
Some((
info.dwVolumeSerialNumber,
info.nFileIndexHigh,
info.nFileIndexLow,
))
} else {
None
}
}
match (identity(a), identity(b)) {
(Some(ia), Some(ib)) => ia == ib,
_ => false,
}
}
#[cfg(not(any(unix, windows)))]
{
let _ = (a, b);
false
}
}
fn restore_source_writable_if_unshared(source: &Path, blob: &Path) {
if paths_share_inode(source, blob) {
return;
}
if let Ok(meta) = fs::metadata(source) {
let mut perms = meta.permissions();
if perms.readonly() {
perms.set_readonly(false);
let _ = fs::set_permissions(source, perms);
}
}
}
pub(crate) fn blob_path_in_store_dir(store_dir: &Path, hash: &str) -> PathBuf {
let prefix = hash.get(..2).unwrap_or(hash);
store_dir.join("blobs").join(prefix).join(hash)
}
#[derive(Debug)]
pub(crate) enum ProbeOutcome {
Hit(Box<EntryMeta>),
Miss,
Fallback(&'static str),
}
pub(crate) fn probe_entry_readonly(
db: &Connection,
store_dir: &Path,
cache_key: &str,
) -> ProbeOutcome {
let committed = db.query_row(
"SELECT committed FROM entries WHERE cache_key = ?1",
params![cache_key],
|row| row.get::<_, bool>(0),
);
match committed {
Ok(true) => {}
Ok(false) => return ProbeOutcome::Miss,
Err(SqlError::QueryReturnedNoRows) => return ProbeOutcome::Miss,
Err(_) => return ProbeOutcome::Fallback("index read failed"),
}
if !matches!(verify_restores_mode(), VerifyRestores::Off) {
return ProbeOutcome::Fallback("verify_restores enabled");
}
let entry_dir = store_dir.join(cache_key);
let meta_path = entry_dir.join("meta.json");
let content = match fs::read_to_string(&meta_path) {
Ok(content) => content,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return ProbeOutcome::Miss,
Err(_) => return ProbeOutcome::Fallback("meta.json unreadable"),
};
let meta: EntryMeta = match serde_json::from_str(&content) {
Ok(meta) => meta,
Err(_) => return ProbeOutcome::Fallback("meta.json unparseable"),
};
if meta.files.is_empty() {
return ProbeOutcome::Fallback("entry has no files");
}
if meta.files.iter().any(|f| entry_dir.join(&f.name).exists()) {
return ProbeOutcome::Fallback("legacy entry needs migration");
}
for cached_file in &meta.files {
let blob = blob_path_in_store_dir(store_dir, &cached_file.hash);
match fs::metadata(&blob) {
Ok(file_meta) if file_meta.is_file() && file_meta.len() == cached_file.size => {}
_ => return ProbeOutcome::Fallback("blob missing or size mismatch"),
}
}
ProbeOutcome::Hit(Box::new(meta))
}
pub(crate) fn open_index_db_readonly(db_path: &Path) -> Result<Connection> {
let db = Connection::open_with_flags(
db_path,
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
)
.with_context(|| format!("opening index read-only {}", db_path.display()))?;
db.pragma_update(None, "busy_timeout", "25")?;
db.pragma_update(None, "query_only", "ON")?;
Ok(db)
}
#[cfg(target_os = "macos")]
const TMUTIL_TIMEOUT: Duration = Duration::from_secs(30);
#[cfg(target_os = "macos")]
pub(crate) fn exclude_from_indexing(dir: &Path) -> Option<std::thread::JoinHandle<()>> {
let sentinel = dir.join(".metadata_never_index");
if !sentinel.exists() {
let _ = fs::File::create(&sentinel);
}
if backup_exclusion_xattr_present(dir) {
return None;
}
let dir = dir.display().to_string();
std::thread::Builder::new()
.name("kache-tmutil".into())
.spawn(move || run_tmutil_addexclusion_bounded(&dir))
.ok()
}
#[cfg(target_os = "macos")]
fn backup_exclusion_xattr_present(dir: &Path) -> bool {
use std::os::unix::ffi::OsStrExt;
let Ok(path) = std::ffi::CString::new(dir.as_os_str().as_bytes()) else {
return false;
};
let name = c"com.apple.metadata:com_apple_backup_excludeItem";
let len =
unsafe { libc::getxattr(path.as_ptr(), name.as_ptr(), std::ptr::null_mut(), 0, 0, 0) };
len >= 0
}
#[cfg(target_os = "macos")]
fn run_tmutil_addexclusion_bounded(dir: &str) {
let child = std::process::Command::new("tmutil")
.args(["addexclusion", dir])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn();
let Ok(mut child) = child else {
return;
};
let started = std::time::Instant::now();
loop {
match child.try_wait() {
Ok(Some(_)) => return,
Ok(None) if started.elapsed() >= TMUTIL_TIMEOUT => {
tracing::debug!(
"tmutil addexclusion still running after {}s (active backup?) — killing it; \
the exclusion will be retried on the next daemon start",
TMUTIL_TIMEOUT.as_secs()
);
let _ = child.kill();
let _ = child.wait();
return;
}
Ok(None) => std::thread::sleep(Duration::from_millis(250)),
Err(_) => return,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct EntryMeta {
pub cache_key: String,
pub crate_name: String,
pub crate_types: Vec<String>,
pub files: Vec<CachedFile>,
pub stdout: String,
pub stderr: String,
#[serde(default)]
pub features: Vec<String>,
#[serde(default)]
pub target: String,
#[serde(default)]
pub profile: String,
#[serde(default)]
pub compile_time_ms: u64,
#[serde(default)]
pub emit_kinds: Vec<String>,
}
impl EntryMeta {
pub fn covers_requested_emit(&self, requested: &[String]) -> bool {
if self.emit_kinds.is_empty() {
return true;
}
requested
.iter()
.filter(|kind| crate::compiler::GATED_EMIT_KINDS.contains(&kind.as_str()))
.all(|kind| self.emit_kinds.iter().any(|have| have == kind))
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct CachedFile {
pub name: String,
pub size: u64,
pub hash: String,
#[serde(default)]
pub executable: bool,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct GcStats {
pub entries_evicted: usize,
pub bytes_freed: u64,
pub blobs_removed: usize,
pub duration_ms: u64,
#[serde(default)]
pub skipped: bool,
#[serde(default)]
pub entries_pinned: usize,
}
#[derive(Debug, Clone, Copy, Default)]
pub struct OrphanSweepStats {
pub scanned: usize,
pub removed: usize,
pub bytes_reclaimed: u64,
}
pub struct Store {
config: Config,
db: Connection,
}
pub(crate) const EVICTION_IDLE_GRACE: Duration = Duration::from_secs(120);
const COMPILE_TIME_BACKFILL_BATCH: i64 = 10_000;
pub(crate) const TOMBSTONE_RETENTION_DAYS: u64 = 14;
pub struct KeyLock {
path: PathBuf,
}
pub enum BuildClaim {
Acquired(KeyLock),
Committed(Box<EntryMeta>),
Contended,
}
struct PreparedKeyLock {
path: PathBuf,
temp: tempfile::NamedTempFile,
}
impl PreparedKeyLock {
fn new(path: PathBuf) -> Result<Self> {
let parent = path
.parent()
.ok_or_else(|| anyhow::anyhow!("key lock has no parent: {}", path.display()))?;
fs::create_dir_all(parent)?;
let mut temp = tempfile::NamedTempFile::new_in(parent)?;
use std::io::Write;
write!(temp, "{}", std::process::id())?;
Ok(Self { path, temp })
}
fn publish(self) -> Result<Option<KeyLock>> {
match self.temp.persist_noclobber(&self.path) {
Ok(_) => Ok(Some(KeyLock { path: self.path })),
Err(e) if e.error.kind() == std::io::ErrorKind::AlreadyExists => Ok(None),
Err(e) => Err(e.error.into()),
}
}
}
pub struct GcLock {
file: Option<fs::File>,
}
impl Drop for KeyLock {
fn drop(&mut self) {
let _ = fs::remove_file(&self.path);
}
}
impl Drop for GcLock {
fn drop(&mut self) {
if let Some(file) = self.file.take() {
let _ = file.unlock();
drop(file);
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum VerifyRestores {
Off,
Sampled,
Always,
}
const VERIFY_SAMPLE_RATE: u64 = 16;
static VERIFY_SAMPLE_COUNTER: AtomicU64 = AtomicU64::new(0);
pub(crate) fn verify_restores_mode() -> VerifyRestores {
parse_verify_restores(std::env::var("KACHE_VERIFY_RESTORES").ok().as_deref())
}
fn parse_verify_restores(value: Option<&str>) -> VerifyRestores {
match value {
Some(v) if v.eq_ignore_ascii_case("sampled") => VerifyRestores::Sampled,
Some(v)
if v.eq_ignore_ascii_case("always") || v == "1" || v.eq_ignore_ascii_case("true") =>
{
VerifyRestores::Always
}
_ => VerifyRestores::Off,
}
}
fn should_verify_this_restore(mode: VerifyRestores) -> bool {
match mode {
VerifyRestores::Off => false,
VerifyRestores::Always => true,
VerifyRestores::Sampled => VERIFY_SAMPLE_COUNTER
.fetch_add(1, Ordering::Relaxed)
.is_multiple_of(VERIFY_SAMPLE_RATE),
}
}
fn max_diagnostics_bytes() -> Option<usize> {
std::env::var("KACHE_MAX_DIAGNOSTICS_BYTES")
.ok()
.and_then(|v| v.parse::<usize>().ok())
.filter(|&n| n > 0)
}
fn cap_diagnostics(s: &str, max: Option<usize>) -> String {
match max {
Some(limit) if s.len() > limit => {
let mut end = limit;
while end > 0 && !s.is_char_boundary(end) {
end -= 1;
}
let omitted = s.len() - end;
format!(
"{}\n[kache: diagnostics truncated, {omitted} bytes omitted (#336)]\n",
&s[..end]
)
}
_ => s.to_string(),
}
}
fn is_executable(metadata: &fs::Metadata) -> bool {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
metadata.permissions().mode() & 0o111 != 0
}
#[cfg(not(unix))]
{
let _ = metadata;
false
}
}
fn zero_byte_is_valid_output(store_name: &str, crate_types: &[String]) -> bool {
matches!(
crate::compiler::classify_by_filename(store_name),
crate::compiler::ArtifactKind::Metadata
) && !crate_types
.iter()
.any(|ct| crate::compiler::rustc::crate_type_produces_metadata(ct))
}
fn fold_field(h: &mut blake3::Hasher, bytes: &[u8]) {
h.update(&(bytes.len() as u64).to_le_bytes());
h.update(bytes);
}
fn emit_kinds_for_files(files: &[CachedFile]) -> Vec<String> {
let mut kinds: Vec<String> = files
.iter()
.filter_map(|f| crate::compiler::emit_kind_for_filename(&f.name))
.map(str::to_string)
.collect();
kinds.sort();
kinds.dedup();
kinds
}
fn compute_content_hash(files: &[CachedFile]) -> String {
let mut sorted: Vec<&CachedFile> = files.iter().collect();
sorted.sort_by(|a, b| a.name.cmp(&b.name).then_with(|| a.hash.cmp(&b.hash)));
let mut h = blake3::Hasher::new();
for f in &sorted {
fold_field(&mut h, f.name.as_bytes());
fold_field(&mut h, f.hash.as_bytes());
fold_field(&mut h, &f.size.to_le_bytes());
fold_field(&mut h, &[u8::from(f.executable)]);
}
h.finalize().to_hex().to_string()
}
const STORE_OPEN_MAX_ATTEMPTS: u32 = 6;
const STORE_OPEN_RETRY_DELAYS_MS: [u64; 5] = [25, 50, 100, 200, 250];
fn sqlite_open_retry_delay(attempt: u32) -> Duration {
let idx = attempt.saturating_sub(1) as usize;
Duration::from_millis(*STORE_OPEN_RETRY_DELAYS_MS.get(idx).unwrap_or(&250))
}
fn is_retryable_sqlite_open_error(err: &SqlError) -> bool {
match err {
SqlError::SqliteFailure(code, _) => matches!(
code.code,
ErrorCode::CannotOpen
| ErrorCode::DatabaseBusy
| ErrorCode::DatabaseLocked
| ErrorCode::SystemIoFailure
),
_ => false,
}
}
fn initialize_db(db: &Connection) -> rusqlite::Result<()> {
db.pragma_update(None, "journal_mode", "WAL")?;
db.pragma_update(None, "synchronous", "NORMAL")?;
db.pragma_update(None, "busy_timeout", "5000")?;
db.execute_batch(
"CREATE TABLE IF NOT EXISTS entries (
cache_key TEXT PRIMARY KEY,
crate_name TEXT NOT NULL,
size INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
last_accessed TEXT NOT NULL DEFAULT (datetime('now')),
hit_count INTEGER NOT NULL DEFAULT 0,
committed INTEGER NOT NULL DEFAULT 0
);",
)?;
let _ = db.execute_batch("ALTER TABLE entries ADD COLUMN crate_type TEXT NOT NULL DEFAULT ''");
let _ = db.execute_batch("ALTER TABLE entries ADD COLUMN profile TEXT NOT NULL DEFAULT ''");
let _ =
db.execute_batch("ALTER TABLE entries ADD COLUMN num_features INTEGER NOT NULL DEFAULT 0");
let _ = db.execute_batch("ALTER TABLE entries ADD COLUMN content_hash TEXT");
let _ = db
.execute_batch("ALTER TABLE entries ADD COLUMN compile_time_ms INTEGER NOT NULL DEFAULT 0");
db.execute_batch(
"CREATE TABLE IF NOT EXISTS blobs (
hash TEXT PRIMARY KEY,
size INTEGER NOT NULL,
refcount INTEGER NOT NULL DEFAULT 1
);",
)?;
db.execute_batch(
"CREATE TABLE IF NOT EXISTS eviction_tombstones (
cache_key TEXT PRIMARY KEY,
evicted_at TEXT NOT NULL DEFAULT (datetime('now')),
policy TEXT NOT NULL DEFAULT '',
size INTEGER NOT NULL DEFAULT 0,
hit_count INTEGER NOT NULL DEFAULT 0,
idle_hours REAL NOT NULL DEFAULT 0,
compile_time_ms INTEGER NOT NULL DEFAULT 0,
demanded_at TEXT
);",
)?;
db.execute_batch(
"CREATE TABLE IF NOT EXISTS incremental_dirs (
path TEXT PRIMARY KEY,
last_seen TEXT NOT NULL DEFAULT (datetime('now'))
);",
)?;
crate::cache_key::ensure_file_hash_cache_schema(db)?;
Ok(())
}
pub(crate) fn open_index_db(db_path: &Path) -> Result<Connection> {
open_index_db_reporting_recovery(db_path).map(|(db, _)| db)
}
pub(crate) fn open_index_db_reporting_recovery(db_path: &Path) -> Result<(Connection, bool)> {
match try_open_index_db(db_path) {
Ok(db) => Ok((db, false)),
Err(err) if is_corruption_error(&err) => recover_corrupt_index(db_path, &err),
Err(err) => Err(err.into()),
}
}
fn recover_corrupt_index(db_path: &Path, err: &SqlError) -> Result<(Connection, bool)> {
let _lock = acquire_index_recovery_lock(db_path);
match try_open_index_db(db_path) {
Ok(db) => return Ok((db, false)),
Err(e) if is_corruption_error(&e) => {} Err(e) => return Err(e.into()),
}
let quarantined = quarantine_corrupt_index(db_path)
.with_context(|| format!("quarantining corrupt index {}", db_path.display()))?;
tracing::warn!(
path = %db_path.display(),
quarantined = %quarantined.display(),
"index database is corrupt ({err}); quarantined it and recreated an empty index. \
Rebuilding the entry rows from the store; run `kache doctor` to inspect."
);
let db = try_open_index_db(db_path)
.map_err(anyhow::Error::from)
.with_context(|| {
format!(
"recreating index database after quarantine {}",
db_path.display()
)
})?;
Ok((db, true))
}
fn acquire_index_recovery_lock(db_path: &Path) -> Option<fs::File> {
let lock_path = index_sidecar_path(db_path, ".recovery-lock");
let file = fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(false)
.open(&lock_path)
.ok()?;
file.lock().ok()?;
Some(file)
}
fn try_open_index_db(db_path: &Path) -> std::result::Result<Connection, SqlError> {
let mut last_error: Option<SqlError> = None;
for attempt in 1..=STORE_OPEN_MAX_ATTEMPTS {
match Connection::open(db_path).and_then(|db| {
initialize_db(&db)?;
Ok(db)
}) {
Ok(db) => return Ok(db),
Err(err)
if attempt < STORE_OPEN_MAX_ATTEMPTS && is_retryable_sqlite_open_error(&err) =>
{
let delay = sqlite_open_retry_delay(attempt);
tracing::debug!(
path = %db_path.display(),
attempt,
?delay,
"retrying transient SQLite open failure: {err}"
);
last_error = Some(err);
std::thread::sleep(delay);
}
Err(err) => {
last_error = Some(err);
break;
}
}
}
Err(last_error.expect("try_open_index_db must record an error before returning"))
}
fn is_corruption_error(err: &SqlError) -> bool {
matches!(
err,
SqlError::SqliteFailure(code, _)
if matches!(code.code, ErrorCode::DatabaseCorrupt | ErrorCode::NotADatabase)
)
}
fn quarantine_corrupt_index(db_path: &Path) -> Result<PathBuf> {
let millis = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or(0);
let file_name = db_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("index.db");
let quarantine = db_path.with_file_name(format!(
"{file_name}.corrupt-{millis}-{}",
std::process::id()
));
fs::rename(db_path, &quarantine)
.with_context(|| format!("renaming corrupt index {} aside", db_path.display()))?;
for ext in ["-wal", "-shm"] {
let from = index_sidecar_path(db_path, ext);
if from.exists() {
let _ = fs::rename(&from, index_sidecar_path(&quarantine, ext));
}
}
Ok(quarantine)
}
fn index_sidecar_path(db_path: &Path, suffix: &str) -> PathBuf {
let mut name = db_path
.file_name()
.map(|n| n.to_os_string())
.unwrap_or_default();
name.push(suffix);
db_path.with_file_name(name)
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct RebuildStats {
pub entries_rebuilt: usize,
pub entries_skipped: usize,
pub blobs_registered: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CrateHistoryEntry {
pub cache_key: String,
pub crate_name: String,
pub entry_dir: PathBuf,
pub compile_time_ms: Option<u64>,
pub size_bytes: Option<u64>,
}
fn positive_or_none(value: i64) -> Option<u64> {
(value > 0).then_some(value as u64)
}
impl Store {
pub fn open(config: &Config) -> Result<Self> {
fs::create_dir_all(&config.cache_dir)
.with_context(|| format!("creating cache directory {}", config.cache_dir.display()))?;
let store_dir = config.store_dir();
fs::create_dir_all(&store_dir)
.with_context(|| format!("creating store directory {}", store_dir.display()))?;
let db_path = config.index_db_path();
let (db, recovered) = open_index_db_reporting_recovery(&db_path)
.with_context(|| format!("opening index database {}", db_path.display()))?;
let store = Store {
config: config.clone(),
db,
};
if recovered {
match store.rebuild_index_from_store() {
Ok(stats) if stats.entries_rebuilt > 0 || stats.entries_skipped > 0 => {
tracing::warn!(
rebuilt = stats.entries_rebuilt,
skipped = stats.entries_skipped,
blobs = stats.blobs_registered,
"rebuilt the index from the store after corruption"
);
}
Ok(_) => {}
Err(e) => tracing::warn!(
"could not rebuild the index from the store after corruption: {e:#}"
),
}
}
Ok(store)
}
pub fn file_hasher(&self) -> crate::cache_key::FileHasher<'_> {
crate::cache_key::FileHasher::from_connection(&self.db)
}
pub fn file_hasher_with_daemon(
&self,
socket_path: PathBuf,
) -> crate::cache_key::FileHasher<'_> {
self.file_hasher().with_daemon(socket_path)
}
pub fn file_hash_lookup(&self, path: &Path) -> crate::cache_key::FileHashLookup {
self.file_hasher().lookup_cached(path)
}
pub fn file_hash_record(&self, fingerprint: &crate::cache_key::FileFingerprint, hash: &str) {
self.file_hasher().record_cached(fingerprint, hash);
}
pub fn contains(&self, cache_key: &str) -> bool {
let entry_dir = self.entry_dir(cache_key);
let meta_path = entry_dir.join("meta.json");
if !meta_path.exists() {
return false;
}
self.db
.query_row(
"SELECT committed FROM entries WHERE cache_key = ?1",
params![cache_key],
|row| row.get::<_, bool>(0),
)
.unwrap_or(false)
}
pub fn get(&self, cache_key: &str) -> Result<Option<EntryMeta>> {
if !self.contains(cache_key) {
self.note_tombstone_demand(cache_key);
return Ok(None);
}
let entry_dir = self.entry_dir(cache_key);
let meta_path = entry_dir.join("meta.json");
let content = fs::read_to_string(&meta_path).context("reading entry meta.json")?;
let meta: EntryMeta = serde_json::from_str(&content).context("parsing entry meta.json")?;
let needs_migration = meta.files.iter().any(|f| entry_dir.join(&f.name).exists());
if needs_migration && let Err(e) = self.migrate_entry_to_blobs(&meta) {
tracing::warn!(
"lazy migration failed for {}: {e}",
&cache_key[..16.min(cache_key.len())]
);
}
let verify_content = should_verify_this_restore(verify_restores_mode());
for cached_file in &meta.files {
let blob = self.blob_path(&cached_file.hash);
if !blob.is_file() {
tracing::warn!(
"cache entry {} missing blob {} for file {}, evicting",
cache_key.get(..16).unwrap_or(cache_key),
&cached_file.hash[..16],
cached_file.name
);
let _ = self.remove_entry(cache_key);
return Ok(None);
}
if let Ok(file_meta) = fs::metadata(&blob)
&& file_meta.len() != cached_file.size
{
tracing::warn!(
"cache entry {} file {} size mismatch (expected {}, got {}), evicting",
cache_key.get(..16).unwrap_or(cache_key),
cached_file.name,
cached_file.size,
file_meta.len(),
);
let _ = self.remove_entry(cache_key);
return Ok(None);
}
if verify_content {
match crate::cache_key::hash_file(&blob) {
Ok(actual) if actual == cached_file.hash => {}
Ok(actual) => {
tracing::warn!(
"cache entry {} file {} content mismatch (expected {}, got {}), evicting",
cache_key.get(..16).unwrap_or(cache_key),
cached_file.name,
&cached_file.hash[..16.min(cached_file.hash.len())],
&actual[..16.min(actual.len())],
);
let _ = self.remove_entry(cache_key);
return Ok(None);
}
Err(e) => {
tracing::warn!(
"cache entry {} file {} unreadable for verification ({e}), evicting",
cache_key.get(..16).unwrap_or(cache_key),
cached_file.name,
);
let _ = self.remove_entry(cache_key);
return Ok(None);
}
}
}
}
self.db.execute(
"UPDATE entries SET last_accessed = datetime('now'), hit_count = hit_count + 1 WHERE cache_key = ?1",
params![cache_key],
)?;
Ok(Some(meta))
}
pub fn try_lock(&self, cache_key: &str) -> Result<Option<KeyLock>> {
self.try_acquire_lock(self.entry_dir(cache_key).with_extension("lock"))
}
pub fn claim_build(&self, cache_key: &str) -> Result<BuildClaim> {
let Some(lock) = self.try_lock(cache_key)? else {
return Ok(BuildClaim::Contended);
};
match self.get(cache_key)? {
Some(meta) if meta.files.is_empty() => {
tracing::warn!("cache entry {cache_key} has no files, evicting before build");
self.remove_entry(cache_key)?;
Ok(BuildClaim::Acquired(lock))
}
Some(meta) => Ok(BuildClaim::Committed(Box::new(meta))),
None => Ok(BuildClaim::Acquired(lock)),
}
}
pub fn try_gc_lock(&self) -> Result<Option<GcLock>> {
self.try_acquire_file_lock(self.config.store_dir().join("gc.lock"))
}
fn try_acquire_lock(&self, lock_path: PathBuf) -> Result<Option<KeyLock>> {
if let Some(lock) = PreparedKeyLock::new(lock_path.clone())?.publish()? {
return Ok(Some(lock));
}
if !self.is_lock_stale(&lock_path)? {
return Ok(None);
}
let _recovery_guard =
self.acquire_file_lock(self.config.store_dir().join("build-lock-recovery.lock"))?;
if let Some(lock) = PreparedKeyLock::new(lock_path.clone())?.publish()? {
return Ok(Some(lock));
}
if !self.is_lock_stale(&lock_path)? {
return Ok(None);
}
match fs::remove_file(&lock_path) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(e.into()),
}
PreparedKeyLock::new(lock_path)?.publish()
}
fn acquire_file_lock(&self, lock_path: PathBuf) -> Result<GcLock> {
fs::create_dir_all(lock_path.parent().unwrap())?;
let mut file = fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(false)
.open(&lock_path)?;
file.lock()?;
use std::io::{Seek, SeekFrom, Write};
file.set_len(0)?;
file.seek(SeekFrom::Start(0))?;
write!(file, "{}", std::process::id())?;
Ok(GcLock { file: Some(file) })
}
fn try_acquire_file_lock(&self, lock_path: PathBuf) -> Result<Option<GcLock>> {
fs::create_dir_all(lock_path.parent().unwrap())?;
let mut file = fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(false)
.open(&lock_path)?;
match file.try_lock() {
Ok(()) => {
use std::io::{Seek, SeekFrom, Write};
file.set_len(0)?;
file.seek(SeekFrom::Start(0))?;
let _ = write!(file, "{}", std::process::id());
Ok(Some(GcLock { file: Some(file) }))
}
Err(std::fs::TryLockError::WouldBlock) => Ok(None),
Err(std::fs::TryLockError::Error(e)) => Err(e.into()),
}
}
pub fn wait_for_committed(&self, cache_key: &str) -> Result<bool> {
let lock_path = self.entry_dir(cache_key).with_extension("lock");
let start = std::time::Instant::now();
let timeout = std::time::Duration::from_secs(600);
while lock_path.exists() && start.elapsed() < timeout {
std::thread::sleep(std::time::Duration::from_millis(100));
}
Ok(self.contains(cache_key))
}
#[allow(dead_code)]
pub fn put(
&self,
cache_key: &str,
crate_name: &str,
crate_types: &[String],
features: &[String],
target: &str,
profile: &str,
output_files: &[(PathBuf, String)], stdout: &str,
stderr: &str,
) -> Result<StorePutResult> {
self.put_with_compile_time(
cache_key,
crate_name,
crate_types,
features,
target,
profile,
output_files,
stdout,
stderr,
0,
)
}
pub fn put_with_compile_time(
&self,
cache_key: &str,
crate_name: &str,
crate_types: &[String],
features: &[String],
target: &str,
profile: &str,
output_files: &[(PathBuf, String)], stdout: &str,
stderr: &str,
compile_time_ms: u64,
) -> Result<StorePutResult> {
let entry_dir = self.entry_dir(cache_key);
fs::create_dir_all(&entry_dir).context("creating entry directory")?;
let mut cached_files = Vec::new();
let mut sources: Vec<PathBuf> = Vec::new();
let mut seen_output_blobs = std::collections::HashSet::new();
let mut put_result = StorePutResult::default();
let mut total_size = 0u64;
for (source_path, store_name) in output_files {
let hash = crate::cache_key::hash_file(source_path)?;
let metadata = fs::metadata(source_path)?;
let size = metadata.len();
let executable = is_executable(&metadata);
if size == 0 && !zero_byte_is_valid_output(store_name, crate_types) {
anyhow::bail!("refusing to cache zero-byte artifact: {}", store_name);
}
total_size += size;
if seen_output_blobs.insert(hash.clone()) {
put_result.output_blobs += 1;
if self.blob_path(&hash).is_file() {
put_result.duplicate_blobs += 1;
} else {
put_result.new_blobs += 1;
}
}
materialize_blob(
source_path,
&self.blob_path(&hash),
hardlink_eligible(store_name, executable),
)?;
cached_files.push(CachedFile {
name: store_name.clone(),
size,
hash,
executable,
});
sources.push(source_path.clone());
}
let content_hash = compute_content_hash(&cached_files);
let emit_kinds = emit_kinds_for_files(&cached_files);
let diag_cap = max_diagnostics_bytes();
let meta = EntryMeta {
cache_key: cache_key.to_string(),
crate_name: crate_name.to_string(),
crate_types: crate_types.to_vec(),
files: cached_files,
stdout: cap_diagnostics(stdout, diag_cap),
stderr: cap_diagnostics(stderr, diag_cap),
features: features.to_vec(),
target: target.to_string(),
profile: profile.to_string(),
compile_time_ms,
emit_kinds,
};
let meta_json =
serde_json::to_string_pretty(&meta).context("serializing entry metadata")?;
let meta_path = entry_dir.join("meta.json");
fs::write(&meta_path, meta_json)?;
crate::atomic::fsync_file(&meta_path).context("flushing entry metadata")?;
let crate_type_str = crate_types.join(",");
let num_features = features.len() as i64;
let tx = self.db.unchecked_transaction()?;
for (file, source) in meta.files.iter().zip(sources.iter()) {
let inserted = tx.execute(
"INSERT OR IGNORE INTO blobs (hash, size, refcount) VALUES (?1, ?2, 1)",
params![file.hash, file.size as i64],
)?;
if inserted == 0 {
tx.execute(
"UPDATE blobs SET refcount = refcount + 1 WHERE hash = ?1",
params![file.hash],
)?;
}
materialize_blob(
source,
&self.blob_path(&file.hash),
hardlink_eligible(&file.name, file.executable),
)?;
}
tx.execute(
"INSERT OR REPLACE INTO entries (cache_key, crate_name, crate_type, profile, num_features, size, content_hash, compile_time_ms, committed) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 1)",
params![cache_key, crate_name, crate_type_str, profile, num_features, total_size as i64, content_hash, compile_time_ms as i64],
)?;
tx.commit()?;
Ok(put_result)
}
pub fn import_downloaded_entry(&self, cache_key: &str) -> Result<()> {
let entry_dir = self.entry_dir(cache_key);
let meta_path = entry_dir.join("meta.json");
let content = fs::read_to_string(&meta_path).context("reading downloaded meta.json")?;
let meta: EntryMeta =
serde_json::from_str(&content).context("parsing downloaded meta.json")?;
let short_key = cache_key.get(..16).unwrap_or(cache_key);
for cached_file in &meta.files {
if !crate::remote_layout::is_blob_hash(&cached_file.hash) {
anyhow::bail!(
"downloaded entry {short_key}: rejecting file {} — malformed blob hash {:?}",
cached_file.name,
cached_file.hash,
);
}
if !crate::remote_layout::is_safe_artifact_name(&cached_file.name) {
anyhow::bail!(
"downloaded entry {short_key}: rejecting unsafe artifact name {:?}",
cached_file.name,
);
}
let file_path = entry_dir.join(&cached_file.name);
if !file_path.is_file() {
anyhow::bail!(
"downloaded entry {short_key} missing file: {}",
cached_file.name
);
}
let file_meta = fs::metadata(&file_path).with_context(|| {
format!("downloaded entry {short_key}: stat {}", cached_file.name)
})?;
if file_meta.len() != cached_file.size {
anyhow::bail!(
"downloaded entry {short_key} file {} size mismatch (expected {}, got {})",
cached_file.name,
cached_file.size,
file_meta.len(),
);
}
let actual = crate::cache_key::hash_file(&file_path).with_context(|| {
format!(
"downloaded entry {short_key}: hashing {} for trust-boundary check",
cached_file.name
)
})?;
if actual != cached_file.hash {
anyhow::bail!(
"downloaded entry {short_key}: content hash mismatch for {} \
(claimed {}, actual {})",
cached_file.name,
cached_file.hash,
actual,
);
}
}
for cached_file in &meta.files {
let blob = self.blob_path(&cached_file.hash);
if !blob.is_file() {
let file_path = entry_dir.join(&cached_file.name);
fs::create_dir_all(blob.parent().unwrap())
.context("creating blob shard directory")?;
fs::rename(&file_path, &blob).with_context(|| {
format!(
"moving downloaded artifact {} to blob store",
file_path.display()
)
})?;
crate::atomic::fsync_file(&blob).context("flushing downloaded blob to disk")?;
set_blob_readonly(&blob);
}
}
let total_size: u64 = meta.files.iter().map(|f| f.size).sum();
let content_hash = compute_content_hash(&meta.files);
let crate_type_str = meta.crate_types.join(",");
let num_features = meta.features.len() as i64;
let tx = self.db.unchecked_transaction()?;
for cached_file in &meta.files {
let inserted = tx.execute(
"INSERT OR IGNORE INTO blobs (hash, size, refcount) VALUES (?1, ?2, 1)",
params![cached_file.hash, cached_file.size as i64],
)?;
if inserted == 0 {
tx.execute(
"UPDATE blobs SET refcount = refcount + 1 WHERE hash = ?1",
params![cached_file.hash],
)?;
}
let blob = self.blob_path(&cached_file.hash);
if !blob.is_file() {
let file_path = entry_dir.join(&cached_file.name);
if !file_path.is_file() {
anyhow::bail!(
"downloaded blob {} vanished during import",
&cached_file.hash[..16.min(cached_file.hash.len())]
);
}
fs::create_dir_all(blob.parent().unwrap())
.context("creating blob shard directory")?;
fs::rename(&file_path, &blob).with_context(|| {
format!(
"restoring downloaded artifact {} to blob store",
file_path.display()
)
})?;
crate::atomic::fsync_file(&blob).context("flushing downloaded blob to disk")?;
set_blob_readonly(&blob);
}
}
tx.execute(
"INSERT OR REPLACE INTO entries (cache_key, crate_name, crate_type, profile, num_features, size, content_hash, compile_time_ms, committed) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 1)",
params![cache_key, meta.crate_name, crate_type_str, meta.profile, num_features, total_size as i64, content_hash, meta.compile_time_ms as i64],
)?;
tx.commit()?;
for cached_file in &meta.files {
let file_path = entry_dir.join(&cached_file.name);
if file_path.is_file() {
let _ = fs::remove_file(&file_path);
}
}
Ok(())
}
pub fn import_restored_entry(&self, cache_key: &str) -> Result<()> {
self.import_downloaded_entry(cache_key)
}
pub fn rebuild_index_from_store(&self) -> Result<RebuildStats> {
let store_dir = self.config.store_dir();
let mut stats = RebuildStats::default();
let dir = match fs::read_dir(&store_dir) {
Ok(dir) => dir,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(stats),
Err(e) => {
return Err(e).with_context(|| format!("scanning store {}", store_dir.display()));
}
};
for entry in dir {
let entry = match entry {
Ok(e) => e,
Err(e) => {
tracing::debug!("skipping unreadable store dir entry: {e}");
continue;
}
};
let path = entry.path();
if !path.is_dir() {
continue;
}
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
if name == "blobs" {
continue;
}
if !crate::cache_key::is_valid_cache_key(name) {
continue;
}
match self.rebuild_one_entry(name, &path) {
Ok(Some(blobs)) => {
stats.entries_rebuilt += 1;
stats.blobs_registered += blobs;
}
Ok(None) => stats.entries_skipped += 1,
Err(e) => {
tracing::debug!(
"skipping entry {} during index rebuild: {e:#}",
&name[..16.min(name.len())]
);
stats.entries_skipped += 1;
}
}
}
Ok(stats)
}
fn rebuild_one_entry(&self, cache_key: &str, entry_dir: &Path) -> Result<Option<usize>> {
let meta_path = entry_dir.join("meta.json");
if !meta_path.is_file() {
return Ok(None);
}
let content = fs::read_to_string(&meta_path).context("reading entry meta.json")?;
let meta: EntryMeta = serde_json::from_str(&content).context("parsing entry meta.json")?;
for file in &meta.files {
if !crate::remote_layout::is_blob_hash(&file.hash)
|| !crate::remote_layout::is_safe_artifact_name(&file.name)
{
return Ok(None);
}
let blob = self.blob_path(&file.hash);
match fs::metadata(&blob) {
Ok(m) if m.len() == file.size => {}
_ => return Ok(None),
}
}
let total_size: u64 = meta.files.iter().map(|f| f.size).sum();
let content_hash = compute_content_hash(&meta.files);
let crate_type_str = meta.crate_types.join(",");
let num_features = meta.features.len() as i64;
let tx = self.db.unchecked_transaction()?;
let inserted = tx.execute(
"INSERT OR IGNORE INTO entries (cache_key, crate_name, crate_type, profile, num_features, size, content_hash, compile_time_ms, committed) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 1)",
params![
cache_key,
meta.crate_name,
crate_type_str,
meta.profile,
num_features,
total_size as i64,
content_hash,
meta.compile_time_ms as i64
],
)?;
if inserted == 0 {
tx.commit()?;
return Ok(None);
}
for file in &meta.files {
let added = tx.execute(
"INSERT OR IGNORE INTO blobs (hash, size, refcount) VALUES (?1, ?2, 1)",
params![file.hash, file.size as i64],
)?;
if added == 0 {
tx.execute(
"UPDATE blobs SET refcount = refcount + 1 WHERE hash = ?1",
params![file.hash],
)?;
}
}
tx.commit()?;
Ok(Some(meta.files.len()))
}
pub fn keys_for_crates(&self, crate_names: &[String]) -> Result<Vec<CrateHistoryEntry>> {
if crate_names.is_empty() {
return Ok(Vec::new());
}
let placeholders: Vec<&str> = crate_names.iter().map(|_| "?").collect();
let sql = format!(
"SELECT cache_key, crate_name, compile_time_ms, size FROM entries WHERE committed = 1 AND crate_name IN ({}) ORDER BY last_accessed DESC",
placeholders.join(",")
);
let mut stmt = self.db.prepare(&sql)?;
let params: Vec<&dyn rusqlite::ToSql> = crate_names
.iter()
.map(|n| n as &dyn rusqlite::ToSql)
.collect();
let rows = stmt.query_map(params.as_slice(), |row| {
let key: String = row.get(0)?;
let cn: String = row.get(1)?;
let compile_time_ms: i64 = row.get(2)?;
let size: i64 = row.get(3)?;
Ok((key, cn, compile_time_ms, size))
})?;
let mut results = Vec::new();
for row in rows {
let (cache_key, crate_name, compile_time_ms, size) = row?;
let entry_dir = self.entry_dir(&cache_key);
results.push(CrateHistoryEntry {
cache_key,
crate_name,
entry_dir,
compile_time_ms: positive_or_none(compile_time_ms),
size_bytes: positive_or_none(size),
});
}
Ok(results)
}
pub fn blob_path(&self, hash: &str) -> PathBuf {
blob_path_in_store_dir(&self.config.store_dir(), hash)
}
#[allow(dead_code)] pub fn blobs_dir(&self) -> PathBuf {
self.config.store_dir().join("blobs")
}
pub fn entry_dir(&self, cache_key: &str) -> PathBuf {
self.config.store_dir().join(cache_key)
}
#[allow(dead_code)]
pub fn cached_file_path(&self, cache_key: &str, filename: &str) -> PathBuf {
self.entry_dir(cache_key).join(filename)
}
pub fn total_size(&self) -> Result<u64> {
let size: i64 =
self.db
.query_row("SELECT COALESCE(SUM(size), 0) FROM entries", [], |row| {
row.get(0)
})?;
Ok(size as u64)
}
pub fn entry_count(&self) -> Result<usize> {
let count: i64 = self
.db
.query_row("SELECT COUNT(*) FROM entries", [], |row| row.get(0))?;
Ok(count as usize)
}
pub fn remember_incremental_dir(&self, path: &Path) -> Result<()> {
let path = path.to_string_lossy().into_owned();
self.db.execute(
"INSERT OR REPLACE INTO incremental_dirs (path, last_seen) VALUES (?1, datetime('now'))",
params![path],
)?;
Ok(())
}
pub fn clean_registered_incremental_dirs(&self) -> Result<usize> {
let paths: Vec<String> = {
let mut stmt = self
.db
.prepare("SELECT path FROM incremental_dirs ORDER BY last_seen ASC")?;
stmt.query_map([], |row| row.get(0))?
.collect::<Result<Vec<_>, _>>()?
};
let mut cleaned = 0;
for path_str in paths {
let path = PathBuf::from(&path_str);
if !path.exists() {
self.db.execute(
"DELETE FROM incremental_dirs WHERE path = ?1",
params![path_str],
)?;
continue;
}
if !path.is_dir() {
tracing::warn!(
"registered incremental path is not a directory, pruning: {}",
path.display()
);
self.db.execute(
"DELETE FROM incremental_dirs WHERE path = ?1",
params![path_str],
)?;
continue;
}
match fs::remove_dir_all(&path) {
Ok(()) => {
self.db.execute(
"DELETE FROM incremental_dirs WHERE path = ?1",
params![path_str],
)?;
cleaned += 1;
}
Err(e) => {
tracing::warn!(
"failed to remove registered incremental dir {}: {}",
path.display(),
e
);
}
}
}
Ok(cleaned)
}
pub(crate) fn eviction_candidates(&self) -> Result<Vec<crate::eviction::EntryFeatures>> {
let mut stmt = self.db.prepare(
"SELECT cache_key, size, hit_count, content_hash, committed,
(julianday('now') - julianday(last_accessed)) * 24.0,
compile_time_ms
FROM entries",
)?;
let rows = stmt
.query_map([], |row| {
Ok(crate::eviction::EntryFeatures {
key: row.get(0)?,
size: row.get(1)?,
hit_count: row.get(2)?,
content_hash: row.get(3)?,
committed: row.get(4)?,
idle_hours: row.get::<_, Option<f64>>(5)?.unwrap_or(0.0),
compile_time_ms: row.get(6)?,
})
})?
.collect::<Result<Vec<_>, _>>()?;
Ok(rows)
}
fn apply_eviction(
&self,
order: &[String],
by_key: &std::collections::HashMap<&str, &crate::eviction::EntryFeatures>,
policy: &str,
stop_at: Option<(u64, u64)>,
) -> GcStats {
let mut stats = GcStats::default();
let (mut current_size, target) = match stop_at {
Some((current, target)) => (current, Some(target)),
None => (0, None),
};
for key in order {
if let Some(target) = target
&& current_size <= target
{
break;
}
let features = by_key.get(key.as_str()).copied();
let size = features.map(|f| f.size).unwrap_or(0);
match self.remove_entry_guarded(key, Some(EVICTION_IDLE_GRACE)) {
Ok(true) => {
stats.entries_evicted += 1;
stats.bytes_freed += size as u64;
current_size = current_size.saturating_sub(size as u64);
if let Some(f) = features {
self.record_tombstone(f, policy);
}
}
Ok(false) => {
stats.entries_pinned += 1;
continue;
}
Err(e) => {
tracing::warn!("gc: skipping eviction of {key}: {e:#}");
continue;
}
}
}
stats
}
fn evict_with(
&self,
policy: &dyn crate::eviction::EvictionPolicy,
stop_at: Option<(u64, u64)>,
) -> Result<GcStats> {
let candidates = self.eviction_candidates()?;
let order = policy.select(&candidates);
if order.is_empty() {
return Ok(GcStats::default());
}
let selected: std::collections::HashSet<&str> = order.iter().map(|k| k.as_str()).collect();
let cost_ms: i64 = candidates
.iter()
.filter(|e| selected.contains(e.key.as_str()))
.map(|e| e.compile_time_ms)
.sum();
tracing::debug!(
policy = policy.name(),
candidates = candidates.len(),
selected = order.len(),
selected_compile_time_ms = cost_ms,
"gc: eviction selection"
);
let by_key: std::collections::HashMap<&str, &crate::eviction::EntryFeatures> =
candidates.iter().map(|e| (e.key.as_str(), e)).collect();
Ok(self.apply_eviction(&order, &by_key, policy.name(), stop_at))
}
pub fn evict(&self) -> Result<GcStats> {
let target = self.config.max_size * 9 / 10; let size_before = self.total_size()?;
if size_before <= target {
return Ok(GcStats::default());
}
let mut stats = self.evict_with(
&crate::eviction::SizePressurePolicy,
Some((size_before, target)),
)?;
stats.blobs_removed = if size_before > self.total_size()? {
stats.entries_evicted } else {
0
};
Ok(stats)
}
pub fn evict_older_than(&self, hours: u64) -> Result<GcStats> {
let mut stats = self.evict_with(&crate::eviction::OlderThanPolicy { hours }, None)?;
stats.blobs_removed = stats.entries_evicted;
Ok(stats)
}
pub fn evict_duplicate_entries(&self) -> Result<GcStats> {
let mut stats = self.evict_with(&crate::eviction::DuplicatePolicy, None)?;
stats.blobs_removed = stats.entries_evicted;
Ok(stats)
}
pub fn sweep_orphan_blobs(&self, min_age: Duration) -> Result<OrphanSweepStats> {
let blobs_dir = self.config.store_dir().join("blobs");
if !blobs_dir.exists() {
return Ok(OrphanSweepStats::default());
}
let now = std::time::SystemTime::now();
let mut candidates: Vec<(String, PathBuf, u64)> = Vec::new();
let mut scanned = 0usize;
for shard in fs::read_dir(&blobs_dir)?.flatten() {
if !shard.path().is_dir() {
continue;
}
let Ok(files) = fs::read_dir(shard.path()) else {
continue;
};
for file in files.flatten() {
let path = file.path();
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
if !is_blob_hash_name(name) {
continue;
}
let Ok(meta) = file.metadata() else { continue };
if !meta.is_file() {
continue;
}
scanned += 1;
let old_enough = meta
.modified()
.ok()
.and_then(|m| now.duration_since(m).ok())
.map(|age| age >= min_age)
.unwrap_or(false);
if old_enough {
candidates.push((name.to_string(), path, meta.len()));
}
}
}
let mut stats = OrphanSweepStats {
scanned,
..Default::default()
};
if candidates.is_empty() {
return Ok(stats);
}
self.db.execute_batch("BEGIN IMMEDIATE")?;
let result = (|| -> Result<()> {
let referenced: std::collections::HashSet<String> = {
let mut stmt = self.db.prepare("SELECT hash FROM blobs")?;
stmt.query_map([], |row| row.get::<_, String>(0))?
.filter_map(|r| r.ok())
.collect()
};
for (hash, path, size) in &candidates {
if referenced.contains(hash) {
continue;
}
unlink_blob(path);
stats.removed += 1;
stats.bytes_reclaimed += *size;
}
Ok(())
})();
match result {
Ok(()) => {
self.db.execute_batch("COMMIT")?;
Ok(stats)
}
Err(e) => {
let _ = self.db.execute_batch("ROLLBACK");
Err(e)
}
}
}
pub fn backfill_content_hashes(&self) -> Result<usize> {
let keys: Vec<String> = {
let mut stmt = self.db.prepare(
"SELECT cache_key FROM entries WHERE content_hash IS NULL AND committed = 1",
)?;
stmt.query_map([], |row| row.get(0))?
.collect::<Result<Vec<_>, _>>()?
};
let mut updated = 0;
for key in &keys {
let meta_path = self.entry_dir(key).join("meta.json");
if let Ok(content) = fs::read_to_string(&meta_path)
&& let Ok(meta) = serde_json::from_str::<EntryMeta>(&content)
{
let content_hash = compute_content_hash(&meta.files);
self.db.execute(
"UPDATE entries SET content_hash = ?1 WHERE cache_key = ?2",
params![content_hash, key],
)?;
updated += 1;
}
}
Ok(updated)
}
pub fn backfill_compile_times(&self) -> Result<usize> {
self.backfill_compile_times_limited(COMPILE_TIME_BACKFILL_BATCH)
}
fn backfill_compile_times_limited(&self, limit: i64) -> Result<usize> {
let keys: Vec<String> = {
let mut stmt = self.db.prepare(
"SELECT cache_key FROM entries WHERE compile_time_ms = 0 AND committed = 1
LIMIT ?1",
)?;
stmt.query_map(params![limit], |row| row.get(0))?
.collect::<Result<Vec<_>, _>>()?
};
let mut updated = 0;
for key in &keys {
let meta_path = self.entry_dir(key).join("meta.json");
if let Ok(content) = fs::read_to_string(&meta_path)
&& let Ok(meta) = serde_json::from_str::<EntryMeta>(&content)
&& meta.compile_time_ms > 0
{
self.db.execute(
"UPDATE entries SET compile_time_ms = ?1 WHERE cache_key = ?2",
params![meta.compile_time_ms as i64, key],
)?;
updated += 1;
}
}
Ok(updated)
}
fn record_tombstone(&self, features: &crate::eviction::EntryFeatures, policy: &str) {
let result = self.db.execute(
"INSERT OR REPLACE INTO eviction_tombstones
(cache_key, evicted_at, policy, size, hit_count, idle_hours, compile_time_ms,
demanded_at)
VALUES (?1, datetime('now'), ?2, ?3, ?4, ?5, ?6, NULL)",
params![
features.key,
policy,
features.size,
features.hit_count,
features.idle_hours,
features.compile_time_ms,
],
);
if let Err(e) = result {
tracing::debug!("gc: could not record tombstone: {e}");
}
}
fn note_tombstone_demand(&self, cache_key: &str) {
let pending: Result<i64, _> = self.db.query_row(
"SELECT EXISTS(SELECT 1 FROM eviction_tombstones
WHERE cache_key = ?1 AND demanded_at IS NULL)",
params![cache_key],
|row| row.get(0),
);
if !matches!(pending, Ok(1)) {
return;
}
let updated = self.db.execute(
"UPDATE eviction_tombstones SET demanded_at = datetime('now')
WHERE cache_key = ?1 AND demanded_at IS NULL",
params![cache_key],
);
match updated {
Ok(_) => tracing::debug!(
cache_key = &cache_key[..16.min(cache_key.len())],
"gc: evicted entry was demanded again"
),
Err(e) => tracing::debug!("gc: could not record tombstone demand: {e}"),
}
}
pub fn prune_tombstones(&self, keep_days: u64) -> Result<usize> {
let removed = self.db.execute(
"DELETE FROM eviction_tombstones WHERE evicted_at < datetime('now', ?1)",
params![format!("-{keep_days} days")],
)?;
Ok(removed)
}
pub fn tombstone_stats(&self) -> Result<(usize, usize)> {
let row = self.db.query_row(
"SELECT COUNT(*), COUNT(demanded_at) FROM eviction_tombstones",
[],
|row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)),
)?;
Ok((row.0.max(0) as usize, row.1.max(0) as usize))
}
pub fn remove_entry(&self, cache_key: &str) -> Result<()> {
self.remove_entry_guarded(cache_key, None).map(|_| ())
}
fn remove_entry_guarded(
&self,
cache_key: &str,
skip_if_idle_lt: Option<Duration>,
) -> Result<bool> {
let entry_dir = self.entry_dir(cache_key);
let meta_path = entry_dir.join("meta.json");
let hashes: Vec<String> = match fs::read_to_string(&meta_path) {
Ok(content) => {
let meta: EntryMeta = serde_json::from_str(&content).with_context(|| {
format!(
"entry {cache_key}: meta.json unparseable — refusing removal so blob \
refcounts are not leaked (#276)"
)
})?;
meta.files.iter().map(|f| f.hash.clone()).collect()
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
let row_exists: i64 = self.db.query_row(
"SELECT EXISTS(SELECT 1 FROM entries WHERE cache_key = ?1)",
params![cache_key],
|row| row.get(0),
)?;
if row_exists != 0 {
anyhow::bail!(
"entry {cache_key}: meta.json missing but DB row present — refusing \
removal so blob refcounts are not leaked (#276)"
);
}
return Ok(false);
}
Err(e) => {
return Err(e).with_context(|| {
format!(
"entry {cache_key}: reading meta.json — refusing removal so blob \
refcounts are not leaked (#276)"
)
});
}
};
let rows_affected = {
let tx = self.db.unchecked_transaction()?;
if let Some(grace) = skip_if_idle_lt {
let recently_accessed: i64 = tx.query_row(
"SELECT EXISTS(SELECT 1 FROM entries \
WHERE cache_key = ?1 AND last_accessed >= datetime('now', ?2))",
params![cache_key, format!("-{} seconds", grace.as_secs())],
|row| row.get(0),
)?;
if recently_accessed != 0 {
return Ok(false);
}
}
let rows_affected = tx.execute(
"DELETE FROM entries WHERE cache_key = ?1",
params![cache_key],
)?;
if rows_affected > 0 {
for hash in &hashes {
tx.execute(
"UPDATE blobs SET refcount = refcount - 1 WHERE hash = ?1",
params![hash],
)?;
let refcount: Option<i64> = tx
.query_row(
"SELECT refcount FROM blobs WHERE hash = ?1",
params![hash],
|row| row.get(0),
)
.ok();
if matches!(refcount, Some(rc) if rc <= 0) {
tx.execute("DELETE FROM blobs WHERE hash = ?1", params![hash])?;
unlink_blob(&self.blob_path(hash));
}
}
}
tx.commit()?;
rows_affected
};
if entry_dir.exists() {
if let Ok(entries) = fs::read_dir(&entry_dir) {
for entry in entries.flatten() {
let path = entry.path();
if let Ok(meta) = fs::metadata(&path) {
let mut perms = meta.permissions();
perms.set_readonly(false);
let _ = fs::set_permissions(&path, perms);
}
}
}
fs::remove_dir_all(&entry_dir)?;
}
Ok(rows_affected > 0 || !hashes.is_empty())
}
#[cfg(test)]
pub(crate) fn set_last_accessed_for_test(&self, cache_key: &str, sql_modifier: &str) {
self.db
.execute(
"UPDATE entries SET last_accessed = datetime('now', ?2) WHERE cache_key = ?1",
params![cache_key, sql_modifier],
)
.unwrap();
}
pub fn clear(&self) -> Result<()> {
let store_dir = self.config.store_dir();
if store_dir.exists() {
for entry in fs::read_dir(&store_dir)?.flatten() {
let path = entry.path();
if path.is_dir() {
Self::make_writable_recursive(&path);
let _ = fs::remove_dir_all(&path);
}
}
}
self.db.execute("DELETE FROM entries", [])?;
self.db.execute("DELETE FROM blobs", [])?;
self.db.execute("DELETE FROM incremental_dirs", [])?;
Ok(())
}
fn make_writable_recursive(dir: &Path) {
if let Ok(entries) = fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
Self::make_writable_recursive(&path);
} else if let Ok(meta) = fs::metadata(&path) {
let mut perms = meta.permissions();
perms.set_readonly(false);
let _ = fs::set_permissions(&path, perms);
}
}
}
}
pub fn list_entries(&self, sort_by: &str) -> Result<Vec<EntryInfo>> {
let order_clause = match sort_by {
"size" => "size DESC",
"hits" => "hit_count DESC",
"age" => "created_at ASC",
_ => "crate_name ASC",
};
let mut stmt = self.db.prepare(&format!(
"SELECT cache_key, crate_name, crate_type, profile, size, created_at, last_accessed, hit_count, content_hash FROM entries WHERE committed = 1 ORDER BY {order_clause}"
))?;
let entries = stmt
.query_map([], |row| {
Ok(EntryInfo {
cache_key: row.get(0)?,
crate_name: row.get(1)?,
crate_type: row.get(2)?,
profile: row.get(3)?,
size: row.get::<_, i64>(4)? as u64,
created_at: row.get(5)?,
last_accessed: row.get(6)?,
hit_count: row.get::<_, i64>(7)? as u64,
content_hash: row.get(8)?,
})
})?
.collect::<Result<Vec<_>, _>>()?;
Ok(entries)
}
fn migrate_entry_to_blobs(&self, meta: &EntryMeta) -> Result<()> {
let entry_dir = self.entry_dir(&meta.cache_key);
for cached_file in &meta.files {
let artifact_path = entry_dir.join(&cached_file.name);
if !artifact_path.exists() {
continue; }
let blob = self.blob_path(&cached_file.hash);
let blob_dir = blob.parent().unwrap();
fs::create_dir_all(blob_dir)?;
let existing: Option<i64> = self
.db
.query_row(
"SELECT refcount FROM blobs WHERE hash = ?1",
params![cached_file.hash],
|row| row.get(0),
)
.ok();
if existing.is_some() {
if let Ok(m) = fs::metadata(&artifact_path) {
let mut perms = m.permissions();
perms.set_readonly(false);
let _ = fs::set_permissions(&artifact_path, perms);
}
fs::remove_file(&artifact_path)?;
self.db.execute(
"UPDATE blobs SET refcount = refcount + 1 WHERE hash = ?1",
params![cached_file.hash],
)?;
} else {
if let Ok(m) = fs::metadata(&artifact_path) {
let mut perms = m.permissions();
if !perms.readonly() {
perms.set_readonly(true);
fs::set_permissions(&artifact_path, perms)?;
}
}
fs::rename(&artifact_path, &blob)?;
self.db.execute(
"INSERT OR IGNORE INTO blobs (hash, size, refcount) VALUES (?1, ?2, 1)",
params![cached_file.hash, cached_file.size as i64],
)?;
if self.db.changes() == 0 {
self.db.execute(
"UPDATE blobs SET refcount = refcount + 1 WHERE hash = ?1",
params![cached_file.hash],
)?;
}
}
}
Ok(())
}
pub fn migrate_to_blobs(&self, progress: impl Fn(usize, usize)) -> Result<MigrationStats> {
let store_dir = self.config.store_dir();
let mut stats = MigrationStats::default();
let mut entry_dirs = Vec::new();
if let Ok(entries) = fs::read_dir(&store_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() && path.file_name().is_some_and(|n| n != "blobs") {
let meta_path = path.join("meta.json");
if meta_path.exists() {
let has_artifacts = fs::read_dir(&path)
.into_iter()
.flatten()
.flatten()
.any(|e| e.file_name() != "meta.json");
if has_artifacts {
entry_dirs.push(path);
}
}
}
}
}
let total = entry_dirs.len();
for (i, entry_dir) in entry_dirs.iter().enumerate() {
progress(i, total);
stats.entries_scanned += 1;
let meta_path = entry_dir.join("meta.json");
let content = match fs::read_to_string(&meta_path) {
Ok(c) => c,
Err(_) => {
stats.entries_skipped += 1;
continue;
}
};
let meta: EntryMeta = match serde_json::from_str(&content) {
Ok(m) => m,
Err(_) => {
stats.entries_skipped += 1;
continue;
}
};
match self.migrate_entry_to_blobs(&meta) {
Ok(()) => stats.entries_migrated += 1,
Err(_) => stats.entries_skipped += 1,
}
}
progress(total, total);
Ok(stats)
}
fn is_lock_stale(&self, lock_path: &Path) -> Result<bool> {
let content = fs::read_to_string(lock_path).unwrap_or_default();
if let Ok(pid) = content.trim().parse::<u32>() {
if !crate::platform::is_process_alive(pid) {
return Ok(true); }
if let Ok(meta) = fs::metadata(lock_path)
&& let Ok(age) = meta.modified()?.elapsed()
&& age > std::time::Duration::from_secs(3600)
{
return Ok(true);
}
Ok(false)
} else {
Ok(true) }
}
pub fn blob_stats(&self) -> Result<BlobStats> {
let total_blobs: i64 = self
.db
.query_row("SELECT COUNT(*) FROM blobs", [], |row| row.get(0))?;
let total_blob_size: i64 =
self.db
.query_row("SELECT COALESCE(SUM(size), 0) FROM blobs", [], |row| {
row.get(0)
})?;
let total_logical_size: i64 =
self.db
.query_row("SELECT COALESCE(SUM(size), 0) FROM entries", [], |row| {
row.get(0)
})?;
Ok(BlobStats {
total_blobs: total_blobs as usize,
total_blob_size: total_blob_size as u64,
total_logical_size: total_logical_size as u64,
savings: (total_logical_size as u64).saturating_sub(total_blob_size as u64),
})
}
}
#[derive(Debug, Default)]
pub struct BlobStats {
pub total_blobs: usize,
pub total_blob_size: u64,
pub total_logical_size: u64,
pub savings: u64,
}
#[derive(Debug, Default)]
#[allow(dead_code)]
pub struct MigrationStats {
pub entries_scanned: usize,
pub entries_migrated: usize,
pub entries_skipped: usize,
pub blobs_created: usize,
pub blobs_reused: usize,
pub bytes_saved: u64,
}
#[derive(Debug, Clone)]
pub struct EntryInfo {
pub cache_key: String,
pub crate_name: String,
pub crate_type: String,
pub profile: String,
pub size: u64,
pub created_at: String,
pub last_accessed: String,
pub hit_count: u64,
pub content_hash: Option<String>,
}
#[cfg(test)]
mod tests {
#[test]
fn test_positive_or_none_treats_non_positive_as_unknown() {
assert_eq!(positive_or_none(0), None, "0 is unknown, not Some(0)");
assert_eq!(positive_or_none(-1), None, "a negative is unknown");
assert_eq!(
positive_or_none(1),
Some(1),
"the smallest real value survives"
);
assert_eq!(positive_or_none(4200), Some(4200));
assert_eq!(positive_or_none(i64::MAX), Some(i64::MAX as u64));
}
use super::*;
use crate::eviction::EvictionPolicy as _;
#[test]
fn content_hash_distinguishes_transposition_exec_bit_and_is_order_independent() {
let cf = |name: &str, hash: &str, executable: bool| CachedFile {
name: name.to_string(),
size: 10,
hash: hash.to_string(),
executable,
};
let a = vec![cf("a.rlib", "H1", false), cf("b.rlib", "H2", false)];
let swapped = vec![cf("a.rlib", "H2", false), cf("b.rlib", "H1", false)];
assert_ne!(
compute_content_hash(&a),
compute_content_hash(&swapped),
"a name<->hash transposition must change the content hash"
);
let exec_a = vec![cf("a.rlib", "H1", true), cf("b.rlib", "H2", false)];
let exec_b = vec![cf("a.rlib", "H1", false), cf("b.rlib", "H2", true)];
assert_ne!(
compute_content_hash(&exec_a),
compute_content_hash(&exec_b),
"moving the exec-bit to a different file must change the content hash"
);
let reordered = vec![cf("b.rlib", "H2", false), cf("a.rlib", "H1", false)];
assert_eq!(
compute_content_hash(&a),
compute_content_hash(&reordered),
"content hash must not depend on file order"
);
}
#[test]
fn hardlink_eligibility_mirrors_restore_strategy_with_insert_exclusions() {
let gate_open = !cfg!(windows);
for name in [
"libserde-abc123.rlib",
"libserde-abc123.rmeta",
"foo.rcgu.o",
"foo.obj",
"foo.dwo",
] {
assert_eq!(
hardlink_eligible(name, false),
gate_open,
"{name} should be hardlink-eligible on insert (behind the Windows gate)"
);
}
assert!(!hardlink_eligible("libfoo.dylib", false));
assert!(!hardlink_eligible("libfoo.so", false));
assert!(!hardlink_eligible("foo.exe", false));
assert!(!hardlink_eligible("serde-abc123.d", false));
assert!(!hardlink_eligible("my-binary", false));
assert!(!hardlink_eligible("libserde-abc123.rlib", true));
}
#[cfg(unix)]
#[test]
fn put_keeps_mutable_kind_blobs_inode_independent_from_the_source() {
use std::os::unix::fs::MetadataExt;
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let store = Store::open(&config).unwrap();
let source = dir.path().join("libfoo.dylib");
fs::write(&source, b"dylib bytes").unwrap();
store
.put(
"key-dylib",
"foo",
&["dylib".to_string()],
&[],
"host",
"dev",
&[(source.clone(), "libfoo.dylib".to_string())],
"",
"",
)
.unwrap();
let hash = crate::cache_key::hash_file(&source).unwrap();
let blob = store.blob_path(&hash);
assert_ne!(
fs::metadata(&blob).unwrap().ino(),
fs::metadata(&source).unwrap().ino(),
"a mutable-kind blob must not share an inode with the build output"
);
}
#[cfg(unix)]
#[test]
fn put_ingests_immutable_kinds_zero_copy_where_the_filesystem_allows() {
use std::os::unix::fs::MetadataExt;
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let store = Store::open(&config).unwrap();
let source = dir.path().join("libfoo-abc.rlib");
fs::write(&source, b"rlib bytes").unwrap();
store
.put(
"key-rlib",
"foo",
&["rlib".to_string()],
&[],
"host",
"dev",
&[(source.clone(), "libfoo-abc.rlib".to_string())],
"",
"",
)
.unwrap();
let hash = crate::cache_key::hash_file(&source).unwrap();
let blob = store.blob_path(&hash);
assert_eq!(fs::read(&blob).unwrap(), b"rlib bytes");
assert!(
fs::metadata(&blob).unwrap().permissions().readonly(),
"store blob must be read-only"
);
if fs::metadata(&blob).unwrap().ino() == fs::metadata(&source).unwrap().ino() {
assert!(
fs::metadata(&source).unwrap().permissions().readonly(),
"a hardlinked source must carry the blob's read-only mode"
);
}
}
#[cfg(unix)]
#[test]
fn put_never_stores_a_symlink_as_a_blob() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let store = Store::open(&config).unwrap();
let target = dir.path().join("real-artifact.rlib");
fs::write(&target, b"real artifact bytes").unwrap();
let symlink = dir.path().join("linked.rlib");
std::os::unix::fs::symlink(&target, &symlink).unwrap();
store
.put(
"key-symlink",
"foo",
&["rlib".to_string()],
&[],
"host",
"dev",
&[(symlink.clone(), "linked.rlib".to_string())],
"",
"",
)
.unwrap();
let hash = crate::cache_key::hash_file(&symlink).unwrap();
let blob = store.blob_path(&hash);
let meta = fs::symlink_metadata(&blob).unwrap();
assert!(
meta.file_type().is_file(),
"blob must be a regular file, not a symlink"
);
assert_eq!(fs::read(&blob).unwrap(), b"real artifact bytes");
}
#[test]
fn materialize_blob_errors_when_source_cannot_be_copied() {
let dir = tempfile::tempdir().unwrap();
let hash = "a".repeat(64);
let source = dir.path().join("missing.rlib");
let blob = dir.path().join("blobs").join("aa").join(&hash);
let err = materialize_blob(&source, &blob, false).unwrap_err();
assert!(
err.to_string().contains("copying"),
"expected copy context, got: {err:#}"
);
assert!(!blob.exists());
}
#[test]
fn materialize_blob_removes_tmp_when_atomic_rename_fails() {
let dir = tempfile::tempdir().unwrap();
let hash = "b".repeat(64);
let source = dir.path().join("source.rlib");
fs::write(&source, b"blob bytes").unwrap();
let blob = dir.path().join("blobs").join("bb").join(&hash);
fs::create_dir_all(&blob).unwrap();
let err = materialize_blob(&source, &blob, false).unwrap_err();
assert!(
err.to_string().contains("atomic rename"),
"expected rename context, got: {err:#}"
);
let tmp_left = fs::read_dir(blob.parent().unwrap())
.unwrap()
.flatten()
.filter(|entry| entry.file_name().to_string_lossy().ends_with(".tmp"))
.count();
assert_eq!(tmp_left, 0, "failed rename must remove its temp file");
assert!(blob.is_dir(), "the conflicting destination dir remains");
}
#[test]
fn materialize_blob_failure_does_not_leave_source_readonly() {
let dir = tempfile::tempdir().unwrap();
let hash = "c".repeat(64);
let source = dir.path().join("source.rlib");
fs::write(&source, b"blob bytes").unwrap();
let blob = dir.path().join("blobs").join("cc").join(&hash);
fs::create_dir_all(&blob).unwrap();
let err = materialize_blob(&source, &blob, true).unwrap_err();
assert!(
err.to_string().contains("atomic rename"),
"expected rename context, got: {err:#}"
);
assert!(
!fs::metadata(&source).unwrap().permissions().readonly(),
"failed hardlink ingest must restore a writable build output"
);
}
#[test]
fn probe_entry_readonly_hit_miss_fallback() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let store = Store::open(&config).unwrap();
let output_file = dir.path().join("out.rlib");
fs::write(&output_file, b"artifact-bytes").unwrap();
store
.put(
"probe_key",
"probe_crate",
&["lib".to_string()],
&[],
"x86_64-unknown-linux-gnu",
"dev",
&[(output_file, "libout.rlib".to_string())],
"out",
"err",
)
.unwrap();
let ro = open_index_db_readonly(&config.index_db_path()).unwrap();
let store_dir = config.store_dir();
let meta = match probe_entry_readonly(&ro, &store_dir, "probe_key") {
ProbeOutcome::Hit(meta) => meta,
other => panic!("expected hit, got {other:?}"),
};
assert_eq!(meta.cache_key, "probe_key");
assert_eq!(meta.stdout, "out");
assert_eq!(meta.files.len(), 1);
let hits: i64 = store
.db
.query_row(
"SELECT hit_count FROM entries WHERE cache_key = 'probe_key'",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(
hits, 0,
"a probe must not record a hit — the pin writer does"
);
assert!(matches!(
probe_entry_readonly(&ro, &store_dir, "no_such_key"),
ProbeOutcome::Miss
));
let blob = store.blob_path(&meta.files[0].hash);
fs::remove_file(&blob).unwrap();
assert!(matches!(
probe_entry_readonly(&ro, &store_dir, "probe_key"),
ProbeOutcome::Fallback(_)
));
}
#[test]
fn probe_connection_refuses_writes() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let _store = Store::open(&config).unwrap();
let ro = open_index_db_readonly(&config.index_db_path()).unwrap();
assert!(
ro.execute("DELETE FROM entries", []).is_err(),
"read-only probe connection must reject writes"
);
}
fn test_config(dir: &Path) -> Config {
Config {
fallback: None,
key_salt: None,
cc_extra_allowlist_flags: Vec::new(),
local_only: false,
remote_readonly: false,
modified_input_guard: false,
local_hit_daemon: false,
windows_hardlink: false,
auto_gc: true,
storage_layout_advice: true,
heartbeat_secs: 30,
explain_miss: false,
path_only_env_vars: Vec::new(),
key_env_vars: Vec::new(),
base_dirs: Vec::new(),
cache_dir: dir.to_path_buf(),
max_size: 1024 * 1024, remote: None,
remote_error: None,
disabled: false,
cache_executables: false,
clean_incremental: true,
event_log_max_size: 1024 * 1024,
event_log_keep_lines: 100,
compression_level: 3,
s3_concurrency: 16,
prefetch_enabled: crate::config::DEFAULT_PREFETCH_ENABLED,
remote_key_cache_refresh_secs: crate::config::DEFAULT_REMOTE_KEY_CACHE_REFRESH_SECS,
prefetch_max_keys: crate::config::DEFAULT_PREFETCH_MAX_KEYS,
prefetch_max_bytes: crate::config::DEFAULT_PREFETCH_MAX_BYTES,
prefetch_deadline_secs: crate::config::DEFAULT_PREFETCH_DEADLINE_SECS,
daemon_idle_timeout_secs: crate::config::DEFAULT_DAEMON_IDLE_TIMEOUT_SECS,
s3_pool_idle_secs: crate::config::DEFAULT_S3_POOL_IDLE_SECS,
}
}
struct EnvVarGuard {
key: &'static str,
previous: Option<std::ffi::OsString>,
}
static ENV_VAR_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
impl EnvVarGuard {
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 }
}
fn remove(key: &'static str) -> Self {
let previous = std::env::var_os(key);
unsafe { std::env::remove_var(key) };
Self { key, previous }
}
}
impl Drop for EnvVarGuard {
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) },
}
}
}
#[test]
fn cap_diagnostics_is_lossless_by_default_and_truncates_when_capped() {
let warnings = "warning: unused variable `x`\nwarning: dead code\n";
assert_eq!(cap_diagnostics(warnings, None), warnings);
assert_eq!(cap_diagnostics(warnings, Some(10_000)), warnings);
let capped = cap_diagnostics(warnings, Some(20));
assert!(capped.starts_with("warning: unused vari"));
assert!(capped.contains("diagnostics truncated"));
assert!(capped.len() < warnings.len() + 80);
let unicode = "wörning: ".repeat(20);
let capped = cap_diagnostics(&unicode, Some(5));
assert!(std::str::from_utf8(capped.as_bytes()).is_ok());
}
#[test]
fn test_store_put_and_get() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let store = Store::open(&config).unwrap();
let output_file = dir.path().join("output.rlib");
std::fs::write(&output_file, b"fake rlib content").unwrap();
store
.put(
"abc123",
"mylib",
&["lib".to_string()],
&["std".to_string()],
"x86_64-unknown-linux-gnu",
"dev",
&[(output_file, "libmylib.rlib".to_string())],
"",
"",
)
.unwrap();
assert!(store.contains("abc123"));
let meta = store.get("abc123").unwrap().unwrap();
assert_eq!(meta.crate_name, "mylib");
assert_eq!(meta.files.len(), 1);
assert_eq!(meta.files[0].name, "libmylib.rlib");
}
#[test]
fn sweep_orphan_blobs_removes_unreferenced_files_only() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let store = Store::open(&config).unwrap();
let output_file = dir.path().join("output.rlib");
std::fs::write(&output_file, b"real rlib content").unwrap();
store
.put(
"abc123",
"mylib",
&["lib".to_string()],
&["std".to_string()],
"x86_64-unknown-linux-gnu",
"dev",
&[(output_file, "libmylib.rlib".to_string())],
"",
"",
)
.unwrap();
let orphan_hash = "f".repeat(64);
let orphan_path = store.blob_path(&orphan_hash);
std::fs::create_dir_all(orphan_path.parent().unwrap()).unwrap();
std::fs::write(&orphan_path, b"orphaned bytes").unwrap();
let tmp_path = orphan_path.with_file_name(format!(".{orphan_hash}.123.0.tmp"));
std::fs::write(&tmp_path, b"in-progress").unwrap();
let stats = store.sweep_orphan_blobs(std::time::Duration::ZERO).unwrap();
assert_eq!(stats.removed, 1, "only the orphan should be removed");
assert_eq!(stats.scanned, 2);
assert_eq!(stats.bytes_reclaimed, b"orphaned bytes".len() as u64);
assert!(!orphan_path.exists(), "orphan blob must be unlinked");
assert!(tmp_path.exists(), "in-progress .tmp must be left alone");
assert!(store.get("abc123").unwrap().is_some());
}
#[test]
fn sweep_orphan_blobs_respects_min_age() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let store = Store::open(&config).unwrap();
let orphan_hash = "a".repeat(64);
let orphan_path = store.blob_path(&orphan_hash);
std::fs::create_dir_all(orphan_path.parent().unwrap()).unwrap();
std::fs::write(&orphan_path, b"fresh orphan").unwrap();
let stats = store
.sweep_orphan_blobs(std::time::Duration::from_secs(3600))
.unwrap();
assert_eq!(stats.removed, 0);
assert!(orphan_path.exists());
}
#[test]
fn store_ingest_accounts_new_blob_bytes_by_mechanism() {
let cache_dir = tempfile::tempdir().unwrap();
let config = test_config(cache_dir.path());
let store = Store::open(&config).unwrap();
let payload = b"store-ingest-accounting-unique-artifact-bytes-0xC0FFEE".repeat(64);
let output_file = cache_dir.path().join("output.rlib");
std::fs::write(&output_file, &payload).unwrap();
let before = crate::opcounts::store_reflinked_bytes()
+ crate::opcounts::store_hardlinked_bytes()
+ crate::opcounts::store_copied_bytes();
let put_result = store
.put(
"ingest_key",
"ingestlib",
&["lib".to_string()],
&[],
"host",
"dev",
&[(output_file, "libingest.rlib".to_string())],
"",
"",
)
.unwrap();
assert_eq!(put_result.new_blobs, 1, "expected a genuinely new blob");
let after = crate::opcounts::store_reflinked_bytes()
+ crate::opcounts::store_hardlinked_bytes()
+ crate::opcounts::store_copied_bytes();
assert!(
after >= before + payload.len() as u64,
"store ingest must account the new blob's bytes (delta {} < {})",
after - before,
payload.len()
);
}
#[test]
fn test_store_put_reports_full_dup_for_existing_blob() {
let cache_dir = tempfile::tempdir().unwrap();
let config = test_config(cache_dir.path());
let store = Store::open(&config).unwrap();
let output_file = cache_dir.path().join("output.rlib");
std::fs::write(&output_file, b"fake rlib content").unwrap();
let put_result = store
.put(
"first_key",
"mylib",
&["lib".to_string()],
&[],
"host",
"dev",
&[(output_file.clone(), "libmylib.rlib".to_string())],
"",
"",
)
.unwrap();
assert_eq!(put_result.output_blobs, 1);
assert_eq!(put_result.duplicate_blobs, 0);
assert_eq!(put_result.new_blobs, 1);
assert!(!put_result.is_full_dup());
let meta = store.get("first_key").unwrap().unwrap();
let hash = meta.files[0].hash.clone();
assert!(store.blob_path(&hash).is_file());
let duplicate_output = cache_dir.path().join("duplicate-output.rlib");
std::fs::write(&duplicate_output, b"fake rlib content").unwrap();
let second_put = store
.put(
"second_key",
"mylib",
&["lib".to_string()],
&[],
"host",
"dev",
&[(duplicate_output, "libmylib.rlib".to_string())],
"",
"",
)
.unwrap();
assert_eq!(second_put.output_blobs, 1);
assert_eq!(second_put.duplicate_blobs, 1);
assert_eq!(second_put.new_blobs, 0);
assert!(second_put.is_full_dup());
store.remove_entry("first_key").unwrap();
assert!(store.blob_path(&hash).exists());
store.remove_entry("second_key").unwrap();
assert!(!store.blob_path(&hash).exists());
}
#[test]
fn test_retryable_sqlite_open_error_for_missing_parent() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("missing").join("index.db");
let err = open_index_db(&db_path).unwrap_err();
let sql_err = err.downcast_ref::<SqlError>().unwrap();
assert!(is_retryable_sqlite_open_error(sql_err));
}
#[test]
fn is_corruption_error_flags_a_non_sqlite_file() {
let dir = tempfile::tempdir().unwrap();
let garbage = dir.path().join("garbage.db");
fs::write(&garbage, b"definitely not a sqlite database").unwrap();
let err = try_open_index_db(&garbage).unwrap_err();
assert!(
is_corruption_error(&err),
"a non-sqlite file must classify as corruption: {err}"
);
let missing = dir.path().join("missing").join("index.db");
let err = try_open_index_db(&missing).unwrap_err();
assert!(!is_corruption_error(&err));
}
fn key(seed: u8) -> String {
blake3::hash(&[seed]).to_hex().to_string()
}
fn put_entry(store: &Store, dir: &Path, seed: u8, crate_name: &str, content: &[u8]) -> String {
let k = key(seed);
let src = dir.join(format!("out-{seed}.rlib"));
std::fs::write(&src, content).unwrap();
store
.put(
&k,
crate_name,
&["lib".to_string()],
&["std".to_string()],
"x86_64-unknown-linux-gnu",
"dev",
&[(src, format!("lib{crate_name}.rlib"))],
"",
"",
)
.unwrap();
k
}
#[test]
fn rebuild_index_from_store_recovers_entries_after_the_index_is_lost() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let (k1, k2) = {
let store = Store::open(&config).unwrap();
let k1 = put_entry(&store, dir.path(), 1, "alpha", b"alpha rlib content");
let k2 = put_entry(&store, dir.path(), 2, "beta", b"beta rlib content");
(k1, k2)
};
std::fs::remove_file(config.index_db_path()).unwrap();
let store = Store::open(&config).unwrap();
assert_eq!(
store.entry_count().unwrap(),
0,
"a fresh index starts with no rows"
);
let stats = store.rebuild_index_from_store().unwrap();
assert_eq!(
stats.entries_rebuilt, 2,
"both entries are adopted: {stats:?}"
);
assert_eq!(stats.blobs_registered, 2);
for k in [&k1, &k2] {
assert!(store.contains(k), "entry {k} must be usable after rebuild");
let meta = store.get(k).unwrap().unwrap();
assert_eq!(meta.files.len(), 1);
}
assert_eq!(store.entry_count().unwrap(), 2);
}
#[test]
fn rebuild_index_is_idempotent_and_does_not_inflate_refcounts() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let store = Store::open(&config).unwrap();
let k = put_entry(&store, dir.path(), 3, "gamma", b"gamma rlib content");
let hash: String = store
.db
.query_row("SELECT hash FROM blobs", [], |r| r.get(0))
.unwrap();
let refcount_before: i64 = store
.db
.query_row(
"SELECT refcount FROM blobs WHERE hash = ?1",
params![hash],
|r| r.get(0),
)
.unwrap();
for _ in 0..3 {
let stats = store.rebuild_index_from_store().unwrap();
assert_eq!(
stats.entries_rebuilt, 0,
"an already-registered entry is not re-adopted"
);
}
let refcount_after: i64 = store
.db
.query_row(
"SELECT refcount FROM blobs WHERE hash = ?1",
params![hash],
|r| r.get(0),
)
.unwrap();
assert_eq!(
refcount_after, refcount_before,
"repeated rebuilds must not inflate refcounts"
);
store.remove_entry(&k).unwrap();
let remaining: i64 = store
.db
.query_row(
"SELECT COUNT(*) FROM blobs WHERE hash = ?1",
params![hash],
|r| r.get(0),
)
.unwrap();
assert_eq!(
remaining, 0,
"blob must be reclaimed on removal, not stranded by an inflated refcount"
);
}
#[test]
fn rebuild_index_skips_entries_whose_blobs_are_missing_or_wrong_size() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let (good, gone, truncated) = {
let store = Store::open(&config).unwrap();
let good = put_entry(&store, dir.path(), 4, "good", b"good content here");
let gone = put_entry(&store, dir.path(), 5, "gone", b"vanishing content");
let truncated = put_entry(&store, dir.path(), 6, "trunc", b"truncated content");
(good, gone, truncated)
};
let meta_of = |k: &str| -> EntryMeta {
let p = config.store_dir().join(k).join("meta.json");
serde_json::from_str(&std::fs::read_to_string(p).unwrap()).unwrap()
};
let gone_hash = meta_of(&gone).files[0].hash.clone();
let trunc_hash = meta_of(&truncated).files[0].hash.clone();
let blob_of = |h: &str| blob_path_in_store_dir(&config.store_dir(), h);
let gone_blob = blob_of(&gone_hash);
let trunc_blob = blob_of(&trunc_hash);
let make_writable = |p: &Path| {
let mut perms = std::fs::metadata(p).unwrap().permissions();
#[allow(clippy::permissions_set_readonly_false)]
perms.set_readonly(false);
std::fs::set_permissions(p, perms).unwrap();
};
make_writable(&gone_blob);
make_writable(&trunc_blob);
std::fs::remove_file(&gone_blob).unwrap();
std::fs::write(&trunc_blob, b"short").unwrap();
std::fs::remove_file(config.index_db_path()).unwrap();
let store = Store::open(&config).unwrap();
let stats = store.rebuild_index_from_store().unwrap();
assert_eq!(stats.entries_rebuilt, 1, "only the intact entry: {stats:?}");
assert_eq!(stats.entries_skipped, 2);
assert!(store.contains(&good));
assert!(
!store.contains(&gone),
"an entry with a missing blob must not be registered"
);
assert!(
!store.contains(&truncated),
"an entry with a wrong-sized blob must not be registered"
);
}
#[test]
fn rebuild_index_ignores_the_blobs_dir_and_foreign_names() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
{
let store = Store::open(&config).unwrap();
put_entry(&store, dir.path(), 7, "delta", b"delta rlib content");
}
std::fs::create_dir_all(config.store_dir().join("not-a-cache-key")).unwrap();
std::fs::create_dir_all(config.store_dir().join("0123456789")).unwrap();
std::fs::remove_file(config.index_db_path()).unwrap();
let store = Store::open(&config).unwrap();
let stats = store.rebuild_index_from_store().unwrap();
assert_eq!(
stats.entries_rebuilt, 1,
"only the real entry dir is adopted: {stats:?}"
);
}
#[test]
fn store_open_rebuilds_automatically_after_quarantining_a_corrupt_index() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let k = {
let store = Store::open(&config).unwrap();
put_entry(&store, dir.path(), 8, "epsilon", b"epsilon rlib content")
};
std::fs::write(config.index_db_path(), b"not a sqlite database at all").unwrap();
for ext in ["-wal", "-shm"] {
let p = index_sidecar_path(&config.index_db_path(), ext);
let _ = std::fs::remove_file(p);
}
let store = Store::open(&config).expect("corrupt index must self-heal");
assert!(
store.contains(&k),
"the entry must be recovered by Store::open, not lost to an empty index"
);
assert_eq!(store.entry_count().unwrap(), 1);
}
#[test]
fn open_index_db_self_heals_a_corrupt_index() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("index.db");
fs::write(
&db_path,
b"this is not a sqlite database; it is garbage bytes",
)
.unwrap();
let db = open_index_db(&db_path).expect("a corrupt index must self-heal, not brick");
let count: i64 = db
.query_row("SELECT COUNT(*) FROM entries", [], |r| r.get(0))
.expect("recreated index must be queryable");
assert_eq!(count, 0, "the recreated index starts empty");
assert!(db_path.is_file(), "a fresh index.db is recreated in place");
let quarantined: Vec<_> = fs::read_dir(dir.path())
.unwrap()
.flatten()
.filter(|e| e.file_name().to_string_lossy().contains(".corrupt-"))
.collect();
assert_eq!(
quarantined.len(),
1,
"the corrupt index is quarantined (kept for forensics), not silently deleted"
);
}
#[test]
fn quarantine_corrupt_index_moves_wal_and_shm_sidecars() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("index.db");
fs::write(&db_path, b"corrupt").unwrap();
fs::write(dir.path().join("index.db-wal"), b"wal").unwrap();
fs::write(dir.path().join("index.db-shm"), b"shm").unwrap();
let quarantined = quarantine_corrupt_index(&db_path).unwrap();
assert!(quarantined.is_file());
assert!(!db_path.exists(), "the corrupt db is moved aside");
assert!(
!dir.path().join("index.db-wal").exists(),
"the -wal sidecar is moved aside"
);
assert!(
!dir.path().join("index.db-shm").exists(),
"the -shm sidecar is moved aside"
);
assert!(index_sidecar_path(&quarantined, "-wal").exists());
assert!(index_sidecar_path(&quarantined, "-shm").exists());
}
#[test]
fn recover_corrupt_index_reuses_a_peer_healed_db_without_requarantine() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("index.db");
let garbage = dir.path().join("garbage.db");
fs::write(&garbage, b"not a sqlite database").unwrap();
let err = try_open_index_db(&garbage).unwrap_err();
drop(try_open_index_db(&db_path).unwrap());
let (db, recovered) = recover_corrupt_index(&db_path, &err).unwrap();
let count: i64 = db
.query_row("SELECT COUNT(*) FROM entries", [], |r| r.get(0))
.unwrap();
assert_eq!(count, 0);
assert!(
!recovered,
"adopting a peer's healed DB must not claim the rebuild: the peer that \
quarantined it owns that, and two processes rebuilding at once would \
double-count blob refcounts"
);
let quarantined = fs::read_dir(dir.path())
.unwrap()
.flatten()
.filter(|e| e.file_name().to_string_lossy().contains(".corrupt-"))
.count();
assert_eq!(
quarantined, 0,
"a healthy DB on re-check must not be quarantined"
);
}
#[test]
fn test_store_open_creates_cache_root() {
let dir = tempfile::tempdir().unwrap();
let cache_dir = dir.path().join("nested").join("cache");
let config = test_config(&cache_dir);
let _store = Store::open(&config).unwrap();
assert!(cache_dir.is_dir());
assert!(config.store_dir().is_dir());
assert!(config.index_db_path().is_file());
}
#[test]
fn test_store_eviction() {
let dir = tempfile::tempdir().unwrap();
let mut config = test_config(dir.path());
config.max_size = 100;
let store = Store::open(&config).unwrap();
let output_file = dir.path().join("big.rlib");
std::fs::write(&output_file, vec![0u8; 200]).unwrap();
store
.put(
"key1",
"big_crate",
&["lib".to_string()],
&[],
"x86_64-unknown-linux-gnu",
"dev",
&[(output_file, "libbig.rlib".to_string())],
"",
"",
)
.unwrap();
store
.db
.execute(
"UPDATE entries SET last_accessed = datetime('now', '-1 hour') WHERE cache_key = 'key1'",
[],
)
.unwrap();
let stats = store.evict().unwrap();
assert!(stats.entries_evicted > 0);
assert!(!store.contains("key1"));
}
#[test]
fn eviction_records_a_tombstone_and_a_later_lookup_marks_demand() {
let dir = tempfile::tempdir().unwrap();
let mut config = test_config(dir.path());
config.max_size = 100; let store = Store::open(&config).unwrap();
let out = dir.path().join("big.rlib");
fs::write(&out, vec![0u8; 4096]).unwrap();
store
.put_with_compile_time(
"doomed",
"c",
&["lib".to_string()],
&[],
"x86_64-unknown-linux-gnu",
"dev",
&[(out, "libbig.rlib".to_string())],
"",
"",
2500,
)
.unwrap();
store
.db
.execute(
"UPDATE entries SET last_accessed = datetime('now', '-2 hours')",
[],
)
.unwrap();
assert!(
store.evict().unwrap().entries_evicted > 0,
"expected eviction"
);
let (key, policy, cost, demanded): (String, String, i64, Option<String>) = store
.db
.query_row(
"SELECT cache_key, policy, compile_time_ms, demanded_at FROM eviction_tombstones",
[],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)),
)
.unwrap();
assert_eq!(key, "doomed");
assert_eq!(policy, "size-pressure", "records which policy chose it");
assert_eq!(cost, 2500, "records the rebuild cost that was destroyed");
assert!(demanded.is_none(), "not demanded yet");
assert_eq!(store.tombstone_stats().unwrap(), (1, 0));
assert!(store.get("doomed").unwrap().is_none());
assert_eq!(store.tombstone_stats().unwrap(), (1, 1));
assert!(store.get("never_existed").unwrap().is_none());
assert_eq!(store.tombstone_stats().unwrap(), (1, 1));
}
#[test]
fn tombstone_demand_records_only_the_first_request() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let store = Store::open(&config).unwrap();
store
.db
.execute(
"INSERT INTO eviction_tombstones (cache_key, evicted_at, demanded_at)
VALUES ('k', datetime('now','-1 hour'), NULL)",
[],
)
.unwrap();
store.note_tombstone_demand("k");
let first: String = store
.db
.query_row(
"SELECT demanded_at FROM eviction_tombstones WHERE cache_key='k'",
[],
|r| r.get(0),
)
.unwrap();
store.note_tombstone_demand("k");
let second: String = store
.db
.query_row(
"SELECT demanded_at FROM eviction_tombstones WHERE cache_key='k'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(first, second, "first demand must not be overwritten");
}
#[test]
fn tombstones_are_pruned_by_age_and_re_eviction_resets_the_record() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let store = Store::open(&config).unwrap();
store
.db
.execute(
"INSERT INTO eviction_tombstones (cache_key, evicted_at) VALUES
('old', datetime('now','-30 days')),
('recent', datetime('now','-1 day'))",
[],
)
.unwrap();
assert_eq!(store.prune_tombstones(14).unwrap(), 1);
assert_eq!(store.tombstone_stats().unwrap().0, 1, "recent one survives");
store
.db
.execute(
"UPDATE eviction_tombstones SET demanded_at = datetime('now') WHERE cache_key='recent'",
[],
)
.unwrap();
let features = crate::eviction::EntryFeatures {
key: "recent".into(),
size: 1,
hit_count: 0,
idle_hours: 5.0,
content_hash: None,
committed: true,
compile_time_ms: 10,
};
store.record_tombstone(&features, "size-pressure");
assert_eq!(
store.tombstone_stats().unwrap(),
(1, 0),
"re-eviction restarts the observation"
);
}
#[test]
fn put_records_compile_time_and_eviction_can_see_it() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let store = Store::open(&config).unwrap();
let out = dir.path().join("out.rlib");
fs::write(&out, b"artifact").unwrap();
store
.put_with_compile_time(
"costly",
"c",
&["lib".to_string()],
&[],
"x86_64-unknown-linux-gnu",
"dev",
&[(out, "libout.rlib".to_string())],
"",
"",
4321,
)
.unwrap();
let indexed: i64 = store
.db
.query_row(
"SELECT compile_time_ms FROM entries WHERE cache_key = 'costly'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(indexed, 4321, "put must index the rebuild cost");
let features = store.eviction_candidates().unwrap();
let entry = features.iter().find(|e| e.key == "costly").unwrap();
assert_eq!(
entry.compile_time_ms, 4321,
"eviction must see rebuild cost (#594)"
);
}
#[test]
fn backfill_compile_times_recovers_pre_index_entries() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let store = Store::open(&config).unwrap();
let out = dir.path().join("out.rlib");
fs::write(&out, b"artifact").unwrap();
store
.put_with_compile_time(
"legacy",
"c",
&["lib".to_string()],
&[],
"x86_64-unknown-linux-gnu",
"dev",
&[(out, "libout.rlib".to_string())],
"",
"",
7777,
)
.unwrap();
store
.db
.execute("UPDATE entries SET compile_time_ms = 0", [])
.unwrap();
assert_eq!(store.backfill_compile_times().unwrap(), 1);
let restored: i64 = store
.db
.query_row(
"SELECT compile_time_ms FROM entries WHERE cache_key = 'legacy'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(restored, 7777);
assert_eq!(store.backfill_compile_times().unwrap(), 0);
}
#[test]
fn backfill_compile_times_is_bounded_per_sweep_and_converges() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let store = Store::open(&config).unwrap();
const LIMIT: i64 = 3;
let total = LIMIT + 2;
for i in 0..total {
let out = dir.path().join(format!("o{i}.rlib"));
fs::write(&out, format!("artifact-{i}")).unwrap();
store
.put_with_compile_time(
&format!("k{i}"),
"c",
&["lib".to_string()],
&[],
"x86_64-unknown-linux-gnu",
"dev",
&[(out, format!("libo{i}.rlib"))],
"",
"",
100,
)
.unwrap();
}
store
.db
.execute("UPDATE entries SET compile_time_ms = 0", [])
.unwrap();
let first = store.backfill_compile_times_limited(LIMIT).unwrap();
assert_eq!(
first, LIMIT as usize,
"one sweep must not backfill the whole store"
);
let second = store.backfill_compile_times_limited(LIMIT).unwrap();
assert_eq!(second, 2, "the remainder converges on the next sweep");
assert_eq!(store.backfill_compile_times_limited(LIMIT).unwrap(), 0);
}
#[test]
fn size_pressure_policy_matches_the_sql_ordering_it_replaced() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let store = Store::open(&config).unwrap();
let seed = [
("huge_stale", 600 * 1024 * 1024_i64, 0_i64, 15.0_f64),
("small_hot", 14 * 1024, 9, 0.1),
("mid", 5 * 1024 * 1024, 2, 48.0),
("zero_size", 0, 0, 3.0),
("just_touched", 1024 * 1024, 1, 0.0),
("ancient_tiny", 512, 0, 5000.0),
("twin_a", 2 * 1024 * 1024, 3, 12.0),
("twin_b", 2 * 1024 * 1024, 3, 12.0),
];
for (key, size, hits, idle) in seed {
store
.db
.execute(
"INSERT INTO entries (cache_key, crate_name, size, hit_count, committed, last_accessed)
VALUES (?1, 'c', ?2, ?3, 1, datetime('now', ?4))",
params![key, size, hits, format!("-{} seconds", (idle * 3600.0) as i64)],
)
.unwrap();
}
let sql_order: Vec<String> = {
let mut stmt = store
.db
.prepare(
"SELECT cache_key FROM entries
ORDER BY
CAST((hit_count + 1) AS REAL)
/ (MAX((julianday('now') - julianday(last_accessed)) * 24.0, 0.01)
* MAX(size / 1048576.0, 0.001))
ASC",
)
.unwrap();
stmt.query_map([], |r| r.get(0))
.unwrap()
.collect::<Result<Vec<_>, _>>()
.unwrap()
};
let candidates = store.eviction_candidates().unwrap();
let policy_order = crate::eviction::SizePressurePolicy.select(&candidates);
let score_of: std::collections::HashMap<&str, f64> = candidates
.iter()
.map(|e| (e.key.as_str(), crate::eviction::size_pressure_score(e)))
.collect();
let seq =
|order: &[String]| -> Vec<f64> { order.iter().map(|k| score_of[k.as_str()]).collect() };
assert_eq!(
seq(&sql_order),
seq(&policy_order),
"policy ranking diverged from the SQL it replaced\n sql: {sql_order:?}\n policy: {policy_order:?}"
);
assert_eq!(policy_order.len(), seed.len(), "every entry must be ranked");
}
#[test]
fn older_than_and_duplicate_policies_match_their_former_sql() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let store = Store::open(&config).unwrap();
for (key, idle_h, hash) in [
("stale", 100.0_f64, Some("h1")),
("fresh", 1.0, Some("h1")),
("boundary", 24.0, None),
("lonely", 200.0, Some("h2")),
] {
store
.db
.execute(
"INSERT INTO entries (cache_key, crate_name, size, committed, content_hash, last_accessed)
VALUES (?1, 'c', 100, 1, ?2, datetime('now', ?3))",
params![key, hash, format!("-{} seconds", (idle_h * 3600.0) as i64)],
)
.unwrap();
}
let candidates = store.eviction_candidates().unwrap();
let sql_old: Vec<String> = {
let mut stmt = store
.db
.prepare("SELECT cache_key FROM entries WHERE last_accessed < datetime('now', '-24 hours')")
.unwrap();
stmt.query_map([], |r| r.get(0))
.unwrap()
.collect::<Result<Vec<_>, _>>()
.unwrap()
};
let mut policy_old = crate::eviction::OlderThanPolicy { hours: 24 }.select(&candidates);
policy_old.sort();
let unambiguous = |v: &[String]| -> Vec<String> {
let mut v: Vec<String> = v.iter().filter(|k| *k != "boundary").cloned().collect();
v.sort();
v
};
assert_eq!(
unambiguous(&policy_old),
unambiguous(&sql_old),
"older-than selection diverged away from the cutoff boundary"
);
let sql_dup: Vec<String> = {
let mut stmt = store
.db
.prepare(
"SELECT e.cache_key FROM entries e
JOIN (SELECT content_hash, MAX(last_accessed) AS newest
FROM entries WHERE content_hash IS NOT NULL AND committed = 1
GROUP BY content_hash HAVING COUNT(*) > 1) d
ON e.content_hash = d.content_hash
WHERE e.last_accessed < d.newest AND e.committed = 1",
)
.unwrap();
stmt.query_map([], |r| r.get(0))
.unwrap()
.collect::<Result<Vec<_>, _>>()
.unwrap()
};
let mut policy_dup = crate::eviction::DuplicatePolicy.select(&candidates);
let mut sql_dup_sorted = sql_dup.clone();
policy_dup.sort();
sql_dup_sorted.sort();
assert_eq!(policy_dup, sql_dup_sorted, "duplicate selection diverged");
assert_eq!(policy_dup, vec!["stale"], "expected the older twin evicted");
}
#[test]
fn evict_skips_recently_accessed_entry() {
let dir = tempfile::tempdir().unwrap();
let mut config = test_config(dir.path());
config.max_size = 100;
let store = Store::open(&config).unwrap();
let output_file = dir.path().join("big.rlib");
std::fs::write(&output_file, vec![0u8; 200]).unwrap();
store
.put(
"live_key",
"live_crate",
&["lib".to_string()],
&[],
"x86_64-unknown-linux-gnu",
"dev",
&[(output_file, "libbig.rlib".to_string())],
"",
"",
)
.unwrap();
let stats = store.evict().unwrap();
assert_eq!(
stats.entries_evicted, 0,
"a recently-accessed entry must be pinned against eviction"
);
assert_eq!(
stats.entries_pinned, 1,
"and it must be COUNTED as held back — that count is the whole \
difference between `evicted 0 entries` reading as a broken GC and \
explaining itself (#509)"
);
assert!(store.contains("live_key"));
store
.db
.execute(
"UPDATE entries SET last_accessed = datetime('now', '-1 hour') WHERE cache_key = 'live_key'",
[],
)
.unwrap();
let stats = store.evict().unwrap();
assert!(stats.entries_evicted > 0);
assert!(!store.contains("live_key"));
}
#[test]
fn remove_entry_ignores_recency_guard() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let store = Store::open(&config).unwrap();
let output_file = dir.path().join("x.rlib");
std::fs::write(&output_file, b"content").unwrap();
store
.put(
"rk",
"c",
&["lib".to_string()],
&[],
"",
"dev",
&[(output_file, "libx.rlib".to_string())],
"",
"",
)
.unwrap();
assert!(
!store
.remove_entry_guarded("rk", Some(EVICTION_IDLE_GRACE))
.unwrap(),
"guarded removal must skip a recently-accessed entry"
);
assert!(store.contains("rk"));
store.remove_entry("rk").unwrap();
assert!(!store.contains("rk"));
}
#[test]
fn test_incremental_dir_registry_deduplicates_and_cleans() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let store = Store::open(&config).unwrap();
let incremental_dir = dir.path().join("target/debug/incremental");
std::fs::create_dir_all(&incremental_dir).unwrap();
std::fs::write(incremental_dir.join("junk"), b"tmp").unwrap();
store.remember_incremental_dir(&incremental_dir).unwrap();
store.remember_incremental_dir(&incremental_dir).unwrap();
store
.remember_incremental_dir(&dir.path().join("missing/incremental"))
.unwrap();
let count_before: i64 = store
.db
.query_row("SELECT COUNT(*) FROM incremental_dirs", [], |row| {
row.get(0)
})
.unwrap();
assert_eq!(count_before, 2);
let cleaned = store.clean_registered_incremental_dirs().unwrap();
assert_eq!(cleaned, 1);
assert!(!incremental_dir.exists());
let count_after: i64 = store
.db
.query_row("SELECT COUNT(*) FROM incremental_dirs", [], |row| {
row.get(0)
})
.unwrap();
assert_eq!(count_after, 0);
}
#[test]
fn clean_registered_incremental_dirs_prunes_a_non_directory_path() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let store = Store::open(&config).unwrap();
let bogus = dir.path().join("not-a-dir");
std::fs::write(&bogus, b"i am a file").unwrap();
store.remember_incremental_dir(&bogus).unwrap();
let cleaned = store.clean_registered_incremental_dirs().unwrap();
assert_eq!(cleaned, 0, "a non-directory is pruned, not cleaned");
assert!(bogus.exists(), "the non-directory file is not deleted");
let remaining: i64 = store
.db
.query_row("SELECT COUNT(*) FROM incremental_dirs", [], |row| {
row.get(0)
})
.unwrap();
assert_eq!(remaining, 0, "the bogus registration was pruned");
}
#[cfg(unix)]
#[test]
fn clean_registered_incremental_dirs_keeps_row_when_remove_fails() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let store = Store::open(&config).unwrap();
let parent = dir.path().join("readonly-parent");
let incremental_dir = parent.join("incremental");
std::fs::create_dir_all(&incremental_dir).unwrap();
std::fs::write(incremental_dir.join("junk"), b"tmp").unwrap();
store.remember_incremental_dir(&incremental_dir).unwrap();
std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o500)).unwrap();
let cleaned = store.clean_registered_incremental_dirs().unwrap();
std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o700)).unwrap();
assert_eq!(cleaned, 0, "failed removals are not counted as cleaned");
assert!(incremental_dir.exists(), "failed removal leaves the dir");
let remaining: i64 = store
.db
.query_row("SELECT COUNT(*) FROM incremental_dirs", [], |row| {
row.get(0)
})
.unwrap();
assert_eq!(remaining, 1, "failed removal keeps the registry row");
}
#[test]
fn test_store_locking() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let store = Store::open(&config).unwrap();
let lock1 = match store.claim_build("testkey").unwrap() {
BuildClaim::Acquired(lock) => lock,
BuildClaim::Committed(_) | BuildClaim::Contended => {
panic!("first build claim should acquire the key")
}
};
assert!(matches!(
store.claim_build("testkey").unwrap(),
BuildClaim::Contended
));
drop(lock1);
assert!(matches!(
store.claim_build("testkey").unwrap(),
BuildClaim::Acquired(_)
));
}
#[test]
fn claim_build_rechecks_entry_after_acquiring_lock() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let peer = Store::open(&config).unwrap();
let waiter = Store::open(&config).unwrap();
let cache_key = "committed_during_claim_race";
let peer_lock = match peer.claim_build(cache_key).unwrap() {
BuildClaim::Acquired(lock) => lock,
BuildClaim::Committed(_) | BuildClaim::Contended => {
panic!("peer should acquire the initial build claim")
}
};
assert!(matches!(
waiter.claim_build(cache_key).unwrap(),
BuildClaim::Contended
));
let output = dir.path().join("lib.rlib");
fs::write(&output, b"peer output").unwrap();
peer.put(
cache_key,
"peer",
&["rlib".to_string()],
&[],
"host",
"dev",
&[(output, "lib.rlib".to_string())],
"",
"",
)
.unwrap();
drop(peer_lock);
match waiter.claim_build(cache_key).unwrap() {
BuildClaim::Committed(meta) => assert_eq!(meta.cache_key, cache_key),
BuildClaim::Acquired(_) => panic!("committed entry must prevent a duplicate compile"),
BuildClaim::Contended => panic!("peer already released the build lock"),
}
assert!(
waiter.try_lock(cache_key).unwrap().is_some(),
"serving the committed entry must release the claim"
);
}
#[test]
fn claim_build_evicts_empty_committed_entry() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let store = Store::open(&config).unwrap();
let cache_key = "empty_committed_entry";
let entry_dir = store.entry_dir(cache_key);
fs::create_dir_all(&entry_dir).unwrap();
let meta = EntryMeta {
cache_key: cache_key.to_string(),
crate_name: "empty".to_string(),
crate_types: vec!["rlib".to_string()],
files: vec![],
stdout: String::new(),
stderr: String::new(),
features: vec![],
target: "host".to_string(),
profile: "dev".to_string(),
compile_time_ms: 0,
emit_kinds: Vec::new(),
};
fs::write(
entry_dir.join("meta.json"),
serde_json::to_string_pretty(&meta).unwrap(),
)
.unwrap();
store
.db
.execute(
"INSERT INTO entries (cache_key, crate_name, size, committed) VALUES (?1, ?2, 0, 1)",
params![cache_key, "empty"],
)
.unwrap();
match store.claim_build(cache_key).unwrap() {
BuildClaim::Acquired(_) => {}
BuildClaim::Committed(_) => panic!("empty entry must not be served"),
BuildClaim::Contended => panic!("no peer owns the build lock"),
}
assert!(store.get(cache_key).unwrap().is_none());
assert!(!entry_dir.exists());
assert!(store.try_lock(cache_key).unwrap().is_some());
}
#[test]
fn prepared_key_lock_is_complete_before_atomic_publication() -> anyhow::Result<()> {
let dir = tempfile::tempdir().unwrap();
let lock_path = dir.path().join("entry.lock");
let first = PreparedKeyLock::new(lock_path.clone()).unwrap();
assert!(
!lock_path.exists(),
"the canonical path must stay absent while PID metadata is prepared"
);
assert_eq!(
fs::read_to_string(first.temp.path()).unwrap(),
std::process::id().to_string()
);
let winner = PreparedKeyLock::new(lock_path.clone())?
.publish()?
.expect("one prepared contender should publish");
assert_eq!(
fs::read_to_string(&lock_path).unwrap(),
std::process::id().to_string(),
"a visible lock must already contain a complete PID"
);
assert!(
first.publish()?.is_none(),
"noclobber publication must preserve the existing owner"
);
drop(winner);
assert!(!lock_path.exists());
Ok(())
}
#[test]
fn concurrent_stale_lock_recovery_has_one_winner() {
const CONTENDERS: usize = 16;
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let store = Store::open(&config).unwrap();
let lock_path = store.entry_dir("stale-race").with_extension("lock");
fs::create_dir_all(lock_path.parent().unwrap()).unwrap();
fs::write(&lock_path, b"not-a-pid").unwrap();
let barrier = std::sync::Arc::new(std::sync::Barrier::new(CONTENDERS));
let mut handles = Vec::new();
for _ in 0..CONTENDERS {
let config = test_config(dir.path());
let barrier = barrier.clone();
handles.push(std::thread::spawn(move || {
let store = Store::open(&config).unwrap();
barrier.wait();
store.try_lock("stale-race").unwrap()
}));
}
let guards: Vec<_> = handles
.into_iter()
.map(|handle| handle.join().unwrap())
.collect();
assert_eq!(
guards.iter().filter(|guard| guard.is_some()).count(),
1,
"serialized stale recovery must publish exactly one live guard"
);
}
#[test]
fn try_lock_recovers_unparseable_stale_lock() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let store = Store::open(&config).unwrap();
let lock_path = store.entry_dir("stale_key").with_extension("lock");
fs::create_dir_all(lock_path.parent().unwrap()).unwrap();
fs::write(&lock_path, b"not-a-pid").unwrap();
let lock = store.try_lock("stale_key").unwrap();
assert!(lock.is_some(), "stale lock should be replaced");
assert_eq!(
fs::read_to_string(&lock_path).unwrap(),
std::process::id().to_string()
);
drop(lock);
assert!(!lock_path.exists(), "dropping the guard removes the lock");
}
#[test]
fn test_store_clear() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let store = Store::open(&config).unwrap();
let output_file = dir.path().join("out.rlib");
std::fs::write(&output_file, b"content").unwrap();
store
.put(
"k1",
"c1",
&["lib".to_string()],
&[],
"",
"dev",
&[(output_file.clone(), "lib.rlib".to_string())],
"",
"",
)
.unwrap();
assert!(store.contains("k1"));
store.clear().unwrap();
assert!(!store.contains("k1"));
}
#[test]
fn test_store_entry_dir() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let store = Store::open(&config).unwrap();
let entry_dir = store.entry_dir("abc123");
assert!(entry_dir.to_string_lossy().contains("store"));
assert!(entry_dir.to_string_lossy().contains("abc123"));
}
#[test]
fn test_store_cached_file_path() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let store = Store::open(&config).unwrap();
let path = store.cached_file_path("key1", "libfoo.rlib");
assert!(path.to_string_lossy().contains("key1"));
assert!(path.to_string_lossy().ends_with("libfoo.rlib"));
}
#[test]
fn test_store_total_size_empty() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let store = Store::open(&config).unwrap();
assert_eq!(store.total_size().unwrap(), 0);
}
#[test]
fn test_store_entry_count_empty() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let store = Store::open(&config).unwrap();
assert_eq!(store.entry_count().unwrap(), 0);
}
#[test]
fn test_store_entry_count_after_put() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let store = Store::open(&config).unwrap();
let output = dir.path().join("a.rlib");
std::fs::write(&output, b"data").unwrap();
store
.put(
"k1",
"c1",
&["lib".into()],
&[],
"",
"dev",
&[(output.clone(), "a.rlib".into())],
"",
"",
)
.unwrap();
rewrite_source(&output, b"data2");
store
.put(
"k2",
"c2",
&["lib".into()],
&[],
"",
"dev",
&[(output, "b.rlib".into())],
"",
"",
)
.unwrap();
assert_eq!(store.entry_count().unwrap(), 2);
}
#[test]
fn test_store_contains_nonexistent() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let store = Store::open(&config).unwrap();
assert!(!store.contains("nonexistent_key"));
}
#[test]
fn test_store_get_nonexistent() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let store = Store::open(&config).unwrap();
assert!(store.get("nonexistent_key").unwrap().is_none());
}
#[test]
fn test_store_remove_entry() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let store = Store::open(&config).unwrap();
let output = dir.path().join("lib.rlib");
std::fs::write(&output, b"content").unwrap();
store
.put(
"rem1",
"c1",
&["lib".into()],
&[],
"",
"dev",
&[(output, "lib.rlib".into())],
"",
"",
)
.unwrap();
assert!(store.contains("rem1"));
store.remove_entry("rem1").unwrap();
assert!(!store.contains("rem1"));
assert_eq!(store.entry_count().unwrap(), 0);
}
#[test]
fn remove_entry_refuses_on_corrupt_meta_no_refcount_leak() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let store = Store::open(&config).unwrap();
let output = dir.path().join("lib.rlib");
std::fs::write(&output, b"content").unwrap();
store
.put(
"corrupt1",
"c1",
&["lib".into()],
&[],
"",
"dev",
&[(output, "lib.rlib".into())],
"",
"",
)
.unwrap();
let refcount_sum = |s: &Store| -> i64 {
s.db.query_row("SELECT COALESCE(SUM(refcount), 0) FROM blobs", [], |r| {
r.get(0)
})
.unwrap()
};
let row_present = |s: &Store| -> i64 {
s.db.query_row(
"SELECT COUNT(*) FROM entries WHERE cache_key = 'corrupt1'",
[],
|r| r.get(0),
)
.unwrap()
};
assert_eq!(refcount_sum(&store), 1, "one blob at refcount 1 after put");
assert_eq!(row_present(&store), 1);
let meta_path = store.entry_dir("corrupt1").join("meta.json");
std::fs::write(&meta_path, b"{ not valid json").unwrap();
assert!(
store.remove_entry("corrupt1").is_err(),
"remove_entry must error on unparseable meta.json rather than leak"
);
assert_eq!(
row_present(&store),
1,
"corrupt entry row must survive a refused removal"
);
assert_eq!(
refcount_sum(&store),
1,
"blob refcounts must be unchanged — no orphan"
);
}
#[test]
fn remove_entry_refuses_when_meta_missing_but_row_present() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let store = Store::open(&config).unwrap();
let output = dir.path().join("lib.rlib");
std::fs::write(&output, b"x").unwrap();
store
.put(
"m1",
"c1",
&["lib".into()],
&[],
"",
"dev",
&[(output, "lib.rlib".into())],
"",
"",
)
.unwrap();
std::fs::remove_file(store.entry_dir("m1").join("meta.json")).unwrap();
assert!(store.remove_entry("m1").is_err());
let still_there: i64 = store
.db
.query_row(
"SELECT COUNT(*) FROM entries WHERE cache_key = 'm1'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(still_there, 1, "entry row must survive a refused removal");
}
#[test]
fn blob_path_is_panic_safe_for_short_hash() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let store = Store::open(&config).unwrap();
let _ = store.blob_path("a");
let _ = store.blob_path("");
}
#[test]
fn test_store_remove_entry_nonexistent() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let store = Store::open(&config).unwrap();
store.remove_entry("nonexistent").unwrap();
}
#[test]
fn test_store_list_entries_empty() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let store = Store::open(&config).unwrap();
let entries = store.list_entries("name").unwrap();
assert!(entries.is_empty());
}
#[test]
fn test_store_list_entries_sort_by() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let store = Store::open(&config).unwrap();
let out1 = dir.path().join("a.rlib");
std::fs::write(&out1, vec![0u8; 100]).unwrap();
store
.put(
"k1",
"alpha",
&["lib".into()],
&[],
"",
"dev",
&[(out1, "a.rlib".into())],
"",
"",
)
.unwrap();
let out2 = dir.path().join("b.rlib");
std::fs::write(&out2, vec![0u8; 200]).unwrap();
store
.put(
"k2",
"beta",
&["lib".into()],
&[],
"",
"dev",
&[(out2, "b.rlib".into())],
"",
"",
)
.unwrap();
let entries = store.list_entries("name").unwrap();
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].crate_name, "alpha");
let entries = store.list_entries("size").unwrap();
assert_eq!(entries.len(), 2);
assert!(entries[0].size >= entries[1].size);
let entries = store.list_entries("hits").unwrap();
assert_eq!(entries.len(), 2);
}
#[test]
fn list_entries_errors_on_non_integer_size_row() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let store = Store::open(&config).unwrap();
store
.db
.execute(
"INSERT INTO entries \
(cache_key, crate_name, crate_type, profile, size, committed) \
VALUES ('bad_size', 'bad', 'lib', 'dev', x'01', 1)",
[],
)
.unwrap();
let err = store.list_entries("name").unwrap_err();
assert!(
err.to_string().contains("Invalid column type"),
"expected SQLite type error, got: {err}"
);
}
#[test]
fn test_store_evict_older_than() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let store = Store::open(&config).unwrap();
let output = dir.path().join("lib.rlib");
std::fs::write(&output, b"content").unwrap();
store
.put(
"k1",
"c1",
&["lib".into()],
&[],
"",
"dev",
&[(output, "lib.rlib".into())],
"",
"",
)
.unwrap();
store
.db
.execute(
"UPDATE entries SET last_accessed = datetime('now', '-48 hours') WHERE cache_key = 'k1'",
[],
)
.unwrap();
let stats = store.evict_older_than(24).unwrap();
assert_eq!(stats.entries_evicted, 1);
assert!(!store.contains("k1"));
}
#[test]
fn test_store_evict_older_than_keeps_recent() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let store = Store::open(&config).unwrap();
let output = dir.path().join("lib.rlib");
std::fs::write(&output, b"content").unwrap();
store
.put(
"k1",
"c1",
&["lib".into()],
&[],
"",
"dev",
&[(output, "lib.rlib".into())],
"",
"",
)
.unwrap();
let stats = store.evict_older_than(9999).unwrap();
assert_eq!(stats.entries_evicted, 0);
assert!(store.contains("k1"));
}
#[test]
fn test_store_import_downloaded_entry() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let store = Store::open(&config).unwrap();
let entry_dir = config.store_dir().join("downloaded_key");
std::fs::create_dir_all(&entry_dir).unwrap();
let artifact_content = b"fake artifact";
std::fs::write(entry_dir.join("lib.rlib"), artifact_content).unwrap();
let hash = crate::cache_key::hash_file(&entry_dir.join("lib.rlib")).unwrap();
let meta = EntryMeta {
cache_key: "downloaded_key".to_string(),
crate_name: "downloaded_crate".to_string(),
crate_types: vec!["lib".to_string()],
files: vec![CachedFile {
name: "lib.rlib".to_string(),
size: artifact_content.len() as u64,
hash,
executable: false,
}],
stdout: String::new(),
stderr: String::new(),
features: vec!["std".to_string()],
target: "x86_64-unknown-linux-gnu".to_string(),
profile: "dev".to_string(),
compile_time_ms: 0,
emit_kinds: Vec::new(),
};
let meta_json = serde_json::to_string_pretty(&meta).unwrap();
std::fs::write(entry_dir.join("meta.json"), meta_json).unwrap();
store.import_downloaded_entry("downloaded_key").unwrap();
assert!(store.contains("downloaded_key"));
assert_eq!(store.entry_count().unwrap(), 1);
}
#[test]
fn test_store_import_downloaded_entry_missing_file() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let store = Store::open(&config).unwrap();
let entry_dir = config.store_dir().join("incomplete_key");
std::fs::create_dir_all(&entry_dir).unwrap();
let meta = EntryMeta {
cache_key: "incomplete_key".to_string(),
crate_name: "incomplete_crate".to_string(),
crate_types: vec!["lib".to_string()],
files: vec![CachedFile {
name: "lib.rlib".to_string(),
size: 42,
hash: "a".repeat(64),
executable: false,
}],
stdout: String::new(),
stderr: String::new(),
features: vec![],
target: String::new(),
profile: "dev".to_string(),
compile_time_ms: 0,
emit_kinds: Vec::new(),
};
let meta_json = serde_json::to_string_pretty(&meta).unwrap();
std::fs::write(entry_dir.join("meta.json"), meta_json).unwrap();
let err = store.import_downloaded_entry("incomplete_key").unwrap_err();
assert!(
err.to_string().contains("missing file"),
"expected 'missing file' error, got: {err}"
);
assert!(!store.contains("incomplete_key"));
}
#[test]
fn test_import_downloaded_entry_creates_blobs() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let store = Store::open(&config).unwrap();
let entry_dir = config.store_dir().join("dl_key");
fs::create_dir_all(&entry_dir).unwrap();
fs::write(entry_dir.join("lib.rlib"), b"artifact data").unwrap();
let hash = crate::cache_key::hash_file(&entry_dir.join("lib.rlib")).unwrap();
let meta = EntryMeta {
cache_key: "dl_key".to_string(),
crate_name: "dl_crate".to_string(),
crate_types: vec!["lib".to_string()],
files: vec![CachedFile {
name: "lib.rlib".to_string(),
size: 13,
hash: hash.clone(),
executable: false,
}],
stdout: String::new(),
stderr: String::new(),
features: vec![],
target: String::new(),
profile: "dev".to_string(),
compile_time_ms: 0,
emit_kinds: Vec::new(),
};
fs::write(
entry_dir.join("meta.json"),
serde_json::to_string_pretty(&meta).unwrap(),
)
.unwrap();
store.import_downloaded_entry("dl_key").unwrap();
let blob = store.blob_path(&hash);
assert!(
blob.exists(),
"blob should be created from downloaded artifact"
);
assert!(
!entry_dir.join("lib.rlib").exists(),
"artifact should have been moved to blob store"
);
assert!(
entry_dir.join("meta.json").exists(),
"meta.json should remain"
);
let perms = fs::metadata(&blob).unwrap().permissions();
assert!(perms.readonly(), "imported blob should be read-only");
let refcount: i64 = store
.db
.query_row(
"SELECT refcount FROM blobs WHERE hash = ?1",
params![&hash],
|row| row.get(0),
)
.unwrap();
assert_eq!(refcount, 1);
assert!(store.contains("dl_key"));
}
#[test]
fn test_store_get_evicts_entry_with_missing_file() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let store = Store::open(&config).unwrap();
let output = dir.path().join("lib.rlib");
std::fs::write(&output, b"content").unwrap();
store
.put(
"damaged_key",
"damaged_crate",
&["lib".into()],
&[],
"",
"dev",
&[(output, "lib.rlib".into())],
"",
"",
)
.unwrap();
assert!(store.contains("damaged_key"));
let meta_content =
std::fs::read_to_string(store.entry_dir("damaged_key").join("meta.json")).unwrap();
let meta: EntryMeta = serde_json::from_str(&meta_content).unwrap();
let blob = store.blob_path(&meta.files[0].hash);
let mut perms = std::fs::metadata(&blob).unwrap().permissions();
perms.set_readonly(false);
std::fs::set_permissions(&blob, perms).unwrap();
std::fs::remove_file(&blob).unwrap();
let result = store.get("damaged_key").unwrap();
assert!(
result.is_none(),
"expected None for entry with missing file"
);
assert!(
!store.contains("damaged_key"),
"entry should have been evicted"
);
}
#[test]
fn test_store_get_evicts_entry_with_corrupted_file() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let store = Store::open(&config).unwrap();
let output = dir.path().join("lib.rlib");
std::fs::write(&output, b"valid rlib content here").unwrap();
store
.put(
"corrupt_key",
"corrupt_crate",
&["lib".into()],
&[],
"",
"dev",
&[(output, "lib.rlib".into())],
"",
"",
)
.unwrap();
assert!(store.contains("corrupt_key"));
let meta_content =
std::fs::read_to_string(store.entry_dir("corrupt_key").join("meta.json")).unwrap();
let meta: EntryMeta = serde_json::from_str(&meta_content).unwrap();
let blob = store.blob_path(&meta.files[0].hash);
let mut perms = std::fs::metadata(&blob).unwrap().permissions();
perms.set_readonly(false);
std::fs::set_permissions(&blob, perms).unwrap();
std::fs::write(&blob, b"short").unwrap();
let result = store.get("corrupt_key").unwrap();
assert!(
result.is_none(),
"expected None for entry with size-corrupted file"
);
assert!(
!store.contains("corrupt_key"),
"entry should have been evicted"
);
}
#[cfg(unix)]
#[test]
fn get_evicts_when_verified_blob_is_unreadable() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let store = Store::open(&config).unwrap();
let output = dir.path().join("lib.rlib");
fs::write(&output, b"readable before chmod").unwrap();
store
.put(
"unreadable_key",
"unreadable_crate",
&["lib".into()],
&[],
"",
"dev",
&[(output, "lib.rlib".into())],
"",
"",
)
.unwrap();
let meta = store.get("unreadable_key").unwrap().unwrap();
let blob = store.blob_path(&meta.files[0].hash);
fs::set_permissions(&blob, fs::Permissions::from_mode(0o000)).unwrap();
let _env_lock = ENV_VAR_TEST_LOCK.lock().unwrap();
let _verify = EnvVarGuard::set("KACHE_VERIFY_RESTORES", "always");
let result = store.get("unreadable_key").unwrap();
assert!(result.is_none(), "unreadable verified blob is evicted");
assert!(!store.contains("unreadable_key"));
}
#[test]
fn test_store_put_rejects_zero_byte_artifact() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let store = Store::open(&config).unwrap();
let output = dir.path().join("empty.rlib");
std::fs::write(&output, b"").unwrap();
let err = store
.put(
"zero_key",
"zero_crate",
&["lib".into()],
&[],
"",
"dev",
&[(output, "empty.rlib".into())],
"",
"",
)
.unwrap_err();
assert!(
err.to_string().contains("zero-byte"),
"expected 'zero-byte' error, got: {err}"
);
assert!(!store.contains("zero_key"));
}
#[test]
fn test_store_put_accepts_zero_byte_rmeta() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let store = Store::open(&config).unwrap();
let rmeta = dir.path().join("libit-15ba26cbaff655a7.rmeta");
std::fs::write(&rmeta, b"").unwrap();
let depinfo = dir.path().join("it-15ba26cbaff655a7.d");
std::fs::write(&depinfo, b"it: tests/it.rs\n").unwrap();
store
.put(
"zero_rmeta_key",
"it",
&[],
&[],
"",
"dev",
&[
(rmeta, "libit-15ba26cbaff655a7.rmeta".into()),
(depinfo, "it-15ba26cbaff655a7.d".into()),
],
"",
"",
)
.unwrap();
let meta = store.get("zero_rmeta_key").unwrap().unwrap();
assert_eq!(meta.files.len(), 2, "sibling outputs survive the empty one");
let stored_rmeta = meta
.files
.iter()
.find(|f| f.name.ends_with(".rmeta"))
.expect("rmeta stored");
assert_eq!(stored_rmeta.size, 0);
assert_eq!(
store
.blob_path(&stored_rmeta.hash)
.metadata()
.unwrap()
.len(),
0,
"empty blob materialized in the content store"
);
assert!(meta.emit_kinds.iter().any(|k| k == "metadata"));
}
#[test]
fn test_store_put_rejects_zero_byte_rmeta_from_a_library_unit() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let store = Store::open(&config).unwrap();
let rmeta = dir.path().join("libfoo-1234.rmeta");
std::fs::write(&rmeta, b"").unwrap();
let err = store
.put(
"truncated_lib_rmeta",
"foo",
&["lib".into()],
&[],
"",
"dev",
&[(rmeta, "libfoo-1234.rmeta".into())],
"",
"",
)
.unwrap_err();
assert!(
err.to_string().contains("zero-byte"),
"expected 'zero-byte' error, got: {err}"
);
assert!(!store.contains("truncated_lib_rmeta"));
}
#[test]
fn zero_byte_is_valid_output_only_for_metadata_without_a_library_unit() {
assert!(zero_byte_is_valid_output("libfoo-1234.rmeta", &[]));
for ct in ["bin", "cdylib", "staticlib"] {
assert!(
zero_byte_is_valid_output("libfoo-1234.rmeta", &[ct.into()]),
"{ct} emits no metadata, so an empty .rmeta is legitimate"
);
}
for ct in ["lib", "rlib", "dylib", "proc-macro", "some-future-type"] {
assert!(
!zero_byte_is_valid_output("libfoo-1234.rmeta", &[ct.into()]),
"{ct} must keep the truncation guard"
);
}
for name in ["libfoo.rlib", "foo.d", "foo.o", "libfoo.so", "foo"] {
assert!(
!zero_byte_is_valid_output(name, &[]),
"{name} must stay rejected when empty"
);
}
}
#[test]
fn test_store_import_rejects_size_mismatch() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let store = Store::open(&config).unwrap();
let entry_dir = config.store_dir().join("mismatch_key");
std::fs::create_dir_all(&entry_dir).unwrap();
let meta = EntryMeta {
cache_key: "mismatch_key".to_string(),
crate_name: "mismatch_crate".to_string(),
crate_types: vec!["lib".to_string()],
files: vec![CachedFile {
name: "lib.rlib".to_string(),
size: 9999, hash: "a".repeat(64),
executable: false,
}],
stdout: String::new(),
stderr: String::new(),
features: vec![],
target: String::new(),
profile: "dev".to_string(),
compile_time_ms: 0,
emit_kinds: Vec::new(),
};
let meta_json = serde_json::to_string_pretty(&meta).unwrap();
std::fs::write(entry_dir.join("meta.json"), meta_json).unwrap();
std::fs::write(entry_dir.join("lib.rlib"), b"small content").unwrap();
let err = store.import_downloaded_entry("mismatch_key").unwrap_err();
assert!(
err.to_string().contains("size mismatch"),
"expected 'size mismatch' error, got: {err}"
);
}
#[cfg(test)]
fn import_with_poisoned_meta(
store: &Store,
config: &Config,
key: &str,
content: &[u8],
mutate: impl FnOnce(&mut CachedFile),
) -> anyhow::Result<()> {
let entry_dir = config.store_dir().join(key);
std::fs::create_dir_all(&entry_dir).unwrap();
std::fs::write(entry_dir.join("lib.rlib"), content).unwrap();
let mut file = CachedFile {
name: "lib.rlib".to_string(),
size: content.len() as u64,
hash: crate::cache_key::hash_file(&entry_dir.join("lib.rlib")).unwrap(),
executable: false,
};
mutate(&mut file);
let meta = EntryMeta {
cache_key: key.to_string(),
crate_name: "c".to_string(),
crate_types: vec!["lib".to_string()],
files: vec![file],
stdout: String::new(),
stderr: String::new(),
features: vec![],
target: String::new(),
profile: "dev".to_string(),
compile_time_ms: 0,
emit_kinds: Vec::new(),
};
std::fs::write(
entry_dir.join("meta.json"),
serde_json::to_string_pretty(&meta).unwrap(),
)
.unwrap();
store.import_downloaded_entry(key)
}
#[test]
fn import_rejects_content_hash_mismatch() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let store = Store::open(&config).unwrap();
let bogus = blake3::hash(b"DIFFERENT!!!!").to_hex().to_string();
let err =
import_with_poisoned_meta(&store, &config, "ch_mismatch", b"real_content!", |f| {
f.hash = bogus;
})
.unwrap_err();
assert!(
err.to_string().contains("content hash mismatch"),
"expected content hash mismatch, got: {err}"
);
assert!(!store.contains("ch_mismatch"));
}
#[test]
fn import_rejects_malformed_hash() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let store = Store::open(&config).unwrap();
let err = import_with_poisoned_meta(&store, &config, "bad_hash", b"data", |f| {
f.hash = "../../etc/passwd".to_string();
})
.unwrap_err();
assert!(
err.to_string().contains("malformed blob hash"),
"expected malformed blob hash, got: {err}"
);
assert!(!store.contains("bad_hash"));
}
#[test]
fn import_rejects_unsafe_artifact_name() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let store = Store::open(&config).unwrap();
for bad in ["/etc/passwd", "../escape.rlib", "sub/dir.rlib"] {
let err = import_with_poisoned_meta(&store, &config, "unsafe_name", b"data", |f| {
f.name = bad.to_string();
})
.unwrap_err();
assert!(
err.to_string().contains("unsafe artifact name"),
"name {bad:?} should be rejected, got: {err}"
);
}
assert!(!store.contains("unsafe_name"));
}
#[test]
fn test_store_keys_for_crates_empty() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let store = Store::open(&config).unwrap();
let result = store.keys_for_crates(&[]).unwrap();
assert!(result.is_empty());
}
#[test]
fn test_store_keys_for_crates_with_entries() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let store = Store::open(&config).unwrap();
let output = dir.path().join("lib.rlib");
std::fs::write(&output, b"content").unwrap();
store
.put(
"k1",
"serde",
&["lib".into()],
&[],
"",
"dev",
&[(output.clone(), "lib.rlib".into())],
"",
"",
)
.unwrap();
rewrite_source(&output, b"content2");
store
.put(
"k2",
"tokio",
&["lib".into()],
&[],
"",
"dev",
&[(output, "lib.rlib".into())],
"",
"",
)
.unwrap();
let result = store.keys_for_crates(&["serde".to_string()]).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].crate_name, "serde");
let result = store
.keys_for_crates(&["serde".to_string(), "tokio".to_string()])
.unwrap();
assert_eq!(result.len(), 2);
}
#[test]
fn test_store_keys_for_crates_nonexistent() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let store = Store::open(&config).unwrap();
let result = store.keys_for_crates(&["nonexistent".to_string()]).unwrap();
assert!(result.is_empty());
}
#[test]
fn keys_for_crates_errors_on_non_text_cache_key_row() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let store = Store::open(&config).unwrap();
store
.db
.execute(
"INSERT INTO entries (cache_key, crate_name, size, committed) \
VALUES (x'80', 'badcrate', 1, 1)",
[],
)
.unwrap();
let err = store
.keys_for_crates(&["badcrate".to_string()])
.unwrap_err();
assert!(
err.to_string().contains("Invalid column type"),
"expected SQLite type error, got: {err}"
);
}
#[test]
fn test_store_put_records_metadata() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let store = Store::open(&config).unwrap();
let output = dir.path().join("lib.rlib");
std::fs::write(&output, b"my rlib content").unwrap();
store
.put(
"meta_key",
"mycrate",
&["lib".into(), "rlib".into()],
&["std".into(), "derive".into()],
"x86_64-unknown-linux-gnu",
"release",
&[(output, "lib.rlib".into())],
"stdout text",
"stderr text",
)
.unwrap();
let meta = store.get("meta_key").unwrap().unwrap();
assert_eq!(meta.crate_name, "mycrate");
assert_eq!(meta.crate_types, vec!["lib", "rlib"]);
assert_eq!(meta.features, vec!["std", "derive"]);
assert_eq!(meta.target, "x86_64-unknown-linux-gnu");
assert_eq!(meta.profile, "release");
assert_eq!(meta.stdout, "stdout text");
assert_eq!(meta.stderr, "stderr text");
assert_eq!(meta.files.len(), 1);
assert!(!meta.files[0].hash.is_empty());
}
#[test]
fn test_store_wait_for_committed_returns_false_when_not_committed() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let store = Store::open(&config).unwrap();
let result = store.wait_for_committed("nope").unwrap();
assert!(!result);
}
#[test]
#[cfg(target_os = "macos")]
fn test_exclude_from_indexing_creates_sentinel() {
let dir = tempfile::tempdir().unwrap();
if let Some(handle) = exclude_from_indexing(dir.path()) {
let _ = handle.join();
}
let sentinel = dir.path().join(".metadata_never_index");
assert!(sentinel.exists());
assert!(
sentinel.metadata().unwrap().len() == 0,
"sentinel should be empty"
);
if let Some(handle) = exclude_from_indexing(dir.path()) {
let _ = handle.join();
}
assert!(sentinel.exists());
}
#[test]
#[cfg(target_os = "macos")]
fn test_exclude_from_indexing_sets_tmutil_xattr() {
let dir = tempfile::tempdir().unwrap();
if let Some(handle) = exclude_from_indexing(dir.path()) {
let _ = handle.join();
}
let output = std::process::Command::new("tmutil")
.args(["isexcluded", &dir.path().display().to_string()])
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("[Excluded]"),
"expected [Excluded] in tmutil output, got: {stdout}"
);
assert!(
exclude_from_indexing(dir.path()).is_none(),
"already-excluded dir must skip the tmutil subprocess"
);
}
#[test]
#[cfg(target_os = "macos")]
fn test_exclude_from_indexing_skips_existing_sentinel() {
let dir = tempfile::tempdir().unwrap();
let sentinel = dir.path().join(".metadata_never_index");
fs::write(&sentinel, b"existing").unwrap();
if let Some(handle) = exclude_from_indexing(dir.path()) {
let _ = handle.join();
}
assert_eq!(fs::read(&sentinel).unwrap(), b"existing");
}
#[test]
fn test_blob_path_sharding() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let store = Store::open(&config).unwrap();
let hash = "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890";
let path = store.blob_path(hash);
assert!(
path.to_string_lossy()
.replace('\\', "/")
.contains("blobs/ab/")
);
assert!(path.to_string_lossy().ends_with(hash));
}
#[test]
fn test_blobs_table_created() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let store = Store::open(&config).unwrap();
let count: i64 = store
.db
.query_row("SELECT COUNT(*) FROM blobs", [], |row| row.get(0))
.unwrap();
assert_eq!(count, 0);
}
#[test]
#[cfg(target_os = "macos")]
fn test_exclude_from_indexing_nonexistent_dir_silent() {
let dir = PathBuf::from("/tmp/kache_test_nonexistent_874291");
assert!(!dir.exists());
if let Some(handle) = exclude_from_indexing(&dir) {
let _ = handle.join();
}
}
#[test]
fn test_put_creates_blob() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let store = Store::open(&config).unwrap();
let output = dir.path().join("lib.rlib");
fs::write(&output, b"rlib content").unwrap();
store
.put(
"k1",
"mycrate",
&["lib".into()],
&[],
"",
"dev",
&[(output, "lib.rlib".into())],
"",
"",
)
.unwrap();
let meta_path = store.entry_dir("k1").join("meta.json");
let content = fs::read_to_string(&meta_path).unwrap();
let meta: EntryMeta = serde_json::from_str(&content).unwrap();
let blob = store.blob_path(&meta.files[0].hash);
assert!(
blob.exists(),
"blob file should exist at {}",
blob.display()
);
let entry_dir = store.entry_dir("k1");
let mut files: Vec<_> = fs::read_dir(&entry_dir)
.unwrap()
.filter_map(|e| e.ok())
.map(|e| e.file_name().to_string_lossy().to_string())
.collect();
files.sort();
assert_eq!(
files,
vec!["meta.json"],
"entry dir should only contain meta.json"
);
}
#[test]
fn test_put_deduplicates_identical_content() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let store = Store::open(&config).unwrap();
let output = dir.path().join("lib.rlib");
rewrite_source(&output, b"same content");
store
.put(
"k1",
"crate_a",
&["lib".into()],
&[],
"",
"dev",
&[(output.clone(), "lib.rlib".into())],
"",
"",
)
.unwrap();
rewrite_source(&output, b"same content");
store
.put(
"k2",
"crate_a",
&["lib".into()],
&[],
"",
"dev",
&[(output, "lib.rlib".into())],
"",
"",
)
.unwrap();
let m1: EntryMeta = serde_json::from_str(
&fs::read_to_string(store.entry_dir("k1").join("meta.json")).unwrap(),
)
.unwrap();
let m2: EntryMeta = serde_json::from_str(
&fs::read_to_string(store.entry_dir("k2").join("meta.json")).unwrap(),
)
.unwrap();
assert_eq!(m1.files[0].hash, m2.files[0].hash);
let refcount: i64 = store
.db
.query_row(
"SELECT refcount FROM blobs WHERE hash = ?1",
params![m1.files[0].hash],
|row| row.get(0),
)
.unwrap();
assert_eq!(refcount, 2);
}
#[test]
fn test_get_verifies_blobs_not_entry_files() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let store = Store::open(&config).unwrap();
let output = dir.path().join("lib.rlib");
fs::write(&output, b"content").unwrap();
store
.put(
"k1",
"c",
&["lib".into()],
&[],
"",
"dev",
&[(output, "lib.rlib".into())],
"",
"",
)
.unwrap();
assert!(!store.entry_dir("k1").join("lib.rlib").exists());
let meta = store.get("k1").unwrap();
assert!(meta.is_some());
}
#[test]
fn test_get_evicts_when_blob_missing() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let store = Store::open(&config).unwrap();
let output = dir.path().join("lib.rlib");
fs::write(&output, b"content").unwrap();
store
.put(
"k1",
"c",
&["lib".into()],
&[],
"",
"dev",
&[(output, "lib.rlib".into())],
"",
"",
)
.unwrap();
let meta_content = fs::read_to_string(store.entry_dir("k1").join("meta.json")).unwrap();
let meta: EntryMeta = serde_json::from_str(&meta_content).unwrap();
let blob = store.blob_path(&meta.files[0].hash);
let mut perms = fs::metadata(&blob).unwrap().permissions();
perms.set_readonly(false);
fs::set_permissions(&blob, perms).unwrap();
fs::remove_file(&blob).unwrap();
let result = store.get("k1").unwrap();
assert!(result.is_none());
assert!(!store.contains("k1"));
}
#[test]
fn test_put_blob_is_readonly() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let store = Store::open(&config).unwrap();
let output = dir.path().join("lib.rlib");
fs::write(&output, b"content").unwrap();
store
.put(
"k1",
"c",
&["lib".into()],
&[],
"",
"dev",
&[(output, "lib.rlib".into())],
"",
"",
)
.unwrap();
let meta: EntryMeta = serde_json::from_str(
&fs::read_to_string(store.entry_dir("k1").join("meta.json")).unwrap(),
)
.unwrap();
let blob = store.blob_path(&meta.files[0].hash);
let perms = fs::metadata(&blob).unwrap().permissions();
assert!(perms.readonly(), "blob should be read-only");
}
#[test]
fn test_remove_entry_decrements_refcount() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let store = Store::open(&config).unwrap();
let output = dir.path().join("lib.rlib");
rewrite_source(&output, b"shared content");
store
.put(
"k1",
"c",
&["lib".into()],
&[],
"",
"dev",
&[(output.clone(), "lib.rlib".into())],
"",
"",
)
.unwrap();
rewrite_source(&output, b"shared content");
store
.put(
"k2",
"c",
&["lib".into()],
&[],
"",
"dev",
&[(output, "lib.rlib".into())],
"",
"",
)
.unwrap();
let meta_content = fs::read_to_string(store.entry_dir("k1").join("meta.json")).unwrap();
let meta: EntryMeta = serde_json::from_str(&meta_content).unwrap();
let hash = meta.files[0].hash.clone();
let blob = store.blob_path(&hash);
store.remove_entry("k1").unwrap();
assert!(blob.exists(), "blob should survive when refcount > 0");
let refcount: i64 = store
.db
.query_row(
"SELECT refcount FROM blobs WHERE hash = ?1",
params![&hash],
|row| row.get(0),
)
.unwrap();
assert_eq!(refcount, 1);
store.remove_entry("k2").unwrap();
assert!(!blob.exists(), "blob should be deleted when refcount = 0");
let count: i64 = store
.db
.query_row(
"SELECT COUNT(*) FROM blobs WHERE hash = ?1",
params![&hash],
|row| row.get(0),
)
.unwrap();
assert_eq!(count, 0);
}
#[test]
fn test_concurrent_puts_sharing_blob_are_consistent() {
const N: usize = 8;
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
Store::open(&config).unwrap();
let content = b"identical artifact content shared across all entries";
let mut handles = Vec::new();
for i in 0..N {
let config = test_config(dir.path());
let src = dir.path().join(format!("art-{i}.rlib"));
std::fs::write(&src, content).unwrap();
handles.push(std::thread::spawn(move || {
let store = Store::open(&config).unwrap();
store
.put(
&format!("key{i}"),
"shared",
&["lib".into()],
&[],
"x86_64-unknown-linux-gnu",
"dev",
&[(src, "libshared.rlib".into())],
"",
"",
)
.unwrap();
}));
}
for h in handles {
h.join().unwrap();
}
let store = Store::open(&config).unwrap();
let hash = store.get("key0").unwrap().unwrap().files[0].hash.clone();
assert_eq!(store.blob_stats().unwrap().total_blobs, 1);
let refcount: i64 = store
.db
.query_row(
"SELECT refcount FROM blobs WHERE hash = ?1",
params![&hash],
|row| row.get(0),
)
.unwrap();
assert_eq!(refcount as usize, N, "refcount must equal the entry count");
assert!(store.blob_path(&hash).is_file());
for i in 0..N {
assert!(
store.contains(&format!("key{i}")),
"entry key{i} must be committed"
);
}
let shard = store.blob_path(&hash).parent().unwrap().to_path_buf();
let tmp_left = std::fs::read_dir(&shard)
.unwrap()
.flatten()
.filter(|e| e.file_name().to_string_lossy().ends_with(".tmp"))
.count();
assert_eq!(tmp_left, 0, "no leftover .tmp files");
for i in 0..N - 1 {
store.remove_entry(&format!("key{i}")).unwrap();
}
assert!(
store.blob_path(&hash).is_file(),
"blob persists while still referenced"
);
store.remove_entry(&format!("key{}", N - 1)).unwrap();
assert!(
!store.blob_path(&hash).is_file(),
"blob reclaimed once the last reference is gone"
);
assert_eq!(store.blob_stats().unwrap().total_blobs, 0);
}
#[test]
fn test_concurrent_put_remove_never_dangles() {
const THREADS: usize = 8;
const ROUNDS: usize = 30;
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
Store::open(&config).unwrap();
let content = b"hot shared blob churned by concurrent puts and removes";
let mut handles = Vec::new();
for t in 0..THREADS {
let config = test_config(dir.path());
let dir_path = dir.path().to_path_buf();
handles.push(std::thread::spawn(move || {
let store = Store::open(&config).unwrap();
for r in 0..ROUNDS {
let key = format!("t{t}r{r}");
let src = dir_path.join(format!("src-{t}-{r}.rlib"));
std::fs::write(&src, content).unwrap();
store
.put(
&key,
"shared",
&["lib".into()],
&[],
"tgt",
"dev",
&[(src, "lib.rlib".into())],
"",
"",
)
.unwrap();
let meta = store
.get(&key)
.unwrap()
.unwrap_or_else(|| panic!("entry {key} vanished right after put"));
assert!(
store.blob_path(&meta.files[0].hash).is_file(),
"blob missing while {key} still references it"
);
store.remove_entry(&key).unwrap();
}
}));
}
for h in handles {
h.join().unwrap();
}
let store = Store::open(&config).unwrap();
assert_eq!(store.blob_stats().unwrap().total_blobs, 0);
}
#[test]
fn test_clear_removes_blobs_too() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let store = Store::open(&config).unwrap();
let output = dir.path().join("lib.rlib");
fs::write(&output, b"content").unwrap();
store
.put(
"k1",
"c",
&["lib".into()],
&[],
"",
"dev",
&[(output, "lib.rlib".into())],
"",
"",
)
.unwrap();
store.clear().unwrap();
let blobs_dir = store.blobs_dir();
if blobs_dir.exists() {
let has_files = fs::read_dir(&blobs_dir)
.unwrap()
.flatten()
.any(|e| e.path().is_dir());
assert!(
!has_files,
"blobs dir should have no shard subdirs after clear"
);
}
let count: i64 = store
.db
.query_row("SELECT COUNT(*) FROM blobs", [], |row| row.get(0))
.unwrap();
assert_eq!(count, 0);
}
#[test]
fn test_get_lazily_migrates_legacy_entry() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let store = Store::open(&config).unwrap();
let entry_dir = config.store_dir().join("old_key");
fs::create_dir_all(&entry_dir).unwrap();
let content = b"old format artifact";
fs::write(entry_dir.join("lib.rlib"), content).unwrap();
let hash = crate::cache_key::hash_file(&entry_dir.join("lib.rlib")).unwrap();
let meta = EntryMeta {
cache_key: "old_key".to_string(),
crate_name: "old_crate".to_string(),
crate_types: vec!["lib".to_string()],
files: vec![CachedFile {
name: "lib.rlib".to_string(),
size: content.len() as u64,
hash: hash.clone(),
executable: false,
}],
stdout: String::new(),
stderr: String::new(),
features: vec![],
target: String::new(),
profile: "dev".to_string(),
compile_time_ms: 0,
emit_kinds: Vec::new(),
};
fs::write(
entry_dir.join("meta.json"),
serde_json::to_string_pretty(&meta).unwrap(),
)
.unwrap();
store
.db
.execute(
"INSERT INTO entries (cache_key, crate_name, size, committed) VALUES ('old_key', 'old_crate', ?1, 1)",
params![content.len() as i64],
)
.unwrap();
let result = store.get("old_key").unwrap();
assert!(result.is_some());
let blob = store.blob_path(&hash);
assert!(
blob.exists(),
"get() should have migrated artifact to blob store"
);
assert!(!entry_dir.join("lib.rlib").exists());
}
#[test]
fn get_evicts_when_lazy_legacy_migration_fails() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let store = Store::open(&config).unwrap();
let entry_dir = config.store_dir().join("old_bad_key");
fs::create_dir_all(&entry_dir).unwrap();
let artifact = entry_dir.join("lib.rlib");
fs::write(&artifact, b"old format artifact").unwrap();
let hash = crate::cache_key::hash_file(&artifact).unwrap();
let meta = EntryMeta {
cache_key: "old_bad_key".to_string(),
crate_name: "old_bad_crate".to_string(),
crate_types: vec!["lib".to_string()],
files: vec![CachedFile {
name: "lib.rlib".to_string(),
size: fs::metadata(&artifact).unwrap().len(),
hash: hash.clone(),
executable: false,
}],
stdout: String::new(),
stderr: String::new(),
features: vec![],
target: String::new(),
profile: "dev".to_string(),
compile_time_ms: 0,
emit_kinds: Vec::new(),
};
fs::write(
entry_dir.join("meta.json"),
serde_json::to_string_pretty(&meta).unwrap(),
)
.unwrap();
store
.db
.execute(
"INSERT INTO entries (cache_key, crate_name, size, committed) \
VALUES ('old_bad_key', 'old_bad_crate', ?1, 1)",
params![fs::metadata(&artifact).unwrap().len() as i64],
)
.unwrap();
let shard_path = store.blobs_dir().join(&hash[..2]);
fs::create_dir_all(store.blobs_dir()).unwrap();
fs::write(&shard_path, b"not a shard directory").unwrap();
let result = store.get("old_bad_key").unwrap();
assert!(
result.is_none(),
"failed migration falls through to eviction"
);
assert!(!store.contains("old_bad_key"));
assert!(shard_path.is_file(), "unrelated shard conflict remains");
}
#[test]
fn test_migrate_to_blobs_bulk() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let store = Store::open(&config).unwrap();
let content = b"shared artifact bytes";
let hash = {
let tmp = dir.path().join("tmp");
fs::write(&tmp, content).unwrap();
crate::cache_key::hash_file(&tmp).unwrap()
};
for key in &["old1", "old2"] {
let entry_dir = config.store_dir().join(key);
fs::create_dir_all(&entry_dir).unwrap();
fs::write(entry_dir.join("lib.rlib"), content).unwrap();
let meta = EntryMeta {
cache_key: key.to_string(),
crate_name: "shared_crate".to_string(),
crate_types: vec!["lib".to_string()],
files: vec![CachedFile {
name: "lib.rlib".to_string(),
size: content.len() as u64,
hash: hash.clone(),
executable: false,
}],
stdout: String::new(),
stderr: String::new(),
features: vec![],
target: String::new(),
profile: "dev".to_string(),
compile_time_ms: 0,
emit_kinds: Vec::new(),
};
fs::write(
entry_dir.join("meta.json"),
serde_json::to_string_pretty(&meta).unwrap(),
)
.unwrap();
store
.db
.execute(
&format!(
"INSERT INTO entries (cache_key, crate_name, size, committed) VALUES ('{key}', 'shared_crate', {}, 1)",
content.len()
),
[],
)
.unwrap();
}
let stats = store.migrate_to_blobs(|_, _| {}).unwrap();
assert_eq!(stats.entries_migrated, 2);
let refcount: i64 = store
.db
.query_row(
"SELECT refcount FROM blobs WHERE hash = ?1",
params![hash],
|row| row.get(0),
)
.unwrap();
assert_eq!(refcount, 2);
}
#[test]
fn test_blob_stats() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let store = Store::open(&config).unwrap();
let stats = store.blob_stats().unwrap();
assert_eq!(stats.total_blobs, 0);
assert_eq!(stats.savings, 0);
let output = dir.path().join("lib.rlib");
rewrite_source(&output, b"shared content!");
store
.put(
"k1",
"c",
&["lib".into()],
&[],
"",
"dev",
&[(output.clone(), "lib.rlib".into())],
"",
"",
)
.unwrap();
rewrite_source(&output, b"shared content!");
store
.put(
"k2",
"c",
&["lib".into()],
&[],
"",
"dev",
&[(output, "lib.rlib".into())],
"",
"",
)
.unwrap();
let stats = store.blob_stats().unwrap();
assert_eq!(stats.total_blobs, 1); assert!(stats.total_logical_size > stats.total_blob_size); assert!(stats.savings > 0);
}
fn write_temp_file(dir: &Path, name: &str, content: &[u8]) -> PathBuf {
let path = dir.join(name);
rewrite_source(&path, content);
path
}
fn rewrite_source(path: &Path, content: &[u8]) {
let _ = fs::remove_file(path);
fs::write(path, content).unwrap();
}
fn read_meta(store: &Store, cache_key: &str) -> EntryMeta {
let meta_path = store.entry_dir(cache_key).join("meta.json");
let content = fs::read_to_string(&meta_path).unwrap();
serde_json::from_str(&content).unwrap()
}
fn blob_refcount(store: &Store, hash: &str) -> Option<i64> {
store
.db
.query_row(
"SELECT refcount FROM blobs WHERE hash = ?1",
params![hash],
|row| row.get(0),
)
.ok()
}
fn blob_table_count(store: &Store) -> i64 {
store
.db
.query_row("SELECT COUNT(*) FROM blobs", [], |row| row.get(0))
.unwrap()
}
#[test]
fn test_full_dedup_lifecycle() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let store = Store::open(&config).unwrap();
let shared = write_temp_file(dir.path(), "shared.rlib", b"shared artifact data");
let unique1 = write_temp_file(dir.path(), "unique1.rlib", b"unique to entry 1");
let unique2 = write_temp_file(dir.path(), "unique2.rlib", b"unique to entry 2");
store
.put(
"entry1",
"crate_a",
&["lib".into()],
&[],
"",
"dev",
&[
(shared.clone(), "shared.rlib".into()),
(unique1, "unique1.rlib".into()),
],
"",
"",
)
.unwrap();
rewrite_source(&shared, b"shared artifact data");
store
.put(
"entry2",
"crate_b",
&["lib".into()],
&[],
"",
"dev",
&[
(shared, "shared.rlib".into()),
(unique2, "unique2.rlib".into()),
],
"",
"",
)
.unwrap();
let meta1 = read_meta(&store, "entry1");
let meta2 = read_meta(&store, "entry2");
let shared_hash = &meta1
.files
.iter()
.find(|f| f.name == "shared.rlib")
.unwrap()
.hash;
let unique1_hash = &meta1
.files
.iter()
.find(|f| f.name == "unique1.rlib")
.unwrap()
.hash;
let unique2_hash = &meta2
.files
.iter()
.find(|f| f.name == "unique2.rlib")
.unwrap()
.hash;
let shared_hash2 = &meta2
.files
.iter()
.find(|f| f.name == "shared.rlib")
.unwrap()
.hash;
assert_eq!(shared_hash, shared_hash2);
assert_eq!(blob_refcount(&store, shared_hash), Some(2));
assert_eq!(blob_refcount(&store, unique1_hash), Some(1));
assert_eq!(blob_refcount(&store, unique2_hash), Some(1));
assert!(store.blob_path(shared_hash).exists());
assert!(store.blob_path(unique1_hash).exists());
assert!(store.blob_path(unique2_hash).exists());
store.remove_entry("entry1").unwrap();
assert_eq!(blob_refcount(&store, shared_hash), Some(1));
assert!(store.blob_path(shared_hash).exists());
assert!(!store.blob_path(unique1_hash).exists());
assert_eq!(blob_refcount(&store, unique1_hash), None);
store.remove_entry("entry2").unwrap();
assert!(!store.blob_path(shared_hash).exists());
assert!(!store.blob_path(unique2_hash).exists());
assert_eq!(blob_refcount(&store, shared_hash), None);
assert_eq!(blob_refcount(&store, unique2_hash), None);
assert_eq!(blob_table_count(&store), 0);
}
#[test]
fn gc_lock_is_mutually_exclusive() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let store = Store::open(&config).unwrap();
let first = store.try_gc_lock().unwrap();
assert!(first.is_some(), "first GC lock acquires");
assert!(
store.try_gc_lock().unwrap().is_none(),
"a second GC lock is refused while the first is held"
);
drop(first);
assert!(
store.try_gc_lock().unwrap().is_some(),
"the GC lock is re-acquirable after release"
);
}
#[cfg(unix)]
#[test]
fn gc_lock_does_not_expire_live_holder_by_mtime() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let store = Store::open(&config).unwrap();
let first = store.try_gc_lock().unwrap().expect("first GC lock");
let lock_path = config.store_dir().join("gc.lock");
let old = filetime::FileTime::from_system_time(
std::time::SystemTime::now() - std::time::Duration::from_secs(2 * 3600),
);
filetime::set_file_mtime(&lock_path, old).unwrap();
assert!(
store.try_gc_lock().unwrap().is_none(),
"an old marker file must not let a second GC steal a live lock"
);
drop(first);
assert!(store.try_gc_lock().unwrap().is_some());
}
#[test]
fn verify_restores_evicts_a_corrupted_blob() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let store = Store::open(&config).unwrap();
let f = write_temp_file(dir.path(), "lib.rlib", b"the real artifact bytes");
store
.put(
"vkey",
"vcrate",
&["lib".into()],
&[],
"aarch64-apple-darwin",
"release",
&[(f, "lib.rlib".into())],
"",
"",
)
.unwrap();
let meta = store.get("vkey").unwrap().expect("entry present after put");
let blob = store.blob_path(&meta.files[0].hash);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&blob, std::fs::Permissions::from_mode(0o644)).unwrap();
}
#[cfg(not(unix))]
{
let mut p = std::fs::metadata(&blob).unwrap().permissions();
p.set_readonly(false);
std::fs::set_permissions(&blob, p).unwrap();
}
std::fs::write(&blob, vec![b'X'; meta.files[0].size as usize]).unwrap();
let _env_lock = ENV_VAR_TEST_LOCK.lock().unwrap();
{
let _verify_off = EnvVarGuard::remove("KACHE_VERIFY_RESTORES");
assert!(
store.get("vkey").unwrap().is_some(),
"without the guard a same-size corrupt blob is not caught"
);
}
let result = {
let _verify_on = EnvVarGuard::set("KACHE_VERIFY_RESTORES", "1");
store.get("vkey").unwrap()
};
assert!(
result.is_none(),
"the guard must evict a blob whose content != its address"
);
}
#[test]
fn verify_restores_mode_parses_tristate() {
assert_eq!(parse_verify_restores(None), VerifyRestores::Off);
assert_eq!(parse_verify_restores(Some("")), VerifyRestores::Off);
assert_eq!(parse_verify_restores(Some("0")), VerifyRestores::Off);
assert_eq!(parse_verify_restores(Some("off")), VerifyRestores::Off);
assert_eq!(
parse_verify_restores(Some("sampled")),
VerifyRestores::Sampled
);
assert_eq!(
parse_verify_restores(Some("SAMPLED")),
VerifyRestores::Sampled
);
assert_eq!(
parse_verify_restores(Some("always")),
VerifyRestores::Always
);
assert_eq!(parse_verify_restores(Some("1")), VerifyRestores::Always);
assert_eq!(parse_verify_restores(Some("true")), VerifyRestores::Always);
}
#[test]
fn verify_restores_sampling_cadence() {
assert!(!should_verify_this_restore(VerifyRestores::Off));
assert!(should_verify_this_restore(VerifyRestores::Always));
let window = VERIFY_SAMPLE_RATE as usize;
let verified = (0..window)
.filter(|_| should_verify_this_restore(VerifyRestores::Sampled))
.count();
assert_eq!(
verified, 1,
"exactly one in {window} consecutive sampled hits must verify"
);
}
#[test]
fn test_put_get_restore_cycle() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let store = Store::open(&config).unwrap();
let file_a = write_temp_file(dir.path(), "a.rlib", b"rlib artifact content");
let file_b = write_temp_file(dir.path(), "b.dylib", b"dylib artifact content");
let file_c = write_temp_file(dir.path(), "c.rmeta", b"rmeta artifact content");
store
.put(
"multi_key",
"multi_crate",
&["lib".into(), "dylib".into()],
&["serde".into(), "tokio".into()],
"aarch64-apple-darwin",
"release",
&[
(file_a, "a.rlib".into()),
(file_b, "b.dylib".into()),
(file_c, "c.rmeta".into()),
],
"some stdout",
"some stderr",
)
.unwrap();
let meta = store.get("multi_key").unwrap().unwrap();
assert_eq!(meta.crate_name, "multi_crate");
assert_eq!(meta.crate_types, vec!["lib", "dylib"]);
assert_eq!(meta.features, vec!["serde", "tokio"]);
assert_eq!(meta.target, "aarch64-apple-darwin");
assert_eq!(meta.profile, "release");
assert_eq!(meta.stdout, "some stdout");
assert_eq!(meta.stderr, "some stderr");
assert_eq!(meta.files.len(), 3);
for cached_file in &meta.files {
let blob = store.blob_path(&cached_file.hash);
assert!(blob.exists(), "blob for {} should exist", cached_file.name);
let perms = fs::metadata(&blob).unwrap().permissions();
assert!(
perms.readonly(),
"blob for {} should be read-only",
cached_file.name
);
}
let entry_dir = store.entry_dir("multi_key");
let mut files: Vec<String> = fs::read_dir(&entry_dir)
.unwrap()
.filter_map(|e| e.ok())
.map(|e| e.file_name().to_string_lossy().to_string())
.collect();
files.sort();
assert_eq!(files, vec!["meta.json"]);
}
#[test]
fn test_clear_removes_all_blobs_and_tables() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let store = Store::open(&config).unwrap();
for i in 0..3 {
let file = write_temp_file(
dir.path(),
&format!("f{i}.rlib"),
format!("content {i}").as_bytes(),
);
store
.put(
&format!("key{i}"),
&format!("crate{i}"),
&["lib".into()],
&[],
"",
"dev",
&[(file, format!("lib{i}.rlib"))],
"",
"",
)
.unwrap();
}
assert_eq!(store.entry_count().unwrap(), 3);
assert!(blob_table_count(&store) >= 3);
store.clear().unwrap();
assert_eq!(store.entry_count().unwrap(), 0);
assert_eq!(blob_table_count(&store), 0);
let blobs_dir = store.blobs_dir();
if blobs_dir.exists() {
let any_content = fs::read_dir(&blobs_dir).unwrap().flatten().any(|_| true);
assert!(!any_content, "blobs dir should be empty after clear");
}
}
#[test]
fn test_migration_of_legacy_entry() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let store = Store::open(&config).unwrap();
let entry_dir = config.store_dir().join("legacy_key");
fs::create_dir_all(&entry_dir).unwrap();
let content_a = b"legacy artifact A";
let content_b = b"legacy artifact B";
fs::write(entry_dir.join("a.rlib"), content_a).unwrap();
fs::write(entry_dir.join("b.dylib"), content_b).unwrap();
let hash_a = crate::cache_key::hash_file(&entry_dir.join("a.rlib")).unwrap();
let hash_b = crate::cache_key::hash_file(&entry_dir.join("b.dylib")).unwrap();
let meta = EntryMeta {
cache_key: "legacy_key".to_string(),
crate_name: "legacy_crate".to_string(),
crate_types: vec!["lib".to_string()],
files: vec![
CachedFile {
name: "a.rlib".to_string(),
size: content_a.len() as u64,
hash: hash_a.clone(),
executable: false,
},
CachedFile {
name: "b.dylib".to_string(),
size: content_b.len() as u64,
hash: hash_b.clone(),
executable: false,
},
],
stdout: String::new(),
stderr: String::new(),
features: vec![],
target: String::new(),
profile: "dev".to_string(),
compile_time_ms: 0,
emit_kinds: Vec::new(),
};
fs::write(
entry_dir.join("meta.json"),
serde_json::to_string_pretty(&meta).unwrap(),
)
.unwrap();
store
.db
.execute(
"INSERT INTO entries (cache_key, crate_name, size, committed) VALUES ('legacy_key', 'legacy_crate', ?1, 1)",
params![(content_a.len() + content_b.len()) as i64],
)
.unwrap();
store.migrate_entry_to_blobs(&meta).unwrap();
assert!(
!entry_dir.join("a.rlib").exists(),
"a.rlib should be moved to blob store"
);
assert!(
!entry_dir.join("b.dylib").exists(),
"b.dylib should be moved to blob store"
);
assert!(entry_dir.join("meta.json").exists());
let blob_a = store.blob_path(&hash_a);
let blob_b = store.blob_path(&hash_b);
assert!(blob_a.exists(), "blob for a.rlib should exist");
assert!(blob_b.exists(), "blob for b.dylib should exist");
assert!(fs::metadata(&blob_a).unwrap().permissions().readonly());
assert!(fs::metadata(&blob_b).unwrap().permissions().readonly());
assert_eq!(blob_refcount(&store, &hash_a), Some(1));
assert_eq!(blob_refcount(&store, &hash_b), Some(1));
let files: Vec<String> = fs::read_dir(&entry_dir)
.unwrap()
.filter_map(|e| e.ok())
.map(|e| e.file_name().to_string_lossy().to_string())
.collect();
assert_eq!(files, vec!["meta.json"]);
}
#[test]
fn migrate_entry_to_blobs_bumps_refcount_when_insert_loses_race() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let store = Store::open(&config).unwrap();
let entry_dir = store.entry_dir("legacy_race");
fs::create_dir_all(&entry_dir).unwrap();
let artifact = entry_dir.join("lib.rlib");
fs::write(&artifact, b"legacy race artifact").unwrap();
let hash = crate::cache_key::hash_file(&artifact).unwrap();
let size = fs::metadata(&artifact).unwrap().len();
let meta = EntryMeta {
cache_key: "legacy_race".to_string(),
crate_name: "legacy_crate".to_string(),
crate_types: vec!["lib".to_string()],
files: vec![CachedFile {
name: "lib.rlib".to_string(),
size,
hash: hash.clone(),
executable: false,
}],
stdout: String::new(),
stderr: String::new(),
features: vec![],
target: String::new(),
profile: "dev".to_string(),
compile_time_ms: 0,
emit_kinds: Vec::new(),
};
store
.db
.execute(
&format!(
"CREATE TEMP TRIGGER seed_blob_before_insert \
BEFORE INSERT ON blobs \
WHEN NEW.hash = '{hash}' \
BEGIN \
INSERT OR IGNORE INTO blobs (hash, size, refcount) \
VALUES (NEW.hash, NEW.size, 41); \
END"
),
[],
)
.unwrap();
store.migrate_entry_to_blobs(&meta).unwrap();
assert_eq!(blob_refcount(&store, &hash), Some(42));
assert!(store.blob_path(&hash).is_file());
assert!(!artifact.exists());
}
#[test]
fn test_eviction_with_shared_blobs() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let store = Store::open(&config).unwrap();
let shared_content = b"shared between 1 and 2";
let unique3_content = b"unique to entry 3 only";
let f = write_temp_file(dir.path(), "shared.rlib", shared_content);
store
.put(
"e1",
"c1",
&["lib".into()],
&[],
"",
"dev",
&[(f, "shared.rlib".into())],
"",
"",
)
.unwrap();
let f = write_temp_file(dir.path(), "shared.rlib", shared_content);
store
.put(
"e2",
"c2",
&["lib".into()],
&[],
"",
"dev",
&[(f, "shared.rlib".into())],
"",
"",
)
.unwrap();
let f = write_temp_file(dir.path(), "unique3.rlib", unique3_content);
store
.put(
"e3",
"c3",
&["lib".into()],
&[],
"",
"dev",
&[(f, "unique3.rlib".into())],
"",
"",
)
.unwrap();
let meta1 = read_meta(&store, "e1");
let meta3 = read_meta(&store, "e3");
let shared_hash = &meta1.files[0].hash;
let unique3_hash = &meta3.files[0].hash;
assert_eq!(blob_refcount(&store, shared_hash), Some(2));
assert_eq!(blob_refcount(&store, unique3_hash), Some(1));
store.remove_entry("e1").unwrap();
assert_eq!(blob_refcount(&store, shared_hash), Some(1));
assert!(store.blob_path(shared_hash).exists());
assert!(store.blob_path(unique3_hash).exists());
assert_eq!(blob_refcount(&store, unique3_hash), Some(1));
store.remove_entry("e2").unwrap();
assert!(!store.blob_path(shared_hash).exists());
assert_eq!(blob_refcount(&store, shared_hash), None);
assert!(store.blob_path(unique3_hash).exists());
assert_eq!(blob_refcount(&store, unique3_hash), Some(1));
let meta = store.get("e3").unwrap();
assert!(meta.is_some());
}
#[test]
fn test_blob_stats_with_known_overlap() {
let dir = tempfile::tempdir().unwrap();
let config = test_config(dir.path());
let store = Store::open(&config).unwrap();
let shared_content = b"AAAA"; let unique_content = b"BBBBBBBB";
let f_shared = write_temp_file(dir.path(), "shared.rlib", shared_content);
let f_unique = write_temp_file(dir.path(), "unique.rlib", unique_content);
store
.put(
"stats1",
"c1",
&["lib".into()],
&[],
"",
"dev",
&[
(f_shared, "shared.rlib".into()),
(f_unique, "unique.rlib".into()),
],
"",
"",
)
.unwrap();
let f_shared = write_temp_file(dir.path(), "shared.rlib", shared_content);
store
.put(
"stats2",
"c2",
&["lib".into()],
&[],
"",
"dev",
&[(f_shared, "shared.rlib".into())],
"",
"",
)
.unwrap();
let stats = store.blob_stats().unwrap();
assert_eq!(stats.total_blobs, 2, "should have 2 unique blobs");
assert_eq!(
stats.total_blob_size, 12,
"physical size should be 12 bytes"
);
assert_eq!(
stats.total_logical_size, 16,
"logical size should be 16 bytes"
);
assert_eq!(stats.savings, 4, "savings should be 4 bytes");
}
#[test]
fn content_hash_golden_pins_serialization() {
let cf = |name: &str, size: u64, hash: &str, executable: bool| CachedFile {
name: name.to_string(),
size,
hash: hash.to_string(),
executable,
};
let files = vec![
cf("foo", 4096, "cccccccccccccccccccccccccccccccc", true),
cf(
"libfoo.rlib",
1024,
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
false,
),
cf(
"libfoo.rmeta",
256,
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
false,
),
];
assert_eq!(
compute_content_hash(&files),
"2dd3b89296eb2d5469d11aa00b312cee8734923698b97890f43ea6a8b9a37585",
);
}
#[test]
fn emit_kinds_derived_from_files() {
let cf = |name: &str| CachedFile {
name: name.to_string(),
size: 1,
hash: "h".to_string(),
executable: false,
};
let kinds = emit_kinds_for_files(&[
cf("libfoo.rlib"),
cf("libfoo.rmeta"),
cf("foo.d"),
cf("foo.dSYM"), ]);
assert_eq!(kinds, vec!["dep-info", "link", "metadata"]);
}
#[test]
fn covers_requested_emit_semantics() {
let mk = |kinds: &[&str]| EntryMeta {
cache_key: "k".into(),
crate_name: "c".into(),
crate_types: vec![],
files: vec![],
stdout: String::new(),
stderr: String::new(),
features: vec![],
target: String::new(),
profile: String::new(),
compile_time_ms: 0,
emit_kinds: kinds.iter().map(|s| s.to_string()).collect(),
};
let req = |kinds: &[&str]| -> Vec<String> { kinds.iter().map(|s| s.to_string()).collect() };
assert!(mk(&["dep-info", "link", "metadata"]).covers_requested_emit(&req(&["link"])));
assert!(
mk(&["dep-info", "metadata"]).covers_requested_emit(&req(&["dep-info", "metadata"]))
);
assert!(!mk(&["link"]).covers_requested_emit(&req(&["link", "obj"])));
assert!(mk(&[]).covers_requested_emit(&req(&["link", "obj"])));
assert!(mk(&["link"]).covers_requested_emit(&req(&["link", "future-exotic"])));
}
#[test]
fn test_put_stores_content_hash() {
let tmp = tempfile::tempdir().unwrap();
let config = test_config(tmp.path());
let store = Store::open(&config).unwrap();
let dir = tmp.path().join("src");
std::fs::create_dir_all(&dir).unwrap();
let file1 = dir.join("lib.rlib");
std::fs::write(&file1, b"artifact-content-1234").unwrap();
store
.put(
"key_ch_1",
"mycrate",
&["lib".to_string()],
&[],
"x86_64-unknown-linux-gnu",
"dev",
&[(file1, "lib.rlib".to_string())],
"",
"",
)
.unwrap();
let ch: String = store
.db
.query_row(
"SELECT content_hash FROM entries WHERE cache_key = 'key_ch_1'",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(
ch.len(),
64,
"content_hash should be full blake3 hex (64 chars)"
);
}
#[test]
fn test_import_downloaded_entry_stores_content_hash() {
let tmp = tempfile::tempdir().unwrap();
let config = test_config(tmp.path());
let store = Store::open(&config).unwrap();
let entry_dir = store.entry_dir("dl_ch_test");
std::fs::create_dir_all(&entry_dir).unwrap();
let artifact = entry_dir.join("lib.rlib");
std::fs::write(&artifact, b"downloaded-artifact-data").unwrap();
let hash = crate::cache_key::hash_file(&artifact).unwrap();
let size = std::fs::metadata(&artifact).unwrap().len();
let meta = EntryMeta {
cache_key: "dl_ch_test".to_string(),
crate_name: "dlcrate".to_string(),
crate_types: vec!["lib".to_string()],
files: vec![CachedFile {
name: "lib.rlib".to_string(),
size,
hash,
executable: false,
}],
stdout: String::new(),
stderr: String::new(),
features: vec![],
target: "x86_64-unknown-linux-gnu".to_string(),
profile: "dev".to_string(),
compile_time_ms: 0,
emit_kinds: Vec::new(),
};
std::fs::write(
entry_dir.join("meta.json"),
serde_json::to_string_pretty(&meta).unwrap(),
)
.unwrap();
store.import_downloaded_entry("dl_ch_test").unwrap();
let ch: String = store
.db
.query_row(
"SELECT content_hash FROM entries WHERE cache_key = 'dl_ch_test'",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(ch.len(), 64);
}
#[test]
fn test_list_entries_includes_content_hash() {
let tmp = tempfile::tempdir().unwrap();
let config = test_config(tmp.path());
let store = Store::open(&config).unwrap();
let dir = tmp.path().join("src");
std::fs::create_dir_all(&dir).unwrap();
let file1 = dir.join("lib.rlib");
std::fs::write(&file1, b"list-test-content").unwrap();
store
.put(
"list_ch_1",
"mycrate",
&["lib".to_string()],
&[],
"x86_64-unknown-linux-gnu",
"dev",
&[(file1, "lib.rlib".to_string())],
"",
"",
)
.unwrap();
let entries = store.list_entries("name").unwrap();
assert_eq!(entries.len(), 1);
assert!(entries[0].content_hash.is_some());
assert_eq!(entries[0].content_hash.as_ref().unwrap().len(), 64);
}
#[test]
fn test_evict_duplicate_entries() {
let tmp = tempfile::tempdir().unwrap();
let config = test_config(tmp.path());
let store = Store::open(&config).unwrap();
let dir = tmp.path().join("src");
std::fs::create_dir_all(&dir).unwrap();
let file1 = dir.join("lib.rlib");
std::fs::write(&file1, b"same-content-bytes").unwrap();
store
.put(
"dup_key_1",
"mycrate",
&["lib".to_string()],
&[],
"x86_64-unknown-linux-gnu",
"dev",
&[(file1.clone(), "lib.rlib".to_string())],
"",
"",
)
.unwrap();
store
.db
.execute(
"UPDATE entries SET last_accessed = datetime('now', '-1 hour') WHERE cache_key = 'dup_key_1'",
[],
)
.unwrap();
store
.put(
"dup_key_2",
"mycrate",
&["lib".to_string()],
&[],
"x86_64-unknown-linux-gnu",
"dev",
&[(file1, "lib.rlib".to_string())],
"",
"",
)
.unwrap();
assert_eq!(store.entry_count().unwrap(), 2);
let stats = store.evict_duplicate_entries().unwrap();
assert_eq!(stats.entries_evicted, 1);
assert_eq!(store.entry_count().unwrap(), 1);
assert!(store.contains("dup_key_2"));
assert!(!store.contains("dup_key_1"));
}
#[test]
fn evict_duplicate_entries_skips_victim_with_corrupt_meta() {
let tmp = tempfile::tempdir().unwrap();
let config = test_config(tmp.path());
let store = Store::open(&config).unwrap();
let dir = tmp.path().join("src");
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("lib.rlib");
rewrite_source(&file, b"same-content-for-corrupt-dedup");
store
.put(
"dup_corrupt_old",
"mycrate",
&["lib".to_string()],
&[],
"x86_64-unknown-linux-gnu",
"dev",
&[(file.clone(), "lib.rlib".to_string())],
"",
"",
)
.unwrap();
store
.db
.execute(
"UPDATE entries SET last_accessed = datetime('now', '-1 hour') \
WHERE cache_key = 'dup_corrupt_old'",
[],
)
.unwrap();
rewrite_source(&file, b"same-content-for-corrupt-dedup");
store
.put(
"dup_corrupt_new",
"mycrate",
&["lib".to_string()],
&[],
"x86_64-unknown-linux-gnu",
"dev",
&[(file, "lib.rlib".to_string())],
"",
"",
)
.unwrap();
std::fs::write(
store.entry_dir("dup_corrupt_old").join("meta.json"),
b"{not json",
)
.unwrap();
let stats = store.evict_duplicate_entries().unwrap();
assert_eq!(stats.entries_evicted, 0, "corrupt victim is skipped");
assert!(store.contains("dup_corrupt_old"));
assert!(store.contains("dup_corrupt_new"));
}
#[test]
fn test_backfill_content_hashes() {
let tmp = tempfile::tempdir().unwrap();
let config = test_config(tmp.path());
let store = Store::open(&config).unwrap();
let dir = tmp.path().join("src");
std::fs::create_dir_all(&dir).unwrap();
let file1 = dir.join("lib.rlib");
std::fs::write(&file1, b"backfill-content").unwrap();
store
.put(
"bf_key_1",
"mycrate",
&["lib".to_string()],
&[],
"x86_64-unknown-linux-gnu",
"dev",
&[(file1, "lib.rlib".to_string())],
"",
"",
)
.unwrap();
store
.db
.execute(
"UPDATE entries SET content_hash = NULL WHERE cache_key = 'bf_key_1'",
[],
)
.unwrap();
let backfilled = store.backfill_content_hashes().unwrap();
assert_eq!(backfilled, 1);
let ch: String = store
.db
.query_row(
"SELECT content_hash FROM entries WHERE cache_key = 'bf_key_1'",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(ch.len(), 64);
}
#[test]
fn test_content_hash_column_exists() {
let tmp = tempfile::tempdir().unwrap();
let config = test_config(tmp.path());
let store = Store::open(&config).unwrap();
let result: Result<Option<String>, _> =
store
.db
.query_row("SELECT content_hash FROM entries LIMIT 1", [], |row| {
row.get(0)
});
assert!(result.is_ok() || result.unwrap_err().to_string().contains("no rows"));
}
#[test]
fn test_content_hash_full_dedup_lifecycle() {
let tmp = tempfile::tempdir().unwrap();
let config = test_config(tmp.path());
let store = Store::open(&config).unwrap();
let dir = tmp.path().join("src");
std::fs::create_dir_all(&dir).unwrap();
let file_a = dir.join("a.rlib");
std::fs::write(&file_a, b"shared-content").unwrap();
let file_b = dir.join("b.rlib");
std::fs::write(&file_b, b"different-content").unwrap();
store
.put(
"ch_lc_1",
"mycrate",
&["lib".to_string()],
&[],
"x86_64-unknown-linux-gnu",
"dev",
&[(file_a.clone(), "a.rlib".to_string())],
"",
"",
)
.unwrap();
store
.db
.execute(
"UPDATE entries SET last_accessed = datetime('now', '-1 hour') WHERE cache_key = 'ch_lc_1'",
[],
)
.unwrap();
store
.put(
"ch_lc_2",
"mycrate",
&["lib".to_string()],
&[],
"x86_64-unknown-linux-gnu",
"dev",
&[(file_a, "a.rlib".to_string())],
"",
"",
)
.unwrap();
store
.put(
"ch_lc_3",
"othercrate",
&["lib".to_string()],
&[],
"x86_64-unknown-linux-gnu",
"dev",
&[(file_b, "b.rlib".to_string())],
"",
"",
)
.unwrap();
let entries = store.list_entries("name").unwrap();
assert_eq!(entries.len(), 3);
let ch1 = entries
.iter()
.find(|e| e.cache_key == "ch_lc_1")
.unwrap()
.content_hash
.as_ref()
.unwrap();
let ch2 = entries
.iter()
.find(|e| e.cache_key == "ch_lc_2")
.unwrap()
.content_hash
.as_ref()
.unwrap();
let ch3 = entries
.iter()
.find(|e| e.cache_key == "ch_lc_3")
.unwrap()
.content_hash
.as_ref()
.unwrap();
assert_eq!(ch1, ch2, "identical content should have same hash");
assert_ne!(ch1, ch3, "different content should have different hash");
let stats = store.evict_duplicate_entries().unwrap();
assert_eq!(stats.entries_evicted, 1);
assert_eq!(store.entry_count().unwrap(), 2);
assert!(store.contains("ch_lc_2")); assert!(store.contains("ch_lc_3")); assert!(!store.contains("ch_lc_1")); }
}