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#[allow(clippy::panic)] pub fn asset<A: Embed>(name: &str) -> String {
20 try_asset::<A>(name).unwrap_or_else(|e| panic!("{e}"))
21}
22
23pub 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
31fn 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
48pub fn asset_bytes<A: Embed>(name: &str) -> Option<Bytes> {
53 A::get(name).map(bytes_of)
54}
55
56#[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)] pub fn load_manifest<A: Embed>() -> crate::AssetManifest {
74 try_load_manifest::<A>().unwrap_or_else(|e| panic!("{e}"))
75}
76
77pub 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
83pub 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
97fn is_denied(filename: &str) -> bool {
105 filename == "manifest.json" || filename.ends_with(".br") || filename.ends_with(".gz")
106}
107
108struct Encoding<'a> {
110 token: &'a str,
111 q: f32,
112}
113
114fn 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 let mut q = 1.0_f32;
131 for param in parts {
132 let Some((name, value)) = param.split_once('=') else {
133 continue;
134 };
135 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
149pub 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 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 let mut candidates = [("br", q_of("br")), ("gzip", q_of("gzip"))];
195 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 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
221fn is_embedded<A: Embed + 'static>(name: &str) -> bool {
223 #[cfg(not(debug_assertions))]
224 {
225 match cached_names::<A>() {
227 Some(names) => names.contains(name),
228 None => A::iter().any(|f| f == name),
230 }
231 }
232 #[cfg(debug_assertions)]
233 {
234 A::iter().any(|f| f == name)
237 }
238}
239
240#[cfg(not(debug_assertions))]
242type NameSet = std::collections::HashSet<std::borrow::Cow<'static, str>>;
243
244#[cfg(not(debug_assertions))]
249const NAME_CACHE_SLOTS: usize = 4;
250
251#[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#[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 let (owner, names) = slot.get_or_init(|| (key, A::iter().collect()));
276 if *owner == key {
278 return Some(names);
279 }
280 }
281 None
282}
283
284fn 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 let etag = format!(
313 "\"{}{}\"",
314 hex8(&file.metadata.sha256_hash()),
315 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 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 .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
364fn 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
376fn hex8(hash: &[u8; 32]) -> String {
378 hash[..8].iter().map(|b| format!("{b:02x}")).collect()
379}
380
381fn 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 Some("js") | Some("mjs") => "text/javascript; charset=utf-8",
398 Some("css") => "text/css; charset=utf-8",
399 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 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 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 assert_eq!(mime_for("forma.deadbeef.wasm"), "application/wasm");
469 }
470
471 #[test]
472 fn mime_default_stays_fail_closed() {
473 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 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 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 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 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 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 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 assert_eq!(pick("br, gzip", true, true), Some("br"));
553 }
554
555 #[test]
556 fn tokens_match_exactly_not_by_substring() {
557 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 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 assert_eq!(pick("*", true, true), None);
575 assert_eq!(pick("identity", true, true), None);
576 assert_eq!(pick("", true, true), None);
577 }
578
579 #[derive(Embed)]
582 #[folder = "test_fixtures/"]
583 struct Fixtures;
584
585 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 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 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 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 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 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 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 #[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 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 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 #[cfg(not(debug_assertions))]
774 const _: () = assert!(5 > NAME_CACHE_SLOTS, "serve more types than slots");
775
776 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}