//! Static frontend assets: embedded at compile time (`--features embedded`) //! or read from disk in the dev flow (Trunk's output dir or $FBNG_DIST). use std::borrow::Cow; #[cfg(not(feature = "embedded"))] use std::path::PathBuf; #[cfg(feature = "embedded")] mod embedded { use rust_embed::RustEmbed; #[derive(RustEmbed)] #[folder = "dist/"] pub struct Assets; } /// An asset: bytes, content-type, cache-control, and an ETag when the bytes /// are embedded (the dev flow serves from disk and has none). pub(crate) type Asset = (Cow<'static, [u8]>, String, String, Option); /// Look up an asset by (slash-separated) path. pub(crate) fn get_asset(path: &str) -> Option { let (bytes, from_disk, etag) = read(path)?; let mime = mime_guess::from_path(path) .first_or_octet_stream() .to_string(); let cache = if from_disk || path == "index.html" { "no-cache".to_string() } else { // Trunk hashes asset file names, so they are safe to cache forever. "public, max-age=31536000, immutable".to_string() }; Some((bytes, mime, cache, etag)) } #[cfg(feature = "embedded")] fn read(path: &str) -> Option<(Cow<'static, [u8]>, bool, Option)> { embedded::Assets::get(path).map(|c| { // The embedded hash is the file's content hash, so it doubles as a // strong ETag. let mut etag = String::with_capacity(2 + 32); etag.push('"'); for b in c.metadata.sha256_hash().iter().take(16) { use std::fmt::Write as _; let _ = write!(etag, "{b:02x}"); } etag.push('"'); (c.data, false, Some(etag)) }) } #[cfg(not(feature = "embedded"))] fn dev_dist_dir() -> PathBuf { std::env::var_os("FBNG_DIST") .map(PathBuf::from) .unwrap_or_else(|| PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../web/dist")) } /// True if the requested asset path may be joined onto the dist directory. /// /// `path` comes straight from the request URI and hyper does not normalize /// `..`, so only plain relative paths are allowed. Anything else (a `..` /// segment, a leading `/`, a Windows prefix) could read outside the dist dir. #[cfg(not(feature = "embedded"))] fn is_safe_asset_path(path: &str) -> bool { !path.is_empty() && std::path::Path::new(path) .components() .all(|c| matches!(c, std::path::Component::Normal(_))) } #[cfg(not(feature = "embedded"))] fn read(path: &str) -> Option<(Cow<'static, [u8]>, bool, Option)> { if !is_safe_asset_path(path) { return None; } let p = dev_dist_dir().join(path); if p.is_file() { // No ETag from disk: the dev flow wants every reload to be fresh. std::fs::read(&p).ok().map(|b| (Cow::Owned(b), true, None)) } else { None } } #[cfg(all(test, not(feature = "embedded")))] mod tests { use super::is_safe_asset_path; #[test] fn asset_paths_outside_dist_are_rejected() { assert!(is_safe_asset_path("index.html")); assert!(is_safe_asset_path("assets/app-abc123.js")); // `components()` drops interior "." segments, so this stays inside. assert!(is_safe_asset_path("assets/./app.js")); for bad in [ "", "..", "../secret", "assets/../../secret", "/etc/passwd", "./index.html", ] { assert!(!is_safe_asset_path(bad), "{bad:?} must be rejected"); } } }