Skip to main content

trillium_cache/
policy.rs

1//! Stored cache policy — the value type for a captured exchange.
2//!
3//! Section-specific logic lives in sibling modules:
4//! - [`crate::storability`] — RFC 9111 §3 (`is_storable`)
5//! - [`crate::freshness`]   — RFC 9111 §4.2 (`age` / `time_to_live` / `is_stale`)
6//! - [`crate::validation`]  — RFC 9111 §4.3 (`before_request`)
7//!
8//! Portions of this and the sibling modules are derived from
9//! [`rusty-http-cache-semantics`](https://github.com/kornelski/rusty-http-cache-semantics)
10//! by Kornel Lesiński, used under the BSD-2-Clause license. See
11//! `LICENSE-BSD-2-CLAUSE-http-cache-semantics` at the crate root for the
12//! original notice.
13
14use std::time::{Duration, SystemTime};
15use trillium_caching_headers::{CacheControlDirective, CacheControlHeader, CachingHeadersExt};
16use trillium_http::{Headers, KnownHeaderName, Method, Status};
17
18/// Resolve the effective response Cache-Control for a response, applying
19/// the RFC 9213 §2.2 targeted-field override:
20/// when the cache is shared and a non-empty, validly-structured
21/// `CDN-Cache-Control` is present, it fully replaces `Cache-Control` (and
22/// downstream code MUST also ignore `Expires`, signalled by the returned
23/// `targeted_cc_in_effect`). Per §2.1, parse-error or empty targeted
24/// fields MUST be ignored.
25pub(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
40// Derive the effective response Cache-Control and its targeted-field flag from a
41// response's headers and caching options. This is a pure function of the two inputs.
42fn 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    // RFC 9111 §5.4: when no Cache-Control is present, treat
50    // `Pragma: no-cache` as if `Cache-Control: no-cache` were set. This
51    // is suppressed when a targeted field took effect (Pragma is part of
52    // the Cache-Control / Expires family the targeted-field rule
53    // displaces).
54    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
65/// RFC 9213 §2.1: targeted fields are Dictionary Structured Fields (RFC
66/// 8941 §3.2). A full SF parser is out of scope, but this catches the
67/// common "garbage trailing tokens" case (e.g. `max-age=10000, &&&&&`) by
68/// requiring each comma-separated member to begin with a valid sf-key
69/// (RFC 8941 §3.1.2). Unrecognized but well-formed members are kept; the
70/// `CacheControlHeader` parser handles those as `UnknownDirective`.
71fn 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
86// RFC 8941 §3.1.2 grammar requires sf-key to be lowercase, but
87// `CacheControlHeader::parse` lowercases the whole header before parsing
88// (matching the case-insensitive convention of Cache-Control directives).
89// We mirror that here so a permissive parser isn't gated by a strict
90// validator — a server sending `CDN-Cache-Control: MaX-aGe=3600` is
91// honored, while genuinely-invalid keys like `&&&&&` are still rejected.
92fn 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/// Configuration that controls cache behavior.
104#[derive(Debug, Copy, Clone, fieldwork::Fieldwork)]
105#[fieldwork(get, set, get_mut, with, rename_predicates)]
106pub struct CacheOptions {
107    /// whether the cache is treated as a *shared cache*
108    ///
109    /// Shared cache, suitable for a proxy or cdn: `s-maxage` is honored, `private` responses are
110    /// refused, and `Authorization`-bearing requests require explicit opt-in (`public`,
111    /// `s-maxage`, or `must-revalidate`)
112    ///
113    /// Non-shared-cache (the default) treats the cache as a single-user (browser-style) private
114    /// cache.
115    ///
116    /// Default: false
117    pub(crate) shared: bool,
118
119    /// heuristic-freshness ratio
120    ///
121    /// When a response has no explicit expiration but does have `Last-Modified`, freshness
122    /// lifetime is computed as `cache_heuristic * (Date - Last-Modified)`.
123    ///
124    /// Default: 0.1 (10%)
125    pub(crate) cache_heuristic: f32,
126
127    /// the default freshness lifetime for responses with `Cache-Control:
128    /// immutable` and no other expiration
129    ///
130    /// Default: 24h
131    #[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/// Captured snapshot of a request/response exchange.
146///
147/// `CachePolicy` is the value type that [`Cache`][crate::Cache] hands to
148/// a [`CacheStorage`][crate::CacheStorage] backend for storage and
149/// retrieval. To a storage backend it's an opaque blob: store it,
150/// return it on lookup, and use [`same_variant_as`][Self::same_variant_as]
151/// to decide whether a new entry replaces an existing one or appends as
152/// a new `Vary` variant.
153#[derive(Debug, Clone)]
154pub struct CachePolicy {
155    pub(crate) request_method: Method,
156    /// Captured request header values for the headers named in the
157    /// response's `Vary`. Empty if no `Vary` header. Each entry is
158    /// `(lowercase-name, Option<value>)`; `None` value means the header
159    /// was absent on the original request.
160    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    /// True when `response_cache_control` came from a targeted field
165    /// (RFC 9213 — currently `CDN-Cache-Control`) rather than `Cache-Control`.
166    /// Per §2.2, the cache MUST then ignore both `Cache-Control` and
167    /// `Expires` for caching policy decisions; freshness math uses this flag
168    /// to suppress the `Expires` fallback.
169    pub(crate) targeted_cc_in_effect: bool,
170    pub(crate) response_time: SystemTime,
171    pub(crate) options: CacheOptions,
172}
173
174impl CachePolicy {
175    /// True when `other` would select the same stored variant as `self`
176    /// for the same [`CacheKey`][crate::CacheKey] — i.e. both responses
177    /// were captured with matching values for every header listed in
178    /// `Vary`. [`CacheStorage`][crate::CacheStorage] implementations use
179    /// this to decide whether a `put` should replace an existing variant
180    /// or append a new one.
181    pub fn same_variant_as(&self, other: &Self) -> bool {
182        self.vary_snapshot == other.vary_snapshot
183    }
184
185    // Build a stored policy from a completed exchange. `response_time` is the
186    // wall-clock time the response was received from the origin.
187    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// On-disk proxy for `CachePolicy`, used by the `FileSystemStorage` backend to persist a
214// policy through rkyv. It carries only the fields captured directly from the exchange;
215// `response_cache_control` and `targeted_cc_in_effect` are a pure function of the stored
216// headers and options, so they are recomputed on load rather than serialized. `CacheOptions`
217// is flattened into individual fields so no rkyv-archived type is generated for the public
218// `CacheOptions`; destructuring it here makes a future added field a compile error until it
219// is threaded through.
220#[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    // RFC 9110 §5.3: multiple `Vary:` header lines are equivalent to one
293    // line with comma-separated values. `get_str` returns None when more
294    // than one line is present (HeaderValues::one), so iterate the values
295    // and flatten — otherwise we'd silently miss a `Vary: *` on a second
296    // line and incorrectly serve a non-matching cached entry.
297    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    // RFC 9110 §5.3: multiple `Vary:` lines fold to one comma-list.
322    // `Headers::get_str` returns None for multi-value headers, so a naive
323    // implementation would silently miss the second line and over-cache.
324    #[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        // Append a second `Vary:` line — the test fixture's `insert`
333        // would replace, so we have to call append directly.
334        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    // RFC 9111 §4.1: `Vary: *` means "never reuse" — a `*` on any line
347    // should be honored even when paired with empty or other tokens.
348    #[test]
349    fn vary_snapshot_captures_star_from_second_line() {
350        let mut conn = exchange(
351            Method::Get,
352            &[],
353            Status::Ok,
354            &[(Vary, "")], // empty first line
355        );
356        conn.response_headers_mut().append(Vary, "*");
357
358        let policy = policy_from(&conn, SystemTime::now(), private_cache());
359        // The `*` survives flattening so vary_matches will return false.
360        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        // Valid sf-key starts with [a-z*] and contains [a-z0-9_*\-.]
384        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        // Wrong-type values are caught downstream by CC parsing, not here —
388        // we only validate keys at this layer.
389        assert!(looks_like_valid_sf_dictionary(r#"max-age="600""#));
390
391        // Mixed-case keys are accepted — `CacheControlHeader::parse`
392        // lowercases before parsing, so this matches the actual parser's
393        // case-insensitive behavior.
394        assert!(looks_like_valid_sf_dictionary("MaX-aGe=3600"));
395
396        // Invalid: garbage-character keys.
397        assert!(!looks_like_valid_sf_dictionary("max-age=10000, &&&&&"));
398        assert!(!looks_like_valid_sf_dictionary("&&&&&"));
399        // Invalid: empty.
400        assert!(!looks_like_valid_sf_dictionary(""));
401        assert!(!looks_like_valid_sf_dictionary("   "));
402        // Invalid: trailing/middle empty members from stray commas.
403        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        // recomputed from the stored headers + options, not serialized
446        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}