oficina / slides Public

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

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
9//!
10//! Each document gets its own wasm module instance, and a trapped
11//! instance is discarded and rebuilt by the host. That defence is
12//! architectural, not something this module can validate its way out of
13//! — `yrs` decodes wire bytes with `std::str::from_utf8_unchecked` in
14//! places, so a sufficiently corrupted update can reach real undefined
15//! behaviour deep inside the CRDT decode, and finding that requires
16//! actually parsing the update — which is `yrs`'s job, not ours to redo.
17//!
18//! What *is* this module's job is to not make an upstream trap worse than
19//! it has to be, and the rules are Docs' (`docs/core/src/abi.rs`):
20//!
21//! - No `RefCell` borrow is held across a call into `yrs`. A wasm trap
22//! does not unwind — with `panic = "abort"` no destructors run — so a
23//! borrow guard on the stack at the moment of a trap stays "borrowed"
24//! in the instance's memory forever, poisoning every later call that
25//! needs `borrow_mut()` even for documents the bad input never touched.
26//! `get_doc` clones the `Doc` out (an `Arc` bump, not a copy of CRDT
27//! state) and releases the borrow before doing anything that might trap.
28//! The `OUT`/`ERR` slabs are only ever borrowed for a plain insert or
29//! clone, after the `yrs` call has returned.
30//! - Every raw pointer/length pair this module receives from the host is
31//! bounds-checked against the module's actual linear memory
32//! (`in_bounds`) before a slice is ever constructed over it, turning a
33//! host bug into a returned error code instead of an out-of-bounds trap.
34//! - A handle that doesn't exist is distinguishable from a real, empty
35//! document: every `_len` and `_read` returns `ERR_NO_DOC` for the
36//! former, never 0 bytes.
37//!
38//! # The read pairs
39//!
40//! `state`, `json`, `out` and `err` all follow one convention: `_len(h)`
41//! returns the byte length (or a negative code), the host allocates that
42//! many bytes with `slides_core_alloc`, and `_read(h, ptr, cap)` copies
43//! the bytes in and returns how many it wrote. `out` and `err` are the
44//! two results of the last `slides_core_ops` call on that handle: a
45//! success stores the update in `out` and clears `err`; a refusal stores
46//! the message in `err` and clears `out`. Before any ops call both read
47//! as empty. `slides_core_check` (codex 3) files its message in the same
48//! `err` slot, so `err` always describes the most recent ops or check
49//! call.
50
51use crate::{apply_ops, apply_update, state_bytes, view_json, CoreError};
52use std::cell::RefCell;
53use std::collections::HashMap;
54use yrs::Doc;
55
56thread_local! {
57 static DOCS: RefCell<HashMap<u32, Doc>> = RefCell::new(HashMap::new());
58 static OUT: RefCell<HashMap<u32, Vec<u8>>> = RefCell::new(HashMap::new());
59 static ERR: RefCell<HashMap<u32, String>> = 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/// The op batch was refused and nothing applied; the message is in the
74/// `err` read pair.
75pub const ERR_OPS: i32 = -6;
76/// `slides_core_check` found text outside the §2.6 vocabulary; the
77/// message is in the `err` read pair. The document is untouched — the
78/// check reads and never writes — so what the host does about it is the
79/// host's call (refuse the update that produced it, live.go).
80pub const ERR_SHAPE: i32 = -7;
81
82/// Clones the `Doc` for `handle` out of `DOCS` and releases the borrow
83/// before returning it, so callers never hold a `RefCell` guard across a
84/// call into `yrs` that might trap.
85fn get_doc(handle: u32) -> Option<Doc> {
86 DOCS.with(|d| d.borrow().get(&handle).cloned())
87}
88
89pub fn doc_new(guid: &str, client_id: u64) -> u32 {
90 let id = NEXT.with(|n| {
91 let mut n = n.borrow_mut();
92 let id = *n;
93 // 0 is the failure sentinel every caller checks handles against.
94 // Wrapping `u32` addition would land exactly on 0 after 2^32
95 // calls and hand out a "null" handle as if it were real; skip 0
96 // on wraparound instead of landing on it.
97 let next = id.wrapping_add(1);
98 *n = if next == 0 { 1 } else { next };
99 id
100 });
101 let doc = crate::new_doc(guid, client_id);
102 DOCS.with(|d| d.borrow_mut().insert(id, doc));
103 id
104}
105
106pub fn doc_free(handle: u32) {
107 DOCS.with(|d| d.borrow_mut().remove(&handle));
108 OUT.with(|o| o.borrow_mut().remove(&handle));
109 ERR.with(|e| e.borrow_mut().remove(&handle));
110}
111
112pub fn apply_slice(handle: u32, update: &[u8]) -> i32 {
113 match get_doc(handle) {
114 None => ERR_NO_DOC,
115 Some(doc) => match apply_update(&doc, update) {
116 Ok(()) => 0,
117 Err(CoreError::Decode(_)) => ERR_DECODE,
118 Err(CoreError::Apply(_)) | Err(CoreError::Ops(_)) => ERR_APPLY,
119 },
120 }
121}
122
123/// Runs an op batch and files its result under the handle. Returns 0 on
124/// success (update in `out`), `ERR_OPS` on refusal (message in `err`),
125/// `ERR_NO_DOC` for an unknown handle.
126pub fn ops_str(handle: u32, ops: &str) -> i32 {
127 let Some(doc) = get_doc(handle) else {
128 return ERR_NO_DOC;
129 };
130 match apply_ops(&doc, ops) {
131 Ok(update) => {
132 OUT.with(|o| o.borrow_mut().insert(handle, update));
133 ERR.with(|e| e.borrow_mut().remove(&handle));
134 0
135 }
136 Err(e) => {
137 let msg = match e {
138 CoreError::Ops(m) => m,
139 other => other.to_string(),
140 };
141 ERR.with(|e| e.borrow_mut().insert(handle, msg));
142 OUT.with(|o| o.borrow_mut().remove(&handle));
143 ERR_OPS
144 }
145 }
146}
147
148/// Runs the accepted-shape check (`check.rs`) over the handle's document.
149/// Returns 0 when everything is within the vocabulary, `ERR_SHAPE` with
150/// the message filed in `err` when something is not, `ERR_NO_DOC` for an
151/// unknown handle. Like `ops_str`, a call clears the previous `err`, so
152/// the read pair always describes the most recent call.
153pub fn check_doc(handle: u32) -> i32 {
154 let Some(doc) = get_doc(handle) else {
155 return ERR_NO_DOC;
156 };
157 match crate::check(&doc) {
158 Ok(()) => {
159 ERR.with(|e| e.borrow_mut().remove(&handle));
160 0
161 }
162 Err(msg) => {
163 ERR.with(|e| e.borrow_mut().insert(handle, msg));
164 ERR_SHAPE
165 }
166 }
167}
168
169/// `None` for an unknown handle — distinct from `Some(vec![])`, which
170/// is never what `state_bytes` returns for a live document anyway (an
171/// empty document still encodes to a few bytes).
172pub fn state_vec(handle: u32) -> Option<Vec<u8>> {
173 get_doc(handle).map(|doc| state_bytes(&doc))
174}
175
176/// `None` for an unknown handle. Never `Some("")`: `view_json` always
177/// emits a complete object.
178pub fn json_string(handle: u32) -> Option<String> {
179 get_doc(handle).map(|doc| view_json(&doc))
180}
181
182/// The last ops call's update; empty if there was none or it failed.
183/// `None` for an unknown handle.
184pub fn out_vec(handle: u32) -> Option<Vec<u8>> {
185 get_doc(handle)?;
186 Some(OUT.with(|o| o.borrow().get(&handle).cloned().unwrap_or_default()))
187}
188
189/// The last ops call's refusal message; empty if there was none or it
190/// succeeded. `None` for an unknown handle.
191pub fn err_string(handle: u32) -> Option<String> {
192 get_doc(handle)?;
193 Some(ERR.with(|e| e.borrow().get(&handle).cloned().unwrap_or_default()))
194}
195
196// --- wasm exports ---
197
198/// The `Layout` for a `len`-byte buffer, or `None` if `len` isn't
199/// representable — `Layout::from_size_align` rejects any size that would
200/// round up past `isize::MAX` once aligned. That's a real possibility here:
201/// `len` is a host-supplied `u32`, and on wasm32 (where `usize` is 32-bit)
202/// `isize::MAX` is only `0x7FFF_FFFF`, well inside `u32`'s range.
203fn layout_for(len: u32) -> Option<std::alloc::Layout> {
204 // align 1: these are byte buffers the host writes into.
205 std::alloc::Layout::from_size_align(len.max(1) as usize, 1).ok()
206}
207
208/// Number of bytes in this module's currently instantiated linear memory.
209/// On wasm32 this is the real bound `in_bounds` checks host pointers
210/// against. Native builds have no such thing, so `in_bounds` degrades
211/// there to just the `isize::MAX` length check.
212#[cfg(target_arch = "wasm32")]
213fn memory_len_bytes() -> u64 {
214 const WASM_PAGE_BYTES: u64 = 65536;
215 core::arch::wasm32::memory_size(0) as u64 * WASM_PAGE_BYTES
216}
217
218#[cfg(not(target_arch = "wasm32"))]
219fn memory_len_bytes() -> u64 {
220 u64::MAX
221}
222
223/// True if `ptr..ptr+len` is representable as a Rust slice and (on wasm32)
224/// fully inside this module's currently instantiated linear memory. Every
225/// export taking a pointer calls this before constructing a slice over
226/// host-supplied bytes, turning a bad pair into `ERR_INVALID_LENGTH`
227/// instead of an out-of-bounds-memory trap. It cannot validate the
228/// *contents* of the bytes — that's the CRDT decode's job.
229fn in_bounds(ptr: u32, len: u32) -> bool {
230 if len as u64 > isize::MAX as u64 {
231 return false;
232 }
233 match (ptr as u64).checked_add(len as u64) {
234 Some(end) => end <= memory_len_bytes(),
235 None => false,
236 }
237}
238
239/// Allocates `len` bytes in this module's linear memory for the host to
240/// write into. Returns 0 on failure — `len` too large to form a valid
241/// `Layout` (see `layout_for`), or genuine allocator exhaustion — and
242/// **the host must check for a 0 return before using the pointer**;
243/// nothing here traps on a bad `len`.
244#[no_mangle]
245pub extern "C" fn slides_core_alloc(len: u32) -> u32 {
246 match layout_for(len) {
247 // SAFETY: `layout_for` never returns a zero-size Layout (it floors
248 // `len` to 1), which is the one precondition `std::alloc::alloc`
249 // places beyond having a valid Layout at all.
250 Some(layout) => unsafe { std::alloc::alloc(layout) as u32 },
251 None => 0,
252 }
253}
254
255/// # Safety
256///
257/// `ptr` must be either 0 (a no-op) or a value previously returned by
258/// `slides_core_alloc`, not yet freed, and `len` must be exactly the `len`
259/// that allocation was made with — `layout_for(len)` must reproduce the
260/// same `Layout` used to allocate it, per `std::alloc::dealloc`'s contract.
261#[no_mangle]
262pub unsafe extern "C" fn slides_core_free(ptr: u32, len: u32) {
263 if ptr == 0 {
264 return;
265 }
266 // A `len` with no valid Layout cannot be the `len` slides_core_alloc
267 // succeeded with — alloc would have already returned 0 for it. Rather
268 // than call `dealloc` with a fabricated Layout (undefined behaviour if
269 // it doesn't match the allocation), treat this as a no-op.
270 if let Some(layout) = layout_for(len) {
271 std::alloc::dealloc(ptr as *mut u8, layout);
272 }
273}
274
275/// # Safety
276///
277/// `guid_ptr` and `guid_len` must describe a byte range that lies entirely
278/// within this module's linear memory and is not mutated for the duration
279/// of this call. (Checked by `in_bounds` before any slice is constructed —
280/// this safety requirement covers what that check cannot see, such as
281/// concurrent mutation.)
282#[no_mangle]
283pub unsafe extern "C" fn slides_core_doc_new(guid_ptr: u32, guid_len: u32, client_id: u64) -> u32 {
284 if !in_bounds(guid_ptr, guid_len) {
285 return 0; // out-of-bounds ptr/len is a null handle, not a trap
286 }
287 let bytes = std::slice::from_raw_parts(guid_ptr as *const u8, guid_len as usize);
288 match std::str::from_utf8(bytes) {
289 Ok(guid) => doc_new(guid, client_id),
290 Err(_) => 0, // invalid UTF-8 GUID is a null handle, not a trap
291 }
292}
293
294#[no_mangle]
295pub extern "C" fn slides_core_doc_free(handle: u32) {
296 doc_free(handle)
297}
298
299/// # Safety
300///
301/// `ptr` and `len` must describe a byte range that lies entirely within
302/// this module's linear memory and is not mutated for the duration of this
303/// call. (Checked by `in_bounds` before any slice is constructed.)
304#[no_mangle]
305pub unsafe extern "C" fn slides_core_apply(handle: u32, ptr: u32, len: u32) -> i32 {
306 if !in_bounds(ptr, len) {
307 return ERR_INVALID_LENGTH;
308 }
309 let bytes = std::slice::from_raw_parts(ptr as *const u8, len as usize);
310 apply_slice(handle, bytes)
311}
312
313/// # Safety
314///
315/// `ptr` and `len` must describe a byte range that lies entirely within
316/// this module's linear memory and is not mutated for the duration of this
317/// call. (Checked by `in_bounds` before any slice is constructed.)
318#[no_mangle]
319pub unsafe extern "C" fn slides_core_ops(handle: u32, ptr: u32, len: u32) -> i32 {
320 if !in_bounds(ptr, len) {
321 return ERR_INVALID_LENGTH;
322 }
323 let bytes = std::slice::from_raw_parts(ptr as *const u8, len as usize);
324 match std::str::from_utf8(bytes) {
325 Ok(ops) => ops_str(handle, ops),
326 Err(e) => {
327 if get_doc(handle).is_none() {
328 return ERR_NO_DOC;
329 }
330 ERR.with(|m| {
331 m.borrow_mut()
332 .insert(handle, format!("ops are not UTF-8: {e}"))
333 });
334 OUT.with(|o| o.borrow_mut().remove(&handle));
335 ERR_OPS
336 }
337 }
338}
339
340/// The accepted-shape check (`check.rs`): 0, `ERR_SHAPE` (message in the
341/// `err` read pair) or `ERR_NO_DOC`. Takes no pointer, so nothing to
342/// bounds-check.
343#[no_mangle]
344pub extern "C" fn slides_core_check(handle: u32) -> i32 {
345 check_doc(handle)
346}
347
348/// The byte length of `bytes`, clamped to `i32` — on native 64-bit test
349/// builds a length can exceed it; on wasm32 it cannot but the clamp
350/// costs nothing.
351fn len_code(bytes: &[u8]) -> i32 {
352 bytes.len().try_into().unwrap_or(i32::MAX)
353}
354
355/// Copies `bytes` to the host's buffer and returns how many were
356/// written, or `ERR_BUFFER_TOO_SMALL` / `ERR_INVALID_LENGTH`.
357///
358/// # Safety
359///
360/// `out_ptr` and `out_len` must describe a byte range that lies entirely
361/// within this module's linear memory and is valid for writes for the
362/// duration of this call. (Checked by `in_bounds` before any write.)
363unsafe fn read_into(bytes: &[u8], out_ptr: u32, out_len: u32) -> i32 {
364 if bytes.len() > out_len as usize {
365 return ERR_BUFFER_TOO_SMALL;
366 }
367 if !in_bounds(out_ptr, out_len) {
368 return ERR_INVALID_LENGTH;
369 }
370 std::ptr::copy_nonoverlapping(bytes.as_ptr(), out_ptr as *mut u8, bytes.len());
371 len_code(bytes)
372}
373
374/// Byte length of the document's whole state as one v1 update, or
375/// `ERR_NO_DOC`.
376#[no_mangle]
377pub extern "C" fn slides_core_state_len(handle: u32) -> i32 {
378 match state_vec(handle) {
379 Some(v) => len_code(&v),
380 None => ERR_NO_DOC,
381 }
382}
383
384/// # Safety
385///
386/// `out_ptr` and `out_len` must describe a byte range that lies entirely
387/// within this module's linear memory and is valid for writes for the
388/// duration of this call. (Checked by `in_bounds` before any write.)
389#[no_mangle]
390pub unsafe extern "C" fn slides_core_state_read(handle: u32, out_ptr: u32, out_len: u32) -> i32 {
391 match state_vec(handle) {
392 Some(v) => read_into(&v, out_ptr, out_len),
393 None => ERR_NO_DOC,
394 }
395}
396
397/// Byte length of the deck's JSON view, or `ERR_NO_DOC`.
398#[no_mangle]
399pub extern "C" fn slides_core_json_len(handle: u32) -> i32 {
400 match json_string(handle) {
401 Some(s) => len_code(s.as_bytes()),
402 None => ERR_NO_DOC,
403 }
404}
405
406/// # Safety
407///
408/// `out_ptr` and `out_len` must describe a byte range that lies entirely
409/// within this module's linear memory and is valid for writes for the
410/// duration of this call. (Checked by `in_bounds` before any write.)
411#[no_mangle]
412pub unsafe extern "C" fn slides_core_json_read(handle: u32, out_ptr: u32, out_len: u32) -> i32 {
413 match json_string(handle) {
414 Some(s) => read_into(s.as_bytes(), out_ptr, out_len),
415 None => ERR_NO_DOC,
416 }
417}
418
419/// Byte length of the last ops call's update (0 if none), or
420/// `ERR_NO_DOC`.
421#[no_mangle]
422pub extern "C" fn slides_core_out_len(handle: u32) -> i32 {
423 match out_vec(handle) {
424 Some(v) => len_code(&v),
425 None => ERR_NO_DOC,
426 }
427}
428
429/// # Safety
430///
431/// `out_ptr` and `out_len` must describe a byte range that lies entirely
432/// within this module's linear memory and is valid for writes for the
433/// duration of this call. (Checked by `in_bounds` before any write.)
434#[no_mangle]
435pub unsafe extern "C" fn slides_core_out_read(handle: u32, out_ptr: u32, out_len: u32) -> i32 {
436 match out_vec(handle) {
437 Some(v) => read_into(&v, out_ptr, out_len),
438 None => ERR_NO_DOC,
439 }
440}
441
442/// Byte length of the last ops call's refusal message (0 if none), or
443/// `ERR_NO_DOC`.
444#[no_mangle]
445pub extern "C" fn slides_core_err_len(handle: u32) -> i32 {
446 match err_string(handle) {
447 Some(s) => len_code(s.as_bytes()),
448 None => ERR_NO_DOC,
449 }
450}
451
452/// # Safety
453///
454/// `out_ptr` and `out_len` must describe a byte range that lies entirely
455/// within this module's linear memory and is valid for writes for the
456/// duration of this call. (Checked by `in_bounds` before any write.)
457#[no_mangle]
458pub unsafe extern "C" fn slides_core_err_read(handle: u32, out_ptr: u32, out_len: u32) -> i32 {
459 match err_string(handle) {
460 Some(s) => read_into(s.as_bytes(), out_ptr, out_len),
461 None => ERR_NO_DOC,
462 }
463}
464