Skip to main content

orinium_browser/platform/network/
cache.rs

1//! In-memory HTTP response cache with TTL and LRU eviction.
2
3use std::num::NonZeroUsize;
4use std::sync::atomic::{AtomicBool, Ordering};
5use std::sync::{Arc, Mutex};
6use std::time::{Duration, SystemTime};
7
8use lru::LruCache;
9
10use crate::platform::cache_stats::{CacheStats, CacheStatsSnapshot};
11
12use super::core::Response;
13
14/// Maximum number of responses kept before the oldest ones are evicted.
15const DEFAULT_MAX_ENTRIES: usize = 512;
16
17#[derive(Debug)]
18pub struct Cache {
19    enabled: AtomicBool,
20    store: Arc<Mutex<LruCache<String, CachedResponse>>>,
21    stats: CacheStats,
22}
23
24#[derive(Debug)]
25struct CachedResponse {
26    response: Response,
27    expires_at: Option<SystemTime>,
28    request_headers: Vec<(String, String)>,
29}
30
31impl Default for Cache {
32    fn default() -> Self {
33        Self::new()
34    }
35}
36
37impl Cache {
38    pub fn new() -> Self {
39        Self::with_capacity(DEFAULT_MAX_ENTRIES)
40    }
41
42    /// Creates a cache holding at most `max_entries` responses.
43    pub fn with_capacity(max_entries: usize) -> Self {
44        let capacity = NonZeroUsize::new(max_entries.max(1)).unwrap();
45        Self {
46            enabled: AtomicBool::new(true),
47            store: Arc::new(Mutex::new(LruCache::new(capacity))),
48            stats: CacheStats::new("http"),
49        }
50    }
51
52    /// Enables or disables caching. When disabled, `get` always misses and
53    /// `set` is a no-op.
54    pub fn set_enabled(&self, enabled: bool) {
55        self.enabled.store(enabled, Ordering::Relaxed);
56    }
57
58    pub fn is_enabled(&self) -> bool {
59        self.enabled.load(Ordering::Relaxed)
60    }
61
62    /// Returns a cached response only when the request headers still match.
63    pub fn get(&self, url: &str) -> Option<Response> {
64        self.get_for_request(url, &[])
65    }
66
67    pub fn get_for_request(
68        &self,
69        url: &str,
70        request_headers: &[(String, String)],
71    ) -> Option<Response> {
72        if !self.is_enabled() {
73            return None;
74        }
75        let mut store = self.store.lock().ok()?;
76        let Some(entry) = store.get(url) else {
77            self.stats.record_miss();
78            return None;
79        };
80        if let Some(exp) = entry.expires_at
81            && SystemTime::now() > exp
82        {
83            store.pop(url);
84            self.stats.record_eviction();
85            self.stats.record_miss();
86            return None;
87        }
88        if normalized_headers(request_headers) != entry.request_headers {
89            self.stats.record_miss();
90            return None;
91        }
92        self.stats.record_hit();
93        Some(entry.response.clone())
94    }
95
96    /// Caches a response unless its headers forbid it or contain `Vary: *`.
97    pub fn set(&self, url: &str, response: &Response) {
98        self.set_for_request(url, response, &[]);
99    }
100
101    pub fn set_for_request(
102        &self,
103        url: &str,
104        response: &Response,
105        request_headers: &[(String, String)],
106    ) {
107        if !self.is_enabled() {
108            return;
109        }
110        if forbids_caching(&response.headers) {
111            self.remove(url);
112            return;
113        }
114        if has_vary_star(&response.headers) {
115            self.remove(url);
116            return;
117        }
118        if let Ok(mut store) = self.store.lock() {
119            // `push` returns the replaced entry when the URL is revalidated
120            // and the least recently used entry when the cache overflows.
121            let evicted = store.push(
122                url.to_string(),
123                CachedResponse {
124                    response: response.clone(),
125                    expires_at: expiry_from_headers(&response.headers),
126                    request_headers: normalized_headers(request_headers),
127                },
128            );
129            if let Some((evicted_url, _)) = evicted
130                && evicted_url != url
131            {
132                self.stats.record_eviction();
133            }
134        }
135    }
136
137    /// Snapshot of this cache's hit/miss/eviction counters.
138    pub fn stats(&self) -> CacheStatsSnapshot {
139        self.stats.snapshot()
140    }
141
142    /// Number of responses currently held.
143    pub fn len(&self) -> usize {
144        self.store.lock().map(|store| store.len()).unwrap_or(0)
145    }
146
147    pub fn is_empty(&self) -> bool {
148        self.len() == 0
149    }
150
151    pub fn clear(&self) {
152        if let Ok(mut store) = self.store.lock() {
153            store.clear();
154        }
155    }
156
157    fn remove(&self, url: &str) {
158        if let Ok(mut store) = self.store.lock() {
159            store.pop(url);
160        }
161    }
162}
163
164fn has_vary_star(response_headers: &[(String, String)]) -> bool {
165    response_headers.iter().any(|(name, value)| {
166        if !name.eq_ignore_ascii_case("vary") {
167            return false;
168        }
169        value.split(',').map(str::trim).any(|field| field == "*")
170    })
171}
172
173fn normalized_headers(headers: &[(String, String)]) -> Vec<(String, String)> {
174    headers
175        .iter()
176        .map(|(name, value)| (name.to_ascii_lowercase(), value.trim().to_string()))
177        .collect()
178}
179
180/// Returns whether `Cache-Control` forbids reusing the response.
181fn forbids_caching(headers: &[(String, String)]) -> bool {
182    headers.iter().any(|(name, value)| {
183        name.eq_ignore_ascii_case("cache-control")
184            && (value.contains("no-store") || value.contains("no-cache"))
185    })
186}
187
188/// Derives the expiry time from `Cache-Control: max-age`.
189///
190/// Returns `None` when no freshness lifetime is given (the response stays in
191/// the cache until LRU eviction).
192fn expiry_from_headers(headers: &[(String, String)]) -> Option<SystemTime> {
193    for (name, value) in headers {
194        if !name.eq_ignore_ascii_case("cache-control") {
195            continue;
196        }
197        if let Some(pos) = value.find("max-age=") {
198            let digits = value[pos + 8..]
199                .split(|c: char| !c.is_ascii_digit())
200                .next()
201                .unwrap_or("0");
202            if let Ok(max_age) = digits.parse::<u64>() {
203                return Some(SystemTime::now() + Duration::from_secs(max_age));
204            }
205        }
206    }
207    None
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213    use crate::platform::network::core::{Response, StatusCode};
214
215    fn response(body: &[u8], headers: Vec<(String, String)>) -> Response {
216        Response {
217            url: "https://example.test/".to_string(),
218            status: StatusCode::from(hyper::StatusCode::OK),
219            reason_phrase: "OK".to_string(),
220            headers,
221            body: body.to_vec(),
222        }
223    }
224
225    fn cache_control(value: &str) -> Vec<(String, String)> {
226        vec![("cache-control".to_string(), value.to_string())]
227    }
228
229    #[test]
230    fn cached_response_is_returned_until_expiry() {
231        let cache = Cache::with_capacity(16);
232        let url = "https://example.test/page";
233
234        cache.set(url, &response(b"hello", cache_control("max-age=100")));
235
236        let cached = cache.get(url).expect("response should be cached");
237        assert_eq!(cached.body, b"hello");
238    }
239
240    #[test]
241    fn expired_entry_is_treated_as_a_miss() {
242        let cache = Cache::with_capacity(16);
243        let url = "https://example.test/page";
244
245        cache.set(url, &response(b"hello", cache_control("max-age=0")));
246
247        assert!(
248            cache.get(url).is_none(),
249            "max-age=0 must expire immediately"
250        );
251    }
252
253    #[test]
254    fn no_store_responses_are_not_cached() {
255        let cache = Cache::with_capacity(16);
256        let url = "https://example.test/page";
257
258        cache.set(url, &response(b"hello", cache_control("no-store")));
259        cache.set(
260            "https://example.test/no-cache",
261            &response(b"hello", cache_control("no-cache")),
262        );
263
264        assert!(cache.get(url).is_none());
265        assert!(cache.get("https://example.test/no-cache").is_none());
266    }
267
268    #[test]
269    fn lru_evicts_the_oldest_entry() {
270        let cache = Cache::with_capacity(2);
271
272        cache.set("https://example.test/a", &response(b"a", Vec::new()));
273        cache.set("https://example.test/b", &response(b"b", Vec::new()));
274        cache.set("https://example.test/c", &response(b"c", Vec::new()));
275
276        assert!(cache.get("https://example.test/a").is_none());
277        assert!(cache.get("https://example.test/b").is_some());
278        assert!(cache.get("https://example.test/c").is_some());
279    }
280
281    #[test]
282    fn disabled_cache_ignores_sets_and_misses() {
283        let cache = Cache::with_capacity(16);
284        let url = "https://example.test/page";
285
286        cache.set_enabled(false);
287        cache.set(url, &response(b"hello", cache_control("max-age=100")));
288        assert!(!cache.is_enabled());
289        assert!(cache.get(url).is_none());
290
291        cache.set_enabled(true);
292        assert!(cache.is_enabled());
293        assert!(
294            cache.get(url).is_none(),
295            "set while disabled must be ignored"
296        );
297
298        cache.set(url, &response(b"hello", cache_control("max-age=100")));
299        assert_eq!(cache.get(url).unwrap().body, b"hello");
300    }
301
302    #[test]
303    fn len_tracks_entries_and_clear_empties_it() {
304        let cache = Cache::with_capacity(16);
305
306        assert!(cache.is_empty());
307        cache.set("https://example.test/a", &response(b"a", Vec::new()));
308        cache.set("https://example.test/b", &response(b"b", Vec::new()));
309        assert_eq!(cache.len(), 2);
310
311        cache.clear();
312        assert!(cache.is_empty());
313        assert!(cache.get("https://example.test/a").is_none());
314    }
315
316    #[test]
317    fn same_url_cache_entry_requires_matching_request_headers() {
318        let cache = Cache::with_capacity(16);
319        let url = "https://example.test/language";
320        let request = vec![("Accept-Language".to_string(), "ja".to_string())];
321        cache.set_for_request(
322            url,
323            &response(
324                b"Japanese",
325                vec![("Vary".to_string(), "Accept-Language".to_string())],
326            ),
327            &request,
328        );
329
330        assert_eq!(
331            cache.get_for_request(url, &request).unwrap().body,
332            b"Japanese"
333        );
334        assert!(
335            cache
336                .get_for_request(url, &[("Accept-Language".to_string(), "en".to_string())])
337                .is_none()
338        );
339        assert!(cache.get(url).is_none());
340    }
341
342    #[test]
343    fn stats_count_hits_misses_and_lru_evictions() {
344        let cache = Cache::with_capacity(2);
345        let a = "https://example.test/a";
346        let b = "https://example.test/b";
347        let c = "https://example.test/c";
348
349        assert!(cache.get(a).is_none());
350        cache.set(a, &response(b"a", Vec::new()));
351        assert!(cache.get(a).is_some());
352        assert!(cache.get(a).is_some());
353        cache.set(b, &response(b"b", Vec::new()));
354        cache.set(c, &response(b"c", Vec::new()));
355
356        let stats = cache.stats();
357        assert_eq!(stats.name, "http");
358        assert_eq!(stats.hits, 2);
359        assert_eq!(stats.misses, 1);
360        assert_eq!(stats.evictions, 1, "inserting c evicts the oldest entry");
361    }
362
363    #[test]
364    fn expired_entries_count_as_misses_and_evictions() {
365        let cache = Cache::with_capacity(16);
366        let url = "https://example.test/page";
367
368        cache.set(url, &response(b"hello", cache_control("max-age=0")));
369        assert!(cache.get(url).is_none());
370
371        let stats = cache.stats();
372        assert_eq!(stats.hits, 0);
373        assert_eq!(stats.misses, 1);
374        assert_eq!(stats.evictions, 1);
375    }
376}