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