From adde8a80921b199ddd7105998f3760d345b468fc Mon Sep 17 00:00:00 2001 From: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Date: Sun, 11 Oct 2026 17:55:50 -0700 Subject: [PATCH] fix(native): reject required otool fields truncated before their value (#1995) * fix(native): reject required otool fields truncated before their value #1967 let requireUniqueField count a label at end of line so an empty dylib-use options row is present. That applied to every required field, so truncated output such as a bare vmaddr, vmsize, uuid or entryoff line passed the presence check while parseField dropped it, and the value silently became null. Let parseField decide which labeled line carries a value: the single matching line must parse as that field. The empty options row remains the only valueless field parseField admits. Co-Authored-By: Claude Opus 5.5 * fix(native): admit the empty segname of Mach-O object files MH_OBJECT files put their sections in one segment whose name is empty, and otool -l prints it as a bare segname label. Requiring a value for every scalar field rejected valid object-file output as unreadable. Let parseField admit an empty segname like the empty dylib-use options row, and keep projecting an empty or absent segment name as "unknown", as main did. Co-Authored-By: Claude Opus 5.5 * fix(native): admit all-NUL Mach-O section names An all-NUL section_64.sectname prints as a bare sectname label, like an empty segname. Admit it in parseField and keep projecting the empty name as "unknown", as main did. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- src/native/parsers/otool.ts | 24 +++++-- .../native/nativeLoadKinds.golden.test.ts | 68 +++++++++++++++++++ 2 files changed, 85 insertions(+), 7 deletions(-) diff --git a/src/native/parsers/otool.ts b/src/native/parsers/otool.ts index 85c54785a..3eb2ae81b 100644 --- a/src/native/parsers/otool.ts +++ b/src/native/parsers/otool.ts @@ -360,7 +360,10 @@ const parseFields = (block: string): Record => { 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", ""]; + // Segment and section names are fixed char[16] fields that may be all NUL; + // MH_OBJECT files put their sections in a segment with an empty segname. + if (line === "options" || line === "segname" || line === "sectname") + return [line, ""]; // 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 @@ -371,17 +374,20 @@ const parseField = (rawLine: string): readonly [string, string] | undefined => { const requireUniqueField = (block: string, key: string): void => { const escaped = key.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); 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) + const lines = block.split("\n").filter((line) => pattern.test(line)); + if (lines.length !== 1) throw new TypeError(`otool field ${key} is missing or repeated`); + // parseField owns which labeled lines carry a value; a bare required label + // is truncated output, except the empty dylib-use options row it admits. + if (parseField(lines[0] ?? "")?.[0] !== key) + throw new TypeError(`otool field ${key} has no value`); }; const parseSegment = ( block: string, fields: Readonly>, ): ParsedSegment => ({ - name: stringField(fields, "segname") ?? "unknown", + name: nonEmptyName(stringField(fields, "segname")), vm_address: hexField(fields, "vmaddr"), vm_size: numberField(fields, "vmsize"), file_offset: numberField(fields, "fileoff"), @@ -408,8 +414,8 @@ const parseSections = (block: string): ParsedSection[] => ]) requireUniqueField(sectionBlock, key); return { - segment: stringField(fields, "segname") ?? "unknown", - name: stringField(fields, "sectname") ?? "unknown", + segment: nonEmptyName(stringField(fields, "segname")), + name: nonEmptyName(stringField(fields, "sectname")), address: hexField(fields, "addr"), size: numberField(fields, "size"), file_offset: numberField(fields, "offset"), @@ -464,6 +470,10 @@ const numeric = (value: string): number | null => { return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : null; }; +/** Project an absent or empty Mach-O name as before: the "unknown" label. */ +const nonEmptyName = (name: string | null): string => + name === null || name.length === 0 ? "unknown" : name; + const stringField = ( fields: Readonly>, name: string, diff --git a/tests/boundary/providers/native/nativeLoadKinds.golden.test.ts b/tests/boundary/providers/native/nativeLoadKinds.golden.test.ts index 99adc61f1..87912a31d 100644 --- a/tests/boundary/providers/native/nativeLoadKinds.golden.test.ts +++ b/tests/boundary/providers/native/nativeLoadKinds.golden.test.ts @@ -95,6 +95,74 @@ it("retains empty dylib-use options and rejects missing or ambiguous encoding fi } }); +it.each(["vmaddr", "vmsize", "uuid", "entryoff"])( + "rejects a required %s label truncated before its value", + async (key) => { + const raw = await nativeFixture("otool-load.txt"); + const truncated = raw.replace( + new RegExp(`^(\\s*${key})\\s+\\S.*$`, "mu"), + "$1", + ); + expect(truncated).not.toBe(raw); + expect(() => parseOtoolLoadCommands(raw)).not.toThrow(); + // Only the empty dylib-use options row may omit its value. + expect(() => parseOtoolLoadCommands(truncated)).toThrow( + `otool field ${key} has no value`, + ); + }, +); + +it("retains the unnamed segment of a Mach-O object file", () => { + // Captured from `otool -l` on a clang `-c` object: MH_OBJECT sections live + // in one segment whose segname is empty, printed as a bare label. + const objectSegment = [ + "Load command 0", + " cmd LC_SEGMENT_64", + " cmdsize 152", + " segname ", + " vmaddr 0x0000000000000000", + " vmsize 0x0000000000000018", + " fileoff 392", + " filesize 24", + " maxprot 0x00000007", + " initprot 0x00000007", + " nsects 1", + " flags 0x0", + "Section", + " sectname __text", + " segname __TEXT", + " addr 0x0000000000000000", + " size 0x0000000000000018", + " offset 392", + " align 2^2 (4)", + " reloff 0", + " nreloc 0", + " flags 0x80000400", + " reserved1 0", + " reserved2 0", + "", + ].join("\n"); + const load = parseOtoolLoadCommands(withOtoolMachHeader(objectSegment)); + expect(load.segments).toMatchObject([ + { name: "unknown", sections: [{ segment: "__TEXT", name: "__text" }] }, + ]); + // An all-NUL section name is printed the same way and stays "unknown". + expect( + parseOtoolLoadCommands( + withOtoolMachHeader( + objectSegment.replace(" sectname __text", " sectname "), + ), + ).segments, + ).toMatchObject([{ sections: [{ segment: "__TEXT", name: "unknown" }] }]); + expect(() => + parseOtoolLoadCommands( + withOtoolMachHeader( + objectSegment.replace(" vmaddr 0x0000000000000000", " vmaddr"), + ), + ), + ).toThrow("otool field vmaddr has no value"); +}); + it("preserves dependency install names that begin with the word stamp", () => { const load = parseOtoolLoadCommands( withOtoolMachHeader(