From ce99c8d61c08e6c8e30434b85313f82a2dda2f5c Mon Sep 17 00:00:00 2001 From: Helen Kwok <107752876+helenkwok@users.noreply.github.com> Date: Sun, 1 Nov 2026 21:58:50 +0800 Subject: [PATCH] craft-web-host: tests for message types, file names and the host objects the bridge parses, and a CI workflow that runs them --- .github/workflows/web-host.yml | 45 ++++++++++++++ Cargo.lock | 1 + crates/web-host/Cargo.toml | 3 + crates/web-host/src/lib.rs | 26 ++++++++ crates/web-host/src/web.rs | 106 +++++++++++++++++++++++++++++++++ 5 files changed, 181 insertions(+) create mode 100644 .github/workflows/web-host.yml diff --git a/.github/workflows/web-host.yml b/.github/workflows/web-host.yml new file mode 100644 index 00000000..f308a1cf --- /dev/null +++ b/.github/workflows/web-host.yml @@ -0,0 +1,45 @@ +name: Shared web host + +on: + push: + branches: [main] + paths: ["crates/web-host/**", "Cargo.toml", "Cargo.lock", ".github/workflows/web-host.yml"] + pull_request: + paths: ["crates/web-host/**", "Cargo.toml", "Cargo.lock", ".github/workflows/web-host.yml"] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: shared-web-host-${{ github.ref }} + cancel-in-progress: true + +jobs: + check: + timeout-minutes: 20 + name: Web host lint, tests, WASM tests + runs-on: ubuntu-latest + env: + CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUNNER: wasm-bindgen-test-runner + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable + with: + components: rustfmt, clippy + targets: wasm32-unknown-unknown + - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3 + with: + key: craft-web-host + - run: cargo fmt -p craft-web-host --check + - run: cargo clippy -p craft-web-host --all-targets --locked -- -D warnings + - run: cargo clippy -p craft-web-host --all-targets --locked --target wasm32-unknown-unknown -- -D warnings + - run: cargo test -p craft-web-host --locked + - name: Install the matching WASM test runner + shell: bash + run: | + runner_version=$(grep -A1 '^name = "wasm-bindgen"$' Cargo.lock | sed -n 's/^version = "\(.*\)"$/\1/p') + cargo install wasm-bindgen-cli --version "$runner_version" --locked + - run: cargo test -p craft-web-host --locked --target wasm32-unknown-unknown diff --git a/Cargo.lock b/Cargo.lock index 476fbdb1..d9c92377 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1197,6 +1197,7 @@ name = "craft-web-host" version = "0.1.0" dependencies = [ "wasm-bindgen", + "wasm-bindgen-test", "web-sys", ] diff --git a/crates/web-host/Cargo.toml b/crates/web-host/Cargo.toml index fa9342b9..1f50f570 100644 --- a/crates/web-host/Cargo.toml +++ b/crates/web-host/Cargo.toml @@ -12,5 +12,8 @@ repository.workspace = true wasm-bindgen = "0.2" web-sys = { version = "0.3", features = ["Blob", "CustomEvent", "CustomEventInit", "Element", "Event", "EventTarget", "Location", "MessageEvent", "Window"] } +[target.'cfg(target_arch = "wasm32")'.dev-dependencies] +wasm-bindgen-test = "0.3" + [lints] workspace = true diff --git a/crates/web-host/src/lib.rs b/crates/web-host/src/lib.rs index ad583171..7908dcc2 100644 --- a/crates/web-host/src/lib.rs +++ b/crates/web-host/src/lib.rs @@ -207,6 +207,32 @@ mod tests { assert_eq!(file_name(Some(&"é".repeat(300)), "x").chars().count(), MAX_NAME_CHARS); } + #[test] + fn malformed_message_types_name_no_kind() { + for bad in + ["", ":", "pdfcraft", "pdfcraft:", ":open", "pdfcraft:open:extra", "pdfcraft:OPEN", "pdfcraft: open", "pdfcraftx:open", "pdfcraft/open"] + { + assert_eq!(Incoming::parse("pdfcraft", bad), None, "{bad:?}"); + } + assert_eq!(Incoming::parse("pdfcraft", "pdfcraft:open"), Some(Incoming::Open)); + // The app's own outgoing kinds are not messages to it. + assert_eq!(Incoming::parse("pdfcraft", "pdfcraft:save"), None); + } + + #[test] + fn host_file_names_with_hostile_parts_are_cleaned() { + // Only control characters, or only separators: nothing is left, so the fallback. + assert_eq!(file_name(Some("\u{0}\u{7}\n\t"), "Drawing.dxf"), "Drawing.dxf"); + assert_eq!(file_name(Some("///"), "Drawing.dxf"), "Drawing.dxf"); + assert_eq!(file_name(Some(" "), "Drawing.dxf"), "Drawing.dxf"); + // Mixed separators and a control character inside the last part. + assert_eq!(file_name(Some("a/b\\c\u{1b}[31m.txt"), "x"), "c[31m.txt"); + // Exactly at the limit stays whole; one over is cut; the limit counts characters, not bytes. + assert_eq!(file_name(Some(&"a".repeat(MAX_NAME_CHARS)), "x").chars().count(), MAX_NAME_CHARS); + assert_eq!(file_name(Some(&"a".repeat(MAX_NAME_CHARS + 1)), "x").chars().count(), MAX_NAME_CHARS); + assert_eq!(file_name(Some(&"\u{1F4C4}".repeat(300)), "x"), "\u{1F4C4}".repeat(MAX_NAME_CHARS)); + } + #[test] fn messages_that_arrive_while_the_handler_runs_wait_their_turn() { use std::rc::Rc; diff --git a/crates/web-host/src/web.rs b/crates/web-host/src/web.rs index 969a79e5..b899bb70 100644 --- a/crates/web-host/src/web.rs +++ b/crates/web-host/src/web.rs @@ -247,3 +247,109 @@ fn get(data: &JsValue, key: &str) -> JsValue { fn string(data: &JsValue, key: &str) -> Option { get(data, key).as_string() } + +/// Node tests for the parsing of a host's objects: wrong shapes, missing fields, and sizes the +/// host states against the data it sends. No browser; run with `wasm-bindgen-test-runner`. +#[cfg(test)] +mod tests { + use super::*; + use wasm_bindgen_test::wasm_bindgen_test; + + fn obj(fields: &[(&str, JsValue)]) -> JsValue { + object(&[], fields).expect("an object").into() + } + + fn buffer(len: u32) -> js_sys::ArrayBuffer { + js_sys::ArrayBuffer::new(len) + } + + #[wasm_bindgen_test] + fn an_open_without_bytes_or_a_blob_is_dropped() { + for data in [ + obj(&[("name", "a.pdf".into())]), + obj(&[("name", "a.pdf".into()), ("bytes", "text".into())]), + obj(&[("name", "a.pdf".into()), ("bytes", 42.into())]), + obj(&[("name", "a.pdf".into()), ("file", "not a blob".into())]), + JsValue::NULL, + JsValue::UNDEFINED, + JsValue::from_str("a string"), + JsValue::from_f64(7.0), + ] { + assert!(read(Incoming::Open, &data).is_none()); + } + } + + #[wasm_bindgen_test] + fn an_open_takes_bytes_as_an_array_buffer_or_a_uint8_array() { + let ab = buffer(3); + let array = js_sys::Uint8Array::new_with_length(5); + for (bytes, len) in [(JsValue::from(ab), 3.0), (JsValue::from(array), 5.0)] { + let Some(Message::Open { data, .. }) = read(Incoming::Open, &obj(&[("name", "a.pdf".into()), ("bytes", bytes)])) else { + panic!("an open with bytes is read"); + }; + assert_eq!(data.size(), len); + } + } + + #[wasm_bindgen_test] + fn an_open_without_a_name_or_with_a_hostile_one_gets_a_clean_name() { + let named = |name: Option<&str>| { + let mut fields = vec![("bytes", JsValue::from(buffer(1)))]; + if let Some(n) = name { + fields.push(("name", n.into())); + } + match read(Incoming::Open, &obj(&fields)) { + Some(Message::Open { name, .. }) => name, + _ => panic!("an open with bytes is read"), + } + }; + assert_eq!(named(None), "document"); + assert_eq!(named(Some("../../etc/passwd")), "passwd"); + assert_eq!(named(Some("a\u{0}b.txt")), "ab.txt"); + assert_eq!(named(Some(&"x".repeat(300))).chars().count(), crate::MAX_NAME_CHARS); + } + + #[wasm_bindgen_test] + fn the_size_is_the_data_s_own_not_the_hosts_claim() { + // An ArrayBuffer of 64 MiB with a `size` field of 1: the bytes are viewed in place, not + // copied, and the app sees their real length before it decides to read them. + let ab = buffer(64 << 20); + let Some(Message::Open { data, .. }) = read(Incoming::Open, &obj(&[("bytes", ab.clone().into()), ("size", 1.into())])) else { + panic!("an open with bytes is read"); + }; + assert_eq!(data.size(), f64::from(64u32 << 20)); + let FileData::Bytes(view) = &data else { panic!("bytes stay bytes") }; + assert!(js_sys::Object::is(&view.buffer(), &ab), "a view of the host's buffer, not a copy"); + + // A Blob of 5 bytes with a `size` field of a terabyte. + let blob = web_sys::Blob::new_with_str_sequence(&js_sys::Array::of1(&"hello".into())).expect("a Blob"); + let Some(Message::Open { data, .. }) = read(Incoming::Open, &obj(&[("file", blob.into()), ("size", 1e12.into())])) else { + panic!("an open with a Blob is read"); + }; + assert_eq!(data.size(), 5.0); + } + + #[wasm_bindgen_test] + fn a_saved_needs_a_string_id() { + assert!(matches!(read(Incoming::Saved, &obj(&[("id", "s1".into())])), Some(Message::Saved { id }) if id == "s1")); + for data in [obj(&[]), obj(&[("id", 3.into())]), JsValue::NULL, JsValue::from_str("s1")] { + assert!(read(Incoming::Saved, &data).is_none()); + } + } + + #[wasm_bindgen_test] + fn a_host_message_and_a_handoff_done_read_whatever_fields_exist() { + // Nothing required: a missing version is the contract's, `handoff` is true only for `true`. + for data in [JsValue::NULL, obj(&[]), obj(&[("handoff", "yes".into())]), obj(&[("handoff", 1.into())])] { + let Some(Message::Host { version, handoff }) = read(Incoming::Host, &data) else { panic!("a host message is read") }; + assert_eq!(version, crate::VERSION); + assert!(!handoff); + } + assert!(matches!(read(Incoming::Host, &obj(&[("handoff", true.into())])), Some(Message::Host { handoff: true, .. }))); + assert!(matches!( + read(Incoming::HandoffDone, &obj(&[("name", "a.pdf".into()), ("app", "pdfcraft".into())])), + Some(Message::HandoffDone { name, app }) if name == "a.pdf" && app == "pdfcraft" + )); + assert!(matches!(read(Incoming::HandoffDone, &JsValue::NULL), Some(Message::HandoffDone { name, app }) if name.is_empty() && app.is_empty())); + } +}