diff --git a/.claude/skills/plotjuggler-plugin/references/parser-module.md b/.claude/skills/plotjuggler-plugin/references/parser-module.md index 3c8ab32b..c2f49410 100644 --- a/.claude/skills/plotjuggler-plugin/references/parser-module.md +++ b/.claude/skills/plotjuggler-plugin/references/parser-module.md @@ -111,20 +111,24 @@ 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`) and a +function-table maximum (wasm-ld emits one; the host caps it at 65536 elements). ## Choose a schema-compatibility strategy @@ -170,8 +174,12 @@ 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 (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/.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..8f87cb1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,44 @@ 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, 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 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 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] ### Fix: Conan `plugin_host` component links the parser-module host (PATCH) diff --git a/CLAUDE.md b/CLAUDE.md index 61cb3c6a..50dacd5f 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, 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 8cae0b95..a145407f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -161,6 +161,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/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..803fa928 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, 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 new file mode 100644 index 00000000..78f15a51 --- /dev/null +++ b/pj_base/include/pj_base/parser_module_wasm.hpp @@ -0,0 +1,141 @@ +#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 WasmTableLimits { + uint32_t minimum_elements = 0; + std::optional maximum_elements; + + bool operator==(const WasmTableLimits&) 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; + std::vector tables; + 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); + +/** 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 new file mode 100644 index 00000000..75300100 --- /dev/null +++ b/pj_base/src/parser_module_wasm.cpp @@ -0,0 +1,666 @@ +// 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" +#include "pj_base/parser_module_manifest.hpp" + +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 tables; + 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()); + } + module->tables.push_back( + WasmTableLimits{.minimum_elements = limits->minimum_pages, .maximum_elements = limits->maximum_pages}); + 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 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) { + 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.tables = std::move(builder.tables); + 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 4: + parsed = parseTableSection(*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; +} + +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_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..140e3cd3 --- /dev/null +++ b/pj_base/tests/parser_module_wasm_test.cpp @@ -0,0 +1,114 @@ +// 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"); +} + +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 new file mode 100644 index 00000000..dda84bc4 --- /dev/null +++ b/pj_base/tools/pj_wasm_embed_manifest.cpp @@ -0,0 +1,145 @@ +// 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()); + } + 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")); + } + + 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' + << " 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: " << 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 {}; +} + +[[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 = 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); + 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..62145714 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,20 @@ 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, 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 | @@ -63,7 +77,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..64d95718 100644 --- a/pj_plugins/CMakeLists.txt +++ b/pj_plugins/CMakeLists.txt @@ -1,5 +1,43 @@ 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() +# 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 ) @@ -94,6 +132,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 +151,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 +363,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 $) +endif() set_target_properties(pj_plugin_host PROPERTIES EXPORT_NAME plugin_host) add_library(plotjuggler_sdk::plugin_host ALIAS pj_plugin_host) @@ -319,6 +394,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 +692,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) diff --git a/pj_plugins/docs/ARCHITECTURE.md b/pj_plugins/docs/ARCHITECTURE.md index 696f56f0..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 @@ -169,14 +171,68 @@ 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 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) + +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 +439,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..8c10456c 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,12 @@ class NativeParserModule { [[nodiscard]] static Expected load( std::string_view path, DiagnosticSink sink = {}, std::string diagnostic_source = "NativeParserModule"); + /// 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"); + [[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..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 @@ -126,6 +129,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 { @@ -147,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); @@ -159,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 new file mode 100644 index 00000000..2c4fb509 --- /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 Aggregate resource accounting for parser-module admission. + * + * 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 + +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 ParserModuleBudgetKind : uint8_t { + kNone, + kModuleCount, + kArtifactFileSize, + kTotalClaims, + kActiveInstances, + 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 { + uint64_t reservation = 0; + ParserModuleBudgetKind exhausted_budget = ParserModuleBudgetKind::kNone; + std::string diagnostic; + + [[nodiscard]] bool accepted() const noexcept { + return reservation != 0; + } +}; + +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 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( + uint64_t artifact_bytes, uint64_t claim_count, uint64_t declared_linear_memory_maximum); + + /// 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); + + /// 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; + + private: + struct ModuleReservation { + 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_; + uint64_t next_reservation_ = 1; + std::map modules_; +}; + +} // 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..d5d3bfb9 --- /dev/null +++ b/pj_plugins/include/pj_plugins/host/wasm_parser_module.hpp @@ -0,0 +1,107 @@ +#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 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 + * 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. + * + * 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 +#include +#include +#include + +#include "pj_base/diagnostic_sink.hpp" +#include "pj_base/expected.hpp" +#include "pj_base/parser_module_wasm.hpp" +#include "pj_plugins/host/parser_module_session_budget.hpp" + +namespace PJ { + +namespace detail { +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 kDefaultMeteringPointsPerCall = UINT64_C(10000000); + + uint64_t maximum_artifact_bytes = kDefaultMaximumArtifactBytes; + 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, audit, admit, and compile one wasm parser module. + [[nodiscard]] static Expected load( + std::string_view path, const WasmParserModuleLoadOptions& options = {}); + + [[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]] uint64_t declaredTableElements() const noexcept; + + 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..d6690d95 --- /dev/null +++ b/pj_plugins/include/pj_plugins/host/wasm_parser_module_runtime.hpp @@ -0,0 +1,82 @@ +#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. + * + * 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 +#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(); + ~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`. 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 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; + /// 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); + + 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..bb0c3d9d 100644 --- a/pj_plugins/src/detail/native_parser_module_state.hpp +++ b/pj_plugins/src/detail/native_parser_module_state.hpp @@ -2,17 +2,25 @@ // 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; + /// Null when the host loaded without a session budget (0.22 behavior). + std::shared_ptr session_budget; + 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/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..56d26e85 --- /dev/null +++ b/pj_plugins/src/detail/wasm_parser_module_state.hpp @@ -0,0 +1,32 @@ +#pragma once +// Copyright 2026 Davide Faconti +// SPDX-License-Identifier: Apache-2.0 + +#include + +#include +#include +#include + +#include "pj_plugins/host/parser_module_session_budget.hpp" + +namespace PJ::detail { + +struct WasmParserModuleState { + ~WasmParserModuleState(); + + std::string path; + std::string manifest_json; + 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; + 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 0a9a181f..73b339f6 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,27 @@ Expected resolve( } // namespace +namespace detail { + +NativeParserModuleState::~NativeParserModuleState() { + if (session_budget != nullptr) { + session_budget->releaseModule(module_reservation); + } +} + +} // 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, 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) { detail::LibraryPathIdentity recorded_path; auto handle_result = detail::openNativeParserModule(path, &recorded_path); if (!handle_result) { @@ -113,6 +130,29 @@ Expected NativeParserModule::load( const auto* manifest = reinterpret_cast(static_cast(manifest_addr)); state->manifest_json.assign(manifest, static_cast(manifest_len)); + 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; + } + 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..d9afb435 100644 --- a/pj_plugins/src/parser_module_runtime.cpp +++ b/pj_plugins/src/parser_module_runtime.cpp @@ -9,14 +9,14 @@ #include #include #include +#include #include #include #include #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 +37,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 +57,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 +71,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 +82,24 @@ Expected NativeParserModuleInstance::create( if (!module.valid()) { return unexpected("cannot create an instance from an invalid native parser module"); } + 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) { + 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()); } - return NativeParserModuleInstance(module.state_, token, claim_index); + NativeParserModuleInstance instance(module.state_, token, claim_index); + instance.instance_budget_reserved_ = budget != nullptr; + return instance; } Expected NativeParserModuleInstance::bind(const parser_module::BindingInfoV1& info) { @@ -331,17 +234,29 @@ void NativeParserModuleInstance::reset() noexcept { if (module_ != nullptr && token_ != PJ_MODULE_CREATION_ERROR_TOKEN) { module_->destroy(token_); } + if (module_ != nullptr && instance_budget_reserved_) { + module_->session_budget->releaseInstance(module_->module_reservation); + } token_ = PJ_MODULE_CREATION_ERROR_TOKEN; bound_ = false; + instance_budget_reserved_ = false; module_.reset(); } 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; } @@ -359,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; @@ -368,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 new file mode 100644 index 00000000..99309aba --- /dev/null +++ b/pj_plugins/src/parser_module_session_budget.cpp @@ -0,0 +1,119 @@ +// 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 declineBudget(ParserModuleBudgetKind kind, std::string_view name) { + return ParserModuleAdmissionDecision{ + .reservation = 0, + .exhausted_budget = kind, + .diagnostic = "parser-module admission DECLINE: " + 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( + 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"); + } + 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"); + } + + const uint64_t reservation = next_reservation_++; + modules_.emplace( + reservation, ModuleReservation{ + .claim_count = claim_count, + .declared_linear_memory_maximum = declared_linear_memory_maximum, + .active_instances = 0, + }); + ++usage_.modules; + usage_.claims += claim_count; + return ParserModuleAdmissionDecision{ + .reservation = reservation, .exhausted_budget = ParserModuleBudgetKind::kNone, .diagnostic = {}}; +} + +ParserModuleAdmissionDecision ParserModuleSessionBudgetTracker::admitInstance(uint64_t module_reservation) { + const std::scoped_lock lock(mutex_); + auto module = modules_.find(module_reservation); + if (module == modules_.end()) { + 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"); + } + 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 ParserModuleAdmissionDecision{ + .reservation = module_reservation, .exhausted_budget = ParserModuleBudgetKind::kNone, .diagnostic = {}}; +} + +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; + } + --module->second.active_instances; + --usage_.active_instances; + usage_.declared_linear_memory_bytes -= module->second.declared_linear_memory_maximum; +} + +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); +} + +const ParserModuleSessionBudgetLimits& ParserModuleSessionBudgetTracker::limits() const noexcept { + return limits_; +} + +ParserModuleSessionBudgetUsage ParserModuleSessionBudgetTracker::usage() const { + const std::scoped_lock lock(mutex_); + return usage_; +} + +} // 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..856add9f --- /dev/null +++ b/pj_plugins/src/wasm_parser_module.cpp @@ -0,0 +1,219 @@ +// 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")); + } + 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); + 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 (session_budget != nullptr) { + session_budget->releaseModule(module_reservation); + } +} + +} // namespace detail + +WasmParserModule::WasmParserModule(std::shared_ptr state) + : state_(std::move(state)) {} + +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 (options.sink) { + options.sink( + Diagnostic{ + .level = DiagnosticLevel::kError, + .source = options.diagnostic_source, + .id = std::string(path), + .message = message, + }); + } + 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"); + } + auto bytes = readFile(path, limits.maximum_artifact_bytes); + if (!bytes) { + return reject(bytes.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 = + 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 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 = audited->declared_linear_memory_maximum; + state->declared_table_elements = audited->declared_table_elements; + state->metering_points_per_call = limits.metering_points_per_call; + 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; + } + 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; +} + +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 new file mode 100644 index 00000000..94c1a05e --- /dev/null +++ b/pj_plugins/src/wasm_parser_module_runtime.cpp @@ -0,0 +1,687 @@ +// 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) { + // 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)}; + (void)callVoid(PJ_MODULE_DESTROY_EXPORT_NAME, destroy, arguments); + } + 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) { + module->session_budget->releaseInstance(module->module_reservation); + } + } + + void noteContractViolation(std::string message) { + lifecycle_diagnostic = std::move(message); + } + + [[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; + 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 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) + : state_(&state), address_(address), size_(size) {} + + GuestAllocation(const GuestAllocation&) = delete; + GuestAllocation& operator=(const GuestAllocation&) = delete; + + ~GuestAllocation() { + auto released = release(); + if (!released) { + state_->noteContractViolation("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() = 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_count) { + return reject("claim index is outside the wasm parser-module manifest"); + } + auto state = std::make_unique(); + state->module = module.state_; + state->claim_index = claim_index; + 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"); + } + + 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) { + 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) { + return reject(exports.error(), ParserModuleFaultKind::kContractViolation); + } + auto initialized = state->callVoid("_initialize", state->initialize, {}); + if (!initialized) { + return reject( + "parser-module _initialize failed: " + initialized.error(), ParserModuleFaultKind::kContractViolation); + } + auto abi = state->callI32(PJ_MODULE_ABI_EXPORT_NAME, state->abi, {}); + if (!abi) { + return reject("pj_module_abi failed: " + abi.error(), ParserModuleFaultKind::kContractViolation); + } + if (static_cast(*abi) != PJ_PARSER_MODULE_ABI_VERSION) { + 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) { + 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) { + return reject(message.error(), ParserModuleFaultKind::kContractViolation); + } + 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::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) { + 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) { + state_->noteContractViolation(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; + 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(); + state_->noteContractViolation(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; + 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"; + state_->noteContractViolation(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) { + state_->noteContractViolation(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 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) { + state_->noteContractViolation(result->message); + } + 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; +} + +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_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 new file mode 100644 index 00000000..8931530f --- /dev/null +++ b/pj_plugins/tests/parser_module_session_budget_test.cpp @@ -0,0 +1,151 @@ +// Copyright 2026 Davide Faconti +// SPDX-License-Identifier: Apache-2.0 + +#include "pj_plugins/host/parser_module_session_budget.hpp" + +#include + +#include +#include +#include +#include + +namespace PJ { +namespace { + +void expectDecline( + const ParserModuleAdmissionDecision& decision, ParserModuleBudgetKind budget, std::string_view diagnostic_name) { + 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); +} + +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(101, 1, 100), ParserModuleBudgetKind::kArtifactFileSize, "artifact_file_size"); + EXPECT_EQ(tracker.usage().modules, 0U); + } + { + ParserModuleSessionBudgetTracker tracker(limits); + expectDecline(tracker.admitModule(100, 3, 100), ParserModuleBudgetKind::kTotalClaims, "total_claims"); + EXPECT_EQ(tracker.usage().claims, 0U); + } + { + ParserModuleSessionBudgetTracker tracker(limits); + 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, + .maximum_artifact_bytes = 100, + .maximum_claims = 4, + .maximum_active_instances = 2, + .maximum_linear_memory_bytes = 300, + }; + ParserModuleSessionBudgetTracker tracker(limits); + 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.reservation), ParserModuleBudgetKind::kTotalLinearMemory, "total_linear_memory"); + EXPECT_EQ(tracker.usage().active_instances, 1U); + 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, ReleasesAreIdempotentAndRestoreCapacity) { + ParserModuleSessionBudgetTracker tracker( + ParserModuleSessionBudgetLimits{ + .maximum_modules = 1, + .maximum_artifact_bytes = 10, + .maximum_claims = 1, + .maximum_active_instances = 1, + .maximum_linear_memory_bytes = 20, + }); + 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); + 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..ed10836d --- /dev/null +++ b/pj_plugins/tests/wasm_parser_module_hardening_test.cpp @@ -0,0 +1,276 @@ +// 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/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 { + +const ParserModuleClaimKey kClaim{"org.plotjuggler.test.adversarial-wasm", "adversarial"}; + +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); +} + +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}); + EXPECT_TRUE(result.has_value()) << result.error(); + 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); + std::vector diagnostics; + 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); + + 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, 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, budgeted(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); + + // 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) { + 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(); + + 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, 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); + 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); + + 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 = tracker.state(kClaim); + EXPECT_EQ(disabled.health, ParserModuleClaimHealth::kDisabled); + EXPECT_EQ(disabled.quarantine_count, 2U); + 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 +} // 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..3f27b7fb --- /dev/null +++ b/pj_plugins/tests/wasm_parser_module_test.cpp @@ -0,0 +1,812 @@ +// 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; +} + +/// 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); + 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; +} + +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, + .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, 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); + 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"}, + {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(), collectingInto(&diagnostics)); + 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, 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(); + 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, TypesCreationTrapsAsContractViolations) { + 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); + + // 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, 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; + // 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))); + 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); +} + +} // 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