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 crate::AssetManifest::from_json(&data)
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"
106 || filename.ends_with(".ir")
107 || filename.ends_with(".br")
108 || filename.ends_with(".gz")
109}
110
111struct Encoding<'a> {
113 token: &'a str,
114 q: f32,
115}
116
117fn 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 let mut q = 1.0_f32;
134 for param in parts {
135 let Some((name, value)) = param.split_once('=') else {
136 continue;
137 };
138 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
152pub 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 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 let mut candidates = [("br", q_of("br")), ("gzip", q_of("gzip"))];
198 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 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
236fn is_embedded<A: Embed + 'static>(name: &str) -> bool {
238 #[cfg(not(debug_assertions))]
239 {
240 match cached_names::<A>() {
242 Some(names) => names.contains(name),
243 None => A::iter().any(|f| f == name),
245 }
246 }
247 #[cfg(debug_assertions)]
248 {
249 A::iter().any(|f| f == name)
252 }
253}
254
255#[cfg(not(debug_assertions))]
257type NameSet = std::collections::HashSet<std::borrow::Cow<'static, str>>;
258
259#[cfg(not(debug_assertions))]
264const NAME_CACHE_SLOTS: usize = 4;
265
266#[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#[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 let (owner, names) = slot.get_or_init(|| (key, A::iter().collect()));
291 if *owner == key {
293 return Some(names);
294 }
295 }
296 None
297}
298
299fn 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 let etag = format!(
340 "\"{}{}\"",
341 hex8(&file.metadata.sha256_hash()),
342 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 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 .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
391fn 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
403fn hex8(hash: &[u8; 32]) -> String {
405 hash[..8].iter().map(|b| format!("{b:02x}")).collect()
406}
407
408fn 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 Some("js") | Some("mjs") => "text/javascript; charset=utf-8",
425 Some("css") => "text/css; charset=utf-8",
426 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 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 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 assert_eq!(mime_for("forma.deadbeef.wasm"), "application/wasm");
496 }
497
498 #[test]
499 fn mime_default_stays_fail_closed() {
500 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 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 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 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 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 assert_eq!(pick("br, gzip", true, true), Some("br"));
575 }
576
577 #[test]
578 fn tokens_match_exactly_not_by_substring() {
579 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 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 assert_eq!(pick("*", true, true), None);
597 assert_eq!(pick("identity", true, true), None);
598 assert_eq!(pick("", true, true), None);
599 }
600
601 #[derive(Embed)]
604 #[folder = "test_fixtures/"]
605 struct Fixtures;
606
607 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 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 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 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 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 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 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 #[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 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 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 #[cfg(not(debug_assertions))]
796 const _: () = assert!(5 > NAME_CACHE_SLOTS, "serve more types than slots");
797
798 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}