Add Region

This commit is contained in:
Sergey Kuznetsov
2026-08-03 14:45:04 +01:00
parent 047a3f5cb8
commit 0df034a685
7 changed files with 141 additions and 62 deletions

View File

@@ -1,3 +1,4 @@
use crate::region::Region;
use crate::vm::{MAX_FIELD_BYTES, VmState};
use wasmi::{Caller, Memory};
use xrpl_host_functions::{HostError, HostFunctionSpec, HostFunctions, HostResult};
@@ -67,25 +68,14 @@ fn memory(caller: &Caller<'_, VmState<'_>>) -> Result<Memory, HostError> {
caller.data().memory.ok_or(HostError::NoMemExported)
}
/// Validate `[ptr, ptr + len)` against `data` and return that slice of it.
pub(crate) fn region(data: &[u8], ptr: i32, len: i32) -> HostResult<&[u8]> {
let (Ok(ptr), Ok(len)) = (usize::try_from(ptr), usize::try_from(len)) else {
return Err(HostError::InvalidParams);
};
if len > MAX_FIELD_BYTES {
return Err(HostError::DataFieldTooLarge);
}
let end = ptr.checked_add(len).ok_or(HostError::PointerOutOfBounds)?;
data.get(ptr..end).ok_or(HostError::PointerOutOfBounds)
}
/// [`Region::read`] of the guest's memory, for a call that reads and writes nothing
/// back (`trace`, `trace_num`).
pub(crate) fn read_borrowed<'a>(
caller: &'a Caller<'_, VmState<'_>>,
ptr: i32,
len: i32,
input: Region,
) -> HostResult<&'a [u8]> {
let mem = memory(caller)?;
region(mem.data(caller), ptr, len)
input.read(mem.data(caller))
}
/// Service a call whose answer is bytes, written straight into the guest's output
@@ -97,27 +87,24 @@ pub(crate) fn read_borrowed<'a>(
/// cap, and both checks below are reachable.
pub(crate) fn write_into(
caller: &mut Caller<'_, VmState<'_>>,
dst: i32,
cap: i32,
out: Region,
fill: impl FnOnce(&dyn HostFunctions, &mut [u8]) -> HostResult<usize>,
) -> HostResult<i32> {
let (Ok(dst), Ok(cap)) = (usize::try_from(dst), usize::try_from(cap)) else {
return Err(HostError::InvalidParams);
};
let range = out.range()?;
let cap = range.len();
let mem = memory(caller)?;
let host: &dyn HostFunctions = caller.data().host;
let end = dst.checked_add(cap).ok_or(HostError::PointerOutOfBounds)?;
// Bounds-checked over the guest's whole declared region, so a buffer running
// past memory is a wrong pointer rather than a truncated prefix being served…
let out = mem
let buf = mem
.data_mut(&mut *caller)
.get_mut(dst..end)
.get_mut(range)
.ok_or(HostError::PointerOutOfBounds)?;
// …of which only the field cap is writable, so no call can exceed it whatever
// the guest declared.
let out = &mut out[..cap.min(MAX_FIELD_BYTES)];
let buf = &mut buf[..cap.min(MAX_FIELD_BYTES)];
let n = fill(host, out)?;
let n = fill(host, buf)?;
if n > MAX_FIELD_BYTES {
return Err(HostError::DataFieldTooLarge);
@@ -140,9 +127,9 @@ pub(crate) fn write_into(
/// passed.
///
/// `call` gets the guest's whole memory, so it can borrow any number of input
/// regions with [`region`] — which a `&mut` view of that memory would forbid. That
/// is why the answer goes through a buffer instead of straight into the guest as
/// [`write_into`]'s does.
/// regions with [`Region::read`] — which a `&mut` view of that memory would forbid.
/// That is why the answer goes through a buffer instead of straight into the guest
/// as [`write_into`]'s does.
///
/// **The host is never told the guest's capacity**: it is offered the whole buffer
/// and reports the value's true length, so the fit is decided here, with nothing yet
@@ -151,10 +138,9 @@ pub(crate) fn write_into(
/// The output is judged after the inputs, so a call with both bad reports the
/// input's verdict. `NoMemExported` precedes both: there is no memory to validate a
/// region against.
pub(crate) fn scratch_write(
pub(crate) fn write_buffered(
caller: &mut Caller<'_, VmState<'_>>,
dst: i32,
cap: i32,
out: Region,
call: impl FnOnce(&dyn HostFunctions, &[u8], &mut [u8]) -> HostResult<usize>,
) -> HostResult<i32> {
let mem = memory(caller)?;
@@ -166,21 +152,19 @@ pub(crate) fn scratch_write(
let n = call(host, data, &mut state.out_buffer[..])?;
let (Ok(dst), Ok(cap)) = (usize::try_from(dst), usize::try_from(cap)) else {
return Err(HostError::InvalidParams);
};
// `out` is checked here rather than before the call: the inputs are judged
// first, so a call with both malformed reports the input's verdict.
let range = out.range()?;
let cap = range.len();
if n > MAX_FIELD_BYTES {
return Err(HostError::DataFieldTooLarge);
}
let end = dst.checked_add(cap).ok_or(HostError::PointerOutOfBounds)?;
let out = data
.get_mut(dst..end)
.ok_or(HostError::PointerOutOfBounds)?;
let buf = data.get_mut(range).ok_or(HostError::PointerOutOfBounds)?;
if n > cap {
return Err(HostError::BufferTooSmall);
}
charge_transfer(state, n)?;
out[..n].copy_from_slice(&state.out_buffer[..n]);
buf[..n].copy_from_slice(&state.out_buffer[..n]);
#[expect(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
@@ -190,10 +174,6 @@ pub(crate) fn scratch_write(
Ok(n)
}
// A `Caller` exists only during a host call, so everything above that takes one is
// unreachable from here; `tests/` covers those by running real modules against a
// fake host.
#[cfg(test)]
mod tests {
use super::*;

View File

@@ -16,6 +16,7 @@
)]
mod abi;
mod region;
mod register;
mod vm;

View File

@@ -0,0 +1,50 @@
use crate::vm::MAX_FIELD_BYTES;
use core::ops::Range;
use xrpl_host_functions::{HostError, HostResult};
/// A byte region as the guest declared it: the `(ptr, len)` pair off the wire, not
/// yet checked.
///
/// Every byte parameter in this ABI is such a pair, so pairing them once at the wire
/// boundary is what keeps the helpers in `abi.rs` from each taking two loose integers
/// they could be handed in either order.
///
/// It lives in a module of its own so that the fields are out of reach and
/// [`range`](Region::range) is the *only* way to indices — the check cannot be
/// skipped, only deferred. Construction is infallible for that reason: a call whose
/// output region is malformed is then refused in the order its own helper chooses,
/// rather than at the moment the pair happened to be formed.
#[derive(Copy, Clone)]
pub(crate) struct Region {
ptr: i32,
len: i32,
}
impl Region {
pub(crate) fn new(ptr: i32, len: i32) -> Region {
Region { ptr, len }
}
/// `start..end` as indices. The conversion is the negativity check — it fails on
/// exactly the negative values — and the addition guards a 32-bit `usize`, where
/// two `i32`s can sum past the end.
pub(crate) fn range(self) -> HostResult<Range<usize>> {
let (Ok(start), Ok(len)) = (usize::try_from(self.ptr), usize::try_from(self.len)) else {
return Err(HostError::InvalidParams);
};
let end = start
.checked_add(len)
.ok_or(HostError::PointerOutOfBounds)?;
Ok(start..end)
}
/// The region's bytes, refused past the field cap. No copy: the slice aliases
/// `data`.
pub(crate) fn read(self, data: &[u8]) -> HostResult<&[u8]> {
let range = self.range()?;
if range.len() > MAX_FIELD_BYTES {
return Err(HostError::DataFieldTooLarge);
}
data.get(range).ok_or(HostError::PointerOutOfBounds)
}
}

View File

@@ -1,4 +1,5 @@
use crate::abi::{charged, read_borrowed, region, scratch_write, write_into};
use crate::abi::{charged, read_borrowed, write_buffered, write_into};
use crate::region::Region;
use crate::vm::VmState;
use wasmi::{Caller, Linker};
use xrpl_host_functions::{HostError, HostFunctionSpec};
@@ -29,7 +30,8 @@ pub(crate) fn register_host_functions(
out_len: i32|
-> Result<i32, wasmi::Error> {
charged(&mut caller, HostFunctionSpec::GetLedgerSqn, |c| {
write_into(c, out_ptr, out_len, |host, out| host.get_ledger_sqn(out))
let out = Region::new(out_ptr, out_len);
write_into(c, out, |host, out| host.get_ledger_sqn(out))
})
},
),
@@ -45,7 +47,8 @@ pub(crate) fn register_host_functions(
&mut caller,
HostFunctionSpec::GetCurrentLedgerObjField,
|c| {
write_into(c, out_ptr, out_len, |host, out| {
let out = Region::new(out_ptr, out_len);
write_into(c, out, |host, out| {
host.get_current_ledger_obj_field(field, out)
})
},
@@ -62,9 +65,10 @@ pub(crate) fn register_host_functions(
out_len: i32|
-> Result<i32, wasmi::Error> {
charged(&mut caller, HostFunctionSpec::Sha512Half, |c| {
scratch_write(c, out_ptr, out_len, |host, data, out| {
let input = region(data, data_ptr, data_len)?;
host.sha512_half(input, out)
let out = Region::new(out_ptr, out_len);
let input = Region::new(data_ptr, data_len);
write_buffered(c, out, |host, data, buf| {
host.sha512_half(input.read(data)?, buf)
})
})
},
@@ -81,8 +85,8 @@ pub(crate) fn register_host_functions(
-> Result<i32, wasmi::Error> {
charged(&mut caller, HostFunctionSpec::Trace, |c| {
let host = c.data().host;
let msg = read_borrowed(c, msg_ptr, msg_len)?;
let data = read_borrowed(c, data_ptr, data_len)?;
let msg = read_borrowed(c, Region::new(msg_ptr, msg_len))?;
let data = read_borrowed(c, Region::new(data_ptr, data_len))?;
let msg = core::str::from_utf8(msg).map_err(|_| HostError::Decoding)?;
host.trace(msg, data, as_hex != 0)?;
Ok(0)
@@ -99,7 +103,7 @@ pub(crate) fn register_host_functions(
-> Result<i32, wasmi::Error> {
charged(&mut caller, HostFunctionSpec::TraceNum, |c| {
let host = c.data().host;
let msg = read_borrowed(c, msg_ptr, msg_len)?;
let msg = read_borrowed(c, Region::new(msg_ptr, msg_len))?;
let msg = core::str::from_utf8(msg).map_err(|_| HostError::Decoding)?;
host.trace_num(msg, number)?;
Ok(0)

View File

@@ -55,7 +55,7 @@ pub(crate) struct VmState<'h> {
/// have to resolve per instance: a cached handle would serve a call against the
/// wrong instance's memory, which is a wrong answer rather than an error.
pub(crate) memory: Option<Memory>,
/// Where a host writes a value before [`crate::abi::scratch_write`] copies it
/// Where a host writes a value before [`crate::abi::write_buffered`] copies it
/// to the guest. One buffer per run, so no call zero-fills one of its own.
///
/// Inline rather than boxed: the store's data is built once and then only

View File

@@ -294,7 +294,7 @@ fn both_of_traces_regions_are_checked() {
}
// ---------------------------------------------------------------------------
// Both at once (`scratch_write`, via `sha512_half`)
// Both at once (`write_buffered`, via `sha512_half`)
// ---------------------------------------------------------------------------
/// A call with an input and an output region decides everything about the input
@@ -362,11 +362,11 @@ fn a_read_write_output_obeys_the_write_rules() {
/// A refused value reaches guest memory in no part, however much of it the host
/// wrote. The host answers with 32 bytes it did write and a length it did not, so
/// the refusal happens with the value sitting in the run's scratch — and the
/// the refusal happens with the value sitting in the run's output buffer — and the
/// guest's buffer has to come back untouched.
///
/// Stronger than the contract asks for: a guest must not read its buffer on a
/// negative status. It holds because the scratch is copied to the guest only after
/// negative status. It holds because the buffer is copied to the guest only after
/// the length, the bounds, the fit and the budget have all passed, so there is no
/// window in which a refused value is in guest memory.
#[test]

View File

@@ -351,7 +351,7 @@ recovered `HostFuncWrapper.h` protos:
| shape | count | helper |
| --- | --- | --- |
| ≥1 byte input **and** a byte output | **38** | `scratch_write` |
| ≥1 byte input **and** a byte output | **38** | `write_buffered` |
| byte output only | 9 | `write_into`, unchanged |
| byte inputs only, or scalars | 18 | `read_borrowed` / `region`, unchanged |
@@ -682,7 +682,7 @@ same one-instance-per-run assumption C10's cache would take on.
the *output*" for the census that decided it and for why `MaybeUninit` is not the
answer.
`read_write` is gone, replaced by `scratch_write`, and the input primitive split in
`read_write` is gone, replaced by `write_buffered`, and the input primitive split in
two: `region(data, ptr, len)` does the validation and slicing against a plain `&[u8]`,
and `read_borrowed` is now that over the guest's memory for the calls that read
without writing. Taking bytes rather than a `Caller` is the whole trick — input
@@ -724,7 +724,7 @@ same one-instance-per-run assumption C10's cache would take on.
then only borrowed, so a kilobyte in it costs one move where a `Box` costs an
allocation. **Lazy init is deferred to a benchmark, not rejected.** `Option<[u8; N]>`
with `get_or_insert_with` is the shape (not `OnceCell`, which is for init behind a
shared borrow; `scratch_write` holds `&mut VmState`), and the case against it today is
shared borrow; `write_buffered` holds `&mut VmState`), and the case against it today is
a magnitude argument that a measurement could overturn: it defers one ~1 KiB fill per
run — invisible beside the `Module::new` that starts every run — and pays for it with a
discriminant test on every host call, which is the direction C11 was moving cost away
@@ -1038,6 +1038,48 @@ instead of error text to parse. D16's `gas = 0` decision belongs there too, sinc
a TER choice. Deferred as before: macro-emitted `link_*` shims, the generated C header,
the probe-module test.
## `Region`: the wire's `(ptr, len)` as one type (2026-08-03)
Every byte parameter in the ABI is a `(ptr, len)` pair, so `crates/xrpl-wasm-vm/src/region.rs`
makes the pair a type. `abi.rs`'s helpers take one `Region` where they took two loose
`i32`s, and `register.rs` forms one per wire pair, next to the wasm parameter list where a
reader can check it against the signature.
**What it does and does not check** is the part worth recording, because the obvious
expectation is wrong. It cannot catch a swapped pair: `Region::new(len, ptr)` compiles, and
no type can do better at that boundary — the values arrive as indistinguishable `i32`s in
positional order, so establishing the mapping is a job for a human reading it or for the
deferred shim generator emitting it. What it *does* enforce is that the pair cannot be used
unchecked: `range()` is the only way from a `Region` to indices, and it is where
`InvalidParams` (the conversion is the negativity check) and the end-overflow guard live.
Three copies of that conversion in `abi.rs` became one.
**The type is in a module of its own, and that is load-bearing.** Rust privacy is
module-level, so with `Region` declared in `abi.rs` the helpers there could still read
`out.ptr` and skip `range()` — the invariant would have held by convention only. Separated,
an attempted bypass is `error[E0616]: field ptr of struct Region is private`, which is how
this was verified.
**Construction is infallible on purpose.** Validating in `new` would hoist the output
region's verdict above the host call, and `write_buffered` deliberately judges the inputs
first (`a_read_write_checks_its_input_before_its_output` pins it, including the negative-`dst`
case). Deferring the check to `range()` is what lets the type exist without moving that
order.
Two orderings did shift, both unobservable: `range()` runs its end-overflow guard before
the field-cap check, where `region()` had the cap first, and `write_into` can now answer
`PointerOutOfBounds` before `NoMemExported`. Both need `ptr + len` to overflow `usize`,
which two `i32`s cannot do on a 64-bit target — the guard is there for a 32-bit one.
`Ptr`/`Len` as separate newtypes were considered and dropped. They catch only ptr↔len
confusion, not the mispairing that scales with the ABI, and they cannot reach the wire
either: `wasmi::WasmTy` looks implementable — public, no sealing supertrait — but its bound
names `UntypedVal`, which wasmi re-exports only through a **private** `mod core`
(`lib.rs:109-137`), so the impls cannot be written. Probed: `error[E0603]: module core is
private`. The escape hatch is a direct `wasmi_core` dependency pinned in lockstep with
wasmi's own, plus a `#[doc(hidden)]` method — not worth it on a consensus path, so host
function parameters stay `i32` and are paired on the first line of each arm.
## The comment cut-back (done, 2026-08-03)
**Done over `src/` and `tests/`, after C11 and with C12 deferred to a benchmark** — so no
@@ -1063,8 +1105,10 @@ and `kWasmTransferLimit`, which are where the numbers are defined for the rest o
system and are named in `the_limits_are_the_protocol_limits` for that reason. The parity
evidence itself is not lost — it is in this document, and this is where it belongs.
The `scratch` field is now `VmState::out_buffer`. `abi::scratch_write` keeps its name;
if that reads inconsistently, the rename is a one-liner.
The buffer is `VmState::out_buffer` and the helper that stages through it is
`abi::write_buffered`, beside `abi::write_into` — the two ways a call's byte answer reaches
the guest, verb first in both. "Scratch" survives in this document as the name of the
design, not of anything in the code.
Two TODOs were made honest rather than deleted, since both read as gaps and neither is
one: the start-section TODO now says why wasmi 1.1 cannot close it and that the section is
@@ -1099,7 +1143,7 @@ What it cut:
notes explaining a borrow the compiler already enforces.
- The same rationale on a field and on the function that reads it: `VmState::memory` keeps
the arena-index invariant and `abi::memory` keeps only the two ways it is absent.
- Paragraphs duplicating this document — `scratch_write`'s case for its design went from
- Paragraphs duplicating this document — `write_buffered`'s case for its design went from
five paragraphs to three short ones, the ABI-shape argument left here.
- Every C++ citation, per the rule above.