orinium_browser/platform/network/
cache.rs1use 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
14const 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 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 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 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 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 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 pub fn stats(&self) -> CacheStatsSnapshot {
139 self.stats.snapshot()
140 }
141
142 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
180fn 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
188fn 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}