trillium-cache 0.2.0

http cache handler for trillium.rs
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
//! Stored cache policy — the value type for a captured exchange.
//!
//! Section-specific logic lives in sibling modules:
//! - [`crate::storability`] — RFC 9111 §3 (`is_storable`)
//! - [`crate::freshness`]   — RFC 9111 §4.2 (`age` / `time_to_live` / `is_stale`)
//! - [`crate::validation`]  — RFC 9111 §4.3 (`before_request`)
//!
//! Portions of this and the sibling modules are derived from
//! [`rusty-http-cache-semantics`](https://github.com/kornelski/rusty-http-cache-semantics)
//! by Kornel Lesiński, used under the BSD-2-Clause license. See
//! `LICENSE-BSD-2-CLAUSE-http-cache-semantics` at the crate root for the
//! original notice.

use std::time::{Duration, SystemTime};
use trillium_caching_headers::{CacheControlDirective, CacheControlHeader, CachingHeadersExt};
use trillium_http::{Headers, KnownHeaderName, Method, Status};

/// Resolve the effective response Cache-Control for a response, applying
/// the RFC 9213 §2.2 targeted-field override:
/// when the cache is shared and a non-empty, validly-structured
/// `CDN-Cache-Control` is present, it fully replaces `Cache-Control` (and
/// downstream code MUST also ignore `Expires`, signalled by the returned
/// `targeted_cc_in_effect`). Per §2.1, parse-error or empty targeted
/// fields MUST be ignored.
pub(crate) fn effective_response_cache_control(
    response_headers: &Headers,
    options: &CacheOptions,
) -> (Option<CacheControlHeader>, bool) {
    if options.shared
        && let Some(raw) = response_headers.get_str(KnownHeaderName::CdnCacheControl)
        && looks_like_valid_sf_dictionary(raw)
        && let Some(cdn_cc) = response_headers.cdn_cache_control()
        && !cdn_cc.is_empty()
    {
        return (Some(cdn_cc), true);
    }
    (response_headers.cache_control(), false)
}

// Derive the effective response Cache-Control and its targeted-field flag from a
// response's headers and caching options. This is a pure function of the two inputs.
fn derive_response_cache_control(
    response_headers: &Headers,
    options: &CacheOptions,
) -> (Option<CacheControlHeader>, bool) {
    let (mut response_cache_control, targeted_cc_in_effect) =
        effective_response_cache_control(response_headers, options);

    // RFC 9111 §5.4: when no Cache-Control is present, treat
    // `Pragma: no-cache` as if `Cache-Control: no-cache` were set. This
    // is suppressed when a targeted field took effect (Pragma is part of
    // the Cache-Control / Expires family the targeted-field rule
    // displaces).
    if response_cache_control.is_none()
        && response_headers
            .get_str(KnownHeaderName::Pragma)
            .is_some_and(|p| p.contains("no-cache"))
    {
        response_cache_control = Some(CacheControlHeader::from(CacheControlDirective::NoCache));
    }

    (response_cache_control, targeted_cc_in_effect)
}

/// RFC 9213 §2.1: targeted fields are Dictionary Structured Fields (RFC
/// 8941 §3.2). A full SF parser is out of scope, but this catches the
/// common "garbage trailing tokens" case (e.g. `max-age=10000, &&&&&`) by
/// requiring each comma-separated member to begin with a valid sf-key
/// (RFC 8941 §3.1.2). Unrecognized but well-formed members are kept; the
/// `CacheControlHeader` parser handles those as `UnknownDirective`.
fn looks_like_valid_sf_dictionary(s: &str) -> bool {
    let s = s.trim();
    if s.is_empty() {
        return false;
    }
    s.split(',').all(|member| {
        let member = member.trim();
        if member.is_empty() {
            return false;
        }
        let key = member.split_once('=').map_or(member, |(k, _)| k).trim_end();
        is_valid_sf_key(key)
    })
}

// RFC 8941 §3.1.2 grammar requires sf-key to be lowercase, but
// `CacheControlHeader::parse` lowercases the whole header before parsing
// (matching the case-insensitive convention of Cache-Control directives).
// We mirror that here so a permissive parser isn't gated by a strict
// validator — a server sending `CDN-Cache-Control: MaX-aGe=3600` is
// honored, while genuinely-invalid keys like `&&&&&` are still rejected.
fn is_valid_sf_key(s: &str) -> bool {
    let mut chars = s.chars();
    let Some(first) = chars.next() else {
        return false;
    };
    if !first.is_ascii_alphabetic() && first != '*' {
        return false;
    }
    chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.' | '*'))
}

/// Configuration that controls cache behavior.
#[derive(Debug, Copy, Clone, fieldwork::Fieldwork)]
#[fieldwork(get, set, get_mut, with, rename_predicates)]
pub struct CacheOptions {
    /// whether the cache is treated as a *shared cache*
    ///
    /// Shared cache, suitable for a proxy or cdn: `s-maxage` is honored, `private` responses are
    /// refused, and `Authorization`-bearing requests require explicit opt-in (`public`,
    /// `s-maxage`, or `must-revalidate`)
    ///
    /// Non-shared-cache (the default) treats the cache as a single-user (browser-style) private
    /// cache.
    ///
    /// Default: false
    pub(crate) shared: bool,

    /// heuristic-freshness ratio
    ///
    /// When a response has no explicit expiration but does have `Last-Modified`, freshness
    /// lifetime is computed as `cache_heuristic * (Date - Last-Modified)`.
    ///
    /// Default: 0.1 (10%)
    pub(crate) cache_heuristic: f32,

    /// the default freshness lifetime for responses with `Cache-Control:
    /// immutable` and no other expiration
    ///
    /// Default: 24h
    #[field(copy)]
    pub(crate) immutable_min_time_to_live: Duration,
}

impl Default for CacheOptions {
    fn default() -> Self {
        Self {
            shared: false,
            cache_heuristic: 0.1,
            immutable_min_time_to_live: Duration::from_secs(24 * 3600),
        }
    }
}

/// Captured snapshot of a request/response exchange.
///
/// `CachePolicy` is the value type that [`Cache`][crate::Cache] hands to
/// a [`CacheStorage`][crate::CacheStorage] backend for storage and
/// retrieval. To a storage backend it's an opaque blob: store it,
/// return it on lookup, and use [`same_variant_as`][Self::same_variant_as]
/// to decide whether a new entry replaces an existing one or appends as
/// a new `Vary` variant.
#[derive(Debug, Clone)]
pub struct CachePolicy {
    pub(crate) request_method: Method,
    /// Captured request header values for the headers named in the
    /// response's `Vary`. Empty if no `Vary` header. Each entry is
    /// `(lowercase-name, Option<value>)`; `None` value means the header
    /// was absent on the original request.
    pub(crate) vary_snapshot: Vec<(String, Option<String>)>,
    pub(crate) response_status: Status,
    pub(crate) response_headers: Headers,
    pub(crate) response_cache_control: Option<CacheControlHeader>,
    /// True when `response_cache_control` came from a targeted field
    /// (RFC 9213 — currently `CDN-Cache-Control`) rather than `Cache-Control`.
    /// Per §2.2, the cache MUST then ignore both `Cache-Control` and
    /// `Expires` for caching policy decisions; freshness math uses this flag
    /// to suppress the `Expires` fallback.
    pub(crate) targeted_cc_in_effect: bool,
    pub(crate) response_time: SystemTime,
    pub(crate) options: CacheOptions,
}

impl CachePolicy {
    /// True when `other` would select the same stored variant as `self`
    /// for the same [`CacheKey`][crate::CacheKey] — i.e. both responses
    /// were captured with matching values for every header listed in
    /// `Vary`. [`CacheStorage`][crate::CacheStorage] implementations use
    /// this to decide whether a `put` should replace an existing variant
    /// or append a new one.
    pub fn same_variant_as(&self, other: &Self) -> bool {
        self.vary_snapshot == other.vary_snapshot
    }

    // Build a stored policy from a completed exchange. `response_time` is the
    // wall-clock time the response was received from the origin.
    pub(crate) fn new(
        request_method: Method,
        request_headers: &Headers,
        response_status: Status,
        response_headers: Headers,
        response_time: SystemTime,
        options: CacheOptions,
    ) -> Self {
        let (response_cache_control, targeted_cc_in_effect) =
            derive_response_cache_control(&response_headers, &options);

        let vary_snapshot = build_vary_snapshot(&response_headers, request_headers);

        Self {
            request_method,
            vary_snapshot,
            response_status,
            response_headers,
            response_cache_control,
            targeted_cc_in_effect,
            response_time,
            options,
        }
    }
}

// On-disk proxy for `CachePolicy`, used by the `FileSystemStorage` backend to persist a
// policy through rkyv. It carries only the fields captured directly from the exchange;
// `response_cache_control` and `targeted_cc_in_effect` are a pure function of the stored
// headers and options, so they are recomputed on load rather than serialized. `CacheOptions`
// is flattened into individual fields so no rkyv-archived type is generated for the public
// `CacheOptions`; destructuring it here makes a future added field a compile error until it
// is threaded through.
#[cfg(feature = "fs")]
#[derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)]
pub(crate) struct PolicyRepr {
    request_method: Method,
    vary_snapshot: Vec<(String, Option<String>)>,
    response_status: Status,
    response_headers: Headers,
    #[rkyv(with = rkyv::with::AsUnixTime)]
    response_time: SystemTime,
    shared: bool,
    cache_heuristic: f32,
    immutable_min_time_to_live: Duration,
}

#[cfg(feature = "fs")]
impl From<&CachePolicy> for PolicyRepr {
    fn from(policy: &CachePolicy) -> Self {
        let CacheOptions {
            shared,
            cache_heuristic,
            immutable_min_time_to_live,
        } = policy.options;
        Self {
            request_method: policy.request_method,
            vary_snapshot: policy.vary_snapshot.clone(),
            response_status: policy.response_status,
            response_headers: policy.response_headers.clone(),
            response_time: policy.response_time,
            shared,
            cache_heuristic,
            immutable_min_time_to_live,
        }
    }
}

#[cfg(feature = "fs")]
impl From<PolicyRepr> for CachePolicy {
    fn from(repr: PolicyRepr) -> Self {
        let PolicyRepr {
            request_method,
            vary_snapshot,
            response_status,
            response_headers,
            response_time,
            shared,
            cache_heuristic,
            immutable_min_time_to_live,
        } = repr;
        let options = CacheOptions {
            shared,
            cache_heuristic,
            immutable_min_time_to_live,
        };
        let (response_cache_control, targeted_cc_in_effect) =
            derive_response_cache_control(&response_headers, &options);
        Self {
            request_method,
            vary_snapshot,
            response_status,
            response_headers,
            response_cache_control,
            targeted_cc_in_effect,
            response_time,
            options,
        }
    }
}

fn build_vary_snapshot(
    response_headers: &Headers,
    request_headers: &Headers,
) -> Vec<(String, Option<String>)> {
    // RFC 9110 §5.3: multiple `Vary:` header lines are equivalent to one
    // line with comma-separated values. `get_str` returns None when more
    // than one line is present (HeaderValues::one), so iterate the values
    // and flatten — otherwise we'd silently miss a `Vary: *` on a second
    // line and incorrectly serve a non-matching cached entry.
    let Some(values) = response_headers.get_values(KnownHeaderName::Vary) else {
        return Vec::new();
    };
    values
        .iter()
        .filter_map(|v| v.as_str())
        .flat_map(|line| line.split(','))
        .map(str::trim)
        .filter(|n| !n.is_empty())
        .map(|name| {
            let lower = name.to_ascii_lowercase();
            let value = request_headers.get_str(lower.as_str()).map(str::to_string);
            (lower, value)
        })
        .collect()
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::test_helpers::*;
    use trillium_client::ConnExt;
    use trillium_http::KnownHeaderName::*;

    // RFC 9110 §5.3: multiple `Vary:` lines fold to one comma-list.
    // `Headers::get_str` returns None for multi-value headers, so a naive
    // implementation would silently miss the second line and over-cache.
    #[test]
    fn vary_snapshot_handles_multiple_header_lines() {
        let mut conn = exchange(
            Method::Get,
            &[(AcceptEncoding, "gzip"), (AcceptLanguage, "en-US")],
            Status::Ok,
            &[(Vary, "Accept-Encoding")],
        );
        // Append a second `Vary:` line — the test fixture's `insert`
        // would replace, so we have to call append directly.
        conn.response_headers_mut().append(Vary, "Accept-Language");

        let policy = policy_from(&conn, SystemTime::now(), private_cache());
        assert_eq!(
            policy.vary_snapshot,
            vec![
                ("accept-encoding".to_string(), Some("gzip".to_string())),
                ("accept-language".to_string(), Some("en-US".to_string())),
            ]
        );
    }

    // RFC 9111 §4.1: `Vary: *` means "never reuse" — a `*` on any line
    // should be honored even when paired with empty or other tokens.
    #[test]
    fn vary_snapshot_captures_star_from_second_line() {
        let mut conn = exchange(
            Method::Get,
            &[],
            Status::Ok,
            &[(Vary, "")], // empty first line
        );
        conn.response_headers_mut().append(Vary, "*");

        let policy = policy_from(&conn, SystemTime::now(), private_cache());
        // The `*` survives flattening so vary_matches will return false.
        assert!(policy.vary_snapshot.iter().any(|(name, _)| name == "*"));
    }

    #[test]
    fn vary_snapshot_captures_named_request_headers() {
        let conn = exchange(
            Method::Get,
            &[(AcceptEncoding, "gzip"), (AcceptLanguage, "en-US")],
            Status::Ok,
            &[(Vary, "Accept-Encoding, Accept-Language")],
        );
        let policy = policy_from(&conn, SystemTime::now(), private_cache());
        assert_eq!(
            policy.vary_snapshot,
            vec![
                ("accept-encoding".to_string(), Some("gzip".to_string())),
                ("accept-language".to_string(), Some("en-US".to_string())),
            ]
        );
    }

    #[test]
    fn sf_dictionary_validator() {
        // Valid sf-key starts with [a-z*] and contains [a-z0-9_*\-.]
        assert!(looks_like_valid_sf_dictionary("max-age=600"));
        assert!(looks_like_valid_sf_dictionary("no-store"));
        assert!(looks_like_valid_sf_dictionary("max-age=600, no-store"));
        // Wrong-type values are caught downstream by CC parsing, not here —
        // we only validate keys at this layer.
        assert!(looks_like_valid_sf_dictionary(r#"max-age="600""#));

        // Mixed-case keys are accepted — `CacheControlHeader::parse`
        // lowercases before parsing, so this matches the actual parser's
        // case-insensitive behavior.
        assert!(looks_like_valid_sf_dictionary("MaX-aGe=3600"));

        // Invalid: garbage-character keys.
        assert!(!looks_like_valid_sf_dictionary("max-age=10000, &&&&&"));
        assert!(!looks_like_valid_sf_dictionary("&&&&&"));
        // Invalid: empty.
        assert!(!looks_like_valid_sf_dictionary(""));
        assert!(!looks_like_valid_sf_dictionary("   "));
        // Invalid: trailing/middle empty members from stray commas.
        assert!(!looks_like_valid_sf_dictionary("max-age=600,"));
    }

    #[test]
    fn vary_snapshot_records_absent_request_header_as_none() {
        let conn = exchange(Method::Get, &[], Status::Ok, &[(Vary, "Accept-Encoding")]);
        let policy = policy_from(&conn, SystemTime::now(), private_cache());
        assert_eq!(
            policy.vary_snapshot,
            vec![("accept-encoding".to_string(), None)]
        );
    }

    #[cfg(feature = "fs")]
    #[test]
    fn policy_round_trips_through_rkyv() {
        let conn = exchange(
            Method::Get,
            &[(AcceptEncoding, "gzip")],
            Status::Ok,
            &[(CacheControl, "max-age=600"), (Vary, "Accept-Encoding")],
        );
        let policy = policy_from(&conn, SystemTime::now(), private_cache());

        let repr = PolicyRepr::from(&policy);
        let bytes = rkyv::to_bytes::<rkyv::rancor::Error>(&repr).unwrap();
        let restored: CachePolicy = rkyv::from_bytes::<PolicyRepr, rkyv::rancor::Error>(&bytes)
            .unwrap()
            .into();

        assert_eq!(restored.request_method, policy.request_method);
        assert_eq!(restored.response_status, policy.response_status);
        assert_eq!(restored.vary_snapshot, policy.vary_snapshot);
        assert_eq!(restored.response_time, policy.response_time);
        assert_eq!(
            restored.response_headers.get_str(CacheControl),
            policy.response_headers.get_str(CacheControl)
        );
        assert_eq!(
            restored.response_headers.get_str(Vary),
            policy.response_headers.get_str(Vary)
        );
        // recomputed from the stored headers + options, not serialized
        assert_eq!(restored.targeted_cc_in_effect, policy.targeted_cc_in_effect);
        assert_eq!(
            restored.response_cache_control.is_some(),
            policy.response_cache_control.is_some()
        );
    }
}