From 429f187b97c1c1390066c52c73bbbe30259e975b Mon Sep 17 00:00:00 2001 From: Dmitry Savonin Date: Fri, 7 Aug 2026 20:34:07 +0400 Subject: [PATCH] fix(vm): treat unallocated tables as empty instead of panicking --- src/vm/executor.rs | 9 ++ src/vm/executor/control_flow.rs | 10 +- src/vm/executor/table.rs | 57 ++++----- tests/tables.rs | 203 ++++++++++++++++++++++++++++++++ 4 files changed, 240 insertions(+), 39 deletions(-) create mode 100644 tests/tables.rs diff --git a/src/vm/executor.rs b/src/vm/executor.rs index e3b74cfe..b847f646 100644 --- a/src/vm/executor.rs +++ b/src/vm/executor.rs @@ -410,6 +410,15 @@ impl<'a, T> RwasmExecutor<'a, T> { Ok(()) } + /// Invokes a syscall by its index. + /// + /// An index that the import linker can't resolve is a fatal error, not a trap: the number of + /// stack params and results is taken from the linker, so an unresolved index leaves us with no + /// way to know how many values to pop, and any guess would desynchronize the value stack. + /// zkVM proving requires the same execution trace on every run, so the caller must supply an + /// import linker that resolves every syscall the module can reach — restricting the reachable + /// set is the linker's responsibility, and [`crate::always_failing_syscall_handler`] is the way + /// to reject a syscall that is declared but must not be executed. pub(crate) fn invoke_syscall(&mut self, sys_func_idx: SysFuncIdx) -> Result<(), TrapCode> { let (params, result) = self .store diff --git a/src/vm/executor/control_flow.rs b/src/vm/executor/control_flow.rs index 8ec4e55b..e8c69c12 100644 --- a/src/vm/executor/control_flow.rs +++ b/src/vm/executor/control_flow.rs @@ -87,10 +87,7 @@ impl<'a, T> RwasmExecutor<'a, T> { let func_index: u32 = self.sp.pop_as(); self.store.last_signature = Some(signature_idx); let instr_ref: u32 = self - .store - .tables - .get(&table) - .expect("rwasm: unresolved table index") + .resolve_table(table) .get_untyped(func_index) .ok_or(TrapCode::TableOutOfBounds)? .into(); @@ -141,10 +138,7 @@ impl<'a, T> RwasmExecutor<'a, T> { let func_index: u32 = self.sp.pop_as(); self.store.last_signature = Some(signature_idx); let instr_ref = self - .store - .tables - .get(&table) - .expect("rwasm: unresolved table index") + .resolve_table(table) .get_untyped(func_index) .map(|v| v.as_u32()) .ok_or(TrapCode::TableOutOfBounds)?; diff --git a/src/vm/executor/table.rs b/src/vm/executor/table.rs index bc839db2..f39c1592 100644 --- a/src/vm/executor/table.rs +++ b/src/vm/executor/table.rs @@ -1,14 +1,21 @@ use crate::{ElementSegmentIdx, RwasmExecutor, TableEntity, TableIdx, TrapCode}; impl<'a, T> RwasmExecutor<'a, T> { + /// Resolves a table by its index, materializing an empty one if it doesn't exist yet. + /// + /// Tables enter the store only when the module executes `TableGrow` (the translator emits one + /// per declared table), but a module is free to reference a table index it never grows, and + /// verification can't reject that because table indices carry no declaration. + /// Such a table is treated as a zero-length table, which matches Wasm semantics: `table.size` + /// returns 0 and every element access is bound-checked into `TrapCode::TableOutOfBounds`. + #[inline(always)] + pub(crate) fn resolve_table(&mut self, table_idx: TableIdx) -> &mut TableEntity { + self.store.tables.entry(table_idx).or_default() + } + #[inline(always)] pub(crate) fn visit_table_size(&mut self, table_idx: TableIdx) { - let table_size = self - .store - .tables - .get(&table_idx) - .expect("rwasm: unresolved table segment") - .size(); + let table_size = self.resolve_table(table_idx).size(); self.sp.push_as(table_size); self.ip.add(1); } @@ -17,7 +24,7 @@ impl<'a, T> RwasmExecutor<'a, T> { pub(crate) fn visit_table_grow(&mut self, table_idx: TableIdx) -> Result<(), TrapCode> { let (init, delta) = self.sp.pop2(); let delta: u32 = delta.into(); - let table = self.store.tables.entry(table_idx).or_default(); + let table = self.resolve_table(table_idx); let result = table.grow_untyped(delta, init); self.sp.push_as(result); #[cfg(feature = "tracing")] @@ -31,10 +38,7 @@ impl<'a, T> RwasmExecutor<'a, T> { #[inline(always)] pub(crate) fn visit_table_fill(&mut self, table_idx: TableIdx) -> Result<(), TrapCode> { let (i, val, n) = self.sp.pop3(); - self.store - .tables - .get_mut(&table_idx) - .expect("rwasm: missing table") + self.resolve_table(table_idx) .fill_untyped(i.into(), val, n.into())?; self.ip.add(1); Ok(()) @@ -44,10 +48,7 @@ impl<'a, T> RwasmExecutor<'a, T> { pub(crate) fn visit_table_get(&mut self, table_idx: TableIdx) -> Result<(), TrapCode> { let index = self.sp.pop(); let value = self - .store - .tables - .get_mut(&table_idx) - .expect("rwasm: missing table") + .resolve_table(table_idx) .get_untyped(index.into()) .ok_or(TrapCode::TableOutOfBounds)?; self.sp.push(value); @@ -58,10 +59,7 @@ impl<'a, T> RwasmExecutor<'a, T> { #[inline(always)] pub(crate) fn visit_table_set(&mut self, table_idx: TableIdx) -> Result<(), TrapCode> { let (index, value) = self.sp.pop2(); - self.store - .tables - .get_mut(&table_idx) - .expect("rwasm: missing table") + self.resolve_table(table_idx) .set_untyped(index.into(), value) .map_err(|_| TrapCode::TableOutOfBounds)?; #[cfg(feature = "tracing")] @@ -84,18 +82,19 @@ impl<'a, T> RwasmExecutor<'a, T> { let dst_index = u32::from(d); // Query both tables and check if they are the same: if src_table_idx != dst_table_idx { - let [src, dst] = self + self.resolve_table(src_table_idx); + self.resolve_table(dst_table_idx); + // Both tables are materialized above, so the disjoint lookup always succeeds + let [Some(src), Some(dst)] = self .store .tables .get_disjoint_mut([&src_table_idx, &dst_table_idx]) - .map(|v| v.expect("rwasm: unresolved table segment")); + else { + return Err(TrapCode::TableOutOfBounds); + }; TableEntity::copy(dst, dst_index, src, src_index, len)?; } else { - let src = self - .store - .tables - .get_mut(&src_table_idx) - .expect("rwasm: unresolved table segment"); + let src = self.resolve_table(src_table_idx); src.copy_within(dst_index, src_index, len)?; } self.ip.add(1); @@ -135,11 +134,7 @@ impl<'a, T> RwasmExecutor<'a, T> { if is_empty_segment { module_elements_section = &[]; } - let table = self - .store - .tables - .get_mut(&table_idx) - .expect("rwasm: missing table"); + let table = self.resolve_table(table_idx); table.init_untyped(dst_index, module_elements_section, src_index, len)?; self.ip.add(2); diff --git a/tests/tables.rs b/tests/tables.rs new file mode 100644 index 00000000..9314c2b7 --- /dev/null +++ b/tests/tables.rs @@ -0,0 +1,203 @@ +//! Tables that a module references but never grows must behave as zero-length tables instead of +//! panicking the interpreter. + +use rwasm::{ + always_failing_syscall_handler, instruction_set, ExecutionEngine, ImportLinker, InstructionSet, + RwasmModule, RwasmModuleBuilder, RwasmStore, TrapCode, Value, +}; + +/// The index of a table that no test module ever grows. +const UNGROWN_TABLE: u16 = 3; + +fn verified_module(code_section: InstructionSet, elem_section: &[u32]) -> RwasmModule { + let module = RwasmModuleBuilder::new(code_section) + .with_elem_section(elem_section) + .build(); + RwasmModule::new_verified_exact(&module.serialize()).expect("module must pass verification") +} + +fn execute(code_section: InstructionSet, result: &mut [Value]) -> Result<(), TrapCode> { + let module = verified_module(code_section, &[]); + let engine = ExecutionEngine::new(); + let mut store = RwasmStore::new( + ImportLinker::default().into(), + (), + always_failing_syscall_handler, + None, + None, + ); + engine.execute(&mut store, &module, &[], result) +} + +fn execute_and_trap(code_section: InstructionSet) -> TrapCode { + execute(code_section, &mut []).expect_err("execution must trap") +} + +#[test] +fn test_table_size_of_ungrown_table_is_zero() { + let mut result = [Value::I32(-1)]; + execute( + instruction_set! { + TableSize(UNGROWN_TABLE) + Return + }, + &mut result, + ) + .unwrap(); + assert_eq!(result[0].i32(), Some(0)); +} + +#[test] +fn test_table_get_of_ungrown_table_traps() { + let trap_code = execute_and_trap(instruction_set! { + I32Const(0) + TableGet(UNGROWN_TABLE) + Drop + Return + }); + assert_eq!(trap_code, TrapCode::TableOutOfBounds); +} + +#[test] +fn test_table_set_of_ungrown_table_traps() { + let trap_code = execute_and_trap(instruction_set! { + I32Const(0) // index + I32Const(1) // value + TableSet(UNGROWN_TABLE) + Return + }); + assert_eq!(trap_code, TrapCode::TableOutOfBounds); +} + +#[test] +fn test_table_fill_of_ungrown_table_traps() { + let trap_code = execute_and_trap(instruction_set! { + I32Const(0) // d + I32Const(0) // val + I32Const(1) // n + TableFill(UNGROWN_TABLE) + Return + }); + assert_eq!(trap_code, TrapCode::TableOutOfBounds); +} + +#[test] +fn test_empty_table_fill_of_ungrown_table_succeeds() { + execute( + instruction_set! { + I32Const(0) // d + I32Const(0) // val + I32Const(0) // n + TableFill(UNGROWN_TABLE) + Return + }, + &mut [], + ) + .unwrap(); +} + +#[test] +fn test_table_copy_of_ungrown_tables_traps() { + let trap_code = execute_and_trap(instruction_set! { + I32Const(0) // d + I32Const(0) // s + I32Const(1) // n + .op_table_copy(UNGROWN_TABLE, UNGROWN_TABLE + 1) + Return + }); + assert_eq!(trap_code, TrapCode::TableOutOfBounds); +} + +#[test] +fn test_empty_table_copy_of_ungrown_tables_succeeds() { + execute( + instruction_set! { + I32Const(0) // d + I32Const(0) // s + I32Const(0) // n + .op_table_copy(UNGROWN_TABLE, UNGROWN_TABLE + 1) + Return + }, + &mut [], + ) + .unwrap(); +} + +#[test] +fn test_table_copy_within_ungrown_table_traps() { + let trap_code = execute_and_trap(instruction_set! { + I32Const(0) // d + I32Const(0) // s + I32Const(1) // n + .op_table_copy(UNGROWN_TABLE, UNGROWN_TABLE) + Return + }); + assert_eq!(trap_code, TrapCode::TableOutOfBounds); +} + +#[test] +fn test_table_init_into_ungrown_table_traps() { + let module = verified_module( + instruction_set! { + I32Const(0) // d + I32Const(0) // s + I32Const(1) // n + TableInit(1) + TableGet(UNGROWN_TABLE) // table index payload + Return + }, + &[0], + ); + let engine = ExecutionEngine::new(); + let mut store = RwasmStore::new( + ImportLinker::default().into(), + (), + always_failing_syscall_handler, + None, + None, + ); + let trap_code = engine + .execute(&mut store, &module, &[], &mut []) + .expect_err("execution must trap"); + assert_eq!(trap_code, TrapCode::TableOutOfBounds); +} + +#[test] +fn test_call_indirect_through_ungrown_table_traps() { + let trap_code = execute_and_trap(instruction_set! { + I32Const(0) // func index + CallIndirect(0) + TableGet(UNGROWN_TABLE) // table index payload + Return + }); + assert_eq!(trap_code, TrapCode::TableOutOfBounds); +} + +#[test] +fn test_return_call_indirect_through_ungrown_table_traps() { + let trap_code = execute_and_trap(instruction_set! { + I32Const(0) // func index + ReturnCallIndirect(0) + TableGet(UNGROWN_TABLE) // table index payload + Return + }); + assert_eq!(trap_code, TrapCode::TableOutOfBounds); +} + +#[test] +fn test_grown_table_still_reports_its_size() { + let mut result = [Value::I32(-1)]; + execute( + instruction_set! { + I32Const(0) // init + I32Const(2) // delta + TableGrow(UNGROWN_TABLE) + Drop + TableSize(UNGROWN_TABLE) + Return + }, + &mut result, + ) + .unwrap(); + assert_eq!(result[0].i32(), Some(2)); +}