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 | |
| 13 | use oxedyne_fe2o3_core::prelude::*; |
| 14 | use oxedyne_fe2o3_net::http::{ |
| 15 | encoding, |
| 16 | fields::{ |
| 17 | HeaderFields, |
| 18 | HeaderFieldValue, |
| 19 | HeaderName, |
| 20 | }, |
| 21 | msg::HttpMessage, |
| 22 | status::HttpStatus, |
| 23 | }; |
| 24 | |
| 25 | use 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. |
| 38 | pub 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. |
| 53 | pub 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. |
| 85 | pub 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? |
| 107 | pub 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. |
| 135 | pub 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. |
| 163 | pub 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)] |
| 180 | pub 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. |
| 199 | pub 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)] |
| 219 | mod 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 | } |