1use axum::{
2 body::{Body, Bytes},
3 extract::Path,
4 http::{header, HeaderMap, StatusCode},
5 response::{IntoResponse, Response},
6};
7use rust_embed::Embed;
8
9pub 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
21pub fn try_asset<A: Embed>(name: &str) -> Result<String, String> {
23 asset::<A>(name)
24}
25
26fn 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
43pub fn asset_bytes<A: Embed>(name: &str) -> Option<Bytes> {
48 A::get(name).map(bytes_of)
49}
50
51pub fn load_manifest<A: Embed>() -> Result<crate::AssetManifest, String> {
58 let data = asset::<A>("manifest.json")?;
59 crate::AssetManifest::from_json(&data)
60}
61
62pub fn try_load_manifest<A: Embed>() -> Result<crate::AssetManifest, String> {
64 load_manifest::<A>()
65}
66
67pub 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
81fn 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
98pub(crate) struct Encoding<'a> {
100 pub(crate) token: &'a str,
101 pub(crate) q: f32,
102}
103
104pub(crate) fn 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 let mut q = 1.0_f32;
121 for param in parts {
122 let Some((name, value)) = param.split_once('=') else {
123 continue;
124 };
125 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
139pub 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 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 let mut candidates = [("br", q_of("br")), ("gzip", q_of("gzip"))];
185 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 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
223fn is_embedded<A: Embed + 'static>(name: &str) -> bool {
225 #[cfg(not(debug_assertions))]
226 {
227 match cached_names::<A>() {
229 Some(names) => names.contains(name),
230 None => A::iter().any(|f| f == name),
232 }
233 }
234 #[cfg(debug_assertions)]
235 {
236 A::iter().any(|f| f == name)
239 }
240}
241
242#[cfg(not(debug_assertions))]
244type NameSet = std::collections::HashSet<std::borrow::Cow<'static, str>>;
245
246#[cfg(not(debug_assertions))]
251const NAME_CACHE_SLOTS: usize = 4;
252
253#[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#[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 let (owner, names) = slot.get_or_init(|| (key, A::iter().collect()));
278 if *owner == key {
280 return Some(names);
281 }
282 }
283 None
284}
285
286fn 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 let etag = format!(
327 "\"{}{}\"",
328 hex8(&file.metadata.sha256_hash()),
329 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 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 .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
378fn 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
390fn hex8(hash: &[u8; 32]) -> String {
392 hash[..8].iter().map(|b| format!("{b:02x}")).collect()
393}
394
395fn 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 Some("js") | Some("mjs") => "text/javascript; charset=utf-8",
412 Some("css") => "text/css; charset=utf-8",
413 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 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 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 assert_eq!(mime_for("forma.deadbeef.wasm"), "application/wasm");
483 }
484
485 #[test]
486 fn mime_default_stays_fail_closed() {
487 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 assert!(is_denied("manifest.json"));
510 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 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 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 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 assert_eq!(pick("br, gzip", true, true), Some("br"));
565 }
566
567 #[test]
568 fn tokens_match_exactly_not_by_substring() {
569 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 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 assert_eq!(pick("*", true, true), None);
587 assert_eq!(pick("identity", true, true), None);
588 assert_eq!(pick("", true, true), None);
589 }
590
591 #[derive(Embed)]
594 #[folder = "test_fixtures/"]
595 struct Fixtures;
596
597 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 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 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 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 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 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 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 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 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 #[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 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 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 #[cfg(not(debug_assertions))]
826 const _: () = assert!(5 > NAME_CACHE_SLOTS, "serve more types than slots");
827
828 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}