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        let cache_dir = if config.enabled {
69            let dir = config.directory.clone().unwrap_or_else(|| {
70                // Auto-detect cache directory
71                dirs::cache_dir()
72                    .unwrap_or_else(|| PathBuf::from("/tmp"))
73                    .join("hyperi-rustlib")
74                    .join("secrets")
75            });
76
77            // Create if missing AND force the configured mode on
78            // every new(). F6: previously skipped chmod on existing
79            // dirs, leaving umask-default perms.
80            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    /// Get a fresh secret from cache.
97    ///
98    /// Returns `None` if not cached or expired.
99    pub fn get(&self, key: &str) -> Option<SecretValue> {
100        // Check memory cache
101        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        // Check disk cache
110        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    /// Get a stale secret from cache (for fallback on provider failure).
123    ///
124    /// Returns a cached value even if expired, as long as it's within the grace period.
125    pub fn get_stale(&self, key: &str) -> Option<SecretValue> {
126        // Check memory cache
127        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        // Check disk cache
136        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    /// Store a secret in cache.
148    ///
149    /// # Errors
150    ///
151    /// Returns an error if disk cache write fails.
152    pub fn set(&mut self, key: &str, value: &SecretValue) -> SecretsResult<()> {
153        if !self.config.enabled {
154            return Ok(());
155        }
156
157        // Store in memory
158        self.memory.insert(key.to_string(), value.clone());
159
160        // Store on disk
161        self.save_to_disk(key, value)?;
162
163        debug!(key = %key, "Secret cached");
164        Ok(())
165    }
166
167    /// Clear all cached secrets.
168    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            // F6: re-creating the dir without permissions falls back
175            // to umask. Force the configured mode on every recreate.
176            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    /// Get cache statistics.
183    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    /// Load a secret from disk cache.
200    ///
201    /// Detects encryption envelopes by their `"v":` JSON marker and
202    /// decrypts via [`crypto::open`] when an encryption key is
203    /// configured. Legacy plaintext entries (no envelope) are still
204    /// accepted but loaded with a warning -- operators upgrading from
205    /// pre-encryption deployments see one notice per file. A future
206    /// release will hard-reject legacy entries to force a clean
207    /// migration; for now we read-through so existing caches keep
208    /// working.
209    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        // Pick the load path based on (a) whether the file looks like
220        // an encrypted envelope, and (b) whether an encryption key is
221        // configured. This handles upgrades cleanly.
222        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            // Legacy plaintext path. Warn once per load to nudge
243            // operators toward re-running with an `encryption_key`
244            // configured, which will rewrite entries on next refresh.
245            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    /// Save a secret to disk cache.
259    ///
260    /// When `CacheConfig.encryption_key` is set, the serialised
261    /// `CacheEntry` is encrypted via AES-256-GCM (see [`crypto`]).
262    /// Without a key, the previous plaintext-base64-JSON shape is
263    /// retained -- this keeps the cache usable in development without
264    /// forcing operators to provision a key, but the misleading
265    /// `encryption_key: None` plaintext path is now loud at startup
266    /// (a `tracing::warn!` from `SecretCache::new`).
267    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    /// Convert a cache key to a safe filename.
290    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
297/// Ensure `dir` exists; chmod to `mode` on Unix when `mode` is
298/// `Some`. `None` skips chmod -- used by operators on S3-FUSE,
299/// root-squashed NFS, or other mounts that reject chmod.
300fn 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
320/// Atomic-write `bytes` to `path`; chmod to `mode` on Unix when
321/// `mode` is `Some`. Sets perms BEFORE rename so the file is never
322/// visible at the umask default even briefly.
323fn 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        // Keep the temp dir from being deleted
362        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        // Store a secret
420        {
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        // Create a new cache instance and retrieve
427        {
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,             // Immediately expired
439            stale_grace_secs: 86400, // But within grace
440            ..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        // get() should return None (expired)
448        assert!(cache.get("stale-key").is_none());
449
450        // get_stale() should return the value (within grace)
451        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        // Hit
481        let _ = cache.get("key");
482        // Miss
483        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    /// Cascade override: `dir_mode: None` skips chmod (S3-FUSE /
504    /// root-squashed NFS / similar mounts that reject `chmod`).
505    #[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        // Pre-set the dir to a non-private mode that ensure_dir_private
518        // would normally clobber; with mode: None it must NOT change.
519        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    /// Codex F6 regression (Unix): create -> set -> clear -> set;
530    /// directory stays 0700 and the cache file lands at 0600.
531    #[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}