From b777e5d4d8a7b60edad7d5c0eaa56408a4b3f610 Mon Sep 17 00:00:00 2001 From: Arthas <29607505+bugstan@users.noreply.github.com> Date: Mon, 12 Oct 2026 04:44:15 +0700 Subject: [PATCH] fix(wasm): preserve debug module names in WABT output (#1967) * fix(wasm): preserve debug module names in WABT output * fix(native): accept otool dylib-use options Preserve modern dylib options while requiring one encoding metadata row and retaining duplicate and multiline-output checks. Share field parsing between preservation and validation. Verify delayed dylibs through real macOS CLI/MCP workflows and refresh the high-address golden with the provider's -h -l capture. Validated 85 focused native tests, npm run check, and verify:macos-bundle on macOS arm64. --------- Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> --- docs/testing.md | 4 +- docs/wasm-artifacts.md | 3 +- scripts/verify-wasm-artifact.mjs | 30 +++++++- scripts/verify/apple/native-provenance.mjs | 13 ++++ src/native/parsers/otool.ts | 73 ++++++++++-------- src/wasm/WabtArtifactProvider.ts | 6 +- src/wasm/WabtOutput.test.ts | 24 ++++++ src/wasm/WabtOutput.ts | 19 +++-- src/wasm/WabtWat.ts | 74 ++++++++++++++++--- .../native/nativeLoadKinds.golden.test.ts | 63 ++++++++++++++++ .../native-macos/otool-high-address.md | 2 +- .../native-macos/otool-high-address.txt | 3 + tests/fixtures/wasm/module-names.wat | 4 + 13 files changed, 262 insertions(+), 56 deletions(-) create mode 100644 tests/fixtures/wasm/module-names.wat diff --git a/docs/testing.md b/docs/testing.md index 45c3dcf84..09b01e79c 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -1387,7 +1387,9 @@ reports a named skip; `--require-tools` makes missing configuration fail. The verifier creates and validates real modules with custom sections, multiple import/export kinds, escaped names, an empty module, and a bulk-memory -DataCount section. It compares exact WAT, +DataCount section. Debug-name fixtures cover compiler-generated module names +and preserved name-section payloads with empty, multiline, Unicode, long and +NUL-containing names. It compares exact WAT, section output and selected/tool digests through the compiled CLI and real MCP SDK, validates advertised schemas, checks identical CLI/MCP Evidence IDs and session bundle readback, retains invalid/truncated module diagnostics, preserves diff --git a/docs/wasm-artifacts.md b/docs/wasm-artifacts.md index 647396d53..5289976a7 100644 --- a/docs/wasm-artifacts.md +++ b/docs/wasm-artifacts.md @@ -37,7 +37,8 @@ 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, exact import/export WAT +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. diff --git a/scripts/verify-wasm-artifact.mjs b/scripts/verify-wasm-artifact.mjs index 9a8c53644..82ddd0fb0 100644 --- a/scripts/verify-wasm-artifact.mjs +++ b/scripts/verify-wasm-artifact.mjs @@ -131,15 +131,35 @@ try { i32.const 1 memory.init 0 data.drop 0))\n`, - ]; - for (const [index, source] of sources.entries()) { + ].map((source) => ({ source, debugNames: false })); + sources.push( + { + source: + '(module $named (func (export "answer") (result i32) i32.const 42))\n', + debugNames: true, + }, + { + source: await readFile( + new URL("../tests/fixtures/wasm/module-names.wat", import.meta.url), + "utf8", + ), + debugNames: false, + }, + ); + for (const [index, { source, debugNames }] of sources.entries()) { const fixtureRoot = join(root.path, `fixture-${index}`); await mkdir(fixtureRoot); const path = join(fixtureRoot, "module.wasm"); await writeFile(join(fixtureRoot, "source.wat"), source); await tool( "wat2wasm", - ["--enable-all", "source.wat", "-o", "module.wasm"], + [ + "--enable-all", + ...(debugNames ? ["--debug-names"] : []), + "source.wat", + "-o", + "module.wasm", + ], fixtureRoot, ); await tool("wasm-validate", ["--enable-all", "module.wasm"], fixtureRoot); @@ -229,6 +249,10 @@ try { '"unicode-é\\\t"', ], ); + if (debugNames) { + assert.ok(report.headers.includes("\nmodule name: \n")); + assert.deepEqual(report.exports, ['(export "answer" (func 0))']); + } if (index === 4) { assert.match( headers, diff --git a/scripts/verify/apple/native-provenance.mjs b/scripts/verify/apple/native-provenance.mjs index ad7bf6cc1..a0cf192a7 100644 --- a/scripts/verify/apple/native-provenance.mjs +++ b/scripts/verify/apple/native-provenance.mjs @@ -48,6 +48,19 @@ export async function verifyNativeProvenance(app, executable) { assert.equal(mcp.raw_result, null); assert.ok(cli.normalized_result.provenance.length > 0); assert.deepEqual(mcp.normalized_result, cli.normalized_result); + if (operation === "inspect_macho") { + const delayed = cli.normalized_result.load_commands.items.find( + ({ fields }) => fields.name === "@rpath/libdelay.dylib (offset 28)", + ); + assert.equal(delayed?.kind, "LC_LOAD_DYLIB"); + assert.equal(delayed.fields.options, "delay-init"); + assert.equal(delayed.fields["time stamp"], undefined); + assert.ok( + cli.normalized_result.dependencies.items.some( + ({ path }) => path === "@rpath/libdelay.dylib", + ), + ); + } assert.deepEqual( JSON.parse(response.content.find((item) => item.type === "text").text), response.structuredContent, diff --git a/src/native/parsers/otool.ts b/src/native/parsers/otool.ts index 1321a8696..85c54785a 100644 --- a/src/native/parsers/otool.ts +++ b/src/native/parsers/otool.ts @@ -52,6 +52,16 @@ const DYLIB_COMMANDS = new Set([ "LC_LOAD_UPWARD_DYLIB", ]); +const DYLIB_FIELDS = [ + "cmd", + "cmdsize", + "name", + "current version", + "compatibility version", +]; +// otool prints options for dylib_use_command and a timestamp for dylib_command. +const DYLIB_ENCODING_FIELDS = ["time stamp", "options"]; + /** * otool emits LF, but a capture replayed from another host or toolchain can * carry CRLF. The section/command boundaries below match a bare `\n`, so a @@ -195,7 +205,7 @@ const parseLoadCommand = (block: string, state: ParsedLoadCommands): void => { const kind = stringField(fields, "cmd"); if (kind === null) throw new TypeError("otool load command is missing cmd"); for (const key of scalarFields(kind)) requireUniqueField(header, key); - if (DYLIB_COMMANDS.has(kind)) validateDylibCommandLines(header); + if (DYLIB_COMMANDS.has(kind)) validateDylibCommandLines(header, fields); state.commands.push({ index, kind, @@ -208,19 +218,23 @@ const parseLoadCommand = (block: string, state: ParsedLoadCommands): void => { collectSegment(kind, block, fields, state); }; -const validateDylibCommandLines = (block: string): void => { - const allowed = new Set([ - "cmd", - "cmdsize", - "name", - "time stamp", - "current version", - "compatibility version", - ]); +const validateDylibCommandLines = ( + block: string, + fields: Readonly>, +): void => { + const encodingFields = DYLIB_ENCODING_FIELDS.filter((key) => + Object.hasOwn(fields, key), + ); + if (encodingFields.length !== 1) + throw new TypeError( + "otool dylib command must contain timestamp or options", + ); + for (const key of encodingFields) requireUniqueField(block, key); + const allowed = new Set([...DYLIB_FIELDS, ...DYLIB_ENCODING_FIELDS]); for (const rawLine of block.split("\n").slice(1)) { const line = rawLine.trim(); if (line.length === 0) continue; - const key = /^(time stamp|\S+(?:\s+version)?)\s+/u.exec(line)?.[1]; + const key = parseField(line)?.[0]; if (key === undefined || !allowed.has(key)) throw new TypeError( "otool dylib command contains ambiguous multiline text", @@ -230,14 +244,7 @@ const validateDylibCommandLines = (block: string): void => { const scalarFields = (kind: string): readonly string[] => { const common = ["cmd", "cmdsize"]; - if (DYLIB_COMMANDS.has(kind)) - return [ - ...common, - "name", - "time stamp", - "current version", - "compatibility version", - ]; + if (DYLIB_COMMANDS.has(kind)) return DYLIB_FIELDS; if (kind === "LC_LOAD_DYLINKER" || kind === "LC_ID_DYLINKER") return [...common, "name"]; if (kind === "LC_UUID") return [...common, "uuid"]; @@ -339,25 +346,31 @@ const collectSegment = ( const parseFields = (block: string): Record => { const fields: Record = {}; for (const rawLine of block.split(/\r?\n/u).slice(1)) { - const line = rawLine.trim(); - // Keys are `token` or a compound `token version`. `time stamp` is the one - // other real key, and it must be matched as a whole: treating `stamp` as a - // generic suffix would swallow an install name such as - // `name stamp plugin.dylib`, hiding the dependency path behind `unknown`. - const match = /^((?:time stamp)|\S+(?:\s+version)?)\s+(.+)$/u.exec(line); - if (match?.[1] === undefined || match[2] === undefined) continue; - const value = match[2].trim(); - fields[match[1]] = - match[1] === "segname" || match[1] === "sectname" + const field = parseField(rawLine); + if (field === undefined) continue; + const [key, value] = field; + fields[key] = + key === "segname" || key === "sectname" ? value : (numeric(value) ?? value); } return fields; }; +const parseField = (rawLine: string): readonly [string, string] | undefined => { + const line = rawLine.trim(); + // A dylib-use command with no flags still has an empty options row. + if (line === "options") return ["options", ""]; + // Match time stamp as a whole without swallowing `name stamp plugin.dylib`. + const match = /^((?:time stamp)|\S+(?:\s+version)?)\s+(.+)$/u.exec(line); + return match?.[1] === undefined || match[2] === undefined + ? undefined + : [match[1], match[2].trim()]; +}; + const requireUniqueField = (block: string, key: string): void => { const escaped = key.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); - const pattern = new RegExp(`^\\s*${escaped}\\s+`, "mu"); + const pattern = new RegExp(`^\\s*${escaped}(?:\\s|$)`, "mu"); let count = 0; for (const line of block.split("\n")) if (pattern.test(line)) count += 1; if (count !== 1) diff --git a/src/wasm/WabtArtifactProvider.ts b/src/wasm/WabtArtifactProvider.ts index 6ffaa04a9..ffed3871b 100644 --- a/src/wasm/WabtArtifactProvider.ts +++ b/src/wasm/WabtArtifactProvider.ts @@ -323,9 +323,8 @@ export class WabtArtifactProvider implements WasmArtifactPort { OPERATION, "Unexpected WABT decoding output.", ); - const { imports, exports, customSectionNames } = parseWabtWat( - wat.stdout.text, - ); + const { imports, exports, customSectionNames, debugModuleNames } = + parseWabtWat(wat.stdout.text); capturedOutput = { stdout: dump.stdout.text, stderr: dump.stderr.text, @@ -335,6 +334,7 @@ export class WabtArtifactProvider implements WasmArtifactPort { dump.stdout.text, artifact.bytes, customSectionNames, + debugModuleNames, ); const limitations = [ "WAT is a decoded representation, not original source or proof of runtime behavior. Custom annotations may retain payloads, but WAT does not establish original binary layout or round-trip byte identity. Original bytes and objdump output remain authoritative.", diff --git a/src/wasm/WabtOutput.test.ts b/src/wasm/WabtOutput.test.ts index c12402b38..fa65230e3 100644 --- a/src/wasm/WabtOutput.test.ts +++ b/src/wasm/WabtOutput.test.ts @@ -138,6 +138,28 @@ it("accepts an unpadded DataCount row and still rejects malformed output and bad ), ).toThrow(/header/u); }); +it.each([ + { displayed: [], decoded: ["named"] }, + { displayed: ["named"], decoded: [] }, + { displayed: ["other"], decoded: ["named"] }, +])( + "rejects module-name headers inconsistent with decoded WAT: %j", + async ({ displayed, decoded }) => { + const text = (await golden()).replace( + "\n\nSections:", + `\n${displayed.map((name) => `module name: <${name}>\n`).join("")}\nSections:`, + ); + expect(() => parseWabtOutput(text, 76, ["test"], decoded)).toThrow( + /header format/u, + ); + }, +); +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); +}); it("extracts escaped WAT import/export forms while retaining multiline producer text", async () => { const text = await readFile( new URL("../../tests/fixtures/wasm/decoded.wat", import.meta.url), @@ -147,6 +169,7 @@ it("extracts escaped WAT import/export forms while retaining multiline producer imports: ['(import "env" "foo" (func (;0;) (type 0)))'], exports: ['(export "memory" (memory 0))', '(export "run" (func 1))'], customSectionNames: ["test"], + debugModuleNames: [], }); expect( parseWabtWat( @@ -156,6 +179,7 @@ it("extracts escaped WAT import/export forms while retaining multiline producer imports: ['(import "a.b" "c\\0aline" (func))'], exports: ['(export "fake\\22Export[1]:" (func 0))'], customSectionNames: [], + debugModuleNames: [], }); expect( parseWabtWat( diff --git a/src/wasm/WabtOutput.ts b/src/wasm/WabtOutput.ts index a434b7d17..e13e94f31 100644 --- a/src/wasm/WabtOutput.ts +++ b/src/wasm/WabtOutput.ts @@ -3,12 +3,21 @@ export const parseWabtOutput = ( text: string, artifactBytes: number, customSectionNames: readonly string[], + debugModuleNames: readonly string[] = [], ) => { - const prefix = - /^\nmodule\.wasm:\tfile format wasm 0x1\n\nSections:\n\n/u.exec(text); - if (prefix === null) + // WABT prints module names with printf's %.*s: embedded NUL ends the display. + const preamble = + "\nmodule.wasm:\tfile format wasm 0x1\n" + + debugModuleNames + .map((name) => { + const displayedName = name.split("\0", 1)[0]; + return `module name: <${displayedName}>\n`; + }) + .join(""); + const prefix = `${preamble}\nSections:\n\n`; + if (!text.startsWith(prefix)) throw new Error("Unsupported WABT section header format."); - let offset = prefix[0].length; + let offset = prefix.length; let customIndex = 0; const sections = []; while (!text.startsWith("\nSection Details:\n", offset)) { @@ -61,6 +70,6 @@ export const parseWabtOutput = ( return { sections, headers: text.slice(0, separator), - details: `module.wasm:\tfile format wasm 0x1\n\n${text.slice(separator)}`, + details: `${preamble.slice(1)}\n${text.slice(separator)}`, }; }; diff --git a/src/wasm/WabtWat.ts b/src/wasm/WabtWat.ts index a653d8e58..61a7239a4 100644 --- a/src/wasm/WabtWat.ts +++ b/src/wasm/WabtWat.ts @@ -1,8 +1,5 @@ -/** Decode only a producer custom-section name to frame objdump's raw display text. */ -const customSectionName = (form: string): string => { - const prefix = /^\(@custom\s+"/u.exec(form); - if (prefix === null) throw new Error("Unsupported WABT custom-section name."); - const start = prefix[0].length; +/** 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++; @@ -45,18 +42,53 @@ const customSectionName = (form: string): string => { index += 2; } } - return new TextDecoder("utf-8", { fatal: true }).decode( - bytes.subarray(0, length), - ); + return { bytes: bytes.subarray(0, length), end: end + 1 }; }; -/** Extract exact top-level WABT WAT forms; no binary decoding or import/export name unescaping. */ +/** Read only module-name metadata from WABT's preserved name-section payload. */ +const moduleNames = (bytes: Uint8Array): string[] => { + const names: string[] = []; + let offset = 0; + const uint32 = (end: number): number => { + let value = 0; + for (let shift = 0; shift <= 28 && offset < end; shift += 7) { + const byte = bytes[offset++]; + if (byte === undefined) break; + if (shift === 28 && byte > 15) break; + value += (byte & 127) * 2 ** shift; + if (byte < 128) return value; + } + 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), + ), + ); + } + offset = end; + } + return names; +}; + +/** Extract exact top-level WABT WAT forms; no raw WASM decoding or import/export name unescaping. */ export const parseWabtWat = (text: string) => { if (!/^\(module(?:\s|\))/u.test(text)) throw new Error("Unsupported WABT WAT module prefix."); const imports: string[] = []; const exports: string[] = []; const customSectionNames: string[] = []; + const debugModuleNames: string[] = []; let depth = 0; let start = 0; let quoted = false; @@ -110,8 +142,26 @@ export const parseWabtWat = (text: string) => { const form = text.slice(start, index + 1); 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)) - customSectionNames.push(customSectionName(form)); + else if (/^\(@custom\s/u.test(form)) { + const prefix = /^\(@custom\s+"/u.exec(form); + if (prefix === null) + throw new Error("Unsupported WABT custom-section name."); + const nameString = watString(form, prefix[0].length); + const name = new TextDecoder("utf-8", { fatal: true }).decode( + nameString.bytes, + ); + customSectionNames.push(name); + if (name === "name") { + const payloadPrefix = /^\s+"/u.exec(form.slice(nameString.end)); + if (payloadPrefix === null) + throw new Error("Unsupported WABT name-section payload."); + const payload = watString( + form, + nameString.end + payloadPrefix[0].length, + ); + debugModuleNames.push(...moduleNames(payload.bytes)); + } + } } if (--depth < 0) throw new Error("Unbalanced WABT WAT output."); if (depth === 0) closed = true; @@ -120,5 +170,5 @@ export const parseWabtWat = (text: string) => { } if (depth !== 0 || quoted || commentDepth !== 0) throw new Error("Incomplete WABT WAT output."); - return { imports, exports, customSectionNames }; + return { imports, exports, customSectionNames, debugModuleNames }; }; diff --git a/tests/boundary/providers/native/nativeLoadKinds.golden.test.ts b/tests/boundary/providers/native/nativeLoadKinds.golden.test.ts index 057083c84..99adc61f1 100644 --- a/tests/boundary/providers/native/nativeLoadKinds.golden.test.ts +++ b/tests/boundary/providers/native/nativeLoadKinds.golden.test.ts @@ -32,6 +32,69 @@ it("retains lazily loaded library dependencies", () => { }); }); +// Captured from the real Apple bundle CI fixture's `otool -h -l` output. +const delayedDylib = withOtoolMachHeader(`Load command 0 + cmd LC_LOAD_DYLIB + cmdsize 56 + name @rpath/libdelay.dylib (offset 28) + options delay-init + current version 0.0.0 +compatibility version 1.0.0 +`); + +it("preserves dylib-use options through native Mach-O inspection", async () => { + const runner = new NativeFixtureRunner({ "otool:-h": delayedDylib }); + const client = new NativeMacOSProvider({}, runner, "darwin").createClient( + nativeMachoTarget("/owned/fixture"), + ); + const result = await client.execute("inspect_macho", {}); + if (!result.ok) throw result.error; + expect(result.value.result).toMatchObject({ + load_commands: { + items: [{ kind: "LC_LOAD_DYLIB", fields: { options: "delay-init" } }], + }, + dependencies: { + items: [ + { + path: "@rpath/libdelay.dylib", + kind: "LC_LOAD_DYLIB", + current_version: "0.0.0", + compatibility_version: "1.0.0", + }, + ], + }, + }); +}); + +it("retains empty dylib-use options and rejects missing or ambiguous encoding fields", () => { + expect( + parseOtoolLoadCommands(delayedDylib.replace("delay-init", "")).commands[0] + ?.fields.options, + ).toBe(""); + const malformed: Array = [ + ["", /timestamp or options/u], + [ + " options delay-init\n options weak\n", + /options is missing or repeated/u, + ], + [ + " options delay-init\n time stamp 2 Thu Jan 1 00:00:02 1970\n", + /timestamp or options/u, + ], + [ + " options delay-init\n injected text\n", + /ambiguous multiline text/u, + ], + ]; + for (const [replacement, reason] of malformed) { + expect(() => + parseOtoolLoadCommands( + delayedDylib.replace(" options delay-init\n", replacement), + ), + ).toThrow(reason); + } +}); + it("preserves dependency install names that begin with the word stamp", () => { const load = parseOtoolLoadCommands( withOtoolMachHeader( diff --git a/tests/fixtures/native-macos/otool-high-address.md b/tests/fixtures/native-macos/otool-high-address.md index db72f1444..34aed47e4 100644 --- a/tests/fixtures/native-macos/otool-high-address.md +++ b/tests/fixtures/native-macos/otool-high-address.md @@ -23,5 +23,5 @@ section = struct.pack( ) Path('/tmp/rea-high-address.macho').write_bytes(header + segment + section + b'\xc0') PY -xcrun otool -l /tmp/rea-high-address.macho +xcrun otool -h -l /tmp/rea-high-address.macho ``` diff --git a/tests/fixtures/native-macos/otool-high-address.txt b/tests/fixtures/native-macos/otool-high-address.txt index 1ab9970ac..52ef05c1f 100644 --- a/tests/fixtures/native-macos/otool-high-address.txt +++ b/tests/fixtures/native-macos/otool-high-address.txt @@ -1,4 +1,7 @@ fixture: +Mach header + magic cputype cpusubtype caps filetype ncmds sizeofcmds flags + 0xfeedfacf 16777228 0 0x00 5 1 152 0x00000000 Load command 0 cmd LC_SEGMENT_64 cmdsize 152 diff --git a/tests/fixtures/wasm/module-names.wat b/tests/fixtures/wasm/module-names.wat new file mode 100644 index 000000000..7d8225a89 --- /dev/null +++ b/tests/fixtures/wasm/module-names.wat @@ -0,0 +1,4 @@ +(module + (@custom "name" "\00\01\00") + (@custom "name" "\00\ad\01\ab\01unicode-\c3\a9 >\0a\0aSections:\0a\0aa-long-package-path/a-long-package-path/a-long-package-path/a-long-package-path/a-long-package-path/a-long-package-path/a-long-package-path/inside") + (@custom "name" "\00\0f\0evisible\00hidden"))