mirror of
https://github.com/morluto/rea.git
synced 2026-10-11 21:39:55 +00:00
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:
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Vendored
+17
@@ -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");
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user