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