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