1use 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
34struct 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
53pub struct CachedStore<S: CredentialStore> {
59 inner: S,
60 ttl: Duration,
61 entries: RwLock<HashMap<String, CachedEntry>>,
62}
63
64impl<S: CredentialStore> CachedStore<S> {
65 pub fn new(inner: S, ttl: Duration) -> Self {
70 Self {
71 inner,
72 ttl,
73 entries: RwLock::new(HashMap::new()),
74 }
75 }
76
77 pub fn invalidate_all(&self) {
79 if let Ok(mut entries) = self.entries.write() {
80 entries.clear();
81 }
82 }
83
84 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 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 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 let cache = CachedStore::new(store_with_entry("a/b", "secret-A"), Duration::from_secs(60));
204
205 assert_eq!(exposed(&cache.get("a/b").unwrap()), Some("secret-A"));
207 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 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 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 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 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}