Skip to main content

hyperi_rustlib/secrets/
cache.rs

1// Project:   hyperi-rustlib
2// File:      src/secrets/cache.rs
3// Purpose:   Secret caching with disk persistence and stale fallback
4// Language:  Rust
5//
6// License:   BUSL-1.1
7// Copyright: (c) 2026 HYPERI PTY LIMITED
8
9//! Secret caching with disk persistence and stale fallback.
10//!
11//! The cache provides resilience when external providers are unavailable:
12//!
13//! ```text
14//! get_secret(key)
15//!     │
16//!     ├─ Check memory cache
17//!     │   └─ Hit + fresh → Return immediately
18//!     │
19//!     ├─ Check disk cache
20//!     │   └─ Hit + fresh → Update memory, return
21//!     │
22//!     └─ Return None (caller fetches from provider)
23//!
24//! get_stale(key)  // Called on provider failure
25//!     │
26//!     ├─ Check memory cache (within grace period)
27//!     │   └─ Hit → Return with warning
28//!     │
29//!     └─ Check disk cache (within grace period)
30//!         └─ Hit → Return with warning
31//! ```
32
33use 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
44/// Secret cache with memory and disk tiers.
45pub struct SecretCache {
46    /// In-memory cache.
47    memory: HashMap<String, SecretValue>,
48
49    /// Disk cache directory.
50    cache_dir: Option<PathBuf>,
51
52    /// Configuration.
53    config: CacheConfig,
54
55    /// Statistics.
56    hits: AtomicU64,
57    misses: AtomicU64,
58    stale_hits: AtomicU64,
59}
60
61impl SecretCache {
62    /// Create a new secret cache.
63    ///
64    /// # Errors
65    ///
66    /// Returns an error if the cache directory cannot be created.
67    pub fn new(config: &CacheConfig) -> SecretsResult<Self> {
68        // Enforce the production guardrail at construction: reject plaintext disk cache in prod here, not only when
69        // an app remembers to call validate() at startup.
70        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                // Auto-detect cache directory
76                dirs::cache_dir()
77                    .unwrap_or_else(|| PathBuf::from("/tmp"))
78                    .join("hyperi-rustlib")
79                    .join("secrets")
80            });
81
82            // Create if missing AND force the configured mode on
83            // every new(). F6: previously skipped chmod on existing
84            // dirs, leaving umask-default perms.
85            ensure_dir_private(&dir, config.dir_mode)?;
86
87            // Loud at startup when the disk tier would persist plaintext
88            // secrets, and a quiet note when it falls back to memory-only.
89            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    /// Get a fresh secret from cache.
121    ///
122    /// Returns `None` if not cached or expired.
123    pub fn get(&self, key: &str) -> Option<SecretValue> {
124        // Check memory cache
125        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        // Check disk cache
134        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    /// Get a stale secret from cache (for fallback on provider failure).
147    ///
148    /// Returns a cached value even if expired, as long as it's within the grace period.
149    pub fn get_stale(&self, key: &str) -> Option<SecretValue> {
150        // Check memory cache
151        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        // Check disk cache
160        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    /// Store a secret in cache.
172    ///
173    /// # Errors
174    ///
175    /// Returns an error if disk cache write fails.
176    pub fn set(&mut self, key: &str, value: &SecretValue) -> SecretsResult<()> {
177        if !self.config.enabled {
178            return Ok(());
179        }
180
181        // Store in memory
182        self.memory.insert(key.to_string(), value.clone());
183
184        // Store on disk
185        self.save_to_disk(key, value)?;
186
187        debug!(key = %key, "Secret cached");
188        Ok(())
189    }
190
191    /// Clear all cached secrets.
192    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            // F6: re-creating the dir without permissions falls back
199            // to umask. Force the configured mode on every recreate.
200            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    /// Get cache statistics.
207    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    /// Load a secret from disk cache.
224    ///
225    /// Detects encryption envelopes by their `"v":` JSON marker and
226    /// decrypts via [`crypto::open`] when an encryption key is
227    /// configured. Legacy plaintext entries (no envelope) are still
228    /// accepted but loaded with a warning -- operators upgrading from
229    /// pre-encryption deployments see one notice per file. A future
230    /// release will hard-reject legacy entries to force a clean
231    /// migration; for now we read-through so existing caches keep
232    /// working.
233    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        // Pick the load path based on (a) whether the file looks like
244        // an encrypted envelope, and (b) whether an encryption key is
245        // configured. This handles upgrades cleanly.
246        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            // Legacy plaintext path. Warn once per load to nudge
267            // operators toward re-running with an `encryption_key`
268            // configured, which will rewrite entries on next refresh.
269            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    /// Save a secret to disk cache.
283    ///
284    /// When `CacheConfig.encryption_key` is set, the serialised `CacheEntry`
285    /// is encrypted via AES-256-GCM (see [`crypto`]). Without a key the disk
286    /// tier is skipped (memory-only) by default -- secrets are NEVER written
287    /// as plaintext unless `allow_plaintext_disk_cache` is explicitly set,
288    /// which `SecretCache::new` warns about at startup and `validate` rejects
289    /// under a production profile.
290    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            // Explicit opt-in to plaintext on disk (legacy). Flagged by the
306            // startup warning in `new()` and rejected by `validate()` in prod.
307            plaintext
308        } else {
309            // Default: no key + no opt-in -> never persist plaintext secrets.
310            // The disk tier is skipped (memory cache still serves this key);
311            // persistence resumes once an encryption_key is configured.
312            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    /// Convert a cache key to a safe filename.
325    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
332/// Ensure `dir` exists; chmod to `mode` on Unix when `mode` is
333/// `Some`. `None` skips chmod -- used by operators on S3-FUSE,
334/// root-squashed NFS, or other mounts that reject chmod.
335fn 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
355/// Atomic-write `bytes` to `path`; chmod to `mode` on Unix when
356/// `mode` is `Some`. Sets perms BEFORE rename so the file is never
357/// visible at the umask default even briefly.
358fn 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        // Keep the temp dir from being deleted
397        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            // These tests exercise the disk tier's mechanics (persistence,
407            // perms), so they explicitly opt into the plaintext disk path.
408            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        // Store a secret
458        {
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        // Create a new cache instance and retrieve
465        {
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,             // Immediately expired
477            stale_grace_secs: 86400, // But within grace
478            ..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        // get() should return None (expired)
486        assert!(cache.get("stale-key").is_none());
487
488        // get_stale() should return the value (within grace)
489        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        // Hit
519        let _ = cache.get("key");
520        // Miss
521        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    /// Cascade override: `dir_mode: None` skips chmod (S3-FUSE /
542    /// root-squashed NFS / similar mounts that reject `chmod`).
543    #[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        // Pre-set the dir to a non-private mode that ensure_dir_private
556        // would normally clobber; with mode: None it must NOT change.
557        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    /// Regression (Unix): create -> set -> clear -> set;
568    /// directory stays 0700 and the cache file lands at 0600.
569    #[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            // This test asserts on-disk file perms, so it needs files written.
580            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        // No encryption_key and no opt-in -> the disk tier is skipped entirely.
611        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, // the default
618            ..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        // Memory cache still serves it...
626        assert_eq!(
627            cache.get("k").unwrap().as_bytes(),
628            b"plaintext-secret",
629            "memory tier still works"
630        );
631        // ...but NOTHING was written to disk.
632        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, // opt-in
648            ..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        // It is genuinely plaintext, not an AES-GCM envelope: a fresh cache
659        // with NO encryption_key can still read it back (an encrypted entry
660        // would be undecryptable without the key).
661        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}