fix(native): reject ambiguous otool load-command framing

Validate Mach command counts, byte totals, scalar uniqueness, section counts, and build-tool counts before claiming exhaustive evidence. Preserve raw output in typed parsing failures and distinguish dylib commands from the dynamic loader. Correct inconsistent handcrafted fixtures and label their provenance.

Validation: 38 native tests, combined checks, and temporary-directory hygiene. Multiline dylib-name ambiguity was reproduced with the host Apple otool; regression workflows use captured output and injected command runners.
This commit is contained in:
morluto
2026-10-12 05:24:29 +08:00
parent a8d4984404
commit 108b5f9425
10 changed files with 414 additions and 79 deletions
+32 -2
View File
@@ -108,7 +108,37 @@ export const inspectNativeMacho = async (
else if (optional.error._tag === "AnalysisCapabilityUnavailableError")
limitations.push(VTOOL_UNAVAILABLE_LIMITATION);
else return err(optional.error);
const normalized = normalizeMacho(captures, context.invocation, limitations);
const otool = captures.find(({ tool }) => tool === "otool");
if (otool === undefined) throw new TypeError("Missing otool capture");
let load: ReturnType<typeof parseOtoolLoadCommands>;
try {
load = parseOtoolLoadCommands(sliceOutput(otool));
} catch (cause: unknown) {
return err(
new AnalysisOutputError(
"inspect_macho",
"Malformed otool load-command output",
{
cause,
capturedOutput: {
stdout: otool.stdout,
stderr: otool.stderr,
truncated: false,
stdout_bytes: otool.stdoutBytes,
stderr_bytes: otool.stderrBytes,
exit_code: otool.exitCode,
signal: otool.signal,
},
},
),
);
}
const normalized = normalizeMacho(
captures,
context.invocation,
limitations,
load,
);
const segmentOffset = segmentContainerOffset(
normalized,
selectedArchitecture,
@@ -135,10 +165,10 @@ const normalizeMacho = (
captures: readonly NativeCommandCapture[],
toInvocation: (capture: NativeCommandCapture) => NativeCommandInvocation,
additionalLimitations: readonly string[],
load: ReturnType<typeof parseOtoolLoadCommands>,
) => {
const byTool = captureLookup(captures);
const architectures = parseLipoArchitectures(byTool("lipo").stdout);
const load = parseOtoolLoadCommands(sliceOutput(byTool("otool")));
const imports = parseDyldSymbols(
sliceOutput(byTool("dyld_info", 0)),
"imports",
+162 -15
View File
@@ -43,6 +43,15 @@ interface ParsedLoadCommands {
readonly flags: Set<string>;
}
const DYLIB_COMMANDS = new Set([
"LC_ID_DYLIB",
"LC_LOAD_DYLIB",
"LC_LOAD_WEAK_DYLIB",
"LC_REEXPORT_DYLIB",
"LC_LAZY_LOAD_DYLIB",
"LC_LOAD_UPWARD_DYLIB",
]);
/**
* 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
@@ -57,11 +66,14 @@ const normalizeLineEndings = (output: string): string =>
export const parseOtoolLoadCommands = (raw: string) => {
const output = normalizeLineEndings(raw);
const header = parseHeader(output);
const state = createLoadCommandState(header?.flags);
if (header === undefined)
throw new TypeError("otool output is missing the Mach header");
const state = createLoadCommandState(header.flags);
for (const block of output.split(/(?=^Load command \d+$)/mu))
parseLoadCommand(block, state);
validateLoadCommandFraming(header, state.commands);
return {
fileType: header?.fileType ?? null,
fileType: header.fileType,
flags: [...state.flags].sort(),
uuid: state.uuid,
commands: state.commands,
@@ -74,7 +86,14 @@ export const parseOtoolLoadCommands = (raw: string) => {
const parseHeader = (
output: string,
): { fileType: string | null; flags: string[] } | undefined => {
):
| {
fileType: string | null;
flags: string[];
commandCount: number;
commandBytes: number;
}
| undefined => {
const lines = output.split(/\r?\n/u).map((line) => line.trim());
const columnsLine = lines.findIndex((line) => /(?:^|\s)magic\s/u.test(line));
if (columnsLine < 0) return undefined;
@@ -82,6 +101,10 @@ const parseHeader = (
const dataLine = lines.slice(columnsLine + 1).find((line) => line.length > 0);
const values = dataLine?.split(/\s+/u);
if (columns === undefined || values === undefined) return undefined;
const commandCount = headerValue(columns, values, "ncmds");
const commandBytes = headerValue(columns, values, "sizeofcmds");
if (commandCount === null || commandBytes === null)
throw new TypeError("otool Mach header is missing valid command framing");
const fileTypeIndex = columns.indexOf("filetype");
const flagsIndex = columns.indexOf("flags");
const complete = values.length >= columns.length;
@@ -92,6 +115,8 @@ const parseHeader = (
const flagsEnd =
flagsIndex >= 0 ? values.length - (columns.length - flagsIndex - 1) : 0;
return {
commandCount,
commandBytes,
fileType:
complete && fileTypeValueIndex >= 0 && fileTypeValueIndex < values.length
? (values[fileTypeValueIndex] ?? null)
@@ -105,6 +130,48 @@ const parseHeader = (
};
};
const headerValue = (
columns: readonly string[],
values: readonly string[],
name: string,
): number | null => {
const index = columns.indexOf(name);
if (index < 0) return null;
const flagsIndex = columns.indexOf("flags");
const valueIndex =
flagsIndex >= 0 && index > flagsIndex
? values.length - (columns.length - index)
: index;
const value = values[valueIndex];
return value === undefined ? null : numeric(value);
};
const validateLoadCommandFraming = (
header: { readonly commandCount: number; readonly commandBytes: number },
commands: readonly {
readonly index: number;
readonly fields: Readonly<Record<string, string | number | null>>;
}[],
): void => {
if (commands.length !== header.commandCount)
throw new TypeError(
"otool load-command count does not match the Mach header",
);
let totalBytes = 0;
for (const [expectedIndex, command] of commands.entries()) {
const size = numberField(command.fields, "cmdsize");
if (command.index !== expectedIndex || size === null || size < 8)
throw new TypeError("otool load-command sequence or size is invalid");
totalBytes += size;
if (!Number.isSafeInteger(totalBytes))
throw new TypeError("otool load-command byte count is unsafe");
}
if (totalBytes !== header.commandBytes)
throw new TypeError(
"otool load-command sizes do not match the Mach header",
);
};
const createLoadCommandState = (
headerFlags: readonly string[] | undefined,
): ParsedLoadCommands => ({
@@ -120,11 +187,17 @@ const createLoadCommandState = (
const parseLoadCommand = (block: string, state: ParsedLoadCommands): void => {
const indexText = /^Load command (\d+)/u.exec(block)?.[1];
if (indexText === undefined) return;
const index = Number(indexText);
if (!Number.isSafeInteger(index))
throw new TypeError("otool load-command index is unsafe");
const header = block.split(/\n\s*Section\n/u)[0] ?? block;
const fields = parseFields(header);
const kind = stringField(fields, "cmd") ?? "unknown";
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);
state.commands.push({
index: Number.parseInt(indexText, 10),
index,
kind,
fields,
});
@@ -135,6 +208,59 @@ 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",
]);
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];
if (key === undefined || !allowed.has(key))
throw new TypeError(
"otool dylib command contains ambiguous multiline text",
);
}
};
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 (kind === "LC_LOAD_DYLINKER" || kind === "LC_ID_DYLINKER")
return [...common, "name"];
if (kind === "LC_UUID") return [...common, "uuid"];
if (kind === "LC_MAIN") return [...common, "entryoff"];
if (kind === "LC_BUILD_VERSION")
return [...common, "platform", "minos", "sdk", "ntools"];
if (kind.startsWith("LC_VERSION_MIN_")) return [...common, "version", "sdk"];
if (kind === "LC_SEGMENT" || kind === "LC_SEGMENT_64")
return [
...common,
"segname",
"vmaddr",
"vmsize",
"fileoff",
"filesize",
"maxprot",
"initprot",
"nsects",
"flags",
];
return common;
};
const collectUuid = (
kind: string,
fields: Readonly<Record<string, string | number | null>>,
@@ -161,11 +287,14 @@ const collectBuild = (
state: ParsedLoadCommands,
): void => {
if (kind === "LC_BUILD_VERSION") {
const tools = parseBuildTools(block);
if (numberField(fields, "ntools") !== tools.length)
throw new TypeError("otool build-tool count does not match ntools");
state.builds.push({
platform: stringField(fields, "platform"),
minimum_os: stringField(fields, "minos"),
sdk: stringField(fields, "sdk"),
tools: parseBuildTools(block),
tools,
});
return;
}
@@ -183,13 +312,7 @@ const collectDependency = (
fields: Readonly<Record<string, string | number | null>>,
state: ParsedLoadCommands,
): void => {
if (
!kind.startsWith("LC_LOAD_") &&
kind !== "LC_ID_DYLIB" &&
kind !== "LC_REEXPORT_DYLIB" &&
kind !== "LC_LAZY_LOAD_DYLIB"
)
return;
if (!DYLIB_COMMANDS.has(kind)) return;
state.dependencies.push({
path: stripOffsetSuffix(stringField(fields, "name")),
kind,
@@ -204,8 +327,13 @@ const collectSegment = (
fields: Readonly<Record<string, string | number | null>>,
state: ParsedLoadCommands,
): void => {
if (kind === "LC_SEGMENT" || kind === "LC_SEGMENT_64")
state.segments.push(parseSegment(block, fields));
if (kind !== "LC_SEGMENT" && kind !== "LC_SEGMENT_64") return;
const segment = parseSegment(block, fields);
if (numberField(fields, "nsects") !== segment.sections.length)
throw new TypeError(
"otool segment section count does not match its sections",
);
state.segments.push(segment);
};
const parseFields = (block: string): Record<string, string | number | null> => {
@@ -227,6 +355,15 @@ const parseFields = (block: string): Record<string, string | number | null> => {
return fields;
};
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)
throw new TypeError(`otool field ${key} is missing or repeated`);
};
const parseSegment = (
block: string,
fields: Readonly<Record<string, string | number | null>>,
@@ -247,6 +384,16 @@ const parseSections = (block: string): ParsedSection[] =>
.slice(1)
.map((sectionBlock) => {
const fields = parseFields(`Section${sectionBlock}`);
for (const key of [
"sectname",
"segname",
"addr",
"size",
"offset",
"align",
"flags",
])
requireUniqueField(sectionBlock, key);
return {
segment: stringField(fields, "segname") ?? "unknown",
name: stringField(fields, "sectname") ?? "unknown",
@@ -21,6 +21,7 @@ import {
nativeFixture as fixture,
nativeMachoTarget as machoTarget,
nativeMachoTargetForFile,
withOtoolMachHeader,
} from "../../../fixtures/nativeCommands.js";
import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js";
@@ -143,6 +144,7 @@ it("retains native inventory facts from captured Apple tool output", async () =>
const outputs: Record<string, string> = {};
for (const tool of ["file", "lipo", "otool", "nm", "dwarfdump", "vtool"])
outputs[tool] = await fixture(`dyld-inventory/${tool}.txt`);
outputs.otool = withOtoolMachHeader(outputs.otool ?? "");
outputs["dyld_info:-imports"] = await fixture("dyld-inventory/imports.txt");
const exportsOutput = await fixture("dyld-inventory/exports.txt");
outputs["dyld_info:-exports"] = exportsOutput;
@@ -275,7 +277,8 @@ it("retains symbol names containing spaces in dyld inventory rows", () => {
});
it("preserves numeric-looking segment and section identifiers from otool", () => {
const parsed = parseOtoolLoadCommands(`Load command 1
const parsed = parseOtoolLoadCommands(
withOtoolMachHeader(`Load command 0
cmd LC_SEGMENT_64
cmdsize 152
segname 0001
@@ -299,7 +302,8 @@ Section
flags 0x00000000
reserved1 0
reserved2 0
`);
`),
);
expect(parsed.segments[0]).toMatchObject({
name: "0001",
file_offset: 16384,
@@ -367,7 +371,8 @@ it.each([
])(
"keeps load-command-looking text inside native install names: %s",
(installName) => {
const parsed = parseOtoolLoadCommands(`Load command 0
const parsed = parseOtoolLoadCommands(
withOtoolMachHeader(`Load command 0
cmd LC_ID_DYLIB
cmdsize 80
name ${installName} (offset 24)
@@ -378,7 +383,8 @@ Load command 1
cmd LC_UUID
cmdsize 24
uuid 01234567-89AB-CDEF-0123-456789ABCDEF
`);
`),
);
expect(parsed.commands.map(({ index, kind }) => ({ index, kind }))).toEqual(
[
{ index: 0, kind: "LC_ID_DYLIB" },
@@ -4,20 +4,25 @@ import { NativeMacOSProvider } from "../../../../src/native/NativeMacOSProvider.
import { parseOtoolLoadCommands } from "../../../../src/native/parsers/otool.js";
import {
NativeFixtureRunner,
nativeFixture,
nativeMachoTarget,
withOtoolMachHeader,
} from "../../../fixtures/nativeCommands.js";
it("retains lazily loaded library dependencies", () => {
const load = parseOtoolLoadCommands(
[
"Load command 9",
" cmd LC_LAZY_LOAD_DYLIB",
" name /usr/lib/libLazy.dylib (offset 24)",
" time stamp 2 Thu Jan 1 00:00:02 1970",
" current version 1.0.0",
"compatibility version 1.0.0",
"",
].join("\n"),
withOtoolMachHeader(
[
"Load command 0",
" cmd LC_LAZY_LOAD_DYLIB",
" cmdsize 48",
" name /usr/lib/libLazy.dylib (offset 24)",
" time stamp 2 Thu Jan 1 00:00:02 1970",
" current version 1.0.0",
"compatibility version 1.0.0",
"",
].join("\n"),
),
);
expect(load.dependencies).toContainEqual({
path: "/usr/lib/libLazy.dylib",
@@ -29,15 +34,18 @@ it("retains lazily loaded library dependencies", () => {
it("preserves dependency install names that begin with the word stamp", () => {
const load = parseOtoolLoadCommands(
[
"Load command 2",
" cmd LC_LOAD_DYLIB",
" name stamp plugin.dylib (offset 24)",
" time stamp 2 Thu Jan 1 00:00:02 1970",
" current version 1.0.0",
"compatibility version 1.0.0",
"",
].join("\n"),
withOtoolMachHeader(
[
"Load command 0",
" cmd LC_LOAD_DYLIB",
" cmdsize 48",
" name stamp plugin.dylib (offset 24)",
" time stamp 2 Thu Jan 1 00:00:02 1970",
" current version 1.0.0",
"compatibility version 1.0.0",
"",
].join("\n"),
),
);
expect(load.dependencies).toContainEqual({
path: "stamp plugin.dylib",
@@ -54,20 +62,25 @@ it("preserves dependency install names that begin with the word stamp", () => {
it("normalizes legacy version-minimum commands into build metadata", () => {
const load = parseOtoolLoadCommands(
[
"Load command 3",
" cmd LC_VERSION_MIN_MACOSX",
" version 10.13",
" sdk 10.15",
"Load command 4",
" cmd LC_VERSION_MIN_IPHONEOS",
" version 12.0",
" sdk 12.1",
"Load command 5",
" cmd LC_SOURCE_VERSION",
" version 1.2.3",
"",
].join("\n"),
withOtoolMachHeader(
[
"Load command 0",
" cmd LC_VERSION_MIN_MACOSX",
" cmdsize 16",
" version 10.13",
" sdk 10.15",
"Load command 1",
" cmd LC_VERSION_MIN_IPHONEOS",
" cmdsize 16",
" version 12.0",
" sdk 12.1",
"Load command 2",
" cmd LC_SOURCE_VERSION",
" cmdsize 16",
" version 1.2.3",
"",
].join("\n"),
),
);
expect(load.builds).toEqual([
{
@@ -85,15 +98,30 @@ it("normalizes legacy version-minimum commands into build metadata", () => {
]);
});
it("does not classify LC_LOAD_DYLINKER as a dylib dependency", async () => {
const load = parseOtoolLoadCommands(
await nativeFixture("native-macho-header/otool-header-load.txt"),
);
expect(load.commands.some(({ kind }) => kind === "LC_LOAD_DYLINKER")).toBe(
true,
);
expect(load.dependencies.some(({ path }) => path === "/usr/lib/dyld")).toBe(
false,
);
});
it("limits thread-state entrypoints instead of reporting none silently", async () => {
const runner = new NativeFixtureRunner({
"otool:-h": [
"Load command 0",
" cmd LC_UNIXTHREAD",
" flavor 1",
" count 1",
"",
].join("\n"),
"otool:-h": withOtoolMachHeader(
[
"Load command 0",
" cmd LC_UNIXTHREAD",
" cmdsize 16",
" flavor 1",
" count 1",
"",
].join("\n"),
),
});
const client = new NativeMacOSProvider({}, runner, "darwin").createClient(
nativeMachoTarget("/owned/legacy-fixture"),
@@ -1,6 +1,9 @@
import { expect, it } from "vitest";
import { parseOtoolLoadCommands } from "../../../../src/native/parsers/otool.js";
import { nativeFixture } from "../../../fixtures/nativeCommands.js";
import {
nativeFixture,
withOtoolMachHeader,
} from "../../../fixtures/nativeCommands.js";
it.each([
["decimal-space", "123 library.dylib"],
["hex-space", "0x123 library.dylib"],
@@ -9,7 +12,9 @@ it.each([
["ordinary", "ordinary.dylib"],
])("preserves native install-name text from %s capture", async (slug, name) => {
const load = parseOtoolLoadCommands(
await nativeFixture(`load-command-text-values/${slug}.txt`),
withOtoolMachHeader(
await nativeFixture(`load-command-text-values/${slug}.txt`),
),
);
expect(load.dependencies).toContainEqual({
path: name,
@@ -6,6 +6,7 @@ import {
NativeFixtureRunner,
nativeFixture,
nativeMachoTarget,
withOtoolMachHeader,
} from "../../../fixtures/nativeCommands.js";
it("collects the actual Mach header alongside the native load commands", async () => {
@@ -32,6 +33,36 @@ it("collects the actual Mach header alongside the native load commands", async (
});
});
it("rejects line breaks in a dylib name that manufacture a command and retains otool output", async () => {
const output = [
"/owned/fixture:",
"Mach header",
" magic cputype cpusubtype caps filetype ncmds sizeofcmds flags",
" 0xfeedfacf 16777228 0 0x00 2 1 120 0x00000000",
"Load command 0",
" cmd LC_LOAD_DYLIB",
" cmdsize 120",
" name @rpath/a",
"Load command 77",
" cmd LC_REEXPORT_DYLIB",
" name /evil.dylib (offset 24)",
" time stamp 2 Thu Jan 1 00:00:02 1970",
" current version 1.0.0",
"compatibility version 1.0.0",
"",
].join("\n");
const runner = new NativeFixtureRunner({ "otool:-h": output });
const client = new NativeMacOSProvider({}, runner, "darwin").createClient(
nativeMachoTarget("/owned/fixture"),
);
const result = await client.execute("inspect_macho", {});
if (result.ok) throw new Error("expected malformed otool framing to fail");
expect(result.error).toMatchObject({
_tag: "AnalysisOutputError",
capturedOutput: { stdout: output, stderr: "", truncated: false },
});
});
it("does not reinterpret segment and section flags as Mach header flags", async () => {
const load = parseOtoolLoadCommands(
await nativeFixture("native-macho-header/otool-header-load.txt"),
@@ -56,11 +87,8 @@ it("decodes Mach header fields by their column names", () => {
const reordered = parseOtoolLoadCommands(
[
"Mach header",
" flags filetype magic cputype",
" NOUNDEFS 2 0xfeedfacf 16777228",
"Load command 0",
" cmd LC_UUID",
" uuid 01234567-89ab-cdef-0123-456789abcdef",
" flags filetype magic cputype ncmds sizeofcmds",
" NOUNDEFS 2 0xfeedfacf 16777228 0 0",
].join("\n"),
);
expect(reordered.fileType).toBe("2");
@@ -69,19 +97,83 @@ it("decodes Mach header fields by their column names", () => {
[
"Mach header",
" filetype magic flags ncmds sizeofcmds",
" 2 0xfeedfacf NOUNDEFS DYLDLINK 4 512",
" 2 0xfeedfacf NOUNDEFS DYLDLINK 0 0",
].join("\n"),
);
expect(reorderedMultiwordFlags.fileType).toBe("2");
expect(reorderedMultiwordFlags.flags).toEqual(["DYLDLINK", "NOUNDEFS"]);
const missing = parseOtoolLoadCommands(
"Mach header\n magic cputype flags\n 0xfeedfacf 1 NOUNDEFS\n",
expect(() =>
parseOtoolLoadCommands(
"Mach header\n magic cputype flags\n 0xfeedfacf 1 NOUNDEFS\n",
),
).toThrow(/command framing/u);
expect(() =>
parseOtoolLoadCommands(
"Mach header\n filetype magic flags ncmds sizeofcmds\n 2 0xfeedfacf NOUNDEFS\n",
),
).toThrow(/command framing/u);
});
it("rejects duplicate semantic fields, injected sections, and truncated framing", () => {
const duplicateUuid = withOtoolMachHeader(`Load command 0
cmd LC_UUID
cmdsize 24
uuid 01234567-89ab-cdef-0123-456789abcdef
uuid fedcba98-7654-3210-fedc-ba9876543210
`);
expect(() => parseOtoolLoadCommands(duplicateUuid)).toThrow(/uuid/u);
const injectedSection = withOtoolMachHeader(`Load command 0
cmd LC_SEGMENT_64
cmdsize 152
segname __TEXT
Section
sectname __fake
segname __TEXT
addr 0x1000
size 0x10
offset 4096
align 2^0 (1)
flags 0x0
`);
expect(() => parseOtoolLoadCommands(injectedSection)).toThrow(/vmaddr/u);
const incorrectSectionCount = withOtoolMachHeader(`Load command 0
cmd LC_SEGMENT_64
cmdsize 152
segname __TEXT
vmaddr 0x100000000
vmsize 0x2000
fileoff 0
filesize 8192
maxprot 0x5
initprot 0x5
nsects 0
flags 0x0
Section
sectname __text
segname __TEXT
addr 0x100001000
size 0x100
offset 4096
align 4
flags 0x0
`);
expect(() => parseOtoolLoadCommands(incorrectSectionCount)).toThrow(
/section count/u,
);
expect(missing.fileType).toBeNull();
expect(missing.flags).toEqual(["NOUNDEFS"]);
const incomplete = parseOtoolLoadCommands(
"Mach header\n filetype magic flags ncmds sizeofcmds\n 2 0xfeedfacf NOUNDEFS\n",
);
expect(incomplete.fileType).toBeNull();
expect(incomplete.flags).toEqual([]);
const truncatedCount = [
"Mach header",
" magic cputype cpusubtype caps filetype ncmds sizeofcmds flags",
" 0xfeedfacf 16777228 0 0x00 2 2 48 0x00000000",
"Load command 0",
" cmd LC_UUID",
" cmdsize 24",
" uuid 01234567-89ab-cdef-0123-456789abcdef",
].join("\n");
expect(() => parseOtoolLoadCommands(truncatedCount)).toThrow(/count/u);
const truncatedBytes = truncatedCount.replace("2 2 48", "2 1 32");
expect(() => parseOtoolLoadCommands(truncatedBytes)).toThrow(/sizes/u);
});
@@ -5,11 +5,14 @@ import {
NativeFixtureRunner,
nativeFixture,
nativeMachoTarget,
withOtoolMachHeader,
} from "../../../fixtures/nativeCommands.js";
it("retains native re-export dependencies alongside ordinary and identity commands", async () => {
const runner = new NativeFixtureRunner({
otool: await nativeFixture("native-reexport/otool-load.txt"),
otool: withOtoolMachHeader(
await nativeFixture("native-reexport/otool-load.txt"),
),
});
const client = new NativeMacOSProvider({}, runner, "darwin").createClient(
nativeMachoTarget("/owned/libOuter.dylib"),
+7
View File
@@ -0,0 +1,7 @@
# Representative native load-command fixture
`otool-load.txt` is a compact, handcrafted representative used by the native
fixture runner. Its Mach header is made internally consistent with the five
listed commands (296 command bytes); it is not a capture from a real binary.
Use fixtures under `native-macho-header/` or `native-reexport/` for producer
captures.
+1 -1
View File
@@ -1,7 +1,7 @@
fixture:
Mach header
magic cputype cpusubtype caps filetype ncmds sizeofcmds flags
0xfeedfacf 16777228 0 0x00 EXECUTE 4 512 NOUNDEFS DYLDLINK
0xfeedfacf 16777228 0 0x00 EXECUTE 5 296 NOUNDEFS DYLDLINK
Load command 0
cmd LC_SEGMENT_64
cmdsize 152
+17
View File
@@ -124,3 +124,20 @@ export const nativeMachoTargetForFile = async (
/** Load one captured native tool output. */
export const nativeFixture = (name: string): Promise<string> =>
readFile(new URL(`./native-macos/${name}`, import.meta.url), "utf8");
/** Add consistent Mach command-count metadata to a command-only test capture. */
export const withOtoolMachHeader = (output: string): string => {
const count = [...output.matchAll(/^Load command (\d+)$/gmu)].length;
const sizes = [...output.matchAll(/^\s*cmdsize\s+(\d+)\s*$/gmu)].map(
([, size]) => Number(size),
);
if (sizes.length !== count)
throw new TypeError("command-only fixture must provide every cmdsize");
const bytes = sizes.reduce((sum, size) => sum + size, 0);
return [
"Mach header",
" magic cputype cpusubtype caps filetype ncmds sizeofcmds flags",
` 0xfeedfacf 16777228 0 0x00 2 ${count} ${bytes} 0x00000000`,
output,
].join("\n");
};