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 $DOVENEST_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, etag) = read(path)?; |
| 24 | let mime = mime_guess::from_path(path) |
| 25 | .first_or_octet_stream() |
| 26 | .to_string(); |
| 27 | let cache = if etag.is_none() || 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]>, 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 etag = format!("\"{}\"", crate::hex(&c.metadata.sha256_hash()[..16])); |
| 42 | (c.data, Some(etag)) |
| 43 | }) |
| 44 | } |
| 45 | |
| 46 | #[cfg(not(feature = "embedded"))] |
| 47 | fn dev_dist_dir() -> PathBuf { |
| 48 | std::env::var_os("DOVENEST_DIST") |
| 49 | .map(PathBuf::from) |
| 50 | .unwrap_or_else(|| PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../web/dist")) |
| 51 | } |
| 52 | |
| 53 | /// True if the requested asset path may be joined onto the dist directory. |
| 54 | /// |
| 55 | /// `path` comes straight from the request URI and hyper does not normalize |
| 56 | /// `..`, so only plain relative paths are allowed. Anything else (a `..` |
| 57 | /// segment, a leading `/`, a Windows prefix) could read outside the dist dir. |
| 58 | #[cfg(not(feature = "embedded"))] |
| 59 | fn is_safe_asset_path(path: &str) -> bool { |
| 60 | !path.is_empty() |
| 61 | && std::path::Path::new(path) |
| 62 | .components() |
| 63 | .all(|c| matches!(c, std::path::Component::Normal(_))) |
| 64 | } |
| 65 | |
| 66 | #[cfg(not(feature = "embedded"))] |
| 67 | fn read(path: &str) -> Option<(Cow<'static, [u8]>, Option<String>)> { |
| 68 | if !is_safe_asset_path(path) { |
| 69 | return None; |
| 70 | } |
| 71 | let p = dev_dist_dir().join(path); |
| 72 | if p.is_file() { |
| 73 | // No ETag from disk: the dev flow wants every reload to be fresh. |
| 74 | std::fs::read(&p).ok().map(|b| (Cow::Owned(b), None)) |
| 75 | } else { |
| 76 | None |
| 77 | } |
| 78 | } |
| 79 | |
| 80 | #[cfg(all(test, not(feature = "embedded")))] |
| 81 | mod tests { |
| 82 | use super::is_safe_asset_path; |
| 83 | |
| 84 | #[test] |
| 85 | fn asset_paths_outside_dist_are_rejected() { |
| 86 | assert!(is_safe_asset_path("index.html")); |
| 87 | assert!(is_safe_asset_path("assets/app-abc123.js")); |
| 88 | // `components()` drops interior "." segments, so this stays inside. |
| 89 | assert!(is_safe_asset_path("assets/./app.js")); |
| 90 | for bad in [ |
| 91 | "", |
| 92 | "..", |
| 93 | "../secret", |
| 94 | "assets/../../secret", |
| 95 | "/etc/passwd", |
| 96 | "./index.html", |
| 97 | ] { |
| 98 | assert!(!is_safe_asset_path(bad), "{bad:?} must be rejected"); |
| 99 | } |
| 100 | } |
| 101 | } |
| 102 |