Skip to main content

solverforge_ui/
assets.rs

1//! Framework-neutral access to the embedded `/sf/*` asset tree.
2//!
3//! This module is available without the optional `axum` feature. Hosts using
4//! another HTTP framework can call [`get`] and translate the returned metadata
5//! into their own response type.
6
7use include_dir::{include_dir, Dir, DirEntry};
8use std::{error::Error, fmt, sync::LazyLock};
9
10static ASSETS: Dir = include_dir!("$CARGO_MANIFEST_DIR/static/sf");
11static PATHS: LazyLock<Vec<&'static str>> = LazyLock::new(|| {
12    let mut paths = Vec::new();
13    collect_paths(ASSETS.entries(), &mut paths);
14    paths.sort_unstable();
15    paths
16});
17
18/// Error returned by [`get`] when an asset path cannot be served.
19#[derive(Clone, Copy, Debug, Eq, PartialEq)]
20#[non_exhaustive]
21pub enum AssetError {
22    /// The path is not a valid `/sf`-relative asset path.
23    InvalidPath,
24    /// The path is valid but no embedded asset exists at that location.
25    NotFound,
26}
27
28impl fmt::Display for AssetError {
29    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
30        match self {
31            Self::InvalidPath => f.write_str("invalid embedded asset path"),
32            Self::NotFound => f.write_str("embedded asset not found"),
33        }
34    }
35}
36
37impl Error for AssetError {}
38
39/// An embedded SolverForge UI asset plus the HTTP metadata used by the Axum
40/// adapter.
41#[derive(Clone, Copy, Debug, Eq, PartialEq)]
42#[non_exhaustive]
43pub struct UiAsset {
44    /// Canonical path relative to `/sf`.
45    path: &'static str,
46    /// HTTP `Content-Type` value.
47    content_type: &'static str,
48    /// HTTP `Cache-Control` value.
49    cache_control: &'static str,
50    /// Embedded file contents.
51    bytes: &'static [u8],
52}
53
54impl UiAsset {
55    /// Returns the canonical path relative to `/sf`.
56    pub fn path(&self) -> &'static str {
57        self.path
58    }
59
60    /// Returns the HTTP `Content-Type` value for this asset.
61    pub fn content_type(&self) -> &'static str {
62        self.content_type
63    }
64
65    /// Returns the HTTP `Cache-Control` value for this asset.
66    pub fn cache_control(&self) -> &'static str {
67        self.cache_control
68    }
69
70    /// Returns the embedded file contents.
71    pub fn bytes(&self) -> &'static [u8] {
72        self.bytes
73    }
74}
75
76/// Looks up an embedded asset by `/sf`-relative path.
77///
78/// Returns [`AssetError::InvalidPath`] for unsafe paths, including absolute
79/// paths, empty paths, backslash paths, duplicate slashes, and `.`/`..`
80/// segments. Returns [`AssetError::NotFound`] for valid paths that are not
81/// embedded.
82pub fn get(path: &str) -> Result<UiAsset, AssetError> {
83    let path = validate_path(path)?;
84    let file = ASSETS.get_file(path).ok_or(AssetError::NotFound)?;
85    let path = file.path().to_str().ok_or(AssetError::NotFound)?;
86    Ok(UiAsset {
87        path,
88        content_type: content_type_from_path(path),
89        cache_control: cache_control_from_path(path),
90        bytes: file.contents(),
91    })
92}
93
94/// Returns all embedded `/sf`-relative asset paths in stable sorted order.
95pub fn paths() -> &'static [&'static str] {
96    PATHS.as_slice()
97}
98
99/// Returns the crate version that produced this embedded asset set.
100pub fn version() -> &'static str {
101    env!("CARGO_PKG_VERSION")
102}
103
104fn collect_paths(entries: &'static [DirEntry<'static>], paths: &mut Vec<&'static str>) {
105    for entry in entries {
106        match entry {
107            DirEntry::Dir(dir) => collect_paths(dir.entries(), paths),
108            DirEntry::File(file) => {
109                if let Some(path) = file.path().to_str() {
110                    paths.push(path);
111                }
112            }
113        }
114    }
115}
116
117fn validate_path(path: &str) -> Result<&str, AssetError> {
118    if path.is_empty()
119        || path.starts_with('/')
120        || path.starts_with('\\')
121        || path.contains('\\')
122        || path
123            .split('/')
124            .any(|part| part.is_empty() || part == "." || part == "..")
125    {
126        return Err(AssetError::InvalidPath);
127    }
128    Ok(path)
129}
130
131fn content_type_from_path(path: &str) -> &'static str {
132    match path.rsplit('.').next() {
133        Some("css") => "text/css; charset=utf-8",
134        Some("js") | Some("mjs") => "application/javascript; charset=utf-8",
135        Some("svg") => "image/svg+xml",
136        Some("woff2") => "font/woff2",
137        Some("woff") => "font/woff",
138        Some("ttf") => "font/ttf",
139        Some("eot") => "application/vnd.ms-fontobject",
140        Some("png") => "image/png",
141        Some("jpg" | "jpeg") => "image/jpeg",
142        Some("ico") => "image/x-icon",
143        Some("json") | Some("map") => "application/json",
144        Some("html") => "text/html; charset=utf-8",
145        _ => "application/octet-stream",
146    }
147}
148
149fn cache_control_from_path(path: &str) -> &'static str {
150    if is_immutable(path) {
151        "public, max-age=31536000, immutable"
152    } else {
153        "public, max-age=3600"
154    }
155}
156
157fn is_immutable(path: &str) -> bool {
158    path.starts_with("fonts/")
159        || path.starts_with("vendor/")
160        || path.starts_with("img/")
161        || is_versioned_bundle(path)
162}
163
164fn is_versioned_bundle(path: &str) -> bool {
165    path.strip_prefix("sf.")
166        .and_then(|rest| rest.rsplit_once('.'))
167        .map(|(version, ext)| {
168            !version.is_empty()
169                && version.chars().all(|ch| {
170                    ch.is_ascii_digit()
171                        || ch == '.'
172                        || ch == '-'
173                        || ch == '+'
174                        || ch.is_ascii_alphabetic()
175                })
176                && matches!(ext, "css" | "js" | "mjs")
177        })
178        .unwrap_or(false)
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184
185    #[test]
186    fn returns_assets_with_metadata() {
187        let asset = get("sf.js").expect("sf.js should be embedded");
188        assert_eq!(asset.path(), "sf.js");
189        assert_eq!(
190            asset.content_type(),
191            "application/javascript; charset=utf-8"
192        );
193        assert_eq!(asset.cache_control(), "public, max-age=3600");
194        assert!(!asset.bytes().is_empty());
195
196        let css = get("sf.css").expect("sf.css should be embedded");
197        assert_eq!(css.content_type(), "text/css; charset=utf-8");
198    }
199
200    #[test]
201    fn returns_version_and_paths() {
202        assert_eq!(version(), env!("CARGO_PKG_VERSION"));
203        let paths = paths();
204        assert!(paths.contains(&"sf.js"));
205        assert!(paths.contains(&"sf.css"));
206        assert!(paths.contains(&"img/ouroboros.svg"));
207    }
208
209    #[test]
210    fn returns_not_found_for_missing_assets() {
211        assert_eq!(get("does-not-exist.js"), Err(AssetError::NotFound));
212    }
213
214    #[test]
215    fn rejects_invalid_paths() {
216        assert_eq!(get(""), Err(AssetError::InvalidPath));
217        assert_eq!(get("./sf.js"), Err(AssetError::InvalidPath));
218        assert_eq!(get("/sf.js"), Err(AssetError::InvalidPath));
219        assert_eq!(get("sf//sf.js"), Err(AssetError::InvalidPath));
220        assert_eq!(get("sf.js/"), Err(AssetError::InvalidPath));
221        assert_eq!(get("../sf.js"), Err(AssetError::InvalidPath));
222        assert_eq!(get("vendor/../sf.js"), Err(AssetError::InvalidPath));
223        assert_eq!(
224            get(r"vendor\leaflet\leaflet.js"),
225            Err(AssetError::InvalidPath)
226        );
227    }
228
229    #[test]
230    fn paths_are_sorted() {
231        let paths = paths();
232        let mut sorted = paths.to_vec();
233        sorted.sort_unstable();
234        assert_eq!(paths, sorted.as_slice());
235    }
236
237    #[test]
238    fn cache_and_content_type_rules_match_route_contract() {
239        assert_eq!(
240            content_type_from_path("styles/sf.css"),
241            "text/css; charset=utf-8"
242        );
243        assert_eq!(
244            content_type_from_path("scripts/sf.js"),
245            "application/javascript; charset=utf-8"
246        );
247        assert_eq!(
248            content_type_from_path("scripts/sf.mjs"),
249            "application/javascript; charset=utf-8"
250        );
251        assert_eq!(content_type_from_path("img/logo.svg"), "image/svg+xml");
252        assert_eq!(content_type_from_path("data.json"), "application/json");
253        assert_eq!(content_type_from_path("bundle.map"), "application/json");
254
255        assert_eq!(
256            cache_control_from_path("fonts/jetbrains-mono.woff2"),
257            "public, max-age=31536000, immutable"
258        );
259        assert_eq!(
260            cache_control_from_path("vendor/leaflet/leaflet.js"),
261            "public, max-age=31536000, immutable"
262        );
263        assert_eq!(
264            cache_control_from_path("img/ouroboros.svg"),
265            "public, max-age=31536000, immutable"
266        );
267        assert_eq!(
268            cache_control_from_path("sf.0.7.0.css"),
269            "public, max-age=31536000, immutable"
270        );
271        assert_eq!(
272            cache_control_from_path("sf.0.7.0.js"),
273            "public, max-age=31536000, immutable"
274        );
275        assert_eq!(
276            cache_control_from_path("sf.0.7.0.mjs"),
277            "public, max-age=31536000, immutable"
278        );
279        assert_eq!(cache_control_from_path("sf.css"), "public, max-age=3600");
280    }
281}