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