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