From 1add813744afc7adac463c3ff3e1e3e62b6a40f3 Mon Sep 17 00:00:00 2001 From: Christoph Herzog Date: Thu, 22 Oct 2026 10:20:05 +0200 Subject: [PATCH] fix(napi): bound guest copies and retain queued message ownership --- src/guest/abi.rs | 33 +- src/guest/mod.rs | 4 + src/guest/napi.rs | 937 ++++++++++++------ src/guest/napi_js.rs | 37 +- src/guest/util.rs | 229 ++++- src/guest/util_js.rs | 17 +- tests/core_callbacks.rs | 107 +- tests/guest_boundary_accounting.rs | 522 ++++++++++ tests/guest_error_callbacks.rs | 93 ++ tests/manifest_tests.rs | 6 +- tests/message_memory.rs | 210 ++++ tests/programs/run_script_test.c | 11 +- tests/programs/test_charged_cstring_bridge.c | 47 + tests/programs/test_configure_without_env.c | 9 + tests/programs/test_embedded_nul_names.c | 51 + tests/programs/test_guest_message_budget.c | 34 + tests/programs/test_guest_runtime_flags.c | 28 + .../programs/test_guest_unmanaged_controls.c | 146 +++ tests/programs/test_js_wasm_disabled.c | 57 ++ tests/programs/test_property_descriptor_cap.c | 31 + tests/programs/test_ref_churn.c | 52 + tests/programs/test_ref_hold.c | 51 + tests/programs/test_unlimited_value_handles.c | 20 + tests/review_memory.rs | 323 ++++++ tests/v8_host_control.rs | 168 +++- 25 files changed, 2829 insertions(+), 394 deletions(-) create mode 100644 tests/guest_boundary_accounting.rs create mode 100644 tests/guest_error_callbacks.rs create mode 100644 tests/message_memory.rs create mode 100644 tests/programs/test_charged_cstring_bridge.c create mode 100644 tests/programs/test_configure_without_env.c create mode 100644 tests/programs/test_embedded_nul_names.c create mode 100644 tests/programs/test_guest_message_budget.c create mode 100644 tests/programs/test_guest_runtime_flags.c create mode 100644 tests/programs/test_guest_unmanaged_controls.c create mode 100644 tests/programs/test_js_wasm_disabled.c create mode 100644 tests/programs/test_property_descriptor_cap.c create mode 100644 tests/programs/test_ref_churn.c create mode 100644 tests/programs/test_ref_hold.c create mode 100644 tests/programs/test_unlimited_value_handles.c create mode 100644 tests/review_memory.rs diff --git a/src/guest/abi.rs b/src/guest/abi.rs index a264a948..bcc182ae 100644 --- a/src/guest/abi.rs +++ b/src/guest/abi.rs @@ -2,7 +2,7 @@ use wasmer::FunctionEnvMut; use crate::NapiEnv; -use super::util::read_guest_bytes; +use super::util::{HostCopy, read_guest_bytes}; #[repr(C)] struct Wasm32VersionedHeader { @@ -138,7 +138,7 @@ fn read_versioned( guest_ptr: u32, prefix_size: usize, version: u32, -) -> Option> { +) -> Option> { if guest_ptr == 0 { return None; } @@ -299,6 +299,8 @@ pub(crate) fn read_js_source( } } +// Kept alongside the versioned ABI while bytecode imports fail closed. +#[allow(dead_code)] pub(crate) struct BytecodeOpen { pub source_text: u32, pub filename: u32, @@ -307,11 +309,12 @@ pub(crate) struct BytecodeOpen { pub host_defined_option_id: u32, pub line_offset: i32, pub column_offset: i32, - pub cache: Vec, + pub cache: HostCopy, pub has_cache: u8, pub cache_policy: u8, } +#[allow(dead_code)] pub(crate) fn read_bytecode_open( env: &mut FunctionEnvMut, guest_ptr: u32, @@ -336,7 +339,7 @@ pub(crate) fn read_bytecode_open( } read_guest_bytes(env, cache_ptr, cache_length)? } else { - Vec::new() + HostCopy::default() }; Some(BytecodeOpen { source_text: u32_at( @@ -557,3 +560,25 @@ impl Wasm32EnvHooksV1 { std::mem::offset_of!(Self, oom_error_callback) } } + +#[cfg(test)] +mod tests { + use super::is_default_engine_flags; + + #[test] + fn only_edgejs_default_engine_flags_are_inert() { + assert!(is_default_engine_flags( + b"--js-source-phase-imports --harmony-import-attributes" + )); + assert!(is_default_engine_flags(b"--js-source-phase-imports")); + assert!(is_default_engine_flags(b"--harmony-import-attributes")); + assert!(is_default_engine_flags(b"")); + assert!(!is_default_engine_flags(b"--allow-natives-syntax")); + assert!(!is_default_engine_flags( + b"--js-source-phase-imports --allow-natives-syntax" + )); + assert!(!is_default_engine_flags( + b"--harmony-import-attributes --js-source-phase-imports" + )); + } +} diff --git a/src/guest/mod.rs b/src/guest/mod.rs index 6299bb3c..cb62272f 100644 --- a/src/guest/mod.rs +++ b/src/guest/mod.rs @@ -25,6 +25,10 @@ pub const MAX_GUEST_CSTRING_SCAN: usize = 64 * 1024; /// 2-Mbit integer), which keeps the transient uncounted host memory negligible. pub const MAX_NAPI_CALLBACK_ARGS: usize = 64 * 1024; pub const MAX_NAPI_BIGINT_WORDS: usize = 32 * 1024; +/// One N-API definition call rarely needs even hundreds of properties. Bound +/// its nine host descriptor arrays and copied names independently of memory +/// limit configuration (which may be unlimited in local tools). +pub const MAX_NAPI_PROPERTY_DESCRIPTORS: usize = 4096; /// Byte width for the public `napi_typedarray_type` enum. Keep the mapping in /// one place for both guest backends; values outside the public enum are not diff --git a/src/guest/napi.rs b/src/guest/napi.rs index 50aaaa9f..48edd406 100644 --- a/src/guest/napi.rs +++ b/src/guest/napi.rs @@ -1,10 +1,17 @@ +// WASIX import handlers must return WasiError directly so Wasmer preserves +// guest exits and traps. Boxing it would change the host-function ABI. +#![allow(clippy::result_large_err)] + // ============================================================ // WASM import handlers for "napi" module // ============================================================ // --- Init --- -use std::ffi::{CString, c_char}; +use std::{ + ffi::{CString, c_char}, + sync::Arc, +}; use wasmer::{AsStoreMut, Function, FunctionEnv, FunctionEnvMut, Imports, namespace}; use wasmer_wasix::WasiError; @@ -12,15 +19,92 @@ use wasmer_wasix::wasmer_wasix_types::wasi::ExitCode; use crate::{ NAPI_EXTENSION_WASMER_MODULE_NAME, NAPI_MODULE_NAME, NapiEnv, RequestedHeap, + budget::{Pool, ResourceBudget}, guest::{ MAX_GUEST_CSTRING_SCAN, MAX_NAPI_BIGINT_WORDS, MAX_NAPI_CALLBACK_ARGS, + MAX_NAPI_PROPERTY_DESCRIPTORS, callback::{take_pending_guest_exit, with_callback_state}, }, + message::{MessageCharge, SERIALIZATION_RESERVATION}, snapi::*, }; use super::{abi, util::*}; +// The provider caps source text at 4 MiB, parameters at 1 MiB total, and +// filenames at 1 MiB. Each UTF-8 conversion can hold a temporary buffer and +// an owned string at once: 8 + 2 + 2 MiB. The remaining 4 MiB covers vector +// capacity, allocator slack, and a bytecode-source copy. Nested callbacks +// retain each invocation's reservation until its bridge call has unwound. +const COMPILE_SCRATCH_RESERVATION: u64 = 16 * 1024 * 1024; +// SnapshotOwnProperties caps the sandbox at 16,384 own keys. The native +// vector and its persistent V8 handles remain live across getter callbacks. +const CONTEXT_SNAPSHOT_RESERVATION: u64 = 2 * 1024 * 1024; +// Module requests are capped at 4 MiB of retained metadata by the provider. +// A link call can hold one serialized lookup key, one mismatch diagnostic, +// and two arrays of at most 4,096 pointers; this covers their native scratch. +const MODULE_LINK_SCRATCH_RESERVATION: u64 = 4 * 1024 * 1024; + +struct HostTransientReservation { + budget: Arc, + bytes: u64, +} + +impl HostTransientReservation { + fn reserve(budget: Arc, bytes: u64) -> Option { + budget.try_charge(Pool::HostTransient, bytes).ok()?; + Some(Self { budget, bytes }) + } +} + +impl Drop for HostTransientReservation { + fn drop(&mut self) { + self.budget.uncharge(Pool::HostTransient, self.bytes); + } +} + +/// Keep explicit-length guest names as raw bytes. `CString::new` rejects an +/// embedded NUL and its empty fallback would leave C++ reading the original +/// nonzero length past that fallback allocation. Only AUTO_LENGTH needs an +/// owned NUL-terminated copy. +struct GuestName<'a> { + bytes: &'a HostCopy, + terminated: Option>, +} + +impl<'a> GuestName<'a> { + fn new(bytes: &'a HostCopy, length: u32) -> Option { + if length != u32::MAX && bytes.len() != length as usize { + return None; + } + let terminated = if length == u32::MAX && !bytes.is_empty() { + Some(bytes.terminated_copy()?) + } else { + None + }; + Some(Self { bytes, terminated }) + } + + fn as_ptr(&self) -> *const c_char { + if let Some(terminated) = &self.terminated { + terminated.as_ptr().cast() + } else if self.bytes.is_empty() { + c"".as_ptr() + } else { + self.bytes.as_ptr().cast() + } + } +} + +macro_rules! reserve_property_array { + ($env:expr, $count:expr, $ty:ty) => { + match HostCopy::<$ty>::with_capacity($env.data().budget.clone(), $count) { + Some(array) => array, + None => return Ok(1), + } + }; +} + fn guest_napi_wasm_init_env(mut env: FunctionEnvMut) -> i32 { if env.data().memory.is_none() { return 0; @@ -435,7 +519,9 @@ fn guest_unofficial_napi_create_uninitialized_arraybuffer( _zero_fill: i32, result_ptr: u32, ) -> Result { - // Engines which mandate zero initialization may return zeroed storage. + // Imported providers cannot adopt a guest allocation as host engine + // storage. Allocate directly in the provider without requesting a guest + // pointer; engines which mandate zero initialization may return zeros. guest_napi_create_arraybuffer(env, napi_env, length, 0, result_ptr) } @@ -605,15 +691,17 @@ fn guest_unofficial_napi_create_private_symbol( read_guest_bytes(&mut env, desc_ptr, wl as usize) } } else { - Some(Vec::new()) + Some(HostCopy::default()) }; let Some(desc) = desc else { return 1; }; - let cs = CString::new(desc).unwrap_or_default(); + let Some(name) = GuestName::new(&desc, wl) else { + return 1; + }; let mut out = 0u32; let status = unsafe { - snapi_bridge_unofficial_create_private_symbol(env_handle, cs.as_ptr(), wl, &mut out) + snapi_bridge_unofficial_create_private_symbol(env_handle, name.as_ptr(), wl, &mut out) }; if status == 0 && result_ptr != 0 { write_guest_u32(&mut env, result_ptr, out); @@ -699,18 +787,51 @@ fn guest_unofficial_napi_structured_clone( value: u32, transfer_list: u32, result_ptr: u32, -) -> i32 { +) -> Result { + if result_ptr == 0 || read_guest_bytes(&mut env, result_ptr, 4).is_none() { + return Ok(1); + } + let budget = env.data().budget.clone(); + if budget + .try_charge(Pool::HostTransient, SERIALIZATION_RESERVATION) + .is_err() + { + return Ok(1); + } let env_handle = snapi_env(&env, napi_env); let value_id = value; let transfer_list_id = transfer_list; let mut out = 0u32; - let status = unsafe { + let status = with_cb_context(&mut env, napi_env, || unsafe { snapi_bridge_unofficial_structured_clone(env_handle, value_id, transfer_list_id, &mut out) - }; - if status == 0 && result_ptr != 0 { - write_guest_u32(&mut env, result_ptr, out); + }); + budget.uncharge(Pool::HostTransient, SERIALIZATION_RESERVATION); + let status = status?; + if status == 0 && !write_guest_u32(&mut env, result_ptr as u32, out) { + return Ok(1); } - status + Ok(status) +} + +/// Bind every queued native message to this guest memory. The serializer can +/// retain SharedArrayBuffer and transferred ArrayBuffer backing stores, and +/// does not expose a complete backing-store ownership flag. A detached shared +/// handle keeps the mapping alive after the producer Store exits; if the +/// public runtime cannot provide one, serialization fails before native work. +fn message_memory_lease( + env: &FunctionEnvMut, +) -> Option<( + Arc, + Option, +)> { + let heap = Arc::clone(env.data().guest_heap.as_ref()?); + let memory = env.data().memory.as_ref()?; + let shared = if memory.ty(env).shared { + Some(memory.as_shared(env)?) + } else { + None + }; + Some((heap, shared)) } fn guest_unofficial_napi_message_create( @@ -718,17 +839,62 @@ fn guest_unofficial_napi_message_create( napi_env: u32, value: u32, payload_out_ptr: u32, -) -> i32 { +) -> Result { if payload_out_ptr == 0 { - return 1; + return Ok(1); } + let Some((memory, shared_memory)) = message_memory_lease(&env) else { + return Ok(1); + }; + let pending = env.data().pending_messages.clone(); + let Ok(mut charge) = + MessageCharge::reserve(env.data().budget.clone(), SERIALIZATION_RESERVATION) + else { + return Ok(1); + }; let env_handle = snapi_env(&env, napi_env); let mut message = 0u32; - let status = unsafe { snapi_bridge_unofficial_message_create(env_handle, value, &mut message) }; - if status == 0 { - write_guest_u32(&mut env, payload_out_ptr, message); + let mut retained_bytes = 0u64; + let status = with_cb_context(&mut env, napi_env, || unsafe { + snapi_bridge_unofficial_message_create_metered( + env_handle, + value, + &mut message, + &mut retained_bytes, + ) + }); + let status = match status { + Ok(status) => status, + Err(error) => { + if message != 0 { + unsafe { snapi_bridge_unofficial_message_drop(message) }; + } + return Err(error); + } + }; + if status != 0 { + if message != 0 { + unsafe { snapi_bridge_unofficial_message_drop(message) }; + } + return Ok(status); } - status + if !charge.shrink(retained_bytes) { + unsafe { snapi_bridge_unofficial_message_drop(message) }; + return Ok(1); + } + charge.bind_memory(memory, shared_memory); + if let Err(rejected_charge) = pending.insert(message, charge) { + unsafe { snapi_bridge_unofficial_message_drop(message) }; + drop(rejected_charge); + return Ok(1); + } + if !write_guest_u32(&mut env, payload_out_ptr as u32, message) { + let charge = pending.take(message); + unsafe { snapi_bridge_unofficial_message_drop(message) }; + drop(charge); + return Ok(1); + } + Ok(0) } fn guest_unofficial_napi_message_take( @@ -736,25 +902,42 @@ fn guest_unofficial_napi_message_take( napi_env: u32, payload: u32, result_out_ptr: u32, -) -> i32 { +) -> Result { if payload == 0 { - return 1; + return Ok(1); } - if result_out_ptr == 0 { - unsafe { snapi_bridge_unofficial_message_drop(payload) }; - return 1; + let Some(memory) = env.data().guest_heap.as_ref() else { + return Ok(1); + }; + let Some(_charge) = env + .data() + .pending_messages + .take_for_memory(payload as u32, memory) + else { + return Ok(1); + }; + if result_out_ptr == 0 || read_guest_bytes(&mut env, result_out_ptr, 4).is_none() { + unsafe { snapi_bridge_unofficial_message_drop(payload as u32) }; + return Ok(1); } let env_handle = snapi_env(&env, napi_env); let mut value = 0u32; - let status = unsafe { snapi_bridge_unofficial_message_take(env_handle, payload, &mut value) }; - if status == 0 { - write_guest_u32(&mut env, result_out_ptr, value); + let status = with_cb_context(&mut env, napi_env, || unsafe { + snapi_bridge_unofficial_message_take(env_handle, payload as u32, &mut value) + })?; + if status == 0 && !write_guest_u32(&mut env, result_out_ptr as u32, value) { + return Ok(1); } - status + Ok(status) } -fn guest_unofficial_napi_message_drop(_env: FunctionEnvMut, payload: u32) { - unsafe { snapi_bridge_unofficial_message_drop(payload) }; +fn guest_unofficial_napi_message_drop(env: FunctionEnvMut, payload: u32) { + if payload != 0 { + let Some(_charge) = env.data().pending_messages.take(payload as u32) else { + return; + }; + unsafe { snapi_bridge_unofficial_message_drop(payload as u32) }; + } } fn guest_unofficial_napi_enqueue_microtask( @@ -783,24 +966,25 @@ fn guest_unofficial_napi_get_own_non_index_properties( value: u32, filter_bits: u32, result_out_ptr: u32, -) -> i32 { - if value == 0 || result_out_ptr == 0 { - return 1; +) -> Result { + if value == 0 || result_out_ptr == 0 || read_guest_bytes(&mut env, result_out_ptr, 4).is_none() + { + return Ok(1); } let env_handle = snapi_env(&env, napi_env); let mut out = 0u32; - let status = unsafe { + let status = with_cb_context(&mut env, napi_env, || unsafe { snapi_bridge_unofficial_get_own_non_index_properties( env_handle, value, filter_bits, &mut out, ) - }; - if status == 0 { - write_guest_u32(&mut env, result_out_ptr, out); + })?; + if status == 0 && !write_guest_u32(&mut env, result_out_ptr as u32, out) { + return Ok(1); } - status + Ok(status) } fn guest_unofficial_napi_set_promise_hooks( @@ -843,11 +1027,9 @@ fn guest_unofficial_napi_get_error_metadata( error: u32, mode: i32, metadata_ptr: u32, -) -> i32 { - // The whole 28-byte metadata struct must lie below 4 GiB, so the field - // offsets below cannot wrap. - if metadata_ptr != 0 && u64::from(metadata_ptr) + 28 > 1u64 << 32 { - return 1; +) -> Result { + if metadata_ptr == 0 || read_guest_bytes(&mut env, metadata_ptr, 28).is_none() { + return Ok(1); } let env_handle = snapi_env(&env, napi_env); let error_id = error; @@ -858,7 +1040,7 @@ fn guest_unofficial_napi_get_error_metadata( let mut line_number = 0i32; let mut start_column = 0i32; let mut end_column = 0i32; - let status = unsafe { + let status = with_cb_context(&mut env, napi_env, || unsafe { snapi_bridge_unofficial_get_error_metadata( env_handle, error_id, @@ -871,20 +1053,18 @@ fn guest_unofficial_napi_get_error_metadata( &mut start_column, &mut end_column, ) - }; + })?; if status != 0 { - return status; + return Ok(status); } - if metadata_ptr != 0 { - write_guest_u32(&mut env, metadata_ptr, source_line_id); - write_guest_u32(&mut env, metadata_ptr + 4, script_resource_name_id); - write_guest_u32(&mut env, metadata_ptr + 8, stderr_line_id); - write_guest_u32(&mut env, metadata_ptr + 12, thrown_at_id); - write_guest_i32(&mut env, metadata_ptr + 16, line_number); - write_guest_i32(&mut env, metadata_ptr + 20, start_column); - write_guest_i32(&mut env, metadata_ptr + 24, end_column); - } - 0 + let wrote = write_guest_u32(&mut env, metadata_ptr as u32, source_line_id) + && write_guest_u32(&mut env, metadata_ptr as u32 + 4, script_resource_name_id) + && write_guest_u32(&mut env, metadata_ptr as u32 + 8, stderr_line_id) + && write_guest_u32(&mut env, metadata_ptr as u32 + 12, thrown_at_id) + && write_guest_i32(&mut env, metadata_ptr as u32 + 16, line_number) + && write_guest_i32(&mut env, metadata_ptr as u32 + 20, start_column) + && write_guest_i32(&mut env, metadata_ptr as u32 + 24, end_column); + Ok(i32::from(!wrote)) } fn guest_unofficial_napi_configure_source_maps( @@ -899,23 +1079,27 @@ fn guest_unofficial_napi_configure_source_maps( } fn guest_unofficial_napi_preserve_error_source_message( - env: FunctionEnvMut, + mut env: FunctionEnvMut, napi_env: u32, error: u32, -) -> i32 { +) -> Result { let env_handle = snapi_env(&env, napi_env); - let error_id = error; - unsafe { snapi_bridge_unofficial_preserve_error_source_message(env_handle, error_id) } + let error_id = if error != 0 { error as u32 } else { 0 }; + with_cb_context(&mut env, napi_env, || unsafe { + snapi_bridge_unofficial_preserve_error_source_message(env_handle, error_id) + }) } fn guest_unofficial_napi_mark_promise_as_handled( - env: FunctionEnvMut, + mut env: FunctionEnvMut, napi_env: u32, promise: u32, -) -> i32 { +) -> Result { let env_handle = snapi_env(&env, napi_env); - let promise_id = promise; - unsafe { snapi_bridge_unofficial_mark_promise_as_handled(env_handle, promise_id) } + let promise_id = if promise != 0 { promise as u32 } else { 0 }; + with_cb_context(&mut env, napi_env, || unsafe { + snapi_bridge_unofficial_mark_promise_as_handled(env_handle, promise_id) + }) } fn guest_unofficial_napi_get_heap_statistics( @@ -1040,28 +1224,15 @@ fn guest_unofficial_napi_configure_near_heap_limit_callback( } fn guest_unofficial_napi_profile_start( - mut env: FunctionEnvMut, - napi_env: u32, - kind: i32, - result_ptr: u32, - profile_ptr: u32, + _env: FunctionEnvMut, + _napi_env: u32, + _kind: i32, + _result_ptr: u32, + _profile_ptr: u32, ) -> i32 { - let env_handle = snapi_env(&env, napi_env); - let mut result = 0i32; - let mut profile = 0u32; - let status = unsafe { - snapi_bridge_unofficial_profile_start(env_handle, kind, &mut result, &mut profile) - }; - if status != 0 { - return status; - } - if result_ptr != 0 { - write_guest_i32(&mut env, result_ptr, result); - } - if profile_ptr != 0 { - write_guest_u32(&mut env, profile_ptr, profile); - } - 0 + // V8's CPU and heap profilers retain native samples outside the per-app + // budget. Do not let a guest start an unbounded profiling session. + 1 } fn guest_unofficial_napi_profile_stop( @@ -1083,50 +1254,24 @@ fn guest_unofficial_napi_profile_stop( } fn guest_unofficial_napi_take_heap_snapshot( - mut env: FunctionEnvMut, - napi_env: u32, - options_ptr: u32, - json_ptr: u32, + _env: FunctionEnvMut, + _napi_env: u32, + _options_ptr: u32, + _json_ptr: u32, ) -> i32 { - let env_handle = snapi_env(&env, napi_env); - let (expose_internals, expose_numeric_values) = if options_ptr != 0 { - let Some(bytes) = read_guest_bytes(&mut env, options_ptr, 2) else { - return 1; - }; - ((bytes[0] != 0) as i32, (bytes[1] != 0) as i32) - } else { - (0, 0) - }; - let mut json = 0u32; - let status = unsafe { - snapi_bridge_unofficial_take_heap_snapshot( - env_handle, - expose_internals, - expose_numeric_values, - &mut json, - ) - }; - if status != 0 { - return status; - } - if json_ptr != 0 && !write_guest_u32(&mut env, json_ptr, json) { - return 1; - } - 0 + // V8 builds both the snapshot and its retained external ArrayBuffer in + // native memory that is invisible to the instance memory accountant. + 1 } fn guest_unofficial_napi_create_serdes_binding( - mut env: FunctionEnvMut, - napi_env: u32, - result_ptr: u32, + _env: FunctionEnvMut, + _napi_env: u32, + _result_ptr: u32, ) -> i32 { - let env_handle = snapi_env(&env, napi_env); - let mut out = 0u32; - let status = unsafe { snapi_bridge_unofficial_create_serdes_binding(env_handle, &mut out) }; - if status == 0 && result_ptr != 0 { - write_guest_u32(&mut env, result_ptr, out); - } - status + // Serializer and Deserializer retain native buffers without a budget + // owner. Refuse the guest binding until both paths are accounted. + 1 } fn guest_napi_add_env_cleanup_hook( @@ -1155,13 +1300,16 @@ fn guest_unofficial_napi_contextify_contains_module_syntax( resource_name_or_undefined: u32, cjs_var_in_scope: i32, result_ptr: u32, -) -> i32 { +) -> Result { + if result_ptr == 0 || read_guest_bytes(&mut env, result_ptr, 1).is_none() { + return Ok(1); + } let env_handle = snapi_env(&env, napi_env); let code_id = code; let filename_id = filename; let resource_name_id = resource_name_or_undefined; let mut result = 0i32; - let status = unsafe { + let status = with_cb_context(&mut env, napi_env, || unsafe { snapi_bridge_unofficial_contextify_contains_module_syntax( env_handle, code_id, @@ -1170,11 +1318,11 @@ fn guest_unofficial_napi_contextify_contains_module_syntax( cjs_var_in_scope, &mut result, ) - }; - if status == 0 && result_ptr != 0 { - write_guest_u8(&mut env, result_ptr, (result != 0) as u8); + })?; + if status == 0 && !write_guest_u8(&mut env, result_ptr as u32, (result != 0) as u8) { + return Ok(1); } - status + Ok(status) } #[allow(clippy::too_many_arguments)] @@ -1189,10 +1337,19 @@ fn guest_unofficial_napi_contextify_make_context( own_microtask_queue: i32, host_defined_option_id: u32, result_ptr: u32, -) -> i32 { +) -> Result { + if result_ptr == 0 || read_guest_bytes(&mut env, result_ptr, 4).is_none() { + return Ok(1); + } + let Some(_snapshot_charge) = HostTransientReservation::reserve( + Arc::clone(&env.data().budget), + CONTEXT_SNAPSHOT_RESERVATION, + ) else { + return Ok(1); + }; let env_handle = snapi_env(&env, napi_env); let mut result_id = 0u32; - let status = unsafe { + let status = with_cb_context(&mut env, napi_env, || unsafe { snapi_bridge_unofficial_contextify_make_context( env_handle, sandbox_or_symbol, @@ -1204,11 +1361,11 @@ fn guest_unofficial_napi_contextify_make_context( host_defined_option_id, &mut result_id, ) - }; - if status == 0 && result_ptr != 0 { - write_guest_u32(&mut env, result_ptr, result_id); + })?; + if status == 0 && !write_guest_u32(&mut env, result_ptr as u32, result_id) { + return Ok(1); } - status + Ok(status) } #[allow(clippy::too_many_arguments)] @@ -1227,6 +1384,12 @@ fn guest_unofficial_napi_contextify_run_script( host_defined_option_id: u32, result_ptr: u32, ) -> Result { + // V8's contextify timeout and SIGINT options start unmanaged OS threads + // and can cancel a host-initiated isolate termination. The instance's + // own workload manager is the only authority for execution deadlines. + if timeout != -1 || break_on_sigint != 0 { + return Ok(1); + } let Some((source_text, source_bytecode)) = abi::read_js_source(&mut env, source) else { return Ok(1); }; @@ -1249,8 +1412,8 @@ fn guest_unofficial_napi_contextify_run_script( &mut result_id, ) })?; - if status == 0 && result_ptr != 0 { - write_guest_u32(&mut env, result_ptr, result_id); + if status == 0 { + write_guest_u32(&mut env, result_ptr as u32, result_id); } Ok(status) } @@ -1268,13 +1431,22 @@ fn guest_unofficial_napi_contextify_compile_function( params_or_undefined: u32, host_defined_option_id: u32, result_ptr: u32, -) -> i32 { +) -> Result { let Some((source_text, source_bytecode)) = abi::read_js_source(&mut env, source) else { - return 1; + return Ok(1); + }; + if result_ptr == 0 || read_guest_bytes(&mut env, result_ptr, 4).is_none() { + return Ok(1); + } + let Some(_scratch_charge) = HostTransientReservation::reserve( + Arc::clone(&env.data().budget), + COMPILE_SCRATCH_RESERVATION, + ) else { + return Ok(1); }; let env_handle = snapi_env(&env, napi_env); let mut result_id = 0u32; - let status = unsafe { + let status = with_cb_context(&mut env, napi_env, || unsafe { snapi_bridge_unofficial_contextify_compile_function( env_handle, source_text, @@ -1288,58 +1460,22 @@ fn guest_unofficial_napi_contextify_compile_function( host_defined_option_id, &mut result_id, ) - }; - if status == 0 && result_ptr != 0 { - write_guest_u32(&mut env, result_ptr, result_id); + })?; + if status == 0 && !write_guest_u32(&mut env, result_ptr as u32, result_id) { + return Ok(1); } - status + Ok(status) } fn guest_unofficial_napi_bytecode_open( - mut env: FunctionEnvMut, - napi_env: u32, - options_ptr: u32, - result_ptr: u32, + _env: FunctionEnvMut, + _napi_env: u32, + _options_ptr: u32, + _result_ptr: u32, ) -> i32 { - let Some(options) = abi::read_bytecode_open(&mut env, options_ptr) else { - return 1; - }; - // The whole 6-byte result must lie below 4 GiB (checked before - // anything is created), so the field offsets below cannot wrap. - if result_ptr == 0 || u64::from(result_ptr) + 6 > 1u64 << 32 { - return 1; - } - let env_handle = snapi_env(&env, napi_env); - let mut bytecode_id = 0u32; - let mut cache_rejected = 0u8; - let mut can_parse_as_module = 0u8; - let status = unsafe { - snapi_bridge_unofficial_bytecode_open( - env_handle, - options.source_text, - options.filename, - options.shape, - options.params_or_undefined, - options.host_defined_option_id, - options.line_offset, - options.column_offset, - if options.has_cache != 0 { - options.cache.as_ptr() - } else { - std::ptr::null() - }, - options.cache.len(), - options.has_cache, - options.cache_policy, - &mut bytecode_id, - &mut cache_rejected, - &mut can_parse_as_module, - ) - }; - write_guest_u32(&mut env, result_ptr, bytecode_id); - write_guest_u8(&mut env, result_ptr + 4, cache_rejected); - write_guest_u8(&mut env, result_ptr + 5, can_parse_as_module); - status + // Bytecode handles own persistent native source and cache copies. The + // guest cannot create them until those copies have a lasting budget charge. + 1 } fn guest_unofficial_napi_bytecode_serialize( @@ -1372,20 +1508,18 @@ fn guest_unofficial_napi_module_wrap_create( napi_env: u32, options_ptr: u32, result_ptr: u32, -) -> i32 { +) -> Result { let Some(options) = abi::read_module_create(&mut env, options_ptr) else { - return 1; + return Ok(1); }; - // The whole 9-byte result must lie below 4 GiB (checked before - // anything is created), so the field offsets below cannot wrap. - if result_ptr == 0 || u64::from(result_ptr) + 9 > 1u64 << 32 { - return 1; + if result_ptr == 0 || read_guest_bytes(&mut env, result_ptr, 9).is_none() { + return Ok(1); } let env_handle = snapi_env(&env, napi_env); let mut handle_id = 0u32; let mut requests_id = 0u32; let mut has_top_level_await = 0u8; - let status = unsafe { + let status = with_cb_context(&mut env, napi_env, || unsafe { snapi_bridge_unofficial_module_wrap_create( env_handle, options.kind, @@ -1403,13 +1537,25 @@ fn guest_unofficial_napi_module_wrap_create( &mut requests_id, &mut has_top_level_await, ) + }); + let status = match status { + Ok(status) => status, + Err(error) => { + if handle_id != 0 { + unsafe { snapi_bridge_unofficial_module_wrap_destroy(env_handle, handle_id) }; + } + return Err(error); + } }; - if status == 0 { - write_guest_u32(&mut env, result_ptr, handle_id); - write_guest_u32(&mut env, result_ptr + 4, requests_id); - write_guest_bytes(&mut env, result_ptr + 8, &[has_top_level_await]); + if status == 0 + && (!write_guest_u32(&mut env, result_ptr as u32, handle_id) + || !write_guest_u32(&mut env, result_ptr as u32 + 4, requests_id) + || !write_guest_bytes(&mut env, result_ptr as u32 + 8, &[has_top_level_await])) + { + unsafe { snapi_bridge_unofficial_module_wrap_destroy(env_handle, handle_id) }; + return Ok(1); } - status + Ok(status) } fn guest_unofficial_napi_module_wrap_destroy( @@ -1427,34 +1573,45 @@ fn guest_unofficial_napi_module_wrap_link( handle: u32, count: u32, linked_handles_ptr: u32, -) -> i32 { +) -> Result { + if !(0..=4096).contains(&count) { + return Ok(1); + } + let Some(_scratch_charge) = HostTransientReservation::reserve( + Arc::clone(&env.data().budget), + MODULE_LINK_SCRATCH_RESERVATION, + ) else { + return Ok(1); + }; let env_handle = snapi_env(&env, napi_env); let count_u = count; let linked_handles = if count_u > 0 { let Some(ids) = read_guest_u32_array(&mut env, linked_handles_ptr, count_u as usize) else { - return 1; + return Ok(1); }; ids } else { - Vec::new() + HostCopy::default() }; - unsafe { + with_cb_context(&mut env, napi_env, || unsafe { snapi_bridge_unofficial_module_wrap_link( env_handle, handle, count_u, linked_handles.as_ptr(), ) - } + }) } fn guest_unofficial_napi_module_wrap_instantiate( - env: FunctionEnvMut, + mut env: FunctionEnvMut, napi_env: u32, handle: u32, -) -> i32 { +) -> Result { let env_handle = snapi_env(&env, napi_env); - unsafe { snapi_bridge_unofficial_module_wrap_instantiate(env_handle, handle) } + with_cb_context(&mut env, napi_env, || unsafe { + snapi_bridge_unofficial_module_wrap_instantiate(env_handle, handle as u32) + }) } fn guest_unofficial_napi_module_wrap_evaluate( @@ -1512,16 +1669,16 @@ fn guest_unofficial_napi_module_wrap_get_namespace( napi_env: u32, handle: u32, result_ptr: u32, -) -> i32 { +) -> Result { let env_handle = snapi_env(&env, napi_env); let mut result_id = 0u32; - let status = unsafe { - snapi_bridge_unofficial_module_wrap_get_namespace(env_handle, handle, &mut result_id) - }; + let status = with_cb_context(&mut env, napi_env, || unsafe { + snapi_bridge_unofficial_module_wrap_get_namespace(env_handle, handle as u32, &mut result_id) + })?; if status == 0 && result_ptr != 0 { - write_guest_u32(&mut env, result_ptr, result_id); + write_guest_u32(&mut env, result_ptr as u32, result_id); } - status + Ok(status) } fn guest_unofficial_napi_module_wrap_get_state( @@ -1577,22 +1734,22 @@ fn guest_unofficial_napi_module_wrap_check_unsettled_top_level_await( handle: u32, warnings: i32, settled_ptr: u32, -) -> i32 { +) -> Result { let env_handle = snapi_env(&env, napi_env); let handle_id = handle; let mut settled = 0i32; - let status = unsafe { + let status = with_cb_context(&mut env, napi_env, || unsafe { snapi_bridge_unofficial_module_wrap_check_unsettled_top_level_await( env_handle, handle_id, warnings, &mut settled, ) - }; + })?; if status == 0 && settled_ptr != 0 { - write_guest_u8(&mut env, settled_ptr, (settled != 0) as u8); + write_guest_u8(&mut env, settled_ptr as u32, (settled != 0) as u8); } - status + Ok(status) } fn guest_unofficial_napi_module_wrap_set_export( @@ -1651,20 +1808,14 @@ fn guest_unofficial_napi_module_wrap_get_module_source_object( } fn guest_unofficial_napi_module_wrap_create_cached_data( - mut env: FunctionEnvMut, - napi_env: u32, - handle: u32, - result_ptr: u32, + _env: FunctionEnvMut, + _napi_env: u32, + _handle: u32, + _result_ptr: u32, ) -> i32 { - let env_handle = snapi_env(&env, napi_env); - let mut result_id = 0u32; - let status = unsafe { - snapi_bridge_unofficial_module_wrap_create_cached_data(env_handle, handle, &mut result_id) - }; - if status == 0 && result_ptr != 0 { - write_guest_u32(&mut env, result_ptr, result_id); - } - status + // CreateCodeCache allocates native CachedData before a size can be read. + // Keep this import closed until the native allocation has an enforced cap. + 1 } fn guest_unofficial_napi_module_wrap_set_hooks( @@ -2020,7 +2171,9 @@ fn guest_napi_get_value_string_utf8( if hbs as u64 > guest_data_size(&mut env) { return 1; } - let mut hb = vec![0 as c_char; hbs]; + let Some(mut hb) = HostCopy::::zeroed(env.data().budget.clone(), hbs) else { + return 1; + }; let mut rl: usize = 0; let s = unsafe { snapi_bridge_get_value_string_utf8( @@ -2061,7 +2214,9 @@ fn guest_napi_get_value_string_latin1( if hbs as u64 > guest_data_size(&mut env) { return 1; } - let mut hb = vec![0 as c_char; hbs]; + let Some(mut hb) = HostCopy::::zeroed(env.data().budget.clone(), hbs) else { + return 1; + }; let mut rl: usize = 0; let s = unsafe { snapi_bridge_get_value_string_latin1( @@ -2261,6 +2416,8 @@ macro_rules! guest_coerce { }; } +guest_coerce!(guest_napi_coerce_to_bool, snapi_bridge_coerce_to_bool); + macro_rules! guest_coerce_with_callbacks { ($name:ident, $bridge:ident) => { fn $name( @@ -2282,7 +2439,6 @@ macro_rules! guest_coerce_with_callbacks { }; } -guest_coerce!(guest_napi_coerce_to_bool, snapi_bridge_coerce_to_bool); guest_coerce_with_callbacks!(guest_napi_coerce_to_number, snapi_bridge_coerce_to_number); guest_coerce_with_callbacks!(guest_napi_coerce_to_string, snapi_bridge_coerce_to_string); guest_coerce_with_callbacks!(guest_napi_coerce_to_object, snapi_bridge_coerce_to_object); @@ -2384,7 +2540,9 @@ fn guest_napi_set_named_property( let Some(nb) = read_guest_c_string(&mut env, np) else { return Ok(1); }; - let cn = CString::new(nb).unwrap_or_default(); + let Some(cn) = GuestName::new(&nb, u32::MAX) else { + return Ok(1); + }; let snapi = snapi_env(&env, e); with_cb_context(&mut env, e, || unsafe { snapi_bridge_set_named_property(snapi, o, cn.as_ptr(), v) @@ -2401,7 +2559,9 @@ fn guest_napi_get_named_property( let Some(nb) = read_guest_c_string(&mut env, np) else { return Ok(1); }; - let cn = CString::new(nb).unwrap_or_default(); + let Some(cn) = GuestName::new(&nb, u32::MAX) else { + return Ok(1); + }; let mut out: u32 = 0; let snapi = snapi_env(&env, e); let s = with_cb_context(&mut env, e, || unsafe { @@ -2423,7 +2583,9 @@ fn guest_napi_has_named_property( let Some(nb) = read_guest_c_string(&mut env, np) else { return Ok(1); }; - let cn = CString::new(nb).unwrap_or_default(); + let Some(cn) = GuestName::new(&nb, u32::MAX) else { + return Ok(1); + }; let mut r: i32 = 0; let snapi = snapi_env(&env, e); let s = with_cb_context(&mut env, e, || unsafe { @@ -2610,17 +2772,29 @@ fn guest_napi_throw_error( code_ptr: u32, msg_ptr: u32, ) -> Result { - let snapi = snapi_env(&env, e); let code_bytes = if code_ptr != 0 { - read_guest_c_string(&mut env, code_ptr) + let Some(bytes) = read_guest_c_string(&mut env, code_ptr) else { + return Ok(1); + }; + Some(bytes) } else { None }; let Some(msg_bytes) = read_guest_c_string(&mut env, msg_ptr) else { return Ok(1); }; - let c_code = code_bytes.map(|b| CString::new(b).unwrap_or_default()); - let c_msg = CString::new(msg_bytes).unwrap_or_default(); + let c_code = if let Some(bytes) = code_bytes.as_ref() { + let Some(name) = GuestName::new(bytes, u32::MAX) else { + return Ok(1); + }; + Some(name) + } else { + None + }; + let Some(c_msg) = GuestName::new(&msg_bytes, u32::MAX) else { + return Ok(1); + }; + let snapi = snapi_env(&env, e); with_cb_context(&mut env, e, || unsafe { snapi_bridge_throw_error( snapi, @@ -2636,17 +2810,29 @@ fn guest_napi_throw_type_error( code_ptr: u32, msg_ptr: u32, ) -> Result { - let snapi = snapi_env(&env, e); let code_bytes = if code_ptr != 0 { - read_guest_c_string(&mut env, code_ptr) + let Some(bytes) = read_guest_c_string(&mut env, code_ptr) else { + return Ok(1); + }; + Some(bytes) } else { None }; let Some(msg_bytes) = read_guest_c_string(&mut env, msg_ptr) else { return Ok(1); }; - let c_code = code_bytes.map(|b| CString::new(b).unwrap_or_default()); - let c_msg = CString::new(msg_bytes).unwrap_or_default(); + let c_code = if let Some(bytes) = code_bytes.as_ref() { + let Some(name) = GuestName::new(bytes, u32::MAX) else { + return Ok(1); + }; + Some(name) + } else { + None + }; + let Some(c_msg) = GuestName::new(&msg_bytes, u32::MAX) else { + return Ok(1); + }; + let snapi = snapi_env(&env, e); with_cb_context(&mut env, e, || unsafe { snapi_bridge_throw_type_error( snapi, @@ -2662,17 +2848,29 @@ fn guest_napi_throw_range_error( code_ptr: u32, msg_ptr: u32, ) -> Result { - let snapi = snapi_env(&env, e); let code_bytes = if code_ptr != 0 { - read_guest_c_string(&mut env, code_ptr) + let Some(bytes) = read_guest_c_string(&mut env, code_ptr) else { + return Ok(1); + }; + Some(bytes) } else { None }; let Some(msg_bytes) = read_guest_c_string(&mut env, msg_ptr) else { return Ok(1); }; - let c_code = code_bytes.map(|b| CString::new(b).unwrap_or_default()); - let c_msg = CString::new(msg_bytes).unwrap_or_default(); + let c_code = if let Some(bytes) = code_bytes.as_ref() { + let Some(name) = GuestName::new(bytes, u32::MAX) else { + return Ok(1); + }; + Some(name) + } else { + None + }; + let Some(c_msg) = GuestName::new(&msg_bytes, u32::MAX) else { + return Ok(1); + }; + let snapi = snapi_env(&env, e); with_cb_context(&mut env, e, || unsafe { snapi_bridge_throw_range_error( snapi, @@ -3310,9 +3508,7 @@ fn guest_napi_open_handle_scope(mut env: FunctionEnvMut, e: u32, rp: u3 s } fn guest_napi_close_handle_scope(env: FunctionEnvMut, e: u32, scope: u32) -> i32 { - let s = unsafe { snapi_bridge_close_handle_scope(snapi_env(&env, e), scope) }; - if s == 0 {} - s + unsafe { snapi_bridge_close_handle_scope(snapi_env(&env, e), scope as u32) } } fn guest_napi_open_escapable_handle_scope( @@ -3408,7 +3604,7 @@ fn guest_napi_call_function( }; ids } else { - vec![] + HostCopy::default() }; let snapi = snapi_env(&env, e); @@ -3435,8 +3631,8 @@ fn guest_napi_create_function( rp: u32, ) -> i32 { // Read function name - let wl = name_len; - let name_bytes: Vec = if wl == 0xFFFFFFFFu32 { + let wl = name_len as u32; + let name_bytes: HostCopy = if wl == 0xFFFFFFFFu32 { // NAPI_AUTO_LENGTH: read null-terminated string read_guest_c_string(&mut env, name_ptr).unwrap_or_default() } else if wl > 0 && name_ptr != 0 { @@ -3445,7 +3641,10 @@ fn guest_napi_create_function( }; bytes } else { - vec![] + HostCopy::default() + }; + let Some(name) = GuestName::new(&name_bytes, wl) else { + return 1; }; // Allocate a registration ID in the C++ callback registry @@ -3458,9 +3657,8 @@ fn guest_napi_create_function( // Create a JS function in V8 with generic_wasm_callback as its native callback. // The reg_id is stored as the function's data pointer so generic_wasm_callback // can look up which WASM function to invoke. - let c_name = CString::new(name_bytes).unwrap_or_default(); let mut out: u32 = 0; - let s = unsafe { snapi_bridge_create_function(snapi, c_name.as_ptr(), wl, reg_id, &mut out) }; + let s = unsafe { snapi_bridge_create_function(snapi, name.as_ptr(), wl, reg_id, &mut out) }; if s != 0 { return s; } @@ -3504,7 +3702,10 @@ fn guest_napi_get_cb_info( // Query the bridge for callback context let mut actual_argc: u32 = wanted; - let mut argv_ids = vec![0u32; wanted as usize]; + let Some(mut argv_ids) = HostCopy::::zeroed(env.data().budget.clone(), wanted as usize) + else { + return 1; + }; let mut this_id: u32 = 0; let mut data_val: u64 = 0; @@ -3597,10 +3798,12 @@ fn guest_napi_define_class( props_ptr: u32, rp: u32, ) -> Result { - let snapi = snapi_env(&env, e); + if rp == 0 || read_guest_bytes(&mut env, rp, 4).is_none() { + return Ok(1); + } // Read class name - let wl = name_len; - let name_bytes: Vec = if wl == 0xFFFFFFFFu32 { + let wl = name_len as u32; + let name_bytes: HostCopy = if wl == 0xFFFFFFFFu32 { read_guest_c_string(&mut env, name_ptr).unwrap_or_default() } else if wl > 0 && name_ptr != 0 { let Some(bytes) = read_guest_bytes(&mut env, name_ptr, wl as usize) else { @@ -3608,15 +3811,29 @@ fn guest_napi_define_class( }; bytes } else { - vec![] + HostCopy::default() + }; + + if prop_count as usize > MAX_NAPI_PROPERTY_DESCRIPTORS { + return Ok(1); + } + // The bridge builds one native napi_property_descriptor per property. + // Reserve twice its current 64-byte size through any Proxy callback. + let Some(_native_descriptors) = + HostTransientReservation::reserve(Arc::clone(&env.data().budget), prop_count as u64 * 128) + else { + return Ok(1); + }; + let Some(name) = GuestName::new(&name_bytes, wl) else { + return Ok(1); }; // Register the constructor callback + let snapi = snapi_env(&env, e); let ctor_reg_id = unsafe { snapi_bridge_alloc_cb_reg_id(snapi) }; unsafe { snapi_bridge_register_callback(snapi, ctor_reg_id, e, constructor, ctor_data as u64) }; - let pc = prop_count; - let c_name = CString::new(name_bytes).unwrap_or_default(); + let pc = prop_count as u32; if pc == 0 { // No properties — simple case @@ -3624,7 +3841,7 @@ fn guest_napi_define_class( let s = with_cb_context(&mut env, e, || unsafe { snapi_bridge_define_class( snapi, - c_name.as_ptr(), + name.as_ptr(), wl, ctor_reg_id, 0, @@ -3652,15 +3869,15 @@ fn guest_napi_define_class( return Ok(1); }; - let mut prop_names_c: Vec = Vec::with_capacity(pc as usize); - let mut prop_names_ptrs: Vec<*const std::ffi::c_char> = Vec::with_capacity(pc as usize); - let mut prop_name_ids: Vec = Vec::with_capacity(pc as usize); - let mut prop_types: Vec = Vec::with_capacity(pc as usize); - let mut prop_value_ids: Vec = Vec::with_capacity(pc as usize); - let mut prop_method_reg_ids: Vec = Vec::with_capacity(pc as usize); - let mut prop_getter_reg_ids: Vec = Vec::with_capacity(pc as usize); - let mut prop_setter_reg_ids: Vec = Vec::with_capacity(pc as usize); - let mut prop_attributes: Vec = Vec::with_capacity(pc as usize); + let mut prop_names_c = reserve_property_array!(env, pc as usize, CString); + let mut prop_names_ptrs = reserve_property_array!(env, pc as usize, *const c_char); + let mut prop_name_ids = reserve_property_array!(env, pc as usize, u32); + let mut prop_types = reserve_property_array!(env, pc as usize, u32); + let mut prop_value_ids = reserve_property_array!(env, pc as usize, u32); + let mut prop_method_reg_ids = reserve_property_array!(env, pc as usize, u32); + let mut prop_getter_reg_ids = reserve_property_array!(env, pc as usize, u32); + let mut prop_setter_reg_ids = reserve_property_array!(env, pc as usize, u32); + let mut prop_attributes = reserve_property_array!(env, pc as usize, i32); for i in 0..pc as usize { let base = i * PROP_DESC_SIZE; @@ -3705,9 +3922,8 @@ fn guest_napi_define_class( let pname = if utf8name_guest != 0 { read_guest_c_string(&mut env, utf8name_guest).unwrap_or_default() } else { - vec![] + HostCopy::default() }; - let c_pname = CString::new(pname).unwrap_or_default(); prop_name_ids.push(name_id); // Determine property type and register callbacks as needed @@ -3772,11 +3988,13 @@ fn guest_napi_define_class( } prop_attributes.push(attrs); - prop_names_c.push(c_pname); + if prop_names_c.push_cstring(pname.as_slice()).is_none() { + return Ok(1); + } } // Build pointer array (must live as long as the FFI call) - for cn in &prop_names_c { + for cn in prop_names_c.iter() { prop_names_ptrs.push(cn.as_ptr()); } @@ -3784,7 +4002,7 @@ fn guest_napi_define_class( let s = with_cb_context(&mut env, e, || unsafe { snapi_bridge_define_class( snapi, - c_name.as_ptr(), + name.as_ptr(), wl, ctor_reg_id, pc, @@ -3813,6 +4031,14 @@ fn guest_napi_define_properties( prop_count: u32, props_ptr: u32, ) -> Result { + if prop_count as usize > MAX_NAPI_PROPERTY_DESCRIPTORS { + return Ok(1); + } + let Some(_native_descriptors) = + HostTransientReservation::reserve(Arc::clone(&env.data().budget), prop_count as u64 * 128) + else { + return Ok(1); + }; let snapi = snapi_env(&env, e); let pc = prop_count; if pc == 0 { @@ -3838,15 +4064,15 @@ fn guest_napi_define_properties( return Ok(1); }; - let mut prop_names_c: Vec = Vec::with_capacity(pc as usize); - let mut prop_names_ptrs: Vec<*const std::ffi::c_char> = Vec::with_capacity(pc as usize); - let mut prop_name_ids: Vec = Vec::with_capacity(pc as usize); - let mut prop_types: Vec = Vec::with_capacity(pc as usize); - let mut prop_value_ids: Vec = Vec::with_capacity(pc as usize); - let mut prop_method_reg_ids: Vec = Vec::with_capacity(pc as usize); - let mut prop_getter_reg_ids: Vec = Vec::with_capacity(pc as usize); - let mut prop_setter_reg_ids: Vec = Vec::with_capacity(pc as usize); - let mut prop_attributes: Vec = Vec::with_capacity(pc as usize); + let mut prop_names_c = reserve_property_array!(env, pc as usize, CString); + let mut prop_names_ptrs = reserve_property_array!(env, pc as usize, *const c_char); + let mut prop_name_ids = reserve_property_array!(env, pc as usize, u32); + let mut prop_types = reserve_property_array!(env, pc as usize, u32); + let mut prop_value_ids = reserve_property_array!(env, pc as usize, u32); + let mut prop_method_reg_ids = reserve_property_array!(env, pc as usize, u32); + let mut prop_getter_reg_ids = reserve_property_array!(env, pc as usize, u32); + let mut prop_setter_reg_ids = reserve_property_array!(env, pc as usize, u32); + let mut prop_attributes = reserve_property_array!(env, pc as usize, i32); for i in 0..pc as usize { let base = i * PROP_DESC_SIZE; @@ -3890,9 +4116,8 @@ fn guest_napi_define_properties( let pname = if utf8name_guest != 0 { read_guest_c_string(&mut env, utf8name_guest).unwrap_or_default() } else { - vec![] + HostCopy::default() }; - let c_pname = CString::new(pname).unwrap_or_default(); prop_name_ids.push(name_id); if method_ptr != 0 { @@ -3951,10 +4176,12 @@ fn guest_napi_define_properties( } prop_attributes.push(attrs); - prop_names_c.push(c_pname); + if prop_names_c.push_cstring(pname.as_slice()).is_none() { + return Ok(1); + } } - for cn in &prop_names_c { + for cn in prop_names_c.iter() { prop_names_ptrs.push(cn.as_ptr()); } @@ -4028,15 +4255,20 @@ fn guest_napi_create_string_utf16( } else { wl as usize }; - let byte_len = char_count * 2; + let Some(byte_len) = char_count.checked_mul(2) else { + return 1; + }; let Some(raw_bytes) = read_guest_bytes(&mut env, str_ptr, byte_len) else { return 1; }; - // Convert bytes to u16 array - let u16_data: Vec = raw_bytes - .chunks_exact(2) - .map(|c| u16::from_le_bytes([c[0], c[1]])) - .collect(); + // Both live copies are charged until the native call returns. + let Some(mut u16_data) = HostCopy::::with_capacity(env.data().budget.clone(), char_count) + else { + return 1; + }; + for c in raw_bytes.chunks_exact(2) { + u16_data.push(u16::from_le_bytes([c[0], c[1]])); + } let mut out: u32 = 0; // Always pass the actual char count to the 64-bit bridge (not the WASM32 NAPI_AUTO_LENGTH sentinel) let s = unsafe { @@ -4061,16 +4293,22 @@ fn guest_napi_get_value_string_utf16( bs: u32, rp: u32, ) -> i32 { - let hbs = bs as usize; + let Ok(hbs) = usize::try_from(bs) else { + return 1; + }; // Each unit is 2 bytes; reject a claimed size that overflows or cannot fit // in the guest's own memory before allocating the host scratch mirror. let Some(byte_len) = hbs.checked_mul(2) else { return 1; }; - if byte_len as u64 > guest_data_size(&mut env) { + if byte_len as u64 > guest_data_size(&mut env) + || (bp != 0 && !guest_range_is_valid(&mut env, bp, byte_len)) + { return 1; } - let mut hb = vec![0u16; hbs]; + let Some(mut hb) = HostCopy::::zeroed(env.data().budget.clone(), hbs) else { + return 1; + }; let mut rl: usize = 0; let s = unsafe { snapi_bridge_get_value_string_utf16( @@ -4090,9 +4328,11 @@ fn guest_napi_get_value_string_utf16( } if bp != 0 && hbs > 0 { let n = hbs.min(rl + 1); - // Write u16 values as LE bytes to guest memory - let bytes: Vec = hb[..n].iter().flat_map(|&v| v.to_le_bytes()).collect(); - write_guest_bytes(&mut env, bp, &bytes); + // Convert in place, then copy the same charged buffer to guest memory. + for unit in &mut hb.as_mut_slice()[..n] { + *unit = unit.to_le(); + } + write_guest_pod_slice(&mut env, bp, &hb[..n]); } if rp != 0 { write_guest_u32(&mut env, rp, rl as u32); @@ -4110,15 +4350,24 @@ fn guest_napi_create_bigint_words( words_ptr: u32, rp: u32, ) -> i32 { - let wc = word_count; + if word_count as usize > MAX_NAPI_BIGINT_WORDS { + return 1; + } + let wc = word_count as u32; // Read u64 words from guest memory (each is 8 bytes) - let Some(words_bytes) = read_guest_bytes(&mut env, words_ptr, wc as usize * 8) else { + let Some(byte_len) = (wc as usize).checked_mul(8) else { return 1; }; - let words: Vec = words_bytes - .chunks_exact(8) - .map(|c| u64::from_le_bytes(c.try_into().unwrap())) - .collect(); + let Some(words_bytes) = read_guest_bytes(&mut env, words_ptr, byte_len) else { + return 1; + }; + let Some(mut words) = HostCopy::::with_capacity(env.data().budget.clone(), wc as usize) + else { + return 1; + }; + for c in words_bytes.chunks_exact(8) { + words.push(u64::from_le_bytes(c.try_into().unwrap())); + } let mut out: u32 = 0; let s = unsafe { snapi_bridge_create_bigint_words(snapi_env(&env, e), sign_bit, wc, words.as_ptr(), &mut out) @@ -4141,7 +4390,8 @@ fn guest_napi_get_value_bigint_words( let Some(wc_bytes) = read_guest_bytes(&mut env, wc_ptr, 4) else { return 1; }; - let mut word_count = u32::from_le_bytes(wc_bytes.try_into().unwrap()) as usize; + let mut word_count = u32::from_le_bytes(wc_bytes.as_slice().try_into().unwrap()) as usize; + let capacity = word_count; if words_ptr == 0 { // Query mode: just get the word count @@ -4166,27 +4416,35 @@ fn guest_napi_get_value_bigint_words( if word_count > MAX_NAPI_BIGINT_WORDS { return 1; } + if !guest_range_is_valid(&mut env, words_ptr, word_count * 8) { + return 1; + } let mut sign: i32 = 0; - let mut words = vec![0u64; word_count]; + let Some(mut words) = HostCopy::::zeroed(env.data().budget.clone(), word_count) else { + return 1; + }; let s = unsafe { snapi_bridge_get_value_bigint_words( snapi_env(&env, e), vh, &mut sign, &mut word_count, - words.as_mut_ptr(), + if capacity == 0 { + std::ptr::null_mut() + } else { + words.as_mut_ptr() + }, ) }; if s == 0 { - write_guest_i32(&mut env, sign_ptr, sign); - write_guest_u32(&mut env, wc_ptr, word_count as u32); - // Write u64 words as LE bytes to guest - let bytes: Vec = words[..word_count] - .iter() - .flat_map(|&v| v.to_le_bytes()) - .collect(); - write_guest_bytes(&mut env, words_ptr, &bytes); + write_guest_i32(&mut env, sign_ptr as u32, sign); + write_guest_u32(&mut env, wc_ptr as u32, word_count as u32); + let copied = capacity.min(word_count); + for word in &mut words.as_mut_slice()[..copied] { + *word = word.to_le(); + } + write_guest_pod_slice(&mut env, words_ptr, &words[..copied]); } s } @@ -4451,6 +4709,7 @@ fn guest_napi_get_buffer_info( 0 } +#[allow(clippy::too_many_arguments)] // The imported C ABI has eight operands. fn guest_unofficial_napi_acquire_buffer_lease( mut env: FunctionEnvMut, e: u32, @@ -4801,7 +5060,7 @@ fn guest_napi_new_instance( }; ids } else { - vec![] + HostCopy::default() }; let snapi = snapi_env(&env, e); @@ -5219,3 +5478,37 @@ pub fn register_env_imports(store: &mut impl AsStoreMut, io: &mut Imports) { guest_env_ossl_set_max_threads ); } + +#[cfg(test)] +mod guest_name_tests { + use super::*; + use crate::ResourceBudget; + + #[test] + fn explicit_names_keep_embedded_nul_and_reject_missing_bytes() { + let mut bytes = HostCopy::::zeroed(ResourceBudget::with_memory_limit(64), 3).unwrap(); + bytes.as_mut_slice().copy_from_slice(b"a\0b"); + let explicit = GuestName::new(&bytes, 3).unwrap(); + assert!(explicit.terminated.is_none()); + let received = unsafe { std::slice::from_raw_parts(explicit.as_ptr().cast::(), 3) }; + assert_eq!(received, b"a\0b"); + assert!(GuestName::new(&bytes, 4).is_none()); + + let missing = HostCopy::::empty(); + assert!(GuestName::new(&missing, 1).is_none()); + assert!(GuestName::new(&bytes, u32::MAX).is_none()); + + let budget = ResourceBudget::with_memory_limit(64); + let mut plain = HostCopy::::zeroed(budget.clone(), 3).unwrap(); + plain.as_mut_slice().copy_from_slice(b"abc"); + let before = budget.snapshot().host_transient; + let auto = GuestName::new(&plain, u32::MAX).unwrap(); + assert_eq!(budget.snapshot().host_transient, before + 4); + assert_eq!( + unsafe { std::ffi::CStr::from_ptr(auto.as_ptr()) }.to_bytes(), + b"abc" + ); + drop(auto); + assert_eq!(budget.snapshot().host_transient, before); + } +} diff --git a/src/guest/napi_js.rs b/src/guest/napi_js.rs index 42a7f625..147d2487 100644 --- a/src/guest/napi_js.rs +++ b/src/guest/napi_js.rs @@ -26,6 +26,7 @@ use crate::{ }, typedarray_element_size, }, + message::MessageCharge, snapi::*, }; @@ -1058,10 +1059,20 @@ fn guest_unofficial_napi_message_create( let env_handle = snapi_env(&env, napi_env); let mut payload = 0u32; let status = unsafe { snapi_bridge_unofficial_message_create(env_handle, value, &mut payload) }; - if status == 0 && payload_out_ptr != 0 { - write_guest_u32(&mut env, payload_out_ptr, payload); + if status != 0 { + return status; } - status + let charge = MessageCharge::reserve(env.data().budget.clone(), 0).expect("zero-byte charge"); + if env.data().pending_messages.insert(payload, charge).is_err() { + unsafe { snapi_bridge_unofficial_message_drop(payload) }; + return 1; + } + if !write_guest_u32(&mut env, payload_out_ptr, payload) { + drop(env.data().pending_messages.take(payload)); + unsafe { snapi_bridge_unofficial_message_drop(payload) }; + return 1; + } + 0 } #[cfg(all(target_arch = "wasm32", feature = "js"))] @@ -1074,6 +1085,13 @@ async fn guest_unofficial_napi_message_take_async( if payload == 0 { return 1; } + let charge = { + let locked = env.read().await; + locked.data().pending_messages.take(payload) + }; + let Some(charge) = charge else { + return 1; + }; if result_out_ptr == 0 { unsafe { snapi_bridge_unofficial_message_drop(payload) }; return 1; @@ -1083,6 +1101,7 @@ async fn guest_unofficial_napi_message_take_async( env.data().resolve_napi_env(napi_env) }; let (status, value) = with_callback_state_async(env.as_mut(), env_handle, async move { + let _charge = charge; if crate::snapi_js::wait_for_message(payload).await.is_err() { unsafe { snapi_bridge_unofficial_message_drop(payload) }; return (9, 0); @@ -1111,6 +1130,9 @@ fn guest_unofficial_napi_message_take_sync( if payload == 0 { return 1; } + let Some(_charge) = env.data().pending_messages.take(payload) else { + return 1; + }; if result_out_ptr == 0 { unsafe { snapi_bridge_unofficial_message_drop(payload) }; return 1; @@ -1134,8 +1156,13 @@ fn message_take_import(store: &mut impl AsStoreMut, fe: &FunctionEnv) - Function::new_typed_with_env(store, fe, guest_unofficial_napi_message_take_sync) } -fn guest_unofficial_napi_message_drop(_env: FunctionEnvMut, payload: u32) { - unsafe { snapi_bridge_unofficial_message_drop(payload) }; +fn guest_unofficial_napi_message_drop(env: FunctionEnvMut, payload: u32) { + if payload != 0 { + let Some(_charge) = env.data().pending_messages.take(payload) else { + return; + }; + unsafe { snapi_bridge_unofficial_message_drop(payload) }; + } } fn guest_unofficial_napi_enqueue_microtask( diff --git a/src/guest/util.rs b/src/guest/util.rs index 29a54075..51ae6e04 100644 --- a/src/guest/util.rs +++ b/src/guest/util.rs @@ -2,15 +2,126 @@ // Guest memory helpers // ============================================================ +use std::{ffi::CString, ops::Deref, sync::Arc}; + use wasmer::{AsStoreMut, FunctionEnvMut}; -use crate::NapiEnv; +use crate::{ + NapiEnv, + budget::{Pool, ResourceBudget}, +}; -pub fn guest_range_is_valid(env: &mut FunctionEnvMut, ptr: u32, len: usize) -> bool { - let Some(end) = u64::from(ptr).checked_add(len as u64) else { - return false; - }; - end <= guest_data_size(env) +/// A host copy of guest data whose budget charge survives for its full lifetime. +/// Keeping the owner with the bytes makes nested and concurrent N-API calls +/// account their peak copies instead of briefly charging only the read itself. +pub(crate) struct HostCopy { + data: Vec, + budget: Option>, + charged: u64, + reserved_elements: usize, +} + +impl HostCopy { + pub(crate) fn zeroed(budget: Arc, count: usize) -> Option { + let mut copy = Self::with_capacity(budget, count)?; + copy.data.resize_with(count, T::default); + Some(copy) + } +} + +impl HostCopy { + pub(crate) fn with_capacity(budget: Arc, count: usize) -> Option { + let charged = u64::try_from(count.checked_mul(std::mem::size_of::())?).ok()?; + budget.try_charge(Pool::HostTransient, charged).ok()?; + let mut copy = Self { + data: Vec::new(), + budget: Some(budget), + charged, + reserved_elements: count, + }; + copy.data.try_reserve_exact(count).ok()?; + Some(copy) + } + + pub(crate) fn empty() -> Self { + Self { + data: Vec::new(), + budget: None, + charged: 0, + reserved_elements: 0, + } + } + + pub(crate) fn as_mut_ptr(&mut self) -> *mut T { + self.data.as_mut_ptr() + } + + pub(crate) fn as_mut_slice(&mut self) -> &mut [T] { + self.data.as_mut_slice() + } + + pub(crate) fn push(&mut self, value: T) { + assert!(self.data.len() < self.reserved_elements); + self.data.push(value); + } +} + +impl HostCopy { + /// Produce a NUL-terminated host copy while keeping its second allocation + /// charged for as long as native code can read it. + pub(crate) fn terminated_copy(&self) -> Option { + let budget = Arc::clone(self.budget.as_ref()?); + let mut terminated = Self::with_capacity(budget, self.data.len().checked_add(1)?)?; + for &byte in &self.data { + if byte == 0 { + return None; + } + terminated.push(byte); + } + terminated.push(0); + Some(terminated) + } +} + +impl HostCopy { + /// Keep copied property names charged while the descriptor array holds + /// them; each name may be as long as MAX_GUEST_CSTRING_SCAN. + pub(crate) fn push_cstring(&mut self, bytes: &[u8]) -> Option<()> { + if self.data.len() == self.reserved_elements { + return None; + } + let additional = u64::try_from(bytes.len().checked_add(1)?).ok()?; + let budget = self.budget.as_ref()?; + budget.try_charge(Pool::HostTransient, additional).ok()?; + self.charged += additional; + self.data.push(CString::new(bytes).unwrap_or_default()); + Some(()) + } +} + +impl Default for HostCopy { + fn default() -> Self { + Self::empty() + } +} + +impl Deref for HostCopy { + type Target = Vec; + + fn deref(&self) -> &Self::Target { + &self.data + } +} + +impl Drop for HostCopy { + fn drop(&mut self) { + // Release the allocation before its budget charge so a concurrent + // reservation cannot observe bytes that are still live as free. + drop(std::mem::take(&mut self.data)); + if let Some(budget) = &self.budget { + budget.uncharge(Pool::HostTransient, self.charged); + } + } } pub fn write_guest_bytes(env: &mut FunctionEnvMut, guest_ptr: u32, data: &[u8]) -> bool { @@ -50,20 +161,21 @@ pub fn read_guest_bytes( env: &mut FunctionEnvMut, guest_ptr: u32, len: usize, -) -> Option> { +) -> Option> { let (state, store) = env.data_and_store_mut(); let memory = state.memory.clone()?; let view = memory.view(&store); - // A guest cannot legitimately reference more bytes than its own linear - // memory holds, so reject a length that exceeds it before allocating. This - // bounds the host copy to memory the guest was already charged for and - // stops a bogus guest-supplied length from allocating gigabytes here (the - // subsequent bounds-checked read would fail, but only after the `vec!`). - if len as u64 > view.data_size() { + // Validate the entire range before allocating. Checking only `len` lets a + // guest point near the end of memory and request a huge host allocation + // that the subsequent bounds-checked read would reject too late. + // Keep wasm32 offsets below 4 GiB even if the backing memory is larger; + // validated structure readers add their field offsets in u32 arithmetic. + let end = u64::from(guest_ptr).checked_add(u64::try_from(len).ok()?)?; + if end > (1u64 << 32) || end > view.data_size() { return None; } - let mut out = vec![0u8; len]; - view.read(guest_ptr as u64, &mut out).ok()?; + let mut out = HostCopy::zeroed(Arc::clone(&state.budget), len)?; + view.read(guest_ptr as u64, &mut out.data).ok()?; Some(out) } @@ -104,6 +216,15 @@ pub fn guest_data_size(env: &mut FunctionEnvMut) -> u64 { memory.view(&store).data_size() } +/// Validate a guest-owned byte range without copying or allocating it. Native +/// external backing stores keep this pointer after the import returns. +pub fn guest_range_is_valid(env: &mut FunctionEnvMut, ptr: u32, len: usize) -> bool { + let Some(end) = u64::from(ptr).checked_add(len as u64) else { + return false; + }; + end <= (1u64 << 32) && end <= guest_data_size(env) +} + /// Allocate `len` bytes of guest memory, passing the import's store directly so /// the heap can claim more from the guest when its arena is short. /// @@ -120,6 +241,9 @@ pub fn alloc_guest( heap.alloc_with_store(&mut store, len, zero) } +// Used by optional guest import paths; the current V8 path allocates through +// the charged host-copy helpers instead. +#[allow(dead_code)] pub fn allocate_guest_bytes(env: &mut FunctionEnvMut, data: &[u8]) -> Option { let heap = env.data().guest_heap.clone()?; let guest_ptr = alloc_guest(env, &heap, data.len(), false)?; @@ -146,32 +270,71 @@ pub fn read_guest_u32_array( env: &mut FunctionEnvMut, guest_ptr: u32, count: usize, -) -> Option> { +) -> Option> { // Guard the byte-length multiply against overflow; the read below is then // clamped to the guest's memory size by `read_guest_bytes`. let byte_len = count.checked_mul(4)?; let bytes = read_guest_bytes(env, guest_ptr, byte_len)?; - let mut result = Vec::with_capacity(count); - for chunk in bytes.chunks_exact(4) { - result.push(u32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]])); + let mut result = HostCopy::zeroed(Arc::clone(&env.data().budget), count)?; + for (slot, chunk) in result.data.iter_mut().zip(bytes.chunks_exact(4)) { + *slot = u32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]); } Some(result) } -pub fn read_guest_c_string(env: &mut FunctionEnvMut, guest_ptr: u32) -> Option> { - let (state, store) = env.data_and_store_mut(); - let memory = state.memory.clone()?; - let view = memory.view(&store); - let mut out = Vec::new(); - let mut offset = guest_ptr as u64; - for _ in 0..super::MAX_GUEST_CSTRING_SCAN { - let mut b = [0u8; 1]; - view.read(offset, &mut b).ok()?; - if b[0] == 0 { - return Some(out); +pub fn read_guest_c_string( + env: &mut FunctionEnvMut, + guest_ptr: u32, +) -> Option> { + let len = { + let (state, store) = env.data_and_store_mut(); + let memory = state.memory.clone()?; + let view = memory.view(&store); + let mut len = None; + for i in 0..super::MAX_GUEST_CSTRING_SCAN { + let mut b = [0u8; 1]; + let offset = u64::from(guest_ptr).checked_add(i as u64)?; + if offset > u64::from(u32::MAX) { + return None; + } + view.read(offset, &mut b).ok()?; + if b[0] == 0 { + len = Some(i); + break; + } } - out.push(b[0]); - offset += 1; + len? + }; + read_guest_bytes(env, guest_ptr, len) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn host_copies_charge_for_their_lifetime_and_rollback_failed_allocations() { + let budget = ResourceBudget::with_memory_limit(8); + let first = HostCopy::::zeroed(Arc::clone(&budget), 6).unwrap(); + assert_eq!(budget.snapshot().host_transient, 6); + assert!(HostCopy::::zeroed(Arc::clone(&budget), 1).is_none()); + assert_eq!(budget.snapshot().host_transient, 6); + drop(first); + assert_eq!(budget.snapshot().host_transient, 0); + + let unlimited = ResourceBudget::unlimited(); + assert!(HostCopy::::zeroed(Arc::clone(&unlimited), usize::MAX).is_none()); + assert_eq!(unlimited.snapshot().host_transient, 0); + + let array_bytes = 2 * std::mem::size_of::() as u64; + let names_budget = ResourceBudget::with_memory_limit(array_bytes + 5); + let mut names = HostCopy::::with_capacity(Arc::clone(&names_budget), 2).unwrap(); + assert_eq!(names_budget.snapshot().host_transient, array_bytes); + assert!(names.push_cstring(b"ab").is_some()); + assert_eq!(names_budget.snapshot().host_transient, array_bytes + 3); + assert!(names.push_cstring(b"too long").is_none()); + assert_eq!(names_budget.snapshot().host_transient, array_bytes + 3); + drop(names); + assert_eq!(names_budget.snapshot().host_transient, 0); } - None } diff --git a/src/guest/util_js.rs b/src/guest/util_js.rs index 971cc6d4..c850cc7a 100644 --- a/src/guest/util_js.rs +++ b/src/guest/util_js.rs @@ -16,6 +16,9 @@ use crate::{ GuestBackingStoreMapping, HostBufferCopy, NapiEnv, snapi::snapi_bridge_create_reference, }; +/// The JavaScript-hosted guest backend has no native host snapshot budget. +pub(crate) type HostCopy = Vec; + const JS_BACKING_TOKEN_MARKER: u64 = 1 << 63; const JS_BACKING_TOKEN_OFFSET_MASK: u64 = u32::MAX as u64; @@ -404,7 +407,9 @@ pub fn read_guest_bytes( if (guest_ptr as u64).checked_add(len as u64)? > wasmer_napi_guest_byte_length(&memory) as u64 { return None; } - let mut out = vec![0u8; len]; + let mut out = Vec::new(); + out.try_reserve_exact(len).ok()?; + out.resize(len, 0); wasmer_napi_guest_read(&memory, guest_ptr, &mut out).then_some(out) } @@ -433,6 +438,13 @@ pub(crate) fn read_guest_fatal_text( .unwrap_or_else(|| "(invalid guest string)".to_owned()) } +pub fn guest_data_size(env: &mut FunctionEnvMut) -> u64 { + let Some(memory) = guest_memory(env) else { + return 0; + }; + wasmer_napi_guest_byte_length(&memory) as u64 +} + pub fn allocate_guest_buffer(env: &mut FunctionEnvMut, byte_len: usize) -> Option { // Exact-size large transfers avoid doubling peak memory for package // archives and Wasm binaries while retaining cheap pooled size classes for @@ -704,7 +716,8 @@ pub fn read_guest_u32_array( count: usize, ) -> Option> { let bytes = read_guest_bytes(env, guest_ptr, count.checked_mul(4)?)?; - let mut result = Vec::with_capacity(count); + let mut result = Vec::new(); + result.try_reserve_exact(count).ok()?; for chunk in bytes.chunks_exact(4) { result.push(u32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]])); } diff --git a/tests/core_callbacks.rs b/tests/core_callbacks.rs index 8b5156e9..4a2ea2b1 100644 --- a/tests/core_callbacks.rs +++ b/tests/core_callbacks.rs @@ -1,22 +1,8 @@ use wasmer::{AsStoreMut, Function, Instance, Module, RuntimeError, Store}; use wasmer_napi::NapiCtx; use wasmer_wasix::{WasiError, wasmer_wasix_types::wasi::ExitCode}; -fn configure_runtime() { - unsafe extern "C" { - fn snapi_bridge_unofficial_configure_runtime( - flags: *const std::ffi::c_char, - length: u32, - ) -> i32; - } - assert_eq!( - unsafe { snapi_bridge_unofficial_configure_runtime(std::ptr::null(), 0) }, - 0 - ); -} - #[test] fn core_callback_exit_probe() { - configure_runtime(); let cases = [ ( "create_buffer", @@ -207,11 +193,7 @@ fn core_callback_exit_probe() { ); let instance = Instance::new(&mut store, &module, &imports).unwrap(); session - .configure_instance( - &mut store.as_store_mut(), - &instance, - Some(instance.exports.get_memory("memory").unwrap()), - ) + .configure_instance(&mut store.as_store_mut(), &instance, None) .unwrap(); let result = instance .exports @@ -226,9 +208,88 @@ fn core_callback_exit_probe() { assert!(failures.is_empty(), "Lost callback exits: {failures:?}"); } +#[test] +fn module_error_construction_preserves_guest_exit() { + let wat = r#"(module + (import "napi_extension_wasmer_v0" "unofficial_napi_create_env" (func $create (param i32 i32 i32 i32) (result i32))) + (import "napi" "napi_create_function" (func $function (param i32 i32 i32 i32 i32 i32) (result i32))) + (import "napi" "napi_get_global" (func $global (param i32 i32) (result i32))) + (import "napi" "napi_set_named_property" (func $set (param i32 i32 i32 i32) (result i32))) + (import "napi" "napi_create_object" (func $object (param i32 i32) (result i32))) + (import "napi" "napi_create_string_utf8" (func $string (param i32 i32 i32 i32) (result i32))) + (import "napi" "napi_run_script" (func $script (param i32 i32 i32) (result i32))) + (import "napi_extension_wasmer_v0" "unofficial_napi_module_wrap_create" (func $module + (param i32 i32 i32) (result i32))) + (import "napi_extension_wasmer_v0" "unofficial_napi_module_wrap_get_namespace" (func $current_namespace (param i32 i32 i32) (result i32))) + (import "wasi_snapshot_preview1" "proc_exit" (func $exit (param i32))) + (memory (export "memory") 1) + (table (export "__indirect_function_table") 1 funcref) + (elem (i32.const 0) $callback) + (data (i32.const 100) "exitNow\00") + (data (i32.const 140) "Object.defineProperty(Error.prototype, 'code', {set(v){exitNow()}}); 0\00") + (data (i32.const 240) "test:module-error") + (data (i32.const 280) "export const x = 1;") + (func $callback (param i32 i32) (result i32) + (call $exit (i32.const 57)) (i32.const 0)) + (func (export "run") (result i32) + (local $env i32) + (drop (call $create (i32.const 8) (i32.const 0) (i32.const 4) (i32.const 8))) + (local.set $env (i32.load (i32.const 4))) + (drop (call $function (local.get $env) (i32.const 100) (i32.const -1) (i32.const 0) (i32.const 0) (i32.const 12))) + (drop (call $global (local.get $env) (i32.const 16))) + (drop (call $set (local.get $env) (i32.load (i32.const 16)) (i32.const 100) (i32.load (i32.const 12)))) + (drop (call $string (local.get $env) (i32.const 140) (i32.const -1) (i32.const 20))) + (drop (call $script (local.get $env) (i32.load (i32.const 20)) (i32.const 24))) + (drop (call $object (local.get $env) (i32.const 32))) + (drop (call $string (local.get $env) (i32.const 240) (i32.const 17) (i32.const 36))) + (drop (call $string (local.get $env) (i32.const 280) (i32.const 19) (i32.const 40))) + ;; Versioned source-text module descriptor and JS source union. + (i32.store (i32.const 512) (i32.const 40)) + (i32.store (i32.const 516) (i32.const 1)) + (i32.store (i32.const 520) (i32.const 1)) + (i32.store (i32.const 524) (i32.load (i32.const 32))) + (i32.store (i32.const 528) (i32.load (i32.const 36))) + (i32.store (i32.const 532) (i32.const 0)) + (i32.store (i32.const 536) (i32.const 560)) + (i32.store (i32.const 540) (i32.const 0)) + (i32.store (i32.const 544) (i32.const 0)) + (i32.store (i32.const 548) (i32.const 0)) + (i32.store (i32.const 560) (i32.const 0)) + (i32.store (i32.const 564) (i32.load (i32.const 40))) + (i32.store (i32.const 568) (i32.const 0)) + (drop (call $module (local.get $env) (i32.const 512) (i32.const 44))) + (call $current_namespace (local.get $env) (i32.load (i32.const 44)) (i32.const 52))))"#; + let ctx = NapiCtx::default(); + let mut store = Store::default(); + let module = Module::new(&store, wat::parse_str(wat).unwrap()).unwrap(); + let session = ctx.new_session(&module).unwrap(); + let mut imports = session.create_imports(&mut store.as_store_mut()).unwrap(); + imports.define( + "wasi_snapshot_preview1", + "proc_exit", + Function::new_typed(&mut store, |code: i32| -> Result<(), RuntimeError> { + Err(RuntimeError::user(Box::new(WasiError::Exit( + ExitCode::from(code), + )))) + }), + ); + let instance = Instance::new(&mut store, &module, &imports).unwrap(); + session + .configure_instance(&mut store.as_store_mut(), &instance, None) + .unwrap(); + let result = instance + .exports + .get_typed_function::<(), i32>(&store, "run") + .unwrap() + .call(&mut store); + assert!( + matches!(result, Err(ref error) if matches!(error.downcast_ref::(), Some(WasiError::Exit(code)) if *code == ExitCode::from(57))), + "{result:?}" + ); +} + #[test] fn reentrant_release_refuses_to_dispose_the_entered_isolate() { - configure_runtime(); let wat = r#"(module (import "napi_extension_wasmer_v0" "unofficial_napi_create_env" (func $create (param i32 i32 i32 i32) (result i32))) (import "napi_extension_wasmer_v0" "unofficial_napi_release_env" (func $release (param i32 i32) (result i32))) @@ -265,11 +326,7 @@ fn reentrant_release_refuses_to_dispose_the_entered_isolate() { let imports = session.create_imports(&mut store.as_store_mut()).unwrap(); let instance = Instance::new(&mut store, &module, &imports).unwrap(); session - .configure_instance( - &mut store.as_store_mut(), - &instance, - Some(instance.exports.get_memory("memory").unwrap()), - ) + .configure_instance(&mut store.as_store_mut(), &instance, None) .unwrap(); let status = instance .exports diff --git a/tests/guest_boundary_accounting.rs b/tests/guest_boundary_accounting.rs new file mode 100644 index 00000000..c848dcb6 --- /dev/null +++ b/tests/guest_boundary_accounting.rs @@ -0,0 +1,522 @@ +use std::sync::{ + Arc, + atomic::{AtomicU64, Ordering}, +}; + +use wasmer::{AsStoreMut, Function, Instance, Module, RuntimeError, Store}; +use wasmer_napi::NapiCtx; +use wasmer_wasix::{WasiError, wasmer_wasix_types::wasi::ExitCode}; + +const MIB: u64 = 1024 * 1024; + +#[test] +fn proxy_and_getter_exits_cross_versioned_boundaries() { + let wat = r#"(module + (import "napi_extension_wasmer_v0" "unofficial_napi_create_env" (func $create (param i32 i32 i32 i32) (result i32))) + (import "napi" "napi_create_function" (func $function (param i32 i32 i32 i32 i32 i32) (result i32))) + (import "napi" "napi_get_global" (func $global (param i32 i32) (result i32))) + (import "napi" "napi_set_named_property" (func $set (param i32 i32 i32 i32) (result i32))) + (import "napi" "napi_create_string_utf8" (func $string (param i32 i32 i32 i32) (result i32))) + (import "napi" "napi_run_script" (func $script (param i32 i32 i32) (result i32))) + + + + (import "napi_extension_wasmer_v0" "unofficial_napi_contextify_make_context" (func $make_current + (param i32 i32 i32 i32 i32 i32 i32 i32 i32) (result i32))) + (import "napi_extension_wasmer_v0" "unofficial_napi_get_own_non_index_properties" (func $own_current + (param i32 i32 i32 i32) (result i32))) + (import "napi_extension_wasmer_v0" "unofficial_napi_contextify_contains_module_syntax" (func $syntax_current + (param i32 i32 i32 i32 i32 i32) (result i32))) + (import "wasi_snapshot_preview1" "proc_exit" (func $exit (param i32))) + (import "test" "observe" (func $observe)) + (memory (export "memory") 1) + (table (export "__indirect_function_table") 1 funcref) + (elem (i32.const 0) $getter) + (data (i32.const 100) "exitNow\00") + (data (i32.const 150) "Object.defineProperty({}, 'x', { enumerable: true, get() { return exitNow() } })\00") + (data (i32.const 260) "new Proxy({}, { ownKeys() { exitNow(); return []; } })\00") + (data (i32.const 380) "({ toString() { exitNow(); return 'x'; } })\00") + (data (i32.const 500) "ctx\00") + (func $getter (param i32 i32) (result i32) + (call $observe) + (call $exit (i32.const 31)) + (i32.const 0)) + (func (export "run") (param $mode i32) (result i32) + (local $env i32) (local $kind i32) (local $source i32) + (if (call $create (i32.const 8) (i32.const 0) (i32.const 4) (i32.const 8)) + (then (return (i32.const 1)))) + (local.set $env (i32.load (i32.const 4))) + (if (call $function (local.get $env) (i32.const 100) (i32.const -1) + (i32.const 0) (i32.const 0) (i32.const 12)) + (then (return (i32.const 2)))) + (if (call $global (local.get $env) (i32.const 16)) + (then (return (i32.const 3)))) + (if (call $set (local.get $env) (i32.load (i32.const 16)) + (i32.const 100) (i32.load (i32.const 12))) + (then (return (i32.const 4)))) + (local.set $kind (i32.rem_u (local.get $mode) (i32.const 3))) + (local.set $source (i32.const 150)) + (if (i32.eq (local.get $kind) (i32.const 1)) + (then (local.set $source (i32.const 260)))) + (if (i32.eq (local.get $kind) (i32.const 2)) + (then (local.set $source (i32.const 380)))) + (if (call $string (local.get $env) (local.get $source) (i32.const -1) (i32.const 20)) + (then (return (i32.const 5)))) + (if (call $script (local.get $env) (i32.load (i32.const 20)) (i32.const 24)) + (then (return (i32.const 6)))) + (if (call $string (local.get $env) (i32.const 500) (i32.const -1) (i32.const 28)) + (then (return (i32.const 7)))) + (if (result i32) (i32.eqz (local.get $kind)) + (then (call $make_current (local.get $env) (i32.load (i32.const 24)) + (i32.load (i32.const 28)) (i32.const 0) (i32.const 1) (i32.const 1) + (i32.const 0) (i32.const 0) (i32.const 32))) + (else (if (result i32) (i32.eq (local.get $kind) (i32.const 1)) + (then (call $own_current (local.get $env) (i32.load (i32.const 24)) + (i32.const 0) (i32.const 32))) + (else (call $syntax_current (local.get $env) (i32.load (i32.const 24)) + (i32.load (i32.const 28)) (i32.const 0) (i32.const 0) + (i32.const 32))))))))"#; + + for mode in 0..3 { + let ctx = NapiCtx::builder().total_memory_bytes(160 * MIB).build(); + let budget = ctx.budget(); + let observed = Arc::new(AtomicU64::new(0)); + let mut store = Store::default(); + let module = Module::new(&store, wat::parse_str(wat).unwrap()).unwrap(); + let session = ctx.new_session(&module).unwrap(); + let mut imports = session.create_imports(&mut store.as_store_mut()).unwrap(); + imports.define( + "wasi_snapshot_preview1", + "proc_exit", + Function::new_typed(&mut store, |code: i32| -> Result<(), RuntimeError> { + Err(RuntimeError::user(Box::new(WasiError::Exit( + ExitCode::from(code), + )))) + }), + ); + let observed_for_hook = Arc::clone(&observed); + let budget_for_hook = Arc::clone(&budget); + imports.define( + "test", + "observe", + Function::new_typed(&mut store, move || { + observed_for_hook + .store(budget_for_hook.snapshot().host_transient, Ordering::Relaxed); + }), + ); + let instance = Instance::new(&mut store, &module, &imports).unwrap(); + session + .configure_instance(&mut store.as_store_mut(), &instance, None) + .unwrap(); + let run = instance + .exports + .get_typed_function::(&store, "run") + .unwrap(); + let error = run.call(&mut store, mode).unwrap_err(); + assert!( + matches!( + error.downcast_ref::(), + Some(WasiError::Exit(code)) if *code == ExitCode::from(31) + ), + "mode {mode}: {error}" + ); + if mode % 3 == 0 { + assert_eq!(observed.load(Ordering::Relaxed), 2 * MIB); + } + assert_eq!(budget.snapshot().host_transient, 0, "mode {mode}"); + } +} + +#[test] +fn nested_compile_reservations_enforce_finite_budget_and_unwind() { + let wat = r#"(module + (import "napi_extension_wasmer_v0" "unofficial_napi_create_env" (func $create (param i32 i32 i32 i32) (result i32))) + (import "napi" "napi_create_function" (func $function (param i32 i32 i32 i32 i32 i32) (result i32))) + (import "napi" "napi_get_global" (func $global (param i32 i32) (result i32))) + (import "napi" "napi_set_named_property" (func $set (param i32 i32 i32 i32) (result i32))) + (import "napi" "napi_create_string_utf8" (func $string (param i32 i32 i32 i32) (result i32))) + (import "napi" "napi_run_script" (func $script (param i32 i32 i32) (result i32))) + + (import "napi_extension_wasmer_v0" "unofficial_napi_contextify_compile_function" (func $compile_current + (param i32 i32 i32 i32 i32 i32 i32 i32 i32 i32) (result i32))) + (import "test" "observe" (func $observe)) + (memory (export "memory") 1) + (table (export "__indirect_function_table") 1 funcref) + (elem (i32.const 0) $getter) + (global $mode (mut i32) (i32.const 0)) + (global $calls (mut i32) (i32.const 0)) + (global $denials (mut i32) (i32.const 0)) + (data (i32.const 100) "getParam\00") + (data (i32.const 150) "Object.defineProperty(['x'], '0', {get(){return getParam()}})\00") + (data (i32.const 250) "['x']\00") + (data (i32.const 300) "return x;\00") + (data (i32.const 320) "compile.js\00") + (data (i32.const 350) "x\00") + (func $compile (result i32) + (call $compile_current (i32.load (i32.const 4)) + (i32.const 400) (i32.load (i32.const 28)) + (i32.const 0) (i32.const 0) (i32.const 0) (i32.const 0) + (i32.load (i32.const 24)) (i32.const 0) (i32.const 44))) + (func $getter (param i32 i32) (result i32) + (global.set $calls (i32.add (global.get $calls) (i32.const 1))) + (call $observe) + (if (call $compile) + (then (global.set $denials + (i32.add (global.get $denials) (i32.const 1))))) + (i32.load (i32.const 36))) + (func (export "calls") (result i32) (global.get $calls)) + (func (export "denials") (result i32) (global.get $denials)) + (func (export "run") (param $mode_arg i32) (param $recursive i32) (result i32) + (local $env i32) (local $params_source i32) + (global.set $mode (local.get $mode_arg)) + (if (call $create (i32.const 8) (i32.const 0) (i32.const 4) (i32.const 8)) + (then (return (i32.const 1)))) + (local.set $env (i32.load (i32.const 4))) + (if (call $function (local.get $env) (i32.const 100) (i32.const -1) + (i32.const 0) (i32.const 0) (i32.const 12)) + (then (return (i32.const 2)))) + (if (call $global (local.get $env) (i32.const 16)) + (then (return (i32.const 3)))) + (if (call $set (local.get $env) (i32.load (i32.const 16)) + (i32.const 100) (i32.load (i32.const 12))) + (then (return (i32.const 4)))) + (local.set $params_source (i32.const 250)) + (if (local.get $recursive) + (then (local.set $params_source (i32.const 150)))) + (if (call $string (local.get $env) (local.get $params_source) + (i32.const -1) (i32.const 20)) + (then (return (i32.const 5)))) + (if (call $script (local.get $env) (i32.load (i32.const 20)) (i32.const 24)) + (then (return (i32.const 6)))) + (if (call $string (local.get $env) (i32.const 320) (i32.const -1) (i32.const 28)) + (then (return (i32.const 7)))) + (if (call $string (local.get $env) (i32.const 300) (i32.const -1) (i32.const 32)) + (then (return (i32.const 8)))) + (if (call $string (local.get $env) (i32.const 350) (i32.const -1) (i32.const 36)) + (then (return (i32.const 9)))) + (i32.store (i32.const 400) (i32.const 0)) + (i32.store (i32.const 404) (i32.load (i32.const 32))) + (i32.store (i32.const 408) (i32.const 0)) + (call $compile)))"#; + + for mode in 0..1 { + for recursive in [false, true] { + // The guest heap and default V8 reservation consume about 106 MiB. + // A 128 MiB workload can compile a small function. At 144 MiB, + // two nested 16 MiB reservations fit and the third is denied. + let limit = if recursive { 144 * MIB } else { 128 * MIB }; + let ctx = NapiCtx::builder().total_memory_bytes(limit).build(); + let budget = ctx.budget(); + let peak = Arc::new(AtomicU64::new(0)); + let mut store = Store::default(); + let module = Module::new(&store, wat::parse_str(wat).unwrap()).unwrap(); + let session = ctx.new_session(&module).unwrap(); + let mut imports = session.create_imports(&mut store.as_store_mut()).unwrap(); + let observed_peak = Arc::clone(&peak); + let observed_budget = Arc::clone(&budget); + imports.define( + "test", + "observe", + Function::new_typed(&mut store, move || { + observed_peak + .fetch_max(observed_budget.snapshot().host_transient, Ordering::Relaxed); + }), + ); + let instance = Instance::new(&mut store, &module, &imports).unwrap(); + session + .configure_instance(&mut store.as_store_mut(), &instance, None) + .unwrap(); + let run = instance + .exports + .get_typed_function::<(i32, i32), i32>(&store, "run") + .unwrap(); + assert_eq!( + run.call(&mut store, mode, i32::from(recursive)).unwrap(), + 0, + "mode {mode}, recursive {recursive}, budget {:?}", + budget.snapshot() + ); + let calls = instance + .exports + .get_typed_function::<(), i32>(&store, "calls") + .unwrap() + .call(&mut store) + .unwrap(); + let denials = instance + .exports + .get_typed_function::<(), i32>(&store, "denials") + .unwrap() + .call(&mut store) + .unwrap(); + if recursive { + assert_eq!(calls, 2, "mode {mode}"); + assert_eq!(denials, 1, "mode {mode}"); + assert_eq!(peak.load(Ordering::Relaxed), 32 * MIB); + } else { + assert_eq!(calls, 0, "mode {mode}"); + assert_eq!(denials, 0, "mode {mode}"); + } + assert_eq!(budget.snapshot().host_transient, 0); + } + } +} + +#[test] +fn large_sparse_message_retains_charge_through_deserialization() { + // The wire payload is small, but the decoded array's logical element + // range is over 16 MiB of pointer slots. Consumption must keep the + // serialized owner charged until native message_take destroys it. + let wat = r#"(module + (import "napi_extension_wasmer_v0" "unofficial_napi_create_env" (func $create (param i32 i32 i32 i32) (result i32))) + (import "napi" "napi_create_string_utf8" (func $string (param i32 i32 i32 i32) (result i32))) + (import "napi" "napi_run_script" (func $script (param i32 i32 i32) (result i32))) + (import "napi" "napi_get_array_length" (func $length (param i32 i32 i32) (result i32))) + (import "napi_extension_wasmer_v0" "unofficial_napi_message_create" (func $message_create + (param i32 i32 i32) (result i32))) + (import "napi_extension_wasmer_v0" "unofficial_napi_message_take" (func $message_take + (param i32 i32 i32) (result i32))) + (memory (export "memory") 1) + (data (i32.const 100) "new Array(5000000)\00") + (func (export "create") (result i32) + (local $env i32) + (if (call $create (i32.const 8) (i32.const 0) (i32.const 4) (i32.const 8)) + (then (return (i32.const 1)))) + (local.set $env (i32.load (i32.const 4))) + (if (call $string (local.get $env) (i32.const 100) (i32.const -1) (i32.const 12)) + (then (return (i32.const 2)))) + (if (call $script (local.get $env) (i32.load (i32.const 12)) (i32.const 16)) + (then (return (i32.const 3)))) + (if (call $message_create (local.get $env) (i32.load (i32.const 16)) + (i32.const 20)) + (then (return (i32.const 4)))) + (i32.load (i32.const 20))) + (func (export "consume") (result i32) + (local $env i32) + (local.set $env (i32.load (i32.const 4))) + (if (call $message_take (local.get $env) (i32.load (i32.const 20)) + (i32.const 24)) + (then (return (i32.const -1)))) + (if (call $length (local.get $env) (i32.load (i32.const 24)) (i32.const 28)) + (then (return (i32.const -2)))) + (i32.load (i32.const 28))))"#; + + let ctx = NapiCtx::builder().total_memory_bytes(192 * MIB).build(); + let budget = ctx.budget(); + let mut store = Store::default(); + let module = Module::new(&store, wat::parse_str(wat).unwrap()).unwrap(); + let session = ctx.new_session(&module).unwrap(); + let imports = session.create_imports(&mut store.as_store_mut()).unwrap(); + let instance = Instance::new(&mut store, &module, &imports).unwrap(); + session + .configure_instance(&mut store.as_store_mut(), &instance, None) + .unwrap(); + let create = instance + .exports + .get_typed_function::<(), i32>(&store, "create") + .unwrap(); + let consume = instance + .exports + .get_typed_function::<(), i32>(&store, "consume") + .unwrap(); + let message = create.call(&mut store).unwrap(); + assert!(message > 0, "message create returned status {message}"); + assert!( + budget.snapshot().serialized_message > 0, + "message create failed: {message}" + ); + assert_eq!(consume.call(&mut store).unwrap(), 5_000_000); + assert_eq!(budget.snapshot().serialized_message, 0); + assert_eq!(budget.snapshot().host_transient, 0); +} + +#[test] +fn promise_rejection_callback_exit_crosses_versioned_import_boundary() { + // Adapted from the fresh review's direct-WASM proof. Registering the + // callback after rejection makes mark_promise_as_handled invoke it. + let wat = r#"(module + (import "napi_extension_wasmer_v0" "unofficial_napi_create_env" (func $create (param i32 i32 i32 i32) (result i32))) + (import "napi" "napi_create_function" (func $function (param i32 i32 i32 i32 i32 i32) (result i32))) + (import "napi" "napi_get_global" (func $global (param i32 i32) (result i32))) + (import "napi" "napi_set_named_property" (func $set (param i32 i32 i32 i32) (result i32))) + (import "napi" "napi_create_string_utf8" (func $string (param i32 i32 i32 i32) (result i32))) + (import "napi" "napi_run_script" (func $script (param i32 i32 i32) (result i32))) + (import "napi_extension_wasmer_v0" "unofficial_napi_set_promise_reject_callback" (func $set_cb (param i32 i32) (result i32))) + + (import "napi_extension_wasmer_v0" "unofficial_napi_mark_promise_as_handled" (func $mark_current (param i32 i32) (result i32))) + (import "wasi_snapshot_preview1" "proc_exit" (func $exit (param i32))) + (memory (export "memory") 1) + (table (export "__indirect_function_table") 1 funcref) + (elem (i32.const 0) $callback) + (data (i32.const 100) "exitNow\00") + (data (i32.const 150) "Promise.reject(7)\00") + (func $callback (param i32 i32) (result i32) + (call $exit (i32.const 23)) + (i32.const 0)) + (func (export "run") (param $mode i32) (result i32) + (local $env i32) + (if (call $create (i32.const 8) (i32.const 0) (i32.const 4) (i32.const 8)) + (then (return (i32.const 1)))) + (local.set $env (i32.load (i32.const 4))) + (if (call $function (local.get $env) (i32.const 100) (i32.const -1) + (i32.const 0) (i32.const 0) (i32.const 12)) + (then (return (i32.const 2)))) + (if (call $global (local.get $env) (i32.const 16)) + (then (return (i32.const 3)))) + (if (call $set (local.get $env) (i32.load (i32.const 16)) + (i32.const 100) (i32.load (i32.const 12))) + (then (return (i32.const 4)))) + (if (call $string (local.get $env) (i32.const 150) (i32.const -1) (i32.const 20)) + (then (return (i32.const 5)))) + (if (call $script (local.get $env) (i32.load (i32.const 20)) (i32.const 24)) + (then (return (i32.const 6)))) + (if (call $set_cb (local.get $env) (i32.load (i32.const 12))) + (then (return (i32.const 7)))) + (call $mark_current (local.get $env) (i32.load (i32.const 24)))))"#; + + for mode in 0..1 { + let ctx = NapiCtx::default(); + let mut store = Store::default(); + let module = Module::new(&store, wat::parse_str(wat).unwrap()).unwrap(); + let session = ctx.new_session(&module).unwrap(); + let mut imports = session.create_imports(&mut store.as_store_mut()).unwrap(); + imports.define( + "wasi_snapshot_preview1", + "proc_exit", + Function::new_typed(&mut store, |code: i32| -> Result<(), RuntimeError> { + Err(RuntimeError::user(Box::new(WasiError::Exit( + ExitCode::from(code), + )))) + }), + ); + let instance = Instance::new(&mut store, &module, &imports).unwrap(); + session + .configure_instance(&mut store.as_store_mut(), &instance, None) + .unwrap(); + let run = instance + .exports + .get_typed_function::(&store, "run") + .unwrap(); + let error = run.call(&mut store, mode).unwrap_err(); + assert!( + matches!( + error.downcast_ref::(), + Some(WasiError::Exit(code)) if *code == ExitCode::from(23) + ), + "mode {mode}: {error}" + ); + assert_eq!(ctx.budget().snapshot().host_transient, 0); + } +} + +#[test] +fn module_code_cache_imports_fail_before_native_allocation() { + // Use a live source-text module: an enabled import would reach V8's + // CreateCodeCache path, rather than fail on an invalid module handle. + let wat = r#"(module + (import "napi_extension_wasmer_v0" "unofficial_napi_create_env" (func $create (param i32 i32 i32 i32) (result i32))) + (import "napi_extension_wasmer_v0" "unofficial_napi_release_env" (func $release (param i32 i32) (result i32))) + (import "napi" "napi_create_object" (func $object (param i32 i32) (result i32))) + (import "napi" "napi_create_string_utf8" (func $string (param i32 i32 i32 i32) (result i32))) + (import "napi_extension_wasmer_v0" "unofficial_napi_module_wrap_create" (func $module + (param i32 i32 i32) (result i32))) + (import "napi_extension_wasmer_v0" "unofficial_napi_module_wrap_destroy" (func $destroy (param i32 i32) (result i32))) + + (import "napi_extension_wasmer_v0" "unofficial_napi_module_wrap_create_cached_data" (func $current + (param i32 i32 i32) (result i32))) + (memory (export "memory") 1) + (data (i32.const 100) "test:cache-module") + (data (i32.const 140) "export const x = 1;") + (func (export "run") (param $mode i32) (result i32) + (local $env i32) (local $handle i32) (local $status i32) + (if (call $create (i32.const 8) (i32.const 0) (i32.const 4) (i32.const 8)) + (then (return (i32.const 2)))) + (local.set $env (i32.load (i32.const 4))) + (if (call $object (local.get $env) (i32.const 12)) + (then (return (i32.const 3)))) + (if (call $string (local.get $env) (i32.const 100) (i32.const 17) (i32.const 16)) + (then (return (i32.const 4)))) + (if (call $string (local.get $env) (i32.const 140) (i32.const 19) (i32.const 20)) + (then (return (i32.const 5)))) + (i32.store (i32.const 512) (i32.const 40)) + (i32.store (i32.const 516) (i32.const 1)) + (i32.store (i32.const 520) (i32.const 1)) + (i32.store (i32.const 524) (i32.load (i32.const 12))) + (i32.store (i32.const 528) (i32.load (i32.const 16))) + (i32.store (i32.const 532) (i32.const 0)) + (i32.store (i32.const 536) (i32.const 560)) + (i32.store (i32.const 540) (i32.const 0)) + (i32.store (i32.const 544) (i32.const 0)) + (i32.store (i32.const 548) (i32.const 0)) + (i32.store (i32.const 560) (i32.const 0)) + (i32.store (i32.const 564) (i32.load (i32.const 20))) + (i32.store (i32.const 568) (i32.const 0)) + (if (call $module (local.get $env) (i32.const 512) (i32.const 24)) + (then (return (i32.const 6)))) + (local.set $handle (i32.load (i32.const 24))) + (i32.store (i32.const 40) (i32.const 123456)) + (local.set $status (call $current (local.get $env) (local.get $handle) (i32.const 40))) + (if (call $destroy (local.get $env) (local.get $handle)) + (then (return (i32.const 7)))) + (if (call $release (i32.load (i32.const 8)) (i32.const 0)) + (then (return (i32.const 8)))) + (local.get $status)) + (func (export "output") (result i32) (i32.load (i32.const 40))))"#; + + for mode in 0..1 { + let ctx = NapiCtx::default(); + let budget = ctx.budget(); + let mut store = Store::default(); + let module = Module::new(&store, wat::parse_str(wat).unwrap()).unwrap(); + let session = ctx.new_session(&module).unwrap(); + let imports = session.create_imports(&mut store.as_store_mut()).unwrap(); + let instance = Instance::new(&mut store, &module, &imports).unwrap(); + session + .configure_instance(&mut store.as_store_mut(), &instance, None) + .unwrap(); + let run = instance + .exports + .get_typed_function::(&store, "run") + .unwrap(); + assert_eq!(run.call(&mut store, mode).unwrap(), 1, "mode {mode}"); + let output = instance + .exports + .get_typed_function::<(), i32>(&store, "output") + .unwrap(); + assert_eq!(output.call(&mut store).unwrap(), 123456); + assert_eq!(budget.snapshot().host_transient, 0); + assert_eq!(budget.snapshot().serialized_message, 0); + } +} + +#[test] +fn module_link_rejects_oversized_counts_before_guest_copy() { + let wat = r#"(module + (import "napi_extension_wasmer_v0" "unofficial_napi_module_wrap_link" (func $link + (param i32 i32 i32 i32) (result i32))) + (memory (export "memory") 1) + (func (export "run") (param $count i32) (result i32) + (call $link (i32.const 1) (i32.const 1) (local.get $count) (i32.const 2147483647))))"#; + + let ctx = NapiCtx::builder().total_memory_bytes(8 * MIB).build(); + let budget = ctx.budget(); + let mut store = Store::default(); + let module = Module::new(&store, wat::parse_str(wat).unwrap()).unwrap(); + let session = ctx.new_session(&module).unwrap(); + let imports = session.create_imports(&mut store.as_store_mut()).unwrap(); + let instance = Instance::new(&mut store, &module, &imports).unwrap(); + session + .configure_instance(&mut store.as_store_mut(), &instance, None) + .unwrap(); + let run = instance + .exports + .get_typed_function::(&store, "run") + .unwrap(); + for count in [-1, 4097, i32::MAX] { + assert_eq!(run.call(&mut store, count).unwrap(), 1); + assert_eq!(budget.snapshot().host_transient, 0); + } + // An in-range count reaches input validation, then releases its scratch + // reservation when the guest pointer is invalid. + assert_eq!(run.call(&mut store, 4096).unwrap(), 1); + assert_eq!(budget.snapshot().host_transient, 0); +} diff --git a/tests/guest_error_callbacks.rs b/tests/guest_error_callbacks.rs new file mode 100644 index 00000000..8a9dafa7 --- /dev/null +++ b/tests/guest_error_callbacks.rs @@ -0,0 +1,93 @@ +use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, +}; + +use wasmer::{AsStoreMut, Function, Instance, Module, RuntimeError, Store}; +use wasmer_napi::NapiCtx; +use wasmer_wasix::{WasiError, wasmer_wasix_types::wasi::ExitCode}; + +#[test] +fn source_map_callback_exit_crosses_error_metadata_imports() { + let wat = r#"(module + (import "napi_extension_wasmer_v0" "unofficial_napi_create_env" (func $create (param i32 i32 i32 i32) (result i32))) + (import "napi" "napi_create_function" (func $function (param i32 i32 i32 i32 i32 i32) (result i32))) + (import "napi" "napi_create_string_utf8" (func $string (param i32 i32 i32 i32) (result i32))) + (import "napi" "napi_run_script" (func $script (param i32 i32 i32) (result i32))) + (import "napi_extension_wasmer_v0" "unofficial_napi_configure_source_maps" (func $configure (param i32 i32 i32) (result i32))) + (import "napi_extension_wasmer_v0" "unofficial_napi_get_error_metadata" (func $metadata (param i32 i32 i32 i32) (result i32))) + (import "napi_extension_wasmer_v0" "unofficial_napi_preserve_error_source_message" (func $preserve_current (param i32 i32) (result i32))) + (import "wasi_snapshot_preview1" "proc_exit" (func $exit (param i32))) + (import "test" "observe" (func $observe)) + (memory (export "memory") 1) + (table (export "__indirect_function_table") 1 funcref) + (elem (i32.const 0) $callback) + (data (i32.const 100) "sourceMap\00") + (data (i32.const 160) "(() => { try { throw new Error('boom'); } catch (e) { return e; } })()\0a//# sourceURL=original.js\00") + (func $callback (param i32 i32) (result i32) + (call $observe) + (call $exit (i32.const 41)) + (i32.const 0)) + (func (export "run") (param $mode i32) (result i32) + (local $env i32) + (if (call $create (i32.const 8) (i32.const 0) (i32.const 4) (i32.const 8)) + (then (return (i32.const 1)))) + (local.set $env (i32.load (i32.const 4))) + (if (call $function (local.get $env) (i32.const 100) (i32.const -1) + (i32.const 0) (i32.const 0) (i32.const 12)) + (then (return (i32.const 2)))) + (if (call $configure (local.get $env) (i32.const 1) (i32.load (i32.const 12))) + (then (return (i32.const 3)))) + (if (call $string (local.get $env) (i32.const 160) (i32.const -1) (i32.const 16)) + (then (return (i32.const 4)))) + (if (call $script (local.get $env) (i32.load (i32.const 16)) (i32.const 20)) + (then (return (i32.const 5)))) + (if (result i32) (i32.eqz (local.get $mode)) + (then (call $metadata (local.get $env) (i32.load (i32.const 20)) + (i32.const 0) (i32.const 32))) + (else (call $preserve_current (local.get $env) (i32.load (i32.const 20)))))))"#; + + for mode in 0..2 { + let ctx = NapiCtx::default(); + let callback_count = Arc::new(AtomicUsize::new(0)); + let mut store = Store::default(); + let module = Module::new(&store, wat::parse_str(wat).unwrap()).unwrap(); + let session = ctx.new_session(&module).unwrap(); + let mut imports = session.create_imports(&mut store.as_store_mut()).unwrap(); + imports.define( + "wasi_snapshot_preview1", + "proc_exit", + Function::new_typed(&mut store, |code: i32| -> Result<(), RuntimeError> { + Err(RuntimeError::user(Box::new(WasiError::Exit( + ExitCode::from(code), + )))) + }), + ); + let observed = Arc::clone(&callback_count); + imports.define( + "test", + "observe", + Function::new_typed(&mut store, move || { + observed.fetch_add(1, Ordering::Relaxed); + }), + ); + let instance = Instance::new(&mut store, &module, &imports).unwrap(); + session + .configure_instance(&mut store.as_store_mut(), &instance, None) + .unwrap(); + let run = instance + .exports + .get_typed_function::(&store, "run") + .unwrap(); + let error = run.call(&mut store, mode).unwrap_err(); + assert!( + matches!( + error.downcast_ref::(), + Some(WasiError::Exit(code)) if *code == ExitCode::from(41) + ), + "mode {mode}: {error}" + ); + assert_eq!(callback_count.load(Ordering::Relaxed), 1, "mode {mode}"); + assert_eq!(ctx.budget().snapshot().host_transient, 0); + } +} diff --git a/tests/manifest_tests.rs b/tests/manifest_tests.rs index a018786d..ff62ffa7 100644 --- a/tests/manifest_tests.rs +++ b/tests/manifest_tests.rs @@ -6,7 +6,11 @@ use std::sync::OnceLock; use serde::Deserialize; fn crate_root() -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")) + // The standalone wrapper builds through a temporary manifest directory. + // Fixture scripts and nested CLI builds must use the actual source tree. + option_env!("NAPI_SOURCE_ROOT") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(env!("CARGO_MANIFEST_DIR"))) } fn path_from_env_or(name: &str, default: PathBuf) -> PathBuf { diff --git a/tests/message_memory.rs b/tests/message_memory.rs new file mode 100644 index 00000000..c9beecba --- /dev/null +++ b/tests/message_memory.rs @@ -0,0 +1,210 @@ +use wasmer::{AsStoreMut, Imports, Instance, Memory, MemoryType, Module, SharedMemory, Store}; +use wasmer_napi::NapiCtx; + +// The cross-memory case is based on the ship-review reproducer: a serialized +// SharedArrayBuffer retains a V8 backing-store pointer into the producer's +// guest memory after its Store has gone away. +const WAT: &str = r#"(module + (import "napi_extension_wasmer_v0" "unofficial_napi_create_env" (func $create (param i32 i32 i32 i32) (result i32))) + (import "napi" "node_api_create_sharedarraybuffer" (func $sab (param i32 i32 i32 i32) (result i32))) + + + + (import "napi_extension_wasmer_v0" "unofficial_napi_message_create" (func $message_create (param i32 i32 i32) (result i32))) + (import "napi_extension_wasmer_v0" "unofficial_napi_message_take" (func $message_take (param i32 i32 i32) (result i32))) + (import "napi_extension_wasmer_v0" "unofficial_napi_message_drop" (func $message_drop (param i32))) + (import "napi" "napi_get_global" (func $global (param i32 i32) (result i32))) + (import "napi" "napi_set_named_property" (func $set (param i32 i32 i32 i32) (result i32))) + (import "napi" "napi_create_string_utf8" (func $string (param i32 i32 i32 i32) (result i32))) + (import "napi" "napi_run_script" (func $run (param i32 i32 i32) (result i32))) + (import "napi" "napi_get_value_int32" (func $int (param i32 i32 i32) (result i32))) + (memory (export "memory") 1) + (global $env (mut i32) (i32.const 0)) + (data (i32.const 100) "sab\00") + (data (i32.const 110) "new Uint8Array(sab)[0]\00") + (func (export "init") (result i32) (local $status i32) + (local.set $status (call $create (i32.const 8) (i32.const 0) (i32.const 4) (i32.const 8))) + (global.set $env (i32.load (i32.const 4))) + (local.get $status)) + + (func (export "produce_current") (result i32) (local $env i32) + (local.set $env (global.get $env)) + (if (call $sab (local.get $env) (i32.const 8) (i32.const 16) (i32.const 20)) (then unreachable)) + (i32.store8 (i32.load (i32.const 16)) (i32.const 42)) + (if (call $message_create (local.get $env) (i32.load (i32.const 20)) (i32.const 24)) (then unreachable)) + (i32.load (i32.const 24))) + + (func (export "read_current") (param $id i32) (result i32) + (call $message_take (global.get $env) (local.get $id) (i32.const 20))) + + (func (export "release_current") (param $id i32) (call $message_drop (local.get $id))) + (func (export "use_sab") (result i32) (local $env i32) + (local.set $env (global.get $env)) + (if (call $global (local.get $env) (i32.const 24)) (then unreachable)) + (if (call $set (local.get $env) (i32.load (i32.const 24)) (i32.const 100) (i32.load (i32.const 20))) (then unreachable)) + (if (call $string (local.get $env) (i32.const 110) (i32.const -1) (i32.const 28)) (then unreachable)) + (if (call $run (local.get $env) (i32.load (i32.const 28)) (i32.const 32)) (then unreachable)) + (if (call $int (local.get $env) (i32.load (i32.const 32)) (i32.const 36)) (then unreachable)) + (i32.load (i32.const 36))))"#; + +fn setup(ctx: &NapiCtx, shared: Option) -> (Store, Instance) { + let mut store = Store::default(); + let source = if shared.is_some() { + WAT.replace( + "(memory (export \"memory\") 1)", + "(import \"env\" \"memory\" (memory 1 1024 shared)) (export \"memory\" (memory 0))", + ) + } else { + WAT.to_string() + }; + let module = Module::new(&store, wat::parse_str(source).unwrap()).unwrap(); + let mut imports = Imports::new(); + if let Some(shared) = shared { + imports.define("env", "memory", shared.attach(&mut store)); + } + let hooks = ctx.runtime_hooks(); + let state = hooks + .add_imports(&module, &mut store.as_store_mut(), &mut imports) + .unwrap(); + let instance = Instance::new(&mut store, &module, &imports).unwrap(); + hooks + .configure_instance(&module, &mut store.as_store_mut(), &instance, None, state) + .unwrap(); + assert_eq!(call0(&mut store, &instance, "init"), 0); + (store, instance) +} + +fn call0(store: &mut Store, instance: &Instance, name: &str) -> i32 { + instance + .exports + .get_typed_function::<(), i32>(&*store, name) + .unwrap() + .call(store) + .unwrap() +} + +fn call1(store: &mut Store, instance: &Instance, name: &str, id: i32) -> i32 { + instance + .exports + .get_typed_function::(&*store, name) + .unwrap() + .call(store, id) + .unwrap() +} + +fn release(store: &mut Store, instance: &Instance, name: &str, id: i32) { + instance + .exports + .get_typed_function::(&*store, name) + .unwrap() + .call(store, id) + .unwrap(); +} + +#[test] +fn serialized_sab_rejects_a_different_memory_after_producer_teardown() { + for (produce, read, release_name) in [("produce_current", "read_current", "release_current")] { + let ctx = NapiCtx::default(); + let (mut consumer, consumer_instance) = setup(&ctx, None); + let consumer_bytes = ctx.budget().snapshot().wasm_linear; + let id = { + let (mut producer, producer_instance) = setup(&ctx, None); + call0(&mut producer, &producer_instance, produce) + }; + let after_teardown = ctx.budget().snapshot(); + assert!(after_teardown.wasm_linear > consumer_bytes); + assert!(after_teardown.serialized_message > 0); + assert_ne!(call1(&mut consumer, &consumer_instance, read, id), 0); + release(&mut consumer, &consumer_instance, release_name, id); + let after_release = ctx.budget().snapshot(); + assert_eq!(after_release.serialized_message, 0); + assert_eq!(after_release.wasm_linear, consumer_bytes); + } +} + +#[test] +fn serialized_sab_survives_producer_teardown_and_message_release_in_same_memory() { + for (produce, read, release_name) in [("produce_current", "read_current", "release_current")] { + let ctx = NapiCtx::default(); + let mut origin_store = Store::default(); + let memory = Memory::new(&mut origin_store, MemoryType::new(1, Some(1024), true)).unwrap(); + let shared = memory.as_shared(&origin_store).unwrap(); + drop(origin_store); + let (mut consumer, consumer_instance) = setup(&ctx, Some(shared.clone())); + let id = { + let (mut producer, producer_instance) = setup(&ctx, Some(shared.clone())); + call0(&mut producer, &producer_instance, produce) + }; + drop(shared); + let queued = ctx.budget().snapshot(); + assert!(queued.wasm_linear > 0); + assert!(queued.serialized_message > 0); + assert_eq!(call1(&mut consumer, &consumer_instance, read, id), 0); + assert_eq!(call0(&mut consumer, &consumer_instance, "use_sab"), 42); + assert_ne!(call1(&mut consumer, &consumer_instance, read, id), 0); + release(&mut consumer, &consumer_instance, release_name, id); + assert_eq!(call0(&mut consumer, &consumer_instance, "use_sab"), 42); + assert_eq!(ctx.budget().snapshot().serialized_message, 0); + assert!(ctx.budget().snapshot().wasm_linear > 0); + drop(consumer); + assert_eq!(ctx.budget().snapshot().wasm_linear, 0); + } +} + +#[cfg(target_os = "linux")] +fn has_mapping_at(address: usize) -> bool { + // Query the kernel's mapping table. After every Store has dropped, this + // test never dereferences the guest address just to check its lifetime. + std::fs::read_to_string("/proc/self/maps") + .unwrap() + .lines() + .filter_map(|line| line.split_whitespace().next()) + .filter_map(|range| range.split_once('-')) + .filter_map(|(start, end)| { + Some(( + usize::from_str_radix(start, 16).ok()?, + usize::from_str_radix(end, 16).ok()?, + )) + }) + .any(|(start, end)| start <= address && address < end) +} + +#[cfg(target_os = "linux")] +#[test] +fn queued_sab_alone_pins_mapping_until_release_or_host_stop() { + for (produce, stop_host) in [("produce_current", false), ("produce_current", true)] { + let ctx = NapiCtx::default(); + let budget = ctx.budget(); + let (base, shared) = { + let mut origin_store = Store::default(); + let memory = + Memory::new(&mut origin_store, MemoryType::new(1, Some(1024), true)).unwrap(); + let base = memory.view(&origin_store).data_ptr() as usize; + let shared = memory.as_shared(&origin_store).unwrap(); + (base, shared) + }; + let message_id = { + let (mut producer, instance) = setup(&ctx, Some(shared.clone())); + call0(&mut producer, &instance, produce) + }; + assert!(message_id > 0); + drop(shared); + + // The queued payload is now the sole owner of its detached mapping + // and GuestHeap charge. No Store or external SharedMemory remains. + assert!(has_mapping_at(base)); + let queued = budget.snapshot(); + assert!(queued.wasm_linear > 0); + assert!(queued.serialized_message > 0); + + if stop_host { + ctx.runtime_control().terminate_all(); + } else { + drop(ctx); + } + assert!(!has_mapping_at(base)); + let released = budget.snapshot(); + assert_eq!(released.wasm_linear, 0); + assert_eq!(released.serialized_message, 0); + } +} diff --git a/tests/programs/run_script_test.c b/tests/programs/run_script_test.c index db0d3c50..5711e2eb 100644 --- a/tests/programs/run_script_test.c +++ b/tests/programs/run_script_test.c @@ -805,8 +805,17 @@ int main(void) { (void*)(uintptr_t)0x22, NULL)); NAPI_CALL(env, unofficial_napi_release_env(finalizer_env_scope, NULL)); +#if defined(__wasm__) + // The WASIX bridge clears its guest callback context before releasing the + // provider env. Guest finalizers cannot run after the host kill registry + // stops tracking that env. + CHECK_OR_FAIL(wrap_finalizer_count == 0 && add_finalizer_count == 0, + "guest finalizer ran during uninterruptible env teardown"); +#else + // Direct native users still receive their normal N-API finalizers. CHECK_OR_FAIL(wrap_finalizer_count == 1 && add_finalizer_count == 1, - "guest finalizers were not dispatched exactly once"); + "native finalizers were not dispatched exactly once"); +#endif // Environment teardown owns the failure/cancellation path for outstanding // leases. It must discard the snapshot and host reference without requiring diff --git a/tests/programs/test_charged_cstring_bridge.c b/tests/programs/test_charged_cstring_bridge.c new file mode 100644 index 00000000..b3a63eea --- /dev/null +++ b/tests/programs/test_charged_cstring_bridge.c @@ -0,0 +1,47 @@ +#include + +#include "napi_test_helpers.h" +#include "unofficial_napi.h" + +int main(void) { + napi_env env = napi_wasm_init_env(); + CHECK_OR_FAIL(env != NULL, "napi_wasm_init_env returned NULL"); + + napi_value object; + napi_value value; + napi_value got; + bool has = false; + int32_t number = 0; + NAPI_CALL(env, napi_create_object(env, &object)); + NAPI_CALL(env, napi_create_int32(env, 42, &value)); + NAPI_CALL(env, napi_set_named_property(env, object, "charged", value)); + NAPI_CALL(env, napi_get_named_property(env, object, "charged", &got)); + NAPI_CALL(env, napi_get_value_int32(env, got, &number)); + CHECK_OR_FAIL(number == 42, "named property value changed"); + NAPI_CALL(env, napi_has_named_property(env, object, "charged", &has)); + CHECK_OR_FAIL(has, "named property was lost"); + + // Outside a JavaScript callback, the bridge may report + // napi_pending_exception even when the arguments reached V8. Match the + // existing throw test's convention, clearing only after napi_ok. + napi_status status = napi_throw_error(env, "E_GUEST", "guest error"); + CHECK_OR_FAIL(status == napi_ok || status == napi_pending_exception, + "napi_throw_error returned an unexpected status"); + if (status == napi_ok) { + NAPI_CALL(env, napi_get_and_clear_last_exception(env, &got)); + } + status = napi_throw_type_error(env, "E_TYPE", "guest type error"); + CHECK_OR_FAIL(status == napi_ok || status == napi_pending_exception, + "napi_throw_type_error returned an unexpected status"); + if (status == napi_ok) { + NAPI_CALL(env, napi_get_and_clear_last_exception(env, &got)); + } + status = napi_throw_range_error(env, "E_RANGE", "guest range error"); + CHECK_OR_FAIL(status == napi_ok || status == napi_pending_exception, + "napi_throw_range_error returned an unexpected status"); + if (status == napi_ok) { + NAPI_CALL(env, napi_get_and_clear_last_exception(env, &got)); + } + puts("CHARGED_CSTRING_BRIDGE_OK"); + return 0; +} diff --git a/tests/programs/test_configure_without_env.c b/tests/programs/test_configure_without_env.c new file mode 100644 index 00000000..1cadb70c --- /dev/null +++ b/tests/programs/test_configure_without_env.c @@ -0,0 +1,9 @@ +#include + +#include "unofficial_napi.h" + +int main(void) { + if (unofficial_napi_configure_runtime(NULL) != napi_ok) return 1; + puts("CONFIGURED_WITHOUT_ENV"); + return 0; +} diff --git a/tests/programs/test_embedded_nul_names.c b/tests/programs/test_embedded_nul_names.c new file mode 100644 index 00000000..bf9558a1 --- /dev/null +++ b/tests/programs/test_embedded_nul_names.c @@ -0,0 +1,51 @@ +#include +#include + +#include "napi_test_helpers.h" +#include "unofficial_napi.h" + +static napi_value no_op(napi_env env, napi_callback_info info) { + (void)info; + napi_value result = NULL; + napi_get_undefined(env, &result); + return result; +} + +int main(void) { + napi_env env = napi_wasm_init_env(); + CHECK_OR_FAIL(env != NULL, "napi_wasm_init_env returned NULL"); + static const char name[] = {'a', '\0', 'b'}; + + napi_value function; + NAPI_CALL(env, napi_create_function(env, name, sizeof(name), no_op, NULL, + &function)); + napi_value function_name; + NAPI_CALL(env, napi_get_named_property(env, function, "name", &function_name)); + char value[sizeof(name) + 1] = {0}; + size_t length = 0; + NAPI_CALL(env, napi_get_value_string_utf8(env, function_name, value, + sizeof(value), &length)); + CHECK_OR_FAIL(length == sizeof(name), "explicit function name was truncated"); + CHECK_OR_FAIL(memcmp(value, name, sizeof(name)) == 0, + "explicit function name bytes changed"); + + napi_value klass; + NAPI_CALL(env, napi_define_class(env, name, sizeof(name), no_op, NULL, + 0, NULL, &klass)); + napi_value private_symbol; + NAPI_CALL(env, unofficial_napi_create_private_symbol(env, name, + sizeof(name), + &private_symbol)); + + CHECK_OR_FAIL(napi_create_function(env, NULL, sizeof(name), no_op, NULL, + &function) == napi_invalid_arg, + "missing explicit function name was accepted"); + CHECK_OR_FAIL(napi_define_class(env, NULL, sizeof(name), no_op, NULL, + 0, NULL, &klass) == napi_invalid_arg, + "missing explicit class name was accepted"); + CHECK_OR_FAIL(unofficial_napi_create_private_symbol(env, NULL, sizeof(name), + &private_symbol) == napi_invalid_arg, + "missing explicit private description was accepted"); + puts("EMBEDDED_NUL_NAMES_OK"); + return 0; +} diff --git a/tests/programs/test_guest_message_budget.c b/tests/programs/test_guest_message_budget.c new file mode 100644 index 00000000..1372cada --- /dev/null +++ b/tests/programs/test_guest_message_budget.c @@ -0,0 +1,34 @@ +#include + +#include "napi_test_helpers.h" +#include "unofficial_napi.h" + +int main(void) { + napi_env env = napi_wasm_init_env(); + CHECK_OR_FAIL(env != NULL, "napi_wasm_init_env returned NULL"); + + napi_value source; + napi_value expected; + NAPI_CALL(env, napi_create_object(env, &source)); + NAPI_CALL(env, napi_create_int32(env, 42, &expected)); + NAPI_CALL(env, napi_set_named_property(env, source, "answer", expected)); + + unofficial_napi_message message = NULL; + NAPI_CALL(env, unofficial_napi_message_create(env, source, &message)); + CHECK_OR_FAIL(message != NULL, "message creation returned NULL"); + napi_value received = NULL; + NAPI_CALL(env, unofficial_napi_message_take(env, message, &received)); + napi_value answer; + int32_t actual = 0; + NAPI_CALL(env, napi_get_named_property(env, received, "answer", &answer)); + NAPI_CALL(env, napi_get_value_int32(env, answer, &actual)); + CHECK_OR_FAIL(actual == 42, "received message changed across the bridge"); + + // Deliberately leave one payload pending: the owning NapiCtx must release + // its bytes and native handle when that workload is destroyed. + unofficial_napi_message pending = NULL; + NAPI_CALL(env, unofficial_napi_message_create(env, source, &pending)); + CHECK_OR_FAIL(pending != NULL, "pending message creation returned NULL"); + puts("MESSAGE_BUDGET_OK"); + return 0; +} diff --git a/tests/programs/test_guest_runtime_flags.c b/tests/programs/test_guest_runtime_flags.c new file mode 100644 index 00000000..6b8230a2 --- /dev/null +++ b/tests/programs/test_guest_runtime_flags.c @@ -0,0 +1,28 @@ +#include +#include + +#include "unofficial_napi.h" + +int main(void) { + // The first byte is NUL, so an accidental strlen() would see no flags and + // allow the guest to configure process-wide V8 state for other workloads. + static const char flags[] = {'\0', '-', '-', 'n', 'o', '-', 'j', 'i', 't'}; + unofficial_napi_runtime_options options = { + .size = sizeof(options), + .version = UNOFFICIAL_NAPI_RUNTIME_OPTIONS_VERSION, + .engine_flags = flags, + .engine_flags_length = sizeof(flags), + }; + if (unofficial_napi_configure_runtime(&options) != napi_invalid_arg) { + return 1; + } + // No host copy should be attempted for a guest-controlled length. The + // request is forbidden before its pointer or payload need to be examined. + options.engine_flags = (const char*)1; + options.engine_flags_length = UINT32_MAX; + if (unofficial_napi_configure_runtime(&options) != napi_invalid_arg) { + return 2; + } + puts("FLAGS_REJECTED"); + return 0; +} diff --git a/tests/programs/test_guest_unmanaged_controls.c b/tests/programs/test_guest_unmanaged_controls.c new file mode 100644 index 00000000..7bfaa353 --- /dev/null +++ b/tests/programs/test_guest_unmanaged_controls.c @@ -0,0 +1,146 @@ +#include + +#include "napi_test_helpers.h" +#include "unofficial_napi.h" + +static void NAPI_CDECL UnmanagedInterrupt(napi_env env, void* data) { + (void)env; + (void)data; +} + +static void NAPI_CDECL WrapFinalizer(napi_env env, void* data, void* hint) { + (void)env; + (void)data; + (void)hint; +} + +static unofficial_napi_env_owner callback_owner = NULL; +static napi_status callback_release_status = napi_ok; + +static napi_value NAPI_CDECL ReleaseEnteredEnv(napi_env env, + napi_callback_info info) { + (void)info; + callback_release_status = unofficial_napi_release_env(callback_owner, NULL); + napi_value undefined_value = NULL; + if (napi_get_undefined(env, &undefined_value) != napi_ok) return NULL; + return undefined_value; +} + +int main(void) { + napi_env env = napi_wasm_init_env(); + CHECK_OR_FAIL(env != NULL, "napi_wasm_init_env returned NULL"); + + unofficial_napi_profile_start_result profile_result = + unofficial_napi_profile_start_ok; + unofficial_napi_profile profile = NULL; + CHECK_OR_FAIL(unofficial_napi_profile_start(env, unofficial_napi_profile_cpu, + &profile_result, &profile) != napi_ok, + "guest started an unbounded native CPU profiler"); + CHECK_OR_FAIL(profile == NULL, "rejected profiler returned a handle"); + CHECK_OR_FAIL(unofficial_napi_request_interrupt(env, UnmanagedInterrupt, + NULL) != napi_ok, + "guest queued an unbounded native interrupt"); + + napi_value unsupported = NULL; + CHECK_OR_FAIL(unofficial_napi_take_heap_snapshot(env, NULL, &unsupported) != + napi_ok, + "guest created an uncharged native heap snapshot"); + CHECK_OR_FAIL(unsupported == NULL, "rejected snapshot returned a value"); + CHECK_OR_FAIL(unofficial_napi_create_serdes_binding(env, &unsupported) != + napi_ok, + "guest created an uncharged native serializer"); + CHECK_OR_FAIL(unsupported == NULL, "rejected serializer returned a value"); + + unofficial_napi_bytecode_open_options bytecode_options = {0}; + bytecode_options.size = sizeof(bytecode_options); + bytecode_options.version = UNOFFICIAL_NAPI_BYTECODE_OPEN_OPTIONS_VERSION; + NAPI_CALL(env, napi_create_string_utf8(env, "1 + 1", NAPI_AUTO_LENGTH, + &bytecode_options.source_text)); + NAPI_CALL(env, napi_create_string_utf8(env, "script.js", NAPI_AUTO_LENGTH, + &bytecode_options.filename)); + bytecode_options.shape = unofficial_napi_bytecode_shape_script; + NAPI_CALL(env, napi_get_undefined(env, + &bytecode_options.params_or_undefined)); + bytecode_options.host_defined_option_id = + bytecode_options.params_or_undefined; + unofficial_napi_bytecode_open_result bytecode_result = {0}; + CHECK_OR_FAIL(unofficial_napi_bytecode_open(env, &bytecode_options, + &bytecode_result) != napi_ok, + "guest created an uncharged native bytecode handle"); + CHECK_OR_FAIL(bytecode_result.bytecode == NULL, + "rejected bytecode open returned a handle"); + + napi_value wrapped; + napi_ref user_ref = NULL; + void* unwrapped = NULL; + NAPI_CALL(env, napi_create_object(env, &wrapped)); + NAPI_CALL(env, napi_wrap(env, wrapped, (void*)0x1234, WrapFinalizer, + NULL, &user_ref)); + NAPI_CALL(env, napi_delete_reference(env, user_ref)); + NAPI_CALL(env, napi_unwrap(env, wrapped, &unwrapped)); + CHECK_OR_FAIL(unwrapped == (void*)0x1234, + "deleted user ref invalidated wrapped object"); + NAPI_CALL(env, napi_remove_wrap(env, wrapped, &unwrapped)); + CHECK_OR_FAIL(unwrapped == (void*)0x1234, + "remove_wrap read a deleted user ref"); + + napi_env callback_env = NULL; + NAPI_CALL(env, unofficial_napi_create_env(8, NULL, &callback_env, + &callback_owner)); + napi_value callback = NULL; + napi_value callback_global = NULL; + NAPI_CALL(callback_env, napi_create_function(callback_env, "release", + NAPI_AUTO_LENGTH, + ReleaseEnteredEnv, NULL, + &callback)); + NAPI_CALL(callback_env, napi_get_global(callback_env, &callback_global)); + napi_value callback_result = NULL; + NAPI_CALL(callback_env, napi_call_function(callback_env, callback_global, + callback, 0, NULL, + &callback_result)); + CHECK_OR_FAIL(callback_release_status != napi_ok, + "guest disposed an entered V8 isolate"); + NAPI_CALL(callback_env, unofficial_napi_release_env(callback_owner, NULL)); + + napi_value source; + napi_value undefined_value; + napi_value result = NULL; + NAPI_CALL(env, napi_create_string_utf8(env, "1 + 1", NAPI_AUTO_LENGTH, + &source)); + NAPI_CALL(env, napi_get_undefined(env, &undefined_value)); + const unofficial_napi_js_source script = + unofficial_napi_js_source_from_text(source); + + CHECK_OR_FAIL(unofficial_napi_contextify_run_script( + env, undefined_value, &script, undefined_value, 0, 0, + 0, true, false, false, undefined_value, &result) != napi_ok, + "guest started an unmanaged timeout watchdog"); + CHECK_OR_FAIL(unofficial_napi_contextify_run_script( + env, undefined_value, &script, undefined_value, 0, 0, + -1, true, true, false, undefined_value, &result) != napi_ok, + "guest installed a process-wide SIGINT watchdog"); + NAPI_CALL(env, unofficial_napi_contextify_run_script( + env, undefined_value, &script, undefined_value, 0, 0, + -1, true, false, false, undefined_value, &result)); + + napi_value sparse_source; + napi_value sparse_array; + NAPI_CALL(env, napi_create_string_utf8(env, "new Array(4294967295)", + NAPI_AUTO_LENGTH, &sparse_source)); + NAPI_CALL(env, napi_run_script(env, sparse_source, &sparse_array)); + const unofficial_napi_js_source function_source = + unofficial_napi_js_source_from_text(source); + CHECK_OR_FAIL(unofficial_napi_contextify_compile_function( + env, &function_source, source, 0, 0, undefined_value, + undefined_value, sparse_array, undefined_value, + &result) != napi_ok, + "sparse parameter array caused a native reserve"); + CHECK_OR_FAIL(unofficial_napi_contextify_compile_function( + env, &function_source, source, 0, 0, undefined_value, + sparse_array, undefined_value, undefined_value, + &result) != napi_ok, + "sparse context extension array caused a native reserve"); + + puts("UNMANAGED_CONTROLS_REJECTED"); + return 0; +} diff --git a/tests/programs/test_js_wasm_disabled.c b/tests/programs/test_js_wasm_disabled.c new file mode 100644 index 00000000..7504b22c --- /dev/null +++ b/tests/programs/test_js_wasm_disabled.c @@ -0,0 +1,57 @@ +#include +#include + +#include "napi_test_helpers.h" +#include "unofficial_napi.h" + +static int check_undefined(napi_env env, napi_value result) { + char text[16] = {0}; + size_t length = 0; + if (napi_get_value_string_utf8(env, result, text, sizeof(text), &length) != + napi_ok) { + return 0; + } + return length == strlen("undefined") && strcmp(text, "undefined") == 0; +} + +int main(void) { + napi_env env = napi_wasm_init_env(); + CHECK_OR_FAIL(env != NULL, "napi_wasm_init_env returned NULL"); + napi_value source; + NAPI_CALL(env, napi_create_string_utf8(env, "typeof WebAssembly", + NAPI_AUTO_LENGTH, &source)); + napi_value result; + NAPI_CALL(env, napi_run_script(env, source, &result)); + CHECK_OR_FAIL(check_undefined(env, result), + "V8 WebAssembly is available in the root context"); + + // A fresh realm would reinstate the constructor if V8 ever enables + // ShadowRealm by default. Keep that escape path in this regression. + napi_value realm_source; + NAPI_CALL(env, napi_create_string_utf8( + env, + "typeof ShadowRealm === 'undefined' ? 'undefined' : " + "new ShadowRealm().evaluate('typeof WebAssembly')", + NAPI_AUTO_LENGTH, &realm_source)); + NAPI_CALL(env, napi_run_script(env, realm_source, &result)); + CHECK_OR_FAIL(check_undefined(env, result), + "V8 WebAssembly is available through a fresh realm"); + + napi_value undefined_value; + napi_value sandbox; + napi_value context; + NAPI_CALL(env, napi_get_undefined(env, &undefined_value)); + NAPI_CALL(env, napi_create_object(env, &sandbox)); + NAPI_CALL(env, unofficial_napi_contextify_make_context( + env, sandbox, undefined_value, undefined_value, true, + true, true, undefined_value, &context)); + const unofficial_napi_js_source context_source = + unofficial_napi_js_source_from_text(source); + NAPI_CALL(env, unofficial_napi_contextify_run_script( + env, context, &context_source, undefined_value, 0, 0, + -1, true, false, false, undefined_value, &result)); + CHECK_OR_FAIL(check_undefined(env, result), + "V8 WebAssembly is available in a vm context"); + puts("JS_WASM_DISABLED_OK"); + return 0; +} diff --git a/tests/programs/test_property_descriptor_cap.c b/tests/programs/test_property_descriptor_cap.c new file mode 100644 index 00000000..2866d264 --- /dev/null +++ b/tests/programs/test_property_descriptor_cap.c @@ -0,0 +1,31 @@ +#include + +#include "napi_test_helpers.h" + +static napi_value constructor(napi_env env, napi_callback_info info) { + (void)info; + napi_value value = NULL; + napi_get_undefined(env, &value); + return value; +} + +int main(void) { + napi_env env = napi_wasm_init_env(); + CHECK_OR_FAIL(env != NULL, "napi_wasm_init_env returned NULL"); + napi_value object; + NAPI_CALL(env, napi_create_object(env, &object)); + + // The bridge must reject the count before reading descriptors or allocating + // host arrays: only one descriptor is actually present at this pointer. + napi_property_descriptor one = {0}; + if (napi_define_properties(env, object, 4097, &one) != napi_invalid_arg) { + return 1; + } + napi_value klass; + if (napi_define_class(env, "Cap", NAPI_AUTO_LENGTH, constructor, NULL, + 4097, &one, &klass) != napi_invalid_arg) { + return 2; + } + puts("PROPERTY_CAP_REJECTED"); + return 0; +} diff --git a/tests/programs/test_ref_churn.c b/tests/programs/test_ref_churn.c new file mode 100644 index 00000000..90863468 --- /dev/null +++ b/tests/programs/test_ref_churn.c @@ -0,0 +1,52 @@ +#include +#include + +#include "napi_test_helpers.h" +#include "unofficial_napi.h" + +// One host handle per short-lived JS object, the way a workload keeps a +// native handle for every hash or stream it creates. Nothing retains the +// objects, but they are too small for the JS heap to ever collect them on +// its own: only the provider's bookkeeping charge asks V8 for the collection +// that lets the finalizers release the refs. Well past the fixed cap a +// finite budget used to impose. +#define ITERATIONS 16384u + +static unsigned finalized = 0; + +static void ReleaseRef(napi_env env, void* data, void* hint) { + (void)hint; + if (napi_delete_reference(env, (napi_ref)data) == napi_ok) finalized++; +} + +// Guest finalizers only run at a provider checkpoint. +static int Checkpoint(napi_env env) { + uint32_t state = 0; + NAPI_CALL(env, unofficial_napi_event_loop_checkpoint( + env, unofficial_napi_event_loop_checkpoint_microtasks, + false, &state)); + return 0; +} + +int main(void) { + napi_env env = napi_wasm_init_env(); + CHECK_OR_FAIL(env != NULL, "napi_wasm_init_env returned NULL"); + + for (unsigned i = 0; i < ITERATIONS; ++i) { + napi_handle_scope scope; + NAPI_CALL(env, napi_open_handle_scope(env, &scope)); + napi_value obj; + NAPI_CALL(env, napi_create_object(env, &obj)); + napi_ref ref; + NAPI_CALL(env, napi_create_reference(env, obj, 0, &ref)); + NAPI_CALL(env, napi_add_finalizer(env, obj, ref, ReleaseRef, NULL, NULL)); + NAPI_CALL(env, napi_close_handle_scope(env, scope)); + if (i % 512 == 0 && Checkpoint(env) != 0) return 1; + } + if (Checkpoint(env) != 0) return 1; + + CHECK_OR_FAIL(finalized > 0, + "no reference was released: V8 was never asked to collect"); + printf("REF_CHURN_OK finalized=%u\n", finalized); + return 0; +} diff --git a/tests/programs/test_ref_hold.c b/tests/programs/test_ref_hold.c new file mode 100644 index 00000000..7fb31081 --- /dev/null +++ b/tests/programs/test_ref_hold.c @@ -0,0 +1,51 @@ +#include +#include + +#include "napi_test_helpers.h" + +// Retains a strong reference per object until the embedder's budget refuses +// the bookkeeping. The refusal is an out-of-memory condition for the whole +// env, so JS must not run afterwards. +#define MAX_REFS (4u * 1024u * 1024u) + +int main(void) { + napi_env env = napi_wasm_init_env(); + CHECK_OR_FAIL(env != NULL, "napi_wasm_init_env returned NULL"); + puts("REF_HOLD_START"); + + // Prepared up front: once the budget is exhausted, nothing that copies + // guest data to the host (such as creating a string) can be charged. + napi_value script; + napi_ref script_ref; + NAPI_CALL(env, napi_create_string_utf8(env, "1 + 1", NAPI_AUTO_LENGTH, + &script)); + NAPI_CALL(env, napi_create_reference(env, script, 1, &script_ref)); + + unsigned held = 0; + napi_ref last = NULL; + napi_status status = napi_ok; + while (held < MAX_REFS) { + napi_handle_scope scope; + NAPI_CALL(env, napi_open_handle_scope(env, &scope)); + napi_value obj; + NAPI_CALL(env, napi_create_object(env, &obj)); + napi_ref ref; + status = napi_create_reference(env, obj, 1, &ref); + NAPI_CALL(env, napi_close_handle_scope(env, scope)); + if (status != napi_ok) break; + last = ref; + ++held; + } + CHECK_OR_FAIL(status != napi_ok, "the budget never refused a reference"); + printf("REF_HOLD_REFUSED held=%u status=%d\n", held, (int)status); + + // Make room for the script's own handles: the env must stay terminated + // even though the budget could now cover them. + CHECK_OR_FAIL(last != NULL, "no reference was ever held"); + NAPI_CALL(env, napi_delete_reference(env, last)); + napi_value result; + NAPI_CALL(env, napi_get_reference_value(env, script_ref, &script)); + status = napi_run_script(env, script, &result); + printf("JS_AFTER_REFUSAL status=%d\n", (int)status); + return 0; +} diff --git a/tests/programs/test_unlimited_value_handles.c b/tests/programs/test_unlimited_value_handles.c new file mode 100644 index 00000000..166a6373 --- /dev/null +++ b/tests/programs/test_unlimited_value_handles.c @@ -0,0 +1,20 @@ +#include + +#include "napi_test_helpers.h" + +int main(void) { + napi_env env = napi_wasm_init_env(); + CHECK_OR_FAIL(env != NULL, "napi_wasm_init_env returned NULL"); + + // Value handles are charged to the memory budget, never capped at a fixed + // count: HTTP write batches and certificate enumeration create thousands of + // transient values in one scope. + for (int i = 0; i < 5000; ++i) { + napi_value value = NULL; + NAPI_CALL(env, napi_create_int32(env, i, &value)); + CHECK_OR_FAIL(value != NULL, "value handle was refused"); + } + + puts("UNLIMITED_VALUE_HANDLES_OK"); + return 0; +} diff --git a/tests/review_memory.rs b/tests/review_memory.rs new file mode 100644 index 00000000..c156bb0e --- /dev/null +++ b/tests/review_memory.rs @@ -0,0 +1,323 @@ +use wasmer::{AsStoreMut, Instance, Module, Store}; +use wasmer_napi::NapiCtx; +use wasmer_napi::budget::Pool; + +#[test] +fn invalid_external_buffer_ranges_are_rejected() { + for (name, import, operation) in [ + ( + "external_arraybuffer", + "napi_create_external_arraybuffer", + "(call $op (local.get $env) (i32.mul (memory.size) (i32.const 65536)) (i32.const 1) (i32.const 0) (i32.const 0) (i32.const 20))", + ), + ( + "external_buffer", + "napi_create_external_buffer", + "(call $op (local.get $env) (i32.const 1) (i32.mul (memory.size) (i32.const 65536)) (i32.const 0) (i32.const 0) (i32.const 20))", + ), + ( + "negative_external_arraybuffer", + "napi_create_external_arraybuffer", + "(call $op (local.get $env) (i32.const 64) (i32.const -1) (i32.const 0) (i32.const 0) (i32.const 20))", + ), + ( + "negative_external_buffer", + "napi_create_external_buffer", + "(call $op (local.get $env) (i32.const -1) (i32.const 64) (i32.const 0) (i32.const 0) (i32.const 20))", + ), + ] { + let wat = format!( + r#"(module + (import "napi_extension_wasmer_v0" "unofficial_napi_create_env" (func $create (param i32 i32 i32 i32) (result i32))) + (import "napi" "{import}" (func $op (param i32 i32 i32 i32 i32 i32) (result i32))) + (memory (export "memory") 1) + (func (export "run") (result i32) (local $env i32) + (drop (call $create (i32.const 8) (i32.const 0) (i32.const 4) (i32.const 8))) + (local.set $env (i32.load (i32.const 4))) + {operation}))"# + ); + let ctx = NapiCtx::default(); + let mut store = Store::default(); + let module = Module::new(&store, wat::parse_str(wat).unwrap()).unwrap(); + let session = ctx.new_session(&module).unwrap(); + let imports = session.create_imports(&mut store.as_store_mut()).unwrap(); + let instance = Instance::new(&mut store, &module, &imports).unwrap(); + session + .configure_instance(&mut store.as_store_mut(), &instance, None) + .unwrap(); + let status = instance + .exports + .get_typed_function::<(), i32>(&store, "run") + .unwrap() + .call(&mut store) + .unwrap(); + assert_ne!(status, 0, "{name} accepted an invalid guest range"); + } +} + +#[test] +fn invalid_allocation_requests_are_rejected_before_native_calls() { + let cases = [ + ( + "napi_create_arraybuffer", + "(param i32 i32 i32 i32)", + "(call $op (local.get $env) (i32.const -1) (i32.const 24) (i32.const 28))", + ), + ( + "napi_create_buffer", + "(param i32 i32 i32 i32)", + "(call $op (local.get $env) (i32.const -1) (i32.const 24) (i32.const 28))", + ), + ( + "napi_create_buffer_copy", + "(param i32 i32 i32 i32 i32)", + "(call $op (local.get $env) (i32.const -1) (i32.const 64) (i32.const 24) (i32.const 28))", + ), + ( + "node_api_create_sharedarraybuffer", + "(param i32 i32 i32 i32)", + "(call $op (local.get $env) (i32.const -1) (i32.const 24) (i32.const 28))", + ), + ]; + for (name, signature, operation) in cases { + let wat = format!( + r#"(module + (import "napi_extension_wasmer_v0" "unofficial_napi_create_env" (func $create (param i32 i32 i32 i32) (result i32))) + (import "napi" "{name}" (func $op {signature} (result i32))) + (memory (export "memory") 1) + (func (export "run") (result i32) (local $env i32) + (drop (call $create (i32.const 8) (i32.const 0) (i32.const 4) (i32.const 8))) + (local.set $env (i32.load (i32.const 4))) + {operation}))"# + ); + let ctx = NapiCtx::default(); + let mut store = Store::default(); + let module = Module::new(&store, wat::parse_str(wat).unwrap()).unwrap(); + let session = ctx.new_session(&module).unwrap(); + let imports = session.create_imports(&mut store.as_store_mut()).unwrap(); + let instance = Instance::new(&mut store, &module, &imports).unwrap(); + session + .configure_instance(&mut store.as_store_mut(), &instance, None) + .unwrap(); + let status = instance + .exports + .get_typed_function::<(), i32>(&store, "run") + .unwrap() + .call(&mut store) + .unwrap(); + assert_ne!(status, 0, "{name} accepted an invalid allocation request"); + } +} + +#[test] +fn shared_arraybuffer_exposes_a_guest_address_for_its_backing_store() { + let wat = r#"(module + (import "napi_extension_wasmer_v0" "unofficial_napi_create_env" (func $create (param i32 i32 i32 i32) (result i32))) + (import "napi" "node_api_create_sharedarraybuffer" (func $create_sab (param i32 i32 i32 i32) (result i32))) + (memory (export "memory") 1) + (func (export "run") (result i32) (local $env i32) (local $ptr i32) + (if (call $create (i32.const 8) (i32.const 0) (i32.const 4) (i32.const 8)) + (then (return (i32.const 1)))) + (local.set $env (i32.load (i32.const 4))) + (if (call $create_sab (local.get $env) (i32.const 8) (i32.const 24) (i32.const 28)) + (then (return (i32.const 2)))) + (local.set $ptr (i32.load (i32.const 24))) + (if (i32.eqz (local.get $ptr)) (then (return (i32.const 3)))) + (if (i32.gt_u (i32.add (local.get $ptr) (i32.const 8)) + (i32.mul (memory.size) (i32.const 65536))) + (then (return (i32.const 4)))) + (i64.store (local.get $ptr) (i64.const 123456789)) + (if (i64.ne (i64.load (local.get $ptr)) (i64.const 123456789)) + (then (return (i32.const 5)))) + (i32.const 0)))"#; + let ctx = NapiCtx::default(); + let mut store = Store::default(); + let module = Module::new(&store, wat::parse_str(wat).unwrap()).unwrap(); + let session = ctx.new_session(&module).unwrap(); + let imports = session.create_imports(&mut store.as_store_mut()).unwrap(); + let instance = Instance::new(&mut store, &module, &imports).unwrap(); + session + .configure_instance(&mut store.as_store_mut(), &instance, None) + .unwrap(); + let status = instance + .exports + .get_typed_function::<(), i32>(&store, "run") + .unwrap() + .call(&mut store) + .unwrap(); + assert_eq!(status, 0, "shared backing store was not guest addressable"); +} + +#[test] +fn bigint_query_and_short_output_report_required_words_without_overwrite() { + let wat = r#"(module + (import "napi_extension_wasmer_v0" "unofficial_napi_create_env" (func $create (param i32 i32 i32 i32) (result i32))) + (import "napi" "napi_create_bigint_words" (func $create_words (param i32 i32 i32 i32 i32) (result i32))) + (import "napi" "napi_get_value_bigint_words" (func $get_words (param i32 i32 i32 i32 i32) (result i32))) + (memory (export "memory") 1) + (func (export "run") (result i32) (local $env i32) + (drop (call $create (i32.const 8) (i32.const 0) (i32.const 4) (i32.const 8))) + (local.set $env (i32.load (i32.const 4))) + (i64.store (i32.const 80) (i64.const 17)) + (i64.store (i32.const 88) (i64.const 34)) + (i64.store (i32.const 96) (i64.const 51)) + (if (call $create_words (local.get $env) (i32.const 1) (i32.const 3) (i32.const 80) (i32.const 20)) + (then (return (i32.const 1)))) + (i32.store (i32.const 28) (i32.const 0)) + (if (call $get_words (local.get $env) (i32.load (i32.const 20)) (i32.const 24) (i32.const 28) (i32.const 0)) + (then (return (i32.const 2)))) + (if (i32.ne (i32.load (i32.const 28)) (i32.const 3)) (then (return (i32.const 3)))) + (i32.store (i32.const 28) (i32.const 1)) + (i64.store (i32.const 112) (i64.const 99)) + (if (call $get_words (local.get $env) (i32.load (i32.const 20)) (i32.const 24) (i32.const 28) (i32.const 104)) + (then (return (i32.const 4)))) + (if (i32.ne (i32.load (i32.const 28)) (i32.const 3)) (then (return (i32.const 5)))) + (if (i64.ne (i64.load (i32.const 104)) (i64.const 17)) (then (return (i32.const 6)))) + (if (i64.ne (i64.load (i32.const 112)) (i64.const 99)) (then (return (i32.const 7)))) + (i32.const 0)))"#; + let ctx = NapiCtx::default(); + let mut store = Store::default(); + let module = Module::new(&store, wat::parse_str(wat).unwrap()).unwrap(); + let session = ctx.new_session(&module).unwrap(); + let imports = session.create_imports(&mut store.as_store_mut()).unwrap(); + let instance = Instance::new(&mut store, &module, &imports).unwrap(); + session + .configure_instance(&mut store.as_store_mut(), &instance, None) + .unwrap(); + let status = instance + .exports + .get_typed_function::<(), i32>(&store, "run") + .unwrap() + .call(&mut store) + .unwrap(); + assert_eq!(status, 0); +} + +#[test] +fn converted_utf16_and_bigint_inputs_are_charged_under_finite_budgets() { + let wat = r#"(module + (import "napi_extension_wasmer_v0" "unofficial_napi_create_env" (func $create (param i32 i32 i32 i32) (result i32))) + (import "napi" "napi_create_string_utf16" (func $utf16 (param i32 i32 i32 i32) (result i32))) + (import "napi" "napi_create_bigint_words" (func $bigint (param i32 i32 i32 i32 i32) (result i32))) + (memory (export "memory") 1) + (func (export "init") (result i32) + (i32.store16 (i32.const 100) (i32.const 65)) + (i32.store16 (i32.const 102) (i32.const 66)) + (i64.store (i32.const 120) (i64.const 17)) + (i64.store (i32.const 128) (i64.const 34)) + (call $create (i32.const 8) (i32.const 0) (i32.const 4) (i32.const 8))) + (func (export "run") (param $mode i32) (result i32) + (if (result i32) (i32.eqz (local.get $mode)) + (then (call $utf16 (i32.load (i32.const 4)) (i32.const 100) (i32.const 2) (i32.const 20))) + (else (call $bigint (i32.load (i32.const 4)) (i32.const 0) (i32.const 2) (i32.const 120) (i32.const 20))))))"#; + for (mode, live_bytes) in [(0, 8u64), (1, 32u64)] { + for allowed in [live_bytes - 1, live_bytes] { + let ctx = NapiCtx::builder() + .total_memory_bytes(128 * 1024 * 1024) + .build(); + let budget = ctx.budget(); + let mut store = Store::default(); + let module = Module::new(&store, wat::parse_str(wat).unwrap()).unwrap(); + let session = ctx.new_session(&module).unwrap(); + let imports = session.create_imports(&mut store.as_store_mut()).unwrap(); + let instance = Instance::new(&mut store, &module, &imports).unwrap(); + session + .configure_instance(&mut store.as_store_mut(), &instance, None) + .unwrap(); + assert_eq!( + instance + .exports + .get_typed_function::<(), i32>(&store, "init") + .unwrap() + .call(&mut store) + .unwrap(), + 0 + ); + let reserve = budget.memory_remaining().checked_sub(allowed).unwrap(); + budget.try_charge(Pool::HostTransient, reserve).unwrap(); + let status = instance + .exports + .get_typed_function::(&store, "run") + .unwrap() + .call(&mut store, mode) + .unwrap(); + if allowed == live_bytes { + assert_eq!(status, 0, "mode {mode}"); + } else { + assert_ne!(status, 0, "mode {mode} accepted uncharged conversion"); + } + budget.uncharge(Pool::HostTransient, reserve); + assert_eq!(budget.snapshot().host_transient, 0); + } + } +} + +#[test] +fn utf16_and_bigint_outputs_use_only_charged_scratch() { + let wat = r#"(module + (import "napi_extension_wasmer_v0" "unofficial_napi_create_env" (func $create (param i32 i32 i32 i32) (result i32))) + (import "napi" "napi_create_string_utf16" (func $make_string (param i32 i32 i32 i32) (result i32))) + (import "napi" "napi_get_value_string_utf16" (func $get_string (param i32 i32 i32 i32 i32) (result i32))) + (import "napi" "napi_create_bigint_words" (func $make_bigint (param i32 i32 i32 i32 i32) (result i32))) + (import "napi" "napi_get_value_bigint_words" (func $get_bigint (param i32 i32 i32 i32 i32) (result i32))) + (memory (export "memory") 1) + (func (export "init") (result i32) + (i32.store16 (i32.const 100) (i32.const 65)) + (i32.store16 (i32.const 102) (i32.const 66)) + (i64.store (i32.const 120) (i64.const 17)) + (i64.store (i32.const 128) (i64.const 34)) + (if (call $create (i32.const 8) (i32.const 0) (i32.const 4) (i32.const 8)) + (then (return (i32.const 1)))) + (if (call $make_string (i32.load (i32.const 4)) (i32.const 100) (i32.const 2) (i32.const 20)) + (then (return (i32.const 2)))) + (if (call $make_bigint (i32.load (i32.const 4)) (i32.const 0) (i32.const 2) (i32.const 120) (i32.const 24)) + (then (return (i32.const 3)))) + (i32.store (i32.const 44) (i32.const 2)) + (i32.const 0)) + (func (export "run") (param $mode i32) (result i32) + (if (result i32) (i32.eqz (local.get $mode)) + (then (call $get_string (i32.load (i32.const 4)) (i32.load (i32.const 20)) + (i32.const 300) (i32.const 2) (i32.const 40))) + (else (call $get_bigint (i32.load (i32.const 4)) (i32.load (i32.const 24)) + (i32.const 48) (i32.const 44) (i32.const 320))))))"#; + for (mode, live_bytes) in [(0, 4u64), (1, 20u64)] { + for allowed in [live_bytes - 1, live_bytes] { + let ctx = NapiCtx::builder() + .total_memory_bytes(128 * 1024 * 1024) + .build(); + let budget = ctx.budget(); + let mut store = Store::default(); + let module = Module::new(&store, wat::parse_str(wat).unwrap()).unwrap(); + let session = ctx.new_session(&module).unwrap(); + let imports = session.create_imports(&mut store.as_store_mut()).unwrap(); + let instance = Instance::new(&mut store, &module, &imports).unwrap(); + session + .configure_instance(&mut store.as_store_mut(), &instance, None) + .unwrap(); + assert_eq!( + instance + .exports + .get_typed_function::<(), i32>(&store, "init") + .unwrap() + .call(&mut store) + .unwrap(), + 0 + ); + let reserve = budget.memory_remaining().checked_sub(allowed).unwrap(); + budget.try_charge(Pool::HostTransient, reserve).unwrap(); + let status = instance + .exports + .get_typed_function::(&store, "run") + .unwrap() + .call(&mut store, mode) + .unwrap(); + if allowed == live_bytes { + assert_eq!(status, 0, "mode {mode}"); + } else { + assert_ne!(status, 0, "mode {mode} accepted uncharged output scratch"); + } + budget.uncharge(Pool::HostTransient, reserve); + assert_eq!(budget.snapshot().host_transient, 0); + } + } +} diff --git a/tests/v8_host_control.rs b/tests/v8_host_control.rs index 2c140609..309cdaa8 100644 --- a/tests/v8_host_control.rs +++ b/tests/v8_host_control.rs @@ -29,6 +29,10 @@ const NAPI_PENDING_EXCEPTION: i32 = 10; /// ceiling is clamped. const BUDGET_BELOW_ISOLATE_FLOOR: u64 = 20 * 1024 * 1024; +/// Admits a guest and a default isolate with only a few MiB to spare, which a +/// guest holding tens of thousands of references exhausts. +const BUDGET_STARVING_BOOKKEEPING: u64 = 144 * 1024 * 1024; + #[test] fn two_guest_isolates_run_sequentially() { let wasm = build_wasix_test("hello_napi_test"); @@ -43,6 +47,83 @@ fn two_guest_isolates_run_sequentially() { } } +#[test] +fn standalone_unlimited_context_does_not_apply_managed_value_cap() { + let wasm = build_wasix_test("test_unlimited_value_handles"); + let (exit_code, stdout, stderr) = + run_guest(&NapiCtx::default(), &wasm).expect("guest run failed"); + assert_eq!(exit_code, 0, "{stdout}\n{stderr}"); + assert!( + stdout.contains("UNLIMITED_VALUE_HANDLES_OK"), + "{stdout}\n{stderr}" + ); +} + +#[test] +fn finite_budget_charges_value_handles_instead_of_capping_them() { + let wasm = build_wasix_test("test_unlimited_value_handles"); + let ctx = NapiCtx::builder() + .total_memory_bytes(GENEROUS_BUDGET) + .build(); + let (exit_code, stdout, stderr) = run_guest(&ctx, &wasm).expect("guest run failed"); + assert_eq!(exit_code, 0, "{stdout}\n{stderr}"); + assert!( + stdout.contains("UNLIMITED_VALUE_HANDLES_OK"), + "{stdout}\n{stderr}" + ); +} + +/// Waits for WASIX's asynchronous final-store cleanup, which is what releases +/// an isolate's reservations, then returns the budget snapshot. +fn usage_after_quiescence(ctx: &NapiCtx) -> wasmer_napi::ResourceUsage { + let deadline = Instant::now() + Duration::from_secs(10); + while ctx.budget().snapshot().live_isolates != 0 && Instant::now() < deadline { + thread::sleep(Duration::from_millis(20)); + } + ctx.budget().snapshot() +} + +/// A workload holding a host handle per short-lived object used to fail past +/// a fixed handle count, because the tiny JS objects never made V8 collect the +/// handles' owners. The bookkeeping charge now asks for that collection. +#[test] +fn many_short_lived_refs_are_collected_under_a_finite_budget() { + let wasm = build_wasix_test("test_ref_churn"); + let ctx = NapiCtx::builder() + .total_memory_bytes(GENEROUS_BUDGET) + .build(); + let (exit_code, stdout, stderr) = run_guest(&ctx, &wasm).expect("guest run failed"); + assert_eq!(exit_code, 0, "{stdout}\n{stderr}"); + assert!(stdout.contains("REF_CHURN_OK"), "{stdout}\n{stderr}"); + let usage = usage_after_quiescence(&ctx); + assert_eq!( + usage.host_bookkeeping, 0, + "bookkeeping outlived the guest: {usage:?}" + ); +} + +/// Bookkeeping the budget cannot cover is an out-of-memory condition: the env +/// is stopped like one whose heap growth was refused, not left half-working. +#[test] +fn bookkeeping_exhaustion_stops_the_env() { + let wasm = build_wasix_test("test_ref_hold"); + let ctx = NapiCtx::builder() + .total_memory_bytes(BUDGET_STARVING_BOOKKEEPING) + .build(); + let (exit_code, stdout, stderr) = run_guest(&ctx, &wasm).expect("guest run failed"); + assert_eq!(exit_code, 0, "{stdout}\n{stderr}"); + assert!(stdout.contains("REF_HOLD_REFUSED"), "{stdout}\n{stderr}"); + assert!( + stdout.contains(&format!("JS_AFTER_REFUSAL status={NAPI_PENDING_EXCEPTION}")), + "the refusal did not terminate the isolate: {stdout}\n{stderr}" + ); + let usage = usage_after_quiescence(&ctx); + assert_eq!( + usage.host_bookkeeping, 0, + "bookkeeping outlived the guest: {usage:?}" + ); +} + #[test] fn guest_fatal_error_exits_only_its_workload() { let fatal_wasm = build_wasix_test("test_guest_fatal_error"); @@ -64,6 +145,91 @@ fn guest_fatal_error_exits_only_its_workload() { ); } +#[test] +fn guest_cannot_set_process_wide_v8_flags_with_embedded_nul() { + let wasm = build_wasix_test("test_guest_runtime_flags"); + let (exit_code, stdout, stderr) = + run_guest(&NapiCtx::default(), &wasm).expect("guest run failed"); + assert_eq!(exit_code, 0, "{stdout}\n{stderr}"); + assert!(stdout.contains("FLAGS_REJECTED"), "{stdout}\n{stderr}"); +} + +#[test] +fn guest_property_descriptor_count_is_bounded_before_read() { + let wasm = build_wasix_test("test_property_descriptor_cap"); + let (exit_code, stdout, stderr) = + run_guest(&NapiCtx::default(), &wasm).expect("guest run failed"); + assert_eq!(exit_code, 0, "{stdout}\n{stderr}"); + assert!( + stdout.contains("PROPERTY_CAP_REJECTED"), + "{stdout}\n{stderr}" + ); +} + +#[test] +fn explicit_length_names_preserve_embedded_nul_without_native_overread() { + let wasm = build_wasix_test("test_embedded_nul_names"); + let (exit_code, stdout, stderr) = + run_guest(&NapiCtx::default(), &wasm).expect("guest run failed"); + assert_eq!(exit_code, 0, "{stdout}\n{stderr}"); + assert!( + stdout.contains("EMBEDDED_NUL_NAMES_OK"), + "{stdout}\n{stderr}" + ); +} + +#[test] +fn guest_cannot_allocate_unmetered_v8_webassembly_memory() { + let wasm = build_wasix_test("test_js_wasm_disabled"); + let (exit_code, stdout, stderr) = + run_guest(&NapiCtx::default(), &wasm).expect("guest run failed"); + assert_eq!(exit_code, 0, "{stdout}\n{stderr}"); + assert!(stdout.contains("JS_WASM_DISABLED_OK"), "{stdout}\n{stderr}"); +} + +#[test] +fn guest_cannot_start_unmanaged_v8_workers_or_profiler() { + let wasm = build_wasix_test("test_guest_unmanaged_controls"); + let (exit_code, stdout, stderr) = + run_guest(&NapiCtx::default(), &wasm).expect("guest run failed"); + assert_eq!(exit_code, 0, "{stdout}\n{stderr}"); + assert!( + stdout.contains("UNMANAGED_CONTROLS_REJECTED"), + "{stdout}\n{stderr}" + ); +} + +#[test] +fn guest_messages_remain_charged_until_instance_shutdown() { + let wasm = build_wasix_test("test_guest_message_budget"); + let ctx = NapiCtx::builder() + .total_memory_bytes(GENEROUS_BUDGET) + .build(); + let budget = ctx.budget(); + let (exit_code, stdout, stderr) = run_guest(&ctx, &wasm).expect("guest run failed"); + assert_eq!(exit_code, 0, "{stdout}\n{stderr}"); + assert!(stdout.contains("MESSAGE_BUDGET_OK"), "{stdout}\n{stderr}"); + assert!( + budget.snapshot().serialized_message > 0, + "queued message escaped memory accounting" + ); + ctx.runtime_control().terminate_all(); + assert_eq!(budget.snapshot().serialized_message, 0); + drop(ctx); +} + +#[test] +fn named_properties_and_throw_errors_use_charged_guest_strings() { + let wasm = build_wasix_test("test_charged_cstring_bridge"); + let (exit_code, stdout, stderr) = + run_guest(&NapiCtx::default(), &wasm).expect("guest run failed"); + assert_eq!(exit_code, 0, "{stdout}\n{stderr}"); + assert!( + stdout.contains("CHARGED_CSTRING_BRIDGE_OK"), + "{stdout}\n{stderr}" + ); +} + #[test] fn two_guest_isolates_can_coexist_while_one_sleeps() { let sleeping_wasm = build_wasix_test("test_env_sleep"); @@ -211,7 +377,7 @@ fn isolate_reservations_are_released_when_the_guest_exits() { "guest did not report success\n--- stdout ---\n{stdout}" ); - let usage = ctx.budget().snapshot(); + let usage = usage_after_quiescence(&ctx); assert_eq!( usage.live_isolates, 0, "an isolate outlived the guest: {usage:?}"