use std::cell::RefCell; use std::fs; use std::io::Write; use std::path::{Path, PathBuf}; use std::time::{Duration, SystemTime}; /// Diagnostic entry for a single cache lookup. #[derive(Debug, Clone)] pub struct CacheDiag { pub key: String, pub hit: bool, pub age_ms: Option, } pub struct Cache { dir: Option, jitter_pct: u8, diagnostics: RefCell>, } impl Cache { /// Create a disabled cache where all operations are no-ops. /// Used for --no-cache mode. pub fn disabled() -> Self { Self { dir: None, jitter_pct: 0, diagnostics: RefCell::new(Vec::new()), } } /// Create cache with secure directory. Returns disabled cache on failure. /// Replaces `{session_id}`, `{cache_version}`, and `{config_hash}` in template. pub fn new( template: &str, session_id: &str, cache_version: u32, config_hash: &str, jitter_pct: u8, ) -> Self { let dir_str = template .replace("{session_id}", session_id) .replace("{cache_version}", &cache_version.to_string()) .replace("{config_hash}", config_hash); let dir = PathBuf::from(&dir_str); if !dir.exists() { if fs::create_dir_all(&dir).is_err() { return Self { dir: None, jitter_pct, diagnostics: RefCell::new(Vec::new()), }; } #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let _ = fs::set_permissions(&dir, fs::Permissions::from_mode(0o700)); } } // Security: verify ownership, not a symlink, not world-writable if !verify_cache_dir(&dir) { return Self { dir: None, jitter_pct, diagnostics: RefCell::new(Vec::new()), }; } Self { dir: Some(dir), jitter_pct, diagnostics: RefCell::new(Vec::new()), } } pub fn dir(&self) -> Option<&Path> { self.dir.as_deref() } /// Get cached value if fresher than TTL (with per-key jitter applied). pub fn get(&self, key: &str, ttl: Duration) -> Option { let path = match self.key_path(key) { Some(p) => p, None => { self.record_diag(key, false, None); return None; } }; let meta = match fs::metadata(&path).ok() { Some(m) => m, None => { self.record_diag(key, false, None); return None; } }; let modified = match meta.modified().ok() { Some(m) => m, None => { self.record_diag(key, false, None); return None; } }; let age = match SystemTime::now().duration_since(modified).ok() { Some(a) => a, None => { self.record_diag(key, false, None); return None; } }; let age_ms = age.as_millis() as u64; let effective_ttl = self.jittered_ttl(key, ttl); if age < effective_ttl { let value = fs::read_to_string(&path).ok(); self.record_diag(key, value.is_some(), Some(age_ms)); value } else { self.record_diag(key, false, Some(age_ms)); None } } fn record_diag(&self, key: &str, hit: bool, age_ms: Option) { if let Ok(mut diags) = self.diagnostics.try_borrow_mut() { diags.push(CacheDiag { key: key.to_string(), hit, age_ms, }); } } /// Return collected cache diagnostics (for --dump-state). pub fn diagnostics(&self) -> Vec { self.diagnostics.borrow().clone() } /// Apply deterministic per-key jitter to TTL. /// Uses FNV-1a hash of key to produce stable jitter (same key = same jitter every time). fn jittered_ttl(&self, key: &str, base_ttl: Duration) -> Duration { if self.jitter_pct == 0 { return base_ttl; } // FNV-1a hash of key for deterministic per-key jitter let mut hash: u64 = 0xcbf2_9ce4_8422_2325; for byte in key.bytes() { hash ^= u64::from(byte); hash = hash.wrapping_mul(0x0100_0000_01b3); } // Map hash to range [-jitter_pct, +jitter_pct] let jitter_range = f64::from(self.jitter_pct) / 100.0; let normalized = (hash % 2001) as f64 / 1000.0 - 1.0; // [-1.0, 1.0] let multiplier = 1.0 + (normalized * jitter_range); let jittered_ms = (base_ttl.as_millis() as f64 * multiplier) as u64; // Clamp: minimum 100ms to avoid zero TTL Duration::from_millis(jittered_ms.max(100)) } /// Get stale cached value (ignores TTL). Used as fallback on command failure. pub fn get_stale(&self, key: &str) -> Option { let path = self.key_path(key)?; fs::read_to_string(&path).ok() } /// Atomic write: write to .tmp then rename (prevents partial reads). /// Uses flock with LOCK_NB — skips cache on contention rather than blocking. pub fn set(&self, key: &str, value: &str) -> Option<()> { let path = self.key_path(key)?; let tmp = path.with_extension("tmp"); // Try non-blocking flock let lock_path = path.with_extension("lock"); let lock_file = fs::File::create(&lock_path).ok()?; if !try_flock(&lock_file) { return None; // contention — skip cache write } let mut f = fs::File::create(&tmp).ok()?; f.write_all(value.as_bytes()).ok()?; fs::rename(&tmp, &path).ok()?; unlock(&lock_file); Some(()) } /// Remove cache entries matching a prefix (e.g., "trend_" to flush all trend data). pub fn flush_prefix(&self, prefix: &str) { let dir = match &self.dir { Some(d) => d, None => return, }; if let Ok(entries) = fs::read_dir(dir) { for entry in entries.flatten() { let name = entry.file_name(); let name_str = name.to_string_lossy(); if name_str.starts_with(prefix) { let _ = fs::remove_file(entry.path()); } } } } fn key_path(&self, key: &str) -> Option { let dir = self.dir.as_ref()?; let safe_key: String = key .chars() .map(|c| { if c.is_ascii_alphanumeric() || c == '_' || c == '-' { c } else { '_' } }) .collect(); Some(dir.join(safe_key)) } } fn verify_cache_dir(dir: &Path) -> bool { let meta = match fs::symlink_metadata(dir) { Ok(m) => m, Err(_) => return false, }; if !meta.is_dir() || meta.file_type().is_symlink() { return false; } #[cfg(unix)] { use std::os::unix::fs::MetadataExt; // Must be owned by current user if meta.uid() != unsafe { libc::getuid() } { return false; } // Must not be world-writable if meta.mode() & 0o002 != 0 { return false; } } true } fn try_flock(file: &fs::File) -> bool { #[cfg(unix)] { use std::os::unix::io::AsRawFd; let ret = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) }; ret == 0 } #[cfg(not(unix))] { let _ = file; true } } fn unlock(file: &fs::File) { #[cfg(unix)] { use std::os::unix::io::AsRawFd; unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_UN); } } #[cfg(not(unix))] { let _ = file; } } /// Session ID: first 12 chars of MD5 hex of project_dir. /// Same algorithm as bash for cache compatibility during migration. pub fn session_id(project_dir: &str) -> String { use md5::{Digest, Md5}; let hash = Md5::digest(project_dir.as_bytes()); format!("{:x}", hash)[..12].to_string() } /// Garbage-collect old cache directories. /// Runs at most once per `gc_interval_hours`. Deletes dirs older than `gc_days` /// that match /tmp/claude-sl-* and are owned by the current user. /// Never blocks: uses non-blocking flock on a sentinel file. pub fn gc(gc_days: u16, gc_interval_hours: u16) { let lock_path = Path::new("/tmp/claude-sl-gc.lock"); // Check interval: if lock file exists and is younger than gc_interval, skip if let Ok(meta) = fs::metadata(lock_path) { if let Ok(modified) = meta.modified() { if let Ok(age) = SystemTime::now().duration_since(modified) { if age < Duration::from_secs(u64::from(gc_interval_hours) * 3600) { return; } } } } // Try non-blocking lock let lock_file = match fs::File::create(lock_path) { Ok(f) => f, Err(_) => return, }; if !try_flock(&lock_file) { return; // another process is GC-ing } // Touch the lock file (create already set mtime to now) let max_age = Duration::from_secs(u64::from(gc_days) * 86400); let entries = match fs::read_dir("/tmp") { Ok(e) => e, Err(_) => { unlock(&lock_file); return; } }; let uid = current_uid(); for entry in entries.flatten() { let name = entry.file_name(); let name_str = name.to_string_lossy(); if !name_str.starts_with("claude-sl-") { continue; } let path = entry.path(); // Safety: skip symlinks let meta = match fs::symlink_metadata(&path) { Ok(m) => m, Err(_) => continue, }; if !meta.is_dir() || meta.file_type().is_symlink() { continue; } // Only delete dirs owned by current user #[cfg(unix)] { use std::os::unix::fs::MetadataExt; if meta.uid() != uid { continue; } } // Check age if let Ok(modified) = meta.modified() { if let Ok(age) = SystemTime::now().duration_since(modified) { if age > max_age { let _ = fs::remove_dir_all(&path); } } } } unlock(&lock_file); } fn current_uid() -> u32 { #[cfg(unix)] { unsafe { libc::getuid() } } #[cfg(not(unix))] { 0 } }