From 8aa14d5b3e0cb5e8f51ae3d6989a0fcb2ac04726 Mon Sep 17 00:00:00 2001 From: meh Date: Sat, 15 Aug 2026 19:37:23 +0700 Subject: [PATCH 1/2] refactor(wasm): separate acquiring a module from running it `run_guest` read the file and instantiated in one body, so a caller with bytes already in hand had nowhere to enter. A module fetched from a page never touches the disk. `run_guest_bytes` is that entry point and now holds the whole sequence: validate, link, `instantiate_and_start`, call the entry export, check the status, warn when a guest reports OK without mutating anything. `run_guest` reads the file and calls it. One copy rather than two, deliberately. Everything that decides whether a guest ran correctly is subtle enough to be worth having in a single place -- the note on why this is `instantiate_and_start` and not `instantiate` has to keep applying to the only copy of that call. No behaviour change: both existing callers keep the signature they had. --- apps/chuzz/src/wasm_page.rs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/apps/chuzz/src/wasm_page.rs b/apps/chuzz/src/wasm_page.rs index 10dbcba..51fdaf9 100644 --- a/apps/chuzz/src/wasm_page.rs +++ b/apps/chuzz/src/wasm_page.rs @@ -59,9 +59,21 @@ pub fn run_guest( ) -> Result> { let bytes = std::fs::read(module_path) .map_err(|error| format!("could not read {}: {error}", module_path.display()))?; + run_guest_bytes(&bytes, document, mount) +} +/// The same, for a module that is already in memory. +/// +/// Split out because a module fetched from a page never touches the disk, and +/// two copies of the instantiate sequence would drift. Everything that decides +/// whether a guest ran correctly lives here and nowhere else. +pub fn run_guest_bytes( + bytes: &[u8], + document: BaseDocument, + mount: NodeId, +) -> Result> { let engine = Engine::default(); - let module = Module::new(&engine, &bytes[..])?; + let module = Module::new(&engine, bytes)?; let mut store = Store::new(&engine, Host::new(document, mount)); let mut linker = >::new(&engine); blitz_wasm::add_to_linker(&mut linker)?; From 1df0f5af6385595ae2cba463e5ddb971076e1db3 Mon Sep 17 00:00:00 2001 From: meh Date: Sat, 15 Aug 2026 19:46:37 +0700 Subject: [PATCH 2/2] feat(browser): mount a module a page declares, keeping its fallback A page can now hand its interface to a WebAssembly guest: The module is fetched with the page, vetted, and mounted against the parsed document. No JavaScript anywhere in the path: a page that declares a module is parsed without a script runtime, because attaching an engine to run none is dead weight. This is the other shape from `--wasm`. That replaces the document entirely -- an empty `` the guest fills, no HTML at all. Here the document exists first, including whatever sits inside the mount, and the guest takes that element over. The fallback is the reason for most of the care here. It is what a browser without wasm shows, so every way of failing has to leave it standing: a bad selector, a 404, a server answering 200 with its index page, a module that instantiates and then reports a non-OK status. The mount is emptied only after the entry export has returned OK. Failure re-parses rather than repairs. A guest appends into the mount as it builds, so a run that fails part way leaves its own half-built tree beside the fallback with no way to tell them apart afterwards. Parsing the same bytes again is the only way to be certain the document is what the page said. Bytes are vetted before wasmi sees them, because the failure that catches is not a corrupt module. The site this exists for is behind a CDN that answers 200 with its index page for every unknown path, so a missing module arrives as `` and wasmi reports byte offsets in a file that is not a module. That has to say the server returned HTML: the fix is a deploy, not a rebuild. Same origin only. The guest runs against the host ABI with the whole document reachable, which is a different trust decision from a local file named on the command line. Cross-origin needs a policy, not a default. `runtime="..."` is parsed and ignored. Nothing implements preloaded runtimes yet and the live page carries the attribute, so failing on it would reject the page it is meant to serve. --- apps/chuzz/src/browser.rs | 280 ++++++++++++++++++++++++++++++++++-- apps/chuzz/src/wasm_page.rs | 106 ++++++++++++++ 2 files changed, 378 insertions(+), 8 deletions(-) diff --git a/apps/chuzz/src/browser.rs b/apps/chuzz/src/browser.rs index 8cbb781..582ceab 100644 --- a/apps/chuzz/src/browser.rs +++ b/apps/chuzz/src/browser.rs @@ -119,11 +119,23 @@ enum PageSource { Html { html: String, scripts: HashMap, + /// A module the page asked for with ` +/// ``` +/// +/// `runtime="..."` is read and ignored. The design has it selecting a +/// preloaded runtime by version, nothing implements preloaded runtimes yet, and +/// failing on an attribute whose meaning has not been decided would make the +/// page harder to write rather than safer. +#[derive(Debug, Clone)] +pub struct WasmScript { + pub src: String, + pub mount: String, +} + +/// The first wasm script tag in a parsed document, if it has one. +/// +/// Returns `None` for the overwhelming majority of pages, which is the point: +/// a document without the tag must take no new code path at all. +pub fn find_wasm_script(document: &BaseDocument) -> Option { + let node = document + .query_selector(r#"script[type="application/wasm"]"#) + .ok() + .flatten()?; + let element = document.get_node(node)?.element_data()?; + let attr = |name: &str| { + element + .attrs + .iter() + .find(|attr| attr.name.local.as_ref() == name) + .map(|attr| attr.value.to_string()) + }; + Some(WasmScript { + src: attr("src")?, + // No `mount` means the guest has nowhere to build, which is a page + // authoring error rather than something to guess at. + mount: attr("mount")?, + }) +} + +/// Whether fetched bytes are plausibly a WebAssembly module. +/// +/// Worth doing separately from `Module::new`, because the failure this catches +/// is not a corrupt module. A CDN that serves its index page under `200` for +/// every unknown path hands back ``, and wasmi then reports a +/// parse error describing byte offsets in a file that is not a module at all. +/// Whoever is debugging needs to be told the server answered with a page. +pub fn validate_module(bytes: &[u8]) -> Result<(), String> { + const MAGIC: &[u8] = b"\0asm"; + if bytes.starts_with(MAGIC) { + return Ok(()); + } + let head = &bytes[..bytes.len().min(64)]; + let looks_like_html = head + .iter() + .position(|byte| !byte.is_ascii_whitespace()) + .is_some_and(|start| head[start] == b'<'); + if looks_like_html { + return Err( + "the server returned HTML, not a module. A CDN that answers 200 with its \ + index page for unknown paths does this; check the module is actually \ + deployed at that path" + .to_owned(), + ); + } + Err(format!( + "not a WebAssembly module: expected the magic bytes \\0asm, found {:02x?}", + &bytes[..bytes.len().min(4)] + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The three answers have to be distinguishable, because they send whoever + /// is debugging to three different places: the deploy, the server config, + /// and the module itself. + #[test] + fn validation_tells_html_apart_from_a_corrupt_module() { + assert!(validate_module(b"\0asm\x01\0\0\0").is_ok()); + + let html = validate_module(b"").unwrap_err(); + assert!(html.contains("HTML"), "{html}"); + + let leading_space = validate_module(b"\n ").unwrap_err(); + assert!( + leading_space.contains("HTML"), + "whitespace before the tag still means a page: {leading_space}" + ); + + let corrupt = validate_module(&[0xde, 0xad, 0xbe, 0xef]).unwrap_err(); + assert!( + !corrupt.contains("HTML") && corrupt.contains("magic"), + "a corrupt module must not be reported as HTML: {corrupt}" + ); + + // Shorter than the magic bytes, and the slicing must not panic. + assert!(validate_module(b"\0as").is_err()); + assert!(validate_module(b"").is_err()); + } +}