assets.rs
| 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 | use std::borrow::Cow; |
| 5 | #[cfg(not(feature = "embedded"))] |
| 6 | use std::path::PathBuf; |
| 7 | |
| 8 | #[cfg(feature = "embedded")] |
| 9 | mod 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). |
| 19 | pub(crate) type Asset = (Cow<'static, [u8]>, String, String, Option<String>); |
| 20 | |
| 21 | /// Look up an asset by (slash-separated) path. |
| 22 | pub(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")] |
| 37 | fn 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"))] |
| 53 | fn 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"))] |
| 65 | fn 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"))] |
| 73 | fn 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")))] |
| 87 | mod 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 |