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>
This commit is contained in:
Arthas 2026-10-12 04:44:15 +07:00 committed by GitHub
parent 1d4c4eb030
commit b777e5d4d8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 262 additions and 56 deletions

View File

@ -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

View File

@ -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.

View File

@ -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: <named>\n"));
assert.deepEqual(report.exports, ['(export "answer" (func 0))']);
}
if (index === 4) {
assert.match(
headers,

View File

@ -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,

View File

@ -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<Record<string, string | number | null>>,
): 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<string, string | number | null> => {
const fields: Record<string, string | number | null> = {};
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)

View File

@ -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.",

View File

@ -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(

View File

@ -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)}`,
};
};

View File

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

View File

@ -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<readonly [string, RegExp]> = [
["", /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(

View File

@ -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
```

View File

@ -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

4
tests/fixtures/wasm/module-names.wat vendored Normal file
View File

@ -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"))