core: fix 1 - zero-import gate and locked build
Address review findings: 1. Add zero-import test at core/tests/no_host_imports.rs using wasmparser to verify wasm has no host imports. Gracefully skip if artifact absent; fail with import names if any found. This gates against the current doc-tree: yrs -> fastrand -> getrandom(wasm_js) -> wasm-bindgen. 2. Add crate-level doc comment recording the constraint: never call yrs::Doc::new() — always use Doc::with_client_id(). Doc::new() pulls random entropy via fastrand, which is dead-stripped today but would break the gate when called. Verified Doc::with_client_id exists. 3. Exact-pin yrs and wasmparser with "=" in Cargo.toml and add --locked to scripts/build-wasm.sh to enforce Cargo.lock versions. Test both paths: cargo test (skips before artifact), then build-wasm.sh, then cargo test again (passes). Artifact remains 386 bytes.
5 files changed,
+107
−3
core/Cargo.lock+56 −1core/Cargo.toml+4 −1core/src/lib.rs+7 −0core/tests/no_host_imports.rs+39 −0scripts/build-wasm.sh+1 −1
diff --git a/core/Cargo.lock b/core/Cargo.lock| index fb1aa4e..341d10e 100644 |
| --- a/core/Cargo.lock |
| +++ b/core/Cargo.lock |
| @@ -65,7 +65,7 @@ checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" |
| dependencies = [ |
| "cfg-if", |
| "crossbeam-utils", |
| - "hashbrown", |
| + "hashbrown 0.14.5", |
| "lock_api", |
| "once_cell", |
| "parking_lot_core", |
| @@ -75,9 +75,16 @@ dependencies = [ |
| name = "docs-core" |
| version = "0.1.0" |
| dependencies = [ |
| + "wasmparser", |
| "yrs", |
| ] |
| +[[package]] |
| +name = "equivalent" |
| +version = "1.0.2" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" |
| + |
| [[package]] |
| name = "event-listener" |
| version = "5.4.2" |
| @@ -107,6 +114,12 @@ dependencies = [ |
| "getrandom", |
| ] |
| +[[package]] |
| +name = "foldhash" |
| +version = "0.2.0" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" |
| + |
| [[package]] |
| name = "getrandom" |
| version = "0.4.3" |
| @@ -126,6 +139,29 @@ version = "0.14.5" |
| source = "registry+https://github.com/rust-lang/crates.io-index" |
| checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" |
| +[[package]] |
| +name = "hashbrown" |
| +version = "0.17.1" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" |
| +dependencies = [ |
| + "foldhash", |
| + "serde", |
| + "serde_core", |
| +] |
| + |
| +[[package]] |
| +name = "indexmap" |
| +version = "2.14.1" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "07aa2048142242915a31d35844fb311e0e53fcca590c3a0a40dcf1b841fa09eb" |
| +dependencies = [ |
| + "equivalent", |
| + "hashbrown 0.17.1", |
| + "serde", |
| + "serde_core", |
| +] |
| + |
| [[package]] |
| name = "itoa" |
| version = "1.0.18" |
| @@ -239,6 +275,12 @@ version = "1.2.0" |
| source = "registry+https://github.com/rust-lang/crates.io-index" |
| checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" |
| +[[package]] |
| +name = "semver" |
| +version = "1.0.28" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" |
| + |
| [[package]] |
| name = "serde" |
| version = "1.0.229" |
| @@ -390,6 +432,19 @@ dependencies = [ |
| "unicode-ident", |
| ] |
| +[[package]] |
| +name = "wasmparser" |
| +version = "0.258.0" |
| +source = "registry+https://github.com/rust-lang/crates.io-index" |
| +checksum = "d9a61719f93a87b16d325921e251800c4833f8fab50fa21c7de73aed50086313" |
| +dependencies = [ |
| + "bitflags", |
| + "hashbrown 0.17.1", |
| + "indexmap", |
| + "semver", |
| + "serde", |
| +] |
| + |
| [[package]] |
| name = "windows-link" |
| version = "0.2.1" |
diff --git a/core/Cargo.toml b/core/Cargo.toml| index 748f7a1..1352fe6 100644 |
| --- a/core/Cargo.toml |
| +++ b/core/Cargo.toml |
| @@ -7,9 +7,12 @@ edition = "2021" |
| crate-type = ["cdylib", "rlib"] |
| [dependencies] |
| -yrs = "0.23.5" |
| +yrs = "=0.23.5" |
| [profile.release] |
| opt-level = "s" |
| lto = true |
| panic = "abort" |
| + |
| +[dev-dependencies] |
| +wasmparser = "=0.258.0" |
diff --git a/core/src/lib.rs b/core/src/lib.rs| index 588df2d..d9bde7d 100644 |
| --- a/core/src/lib.rs |
| +++ b/core/src/lib.rs |
| @@ -1,5 +1,12 @@ |
| //! The Docs core: Yjs CRDT and (later) text layout, compiled to wasm and |
| //! run both under wazero on the server and — for layout only — in the browser. |
| +//! |
| +//! # Important: Client ID initialization |
| +//! |
| +//! Never call `yrs::Doc::new()` in this crate — always use `Doc::with_client_id()`. |
| +//! `Doc::new()` internally reaches for random entropy via fastrand, which pulls a JS |
| +//! import that the Go/wazero host cannot provide. Providing an explicit client ID |
| +//! avoids this and aligns with the design, which assigns client IDs deterministically. |
| /// Returns the ABI version this module exports. The Go host checks this on |
| /// load and refuses a module it does not understand, so a stale .wasm on a |
diff --git a/core/tests/no_host_imports.rs b/core/tests/no_host_imports.rs| new file mode 100644 |
| index 0000000..d9e849e |
| --- /dev/null |
| +++ b/core/tests/no_host_imports.rs |
| @@ -0,0 +1,39 @@ |
| +use std::path::Path; |
| + |
| +#[test] |
| +fn no_host_imports() { |
| + let wasm_path = "target/wasm32-unknown-unknown/release/docs_core.wasm"; |
| + |
| + // Skip if artifact not yet built |
| + if !Path::new(wasm_path).exists() { |
| + eprintln!( |
| + "SKIPPED: wasm artifact not found at {}. Run ./scripts/build-wasm.sh first.", |
| + wasm_path |
| + ); |
| + return; |
| + } |
| + |
| + let wasm_bytes = std::fs::read(wasm_path).expect("Failed to read wasm file"); |
| + let parser = wasmparser::Parser::new(0); |
| + |
| + let mut imports = Vec::new(); |
| + |
| + for payload in parser.parse_all(&wasm_bytes) { |
| + let payload = payload.expect("Failed to parse wasm module"); |
| + |
| + if let wasmparser::Payload::ImportSection(reader) = payload { |
| + let import_reader = reader.into_imports(); |
| + for import_result in import_reader { |
| + let import = import_result.expect("Failed to parse import"); |
| + imports.push(format!("{}.{}", import.module, import.name)); |
| + } |
| + } |
| + } |
| + |
| + if !imports.is_empty() { |
| + panic!( |
| + "WASM module has host imports (breaks Go/wazero compatibility):\n {}", |
| + imports.join("\n ") |
| + ); |
| + } |
| +} |
diff --git a/scripts/build-wasm.sh b/scripts/build-wasm.sh| index db0d9bc..df8d4eb 100755 |
| --- a/scripts/build-wasm.sh |
| +++ b/scripts/build-wasm.sh |
| @@ -3,7 +3,7 @@ |
| # provides no JS glue, so the module must be plain wasm32 with a C ABI. |
| set -euo pipefail |
| cd "$(dirname "$0")/.." |
| -cargo build --release --manifest-path core/Cargo.toml --target wasm32-unknown-unknown |
| +cargo build --release --manifest-path core/Cargo.toml --target wasm32-unknown-unknown --locked |
| out="core/target/wasm32-unknown-unknown/release/docs_core.wasm" |
| [ -f "$out" ] || { echo "build-wasm: no artifact at $out" >&2; exit 1; } |
| echo "$out ($(wc -c < "$out") bytes)" |