Oregami
Repositories/oxedyne/fe2o3

oxedyne/fe2o3/fe2o3_steel/src/srv/cache.rs

15.2 KiB, 31 runs

created by r1870400018:13466, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1//! HTTP caching for static responses: entity tags, conditional requests and
2//! cache directives.
3//!
4//! A server that emits no validators can never answer `304 Not Modified`, so it
5//! re-sends every byte of every asset on every request, however little has
6//! changed. A server that emits no cache directives leaves the browser to guess
7//! how long it may keep a document, and a browser guessing about an application
8//! shell will eventually serve a stale one. This module supplies both halves.
9//!
10//! [Written with AI entirely](https://need2know.ai/entirely-ai/code)\
11//! Anthropic Claude
12
13use oxedyne_fe2o3_core::prelude::*;
14use oxedyne_fe2o3_net::http::{
15 encoding,
16 fields::{
17 HeaderFields,
18 HeaderFieldValue,
19 HeaderName,
20 },
21 msg::HttpMessage,
22 status::HttpStatus,
23};
24
25use std::{
26 fs::Metadata,
27 path::Path,
28 time::UNIX_EPOCH,
29};
30
31
32/// Entity tag for a static file, derived from its modification time and size.
33///
34/// The pair is what the filesystem already knows, and it changes whenever the
35/// file does. A digest of the contents would be a stronger tag, but computing one
36/// means reading the whole file on every conditional request, which is precisely
37/// the work the tag exists to avoid.
38pub fn entity_tag(meta: &Metadata) -> Outcome<String> {
39 let modified = res!(meta.modified());
40 let secs = match modified.duration_since(UNIX_EPOCH) {
41 Ok(dur) => dur.as_secs(),
42 Err(_) => 0, // A file dated before the epoch is not stale, merely odd.
43 };
44 Ok(fmt!("\"{:x}-{:x}\"", secs, meta.len()))
45}
46
47/// Does the client already hold this exact entity?
48///
49/// Per RFC 9110 §13.1.2 an `If-None-Match` listing the current tag, or `*`, means
50/// the copy in hand is current and the body must not be sent again. A weak tag is
51/// accepted against its strong twin, since this comparison is about identity, not
52/// byte-for-byte equivalence.
53pub fn is_current(req: &HeaderFields, etag: &str) -> bool {
54 match req.get_one(&HeaderName::IfNoneMatch) {
55 Some(val) => fmt!("{}", val)
56 .split(',')
57 .map(|given| given.trim())
58 .any(|given|
59 given == "*"
60 || given == etag
61 || given.strip_prefix("W/").map_or(false, |given| given == etag)
62 ),
63 None => false,
64 }
65}
66
67/// Cache directive for a static response.
68///
69/// Three cases, in order.
70///
71/// An entry document is always revalidated, because a deploy that changes it is
72/// invisible to anyone still holding the old one. That holds whatever the
73/// filename says, since a document is the thing a reader has bookmarked.
74///
75/// An asset whose filename carries a content hash may be held for
76/// `fingerprint_max_age_secs` and marked `immutable` (RFC 8246): the name is a
77/// promise that the bytes under it cannot change, so revalidating it can only
78/// ever confirm what the client already has. `immutable` is what stops a browser
79/// asking again on a manual reload.
80///
81/// Every other asset may be held for `max_age_secs`, which an operator should
82/// raise above zero only when the filenames carry a content hash, since a cached
83/// asset under a stable name survives the deploy that replaced it. The default
84/// of zero revalidates everything, which the entity tag makes cheap.
85pub fn cache_control(
86 content_type: &str,
87 path: &Path,
88 max_age_secs: u32,
89 fingerprint_secs: u32,
90)
91 -> String
92{
93 if is_document(content_type) {
94 return fmt!("no-cache");
95 }
96 if fingerprint_secs > 0 && is_fingerprinted(path) {
97 return fmt!("public, max-age={}, immutable", fingerprint_secs);
98 }
99 if max_age_secs == 0 {
100 fmt!("no-cache")
101 } else {
102 fmt!("public, max-age={}", max_age_secs)
103 }
104}
105
106/// Is this an entry document, rather than an asset it refers to?
107pub fn is_document(content_type: &str) -> bool {
108 content_type.contains("text/html")
109}
110
111/// Does this filename carry a content hash?
112///
113/// Every build tool that fingerprints its output puts a run of hex in the name
114/// -- `app.4f3a9c21.js`, `main-8ab19c7e.css`, `module_1f2e3d4c_bg.wasm` -- and
115/// the point of doing so is that a changed file gets a different name. That is
116/// what makes a year-long `max-age` safe, and nothing else does.
117///
118/// The test is deliberately narrow, because a false positive means a browser
119/// holding a stale file for a year:
120///
121/// - the run is at least eight characters, which is the shortest hash any of
122/// these tools emits;
123/// - every character is a hex digit, so words are not mistaken for hashes;
124/// - at least one is a numeral and at least one a letter, so neither a run of
125/// letters that happens to be hex (`deadbeef`, `facecafe`, and every English
126/// word spellable in `a`--`f`) nor a run of numerals that is plainly a date or
127/// an identifier (`20260728`) is mistaken for a hash;
128/// - and it stands as its own segment, delimited by `.`, `-` or `_`, so a hash
129/// is never read out of the middle of a longer word.
130///
131/// A real hash trips all four almost always: an eight-character hex digest
132/// misses only when it happens to be all letters or all numerals, which is about
133/// one name in forty, and the miss costs a revalidation rather than a stale
134/// file.
135pub fn is_fingerprinted(path: &Path) -> bool {
136 let name = match path.file_name().and_then(|n| n.to_str()) {
137 Some(n) => n,
138 None => return false,
139 };
140 name.split(|c| c == '.' || c == '-' || c == '_').any(|seg|
141 seg.len() >= 8
142 && seg.bytes().all(|b| b.is_ascii_hexdigit())
143 && seg.bytes().any(|b| b.is_ascii_digit())
144 && seg.bytes().any(|b| b.is_ascii_alphabetic())
145 )
146}
147
148/// Stamp a response the server generated, so no store may serve it unasked.
149///
150/// A generated response describes the site at the instant it was asked for, and
151/// the next thing an author writes changes it. Carrying no directive and no
152/// validator, it is not merely uncached -- RFC 9111 §4.2.2 lets a store invent a
153/// freshness lifetime for it, and a browser will then redraw a page from a copy
154/// taken before the post existed. That is the stale index an author has to force
155/// a refresh to get past, and forcing a refresh is not something a reader will
156/// think to do. `no-cache` keeps the store and forbids the guess: the response
157/// may be held, and may never be used without asking first.
158///
159/// A response that already says how long it may be held keeps what it said. This
160/// is a default for the responses that say nothing, not an override -- and
161/// `Cache-Control` is a list field, so appending a second directive would leave
162/// both in force rather than replacing the first.
163pub fn generated(resp: HttpMessage) -> HttpMessage {
164 if resp.header.fields.get_one(&HeaderName::CacheControl).is_some() {
165 return resp;
166 }
167 resp.with_field(
168 HeaderName::CacheControl,
169 HeaderFieldValue::Generic(fmt!("no-cache")),
170 )
171}
172
173/// Fails unless a response forbids a store from serving it unasked.
174///
175/// The invariant [`generated`] exists to keep, in one place so that the tests of every surface that
176/// must hold it say the same thing -- and so a refactor that drops one of those calls fails a test
177/// rather than going out. Six surfaces hold it and one of them was tested; that is how five of them
178/// came to be able to break quietly.
179#[cfg(test)]
180pub fn assert_not_held(resp: &HttpMessage, what: &str) {
181 match resp.header.fields.get_one(&HeaderName::CacheControl) {
182 Some(val) => {
183 let directive = fmt!("{}", val).to_ascii_lowercase();
184 assert!(directive.contains("no-cache") || directive.contains("no-store"),
185 "{} may be served from a store unasked: '{}'", what, directive);
186 }
187 None => panic!(
188 "{} carried no cache directive, so a store is free to invent a lifetime for it", what),
189 }
190}
191
192/// A `304 Not Modified`: the validators and directives, and no body.
193///
194/// `varies_by_encoding` says whether the representation is one the server would
195/// have offered a content coding for. It has to be said here as much as on a
196/// `200`: RFC 9111 §4.3.4 has a cache update its stored response from the fields
197/// of the `304`, so a `Vary` omitted here would undo the one stored with the
198/// body, and the cache would go back to serving one encoding to everybody.
199pub fn not_modified(
200 etag: String,
201 directive: String,
202 varies_by_encoding: bool,
203)
204 -> Outcome<HttpMessage>
205{
206 let mut msg = HttpMessage::new_response(HttpStatus::NotModified)
207 .with_field(HeaderName::ETag, res!(HeaderFieldValue::new(
208 &HeaderName::ETag, &etag)))
209 .with_field(HeaderName::CacheControl, res!(HeaderFieldValue::new(
210 &HeaderName::CacheControl, &directive)));
211 if varies_by_encoding {
212 encoding::mark_varying(&mut msg);
213 }
214 Ok(msg)
215}
216
217
218#[cfg(test)]
219mod tests {
220 use super::*;
221
222 fn headers_with(name: HeaderName, value: &str) -> Outcome<HeaderFields> {
223 let mut fields = HeaderFields::default();
224 fields.insert(name.clone(), res!(HeaderFieldValue::new(&name, value)), None);
225 Ok(fields)
226 }
227
228 #[test]
229 fn if_none_match_recognises_the_current_tag() -> Outcome<()> {
230 let fields = res!(headers_with(HeaderName::IfNoneMatch, "\"abc-10\""));
231 assert!(is_current(&fields, "\"abc-10\""));
232 assert!(!is_current(&fields, "\"abc-11\""));
233 Ok(())
234 }
235
236 #[test]
237 fn if_none_match_accepts_a_list_a_wildcard_and_a_weak_tag() -> Outcome<()> {
238 let listed = res!(headers_with(
239 HeaderName::IfNoneMatch, "\"other\", \"abc-10\""));
240 assert!(is_current(&listed, "\"abc-10\""));
241
242 let wildcard = res!(headers_with(HeaderName::IfNoneMatch, "*"));
243 assert!(is_current(&wildcard, "\"abc-10\""));
244
245 let weak = res!(headers_with(HeaderName::IfNoneMatch, "W/\"abc-10\""));
246 assert!(is_current(&weak, "\"abc-10\""));
247 Ok(())
248 }
249
250 #[test]
251 fn a_request_without_the_header_is_never_current() -> Outcome<()> {
252 let fields = HeaderFields::default();
253 assert!(!is_current(&fields, "\"abc-10\""));
254 Ok(())
255 }
256
257 const YEAR: u32 = 31_536_000;
258
259 fn at(name: &str) -> std::path::PathBuf {
260 std::path::PathBuf::from("/srv/www").join(name)
261 }
262
263 #[test]
264 fn a_document_always_revalidates_however_long_the_max_age() {
265 assert_eq!(
266 cache_control("text/html; charset=utf-8", &at("index.html"), YEAR, YEAR),
267 "no-cache");
268 assert_eq!(cache_control("text/html", &at("index.html"), 0, YEAR), "no-cache");
269 // Even one whose own name carries a hash: a document is the thing a
270 // reader has bookmarked, and a deploy that changes it must be seen.
271 assert_eq!(
272 cache_control("text/html", &at("page.4f3a9c21.html"), 0, YEAR),
273 "no-cache");
274 }
275
276 /// A name that carries a content hash is a promise the bytes cannot change
277 /// under it, which is the only thing that makes a year safe.
278 #[test]
279 fn a_hashed_name_is_held_and_never_revalidated() {
280 assert_eq!(
281 cache_control("application/wasm", &at("module_1f2e3d4c_bg.wasm"), 0, YEAR),
282 fmt!("public, max-age={}, immutable", YEAR));
283 // The operator can switch the whole treatment off.
284 assert_eq!(
285 cache_control("application/wasm", &at("module_1f2e3d4c_bg.wasm"), 0, 0),
286 "no-cache");
287 }
288
289 /// Narrow on purpose: a false positive means a browser holding a stale file
290 /// for a year.
291 #[test]
292 fn only_a_name_that_really_carries_a_hash_is_read_as_one() {
293 for name in [
294 "app.4f3a9c21.js",
295 "main-8ab19c7e.css",
296 "module_1f2e3d4c_bg.wasm",
297 "sha-2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae.bin",
298 ] {
299 assert!(is_fingerprinted(&at(name)), "{} carries a hash", name);
300 }
301 for name in [
302 "index.html",
303 "explayna_bg.wasm",
304 "app.js",
305 "style.css",
306 // Hex, but all letters: an English word, not a digest.
307 "deadbeef.js",
308 "facecafe.css",
309 // All numerals: a date or an identifier, not a digest.
310 "20260728.json",
311 "post-20260728.html",
312 // Too short to be any tool's output.
313 "app.4f3a9c.js",
314 // Hash-shaped, but buried in a longer word rather than its own segment.
315 "prefix4f3a9c21suffix.js",
316 ] {
317 assert!(!is_fingerprinted(&at(name)), "{} does not carry a hash", name);
318 }
319 }
320
321 /// A generated response says so, rather than leaving a store to guess a lifetime for it.
322 #[test]
323 fn a_generated_response_is_never_served_unasked() -> Outcome<()> {
324 let resp = generated(HttpMessage::new_response(HttpStatus::OK));
325 let held = res!(resp.header.fields.get_one(&HeaderName::CacheControl).ok_or_else(||
326 err!("A generated response carried no cache directive."; Missing)));
327 assert_eq!(fmt!("{}", held), "no-cache");
328 Ok(())
329 }
330
331 /// `Cache-Control` is a list field, so a second stamp would leave both directives in force.
332 #[test]
333 fn stamping_twice_leaves_one_directive() -> Outcome<()> {
334 let resp = generated(generated(HttpMessage::new_response(HttpStatus::OK)));
335 let all = res!(resp.header.fields.get_list(&HeaderName::CacheControl).ok_or_else(||
336 err!("A generated response carried no cache directive."; Missing)));
337 assert_eq!(all.len(), 1, "The directive was repeated rather than replaced.");
338 Ok(())
339 }
340
341 /// A response that has said how long it may be held is not overruled by the default.
342 #[test]
343 fn an_explicit_directive_survives_the_default() -> Outcome<()> {
344 let held = HttpMessage::new_response(HttpStatus::OK)
345 .with_field(
346 HeaderName::CacheControl,
347 HeaderFieldValue::Generic(fmt!("public, max-age=86400")),
348 );
349 let resp = generated(held);
350 let all = res!(resp.header.fields.get_list(&HeaderName::CacheControl).ok_or_else(||
351 err!("The directive went missing."; Missing)));
352 assert_eq!(all.len(), 1);
353 assert_eq!(fmt!("{}", all[0]), "public, max-age=86400");
354 Ok(())
355 }
356
357 #[test]
358 fn an_asset_is_held_only_when_the_operator_asks_for_it() {
359 assert_eq!(cache_control("application/wasm", &at("app_bg.wasm"), 0, YEAR),
360 "no-cache");
361 assert_eq!(cache_control("application/wasm", &at("app_bg.wasm"), 3600, YEAR),
362 "public, max-age=3600");
363 }
364
365 /// A `304` restates the fields a cache stores, so one that dropped `Vary`
366 /// would send the cache back to serving one encoding to everybody.
367 #[test]
368 fn a_not_modified_repeats_what_the_response_varies_by() -> Outcome<()> {
369 let varying = res!(not_modified(
370 fmt!("\"abc-10-gzip\""), fmt!("no-cache"), true));
371 let held = res!(varying.header.fields.get_one(&HeaderName::Vary).ok_or_else(||
372 err!("The 304 did not say what it varies by."; Missing)));
373 assert_eq!(fmt!("{}", held).to_ascii_lowercase(), "accept-encoding");
374
375 let fixed = res!(not_modified(fmt!("\"abc-10\""), fmt!("no-cache"), false));
376 assert!(fixed.header.fields.get_one(&HeaderName::Vary).is_none(),
377 "a representation with only one form does not vary");
378 Ok(())
379 }
380}