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:
parent
091cb81fc3
commit
adde8a8092
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in New Issue