use axum::{
body::{Body, Bytes},
extract::Path,
http::{header, HeaderMap, StatusCode},
response::{IntoResponse, Response},
};
use rust_embed::Embed;
pub fn asset<A: Embed>(name: &str) -> String {
try_asset::<A>(name).unwrap_or_else(|e| panic!("{e}"))
}
pub fn try_asset<A: Embed>(name: &str) -> Result<String, String> {
let file = A::get(name).ok_or_else(|| format!("Missing asset: {name}"))?;
std::str::from_utf8(&file.data)
.map(|s| s.to_owned())
.map_err(|_| format!("Non-UTF8 asset: {name}"))
}
fn bytes_of(file: rust_embed::EmbeddedFile) -> Bytes {
match file.data {
std::borrow::Cow::Borrowed(b) => Bytes::from_static(b),
std::borrow::Cow::Owned(v) => Bytes::from(v),
}
}
fn body_of(file: rust_embed::EmbeddedFile) -> Body {
Body::from(bytes_of(file))
}
pub fn asset_bytes<A: Embed>(name: &str) -> Option<Bytes> {
A::get(name).map(bytes_of)
}
pub fn load_manifest<A: Embed>() -> crate::AssetManifest {
try_load_manifest::<A>().unwrap_or_else(|e| panic!("{e}"))
}
pub fn try_load_manifest<A: Embed>() -> Result<crate::AssetManifest, String> {
let data = try_asset::<A>("manifest.json")?;
serde_json::from_str(&data).map_err(|e| format!("Failed to parse manifest.json: {e}"))
}
fn is_denied(filename: &str) -> bool {
filename == "manifest.json" || filename.ends_with(".br") || filename.ends_with(".gz")
}
struct Encoding<'a> {
token: &'a str,
q: f32,
}
fn parse_accept_encoding(header: &str) -> Vec<Encoding<'_>> {
header
.split(',')
.filter_map(|element| {
let mut parts = element.split(';').map(str::trim);
let token = parts.next()?;
if token.is_empty() {
return None;
}
let mut q = 1.0_f32;
for param in parts {
let Some((name, value)) = param.split_once('=') else {
continue;
};
if name.trim() == "q" {
q = value.trim().parse().unwrap_or(1.0);
}
}
Some(Encoding {
token,
q: q.clamp(0.0, 1.0),
})
})
.collect()
}
pub async fn serve_asset<A: Embed + 'static>(
Path(filename): Path<String>,
headers: HeaderMap,
) -> Response {
if is_denied(&filename) {
return StatusCode::NOT_FOUND.into_response();
}
if !is_embedded::<A>(&filename) {
return StatusCode::NOT_FOUND.into_response();
}
let accept = headers
.get(header::ACCEPT_ENCODING)
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_ascii_lowercase();
let accepted = parse_accept_encoding(&accept);
let q_of = |name: &str| {
accepted
.iter()
.find(|e| e.token == name)
.map(|e| e.q)
.unwrap_or(0.0)
};
let mut candidates = [("br", q_of("br")), ("gzip", q_of("gzip"))];
candidates.sort_by(|a, b| b.1.total_cmp(&a.1));
let if_none_match = headers
.get(header::IF_NONE_MATCH)
.and_then(|v| v.to_str().ok());
for (encoding, q) in candidates {
if q <= 0.0 {
continue;
}
let suffix = if encoding == "br" { "br" } else { "gz" };
if let Some(file) = A::get(&format!("{filename}.{suffix}")) {
return asset_response(&filename, file, Some(encoding), if_none_match);
}
}
match A::get(&filename) {
Some(file) => asset_response(&filename, file, None, if_none_match),
None => StatusCode::NOT_FOUND.into_response(),
}
}
fn is_embedded<A: Embed + 'static>(name: &str) -> bool {
#[cfg(not(debug_assertions))]
{
match cached_names::<A>() {
Some(names) => names.contains(name),
None => A::iter().any(|f| f == name),
}
}
#[cfg(debug_assertions)]
{
A::iter().any(|f| f == name)
}
}
#[cfg(not(debug_assertions))]
type NameSet = std::collections::HashSet<std::borrow::Cow<'static, str>>;
#[cfg(not(debug_assertions))]
const NAME_CACHE_SLOTS: usize = 4;
#[cfg(not(debug_assertions))]
static NAME_CACHE: [std::sync::OnceLock<(std::any::TypeId, NameSet)>; NAME_CACHE_SLOTS] =
[const { std::sync::OnceLock::new() }; NAME_CACHE_SLOTS];
#[cfg(not(debug_assertions))]
fn cached_names<A: Embed + 'static>() -> Option<&'static NameSet> {
let key = std::any::TypeId::of::<A>();
for slot in &NAME_CACHE {
let (owner, names) = slot.get_or_init(|| (key, A::iter().collect()));
if *owner == key {
return Some(names);
}
}
None
}
fn is_content_hashed(filename: &str) -> bool {
let name = filename.rsplit('/').next().unwrap_or(filename);
name.split('.').skip(1).any(|segment| {
segment.len() == 8
&& segment
.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
})
}
fn asset_response(
filename: &str,
file: rust_embed::EmbeddedFile,
encoding: Option<&str>,
if_none_match: Option<&str>,
) -> Response {
let mime = mime_for(filename);
let etag = format!(
"\"{}{}\"",
hex8(&file.metadata.sha256_hash()),
encoding.unwrap_or("")
);
let cache_control = if is_content_hashed(filename) {
"public, max-age=31536000, immutable"
} else {
"public, max-age=0, must-revalidate"
};
if if_none_match.map(|v| v.split(',').any(|c| c.trim() == etag)) == Some(true) {
return Response::builder()
.status(StatusCode::NOT_MODIFIED)
.header(header::CACHE_CONTROL, cache_control)
.header(header::ETAG, &etag)
.header("vary", "accept-encoding")
.body(Body::empty())
.unwrap()
.into_response();
}
let mut builder = Response::builder()
.header(header::CONTENT_TYPE, mime)
.header(header::CACHE_CONTROL, cache_control)
.header(header::ETAG, &etag)
.header("vary", "accept-encoding")
.header(
header::CONTENT_SECURITY_POLICY,
"default-src 'none'; sandbox",
);
for (name, value) in crate::types::asset_security_headers() {
builder = builder.header(name, value);
}
if let Some(enc) = encoding {
builder = builder.header(header::CONTENT_ENCODING, enc);
}
builder.body(body_of(file)).unwrap().into_response()
}
fn hex8(hash: &[u8; 32]) -> String {
hash[..8].iter().map(|b| format!("{b:02x}")).collect()
}
fn mime_for(filename: &str) -> &'static str {
let base = filename.rsplit('/').next().unwrap_or(filename);
let ext = base.rsplit_once('.').map(|(_, e)| e.to_ascii_lowercase());
match ext.as_deref() {
Some("js") | Some("mjs") => "text/javascript; charset=utf-8",
Some("css") => "text/css; charset=utf-8",
Some("wasm") => "application/wasm",
Some("json") => "application/json",
Some("map") => "application/json",
Some("html") => "text/html; charset=utf-8",
Some("txt") => "text/plain; charset=utf-8",
Some("xml") => "application/xml",
Some("webmanifest") => "application/manifest+json",
Some("svg") => "image/svg+xml",
Some("png") => "image/png",
Some("jpg") | Some("jpeg") => "image/jpeg",
Some("gif") => "image/gif",
Some("webp") => "image/webp",
Some("avif") => "image/avif",
Some("ico") => "image/x-icon",
Some("woff2") => "font/woff2",
Some("woff") => "font/woff",
Some("ttf") => "font/ttf",
Some("otf") => "font/otf",
_ => "application/octet-stream",
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mime_matching_is_case_insensitive() {
assert_eq!(
mime_for("app.abc123ef.js"),
"text/javascript; charset=utf-8"
);
assert_eq!(
mime_for("APP.ABC123EF.JS"),
"text/javascript; charset=utf-8"
);
assert_eq!(mime_for("app.MJS"), "text/javascript; charset=utf-8");
assert_eq!(mime_for("style.CSS"), "text/css; charset=utf-8");
}
#[test]
fn mime_reads_the_extension_after_the_last_separator() {
assert_eq!(mime_for("img.v2/logo"), "application/octet-stream");
assert_eq!(mime_for("img.v2/logo.png"), "image/png");
assert_eq!(mime_for("noextension"), "application/octet-stream");
}
#[test]
fn mime_covers_images_fonts_and_maps() {
assert_eq!(mime_for("logo.a1b2c3d4.png"), "image/png");
assert_eq!(mime_for("photo.jpeg"), "image/jpeg");
assert_eq!(mime_for("icon.ico"), "image/x-icon");
assert_eq!(mime_for("inter.woff2"), "font/woff2");
assert_eq!(mime_for("legacy.woff"), "font/woff");
assert_eq!(mime_for("app.js.map"), "application/json");
}
#[test]
fn mime_wasm_is_pinned() {
assert_eq!(mime_for("forma.deadbeef.wasm"), "application/wasm");
}
#[test]
fn mime_default_stays_fail_closed() {
assert_eq!(mime_for("page.abc12345.ir"), "application/octet-stream");
assert_eq!(mime_for("archive.zip"), "application/octet-stream");
assert_eq!(mime_for("thing.unknownext"), "application/octet-stream");
}
#[test]
fn content_hash_detection_gates_immutable_caching() {
assert!(is_content_hashed("app.a1b2c3d4.js"));
assert!(is_content_hashed("page.00000000.ir"));
assert!(!is_content_hashed("manifest.json"));
assert!(!is_content_hashed("sw.js"));
assert!(!is_content_hashed("index.html"));
assert!(!is_content_hashed("Inter Variable.woff2"));
assert!(!is_content_hashed("app.a1b2c3.js"));
assert!(!is_content_hashed("app.a1b2c3d4e5.js"));
assert!(!is_content_hashed("app.zzzzzzzz.js"));
}
#[test]
fn denied_names_are_never_served() {
assert!(is_denied("manifest.json"));
assert!(is_denied("app.a1b2c3d4.js.br"));
assert!(is_denied("app.a1b2c3d4.js.gz"));
assert!(!is_denied("app.a1b2c3d4.js"));
}
fn pick(header: &str, has_br: bool, has_gz: bool) -> Option<&'static str> {
let lowered = header.to_ascii_lowercase();
let accepted = parse_accept_encoding(&lowered);
let q_of = |name: &str| {
accepted
.iter()
.find(|e| e.token == name)
.map(|e| e.q)
.unwrap_or(0.0)
};
let mut candidates = [("br", q_of("br")), ("gzip", q_of("gzip"))];
candidates.sort_by(|a, b| b.1.total_cmp(&a.1));
for (encoding, q) in candidates {
if q <= 0.0 {
continue;
}
if (encoding == "br" && has_br) || (encoding == "gzip" && has_gz) {
return Some(encoding);
}
}
None
}
#[test]
fn qvalue_zero_is_a_rejection_not_an_acceptance() {
assert_eq!(pick("gzip, deflate, br;q=0", true, true), Some("gzip"));
assert_eq!(pick("gzip;q=0, br", true, true), Some("br"));
assert_eq!(pick("gzip;q=0, br;q=0", true, true), None);
assert_eq!(pick("br; q=0, gzip", true, true), Some("gzip"));
assert_eq!(pick("br;Q=0, gzip", true, true), Some("gzip"));
}
#[test]
fn higher_qvalue_wins() {
assert_eq!(pick("br;q=0.1, gzip;q=0.9", true, true), Some("gzip"));
assert_eq!(pick("br;q=0.9, gzip;q=0.1", true, true), Some("br"));
assert_eq!(pick("br, gzip", true, true), Some("br"));
}
#[test]
fn tokens_match_exactly_not_by_substring() {
assert_eq!(pick("x-brotli-nope", true, true), None);
assert_eq!(pick("gzipped-thing", true, true), None);
}
#[test]
fn falls_through_to_the_next_candidate_when_a_sibling_is_missing() {
assert_eq!(pick("gzip, deflate, br", false, true), Some("gzip"));
assert_eq!(pick("gzip, deflate, br", true, false), Some("br"));
assert_eq!(pick("gzip, deflate, br", false, false), None);
}
#[test]
fn wildcard_identity_and_absent_headers_serve_raw() {
assert_eq!(pick("*", true, true), None);
assert_eq!(pick("identity", true, true), None);
assert_eq!(pick("", true, true), None);
}
#[derive(Embed)]
#[folder = "test_fixtures/"]
struct Fixtures;
async fn get(name: &str, accept: Option<&str>) -> Response {
let mut headers = HeaderMap::new();
if let Some(accept) = accept {
headers.insert(header::ACCEPT_ENCODING, accept.parse().unwrap());
}
serve_asset::<Fixtures>(Path(name.to_string()), headers).await
}
fn header_of(response: &Response, name: &str) -> Option<String> {
response
.headers()
.get(name)
.map(|v| v.to_str().unwrap().to_string())
}
#[tokio::test]
async fn serves_a_hashed_asset_immutable_with_an_etag() {
let response = get("app.a1b2c3d4.js", None).await;
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
header_of(&response, "content-type").as_deref(),
Some("text/javascript; charset=utf-8")
);
assert_eq!(
header_of(&response, "cache-control").as_deref(),
Some("public, max-age=31536000, immutable")
);
assert!(header_of(&response, "etag").is_some());
assert_eq!(
header_of(&response, "x-content-type-options").as_deref(),
Some("nosniff")
);
}
#[tokio::test]
async fn un_hashed_assets_are_revalidated_not_pinned_for_a_year() {
let response = get("style.css", None).await;
assert_eq!(
header_of(&response, "cache-control").as_deref(),
Some("public, max-age=0, must-revalidate")
);
assert!(header_of(&response, "etag").is_some());
}
#[tokio::test]
async fn conditional_request_gets_304_without_a_body() {
let first = get("style.css", None).await;
let etag = header_of(&first, "etag").unwrap();
let mut headers = HeaderMap::new();
headers.insert(header::IF_NONE_MATCH, etag.parse().unwrap());
let second = serve_asset::<Fixtures>(Path("style.css".to_string()), headers).await;
assert_eq!(second.status(), StatusCode::NOT_MODIFIED);
}
#[tokio::test]
async fn negotiation_honours_qvalue_zero_end_to_end() {
let response = get("style.css", Some("gzip, deflate, br")).await;
assert_eq!(
header_of(&response, "content-encoding").as_deref(),
Some("br")
);
let response = get("style.css", Some("gzip, deflate, br;q=0")).await;
assert_eq!(header_of(&response, "content-encoding"), None);
}
#[tokio::test]
async fn falls_back_to_gzip_when_only_a_gz_sibling_exists() {
let response = get("app.a1b2c3d4.js", Some("gzip, deflate, br")).await;
assert_eq!(
header_of(&response, "content-encoding").as_deref(),
Some("gzip")
);
}
#[tokio::test]
async fn manifest_and_compressed_siblings_are_not_directly_servable() {
assert_eq!(
get("manifest.json", None).await.status(),
StatusCode::NOT_FOUND
);
assert_eq!(
get("app.a1b2c3d4.js.gz", None).await.status(),
StatusCode::NOT_FOUND
);
assert_eq!(
get("style.css.br", None).await.status(),
StatusCode::NOT_FOUND
);
}
#[tokio::test]
async fn filename_aliases_404_in_debug_just_like_release() {
for alias in [
"APP.A1B2C3D4.JS",
"app.a1b2c3d4.js::$DATA",
"app.a1b2c3d4.js.",
"app.a1b2c3d4.js ",
"./app.a1b2c3d4.js",
"../Cargo.toml",
] {
assert_eq!(
get(alias, None).await.status(),
StatusCode::NOT_FOUND,
"alias {alias:?} must not resolve"
);
}
}
#[tokio::test]
async fn missing_asset_is_404() {
assert_eq!(get("nope.js", None).await.status(), StatusCode::NOT_FOUND);
}
#[derive(Embed)]
#[folder = "tests/fixtures/asset-sets/a/"]
struct SetA;
#[derive(Embed)]
#[folder = "tests/fixtures/asset-sets/b/"]
struct SetB;
#[derive(Embed)]
#[folder = "tests/fixtures/asset-sets/c/"]
struct SetC;
#[derive(Embed)]
#[folder = "tests/fixtures/asset-sets/d/"]
struct SetD;
#[derive(Embed)]
#[folder = "tests/fixtures/asset-sets/e/"]
struct SetE;
async fn fetch<A: Embed + 'static>(name: &str) -> (StatusCode, Vec<u8>) {
let response = serve_asset::<A>(Path(name.to_string()), HeaderMap::new()).await;
let status = response.status();
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.expect("an asset body is in memory and cannot fail to collect");
(status, body.to_vec())
}
async fn assert_serves_its_own<A: Embed + 'static>(
own: &str,
own_js: &[u8],
own_css: &[u8],
foreign: &str,
) {
assert_eq!(
fetch::<A>(own).await,
(StatusCode::OK, own_js.to_vec()),
"{own} from its own set"
);
assert_eq!(
fetch::<A>("shared.css").await,
(StatusCode::OK, own_css.to_vec()),
"shared.css from the set holding {own}"
);
assert_eq!(
fetch::<A>(foreign).await.0,
StatusCode::NOT_FOUND,
"{foreign} from the set holding {own}"
);
}
#[tokio::test]
async fn more_embed_types_than_cache_slots_each_serve_their_own_assets() {
#[cfg(not(debug_assertions))]
const _: () = assert!(5 > NAME_CACHE_SLOTS, "serve more types than slots");
for _ in 0..2 {
assert_serves_its_own::<SetA>(
"a.aaaaaaaa.js",
b"export default \"a\";\n",
b".a{}\n",
"b.bbbbbbbb.js",
)
.await;
assert_serves_its_own::<SetB>(
"b.bbbbbbbb.js",
b"export default \"bb\";\n",
b".bb{}\n",
"c.cccccccc.js",
)
.await;
assert_serves_its_own::<SetC>(
"c.cccccccc.js",
b"export default \"ccc\";\n",
b".ccc{}\n",
"d.dddddddd.js",
)
.await;
assert_serves_its_own::<SetD>(
"d.dddddddd.js",
b"export default \"dddd\";\n",
b".dddd{}\n",
"e.eeeeeeee.js",
)
.await;
assert_serves_its_own::<SetE>(
"e.eeeeeeee.js",
b"export default \"eeeee\";\n",
b".eeeee{}\n",
"a.aaaaaaaa.js",
)
.await;
}
}
}