Skip to main content

devboy_storage/
cache.rs

1//! In-memory TTL cache layer on top of a [`CredentialStore`].
2//!
3//! The OS keychain is fast enough for interactive CLI usage, but inside a long-running
4//! MCP proxy loop we call `get()` on every routing decision and telemetry flush. On
5//! macOS that also risks repeated UI prompts if the Keychain access control list is
6//! strict. A short-lived in-memory cache cuts the lookup cost without compromising
7//! safety: secrets still live in OS-protected storage and are zeroized on drop.
8//!
9//! # Guarantees
10//!
11//! - TTL of `0` disables caching entirely (useful for high-security configurations).
12//! - `store()` / `delete()` on the wrapped store also invalidate the cache entry so we
13//!   do not serve stale secrets after rotation.
14//! - Cached values are held as [`secrecy::SecretString`], whose `Debug` impl
15//!   redacts the value and which zeroizes its buffer on drop — so eviction
16//!   and cache-drop scrub the in-memory copy without manual `Zeroizing`
17//!   wrappers.
18//! - The [`std::fmt::Debug`] impl never prints values.
19//!
20//! # Non-goals
21//!
22//! - Cross-process coherence: every process has its own cache. Rotation semantics rely
23//!   on processes being short-lived or reconnecting before `cache_ttl_secs` elapse.
24
25use std::collections::HashMap;
26use std::sync::RwLock;
27use std::time::{Duration, Instant};
28
29use devboy_core::Result;
30use secrecy::SecretString;
31
32use crate::CredentialStore;
33
34/// Entry in the cache — a `SecretString` (zeroized on drop) plus an expiry timestamp.
35struct CachedEntry {
36    value: SecretString,
37    expires_at: Instant,
38}
39
40impl CachedEntry {
41    fn new(value: SecretString, ttl: Duration) -> Self {
42        Self {
43            value,
44            expires_at: Instant::now() + ttl,
45        }
46    }
47
48    fn is_fresh(&self) -> bool {
49        Instant::now() < self.expires_at
50    }
51}
52
53/// Caching wrapper around any [`CredentialStore`].
54///
55/// Use [`CachedStore::new`] with an explicit TTL. Passing `ttl = Duration::from_secs(0)`
56/// disables caching and makes every `get()` hit the inner store directly — the wrapper
57/// simply proxies in that case.
58pub struct CachedStore<S: CredentialStore> {
59    inner: S,
60    ttl: Duration,
61    entries: RwLock<HashMap<String, CachedEntry>>,
62}
63
64impl<S: CredentialStore> CachedStore<S> {
65    /// Wrap `inner` with a cache that keeps successful reads for `ttl`.
66    ///
67    /// Cache misses (key not found) are *not* cached — we do not want to pin a stale
68    /// negative result when credentials are rotated in behind the scenes.
69    pub fn new(inner: S, ttl: Duration) -> Self {
70        Self {
71            inner,
72            ttl,
73            entries: RwLock::new(HashMap::new()),
74        }
75    }
76
77    /// Drop every cached entry (wiping their buffers). Useful for explicit logout flows.
78    pub fn invalidate_all(&self) {
79        if let Ok(mut entries) = self.entries.write() {
80            entries.clear();
81        }
82    }
83
84    /// Drop a single cached entry.
85    pub fn invalidate(&self, key: &str) {
86        if let Ok(mut entries) = self.entries.write() {
87            entries.remove(key);
88        }
89    }
90
91    fn caching_disabled(&self) -> bool {
92        self.ttl.is_zero()
93    }
94
95    fn lookup_fresh(&self, key: &str) -> Option<SecretString> {
96        let entries = self.entries.read().ok()?;
97        let entry = entries.get(key)?;
98        if entry.is_fresh() {
99            // Clone the SecretString directly — no `expose_secret()`
100            // call, no extra plaintext String allocation, and the
101            // returned value keeps the same zeroize-on-drop discipline
102            // as the cached entry.
103            Some(entry.value.clone())
104        } else {
105            None
106        }
107    }
108
109    fn insert(&self, key: &str, value: &SecretString) {
110        let Ok(mut entries) = self.entries.write() else {
111            return;
112        };
113        entries.insert(key.to_string(), CachedEntry::new(value.clone(), self.ttl));
114    }
115
116    fn purge_expired_locked(&self) {
117        let Ok(mut entries) = self.entries.write() else {
118            return;
119        };
120        let now = Instant::now();
121        entries.retain(|_, e| e.expires_at > now);
122    }
123}
124
125impl<S: CredentialStore> std::fmt::Debug for CachedStore<S> {
126    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
127        let size = self.entries.read().map(|e| e.len()).unwrap_or(0);
128        f.debug_struct("CachedStore")
129            .field("ttl_secs", &self.ttl.as_secs())
130            .field("cached_entries", &size)
131            .field("values", &"<redacted>")
132            .finish()
133    }
134}
135
136impl<S: CredentialStore> CredentialStore for CachedStore<S> {
137    fn store(&self, key: &str, value: &SecretString) -> Result<()> {
138        let res = self.inner.store(key, value);
139        self.invalidate(key);
140        res
141    }
142
143    fn get(&self, key: &str) -> Result<Option<SecretString>> {
144        if self.caching_disabled() {
145            return self.inner.get(key);
146        }
147
148        if let Some(v) = self.lookup_fresh(key) {
149            return Ok(Some(v));
150        }
151
152        // Opportunistic purge of other stale entries.
153        self.purge_expired_locked();
154
155        match self.inner.get(key)? {
156            Some(value) => {
157                self.insert(key, &value);
158                Ok(Some(value))
159            }
160            None => Ok(None),
161        }
162    }
163
164    fn delete(&self, key: &str) -> Result<()> {
165        let res = self.inner.delete(key);
166        self.invalidate(key);
167        res
168    }
169
170    fn is_available(&self) -> bool {
171        self.inner.is_available()
172    }
173
174    fn is_writable(&self) -> bool {
175        self.inner.is_writable()
176    }
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182    use crate::MemoryStore;
183    use secrecy::ExposeSecret;
184    use std::thread;
185
186    fn store_with_entry(k: &str, v: &str) -> MemoryStore {
187        MemoryStore::with_credentials([(k.to_string(), v.to_string())])
188    }
189
190    fn secret(s: &str) -> SecretString {
191        SecretString::from(s.to_string())
192    }
193
194    fn exposed(s: &Option<SecretString>) -> Option<&str> {
195        s.as_ref().map(|v| v.expose_secret())
196    }
197
198    #[test]
199    fn test_cache_hit_returns_value_without_hitting_inner() {
200        // A MemoryStore whose credentials vanish after the first read would prove this,
201        // but we settle for the simpler invariant: the cache returns the same string
202        // and the debug output acknowledges the size.
203        let cache = CachedStore::new(store_with_entry("a/b", "secret-A"), Duration::from_secs(60));
204
205        // Prime
206        assert_eq!(exposed(&cache.get("a/b").unwrap()), Some("secret-A"));
207        // Debug does not leak the value
208        let dbg = format!("{:?}", cache);
209        assert!(dbg.contains("cached_entries: 1"));
210        assert!(!dbg.contains("secret-A"));
211    }
212
213    #[test]
214    fn test_cache_respects_ttl_and_refetches() {
215        let cache = CachedStore::new(
216            store_with_entry("a/b", "secret-A"),
217            Duration::from_millis(50),
218        );
219
220        assert_eq!(exposed(&cache.get("a/b").unwrap()), Some("secret-A"));
221        thread::sleep(Duration::from_millis(80));
222        // Still finds it but now from the inner store (cache miss → fresh fetch).
223        assert_eq!(exposed(&cache.get("a/b").unwrap()), Some("secret-A"));
224    }
225
226    #[test]
227    fn test_cache_zero_ttl_disables_caching() {
228        let cache = CachedStore::new(store_with_entry("a/b", "v"), Duration::ZERO);
229        assert_eq!(exposed(&cache.get("a/b").unwrap()), Some("v"));
230
231        let dbg = format!("{:?}", cache);
232        assert!(dbg.contains("cached_entries: 0"));
233    }
234
235    #[test]
236    fn test_store_invalidates_cache_entry() {
237        let inner = MemoryStore::new();
238        inner.store("k", &secret("v1")).unwrap();
239
240        let cache = CachedStore::new(inner, Duration::from_secs(60));
241        assert_eq!(exposed(&cache.get("k").unwrap()), Some("v1"));
242
243        // Rotate through the cache — rotation must reach the inner store AND bust cache.
244        cache.store("k", &secret("v2")).unwrap();
245        assert_eq!(exposed(&cache.get("k").unwrap()), Some("v2"));
246    }
247
248    #[test]
249    fn test_delete_invalidates_cache_entry() {
250        let inner = MemoryStore::new();
251        inner.store("k", &secret("v1")).unwrap();
252        let cache = CachedStore::new(inner, Duration::from_secs(60));
253
254        assert_eq!(exposed(&cache.get("k").unwrap()), Some("v1"));
255
256        cache.delete("k").unwrap();
257        assert!(cache.get("k").unwrap().is_none());
258    }
259
260    #[test]
261    fn test_missing_keys_not_cached() {
262        // Absent credentials must not pin a "missing" state, otherwise rotation-in
263        // would never be observed until TTL.
264        let inner = MemoryStore::new();
265        let cache = CachedStore::new(inner, Duration::from_secs(60));
266
267        assert!(cache.get("k").unwrap().is_none());
268
269        // Populate inner directly (bypass cache)…
270        // we need a &inner, but `cache` owns it — so reach through `invalidate_all` as a
271        // proxy for "do not keep negative entries".
272        // Simpler: set via cache.store(), which invalidates and then the lookup is fresh.
273        cache.store("k", &secret("later")).unwrap();
274        assert_eq!(exposed(&cache.get("k").unwrap()), Some("later"));
275    }
276
277    #[test]
278    fn test_invalidate_all_drops_every_entry() {
279        let inner = MemoryStore::with_credentials([
280            ("a".to_string(), "1".to_string()),
281            ("b".to_string(), "2".to_string()),
282        ]);
283        let cache = CachedStore::new(inner, Duration::from_secs(60));
284        cache.get("a").unwrap();
285        cache.get("b").unwrap();
286
287        cache.invalidate_all();
288        let dbg = format!("{:?}", cache);
289        assert!(dbg.contains("cached_entries: 0"));
290    }
291
292    #[test]
293    fn test_writable_and_available_delegate_to_inner() {
294        let cache = CachedStore::new(MemoryStore::new(), Duration::from_secs(10));
295        assert!(cache.is_writable());
296        assert!(cache.is_available());
297    }
298}