use std::path::{Path, PathBuf};
use crate::errors::CsiError;
const LAST_ACCESS_FILE: &str = ".cfgd-last-access";
const COMPLETE_SENTINEL: &str = ".cfgd-complete";
pub struct Cache {
root: PathBuf,
max_bytes: u64,
}
impl Cache {
pub fn new(root: PathBuf, max_bytes: u64) -> Result<Self, CsiError> {
std::fs::create_dir_all(&root)?;
Ok(Self { root, max_bytes })
}
pub fn get_or_pull(
&self,
module: &str,
version: &str,
oci_ref: &str,
) -> Result<PathBuf, CsiError> {
let entry_dir = self.entry_path(module, version)?;
if entry_dir.is_dir() && is_complete(&entry_dir) {
touch_atime(&entry_dir);
return Ok(entry_dir);
}
let tmp_name = format!(".tmp-{}-{}-{}", module, version, std::process::id());
let tmp_dir = self.root.join(&tmp_name);
std::fs::create_dir_all(&tmp_dir)?;
let pull_result = cfgd_core::oci::pull_module(oci_ref, &tmp_dir, false, None);
if let Err(e) = pull_result {
let _ = std::fs::remove_dir_all(&tmp_dir);
return Err(e.into());
}
if let Err(e) = cfgd_core::atomic_write_str(&tmp_dir.join(COMPLETE_SENTINEL), "") {
tracing::warn!("failed to write cache sentinel: {e}");
}
touch_atime(&tmp_dir);
if let Some(parent) = entry_dir.parent() {
std::fs::create_dir_all(parent)?;
}
if let Err(e) = std::fs::rename(&tmp_dir, &entry_dir) {
tracing::warn!(module = %module, version = %version, error = %e, "cache rename race, discarding duplicate pull");
let _ = std::fs::remove_dir_all(&tmp_dir);
}
if let Err(e) = self.evict_lru() {
tracing::warn!(error = %e, "cache eviction failed");
}
Ok(entry_dir)
}
pub fn get(&self, module: &str, version: &str) -> Option<PathBuf> {
let entry_dir = self.entry_path(module, version).ok()?;
if entry_dir.is_dir() && is_complete(&entry_dir) {
touch_atime(&entry_dir);
Some(entry_dir)
} else {
None
}
}
pub fn evict_lru(&self) -> Result<(), CsiError> {
let current = self.current_size_bytes();
if current <= self.max_bytes {
return Ok(());
}
let mut entries = self.list_entries()?;
entries.sort_by_key(|(_, atime)| *atime);
let mut freed = 0u64;
let overflow = current.saturating_sub(self.max_bytes);
for (path, _) in &entries {
if freed >= overflow {
break;
}
let size = dir_size(path);
if let Err(e) = std::fs::remove_dir_all(path) {
tracing::warn!(path = %path.display(), error = %e, "failed to evict cache entry");
continue;
}
if let Some(parent) = path.parent() {
let _ = std::fs::remove_dir(parent);
}
freed += size;
tracing::info!(path = %path.display(), freed_bytes = size, "evicted cache entry");
}
Ok(())
}
pub fn current_size_bytes(&self) -> u64 {
dir_size_excluding_markers(&self.root)
}
fn entry_path(&self, module: &str, version: &str) -> Result<PathBuf, CsiError> {
cfgd_core::validate_no_traversal(Path::new(module)).map_err(|e| {
CsiError::InvalidAttribute {
key: format!("module: {e}"),
}
})?;
cfgd_core::validate_no_traversal(Path::new(version)).map_err(|e| {
CsiError::InvalidAttribute {
key: format!("version: {e}"),
}
})?;
Ok(self.root.join(module).join(version))
}
fn list_entries(&self) -> Result<Vec<(PathBuf, u64)>, CsiError> {
let mut entries = Vec::new();
let module_dirs = match std::fs::read_dir(&self.root) {
Ok(rd) => rd,
Err(e) => {
tracing::warn!(path = %self.root.display(), error = %e, "cannot read cache root");
return Ok(entries);
}
};
for module_entry in module_dirs {
let module_entry = module_entry?;
let module_path = module_entry.path();
if !module_path.is_dir() {
continue;
}
if module_path
.file_name()
.is_some_and(|n| n.to_str().is_some_and(|s| s.starts_with(".tmp-")))
{
continue;
}
let version_dirs = match std::fs::read_dir(&module_path) {
Ok(rd) => rd,
Err(_) => continue,
};
for version_entry in version_dirs {
let version_entry = version_entry?;
let version_path = version_entry.path();
if !version_path.is_dir() {
continue;
}
let atime = read_atime(&version_path);
entries.push((version_path, atime));
}
}
Ok(entries)
}
}
fn touch_atime(path: &Path) {
let now = cfgd_core::unix_secs_now();
let _ = cfgd_core::atomic_write_str(&path.join(LAST_ACCESS_FILE), &now.to_string());
}
fn read_atime(path: &Path) -> u64 {
std::fs::read_to_string(path.join(LAST_ACCESS_FILE))
.ok()
.and_then(|s| s.trim().parse::<u64>().ok())
.unwrap_or(0)
}
fn is_complete(path: &Path) -> bool {
path.join(COMPLETE_SENTINEL).exists()
}
fn dir_size_excluding_markers(path: &Path) -> u64 {
let mut total = 0u64;
if let Ok(entries) = std::fs::read_dir(path) {
for entry in entries.flatten() {
let p = entry.path();
if p.is_dir() {
total = total.saturating_add(dir_size_excluding_markers(&p));
} else {
let name = p.file_name().and_then(|n| n.to_str()).unwrap_or("");
if name == LAST_ACCESS_FILE || name == COMPLETE_SENTINEL {
continue;
}
if let Ok(meta) = p.metadata() {
total = total.saturating_add(meta.len());
}
}
}
}
total
}
fn dir_size(path: &Path) -> u64 {
let mut total = 0u64;
if let Ok(entries) = std::fs::read_dir(path) {
for entry in entries.flatten() {
let p = entry.path();
if p.is_dir() {
total = total.saturating_add(dir_size(&p));
} else if let Ok(meta) = p.metadata() {
total = total.saturating_add(meta.len());
}
}
}
total
}
#[cfg(test)]
mod tests {
use super::*;
fn make_cache(dir: &Path, max_bytes: u64) -> Cache {
Cache::new(dir.to_path_buf(), max_bytes).unwrap()
}
fn populate_entry(dir: &Path, module: &str, version: &str, content_size: usize, atime: u64) {
let entry = dir.join(module).join(version);
std::fs::create_dir_all(&entry).unwrap();
std::fs::write(entry.join("data.txt"), "x".repeat(content_size)).unwrap();
std::fs::write(entry.join(COMPLETE_SENTINEL), "").unwrap();
std::fs::write(entry.join(LAST_ACCESS_FILE), atime.to_string()).unwrap();
}
#[test]
fn cache_get_returns_none_when_empty() {
let dir = tempfile::tempdir().unwrap();
let cache = make_cache(dir.path(), 1024 * 1024);
assert!(cache.get("nettools", "1.0").is_none());
}
#[test]
fn cache_get_returns_path_after_manual_populate() {
let dir = tempfile::tempdir().unwrap();
let cache = make_cache(dir.path(), 1024 * 1024);
populate_entry(dir.path(), "mymod", "1.0", 100, 5000);
let result = cache.get("mymod", "1.0");
assert!(result.is_some());
assert_eq!(result.unwrap(), dir.path().join("mymod").join("1.0"));
}
#[test]
fn cache_get_returns_none_for_incomplete_entry() {
let dir = tempfile::tempdir().unwrap();
let cache = make_cache(dir.path(), 1024 * 1024);
let entry = dir.path().join("partial").join("1.0");
std::fs::create_dir_all(&entry).unwrap();
std::fs::write(entry.join("data.txt"), "some data").unwrap();
assert!(cache.get("partial", "1.0").is_none());
}
#[test]
fn cache_size_tracking_excludes_markers() {
let dir = tempfile::tempdir().unwrap();
let cache = make_cache(dir.path(), 1024 * 1024);
assert_eq!(cache.current_size_bytes(), 0);
populate_entry(dir.path(), "mod1", "v1", 1000, 5000);
let size = cache.current_size_bytes();
assert!(size >= 1000);
assert!(size < 1100); }
#[test]
fn cache_eviction_removes_oldest() {
let dir = tempfile::tempdir().unwrap();
let cache = make_cache(dir.path(), 500);
populate_entry(dir.path(), "old-mod", "v1", 300, 1000);
populate_entry(dir.path(), "new-mod", "v1", 300, 9999);
let old_entry = dir.path().join("old-mod").join("v1");
let new_entry = dir.path().join("new-mod").join("v1");
assert!(old_entry.exists());
assert!(new_entry.exists());
cache.evict_lru().unwrap();
assert!(!old_entry.exists(), "old entry should have been evicted");
assert!(new_entry.exists(), "new entry should be retained");
}
#[test]
fn cache_no_eviction_when_under_limit() {
let dir = tempfile::tempdir().unwrap();
let cache = make_cache(dir.path(), 1024 * 1024);
populate_entry(dir.path(), "mod1", "v1", 10, 5000);
cache.evict_lru().unwrap();
assert!(dir.path().join("mod1").join("v1").exists());
}
#[test]
fn entry_path_layout() {
let dir = tempfile::tempdir().unwrap();
let cache = make_cache(dir.path(), 1024);
let path = cache.entry_path("nettools", "1.2.3").unwrap();
assert_eq!(path, dir.path().join("nettools").join("1.2.3"));
}
#[test]
fn entry_path_rejects_traversal() {
let dir = tempfile::tempdir().unwrap();
let cache = make_cache(dir.path(), 1024);
assert!(cache.entry_path("../../etc", "passwd").is_err());
assert!(cache.entry_path("good-mod", "../../../tmp").is_err());
}
#[test]
fn is_complete_true_when_sentinel_present() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join(COMPLETE_SENTINEL), "").unwrap();
assert!(is_complete(dir.path()));
}
#[test]
fn is_complete_false_when_no_sentinel() {
let dir = tempfile::tempdir().unwrap();
assert!(!is_complete(dir.path()));
}
#[test]
fn cache_eviction_removes_multiple_oldest_until_under_limit() {
let dir = tempfile::tempdir().unwrap();
let cache = make_cache(dir.path(), 250);
populate_entry(dir.path(), "mod-a", "v1", 200, 1000);
populate_entry(dir.path(), "mod-b", "v1", 200, 2000);
populate_entry(dir.path(), "mod-c", "v1", 200, 3000);
populate_entry(dir.path(), "mod-d", "v1", 200, 4000);
assert!(cache.current_size_bytes() >= 800);
cache.evict_lru().unwrap();
assert!(
!dir.path().join("mod-a").join("v1").exists(),
"oldest entry should be evicted"
);
assert!(
!dir.path().join("mod-b").join("v1").exists(),
"second oldest should be evicted"
);
assert!(
!dir.path().join("mod-c").join("v1").exists(),
"third oldest should be evicted"
);
assert!(
dir.path().join("mod-d").join("v1").exists(),
"newest entry should survive"
);
assert!(cache.current_size_bytes() <= 250);
}
#[test]
fn cache_eviction_multiple_versions_of_same_module() {
let dir = tempfile::tempdir().unwrap();
let cache = make_cache(dir.path(), 350);
populate_entry(dir.path(), "nettools", "1.0", 200, 1000);
populate_entry(dir.path(), "nettools", "2.0", 200, 5000);
cache.evict_lru().unwrap();
assert!(
!dir.path().join("nettools").join("1.0").exists(),
"older version should be evicted"
);
assert!(
dir.path().join("nettools").join("2.0").exists(),
"newer version should survive"
);
}
#[test]
fn list_entries_skips_temp_dirs() {
let dir = tempfile::tempdir().unwrap();
let cache = make_cache(dir.path(), 1024 * 1024);
populate_entry(dir.path(), "real-mod", "v1", 100, 5000);
let tmp_dir = dir.path().join(".tmp-real-mod-v2-12345");
std::fs::create_dir_all(&tmp_dir).unwrap();
std::fs::write(tmp_dir.join("data.txt"), "partial").unwrap();
let entries = cache.list_entries().unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].0, dir.path().join("real-mod").join("v1"));
}
#[test]
fn read_atime_returns_zero_for_missing_file() {
let dir = tempfile::tempdir().unwrap();
assert_eq!(read_atime(dir.path()), 0);
}
#[test]
fn touch_atime_writes_timestamp() {
let dir = tempfile::tempdir().unwrap();
touch_atime(dir.path());
let atime = read_atime(dir.path());
assert!(atime > 1_577_836_800);
}
#[test]
fn cache_size_zero_for_empty() {
let dir = tempfile::tempdir().unwrap();
let cache = make_cache(dir.path(), 1024);
assert_eq!(cache.current_size_bytes(), 0);
}
#[test]
fn cache_get_updates_access_time() {
let dir = tempfile::tempdir().unwrap();
let cache = make_cache(dir.path(), 1024 * 1024);
populate_entry(dir.path(), "mymod", "1.0", 100, 1000);
let atime_before = read_atime(&dir.path().join("mymod").join("1.0"));
assert_eq!(atime_before, 1000);
cache.get("mymod", "1.0").unwrap();
let atime_after = read_atime(&dir.path().join("mymod").join("1.0"));
assert!(
atime_after > 1000,
"access time should be updated after get()"
);
}
}