Skip to main content

laterite_admin/
http_cache.rs

1//! Conditional responses and cache policy for admin and plugin routes.
2//!
3//! This is response correctness, not a cache: nothing is stored server-side and
4//! nothing here is disabled in development. A validator that only appears in
5//! production is a validator nobody has tested, and the wrong policy on a
6//! generated file is caught precisely because it is live while you work.
7//!
8//! Two shapes are offered, and which one fits is decided by whether the URL
9//! names the content:
10//!
11//! - A **fingerprinted** URL carries a digest of the bytes it serves, so a
12//!   change produces a different URL. Those may be cached forever, and the
13//!   answer never goes stale because the question changes.
14//! - A **stable** URL keeps its name across changes, so it may only be stored
15//!   with revalidation. An `ETag` makes that revalidation cheap: unchanged
16//!   bytes come back as a bodyless `304`.
17
18use axum::http::{header, HeaderMap, HeaderValue, StatusCode};
19use axum::response::{IntoResponse, Response};
20
21/// A fingerprinted URL's policy: the digest in the path is the version, so the
22/// bytes behind it can never change.
23pub const IMMUTABLE: &str = "public, max-age=31536000, immutable";
24
25/// A stable URL's policy: store it, but ask before reusing it. Not `no-store`:
26/// the copy is kept, and the `ETag` turns the check into a `304`.
27pub const REVALIDATE: &str = "public, no-cache";
28
29/// An authenticated screen's policy. Admin HTML is per-operator and often
30/// carries a request token, so no shared cache may hold it and no browser may
31/// keep it on disk for the next person at the machine.
32pub const PRIVATE_NO_STORE: &str = "private, no-store";
33
34/// Length of the digest used in URLs and entity tags. Eight hex characters is
35/// 32 bits: ample to tell one build's copy of a file from another's, while
36/// leaving the URL readable.
37const DIGEST_LEN: usize = 8;
38
39/// A short content digest, used both as the URL's version and as its `ETag`.
40pub fn digest(bytes: &[u8]) -> String {
41    blake3::hash(bytes).to_hex()[..DIGEST_LEN].to_string()
42}
43
44/// Inserts a digest before the extension: `laterite.css` becomes
45/// `laterite.a1b2c3d4.css`, and `fields/ref-picker.js` keeps its directory.
46/// A name with no extension takes the digest as a suffix.
47pub(crate) fn fingerprint(key: &str, digest: &str) -> String {
48    match key.rsplit_once('.') {
49        Some((stem, ext)) => format!("{stem}.{digest}.{ext}"),
50        None => format!("{key}.{digest}"),
51    }
52}
53
54/// The inverse of [`fingerprint`]: recovers the registry key from a served path
55/// and reports whether the path named its content.
56///
57/// A digest is only recognised in the exact shape [`fingerprint`] writes, so a
58/// file whose own name happens to contain a dot-separated hex word keeps it.
59pub(crate) fn strip_fingerprint(path: &str) -> (String, bool) {
60    let Some((rest, ext)) = path.rsplit_once('.') else {
61        return (path.to_string(), false);
62    };
63    match rest.rsplit_once('.') {
64        Some((stem, maybe_digest))
65            if maybe_digest.len() == DIGEST_LEN
66                && maybe_digest.bytes().all(|b| b.is_ascii_hexdigit()) =>
67        {
68            (format!("{stem}.{ext}"), true)
69        }
70        _ => (path.to_string(), false),
71    }
72}
73
74/// Whether the request already holds this exact entity, per `If-None-Match`.
75///
76/// Compares against each tag in the list rather than the raw header, so a
77/// browser sending several (or `W/` weak forms, which a proxy may add) is
78/// answered correctly.
79pub fn matches_etag(headers: &HeaderMap, etag: &str) -> bool {
80    let Some(raw) = headers
81        .get(header::IF_NONE_MATCH)
82        .and_then(|v| v.to_str().ok())
83    else {
84        return false;
85    };
86    raw.split(',').any(|candidate| {
87        let candidate = candidate.trim().trim_start_matches("W/").trim_matches('"');
88        candidate == etag || candidate == "*"
89    })
90}
91
92/// Answers a request for `body` with its validator, or a bodyless `304` when the
93/// client already holds it.
94///
95/// The `ETag` is weak (`W/`): the bytes handed to a client may be compressed by
96/// a layer above this one, and a strong tag would then be claiming
97/// byte-for-byte identity the response does not have (RFC 9110 ยง8.8.1).
98pub fn conditional(
99    headers: &HeaderMap,
100    content_type: &str,
101    cache_control: &str,
102    body: Vec<u8>,
103) -> Response {
104    let tag = digest(&body);
105    let quoted = format!("W/\"{tag}\"");
106    let etag = match HeaderValue::from_str(&quoted) {
107        Ok(v) => v,
108        // A digest is hex, so this cannot fail; answer without a validator
109        // rather than lose the response to an unwrap.
110        Err(_) => return ([(header::CACHE_CONTROL, cache_control)], body).into_response(),
111    };
112    if matches_etag(headers, &tag) {
113        return (
114            StatusCode::NOT_MODIFIED,
115            [
116                (header::ETAG, etag),
117                (
118                    header::CACHE_CONTROL,
119                    HeaderValue::from_str(cache_control).unwrap(),
120                ),
121            ],
122        )
123            .into_response();
124    }
125    (
126        [
127            (
128                header::CONTENT_TYPE,
129                HeaderValue::from_str(content_type).unwrap(),
130            ),
131            (header::ETAG, etag),
132            (
133                header::CACHE_CONTROL,
134                HeaderValue::from_str(cache_control).unwrap(),
135            ),
136        ],
137        body,
138    )
139        .into_response()
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145
146    #[test]
147    fn a_digest_round_trips_through_a_url() {
148        let d = digest(b"body");
149        let url = fingerprint("laterite.css", &d);
150        assert_eq!(url, format!("laterite.{d}.css"));
151        assert_eq!(strip_fingerprint(&url), ("laterite.css".to_string(), true));
152    }
153
154    #[test]
155    fn a_nested_key_keeps_its_directory() {
156        let url = fingerprint("fields/ref-picker.js", "a1b2c3d4");
157        assert_eq!(url, "fields/ref-picker.a1b2c3d4.js");
158        assert_eq!(
159            strip_fingerprint(&url),
160            ("fields/ref-picker.js".to_string(), true)
161        );
162    }
163
164    #[test]
165    fn an_unfingerprinted_path_is_reported_as_such() {
166        // This is what decides the cache policy, so mistaking a plain path for a
167        // fingerprinted one would serve a mutable file as immutable.
168        assert_eq!(
169            strip_fingerprint("laterite.css"),
170            ("laterite.css".to_string(), false)
171        );
172        assert_eq!(
173            strip_fingerprint("mark.svg"),
174            ("mark.svg".to_string(), false)
175        );
176    }
177
178    #[test]
179    fn a_hex_word_in_a_real_name_is_not_mistaken_for_a_digest() {
180        // Only the exact eight-hex shape counts; a file genuinely called this
181        // keeps its name.
182        assert_eq!(
183            strip_fingerprint("vendor.deadbeefcafe.js"),
184            ("vendor.deadbeefcafe.js".to_string(), false)
185        );
186    }
187
188    #[test]
189    fn if_none_match_recognises_the_tag_among_several() {
190        let mut headers = HeaderMap::new();
191        headers.insert(
192            header::IF_NONE_MATCH,
193            HeaderValue::from_static("\"aaaaaaaa\", W/\"a1b2c3d4\""),
194        );
195        assert!(matches_etag(&headers, "a1b2c3d4"));
196        assert!(!matches_etag(&headers, "ffffffff"));
197    }
198
199    #[test]
200    fn an_absent_header_never_matches() {
201        assert!(!matches_etag(&HeaderMap::new(), "a1b2c3d4"));
202    }
203
204    #[test]
205    fn an_unchanged_body_comes_back_bodyless() {
206        let body = b"h1 { color: red }".to_vec();
207        let tag = digest(&body);
208        let mut headers = HeaderMap::new();
209        headers.insert(
210            header::IF_NONE_MATCH,
211            HeaderValue::from_str(&format!("W/\"{tag}\"")).unwrap(),
212        );
213        let resp = conditional(&headers, "text/css", REVALIDATE, body);
214        assert_eq!(resp.status(), StatusCode::NOT_MODIFIED);
215    }
216
217    #[test]
218    fn a_changed_body_comes_back_whole() {
219        let mut headers = HeaderMap::new();
220        headers.insert(
221            header::IF_NONE_MATCH,
222            HeaderValue::from_static("W/\"stale\""),
223        );
224        let resp = conditional(&headers, "text/css", REVALIDATE, b"new".to_vec());
225        assert_eq!(resp.status(), StatusCode::OK);
226        assert!(resp.headers().get(header::ETAG).is_some());
227    }
228}