Skip to main content

forma_server/
assets.rs

1use axum::{
2    body::{Body, Bytes},
3    extract::Path,
4    http::{header, HeaderMap, StatusCode},
5    response::{IntoResponse, Response},
6};
7use rust_embed::Embed;
8
9/// Load a text asset from the embedded dist directory.
10///
11/// # Errors
12///
13/// A missing or non-UTF-8 file returns an error naming the asset.
14pub fn asset<A: Embed>(name: &str) -> Result<String, String> {
15    let file = A::get(name).ok_or_else(|| format!("Missing asset: {name}"))?;
16    std::str::from_utf8(&file.data)
17        .map(|s| s.to_owned())
18        .map_err(|_| format!("Non-UTF8 asset: {name}"))
19}
20
21/// Compatibility alias for [`asset`], with the same fallible result.
22pub fn try_asset<A: Embed>(name: &str) -> Result<String, String> {
23    asset::<A>(name)
24}
25
26/// Borrow an embedded file's bytes without copying them.
27///
28/// In release builds `rust-embed` hands back `Cow::Borrowed(&'static [u8])`,
29/// so this is a pointer copy. The previous `.to_vec()` meant N concurrent
30/// requests for the biggest asset (the WASM binary) allocated N full copies —
31/// an unauthenticated memory amplifier that scales with connection count.
32fn bytes_of(file: rust_embed::EmbeddedFile) -> Bytes {
33    match file.data {
34        std::borrow::Cow::Borrowed(b) => Bytes::from_static(b),
35        std::borrow::Cow::Owned(v) => Bytes::from(v),
36    }
37}
38
39fn body_of(file: rust_embed::EmbeddedFile) -> Body {
40    Body::from(bytes_of(file))
41}
42
43/// Load raw bytes from the embedded dist directory. Returns None if missing.
44///
45/// Returns [`Bytes`] rather than `Vec<u8>` so callers do not copy the file;
46/// `Bytes` derefs to `[u8]` and converts into a response `Body` for free.
47pub fn asset_bytes<A: Embed>(name: &str) -> Option<Bytes> {
48    A::get(name).map(bytes_of)
49}
50
51/// Load and parse the asset manifest from embedded assets.
52///
53/// # Errors
54///
55/// A missing, non-UTF-8, malformed or incompatible `manifest.json` returns its
56/// asset or manifest-validation error. Handle it before opening the listener.
57pub fn load_manifest<A: Embed>() -> Result<crate::AssetManifest, String> {
58    let data = asset::<A>("manifest.json")?;
59    crate::AssetManifest::from_json(&data)
60}
61
62/// Compatibility alias for [`load_manifest`], with the same fallible result.
63pub fn try_load_manifest<A: Embed>() -> Result<crate::AssetManifest, String> {
64    load_manifest::<A>()
65}
66
67/// Each route's `modulepreload` list from the embedded `manifest.json`, for
68/// [`RenderCtx::with_module_preloads`](crate::RenderCtx::with_module_preloads)
69/// Call it at start, beside [`try_load_manifest`].
70///
71/// # Errors
72///
73/// A missing or malformed manifest, or a preload that names a file the
74/// manifest does not list among the build's `assets`; refuse to start on it,
75/// as on a bad module.
76pub fn try_load_module_preloads<A: Embed>() -> Result<crate::ModulePreloads, String> {
77    let data = try_asset::<A>("manifest.json")?;
78    crate::head::ModulePreloads::from_manifest_json(&data)
79}
80
81/// Files that must never be served through the asset route.
82///
83/// `manifest.json` exposes the full route table, asset graph and per-route
84/// size budgets, and nothing client-side fetches it. The same goes for
85/// `size-report.json`, the build's size report written beside it
86/// (FORMA-190): every route with its class, ceilings and bytes. A direct `.br`/`.gz`
87/// request would return compressed bytes with no `Content-Encoding`, i.e. a
88/// corrupt response. IR modules are server input and are never public assets — those siblings are only ever reachable through content
89/// negotiation below.
90fn is_denied(filename: &str) -> bool {
91    filename == "manifest.json"
92        || filename == "size-report.json"
93        || filename.ends_with(".ir")
94        || filename.ends_with(".br")
95        || filename.ends_with(".gz")
96}
97
98/// A parsed `Accept-Encoding` entry.
99struct Encoding<'a> {
100    token: &'a str,
101    q: f32,
102}
103
104/// Parse `Accept-Encoding` into (token, qvalue) pairs.
105///
106/// RFC 9110 §12.5.3: `q=0` means "not acceptable". A substring test reads
107/// `br;q=0` — the standard way a client or proxy says it cannot decode
108/// brotli — as acceptance, and then serves an undecodable body. A substring
109/// test also matches `x-brotli-nope` as "br".
110fn parse_accept_encoding(header: &str) -> Vec<Encoding<'_>> {
111    header
112        .split(',')
113        .filter_map(|element| {
114            let mut parts = element.split(';').map(str::trim);
115            let token = parts.next()?;
116            if token.is_empty() {
117                return None;
118            }
119            // Default q=1.0; an unparseable q is lenient (also 1.0).
120            let mut q = 1.0_f32;
121            for param in parts {
122                let Some((name, value)) = param.split_once('=') else {
123                    continue;
124                };
125                // Match the parameter name exactly after trimming — splitting
126                // on the literal ";q=" would miss `br; q=0` and `br;Q=0`.
127                if name.trim() == "q" {
128                    q = value.trim().parse().unwrap_or(1.0);
129                }
130            }
131            Some(Encoding {
132                token,
133                q: q.clamp(0.0, 1.0),
134            })
135        })
136        .collect()
137}
138
139/// Axum handler: serve a static asset with content negotiation (brotli, gzip).
140///
141/// Mount at `/_assets/{filename}` in your router. One binary may mount it for
142/// several `Embed` types; each serves exactly its own files.
143///
144/// `A` is `'static` because release builds cache each type's file list under
145/// its [`TypeId`](std::any::TypeId). Every `#[derive(Embed)]` type is, and
146/// mounting the handler on an axum route already required it.
147pub async fn serve_asset<A: Embed + 'static>(
148    Path(filename): Path<String>,
149    headers: HeaderMap,
150) -> Response {
151    if is_denied(&filename) {
152        return StatusCode::NOT_FOUND.into_response();
153    }
154
155    // Validate the requested name against the embedded file list BEFORE any
156    // lookup. In debug builds rust-embed reads from the filesystem, so on
157    // Windows `A::get` is case-insensitive and accepts NTFS stream syntax
158    // (`app.abc.js::$DATA`) — both of which 404 in release. Comparing against
159    // `iter()` (which yields the on-disk casing in debug and the compiled-in
160    // list in release) makes the two profiles agree, and closes the alias
161    // without a charset allowlist that would 404 legitimate un-hashed font
162    // filenames containing spaces.
163    if !is_embedded::<A>(&filename) {
164        return StatusCode::NOT_FOUND.into_response();
165    }
166
167    let accept = headers
168        .get(header::ACCEPT_ENCODING)
169        .and_then(|v| v.to_str().ok())
170        .unwrap_or("")
171        .to_ascii_lowercase();
172    let accepted = parse_accept_encoding(&accept);
173    let q_of = |name: &str| {
174        accepted
175            .iter()
176            .find(|e| e.token == name)
177            .map(|e| e.q)
178            .unwrap_or(0.0)
179    };
180
181    // Ordered preference list. `*` and `identity` deliberately fall through to
182    // the raw asset: treating `*` as licence to send brotli would be a
183    // robustness regression.
184    let mut candidates = [("br", q_of("br")), ("gzip", q_of("gzip"))];
185    // Stable sort by q descending, br winning ties (preserving the previous
186    // br-first bias).
187    candidates.sort_by(|a, b| b.1.total_cmp(&a.1));
188
189    let if_none_match = headers
190        .get(header::IF_NONE_MATCH)
191        .and_then(|v| v.to_str().ok());
192
193    for (encoding, q) in candidates {
194        if q <= 0.0 {
195            continue;
196        }
197        let suffix = if encoding == "br" { "br" } else { "gz" };
198        // Serve the first candidate whose compressed sibling actually exists —
199        // an asset with only a .gz used to be served raw to a br+gzip client.
200        if let Some(file) = A::get(&format!("{filename}.{suffix}")) {
201            return asset_response(
202                &filename,
203                file,
204                Some(encoding),
205                if_none_match,
206                immutable_asset::<A>(&filename),
207            );
208        }
209    }
210
211    match A::get(&filename) {
212        Some(file) => asset_response(
213            &filename,
214            file,
215            None,
216            if_none_match,
217            immutable_asset::<A>(&filename),
218        ),
219        None => StatusCode::NOT_FOUND.into_response(),
220    }
221}
222
223/// True if `name` is exactly one of `A`'s embedded file names.
224fn is_embedded<A: Embed + 'static>(name: &str) -> bool {
225    #[cfg(not(debug_assertions))]
226    {
227        // Release: the list is compiled in and immutable, so cache it, per type.
228        match cached_names::<A>() {
229            Some(names) => names.contains(name),
230            // Verified by: crates/forma-server/src/assets.rs > "more_embed_types_than_cache_slots_each_serve_their_own_assets"
231            None => A::iter().any(|f| f == name),
232        }
233    }
234    #[cfg(debug_assertions)]
235    {
236        // Debug: rust-embed walks the folder, so never cache — a newly built
237        // asset must be servable without restarting the dev server.
238        A::iter().any(|f| f == name)
239    }
240}
241
242/// One `Embed` type's file names, borrowed from its compiled-in list.
243#[cfg(not(debug_assertions))]
244type NameSet = std::collections::HashSet<std::borrow::Cow<'static, str>>;
245
246/// How many `Embed` types get a cached name set. An application serves one: asset
247/// retention (FORMA-186) copies the previous release's files into the one `dist/`
248/// the application embeds. A type beyond the last slot is checked by scanning its
249/// list: slower, never wrong.
250#[cfg(not(debug_assertions))]
251const NAME_CACHE_SLOTS: usize = 4;
252
253/// One cached name set per `Embed` type, each slot claimed by the first request
254/// its type serves and never written again.
255///
256/// The cache carries its own `TypeId` key because a `static` cannot be per
257/// type: one declared inside a generic function is a single static shared by
258/// every instantiation. 0.2.0's cache was such a static, so the first type
259/// served filled it and every other type's valid assets answered 404.
260///
261/// Slots rather than a locked map: reading a filled `OnceLock` is one atomic
262/// load, where every `RwLock` read writes a shared counter that all the cores
263/// serving assets would contend on.
264#[cfg(not(debug_assertions))]
265static NAME_CACHE: [std::sync::OnceLock<(std::any::TypeId, NameSet)>; NAME_CACHE_SLOTS] =
266    [const { std::sync::OnceLock::new() }; NAME_CACHE_SLOTS];
267
268/// `A`'s cached name set, or `None` if every slot belongs to another type.
269#[cfg(not(debug_assertions))]
270fn cached_names<A: Embed + 'static>() -> Option<&'static NameSet> {
271    let key = std::any::TypeId::of::<A>();
272    for slot in &NAME_CACHE {
273        // Slots fill in order and are written once, so a type absent from
274        // every earlier slot cannot be in a later one: the first empty slot is
275        // this type's to claim. A concurrent claim on the same slot blocks
276        // until the winner has filled it, then reads the winner's key.
277        let (owner, names) = slot.get_or_init(|| (key, A::iter().collect()));
278        // Verified by: crates/forma-server/tests/asset_sets.rs > "two_embed_types_each_serve_their_own_assets"
279        if *owner == key {
280            return Some(names);
281        }
282    }
283    None
284}
285
286/// Trust only validated manifest metadata, including retained entries. Debug
287/// rereads on every request; release caches are keyed by the actual Embed type.
288fn immutable_asset<A: Embed + 'static>(filename: &str) -> bool {
289    fn names<A: Embed>() -> std::collections::HashSet<String> {
290        try_load_manifest::<A>()
291            .map(|m| {
292                m.assets
293                    .into_iter()
294                    .filter_map(|(name, meta)| meta.immutable.then_some(name))
295                    .collect()
296            })
297            .unwrap_or_default()
298    }
299    #[cfg(not(debug_assertions))]
300    {
301        type Entry = (std::any::TypeId, std::collections::HashSet<String>);
302        static CACHE: [std::sync::OnceLock<Entry>; 4] = [const { std::sync::OnceLock::new() }; 4];
303        let key = std::any::TypeId::of::<A>();
304        for slot in &CACHE {
305            let (owner, names) = slot.get_or_init(|| (key, names::<A>()));
306            if *owner == key {
307                return names.contains(filename);
308            }
309        }
310    }
311    names::<A>().contains(filename)
312}
313
314fn asset_response(
315    filename: &str,
316    file: rust_embed::EmbeddedFile,
317    encoding: Option<&str>,
318    if_none_match: Option<&str>,
319    immutable: bool,
320) -> Response {
321    let mime = mime_for(filename);
322    // Strong ETag from the content hash rust-embed already computed. This
323    // matters because un-hashed assets (every .woff2, every .svg) now get
324    // `max-age=0, must-revalidate`; without a validator that would mean a full
325    // 200-with-body re-download on every navigation.
326    let etag = format!(
327        "\"{}{}\"",
328        hex8(&file.metadata.sha256_hash()),
329        // The compressed variants are different bytes under the same URL, and
330        // `Vary: accept-encoding` is advisory to caches only, so qualify it.
331        encoding.unwrap_or("")
332    );
333
334    let cache_control = if immutable {
335        "public, max-age=31536000, immutable"
336    } else {
337        "public, max-age=0, must-revalidate"
338    };
339
340    // Answer a conditional request without re-sending the body.
341    if if_none_match.map(|v| v.split(',').any(|c| c.trim() == etag)) == Some(true) {
342        return built(
343            Response::builder()
344                .status(StatusCode::NOT_MODIFIED)
345                .header(header::CACHE_CONTROL, cache_control)
346                .header(header::ETAG, &etag)
347                .header("vary", "accept-encoding")
348                .body(Body::empty()),
349        );
350    }
351
352    let mut builder = Response::builder()
353        .header(header::CONTENT_TYPE, mime)
354        .header(header::CACHE_CONTROL, cache_control)
355        .header(header::ETAG, &etag)
356        .header("vary", "accept-encoding")
357        // Harmless for js/css/wasm — a subresource's own CSP does not govern
358        // its execution, only a directly-navigated document's. It bites
359        // exactly the case it is meant to: an `.svg` or `.html` in dist that
360        // someone navigates to directly cannot run script. It does not collide
361        // with the per-page policy, which the app sets on page responses.
362        .header(
363            header::CONTENT_SECURITY_POLICY,
364            "default-src 'none'; sandbox",
365        );
366
367    for (name, value) in crate::types::asset_security_headers() {
368        builder = builder.header(name, value);
369    }
370
371    if let Some(enc) = encoding {
372        builder = builder.header(header::CONTENT_ENCODING, enc);
373    }
374
375    built(builder.body(body_of(file)))
376}
377
378/// A response from a builder, or a bare 500 if a header failed to parse.
379///
380/// Every header above is a static string or an ETag of hex digits, so the
381/// builder cannot fail today; this keeps a future header that can from
382/// panicking the request (DR-12) and sends no body without its headers.
383fn built(response: Result<Response<Body>, axum::http::Error>) -> Response {
384    match response {
385        Ok(response) => response.into_response(),
386        Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
387    }
388}
389
390/// First 8 bytes of a hash, hex-encoded.
391fn hex8(hash: &[u8; 32]) -> String {
392    hash[..8].iter().map(|b| format!("{b:02x}")).collect()
393}
394
395/// Map a filename to its `Content-Type`.
396///
397/// Extension matching is case-insensitive and anchored to the last dot after
398/// the last path separator — the dashboard template mounts a wildcard route,
399/// so `filename` can contain `/`, and `img.v2/logo` must not be read as
400/// extension `v2/logo`.
401///
402/// Anything unrecognized stays `application/octet-stream`. Combined with the
403/// `nosniff` header that is the correct fail-closed answer, and the default is
404/// deliberate: `.ir` files are opaque binaries read via `arrayBuffer()`, so
405/// octet-stream is the honest type for them.
406fn mime_for(filename: &str) -> &'static str {
407    let base = filename.rsplit('/').next().unwrap_or(filename);
408    let ext = base.rsplit_once('.').map(|(_, e)| e.to_ascii_lowercase());
409    match ext.as_deref() {
410        // One spelling of the JS MIME for both extensions (WHATWG-preferred).
411        Some("js") | Some("mjs") => "text/javascript; charset=utf-8",
412        Some("css") => "text/css; charset=utf-8",
413        // Hard requirement, not a nicety: the wasm-pack loader uses
414        // WebAssembly.instantiateStreaming, which strictly rejects any other
415        // type.
416        Some("wasm") => "application/wasm",
417        Some("json") => "application/json",
418        Some("map") => "application/json",
419        Some("html") => "text/html; charset=utf-8",
420        Some("txt") => "text/plain; charset=utf-8",
421        Some("xml") => "application/xml",
422        Some("webmanifest") => "application/manifest+json",
423        Some("svg") => "image/svg+xml",
424        Some("png") => "image/png",
425        Some("jpg") | Some("jpeg") => "image/jpeg",
426        Some("gif") => "image/gif",
427        Some("webp") => "image/webp",
428        Some("avif") => "image/avif",
429        Some("ico") => "image/x-icon",
430        Some("woff2") => "font/woff2",
431        Some("woff") => "font/woff",
432        Some("ttf") => "font/ttf",
433        Some("otf") => "font/otf",
434        _ => "application/octet-stream",
435    }
436}
437
438#[cfg(test)]
439mod tests {
440    use super::*;
441
442    #[test]
443    fn mime_matching_is_case_insensitive() {
444        // The regression that actually matters: `nosniff` hard-blocks a script
445        // or stylesheet served as octet-stream, and in debug builds the
446        // filesystem lookup is case-insensitive on Windows, so an uppercase
447        // request used to resolve the file and then be refused by the browser.
448        assert_eq!(
449            mime_for("app.abc123ef.js"),
450            "text/javascript; charset=utf-8"
451        );
452        assert_eq!(
453            mime_for("APP.ABC123EF.JS"),
454            "text/javascript; charset=utf-8"
455        );
456        assert_eq!(mime_for("app.MJS"), "text/javascript; charset=utf-8");
457        assert_eq!(mime_for("style.CSS"), "text/css; charset=utf-8");
458    }
459
460    #[test]
461    fn mime_reads_the_extension_after_the_last_separator() {
462        // A wildcard asset route can deliver a path, and `img.v2/logo` has no
463        // extension at all.
464        assert_eq!(mime_for("img.v2/logo"), "application/octet-stream");
465        assert_eq!(mime_for("img.v2/logo.png"), "image/png");
466        assert_eq!(mime_for("noextension"), "application/octet-stream");
467    }
468
469    #[test]
470    fn mime_covers_images_fonts_and_maps() {
471        assert_eq!(mime_for("logo.a1b2c3d4.png"), "image/png");
472        assert_eq!(mime_for("photo.jpeg"), "image/jpeg");
473        assert_eq!(mime_for("icon.ico"), "image/x-icon");
474        assert_eq!(mime_for("inter.woff2"), "font/woff2");
475        assert_eq!(mime_for("legacy.woff"), "font/woff");
476        assert_eq!(mime_for("app.js.map"), "application/json");
477    }
478
479    #[test]
480    fn mime_wasm_is_pinned() {
481        // WebAssembly.instantiateStreaming rejects anything else.
482        assert_eq!(mime_for("forma.deadbeef.wasm"), "application/wasm");
483    }
484
485    #[test]
486    fn mime_default_stays_fail_closed() {
487        // Pinned so a future widening does not silently swallow these: with
488        // nosniff set, octet-stream is the correct answer for an opaque binary.
489        assert_eq!(mime_for("page.abc12345.ir"), "application/octet-stream");
490        assert_eq!(mime_for("archive.zip"), "application/octet-stream");
491        assert_eq!(mime_for("thing.unknownext"), "application/octet-stream");
492    }
493
494    #[test]
495    fn metadata_is_required_for_immutable_caching() {
496        #[derive(Embed)]
497        #[folder = "test_fixtures/"]
498        struct Files;
499        assert!(immutable_asset::<Files>("app.a1b2c3d4.js"));
500        assert!(!immutable_asset::<Files>("style.css"));
501        assert!(!immutable_asset::<Files>("unlisted.a1b2c3d4.js"));
502    }
503
504    #[test]
505    fn denied_names_are_never_served() {
506        // manifest.json exposes the route table and asset graph; a direct
507        // .br/.gz request would return compressed bytes with no
508        // Content-Encoding.
509        assert!(is_denied("manifest.json"));
510        // The build's size report sits beside the manifest (FORMA-190) and
511        // lists the same route table with each route's bytes.
512        assert!(is_denied("size-report.json"));
513        assert!(is_denied("page.abc12345.ir"));
514        assert!(is_denied("app.a1b2c3d4.js.br"));
515        assert!(is_denied("app.a1b2c3d4.js.gz"));
516        assert!(!is_denied("app.a1b2c3d4.js"));
517    }
518
519    // -- Accept-Encoding negotiation ----------------------------------------
520
521    /// Resolve which encoding `serve_asset` would pick, given a header and the
522    /// set of compressed siblings that exist.
523    fn pick(header: &str, has_br: bool, has_gz: bool) -> Option<&'static str> {
524        let lowered = header.to_ascii_lowercase();
525        let accepted = parse_accept_encoding(&lowered);
526        let q_of = |name: &str| {
527            accepted
528                .iter()
529                .find(|e| e.token == name)
530                .map(|e| e.q)
531                .unwrap_or(0.0)
532        };
533        let mut candidates = [("br", q_of("br")), ("gzip", q_of("gzip"))];
534        candidates.sort_by(|a, b| b.1.total_cmp(&a.1));
535        for (encoding, q) in candidates {
536            if q <= 0.0 {
537                continue;
538            }
539            if (encoding == "br" && has_br) || (encoding == "gzip" && has_gz) {
540                return Some(encoding);
541            }
542        }
543        None
544    }
545
546    #[test]
547    fn qvalue_zero_is_a_rejection_not_an_acceptance() {
548        // The standard way a client or proxy says "I cannot decode brotli".
549        // A substring test read this as acceptance and shipped an undecodable
550        // body — i.e. the whole app bundle failed to load, with no fallback.
551        assert_eq!(pick("gzip, deflate, br;q=0", true, true), Some("gzip"));
552        assert_eq!(pick("gzip;q=0, br", true, true), Some("br"));
553        assert_eq!(pick("gzip;q=0, br;q=0", true, true), None);
554        // Whitespace and an uppercase parameter name must not defeat it.
555        assert_eq!(pick("br; q=0, gzip", true, true), Some("gzip"));
556        assert_eq!(pick("br;Q=0, gzip", true, true), Some("gzip"));
557    }
558
559    #[test]
560    fn higher_qvalue_wins() {
561        assert_eq!(pick("br;q=0.1, gzip;q=0.9", true, true), Some("gzip"));
562        assert_eq!(pick("br;q=0.9, gzip;q=0.1", true, true), Some("br"));
563        // br wins ties, preserving the previous bias.
564        assert_eq!(pick("br, gzip", true, true), Some("br"));
565    }
566
567    #[test]
568    fn tokens_match_exactly_not_by_substring() {
569        // `x-brotli-nope` used to be served brotli.
570        assert_eq!(pick("x-brotli-nope", true, true), None);
571        assert_eq!(pick("gzipped-thing", true, true), None);
572    }
573
574    #[test]
575    fn falls_through_to_the_next_candidate_when_a_sibling_is_missing() {
576        // An asset with a .gz but no .br used to be served RAW to a br+gzip
577        // client.
578        assert_eq!(pick("gzip, deflate, br", false, true), Some("gzip"));
579        assert_eq!(pick("gzip, deflate, br", true, false), Some("br"));
580        assert_eq!(pick("gzip, deflate, br", false, false), None);
581    }
582
583    #[test]
584    fn wildcard_identity_and_absent_headers_serve_raw() {
585        // `*` is deliberately not treated as licence to send brotli.
586        assert_eq!(pick("*", true, true), None);
587        assert_eq!(pick("identity", true, true), None);
588        assert_eq!(pick("", true, true), None);
589    }
590
591    // -- End-to-end handler behavior ----------------------------------------
592
593    #[derive(Embed)]
594    #[folder = "test_fixtures/"]
595    struct Fixtures;
596
597    /// Call the handler with an optional Accept-Encoding header.
598    async fn get(name: &str, accept: Option<&str>) -> Response {
599        let mut headers = HeaderMap::new();
600        if let Some(accept) = accept {
601            headers.insert(header::ACCEPT_ENCODING, accept.parse().unwrap());
602        }
603        serve_asset::<Fixtures>(Path(name.to_string()), headers).await
604    }
605
606    fn header_of(response: &Response, name: &str) -> Option<String> {
607        response
608            .headers()
609            .get(name)
610            .map(|v| v.to_str().unwrap().to_string())
611    }
612
613    #[tokio::test]
614    async fn serves_a_hashed_asset_immutable_with_an_etag() {
615        let response = get("app.a1b2c3d4.js", None).await;
616        assert_eq!(response.status(), StatusCode::OK);
617        assert_eq!(
618            header_of(&response, "content-type").as_deref(),
619            Some("text/javascript; charset=utf-8")
620        );
621        assert_eq!(
622            header_of(&response, "cache-control").as_deref(),
623            Some("public, max-age=31536000, immutable")
624        );
625        assert!(header_of(&response, "etag").is_some());
626        assert_eq!(
627            header_of(&response, "x-content-type-options").as_deref(),
628            Some("nosniff")
629        );
630    }
631
632    #[tokio::test]
633    async fn un_hashed_assets_are_revalidated_not_pinned_for_a_year() {
634        // `immutable` on an un-hashed name means a redeploy cannot invalidate
635        // it — the ETag is what keeps that cheap.
636        let response = get("style.css", None).await;
637        assert_eq!(
638            header_of(&response, "cache-control").as_deref(),
639            Some("public, max-age=0, must-revalidate")
640        );
641        assert!(header_of(&response, "etag").is_some());
642    }
643
644    #[tokio::test]
645    async fn conditional_request_gets_304_without_a_body() {
646        let first = get("style.css", None).await;
647        let etag = header_of(&first, "etag").unwrap();
648
649        let mut headers = HeaderMap::new();
650        headers.insert(header::IF_NONE_MATCH, etag.parse().unwrap());
651        let second = serve_asset::<Fixtures>(Path("style.css".to_string()), headers).await;
652        assert_eq!(second.status(), StatusCode::NOT_MODIFIED);
653    }
654
655    #[tokio::test]
656    async fn negotiation_honours_qvalue_zero_end_to_end() {
657        // style.css has a .br sibling but no .gz.
658        let response = get("style.css", Some("gzip, deflate, br")).await;
659        assert_eq!(
660            header_of(&response, "content-encoding").as_deref(),
661            Some("br")
662        );
663
664        // ...and a client that cannot decode brotli must not receive it.
665        let response = get("style.css", Some("gzip, deflate, br;q=0")).await;
666        assert_eq!(header_of(&response, "content-encoding"), None);
667    }
668
669    #[tokio::test]
670    async fn falls_back_to_gzip_when_only_a_gz_sibling_exists() {
671        // app.a1b2c3d4.js has a .gz but no .br — this used to serve raw.
672        let response = get("app.a1b2c3d4.js", Some("gzip, deflate, br")).await;
673        assert_eq!(
674            header_of(&response, "content-encoding").as_deref(),
675            Some("gzip")
676        );
677    }
678
679    /// The fixtures plus a `size-report.json`, as an output of `@getforma/build`
680    /// 3.0 embeds it beside the manifest (FORMA-190): really embedded, so that a
681    /// 404 for it can only be the handler's refusal and not an absent file.
682    struct WithReport;
683
684    impl Embed for WithReport {
685        fn get(path: &str) -> Option<rust_embed::EmbeddedFile> {
686            if path != "size-report.json" {
687                return Fixtures::get(path);
688            }
689            let mut file = Fixtures::get("manifest.json")?;
690            file.data = std::borrow::Cow::Borrowed(br#"{"version":1,"routes":[]}"#);
691            Some(file)
692        }
693        fn iter() -> impl Iterator<Item = std::borrow::Cow<'static, str>> {
694            Fixtures::iter().chain(std::iter::once(std::borrow::Cow::Borrowed(
695                "size-report.json",
696            )))
697        }
698    }
699
700    #[tokio::test]
701    async fn an_embedded_size_report_is_not_served_through_the_route() {
702        assert!(is_embedded::<WithReport>("size-report.json"));
703        assert!(WithReport::get("size-report.json").is_some());
704        for accept in [None, Some("br, gzip"), Some("identity")] {
705            let mut headers = HeaderMap::new();
706            if let Some(accept) = accept {
707                headers.insert(header::ACCEPT_ENCODING, accept.parse().unwrap());
708            }
709            let response =
710                serve_asset::<WithReport>(Path("size-report.json".to_string()), headers).await;
711            assert_eq!(response.status(), StatusCode::NOT_FOUND, "{accept:?}");
712        }
713        // The same embed still serves what it should: the report's neighbours.
714        let served =
715            serve_asset::<WithReport>(Path("app.a1b2c3d4.js".to_string()), HeaderMap::new()).await;
716        assert_eq!(served.status(), StatusCode::OK);
717    }
718
719    #[tokio::test]
720    async fn manifest_and_compressed_siblings_are_not_directly_servable() {
721        // manifest.json exposes the route table and asset graph; a raw .gz
722        // response would be corrupt (compressed bytes, no Content-Encoding).
723        assert_eq!(
724            get("manifest.json", None).await.status(),
725            StatusCode::NOT_FOUND
726        );
727        assert_eq!(
728            get("app.a1b2c3d4.js.gz", None).await.status(),
729            StatusCode::NOT_FOUND
730        );
731        assert_eq!(
732            get("style.css.br", None).await.status(),
733            StatusCode::NOT_FOUND
734        );
735    }
736
737    #[tokio::test]
738    async fn filename_aliases_404_in_debug_just_like_release() {
739        // In debug builds rust-embed reads from the filesystem, so on Windows
740        // these used to resolve (as octet-stream) while 404ing in release —
741        // a dev/prod divergence that hides MIME and cache bugs until deploy.
742        for alias in [
743            "APP.A1B2C3D4.JS",
744            "app.a1b2c3d4.js::$DATA",
745            "app.a1b2c3d4.js.",
746            "app.a1b2c3d4.js ",
747            "./app.a1b2c3d4.js",
748            "../Cargo.toml",
749        ] {
750            assert_eq!(
751                get(alias, None).await.status(),
752                StatusCode::NOT_FOUND,
753                "alias {alias:?} must not resolve"
754            );
755        }
756    }
757
758    #[tokio::test]
759    async fn missing_asset_is_404() {
760        assert_eq!(get("nope.js", None).await.status(), StatusCode::NOT_FOUND);
761    }
762
763    // -- One binary, more Embed types than the release name cache holds ------
764
765    #[derive(Embed)]
766    #[folder = "tests/fixtures/asset-sets/a/"]
767    struct SetA;
768
769    #[derive(Embed)]
770    #[folder = "tests/fixtures/asset-sets/b/"]
771    struct SetB;
772
773    #[derive(Embed)]
774    #[folder = "tests/fixtures/asset-sets/c/"]
775    struct SetC;
776
777    #[derive(Embed)]
778    #[folder = "tests/fixtures/asset-sets/d/"]
779    struct SetD;
780
781    #[derive(Embed)]
782    #[folder = "tests/fixtures/asset-sets/e/"]
783    struct SetE;
784
785    /// Serve `name` from `A`; return status and body.
786    async fn fetch<A: Embed + 'static>(name: &str) -> (StatusCode, Vec<u8>) {
787        let response = serve_asset::<A>(Path(name.to_string()), HeaderMap::new()).await;
788        let status = response.status();
789        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
790            .await
791            .expect("an asset body is in memory and cannot fail to collect");
792        (status, body.to_vec())
793    }
794
795    /// `A` serves its own hashed file and its own `shared.css` byte for byte,
796    /// and refuses `foreign`, another set's file.
797    async fn assert_serves_its_own<A: Embed + 'static>(
798        own: &str,
799        own_js: &[u8],
800        own_css: &[u8],
801        foreign: &str,
802    ) {
803        assert_eq!(
804            fetch::<A>(own).await,
805            (StatusCode::OK, own_js.to_vec()),
806            "{own} from its own set"
807        );
808        assert_eq!(
809            fetch::<A>("shared.css").await,
810            (StatusCode::OK, own_css.to_vec()),
811            "shared.css from the set holding {own}"
812        );
813        assert_eq!(
814            fetch::<A>(foreign).await.0,
815            StatusCode::NOT_FOUND,
816            "{foreign} from the set holding {own}"
817        );
818    }
819
820    #[tokio::test]
821    async fn more_embed_types_than_cache_slots_each_serve_their_own_assets() {
822        // Five types against the cache's slots, so in release at least one of
823        // them is checked by the uncached scan, whichever order this binary's
824        // tests reach the cache in (`Fixtures` may hold a slot too).
825        #[cfg(not(debug_assertions))]
826        const _: () = assert!(5 > NAME_CACHE_SLOTS, "serve more types than slots");
827
828        // Twice, so every type is served again after all five have claimed or
829        // missed a slot.
830        for _ in 0..2 {
831            assert_serves_its_own::<SetA>(
832                "a.aaaaaaaa.js",
833                b"export default \"a\";\n",
834                b".a{}\n",
835                "b.bbbbbbbb.js",
836            )
837            .await;
838            assert_serves_its_own::<SetB>(
839                "b.bbbbbbbb.js",
840                b"export default \"bb\";\n",
841                b".bb{}\n",
842                "c.cccccccc.js",
843            )
844            .await;
845            assert_serves_its_own::<SetC>(
846                "c.cccccccc.js",
847                b"export default \"ccc\";\n",
848                b".ccc{}\n",
849                "d.dddddddd.js",
850            )
851            .await;
852            assert_serves_its_own::<SetD>(
853                "d.dddddddd.js",
854                b"export default \"dddd\";\n",
855                b".dddd{}\n",
856                "e.eeeeeeee.js",
857            )
858            .await;
859            assert_serves_its_own::<SetE>(
860                "e.eeeeeeee.js",
861                b"export default \"eeeee\";\n",
862                b".eeeee{}\n",
863                "a.aaaaaaaa.js",
864            )
865            .await;
866        }
867    }
868}