mirror of
https://github.com/XRPLF/rippled.git
synced 2026-08-21 14:20:56 +00:00
Add preflight to c++ code
This commit is contained in:
@@ -1,11 +1,14 @@
|
||||
//! The cxx bridge between the escrow wasm engine and xrpld.
|
||||
//!
|
||||
//! Two crossings. C++ calls `run_escrow` once per escrow finish; the engine's host
|
||||
//! Three crossings. C++ calls `run_escrow` once per escrow finish; the engine's host
|
||||
//! calls come back out through the C++ `HostContext`, which `CxxHost` presents to the
|
||||
//! engine as an ordinary [`HostFunctions`] implementor. The ABI those calls speak is
|
||||
//! declared once, in `xrpl-host-functions`, so neither side of this file gets to
|
||||
//! restate a signature.
|
||||
//!
|
||||
//! `check_escrow` is the third, and it crosses in one direction only: screening a
|
||||
//! module needs no host, so nothing comes back out.
|
||||
//!
|
||||
//! **Neither direction may unwind into the other**, and the two halves of that are
|
||||
//! not symmetric:
|
||||
//!
|
||||
@@ -26,7 +29,7 @@
|
||||
use std::any::Any;
|
||||
use std::panic::{AssertUnwindSafe, catch_unwind};
|
||||
use xrpl_host_functions::{HostError, HostFunctions, HostResult};
|
||||
use xrpl_wasm_vm::{RunError, RunFailure, RunOutcome, run};
|
||||
use xrpl_wasm_vm::{CheckError, RunError, RunFailure, RunOutcome, check, run};
|
||||
|
||||
/// [`guarded`] must be able to stop an unwind. Under `panic = "abort"` it cannot,
|
||||
/// and every arithmetic overflow in the engine becomes a node crash instead of a
|
||||
@@ -77,6 +80,35 @@ mod ffi {
|
||||
detail: String,
|
||||
}
|
||||
|
||||
/// Why a module cannot be run — one variant per way [`check`] can refuse it,
|
||||
/// so the caller maps a status to a TER rather than reading a message.
|
||||
#[derive(Debug, Hash)]
|
||||
#[repr(i32)]
|
||||
enum CheckStatus {
|
||||
/// The module compiles, imports only what the engine serves, and exports
|
||||
/// the entry point as `() -> i32`.
|
||||
Ok,
|
||||
/// `wasm` is not a valid module under this engine's configuration.
|
||||
Compile,
|
||||
/// An import the engine does not define: another module namespace, a name
|
||||
/// that is not a host function, or one imported as something else.
|
||||
Import,
|
||||
/// No export of that name with signature `() -> i32`.
|
||||
EntryPoint,
|
||||
/// 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.
|
||||
Panic,
|
||||
}
|
||||
|
||||
/// A check's verdict. No cost, because nothing was executed.
|
||||
struct CheckResult {
|
||||
status: CheckStatus,
|
||||
/// The engine's own description of the refusal, for the log. Empty on
|
||||
/// `Ok`.
|
||||
detail: String,
|
||||
}
|
||||
|
||||
extern "Rust" {
|
||||
/// Run `wasm`'s `function_name` export with `gas` fuel, servicing host calls
|
||||
/// through `host`.
|
||||
@@ -89,6 +121,15 @@ mod ffi {
|
||||
/// instruction; the C++ front refuses it as `temBAD_AMOUNT` before calling
|
||||
/// here, so it is not given a status of its own.
|
||||
fn run_escrow(host: &HostContext, wasm: &[u8], gas: u64, function_name: &str) -> RunResult;
|
||||
|
||||
/// Screen `wasm` before it can reach the ledger: whether [`run_escrow`]
|
||||
/// would refuse it before the guest's first instruction.
|
||||
///
|
||||
/// Takes no host, no gas and no store — the verdict comes from the
|
||||
/// compiled module alone, which is what makes it callable from a
|
||||
/// transaction's preflight, where there is no ledger to serve a host call
|
||||
/// from. **Never throws**, for the same reason [`run_escrow`] does not.
|
||||
fn check_escrow(wasm: &[u8], function_name: &str) -> CheckResult;
|
||||
}
|
||||
|
||||
unsafe extern "C++" {
|
||||
@@ -147,6 +188,11 @@ struct CxxHost<'a> {
|
||||
///
|
||||
/// The conversion *is* the sign test — it fails on exactly the negative values — so
|
||||
/// there is no cast to argue about.
|
||||
///
|
||||
/// Named functions rather than `From` impls, and not by preference: every type
|
||||
/// involved — `i32`, `Result`, `HostError` — is foreign to this crate, so the orphan
|
||||
/// rule forbids the impl. Two readings of the same `i32` would want distinguishing
|
||||
/// names here in any case.
|
||||
fn bytes_written(n: i32) -> HostResult<usize> {
|
||||
usize::try_from(n).map_err(|_| HostError::from_code(n))
|
||||
}
|
||||
@@ -187,14 +233,50 @@ fn run_escrow(
|
||||
gas: u64,
|
||||
function_name: &str,
|
||||
) -> ffi::RunResult {
|
||||
guarded(|| {
|
||||
let host = CxxHost { ctx: host };
|
||||
flatten(run(wasm, gas, &host, function_name))
|
||||
})
|
||||
guarded(
|
||||
|| {
|
||||
let host = CxxHost { ctx: host };
|
||||
run(wasm, gas, &host, function_name).into()
|
||||
},
|
||||
ffi::RunResult::panicked,
|
||||
)
|
||||
}
|
||||
|
||||
/// Run `body`, turning a panic into [`ffi::RunStatus::Panic`] rather than letting it
|
||||
/// unwind into C++.
|
||||
fn check_escrow(wasm: &[u8], function_name: &str) -> ffi::CheckResult {
|
||||
guarded(
|
||||
|| check(wasm, function_name).into(),
|
||||
ffi::CheckResult::panicked,
|
||||
)
|
||||
}
|
||||
|
||||
impl ffi::RunResult {
|
||||
/// A run the engine panicked in.
|
||||
///
|
||||
/// The cost is not reported: a panicking run's meter is not evidence of
|
||||
/// anything, and `0` says "unknown" where a number would say "this is what it
|
||||
/// owed".
|
||||
fn panicked(detail: String) -> ffi::RunResult {
|
||||
ffi::RunResult {
|
||||
status: ffi::RunStatus::Panic,
|
||||
result: 0,
|
||||
gas_used: 0,
|
||||
detail,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ffi::CheckResult {
|
||||
/// A check the engine panicked in.
|
||||
fn panicked(detail: String) -> ffi::CheckResult {
|
||||
ffi::CheckResult {
|
||||
status: ffi::CheckStatus::Panic,
|
||||
detail,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `body`, handing a panic to `panicked` rather than letting it unwind into
|
||||
/// C++.
|
||||
///
|
||||
/// **Why catching here is enough.** An unwind can only be caught where every frame
|
||||
/// between the panic and the catch is Rust, and every frame here is: the engine and
|
||||
@@ -207,15 +289,11 @@ fn run_escrow(
|
||||
/// and the one thing that outlives the call — the C++ `HostContext` — is only ever
|
||||
/// touched through those `noexcept` methods, which either complete or report.
|
||||
///
|
||||
/// The cost is not reported. A panicking run's meter is not evidence of anything, and
|
||||
/// `0` says "unknown" where a number would say "this is what it owed".
|
||||
fn guarded(body: impl FnOnce() -> ffi::RunResult) -> ffi::RunResult {
|
||||
catch_unwind(AssertUnwindSafe(body)).unwrap_or_else(|payload| ffi::RunResult {
|
||||
status: ffi::RunStatus::Panic,
|
||||
result: 0,
|
||||
gas_used: 0,
|
||||
detail: panic_detail(&*payload),
|
||||
})
|
||||
/// Generic over the result so both crossings share the one catch: the two answer
|
||||
/// with different structs, and a second `catch_unwind` is the last thing this file
|
||||
/// should have two of.
|
||||
fn guarded<T>(body: impl FnOnce() -> T, panicked: impl FnOnce(String) -> T) -> T {
|
||||
catch_unwind(AssertUnwindSafe(body)).unwrap_or_else(|payload| panicked(panic_detail(&*payload)))
|
||||
}
|
||||
|
||||
/// The panic's message, for the log.
|
||||
@@ -231,23 +309,29 @@ fn panic_detail(payload: &(dyn Any + Send)) -> String {
|
||||
format!("panicked: {message}")
|
||||
}
|
||||
|
||||
/// Flatten the engine's two-channel result onto the one struct cxx can carry.
|
||||
fn flatten(result: Result<RunOutcome, RunFailure>) -> ffi::RunResult {
|
||||
match result {
|
||||
Ok(RunOutcome { result, fuel_used }) => ffi::RunResult {
|
||||
status: ffi::RunStatus::Ok,
|
||||
result,
|
||||
gas_used: fuel_used,
|
||||
detail: String::new(),
|
||||
},
|
||||
// `fuel_used` is carried on both channels by construction, so a failed run
|
||||
// reports its cost here without this having to decide what one is.
|
||||
Err(RunFailure { error, fuel_used }) => ffi::RunResult {
|
||||
status: status_of(&error),
|
||||
result: 0,
|
||||
gas_used: fuel_used,
|
||||
detail: error.to_string(),
|
||||
},
|
||||
/// The engine's two-channel result on the one struct cxx can carry.
|
||||
///
|
||||
/// A `From` rather than a named function because the mapping is total and there is
|
||||
/// only one of it: every field of the wire struct is decided by the outcome, so
|
||||
/// there is no second reading for a name to distinguish.
|
||||
impl From<Result<RunOutcome, RunFailure>> for ffi::RunResult {
|
||||
fn from(result: Result<RunOutcome, RunFailure>) -> ffi::RunResult {
|
||||
match result {
|
||||
Ok(RunOutcome { result, fuel_used }) => ffi::RunResult {
|
||||
status: ffi::RunStatus::Ok,
|
||||
result,
|
||||
gas_used: fuel_used,
|
||||
detail: String::new(),
|
||||
},
|
||||
// `fuel_used` is carried on both channels by construction, so a failed
|
||||
// run reports its cost here without this having to decide what one is.
|
||||
Err(RunFailure { error, fuel_used }) => ffi::RunResult {
|
||||
status: ffi::RunStatus::from(&error),
|
||||
result: 0,
|
||||
gas_used: fuel_used,
|
||||
detail: error.to_string(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,15 +339,45 @@ fn flatten(result: Result<RunOutcome, RunFailure>) -> ffi::RunResult {
|
||||
///
|
||||
/// Exhaustive rather than closed with a wildcard: an outcome added to the engine has
|
||||
/// to be given a status — and therefore a TER on the far side — before this compiles.
|
||||
fn status_of(error: &RunError) -> ffi::RunStatus {
|
||||
match error {
|
||||
RunError::Compile(_) => ffi::RunStatus::Compile,
|
||||
RunError::Instantiate(_) => ffi::RunStatus::Instantiate,
|
||||
RunError::EntryPoint(_) => ffi::RunStatus::EntryPoint,
|
||||
RunError::OutOfGas => ffi::RunStatus::OutOfGas,
|
||||
RunError::Internal => ffi::RunStatus::Internal,
|
||||
RunError::NoMemory => ffi::RunStatus::NoMemory,
|
||||
RunError::Trap(_) => ffi::RunStatus::Trap,
|
||||
impl From<&RunError> for ffi::RunStatus {
|
||||
fn from(error: &RunError) -> ffi::RunStatus {
|
||||
match error {
|
||||
RunError::Compile(_) => ffi::RunStatus::Compile,
|
||||
RunError::Instantiate(_) => ffi::RunStatus::Instantiate,
|
||||
RunError::EntryPoint(_) => ffi::RunStatus::EntryPoint,
|
||||
RunError::OutOfGas => ffi::RunStatus::OutOfGas,
|
||||
RunError::Internal => ffi::RunStatus::Internal,
|
||||
RunError::NoMemory => ffi::RunStatus::NoMemory,
|
||||
RunError::Trap(_) => ffi::RunStatus::Trap,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A verdict on the wire. No cost to carry, so `Ok` is the empty description.
|
||||
impl From<Result<(), CheckError>> for ffi::CheckResult {
|
||||
fn from(result: Result<(), CheckError>) -> ffi::CheckResult {
|
||||
match result {
|
||||
Ok(()) => ffi::CheckResult {
|
||||
status: ffi::CheckStatus::Ok,
|
||||
detail: String::new(),
|
||||
},
|
||||
Err(error) => ffi::CheckResult {
|
||||
status: ffi::CheckStatus::from(&error),
|
||||
detail: error.to_string(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The status a [`CheckError`] crosses as, exhaustive for the same reason
|
||||
/// [`ffi::RunStatus`]'s conversion is.
|
||||
impl From<&CheckError> for ffi::CheckStatus {
|
||||
fn from(error: &CheckError) -> ffi::CheckStatus {
|
||||
match error {
|
||||
CheckError::Compile(_) => ffi::CheckStatus::Compile,
|
||||
CheckError::Import(_) => ffi::CheckStatus::Import,
|
||||
CheckError::EntryPoint(_) => ffi::CheckStatus::EntryPoint,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -275,11 +389,13 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
fn ok(result: i32, fuel_used: u64) -> ffi::RunResult {
|
||||
flatten(Ok(RunOutcome { result, fuel_used }))
|
||||
let outcome: Result<RunOutcome, RunFailure> = Ok(RunOutcome { result, fuel_used });
|
||||
outcome.into()
|
||||
}
|
||||
|
||||
fn failed(error: RunError, fuel_used: u64) -> ffi::RunResult {
|
||||
flatten(Err(RunFailure { error, fuel_used }))
|
||||
let outcome: Result<RunOutcome, RunFailure> = Err(RunFailure { error, fuel_used });
|
||||
outcome.into()
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -303,8 +419,8 @@ mod tests {
|
||||
assert_eq!(crossed.result, 0, "a failed run returned no value");
|
||||
}
|
||||
|
||||
/// The `RunError` set as the test *expects* it, not as `status_of` reports it:
|
||||
/// deriving it from the function under test would make the assertion vacuous.
|
||||
/// The `RunError` set as the test *expects* it, not as the conversion reports it:
|
||||
/// deriving it from the code under test would make the assertion vacuous.
|
||||
fn every_run_error() -> Vec<RunError> {
|
||||
vec![
|
||||
RunError::Compile(String::new()),
|
||||
@@ -323,7 +439,7 @@ mod tests {
|
||||
fn every_run_error_crosses_as_a_status_of_its_own() {
|
||||
let mut seen = Vec::new();
|
||||
for error in every_run_error() {
|
||||
let status = status_of(&error);
|
||||
let status = ffi::RunStatus::from(&error);
|
||||
assert!(
|
||||
!seen.contains(&status),
|
||||
"{error:?} shares {status:?} with an earlier outcome"
|
||||
@@ -337,13 +453,17 @@ mod tests {
|
||||
#[test]
|
||||
fn no_failure_crosses_as_success() {
|
||||
for error in every_run_error() {
|
||||
assert_ne!(status_of(&error), ffi::RunStatus::Ok, "{error:?}");
|
||||
assert_ne!(
|
||||
ffi::RunStatus::from(&error),
|
||||
ffi::RunStatus::Ok,
|
||||
"{error:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_panic_becomes_a_status_instead_of_an_unwind() {
|
||||
let crossed = guarded(|| panic!("the engine came apart"));
|
||||
let crossed = guarded(|| panic!("the engine came apart"), ffi::RunResult::panicked);
|
||||
|
||||
assert_eq!(crossed.status, ffi::RunStatus::Panic);
|
||||
assert_eq!(crossed.detail, "panicked: the engine came apart");
|
||||
@@ -355,14 +475,17 @@ mod tests {
|
||||
#[test]
|
||||
fn a_formatted_panic_keeps_its_message() {
|
||||
let overflowed = 3;
|
||||
let crossed = guarded(|| panic!("gas underflowed by {overflowed}"));
|
||||
let crossed = guarded(
|
||||
|| panic!("gas underflowed by {overflowed}"),
|
||||
ffi::RunResult::panicked,
|
||||
);
|
||||
|
||||
assert_eq!(crossed.detail, "panicked: gas underflowed by 3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_panic_with_no_message_still_reports_one() {
|
||||
let crossed = guarded(|| std::panic::panic_any(7u32));
|
||||
let crossed = guarded(|| std::panic::panic_any(7u32), ffi::RunResult::panicked);
|
||||
|
||||
assert_eq!(crossed.status, ffi::RunStatus::Panic);
|
||||
assert_eq!(crossed.detail, "panicked: payload is not a string");
|
||||
@@ -370,7 +493,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn a_run_that_does_not_panic_is_untouched() {
|
||||
let crossed = guarded(|| ok(1, 2));
|
||||
let crossed = guarded(|| ok(1, 2), ffi::RunResult::panicked);
|
||||
|
||||
assert_eq!(crossed.status, ffi::RunStatus::Ok);
|
||||
assert_eq!(crossed.result, 1);
|
||||
@@ -394,4 +517,88 @@ mod tests {
|
||||
assert_eq!(bytes_written(-1), Err(HostError::Internal));
|
||||
assert_eq!(reported(-1), Err(HostError::Internal));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// The check crossing
|
||||
//
|
||||
// `check_escrow` takes no host, so unlike `run_escrow` it can be called
|
||||
// outright here — the modules are hand-written bytes because this crate has
|
||||
// no assembler and needs none for two of them.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// The smallest valid module: the eight-byte header and nothing else. It
|
||||
/// compiles and imports nothing, so it reaches the entry-point stage.
|
||||
const EMPTY_MODULE: [u8; 8] = [0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00];
|
||||
|
||||
#[test]
|
||||
fn a_module_that_does_not_compile_crosses_as_compile() {
|
||||
let crossed = check_escrow(b"not wasm", "escrow_finish");
|
||||
|
||||
assert_eq!(crossed.status, ffi::CheckStatus::Compile);
|
||||
assert!(
|
||||
crossed.detail.starts_with("compile: "),
|
||||
"{}",
|
||||
crossed.detail
|
||||
);
|
||||
}
|
||||
|
||||
/// The whole crossing, end to end: a real module through the real engine, with
|
||||
/// the refusal the C++ side will log.
|
||||
#[test]
|
||||
fn a_module_without_the_entry_point_crosses_as_entry_point() {
|
||||
let crossed = check_escrow(&EMPTY_MODULE, "escrow_finish");
|
||||
|
||||
assert_eq!(crossed.status, ffi::CheckStatus::EntryPoint);
|
||||
assert_eq!(crossed.detail, "no entry point 'escrow_finish'");
|
||||
}
|
||||
|
||||
/// The `CheckError` set as the test *expects* it, not as the conversion reports
|
||||
/// it: deriving it from the code under test would make the assertion vacuous.
|
||||
fn every_check_error() -> Vec<CheckError> {
|
||||
vec![
|
||||
CheckError::Compile(String::new()),
|
||||
CheckError::Import(String::new()),
|
||||
CheckError::EntryPoint(String::new()),
|
||||
]
|
||||
}
|
||||
|
||||
/// Distinct statuses, because the TER map on the far side reads nothing else.
|
||||
#[test]
|
||||
fn every_check_error_crosses_as_a_status_of_its_own() {
|
||||
let mut seen = Vec::new();
|
||||
for error in every_check_error() {
|
||||
let status = ffi::CheckStatus::from(&error);
|
||||
assert!(
|
||||
!seen.contains(&status),
|
||||
"{error:?} shares {status:?} with an earlier refusal"
|
||||
);
|
||||
seen.push(status);
|
||||
}
|
||||
}
|
||||
|
||||
/// `Ok` is the one status no refusal may take: the far side reads it as
|
||||
/// `tesSUCCESS` and would let the module through.
|
||||
#[test]
|
||||
fn no_refusal_crosses_as_success() {
|
||||
for error in every_check_error() {
|
||||
assert_ne!(
|
||||
ffi::CheckStatus::from(&error),
|
||||
ffi::CheckStatus::Ok,
|
||||
"{error:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A panic during a check is its own status rather than one more malformed
|
||||
/// module: the far side answers a node-local failure, not `temBAD_WASM`.
|
||||
#[test]
|
||||
fn a_panic_during_a_check_becomes_a_status_instead_of_an_unwind() {
|
||||
let crossed = guarded(
|
||||
|| panic!("the checker came apart"),
|
||||
ffi::CheckResult::panicked,
|
||||
);
|
||||
|
||||
assert_eq!(crossed.status, ffi::CheckStatus::Panic);
|
||||
assert_eq!(crossed.detail, "panicked: the checker came apart");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,34 +54,39 @@ pub fn check(wasm: &[u8], function_name: &str) -> Result<(), CheckError> {
|
||||
check_entry_point(&module, function_name)
|
||||
}
|
||||
|
||||
/// Every import must be one the linker defines.
|
||||
///
|
||||
/// The set is [`HostFunctionSpec::ALL`], which is also what
|
||||
/// [`crate::register::register_host_functions`] iterates — so a check and a run
|
||||
/// cannot disagree about which names exist, and adding a host function extends
|
||||
/// both at once. The one thing this does not compare is the *signature*, which
|
||||
/// still parts a module from the engine at instantiation.
|
||||
/// Every import must be one the linker defines. The first that is not ends the
|
||||
/// check, so a module with several faults reports the earliest.
|
||||
fn check_imports(module: &Module) -> Result<(), CheckError> {
|
||||
for import in module.imports() {
|
||||
let name = import.name();
|
||||
check_import(import.module(), import.name(), import.ty()).map_err(CheckError::Import)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
if import.module() != HOST_MODULE {
|
||||
return Err(CheckError::Import(format!(
|
||||
"'{}::{name}' is not from '{HOST_MODULE}'",
|
||||
import.module()
|
||||
)));
|
||||
}
|
||||
if !HostFunctionSpec::ALL
|
||||
.iter()
|
||||
.any(|op| op.wasm_name() == name)
|
||||
{
|
||||
return Err(CheckError::Import(format!("no host function '{name}'")));
|
||||
}
|
||||
if !matches!(import.ty(), ExternType::Func(_)) {
|
||||
return Err(CheckError::Import(format!(
|
||||
"'{HOST_MODULE}::{name}' is not a function"
|
||||
)));
|
||||
}
|
||||
/// Whether the engine defines this one import.
|
||||
///
|
||||
/// The set of names is [`HostFunctionSpec::ALL`], which is also what
|
||||
/// [`crate::register::register_host_functions`] iterates — so a check and a run
|
||||
/// cannot disagree about which names exist, and adding a host function extends
|
||||
/// both at once. The one thing this does not compare is `ty`'s *signature*, which
|
||||
/// still parts a module from the engine at instantiation; the kind is compared
|
||||
/// because the engine defines these names as functions and as nothing else.
|
||||
///
|
||||
/// The rules are ordered, not merely alternatives: a guest importing `env::malloc`
|
||||
/// is told about the namespace rather than that `malloc` is not a host function,
|
||||
/// because the namespace is the one that explains every other import it has too.
|
||||
fn check_import(module: &str, name: &str, ty: &ExternType) -> Result<(), String> {
|
||||
if module != HOST_MODULE {
|
||||
return Err(format!("'{module}::{name}' is not from '{HOST_MODULE}'"));
|
||||
}
|
||||
if !HostFunctionSpec::ALL
|
||||
.iter()
|
||||
.any(|op| op.wasm_name() == name)
|
||||
{
|
||||
return Err(format!("no host function '{name}'"));
|
||||
}
|
||||
if !matches!(ty, ExternType::Func(_)) {
|
||||
return Err(format!("'{HOST_MODULE}::{name}' is not a function"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -112,9 +117,97 @@ pub(crate) fn entry_point_fault(found: Option<ExternType>, name: &str) -> String
|
||||
}
|
||||
}
|
||||
|
||||
/// The rules, one by one, on inputs built directly rather than parsed out of a
|
||||
/// module. `tests/preflight.rs` runs real modules through [`check`]; what is here is
|
||||
/// what a module cannot state precisely — which rule fires, in which order, and in
|
||||
/// what words the caller logs it.
|
||||
///
|
||||
/// `wat` is a dev-dependency, so the one test here that does need a module writes it
|
||||
/// as text like every other test in the crate. What the library must not gain is a
|
||||
/// text *entry point* — `check` and `run` take binaries — and a `cfg(test)` caller
|
||||
/// cannot give it one.
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use wasmi::{GlobalType, MemoryType, Mutability};
|
||||
|
||||
/// A host function as a guest declares it. Any function type will do: the
|
||||
/// signature is not what [`check_import`] compares.
|
||||
fn a_function() -> ExternType {
|
||||
ExternType::Func(FuncType::new([ValType::I32], [ValType::I32]))
|
||||
}
|
||||
|
||||
/// A name every one of these tests can use, taken from the ABI rather than
|
||||
/// spelled, so it stays a real host function as the ABI changes.
|
||||
fn a_host_function_name() -> &'static str {
|
||||
HostFunctionSpec::ALL[0].wasm_name()
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Imports
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Every name the ABI declares is served. Derived from `ALL` rather than
|
||||
/// listed, so a host function added to the ABI is covered the day it lands.
|
||||
#[test]
|
||||
fn every_declared_host_function_is_served() {
|
||||
for op in HostFunctionSpec::ALL {
|
||||
assert_eq!(
|
||||
check_import(HOST_MODULE, op.wasm_name(), &a_function()),
|
||||
Ok(()),
|
||||
"{}",
|
||||
op.wasm_name()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_import_from_another_namespace_is_refused() {
|
||||
for namespace in ["env", "host", "host_lib2", ""] {
|
||||
let refusal = check_import(namespace, a_host_function_name(), &a_function())
|
||||
.expect_err(namespace);
|
||||
assert!(
|
||||
refusal.contains("is not from 'host_lib'"),
|
||||
"{namespace}: {refusal}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_name_is_refused() {
|
||||
let refusal =
|
||||
check_import(HOST_MODULE, "no_such_function", &a_function()).expect_err("unknown name");
|
||||
assert_eq!(refusal, "no host function 'no_such_function'");
|
||||
}
|
||||
|
||||
/// The engine defines these names as functions and as nothing else, so a module
|
||||
/// importing one as a global or a memory does not link either.
|
||||
#[test]
|
||||
fn a_host_function_imported_as_anything_else_is_refused() {
|
||||
for ty in [
|
||||
ExternType::Global(GlobalType::new(ValType::I32, Mutability::Const)),
|
||||
ExternType::Memory(MemoryType::new(1, None)),
|
||||
] {
|
||||
let name = a_host_function_name();
|
||||
let refusal = check_import(HOST_MODULE, name, &ty).expect_err("not a function");
|
||||
assert_eq!(refusal, format!("'host_lib::{name}' is not a function"));
|
||||
}
|
||||
}
|
||||
|
||||
/// The rules are ordered. An import that breaks two of them is reported by the
|
||||
/// first, so the message a contract author reads is the one that explains the
|
||||
/// rest of their imports too.
|
||||
#[test]
|
||||
fn the_namespace_is_reported_before_the_name() {
|
||||
let refusal = check_import("env", "no_such_function", &a_function())
|
||||
.expect_err("neither the namespace nor the name is served");
|
||||
|
||||
assert!(refusal.contains("is not from 'host_lib'"), "{refusal}");
|
||||
assert!(
|
||||
!refusal.contains("no host function"),
|
||||
"the namespace explains it: {refusal}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Both halves of the type are load-bearing, and neither is checked anywhere
|
||||
/// a module cannot reach.
|
||||
@@ -131,4 +224,64 @@ mod tests {
|
||||
assert!(!is_entry_point(&wrong), "{wrong:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Three faults, three descriptions. A run reports these too, with wasmi's own
|
||||
/// error appended, so a swapped arm would mislead at both stages at once.
|
||||
#[test]
|
||||
fn each_entry_point_fault_is_described_as_itself() {
|
||||
assert_eq!(
|
||||
entry_point_fault(Some(a_function()), "finish"),
|
||||
"entry point 'finish' has the wrong signature, expected '() -> i32'"
|
||||
);
|
||||
assert_eq!(
|
||||
entry_point_fault(
|
||||
Some(ExternType::Global(GlobalType::new(
|
||||
ValType::I32,
|
||||
Mutability::Const
|
||||
))),
|
||||
"finish"
|
||||
),
|
||||
"export 'finish' is not a function"
|
||||
);
|
||||
assert_eq!(
|
||||
entry_point_fault(None, "finish"),
|
||||
"no entry point 'finish'",
|
||||
"an absent export must not be reported as a wrong signature"
|
||||
);
|
||||
}
|
||||
|
||||
/// 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]
|
||||
fn a_refusal_names_its_stage() {
|
||||
assert_eq!(
|
||||
CheckError::Compile("bad magic".to_string()).to_string(),
|
||||
"compile: bad magic"
|
||||
);
|
||||
assert_eq!(
|
||||
CheckError::Import("no host function 'x'".to_string()).to_string(),
|
||||
"import: no host function 'x'"
|
||||
);
|
||||
// The entry point's detail already says which of its three faults it is,
|
||||
// so a prefix would only repeat it.
|
||||
assert_eq!(
|
||||
CheckError::EntryPoint("no entry point 'finish'".to_string()).to_string(),
|
||||
"no entry point 'finish'"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_stages_run_in_order() {
|
||||
assert!(
|
||||
matches!(check(b"not wasm", "finish"), Err(CheckError::Compile(_))),
|
||||
"nothing is screened until the module compiles"
|
||||
);
|
||||
|
||||
// A module that compiles and imports nothing, so it reaches the entry point.
|
||||
let empty = wat::parse_str("(module)").expect("assembles");
|
||||
assert!(
|
||||
matches!(check(&empty, "finish"), Err(CheckError::EntryPoint(_))),
|
||||
"a module that compiles and imports nothing reaches the entry point"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,6 +174,21 @@ fn a_host_function_imported_as_a_global_does_not_pass() {
|
||||
);
|
||||
}
|
||||
|
||||
/// A module faulty at two stages is refused by the earlier one — it imports what no
|
||||
/// engine serves *and* exports no entry point. The imports are what the rest of the
|
||||
/// module depends on, so that is the message worth having.
|
||||
#[test]
|
||||
fn the_earlier_stage_is_the_one_reported() {
|
||||
let refusal = refusal(
|
||||
r#"(module
|
||||
(import "host_lib" "no_such_function" (func $f (result i32)))
|
||||
(memory (export "memory") 1)
|
||||
(func (export "not_the_entry_point") (result i32) (call $f)))"#,
|
||||
);
|
||||
|
||||
assert_stage!(refusal, CheckError::Import(_));
|
||||
}
|
||||
|
||||
/// The signature is the one part of an import screening does not compare, so a
|
||||
/// module that will not link can still pass. Recorded here because it is the gap
|
||||
/// this stage leaves, not because it is wanted.
|
||||
|
||||
@@ -5,6 +5,11 @@
|
||||
`crates/xrpl-wasm-vm-ffi/src/lib.rs` is the whole of the Rust half; `HostContext.{h,cpp}` and
|
||||
`WasmVM.{h,cpp}` are the C++ half. Two decisions carry the design.
|
||||
|
||||
Three crossings, not two: `run_escrow` in, the host calls back out, and `check_escrow`
|
||||
in. The third goes one way only — screening a module needs no host — so it takes no
|
||||
`HostContext`, has no C++-exception half to contain, and is the one bridge function the
|
||||
crate's own tests can call outright.
|
||||
|
||||
**The result is total, not `Result<T>`.** cxx's `Result` sugar throws a `rust::Error` into
|
||||
C++; a status is the better interface for a condition the caller has to turn into a TER
|
||||
anyway. `RunResult { status, result, gas_used, detail }` flattens the engine's
|
||||
@@ -26,6 +31,12 @@ given a status *and* a TER.
|
||||
- The asymmetry is what makes each half sufficient: because the C++ shims never unwind,
|
||||
every frame between a panic and `catch_unwind` is Rust.
|
||||
|
||||
Both halves are named `guarded`, and each is one function that every crossing goes through:
|
||||
Rust's takes the panic arm as an argument (`ffi::RunResult::panicked`), C++'s takes the value
|
||||
to answer with if the call throws. Anything C++ catches there is xrpld's own — a bad
|
||||
allocation, or a `funcName` that is not valid UTF-8 and so cannot become a `rust::Str` —
|
||||
never a wasm outcome, since those arrive as statuses.
|
||||
|
||||
`HostContext` holds a `HostFunctions&` and lowers its typed `std::expected` onto the wire.
|
||||
The `&self`-vs-non-const worry was a non-issue: a `const` member function holding a
|
||||
non-const reference can still call `cacheLedgerObj`/`updateData`. `cxx_name` on each method
|
||||
@@ -53,13 +64,55 @@ node's, and charging a transaction for a node's defect would write that defect i
|
||||
| `Internal`, `Panic` | `tecINTERNAL` | none |
|
||||
|
||||
The `Compile`/`Instantiate`/`EntryPoint` row is `tecINTERNAL` because preflight is meant to
|
||||
have refused such a module with `temBAD_WASM` long before apply — which is why preflight is
|
||||
item 1 in [the roadmap](index.md#next). `NoMemory` had no old TER to match (it used to reach
|
||||
the guest as code -14); `tecFAILED_PROCESSING` treats it as the contract fault it is.
|
||||
have refused such a module with `temBAD_WASM` long before apply. **That row is now known to
|
||||
be wrong for `Instantiate`** — see below. `NoMemory` had no old TER to match (it used to
|
||||
reach the guest as code -14); `tecFAILED_PROCESSING` treats it as the contract fault it is.
|
||||
|
||||
`gas <= 0` is refused as `temBAD_AMOUNT` before the engine is called, restoring what
|
||||
`WasmiEngine::run` did — see [open-questions.md](open-questions.md).
|
||||
|
||||
## The preflight map
|
||||
|
||||
`preflightEscrowWasm` owns it, and it is deliberately flat: every fault in the module is
|
||||
one answer, because a caller's only decision is whether the transaction may proceed.
|
||||
|
||||
| `CheckStatus` | `NotTEC` |
|
||||
|---|---|
|
||||
| `Ok` | `tesSUCCESS` |
|
||||
| `Compile`, `Import`, `EntryPoint` | `temBAD_WASM` |
|
||||
| `Panic` | `telFAILED_PROCESSING` |
|
||||
|
||||
The statuses stay distinct anyway: the *detail* is what a contract author needs, and one
|
||||
status per stage keeps the map's arms reviewable and lets it grow without inventing
|
||||
distinctions later.
|
||||
|
||||
`Panic` is not `temBAD_WASM`. A defect in the engine teaches nothing about the module, and
|
||||
`tem` would record our bug as the transaction's malformation; `tel` is the preflight
|
||||
analogue of `tecINTERNAL`'s "the fault is the node's" — local, not forwarded, no fee. Two
|
||||
things follow that are worth stating: divergence between nodes is not what the code choice
|
||||
fixes (a panic in deterministic code is not node-local, and if it were, no TER would
|
||||
reconcile the two), and this arm has no test on the C++ side, because there is no reliable
|
||||
way to make the engine panic from a fixture.
|
||||
|
||||
**The signature the C++ front does not have is the point**: `(Bytes, beast::Journal,
|
||||
std::string_view) -> NotTEC`, with no `HostFunctions&`. The deleted `preflightEscrowWasm`
|
||||
took one and could therefore never have been called from a real `preflight()` —
|
||||
`PreflightContext` has no view to build a host over.
|
||||
|
||||
## Why `Instantiate` should stop being `tecINTERNAL`
|
||||
|
||||
`check` closes compile, imports and the entry point, but two ways instantiation fails are
|
||||
invisible to it: a start section that traps, and a linear memory over the page cap that the
|
||||
module does not export ([engine.md](engine.md)). Both are deterministic properties of the
|
||||
module, so a contract can pass preflight, be escrowed, and then fail to instantiate at
|
||||
apply — where the map currently blames the node and charges nothing.
|
||||
|
||||
The fix is two lines and its own change: report `Instantiate` as `tecFAILED_PROCESSING`
|
||||
with its gas, and in `vm::run` classify a failure carrying a trap code (`e.as_trap_code()`)
|
||||
as `Trap` rather than `Instantiate`, since a start section trapping is guest code trapping.
|
||||
`tecINTERNAL` then means what it says — `Internal` and `Panic`, the node's own defects — and
|
||||
the map stops depending on preflight's completeness for its correctness.
|
||||
|
||||
## The one copy left on the byte path, and why it needs `HostFunctions` to change
|
||||
|
||||
The engine's side of the byte path is copy-free by construction — `write_into` hands the host
|
||||
|
||||
@@ -122,6 +122,13 @@ so the configuration that decides validity cannot differ. The import set is
|
||||
host function extends the check and the linker at once. And the entry point's three faults are
|
||||
described by one `entry_point_fault`, called from `run` with wasmi's error appended.
|
||||
|
||||
The rules themselves are pure functions over what a module *declares* — `check_import` takes
|
||||
`(namespace, name, ExternType)`, `entry_point_fault` takes an `Option<ExternType>` — so the
|
||||
unit tests state each rule, its precedence and its wording on inputs built directly, and
|
||||
`tests/preflight.rs` is left to run real modules. Precedence is a decision, not an accident:
|
||||
an import breaking two rules reports the namespace, which is what explains the module's other
|
||||
imports too.
|
||||
|
||||
What it cannot see is guest behaviour and anything absent from the module's exports: a start
|
||||
section that traps, and a linear memory over the page cap that the module keeps to itself.
|
||||
Both pass the check and then fail instantiation, which is why a run's own refusal at that
|
||||
|
||||
@@ -49,8 +49,9 @@ than guessing. See [history.md](history.md) for what is worth recovering.
|
||||
(`check` — compile, imports, entry point, with no host, store or gas), `abi.rs` (gas,
|
||||
transfer budget, guest-memory marshaling), `region.rs` (the `(ptr, len)` type),
|
||||
`register.rs` (one `func_wrap` per host function). See [engine.md](engine.md).
|
||||
- `xrpl-wasm-vm-ffi/` — the cxx bridge, both crossings. `RunStatus`/`RunResult`,
|
||||
`run_escrow`, `CxxHost`, the panic guard. See [bridge.md](bridge.md).
|
||||
- `xrpl-wasm-vm-ffi/` — the cxx bridge, all three crossings. `RunStatus`/`RunResult` and
|
||||
`run_escrow`, `CheckStatus`/`CheckResult` and `check_escrow`, `CxxHost`, the panic
|
||||
guard. See [bridge.md](bridge.md).
|
||||
- `xrpl-wasm-testkit/` — **test-only**: `compile_wat`, so the C++ tests write their modules
|
||||
as WebAssembly text. A crate of its own so `wat` cannot reach the shipped node; see
|
||||
[testing.md](testing.md).
|
||||
@@ -58,7 +59,8 @@ than guessing. See [history.md](history.md) for what is worth recovering.
|
||||
`HostFunctions` interface), `HostFuncImpl*.cpp` (its implementations, over
|
||||
`ApplyContext&`), `WasmCommon.h` (`HostFunctionError`, `Wmem`, `WasmTER`, `FieldLocator`).
|
||||
The bridge's C++ half is `HostContext.{h,cpp}` (the ABI-shaped view of `HostFunctions`)
|
||||
and `WasmVM.{h,cpp}` (`runEscrowWasm`, gas validation, the TER map).
|
||||
and `WasmVM.{h,cpp}` (`runEscrowWasm`, `preflightEscrowWasm`, gas validation, both TER
|
||||
maps).
|
||||
- `src/tests/libxrpl/tx/wasm/` — the C++ tests, in the `xrpl_tests` gtest binary.
|
||||
- `include/xrpl/tx/wasm/README.md` is **stale**: it uses the long name `get_ledger_sqn`
|
||||
where the code registers `ldgr_index`, and references `detail/WasmVM.cpp`,
|
||||
@@ -68,15 +70,17 @@ than guessing. See [history.md](history.md) for what is worth recovering.
|
||||
## Current state (2026-08-04)
|
||||
|
||||
**The whole workspace is green**: `cargo test --workspace`, `clippy --workspace
|
||||
--all-targets`, `fmt`, and `cargo doc -p xrpl-wasm-vm --no-deps`. **154 tests** — 33 macro,
|
||||
12 facade, 1 doctest, **96 in `xrpl-wasm-vm`** (11 unit; 85 integration — 13 `budgets`,
|
||||
12 `host_calls`, 23 `memory_policy`, 16 `preflight`, 21 `vm_limits`), 10 in
|
||||
`xrpl-wasm-vm-ffi`, 2 in `xrpl-wasm-testkit`. On the C++ side, **27 tests over the whole
|
||||
loop** in six fixtures: `./xrpl_tests --gtest_filter='WasmVMTest.*:*Call.*'`.
|
||||
--all-targets`, `fmt`, and `cargo doc -p xrpl-wasm-vm --no-deps`. **168 tests** — 33 macro,
|
||||
12 facade, 1 doctest, **105 in `xrpl-wasm-vm`** (19 unit; 86 integration — 13 `budgets`,
|
||||
12 `host_calls`, 23 `memory_policy`, 17 `preflight`, 21 `vm_limits`), 15 in
|
||||
`xrpl-wasm-vm-ffi`, 2 in `xrpl-wasm-testkit`. On the C++ side, **37 tests over the whole
|
||||
loop** in seven fixtures: `./xrpl_tests
|
||||
--gtest_filter='WasmVMTest.*:*Call.*:PreflightTest.*'`.
|
||||
|
||||
**Both crossings are wired and a real contract runs through them**: C++ calls
|
||||
**All three crossings are wired and a real contract runs through them**: C++ calls
|
||||
`runEscrowWasm`, the engine services `ldgr_index` by calling back into
|
||||
`xrpl::HostFunctions`, and the guest reads the answer out of its own memory. Five host
|
||||
`xrpl::HostFunctions`, and the guest reads the answer out of its own memory;
|
||||
`preflightEscrowWasm` screens a module through the third, with no host at all. Five host
|
||||
functions are registered (`ldgr_index`, `home_le_field`, `sha512_half`, `trace`,
|
||||
`trace_num`) out of the ~65 the full ABI will carry.
|
||||
|
||||
@@ -85,23 +89,29 @@ functions are registered (`ldgr_index`, `home_le_field`, `sha512_half`, `trace`,
|
||||
|
||||
## Next
|
||||
|
||||
1. **`preflightEscrowWasm`.** The engine half is done — `check` in `preflight.rs`. What is
|
||||
left is the second bridge entry (`check_escrow`, a `CheckStatus`/`CheckResult` pair
|
||||
mirroring `RunStatus`/`RunResult`) and the C++ front, whose signature is
|
||||
`(Bytes, beast::Journal, std::string_view) -> NotTEC`: **no `HostFunctions&`**, since a
|
||||
check needs no host and a `PreflightContext` has no ledger to build one from.
|
||||
Two decisions are still open — what a *panic* at preflight returns
|
||||
(`telFAILED_PROCESSING` reads as the preflight analogue of `tecINTERNAL`'s "the fault is
|
||||
the node's"), and whether the apply-side map moves `Instantiate` off `tecINTERNAL` in the
|
||||
same change; see [bridge.md](bridge.md).
|
||||
2. **A caller.** `EscrowFinish.cpp` still has no wasm reference, so `runEscrowWasm` is
|
||||
reached only from `src/tests/libxrpl/tx/wasm/`. Wiring it up is what makes
|
||||
`WasmHostFunctionsImpl` (over a real `ApplyContext`) the host in production rather than
|
||||
in principle.
|
||||
3. **A gas parity oracle.** `Wasm_test.cpp` asserts exact gas numbers (e.g. 29'502) and is
|
||||
1. **Move `Instantiate` off `tecINTERNAL`**, and report a trapping start section as `Trap`
|
||||
rather than as a module that would not instantiate. Two lines plus their tests, and it is
|
||||
what actually removes the papering-over: `check` cannot see either of the two remaining
|
||||
instantiate faults, so the apply-side map must stop depending on preflight's
|
||||
completeness. [bridge.md](bridge.md) has the reasoning.
|
||||
2. **A caller.** `EscrowFinish.cpp` still has no wasm reference, so `runEscrowWasm` and
|
||||
`preflightEscrowWasm` are reached only from `src/tests/libxrpl/tx/wasm/`. Wiring it up is
|
||||
what makes `WasmHostFunctionsImpl` (over a real `ApplyContext`) the host in production
|
||||
rather than in principle. **Blocked on the protocol fields**: `FinishFunction` and
|
||||
`ComputationAllowance` exist nowhere in this fork or upstream, and adding
|
||||
`FinishFunction` also has to answer the **contract code-size cap** — there is none, and
|
||||
preflight's cost is linear in the blob (a 249 KB module of duplicate imports measures
|
||||
1.5 ms, mostly wasmi's own parse).
|
||||
3. **Import signatures at preflight.** `check` compares an import's namespace, name and
|
||||
kind, not its type, so a mistyped import still parts a module from the engine at
|
||||
instantiation. Deferred to when `host_functions!` generates the wasm-level lowering,
|
||||
which the C header and the typed `link_*` shims in [abi.md](abi.md) both want anyway.
|
||||
Note the deleted C++ `check` did not compare signatures either, so this is inherited
|
||||
rather than new.
|
||||
4. **A gas parity oracle.** `Wasm_test.cpp` asserts exact gas numbers (e.g. 29'502) and is
|
||||
the best oracle we have, but it is commented out and its fixtures cannot run on this
|
||||
engine — see the `env` finding in [testing.md](testing.md).
|
||||
4. **The `Bytes`-by-value copy in `HostFunctions`** — [bridge.md](bridge.md). A
|
||||
5. **The `Bytes`-by-value copy in `HostFunctions`** — [bridge.md](bridge.md). A
|
||||
49-signature sweep, so it wants a caller to measure against first.
|
||||
|
||||
Also open: the two performance items and the ABI questions in
|
||||
|
||||
@@ -39,6 +39,9 @@
|
||||
(`let f: fn(&ffi::HostContext) -> _ = ...`) fails with `Undefined symbols:
|
||||
_rs$wasm_vm$cxxbridge1$…`. So keep those tests on pure logic — the status map, the panic
|
||||
guard, the wire conversions — and put anything that needs a host in the gtest.
|
||||
**`check_escrow` is the exception**: it takes no `HostContext`, so its tests call the real
|
||||
bridge function, hand-writing the two modules they need as bytes (the eight-byte header is
|
||||
a valid module) rather than reaching for an assembler this crate does not have.
|
||||
|
||||
## The Rust tests
|
||||
|
||||
@@ -99,6 +102,13 @@ Then one fixture per host function — `LedgerSqnCall`, `CurrentLedgerObjFieldCa
|
||||
`Sha512HalfCall`, `TraceCall`, `TraceNumCall` — because the module *is* that function's shared
|
||||
setup. `WasmVMTest` keeps what belongs to the engine rather than to any function.
|
||||
|
||||
**`PreflightTest` deliberately derives from `testing::Test`, not from `WasmTest`**, and holds
|
||||
no mock: `preflightEscrowWasm` takes no host, and a fixture that supplied one would hide the
|
||||
signature that is the point. That is why `assembleWat` is a free function in `WasmFixture.h`
|
||||
rather than a `WasmTest` member. `PreflightTest.ScreeningAgreesWithARun` is the one test
|
||||
there that does build a host — it puts the same modules through `runEscrowWasm` so the two
|
||||
entry points do not have to be trusted to agree.
|
||||
|
||||
**The journal is captured, not sent to a null sink.**
|
||||
`WasmVMTest.ThrowingHostFunctionBecomesInternal` asserts the exception text *and* that the log
|
||||
names `getLedgerSqn`; without that, an exception silently swallowed with no log would pass, and
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
#pragma once
|
||||
|
||||
#include <xrpl/beast/utility/Journal.h>
|
||||
#include <xrpl/protocol/TER.h>
|
||||
#include <xrpl/tx/wasm/HostFunc.h>
|
||||
#include <xrpl/tx/wasm/WasmCommon.h>
|
||||
|
||||
@@ -30,4 +32,24 @@ runEscrowWasm(
|
||||
std::int64_t gasLimit,
|
||||
std::string_view funcName = escrowFunctionName);
|
||||
|
||||
// Screen `wasmCode`: whether `runEscrowWasm` would refuse it before the contract's
|
||||
// first instruction. Compiles the module and reads its imports and exports; runs
|
||||
// nothing.
|
||||
//
|
||||
// Takes no `HostFunctions`, because the verdict comes from the compiled module alone.
|
||||
// That is what makes this callable from a transactor's `preflight`, which has no view
|
||||
// to build a host over.
|
||||
//
|
||||
// `temBAD_WASM` for every fault in the module - the transaction carries something this
|
||||
// engine cannot run, so it is refused before it can reach the ledger.
|
||||
// `telFAILED_PROCESSING` if the engine itself failed: nothing was learned about the
|
||||
// module, and a defect here is not evidence that the transaction is malformed.
|
||||
//
|
||||
// Does not throw.
|
||||
NotTEC
|
||||
preflightEscrowWasm(
|
||||
Bytes const& wasmCode,
|
||||
beast::Journal j,
|
||||
std::string_view funcName = escrowFunctionName);
|
||||
|
||||
} // namespace xrpl
|
||||
|
||||
@@ -14,12 +14,14 @@
|
||||
#include <expected>
|
||||
#include <optional>
|
||||
#include <string_view>
|
||||
#include <type_traits>
|
||||
|
||||
namespace xrpl {
|
||||
|
||||
namespace {
|
||||
|
||||
using RunStatus = rs::wasm_vm::RunStatus;
|
||||
using CheckStatus = rs::wasm_vm::CheckStatus;
|
||||
|
||||
// The engine's outcome as the caller's: a value with its cost, or a TER with the cost to
|
||||
// record beside it.
|
||||
@@ -65,9 +67,69 @@ outcome(rs::wasm_vm::RunResult const& run)
|
||||
case RunStatus::Panic:
|
||||
return std::unexpected(WasmTER{.ter = tecINTERNAL, .cost = std::nullopt});
|
||||
}
|
||||
std::unreachable();
|
||||
}
|
||||
|
||||
// Not reachable through the enum, but a value outside it is representable.
|
||||
return std::unexpected(WasmTER{.ter = tecINTERNAL, .cost = std::nullopt});
|
||||
// Call into the engine, answering `onThrow` if the call throws.
|
||||
//
|
||||
// The engine reports every outcome as a status rather than an exception, so anything
|
||||
// caught here is xrpld's own: a bad allocation, or a `funcName` that is not valid UTF-8
|
||||
// and so cannot become a `rust::Str`. Both entry points answer such a failure the way
|
||||
// they answer a defect in the engine itself.
|
||||
//
|
||||
// The counterpart of the engine's own `guarded`, which stops a Rust panic on the other
|
||||
// side of the bridge. Neither side may unwind into the other, and this is this side's
|
||||
// half: the reason `HostContext`'s methods are `noexcept` rather than relying on cxx is
|
||||
// documented in `docs/claude/wasm-vm/bridge.md`.
|
||||
template <class Call>
|
||||
std::invoke_result_t<Call>
|
||||
guarded(beast::Journal j, std::invoke_result_t<Call> onThrow, Call&& call)
|
||||
{
|
||||
try
|
||||
{
|
||||
return call();
|
||||
}
|
||||
catch (std::exception const& e)
|
||||
{
|
||||
JLOG(j.error()) << "wasm: engine call threw: " << e.what();
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
JLOG(j.error()) << "wasm: engine call threw a non-exception";
|
||||
}
|
||||
|
||||
return onThrow;
|
||||
}
|
||||
|
||||
// A screening verdict as a TER.
|
||||
//
|
||||
// `temBAD_WASM` says the transaction carries something this engine cannot run: a
|
||||
// malformed transaction, refused before it can reach the ledger. A panic inside the
|
||||
// engine is different in kind - nothing was learned about the module - so the answer is
|
||||
// node-local rather than a claim about the transaction.
|
||||
//
|
||||
// Exhaustive over the status enum, with no `default`, for the same reason `outcome` is.
|
||||
NotTEC
|
||||
verdict(CheckStatus status)
|
||||
{
|
||||
switch (status)
|
||||
{
|
||||
case CheckStatus::Ok:
|
||||
return tesSUCCESS;
|
||||
|
||||
// The module will not compile, imports what no engine of this ABI serves, or
|
||||
// does not export the entry point as `() -> i32`.
|
||||
case CheckStatus::Compile:
|
||||
case CheckStatus::Import:
|
||||
case CheckStatus::EntryPoint:
|
||||
return temBAD_WASM;
|
||||
|
||||
// The engine panicked: a defect in the engine, reported rather than fatal to
|
||||
// the node, and not the transaction's fault.
|
||||
case CheckStatus::Panic:
|
||||
return telFAILED_PROCESSING;
|
||||
}
|
||||
std::unreachable();
|
||||
}
|
||||
|
||||
} // namespace
|
||||
@@ -85,15 +147,16 @@ runEscrowWasm(
|
||||
if (gasLimit <= 0)
|
||||
return std::unexpected(WasmTER{.ter = temBAD_AMOUNT, .cost = std::nullopt});
|
||||
|
||||
try
|
||||
{
|
||||
// The host caches the current ledger object, the slot table and the contract's
|
||||
// data for the length of one run, so a reused one would answer a later contract
|
||||
// out of an earlier contract's state.
|
||||
auto const nodeSideFault = std::unexpected(WasmTER{.ter = tecINTERNAL, .cost = std::nullopt});
|
||||
|
||||
return guarded(hfs.getJournal(), nodeSideFault, [&]() -> std::expected<EscrowResult, WasmTER> {
|
||||
// The host caches the current ledger object, the slot table and the
|
||||
// contract's data for the length of one run, so a reused one would answer a
|
||||
// later contract out of an earlier contract's state.
|
||||
if (!hfs.checkSelf())
|
||||
{
|
||||
JLOG(hfs.getJournal().error()) << "wasm: host functions not clean before the run";
|
||||
return std::unexpected(WasmTER{.ter = tecINTERNAL, .cost = std::nullopt});
|
||||
return nodeSideFault;
|
||||
}
|
||||
|
||||
HostContext ctx{hfs};
|
||||
@@ -111,20 +174,26 @@ runEscrowWasm(
|
||||
<< ", ter: " << transToken(result.error().ter);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
// The engine reports every wasm outcome as a status rather than an exception, so
|
||||
// anything caught here is xrpld's own: a bad allocation, or a `funcName` that is not
|
||||
// valid UTF-8 and so cannot become a `rust::Str`.
|
||||
catch (std::exception const& e)
|
||||
{
|
||||
JLOG(hfs.getJournal().error()) << "wasm: engine call threw: " << e.what();
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
JLOG(hfs.getJournal().error()) << "wasm: engine call threw a non-exception";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return std::unexpected(WasmTER{.ter = tecINTERNAL, .cost = std::nullopt});
|
||||
NotTEC
|
||||
preflightEscrowWasm(Bytes const& wasmCode, beast::Journal j, std::string_view funcName)
|
||||
{
|
||||
return guarded(j, NotTEC{telFAILED_PROCESSING}, [&]() {
|
||||
auto const checked = rs::wasm_vm::check_escrow(
|
||||
rust::Slice<std::uint8_t const>(wasmCode.data(), wasmCode.size()),
|
||||
rust::Str(funcName.data(), funcName.size()));
|
||||
|
||||
auto const ter = verdict(checked.status);
|
||||
if (!isTesSuccess(ter))
|
||||
{
|
||||
JLOG(j.warn()) << "wasm: "
|
||||
<< std::string_view(checked.detail.data(), checked.detail.size())
|
||||
<< ", ter: " << transToken(ter);
|
||||
}
|
||||
return ter;
|
||||
});
|
||||
}
|
||||
|
||||
} // namespace xrpl
|
||||
|
||||
199
src/tests/libxrpl/tx/wasm/Preflight.cpp
Normal file
199
src/tests/libxrpl/tx/wasm/Preflight.cpp
Normal file
@@ -0,0 +1,199 @@
|
||||
#include <tx/wasm/WasmFixture.h>
|
||||
|
||||
#include <xrpl/protocol/TER.h>
|
||||
#include <xrpl/tx/wasm/WasmCommon.h>
|
||||
#include <xrpl/tx/wasm/WasmVM.h>
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
namespace xrpl::test {
|
||||
|
||||
namespace {
|
||||
|
||||
// A contract the engine can run: it compiles, imports only a declared host function, and
|
||||
// exports the entry point as `() -> i32`.
|
||||
constexpr std::string_view kRunnableWat = R"wat(
|
||||
(module
|
||||
(import "host_lib" "ldgr_index" (func $ldgr_index (param i32 i32) (result i32)))
|
||||
(memory (export "memory") 1)
|
||||
(func (export "escrow_finish") (result i32)
|
||||
(call $ldgr_index (i32.const 0) (i32.const 4))))
|
||||
)wat";
|
||||
|
||||
} // namespace
|
||||
|
||||
// `preflightEscrowWasm` takes no host, so this fixture holds none - which is the point of
|
||||
// the signature, and what deriving from `WasmTest` would hide. Only a journal, to read the
|
||||
// refusal out of.
|
||||
class PreflightTest : public testing::Test
|
||||
{
|
||||
protected:
|
||||
CapturingSink sink_;
|
||||
|
||||
NotTEC
|
||||
preflight(std::string_view wat, std::string_view funcName = escrowFunctionName)
|
||||
{
|
||||
return preflightEscrowWasm(assembleWat(wat), beast::Journal{sink_}, funcName);
|
||||
}
|
||||
|
||||
NotTEC
|
||||
preflightBytes(Bytes const& wasm, std::string_view funcName = escrowFunctionName)
|
||||
{
|
||||
return preflightEscrowWasm(wasm, beast::Journal{sink_}, funcName);
|
||||
}
|
||||
|
||||
[[nodiscard]] std::string const&
|
||||
logged() const
|
||||
{
|
||||
return sink_.text();
|
||||
}
|
||||
};
|
||||
|
||||
TEST_F(PreflightTest, RunnableContractPasses)
|
||||
{
|
||||
EXPECT_EQ(preflight(kRunnableWat), tesSUCCESS);
|
||||
EXPECT_TRUE(logged().empty()) << logged();
|
||||
}
|
||||
|
||||
TEST_F(PreflightTest, GarbageIsRefused)
|
||||
{
|
||||
EXPECT_EQ(preflightBytes(Bytes{}), temBAD_WASM);
|
||||
EXPECT_EQ(preflightBytes(Bytes{0x00, 0x61, 0x73, 0x6d}), temBAD_WASM);
|
||||
}
|
||||
|
||||
// The engine takes wasm binaries, and text is not one. The suite writes its modules as text
|
||||
// and assembles them, so this feeds the engine the very text the other tests assemble: a
|
||||
// transaction's validity must not depend on whether an assembler was linked in.
|
||||
TEST_F(PreflightTest, TextFormatModuleIsRefused)
|
||||
{
|
||||
Bytes const text{kRunnableWat.begin(), kRunnableWat.end()};
|
||||
|
||||
EXPECT_EQ(preflightBytes(text), temBAD_WASM);
|
||||
EXPECT_EQ(preflight(kRunnableWat), tesSUCCESS) << "the same module, assembled first";
|
||||
}
|
||||
|
||||
TEST_F(PreflightTest, ImportOfAnUnknownHostFunctionIsRefused)
|
||||
{
|
||||
constexpr std::string_view wat = R"wat(
|
||||
(module
|
||||
(import "host_lib" "no_such_function" (func $f (param i32) (result i32)))
|
||||
(memory (export "memory") 1)
|
||||
(func (export "escrow_finish") (result i32) (call $f (i32.const 0))))
|
||||
)wat";
|
||||
|
||||
EXPECT_EQ(preflight(wat), temBAD_WASM);
|
||||
EXPECT_THAT(logged(), testing::HasSubstr("no host function 'no_such_function'"));
|
||||
}
|
||||
|
||||
// Host functions are registered under one module name. `env` is what plain clang emits, so a
|
||||
// contract built without the SDK's import attributes lands here.
|
||||
TEST_F(PreflightTest, ImportFromAnotherModuleIsRefused)
|
||||
{
|
||||
constexpr std::string_view wat = R"wat(
|
||||
(module
|
||||
(import "env" "ldgr_index" (func $f (param i32 i32) (result i32)))
|
||||
(memory (export "memory") 1)
|
||||
(func (export "escrow_finish") (result i32) (i32.const 0)))
|
||||
)wat";
|
||||
|
||||
EXPECT_EQ(preflight(wat), temBAD_WASM);
|
||||
EXPECT_THAT(logged(), testing::HasSubstr("is not from 'host_lib'"));
|
||||
}
|
||||
|
||||
TEST_F(PreflightTest, MissingEntryPointIsRefused)
|
||||
{
|
||||
constexpr std::string_view wat = R"wat(
|
||||
(module
|
||||
(memory (export "memory") 1)
|
||||
(func (export "other") (result i32) (i32.const 0)))
|
||||
)wat";
|
||||
|
||||
EXPECT_EQ(preflight(wat), temBAD_WASM);
|
||||
EXPECT_THAT(logged(), testing::HasSubstr("no entry point 'escrow_finish'"));
|
||||
}
|
||||
|
||||
TEST_F(PreflightTest, EntryPointOfTheWrongTypeIsRefused)
|
||||
{
|
||||
constexpr std::string_view wat = R"wat(
|
||||
(module
|
||||
(memory (export "memory") 1)
|
||||
(func (export "escrow_finish") (result i64) (i64.const 0)))
|
||||
)wat";
|
||||
|
||||
EXPECT_EQ(preflight(wat), temBAD_WASM);
|
||||
EXPECT_THAT(logged(), testing::HasSubstr("has the wrong signature"));
|
||||
}
|
||||
|
||||
// Screening is for the entry point the caller names, as a run is: a contract screened for one
|
||||
// export says nothing about another.
|
||||
TEST_F(PreflightTest, EntryPointIsTheNameTheCallerGives)
|
||||
{
|
||||
constexpr std::string_view wat = R"wat(
|
||||
(module
|
||||
(memory (export "memory") 1)
|
||||
(func (export "other") (result i32) (i32.const 0)))
|
||||
)wat";
|
||||
|
||||
EXPECT_EQ(preflight(wat, "other"), tesSUCCESS);
|
||||
EXPECT_EQ(preflight(wat), temBAD_WASM);
|
||||
}
|
||||
|
||||
// Every refusal is logged with the engine's own description and the TER: without it a node
|
||||
// operator has a `temBAD_WASM` and no way to tell a contract author which of the three
|
||||
// stages refused the module.
|
||||
TEST_F(PreflightTest, RefusalNamesTheReasonAndTheTer)
|
||||
{
|
||||
EXPECT_EQ(preflightBytes(Bytes{0x00, 0x61, 0x73, 0x6d}), temBAD_WASM);
|
||||
|
||||
EXPECT_THAT(logged(), testing::HasSubstr("compile: "));
|
||||
EXPECT_THAT(logged(), testing::HasSubstr(transToken(temBAD_WASM)));
|
||||
}
|
||||
|
||||
// A module that passes screening still has to pass the run's own stages, and one that fails
|
||||
// screening would have failed the run. Same modules through both entry points, so the two do
|
||||
// not have to be trusted to agree.
|
||||
TEST_F(PreflightTest, ScreeningAgreesWithARun)
|
||||
{
|
||||
struct Case
|
||||
{
|
||||
std::string_view label;
|
||||
std::string_view wat;
|
||||
bool passes;
|
||||
};
|
||||
|
||||
// clang-format off
|
||||
constexpr Case cases[]{
|
||||
{.label = "a runnable contract", .wat = kRunnableWat, .passes = true},
|
||||
{.label = "an unknown host function",
|
||||
.wat = R"wat((module (import "host_lib" "nope" (func $f (result i32)))
|
||||
(memory (export "memory") 1)
|
||||
(func (export "escrow_finish") (result i32) (call $f))))wat",
|
||||
.passes = false},
|
||||
{.label = "no entry point",
|
||||
.wat = R"wat((module (memory (export "memory") 1)
|
||||
(func (export "other") (result i32) (i32.const 0))))wat",
|
||||
.passes = false},
|
||||
};
|
||||
// clang-format on
|
||||
|
||||
for (auto const& [label, wat, passes] : cases)
|
||||
{
|
||||
auto const screened = preflight(wat);
|
||||
EXPECT_EQ(isTesSuccess(screened), passes) << label;
|
||||
|
||||
// The run's own verdict on the same bytes. A refused module must not reach the
|
||||
// contract's first instruction; an accepted one must get past the entry-point
|
||||
// lookup, whatever it then does.
|
||||
testing::StrictMock<MockHostFunctions> host{beast::Journal{sink_}};
|
||||
EXPECT_CALL(host, checkSelf()).WillRepeatedly(testing::Return(true));
|
||||
EXPECT_CALL(host, getLedgerSqn()).WillRepeatedly(testing::Return(7u));
|
||||
|
||||
auto const ran = runEscrowWasm(assembleWat(wat), host, 100'000);
|
||||
EXPECT_EQ(ran.has_value(), passes) << label;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace xrpl::test
|
||||
@@ -49,12 +49,25 @@ public:
|
||||
}
|
||||
};
|
||||
|
||||
// Base for every wasm test: a mocked host whose log is captured, and one way into the engine.
|
||||
// Assemble `wat`. Throws `rust::Error` on a typo, which gtest reports against the test that
|
||||
// holds it.
|
||||
//
|
||||
// Modules are written as WebAssembly text and assembled here. The assembler is in a
|
||||
// test-only crate: the engine itself refuses text (`the_vm_refuses_a_text_format_module`),
|
||||
// because a text assembler on the consensus path would make a transaction's validity a build
|
||||
// flag.
|
||||
// A free function because not every wasm test needs a host: `preflightEscrowWasm` takes none,
|
||||
// so its fixture derives from `testing::Test` rather than from `WasmTest`.
|
||||
inline Bytes
|
||||
assembleWat(std::string_view wat)
|
||||
{
|
||||
auto const wasm = rs::wasm_testkit::compile_wat(rust::Str(wat.data(), wat.size()));
|
||||
return Bytes{wasm.begin(), wasm.end()};
|
||||
}
|
||||
|
||||
// Base for every wasm test that runs a contract: a mocked host whose log is captured, and one
|
||||
// way into the engine.
|
||||
//
|
||||
// Modules are written as WebAssembly text and assembled by `assembleWat`. The assembler is in
|
||||
// a test-only crate: the engine itself refuses text
|
||||
// (`the_vm_refuses_a_text_format_module`), because a text assembler on the consensus path
|
||||
// would make a transaction's validity a build flag.
|
||||
class WasmTest : public testing::Test
|
||||
{
|
||||
protected:
|
||||
@@ -77,13 +90,10 @@ protected:
|
||||
EXPECT_CALL(host_, checkSelf()).WillRepeatedly(testing::Return(true));
|
||||
}
|
||||
|
||||
// Assemble `wat`. Throws `rust::Error` on a typo, which gtest reports against the test
|
||||
// that holds the fixture.
|
||||
static Bytes
|
||||
assemble(std::string_view wat)
|
||||
{
|
||||
auto const wasm = rs::wasm_testkit::compile_wat(rust::Str(wat.data(), wat.size()));
|
||||
return Bytes{wasm.begin(), wasm.end()};
|
||||
return assembleWat(wat);
|
||||
}
|
||||
|
||||
std::expected<EscrowResult, WasmTER>
|
||||
|
||||
Reference in New Issue
Block a user