| 1 | use std::fs; |
| 2 | use std::path::PathBuf; |
| 3 | |
| 4 | use serde_json::{json, Value}; |
| 5 | use yrs::types::text::YChange; |
| 6 | use yrs::types::Attrs; |
| 7 | use yrs::{Any, Doc, Options, Text, Transact}; |
| 8 | |
| 9 | fn fixture(name: &str) -> (Vec<u8>, String, Value) { |
| 10 | let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")) |
| 11 | .parent() |
| 12 | .unwrap() |
| 13 | .join("fixtures/data"); |
| 14 | let update = fs::read(dir.join(format!("{name}.update.bin"))) |
| 15 | .unwrap_or_else(|e| panic!("missing fixture {name}.update.bin: {e}")); |
| 16 | let expected: Value = serde_json::from_str( |
| 17 | &fs::read_to_string(dir.join(format!("{name}.expected.json"))).unwrap(), |
| 18 | ) |
| 19 | .unwrap(); |
| 20 | let text = expected["text"].as_str().unwrap().to_string(); |
| 21 | let delta = expected["delta"].clone(); |
| 22 | (update, text, delta) |
| 23 | } |
| 24 | |
| 25 | /// Converts a `yrs::Any` into the equivalent `serde_json::Value`, so an |
| 26 | /// attribute map read off the CRDT can be compared directly against the |
| 27 | /// `expected.json` fixture written by real JS `yjs`. |
| 28 | fn any_to_json(any: &Any) -> Value { |
| 29 | match any { |
| 30 | Any::Null => Value::Null, |
| 31 | Any::Bool(b) => Value::Bool(*b), |
| 32 | Any::Number(n) => json!(n), |
| 33 | Any::BigInt(n) => json!(n), |
| 34 | Any::String(s) => Value::String(s.to_string()), |
| 35 | // A Yjs delta's `attributes` map comes from JSON-compatible formatting |
| 36 | // marks (bold: true, and similar) — never `undefined` or a binary |
| 37 | // buffer. Folding either into `Value::Null` would let a future |
| 38 | // fixture with one of these silently compare equal to a bare |
| 39 | // `null` attribute value instead of failing loudly on a shape this |
| 40 | // test doesn't know how to compare. |
| 41 | Any::Undefined => unreachable!( |
| 42 | "Any::Undefined in a delta attribute — not representable in the fixture JSON" |
| 43 | ), |
| 44 | Any::Buffer(_) => { |
| 45 | unreachable!("Any::Buffer in a delta attribute — not representable in the fixture JSON") |
| 46 | } |
| 47 | Any::Array(items) => Value::Array(items.iter().map(any_to_json).collect()), |
| 48 | Any::Map(map) => { |
| 49 | let mut entries: Vec<(&String, &Any)> = map.iter().collect(); |
| 50 | entries.sort_by(|a, b| a.0.cmp(b.0)); |
| 51 | let mut obj = serde_json::Map::new(); |
| 52 | for (k, v) in entries { |
| 53 | obj.insert(k.clone(), any_to_json(v)); |
| 54 | } |
| 55 | Value::Object(obj) |
| 56 | } |
| 57 | } |
| 58 | } |
| 59 | |
| 60 | /// Converts an `Attrs` map (`HashMap<Arc<str>, Any>`) into a `serde_json::Value` |
| 61 | /// object, with keys sorted so the comparison isn't flaky on `HashMap` |
| 62 | /// iteration order. |
| 63 | fn attrs_to_json(attrs: &Attrs) -> Value { |
| 64 | let mut entries: Vec<(&std::sync::Arc<str>, &Any)> = attrs.iter().collect(); |
| 65 | entries.sort_by(|a, b| a.0.cmp(b.0)); |
| 66 | let mut obj = serde_json::Map::new(); |
| 67 | for (k, v) in entries { |
| 68 | obj.insert(k.to_string(), any_to_json(v)); |
| 69 | } |
| 70 | Value::Object(obj) |
| 71 | } |
| 72 | |
| 73 | /// Produces the same attributed-run shape as Yjs's `toDelta()`, so the |
| 74 | /// fixture's `delta` field (which is what actually proves formatting round- |
| 75 | /// trips, not just plain text) can be compared directly. |
| 76 | fn delta_of(doc: &Doc) -> Value { |
| 77 | let text = doc.get_or_insert_text("content"); |
| 78 | let txn = doc.transact(); |
| 79 | let diffs = text.diff(&txn, YChange::identity); |
| 80 | let runs: Vec<Value> = diffs |
| 81 | .into_iter() |
| 82 | .map(|diff| { |
| 83 | let insert = diff.insert.to_string(&txn); |
| 84 | let mut obj = serde_json::Map::new(); |
| 85 | obj.insert("insert".to_string(), Value::String(insert)); |
| 86 | if let Some(attrs) = diff.attributes { |
| 87 | obj.insert("attributes".to_string(), attrs_to_json(&attrs)); |
| 88 | } |
| 89 | Value::Object(obj) |
| 90 | }) |
| 91 | .collect(); |
| 92 | Value::Array(runs) |
| 93 | } |
| 94 | |
| 95 | fn check(name: &str) { |
| 96 | let (update, expected_text, expected_delta) = fixture(name); |
| 97 | // Never yrs::Doc::new() (draws a random client id) or |
| 98 | // yrs::Doc::with_client_id() (still draws a random GUID) — both reach |
| 99 | // fastrand entropy that this crate's custom getrandom hook refuses to |
| 100 | // fabricate. Supply both the client id and the GUID explicitly via |
| 101 | // Options::with_guid_and_client_id instead; this is the construction |
| 102 | // docs-core itself must use everywhere (see core/src/lib.rs and |
| 103 | // clippy.toml, which bans the other two at the source level). |
| 104 | let guid: yrs::Uuid = "wire-compat-test-guid".into(); |
| 105 | let doc = Doc::with_options(Options::with_guid_and_client_id(guid, 9001)); |
| 106 | docs_core::apply_update(&doc, &update).expect("apply failed"); |
| 107 | assert_eq!( |
| 108 | docs_core::text_content(&doc), |
| 109 | expected_text, |
| 110 | "fixture {name} text" |
| 111 | ); |
| 112 | assert_eq!(delta_of(&doc), expected_delta, "fixture {name} delta"); |
| 113 | } |
| 114 | |
| 115 | #[test] |
| 116 | fn applies_hello() { |
| 117 | check("hello"); |
| 118 | } |
| 119 | |
| 120 | #[test] |
| 121 | fn applies_unicode() { |
| 122 | check("unicode"); |
| 123 | } |
| 124 | |
| 125 | #[test] |
| 126 | fn applies_formatted() { |
| 127 | check("formatted"); |
| 128 | } |
| 129 | |
| 130 | #[test] |
| 131 | fn applies_concurrent() { |
| 132 | check("concurrent"); |
| 133 | } |
| 134 | |
| 135 | // Note: the brief's Step 1 listing calls `docs_core::abi::doc_new()` with no |
| 136 | // arguments and destructures `fixture()` into a 2-tuple. Neither matches |
| 137 | // what's actually in this file: `doc_new` takes the GUID and client id from |
| 138 | // the host (amendment 1 — never drawn from `yrs`, see core/src/lib.rs), and |
| 139 | // `fixture()` above returns a 3-tuple (update, text, delta). Adjusted here |
| 140 | // to match; the round-trip and error-code behaviour the brief cares about |
| 141 | // are unchanged. |
| 142 | // |
| 143 | // Fix round 1 on the fragment-migration task: this used to apply the |
| 144 | // "unicode" fixture (a bare Y.Text on root "content", fixtures/data) and |
| 145 | // compare against its plain text. That stopped being what this ABI call |
| 146 | // exercises the moment `abi::text_string` was repointed to read |
| 147 | // `fragment_text` (root "prosemirror") instead of `text_content` — see |
| 148 | // that function's own doc comment. A Y.Text-only fixture round-trips |
| 149 | // through `apply_slice` fine (the update decodes and applies regardless |
| 150 | // of which root it targets) but reads back as "" through `text_string` |
| 151 | // now, which would make this test assert the wrong thing quietly. The |
| 152 | // fixture is a fragment one instead (`fixtures/vectors/fragment/simple.bin` |
| 153 | // — the same vectors `core/tests/fragment.rs` tests `fragment_text`/ |
| 154 | // `fragment_html` against directly), so this still proves what it always |
| 155 | // proved: a real Yjs update, applied through the wasm ABI exactly as the |
| 156 | // Go host calls it, reads back through that same ABI as the text a |
| 157 | // document actually holds. |
| 158 | #[test] |
| 159 | fn abi_round_trips_a_fixture() { |
| 160 | let update = fs::read( |
| 161 | PathBuf::from(env!("CARGO_MANIFEST_DIR")) |
| 162 | .parent() |
| 163 | .unwrap() |
| 164 | .join("fixtures/vectors/fragment/simple.bin"), |
| 165 | ) |
| 166 | .expect("missing fixtures/vectors/fragment/simple.bin"); |
| 167 | let expected = "First paragraph.\n\nSecond paragraph."; |
| 168 | |
| 169 | let handle = docs_core::abi::doc_new("abi-round-trips-a-fixture-guid", 42); |
| 170 | assert_ne!(handle, 0, "doc_new returned a null handle"); |
| 171 | |
| 172 | let rc = docs_core::abi::apply_slice(handle, &update); |
| 173 | assert_eq!(rc, 0, "apply returned {rc}"); |
| 174 | |
| 175 | // text_string returns None for an unknown handle (fix-round-1 finding |
| 176 | // 3) and Some(String) for a live one; this handle was just created, so |
| 177 | // unwrap is appropriate here. |
| 178 | let got = docs_core::abi::text_string(handle).expect("handle was just created"); |
| 179 | assert_eq!(got, expected); |
| 180 | |
| 181 | docs_core::abi::doc_free(handle); |
| 182 | } |
| 183 | |
| 184 | // A single, harmless, non-UB-triggering piece of garbage: `0xff` repeated |
| 185 | // is simply not a valid Yjs v1 update header, so `apply_update` rejects it |
| 186 | // during ordinary decoding — a clean negative error code, exercised here |
| 187 | // as a fast sanity check with no wasm runtime required. |
| 188 | #[test] |
| 189 | fn abi_rejects_garbage_without_panicking() { |
| 190 | let handle = docs_core::abi::doc_new("abi-rejects-garbage-guid", 43); |
| 191 | let rc = docs_core::abi::apply_slice(handle, &[0xff, 0xff, 0xff, 0xff]); |
| 192 | assert!( |
| 193 | rc < 0, |
| 194 | "garbage input should return an error code, got {rc}" |
| 195 | ); |
| 196 | docs_core::abi::doc_free(handle); |
| 197 | } |
| 198 | |
| 199 | // Fix-round-1 finding 5's actual mutation fuzz over the real fixtures |
| 200 | // (flip 1-3 bytes, fixed seed, assert containment) lives in |
| 201 | // core/tests/wasm_trap_containment.rs, not here. Reason: some mutated |
| 202 | // inputs reach real undefined behaviour deep in `yrs`'s wire decode (see |
| 203 | // core/src/abi.rs's module doc comment), and under *native* execution |
| 204 | // (this file, plain `cargo test`) that UB is not sandboxed the way wasm |
| 205 | // linear memory is — it can bring down the whole native test process with |
| 206 | // an uncatchable abort (verified empirically while building this fix: a |
| 207 | // native-only version of this fuzz hit "thread caused non-unwinding |
| 208 | // panic. aborting." within a few thousand random 1-3 byte flips, which is |
| 209 | // not something `catch_unwind` can contain). The actual *deployment* |
| 210 | // target (wasm32, called through a wasm runtime) does not have this |
| 211 | // problem — a wasm trap is contained to the single host call, which is |
| 212 | // exactly the property finding 1 and 5 need proven. So the mutation fuzz |
| 213 | // runs the real compiled wasm module under `wasmi` instead, where traps |
| 214 | // are always safely observable as an `Err` rather than a process crash. |
| 215 | |