oficina / docs Public

Clone
git clone https://amadan.net/oficina/docs

Plain git — no account needed to clone.

Download

Download this file

1//! Runs the *actual compiled wasm32 module* (not the native `docs_core::abi`
2//! functions directly) under `wasmi`, a pure-Rust wasm interpreter, and
3//! proves the two properties fix-round-1's review demanded for finding 1:
4//!
5//! 1. A wasm trap inside `docs_core_apply` for one document (a corrupted
6//! update reaching the undefined behaviour documented in
7//! `core/src/abi.rs`'s module doc comment) must not corrupt any *other*
8//! document already alive in the same module instance.
9//! 2. It must not leave the instance permanently unable to create new
10//! documents (the `panic_already_borrowed` cascade fix-round-1 found:
11//! a `RefCell` borrow held across a call that traps never gets its
12//! `Drop` run, because a wasm trap — `unreachable` under this crate's
13//! `panic = "abort"` release profile — does not unwind).
14//!
15//! Why `wasmi` and not just native `cargo test` calls into `docs_core::abi`
16//! directly: a wasm trap is *contained to the single host call* by any
17//! conforming wasm runtime (wasmi here; wazero in production) — the
18//! embedding process keeps running and the instance's linear memory
19//! persists exactly as it was at the moment of the trap, which is the
20//! only way to observe whether *other* state in that memory (like our
21//! `RefCell`'s borrow flag) got corrupted. A native panic in a `cargo
22//! test` process is a different, less faithful animal: with the default
23//! unwind panic strategy Rust runs destructors as it unwinds (so a
24//! same-process repro would *not* show the leak this module actually has
25//! in the shipped wasm32 `panic = "abort"` build), and for inputs that
26//! reach genuine memory-safety UB, native execution is not sandboxed the
27//! way wasm linear memory is, so it can just as easily produce a hard
28//! process abort that proves nothing about containment (empirically: an
29//! earlier native-only version of this fuzz found an input that took down
30//! the entire native test binary with "thread caused non-unwinding panic.
31//! aborting" — see the fix-round-1 report). `wasmi`'s sandboxed execution
32//! model is what actually matches wazero's, so it is what this test uses.
33//!
34//! # A second, distinct finding surfaced while building this test: hangs
35//!
36//! Fuzzing this way (before fuel metering was added below) turned up
37//! inputs that are not traps at all: `docs_core_apply` on a 3-byte-flipped
38//! `concurrent` fixture (`02010200040107636f6e6f656e7403424293010100040107
39//! 636f6e74656e74034141418a`, seed `0xC0FFEE`, trial 1133 of this file's
40//! RNG sequence) ran for **163 seconds** of real CPU time before returning
41//! an ordinary `Ok` — not an infinite loop (confirmed: it does terminate),
42//! but pathologically slow, reproducibly, deterministically, every time.
43//! This is a *third* category alongside "succeeds" and "traps": malformed
44//! input reaching a slow path deep in `yrs`'s decode/merge logic — plausibly
45//! a corrupted length/count field being reinterpreted as a huge value. This
46//! is upstream, in the same `yrs` decode territory as the UB findings this
47//! module's doc comment describes, and not something `core/src/abi.rs` can
48//! fix. It is, however, a real resource-exhaustion vector worth flagging
49//! for whoever configures the production wazero host (Task 6): a single
50//! ~36-byte corrupted update can tie up a call for minutes of CPU time.
51//!
52//! Below, `Harness::apply` uses `wasmi`'s fuel metering to bound each call
53//! to a fixed instruction budget, so this fuzz test treats "ran out of
54//! fuel" the same as a trap (both are "did not return cleanly") rather
55//! than actually blocking for minutes per pathological input — the
56//! containment property under test (no cross-document damage, instance
57//! still usable) applies equally to both.
58//!
59//! # Fix-round-2: liveness was checked, not content (finding A)
60//!
61//! Fix-round-1's per-trial check only asserted `text_len(control) >= 0` —
62//! that the control document still *exists* — and never read its content
63//! back at all, so silent content corruption of another document (one of
64//! the two failure modes this test exists to catch) would have passed
65//! silently. `docs_core_text_read` is now bound in the `Harness`
66//! (`read_text`), and every trial compares each control document's full
67//! text content byte-for-byte against the value it was seeded with, not
68//! just checking that a length comes back non-negative.
69//!
70//! This test builds `core`'s wasm32-unknown-unknown release artifact
71//! itself (via `cargo build`) so it always exercises current source, not a
72//! possibly-stale artifact left over from a previous `scripts/build-wasm.sh`
73//! run.
74
75use std::path::PathBuf;
76use std::process::Command;
77
78use wasmi::{Config, Engine, Instance, Linker, Memory, Module, Store, TypedFunc};
79
80/// Fuel budget for a single `docs_core_apply` call. Measured control-
81/// document applies (real, valid fixtures) consumed 21,978 / 21,989 /
82/// 44,281 / 31,382 fuel units (hello / unicode / formatted / concurrent —
83/// see this test's own `eprintln!` output). 200,000,000 is roughly 4,500x
84/// the highest of those (44,281, `formatted`), while stopping the
85/// 163-second pathological input (see the module doc comment) after a
86/// small fraction of a second instead.
87const APPLY_FUEL_BUDGET: u64 = 200_000_000;
88
89struct Harness {
90 store: Store<()>,
91 memory: Memory,
92 alloc: TypedFunc<u32, u32>,
93 free: TypedFunc<(u32, u32), ()>,
94 doc_new: TypedFunc<(u32, u32, u64), u32>,
95 apply: TypedFunc<(u32, u32, u32), i32>,
96 text_len: TypedFunc<u32, i32>,
97 text_read: TypedFunc<(u32, u32, u32), i32>,
98}
99
100impl Harness {
101 fn new(wasm_bytes: &[u8]) -> Self {
102 let mut config = Config::default();
103 config.consume_fuel(true);
104 let engine = Engine::new(&config);
105 let module = Module::new(&engine, wasm_bytes).expect("module should parse and validate");
106 let mut store = Store::new(&engine, ());
107 store
108 .set_fuel(u64::MAX / 2)
109 .expect("fuel metering is enabled");
110 // Zero imports (core/tests/no_host_imports.rs enforces this), so an
111 // empty Linker is enough to instantiate.
112 let linker = Linker::<()>::new(&engine);
113 let instance = linker
114 .instantiate(&mut store, &module)
115 .expect("instantiate")
116 .start(&mut store)
117 .expect("start (module has no start function requiring host calls)");
118 Self::from_instance(store, instance)
119 }
120
121 fn from_instance(store: Store<()>, instance: Instance) -> Self {
122 let memory = instance
123 .get_memory(&store, "memory")
124 .expect("module exports linear memory");
125 let alloc = instance
126 .get_typed_func::<u32, u32>(&store, "docs_core_alloc")
127 .expect("docs_core_alloc export");
128 let free = instance
129 .get_typed_func::<(u32, u32), ()>(&store, "docs_core_free")
130 .expect("docs_core_free export");
131 let doc_new = instance
132 .get_typed_func::<(u32, u32, u64), u32>(&store, "docs_core_doc_new")
133 .expect("docs_core_doc_new export");
134 let apply = instance
135 .get_typed_func::<(u32, u32, u32), i32>(&store, "docs_core_apply")
136 .expect("docs_core_apply export");
137 let text_len = instance
138 .get_typed_func::<u32, i32>(&store, "docs_core_text_len")
139 .expect("docs_core_text_len export");
140 let text_read = instance
141 .get_typed_func::<(u32, u32, u32), i32>(&store, "docs_core_text_read")
142 .expect("docs_core_text_read export");
143 Self {
144 store,
145 memory,
146 alloc,
147 free,
148 doc_new,
149 apply,
150 text_len,
151 text_read,
152 }
153 }
154
155 /// Writes `bytes` into freshly wasm-allocated memory. Returns the
156 /// pointer and length (the caller must `free_buf` it when done).
157 fn write_buf(&mut self, bytes: &[u8]) -> Result<(u32, u32), wasmi::Error> {
158 let len = bytes.len() as u32;
159 let ptr = self.alloc.call(&mut self.store, len)?;
160 assert_ne!(ptr, 0, "docs_core_alloc failed for {len} bytes");
161 self.memory
162 .write(&mut self.store, ptr as usize, bytes)
163 .expect("write within a just-allocated buffer cannot be out of bounds");
164 Ok((ptr, len))
165 }
166
167 fn free_buf(&mut self, ptr: u32, len: u32) -> Result<(), wasmi::Error> {
168 self.free.call(&mut self.store, (ptr, len))
169 }
170
171 fn doc_new(&mut self, guid: &str, client_id: u64) -> Result<u32, wasmi::Error> {
172 let (ptr, len) = self.write_buf(guid.as_bytes())?;
173 let handle = self.doc_new.call(&mut self.store, (ptr, len, client_id))?;
174 self.free_buf(ptr, len)?;
175 Ok(handle)
176 }
177
178 /// Applies `update` to `handle` with a bounded fuel budget
179 /// (`APPLY_FUEL_BUDGET`) so a pathologically slow input (see the module
180 /// doc comment) fails fast with `TrapCode::OutOfFuel` instead of
181 /// actually running for however long it would otherwise take. Returns
182 /// the fuel actually consumed alongside the call result, for
183 /// calibration/reporting.
184 fn apply(&mut self, handle: u32, update: &[u8]) -> (Result<i32, wasmi::Error>, u64) {
185 let (ptr, len) = match self.write_buf(update) {
186 Ok(pl) => pl,
187 Err(e) => return (Err(e), 0),
188 };
189 let before = self.store.get_fuel().expect("fuel metering is enabled");
190 self.store
191 .set_fuel(APPLY_FUEL_BUDGET)
192 .expect("fuel metering is enabled");
193 let result = self.apply.call(&mut self.store, (handle, ptr, len));
194 let after = self.store.get_fuel().unwrap_or(0);
195 let consumed = APPLY_FUEL_BUDGET.saturating_sub(after);
196 // Restore a large budget for bookkeeping calls (doc_new, text_len,
197 // text_read, free) that aren't under test here.
198 self.store.set_fuel(u64::MAX / 2).ok();
199 let _ = before;
200 if let Err(pl) = self.free_buf(ptr, len) {
201 // Freeing after a trapped/fuel-exhausted apply can itself only
202 // fail if the module's allocator state is now unreliable, in
203 // which case that failure is itself useful trap information,
204 // not something to hide.
205 return (Err(pl), consumed);
206 }
207 (result, consumed)
208 }
209
210 fn text_len(&mut self, handle: u32) -> Result<i32, wasmi::Error> {
211 self.text_len.call(&mut self.store, handle)
212 }
213
214 /// Reads `handle`'s full text content back out of wasm memory,
215 /// byte-for-byte: calls `docs_core_text_len` to size a buffer,
216 /// allocates it, calls `docs_core_text_read` into it, copies the
217 /// written bytes out, and frees the buffer. Fix-round-2 finding A:
218 /// added so the containment test can compare *content*, not just
219 /// whether a handle still answers `text_len` non-negatively.
220 ///
221 /// Panics (rather than returning a `Result`) on ERR_NO_DOC or a
222 /// length/read-count mismatch — those indicate a caller bug (reading a
223 /// handle that was never confirmed live) or an ABI contract violation
224 /// distinct from the traps this harness expects and handles via `Err`.
225 fn read_text(&mut self, handle: u32) -> Result<Vec<u8>, wasmi::Error> {
226 let len = self.text_len.call(&mut self.store, handle)?;
227 assert!(
228 len >= 0,
229 "read_text called on handle {handle} which text_len rejects: rc={len} (ERR_NO_DOC)"
230 );
231 let len = len as u32;
232 if len == 0 {
233 return Ok(Vec::new());
234 }
235 let out_ptr = self.alloc.call(&mut self.store, len)?;
236 assert_ne!(
237 out_ptr, 0,
238 "docs_core_alloc failed for {len} bytes (text_read buffer)"
239 );
240 let rc = self
241 .text_read
242 .call(&mut self.store, (handle, out_ptr, len))?;
243 assert_eq!(
244 rc, len as i32,
245 "docs_core_text_read returned {rc} bytes written, expected {len} \
246 (handle {handle} changed length between text_len and text_read?)"
247 );
248 let mut buf = vec![0u8; len as usize];
249 self.memory
250 .read(&mut self.store, out_ptr as usize, &mut buf)
251 .expect("read within a just-written buffer cannot be out of bounds");
252 self.free.call(&mut self.store, (out_ptr, len))?;
253 Ok(buf)
254 }
255}
256
257/// Builds `core`'s wasm32-unknown-unknown release artifact from current
258/// source and returns its bytes. Deliberately does not shell out to
259/// `scripts/build-wasm.sh` (which also runs clippy — slow, and irrelevant
260/// to this test) and instead runs just the `cargo build` step directly.
261fn build_wasm() -> Vec<u8> {
262 let core_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
263 let workspace_root = core_dir
264 .parent()
265 .expect("core/ always has a parent (the repo root)");
266 // Use the same canonical host and flags as the committed artifact. Rebuild
267 // current source, without recursively linting the test that invoked us.
268 let status = Command::new("bash")
269 .args(["scripts/build-wasm.sh", "--build-only"])
270 .current_dir(workspace_root)
271 .status()
272 .expect("failed to run canonical wasm builder");
273 assert!(status.success(), "canonical wasm build failed");
274 let artifact = core_dir.join("target/wasm32-unknown-unknown/release/docs_core.wasm");
275 std::fs::read(&artifact).unwrap_or_else(|e| panic!("reading {artifact:?}: {e}"))
276}
277
278// Fix round 1 on the fragment-migration task: this used to point at
279// fixtures/data (bare Y.Text on root "content"). It now points at
280// fixtures/vectors/fragment instead: `read_text` below goes through
281// the wasm ABI's docs_core_text_len/docs_core_text_read, which now
282// read fragment_text (root "prosemirror"), not text_content — see
283// core/src/abi.rs's text_string doc comment. A fixtures/data update
284// still applies cleanly through this same ABI (apply_slice decodes
285// and merges regardless of which root it targets), but reads back as
286// "" now, which would make every content check below compare against
287// the wrong thing silently. fixtures/generate-fragment.mjs's own
288// 'trap-containment vectors' section builds hello/unicode/formatted/
289// concurrent to match — same names, same content and merge
290// characteristics fixtures/data's originals have, wrapped in a single
291// <paragraph> each instead of a bare Y.Text, with a matching
292// expected.json alongside (that generator's plainTextOf mirrors
293// fragment_text, so the two agree on what "text" means).
294fn fixtures_dir() -> PathBuf {
295 PathBuf::from(env!("CARGO_MANIFEST_DIR"))
296 .parent()
297 .unwrap()
298 .join("fixtures/vectors/fragment")
299}
300
301fn fixtures() -> Vec<(&'static str, Vec<u8>)> {
302 let dir = fixtures_dir();
303 ["hello", "unicode", "formatted", "concurrent"]
304 .iter()
305 .map(|name| {
306 let bytes = std::fs::read(dir.join(format!("{name}.update.bin"))).unwrap();
307 (*name, bytes)
308 })
309 .collect()
310}
311
312/// The plain-text `text` field from a fixture's `{name}.expected.json` —
313/// the expected content of the root text type once `{name}.update.bin` is
314/// applied to a fresh document. Used to check control documents'
315/// *content*, not just that they still exist (fix-round-2 finding A).
316fn expected_text(name: &str) -> String {
317 let path = fixtures_dir().join(format!("{name}.expected.json"));
318 let raw = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("reading {path:?}: {e}"));
319 let json: serde_json::Value =
320 serde_json::from_str(&raw).unwrap_or_else(|e| panic!("parsing {path:?}: {e}"));
321 json["text"]
322 .as_str()
323 .unwrap_or_else(|| panic!("{path:?} has no string \"text\" field"))
324 .to_string()
325}
326
327// Deterministic xorshift64 PRNG so a failure is reproducible without a
328// crates.io dependency (fix-round-1 finding 5: "use a fixed seed").
329struct Rng(u64);
330impl Rng {
331 fn next_u64(&mut self) -> u64 {
332 let mut x = self.0;
333 x ^= x << 13;
334 x ^= x >> 7;
335 x ^= x << 17;
336 self.0 = x;
337 x
338 }
339 fn next_usize(&mut self, bound: usize) -> usize {
340 (self.next_u64() % bound as u64) as usize
341 }
342}
343
344fn mutate(rng: &mut Rng, base: &[u8]) -> Vec<u8> {
345 let mut data = base.to_vec();
346 let n_flips = 1 + rng.next_usize(3); // 1..=3 bytes, per finding 5
347 for _ in 0..n_flips {
348 if data.is_empty() {
349 break;
350 }
351 let idx = rng.next_usize(data.len());
352 data[idx] ^= (1 + rng.next_usize(255)) as u8;
353 }
354 data
355}
356
357/// The actual regression test: a wasm trap (or fuel exhaustion — see the
358/// module doc comment on the hang finding) on one document's `apply` must
359/// not affect any other document in the same instance — neither its
360/// existence (`text_len` still answers) nor its *content* (`text_read`
361/// still returns exactly what it was seeded with, fix-round-2 finding A)
362/// — and must not stop the instance from creating new documents afterward.
363///
364/// Mutation-fuzzes the real fixtures (never edits them) with a fixed seed
365/// (`0xC0FFEE`) over a bounded 1500 trials — enough to reliably reproduce
366/// both traps and the pathologically-slow input documented above, while
367/// finishing in well under a minute. Some inputs are expected to fail (as
368/// a trap or a fuel cutoff) — that is upstream `yrs` behaviour on malformed
369/// wire bytes and not something this crate can fix. What this test asserts
370/// is the containment property this crate *can* guarantee: every trial's
371/// outcome — success, a clean negative error code, or a contained failure —
372/// never damages any other document's existence or content, and
373/// `docs_core_doc_new` keeps working afterward.
374#[test]
375fn trap_on_one_document_does_not_corrupt_others_or_wedge_the_instance() {
376 let wasm = build_wasm();
377 let mut h = Harness::new(&wasm);
378 let fixtures = fixtures();
379
380 // Three "control" documents, representing other live documents in the
381 // same instance, each seeded with real, valid content up front. Each
382 // carries its *expected plain-text content*, not just its raw update
383 // bytes, so every trial can check content, not just liveness.
384 let mut controls = Vec::new();
385 for (i, (name, bytes)) in fixtures.iter().enumerate() {
386 let handle = h
387 .doc_new(&format!("control-{name}"), 1000 + i as u64)
388 .expect("doc_new for a control document must not trap");
389 assert_ne!(handle, 0);
390 let (result, fuel) = h.apply(handle, bytes);
391 let rc = result.expect("applying a known-good fixture must not trap");
392 assert_eq!(
393 rc, 0,
394 "control document {name} failed to apply its own fixture"
395 );
396 eprintln!("control {name}: fuel consumed = {fuel}");
397 let expected = expected_text(name);
398 let got = h
399 .read_text(handle)
400 .expect("reading a freshly-seeded control must not trap");
401 assert_eq!(
402 got,
403 expected.as_bytes(),
404 "control document {name} content wrong immediately after seeding"
405 );
406 controls.push((name, handle, expected));
407 }
408
409 let mut rng = Rng(0xC0FFEE_u64);
410 const TRIALS: u32 = 1500;
411 let mut oks = 0u32;
412 let mut clean_errors = 0u32;
413 let mut traps = 0u32;
414 let mut out_of_fuel = 0u32;
415 let mut trapped_inputs: Vec<(String, usize, String, String)> = Vec::new();
416
417 for i in 0..TRIALS {
418 let (name, base) = &fixtures[rng.next_usize(fixtures.len())];
419 let mutated = mutate(&mut rng, base);
420
421 let handle = h
422 .doc_new(&format!("trial-{i}"), 2_000_000 + i as u64)
423 .unwrap_or_else(|e| {
424 panic!("doc_new must keep working after {traps} prior trap(s) (trial {i}): {e}")
425 });
426 assert_ne!(
427 handle, 0,
428 "doc_new returned a null handle at trial {i} after {traps} prior trap(s)"
429 );
430
431 let (result, _fuel) = h.apply(handle, &mutated);
432 match result {
433 Ok(0) => oks += 1,
434 Ok(rc) => {
435 assert!(
436 rc < 0,
437 "apply returned a positive non-zero code {rc} at trial {i}"
438 );
439 clean_errors += 1;
440 }
441 Err(e) => {
442 traps += 1;
443 let code = e
444 .as_trap_code()
445 .map(|c| format!("{c:?}"))
446 .unwrap_or_else(|| "non-trap error".to_string());
447 if code == "OutOfFuel" {
448 out_of_fuel += 1;
449 }
450 let hex: String = mutated.iter().map(|b| format!("{b:02x}")).collect();
451 trapped_inputs.push((name.to_string(), mutated.len(), code, hex));
452 }
453 }
454
455 // The containment check: whatever just happened to `handle`, every
456 // control document must still exist AND still hold exactly the
457 // content it was seeded with (fix-round-2 finding A — a length
458 // check alone would miss silent content corruption).
459 for (cname, chandle, cexpected) in &controls {
460 let len = h.text_len(*chandle).unwrap_or_else(|e| {
461 panic!(
462 "control document {cname} became unreachable after trial {i} \
463 (fixture {name}, {} mutated bytes): {e}",
464 mutated.len()
465 )
466 });
467 assert!(
468 len >= 0,
469 "control document {cname} lost after trial {i} (fixture {name}): \
470 text_len returned {len} (ERR_NO_DOC)"
471 );
472 let got = h.read_text(*chandle).unwrap_or_else(|e| {
473 panic!(
474 "control document {cname} text_read trapped after trial {i} \
475 (fixture {name}, {} mutated bytes): {e}",
476 mutated.len()
477 )
478 });
479 assert_eq!(
480 got,
481 cexpected.as_bytes(),
482 "control document {cname} CONTENT CORRUPTED after trial {i} \
483 (fixture {name}, {} mutated bytes): expected {cexpected:?}, got {:?}",
484 mutated.len(),
485 String::from_utf8_lossy(&got),
486 );
487 }
488 }
489
490 let n_fixtures = fixtures.len();
491 eprintln!(
492 "wasm_trap_containment: {TRIALS} trials over {n_fixtures} fixtures -> \
493 oks={oks} clean_errors={clean_errors} traps={traps} (of which out_of_fuel={out_of_fuel})"
494 );
495 if !trapped_inputs.is_empty() {
496 eprintln!("trapped/cutoff inputs (fixture, mutated byte length, trap code, hex):");
497 for (name, len, code, hex) in &trapped_inputs {
498 eprintln!(" {name}: {len} bytes, {code}, {hex}");
499 }
500 }
501
502 assert_eq!(
503 oks + clean_errors + traps,
504 TRIALS,
505 "every trial must be accounted for as ok, clean error, or contained failure"
506 );
507
508 // Final check: the instance is still fully usable after however many
509 // traps/cutoffs occurred, for a document that never existed before.
510 let final_handle = h
511 .doc_new("final-handle-after-fuzz", 999_999)
512 .expect("doc_new must still work after the whole fuzz run");
513 assert_ne!(final_handle, 0);
514 let (_, hello) = fixtures.iter().find(|(n, _)| *n == "hello").unwrap();
515 let (result, _fuel) = h.apply(final_handle, hello);
516 let rc = result.expect("a fresh document must still apply a good update after the fuzz run");
517 assert_eq!(rc, 0);
518}
519
520/// Focused, named regression test for fix-round-1 finding 1 (the leaked
521/// `RefCell` borrow), using a confirmed-trapping mutation of the `unicode`
522/// fixture, found by the fuzz test above:
523/// `01010100040107636f6e74656e741168c3a96c6c6f20f09f8c8d206361ffc3a900`
524/// (3 bytes flipped from the real fixture), which reliably yields
525/// `TrapCode(MemoryOutOfBounds)`. Kept separate from the broader fuzz so
526/// this specific, named repro can't silently stop being exercised if the
527/// fuzz's RNG sequence or trial count ever changes.
528///
529/// Fix-round-2 finding B: an earlier version of this test used the
530/// coordinator's originally-named byte flip instead — `unicode`, byte 16,
531/// `c3` -> `00` (corrupting the `é` in "héllo"). That flip does **not**
532/// itself trap through `docs_core_apply` in this build (verified in both
533/// fix-round-1's native and wasm32 testing: the invalid bytes are simply
534/// accepted by `yrs`'s unchecked UTF-8 decode and applied without
535/// complaint), so a test built on it passed unchanged even with the
536/// fix-round-1 bug reintroduced — it guarded nothing, despite its name.
537/// This version uses the mutation above instead, which does genuinely
538/// trap, so reintroducing the bug actually fails this test (verified — see
539/// the fix-round-2 report).
540#[test]
541fn borrow_leak_repro_trapping_unicode_mutation() {
542 let wasm = build_wasm();
543 let mut h = Harness::new(&wasm);
544 let fixtures = fixtures();
545 let hello = &fixtures.iter().find(|(n, _)| *n == "hello").unwrap().1;
546 let hello_expected = expected_text("hello");
547
548 let trapping: Vec<u8> = {
549 let hex = "01010100040107636f6e74656e741168c3a96c6c6f20f09f8c8d206361ffc3a900";
550 (0..hex.len())
551 .step_by(2)
552 .map(|i| u8::from_str_radix(&hex[i..i + 2], 16).unwrap())
553 .collect()
554 };
555
556 // A live, unrelated control document, created *before* the corrupted
557 // apply, standing in for "other documents in the instance" — checked
558 // for content, not just existence.
559 let control = h
560 .doc_new("control-before-corruption", 42)
561 .expect("doc_new must not trap");
562 let (control_result, _) = h.apply(control, hello);
563 assert_eq!(control_result.unwrap(), 0);
564 let control_content = h
565 .read_text(control)
566 .expect("reading the freshly-seeded control");
567 assert_eq!(control_content, hello_expected.as_bytes());
568
569 let handle = h
570 .doc_new("corrupted-unicode", 7)
571 .expect("doc_new must not trap");
572 assert_ne!(handle, 0);
573 let (result, _fuel) = h.apply(handle, &trapping);
574 eprintln!("borrow_leak_repro: apply(trapping mutation) = {result:?}");
575 assert!(
576 result.is_err(),
577 "this mutation is expected to trap (TrapCode::MemoryOutOfBounds); got {result:?} instead \
578 — if this ever starts succeeding, the mutation stopped being a valid repro and needs replacing"
579 );
580
581 // Whatever just happened, the pre-existing control document must be
582 // unaffected — both still there AND still holding its original
583 // content, not just answering text_len non-negatively.
584 let control_len = h
585 .text_len(control)
586 .expect("control document must survive the corrupted apply");
587 assert!(control_len >= 0);
588 let control_content_after = h
589 .read_text(control)
590 .expect("control document must still be readable after the corrupted apply");
591 assert_eq!(
592 control_content_after,
593 hello_expected.as_bytes(),
594 "control document content changed after the trapping apply to a different document"
595 );
596
597 // ...and doc_new must still work afterward — the exact property
598 // fix-round-1 finding 1 was about (a leaked RefCell borrow would make
599 // every subsequent doc_new fail).
600 let after = h
601 .doc_new("after-corruption", 99)
602 .expect("doc_new must keep working after the corrupted apply");
603 assert_ne!(
604 after, 0,
605 "doc_new returned a null handle after the corrupted apply"
606 );
607 let (after_result, _) = h.apply(after, hello);
608 assert_eq!(
609 after_result.expect("a brand-new document must still apply a good update"),
610 0
611 );
612}
613