oficina / docs Public

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

Plain git — no account needed to clone.

Download

Download this file

1//! The C ABI the Go host calls through wazero. No wasm-bindgen: wazero
2//! provides no JS glue, so everything crosses as integers and linear-memory
3//! offsets.
4//!
5//! Handles are indices into a slab rather than raw pointers, so a stale
6//! handle from the host is a null lookup instead of a wild dereference.
7//!
8//! # Containment, after the fix-round-1 review
9//!
10//! §5.2's architecture is: each document gets its own wasm module instance,
11//! and a trapped instance is discarded and rebuilt from a snapshot by the
12//! host. That defence is necessarily architectural, not something this
13//! module can validate its way out of — `yrs` decodes wire bytes with
14//! `std::str::from_utf8_unchecked` in places (`encoding/read.rs:137`,
15//! `lib.rs:763`, `decoder.rs:480`), so a sufficiently corrupted update can
16//! reach real undefined behaviour deep inside the CRDT decode, and finding
17//! that requires actually parsing the update — which is `yrs`'s job, not
18//! ours to redo.
19//!
20//! What *is* this module's job is to not make an upstream trap worse than
21//! it has to be:
22//!
23//! - No `DOCS` `RefCell` borrow is held across a call into `yrs`
24//! (`apply_update`, `fragment_text`, `fragment_html`). A wasm trap does not unwind — it is
25//! compiled to an `unreachable` instruction with `panic = "abort"`
26//! (`Cargo.toml`), which does not run destructors — so a borrow guard
27//! still on the stack at the moment of a trap never gets dropped and
28//! stays "borrowed" in the instance's memory forever, poisoning every
29//! later call that needs `borrow_mut()` (`doc_new`, `doc_free`) even for
30//! documents the bad update never touched. `get_doc` below clones the
31//! `Doc` out (an `Arc` clone — `yrs::Doc` is `#[derive(Clone)]` over
32//! `DocStore(Arc<StoreInner>)`, so this is cheap and doesn't copy CRDT
33//! state) and releases the borrow before doing anything that might trap.
34//! - Every raw pointer/length pair this module receives from the host
35//! (`docs_core_doc_new`'s GUID, `docs_core_apply`'s update bytes,
36//! `docs_core_text_read`'s output buffer) is bounds-checked against the
37//! module's actual linear memory (`in_bounds`) before a slice is ever
38//! constructed over it. This turns a host bug (a bad pointer or a
39//! length that doesn't fit) into a returned error code instead of an
40//! out-of-bounds-memory trap — one we could prevent outright, unlike the
41//! CRDT-internal ones above.
42//! - A handle that doesn't exist in `DOCS` is distinguishable from a
43//! handle to a real, empty document: `docs_core_text_len` and
44//! `docs_core_text_read` both return `ERR_NO_DOC` for the former, not 0
45//! bytes — a host silently persisting `""` over live content because it
46//! held a stale handle is exactly the kind of silent corruption §5.2
47//! exists to avoid.
48
49use crate::{
50 apply_update, fragment_html, fragment_json, fragment_text, snapshot_bytes, verify_snapshot,
51 CoreError,
52};
53use std::cell::RefCell;
54use std::collections::HashMap;
55use yrs::updates::decoder::Decode;
56use yrs::{Doc, Options};
57
58thread_local! {
59 static DOCS: RefCell<HashMap<u32, Doc>> = RefCell::new(HashMap::new());
60 static NEXT: RefCell<u32> = const { RefCell::new(1) };
61}
62
63pub const ERR_NO_DOC: i32 = -1;
64pub const ERR_DECODE: i32 = -2;
65pub const ERR_APPLY: i32 = -3;
66pub const ERR_BUFFER_TOO_SMALL: i32 = -4;
67/// The host handed us a `ptr`/`len` pair this module's linear memory
68/// cannot possibly satisfy: `len` alone exceeds `isize::MAX` (the limit
69/// `std::slice::from_raw_parts` requires), or `ptr..ptr+len` reaches past
70/// the module's currently instantiated memory, or the addition overflows
71/// `u64`. See `in_bounds`.
72pub const ERR_INVALID_LENGTH: i32 = -5;
73
74/// Clones the `Doc` for `handle` out of `DOCS` and releases the borrow
75/// before returning it, so callers never hold a `RefCell` guard across a
76/// call into `yrs` that might trap. `yrs::Doc` clones cheaply (an `Arc`
77/// bump, not a copy of CRDT state) so this costs nothing meaningful.
78fn get_doc(handle: u32) -> Option<Doc> {
79 DOCS.with(|d| d.borrow().get(&handle).cloned())
80}
81
82pub fn doc_new(guid: &str, client_id: u64) -> u32 {
83 let id = NEXT.with(|n| {
84 let mut n = n.borrow_mut();
85 let id = *n;
86 // Finding 6: 0 is the failure sentinel every caller checks handles
87 // against. Wrapping `u32` addition would land exactly on 0 after
88 // 2^32 calls and silently hand out a "null" handle as if it were
89 // real; skip 0 on wraparound instead of landing on it.
90 let next = id.wrapping_add(1);
91 *n = if next == 0 { 1 } else { next };
92 id
93 });
94 let doc = Doc::with_options(Options::with_guid_and_client_id(guid.into(), client_id));
95 DOCS.with(|d| d.borrow_mut().insert(id, doc));
96 id
97}
98
99pub fn doc_free(handle: u32) {
100 DOCS.with(|d| d.borrow_mut().remove(&handle));
101}
102
103pub fn apply_slice(handle: u32, update: &[u8]) -> i32 {
104 match get_doc(handle) {
105 None => ERR_NO_DOC,
106 Some(doc) => match apply_update(&doc, update) {
107 Ok(()) => 0,
108 Err(CoreError::Decode(_)) => ERR_DECODE,
109 Err(CoreError::Apply(_)) => ERR_APPLY,
110 },
111 }
112}
113
114/// `None` for an unknown handle — distinct from `Some(String::new())`, a
115/// real empty document. Callers must not collapse the two (Finding 3).
116///
117/// Reads `fragment_text` (root "prosemirror"), not `text_content` (root
118/// "content"). Fix round 1 on the fragment-migration task: every real
119/// document has stored its content as a Y.XmlFragment on "prosemirror"
120/// since seed data and the editor moved there, so `text_content`'s root
121/// has been empty for any of them since — `docs_core_text_len`/
122/// `docs_core_text_read` (and this function, the thing both of them
123/// call) were a silent trap, reporting an empty document's worth of
124/// success instead of a real one's prose. "This document's text" means
125/// `fragment_text`'s plain-text rendering of the fragment now.
126///
127/// No ABI version bump for this: `docs_core_text_len`/
128/// `docs_core_text_read`'s signatures and calling convention are
129/// unchanged — same byte-length-then-bytes shape the host already
130/// speaks — only what they read changed, and the host and this module
131/// ship embedded together with no version skew between separately
132/// deployed copies to guard against (`docs_core_abi_version`'s own doc
133/// comment covers what *does* need a bump: an incompatible export
134/// shape). Recorded here because "the semantics changed but the number
135/// did not" is exactly the kind of thing that looks like an oversight
136/// later.
137///
138/// `text_content` itself is untouched and still backs the Rust↔JS
139/// Y.Text wire fixtures (`core/tests/wire_compat.rs`) directly — this
140/// repoints only what the wasm ABI exposes as "text", not the function
141/// the wire contract tests call by name. A future consumer that
142/// genuinely wants the bare-Y.Text root back (there is none today) can
143/// still call `text_content` directly; nothing here deletes it.
144pub fn text_string(handle: u32) -> Option<String> {
145 get_doc(handle).map(|doc| fragment_text(&doc))
146}
147
148/// `None` for an unknown handle — distinct from `Some(String::new())`, a
149/// real empty document. Callers must not collapse the two (Finding 3),
150/// exactly as `text_string` above.
151pub fn html_string(handle: u32) -> Option<String> {
152 get_doc(handle).map(|doc| fragment_html(&doc))
153}
154
155/// `None` for an unknown handle — distinct from `Some(String::new())`, a
156/// real empty document (Finding 3), exactly as `text_string` and
157/// `html_string` above. Note that an empty document is never the empty
158/// *string* here: `fragment_json` always emits at least
159/// `{"type":"doc","content":[]}`, so a zero length from
160/// `docs_core_json_len` is unreachable for a live handle and the host
161/// need not treat it as a special case the way the other two do.
162pub fn json_string(handle: u32) -> Option<String> {
163 get_doc(handle).map(|doc| fragment_json(&doc))
164}
165
166/// The whole document as one update. Bytes, not a string — the host
167/// reads it through the same len/read convention as the three
168/// renderers, which is why this is shaped like them.
169pub fn snapshot_vec(handle: u32) -> Option<Vec<u8>> {
170 get_doc(handle).map(|doc| snapshot_bytes(&doc))
171}
172
173// --- wasm exports ---
174
175// Amended by pre-flight ruling R2. The obvious version — Vec::with_capacity
176// then Vec::from_raw_parts(ptr, 0, len) — is undefined behaviour: with_capacity
177// guarantees AT LEAST len, so reconstructing with capacity `len` is wrong
178// whenever the allocator rounded up. std::alloc with an identical Layout on
179// both sides has no such gap.
180
181/// The `Layout` for a `len`-byte buffer, or `None` if `len` isn't
182/// representable — `Layout::from_size_align` rejects any size that would
183/// round up past `isize::MAX` once aligned. That's a real possibility here:
184/// `len` is a host-supplied `u32`, and on wasm32 (where `usize` is 32-bit)
185/// `isize::MAX` is only `0x7FFF_FFFF`, well inside `u32`'s range. Fix-round-1
186/// finding 2: the earlier version's `.expect("byte layout is always
187/// valid")` was simply wrong — this is reachable from host input, not an
188/// invariant.
189fn layout_for(len: u32) -> Option<std::alloc::Layout> {
190 // align 1: these are byte buffers the host writes into.
191 std::alloc::Layout::from_size_align(len.max(1) as usize, 1).ok()
192}
193
194/// Number of bytes in this module's currently instantiated linear memory.
195/// On wasm32 this is the real bound `in_bounds` checks host pointers
196/// against. Native builds (this crate's own tests, `emit_update`) have no
197/// such thing as "this module's linear memory" — the concept doesn't
198/// apply outside the sandboxed wasm target — so `in_bounds` degrades on
199/// native to just the `isize::MAX` length check, which is still meaningful
200/// there and is what this crate's own tests exercise.
201#[cfg(target_arch = "wasm32")]
202fn memory_len_bytes() -> u64 {
203 const WASM_PAGE_BYTES: u64 = 65536;
204 core::arch::wasm32::memory_size(0) as u64 * WASM_PAGE_BYTES
205}
206
207#[cfg(not(target_arch = "wasm32"))]
208fn memory_len_bytes() -> u64 {
209 u64::MAX
210}
211
212/// True if `ptr..ptr+len` is representable as a Rust slice and (on wasm32)
213/// fully inside this module's currently instantiated linear memory.
214/// `docs_core_doc_new`, `docs_core_apply`, and `docs_core_text_read` all
215/// call this before constructing a slice over host-supplied bytes, turning
216/// a bad `ptr`/`len` pair into `ERR_INVALID_LENGTH` instead of an
217/// out-of-bounds-memory trap (fix-round-1 finding 4). This cannot and does
218/// not validate the *contents* of the bytes — that's the CRDT decode's job,
219/// and where the upstream UB this module can't fix lives.
220fn in_bounds(ptr: u32, len: u32) -> bool {
221 if len as u64 > isize::MAX as u64 {
222 return false;
223 }
224 match (ptr as u64).checked_add(len as u64) {
225 Some(end) => end <= memory_len_bytes(),
226 None => false,
227 }
228}
229
230/// Allocates `len` bytes in this module's linear memory for the host to
231/// write into (typically before `docs_core_apply` or as the destination
232/// for `docs_core_text_read`). Returns 0 on failure — `len` too large to
233/// form a valid `Layout` (see `layout_for`), or genuine allocator
234/// exhaustion — and **the host must check for a 0 return before using the
235/// pointer**; nothing here traps on a bad `len`.
236#[no_mangle]
237pub extern "C" fn docs_core_alloc(len: u32) -> u32 {
238 match layout_for(len) {
239 // SAFETY: `layout_for` never returns a zero-size Layout (it floors
240 // `len` to 1), which is the one precondition `std::alloc::alloc`
241 // places beyond having a valid Layout at all.
242 Some(layout) => unsafe { std::alloc::alloc(layout) as u32 },
243 None => 0,
244 }
245}
246
247/// # Safety
248///
249/// `ptr` must be either 0 (a no-op) or a value previously returned by
250/// `docs_core_alloc`, not yet freed, and `len` must be exactly the `len`
251/// that allocation was made with — `layout_for(len)` must reproduce the
252/// same `Layout` used to allocate it, per `std::alloc::dealloc`'s contract.
253#[no_mangle]
254pub unsafe extern "C" fn docs_core_free(ptr: u32, len: u32) {
255 if ptr == 0 {
256 return;
257 }
258 // A `len` with no valid Layout cannot be the `len` docs_core_alloc
259 // succeeded with — alloc would have already returned 0 for it. Rather
260 // than call `dealloc` with a fabricated Layout (undefined behaviour if
261 // it doesn't match the allocation), treat this as a no-op: it means
262 // the host handed back an inconsistent ptr/len, not that memory needs
263 // reclaiming.
264 if let Some(layout) = layout_for(len) {
265 std::alloc::dealloc(ptr as *mut u8, layout);
266 }
267}
268
269/// # Safety
270///
271/// `guid_ptr` and `guid_len` must describe a byte range that lies entirely
272/// within this module's linear memory and is not mutated for the duration
273/// of this call. (Checked by `in_bounds` before any slice is constructed —
274/// this safety requirement covers what that check cannot see, such as
275/// concurrent mutation.)
276#[no_mangle]
277pub unsafe extern "C" fn docs_core_doc_new(guid_ptr: u32, guid_len: u32, client_id: u64) -> u32 {
278 if !in_bounds(guid_ptr, guid_len) {
279 return 0; // out-of-bounds ptr/len is a null handle, not a trap
280 }
281 let bytes = std::slice::from_raw_parts(guid_ptr as *const u8, guid_len as usize);
282 match std::str::from_utf8(bytes) {
283 Ok(guid) => doc_new(guid, client_id),
284 Err(_) => 0, // invalid UTF-8 GUID is a null handle, not a trap
285 }
286}
287
288#[no_mangle]
289pub extern "C" fn docs_core_doc_free(handle: u32) {
290 doc_free(handle)
291}
292
293/// # Safety
294///
295/// `ptr` and `len` must describe a byte range that lies entirely within
296/// this module's linear memory and is not mutated for the duration of this
297/// call. (Checked by `in_bounds` before any slice is constructed — this
298/// safety requirement covers what that check cannot see, such as
299/// concurrent mutation.)
300#[no_mangle]
301pub unsafe extern "C" fn docs_core_apply(handle: u32, ptr: u32, len: u32) -> i32 {
302 if !in_bounds(ptr, len) {
303 return ERR_INVALID_LENGTH;
304 }
305 let bytes = std::slice::from_raw_parts(ptr as *const u8, len as usize);
306 apply_slice(handle, bytes)
307}
308
309/// Returns the byte length of `handle`'s text content, or `ERR_NO_DOC` if
310/// `handle` doesn't name a live document — distinct from 0, which means a
311/// real, empty document (Finding 3).
312#[no_mangle]
313pub extern "C" fn docs_core_text_len(handle: u32) -> i32 {
314 match text_string(handle) {
315 Some(s) => s.len().try_into().unwrap_or(i32::MAX),
316 None => ERR_NO_DOC,
317 }
318}
319
320/// # Safety
321///
322/// `out_ptr` and `out_len` must describe a byte range that lies entirely
323/// within this module's linear memory and is valid for writes for the
324/// duration of this call. (Checked by `in_bounds` before any write — this
325/// safety requirement covers what that check cannot see, such as
326/// concurrent mutation.)
327#[no_mangle]
328pub unsafe extern "C" fn docs_core_text_read(handle: u32, out_ptr: u32, out_len: u32) -> i32 {
329 let s = match text_string(handle) {
330 Some(s) => s,
331 None => return ERR_NO_DOC, // stale/unknown handle, not an empty read
332 };
333 if s.len() > out_len as usize {
334 return ERR_BUFFER_TOO_SMALL;
335 }
336 if !in_bounds(out_ptr, out_len) {
337 return ERR_INVALID_LENGTH;
338 }
339 std::ptr::copy_nonoverlapping(s.as_ptr(), out_ptr as *mut u8, s.len());
340 // s.len() <= out_len, and in_bounds(out_ptr, out_len) already proved
341 // out_len <= isize::MAX, so this fits in i32... only when isize is
342 // 32-bit (wasm32). On native 64-bit test builds isize::MAX is far
343 // larger, so clamp explicitly rather than rely on that coincidence.
344 s.len().try_into().unwrap_or(i32::MAX)
345}
346
347/// Returns the byte length of `handle`'s HTML rendering, or `ERR_NO_DOC`
348/// if `handle` doesn't name a live document — distinct from 0, which
349/// means a real, empty document (Finding 3), exactly as
350/// `docs_core_text_len` above.
351#[no_mangle]
352pub extern "C" fn docs_core_html_len(handle: u32) -> i32 {
353 match html_string(handle) {
354 Some(s) => s.len().try_into().unwrap_or(i32::MAX),
355 None => ERR_NO_DOC,
356 }
357}
358
359/// # Safety
360///
361/// `out_ptr` and `out_len` must describe a byte range that lies entirely
362/// within this module's linear memory and is valid for writes for the
363/// duration of this call. (Checked by `in_bounds` before any write — this
364/// safety requirement covers what that check cannot see, such as
365/// concurrent mutation.)
366#[no_mangle]
367pub unsafe extern "C" fn docs_core_html_read(handle: u32, out_ptr: u32, out_len: u32) -> i32 {
368 let s = match html_string(handle) {
369 Some(s) => s,
370 None => return ERR_NO_DOC, // stale/unknown handle, not an empty read
371 };
372 if s.len() > out_len as usize {
373 return ERR_BUFFER_TOO_SMALL;
374 }
375 if !in_bounds(out_ptr, out_len) {
376 return ERR_INVALID_LENGTH;
377 }
378 std::ptr::copy_nonoverlapping(s.as_ptr(), out_ptr as *mut u8, s.len());
379 // s.len() <= out_len, and in_bounds(out_ptr, out_len) already proved
380 // out_len <= isize::MAX, so this fits in i32... only when isize is
381 // 32-bit (wasm32). On native 64-bit test builds isize::MAX is far
382 // larger, so clamp explicitly rather than rely on that coincidence.
383 s.len().try_into().unwrap_or(i32::MAX)
384}
385
386/// Returns the byte length of `handle`'s JSON rendering, or `ERR_NO_DOC`
387/// if `handle` doesn't name a live document — distinct from 0, exactly
388/// as `docs_core_text_len` and `docs_core_html_len` above. (0 is not
389/// reachable here at all for a live handle; see `json_string`.)
390#[no_mangle]
391pub extern "C" fn docs_core_json_len(handle: u32) -> i32 {
392 match json_string(handle) {
393 Some(s) => s.len().try_into().unwrap_or(i32::MAX),
394 None => ERR_NO_DOC,
395 }
396}
397
398/// # Safety
399///
400/// `out_ptr` and `out_len` must describe a byte range that lies entirely
401/// within this module's linear memory and is valid for writes for the
402/// duration of this call. (Checked by `in_bounds` before any write — this
403/// safety requirement covers what that check cannot see, such as
404/// concurrent mutation.)
405#[no_mangle]
406pub unsafe extern "C" fn docs_core_json_read(handle: u32, out_ptr: u32, out_len: u32) -> i32 {
407 let s = match json_string(handle) {
408 Some(s) => s,
409 None => return ERR_NO_DOC, // stale/unknown handle, not an empty read
410 };
411 if s.len() > out_len as usize {
412 return ERR_BUFFER_TOO_SMALL;
413 }
414 if !in_bounds(out_ptr, out_len) {
415 return ERR_INVALID_LENGTH;
416 }
417 std::ptr::copy_nonoverlapping(s.as_ptr(), out_ptr as *mut u8, s.len());
418 // Same clamp, same reason as the two read functions above.
419 s.len().try_into().unwrap_or(i32::MAX)
420}
421
422/// Returns the byte length of `handle`'s whole-state snapshot, or
423/// `ERR_NO_DOC` if `handle` doesn't name a live document — distinct
424/// from 0, exactly as `docs_core_text_len`, `docs_core_html_len`, and
425/// `docs_core_json_len` above.
426#[no_mangle]
427pub extern "C" fn docs_core_snapshot_len(handle: u32) -> i32 {
428 match snapshot_vec(handle) {
429 Some(v) => v.len().try_into().unwrap_or(i32::MAX),
430 None => ERR_NO_DOC,
431 }
432}
433
434/// # Safety
435///
436/// `out_ptr` and `out_len` must describe a byte range that lies entirely
437/// within this module's linear memory and is valid for writes for the
438/// duration of this call. (Checked by `in_bounds` before any write — this
439/// safety requirement covers what that check cannot see, such as
440/// concurrent mutation.)
441#[no_mangle]
442pub unsafe extern "C" fn docs_core_snapshot_read(handle: u32, out_ptr: u32, out_len: u32) -> i32 {
443 let v = match snapshot_vec(handle) {
444 Some(v) => v,
445 None => return ERR_NO_DOC, // stale/unknown handle, not an empty read
446 };
447 if v.len() > out_len as usize {
448 return ERR_BUFFER_TOO_SMALL;
449 }
450 if !in_bounds(out_ptr, out_len) {
451 return ERR_INVALID_LENGTH;
452 }
453 std::ptr::copy_nonoverlapping(v.as_ptr(), out_ptr as *mut u8, v.len());
454 // Same clamp, same reason as the two read functions above.
455 v.len().try_into().unwrap_or(i32::MAX)
456}
457
458/// 0 = the candidate is this document, 1 = it is not, `ERR_DECODE` = the
459/// bytes are not an update at all, `ERR_NO_DOC` = no such handle.
460///
461/// Deviation from the brief's Step 5 listing: that code called
462/// `verify_snapshot` directly and let its `bool` collapse "undecodable"
463/// into the same `1` as a genuine mismatch, which contradicts this
464/// export's own documented contract (`ERR_DECODE` as a distinct
465/// outcome). `verify_snapshot`'s signature — `-> bool` — is the
466/// documented Rust-level interface and stays that way; the decode is
467/// checked here, at the ABI boundary, so the two failure modes reach
468/// the host as different codes without changing that signature.
469///
470/// # Safety
471///
472/// `ptr` and `len` must describe a byte range that lies entirely within
473/// this module's linear memory and is not mutated for the duration of
474/// this call. (Checked by `in_bounds` before any slice is constructed —
475/// this safety requirement covers what that check cannot see, such as
476/// concurrent mutation.)
477#[no_mangle]
478pub unsafe extern "C" fn docs_core_verify_snapshot(handle: u32, ptr: u32, len: u32) -> i32 {
479 if !in_bounds(ptr, len) {
480 return ERR_INVALID_LENGTH;
481 }
482 let candidate = std::slice::from_raw_parts(ptr as *const u8, len as usize);
483 match get_doc(handle) {
484 None => ERR_NO_DOC, // stale/unknown handle, not a mismatch verdict
485 Some(doc) => {
486 if yrs::Update::decode_v1(candidate).is_err() {
487 return ERR_DECODE;
488 }
489 // The verifier's throwaway document takes a fixed guid and
490 // client id rather than generating any: entropy inside the
491 // core is banned (clippy disallowed-methods), and this
492 // document is discarded before the call returns, so a fixed
493 // pair is not merely acceptable but correct.
494 if verify_snapshot(&doc, candidate, "docs-core-verify", 1) {
495 0
496 } else {
497 1
498 }
499 }
500 }
501}
502