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/// RFC 9213 §2.1: targeted fields are Dictionary Structured Fields (RFC
41/// 8941 §3.2). A full SF parser is out of scope, but this catches the
42/// common "garbage trailing tokens" case (e.g. `max-age=10000, &&&&&`) by
43/// requiring each comma-separated member to begin with a valid sf-key
44/// (RFC 8941 §3.1.2). Unrecognized but well-formed members are kept; the
45/// `CacheControlHeader` parser handles those as `UnknownDirective`.
46fn looks_like_valid_sf_dictionary(s: &str) -> bool {
47    let s = s.trim();
48    if s.is_empty() {
49        return false;
50    }
51    s.split(',').all(|member| {
52        let member = member.trim();
53        if member.is_empty() {
54            return false;
55        }
56        let key = member.split_once('=').map_or(member, |(k, _)| k).trim_end();
57        is_valid_sf_key(key)
58    })
59}
60
61// RFC 8941 §3.1.2 grammar requires sf-key to be lowercase, but
62// `CacheControlHeader::parse` lowercases the whole header before parsing
63// (matching the case-insensitive convention of Cache-Control directives).
64// We mirror that here so a permissive parser isn't gated by a strict
65// validator — a server sending `CDN-Cache-Control: MaX-aGe=3600` is
66// honored, while genuinely-invalid keys like `&&&&&` are still rejected.
67fn is_valid_sf_key(s: &str) -> bool {
68    let mut chars = s.chars();
69    let Some(first) = chars.next() else {
70        return false;
71    };
72    if !first.is_ascii_alphabetic() && first != '*' {
73        return false;
74    }
75    chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.' | '*'))
76}
77
78/// Configuration that controls cache behavior.
79#[derive(Debug, Copy, Clone, fieldwork::Fieldwork)]
80#[fieldwork(get, set, get_mut, with, rename_predicates)]
81pub struct CacheOptions {
82    /// whether the cache is treated as a *shared cache*
83    ///
84    /// Shared cache, suitable for a proxy or cdn: `s-maxage` is honored, `private` responses are
85    /// refused, and `Authorization`-bearing requests require explicit opt-in (`public`,
86    /// `s-maxage`, or `must-revalidate`)
87    ///
88    /// Non-shared-cache (the default) treats the cache as a single-user (browser-style) private
89    /// cache.
90    ///
91    /// Default: false
92    pub(crate) shared: bool,
93
94    /// heuristic-freshness ratio
95    ///
96    /// When a response has no explicit expiration but does have `Last-Modified`, freshness
97    /// lifetime is computed as `cache_heuristic * (Date - Last-Modified)`.
98    ///
99    /// Default: 0.1 (10%)
100    pub(crate) cache_heuristic: f32,
101
102    /// the default freshness lifetime for responses with `Cache-Control:
103    /// immutable` and no other expiration
104    ///
105    /// Default: 24h
106    #[field(copy)]
107    pub(crate) immutable_min_time_to_live: Duration,
108}
109
110impl Default for CacheOptions {
111    fn default() -> Self {
112        Self {
113            shared: false,
114            cache_heuristic: 0.1,
115            immutable_min_time_to_live: Duration::from_secs(24 * 3600),
116        }
117    }
118}
119
120/// Captured snapshot of a request/response exchange.
121///
122/// `CachePolicy` is the value type that [`Cache`][crate::Cache] hands to
123/// a [`CacheStorage`][crate::CacheStorage] backend for storage and
124/// retrieval. To a storage backend it's an opaque blob: store it,
125/// return it on lookup, and use [`same_variant_as`][Self::same_variant_as]
126/// to decide whether a new entry replaces an existing one or appends as
127/// a new `Vary` variant.
128#[derive(Debug, Clone)]
129pub struct CachePolicy {
130    pub(crate) request_method: Method,
131    /// Captured request header values for the headers named in the
132    /// response's `Vary`. Empty if no `Vary` header. Each entry is
133    /// `(lowercase-name, Option<value>)`; `None` value means the header
134    /// was absent on the original request.
135    pub(crate) vary_snapshot: Vec<(String, Option<String>)>,
136    pub(crate) response_status: Status,
137    pub(crate) response_headers: Headers,
138    pub(crate) response_cache_control: Option<CacheControlHeader>,
139    /// True when `response_cache_control` came from a targeted field
140    /// (RFC 9213 — currently `CDN-Cache-Control`) rather than `Cache-Control`.
141    /// Per §2.2, the cache MUST then ignore both `Cache-Control` and
142    /// `Expires` for caching policy decisions; freshness math uses this flag
143    /// to suppress the `Expires` fallback.
144    pub(crate) targeted_cc_in_effect: bool,
145    pub(crate) response_time: SystemTime,
146    pub(crate) options: CacheOptions,
147}
148
149impl CachePolicy {
150    /// True when `other` would select the same stored variant as `self`
151    /// for the same [`CacheKey`][crate::CacheKey] — i.e. both responses
152    /// were captured with matching values for every header listed in
153    /// `Vary`. [`CacheStorage`][crate::CacheStorage] implementations use
154    /// this to decide whether a `put` should replace an existing variant
155    /// or append a new one.
156    pub fn same_variant_as(&self, other: &Self) -> bool {
157        self.vary_snapshot == other.vary_snapshot
158    }
159
160    // Build a stored policy from a completed exchange. `response_time` is the
161    // wall-clock time the response was received from the origin.
162    pub(crate) fn new(
163        request_method: Method,
164        request_headers: &Headers,
165        response_status: Status,
166        response_headers: Headers,
167        response_time: SystemTime,
168        options: CacheOptions,
169    ) -> Self {
170        let (mut response_cache_control, targeted_cc_in_effect) =
171            effective_response_cache_control(&response_headers, &options);
172
173        // RFC 9111 §5.4: when no Cache-Control is present, treat
174        // `Pragma: no-cache` as if `Cache-Control: no-cache` were set. This
175        // is suppressed when a targeted field took effect (Pragma is part of
176        // the Cache-Control / Expires family the targeted-field rule
177        // displaces).
178        if response_cache_control.is_none()
179            && response_headers
180                .get_str(KnownHeaderName::Pragma)
181                .is_some_and(|p| p.contains("no-cache"))
182        {
183            response_cache_control = Some(CacheControlHeader::from(CacheControlDirective::NoCache));
184        }
185
186        let vary_snapshot = build_vary_snapshot(&response_headers, request_headers);
187
188        Self {
189            request_method,
190            vary_snapshot,
191            response_status,
192            response_headers,
193            response_cache_control,
194            targeted_cc_in_effect,
195            response_time,
196            options,
197        }
198    }
199}
200
201fn build_vary_snapshot(
202    response_headers: &Headers,
203    request_headers: &Headers,
204) -> Vec<(String, Option<String>)> {
205    // RFC 9110 §5.3: multiple `Vary:` header lines are equivalent to one
206    // line with comma-separated values. `get_str` returns None when more
207    // than one line is present (HeaderValues::one), so iterate the values
208    // and flatten — otherwise we'd silently miss a `Vary: *` on a second
209    // line and incorrectly serve a non-matching cached entry.
210    let Some(values) = response_headers.get_values(KnownHeaderName::Vary) else {
211        return Vec::new();
212    };
213    values
214        .iter()
215        .filter_map(|v| v.as_str())
216        .flat_map(|line| line.split(','))
217        .map(str::trim)
218        .filter(|n| !n.is_empty())
219        .map(|name| {
220            let lower = name.to_ascii_lowercase();
221            let value = request_headers.get_str(lower.as_str()).map(str::to_string);
222            (lower, value)
223        })
224        .collect()
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230    use crate::test_helpers::*;
231    use trillium_client::ConnExt;
232    use trillium_http::KnownHeaderName::*;
233
234    // RFC 9110 §5.3: multiple `Vary:` lines fold to one comma-list.
235    // `Headers::get_str` returns None for multi-value headers, so a naive
236    // implementation would silently miss the second line and over-cache.
237    #[test]
238    fn vary_snapshot_handles_multiple_header_lines() {
239        let mut conn = exchange(
240            Method::Get,
241            &[(AcceptEncoding, "gzip"), (AcceptLanguage, "en-US")],
242            Status::Ok,
243            &[(Vary, "Accept-Encoding")],
244        );
245        // Append a second `Vary:` line — the test fixture's `insert`
246        // would replace, so we have to call append directly.
247        conn.response_headers_mut().append(Vary, "Accept-Language");
248
249        let policy = policy_from(&conn, SystemTime::now(), private_cache());
250        assert_eq!(
251            policy.vary_snapshot,
252            vec![
253                ("accept-encoding".to_string(), Some("gzip".to_string())),
254                ("accept-language".to_string(), Some("en-US".to_string())),
255            ]
256        );
257    }
258
259    // RFC 9111 §4.1: `Vary: *` means "never reuse" — a `*` on any line
260    // should be honored even when paired with empty or other tokens.
261    #[test]
262    fn vary_snapshot_captures_star_from_second_line() {
263        let mut conn = exchange(
264            Method::Get,
265            &[],
266            Status::Ok,
267            &[(Vary, "")], // empty first line
268        );
269        conn.response_headers_mut().append(Vary, "*");
270
271        let policy = policy_from(&conn, SystemTime::now(), private_cache());
272        // The `*` survives flattening so vary_matches will return false.
273        assert!(policy.vary_snapshot.iter().any(|(name, _)| name == "*"));
274    }
275
276    #[test]
277    fn vary_snapshot_captures_named_request_headers() {
278        let conn = exchange(
279            Method::Get,
280            &[(AcceptEncoding, "gzip"), (AcceptLanguage, "en-US")],
281            Status::Ok,
282            &[(Vary, "Accept-Encoding, Accept-Language")],
283        );
284        let policy = policy_from(&conn, SystemTime::now(), private_cache());
285        assert_eq!(
286            policy.vary_snapshot,
287            vec![
288                ("accept-encoding".to_string(), Some("gzip".to_string())),
289                ("accept-language".to_string(), Some("en-US".to_string())),
290            ]
291        );
292    }
293
294    #[test]
295    fn sf_dictionary_validator() {
296        // Valid sf-key starts with [a-z*] and contains [a-z0-9_*\-.]
297        assert!(looks_like_valid_sf_dictionary("max-age=600"));
298        assert!(looks_like_valid_sf_dictionary("no-store"));
299        assert!(looks_like_valid_sf_dictionary("max-age=600, no-store"));
300        // Wrong-type values are caught downstream by CC parsing, not here —
301        // we only validate keys at this layer.
302        assert!(looks_like_valid_sf_dictionary(r#"max-age="600""#));
303
304        // Mixed-case keys are accepted — `CacheControlHeader::parse`
305        // lowercases before parsing, so this matches the actual parser's
306        // case-insensitive behavior.
307        assert!(looks_like_valid_sf_dictionary("MaX-aGe=3600"));
308
309        // Invalid: garbage-character keys.
310        assert!(!looks_like_valid_sf_dictionary("max-age=10000, &&&&&"));
311        assert!(!looks_like_valid_sf_dictionary("&&&&&"));
312        // Invalid: empty.
313        assert!(!looks_like_valid_sf_dictionary(""));
314        assert!(!looks_like_valid_sf_dictionary("   "));
315        // Invalid: trailing/middle empty members from stray commas.
316        assert!(!looks_like_valid_sf_dictionary("max-age=600,"));
317    }
318
319    #[test]
320    fn vary_snapshot_records_absent_request_header_as_none() {
321        let conn = exchange(Method::Get, &[], Status::Ok, &[(Vary, "Accept-Encoding")]);
322        let policy = policy_from(&conn, SystemTime::now(), private_cache());
323        assert_eq!(
324            policy.vary_snapshot,
325            vec![("accept-encoding".to_string(), None)]
326        );
327    }
328}