1use 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
20#[non_exhaustive]
21pub enum AssetError {
22 InvalidPath,
24 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
42#[non_exhaustive]
43pub struct UiAsset {
44 path: &'static str,
46 content_type: &'static str,
48 cache_control: &'static str,
50 bytes: &'static [u8],
52}
53
54impl UiAsset {
55 pub fn path(&self) -> &'static str {
57 self.path
58 }
59
60 pub fn content_type(&self) -> &'static str {
62 self.content_type
63 }
64
65 pub fn cache_control(&self) -> &'static str {
67 self.cache_control
68 }
69
70 pub fn bytes(&self) -> &'static [u8] {
72 self.bytes
73 }
74}
75
76pub 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
94pub fn paths() -> &'static [&'static str] {
96 PATHS.as_slice()
97}
98
99pub 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}