Files
reverse-engineering-anything/src/hopper/HopperAnalysisProfile.test.ts
T
morluto b231c28cf6 fix(hopper): own Mach-O preparation through cleanup retry
Preserve exact selected-image identity and descriptor ownership across preparation, final cleanup, and retries. Keep physical failures and failed cleanup leases observable through provider and launcher boundaries.

Closes #5688
2026-10-21 06:12:09 +08:00

331 lines
11 KiB
TypeScript

import { createHash } from "node:crypto";
import { PassThrough } from "node:stream";
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { afterEach, expect, it, vi } from "vitest";
vi.mock("node:fs", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:fs")>();
return {
...actual,
createReadStream: vi.fn(actual.createReadStream),
};
});
import { createReadStream } from "node:fs";
import { createInspectionResources } from "../../tests/fixtures/inspectionResources.js";
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
import {
hopperLoaderArgsForTarget,
resolveHopperAnalysisProfile,
} from "./HopperAnalysisProfile.js";
let inspectionResources = createInspectionResources();
afterEach(async () => {
await inspectionResources.close();
inspectionResources = createInspectionResources();
});
it("routes physical universal Mach-O containers through Hopper's FAT loader", () => {
const result = hopperLoaderArgsForTarget(
{
path: "/tmp/universal",
sha256: "a".repeat(64),
kind: "executable",
format: "mach-o-universal",
architecture: "arm64",
availableArchitectures: ["x86_64", "arm64"],
universalSlice: { offset: 0, size: 32 },
},
"fat64",
);
expect(result).toEqual({
ok: true,
value: ["-l", "FAT", "--aarch64", "-l", "Mach-O"],
});
});
it("prepares a verified thin arm64e slice for a FAT32 system container", async () => {
const directory = await createTestTempDirectory("rea-hopper-fat32-profile-");
const writeUInt32 = (buffer: Buffer, offset: number, value: number): void => {
buffer.writeUInt32BE(value, offset);
};
const slices = [
{ cpuType: 0x01000007, cpuSubtype: 3 },
{ cpuType: 0x0100000c, cpuSubtype: 0x80000002 },
{ cpuType: 0x0100000c, cpuSubtype: 0x8000000c },
];
const container = Buffer.alloc(3 * 4096 + 64);
writeUInt32(container, 0, 0xcafebabe);
writeUInt32(container, 4, slices.length);
slices.forEach((slice, index) => {
const offset = 4096 * (index + 1);
const entry = 8 + index * 20;
writeUInt32(container, entry, slice.cpuType);
writeUInt32(container, entry + 4, slice.cpuSubtype);
writeUInt32(container, entry + 8, offset);
writeUInt32(container, entry + 12, 64);
writeUInt32(container, entry + 16, 12);
writeUInt32(container, offset, 0xfeedfacf);
writeUInt32(container, offset + 4, slice.cpuType);
writeUInt32(container, offset + 8, slice.cpuSubtype);
});
const arm64eOffset = 8192;
const path = join(directory, "system.fat");
await writeFile(path, container);
const result = await resolveHopperAnalysisProfile(
{
path,
sha256: createHash("sha256").update(container).digest("hex"),
kind: "executable",
format: "mach-o-universal",
architecture: "arm64",
availableArchitectures: ["x86_64", "arm64"],
universalSlice: { offset: arm64eOffset, size: 64 },
},
{
launcherPath: path,
loaderArgsOverride: [],
provider: { id: "hopper", name: "Hopper Disassembler", version: null },
resourceScope: inspectionResources.owner,
},
);
if (!result.ok) throw new Error(result.error.message);
const parameters = result.value.profile?.parameters;
expect(parameters).toMatchObject({
macho_container: "fat32",
prepared_image: {
method: "fat32_thin_slice",
source_offset: arm64eOffset,
byte_length: 64,
cpu_type: 0x0100000c,
cpu_subtype: 0x80000002,
},
target_selection: {
kind: "macho-selected-image-v1",
container: "fat",
offset: arm64eOffset,
byte_length: 64,
cpu_type: 0x0100000c,
cpu_subtype: 0x80000002,
container_table_sha256: expect.any(String),
},
loader: {
source: "derived",
arguments: ["-l", "Mach-O", "--aarch64"],
},
});
});
it("commits a thin header selection only for derived Hopper loader arguments", async () => {
const directory = await createTestTempDirectory("rea-hopper-thin-profile-");
const thin = Buffer.alloc(64);
thin.writeUInt32LE(0xfeedfacf, 0);
thin.writeUInt32LE(0x0100000c, 4);
thin.writeUInt32LE(0x80000002, 8);
const path = join(directory, "arm64e.macho");
await writeFile(path, thin);
const target = {
path,
sha256: createHash("sha256").update(thin).digest("hex"),
kind: "executable" as const,
format: "mach-o" as const,
architecture: "arm64" as const,
availableArchitectures: ["arm64" as const],
};
const provider = {
id: "hopper",
name: "Hopper Disassembler",
version: null,
};
const resolved = await resolveHopperAnalysisProfile(target, {
launcherPath: path,
loaderArgsOverride: [],
provider,
resourceScope: inspectionResources.owner,
});
if (!resolved.ok) throw new Error(resolved.error.message);
expect(resolved.value.profile?.parameters.target_selection).toEqual({
kind: "macho-selected-image-v1",
container: "thin",
offset: 0,
byte_length: 64,
cpu_type: 0x0100000c,
cpu_subtype: 0x80000002,
container_table_sha256: null,
});
expect(resolved.value.profile?.parameters.prepared_image).toEqual({
method: "thin_verified_copy",
byte_length: thin.length,
cpu_type: 0x0100000c,
cpu_subtype: 0x80000002,
});
const overridden = await resolveHopperAnalysisProfile(target, {
launcherPath: path,
loaderArgsOverride: ["-l", "Mach-O"],
provider,
resourceScope: inspectionResources.owner,
});
if (!overridden.ok) throw new Error(overridden.error.message);
expect(overridden.value.profile?.parameters).not.toHaveProperty(
"target_selection",
);
const changedSameSize = Buffer.from(thin);
changedSameSize.writeUInt8(
changedSameSize.readUInt8(changedSameSize.length - 1) ^ 1,
changedSameSize.length - 1,
);
await writeFile(path, changedSameSize);
const staleAdmission = await resolveHopperAnalysisProfile(target, {
launcherPath: path,
loaderArgsOverride: [],
provider,
resourceScope: inspectionResources.owner,
});
expect(staleAdmission.ok).toBe(false);
if (staleAdmission.ok) throw new Error("stale thin admission was accepted");
expect(staleAdmission.error.message).toContain(
"Thin Mach-O source changed after target admission",
);
});
it("keeps a read failure primary when cancellation arrives with the stream error", async () => {
const path = "/fixture/arm64.macho";
const target = {
path,
sha256: "a".repeat(64),
kind: "executable" as const,
format: "mach-o" as const,
architecture: "arm64" as const,
availableArchitectures: ["arm64" as const],
};
const controller = new AbortController();
const reason = { request: "cancel-after-read-error" };
const physicalFailure = new Error("fixture read failure");
const stream = new PassThrough();
vi.mocked(createReadStream).mockReturnValueOnce(
stream as unknown as ReturnType<typeof createReadStream>,
);
stream.once("error", () => controller.abort(reason));
const resolving = resolveHopperAnalysisProfile(target, {
launcherPath: "/fixture/hopper",
loaderArgsOverride: [],
provider: { id: "hopper", name: "Hopper", version: null },
resourceScope: inspectionResources.owner,
signal: controller.signal,
});
stream.destroy(physicalFailure);
const result = await resolving;
expect(result.ok).toBe(false);
if (result.ok) throw new Error("A failed header read was accepted");
expect(result.error._tag).toBe("BinaryTargetError");
expect(result.error.cause).toBe(physicalFailure);
});
it("retains the exact caller reason when the owned header stream is aborted", async () => {
const controller = new AbortController();
const reason = { request: "cancel-header-read" };
const stream = new PassThrough();
vi.mocked(createReadStream).mockReturnValueOnce(
stream as unknown as ReturnType<typeof createReadStream>,
);
const resolving = resolveHopperAnalysisProfile(
{
path: "/fixture/arm64.macho",
sha256: "a".repeat(64),
kind: "executable",
format: "mach-o",
architecture: "arm64",
availableArchitectures: ["arm64"],
},
{
launcherPath: "/fixture/hopper",
loaderArgsOverride: [],
provider: { id: "hopper", name: "Hopper", version: null },
resourceScope: inspectionResources.owner,
signal: controller.signal,
},
);
controller.abort(reason);
const result = await resolving;
expect(result.ok).toBe(false);
if (result.ok) throw new Error("An aborted header read was accepted");
expect(result.error._tag).toBe("AnalysisCancelledError");
expect(result.error.cause).toBe(reason);
});
it("keeps failed Mach-O preflight cleanup attached to the supplied profile owner", async () => {
const directory = await createTestTempDirectory("rea-hopper-profile-owner-");
const sourcePath = join(directory, "source.macho");
const bytes = Buffer.alloc(100_000, 0x5a);
bytes.writeUInt32BE(0xfeedfacf, 0);
bytes.writeUInt32BE(0x0100000c, 4);
bytes.writeUInt32BE(0x80000002, 8);
await writeFile(sourcePath, bytes);
const sha256 = createHash("sha256").update(bytes).digest("hex");
const actualFs =
await vi.importActual<typeof import("node:fs/promises")>(
"node:fs/promises",
);
const actualNodeFs =
await vi.importActual<typeof import("node:fs")>("node:fs");
vi.mocked(createReadStream).mockReset();
vi.mocked(createReadStream).mockImplementation(actualNodeFs.createReadStream);
const closeFailure = new Error("profile preflight close failure");
let allowClose = false;
const imageFileSystem = {
open: async (...args: Parameters<typeof actualFs.open>) => {
const handle = await actualFs.open(...args);
const closeHandle = handle.close.bind(handle);
Object.defineProperty(handle, "close", {
configurable: true,
value: async (): Promise<void> => {
if (!allowClose) throw closeFailure;
await closeHandle();
},
});
return handle;
},
};
const owner = inspectionResources;
const resolved = await resolveHopperAnalysisProfile(
{
path: sourcePath,
sha256,
kind: "executable",
format: "mach-o",
architecture: "arm64",
availableArchitectures: ["arm64"],
},
{
launcherPath: sourcePath,
loaderArgsOverride: [],
provider: { id: "hopper", name: "Hopper", version: null },
resourceScope: owner.owner,
imageFileSystem,
},
);
expect(resolved.ok).toBe(false);
if (resolved.ok) throw new Error("Failed preflight cleanup was accepted");
expect(resolved.error._tag).toBe("BinaryTargetError");
expect(resolved.error.cause).toBe(closeFailure);
expect(resolved.error.cleanupIncomplete).toBe(true);
expect(resolved.error.cleanupResources).toEqual([
`hopper-macho-preflight:${sourcePath}`,
]);
allowClose = true;
await owner.close();
expect(resolved.error.cleanupIncomplete).toBe(false);
});