feat(wasm): compare retained core-module interfaces (#2464)
Squash merge of https://github.com/morluto/rea/pull/2464. Co-authored-by: Thang Luu <76550342+thangldw@users.noreply.github.com>
This commit is contained in:
parent
75e7965dc3
commit
911894d758
|
|
@ -249,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`.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -79,3 +79,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.
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,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;
|
||||
};
|
||||
|
|
@ -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 };
|
||||
};
|
||||
|
|
@ -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();
|
||||
}
|
||||
},
|
||||
);
|
||||
Loading…
Reference in New Issue