//! Runs the *actual compiled wasm32 module* (not the native `docs_core::abi` //! functions directly) under `wasmi`, a pure-Rust wasm interpreter, and //! proves the two properties fix-round-1's review demanded for finding 1: //! //! 1. A wasm trap inside `docs_core_apply` for one document (a corrupted //! update reaching the undefined behaviour documented in //! `core/src/abi.rs`'s module doc comment) must not corrupt any *other* //! document already alive in the same module instance. //! 2. It must not leave the instance permanently unable to create new //! documents (the `panic_already_borrowed` cascade fix-round-1 found: //! a `RefCell` borrow held across a call that traps never gets its //! `Drop` run, because a wasm trap — `unreachable` under this crate's //! `panic = "abort"` release profile — does not unwind). //! //! Why `wasmi` and not just native `cargo test` calls into `docs_core::abi` //! directly: a wasm trap is *contained to the single host call* by any //! conforming wasm runtime (wasmi here; wazero in production) — the //! embedding process keeps running and the instance's linear memory //! persists exactly as it was at the moment of the trap, which is the //! only way to observe whether *other* state in that memory (like our //! `RefCell`'s borrow flag) got corrupted. A native panic in a `cargo //! test` process is a different, less faithful animal: with the default //! unwind panic strategy Rust runs destructors as it unwinds (so a //! same-process repro would *not* show the leak this module actually has //! in the shipped wasm32 `panic = "abort"` build), and for inputs that //! reach genuine memory-safety UB, native execution is not sandboxed the //! way wasm linear memory is, so it can just as easily produce a hard //! process abort that proves nothing about containment (empirically: an //! earlier native-only version of this fuzz found an input that took down //! the entire native test binary with "thread caused non-unwinding panic. //! aborting" — see the fix-round-1 report). `wasmi`'s sandboxed execution //! model is what actually matches wazero's, so it is what this test uses. //! //! # A second, distinct finding surfaced while building this test: hangs //! //! Fuzzing this way (before fuel metering was added below) turned up //! inputs that are not traps at all: `docs_core_apply` on a 3-byte-flipped //! `concurrent` fixture (`02010200040107636f6e6f656e7403424293010100040107 //! 636f6e74656e74034141418a`, seed `0xC0FFEE`, trial 1133 of this file's //! RNG sequence) ran for **163 seconds** of real CPU time before returning //! an ordinary `Ok` — not an infinite loop (confirmed: it does terminate), //! but pathologically slow, reproducibly, deterministically, every time. //! This is a *third* category alongside "succeeds" and "traps": malformed //! input reaching a slow path deep in `yrs`'s decode/merge logic — plausibly //! a corrupted length/count field being reinterpreted as a huge value. This //! is upstream, in the same `yrs` decode territory as the UB findings this //! module's doc comment describes, and not something `core/src/abi.rs` can //! fix. It is, however, a real resource-exhaustion vector worth flagging //! for whoever configures the production wazero host (Task 6): a single //! ~36-byte corrupted update can tie up a call for minutes of CPU time. //! //! Below, `Harness::apply` uses `wasmi`'s fuel metering to bound each call //! to a fixed instruction budget, so this fuzz test treats "ran out of //! fuel" the same as a trap (both are "did not return cleanly") rather //! than actually blocking for minutes per pathological input — the //! containment property under test (no cross-document damage, instance //! still usable) applies equally to both. //! //! # Fix-round-2: liveness was checked, not content (finding A) //! //! Fix-round-1's per-trial check only asserted `text_len(control) >= 0` — //! that the control document still *exists* — and never read its content //! back at all, so silent content corruption of another document (one of //! the two failure modes this test exists to catch) would have passed //! silently. `docs_core_text_read` is now bound in the `Harness` //! (`read_text`), and every trial compares each control document's full //! text content byte-for-byte against the value it was seeded with, not //! just checking that a length comes back non-negative. //! //! This test builds `core`'s wasm32-unknown-unknown release artifact //! itself (via `cargo build`) so it always exercises current source, not a //! possibly-stale artifact left over from a previous `scripts/build-wasm.sh` //! run. use std::path::PathBuf; use std::process::Command; use wasmi::{Config, Engine, Instance, Linker, Memory, Module, Store, TypedFunc}; /// Fuel budget for a single `docs_core_apply` call. Measured control- /// document applies (real, valid fixtures) consumed 21,978 / 21,989 / /// 44,281 / 31,382 fuel units (hello / unicode / formatted / concurrent — /// see this test's own `eprintln!` output). 200,000,000 is roughly 4,500x /// the highest of those (44,281, `formatted`), while stopping the /// 163-second pathological input (see the module doc comment) after a /// small fraction of a second instead. const APPLY_FUEL_BUDGET: u64 = 200_000_000; struct Harness { store: Store<()>, memory: Memory, alloc: TypedFunc, free: TypedFunc<(u32, u32), ()>, doc_new: TypedFunc<(u32, u32, u64), u32>, apply: TypedFunc<(u32, u32, u32), i32>, text_len: TypedFunc, text_read: TypedFunc<(u32, u32, u32), i32>, } impl Harness { fn new(wasm_bytes: &[u8]) -> Self { let mut config = Config::default(); config.consume_fuel(true); let engine = Engine::new(&config); let module = Module::new(&engine, wasm_bytes).expect("module should parse and validate"); let mut store = Store::new(&engine, ()); store .set_fuel(u64::MAX / 2) .expect("fuel metering is enabled"); // Zero imports (core/tests/no_host_imports.rs enforces this), so an // empty Linker is enough to instantiate. let linker = Linker::<()>::new(&engine); let instance = linker .instantiate(&mut store, &module) .expect("instantiate") .start(&mut store) .expect("start (module has no start function requiring host calls)"); Self::from_instance(store, instance) } fn from_instance(store: Store<()>, instance: Instance) -> Self { let memory = instance .get_memory(&store, "memory") .expect("module exports linear memory"); let alloc = instance .get_typed_func::(&store, "docs_core_alloc") .expect("docs_core_alloc export"); let free = instance .get_typed_func::<(u32, u32), ()>(&store, "docs_core_free") .expect("docs_core_free export"); let doc_new = instance .get_typed_func::<(u32, u32, u64), u32>(&store, "docs_core_doc_new") .expect("docs_core_doc_new export"); let apply = instance .get_typed_func::<(u32, u32, u32), i32>(&store, "docs_core_apply") .expect("docs_core_apply export"); let text_len = instance .get_typed_func::(&store, "docs_core_text_len") .expect("docs_core_text_len export"); let text_read = instance .get_typed_func::<(u32, u32, u32), i32>(&store, "docs_core_text_read") .expect("docs_core_text_read export"); Self { store, memory, alloc, free, doc_new, apply, text_len, text_read, } } /// Writes `bytes` into freshly wasm-allocated memory. Returns the /// pointer and length (the caller must `free_buf` it when done). fn write_buf(&mut self, bytes: &[u8]) -> Result<(u32, u32), wasmi::Error> { let len = bytes.len() as u32; let ptr = self.alloc.call(&mut self.store, len)?; assert_ne!(ptr, 0, "docs_core_alloc failed for {len} bytes"); self.memory .write(&mut self.store, ptr as usize, bytes) .expect("write within a just-allocated buffer cannot be out of bounds"); Ok((ptr, len)) } fn free_buf(&mut self, ptr: u32, len: u32) -> Result<(), wasmi::Error> { self.free.call(&mut self.store, (ptr, len)) } fn doc_new(&mut self, guid: &str, client_id: u64) -> Result { let (ptr, len) = self.write_buf(guid.as_bytes())?; let handle = self.doc_new.call(&mut self.store, (ptr, len, client_id))?; self.free_buf(ptr, len)?; Ok(handle) } /// Applies `update` to `handle` with a bounded fuel budget /// (`APPLY_FUEL_BUDGET`) so a pathologically slow input (see the module /// doc comment) fails fast with `TrapCode::OutOfFuel` instead of /// actually running for however long it would otherwise take. Returns /// the fuel actually consumed alongside the call result, for /// calibration/reporting. fn apply(&mut self, handle: u32, update: &[u8]) -> (Result, u64) { let (ptr, len) = match self.write_buf(update) { Ok(pl) => pl, Err(e) => return (Err(e), 0), }; let before = self.store.get_fuel().expect("fuel metering is enabled"); self.store .set_fuel(APPLY_FUEL_BUDGET) .expect("fuel metering is enabled"); let result = self.apply.call(&mut self.store, (handle, ptr, len)); let after = self.store.get_fuel().unwrap_or(0); let consumed = APPLY_FUEL_BUDGET.saturating_sub(after); // Restore a large budget for bookkeeping calls (doc_new, text_len, // text_read, free) that aren't under test here. self.store.set_fuel(u64::MAX / 2).ok(); let _ = before; if let Err(pl) = self.free_buf(ptr, len) { // Freeing after a trapped/fuel-exhausted apply can itself only // fail if the module's allocator state is now unreliable, in // which case that failure is itself useful trap information, // not something to hide. return (Err(pl), consumed); } (result, consumed) } fn text_len(&mut self, handle: u32) -> Result { self.text_len.call(&mut self.store, handle) } /// Reads `handle`'s full text content back out of wasm memory, /// byte-for-byte: calls `docs_core_text_len` to size a buffer, /// allocates it, calls `docs_core_text_read` into it, copies the /// written bytes out, and frees the buffer. Fix-round-2 finding A: /// added so the containment test can compare *content*, not just /// whether a handle still answers `text_len` non-negatively. /// /// Panics (rather than returning a `Result`) on ERR_NO_DOC or a /// length/read-count mismatch — those indicate a caller bug (reading a /// handle that was never confirmed live) or an ABI contract violation /// distinct from the traps this harness expects and handles via `Err`. fn read_text(&mut self, handle: u32) -> Result, wasmi::Error> { let len = self.text_len.call(&mut self.store, handle)?; assert!( len >= 0, "read_text called on handle {handle} which text_len rejects: rc={len} (ERR_NO_DOC)" ); let len = len as u32; if len == 0 { return Ok(Vec::new()); } let out_ptr = self.alloc.call(&mut self.store, len)?; assert_ne!( out_ptr, 0, "docs_core_alloc failed for {len} bytes (text_read buffer)" ); let rc = self .text_read .call(&mut self.store, (handle, out_ptr, len))?; assert_eq!( rc, len as i32, "docs_core_text_read returned {rc} bytes written, expected {len} \ (handle {handle} changed length between text_len and text_read?)" ); let mut buf = vec![0u8; len as usize]; self.memory .read(&mut self.store, out_ptr as usize, &mut buf) .expect("read within a just-written buffer cannot be out of bounds"); self.free.call(&mut self.store, (out_ptr, len))?; Ok(buf) } } /// Builds `core`'s wasm32-unknown-unknown release artifact from current /// source and returns its bytes. Deliberately does not shell out to /// `scripts/build-wasm.sh` (which also runs clippy — slow, and irrelevant /// to this test) and instead runs just the `cargo build` step directly. fn build_wasm() -> Vec { let core_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); let workspace_root = core_dir .parent() .expect("core/ always has a parent (the repo root)"); // Use the same canonical host and flags as the committed artifact. Rebuild // current source, without recursively linting the test that invoked us. let status = Command::new("bash") .args(["scripts/build-wasm.sh", "--build-only"]) .current_dir(workspace_root) .status() .expect("failed to run canonical wasm builder"); assert!(status.success(), "canonical wasm build failed"); let artifact = core_dir.join("target/wasm32-unknown-unknown/release/docs_core.wasm"); std::fs::read(&artifact).unwrap_or_else(|e| panic!("reading {artifact:?}: {e}")) } // Fix round 1 on the fragment-migration task: this used to point at // fixtures/data (bare Y.Text on root "content"). It now points at // fixtures/vectors/fragment instead: `read_text` below goes through // the wasm ABI's docs_core_text_len/docs_core_text_read, which now // read fragment_text (root "prosemirror"), not text_content — see // core/src/abi.rs's text_string doc comment. A fixtures/data update // still applies cleanly through this same ABI (apply_slice decodes // and merges regardless of which root it targets), but reads back as // "" now, which would make every content check below compare against // the wrong thing silently. fixtures/generate-fragment.mjs's own // 'trap-containment vectors' section builds hello/unicode/formatted/ // concurrent to match — same names, same content and merge // characteristics fixtures/data's originals have, wrapped in a single // each instead of a bare Y.Text, with a matching // expected.json alongside (that generator's plainTextOf mirrors // fragment_text, so the two agree on what "text" means). fn fixtures_dir() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")) .parent() .unwrap() .join("fixtures/vectors/fragment") } fn fixtures() -> Vec<(&'static str, Vec)> { let dir = fixtures_dir(); ["hello", "unicode", "formatted", "concurrent"] .iter() .map(|name| { let bytes = std::fs::read(dir.join(format!("{name}.update.bin"))).unwrap(); (*name, bytes) }) .collect() } /// The plain-text `text` field from a fixture's `{name}.expected.json` — /// the expected content of the root text type once `{name}.update.bin` is /// applied to a fresh document. Used to check control documents' /// *content*, not just that they still exist (fix-round-2 finding A). fn expected_text(name: &str) -> String { let path = fixtures_dir().join(format!("{name}.expected.json")); let raw = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("reading {path:?}: {e}")); let json: serde_json::Value = serde_json::from_str(&raw).unwrap_or_else(|e| panic!("parsing {path:?}: {e}")); json["text"] .as_str() .unwrap_or_else(|| panic!("{path:?} has no string \"text\" field")) .to_string() } // Deterministic xorshift64 PRNG so a failure is reproducible without a // crates.io dependency (fix-round-1 finding 5: "use a fixed seed"). struct Rng(u64); impl Rng { fn next_u64(&mut self) -> u64 { let mut x = self.0; x ^= x << 13; x ^= x >> 7; x ^= x << 17; self.0 = x; x } fn next_usize(&mut self, bound: usize) -> usize { (self.next_u64() % bound as u64) as usize } } fn mutate(rng: &mut Rng, base: &[u8]) -> Vec { let mut data = base.to_vec(); let n_flips = 1 + rng.next_usize(3); // 1..=3 bytes, per finding 5 for _ in 0..n_flips { if data.is_empty() { break; } let idx = rng.next_usize(data.len()); data[idx] ^= (1 + rng.next_usize(255)) as u8; } data } /// The actual regression test: a wasm trap (or fuel exhaustion — see the /// module doc comment on the hang finding) on one document's `apply` must /// not affect any other document in the same instance — neither its /// existence (`text_len` still answers) nor its *content* (`text_read` /// still returns exactly what it was seeded with, fix-round-2 finding A) /// — and must not stop the instance from creating new documents afterward. /// /// Mutation-fuzzes the real fixtures (never edits them) with a fixed seed /// (`0xC0FFEE`) over a bounded 1500 trials — enough to reliably reproduce /// both traps and the pathologically-slow input documented above, while /// finishing in well under a minute. Some inputs are expected to fail (as /// a trap or a fuel cutoff) — that is upstream `yrs` behaviour on malformed /// wire bytes and not something this crate can fix. What this test asserts /// is the containment property this crate *can* guarantee: every trial's /// outcome — success, a clean negative error code, or a contained failure — /// never damages any other document's existence or content, and /// `docs_core_doc_new` keeps working afterward. #[test] fn trap_on_one_document_does_not_corrupt_others_or_wedge_the_instance() { let wasm = build_wasm(); let mut h = Harness::new(&wasm); let fixtures = fixtures(); // Three "control" documents, representing other live documents in the // same instance, each seeded with real, valid content up front. Each // carries its *expected plain-text content*, not just its raw update // bytes, so every trial can check content, not just liveness. let mut controls = Vec::new(); for (i, (name, bytes)) in fixtures.iter().enumerate() { let handle = h .doc_new(&format!("control-{name}"), 1000 + i as u64) .expect("doc_new for a control document must not trap"); assert_ne!(handle, 0); let (result, fuel) = h.apply(handle, bytes); let rc = result.expect("applying a known-good fixture must not trap"); assert_eq!( rc, 0, "control document {name} failed to apply its own fixture" ); eprintln!("control {name}: fuel consumed = {fuel}"); let expected = expected_text(name); let got = h .read_text(handle) .expect("reading a freshly-seeded control must not trap"); assert_eq!( got, expected.as_bytes(), "control document {name} content wrong immediately after seeding" ); controls.push((name, handle, expected)); } let mut rng = Rng(0xC0FFEE_u64); const TRIALS: u32 = 1500; let mut oks = 0u32; let mut clean_errors = 0u32; let mut traps = 0u32; let mut out_of_fuel = 0u32; let mut trapped_inputs: Vec<(String, usize, String, String)> = Vec::new(); for i in 0..TRIALS { let (name, base) = &fixtures[rng.next_usize(fixtures.len())]; let mutated = mutate(&mut rng, base); let handle = h .doc_new(&format!("trial-{i}"), 2_000_000 + i as u64) .unwrap_or_else(|e| { panic!("doc_new must keep working after {traps} prior trap(s) (trial {i}): {e}") }); assert_ne!( handle, 0, "doc_new returned a null handle at trial {i} after {traps} prior trap(s)" ); let (result, _fuel) = h.apply(handle, &mutated); match result { Ok(0) => oks += 1, Ok(rc) => { assert!( rc < 0, "apply returned a positive non-zero code {rc} at trial {i}" ); clean_errors += 1; } Err(e) => { traps += 1; let code = e .as_trap_code() .map(|c| format!("{c:?}")) .unwrap_or_else(|| "non-trap error".to_string()); if code == "OutOfFuel" { out_of_fuel += 1; } let hex: String = mutated.iter().map(|b| format!("{b:02x}")).collect(); trapped_inputs.push((name.to_string(), mutated.len(), code, hex)); } } // The containment check: whatever just happened to `handle`, every // control document must still exist AND still hold exactly the // content it was seeded with (fix-round-2 finding A — a length // check alone would miss silent content corruption). for (cname, chandle, cexpected) in &controls { let len = h.text_len(*chandle).unwrap_or_else(|e| { panic!( "control document {cname} became unreachable after trial {i} \ (fixture {name}, {} mutated bytes): {e}", mutated.len() ) }); assert!( len >= 0, "control document {cname} lost after trial {i} (fixture {name}): \ text_len returned {len} (ERR_NO_DOC)" ); let got = h.read_text(*chandle).unwrap_or_else(|e| { panic!( "control document {cname} text_read trapped after trial {i} \ (fixture {name}, {} mutated bytes): {e}", mutated.len() ) }); assert_eq!( got, cexpected.as_bytes(), "control document {cname} CONTENT CORRUPTED after trial {i} \ (fixture {name}, {} mutated bytes): expected {cexpected:?}, got {:?}", mutated.len(), String::from_utf8_lossy(&got), ); } } let n_fixtures = fixtures.len(); eprintln!( "wasm_trap_containment: {TRIALS} trials over {n_fixtures} fixtures -> \ oks={oks} clean_errors={clean_errors} traps={traps} (of which out_of_fuel={out_of_fuel})" ); if !trapped_inputs.is_empty() { eprintln!("trapped/cutoff inputs (fixture, mutated byte length, trap code, hex):"); for (name, len, code, hex) in &trapped_inputs { eprintln!(" {name}: {len} bytes, {code}, {hex}"); } } assert_eq!( oks + clean_errors + traps, TRIALS, "every trial must be accounted for as ok, clean error, or contained failure" ); // Final check: the instance is still fully usable after however many // traps/cutoffs occurred, for a document that never existed before. let final_handle = h .doc_new("final-handle-after-fuzz", 999_999) .expect("doc_new must still work after the whole fuzz run"); assert_ne!(final_handle, 0); let (_, hello) = fixtures.iter().find(|(n, _)| *n == "hello").unwrap(); let (result, _fuel) = h.apply(final_handle, hello); let rc = result.expect("a fresh document must still apply a good update after the fuzz run"); assert_eq!(rc, 0); } /// Focused, named regression test for fix-round-1 finding 1 (the leaked /// `RefCell` borrow), using a confirmed-trapping mutation of the `unicode` /// fixture, found by the fuzz test above: /// `01010100040107636f6e74656e741168c3a96c6c6f20f09f8c8d206361ffc3a900` /// (3 bytes flipped from the real fixture), which reliably yields /// `TrapCode(MemoryOutOfBounds)`. Kept separate from the broader fuzz so /// this specific, named repro can't silently stop being exercised if the /// fuzz's RNG sequence or trial count ever changes. /// /// Fix-round-2 finding B: an earlier version of this test used the /// coordinator's originally-named byte flip instead — `unicode`, byte 16, /// `c3` -> `00` (corrupting the `é` in "héllo"). That flip does **not** /// itself trap through `docs_core_apply` in this build (verified in both /// fix-round-1's native and wasm32 testing: the invalid bytes are simply /// accepted by `yrs`'s unchecked UTF-8 decode and applied without /// complaint), so a test built on it passed unchanged even with the /// fix-round-1 bug reintroduced — it guarded nothing, despite its name. /// This version uses the mutation above instead, which does genuinely /// trap, so reintroducing the bug actually fails this test (verified — see /// the fix-round-2 report). #[test] fn borrow_leak_repro_trapping_unicode_mutation() { let wasm = build_wasm(); let mut h = Harness::new(&wasm); let fixtures = fixtures(); let hello = &fixtures.iter().find(|(n, _)| *n == "hello").unwrap().1; let hello_expected = expected_text("hello"); let trapping: Vec = { let hex = "01010100040107636f6e74656e741168c3a96c6c6f20f09f8c8d206361ffc3a900"; (0..hex.len()) .step_by(2) .map(|i| u8::from_str_radix(&hex[i..i + 2], 16).unwrap()) .collect() }; // A live, unrelated control document, created *before* the corrupted // apply, standing in for "other documents in the instance" — checked // for content, not just existence. let control = h .doc_new("control-before-corruption", 42) .expect("doc_new must not trap"); let (control_result, _) = h.apply(control, hello); assert_eq!(control_result.unwrap(), 0); let control_content = h .read_text(control) .expect("reading the freshly-seeded control"); assert_eq!(control_content, hello_expected.as_bytes()); let handle = h .doc_new("corrupted-unicode", 7) .expect("doc_new must not trap"); assert_ne!(handle, 0); let (result, _fuel) = h.apply(handle, &trapping); eprintln!("borrow_leak_repro: apply(trapping mutation) = {result:?}"); assert!( result.is_err(), "this mutation is expected to trap (TrapCode::MemoryOutOfBounds); got {result:?} instead \ — if this ever starts succeeding, the mutation stopped being a valid repro and needs replacing" ); // Whatever just happened, the pre-existing control document must be // unaffected — both still there AND still holding its original // content, not just answering text_len non-negatively. let control_len = h .text_len(control) .expect("control document must survive the corrupted apply"); assert!(control_len >= 0); let control_content_after = h .read_text(control) .expect("control document must still be readable after the corrupted apply"); assert_eq!( control_content_after, hello_expected.as_bytes(), "control document content changed after the trapping apply to a different document" ); // ...and doc_new must still work afterward — the exact property // fix-round-1 finding 1 was about (a leaked RefCell borrow would make // every subsequent doc_new fail). let after = h .doc_new("after-corruption", 99) .expect("doc_new must keep working after the corrupted apply"); assert_ne!( after, 0, "doc_new returned a null handle after the corrupted apply" ); let (after_result, _) = h.apply(after, hello); assert_eq!( after_result.expect("a brand-new document must still apply a good update"), 0 ); }