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) -> String {
14 try_asset::<A>(name).unwrap_or_else(|e| panic!("{e}"))
15}
16
17pub fn try_asset<A: Embed>(name: &str) -> Result<String, String> {
19 let file = A::get(name).ok_or_else(|| format!("Missing asset: {name}"))?;
20 std::str::from_utf8(&file.data)
21 .map(|s| s.to_owned())
22 .map_err(|_| format!("Non-UTF8 asset: {name}"))
23}
24
25fn bytes_of(file: rust_embed::EmbeddedFile) -> Bytes {
32 match file.data {
33 std::borrow::Cow::Borrowed(b) => Bytes::from_static(b),
34 std::borrow::Cow::Owned(v) => Bytes::from(v),
35 }
36}
37
38fn body_of(file: rust_embed::EmbeddedFile) -> Body {
39 Body::from(bytes_of(file))
40}
41
42pub fn asset_bytes<A: Embed>(name: &str) -> Option<Bytes> {
47 A::get(name).map(bytes_of)
48}
49
50pub fn load_manifest<A: Embed>() -> crate::AssetManifest {
54 try_load_manifest::<A>().unwrap_or_else(|e| panic!("{e}"))
55}
56
57pub fn try_load_manifest<A: Embed>() -> Result<crate::AssetManifest, String> {
59 let data = try_asset::<A>("manifest.json")?;
60 serde_json::from_str(&data).map_err(|e| format!("Failed to parse manifest.json: {e}"))
61}
62
63fn is_denied(filename: &str) -> bool {
71 filename == "manifest.json" || filename.ends_with(".br") || filename.ends_with(".gz")
72}
73
74struct Encoding<'a> {
76 token: &'a str,
77 q: f32,
78}
79
80fn parse_accept_encoding(header: &str) -> Vec<Encoding<'_>> {
87 header
88 .split(',')
89 .filter_map(|element| {
90 let mut parts = element.split(';').map(str::trim);
91 let token = parts.next()?;
92 if token.is_empty() {
93 return None;
94 }
95 let mut q = 1.0_f32;
97 for param in parts {
98 let Some((name, value)) = param.split_once('=') else {
99 continue;
100 };
101 if name.trim() == "q" {
104 q = value.trim().parse().unwrap_or(1.0);
105 }
106 }
107 Some(Encoding {
108 token,
109 q: q.clamp(0.0, 1.0),
110 })
111 })
112 .collect()
113}
114
115pub async fn serve_asset<A: Embed>(Path(filename): Path<String>, headers: HeaderMap) -> Response {
119 if is_denied(&filename) {
120 return StatusCode::NOT_FOUND.into_response();
121 }
122
123 if !is_embedded::<A>(&filename) {
132 return StatusCode::NOT_FOUND.into_response();
133 }
134
135 let accept = headers
136 .get(header::ACCEPT_ENCODING)
137 .and_then(|v| v.to_str().ok())
138 .unwrap_or("")
139 .to_ascii_lowercase();
140 let accepted = parse_accept_encoding(&accept);
141 let q_of = |name: &str| {
142 accepted
143 .iter()
144 .find(|e| e.token == name)
145 .map(|e| e.q)
146 .unwrap_or(0.0)
147 };
148
149 let mut candidates = [("br", q_of("br")), ("gzip", q_of("gzip"))];
153 candidates.sort_by(|a, b| b.1.total_cmp(&a.1));
156
157 let if_none_match = headers
158 .get(header::IF_NONE_MATCH)
159 .and_then(|v| v.to_str().ok());
160
161 for (encoding, q) in candidates {
162 if q <= 0.0 {
163 continue;
164 }
165 let suffix = if encoding == "br" { "br" } else { "gz" };
166 if let Some(file) = A::get(&format!("{filename}.{suffix}")) {
169 return asset_response(&filename, file, Some(encoding), if_none_match);
170 }
171 }
172
173 match A::get(&filename) {
174 Some(file) => asset_response(&filename, file, None, if_none_match),
175 None => StatusCode::NOT_FOUND.into_response(),
176 }
177}
178
179fn is_embedded<A: Embed>(name: &str) -> bool {
181 #[cfg(not(debug_assertions))]
182 {
183 use std::collections::HashSet;
185 use std::sync::OnceLock;
186 static NAMES: OnceLock<HashSet<String>> = OnceLock::new();
187 return NAMES
188 .get_or_init(|| A::iter().map(|f| f.to_string()).collect())
189 .contains(name);
190 }
191 #[cfg(debug_assertions)]
192 {
193 A::iter().any(|f| f == name)
196 }
197}
198
199fn is_content_hashed(filename: &str) -> bool {
207 let name = filename.rsplit('/').next().unwrap_or(filename);
208 name.split('.').skip(1).any(|segment| {
209 segment.len() == 8
210 && segment
211 .bytes()
212 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
213 })
214}
215
216fn asset_response(
217 filename: &str,
218 file: rust_embed::EmbeddedFile,
219 encoding: Option<&str>,
220 if_none_match: Option<&str>,
221) -> Response {
222 let mime = mime_for(filename);
223 let etag = format!(
228 "\"{}{}\"",
229 hex8(&file.metadata.sha256_hash()),
230 encoding.unwrap_or("")
233 );
234
235 let cache_control = if is_content_hashed(filename) {
236 "public, max-age=31536000, immutable"
237 } else {
238 "public, max-age=0, must-revalidate"
239 };
240
241 if if_none_match.map(|v| v.split(',').any(|c| c.trim() == etag)) == Some(true) {
243 return Response::builder()
244 .status(StatusCode::NOT_MODIFIED)
245 .header(header::CACHE_CONTROL, cache_control)
246 .header(header::ETAG, &etag)
247 .header("vary", "accept-encoding")
248 .body(Body::empty())
249 .unwrap()
250 .into_response();
251 }
252
253 let mut builder = Response::builder()
254 .header(header::CONTENT_TYPE, mime)
255 .header(header::CACHE_CONTROL, cache_control)
256 .header(header::ETAG, &etag)
257 .header("vary", "accept-encoding")
258 .header(
264 header::CONTENT_SECURITY_POLICY,
265 "default-src 'none'; sandbox",
266 );
267
268 for (name, value) in crate::types::asset_security_headers() {
269 builder = builder.header(name, value);
270 }
271
272 if let Some(enc) = encoding {
273 builder = builder.header(header::CONTENT_ENCODING, enc);
274 }
275
276 builder.body(body_of(file)).unwrap().into_response()
277}
278
279fn hex8(hash: &[u8; 32]) -> String {
281 hash[..8].iter().map(|b| format!("{b:02x}")).collect()
282}
283
284fn mime_for(filename: &str) -> &'static str {
296 let base = filename.rsplit('/').next().unwrap_or(filename);
297 let ext = base.rsplit_once('.').map(|(_, e)| e.to_ascii_lowercase());
298 match ext.as_deref() {
299 Some("js") | Some("mjs") => "text/javascript; charset=utf-8",
301 Some("css") => "text/css; charset=utf-8",
302 Some("wasm") => "application/wasm",
306 Some("json") => "application/json",
307 Some("map") => "application/json",
308 Some("html") => "text/html; charset=utf-8",
309 Some("txt") => "text/plain; charset=utf-8",
310 Some("xml") => "application/xml",
311 Some("webmanifest") => "application/manifest+json",
312 Some("svg") => "image/svg+xml",
313 Some("png") => "image/png",
314 Some("jpg") | Some("jpeg") => "image/jpeg",
315 Some("gif") => "image/gif",
316 Some("webp") => "image/webp",
317 Some("avif") => "image/avif",
318 Some("ico") => "image/x-icon",
319 Some("woff2") => "font/woff2",
320 Some("woff") => "font/woff",
321 Some("ttf") => "font/ttf",
322 Some("otf") => "font/otf",
323 _ => "application/octet-stream",
324 }
325}
326
327#[cfg(test)]
328mod tests {
329 use super::*;
330
331 #[test]
332 fn mime_matching_is_case_insensitive() {
333 assert_eq!(
338 mime_for("app.abc123ef.js"),
339 "text/javascript; charset=utf-8"
340 );
341 assert_eq!(
342 mime_for("APP.ABC123EF.JS"),
343 "text/javascript; charset=utf-8"
344 );
345 assert_eq!(mime_for("app.MJS"), "text/javascript; charset=utf-8");
346 assert_eq!(mime_for("style.CSS"), "text/css; charset=utf-8");
347 }
348
349 #[test]
350 fn mime_reads_the_extension_after_the_last_separator() {
351 assert_eq!(mime_for("img.v2/logo"), "application/octet-stream");
354 assert_eq!(mime_for("img.v2/logo.png"), "image/png");
355 assert_eq!(mime_for("noextension"), "application/octet-stream");
356 }
357
358 #[test]
359 fn mime_covers_images_fonts_and_maps() {
360 assert_eq!(mime_for("logo.a1b2c3d4.png"), "image/png");
361 assert_eq!(mime_for("photo.jpeg"), "image/jpeg");
362 assert_eq!(mime_for("icon.ico"), "image/x-icon");
363 assert_eq!(mime_for("inter.woff2"), "font/woff2");
364 assert_eq!(mime_for("legacy.woff"), "font/woff");
365 assert_eq!(mime_for("app.js.map"), "application/json");
366 }
367
368 #[test]
369 fn mime_wasm_is_pinned() {
370 assert_eq!(mime_for("forma.deadbeef.wasm"), "application/wasm");
372 }
373
374 #[test]
375 fn mime_default_stays_fail_closed() {
376 assert_eq!(mime_for("page.abc12345.ir"), "application/octet-stream");
379 assert_eq!(mime_for("archive.zip"), "application/octet-stream");
380 assert_eq!(mime_for("thing.unknownext"), "application/octet-stream");
381 }
382
383 #[test]
384 fn content_hash_detection_gates_immutable_caching() {
385 assert!(is_content_hashed("app.a1b2c3d4.js"));
386 assert!(is_content_hashed("page.00000000.ir"));
387 assert!(!is_content_hashed("manifest.json"));
390 assert!(!is_content_hashed("sw.js"));
391 assert!(!is_content_hashed("index.html"));
392 assert!(!is_content_hashed("Inter Variable.woff2"));
393 assert!(!is_content_hashed("app.a1b2c3.js"));
395 assert!(!is_content_hashed("app.a1b2c3d4e5.js"));
396 assert!(!is_content_hashed("app.zzzzzzzz.js"));
397 }
398
399 #[test]
400 fn denied_names_are_never_served() {
401 assert!(is_denied("manifest.json"));
405 assert!(is_denied("app.a1b2c3d4.js.br"));
406 assert!(is_denied("app.a1b2c3d4.js.gz"));
407 assert!(!is_denied("app.a1b2c3d4.js"));
408 }
409
410 fn pick(header: &str, has_br: bool, has_gz: bool) -> Option<&'static str> {
415 let lowered = header.to_ascii_lowercase();
416 let accepted = parse_accept_encoding(&lowered);
417 let q_of = |name: &str| {
418 accepted
419 .iter()
420 .find(|e| e.token == name)
421 .map(|e| e.q)
422 .unwrap_or(0.0)
423 };
424 let mut candidates = [("br", q_of("br")), ("gzip", q_of("gzip"))];
425 candidates.sort_by(|a, b| b.1.total_cmp(&a.1));
426 for (encoding, q) in candidates {
427 if q <= 0.0 {
428 continue;
429 }
430 if (encoding == "br" && has_br) || (encoding == "gzip" && has_gz) {
431 return Some(encoding);
432 }
433 }
434 None
435 }
436
437 #[test]
438 fn qvalue_zero_is_a_rejection_not_an_acceptance() {
439 assert_eq!(pick("gzip, deflate, br;q=0", true, true), Some("gzip"));
443 assert_eq!(pick("gzip;q=0, br", true, true), Some("br"));
444 assert_eq!(pick("gzip;q=0, br;q=0", true, true), None);
445 assert_eq!(pick("br; q=0, gzip", true, true), Some("gzip"));
447 assert_eq!(pick("br;Q=0, gzip", true, true), Some("gzip"));
448 }
449
450 #[test]
451 fn higher_qvalue_wins() {
452 assert_eq!(pick("br;q=0.1, gzip;q=0.9", true, true), Some("gzip"));
453 assert_eq!(pick("br;q=0.9, gzip;q=0.1", true, true), Some("br"));
454 assert_eq!(pick("br, gzip", true, true), Some("br"));
456 }
457
458 #[test]
459 fn tokens_match_exactly_not_by_substring() {
460 assert_eq!(pick("x-brotli-nope", true, true), None);
462 assert_eq!(pick("gzipped-thing", true, true), None);
463 }
464
465 #[test]
466 fn falls_through_to_the_next_candidate_when_a_sibling_is_missing() {
467 assert_eq!(pick("gzip, deflate, br", false, true), Some("gzip"));
470 assert_eq!(pick("gzip, deflate, br", true, false), Some("br"));
471 assert_eq!(pick("gzip, deflate, br", false, false), None);
472 }
473
474 #[test]
475 fn wildcard_identity_and_absent_headers_serve_raw() {
476 assert_eq!(pick("*", true, true), None);
478 assert_eq!(pick("identity", true, true), None);
479 assert_eq!(pick("", true, true), None);
480 }
481
482 #[derive(Embed)]
485 #[folder = "test_fixtures/"]
486 struct Fixtures;
487
488 async fn get(name: &str, accept: Option<&str>) -> Response {
490 let mut headers = HeaderMap::new();
491 if let Some(accept) = accept {
492 headers.insert(header::ACCEPT_ENCODING, accept.parse().unwrap());
493 }
494 serve_asset::<Fixtures>(Path(name.to_string()), headers).await
495 }
496
497 fn header_of(response: &Response, name: &str) -> Option<String> {
498 response
499 .headers()
500 .get(name)
501 .map(|v| v.to_str().unwrap().to_string())
502 }
503
504 #[tokio::test]
505 async fn serves_a_hashed_asset_immutable_with_an_etag() {
506 let response = get("app.a1b2c3d4.js", None).await;
507 assert_eq!(response.status(), StatusCode::OK);
508 assert_eq!(
509 header_of(&response, "content-type").as_deref(),
510 Some("text/javascript; charset=utf-8")
511 );
512 assert_eq!(
513 header_of(&response, "cache-control").as_deref(),
514 Some("public, max-age=31536000, immutable")
515 );
516 assert!(header_of(&response, "etag").is_some());
517 assert_eq!(
518 header_of(&response, "x-content-type-options").as_deref(),
519 Some("nosniff")
520 );
521 }
522
523 #[tokio::test]
524 async fn un_hashed_assets_are_revalidated_not_pinned_for_a_year() {
525 let response = get("style.css", None).await;
528 assert_eq!(
529 header_of(&response, "cache-control").as_deref(),
530 Some("public, max-age=0, must-revalidate")
531 );
532 assert!(header_of(&response, "etag").is_some());
533 }
534
535 #[tokio::test]
536 async fn conditional_request_gets_304_without_a_body() {
537 let first = get("style.css", None).await;
538 let etag = header_of(&first, "etag").unwrap();
539
540 let mut headers = HeaderMap::new();
541 headers.insert(header::IF_NONE_MATCH, etag.parse().unwrap());
542 let second = serve_asset::<Fixtures>(Path("style.css".to_string()), headers).await;
543 assert_eq!(second.status(), StatusCode::NOT_MODIFIED);
544 }
545
546 #[tokio::test]
547 async fn negotiation_honours_qvalue_zero_end_to_end() {
548 let response = get("style.css", Some("gzip, deflate, br")).await;
550 assert_eq!(
551 header_of(&response, "content-encoding").as_deref(),
552 Some("br")
553 );
554
555 let response = get("style.css", Some("gzip, deflate, br;q=0")).await;
557 assert_eq!(header_of(&response, "content-encoding"), None);
558 }
559
560 #[tokio::test]
561 async fn falls_back_to_gzip_when_only_a_gz_sibling_exists() {
562 let response = get("app.a1b2c3d4.js", Some("gzip, deflate, br")).await;
564 assert_eq!(
565 header_of(&response, "content-encoding").as_deref(),
566 Some("gzip")
567 );
568 }
569
570 #[tokio::test]
571 async fn manifest_and_compressed_siblings_are_not_directly_servable() {
572 assert_eq!(
575 get("manifest.json", None).await.status(),
576 StatusCode::NOT_FOUND
577 );
578 assert_eq!(
579 get("app.a1b2c3d4.js.gz", None).await.status(),
580 StatusCode::NOT_FOUND
581 );
582 assert_eq!(
583 get("style.css.br", None).await.status(),
584 StatusCode::NOT_FOUND
585 );
586 }
587
588 #[tokio::test]
589 async fn filename_aliases_404_in_debug_just_like_release() {
590 for alias in [
594 "APP.A1B2C3D4.JS",
595 "app.a1b2c3d4.js::$DATA",
596 "app.a1b2c3d4.js.",
597 "app.a1b2c3d4.js ",
598 "./app.a1b2c3d4.js",
599 "../Cargo.toml",
600 ] {
601 assert_eq!(
602 get(alias, None).await.status(),
603 StatusCode::NOT_FOUND,
604 "alias {alias:?} must not resolve"
605 );
606 }
607 }
608
609 #[tokio::test]
610 async fn missing_asset_is_404() {
611 assert_eq!(get("nope.js", None).await.status(), StatusCode::NOT_FOUND);
612 }
613}