Skip to main content

wire/
assets.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4// Copyright (c) 2026 2youg1 and the sprawling contributors
5
6//! The client the browser downloads, and which bytes answer which path.
7//!
8//! Two sources, one contract (`ClientAssets`). Which source is in use
9//! changes no policy, which is what makes this
10//! an adapter rather than a decision.
11//!
12//! The whole of the path policy is here and none of it is in a handler:
13//! `""` and `"/"` mean the index page, a path that steps outside the
14//! bundle is a miss rather than an error, and the content type is the
15//! one a browser needs to run a WebAssembly client. `wire::server`
16//! adds headers to what this answers and decides nothing.
17
18use kernel::{AxCode, AxError};
19
20/// One file of the embedded client: the path a browser requests and the
21/// gzip bytes the build wrote into the binary.
22#[derive(Debug, Clone, Copy)]
23pub struct EmbeddedFile {
24    /// Request path relative to the root, forward slashes, no leading
25    /// slash: `index.html`, `web.js`, `snippets/<crate>/inline0.js`.
26    pub path: &'static str,
27    /// The file, gzip-compressed at build time with a zeroed timestamp,
28    /// so the same source bytes embed identically on every build.
29    pub gz: &'static [u8],
30}
31
32/// The client the browser downloads, from whichever source this process
33/// was given. Two sources, one contract: the release binary carries the
34/// bundle inside itself; a development loop points at the directory the
35/// wasm build writes, read per request so an edit shows on refresh.
36#[derive(Debug)]
37pub enum ClientAssets {
38    /// The bundle inside the binary, one gzip per file.
39    Embedded(&'static [EmbeddedFile]),
40    /// A directory on disk, read per request. Loopback development only;
41    /// the release path never constructs this arm.
42    Disk(std::path::PathBuf),
43}
44
45/// What one asset request gets back.
46#[derive(Debug)]
47pub enum AssetReply {
48    Found {
49        bytes: Vec<u8>,
50        content_type: &'static str,
51        /// Whether `bytes` are gzip and need `Content-Encoding: gzip`.
52        gzipped: bool,
53    },
54    Miss(AxError),
55}
56
57impl ClientAssets {
58    /// Answers one request path. Pure over the embedded arm; the disk arm
59    /// reads exactly the file the sanitised path names.
60    ///
61    /// `""` and `"/"` mean `index.html`. A path that steps outside the
62    /// bundle (`..`, empty segments, drive letters, leading dots) is a
63    /// miss, not an error: the bundle is the whole world this route knows.
64    #[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
113/// Normalises a request path to a bundle-relative one, or refuses.
114/// Rejects `..`, empty segments, backslashes, drive colons and segments
115/// that start with a dot; empty input means the index page.
116fn 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
134/// The content type a browser needs to run the client. `application/wasm`
135/// is load-bearing: `WebAssembly.instantiateStreaming` refuses anything
136/// else and the loader falls back to a slower path with a console warning.
137fn 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}