From 4300c5d7d6a14bbaeb0a16d494c8968637c54a2e Mon Sep 17 00:00:00 2001 From: Sergey Kuznetsov Date: Fri, 21 Aug 2026 12:32:10 +0100 Subject: [PATCH] Limit vm table size --- crates/xrpl-wasm-vm-ffi/src/lib.rs | 4 + crates/xrpl-wasm-vm/src/lib.rs | 4 +- crates/xrpl-wasm-vm/src/preflight.rs | 81 +++++++++++++---- crates/xrpl-wasm-vm/src/vm.rs | 60 +++++++++++-- crates/xrpl-wasm-vm/tests/preflight.rs | 112 ++++++++++++++++++++---- crates/xrpl-wasm-vm/tests/vm_limits.rs | 64 +++++++++++++- src/libxrpl/tx/wasm/WasmVM.cpp | 5 +- src/tests/libxrpl/tx/wasm/Preflight.cpp | 25 ++++++ 8 files changed, 308 insertions(+), 47 deletions(-) diff --git a/crates/xrpl-wasm-vm-ffi/src/lib.rs b/crates/xrpl-wasm-vm-ffi/src/lib.rs index fc49626613..d9f63eeb71 100644 --- a/crates/xrpl-wasm-vm-ffi/src/lib.rs +++ b/crates/xrpl-wasm-vm-ffi/src/lib.rs @@ -97,6 +97,8 @@ mod ffi { EntryPoint, /// The module asks for more linear memory than the engine grants. Memory, + /// The module asks for a larger table than the engine grants. + Table, /// The engine panicked. A defect in this crate or the one below it, and /// not a fault in the module — which is why it is a status of its own /// rather than one more way a contract can be malformed. @@ -1036,6 +1038,7 @@ impl From<&CheckError> for ffi::CheckStatus { CheckError::Import(_) => ffi::CheckStatus::Import, CheckError::EntryPoint(_) => ffi::CheckStatus::EntryPoint, CheckError::Memory(_) => ffi::CheckStatus::Memory, + CheckError::Table(_) => ffi::CheckStatus::Table, } } } @@ -1242,6 +1245,7 @@ mod tests { CheckError::Import(String::new()), CheckError::EntryPoint(String::new()), CheckError::Memory(String::new()), + CheckError::Table(String::new()), ] } diff --git a/crates/xrpl-wasm-vm/src/lib.rs b/crates/xrpl-wasm-vm/src/lib.rs index aae00006c8..b67690825e 100644 --- a/crates/xrpl-wasm-vm/src/lib.rs +++ b/crates/xrpl-wasm-vm/src/lib.rs @@ -23,6 +23,6 @@ mod vm; pub use preflight::{CheckError, check}; pub use vm::{ - MAX_FIELD_BYTES, MAX_MEMORY_BYTES, MAX_MEMORY_PAGES, RunError, RunFailure, RunOutcome, - TRANSFER_LIMIT_BYTES, run, + MAX_FIELD_BYTES, MAX_MEMORY_BYTES, MAX_MEMORY_PAGES, MAX_TABLE_ELEMENTS, RunError, RunFailure, + RunOutcome, TRANSFER_LIMIT_BYTES, run, }; diff --git a/crates/xrpl-wasm-vm/src/preflight.rs b/crates/xrpl-wasm-vm/src/preflight.rs index 026d69b721..8a2a89fd20 100644 --- a/crates/xrpl-wasm-vm/src/preflight.rs +++ b/crates/xrpl-wasm-vm/src/preflight.rs @@ -13,15 +13,17 @@ //! passes: it is guest code, and executing it is the one thing a check must not do //! — a trap in one is charged to the contract like any other trap. //! -//! One thing it screens that a run can only discover: an exported memory larger -//! than the engine grants. See [`check_memory`] for what stays invisible. +//! Two things it screens that a run can only discover: an exported memory, or an +//! exported table, larger than the engine grants. Both read the same export list, so +//! [`check_exported_resources`] is one pass — see it for what stays invisible, and +//! why the table case leaves much more of it there. use std::fmt; use wasmi::{ExternType, FuncType, Module, ValType}; use xrpl_host_functions::HostFunctionSpec; use crate::register::HOST_MODULE; -use crate::vm::{MAX_MEMORY_PAGES, compile}; +use crate::vm::{MAX_MEMORY_PAGES, MAX_TABLE_ELEMENTS, compile}; /// Why a module cannot be run. One variant per stage, since the caller maps the /// stages separately. @@ -37,6 +39,8 @@ pub enum CheckError { EntryPoint(String), /// The module asks for more linear memory than the engine grants. Memory(String), + /// The module asks for a larger table than the engine grants. + Table(String), } impl fmt::Display for CheckError { @@ -48,22 +52,24 @@ impl fmt::Display for CheckError { // "no entry point" would be wrong for an export of the wrong type. CheckError::EntryPoint(detail) => write!(f, "{detail}"), CheckError::Memory(detail) => write!(f, "memory: {detail}"), + CheckError::Table(detail) => write!(f, "table: {detail}"), } } } /// Screen `wasm`: it must compile, import only what the engine serves, export -/// `function_name` as `() -> i32`, and ask for no more memory than it may have. +/// `function_name` as `() -> i32`, and ask for no more memory or table than it may +/// have. /// /// The stages are ordered by how much of the module each explains. An import fault /// is reported before a missing entry point because the imports are what the rest of -/// the module is built on; memory comes last, being a resource request rather than a +/// the module is built on; the resource caps come last, being a request rather than a /// mistake about the ABI. pub fn check(wasm: &[u8], function_name: &str) -> Result<(), CheckError> { let module = compile(wasm).map_err(CheckError::Compile)?; check_imports(&module)?; check_entry_point(&module, function_name)?; - check_memory(&module) + check_exported_resources(&module) } /// Every import must be one the linker defines. The first that is not ends the @@ -116,18 +122,23 @@ fn is_entry_point(ty: &FuncType) -> bool { ty.params().is_empty() && matches!(ty.results(), [ValType::I32]) } -/// A module may not declare more linear memory than the engine grants. +/// A module may declare no more linear memory, and no larger a table, than the +/// engine grants. One pass over the exports, since both rules read the same list and +/// the export table is the only place either is visible. /// -/// Only what it *exports* is visible here. A memory a module keeps to itself is not -/// in its exports, and the store's limiter is what refuses that one — at -/// instantiation, where the run is charged nothing and the caller cannot tell it -/// from any other resource failure. Screening the exported case covers every -/// contract built against the guest SDK, since a contract needs an exported memory -/// to make a host call at all. -fn check_memory(module: &Module) -> Result<(), CheckError> { +/// A module faulting on both is reported by whichever it declares first. Neither +/// fault explains the other, so there is no precedence to preserve — only the need +/// for every node to reach the same verdict, which export order already gives. +fn check_exported_resources(module: &Module) -> Result<(), CheckError> { for export in module.exports() { - if let ExternType::Memory(ty) = export.ty() { - check_initial_pages(ty.minimum()).map_err(CheckError::Memory)?; + match export.ty() { + ExternType::Memory(ty) => { + check_initial_pages(ty.minimum()).map_err(CheckError::Memory)?; + } + ExternType::Table(ty) => { + check_initial_elements(ty.minimum()).map_err(CheckError::Table)?; + } + _ => {} } } Ok(()) @@ -148,6 +159,21 @@ fn check_initial_pages(pages: u64) -> Result<(), String> { Ok(()) } +/// Whether the engine will grant a table of this declared initial size. +/// +/// The *minimum* is the whole question: `table.grow` belongs to the reference-types +/// proposal, which [`crate::vm`]'s engine turns off, so a table never becomes larger +/// than it was declared and a declared maximum past the cap is simply unreachable. +fn check_initial_elements(elements: u64) -> Result<(), String> { + let cap = u64::try_from(MAX_TABLE_ELEMENTS).expect("the cap is a small constant"); + if elements > cap { + return Err(format!( + "initial table of {elements} elements is past the {MAX_TABLE_ELEMENTS}-element cap" + )); + } + Ok(()) +} + /// How an entry-point lookup failed, in the words both stages use: a check and a /// run describe the same module the same way, and "no entry point" would send a /// contract author looking for a function they already have. @@ -310,6 +336,25 @@ mod tests { ); } + /// The cap itself is granted; one element past it is not. The boundary is the + /// whole rule, and it is the same boundary the store's limiter applies at + /// instantiation. + #[test] + fn the_initial_table_may_reach_the_cap_but_not_pass_it() { + let cap = u64::try_from(MAX_TABLE_ELEMENTS).expect("fits"); + assert_eq!(check_initial_elements(0), Ok(())); + assert_eq!(check_initial_elements(cap), Ok(())); + + let past = cap + 1; + let refusal = check_initial_elements(past).expect_err("one element past the cap"); + assert_eq!( + refusal, + format!( + "initial table of {past} elements is past the {MAX_TABLE_ELEMENTS}-element cap" + ) + ); + } + /// The bridge logs this string and the C++ tests match on it, so the stage's /// prefix is part of the interface rather than a debugging aid. #[test] @@ -322,6 +367,10 @@ mod tests { CheckError::Memory("initial memory of 129 pages".to_string()).to_string(), "memory: initial memory of 129 pages" ); + assert_eq!( + CheckError::Table("initial table of 1025 elements".to_string()).to_string(), + "table: initial table of 1025 elements" + ); assert_eq!( CheckError::Import("no host function 'x'".to_string()).to_string(), "import: no host function 'x'" diff --git a/crates/xrpl-wasm-vm/src/vm.rs b/crates/xrpl-wasm-vm/src/vm.rs index 0fe2795d2e..09db3a4ebb 100644 --- a/crates/xrpl-wasm-vm/src/vm.rs +++ b/crates/xrpl-wasm-vm/src/vm.rs @@ -20,6 +20,14 @@ pub const MAX_MEMORY_PAGES: u32 = 128; /// [`MAX_MEMORY_PAGES`] in bytes: 8 MiB. pub const MAX_MEMORY_BYTES: usize = (MAX_MEMORY_PAGES * WASM_PAGE_BYTES) as usize; +/// Cap on a table's element count. +/// +/// A table entry is 8 bytes and wasmi materializes every one of them inside +/// `instantiate_and_start` — before the guest's first instruction, so no gas charge +/// can reach the cost. Without this cap the ceiling is the validator's, `u32::MAX` +/// entries, which a module asks for in five bytes of LEB128 and pays for in ~34 GiB. +pub const MAX_TABLE_ELEMENTS: usize = 1024; + /// Total bytes that may cross the host/guest boundary in one [`run`], separate /// from gas. pub const TRANSFER_LIMIT_BYTES: u64 = 1 << 20; @@ -33,8 +41,8 @@ pub const MAX_FIELD_BYTES: usize = 1024; /// State threaded through every host call, stored in the wasmi [`Store`]. pub(crate) struct VmState<'h> { pub(crate) host: &'h dyn HostFunctions, - /// Enforces [`MAX_MEMORY_BYTES`] via `Store::limiter`, which needs a `&mut` - /// into it from `&mut VmState` — hence a field rather than a local. + /// Enforces [`store_limits`] via `Store::limiter`, which needs a `&mut` into it + /// from `&mut VmState` — hence a field rather than a local. pub(crate) mem_limits: StoreLimits, /// Remaining transfer budget for this run ([`TRANSFER_LIMIT_BYTES`]). /// @@ -254,6 +262,30 @@ fn build_wasm_engine() -> Engine { Engine::new(&config) } +/// Every resource ceiling a run is given, in one place. +/// +/// The two *size* caps are what a contract can reach today. The three *count* caps +/// are set to 1 although [`build_wasm_engine`] already forces each: turning +/// `wasm_reference_types` on would let a module declare up to +/// `wasmparser::MAX_WASM_TABLES` tables, `wasm_multi_memory` likewise for memories, +/// and both size caps are **per table and per memory, not aggregate** — so a feature +/// flag flipped in isolation would multiply the ceiling by a hundred rather than +/// leave it be. The counts are what keeps those two decisions independent. +/// +/// wasmi enforces the counts by asking the limiter before it allocates +/// (`can_create_more_instances`/`_memories`/`_tables`); they default to 10000, so +/// leaving them unset is not the same as their being unreachable. +fn store_limits() -> StoreLimits { + StoreLimitsBuilder::new() + .memory_size(MAX_MEMORY_BYTES) + .table_elements(MAX_TABLE_ELEMENTS) + .instances(1) + .tables(1) + .memories(1) + .trap_on_grow_failure(true) + .build() +} + /// Compile `wasm` for this engine. /// /// The one path to a [`Module`]: the configuration is what decides whether a @@ -275,15 +307,11 @@ pub fn run<'h>( let module = compile(wasm).map_err(|detail| RunFailure::owing_nothing(RunError::Compile(detail)))?; - let mem_limits = StoreLimitsBuilder::new() - .memory_size(MAX_MEMORY_BYTES) - .trap_on_grow_failure(true) - .build(); let mut store = Store::new( engine, VmState { host, - mem_limits, + mem_limits: store_limits(), transfer_budget: Cell::new(TRANSFER_LIMIT_BYTES), memory: None, out_buffer: [0u8; MAX_FIELD_BYTES], @@ -341,12 +369,30 @@ mod tests { assert!(Engine::same(wasm_engine(), wasm_engine())); } + /// One instance, one table, one memory — asserted here rather than through a + /// module, because no module can reach these. `wasm_reference_types(false)` and + /// `wasm_multi_memory(false)` make a module declaring a second table or memory + /// fail *validation*, so a run never gets far enough to consult the limiter. + /// That is exactly why the counts are worth pinning: they are the ceiling that + /// survives one of those flags being turned on, and nothing else would fail if + /// they were silently dropped. + #[test] + fn the_store_grants_one_of_each_thing_a_module_can_own() { + use wasmi::ResourceLimiter; + + let limits = store_limits(); + assert_eq!(limits.instances(), 1); + assert_eq!(limits.tables(), 1); + assert_eq!(limits.memories(), 1); + } + /// The only place these numbers appear as literals; every other test derives /// them from the constants. #[test] fn the_limits_are_the_protocol_limits() { assert_eq!(MAX_MEMORY_PAGES, 128, "linear-memory page cap"); assert_eq!(MAX_MEMORY_BYTES, 8 * 1024 * 1024, "page cap in bytes"); + assert_eq!(MAX_TABLE_ELEMENTS, 1024, "table-element cap"); assert_eq!(MAX_FIELD_BYTES, 1024, "kMaxWasmDataLength"); assert_eq!(TRANSFER_LIMIT_BYTES, 1 << 20, "kWasmTransferLimit"); } diff --git a/crates/xrpl-wasm-vm/tests/preflight.rs b/crates/xrpl-wasm-vm/tests/preflight.rs index 4a9badb1d0..4192fcfdf4 100644 --- a/crates/xrpl-wasm-vm/tests/preflight.rs +++ b/crates/xrpl-wasm-vm/tests/preflight.rs @@ -8,7 +8,7 @@ mod support; use support::{ENTRY, FakeHost, ONE_PAGE, PLENTY_OF_GAS, assemble, import, module}; use xrpl_host_functions::HostFunctionSpec; -use xrpl_wasm_vm::{CheckError, MAX_MEMORY_PAGES, RunError}; +use xrpl_wasm_vm::{CheckError, MAX_MEMORY_PAGES, MAX_TABLE_ELEMENTS, RunError}; /// Assert which stage screening refused a module at, because the caller maps the /// stages separately. The error comes back out for the tests that also read its @@ -464,30 +464,104 @@ fn a_declared_maximum_past_the_cap_still_passes() { )); } -/// The gap, listed rather than described, and now one entry long. A memory a module -/// keeps to itself is not in its exports, so this is the one module that passes -/// screening and then fails to *instantiate* — which is why a run's refusal at that -/// stage cannot be read as the node's fault. +/// A module asking for more table than the engine grants is refused for the same +/// reason a memory is. The cap itself passes. +#[test] +fn an_exported_table_past_the_cap_does_not_pass() { + let wat = module( + &[&format!( + r#"(table (export "t") {} funcref)"#, + MAX_TABLE_ELEMENTS + 1 + )], + "(i32.const 0)", + ); + let refusal = assert_stage!(refusal(&wat), CheckError::Table(_)).to_string(); + assert!(refusal.contains("past the 1024-element cap"), "{refusal}"); + + passes(&module( + &[&format!(r#"(table (export "t") {MAX_TABLE_ELEMENTS} funcref)"#)], + "(i32.const 0)", + )); +} + +/// Both caps are applied in one pass over the exports, so neither may end the walk +/// early: a passing memory must not hide a failing table declared after it, and a +/// passing table must not hide a failing memory. +#[test] +fn one_pass_screens_both_resources() { + let after_a_passing_memory = refusal(&module( + &[ + ONE_PAGE, + &format!(r#"(table (export "t") {} funcref)"#, MAX_TABLE_ELEMENTS + 1), + ], + "(i32.const 0)", + )); + assert_stage!(after_a_passing_memory, CheckError::Table(_)); + + let after_a_passing_table = refusal(&module( + &[ + r#"(table (export "t") 1 funcref)"#, + &format!(r#"(memory (export "memory") {})"#, MAX_MEMORY_PAGES + 1), + ], + "(i32.const 0)", + )); + assert_stage!(after_a_passing_table, CheckError::Memory(_)); +} + +/// As with memory, a declared *maximum* past the cap is unreachable rather than +/// wrong: `vm_limits` runs this very module to completion. +#[test] +fn a_declared_table_maximum_past_the_cap_still_passes() { + passes(&module( + &[&format!( + r#"(table (export "t") 1 {} funcref)"#, + MAX_TABLE_ELEMENTS + 1 + )], + "(i32.const 0)", + )); +} + +/// The gap, listed rather than described. A memory or a table a module keeps to +/// itself is not in its exports, so these are the modules that pass screening and +/// then fail to *instantiate* — which is why a run's refusal at that stage cannot be +/// read as the node's fault. /// -/// A contract needs an exported memory to make any host call, so a module of this -/// shape can do nothing but compute; the SDK does not produce one. +/// The two entries are not equally remote. A contract needs an exported memory to +/// make any host call, so the memory row can do nothing but compute and the SDK does +/// not produce one. A table, though, is *normally* unexported — Rust exports +/// `__indirect_function_table` only under `--export-table` — so the table row is the +/// shape a hostile module actually takes, and the store's limiter is the only thing +/// standing in front of it. #[test] fn what_static_screening_cannot_see() { let host = FakeHost::new(); - let wat = format!( - r#"(module (memory {}) - (func (export "finish") (result i32) (i32.const 0)))"#, - MAX_MEMORY_PAGES + 1 - ); - passes(&wat); + for (label, declaration) in [ + ("memory", format!("(memory {})", MAX_MEMORY_PAGES + 1)), + ( + "table", + format!("(table {} funcref)", MAX_TABLE_ELEMENTS + 1), + ), + ] { + let wat = format!( + r#"(module {declaration} + (func (export "finish") (result i32) (i32.const 0)))"# + ); - let failure = xrpl_wasm_vm::run(&assemble(&wat), PLENTY_OF_GAS, &host, ENTRY) - .expect_err("the store's limiter must refuse the memory"); - assert!( - matches!(failure.error, RunError::Instantiate(_)), - "{failure}" - ); + passes(&wat); + + let failure = match xrpl_wasm_vm::run(&assemble(&wat), PLENTY_OF_GAS, &host, ENTRY) { + Err(failure) => failure, + Ok(outcome) => panic!( + "the store's limiter must refuse the {label}, but the module returned {}", + outcome.result + ), + }; + assert!( + matches!(failure.error, RunError::Instantiate(_)), + "{label}: {failure}" + ); + } } /// A start section is guest code, so screening cannot see whether it traps — but it diff --git a/crates/xrpl-wasm-vm/tests/vm_limits.rs b/crates/xrpl-wasm-vm/tests/vm_limits.rs index 4fa60e8014..a4e8eba35d 100644 --- a/crates/xrpl-wasm-vm/tests/vm_limits.rs +++ b/crates/xrpl-wasm-vm/tests/vm_limits.rs @@ -9,7 +9,7 @@ mod support; use support::{ FakeHost, ONE_PAGE, PLENTY_OF_GAS, failure, import, module, run, run_entry, run_with_gas, }; -use xrpl_wasm_vm::{MAX_MEMORY_PAGES, RunError}; +use xrpl_wasm_vm::{MAX_MEMORY_PAGES, MAX_TABLE_ELEMENTS, RunError}; /// Assert which stage a run failed at, because the caller maps the stages to /// different outcomes. A stage is one `RunError` variant, so the expectation is a @@ -99,6 +99,68 @@ fn a_declared_maximum_past_the_cap_is_allowed_but_unreachable() { assert_stage!(failure(&wat, &host), RunError::Trap(_)); } +// --------------------------------------------------------------------------- +// Tables +// --------------------------------------------------------------------------- + +/// A table's whole cost is paid at instantiation: wasmi writes all 8 bytes of every +/// element before the guest's first instruction, so a module declaring more than the +/// cap must be refused there rather than charged for it. +#[test] +fn an_initial_table_past_the_cap_is_refused() { + let host = FakeHost::new(); + + let wat = module( + &[&format!("(table {} funcref)", MAX_TABLE_ELEMENTS + 1)], + "(i32.const 0)", + ); + assert_stage!(failure(&wat, &host), RunError::Instantiate(_)); +} + +/// The cap itself is allowed. +#[test] +fn an_initial_table_at_the_cap_is_allowed() { + let host = FakeHost::new(); + + let wat = module( + &[&format!("(table {MAX_TABLE_ELEMENTS} funcref)")], + "(i32.const 0)", + ); + assert_eq!(run(&wat, &host).expect("should run").result, 0); +} + +/// The cap binds a table the module keeps to itself, which is the case that matters: +/// a contract has no reason to export its table, so screening never sees the one a +/// hostile module declares. +#[test] +fn the_table_cap_binds_an_unexported_table() { + let host = FakeHost::new(); + + let wat = module( + &[&format!("(table {} funcref)", u32::from(u16::MAX) * 100)], + "(i32.const 0)", + ); + assert_stage!(failure(&wat, &host), RunError::Instantiate(_)); +} + +/// A declared *maximum* past the cap is legal and simply unreachable, mirroring what +/// linear memory allows. Nothing can reach it: `table.grow` is a reference-types +/// instruction and the engine turns that feature off, so a table's declared minimum +/// is also its final size. +#[test] +fn a_declared_table_maximum_past_the_cap_is_allowed_but_unreachable() { + let host = FakeHost::new(); + + let wat = module( + &[&format!( + "(table 1 {} funcref)", + u64::try_from(MAX_TABLE_ELEMENTS).expect("fits") + 1 + )], + "(i32.const 0)", + ); + assert_eq!(run(&wat, &host).expect("should run").result, 0); +} + // --------------------------------------------------------------------------- // Engine configuration // --------------------------------------------------------------------------- diff --git a/src/libxrpl/tx/wasm/WasmVM.cpp b/src/libxrpl/tx/wasm/WasmVM.cpp index 876a52b373..e01493e52f 100644 --- a/src/libxrpl/tx/wasm/WasmVM.cpp +++ b/src/libxrpl/tx/wasm/WasmVM.cpp @@ -96,12 +96,13 @@ verdict(CheckStatus status) return tesSUCCESS; // The module will not compile, imports what no engine of this ABI serves, does - // not export the entry point as `() -> i32`, or asks for more linear memory than - // it may have. + // not export the entry point as `() -> i32`, or asks for more linear memory or + // table than it may have. case CheckStatus::Compile: case CheckStatus::Import: case CheckStatus::EntryPoint: case CheckStatus::Memory: + case CheckStatus::Table: return temBAD_WASM; // The engine panicked: a defect in the engine, reported rather than fatal to diff --git a/src/tests/libxrpl/tx/wasm/Preflight.cpp b/src/tests/libxrpl/tx/wasm/Preflight.cpp index 0391f09711..24b358b269 100644 --- a/src/tests/libxrpl/tx/wasm/Preflight.cpp +++ b/src/tests/libxrpl/tx/wasm/Preflight.cpp @@ -127,6 +127,31 @@ TEST_F(PreflightTest, MemoryPastTheCapIsRefused) EXPECT_EQ(preflight(atTheCap), tesSUCCESS); } +// A table is allocated in full at instantiation, before any gas is charged, so an oversized +// one is refused before it can be escrowed. Screening sees only an *exported* table; the +// store's limiter is what refuses the table a contract keeps to itself. +TEST_F(PreflightTest, TablePastTheCapIsRefused) +{ + constexpr std::string_view tooMuch = R"wat( + (module + (memory (export "memory") 1) + (table (export "t") 1025 funcref) + (func (export "escrow_finish") (result i32) (i32.const 0))) + )wat"; + + EXPECT_EQ(preflight(tooMuch), temBAD_WASM); + EXPECT_THAT(logged(), testing::HasSubstr("table: initial table of 1025 elements")); + + constexpr std::string_view atTheCap = R"wat( + (module + (memory (export "memory") 1) + (table (export "t") 1024 funcref) + (func (export "escrow_finish") (result i32) (i32.const 0))) + )wat"; + + EXPECT_EQ(preflight(atTheCap), tesSUCCESS); +} + TEST_F(PreflightTest, MissingEntryPointIsRefused) { constexpr std::string_view wat = R"wat(