fix(wasm): preserve core inspection with optional metadata warnings

Retain warnings from validator, objdump and decoder through one diagnostic owner. Keep uninterpretable name bytes and explicit name-metadata coverage in complete results and summaries.

Fixes #6812
This commit is contained in:
morluto
2026-10-24 00:27:57 +08:00
parent 929a0a76c8
commit 6c6e2ad62b
12 changed files with 239 additions and 45 deletions
+17 -8
View File
@@ -62,14 +62,23 @@ returns `resource_constraint` with resource `file-size` and its fixed byte
allowance in `reported_limits.maximum_input_bytes`; retrying the unchanged file
does not raise that allowance. WASM bytes are copied
into a private workspace as read-only `module.wasm`; its digest is rechecked
before and after each tool. WABT validates with `--enable-all`,
reports sections through `wasm-objdump -h -x`, and decodes through
`wasm2wat --enable-all`. The result retains selected byte SHA-256 and size,
section payload ranges, full header/detail output (including module-name
preambles), exact import/export WAT
forms, decoded WAT and its UTF-8 SHA-256. Producer format drift and invalid or
truncated modules are failures, with validation diagnostics and partial Evidence
retaining selected byte identity and tool provenance.
before and after each tool. WABT validates with
`wasm-validate --enable-all --ignore-custom-section-errors`, reports sections
through `wasm-objdump -h -x`, and decodes through
`wasm2wat --enable-all --ignore-custom-section-errors`. The result retains
selected byte SHA-256 and size, section payload ranges, full header/detail output
(including module-name preambles), exact import/export WAT forms, decoded WAT
and its UTF-8 SHA-256. Producer format drift and invalid or truncated modules
are failures, with validation diagnostics and partial Evidence retaining
selected byte identity and tool provenance.
Core validation remains authoritative when an optional custom section cannot
be interpreted. WABT warning output is preserved in `custom_section_warnings`;
`debug_name_metadata` reports whether the optional name section was absent,
fully interpreted, or uninterpretable. Older portable reports retain their
original strict command profile; missing metadata coverage is `unknown` and
missing warning history is `null`. The raw custom section remains in the
decoded WAT. An uninterpretable name section leaves debug module names unknown.
The tool profile preserves upstream release/source identity, resolved executable
paths, executable digests, exact banners and arguments. Executable digests are
+1 -1
View File
@@ -22,7 +22,7 @@ export const INSPECT_WASM_ARTIFACT_CONTRACT = {
...toolContractMetadata("inspect_wasm_artifact"),
kind: "artifact-provider",
description:
'Validate an explicit local WASM artifact with caller-supplied WABT 1.0.42. Omitted or complete detail returns exact byte SHA-256, sections, upstream import/export WAT forms and decoded WAT inline as Evidence. For counts, validation and WAT identity without WAT text, pass detail: "summary" on this first call; it retains the complete original Evidence and returns the same typed summary as inspect_analysis_view, avoiding oversized complete delivery. Read exact imports/exports or WAT pages with inspect_analysis_view using normalized_result.parent_evidence_id. Delivery selection does not change analysis, provider inputs or source identity. Adjust command_timeout_ms for each WABT command (default 30000 ms), separately from the client deadline; input/output safety limits stay fixed. Optionally associate selected JavaScript glue literals with explicit local candidates while preserving ambiguous URL/basename matches. Configure absolute REA_WABT_BIN_DIRECTORY. No installation, implicit fetch, WASM execution or original-source claim.',
'Validate an explicit local WASM artifact with caller-supplied WABT 1.0.42. Optional custom-section interpretation errors do not invalidate the core module; warnings and name-section interpretation status remain in the result. Omitted or complete detail returns exact byte SHA-256, sections, upstream import/export WAT forms and decoded WAT inline as Evidence. For counts, validation and WAT identity without WAT text, pass detail: "summary" on this first call; it retains the complete original Evidence and returns the same typed summary as inspect_analysis_view, avoiding oversized complete delivery. Read exact imports/exports or WAT pages with inspect_analysis_view using normalized_result.parent_evidence_id. Delivery selection does not change analysis, provider inputs or source identity. Adjust command_timeout_ms for each WABT command (default 30000 ms), separately from the client deadline; input/output safety limits stay fixed. Optionally associate selected JavaScript glue literals with explicit local candidates while preserving ambiguous URL/basename matches. Configure absolute REA_WABT_BIN_DIRECTORY. No installation, implicit fetch, WASM execution or original-source claim.',
inputSchema: wasmArtifactInspectionInputSchema,
outputSchema: analysisDetailOutputSchemaOf(
wasmArtifactSchema,
@@ -50,6 +50,8 @@ export const wasmViewItemSchema = z.union([
export const wasmSummarySchema = z.strictObject({
bytes: wasmArtifactSchema.shape.artifact.shape.bytes,
validation: wasmArtifactSchema.shape.validation,
debug_name_metadata: wasmArtifactSchema.shape.debug_name_metadata,
custom_section_warnings: wasmArtifactSchema.shape.custom_section_warnings,
available_counts: z.strictObject({
sections: z.int().nonnegative(),
imports: z.int().nonnegative(),
@@ -133,6 +135,8 @@ export const projectWasmArtifactView = (
summary: {
bytes: report.artifact.bytes,
validation: report.validation,
debug_name_metadata: report.debug_name_metadata,
custom_section_warnings: report.custom_section_warnings,
available_counts: {
sections: report.sections.length,
imports: report.imports.length,
+19 -1
View File
@@ -48,6 +48,18 @@ export const wasmArtifactSchema = z
interface: wasmInterfaceSchema.exactOptional(),
imports: z.array(z.string()),
exports: z.array(z.string()),
debug_name_metadata: z
.enum(["unknown", "not-present", "complete", "uninterpretable"])
.default("unknown"),
custom_section_warnings: z
.array(
z.strictObject({
tool: z.enum(["wasm-validate", "wasm-objdump", "wasm2wat"]),
text: z.string().min(1),
}),
)
.nullable()
.default(null),
headers: z.string(),
details: z.string(),
wat: z.strictObject({
@@ -119,12 +131,18 @@ export const wasmArtifactSchema = z
path: ["candidates"],
message: "Selected candidate does not match artifact identity.",
});
const tolerantCustomSections =
value.tool_profile.commands[0]?.arguments.includes(
"--ignore-custom-section-errors",
);
for (const [index, command] of value.tool_profile.commands.entries()) {
const expected = ["wasm-validate", "wasm-objdump", "wasm2wat"][index];
const argumentsExpected =
command.tool === "wasm-objdump"
? ["-h", "-x", "module.wasm"]
: ["--enable-all", "module.wasm"];
: tolerantCustomSections
? ["--enable-all", "--ignore-custom-section-errors", "module.wasm"]
: ["--enable-all", "module.wasm"];
if (
command.tool !== expected ||
command.version_banner.trim() !== "1.0.42" ||
+71
View File
@@ -207,6 +207,77 @@ const createWabtArtifactFixture = async () => {
return { selectedRoot, path, bytes, dump };
};
it("keeps core WASM usable when optional name-section metadata is malformed", async () => {
const fixture = await createWabtArtifactFixture();
const diagnostic =
"0000011: warning: invalid sub-section size: extends past end\n";
const dump = [
"",
"module.wasm:\tfile format wasm 0x1",
"",
"Sections:",
"",
' Custom start=0x0000000a end=0x0000004c (size=0x00000042) "name"',
"",
"Section Details:",
"",
].join("\n");
const provider = new WabtArtifactProvider(
{ REA_WABT_BIN_DIRECTORY: fixture.selectedRoot },
(input) => {
const version = input.arguments[0] === "--version";
const isObjdump = input.command.includes("wasm-objdump");
const isDecoder = input.command.includes("wasm2wat");
const stdout = version
? "1.0.42\n"
: isObjdump
? dump
: isDecoder
? '(module (@custom "name" "abc"))\n'
: "";
const stderr = version ? "" : diagnostic;
return spawnOwnedProviderProcess({
...input,
hostEnvironment: nodeHostEnvironment(input.hostEnvironment),
command: process.execPath,
arguments: [
"-e",
`process.stdout.write(${JSON.stringify(stdout)}); process.stderr.write(${JSON.stringify(stderr)});`,
],
expectedCommand: null,
});
},
);
try {
const result = await provider.inspect({
path: fixture.path,
glue_paths: [],
candidate_paths: [],
});
if (!result.ok) throw result.error;
expect(result.value.result).toMatchObject({
validation: "valid",
debug_name_metadata: "uninterpretable",
custom_section_warnings: [
{ tool: "wasm-validate", text: diagnostic },
{ tool: "wasm-objdump", text: diagnostic },
{ tool: "wasm2wat", text: diagnostic },
],
sections: [{ kind: "Custom", start: 10, end: 76, description: '"name"' }],
limitations: expect.arrayContaining([
expect.stringContaining("optional WebAssembly name custom section"),
]),
});
expect(
wasmArtifactSchema.parse(result.value.result).tool_profile.commands[0]
?.arguments,
).toContain("--ignore-custom-section-errors");
expect(await readFile(fixture.path)).toEqual(fixture.bytes);
} finally {
await provider.close();
}
});
const assertModeOutcome = (
mode: OutcomeMode,
result: Awaited<ReturnType<WabtArtifactProvider["inspect"]>>,
+59 -11
View File
@@ -59,6 +59,11 @@ type Launcher = NonNullable<Parameters<typeof runOwnedCommand>[2]>["launcher"];
const OPERATION = "inspect_wasm_artifact";
const digest = (text: string) =>
createHash("sha256").update(text).digest("hex");
const warningOutput = (text: string): readonly string[] | null => {
if (text === "") return [];
const lines = text.trimEnd().split("\n");
return lines.every((line) => /^\S+: warning: /u.test(line)) ? lines : null;
};
/** Run caller-supplied WABT against private copies of exactly selected bytes. */
export class WabtArtifactProvider implements WasmArtifactPort {
readonly #resources = new ArtifactResourceScope();
@@ -123,13 +128,17 @@ export class WabtArtifactProvider implements WasmArtifactPort {
"REA_WABT_BIN_DIRECTORY must be an absolute tool directory.",
);
const commands: {
tool: string;
tool: "wasm-validate" | "wasm-objdump" | "wasm2wat";
path: string;
sha256: string;
version_banner: string;
arguments: string[];
}[] = [];
for (const tool of ["wasm-validate", "wasm-objdump", "wasm2wat"]) {
for (const tool of [
"wasm-validate",
"wasm-objdump",
"wasm2wat",
] as const) {
const path = await realpath(
join(
directory,
@@ -152,7 +161,11 @@ export class WabtArtifactProvider implements WasmArtifactPort {
arguments:
tool === "wasm-objdump"
? ["-h", "-x", "module.wasm"]
: ["--enable-all", "module.wasm"],
: [
"--enable-all",
"--ignore-custom-section-errors",
"module.wasm",
],
});
}
const root = await this.createRuntime();
@@ -349,7 +362,7 @@ export class WabtArtifactProvider implements WasmArtifactPort {
confidence: "observed",
locations: [{ kind: "artifact-path", path: input.path }],
limitations: [
"WABT rejected these exact selected bytes with --enable-all; no complete inspection or target execution occurred.",
"WABT rejected these exact selected bytes with --enable-all and --ignore-custom-section-errors; no complete inspection or target execution occurred.",
],
},
),
@@ -364,28 +377,51 @@ export class WabtArtifactProvider implements WasmArtifactPort {
path: ["path"],
reason: "invalid_format",
message:
"Selected bytes failed WABT validation with --enable-all; inspect retained upstream diagnostics.",
"Selected bytes failed WABT validation with --enable-all and --ignore-custom-section-errors; inspect retained upstream diagnostics.",
},
],
);
const customSectionWarnings: {
tool: (typeof commands)[number]["tool"];
text: string;
}[] = [];
const optionalLimitations: string[] = [];
const retainWarnings = (
tool: (typeof commands)[number]["tool"],
output: typeof validation,
) => {
const warnings = warningOutput(output.stderr.text);
if (warnings === null)
throw new AnalysisOutputError(
OPERATION,
"Unexpected WABT diagnostic output.",
{
capturedOutput: {
stdout: output.stdout.text,
stderr: output.stderr.text,
truncated: false,
},
},
);
if (warnings.length > 0)
customSectionWarnings.push({ tool, text: output.stderr.text });
};
retainWarnings("wasm-validate", validation);
const dump = await execute(objdump, objdump.arguments);
capturedOutput = {
stdout: dump.stdout.text,
stderr: dump.stderr.text,
truncated: false,
};
if (dump.stderr.text !== "")
throw new AnalysisOutputError(
OPERATION,
"Unexpected WABT section output.",
);
retainWarnings("wasm-objdump", dump);
const wat = await execute(decode, decode.arguments);
capturedOutput = {
stdout: wat.stdout.text,
stderr: wat.stderr.text,
truncated: false,
};
if (!wat.stdout.text.startsWith("(module") || wat.stderr.text !== "")
retainWarnings("wasm2wat", wat);
if (!wat.stdout.text.startsWith("(module"))
throw new AnalysisOutputError(
OPERATION,
"Unexpected WABT decoding output.",
@@ -395,8 +431,17 @@ export class WabtArtifactProvider implements WasmArtifactPort {
exports,
customSectionNames,
debugModuleNames,
debugNameMetadata,
interface: interfaceFacts,
} = parseWabtWat(wat.stdout.text);
if (debugNameMetadata === "uninterpretable")
optionalLimitations.push(
"The optional WebAssembly name custom section could not be interpreted; its original custom-section payload remains in decoded WAT, while debug module names are unavailable.",
);
if (customSectionWarnings.length > 0)
optionalLimitations.push(
"WABT reported optional custom-section interpretation warnings; exact warning output is retained in custom_section_warnings.",
);
capturedOutput = {
stdout: dump.stdout.text,
stderr: dump.stderr.text,
@@ -414,6 +459,7 @@ export class WabtArtifactProvider implements WasmArtifactPort {
"JavaScript references are AST-observed static literals only. Candidate matches are associations, not proven data flow, URL-to-file identity or runtime loading. Computed references and aliases are not resolved.",
"No target execution or implicit fetch. WABT tools are trusted caller-supplied executables; SHA-256 identifies their bytes, not publisher authenticity. No OS memory sandbox is claimed.",
`Each WABT command has a ${String(commandTimeoutMs)} ms deadline and a fixed 32 MiB combined output budget. The deadline is not a whole-inspection budget. Oversized output fails instead of producing truncated success.`,
...optionalLimitations,
];
const report = wasmArtifactSchema.parse({
artifact,
@@ -422,6 +468,8 @@ export class WabtArtifactProvider implements WasmArtifactPort {
imports,
exports,
interface: interfaceFacts,
debug_name_metadata: debugNameMetadata,
custom_section_warnings: customSectionWarnings,
headers,
details,
wat: {
+6 -2
View File
@@ -196,8 +196,12 @@ it.each([
it.each([
'(module (@custom "name" "\\00\\06\\05name"))',
'(module (@custom "name" "\\00\\05\\05name"))',
])("rejects incomplete decoded module-name metadata: %s", (text) => {
expect(() => parseWabtWat(text)).toThrow(/metadata/u);
])("retains an uninterpretable optional name section: %s", (text) => {
expect(parseWabtWat(text)).toMatchObject({
customSectionNames: ["name"],
debugModuleNames: [],
debugNameMetadata: "uninterpretable",
});
});
it("frames custom section names at their NUL-terminated objdump display", async () => {
const text = await golden();
+32 -18
View File
@@ -2,7 +2,7 @@ import { parseWabtInterface } from "./WabtInterface.js";
import { watString, watStringEnd } from "./WabtString.js";
/** Read only module-name metadata from WABT's preserved name-section payload. */
const moduleNames = (bytes: Uint8Array): string[] => {
const moduleNames = (bytes: Uint8Array): string[] | null => {
const names: string[] = [];
let offset = 0;
const uint32 = (end: number): number => {
@@ -16,23 +16,27 @@ const moduleNames = (bytes: Uint8Array): string[] => {
}
throw new Error("Incomplete WABT name-section metadata.");
};
while (offset < bytes.length) {
const type = uint32(bytes.length);
const size = uint32(bytes.length);
const end = offset + size;
if (end > bytes.length)
throw new Error("Incomplete WABT name-section metadata.");
if (type === 0 && size !== 0) {
const length = uint32(end);
if (offset + length !== end)
throw new Error("Incomplete WABT module-name metadata.");
names.push(
new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(
bytes.subarray(offset, end),
),
);
try {
while (offset < bytes.length) {
const type = uint32(bytes.length);
const size = uint32(bytes.length);
const end = offset + size;
if (end > bytes.length)
throw new Error("Incomplete WABT name-section metadata.");
if (type === 0 && size !== 0) {
const length = uint32(end);
if (offset + length !== end)
throw new Error("Incomplete WABT module-name metadata.");
names.push(
new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(
bytes.subarray(offset, end),
),
);
}
offset = end;
}
offset = end;
} catch {
return null;
}
return names;
};
@@ -46,6 +50,8 @@ export const parseWabtWat = (text: string) => {
const exports: string[] = [];
const customSectionNames: string[] = [];
const debugModuleNames: string[] = [];
let debugNameMetadata: "not-present" | "complete" | "uninterpretable" =
"not-present";
let depth = 0;
let start = 0;
let commentDepth = 0;
@@ -126,7 +132,14 @@ export const parseWabtWat = (text: string) => {
form,
nameString.end + payloadPrefix[0].length,
);
debugModuleNames.push(...moduleNames(payload.bytes));
const names = moduleNames(payload.bytes);
if (names === null) {
debugNameMetadata = "uninterpretable";
debugModuleNames.length = 0;
} else if (debugNameMetadata !== "uninterpretable") {
debugNameMetadata = "complete";
debugModuleNames.push(...names);
}
}
}
}
@@ -142,6 +155,7 @@ export const parseWabtWat = (text: string) => {
exports,
customSectionNames,
debugModuleNames,
debugNameMetadata,
interface: parseWabtInterface(forms),
};
};
@@ -15,7 +15,7 @@ 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 sources = (["i32", "i64"] as const).map((parameter, index) => {
const report = wasmArtifactFixture();
report.interface = {
state: "complete",
@@ -33,6 +33,18 @@ cliTest(
},
],
};
// The left source predates optional-metadata coverage fields and the
// tolerant custom-section profile. Portable Evidence remains readable.
const {
debug_name_metadata: _debugNames,
custom_section_warnings: _warnings,
...legacyReport
} = report;
if (index === 0)
for (const command of legacyReport.tool_profile.commands)
command.arguments = command.arguments.filter(
(argument) => argument !== "--ignore-custom-section-errors",
);
return createEvidence(
{
path: report.artifact.path,
@@ -43,7 +55,7 @@ cliTest(
{
operation: "inspect_wasm_artifact",
parameters: { path: report.artifact.path },
result: report,
result: index === 0 ? legacyReport : report,
confidence: "observed",
},
);
@@ -60,6 +60,10 @@ const assertFirstSummaryDelivery = async (
code_units: value.wat.text.length,
});
expect(summary.summary.wat).not.toHaveProperty("text");
expect(summary.summary).toMatchObject({
debug_name_metadata: value.debug_name_metadata,
custom_section_warnings: value.custom_section_warnings,
});
const retained = harness.session.evidenceById(summary.parent_evidence_id);
if (retained === undefined) throw new Error("Summary lost original Evidence");
expect(retained.normalized_result).toEqual(value);
@@ -424,6 +428,13 @@ it("reads an oversized completed WASM result through its reported retained refer
...wasmArtifactFixture(),
imports: ['(import "env" "memory.with punctuation" (memory 1))'],
exports: ['(export "first" (func 0))', '(export "second" (func 1))'],
debug_name_metadata: "uninterpretable" as const,
custom_section_warnings: [
{
tool: "wasm-validate" as const,
text: "0000011: warning: invalid sub-section size: extends past end\n",
},
],
wat: {
text,
sha256: createHash("sha256").update(text, "utf8").digest("hex"),
+2 -1
View File
@@ -2,7 +2,8 @@
`module.wat` is source-owned. WABT 1.0.42 `wat2wasm module.wat -o module.wasm`
produced the binary used for `wasm-objdump -h -x module.wasm` (`objdump.txt`) and
`wasm2wat --enable-all module.wasm` (`decoded.wat`). No binary is checked in.
`wasm2wat --enable-all --ignore-custom-section-errors module.wasm`
(`decoded.wat`). No binary is checked in.
The module has an imported function, exported memory/function and a `test`
custom section. The custom payload is retained as an `@custom` annotation in decoded WAT.
+3 -1
View File
@@ -11,6 +11,8 @@ export const wasmArtifactFixture = (
sections: [],
imports: [],
exports: [],
debug_name_metadata: "not-present",
custom_section_warnings: [],
headers: "",
details: "",
wat: {
@@ -31,7 +33,7 @@ export const wasmArtifactFixture = (
arguments:
tool === "wasm-objdump"
? ["-h", "-x", "module.wasm"]
: ["--enable-all", "module.wasm"],
: ["--enable-all", "--ignore-custom-section-errors", "module.wasm"],
}),
),
timeout_ms: 30_000,