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 config
71 .validate(crate::env::is_production())
72 .map_err(SecretsError::ConfigError)?;
73 let cache_dir = if config.enabled {
74 let dir = config.directory.clone().unwrap_or_else(|| {
75 dirs::cache_dir()
77 .unwrap_or_else(|| PathBuf::from("/tmp"))
78 .join("hyperi-rustlib")
79 .join("secrets")
80 });
81
82 ensure_dir_private(&dir, config.dir_mode)?;
86
87 if config.encryption_key.is_none() {
90 if config.allow_plaintext_disk_cache {
91 warn!(
92 directory = %dir.display(),
93 "secrets disk cache is writing UNENCRYPTED secrets \
94 (allow_plaintext_disk_cache=true, no encryption_key) -- \
95 configure an encryption_key; this is rejected in production"
96 );
97 } else {
98 debug!(
99 directory = %dir.display(),
100 "secrets disk cache is memory-only (no encryption_key); \
101 set encryption_key to persist secrets encrypted"
102 );
103 }
104 }
105 Some(dir)
106 } else {
107 None
108 };
109
110 Ok(Self {
111 memory: HashMap::new(),
112 cache_dir,
113 config: config.clone(),
114 hits: AtomicU64::new(0),
115 misses: AtomicU64::new(0),
116 stale_hits: AtomicU64::new(0),
117 })
118 }
119
120 pub fn get(&self, key: &str) -> Option<SecretValue> {
124 if let Some(value) = self.memory.get(key)
126 && !value.is_expired(self.config.ttl_secs)
127 {
128 self.hits.fetch_add(1, Ordering::Relaxed);
129 debug!(key = %key, "Cache hit (memory)");
130 return Some(value.clone());
131 }
132
133 if let Some(value) = self.load_from_disk(key)
135 && !value.is_expired(self.config.ttl_secs)
136 {
137 self.hits.fetch_add(1, Ordering::Relaxed);
138 debug!(key = %key, "Cache hit (disk)");
139 return Some(value);
140 }
141
142 self.misses.fetch_add(1, Ordering::Relaxed);
143 None
144 }
145
146 pub fn get_stale(&self, key: &str) -> Option<SecretValue> {
150 if let Some(value) = self.memory.get(key)
152 && value.is_within_grace(self.config.ttl_secs, self.config.stale_grace_secs)
153 {
154 self.stale_hits.fetch_add(1, Ordering::Relaxed);
155 debug!(key = %key, "Stale cache hit (memory)");
156 return Some(value.clone());
157 }
158
159 if let Some(value) = self.load_from_disk(key)
161 && value.is_within_grace(self.config.ttl_secs, self.config.stale_grace_secs)
162 {
163 self.stale_hits.fetch_add(1, Ordering::Relaxed);
164 debug!(key = %key, "Stale cache hit (disk)");
165 return Some(value);
166 }
167
168 None
169 }
170
171 pub fn set(&mut self, key: &str, value: &SecretValue) -> SecretsResult<()> {
177 if !self.config.enabled {
178 return Ok(());
179 }
180
181 self.memory.insert(key.to_string(), value.clone());
183
184 self.save_to_disk(key, value)?;
186
187 debug!(key = %key, "Secret cached");
188 Ok(())
189 }
190
191 pub fn clear(&mut self) {
193 self.memory.clear();
194 if let Some(ref dir) = self.cache_dir {
195 if let Err(e) = std::fs::remove_dir_all(dir) {
196 warn!(error = %e, "Failed to clear disk cache");
197 }
198 if let Err(e) = ensure_dir_private(dir, self.config.dir_mode) {
201 warn!(error = %e, "Failed to restore cache directory perms");
202 }
203 }
204 }
205
206 pub fn stats(&self) -> CacheStats {
208 let disk_entries = self
209 .cache_dir
210 .as_ref()
211 .and_then(|dir| std::fs::read_dir(dir).ok())
212 .map_or(0, |entries| entries.count());
213
214 CacheStats {
215 memory_entries: self.memory.len(),
216 disk_entries,
217 hits: self.hits.load(Ordering::Relaxed),
218 misses: self.misses.load(Ordering::Relaxed),
219 stale_hits: self.stale_hits.load(Ordering::Relaxed),
220 }
221 }
222
223 fn load_from_disk(&self, key: &str) -> Option<SecretValue> {
234 let cache_dir = self.cache_dir.as_ref()?;
235 let cache_file = cache_dir.join(Self::key_to_filename(key));
236
237 if !cache_file.exists() {
238 return None;
239 }
240
241 let raw = std::fs::read(&cache_file).ok()?;
242
243 let entry_bytes = if crypto::Envelope::looks_like(&raw) {
247 let Some(ref user_key) = self.config.encryption_key else {
248 tracing::warn!(
249 file = %cache_file.display(),
250 "cache file is encrypted but no encryption_key configured -- skipping",
251 );
252 return None;
253 };
254 match crypto::open(user_key.expose(), &raw, &crypto::aad_for(key)) {
255 Ok(plain) => plain,
256 Err(e) => {
257 tracing::warn!(
258 file = %cache_file.display(),
259 error = %e,
260 "cache file decrypt failed -- skipping",
261 );
262 return None;
263 }
264 }
265 } else {
266 if self.config.encryption_key.is_some() {
270 tracing::warn!(
271 file = %cache_file.display(),
272 "cache file is plaintext but encryption_key is set -- will be re-encrypted on next refresh",
273 );
274 }
275 raw
276 };
277
278 let entry: CacheEntry = serde_json::from_slice(&entry_bytes).ok()?;
279 entry.to_value().ok()
280 }
281
282 fn save_to_disk(&self, key: &str, value: &SecretValue) -> SecretsResult<()> {
291 let Some(ref cache_dir) = self.cache_dir else {
292 return Ok(());
293 };
294
295 let cache_file = cache_dir.join(Self::key_to_filename(key));
296 let entry = CacheEntry::from_value(value);
297
298 let plaintext = serde_json::to_vec(&entry).map_err(|e| {
299 SecretsError::CacheError(format!("failed to serialize cache entry: {e}"))
300 })?;
301
302 let payload: Vec<u8> = if let Some(ref user_key) = self.config.encryption_key {
303 crypto::seal(user_key.expose(), &plaintext, &crypto::aad_for(key))?.into_bytes()
304 } else if self.config.allow_plaintext_disk_cache {
305 plaintext
308 } else {
309 debug!(
313 key = %key,
314 "skipping disk cache write: no encryption_key and \
315 allow_plaintext_disk_cache=false (memory-only)"
316 );
317 return Ok(());
318 };
319
320 write_private_file_atomic(&cache_file, &payload, self.config.file_mode)?;
321 Ok(())
322 }
323
324 fn key_to_filename(key: &str) -> String {
326 use base64::Engine;
327 let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(key);
328 format!("{encoded}.json")
329 }
330}
331
332fn ensure_dir_private(dir: &std::path::Path, mode: Option<u32>) -> SecretsResult<()> {
336 std::fs::create_dir_all(dir).map_err(|e| {
337 SecretsError::CacheError(format!(
338 "failed to create cache directory {}: {e}",
339 dir.display()
340 ))
341 })?;
342 #[cfg(unix)]
343 if let Some(m) = mode {
344 use std::os::unix::fs::PermissionsExt;
345 std::fs::set_permissions(dir, std::fs::Permissions::from_mode(m)).map_err(|e| {
346 SecretsError::CacheError(format!(
347 "failed to set cache directory permissions on {}: {e}",
348 dir.display()
349 ))
350 })?;
351 }
352 Ok(())
353}
354
355fn write_private_file_atomic(
359 path: &std::path::Path,
360 bytes: &[u8],
361 mode: Option<u32>,
362) -> SecretsResult<()> {
363 let temp_path = path.with_extension("json.tmp");
364 std::fs::write(&temp_path, bytes).map_err(|e| {
365 SecretsError::CacheError(format!(
366 "failed to write cache temp {}: {e}",
367 temp_path.display()
368 ))
369 })?;
370 #[cfg(unix)]
371 if let Some(m) = mode {
372 use std::os::unix::fs::PermissionsExt;
373 std::fs::set_permissions(&temp_path, std::fs::Permissions::from_mode(m)).map_err(|e| {
374 SecretsError::CacheError(format!(
375 "failed to set cache file permissions on {}: {e}",
376 temp_path.display()
377 ))
378 })?;
379 }
380 std::fs::rename(&temp_path, path).map_err(|e| {
381 SecretsError::CacheError(format!(
382 "failed to rename cache temp into place {}: {e}",
383 path.display()
384 ))
385 })?;
386 Ok(())
387}
388
389#[cfg(test)]
390mod tests {
391 use super::*;
392
393 fn test_config() -> CacheConfig {
394 let temp_dir = tempfile::tempdir().unwrap();
395 let path = temp_dir.path().to_path_buf();
396 std::mem::forget(temp_dir);
398 CacheConfig {
399 enabled: true,
400 directory: Some(path),
401 ttl_secs: 3600,
402 stale_grace_secs: 86400,
403 refresh_interval_secs: 1800,
404 refresh_jitter_secs: 300,
405 encryption_key: None,
406 allow_plaintext_disk_cache: true,
409 dir_mode: Some(0o700),
410 file_mode: Some(0o600),
411 }
412 }
413
414 #[test]
415 fn test_cache_new() {
416 let config = test_config();
417 let cache = SecretCache::new(&config);
418 assert!(cache.is_ok());
419 }
420
421 #[test]
422 fn test_cache_disabled() {
423 let config = CacheConfig {
424 enabled: false,
425 ..Default::default()
426 };
427 let cache = SecretCache::new(&config).unwrap();
428 assert!(cache.cache_dir.is_none());
429 }
430
431 #[test]
432 fn test_cache_set_get() {
433 let config = test_config();
434 let mut cache = SecretCache::new(&config).unwrap();
435
436 let value = SecretValue::new(b"secret-data".to_vec());
437 cache.set("test-key", &value).unwrap();
438
439 let retrieved = cache.get("test-key");
440 assert!(retrieved.is_some());
441 assert_eq!(retrieved.unwrap().as_bytes(), b"secret-data");
442 }
443
444 #[test]
445 fn test_cache_miss() {
446 let config = test_config();
447 let cache = SecretCache::new(&config).unwrap();
448
449 let retrieved = cache.get("nonexistent");
450 assert!(retrieved.is_none());
451 }
452
453 #[test]
454 fn test_cache_disk_persistence() {
455 let config = test_config();
456
457 {
459 let mut cache = SecretCache::new(&config).unwrap();
460 let value = SecretValue::new(b"persistent-secret".to_vec());
461 cache.set("persist-key", &value).unwrap();
462 }
463
464 {
466 let cache = SecretCache::new(&config).unwrap();
467 let retrieved = cache.get("persist-key");
468 assert!(retrieved.is_some());
469 assert_eq!(retrieved.unwrap().as_bytes(), b"persistent-secret");
470 }
471 }
472
473 #[test]
474 fn test_cache_stale_fallback() {
475 let config = CacheConfig {
476 ttl_secs: 0, stale_grace_secs: 86400, ..test_config()
479 };
480 let mut cache = SecretCache::new(&config).unwrap();
481
482 let value = SecretValue::new(b"stale-secret".to_vec());
483 cache.set("stale-key", &value).unwrap();
484
485 assert!(cache.get("stale-key").is_none());
487
488 let stale = cache.get_stale("stale-key");
490 assert!(stale.is_some());
491 assert_eq!(stale.unwrap().as_bytes(), b"stale-secret");
492 }
493
494 #[test]
495 fn test_cache_clear() {
496 let config = test_config();
497 let mut cache = SecretCache::new(&config).unwrap();
498
499 let value = SecretValue::new(b"secret".to_vec());
500 cache.set("key1", &value).unwrap();
501 cache.set("key2", &value).unwrap();
502
503 cache.clear();
504
505 assert!(cache.get("key1").is_none());
506 assert!(cache.get("key2").is_none());
507 assert_eq!(cache.stats().memory_entries, 0);
508 }
509
510 #[test]
511 fn test_cache_stats() {
512 let config = test_config();
513 let mut cache = SecretCache::new(&config).unwrap();
514
515 let value = SecretValue::new(b"secret".to_vec());
516 cache.set("key", &value).unwrap();
517
518 let _ = cache.get("key");
520 let _ = cache.get("nonexistent");
522
523 let stats = cache.stats();
524 assert_eq!(stats.memory_entries, 1);
525 assert_eq!(stats.hits, 1);
526 assert_eq!(stats.misses, 1);
527 }
528
529 #[test]
530 fn test_key_to_filename() {
531 let filename = SecretCache::key_to_filename("test/key:with/special");
532 assert!(
533 std::path::Path::new(&filename)
534 .extension()
535 .is_some_and(|ext| ext.eq_ignore_ascii_case("json"))
536 );
537 assert!(!filename.contains('/'));
538 assert!(!filename.contains(':'));
539 }
540
541 #[cfg(unix)]
544 #[test]
545 fn dir_mode_none_skips_chmod() {
546 use std::os::unix::fs::PermissionsExt;
547 let temp_dir = tempfile::tempdir().unwrap();
548 let cfg = CacheConfig {
549 enabled: true,
550 directory: Some(temp_dir.path().to_path_buf()),
551 dir_mode: None,
552 file_mode: None,
553 ..Default::default()
554 };
555 std::fs::set_permissions(temp_dir.path(), std::fs::Permissions::from_mode(0o755)).unwrap();
558 let _cache = SecretCache::new(&cfg).unwrap();
559 let mode = std::fs::metadata(temp_dir.path())
560 .unwrap()
561 .permissions()
562 .mode()
563 & 0o7777;
564 assert_eq!(mode, 0o755, "dir_mode: None must skip chmod");
565 }
566
567 #[cfg(unix)]
570 #[test]
571 fn cache_directory_and_files_stay_private_after_clear() {
572 use crate::secrets::types::SecretValue;
573 use std::os::unix::fs::PermissionsExt;
574
575 let temp_dir = tempfile::tempdir().unwrap();
576 let cfg = CacheConfig {
577 enabled: true,
578 directory: Some(temp_dir.path().to_path_buf()),
579 allow_plaintext_disk_cache: true,
581 ..Default::default()
582 };
583 let mut cache = SecretCache::new(&cfg).unwrap();
584 let dir = cache.cache_dir.as_ref().unwrap().clone();
585
586 let mode_after_new = std::fs::metadata(&dir).unwrap().permissions().mode() & 0o7777;
587 assert_eq!(mode_after_new, 0o700);
588
589 cache.set("k", &SecretValue::new(b"v".to_vec())).unwrap();
590
591 let cache_file = dir.join(SecretCache::key_to_filename("k"));
592 let file_mode = std::fs::metadata(&cache_file).unwrap().permissions().mode() & 0o7777;
593 assert_eq!(file_mode, 0o600);
594
595 cache.clear();
596 let mode_after_clear = std::fs::metadata(&dir).unwrap().permissions().mode() & 0o7777;
597 assert_eq!(mode_after_clear, 0o700);
598
599 cache.set("k2", &SecretValue::new(b"v".to_vec())).unwrap();
600 let post_clear_file_mode = std::fs::metadata(dir.join(SecretCache::key_to_filename("k2")))
601 .unwrap()
602 .permissions()
603 .mode()
604 & 0o7777;
605 assert_eq!(post_clear_file_mode, 0o600);
606 }
607
608 #[test]
609 fn default_never_writes_plaintext_to_disk() {
610 let temp_dir = tempfile::tempdir().unwrap();
612 let dir = temp_dir.path().to_path_buf();
613 let cfg = CacheConfig {
614 enabled: true,
615 directory: Some(dir.clone()),
616 encryption_key: None,
617 allow_plaintext_disk_cache: false, ..Default::default()
619 };
620 let mut cache = SecretCache::new(&cfg).unwrap();
621 cache
622 .set("k", &SecretValue::new(b"plaintext-secret".to_vec()))
623 .unwrap();
624
625 assert_eq!(
627 cache.get("k").unwrap().as_bytes(),
628 b"plaintext-secret",
629 "memory tier still works"
630 );
631 let cache_file = dir.join(SecretCache::key_to_filename("k"));
633 assert!(
634 !cache_file.exists(),
635 "default config must not persist plaintext secrets to disk"
636 );
637 }
638
639 #[test]
640 fn plaintext_disk_requires_explicit_opt_in() {
641 let temp_dir = tempfile::tempdir().unwrap();
642 let dir = temp_dir.path().to_path_buf();
643 let cfg = CacheConfig {
644 enabled: true,
645 directory: Some(dir.clone()),
646 encryption_key: None,
647 allow_plaintext_disk_cache: true, ..Default::default()
649 };
650 let mut cache = SecretCache::new(&cfg).unwrap();
651 cache
652 .set("k", &SecretValue::new(b"plaintext-secret".to_vec()))
653 .unwrap();
654
655 let cache_file = dir.join(SecretCache::key_to_filename("k"));
656 assert!(cache_file.exists(), "opt-in must persist to disk");
657
658 let fresh = SecretCache::new(&cfg).unwrap();
662 assert_eq!(
663 fresh.get("k").unwrap().as_bytes(),
664 b"plaintext-secret",
665 "plaintext entry is readable from disk without a key"
666 );
667 }
668}