From 75ca1a08edb9731e82868ec04fb7ed3d7ab2484c Mon Sep 17 00:00:00 2001 From: Davide Faconti Date: Sun, 9 Aug 2026 00:24:22 +0200 Subject: [PATCH 1/2] feat(parser): wasm parser-module loader, hardening, and authoring target (0.24.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SDK PR 1b of the parser-extensibility v4 architecture (spec: pj-official-plugins PR #272), following the core PR (#172, 0.22.0): a second loader for the already-frozen module ABI — wasmer execution, hardening, and the wasm authoring target. Ships as its own 0.24.0 release (0.23.0 was taken by the symbol-provenance fix, #175), so an SDK with the wasm loader is distinguishable from one without it. Rebased onto main after #174–#176: the budget-aware NativeParserModule::load keeps #175's defining-object symbol provenance, and the docs no longer describe wasm as unavailable. - wasmer 7.0.1 statically linked (pinned; required only by the plugin_host component — plugin_sdk consumers stay wasmer-free). Exit-criterion prototype findings encoded in the loader contract: the pinned static lib exports no wasm_module_share/obtain symbols (engine-owned module reuse: one compilation, store-per-bound-instance with isolated state) and no creator-thread affinity exists — calls are sequential-only, host-serialized. Pin re-evaluated against 7.2.1: no C-API gains, WASI-syscall CVEs unreachable under the empty import allow-list, and 7.2 drops x86_64-darwin (rationale in ARCHITECTURE.md) - wasm loader: validation before any instantiation — manifest custom section via the shared codec (exactly one), reactor model enforced (_initialize required, start section/_start rejected), operational export set verified by name AND signature through the shared pj_base wasm inspector, and a frozen EMPTY import allow-list (a parser module may import nothing) - execution runtime: metered store-per-instance calls (the pinned lib exports the wasmer_metering_* C API but no interrupt/epoch/deadline surface, so limits are enforceable instruction metering — fresh point allowance per guest call, exhaustion = distinct contract violation), linear-memory base re-acquired at every point of use with overflow-safe bounds, splices resolved against the original host payload, shared fault taxonomy + strike tracker with quarantine replay - memory caps at validation: artifacts must declare a linear-memory maximum (default cap 256 MiB); the engine enforces it at runtime. Aggregate session budgets (modules, artifact size, claims, active instances, declared memory) gate admission with DECLINE and mutate nothing on rejection - adversarial fixtures: unreachable trap, metered infinite loop, memory-growth bomb, admission limits, quarantine replay — plus the M1 rejection matrix - pj-wasm-embed-manifest installed CLI (embed/verify) wrapping the shared section codec; pj_add_parser_module(... TARGETS native wasm) builds both artifacts from one source with post-link audit, dogfooded on the toy module - fix: release the metering middleware on the adapter-failure path - VERSION 0.24.0, CHANGELOG entry, CI wasmer job (metering-symbol check) Tests: 80/80 Debug+ASAN with both toolchain roots; graceful skip verified for each root independently. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017U391nLF7Motf4FehVRiXz --- .../references/parser-module.md | 22 +- .github/workflows/linux-ci.yml | 23 + CHANGELOG.md | 26 + CLAUDE.md | 9 +- CMakeLists.txt | 3 + VERSION | 2 +- cmake/PjParserModule.cmake | 243 ++++-- cmake/parser_module_wasi_no_io_stubs.cpp | 31 + cmake/plotjuggler_sdkConfig.cmake.in | 39 + pj_base/CMakeLists.txt | 96 +-- .../include/pj_base/parser_module/README.md | 18 +- .../include/pj_base/parser_module_wasm.hpp | 98 +++ pj_base/src/parser_module_wasm.cpp | 577 +++++++++++++ pj_base/tests/parser_module_wasm_audit.cpp | 554 ------------- pj_base/tests/parser_module_wasm_test.cpp | 78 ++ pj_base/tools/pj_wasm_embed_manifest.cpp | 156 ++++ pj_plugins/CLAUDE.md | 19 +- pj_plugins/CMakeLists.txt | 129 +++ pj_plugins/docs/ARCHITECTURE.md | 55 +- .../pj_plugins/host/native_parser_module.hpp | 15 +- .../pj_plugins/host/parser_module_runtime.hpp | 1 + .../host/parser_module_session_budget.hpp | 105 +++ .../pj_plugins/host/wasm_parser_module.hpp | 105 +++ .../host/wasm_parser_module_runtime.hpp | 78 ++ .../src/detail/native_parser_module_state.hpp | 9 + .../detail/parser_module_result_helpers.hpp | 141 ++++ .../src/detail/wasm_parser_module_state.hpp | 34 + pj_plugins/src/native_parser_module.cpp | 43 + pj_plugins/src/parser_module_runtime.cpp | 137 +--- .../src/parser_module_session_budget.cpp | 125 +++ pj_plugins/src/wasm_parser_module.cpp | 256 ++++++ pj_plugins/src/wasm_parser_module_runtime.cpp | 767 ++++++++++++++++++ .../tests/adversarial_wasm_parser_module.cpp | 64 ++ ...adversarial_wasm_parser_module.module.json | 16 + .../tests/native_parser_module_test.cpp | 43 + .../parser_module_session_budget_test.cpp | 94 +++ .../wasm_parser_module_hardening_test.cpp | 203 +++++ pj_plugins/tests/wasm_parser_module_test.cpp | 728 +++++++++++++++++ .../wasmer_shared_module_prototype_test.cpp | 142 ++++ 39 files changed, 4465 insertions(+), 819 deletions(-) create mode 100644 cmake/parser_module_wasi_no_io_stubs.cpp create mode 100644 pj_base/include/pj_base/parser_module_wasm.hpp create mode 100644 pj_base/src/parser_module_wasm.cpp delete mode 100644 pj_base/tests/parser_module_wasm_audit.cpp create mode 100644 pj_base/tests/parser_module_wasm_test.cpp create mode 100644 pj_base/tools/pj_wasm_embed_manifest.cpp create mode 100644 pj_plugins/include/pj_plugins/host/parser_module_session_budget.hpp create mode 100644 pj_plugins/include/pj_plugins/host/wasm_parser_module.hpp create mode 100644 pj_plugins/include/pj_plugins/host/wasm_parser_module_runtime.hpp create mode 100644 pj_plugins/src/detail/parser_module_result_helpers.hpp create mode 100644 pj_plugins/src/detail/wasm_parser_module_state.hpp create mode 100644 pj_plugins/src/parser_module_session_budget.cpp create mode 100644 pj_plugins/src/wasm_parser_module.cpp create mode 100644 pj_plugins/src/wasm_parser_module_runtime.cpp create mode 100644 pj_plugins/tests/adversarial_wasm_parser_module.cpp create mode 100644 pj_plugins/tests/adversarial_wasm_parser_module.module.json create mode 100644 pj_plugins/tests/parser_module_session_budget_test.cpp create mode 100644 pj_plugins/tests/wasm_parser_module_hardening_test.cpp create mode 100644 pj_plugins/tests/wasm_parser_module_test.cpp create mode 100644 pj_plugins/tests/wasmer_shared_module_prototype_test.cpp diff --git a/.claude/skills/plotjuggler-plugin/references/parser-module.md b/.claude/skills/plotjuggler-plugin/references/parser-module.md index 3c8ab32b..9abf4705 100644 --- a/.claude/skills/plotjuggler-plugin/references/parser-module.md +++ b/.claude/skills/plotjuggler-plugin/references/parser-module.md @@ -111,20 +111,23 @@ embedded bytes; catalog ingestion validates the complete JSON transactionally. ## Build ```cmake -find_package(plotjuggler_sdk 0.22 REQUIRED COMPONENTS parser_module) +find_package(plotjuggler_sdk 0.24 REQUIRED COMPONENTS parser_module) pj_add_parser_module(raw_mono_image_parser SOURCE raw_mono_image_parser.cpp MANIFEST raw_mono_image_parser.module.json - TARGETS native + TARGETS native wasm # either, or both from the same source ) ``` The helper embeds the manifest, hides every non-ABI symbol, and exports the -complete native `pj_module_*` set. SDK 0.22 accepts only `TARGETS native`; -requesting `TARGETS wasm` stops configuration with “wasm support arrives with -the SDK wasm loader milestone”. The shipped WASI check is structural -conformance testing, not a wasm authoring or execution target. +complete native `pj_module_*` set. `TARGETS wasm` (SDK 0.24+) requires +`PJ_WASI_SDK_ROOT` pointing at wasi-sdk 27: it builds a C++17 WASI reactor +with exceptions disabled, omits the native manifest address/length exports, +embeds the manifest in the `pj_parser_module_manifest` custom section via the +installed `pj-wasm-embed-manifest` tool, and audits the export set post-link. +Wasm reactors import nothing and must declare a linear-memory maximum +(default 256 MiB; override with `PJ_PARSER_MODULE_WASM_MAX_MEMORY_BYTES`). ## Choose a schema-compatibility strategy @@ -170,8 +173,11 @@ invalid descriptor. ## Traps - The kit is header-only and WASI-clean: no threads, filesystem, iostream, host - SDK linkage, or exceptions across its API. In SDK 0.22 the supported build - product is nevertheless native-only. + SDK linkage, or exceptions across its API. The same source builds the native + and the wasm artifact; keep it that way even if you only ship one today. +- Wasm execution is instruction-metered per guest call and memory-capped by the + declared maximum; a trap or metering exhaustion is a contract strike, not a + data error. - Return `pj::Status` / `pj::Expected`; do not throw. `Blob` uses nothrow allocation and protobuf matching is bounded, so allocation failure is a reported data error rather than a process abort or contract strike. diff --git a/.github/workflows/linux-ci.yml b/.github/workflows/linux-ci.yml index cbf1f071..2922cf17 100644 --- a/.github/workflows/linux-ci.yml +++ b/.github/workflows/linux-ci.yml @@ -161,6 +161,25 @@ jobs: echo "PJ_WASI_SDK_ROOT=${install_dir}" >> "${GITHUB_ENV}" "${install_dir}/bin/clang++" --version + - name: Install Wasmer C API 7.0.1 + shell: bash + run: | + set -euo pipefail + version="7.0.1" + archive="${RUNNER_TEMP}/wasmer-${version}.tar.gz" + install_dir="${RUNNER_TEMP}/wasmer-${version}" + url="https://github.com/wasmerio/wasmer/releases/download/v${version}/wasmer-linux-amd64.tar.gz" + + curl -fsSL --retry 5 --retry-all-errors "${url}" -o "${archive}" + mkdir -p "${install_dir}" + tar -xzf "${archive}" -C "${install_dir}" + test -f "${install_dir}/include/wasm.h" + test -f "${install_dir}/include/wasmer.h" + test -f "${install_dir}/lib/libwasmer.a" + nm -g --defined-only "${install_dir}/lib/libwasmer.a" > "${RUNNER_TEMP}/wasmer-symbols.txt" + grep -q wasmer_metering_set_remaining_points "${RUNNER_TEMP}/wasmer-symbols.txt" + echo "PJ_WASMER_ROOT=${install_dir}" >> "${GITHUB_ENV}" + - name: Configure ccache # Compiler-output cache for our own C++ — complementary to the Conan # cache (which holds prebuilt third-party packages, not our objects). @@ -223,6 +242,7 @@ jobs: -DCMAKE_C_COMPILER_LAUNCHER=ccache -DCMAKE_CXX_COMPILER_LAUNCHER=ccache -DPJ_WASI_SDK_ROOT=${PJ_WASI_SDK_ROOT} + -DPJ_WASMER_ROOT=${PJ_WASMER_ROOT} -DPJ_ENABLE_ABI_CHECK=ON - name: Build @@ -233,6 +253,9 @@ jobs: cmake --build build --target parser_module_wasm_conformance_fixture ctest --test-dir build -L wasi --output-on-failure + - name: Wasmer parser-module execution + run: ctest --test-dir build -L wasmer --output-on-failure + - name: ABI drift gate # Mechanically enforces the Release Versioning policy (CLAUDE.md): a non-MAJOR # change must not break ABI. Diffs the mock_data_source_plugin canary DSO against diff --git a/CHANGELOG.md b/CHANGELOG.md index 493bdc43..afbae846 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,32 @@ All notable changes to `plotjuggler_sdk` are recorded here. Versioning policy is in [`CLAUDE.md`](./CLAUDE.md) → "Release Versioning". +## [0.24.0] + +### Feature: sandboxed wasm parser modules and authoring preset (MINOR) + +Functional parser modules can now be validated, compiled once, and executed as +WASI reactors through the pinned Wasmer 7.0.1 C API: + +- The wasm loader admits only reactors with the frozen operational signatures, + `_initialize`, exactly one manifest section, bounded exported memory, no + start function, and the v1 empty import allow-list. +- Store-per-instance execution copies ABI blocks through guest allocation, + revalidates linear-memory ranges after every guest call, preserves host + payload splice semantics, and classifies traps or metering exhaustion as + contract violations. +- Instruction metering, declared-memory caps, and pure session admission + budgets bound calls, artifact size, modules, claims, instances, and aggregate + declared memory. Adversarial trap, infinite-loop, memory-growth, and + quarantine-replay fixtures pin the failure behavior. +- The installed `pj-wasm-embed-manifest` frontend embeds or verifies exact + manifest bytes and performs the shared static ABI audit. +- `pj_add_parser_module(... TARGETS wasm)` provides the wasi-sdk 27 C++17 + reactor preset, manifest embedding, and post-link audit; `TARGETS native wasm` + emits both artifacts from one author source. + +The wasm execution libraries remain optional when `PJ_WASMER_ROOT` is unset. + ## [0.23.1] ### Fix: Conan `plugin_host` component links the parser-module host (PATCH) diff --git a/CLAUDE.md b/CLAUDE.md index 61cb3c6a..ffa30512 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,11 +21,14 @@ not in the PJ4 superproject. This file is the root navigation node for the whole VoxelGrid, PlotMarkers) and their canonical wire codecs, the C-ABI protocol headers for DataSource/MessageParser/Toolbox + the C++ SDK base classes / host-view helpers built on them, the standalone C++17 functional parser-module authoring kit (`pj_base/parser_module/`), the host-side - wasm parser-module manifest custom-section codec, and the test-only static WASI ABI auditor. The - 0.22 authoring helper builds native parser modules only; wasm loading/execution is not present. + wasm parser-module manifest custom-section codec + static wasm ABI inspector, and the installed + `pj-wasm-embed-manifest` CLI. `pj_add_parser_module(... TARGETS native wasm)` builds both + artifacts from one source (wasm needs `PJ_WASI_SDK_ROOT`, wasi-sdk 27). - **pj_plugins** — host-side loaders + RAII handles + plugin **discovery** (directory scan + embedded-manifest inspection) for four plugin families (DataSource, MessageParser, Dialog, Toolbox), - parser claim admission/resolution and native functional parser-module execution, + parser claim admission/resolution, native functional parser-module execution, the optional + sandboxed wasm parser-module loader/runtime (Wasmer 7.0.1, gated on `PJ_WASMER_ROOT`, only the + `plugin_host` component depends on it) with session budgets, config-envelope helpers, and the **dialog C ABI** (`pj_plugins/dialog_protocol/`). The duplicate-resolution *catalog* (which copy wins by priority/version/compatibility) is host policy and lives in the app (`pj_runtime`), built on these discovery primitives. Note the split: the DataSource/MessageParser/Toolbox C-ABI diff --git a/CMakeLists.txt b/CMakeLists.txt index 8cae0b95..a0de89fa 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -110,6 +110,8 @@ find_package(FastFloat REQUIRED) # Modules # --------------------------------------------------------------------------- +set(PJ_SDK_WITH_WASMER OFF) + if(PJ_BUILD_TESTS) enable_testing() endif() @@ -161,6 +163,7 @@ if(PJ_INSTALL_SDK) "${CMAKE_CURRENT_BINARY_DIR}/plotjuggler_sdkConfigVersion.cmake" cmake/PjPluginManifest.cmake cmake/PjParserModule.cmake + cmake/parser_module_wasi_no_io_stubs.cpp DESTINATION ${PJ_PACKAGE_CMAKE_DIR} ) endif() diff --git a/VERSION b/VERSION index 610e2872..2094a100 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.23.1 +0.24.0 diff --git a/cmake/PjParserModule.cmake b/cmake/PjParserModule.cmake index b70d6847..66d20610 100644 --- a/cmake/PjParserModule.cmake +++ b/cmake/PjParserModule.cmake @@ -1,7 +1,25 @@ # PjParserModule.cmake # -# Native functional parser-module target helper. Wasm manifest embedding and -# toolchain support arrive with the wasm loader milestone. +# Functional parser-module target helper for native modules and wasi-sdk 27 +# reactor modules. Both targets compile the same author source and consume the +# same manifest bytes. + +function(_pj_parser_module_kit_include OUTPUT) + if(NOT TARGET plotjuggler_sdk::parser_module) + message(FATAL_ERROR "pj_add_parser_module: plotjuggler_sdk::parser_module is unavailable") + endif() + get_target_property(_includes plotjuggler_sdk::parser_module INTERFACE_INCLUDE_DIRECTORIES) + foreach(_include IN LISTS _includes) + if(_include MATCHES "^\\$$") + set(${OUTPUT} "${CMAKE_MATCH_1}" PARENT_SCOPE) + return() + elseif(NOT _include MATCHES "^\\$<" AND IS_DIRECTORY "${_include}") + set(${OUTPUT} "${_include}" PARENT_SCOPE) + return() + endif() + endforeach() + message(FATAL_ERROR "pj_add_parser_module: cannot resolve the parser-module kit include directory") +endfunction() function(pj_add_parser_module TARGET) set(_options) @@ -20,13 +38,21 @@ function(pj_add_parser_module TARGET) message(FATAL_ERROR "pj_add_parser_module(${TARGET}): MANIFEST is required") endif() if(NOT ARG_TARGETS) - message(FATAL_ERROR "pj_add_parser_module(${TARGET}): TARGETS native is required") + message(FATAL_ERROR "pj_add_parser_module(${TARGET}): TARGETS native and/or wasm is required") endif() + + list(REMOVE_DUPLICATES ARG_TARGETS) + set(_build_native OFF) + set(_build_wasm OFF) foreach(_requested_target IN LISTS ARG_TARGETS) - if(NOT _requested_target STREQUAL "native") + if(_requested_target STREQUAL "native") + set(_build_native ON) + elseif(_requested_target STREQUAL "wasm") + set(_build_wasm ON) + else() message(FATAL_ERROR - "pj_add_parser_module(${TARGET}): TARGETS ${_requested_target} is unavailable; " - "wasm support arrives with the SDK wasm loader milestone") + "pj_add_parser_module(${TARGET}): unsupported TARGETS value '${_requested_target}'; " + "expected native and/or wasm") endif() endforeach() @@ -47,56 +73,167 @@ function(pj_add_parser_module TARGET) "pj_add_parser_module(${TARGET}): MANIFEST must contain a claims array") endif() string(JSON _claim_count LENGTH "${_manifest_json}" claims) - if(_manifest_json MATCHES "\\)PJM\"") - message(FATAL_ERROR - "pj_add_parser_module(${TARGET}): MANIFEST contains the reserved raw-string delimiter") - endif() - set(_generated_dir "${CMAKE_CURRENT_BINARY_DIR}/${TARGET}_parser_module_generated") - file(MAKE_DIRECTORY "${_generated_dir}") - set(_manifest_header "${_generated_dir}/${TARGET}_manifest.hpp") - file(WRITE "${_manifest_header}" - "#pragma once\n" - "#define PJ_PARSER_MODULE_HAS_MANIFEST 1\n" - "namespace pj { namespace detail {\n" - "inline constexpr char kBuiltManifest[] = R\"PJM(${_manifest_json})PJM\";\n" - "} }\n" - "#define PJ_PARSER_MODULE_CLAIM_COUNT ${_claim_count}\n") + if(_build_native) + if(_manifest_json MATCHES "\\)PJM\"") + message(FATAL_ERROR + "pj_add_parser_module(${TARGET}): MANIFEST contains the reserved raw-string delimiter") + endif() + + set(_generated_dir "${CMAKE_CURRENT_BINARY_DIR}/${TARGET}_parser_module_generated") + file(MAKE_DIRECTORY "${_generated_dir}") + set(_manifest_header "${_generated_dir}/${TARGET}_manifest.hpp") + file(WRITE "${_manifest_header}" + "#pragma once\n" + "#define PJ_PARSER_MODULE_HAS_MANIFEST 1\n" + "namespace pj { namespace detail {\n" + "inline constexpr char kBuiltManifest[] = R\"PJM(${_manifest_json})PJM\";\n" + "} }\n" + "#define PJ_PARSER_MODULE_CLAIM_COUNT ${_claim_count}\n") + + add_library(${TARGET} MODULE "${_module_source}" "${_manifest_header}") + target_link_libraries(${TARGET} PRIVATE plotjuggler_sdk::parser_module) + target_include_directories(${TARGET} PRIVATE "${_generated_dir}") + target_compile_definitions(${TARGET} PRIVATE + PJ_PARSER_MODULE_MANIFEST_HEADER=\"${TARGET}_manifest.hpp\") + set_target_properties(${TARGET} PROPERTIES + CXX_STANDARD 17 + CXX_STANDARD_REQUIRED YES + CXX_EXTENSIONS NO + CXX_VISIBILITY_PRESET hidden + C_VISIBILITY_PRESET hidden + VISIBILITY_INLINES_HIDDEN YES + ) + if(DEFINED PJ_WARNING_FLAGS) + target_compile_options(${TARGET} PRIVATE ${PJ_WARNING_FLAGS}) + endif() - add_library(${TARGET} MODULE "${_module_source}" "${_manifest_header}") - target_link_libraries(${TARGET} PRIVATE plotjuggler_sdk::parser_module) - target_include_directories(${TARGET} PRIVATE "${_generated_dir}") - target_compile_definitions(${TARGET} PRIVATE - PJ_PARSER_MODULE_MANIFEST_HEADER=\"${TARGET}_manifest.hpp\") - set_target_properties(${TARGET} PROPERTIES - CXX_STANDARD 17 - CXX_STANDARD_REQUIRED YES - CXX_EXTENSIONS NO - CXX_VISIBILITY_PRESET hidden - C_VISIBILITY_PRESET hidden - VISIBILITY_INLINES_HIDDEN YES - ) - if(DEFINED PJ_WARNING_FLAGS) - target_compile_options(${TARGET} PRIVATE ${PJ_WARNING_FLAGS}) + if(CMAKE_SYSTEM_NAME STREQUAL "Linux") + set(_version_script "${_generated_dir}/${TARGET}.map") + file(WRITE "${_version_script}" + "{\n global:\n pj_module_abi;\n pj_module_create;\n pj_module_destroy;\n" + " pj_module_bind;\n pj_module_parse;\n pj_module_last_error;\n" + " pj_module_alloc;\n pj_module_free;\n pj_module_manifest_addr;\n" + " pj_module_manifest_len;\n local: *;\n};\n") + target_link_options(${TARGET} PRIVATE + "LINKER:-z,defs" + "LINKER:--exclude-libs,ALL" + "LINKER:--version-script=${_version_script}") + elseif(APPLE) + set(_exported_symbols "${_generated_dir}/${TARGET}.exports") + file(WRITE "${_exported_symbols}" + "_pj_module_abi\n_pj_module_create\n_pj_module_destroy\n_pj_module_bind\n" + "_pj_module_parse\n_pj_module_last_error\n_pj_module_alloc\n_pj_module_free\n" + "_pj_module_manifest_addr\n_pj_module_manifest_len\n") + target_link_options(${TARGET} PRIVATE "LINKER:-exported_symbols_list,${_exported_symbols}") + endif() endif() - if(CMAKE_SYSTEM_NAME STREQUAL "Linux") - set(_version_script "${_generated_dir}/${TARGET}.map") - file(WRITE "${_version_script}" - "{\n global:\n pj_module_abi;\n pj_module_create;\n pj_module_destroy;\n" - " pj_module_bind;\n pj_module_parse;\n pj_module_last_error;\n" - " pj_module_alloc;\n pj_module_free;\n pj_module_manifest_addr;\n" - " pj_module_manifest_len;\n local: *;\n};\n") - target_link_options(${TARGET} PRIVATE - "LINKER:-z,defs" - "LINKER:--exclude-libs,ALL" - "LINKER:--version-script=${_version_script}") - elseif(APPLE) - set(_exported_symbols "${_generated_dir}/${TARGET}.exports") - file(WRITE "${_exported_symbols}" - "_pj_module_abi\n_pj_module_create\n_pj_module_destroy\n_pj_module_bind\n" - "_pj_module_parse\n_pj_module_last_error\n_pj_module_alloc\n_pj_module_free\n" - "_pj_module_manifest_addr\n_pj_module_manifest_len\n") - target_link_options(${TARGET} PRIVATE "LINKER:-exported_symbols_list,${_exported_symbols}") + if(_build_wasm) + if(NOT DEFINED PJ_WASI_SDK_ROOT OR NOT PJ_WASI_SDK_ROOT) + set(PJ_WASI_SDK_ROOT "$ENV{PJ_WASI_SDK_ROOT}" CACHE PATH + "wasi-sdk 27 root used for parser-module wasm targets") + endif() + if(NOT PJ_WASI_SDK_ROOT) + message(FATAL_ERROR + "pj_add_parser_module(${TARGET}): TARGETS wasm requires PJ_WASI_SDK_ROOT " + "to name a wasi-sdk 27 installation") + endif() + set(_wasi_clang "${PJ_WASI_SDK_ROOT}/bin/clang++") + set(_wasi_sysroot "${PJ_WASI_SDK_ROOT}/share/wasi-sysroot") + if(NOT EXISTS "${_wasi_clang}" OR NOT IS_DIRECTORY "${_wasi_sysroot}" OR + NOT EXISTS "${PJ_WASI_SDK_ROOT}/VERSION") + message(FATAL_ERROR + "pj_add_parser_module(${TARGET}): wasi-sdk is incomplete under " + "PJ_WASI_SDK_ROOT=${PJ_WASI_SDK_ROOT}") + endif() + file(STRINGS "${PJ_WASI_SDK_ROOT}/VERSION" _wasi_version LIMIT_COUNT 1) + if(NOT _wasi_version MATCHES "^27\\.") + message(FATAL_ERROR + "pj_add_parser_module(${TARGET}): TARGETS wasm requires wasi-sdk 27; " + "found '${_wasi_version}'") + endif() + if(NOT TARGET plotjuggler_sdk::wasm_embed_manifest) + message(FATAL_ERROR + "pj_add_parser_module(${TARGET}): the pj-wasm-embed-manifest SDK tool is unavailable") + endif() + + set(PJ_PARSER_MODULE_WASM_MAX_MEMORY_BYTES "268435456" CACHE STRING + "Declared maximum linear memory for authored parser-module wasm reactors") + set(PJ_PARSER_MODULE_WASM_STACK_SIZE_BYTES "1048576" CACHE STRING + "Shadow-stack size for authored parser-module wasm reactors") + if(NOT PJ_PARSER_MODULE_WASM_MAX_MEMORY_BYTES MATCHES "^[0-9]+$") + message(FATAL_ERROR "PJ_PARSER_MODULE_WASM_MAX_MEMORY_BYTES must be an integer byte count") + endif() + math(EXPR _maximum_memory_remainder "${PJ_PARSER_MODULE_WASM_MAX_MEMORY_BYTES} % 65536") + if(PJ_PARSER_MODULE_WASM_MAX_MEMORY_BYTES LESS 65536 OR NOT _maximum_memory_remainder EQUAL 0) + message(FATAL_ERROR + "PJ_PARSER_MODULE_WASM_MAX_MEMORY_BYTES must be a positive multiple of 65536") + endif() + if(NOT PJ_PARSER_MODULE_WASM_STACK_SIZE_BYTES MATCHES "^[0-9]+$" OR + PJ_PARSER_MODULE_WASM_STACK_SIZE_BYTES LESS 262144) + message(FATAL_ERROR + "PJ_PARSER_MODULE_WASM_STACK_SIZE_BYTES must be an integer of at least 262144 bytes") + endif() + + _pj_parser_module_kit_include(_kit_include) + set(_no_io_stubs "${CMAKE_CURRENT_FUNCTION_LIST_DIR}/parser_module_wasi_no_io_stubs.cpp") + if(NOT EXISTS "${_no_io_stubs}") + message(FATAL_ERROR "pj_add_parser_module(${TARGET}): missing wasm support source ${_no_io_stubs}") + endif() + file(GLOB _parser_module_headers CONFIGURE_DEPENDS "${_kit_include}/pj_base/parser_module/*.hpp") + set(_wasm_dir "${CMAKE_CURRENT_BINARY_DIR}/${TARGET}_parser_module_wasm") + set(_wasm_raw "${_wasm_dir}/${TARGET}.raw.wasm") + set(_wasm_output "${_wasm_dir}/${TARGET}.wasm") + + add_custom_command( + OUTPUT "${_wasm_raw}" + COMMAND ${CMAKE_COMMAND} -E make_directory "${_wasm_dir}" + COMMAND "${_wasi_clang}" + --target=wasm32-wasip1 + --sysroot=${_wasi_sysroot} + -mexec-model=reactor + -std=c++17 + -fno-exceptions + -fno-rtti + -fvisibility=hidden + -O1 + -Wall -Wextra -Werror + -DPJ_PARSER_MODULE_CLAIM_COUNT=${_claim_count} + -I${_kit_include} + "${_module_source}" + "${_no_io_stubs}" + -Wl,--export=pj_module_abi + -Wl,--export=pj_module_create + -Wl,--export=pj_module_destroy + -Wl,--export=pj_module_bind + -Wl,--export=pj_module_parse + -Wl,--export=pj_module_last_error + -Wl,--export=pj_module_alloc + -Wl,--export=pj_module_free + -Wl,--export-memory + -Wl,--max-memory=${PJ_PARSER_MODULE_WASM_MAX_MEMORY_BYTES} + -Wl,-z,stack-size=${PJ_PARSER_MODULE_WASM_STACK_SIZE_BYTES} + -Wl,--stack-first + -o "${_wasm_raw}" + DEPENDS "${_module_source}" "${_no_io_stubs}" ${_parser_module_headers} + COMMENT "Compiling ${TARGET} as a C++17 WASI reactor" + VERBATIM + ) + add_custom_command( + OUTPUT "${_wasm_output}" + COMMAND ${CMAKE_COMMAND} -E env ASAN_OPTIONS=detect_leaks=0 + $ + embed "${_wasm_raw}" "${_module_manifest}" "${_wasm_output}" + COMMAND ${CMAKE_COMMAND} -E env ASAN_OPTIONS=detect_leaks=0 + $ + verify "${_wasm_output}" "${_module_manifest}" + "${PJ_PARSER_MODULE_WASM_MAX_MEMORY_BYTES}" + DEPENDS "${_wasm_raw}" "${_module_manifest}" plotjuggler_sdk::wasm_embed_manifest + COMMENT "Embedding and auditing ${TARGET} parser-module manifest" + VERBATIM + ) + add_custom_target(${TARGET}_wasm ALL DEPENDS "${_wasm_output}") + set_property(TARGET ${TARGET}_wasm PROPERTY PJ_PARSER_MODULE_WASM_PATH "${_wasm_output}") endif() endfunction() diff --git a/cmake/parser_module_wasi_no_io_stubs.cpp b/cmake/parser_module_wasi_no_io_stubs.cpp new file mode 100644 index 00000000..e6f529d5 --- /dev/null +++ b/cmake/parser_module_wasi_no_io_stubs.cpp @@ -0,0 +1,31 @@ +// Copyright 2026 Davide Faconti +// SPDX-License-Identifier: Apache-2.0 + +#include + +// The authoring kit performs no I/O, but wasi-libc's unreachable abort and +// stdio teardown paths otherwise leave three fd imports in a C++ reactor. +// Resolve them inside each authored module so the frozen host import allow-list +// remains empty. WASI errno 8 is BADF. +extern "C" uint32_t __imported_wasi_snapshot_preview1_fd_close(uint32_t fd) { + (void)fd; + return 8; +} + +extern "C" uint32_t __imported_wasi_snapshot_preview1_fd_seek( + uint32_t fd, uint64_t offset, uint32_t whence, uint32_t new_offset) { + (void)fd; + (void)offset; + (void)whence; + (void)new_offset; + return 8; +} + +extern "C" uint32_t __imported_wasi_snapshot_preview1_fd_write( + uint32_t fd, uint32_t iovecs, uint32_t iovec_count, uint32_t written) { + (void)fd; + (void)iovecs; + (void)iovec_count; + (void)written; + return 8; +} diff --git a/cmake/plotjuggler_sdkConfig.cmake.in b/cmake/plotjuggler_sdkConfig.cmake.in index 0f62038e..80bf1420 100644 --- a/cmake/plotjuggler_sdkConfig.cmake.in +++ b/cmake/plotjuggler_sdkConfig.cmake.in @@ -8,6 +8,45 @@ if(NOT plotjuggler_sdk_FIND_COMPONENTS) set(plotjuggler_sdk_FIND_COMPONENTS plugin_sdk) endif() +# A package built with wasm parser-module execution retains its pinned Wasmer +# dependency. Define the imported dependency before loading the exported SDK +# targets, whose static archive link interface refers to it. +if(@PJ_SDK_WITH_WASMER@ AND "plugin_host" IN_LIST plotjuggler_sdk_FIND_COMPONENTS) + set(PJ_WASMER_ROOT "$ENV{PJ_WASMER_ROOT}" CACHE PATH + "Wasmer 7.0.1 C-API root used for wasm parser-module execution") + set(_pj_wasmer_include "${PJ_WASMER_ROOT}/include") + find_file(_pj_wasmer_library + NAMES libwasmer.a libwasmer.lib wasmer.lib + PATHS "${PJ_WASMER_ROOT}/lib" + NO_DEFAULT_PATH + NO_CACHE) + if(NOT PJ_WASMER_ROOT OR + NOT EXISTS "${_pj_wasmer_include}/wasm.h" OR + NOT EXISTS "${_pj_wasmer_include}/wasmer.h" OR + NOT _pj_wasmer_library) + set(plotjuggler_sdk_FOUND FALSE) + set(plotjuggler_sdk_NOT_FOUND_MESSAGE + "plotjuggler_sdk was built with wasm parser-module execution; set PJ_WASMER_ROOT to a Wasmer 7.0.1 C-API installation") + return() + endif() + file(STRINGS "${_pj_wasmer_include}/wasmer.h" _pj_wasmer_version_line + REGEX "^#define WASMER_VERSION ") + if(NOT _pj_wasmer_version_line MATCHES "\"7\\.0\\.1\"") + set(plotjuggler_sdk_FOUND FALSE) + set(plotjuggler_sdk_NOT_FOUND_MESSAGE + "plotjuggler_sdk wasm parser-module execution requires Wasmer 7.0.1; found '${_pj_wasmer_version_line}'") + return() + endif() + find_dependency(Threads) + if(NOT TARGET pj_wasmer_static) + add_library(pj_wasmer_static UNKNOWN IMPORTED) + set_target_properties(pj_wasmer_static PROPERTIES + IMPORTED_LOCATION "${_pj_wasmer_library}" + INTERFACE_INCLUDE_DIRECTORIES "${_pj_wasmer_include}" + ) + endif() +endif() + # Include the exported targets (defines plotjuggler_sdk::base, etc.). include("${CMAKE_CURRENT_LIST_DIR}/plotjuggler_sdkTargets.cmake") diff --git a/pj_base/CMakeLists.txt b/pj_base/CMakeLists.txt index 758d80b5..d7bb5177 100644 --- a/pj_base/CMakeLists.txt +++ b/pj_base/CMakeLists.txt @@ -31,6 +31,7 @@ add_library(pj_base STATIC src/number_parse.cpp src/parser_module_abi.cpp src/parser_module_manifest.cpp + src/parser_module_wasm.cpp src/semver.cpp src/type_tree.cpp src/data_source_host_views.cpp @@ -68,6 +69,18 @@ if(PJ_ASSERT_THROWS) target_compile_definitions(pj_base PUBLIC PJ_ASSERT_THROWS) endif() +# Installed build-time frontend for embedding exact manifest bytes and running +# the same static wasm audit used by the SDK conformance gate. +add_executable(pj_wasm_embed_manifest tools/pj_wasm_embed_manifest.cpp) +target_compile_features(pj_wasm_embed_manifest PRIVATE cxx_std_20) +target_compile_options(pj_wasm_embed_manifest PRIVATE ${PJ_WARNING_FLAGS}) +target_link_libraries(pj_wasm_embed_manifest PRIVATE pj_base) +set_target_properties(pj_wasm_embed_manifest PROPERTIES + OUTPUT_NAME pj-wasm-embed-manifest + EXPORT_NAME wasm_embed_manifest +) +add_executable(plotjuggler_sdk::wasm_embed_manifest ALIAS pj_wasm_embed_manifest) + # Standalone, header-only functional parser-module authoring kit. This target # intentionally carries include paths and a C++ floor only: modules link no SDK # library and remain suitable for native and WASI reactor builds. @@ -85,9 +98,10 @@ add_library(plotjuggler_sdk::parser_module ALIAS pj_parser_module) # --------------------------------------------------------------------------- if(PJ_INSTALL_SDK) - install(TARGETS pj_base pj_parser_module EXPORT plotjuggler_sdkTargets + install(TARGETS pj_base pj_parser_module pj_wasm_embed_manifest EXPORT plotjuggler_sdkTargets ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR} LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR} + RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} ) install(DIRECTORY include/ DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}) install(FILES @@ -112,6 +126,7 @@ if(PJ_BUILD_TESTS) tests/plugin_data_api_test.cpp tests/parser_module_abi_test.cpp tests/parser_module_manifest_test.cpp + tests/parser_module_wasm_test.cpp tests/data_processors_api_test.cpp tests/settings_store_host_test.cpp tests/parser_runtime_host_test.cpp @@ -180,8 +195,9 @@ if(PJ_BUILD_TESTS) target_link_libraries(parser_module_object_writer_test PRIVATE pj_parser_module pj_base GTest::gtest_main) add_test(NAME parser_module_object_writer_test COMMAND parser_module_object_writer_test) - # Static wasm ABI conformance. The authoring source is compiled directly by - # wasi-sdk, then a host-side auditor embeds and validates the final module. + # Static wasm ABI conformance. The production authoring helper compiles the + # fixture through wasi-sdk, embeds its manifest, and audits the result. + set(_pj_build_toy_wasm OFF) set(PJ_WASI_SDK_ROOT "$ENV{PJ_WASI_SDK_ROOT}" CACHE PATH "wasi-sdk 27 root used for parser-module conformance tests") set(_pj_wasi_clang "${PJ_WASI_SDK_ROOT}/bin/clang++") @@ -201,68 +217,30 @@ if(PJ_BUILD_TESTS) message(STATUS "Parser-module WASI conformance enabled with wasi-sdk ${_pj_wasi_version}: ${PJ_WASI_SDK_ROOT}") - add_executable(parser_module_wasm_audit tests/parser_module_wasm_audit.cpp) - target_compile_options(parser_module_wasm_audit PRIVATE ${PJ_WARNING_FLAGS}) - target_link_libraries(parser_module_wasm_audit PRIVATE pj_base) + set(_pj_build_toy_wasm ON) + endif() - file(GLOB _pj_parser_module_headers CONFIGURE_DEPENDS - "${CMAKE_CURRENT_SOURCE_DIR}/include/pj_base/parser_module/*.hpp") - set(_pj_wasm_dir "${CMAKE_CURRENT_BINARY_DIR}/parser_module_wasm_conformance") - set(_pj_wasm_raw "${_pj_wasm_dir}/toy_cdr_pointcloud.raw.wasm") - set(_pj_wasm_embedded "${_pj_wasm_dir}/toy_cdr_pointcloud.wasm") - set(_pj_wasm_source "${CMAKE_CURRENT_SOURCE_DIR}/tests/toy_cdr_pointcloud_module.cpp") + set(_pj_toy_targets native) + if(_pj_build_toy_wasm) + list(APPEND _pj_toy_targets wasm) + endif() + pj_add_parser_module(toy_cdr_pointcloud_module + SOURCE "${CMAKE_CURRENT_SOURCE_DIR}/tests/toy_cdr_pointcloud_module.cpp" + MANIFEST "${CMAKE_CURRENT_SOURCE_DIR}/tests/toy_cdr_pointcloud.module.json" + TARGETS ${_pj_toy_targets} + ) + if(_pj_build_toy_wasm) + get_property(_pj_wasm_embedded TARGET toy_cdr_pointcloud_module_wasm + PROPERTY PJ_PARSER_MODULE_WASM_PATH) set(_pj_wasm_manifest "${CMAKE_CURRENT_SOURCE_DIR}/tests/toy_cdr_pointcloud.module.json") - - add_custom_command( - OUTPUT "${_pj_wasm_raw}" - COMMAND ${CMAKE_COMMAND} -E make_directory "${_pj_wasm_dir}" - COMMAND "${_pj_wasi_clang}" - --target=wasm32-wasip1 - --sysroot=${_pj_wasi_sysroot} - -mexec-model=reactor - -std=c++17 - -fno-exceptions - -fno-rtti - -fvisibility=hidden - -O1 - -Wall -Wextra -Werror - -DPJ_PARSER_MODULE_CLAIM_COUNT=2 - -I${CMAKE_CURRENT_SOURCE_DIR}/include - "${_pj_wasm_source}" - -Wl,--export=pj_module_abi - -Wl,--export=pj_module_create - -Wl,--export=pj_module_destroy - -Wl,--export=pj_module_bind - -Wl,--export=pj_module_parse - -Wl,--export=pj_module_last_error - -Wl,--export=pj_module_alloc - -Wl,--export=pj_module_free - -o "${_pj_wasm_raw}" - DEPENDS "${_pj_wasm_source}" ${_pj_parser_module_headers} - COMMENT "Compiling C++17 parser-module WASI reactor fixture" - VERBATIM - ) - add_custom_command( - OUTPUT "${_pj_wasm_embedded}" - COMMAND ${CMAKE_COMMAND} -E env ASAN_OPTIONS=detect_leaks=0 - $ - --embed "${_pj_wasm_raw}" "${_pj_wasm_manifest}" "${_pj_wasm_embedded}" - DEPENDS "${_pj_wasm_raw}" "${_pj_wasm_manifest}" parser_module_wasm_audit - COMMENT "Embedding parser-module wasm manifest with the shared codec" - VERBATIM - ) add_custom_target(parser_module_wasm_conformance_fixture ALL - DEPENDS "${_pj_wasm_embedded}") + DEPENDS toy_cdr_pointcloud_module_wasm) + set_property(TARGET parser_module_wasm_conformance_fixture PROPERTY + PJ_PARSER_MODULE_WASM_FIXTURE_PATH "${_pj_wasm_embedded}") add_test(NAME parser_module_wasm_conformance_test - COMMAND parser_module_wasm_audit --audit "${_pj_wasm_embedded}" "${_pj_wasm_manifest}") + COMMAND pj_wasm_embed_manifest verify "${_pj_wasm_embedded}" "${_pj_wasm_manifest}") set_tests_properties(parser_module_wasm_conformance_test PROPERTIES LABELS "parser_module;wasi" REQUIRED_FILES "${_pj_wasm_embedded}") endif() - - pj_add_parser_module(toy_cdr_pointcloud_module - SOURCE "${CMAKE_CURRENT_SOURCE_DIR}/tests/toy_cdr_pointcloud_module.cpp" - MANIFEST "${CMAKE_CURRENT_SOURCE_DIR}/tests/toy_cdr_pointcloud.module.json" - TARGETS native - ) endif() diff --git a/pj_base/include/pj_base/parser_module/README.md b/pj_base/include/pj_base/parser_module/README.md index 4ce87d68..9c77d3b9 100644 --- a/pj_base/include/pj_base/parser_module/README.md +++ b/pj_base/include/pj_base/parser_module/README.md @@ -47,11 +47,19 @@ claims. `ObjectWriter` provides `image`, `pointCloud`, `depthImage`, `occupancyGrid`, `compressedPointCloud`, `mesh3D`, `videoFrame`, `occupancyGridUpdate`, and `voxelGrid` builders. -Native modules are built with `pj_add_parser_module(... TARGETS native)`. The -target links no SDK library; it receives this subtree only as an include path. -`TARGETS wasm` is not available in SDK 0.22. The wasi-sdk gate compiles and -statically audits a reactor fixture, but it does not provide wasm authoring or -execution. +Native and wasm modules are built with `pj_add_parser_module` using `TARGETS +native`, `TARGETS wasm`, or both. Each target links no SDK library; it receives +this subtree only as an include path. Wasm targets require `PJ_WASI_SDK_ROOT` +to select wasi-sdk 27 and are post-linked through the installed +`pj-wasm-embed-manifest` embed-and-audit frontend. + +WASI reactor modules use the same operational exports and compile with +exceptions disabled. Their manifest is delivered in the +`pj_parser_module_manifest` custom section, so the native-only manifest address +and length exports are omitted automatically when targeting wasm. Authored +reactors have an empty import set and a declared linear-memory maximum; the +default maximum is 256 MiB and can be configured with +`PJ_PARSER_MODULE_WASM_MAX_MEMORY_BYTES`. See `.claude/skills/plotjuggler-plugin/references/parser-module.md` at the repository diff --git a/pj_base/include/pj_base/parser_module_wasm.hpp b/pj_base/include/pj_base/parser_module_wasm.hpp new file mode 100644 index 00000000..50851118 --- /dev/null +++ b/pj_base/include/pj_base/parser_module_wasm.hpp @@ -0,0 +1,98 @@ +#pragma once +// Copyright 2026 Davide Faconti +// SPDX-License-Identifier: Apache-2.0 + +/** + * @file parser_module_wasm.hpp + * @brief Bounds-checked static inspection of parser-module wasm binaries. + */ + +#include +#include +#include +#include +#include +#include + +#include "pj_base/expected.hpp" +#include "pj_base/span.hpp" + +namespace PJ::parser_module { + +inline constexpr uint8_t kWasmValueI32 = UINT8_C(0x7F); +inline constexpr uint8_t kWasmValueI64 = UINT8_C(0x7E); +inline constexpr uint64_t kWasmMemoryPageBytes = UINT64_C(65536); + +enum class WasmExternalKind : uint8_t { + kFunction = 0, + kTable = 1, + kMemory = 2, + kGlobal = 3, + kTag = 4, +}; + +struct WasmFunctionSignature { + std::vector parameters; + std::vector results; + + bool operator==(const WasmFunctionSignature&) const = default; +}; + +struct WasmMemoryLimits { + uint32_t minimum_pages = 0; + std::optional maximum_pages; + + bool operator==(const WasmMemoryLimits&) const = default; +}; + +struct WasmImport { + std::string module; + std::string name; + WasmExternalKind kind = WasmExternalKind::kFunction; + std::optional function_signature; +}; + +struct WasmExport { + std::string name; + WasmExternalKind kind = WasmExternalKind::kFunction; + uint32_t index = 0; + std::optional function_signature; + std::optional memory_limits; +}; + +struct WasmModuleInfo { + std::vector imports; + std::vector exports; + std::vector memories; + bool has_start_section = false; + size_t section_count = 0; + size_t function_type_count = 0; + size_t function_count = 0; + + [[nodiscard]] const WasmExport* findExport(std::string_view name) const noexcept; +}; + +/** Inspect the sections needed for parser-module admission. + * + * Every section and variable-length integer is bounds-checked. Function + * signatures are resolved for imported and exported functions. Malformed, + * truncated, duplicate, or unsupported binary constructs return an error. + */ +[[nodiscard]] Expected inspectWasmModule(Span wasm); + +/** Validate the frozen operational exports and reactor constraints. + * + * Import policy and the exported linear memory needed for execution are loader + * policy layered on top of this ABI-only validation. + */ +[[nodiscard]] Expected validateParserModuleWasmAbi(const WasmModuleInfo& module); + +/** Validate bounded linear memory and return its aggregate declared maximum. + * + * Every declared memory must provide a maximum no larger than + * `maximum_bytes`. The operational `memory` export must resolve to one of + * those declarations. Byte arithmetic is overflow-checked. + */ +[[nodiscard]] Expected validateParserModuleWasmMemory(const WasmModuleInfo& module, uint64_t maximum_bytes); + +} // namespace PJ::parser_module diff --git a/pj_base/src/parser_module_wasm.cpp b/pj_base/src/parser_module_wasm.cpp new file mode 100644 index 00000000..20611b85 --- /dev/null +++ b/pj_base/src/parser_module_wasm.cpp @@ -0,0 +1,577 @@ +// Copyright 2026 Davide Faconti +// SPDX-License-Identifier: Apache-2.0 + +#include "pj_base/parser_module_wasm.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "pj_base/parser_module_abi.h" + +namespace PJ::parser_module { +namespace { + +constexpr std::array kWasmPreamble{0x00, 0x61, 0x73, 0x6D, 0x01, 0x00, 0x00, 0x00}; + +class Cursor { + public: + explicit Cursor(Span bytes) : bytes_(bytes) {} + + [[nodiscard]] bool empty() const noexcept { + return position_ == bytes_.size(); + } + + [[nodiscard]] size_t remaining() const noexcept { + return position_ <= bytes_.size() ? bytes_.size() - position_ : 0; + } + + [[nodiscard]] Expected byte() { + if (position_ >= bytes_.size()) { + return unexpected(std::string("truncated wasm byte")); + } + return bytes_[position_++]; + } + + [[nodiscard]] Expected varUint32() { + uint32_t value = 0; + for (size_t index = 0; index < 5; ++index) { + auto next = byte(); + if (!next) { + return unexpected(next.error()); + } + if (index == 4 && (*next & UINT8_C(0xF0)) != 0) { + return unexpected(std::string("wasm varuint32 overflows uint32")); + } + value |= static_cast(*next & UINT8_C(0x7F)) << (index * 7U); + if ((*next & UINT8_C(0x80)) == 0) { + return value; + } + } + return unexpected(std::string("wasm varuint32 exceeds five bytes")); + } + + [[nodiscard]] Expected name() { + auto length = varUint32(); + if (!length) { + return unexpected(length.error()); + } + if (static_cast(*length) > remaining()) { + return unexpected(std::string("wasm name exceeds the remaining section bytes")); + } + const char* begin = reinterpret_cast(bytes_.data() + position_); + position_ += *length; + return std::string(begin, *length); + } + + [[nodiscard]] Expected take(uint32_t size) { + if (static_cast(size) > remaining()) { + return unexpected(std::string("wasm section exceeds the remaining module bytes")); + } + Cursor result(bytes_.subspan(position_, size)); + position_ += size; + return result; + } + + private: + Span bytes_; + size_t position_ = 0; +}; + +struct PendingImport { + WasmImport value; + std::optional function_type; +}; + +struct ModuleBuilder { + std::vector types; + std::vector imported_function_types; + std::vector defined_function_types; + std::vector imported_memories; + std::vector defined_memories; + std::vector imports; + std::vector exports; + bool has_start_section = false; + size_t section_count = 0; +}; + +[[nodiscard]] bool validValueType(uint8_t value) { + switch (value) { + case kWasmValueI32: + case kWasmValueI64: + case 0x7D: // f32 + case 0x7C: // f64 + case 0x7B: // v128 + case 0x70: // funcref + case 0x6F: // externref + return true; + default: + return false; + } +} + +[[nodiscard]] Expected requireCountFits( + uint32_t count, size_t remaining, size_t minimum_entry_size, std::string_view section) { + if (minimum_entry_size == 0 || static_cast(count) > remaining / minimum_entry_size) { + return unexpected(std::string("wasm ") + std::string(section) + " count exceeds the remaining section bytes"); + } + return {}; +} + +[[nodiscard]] Expected> readValueTypes(Cursor* cursor) { + auto count = cursor->varUint32(); + if (!count) { + return unexpected(count.error()); + } + auto bounded = requireCountFits(*count, cursor->remaining(), 1, "value-type"); + if (!bounded) { + return unexpected(bounded.error()); + } + std::vector result; + result.reserve(*count); + for (uint32_t index = 0; index < *count; ++index) { + auto value = cursor->byte(); + if (!value) { + return unexpected(value.error()); + } + if (!validValueType(*value)) { + return unexpected(std::string("wasm function type contains an invalid value type")); + } + result.push_back(*value); + } + return result; +} + +[[nodiscard]] Expected requireConsumed(const Cursor& cursor, std::string_view section) { + if (!cursor.empty()) { + return unexpected(std::string(section) + " section contains trailing bytes"); + } + return {}; +} + +[[nodiscard]] Expected parseTypeSection(Cursor cursor, ModuleBuilder* module) { + auto count = cursor.varUint32(); + if (!count) { + return unexpected(count.error()); + } + auto bounded = requireCountFits(*count, cursor.remaining(), 3, "type-section entry"); + if (!bounded) { + return unexpected(bounded.error()); + } + module->types.reserve(*count); + for (uint32_t index = 0; index < *count; ++index) { + auto form = cursor.byte(); + if (!form || *form != UINT8_C(0x60)) { + return unexpected(std::string("wasm type section contains a non-function type")); + } + auto parameters = readValueTypes(&cursor); + auto results = readValueTypes(&cursor); + if (!parameters) { + return unexpected(parameters.error()); + } + if (!results) { + return unexpected(results.error()); + } + module->types.push_back(WasmFunctionSignature{std::move(*parameters), std::move(*results)}); + } + return requireConsumed(cursor, "type"); +} + +[[nodiscard]] Expected readLimits(Cursor* cursor) { + auto flags = cursor->varUint32(); + auto minimum = cursor->varUint32(); + if (!flags || !minimum) { + return unexpected(std::string("truncated wasm limits")); + } + if (*flags > 1) { + return unexpected(std::string("unsupported wasm limits flags")); + } + WasmMemoryLimits limits{.minimum_pages = *minimum, .maximum_pages = std::nullopt}; + if ((*flags & 1U) != 0) { + auto maximum = cursor->varUint32(); + if (!maximum) { + return unexpected(maximum.error()); + } + if (*maximum < *minimum) { + return unexpected(std::string("wasm limits maximum is smaller than its minimum")); + } + limits.maximum_pages = *maximum; + } + return limits; +} + +[[nodiscard]] Expected parseImportSection(Cursor cursor, ModuleBuilder* module) { + auto count = cursor.varUint32(); + if (!count) { + return unexpected(count.error()); + } + auto bounded = requireCountFits(*count, cursor.remaining(), 4, "import-section entry"); + if (!bounded) { + return unexpected(bounded.error()); + } + module->imports.reserve(*count); + for (uint32_t index = 0; index < *count; ++index) { + auto module_name = cursor.name(); + auto field_name = cursor.name(); + auto kind = cursor.byte(); + if (!module_name || !field_name || !kind || *kind > static_cast(WasmExternalKind::kTag)) { + return unexpected(std::string("truncated or invalid wasm import entry")); + } + + PendingImport imported{ + .value = + WasmImport{ + .module = std::move(*module_name), + .name = std::move(*field_name), + .kind = static_cast(*kind), + .function_signature = std::nullopt, + }, + .function_type = std::nullopt, + }; + switch (imported.value.kind) { + case WasmExternalKind::kFunction: { + auto type_index = cursor.varUint32(); + if (!type_index) { + return unexpected(type_index.error()); + } + imported.function_type = *type_index; + module->imported_function_types.push_back(*type_index); + break; + } + case WasmExternalKind::kTable: { + auto element_type = cursor.byte(); + if (!element_type || (*element_type != UINT8_C(0x70) && *element_type != UINT8_C(0x6F))) { + return unexpected(std::string("invalid wasm table import")); + } + auto limits = readLimits(&cursor); + if (!limits) { + return unexpected(limits.error()); + } + break; + } + case WasmExternalKind::kMemory: { + auto limits = readLimits(&cursor); + if (!limits) { + return unexpected(limits.error()); + } + module->imported_memories.push_back(*limits); + break; + } + case WasmExternalKind::kGlobal: { + auto value_type = cursor.byte(); + auto mutability = cursor.byte(); + if (!value_type || !mutability || !validValueType(*value_type) || *mutability > 1) { + return unexpected(std::string("invalid wasm global import")); + } + break; + } + case WasmExternalKind::kTag: { + auto attribute = cursor.varUint32(); + auto type_index = cursor.varUint32(); + if (!attribute || !type_index) { + return unexpected(std::string("truncated wasm tag import")); + } + break; + } + } + module->imports.push_back(std::move(imported)); + } + return requireConsumed(cursor, "import"); +} + +[[nodiscard]] Expected parseFunctionSection(Cursor cursor, ModuleBuilder* module) { + auto count = cursor.varUint32(); + if (!count) { + return unexpected(count.error()); + } + auto bounded = requireCountFits(*count, cursor.remaining(), 1, "function-section entry"); + if (!bounded) { + return unexpected(bounded.error()); + } + module->defined_function_types.reserve(*count); + for (uint32_t index = 0; index < *count; ++index) { + auto type_index = cursor.varUint32(); + if (!type_index) { + return unexpected(type_index.error()); + } + module->defined_function_types.push_back(*type_index); + } + return requireConsumed(cursor, "function"); +} + +[[nodiscard]] Expected parseMemorySection(Cursor cursor, ModuleBuilder* module) { + auto count = cursor.varUint32(); + if (!count) { + return unexpected(count.error()); + } + auto bounded = requireCountFits(*count, cursor.remaining(), 2, "memory-section entry"); + if (!bounded) { + return unexpected(bounded.error()); + } + module->defined_memories.reserve(*count); + for (uint32_t index = 0; index < *count; ++index) { + auto limits = readLimits(&cursor); + if (!limits) { + return unexpected(limits.error()); + } + module->defined_memories.push_back(*limits); + } + return requireConsumed(cursor, "memory"); +} + +[[nodiscard]] Expected parseExportSection(Cursor cursor, ModuleBuilder* module) { + auto count = cursor.varUint32(); + if (!count) { + return unexpected(count.error()); + } + auto bounded = requireCountFits(*count, cursor.remaining(), 3, "export-section entry"); + if (!bounded) { + return unexpected(bounded.error()); + } + module->exports.reserve(*count); + for (uint32_t index = 0; index < *count; ++index) { + auto name = cursor.name(); + auto kind = cursor.byte(); + auto item_index = cursor.varUint32(); + if (!name || !kind || !item_index || *kind > static_cast(WasmExternalKind::kTag)) { + return unexpected(std::string("truncated or invalid wasm export entry")); + } + if (std::any_of(module->exports.begin(), module->exports.end(), [&](const WasmExport& item) { + return item.name == *name; + })) { + return unexpected(std::string("duplicate wasm export name: ") + *name); + } + module->exports.push_back( + WasmExport{ + .name = std::move(*name), + .kind = static_cast(*kind), + .index = *item_index, + .function_signature = std::nullopt, + .memory_limits = std::nullopt, + }); + } + return requireConsumed(cursor, "export"); +} + +[[nodiscard]] Expected finishModule(ModuleBuilder builder) { + std::vector function_types; + function_types.reserve(builder.imported_function_types.size() + builder.defined_function_types.size()); + function_types.insert( + function_types.end(), builder.imported_function_types.begin(), builder.imported_function_types.end()); + function_types.insert( + function_types.end(), builder.defined_function_types.begin(), builder.defined_function_types.end()); + for (const uint32_t type_index : function_types) { + if (type_index >= builder.types.size()) { + return unexpected(std::string("wasm function references an invalid type index")); + } + } + + WasmModuleInfo result; + result.has_start_section = builder.has_start_section; + result.section_count = builder.section_count; + result.function_type_count = builder.types.size(); + result.function_count = function_types.size(); + result.memories.reserve(builder.imported_memories.size() + builder.defined_memories.size()); + result.memories.insert(result.memories.end(), builder.imported_memories.begin(), builder.imported_memories.end()); + result.memories.insert(result.memories.end(), builder.defined_memories.begin(), builder.defined_memories.end()); + result.imports.reserve(builder.imports.size()); + for (auto& imported : builder.imports) { + if (imported.function_type.has_value()) { + if (*imported.function_type >= builder.types.size()) { + return unexpected(std::string("wasm function import references an invalid type index")); + } + imported.value.function_signature = builder.types[*imported.function_type]; + } + result.imports.push_back(std::move(imported.value)); + } + result.exports.reserve(builder.exports.size()); + for (auto& exported : builder.exports) { + if (exported.kind == WasmExternalKind::kFunction) { + if (exported.index >= function_types.size()) { + return unexpected(std::string("wasm function export references an invalid function index")); + } + exported.function_signature = builder.types[function_types[exported.index]]; + } else if (exported.kind == WasmExternalKind::kMemory) { + if (exported.index >= result.memories.size()) { + return unexpected(std::string("wasm memory export references an invalid memory index")); + } + exported.memory_limits = result.memories[exported.index]; + } + result.exports.push_back(std::move(exported)); + } + return result; +} + +[[nodiscard]] Expected inspectWasmModuleImpl(Span wasm) { + if (wasm.size() < kWasmPreamble.size() || !std::equal(kWasmPreamble.begin(), kWasmPreamble.end(), wasm.begin())) { + return unexpected(std::string("invalid wasm preamble")); + } + + ModuleBuilder module; + Cursor cursor(wasm.subspan(kWasmPreamble.size())); + std::array seen{}; + while (!cursor.empty()) { + auto section_id = cursor.byte(); + auto section_size = cursor.varUint32(); + if (!section_id || !section_size) { + return unexpected(std::string("truncated wasm section header")); + } + if (*section_id > 12) { + return unexpected(std::string("unknown wasm section id")); + } + auto section = cursor.take(*section_size); + if (!section) { + return unexpected(section.error()); + } + ++module.section_count; + if (*section_id != 0) { + if (seen[*section_id]) { + return unexpected(std::string("duplicate standard wasm section")); + } + seen[*section_id] = true; + } + + Expected parsed; + switch (*section_id) { + case 1: + parsed = parseTypeSection(*section, &module); + break; + case 2: + parsed = parseImportSection(*section, &module); + break; + case 3: + parsed = parseFunctionSection(*section, &module); + break; + case 5: + parsed = parseMemorySection(*section, &module); + break; + case 7: + parsed = parseExportSection(*section, &module); + break; + case 8: + module.has_start_section = true; + break; + default: + break; + } + if (!parsed) { + return unexpected(parsed.error()); + } + } + return finishModule(std::move(module)); +} + +struct ExpectedExport { + std::string_view name; + WasmFunctionSignature signature; +}; + +} // namespace + +const WasmExport* WasmModuleInfo::findExport(std::string_view name) const noexcept { + const auto found = + std::find_if(exports.begin(), exports.end(), [&](const WasmExport& item) { return item.name == name; }); + return found == exports.end() ? nullptr : &*found; +} + +Expected inspectWasmModule(Span wasm) { + try { + return inspectWasmModuleImpl(wasm); + } catch (const std::bad_alloc&) { + return unexpected(std::string("allocation failed while inspecting the wasm module")); + } catch (...) { + return unexpected(std::string("unexpected failure while inspecting the wasm module")); + } +} + +Expected validateParserModuleWasmAbi(const WasmModuleInfo& module) { + if (module.has_start_section) { + return unexpected(std::string("wasm reactor contains a forbidden start section")); + } + if (module.findExport("_start") != nullptr) { + return unexpected(std::string("wasm reactor exports forbidden _start")); + } + if (module.findExport(PJ_MODULE_MANIFEST_ADDR_EXPORT_NAME) != nullptr || + module.findExport(PJ_MODULE_MANIFEST_LEN_EXPORT_NAME) != nullptr) { + return unexpected(std::string("wasm reactor exports native-only manifest metadata")); + } + + const std::array expected{{ + {PJ_MODULE_ABI_EXPORT_NAME, {{}, {kWasmValueI32}}}, + {PJ_MODULE_CREATE_EXPORT_NAME, {{kWasmValueI32}, {kWasmValueI64}}}, + {PJ_MODULE_DESTROY_EXPORT_NAME, {{kWasmValueI64}, {}}}, + {PJ_MODULE_BIND_EXPORT_NAME, {{kWasmValueI64, kWasmValueI64, kWasmValueI64}, {kWasmValueI32}}}, + {PJ_MODULE_PARSE_EXPORT_NAME, + {{kWasmValueI64, kWasmValueI64, kWasmValueI64, kWasmValueI64, kWasmValueI64}, {kWasmValueI32}}}, + {PJ_MODULE_LAST_ERROR_EXPORT_NAME, {{kWasmValueI64, kWasmValueI64, kWasmValueI64}, {kWasmValueI64}}}, + {PJ_MODULE_ALLOC_EXPORT_NAME, {{kWasmValueI64}, {kWasmValueI64}}}, + {PJ_MODULE_FREE_EXPORT_NAME, {{kWasmValueI64, kWasmValueI64}, {}}}, + {"_initialize", {{}, {}}}, + }}; + for (const auto& entry : expected) { + const WasmExport* exported = module.findExport(entry.name); + if (exported == nullptr) { + return unexpected(std::string("missing function export: ") + std::string(entry.name)); + } + if (exported->kind != WasmExternalKind::kFunction || !exported->function_signature.has_value()) { + return unexpected(std::string("export is not a valid function: ") + std::string(entry.name)); + } + if (*exported->function_signature != entry.signature) { + return unexpected(std::string("function export has the wrong wasm signature: ") + std::string(entry.name)); + } + } + + for (const auto& exported : module.exports) { + if (exported.name.rfind("pj_module_", 0) != 0) { + continue; + } + const auto found = std::find_if( + expected.begin(), expected.end(), [&](const ExpectedExport& entry) { return entry.name == exported.name; }); + if (found == expected.end()) { + return unexpected(std::string("unexpected parser-module export: ") + exported.name); + } + } + return {}; +} + +Expected validateParserModuleWasmMemory(const WasmModuleInfo& module, uint64_t maximum_bytes) { + const WasmExport* memory_export = module.findExport("memory"); + if (memory_export == nullptr || memory_export->kind != WasmExternalKind::kMemory || + !memory_export->memory_limits.has_value()) { + return unexpected(std::string("wasm parser module must export declared linear memory as 'memory'")); + } + if (module.memories.empty()) { + return unexpected(std::string("wasm parser module declares no linear memory")); + } + + uint64_t aggregate_maximum = 0; + for (const auto& memory : module.memories) { + if (!memory.maximum_pages.has_value()) { + return unexpected(std::string("wasm parser-module memory has no declared maximum")); + } + const uint64_t pages = *memory.maximum_pages; + if (pages > std::numeric_limits::max() / kWasmMemoryPageBytes) { + return unexpected(std::string("wasm parser-module memory maximum overflows bytes")); + } + const uint64_t bytes = pages * kWasmMemoryPageBytes; + if (bytes > maximum_bytes) { + return unexpected( + "wasm parser-module memory maximum " + std::to_string(bytes) + " exceeds configured cap " + + std::to_string(maximum_bytes)); + } + if (bytes > std::numeric_limits::max() - aggregate_maximum) { + return unexpected(std::string("aggregate wasm parser-module memory maximum overflows bytes")); + } + aggregate_maximum += bytes; + } + return aggregate_maximum; +} + +} // namespace PJ::parser_module diff --git a/pj_base/tests/parser_module_wasm_audit.cpp b/pj_base/tests/parser_module_wasm_audit.cpp deleted file mode 100644 index d299db50..00000000 --- a/pj_base/tests/parser_module_wasm_audit.cpp +++ /dev/null @@ -1,554 +0,0 @@ -// Copyright 2026 Davide Faconti -// SPDX-License-Identifier: Apache-2.0 - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include "pj_base/parser_module_manifest.hpp" - -namespace { - -using PJ::Expected; -using PJ::Span; -using PJ::unexpected; - -constexpr std::array kWasmPreamble{0x00, 0x61, 0x73, 0x6D, 0x01, 0x00, 0x00, 0x00}; -constexpr uint8_t kI32 = 0x7F; -constexpr uint8_t kI64 = 0x7E; - -class Cursor { - public: - explicit Cursor(Span bytes) : bytes_(bytes) {} - - [[nodiscard]] bool empty() const noexcept { - return position_ == bytes_.size(); - } - - [[nodiscard]] size_t remaining() const noexcept { - return position_ <= bytes_.size() ? bytes_.size() - position_ : 0; - } - - [[nodiscard]] Expected byte() { - if (position_ >= bytes_.size()) { - return unexpected(std::string("truncated wasm byte")); - } - return bytes_[position_++]; - } - - [[nodiscard]] Expected varUint32() { - uint32_t value = 0; - for (size_t index = 0; index < 5; ++index) { - auto next = byte(); - if (!next) { - return unexpected(next.error()); - } - if (index == 4 && (*next & UINT8_C(0xF0)) != 0) { - return unexpected(std::string("wasm varuint32 overflows uint32")); - } - value |= static_cast(*next & UINT8_C(0x7F)) << (index * 7U); - if ((*next & UINT8_C(0x80)) == 0) { - return value; - } - } - return unexpected(std::string("wasm varuint32 exceeds five bytes")); - } - - [[nodiscard]] Expected name() { - auto length = varUint32(); - if (!length) { - return unexpected(length.error()); - } - if (static_cast(*length) > remaining()) { - return unexpected(std::string("wasm name exceeds the remaining section bytes")); - } - const char* begin = reinterpret_cast(bytes_.data() + position_); - position_ += *length; - return std::string(begin, *length); - } - - [[nodiscard]] Expected take(uint32_t size) { - if (static_cast(size) > remaining()) { - return unexpected(std::string("wasm section exceeds the remaining module bytes")); - } - Cursor result(bytes_.subspan(position_, size)); - position_ += size; - return result; - } - - private: - Span bytes_; - size_t position_ = 0; -}; - -struct FunctionType { - std::vector parameters; - std::vector results; -}; - -struct Export { - std::string name; - uint8_t kind = 0; - uint32_t index = 0; -}; - -struct ModuleInfo { - std::vector types; - std::vector function_types; - std::vector exports; - bool has_start_section = false; - size_t section_count = 0; -}; - -[[nodiscard]] bool validValueType(uint8_t value) { - switch (value) { - case 0x7F: // i32 - case 0x7E: // i64 - case 0x7D: // f32 - case 0x7C: // f64 - case 0x7B: // v128 - case 0x70: // funcref - case 0x6F: // externref - return true; - default: - return false; - } -} - -[[nodiscard]] Expected> readValueTypes(Cursor* cursor) { - auto count = cursor->varUint32(); - if (!count) { - return unexpected(count.error()); - } - try { - std::vector result; - result.reserve(*count); - for (uint32_t index = 0; index < *count; ++index) { - auto value = cursor->byte(); - if (!value) { - return unexpected(value.error()); - } - if (!validValueType(*value)) { - return unexpected(std::string("wasm function type contains an invalid value type")); - } - result.push_back(*value); - } - return result; - } catch (const std::bad_alloc&) { - return unexpected(std::string("allocation failed while reading wasm value types")); - } -} - -[[nodiscard]] Expected requireConsumed(const Cursor& cursor, std::string_view section) { - if (!cursor.empty()) { - return unexpected(std::string(section) + " section contains trailing bytes"); - } - return {}; -} - -[[nodiscard]] Expected parseTypeSection(Cursor cursor, ModuleInfo* module) { - auto count = cursor.varUint32(); - if (!count) { - return unexpected(count.error()); - } - try { - module->types.reserve(*count); - for (uint32_t index = 0; index < *count; ++index) { - auto form = cursor.byte(); - if (!form || *form != UINT8_C(0x60)) { - return unexpected(std::string("wasm type section contains a non-function type")); - } - auto parameters = readValueTypes(&cursor); - auto results = readValueTypes(&cursor); - if (!parameters) { - return unexpected(parameters.error()); - } - if (!results) { - return unexpected(results.error()); - } - module->types.push_back(FunctionType{std::move(*parameters), std::move(*results)}); - } - } catch (const std::bad_alloc&) { - return unexpected(std::string("allocation failed while reading the wasm type section")); - } - return requireConsumed(cursor, "type"); -} - -[[nodiscard]] Expected readLimits(Cursor* cursor) { - auto flags = cursor->varUint32(); - auto minimum = cursor->varUint32(); - if (!flags || !minimum) { - return unexpected(std::string("truncated wasm limits")); - } - if (*flags > 1) { - return unexpected(std::string("unsupported wasm limits flags")); - } - if ((*flags & 1U) != 0) { - auto maximum = cursor->varUint32(); - if (!maximum) { - return unexpected(maximum.error()); - } - } - return {}; -} - -[[nodiscard]] Expected parseImportSection(Cursor cursor, ModuleInfo* module) { - auto count = cursor.varUint32(); - if (!count) { - return unexpected(count.error()); - } - for (uint32_t index = 0; index < *count; ++index) { - auto module_name = cursor.name(); - auto field_name = cursor.name(); - auto kind = cursor.byte(); - if (!module_name || !field_name || !kind) { - return unexpected(std::string("truncated wasm import entry")); - } - switch (*kind) { - case 0: { - auto type_index = cursor.varUint32(); - if (!type_index) { - return unexpected(type_index.error()); - } - module->function_types.push_back(*type_index); - break; - } - case 1: { - auto element_type = cursor.byte(); - if (!element_type || (*element_type != UINT8_C(0x70) && *element_type != UINT8_C(0x6F))) { - return unexpected(std::string("invalid wasm table import")); - } - auto limits = readLimits(&cursor); - if (!limits) { - return unexpected(limits.error()); - } - break; - } - case 2: { - auto limits = readLimits(&cursor); - if (!limits) { - return unexpected(limits.error()); - } - break; - } - case 3: { - auto value_type = cursor.byte(); - auto mutability = cursor.byte(); - if (!value_type || !mutability || !validValueType(*value_type) || *mutability > 1) { - return unexpected(std::string("invalid wasm global import")); - } - break; - } - case 4: { - auto attribute = cursor.varUint32(); - auto type_index = cursor.varUint32(); - if (!attribute || !type_index) { - return unexpected(std::string("truncated wasm tag import")); - } - break; - } - default: - return unexpected(std::string("unknown wasm import kind")); - } - } - return requireConsumed(cursor, "import"); -} - -[[nodiscard]] Expected parseFunctionSection(Cursor cursor, ModuleInfo* module) { - auto count = cursor.varUint32(); - if (!count) { - return unexpected(count.error()); - } - try { - module->function_types.reserve(module->function_types.size() + *count); - for (uint32_t index = 0; index < *count; ++index) { - auto type_index = cursor.varUint32(); - if (!type_index) { - return unexpected(type_index.error()); - } - module->function_types.push_back(*type_index); - } - } catch (const std::bad_alloc&) { - return unexpected(std::string("allocation failed while reading the wasm function section")); - } - return requireConsumed(cursor, "function"); -} - -[[nodiscard]] Expected parseExportSection(Cursor cursor, ModuleInfo* module) { - auto count = cursor.varUint32(); - if (!count) { - return unexpected(count.error()); - } - try { - module->exports.reserve(*count); - for (uint32_t index = 0; index < *count; ++index) { - auto name = cursor.name(); - auto kind = cursor.byte(); - auto item_index = cursor.varUint32(); - if (!name || !kind || !item_index) { - return unexpected(std::string("truncated wasm export entry")); - } - if (std::any_of( - module->exports.begin(), module->exports.end(), [&](const Export& item) { return item.name == *name; })) { - return unexpected(std::string("duplicate wasm export name: ") + *name); - } - module->exports.push_back(Export{std::move(*name), *kind, *item_index}); - } - } catch (const std::bad_alloc&) { - return unexpected(std::string("allocation failed while reading the wasm export section")); - } - return requireConsumed(cursor, "export"); -} - -[[nodiscard]] Expected inspectModule(Span wasm) { - if (wasm.size() < kWasmPreamble.size() || !std::equal(kWasmPreamble.begin(), kWasmPreamble.end(), wasm.begin())) { - return unexpected(std::string("invalid wasm preamble")); - } - - ModuleInfo module; - Cursor cursor(wasm.subspan(kWasmPreamble.size())); - std::array seen{}; - while (!cursor.empty()) { - auto section_id = cursor.byte(); - auto section_size = cursor.varUint32(); - if (!section_id || !section_size) { - return unexpected(std::string("truncated wasm section header")); - } - if (*section_id > 12) { - return unexpected(std::string("unknown wasm section id")); - } - auto section = cursor.take(*section_size); - if (!section) { - return unexpected(section.error()); - } - ++module.section_count; - if (*section_id != 0) { - if (seen[*section_id]) { - return unexpected(std::string("duplicate standard wasm section")); - } - seen[*section_id] = true; - } - Expected parsed; - switch (*section_id) { - case 1: - parsed = parseTypeSection(*section, &module); - break; - case 2: - parsed = parseImportSection(*section, &module); - break; - case 3: - parsed = parseFunctionSection(*section, &module); - break; - case 7: - parsed = parseExportSection(*section, &module); - break; - case 8: - module.has_start_section = true; - break; - default: - break; - } - if (!parsed) { - return unexpected(parsed.error()); - } - } - if (!seen[1] || !seen[3] || !seen[7]) { - return unexpected(std::string("wasm is missing a type, function, or export section")); - } - for (const uint32_t type_index : module.function_types) { - if (type_index >= module.types.size()) { - return unexpected(std::string("wasm function references an invalid type index")); - } - } - return module; -} - -[[nodiscard]] Expected> readFile(const std::string& path) { - std::ifstream input(path, std::ios::binary | std::ios::ate); - if (!input) { - return unexpected(std::string("cannot open file: ") + path); - } - const std::streamoff end = input.tellg(); - if (end < 0 || static_cast(end) > std::numeric_limits::max() || - end > std::numeric_limits::max()) { - return unexpected(std::string("file size is invalid: ") + path); - } - std::vector bytes(static_cast(end)); - input.seekg(0); - if (!bytes.empty()) { - input.read(reinterpret_cast(bytes.data()), static_cast(bytes.size())); - } - if (!input) { - return unexpected(std::string("cannot read complete file: ") + path); - } - return bytes; -} - -[[nodiscard]] Expected writeFile(const std::string& path, Span bytes) { - std::ofstream output(path, std::ios::binary | std::ios::trunc); - if (!output) { - return unexpected(std::string("cannot create file: ") + path); - } - if (!bytes.empty()) { - output.write(reinterpret_cast(bytes.data()), static_cast(bytes.size())); - } - if (!output) { - return unexpected(std::string("cannot write complete file: ") + path); - } - return {}; -} - -[[nodiscard]] const Export* findExport(const ModuleInfo& module, std::string_view name) { - const Export* result = nullptr; - for (const auto& item : module.exports) { - if (item.name == name) { - if (result != nullptr) { - return nullptr; - } - result = &item; - } - } - return result; -} - -/// One mandatory reactor export and its exact wasm function signature. -struct ExpectedExport { - std::string_view name; - std::vector parameters; - std::vector results; -}; - -[[nodiscard]] Expected requireFunction( - const ModuleInfo& module, std::string_view name, const std::vector& parameters, - const std::vector& results) { - const Export* item = findExport(module, name); - if (item == nullptr) { - return unexpected(std::string("missing or duplicate function export: ") + std::string(name)); - } - if (item->kind != 0 || item->index >= module.function_types.size()) { - return unexpected(std::string("export is not a valid function: ") + std::string(name)); - } - const FunctionType& type = module.types[module.function_types[item->index]]; - if (type.parameters != parameters || type.results != results) { - return unexpected(std::string("function export has the wrong wasm signature: ") + std::string(name)); - } - return {}; -} - -[[nodiscard]] Expected audit(const std::string& wasm_path, const std::string& manifest_path) { - auto wasm = readFile(wasm_path); - auto manifest = readFile(manifest_path); - if (!wasm) { - return unexpected(wasm.error()); - } - if (!manifest) { - return unexpected(manifest.error()); - } - auto embedded = PJ::parser_module::readManifestSection(*wasm); - if (!embedded) { - return unexpected(embedded.error()); - } - if (embedded->size() != manifest->size() || !std::equal(embedded->begin(), embedded->end(), manifest->begin())) { - return unexpected(std::string("embedded parser-module manifest bytes do not match the source file")); - } - - auto module = inspectModule(*wasm); - if (!module) { - return unexpected(module.error()); - } - if (module->has_start_section) { - return unexpected(std::string("wasm reactor contains a forbidden start section")); - } - if (findExport(*module, "_start") != nullptr) { - return unexpected(std::string("wasm reactor exports forbidden _start")); - } - if (findExport(*module, PJ_MODULE_MANIFEST_ADDR_EXPORT_NAME) != nullptr || - findExport(*module, PJ_MODULE_MANIFEST_LEN_EXPORT_NAME) != nullptr) { - return unexpected(std::string("wasm reactor exports native-only manifest metadata")); - } - - const std::array expected{{ - {PJ_MODULE_ABI_EXPORT_NAME, {}, {kI32}}, - {PJ_MODULE_CREATE_EXPORT_NAME, {kI32}, {kI64}}, - {PJ_MODULE_DESTROY_EXPORT_NAME, {kI64}, {}}, - {PJ_MODULE_BIND_EXPORT_NAME, {kI64, kI64, kI64}, {kI32}}, - {PJ_MODULE_PARSE_EXPORT_NAME, {kI64, kI64, kI64, kI64, kI64}, {kI32}}, - {PJ_MODULE_LAST_ERROR_EXPORT_NAME, {kI64, kI64, kI64}, {kI64}}, - {PJ_MODULE_ALLOC_EXPORT_NAME, {kI64}, {kI64}}, - {PJ_MODULE_FREE_EXPORT_NAME, {kI64, kI64}, {}}, - {"_initialize", {}, {}}, - }}; - for (const auto& entry : expected) { - auto valid = requireFunction(*module, entry.name, entry.parameters, entry.results); - if (!valid) { - return unexpected(valid.error()); - } - } - - for (const auto& item : module->exports) { - if (item.name.rfind("pj_module_", 0) != 0) { - continue; - } - const auto found = std::find_if( - expected.begin(), expected.end(), [&](const ExpectedExport& entry) { return entry.name == item.name; }); - if (found == expected.end()) { - return unexpected(std::string("unexpected parser-module export: ") + item.name); - } - } - - std::cout << "WASM parser-module ABI conformance: PASS\n" - << " sections enumerated: " << module->section_count << '\n' - << " function types: " << module->types.size() << ", functions: " << module->function_types.size() - << ", exports: " << module->exports.size() << '\n' - << " operational exports: 8 exact signatures verified\n" - << " reactor: _initialize exported; _start/start section absent\n" - << " native-only metadata exports: absent\n" - << " manifest section: exactly one, " << embedded->size() << " exact bytes\n"; - return {}; -} - -[[nodiscard]] Expected embed( - const std::string& input_path, const std::string& manifest_path, const std::string& output_path) { - auto wasm = readFile(input_path); - auto manifest = readFile(manifest_path); - if (!wasm) { - return unexpected(wasm.error()); - } - if (!manifest) { - return unexpected(manifest.error()); - } - auto output = PJ::parser_module::appendManifestSection(*wasm, *manifest); - if (!output) { - return unexpected(output.error()); - } - return writeFile(output_path, *output); -} - -} // namespace - -int main(int argc, char** argv) { - Expected result = unexpected(std::string("invalid arguments")); - if (argc == 5 && std::string_view(argv[1]) == "--embed") { - result = embed(argv[2], argv[3], argv[4]); - } else if (argc == 4 && std::string_view(argv[1]) == "--audit") { - result = audit(argv[2], argv[3]); - } else { - std::cerr << "usage: parser_module_wasm_audit --embed INPUT.wasm MANIFEST.json OUTPUT.wasm\n" - " or: parser_module_wasm_audit --audit MODULE.wasm MANIFEST.json\n"; - return 2; - } - if (!result) { - std::cerr << "parser-module wasm audit failed: " << result.error() << '\n'; - return 1; - } - return 0; -} diff --git a/pj_base/tests/parser_module_wasm_test.cpp b/pj_base/tests/parser_module_wasm_test.cpp new file mode 100644 index 00000000..6381d1a1 --- /dev/null +++ b/pj_base/tests/parser_module_wasm_test.cpp @@ -0,0 +1,78 @@ +// Copyright 2026 Davide Faconti +// SPDX-License-Identifier: Apache-2.0 + +#include "pj_base/parser_module_wasm.hpp" + +#include + +#include +#include +#include +#include + +namespace PJ::parser_module { +namespace { + +std::vector module() { + return {0x00, 0x61, 0x73, 0x6D, 0x01, 0x00, 0x00, 0x00}; +} + +void appendSection(std::vector* wasm, uint8_t id, const std::vector& payload) { + ASSERT_LT(payload.size(), 128U); + wasm->push_back(id); + wasm->push_back(static_cast(payload.size())); + wasm->insert(wasm->end(), payload.begin(), payload.end()); +} + +void expectRejected(std::vector wasm, std::string expected) { + auto inspected = inspectWasmModule(wasm); + ASSERT_FALSE(inspected.has_value()); + EXPECT_NE(inspected.error().find(expected), std::string::npos) << inspected.error(); +} + +TEST(ParserModuleWasm, RejectsMalformedSyntheticSections) { + auto truncated = module(); + truncated.insert(truncated.end(), {1, 4, 0}); + expectRejected(std::move(truncated), "section exceeds"); + + auto overflowing_varuint = module(); + overflowing_varuint.insert(overflowing_varuint.end(), {1, 0x80, 0x80, 0x80, 0x80, 0x10}); + expectRejected(std::move(overflowing_varuint), "section header"); + + auto duplicate = module(); + appendSection(&duplicate, 1, {0}); + appendSection(&duplicate, 1, {0}); + expectRejected(std::move(duplicate), "duplicate standard"); + + auto trailing = module(); + appendSection(&trailing, 1, {0, 0}); + expectRejected(std::move(trailing), "trailing bytes"); + + auto invalid_value_type = module(); + appendSection(&invalid_value_type, 1, {1, 0x60, 1, 0x01, 0}); + expectRejected(std::move(invalid_value_type), "invalid value type"); + + auto unknown_section = module(); + appendSection(&unknown_section, 13, {}); + expectRejected(std::move(unknown_section), "unknown wasm section id"); +} + +TEST(ParserModuleWasm, RejectsInvalidIndicesAndUnboundedVectorCounts) { + auto invalid_type_index = module(); + appendSection(&invalid_type_index, 1, {1, 0x60, 0, 0}); + appendSection(&invalid_type_index, 3, {1, 1}); + expectRejected(std::move(invalid_type_index), "invalid type index"); + + auto invalid_function_index = module(); + appendSection(&invalid_function_index, 1, {1, 0x60, 0, 0}); + appendSection(&invalid_function_index, 3, {1, 0}); + appendSection(&invalid_function_index, 7, {1, 1, 'f', 0, 1}); + expectRejected(std::move(invalid_function_index), "invalid function index"); + + auto hostile_count = module(); + appendSection(&hostile_count, 1, {0xFF, 0xFF, 0xFF, 0xFF, 0x0F}); + expectRejected(std::move(hostile_count), "count exceeds the remaining section bytes"); +} + +} // namespace +} // namespace PJ::parser_module diff --git a/pj_base/tools/pj_wasm_embed_manifest.cpp b/pj_base/tools/pj_wasm_embed_manifest.cpp new file mode 100644 index 00000000..f63b6dc6 --- /dev/null +++ b/pj_base/tools/pj_wasm_embed_manifest.cpp @@ -0,0 +1,156 @@ +// Copyright 2026 Davide Faconti +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "pj_base/parser_module_manifest.hpp" +#include "pj_base/parser_module_wasm.hpp" + +namespace { + +using PJ::Expected; +using PJ::Span; +using PJ::unexpected; + +[[nodiscard]] Expected> readFile(const std::string& path) { + std::ifstream input(path, std::ios::binary | std::ios::ate); + if (!input) { + return unexpected(std::string("cannot open file: ") + path); + } + const std::streamoff end = input.tellg(); + if (end < 0 || static_cast(end) > std::numeric_limits::max() || + end > std::numeric_limits::max()) { + return unexpected(std::string("file size is invalid: ") + path); + } + std::vector bytes(static_cast(end)); + input.seekg(0); + if (!bytes.empty()) { + input.read(reinterpret_cast(bytes.data()), static_cast(bytes.size())); + } + if (!input) { + return unexpected(std::string("cannot read complete file: ") + path); + } + return bytes; +} + +[[nodiscard]] Expected writeFile(const std::string& path, Span bytes) { + std::ofstream output(path, std::ios::binary | std::ios::trunc); + if (!output) { + return unexpected(std::string("cannot create file: ") + path); + } + if (!bytes.empty()) { + output.write(reinterpret_cast(bytes.data()), static_cast(bytes.size())); + } + if (!output) { + return unexpected(std::string("cannot write complete file: ") + path); + } + return {}; +} + +[[nodiscard]] Expected verify( + const std::string& wasm_path, const std::string& manifest_path, uint64_t maximum_memory_bytes) { + auto wasm = readFile(wasm_path); + auto manifest = readFile(manifest_path); + if (!wasm) { + return unexpected(wasm.error()); + } + if (!manifest) { + return unexpected(manifest.error()); + } + auto embedded = PJ::parser_module::readManifestSection(*wasm); + if (!embedded) { + return unexpected(embedded.error()); + } + if (embedded->size() != manifest->size() || !std::equal(embedded->begin(), embedded->end(), manifest->begin())) { + return unexpected(std::string("embedded parser-module manifest bytes do not match the source file")); + } + + auto module = PJ::parser_module::inspectWasmModule(*wasm); + if (!module) { + return unexpected(module.error()); + } + auto abi = PJ::parser_module::validateParserModuleWasmAbi(*module); + if (!abi) { + return unexpected(abi.error()); + } + if (!module->imports.empty()) { + const auto& imported = module->imports.front(); + return unexpected("disallowed parser-module import: " + imported.module + "." + imported.name); + } + auto memory = PJ::parser_module::validateParserModuleWasmMemory(*module, maximum_memory_bytes); + if (!memory) { + return unexpected(memory.error()); + } + + std::cout << "WASM parser-module ABI conformance: PASS\n" + << " sections enumerated: " << module->section_count << '\n' + << " function types: " << module->function_type_count << ", functions: " << module->function_count + << ", exports: " << module->exports.size() << '\n' + << " operational exports: 8 exact signatures verified\n" + << " reactor: _initialize exported; _start/start section absent\n" + << " native-only metadata exports: absent\n" + << " imports: empty frozen allow-list verified\n" + << " declared linear-memory maximum: " << *memory << " bytes\n" + << " manifest section: exactly one, " << embedded->size() << " exact bytes\n"; + return {}; +} + +[[nodiscard]] Expected embed( + const std::string& input_path, const std::string& manifest_path, const std::string& output_path) { + auto wasm = readFile(input_path); + auto manifest = readFile(manifest_path); + if (!wasm) { + return unexpected(wasm.error()); + } + if (!manifest) { + return unexpected(manifest.error()); + } + auto output = PJ::parser_module::appendManifestSection(*wasm, *manifest); + if (!output) { + return unexpected(output.error()); + } + return writeFile(output_path, *output); +} + +void usage() { + std::cerr << "usage: pj-wasm-embed-manifest embed INPUT.wasm MANIFEST.json OUTPUT.wasm\n" + " or: pj-wasm-embed-manifest verify MODULE.wasm MANIFEST.json [MAX_MEMORY_BYTES]\n"; +} + +} // namespace + +int main(int argc, char** argv) { + Expected result = unexpected(std::string("invalid arguments")); + if (argc == 5 && std::string_view(argv[1]) == "embed") { + result = embed(argv[2], argv[3], argv[4]); + } else if ((argc == 4 || argc == 5) && std::string_view(argv[1]) == "verify") { + uint64_t maximum_memory_bytes = UINT64_C(256) * 1024U * 1024U; + if (argc == 5) { + const std::string_view text(argv[4]); + const auto parsed = std::from_chars(text.data(), text.data() + text.size(), maximum_memory_bytes); + if (parsed.ec != std::errc{} || parsed.ptr != text.data() + text.size() || maximum_memory_bytes == 0) { + std::cerr << "parser-module wasm operation failed: MAX_MEMORY_BYTES must be a positive integer\n"; + return 1; + } + } + result = verify(argv[2], argv[3], maximum_memory_bytes); + } else { + usage(); + return 2; + } + if (!result) { + std::cerr << "parser-module wasm operation failed: " << result.error() << '\n'; + return 1; + } + return 0; +} diff --git a/pj_plugins/CLAUDE.md b/pj_plugins/CLAUDE.md index ea4afcf6..8960c274 100644 --- a/pj_plugins/CLAUDE.md +++ b/pj_plugins/CLAUDE.md @@ -17,9 +17,9 @@ submodule-internal modules; `pj_base` carries none). - `include/pj_plugins/host/` — host loaders + RAII handles for DataSource / MessageParser / Toolbox, the embedded-manifest `plugin_catalog` scanner (`scanPluginDsos` / `inspectPluginDso`), parser claim admission + per-route - resolution (`ParserClaimCatalog`, `ParserRouteResolver`), native functional - parser-module loading/execution (`NativeParserModule`, - `NativeParserModuleInstance`, `ParserModuleStrikeTracker`), + resolution (`ParserClaimCatalog`, `ParserRouteResolver`), native and Wasmer + functional parser-module loading/execution (`NativeParserModule`, + `WasmParserModule`, their instance wrappers, `ParserModuleStrikeTracker`), `ServiceRegistryBuilder`, `ConfigEnvelope`. The DSO duplicate-resolution catalog that composes loaded plugin families into a set is **host policy** and lives in the app (`pj_runtime`, `PluginRuntimeCatalog`), not here. @@ -53,6 +53,15 @@ submodule-internal modules; `pj_base` carries none). the complete per-handle export set and retains every opened DSO for the process session, including rejected artifacts. Instance wrappers still call `pj_module_destroy`; only the code mapping has session lifetime. +- **Wasm parser modules have an empty import allow-list in v1.** The loader + admits reactors with the exact operational exports, `_initialize`, exported + memory with a bounded declared maximum, no start function, and no imports. + One engine-owned compiled Wasmer module creates independent stores per + instance. Sequential cross-thread use is supported, but overlapping calls on + one instance are forbidden and must be serialized by the application host. + Wasmer metering is reset for every ABI call; exhaustion is a contract strike. + The pinned static archive has no public interrupt/epoch API, and native stack + depth uses Wasmer's guarded default. ## Read deeper | For | Read | @@ -63,7 +72,7 @@ submodule-internal modules; `pj_base` carries none). | Host loader + factory pattern | `include/pj_plugins/host/data_source_library.hpp`, `…/data_source_handle.hpp` | | Discovery from embedded manifests | `include/pj_plugins/host/plugin_catalog.hpp` (the duplicate-resolution catalog is host-side in `pj_runtime`) | | Parser claim admission and route selection | `include/pj_plugins/host/parser_claim_catalog.hpp`, `parser_route_resolver.hpp` | -| Native functional parser modules | `include/pj_plugins/host/native_parser_module.hpp`, `parser_module_runtime.hpp` | -| Authoring native functional parser modules | `../pj_base/include/pj_base/parser_module/README.md`, `module.hpp`, `../.claude/skills/plotjuggler-plugin/references/parser-module.md` | +| Native and wasm functional parser modules | `include/pj_plugins/host/native_parser_module.hpp`, `parser_module_runtime.hpp`, `wasm_parser_module.hpp`, `wasm_parser_module_runtime.hpp` | +| Authoring functional parser modules | `../pj_base/include/pj_base/parser_module/README.md`, `module.hpp`, `../.claude/skills/plotjuggler-plugin/references/parser-module.md` | | Service wiring into `bind()` | `include/pj_plugins/host/service_registry_builder.hpp` | | Builtin-object ingest policy | `include/pj_plugins/sdk/object_ingest_policy.hpp` | diff --git a/pj_plugins/CMakeLists.txt b/pj_plugins/CMakeLists.txt index b2bb9758..2d6d9e9d 100644 --- a/pj_plugins/CMakeLists.txt +++ b/pj_plugins/CMakeLists.txt @@ -1,5 +1,40 @@ find_package(nlohmann_json REQUIRED) +set(PJ_WASMER_ROOT "$ENV{PJ_WASMER_ROOT}" CACHE PATH + "Wasmer 7.0.1 C-API root used for wasm parser-module execution") +set(_pj_wasmer_include "${PJ_WASMER_ROOT}/include") +find_file(_pj_wasmer_library + NAMES libwasmer.a libwasmer.lib wasmer.lib + PATHS "${PJ_WASMER_ROOT}/lib" + NO_DEFAULT_PATH + NO_CACHE) +set(PJ_WASMER_AVAILABLE OFF) +if(NOT PJ_WASMER_ROOT) + message(STATUS + "Wasm parser-module loader skipped: PJ_WASMER_ROOT cache/env variable is not set") +elseif(NOT EXISTS "${_pj_wasmer_include}/wasm.h" OR + NOT EXISTS "${_pj_wasmer_include}/wasmer.h" OR + NOT _pj_wasmer_library) + message(STATUS + "Wasm parser-module loader skipped: Wasmer C-API is missing under PJ_WASMER_ROOT=${PJ_WASMER_ROOT}") +else() + file(STRINGS "${_pj_wasmer_include}/wasmer.h" _pj_wasmer_version_line + REGEX "^#define WASMER_VERSION ") + if(NOT _pj_wasmer_version_line MATCHES "\"7\\.0\\.1\"") + message(FATAL_ERROR + "Wasm parser-module loader requires Wasmer 7.0.1; found '${_pj_wasmer_version_line}'") + endif() + find_package(Threads REQUIRED) + add_library(pj_wasmer_static UNKNOWN IMPORTED) + set_target_properties(pj_wasmer_static PROPERTIES + IMPORTED_LOCATION "${_pj_wasmer_library}" + INTERFACE_INCLUDE_DIRECTORIES "${_pj_wasmer_include}" + ) + set(PJ_WASMER_AVAILABLE ON) + message(STATUS "Wasm parser-module loader enabled with Wasmer 7.0.1: ${PJ_WASMER_ROOT}") +endif() +set(PJ_SDK_WITH_WASMER ${PJ_WASMER_AVAILABLE} PARENT_SCOPE) + add_library(pj_plugin_loader_detail STATIC src/detail/vtable_validation.cpp ) @@ -94,6 +129,7 @@ add_library(plotjuggler_sdk::parser_claim_catalog ALIAS pj_parser_claim_catalog) add_library(pj_parser_module_host STATIC src/native_parser_module.cpp src/parser_module_runtime.cpp + src/parser_module_session_budget.cpp ) target_include_directories(pj_parser_module_host PUBLIC @@ -112,10 +148,43 @@ target_link_libraries(pj_parser_module_host PUBLIC pj_base PRIVATE + pj_parser_claim_catalog ${CMAKE_DL_LIBS} ) add_library(plotjuggler_sdk::parser_module_host ALIAS pj_parser_module_host) +if(PJ_WASMER_AVAILABLE) + add_library(pj_wasm_parser_module_host STATIC + src/wasm_parser_module.cpp + src/wasm_parser_module_runtime.cpp + ) + target_include_directories(pj_wasm_parser_module_host + PUBLIC + $ + $ + PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/src + ) + target_compile_features(pj_wasm_parser_module_host PUBLIC cxx_std_20) + target_compile_options(pj_wasm_parser_module_host PRIVATE ${PJ_WARNING_FLAGS}) + set_target_properties(pj_wasm_parser_module_host PROPERTIES + POSITION_INDEPENDENT_CODE ON + EXPORT_NAME wasm_parser_module_host + ) + target_link_libraries(pj_wasm_parser_module_host + PUBLIC + pj_parser_module_host + PRIVATE + pj_wasmer_static + Threads::Threads + ${CMAKE_DL_LIBS} + ) + if(UNIX) + target_link_libraries(pj_wasm_parser_module_host PRIVATE m) + endif() + add_library(plotjuggler_sdk::wasm_parser_module_host ALIAS pj_wasm_parser_module_host) +endif() + # --------------------------------------------------------------------------- # pj_data_source_host — host-side DataSource library loader # --------------------------------------------------------------------------- @@ -291,6 +360,9 @@ target_link_libraries(pj_plugin_host INTERFACE pj_parser_claim_catalog pj_parser_module_host ) +if(PJ_WASMER_AVAILABLE) + target_link_libraries(pj_plugin_host INTERFACE pj_wasm_parser_module_host) +endif() set_target_properties(pj_plugin_host PROPERTIES EXPORT_NAME plugin_host) add_library(plotjuggler_sdk::plugin_host ALIAS pj_plugin_host) @@ -319,6 +391,53 @@ function(pj_add_native_parser_module_fixture target) endif() endfunction() +if(PJ_WASMER_AVAILABLE) + add_executable(wasmer_shared_module_prototype_test tests/wasmer_shared_module_prototype_test.cpp) + target_compile_options(wasmer_shared_module_prototype_test PRIVATE ${PJ_WARNING_FLAGS}) + target_link_libraries(wasmer_shared_module_prototype_test PRIVATE + pj_wasmer_static Threads::Threads ${CMAKE_DL_LIBS} GTest::gtest_main) + if(UNIX) + target_link_libraries(wasmer_shared_module_prototype_test PRIVATE m) + endif() + add_test(NAME wasmer_shared_module_prototype_test COMMAND wasmer_shared_module_prototype_test) + set_tests_properties(wasmer_shared_module_prototype_test PROPERTIES LABELS "parser_module;wasmer") + + if(TARGET parser_module_wasm_conformance_fixture) + get_property(_pj_toy_wasm_path TARGET parser_module_wasm_conformance_fixture + PROPERTY PJ_PARSER_MODULE_WASM_FIXTURE_PATH) + add_executable(wasm_parser_module_test tests/wasm_parser_module_test.cpp) + add_dependencies(wasm_parser_module_test parser_module_wasm_conformance_fixture) + target_compile_definitions(wasm_parser_module_test PRIVATE + PJ_TOY_CDR_POINTCLOUD_WASM_PATH="${_pj_toy_wasm_path}") + target_compile_options(wasm_parser_module_test PRIVATE ${PJ_WARNING_FLAGS}) + target_link_libraries(wasm_parser_module_test PRIVATE + pj_wasm_parser_module_host pj_parser_claim_catalog GTest::gtest_main) + add_test(NAME wasm_parser_module_test COMMAND wasm_parser_module_test) + set_tests_properties(wasm_parser_module_test PROPERTIES LABELS "parser_module;wasmer;wasi") + + pj_add_parser_module(adversarial_wasm_parser_module + SOURCE "${CMAKE_CURRENT_SOURCE_DIR}/tests/adversarial_wasm_parser_module.cpp" + MANIFEST "${CMAKE_CURRENT_SOURCE_DIR}/tests/adversarial_wasm_parser_module.module.json" + TARGETS wasm + ) + get_property(_pj_adversarial_wasm_path TARGET adversarial_wasm_parser_module_wasm + PROPERTY PJ_PARSER_MODULE_WASM_PATH) + add_executable(wasm_parser_module_hardening_test + tests/wasm_parser_module_hardening_test.cpp) + add_dependencies(wasm_parser_module_hardening_test adversarial_wasm_parser_module_wasm) + target_compile_definitions(wasm_parser_module_hardening_test PRIVATE + PJ_ADVERSARIAL_WASM_PATH="${_pj_adversarial_wasm_path}") + target_compile_options(wasm_parser_module_hardening_test PRIVATE ${PJ_WARNING_FLAGS}) + target_link_libraries(wasm_parser_module_hardening_test PRIVATE + pj_wasm_parser_module_host GTest::gtest_main) + add_test(NAME wasm_parser_module_hardening_test COMMAND wasm_parser_module_hardening_test) + set_tests_properties(wasm_parser_module_hardening_test PROPERTIES + LABELS "parser_module;wasmer;wasi") + else() + message(STATUS "Wasm parser-module E2E skipped: the wasi-sdk fixture target is unavailable") + endif() +endif() + pj_add_native_parser_module_fixture(native_parser_module_fixture) pj_add_native_parser_module_fixture(native_parser_module_missing_export PJ_FIXTURE_OMIT_FREE) pj_add_native_parser_module_fixture(native_parser_module_wrong_abi PJ_FIXTURE_WRONG_ABI) @@ -570,6 +689,11 @@ target_link_libraries(parser_module_runtime_test PRIVATE ) add_test(NAME parser_module_runtime_test COMMAND parser_module_runtime_test) +add_executable(parser_module_session_budget_test tests/parser_module_session_budget_test.cpp) +target_compile_options(parser_module_session_budget_test PRIVATE ${PJ_WARNING_FLAGS}) +target_link_libraries(parser_module_session_budget_test PRIVATE pj_parser_module_host GTest::gtest_main) +add_test(NAME parser_module_session_budget_test COMMAND parser_module_session_budget_test) + # Keystone integration: a C++17 module authored and built only with the # header-only kit, then exercised through the production native loader/runtime. add_executable(parser_module_authoring_e2e_test tests/parser_module_authoring_e2e_test.cpp) @@ -686,6 +810,10 @@ endif() # PJ_BUILD_TESTS # --------------------------------------------------------------------------- if(PJ_INSTALL_SDK) + set(_pj_plugin_host_install_targets) + if(PJ_WASMER_AVAILABLE) + list(APPEND _pj_plugin_host_install_targets pj_wasm_parser_module_host) + endif() install(TARGETS pj_plugin_loader_detail pj_plugin_sdk @@ -696,6 +824,7 @@ if(PJ_INSTALL_SDK) pj_message_parser_host pj_toolbox_host pj_plugin_host + ${_pj_plugin_host_install_targets} EXPORT plotjuggler_sdkTargets ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR} LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR} diff --git a/pj_plugins/docs/ARCHITECTURE.md b/pj_plugins/docs/ARCHITECTURE.md index 696f56f0..321b2207 100644 --- a/pj_plugins/docs/ARCHITECTURE.md +++ b/pj_plugins/docs/ARCHITECTURE.md @@ -169,14 +169,50 @@ matching is bounded, so allocation failure is returned as data error rather than escaping as a trap. `PJ_FUNCTIONAL_PARSER` supplies the complete native export set, uses synchronized index+generation instance tokens to reject stale handles, and catches user exceptions at the C boundary. -`pj_add_parser_module(... TARGETS native)` builds a hidden-visibility module and -embeds its JSON manifest behind the native metadata exports. `TARGETS wasm` is -not available in SDK 0.22. The shared host codec can append and read the exact -JSON bytes in a wasm `pj_parser_module_manifest` custom section; the conditional -wasi-sdk 27 compile gate builds the toy source with C++17 and exceptions -disabled, then statically audits the reactor model and every operational export -signature. This is structural conformance only: no wasm loader or execution -runtime ships in this release. +`pj_add_parser_module(... TARGETS native wasm)` builds hidden-visibility native +and wasi-sdk 27 reactor artifacts from one source. It embeds the JSON manifest +behind the native metadata exports and in the wasm custom section. Wasm +reactors omit those two metadata exports and carry the exact JSON bytes in the +`pj_parser_module_manifest` custom section. The shared host codec appends and +reads that section; the conditional wasi-sdk 27 compile gate builds the same +toy module with C++17 and exceptions disabled, then statically audits the +reactor model and every operational export signature without executing wasm. + +When `PJ_WASMER_ROOT` selects the pinned Wasmer 7.0.1 C API, the optional +`WasmParserModule` loader applies that static audit before compilation and +requires exported memory plus an empty import set. The fixture supplies its +unreachable WASI I/O fallbacks internally, so no fd, path, socket, clock, +random, environment, or scheduler capability enters the frozen v1 allow-list. +An engine-owned compiled module is instantiated in one independent store per +bound instance. Store calls may migrate between threads sequentially, but the +host must serialize calls on an instance. The runtime reacquires linear memory +after every guest call, validates every returned range, and resolves splices +against the original host payload. Per-call Wasmer instruction metering is the +enforceable execution deadline; the pinned archive exposes no public interrupt +or epoch API. Artifacts must declare a bounded memory maximum, and a separate +session tracker admits module count, file size, total claims, active instances, +and aggregate per-instance declared memory. Guest traps and metering exhaustion +join malformed descriptors and bad offsets in the contract-violation strike +path; module-reported parse errors remain strike-free data errors. + +### Wasmer pin rationale (7.0.1, evaluated against 7.2.1 on 2026-08-09) + +The 7.0.1 pin was re-evaluated symbol-by-symbol against the 7.2.x line: + +- 7.2.x adds nothing the loader needs: `wasm_module_share/obtain` are still + absent from the static archive, the exported metering symbol set is + identical, and the "interruptable computation" work remains internal Rust + surface with no public C interrupt/epoch API. The only C-API additions are + `wasmer_features_*` toggles the loader does not require. +- The WASI-syscall CVEs fixed in 7.2.0 (unbounded host allocation in + `getcwd`/`random_get`, `poll_oneoff`, `sock_recv`/`sock_recv_from`) are + structurally unreachable here: the frozen empty import allow-list rejects + any module importing those syscalls before instantiation. **Re-evaluate the + pin before ever widening the import allow-list** — no release currently + combines those fixes with x86_64-darwin support. +- 7.2.0 dropped the x86_64-darwin target, so moving the pin would end wasm + parser-module support on Intel macOS while the SDK still ships x86_64 + macOS artifacts. ## 0. C protocol v4 (current under ABI v5) @@ -383,7 +419,8 @@ pj_plugins/ toolbox_library.cpp cmake/ - PjParserModule.cmake ← pj_add_parser_module native target helper + PjParserModule.cmake ← pj_add_parser_module native/wasm target helper + parser_module_wasi_no_io_stubs.cpp ← closes the v1 empty wasm import set (PlotJuggler application repo — not part of this SDK submodule) pj_datastore/ diff --git a/pj_plugins/include/pj_plugins/host/native_parser_module.hpp b/pj_plugins/include/pj_plugins/host/native_parser_module.hpp index 2930decf..ae36a880 100644 --- a/pj_plugins/include/pj_plugins/host/native_parser_module.hpp +++ b/pj_plugins/include/pj_plugins/host/native_parser_module.hpp @@ -6,10 +6,11 @@ * @file native_parser_module.hpp * @brief Session-lifetime loader for native functional parser modules. * - * Loading resolves the complete frozen module ABI and copies the module's - * embedded manifest. Successfully opened artifacts remain loaded for the - * process session; no module code or manifest pointer is used after unload. - * Manifest admission remains an explicit ParserClaimCatalog caller step. + * Loading resolves the complete frozen module ABI, copies the embedded + * manifest, and validates its identity and claims for budget accounting. + * Successfully opened artifacts remain loaded for the process session; no + * module code or manifest pointer is used after unload. Catalog insertion and + * provenance assignment remain an explicit ParserClaimCatalog caller step. */ #include @@ -18,6 +19,7 @@ #include "pj_base/diagnostic_sink.hpp" #include "pj_base/expected.hpp" +#include "pj_plugins/host/parser_module_session_budget.hpp" namespace PJ { @@ -36,6 +38,11 @@ class NativeParserModule { [[nodiscard]] static Expected load( std::string_view path, DiagnosticSink sink = {}, std::string diagnostic_source = "NativeParserModule"); + /// Load using an application-owned aggregate session budget. + [[nodiscard]] static Expected load( + std::string_view path, std::shared_ptr budget, DiagnosticSink sink = {}, + std::string diagnostic_source = "NativeParserModule"); + [[nodiscard]] bool valid() const noexcept { return state_ != nullptr; } diff --git a/pj_plugins/include/pj_plugins/host/parser_module_runtime.hpp b/pj_plugins/include/pj_plugins/host/parser_module_runtime.hpp index dbccc8e4..d78f674b 100644 --- a/pj_plugins/include/pj_plugins/host/parser_module_runtime.hpp +++ b/pj_plugins/include/pj_plugins/host/parser_module_runtime.hpp @@ -126,6 +126,7 @@ class NativeParserModuleInstance { parser_module::Route bound_route_ = parser_module::Route::kScalar; uint16_t expected_object_type_ = 0; bool bound_ = false; + bool instance_budget_reserved_ = false; }; struct ParserModuleClaimKey { diff --git a/pj_plugins/include/pj_plugins/host/parser_module_session_budget.hpp b/pj_plugins/include/pj_plugins/host/parser_module_session_budget.hpp new file mode 100644 index 00000000..4516e709 --- /dev/null +++ b/pj_plugins/include/pj_plugins/host/parser_module_session_budget.hpp @@ -0,0 +1,105 @@ +#pragma once +// Copyright 2026 Davide Faconti +// SPDX-License-Identifier: Apache-2.0 + +/** + * @file parser_module_session_budget.hpp + * @brief Pure admission accounting for parser-module session limits. + * + * This state is deliberately non-thread-safe. The application host owns + * serialization and calls it before compilation or lazy instantiation. A + * declined reservation never mutates usage. + */ + +#include +#include +#include +#include +#include +#include + +namespace PJ { + +struct ParserModuleSessionBudgetLimits { + static constexpr uint64_t kDefaultMaximumModules = 64; + static constexpr uint64_t kDefaultMaximumArtifactBytes = UINT64_C(64) * 1024U * 1024U; + static constexpr uint64_t kDefaultMaximumClaims = 4096; + static constexpr uint64_t kDefaultMaximumActiveInstances = 128; + static constexpr uint64_t kDefaultMaximumLinearMemoryBytes = UINT64_C(4) * 1024U * 1024U * 1024U; + + uint64_t maximum_modules = kDefaultMaximumModules; + uint64_t maximum_artifact_bytes = kDefaultMaximumArtifactBytes; + uint64_t maximum_claims = kDefaultMaximumClaims; + uint64_t maximum_active_instances = kDefaultMaximumActiveInstances; + uint64_t maximum_linear_memory_bytes = kDefaultMaximumLinearMemoryBytes; +}; + +enum class ParserModuleAdmissionOutcome : uint8_t { + kAccept, + kDecline, +}; + +enum class ParserModuleBudgetKind : uint8_t { + kNone, + kModuleCount, + kArtifactFileSize, + kTotalClaims, + kActiveInstances, + kTotalLinearMemory, +}; + +struct ParserModuleAdmissionDecision { + ParserModuleAdmissionOutcome outcome = ParserModuleAdmissionOutcome::kDecline; + ParserModuleBudgetKind exhausted_budget = ParserModuleBudgetKind::kNone; + std::string diagnostic; + + [[nodiscard]] bool accepted() const noexcept { + return outcome == ParserModuleAdmissionOutcome::kAccept; + } +}; + +struct ParserModuleSessionBudgetUsage { + uint64_t modules = 0; + uint64_t claims = 0; + uint64_t active_instances = 0; + uint64_t declared_linear_memory_bytes = 0; +}; + +class ParserModuleSessionBudgetTracker { + public: + explicit ParserModuleSessionBudgetTracker(ParserModuleSessionBudgetLimits limits = {}); + + /// Reserve one compiled module before compilation. `artifact_bytes` is a + /// per-file gate; claims contribute to the aggregate session total. + [[nodiscard]] ParserModuleAdmissionDecision admitModule( + std::string module_id, uint64_t artifact_bytes, uint64_t claim_count, uint64_t declared_linear_memory_maximum); + + /// Reserve one lazy instance. Its module's declared memory maximum is added + /// to aggregate memory because every instance owns an independent store. + [[nodiscard]] ParserModuleAdmissionDecision admitInstance(std::string_view module_id); + + [[nodiscard]] bool releaseInstance(std::string_view module_id); + [[nodiscard]] bool releaseModule(std::string_view module_id); + + [[nodiscard]] const ParserModuleSessionBudgetLimits& limits() const noexcept; + [[nodiscard]] ParserModuleSessionBudgetUsage usage() const noexcept; + + private: + struct ModuleReservation { + uint64_t artifact_bytes = 0; + uint64_t claim_count = 0; + uint64_t declared_linear_memory_maximum = 0; + uint64_t active_instances = 0; + }; + + ParserModuleSessionBudgetLimits limits_; + ParserModuleSessionBudgetUsage usage_; + std::map> modules_; +}; + +/// Process-session defaults used by loader overloads that are not supplied an +/// application-owned tracker. The returned tracker is shared by native and +/// wasm admission. +[[nodiscard]] std::shared_ptr defaultParserModuleSessionBudget(); + +} // namespace PJ diff --git a/pj_plugins/include/pj_plugins/host/wasm_parser_module.hpp b/pj_plugins/include/pj_plugins/host/wasm_parser_module.hpp new file mode 100644 index 00000000..3b93e423 --- /dev/null +++ b/pj_plugins/include/pj_plugins/host/wasm_parser_module.hpp @@ -0,0 +1,105 @@ +#pragma once +// Copyright 2026 Davide Faconti +// SPDX-License-Identifier: Apache-2.0 + +/** + * @file wasm_parser_module.hpp + * @brief Wasmer-backed loader for sandboxed functional parser modules. + * + * Admission is passive: the loader validates the manifest section, reactor + * shape, operational signatures, exported memory, and imports before Wasmer + * compiles the artifact. The v1 import allow-list is deliberately empty. In + * particular, every `wasi_snapshot_preview1` fd, path, socket, environment, + * clock, random, process, and scheduler import is rejected. + * + * Wasmer 7.0.1's static C archive does not provide the share/obtain symbols + * declared by wasm.h. Its engine-owned `wasmer_module_new` extension is the + * equivalent used here: one compiled module is instantiated in an independent + * store for every bound instance. The exit-criterion prototype also verified + * that a store/instance tolerates sequential calls from a thread other than + * its creator. Instances therefore have no creator-thread affinity, but calls + * on one instance must never overlap. Per-store executor serialization remains + * a host responsibility. + * + * Wasmer's metering middleware is enabled for every compiled module and each + * guest call receives a fresh instruction-point allowance. The pinned archive + * exports no public interrupt or epoch API, so metering is the enforceable + * deadline mechanism. Linear memory must declare a maximum within the loader + * cap; Wasmer enforces that maximum at runtime. The SDK authoring preset puts + * a configurable 1 MiB guest shadow stack before data segments so overflow + * traps instead of corrupting them. Native engine stack depth relies on + * Wasmer 7's guarded default because its C API exposes no stack-limit setter. + */ + +#include +#include +#include +#include + +#include "pj_base/diagnostic_sink.hpp" +#include "pj_base/expected.hpp" +#include "pj_plugins/host/parser_module_runtime.hpp" +#include "pj_plugins/host/parser_module_session_budget.hpp" + +namespace PJ { + +namespace detail { +struct WasmParserModuleState; +} + +class WasmParserModuleInstance; + +struct WasmParserModuleLimits { + static constexpr uint64_t kDefaultMaximumArtifactBytes = UINT64_C(64) * 1024U * 1024U; + static constexpr uint64_t kDefaultMaximumLinearMemoryBytes = UINT64_C(256) * 1024U * 1024U; + static constexpr uint64_t kDefaultMeteringPointsPerCall = UINT64_C(10000000); + + uint64_t maximum_artifact_bytes = kDefaultMaximumArtifactBytes; + uint64_t maximum_linear_memory_bytes = kDefaultMaximumLinearMemoryBytes; + uint64_t metering_points_per_call = kDefaultMeteringPointsPerCall; +}; + +class WasmParserModule { + public: + WasmParserModule() = default; + + /// Read, validate, and compile one wasm parser module. Rejection emits + /// exactly one error diagnostic when a sink is supplied. + [[nodiscard]] static Expected load( + std::string_view path, DiagnosticSink sink = {}, std::string diagnostic_source = "WasmParserModule"); + + /// Load with explicit artifact, linear-memory, and instruction budgets. + [[nodiscard]] static Expected load( + std::string_view path, const WasmParserModuleLimits& limits, DiagnosticSink sink = {}, + std::string diagnostic_source = "WasmParserModule"); + + /// Load using an application-owned aggregate session budget. + [[nodiscard]] static Expected load( + std::string_view path, std::shared_ptr budget, DiagnosticSink sink = {}, + std::string diagnostic_source = "WasmParserModule"); + + /// Load with both per-artifact limits and aggregate session budgets. + [[nodiscard]] static Expected load( + std::string_view path, const WasmParserModuleLimits& limits, + std::shared_ptr budget, DiagnosticSink sink = {}, + std::string diagnostic_source = "WasmParserModule"); + + [[nodiscard]] bool valid() const noexcept { + return state_ != nullptr; + } + + [[nodiscard]] std::string_view path() const noexcept; + [[nodiscard]] std::string_view manifestJson() const noexcept; + [[nodiscard]] uint64_t artifactSize() const noexcept; + [[nodiscard]] uint64_t declaredLinearMemoryMaximum() const noexcept; + [[nodiscard]] ParserModuleStrikeState strikeState(uint32_t claim_index) const; + + private: + explicit WasmParserModule(std::shared_ptr state); + + std::shared_ptr state_; + + friend class WasmParserModuleInstance; +}; + +} // namespace PJ diff --git a/pj_plugins/include/pj_plugins/host/wasm_parser_module_runtime.hpp b/pj_plugins/include/pj_plugins/host/wasm_parser_module_runtime.hpp new file mode 100644 index 00000000..cf3b04aa --- /dev/null +++ b/pj_plugins/include/pj_plugins/host/wasm_parser_module_runtime.hpp @@ -0,0 +1,78 @@ +#pragma once +// Copyright 2026 Davide Faconti +// SPDX-License-Identifier: Apache-2.0 + +/** + * @file wasm_parser_module_runtime.hpp + * @brief Store-per-instance wasm parser-module lifecycle wrapper. + * + * The wrapper is move-only and not concurrently callable. A host may migrate + * it between threads when calls do not overlap. Every host access to guest + * memory re-acquires the current base and size after the preceding guest call. + */ + +#include +#include +#include +#include + +#include "pj_base/expected.hpp" +#include "pj_base/parser_module_abi.h" +#include "pj_plugins/host/parser_module_runtime.hpp" +#include "pj_plugins/host/wasm_parser_module.hpp" + +namespace PJ { + +namespace detail { +struct WasmParserModuleInstanceState; +} + +enum class WasmParserModuleCreateOutcome : uint8_t { + kError, + kAdmissionDecline, +}; + +struct WasmParserModuleCreateError { + WasmParserModuleCreateOutcome outcome = WasmParserModuleCreateOutcome::kError; + ParserModuleFaultKind fault = ParserModuleFaultKind::kNone; + std::string message; +}; + +class WasmParserModuleInstance { + public: + WasmParserModuleInstance() = default; + ~WasmParserModuleInstance(); + + WasmParserModuleInstance(WasmParserModuleInstance&& other) noexcept; + WasmParserModuleInstance& operator=(WasmParserModuleInstance&& other) noexcept; + + WasmParserModuleInstance(const WasmParserModuleInstance&) = delete; + WasmParserModuleInstance& operator=(const WasmParserModuleInstance&) = delete; + + /// Instantiate the shared compiled module in a new store, run `_initialize` + /// exactly once, then create the manifest claim at `claim_index`. + [[nodiscard]] static Expected create( + const WasmParserModule& module, uint32_t claim_index); + + [[nodiscard]] Expected bind(const parser_module::BindingInfoV1& info); + + /// Parse one message. Contract violations accrue per module claim. The + /// third violation destroys and recreates the instance through the accepted + /// create/bind inputs; a second quarantine disables the claim for the + /// session and invalidates this wrapper. + [[nodiscard]] Expected parse(const parser_module::ParseInputV1& input); + + [[nodiscard]] bool valid() const noexcept; + [[nodiscard]] uint32_t claimIndex() const noexcept; + [[nodiscard]] ParserModuleStrikeState strikeState() const; + [[nodiscard]] std::string_view lifecycleDiagnostic() const noexcept; + + private: + explicit WasmParserModuleInstance(std::unique_ptr state); + + [[nodiscard]] Expected recreateBoundInstance(); + + std::unique_ptr state_; +}; + +} // namespace PJ diff --git a/pj_plugins/src/detail/native_parser_module_state.hpp b/pj_plugins/src/detail/native_parser_module_state.hpp index 91a7a7e7..1c920dfe 100644 --- a/pj_plugins/src/detail/native_parser_module_state.hpp +++ b/pj_plugins/src/detail/native_parser_module_state.hpp @@ -2,17 +2,26 @@ // Copyright 2026 Davide Faconti // SPDX-License-Identifier: Apache-2.0 +#include #include +#include #include "detail/native_parser_module_loader.hpp" #include "pj_base/parser_module_abi.h" +#include "pj_plugins/host/parser_module_session_budget.hpp" namespace PJ::detail { struct NativeParserModuleState { + ~NativeParserModuleState(); + NativeModuleHandle handle = nullptr; std::string path; std::string manifest_json; + std::string module_id; + std::vector claim_ids; + std::shared_ptr session_budget; + bool module_budget_reserved = false; PJ_module_abi_fn_t abi = nullptr; PJ_module_create_fn_t create = nullptr; diff --git a/pj_plugins/src/detail/parser_module_result_helpers.hpp b/pj_plugins/src/detail/parser_module_result_helpers.hpp new file mode 100644 index 00000000..59b115b4 --- /dev/null +++ b/pj_plugins/src/detail/parser_module_result_helpers.hpp @@ -0,0 +1,141 @@ +#pragma once +// Copyright 2026 Davide Faconti +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "pj_base/builtin/builtin_object_codec.hpp" +#include "pj_base/builtin_object_abi.h" +#include "pj_base/expected.hpp" +#include "pj_base/parser_module_abi.h" +#include "pj_base/span.hpp" +#include "pj_plugins/host/parser_module_runtime.hpp" + +namespace PJ::detail { + +inline ParserModuleParseResult contractViolation(int32_t code, std::string message) { + return ParserModuleParseResult{ + .fault = ParserModuleFaultKind::kContractViolation, + .result_code = code, + .message = std::move(message), + .output = std::nullopt, + }; +} + +inline Expected ownScalarOutput(const parser_module::ScalarOutputV1& scalar) { + ParserModuleScalarOutput owned; + owned.has_timestamp = scalar.has_timestamp; + owned.timestamp_ns = scalar.timestamp_ns; + owned.fields.reserve(scalar.fields.size()); + for (const auto& field : scalar.fields) { + ParserModuleScalarValue value = std::visit( + [](const Value& item) -> ParserModuleScalarValue { + if constexpr (std::is_same_v) { + return std::string(item); + } else { + return item; + } + }, + field.value); + owned.fields.push_back(ParserModuleScalarField{.name = std::string(field.name), .value = std::move(value)}); + } + return owned; +} + +inline Expected ownObjectOutput( + const parser_module::ObjectOutputV1& object, Span input_payload, uint16_t expected_type) { + if (object.object_type != expected_type) { + return unexpected( + "output object type " + std::to_string(object.object_type) + " does not match bound type " + + std::to_string(expected_type)); + } + + const auto type = static_cast(object.object_type); + auto decoded = deserializeBuiltinObject(type, object.wire.data(), object.wire.size()); + if (!decoded) { + return unexpected("output canonical wire is malformed: " + decoded.error()); + } + + ParserModuleObjectOutput owned; + owned.object = std::move(*decoded); + owned.wire.assign(object.wire.begin(), object.wire.end()); + if (object.splice.has_value()) { + uint32_t eligible_field = 0; + if (!pj_builtin_object_splice_field_number_v1(object.object_type, &eligible_field) || + eligible_field != object.splice->field_number) { + return unexpected("output splice field is not eligible for the object type"); + } + const uint64_t payload_size = static_cast(input_payload.size()); + if (object.splice->input_offset > payload_size || + object.splice->input_length > payload_size - object.splice->input_offset) { + return unexpected("output splice range is outside the parse payload"); + } + const auto offset = static_cast(object.splice->input_offset); + const auto length = static_cast(object.splice->input_length); + auto materialized = std::make_shared>( + input_payload.begin() + static_cast(offset), + input_payload.begin() + static_cast(offset + length)); + const auto attach = [&]() -> bool { + auto* typed = std::any_cast(&owned.object); + if (typed == nullptr) { + return false; + } + typed->data = Span(materialized->data(), materialized->size()); + typed->anchor = materialized; + return true; + }; + bool attached = false; + switch (type) { + case sdk::BuiltinObjectType::kImage: + attached = attach.template operator()(); + break; + case sdk::BuiltinObjectType::kPointCloud: + attached = attach.template operator()(); + break; + case sdk::BuiltinObjectType::kDepthImage: + attached = attach.template operator()(); + break; + case sdk::BuiltinObjectType::kOccupancyGrid: + attached = attach.template operator()(); + break; + case sdk::BuiltinObjectType::kCompressedPointCloud: + attached = attach.template operator()(); + break; + case sdk::BuiltinObjectType::kMesh3D: + attached = attach.template operator()(); + break; + case sdk::BuiltinObjectType::kVideoFrame: + attached = attach.template operator()(); + break; + case sdk::BuiltinObjectType::kOccupancyGridUpdate: + attached = attach.template operator()(); + break; + case sdk::BuiltinObjectType::kVoxelGrid: + attached = attach.template operator()(); + break; + default: + break; + } + if (!attached) { + return unexpected("output splice could not be attached to its canonical object"); + } + owned.splice = ParserModuleObjectSplice{ + .field_number = object.splice->field_number, + .input_offset = object.splice->input_offset, + .payload_bytes = *materialized, + }; + } + return owned; +} + +} // namespace PJ::detail diff --git a/pj_plugins/src/detail/wasm_parser_module_state.hpp b/pj_plugins/src/detail/wasm_parser_module_state.hpp new file mode 100644 index 00000000..414fdf6a --- /dev/null +++ b/pj_plugins/src/detail/wasm_parser_module_state.hpp @@ -0,0 +1,34 @@ +#pragma once +// Copyright 2026 Davide Faconti +// SPDX-License-Identifier: Apache-2.0 + +#include + +#include +#include +#include +#include + +#include "pj_plugins/host/parser_module_runtime.hpp" +#include "pj_plugins/host/parser_module_session_budget.hpp" + +namespace PJ::detail { + +struct WasmParserModuleState { + ~WasmParserModuleState(); + + std::string path; + std::string manifest_json; + std::string module_id; + std::vector claim_ids; + uint64_t artifact_size = 0; + uint64_t declared_linear_memory_maximum = 0; + uint64_t metering_points_per_call = 0; + wasm_engine_t* engine = nullptr; + wasm_module_t* module = nullptr; + std::shared_ptr session_budget; + std::shared_ptr strike_tracker; + bool module_budget_reserved = false; +}; + +} // namespace PJ::detail diff --git a/pj_plugins/src/native_parser_module.cpp b/pj_plugins/src/native_parser_module.cpp index 0a9a181f..78f01624 100644 --- a/pj_plugins/src/native_parser_module.cpp +++ b/pj_plugins/src/native_parser_module.cpp @@ -13,6 +13,7 @@ #include "detail/native_parser_module_state.hpp" #include "pj_base/parser_module_abi.h" +#include "pj_plugins/host/parser_claim_catalog.hpp" namespace PJ { namespace { @@ -58,11 +59,30 @@ Expected resolve( } // namespace +namespace detail { + +NativeParserModuleState::~NativeParserModuleState() { + if (module_budget_reserved && session_budget != nullptr) { + (void)session_budget->releaseModule(module_id); + } +} + +} // namespace detail + NativeParserModule::NativeParserModule(std::shared_ptr state) : state_(std::move(state)) {} Expected NativeParserModule::load( std::string_view path, DiagnosticSink sink, std::string diagnostic_source) { + return load(path, defaultParserModuleSessionBudget(), std::move(sink), std::move(diagnostic_source)); +} + +Expected NativeParserModule::load( + std::string_view path, std::shared_ptr budget, DiagnosticSink sink, + std::string diagnostic_source) { + if (budget == nullptr) { + return rejectLoad(path, sink, diagnostic_source, "native parser-module session budget is null"); + } detail::LibraryPathIdentity recorded_path; auto handle_result = detail::openNativeParserModule(path, &recorded_path); if (!handle_result) { @@ -113,6 +133,29 @@ Expected NativeParserModule::load( const auto* manifest = reinterpret_cast(static_cast(manifest_addr)); state->manifest_json.assign(manifest, static_cast(manifest_len)); + auto decoded_manifest = decodeParserModuleManifest(state->manifest_json, ParserClaimProvenance::kFolderDrop); + if (!decoded_manifest) { + return rejectLoad( + path, sink, diagnostic_source, "invalid native parser-module manifest: " + decoded_manifest.error()); + } + std::error_code file_error; + const uintmax_t artifact_size = std::filesystem::file_size(std::filesystem::path(path), file_error); + if (file_error || artifact_size > std::numeric_limits::max()) { + return rejectLoad(path, sink, diagnostic_source, "native parser-module artifact file size is unreadable"); + } + state->module_id = decoded_manifest->id; + state->claim_ids.reserve(decoded_manifest->claims.size()); + for (const auto& claim : decoded_manifest->claims) { + state->claim_ids.push_back(claim.claim_id); + } + state->session_budget = budget; + auto admission = budget->admitModule( + decoded_manifest->id, static_cast(artifact_size), decoded_manifest->claims.size(), 0); + if (!admission.accepted()) { + return rejectLoad(path, sink, diagnostic_source, std::move(admission.diagnostic)); + } + state->module_budget_reserved = true; + return NativeParserModule(std::move(state)); } diff --git a/pj_plugins/src/parser_module_runtime.cpp b/pj_plugins/src/parser_module_runtime.cpp index 79dc3fda..0a706929 100644 --- a/pj_plugins/src/parser_module_runtime.cpp +++ b/pj_plugins/src/parser_module_runtime.cpp @@ -15,8 +15,7 @@ #include #include "detail/native_parser_module_state.hpp" -#include "pj_base/builtin/builtin_object_codec.hpp" -#include "pj_base/builtin_object_abi.h" +#include "detail/parser_module_result_helpers.hpp" #include "pj_base/span.hpp" namespace PJ { @@ -37,120 +36,9 @@ Expected copyLastError(const detail::NativeParserModuleState& modul return std::string(buffer.begin(), terminator); } -ParserModuleParseResult contractViolation(int32_t code, std::string message) { - return ParserModuleParseResult{ - .fault = ParserModuleFaultKind::kContractViolation, - .result_code = code, - .message = std::move(message), - .output = std::nullopt, - }; -} - -Expected ownScalarOutput(const parser_module::ScalarOutputV1& scalar) { - ParserModuleScalarOutput owned; - owned.has_timestamp = scalar.has_timestamp; - owned.timestamp_ns = scalar.timestamp_ns; - owned.fields.reserve(scalar.fields.size()); - for (const auto& field : scalar.fields) { - ParserModuleScalarValue value = std::visit( - [](const Value& item) -> ParserModuleScalarValue { - if constexpr (std::is_same_v) { - return std::string(item); - } else { - return item; - } - }, - field.value); - owned.fields.push_back(ParserModuleScalarField{.name = std::string(field.name), .value = std::move(value)}); - } - return owned; -} - -Expected ownObjectOutput( - const parser_module::ObjectOutputV1& object, Span input_payload, uint16_t expected_type) { - if (object.object_type != expected_type) { - return unexpected( - "output object type " + std::to_string(object.object_type) + " does not match bound type " + - std::to_string(expected_type)); - } - - const auto type = static_cast(object.object_type); - auto decoded = deserializeBuiltinObject(type, object.wire.data(), object.wire.size()); - if (!decoded) { - return unexpected("output canonical wire is malformed: " + decoded.error()); - } - - ParserModuleObjectOutput owned; - owned.object = std::move(*decoded); - owned.wire.assign(object.wire.begin(), object.wire.end()); - if (object.splice.has_value()) { - uint32_t eligible_field = 0; - if (!pj_builtin_object_splice_field_number_v1(object.object_type, &eligible_field) || - eligible_field != object.splice->field_number) { - return unexpected("output splice field is not eligible for the object type"); - } - const uint64_t payload_size = static_cast(input_payload.size()); - if (object.splice->input_offset > payload_size || - object.splice->input_length > payload_size - object.splice->input_offset) { - return unexpected("output splice range is outside the parse payload"); - } - const auto offset = static_cast(object.splice->input_offset); - const auto length = static_cast(object.splice->input_length); - auto materialized = std::make_shared>( - input_payload.begin() + static_cast(offset), - input_payload.begin() + static_cast(offset + length)); - const auto attach = [&]() -> bool { - auto* typed = std::any_cast(&owned.object); - if (typed == nullptr) { - return false; - } - typed->data = Span(materialized->data(), materialized->size()); - typed->anchor = materialized; - return true; - }; - bool attached = false; - switch (type) { - case sdk::BuiltinObjectType::kImage: - attached = attach.template operator()(); - break; - case sdk::BuiltinObjectType::kPointCloud: - attached = attach.template operator()(); - break; - case sdk::BuiltinObjectType::kDepthImage: - attached = attach.template operator()(); - break; - case sdk::BuiltinObjectType::kOccupancyGrid: - attached = attach.template operator()(); - break; - case sdk::BuiltinObjectType::kCompressedPointCloud: - attached = attach.template operator()(); - break; - case sdk::BuiltinObjectType::kMesh3D: - attached = attach.template operator()(); - break; - case sdk::BuiltinObjectType::kVideoFrame: - attached = attach.template operator()(); - break; - case sdk::BuiltinObjectType::kOccupancyGridUpdate: - attached = attach.template operator()(); - break; - case sdk::BuiltinObjectType::kVoxelGrid: - attached = attach.template operator()(); - break; - default: - break; - } - if (!attached) { - return unexpected("output splice could not be attached to its canonical object"); - } - owned.splice = ParserModuleObjectSplice{ - .field_number = object.splice->field_number, - .input_offset = object.splice->input_offset, - .payload_bytes = *materialized, - }; - } - return owned; -} +using detail::contractViolation; +using detail::ownObjectOutput; +using detail::ownScalarOutput; } // namespace @@ -168,7 +56,8 @@ NativeParserModuleInstance::NativeParserModuleInstance(NativeParserModuleInstanc claim_index_(other.claim_index_), bound_route_(other.bound_route_), expected_object_type_(other.expected_object_type_), - bound_(other.bound_) { + bound_(other.bound_), + instance_budget_reserved_(std::exchange(other.instance_budget_reserved_, false)) { other.bound_ = false; } @@ -181,6 +70,7 @@ NativeParserModuleInstance& NativeParserModuleInstance::operator=(NativeParserMo bound_route_ = other.bound_route_; expected_object_type_ = other.expected_object_type_; bound_ = other.bound_; + instance_budget_reserved_ = std::exchange(other.instance_budget_reserved_, false); other.bound_ = false; } return *this; @@ -191,12 +81,19 @@ Expected NativeParserModuleInstance::create( if (!module.valid()) { return unexpected("cannot create an instance from an invalid native parser module"); } + auto admission = module.state_->session_budget->admitInstance(module.state_->module_id); + if (!admission.accepted()) { + return unexpected(std::move(admission.diagnostic)); + } const uint64_t token = module.state_->create(claim_index); if (token == PJ_MODULE_CREATION_ERROR_TOKEN) { + (void)module.state_->session_budget->releaseInstance(module.state_->module_id); auto message = copyLastError(*module.state_, PJ_MODULE_CREATION_ERROR_TOKEN); return unexpected(message ? *message : message.error()); } - return NativeParserModuleInstance(module.state_, token, claim_index); + NativeParserModuleInstance instance(module.state_, token, claim_index); + instance.instance_budget_reserved_ = true; + return instance; } Expected NativeParserModuleInstance::bind(const parser_module::BindingInfoV1& info) { @@ -331,8 +228,12 @@ void NativeParserModuleInstance::reset() noexcept { if (module_ != nullptr && token_ != PJ_MODULE_CREATION_ERROR_TOKEN) { module_->destroy(token_); } + if (module_ != nullptr && instance_budget_reserved_) { + (void)module_->session_budget->releaseInstance(module_->module_id); + } token_ = PJ_MODULE_CREATION_ERROR_TOKEN; bound_ = false; + instance_budget_reserved_ = false; module_.reset(); } diff --git a/pj_plugins/src/parser_module_session_budget.cpp b/pj_plugins/src/parser_module_session_budget.cpp new file mode 100644 index 00000000..e4aa82a1 --- /dev/null +++ b/pj_plugins/src/parser_module_session_budget.cpp @@ -0,0 +1,125 @@ +// Copyright 2026 Davide Faconti +// SPDX-License-Identifier: Apache-2.0 + +#include "pj_plugins/host/parser_module_session_budget.hpp" + +#include +#include +#include +#include + +namespace PJ { +namespace { + +ParserModuleAdmissionDecision accept() { + return ParserModuleAdmissionDecision{ + .outcome = ParserModuleAdmissionOutcome::kAccept, + .exhausted_budget = ParserModuleBudgetKind::kNone, + .diagnostic = {}, + }; +} + +ParserModuleAdmissionDecision decline(ParserModuleBudgetKind kind, std::string reason) { + return ParserModuleAdmissionDecision{ + .outcome = ParserModuleAdmissionOutcome::kDecline, + .exhausted_budget = kind, + .diagnostic = "parser-module admission DECLINE: " + std::move(reason), + }; +} + +ParserModuleAdmissionDecision declineBudget(ParserModuleBudgetKind kind, std::string_view name) { + return decline(kind, std::string(name) + " budget exhausted"); +} + +bool exceedsAggregate(uint64_t current, uint64_t additional, uint64_t maximum) { + return current > maximum || additional > maximum - current; +} + +} // namespace + +ParserModuleSessionBudgetTracker::ParserModuleSessionBudgetTracker(ParserModuleSessionBudgetLimits limits) + : limits_(limits) {} + +ParserModuleAdmissionDecision ParserModuleSessionBudgetTracker::admitModule( + std::string module_id, uint64_t artifact_bytes, uint64_t claim_count, uint64_t declared_linear_memory_maximum) { + if (modules_.find(module_id) != modules_.end()) { + return decline(ParserModuleBudgetKind::kNone, "module is already admitted"); + } + if (usage_.modules >= limits_.maximum_modules) { + return declineBudget(ParserModuleBudgetKind::kModuleCount, "module_count"); + } + if (artifact_bytes > limits_.maximum_artifact_bytes) { + return declineBudget(ParserModuleBudgetKind::kArtifactFileSize, "artifact_file_size"); + } + if (exceedsAggregate(usage_.claims, claim_count, limits_.maximum_claims)) { + return declineBudget(ParserModuleBudgetKind::kTotalClaims, "total_claims"); + } + + modules_.emplace( + std::move(module_id), ModuleReservation{ + .artifact_bytes = artifact_bytes, + .claim_count = claim_count, + .declared_linear_memory_maximum = declared_linear_memory_maximum, + .active_instances = 0, + }); + ++usage_.modules; + usage_.claims += claim_count; + return accept(); +} + +ParserModuleAdmissionDecision ParserModuleSessionBudgetTracker::admitInstance(std::string_view module_id) { + auto module = modules_.find(module_id); + if (module == modules_.end()) { + return decline(ParserModuleBudgetKind::kNone, "module is not admitted"); + } + if (usage_.active_instances >= limits_.maximum_active_instances) { + return declineBudget(ParserModuleBudgetKind::kActiveInstances, "active_instances"); + } + if (exceedsAggregate( + usage_.declared_linear_memory_bytes, module->second.declared_linear_memory_maximum, + limits_.maximum_linear_memory_bytes)) { + return declineBudget(ParserModuleBudgetKind::kTotalLinearMemory, "total_linear_memory"); + } + + ++module->second.active_instances; + ++usage_.active_instances; + usage_.declared_linear_memory_bytes += module->second.declared_linear_memory_maximum; + return accept(); +} + +bool ParserModuleSessionBudgetTracker::releaseInstance(std::string_view module_id) { + auto module = modules_.find(module_id); + if (module == modules_.end() || module->second.active_instances == 0) { + return false; + } + --module->second.active_instances; + --usage_.active_instances; + usage_.declared_linear_memory_bytes -= module->second.declared_linear_memory_maximum; + return true; +} + +bool ParserModuleSessionBudgetTracker::releaseModule(std::string_view module_id) { + auto module = modules_.find(module_id); + if (module == modules_.end() || module->second.active_instances != 0) { + return false; + } + --usage_.modules; + usage_.claims -= module->second.claim_count; + modules_.erase(module); + return true; +} + +const ParserModuleSessionBudgetLimits& ParserModuleSessionBudgetTracker::limits() const noexcept { + return limits_; +} + +ParserModuleSessionBudgetUsage ParserModuleSessionBudgetTracker::usage() const noexcept { + return usage_; +} + +std::shared_ptr defaultParserModuleSessionBudget() { + static auto tracker = std::make_shared(); + return tracker; +} + +} // namespace PJ diff --git a/pj_plugins/src/wasm_parser_module.cpp b/pj_plugins/src/wasm_parser_module.cpp new file mode 100644 index 00000000..53f22b3b --- /dev/null +++ b/pj_plugins/src/wasm_parser_module.cpp @@ -0,0 +1,256 @@ +// Copyright 2026 Davide Faconti +// SPDX-License-Identifier: Apache-2.0 + +#include "pj_plugins/host/wasm_parser_module.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "detail/wasm_parser_module_state.hpp" +#include "pj_base/parser_module_manifest.hpp" +#include "pj_base/parser_module_wasm.hpp" +#include "pj_plugins/host/parser_claim_catalog.hpp" + +namespace PJ { +namespace { + +Expected> readFile(std::string_view path, uint64_t maximum_bytes) { + std::ifstream input(std::string(path), std::ios::binary | std::ios::ate); + if (!input) { + return unexpected(std::string("cannot open wasm parser module: ") + std::string(path)); + } + const std::streamoff end = input.tellg(); + if (end < 0 || static_cast(end) > std::numeric_limits::max() || + end > std::numeric_limits::max()) { + return unexpected(std::string("wasm parser-module file size is invalid")); + } + if (static_cast(end) > maximum_bytes) { + return unexpected( + "parser-module admission DECLINE: artifact_file_size budget exhausted (size " + + std::to_string(static_cast(end)) + ", limit " + std::to_string(maximum_bytes) + ")"); + } + std::vector bytes(static_cast(end)); + input.seekg(0); + if (!bytes.empty()) { + input.read(reinterpret_cast(bytes.data()), static_cast(bytes.size())); + } + if (!input) { + return unexpected(std::string("cannot read complete wasm parser-module file")); + } + return bytes; +} + +std::string wasmerLastError() { + const int length = wasmer_last_error_length(); + if (length <= 0) { + return "Wasmer supplied no diagnostic"; + } + std::string message(static_cast(length), '\0'); + const int written = wasmer_last_error_message(message.data(), length); + if (written <= 0) { + return "Wasmer diagnostic retrieval failed"; + } + if (!message.empty() && message.back() == '\0') { + message.pop_back(); + } + return message; +} + +std::string wasmerFailure(std::string_view action) { + return std::string(action) + ": " + wasmerLastError(); +} + +uint64_t unitMeteringCost(wasmer_parser_operator_t) { + return 1; +} + +Expected createMeteredEngine(uint64_t points_per_call) { + wasm_config_t* config = wasm_config_new(); + if (config == nullptr) { + return unexpected(wasmerFailure("failed to create Wasmer configuration")); + } + wasmer_metering_t* metering = wasmer_metering_new(points_per_call, &unitMeteringCost); + if (metering == nullptr) { + wasm_config_delete(config); + return unexpected(wasmerFailure("failed to create Wasmer metering middleware")); + } + wasmer_middleware_t* middleware = wasmer_metering_as_middleware(metering); + if (middleware == nullptr) { + wasmer_metering_delete(metering); + wasm_config_delete(config); + return unexpected(wasmerFailure("failed to adapt Wasmer metering middleware")); + } + wasm_config_push_middleware(config, middleware); + wasm_engine_t* engine = wasm_engine_new_with_config(config); + if (engine == nullptr) { + wasm_config_delete(config); + return unexpected(wasmerFailure("failed to create metered Wasmer engine")); + } + return engine; +} + +} // namespace + +namespace detail { + +WasmParserModuleState::~WasmParserModuleState() { + if (module != nullptr) { + wasm_module_delete(module); + } + if (engine != nullptr) { + wasm_engine_delete(engine); + } + if (module_budget_reserved && session_budget != nullptr) { + (void)session_budget->releaseModule(module_id); + } +} + +} // namespace detail + +WasmParserModule::WasmParserModule(std::shared_ptr state) + : state_(std::move(state)) {} + +Expected WasmParserModule::load( + std::string_view path, DiagnosticSink sink, std::string diagnostic_source) { + return load( + path, WasmParserModuleLimits{}, defaultParserModuleSessionBudget(), std::move(sink), + std::move(diagnostic_source)); +} + +Expected WasmParserModule::load( + std::string_view path, const WasmParserModuleLimits& limits, DiagnosticSink sink, std::string diagnostic_source) { + return load(path, limits, defaultParserModuleSessionBudget(), std::move(sink), std::move(diagnostic_source)); +} + +Expected WasmParserModule::load( + std::string_view path, std::shared_ptr budget, DiagnosticSink sink, + std::string diagnostic_source) { + return load(path, WasmParserModuleLimits{}, std::move(budget), std::move(sink), std::move(diagnostic_source)); +} + +Expected WasmParserModule::load( + std::string_view path, const WasmParserModuleLimits& limits, + std::shared_ptr budget, DiagnosticSink sink, std::string diagnostic_source) { + // Every rejection emits exactly one error diagnostic and returns the same + // text to the caller. + const auto reject = [&](std::string message) -> Expected { + if (sink) { + sink( + Diagnostic{ + .level = DiagnosticLevel::kError, + .source = diagnostic_source, + .id = std::string(path), + .message = message, + }); + } + return unexpected(std::move(message)); + }; + + if (limits.maximum_artifact_bytes == 0 || limits.maximum_linear_memory_bytes == 0 || + limits.metering_points_per_call == 0) { + return reject("wasm parser-module limits must all be nonzero"); + } + if (budget == nullptr) { + return reject("wasm parser-module session budget is null"); + } + const uint64_t maximum_artifact_bytes = + std::min(limits.maximum_artifact_bytes, budget->limits().maximum_artifact_bytes); + auto bytes = readFile(path, maximum_artifact_bytes); + if (!bytes) { + return reject(bytes.error()); + } + + auto manifest = parser_module::readManifestSection(*bytes); + if (!manifest) { + return reject("invalid wasm parser-module manifest: " + manifest.error()); + } + const char* manifest_data = manifest->empty() ? "" : reinterpret_cast(manifest->data()); + const std::string_view manifest_json(manifest_data, manifest->size()); + auto decoded_manifest = decodeParserModuleManifest(manifest_json, ParserClaimProvenance::kFolderDrop); + if (!decoded_manifest) { + return reject("invalid wasm parser-module manifest: " + decoded_manifest.error()); + } + auto inspected = parser_module::inspectWasmModule(*bytes); + if (!inspected) { + return reject("invalid wasm parser module: " + inspected.error()); + } + if (!inspected->imports.empty()) { + const auto& imported = inspected->imports.front(); + return reject("wasm parser module uses disallowed import '" + imported.module + "." + imported.name + "'"); + } + auto abi = parser_module::validateParserModuleWasmAbi(*inspected); + if (!abi) { + return reject(abi.error()); + } + auto memory_maximum = parser_module::validateParserModuleWasmMemory(*inspected, limits.maximum_linear_memory_bytes); + if (!memory_maximum) { + return reject(memory_maximum.error()); + } + + auto state = std::make_shared(); + state->path = path; + state->artifact_size = bytes->size(); + state->declared_linear_memory_maximum = *memory_maximum; + state->metering_points_per_call = limits.metering_points_per_call; + state->module_id = decoded_manifest->id; + state->claim_ids.reserve(decoded_manifest->claims.size()); + for (const auto& claim : decoded_manifest->claims) { + state->claim_ids.push_back(claim.claim_id); + } + state->manifest_json.assign(manifest_json); + state->session_budget = budget; + state->strike_tracker = std::make_shared(); + auto admission = + budget->admitModule(decoded_manifest->id, bytes->size(), decoded_manifest->claims.size(), *memory_maximum); + if (!admission.accepted()) { + return reject(std::move(admission.diagnostic)); + } + state->module_budget_reserved = true; + auto engine = createMeteredEngine(limits.metering_points_per_call); + if (!engine) { + return reject(engine.error()); + } + state->engine = *engine; + const wasm_byte_vec_t binary{ + .size = bytes->size(), + .data = reinterpret_cast(bytes->data()), + }; + state->module = wasmer_module_new(state->engine, &binary); + if (state->module == nullptr) { + return reject(wasmerFailure("Wasmer rejected parser module")); + } + return WasmParserModule(std::move(state)); +} + +std::string_view WasmParserModule::path() const noexcept { + return state_ == nullptr ? std::string_view{} : std::string_view(state_->path); +} + +std::string_view WasmParserModule::manifestJson() const noexcept { + return state_ == nullptr ? std::string_view{} : std::string_view(state_->manifest_json); +} + +uint64_t WasmParserModule::artifactSize() const noexcept { + return state_ == nullptr ? 0 : state_->artifact_size; +} + +uint64_t WasmParserModule::declaredLinearMemoryMaximum() const noexcept { + return state_ == nullptr ? 0 : state_->declared_linear_memory_maximum; +} + +ParserModuleStrikeState WasmParserModule::strikeState(uint32_t claim_index) const { + if (state_ == nullptr || claim_index >= state_->claim_ids.size()) { + return {}; + } + return state_->strike_tracker->state(ParserModuleClaimKey{state_->module_id, state_->claim_ids[claim_index]}); +} + +} // namespace PJ diff --git a/pj_plugins/src/wasm_parser_module_runtime.cpp b/pj_plugins/src/wasm_parser_module_runtime.cpp new file mode 100644 index 00000000..9e22dedd --- /dev/null +++ b/pj_plugins/src/wasm_parser_module_runtime.cpp @@ -0,0 +1,767 @@ +// Copyright 2026 Davide Faconti +// SPDX-License-Identifier: Apache-2.0 + +#include "pj_plugins/host/wasm_parser_module_runtime.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "detail/parser_module_result_helpers.hpp" +#include "detail/wasm_parser_module_state.hpp" +#include "pj_base/parser_module_wasm.hpp" +#include "pj_base/span.hpp" + +namespace PJ { +namespace { + +using detail::contractViolation; + +/// pj_module_parse writes the output descriptor address into the first host +/// slot and its length into the second. Both slots are i64. +constexpr size_t kOutputSlotBytes = 8; +constexpr size_t kOutputBlockBytes = 2 * kOutputSlotBytes; + +std::string trapMessage(wasm_trap_t* trap) { + wasm_message_t message = WASM_EMPTY_VEC; + wasm_trap_message(trap, &message); + size_t size = message.size; + if (size != 0 && message.data[size - 1] == '\0') { + --size; + } + std::string result; + if (size != 0 && message.data != nullptr) { + result.assign(message.data, size); + } + wasm_byte_vec_delete(&message); + wasm_trap_delete(trap); + return result.empty() ? "wasm trap without a message" : "wasm trap: " + result; +} + +std::string meteringExhaustedMessage(std::string_view function_name, uint64_t points_per_call) { + return "wasm metering exhausted during " + std::string(function_name) + " (instruction-point limit " + + std::to_string(points_per_call) + ")"; +} + +/// Every guest ABI argument is an i64 token, address, or byte count. +wasm_val_t wasmI64(uint64_t value) { + const wasm_val_t argument = WASM_I64_VAL(static_cast(value)); + return argument; +} + +/// Call one guest export with a fresh instruction-point allowance. A trap and +/// an exhausted allowance both fail the call; exhaustion is reported in +/// preference to the trap message because it names the enforced deadline. +Expected callGuest( + wasm_instance_t* instance, uint64_t points_per_call, std::string_view function_name, wasm_func_t* function, + Span arguments, wasm_val_vec_t* results) { + wasmer_metering_set_remaining_points(instance, points_per_call); + const wasm_val_vec_t args{.size = arguments.size(), .data = arguments.data()}; + if (wasm_trap_t* trap = wasm_func_call(function, &args, results)) { + const bool exhausted = wasmer_metering_points_are_exhausted(instance); + std::string message = trapMessage(trap); + if (exhausted) { + return unexpected(meteringExhaustedMessage(function_name, points_per_call)); + } + return unexpected(std::move(message)); + } + if (wasmer_metering_points_are_exhausted(instance)) { + return unexpected(meteringExhaustedMessage(function_name, points_per_call)); + } + return {}; +} + +std::string exportName(const wasm_exporttype_t* exported) { + const wasm_name_t* name = wasm_exporttype_name(exported); + return std::string(name->data, name->size); +} + +uint64_t decodeU64(Span bytes) { + uint64_t value = 0; + for (size_t index = 0; index < kOutputSlotBytes; ++index) { + value |= static_cast(bytes[index]) << (index * 8U); + } + return value; +} + +ParserModuleBindResult bindContractViolation(int32_t code, std::string message) { + return ParserModuleBindResult{ + .outcome = ParserModuleBindOutcome::kError, + .fault = ParserModuleFaultKind::kContractViolation, + .result_code = code, + .message = std::move(message), + }; +} + +} // namespace + +namespace detail { + +struct WasmParserModuleInstanceState { + ~WasmParserModuleInstanceState() { + if (token != PJ_MODULE_CREATION_ERROR_TOKEN && destroy != nullptr) { + wasm_val_t arguments[1] = {wasmI64(token)}; + auto destroyed = callVoid(PJ_MODULE_DESTROY_EXPORT_NAME, destroy, arguments); + if (!destroyed) { + (void)recordContractViolation("pj_module_destroy failed: " + destroyed.error()); + } + } + if (exports_initialized) { + wasm_extern_vec_delete(&exports); + } + if (instance != nullptr) { + wasm_instance_delete(instance); + } + if (store != nullptr) { + wasm_store_delete(store); + } + if (instance_budget_reserved && module->session_budget != nullptr) { + (void)module->session_budget->releaseInstance(module->module_id); + } + } + + [[nodiscard]] ParserModuleClaimKey claimKey() const { + return ParserModuleClaimKey{module->module_id, module->claim_ids[claim_index]}; + } + + [[nodiscard]] ParserModuleStrikeState recordContractViolation(std::string message) { + lifecycle_diagnostic = std::move(message); + return module->strike_tracker->recordFault(claimKey(), ParserModuleFaultKind::kContractViolation); + } + + [[nodiscard]] Expected callVoid( + std::string_view function_name, wasm_func_t* function, Span arguments) const { + wasm_val_vec_t results = WASM_EMPTY_VEC; + return callGuest(instance, module->metering_points_per_call, function_name, function, arguments, &results); + } + + [[nodiscard]] Expected callI32( + std::string_view function_name, wasm_func_t* function, Span arguments) const { + wasm_val_t values[1] = {WASM_INIT_VAL}; + wasm_val_vec_t results = WASM_ARRAY_VEC(values); + auto called = callGuest(instance, module->metering_points_per_call, function_name, function, arguments, &results); + if (!called) { + return unexpected(called.error()); + } + if (values[0].kind != WASM_I32) { + return unexpected(std::string("wasm function returned a non-i32 result")); + } + return values[0].of.i32; + } + + [[nodiscard]] Expected callI64( + std::string_view function_name, wasm_func_t* function, Span arguments) const { + wasm_val_t values[1] = {WASM_INIT_VAL}; + wasm_val_vec_t results = WASM_ARRAY_VEC(values); + auto called = callGuest(instance, module->metering_points_per_call, function_name, function, arguments, &results); + if (!called) { + return unexpected(called.error()); + } + if (values[0].kind != WASM_I64) { + return unexpected(std::string("wasm function returned a non-i64 result")); + } + return values[0].of.i64; + } + + [[nodiscard]] Expected> memoryRange(uint64_t address, uint64_t length) const { + if (memory == nullptr || address > std::numeric_limits::max() || + length > std::numeric_limits::max()) { + return unexpected(std::string("guest memory range exceeds the host address range")); + } + const size_t offset = static_cast(address); + const size_t size = static_cast(length); + const size_t memory_size = wasm_memory_data_size(memory); + if (offset > memory_size || size > memory_size - offset) { + return unexpected(std::string("guest memory range is outside current linear memory")); + } + auto* base = reinterpret_cast(wasm_memory_data(memory)); + if (base == nullptr && memory_size != 0) { + return unexpected(std::string("Wasmer returned a null linear-memory base")); + } + return Span(base == nullptr ? nullptr : base + offset, size); + } + + [[nodiscard]] Expected allocate(uint64_t size) const { + wasm_val_t arguments[1] = {wasmI64(size)}; + auto result = callI64(PJ_MODULE_ALLOC_EXPORT_NAME, alloc, arguments); + if (!result) { + return unexpected(result.error()); + } + const uint64_t address = static_cast(*result); + if (address == 0) { + return unexpected(std::string("pj_module_alloc returned token zero")); + } + return address; + } + + [[nodiscard]] Expected freeAllocation(uint64_t address, uint64_t size) const { + wasm_val_t arguments[2] = {wasmI64(address), wasmI64(size)}; + return callVoid(PJ_MODULE_FREE_EXPORT_NAME, free, arguments); + } + + /// Read the guest error string into an already-allocated guest buffer. The + /// caller owns that buffer and releases it whatever the outcome here. + [[nodiscard]] Expected readLastError(uint64_t error_token, uint64_t buffer_address) const { + wasm_val_t arguments[3] = { + wasmI64(error_token), + wasmI64(buffer_address), + wasmI64(PJ_MODULE_ERROR_BUFFER_SIZE), + }; + auto written_result = callI64(PJ_MODULE_LAST_ERROR_EXPORT_NAME, last_error, arguments); + if (!written_result) { + return unexpected("pj_module_last_error trapped: " + written_result.error()); + } + const uint64_t written = static_cast(*written_result); + if (written > PJ_MODULE_ERROR_BUFFER_SIZE) { + return unexpected(std::string("pj_module_last_error returned a length larger than its buffer")); + } + auto bytes = memoryRange(buffer_address, written); + if (!bytes) { + return unexpected(bytes.error()); + } + const auto terminator = std::find(bytes->begin(), bytes->end(), uint8_t{0}); + return std::string(bytes->begin(), terminator); + } + + [[nodiscard]] Expected copyLastError(uint64_t error_token) const { + auto address = allocate(PJ_MODULE_ERROR_BUFFER_SIZE); + if (!address) { + return unexpected("cannot allocate the guest error buffer: " + address.error()); + } + auto message = readLastError(error_token, *address); + auto released = freeAllocation(*address, PJ_MODULE_ERROR_BUFFER_SIZE); + if (!message) { + return unexpected(message.error()); + } + if (!released) { + return unexpected("pj_module_free trapped after last_error: " + released.error()); + } + return message; + } + + std::shared_ptr module; + wasm_store_t* store = nullptr; + wasm_instance_t* instance = nullptr; + wasm_extern_vec_t exports = WASM_EMPTY_VEC; + bool exports_initialized = false; + wasm_memory_t* memory = nullptr; + wasm_func_t* initialize = nullptr; + wasm_func_t* abi = nullptr; + wasm_func_t* create = nullptr; + wasm_func_t* destroy = nullptr; + wasm_func_t* bind = nullptr; + wasm_func_t* parse = nullptr; + wasm_func_t* last_error = nullptr; + wasm_func_t* alloc = nullptr; + wasm_func_t* free = nullptr; + uint64_t token = PJ_MODULE_CREATION_ERROR_TOKEN; + uint32_t claim_index = 0; + parser_module::Route bound_route = parser_module::Route::kScalar; + uint16_t expected_object_type = 0; + bool bound = false; + bool instance_budget_reserved = false; + bool recreation_pending = false; + std::vector binding_bytes; + std::string lifecycle_diagnostic; +}; + +} // namespace detail + +namespace { + +/// Owns one `pj_module_alloc` region for the duration of a host operation. +/// The destructor releases it and records a contract fault if guest free +/// traps; `release()` frees it early and returns that fault directly. +class GuestAllocation { + public: + GuestAllocation(detail::WasmParserModuleInstanceState& state, uint64_t address, uint64_t size) + : state_(&state), address_(address), size_(size) {} + + GuestAllocation(const GuestAllocation&) = delete; + GuestAllocation& operator=(const GuestAllocation&) = delete; + + ~GuestAllocation() { + auto released = release(); + if (!released) { + (void)state_->recordContractViolation("pj_module_free failed during cleanup: " + released.error()); + } + } + + [[nodiscard]] uint64_t address() const noexcept { + return address_; + } + + /// Free now instead of at scope exit. A released allocation stays released. + [[nodiscard]] Expected release() { + if (address_ == 0) { + return {}; + } + return state_->freeAllocation(std::exchange(address_, UINT64_C(0)), size_); + } + + private: + detail::WasmParserModuleInstanceState* state_; + uint64_t address_; + uint64_t size_; +}; + +/// Release both parse allocations, output slots first, and report the first +/// guest free that failed. +Expected releaseParseBuffers(GuestAllocation& slots, GuestAllocation& input) { + auto released_slots = slots.release(); + auto released_input = input.release(); + if (!released_slots) { + return unexpected("pj_module_free failed: " + released_slots.error()); + } + if (!released_input) { + return unexpected("pj_module_free failed: " + released_input.error()); + } + return {}; +} + +Expected bindRuntimeExports(detail::WasmParserModuleInstanceState* state) { + wasm_exporttype_vec_t declarations = WASM_EMPTY_VEC; + wasm_module_exports(state->module->module, &declarations); + wasm_instance_exports(state->instance, &state->exports); + state->exports_initialized = true; + if (declarations.size != state->exports.size) { + wasm_exporttype_vec_delete(&declarations); + return unexpected(std::string("Wasmer returned an export count inconsistent with the compiled module")); + } + + for (size_t index = 0; index < declarations.size; ++index) { + const std::string name = exportName(declarations.data[index]); + wasm_extern_t* external = state->exports.data[index]; + if (name == "memory") { + state->memory = wasm_extern_as_memory(external); + } else if (name == "_initialize") { + state->initialize = wasm_extern_as_func(external); + } else if (name == PJ_MODULE_ABI_EXPORT_NAME) { + state->abi = wasm_extern_as_func(external); + } else if (name == PJ_MODULE_CREATE_EXPORT_NAME) { + state->create = wasm_extern_as_func(external); + } else if (name == PJ_MODULE_DESTROY_EXPORT_NAME) { + state->destroy = wasm_extern_as_func(external); + } else if (name == PJ_MODULE_BIND_EXPORT_NAME) { + state->bind = wasm_extern_as_func(external); + } else if (name == PJ_MODULE_PARSE_EXPORT_NAME) { + state->parse = wasm_extern_as_func(external); + } else if (name == PJ_MODULE_LAST_ERROR_EXPORT_NAME) { + state->last_error = wasm_extern_as_func(external); + } else if (name == PJ_MODULE_ALLOC_EXPORT_NAME) { + state->alloc = wasm_extern_as_func(external); + } else if (name == PJ_MODULE_FREE_EXPORT_NAME) { + state->free = wasm_extern_as_func(external); + } + } + wasm_exporttype_vec_delete(&declarations); + + if (state->memory == nullptr || state->initialize == nullptr || state->abi == nullptr || state->create == nullptr || + state->destroy == nullptr || state->bind == nullptr || state->parse == nullptr || state->last_error == nullptr || + state->alloc == nullptr || state->free == nullptr) { + return unexpected(std::string("Wasmer instance is missing a statically validated runtime export")); + } + return {}; +} + +} // namespace + +WasmParserModuleInstance::WasmParserModuleInstance(std::unique_ptr state) + : state_(std::move(state)) {} + +WasmParserModuleInstance::~WasmParserModuleInstance() = default; +WasmParserModuleInstance::WasmParserModuleInstance(WasmParserModuleInstance&& other) noexcept = default; +WasmParserModuleInstance& WasmParserModuleInstance::operator=(WasmParserModuleInstance&& other) noexcept = default; + +Expected WasmParserModuleInstance::create( + const WasmParserModule& module, uint32_t claim_index) { + const auto reject = + [](std::string message, ParserModuleFaultKind fault = ParserModuleFaultKind::kNone, + WasmParserModuleCreateOutcome outcome = + WasmParserModuleCreateOutcome::kError) -> Expected { + return unexpected( + WasmParserModuleCreateError{ + .outcome = outcome, + .fault = fault, + .message = std::move(message), + }); + }; + if (!module.valid()) { + return reject("cannot create an instance from an invalid wasm parser module"); + } + if (claim_index >= module.state_->claim_ids.size()) { + return reject("claim index is outside the wasm parser-module manifest"); + } + const ParserModuleClaimKey key{module.state_->module_id, module.state_->claim_ids[claim_index]}; + const ParserModuleStrikeState initial_health = module.state_->strike_tracker->state(key); + if (initial_health.health == ParserModuleClaimHealth::kDisabled) { + return reject( + "parser-module claim is disabled for the session", ParserModuleFaultKind::kNone, + WasmParserModuleCreateOutcome::kAdmissionDecline); + } + auto admission = module.state_->session_budget->admitInstance(module.state_->module_id); + if (!admission.accepted()) { + return reject( + std::move(admission.diagnostic), ParserModuleFaultKind::kNone, + WasmParserModuleCreateOutcome::kAdmissionDecline); + } + auto state = std::make_unique(); + state->module = module.state_; + state->claim_index = claim_index; + state->instance_budget_reserved = true; + state->recreation_pending = initial_health.health == ParserModuleClaimHealth::kQuarantined; + state->store = wasm_store_new(state->module->engine); + if (state->store == nullptr) { + return reject("failed to create a Wasmer store"); + } + + const wasm_extern_vec_t imports = WASM_EMPTY_VEC; + wasm_trap_t* instantiation_trap = nullptr; + state->instance = wasm_instance_new(state->store, state->module->module, &imports, &instantiation_trap); + if (state->instance == nullptr) { + if (instantiation_trap != nullptr) { + const std::string message = "wasm instantiation failed: " + trapMessage(instantiation_trap); + (void)state->recordContractViolation(message); + return reject(message, ParserModuleFaultKind::kContractViolation); + } + return reject("Wasmer failed to instantiate the parser module"); + } + auto exports = bindRuntimeExports(state.get()); + if (!exports) { + (void)state->recordContractViolation(exports.error()); + return reject(exports.error(), ParserModuleFaultKind::kContractViolation); + } + auto initialized = state->callVoid("_initialize", state->initialize, {}); + if (!initialized) { + const std::string message = "parser-module _initialize failed: " + initialized.error(); + (void)state->recordContractViolation(message); + return reject(message, ParserModuleFaultKind::kContractViolation); + } + auto abi = state->callI32(PJ_MODULE_ABI_EXPORT_NAME, state->abi, {}); + if (!abi) { + const std::string message = "pj_module_abi failed: " + abi.error(); + (void)state->recordContractViolation(message); + return reject(message, ParserModuleFaultKind::kContractViolation); + } + if (static_cast(*abi) != PJ_PARSER_MODULE_ABI_VERSION) { + const std::string message = "wasm parser module ABI mismatch (expected " + + std::to_string(PJ_PARSER_MODULE_ABI_VERSION) + ", got " + + std::to_string(static_cast(*abi)) + ")"; + (void)state->recordContractViolation(message); + return reject(message, ParserModuleFaultKind::kContractViolation); + } + + wasm_val_t arguments[1] = {WASM_I32_VAL(static_cast(claim_index))}; + auto token = state->callI64(PJ_MODULE_CREATE_EXPORT_NAME, state->create, arguments); + if (!token) { + const std::string message = "pj_module_create failed: " + token.error(); + (void)state->recordContractViolation(message); + return reject(message, ParserModuleFaultKind::kContractViolation); + } + state->token = static_cast(*token); + if (state->token == PJ_MODULE_CREATION_ERROR_TOKEN) { + auto message = state->copyLastError(PJ_MODULE_CREATION_ERROR_TOKEN); + if (!message) { + (void)state->recordContractViolation(message.error()); + return reject(message.error(), ParserModuleFaultKind::kContractViolation); + } + return reject(*message); + } + return WasmParserModuleInstance(std::move(state)); +} + +Expected WasmParserModuleInstance::recreateBoundInstance() { + if (state_ == nullptr || state_->binding_bytes.empty()) { + return unexpected(std::string("quarantined wasm parser-module instance has no accepted binding to replay")); + } + const uint32_t claim_index = state_->claim_index; + const std::vector binding_bytes = state_->binding_bytes; + auto binding = parser_module::readBindingInfoV1(binding_bytes); + if (!binding) { + return unexpected("cannot decode the quarantined binding for replay: " + binding.error()); + } + WasmParserModule module(state_->module); + state_.reset(); + + auto recreated = create(module, claim_index); + if (!recreated) { + return unexpected("quarantine recreation failed during create: " + recreated.error().message); + } + auto rebound = recreated->bind(*binding); + if (!rebound) { + return unexpected("quarantine recreation failed during bind: " + rebound.error()); + } + if (rebound->outcome != ParserModuleBindOutcome::kAccept) { + return unexpected("quarantine binding replay was not accepted: " + rebound->message); + } + state_ = std::move(recreated->state_); + return {}; +} + +Expected WasmParserModuleInstance::bind(const parser_module::BindingInfoV1& info) { + if (!valid()) { + return unexpected(std::string("cannot bind an invalid wasm parser-module instance")); + } + if (info.claim_index != state_->claim_index) { + return unexpected(std::string("BindingInfo claim_index does not match the created wasm instance")); + } + auto encoded = parser_module::writeBindingInfoV1(info); + if (!encoded) { + return unexpected(encoded.error()); + } + auto address = state_->allocate(encoded->size()); + if (!address) { + (void)state_->recordContractViolation(address.error()); + return bindContractViolation(PJ_MODULE_ERR_ALLOCATION_FAILURE, address.error()); + } + GuestAllocation input_buffer(*state_, *address, encoded->size()); + auto guest_input = state_->memoryRange(input_buffer.address(), encoded->size()); + if (!guest_input) { + (void)state_->recordContractViolation(guest_input.error()); + return bindContractViolation(PJ_MODULE_ERR_GENERIC, guest_input.error()); + } + std::copy(encoded->begin(), encoded->end(), guest_input->begin()); + + wasm_val_t arguments[3] = { + wasmI64(state_->token), + wasmI64(input_buffer.address()), + wasmI64(encoded->size()), + }; + auto code_result = state_->callI32(PJ_MODULE_BIND_EXPORT_NAME, state_->bind, arguments); + auto released = input_buffer.release(); + if (!code_result) { + state_->bound = false; + (void)state_->recordContractViolation(code_result.error()); + return bindContractViolation(PJ_MODULE_ERR_GENERIC, code_result.error()); + } + if (!released) { + state_->bound = false; + const std::string message = "pj_module_free failed after bind: " + released.error(); + (void)state_->recordContractViolation(message); + return bindContractViolation(PJ_MODULE_ERR_GENERIC, message); + } + + const int32_t code = *code_result; + ParserModuleBindResult result{ + .outcome = ParserModuleBindOutcome::kError, + .fault = ParserModuleFaultKind::kNone, + .result_code = code, + .message = {}, + }; + if (code == PJ_MODULE_OK) { + result.outcome = ParserModuleBindOutcome::kAccept; + state_->bound_route = info.route; + state_->expected_object_type = info.expected_object_type; + state_->bound = true; + state_->binding_bytes = *encoded; + if (state_->recreation_pending) { + (void)state_->module->strike_tracker->markRecreated(state_->claimKey()); + state_->recreation_pending = false; + } + return result; + } + + state_->bound = false; + if (code == PJ_MODULE_DECLINE) { + result.outcome = ParserModuleBindOutcome::kDecline; + } else if (code < 0) { + result.outcome = ParserModuleBindOutcome::kError; + if (code == PJ_MODULE_ERR_BAD_TOKEN) { + result.fault = ParserModuleFaultKind::kContractViolation; + } + } else { + const std::string message = "pj_module_bind returned an out-of-contract positive result"; + (void)state_->recordContractViolation(message); + return bindContractViolation(code, message); + } + + auto message = state_->copyLastError(state_->token); + if (!message) { + result.fault = ParserModuleFaultKind::kContractViolation; + result.message = message.error(); + } else { + result.message = std::move(*message); + } + if (result.fault == ParserModuleFaultKind::kContractViolation) { + (void)state_->recordContractViolation(result.message); + } + return result; +} + +Expected WasmParserModuleInstance::parse(const parser_module::ParseInputV1& input) { + if (!valid()) { + return unexpected(std::string("cannot parse with an invalid wasm parser-module instance")); + } + if (!state_->bound) { + return unexpected(std::string("cannot parse before an accepted wasm module bind")); + } + const ParserModuleStrikeState health = state_->module->strike_tracker->state(state_->claimKey()); + if (health.health == ParserModuleClaimHealth::kDisabled) { + return contractViolation(PJ_MODULE_ERR_GENERIC, "parser-module claim is disabled for the session"); + } + if (health.health == ParserModuleClaimHealth::kQuarantined) { + auto recreated = recreateBoundInstance(); + if (!recreated) { + return contractViolation(PJ_MODULE_ERR_GENERIC, recreated.error()); + } + } + + const auto parse_once = [&]() -> Expected { + auto encoded = parser_module::writeParseInputV1(input); + if (!encoded) { + return unexpected(encoded.error()); + } + + auto input_address = state_->allocate(encoded->size()); + if (!input_address) { + return contractViolation(PJ_MODULE_ERR_ALLOCATION_FAILURE, input_address.error()); + } + GuestAllocation input_buffer(*state_, *input_address, encoded->size()); + auto slots_address = state_->allocate(kOutputBlockBytes); + if (!slots_address) { + return contractViolation(PJ_MODULE_ERR_ALLOCATION_FAILURE, slots_address.error()); + } + GuestAllocation slots_buffer(*state_, *slots_address, kOutputBlockBytes); + + auto guest_input = state_->memoryRange(input_buffer.address(), encoded->size()); + auto guest_slots = state_->memoryRange(slots_buffer.address(), kOutputBlockBytes); + if (!guest_input || !guest_slots) { + return contractViolation(PJ_MODULE_ERR_GENERIC, guest_input ? guest_slots.error() : guest_input.error()); + } + std::copy(encoded->begin(), encoded->end(), guest_input->begin()); + std::fill(guest_slots->begin(), guest_slots->end(), uint8_t{0}); + + wasm_val_t arguments[5] = { + wasmI64(state_->token), + wasmI64(input_buffer.address()), + wasmI64(encoded->size()), + wasmI64(slots_buffer.address()), + wasmI64(slots_buffer.address() + kOutputSlotBytes), + }; + auto code_result = state_->callI32(PJ_MODULE_PARSE_EXPORT_NAME, state_->parse, arguments); + if (!code_result) { + return contractViolation(PJ_MODULE_ERR_GENERIC, code_result.error()); + } + + const int32_t code = *code_result; + if (code < 0) { + auto message = state_->copyLastError(state_->token); + auto released = releaseParseBuffers(slots_buffer, input_buffer); + if (!message || !released) { + return contractViolation(code, !message ? message.error() : released.error()); + } + return ParserModuleParseResult{ + .fault = code == PJ_MODULE_ERR_BAD_TOKEN ? ParserModuleFaultKind::kContractViolation + : ParserModuleFaultKind::kDataError, + .result_code = code, + .message = std::move(*message), + .output = std::nullopt, + }; + } + if (code != PJ_MODULE_OK) { + return contractViolation(code, "pj_module_parse returned a nonzero non-error result"); + } + + // parse may grow or relocate memory. Re-acquire before reading both return + // slots, then re-acquire again for the module-owned descriptor itself. + auto returned_slots = state_->memoryRange(slots_buffer.address(), kOutputBlockBytes); + if (!returned_slots) { + return contractViolation(code, returned_slots.error()); + } + const uint64_t output_address = decodeU64(returned_slots->first(kOutputSlotBytes)); + const uint64_t output_length = decodeU64(returned_slots->subspan(kOutputSlotBytes, kOutputSlotBytes)); + if (output_address == 0 || output_length == 0) { + return contractViolation(code, "pj_module_parse returned an unreadable output descriptor"); + } + auto guest_output = state_->memoryRange(output_address, output_length); + if (!guest_output) { + return contractViolation(code, guest_output.error()); + } + const std::vector descriptor_bytes(guest_output->begin(), guest_output->end()); + + auto released = releaseParseBuffers(slots_buffer, input_buffer); + if (!released) { + return contractViolation(code, released.error()); + } + + auto descriptor = parser_module::readOutputDescriptorV1(descriptor_bytes); + if (!descriptor) { + return contractViolation(code, "malformed output descriptor: " + descriptor.error()); + } + + std::optional output; + if (state_->bound_route == parser_module::Route::kScalar) { + const auto* scalar = std::get_if(&*descriptor); + if (scalar == nullptr) { + return contractViolation(code, "output descriptor route does not match the scalar binding"); + } + auto owned = detail::ownScalarOutput(*scalar); + if (!owned) { + return contractViolation(code, owned.error()); + } + output = ParserModuleOutput(std::move(*owned)); + } else { + const auto* object = std::get_if(&*descriptor); + if (object == nullptr) { + return contractViolation(code, "output descriptor route does not match the object binding"); + } + auto owned = detail::ownObjectOutput(*object, input.payload, state_->expected_object_type); + if (!owned) { + return contractViolation(code, owned.error()); + } + output = ParserModuleOutput(std::move(*owned)); + } + return ParserModuleParseResult{ + .fault = ParserModuleFaultKind::kNone, + .result_code = code, + .message = {}, + .output = std::move(output), + }; + }; + + auto result = parse_once(); + if (!result || result->fault != ParserModuleFaultKind::kContractViolation) { + return result; + } + + const ParserModuleStrikeState strike = state_->recordContractViolation(result->message); + if (strike.health == ParserModuleClaimHealth::kQuarantined) { + auto recreated = recreateBoundInstance(); + if (!recreated) { + result->message += "; automatic quarantine recreation failed: " + recreated.error(); + } else { + result->message += "; claim quarantined and recreated through create/bind replay"; + } + } else if (strike.health == ParserModuleClaimHealth::kDisabled) { + result->message += "; claim disabled for the session after repeat quarantine"; + state_.reset(); + } + return result; +} + +bool WasmParserModuleInstance::valid() const noexcept { + return state_ != nullptr && state_->token != PJ_MODULE_CREATION_ERROR_TOKEN; +} + +uint32_t WasmParserModuleInstance::claimIndex() const noexcept { + return state_ == nullptr ? 0 : state_->claim_index; +} + +ParserModuleStrikeState WasmParserModuleInstance::strikeState() const { + return state_ == nullptr ? ParserModuleStrikeState{} : state_->module->strike_tracker->state(state_->claimKey()); +} + +std::string_view WasmParserModuleInstance::lifecycleDiagnostic() const noexcept { + return state_ == nullptr ? std::string_view{} : std::string_view(state_->lifecycle_diagnostic); +} + +} // namespace PJ diff --git a/pj_plugins/tests/adversarial_wasm_parser_module.cpp b/pj_plugins/tests/adversarial_wasm_parser_module.cpp new file mode 100644 index 00000000..b0edc01d --- /dev/null +++ b/pj_plugins/tests/adversarial_wasm_parser_module.cpp @@ -0,0 +1,64 @@ +// Copyright 2026 Davide Faconti +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include + +#include "pj_base/parser_module/module.hpp" + +class AdversarialWasmParser : public pj::FunctionalParser { + public: + pj::Status bind(const pj::BindingInfo&) override { + return pj::Status::ok(); + } + + pj::Status parseObject(pj::PayloadView payload, pj::Timestamp, pj::ObjectWriter& output) override { + if (payload.size == 0 || payload.data == nullptr) { + return pj::Status::error("adversarial fixture requires a behavior byte"); + } + switch (payload.data[0]) { + case 0: + __builtin_trap(); + return pj::Status::error("unreachable returned"); + case 1: { + volatile uint64_t progress = 0; + while (true) { + ++progress; + } + } + case 2: { + const size_t before = __builtin_wasm_memory_size(0); + const size_t result = __builtin_wasm_memory_grow(0, std::numeric_limits::max()); + const size_t after = __builtin_wasm_memory_size(0); + if (result != std::numeric_limits::max() || before != after) { + return pj::Status::error("memory growth escaped the declared maximum"); + } + return pj::Status::error("memory growth rejected by declared maximum"); + } + default: + break; + } + + auto cloud = output.pointCloud(); + if (auto status = cloud.setWidth(1); !status.isOk()) { + return status; + } + if (auto status = cloud.setHeight(1); !status.isOk()) { + return status; + } + if (auto status = cloud.setPointStep(1); !status.isOk()) { + return status; + } + if (auto status = cloud.setRowStep(1); !status.isOk()) { + return status; + } + if (auto status = cloud.setDense(true); !status.isOk()) { + return status; + } + const uint8_t data = 42; + return cloud.setData(pj::PayloadView(&data, 1)); + } +}; + +PJ_FUNCTIONAL_PARSER(AdversarialWasmParser) diff --git a/pj_plugins/tests/adversarial_wasm_parser_module.module.json b/pj_plugins/tests/adversarial_wasm_parser_module.module.json new file mode 100644 index 00000000..4ac31574 --- /dev/null +++ b/pj_plugins/tests/adversarial_wasm_parser_module.module.json @@ -0,0 +1,16 @@ +{ + "module_abi": 1, + "id": "org.plotjuggler.test.adversarial-wasm", + "name": "Adversarial wasm parser-module fixture", + "version": "1.0.0", + "claims": [ + { + "claim_id": "adversarial", + "encoding": "ros2msg", + "type_name": "test_msgs/msg/Adversarial", + "routes": ["object"], + "object_type": "kPointCloud", + "priority": 0 + } + ] +} diff --git a/pj_plugins/tests/native_parser_module_test.cpp b/pj_plugins/tests/native_parser_module_test.cpp index 8604f8be..5919f29f 100644 --- a/pj_plugins/tests/native_parser_module_test.cpp +++ b/pj_plugins/tests/native_parser_module_test.cpp @@ -8,12 +8,15 @@ #include #include #include +#include #include #include #include "native_parser_module_fixture.hpp" #include "pj_base/parser_module_abi.h" #include "pj_plugins/host/parser_claim_catalog.hpp" +#include "pj_plugins/host/parser_module_runtime.hpp" +#include "pj_plugins/host/parser_module_session_budget.hpp" namespace PJ { namespace { @@ -130,5 +133,45 @@ TEST(NativeParserModule, ReportsSpecificLoaderFailureCauses) { EXPECT_NE(unreadable.error().find("manifest is unreadable"), std::string::npos); } +TEST(NativeParserModule, EnforcesAggregateBudgetsAtLoadAndCreate) { + const uint64_t artifact_size = std::filesystem::file_size(PJ_NATIVE_MODULE_FIXTURE_PATH); + const auto make_budget = [&](ParserModuleSessionBudgetLimits limits) { + return std::make_shared(limits); + }; + ParserModuleSessionBudgetLimits limits; + limits.maximum_modules = 0; + auto module_budget = make_budget(limits); + auto module_decline = NativeParserModule::load(PJ_NATIVE_MODULE_FIXTURE_PATH, module_budget); + ASSERT_FALSE(module_decline.has_value()); + EXPECT_NE(module_decline.error().find("module_count"), std::string::npos); + EXPECT_EQ(module_budget->usage().modules, 0U); + + limits = {}; + limits.maximum_artifact_bytes = artifact_size - 1; + auto artifact_budget = make_budget(limits); + auto artifact = NativeParserModule::load(PJ_NATIVE_MODULE_FIXTURE_PATH, artifact_budget); + ASSERT_FALSE(artifact.has_value()); + EXPECT_NE(artifact.error().find("artifact_file_size"), std::string::npos); + EXPECT_EQ(artifact_budget->usage().modules, 0U); + + limits = {}; + limits.maximum_claims = pj_fixture::kClaimCount - 1; + auto claim_budget = make_budget(limits); + auto claims = NativeParserModule::load(PJ_NATIVE_MODULE_FIXTURE_PATH, claim_budget); + ASSERT_FALSE(claims.has_value()); + EXPECT_NE(claims.error().find("total_claims"), std::string::npos); + EXPECT_EQ(claim_budget->usage().modules, 0U); + + limits = {}; + limits.maximum_active_instances = 0; + auto instance_budget = make_budget(limits); + auto module = NativeParserModule::load(PJ_NATIVE_MODULE_FIXTURE_PATH, instance_budget); + ASSERT_TRUE(module.has_value()) << module.error(); + auto instance = NativeParserModuleInstance::create(*module, 0); + ASSERT_FALSE(instance.has_value()); + EXPECT_NE(instance.error().find("active_instances"), std::string::npos); + EXPECT_EQ(instance_budget->usage().active_instances, 0U); +} + } // namespace } // namespace PJ diff --git a/pj_plugins/tests/parser_module_session_budget_test.cpp b/pj_plugins/tests/parser_module_session_budget_test.cpp new file mode 100644 index 00000000..67f9cdf8 --- /dev/null +++ b/pj_plugins/tests/parser_module_session_budget_test.cpp @@ -0,0 +1,94 @@ +// Copyright 2026 Davide Faconti +// SPDX-License-Identifier: Apache-2.0 + +#include "pj_plugins/host/parser_module_session_budget.hpp" + +#include + +#include +#include +#include + +namespace PJ { +namespace { + +void expectDecline( + const ParserModuleAdmissionDecision& decision, ParserModuleBudgetKind budget, std::string_view diagnostic_name) { + EXPECT_EQ(decision.outcome, ParserModuleAdmissionOutcome::kDecline); + EXPECT_EQ(decision.exhausted_budget, budget); + EXPECT_NE(decision.diagnostic.find(diagnostic_name), std::string::npos); +} + +TEST(ParserModuleSessionBudget, RejectsEachModuleAdmissionBudgetWithoutMutation) { + const ParserModuleSessionBudgetLimits limits{ + .maximum_modules = 1, + .maximum_artifact_bytes = 100, + .maximum_claims = 2, + .maximum_active_instances = 2, + .maximum_linear_memory_bytes = 400, + }; + + { + ParserModuleSessionBudgetTracker tracker(limits); + expectDecline( + tracker.admitModule("oversize", 101, 1, 100), ParserModuleBudgetKind::kArtifactFileSize, "artifact_file_size"); + EXPECT_EQ(tracker.usage().modules, 0U); + } + { + ParserModuleSessionBudgetTracker tracker(limits); + expectDecline(tracker.admitModule("claims", 100, 3, 100), ParserModuleBudgetKind::kTotalClaims, "total_claims"); + EXPECT_EQ(tracker.usage().claims, 0U); + } + { + ParserModuleSessionBudgetTracker tracker(limits); + ASSERT_TRUE(tracker.admitModule("first", 100, 2, 100).accepted()); + expectDecline(tracker.admitModule("second", 1, 0, 1), ParserModuleBudgetKind::kModuleCount, "module_count"); + EXPECT_EQ(tracker.usage().modules, 1U); + EXPECT_EQ(tracker.usage().claims, 2U); + } +} + +TEST(ParserModuleSessionBudget, AppliesInstanceAndDeclaredMemoryBudgetsIndependently) { + const ParserModuleSessionBudgetLimits limits{ + .maximum_modules = 2, + .maximum_artifact_bytes = 100, + .maximum_claims = 4, + .maximum_active_instances = 2, + .maximum_linear_memory_bytes = 300, + }; + ParserModuleSessionBudgetTracker tracker(limits); + ASSERT_TRUE(tracker.admitModule("small", 100, 1, 100).accepted()); + ASSERT_TRUE(tracker.admitModule("large", 100, 1, 250).accepted()); + ASSERT_TRUE(tracker.admitInstance("large").accepted()); + + expectDecline(tracker.admitInstance("small"), ParserModuleBudgetKind::kTotalLinearMemory, "total_linear_memory"); + EXPECT_EQ(tracker.usage().active_instances, 1U); + EXPECT_TRUE(tracker.releaseInstance("large")); + ASSERT_TRUE(tracker.admitInstance("small").accepted()); + ASSERT_TRUE(tracker.admitInstance("small").accepted()); + expectDecline(tracker.admitInstance("small"), ParserModuleBudgetKind::kActiveInstances, "active_instances"); +} + +TEST(ParserModuleSessionBudget, ReleaseRequiresNoLiveInstancesAndRestoresCapacity) { + ParserModuleSessionBudgetTracker tracker( + ParserModuleSessionBudgetLimits{ + .maximum_modules = 1, + .maximum_artifact_bytes = 10, + .maximum_claims = 1, + .maximum_active_instances = 1, + .maximum_linear_memory_bytes = 20, + }); + ASSERT_TRUE(tracker.admitModule("module", 10, 1, 20).accepted()); + EXPECT_FALSE(tracker.admitModule("module", 10, 1, 20).accepted()); + ASSERT_TRUE(tracker.admitInstance("module").accepted()); + EXPECT_FALSE(tracker.releaseModule("module")); + EXPECT_TRUE(tracker.releaseInstance("module")); + EXPECT_TRUE(tracker.releaseModule("module")); + EXPECT_EQ(tracker.usage().modules, 0U); + EXPECT_EQ(tracker.usage().claims, 0U); + EXPECT_EQ(tracker.usage().active_instances, 0U); + EXPECT_EQ(tracker.usage().declared_linear_memory_bytes, 0U); +} + +} // namespace +} // namespace PJ diff --git a/pj_plugins/tests/wasm_parser_module_hardening_test.cpp b/pj_plugins/tests/wasm_parser_module_hardening_test.cpp new file mode 100644 index 00000000..ea8a7571 --- /dev/null +++ b/pj_plugins/tests/wasm_parser_module_hardening_test.cpp @@ -0,0 +1,203 @@ +// Copyright 2026 Davide Faconti +// SPDX-License-Identifier: Apache-2.0 + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "pj_base/builtin/point_cloud.hpp" +#include "pj_base/builtin_object_abi.h" +#include "pj_plugins/host/parser_module_runtime.hpp" +#include "pj_plugins/host/parser_module_session_budget.hpp" +#include "pj_plugins/host/wasm_parser_module.hpp" +#include "pj_plugins/host/wasm_parser_module_runtime.hpp" + +namespace PJ { +namespace { + +Span bytes(std::string_view text) { + return {reinterpret_cast(text.data()), text.size()}; +} + +parser_module::BindingInfoV1 binding() { + return parser_module::BindingInfoV1{ + .route = parser_module::Route::kObject, + .claim_index = 0, + .expected_object_type = PJ_BUILTIN_OBJECT_TYPE_POINTCLOUD, + .encoding = bytes("ros2msg"), + .type_name = bytes("test_msgs/msg/Adversarial"), + .schema = bytes("uint8 behavior\n"), + .claim_id = bytes("adversarial"), + .config_json = bytes("{}"), + .schema_digest = {}, + }; +} + +Expected createBound(const WasmParserModule& module) { + auto instance = WasmParserModuleInstance::create(module, 0); + if (!instance) { + return unexpected(instance.error().message); + } + auto result = instance->bind(binding()); + if (!result) { + return unexpected(result.error()); + } + if (result->outcome != ParserModuleBindOutcome::kAccept) { + return unexpected("adversarial fixture bind was not accepted: " + result->message); + } + return std::move(*instance); +} + +ParserModuleParseResult parseBehavior(WasmParserModuleInstance& instance, uint8_t behavior) { + const std::array payload{behavior}; + auto result = instance.parse(parser_module::ParseInputV1{.payload = payload}); + EXPECT_TRUE(result.has_value()) << result.error(); + return result ? std::move(*result) : ParserModuleParseResult{}; +} + +TEST(WasmParserModuleHardening, EnforcesArtifactAndDeclaredMemoryAdmissionCaps) { + const uint64_t file_size = std::filesystem::file_size(PJ_ADVERSARIAL_WASM_PATH); + WasmParserModuleLimits limits; + limits.maximum_artifact_bytes = file_size - 1; + std::vector diagnostics; + auto oversized = WasmParserModule::load( + PJ_ADVERSARIAL_WASM_PATH, limits, [&](const Diagnostic& diagnostic) { diagnostics.push_back(diagnostic); }); + ASSERT_FALSE(oversized.has_value()); + ASSERT_EQ(diagnostics.size(), 1U); + EXPECT_NE(oversized.error().find("artifact_file_size budget exhausted"), std::string::npos); + + limits = WasmParserModuleLimits{}; + limits.maximum_linear_memory_bytes = UINT64_C(128) * 1024U * 1024U; + diagnostics.clear(); + auto memory_bomb = WasmParserModule::load( + PJ_ADVERSARIAL_WASM_PATH, limits, [&](const Diagnostic& diagnostic) { diagnostics.push_back(diagnostic); }); + ASSERT_FALSE(memory_bomb.has_value()); + ASSERT_EQ(diagnostics.size(), 1U); + EXPECT_NE(memory_bomb.error().find("exceeds configured cap"), std::string::npos); +} + +TEST(WasmParserModuleHardening, EnforcesAggregateBudgetsAtActualAdmissionBoundaries) { + const uint64_t file_size = std::filesystem::file_size(PJ_ADVERSARIAL_WASM_PATH); + const auto load_with = [](ParserModuleSessionBudgetLimits limits) { + auto budget = std::make_shared(limits); + auto loaded = WasmParserModule::load(PJ_ADVERSARIAL_WASM_PATH, budget); + return std::pair(std::move(budget), std::move(loaded)); + }; + + ParserModuleSessionBudgetLimits limits; + limits.maximum_modules = 0; + auto [module_budget, module_decline] = load_with(limits); + ASSERT_FALSE(module_decline.has_value()); + EXPECT_NE(module_decline.error().find("module_count"), std::string::npos); + EXPECT_EQ(module_budget->usage().modules, 0U); + + limits = {}; + limits.maximum_artifact_bytes = file_size - 1; + auto [artifact_budget, artifact_decline] = load_with(limits); + ASSERT_FALSE(artifact_decline.has_value()); + EXPECT_NE(artifact_decline.error().find("artifact_file_size"), std::string::npos); + EXPECT_EQ(artifact_budget->usage().modules, 0U); + + limits = {}; + limits.maximum_claims = 0; + auto [claim_budget, claim_decline] = load_with(limits); + ASSERT_FALSE(claim_decline.has_value()); + EXPECT_NE(claim_decline.error().find("total_claims"), std::string::npos); + EXPECT_EQ(claim_budget->usage().modules, 0U); + + limits = {}; + limits.maximum_active_instances = 0; + auto [instance_budget, active_module] = load_with(limits); + ASSERT_TRUE(active_module.has_value()) << active_module.error(); + auto active_decline = WasmParserModuleInstance::create(*active_module, 0); + ASSERT_FALSE(active_decline.has_value()); + EXPECT_EQ(active_decline.error().outcome, WasmParserModuleCreateOutcome::kAdmissionDecline); + EXPECT_NE(active_decline.error().message.find("active_instances"), std::string::npos); + EXPECT_EQ(instance_budget->usage().active_instances, 0U); + + limits = {}; + limits.maximum_linear_memory_bytes = UINT64_C(128) * 1024U * 1024U; + auto [memory_budget, memory_module] = load_with(limits); + ASSERT_TRUE(memory_module.has_value()) << memory_module.error(); + auto memory_decline = WasmParserModuleInstance::create(*memory_module, 0); + ASSERT_FALSE(memory_decline.has_value()); + EXPECT_EQ(memory_decline.error().outcome, WasmParserModuleCreateOutcome::kAdmissionDecline); + EXPECT_NE(memory_decline.error().message.find("total_linear_memory"), std::string::npos); + EXPECT_EQ(memory_budget->usage().declared_linear_memory_bytes, 0U); +} + +TEST(WasmParserModuleHardening, MetersInfiniteLoopAsDistinctContractViolation) { + WasmParserModuleLimits limits; + limits.metering_points_per_call = UINT64_C(1000000); + auto module = WasmParserModule::load(PJ_ADVERSARIAL_WASM_PATH, limits); + ASSERT_TRUE(module.has_value()) << module.error(); + auto instance = createBound(*module); + ASSERT_TRUE(instance.has_value()) << instance.error(); + + const ParserModuleParseResult result = parseBehavior(*instance, 1); + EXPECT_EQ(result.fault, ParserModuleFaultKind::kContractViolation); + EXPECT_NE(result.message.find("wasm metering exhausted during pj_module_parse"), std::string::npos); + EXPECT_NE(result.message.find("instruction-point limit 1000000"), std::string::npos); +} + +TEST(WasmParserModuleHardening, EngineRejectsRuntimeGrowthPastDeclaredMaximum) { + auto module = WasmParserModule::load(PJ_ADVERSARIAL_WASM_PATH); + ASSERT_TRUE(module.has_value()) << module.error(); + EXPECT_EQ(module->declaredLinearMemoryMaximum(), UINT64_C(256) * 1024U * 1024U); + auto instance = createBound(*module); + ASSERT_TRUE(instance.has_value()) << instance.error(); + + const ParserModuleParseResult result = parseBehavior(*instance, 2); + EXPECT_EQ(result.fault, ParserModuleFaultKind::kDataError); + EXPECT_NE(result.message.find("memory growth rejected by declared maximum"), std::string::npos); +} + +TEST(WasmParserModuleHardening, TrapQuarantineReplaysBindingThenDisablesOnRepeat) { + auto module = WasmParserModule::load(PJ_ADVERSARIAL_WASM_PATH); + ASSERT_TRUE(module.has_value()) << module.error(); + auto instance = createBound(*module); + ASSERT_TRUE(instance.has_value()) << instance.error(); + + for (uint8_t strike = 1; strike <= 3; ++strike) { + const ParserModuleParseResult result = parseBehavior(*instance, 0); + ASSERT_EQ(result.fault, ParserModuleFaultKind::kContractViolation); + const ParserModuleStrikeState state = module->strikeState(0); + if (strike < 3) { + EXPECT_EQ(state.health, ParserModuleClaimHealth::kActive); + EXPECT_EQ(state.strikes, strike); + } else { + EXPECT_EQ(state.health, ParserModuleClaimHealth::kActive); + EXPECT_EQ(state.strikes, 0U); + EXPECT_EQ(state.quarantine_count, 1U); + EXPECT_NE(result.message.find("quarantined and recreated"), std::string::npos); + } + } + + const ParserModuleParseResult recovered = parseBehavior(*instance, 3); + ASSERT_EQ(recovered.fault, ParserModuleFaultKind::kNone) << recovered.message; + const auto* object = std::get_if(&*recovered.output); + ASSERT_NE(object, nullptr); + const auto* cloud = std::any_cast(&object->object); + ASSERT_NE(cloud, nullptr); + ASSERT_EQ(cloud->data.size(), 1U); + EXPECT_EQ(cloud->data[0], 42U); + + for (uint8_t strike = 0; strike < 3; ++strike) { + const ParserModuleParseResult result = parseBehavior(*instance, 0); + ASSERT_EQ(result.fault, ParserModuleFaultKind::kContractViolation); + } + const ParserModuleStrikeState disabled = module->strikeState(0); + EXPECT_EQ(disabled.health, ParserModuleClaimHealth::kDisabled); + EXPECT_EQ(disabled.quarantine_count, 2U); + EXPECT_FALSE(instance->valid()); +} + +} // namespace +} // namespace PJ diff --git a/pj_plugins/tests/wasm_parser_module_test.cpp b/pj_plugins/tests/wasm_parser_module_test.cpp new file mode 100644 index 00000000..a7653a9e --- /dev/null +++ b/pj_plugins/tests/wasm_parser_module_test.cpp @@ -0,0 +1,728 @@ +// Copyright 2026 Davide Faconti +// SPDX-License-Identifier: Apache-2.0 + +#include "pj_plugins/host/wasm_parser_module.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "pj_base/builtin/point_cloud.hpp" +#include "pj_base/builtin_object_abi.h" +#include "pj_base/parser_module_abi.h" +#include "pj_base/parser_module_manifest.hpp" +#include "pj_base/parser_module_wasm.hpp" +#include "pj_plugins/host/parser_claim_catalog.hpp" +#include "pj_plugins/host/wasm_parser_module_runtime.hpp" + +namespace PJ { +namespace { + +constexpr std::string_view kSchema = "uint32 width\nstring frame_id\nuint8[] data\n"; + +Span bytes(std::string_view text) { + return {reinterpret_cast(text.data()), text.size()}; +} + +std::vector readFile(std::string_view path) { + std::ifstream input(std::string(path), std::ios::binary | std::ios::ate); + EXPECT_TRUE(input.good()); + const std::streamoff size = input.tellg(); + EXPECT_GT(size, 0); + std::vector result(static_cast(size)); + input.seekg(0); + input.read(reinterpret_cast(result.data()), static_cast(result.size())); + EXPECT_TRUE(input.good()); + return result; +} + +std::optional readVarUint32(const std::vector& wasm, size_t* position) { + uint32_t value = 0; + for (size_t index = 0; index < 5; ++index) { + if (*position >= wasm.size()) { + return std::nullopt; + } + const uint8_t next = wasm[(*position)++]; + if (index == 4 && (next & UINT8_C(0xF0)) != 0) { + return std::nullopt; + } + value |= static_cast(next & UINT8_C(0x7F)) << (index * 7U); + if ((next & UINT8_C(0x80)) == 0) { + return value; + } + } + return std::nullopt; +} + +std::vector encodeVarUint32(uint32_t value) { + std::vector output; + do { + uint8_t next = static_cast(value & UINT32_C(0x7F)); + value >>= 7U; + if (value != 0) { + next |= UINT8_C(0x80); + } + output.push_back(next); + } while (value != 0); + return output; +} + +template +void append(std::vector* output, const Bytes& bytes) { + output->insert(output->end(), std::begin(bytes), std::end(bytes)); +} + +/// Encode one wasm section: id byte, varuint32 payload size, payload. +std::vector encodeSection(uint8_t id, const std::vector& payload) { + std::vector section{id}; + append(§ion, encodeVarUint32(static_cast(payload.size()))); + append(§ion, payload); + return section; +} + +struct SectionRange { + uint8_t id = 0; + size_t begin = 0; + size_t size_begin = 0; + size_t size_end = 0; + size_t payload_begin = 0; + size_t end = 0; +}; + +std::vector sections(const std::vector& wasm) { + std::vector result; + size_t position = 8; + while (position < wasm.size()) { + const size_t begin = position; + const uint8_t id = wasm[position++]; + const size_t size_begin = position; + auto size = readVarUint32(wasm, &position); + if (!size || static_cast(*size) > wasm.size() - position) { + ADD_FAILURE() << "malformed wasm fixture section"; + return {}; + } + const size_t end = position + *size; + result.push_back( + SectionRange{ + .id = id, + .begin = begin, + .size_begin = size_begin, + .size_end = position, + .payload_begin = position, + .end = end, + }); + position = end; + } + return result; +} + +std::optional findSection(const std::vector& wasm, uint8_t id) { + const auto ranges = sections(wasm); + const auto found = + std::find_if(ranges.begin(), ranges.end(), [&](const SectionRange& section) { return section.id == id; }); + return found == ranges.end() ? std::nullopt : std::optional(*found); +} + +std::optional findManifestSection(const std::vector& wasm) { + for (const auto& section : sections(wasm)) { + if (section.id != 0) { + continue; + } + size_t position = section.payload_begin; + auto name_length = readVarUint32(wasm, &position); + if (!name_length || static_cast(*name_length) > section.end - position) { + return std::nullopt; + } + const std::string_view name( + reinterpret_cast(wasm.data() + position), static_cast(*name_length)); + if (name == PJ_PARSER_MODULE_MANIFEST_SECTION_NAME) { + return section; + } + } + return std::nullopt; +} + +struct ExportLocation { + std::string name; + size_t name_begin = 0; + uint8_t kind = 0; + uint32_t index = 0; + size_t index_begin = 0; + size_t index_end = 0; +}; + +std::vector exportLocations(const std::vector& wasm) { + const auto section = findSection(wasm, 7); + if (!section) { + return {}; + } + size_t position = section->payload_begin; + auto count = readVarUint32(wasm, &position); + if (!count) { + return {}; + } + std::vector result; + for (uint32_t index = 0; index < *count; ++index) { + auto name_length = readVarUint32(wasm, &position); + if (!name_length || static_cast(*name_length) > section->end - position) { + return {}; + } + const size_t name_begin = position; + std::string name(reinterpret_cast(wasm.data() + position), static_cast(*name_length)); + position += *name_length; + if (position >= section->end) { + return {}; + } + const uint8_t kind = wasm[position++]; + const size_t item_begin = position; + auto item_index = readVarUint32(wasm, &position); + if (!item_index) { + return {}; + } + result.push_back( + ExportLocation{ + .name = std::move(name), + .name_begin = name_begin, + .kind = kind, + .index = *item_index, + .index_begin = item_begin, + .index_end = position, + }); + } + return result; +} + +std::optional findExportLocation(const std::vector& wasm, std::string_view name) { + const auto exports = exportLocations(wasm); + const auto found = + std::find_if(exports.begin(), exports.end(), [&](const ExportLocation& item) { return item.name == name; }); + return found == exports.end() ? std::nullopt : std::optional(*found); +} + +void insertSectionAfter(std::vector* wasm, uint8_t preceding_id, const std::vector& section) { + const auto preceding = findSection(*wasm, preceding_id); + ASSERT_TRUE(preceding.has_value()); + wasm->insert(wasm->begin() + static_cast(preceding->end), section.begin(), section.end()); +} + +std::vector withoutManifest(std::vector wasm) { + const auto manifest = findManifestSection(wasm); + EXPECT_TRUE(manifest.has_value()); + if (manifest) { + wasm.erase( + wasm.begin() + static_cast(manifest->begin), wasm.begin() + static_cast(manifest->end)); + } + return wasm; +} + +std::vector withDuplicateManifest(std::vector wasm) { + const auto manifest = findManifestSection(wasm); + EXPECT_TRUE(manifest.has_value()); + if (manifest) { + const std::vector duplicate( + wasm.begin() + static_cast(manifest->begin), wasm.begin() + static_cast(manifest->end)); + wasm.insert(wasm.end(), duplicate.begin(), duplicate.end()); + } + return wasm; +} + +std::vector withMalformedManifest(std::vector wasm) { + wasm = withoutManifest(std::move(wasm)); + constexpr std::string_view kMalformed = "{"; + auto embedded = parser_module::appendManifestSection(wasm, bytes(kMalformed)); + EXPECT_TRUE(embedded.has_value()) << embedded.error(); + return embedded ? std::move(*embedded) : std::move(wasm); +} + +std::vector withStartSection(std::vector wasm) { + const auto initialize = findExportLocation(wasm, "_initialize"); + EXPECT_TRUE(initialize.has_value()); + if (initialize) { + insertSectionAfter(&wasm, 7, encodeSection(8, encodeVarUint32(initialize->index))); + } + return wasm; +} + +std::vector withStartExport(std::vector wasm) { + const auto memory = findExportLocation(wasm, "memory"); + EXPECT_TRUE(memory.has_value()); + if (memory) { + constexpr std::string_view kStart = "_start"; + static_assert(kStart.size() == 6); + std::copy(kStart.begin(), kStart.end(), wasm.begin() + static_cast(memory->name_begin)); + } + return wasm; +} + +std::vector withoutOperationalExport(std::vector wasm) { + const auto exported = findExportLocation(wasm, PJ_MODULE_FREE_EXPORT_NAME); + EXPECT_TRUE(exported.has_value()); + if (exported) { + wasm[exported->name_begin] = 'x'; + } + return wasm; +} + +std::vector withWrongExportSignature(std::vector wasm) { + const auto abi = findExportLocation(wasm, PJ_MODULE_ABI_EXPORT_NAME); + const auto create = findExportLocation(wasm, PJ_MODULE_CREATE_EXPORT_NAME); + EXPECT_TRUE(abi.has_value()); + EXPECT_TRUE(create.has_value()); + if (abi && create) { + const auto encoded_index = encodeVarUint32(create->index); + EXPECT_EQ(encoded_index.size(), abi->index_end - abi->index_begin); + if (encoded_index.size() == abi->index_end - abi->index_begin) { + std::copy(encoded_index.begin(), encoded_index.end(), wasm.begin() + static_cast(abi->index_begin)); + } + } + return wasm; +} + +std::vector withDisallowedImport(std::vector wasm) { + constexpr std::string_view kModule = "wasi_snapshot_preview1"; + constexpr std::string_view kName = "fd_write"; + std::vector payload{1, static_cast(kModule.size())}; + append(&payload, kModule); + payload.push_back(static_cast(kName.size())); + append(&payload, kName); + payload.push_back(0); // function import + payload.push_back(0); // function type zero + insertSectionAfter(&wasm, 1, encodeSection(2, payload)); + return wasm; +} + +std::vector withoutMemoryMaximum(std::vector wasm) { + const auto memory = findSection(wasm, 5); + EXPECT_TRUE(memory.has_value()); + if (!memory) { + return wasm; + } + size_t position = memory->payload_begin; + const auto count = readVarUint32(wasm, &position); + const auto flags = readVarUint32(wasm, &position); + const auto minimum = readVarUint32(wasm, &position); + const auto maximum = readVarUint32(wasm, &position); + EXPECT_EQ(count, 1U); + EXPECT_EQ(flags, 1U); + EXPECT_TRUE(minimum.has_value()); + EXPECT_TRUE(maximum.has_value()); + EXPECT_EQ(position, memory->end); + if (!count || *count != 1 || !flags || *flags != 1 || !minimum || !maximum || position != memory->end) { + return wasm; + } + + std::vector payload{1, 0}; + append(&payload, encodeVarUint32(*minimum)); + const auto replacement = encodeSection(5, payload); + wasm.erase(wasm.begin() + static_cast(memory->begin), wasm.begin() + static_cast(memory->end)); + wasm.insert(wasm.begin() + static_cast(memory->begin), replacement.begin(), replacement.end()); + return wasm; +} + +std::vector withFunctionFirstOpcode(std::vector wasm, std::string_view export_name, uint8_t opcode) { + const auto function = findExportLocation(wasm, export_name); + const auto code = findSection(wasm, 10); + EXPECT_TRUE(function.has_value()); + EXPECT_TRUE(code.has_value()); + if (!function || !code) { + return wasm; + } + size_t position = code->payload_begin; + auto function_count = readVarUint32(wasm, &position); + EXPECT_TRUE(function_count.has_value()); + if (!function_count || function->index >= *function_count) { + ADD_FAILURE() << export_name << " export does not identify a defined fixture function"; + return wasm; + } + for (uint32_t function_index = 0; function_index < *function_count; ++function_index) { + auto body_size = readVarUint32(wasm, &position); + if (!body_size || static_cast(*body_size) > code->end - position) { + ADD_FAILURE() << "malformed fixture function body"; + return wasm; + } + const size_t body_end = position + *body_size; + auto local_group_count = readVarUint32(wasm, &position); + if (!local_group_count) { + return wasm; + } + for (uint32_t local = 0; local < *local_group_count; ++local) { + auto count = readVarUint32(wasm, &position); + if (!count || position >= body_end) { + return wasm; + } + ++position; // local value type + } + if (function_index == function->index) { + EXPECT_LT(position, body_end); + if (position < body_end) { + wasm[position] = opcode; + } + return wasm; + } + position = body_end; + } + return wasm; +} + +std::vector withInsertedFunctionFirstOpcode( + std::vector wasm, std::string_view export_name, uint8_t opcode) { + const auto function = findExportLocation(wasm, export_name); + const auto code = findSection(wasm, 10); + EXPECT_TRUE(function.has_value()); + EXPECT_TRUE(code.has_value()); + if (!function || !code) { + return wasm; + } + + std::vector payload( + wasm.begin() + static_cast(code->payload_begin), wasm.begin() + static_cast(code->end)); + size_t position = 0; + const auto function_count = readVarUint32(payload, &position); + EXPECT_TRUE(function_count.has_value()); + if (!function_count || function->index >= *function_count) { + ADD_FAILURE() << export_name << " export does not identify a defined fixture function"; + return wasm; + } + + for (uint32_t function_index = 0; function_index < *function_count; ++function_index) { + const size_t body_size_begin = position; + const auto body_size = readVarUint32(payload, &position); + const size_t body_size_end = position; + if (!body_size || static_cast(*body_size) > payload.size() - position) { + ADD_FAILURE() << "malformed fixture function body"; + return wasm; + } + const size_t body_end = position + *body_size; + const auto local_group_count = readVarUint32(payload, &position); + if (!local_group_count) { + return wasm; + } + for (uint32_t local = 0; local < *local_group_count; ++local) { + const auto count = readVarUint32(payload, &position); + if (!count || position >= body_end) { + return wasm; + } + ++position; // local value type + } + if (function_index == function->index) { + const auto encoded_body_size = encodeVarUint32(*body_size + 1U); + payload.erase( + payload.begin() + static_cast(body_size_begin), + payload.begin() + static_cast(body_size_end)); + payload.insert( + payload.begin() + static_cast(body_size_begin), encoded_body_size.begin(), + encoded_body_size.end()); + const size_t adjusted_position = position - (body_size_end - body_size_begin) + encoded_body_size.size(); + payload.insert(payload.begin() + static_cast(adjusted_position), opcode); + + const auto replacement = encodeSection(10, payload); + wasm.erase(wasm.begin() + static_cast(code->begin), wasm.begin() + static_cast(code->end)); + wasm.insert(wasm.begin() + static_cast(code->begin), replacement.begin(), replacement.end()); + return wasm; + } + position = body_end; + } + return wasm; +} + +std::vector withTrappingParse(std::vector wasm) { + return withFunctionFirstOpcode(std::move(wasm), PJ_MODULE_PARSE_EXPORT_NAME, 0x00); // unreachable +} + +std::vector withTrappingCreate(std::vector wasm) { + return withFunctionFirstOpcode(std::move(wasm), PJ_MODULE_CREATE_EXPORT_NAME, 0x00); // unreachable +} + +std::vector withTrappingDestroy(std::vector wasm) { + return withFunctionFirstOpcode(std::move(wasm), PJ_MODULE_DESTROY_EXPORT_NAME, 0x00); // unreachable +} + +std::vector withTrappingFree(std::vector wasm) { + return withInsertedFunctionFirstOpcode(std::move(wasm), PJ_MODULE_FREE_EXPORT_NAME, 0x00); // unreachable +} + +std::vector withInvalidParseOpcode(std::vector wasm) { + return withFunctionFirstOpcode(std::move(wasm), PJ_MODULE_PARSE_EXPORT_NAME, 0xFF); +} + +class TemporaryWasm { + public: + explicit TemporaryWasm(const std::vector& bytes) { + static std::atomic sequence{0}; + const uint64_t nonce = static_cast(std::chrono::steady_clock::now().time_since_epoch().count()); + path_ = std::filesystem::temp_directory_path() / + ("pj-wasm-parser-module-" + std::to_string(nonce) + "-" + std::to_string(sequence.fetch_add(1)) + ".wasm"); + std::ofstream output(path_, std::ios::binary | std::ios::trunc); + output.write(reinterpret_cast(bytes.data()), static_cast(bytes.size())); + EXPECT_TRUE(output.good()); + } + + ~TemporaryWasm() { + std::error_code error; + std::filesystem::remove(path_, error); + } + + [[nodiscard]] std::string string() const { + return path_.string(); + } + + private: + std::filesystem::path path_; +}; + +void appendU32(std::vector& output, uint32_t value) { + const size_t relative = output.size() - 4; + output.insert(output.end(), (4 - (relative % 4)) % 4, 0); + for (size_t index = 0; index < 4; ++index) { + output.push_back(static_cast(value >> (index * 8U))); + } +} + +std::vector toyPayload() { + std::vector output{0, 1, 0, 0}; + appendU32(output, 2); + appendU32(output, 4); + output.insert(output.end(), {'m', 'a', 'p', 0}); + appendU32(output, 8); + output.insert(output.end(), {1, 2, 3, 4, 5, 6, 7, 8}); + return output; +} + +parser_module::BindingInfoV1 binding(uint32_t claim_index, std::string_view schema) { + return parser_module::BindingInfoV1{ + .route = parser_module::Route::kObject, + .claim_index = claim_index, + .expected_object_type = PJ_BUILTIN_OBJECT_TYPE_POINTCLOUD, + .encoding = bytes("ros2msg"), + .type_name = bytes(claim_index == 0 ? "toy_msgs/msg/Cloud" : "toy_msgs/msg/CloudSplice"), + .schema = bytes(schema), + .claim_id = bytes(claim_index == 0 ? "full-wire" : "spliced"), + .config_json = bytes("{}"), + .schema_digest = {}, + }; +} + +TEST(WasmParserModule, LoadsValidatesAndAdmitsManifestWithoutInstantiation) { + std::vector diagnostics; + auto module = WasmParserModule::load( + PJ_TOY_CDR_POINTCLOUD_WASM_PATH, [&](const Diagnostic& diagnostic) { diagnostics.push_back(diagnostic); }); + ASSERT_TRUE(module.has_value()) << module.error(); + EXPECT_TRUE(module->valid()); + EXPECT_TRUE(diagnostics.empty()); + + ParserClaimCatalog catalog; + auto manifest = catalog.ingestModuleManifest(module->manifestJson(), ParserClaimProvenance::kFolderDrop, 31); + ASSERT_TRUE(manifest.has_value()) << manifest.error(); + EXPECT_EQ(manifest->id, "org.plotjuggler.test.kit-cdr-pointcloud"); + EXPECT_EQ(manifest->claims.size(), 2U); +} + +TEST(WasmParserModule, RejectsLoaderViolationsWithOneDiagnostic) { + const auto valid = readFile(PJ_TOY_CDR_POINTCLOUD_WASM_PATH); + const std::vector, std::string>> cases{ + {withoutManifest(valid), "no parser-module manifest"}, + {withDuplicateManifest(valid), "multiple parser-module manifest"}, + {withMalformedManifest(valid), "manifest is invalid JSON"}, + {withStartSection(valid), "forbidden start section"}, + {withStartExport(valid), "forbidden _start"}, + {withoutOperationalExport(valid), PJ_MODULE_FREE_EXPORT_NAME}, + {withWrongExportSignature(valid), "wrong wasm signature"}, + {withDisallowedImport(valid), "wasi_snapshot_preview1.fd_write"}, + {withoutMemoryMaximum(valid), "memory has no declared maximum"}, + {withInvalidParseOpcode(valid), "Wasmer rejected parser module"}, + }; + + for (const auto& [artifact, expected] : cases) { + TemporaryWasm file(artifact); + std::vector diagnostics; + auto module = + WasmParserModule::load(file.string(), [&](const Diagnostic& diagnostic) { diagnostics.push_back(diagnostic); }); + EXPECT_FALSE(module.has_value()) << expected; + ASSERT_EQ(diagnostics.size(), 1U) << expected; + EXPECT_EQ(diagnostics.front().level, DiagnosticLevel::kError); + EXPECT_EQ(diagnostics.front().message, module.error()); + EXPECT_NE(module.error().find(expected), std::string::npos) << module.error(); + } +} + +TEST(WasmParserModule, RunsFullWireAndSplicedPointCloudLifecycles) { + auto module = WasmParserModule::load(PJ_TOY_CDR_POINTCLOUD_WASM_PATH); + ASSERT_TRUE(module.has_value()) << module.error(); + const auto payload = toyPayload(); + const parser_module::ParseInputV1 input{ + .has_timestamp = false, + .timestamp_ns = 0, + .payload = payload, + }; + + auto full = WasmParserModuleInstance::create(*module, 0); + ASSERT_TRUE(full.has_value()) << full.error().message; + auto full_bind = full->bind(binding(0, kSchema)); + ASSERT_TRUE(full_bind.has_value()) << full_bind.error(); + ASSERT_EQ(full_bind->outcome, ParserModuleBindOutcome::kAccept); + auto full_result = full->parse(input); + ASSERT_TRUE(full_result.has_value()) << full_result.error(); + ASSERT_EQ(full_result->fault, ParserModuleFaultKind::kNone) << full_result->message; + const auto* full_object = std::get_if(&*full_result->output); + ASSERT_NE(full_object, nullptr); + EXPECT_FALSE(full_object->splice.has_value()); + const auto* full_cloud = std::any_cast(&full_object->object); + ASSERT_NE(full_cloud, nullptr); + EXPECT_EQ(full_cloud->width, 2U); + EXPECT_EQ(full_cloud->frame_id, "map"); + const std::array expected_data{1, 2, 3, 4, 5, 6, 7, 8}; + EXPECT_TRUE(std::equal(full_cloud->data.begin(), full_cloud->data.end(), expected_data.begin(), expected_data.end())); + + auto spliced = WasmParserModuleInstance::create(*module, 1); + ASSERT_TRUE(spliced.has_value()) << spliced.error().message; + auto splice_bind = spliced->bind(binding(1, kSchema)); + ASSERT_TRUE(splice_bind.has_value()) << splice_bind.error(); + ASSERT_EQ(splice_bind->outcome, ParserModuleBindOutcome::kAccept); + auto splice_result = spliced->parse(input); + ASSERT_TRUE(splice_result.has_value()) << splice_result.error(); + ASSERT_EQ(splice_result->fault, ParserModuleFaultKind::kNone) << splice_result->message; + const auto* splice_object = std::get_if(&*splice_result->output); + ASSERT_NE(splice_object, nullptr); + ASSERT_TRUE(splice_object->splice.has_value()); + EXPECT_EQ(splice_object->splice->field_number, 9U); + EXPECT_EQ(splice_object->splice->input_offset, 20U); + EXPECT_EQ(splice_object->splice->payload_bytes, (std::vector{1, 2, 3, 4, 5, 6, 7, 8})); + const auto* splice_cloud = std::any_cast(&splice_object->object); + ASSERT_NE(splice_cloud, nullptr); + EXPECT_TRUE( + std::equal(splice_cloud->data.begin(), splice_cloud->data.end(), expected_data.begin(), expected_data.end())); +} + +TEST(WasmParserModule, SurfacesBindDecline) { + auto module = WasmParserModule::load(PJ_TOY_CDR_POINTCLOUD_WASM_PATH); + ASSERT_TRUE(module.has_value()) << module.error(); + auto instance = WasmParserModuleInstance::create(*module, 0); + ASSERT_TRUE(instance.has_value()) << instance.error().message; + auto result = instance->bind(binding(0, "uint32 width\nstring frame_id\n")); + ASSERT_TRUE(result.has_value()) << result.error(); + EXPECT_EQ(result->outcome, ParserModuleBindOutcome::kDecline); + EXPECT_EQ(result->fault, ParserModuleFaultKind::kNone); + EXPECT_NE(result->message.find("unsupported toy schema revision"), std::string::npos); +} + +TEST(WasmParserModule, DeepSchemaReturnsDepthErrorWithConfiguredShadowStack) { + std::string schema = "T1 next\n"; + for (size_t depth = 1; depth <= 64; ++depth) { + schema += "MSG: T" + std::to_string(depth) + "\n"; + schema += depth < 64 ? "T" + std::to_string(depth + 1) + " next\n" : "uint32 value\n"; + } + + auto module = WasmParserModule::load(PJ_TOY_CDR_POINTCLOUD_WASM_PATH); + ASSERT_TRUE(module.has_value()) << module.error(); + auto instance = WasmParserModuleInstance::create(*module, 0); + ASSERT_TRUE(instance.has_value()) << instance.error().message; + auto result = instance->bind(binding(0, schema)); + ASSERT_TRUE(result.has_value()) << result.error(); + EXPECT_EQ(result->outcome, ParserModuleBindOutcome::kDecline); + EXPECT_EQ(result->fault, ParserModuleFaultKind::kNone); + EXPECT_NE(result->message.find("nesting depth exceeds 64"), std::string::npos); +} + +TEST(WasmParserModule, CopiesTokenZeroCreationError) { + auto module = WasmParserModule::load(PJ_TOY_CDR_POINTCLOUD_WASM_PATH); + ASSERT_TRUE(module.has_value()) << module.error(); + auto instance = WasmParserModuleInstance::create(*module, 2); + ASSERT_FALSE(instance.has_value()); + EXPECT_NE(instance.error().message.find("claim index is outside the wasm parser-module manifest"), std::string::npos); +} + +TEST(WasmParserModule, ClassifiesModuleParseErrorAsStrikeFreeDataError) { + auto module = WasmParserModule::load(PJ_TOY_CDR_POINTCLOUD_WASM_PATH); + ASSERT_TRUE(module.has_value()) << module.error(); + auto instance = WasmParserModuleInstance::create(*module, 0); + ASSERT_TRUE(instance.has_value()) << instance.error().message; + auto bound = instance->bind(binding(0, kSchema)); + ASSERT_TRUE(bound.has_value()) << bound.error(); + ASSERT_EQ(bound->outcome, ParserModuleBindOutcome::kAccept); + + const std::array truncated_payload{0, 1, 0, 0}; + auto result = instance->parse(parser_module::ParseInputV1{.payload = truncated_payload}); + ASSERT_TRUE(result.has_value()) << result.error(); + EXPECT_EQ(result->fault, ParserModuleFaultKind::kDataError); + EXPECT_FALSE(result->output.has_value()); + + ParserModuleStrikeTracker tracker; + const ParserModuleClaimKey key{"org.plotjuggler.test.kit-cdr-pointcloud", "full-wire"}; + EXPECT_EQ(tracker.recordFault(key, result->fault).strikes, 0U); + EXPECT_EQ(tracker.state(key).health, ParserModuleClaimHealth::kActive); +} + +TEST(WasmParserModule, ClassifiesGuestTrapAsContractViolation) { + TemporaryWasm artifact(withTrappingParse(readFile(PJ_TOY_CDR_POINTCLOUD_WASM_PATH))); + auto module = WasmParserModule::load(artifact.string()); + ASSERT_TRUE(module.has_value()) << module.error(); + auto instance = WasmParserModuleInstance::create(*module, 0); + ASSERT_TRUE(instance.has_value()) << instance.error().message; + auto bound = instance->bind(binding(0, kSchema)); + ASSERT_TRUE(bound.has_value()) << bound.error(); + ASSERT_EQ(bound->outcome, ParserModuleBindOutcome::kAccept); + const auto payload = toyPayload(); + auto result = instance->parse(parser_module::ParseInputV1{.payload = payload}); + ASSERT_TRUE(result.has_value()) << result.error(); + EXPECT_EQ(result->fault, ParserModuleFaultKind::kContractViolation); + EXPECT_NE(result->message.find("wasm trap"), std::string::npos); + + ParserModuleStrikeTracker tracker; + const ParserModuleClaimKey key{"org.plotjuggler.test.kit-cdr-pointcloud", "full-wire"}; + EXPECT_EQ(tracker.recordFault(key, result->fault).strikes, 1U); +} + +TEST(WasmParserModule, TypesCreationTrapsAndRecordsTheirStrike) { + TemporaryWasm artifact(withTrappingCreate(readFile(PJ_TOY_CDR_POINTCLOUD_WASM_PATH))); + auto module = WasmParserModule::load(artifact.string()); + ASSERT_TRUE(module.has_value()) << module.error(); + auto instance = WasmParserModuleInstance::create(*module, 0); + ASSERT_FALSE(instance.has_value()); + EXPECT_EQ(instance.error().outcome, WasmParserModuleCreateOutcome::kError); + EXPECT_EQ(instance.error().fault, ParserModuleFaultKind::kContractViolation); + EXPECT_NE(instance.error().message.find("pj_module_create failed: wasm trap"), std::string::npos); + EXPECT_EQ(module->strikeState(0).strikes, 1U); +} + +TEST(WasmParserModule, RecordsDestroyAndGuestFreeTraps) { + { + TemporaryWasm artifact(withTrappingDestroy(readFile(PJ_TOY_CDR_POINTCLOUD_WASM_PATH))); + auto module = WasmParserModule::load(artifact.string()); + ASSERT_TRUE(module.has_value()) << module.error(); + { + auto instance = WasmParserModuleInstance::create(*module, 0); + ASSERT_TRUE(instance.has_value()) << instance.error().message; + } + EXPECT_EQ(module->strikeState(0).strikes, 1U); + } + + TemporaryWasm artifact(withTrappingFree(readFile(PJ_TOY_CDR_POINTCLOUD_WASM_PATH))); + auto module = WasmParserModule::load(artifact.string()); + ASSERT_TRUE(module.has_value()) << module.error(); + auto instance = WasmParserModuleInstance::create(*module, 0); + ASSERT_TRUE(instance.has_value()) << instance.error().message; + auto result = instance->bind(binding(0, kSchema)); + ASSERT_TRUE(result.has_value()) << result.error(); + EXPECT_EQ(result->fault, ParserModuleFaultKind::kContractViolation); + EXPECT_NE(result->message.find("pj_module_free failed after bind"), std::string::npos); + EXPECT_NE(instance->lifecycleDiagnostic().find("pj_module_free failed after bind"), std::string_view::npos); + EXPECT_EQ(module->strikeState(0).strikes, 1U); +} + +} // namespace +} // namespace PJ diff --git a/pj_plugins/tests/wasmer_shared_module_prototype_test.cpp b/pj_plugins/tests/wasmer_shared_module_prototype_test.cpp new file mode 100644 index 00000000..8bfbebda --- /dev/null +++ b/pj_plugins/tests/wasmer_shared_module_prototype_test.cpp @@ -0,0 +1,142 @@ +// Copyright 2026 Davide Faconti +// SPDX-License-Identifier: Apache-2.0 + +#include +#include + +#include +#include +#include + +namespace PJ { +namespace { + +// (module +// (global (mut i32) (i32.const 0)) +// (func (export "next_value") (result i32) +// global.get 0 i32.const 1 i32.add global.set 0 global.get 0)) +constexpr std::array kCounterModule{ + 0x00, 0x61, 0x73, 0x6D, 0x01, 0x00, 0x00, 0x00, // preamble + 0x01, 0x05, 0x01, 0x60, 0x00, 0x01, 0x7F, // type + 0x03, 0x02, 0x01, 0x00, // function + 0x06, 0x06, 0x01, 0x7F, 0x01, 0x41, 0x00, 0x0B, // mutable global + 0x07, 0x0E, 0x01, 0x0A, 'n', 'e', 'x', 't', '_', 'v', // export + 'a', 'l', 'u', 'e', 0x00, 0x00, 0x0A, 0x0D, 0x01, 0x0B, 0x00, 0x23, 0x00, 0x41, 0x01, 0x6A, // code + 0x24, 0x00, 0x23, 0x00, 0x0B, +}; + +struct StoreInstance { + ~StoreInstance() { + if (exports_initialized) { + wasm_extern_vec_delete(&exports); + } + if (instance != nullptr) { + wasm_instance_delete(instance); + } + if (store != nullptr) { + wasm_store_delete(store); + } + } + + wasm_store_t* store = nullptr; + wasm_instance_t* instance = nullptr; + wasm_extern_vec_t exports = WASM_EMPTY_VEC; + bool exports_initialized = false; + wasm_func_t* next = nullptr; +}; + +bool instantiate(wasm_engine_t* engine, wasm_module_t* module, StoreInstance* output) { + output->store = wasm_store_new(engine); + if (output->store == nullptr) { + return false; + } + const wasm_extern_vec_t imports = WASM_EMPTY_VEC; + output->instance = wasm_instance_new(output->store, module, &imports, nullptr); + if (output->instance == nullptr) { + return false; + } + wasm_instance_exports(output->instance, &output->exports); + output->exports_initialized = true; + if (output->exports.size != 1) { + return false; + } + output->next = wasm_extern_as_func(output->exports.data[0]); + return output->next != nullptr; +} + +bool nextValue(wasm_func_t* function, int32_t* output) { + const wasm_val_vec_t arguments = WASM_EMPTY_VEC; + wasm_val_t result_values[1] = {WASM_INIT_VAL}; + wasm_val_vec_t results = WASM_ARRAY_VEC(result_values); + wasm_trap_t* trap = wasm_func_call(function, &arguments, &results); + if (trap != nullptr) { + wasm_trap_delete(trap); + return false; + } + if (result_values[0].kind != WASM_I32) { + return false; + } + *output = result_values[0].of.i32; + return true; +} + +TEST(WasmerSharedModulePrototype, CompilesOnceAndInstantiatesIntoIndependentStores) { + wasm_engine_t* engine = wasm_engine_new(); + ASSERT_NE(engine, nullptr); + const wasm_byte_vec_t binary{ + .size = kCounterModule.size(), + .data = reinterpret_cast(const_cast(kCounterModule.data())), + }; + wasm_module_t* module = wasmer_module_new(engine, &binary); + ASSERT_NE(module, nullptr); + + { + StoreInstance first; + StoreInstance second; + ASSERT_TRUE(instantiate(engine, module, &first)); + ASSERT_TRUE(instantiate(engine, module, &second)); + int32_t first_value = 0; + int32_t second_value = 0; + EXPECT_TRUE(nextValue(first.next, &first_value)); + EXPECT_TRUE(nextValue(second.next, &second_value)); + EXPECT_EQ(first_value, 1); + EXPECT_EQ(second_value, 1); + } + + wasm_module_delete(module); + wasm_engine_delete(engine); +} + +TEST(WasmerSharedModulePrototype, AllowsSequentialCallFromNonCreatorThread) { + wasm_engine_t* engine = wasm_engine_new(); + ASSERT_NE(engine, nullptr); + const wasm_byte_vec_t binary{ + .size = kCounterModule.size(), + .data = reinterpret_cast(const_cast(kCounterModule.data())), + }; + wasm_module_t* module = wasmer_module_new(engine, &binary); + ASSERT_NE(module, nullptr); + { + StoreInstance instance; + ASSERT_TRUE(instantiate(engine, module, &instance)); + + int32_t creator_value = 0; + ASSERT_TRUE(nextValue(instance.next, &creator_value)); + int32_t other_thread_value = 0; + bool other_thread_ok = false; + std::thread caller([&] { other_thread_ok = nextValue(instance.next, &other_thread_value); }); + caller.join(); + int32_t returned_value = 0; + EXPECT_TRUE(nextValue(instance.next, &returned_value)); + EXPECT_TRUE(other_thread_ok); + EXPECT_EQ(creator_value, 1); + EXPECT_EQ(other_thread_value, 2); + EXPECT_EQ(returned_value, 3); + } + + wasm_module_delete(module); + wasm_engine_delete(engine); +} + +} // namespace +} // namespace PJ From 8873b0daf2ed4e3ac54b1c0e54c9a3fbd85c5213 Mon Sep 17 00:00:00 2001 From: Davide Faconti Date: Sun, 30 Aug 2026 20:03:13 +0200 Subject: [PATCH 2/2] refactor(wasm): host-driven fault policy, resource-keyed budgets, table caps, in-tree executor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review rework of the wasm parser-module loader (PR #173). No ABI or protocol change; the installed host C++ API of 0.22/0.23 is unchanged except for additions, and NativeParserModule::load(path, sink) behaves exactly as in 0.22. - The wasm instance wrapper now has the native wrapper's fault contract: it classifies traps, metering exhaustion, malformed descriptors, and bad splices as contract violations and returns them. Strike recording, quarantine, create/bind replay, and disabling are host policy through the shared ParserModuleStrikeTracker, exactly as for native modules. This deletes the per-instance self-recreation (which was claim-wide in state but per-instance in replay, and left sibling instances on poisoned stores) and the loader-owned tracker every instance of a module raced on. - ParserModuleStrikeTracker is thread-safe (scalar and object routes of one claim run on different threads) and a contract violation while quarantined — the replay itself faulting — disables the claim instead of leaving it quarantined forever. - ParserModuleSessionBudgetTracker counts resources under opaque reservation ids instead of manifest ids: two loads of one artifact, or native and wasm builds of one source, are two reservations; duplicate-provider policy stays in the catalog. It is thread-safe because wrappers release reservations from destructors on arbitrary threads. The process-global default budget is gone: both loaders take an optional budget and do no accounting without one. - WasmParserModule::load has one signature (path + WasmParserModuleLoadOptions) instead of four overloads. - pj_base validateParserModuleWasmArtifact() is the single static admission audit used by the loader and pj-wasm-embed-manifest. It now parses the table section: every table must declare a maximum and the aggregate is capped (default 65536 elements) — table storage is host memory outside the linear memory budget and is allocated at instantiation, before any metering. - Compilation prefers Wasmer's Singlepass backend (linear compile time on untrusted input; no cancellable compile API exists). Docs now say metering is an instruction budget, not a wall-clock deadline. - The executor is an in-tree component: pj_plugin_host links it through BUILD_INTERFACE only, it is not installed or exported, and the never-taken PJ_SDK_WITH_WASMER block in plotjuggler_sdkConfig.cmake.in is removed. Installed packages stay wasmer-free and ship the authoring preset and tool. - Tests: token-zero creation error now reaches the guest (three-claim manifest on a two-claim module); table-cap loader rejections; host-driven quarantine loop; concurrent instances under one tracker; concurrent budget accounting; tracker disables on replay fault. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017U391nLF7Motf4FehVRiXz --- .../references/parser-module.md | 10 +- CHANGELOG.md | 36 ++-- CLAUDE.md | 4 +- CMakeLists.txt | 2 - cmake/plotjuggler_sdkConfig.cmake.in | 39 ----- .../include/pj_base/parser_module/README.md | 6 +- .../include/pj_base/parser_module_wasm.hpp | 43 +++++ pj_base/src/parser_module_wasm.cpp | 89 ++++++++++ pj_base/tests/parser_module_wasm_test.cpp | 36 ++++ pj_base/tools/pj_wasm_embed_manifest.cpp | 45 ++--- pj_plugins/CLAUDE.md | 19 ++- pj_plugins/CMakeLists.txt | 12 +- pj_plugins/docs/ARCHITECTURE.md | 52 ++++-- .../pj_plugins/host/native_parser_module.hpp | 3 +- .../pj_plugins/host/parser_module_runtime.hpp | 14 +- .../host/parser_module_session_budget.hpp | 60 +++---- .../pj_plugins/host/wasm_parser_module.hpp | 74 ++++---- .../host/wasm_parser_module_runtime.hpp | 22 ++- .../src/detail/native_parser_module_state.hpp | 7 +- .../src/detail/wasm_parser_module_state.hpp | 10 +- pj_plugins/src/native_parser_module.cpp | 51 +++--- pj_plugins/src/parser_module_runtime.cpp | 30 +++- .../src/parser_module_session_budget.cpp | 88 +++++----- pj_plugins/src/wasm_parser_module.cpp | 107 ++++-------- pj_plugins/src/wasm_parser_module_runtime.cpp | 160 +++++------------- .../tests/parser_module_runtime_test.cpp | 20 +++ .../parser_module_session_budget_test.cpp | 101 ++++++++--- .../wasm_parser_module_hardening_test.cpp | 139 +++++++++++---- pj_plugins/tests/wasm_parser_module_test.cpp | 112 ++++++++++-- 29 files changed, 840 insertions(+), 551 deletions(-) diff --git a/.claude/skills/plotjuggler-plugin/references/parser-module.md b/.claude/skills/plotjuggler-plugin/references/parser-module.md index 9abf4705..c2f49410 100644 --- a/.claude/skills/plotjuggler-plugin/references/parser-module.md +++ b/.claude/skills/plotjuggler-plugin/references/parser-module.md @@ -127,7 +127,8 @@ with exceptions disabled, omits the native manifest address/length exports, embeds the manifest in the `pj_parser_module_manifest` custom section via the installed `pj-wasm-embed-manifest` tool, and audits the export set post-link. Wasm reactors import nothing and must declare a linear-memory maximum -(default 256 MiB; override with `PJ_PARSER_MODULE_WASM_MAX_MEMORY_BYTES`). +(default 256 MiB; override with `PJ_PARSER_MODULE_WASM_MAX_MEMORY_BYTES`) and a +function-table maximum (wasm-ld emits one; the host caps it at 65536 elements). ## Choose a schema-compatibility strategy @@ -175,9 +176,10 @@ invalid descriptor. - The kit is header-only and WASI-clean: no threads, filesystem, iostream, host SDK linkage, or exceptions across its API. The same source builds the native and the wasm artifact; keep it that way even if you only ship one today. -- Wasm execution is instruction-metered per guest call and memory-capped by the - declared maximum; a trap or metering exhaustion is a contract strike, not a - data error. +- Wasm execution is instruction-metered per guest call (an instruction budget, + not a wall-clock deadline) and memory-capped by the declared maximum; a trap + or metering exhaustion is a contract violation the host strikes, not a data + error. Native and wasm instances share one host-side strike/quarantine loop. - Return `pj::Status` / `pj::Expected`; do not throw. `Blob` uses nothrow allocation and protobuf matching is bounded, so allocation failure is a reported data error rather than a process abort or contract strike. diff --git a/CHANGELOG.md b/CHANGELOG.md index afbae846..8f87cb1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,23 +11,35 @@ Functional parser modules can now be validated, compiled once, and executed as WASI reactors through the pinned Wasmer 7.0.1 C API: - The wasm loader admits only reactors with the frozen operational signatures, - `_initialize`, exactly one manifest section, bounded exported memory, no - start function, and the v1 empty import allow-list. -- Store-per-instance execution copies ABI blocks through guest allocation, - revalidates linear-memory ranges after every guest call, preserves host - payload splice semantics, and classifies traps or metering exhaustion as - contract violations. -- Instruction metering, declared-memory caps, and pure session admission - budgets bound calls, artifact size, modules, claims, instances, and aggregate - declared memory. Adversarial trap, infinite-loop, memory-growth, and - quarantine-replay fixtures pin the failure behavior. + `_initialize`, exactly one manifest section, bounded exported memory, bounded + tables, no start function, and the v1 empty import allow-list. The audit is + one shared `pj_base` entry point (`validateParserModuleWasmArtifact`) used by + both the loader and the `pj-wasm-embed-manifest` tool. +- Store-per-instance execution (Singlepass backend when available) copies ABI + blocks through guest allocation, revalidates linear-memory ranges after every + guest call, preserves host payload splice semantics, and classifies traps or + metering exhaustion as contract violations. The wasm wrapper has the native + wrapper's fault contract: it classifies, the host records strikes, + quarantines, and replays through the shared `ParserModuleStrikeTracker`. +- Instruction metering, declared-memory and table caps, and an optional + thread-safe session budget bound calls, artifact size, modules, claims, + instances, and aggregate declared memory. Reservations are opaque ids that + count resources, never manifest identities. Adversarial trap, infinite-loop, + memory-growth, table-cap, and host-driven quarantine fixtures pin the + behavior. +- `ParserModuleStrikeTracker` is now thread-safe, and a contract violation + while a claim is quarantined (a failed create/bind replay) disables it instead + of leaving it quarantined forever. - The installed `pj-wasm-embed-manifest` frontend embeds or verifies exact - manifest bytes and performs the shared static ABI audit. + manifest bytes and performs the shared static audit. - `pj_add_parser_module(... TARGETS wasm)` provides the wasi-sdk 27 C++17 reactor preset, manifest embedding, and post-link audit; `TARGETS native wasm` emits both artifacts from one author source. -The wasm execution libraries remain optional when `PJ_WASMER_ROOT` is unset. +The wasm executor is an in-tree component gated on `PJ_WASMER_ROOT`; installed +packages stay wasmer-free and ship the authoring preset and tool only. +`NativeParserModule::load(path, sink)` is unchanged from 0.22: admission +accounting happens only through the new budget overload. ## [0.23.1] diff --git a/CLAUDE.md b/CLAUDE.md index ffa30512..50dacd5f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,8 +27,8 @@ not in the PJ4 superproject. This file is the root navigation node for the whole - **pj_plugins** — host-side loaders + RAII handles + plugin **discovery** (directory scan + embedded-manifest inspection) for four plugin families (DataSource, MessageParser, Dialog, Toolbox), parser claim admission/resolution, native functional parser-module execution, the optional - sandboxed wasm parser-module loader/runtime (Wasmer 7.0.1, gated on `PJ_WASMER_ROOT`, only the - `plugin_host` component depends on it) with session budgets, + sandboxed wasm parser-module loader/runtime (Wasmer 7.0.1, in-tree only: gated on + `PJ_WASMER_ROOT`, never part of an installed package) with optional session budgets, config-envelope helpers, and the **dialog C ABI** (`pj_plugins/dialog_protocol/`). The duplicate-resolution *catalog* (which copy wins by priority/version/compatibility) is host policy and lives in the app (`pj_runtime`), built on these discovery primitives. Note the split: the DataSource/MessageParser/Toolbox C-ABI diff --git a/CMakeLists.txt b/CMakeLists.txt index a0de89fa..a145407f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -110,8 +110,6 @@ find_package(FastFloat REQUIRED) # Modules # --------------------------------------------------------------------------- -set(PJ_SDK_WITH_WASMER OFF) - if(PJ_BUILD_TESTS) enable_testing() endif() diff --git a/cmake/plotjuggler_sdkConfig.cmake.in b/cmake/plotjuggler_sdkConfig.cmake.in index 80bf1420..0f62038e 100644 --- a/cmake/plotjuggler_sdkConfig.cmake.in +++ b/cmake/plotjuggler_sdkConfig.cmake.in @@ -8,45 +8,6 @@ if(NOT plotjuggler_sdk_FIND_COMPONENTS) set(plotjuggler_sdk_FIND_COMPONENTS plugin_sdk) endif() -# A package built with wasm parser-module execution retains its pinned Wasmer -# dependency. Define the imported dependency before loading the exported SDK -# targets, whose static archive link interface refers to it. -if(@PJ_SDK_WITH_WASMER@ AND "plugin_host" IN_LIST plotjuggler_sdk_FIND_COMPONENTS) - set(PJ_WASMER_ROOT "$ENV{PJ_WASMER_ROOT}" CACHE PATH - "Wasmer 7.0.1 C-API root used for wasm parser-module execution") - set(_pj_wasmer_include "${PJ_WASMER_ROOT}/include") - find_file(_pj_wasmer_library - NAMES libwasmer.a libwasmer.lib wasmer.lib - PATHS "${PJ_WASMER_ROOT}/lib" - NO_DEFAULT_PATH - NO_CACHE) - if(NOT PJ_WASMER_ROOT OR - NOT EXISTS "${_pj_wasmer_include}/wasm.h" OR - NOT EXISTS "${_pj_wasmer_include}/wasmer.h" OR - NOT _pj_wasmer_library) - set(plotjuggler_sdk_FOUND FALSE) - set(plotjuggler_sdk_NOT_FOUND_MESSAGE - "plotjuggler_sdk was built with wasm parser-module execution; set PJ_WASMER_ROOT to a Wasmer 7.0.1 C-API installation") - return() - endif() - file(STRINGS "${_pj_wasmer_include}/wasmer.h" _pj_wasmer_version_line - REGEX "^#define WASMER_VERSION ") - if(NOT _pj_wasmer_version_line MATCHES "\"7\\.0\\.1\"") - set(plotjuggler_sdk_FOUND FALSE) - set(plotjuggler_sdk_NOT_FOUND_MESSAGE - "plotjuggler_sdk wasm parser-module execution requires Wasmer 7.0.1; found '${_pj_wasmer_version_line}'") - return() - endif() - find_dependency(Threads) - if(NOT TARGET pj_wasmer_static) - add_library(pj_wasmer_static UNKNOWN IMPORTED) - set_target_properties(pj_wasmer_static PROPERTIES - IMPORTED_LOCATION "${_pj_wasmer_library}" - INTERFACE_INCLUDE_DIRECTORIES "${_pj_wasmer_include}" - ) - endif() -endif() - # Include the exported targets (defines plotjuggler_sdk::base, etc.). include("${CMAKE_CURRENT_LIST_DIR}/plotjuggler_sdkTargets.cmake") diff --git a/pj_base/include/pj_base/parser_module/README.md b/pj_base/include/pj_base/parser_module/README.md index 9c77d3b9..803fa928 100644 --- a/pj_base/include/pj_base/parser_module/README.md +++ b/pj_base/include/pj_base/parser_module/README.md @@ -57,9 +57,9 @@ WASI reactor modules use the same operational exports and compile with exceptions disabled. Their manifest is delivered in the `pj_parser_module_manifest` custom section, so the native-only manifest address and length exports are omitted automatically when targeting wasm. Authored -reactors have an empty import set and a declared linear-memory maximum; the -default maximum is 256 MiB and can be configured with -`PJ_PARSER_MODULE_WASM_MAX_MEMORY_BYTES`. +reactors have an empty import set, a declared linear-memory maximum (default +256 MiB, configurable with `PJ_PARSER_MODULE_WASM_MAX_MEMORY_BYTES`), and a +declared table maximum (wasm-ld emits one; the host caps it at 65536 elements). See `.claude/skills/plotjuggler-plugin/references/parser-module.md` at the repository diff --git a/pj_base/include/pj_base/parser_module_wasm.hpp b/pj_base/include/pj_base/parser_module_wasm.hpp index 50851118..78f15a51 100644 --- a/pj_base/include/pj_base/parser_module_wasm.hpp +++ b/pj_base/include/pj_base/parser_module_wasm.hpp @@ -45,6 +45,13 @@ struct WasmMemoryLimits { bool operator==(const WasmMemoryLimits&) const = default; }; +struct WasmTableLimits { + uint32_t minimum_elements = 0; + std::optional maximum_elements; + + bool operator==(const WasmTableLimits&) const = default; +}; + struct WasmImport { std::string module; std::string name; @@ -64,6 +71,7 @@ struct WasmModuleInfo { std::vector imports; std::vector exports; std::vector memories; + std::vector tables; bool has_start_section = false; size_t section_count = 0; size_t function_type_count = 0; @@ -95,4 +103,39 @@ struct WasmModuleInfo { */ [[nodiscard]] Expected validateParserModuleWasmMemory(const WasmModuleInfo& module, uint64_t maximum_bytes); +/** Validate bounded tables and return their aggregate declared maximum. + * + * Table storage is host memory outside linear memory, so every declared table + * (imported or defined) must provide a maximum, and the sum of those maxima + * must not exceed `maximum_elements`. A module without tables returns 0. + */ +[[nodiscard]] Expected validateParserModuleWasmTables( + const WasmModuleInfo& module, uint64_t maximum_elements); + +/// Per-artifact resource caps applied by the static admission audit. +struct ParserModuleWasmLimits { + static constexpr uint64_t kDefaultMaximumLinearMemoryBytes = UINT64_C(256) * 1024U * 1024U; + static constexpr uint64_t kDefaultMaximumTableElements = UINT64_C(65536); + + uint64_t maximum_linear_memory_bytes = kDefaultMaximumLinearMemoryBytes; + uint64_t maximum_table_elements = kDefaultMaximumTableElements; +}; + +/// Result of a passed admission audit. `manifest_json` views the audited bytes. +struct ParserModuleWasmArtifact { + Span manifest_json; + WasmModuleInfo module; + uint64_t declared_linear_memory_maximum = 0; + uint64_t declared_table_elements = 0; +}; + +/** The complete static admission audit shared by the host loader and the + * `pj-wasm-embed-manifest` tool: exactly one manifest section, an empty import + * set (the frozen v1 allow-list), the frozen operational export ABI, bounded + * exported linear memory, and bounded tables. Nothing is compiled or executed. + * The manifest bytes are returned verbatim; JSON validation is the caller's. + */ +[[nodiscard]] Expected validateParserModuleWasmArtifact( + Span wasm, const ParserModuleWasmLimits& limits); + } // namespace PJ::parser_module diff --git a/pj_base/src/parser_module_wasm.cpp b/pj_base/src/parser_module_wasm.cpp index 20611b85..75300100 100644 --- a/pj_base/src/parser_module_wasm.cpp +++ b/pj_base/src/parser_module_wasm.cpp @@ -15,6 +15,7 @@ #include #include "pj_base/parser_module_abi.h" +#include "pj_base/parser_module_manifest.hpp" namespace PJ::parser_module { namespace { @@ -96,6 +97,7 @@ struct ModuleBuilder { std::vector defined_function_types; std::vector imported_memories; std::vector defined_memories; + std::vector tables; std::vector imports; std::vector exports; bool has_start_section = false; @@ -254,6 +256,8 @@ struct ModuleBuilder { if (!limits) { return unexpected(limits.error()); } + module->tables.push_back( + WasmTableLimits{.minimum_elements = limits->minimum_pages, .maximum_elements = limits->maximum_pages}); break; } case WasmExternalKind::kMemory: { @@ -306,6 +310,36 @@ struct ModuleBuilder { return requireConsumed(cursor, "function"); } +[[nodiscard]] Expected parseTableSection(Cursor cursor, ModuleBuilder* module) { + auto count = cursor.varUint32(); + if (!count) { + return unexpected(count.error()); + } + auto bounded = requireCountFits(*count, cursor.remaining(), 3, "table-section entry"); + if (!bounded) { + return unexpected(bounded.error()); + } + module->tables.reserve(module->tables.size() + *count); + for (uint32_t index = 0; index < *count; ++index) { + auto reference_type = cursor.byte(); + if (!reference_type) { + return unexpected(reference_type.error()); + } + if (*reference_type != UINT8_C(0x70) && *reference_type != UINT8_C(0x6F)) { + // 0x40 introduces the table-with-initializer form; neither it nor typed + // references are part of the frozen v1 module shape. + return unexpected(std::string("unsupported wasm table form")); + } + auto limits = readLimits(&cursor); + if (!limits) { + return unexpected(limits.error()); + } + module->tables.push_back( + WasmTableLimits{.minimum_elements = limits->minimum_pages, .maximum_elements = limits->maximum_pages}); + } + return requireConsumed(cursor, "table"); +} + [[nodiscard]] Expected parseMemorySection(Cursor cursor, ModuleBuilder* module) { auto count = cursor.varUint32(); if (!count) { @@ -381,6 +415,7 @@ struct ModuleBuilder { result.memories.reserve(builder.imported_memories.size() + builder.defined_memories.size()); result.memories.insert(result.memories.end(), builder.imported_memories.begin(), builder.imported_memories.end()); result.memories.insert(result.memories.end(), builder.defined_memories.begin(), builder.defined_memories.end()); + result.tables = std::move(builder.tables); result.imports.reserve(builder.imports.size()); for (auto& imported : builder.imports) { if (imported.function_type.has_value()) { @@ -449,6 +484,9 @@ struct ModuleBuilder { case 3: parsed = parseFunctionSection(*section, &module); break; + case 4: + parsed = parseTableSection(*section, &module); + break; case 5: parsed = parseMemorySection(*section, &module); break; @@ -574,4 +612,55 @@ Expected validateParserModuleWasmMemory(const WasmModuleInfo& module, return aggregate_maximum; } +Expected validateParserModuleWasmTables(const WasmModuleInfo& module, uint64_t maximum_elements) { + uint64_t aggregate_maximum = 0; + for (const auto& table : module.tables) { + if (!table.maximum_elements.has_value()) { + return unexpected(std::string("wasm parser-module table has no declared maximum")); + } + const uint64_t elements = *table.maximum_elements; + if (elements > maximum_elements || elements > maximum_elements - aggregate_maximum) { + return unexpected( + "wasm parser-module table maximum " + std::to_string(elements) + " exceeds configured cap " + + std::to_string(maximum_elements) + " elements"); + } + aggregate_maximum += elements; + } + return aggregate_maximum; +} + +Expected validateParserModuleWasmArtifact( + Span wasm, const ParserModuleWasmLimits& limits) { + auto manifest = readManifestSection(wasm); + if (!manifest) { + return unexpected("invalid wasm parser-module manifest: " + manifest.error()); + } + auto inspected = inspectWasmModule(wasm); + if (!inspected) { + return unexpected("invalid wasm parser module: " + inspected.error()); + } + if (!inspected->imports.empty()) { + const auto& imported = inspected->imports.front(); + return unexpected("wasm parser module uses disallowed import '" + imported.module + "." + imported.name + "'"); + } + auto abi = validateParserModuleWasmAbi(*inspected); + if (!abi) { + return unexpected(abi.error()); + } + auto memory_maximum = validateParserModuleWasmMemory(*inspected, limits.maximum_linear_memory_bytes); + if (!memory_maximum) { + return unexpected(memory_maximum.error()); + } + auto table_maximum = validateParserModuleWasmTables(*inspected, limits.maximum_table_elements); + if (!table_maximum) { + return unexpected(table_maximum.error()); + } + return ParserModuleWasmArtifact{ + .manifest_json = *manifest, + .module = std::move(*inspected), + .declared_linear_memory_maximum = *memory_maximum, + .declared_table_elements = *table_maximum, + }; +} + } // namespace PJ::parser_module diff --git a/pj_base/tests/parser_module_wasm_test.cpp b/pj_base/tests/parser_module_wasm_test.cpp index 6381d1a1..140e3cd3 100644 --- a/pj_base/tests/parser_module_wasm_test.cpp +++ b/pj_base/tests/parser_module_wasm_test.cpp @@ -74,5 +74,41 @@ TEST(ParserModuleWasm, RejectsInvalidIndicesAndUnboundedVectorCounts) { expectRejected(std::move(hostile_count), "count exceeds the remaining section bytes"); } +TEST(ParserModuleWasm, ParsesAndBoundsTableSections) { + auto unsupported_form = module(); + appendSection(&unsupported_form, 4, {1, 0x40, 0x00, 0x70, 0, 1}); + expectRejected(std::move(unsupported_form), "unsupported wasm table form"); + + auto unbounded = module(); + appendSection(&unbounded, 4, {1, 0x70, 0, 25}); + auto inspected_unbounded = inspectWasmModule(unbounded); + ASSERT_TRUE(inspected_unbounded.has_value()) << inspected_unbounded.error(); + ASSERT_EQ(inspected_unbounded->tables.size(), 1U); + EXPECT_EQ(inspected_unbounded->tables.front().minimum_elements, 25U); + EXPECT_FALSE(inspected_unbounded->tables.front().maximum_elements.has_value()); + auto unbounded_tables = validateParserModuleWasmTables(*inspected_unbounded, 1000); + ASSERT_FALSE(unbounded_tables.has_value()); + EXPECT_NE(unbounded_tables.error().find("no declared maximum"), std::string::npos); + + auto bounded = module(); + appendSection(&bounded, 4, {2, 0x70, 1, 25, 100, 0x6F, 1, 0, 50}); + auto inspected_bounded = inspectWasmModule(bounded); + ASSERT_TRUE(inspected_bounded.has_value()) << inspected_bounded.error(); + ASSERT_EQ(inspected_bounded->tables.size(), 2U); + auto within_cap = validateParserModuleWasmTables(*inspected_bounded, 150); + ASSERT_TRUE(within_cap.has_value()) << within_cap.error(); + EXPECT_EQ(*within_cap, 150U); + auto over_cap = validateParserModuleWasmTables(*inspected_bounded, 149); + ASSERT_FALSE(over_cap.has_value()); + EXPECT_NE(over_cap.error().find("exceeds configured cap"), std::string::npos); + + auto none = module(); + auto inspected_none = inspectWasmModule(none); + ASSERT_TRUE(inspected_none.has_value()); + auto no_tables = validateParserModuleWasmTables(*inspected_none, 0); + ASSERT_TRUE(no_tables.has_value()); + EXPECT_EQ(*no_tables, 0U); +} + } // namespace } // namespace PJ::parser_module diff --git a/pj_base/tools/pj_wasm_embed_manifest.cpp b/pj_base/tools/pj_wasm_embed_manifest.cpp index f63b6dc6..dda84bc4 100644 --- a/pj_base/tools/pj_wasm_embed_manifest.cpp +++ b/pj_base/tools/pj_wasm_embed_manifest.cpp @@ -67,41 +67,30 @@ using PJ::unexpected; if (!manifest) { return unexpected(manifest.error()); } - auto embedded = PJ::parser_module::readManifestSection(*wasm); - if (!embedded) { - return unexpected(embedded.error()); - } - if (embedded->size() != manifest->size() || !std::equal(embedded->begin(), embedded->end(), manifest->begin())) { + PJ::parser_module::ParserModuleWasmLimits limits; + limits.maximum_linear_memory_bytes = maximum_memory_bytes; + auto artifact = PJ::parser_module::validateParserModuleWasmArtifact(*wasm, limits); + if (!artifact) { + return unexpected(artifact.error()); + } + const auto& embedded = artifact->manifest_json; + if (embedded.size() != manifest->size() || !std::equal(embedded.begin(), embedded.end(), manifest->begin())) { return unexpected(std::string("embedded parser-module manifest bytes do not match the source file")); } - auto module = PJ::parser_module::inspectWasmModule(*wasm); - if (!module) { - return unexpected(module.error()); - } - auto abi = PJ::parser_module::validateParserModuleWasmAbi(*module); - if (!abi) { - return unexpected(abi.error()); - } - if (!module->imports.empty()) { - const auto& imported = module->imports.front(); - return unexpected("disallowed parser-module import: " + imported.module + "." + imported.name); - } - auto memory = PJ::parser_module::validateParserModuleWasmMemory(*module, maximum_memory_bytes); - if (!memory) { - return unexpected(memory.error()); - } - + const auto& module = artifact->module; std::cout << "WASM parser-module ABI conformance: PASS\n" - << " sections enumerated: " << module->section_count << '\n' - << " function types: " << module->function_type_count << ", functions: " << module->function_count - << ", exports: " << module->exports.size() << '\n' + << " sections enumerated: " << module.section_count << '\n' + << " function types: " << module.function_type_count << ", functions: " << module.function_count + << ", exports: " << module.exports.size() << '\n' << " operational exports: 8 exact signatures verified\n" << " reactor: _initialize exported; _start/start section absent\n" << " native-only metadata exports: absent\n" << " imports: empty frozen allow-list verified\n" - << " declared linear-memory maximum: " << *memory << " bytes\n" - << " manifest section: exactly one, " << embedded->size() << " exact bytes\n"; + << " declared linear-memory maximum: " << artifact->declared_linear_memory_maximum << " bytes\n" + << " declared table elements: " << artifact->declared_table_elements << " (cap " + << limits.maximum_table_elements << ")\n" + << " manifest section: exactly one, " << embedded.size() << " exact bytes\n"; return {}; } @@ -134,7 +123,7 @@ int main(int argc, char** argv) { if (argc == 5 && std::string_view(argv[1]) == "embed") { result = embed(argv[2], argv[3], argv[4]); } else if ((argc == 4 || argc == 5) && std::string_view(argv[1]) == "verify") { - uint64_t maximum_memory_bytes = UINT64_C(256) * 1024U * 1024U; + uint64_t maximum_memory_bytes = PJ::parser_module::ParserModuleWasmLimits::kDefaultMaximumLinearMemoryBytes; if (argc == 5) { const std::string_view text(argv[4]); const auto parsed = std::from_chars(text.data(), text.data() + text.size(), maximum_memory_bytes); diff --git a/pj_plugins/CLAUDE.md b/pj_plugins/CLAUDE.md index 8960c274..62145714 100644 --- a/pj_plugins/CLAUDE.md +++ b/pj_plugins/CLAUDE.md @@ -55,13 +55,18 @@ submodule-internal modules; `pj_base` carries none). `pj_module_destroy`; only the code mapping has session lifetime. - **Wasm parser modules have an empty import allow-list in v1.** The loader admits reactors with the exact operational exports, `_initialize`, exported - memory with a bounded declared maximum, no start function, and no imports. - One engine-owned compiled Wasmer module creates independent stores per - instance. Sequential cross-thread use is supported, but overlapping calls on - one instance are forbidden and must be serialized by the application host. - Wasmer metering is reset for every ABI call; exhaustion is a contract strike. - The pinned static archive has no public interrupt/epoch API, and native stack - depth uses Wasmer's guarded default. + memory with a bounded declared maximum, tables with a bounded declared + maximum, no start function, and no imports — the same `pj_base` audit the + embed tool runs. One engine-owned compiled Wasmer module (Singlepass when + available) creates independent stores per instance. Sequential cross-thread + use is supported, but overlapping calls on one instance are forbidden and + must be serialized by the application host. Wasmer metering is reset for + every ABI call; exhaustion is a contract violation the host strikes (the wasm + wrapper classifies faults exactly like the native one; strike, quarantine, + and replay policy stay in the host's `ParserModuleStrikeTracker`). The pinned + static archive has no public interrupt/epoch API, and native stack depth uses + Wasmer's guarded default. The executor is in-tree only (`PJ_WASMER_ROOT`); + installed packages are wasmer-free. ## Read deeper | For | Read | diff --git a/pj_plugins/CMakeLists.txt b/pj_plugins/CMakeLists.txt index 2d6d9e9d..64d95718 100644 --- a/pj_plugins/CMakeLists.txt +++ b/pj_plugins/CMakeLists.txt @@ -33,7 +33,10 @@ else() set(PJ_WASMER_AVAILABLE ON) message(STATUS "Wasm parser-module loader enabled with Wasmer 7.0.1: ${PJ_WASMER_ROOT}") endif() -set(PJ_SDK_WITH_WASMER ${PJ_WASMER_AVAILABLE} PARENT_SCOPE) +# The wasm executor is an in-tree component: the application builds this SDK +# from source with PJ_WASMER_ROOT set. Installed packages stay wasmer-free (they +# still ship the wasm authoring preset and pj-wasm-embed-manifest), so nothing +# below exports a target that would reference pj_wasmer_static. add_library(pj_plugin_loader_detail STATIC src/detail/vtable_validation.cpp @@ -361,7 +364,7 @@ target_link_libraries(pj_plugin_host INTERFACE pj_parser_module_host ) if(PJ_WASMER_AVAILABLE) - target_link_libraries(pj_plugin_host INTERFACE pj_wasm_parser_module_host) + target_link_libraries(pj_plugin_host INTERFACE $) endif() set_target_properties(pj_plugin_host PROPERTIES EXPORT_NAME plugin_host) add_library(plotjuggler_sdk::plugin_host ALIAS pj_plugin_host) @@ -810,10 +813,6 @@ endif() # PJ_BUILD_TESTS # --------------------------------------------------------------------------- if(PJ_INSTALL_SDK) - set(_pj_plugin_host_install_targets) - if(PJ_WASMER_AVAILABLE) - list(APPEND _pj_plugin_host_install_targets pj_wasm_parser_module_host) - endif() install(TARGETS pj_plugin_loader_detail pj_plugin_sdk @@ -824,7 +823,6 @@ if(PJ_INSTALL_SDK) pj_message_parser_host pj_toolbox_host pj_plugin_host - ${_pj_plugin_host_install_targets} EXPORT plotjuggler_sdkTargets ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR} LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR} diff --git a/pj_plugins/docs/ARCHITECTURE.md b/pj_plugins/docs/ARCHITECTURE.md index 321b2207..4c11e81b 100644 --- a/pj_plugins/docs/ARCHITECTURE.md +++ b/pj_plugins/docs/ARCHITECTURE.md @@ -150,7 +150,9 @@ type/route mismatch, and bad tokens are contract violations; other module-reported per-message failures are data errors. The separate non-thread-safe `ParserModuleStrikeTracker` quarantines a module claim on its third contract violation, permits one same-descriptor recreation, and disables -the claim after a repeated three-strike cycle. Executor placement, generation +the claim after a repeated three-strike cycle or a contract violation during the +replay itself. It is shared by native and wasm instances and is thread-safe, +because the scalar and object routes of one claim run on different threads. Executor placement, generation ownership, folder scanning, and rescan policy remain application concerns. Module authors use the standalone C++17 headers under @@ -179,21 +181,39 @@ toy module with C++17 and exceptions disabled, then statically audits the reactor model and every operational export signature without executing wasm. When `PJ_WASMER_ROOT` selects the pinned Wasmer 7.0.1 C API, the optional -`WasmParserModule` loader applies that static audit before compilation and -requires exported memory plus an empty import set. The fixture supplies its -unreachable WASI I/O fallbacks internally, so no fd, path, socket, clock, -random, environment, or scheduler capability enters the frozen v1 allow-list. -An engine-owned compiled module is instantiated in one independent store per -bound instance. Store calls may migrate between threads sequentially, but the -host must serialize calls on an instance. The runtime reacquires linear memory -after every guest call, validates every returned range, and resolves splices -against the original host payload. Per-call Wasmer instruction metering is the -enforceable execution deadline; the pinned archive exposes no public interrupt -or epoch API. Artifacts must declare a bounded memory maximum, and a separate -session tracker admits module count, file size, total claims, active instances, -and aggregate per-instance declared memory. Guest traps and metering exhaustion -join malformed descriptors and bad offsets in the contract-violation strike -path; module-reported parse errors remain strike-free data errors. +`WasmParserModule` loader runs the same `pj_base` static audit as the +`pj-wasm-embed-manifest` tool (`validateParserModuleWasmArtifact`) before +compilation: exactly one manifest section, an empty import set, the frozen +export ABI, exported linear memory with a declared maximum, and tables with a +declared maximum (default cap 65536 elements — table storage is host memory +outside the linear-memory budget). The authoring preset supplies unreachable +WASI I/O fallbacks internally, so no fd, path, socket, clock, random, +environment, or scheduler capability enters the frozen v1 allow-list. +Compilation prefers Wasmer's Singlepass backend (linear compile time; there is +no cancellable compile API) and always runs synchronously inside `load()` — the +host keeps it off the UI thread. An engine-owned compiled module is +instantiated in one independent store per bound instance. Store calls may +migrate between threads sequentially, but the host must serialize calls on an +instance. The runtime reacquires linear memory after every guest call, +validates every returned range, and resolves splices against the original host +payload. Per-call instruction metering bounds guest execution; it is an +instruction budget, not a wall-clock deadline (bulk operators such as +`memory.copy` cost one point regardless of size, and the pinned archive exposes +no public interrupt or epoch API). Artifacts must declare a bounded memory +maximum, and the optional, thread-safe `ParserModuleSessionBudgetTracker` +admits module count, file size, total claims, active instances, and aggregate +per-instance declared memory; it counts resources under opaque reservation ids +and never keys on manifest identity, which remains catalog policy. Both loaders +accept a null budget and then perform no admission accounting. + +The wasm wrapper carries exactly the native wrapper's fault contract: guest +traps and metering exhaustion join malformed descriptors and bad offsets as +returned contract violations, module-reported parse errors are strike-free data +errors, and the host records faults, quarantines, replays create/bind, and +disables through `ParserModuleStrikeTracker`. The executor is an in-tree +component: applications build the SDK from source with `PJ_WASMER_ROOT`, while +installed packages stay wasmer-free and ship only the authoring preset and the +embed/audit tool. ### Wasmer pin rationale (7.0.1, evaluated against 7.2.1 on 2026-08-09) diff --git a/pj_plugins/include/pj_plugins/host/native_parser_module.hpp b/pj_plugins/include/pj_plugins/host/native_parser_module.hpp index ae36a880..8c10456c 100644 --- a/pj_plugins/include/pj_plugins/host/native_parser_module.hpp +++ b/pj_plugins/include/pj_plugins/host/native_parser_module.hpp @@ -38,7 +38,8 @@ class NativeParserModule { [[nodiscard]] static Expected load( std::string_view path, DiagnosticSink sink = {}, std::string diagnostic_source = "NativeParserModule"); - /// Load using an application-owned aggregate session budget. + /// Load under an application-owned aggregate session budget. A null budget + /// behaves exactly like the overload above: no admission accounting. [[nodiscard]] static Expected load( std::string_view path, std::shared_ptr budget, DiagnosticSink sink = {}, std::string diagnostic_source = "NativeParserModule"); diff --git a/pj_plugins/include/pj_plugins/host/parser_module_runtime.hpp b/pj_plugins/include/pj_plugins/host/parser_module_runtime.hpp index d78f674b..3f76d5b4 100644 --- a/pj_plugins/include/pj_plugins/host/parser_module_runtime.hpp +++ b/pj_plugins/include/pj_plugins/host/parser_module_runtime.hpp @@ -8,14 +8,17 @@ * * The wrapper performs one serialized create/bind/parse/destroy lifecycle. * Module-owned descriptor views are decoded and copied before parse returns. - * The independent strike tracker is deliberately pure, non-thread-safe state - * so a host executor can apply its own scheduling and generation policy. + * The independent strike tracker holds pure per-claim state that a host + * executor drives with its own scheduling and generation policy; it is + * thread-safe because the scalar and object routes of one claim run on + * different threads. */ #include #include #include #include +#include #include #include #include @@ -148,7 +151,11 @@ struct ParserModuleStrikeState { uint8_t quarantine_count = 0; }; -/// Pure per-(module, claim) contract-fault state. Data errors never mutate it. +/// Per-(module, claim) contract-fault state shared by native and wasm modules. +/// Data errors never mutate it. Three contract violations quarantine the claim; +/// the host then replays create/bind and calls markRecreated(). A contract +/// violation while quarantined (a failed replay) or a second three-strike cycle +/// disables the claim for the session. Thread-safe. class ParserModuleStrikeTracker { public: [[nodiscard]] ParserModuleStrikeState recordFault(const ParserModuleClaimKey& key, ParserModuleFaultKind fault); @@ -160,6 +167,7 @@ class ParserModuleStrikeTracker { [[nodiscard]] ParserModuleStrikeState state(const ParserModuleClaimKey& key) const; private: + mutable std::mutex mutex_; std::map states_; }; diff --git a/pj_plugins/include/pj_plugins/host/parser_module_session_budget.hpp b/pj_plugins/include/pj_plugins/host/parser_module_session_budget.hpp index 4516e709..2c4fb509 100644 --- a/pj_plugins/include/pj_plugins/host/parser_module_session_budget.hpp +++ b/pj_plugins/include/pj_plugins/host/parser_module_session_budget.hpp @@ -4,19 +4,22 @@ /** * @file parser_module_session_budget.hpp - * @brief Pure admission accounting for parser-module session limits. + * @brief Aggregate resource accounting for parser-module admission. * - * This state is deliberately non-thread-safe. The application host owns - * serialization and calls it before compilation or lazy instantiation. A - * declined reservation never mutates usage. + * The tracker counts resources, not identities: every accepted module + * reservation is an opaque id, so two loads of one artifact (or native and + * wasm builds of one source) are two reservations. Duplicate-provider policy + * belongs to the claim catalog, not here. + * + * The tracker is thread-safe. Loader wrappers release reservations from their + * destructors, which run on whichever thread drops the wrapper, so callers + * cannot be asked to serialize it. A declined reservation never mutates usage. */ #include -#include #include -#include +#include #include -#include namespace PJ { @@ -34,11 +37,6 @@ struct ParserModuleSessionBudgetLimits { uint64_t maximum_linear_memory_bytes = kDefaultMaximumLinearMemoryBytes; }; -enum class ParserModuleAdmissionOutcome : uint8_t { - kAccept, - kDecline, -}; - enum class ParserModuleBudgetKind : uint8_t { kNone, kModuleCount, @@ -48,13 +46,15 @@ enum class ParserModuleBudgetKind : uint8_t { kTotalLinearMemory, }; +/// Outcome of one admission request. `reservation` is nonzero exactly when +/// the request was accepted and is the handle for every later call. struct ParserModuleAdmissionDecision { - ParserModuleAdmissionOutcome outcome = ParserModuleAdmissionOutcome::kDecline; + uint64_t reservation = 0; ParserModuleBudgetKind exhausted_budget = ParserModuleBudgetKind::kNone; std::string diagnostic; [[nodiscard]] bool accepted() const noexcept { - return outcome == ParserModuleAdmissionOutcome::kAccept; + return reservation != 0; } }; @@ -69,37 +69,37 @@ class ParserModuleSessionBudgetTracker { public: explicit ParserModuleSessionBudgetTracker(ParserModuleSessionBudgetLimits limits = {}); - /// Reserve one compiled module before compilation. `artifact_bytes` is a - /// per-file gate; claims contribute to the aggregate session total. + /// Reserve one module before compilation. `artifact_bytes` is a per-file + /// gate; `claim_count` joins the aggregate claim total; + /// `declared_linear_memory_maximum` is charged per admitted instance. [[nodiscard]] ParserModuleAdmissionDecision admitModule( - std::string module_id, uint64_t artifact_bytes, uint64_t claim_count, uint64_t declared_linear_memory_maximum); + uint64_t artifact_bytes, uint64_t claim_count, uint64_t declared_linear_memory_maximum); - /// Reserve one lazy instance. Its module's declared memory maximum is added - /// to aggregate memory because every instance owns an independent store. - [[nodiscard]] ParserModuleAdmissionDecision admitInstance(std::string_view module_id); + /// Reserve one instance of an admitted module. Every instance owns an + /// independent store, so the module's declared memory is charged again. + [[nodiscard]] ParserModuleAdmissionDecision admitInstance(uint64_t module_reservation); - [[nodiscard]] bool releaseInstance(std::string_view module_id); - [[nodiscard]] bool releaseModule(std::string_view module_id); + /// Releases are idempotent-safe: unknown or already-released ids are ignored. + void releaseInstance(uint64_t module_reservation); + /// A module reservation is released even when instances are still live; + /// their later releases then find no module and are ignored. + void releaseModule(uint64_t module_reservation); [[nodiscard]] const ParserModuleSessionBudgetLimits& limits() const noexcept; - [[nodiscard]] ParserModuleSessionBudgetUsage usage() const noexcept; + [[nodiscard]] ParserModuleSessionBudgetUsage usage() const; private: struct ModuleReservation { - uint64_t artifact_bytes = 0; uint64_t claim_count = 0; uint64_t declared_linear_memory_maximum = 0; uint64_t active_instances = 0; }; ParserModuleSessionBudgetLimits limits_; + mutable std::mutex mutex_; ParserModuleSessionBudgetUsage usage_; - std::map> modules_; + uint64_t next_reservation_ = 1; + std::map modules_; }; -/// Process-session defaults used by loader overloads that are not supplied an -/// application-owned tracker. The returned tracker is shared by native and -/// wasm admission. -[[nodiscard]] std::shared_ptr defaultParserModuleSessionBudget(); - } // namespace PJ diff --git a/pj_plugins/include/pj_plugins/host/wasm_parser_module.hpp b/pj_plugins/include/pj_plugins/host/wasm_parser_module.hpp index 3b93e423..d5d3bfb9 100644 --- a/pj_plugins/include/pj_plugins/host/wasm_parser_module.hpp +++ b/pj_plugins/include/pj_plugins/host/wasm_parser_module.hpp @@ -6,11 +6,12 @@ * @file wasm_parser_module.hpp * @brief Wasmer-backed loader for sandboxed functional parser modules. * - * Admission is passive: the loader validates the manifest section, reactor - * shape, operational signatures, exported memory, and imports before Wasmer - * compiles the artifact. The v1 import allow-list is deliberately empty. In - * particular, every `wasi_snapshot_preview1` fd, path, socket, environment, - * clock, random, process, and scheduler import is rejected. + * Admission is passive: the shared pj_base audit validates the manifest + * section, reactor shape, operational signatures, exported memory, bounded + * tables, and imports before Wasmer compiles the artifact. The v1 import + * allow-list is deliberately empty. In particular, every + * `wasi_snapshot_preview1` fd, path, socket, environment, clock, random, + * process, and scheduler import is rejected. * * Wasmer 7.0.1's static C archive does not provide the share/obtain symbols * declared by wasm.h. Its engine-owned `wasmer_module_new` extension is the @@ -21,14 +22,22 @@ * on one instance must never overlap. Per-store executor serialization remains * a host responsibility. * - * Wasmer's metering middleware is enabled for every compiled module and each - * guest call receives a fresh instruction-point allowance. The pinned archive - * exports no public interrupt or epoch API, so metering is the enforceable - * deadline mechanism. Linear memory must declare a maximum within the loader - * cap; Wasmer enforces that maximum at runtime. The SDK authoring preset puts - * a configurable 1 MiB guest shadow stack before data segments so overflow - * traps instead of corrupting them. Native engine stack depth relies on - * Wasmer 7's guarded default because its C API exposes no stack-limit setter. + * Compilation uses Wasmer's Singlepass backend when the archive provides it: + * its compile time is linear in artifact size, which is the only bound + * available for compiling an untrusted artifact (there is no cancellable + * compile API). Wasmer's metering middleware is enabled for every compiled + * module and each guest call receives a fresh instruction-point allowance. + * That is an instruction budget, not a wall-clock deadline: bulk operators + * such as memory.copy cost one point regardless of size, and the pinned + * archive exports no interrupt or epoch API. Linear memory must declare a + * maximum within the loader cap; Wasmer enforces that maximum at runtime. The + * SDK authoring preset puts a configurable 1 MiB guest shadow stack before + * data segments so overflow traps instead of corrupting them. Native engine + * stack depth relies on Wasmer 7's guarded default because its C API exposes + * no stack-limit setter. + * + * Like the native loader, this loader carries no fault policy: instances + * classify faults and the host feeds them to ParserModuleStrikeTracker. */ #include @@ -38,7 +47,7 @@ #include "pj_base/diagnostic_sink.hpp" #include "pj_base/expected.hpp" -#include "pj_plugins/host/parser_module_runtime.hpp" +#include "pj_base/parser_module_wasm.hpp" #include "pj_plugins/host/parser_module_session_budget.hpp" namespace PJ { @@ -49,40 +58,33 @@ struct WasmParserModuleState; class WasmParserModuleInstance; +/// Per-artifact caps and the per-call instruction budget. struct WasmParserModuleLimits { static constexpr uint64_t kDefaultMaximumArtifactBytes = UINT64_C(64) * 1024U * 1024U; - static constexpr uint64_t kDefaultMaximumLinearMemoryBytes = UINT64_C(256) * 1024U * 1024U; static constexpr uint64_t kDefaultMeteringPointsPerCall = UINT64_C(10000000); uint64_t maximum_artifact_bytes = kDefaultMaximumArtifactBytes; - uint64_t maximum_linear_memory_bytes = kDefaultMaximumLinearMemoryBytes; + uint64_t maximum_linear_memory_bytes = parser_module::ParserModuleWasmLimits::kDefaultMaximumLinearMemoryBytes; + uint64_t maximum_table_elements = parser_module::ParserModuleWasmLimits::kDefaultMaximumTableElements; uint64_t metering_points_per_call = kDefaultMeteringPointsPerCall; }; +struct WasmParserModuleLoadOptions { + WasmParserModuleLimits limits; + /// Optional application-owned aggregate session budget. + std::shared_ptr budget; + /// Every rejection emits exactly one error diagnostic when a sink is set. + DiagnosticSink sink; + std::string diagnostic_source = "WasmParserModule"; +}; + class WasmParserModule { public: WasmParserModule() = default; - /// Read, validate, and compile one wasm parser module. Rejection emits - /// exactly one error diagnostic when a sink is supplied. - [[nodiscard]] static Expected load( - std::string_view path, DiagnosticSink sink = {}, std::string diagnostic_source = "WasmParserModule"); - - /// Load with explicit artifact, linear-memory, and instruction budgets. - [[nodiscard]] static Expected load( - std::string_view path, const WasmParserModuleLimits& limits, DiagnosticSink sink = {}, - std::string diagnostic_source = "WasmParserModule"); - - /// Load using an application-owned aggregate session budget. - [[nodiscard]] static Expected load( - std::string_view path, std::shared_ptr budget, DiagnosticSink sink = {}, - std::string diagnostic_source = "WasmParserModule"); - - /// Load with both per-artifact limits and aggregate session budgets. + /// Read, audit, admit, and compile one wasm parser module. [[nodiscard]] static Expected load( - std::string_view path, const WasmParserModuleLimits& limits, - std::shared_ptr budget, DiagnosticSink sink = {}, - std::string diagnostic_source = "WasmParserModule"); + std::string_view path, const WasmParserModuleLoadOptions& options = {}); [[nodiscard]] bool valid() const noexcept { return state_ != nullptr; @@ -92,7 +94,7 @@ class WasmParserModule { [[nodiscard]] std::string_view manifestJson() const noexcept; [[nodiscard]] uint64_t artifactSize() const noexcept; [[nodiscard]] uint64_t declaredLinearMemoryMaximum() const noexcept; - [[nodiscard]] ParserModuleStrikeState strikeState(uint32_t claim_index) const; + [[nodiscard]] uint64_t declaredTableElements() const noexcept; private: explicit WasmParserModule(std::shared_ptr state); diff --git a/pj_plugins/include/pj_plugins/host/wasm_parser_module_runtime.hpp b/pj_plugins/include/pj_plugins/host/wasm_parser_module_runtime.hpp index cf3b04aa..d6690d95 100644 --- a/pj_plugins/include/pj_plugins/host/wasm_parser_module_runtime.hpp +++ b/pj_plugins/include/pj_plugins/host/wasm_parser_module_runtime.hpp @@ -9,6 +9,12 @@ * The wrapper is move-only and not concurrently callable. A host may migrate * it between threads when calls do not overlap. Every host access to guest * memory re-acquires the current base and size after the preceding guest call. + * + * Like NativeParserModuleInstance, the wrapper only classifies faults: traps, + * metering exhaustion, malformed descriptors, and bad splices are returned as + * contract violations, module-reported failures as data errors. Recording + * strikes, quarantining a claim, and replaying create/bind are host policy + * driven through ParserModuleStrikeTracker, exactly as for native modules. */ #include @@ -40,7 +46,7 @@ struct WasmParserModuleCreateError { class WasmParserModuleInstance { public: - WasmParserModuleInstance() = default; + WasmParserModuleInstance(); ~WasmParserModuleInstance(); WasmParserModuleInstance(WasmParserModuleInstance&& other) noexcept; @@ -50,28 +56,26 @@ class WasmParserModuleInstance { WasmParserModuleInstance& operator=(const WasmParserModuleInstance&) = delete; /// Instantiate the shared compiled module in a new store, run `_initialize` - /// exactly once, then create the manifest claim at `claim_index`. + /// exactly once, then create the manifest claim at `claim_index`. A trap on + /// that path is returned with `fault == kContractViolation`; a session-budget + /// rejection with `outcome == kAdmissionDecline`. [[nodiscard]] static Expected create( const WasmParserModule& module, uint32_t claim_index); [[nodiscard]] Expected bind(const parser_module::BindingInfoV1& info); - /// Parse one message. Contract violations accrue per module claim. The - /// third violation destroys and recreates the instance through the accepted - /// create/bind inputs; a second quarantine disables the claim for the - /// session and invalidates this wrapper. + /// Parse one message and consume the returned descriptor transactionally. [[nodiscard]] Expected parse(const parser_module::ParseInputV1& input); [[nodiscard]] bool valid() const noexcept; [[nodiscard]] uint32_t claimIndex() const noexcept; - [[nodiscard]] ParserModuleStrikeState strikeState() const; + /// The most recent contract-violation text, including guest `free` faults + /// raised while cleaning up after another failure. [[nodiscard]] std::string_view lifecycleDiagnostic() const noexcept; private: explicit WasmParserModuleInstance(std::unique_ptr state); - [[nodiscard]] Expected recreateBoundInstance(); - std::unique_ptr state_; }; diff --git a/pj_plugins/src/detail/native_parser_module_state.hpp b/pj_plugins/src/detail/native_parser_module_state.hpp index 1c920dfe..bb0c3d9d 100644 --- a/pj_plugins/src/detail/native_parser_module_state.hpp +++ b/pj_plugins/src/detail/native_parser_module_state.hpp @@ -2,9 +2,9 @@ // Copyright 2026 Davide Faconti // SPDX-License-Identifier: Apache-2.0 +#include #include #include -#include #include "detail/native_parser_module_loader.hpp" #include "pj_base/parser_module_abi.h" @@ -18,10 +18,9 @@ struct NativeParserModuleState { NativeModuleHandle handle = nullptr; std::string path; std::string manifest_json; - std::string module_id; - std::vector claim_ids; + /// Null when the host loaded without a session budget (0.22 behavior). std::shared_ptr session_budget; - bool module_budget_reserved = false; + uint64_t module_reservation = 0; PJ_module_abi_fn_t abi = nullptr; PJ_module_create_fn_t create = nullptr; diff --git a/pj_plugins/src/detail/wasm_parser_module_state.hpp b/pj_plugins/src/detail/wasm_parser_module_state.hpp index 414fdf6a..56d26e85 100644 --- a/pj_plugins/src/detail/wasm_parser_module_state.hpp +++ b/pj_plugins/src/detail/wasm_parser_module_state.hpp @@ -7,9 +7,7 @@ #include #include #include -#include -#include "pj_plugins/host/parser_module_runtime.hpp" #include "pj_plugins/host/parser_module_session_budget.hpp" namespace PJ::detail { @@ -19,16 +17,16 @@ struct WasmParserModuleState { std::string path; std::string manifest_json; - std::string module_id; - std::vector claim_ids; + uint64_t claim_count = 0; uint64_t artifact_size = 0; uint64_t declared_linear_memory_maximum = 0; + uint64_t declared_table_elements = 0; uint64_t metering_points_per_call = 0; wasm_engine_t* engine = nullptr; wasm_module_t* module = nullptr; + /// Null when the host loaded without a session budget. std::shared_ptr session_budget; - std::shared_ptr strike_tracker; - bool module_budget_reserved = false; + uint64_t module_reservation = 0; }; } // namespace PJ::detail diff --git a/pj_plugins/src/native_parser_module.cpp b/pj_plugins/src/native_parser_module.cpp index 78f01624..73b339f6 100644 --- a/pj_plugins/src/native_parser_module.cpp +++ b/pj_plugins/src/native_parser_module.cpp @@ -62,8 +62,8 @@ Expected resolve( namespace detail { NativeParserModuleState::~NativeParserModuleState() { - if (module_budget_reserved && session_budget != nullptr) { - (void)session_budget->releaseModule(module_id); + if (session_budget != nullptr) { + session_budget->releaseModule(module_reservation); } } @@ -74,15 +74,12 @@ NativeParserModule::NativeParserModule(std::shared_ptr NativeParserModule::load( std::string_view path, DiagnosticSink sink, std::string diagnostic_source) { - return load(path, defaultParserModuleSessionBudget(), std::move(sink), std::move(diagnostic_source)); + return load(path, nullptr, std::move(sink), std::move(diagnostic_source)); } Expected NativeParserModule::load( std::string_view path, std::shared_ptr budget, DiagnosticSink sink, std::string diagnostic_source) { - if (budget == nullptr) { - return rejectLoad(path, sink, diagnostic_source, "native parser-module session budget is null"); - } detail::LibraryPathIdentity recorded_path; auto handle_result = detail::openNativeParserModule(path, &recorded_path); if (!handle_result) { @@ -133,28 +130,28 @@ Expected NativeParserModule::load( const auto* manifest = reinterpret_cast(static_cast(manifest_addr)); state->manifest_json.assign(manifest, static_cast(manifest_len)); - auto decoded_manifest = decodeParserModuleManifest(state->manifest_json, ParserClaimProvenance::kFolderDrop); - if (!decoded_manifest) { - return rejectLoad( - path, sink, diagnostic_source, "invalid native parser-module manifest: " + decoded_manifest.error()); - } - std::error_code file_error; - const uintmax_t artifact_size = std::filesystem::file_size(std::filesystem::path(path), file_error); - if (file_error || artifact_size > std::numeric_limits::max()) { - return rejectLoad(path, sink, diagnostic_source, "native parser-module artifact file size is unreadable"); - } - state->module_id = decoded_manifest->id; - state->claim_ids.reserve(decoded_manifest->claims.size()); - for (const auto& claim : decoded_manifest->claims) { - state->claim_ids.push_back(claim.claim_id); - } - state->session_budget = budget; - auto admission = budget->admitModule( - decoded_manifest->id, static_cast(artifact_size), decoded_manifest->claims.size(), 0); - if (!admission.accepted()) { - return rejectLoad(path, sink, diagnostic_source, std::move(admission.diagnostic)); + if (budget != nullptr) { + // Budget accounting needs the claim count, so the manifest is decoded here + // as well as at catalog ingestion. Without a budget the 0.22 contract holds: + // the loader copies the bytes and leaves every manifest decision to the + // catalog. + auto decoded_manifest = decodeParserModuleManifest(state->manifest_json, ParserClaimProvenance::kFolderDrop); + if (!decoded_manifest) { + return rejectLoad( + path, sink, diagnostic_source, "invalid native parser-module manifest: " + decoded_manifest.error()); + } + std::error_code file_error; + const uintmax_t artifact_size = std::filesystem::file_size(std::filesystem::path(path), file_error); + if (file_error || artifact_size > std::numeric_limits::max()) { + return rejectLoad(path, sink, diagnostic_source, "native parser-module artifact file size is unreadable"); + } + auto admission = budget->admitModule(static_cast(artifact_size), decoded_manifest->claims.size(), 0); + if (!admission.accepted()) { + return rejectLoad(path, sink, diagnostic_source, std::move(admission.diagnostic)); + } + state->session_budget = std::move(budget); + state->module_reservation = admission.reservation; } - state->module_budget_reserved = true; return NativeParserModule(std::move(state)); } diff --git a/pj_plugins/src/parser_module_runtime.cpp b/pj_plugins/src/parser_module_runtime.cpp index 0a706929..d9afb435 100644 --- a/pj_plugins/src/parser_module_runtime.cpp +++ b/pj_plugins/src/parser_module_runtime.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -81,18 +82,23 @@ Expected NativeParserModuleInstance::create( if (!module.valid()) { return unexpected("cannot create an instance from an invalid native parser module"); } - auto admission = module.state_->session_budget->admitInstance(module.state_->module_id); - if (!admission.accepted()) { - return unexpected(std::move(admission.diagnostic)); + const auto& budget = module.state_->session_budget; + if (budget != nullptr) { + auto admission = budget->admitInstance(module.state_->module_reservation); + if (!admission.accepted()) { + return unexpected(std::move(admission.diagnostic)); + } } const uint64_t token = module.state_->create(claim_index); if (token == PJ_MODULE_CREATION_ERROR_TOKEN) { - (void)module.state_->session_budget->releaseInstance(module.state_->module_id); + if (budget != nullptr) { + budget->releaseInstance(module.state_->module_reservation); + } auto message = copyLastError(*module.state_, PJ_MODULE_CREATION_ERROR_TOKEN); return unexpected(message ? *message : message.error()); } NativeParserModuleInstance instance(module.state_, token, claim_index); - instance.instance_budget_reserved_ = true; + instance.instance_budget_reserved_ = budget != nullptr; return instance; } @@ -229,7 +235,7 @@ void NativeParserModuleInstance::reset() noexcept { module_->destroy(token_); } if (module_ != nullptr && instance_budget_reserved_) { - (void)module_->session_budget->releaseInstance(module_->module_id); + module_->session_budget->releaseInstance(module_->module_reservation); } token_ = PJ_MODULE_CREATION_ERROR_TOKEN; bound_ = false; @@ -239,10 +245,18 @@ void NativeParserModuleInstance::reset() noexcept { ParserModuleStrikeState ParserModuleStrikeTracker::recordFault( const ParserModuleClaimKey& key, ParserModuleFaultKind fault) { + const std::scoped_lock lock(mutex_); auto [it, inserted] = states_.try_emplace(key); (void)inserted; auto& state = it->second; - if (fault != ParserModuleFaultKind::kContractViolation || state.health != ParserModuleClaimHealth::kActive) { + if (fault != ParserModuleFaultKind::kContractViolation || state.health == ParserModuleClaimHealth::kDisabled) { + return state; + } + if (state.health == ParserModuleClaimHealth::kQuarantined) { + // The replay itself faulted: that is the repeat the policy disables on. + state.strikes = 0; + ++state.quarantine_count; + state.health = ParserModuleClaimHealth::kDisabled; return state; } @@ -260,6 +274,7 @@ ParserModuleStrikeState ParserModuleStrikeTracker::recordFault( } bool ParserModuleStrikeTracker::markRecreated(const ParserModuleClaimKey& key) { + const std::scoped_lock lock(mutex_); auto it = states_.find(key); if (it == states_.end() || it->second.health != ParserModuleClaimHealth::kQuarantined) { return false; @@ -269,6 +284,7 @@ bool ParserModuleStrikeTracker::markRecreated(const ParserModuleClaimKey& key) { } ParserModuleStrikeState ParserModuleStrikeTracker::state(const ParserModuleClaimKey& key) const { + const std::scoped_lock lock(mutex_); const auto it = states_.find(key); return it == states_.end() ? ParserModuleStrikeState{} : it->second; } diff --git a/pj_plugins/src/parser_module_session_budget.cpp b/pj_plugins/src/parser_module_session_budget.cpp index e4aa82a1..99309aba 100644 --- a/pj_plugins/src/parser_module_session_budget.cpp +++ b/pj_plugins/src/parser_module_session_budget.cpp @@ -4,33 +4,21 @@ #include "pj_plugins/host/parser_module_session_budget.hpp" #include -#include +#include #include -#include +#include namespace PJ { namespace { -ParserModuleAdmissionDecision accept() { - return ParserModuleAdmissionDecision{ - .outcome = ParserModuleAdmissionOutcome::kAccept, - .exhausted_budget = ParserModuleBudgetKind::kNone, - .diagnostic = {}, - }; -} - -ParserModuleAdmissionDecision decline(ParserModuleBudgetKind kind, std::string reason) { +ParserModuleAdmissionDecision declineBudget(ParserModuleBudgetKind kind, std::string_view name) { return ParserModuleAdmissionDecision{ - .outcome = ParserModuleAdmissionOutcome::kDecline, + .reservation = 0, .exhausted_budget = kind, - .diagnostic = "parser-module admission DECLINE: " + std::move(reason), + .diagnostic = "parser-module admission DECLINE: " + std::string(name) + " budget exhausted", }; } -ParserModuleAdmissionDecision declineBudget(ParserModuleBudgetKind kind, std::string_view name) { - return decline(kind, std::string(name) + " budget exhausted"); -} - bool exceedsAggregate(uint64_t current, uint64_t additional, uint64_t maximum) { return current > maximum || additional > maximum - current; } @@ -41,10 +29,8 @@ ParserModuleSessionBudgetTracker::ParserModuleSessionBudgetTracker(ParserModuleS : limits_(limits) {} ParserModuleAdmissionDecision ParserModuleSessionBudgetTracker::admitModule( - std::string module_id, uint64_t artifact_bytes, uint64_t claim_count, uint64_t declared_linear_memory_maximum) { - if (modules_.find(module_id) != modules_.end()) { - return decline(ParserModuleBudgetKind::kNone, "module is already admitted"); - } + uint64_t artifact_bytes, uint64_t claim_count, uint64_t declared_linear_memory_maximum) { + const std::scoped_lock lock(mutex_); if (usage_.modules >= limits_.maximum_modules) { return declineBudget(ParserModuleBudgetKind::kModuleCount, "module_count"); } @@ -55,22 +41,28 @@ ParserModuleAdmissionDecision ParserModuleSessionBudgetTracker::admitModule( return declineBudget(ParserModuleBudgetKind::kTotalClaims, "total_claims"); } + const uint64_t reservation = next_reservation_++; modules_.emplace( - std::move(module_id), ModuleReservation{ - .artifact_bytes = artifact_bytes, - .claim_count = claim_count, - .declared_linear_memory_maximum = declared_linear_memory_maximum, - .active_instances = 0, - }); + reservation, ModuleReservation{ + .claim_count = claim_count, + .declared_linear_memory_maximum = declared_linear_memory_maximum, + .active_instances = 0, + }); ++usage_.modules; usage_.claims += claim_count; - return accept(); + return ParserModuleAdmissionDecision{ + .reservation = reservation, .exhausted_budget = ParserModuleBudgetKind::kNone, .diagnostic = {}}; } -ParserModuleAdmissionDecision ParserModuleSessionBudgetTracker::admitInstance(std::string_view module_id) { - auto module = modules_.find(module_id); +ParserModuleAdmissionDecision ParserModuleSessionBudgetTracker::admitInstance(uint64_t module_reservation) { + const std::scoped_lock lock(mutex_); + auto module = modules_.find(module_reservation); if (module == modules_.end()) { - return decline(ParserModuleBudgetKind::kNone, "module is not admitted"); + return ParserModuleAdmissionDecision{ + .reservation = 0, + .exhausted_budget = ParserModuleBudgetKind::kNone, + .diagnostic = "parser-module admission DECLINE: module is not admitted", + }; } if (usage_.active_instances >= limits_.maximum_active_instances) { return declineBudget(ParserModuleBudgetKind::kActiveInstances, "active_instances"); @@ -84,42 +76,44 @@ ParserModuleAdmissionDecision ParserModuleSessionBudgetTracker::admitInstance(st ++module->second.active_instances; ++usage_.active_instances; usage_.declared_linear_memory_bytes += module->second.declared_linear_memory_maximum; - return accept(); + return ParserModuleAdmissionDecision{ + .reservation = module_reservation, .exhausted_budget = ParserModuleBudgetKind::kNone, .diagnostic = {}}; } -bool ParserModuleSessionBudgetTracker::releaseInstance(std::string_view module_id) { - auto module = modules_.find(module_id); +void ParserModuleSessionBudgetTracker::releaseInstance(uint64_t module_reservation) { + const std::scoped_lock lock(mutex_); + auto module = modules_.find(module_reservation); if (module == modules_.end() || module->second.active_instances == 0) { - return false; + return; } --module->second.active_instances; --usage_.active_instances; usage_.declared_linear_memory_bytes -= module->second.declared_linear_memory_maximum; - return true; } -bool ParserModuleSessionBudgetTracker::releaseModule(std::string_view module_id) { - auto module = modules_.find(module_id); - if (module == modules_.end() || module->second.active_instances != 0) { - return false; +void ParserModuleSessionBudgetTracker::releaseModule(uint64_t module_reservation) { + const std::scoped_lock lock(mutex_); + auto module = modules_.find(module_reservation); + if (module == modules_.end()) { + return; } + // Live instances of a dropped module still hold their store; their memory + // and instance counts are given back when each of them is released. + usage_.active_instances -= module->second.active_instances; + usage_.declared_linear_memory_bytes -= + module->second.active_instances * module->second.declared_linear_memory_maximum; --usage_.modules; usage_.claims -= module->second.claim_count; modules_.erase(module); - return true; } const ParserModuleSessionBudgetLimits& ParserModuleSessionBudgetTracker::limits() const noexcept { return limits_; } -ParserModuleSessionBudgetUsage ParserModuleSessionBudgetTracker::usage() const noexcept { +ParserModuleSessionBudgetUsage ParserModuleSessionBudgetTracker::usage() const { + const std::scoped_lock lock(mutex_); return usage_; } -std::shared_ptr defaultParserModuleSessionBudget() { - static auto tracker = std::make_shared(); - return tracker; -} - } // namespace PJ diff --git a/pj_plugins/src/wasm_parser_module.cpp b/pj_plugins/src/wasm_parser_module.cpp index 53f22b3b..856add9f 100644 --- a/pj_plugins/src/wasm_parser_module.cpp +++ b/pj_plugins/src/wasm_parser_module.cpp @@ -77,6 +77,9 @@ Expected createMeteredEngine(uint64_t points_per_call) { if (config == nullptr) { return unexpected(wasmerFailure("failed to create Wasmer configuration")); } + if (wasmer_is_backend_available(SINGLEPASS)) { + wasm_config_set_backend(config, SINGLEPASS); + } wasmer_metering_t* metering = wasmer_metering_new(points_per_call, &unitMeteringCost); if (metering == nullptr) { wasm_config_delete(config); @@ -108,8 +111,8 @@ WasmParserModuleState::~WasmParserModuleState() { if (engine != nullptr) { wasm_engine_delete(engine); } - if (module_budget_reserved && session_budget != nullptr) { - (void)session_budget->releaseModule(module_id); + if (session_budget != nullptr) { + session_budget->releaseModule(module_reservation); } } @@ -118,35 +121,15 @@ WasmParserModuleState::~WasmParserModuleState() { WasmParserModule::WasmParserModule(std::shared_ptr state) : state_(std::move(state)) {} -Expected WasmParserModule::load( - std::string_view path, DiagnosticSink sink, std::string diagnostic_source) { - return load( - path, WasmParserModuleLimits{}, defaultParserModuleSessionBudget(), std::move(sink), - std::move(diagnostic_source)); -} - -Expected WasmParserModule::load( - std::string_view path, const WasmParserModuleLimits& limits, DiagnosticSink sink, std::string diagnostic_source) { - return load(path, limits, defaultParserModuleSessionBudget(), std::move(sink), std::move(diagnostic_source)); -} - -Expected WasmParserModule::load( - std::string_view path, std::shared_ptr budget, DiagnosticSink sink, - std::string diagnostic_source) { - return load(path, WasmParserModuleLimits{}, std::move(budget), std::move(sink), std::move(diagnostic_source)); -} - -Expected WasmParserModule::load( - std::string_view path, const WasmParserModuleLimits& limits, - std::shared_ptr budget, DiagnosticSink sink, std::string diagnostic_source) { +Expected WasmParserModule::load(std::string_view path, const WasmParserModuleLoadOptions& options) { // Every rejection emits exactly one error diagnostic and returns the same // text to the caller. const auto reject = [&](std::string message) -> Expected { - if (sink) { - sink( + if (options.sink) { + options.sink( Diagnostic{ .level = DiagnosticLevel::kError, - .source = diagnostic_source, + .source = options.diagnostic_source, .id = std::string(path), .message = message, }); @@ -154,66 +137,49 @@ Expected WasmParserModule::load( return unexpected(std::move(message)); }; + const WasmParserModuleLimits& limits = options.limits; if (limits.maximum_artifact_bytes == 0 || limits.maximum_linear_memory_bytes == 0 || limits.metering_points_per_call == 0) { return reject("wasm parser-module limits must all be nonzero"); } - if (budget == nullptr) { - return reject("wasm parser-module session budget is null"); - } - const uint64_t maximum_artifact_bytes = - std::min(limits.maximum_artifact_bytes, budget->limits().maximum_artifact_bytes); - auto bytes = readFile(path, maximum_artifact_bytes); + auto bytes = readFile(path, limits.maximum_artifact_bytes); if (!bytes) { return reject(bytes.error()); } - auto manifest = parser_module::readManifestSection(*bytes); - if (!manifest) { - return reject("invalid wasm parser-module manifest: " + manifest.error()); + auto audited = parser_module::validateParserModuleWasmArtifact( + *bytes, parser_module::ParserModuleWasmLimits{ + .maximum_linear_memory_bytes = limits.maximum_linear_memory_bytes, + .maximum_table_elements = limits.maximum_table_elements, + }); + if (!audited) { + return reject(audited.error()); } - const char* manifest_data = manifest->empty() ? "" : reinterpret_cast(manifest->data()); - const std::string_view manifest_json(manifest_data, manifest->size()); + const char* manifest_data = + audited->manifest_json.empty() ? "" : reinterpret_cast(audited->manifest_json.data()); + const std::string_view manifest_json(manifest_data, audited->manifest_json.size()); auto decoded_manifest = decodeParserModuleManifest(manifest_json, ParserClaimProvenance::kFolderDrop); if (!decoded_manifest) { return reject("invalid wasm parser-module manifest: " + decoded_manifest.error()); } - auto inspected = parser_module::inspectWasmModule(*bytes); - if (!inspected) { - return reject("invalid wasm parser module: " + inspected.error()); - } - if (!inspected->imports.empty()) { - const auto& imported = inspected->imports.front(); - return reject("wasm parser module uses disallowed import '" + imported.module + "." + imported.name + "'"); - } - auto abi = parser_module::validateParserModuleWasmAbi(*inspected); - if (!abi) { - return reject(abi.error()); - } - auto memory_maximum = parser_module::validateParserModuleWasmMemory(*inspected, limits.maximum_linear_memory_bytes); - if (!memory_maximum) { - return reject(memory_maximum.error()); - } auto state = std::make_shared(); state->path = path; + state->manifest_json.assign(manifest_json); + state->claim_count = decoded_manifest->claims.size(); state->artifact_size = bytes->size(); - state->declared_linear_memory_maximum = *memory_maximum; + state->declared_linear_memory_maximum = audited->declared_linear_memory_maximum; + state->declared_table_elements = audited->declared_table_elements; state->metering_points_per_call = limits.metering_points_per_call; - state->module_id = decoded_manifest->id; - state->claim_ids.reserve(decoded_manifest->claims.size()); - for (const auto& claim : decoded_manifest->claims) { - state->claim_ids.push_back(claim.claim_id); - } - state->manifest_json.assign(manifest_json); - state->session_budget = budget; - state->strike_tracker = std::make_shared(); - auto admission = - budget->admitModule(decoded_manifest->id, bytes->size(), decoded_manifest->claims.size(), *memory_maximum); - if (!admission.accepted()) { - return reject(std::move(admission.diagnostic)); + if (options.budget != nullptr) { + auto admission = options.budget->admitModule( + bytes->size(), decoded_manifest->claims.size(), audited->declared_linear_memory_maximum); + if (!admission.accepted()) { + return reject(std::move(admission.diagnostic)); + } + state->session_budget = options.budget; + state->module_reservation = admission.reservation; } - state->module_budget_reserved = true; auto engine = createMeteredEngine(limits.metering_points_per_call); if (!engine) { return reject(engine.error()); @@ -246,11 +212,8 @@ uint64_t WasmParserModule::declaredLinearMemoryMaximum() const noexcept { return state_ == nullptr ? 0 : state_->declared_linear_memory_maximum; } -ParserModuleStrikeState WasmParserModule::strikeState(uint32_t claim_index) const { - if (state_ == nullptr || claim_index >= state_->claim_ids.size()) { - return {}; - } - return state_->strike_tracker->state(ParserModuleClaimKey{state_->module_id, state_->claim_ids[claim_index]}); +uint64_t WasmParserModule::declaredTableElements() const noexcept { + return state_ == nullptr ? 0 : state_->declared_table_elements; } } // namespace PJ diff --git a/pj_plugins/src/wasm_parser_module_runtime.cpp b/pj_plugins/src/wasm_parser_module_runtime.cpp index 9e22dedd..94c1a05e 100644 --- a/pj_plugins/src/wasm_parser_module_runtime.cpp +++ b/pj_plugins/src/wasm_parser_module_runtime.cpp @@ -109,11 +109,10 @@ namespace detail { struct WasmParserModuleInstanceState { ~WasmParserModuleInstanceState() { if (token != PJ_MODULE_CREATION_ERROR_TOKEN && destroy != nullptr) { + // Best-effort teardown: the store is deleted right after, so a destroy + // trap has nothing left to corrupt and nobody left to report to. wasm_val_t arguments[1] = {wasmI64(token)}; - auto destroyed = callVoid(PJ_MODULE_DESTROY_EXPORT_NAME, destroy, arguments); - if (!destroyed) { - (void)recordContractViolation("pj_module_destroy failed: " + destroyed.error()); - } + (void)callVoid(PJ_MODULE_DESTROY_EXPORT_NAME, destroy, arguments); } if (exports_initialized) { wasm_extern_vec_delete(&exports); @@ -125,17 +124,12 @@ struct WasmParserModuleInstanceState { wasm_store_delete(store); } if (instance_budget_reserved && module->session_budget != nullptr) { - (void)module->session_budget->releaseInstance(module->module_id); + module->session_budget->releaseInstance(module->module_reservation); } } - [[nodiscard]] ParserModuleClaimKey claimKey() const { - return ParserModuleClaimKey{module->module_id, module->claim_ids[claim_index]}; - } - - [[nodiscard]] ParserModuleStrikeState recordContractViolation(std::string message) { + void noteContractViolation(std::string message) { lifecycle_diagnostic = std::move(message); - return module->strike_tracker->recordFault(claimKey(), ParserModuleFaultKind::kContractViolation); } [[nodiscard]] Expected callVoid( @@ -269,8 +263,6 @@ struct WasmParserModuleInstanceState { uint16_t expected_object_type = 0; bool bound = false; bool instance_budget_reserved = false; - bool recreation_pending = false; - std::vector binding_bytes; std::string lifecycle_diagnostic; }; @@ -279,8 +271,8 @@ struct WasmParserModuleInstanceState { namespace { /// Owns one `pj_module_alloc` region for the duration of a host operation. -/// The destructor releases it and records a contract fault if guest free -/// traps; `release()` frees it early and returns that fault directly. +/// The destructor releases it and notes a contract fault if guest free traps; +/// `release()` frees it early and returns that fault directly. class GuestAllocation { public: GuestAllocation(detail::WasmParserModuleInstanceState& state, uint64_t address, uint64_t size) @@ -292,7 +284,7 @@ class GuestAllocation { ~GuestAllocation() { auto released = release(); if (!released) { - (void)state_->recordContractViolation("pj_module_free failed during cleanup: " + released.error()); + state_->noteContractViolation("pj_module_free failed during cleanup: " + released.error()); } } @@ -378,6 +370,7 @@ Expected bindRuntimeExports(detail::WasmParserModuleInstanceState* state) WasmParserModuleInstance::WasmParserModuleInstance(std::unique_ptr state) : state_(std::move(state)) {} +WasmParserModuleInstance::WasmParserModuleInstance() = default; WasmParserModuleInstance::~WasmParserModuleInstance() = default; WasmParserModuleInstance::WasmParserModuleInstance(WasmParserModuleInstance&& other) noexcept = default; WasmParserModuleInstance& WasmParserModuleInstance::operator=(WasmParserModuleInstance&& other) noexcept = default; @@ -398,27 +391,21 @@ Expected WasmParserModule if (!module.valid()) { return reject("cannot create an instance from an invalid wasm parser module"); } - if (claim_index >= module.state_->claim_ids.size()) { + if (claim_index >= module.state_->claim_count) { return reject("claim index is outside the wasm parser-module manifest"); } - const ParserModuleClaimKey key{module.state_->module_id, module.state_->claim_ids[claim_index]}; - const ParserModuleStrikeState initial_health = module.state_->strike_tracker->state(key); - if (initial_health.health == ParserModuleClaimHealth::kDisabled) { - return reject( - "parser-module claim is disabled for the session", ParserModuleFaultKind::kNone, - WasmParserModuleCreateOutcome::kAdmissionDecline); - } - auto admission = module.state_->session_budget->admitInstance(module.state_->module_id); - if (!admission.accepted()) { - return reject( - std::move(admission.diagnostic), ParserModuleFaultKind::kNone, - WasmParserModuleCreateOutcome::kAdmissionDecline); - } auto state = std::make_unique(); state->module = module.state_; state->claim_index = claim_index; - state->instance_budget_reserved = true; - state->recreation_pending = initial_health.health == ParserModuleClaimHealth::kQuarantined; + if (module.state_->session_budget != nullptr) { + auto admission = module.state_->session_budget->admitInstance(module.state_->module_reservation); + if (!admission.accepted()) { + return reject( + std::move(admission.diagnostic), ParserModuleFaultKind::kNone, + WasmParserModuleCreateOutcome::kAdmissionDecline); + } + state->instance_budget_reserved = true; + } state->store = wasm_store_new(state->module->engine); if (state->store == nullptr) { return reject("failed to create a Wasmer store"); @@ -429,84 +416,49 @@ Expected WasmParserModule state->instance = wasm_instance_new(state->store, state->module->module, &imports, &instantiation_trap); if (state->instance == nullptr) { if (instantiation_trap != nullptr) { - const std::string message = "wasm instantiation failed: " + trapMessage(instantiation_trap); - (void)state->recordContractViolation(message); - return reject(message, ParserModuleFaultKind::kContractViolation); + return reject( + "wasm instantiation failed: " + trapMessage(instantiation_trap), ParserModuleFaultKind::kContractViolation); } return reject("Wasmer failed to instantiate the parser module"); } auto exports = bindRuntimeExports(state.get()); if (!exports) { - (void)state->recordContractViolation(exports.error()); return reject(exports.error(), ParserModuleFaultKind::kContractViolation); } auto initialized = state->callVoid("_initialize", state->initialize, {}); if (!initialized) { - const std::string message = "parser-module _initialize failed: " + initialized.error(); - (void)state->recordContractViolation(message); - return reject(message, ParserModuleFaultKind::kContractViolation); + return reject( + "parser-module _initialize failed: " + initialized.error(), ParserModuleFaultKind::kContractViolation); } auto abi = state->callI32(PJ_MODULE_ABI_EXPORT_NAME, state->abi, {}); if (!abi) { - const std::string message = "pj_module_abi failed: " + abi.error(); - (void)state->recordContractViolation(message); - return reject(message, ParserModuleFaultKind::kContractViolation); + return reject("pj_module_abi failed: " + abi.error(), ParserModuleFaultKind::kContractViolation); } if (static_cast(*abi) != PJ_PARSER_MODULE_ABI_VERSION) { - const std::string message = "wasm parser module ABI mismatch (expected " + - std::to_string(PJ_PARSER_MODULE_ABI_VERSION) + ", got " + - std::to_string(static_cast(*abi)) + ")"; - (void)state->recordContractViolation(message); - return reject(message, ParserModuleFaultKind::kContractViolation); + return reject( + "wasm parser module ABI mismatch (expected " + std::to_string(PJ_PARSER_MODULE_ABI_VERSION) + ", got " + + std::to_string(static_cast(*abi)) + ")", + ParserModuleFaultKind::kContractViolation); } wasm_val_t arguments[1] = {WASM_I32_VAL(static_cast(claim_index))}; auto token = state->callI64(PJ_MODULE_CREATE_EXPORT_NAME, state->create, arguments); if (!token) { - const std::string message = "pj_module_create failed: " + token.error(); - (void)state->recordContractViolation(message); - return reject(message, ParserModuleFaultKind::kContractViolation); + return reject("pj_module_create failed: " + token.error(), ParserModuleFaultKind::kContractViolation); } state->token = static_cast(*token); if (state->token == PJ_MODULE_CREATION_ERROR_TOKEN) { auto message = state->copyLastError(PJ_MODULE_CREATION_ERROR_TOKEN); if (!message) { - (void)state->recordContractViolation(message.error()); return reject(message.error(), ParserModuleFaultKind::kContractViolation); } - return reject(*message); + return reject( + message->empty() ? std::string("pj_module_create returned the creation-error token without a diagnostic") + : std::move(*message)); } return WasmParserModuleInstance(std::move(state)); } -Expected WasmParserModuleInstance::recreateBoundInstance() { - if (state_ == nullptr || state_->binding_bytes.empty()) { - return unexpected(std::string("quarantined wasm parser-module instance has no accepted binding to replay")); - } - const uint32_t claim_index = state_->claim_index; - const std::vector binding_bytes = state_->binding_bytes; - auto binding = parser_module::readBindingInfoV1(binding_bytes); - if (!binding) { - return unexpected("cannot decode the quarantined binding for replay: " + binding.error()); - } - WasmParserModule module(state_->module); - state_.reset(); - - auto recreated = create(module, claim_index); - if (!recreated) { - return unexpected("quarantine recreation failed during create: " + recreated.error().message); - } - auto rebound = recreated->bind(*binding); - if (!rebound) { - return unexpected("quarantine recreation failed during bind: " + rebound.error()); - } - if (rebound->outcome != ParserModuleBindOutcome::kAccept) { - return unexpected("quarantine binding replay was not accepted: " + rebound->message); - } - state_ = std::move(recreated->state_); - return {}; -} - Expected WasmParserModuleInstance::bind(const parser_module::BindingInfoV1& info) { if (!valid()) { return unexpected(std::string("cannot bind an invalid wasm parser-module instance")); @@ -520,13 +472,13 @@ Expected WasmParserModuleInstance::bind(const parser_mod } auto address = state_->allocate(encoded->size()); if (!address) { - (void)state_->recordContractViolation(address.error()); + state_->noteContractViolation(address.error()); return bindContractViolation(PJ_MODULE_ERR_ALLOCATION_FAILURE, address.error()); } GuestAllocation input_buffer(*state_, *address, encoded->size()); auto guest_input = state_->memoryRange(input_buffer.address(), encoded->size()); if (!guest_input) { - (void)state_->recordContractViolation(guest_input.error()); + state_->noteContractViolation(guest_input.error()); return bindContractViolation(PJ_MODULE_ERR_GENERIC, guest_input.error()); } std::copy(encoded->begin(), encoded->end(), guest_input->begin()); @@ -540,13 +492,13 @@ Expected WasmParserModuleInstance::bind(const parser_mod auto released = input_buffer.release(); if (!code_result) { state_->bound = false; - (void)state_->recordContractViolation(code_result.error()); + state_->noteContractViolation(code_result.error()); return bindContractViolation(PJ_MODULE_ERR_GENERIC, code_result.error()); } if (!released) { state_->bound = false; const std::string message = "pj_module_free failed after bind: " + released.error(); - (void)state_->recordContractViolation(message); + state_->noteContractViolation(message); return bindContractViolation(PJ_MODULE_ERR_GENERIC, message); } @@ -562,11 +514,6 @@ Expected WasmParserModuleInstance::bind(const parser_mod state_->bound_route = info.route; state_->expected_object_type = info.expected_object_type; state_->bound = true; - state_->binding_bytes = *encoded; - if (state_->recreation_pending) { - (void)state_->module->strike_tracker->markRecreated(state_->claimKey()); - state_->recreation_pending = false; - } return result; } @@ -580,7 +527,7 @@ Expected WasmParserModuleInstance::bind(const parser_mod } } else { const std::string message = "pj_module_bind returned an out-of-contract positive result"; - (void)state_->recordContractViolation(message); + state_->noteContractViolation(message); return bindContractViolation(code, message); } @@ -592,7 +539,7 @@ Expected WasmParserModuleInstance::bind(const parser_mod result.message = std::move(*message); } if (result.fault == ParserModuleFaultKind::kContractViolation) { - (void)state_->recordContractViolation(result.message); + state_->noteContractViolation(result.message); } return result; } @@ -604,16 +551,6 @@ Expected WasmParserModuleInstance::parse(const parser_m if (!state_->bound) { return unexpected(std::string("cannot parse before an accepted wasm module bind")); } - const ParserModuleStrikeState health = state_->module->strike_tracker->state(state_->claimKey()); - if (health.health == ParserModuleClaimHealth::kDisabled) { - return contractViolation(PJ_MODULE_ERR_GENERIC, "parser-module claim is disabled for the session"); - } - if (health.health == ParserModuleClaimHealth::kQuarantined) { - auto recreated = recreateBoundInstance(); - if (!recreated) { - return contractViolation(PJ_MODULE_ERR_GENERIC, recreated.error()); - } - } const auto parse_once = [&]() -> Expected { auto encoded = parser_module::writeParseInputV1(input); @@ -729,21 +666,8 @@ Expected WasmParserModuleInstance::parse(const parser_m }; auto result = parse_once(); - if (!result || result->fault != ParserModuleFaultKind::kContractViolation) { - return result; - } - - const ParserModuleStrikeState strike = state_->recordContractViolation(result->message); - if (strike.health == ParserModuleClaimHealth::kQuarantined) { - auto recreated = recreateBoundInstance(); - if (!recreated) { - result->message += "; automatic quarantine recreation failed: " + recreated.error(); - } else { - result->message += "; claim quarantined and recreated through create/bind replay"; - } - } else if (strike.health == ParserModuleClaimHealth::kDisabled) { - result->message += "; claim disabled for the session after repeat quarantine"; - state_.reset(); + if (result && result->fault == ParserModuleFaultKind::kContractViolation) { + state_->noteContractViolation(result->message); } return result; } @@ -756,10 +680,6 @@ uint32_t WasmParserModuleInstance::claimIndex() const noexcept { return state_ == nullptr ? 0 : state_->claim_index; } -ParserModuleStrikeState WasmParserModuleInstance::strikeState() const { - return state_ == nullptr ? ParserModuleStrikeState{} : state_->module->strike_tracker->state(state_->claimKey()); -} - std::string_view WasmParserModuleInstance::lifecycleDiagnostic() const noexcept { return state_ == nullptr ? std::string_view{} : std::string_view(state_->lifecycle_diagnostic); } diff --git a/pj_plugins/tests/parser_module_runtime_test.cpp b/pj_plugins/tests/parser_module_runtime_test.cpp index 2ec65341..41614a0e 100644 --- a/pj_plugins/tests/parser_module_runtime_test.cpp +++ b/pj_plugins/tests/parser_module_runtime_test.cpp @@ -214,5 +214,25 @@ TEST(ParserModuleRuntime, StrikeTrackerQuarantinesReplaysAndThenDisables) { EXPECT_EQ(tracker.state(key).quarantine_count, 2U); } +TEST(ParserModuleRuntime, StrikeTrackerDisablesWhenTheReplayItselfFaults) { + const ParserModuleClaimKey key{"org.plotjuggler.test.native-module", "malformed"}; + ParserModuleStrikeTracker tracker; + for (int strike = 1; strike <= 3; ++strike) { + (void)tracker.recordFault(key, ParserModuleFaultKind::kContractViolation); + } + ASSERT_EQ(tracker.state(key).health, ParserModuleClaimHealth::kQuarantined); + + // A data error during replay is still not a strike. + EXPECT_EQ(tracker.recordFault(key, ParserModuleFaultKind::kDataError).health, ParserModuleClaimHealth::kQuarantined); + // A contract violation during replay (create/bind trapping again) is the + // repeat the policy disables on; the claim can never be stuck quarantined. + const auto disabled = tracker.recordFault(key, ParserModuleFaultKind::kContractViolation); + EXPECT_EQ(disabled.health, ParserModuleClaimHealth::kDisabled); + EXPECT_EQ(disabled.quarantine_count, 2U); + EXPECT_FALSE(tracker.markRecreated(key)); + EXPECT_EQ( + tracker.recordFault(key, ParserModuleFaultKind::kContractViolation).health, ParserModuleClaimHealth::kDisabled); +} + } // namespace } // namespace PJ diff --git a/pj_plugins/tests/parser_module_session_budget_test.cpp b/pj_plugins/tests/parser_module_session_budget_test.cpp index 67f9cdf8..8931530f 100644 --- a/pj_plugins/tests/parser_module_session_budget_test.cpp +++ b/pj_plugins/tests/parser_module_session_budget_test.cpp @@ -5,16 +5,18 @@ #include -#include #include #include +#include +#include namespace PJ { namespace { void expectDecline( const ParserModuleAdmissionDecision& decision, ParserModuleBudgetKind budget, std::string_view diagnostic_name) { - EXPECT_EQ(decision.outcome, ParserModuleAdmissionOutcome::kDecline); + EXPECT_FALSE(decision.accepted()); + EXPECT_EQ(decision.reservation, 0U); EXPECT_EQ(decision.exhausted_budget, budget); EXPECT_NE(decision.diagnostic.find(diagnostic_name), std::string::npos); } @@ -30,24 +32,38 @@ TEST(ParserModuleSessionBudget, RejectsEachModuleAdmissionBudgetWithoutMutation) { ParserModuleSessionBudgetTracker tracker(limits); - expectDecline( - tracker.admitModule("oversize", 101, 1, 100), ParserModuleBudgetKind::kArtifactFileSize, "artifact_file_size"); + expectDecline(tracker.admitModule(101, 1, 100), ParserModuleBudgetKind::kArtifactFileSize, "artifact_file_size"); EXPECT_EQ(tracker.usage().modules, 0U); } { ParserModuleSessionBudgetTracker tracker(limits); - expectDecline(tracker.admitModule("claims", 100, 3, 100), ParserModuleBudgetKind::kTotalClaims, "total_claims"); + expectDecline(tracker.admitModule(100, 3, 100), ParserModuleBudgetKind::kTotalClaims, "total_claims"); EXPECT_EQ(tracker.usage().claims, 0U); } { ParserModuleSessionBudgetTracker tracker(limits); - ASSERT_TRUE(tracker.admitModule("first", 100, 2, 100).accepted()); - expectDecline(tracker.admitModule("second", 1, 0, 1), ParserModuleBudgetKind::kModuleCount, "module_count"); + ASSERT_TRUE(tracker.admitModule(100, 2, 100).accepted()); + expectDecline(tracker.admitModule(1, 0, 1), ParserModuleBudgetKind::kModuleCount, "module_count"); EXPECT_EQ(tracker.usage().modules, 1U); EXPECT_EQ(tracker.usage().claims, 2U); } } +TEST(ParserModuleSessionBudget, CountsResourcesNotIdentities) { + // Two loads of one artifact (or native and wasm builds of one source) are + // two reservations; duplicate-provider policy belongs to the catalog. + ParserModuleSessionBudgetLimits limits; + limits.maximum_modules = 2; + ParserModuleSessionBudgetTracker tracker(limits); + const auto first = tracker.admitModule(10, 1, 0); + const auto second = tracker.admitModule(10, 1, 0); + ASSERT_TRUE(first.accepted()); + ASSERT_TRUE(second.accepted()); + EXPECT_NE(first.reservation, second.reservation); + EXPECT_EQ(tracker.usage().modules, 2U); + expectDecline(tracker.admitModule(10, 1, 0), ParserModuleBudgetKind::kModuleCount, "module_count"); +} + TEST(ParserModuleSessionBudget, AppliesInstanceAndDeclaredMemoryBudgetsIndependently) { const ParserModuleSessionBudgetLimits limits{ .maximum_modules = 2, @@ -57,19 +73,24 @@ TEST(ParserModuleSessionBudget, AppliesInstanceAndDeclaredMemoryBudgetsIndepende .maximum_linear_memory_bytes = 300, }; ParserModuleSessionBudgetTracker tracker(limits); - ASSERT_TRUE(tracker.admitModule("small", 100, 1, 100).accepted()); - ASSERT_TRUE(tracker.admitModule("large", 100, 1, 250).accepted()); - ASSERT_TRUE(tracker.admitInstance("large").accepted()); + const auto small = tracker.admitModule(100, 1, 100); + const auto large = tracker.admitModule(100, 1, 250); + ASSERT_TRUE(small.accepted()); + ASSERT_TRUE(large.accepted()); + ASSERT_TRUE(tracker.admitInstance(large.reservation).accepted()); - expectDecline(tracker.admitInstance("small"), ParserModuleBudgetKind::kTotalLinearMemory, "total_linear_memory"); + expectDecline( + tracker.admitInstance(small.reservation), ParserModuleBudgetKind::kTotalLinearMemory, "total_linear_memory"); EXPECT_EQ(tracker.usage().active_instances, 1U); - EXPECT_TRUE(tracker.releaseInstance("large")); - ASSERT_TRUE(tracker.admitInstance("small").accepted()); - ASSERT_TRUE(tracker.admitInstance("small").accepted()); - expectDecline(tracker.admitInstance("small"), ParserModuleBudgetKind::kActiveInstances, "active_instances"); + tracker.releaseInstance(large.reservation); + ASSERT_TRUE(tracker.admitInstance(small.reservation).accepted()); + ASSERT_TRUE(tracker.admitInstance(small.reservation).accepted()); + expectDecline(tracker.admitInstance(small.reservation), ParserModuleBudgetKind::kActiveInstances, "active_instances"); + + expectDecline(tracker.admitInstance(0), ParserModuleBudgetKind::kNone, "not admitted"); } -TEST(ParserModuleSessionBudget, ReleaseRequiresNoLiveInstancesAndRestoresCapacity) { +TEST(ParserModuleSessionBudget, ReleasesAreIdempotentAndRestoreCapacity) { ParserModuleSessionBudgetTracker tracker( ParserModuleSessionBudgetLimits{ .maximum_modules = 1, @@ -78,12 +99,48 @@ TEST(ParserModuleSessionBudget, ReleaseRequiresNoLiveInstancesAndRestoresCapacit .maximum_active_instances = 1, .maximum_linear_memory_bytes = 20, }); - ASSERT_TRUE(tracker.admitModule("module", 10, 1, 20).accepted()); - EXPECT_FALSE(tracker.admitModule("module", 10, 1, 20).accepted()); - ASSERT_TRUE(tracker.admitInstance("module").accepted()); - EXPECT_FALSE(tracker.releaseModule("module")); - EXPECT_TRUE(tracker.releaseInstance("module")); - EXPECT_TRUE(tracker.releaseModule("module")); + const auto module = tracker.admitModule(10, 1, 20); + ASSERT_TRUE(module.accepted()); + ASSERT_TRUE(tracker.admitInstance(module.reservation).accepted()); + tracker.releaseInstance(module.reservation); + tracker.releaseInstance(module.reservation); // ignored + EXPECT_EQ(tracker.usage().active_instances, 0U); + EXPECT_EQ(tracker.usage().declared_linear_memory_bytes, 0U); + + ASSERT_TRUE(tracker.admitInstance(module.reservation).accepted()); + tracker.releaseModule(module.reservation); // gives back the live instance too + tracker.releaseModule(module.reservation); // ignored + tracker.releaseInstance(module.reservation); // ignored: module is gone + EXPECT_EQ(tracker.usage().modules, 0U); + EXPECT_EQ(tracker.usage().claims, 0U); + EXPECT_EQ(tracker.usage().active_instances, 0U); + EXPECT_EQ(tracker.usage().declared_linear_memory_bytes, 0U); + ASSERT_TRUE(tracker.admitModule(10, 1, 20).accepted()); +} + +TEST(ParserModuleSessionBudget, TracksConcurrentAdmissionAndReleaseFromManyThreads) { + // Wrapper destructors release reservations on whatever thread drops them, + // so the tracker must synchronize itself. + ParserModuleSessionBudgetLimits limits; + limits.maximum_modules = 1000; + limits.maximum_claims = 100000; + ParserModuleSessionBudgetTracker tracker(limits); + std::vector threads; + for (int worker = 0; worker < 8; ++worker) { + threads.emplace_back([&tracker] { + for (int round = 0; round < 200; ++round) { + const auto module = tracker.admitModule(1, 1, 1); + ASSERT_TRUE(module.accepted()); + const auto instance = tracker.admitInstance(module.reservation); + ASSERT_TRUE(instance.accepted()); + tracker.releaseInstance(module.reservation); + tracker.releaseModule(module.reservation); + } + }); + } + for (auto& thread : threads) { + thread.join(); + } EXPECT_EQ(tracker.usage().modules, 0U); EXPECT_EQ(tracker.usage().claims, 0U); EXPECT_EQ(tracker.usage().active_instances, 0U); diff --git a/pj_plugins/tests/wasm_parser_module_hardening_test.cpp b/pj_plugins/tests/wasm_parser_module_hardening_test.cpp index ea8a7571..ed10836d 100644 --- a/pj_plugins/tests/wasm_parser_module_hardening_test.cpp +++ b/pj_plugins/tests/wasm_parser_module_hardening_test.cpp @@ -8,8 +8,11 @@ #include #include #include +#include #include #include +#include +#include #include #include "pj_base/builtin/point_cloud.hpp" @@ -22,6 +25,8 @@ namespace PJ { namespace { +const ParserModuleClaimKey kClaim{"org.plotjuggler.test.adversarial-wasm", "adversarial"}; + Span bytes(std::string_view text) { return {reinterpret_cast(text.data()), text.size()}; } @@ -55,6 +60,12 @@ Expected createBound(const WasmParserModule& module) { return std::move(*instance); } +WasmParserModuleLoadOptions budgeted(std::shared_ptr budget) { + WasmParserModuleLoadOptions options; + options.budget = std::move(budget); + return options; +} + ParserModuleParseResult parseBehavior(WasmParserModuleInstance& instance, uint8_t behavior) { const std::array payload{behavior}; auto result = instance.parse(parser_module::ParseInputV1{.payload = payload}); @@ -62,32 +73,50 @@ ParserModuleParseResult parseBehavior(WasmParserModuleInstance& instance, uint8_ return result ? std::move(*result) : ParserModuleParseResult{}; } +void expectPointCloud42(const ParserModuleParseResult& result) { + ASSERT_EQ(result.fault, ParserModuleFaultKind::kNone) << result.message; + const auto* object = std::get_if(&*result.output); + ASSERT_NE(object, nullptr); + const auto* cloud = std::any_cast(&object->object); + ASSERT_NE(cloud, nullptr); + ASSERT_EQ(cloud->data.size(), 1U); + EXPECT_EQ(cloud->data[0], 42U); +} + TEST(WasmParserModuleHardening, EnforcesArtifactAndDeclaredMemoryAdmissionCaps) { const uint64_t file_size = std::filesystem::file_size(PJ_ADVERSARIAL_WASM_PATH); - WasmParserModuleLimits limits; - limits.maximum_artifact_bytes = file_size - 1; std::vector diagnostics; - auto oversized = WasmParserModule::load( - PJ_ADVERSARIAL_WASM_PATH, limits, [&](const Diagnostic& diagnostic) { diagnostics.push_back(diagnostic); }); + WasmParserModuleLoadOptions options; + options.sink = [&](const Diagnostic& diagnostic) { diagnostics.push_back(diagnostic); }; + + options.limits.maximum_artifact_bytes = file_size - 1; + auto oversized = WasmParserModule::load(PJ_ADVERSARIAL_WASM_PATH, options); ASSERT_FALSE(oversized.has_value()); ASSERT_EQ(diagnostics.size(), 1U); EXPECT_NE(oversized.error().find("artifact_file_size budget exhausted"), std::string::npos); - limits = WasmParserModuleLimits{}; - limits.maximum_linear_memory_bytes = UINT64_C(128) * 1024U * 1024U; + options.limits = WasmParserModuleLimits{}; + options.limits.maximum_linear_memory_bytes = UINT64_C(128) * 1024U * 1024U; diagnostics.clear(); - auto memory_bomb = WasmParserModule::load( - PJ_ADVERSARIAL_WASM_PATH, limits, [&](const Diagnostic& diagnostic) { diagnostics.push_back(diagnostic); }); + auto memory_bomb = WasmParserModule::load(PJ_ADVERSARIAL_WASM_PATH, options); ASSERT_FALSE(memory_bomb.has_value()); ASSERT_EQ(diagnostics.size(), 1U); EXPECT_NE(memory_bomb.error().find("exceeds configured cap"), std::string::npos); + + options.limits = WasmParserModuleLimits{}; + options.limits.maximum_table_elements = 0; + diagnostics.clear(); + auto table_bomb = WasmParserModule::load(PJ_ADVERSARIAL_WASM_PATH, options); + ASSERT_FALSE(table_bomb.has_value()); + ASSERT_EQ(diagnostics.size(), 1U); + EXPECT_NE(table_bomb.error().find("table maximum"), std::string::npos); } TEST(WasmParserModuleHardening, EnforcesAggregateBudgetsAtActualAdmissionBoundaries) { const uint64_t file_size = std::filesystem::file_size(PJ_ADVERSARIAL_WASM_PATH); const auto load_with = [](ParserModuleSessionBudgetLimits limits) { auto budget = std::make_shared(limits); - auto loaded = WasmParserModule::load(PJ_ADVERSARIAL_WASM_PATH, budget); + auto loaded = WasmParserModule::load(PJ_ADVERSARIAL_WASM_PATH, budgeted(budget)); return std::pair(std::move(budget), std::move(loaded)); }; @@ -131,12 +160,30 @@ TEST(WasmParserModuleHardening, EnforcesAggregateBudgetsAtActualAdmissionBoundar EXPECT_EQ(memory_decline.error().outcome, WasmParserModuleCreateOutcome::kAdmissionDecline); EXPECT_NE(memory_decline.error().message.find("total_linear_memory"), std::string::npos); EXPECT_EQ(memory_budget->usage().declared_linear_memory_bytes, 0U); + + // Reservations are released by the wrappers themselves, module last. + limits = {}; + limits.maximum_modules = 1; + limits.maximum_active_instances = 1; + auto [release_budget, released_load] = load_with(limits); + ASSERT_TRUE(released_load.has_value()) << released_load.error(); + std::optional released_module(std::move(*released_load)); + { + auto instance = createBound(*released_module); + ASSERT_TRUE(instance.has_value()) << instance.error(); + EXPECT_EQ(release_budget->usage().active_instances, 1U); + released_module.reset(); + EXPECT_EQ(release_budget->usage().modules, 1U) << "module reservation outlives its instances"; + } + EXPECT_EQ(release_budget->usage().modules, 0U); + EXPECT_EQ(release_budget->usage().active_instances, 0U); + EXPECT_EQ(release_budget->usage().declared_linear_memory_bytes, 0U); } TEST(WasmParserModuleHardening, MetersInfiniteLoopAsDistinctContractViolation) { - WasmParserModuleLimits limits; - limits.metering_points_per_call = UINT64_C(1000000); - auto module = WasmParserModule::load(PJ_ADVERSARIAL_WASM_PATH, limits); + WasmParserModuleLoadOptions options; + options.limits.metering_points_per_call = UINT64_C(1000000); + auto module = WasmParserModule::load(PJ_ADVERSARIAL_WASM_PATH, options); ASSERT_TRUE(module.has_value()) << module.error(); auto instance = createBound(*module); ASSERT_TRUE(instance.has_value()) << instance.error(); @@ -159,44 +206,70 @@ TEST(WasmParserModuleHardening, EngineRejectsRuntimeGrowthPastDeclaredMaximum) { EXPECT_NE(result.message.find("memory growth rejected by declared maximum"), std::string::npos); } -TEST(WasmParserModuleHardening, TrapQuarantineReplaysBindingThenDisablesOnRepeat) { +TEST(WasmParserModuleHardening, HostDrivenQuarantineReplaysBindingThenDisablesOnRepeat) { + // The wasm wrapper only classifies; this is the host loop, identical to the + // native one: record faults, replay create/bind on quarantine, mark the + // recreation, and stop creating instances once the claim is disabled. auto module = WasmParserModule::load(PJ_ADVERSARIAL_WASM_PATH); ASSERT_TRUE(module.has_value()) << module.error(); + ParserModuleStrikeTracker tracker; auto instance = createBound(*module); ASSERT_TRUE(instance.has_value()) << instance.error(); for (uint8_t strike = 1; strike <= 3; ++strike) { const ParserModuleParseResult result = parseBehavior(*instance, 0); ASSERT_EQ(result.fault, ParserModuleFaultKind::kContractViolation); - const ParserModuleStrikeState state = module->strikeState(0); - if (strike < 3) { - EXPECT_EQ(state.health, ParserModuleClaimHealth::kActive); - EXPECT_EQ(state.strikes, strike); - } else { - EXPECT_EQ(state.health, ParserModuleClaimHealth::kActive); - EXPECT_EQ(state.strikes, 0U); - EXPECT_EQ(state.quarantine_count, 1U); - EXPECT_NE(result.message.find("quarantined and recreated"), std::string::npos); - } + EXPECT_NE(result.message.find("wasm trap"), std::string::npos); + EXPECT_EQ(instance->lifecycleDiagnostic(), result.message); + const ParserModuleStrikeState state = tracker.recordFault(kClaim, result.fault); + EXPECT_EQ(state.health, strike < 3 ? ParserModuleClaimHealth::kActive : ParserModuleClaimHealth::kQuarantined); } + EXPECT_EQ(tracker.state(kClaim).quarantine_count, 1U); - const ParserModuleParseResult recovered = parseBehavior(*instance, 3); - ASSERT_EQ(recovered.fault, ParserModuleFaultKind::kNone) << recovered.message; - const auto* object = std::get_if(&*recovered.output); - ASSERT_NE(object, nullptr); - const auto* cloud = std::any_cast(&object->object); - ASSERT_NE(cloud, nullptr); - ASSERT_EQ(cloud->data.size(), 1U); - EXPECT_EQ(cloud->data[0], 42U); + instance = createBound(*module); + ASSERT_TRUE(instance.has_value()) << instance.error(); + ASSERT_TRUE(tracker.markRecreated(kClaim)); + expectPointCloud42(parseBehavior(*instance, 3)); for (uint8_t strike = 0; strike < 3; ++strike) { const ParserModuleParseResult result = parseBehavior(*instance, 0); ASSERT_EQ(result.fault, ParserModuleFaultKind::kContractViolation); + (void)tracker.recordFault(kClaim, result.fault); } - const ParserModuleStrikeState disabled = module->strikeState(0); + const ParserModuleStrikeState disabled = tracker.state(kClaim); EXPECT_EQ(disabled.health, ParserModuleClaimHealth::kDisabled); EXPECT_EQ(disabled.quarantine_count, 2U); - EXPECT_FALSE(instance->valid()); + EXPECT_FALSE(tracker.markRecreated(kClaim)); +} + +TEST(WasmParserModuleHardening, IndependentInstancesRunConcurrentlyUnderOneTracker) { + // Scalar and object routes of one claim run on different threads in the + // host; the stores are independent and the shared tracker is synchronized. + auto budget = std::make_shared(); + auto module = WasmParserModule::load(PJ_ADVERSARIAL_WASM_PATH, budgeted(budget)); + ASSERT_TRUE(module.has_value()) << module.error(); + ParserModuleStrikeTracker tracker; + + std::vector threads; + for (int worker = 0; worker < 4; ++worker) { + threads.emplace_back([&] { + auto instance = createBound(*module); + ASSERT_TRUE(instance.has_value()) << instance.error(); + for (int round = 0; round < 25; ++round) { + expectPointCloud42(parseBehavior(*instance, 3)); + const ParserModuleParseResult trap = parseBehavior(*instance, 2); // data error, never a strike + EXPECT_EQ(trap.fault, ParserModuleFaultKind::kDataError); + (void)tracker.recordFault(kClaim, trap.fault); + } + }); + } + for (auto& thread : threads) { + thread.join(); + } + EXPECT_EQ(tracker.state(kClaim).strikes, 0U); + EXPECT_EQ(tracker.state(kClaim).health, ParserModuleClaimHealth::kActive); + EXPECT_EQ(budget->usage().active_instances, 0U); + EXPECT_EQ(budget->usage().modules, 1U); } } // namespace diff --git a/pj_plugins/tests/wasm_parser_module_test.cpp b/pj_plugins/tests/wasm_parser_module_test.cpp index a7653a9e..3f27b7fb 100644 --- a/pj_plugins/tests/wasm_parser_module_test.cpp +++ b/pj_plugins/tests/wasm_parser_module_test.cpp @@ -334,6 +334,62 @@ std::vector withoutMemoryMaximum(std::vector wasm) { return wasm; } +/// Rewrite the fixture's single funcref table limits. A null maximum drops +/// the maximum flag entirely. +std::vector withTableLimits(std::vector wasm, std::optional maximum) { + const auto table = findSection(wasm, 4); + EXPECT_TRUE(table.has_value()); + if (!table) { + return wasm; + } + size_t position = table->payload_begin; + const auto count = readVarUint32(wasm, &position); + const uint8_t reference_type = wasm[position++]; + const auto flags = readVarUint32(wasm, &position); + const auto minimum = readVarUint32(wasm, &position); + EXPECT_EQ(count, 1U); + EXPECT_EQ(reference_type, 0x70); + EXPECT_EQ(flags, 1U); + EXPECT_TRUE(minimum.has_value()); + if (!count || *count != 1 || !flags || *flags != 1 || !minimum) { + return wasm; + } + + std::vector payload{1, 0x70, static_cast(maximum ? 1 : 0)}; + append(&payload, encodeVarUint32(*minimum)); + if (maximum) { + append(&payload, encodeVarUint32(std::max(*maximum, *minimum))); + } + const auto replacement = encodeSection(4, payload); + wasm.erase(wasm.begin() + static_cast(table->begin), wasm.begin() + static_cast(table->end)); + wasm.insert(wasm.begin() + static_cast(table->begin), replacement.begin(), replacement.end()); + return wasm; +} + +/// Replace the embedded manifest with one declaring three claims while the +/// compiled module still knows two, so the guest's own creation-error path +/// (token zero) is reachable through a claim index the host accepts. +std::vector withThreeClaimManifest(std::vector wasm) { + wasm = withoutManifest(std::move(wasm)); + constexpr std::string_view kManifest = R"({ + "module_abi": 1, + "id": "org.plotjuggler.test.kit-cdr-pointcloud", + "name": "Authoring kit CDR PointCloud fixture", + "version": "1.0.0", + "claims": [ + {"claim_id": "full-wire", "encoding": "ros2msg", "type_name": "toy_msgs/msg/Cloud", + "routes": ["object"], "object_type": "kPointCloud", "priority": 0}, + {"claim_id": "spliced", "encoding": "ros2msg", "type_name": "toy_msgs/msg/CloudSplice", + "routes": ["object"], "object_type": "kPointCloud", "priority": 0}, + {"claim_id": "phantom", "encoding": "ros2msg", "type_name": "toy_msgs/msg/Phantom", + "routes": ["object"], "object_type": "kPointCloud", "priority": 0} + ] +})"; + auto embedded = parser_module::appendManifestSection(wasm, bytes(kManifest)); + EXPECT_TRUE(embedded.has_value()) << embedded.error(); + return embedded ? std::move(*embedded) : std::move(wasm); +} + std::vector withFunctionFirstOpcode(std::vector wasm, std::string_view export_name, uint8_t opcode) { const auto function = findExportLocation(wasm, export_name); const auto code = findSection(wasm, 10); @@ -503,6 +559,12 @@ std::vector toyPayload() { return output; } +WasmParserModuleLoadOptions collectingInto(std::vector* diagnostics) { + WasmParserModuleLoadOptions options; + options.sink = [diagnostics](const Diagnostic& diagnostic) { diagnostics->push_back(diagnostic); }; + return options; +} + parser_module::BindingInfoV1 binding(uint32_t claim_index, std::string_view schema) { return parser_module::BindingInfoV1{ .route = parser_module::Route::kObject, @@ -519,11 +581,13 @@ parser_module::BindingInfoV1 binding(uint32_t claim_index, std::string_view sche TEST(WasmParserModule, LoadsValidatesAndAdmitsManifestWithoutInstantiation) { std::vector diagnostics; - auto module = WasmParserModule::load( - PJ_TOY_CDR_POINTCLOUD_WASM_PATH, [&](const Diagnostic& diagnostic) { diagnostics.push_back(diagnostic); }); + auto module = WasmParserModule::load(PJ_TOY_CDR_POINTCLOUD_WASM_PATH, collectingInto(&diagnostics)); ASSERT_TRUE(module.has_value()) << module.error(); EXPECT_TRUE(module->valid()); EXPECT_TRUE(diagnostics.empty()); + EXPECT_EQ(module->declaredLinearMemoryMaximum(), UINT64_C(256) * 1024U * 1024U); + EXPECT_GT(module->declaredTableElements(), 0U); + EXPECT_LE(module->declaredTableElements(), parser_module::ParserModuleWasmLimits::kDefaultMaximumTableElements); ParserClaimCatalog catalog; auto manifest = catalog.ingestModuleManifest(module->manifestJson(), ParserClaimProvenance::kFolderDrop, 31); @@ -544,14 +608,15 @@ TEST(WasmParserModule, RejectsLoaderViolationsWithOneDiagnostic) { {withWrongExportSignature(valid), "wrong wasm signature"}, {withDisallowedImport(valid), "wasi_snapshot_preview1.fd_write"}, {withoutMemoryMaximum(valid), "memory has no declared maximum"}, + {withTableLimits(valid, std::nullopt), "table has no declared maximum"}, + {withTableLimits(valid, UINT32_C(1) << 20U), "table maximum 1048576 exceeds configured cap"}, {withInvalidParseOpcode(valid), "Wasmer rejected parser module"}, }; for (const auto& [artifact, expected] : cases) { TemporaryWasm file(artifact); std::vector diagnostics; - auto module = - WasmParserModule::load(file.string(), [&](const Diagnostic& diagnostic) { diagnostics.push_back(diagnostic); }); + auto module = WasmParserModule::load(file.string(), collectingInto(&diagnostics)); EXPECT_FALSE(module.has_value()) << expected; ASSERT_EQ(diagnostics.size(), 1U) << expected; EXPECT_EQ(diagnostics.front().level, DiagnosticLevel::kError); @@ -638,14 +703,30 @@ TEST(WasmParserModule, DeepSchemaReturnsDepthErrorWithConfiguredShadowStack) { EXPECT_NE(result->message.find("nesting depth exceeds 64"), std::string::npos); } -TEST(WasmParserModule, CopiesTokenZeroCreationError) { +TEST(WasmParserModule, RejectsClaimIndexOutsideTheManifestBeforeCallingTheGuest) { auto module = WasmParserModule::load(PJ_TOY_CDR_POINTCLOUD_WASM_PATH); ASSERT_TRUE(module.has_value()) << module.error(); auto instance = WasmParserModuleInstance::create(*module, 2); ASSERT_FALSE(instance.has_value()); + EXPECT_EQ(instance.error().outcome, WasmParserModuleCreateOutcome::kError); + EXPECT_EQ(instance.error().fault, ParserModuleFaultKind::kNone); EXPECT_NE(instance.error().message.find("claim index is outside the wasm parser-module manifest"), std::string::npos); } +TEST(WasmParserModule, CopiesTokenZeroCreationError) { + TemporaryWasm artifact(withThreeClaimManifest(readFile(PJ_TOY_CDR_POINTCLOUD_WASM_PATH))); + auto module = WasmParserModule::load(artifact.string()); + ASSERT_TRUE(module.has_value()) << module.error(); + // The host accepts index 2; the guest (built with two claims) returns the + // creation-error token and the message is read back through + // pj_module_last_error(0, ...). + auto instance = WasmParserModuleInstance::create(*module, 2); + ASSERT_FALSE(instance.has_value()); + EXPECT_EQ(instance.error().outcome, WasmParserModuleCreateOutcome::kError); + EXPECT_EQ(instance.error().fault, ParserModuleFaultKind::kNone); + EXPECT_EQ(instance.error().message, "claim index is outside the module manifest"); +} + TEST(WasmParserModule, ClassifiesModuleParseErrorAsStrikeFreeDataError) { auto module = WasmParserModule::load(PJ_TOY_CDR_POINTCLOUD_WASM_PATH); ASSERT_TRUE(module.has_value()) << module.error(); @@ -687,7 +768,7 @@ TEST(WasmParserModule, ClassifiesGuestTrapAsContractViolation) { EXPECT_EQ(tracker.recordFault(key, result->fault).strikes, 1U); } -TEST(WasmParserModule, TypesCreationTrapsAndRecordsTheirStrike) { +TEST(WasmParserModule, TypesCreationTrapsAsContractViolations) { TemporaryWasm artifact(withTrappingCreate(readFile(PJ_TOY_CDR_POINTCLOUD_WASM_PATH))); auto module = WasmParserModule::load(artifact.string()); ASSERT_TRUE(module.has_value()) << module.error(); @@ -696,19 +777,23 @@ TEST(WasmParserModule, TypesCreationTrapsAndRecordsTheirStrike) { EXPECT_EQ(instance.error().outcome, WasmParserModuleCreateOutcome::kError); EXPECT_EQ(instance.error().fault, ParserModuleFaultKind::kContractViolation); EXPECT_NE(instance.error().message.find("pj_module_create failed: wasm trap"), std::string::npos); - EXPECT_EQ(module->strikeState(0).strikes, 1U); + + // The host feeds the classified fault to the same tracker native uses. + ParserModuleStrikeTracker tracker; + const ParserModuleClaimKey key{"org.plotjuggler.test.kit-cdr-pointcloud", "full-wire"}; + EXPECT_EQ(tracker.recordFault(key, instance.error().fault).strikes, 1U); } -TEST(WasmParserModule, RecordsDestroyAndGuestFreeTraps) { +TEST(WasmParserModule, SurvivesDestroyTrapsAndReportsGuestFreeTraps) { { TemporaryWasm artifact(withTrappingDestroy(readFile(PJ_TOY_CDR_POINTCLOUD_WASM_PATH))); auto module = WasmParserModule::load(artifact.string()); ASSERT_TRUE(module.has_value()) << module.error(); - { - auto instance = WasmParserModuleInstance::create(*module, 0); - ASSERT_TRUE(instance.has_value()) << instance.error().message; - } - EXPECT_EQ(module->strikeState(0).strikes, 1U); + auto instance = WasmParserModuleInstance::create(*module, 0); + ASSERT_TRUE(instance.has_value()) << instance.error().message; + // Teardown is best-effort: a trapping destroy must not escape the wrapper. + *instance = WasmParserModuleInstance{}; + EXPECT_FALSE(instance->valid()); } TemporaryWasm artifact(withTrappingFree(readFile(PJ_TOY_CDR_POINTCLOUD_WASM_PATH))); @@ -721,7 +806,6 @@ TEST(WasmParserModule, RecordsDestroyAndGuestFreeTraps) { EXPECT_EQ(result->fault, ParserModuleFaultKind::kContractViolation); EXPECT_NE(result->message.find("pj_module_free failed after bind"), std::string::npos); EXPECT_NE(instance->lifecycleDiagnostic().find("pj_module_free failed after bind"), std::string_view::npos); - EXPECT_EQ(module->strikeState(0).strikes, 1U); } } // namespace