//! The C ABI the Go host calls through wazero. No wasm-bindgen: wazero //! provides no JS glue, so everything crosses as integers and linear-memory //! offsets. //! //! Handles are indices into a slab rather than raw pointers, so a stale //! handle from the host is a null lookup instead of a wild dereference. //! //! # Containment //! //! Each document gets its own wasm module instance, and a trapped //! instance is discarded and rebuilt by the host. That defence is //! architectural, not something this module can validate its way out of //! — `yrs` decodes wire bytes with `std::str::from_utf8_unchecked` in //! places, so a sufficiently corrupted update can reach real undefined //! behaviour deep inside the CRDT decode, and finding that requires //! actually parsing the update — which is `yrs`'s job, not ours to redo. //! //! What *is* this module's job is to not make an upstream trap worse than //! it has to be, and the rules are Docs' (`docs/core/src/abi.rs`): //! //! - No `RefCell` borrow is held across a call into `yrs`. A wasm trap //! does not unwind — with `panic = "abort"` no destructors run — so a //! borrow guard on the stack at the moment of a trap stays "borrowed" //! in the instance's memory forever, poisoning every later call that //! needs `borrow_mut()` even for documents the bad input never touched. //! `get_doc` clones the `Doc` out (an `Arc` bump, not a copy of CRDT //! state) and releases the borrow before doing anything that might trap. //! The `OUT`/`ERR` slabs are only ever borrowed for a plain insert or //! clone, after the `yrs` call has returned. //! - Every raw pointer/length pair this module receives from the host is //! bounds-checked against the module's actual linear memory //! (`in_bounds`) before a slice is ever constructed over it, turning a //! host bug into a returned error code instead of an out-of-bounds trap. //! - A handle that doesn't exist is distinguishable from a real, empty //! document: every `_len` and `_read` returns `ERR_NO_DOC` for the //! former, never 0 bytes. //! //! # The read pairs //! //! `state`, `json`, `out` and `err` all follow one convention: `_len(h)` //! returns the byte length (or a negative code), the host allocates that //! many bytes with `slides_core_alloc`, and `_read(h, ptr, cap)` copies //! the bytes in and returns how many it wrote. `out` and `err` are the //! two results of the last `slides_core_ops` call on that handle: a //! success stores the update in `out` and clears `err`; a refusal stores //! the message in `err` and clears `out`. Before any ops call both read //! as empty. `slides_core_check` (codex 3) files its message in the same //! `err` slot, so `err` always describes the most recent ops or check //! call. use crate::{apply_ops, apply_update, state_bytes, view_json, CoreError}; use std::cell::RefCell; use std::collections::HashMap; use yrs::Doc; thread_local! { static DOCS: RefCell> = RefCell::new(HashMap::new()); static OUT: RefCell>> = RefCell::new(HashMap::new()); static ERR: RefCell> = RefCell::new(HashMap::new()); static NEXT: RefCell = const { RefCell::new(1) }; } pub const ERR_NO_DOC: i32 = -1; pub const ERR_DECODE: i32 = -2; pub const ERR_APPLY: i32 = -3; pub const ERR_BUFFER_TOO_SMALL: i32 = -4; /// The host handed us a `ptr`/`len` pair this module's linear memory /// cannot possibly satisfy: `len` alone exceeds `isize::MAX` (the limit /// `std::slice::from_raw_parts` requires), or `ptr..ptr+len` reaches past /// the module's currently instantiated memory, or the addition overflows /// `u64`. See `in_bounds`. pub const ERR_INVALID_LENGTH: i32 = -5; /// The op batch was refused and nothing applied; the message is in the /// `err` read pair. pub const ERR_OPS: i32 = -6; /// `slides_core_check` found text outside the §2.6 vocabulary; the /// message is in the `err` read pair. The document is untouched — the /// check reads and never writes — so what the host does about it is the /// host's call (refuse the update that produced it, live.go). pub const ERR_SHAPE: i32 = -7; /// Clones the `Doc` for `handle` out of `DOCS` and releases the borrow /// before returning it, so callers never hold a `RefCell` guard across a /// call into `yrs` that might trap. fn get_doc(handle: u32) -> Option { DOCS.with(|d| d.borrow().get(&handle).cloned()) } pub fn doc_new(guid: &str, client_id: u64) -> u32 { let id = NEXT.with(|n| { let mut n = n.borrow_mut(); let id = *n; // 0 is the failure sentinel every caller checks handles against. // Wrapping `u32` addition would land exactly on 0 after 2^32 // calls and hand out a "null" handle as if it were real; skip 0 // on wraparound instead of landing on it. let next = id.wrapping_add(1); *n = if next == 0 { 1 } else { next }; id }); let doc = crate::new_doc(guid, client_id); DOCS.with(|d| d.borrow_mut().insert(id, doc)); id } pub fn doc_free(handle: u32) { DOCS.with(|d| d.borrow_mut().remove(&handle)); OUT.with(|o| o.borrow_mut().remove(&handle)); ERR.with(|e| e.borrow_mut().remove(&handle)); } pub fn apply_slice(handle: u32, update: &[u8]) -> i32 { match get_doc(handle) { None => ERR_NO_DOC, Some(doc) => match apply_update(&doc, update) { Ok(()) => 0, Err(CoreError::Decode(_)) => ERR_DECODE, Err(CoreError::Apply(_)) | Err(CoreError::Ops(_)) => ERR_APPLY, }, } } /// Runs an op batch and files its result under the handle. Returns 0 on /// success (update in `out`), `ERR_OPS` on refusal (message in `err`), /// `ERR_NO_DOC` for an unknown handle. pub fn ops_str(handle: u32, ops: &str) -> i32 { let Some(doc) = get_doc(handle) else { return ERR_NO_DOC; }; match apply_ops(&doc, ops) { Ok(update) => { OUT.with(|o| o.borrow_mut().insert(handle, update)); ERR.with(|e| e.borrow_mut().remove(&handle)); 0 } Err(e) => { let msg = match e { CoreError::Ops(m) => m, other => other.to_string(), }; ERR.with(|e| e.borrow_mut().insert(handle, msg)); OUT.with(|o| o.borrow_mut().remove(&handle)); ERR_OPS } } } /// Runs the accepted-shape check (`check.rs`) over the handle's document. /// Returns 0 when everything is within the vocabulary, `ERR_SHAPE` with /// the message filed in `err` when something is not, `ERR_NO_DOC` for an /// unknown handle. Like `ops_str`, a call clears the previous `err`, so /// the read pair always describes the most recent call. pub fn check_doc(handle: u32) -> i32 { let Some(doc) = get_doc(handle) else { return ERR_NO_DOC; }; match crate::check(&doc) { Ok(()) => { ERR.with(|e| e.borrow_mut().remove(&handle)); 0 } Err(msg) => { ERR.with(|e| e.borrow_mut().insert(handle, msg)); ERR_SHAPE } } } /// `None` for an unknown handle — distinct from `Some(vec![])`, which /// is never what `state_bytes` returns for a live document anyway (an /// empty document still encodes to a few bytes). pub fn state_vec(handle: u32) -> Option> { get_doc(handle).map(|doc| state_bytes(&doc)) } /// `None` for an unknown handle. Never `Some("")`: `view_json` always /// emits a complete object. pub fn json_string(handle: u32) -> Option { get_doc(handle).map(|doc| view_json(&doc)) } /// The last ops call's update; empty if there was none or it failed. /// `None` for an unknown handle. pub fn out_vec(handle: u32) -> Option> { get_doc(handle)?; Some(OUT.with(|o| o.borrow().get(&handle).cloned().unwrap_or_default())) } /// The last ops call's refusal message; empty if there was none or it /// succeeded. `None` for an unknown handle. pub fn err_string(handle: u32) -> Option { get_doc(handle)?; Some(ERR.with(|e| e.borrow().get(&handle).cloned().unwrap_or_default())) } // --- wasm exports --- /// The `Layout` for a `len`-byte buffer, or `None` if `len` isn't /// representable — `Layout::from_size_align` rejects any size that would /// round up past `isize::MAX` once aligned. That's a real possibility here: /// `len` is a host-supplied `u32`, and on wasm32 (where `usize` is 32-bit) /// `isize::MAX` is only `0x7FFF_FFFF`, well inside `u32`'s range. fn layout_for(len: u32) -> Option { // align 1: these are byte buffers the host writes into. std::alloc::Layout::from_size_align(len.max(1) as usize, 1).ok() } /// Number of bytes in this module's currently instantiated linear memory. /// On wasm32 this is the real bound `in_bounds` checks host pointers /// against. Native builds have no such thing, so `in_bounds` degrades /// there to just the `isize::MAX` length check. #[cfg(target_arch = "wasm32")] fn memory_len_bytes() -> u64 { const WASM_PAGE_BYTES: u64 = 65536; core::arch::wasm32::memory_size(0) as u64 * WASM_PAGE_BYTES } #[cfg(not(target_arch = "wasm32"))] fn memory_len_bytes() -> u64 { u64::MAX } /// True if `ptr..ptr+len` is representable as a Rust slice and (on wasm32) /// fully inside this module's currently instantiated linear memory. Every /// export taking a pointer calls this before constructing a slice over /// host-supplied bytes, turning a bad pair into `ERR_INVALID_LENGTH` /// instead of an out-of-bounds-memory trap. It cannot validate the /// *contents* of the bytes — that's the CRDT decode's job. fn in_bounds(ptr: u32, len: u32) -> bool { if len as u64 > isize::MAX as u64 { return false; } match (ptr as u64).checked_add(len as u64) { Some(end) => end <= memory_len_bytes(), None => false, } } /// Allocates `len` bytes in this module's linear memory for the host to /// write into. Returns 0 on failure — `len` too large to form a valid /// `Layout` (see `layout_for`), or genuine allocator exhaustion — and /// **the host must check for a 0 return before using the pointer**; /// nothing here traps on a bad `len`. #[no_mangle] pub extern "C" fn slides_core_alloc(len: u32) -> u32 { match layout_for(len) { // SAFETY: `layout_for` never returns a zero-size Layout (it floors // `len` to 1), which is the one precondition `std::alloc::alloc` // places beyond having a valid Layout at all. Some(layout) => unsafe { std::alloc::alloc(layout) as u32 }, None => 0, } } /// # Safety /// /// `ptr` must be either 0 (a no-op) or a value previously returned by /// `slides_core_alloc`, not yet freed, and `len` must be exactly the `len` /// that allocation was made with — `layout_for(len)` must reproduce the /// same `Layout` used to allocate it, per `std::alloc::dealloc`'s contract. #[no_mangle] pub unsafe extern "C" fn slides_core_free(ptr: u32, len: u32) { if ptr == 0 { return; } // A `len` with no valid Layout cannot be the `len` slides_core_alloc // succeeded with — alloc would have already returned 0 for it. Rather // than call `dealloc` with a fabricated Layout (undefined behaviour if // it doesn't match the allocation), treat this as a no-op. if let Some(layout) = layout_for(len) { std::alloc::dealloc(ptr as *mut u8, layout); } } /// # Safety /// /// `guid_ptr` and `guid_len` must describe a byte range that lies entirely /// within this module's linear memory and is not mutated for the duration /// of this call. (Checked by `in_bounds` before any slice is constructed — /// this safety requirement covers what that check cannot see, such as /// concurrent mutation.) #[no_mangle] pub unsafe extern "C" fn slides_core_doc_new(guid_ptr: u32, guid_len: u32, client_id: u64) -> u32 { if !in_bounds(guid_ptr, guid_len) { return 0; // out-of-bounds ptr/len is a null handle, not a trap } let bytes = std::slice::from_raw_parts(guid_ptr as *const u8, guid_len as usize); match std::str::from_utf8(bytes) { Ok(guid) => doc_new(guid, client_id), Err(_) => 0, // invalid UTF-8 GUID is a null handle, not a trap } } #[no_mangle] pub extern "C" fn slides_core_doc_free(handle: u32) { doc_free(handle) } /// # Safety /// /// `ptr` and `len` must describe a byte range that lies entirely within /// this module's linear memory and is not mutated for the duration of this /// call. (Checked by `in_bounds` before any slice is constructed.) #[no_mangle] pub unsafe extern "C" fn slides_core_apply(handle: u32, ptr: u32, len: u32) -> i32 { if !in_bounds(ptr, len) { return ERR_INVALID_LENGTH; } let bytes = std::slice::from_raw_parts(ptr as *const u8, len as usize); apply_slice(handle, bytes) } /// # Safety /// /// `ptr` and `len` must describe a byte range that lies entirely within /// this module's linear memory and is not mutated for the duration of this /// call. (Checked by `in_bounds` before any slice is constructed.) #[no_mangle] pub unsafe extern "C" fn slides_core_ops(handle: u32, ptr: u32, len: u32) -> i32 { if !in_bounds(ptr, len) { return ERR_INVALID_LENGTH; } let bytes = std::slice::from_raw_parts(ptr as *const u8, len as usize); match std::str::from_utf8(bytes) { Ok(ops) => ops_str(handle, ops), Err(e) => { if get_doc(handle).is_none() { return ERR_NO_DOC; } ERR.with(|m| { m.borrow_mut() .insert(handle, format!("ops are not UTF-8: {e}")) }); OUT.with(|o| o.borrow_mut().remove(&handle)); ERR_OPS } } } /// The accepted-shape check (`check.rs`): 0, `ERR_SHAPE` (message in the /// `err` read pair) or `ERR_NO_DOC`. Takes no pointer, so nothing to /// bounds-check. #[no_mangle] pub extern "C" fn slides_core_check(handle: u32) -> i32 { check_doc(handle) } /// The byte length of `bytes`, clamped to `i32` — on native 64-bit test /// builds a length can exceed it; on wasm32 it cannot but the clamp /// costs nothing. fn len_code(bytes: &[u8]) -> i32 { bytes.len().try_into().unwrap_or(i32::MAX) } /// Copies `bytes` to the host's buffer and returns how many were /// written, or `ERR_BUFFER_TOO_SMALL` / `ERR_INVALID_LENGTH`. /// /// # Safety /// /// `out_ptr` and `out_len` must describe a byte range that lies entirely /// within this module's linear memory and is valid for writes for the /// duration of this call. (Checked by `in_bounds` before any write.) unsafe fn read_into(bytes: &[u8], out_ptr: u32, out_len: u32) -> i32 { if bytes.len() > out_len as usize { return ERR_BUFFER_TOO_SMALL; } if !in_bounds(out_ptr, out_len) { return ERR_INVALID_LENGTH; } std::ptr::copy_nonoverlapping(bytes.as_ptr(), out_ptr as *mut u8, bytes.len()); len_code(bytes) } /// Byte length of the document's whole state as one v1 update, or /// `ERR_NO_DOC`. #[no_mangle] pub extern "C" fn slides_core_state_len(handle: u32) -> i32 { match state_vec(handle) { Some(v) => len_code(&v), None => ERR_NO_DOC, } } /// # Safety /// /// `out_ptr` and `out_len` must describe a byte range that lies entirely /// within this module's linear memory and is valid for writes for the /// duration of this call. (Checked by `in_bounds` before any write.) #[no_mangle] pub unsafe extern "C" fn slides_core_state_read(handle: u32, out_ptr: u32, out_len: u32) -> i32 { match state_vec(handle) { Some(v) => read_into(&v, out_ptr, out_len), None => ERR_NO_DOC, } } /// Byte length of the deck's JSON view, or `ERR_NO_DOC`. #[no_mangle] pub extern "C" fn slides_core_json_len(handle: u32) -> i32 { match json_string(handle) { Some(s) => len_code(s.as_bytes()), None => ERR_NO_DOC, } } /// # Safety /// /// `out_ptr` and `out_len` must describe a byte range that lies entirely /// within this module's linear memory and is valid for writes for the /// duration of this call. (Checked by `in_bounds` before any write.) #[no_mangle] pub unsafe extern "C" fn slides_core_json_read(handle: u32, out_ptr: u32, out_len: u32) -> i32 { match json_string(handle) { Some(s) => read_into(s.as_bytes(), out_ptr, out_len), None => ERR_NO_DOC, } } /// Byte length of the last ops call's update (0 if none), or /// `ERR_NO_DOC`. #[no_mangle] pub extern "C" fn slides_core_out_len(handle: u32) -> i32 { match out_vec(handle) { Some(v) => len_code(&v), None => ERR_NO_DOC, } } /// # Safety /// /// `out_ptr` and `out_len` must describe a byte range that lies entirely /// within this module's linear memory and is valid for writes for the /// duration of this call. (Checked by `in_bounds` before any write.) #[no_mangle] pub unsafe extern "C" fn slides_core_out_read(handle: u32, out_ptr: u32, out_len: u32) -> i32 { match out_vec(handle) { Some(v) => read_into(&v, out_ptr, out_len), None => ERR_NO_DOC, } } /// Byte length of the last ops call's refusal message (0 if none), or /// `ERR_NO_DOC`. #[no_mangle] pub extern "C" fn slides_core_err_len(handle: u32) -> i32 { match err_string(handle) { Some(s) => len_code(s.as_bytes()), None => ERR_NO_DOC, } } /// # Safety /// /// `out_ptr` and `out_len` must describe a byte range that lies entirely /// within this module's linear memory and is valid for writes for the /// duration of this call. (Checked by `in_bounds` before any write.) #[no_mangle] pub unsafe extern "C" fn slides_core_err_read(handle: u32, out_ptr: u32, out_len: u32) -> i32 { match err_string(handle) { Some(s) => read_into(s.as_bytes(), out_ptr, out_len), None => ERR_NO_DOC, } }