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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kaoru0822 2026-10-11 17:55:50 -07:00 committed by GitHub
parent 091cb81fc3
commit adde8a8092
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 85 additions and 7 deletions

View File

@ -360,7 +360,10 @@ const parseFields = (block: string): Record<string, string | number | null> => {
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<Record<string, string | number | null>>,
): 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<Record<string, string | number | null>>,
name: string,

View File

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