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 (Codex review
69        // 2026-06-03): reject plaintext disk cache in prod here, not only when
70        // an app remembers to call validate() at startup.
71        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                // Auto-detect cache directory
77                dirs::cache_dir()
78                    .unwrap_or_else(|| PathBuf::from("/tmp"))
79                    .join("hyperi-rustlib")
80                    .join("secrets")
81            });
82
83            // Create if missing AND force the configured mode on
84            // every new(). F6: previously skipped chmod on existing
85            // dirs, leaving umask-default perms.
86            ensure_dir_private(&dir, config.dir_mode)?;
87
88            // Loud at startup when the disk tier would persist plaintext
89            // secrets, and a quiet note when it falls back to memory-only.
90            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    /// Get a fresh secret from cache.
122    ///
123    /// Returns `None` if not cached or expired.
124    pub fn get(&self, key: &str) -> Option<SecretValue> {
125        // Check memory cache
126        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        // Check disk cache
135        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    /// Get a stale secret from cache (for fallback on provider failure).
148    ///
149    /// Returns a cached value even if expired, as long as it's within the grace period.
150    pub fn get_stale(&self, key: &str) -> Option<SecretValue> {
151        // Check memory cache
152        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        // Check disk cache
161        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    /// Store a secret in cache.
173    ///
174    /// # Errors
175    ///
176    /// Returns an error if disk cache write fails.
177    pub fn set(&mut self, key: &str, value: &SecretValue) -> SecretsResult<()> {
178        if !self.config.enabled {
179            return Ok(());
180        }
181
182        // Store in memory
183        self.memory.insert(key.to_string(), value.clone());
184
185        // Store on disk
186        self.save_to_disk(key, value)?;
187
188        debug!(key = %key, "Secret cached");
189        Ok(())
190    }
191
192    /// Clear all cached secrets.
193    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            // F6: re-creating the dir without permissions falls back
200            // to umask. Force the configured mode on every recreate.
201            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    /// Get cache statistics.
208    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    /// Load a secret from disk cache.
225    ///
226    /// Detects encryption envelopes by their `"v":` JSON marker and
227    /// decrypts via [`crypto::open`] when an encryption key is
228    /// configured. Legacy plaintext entries (no envelope) are still
229    /// accepted but loaded with a warning -- operators upgrading from
230    /// pre-encryption deployments see one notice per file. A future
231    /// release will hard-reject legacy entries to force a clean
232    /// migration; for now we read-through so existing caches keep
233    /// working.
234    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        // Pick the load path based on (a) whether the file looks like
245        // an encrypted envelope, and (b) whether an encryption key is
246        // configured. This handles upgrades cleanly.
247        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            // Legacy plaintext path. Warn once per load to nudge
268            // operators toward re-running with an `encryption_key`
269            // configured, which will rewrite entries on next refresh.
270            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    /// Save a secret to disk cache.
284    ///
285    /// When `CacheConfig.encryption_key` is set, the serialised `CacheEntry`
286    /// is encrypted via AES-256-GCM (see [`crypto`]). Without a key the disk
287    /// tier is skipped (memory-only) by default -- secrets are NEVER written
288    /// as plaintext unless `allow_plaintext_disk_cache` is explicitly set,
289    /// which `SecretCache::new` warns about at startup and `validate` rejects
290    /// under a production profile.
291    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            // Explicit opt-in to plaintext on disk (legacy). Flagged by the
307            // startup warning in `new()` and rejected by `validate()` in prod.
308            plaintext
309        } else {
310            // Default: no key + no opt-in -> never persist plaintext secrets.
311            // The disk tier is skipped (memory cache still serves this key);
312            // persistence resumes once an encryption_key is configured.
313            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    /// Convert a cache key to a safe filename.
326    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
333/// Ensure `dir` exists; chmod to `mode` on Unix when `mode` is
334/// `Some`. `None` skips chmod -- used by operators on S3-FUSE,
335/// root-squashed NFS, or other mounts that reject chmod.
336fn 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
356/// Atomic-write `bytes` to `path`; chmod to `mode` on Unix when
357/// `mode` is `Some`. Sets perms BEFORE rename so the file is never
358/// visible at the umask default even briefly.
359fn 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        // Keep the temp dir from being deleted
398        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            // These tests exercise the disk tier's mechanics (persistence,
408            // perms), so they explicitly opt into the plaintext disk path.
409            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        // Store a secret
459        {
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        // Create a new cache instance and retrieve
466        {
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,             // Immediately expired
478            stale_grace_secs: 86400, // But within grace
479            ..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        // get() should return None (expired)
487        assert!(cache.get("stale-key").is_none());
488
489        // get_stale() should return the value (within grace)
490        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        // Hit
520        let _ = cache.get("key");
521        // Miss
522        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    /// Cascade override: `dir_mode: None` skips chmod (S3-FUSE /
543    /// root-squashed NFS / similar mounts that reject `chmod`).
544    #[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        // Pre-set the dir to a non-private mode that ensure_dir_private
557        // would normally clobber; with mode: None it must NOT change.
558        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    /// Codex F6 regression (Unix): create -> set -> clear -> set;
569    /// directory stays 0700 and the cache file lands at 0600.
570    #[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            // This test asserts on-disk file perms, so it needs files written.
581            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        // No encryption_key and no opt-in -> the disk tier is skipped entirely.
612        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, // the default
619            ..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        // Memory cache still serves it...
627        assert_eq!(
628            cache.get("k").unwrap().as_bytes(),
629            b"plaintext-secret",
630            "memory tier still works"
631        );
632        // ...but NOTHING was written to disk.
633        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, // opt-in
649            ..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        // It is genuinely plaintext, not an AES-GCM envelope: a fresh cache
660        // with NO encryption_key can still read it back (an encrypted entry
661        // would be undecryptable without the key).
662        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}