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()); + } +}