fad06fd
//! 把前端构建产物(dist/)嵌入本库。
//!
//! `build.rs` 会调用 bun(无则 npm)执行前端构建,随后将 dist/ 下所有文件以
//! `include_bytes!` 生成到 `$OUT_DIR/dist_embed.rs`,由本文件引入后对外提供:
//!
//! - [`index()`]:`index.html`(SPA 入口)
//! - [`file(path)`]:按请求路径取任意静态资源,如 `/assets/index-xxx.js`
//! - [`exists(path)`] / [`paths()`]:资源存在性查询与清单
mod dist_embed {
include!(concat!(env!("OUT_DIR"), "/dist_embed.rs"));
}
/// 一个嵌入在库中的 dist 静态资源。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Asset {
/// 相对 dist/ 的路径(不含前导斜杠),如 `assets/index-xxx.js`。
pub path: &'static str,
/// 按扩展名推断的 MIME type。
pub mime: &'static str,
/// 文件内容。
pub content: &'static [u8],
}
/// SPA 入口 `index.html`。
pub fn index() -> Asset {
Asset {
path: "index.html",
mime: "text/html; charset=utf-8",
content: dist_embed::INDEX,
}
}
/// 按请求路径查找嵌入的资源。
///
/// 接受 `/assets/x.js`、`assets/x.js`、带 `?query`/`#hash` 的路径;
/// 根路径与 `index.html` 归一到 [`index()`];含 `..` 的越界路径返回 `None`。
pub fn file(path: &str) -> Option<Asset> {
let key = normalize(path)?;
if key.is_empty() || key == "index.html" {
return Some(index());
}
dist_embed::FILES
.iter()
.find(|(p, _)| *p == key.as_str())
.map(|(p, content)| Asset {
path: p,
mime: mime_of(p),
content,
})
}
pub fn exists(path: &str) -> bool {
match normalize(path) {
None => false,
Some(key) => {
key.is_empty()
|| key == "index.html"
|| dist_embed::FILES.iter().any(|(p, _)| *p == key.as_str())
}
}
}
pub fn paths() -> &'static [&'static str] {
dist_embed::PATHS
}
fn normalize(path: &str) -> Option<String> {
let path = path.split(['?', '#']).next().unwrap_or(path);
let mut segs: Vec<&str> = Vec::new();
for seg in path.split('/') {
match seg {
"" | "." => {}
".." => return None,
s => segs.push(s),
}
}
Some(segs.join("/"))
}
fn mime_of(path: &str) -> &'static str {
let ext = path
.rsplit_once('.')
.map_or("", |(_, e)| e)
.to_ascii_lowercase();
match ext.as_str() {
"html" | "htm" => "text/html; charset=utf-8",
"js" | "mjs" => "text/javascript; charset=utf-8",
"css" => "text/css; charset=utf-8",
"json" | "map" => "application/json",
"webmanifest" => "application/manifest+json",
"svg" => "image/svg+xml",
"png" => "image/png",
"jpg" | "jpeg" => "image/jpeg",
"gif" => "image/gif",
"webp" => "image/webp",
"avif" => "image/avif",
"ico" => "image/x-icon",
"wasm" => "application/wasm",
"ttf" => "font/ttf",
"otf" => "font/otf",
"woff" => "font/woff",
"woff2" => "font/woff2",
"eot" => "application/vnd.ms-fontobject",
"txt" => "text/plain; charset=utf-8",
"xml" => "application/xml",
"mp4" => "video/mp4",
"webm" => "video/webm",
"pdf" => "application/pdf",
_ => "application/octet-stream",
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn index_is_html() {
let a = index();
assert_eq!(a.mime, "text/html; charset=utf-8");
assert!(
a.content
.to_ascii_lowercase()
.windows(5)
.any(|w| w == b"<html")
);
}
#[test]
fn lookup_with_and_without_leading_slash() {
let js = paths()
.iter()
.find(|p| p.ends_with(".js"))
.expect("js in dist");
let a = file(&format!("/{js}")).unwrap();
assert_eq!(a.path, *js);
assert_eq!(a.mime, "text/javascript; charset=utf-8");
assert_eq!(file(js).unwrap().content, a.content);
let css = paths()
.iter()
.find(|p| p.ends_with(".css"))
.expect("css in dist");
assert_eq!(file(css).unwrap().mime, "text/css; charset=utf-8");
}
#[test]
fn wasm_mime() {
if let Some(w) = paths().iter().find(|p| p.ends_with(".wasm")) {
assert_eq!(file(w).unwrap().mime, "application/wasm");
}
}
#[test]
fn root_and_index_normalize() {
assert_eq!(file("/").unwrap().path, "index.html");
assert_eq!(file("").unwrap().path, "index.html");
assert_eq!(file("/?q=1").unwrap().path, "index.html");
assert_eq!(file("index.html").unwrap().path, "index.html");
}
#[test]
fn traversal_rejected_and_missing_none() {
assert!(file("/../../etc/passwd").is_none());
assert!(file("assets/../../x").is_none());
assert!(file("no-such-file.xyz").is_none());
assert!(!exists("../x"));
assert!(exists("/"));
}
}