use std::{
collections::HashMap,
fs::{create_dir_all, remove_dir_all},
path::{Path, PathBuf},
sync::Mutex,
time::{Duration, Instant},
};
use crate::common::{CvmfsError, CvmfsResult};
const DEFAULT_TTL: Duration = Duration::from_secs(3600);
const DEFAULT_NEGATIVE_TTL: Duration = Duration::from_secs(5);
const DEFAULT_QUOTA: u64 = 4 * 1024 * 1024 * 1024;
#[derive(Debug)]
pub struct Cache {
pub cache_directory: String,
ttl: Duration,
negative_ttl: Duration,
negative_entries: Mutex<HashMap<String, Instant>>,
quota: u64,
}
impl Cache {
pub fn new(cache_directory: String) -> CvmfsResult<Self> {
let path = Path::new(&cache_directory);
Ok(Self {
cache_directory: path.to_str().ok_or(CvmfsError::FileNotFound)?.into(),
ttl: DEFAULT_TTL,
negative_ttl: DEFAULT_NEGATIVE_TTL,
negative_entries: Mutex::new(HashMap::new()),
quota: DEFAULT_QUOTA,
})
}
pub fn with_ttl(mut self, ttl: Duration, negative_ttl: Duration) -> Self {
self.ttl = ttl;
self.negative_ttl = negative_ttl;
self
}
pub fn with_quota(mut self, quota_bytes: u64) -> Self {
self.quota = quota_bytes;
self
}
pub fn initialize(&self) -> CvmfsResult<()> {
let base_path = self.create_directory("data")?;
for i in 0x00..=0xff {
let new_folder = format!("{:02x}", i);
let new_file = Path::join::<&Path>(base_path.as_ref(), new_folder.as_ref());
create_dir_all(new_file)?;
}
Ok(())
}
fn create_directory(&self, path: &str) -> CvmfsResult<String> {
let cache_full_path = Path::new(&self.cache_directory).join(path);
create_dir_all(cache_full_path.clone())?;
cache_full_path
.into_os_string()
.into_string()
.map_err(|_| CvmfsError::FileNotFound)
}
pub fn add(&self, file_name: &str) -> CvmfsResult<PathBuf> {
if file_name.contains("..") || file_name.starts_with('/') {
return Err(CvmfsError::IO("invalid cache filename".to_string()));
}
let path = Path::join(self.cache_directory.as_ref(), file_name);
Ok(path)
}
pub fn get(&self, file_name: &str) -> Option<PathBuf> {
if self.is_negative_cached(file_name) {
return None;
}
let path = self.add(file_name).ok()?;
if !path.is_file() {
return None;
}
let is_data_object = file_name.starts_with("data/");
if !is_data_object && self.is_expired(&path) {
std::fs::remove_file(&path).ok();
return None;
}
Some(path)
}
pub fn record_negative(&self, file_name: &str) {
if let Ok(mut entries) = self.negative_entries.lock() {
entries.insert(file_name.to_string(), Instant::now());
}
}
fn is_negative_cached(&self, file_name: &str) -> bool {
let Ok(mut entries) = self.negative_entries.lock() else {
return false;
};
let Some(inserted) = entries.get(file_name) else {
return false;
};
if inserted.elapsed() < self.negative_ttl {
return true;
}
entries.remove(file_name);
false
}
fn is_expired(&self, path: &Path) -> bool {
path.metadata()
.and_then(|m| m.modified())
.map(|modified| modified.elapsed().unwrap_or_default() > self.ttl)
.unwrap_or(true)
}
pub fn evict(&self) -> CvmfsResult<()> {
let data_path = Path::new(&self.cache_directory).join("data");
if data_path.exists() && data_path.is_dir() {
remove_dir_all(data_path)?;
self.initialize()?;
}
if let Ok(mut entries) = self.negative_entries.lock() {
entries.clear();
}
Ok(())
}
pub fn cache_size(&self) -> u64 {
let data_path = Path::new(&self.cache_directory).join("data");
Self::dir_size(&data_path)
}
pub fn enforce_quota(&self) -> CvmfsResult<()> {
let current = self.cache_size();
if current <= self.quota {
return Ok(());
}
let data_path = Path::new(&self.cache_directory).join("data");
let mut files = Self::collect_files_by_atime(&data_path);
files.sort_by_key(|(_, atime)| *atime);
let mut freed = 0u64;
let target = current - self.quota;
for (path, _) in &files {
if freed >= target {
break;
}
if let Ok(meta) = path.metadata() {
freed += meta.len();
std::fs::remove_file(path).ok();
}
}
Ok(())
}
fn dir_size(path: &Path) -> u64 {
let mut total = 0;
if let Ok(entries) = std::fs::read_dir(path) {
for entry in entries.flatten() {
let p = entry.path();
if p.is_dir() {
total += Self::dir_size(&p);
} else if let Ok(meta) = p.metadata() {
total += meta.len();
}
}
}
total
}
fn collect_files_by_atime(path: &Path) -> Vec<(PathBuf, std::time::SystemTime)> {
let mut files = Vec::new();
if let Ok(entries) = std::fs::read_dir(path) {
for entry in entries.flatten() {
let p = entry.path();
if p.is_dir() {
files.extend(Self::collect_files_by_atime(&p));
} else if let Ok(meta) = p.metadata() {
let atime = meta
.accessed()
.or_else(|_| meta.modified())
.unwrap_or(std::time::UNIX_EPOCH);
files.push((p, atime));
}
}
}
files
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn tmp_cache_dir(name: &str) -> PathBuf {
std::env::temp_dir().join(format!("cvmfs_cache_{}_{}", name, std::process::id()))
}
#[test]
fn cache_new_valid_path() {
let dir = tmp_cache_dir("new");
fs::create_dir_all(&dir).unwrap();
let cache = Cache::new(dir.to_str().unwrap().into()).unwrap();
assert_eq!(cache.cache_directory, dir.to_str().unwrap());
fs::remove_dir_all(&dir).ok();
}
#[test]
fn cache_initialize_creates_data_subdirs() {
let dir = tmp_cache_dir("init");
fs::create_dir_all(&dir).unwrap();
let cache = Cache::new(dir.to_str().unwrap().into()).unwrap();
cache.initialize().unwrap();
let data_dir = dir.join("data");
assert!(data_dir.is_dir());
assert!(data_dir.join("00").is_dir());
assert!(data_dir.join("0a").is_dir());
assert!(data_dir.join("ff").is_dir());
assert!(data_dir.join("7f").is_dir());
let count = fs::read_dir(&data_dir).unwrap().count();
assert_eq!(count, 256);
fs::remove_dir_all(&dir).ok();
}
#[test]
fn cache_add_normal_filename() {
let dir = tmp_cache_dir("add");
fs::create_dir_all(&dir).unwrap();
let cache = Cache::new(dir.to_str().unwrap().into()).unwrap();
let result = cache.add("data/ab/cdef1234").unwrap();
assert_eq!(result, dir.join("data/ab/cdef1234"));
fs::remove_dir_all(&dir).ok();
}
#[test]
fn cache_add_path_traversal_rejected() {
let dir = tmp_cache_dir("traversal");
fs::create_dir_all(&dir).unwrap();
let cache = Cache::new(dir.to_str().unwrap().into()).unwrap();
let result = cache.add("../etc/passwd");
assert!(result.is_err());
fs::remove_dir_all(&dir).ok();
}
#[test]
fn cache_add_absolute_path_rejected() {
let dir = tmp_cache_dir("absolute");
fs::create_dir_all(&dir).unwrap();
let cache = Cache::new(dir.to_str().unwrap().into()).unwrap();
let result = cache.add("/etc/passwd");
assert!(result.is_err());
fs::remove_dir_all(&dir).ok();
}
#[test]
fn cache_get_missing_file_returns_none() {
let dir = tmp_cache_dir("get_miss");
fs::create_dir_all(&dir).unwrap();
let cache = Cache::new(dir.to_str().unwrap().into()).unwrap();
cache.initialize().unwrap();
assert!(cache.get("data/ab/nonexistent").is_none());
fs::remove_dir_all(&dir).ok();
}
#[test]
fn cache_get_existing_file_returns_some() {
let dir = tmp_cache_dir("get_hit");
fs::create_dir_all(&dir).unwrap();
let cache = Cache::new(dir.to_str().unwrap().into()).unwrap();
cache.initialize().unwrap();
let file_path = dir.join("data/ab/testfile");
fs::write(&file_path, b"content").unwrap();
let result = cache.get("data/ab/testfile");
assert!(result.is_some());
assert_eq!(result.unwrap(), file_path);
fs::remove_dir_all(&dir).ok();
}
#[test]
fn cache_negative_entry_blocks_lookup() {
let dir = tmp_cache_dir("neg");
fs::create_dir_all(&dir).unwrap();
let cache = Cache::new(dir.to_str().unwrap().into()).unwrap();
cache.initialize().unwrap();
cache.record_negative("data/ab/missing");
assert!(cache.get("data/ab/missing").is_none());
fs::remove_dir_all(&dir).ok();
}
#[test]
fn cache_negative_entry_expires() {
let dir = tmp_cache_dir("neg_expire");
fs::create_dir_all(&dir).unwrap();
let cache = Cache::new(dir.to_str().unwrap().into())
.unwrap()
.with_ttl(Duration::from_secs(60), Duration::from_millis(1));
cache.initialize().unwrap();
cache.record_negative("data/ab/willexpire");
std::thread::sleep(Duration::from_millis(5));
let file_path = dir.join("data/ab/willexpire");
fs::write(&file_path, b"data").unwrap();
assert!(cache.get("data/ab/willexpire").is_some());
fs::remove_dir_all(&dir).ok();
}
#[test]
fn cache_ttl_expired_metadata_not_returned() {
let dir = tmp_cache_dir("ttl_exp");
fs::create_dir_all(&dir).unwrap();
let cache = Cache::new(dir.to_str().unwrap().into())
.unwrap()
.with_ttl(Duration::from_millis(1), Duration::from_secs(5));
cache.initialize().unwrap();
let file_path = dir.join(".cvmfspublished");
fs::write(&file_path, b"old metadata").unwrap();
std::thread::sleep(Duration::from_millis(5));
assert!(cache.get(".cvmfspublished").is_none());
assert!(!file_path.exists());
fs::remove_dir_all(&dir).ok();
}
#[test]
fn cache_data_objects_never_expire() {
let dir = tmp_cache_dir("data_no_exp");
fs::create_dir_all(&dir).unwrap();
let cache = Cache::new(dir.to_str().unwrap().into())
.unwrap()
.with_ttl(Duration::from_millis(1), Duration::from_secs(5));
cache.initialize().unwrap();
let file_path = dir.join("data/ab/deadbeef1234");
fs::write(&file_path, b"content-addressed data").unwrap();
std::thread::sleep(Duration::from_millis(5));
assert!(cache.get("data/ab/deadbeef1234").is_some());
assert!(file_path.exists());
fs::remove_dir_all(&dir).ok();
}
#[test]
fn cache_evict_clears_negative_entries() {
let dir = tmp_cache_dir("evict_neg");
fs::create_dir_all(&dir).unwrap();
let cache = Cache::new(dir.to_str().unwrap().into()).unwrap();
cache.initialize().unwrap();
cache.record_negative("data/ab/neg");
cache.evict().unwrap();
let file_path = dir.join("data/ab/neg");
fs::write(&file_path, b"data").unwrap();
assert!(cache.get("data/ab/neg").is_some());
fs::remove_dir_all(&dir).ok();
}
#[test]
fn cache_with_quota_builder() {
let dir = tmp_cache_dir("quota_builder");
fs::create_dir_all(&dir).unwrap();
let cache = Cache::new(dir.to_str().unwrap().into()).unwrap().with_quota(1024);
assert_eq!(cache.quota, 1024);
fs::remove_dir_all(&dir).ok();
}
#[test]
fn cache_with_ttl_builder() {
let dir = tmp_cache_dir("ttl_builder");
fs::create_dir_all(&dir).unwrap();
let cache = Cache::new(dir.to_str().unwrap().into())
.unwrap()
.with_ttl(Duration::from_secs(120), Duration::from_secs(10));
assert_eq!(cache.ttl, Duration::from_secs(120));
assert_eq!(cache.negative_ttl, Duration::from_secs(10));
fs::remove_dir_all(&dir).ok();
}
#[test]
fn cache_size_empty() {
let dir = tmp_cache_dir("size_empty");
fs::create_dir_all(&dir).unwrap();
let cache = Cache::new(dir.to_str().unwrap().into()).unwrap();
cache.initialize().unwrap();
assert_eq!(cache.cache_size(), 0);
fs::remove_dir_all(&dir).ok();
}
#[test]
fn cache_size_with_files() {
let dir = tmp_cache_dir("size_files");
fs::create_dir_all(&dir).unwrap();
let cache = Cache::new(dir.to_str().unwrap().into()).unwrap();
cache.initialize().unwrap();
fs::write(dir.join("data/ab/file1"), vec![0u8; 100]).unwrap();
fs::write(dir.join("data/cd/file2"), vec![0u8; 200]).unwrap();
assert_eq!(cache.cache_size(), 300);
fs::remove_dir_all(&dir).ok();
}
#[test]
fn enforce_quota_under_limit_noop() {
let dir = tmp_cache_dir("quota_under");
fs::create_dir_all(&dir).unwrap();
let cache = Cache::new(dir.to_str().unwrap().into()).unwrap().with_quota(10_000);
cache.initialize().unwrap();
fs::write(dir.join("data/ab/file1"), vec![0u8; 100]).unwrap();
cache.enforce_quota().unwrap();
assert!(dir.join("data/ab/file1").exists());
fs::remove_dir_all(&dir).ok();
}
#[test]
fn enforce_quota_over_limit_evicts() {
let dir = tmp_cache_dir("quota_over");
fs::create_dir_all(&dir).unwrap();
let cache = Cache::new(dir.to_str().unwrap().into()).unwrap().with_quota(100);
cache.initialize().unwrap();
fs::write(dir.join("data/ab/file1"), vec![0u8; 200]).unwrap();
fs::write(dir.join("data/cd/file2"), vec![0u8; 200]).unwrap();
let size_before = cache.cache_size();
assert!(size_before > 100);
cache.enforce_quota().unwrap();
let size_after = cache.cache_size();
assert!(size_after <= 100);
fs::remove_dir_all(&dir).ok();
}
#[test]
fn collect_files_by_atime_finds_files() {
let dir = tmp_cache_dir("collect");
fs::create_dir_all(&dir).unwrap();
let cache = Cache::new(dir.to_str().unwrap().into()).unwrap();
cache.initialize().unwrap();
fs::write(dir.join("data/ab/f1"), b"a").unwrap();
fs::write(dir.join("data/cd/f2"), b"bb").unwrap();
let data_path = dir.join("data");
let files = Cache::collect_files_by_atime(&data_path);
assert_eq!(files.len(), 2);
fs::remove_dir_all(&dir).ok();
}
#[test]
fn dir_size_empty_dir() {
let dir = tmp_cache_dir("dir_size_empty");
fs::create_dir_all(&dir).unwrap();
assert_eq!(Cache::dir_size(&dir), 0);
fs::remove_dir_all(&dir).ok();
}
#[test]
fn dir_size_nonexistent() {
let dir = PathBuf::from("/tmp/cvmfs_nonexistent_dir_size_test");
assert_eq!(Cache::dir_size(&dir), 0);
}
#[test]
fn cache_evict_clears_and_recreates() {
let dir = tmp_cache_dir("evict");
fs::create_dir_all(&dir).unwrap();
let cache = Cache::new(dir.to_str().unwrap().into()).unwrap();
cache.initialize().unwrap();
let file_path = dir.join("data/ab/toevict");
fs::write(&file_path, b"data").unwrap();
assert!(file_path.is_file());
cache.evict().unwrap();
assert!(!file_path.is_file());
assert!(dir.join("data").is_dir());
assert!(dir.join("data/ab").is_dir());
fs::remove_dir_all(&dir).ok();
}
}