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); } } }