1use std::time::{Duration, SystemTime};
15use trillium_caching_headers::{CacheControlDirective, CacheControlHeader, CachingHeadersExt};
16use trillium_http::{Headers, KnownHeaderName, Method, Status};
17
18pub(crate) fn effective_response_cache_control(
26 response_headers: &Headers,
27 options: &CacheOptions,
28) -> (Option<CacheControlHeader>, bool) {
29 if options.shared
30 && let Some(raw) = response_headers.get_str(KnownHeaderName::CdnCacheControl)
31 && looks_like_valid_sf_dictionary(raw)
32 && let Some(cdn_cc) = response_headers.cdn_cache_control()
33 && !cdn_cc.is_empty()
34 {
35 return (Some(cdn_cc), true);
36 }
37 (response_headers.cache_control(), false)
38}
39
40fn derive_response_cache_control(
43 response_headers: &Headers,
44 options: &CacheOptions,
45) -> (Option<CacheControlHeader>, bool) {
46 let (mut response_cache_control, targeted_cc_in_effect) =
47 effective_response_cache_control(response_headers, options);
48
49 if response_cache_control.is_none()
55 && response_headers
56 .get_str(KnownHeaderName::Pragma)
57 .is_some_and(|p| p.contains("no-cache"))
58 {
59 response_cache_control = Some(CacheControlHeader::from(CacheControlDirective::NoCache));
60 }
61
62 (response_cache_control, targeted_cc_in_effect)
63}
64
65fn looks_like_valid_sf_dictionary(s: &str) -> bool {
72 let s = s.trim();
73 if s.is_empty() {
74 return false;
75 }
76 s.split(',').all(|member| {
77 let member = member.trim();
78 if member.is_empty() {
79 return false;
80 }
81 let key = member.split_once('=').map_or(member, |(k, _)| k).trim_end();
82 is_valid_sf_key(key)
83 })
84}
85
86fn is_valid_sf_key(s: &str) -> bool {
93 let mut chars = s.chars();
94 let Some(first) = chars.next() else {
95 return false;
96 };
97 if !first.is_ascii_alphabetic() && first != '*' {
98 return false;
99 }
100 chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.' | '*'))
101}
102
103#[derive(Debug, Copy, Clone, fieldwork::Fieldwork)]
105#[fieldwork(get, set, get_mut, with, rename_predicates)]
106pub struct CacheOptions {
107 pub(crate) shared: bool,
118
119 pub(crate) cache_heuristic: f32,
126
127 #[field(copy)]
132 pub(crate) immutable_min_time_to_live: Duration,
133}
134
135impl Default for CacheOptions {
136 fn default() -> Self {
137 Self {
138 shared: false,
139 cache_heuristic: 0.1,
140 immutable_min_time_to_live: Duration::from_secs(24 * 3600),
141 }
142 }
143}
144
145#[derive(Debug, Clone)]
154pub struct CachePolicy {
155 pub(crate) request_method: Method,
156 pub(crate) vary_snapshot: Vec<(String, Option<String>)>,
161 pub(crate) response_status: Status,
162 pub(crate) response_headers: Headers,
163 pub(crate) response_cache_control: Option<CacheControlHeader>,
164 pub(crate) targeted_cc_in_effect: bool,
170 pub(crate) response_time: SystemTime,
171 pub(crate) options: CacheOptions,
172}
173
174impl CachePolicy {
175 pub fn same_variant_as(&self, other: &Self) -> bool {
182 self.vary_snapshot == other.vary_snapshot
183 }
184
185 pub(crate) fn new(
188 request_method: Method,
189 request_headers: &Headers,
190 response_status: Status,
191 response_headers: Headers,
192 response_time: SystemTime,
193 options: CacheOptions,
194 ) -> Self {
195 let (response_cache_control, targeted_cc_in_effect) =
196 derive_response_cache_control(&response_headers, &options);
197
198 let vary_snapshot = build_vary_snapshot(&response_headers, request_headers);
199
200 Self {
201 request_method,
202 vary_snapshot,
203 response_status,
204 response_headers,
205 response_cache_control,
206 targeted_cc_in_effect,
207 response_time,
208 options,
209 }
210 }
211}
212
213#[cfg(feature = "fs")]
221#[derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)]
222pub(crate) struct PolicyRepr {
223 request_method: Method,
224 vary_snapshot: Vec<(String, Option<String>)>,
225 response_status: Status,
226 response_headers: Headers,
227 #[rkyv(with = rkyv::with::AsUnixTime)]
228 response_time: SystemTime,
229 shared: bool,
230 cache_heuristic: f32,
231 immutable_min_time_to_live: Duration,
232}
233
234#[cfg(feature = "fs")]
235impl From<&CachePolicy> for PolicyRepr {
236 fn from(policy: &CachePolicy) -> Self {
237 let CacheOptions {
238 shared,
239 cache_heuristic,
240 immutable_min_time_to_live,
241 } = policy.options;
242 Self {
243 request_method: policy.request_method,
244 vary_snapshot: policy.vary_snapshot.clone(),
245 response_status: policy.response_status,
246 response_headers: policy.response_headers.clone(),
247 response_time: policy.response_time,
248 shared,
249 cache_heuristic,
250 immutable_min_time_to_live,
251 }
252 }
253}
254
255#[cfg(feature = "fs")]
256impl From<PolicyRepr> for CachePolicy {
257 fn from(repr: PolicyRepr) -> Self {
258 let PolicyRepr {
259 request_method,
260 vary_snapshot,
261 response_status,
262 response_headers,
263 response_time,
264 shared,
265 cache_heuristic,
266 immutable_min_time_to_live,
267 } = repr;
268 let options = CacheOptions {
269 shared,
270 cache_heuristic,
271 immutable_min_time_to_live,
272 };
273 let (response_cache_control, targeted_cc_in_effect) =
274 derive_response_cache_control(&response_headers, &options);
275 Self {
276 request_method,
277 vary_snapshot,
278 response_status,
279 response_headers,
280 response_cache_control,
281 targeted_cc_in_effect,
282 response_time,
283 options,
284 }
285 }
286}
287
288fn build_vary_snapshot(
289 response_headers: &Headers,
290 request_headers: &Headers,
291) -> Vec<(String, Option<String>)> {
292 let Some(values) = response_headers.get_values(KnownHeaderName::Vary) else {
298 return Vec::new();
299 };
300 values
301 .iter()
302 .filter_map(|v| v.as_str())
303 .flat_map(|line| line.split(','))
304 .map(str::trim)
305 .filter(|n| !n.is_empty())
306 .map(|name| {
307 let lower = name.to_ascii_lowercase();
308 let value = request_headers.get_str(lower.as_str()).map(str::to_string);
309 (lower, value)
310 })
311 .collect()
312}
313
314#[cfg(test)]
315mod tests {
316 use super::*;
317 use crate::test_helpers::*;
318 use trillium_client::ConnExt;
319 use trillium_http::KnownHeaderName::*;
320
321 #[test]
325 fn vary_snapshot_handles_multiple_header_lines() {
326 let mut conn = exchange(
327 Method::Get,
328 &[(AcceptEncoding, "gzip"), (AcceptLanguage, "en-US")],
329 Status::Ok,
330 &[(Vary, "Accept-Encoding")],
331 );
332 conn.response_headers_mut().append(Vary, "Accept-Language");
335
336 let policy = policy_from(&conn, SystemTime::now(), private_cache());
337 assert_eq!(
338 policy.vary_snapshot,
339 vec![
340 ("accept-encoding".to_string(), Some("gzip".to_string())),
341 ("accept-language".to_string(), Some("en-US".to_string())),
342 ]
343 );
344 }
345
346 #[test]
349 fn vary_snapshot_captures_star_from_second_line() {
350 let mut conn = exchange(
351 Method::Get,
352 &[],
353 Status::Ok,
354 &[(Vary, "")], );
356 conn.response_headers_mut().append(Vary, "*");
357
358 let policy = policy_from(&conn, SystemTime::now(), private_cache());
359 assert!(policy.vary_snapshot.iter().any(|(name, _)| name == "*"));
361 }
362
363 #[test]
364 fn vary_snapshot_captures_named_request_headers() {
365 let conn = exchange(
366 Method::Get,
367 &[(AcceptEncoding, "gzip"), (AcceptLanguage, "en-US")],
368 Status::Ok,
369 &[(Vary, "Accept-Encoding, Accept-Language")],
370 );
371 let policy = policy_from(&conn, SystemTime::now(), private_cache());
372 assert_eq!(
373 policy.vary_snapshot,
374 vec![
375 ("accept-encoding".to_string(), Some("gzip".to_string())),
376 ("accept-language".to_string(), Some("en-US".to_string())),
377 ]
378 );
379 }
380
381 #[test]
382 fn sf_dictionary_validator() {
383 assert!(looks_like_valid_sf_dictionary("max-age=600"));
385 assert!(looks_like_valid_sf_dictionary("no-store"));
386 assert!(looks_like_valid_sf_dictionary("max-age=600, no-store"));
387 assert!(looks_like_valid_sf_dictionary(r#"max-age="600""#));
390
391 assert!(looks_like_valid_sf_dictionary("MaX-aGe=3600"));
395
396 assert!(!looks_like_valid_sf_dictionary("max-age=10000, &&&&&"));
398 assert!(!looks_like_valid_sf_dictionary("&&&&&"));
399 assert!(!looks_like_valid_sf_dictionary(""));
401 assert!(!looks_like_valid_sf_dictionary(" "));
402 assert!(!looks_like_valid_sf_dictionary("max-age=600,"));
404 }
405
406 #[test]
407 fn vary_snapshot_records_absent_request_header_as_none() {
408 let conn = exchange(Method::Get, &[], Status::Ok, &[(Vary, "Accept-Encoding")]);
409 let policy = policy_from(&conn, SystemTime::now(), private_cache());
410 assert_eq!(
411 policy.vary_snapshot,
412 vec![("accept-encoding".to_string(), None)]
413 );
414 }
415
416 #[cfg(feature = "fs")]
417 #[test]
418 fn policy_round_trips_through_rkyv() {
419 let conn = exchange(
420 Method::Get,
421 &[(AcceptEncoding, "gzip")],
422 Status::Ok,
423 &[(CacheControl, "max-age=600"), (Vary, "Accept-Encoding")],
424 );
425 let policy = policy_from(&conn, SystemTime::now(), private_cache());
426
427 let repr = PolicyRepr::from(&policy);
428 let bytes = rkyv::to_bytes::<rkyv::rancor::Error>(&repr).unwrap();
429 let restored: CachePolicy = rkyv::from_bytes::<PolicyRepr, rkyv::rancor::Error>(&bytes)
430 .unwrap()
431 .into();
432
433 assert_eq!(restored.request_method, policy.request_method);
434 assert_eq!(restored.response_status, policy.response_status);
435 assert_eq!(restored.vary_snapshot, policy.vary_snapshot);
436 assert_eq!(restored.response_time, policy.response_time);
437 assert_eq!(
438 restored.response_headers.get_str(CacheControl),
439 policy.response_headers.get_str(CacheControl)
440 );
441 assert_eq!(
442 restored.response_headers.get_str(Vary),
443 policy.response_headers.get_str(Vary)
444 );
445 assert_eq!(restored.targeted_cc_in_effect, policy.targeted_cc_in_effect);
447 assert_eq!(
448 restored.response_cache_control.is_some(),
449 policy.response_cache_control.is_some()
450 );
451 }
452}