1use std::collections::HashMap;
34use std::path::PathBuf;
35use std::sync::atomic::{AtomicU64, Ordering};
36
37use tracing::{debug, warn};
38
39use super::CacheStats;
40use super::crypto;
41use super::error::{SecretsError, SecretsResult};
42use super::types::{CacheConfig, CacheEntry, SecretValue};
43
44pub struct SecretCache {
46 memory: HashMap<String, SecretValue>,
48
49 cache_dir: Option<PathBuf>,
51
52 config: CacheConfig,
54
55 hits: AtomicU64,
57 misses: AtomicU64,
58 stale_hits: AtomicU64,
59}
60
61impl SecretCache {
62 pub fn new(config: &CacheConfig) -> SecretsResult<Self> {
68 let cache_dir = if config.enabled {
69 let dir = config.directory.clone().unwrap_or_else(|| {
70 dirs::cache_dir()
72 .unwrap_or_else(|| PathBuf::from("/tmp"))
73 .join("hyperi-rustlib")
74 .join("secrets")
75 });
76
77 ensure_dir_private(&dir, config.dir_mode)?;
81 Some(dir)
82 } else {
83 None
84 };
85
86 Ok(Self {
87 memory: HashMap::new(),
88 cache_dir,
89 config: config.clone(),
90 hits: AtomicU64::new(0),
91 misses: AtomicU64::new(0),
92 stale_hits: AtomicU64::new(0),
93 })
94 }
95
96 pub fn get(&self, key: &str) -> Option<SecretValue> {
100 if let Some(value) = self.memory.get(key)
102 && !value.is_expired(self.config.ttl_secs)
103 {
104 self.hits.fetch_add(1, Ordering::Relaxed);
105 debug!(key = %key, "Cache hit (memory)");
106 return Some(value.clone());
107 }
108
109 if let Some(value) = self.load_from_disk(key)
111 && !value.is_expired(self.config.ttl_secs)
112 {
113 self.hits.fetch_add(1, Ordering::Relaxed);
114 debug!(key = %key, "Cache hit (disk)");
115 return Some(value);
116 }
117
118 self.misses.fetch_add(1, Ordering::Relaxed);
119 None
120 }
121
122 pub fn get_stale(&self, key: &str) -> Option<SecretValue> {
126 if let Some(value) = self.memory.get(key)
128 && value.is_within_grace(self.config.ttl_secs, self.config.stale_grace_secs)
129 {
130 self.stale_hits.fetch_add(1, Ordering::Relaxed);
131 debug!(key = %key, "Stale cache hit (memory)");
132 return Some(value.clone());
133 }
134
135 if let Some(value) = self.load_from_disk(key)
137 && value.is_within_grace(self.config.ttl_secs, self.config.stale_grace_secs)
138 {
139 self.stale_hits.fetch_add(1, Ordering::Relaxed);
140 debug!(key = %key, "Stale cache hit (disk)");
141 return Some(value);
142 }
143
144 None
145 }
146
147 pub fn set(&mut self, key: &str, value: &SecretValue) -> SecretsResult<()> {
153 if !self.config.enabled {
154 return Ok(());
155 }
156
157 self.memory.insert(key.to_string(), value.clone());
159
160 self.save_to_disk(key, value)?;
162
163 debug!(key = %key, "Secret cached");
164 Ok(())
165 }
166
167 pub fn clear(&mut self) {
169 self.memory.clear();
170 if let Some(ref dir) = self.cache_dir {
171 if let Err(e) = std::fs::remove_dir_all(dir) {
172 warn!(error = %e, "Failed to clear disk cache");
173 }
174 if let Err(e) = ensure_dir_private(dir, self.config.dir_mode) {
177 warn!(error = %e, "Failed to restore cache directory perms");
178 }
179 }
180 }
181
182 pub fn stats(&self) -> CacheStats {
184 let disk_entries = self
185 .cache_dir
186 .as_ref()
187 .and_then(|dir| std::fs::read_dir(dir).ok())
188 .map_or(0, |entries| entries.count());
189
190 CacheStats {
191 memory_entries: self.memory.len(),
192 disk_entries,
193 hits: self.hits.load(Ordering::Relaxed),
194 misses: self.misses.load(Ordering::Relaxed),
195 stale_hits: self.stale_hits.load(Ordering::Relaxed),
196 }
197 }
198
199 fn load_from_disk(&self, key: &str) -> Option<SecretValue> {
210 let cache_dir = self.cache_dir.as_ref()?;
211 let cache_file = cache_dir.join(Self::key_to_filename(key));
212
213 if !cache_file.exists() {
214 return None;
215 }
216
217 let raw = std::fs::read(&cache_file).ok()?;
218
219 let entry_bytes = if crypto::Envelope::looks_like(&raw) {
223 let Some(ref user_key) = self.config.encryption_key else {
224 tracing::warn!(
225 file = %cache_file.display(),
226 "cache file is encrypted but no encryption_key configured -- skipping",
227 );
228 return None;
229 };
230 match crypto::open(user_key.expose(), &raw, &crypto::aad_for(key)) {
231 Ok(plain) => plain,
232 Err(e) => {
233 tracing::warn!(
234 file = %cache_file.display(),
235 error = %e,
236 "cache file decrypt failed -- skipping",
237 );
238 return None;
239 }
240 }
241 } else {
242 if self.config.encryption_key.is_some() {
246 tracing::warn!(
247 file = %cache_file.display(),
248 "cache file is plaintext but encryption_key is set -- will be re-encrypted on next refresh",
249 );
250 }
251 raw
252 };
253
254 let entry: CacheEntry = serde_json::from_slice(&entry_bytes).ok()?;
255 entry.to_value().ok()
256 }
257
258 fn save_to_disk(&self, key: &str, value: &SecretValue) -> SecretsResult<()> {
268 let Some(ref cache_dir) = self.cache_dir else {
269 return Ok(());
270 };
271
272 let cache_file = cache_dir.join(Self::key_to_filename(key));
273 let entry = CacheEntry::from_value(value);
274
275 let plaintext = serde_json::to_vec(&entry).map_err(|e| {
276 SecretsError::CacheError(format!("failed to serialize cache entry: {e}"))
277 })?;
278
279 let payload: Vec<u8> = if let Some(ref user_key) = self.config.encryption_key {
280 crypto::seal(user_key.expose(), &plaintext, &crypto::aad_for(key))?.into_bytes()
281 } else {
282 plaintext
283 };
284
285 write_private_file_atomic(&cache_file, &payload, self.config.file_mode)?;
286 Ok(())
287 }
288
289 fn key_to_filename(key: &str) -> String {
291 use base64::Engine;
292 let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(key);
293 format!("{encoded}.json")
294 }
295}
296
297fn ensure_dir_private(dir: &std::path::Path, mode: Option<u32>) -> SecretsResult<()> {
301 std::fs::create_dir_all(dir).map_err(|e| {
302 SecretsError::CacheError(format!(
303 "failed to create cache directory {}: {e}",
304 dir.display()
305 ))
306 })?;
307 #[cfg(unix)]
308 if let Some(m) = mode {
309 use std::os::unix::fs::PermissionsExt;
310 std::fs::set_permissions(dir, std::fs::Permissions::from_mode(m)).map_err(|e| {
311 SecretsError::CacheError(format!(
312 "failed to set cache directory permissions on {}: {e}",
313 dir.display()
314 ))
315 })?;
316 }
317 Ok(())
318}
319
320fn write_private_file_atomic(
324 path: &std::path::Path,
325 bytes: &[u8],
326 mode: Option<u32>,
327) -> SecretsResult<()> {
328 let temp_path = path.with_extension("json.tmp");
329 std::fs::write(&temp_path, bytes).map_err(|e| {
330 SecretsError::CacheError(format!(
331 "failed to write cache temp {}: {e}",
332 temp_path.display()
333 ))
334 })?;
335 #[cfg(unix)]
336 if let Some(m) = mode {
337 use std::os::unix::fs::PermissionsExt;
338 std::fs::set_permissions(&temp_path, std::fs::Permissions::from_mode(m)).map_err(|e| {
339 SecretsError::CacheError(format!(
340 "failed to set cache file permissions on {}: {e}",
341 temp_path.display()
342 ))
343 })?;
344 }
345 std::fs::rename(&temp_path, path).map_err(|e| {
346 SecretsError::CacheError(format!(
347 "failed to rename cache temp into place {}: {e}",
348 path.display()
349 ))
350 })?;
351 Ok(())
352}
353
354#[cfg(test)]
355mod tests {
356 use super::*;
357
358 fn test_config() -> CacheConfig {
359 let temp_dir = tempfile::tempdir().unwrap();
360 let path = temp_dir.path().to_path_buf();
361 std::mem::forget(temp_dir);
363 CacheConfig {
364 enabled: true,
365 directory: Some(path),
366 ttl_secs: 3600,
367 stale_grace_secs: 86400,
368 refresh_interval_secs: 1800,
369 refresh_jitter_secs: 300,
370 encryption_key: None,
371 dir_mode: Some(0o700),
372 file_mode: Some(0o600),
373 }
374 }
375
376 #[test]
377 fn test_cache_new() {
378 let config = test_config();
379 let cache = SecretCache::new(&config);
380 assert!(cache.is_ok());
381 }
382
383 #[test]
384 fn test_cache_disabled() {
385 let config = CacheConfig {
386 enabled: false,
387 ..Default::default()
388 };
389 let cache = SecretCache::new(&config).unwrap();
390 assert!(cache.cache_dir.is_none());
391 }
392
393 #[test]
394 fn test_cache_set_get() {
395 let config = test_config();
396 let mut cache = SecretCache::new(&config).unwrap();
397
398 let value = SecretValue::new(b"secret-data".to_vec());
399 cache.set("test-key", &value).unwrap();
400
401 let retrieved = cache.get("test-key");
402 assert!(retrieved.is_some());
403 assert_eq!(retrieved.unwrap().as_bytes(), b"secret-data");
404 }
405
406 #[test]
407 fn test_cache_miss() {
408 let config = test_config();
409 let cache = SecretCache::new(&config).unwrap();
410
411 let retrieved = cache.get("nonexistent");
412 assert!(retrieved.is_none());
413 }
414
415 #[test]
416 fn test_cache_disk_persistence() {
417 let config = test_config();
418
419 {
421 let mut cache = SecretCache::new(&config).unwrap();
422 let value = SecretValue::new(b"persistent-secret".to_vec());
423 cache.set("persist-key", &value).unwrap();
424 }
425
426 {
428 let cache = SecretCache::new(&config).unwrap();
429 let retrieved = cache.get("persist-key");
430 assert!(retrieved.is_some());
431 assert_eq!(retrieved.unwrap().as_bytes(), b"persistent-secret");
432 }
433 }
434
435 #[test]
436 fn test_cache_stale_fallback() {
437 let config = CacheConfig {
438 ttl_secs: 0, stale_grace_secs: 86400, ..test_config()
441 };
442 let mut cache = SecretCache::new(&config).unwrap();
443
444 let value = SecretValue::new(b"stale-secret".to_vec());
445 cache.set("stale-key", &value).unwrap();
446
447 assert!(cache.get("stale-key").is_none());
449
450 let stale = cache.get_stale("stale-key");
452 assert!(stale.is_some());
453 assert_eq!(stale.unwrap().as_bytes(), b"stale-secret");
454 }
455
456 #[test]
457 fn test_cache_clear() {
458 let config = test_config();
459 let mut cache = SecretCache::new(&config).unwrap();
460
461 let value = SecretValue::new(b"secret".to_vec());
462 cache.set("key1", &value).unwrap();
463 cache.set("key2", &value).unwrap();
464
465 cache.clear();
466
467 assert!(cache.get("key1").is_none());
468 assert!(cache.get("key2").is_none());
469 assert_eq!(cache.stats().memory_entries, 0);
470 }
471
472 #[test]
473 fn test_cache_stats() {
474 let config = test_config();
475 let mut cache = SecretCache::new(&config).unwrap();
476
477 let value = SecretValue::new(b"secret".to_vec());
478 cache.set("key", &value).unwrap();
479
480 let _ = cache.get("key");
482 let _ = cache.get("nonexistent");
484
485 let stats = cache.stats();
486 assert_eq!(stats.memory_entries, 1);
487 assert_eq!(stats.hits, 1);
488 assert_eq!(stats.misses, 1);
489 }
490
491 #[test]
492 fn test_key_to_filename() {
493 let filename = SecretCache::key_to_filename("test/key:with/special");
494 assert!(
495 std::path::Path::new(&filename)
496 .extension()
497 .is_some_and(|ext| ext.eq_ignore_ascii_case("json"))
498 );
499 assert!(!filename.contains('/'));
500 assert!(!filename.contains(':'));
501 }
502
503 #[cfg(unix)]
506 #[test]
507 fn dir_mode_none_skips_chmod() {
508 use std::os::unix::fs::PermissionsExt;
509 let temp_dir = tempfile::tempdir().unwrap();
510 let cfg = CacheConfig {
511 enabled: true,
512 directory: Some(temp_dir.path().to_path_buf()),
513 dir_mode: None,
514 file_mode: None,
515 ..Default::default()
516 };
517 std::fs::set_permissions(temp_dir.path(), std::fs::Permissions::from_mode(0o755)).unwrap();
520 let _cache = SecretCache::new(&cfg).unwrap();
521 let mode = std::fs::metadata(temp_dir.path())
522 .unwrap()
523 .permissions()
524 .mode()
525 & 0o7777;
526 assert_eq!(mode, 0o755, "dir_mode: None must skip chmod");
527 }
528
529 #[cfg(unix)]
532 #[test]
533 fn cache_directory_and_files_stay_private_after_clear() {
534 use crate::secrets::types::SecretValue;
535 use std::os::unix::fs::PermissionsExt;
536
537 let temp_dir = tempfile::tempdir().unwrap();
538 let cfg = CacheConfig {
539 enabled: true,
540 directory: Some(temp_dir.path().to_path_buf()),
541 ..Default::default()
542 };
543 let mut cache = SecretCache::new(&cfg).unwrap();
544 let dir = cache.cache_dir.as_ref().unwrap().clone();
545
546 let mode_after_new = std::fs::metadata(&dir).unwrap().permissions().mode() & 0o7777;
547 assert_eq!(mode_after_new, 0o700);
548
549 cache.set("k", &SecretValue::new(b"v".to_vec())).unwrap();
550
551 let cache_file = dir.join(SecretCache::key_to_filename("k"));
552 let file_mode = std::fs::metadata(&cache_file).unwrap().permissions().mode() & 0o7777;
553 assert_eq!(file_mode, 0o600);
554
555 cache.clear();
556 let mode_after_clear = std::fs::metadata(&dir).unwrap().permissions().mode() & 0o7777;
557 assert_eq!(mode_after_clear, 0o700);
558
559 cache.set("k2", &SecretValue::new(b"v".to_vec())).unwrap();
560 let post_clear_file_mode = std::fs::metadata(dir.join(SecretCache::key_to_filename("k2")))
561 .unwrap()
562 .permissions()
563 .mode()
564 & 0o7777;
565 assert_eq!(post_clear_file_mode, 0o600);
566 }
567}