chore: sync current main into the CI baseline repair
Validate the formatting and reconstruction fixture repairs against the latest merged feature and correctness changes. Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com>
This commit is contained in:
@@ -175,7 +175,9 @@ additional evidence only when the provider accepts and matches them.
|
||||
REA has a built-in read-only snapshot parser for x64 PE targets with .NET 8 RTR
|
||||
9.1 metadata. An optional headless Ghidra adapter adds database annotations and
|
||||
broader metadata recovery on Linux; it requires an explicitly configured
|
||||
NativeAOT JAR. These are separate routes. See the
|
||||
NativeAOT JAR. These are separate routes. The built-in parser also runs on
|
||||
Windows Ghidra P0; read frozen literals from its report, because
|
||||
`search_strings` does not include them. See the
|
||||
[NativeAOT guide](https://github.com/morluto/rea/blob/main/docs/ghidra-nativeaot.md)
|
||||
for setup, supported layouts, and host restrictions. Preserve binary identity
|
||||
and distinguish derived metadata from Ghidra database observations. Recovered
|
||||
@@ -247,3 +249,9 @@ resolution is explicit; missing references and size mismatch remain visible.
|
||||
Other payloads are opaque, and this operation does not decode images or simulate
|
||||
Windows resource loading. Consult the budgets and supported tree profile in
|
||||
[offline binary diagnostics](https://github.com/morluto/rea/blob/main/docs/binary-diagnostics.md).
|
||||
|
||||
For exact core-module import/export differences, use `compare_wasm_interfaces`
|
||||
with two complete inspection Evidence records (or current-session retained
|
||||
references) and explicit scope. Preserve unknown types/duplicate names; static
|
||||
interface equality is not runtime compatibility. CLI accepts complete inline
|
||||
Evidence in `rea compare-wasm-interfaces input.json --json`.
|
||||
|
||||
@@ -11,6 +11,12 @@ import sys
|
||||
|
||||
PROFILE = "pwntools@4.15.0;pyelftools@0.33;unicorn@2.1.2"
|
||||
|
||||
# Linux ET_CORE machine identifiers whose PRSTATUS layout is interpreted with
|
||||
# unchanged pwntools 4.15.0 ctypes. Both share pr_cursig@12, pr_pid@32 and
|
||||
# pr_reg@112; only the register window differs: amd64 is 27 x 8 bytes for a
|
||||
# 336-byte descriptor, AArch64 is 34 x 8 bytes for a 392-byte descriptor.
|
||||
CORE_MACHINES = (62, 183)
|
||||
|
||||
|
||||
class CoreFailure(Exception):
|
||||
def __init__(self, reason, message):
|
||||
@@ -51,12 +57,27 @@ def inspect_core(path, cache):
|
||||
# Other declared OSABIs must never select Linux note ctypes.
|
||||
if content[7] not in (0, 3) or content[8] != 0:
|
||||
raise CoreFailure("unsupported-target", "Initial recorded-core profile requires System V or GNU/Linux OSABI with ABI version zero; selected OSABI=" + str(content[7]) + ", ABI version=" + str(content[8]) + ".")
|
||||
if int.from_bytes(content[16:18], "little") != 4 or int.from_bytes(content[18:20], "little") != 62:
|
||||
raise CoreFailure("unsupported-target", "Initial recorded-core profile requires x86-64 ET_CORE.")
|
||||
machine = int.from_bytes(content[18:20], "little")
|
||||
if int.from_bytes(content[16:18], "little") != 4 or machine not in CORE_MACHINES:
|
||||
raise CoreFailure("unsupported-target", "Initial recorded-core profile requires ET_CORE for x86-64 or AArch64; selected ET_TYPE=" + str(int.from_bytes(content[16:18], "little")) + ", e_machine=" + str(machine) + ".")
|
||||
from elftools.common.exceptions import ELFError
|
||||
from elftools.common.utils import struct_parse
|
||||
from elftools.elf.elffile import ELFFile
|
||||
from pwnlib.elf.datatypes import elf_prstatus_amd64, elf_siginfo_64, user_regs_struct_amd64
|
||||
from pwnlib.elf.datatypes import (
|
||||
elf_prstatus_aarch64,
|
||||
elf_prstatus_amd64,
|
||||
elf_siginfo_64,
|
||||
user_regs_struct_aarch64,
|
||||
user_regs_struct_amd64,
|
||||
)
|
||||
if machine == 62:
|
||||
profile_label = "x86-64"
|
||||
architecture = "x86_64-little-endian"
|
||||
prstatus_type, registers_type = elf_prstatus_amd64, user_regs_struct_amd64
|
||||
else:
|
||||
profile_label = "AArch64"
|
||||
architecture = "aarch64-little-endian"
|
||||
prstatus_type, registers_type = elf_prstatus_aarch64, user_regs_struct_aarch64
|
||||
try:
|
||||
image = ELFFile(io.BytesIO(content))
|
||||
header = image.header
|
||||
@@ -107,17 +128,17 @@ def inspect_core(path, cache):
|
||||
if owner != b"CORE\0":
|
||||
continue
|
||||
if note.n_type == "NT_PRSTATUS":
|
||||
if len(descriptor) < ctypes.sizeof(elf_prstatus_amd64):
|
||||
raise CoreFailure("format", "Linux amd64 NT_PRSTATUS descriptor is truncated.")
|
||||
status = elf_prstatus_amd64.from_buffer_copy(descriptor)
|
||||
if len(descriptor) < ctypes.sizeof(prstatus_type):
|
||||
raise CoreFailure("format", "Linux " + profile_label + " NT_PRSTATUS descriptor is truncated.")
|
||||
status = prstatus_type.from_buffer_copy(descriptor)
|
||||
registers = []
|
||||
for name, kind in user_regs_struct_amd64._fields_:
|
||||
relative = elf_prstatus_amd64.pr_reg.offset + getattr(user_regs_struct_amd64, name).offset
|
||||
for name, kind in registers_type._fields_:
|
||||
relative = prstatus_type.pr_reg.offset + getattr(registers_type, name).offset
|
||||
registers.append({"name": name, "value": hex(getattr(status.pr_reg, name)), "location": location(descriptor_start + relative, ctypes.sizeof(kind), end)})
|
||||
threads.append({"note_index": note_index, "historical_pid": ctypes.c_int32(status.pr_pid).value, "recorded_current_signal": status.pr_cursig, "registers": registers})
|
||||
elif note.n_type == "NT_SIGINFO":
|
||||
if len(descriptor) < ctypes.sizeof(elf_siginfo_64):
|
||||
raise CoreFailure("format", "Linux amd64 NT_SIGINFO descriptor is truncated.")
|
||||
raise CoreFailure("format", "Linux " + profile_label + " NT_SIGINFO descriptor is truncated.")
|
||||
signal = elf_siginfo_64.from_buffer_copy(descriptor)
|
||||
number = ctypes.c_int32(signal.si_signo).value
|
||||
code = ctypes.c_int32(signal.si_code).value
|
||||
@@ -125,7 +146,7 @@ def inspect_core(path, cache):
|
||||
signals.append({"note_index": note_index, "number": number, "code": code, "errno": ctypes.c_int32(signal.si_errno).value,
|
||||
"fault_address": hex(signal.sigfault_addr) if known else None,
|
||||
"fault_address_meaning": "recorded-sigsegv-address" if known else "unknown", "thread_association": "unknown"})
|
||||
return {"format": "elf-core", "architecture": "x86_64-little-endian", "target_execution": "not-performed", "live_process_identity": "unknown",
|
||||
return {"format": "elf-core", "architecture": architecture, "target_execution": "not-performed", "live_process_identity": "unknown",
|
||||
"segments": segments, "notes": notes, "note_padding": padding, "threads": threads, "signals": signals,
|
||||
"note_interpretation_completeness": "unknown",
|
||||
"limitations": ["The initial Linux note profile accepts System V/unspecified or GNU/Linux OSABI with ABI version zero; an unspecified OSABI alone does not prove operating-system origin.",
|
||||
|
||||
@@ -17,10 +17,14 @@ submodule and is a separate, more capable workflow.
|
||||
The parser currently handles x64 PE32+ and RTR 9.1, including bounded
|
||||
DEHYDRATED_DATA processing, MethodTable relationships, virtual slots, and
|
||||
frozen strings. Other RTR versions and PE architectures are reported as
|
||||
unsupported or partial. The matching .NET 8.0.22 fixture is source-built and
|
||||
the parser's Windows-host Ghidra workflow has not yet had a real Windows-host
|
||||
run. This does not establish Windows Ghidra import or decompiler behavior.
|
||||
Windows Ghidra P0 has no database mutation authority. A PE/CLI or ReadyToRun
|
||||
unsupported or partial. The matching .NET 8.0.22 fixture is source-built.
|
||||
|
||||
On a Windows host, Windows Ghidra P0 runs the parser without a JAR. Metadata,
|
||||
MethodTable relationships, virtual slots and frozen strings come from the
|
||||
parser report, and `analyze_function` decompiles slot targets. Tools that read
|
||||
the Ghidra database do not see rehydrated bytes (see below), because only the
|
||||
optional adapter writes them into the database and Windows Ghidra P0 has no
|
||||
database mutation authority. A PE/CLI or ReadyToRun
|
||||
assembly should use `inspect_managed_artifact`; it is not NativeAOT.
|
||||
|
||||
## Build and configure
|
||||
@@ -57,7 +61,9 @@ returns `observations.metadata_recovery` inline from the captured source snapsho
|
||||
including the RTR header, MethodTable addresses, frozen strings, derived overlay
|
||||
identity, and examined/recovered counts. Its enclosing `unsupported` load-image
|
||||
status means independent PE load-image verification was not performed; it does
|
||||
not mean the metadata parser failed or the executable failed to load.
|
||||
not mean the metadata parser failed or the executable failed to load. Without
|
||||
the optional adapter, each recovered type's `type` is `null`, because no Ghidra
|
||||
DataType exists; select it by `address`.
|
||||
|
||||
Pass a returned MethodTable address to `inspect_native_data_type`. A direct PE
|
||||
query also derives the snapshot metadata from the authenticated Ghidra session
|
||||
@@ -67,8 +73,12 @@ The database result remains `unavailable` when Ghidra has no DataType at that
|
||||
address; its `metadata_recovery.source` is `read-only-derived-overlay` and
|
||||
carries related type, interface, slot, and target observations. Generated
|
||||
address identities do not claim original managed names. The parser does not
|
||||
merge frozen literals into Ghidra's database string inventory. Follow slot
|
||||
targets with `analyze_function` and ordinary cross-references.
|
||||
merge frozen literals into Ghidra's database string inventory: read them from
|
||||
`metadata_recovery.frozen_strings`, not `search_strings`. Slot targets come
|
||||
from the parser report; on Windows P0 without the adapter, a `read_bytes` of
|
||||
a slot in the rehydrated region returns no bytes. The Linux adapter route
|
||||
verifies a complete slot read and its target. Follow slot targets with `analyze_function` and ordinary
|
||||
cross-references.
|
||||
|
||||
The built-in CLI path uses a MethodTable address returned by the parser or
|
||||
another observation:
|
||||
@@ -129,3 +139,11 @@ compiler/linker symbols and raw directory bytes provide the oracle. Stripped
|
||||
fixtures exercise heuristic detection. Tests do not execute the resulting
|
||||
NativeAOT binaries. Fixture generation and adapter compilation are optional and
|
||||
never prerequisites of ordinary native analysis or setup.
|
||||
|
||||
The real MCP lane runs only on a Linux x64 host with the adapter. The
|
||||
Windows-host behavior above was checked manually over MCP on Windows 11 x64
|
||||
(non-elevated, Ghidra 12.1.4, JDK 21, no JAR) with the source-built win-x64
|
||||
symbols fixture. The format, RTR version and frozen strings matched the fixture
|
||||
oracle; the derived MethodTable, its base type, its interface and a virtual
|
||||
slot reaching the overridden method matched the linker map; and pseudocode was
|
||||
readable. No CI lane repeats this check.
|
||||
|
||||
@@ -233,8 +233,9 @@ these routes, their layout/host restrictions, derived-memory provenance, and
|
||||
verification coverage.
|
||||
|
||||
REA's experimental Windows Ghidra boundary admits native x86 and x86-64 PE
|
||||
applications and DLLs for static analysis. This does not establish Windows
|
||||
NativeAOT metadata-recovery coverage.
|
||||
applications and DLLs for static analysis. For x64 PE NativeAOT, the read-only
|
||||
parser also runs there; the [NativeAOT guide](ghidra-nativeaot.md) lists what a
|
||||
Windows host covers.
|
||||
|
||||
For NativeAOT analysis, preserve the selected native artifact's path and digest
|
||||
and report recovered type data with its returned provenance. Linking those types
|
||||
|
||||
@@ -604,8 +604,12 @@ failures for such entries name this cause and the operation to rerun with
|
||||
|
||||
Advertised input and output schemas contain no reachable recursive references.
|
||||
Schemas share repeated definitions through schema-local references while
|
||||
preserving complete nested fields and validation rules. Input properties retain
|
||||
their descriptions and literal examples.
|
||||
preserving complete nested fields and validation rules. Input schemas whose
|
||||
shared definitions would exhaust a client's declaration-rendering reference
|
||||
budget (Pi 1.1.0 code-mode shares 32 reference expansions across an input and
|
||||
renders every later reference as `unknown`) are re-projected without shared
|
||||
definitions, so every advertised field renders with complete structure.
|
||||
Input properties retain their descriptions and literal examples.
|
||||
|
||||
When a host forwards input schemas to the model, their examples consume context.
|
||||
REA's advertised examples therefore never copy complete producer Evidence.
|
||||
@@ -652,7 +656,7 @@ JSON Schema:
|
||||
objects or arrays.
|
||||
- The few schemas whose validation structure alone exceeds the budget
|
||||
(`compare_web_captures`, `build_reconstruction_obligation_ledger`,
|
||||
`evaluate_reconstruction_coverage`, `project_managed_application_graph`,
|
||||
`evaluate_reconstruction_coverage`,
|
||||
`compare_application_versions`, `capture_browser_scenario`) are advertised
|
||||
in a reduced form: property names, reference-resolved types (including union
|
||||
alternatives), first-level guidance, and the root's required-field constraints,
|
||||
@@ -662,8 +666,7 @@ JSON Schema:
|
||||
instead of silent acceptance.
|
||||
|
||||
Boundary tests pin every advertised compact schema to the budget, keep them
|
||||
valid JSON Schema that still accepts every canonical example, and hold the
|
||||
set of six reduced presentations as contracts evolve. Client verification
|
||||
valid JSON Schema that still accepts every canonical example. Client verification
|
||||
also checks provider-facing sizes and canonical examples after conversion by
|
||||
the actual Kimi Code CLI; local captures do not establish Moonshot acceptance.
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
# Recorded crash evidence
|
||||
|
||||
`inspect_recorded_crash` / `rea inspect-recorded-crash` inspect a caller-selected
|
||||
Linux ELF64 x86-64 little-endian core independently of an active binary target.
|
||||
Linux ELF64 little-endian core independently of an active binary target. Two
|
||||
architecture profiles are interpreted, x86-64 and AArch64; each binds registers
|
||||
to its own `NT_PRSTATUS` layout, and any other `e_machine` is refused as
|
||||
`unsupported_target` before note interpretation.
|
||||
|
||||
```sh
|
||||
export REA_PWNTOOLS_PYTHON=/absolute/existing/venv/bin/python
|
||||
@@ -10,8 +13,8 @@ rea inspect-recorded-crash ./crash.core --json
|
||||
|
||||
The selected Python needs unchanged pwntools 4.15.0, pyelftools 0.33 and Unicorn 2.1.2.
|
||||
REA does not install these dependencies. ELFFile/its original ELF structures and
|
||||
pwntools' original Linux amd64 ctypes provide parsing and ABI layout. The adapter
|
||||
adds bounds, representation preservation and lifecycle ownership.
|
||||
pwntools' original Linux amd64 and aarch64 ctypes provide parsing and ABI layout.
|
||||
The adapter adds bounds, representation preservation and lifecycle ownership.
|
||||
The initial Linux note profile accepts System V/unspecified and GNU/Linux OSABI
|
||||
with ABI version zero. Other declared ABIs return `unsupported_target` before
|
||||
note interpretation; an unspecified OSABI alone does not prove OS origin.
|
||||
@@ -27,7 +30,8 @@ note interpretation; an unspecified OSABI alone does not prove OS origin.
|
||||
- Every exact Linux CORE PRSTATUS note as a separate recorded thread, with
|
||||
historical PID, signal and lossless uint64 registers bound to source bytes.
|
||||
Each supported note has exactly one interpretation; each thread contains the
|
||||
complete 27-register ABI set, with names bound to their original ABI offsets.
|
||||
complete ABI register set for its architecture (27 registers for amd64,
|
||||
34 for AArch64), with names bound to their original ABI offsets.
|
||||
Physical note ranges cannot overlap or leave segment gaps. ELF and note headers
|
||||
are independently bound to the snapshot, including extended program counts.
|
||||
- Signed SIGINFO number/code/errno. Initially only SIGSEGV codes 1/2 establish the
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
# Plan
|
||||
|
||||
1. Check issue/timeline and complete open/closed PR searches; review #2406.
|
||||
2. Extend the existing core-module WABT inspection in an isolated worktree.
|
||||
3. Extend producer-owned inspection with optional typed interface facts;
|
||||
preserve unknown unsupported signatures and exact WAT forms.
|
||||
4. Add shared Evidence-only comparator and strict input/output contracts.
|
||||
5. Register additive CLI/MCP workflow and documentation.
|
||||
6. Validate original-module workflows, malformed/unknown/cancellation cases;
|
||||
run checks, independent review, repair findings and rerun affected checks.
|
||||
@@ -0,0 +1,61 @@
|
||||
# Core WebAssembly interface comparison (#2356)
|
||||
|
||||
## Outcome and scope
|
||||
|
||||
Compare two exact authenticated `inspect_wasm_artifact` Evidence inputs, inline
|
||||
or retained session references, under required `scope: imports | exports | both`.
|
||||
No module acquisition or execution occurs during comparison. This is a derived
|
||||
workflow within existing WABT inspection authority, not a new provider.
|
||||
|
||||
Producer-owned typed `interface` facts are additive and optional in historical
|
||||
inspection Evidence. WABT's validated canonical WAT owns decoding; exact original
|
||||
forms and names remain available. Comparison never reparses retained display text.
|
||||
Function/tag signatures resolve type uses; memory page size defaults to 65536 bytes and explicit custom page sizes are retained; table/memory limits and global types
|
||||
are compared exactly. Unsupported type/proposal forms remain unknown rather than
|
||||
being compared by raw text or inferred compatible. Names are exact decoded UTF-8,
|
||||
without normalization, including empty names, escapes, punctuation and BOM.
|
||||
|
||||
## Comparison contract
|
||||
|
||||
Return both Evidence IDs and original artifact path/digest/length. Group imports
|
||||
by exact module/name and exports by exact name, independently of kind, so a kind
|
||||
change is reported as changed. Unique admitted signatures yield added, removed,
|
||||
changed or unchanged. Duplicate keys retain every original occurrence and yield
|
||||
unknown; no Cartesian pairing or deduplication. Missing historical facets and
|
||||
incomplete inventories prevent absence claims. Unknown signatures do not erase
|
||||
independently observed entries. Ordering is deterministic by direction and names.
|
||||
|
||||
Both subject digest/path and inspection parameters must match normalized artifact
|
||||
identity. Authenticate Evidence before projection; summary Evidence must be
|
||||
resolved to its retained complete parent by the caller. Report unknown coverage
|
||||
and explicit limitations. Exact static equality is not runtime compatibility,
|
||||
ABI compatibility, linking success or source identity.
|
||||
|
||||
## Authority and overlap
|
||||
|
||||
Reads supplied Evidence only. MCP records derived Evidence through the existing
|
||||
handler/ledger; CLI accepts portable inline Evidence from an explicit JSON input.
|
||||
No new provider, implicit fetch, target mutation or foreign-code execution.
|
||||
PR #2406 (head 86865035) adds component inspection, preamble routing and embedded
|
||||
core-module inspection. Its component parser is not required or duplicated here;
|
||||
shared contract/CLI registration changes remain additive. Core GC/recursive types
|
||||
are outside admitted signature semantics and keep unknown mappings.
|
||||
|
||||
## Resource admission
|
||||
|
||||
Typed producer rows, names and declaration tokenization are admitted before
|
||||
allocation against the existing 32 MiB WABT output representation capacity.
|
||||
Comparison uses linear grouping and cooperative transport yields; it never
|
||||
builds Cartesian duplicate pairings. CLI JSON input retains the shared file
|
||||
admission gate. Token usage and peak process-family memory remain unmeasured.
|
||||
|
||||
## Verification
|
||||
|
||||
Original owned modules assembled using WABT 1.0.42 cover semantically equivalent
|
||||
encodings, changes of kind/signature, names/duplicates, imported index spaces,
|
||||
tables/memory/globals/tags, unknown types and historical incomplete Evidence.
|
||||
Exercise authenticated identity, cancellation, retained lookup, CLI/MCP parity
|
||||
and advertised schemas. Run focused existing WASM/Evidence tests, repository
|
||||
checks and docs validation; independent subagent review before handoff.
|
||||
|
||||
Format reference: https://webassembly.github.io/spec/core/binary/modules.html
|
||||
@@ -0,0 +1,8 @@
|
||||
# Tasks
|
||||
|
||||
- [x] Confirm scope and overlap boundaries against #2406.
|
||||
- [x] Record spec and implementation plan.
|
||||
- [x] Implement typed producer interface and shared comparison.
|
||||
- [x] Add CLI/MCP contracts and public workflow tests.
|
||||
- [x] Run focused, static and documentation checks.
|
||||
- [x] Resolve independent review findings and rerun relevant checks.
|
||||
@@ -0,0 +1,47 @@
|
||||
# Verification
|
||||
|
||||
Verified base: a2d8b94dd50dab8d966239dd63ab84718aa8b4b4 (fetched main).
|
||||
The change was reapplied without conflicts and relevant checks rerun.
|
||||
|
||||
- Focused existing/new parser, provider, service, contract and CLI/MCP tests:
|
||||
8 files / 56 tests passed.
|
||||
- After the final partial-uniqueness correction, reran service and compiled
|
||||
CLI/MCP boundary tests: 2 files / 5 tests passed.
|
||||
- `npm run check`: typecheck, lint, formatting, knip, module boundaries and
|
||||
architecture guards passed. Lint reports warnings; no errors.
|
||||
- `npm run docs:generate`, `npm run docs:check` and
|
||||
`npm run verify:test-discovery` passed; every test file was discovered exactly once.
|
||||
- Real WABT 1.0.42 comparison through compiled CLI and stdio MCP passed:
|
||||
equivalent padded LEB128 original encodings, function signatures, all admitted
|
||||
kinds, custom memory page size, escaped/BOM names, empty interfaces, explicit
|
||||
scope, duplicate imports and unadmitted exception reference signatures.
|
||||
- Existing real WABT artifact verifier passed after correcting its stale inline
|
||||
partial-observation assumption to resolve the MCP retained reference from the
|
||||
Evidence bundle and authenticate the original record.
|
||||
|
||||
## Independent review
|
||||
|
||||
Three findings repaired at their owning boundary:
|
||||
|
||||
1. Memory page size omitted: now preserved, default 65536 canonicalized.
|
||||
2. Shared signature expansion undercounted: charge every retained occurrence.
|
||||
Reviewer independently measured the 1000-params/10000-exports case: original
|
||||
262956-byte WAT produced 61824014-byte JSON before correction. It now retains
|
||||
1949 observations in a partial facet, with 12046676-byte JSON and an explicit
|
||||
capacity issue. Original WAT stays available; incomplete coverage prevents
|
||||
absence and uniqueness claims.
|
||||
3. Partial singleton uniqueness: comparison now returns unknown whenever either
|
||||
selected inventory is incomplete, including matched names.
|
||||
|
||||
Final independent source review reported no actionable findings. Review does not
|
||||
replace the executed tests or real-tool evidence.
|
||||
|
||||
## Scope and measurement limits
|
||||
|
||||
On the original implementation base f254f5a, the complete comparison verifier reported 7.98 seconds elapsed, 6.56 user and
|
||||
3.13 system seconds, and 429441024 bytes maximum RSS via macOS `/usr/bin/time -l`.
|
||||
These are command-accounting measurements, not aggregate concurrent process-family
|
||||
memory or model tokens. Token usage and full process-family peak are unmeasured.
|
||||
Real-tool verification covers macOS arm64 only; Linux/Windows remain unverified.
|
||||
No module was instantiated/executed and no source was fetched. Static equality
|
||||
is not runtime compatibility. Full CI is separate from this local verification record.
|
||||
@@ -646,6 +646,12 @@ records independent symbol/directory/SHA oracles; it never executes the target.
|
||||
Linux also builds stripped, ordinary-native and small malformed/ambiguous/layout
|
||||
negative inputs. The optional Windows fixture workflow builds a PE on a Windows
|
||||
runner; analyzing that PE on Linux does not verify a Windows Ghidra host.
|
||||
On a local Windows host, the .NET 8 ILCompiler finds the linker only through
|
||||
Visual Studio 2019 or 2022 (`vswhere -version [16,18)`). With only Visual
|
||||
Studio Build Tools 2026 installed, open its x64 Developer Command Prompt.
|
||||
In that same `cmd.exe` session, run `set IlcUseEnvironmentalTools=true` and
|
||||
then `npm run build:fixtures:nativeaot`. Running `vcvars64.bat` as a child of
|
||||
PowerShell does not preserve its environment in the parent PowerShell session.
|
||||
|
||||
Build the clean pinned upstream adapter with `build:ghidra:nativeaot`, then set
|
||||
`REA_GHIDRA_NATIVEAOT_JAR`. Run `verify:ghidra:nativeaot -- symbols`, `-- stripped`,
|
||||
|
||||
@@ -13,6 +13,11 @@ Check `binary_session` for configured capability availability. A recognized
|
||||
header is not validation of the complete module; missing WABT is reported
|
||||
separately as an unavailable capability, not as missing native engine support.
|
||||
|
||||
The recognized WASM header is checked before automatic database routing from a
|
||||
.hop filename. Renaming a module to .hop therefore keeps the static-inspection
|
||||
diagnostic. An explicit database selection or raw/COM format selection retains
|
||||
the caller's requested interpretation; ordinary .hop database routing is unchanged.
|
||||
|
||||
Set `REA_WABT_BIN_DIRECTORY` to an absolute directory containing executable
|
||||
`wasm-validate`, `wasm-objdump` and `wasm2wat` (with `.exe` on Windows). Missing
|
||||
executables, unsupported banners and inaccessible tools produce actionable
|
||||
@@ -79,3 +84,46 @@ Both CLI and MCP use the same typed port and application service. MCP publishes
|
||||
SDK-compatible schemas and records Evidence for `get_evidence_bundle` readback.
|
||||
See [WABT provenance](https://github.com/morluto/rea/blob/main/third_party/wabt/README.md) and
|
||||
[testing](testing.md#offline-wasm-artifacts) for real-tool verification.
|
||||
|
||||
## Compare core-module interfaces
|
||||
|
||||
Inspection adds producer-owned typed `interface` facts while keeping exact WAT
|
||||
import/export forms. Function/tag signatures resolve type indices; table/memory
|
||||
limits, memory page size and global value type/mutability remain explicit.
|
||||
Unadmitted types (including GC reference signatures) retain null signatures and
|
||||
issues. Exact decoded names retain escapes, empty names, BOM and punctuation.
|
||||
This facet is absent in historical Evidence that did not examine typed interfaces.
|
||||
|
||||
`compare_wasm_interfaces` accepts two complete `inspect_wasm_artifact` Evidence
|
||||
records, or exact retained references in the current MCP session, and required
|
||||
`scope: "imports" | "exports" | "both"`. It reads no files and needs no active
|
||||
WABT toolchain. CLI uses portable inline Evidence in one JSON document:
|
||||
|
||||
```bash
|
||||
rea compare-wasm-interfaces comparison-input.json --json
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"left": { "kind": "retained-evidence", "evidence_id": "ev_<left-digest>" },
|
||||
"right": { "kind": "retained-evidence", "evidence_id": "ev_<right-digest>" },
|
||||
"scope": "both"
|
||||
}
|
||||
```
|
||||
|
||||
The references above are MCP-only; put complete Evidence objects in the CLI
|
||||
input. Resolve summary delivery to its retained complete parent before comparing.
|
||||
Results retain both input Evidence IDs, artifact paths/digests/lengths and every
|
||||
selected occurrence. Unique names are added, removed, changed or unchanged;
|
||||
duplicate names and unsupported signatures are unknown. Incomplete or historical
|
||||
inventories never establish absence. Imports group by module/name, exports by
|
||||
name, so a change of kind is visible. Output ordering is deterministic.
|
||||
|
||||
Static type equality does not establish runtime compatibility, ABI compatibility
|
||||
or linking success. Comparison covers core modules; WebAssembly components and
|
||||
canonical ABI relationships belong to their separate inspection workflow.
|
||||
|
||||
After building, run `REA_WABT_BIN_DIRECTORY=/absolute/wabt/bin node
|
||||
scripts/verify-wasm-interface-comparison.mjs` for original-module comparison
|
||||
through the compiled CLI and stdio MCP. It uses owned fixtures and WABT 1.0.42;
|
||||
no WASM module is instantiated or executed.
|
||||
|
||||
@@ -29,8 +29,10 @@ admitted raw NMOS 6502 mapping on Windows. Windows P0 admission remains limited
|
||||
to native PE and raw NMOS 6502 images. For x64 PE
|
||||
NativeAOT RTR 9.1, the read-only snapshot parser can report metadata through
|
||||
`inspect_native_load_image` and `inspect_native_data_type` without a JAR or
|
||||
database mutation. A real Windows-host NativeAOT Ghidra run is still required
|
||||
to establish the complete host workflow.
|
||||
database mutation: MethodTable relationships, virtual slots and frozen strings.
|
||||
`analyze_function` decompiles slot targets. Database reads of the rehydrated
|
||||
bytes (`read_bytes`, `search_strings`) return nothing on this host. See
|
||||
[NativeAOT](ghidra-nativeaot.md).
|
||||
|
||||
This boundary does not establish general Windows feature parity. Ghidra GUI
|
||||
state, annotations, other target architectures and formats, and Hopper-only
|
||||
|
||||
@@ -269,6 +269,7 @@
|
||||
"verify:binary:layout": "npm run build:cached && node scripts/verify-binary-layout.mjs",
|
||||
"verify:go:binary": "npm run build:cached && node scripts/verify-go-binary.mjs",
|
||||
"verify:wasm:package": "npm run build:cached && node scripts/verify-packaged-wasm-artifact.mjs",
|
||||
"verify:wasm-interface-comparison": "npm run build:cached && node scripts/verify-wasm-interface-comparison.mjs",
|
||||
"verify:wasm:artifact": "npm run build:cached && node scripts/verify-wasm-artifact.mjs",
|
||||
"verify:evm:interface": "npm run build:cached && node scripts/verify-evm-interface.mjs",
|
||||
"verify:solidity:metadata": "npm run build:cached && node scripts/verify-solidity-metadata.mjs",
|
||||
|
||||
@@ -213,7 +213,9 @@ export const providerCatalog = (sources) => {
|
||||
},
|
||||
{
|
||||
identity: sources.wasmProvider.WABT_PROVIDER_IDENTITY,
|
||||
contracts: sources.wasmContracts.WASM_TOOL_CONTRACTS,
|
||||
contracts: sources.wasmContracts.WASM_TOOL_CONTRACTS.filter(
|
||||
({ kind }) => kind === "artifact-provider",
|
||||
),
|
||||
},
|
||||
{
|
||||
identity: sources.evmProvider.EVMOLE_PROVIDER_IDENTITY,
|
||||
|
||||
@@ -310,7 +310,23 @@ try {
|
||||
}),
|
||||
);
|
||||
assert.equal(error.category, "invalid_input");
|
||||
const failed = parseEvidence(error.details.partial_observation);
|
||||
const partial = error.details.partial_observation;
|
||||
assert.equal(partial.kind, "retained-evidence");
|
||||
const failedBundleReply = await client.callTool({
|
||||
name: "get_evidence_bundle",
|
||||
arguments: {},
|
||||
});
|
||||
assert.notEqual(
|
||||
failedBundleReply.isError,
|
||||
true,
|
||||
mcpTextValue(failedBundleReply),
|
||||
);
|
||||
const failedBundle = JSON.parse(mcpTextValue(failedBundleReply)).result;
|
||||
const failed = parseEvidence(
|
||||
failedBundle.records.find(
|
||||
({ evidence_id }) => evidence_id === partial.evidence_id,
|
||||
),
|
||||
);
|
||||
assert.equal(failed.subject.digest.sha256, sha256(invalidBytes));
|
||||
assert.equal(
|
||||
failed.normalized_result.validation,
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFile } from "node:child_process";
|
||||
import { mkdtemp, readFile, writeFile, rm } from "node:fs/promises";
|
||||
import { join, resolve, isAbsolute } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { promisify } from "node:util";
|
||||
import { Client } from "@modelcontextprotocol/client";
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
|
||||
import { parseEvidence } from "../dist/domain/evidence.js";
|
||||
|
||||
const execute = promisify(execFile);
|
||||
const bin = process.env.REA_WABT_BIN_DIRECTORY;
|
||||
if (!isAbsolute(bin ?? ""))
|
||||
throw new Error("Supply absolute REA_WABT_BIN_DIRECTORY with WABT 1.0.42");
|
||||
const root = await mkdtemp(join(tmpdir(), "rea-wasm-interface-real-"));
|
||||
const cli = resolve("scripts/rea.mjs");
|
||||
const environment = { ...process.env };
|
||||
const command = async (file, args) =>
|
||||
(
|
||||
await execute(file, args, {
|
||||
env: environment,
|
||||
cwd: root,
|
||||
timeout: 120000,
|
||||
maxBuffer: 64 * 1024 * 1024,
|
||||
})
|
||||
).stdout;
|
||||
const client = new Client({
|
||||
name: "wasm-interface-real-verifier",
|
||||
version: "1",
|
||||
});
|
||||
const transport = new StdioClientTransport({
|
||||
command: process.execPath,
|
||||
args: [cli, "mcp"],
|
||||
env: environment,
|
||||
stderr: "pipe",
|
||||
});
|
||||
const source = (parameter) =>
|
||||
`(module (type (func (param ${parameter}) (result i64))) (import "env" "f" (func (type 0))) (table 1 3 funcref) (memory 1 2) (global (mut i64) (i64.const 0)) (tag (param i32)) (export "run\\22\\ef\\bb\\bf" (func 0)) (export "table" (table 0)) (export "memory" (memory 0)) (export "global" (global 0)) (export "tag" (tag 0)))`;
|
||||
const assemble = async (name, wat) => {
|
||||
await writeFile(join(root, `${name}.wat`), wat);
|
||||
const path = join(root, `${name}.wasm`);
|
||||
await command(join(bin, "wat2wasm"), [
|
||||
"--enable-all",
|
||||
`${name}.wat`,
|
||||
"-o",
|
||||
path,
|
||||
]);
|
||||
return path;
|
||||
};
|
||||
const inspect = async (path) => {
|
||||
const result = await client.callTool({
|
||||
name: "inspect_wasm_artifact",
|
||||
arguments: { path },
|
||||
});
|
||||
assert.notEqual(result.isError, true, JSON.stringify(result));
|
||||
return parseEvidence(result.structuredContent);
|
||||
};
|
||||
const compare = async (left, right, scope = "both") => {
|
||||
const input = {
|
||||
left: { kind: "retained-evidence", evidence_id: left.evidence_id },
|
||||
right: { kind: "retained-evidence", evidence_id: right.evidence_id },
|
||||
scope,
|
||||
};
|
||||
const reply = await client.callTool({
|
||||
name: "compare_wasm_interfaces",
|
||||
arguments: input,
|
||||
});
|
||||
assert.notEqual(reply.isError, true, JSON.stringify(reply));
|
||||
const mcp = parseEvidence(reply.structuredContent);
|
||||
const file = join(root, "comparison.json");
|
||||
await writeFile(file, JSON.stringify({ left, right, scope }));
|
||||
const local = parseEvidence(
|
||||
JSON.parse(
|
||||
await command(process.execPath, [
|
||||
cli,
|
||||
"compare-wasm-interfaces",
|
||||
file,
|
||||
"--json",
|
||||
]),
|
||||
),
|
||||
);
|
||||
assert.deepEqual(local, mcp);
|
||||
return mcp.normalized_result;
|
||||
};
|
||||
try {
|
||||
assert.equal(
|
||||
(await command(join(bin, "wat2wasm"), ["--version"])).trim(),
|
||||
"1.0.42",
|
||||
);
|
||||
await client.connect(transport);
|
||||
const path = await assemble("baseline", source("i32"));
|
||||
const original = await readFile(path);
|
||||
assert.equal(original[8], 1);
|
||||
assert.ok(original[9] < 128);
|
||||
// Section lengths allow padded u32 LEB128 encodings. Different original
|
||||
// byte identities must compare equal after validated type resolution.
|
||||
const equivalentPath = join(root, "equivalent.wasm");
|
||||
await writeFile(
|
||||
equivalentPath,
|
||||
Buffer.concat([
|
||||
original.subarray(0, 9),
|
||||
Buffer.from([original[9] | 128, 0]),
|
||||
original.subarray(10),
|
||||
]),
|
||||
);
|
||||
const left = await inspect(path);
|
||||
const equivalent = await inspect(equivalentPath);
|
||||
assert.notEqual(left.subject.digest.sha256, equivalent.subject.digest.sha256);
|
||||
const same = await compare(left, equivalent);
|
||||
assert.deepEqual(same.summary, {
|
||||
added: 0,
|
||||
removed: 0,
|
||||
changed: 0,
|
||||
unchanged: 6,
|
||||
unknown: 0,
|
||||
});
|
||||
const facts = left.normalized_result.interface.entries;
|
||||
assert.deepEqual(facts.find((e) => e.name === "memory").signature, {
|
||||
kind: "memory",
|
||||
page_size: "65536",
|
||||
address_type: "i32",
|
||||
minimum: "1",
|
||||
maximum: "2",
|
||||
shared: false,
|
||||
});
|
||||
assert.deepEqual(facts.find((e) => e.name === "tag").signature, {
|
||||
kind: "tag",
|
||||
params: ["i32"],
|
||||
results: [],
|
||||
});
|
||||
assert.deepEqual(facts.find((e) => e.name === 'run"\ufeff').signature, {
|
||||
kind: "func",
|
||||
params: ["i32"],
|
||||
results: ["i64"],
|
||||
});
|
||||
const changed = await inspect(await assemble("changed", source("i64")));
|
||||
assert.deepEqual((await compare(left, changed)).summary, {
|
||||
added: 0,
|
||||
removed: 0,
|
||||
changed: 2,
|
||||
unchanged: 4,
|
||||
unknown: 0,
|
||||
});
|
||||
const smallPage = await inspect(
|
||||
await assemble(
|
||||
"small-page",
|
||||
'(module (memory 1 2 (pagesize 1)) (export "memory" (memory 0)))',
|
||||
),
|
||||
);
|
||||
const defaultPage = await inspect(
|
||||
await assemble(
|
||||
"default-page",
|
||||
'(module (memory 1 2) (export "memory" (memory 0)))',
|
||||
),
|
||||
);
|
||||
assert.equal((await compare(defaultPage, smallPage)).summary.changed, 1);
|
||||
const empty = await inspect(await assemble("empty", "(module)"));
|
||||
assert.equal((await compare(left, empty)).summary.removed, 6);
|
||||
assert.equal((await compare(empty, left, "exports")).summary.added, 5);
|
||||
const duplicate = await inspect(
|
||||
await assemble(
|
||||
"duplicate",
|
||||
'(module (import "e" "same" (func (param i32))) (import "e" "same" (func (param i64))))',
|
||||
),
|
||||
);
|
||||
const duplicates = await compare(duplicate, duplicate, "imports");
|
||||
assert.equal(duplicates.summary.unknown, 1);
|
||||
assert.equal(duplicates.changes[0].left.length, 2);
|
||||
const unsupported = await inspect(
|
||||
await assemble(
|
||||
"exnref",
|
||||
'(module (import "e" "abstract-ref" (global exnref)))',
|
||||
),
|
||||
);
|
||||
assert.equal((await compare(unsupported, unsupported)).summary.unknown, 1);
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
verified: true,
|
||||
toolchain: "WABT 1.0.42",
|
||||
transport: "compiled CLI + stdio MCP",
|
||||
interface_fact_bytes: Buffer.byteLength(
|
||||
JSON.stringify(left.normalized_result.interface),
|
||||
),
|
||||
comparison_result_bytes: Buffer.byteLength(JSON.stringify(same)),
|
||||
original_sha256: left.subject.digest.sha256,
|
||||
equivalent_sha256: equivalent.subject.digest.sha256,
|
||||
cases: [
|
||||
"equivalent padded LEB128",
|
||||
"function signature change",
|
||||
"all admitted kinds",
|
||||
"custom memory page-size change",
|
||||
"escaped/BOM name",
|
||||
"empty interface additions/removals",
|
||||
"scope",
|
||||
"duplicate imports",
|
||||
"unsupported exception reference",
|
||||
],
|
||||
runtime_execution: "not-performed",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
await client.close();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
@@ -1127,6 +1127,68 @@ exit 1`,
|
||||
expect(projected.message).toContain("not installed for 0");
|
||||
});
|
||||
|
||||
// adb exits 1 on a refused package change: `adb install` after printing the
|
||||
// status line to stderr, `cmd package uninstall` after printing it to stdout.
|
||||
it("preserves the device's install refusal when adb exits nonzero", async () => {
|
||||
const { binary, root } = await writeAdbStub(
|
||||
() => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
|
||||
if [ "$1" = "-s" ] && [ "$3" = "install" ]; then
|
||||
printf 'Performing Streamed Install\\n'
|
||||
printf 'adb: failed to install app.apk: Failure [INSTALL_FAILED_ALREADY_EXISTS: Attempt to re-install com.example without first uninstalling.]\\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
exit 1`,
|
||||
);
|
||||
const apk = join(root, "app.apk");
|
||||
await writeFile(apk, "apk");
|
||||
const projected = await refusedBy(providerOver(binary), {
|
||||
operation: "install_adb_package",
|
||||
input: { serial: "emulator-5554", apk_path: apk, replace: false },
|
||||
});
|
||||
expect(projected).toMatchObject({
|
||||
code: "unsupported_target",
|
||||
details: {
|
||||
reason:
|
||||
"INSTALL_FAILED_ALREADY_EXISTS: Attempt to re-install com.example without first uninstalling.",
|
||||
},
|
||||
});
|
||||
expect(projected.message).toContain(
|
||||
"INSTALL_FAILED_ALREADY_EXISTS: Attempt to re-install com.example without first uninstalling.",
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves the device's uninstall refusal when adb exits nonzero", async () => {
|
||||
const { binary } = await writeAdbStub(
|
||||
() => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
|
||||
if [ "$1" = "-s" ] && [ "$3" = "uninstall" ]; then printf 'Failure [not installed for 0]\\n'; exit 1; fi
|
||||
exit 1`,
|
||||
);
|
||||
const projected = await refusedBy(providerOver(binary), {
|
||||
operation: "uninstall_adb_package",
|
||||
input: { serial: "emulator-5554", package: "com.absent" },
|
||||
});
|
||||
expect(projected).toMatchObject({
|
||||
code: "unsupported_target",
|
||||
details: { reason: "not installed for 0" },
|
||||
});
|
||||
expect(projected.message).toContain("not installed for 0");
|
||||
});
|
||||
|
||||
it("keeps a nonzero install without a device verdict as an unreadable result", async () => {
|
||||
const { binary, root } = await writeAdbStub(
|
||||
() => `if [ "$1" = "version" ]; then ${VERSION_BLOCK}; exit 0; fi
|
||||
if [ "$1" = "-s" ] && [ "$3" = "install" ]; then printf 'adb: failed to install app.apk: closed\\n' >&2; exit 1; fi
|
||||
exit 1`,
|
||||
);
|
||||
const apk = join(root, "app.apk");
|
||||
await writeFile(apk, "apk");
|
||||
const projected = await refusedBy(providerOver(binary), {
|
||||
operation: "install_adb_package",
|
||||
input: { serial: "emulator-5554", apk_path: apk, replace: false },
|
||||
});
|
||||
expect(projected).toMatchObject({ code: "unreadable_output" });
|
||||
});
|
||||
|
||||
it("starts an app through resolved launcher component and force-stops it", async () => {
|
||||
const { binary } = await writeAdbStub(
|
||||
shellStub({
|
||||
|
||||
+36
-2
@@ -71,6 +71,7 @@ import {
|
||||
parseBugreportCopiedPath,
|
||||
parseDumpsysPackageOutput,
|
||||
parseFeatureListOutput,
|
||||
packageChangeFailureReason,
|
||||
parsePackageChangeOutput,
|
||||
parseProcessListOutput,
|
||||
parsePngDimensions,
|
||||
@@ -248,6 +249,31 @@ const runAdb = async (
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* adb exits nonzero when the device refuses a package change, printing the
|
||||
* `Failure [...]` verdict to stderr (install) or stdout (uninstall). Read the
|
||||
* refusal from that failed run so it is not reported as unreadable output.
|
||||
*/
|
||||
const packageChangeRefusal = (
|
||||
operation: string,
|
||||
serial: string,
|
||||
error: AnalysisError,
|
||||
): Result<never, AnalysisError> | undefined => {
|
||||
if (!(error instanceof AnalysisProtocolError)) return undefined;
|
||||
const failure = execFileOutputFailure(error.cause);
|
||||
if (failure === undefined) return undefined;
|
||||
const reason = packageChangeFailureReason(
|
||||
`${failure.stdout}\n${failure.stderr}`,
|
||||
);
|
||||
return reason === null
|
||||
? undefined
|
||||
: err(
|
||||
new AnalysisUnsupportedTargetError(operation, serial, reason, {
|
||||
cause: error,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const clientIdentityResult = (
|
||||
selection: AdbBinarySelection,
|
||||
identity: AdbClientIdentity,
|
||||
@@ -1754,7 +1780,11 @@ export class AdbProvider {
|
||||
{ timeoutMs: PULL_TIMEOUT_MS, maxOutputBytes: PULL_MAX_OUTPUT_BYTES },
|
||||
signal,
|
||||
);
|
||||
if (!installed.ok) return installed;
|
||||
if (!installed.ok)
|
||||
return (
|
||||
packageChangeRefusal(operation, input.serial, installed.error) ??
|
||||
installed
|
||||
);
|
||||
const verdict = parsePackageChangeOutput(installed.value.stdout);
|
||||
if (verdict.status === "failure")
|
||||
return err(
|
||||
@@ -1803,7 +1833,11 @@ export class AdbProvider {
|
||||
},
|
||||
signal,
|
||||
);
|
||||
if (!uninstalled.ok) return uninstalled;
|
||||
if (!uninstalled.ok)
|
||||
return (
|
||||
packageChangeRefusal(operation, input.serial, uninstalled.error) ??
|
||||
uninstalled
|
||||
);
|
||||
const verdict = parsePackageChangeOutput(uninstalled.value.stdout);
|
||||
if (verdict.status === "failure")
|
||||
return err(
|
||||
|
||||
@@ -293,6 +293,10 @@ export const parseBugreportCopiedPath = (stdout: string): string | null => {
|
||||
return match === null ? null : match[1]!;
|
||||
};
|
||||
|
||||
/** Read the device's `Failure [REASON]` verdict from package-change output. */
|
||||
export const packageChangeFailureReason = (output: string): string | null =>
|
||||
/Failure \[([^\]]+)\]/u.exec(output)?.[1] ?? null;
|
||||
|
||||
/**
|
||||
* Parse `adb install`/`adb uninstall` output. adb can print `Failure [...]`
|
||||
* with a zero exit code, so the text decides, not the exit status.
|
||||
@@ -305,10 +309,11 @@ export const parsePackageChangeOutput = (
|
||||
} => {
|
||||
const text = stdout.trim();
|
||||
if (/^Success$/mu.test(text)) return { status: "success", reason: null };
|
||||
const failure = /Failure \[([^\]]+)\]/u.exec(text);
|
||||
return {
|
||||
status: "failure",
|
||||
reason: failure?.[1] ?? (text === "" ? null : text.slice(0, 512)),
|
||||
reason:
|
||||
packageChangeFailureReason(text) ??
|
||||
(text === "" ? null : text.slice(0, 512)),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -162,6 +162,12 @@ export const parseBinaryTarget = async (
|
||||
availableArchitectures: ["x86"],
|
||||
});
|
||||
}
|
||||
// Probe WASM content before suffix-only database routing; an explicit
|
||||
// database selection keeps the caller's chosen interpretation.
|
||||
const artifactFormat =
|
||||
targetKind === "database"
|
||||
? undefined
|
||||
: await detectArtifactFormat(path, handle, signal);
|
||||
if (
|
||||
targetKind === "database" ||
|
||||
(targetKind === undefined && path.toLowerCase().endsWith(".hop"))
|
||||
@@ -173,7 +179,6 @@ export const parseBinaryTarget = async (
|
||||
kind: "database",
|
||||
format: "analysis-database",
|
||||
});
|
||||
const artifactFormat = await detectArtifactFormat(path, handle, signal);
|
||||
if (artifactFormat !== undefined) {
|
||||
const identity = {
|
||||
path,
|
||||
|
||||
@@ -30,6 +30,29 @@ it("labels an envelope containing debugger-derived maps as derived", async () =>
|
||||
expect(evidence.raw_result).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["x86_64-little-endian", "x86_64"],
|
||||
["aarch64-little-endian", "arm64"],
|
||||
] as const)(
|
||||
"names the CPU family carried by a %s core on created Evidence",
|
||||
async (architecture, expected) => {
|
||||
const fixture = recordedCrashFixture("/selected.core", architecture);
|
||||
const service = new RecordedCrashService({
|
||||
inspectAndClose(input, options) {
|
||||
return this.inspect(input, options);
|
||||
},
|
||||
identity: RECORDED_CRASH_TEST_PROVIDER,
|
||||
inspect: () => Promise.resolve(ok(fixture)),
|
||||
});
|
||||
const result = await service.inspect({
|
||||
path: fixture.artifact.path,
|
||||
include_debugger_context: false,
|
||||
});
|
||||
if (!result.ok) throw result.error;
|
||||
expect(parseEvidence(result.value).subject?.architecture).toBe(expected);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{ path: "relative.elf" },
|
||||
{ path: "/selected.core", approval: true },
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { createEvidence, type Evidence } from "../../domain/evidence.js";
|
||||
import {
|
||||
inspectRecordedCrashInputSchema,
|
||||
RECORDED_LINUX_CORE_PROFILES,
|
||||
recordedCrashSchema,
|
||||
} from "../../domain/native/recordedCrash.js";
|
||||
import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js";
|
||||
@@ -101,7 +102,9 @@ export class RecordedCrashService {
|
||||
{
|
||||
path: value.artifact.path,
|
||||
format: "elf",
|
||||
architecture: "x86_64",
|
||||
architecture:
|
||||
RECORDED_LINUX_CORE_PROFILES[value.architecture]
|
||||
.evidenceArchitecture,
|
||||
sha256: value.artifact.sha256,
|
||||
},
|
||||
this.provider.identity,
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { createEvidence } from "../../domain/evidence.js";
|
||||
import { wasmInterfaceComparisonSchema } from "../../contracts/wasm/wasmComparisonContracts.js";
|
||||
import { compareWasmInterfaces } from "./WasmInterfaceComparisonService.js";
|
||||
import type { WasmInterface } from "../../domain/wasm/wasmInterface.js";
|
||||
import { wasmArtifactFixture } from "../../../tests/fixtures/wasm/artifact.js";
|
||||
|
||||
const facts = (
|
||||
name: string,
|
||||
parameter: "i32" | "i64" = "i32",
|
||||
): WasmInterface => ({
|
||||
state: "complete",
|
||||
issues: [],
|
||||
entries: [
|
||||
{
|
||||
direction: "export",
|
||||
module: null,
|
||||
name,
|
||||
kind: "func",
|
||||
index: 0,
|
||||
signature: { kind: "func", params: [parameter], results: [] },
|
||||
wat_form: `(export "${name}" (func 0))`,
|
||||
issue: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
const evidence = (interfaceFacts?: WasmInterface) => {
|
||||
const report = wasmArtifactFixture();
|
||||
if (interfaceFacts !== undefined) report.interface = interfaceFacts;
|
||||
return createEvidence(
|
||||
{
|
||||
path: report.artifact.path,
|
||||
sha256: report.artifact.sha256,
|
||||
format: "file",
|
||||
},
|
||||
{ id: "fixture", name: "typed fixture", version: "1" },
|
||||
{
|
||||
operation: "inspect_wasm_artifact",
|
||||
parameters: { path: report.artifact.path },
|
||||
result: report,
|
||||
confidence: "observed",
|
||||
},
|
||||
);
|
||||
};
|
||||
const compare = async (
|
||||
left: ReturnType<typeof evidence>,
|
||||
right: ReturnType<typeof evidence>,
|
||||
) => {
|
||||
const result = await compareWasmInterfaces({ left, right, scope: "both" });
|
||||
if (!result.ok) throw result.error;
|
||||
return wasmInterfaceComparisonSchema.parse(result.value.normalized_result);
|
||||
};
|
||||
|
||||
it("reports all five comparison states, including duplicate ambiguity and explicit selected scope", async () => {
|
||||
const left = facts("changed");
|
||||
const right = facts("changed", "i64");
|
||||
left.entries.push(
|
||||
...facts("unchanged").entries,
|
||||
...facts("removed").entries,
|
||||
...facts("dup").entries,
|
||||
...facts("dup").entries,
|
||||
);
|
||||
right.entries.push(
|
||||
...facts("unchanged").entries,
|
||||
...facts("added").entries,
|
||||
...facts("dup").entries,
|
||||
);
|
||||
const report = await compare(evidence(left), evidence(right));
|
||||
expect(report.summary).toEqual({
|
||||
changed: 1,
|
||||
unchanged: 1,
|
||||
removed: 1,
|
||||
added: 1,
|
||||
unknown: 1,
|
||||
});
|
||||
expect(report.changes.find((c) => c.name === "dup")).toMatchObject({
|
||||
state: "unknown",
|
||||
left: [expect.anything(), expect.anything()],
|
||||
right: [expect.anything()],
|
||||
});
|
||||
const selected = await compareWasmInterfaces({
|
||||
left: evidence(left),
|
||||
right: evidence(right),
|
||||
scope: "imports",
|
||||
});
|
||||
if (!selected.ok) throw selected.error;
|
||||
expect(selected.value.normalized_result).toMatchObject({
|
||||
changes: [],
|
||||
coverage: "complete-within-inputs",
|
||||
});
|
||||
});
|
||||
|
||||
it("withholds absence claims for historical or partial inventories and unsupported signatures", async () => {
|
||||
const known = facts("f");
|
||||
const absent = await compare(evidence(), evidence(known));
|
||||
expect(absent).toMatchObject({
|
||||
coverage: "partial",
|
||||
changes: [{ state: "unknown" }],
|
||||
});
|
||||
const partial: WasmInterface = {
|
||||
state: "partial",
|
||||
issues: ["unread import table"],
|
||||
entries: [],
|
||||
};
|
||||
expect(await compare(evidence(partial), evidence(known))).toMatchObject({
|
||||
changes: [{ state: "unknown" }],
|
||||
});
|
||||
const unsupported = facts("f");
|
||||
unsupported.entries[0]!.signature = null;
|
||||
expect(await compare(evidence(known), evidence(unsupported))).toMatchObject({
|
||||
changes: [{ state: "unknown" }],
|
||||
});
|
||||
const partialMatch = facts("f");
|
||||
partialMatch.state = "partial";
|
||||
partialMatch.issues = ["Later imports were not examined"];
|
||||
expect(await compare(evidence(partialMatch), evidence(known))).toMatchObject({
|
||||
changes: [{ state: "unknown" }],
|
||||
});
|
||||
expect(await compare(evidence(), evidence())).toMatchObject({
|
||||
coverage: "unknown",
|
||||
changes: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("authenticates semantic Evidence and binds display subject and selection identity", async () => {
|
||||
const left = JSON.parse(JSON.stringify(evidence(facts("f"))));
|
||||
left.normalized_result.interface.entries[0].name = "tampered";
|
||||
expect(
|
||||
await compareWasmInterfaces({
|
||||
left,
|
||||
right: evidence(facts("f")),
|
||||
scope: "both",
|
||||
}),
|
||||
).toMatchObject({ ok: false, error: { _tag: "AnalysisInputError" } });
|
||||
const rebound = JSON.parse(JSON.stringify(evidence(facts("f"))));
|
||||
rebound.subject.local_path = "/other.wasm";
|
||||
expect(
|
||||
await compareWasmInterfaces({
|
||||
left: rebound,
|
||||
right: evidence(facts("f")),
|
||||
scope: "both",
|
||||
}),
|
||||
).toMatchObject({ ok: false, error: { _tag: "AnalysisInputError" } });
|
||||
});
|
||||
|
||||
it("resolves exact retained references and permits cancellation between comparison batches", async () => {
|
||||
const left = evidence(facts("f"));
|
||||
const right = evidence(facts("f", "i64"));
|
||||
const references = {
|
||||
left: { kind: "retained-evidence", evidence_id: left.evidence_id },
|
||||
right: { kind: "retained-evidence", evidence_id: right.evidence_id },
|
||||
scope: "both",
|
||||
};
|
||||
expect(await compareWasmInterfaces(references)).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "EvidenceIntegrityError" },
|
||||
});
|
||||
const result = await compareWasmInterfaces(references, {}, (id) =>
|
||||
id === left.evidence_id ? left : right,
|
||||
);
|
||||
if (!result.ok) throw result.error;
|
||||
expect(result.value.evidence_links).toEqual([
|
||||
left.evidence_id,
|
||||
right.evidence_id,
|
||||
]);
|
||||
const large = facts("f");
|
||||
large.entries.push(
|
||||
...Array.from({ length: 2000 }, (_, i) => facts(`f${i}`).entries[0]!),
|
||||
);
|
||||
const controller = new AbortController();
|
||||
const pending = compareWasmInterfaces(
|
||||
{ left: evidence(large), right: evidence(large), scope: "both" },
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
setImmediate(() => controller.abort());
|
||||
expect(await pending).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "AnalysisCancelledError" },
|
||||
});
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
"keeps a one-sided unsupported interface unknown with reverse=%s",
|
||||
async (reverse) => {
|
||||
const empty: WasmInterface = { state: "complete", issues: [], entries: [] };
|
||||
const unsupported = facts("exception");
|
||||
unsupported.entries[0]!.signature = null;
|
||||
unsupported.entries[0]!.issue = "Unsupported exnref global type";
|
||||
const sides = reverse ? [unsupported, empty] : [empty, unsupported];
|
||||
expect(await compare(evidence(sides[0]), evidence(sides[1]))).toMatchObject(
|
||||
{
|
||||
coverage: "partial",
|
||||
summary: { added: 0, removed: 0, unknown: 1 },
|
||||
changes: [{ state: "unknown" }],
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,250 @@
|
||||
import {
|
||||
WASM_INTERFACE_REPRESENTATION_BYTES,
|
||||
wasmInterfaceEntryRepresentationBytes,
|
||||
} from "../../domain/wasm/wasmInterface.js";
|
||||
import { setImmediate } from "node:timers/promises";
|
||||
import {
|
||||
compareWasmInterfacesInputSchema,
|
||||
wasmInterfaceComparisonSchema,
|
||||
} from "../../contracts/wasm/wasmComparisonContracts.js";
|
||||
import {
|
||||
createEvidence,
|
||||
parseEvidence,
|
||||
type Evidence,
|
||||
} from "../../domain/evidence.js";
|
||||
import {
|
||||
wasmArtifactSchema,
|
||||
type WasmArtifact,
|
||||
} from "../../domain/wasm/wasmArtifact.js";
|
||||
import type { WasmInterfaceEntry } from "../../domain/wasm/wasmInterface.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisInputError,
|
||||
AnalysisResourceConstraintError,
|
||||
} from "../../domain/analysisErrorCore.js";
|
||||
import { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import { analysisInputErrorFromIssues } from "../../domain/inputIssueProjection.js";
|
||||
import { err, ok, type Result } from "../../domain/result.js";
|
||||
import { ARTIFACT_COMPARISON_PROVIDER } from "../InvestigationProviders.js";
|
||||
import {
|
||||
resolvePairedEvidenceRequest,
|
||||
type EvidenceLookup,
|
||||
} from "../EvidenceInputResolver.js";
|
||||
import type { ExecutionOptions } from "../AnalysisProvider.js";
|
||||
import type { z } from "zod";
|
||||
|
||||
const OPERATION = "compare_wasm_interfaces";
|
||||
const parseInspection = (
|
||||
raw: unknown,
|
||||
): { evidence: Evidence; result: WasmArtifact } => {
|
||||
const evidence = parseEvidence(raw);
|
||||
if (evidence.operation !== "inspect_wasm_artifact")
|
||||
throw new TypeError("Expected complete inspect_wasm_artifact Evidence");
|
||||
const result = wasmArtifactSchema.parse(evidence.normalized_result);
|
||||
if (
|
||||
evidence.subject?.digest.sha256 !== result.artifact.sha256 ||
|
||||
evidence.subject.local_path !== result.artifact.path ||
|
||||
evidence.parameters.path !== result.artifact.path
|
||||
)
|
||||
throw new TypeError(
|
||||
"WASM Evidence subject/selection does not match normalized artifact identity",
|
||||
);
|
||||
return { evidence, result };
|
||||
};
|
||||
|
||||
/** Compare only producer-owned typed facts; no raw WAT or original file is reread. */
|
||||
export const compareWasmInterfaces = async (
|
||||
rawInput: unknown,
|
||||
options?: ExecutionOptions,
|
||||
lookup?: EvidenceLookup,
|
||||
): Promise<Result<Evidence, AnalysisError>> => {
|
||||
if (options?.signal?.aborted)
|
||||
return err(new AnalysisCancelledError(OPERATION));
|
||||
const parsed = compareWasmInterfacesInputSchema.safeParse(rawInput);
|
||||
if (!parsed.success)
|
||||
return err(
|
||||
analysisInputErrorFromIssues(OPERATION, parsed.error.issues, rawInput),
|
||||
);
|
||||
const resolved = resolvePairedEvidenceRequest(parsed.data, lookup);
|
||||
if (!resolved.ok) return resolved;
|
||||
try {
|
||||
await setImmediate();
|
||||
options?.signal?.throwIfAborted();
|
||||
const left = parseInspection(resolved.value.left);
|
||||
const right = parseInspection(resolved.value.right);
|
||||
const scope = parsed.data.scope;
|
||||
const selected = (entry: WasmInterfaceEntry): boolean =>
|
||||
scope === "both" ||
|
||||
entry.direction === (scope === "imports" ? "import" : "export");
|
||||
const groups = new Map<
|
||||
string,
|
||||
{
|
||||
left: WasmInterfaceEntry[];
|
||||
right: WasmInterfaceEntry[];
|
||||
entry: WasmInterfaceEntry;
|
||||
}
|
||||
>();
|
||||
let work = 0;
|
||||
for (const [side, inspection] of [
|
||||
["left", left],
|
||||
["right", right],
|
||||
] as const) {
|
||||
for (const entry of inspection.result.interface?.entries ?? []) {
|
||||
if (++work % 256 === 0) {
|
||||
await setImmediate();
|
||||
options?.signal?.throwIfAborted();
|
||||
}
|
||||
if (!selected(entry)) continue;
|
||||
const key = JSON.stringify([entry.direction, entry.module, entry.name]);
|
||||
let group = groups.get(key);
|
||||
if (group === undefined) {
|
||||
group = { left: [], right: [], entry };
|
||||
groups.set(key, group);
|
||||
}
|
||||
group[side].push(entry);
|
||||
}
|
||||
}
|
||||
const knownInventory = (inspection: typeof left): boolean =>
|
||||
inspection.result.interface?.state === "complete";
|
||||
const changes: z.infer<typeof wasmInterfaceComparisonSchema>["changes"] =
|
||||
[];
|
||||
const summary = {
|
||||
added: 0,
|
||||
removed: 0,
|
||||
changed: 0,
|
||||
unchanged: 0,
|
||||
unknown: 0,
|
||||
};
|
||||
let remaining = WASM_INTERFACE_REPRESENTATION_BYTES;
|
||||
for (const group of groups.values()) {
|
||||
remaining -=
|
||||
512 +
|
||||
group.left.reduce(
|
||||
(sum, entry) => sum + wasmInterfaceEntryRepresentationBytes(entry),
|
||||
0,
|
||||
) +
|
||||
group.right.reduce(
|
||||
(sum, entry) => sum + wasmInterfaceEntryRepresentationBytes(entry),
|
||||
0,
|
||||
);
|
||||
if (remaining < 0)
|
||||
throw new AnalysisResourceConstraintError(
|
||||
OPERATION,
|
||||
"capacity",
|
||||
"WASM comparison exceeds typed interface representation capacity",
|
||||
{ maximum_bytes: WASM_INTERFACE_REPRESENTATION_BYTES },
|
||||
);
|
||||
if (++work % 256 === 0) {
|
||||
await setImmediate();
|
||||
options?.signal?.throwIfAborted();
|
||||
}
|
||||
let state: keyof typeof summary;
|
||||
let reason: string | null = null;
|
||||
const l = group.left[0];
|
||||
const r = group.right[0];
|
||||
if (group.left.length > 1 || group.right.length > 1) {
|
||||
state = "unknown";
|
||||
reason =
|
||||
"Duplicate interface names are ambiguous; all occurrences retained";
|
||||
} else if (!knownInventory(left) || !knownInventory(right)) {
|
||||
state = "unknown";
|
||||
reason =
|
||||
"Incomplete interface inventories cannot establish per-key uniqueness or absence";
|
||||
} else if (
|
||||
[l, r].some(
|
||||
(entry) =>
|
||||
entry !== undefined &&
|
||||
(entry.signature === null ||
|
||||
entry.issue !== null ||
|
||||
entry.kind === "unknown"),
|
||||
)
|
||||
) {
|
||||
state = "unknown";
|
||||
reason =
|
||||
"Signature/type semantics were not admitted for every present entry";
|
||||
} else if (l === undefined) state = "added";
|
||||
else if (r === undefined) state = "removed";
|
||||
else if (l.kind !== r.kind) state = "changed";
|
||||
else
|
||||
state =
|
||||
JSON.stringify(l.signature) === JSON.stringify(r.signature)
|
||||
? "unchanged"
|
||||
: "changed";
|
||||
summary[state]++;
|
||||
changes.push({
|
||||
direction: group.entry.direction,
|
||||
module: group.entry.module,
|
||||
name: group.entry.name,
|
||||
state,
|
||||
left: group.left,
|
||||
right: group.right,
|
||||
reason,
|
||||
});
|
||||
}
|
||||
const compareText = (a: string, b: string): number =>
|
||||
a < b ? -1 : a > b ? 1 : 0;
|
||||
changes.sort(
|
||||
(a, b) =>
|
||||
compareText(a.direction, b.direction) ||
|
||||
compareText(a.module ?? "", b.module ?? "") ||
|
||||
compareText(a.name, b.name),
|
||||
);
|
||||
const coverage =
|
||||
left.result.interface === undefined &&
|
||||
right.result.interface === undefined
|
||||
? "unknown"
|
||||
: !knownInventory(left) || !knownInventory(right) || summary.unknown > 0
|
||||
? "partial"
|
||||
: "complete-within-inputs";
|
||||
const limitations = [
|
||||
"Exact declared interface equality is not runtime/ABI compatibility, successful linking or original-source identity.",
|
||||
"Typed facts come from the selected validated WABT decoding; unsupported signature semantics and duplicate names remain unknown.",
|
||||
];
|
||||
const report = wasmInterfaceComparisonSchema.parse({
|
||||
left: {
|
||||
evidence_id: left.evidence.evidence_id,
|
||||
artifact: left.result.artifact,
|
||||
},
|
||||
right: {
|
||||
evidence_id: right.evidence.evidence_id,
|
||||
artifact: right.result.artifact,
|
||||
},
|
||||
scope,
|
||||
coverage,
|
||||
changes,
|
||||
summary,
|
||||
limitations,
|
||||
});
|
||||
await setImmediate();
|
||||
options?.signal?.throwIfAborted();
|
||||
return ok(
|
||||
createEvidence(undefined, ARTIFACT_COMPARISON_PROVIDER, {
|
||||
operation: OPERATION,
|
||||
predicateType: "rea.wasm-interface-comparison",
|
||||
parameters: {
|
||||
left_evidence_id: left.evidence.evidence_id,
|
||||
right_evidence_id: right.evidence.evidence_id,
|
||||
scope,
|
||||
},
|
||||
result: report,
|
||||
confidence: "derived",
|
||||
authority: "analyst-inference",
|
||||
limitations,
|
||||
evidenceLinks: [left.evidence.evidence_id, right.evidence.evidence_id],
|
||||
}),
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
if (options?.signal?.aborted)
|
||||
return err(new AnalysisCancelledError(OPERATION));
|
||||
if (cause instanceof AnalysisError) return err(cause);
|
||||
return err(
|
||||
new AnalysisInputError(OPERATION, { cause }, [
|
||||
{
|
||||
path: [],
|
||||
reason: "invalid_value",
|
||||
message: cause instanceof Error ? cause.message : String(cause),
|
||||
},
|
||||
]),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -1,3 +1,6 @@
|
||||
import { compareWasmInterfaces } from "../application/wasm/WasmInterfaceComparisonService.js";
|
||||
import { parseCliJsonInput } from "../cliJsonInput.js";
|
||||
import { CLI_COMMANDS } from "../cliCommandNames.js";
|
||||
import { z } from "incur";
|
||||
import { resolve } from "node:path";
|
||||
import { wasmArtifactModule } from "../composition/wasm.js";
|
||||
@@ -13,6 +16,29 @@ export const registerWasmCommands = (
|
||||
logger: Logger,
|
||||
environment: Readonly<NodeJS.ProcessEnv>,
|
||||
): void => {
|
||||
cli.command(CLI_COMMANDS.compareWasmInterfaces, {
|
||||
description:
|
||||
"Compare core WASM interfaces from two exact inspection Evidence records",
|
||||
args: z.object({
|
||||
input: z
|
||||
.string()
|
||||
.describe(
|
||||
"Inline JSON or input file containing left/right complete Evidence and required scope",
|
||||
),
|
||||
}),
|
||||
run: ({ args }) =>
|
||||
withCommandCancellation(async (signal) =>
|
||||
logCliCommand(logger, CLI_COMMANDS.compareWasmInterfaces, async () => {
|
||||
const input = await parseCliJsonInput(
|
||||
args.input,
|
||||
"compare_wasm_interfaces",
|
||||
);
|
||||
if (!input.ok) return input.error;
|
||||
const result = await compareWasmInterfaces(input.value, { signal });
|
||||
return result.ok ? result.value : projectAnalysisError(result.error);
|
||||
}),
|
||||
),
|
||||
});
|
||||
const service = wasmArtifactModule.create(environment);
|
||||
cli.command(wasmArtifactModule.cliCommand, {
|
||||
description: "Inspect an explicit local WASM artifact with WABT",
|
||||
|
||||
@@ -4,6 +4,7 @@ export const CLI_COMMANDS = Object.freeze({
|
||||
inspectPacketCapture: "inspect-packet-capture",
|
||||
inspectSignalDecoders: "inspect-signal-decoders",
|
||||
decodeSignalRecording: "decode-signal-recording",
|
||||
compareWasmInterfaces: "compare-wasm-interfaces",
|
||||
inspectWasmArtifact: "inspect-wasm-artifact",
|
||||
inspectSqliteDatabase: "inspect-sqlite-database",
|
||||
inspectEvmInterface: "inspect-evm-interface",
|
||||
|
||||
@@ -30,6 +30,7 @@ const resolveReference = (
|
||||
root: Readonly<Record<string, unknown>>,
|
||||
reference: string,
|
||||
): unknown => {
|
||||
if (reference === "#") return root;
|
||||
if (!reference.startsWith("#/")) return undefined;
|
||||
let value: unknown = root;
|
||||
for (const token of reference.slice(2).split("/")) {
|
||||
@@ -229,3 +230,76 @@ export const presentInputJsonSchema = (
|
||||
const described = describeSchema(flattened, flattened, describeProperty);
|
||||
return isObject(described) ? described : flattened;
|
||||
};
|
||||
|
||||
/**
|
||||
* Clients that render advertised inputs into SDK declarations expand
|
||||
* schema-local references with a shared per-input budget: Pi 1.1.0 code-mode
|
||||
* allows 32 expansions and degrades every later reference to `unknown`
|
||||
* (morluto/rea#1632), hiding the fields those references describe. The budget
|
||||
* counts conservatively — every resolvable, non-cyclic reference occurrence
|
||||
* in every schema position — so any reference-resolving client stays within
|
||||
* its measured limit.
|
||||
*/
|
||||
export const advertisedReferenceExpansionBudget = 32;
|
||||
|
||||
const definitionKeys = new Set(["$defs", "definitions"]);
|
||||
|
||||
const countReferenceExpansions = (
|
||||
node: unknown,
|
||||
root: Readonly<Record<string, unknown>>,
|
||||
active: ReadonlySet<string>,
|
||||
count: { value: number },
|
||||
): void => {
|
||||
if (Array.isArray(node)) {
|
||||
node.forEach((child) =>
|
||||
countReferenceExpansions(child, root, active, count),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!isObject(node)) return;
|
||||
if (typeof node.$ref === "string" && !active.has(node.$ref)) {
|
||||
const target = resolveReference(root, node.$ref);
|
||||
if (target !== undefined) {
|
||||
count.value += 1;
|
||||
countReferenceExpansions(
|
||||
target,
|
||||
root,
|
||||
new Set([...active, node.$ref]),
|
||||
count,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
for (const [key, value] of Object.entries(node)) {
|
||||
if (definitionKeys.has(key)) continue;
|
||||
if (schemaMaps.has(key) && isObject(value)) {
|
||||
for (const child of Object.values(value))
|
||||
countReferenceExpansions(child, root, active, count);
|
||||
} else if (schemaArrays.has(key) || schemaChildren.has(key)) {
|
||||
countReferenceExpansions(value, root, active, count);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/** Conservative client expansion count over every schema position. */
|
||||
export const countLocalReferenceExpansions = (
|
||||
schema: Record<string, unknown>,
|
||||
): number => {
|
||||
const count = { value: 0 };
|
||||
countReferenceExpansions(schema, schema, new Set(), count);
|
||||
return count.value;
|
||||
};
|
||||
|
||||
/**
|
||||
* Keep the shared-definition form of an advertised input while clients can
|
||||
* still resolve it. Once rendering would exhaust the reference budget, fall
|
||||
* back to the inline projection, which carries no shared definitions for the
|
||||
* nonrecursive schemas this repository advertises, so every field renders
|
||||
* with complete structure in every reference-resolving client.
|
||||
*/
|
||||
export const inputSchemaWithinReferenceExpansionBudget = (
|
||||
shared: Record<string, unknown>,
|
||||
inline: () => Record<string, unknown>,
|
||||
budget: number,
|
||||
): Record<string, unknown> =>
|
||||
countLocalReferenceExpansions(shared) <= budget ? shared : inline();
|
||||
|
||||
@@ -67,7 +67,7 @@ export const BINARY_DIAGNOSTICS_TOOL_CONTRACTS = [
|
||||
...toolContractMetadata("inspect_recorded_crash"),
|
||||
kind: "native-provider",
|
||||
description:
|
||||
"Inspect a supplied Linux ELF64 amd64 little-endian core without launching its target or attaching to any live PID. Returns all recorded segments, raw note owners/descriptors and source ranges, every Linux CORE PRSTATUS thread/register and SIGINFO fields inline with SHA-256 Evidence. Historical PID is recorded metadata; current process/file identity, note coverage and signal-thread associations stay unknown. Optionally add derived core-only GDB/pwndbg mapping candidates through explicit BYO configuration; zero map flags mean unknown permissions. Uses the same stable snapshot for both stages, bounded complete output and owned cleanup. Initial Linux x64 profile: unchanged pwntools 4.15.0/pyelftools 0.33/Unicorn 2.1.2; optional unchanged pwndbg 2026.09.15. No implicit executable/library pairing, GDB command language, host configuration changes or dependency installation.",
|
||||
"Inspect a supplied Linux ELF64 amd64 or AArch64 little-endian core without launching its target or attaching to any live PID. Returns all recorded segments, raw note owners/descriptors and source ranges, every Linux CORE PRSTATUS thread/register and SIGINFO fields inline with SHA-256 Evidence. Historical PID is recorded metadata; current process/file identity, note coverage and signal-thread associations stay unknown. Optionally add derived core-only GDB/pwndbg mapping candidates through explicit BYO configuration; zero map flags mean unknown permissions. Uses the same stable snapshot for both stages, bounded complete output and owned cleanup. Linux x64 host profile: unchanged pwntools 4.15.0/pyelftools 0.33/Unicorn 2.1.2; optional unchanged pwndbg 2026.09.15. No implicit executable/library pairing, GDB command language, host configuration changes or dependency installation.",
|
||||
inputSchema: inspectRecordedCrashInputSchema,
|
||||
outputSchema: evidenceResultOf(recordedCrashSchema),
|
||||
examples: [
|
||||
|
||||
@@ -27,6 +27,8 @@ const EXECUTION_WORDING_EXCEPTIONS: Readonly<Record<string, string>> = {
|
||||
inspect_macho: "runs host Mach-O inspection tools, not the target",
|
||||
inspect_evm_interface:
|
||||
"decodes bytecode in REA's bundled worker without executing it",
|
||||
compare_wasm_interfaces:
|
||||
"compares retained inspection Evidence without executing modules",
|
||||
inspect_wasm_artifact:
|
||||
"WABT validates and decompiles the module without executing it",
|
||||
identify_flutter_build:
|
||||
|
||||
@@ -623,6 +623,7 @@ export const TOOL_EFFECTS: Readonly<Record<string, ToolEffects>> = {
|
||||
}),
|
||||
compare_process_captures: sessionEvidence,
|
||||
compare_artifacts: sessionEvidence,
|
||||
compare_wasm_interfaces: sessionEvidence,
|
||||
compare_functions: sessionEvidence,
|
||||
compare_bundles: effects({ mutatesSession: true }),
|
||||
find_changed_behavior: sessionEvidence,
|
||||
|
||||
@@ -3,7 +3,12 @@ import { Ajv2020 } from "ajv/dist/2020.js";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
import { presentInputJsonSchema } from "./inputSchemaPresentation.js";
|
||||
import {
|
||||
advertisedReferenceExpansionBudget,
|
||||
countLocalReferenceExpansions,
|
||||
inputSchemaWithinReferenceExpansionBudget,
|
||||
presentInputJsonSchema,
|
||||
} from "./inputSchemaPresentation.js";
|
||||
import { toolContract } from "./toolContracts.js";
|
||||
import { toolInputSchemaWithMetadata } from "./toolSchemaMetadata.js";
|
||||
import { JAVASCRIPT_APPLICATION_EVIDENCE_EXAMPLE } from "./javascript/javascriptRuntimeReconciliationExample.js";
|
||||
@@ -166,6 +171,105 @@ describe("advertised input references", () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe("advertised reference expansion budget", () => {
|
||||
it("counts references in keyword-named fields, excluding literals", () => {
|
||||
const fields = ["default", "enum", "examples", "const", "$defs"];
|
||||
const schema = {
|
||||
type: "object",
|
||||
properties: Object.fromEntries(
|
||||
fields.map((name) => [name, { $ref: "#/$defs/value" }]),
|
||||
),
|
||||
$defs: { value: { type: "string" } },
|
||||
default: { $ref: "#/$defs/value" },
|
||||
examples: [{ $ref: "#/$defs/value" }],
|
||||
};
|
||||
expect(countLocalReferenceExpansions(schema)).toBe(fields.length);
|
||||
});
|
||||
const reusedComponent = z
|
||||
.object({ kind: z.literal("rec"), value: z.string() })
|
||||
.strict();
|
||||
const inputWithReusedFields = (count: number) =>
|
||||
z.strictObject(
|
||||
Object.fromEntries(
|
||||
Array.from({ length: count }, (_, index) => [
|
||||
`field_${String(index).padStart(2, "0")}`,
|
||||
reusedComponent,
|
||||
]),
|
||||
),
|
||||
);
|
||||
const advertisedInput = (
|
||||
canonical: z.ZodType<Readonly<Record<string, unknown>>>,
|
||||
) =>
|
||||
toolInputSchemaWithMetadata({
|
||||
...toolContract("open_binary"),
|
||||
inputSchema: canonical,
|
||||
})["~standard"].jsonSchema.input({ target: "draft-2020-12" });
|
||||
|
||||
it("keeps shared definitions while every client can resolve them", () => {
|
||||
const advertised = advertisedInput(inputWithReusedFields(8));
|
||||
expect(advertised.$defs).toBeDefined();
|
||||
expect(countLocalReferenceExpansions(advertised)).toBe(8);
|
||||
});
|
||||
|
||||
it("keeps shared definitions exactly at the budget", () => {
|
||||
const advertised = advertisedInput(
|
||||
inputWithReusedFields(advertisedReferenceExpansionBudget),
|
||||
);
|
||||
expect(advertised.$defs).toBeDefined();
|
||||
expect(countLocalReferenceExpansions(advertised)).toBe(
|
||||
advertisedReferenceExpansionBudget,
|
||||
);
|
||||
});
|
||||
|
||||
it("re-projects without shared definitions once the budget is exceeded", () => {
|
||||
const advertised = advertisedInput(
|
||||
inputWithReusedFields(advertisedReferenceExpansionBudget + 1),
|
||||
);
|
||||
expect(advertised.$defs).toBeUndefined();
|
||||
expect(countLocalReferenceExpansions(advertised)).toBe(0);
|
||||
const properties = advertised.properties as Record<string, unknown>;
|
||||
expect(properties.field_00).toMatchObject({
|
||||
type: "object",
|
||||
properties: { kind: { const: "rec" }, value: { type: "string" } },
|
||||
required: ["kind", "value"],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the shared form untouched when it fits and swaps it when not", () => {
|
||||
const shared = {
|
||||
type: "object",
|
||||
properties: { value: { $ref: "#/$defs/shared" } },
|
||||
$defs: { shared: { type: "string" } },
|
||||
};
|
||||
const inlined = {
|
||||
type: "object",
|
||||
properties: { value: { type: "string" } },
|
||||
};
|
||||
expect(
|
||||
inputSchemaWithinReferenceExpansionBudget(
|
||||
shared,
|
||||
() => inlined,
|
||||
advertisedReferenceExpansionBudget,
|
||||
),
|
||||
).toBe(shared);
|
||||
expect(
|
||||
inputSchemaWithinReferenceExpansionBudget(
|
||||
{
|
||||
...shared,
|
||||
properties: Object.fromEntries(
|
||||
Array.from({ length: 33 }, (_, index) => [
|
||||
`field_${index}`,
|
||||
{ $ref: "#/$defs/shared" },
|
||||
]),
|
||||
),
|
||||
},
|
||||
() => inlined,
|
||||
advertisedReferenceExpansionBudget,
|
||||
),
|
||||
).toBe(inlined);
|
||||
});
|
||||
});
|
||||
|
||||
describe("root input presentation", () => {
|
||||
it("preserves prototype-named properties in object-root presentation", () => {
|
||||
const fields = Object.fromEntries([
|
||||
|
||||
@@ -2,7 +2,11 @@ import { z } from "zod";
|
||||
|
||||
import type { ToolContract } from "./toolContractTypes.js";
|
||||
import { advertisedInputExamples } from "./advertisedInputExamples.js";
|
||||
import { presentInputJsonSchema } from "./inputSchemaPresentation.js";
|
||||
import {
|
||||
advertisedReferenceExpansionBudget,
|
||||
inputSchemaWithinReferenceExpansionBudget,
|
||||
presentInputJsonSchema,
|
||||
} from "./inputSchemaPresentation.js";
|
||||
import { digestCanonicalValue } from "../domain/canonicalDigest.js";
|
||||
|
||||
const PROPERTY_DESCRIPTIONS: Readonly<Record<string, string>> = {
|
||||
@@ -66,7 +70,25 @@ export const toolInputSchemaWithMetadata = <Contract extends ToolContract>(
|
||||
});
|
||||
const examples = advertisedInputExamples(contract);
|
||||
return {
|
||||
...presentInputJsonSchema(shared, fallbackPropertyDescription),
|
||||
// Sharing definitions only helps clients that resolve them. A client
|
||||
// rendering declarations from a shared expansion budget degrades
|
||||
// every reference past it to `unknown`, so an input whose shared form
|
||||
// exceeds the budget is re-projected without shared definitions.
|
||||
...inputSchemaWithinReferenceExpansionBudget(
|
||||
presentInputJsonSchema(shared, fallbackPropertyDescription),
|
||||
() =>
|
||||
presentInputJsonSchema(
|
||||
project({
|
||||
...options,
|
||||
libraryOptions: {
|
||||
...options.libraryOptions,
|
||||
reused: "inline",
|
||||
},
|
||||
}),
|
||||
fallbackPropertyDescription,
|
||||
),
|
||||
advertisedReferenceExpansionBudget,
|
||||
),
|
||||
...(examples.length === 0 ? {} : { examples }),
|
||||
};
|
||||
},
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { evidenceResultOf } from "../toolOutputSchemaPrimitives.js";
|
||||
import { z } from "zod";
|
||||
import { evidenceInputSchema } from "../evidenceInputContracts.js";
|
||||
import { wasmInterfaceSchema } from "../../domain/wasm/wasmInterface.js";
|
||||
import { toolContractMetadata } from "../toolEffects.js";
|
||||
import type { ToolContract } from "../toolContractTypes.js";
|
||||
|
||||
export const compareWasmInterfacesInputSchema = z.strictObject({
|
||||
left: evidenceInputSchema.describe(
|
||||
"Exact complete inspect_wasm_artifact Evidence or retained session reference",
|
||||
),
|
||||
right: evidenceInputSchema.describe(
|
||||
"Exact complete inspect_wasm_artifact Evidence or retained session reference",
|
||||
),
|
||||
scope: z
|
||||
.enum(["imports", "exports", "both"])
|
||||
.describe(
|
||||
"Explicit interface selection; static equality does not establish runtime compatibility",
|
||||
),
|
||||
});
|
||||
const identity = z.object({
|
||||
evidence_id: z.string(),
|
||||
artifact: z.object({
|
||||
path: z.string(),
|
||||
sha256: z.string(),
|
||||
bytes: z.number().int().nonnegative(),
|
||||
}),
|
||||
});
|
||||
export const wasmInterfaceComparisonSchema = z.object({
|
||||
left: identity,
|
||||
right: identity,
|
||||
scope: z.enum(["imports", "exports", "both"]),
|
||||
coverage: z.enum(["complete-within-inputs", "partial", "unknown"]),
|
||||
changes: z.array(
|
||||
z.object({
|
||||
direction: z.enum(["import", "export"]),
|
||||
module: z.string().nullable(),
|
||||
name: z.string(),
|
||||
state: z.enum(["added", "removed", "changed", "unchanged", "unknown"]),
|
||||
left: wasmInterfaceSchema.shape.entries,
|
||||
right: wasmInterfaceSchema.shape.entries,
|
||||
reason: z.string().nullable(),
|
||||
}),
|
||||
),
|
||||
summary: z.object({
|
||||
added: z.number(),
|
||||
removed: z.number(),
|
||||
changed: z.number(),
|
||||
unchanged: z.number(),
|
||||
unknown: z.number(),
|
||||
}),
|
||||
limitations: z.array(z.string()),
|
||||
});
|
||||
export const COMPARE_WASM_INTERFACES_CONTRACT = {
|
||||
name: "compare_wasm_interfaces",
|
||||
...toolContractMetadata("compare_wasm_interfaces"),
|
||||
kind: "application",
|
||||
description:
|
||||
"Compare typed core WASM import/export names, kinds and exact signatures from two authenticated inspect_wasm_artifact Evidence records. Select imports, exports or both explicitly. Retained references resolve only in the current session; CLI accepts portable inline Evidence. Duplicate names, unsupported types and missing historical interface facets remain unknown. Preserve original module digests and every occurrence; no source acquisition, module execution or runtime-compatibility inference.",
|
||||
inputSchema: compareWasmInterfacesInputSchema,
|
||||
outputSchema: evidenceResultOf(wasmInterfaceComparisonSchema),
|
||||
examples: [],
|
||||
} as const satisfies ToolContract;
|
||||
@@ -1,3 +1,4 @@
|
||||
import { COMPARE_WASM_INTERFACES_CONTRACT } from "./wasmComparisonContracts.js";
|
||||
import {
|
||||
inspectWasmArtifactInputSchema,
|
||||
wasmArtifactSchema,
|
||||
@@ -40,4 +41,7 @@ export const INSPECT_WASM_ARTIFACT_CONTRACT = {
|
||||
} as const satisfies ToolContract;
|
||||
|
||||
/** Keep the public catalog in its existing order, using its named contract. */
|
||||
export const WASM_TOOL_CONTRACTS = [INSPECT_WASM_ARTIFACT_CONTRACT] as const;
|
||||
export const WASM_TOOL_CONTRACTS = [
|
||||
INSPECT_WASM_ARTIFACT_CONTRACT,
|
||||
COMPARE_WASM_INTERFACES_CONTRACT,
|
||||
] as const;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
inspectRecordedCrashInputSchema,
|
||||
recordedCrashSchema,
|
||||
type RecordedCrash,
|
||||
} from "./recordedCrash.js";
|
||||
import {
|
||||
recordedCrashFixture,
|
||||
@@ -282,3 +283,95 @@ it("keeps absent map flags unknown and validates reported permission bits", () =
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
describe("recorded AArch64 core profile", () => {
|
||||
it("accepts the complete AArch64 register window in ABI order", () => {
|
||||
const value = recordedCrashSchema.parse(
|
||||
recordedCrashFixture("/selected/aarch64.core", "aarch64-little-endian"),
|
||||
);
|
||||
expect(value.architecture).toBe("aarch64-little-endian");
|
||||
const registers = value.threads[0]?.registers ?? [];
|
||||
expect(registers.map(({ name }) => name)).toEqual([
|
||||
...Array.from({ length: 31 }, (_, index) => `x${String(index)}`),
|
||||
"sp",
|
||||
"pc",
|
||||
"pstate",
|
||||
]);
|
||||
expect(registers[14]?.value).toBe("0x1122334455667788");
|
||||
expect(registers.every(({ location }) => location.bytes === "0x8")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the AArch64 note ranges inside their recorded segment", () => {
|
||||
const value = recordedCrashSchema.parse(
|
||||
recordedCrashSignalFixture(undefined, "aarch64-little-endian"),
|
||||
);
|
||||
const note = value.notes[0];
|
||||
const segment = value.segments[0];
|
||||
if (note === undefined || segment === undefined)
|
||||
throw new Error("Expected recorded notes");
|
||||
expect(
|
||||
BigInt(note.location.offset) + BigInt(note.location.bytes),
|
||||
).toBeLessThanOrEqual(BigInt(segment.offset) + BigInt(segment.file_size));
|
||||
expect(value.signals[0]?.fault_address).toBe("0x1122334455667788");
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"an amd64 register name inside an AArch64 window",
|
||||
(v: RecordedCrash) => ({
|
||||
...v,
|
||||
threads: v.threads.map((thread) => ({
|
||||
...thread,
|
||||
registers: thread.registers.map((register, index) =>
|
||||
index === 0 ? { ...register, name: "rax" } : register,
|
||||
),
|
||||
})),
|
||||
}),
|
||||
],
|
||||
[
|
||||
"the amd64 register count",
|
||||
(v: RecordedCrash) => ({
|
||||
...v,
|
||||
threads: v.threads.map((thread) => ({
|
||||
...thread,
|
||||
registers: thread.registers.slice(0, 27),
|
||||
})),
|
||||
}),
|
||||
],
|
||||
[
|
||||
"a 4-byte AArch64 register source",
|
||||
(v: RecordedCrash) => ({
|
||||
...v,
|
||||
threads: v.threads.map((thread) => ({
|
||||
...thread,
|
||||
registers: thread.registers.map((register, index) =>
|
||||
index === 0
|
||||
? {
|
||||
...register,
|
||||
location: { ...register.location, bytes: "0x4" },
|
||||
}
|
||||
: register,
|
||||
),
|
||||
})),
|
||||
}),
|
||||
],
|
||||
] as const)("rejects %s", (_name, change) => {
|
||||
const value = recordedCrashFixture(
|
||||
"/selected/aarch64.core",
|
||||
"aarch64-little-endian",
|
||||
);
|
||||
expect(recordedCrashSchema.safeParse(change(value)).success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects an architecture outside the interpreted profiles", () => {
|
||||
const value = recordedCrashFixture();
|
||||
expect(
|
||||
recordedCrashSchema.safeParse({
|
||||
...value,
|
||||
architecture: "arm-little-endian",
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import type { BinaryArchitecture } from "../binaryTargetTypes.js";
|
||||
import { localPathStringSchema } from "../localPath.js";
|
||||
import { CANONICAL_BASE64_PATTERN } from "../stringPatterns.js";
|
||||
import { recordedNoteCoverageIssues } from "./recordedCrashNoteCoverage.js";
|
||||
@@ -18,7 +19,7 @@ const diagnostics = z.strictObject({
|
||||
const LINUX_CORE_OWNER_BASE64 = "Q09SRQA=";
|
||||
|
||||
/** Complete register identities in the supported Linux amd64 PRSTATUS ABI order. */
|
||||
export const RECORDED_LINUX_AMD64_REGISTERS: readonly string[] = [
|
||||
const RECORDED_LINUX_AMD64_REGISTERS: readonly string[] = [
|
||||
"r15",
|
||||
"r14",
|
||||
"r13",
|
||||
@@ -47,7 +48,95 @@ export const RECORDED_LINUX_AMD64_REGISTERS: readonly string[] = [
|
||||
"fs",
|
||||
"gs",
|
||||
];
|
||||
const supportedRegisters = new Set(RECORDED_LINUX_AMD64_REGISTERS);
|
||||
const RECORDED_LINUX_AARCH64_REGISTERS: readonly string[] = [
|
||||
"x0",
|
||||
"x1",
|
||||
"x2",
|
||||
"x3",
|
||||
"x4",
|
||||
"x5",
|
||||
"x6",
|
||||
"x7",
|
||||
"x8",
|
||||
"x9",
|
||||
"x10",
|
||||
"x11",
|
||||
"x12",
|
||||
"x13",
|
||||
"x14",
|
||||
"x15",
|
||||
"x16",
|
||||
"x17",
|
||||
"x18",
|
||||
"x19",
|
||||
"x20",
|
||||
"x21",
|
||||
"x22",
|
||||
"x23",
|
||||
"x24",
|
||||
"x25",
|
||||
"x26",
|
||||
"x27",
|
||||
"x28",
|
||||
"x29",
|
||||
"x30",
|
||||
"sp",
|
||||
"pc",
|
||||
"pstate",
|
||||
];
|
||||
|
||||
/** Recorded-core architectures whose Linux note ABI is interpreted. */
|
||||
export const RECORDED_LINUX_CORE_ARCHITECTURES = [
|
||||
"x86_64-little-endian",
|
||||
"aarch64-little-endian",
|
||||
] as const;
|
||||
|
||||
export type RecordedCrashArchitecture =
|
||||
(typeof RECORDED_LINUX_CORE_ARCHITECTURES)[number];
|
||||
|
||||
/**
|
||||
* ABI facts for one recorded-core architecture, measured from unchanged pwntools
|
||||
* 4.15.0 `elf_prstatus_*` and `user_regs_struct_*` structures and mirrored by
|
||||
* `bridge/pwntools/recorded_crash_impl.py`. Both profiles place `pr_cursig` at
|
||||
* 12, `pr_pid` at 32 and the register window at 112; the ELF machine, the window
|
||||
* contents and the Evidence CPU family differ per profile.
|
||||
*/
|
||||
export const RECORDED_LINUX_CORE_PROFILES: Record<
|
||||
RecordedCrashArchitecture,
|
||||
{
|
||||
readonly machine: number;
|
||||
readonly evidenceArchitecture: BinaryArchitecture;
|
||||
readonly registers: readonly string[];
|
||||
readonly registerBytes: number;
|
||||
readonly registerOffset: number;
|
||||
readonly prstatusBytes: number;
|
||||
}
|
||||
> = {
|
||||
"x86_64-little-endian": {
|
||||
machine: 62,
|
||||
evidenceArchitecture: "x86_64",
|
||||
registers: RECORDED_LINUX_AMD64_REGISTERS,
|
||||
registerBytes: 8,
|
||||
registerOffset: 112,
|
||||
prstatusBytes: 336,
|
||||
},
|
||||
"aarch64-little-endian": {
|
||||
machine: 183,
|
||||
evidenceArchitecture: "arm64",
|
||||
registers: RECORDED_LINUX_AARCH64_REGISTERS,
|
||||
registerBytes: 8,
|
||||
registerOffset: 112,
|
||||
prstatusBytes: 392,
|
||||
},
|
||||
};
|
||||
|
||||
const recordedCoreRegisterSets: Record<
|
||||
RecordedCrashArchitecture,
|
||||
ReadonlySet<string>
|
||||
> = {
|
||||
"x86_64-little-endian": new Set(RECORDED_LINUX_AMD64_REGISTERS),
|
||||
"aarch64-little-endian": new Set(RECORDED_LINUX_AARCH64_REGISTERS),
|
||||
};
|
||||
|
||||
/** Select an explicit recording and optionally request debugger-derived mapping context. */
|
||||
export const inspectRecordedCrashInputSchema = z.strictObject({
|
||||
@@ -108,7 +197,7 @@ const recordedCrashObjectSchema = z.strictObject({
|
||||
bytes: index,
|
||||
}),
|
||||
format: z.literal("elf-core"),
|
||||
architecture: z.literal("x86_64-little-endian"),
|
||||
architecture: z.enum(RECORDED_LINUX_CORE_ARCHITECTURES),
|
||||
target_execution: z.literal("not-performed"),
|
||||
live_process_identity: z.literal("unknown"),
|
||||
segments: z.array(
|
||||
@@ -284,6 +373,8 @@ export const recordedCrashSchema = recordedCrashObjectSchema.superRefine(
|
||||
"Each supported raw note requires exactly one corresponding interpretation.",
|
||||
);
|
||||
}
|
||||
const profile = RECORDED_LINUX_CORE_PROFILES[value.architecture];
|
||||
const profileRegisters = recordedCoreRegisterSets[value.architecture];
|
||||
for (const thread of value.threads) {
|
||||
const note = value.notes[thread.note_index];
|
||||
if (
|
||||
@@ -295,8 +386,8 @@ export const recordedCrashSchema = recordedCrashObjectSchema.superRefine(
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
thread.registers.length !== RECORDED_LINUX_AMD64_REGISTERS.length ||
|
||||
!thread.registers.every(({ name }) => supportedRegisters.has(name)) ||
|
||||
thread.registers.length !== profile.registers.length ||
|
||||
!thread.registers.every(({ name }) => profileRegisters.has(name)) ||
|
||||
new Set(thread.registers.map(({ name }) => name)).size !==
|
||||
thread.registers.length
|
||||
)
|
||||
@@ -305,7 +396,7 @@ export const recordedCrashSchema = recordedCrashObjectSchema.superRefine(
|
||||
);
|
||||
for (const register of thread.registers)
|
||||
if (
|
||||
BigInt(register.location.bytes) !== 8n ||
|
||||
BigInt(register.location.bytes) !== BigInt(profile.registerBytes) ||
|
||||
!within(
|
||||
register.location,
|
||||
BigInt(note.descriptor_location.offset),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { wasmInterfaceSchema } from "./wasmInterface.js";
|
||||
import { z } from "zod";
|
||||
import { createHash } from "node:crypto";
|
||||
import type { EvidenceSubjectTarget } from "../evidence.js";
|
||||
@@ -32,6 +33,7 @@ export const wasmArtifactSchema = z
|
||||
description: z.string(),
|
||||
}),
|
||||
),
|
||||
interface: wasmInterfaceSchema.exactOptional(),
|
||||
imports: z.array(z.string()),
|
||||
exports: z.array(z.string()),
|
||||
headers: z.string(),
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const kind = z.enum(["func", "table", "memory", "global", "tag", "unknown"]);
|
||||
const valueType = z.enum([
|
||||
"i32",
|
||||
"i64",
|
||||
"f32",
|
||||
"f64",
|
||||
"v128",
|
||||
"funcref",
|
||||
"externref",
|
||||
]);
|
||||
const limit = z.string().regex(/^(?:0|[1-9][0-9]*)$/u);
|
||||
const signature = z.discriminatedUnion("kind", [
|
||||
z.object({
|
||||
kind: z.literal("func"),
|
||||
params: z.array(valueType),
|
||||
results: z.array(valueType),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("tag"),
|
||||
params: z.array(valueType),
|
||||
results: z.array(valueType),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("table"),
|
||||
element: z.enum(["funcref", "externref"]),
|
||||
address_type: z.enum(["i32", "i64"]),
|
||||
minimum: limit,
|
||||
maximum: limit.nullable(),
|
||||
shared: z.boolean(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("memory"),
|
||||
page_size: limit,
|
||||
address_type: z.enum(["i32", "i64"]),
|
||||
minimum: limit,
|
||||
maximum: limit.nullable(),
|
||||
shared: z.boolean(),
|
||||
}),
|
||||
z.object({
|
||||
kind: z.literal("global"),
|
||||
value_type: valueType,
|
||||
mutable: z.boolean(),
|
||||
}),
|
||||
]);
|
||||
|
||||
/** Typed observations decoded once at the WABT producer boundary. */
|
||||
export const wasmInterfaceSchema = z.object({
|
||||
state: z.enum(["complete", "partial"]),
|
||||
entries: z.array(
|
||||
z
|
||||
.object({
|
||||
direction: z.enum(["import", "export"]),
|
||||
module: z.string().nullable(),
|
||||
name: z.string(),
|
||||
kind,
|
||||
index: z.number().int().nonnegative().max(0xffff_ffff).nullable(),
|
||||
signature: signature.nullable(),
|
||||
wat_form: z.string(),
|
||||
issue: z.string().nullable(),
|
||||
})
|
||||
.superRefine((entry, context) => {
|
||||
if (
|
||||
(entry.direction === "export" && entry.module !== null) ||
|
||||
(entry.direction === "import" && entry.module === null)
|
||||
)
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "Interface direction does not match module-name presence",
|
||||
});
|
||||
if (entry.signature !== null && entry.signature.kind !== entry.kind)
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "Interface kind does not match its typed signature",
|
||||
});
|
||||
}),
|
||||
),
|
||||
issues: z.array(z.string()),
|
||||
});
|
||||
export type WasmInterface = z.infer<typeof wasmInterfaceSchema>;
|
||||
export type WasmInterfaceEntry = WasmInterface["entries"][number];
|
||||
export type WasmInterfaceSignature = NonNullable<
|
||||
WasmInterfaceEntry["signature"]
|
||||
>;
|
||||
|
||||
export type WasmInterfaceValueType = z.infer<typeof valueType>;
|
||||
|
||||
/** Bound eager typed output and repeated signature expansion independently of WAT bytes. */
|
||||
export const WASM_INTERFACE_REPRESENTATION_BYTES = 32 * 1024 * 1024;
|
||||
|
||||
/** Conservative JSON/retention charge; shared arrays still expand at every occurrence. */
|
||||
export const wasmInterfaceEntryRepresentationBytes = (
|
||||
entry: WasmInterfaceEntry,
|
||||
): number => {
|
||||
let bytes =
|
||||
1024 +
|
||||
[entry.module, entry.name, entry.wat_form, entry.issue].reduce<number>(
|
||||
(sum, value) => sum + (value?.length ?? 0) * 6,
|
||||
0,
|
||||
);
|
||||
const signature = entry.signature;
|
||||
if (signature?.kind === "func" || signature?.kind === "tag")
|
||||
bytes += (signature.params.length + signature.results.length) * 16;
|
||||
else if (signature !== null)
|
||||
for (const value of Object.values(signature))
|
||||
if (typeof value === "string") bytes += value.length * 6;
|
||||
return bytes;
|
||||
};
|
||||
@@ -63,6 +63,56 @@ const DYLIB_FIELDS = [
|
||||
const DYLIB_ENCODING_FIELDS = ["time stamp", "options"];
|
||||
const EMPTY_VALUE_FIELDS = new Set(["options", "segname", "sectname"]);
|
||||
|
||||
/**
|
||||
* Non-verbose otool prints LC_BUILD_VERSION platform and tool codes as
|
||||
* numbers. Name the codes declared in the SDK's <mach-o/loader.h>, without
|
||||
* the PLATFORM_/TOOL_ prefix, so build metadata uses the same symbolic style
|
||||
* as LC_VERSION_MIN_* platforms. Unknown codes keep their printed value.
|
||||
*/
|
||||
const BUILD_PLATFORM_NAMES: ReadonlyMap<string, string> = new Map([
|
||||
["1", "MACOS"],
|
||||
["2", "IOS"],
|
||||
["3", "TVOS"],
|
||||
["4", "WATCHOS"],
|
||||
["5", "BRIDGEOS"],
|
||||
["6", "MACCATALYST"],
|
||||
["7", "IOSSIMULATOR"],
|
||||
["8", "TVOSSIMULATOR"],
|
||||
["9", "WATCHOSSIMULATOR"],
|
||||
["10", "DRIVERKIT"],
|
||||
["11", "VISIONOS"],
|
||||
["12", "VISIONOSSIMULATOR"],
|
||||
["13", "FIRMWARE"],
|
||||
["14", "SEPOS"],
|
||||
["15", "MACOS_EXCLAVECORE"],
|
||||
["16", "MACOS_EXCLAVEKIT"],
|
||||
["17", "IOS_EXCLAVECORE"],
|
||||
["18", "IOS_EXCLAVEKIT"],
|
||||
["19", "TVOS_EXCLAVECORE"],
|
||||
["20", "TVOS_EXCLAVEKIT"],
|
||||
["21", "WATCHOS_EXCLAVECORE"],
|
||||
["22", "WATCHOS_EXCLAVEKIT"],
|
||||
["23", "VISIONOS_EXCLAVECORE"],
|
||||
["24", "VISIONOS_EXCLAVEKIT"],
|
||||
]);
|
||||
const BUILD_TOOL_NAMES: ReadonlyMap<string, string> = new Map([
|
||||
["1", "CLANG"],
|
||||
["2", "SWIFT"],
|
||||
["3", "LD"],
|
||||
["4", "LLD"],
|
||||
["1024", "METAL"],
|
||||
["1025", "AIRLLD"],
|
||||
["1026", "AIRNT"],
|
||||
["1027", "AIRNT_PLUGIN"],
|
||||
["1028", "AIRPACK"],
|
||||
["1031", "GPUARCHIVER"],
|
||||
["1032", "METAL_FRAMEWORK"],
|
||||
]);
|
||||
const buildCodeName = (
|
||||
names: ReadonlyMap<string, string>,
|
||||
value: string | null,
|
||||
): string | null => (value === null ? null : (names.get(value) ?? value));
|
||||
|
||||
/**
|
||||
* otool emits LF, but a capture replayed from another host or toolchain can
|
||||
* carry CRLF. The section/command boundaries below match a bare `\n`, so a
|
||||
@@ -295,7 +345,10 @@ const collectBuild = (
|
||||
if (numberField(fields, "ntools") !== tools.length)
|
||||
throw new TypeError("otool build-tool count does not match ntools");
|
||||
state.builds.push({
|
||||
platform: stringField(fields, "platform"),
|
||||
platform: buildCodeName(
|
||||
BUILD_PLATFORM_NAMES,
|
||||
stringField(fields, "platform"),
|
||||
),
|
||||
minimum_os: stringField(fields, "minos"),
|
||||
sdk: stringField(fields, "sdk"),
|
||||
tools,
|
||||
@@ -434,7 +487,7 @@ const parseBuildTools = (
|
||||
const tool = /^tool\s+(.+)$/u.exec(lines[index] ?? "")?.[1];
|
||||
const version = /^version\s+(.+)$/u.exec(lines[index + 1] ?? "")?.[1];
|
||||
if (tool !== undefined && version !== undefined)
|
||||
tools.push({ name: tool, version });
|
||||
tools.push({ name: BUILD_TOOL_NAMES.get(tool) ?? tool, version });
|
||||
}
|
||||
return tools;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
RECORDED_LINUX_CORE_ARCHITECTURES,
|
||||
RECORDED_LINUX_CORE_PROFILES,
|
||||
} from "../../domain/native/recordedCrash.js";
|
||||
import {
|
||||
recordedCrashFixture,
|
||||
recordedCrashFixtureBytes,
|
||||
} from "../../../tests/fixtures/binaryDiagnostics/recordedCrash.js";
|
||||
import { validateRecordedCrashSources } from "./RecordedCrashSourceBindings.js";
|
||||
|
||||
const diagnostics = { stdout: "", stderr: "", truncated: false } as const;
|
||||
|
||||
const bind = (
|
||||
architecture?: "x86_64-little-endian" | "aarch64-little-endian",
|
||||
) => {
|
||||
const report = recordedCrashFixture("/selected.core", architecture);
|
||||
return { report, bytes: recordedCrashFixtureBytes(report) };
|
||||
};
|
||||
|
||||
describe("recorded core source bindings", () => {
|
||||
it.each(RECORDED_LINUX_CORE_ARCHITECTURES)(
|
||||
"binds each %s register to its own source bytes",
|
||||
(architecture) => {
|
||||
const { report, bytes } = bind(architecture);
|
||||
expect(() =>
|
||||
validateRecordedCrashSources(report, bytes, diagnostics),
|
||||
).not.toThrow();
|
||||
},
|
||||
);
|
||||
|
||||
it("measures the AArch64 register window one slot wide per name", () => {
|
||||
const profile = RECORDED_LINUX_CORE_PROFILES["aarch64-little-endian"];
|
||||
expect(profile.registers.length).toBe(34);
|
||||
expect(profile.prstatusBytes).toBe(392);
|
||||
expect(profile.machine).toBe(183);
|
||||
// The fixtures and the bindings both read these numbers, so they are pinned
|
||||
// here against the ABI record rather than only used consistently.
|
||||
expect(profile.registerOffset).toBe(112);
|
||||
expect(profile.registerBytes).toBe(8);
|
||||
const amd64 = RECORDED_LINUX_CORE_PROFILES["x86_64-little-endian"];
|
||||
expect(amd64.registers.length).toBe(27);
|
||||
expect(amd64.prstatusBytes).toBe(336);
|
||||
expect(amd64.machine).toBe(62);
|
||||
expect(amd64.registerOffset).toBe(112);
|
||||
expect(amd64.registerBytes).toBe(8);
|
||||
});
|
||||
|
||||
it("stamps each crafted core with the machine its profile declares", () => {
|
||||
for (const architecture of RECORDED_LINUX_CORE_ARCHITECTURES) {
|
||||
const bytes = bind(architecture).bytes;
|
||||
expect(bytes.readUInt16LE(18)).toBe(
|
||||
RECORDED_LINUX_CORE_PROFILES[architecture].machine,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects an AArch64 register bound to another slot", () => {
|
||||
const { report, bytes } = bind("aarch64-little-endian");
|
||||
const shifted = {
|
||||
...report,
|
||||
threads: report.threads.map((thread) => ({
|
||||
...thread,
|
||||
registers: thread.registers.map((register, index) =>
|
||||
index === 3
|
||||
? {
|
||||
...register,
|
||||
location: {
|
||||
...register.location,
|
||||
offset: `0x${(BigInt(register.location.offset) + 8n).toString(
|
||||
16,
|
||||
)}`,
|
||||
},
|
||||
}
|
||||
: register,
|
||||
),
|
||||
})),
|
||||
};
|
||||
expect(() =>
|
||||
validateRecordedCrashSources(shifted, bytes, diagnostics),
|
||||
).toThrow(/aarch64-little-endian register identity/);
|
||||
});
|
||||
|
||||
it("rejects an AArch64 note shorter than the profile descriptor", () => {
|
||||
const { report, bytes } = bind("aarch64-little-endian");
|
||||
const shortened = {
|
||||
...report,
|
||||
notes: report.notes.map((note, index) =>
|
||||
index === 0
|
||||
? {
|
||||
...note,
|
||||
descriptor_location: {
|
||||
...note.descriptor_location,
|
||||
bytes: "0x150",
|
||||
},
|
||||
}
|
||||
: note,
|
||||
),
|
||||
};
|
||||
expect(() =>
|
||||
validateRecordedCrashSources(shortened, bytes, diagnostics),
|
||||
).toThrow(/aarch64-little-endian source bytes/);
|
||||
});
|
||||
|
||||
it("rejects an amd64 register name reported for AArch64", () => {
|
||||
const { report, bytes } = bind("aarch64-little-endian");
|
||||
const renamed = {
|
||||
...report,
|
||||
threads: report.threads.map((thread) => ({
|
||||
...thread,
|
||||
registers: thread.registers.map((register, index) =>
|
||||
index === 0 ? { ...register, name: "rax" } : register,
|
||||
),
|
||||
})),
|
||||
};
|
||||
expect(() =>
|
||||
validateRecordedCrashSources(renamed, bytes, diagnostics),
|
||||
).toThrow(/aarch64-little-endian register identity/);
|
||||
});
|
||||
|
||||
it("rejects a reported PID that differs from the recorded source bytes", () => {
|
||||
const { report, bytes } = bind("aarch64-little-endian");
|
||||
const wrongPid = {
|
||||
...report,
|
||||
threads: report.threads.map((thread) => ({
|
||||
...thread,
|
||||
historical_pid: thread.historical_pid + 1,
|
||||
})),
|
||||
};
|
||||
expect(() =>
|
||||
validateRecordedCrashSources(wrongPid, bytes, diagnostics),
|
||||
).toThrow(/aarch64-little-endian source bytes/);
|
||||
});
|
||||
});
|
||||
@@ -1,23 +1,26 @@
|
||||
import { AnalysisOutputError } from "../../domain/analysisErrorCore.js";
|
||||
import {
|
||||
RECORDED_LINUX_AMD64_REGISTERS,
|
||||
RECORDED_LINUX_CORE_PROFILES,
|
||||
type RecordedCrash,
|
||||
} from "../../domain/native/recordedCrash.js";
|
||||
|
||||
const registerOffsets = new Map<string, number>(
|
||||
RECORDED_LINUX_AMD64_REGISTERS.map((name, index) => [name, 112 + index * 8]),
|
||||
);
|
||||
|
||||
/** Bind interpreted values and register identities to Linux amd64 source bytes. */
|
||||
/** Bind interpreted values and register identities to recorded source bytes. */
|
||||
export function validateRecordedCrashSources(
|
||||
report: RecordedCrash,
|
||||
snapshot: Buffer,
|
||||
diagnostics: RecordedCrash["decoder_diagnostics"],
|
||||
): void {
|
||||
const profile = RECORDED_LINUX_CORE_PROFILES[report.architecture];
|
||||
const registerOffsets = new Map<string, number>(
|
||||
profile.registers.map((name, index) => [
|
||||
name,
|
||||
profile.registerOffset + index * profile.registerBytes,
|
||||
]),
|
||||
);
|
||||
const fail = () => {
|
||||
throw new AnalysisOutputError(
|
||||
"inspect_recorded_crash",
|
||||
"Reported PID or signal value differs from its recorded Linux amd64 source bytes.",
|
||||
`Reported PID or signal value differs from its recorded ${report.architecture} source bytes.`,
|
||||
{ capturedOutput: diagnostics },
|
||||
);
|
||||
};
|
||||
@@ -29,10 +32,10 @@ export function validateRecordedCrashSources(
|
||||
if (length < minimumBytes) return fail();
|
||||
return snapshot.subarray(start, start + length);
|
||||
};
|
||||
// Fixed offsets independently checked against unchanged pwntools 4.15.0
|
||||
// elf_prstatus_amd64 / elf_siginfo_64; this is the sole supported note ABI.
|
||||
// Offsets come from unchanged pwntools 4.15.0 elf_prstatus_amd64 /
|
||||
// elf_prstatus_aarch64 and elf_siginfo_64 through the shared domain profile.
|
||||
for (const thread of report.threads) {
|
||||
const bytes = descriptor(thread.note_index, 336);
|
||||
const bytes = descriptor(thread.note_index, profile.prstatusBytes);
|
||||
if (
|
||||
thread.historical_pid !== bytes.readInt32LE(32) ||
|
||||
thread.recorded_current_signal !== bytes.readInt16LE(12)
|
||||
@@ -49,7 +52,7 @@ export function validateRecordedCrashSources(
|
||||
)
|
||||
throw new AnalysisOutputError(
|
||||
"inspect_recorded_crash",
|
||||
"Reported register source does not match its Linux amd64 register identity.",
|
||||
`Reported register source does not match its ${report.architecture} register identity.`,
|
||||
{ capturedOutput: diagnostics },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { AnalysisOutputError } from "../../domain/analysisErrorCore.js";
|
||||
import type { RecordedCrash } from "../../domain/native/recordedCrash.js";
|
||||
import {
|
||||
RECORDED_LINUX_CORE_PROFILES,
|
||||
type RecordedCrash,
|
||||
} from "../../domain/native/recordedCrash.js";
|
||||
|
||||
// Exact amd64/core enum projections in the pinned pyelftools 0.33 profile.
|
||||
const segmentKinds = new Map([
|
||||
@@ -81,6 +84,8 @@ export function validateRecordedCrashStructure(
|
||||
return snapshot.subarray(Number(offset), Number(offset) + bytes);
|
||||
};
|
||||
const header = bytesAt(0n, 64);
|
||||
const expectedMachine =
|
||||
RECORDED_LINUX_CORE_PROFILES[report.architecture].machine;
|
||||
if (
|
||||
!header
|
||||
.subarray(0, 6)
|
||||
@@ -90,7 +95,7 @@ export function validateRecordedCrashStructure(
|
||||
![0, 3].includes(header[7] ?? -1) ||
|
||||
header[8] !== 0 ||
|
||||
header.readUInt16LE(16) !== 4 ||
|
||||
header.readUInt16LE(18) !== 62
|
||||
header.readUInt16LE(18) !== expectedMachine
|
||||
)
|
||||
fail();
|
||||
const table = header.readBigUInt64LE(32);
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import { compareWasmInterfaces } from "../application/wasm/WasmInterfaceComparisonService.js";
|
||||
import { COMPARE_WASM_INTERFACES_CONTRACT } from "../contracts/wasm/wasmComparisonContracts.js";
|
||||
import { resolvePairedEvidenceRequest } from "../application/EvidenceInputResolver.js";
|
||||
import { recordSessionEvidenceSources } from "./sessionEvidence.js";
|
||||
import type { EvidenceMcpServer } from "./EvidenceMcpServer.js";
|
||||
|
||||
import type { WasmArtifactService } from "../application/wasm/WasmArtifactService.js";
|
||||
@@ -21,6 +25,38 @@ export const registerWasmTools = (
|
||||
withAdmittedAnalysis?: WithAdmittedAnalysis,
|
||||
evidenceById?: EvidenceLookup,
|
||||
): void => {
|
||||
const comparison = COMPARE_WASM_INTERFACES_CONTRACT;
|
||||
const compareHandler = createEvidenceToolHandler(
|
||||
server,
|
||||
async (rawInput, options) => {
|
||||
const input = comparison.inputSchema.safeParse(rawInput);
|
||||
if (!input.success)
|
||||
return err(
|
||||
analysisInputErrorFromIssues(
|
||||
comparison.name,
|
||||
input.error.issues,
|
||||
rawInput,
|
||||
),
|
||||
);
|
||||
const sources = resolvePairedEvidenceRequest(input.data, evidenceById);
|
||||
if (!sources.ok) return sources;
|
||||
const result = await compareWasmInterfaces(sources.value, options);
|
||||
if (!result.ok) return result;
|
||||
const recorded = recordSessionEvidenceSources(recordEvidence, [
|
||||
sources.value.left,
|
||||
sources.value.right,
|
||||
]);
|
||||
return recorded.ok ? result : recorded;
|
||||
},
|
||||
logger,
|
||||
recordEvidence,
|
||||
withAdmittedAnalysis,
|
||||
);
|
||||
server.registerTool(
|
||||
comparison.name,
|
||||
toolRegistrationOptions(comparison),
|
||||
compareHandler(comparison),
|
||||
);
|
||||
const contract = wasmArtifactModule.contract;
|
||||
const handler = createEvidenceToolHandler(
|
||||
server,
|
||||
|
||||
@@ -362,8 +362,13 @@ export class WabtArtifactProvider implements WasmArtifactPort {
|
||||
OPERATION,
|
||||
"Unexpected WABT decoding output.",
|
||||
);
|
||||
const { imports, exports, customSectionNames, debugModuleNames } =
|
||||
parseWabtWat(wat.stdout.text);
|
||||
const {
|
||||
imports,
|
||||
exports,
|
||||
customSectionNames,
|
||||
debugModuleNames,
|
||||
interface: interfaceFacts,
|
||||
} = parseWabtWat(wat.stdout.text);
|
||||
capturedOutput = {
|
||||
stdout: dump.stdout.text,
|
||||
stderr: dump.stderr.text,
|
||||
@@ -388,6 +393,7 @@ export class WabtArtifactProvider implements WasmArtifactPort {
|
||||
sections,
|
||||
imports,
|
||||
exports,
|
||||
interface: interfaceFacts,
|
||||
headers,
|
||||
details,
|
||||
wat: {
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { parseWabtWat } from "./WabtWat.js";
|
||||
|
||||
it("resolves imported index spaces and decoded escaped names without depending on body/type numbering", () => {
|
||||
const report = parseWabtWat(`(module
|
||||
(type (;0;) (func (param i32) (result i64)))
|
||||
(import "a\\22b" "\\ef\\bb\\bf\\00" (func (;0;) (type 0)))
|
||||
(func (;1;) (type 0) (param i32) (result i64) local.get 0 i64.extend_i32_s)
|
||||
(export "" (func 1)))`).interface;
|
||||
expect(report.entries).toMatchObject([
|
||||
{
|
||||
direction: "import",
|
||||
module: 'a"b',
|
||||
name: "\ufeff\0",
|
||||
index: 0,
|
||||
signature: { kind: "func", params: ["i32"], results: ["i64"] },
|
||||
},
|
||||
{
|
||||
direction: "export",
|
||||
name: "",
|
||||
index: 1,
|
||||
signature: { kind: "func", params: ["i32"], results: ["i64"] },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("retains exact memory/table/global/tag declarations and keeps unsupported references unknown", () => {
|
||||
const report = parseWabtWat(`(module
|
||||
(type (func (param i32)))
|
||||
(type (struct (field i32)))
|
||||
(import "env" "table" (table 1 3 funcref))
|
||||
(memory i64 2 4 shared)
|
||||
(global (mut i64) (i64.const 1))
|
||||
(tag (type 0))
|
||||
(func (type 0))
|
||||
(func (param (ref null 1)))
|
||||
(export "memory" (memory 0)) (export "global" (global 0))
|
||||
(export "tag" (tag 0)) (export "gc" (func 1)))`).interface;
|
||||
expect(report.entries.map((e) => e.signature)).toEqual([
|
||||
{
|
||||
kind: "table",
|
||||
element: "funcref",
|
||||
address_type: "i32",
|
||||
minimum: "1",
|
||||
maximum: "3",
|
||||
shared: false,
|
||||
},
|
||||
{
|
||||
kind: "memory",
|
||||
page_size: "65536",
|
||||
address_type: "i64",
|
||||
minimum: "2",
|
||||
maximum: "4",
|
||||
shared: true,
|
||||
},
|
||||
{ kind: "global", value_type: "i64", mutable: true },
|
||||
{ kind: "tag", params: ["i32"], results: [] },
|
||||
null,
|
||||
]);
|
||||
expect(report.entries[4]?.issue).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does not bind recursive-type index spaces by guessing their cardinality", () => {
|
||||
const report = parseWabtWat(
|
||||
`(module (rec (type (struct)) (type (func))) (func (type 1)) (export "f" (func 0)))`,
|
||||
).interface;
|
||||
expect(report.entries).toMatchObject([
|
||||
{ name: "f", signature: null, issue: expect.any(String) },
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves custom memory page sizes and treats unadmitted nested declarations as unknown", () => {
|
||||
const custom = parseWabtWat(
|
||||
'(module (memory 1 2 (pagesize 1)) (export "m" (memory 0)))',
|
||||
).interface;
|
||||
expect(custom.entries[0]?.signature).toMatchObject({
|
||||
kind: "memory",
|
||||
page_size: "1",
|
||||
});
|
||||
const unknown = parseWabtWat(
|
||||
'(module (memory 1 (future-proposal 0)) (export "m" (memory 0)))',
|
||||
).interface;
|
||||
expect(unknown.entries[0]?.signature).toBeNull();
|
||||
});
|
||||
|
||||
it("refuses typed name expansion within the source WABT byte cap before retaining interface output", () => {
|
||||
const name = "x".repeat(6 * 1024 * 1024);
|
||||
expect(
|
||||
parseWabtWat(`(module (import "${name}" "f" (func)))`).interface,
|
||||
).toMatchObject({
|
||||
state: "partial",
|
||||
entries: [],
|
||||
issues: [expect.stringMatching(/representation budget/u)],
|
||||
});
|
||||
});
|
||||
|
||||
it("charges shared type signatures at every retained occurrence and preserves bounded partial facts", () => {
|
||||
const params = Array(1000).fill("i32").join(" ");
|
||||
const exports = Array.from(
|
||||
{ length: 10000 },
|
||||
(_, i) => `(export "f${i}" (func 0))`,
|
||||
).join(" ");
|
||||
const report = parseWabtWat(
|
||||
`(module (type (func (param ${params}))) (import "e" "f" (func (type 0))) ${exports})`,
|
||||
).interface;
|
||||
expect(report.state).toBe("partial");
|
||||
expect(report.entries.length).toBeGreaterThan(1);
|
||||
expect(report.entries.length).toBeLessThan(10001);
|
||||
expect(report.entries[0]?.signature).toMatchObject({
|
||||
kind: "func",
|
||||
params: expect.any(Array),
|
||||
});
|
||||
expect(Buffer.byteLength(JSON.stringify(report))).toBeLessThan(
|
||||
32 * 1024 * 1024,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,325 @@
|
||||
import { AnalysisResourceConstraintError } from "../domain/analysisErrorCore.js";
|
||||
import { wasmInterfaceEntryRepresentationBytes } from "../domain/wasm/wasmInterface.js";
|
||||
import { WasmInterfaceBudget } from "./WasmInterfaceBudget.js";
|
||||
import type {
|
||||
WasmInterface,
|
||||
WasmInterfaceEntry,
|
||||
WasmInterfaceSignature,
|
||||
WasmInterfaceValueType,
|
||||
} from "../domain/wasm/wasmInterface.js";
|
||||
import { watString } from "./WabtString.js";
|
||||
|
||||
/** Split direct WAT children only; function instruction bodies are never materialized. */
|
||||
const parts = (form: string): string[] => {
|
||||
const budget = new WasmInterfaceBudget();
|
||||
const items: string[] = [];
|
||||
let i = 1;
|
||||
while (i < form.length - 1) {
|
||||
if (/\s/u.test(form[i] ?? "")) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (form.startsWith("(;", i)) {
|
||||
let depth = 1;
|
||||
i += 2;
|
||||
while (i < form.length && depth > 0) {
|
||||
if (form.startsWith("(;", i)) {
|
||||
depth++;
|
||||
i += 2;
|
||||
} else if (form.startsWith(";)", i)) {
|
||||
depth--;
|
||||
i += 2;
|
||||
} else i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (form.startsWith(";;", i)) {
|
||||
const end = form.indexOf("\n", i);
|
||||
i = end < 0 ? form.length : end + 1;
|
||||
continue;
|
||||
}
|
||||
const start = i;
|
||||
if (form[i] === '"') {
|
||||
i = watString(form, i + 1).end;
|
||||
} else if (form[i] === "(") {
|
||||
let depth = 1;
|
||||
i++;
|
||||
while (i < form.length && depth > 0) {
|
||||
if (form[i] === '"') i = watString(form, i + 1).end;
|
||||
else {
|
||||
if (form[i] === "(") depth++;
|
||||
else if (form[i] === ")") depth--;
|
||||
i++;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
while (i < form.length && !/[\s()]/u.test(form[i] ?? "")) i++;
|
||||
const atom = form.slice(start, i);
|
||||
if (items[0] === "func" && items.length > 1 && !atom.startsWith("$"))
|
||||
break;
|
||||
}
|
||||
if (i <= start) throw new Error("Malformed WABT form");
|
||||
budget.reserve(64 + (i - start) * 2);
|
||||
items.push(form.slice(start, i));
|
||||
}
|
||||
return items;
|
||||
};
|
||||
const head = (form: string): string | undefined => parts(form)[0];
|
||||
const admittedValues = new Set([
|
||||
"i32",
|
||||
"i64",
|
||||
"f32",
|
||||
"f64",
|
||||
"v128",
|
||||
"funcref",
|
||||
"externref",
|
||||
]);
|
||||
const isValueType = (type: string): type is WasmInterfaceValueType =>
|
||||
admittedValues.has(type);
|
||||
const values = (
|
||||
form: string,
|
||||
category: string,
|
||||
): WasmInterfaceValueType[] | null => {
|
||||
const result: WasmInterfaceValueType[] = [];
|
||||
for (const item of parts(form)) {
|
||||
if (!item.startsWith("(")) continue;
|
||||
const group = parts(item);
|
||||
if (group[0] !== category) continue;
|
||||
for (const type of group.slice(1).filter((t) => !t.startsWith("$"))) {
|
||||
if (!isValueType(type)) return null;
|
||||
result.push(type);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
const functionType = (
|
||||
form: string,
|
||||
kind: "func" | "tag",
|
||||
): WasmInterfaceSignature | null => {
|
||||
const params = values(form, "param");
|
||||
const results = values(form, "result");
|
||||
return params === null || results === null ? null : { kind, params, results };
|
||||
};
|
||||
const name = (text: string | undefined): string => {
|
||||
if (text?.startsWith('"') !== true)
|
||||
throw new Error("WABT interface name is missing");
|
||||
return new TextDecoder("utf8", { fatal: true, ignoreBOM: true }).decode(
|
||||
watString(text, 1).bytes,
|
||||
);
|
||||
};
|
||||
const integer = (text: string): string | null => {
|
||||
if (!/^(?:\d+|0x[\da-f]+)$/iu.test(text)) return null;
|
||||
return BigInt(text).toString();
|
||||
};
|
||||
|
||||
/** Resolve index spaces at the producer, not from retained raw WAT in consumers. */
|
||||
const decodeWabtInterface = (
|
||||
forms: readonly string[],
|
||||
result: WasmInterface,
|
||||
): WasmInterface => {
|
||||
const budget = new WasmInterfaceBudget();
|
||||
const types: (WasmInterfaceSignature | null)[] = [];
|
||||
const typeNames = new Map<string, number>();
|
||||
const definitions = new Map<
|
||||
string,
|
||||
{ signature: WasmInterfaceSignature | null; label: string | undefined }[]
|
||||
>();
|
||||
const typeSpaceUnknown = forms.some((f) => head(f) === "rec");
|
||||
for (const form of forms) {
|
||||
const p = parts(form);
|
||||
if (p[0] !== "type") continue;
|
||||
if (p[1]?.startsWith("$") === true) typeNames.set(p[1], types.length);
|
||||
const body = p.find((item) => item.startsWith("("));
|
||||
budget.reserve(256 + (body?.length ?? 0) * 6);
|
||||
types.push(
|
||||
body !== undefined && head(body) === "func" && !typeSpaceUnknown
|
||||
? functionType(body, "func")
|
||||
: null,
|
||||
);
|
||||
}
|
||||
const signature = (form: string): WasmInterfaceSignature | null => {
|
||||
const p = parts(form);
|
||||
const kind = p[0];
|
||||
if (kind === "func" || kind === "tag") {
|
||||
const use = p.find((t) => t.startsWith("(type "));
|
||||
if (use === undefined) return functionType(form, kind);
|
||||
const reference = parts(use)[1];
|
||||
const index =
|
||||
reference === undefined
|
||||
? undefined
|
||||
: reference.startsWith("$")
|
||||
? typeNames.get(reference)
|
||||
: Number(reference);
|
||||
const type =
|
||||
index === undefined || typeSpaceUnknown ? undefined : types[index];
|
||||
return type?.kind === "func" ? { ...type, kind } : null;
|
||||
}
|
||||
if (kind === "memory" || kind === "table") {
|
||||
const groups = p.slice(1).filter((t) => t.startsWith("("));
|
||||
let pageSize = "65536";
|
||||
for (const group of groups) {
|
||||
const declaration = parts(group);
|
||||
if (
|
||||
kind !== "memory" ||
|
||||
declaration[0] !== "pagesize" ||
|
||||
declaration.length !== 2
|
||||
)
|
||||
return null;
|
||||
const parsedPageSize = integer(declaration[1] ?? "");
|
||||
if (parsedPageSize === null) return null;
|
||||
pageSize = parsedPageSize;
|
||||
}
|
||||
const tokens = p
|
||||
.slice(1)
|
||||
.filter((t) => !t.startsWith("$") && !t.startsWith("("));
|
||||
const address_type = tokens[0] === "i64" ? "i64" : "i32";
|
||||
if (tokens[0] === "i64" || tokens[0] === "i32") tokens.shift();
|
||||
const minimum = integer(tokens.shift() ?? "");
|
||||
const possibleMaximum =
|
||||
tokens[0] === undefined ? null : integer(tokens[0]);
|
||||
if (possibleMaximum !== null) tokens.shift();
|
||||
const shared = tokens[0] === "shared";
|
||||
if (shared) tokens.shift();
|
||||
if (minimum === null) return null;
|
||||
if (kind === "memory")
|
||||
return tokens.length === 0
|
||||
? {
|
||||
kind,
|
||||
page_size: pageSize,
|
||||
address_type,
|
||||
minimum,
|
||||
maximum: possibleMaximum,
|
||||
shared,
|
||||
}
|
||||
: null;
|
||||
const element = tokens.shift();
|
||||
return tokens.length === 0 &&
|
||||
element !== undefined &&
|
||||
(element === "funcref" || element === "externref")
|
||||
? {
|
||||
kind,
|
||||
address_type,
|
||||
minimum,
|
||||
maximum: possibleMaximum,
|
||||
shared,
|
||||
element,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
if (kind === "global") {
|
||||
const declaration = p[1]?.startsWith("$") === true ? p[2] : p[1];
|
||||
const mutable = declaration?.startsWith("(mut ") === true;
|
||||
const value_type =
|
||||
mutable && declaration !== undefined
|
||||
? parts(declaration)[1]
|
||||
: declaration;
|
||||
return value_type !== undefined && isValueType(value_type)
|
||||
? { kind, value_type, mutable }
|
||||
: null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const addDefinition = (
|
||||
form: string,
|
||||
): {
|
||||
index: number;
|
||||
kind: WasmInterfaceEntry["kind"];
|
||||
signature: WasmInterfaceSignature | null;
|
||||
} => {
|
||||
const p = parts(form);
|
||||
const rawKind = p[0] ?? "unknown";
|
||||
const kind = ["func", "table", "memory", "global", "tag"].includes(rawKind)
|
||||
? (rawKind as WasmInterfaceEntry["kind"])
|
||||
: "unknown";
|
||||
const space = definitions.get(kind) ?? [];
|
||||
const item = {
|
||||
signature: signature(form),
|
||||
label: p[1]?.startsWith("$") === true ? p[1] : undefined,
|
||||
};
|
||||
budget.reserve(256);
|
||||
const index = space.length;
|
||||
space.push(item);
|
||||
definitions.set(kind, space);
|
||||
return { index, kind, signature: item.signature };
|
||||
};
|
||||
for (const form of forms) {
|
||||
const p = parts(form);
|
||||
if (p[0] !== "import") continue;
|
||||
const body = p[3];
|
||||
if (body === undefined) throw new Error("Incomplete WABT import");
|
||||
const observed = addDefinition(body);
|
||||
const entry: WasmInterfaceEntry = {
|
||||
direction: "import",
|
||||
module: name(p[1]),
|
||||
name: name(p[2]),
|
||||
...observed,
|
||||
wat_form: form,
|
||||
issue:
|
||||
observed.signature === null
|
||||
? "Import type is outside admitted core signature semantics"
|
||||
: null,
|
||||
};
|
||||
budget.reserve(wasmInterfaceEntryRepresentationBytes(entry));
|
||||
result.entries.push(entry);
|
||||
}
|
||||
for (const form of forms) {
|
||||
const kind = head(form);
|
||||
if (
|
||||
kind !== undefined &&
|
||||
["func", "table", "memory", "global", "tag"].includes(kind)
|
||||
)
|
||||
addDefinition(form);
|
||||
}
|
||||
for (const form of forms) {
|
||||
const p = parts(form);
|
||||
if (p[0] !== "export") continue;
|
||||
const body = p[2];
|
||||
if (body === undefined) throw new Error("Incomplete WABT export");
|
||||
const descriptor = parts(body);
|
||||
const rawKind = descriptor[0] ?? "unknown";
|
||||
const kind = ["func", "table", "memory", "global", "tag"].includes(rawKind)
|
||||
? (rawKind as WasmInterfaceEntry["kind"])
|
||||
: "unknown";
|
||||
const reference = descriptor[1];
|
||||
const space = definitions.get(kind) ?? [];
|
||||
const index =
|
||||
reference === undefined
|
||||
? -1
|
||||
: reference.startsWith("$")
|
||||
? space.findIndex((d) => d.label === reference)
|
||||
: Number(reference);
|
||||
const observed =
|
||||
Number.isSafeInteger(index) && index >= 0 ? space[index] : undefined;
|
||||
const entry: WasmInterfaceEntry = {
|
||||
direction: "export",
|
||||
module: null,
|
||||
name: name(p[1]),
|
||||
kind,
|
||||
index: observed === undefined ? null : index,
|
||||
signature: observed?.signature ?? null,
|
||||
wat_form: form,
|
||||
issue:
|
||||
observed?.signature == null
|
||||
? "Export type/index is outside admitted core signature semantics"
|
||||
: null,
|
||||
};
|
||||
budget.reserve(wasmInterfaceEntryRepresentationBytes(entry));
|
||||
result.entries.push(entry);
|
||||
}
|
||||
// Unsupported signatures leave inventory completeness intact. A missing
|
||||
// signature is unknown per entry, not proof that a name is absent.
|
||||
return result;
|
||||
};
|
||||
|
||||
/** New typed coverage may be incomplete without discarding existing decoded WAT Evidence. */
|
||||
export const parseWabtInterface = (forms: readonly string[]): WasmInterface => {
|
||||
const result: WasmInterface = { state: "complete", entries: [], issues: [] };
|
||||
try {
|
||||
return decodeWabtInterface(forms, result);
|
||||
} catch (cause: unknown) {
|
||||
if (!(cause instanceof AnalysisResourceConstraintError)) throw cause;
|
||||
result.state = "partial";
|
||||
result.issues.push(cause.message);
|
||||
return result;
|
||||
}
|
||||
};
|
||||
@@ -229,7 +229,7 @@ it("extracts escaped WAT import/export forms while retaining multiline producer
|
||||
new URL("../../tests/fixtures/wasm/decoded.wat", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
expect(parseWabtWat(text)).toEqual({
|
||||
expect(parseWabtWat(text)).toMatchObject({
|
||||
imports: ['(import "env" "foo" (func (;0;) (type 0)))'],
|
||||
exports: ['(export "memory" (memory 0))', '(export "run" (func 1))'],
|
||||
customSectionNames: ["test"],
|
||||
@@ -239,7 +239,7 @@ it("extracts escaped WAT import/export forms while retaining multiline producer
|
||||
parseWabtWat(
|
||||
'(module (import "a.b" "c\\0aline" (func)) (export "fake\\22Export[1]:" (func 0)))',
|
||||
),
|
||||
).toEqual({
|
||||
).toMatchObject({
|
||||
imports: ['(import "a.b" "c\\0aline" (func))'],
|
||||
exports: ['(export "fake\\22Export[1]:" (func 0))'],
|
||||
customSectionNames: [],
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
/** Decode a producer WAT string to frame objdump's raw display text. */
|
||||
export const watString = (form: string, start: number) => {
|
||||
let end = start;
|
||||
while (end < form.length && form[end] !== '"') {
|
||||
if (form[end] === "\\") end++;
|
||||
end++;
|
||||
}
|
||||
if (end === form.length)
|
||||
throw new Error("Incomplete WABT custom-section name.");
|
||||
const name = form.slice(start, end);
|
||||
// Escapes shrink; literal UTF-8 has the same byte extent as its source.
|
||||
const bytes = Buffer.alloc(Buffer.byteLength(name, "utf8"));
|
||||
let length = 0;
|
||||
for (let index = 0; index < name.length;) {
|
||||
if (name[index] !== "\\") {
|
||||
const next = name.indexOf("\\", index);
|
||||
const end = next < 0 ? name.length : next;
|
||||
length += bytes.write(name.slice(index, end), length, "utf8");
|
||||
index = end;
|
||||
continue;
|
||||
}
|
||||
const hex = name.slice(index + 1, index + 3);
|
||||
if (/^[\da-f]{2}$/iu.test(hex)) {
|
||||
bytes[length++] = Number.parseInt(hex, 16);
|
||||
index += 3;
|
||||
} else {
|
||||
const escape = name[index + 1];
|
||||
const value =
|
||||
escape === "t"
|
||||
? 9
|
||||
: escape === "n"
|
||||
? 10
|
||||
: escape === "r"
|
||||
? 13
|
||||
: escape === '"'
|
||||
? 34
|
||||
: escape === "\\"
|
||||
? 92
|
||||
: undefined;
|
||||
if (value === undefined) throw new Error("Unsupported WABT name escape.");
|
||||
bytes[length++] = value;
|
||||
index += 2;
|
||||
}
|
||||
}
|
||||
return { bytes: bytes.subarray(0, length), end: end + 1 };
|
||||
};
|
||||
+16
-47
@@ -1,49 +1,5 @@
|
||||
/** Decode a producer WAT string to frame objdump's raw display text. */
|
||||
const watString = (form: string, start: number) => {
|
||||
let end = start;
|
||||
while (end < form.length && form[end] !== '"') {
|
||||
if (form[end] === "\\") end++;
|
||||
end++;
|
||||
}
|
||||
if (end === form.length)
|
||||
throw new Error("Incomplete WABT custom-section name.");
|
||||
const name = form.slice(start, end);
|
||||
// Escapes shrink; literal UTF-8 has the same byte extent as its source.
|
||||
const bytes = Buffer.alloc(Buffer.byteLength(name, "utf8"));
|
||||
let length = 0;
|
||||
for (let index = 0; index < name.length;) {
|
||||
if (name[index] !== "\\") {
|
||||
const next = name.indexOf("\\", index);
|
||||
const end = next < 0 ? name.length : next;
|
||||
length += bytes.write(name.slice(index, end), length, "utf8");
|
||||
index = end;
|
||||
continue;
|
||||
}
|
||||
const hex = name.slice(index + 1, index + 3);
|
||||
if (/^[\da-f]{2}$/iu.test(hex)) {
|
||||
bytes[length++] = Number.parseInt(hex, 16);
|
||||
index += 3;
|
||||
} else {
|
||||
const escape = name[index + 1];
|
||||
const value =
|
||||
escape === "t"
|
||||
? 9
|
||||
: escape === "n"
|
||||
? 10
|
||||
: escape === "r"
|
||||
? 13
|
||||
: escape === '"'
|
||||
? 34
|
||||
: escape === "\\"
|
||||
? 92
|
||||
: undefined;
|
||||
if (value === undefined) throw new Error("Unsupported WABT name escape.");
|
||||
bytes[length++] = value;
|
||||
index += 2;
|
||||
}
|
||||
}
|
||||
return { bytes: bytes.subarray(0, length), end: end + 1 };
|
||||
};
|
||||
import { parseWabtInterface } from "./WabtInterface.js";
|
||||
import { watString } from "./WabtString.js";
|
||||
|
||||
/** Read only module-name metadata from WABT's preserved name-section payload. */
|
||||
const moduleNames = (bytes: Uint8Array): string[] => {
|
||||
@@ -85,6 +41,7 @@ const moduleNames = (bytes: Uint8Array): string[] => {
|
||||
export const parseWabtWat = (text: string) => {
|
||||
if (!/^\(module(?:\s|\))/u.test(text))
|
||||
throw new Error("Unsupported WABT WAT module prefix.");
|
||||
const forms: string[] = [];
|
||||
const imports: string[] = [];
|
||||
const exports: string[] = [];
|
||||
const customSectionNames: string[] = [];
|
||||
@@ -140,6 +97,12 @@ export const parseWabtWat = (text: string) => {
|
||||
} else if (char === ")") {
|
||||
if (depth === 2) {
|
||||
const form = text.slice(start, index + 1);
|
||||
if (
|
||||
/^\((?:type|rec|import|export|func|table|memory|global|tag)\s/u.test(
|
||||
form,
|
||||
)
|
||||
)
|
||||
forms.push(form);
|
||||
if (/^\(import\s/u.test(form)) imports.push(form);
|
||||
else if (/^\(export\s/u.test(form)) exports.push(form);
|
||||
else if (/^\(@custom\s/u.test(form)) {
|
||||
@@ -172,5 +135,11 @@ export const parseWabtWat = (text: string) => {
|
||||
}
|
||||
if (depth !== 0 || quoted || commentDepth !== 0)
|
||||
throw new Error("Incomplete WABT WAT output.");
|
||||
return { imports, exports, customSectionNames, debugModuleNames };
|
||||
return {
|
||||
imports,
|
||||
exports,
|
||||
customSectionNames,
|
||||
debugModuleNames,
|
||||
interface: parseWabtInterface(forms),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { AnalysisResourceConstraintError } from "../domain/analysisErrorCore.js";
|
||||
import { WASM_INTERFACE_REPRESENTATION_BYTES } from "../domain/wasm/wasmInterface.js";
|
||||
|
||||
/** Eager typed observations share the existing WABT output capacity, not an agent token estimate. */
|
||||
export class WasmInterfaceBudget {
|
||||
#remaining = WASM_INTERFACE_REPRESENTATION_BYTES;
|
||||
reserve(bytes: number): void {
|
||||
if (!Number.isSafeInteger(bytes) || bytes < 0 || bytes > this.#remaining)
|
||||
throw new AnalysisResourceConstraintError(
|
||||
"inspect_wasm_artifact",
|
||||
"capacity",
|
||||
"Typed WASM interface decoding exceeds the WABT output representation budget",
|
||||
{ maximum_bytes: WASM_INTERFACE_REPRESENTATION_BYTES },
|
||||
);
|
||||
this.#remaining -= bytes;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
||||
import { Ajv2020 } from "ajv/dist/2020.js";
|
||||
import { expect } from "vitest";
|
||||
import { createEvidence, parseEvidence } from "../../src/domain/evidence.js";
|
||||
import { wasmInterfaceComparisonSchema } from "../../src/contracts/wasm/wasmComparisonContracts.js";
|
||||
import { composeBinarySession } from "../../src/application/binary/BinarySessionComposition.js";
|
||||
import { AnalysisProviderRegistry } from "../../src/application/binary/AnalysisProviderRegistry.js";
|
||||
import { createServer } from "../../src/server/createServer.js";
|
||||
import { wasmArtifactFixture } from "../fixtures/wasm/artifact.js";
|
||||
import { cliTest } from "../support/cli/cliFixture.js";
|
||||
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
|
||||
|
||||
cliTest(
|
||||
"compares portable Evidence identically through CLI and retained MCP with schema/provenance",
|
||||
async ({ cli }) => {
|
||||
const sources = (["i32", "i64"] as const).map((parameter) => {
|
||||
const report = wasmArtifactFixture();
|
||||
report.interface = {
|
||||
state: "complete",
|
||||
issues: [],
|
||||
entries: [
|
||||
{
|
||||
direction: "export",
|
||||
module: null,
|
||||
name: 'quoted"\ufeffname',
|
||||
kind: "func",
|
||||
index: 0,
|
||||
signature: { kind: "func", params: [parameter], results: [] },
|
||||
wat_form: '(export "quoted\\22\\ef\\bb\\bfname" (func 0))',
|
||||
issue: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
return createEvidence(
|
||||
{
|
||||
path: report.artifact.path,
|
||||
sha256: report.artifact.sha256,
|
||||
format: "file",
|
||||
},
|
||||
{ id: "typed-fixture", name: "Typed fixture", version: "1" },
|
||||
{
|
||||
operation: "inspect_wasm_artifact",
|
||||
parameters: { path: report.artifact.path },
|
||||
result: report,
|
||||
confidence: "observed",
|
||||
},
|
||||
);
|
||||
});
|
||||
const [left, right] = sources;
|
||||
if (left === undefined || right === undefined)
|
||||
throw new Error("Missing paired fixture");
|
||||
const root = await createTestTempDirectory("rea-wasm-comparison-");
|
||||
const path = join(root, "input.json");
|
||||
const input = { left, right, scope: "both" };
|
||||
await writeFile(path, JSON.stringify(input));
|
||||
const cliResult = await cli.run({
|
||||
arguments: ["compare-wasm-interfaces", path, "--json"],
|
||||
});
|
||||
expect(cliResult.exitCode).toBe(0);
|
||||
const cliEvidence = parseEvidence(cliResult.json);
|
||||
const cliReport = wasmInterfaceComparisonSchema.parse(
|
||||
cliEvidence.normalized_result,
|
||||
);
|
||||
expect(cliReport).toMatchObject({
|
||||
coverage: "complete-within-inputs",
|
||||
changes: [{ name: 'quoted"\ufeffname', state: "changed" }],
|
||||
left: {
|
||||
evidence_id: left.evidence_id,
|
||||
artifact: {
|
||||
path: "/artifacts/module.wasm",
|
||||
sha256: "a".repeat(64),
|
||||
bytes: 8,
|
||||
},
|
||||
},
|
||||
});
|
||||
const session = composeBinarySession(new AnalysisProviderRegistry([]));
|
||||
const server = createServer({ kind: "session", session });
|
||||
const client = new Client({ name: "wasm-interface-parity", version: "1" });
|
||||
const [ct, st] = InMemoryTransport.createLinkedPair();
|
||||
try {
|
||||
await server.connect(st);
|
||||
await client.connect(ct);
|
||||
const tool = (await client.listTools()).tools.find(
|
||||
(t) => t.name === "compare_wasm_interfaces",
|
||||
);
|
||||
if (tool?.outputSchema === undefined)
|
||||
throw new Error("Missing comparison schema");
|
||||
const outputSchema: Record<string, unknown> = tool.outputSchema;
|
||||
const ajv = new Ajv2020({ strict: false, validateFormats: false });
|
||||
expect(ajv.validate(tool.inputSchema, input)).toBe(true);
|
||||
expect(ajv.validate(tool.inputSchema, { left, right })).toBe(false);
|
||||
const mcp = await client.callTool({ name: tool.name, arguments: input });
|
||||
expect(mcp.isError, JSON.stringify(mcp)).not.toBe(true);
|
||||
expect(
|
||||
ajv.validate(outputSchema, mcp.structuredContent),
|
||||
JSON.stringify(ajv.errors),
|
||||
).toBe(true);
|
||||
const mcpEvidence = parseEvidence(mcp.structuredContent);
|
||||
expect(mcpEvidence.normalized_result).toEqual(cliReport);
|
||||
expect(mcpEvidence.evidence_id).toBe(cliEvidence.evidence_id);
|
||||
expect(mcpEvidence.evidence_links).toEqual([
|
||||
left.evidence_id,
|
||||
right.evidence_id,
|
||||
]);
|
||||
const retained = await client.callTool({
|
||||
name: tool.name,
|
||||
arguments: {
|
||||
scope: "both",
|
||||
left: { kind: "retained-evidence", evidence_id: left.evidence_id },
|
||||
right: { kind: "retained-evidence", evidence_id: right.evidence_id },
|
||||
},
|
||||
});
|
||||
expect(retained.isError).not.toBe(true);
|
||||
expect(parseEvidence(retained.structuredContent).evidence_id).toBe(
|
||||
mcpEvidence.evidence_id,
|
||||
);
|
||||
const invalid = await client.callTool({
|
||||
name: tool.name,
|
||||
arguments: { ...input, scope: "all" },
|
||||
});
|
||||
expect(invalid.isError).toBe(true);
|
||||
} finally {
|
||||
await client.close();
|
||||
await server.close();
|
||||
await session.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -4,6 +4,40 @@ import {
|
||||
recordedCrashFixtureBytes,
|
||||
} from "../../fixtures/binaryDiagnostics/recordedCrash.js";
|
||||
import { validateRecordedCrashStructure } from "../../../src/native/pwntools/RecordedCrashStructureBindings.js";
|
||||
import { RECORDED_LINUX_CORE_PROFILES } from "../../../src/domain/native/recordedCrash.js";
|
||||
|
||||
it.each(["x86_64-little-endian", "aarch64-little-endian"] as const)(
|
||||
"admits a %s core whose header carries that profile's e_machine",
|
||||
(architecture) => {
|
||||
const report = recordedCrashFixture(undefined, architecture);
|
||||
expect(() =>
|
||||
validateRecordedCrashStructure(
|
||||
report,
|
||||
recordedCrashFixtureBytes(report),
|
||||
report.decoder_diagnostics,
|
||||
),
|
||||
).not.toThrow();
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
["x86_64-little-endian", "aarch64-little-endian"],
|
||||
["aarch64-little-endian", "x86_64-little-endian"],
|
||||
] as const)(
|
||||
"rejects a %s report whose header carries the other profile's e_machine",
|
||||
(reported, embedded) => {
|
||||
const report = recordedCrashFixture(undefined, reported);
|
||||
const snapshot = recordedCrashFixtureBytes(report);
|
||||
snapshot.writeUInt16LE(RECORDED_LINUX_CORE_PROFILES[embedded].machine, 18);
|
||||
expect(() =>
|
||||
validateRecordedCrashStructure(
|
||||
report,
|
||||
snapshot,
|
||||
report.decoder_diagnostics,
|
||||
),
|
||||
).toThrow(/Reported core structure differs/);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
[6, 0],
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { AnalysisError } from "../../../src/domain/analysisErrorBase.js";
|
||||
import type { RecordedCrash } from "../../../src/domain/native/recordedCrash.js";
|
||||
import type {
|
||||
RecordedCrash,
|
||||
RecordedCrashArchitecture,
|
||||
} from "../../../src/domain/native/recordedCrash.js";
|
||||
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { expect, it, onTestFinished } from "vitest";
|
||||
@@ -48,6 +51,7 @@ it
|
||||
.runIf(host)
|
||||
.each([
|
||||
"success",
|
||||
"success-aarch64",
|
||||
"wrong-register",
|
||||
"wrong-register-source",
|
||||
"missing-thread",
|
||||
@@ -77,9 +81,13 @@ it
|
||||
] as const)(
|
||||
"validates recorded source bytes and cleanup: %s",
|
||||
async (scenario) => {
|
||||
const architecture = scenario.endsWith("aarch64")
|
||||
? "aarch64-little-endian"
|
||||
: "x86_64-little-endian";
|
||||
const { path, value, bytes } = await fixture(
|
||||
true,
|
||||
scenario === "wrong-owner-display",
|
||||
architecture,
|
||||
);
|
||||
if (scenario.startsWith("wrong-ei-version-"))
|
||||
bytes[6] = Number(scenario.at(-1));
|
||||
@@ -123,8 +131,9 @@ it
|
||||
path,
|
||||
include_debugger_context: false,
|
||||
});
|
||||
if (scenario === "success") {
|
||||
if (scenario === "success" || scenario === "success-aarch64") {
|
||||
if (!result.ok) throw result.error;
|
||||
expect(result.value.architecture).toBe(architecture);
|
||||
expect(result.value.threads).toEqual(value.threads);
|
||||
expect(result.value.diagnostics.stdout).toBe("recorded warning");
|
||||
} else
|
||||
@@ -340,13 +349,17 @@ const expectOptionalContextFailure = (
|
||||
});
|
||||
};
|
||||
|
||||
const fixture = async (includeSignal = false, opaqueOwner = false) => {
|
||||
const fixture = async (
|
||||
includeSignal = false,
|
||||
opaqueOwner = false,
|
||||
architecture: RecordedCrashArchitecture = "x86_64-little-endian",
|
||||
) => {
|
||||
const workspace = await createTestWorkspace("rea-recorded-core-seam-");
|
||||
onTestFinished(() => removeTestWorkspace(workspace.root));
|
||||
const path = join(workspace.root, "recorded.core");
|
||||
const value = includeSignal
|
||||
? recordedCrashSignalFixture(path)
|
||||
: recordedCrashFixture(path);
|
||||
? recordedCrashSignalFixture(path, architecture)
|
||||
: recordedCrashFixture(path, architecture);
|
||||
if (opaqueOwner) {
|
||||
value.notes = value.notes.map((note) =>
|
||||
note.index === 0
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { chmod, realpath, writeFile } from "node:fs/promises";
|
||||
import { chmod, readFile, realpath, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { systemDoctorHost } from "../../../src/application/Doctor.js";
|
||||
@@ -78,7 +78,7 @@ describe("executable format refusal diagnostics", () => {
|
||||
});
|
||||
|
||||
describe("target route diagnostics", () => {
|
||||
it.each(["module.wasm", "module.bin", "misleading.js"])(
|
||||
it.each(["module.wasm", "module.bin", "misleading.js", "misleading.hop"])(
|
||||
"identifies a WASM v1 header in %s without suggesting native provider installation",
|
||||
async (name) => {
|
||||
const directory = await createTestTempDirectory("rea-wasm-target-");
|
||||
@@ -346,3 +346,68 @@ describe("raw-binary load rejection diagnostics", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("caller-selected and inferred target interpretation", () => {
|
||||
it.each(["archive.hop", "archive.HOP"])(
|
||||
"preserves inferred database routing for ZIP content named %s",
|
||||
async (name) => {
|
||||
const directory = await createTestTempDirectory("rea-zip-database-");
|
||||
const bytes = Buffer.alloc(22);
|
||||
bytes.writeUInt32LE(0x06054b50, 0);
|
||||
const zipPath = join(directory, "archive.zip");
|
||||
const databasePath = join(directory, name);
|
||||
await writeFile(zipPath, bytes);
|
||||
await writeFile(databasePath, bytes);
|
||||
expect(await parseBinaryTarget(zipPath)).toMatchObject({
|
||||
ok: true,
|
||||
value: { kind: "archive", format: "zip" },
|
||||
});
|
||||
expect(await parseBinaryTarget(databasePath)).toMatchObject({
|
||||
ok: true,
|
||||
value: { kind: "database", format: "analysis-database" },
|
||||
});
|
||||
expect(await readFile(databasePath)).toEqual(bytes);
|
||||
},
|
||||
);
|
||||
|
||||
it("preserves explicit DOS COM interpretation for bytes with a WASM header", async () => {
|
||||
const directory = await createTestTempDirectory("rea-wasm-explicit-com-");
|
||||
const path = join(directory, "module.hop");
|
||||
await writeFile(path, Buffer.from([0, 0x61, 0x73, 0x6d, 1, 0, 0, 0]));
|
||||
expect(
|
||||
await parseBinaryTarget(path, { formatHint: "dos-com" }),
|
||||
).toMatchObject({
|
||||
ok: true,
|
||||
value: { kind: "executable", format: "dos-com", architecture: "x86" },
|
||||
});
|
||||
});
|
||||
it.each(["module.wasm", "misleading.hop"])(
|
||||
"preserves explicitly selected database routing for %s",
|
||||
async (name) => {
|
||||
const directory = await createTestTempDirectory(
|
||||
"rea-wasm-explicit-database-",
|
||||
);
|
||||
const path = join(directory, name);
|
||||
await writeFile(path, Buffer.from([0, 0x61, 0x73, 0x6d, 1, 0, 0, 0]));
|
||||
expect(
|
||||
await parseBinaryTarget(path, { targetKind: "database" }),
|
||||
).toMatchObject({
|
||||
ok: true,
|
||||
value: { kind: "database", format: "analysis-database" },
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["ordinary.hop", "ordinary.HOP"])(
|
||||
"retains automatic database routing for non-WASM %s",
|
||||
async (name) => {
|
||||
const directory = await createTestTempDirectory("rea-ordinary-database-");
|
||||
const path = join(directory, name);
|
||||
await writeFile(path, "existing database content");
|
||||
expect(await parseBinaryTarget(path)).toMatchObject({
|
||||
ok: true,
|
||||
value: { kind: "database", format: "analysis-database" },
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { advertisedMcpToolSchemas } from "../../fixtures/advertisedMcpTool.js";
|
||||
import { API_SURFACE_EXAMPLE } from "../../../src/contracts/analysisViewApiSurfaceExample.js";
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
@@ -212,16 +213,10 @@ it("advertises exact schemas and projects one layout section from retained Evide
|
||||
inspect: () => Promise.resolve(ok(layout)),
|
||||
});
|
||||
const { client, session } = await connect(service);
|
||||
const advertised = (await client.listTools()).tools.find(
|
||||
(tool) => tool.name === "inspect_analysis_view",
|
||||
const { ajv, inputSchema, outputSchema } = await advertisedMcpToolSchemas(
|
||||
client,
|
||||
"inspect_analysis_view",
|
||||
);
|
||||
if (advertised?.outputSchema === undefined)
|
||||
throw new Error("inspect_analysis_view must publish both schemas");
|
||||
const ajv = new Ajv2020({ strict: false, validateFormats: false });
|
||||
const inputSchema: Record<string, unknown> = advertised.inputSchema;
|
||||
const outputSchema: Record<string, unknown> = advertised.outputSchema;
|
||||
expect(ajv.validateSchema(inputSchema)).toBe(true);
|
||||
expect(ajv.validateSchema(outputSchema)).toBe(true);
|
||||
expect(
|
||||
ajv.validate(inputSchema, {
|
||||
source: { kind: "inline", evidence: analysisViewLayoutEvidence() },
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import {
|
||||
advertisedMcpToolSchemas,
|
||||
assertRecordedMcpEvidence,
|
||||
} from "../../fixtures/advertisedMcpTool.js";
|
||||
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
||||
import { Ajv2020 } from "ajv/dist/2020.js";
|
||||
import { expect, it, onTestFinished } from "vitest";
|
||||
import { BinaryLayoutService } from "../../../src/application/binaryDiagnostics/BinaryLayoutService.js";
|
||||
import { toolContract } from "../../../src/contracts/toolContracts.js";
|
||||
import { parseEvidence } from "../../../src/domain/evidence.js";
|
||||
import { ok } from "../../../src/domain/result.js";
|
||||
import { createServer } from "../../../src/server/createServer.js";
|
||||
import { createTestBinarySession } from "../../fixtures/binarySession.js";
|
||||
@@ -43,16 +45,11 @@ it("advertises exact valid schemas and records inline layout without a binary ta
|
||||
InMemoryTransport.createLinkedPair();
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
const advertised = (await client.listTools()).tools.find(
|
||||
(tool) => tool.name === "inspect_binary_layout",
|
||||
const schemas = await advertisedMcpToolSchemas(
|
||||
client,
|
||||
"inspect_binary_layout",
|
||||
);
|
||||
if (advertised?.outputSchema === undefined)
|
||||
throw new Error("layout must publish both schemas");
|
||||
const ajv = new Ajv2020({ strict: false, validateFormats: false });
|
||||
const inputSchema: Record<string, unknown> = advertised.inputSchema;
|
||||
const outputSchema: Record<string, unknown> = advertised.outputSchema;
|
||||
expect(ajv.validateSchema(inputSchema)).toBe(true);
|
||||
expect(ajv.validateSchema(outputSchema)).toBe(true);
|
||||
const { ajv, inputSchema } = schemas;
|
||||
expect(ajv.validate(inputSchema, { path: value.artifact.path })).toBe(true);
|
||||
expect(
|
||||
ajv.validate(inputSchema, {
|
||||
@@ -64,19 +61,14 @@ it("advertises exact valid schemas and records inline layout without a binary ta
|
||||
name: "inspect_binary_layout",
|
||||
arguments: { path: value.artifact.path },
|
||||
});
|
||||
expect(response.isError).not.toBe(true);
|
||||
expect(
|
||||
ajv.validate(outputSchema, response.structuredContent),
|
||||
JSON.stringify(ajv.errors),
|
||||
).toBe(true);
|
||||
const parsed = toolContract("inspect_binary_layout").outputSchema.parse(
|
||||
response.structuredContent,
|
||||
const evidence = assertRecordedMcpEvidence(
|
||||
response,
|
||||
"inspect_binary_layout",
|
||||
schemas,
|
||||
session,
|
||||
value,
|
||||
"observed",
|
||||
);
|
||||
const evidence = parseEvidence(parsed);
|
||||
expect(evidence.confidence).toBe("observed");
|
||||
expect(evidence.subject?.digest.sha256).toBe(value.artifact.sha256);
|
||||
expect(parsed.normalized_result).toEqual(value);
|
||||
expect(session.evidenceById(evidence.evidence_id)).toEqual(evidence);
|
||||
const viewed = await client.callTool({
|
||||
name: "inspect_analysis_view",
|
||||
arguments: {
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import {
|
||||
advertisedMcpToolSchemas,
|
||||
assertRecordedMcpEvidence,
|
||||
} from "../../fixtures/advertisedMcpTool.js";
|
||||
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
||||
import { createHash } from "node:crypto";
|
||||
import { Ajv2020 } from "ajv/dist/2020.js";
|
||||
@@ -66,16 +70,11 @@ it("advertises exact valid schemas and records inline EVM interface evidence wit
|
||||
InMemoryTransport.createLinkedPair();
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
const advertised = (await client.listTools()).tools.find(
|
||||
(tool) => tool.name === "inspect_evm_interface",
|
||||
const schemas = await advertisedMcpToolSchemas(
|
||||
client,
|
||||
"inspect_evm_interface",
|
||||
);
|
||||
if (advertised?.outputSchema === undefined)
|
||||
throw new Error("EVM interface inspection must publish both schemas");
|
||||
const ajv = new Ajv2020({ strict: false, validateFormats: false });
|
||||
const inputSchema: Record<string, unknown> = advertised.inputSchema;
|
||||
const outputSchema: Record<string, unknown> = advertised.outputSchema;
|
||||
expect(ajv.validateSchema(inputSchema)).toBe(true);
|
||||
expect(ajv.validateSchema(outputSchema)).toBe(true);
|
||||
const { ajv, inputSchema } = schemas;
|
||||
expect(
|
||||
ajv.validate(inputSchema, { path: value.artifact.path, encoding: "hex" }),
|
||||
).toBe(true);
|
||||
@@ -99,19 +98,14 @@ it("advertises exact valid schemas and records inline EVM interface evidence wit
|
||||
name: "inspect_evm_interface",
|
||||
arguments: { path: value.artifact.path, encoding: "hex" },
|
||||
});
|
||||
expect(response.isError).not.toBe(true);
|
||||
expect(
|
||||
ajv.validate(outputSchema, response.structuredContent),
|
||||
JSON.stringify(ajv.errors),
|
||||
).toBe(true);
|
||||
const parsed = toolContract("inspect_evm_interface").outputSchema.parse(
|
||||
response.structuredContent,
|
||||
assertRecordedMcpEvidence(
|
||||
response,
|
||||
"inspect_evm_interface",
|
||||
schemas,
|
||||
session,
|
||||
value,
|
||||
"inferred",
|
||||
);
|
||||
const evidence = parseEvidence(parsed);
|
||||
expect(evidence.confidence).toBe("inferred");
|
||||
expect(evidence.subject?.digest.sha256).toBe(value.artifact.sha256);
|
||||
expect(parsed.normalized_result).toEqual(value);
|
||||
expect(session.evidenceById(evidence.evidence_id)).toEqual(evidence);
|
||||
expect(
|
||||
(
|
||||
await client.callTool({
|
||||
|
||||
@@ -217,6 +217,46 @@ function resolveReference(schema: unknown, reference: string): unknown {
|
||||
return value;
|
||||
}
|
||||
|
||||
function localReferenceExpansions(schema: Record<string, unknown>): number {
|
||||
// Clients that render SDK declarations from the advertised input expand
|
||||
// schema-local references against a shared per-input budget; Pi 1.1.0
|
||||
// code-mode allows 32 and renders every later reference as `unknown`.
|
||||
// Count conservatively: every resolvable, non-cyclic reference occurrence
|
||||
// reached while walking the input, never the definitions themselves.
|
||||
let count = 0;
|
||||
const visit = (node: unknown, active: ReadonlySet<string>): void => {
|
||||
if (Array.isArray(node)) {
|
||||
node.forEach((child) => visit(child, active));
|
||||
return;
|
||||
}
|
||||
if (!isRecord(node)) return;
|
||||
if (typeof node.$ref === "string" && !active.has(node.$ref)) {
|
||||
const target = resolveReference(schema, node.$ref);
|
||||
if (target !== undefined) {
|
||||
count += 1;
|
||||
visit(target, new Set([...active, node.$ref]));
|
||||
return;
|
||||
}
|
||||
}
|
||||
for (const [key, value] of Object.entries(node)) {
|
||||
if (
|
||||
[
|
||||
"$defs",
|
||||
"definitions",
|
||||
"examples",
|
||||
"default",
|
||||
"const",
|
||||
"enum",
|
||||
].includes(key)
|
||||
)
|
||||
continue;
|
||||
visit(value, active);
|
||||
}
|
||||
};
|
||||
visit(schema, new Set());
|
||||
return count;
|
||||
}
|
||||
|
||||
function recursiveReferences(tools: readonly ToolSchemas[]): string[] {
|
||||
return tools.flatMap((tool) =>
|
||||
(["inputSchema", "outputSchema"] as const).flatMap((kind) => {
|
||||
@@ -674,6 +714,14 @@ it("ships nonrecursive input and output schemas in the generated catalog", () =>
|
||||
expect(recursiveReferences(GENERATED_MCP_TOOL_CATALOG)).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps advertised input references within the client expansion budget", () => {
|
||||
for (const tool of GENERATED_MCP_TOOL_CATALOG)
|
||||
expect(
|
||||
localReferenceExpansions(tool.inputSchema),
|
||||
`${tool.name} exhausts the shared reference-expansion budget`,
|
||||
).toBeLessThanOrEqual(32);
|
||||
});
|
||||
|
||||
it("rejects unresolved output-schema references even for uncalled tools", () => {
|
||||
const errors = schemaErrors([
|
||||
{
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
||||
import { Ajv2020 } from "ajv/dist/2020.js";
|
||||
import {
|
||||
advertisedMcpToolSchemas,
|
||||
assertRecordedMcpEvidence,
|
||||
} from "../../fixtures/advertisedMcpTool.js";
|
||||
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
||||
import { expect, it, onTestFinished, vi } from "vitest";
|
||||
import { readFile, realpath, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
@@ -20,20 +24,6 @@ import {
|
||||
wasmArtifactExecution,
|
||||
} from "../../fixtures/wasm/artifact.js";
|
||||
|
||||
const wasmToolSchemas = async (client: Client) => {
|
||||
const advertised = (await client.listTools()).tools.find(
|
||||
(tool) => tool.name === "inspect_wasm_artifact",
|
||||
);
|
||||
if (advertised?.outputSchema === undefined)
|
||||
throw new Error("WASM recovery must name an advertised static tool");
|
||||
const ajv = new Ajv2020({ strict: false, validateFormats: false });
|
||||
const inputSchema: Record<string, unknown> = advertised.inputSchema;
|
||||
const outputSchema: Record<string, unknown> = advertised.outputSchema;
|
||||
expect(ajv.validateSchema(inputSchema)).toBe(true);
|
||||
expect(ajv.validateSchema(outputSchema)).toBe(true);
|
||||
return { name: advertised.name, ajv, inputSchema, outputSchema };
|
||||
};
|
||||
|
||||
const assertFirstSummaryDelivery = async (
|
||||
harness: Awaited<ReturnType<typeof createApplicationMcpHarness>>,
|
||||
value: ReturnType<typeof wasmArtifactFixture>,
|
||||
@@ -43,7 +33,7 @@ const assertFirstSummaryDelivery = async (
|
||||
ajv: wasmAjv,
|
||||
inputSchema,
|
||||
outputSchema,
|
||||
} = await wasmToolSchemas(harness.client);
|
||||
} = await advertisedMcpToolSchemas(harness.client, "inspect_wasm_artifact");
|
||||
const summaryInput = { path: value.artifact.path, detail: "summary" };
|
||||
expect(wasmAjv.validate(inputSchema, summaryInput)).toBe(true);
|
||||
expect(
|
||||
@@ -91,73 +81,86 @@ const assertFirstSummaryDelivery = async (
|
||||
return { summary, retained };
|
||||
};
|
||||
|
||||
it("directs rejected native WASM opening to the advertised static tool while preserving missing-WABT diagnostics", async () => {
|
||||
const directory = await createTestTempDirectory("rea-wasm-recovery-");
|
||||
const path = join(
|
||||
await realpath(directory),
|
||||
process.platform === "win32" ? "module space.bin" : 'module "quoted".bin',
|
||||
);
|
||||
await writeFile(path, Buffer.from([0, 0x61, 0x73, 0x6d, 1, 0, 0, 0]));
|
||||
const createClient = vi.fn(() => {
|
||||
throw new Error("native provider must not start for WASM");
|
||||
});
|
||||
const session = createTestBinarySession(createClient);
|
||||
const harness = await createApplicationMcpHarness({
|
||||
session,
|
||||
serverOptions: { environment: {} },
|
||||
});
|
||||
onTestFinished(harness.close);
|
||||
const { client } = harness;
|
||||
const { name, ajv, inputSchema } = await wasmToolSchemas(client);
|
||||
const input = { path };
|
||||
expect(
|
||||
toolContract("inspect_wasm_artifact").inputSchema.safeParse(input).success,
|
||||
).toBe(true);
|
||||
const opening = await client.callTool({
|
||||
name: "open_binary",
|
||||
arguments: input,
|
||||
});
|
||||
expect(opening.isError).toBe(true);
|
||||
const failure = parseMcpToolError(opening);
|
||||
expect(failure).toMatchObject({
|
||||
error: {
|
||||
code: "target_unavailable",
|
||||
category: "unsupported_target",
|
||||
details: { constraint: "wasm_requires_static_inspection" },
|
||||
},
|
||||
});
|
||||
expect(failure.error.remediation.action).toContain(JSON.stringify({ path }));
|
||||
expect(ajv.validate(inputSchema, input)).toBe(true);
|
||||
const inspection = await client.callTool({
|
||||
name,
|
||||
arguments: input,
|
||||
});
|
||||
expect(inspection.isError).toBe(true);
|
||||
expect(parseMcpToolError(inspection)).toMatchObject({
|
||||
error: {
|
||||
code: "capability_unavailable",
|
||||
details: {
|
||||
operation: "inspect_wasm_artifact",
|
||||
reason: expect.stringContaining("REA_WABT_BIN_DIRECTORY"),
|
||||
it.each(["bin", "hop"])(
|
||||
"directs rejected native WASM opening (%s suffix) to the advertised static tool while preserving missing-WABT diagnostics",
|
||||
async (extension) => {
|
||||
const directory = await createTestTempDirectory("rea-wasm-recovery-");
|
||||
const path = join(
|
||||
await realpath(directory),
|
||||
process.platform === "win32"
|
||||
? `module space.${extension}`
|
||||
: `module "quoted".${extension}`,
|
||||
);
|
||||
await writeFile(path, Buffer.from([0, 0x61, 0x73, 0x6d, 1, 0, 0, 0]));
|
||||
const createClient = vi.fn(() => {
|
||||
throw new Error("native provider must not start for WASM");
|
||||
});
|
||||
const session = createTestBinarySession(createClient);
|
||||
const harness = await createApplicationMcpHarness({
|
||||
session,
|
||||
serverOptions: { environment: {} },
|
||||
});
|
||||
onTestFinished(harness.close);
|
||||
const { client } = harness;
|
||||
const { name, ajv, inputSchema } = await advertisedMcpToolSchemas(
|
||||
client,
|
||||
"inspect_wasm_artifact",
|
||||
);
|
||||
const input = { path };
|
||||
const opening = await client.callTool({
|
||||
name: "open_binary",
|
||||
arguments: input,
|
||||
});
|
||||
expect(opening.isError).toBe(true);
|
||||
const failure = parseMcpToolError(opening);
|
||||
expect(failure).toMatchObject({
|
||||
error: {
|
||||
code: "target_unavailable",
|
||||
category: "unsupported_target",
|
||||
details: { constraint: "wasm_requires_static_inspection" },
|
||||
},
|
||||
},
|
||||
});
|
||||
const summary = await client.callTool({
|
||||
name,
|
||||
arguments: { ...input, detail: "summary" },
|
||||
});
|
||||
expect(parseMcpToolError(summary)).toMatchObject({
|
||||
error: {
|
||||
code: "capability_unavailable",
|
||||
details: { reason: expect.stringContaining("REA_WABT_BIN_DIRECTORY") },
|
||||
},
|
||||
});
|
||||
await client.ping();
|
||||
expect(createClient).not.toHaveBeenCalled();
|
||||
expect(await readFile(path)).toEqual(
|
||||
Buffer.from([0, 0x61, 0x73, 0x6d, 1, 0, 0, 0]),
|
||||
);
|
||||
});
|
||||
});
|
||||
const [, recoveryName, serializedInput] =
|
||||
failure.error.remediation.action.match(
|
||||
/call (\w+) with (\{.*\}) or run/,
|
||||
) ?? [];
|
||||
expect(recoveryName).toBe(name);
|
||||
const recoveryInput = z
|
||||
.object({ path: z.literal(path) })
|
||||
.strict()
|
||||
.parse(JSON.parse(serializedInput ?? "null"));
|
||||
expect(ajv.validate(inputSchema, recoveryInput)).toBe(true);
|
||||
const inspection = await client.callTool({
|
||||
name: recoveryName ?? "",
|
||||
arguments: recoveryInput,
|
||||
});
|
||||
expect(inspection.isError).toBe(true);
|
||||
expect(parseMcpToolError(inspection)).toMatchObject({
|
||||
error: {
|
||||
code: "capability_unavailable",
|
||||
details: {
|
||||
operation: "inspect_wasm_artifact",
|
||||
reason: expect.stringContaining("REA_WABT_BIN_DIRECTORY"),
|
||||
},
|
||||
},
|
||||
});
|
||||
const summary = await client.callTool({
|
||||
name,
|
||||
arguments: { ...input, detail: "summary" },
|
||||
});
|
||||
expect(parseMcpToolError(summary)).toMatchObject({
|
||||
error: {
|
||||
code: "capability_unavailable",
|
||||
details: { reason: expect.stringContaining("REA_WABT_BIN_DIRECTORY") },
|
||||
},
|
||||
});
|
||||
await client.ping();
|
||||
expect(createClient).not.toHaveBeenCalled();
|
||||
expect(await readFile(path)).toEqual(
|
||||
Buffer.from([0, 0x61, 0x73, 0x6d, 1, 0, 0, 0]),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("advertises exact valid schemas and records inline WASM interface evidence without a binary target", async () => {
|
||||
const value = wasmArtifactFixture();
|
||||
@@ -175,7 +178,11 @@ it("advertises exact valid schemas and records inline WASM interface evidence wi
|
||||
});
|
||||
onTestFinished(harness.close);
|
||||
const { client } = harness;
|
||||
const { ajv, inputSchema, outputSchema } = await wasmToolSchemas(client);
|
||||
const schemas = await advertisedMcpToolSchemas(
|
||||
client,
|
||||
"inspect_wasm_artifact",
|
||||
);
|
||||
const { ajv, inputSchema } = schemas;
|
||||
expect(ajv.validate(inputSchema, { path: value.artifact.path })).toBe(true);
|
||||
expect(
|
||||
ajv.validate(inputSchema, {
|
||||
@@ -188,19 +195,14 @@ it("advertises exact valid schemas and records inline WASM interface evidence wi
|
||||
name: "inspect_wasm_artifact",
|
||||
arguments: { path: value.artifact.path },
|
||||
});
|
||||
expect(response.isError).not.toBe(true);
|
||||
expect(
|
||||
ajv.validate(outputSchema, response.structuredContent),
|
||||
JSON.stringify(ajv.errors),
|
||||
).toBe(true);
|
||||
const parsed = toolContract("inspect_wasm_artifact").outputSchema.parse(
|
||||
response.structuredContent,
|
||||
assertRecordedMcpEvidence(
|
||||
response,
|
||||
"inspect_wasm_artifact",
|
||||
schemas,
|
||||
session,
|
||||
value,
|
||||
"observed",
|
||||
);
|
||||
const evidence = parseEvidence(parsed);
|
||||
expect(evidence.confidence).toBe("observed");
|
||||
expect(evidence.subject?.digest.sha256).toBe(value.artifact.sha256);
|
||||
expect(parsed.normalized_result).toEqual(value);
|
||||
expect(session.evidenceById(evidence.evidence_id)).toEqual(evidence);
|
||||
expect(
|
||||
(
|
||||
await client.callTool({
|
||||
|
||||
@@ -229,6 +229,62 @@ it("normalizes legacy version-minimum commands into build metadata", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("names numeric build-version platforms and tools", () => {
|
||||
const load = parseOtoolLoadCommands(
|
||||
withOtoolMachHeader(
|
||||
[
|
||||
"Load command 0",
|
||||
" cmd LC_BUILD_VERSION",
|
||||
" cmdsize 48",
|
||||
" platform 1",
|
||||
" minos 11.0",
|
||||
" sdk 26.5",
|
||||
" ntools 2",
|
||||
" tool 3",
|
||||
" version 1267.0",
|
||||
" tool 1029",
|
||||
" version 1.0",
|
||||
"Load command 1",
|
||||
" cmd LC_BUILD_VERSION",
|
||||
" cmdsize 24",
|
||||
" platform 99",
|
||||
" minos 1.0",
|
||||
" sdk 1.0",
|
||||
" ntools 0",
|
||||
"Load command 2",
|
||||
" cmd LC_BUILD_VERSION",
|
||||
" cmdsize 32",
|
||||
" platform MACCATALYST",
|
||||
" minos 14.0",
|
||||
" sdk 14.2",
|
||||
" ntools 1",
|
||||
" tool SWIFT",
|
||||
" version 5.9",
|
||||
"",
|
||||
].join("\n"),
|
||||
),
|
||||
);
|
||||
expect(load.builds).toEqual([
|
||||
{
|
||||
platform: "MACOS",
|
||||
minimum_os: "11.0",
|
||||
sdk: "26.5",
|
||||
tools: [
|
||||
{ name: "LD", version: "1267.0" },
|
||||
{ name: "1029", version: "1.0" },
|
||||
],
|
||||
},
|
||||
{ platform: "99", minimum_os: "1.0", sdk: "1.0", tools: [] },
|
||||
{
|
||||
platform: "MACCATALYST",
|
||||
minimum_os: "14.0",
|
||||
sdk: "14.2",
|
||||
tools: [{ name: "SWIFT", version: "5.9" }],
|
||||
},
|
||||
]);
|
||||
expect(load.commands[0]?.fields.platform).toBe(1);
|
||||
});
|
||||
|
||||
it("does not classify LC_LOAD_DYLINKER as a dylib dependency", async () => {
|
||||
const load = parseOtoolLoadCommands(
|
||||
await nativeFixture("native-macho-header/otool-header-load.txt"),
|
||||
@@ -273,3 +329,78 @@ it("limits thread-state entrypoints instead of reporting none silently", async (
|
||||
"Thread-state entrypoints (LC_UNIXTHREAD/LC_THREAD) are retained as raw load commands; file offsets are not derived.",
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["1", "MACOS"],
|
||||
["2", "IOS"],
|
||||
["3", "TVOS"],
|
||||
["4", "WATCHOS"],
|
||||
["5", "BRIDGEOS"],
|
||||
["6", "MACCATALYST"],
|
||||
["7", "IOSSIMULATOR"],
|
||||
["8", "TVOSSIMULATOR"],
|
||||
["9", "WATCHOSSIMULATOR"],
|
||||
["10", "DRIVERKIT"],
|
||||
["11", "VISIONOS"],
|
||||
["12", "VISIONOSSIMULATOR"],
|
||||
["13", "FIRMWARE"],
|
||||
["14", "SEPOS"],
|
||||
["15", "MACOS_EXCLAVECORE"],
|
||||
["16", "MACOS_EXCLAVEKIT"],
|
||||
["17", "IOS_EXCLAVECORE"],
|
||||
["18", "IOS_EXCLAVEKIT"],
|
||||
["19", "TVOS_EXCLAVECORE"],
|
||||
["20", "TVOS_EXCLAVEKIT"],
|
||||
["21", "WATCHOS_EXCLAVECORE"],
|
||||
["22", "WATCHOS_EXCLAVEKIT"],
|
||||
["23", "VISIONOS_EXCLAVECORE"],
|
||||
["24", "VISIONOS_EXCLAVEKIT"],
|
||||
] as const)("names build platform %s as %s", (code, name) => {
|
||||
const load = parseOtoolLoadCommands(
|
||||
withOtoolMachHeader(
|
||||
[
|
||||
"Load command 0",
|
||||
"cmd LC_BUILD_VERSION",
|
||||
"cmdsize 24",
|
||||
`platform ${code}`,
|
||||
"minos 1.0",
|
||||
"sdk 1.0",
|
||||
"ntools 0",
|
||||
"",
|
||||
].join("\n"),
|
||||
),
|
||||
);
|
||||
expect(load.builds[0]?.platform).toBe(name);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["1", "CLANG"],
|
||||
["2", "SWIFT"],
|
||||
["3", "LD"],
|
||||
["4", "LLD"],
|
||||
["1024", "METAL"],
|
||||
["1025", "AIRLLD"],
|
||||
["1026", "AIRNT"],
|
||||
["1027", "AIRNT_PLUGIN"],
|
||||
["1028", "AIRPACK"],
|
||||
["1031", "GPUARCHIVER"],
|
||||
["1032", "METAL_FRAMEWORK"],
|
||||
] as const)("names build tool %s as %s", (code, name) => {
|
||||
const load = parseOtoolLoadCommands(
|
||||
withOtoolMachHeader(
|
||||
[
|
||||
"Load command 0",
|
||||
"cmd LC_BUILD_VERSION",
|
||||
"cmdsize 32",
|
||||
"platform 1",
|
||||
"minos 1.0",
|
||||
"sdk 1.0",
|
||||
"ntools 1",
|
||||
`tool ${code}`,
|
||||
"version 1.0",
|
||||
"",
|
||||
].join("\n"),
|
||||
),
|
||||
);
|
||||
expect(load.builds[0]?.tools).toEqual([{ name, version: "1.0" }]);
|
||||
});
|
||||
|
||||
Vendored
+52
@@ -0,0 +1,52 @@
|
||||
import type { Client } from "@modelcontextprotocol/client";
|
||||
import { Ajv2020 } from "ajv/dist/2020.js";
|
||||
import { expect } from "vitest";
|
||||
import { toolContract } from "../../src/contracts/toolContracts.js";
|
||||
import { parseEvidence } from "../../src/domain/evidence.js";
|
||||
import type { BinarySession } from "../../src/application/binary/BinarySession.js";
|
||||
|
||||
/** Verify the actual advertised schemas, retaining a private Ajv registry. */
|
||||
export const advertisedMcpToolSchemas = async (
|
||||
client: Client,
|
||||
name: string,
|
||||
) => {
|
||||
const advertised = (await client.listTools()).tools.find(
|
||||
(tool) => tool.name === name,
|
||||
);
|
||||
if (advertised?.outputSchema === undefined)
|
||||
throw new Error(`${name} must publish both schemas`);
|
||||
const ajv = new Ajv2020({ strict: false, validateFormats: false });
|
||||
const inputSchema: Record<string, unknown> = advertised.inputSchema;
|
||||
const outputSchema: Record<string, unknown> = advertised.outputSchema;
|
||||
expect(ajv.validateSchema(inputSchema)).toBe(true);
|
||||
expect(ajv.validateSchema(outputSchema)).toBe(true);
|
||||
return { name: advertised.name, ajv, inputSchema, outputSchema };
|
||||
};
|
||||
|
||||
/** Keep public output, provenance and session retention checks together. */
|
||||
export const assertRecordedMcpEvidence = (
|
||||
response: Awaited<ReturnType<Client["callTool"]>>,
|
||||
name:
|
||||
| "inspect_wasm_artifact"
|
||||
| "inspect_binary_layout"
|
||||
| "inspect_evm_interface",
|
||||
schemas: Awaited<ReturnType<typeof advertisedMcpToolSchemas>>,
|
||||
session: BinarySession,
|
||||
expected: { readonly artifact: { readonly sha256: string } },
|
||||
confidence: ReturnType<typeof parseEvidence>["confidence"],
|
||||
) => {
|
||||
expect(response.isError).not.toBe(true);
|
||||
expect(
|
||||
schemas.ajv.validate(schemas.outputSchema, response.structuredContent),
|
||||
JSON.stringify(schemas.ajv.errors),
|
||||
).toBe(true);
|
||||
const parsed = toolContract(name).outputSchema.parse(
|
||||
response.structuredContent,
|
||||
);
|
||||
const evidence = parseEvidence(parsed);
|
||||
expect(evidence.confidence).toBe(confidence);
|
||||
expect(evidence.subject?.digest.sha256).toBe(expected.artifact.sha256);
|
||||
expect(parsed.normalized_result).toEqual(expected);
|
||||
expect(session.evidenceById(evidence.evidence_id)).toEqual(evidence);
|
||||
return evidence;
|
||||
};
|
||||
+66
-49
@@ -1,17 +1,41 @@
|
||||
import type { RecordedCrash } from "../../../src/domain/native/recordedCrash.js";
|
||||
import {
|
||||
RECORDED_LINUX_CORE_PROFILES,
|
||||
type RecordedCrash,
|
||||
type RecordedCrashArchitecture,
|
||||
} from "../../../src/domain/native/recordedCrash.js";
|
||||
|
||||
// One note is a 12-byte header, the "CORE\0" owner padded to 8 bytes, then the
|
||||
// descriptor. The first note starts at the end of the owned header and phdr.
|
||||
const NOTE_START = 0x80;
|
||||
const OWNER_START = NOTE_START + 12;
|
||||
const DESCRIPTOR_START = OWNER_START + 8;
|
||||
const NOTE_HEADER_BYTES = 20;
|
||||
|
||||
const hex = (value: number): string => `0x${value.toString(16)}`;
|
||||
|
||||
/** Source-owned observation seam; these bytes do not simulate a verified ELF engine. */
|
||||
export const recordedCrashFixture = (
|
||||
path = "/artifacts/source-owned.core",
|
||||
architecture: RecordedCrashArchitecture = "x86_64-little-endian",
|
||||
): RecordedCrash => {
|
||||
const descriptor = Buffer.alloc(336);
|
||||
const profile = RECORDED_LINUX_CORE_PROFILES[architecture];
|
||||
const noteBytes = NOTE_HEADER_BYTES + profile.prstatusBytes;
|
||||
const descriptor = Buffer.alloc(profile.prstatusBytes);
|
||||
descriptor.writeInt32LE(123, 32);
|
||||
descriptor.writeInt16LE(11, 12);
|
||||
descriptor.writeBigUInt64LE(0x1122334455667788n, 224);
|
||||
// One distinguishable register carries a full uint64 so retention is asserted.
|
||||
descriptor.writeBigUInt64LE(
|
||||
0x1122334455667788n,
|
||||
profile.registerOffset + 14 * profile.registerBytes,
|
||||
);
|
||||
return {
|
||||
artifact: { path, sha256: "a".repeat(64), bytes: 512 },
|
||||
artifact: {
|
||||
path,
|
||||
sha256: "a".repeat(64),
|
||||
bytes: Math.max(512, DESCRIPTOR_START + profile.prstatusBytes),
|
||||
},
|
||||
format: "elf-core",
|
||||
architecture: "x86_64-little-endian",
|
||||
architecture,
|
||||
target_execution: "not-performed",
|
||||
live_process_identity: "unknown",
|
||||
segments: [
|
||||
@@ -20,7 +44,7 @@ export const recordedCrashFixture = (
|
||||
type: "PT_NOTE",
|
||||
header_location: { offset: "0x40", bytes: "0x38" },
|
||||
offset: "0x80",
|
||||
file_size: "0x164",
|
||||
file_size: hex(noteBytes),
|
||||
memory_size: "0x0",
|
||||
virtual_address: "0x0",
|
||||
physical_address: "0x0",
|
||||
@@ -34,11 +58,14 @@ export const recordedCrashFixture = (
|
||||
index: 0,
|
||||
segment_index: 0,
|
||||
type: "NT_PRSTATUS",
|
||||
location: { offset: "0x80", bytes: "0x164" },
|
||||
owner_location: { offset: "0x8c", bytes: "0x5" },
|
||||
location: { offset: hex(NOTE_START), bytes: hex(noteBytes) },
|
||||
owner_location: { offset: hex(OWNER_START), bytes: "0x5" },
|
||||
owner_bytes_base64: Buffer.from("CORE\0").toString("base64"),
|
||||
owner_display: "CORE",
|
||||
descriptor_location: { offset: "0x94", bytes: "0x150" },
|
||||
descriptor_location: {
|
||||
offset: hex(DESCRIPTOR_START),
|
||||
bytes: hex(profile.prstatusBytes),
|
||||
},
|
||||
descriptor_bytes_base64: descriptor.toString("base64"),
|
||||
},
|
||||
],
|
||||
@@ -48,40 +75,16 @@ export const recordedCrashFixture = (
|
||||
note_index: 0,
|
||||
historical_pid: 123,
|
||||
recorded_current_signal: 11,
|
||||
registers: [
|
||||
"r15",
|
||||
"r14",
|
||||
"r13",
|
||||
"r12",
|
||||
"rbp",
|
||||
"rbx",
|
||||
"r11",
|
||||
"r10",
|
||||
"r9",
|
||||
"r8",
|
||||
"rax",
|
||||
"rcx",
|
||||
"rdx",
|
||||
"rsi",
|
||||
"rdi",
|
||||
"orig_rax",
|
||||
"rip",
|
||||
"cs",
|
||||
"eflags",
|
||||
"rsp",
|
||||
"ss",
|
||||
"fs_base",
|
||||
"gs_base",
|
||||
"ds",
|
||||
"es",
|
||||
"fs",
|
||||
"gs",
|
||||
].map((name, index) => ({
|
||||
registers: profile.registers.map((name, index) => ({
|
||||
name,
|
||||
value: name === "rdi" ? "0x1122334455667788" : "0x0",
|
||||
value: index === 14 ? "0x1122334455667788" : "0x0",
|
||||
location: {
|
||||
offset: `0x${(148 + 112 + index * 8).toString(16)}`,
|
||||
bytes: "0x8",
|
||||
offset: hex(
|
||||
DESCRIPTOR_START +
|
||||
profile.registerOffset +
|
||||
index * profile.registerBytes,
|
||||
),
|
||||
bytes: hex(profile.registerBytes),
|
||||
},
|
||||
})),
|
||||
},
|
||||
@@ -100,27 +103,38 @@ export const recordedCrashFixture = (
|
||||
};
|
||||
|
||||
/** Source-owned SIGSEGV descriptor for scalar/source binding regressions. */
|
||||
export const recordedCrashSignalFixture = (path?: string): RecordedCrash => {
|
||||
const value = recordedCrashFixture(path);
|
||||
export const recordedCrashSignalFixture = (
|
||||
path?: string,
|
||||
architecture: RecordedCrashArchitecture = "x86_64-little-endian",
|
||||
): RecordedCrash => {
|
||||
const value = recordedCrashFixture(path, architecture);
|
||||
const profile = RECORDED_LINUX_CORE_PROFILES[architecture];
|
||||
const descriptor = Buffer.alloc(32);
|
||||
descriptor.writeInt32LE(11, 0);
|
||||
descriptor.writeInt32LE(-2, 4);
|
||||
descriptor.writeInt32LE(1, 8);
|
||||
descriptor.writeBigUInt64LE(0x1122334455667788n, 16);
|
||||
value.artifact.bytes = 640;
|
||||
const prstatusNoteBytes = NOTE_HEADER_BYTES + profile.prstatusBytes;
|
||||
const signalNoteStart = NOTE_START + prstatusNoteBytes;
|
||||
const signalDescriptorStart = signalNoteStart + NOTE_HEADER_BYTES;
|
||||
const signalNoteBytes = NOTE_HEADER_BYTES + 32;
|
||||
value.artifact.bytes = Math.max(640, signalDescriptorStart + 32);
|
||||
value.segments = value.segments.map((segment) => ({
|
||||
...segment,
|
||||
file_size: "0x198",
|
||||
file_size: hex(prstatusNoteBytes + signalNoteBytes),
|
||||
}));
|
||||
value.notes.push({
|
||||
index: 1,
|
||||
segment_index: 0,
|
||||
type: "NT_SIGINFO",
|
||||
location: { offset: "0x1e4", bytes: "0x34" },
|
||||
owner_location: { offset: "0x1f0", bytes: "0x5" },
|
||||
location: { offset: hex(signalNoteStart), bytes: hex(signalNoteBytes) },
|
||||
owner_location: { offset: hex(signalNoteStart + 12), bytes: "0x5" },
|
||||
owner_bytes_base64: Buffer.from("CORE\0").toString("base64"),
|
||||
owner_display: "CORE",
|
||||
descriptor_location: { offset: "0x1f8", bytes: "0x20" },
|
||||
descriptor_location: {
|
||||
offset: hex(signalDescriptorStart),
|
||||
bytes: "0x20",
|
||||
},
|
||||
descriptor_bytes_base64: descriptor.toString("base64"),
|
||||
});
|
||||
value.signals.push({
|
||||
@@ -141,7 +155,10 @@ export const recordedCrashFixtureBytes = (value: RecordedCrash): Buffer => {
|
||||
bytes.set([0x7f, 0x45, 0x4c, 0x46, 2, 1, 1]);
|
||||
bytes.writeUInt32LE(1, 20);
|
||||
bytes.writeUInt16LE(4, 16);
|
||||
bytes.writeUInt16LE(62, 18);
|
||||
bytes.writeUInt16LE(
|
||||
RECORDED_LINUX_CORE_PROFILES[value.architecture].machine,
|
||||
18,
|
||||
);
|
||||
bytes.writeBigUInt64LE(64n, 32);
|
||||
bytes.writeUInt16LE(56, 54);
|
||||
bytes.writeUInt16LE(value.segments.length, 56);
|
||||
|
||||
Reference in New Issue
Block a user