use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
use tracing::{debug, warn};
use super::CacheStats;
use super::error::{SecretsError, SecretsResult};
use super::types::{CacheConfig, CacheEntry, SecretValue};
pub struct SecretCache {
memory: HashMap<String, SecretValue>,
cache_dir: Option<PathBuf>,
config: CacheConfig,
hits: AtomicU64,
misses: AtomicU64,
stale_hits: AtomicU64,
}
impl SecretCache {
pub fn new(config: &CacheConfig) -> SecretsResult<Self> {
let cache_dir = if config.enabled {
let dir = config.directory.clone().unwrap_or_else(|| {
dirs::cache_dir()
.unwrap_or_else(|| PathBuf::from("/tmp"))
.join("hyperi-rustlib")
.join("secrets")
});
if !dir.exists() {
std::fs::create_dir_all(&dir).map_err(|e| {
SecretsError::CacheError(format!(
"failed to create cache directory {}: {e}",
dir.display()
))
})?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700))
.map_err(|e| {
SecretsError::CacheError(format!(
"failed to set cache directory permissions on {}: {e}",
dir.display()
))
})?;
}
}
Some(dir)
} else {
None
};
Ok(Self {
memory: HashMap::new(),
cache_dir,
config: config.clone(),
hits: AtomicU64::new(0),
misses: AtomicU64::new(0),
stale_hits: AtomicU64::new(0),
})
}
pub fn get(&self, key: &str) -> Option<SecretValue> {
if let Some(value) = self.memory.get(key)
&& !value.is_expired(self.config.ttl_secs)
{
self.hits.fetch_add(1, Ordering::Relaxed);
debug!(key = %key, "Cache hit (memory)");
return Some(value.clone());
}
if let Some(value) = self.load_from_disk(key)
&& !value.is_expired(self.config.ttl_secs)
{
self.hits.fetch_add(1, Ordering::Relaxed);
debug!(key = %key, "Cache hit (disk)");
return Some(value);
}
self.misses.fetch_add(1, Ordering::Relaxed);
None
}
pub fn get_stale(&self, key: &str) -> Option<SecretValue> {
if let Some(value) = self.memory.get(key)
&& value.is_within_grace(self.config.ttl_secs, self.config.stale_grace_secs)
{
self.stale_hits.fetch_add(1, Ordering::Relaxed);
debug!(key = %key, "Stale cache hit (memory)");
return Some(value.clone());
}
if let Some(value) = self.load_from_disk(key)
&& value.is_within_grace(self.config.ttl_secs, self.config.stale_grace_secs)
{
self.stale_hits.fetch_add(1, Ordering::Relaxed);
debug!(key = %key, "Stale cache hit (disk)");
return Some(value);
}
None
}
pub fn set(&mut self, key: &str, value: &SecretValue) -> SecretsResult<()> {
if !self.config.enabled {
return Ok(());
}
self.memory.insert(key.to_string(), value.clone());
self.save_to_disk(key, value)?;
debug!(key = %key, "Secret cached");
Ok(())
}
pub fn clear(&mut self) {
self.memory.clear();
if let Some(ref dir) = self.cache_dir {
if let Err(e) = std::fs::remove_dir_all(dir) {
warn!(error = %e, "Failed to clear disk cache");
}
let _ = std::fs::create_dir_all(dir);
}
}
pub fn stats(&self) -> CacheStats {
let disk_entries = self
.cache_dir
.as_ref()
.and_then(|dir| std::fs::read_dir(dir).ok())
.map_or(0, |entries| entries.count());
CacheStats {
memory_entries: self.memory.len(),
disk_entries,
hits: self.hits.load(Ordering::Relaxed),
misses: self.misses.load(Ordering::Relaxed),
stale_hits: self.stale_hits.load(Ordering::Relaxed),
}
}
fn load_from_disk(&self, key: &str) -> Option<SecretValue> {
let cache_dir = self.cache_dir.as_ref()?;
let cache_file = cache_dir.join(Self::key_to_filename(key));
if !cache_file.exists() {
return None;
}
let content = std::fs::read_to_string(&cache_file).ok()?;
let entry: CacheEntry = serde_json::from_str(&content).ok()?;
entry.to_value().ok()
}
fn save_to_disk(&self, key: &str, value: &SecretValue) -> SecretsResult<()> {
let Some(ref cache_dir) = self.cache_dir else {
return Ok(());
};
let cache_file = cache_dir.join(Self::key_to_filename(key));
let entry = CacheEntry::from_value(value);
let content = serde_json::to_string_pretty(&entry).map_err(|e| {
SecretsError::CacheError(format!("failed to serialize cache entry: {e}"))
})?;
std::fs::write(&cache_file, content).map_err(|e| {
SecretsError::CacheError(format!(
"failed to write cache file {}: {e}",
cache_file.display()
))
})?;
Ok(())
}
fn key_to_filename(key: &str) -> String {
use base64::Engine;
let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(key);
format!("{encoded}.json")
}
}
#[cfg(test)]
mod tests {
use super::*;
fn test_config() -> CacheConfig {
let temp_dir = tempfile::tempdir().unwrap();
let path = temp_dir.path().to_path_buf();
std::mem::forget(temp_dir);
CacheConfig {
enabled: true,
directory: Some(path),
ttl_secs: 3600,
stale_grace_secs: 86400,
refresh_interval_secs: 1800,
refresh_jitter_secs: 300,
encryption_key: None,
}
}
#[test]
fn test_cache_new() {
let config = test_config();
let cache = SecretCache::new(&config);
assert!(cache.is_ok());
}
#[test]
fn test_cache_disabled() {
let config = CacheConfig {
enabled: false,
..Default::default()
};
let cache = SecretCache::new(&config).unwrap();
assert!(cache.cache_dir.is_none());
}
#[test]
fn test_cache_set_get() {
let config = test_config();
let mut cache = SecretCache::new(&config).unwrap();
let value = SecretValue::new(b"secret-data".to_vec());
cache.set("test-key", &value).unwrap();
let retrieved = cache.get("test-key");
assert!(retrieved.is_some());
assert_eq!(retrieved.unwrap().as_bytes(), b"secret-data");
}
#[test]
fn test_cache_miss() {
let config = test_config();
let cache = SecretCache::new(&config).unwrap();
let retrieved = cache.get("nonexistent");
assert!(retrieved.is_none());
}
#[test]
fn test_cache_disk_persistence() {
let config = test_config();
{
let mut cache = SecretCache::new(&config).unwrap();
let value = SecretValue::new(b"persistent-secret".to_vec());
cache.set("persist-key", &value).unwrap();
}
{
let cache = SecretCache::new(&config).unwrap();
let retrieved = cache.get("persist-key");
assert!(retrieved.is_some());
assert_eq!(retrieved.unwrap().as_bytes(), b"persistent-secret");
}
}
#[test]
fn test_cache_stale_fallback() {
let config = CacheConfig {
ttl_secs: 0, stale_grace_secs: 86400, ..test_config()
};
let mut cache = SecretCache::new(&config).unwrap();
let value = SecretValue::new(b"stale-secret".to_vec());
cache.set("stale-key", &value).unwrap();
assert!(cache.get("stale-key").is_none());
let stale = cache.get_stale("stale-key");
assert!(stale.is_some());
assert_eq!(stale.unwrap().as_bytes(), b"stale-secret");
}
#[test]
fn test_cache_clear() {
let config = test_config();
let mut cache = SecretCache::new(&config).unwrap();
let value = SecretValue::new(b"secret".to_vec());
cache.set("key1", &value).unwrap();
cache.set("key2", &value).unwrap();
cache.clear();
assert!(cache.get("key1").is_none());
assert!(cache.get("key2").is_none());
assert_eq!(cache.stats().memory_entries, 0);
}
#[test]
fn test_cache_stats() {
let config = test_config();
let mut cache = SecretCache::new(&config).unwrap();
let value = SecretValue::new(b"secret".to_vec());
cache.set("key", &value).unwrap();
let _ = cache.get("key");
let _ = cache.get("nonexistent");
let stats = cache.stats();
assert_eq!(stats.memory_entries, 1);
assert_eq!(stats.hits, 1);
assert_eq!(stats.misses, 1);
}
#[test]
fn test_key_to_filename() {
let filename = SecretCache::key_to_filename("test/key:with/special");
assert!(
std::path::Path::new(&filename)
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("json"))
);
assert!(!filename.contains('/'));
assert!(!filename.contains(':'));
}
}