assets.rs
⎇
Raw
1//! Static frontend assets: embedded at compile time (`--features embedded`)
2//! or read from disk in the dev flow (Trunk's output dir or $FBNG_DIST).
3
4#[cfg(not(feature = "embedded"))]
5use std::path::PathBuf;
6
7#[cfg(feature = "embedded")]
8mod embedded {
9 use rust_embed::RustEmbed;
10
11 #[derive(RustEmbed)]
12 #[folder = "dist/"]
13 pub struct Assets;
14}
15
16/// Look up an asset by (slash-separated) path.
17/// Returns (bytes, content-type, cache-control).
18pub(crate) fn get_asset(path: &str) -> Option<(Vec<u8>, String, String)> {
19 let (bytes, from_disk) = read(path)?;
20 let mime = mime_guess::from_path(path)
21 .first_or_octet_stream()
22 .to_string();
23 let cache = if from_disk || path == "index.html" {
24 "no-cache".to_string()
25 } else {
26 // Trunk hashes asset file names, so they are safe to cache forever.
27 "public, max-age=31536000, immutable".to_string()
28 };
29 Some((bytes, mime, cache))
30}
31
32#[cfg(feature = "embedded")]
33fn read(path: &str) -> Option<(Vec<u8>, bool)> {
34 embedded::Assets::get(path).map(|c| (c.data.to_vec(), false))
35}
36
37#[cfg(not(feature = "embedded"))]
38fn dev_dist_dir() -> PathBuf {
39 std::env::var_os("FBNG_DIST")
40 .map(PathBuf::from)
41 .unwrap_or_else(|| PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../web/dist"))
42}
43
44/// True if the requested asset path may be joined onto the dist directory.
45///
46/// `path` comes straight from the request URI and hyper does not normalize
47/// `..`, so only plain relative paths are allowed. Anything else (a `..`
48/// segment, a leading `/`, a Windows prefix) could read outside the dist dir.
49#[cfg(not(feature = "embedded"))]
50fn is_safe_asset_path(path: &str) -> bool {
51 !path.is_empty()
52 && std::path::Path::new(path)
53 .components()
54 .all(|c| matches!(c, std::path::Component::Normal(_)))
55}
56
57#[cfg(not(feature = "embedded"))]
58fn read(path: &str) -> Option<(Vec<u8>, bool)> {
59 if !is_safe_asset_path(path) {
60 return None;
61 }
62 let p = dev_dist_dir().join(path);
63 if p.is_file() {
64 std::fs::read(&p).ok().map(|b| (b, true))
65 } else {
66 None
67 }
68}
69
70#[cfg(all(test, not(feature = "embedded")))]
71mod tests {
72 use super::is_safe_asset_path;
73
74 #[test]
75 fn asset_paths_outside_dist_are_rejected() {
76 assert!(is_safe_asset_path("index.html"));
77 assert!(is_safe_asset_path("assets/app-abc123.js"));
78 // `components()` drops interior "." segments, so this stays inside.
79 assert!(is_safe_asset_path("assets/./app.js"));
80 for bad in [
81 "",
82 "..",
83 "../secret",
84 "assets/../../secret",
85 "/etc/passwd",
86 "./index.html",
87 ] {
88 assert!(!is_safe_asset_path(bad), "{bad:?} must be rejected");
89 }
90 }
91}
92