Rust wasmi integration: WASM sandbox with fuel metering and memory export

ober

40960e650d6efcfd4af65d532f0bd13b3df1d93e

diff --git a/jerboa-native-rs/Cargo.lock b/jerboa-native-rs/Cargo.lock
index 075476a..6c55e63 100644
--- a/jerboa-native-rs/Cargo.lock
+++ b/jerboa-native-rs/Cargo.lock
@@ -30,6 +30,24 @@ dependencies = [
 ]
 
 [[package]]
+name = "argon2"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
+dependencies = [
+ "base64ct",
+ "blake2",
+ "cpufeatures",
+ "password-hash",
+]
+
+[[package]]
+name = "arrayvec"
+version = "0.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
+
+[[package]]
 name = "async-trait"
 version = "0.1.89"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -59,6 +77,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
 
 [[package]]
+name = "blake2"
+version = "0.10.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
+dependencies = [
+ "digest",
+]
+
+[[package]]
 name = "block-buffer"
 version = "0.10.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -203,6 +230,12 @@ dependencies = [
 ]
 
 [[package]]
+name = "downcast-rs"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
+
+[[package]]
 name = "ed25519"
 version = "2.2.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -227,6 +260,12 @@ dependencies = [
 ]
 
 [[package]]
+name = "equivalent"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
+
+[[package]]
 name = "fallible-iterator"
 version = "0.2.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -267,6 +306,12 @@ dependencies = [
 ]
 
 [[package]]
+name = "foldhash"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
+
+[[package]]
 name = "futures-channel"
 version = "0.3.32"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -349,12 +394,27 @@ dependencies = [
 ]
 
 [[package]]
+name = "hashbrown"
+version = "0.15.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
+dependencies = [
+ "foldhash",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.16.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
+
+[[package]]
 name = "hashlink"
 version = "0.9.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af"
 dependencies = [
- "hashbrown",
+ "hashbrown 0.14.5",
 ]
 
 [[package]]
@@ -376,6 +436,16 @@ dependencies = [
 ]
 
 [[package]]
+name = "indexmap"
+version = "2.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
+dependencies = [
+ "equivalent",
+ "hashbrown 0.16.1",
+]
+
+[[package]]
 name = "inotify"
 version = "0.11.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -408,6 +478,7 @@ dependencies = [
 name = "jerboa-native"
 version = "0.1.0"
 dependencies = [
+ "argon2",
  "ed25519-dalek",
  "flate2",
  "hkdf",
@@ -424,6 +495,7 @@ dependencies = [
  "scrypt",
  "sha2",
  "time",
+ "wasmi",
  "webpki-roots 0.26.11",
  "x25519-dalek",
 ]
@@ -445,6 +517,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d"
 
 [[package]]
+name = "libm"
+version = "0.2.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
+
+[[package]]
 name = "libredox"
 version = "0.1.14"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -517,6 +595,12 @@ dependencies = [
 ]
 
 [[package]]
+name = "multi-stash"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "685a9ac4b61f4e728e1d2c6a7844609c16527aeb5e6c865915c08e619c16410f"
+
+[[package]]
 name = "num-conv"
 version = "0.2.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1023,6 +1107,12 @@ dependencies = [
 ]
 
 [[package]]
+name = "spin"
+version = "0.9.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
+
+[[package]]
 name = "spki"
 version = "0.7.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1033,6 +1123,16 @@ dependencies = [
 ]
 
 [[package]]
+name = "string-interner"
+version = "0.18.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1a3275464d7a9f2d4cac57c89c2ef96a8524dba2864c8d6f82e3980baf136f9b"
+dependencies = [
+ "hashbrown 0.15.5",
+ "serde",
+]
+
+[[package]]
 name = "stringprep"
 version = "0.1.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1277,6 +1377,60 @@ dependencies = [
 ]
 
 [[package]]
+name = "wasmi"
+version = "0.40.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a19af97fcb96045dd1d6b4d23e2b4abdbbe81723dbc5c9f016eb52145b320063"
+dependencies = [
+ "arrayvec",
+ "multi-stash",
+ "smallvec",
+ "spin",
+ "wasmi_collections",
+ "wasmi_core",
+ "wasmi_ir",
+ "wasmparser",
+]
+
+[[package]]
+name = "wasmi_collections"
+version = "0.40.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e80d6b275b1c922021939d561574bf376613493ae2b61c6963b15db0e8813562"
+dependencies = [
+ "string-interner",
+]
+
+[[package]]
+name = "wasmi_core"
+version = "0.40.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a8c51482cc32d31c2c7ff211cd2bedd73c5bd057ba16a2ed0110e7a96097c33"
+dependencies = [
+ "downcast-rs",
+ "libm",
+]
+
+[[package]]
+name = "wasmi_ir"
+version = "0.40.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e431a14c186db59212a88516788bd68ed51f87aa1e08d1df742522867b5289a"
+dependencies = [
+ "wasmi_core",
+]
+
+[[package]]
+name = "wasmparser"
+version = "0.221.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d06bfa36ab3ac2be0dee563380147a5b81ba10dd8885d7fbbc9eb574be67d185"
+dependencies = [
+ "bitflags",
+ "indexmap",
+]
+
+[[package]]
 name = "web-sys"
 version = "0.3.91"
 source = "registry+https://github.com/rust-lang/crates.io-index"
diff --git a/jerboa-native-rs/Cargo.toml b/jerboa-native-rs/Cargo.toml
index facda7d..226ed80 100644
--- a/jerboa-native-rs/Cargo.toml
+++ b/jerboa-native-rs/Cargo.toml
@@ -25,6 +25,7 @@ rustls = { version = "0.23", default-features = false, features = ["ring", "logg
 rustls-pki-types = "1"
 rustls-pemfile = "2"
 webpki-roots = "0.26"
+wasmi = "0.40"
 
 [target.'cfg(target_os = "linux")'.dependencies]
 inotify = { version = "0.11", default-features = false }
diff --git a/jerboa-native-rs/src/lib.rs b/jerboa-native-rs/src/lib.rs
index 8ed19bd..71eeff2 100644
--- a/jerboa-native-rs/src/lib.rs
+++ b/jerboa-native-rs/src/lib.rs
@@ -12,6 +12,7 @@ mod integrity;
 mod x25519;
 mod ed25519;
 mod process_ctl;
+mod wasm;
 
 #[cfg(target_os = "linux")]
 mod epoll;
diff --git a/jerboa-native-rs/src/wasm.rs b/jerboa-native-rs/src/wasm.rs
new file mode 100644
index 0000000..aff1e82
--- /dev/null
+++ b/jerboa-native-rs/src/wasm.rs
@@ -0,0 +1,471 @@
+//! Rust-side WASM runtime via wasmi.
+//!
+//! Provides a handle-based FFI for loading and executing WASM modules
+//! from Chez Scheme. This is the "critical sections in Rust VM" path:
+//! security-sensitive parsers run inside wasmi's sandbox, isolated from
+//! the Chez Scheme address space and its ROP gadget surface.
+//!
+//! Architecture:
+//!   Scheme code → FFI → wasmi interpreter → WASM bytecode
+//!
+//! The wasmi interpreter is memory-safe Rust. Even with arbitrary write
+//! inside WASM linear memory, an attacker cannot reach Chez runtime gadgets.
+
+use std::collections::HashMap;
+use std::sync::Mutex;
+
+use wasmi::*;
+use wasmi::core::ValType;
+
+use crate::panic::{ffi_wrap, set_last_error};
+
+// ============================================================
+// Handle management
+// ============================================================
+
+struct WasmModule {
+    engine: Engine,
+    module: Module,
+}
+
+struct WasmInstance {
+    store: Store<()>,
+    instance: Instance,
+}
+
+macro_rules! lazy_handles {
+    ($($name:ident: $type:ty),* $(,)?) => {
+        $(
+            fn $name() -> &'static Mutex<$type> {
+                use std::sync::OnceLock;
+                static INSTANCE: OnceLock<Mutex<$type>> = OnceLock::new();
+                INSTANCE.get_or_init(|| Mutex::new(HashMap::new()))
+            }
+        )*
+    };
+}
+
+lazy_handles! {
+    wasm_modules: HashMap<u64, WasmModule>,
+    wasm_instances: HashMap<u64, WasmInstance>,
+}
+
+static NEXT_WASM_HANDLE: std::sync::atomic::AtomicU64 =
+    std::sync::atomic::AtomicU64::new(1);
+
+fn next_handle() -> u64 {
+    NEXT_WASM_HANDLE.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
+}
+
+// ============================================================
+// Module: load and validate WASM bytecode
+// ============================================================
+
+/// Load a WASM module from bytecode.
+/// Returns a module handle (>0) on success, 0 on error.
+/// Engine is configured with fuel metering for deterministic termination.
+#[no_mangle]
+pub extern "C" fn jerboa_wasm_module_new(
+    bytes: *const u8,
+    bytes_len: usize,
+) -> u64 {
+    match std::panic::catch_unwind(|| {
+        if bytes.is_null() || bytes_len == 0 {
+            set_last_error("null or empty WASM bytecode".to_string());
+            return 0;
+        }
+
+        let wasm_bytes = unsafe { std::slice::from_raw_parts(bytes, bytes_len) };
+
+        let mut config = Config::default();
+        config.consume_fuel(true);
+        let engine = Engine::new(&config);
+
+        let module = match Module::new(&engine, wasm_bytes) {
+            Ok(m) => m,
+            Err(e) => {
+                set_last_error(format!("WASM module validation failed: {e}"));
+                return 0;
+            }
+        };
+
+        let handle = next_handle();
+        wasm_modules()
+            .lock()
+            .unwrap()
+            .insert(handle, WasmModule { engine, module });
+        handle
+    }) {
+        Ok(h) => h,
+        Err(_) => {
+            set_last_error("panic in jerboa_wasm_module_new".to_string());
+            0
+        }
+    }
+}
+
+/// Free a WASM module.
+#[no_mangle]
+pub extern "C" fn jerboa_wasm_module_free(handle: u64) {
+    let _ = wasm_modules().lock().unwrap().remove(&handle);
+}
+
+// ============================================================
+// Instance: instantiate a module for execution
+// ============================================================
+
+/// Instantiate a WASM module (no imports — pure computation).
+/// `fuel` = max instructions (0 = default 10M).
+/// Returns instance handle (>0) on success, 0 on error.
+#[no_mangle]
+pub extern "C" fn jerboa_wasm_instance_new(
+    module_handle: u64,
+    fuel: u64,
+) -> u64 {
+    match std::panic::catch_unwind(|| {
+        let modules = wasm_modules().lock().unwrap();
+        let wmod = match modules.get(&module_handle) {
+            Some(m) => m,
+            None => {
+                set_last_error("invalid module handle".to_string());
+                return 0;
+            }
+        };
+
+        let mut store = Store::new(&wmod.engine, ());
+        let fuel_amount = if fuel == 0 { 10_000_000 } else { fuel };
+        let _ = store.set_fuel(fuel_amount);
+
+        let linker = Linker::new(&wmod.engine);
+
+        let pre = match linker.instantiate(&mut store, &wmod.module) {
+            Ok(pre) => pre,
+            Err(e) => {
+                set_last_error(format!("WASM instantiation failed: {e}"));
+                return 0;
+            }
+        };
+
+        let instance = match pre.start(&mut store) {
+            Ok(inst) => inst,
+            Err(e) => {
+                set_last_error(format!("WASM start function failed: {e}"));
+                return 0;
+            }
+        };
+
+        let handle = next_handle();
+        wasm_instances()
+            .lock()
+            .unwrap()
+            .insert(handle, WasmInstance { store, instance });
+        handle
+    }) {
+        Ok(h) => h,
+        Err(_) => {
+            set_last_error("panic in jerboa_wasm_instance_new".to_string());
+            0
+        }
+    }
+}
+
+/// Free a WASM instance.
+#[no_mangle]
+pub extern "C" fn jerboa_wasm_instance_free(handle: u64) {
+    let _ = wasm_instances().lock().unwrap().remove(&handle);
+}
+
+/// Add fuel to an existing instance.
+/// Returns 0 on success, -1 on error.
+#[no_mangle]
+pub extern "C" fn jerboa_wasm_add_fuel(handle: u64, fuel: u64) -> i32 {
+    ffi_wrap(|| {
+        let mut instances = wasm_instances().lock().unwrap();
+        let inst = match instances.get_mut(&handle) {
+            Some(i) => i,
+            None => {
+                set_last_error("invalid instance handle".to_string());
+                return -1;
+            }
+        };
+        match inst.store.set_fuel(fuel) {
+            Ok(()) => 0,
+            Err(e) => {
+                set_last_error(format!("set_fuel failed: {e}"));
+                -1
+            }
+        }
+    })
+}
+
+/// Get remaining fuel for an instance.
+/// Returns fuel remaining, or -1 on error.
+#[no_mangle]
+pub extern "C" fn jerboa_wasm_fuel_remaining(handle: u64) -> i64 {
+    match std::panic::catch_unwind(|| {
+        let instances = wasm_instances().lock().unwrap();
+        let inst = match instances.get(&handle) {
+            Some(i) => i,
+            None => return -1i64,
+        };
+        inst.store.get_fuel().unwrap_or(0) as i64
+    }) {
+        Ok(f) => f,
+        Err(_) => -1,
+    }
+}
+
+// ============================================================
+// Call: invoke an exported function
+// ============================================================
+
+/// Call an exported WASM function by name.
+///
+/// Arguments and results are passed as i64 arrays. For i32 params,
+/// the value is truncated; for f32/f64, it's reinterpreted from bits.
+///
+/// Returns the number of results on success, -1 on error.
+#[no_mangle]
+pub extern "C" fn jerboa_wasm_call(
+    handle: u64,
+    name: *const u8,
+    name_len: usize,
+    args: *const i64,
+    nargs: usize,
+    results: *mut i64,
+    nresults: usize,
+) -> i32 {
+    match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
+        if name.is_null() {
+            set_last_error("null function name".to_string());
+            return -1;
+        }
+
+        let func_name = match std::str::from_utf8(unsafe {
+            std::slice::from_raw_parts(name, name_len)
+        }) {
+            Ok(s) => s,
+            Err(_) => {
+                set_last_error("invalid UTF-8 function name".to_string());
+                return -1;
+            }
+        };
+
+        let mut instances = wasm_instances().lock().unwrap();
+        let inst = match instances.get_mut(&handle) {
+            Some(i) => i,
+            None => {
+                set_last_error("invalid instance handle".to_string());
+                return -1;
+            }
+        };
+
+        let func = match inst.instance.get_func(&inst.store, func_name) {
+            Some(f) => f,
+            None => {
+                set_last_error(format!("export not found: {func_name}"));
+                return -1;
+            }
+        };
+
+        // Build args matching WASM function signature
+        let func_type = func.ty(&inst.store);
+        let param_types: Vec<ValType> = func_type.params().iter().cloned().collect();
+
+        if param_types.len() != nargs {
+            set_last_error(format!(
+                "argument count mismatch: expected {} got {}",
+                param_types.len(),
+                nargs
+            ));
+            return -1;
+        }
+
+        let wasm_args: Vec<Val> = if nargs > 0 && !args.is_null() {
+            let arg_slice = unsafe { std::slice::from_raw_parts(args, nargs) };
+            arg_slice
+                .iter()
+                .zip(param_types.iter())
+                .map(|(&val, ty)| match ty {
+                    ValType::I32 => Val::I32(val as i32),
+                    ValType::I64 => Val::I64(val),
+                    ValType::F32 => Val::F32(f32::from_bits(val as u32).into()),
+                    ValType::F64 => Val::F64(f64::from_bits(val as u64).into()),
+                    _ => Val::I32(val as i32),
+                })
+                .collect()
+        } else {
+            vec![]
+        };
+
+        // Prepare result slots
+        let result_types: Vec<ValType> = func_type.results().iter().cloned().collect();
+        let actual_nresults = result_types.len();
+        let mut wasm_results: Vec<Val> = result_types
+            .iter()
+            .map(|ty| match ty {
+                ValType::I32 => Val::I32(0),
+                ValType::I64 => Val::I64(0),
+                ValType::F32 => Val::F32(0.0f32.into()),
+                ValType::F64 => Val::F64(0.0f64.into()),
+                _ => Val::I32(0),
+            })
+            .collect();
+
+        // Execute
+        if let Err(e) = func.call(&mut inst.store, &wasm_args, &mut wasm_results) {
+            set_last_error(format!("WASM trap: {e}"));
+            return -1;
+        }
+
+        // Copy results out
+        if !results.is_null() && nresults > 0 {
+            let out = unsafe { std::slice::from_raw_parts_mut(results, nresults) };
+            for (i, val) in wasm_results.into_iter().enumerate() {
+                if i >= nresults {
+                    break;
+                }
+                out[i] = match val {
+                    Val::I32(v) => v as i64,
+                    Val::I64(v) => v,
+                    Val::F32(v) => f32::to_bits(v.into()) as i64,
+                    Val::F64(v) => f64::to_bits(v.into()) as i64,
+                    _ => 0,
+                };
+            }
+        }
+
+        actual_nresults as i32
+    })) {
+        Ok(r) => r,
+        Err(_) => {
+            set_last_error("panic in jerboa_wasm_call".to_string());
+            -1
+        }
+    }
+}
+
+// ============================================================
+// Memory: read/write WASM linear memory from host
+// ============================================================
+
+/// Read bytes from WASM linear memory.
+/// Returns number of bytes read on success, -1 on error.
+#[no_mangle]
+pub extern "C" fn jerboa_wasm_memory_read(
+    handle: u64,
+    offset: u32,
+    buf: *mut u8,
+    len: u32,
+) -> i32 {
+    ffi_wrap(|| {
+        if buf.is_null() {
+            set_last_error("null buffer".to_string());
+            return -1;
+        }
+
+        let instances = wasm_instances().lock().unwrap();
+        let inst = match instances.get(&handle) {
+            Some(i) => i,
+            None => {
+                set_last_error("invalid instance handle".to_string());
+                return -1;
+            }
+        };
+
+        let memory = match inst.instance.get_memory(&inst.store, "memory") {
+            Some(m) => m,
+            None => {
+                set_last_error("no 'memory' export".to_string());
+                return -1;
+            }
+        };
+
+        let mem_data = memory.data(&inst.store);
+        let start = offset as usize;
+        let end = start + len as usize;
+
+        if end > mem_data.len() {
+            set_last_error(format!(
+                "memory read OOB: offset={offset} len={len} size={}",
+                mem_data.len()
+            ));
+            return -1;
+        }
+
+        let out = unsafe { std::slice::from_raw_parts_mut(buf, len as usize) };
+        out.copy_from_slice(&mem_data[start..end]);
+        len as i32
+    })
+}
+
+/// Write bytes to WASM linear memory.
+/// Returns 0 on success, -1 on error.
+#[no_mangle]
+pub extern "C" fn jerboa_wasm_memory_write(
+    handle: u64,
+    offset: u32,
+    buf: *const u8,
+    len: u32,
+) -> i32 {
+    ffi_wrap(|| {
+        if buf.is_null() {
+            set_last_error("null buffer".to_string());
+            return -1;
+        }
+
+        let mut instances = wasm_instances().lock().unwrap();
+        let inst = match instances.get_mut(&handle) {
+            Some(i) => i,
+            None => {
+                set_last_error("invalid instance handle".to_string());
+                return -1;
+            }
+        };
+
+        let memory = match inst.instance.get_memory(&inst.store, "memory") {
+            Some(m) => m,
+            None => {
+                set_last_error("no 'memory' export".to_string());
+                return -1;
+            }
+        };
+
+        let mem_data = memory.data_mut(&mut inst.store);
+        let start = offset as usize;
+        let end = start + len as usize;
+
+        if end > mem_data.len() {
+            set_last_error(format!(
+                "memory write OOB: offset={offset} len={len} size={}",
+                mem_data.len()
+            ));
+            return -1;
+        }
+
+        let input = unsafe { std::slice::from_raw_parts(buf, len as usize) };
+        mem_data[start..end].copy_from_slice(input);
+        0
+    })
+}
+
+/// Get the size of WASM linear memory in bytes.
+/// Returns size on success, -1 on error.
+#[no_mangle]
+pub extern "C" fn jerboa_wasm_memory_size(handle: u64) -> i64 {
+    match std::panic::catch_unwind(|| {
+        let instances = wasm_instances().lock().unwrap();
+        let inst = match instances.get(&handle) {
+            Some(i) => i,
+            None => return -1i64,
+        };
+
+        match inst.instance.get_memory(&inst.store, "memory") {
+            Some(m) => m.data(&inst.store).len() as i64,
+            None => -1,
+        }
+    }) {
+        Ok(s) => s,
+        Err(_) => -1,
+    }
+}
diff --git a/lib/jerboa/wasm/codegen.sls b/lib/jerboa/wasm/codegen.sls
index 7edc9a8..c88a278 100644
--- a/lib/jerboa/wasm/codegen.sls
+++ b/lib/jerboa/wasm/codegen.sls
@@ -1096,6 +1096,10 @@
                   (wasm-export-func (symbol->string (car pair)) idx)))))
           funcs))
 
+      ;; Auto-export memory as "memory" (WASM convention for host access)
+      (unless (null? (wasm-module-memories mod))
+        (wasm-module-add-export! mod (wasm-export-memory "memory" 0)))
+
       ;; Process data segments
       (for-each
         (lambda (form)
diff --git a/tests/test-wasm-sandbox.ss b/tests/test-wasm-sandbox.ss
new file mode 100644
index 0000000..5c7b6ca
--- /dev/null
+++ b/tests/test-wasm-sandbox.ss
@@ -0,0 +1,251 @@
+#!chezscheme
+;;; test-wasm-sandbox.ss — Tests for Rust wasmi WASM sandbox
+;;;
+;;; Verifies that WASM modules compiled from Scheme execute correctly
+;;; inside the Rust wasmi interpreter, fully isolated from Chez Scheme.
+
+(import (except (chezscheme) compile-program)
+        (jerboa wasm format)
+        (jerboa wasm codegen)
+        (std wasm sandbox))
+
+(define pass 0)
+(define fail 0)
+
+(define-syntax test
+  (syntax-rules ()
+    [(_ name expr expected)
+     (guard (exn [#t (set! fail (+ fail 1))
+                     (printf "FAIL ~a: ~a~%" name
+                       (if (message-condition? exn) (condition-message exn) exn))])
+       (let ([got expr])
+         (if (equal? got expected)
+           (begin (set! pass (+ pass 1)) (printf "  ok ~a~%" name))
+           (begin (set! fail (+ fail 1))
+                  (printf "FAIL ~a: got ~s expected ~s~%" name got expected)))))]))
+
+(define (string-contains s sub)
+  (let ([slen (string-length s)] [sublen (string-length sub)])
+    (let lp ([i 0])
+      (cond
+        [(> (+ i sublen) slen) #f]
+        [(string=? (substring s i (+ i sublen)) sub) i]
+        [else (lp (+ i 1))]))))
+
+(printf "--- Rust wasmi Sandbox Tests ---~%~%")
+
+;;; ============================================================
+;;; Section 1: Availability
+;;; ============================================================
+(printf "--- Section 1: Availability ---~%")
+
+(test "wasmi sandbox is available"
+  (wasm-sandbox-available?)
+  #t)
+
+;;; ============================================================
+;;; Section 2: Basic computation
+;;; ============================================================
+(printf "~%--- Section 2: Basic computation ---~%")
+
+(test "factorial(10) in wasmi"
+  (let* ([bv (compile-program
+               '((define (factorial n)
+                   (if (= n 0) 1 (* n (factorial (- n 1)))))))]
+         [mod-h (wasm-sandbox-load bv)]
+         [inst (wasm-sandbox-instantiate mod-h)])
+    (let ([r (wasm-sandbox-call inst "factorial" 10)])
+      (wasm-sandbox-free inst)
+      (wasm-sandbox-free-module mod-h)
+      r))
+  3628800)
+
+(test "fibonacci(20) in wasmi"
+  (let* ([bv (compile-program
+               '((define (fib n)
+                   (if (<= n 1) n
+                     (+ (fib (- n 1)) (fib (- n 2)))))))]
+         [mod-h (wasm-sandbox-load bv)]
+         [inst (wasm-sandbox-instantiate mod-h)])
+    (let ([r (wasm-sandbox-call inst "fib" 20)])
+      (wasm-sandbox-free inst)
+      (wasm-sandbox-free-module mod-h)
+      r))
+  6765)
+
+(test "arithmetic in wasmi"
+  (let* ([bv (compile-program
+               '((define (compute (a i32) (b i32) -> i32)
+                   (+ (* a a) (* b b)))))]
+         [mod-h (wasm-sandbox-load bv)]
+         [inst (wasm-sandbox-instantiate mod-h)])
+    (let ([r (wasm-sandbox-call inst "compute" 3 4)])
+      (wasm-sandbox-free inst)
+      (wasm-sandbox-free-module mod-h)
+      r))
+  25)
+
+;;; ============================================================
+;;; Section 3: Memory operations
+;;; ============================================================
+(printf "~%--- Section 3: Memory operations ---~%")
+
+(test "memory store and load via WASM"
+  (let* ([bv (compile-program
+               '((define-memory 1)
+                 (define (store-val (addr i32) (val i32) -> i32)
+                   (i32.store addr val) val)
+                 (define (load-val (addr i32) -> i32)
+                   (i32.load addr))))]
+         [mod-h (wasm-sandbox-load bv)]
+         [inst (wasm-sandbox-instantiate mod-h)])
+    (wasm-sandbox-call inst "store-val" 0 42)
+    (let ([r (wasm-sandbox-call inst "load-val" 0)])
+      (wasm-sandbox-free inst)
+      (wasm-sandbox-free-module mod-h)
+      r))
+  42)
+
+(test "host memory read"
+  (let* ([bv (compile-program
+               '((define-memory 1)
+                 (define (store-val (addr i32) (val i32) -> i32)
+                   (i32.store addr val) val)))]
+         [mod-h (wasm-sandbox-load bv)]
+         [inst (wasm-sandbox-instantiate mod-h)])
+    (wasm-sandbox-call inst "store-val" 0 42)
+    (let ([data (wasm-sandbox-memory-read inst 0 4)])
+      (wasm-sandbox-free inst)
+      (wasm-sandbox-free-module mod-h)
+      data))
+  #vu8(42 0 0 0))
+
+(test "host memory write then WASM read"
+  (let* ([bv (compile-program
+               '((define-memory 1)
+                 (define (load-val (addr i32) -> i32)
+                   (i32.load addr))))]
+         [mod-h (wasm-sandbox-load bv)]
+         [inst (wasm-sandbox-instantiate mod-h)])
+    (wasm-sandbox-memory-write inst 0 (bytevector 99 0 0 0))
+    (let ([r (wasm-sandbox-call inst "load-val" 0)])
+      (wasm-sandbox-free inst)
+      (wasm-sandbox-free-module mod-h)
+      r))
+  99)
+
+(test "memory size is 1 page (65536 bytes)"
+  (let* ([bv (compile-program
+               '((define-memory 1)
+                 (define (nop) 0)))]
+         [mod-h (wasm-sandbox-load bv)]
+         [inst (wasm-sandbox-instantiate mod-h)])
+    (let ([sz (wasm-sandbox-memory-size inst)])
+      (wasm-sandbox-free inst)
+      (wasm-sandbox-free-module mod-h)
+      sz))
+  65536)
+
+;;; ============================================================
+;;; Section 4: Fuel metering (deterministic termination)
+;;; ============================================================
+(printf "~%--- Section 4: Fuel metering ---~%")
+
+(test "fuel exhaustion traps infinite loop"
+  (let* ([bv (compile-program '((define (spin) (while #t 0) 0)))]
+         [mod-h (wasm-sandbox-load bv)]
+         [inst (wasm-sandbox-instantiate mod-h 'fuel: 1000)])
+    (guard (exn
+      [#t (let ([msg (if (message-condition? exn) (condition-message exn) "")])
+            (wasm-sandbox-free inst)
+            (wasm-sandbox-free-module mod-h)
+            (and (string-contains msg "fuel") 'trapped))])
+      (wasm-sandbox-call inst "spin")
+      'no-trap))
+  'trapped)
+
+(test "sufficient fuel allows computation"
+  (let* ([bv (compile-program '((define (f x) (+ x 1))))]
+         [mod-h (wasm-sandbox-load bv)]
+         [inst (wasm-sandbox-instantiate mod-h 'fuel: 10000000)])
+    (let ([r (wasm-sandbox-call inst "f" 41)])
+      (wasm-sandbox-free inst)
+      (wasm-sandbox-free-module mod-h)
+      r))
+  42)
+
+(test "fuel remaining decreases after execution"
+  (let* ([bv (compile-program '((define (f x) (+ x 1))))]
+         [mod-h (wasm-sandbox-load bv)]
+         [inst (wasm-sandbox-instantiate mod-h 'fuel: 10000)])
+    (let ([before (wasm-sandbox-fuel-remaining inst)])
+      (wasm-sandbox-call inst "f" 1)
+      (let ([after (wasm-sandbox-fuel-remaining inst)])
+        (wasm-sandbox-free inst)
+        (wasm-sandbox-free-module mod-h)
+        (< after before))))
+  #t)
+
+;;; ============================================================
+;;; Section 5: Module validation
+;;; ============================================================
+(printf "~%--- Section 5: Module validation ---~%")
+
+(test "invalid WASM bytecode rejected"
+  (guard (exn [#t 'rejected])
+    (wasm-sandbox-load (bytevector 0 1 2 3 4 5 6 7))
+    'accepted)
+  'rejected)
+
+(test "empty bytevector rejected"
+  (guard (exn [#t 'rejected])
+    (wasm-sandbox-load (bytevector))
+    'accepted)
+  'rejected)
+
+;;; ============================================================
+;;; Section 6: Multiple instances
+;;; ============================================================
+(printf "~%--- Section 6: Multiple instances ---~%")
+
+(test "multiple instances from same module"
+  (let* ([bv (compile-program '((define (f x) (+ x 1))))]
+         [mod-h (wasm-sandbox-load bv)]
+         [inst1 (wasm-sandbox-instantiate mod-h)]
+         [inst2 (wasm-sandbox-instantiate mod-h)])
+    (let ([r1 (wasm-sandbox-call inst1 "f" 10)]
+          [r2 (wasm-sandbox-call inst2 "f" 20)])
+      (wasm-sandbox-free inst1)
+      (wasm-sandbox-free inst2)
+      (wasm-sandbox-free-module mod-h)
+      (+ r1 r2)))
+  32)
+
+(test "instances have isolated memory"
+  (let* ([bv (compile-program
+               '((define-memory 1)
+                 (define (store-val (addr i32) (val i32) -> i32)
+                   (i32.store addr val) val)
+                 (define (load-val (addr i32) -> i32)
+                   (i32.load addr))))]
+         [mod-h (wasm-sandbox-load bv)]
+         [inst1 (wasm-sandbox-instantiate mod-h)]
+         [inst2 (wasm-sandbox-instantiate mod-h)])
+    ;; Write different values in each instance