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 super::core::Response;
11
12const 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 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 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 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 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 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
108fn 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
116fn 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}