Limit vm table size

This commit is contained in:
Sergey Kuznetsov
2026-08-21 12:32:10 +01:00
parent ddaa958754
commit 4300c5d7d6
8 changed files with 308 additions and 47 deletions

View File

@@ -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()),
]
}

View File

@@ -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,
};

View File

@@ -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'"

View File

@@ -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");
}

View File

@@ -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

View File

@@ -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
// ---------------------------------------------------------------------------

View File

@@ -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

View File

@@ -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(