Skip to main content

flaron_sdk/
mem.rs

1//! Guest-side memory helpers and the per-invocation bump arena.
2//!
3//! Every host function that returns data does so by writing into the guest's
4//! linear memory at a slot the SDK allocates with [`guest_alloc`]. The host
5//! returns a packed `(ptr, len)` value that callers decode with the helpers
6//! in this module.
7//!
8//! ## Wasm vs host builds
9//!
10//! On `wasm32-unknown-unknown` the bump arena is a static byte array and a
11//! "pointer" returned from [`guest_alloc`] is a real linear-memory address -
12//! the wasm host writes/reads directly through it.
13//!
14//! On native targets (used by `cargo test`) the arena is a `thread_local` Box
15//! and a "pointer" is an OFFSET into that buffer. Real 64-bit Rust pointers
16//! cannot fit in the i32 ABI slots the wasm host expects, so the test mock
17//! and the SDK both route through the offset model. The cfg-gates in this
18//! module are the only place that distinction shows up - every other module
19//! is target-agnostic.
20//!
21//! This module is `pub(crate)` aside from the two arena hooks
22//! ([`guest_alloc`] and [`reset_arena`]) - it is plumbing, not API.
23
24/// Decode a packed i64 returned from the host into `(ptr, len)`.
25///
26/// Host convention: high 32 bits = pointer into the guest's address space,
27/// low 32 bits = length in bytes.
28pub(crate) fn decode_ptr_len(packed: i64) -> (u32, u32) {
29    let packed = packed as u64;
30    ((packed >> 32) as u32, (packed & 0xFFFF_FFFF) as u32)
31}
32
33/// Pack a `(ptr, len)` pair into a host-style i64 return value.
34///
35/// Inverse of [`decode_ptr_len`]. Used by the test mock host to assemble
36/// responses; the real wasm host packs values the same way.
37#[allow(dead_code)]
38pub(crate) fn encode_ptr_len(ptr: u32, len: u32) -> i64 {
39    (((ptr as u64) << 32) | (len as u64)) as i64
40}
41
42/// Read raw bytes from the guest at the given pointer/offset and length.
43///
44/// # Safety
45/// On wasm the address must lie within memory the host has just written into
46/// the guest's linear memory via the bump arena. On non-wasm the value is
47/// treated as an offset into the thread-local arena buffer instead.
48pub(crate) unsafe fn read_bytes(ptr: u32, len: u32) -> Vec<u8> {
49    if len == 0 {
50        return Vec::new();
51    }
52    #[cfg(target_arch = "wasm32")]
53    {
54        core::slice::from_raw_parts(ptr as usize as *const u8, len as usize).to_vec()
55    }
56    #[cfg(not(target_arch = "wasm32"))]
57    {
58        arena::read_at(ptr as usize, len as usize)
59    }
60}
61
62/// Read a UTF-8 string from guest memory.
63///
64/// # Safety
65/// Same requirements as [`read_bytes`]. Bytes that are not valid UTF-8 are
66/// replaced with the Unicode replacement character - we accept that loss to
67/// avoid undefined behaviour from `from_utf8_unchecked`.
68pub(crate) unsafe fn read_string(ptr: u32, len: u32) -> String {
69    if len == 0 {
70        return String::new();
71    }
72    let bytes = read_bytes(ptr, len);
73    String::from_utf8_lossy(&bytes).into_owned()
74}
75
76/// Read a string from a packed `(ptr, len)` return value.
77///
78/// Returns `None` when the host returned `0` ("no result" / not found).
79///
80/// # Safety
81/// The packed pointer must point at memory the host wrote into the guest's
82/// address space within the current invocation. See [`read_string`].
83pub(crate) unsafe fn read_packed_string(packed: i64) -> Option<String> {
84    if packed == 0 {
85        return None;
86    }
87    let (ptr, len) = decode_ptr_len(packed);
88    Some(read_string(ptr, len))
89}
90
91/// Read raw bytes from a packed `(ptr, len)` return value.
92///
93/// Returns `None` when the host returned `0` ("no result" / not found).
94///
95/// # Safety
96/// The packed pointer must point at memory the host wrote into the guest's
97/// address space within the current invocation.
98pub(crate) unsafe fn read_packed_bytes(packed: i64) -> Option<Vec<u8>> {
99    if packed == 0 {
100        return None;
101    }
102    let (ptr, len) = decode_ptr_len(packed);
103    Some(read_bytes(ptr, len))
104}
105
106/// Decode a hex string into raw bytes. Returns `None` on the first invalid
107/// character - callers MUST treat this as a hard failure (corrupted host
108/// response, never silently zero-fill, especially for crypto material).
109pub(crate) fn hex_decode(hex: &str) -> Option<Vec<u8>> {
110    let hex = hex.as_bytes();
111    if !hex.len().is_multiple_of(2) {
112        return None;
113    }
114    let mut bytes = Vec::with_capacity(hex.len() / 2);
115    for chunk in hex.chunks_exact(2) {
116        let hi = hex_nibble(chunk[0])?;
117        let lo = hex_nibble(chunk[1])?;
118        bytes.push((hi << 4) | lo);
119    }
120    Some(bytes)
121}
122
123fn hex_nibble(c: u8) -> Option<u8> {
124    match c {
125        b'0'..=b'9' => Some(c - b'0'),
126        b'a'..=b'f' => Some(c - b'a' + 10),
127        b'A'..=b'F' => Some(c - b'A' + 10),
128        _ => None,
129    }
130}
131
132/// Pack a borrowed byte slice as a `(ptr, len)` host-call argument.
133///
134/// On wasm this is a zero-cost cast: the i32 is the slice's pointer, which
135/// the wasm host can dereference directly.
136///
137/// On non-wasm targets the slice is copied into the per-thread arena and
138/// the offset is returned - real 64-bit Rust pointers don't fit in i32 ABI
139/// slots, so we use offsets uniformly with the response side.
140pub(crate) fn host_arg_bytes(data: &[u8]) -> (i32, i32) {
141    #[cfg(target_arch = "wasm32")]
142    {
143        (data.as_ptr() as i32, data.len() as i32)
144    }
145    #[cfg(not(target_arch = "wasm32"))]
146    {
147        if data.is_empty() {
148            return (0, 0);
149        }
150        let offset = guest_alloc(data.len() as i32);
151        if offset == 0 {
152            return (0, 0);
153        }
154        arena::write_at(offset as usize, data);
155        (offset, data.len() as i32)
156    }
157}
158
159/// Convenience wrapper for string args.
160pub(crate) fn host_arg_str(s: &str) -> (i32, i32) {
161    host_arg_bytes(s.as_bytes())
162}
163
164// ---------- Per-invocation bump allocator ----------
165//
166// The flaron host calls the guest's exported `alloc(size)` every time it
167// needs to hand a value back (every spark_get, every header lookup, every WS
168// event, etc.). A naive global allocator would leak that memory for the
169// lifetime of the entire WASM instance - long-lived flares would steadily
170// grow until OOM.
171//
172// Instead we use a per-invocation bump arena. The flare's WASM exports
173// (`handle_request`, `ws_open`, `ws_message`, `ws_close`) reset the arena at
174// the top of each invocation; everything the host allocates during that
175// invocation is reclaimed when control returns to the host.
176
177const ARENA_SIZE: usize = 256 * 1024;
178
179#[cfg(target_arch = "wasm32")]
180mod arena {
181    use super::ARENA_SIZE;
182    use core::cell::UnsafeCell;
183
184    struct BumpArena {
185        buf: UnsafeCell<[u8; ARENA_SIZE]>,
186        offset: UnsafeCell<usize>,
187    }
188
189    // SAFETY: WASM guests are single-threaded; nothing else can race the arena.
190    unsafe impl Sync for BumpArena {}
191
192    static ARENA: BumpArena = BumpArena {
193        buf: UnsafeCell::new([0; ARENA_SIZE]),
194        offset: UnsafeCell::new(0),
195    };
196
197    pub fn reset() {
198        // SAFETY: single-threaded guest; we own the arena.
199        unsafe {
200            *ARENA.offset.get() = 0;
201        }
202    }
203
204    pub fn alloc(size: i32) -> i32 {
205        if size <= 0 {
206            return 0;
207        }
208        let size = size as usize;
209        // SAFETY: single-threaded guest; only this function writes `offset`.
210        unsafe {
211            let offset = &mut *ARENA.offset.get();
212            let aligned = (*offset + 7) & !7;
213            if aligned.checked_add(size).is_none_or(|end| end > ARENA_SIZE) {
214                return 0;
215            }
216            *offset = aligned + size;
217            let buf = ARENA.buf.get() as *mut u8;
218            buf.add(aligned) as i32
219        }
220    }
221}
222
223#[cfg(not(target_arch = "wasm32"))]
224mod arena {
225    use super::ARENA_SIZE;
226    use std::cell::RefCell;
227
228    /// First valid offset. The SDK treats a packed return of `0` as "no
229    /// result", so we never hand out offset 0 - the first 8 bytes of the
230    /// buffer are reserved sentinel space.
231    const FIRST_OFFSET: usize = 8;
232
233    struct ThreadArena {
234        buf: Box<[u8; ARENA_SIZE]>,
235        offset: usize,
236    }
237
238    impl ThreadArena {
239        fn new() -> Self {
240            Self {
241                buf: Box::new([0; ARENA_SIZE]),
242                offset: FIRST_OFFSET,
243            }
244        }
245    }
246
247    thread_local! {
248        static ARENA: RefCell<ThreadArena> = RefCell::new(ThreadArena::new());
249    }
250
251    pub fn reset() {
252        ARENA.with(|a| a.borrow_mut().offset = FIRST_OFFSET);
253    }
254
255    /// Returns the OFFSET (not a real pointer) into the per-thread arena.
256    /// On non-wasm targets the SDK and the test mock both interpret the
257    /// returned i32 as `arena[offset..]`, never as a Rust pointer.
258    pub fn alloc(size: i32) -> i32 {
259        if size <= 0 {
260            return 0;
261        }
262        let size = size as usize;
263        ARENA.with(|a| {
264            let mut a = a.borrow_mut();
265            let aligned = (a.offset + 7) & !7;
266            if aligned.checked_add(size).is_none_or(|end| end > ARENA_SIZE) {
267                return 0;
268            }
269            a.offset = aligned + size;
270            aligned as i32
271        })
272    }
273
274    pub fn write_at(offset: usize, data: &[u8]) {
275        ARENA.with(|a| {
276            let mut a = a.borrow_mut();
277            a.buf[offset..offset + data.len()].copy_from_slice(data);
278        })
279    }
280
281    pub fn read_at(offset: usize, len: usize) -> Vec<u8> {
282        ARENA.with(|a| a.borrow().buf[offset..offset + len].to_vec())
283    }
284}
285
286/// Reset the bump arena. Call this at the top of every guest export so the
287/// next host invocation starts with a fresh 256 KiB scratch space.
288///
289/// The flare entry-point macros ([`crate::handle_request!`],
290/// [`crate::ws_handlers!`]) call this for you - only invoke it directly if
291/// you are wiring up your own export functions.
292pub fn reset_arena() {
293    arena::reset();
294}
295
296/// Guest memory allocator the host calls (via the `alloc` export generated
297/// by [`crate::export_alloc!`]) to write return values into the WASM linear
298/// memory. Hands out 8-byte aligned slices from the bump arena.
299///
300/// Returns `0` on failure (size not positive, arena exhausted) - the host
301/// treats `0` as "guest cannot accept this value" and propagates an error.
302pub fn guest_alloc(size: i32) -> i32 {
303    arena::alloc(size)
304}
305
306/// Test-only re-exports of the arena helpers. Used by `crate::ffi`'s mock
307/// host on non-wasm targets to write canned responses.
308#[cfg(not(target_arch = "wasm32"))]
309pub(crate) use arena::{read_at as arena_read_at, write_at as arena_write_at};
310
311#[cfg(test)]
312mod tests {
313    use super::*;
314
315    #[test]
316    fn decode_ptr_len_splits_high_low() {
317        let packed = encode_ptr_len(0xDEAD_BEEF, 0x1234);
318        let (ptr, len) = decode_ptr_len(packed);
319        assert_eq!(ptr, 0xDEAD_BEEF);
320        assert_eq!(len, 0x1234);
321    }
322
323    #[test]
324    fn encode_decode_round_trip_zero() {
325        assert_eq!(decode_ptr_len(0), (0, 0));
326    }
327
328    #[test]
329    fn hex_decode_lowercase() {
330        assert_eq!(hex_decode("deadbeef"), Some(vec![0xde, 0xad, 0xbe, 0xef]));
331    }
332
333    #[test]
334    fn hex_decode_uppercase() {
335        assert_eq!(hex_decode("DEADBEEF"), Some(vec![0xde, 0xad, 0xbe, 0xef]));
336    }
337
338    #[test]
339    fn hex_decode_mixed_case() {
340        assert_eq!(hex_decode("DeAdBeEf"), Some(vec![0xde, 0xad, 0xbe, 0xef]));
341    }
342
343    #[test]
344    fn hex_decode_odd_length_fails() {
345        assert!(hex_decode("abc").is_none());
346    }
347
348    #[test]
349    fn hex_decode_invalid_char_fails() {
350        assert!(hex_decode("zz").is_none());
351    }
352
353    #[test]
354    fn hex_decode_empty_ok() {
355        assert_eq!(hex_decode(""), Some(vec![]));
356    }
357
358    #[test]
359    fn guest_alloc_returns_zero_for_non_positive() {
360        reset_arena();
361        assert_eq!(guest_alloc(0), 0);
362        assert_eq!(guest_alloc(-1), 0);
363    }
364
365    #[test]
366    fn guest_alloc_aligned_to_eight() {
367        reset_arena();
368        let p1 = guest_alloc(1);
369        assert_ne!(p1, 0);
370        let p2 = guest_alloc(1);
371        assert_ne!(p2, 0);
372        // p2 must be 8 bytes after p1 (alignment).
373        assert_eq!(p2 - p1, 8);
374    }
375
376    #[test]
377    fn guest_alloc_returns_zero_when_exhausted() {
378        reset_arena();
379        // Single allocation larger than the arena.
380        assert_eq!(guest_alloc((ARENA_SIZE + 1) as i32), 0);
381    }
382
383    #[test]
384    fn reset_arena_recycles_space() {
385        reset_arena();
386        let p1 = guest_alloc(64);
387        reset_arena();
388        let p2 = guest_alloc(64);
389        assert_eq!(p1, p2);
390    }
391
392    #[test]
393    fn read_packed_zero_is_none() {
394        assert!(unsafe { read_packed_string(0) }.is_none());
395        assert!(unsafe { read_packed_bytes(0) }.is_none());
396    }
397
398    #[test]
399    fn read_bytes_zero_len_is_empty() {
400        let bytes = unsafe { read_bytes(0, 0) };
401        assert!(bytes.is_empty());
402    }
403
404    #[test]
405    fn host_arg_bytes_round_trip() {
406        reset_arena();
407        let (ptr, len) = host_arg_bytes(b"hello");
408        #[cfg(not(target_arch = "wasm32"))]
409        {
410            assert!(ptr > 0);
411            assert_eq!(len, 5);
412            let read = unsafe { read_bytes(ptr as u32, len as u32) };
413            assert_eq!(read, b"hello");
414        }
415        #[cfg(target_arch = "wasm32")]
416        {
417            let _ = (ptr, len);
418        }
419    }
420}