1use kernel::{AxCode, AxError};
19
20#[derive(Debug, Clone, Copy)]
23pub struct EmbeddedFile {
24 pub path: &'static str,
27 pub gz: &'static [u8],
30}
31
32#[derive(Debug)]
37pub enum ClientAssets {
38 Embedded(&'static [EmbeddedFile]),
40 Disk(std::path::PathBuf),
43}
44
45#[derive(Debug)]
47pub enum AssetReply {
48 Found {
49 bytes: Vec<u8>,
50 content_type: &'static str,
51 gzipped: bool,
53 },
54 Miss(AxError),
55}
56
57impl ClientAssets {
58 #[must_use]
65 pub fn lookup(&self, request_path: &str) -> AssetReply {
66 let Some(rel) = sanitize_asset_path(request_path) else {
67 return AssetReply::Miss(
68 AxError::failure(
69 AxCode::InvalidArgs,
70 "serve a client asset",
71 format!("the path {request_path} steps outside the client bundle"),
72 )
73 .with_recovery("request a bundle-relative path such as /web.js"),
74 );
75 };
76 match self {
77 ClientAssets::Embedded(files) => {
78 for file in *files {
79 if file.path == rel {
80 return AssetReply::Found {
81 bytes: file.gz.to_vec(),
82 content_type: content_type_of(&rel),
83 gzipped: true,
84 };
85 }
86 }
87 AssetReply::Miss(missing_asset(&rel, "this binary"))
88 }
89 ClientAssets::Disk(root) => {
90 let full = root.join(&rel);
91 match std::fs::read(&full) {
92 Ok(bytes) => AssetReply::Found {
93 bytes,
94 content_type: content_type_of(&rel),
95 gzipped: false,
96 },
97 Err(_) => AssetReply::Miss(missing_asset(&rel, "the --web-dir directory")),
98 }
99 }
100 }
101 }
102}
103
104fn missing_asset(rel: &str, source: &str) -> AxError {
105 AxError::failure(
106 AxCode::InvalidArgs,
107 "serve a client asset",
108 format!("{source} does not carry {rel}"),
109 )
110 .with_recovery("rebuild the client (`just build-web`), then rebuild or restart the server")
111}
112
113fn sanitize_asset_path(raw: &str) -> Option<String> {
117 let trimmed = raw.trim_start_matches('/');
118 if trimmed.is_empty() {
119 return Some("index.html".to_owned());
120 }
121 if trimmed.contains('\\') || trimmed.contains(':') {
122 return None;
123 }
124 let mut segments = Vec::new();
125 for segment in trimmed.split('/') {
126 if segment.is_empty() || segment.starts_with('.') {
127 return None;
128 }
129 segments.push(segment);
130 }
131 Some(segments.join("/"))
132}
133
134fn content_type_of(rel: &str) -> &'static str {
138 let suffix = rel.rsplit('.').next().unwrap_or_default();
139 match suffix {
140 "html" => "text/html; charset=utf-8",
141 "js" | "mjs" => "text/javascript; charset=utf-8",
142 "wasm" => "application/wasm",
143 "css" => "text/css; charset=utf-8",
144 "json" | "map" => "application/json",
145 "svg" => "image/svg+xml",
146 _ => "application/octet-stream",
147 }
148}
149#[cfg(test)]
150#[allow(
151 clippy::unwrap_used,
152 clippy::expect_used,
153 clippy::panic,
154 clippy::indexing_slicing,
155 reason = "test code"
156)]
157mod tests {
158 use super::*;
159
160 const BUNDLE: &[EmbeddedFile] = &[
161 EmbeddedFile {
162 path: "index.html",
163 gz: b"gzipped-html",
164 },
165 EmbeddedFile {
166 path: "web.js",
167 gz: b"gzipped-js",
168 },
169 EmbeddedFile {
170 path: "web_bg.wasm",
171 gz: b"gzipped-wasm",
172 },
173 EmbeddedFile {
174 path: "snippets/dioxus-abc/inline0.js",
175 gz: b"gzipped-snippet",
176 },
177 ];
178
179 #[test]
180 fn the_embedded_bundle_answers_the_paths_the_page_asks_for() {
181 let assets = ClientAssets::Embedded(BUNDLE);
182 for (asked, want_type, want_bytes) in [
183 ("", "text/html; charset=utf-8", b"gzipped-html".as_slice()),
184 ("/", "text/html; charset=utf-8", b"gzipped-html".as_slice()),
185 (
186 "web.js",
187 "text/javascript; charset=utf-8",
188 b"gzipped-js".as_slice(),
189 ),
190 (
191 "/web_bg.wasm",
192 "application/wasm",
193 b"gzipped-wasm".as_slice(),
194 ),
195 (
196 "snippets/dioxus-abc/inline0.js",
197 "text/javascript; charset=utf-8",
198 b"gzipped-snippet".as_slice(),
199 ),
200 ] {
201 let AssetReply::Found {
202 bytes,
203 content_type,
204 gzipped,
205 } = assets.lookup(asked)
206 else {
207 panic!("{asked} must be found");
208 };
209 assert_eq!(bytes, want_bytes, "{asked}");
210 assert_eq!(content_type, want_type, "{asked}");
211 assert!(gzipped, "embedded files travel gzipped");
212 }
213 }
214
215 #[test]
216 fn a_path_outside_the_bundle_is_a_miss_that_names_the_path() {
217 let assets = ClientAssets::Embedded(BUNDLE);
218 for hostile in [
219 "../Cargo.toml",
220 "a/../../secret",
221 "a//b.js",
222 "C:/windows/system32",
223 "snippets\\x\\y.js",
224 ".git/config",
225 ] {
226 let AssetReply::Miss(err) = assets.lookup(hostile) else {
227 panic!("{hostile} must miss");
228 };
229 assert!(!err.recovery().is_empty(), "a miss says what to do next");
230 }
231 }
232
233 #[test]
234 fn an_unknown_file_misses_and_the_recovery_names_the_rebuild() {
235 let assets = ClientAssets::Embedded(BUNDLE);
236 let AssetReply::Miss(err) = assets.lookup("missing.js") else {
237 panic!("an absent file is a miss");
238 };
239 assert!(err.recovery().contains("just build-web"));
240 }
241
242 #[test]
243 fn the_disk_arm_reads_per_request_and_never_claims_gzip() {
244 let dir = std::env::temp_dir().join(format!("sprawl-assets-{}", std::process::id()));
245 std::fs::create_dir_all(&dir).unwrap();
246 std::fs::write(dir.join("web.js"), b"fresh from disk").unwrap();
247 let assets = ClientAssets::Disk(dir.clone());
248 let AssetReply::Found {
249 bytes,
250 content_type,
251 gzipped,
252 } = assets.lookup("/web.js")
253 else {
254 panic!("the file exists");
255 };
256 assert_eq!(bytes, b"fresh from disk");
257 assert_eq!(content_type, "text/javascript; charset=utf-8");
258 assert!(!gzipped, "disk bytes are identity-encoded");
259 std::fs::write(dir.join("web.js"), b"edited").unwrap();
260 let AssetReply::Found { bytes, .. } = assets.lookup("web.js") else {
261 panic!("still there");
262 };
263 assert_eq!(
264 bytes, b"edited",
265 "a refresh sees the edit without a restart"
266 );
267 std::fs::remove_dir_all(&dir).ok();
268 }
269}