From 1e688e4201d0bedad911dba3eead18505eea00e9 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 24 Oct 2026 02:54:07 +0800 Subject: [PATCH] fix(wasm): preserve partial name observations and coverage Retain module-name displays observed before optional metadata failure so strict objdump framing can use those facts. Preserve uncertainty across name sections and report unknown completeness when unattributed producer warnings concern skipped custom metadata; keep exact warnings, core validation, WAT and artifact provenance. Validation: focused source/process regressions and 61 real WABT1.0.42 CLI/MCP verifier cases pass. The unmodified official macOS arm64 archive matches its published SHA-256. Coverage includes outer/inner malformed name metadata, valid empty-map controls, stable core/interface facts and cleanup. No inspected WASM target was executed. Fixes #6885 --- docs/wasm-artifacts.md | 15 +- scripts/verify-wasm-artifact.mjs | 112 +++++++++++++++ src/wasm/WabtArtifactProvider.test.ts | 198 +++++++++++++++++--------- src/wasm/WabtArtifactProvider.ts | 15 +- src/wasm/WabtOutput.test.ts | 35 +++++ src/wasm/WabtWat.ts | 44 ++++-- 6 files changed, 335 insertions(+), 84 deletions(-) diff --git a/docs/wasm-artifacts.md b/docs/wasm-artifacts.md index 75b2f5c878..8aa4ba9c04 100644 --- a/docs/wasm-artifacts.md +++ b/docs/wasm-artifacts.md @@ -75,10 +75,17 @@ 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. +fully interpreted, uninterpretable, or unknown. WABT custom-section warnings +whose section attribution is unknown prevent a complete debug-name claim, even +when module names were decoded. They do not establish that the name section is +malformed. Exact warnings retain their producing tool and text. 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 complete +debug-name coverage unknown. Module names decoded before a later metadata +failure remain available for exact objdump framing, including names containing +newlines or NUL; those partial observations do not establish complete optional +metadata. The tool profile preserves upstream release/source identity, resolved executable paths, executable digests, exact banners and arguments. Executable digests are diff --git a/scripts/verify-wasm-artifact.mjs b/scripts/verify-wasm-artifact.mjs index 38ee229bd4..fcf7412031 100644 --- a/scripts/verify-wasm-artifact.mjs +++ b/scripts/verify-wasm-artifact.mjs @@ -429,6 +429,118 @@ try { cases += 2; } } + // Source-owned inert core and raw optional payloads reproduce #6885 without + // asking wat2wasm to repair or regenerate the malformed name metadata. + const core = Buffer.from( + "0061736d0100000001040160000003020100070501016600000a040102000b", + "hex", + ); + for (const [label, payload, metadata, observedName] of [ + ["valid-name", Buffer.from("00060568656c6c6f", "hex"), "complete", true], + [ + "valid-name-map", + Buffer.from("00060568656c6c6f010100", "hex"), + "complete", + true, + ], + [ + "inner-name-error", + Buffer.from("00060568656c6c6f010101", "hex"), + "unknown", + true, + ], + [ + "partial-name", + Buffer.from("00060568656c6c6f010201", "hex"), + "uninterpretable", + true, + ], + [ + "initial-name-error", + Buffer.from("0006056e616d65", "hex"), + "uninterpretable", + false, + ], + ]) { + const fixtureRoot = join(root.path, label); + await mkdir(fixtureRoot); + const path = join(fixtureRoot, "module.wasm"), + custom = Buffer.concat([Buffer.from([4]), Buffer.from("name"), payload]), + original = Buffer.concat([core, Buffer.from([0, custom.length]), custom]); + await writeFile(path, original); + const validation = await tool( + "wasm-validate", + ["--enable-all", "--ignore-custom-section-errors", "module.wasm"], + fixtureRoot, + ), + dump = await tool( + "wasm-objdump", + ["-h", "-x", "module.wasm"], + fixtureRoot, + ), + decoded = await tool( + "wasm2wat", + ["--enable-all", "--ignore-custom-section-errors", "module.wasm"], + fixtureRoot, + ); + const input = { path, glue_paths: [], candidate_paths: [] }; + const cli = parseEvidence(await inspectCli(input)); + const reply = await callTool({ + name: "inspect_wasm_artifact", + arguments: input, + }); + assert.notEqual(reply.isError, true, mcpTextValue(reply)); + assert.ok( + ajv.validate(advertised.outputSchema, reply.structuredContent), + JSON.stringify(ajv.errors), + ); + const mcp = parseEvidence(reply.structuredContent); + assert.deepEqual(cli, mcp); + const report = mcp.normalized_result; + assert.equal(report.validation, "valid"); + assert.equal(report.artifact.sha256, sha256(original)); + assert.equal(report.artifact.bytes, original.length); + assert.equal(report.debug_name_metadata, metadata); + assert.equal(report.interface.state, "complete"); + assert.deepEqual(report.exports, ['(export "f" (func 0))']); + assert.deepEqual( + report.interface.entries.map(({ direction, name, signature }) => ({ + direction, + name, + signature, + })), + [ + { + direction: "export", + name: "f", + signature: { kind: "func", params: [], results: [] }, + }, + ], + ); + assert.equal( + report.headers.includes("module name: \n"), + observedName, + ); + assert.equal(report.sections.at(-1).end, original.length); + assert.equal(report.wat.text, decoded.stdout); + assert.equal(report.wat.sha256, sha256(decoded.stdout)); + assert.ok(report.wat.text.includes('(@custom "name"')); + const warnings = [ + { tool: "wasm-validate", text: validation.stderr }, + { tool: "wasm-objdump", text: dump.stderr }, + { tool: "wasm2wat", text: decoded.stderr }, + ].filter(({ text }) => text !== ""); + assert.equal(warnings.length, metadata === "complete" ? 0 : 3); + assert.deepEqual(report.custom_section_warnings, warnings); + if (metadata !== "complete") + assert.ok( + report.limitations.some((text) => + text.includes("complete debug-name coverage remains unknown"), + ), + ); + assert.deepEqual(await readFile(path), original); + cases += 2; + } cases += await verifyWabtCleanup(entrypoint, environment, childTempRoot); } catch (cause) { failures.push(cause); diff --git a/src/wasm/WabtArtifactProvider.test.ts b/src/wasm/WabtArtifactProvider.test.ts index b047bfad09..ba584f68c7 100644 --- a/src/wasm/WabtArtifactProvider.test.ts +++ b/src/wasm/WabtArtifactProvider.test.ts @@ -207,76 +207,136 @@ 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, +it.each([ + { + label: "initially malformed", + preamble: "", + payload: "abc", + diagnostic: + "0000011: warning: invalid sub-section size: extends past end\n", + metadata: "uninterpretable", + }, + { + label: "partially decoded", + preamble: "module name: \n", + payload: String.raw`\00\06\05hello\01\02\01`, + diagnostic: + "0000011: warning: invalid sub-section size: extends past end\n", + metadata: "uninterpretable", + }, + { + label: "an inner map rejected by WABT", + preamble: "module name: \n", + payload: String.raw`\00\06\05hello\01\01\01`, + diagnostic: + "000002f: warning: invalid name count 1, only 0 bytes left in section\n", + metadata: "unknown", + }, + { + label: "a valid empty function-name map", + preamble: "module name: \n", + payload: String.raw`\00\06\05hello\01\01\00`, + diagnostic: "", + metadata: "complete", + }, + { + label: "a valid name with an unattributed custom-section warning", + preamble: "module name: \n", + payload: String.raw`\00\06\05hello\01\01\00`, + diagnostic: + "0000030: warning: unable to read u32 leb128: linking metadata version\n", + metadata: "unknown", + }, +])( + "keeps core WASM usable when optional name metadata is $label", + async ({ preamble, payload, diagnostic, metadata }) => { + const fixture = await createWabtArtifactFixture(); + const dump = [ + "", + `module.wasm:\tfile format wasm 0x1\n${preamble}`.trimEnd(), + "", + "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" "${payload}"))\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: [], }); - }, - ); - 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(); - } -}); + if (!result.ok) throw result.error; + const report = wasmArtifactSchema.parse(result.value.result); + expect(report).toMatchObject({ + validation: "valid", + debug_name_metadata: metadata, + custom_section_warnings: + diagnostic === "" + ? [] + : [ + { tool: "wasm-validate", text: diagnostic }, + { tool: "wasm-objdump", text: diagnostic }, + { tool: "wasm2wat", text: diagnostic }, + ], + sections: [ + { kind: "Custom", start: 10, end: 76, description: '"name"' }, + ], + }); + if (metadata !== "complete") + expect(report.limitations).toContainEqual( + expect.stringContaining( + "complete debug-name coverage remains unknown", + ), + ); + else + expect(report.limitations.join("\n")).not.toContain( + "complete debug-name coverage remains unknown", + ); + expect(report.artifact.sha256).toBe( + createHash("sha256").update(fixture.bytes).digest("hex"), + ); + expect(report.tool_profile.commands[0]?.arguments).toContain( + "--ignore-custom-section-errors", + ); + expect(await readFile(fixture.path)).toEqual(fixture.bytes); + expect(report.headers).toBe( + dump.slice(0, dump.indexOf("Section Details:\n")), + ); + expect(report.wat.text).toBe(`(module (@custom "name" "${payload}"))\n`); + } finally { + await provider.close(); + } + }, +); const assertModeOutcome = ( mode: OutcomeMode, diff --git a/src/wasm/WabtArtifactProvider.ts b/src/wasm/WabtArtifactProvider.ts index 546cede40f..c7cff51466 100644 --- a/src/wasm/WabtArtifactProvider.ts +++ b/src/wasm/WabtArtifactProvider.ts @@ -431,12 +431,23 @@ export class WabtArtifactProvider implements WasmArtifactPort { exports, customSectionNames, debugModuleNames, - debugNameMetadata, + debugNameMetadata: decodedNameMetadata, interface: interfaceFacts, } = parseWabtWat(wat.stdout.text); + // WAT framing reads module names, not every inner name map. A producer + // warning may concern a skipped body or another custom section; without + // section attribution it cannot establish complete debug-name coverage. + const debugNameMetadata = + decodedNameMetadata === "complete" && customSectionWarnings.length > 0 + ? "unknown" + : decodedNameMetadata; 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.", + "The optional WebAssembly name custom section could not be fully interpreted; its original payload remains in decoded WAT. Observed module-name displays are retained for producer framing; complete debug-name coverage remains unknown.", + ); + else if (debugNameMetadata === "unknown") + optionalLimitations.push( + "WABT custom-section warning attribution is unknown, so complete debug-name coverage remains unknown. Observed module-name displays are retained for producer framing.", ); if (customSectionWarnings.length > 0) optionalLimitations.push( diff --git a/src/wasm/WabtOutput.test.ts b/src/wasm/WabtOutput.test.ts index 4c50dc0bae..8df9508481 100644 --- a/src/wasm/WabtOutput.test.ts +++ b/src/wasm/WabtOutput.test.ts @@ -203,6 +203,41 @@ it.each([ debugNameMetadata: "uninterpretable", }); }); +it.each([ + String.raw`\00\06\05hello\01\02\01`, + String.raw`\00\07\05hello\00`, + String.raw`\00\06\05hello\00\06\05later`, +])( + "preserves a module name already observed before malformed metadata: %s", + (payload) => { + expect( + parseWabtWat(`(module (@custom "name" "${payload}"))`), + ).toMatchObject({ + debugModuleNames: ["hello"], + debugNameMetadata: "uninterpretable", + }); + }, +); +it("stops before a module name that WABT rejects as out of order", () => { + expect( + parseWabtWat(String.raw`(module (@custom "name" "\01\00\00\06\05hello"))`), + ).toMatchObject({ + debugModuleNames: [], + debugNameMetadata: "uninterpretable", + }); +}); +it("keeps observed names across custom sections while coverage stays uninterpretable", () => { + expect( + parseWabtWat(String.raw`(module + (@custom "name" "\00\06\05hello") + (@custom "name" "\00\06\05short\01\02\01") + (@custom "name" "\00\06\05later"))`), + ).toMatchObject({ + customSectionNames: ["name", "name", "name"], + debugModuleNames: ["hello", "short", "later"], + debugNameMetadata: "uninterpretable", + }); +}); it("frames custom section names at their NUL-terminated objdump display", async () => { const text = await golden(); expect(parseWabtOutput(text, 76, ["test\0hidden"]).sections.at(-1)).toEqual({ diff --git a/src/wasm/WabtWat.ts b/src/wasm/WabtWat.ts index 648a6cf39b..cf8487a4c9 100644 --- a/src/wasm/WabtWat.ts +++ b/src/wasm/WabtWat.ts @@ -1,8 +1,18 @@ import { parseWabtInterface } from "./WabtInterface.js"; import { watString, watStringEnd } from "./WabtString.js"; +import { err, ok, type Result } from "../domain/result.js"; + +interface WabtNameMetadataFailure { + readonly _tag: "WabtNameMetadataFailure"; + readonly reason: string; + /** Names WABT already observed before rejecting the remaining metadata. */ + readonly partial: readonly string[]; +} /** Read only module-name metadata from WABT's preserved name-section payload. */ -const moduleNames = (bytes: Uint8Array): string[] | null => { +const moduleNames = ( + bytes: Uint8Array, +): Result => { const names: string[] = []; let offset = 0; const uint32 = (end: number): number => { @@ -17,28 +27,41 @@ const moduleNames = (bytes: Uint8Array): string[] | null => { throw new Error("Incomplete WABT name-section metadata."); }; try { + let previousType = -1; while (offset < bytes.length) { const type = uint32(bytes.length); + if (type <= previousType) + throw new Error("Duplicate or out-of-order WABT name subsection."); + previousType = type; 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) + const nameEnd = offset + length; + if (nameEnd > end) throw new Error("Incomplete WABT module-name metadata."); names.push( new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode( - bytes.subarray(offset, end), + bytes.subarray(offset, nameEnd), ), ); + // WABT calls OnModuleName before rejecting trailing subsection bytes. + if (nameEnd !== end) + throw new Error("Unfinished WABT module-name subsection."); } offset = end; } - } catch { - return null; + } catch (cause) { + if (!(cause instanceof Error)) throw cause; + return err({ + _tag: "WabtNameMetadataFailure", + reason: cause.message, + partial: names, + }); } - return names; + return ok(names); }; /** Extract exact top-level WABT WAT forms; no raw WASM decoding or import/export name unescaping. */ @@ -133,12 +156,15 @@ export const parseWabtWat = (text: string) => { nameString.end + payloadPrefix[0].length, ); const names = moduleNames(payload.bytes); - if (names === null) { + // Framing needs every observed display, even when optional + // metadata coverage is incomplete in this or an earlier section. + debugModuleNames.push( + ...(names.ok ? names.value : names.error.partial), + ); + if (!names.ok) { debugNameMetadata = "uninterpretable"; - debugModuleNames.length = 0; } else if (debugNameMetadata !== "uninterpretable") { debugNameMetadata = "complete"; - debugModuleNames.push(...names); } } }