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 super::core::Response;
11
12/// Maximum number of responses kept before the oldest ones are evicted.
13const DEFAULT_MAX_ENTRIES: usize = 512;
14
15#[derive(Debug)]
16pub struct Cache {
17    enabled: AtomicBool,
18    store: Arc<Mutex<LruCache<String, CachedResponse>>>,
19}
20
21#[derive(Debug)]
22struct CachedResponse {
23    response: Response,
24    expires_at: Option<SystemTime>,
25}
26
27impl Default for Cache {
28    fn default() -> Self {
29        Self::new()
30    }
31}
32
33impl Cache {
34    pub fn new() -> Self {
35        Self::with_capacity(DEFAULT_MAX_ENTRIES)
36    }
37
38    /// Creates a cache holding at most `max_entries` responses.
39    pub fn with_capacity(max_entries: usize) -> Self {
40        let capacity = NonZeroUsize::new(max_entries.max(1)).unwrap();
41        Self {
42            enabled: AtomicBool::new(true),
43            store: Arc::new(Mutex::new(LruCache::new(capacity))),
44        }
45    }
46
47    /// Enables or disables caching. When disabled, `get` always misses and
48    /// `set` is a no-op.
49    pub fn set_enabled(&self, enabled: bool) {
50        self.enabled.store(enabled, Ordering::Relaxed);
51    }
52
53    pub fn is_enabled(&self) -> bool {
54        self.enabled.load(Ordering::Relaxed)
55    }
56
57    /// Returns a cached response for `url` if present and not expired.
58    pub fn get(&self, url: &str) -> Option<Response> {
59        if !self.is_enabled() {
60            return None;
61        }
62        let mut store = self.store.lock().ok()?;
63        let entry = store.get(url)?;
64        if let Some(exp) = entry.expires_at
65            && SystemTime::now() > exp
66        {
67            store.pop(url);
68            return None;
69        }
70        Some(entry.response.clone())
71    }
72
73    /// Caches a response unless its headers forbid it.
74    pub fn set(&self, url: &str, response: &Response) {
75        if !self.is_enabled() {
76            return;
77        }
78        if forbids_caching(&response.headers) {
79            return;
80        }
81        if let Ok(mut store) = self.store.lock() {
82            store.put(
83                url.to_string(),
84                CachedResponse {
85                    response: response.clone(),
86                    expires_at: expiry_from_headers(&response.headers),
87                },
88            );
89        }
90    }
91
92    /// Number of responses currently held.
93    pub fn len(&self) -> usize {
94        self.store.lock().map(|store| store.len()).unwrap_or(0)
95    }
96
97    pub fn is_empty(&self) -> bool {
98        self.len() == 0
99    }
100
101    pub fn clear(&self) {
102        if let Ok(mut store) = self.store.lock() {
103            store.clear();
104        }
105    }
106}
107
108/// Returns whether `Cache-Control` forbids reusing the response.
109fn forbids_caching(headers: &[(String, String)]) -> bool {
110    headers.iter().any(|(name, value)| {
111        name.eq_ignore_ascii_case("cache-control")
112            && (value.contains("no-store") || value.contains("no-cache"))
113    })
114}
115
116/// Derives the expiry time from `Cache-Control: max-age`.
117///
118/// Returns `None` when no freshness lifetime is given (the response stays in
119/// the cache until LRU eviction).
120fn expiry_from_headers(headers: &[(String, String)]) -> Option<SystemTime> {
121    for (name, value) in headers {
122        if !name.eq_ignore_ascii_case("cache-control") {
123            continue;
124        }
125        if let Some(pos) = value.find("max-age=") {
126            let digits = value[pos + 8..]
127                .split(|c: char| !c.is_ascii_digit())
128                .next()
129                .unwrap_or("0");
130            if let Ok(max_age) = digits.parse::<u64>() {
131                return Some(SystemTime::now() + Duration::from_secs(max_age));
132            }
133        }
134    }
135    None
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141    use crate::platform::network::core::{Response, StatusCode};
142
143    fn response(body: &[u8], headers: Vec<(String, String)>) -> Response {
144        Response {
145            url: "https://example.test/".to_string(),
146            status: StatusCode::from(hyper::StatusCode::OK),
147            reason_phrase: "OK".to_string(),
148            headers,
149            body: body.to_vec(),
150        }
151    }
152
153    fn cache_control(value: &str) -> Vec<(String, String)> {
154        vec![("cache-control".to_string(), value.to_string())]
155    }
156
157    #[test]
158    fn cached_response_is_returned_until_expiry() {
159        let cache = Cache::with_capacity(16);
160        let url = "https://example.test/page";
161
162        cache.set(url, &response(b"hello", cache_control("max-age=100")));
163
164        let cached = cache.get(url).expect("response should be cached");
165        assert_eq!(cached.body, b"hello");
166    }
167
168    #[test]
169    fn expired_entry_is_treated_as_a_miss() {
170        let cache = Cache::with_capacity(16);
171        let url = "https://example.test/page";
172
173        cache.set(url, &response(b"hello", cache_control("max-age=0")));
174
175        assert!(
176            cache.get(url).is_none(),
177            "max-age=0 must expire immediately"
178        );
179    }
180
181    #[test]
182    fn no_store_responses_are_not_cached() {
183        let cache = Cache::with_capacity(16);
184        let url = "https://example.test/page";
185
186        cache.set(url, &response(b"hello", cache_control("no-store")));
187        cache.set(
188            "https://example.test/no-cache",
189            &response(b"hello", cache_control("no-cache")),
190        );
191
192        assert!(cache.get(url).is_none());
193        assert!(cache.get("https://example.test/no-cache").is_none());
194    }
195
196    #[test]
197    fn lru_evicts_the_oldest_entry() {
198        let cache = Cache::with_capacity(2);
199
200        cache.set("https://example.test/a", &response(b"a", Vec::new()));
201        cache.set("https://example.test/b", &response(b"b", Vec::new()));
202        cache.set("https://example.test/c", &response(b"c", Vec::new()));
203
204        assert!(cache.get("https://example.test/a").is_none());
205        assert!(cache.get("https://example.test/b").is_some());
206        assert!(cache.get("https://example.test/c").is_some());
207    }
208
209    #[test]
210    fn disabled_cache_ignores_sets_and_misses() {
211        let cache = Cache::with_capacity(16);
212        let url = "https://example.test/page";
213
214        cache.set_enabled(false);
215        cache.set(url, &response(b"hello", cache_control("max-age=100")));
216        assert!(!cache.is_enabled());
217        assert!(cache.get(url).is_none());
218
219        cache.set_enabled(true);
220        assert!(cache.is_enabled());
221        assert!(
222            cache.get(url).is_none(),
223            "set while disabled must be ignored"
224        );
225
226        cache.set(url, &response(b"hello", cache_control("max-age=100")));
227        assert_eq!(cache.get(url).unwrap().body, b"hello");
228    }
229
230    #[test]
231    fn len_tracks_entries_and_clear_empties_it() {
232        let cache = Cache::with_capacity(16);
233
234        assert!(cache.is_empty());
235        cache.set("https://example.test/a", &response(b"a", Vec::new()));
236        cache.set("https://example.test/b", &response(b"b", Vec::new()));
237        assert_eq!(cache.len(), 2);
238
239        cache.clear();
240        assert!(cache.is_empty());
241        assert!(cache.get("https://example.test/a").is_none());
242    }
243}