static_web_server/mem_cache/
cache.rs1use bytes::Bytes;
14use compact_str::CompactString;
15use headers::{AcceptRanges, ContentLength, ContentRange, HeaderMap, HeaderMapExt, LastModified};
16use hyper::header::{CONTENT_TYPE, ETAG, HeaderName, HeaderValue};
17use hyper::{Response, StatusCode};
18use mini_moka::sync::Cache;
19use std::path::Path;
20use std::sync::{Arc, OnceLock};
21use std::time::Duration;
22
23use crate::Result;
24use crate::body::{self, Body};
25use crate::conditional_headers::{ConditionalBody, ConditionalHeaders, Validators};
26use crate::handler::RequestHandlerOpts;
27use crate::response::range::{BadRangeError, bytes_range};
28
29pub(crate) static CACHE_STORE: OnceLock<Cache<CompactString, Arc<MemFile>>> = OnceLock::new();
32
33pub(crate) static X_CACHE: HeaderName = HeaderName::from_static("x-cache");
35pub(crate) static X_CACHE_HIT: HeaderValue = HeaderValue::from_static("HIT");
37
38pub struct MemCacheOpts {
40 pub max_file_size: u64,
42}
43
44pub const DEFAULT_CAPACITY: u64 = 100;
46pub const DEFAULT_TTL: u64 = 1800;
48pub const DEFAULT_TTI: u64 = 300;
50pub const DEFAULT_MAX_FILE_SIZE: u64 = 8192;
52
53const MAX_CAPACITY: u64 = 100_000;
55const MAX_TTL: u64 = 86_400;
57const MAX_TTI: u64 = 3_600;
59const MAX_FILE_SIZE: u64 = 32_768;
61
62impl MemCacheOpts {
63 #[inline]
66 pub fn new(max_file_size: u64) -> Self {
67 Self {
68 max_file_size: max_file_size * 1024,
69 }
70 }
71}
72
73pub fn init(handler_opts: &mut RequestHandlerOpts) -> Result {
79 if let Some(advanced_opts) = handler_opts.advanced_opts.as_ref()
80 && let Some(opts) = advanced_opts.memory_cache.as_ref()
81 {
82 let capacity = opts.capacity.unwrap_or(DEFAULT_CAPACITY).min(MAX_CAPACITY);
83 let ttl = opts.ttl.unwrap_or(DEFAULT_TTL).min(MAX_TTL);
84 let tti = opts.tti.unwrap_or(DEFAULT_TTI).min(MAX_TTI);
85 let max_file_size = opts
86 .max_file_size
87 .unwrap_or(DEFAULT_MAX_FILE_SIZE)
88 .min(MAX_FILE_SIZE);
89
90 tracing::info!(
91 enabled = true,
92 capacity,
93 ttl_seconds = ttl,
94 tti_seconds = tti,
95 max_file_size_kib = max_file_size,
96 "in-memory cache"
97 );
98
99 let mem_opts = MemCacheOpts::new(max_file_size);
100
101 let cache = Cache::builder()
102 .max_capacity(capacity)
103 .time_to_live(Duration::from_secs(ttl))
104 .time_to_idle(Duration::from_secs(tti))
105 .build();
106
107 if CACHE_STORE.set(cache).is_err() {
108 tracing::debug!("in-memory cache store already initialized; reusing existing store");
109 }
110
111 handler_opts.memory_cache = Some(mem_opts);
112
113 return Ok(());
114 }
115
116 tracing::info!(enabled = false, "in-memory cache");
117
118 Ok(())
119}
120
121pub(crate) fn lookup(
132 file_path: &Path,
133 headers_opt: &HeaderMap,
134) -> Option<Result<Response<Body>, StatusCode>> {
135 let file_path_str = file_path.to_str()?;
136 let store = CACHE_STORE.get()?;
137 let key = CompactString::from(file_path_str);
138 let mem_file = store.get(&key)?;
139 tracing::debug!("file `{file_path_str}` served from the in-memory cache store");
140 Some(mem_file.response_body(headers_opt).map(|mut resp| {
143 resp.headers_mut()
144 .insert(X_CACHE.clone(), X_CACHE_HIT.clone());
145 resp
146 }))
147}
148
149#[derive(Debug, Clone)]
150pub(crate) struct MemFileTempOpts {
151 pub(crate) file_path: String,
152 pub(crate) content_type: HeaderValue,
156 pub(crate) last_modified: Option<LastModified>,
157 pub(crate) etag: Option<HeaderValue>,
160}
161
162impl MemFileTempOpts {
163 pub(crate) fn new(
164 file_path: String,
165 content_type: HeaderValue,
166 last_modified: Option<LastModified>,
167 etag: Option<HeaderValue>,
168 ) -> Self {
169 Self {
170 file_path,
171 content_type,
172 last_modified,
173 etag,
174 }
175 }
176}
177
178#[derive(Debug)]
184pub(crate) struct MemFile {
185 data: Bytes,
187 content_type: HeaderValue,
191 last_modified: Option<LastModified>,
193 etag: Option<HeaderValue>,
197}
198
199impl MemFile {
200 #[inline]
201 pub(crate) fn new(
202 data: Bytes,
203 content_type: HeaderValue,
204 last_modified: Option<LastModified>,
205 etag: Option<HeaderValue>,
206 ) -> Self {
207 Self {
208 data,
209 content_type,
210 last_modified,
211 etag,
212 }
213 }
214
215 pub(crate) fn response_body(&self, headers: &HeaderMap) -> Result<Response<Body>, StatusCode> {
223 let conditionals = ConditionalHeaders::new(headers);
224 let modified = self.last_modified;
225
226 let etag_typed: Option<headers::ETag> = if conditionals.if_none_match.is_some()
230 || conditionals.if_match.is_some()
231 || conditionals.if_range.is_some()
232 {
233 self.etag
234 .as_ref()
235 .and_then(|hv| hv.to_str().ok().and_then(|s| s.parse().ok()))
236 } else {
237 None
238 };
239
240 let validators = Validators {
241 last_modified: modified,
242 etag: etag_typed.as_ref(),
243 etag_value: self.etag.as_ref(),
244 };
245
246 match conditionals.check(validators) {
247 ConditionalBody::NoBody(resp) => Ok(resp),
248 ConditionalBody::WithBody(range) => {
249 let total_len = self.data.len() as u64;
250
251 bytes_range(range, total_len)
252 .map(|(start, end)| {
253 let sub_len = end - start;
254 let is_partial = sub_len != total_len;
255
256 let body_bytes = if is_partial {
260 self.data.slice(start as usize..end as usize)
261 } else {
262 self.data.clone()
263 };
264 let mut resp = Response::new(body::full(body_bytes));
265
266 if is_partial {
267 *resp.status_mut() = StatusCode::PARTIAL_CONTENT;
268 match ContentRange::bytes(start..end, total_len) {
269 Ok(range) => {
270 resp.headers_mut().typed_insert(range);
271 }
272 Err(err) => {
273 tracing::error!("invalid content range error: {:?}", err);
274 let mut resp = Response::new(crate::body::empty());
275 *resp.status_mut() = StatusCode::RANGE_NOT_SATISFIABLE;
276 resp.headers_mut()
277 .typed_insert(ContentRange::unsatisfied_bytes(total_len));
278 return Ok(resp);
279 }
280 }
281 }
282
283 let h = resp.headers_mut();
284 h.typed_insert(ContentLength(sub_len));
285 h.insert(CONTENT_TYPE, self.content_type.clone());
288 h.typed_insert(AcceptRanges::bytes());
289
290 if let Some(last_modified) = modified {
291 h.typed_insert(last_modified);
292 }
293 if let Some(etag) = self.etag.as_ref() {
294 h.insert(ETAG, etag.clone());
295 }
296
297 Ok(resp)
298 })
299 .unwrap_or_else(|BadRangeError| {
300 let mut resp = Response::new(crate::body::empty());
301 *resp.status_mut() = StatusCode::RANGE_NOT_SATISFIABLE;
302 resp.headers_mut()
303 .typed_insert(ContentRange::unsatisfied_bytes(total_len));
304 Ok(resp)
305 })
306 }
307 }
308 }
309}
310
311#[cfg(test)]
312mod tests {
313 use super::*;
314
315 #[test]
316 fn mem_cache_opts_converts_kib_to_bytes() {
317 let opts = MemCacheOpts::new(8192);
318 assert_eq!(opts.max_file_size, 8192 * 1024);
320 }
321
322 #[test]
323 fn mem_cache_opts_zero_size() {
324 let opts = MemCacheOpts::new(0);
325 assert_eq!(opts.max_file_size, 0);
326 }
327
328 #[test]
329 fn default_constants_are_sane() {
330 assert_eq!(DEFAULT_CAPACITY, 100);
331 assert_eq!(DEFAULT_TTL, 1800);
332 assert_eq!(DEFAULT_TTI, 300);
333 assert_eq!(DEFAULT_MAX_FILE_SIZE, 8192);
334 }
335
336 #[test]
337 fn max_constants_enforce_upper_bounds() {
338 const {
340 assert!(MAX_CAPACITY >= DEFAULT_CAPACITY);
341 }
342 const {
344 assert!(MAX_TTL >= DEFAULT_TTL);
345 }
346 const {
348 assert!(MAX_TTI >= DEFAULT_TTI);
349 }
350 const {
352 assert!(MAX_FILE_SIZE >= DEFAULT_MAX_FILE_SIZE);
353 }
354 }
355
356 #[test]
357 fn init_returns_ok_without_advanced_opts() {
358 let mut handler_opts = crate::handler::RequestHandlerOpts::default();
359 let result = init(&mut handler_opts);
360 assert!(result.is_ok());
361 assert!(handler_opts.memory_cache.is_none());
362 }
363
364 #[test]
365 fn init_returns_ok_without_memory_cache_section() {
366 let mut handler_opts = RequestHandlerOpts {
367 advanced_opts: Some(crate::settings::Advanced {
368 headers: None,
369 rewrites: None,
370 redirects: None,
371 virtual_hosts: None,
372 memory_cache: None,
373 }),
374 ..Default::default()
375 };
376 let result = init(&mut handler_opts);
377 assert!(result.is_ok());
378 assert!(handler_opts.memory_cache.is_none());
379 }
380
381 #[test]
382 fn init_with_defaults_creates_cache() {
383 let mut handler_opts = RequestHandlerOpts {
384 advanced_opts: Some(crate::settings::Advanced {
385 headers: None,
386 rewrites: None,
387 redirects: None,
388 virtual_hosts: None,
389 memory_cache: Some(crate::settings::file::MemoryCache {
390 capacity: None,
391 ttl: None,
392 tti: None,
393 max_file_size: None,
394 }),
395 }),
396 ..Default::default()
397 };
398 let result = init(&mut handler_opts);
399 assert!(result.is_ok());
400 assert!(handler_opts.memory_cache.is_some());
401 let opts = handler_opts.memory_cache.unwrap();
402 assert_eq!(opts.max_file_size, DEFAULT_MAX_FILE_SIZE * 1024);
403 }
404
405 #[test]
406 fn init_clamps_values_to_max() {
407 let capacity = 999_999u64.min(MAX_CAPACITY);
410 let ttl = 999_999u64.min(MAX_TTL);
411 let tti = 999_999u64.min(MAX_TTI);
412 let max_file_size = 999_999u64.min(MAX_FILE_SIZE);
413
414 assert_eq!(capacity, MAX_CAPACITY);
415 assert_eq!(ttl, MAX_TTL);
416 assert_eq!(tti, MAX_TTI);
417 assert_eq!(max_file_size, MAX_FILE_SIZE);
418
419 let opts = MemCacheOpts::new(max_file_size);
420 assert_eq!(opts.max_file_size, MAX_FILE_SIZE * 1024);
421 }
422
423 #[test]
424 fn lookup_returns_none_when_store_uninitialized() {
425 let headers = HeaderMap::new();
430 let path = std::path::Path::new("/nonexistent/path.txt");
431 if CACHE_STORE.get().is_none() {
435 assert!(lookup(path, &headers).is_none());
436 }
437 }
438
439 #[test]
440 fn x_cache_header_constants_are_valid() {
441 assert_eq!(X_CACHE.as_str(), "x-cache");
442 assert_eq!(X_CACHE_HIT.to_str().unwrap(), "HIT");
443 }
444}