core: fix-round-2 — check content, not just liveness, in trap containment fuzz
Independent review verified all six fix-round-1 findings, then found the containment fuzz's per-trial check only asserted a control document's text_len was non-negative, never reading its content back. Silent content corruption of another document would have passed unnoticed — docs_core_text_read wasn't even bound in the test harness. Bind docs_core_text_read (Harness::read_text), load each control's expected plain text from its fixture JSON, and compare byte-for-byte after every trial, not just check the document still answers text_len. Proved the strengthened check by reintroducing the fix-round-1 borrow leak (as the reviewer did) and confirming the fuzz still fails, at the same trial 10 the reviewer found. That failure is via the pre-existing liveness/wedge assertion, not the new content check, because a leaked RefCell borrow on the handle map can't reach into an already-live Doc's own content — recorded honestly rather than left unremarked. Separately proved the content assertion itself fires correctly on real divergence via a throwaway harness-side fault injection (not committed): with a control's expected text deliberately corrupted, the assertion fails immediately with the exact document/trial/expected-vs-actual bytes. Also: borrow_leak_repro_unicode_c3a9_to_00a9 used an input that never actually traps in this build, so it passed unchanged even with the bug reintroduced — switched to a confirmed-trapping mutation and renamed to match, with an explicit assert that it still traps. And corrected the APPLY_FUEL_BUDGET comment's headroom claim (100x) to the measured figure (~4,500x against the highest observed control cost).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011F5CivVm4CmL9ByfSSAaXA
1 file changed,
+167
−44
core/tests/wasm_trap_containment.rs+167 −44
diff --git a/core/tests/wasm_trap_containment.rs b/core/tests/wasm_trap_containment.rs| index 151e9f4..3750fa3 100644 |
| --- a/core/tests/wasm_trap_containment.rs |
| +++ b/core/tests/wasm_trap_containment.rs |
| @@ -56,6 +56,17 @@ |
| //! 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` |
| @@ -66,10 +77,11 @@ use std::process::Command; |
| use wasmi::{Config, Engine, Instance, Linker, Memory, Module, Store, TypedFunc}; |
| -/// Fuel budget for a single `docs_core_apply` call. Calibrated against this |
| -/// test's own control-document applies (real, valid fixtures), which |
| -/// consume on the order of a few hundred thousand fuel units each — this |
| -/// budget gives roughly 100x headroom above that, while stopping the |
| +/// 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; |
| @@ -82,6 +94,7 @@ struct Harness { |
| doc_new: TypedFunc<(u32, u32, u64), u32>, |
| apply: TypedFunc<(u32, u32, u32), i32>, |
| text_len: TypedFunc<u32, i32>, |
| + text_read: TypedFunc<(u32, u32, u32), i32>, |
| } |
| impl Harness { |
| @@ -122,6 +135,9 @@ impl Harness { |
| let text_len = instance |
| .get_typed_func::<u32, i32>(&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, |
| @@ -130,6 +146,7 @@ impl Harness { |
| doc_new, |
| apply, |
| text_len, |
| + text_read, |
| } |
| } |
| @@ -173,7 +190,7 @@ impl Harness { |
| 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, |
| - // free) that aren't under test here. |
| + // 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) { |
| @@ -189,6 +206,43 @@ impl Harness { |
| fn text_len(&mut self, handle: u32) -> Result<i32, wasmi::Error> { |
| 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<Vec<u8>, 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 |
| @@ -217,11 +271,15 @@ fn build_wasm() -> Vec<u8> { |
| std::fs::read(&artifact).unwrap_or_else(|e| panic!("reading {artifact:?}: {e}")) |
| } |
| -fn fixtures() -> Vec<(&'static str, Vec<u8>)> { |
| - let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")) |
| +fn fixtures_dir() -> PathBuf { |
| + PathBuf::from(env!("CARGO_MANIFEST_DIR")) |
| .parent() |
| .unwrap() |
| - .join("fixtures/data"); |
| + .join("fixtures/data") |
| +} |
| + |
| +fn fixtures() -> Vec<(&'static str, Vec<u8>)> { |
| + let dir = fixtures_dir(); |
| ["hello", "unicode", "formatted", "concurrent"] |
| .iter() |
| .map(|name| { |
| @@ -231,6 +289,21 @@ fn fixtures() -> Vec<(&'static str, Vec<u8>)> { |
| .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); |
| @@ -263,8 +336,10 @@ fn mutate(rng: &mut Rng, base: &[u8]) -> Vec<u8> { |
| /// 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, and must not stop |
| -/// the instance from creating new documents afterward. |
| +/// 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 |
| @@ -274,8 +349,8 @@ fn mutate(rng: &mut Rng, base: &[u8]) -> Vec<u8> { |
| /// 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, and `docs_core_doc_new` keeps working |
| -/// afterward. |
| +/// 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(); |
| @@ -283,7 +358,9 @@ fn trap_on_one_document_does_not_corrupt_others_or_wedge_the_instance() { |
| let fixtures = fixtures(); |
| // Three "control" documents, representing other live documents in the |
| - // same instance, each seeded with real, valid content up front. |
| + // 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 |
| @@ -294,7 +371,14 @@ fn trap_on_one_document_does_not_corrupt_others_or_wedge_the_instance() { |
| 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}"); |
| - controls.push((name, handle, bytes.clone())); |
| + 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); |
| @@ -341,8 +425,10 @@ fn trap_on_one_document_does_not_corrupt_others_or_wedge_the_instance() { |
| } |
| // The containment check: whatever just happened to `handle`, every |
| - // control document must still be fully intact. |
| - for (cname, chandle, cbytes) in &controls { |
| + // 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} \ |
| @@ -355,7 +441,21 @@ fn trap_on_one_document_does_not_corrupt_others_or_wedge_the_instance() { |
| "control document {cname} lost after trial {i} (fixture {name}): \ |
| text_len returned {len} (ERR_NO_DOC)" |
| ); |
| - let _ = cbytes; // identity already proven at seed time; length staying >=0 is the liveness check here |
| + 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), |
| + ); |
| } |
| } |
| @@ -389,55 +489,78 @@ fn trap_on_one_document_does_not_corrupt_others_or_wedge_the_instance() { |
| assert_eq!(rc, 0); |
| } |
| -/// Focused regression test for the exact scenario the fix-round-1 review |
| -/// reported: the `unicode` fixture with byte 16 flipped `c3` -> `00` |
| -/// (corrupting the `é` in "héllo" to a bare NUL followed by a dangling |
| -/// UTF-8 continuation byte). Kept separate from the broader fuzz above so |
| +/// 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. |
| /// |
| -/// Empirically (see the fix-round-1 report) this exact single-byte flip |
| -/// does not itself trap through `docs_core_apply` — the invalid bytes are |
| +/// 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, only becoming a hazard if something downstream inspects the |
| -/// string char-by-char (a Rust `Debug`-formats it, for instance). It is |
| -/// included here anyway, under the same harness as every other trial in |
| -/// this file, as the coordinator-specified named repro: whatever the |
| -/// current build does with it, the containment property must hold. |
| +/// 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_unicode_c3a9_to_00a9() { |
| +fn borrow_leak_repro_trapping_unicode_mutation() { |
| let wasm = build_wasm(); |
| let mut h = Harness::new(&wasm); |
| let fixtures = fixtures(); |
| - let (_, unicode_bytes) = fixtures.iter().find(|(n, _)| *n == "unicode").unwrap(); |
| + let hello = &fixtures.iter().find(|(n, _)| *n == "hello").unwrap().1; |
| + let hello_expected = expected_text("hello"); |
| - let mut corrupted = unicode_bytes.clone(); |
| - assert_eq!( |
| - (corrupted[16], corrupted[17]), |
| - (0xc3, 0xa9), |
| - "fixture layout changed; byte 16..18 is no longer the targeted c3 a9" |
| - ); |
| - corrupted[16] = 0x00; |
| + let trapping: Vec<u8> = { |
| + 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." |
| + // 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, &fixtures.iter().find(|(n, _)| *n == "hello").unwrap().1); |
| + 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, &corrupted); |
| - eprintln!("borrow_leak_repro: apply(corrupted c3->00) = {result:?}"); |
| + 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 (trap or clean result), the pre-existing |
| - // control document must be unaffected... |
| + // 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 |
| @@ -446,7 +569,7 @@ fn borrow_leak_repro_unicode_c3a9_to_00a9() { |
| .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, &fixtures.iter().find(|(n, _)| *n == "hello").unwrap().1); |
| + let (after_result, _) = h.apply(after, hello); |
| assert_eq!( |
| after_result.expect("a brand-new document must still apply a good update"), |
| 0 |