mirror of
https://github.com/morluto/rea.git
synced 2026-10-28 10:25:42 +00:00
* fix(native): derive Mach-O word size and byte order from the selected slice inspect_macho read endian and word_size from `file -b` for the whole file. macOS `file` never prints a Mach-O byte order, so endian was null for every target. For a universal binary, word_size matched "64-bit" anywhere in the summary instead of describing the selected slice, and it was null for a thin 32-bit slice. Both now come from the selected slice's otool header: the magic gives the word size and the CPU family gives the byte order (x86 and ARM little, PowerPC big, other families unknown). The file capture is no longer needed by inspect_macho, so it is no longer run there. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(native): select a 32-bit slice beside a 64-bit slice through inspect_macho Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
316 lines
11 KiB
TypeScript
316 lines
11 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
|
|
import {
|
|
CPU,
|
|
LC,
|
|
dylibCommand,
|
|
fatImage,
|
|
machoImage,
|
|
} from "../artifacts/apple/MachoImage.fixture.js";
|
|
import {
|
|
AnalysisCancelledError,
|
|
AnalysisOutputError,
|
|
} from "../domain/analysisErrorCore.js";
|
|
import { err, ok } from "../domain/result.js";
|
|
import { parseMachoLayout, selectMachoSlice } from "./AppleMachoSelection.js";
|
|
import type { NativeCommandCapture } from "./CommandRunner.js";
|
|
import { inspectNativeMacho } from "./NativeMachoInspection.js";
|
|
import {
|
|
NativeFixtureRunner,
|
|
nativeMachoTarget,
|
|
withOtoolMachHeader,
|
|
} from "../../tests/fixtures/nativeCommands.js";
|
|
|
|
const capture = (tool: string, stdout: string): NativeCommandCapture => ({
|
|
tool,
|
|
executable: tool,
|
|
executableSha256: "a".repeat(64),
|
|
toolVersion: null,
|
|
versionReason: null,
|
|
arguments: [],
|
|
stdout,
|
|
stderr: "",
|
|
stdoutBytes: stdout.length,
|
|
stderrBytes: 0,
|
|
exitCode: 0,
|
|
signal: null,
|
|
});
|
|
|
|
const inspect = async (lipo: string, architecture: "x86" | "arm64") => {
|
|
const commands: {
|
|
readonly tool: string;
|
|
readonly args: readonly string[];
|
|
}[] = [];
|
|
const result = await inspectNativeMacho({
|
|
target: {
|
|
kind: "executable",
|
|
format: "mach-o",
|
|
path: "/fixture/app",
|
|
sha256: "b".repeat(64),
|
|
architecture,
|
|
availableArchitectures: ["x86", "arm64"],
|
|
},
|
|
run: (tool, args) => {
|
|
commands.push({ tool, args });
|
|
if (tool === "lipo") return Promise.resolve(ok(capture(tool, lipo)));
|
|
return Promise.resolve(err(new AnalysisCancelledError("inspect_native")));
|
|
},
|
|
invocation: () => {
|
|
throw new Error("invocation is not reached");
|
|
},
|
|
});
|
|
return { result, commands };
|
|
};
|
|
|
|
describe("Mach-O tool architecture selection", () => {
|
|
it("rejects a CPU lipo did not list instead of reading another slice", async () => {
|
|
const { result, commands } = await inspect("architecture arm64\n", "x86");
|
|
expect(result.ok).toBe(false);
|
|
if (result.ok) return;
|
|
expect(result.error).toMatchObject({
|
|
_tag: "AnalysisOutputError",
|
|
reason: "lipo did not list i386; refusing to read another slice",
|
|
});
|
|
expect(commands.map(({ tool }) => tool)).toEqual(["lipo"]);
|
|
});
|
|
|
|
it("passes the arm64e slice lipo listed for an arm64 target", async () => {
|
|
const { commands } = await inspect("architecture arm64e\n", "arm64");
|
|
const otool = commands.find(({ tool }) => tool === "otool");
|
|
expect(otool?.args).toEqual([
|
|
"-h",
|
|
"-l",
|
|
"-arch",
|
|
"arm64e",
|
|
"/fixture/app",
|
|
]);
|
|
});
|
|
|
|
it.each([
|
|
{ names: ["arm64v8", "arm64e"], selected: "arm64e" },
|
|
{ names: ["arm64e", "arm64v8"], selected: "arm64e" },
|
|
{ names: ["arm64v8", "arm64e", "arm64"], selected: "arm64" },
|
|
{ names: ["arm64v8"], selected: "arm64v8" },
|
|
{ names: ["arm64v8", "arm64e.x1"], selected: "arm64v8" },
|
|
{ names: ["arm64e.x1", "arm64v8"], selected: "arm64v8" },
|
|
{ names: ["arm64v8", "arm64e.v1"], selected: "arm64e.v1" },
|
|
{ names: ["arm64e.v1", "arm64v8"], selected: "arm64e.v1" },
|
|
] as const)(
|
|
"keeps inspection and dispatch on $selected for FAT order $names",
|
|
async ({ names, selected }) => {
|
|
const cpus = {
|
|
arm64: CPU.arm64,
|
|
arm64v8: { type: CPU.arm64.type, subtype: 1 },
|
|
arm64e: CPU.arm64e,
|
|
"arm64e.x1": { type: CPU.arm64.type, subtype: 0x8000000c },
|
|
"arm64e.v1": { type: CPU.arm64.type, subtype: 0x81000002 },
|
|
};
|
|
const slices = names.map((name) => {
|
|
const cpu = cpus[name];
|
|
return {
|
|
cpu,
|
|
bytes: machoImage({
|
|
cpu,
|
|
commands: [dylibCommand(LC.LOAD_DYLIB, `/fixture/${name}.dylib`)],
|
|
}),
|
|
};
|
|
});
|
|
const fat = Buffer.from(fatImage(slices));
|
|
const lipo = names
|
|
.map(
|
|
(name, index) =>
|
|
`architecture ${name}\n cputype ${cpus[name].type}\n cpusubtype ${cpus[name].subtype}\n offset ${(index + 1) * 4096}\n size ${slices[index]?.bytes.length}\n align 2^12\n`,
|
|
)
|
|
.join("");
|
|
const { commands } = await inspect(lipo, "arm64");
|
|
expect(commands.find(({ tool }) => tool === "otool")?.args).toEqual([
|
|
"-h",
|
|
"-l",
|
|
"-arch",
|
|
selected.split(".")[0],
|
|
"/fixture/app",
|
|
]);
|
|
expect(commands.find(({ tool }) => tool === "dyld_info")?.args).toContain(
|
|
selected,
|
|
);
|
|
expect(selectMachoSlice(fat, "arm64").slice).toBe(
|
|
(names.findIndex((name) => name === selected) + 1) * 4096,
|
|
);
|
|
expect(parseMachoLayout(fat, "arm64").fixups.dylibs).toEqual([
|
|
`/fixture/${selected}.dylib`,
|
|
]);
|
|
},
|
|
);
|
|
});
|
|
|
|
describe("Mach-O UUID reconciliation", () => {
|
|
const validUuid = "01234567-89AB-CDEF-0123-456789ABCDEF";
|
|
|
|
const runInspection = async (
|
|
dwarfdump: string,
|
|
otoolUuids: readonly string[] = [validUuid],
|
|
targetPath = "/fixture/app",
|
|
) => {
|
|
const runner = new NativeFixtureRunner({
|
|
dwarfdump,
|
|
otool: withOtoolMachHeader(
|
|
otoolUuids
|
|
.map(
|
|
(uuid, index) =>
|
|
`Load command ${index}\n cmd LC_UUID\n cmdsize 24\n uuid ${uuid}`,
|
|
)
|
|
.join("\n"),
|
|
),
|
|
});
|
|
const result = await inspectNativeMacho({
|
|
target: nativeMachoTarget(targetPath),
|
|
run: (tool, args) => runner.run(tool, args),
|
|
invocation: (capture) => ({
|
|
tool: capture.tool,
|
|
command: [capture.executable, ...capture.arguments],
|
|
tool_version: capture.toolVersion,
|
|
version_reason: capture.versionReason,
|
|
executable_sha256: capture.executableSha256,
|
|
exit: { code: capture.exitCode, signal: capture.signal },
|
|
stdout_bytes: capture.stdoutBytes,
|
|
stderr_bytes: capture.stderrBytes,
|
|
}),
|
|
});
|
|
return result;
|
|
};
|
|
|
|
it("keeps a valid otool UUID when dwarfdump is malformed and reports the limitation", async () => {
|
|
const result = await runInspection("UUID: A (arm64) /fixture/app\n");
|
|
if (!result.ok) throw result.error;
|
|
expect(result.value.result).toMatchObject({ uuid: validUuid });
|
|
expect(result.value.limitations).toContain(
|
|
'dwarfdump --uuid returned malformed UUID "A"; using the unambiguous otool LC_UUID.',
|
|
);
|
|
});
|
|
|
|
it("does not choose between conflicting valid UUIDs", async () => {
|
|
const result = await runInspection(
|
|
`UUID: FEDCBA98-7654-3210-FEDC-BA9876543210 (arm64) /fixture/app\n`,
|
|
);
|
|
if (!result.ok) throw result.error;
|
|
expect(result.value.result).toMatchObject({ uuid: null });
|
|
expect(result.value.limitations).toContain(
|
|
`dwarfdump --uuid (FEDCBA98-7654-3210-FEDC-BA9876543210) conflicts with otool LC_UUID (${validUuid}); the image UUID is unknown.`,
|
|
);
|
|
});
|
|
|
|
it("accepts matching UUIDs with different case and preserves dwarfdump spelling", async () => {
|
|
const lower = validUuid.toLowerCase();
|
|
const result = await runInspection(`UUID: ${lower} (arm64) /fixture/app\n`);
|
|
if (!result.ok) throw result.error;
|
|
expect(result.value.result).toMatchObject({ uuid: lower });
|
|
});
|
|
|
|
it("uses a valid dwarfdump UUID when otool has no UUID observation", async () => {
|
|
const result = await runInspection(
|
|
`UUID: ${validUuid} (arm64) /fixture/app\n`,
|
|
[],
|
|
);
|
|
if (!result.ok) throw result.error;
|
|
expect(result.value.result).toMatchObject({ uuid: validUuid });
|
|
});
|
|
|
|
it("frames a path containing a newline and UUID-like text as one dwarfdump row", async () => {
|
|
const path =
|
|
"/fixture/\nUUID: FEDCBA98-7654-3210-FEDC-BA9876543210 (arm64) fake";
|
|
const result = await runInspection(
|
|
`UUID: ${validUuid} (arm64) ${path}\n`,
|
|
[validUuid],
|
|
path,
|
|
);
|
|
if (!result.ok) throw result.error;
|
|
expect(result.value.result).toMatchObject({ uuid: validUuid });
|
|
});
|
|
|
|
it("keeps a conflict between duplicate valid LC_UUID commands unknown despite dwarfdump", async () => {
|
|
const result = await runInspection(
|
|
`UUID: ${validUuid} (arm64) /fixture/app\n`,
|
|
[validUuid, "FEDCBA98-7654-3210-FEDC-BA9876543210"],
|
|
"/fixture/app",
|
|
);
|
|
if (!result.ok) throw result.error;
|
|
expect(result.value.result).toMatchObject({ uuid: null });
|
|
expect(result.value.limitations).toContain(
|
|
`otool returned conflicting LC_UUID values ${JSON.stringify(validUuid)} and ${JSON.stringify("FEDCBA98-7654-3210-FEDC-BA9876543210")}`,
|
|
);
|
|
});
|
|
|
|
it("keeps conflicting duplicate dwarfdump rows unknown despite otool", async () => {
|
|
const conflictingUuid = "FEDCBA98-7654-3210-FEDC-BA9876543210";
|
|
const result = await runInspection(
|
|
`UUID: ${validUuid} (arm64) /fixture/app\nUUID: ${conflictingUuid} (arm64) /fixture/app\n`,
|
|
);
|
|
if (!result.ok) throw result.error;
|
|
expect(result.value.result).toMatchObject({ uuid: null });
|
|
expect(result.value.limitations).toContain(
|
|
"dwarfdump --uuid returned conflicting UUIDs for selected architecture arm64",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("Mach-O slice command scheduling", () => {
|
|
const SLICE_TOOLS = ["otool", "nm", "dyld_info", "dyld_info", "dwarfdump"];
|
|
|
|
/** Finish later-launched slice commands first and record overlap. */
|
|
const reorderingRunner = (failing: ReadonlySet<string> = new Set()) => {
|
|
const fixtures = new NativeFixtureRunner();
|
|
let inFlight = 0;
|
|
let maxInFlight = 0;
|
|
let launched = 0;
|
|
const run = async (tool: string, args: readonly string[]) => {
|
|
if (tool === "lipo") return fixtures.run(tool, args);
|
|
const order = launched++;
|
|
inFlight += 1;
|
|
maxInFlight = Math.max(maxInFlight, inFlight);
|
|
for (let turn = 0; turn < 12 - order * 2; turn += 1)
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
inFlight -= 1;
|
|
if (failing.has(tool))
|
|
return err(new AnalysisOutputError("inspect_macho", `${tool} failed`));
|
|
return fixtures.run(tool, args);
|
|
};
|
|
return { run, maxInFlight: () => maxInFlight };
|
|
};
|
|
|
|
const inspectWith = (run: ReturnType<typeof reorderingRunner>["run"]) =>
|
|
inspectNativeMacho({
|
|
target: nativeMachoTarget("/fixture/app"),
|
|
run,
|
|
invocation: (capture) => ({
|
|
tool: capture.tool,
|
|
command: [capture.executable, ...capture.arguments],
|
|
tool_version: capture.toolVersion,
|
|
version_reason: capture.versionReason,
|
|
executable_sha256: capture.executableSha256,
|
|
exit: { code: capture.exitCode, signal: capture.signal },
|
|
stdout_bytes: capture.stdoutBytes,
|
|
stderr_bytes: capture.stderrBytes,
|
|
}),
|
|
});
|
|
|
|
it("runs independent slice commands together and keeps provenance in declared order", async () => {
|
|
const runner = reorderingRunner();
|
|
const result = await inspectWith(runner.run);
|
|
if (!result.ok) throw result.error;
|
|
expect(runner.maxInFlight()).toBe(SLICE_TOOLS.length + 1);
|
|
expect(result.value.result).toMatchObject({
|
|
provenance: ["lipo", ...SLICE_TOOLS, "vtool"].map((tool) => ({
|
|
tool,
|
|
})),
|
|
});
|
|
});
|
|
|
|
it("reports the first failing slice command in declared order", async () => {
|
|
const runner = reorderingRunner(new Set(["nm", "dwarfdump"]));
|
|
const result = await inspectWith(runner.run);
|
|
expect(result.ok).toBe(false);
|
|
if (result.ok) return;
|
|
expect(result.error).toMatchObject({ reason: "nm failed" });
|
|
});
|
|
});
|