brows3r_lib/cache/mod.rs
1//! Authoritative SWR cache — types and configuration.
2//!
3//! # Layers
4//!
5//! - `mod.rs` — `CacheKey`, `CacheEntry<T>`, `CacheConfig`, `Freshness`.
6//! - `store.rs` — `CacheStore`: in-memory LRU + `redb` disk backend.
7//! - `invalidation.rs` — mutation-triggered invalidation helpers.
8//! - `capability.rs` — stub placeholder for the capability cache (task 20).
9//!
10//! # OCP
11//!
12//! - Add a new cached resource: add a `CacheKey` variant + one arm in
13//! `serialize_key`. Nothing else changes.
14//! - Add a new validation gate: add one `Option` arg to `store::get` and one
15//! check at the top of the function. Existing call sites are unaffected.
16//! - Swap the KV backend: replace the `redb`-specific code inside `CacheStore`
17//! without touching any caller.
18
19pub mod capability;
20pub mod invalidation;
21pub mod store;
22
23pub use capability::{
24 CapabilityCache, CapabilityClass, CapabilityHandle, CapabilityMap, CapabilityRecord, ClearScope,
25};
26
27use serde::{Deserialize, Serialize};
28
29use crate::ids::{BucketId, ObjectKey, ProfileId};
30
31// ---------------------------------------------------------------------------
32// CacheKey
33// ---------------------------------------------------------------------------
34
35/// Discriminated key for every entity the cache can hold.
36///
37/// `serialize_key` produces a stable, prefix-safe byte sequence so that
38/// per-profile invalidation can iterate by prefix without deserialising values.
39#[derive(Debug, Clone, PartialEq, Eq, Hash)]
40pub enum CacheKey {
41 /// Bucket list for a profile.
42 Buckets(ProfileId),
43 /// Object listing at a prefix inside a bucket.
44 Objects {
45 profile: ProfileId,
46 bucket: BucketId,
47 prefix: String,
48 },
49 /// Single-object `HeadObject` result.
50 ObjectHead {
51 profile: ProfileId,
52 bucket: BucketId,
53 key: ObjectKey,
54 },
55 /// Inspector panel data (bucket or object properties).
56 Inspector {
57 profile: ProfileId,
58 bucket: BucketId,
59 key: Option<ObjectKey>,
60 },
61 /// Capability classification result (allowed / denied / unsupported).
62 Capability {
63 profile: ProfileId,
64 bucket: Option<BucketId>,
65 op: String,
66 },
67}
68
69impl CacheKey {
70 /// Stable byte representation used as the `redb` table key and as the
71 /// in-memory `HashMap` lookup key serialisation.
72 ///
73 /// Format: `<variant_tag>/<profile_id>[/<extra...>]`
74 /// Fields are separated by `\x00` so no URL-encoding is needed and the
75 /// keys are prefix-scannable for per-profile invalidation.
76 pub fn serialize_key(&self) -> Vec<u8> {
77 let s = match self {
78 CacheKey::Buckets(pid) => {
79 format!("buckets\x00{}", pid.as_str())
80 }
81 CacheKey::Objects {
82 profile,
83 bucket,
84 prefix,
85 } => {
86 format!(
87 "objects\x00{}\x00{}\x00{}",
88 profile.as_str(),
89 bucket.as_str(),
90 prefix
91 )
92 }
93 CacheKey::ObjectHead {
94 profile,
95 bucket,
96 key,
97 } => {
98 format!(
99 "object_head\x00{}\x00{}\x00{}",
100 profile.as_str(),
101 bucket.as_str(),
102 key.as_str()
103 )
104 }
105 CacheKey::Inspector {
106 profile,
107 bucket,
108 key,
109 } => {
110 let key_part = key.as_ref().map(|k| k.as_str()).unwrap_or("");
111 format!(
112 "inspector\x00{}\x00{}\x00{}",
113 profile.as_str(),
114 bucket.as_str(),
115 key_part
116 )
117 }
118 CacheKey::Capability {
119 profile,
120 bucket,
121 op,
122 } => {
123 let bucket_part = bucket.as_ref().map(|b| b.as_str()).unwrap_or("");
124 format!(
125 "capability\x00{}\x00{}\x00{}",
126 profile.as_str(),
127 bucket_part,
128 op
129 )
130 }
131 };
132 s.into_bytes()
133 }
134
135 /// Return the `ProfileId` this key is scoped to.
136 pub fn profile_id(&self) -> &ProfileId {
137 match self {
138 CacheKey::Buckets(pid) => pid,
139 CacheKey::Objects { profile, .. } => profile,
140 CacheKey::ObjectHead { profile, .. } => profile,
141 CacheKey::Inspector { profile, .. } => profile,
142 CacheKey::Capability { profile, .. } => profile,
143 }
144 }
145}
146
147// ---------------------------------------------------------------------------
148// CacheEntry<T>
149// ---------------------------------------------------------------------------
150
151/// A cached value together with its validity metadata.
152///
153/// `T` is the value type; when stored on disk the bytes are
154/// `serde_json::to_vec(CacheEntry<serde_json::Value>)`.
155#[derive(Debug, Clone, Serialize, Deserialize)]
156pub struct CacheEntry<T> {
157 /// The cached value.
158 pub value: T,
159 /// Unix timestamp (seconds) when the entry was written.
160 pub fetched_at: i64,
161 /// Unix timestamp (seconds) after which the entry is considered expired
162 /// (i.e. `fetched_at + ttl`). A read between `expires_at` and
163 /// `expires_at + swr_window` returns `Freshness::Stale`.
164 pub expires_at: i64,
165 /// ETag of the S3 object at fetch time, if available.
166 pub etag: Option<String>,
167}
168
169// ---------------------------------------------------------------------------
170// Freshness
171// ---------------------------------------------------------------------------
172
173/// Freshness classification returned by `CacheStore::get`.
174#[derive(Debug, Clone, PartialEq, Eq)]
175pub enum Freshness {
176 /// Within TTL — safe to use directly.
177 Fresh,
178 /// Past TTL but within the SWR window — return to caller while the
179 /// background revalidation (wired in later tasks) fetches a fresh copy.
180 Stale,
181 /// Past the SWR window; the caller must fetch a fresh value before rendering.
182 Missing,
183}
184
185// ---------------------------------------------------------------------------
186// CacheRead<T>
187// ---------------------------------------------------------------------------
188
189/// Return type of `CacheStore::get`: value + freshness classification.
190pub struct CacheRead<T> {
191 pub value: T,
192 pub freshness: Freshness,
193}
194
195// ---------------------------------------------------------------------------
196// CacheConfig
197// ---------------------------------------------------------------------------
198
199/// Runtime-configurable cache parameters.
200///
201/// Loaded from `Settings` at startup; `Default` reflects the v1 proposal
202/// values so callers can construct a sensible default without a settings handle.
203#[derive(Debug, Clone)]
204pub struct CacheConfig {
205 /// Time-to-live in seconds (default 30, from `Settings.cache_ttl_secs`).
206 pub default_ttl_secs: u64,
207 /// How long stale data may be served while background revalidation runs.
208 /// Default 60 s — twice the default TTL so there is always a window
209 /// without a blocking S3 fetch during normal browsing.
210 pub swr_window_secs: u64,
211 /// Maximum number of entries kept in the in-memory LRU map.
212 /// Eviction is LRU; disk is unaffected by eviction.
213 pub max_in_memory_entries: usize,
214}
215
216impl Default for CacheConfig {
217 fn default() -> Self {
218 Self {
219 default_ttl_secs: 30,
220 swr_window_secs: 60,
221 max_in_memory_entries: 1024,
222 }
223 }
224}
225
226// ---------------------------------------------------------------------------
227// Tests
228// ---------------------------------------------------------------------------
229
230#[cfg(test)]
231mod tests {
232 use super::*;
233 use crate::ids::{BucketId, ObjectKey, ProfileId};
234
235 #[test]
236 fn serialize_key_buckets_is_stable() {
237 let pid = ProfileId::new("p1");
238 let key = CacheKey::Buckets(pid.clone());
239 let bytes = key.serialize_key();
240 assert!(bytes.starts_with(b"buckets\x00"));
241 // Idempotent
242 assert_eq!(key.serialize_key(), bytes);
243 }
244
245 #[test]
246 fn serialize_key_objects_includes_all_fields() {
247 let key = CacheKey::Objects {
248 profile: ProfileId::new("prof"),
249 bucket: BucketId::new("bkt"),
250 prefix: "folder/".to_string(),
251 };
252 let s = String::from_utf8(key.serialize_key()).unwrap();
253 assert!(s.contains("objects"));
254 assert!(s.contains("prof"));
255 assert!(s.contains("bkt"));
256 assert!(s.contains("folder/"));
257 }
258
259 #[test]
260 fn serialize_key_inspector_none_key_is_stable() {
261 let key = CacheKey::Inspector {
262 profile: ProfileId::new("p"),
263 bucket: BucketId::new("b"),
264 key: None,
265 };
266 let bytes = key.serialize_key();
267 let with_key = CacheKey::Inspector {
268 profile: ProfileId::new("p"),
269 bucket: BucketId::new("b"),
270 key: Some(ObjectKey::new("obj.txt")),
271 };
272 assert_ne!(bytes, with_key.serialize_key());
273 }
274
275 #[test]
276 fn serialize_key_capability_none_bucket_is_stable() {
277 let k1 = CacheKey::Capability {
278 profile: ProfileId::new("p"),
279 bucket: None,
280 op: "ListBuckets".to_string(),
281 };
282 let k2 = CacheKey::Capability {
283 profile: ProfileId::new("p"),
284 bucket: Some(BucketId::new("bkt")),
285 op: "ListBuckets".to_string(),
286 };
287 assert_ne!(k1.serialize_key(), k2.serialize_key());
288 }
289
290 #[test]
291 fn profile_id_accessor_returns_correct_profile() {
292 let pid = ProfileId::new("my-profile");
293 let key = CacheKey::Buckets(pid.clone());
294 assert_eq!(key.profile_id(), &pid);
295
296 let key2 = CacheKey::Objects {
297 profile: pid.clone(),
298 bucket: BucketId::new("b"),
299 prefix: String::new(),
300 };
301 assert_eq!(key2.profile_id(), &pid);
302 }
303
304 #[test]
305 fn cache_config_defaults_match_v1_spec() {
306 let cfg = CacheConfig::default();
307 assert_eq!(cfg.default_ttl_secs, 30);
308 assert_eq!(cfg.swr_window_secs, 60);
309 assert_eq!(cfg.max_in_memory_entries, 1024);
310 }
311}