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}