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
4use std::borrow::Cow;
5#[cfg(not(feature = "embedded"))]
6use std::path::PathBuf;
7
8#[cfg(feature = "embedded")]
9mod embedded {
10 use rust_embed::RustEmbed;
11
12 #[derive(RustEmbed)]
13 #[folder = "dist/"]
14 pub struct Assets;
15}
16
17/// An asset: bytes, content-type, cache-control, and an ETag when the bytes
18/// are embedded (the dev flow serves from disk and has none).
19pub(crate) type Asset = (Cow<'static, [u8]>, String, String, Option<String>);
20
21/// Look up an asset by (slash-separated) path.
22pub(crate) fn get_asset(path: &str) -> Option<Asset> {
23 let (bytes, from_disk, etag) = read(path)?;
24 let mime = mime_guess::from_path(path)
25 .first_or_octet_stream()
26 .to_string();
27 let cache = if from_disk || path == "index.html" {
28 "no-cache".to_string()
29 } else {
30 // Trunk hashes asset file names, so they are safe to cache forever.
31 "public, max-age=31536000, immutable".to_string()
32 };
33 Some((bytes, mime, cache, etag))
34}
35
36#[cfg(feature = "embedded")]
37fn read(path: &str) -> Option<(Cow<'static, [u8]>, bool, Option<String>)> {
38 embedded::Assets::get(path).map(|c| {
39 // The embedded hash is the file's content hash, so it doubles as a
40 // strong ETag.
41 let mut etag = String::with_capacity(2 + 32);
42 etag.push('"');
43 for b in c.metadata.sha256_hash().iter().take(16) {
44 use std::fmt::Write as _;
45 let _ = write!(etag, "{b:02x}");
46 }
47 etag.push('"');
48 (c.data, false, Some(etag))
49 })
50}
51
52#[cfg(not(feature = "embedded"))]
53fn dev_dist_dir() -> PathBuf {
54 std::env::var_os("FBNG_DIST")
55 .map(PathBuf::from)
56 .unwrap_or_else(|| PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../web/dist"))
57}
58
59/// True if the requested asset path may be joined onto the dist directory.
60///
61/// `path` comes straight from the request URI and hyper does not normalize
62/// `..`, so only plain relative paths are allowed. Anything else (a `..`
63/// segment, a leading `/`, a Windows prefix) could read outside the dist dir.
64#[cfg(not(feature = "embedded"))]
65fn is_safe_asset_path(path: &str) -> bool {
66 !path.is_empty()
67 && std::path::Path::new(path)
68 .components()
69 .all(|c| matches!(c, std::path::Component::Normal(_)))
70}
71
72#[cfg(not(feature = "embedded"))]
73fn read(path: &str) -> Option<(Cow<'static, [u8]>, bool, Option<String>)> {
74 if !is_safe_asset_path(path) {
75 return None;
76 }
77 let p = dev_dist_dir().join(path);
78 if p.is_file() {
79 // No ETag from disk: the dev flow wants every reload to be fresh.
80 std::fs::read(&p).ok().map(|b| (Cow::Owned(b), true, None))
81 } else {
82 None
83 }
84}
85
86#[cfg(all(test, not(feature = "embedded")))]
87mod tests {
88 use super::is_safe_asset_path;
89
90 #[test]
91 fn asset_paths_outside_dist_are_rejected() {
92 assert!(is_safe_asset_path("index.html"));
93 assert!(is_safe_asset_path("assets/app-abc123.js"));
94 // `components()` drops interior "." segments, so this stays inside.
95 assert!(is_safe_asset_path("assets/./app.js"));
96 for bad in [
97 "",
98 "..",
99 "../secret",
100 "assets/../../secret",
101 "/etc/passwd",
102 "./index.html",
103 ] {
104 assert!(!is_safe_asset_path(bad), "{bad:?} must be rejected");
105 }
106 }
107}
108