Skip to main content

execsandbox/
lib.rs

1//! Rust bindings for [ExecSandbox]'s WASM guest ABI
2//! (`send`/`recv`/`conn_write`/`max_frame`), for guest modules built for
3//! the `wasm32-wasip1` target.
4//!
5//! It hides pointer/length plumbing and buffer sizing behind a small
6//! Rust-native API: [`send`], [`recv`] and [`conn_write`] work with
7//! `&[u8]`/`Vec<u8>`, [`Kind`] identifies what [`recv`] returned, and
8//! timeouts are an `Option<Duration>`.
9//!
10//! This crate must be built for `wasm32-wasip1` — the ABI it wraps only
11//! exists on that target. It has no dependency on the ExecSandbox host's
12//! own code; the ABI it binds to is defined by the host project at
13//! <https://github.com/amisonnet8/execsandbox>
14//! (`docs/spec/execsandbox_spec_ja.md`, section 5), which this crate
15//! treats as the sole source of truth.
16//!
17//! [ExecSandbox]: https://github.com/amisonnet8/execsandbox
18
19mod abi;
20
21use std::cell::RefCell;
22use std::time::Duration;
23
24/// Identifies what a received [`Message`] represents.
25#[repr(u32)]
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum Kind {
28    /// A sandbox-to-sandbox message sent by a peer's [`send`].
29    Message = 0,
30    /// A new external connection. `conn_id` is valid; `data` is empty.
31    ConnEstablished = 1,
32    /// Data received on an external connection.
33    ConnData = 2,
34    /// An external connection has closed. `conn_id` is valid; `data` is
35    /// empty. Unlike other kinds, this one is always delivered even if
36    /// the mailbox is otherwise full.
37    ConnClosed = 3,
38}
39
40impl Kind {
41    fn from_u32(v: u32) -> Kind {
42        match v {
43            0 => Kind::Message,
44            1 => Kind::ConnEstablished,
45            2 => Kind::ConnData,
46            _ => Kind::ConnClosed,
47        }
48    }
49}
50
51/// One item taken off this sandbox's mailbox by [`recv`].
52#[derive(Debug, Clone)]
53pub struct Message {
54    /// What this message represents.
55    pub kind: Kind,
56    /// The external connection this message relates to. Only meaningful
57    /// when `kind` is not [`Kind::Message`].
58    pub conn_id: u32,
59    /// The message payload. Empty for [`Kind::ConnEstablished`] and
60    /// [`Kind::ConnClosed`].
61    pub data: Vec<u8>,
62}
63
64/// Error returned by [`conn_write`] when `conn_id` does not name a
65/// currently open connection (spec section 5.4).
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub struct UnknownConnection(pub u32);
68
69impl std::fmt::Display for UnknownConnection {
70    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
71        write!(f, "execsandbox: unknown connection id {}", self.0)
72    }
73}
74
75impl std::error::Error for UnknownConnection {}
76
77/// Seam between the safe wrapper below and the actual ABI, so the
78/// wrapper's logic (timeout conversion, buffer growth, unknown-kind
79/// skipping) can be unit tested on any host architecture with a fake
80/// implementation, without requiring a real ExecSandbox host or a wasm32
81/// build. `RealHost` is the only production implementation.
82trait Host {
83    fn send(&self, dest: i32, ptr: *const u8, len: i32);
84    fn recv(&self, meta_ptr: *mut u8, buf_ptr: *mut u8, buf_cap: i32, timeout_ms: i32) -> i32;
85    fn conn_write(&self, conn_id: i32, ptr: *const u8, len: i32) -> i32;
86    fn max_frame(&self) -> i32;
87}
88
89struct RealHost;
90
91#[cfg(target_arch = "wasm32")]
92impl Host for RealHost {
93    fn send(&self, dest: i32, ptr: *const u8, len: i32) {
94        unsafe { abi::send(dest, ptr, len) }
95    }
96    fn recv(&self, meta_ptr: *mut u8, buf_ptr: *mut u8, buf_cap: i32, timeout_ms: i32) -> i32 {
97        unsafe { abi::recv(meta_ptr, buf_ptr, buf_cap, timeout_ms) }
98    }
99    fn conn_write(&self, conn_id: i32, ptr: *const u8, len: i32) -> i32 {
100        unsafe { abi::conn_write(conn_id, ptr, len) }
101    }
102    fn max_frame(&self) -> i32 {
103        unsafe { abi::max_frame() }
104    }
105}
106
107#[cfg(not(target_arch = "wasm32"))]
108impl Host for RealHost {
109    fn send(&self, _dest: i32, _ptr: *const u8, _len: i32) {
110        unreachable!("execsandbox: only usable when built for a wasm32 target")
111    }
112    fn recv(&self, _meta_ptr: *mut u8, _buf_ptr: *mut u8, _buf_cap: i32, _timeout_ms: i32) -> i32 {
113        unreachable!("execsandbox: only usable when built for a wasm32 target")
114    }
115    fn conn_write(&self, _conn_id: i32, _ptr: *const u8, _len: i32) -> i32 {
116        unreachable!("execsandbox: only usable when built for a wasm32 target")
117    }
118    fn max_frame(&self) -> i32 {
119        unreachable!("execsandbox: only usable when built for a wasm32 target")
120    }
121}
122
123thread_local! {
124    // Reused across calls to `recv` and grown on demand. Starting it at
125    // the host's configured max frame size means, per the ABI's own
126    // design, it should never need to grow in practice.
127    static RECV_BUF: RefCell<Vec<u8>> = const { RefCell::new(Vec::new()) };
128}
129
130/// Sends `data` to the destination numbered `dest`, as wired up for this
131/// sandbox instance at launch time (the host's `-d` flag).
132///
133/// `send` never blocks and never reports failure: an unconfigured
134/// destination, an unreachable peer, or a full mailbox on the receiving
135/// side all drop the message silently. This is ExecSandbox's own design,
136/// not a limitation of this wrapper (spec section 3.4).
137///
138/// ```no_run
139/// execsandbox::send(1, b"hello");
140/// ```
141pub fn send(dest: u32, data: &[u8]) {
142    send_with(&RealHost, dest, data)
143}
144
145fn send_with<H: Host>(host: &H, dest: u32, data: &[u8]) {
146    host.send(dest as i32, data.as_ptr(), data.len() as i32);
147}
148
149/// Takes one message off this sandbox's mailbox.
150///
151/// `timeout` follows [`None`] for "wait indefinitely for a message",
152/// [`Duration::ZERO`] for "return immediately", and any other
153/// [`Duration`] for "wait up to that long" (spec section 5.3). An
154/// `Option` is used, rather than a single signed value as Go's SDK does,
155/// because Rust's [`Duration`] cannot be negative.
156///
157/// Returns [`None`] if no message arrived before the timeout elapsed.
158///
159/// ```no_run
160/// use std::time::Duration;
161///
162/// if let Some(msg) = execsandbox::recv(Some(Duration::from_secs(5))) {
163///     println!("{:?} {:?}", msg.kind, msg.data);
164/// }
165/// ```
166pub fn recv(timeout: Option<Duration>) -> Option<Message> {
167    let timeout_ms = duration_to_timeout_ms(timeout);
168    RECV_BUF.with(|buf| recv_with(&RealHost, &mut buf.borrow_mut(), timeout_ms))
169}
170
171fn duration_to_timeout_ms(timeout: Option<Duration>) -> i32 {
172    match timeout {
173        None => -1,
174        Some(d) => d.as_millis().min(i32::MAX as u128) as i32,
175    }
176}
177
178fn recv_with<H: Host>(host: &H, buf: &mut Vec<u8>, timeout_ms: i32) -> Option<Message> {
179    if buf.is_empty() {
180        let size = host.max_frame();
181        buf.resize(if size > 0 { size as usize } else { 4096 }, 0);
182    }
183
184    let mut meta = [0u8; 8];
185    loop {
186        let n = host.recv(
187            meta.as_mut_ptr(),
188            buf.as_mut_ptr(),
189            buf.len() as i32,
190            timeout_ms,
191        );
192        if n == -1 {
193            return None;
194        }
195        if n < -1 {
196            buf.resize((-(n + 1)) as usize, 0);
197            continue;
198        }
199
200        let kind_raw = u32::from_le_bytes(meta[0..4].try_into().unwrap());
201        if kind_raw > Kind::ConnClosed as u32 {
202            // Forward compatibility: the host may add kinds this version
203            // of the wrapper doesn't know about. Per spec section 5.3,
204            // unknown kinds are skipped rather than surfaced. Note this
205            // re-issues the same timeout rather than tracking a
206            // remaining budget, so a stream of unknown kinds can make
207            // recv wait longer than `timeout` in total.
208            continue;
209        }
210        let conn_id = u32::from_le_bytes(meta[4..8].try_into().unwrap());
211        let data = buf[..n as usize].to_vec();
212        return Some(Message {
213            kind: Kind::from_u32(kind_raw),
214            conn_id,
215            data,
216        });
217    }
218}
219
220/// Writes `data` to the external connection identified by `conn_id`.
221/// Returns [`UnknownConnection`] if `conn_id` does not name a currently
222/// open connection (spec section 5.4).
223///
224/// ```no_run
225/// if let Some(msg) = execsandbox::recv(None) {
226///     if msg.kind == execsandbox::Kind::ConnData {
227///         let _ = execsandbox::conn_write(msg.conn_id, &msg.data);
228///     }
229/// }
230/// ```
231pub fn conn_write(conn_id: u32, data: &[u8]) -> Result<(), UnknownConnection> {
232    conn_write_with(&RealHost, conn_id, data)
233}
234
235fn conn_write_with<H: Host>(host: &H, conn_id: u32, data: &[u8]) -> Result<(), UnknownConnection> {
236    if host.conn_write(conn_id as i32, data.as_ptr(), data.len() as i32) == -1 {
237        return Err(UnknownConnection(conn_id));
238    }
239    Ok(())
240}
241
242/// Reports the maximum frame size, in bytes, that this ExecSandbox
243/// instance was configured with (the host's `--max-frame` flag).
244///
245/// ```no_run
246/// let max_frame = execsandbox::max_frame();
247/// ```
248pub fn max_frame() -> usize {
249    max_frame_with(&RealHost)
250}
251
252fn max_frame_with<H: Host>(host: &H) -> usize {
253    host.max_frame() as usize
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259    use std::rc::Rc;
260
261    type SendFn = Box<dyn FnMut(i32, *const u8, i32)>;
262    type RecvFn = Box<dyn FnMut(*mut u8, *mut u8, i32, i32) -> i32>;
263    type ConnWriteFn = Box<dyn FnMut(i32, *const u8, i32) -> i32>;
264    type MaxFrameFn = Box<dyn FnMut() -> i32>;
265
266    #[derive(Default)]
267    struct MockHost {
268        send: RefCell<Option<SendFn>>,
269        recv: RefCell<Option<RecvFn>>,
270        conn_write: RefCell<Option<ConnWriteFn>>,
271        max_frame: RefCell<Option<MaxFrameFn>>,
272    }
273
274    impl Host for MockHost {
275        fn send(&self, dest: i32, ptr: *const u8, len: i32) {
276            (self
277                .send
278                .borrow_mut()
279                .as_mut()
280                .expect("send not configured"))(dest, ptr, len)
281        }
282        fn recv(&self, meta_ptr: *mut u8, buf_ptr: *mut u8, buf_cap: i32, timeout_ms: i32) -> i32 {
283            (self
284                .recv
285                .borrow_mut()
286                .as_mut()
287                .expect("recv not configured"))(meta_ptr, buf_ptr, buf_cap, timeout_ms)
288        }
289        fn conn_write(&self, conn_id: i32, ptr: *const u8, len: i32) -> i32 {
290            (self
291                .conn_write
292                .borrow_mut()
293                .as_mut()
294                .expect("conn_write not configured"))(conn_id, ptr, len)
295        }
296        fn max_frame(&self) -> i32 {
297            (self
298                .max_frame
299                .borrow_mut()
300                .as_mut()
301                .expect("max_frame not configured"))()
302        }
303    }
304
305    /// Builds a recv fake that returns the given (kind, conn_id, payload)
306    /// on the first call and reports a timeout on every subsequent call,
307    /// so a test cannot spin forever if the loop logic has a bug.
308    fn fake_recv_once(
309        kind: Kind,
310        conn_id: u32,
311        payload: &'static [u8],
312    ) -> impl FnMut(*mut u8, *mut u8, i32, i32) -> i32 {
313        let mut served = false;
314        move |meta_ptr, buf_ptr, buf_cap, _timeout_ms| {
315            if served {
316                return -1;
317            }
318            served = true;
319            assert!(
320                payload.len() as i32 <= buf_cap,
321                "fake_recv_once: payload ({} bytes) does not fit in the buffer given by recv ({} bytes)",
322                payload.len(),
323                buf_cap
324            );
325            unsafe {
326                let meta = std::slice::from_raw_parts_mut(meta_ptr, 8);
327                meta[0..4].copy_from_slice(&(kind as u32).to_le_bytes());
328                meta[4..8].copy_from_slice(&conn_id.to_le_bytes());
329                let buf = std::slice::from_raw_parts_mut(buf_ptr, buf_cap as usize);
330                buf[..payload.len()].copy_from_slice(payload);
331            }
332            payload.len() as i32
333        }
334    }
335
336    #[test]
337    fn send_passes_dest_and_length() {
338        let host = MockHost::default();
339        let captured = Rc::new(RefCell::new((0i32, 0i32, false)));
340        let captured2 = captured.clone();
341        *host.send.borrow_mut() = Some(Box::new(move |dest, ptr, len| {
342            *captured2.borrow_mut() = (dest, len, !ptr.is_null());
343        }));
344
345        send_with(&host, 3, b"hi");
346
347        let (dest, len, ptr_nonnull) = *captured.borrow();
348        assert_eq!(dest, 3);
349        assert_eq!(len, 2);
350        assert!(
351            ptr_nonnull,
352            "send should be called with a non-null pointer for non-empty data"
353        );
354    }
355
356    #[test]
357    fn recv_success_decodes_kind_conn_id_and_data() {
358        let host = MockHost::default();
359        *host.max_frame.borrow_mut() = Some(Box::new(|| 4096));
360        *host.recv.borrow_mut() = Some(Box::new(fake_recv_once(Kind::ConnData, 7, b"payload")));
361
362        let msg = recv_with(&host, &mut Vec::new(), 1000).expect("expected a message");
363        assert_eq!(msg.kind, Kind::ConnData);
364        assert_eq!(msg.conn_id, 7);
365        assert_eq!(msg.data, b"payload");
366    }
367
368    #[test]
369    fn recv_reports_timeout_as_none() {
370        let host = MockHost::default();
371        *host.max_frame.borrow_mut() = Some(Box::new(|| 4096));
372        *host.recv.borrow_mut() = Some(Box::new(|_, _, _, _| -1));
373
374        assert!(recv_with(&host, &mut Vec::new(), 100).is_none());
375    }
376
377    #[test]
378    fn duration_to_timeout_ms_matches_abi_convention() {
379        assert_eq!(
380            duration_to_timeout_ms(None),
381            -1,
382            "no timeout should block forever (-1)"
383        );
384        assert_eq!(
385            duration_to_timeout_ms(Some(Duration::ZERO)),
386            0,
387            "a zero duration should return immediately (0)"
388        );
389        assert_eq!(
390            duration_to_timeout_ms(Some(Duration::from_millis(250))),
391            250,
392            "a positive duration should convert to milliseconds"
393        );
394    }
395
396    #[test]
397    fn recv_grows_buffer_on_undersized_reply() {
398        let host = MockHost::default();
399        *host.max_frame.borrow_mut() = Some(Box::new(|| 4)); // deliberately too small
400        let payload: &'static [u8] = b"this does not fit in 4 bytes";
401        let calls = Rc::new(RefCell::new(0));
402        let calls2 = calls.clone();
403        *host.recv.borrow_mut() = Some(Box::new(move |meta_ptr, buf_ptr, buf_cap, _timeout_ms| {
404            *calls2.borrow_mut() += 1;
405            if payload.len() as i32 > buf_cap {
406                return -(payload.len() as i32 + 1);
407            }
408            unsafe {
409                let meta = std::slice::from_raw_parts_mut(meta_ptr, 8);
410                meta[0..4].copy_from_slice(&(Kind::Message as u32).to_le_bytes());
411                meta[4..8].copy_from_slice(&0u32.to_le_bytes());
412                let buf = std::slice::from_raw_parts_mut(buf_ptr, buf_cap as usize);
413                buf[..payload.len()].copy_from_slice(payload);
414            }
415            payload.len() as i32
416        }));
417
418        let mut buf = Vec::new();
419        let msg = recv_with(&host, &mut buf, -1).expect("expected a message after the buffer grew");
420        assert_eq!(msg.data, payload);
421        assert_eq!(
422            *calls.borrow(),
423            2,
424            "recv should be called twice: undersized, then resized"
425        );
426        assert!(buf.len() >= payload.len());
427    }
428
429    #[test]
430    fn recv_skips_unknown_kind() {
431        let host = MockHost::default();
432        *host.max_frame.borrow_mut() = Some(Box::new(|| 4096));
433        let calls = Rc::new(RefCell::new(0));
434        let calls2 = calls.clone();
435        *host.recv.borrow_mut() = Some(Box::new(move |meta_ptr, buf_ptr, buf_cap, _timeout_ms| {
436            *calls2.borrow_mut() += 1;
437            let call = *calls2.borrow();
438            unsafe {
439                let meta = std::slice::from_raw_parts_mut(meta_ptr, 8);
440                let buf = std::slice::from_raw_parts_mut(buf_ptr, buf_cap as usize);
441                if call == 1 {
442                    // A kind the host may add in the future, unknown to
443                    // this wrapper: spec section 5.3 says to skip it.
444                    meta[0..4].copy_from_slice(&99u32.to_le_bytes());
445                    meta[4..8].copy_from_slice(&0u32.to_le_bytes());
446                    buf[..b"ignored".len()].copy_from_slice(b"ignored");
447                    return b"ignored".len() as i32;
448                }
449                meta[0..4].copy_from_slice(&(Kind::Message as u32).to_le_bytes());
450                meta[4..8].copy_from_slice(&0u32.to_le_bytes());
451                buf[..b"real".len()].copy_from_slice(b"real");
452                b"real".len() as i32
453            }
454        }));
455
456        let msg =
457            recv_with(&host, &mut Vec::new(), -1).expect("expected the second, known-kind message");
458        assert_eq!(*calls.borrow(), 2);
459        assert_eq!(msg.kind, Kind::Message);
460        assert_eq!(msg.data, b"real");
461    }
462
463    #[test]
464    fn conn_write_success_returns_ok() {
465        let host = MockHost::default();
466        let captured = Rc::new(RefCell::new(0i32));
467        let captured2 = captured.clone();
468        *host.conn_write.borrow_mut() = Some(Box::new(move |conn_id, _, _| {
469            *captured2.borrow_mut() = conn_id;
470            0
471        }));
472
473        assert!(conn_write_with(&host, 42, b"data").is_ok());
474        assert_eq!(*captured.borrow(), 42);
475    }
476
477    #[test]
478    fn conn_write_unknown_conn_returns_error() {
479        let host = MockHost::default();
480        *host.conn_write.borrow_mut() = Some(Box::new(|_, _, _| -1));
481
482        assert_eq!(
483            conn_write_with(&host, 42, b"data"),
484            Err(UnknownConnection(42))
485        );
486    }
487
488    #[test]
489    fn max_frame_passes_through_host_value() {
490        let host = MockHost::default();
491        *host.max_frame.borrow_mut() = Some(Box::new(|| 1_048_576));
492
493        assert_eq!(max_frame_with(&host), 1_048_576);
494    }
495}