mirror of
https://github.com/morluto/rea.git
synced 2026-11-11 09:21:16 +00:00
783 lines
28 KiB
TypeScript
783 lines
28 KiB
TypeScript
import { requirePermissionDenial } from "../../tests/fixtures/permissionDenial.js";
|
|
import { createHash } from "node:crypto";
|
|
import {
|
|
access,
|
|
chmod,
|
|
mkdir,
|
|
readFile,
|
|
readdir,
|
|
writeFile,
|
|
} from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
|
|
import { afterEach, expect, it, vi } from "vitest";
|
|
|
|
import { createInspectionResources } from "../../tests/fixtures/inspectionResources.js";
|
|
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
|
|
import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
|
|
import { BinaryTargetError } from "../domain/configurationErrors.js";
|
|
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
|
import {
|
|
prepareHopperMachOImage,
|
|
resolveHopperMachOImage as resolveHopperMachOImageWithOwner,
|
|
} from "./HopperMachOImage.js";
|
|
import { HopperApplicationLauncher } from "./BridgeLauncher.js";
|
|
|
|
const CPU_TYPES = { x86_64: 0x01000007, arm64: 0x0100000c } as const;
|
|
const FAT32_ALIGNMENT = 12;
|
|
const FAT32_SLICE_SIZE = 64;
|
|
const ARM64E = 2;
|
|
const ARM64E_X1 = 12;
|
|
const ARM64E_WITH_CAPABILITIES = 0x80000002;
|
|
const ARM64E_X1_WITH_CAPABILITIES = 0x8000000c;
|
|
|
|
interface FatFixtureSlice {
|
|
readonly cpuType: number;
|
|
readonly cpuSubtype: number;
|
|
/** Thin-slice byte order, independent of the container's table byte order. */
|
|
readonly little?: boolean;
|
|
}
|
|
|
|
interface Fat32Fixture {
|
|
readonly container: Buffer;
|
|
readonly sliceOffsets: readonly number[];
|
|
}
|
|
|
|
/** Synthesize a FAT32 container whose slices are 4096-aligned thin headers. */
|
|
const buildFat32Container = (
|
|
slices: readonly FatFixtureSlice[],
|
|
options: { readonly littleEndianTable?: boolean } = {},
|
|
): Fat32Fixture => {
|
|
const littleTable = options.littleEndianTable === true;
|
|
const writeTableUInt32 = littleTable
|
|
? (buffer: Buffer, offset: number, value: number): number =>
|
|
buffer.writeUInt32LE(value, offset)
|
|
: (buffer: Buffer, offset: number, value: number): number =>
|
|
buffer.writeUInt32BE(value, offset);
|
|
const unit = 2 ** FAT32_ALIGNMENT;
|
|
const sliceOffsets = slices.map((_slice, index) => unit * (index + 1));
|
|
const lastOffset = sliceOffsets[sliceOffsets.length - 1] ?? unit;
|
|
const container = Buffer.alloc(lastOffset + FAT32_SLICE_SIZE, 0);
|
|
// The magic VALUE is 0xcafebabe in the table's byte order: little-endian
|
|
// storage yields the FAT_CIGAM magic a big-endian reader observes.
|
|
writeTableUInt32(container, 0, 0xcafebabe);
|
|
writeTableUInt32(container, 4, slices.length);
|
|
for (const [index, slice] of slices.entries()) {
|
|
const entry = 8 + index * 20;
|
|
const offset = sliceOffsets[index]!;
|
|
writeTableUInt32(container, entry, slice.cpuType);
|
|
writeTableUInt32(container, entry + 4, slice.cpuSubtype);
|
|
writeTableUInt32(container, entry + 8, offset);
|
|
writeTableUInt32(container, entry + 12, FAT32_SLICE_SIZE);
|
|
writeTableUInt32(container, entry + 16, FAT32_ALIGNMENT);
|
|
const writeThinUInt32 =
|
|
slice.little === true
|
|
? (buffer: Buffer, fieldOffset: number, value: number): number =>
|
|
buffer.writeUInt32LE(value, fieldOffset)
|
|
: (buffer: Buffer, fieldOffset: number, value: number): number =>
|
|
buffer.writeUInt32BE(value, fieldOffset);
|
|
writeThinUInt32(container, offset, 0xfeedfacf);
|
|
writeThinUInt32(container, offset + 4, slice.cpuType);
|
|
writeThinUInt32(container, offset + 8, slice.cpuSubtype);
|
|
}
|
|
return { container, sliceOffsets };
|
|
};
|
|
|
|
const writeFixture = async (
|
|
directory: string,
|
|
name: string,
|
|
fixture: Fat32Fixture,
|
|
): Promise<string> => {
|
|
const path = join(directory, name);
|
|
await writeFile(path, fixture.container);
|
|
return path;
|
|
};
|
|
|
|
const fat32Target = (
|
|
path: string,
|
|
fixture: Fat32Fixture,
|
|
architecture: "arm64" | "x86_64",
|
|
) => ({
|
|
path,
|
|
sha256: createHash("sha256").update(fixture.container).digest("hex"),
|
|
kind: "executable" as const,
|
|
format: "mach-o-universal" as const,
|
|
architecture,
|
|
availableArchitectures: ["x86_64", "arm64"] as const,
|
|
universalSlice: { offset: fixture.sliceOffsets[0]!, size: FAT32_SLICE_SIZE },
|
|
});
|
|
|
|
const requireResolved = (
|
|
result: Awaited<ReturnType<typeof resolveHopperMachOImage>>,
|
|
) => {
|
|
if (!result.ok)
|
|
throw new Error(
|
|
`FAT fixture resolution failed: ${JSON.stringify(projectAnalysisError(result.error))}`,
|
|
);
|
|
return result.value;
|
|
};
|
|
|
|
let inspectionResources = createInspectionResources();
|
|
afterEach(async () => {
|
|
await inspectionResources.close();
|
|
inspectionResources = createInspectionResources();
|
|
});
|
|
|
|
const resolveHopperMachOImage = (
|
|
target: Parameters<typeof resolveHopperMachOImageWithOwner>[0],
|
|
) =>
|
|
resolveHopperMachOImageWithOwner(target, {
|
|
resourceScope: inspectionResources.owner,
|
|
});
|
|
|
|
it.skipIf(process.platform === "win32")(
|
|
"reports a FAT64 source that became unreadable as a host read denial",
|
|
async (context) => {
|
|
await requirePermissionDenial(context, "file-read");
|
|
const directory = await createTestTempDirectory("rea-hopper-fat64-");
|
|
const path = join(directory, "universal");
|
|
await writeFile(path, Buffer.alloc(64));
|
|
await chmod(path, 0);
|
|
try {
|
|
const result = await resolveHopperMachOImage({
|
|
path,
|
|
sha256: "0".repeat(64),
|
|
kind: "executable",
|
|
format: "mach-o",
|
|
architecture: "arm64",
|
|
availableArchitectures: ["arm64"],
|
|
});
|
|
expect(result.ok).toBe(false);
|
|
if (result.ok) return;
|
|
expect(result.error).toMatchObject({ systemCode: "EACCES" });
|
|
expect(projectAnalysisError(result.error)).toMatchObject({
|
|
code: "access_denied",
|
|
details: { path, system_code: "EACCES" },
|
|
});
|
|
} finally {
|
|
await chmod(path, 0o600);
|
|
}
|
|
},
|
|
);
|
|
|
|
it("rejects an ELF target before attempting FAT source preparation", async () => {
|
|
const directory = await createTestTempDirectory("rea-hopper-mips-boundary-");
|
|
// No file is created: format validation must precede any filesystem lookup.
|
|
const path = join(directory, "mips.elf");
|
|
const result = await resolveHopperMachOImage({
|
|
path,
|
|
sha256: "0".repeat(64),
|
|
kind: "executable",
|
|
format: "elf",
|
|
architecture: "mips",
|
|
availableArchitectures: ["mips"],
|
|
mips: { elfClass: 32, byteOrder: "little", type: 2, flags: 0x70001001 },
|
|
});
|
|
if (result.ok)
|
|
throw new Error("An ELF target entered Mach-O image preparation");
|
|
expect(result.error).toMatchObject({
|
|
_tag: "BinaryTargetError",
|
|
path,
|
|
reason: "Hopper image preparation requires a Mach-O target",
|
|
});
|
|
});
|
|
|
|
it("selects the plain-arm64-decodable arm64e slice from an Apple-style FAT32 container", async () => {
|
|
const directory = await createTestTempDirectory("rea-hopper-fat32-arm64e-");
|
|
// /bin/* system binaries: x86_64, arm64e (cpusubtype 2), arm64e.x1 (subtype 12).
|
|
const fixture = buildFat32Container([
|
|
{ cpuType: CPU_TYPES.x86_64, cpuSubtype: 3 },
|
|
{ cpuType: CPU_TYPES.arm64, cpuSubtype: ARM64E },
|
|
{ cpuType: CPU_TYPES.arm64, cpuSubtype: ARM64E_X1 },
|
|
]);
|
|
const path = await writeFixture(directory, "system.fat", fixture);
|
|
const result = requireResolved(
|
|
await resolveHopperMachOImage(fat32Target(path, fixture, "arm64")),
|
|
);
|
|
expect(result).toMatchObject({
|
|
method: "fat32_thin_slice",
|
|
architecture_count: 3,
|
|
source_offset: fixture.sliceOffsets[1],
|
|
byte_length: FAT32_SLICE_SIZE,
|
|
cpu_type: CPU_TYPES.arm64,
|
|
cpu_subtype: ARM64E,
|
|
});
|
|
});
|
|
|
|
it("ranks capability-bearing arm64 subtypes by base and retains raw identity", async () => {
|
|
const directory = await createTestTempDirectory(
|
|
"rea-hopper-fat32-capabilities-",
|
|
);
|
|
const fixture = buildFat32Container([
|
|
{
|
|
cpuType: CPU_TYPES.arm64,
|
|
cpuSubtype: ARM64E_X1_WITH_CAPABILITIES,
|
|
},
|
|
{
|
|
cpuType: CPU_TYPES.arm64,
|
|
cpuSubtype: ARM64E_WITH_CAPABILITIES,
|
|
},
|
|
]);
|
|
const path = await writeFixture(directory, "system.fat", fixture);
|
|
const selected = requireResolved(
|
|
await resolveHopperMachOImage(fat32Target(path, fixture, "arm64")),
|
|
);
|
|
expect(selected).toMatchObject({
|
|
source_offset: fixture.sliceOffsets[1],
|
|
cpu_subtype: ARM64E_WITH_CAPABILITIES,
|
|
});
|
|
});
|
|
|
|
it("fails closed when capability variants share the preferred arm64e base", async () => {
|
|
const directory = await createTestTempDirectory(
|
|
"rea-hopper-fat32-capability-ambiguity-",
|
|
);
|
|
const fixture = buildFat32Container([
|
|
{ cpuType: CPU_TYPES.arm64, cpuSubtype: 0x80000002 },
|
|
{ cpuType: CPU_TYPES.arm64, cpuSubtype: 0x81000002 },
|
|
]);
|
|
const path = await writeFixture(directory, "ambiguous.fat", fixture);
|
|
const result = await resolveHopperMachOImage(
|
|
fat32Target(path, fixture, "arm64"),
|
|
);
|
|
if (result.ok || !(result.error instanceof BinaryTargetError))
|
|
throw new Error(
|
|
"Expected ambiguous FAT selection to return a BinaryTargetError",
|
|
);
|
|
expect(result.error.reason).toContain("selection for arm64 is ambiguous");
|
|
});
|
|
|
|
it("prefers cpusubtype 2 over 12 regardless of FAT32 table order", async () => {
|
|
const directory = await createTestTempDirectory("rea-hopper-fat32-order-");
|
|
const fixture = buildFat32Container([
|
|
{ cpuType: CPU_TYPES.arm64, cpuSubtype: ARM64E_X1 },
|
|
{ cpuType: CPU_TYPES.arm64, cpuSubtype: ARM64E },
|
|
]);
|
|
const path = await writeFixture(directory, "reordered.fat", fixture);
|
|
const result = requireResolved(
|
|
await resolveHopperMachOImage(fat32Target(path, fixture, "arm64")),
|
|
);
|
|
expect(result).toMatchObject({
|
|
cpu_subtype: ARM64E,
|
|
source_offset: fixture.sliceOffsets[1],
|
|
});
|
|
});
|
|
|
|
it("prefers a plain arm64 slice over arm64e in a FAT32 container", async () => {
|
|
const directory = await createTestTempDirectory("rea-hopper-fat32-plain-");
|
|
const fixture = buildFat32Container([
|
|
{ cpuType: CPU_TYPES.arm64, cpuSubtype: 0 },
|
|
{ cpuType: CPU_TYPES.arm64, cpuSubtype: ARM64E },
|
|
]);
|
|
const path = await writeFixture(directory, "plain.fat", fixture);
|
|
const result = requireResolved(
|
|
await resolveHopperMachOImage(fat32Target(path, fixture, "arm64")),
|
|
);
|
|
expect(result).toMatchObject({
|
|
cpu_subtype: 0,
|
|
source_offset: fixture.sliceOffsets[0],
|
|
});
|
|
});
|
|
|
|
it("keeps a lone arm64e.x1 FAT32 slice selectable to preserve today's open behavior", async () => {
|
|
const directory = await createTestTempDirectory("rea-hopper-fat32-lone-");
|
|
const fixture = buildFat32Container([
|
|
{ cpuType: CPU_TYPES.arm64, cpuSubtype: ARM64E_X1 },
|
|
]);
|
|
const path = await writeFixture(directory, "lone.fat", fixture);
|
|
const result = requireResolved(
|
|
await resolveHopperMachOImage(fat32Target(path, fixture, "arm64")),
|
|
);
|
|
expect(result).toMatchObject({ cpu_subtype: ARM64E_X1 });
|
|
});
|
|
|
|
it("still fails closed when two arm64e.x1 FAT32 slices compete", async () => {
|
|
const directory = await createTestTempDirectory("rea-hopper-fat32-ambig-");
|
|
const fixture = buildFat32Container([
|
|
{ cpuType: CPU_TYPES.arm64, cpuSubtype: ARM64E_X1 },
|
|
{ cpuType: CPU_TYPES.arm64, cpuSubtype: ARM64E_X1 },
|
|
]);
|
|
const path = await writeFixture(directory, "ambiguous.fat", fixture);
|
|
const result = await resolveHopperMachOImage(
|
|
fat32Target(path, fixture, "arm64"),
|
|
);
|
|
if (result.ok) throw new Error("Ambiguous FAT32 selection succeeded");
|
|
expect(
|
|
projectAnalysisError(result.error).details?.reason ?? result.error,
|
|
).toMatch(/selection.*ambiguous/u);
|
|
});
|
|
|
|
it("selects the single x86_64 slice of a FAT32 container for an x86_64 target", async () => {
|
|
const directory = await createTestTempDirectory("rea-hopper-fat32-intel-");
|
|
const fixture = buildFat32Container([
|
|
{ cpuType: CPU_TYPES.arm64, cpuSubtype: ARM64E },
|
|
{ cpuType: CPU_TYPES.x86_64, cpuSubtype: 3 },
|
|
]);
|
|
const path = await writeFixture(directory, "universal.fat", fixture);
|
|
const result = requireResolved(
|
|
await resolveHopperMachOImage(fat32Target(path, fixture, "x86_64")),
|
|
);
|
|
expect(result).toMatchObject({
|
|
cpu_type: CPU_TYPES.x86_64,
|
|
cpu_subtype: 3,
|
|
source_offset: fixture.sliceOffsets[1],
|
|
});
|
|
});
|
|
|
|
it("parses a little-endian FAT32 table without tying it to slice byte order", async () => {
|
|
const directory = await createTestTempDirectory("rea-hopper-fat32-le-");
|
|
const fixture = buildFat32Container(
|
|
[
|
|
{ cpuType: CPU_TYPES.x86_64, cpuSubtype: 3 },
|
|
{ cpuType: CPU_TYPES.arm64, cpuSubtype: ARM64E },
|
|
{ cpuType: CPU_TYPES.arm64, cpuSubtype: ARM64E_X1 },
|
|
],
|
|
{ littleEndianTable: true },
|
|
);
|
|
const path = await writeFixture(directory, "little.fat", fixture);
|
|
const result = requireResolved(
|
|
await resolveHopperMachOImage(fat32Target(path, fixture, "arm64")),
|
|
);
|
|
expect(result).toMatchObject({
|
|
method: "fat32_thin_slice",
|
|
cpu_subtype: ARM64E,
|
|
source_offset: fixture.sliceOffsets[1],
|
|
});
|
|
});
|
|
|
|
it("rejects a FAT32 slice whose declared range extends beyond the file", async () => {
|
|
const directory = await createTestTempDirectory("rea-hopper-fat32-range-");
|
|
const fixture = buildFat32Container([
|
|
{ cpuType: CPU_TYPES.arm64, cpuSubtype: ARM64E },
|
|
]);
|
|
fixture.container.writeUInt32BE(fixture.container.length + 16, 8 + 12);
|
|
const path = await writeFixture(directory, "overrange.fat", fixture);
|
|
const result = await resolveHopperMachOImage(
|
|
fat32Target(path, fixture, "arm64"),
|
|
);
|
|
if (result.ok) throw new Error("An overranging FAT32 slice was accepted");
|
|
expect(
|
|
projectAnalysisError(result.error).details?.reason ?? result.error,
|
|
).toMatch(/extends beyond the file/u);
|
|
});
|
|
|
|
it("copies exactly the selected FAT32 slice bytes and verifies the source", async () => {
|
|
const directory = await createTestTempDirectory("rea-hopper-fat32-copy-");
|
|
const fixture = buildFat32Container([
|
|
{ cpuType: CPU_TYPES.x86_64, cpuSubtype: 3 },
|
|
{ cpuType: CPU_TYPES.arm64, cpuSubtype: ARM64E },
|
|
{ cpuType: CPU_TYPES.arm64, cpuSubtype: ARM64E_X1 },
|
|
]);
|
|
const path = await writeFixture(directory, "system.fat", fixture);
|
|
const target = fat32Target(path, fixture, "arm64");
|
|
const resolved = requireResolved(await resolveHopperMachOImage(target));
|
|
const destination = join(directory, "image.macho");
|
|
const prepared = await prepareHopperMachOImage(
|
|
path,
|
|
target.sha256,
|
|
resolved,
|
|
destination,
|
|
{ resourceScope: new ArtifactResourceScope() },
|
|
);
|
|
if (!prepared.ok) throw new Error(prepared.error.userMessage);
|
|
const arm64eOffset = fixture.sliceOffsets[1]!;
|
|
const copied = fixture.container.subarray(
|
|
arm64eOffset,
|
|
arm64eOffset + FAT32_SLICE_SIZE,
|
|
);
|
|
expect(await readFile(destination)).toEqual(copied);
|
|
});
|
|
|
|
it("retains the caller reason when a FAT copy is cancelled after its final write", async () => {
|
|
const directory = await createTestTempDirectory("rea-hopper-fat-cancel-");
|
|
const fixture = buildFat32Container([
|
|
{ cpuType: CPU_TYPES.arm64, cpuSubtype: ARM64E },
|
|
]);
|
|
const path = await writeFixture(directory, "system.fat", fixture);
|
|
const target = fat32Target(path, fixture, "arm64");
|
|
const image = requireResolved(await resolveHopperMachOImage(target));
|
|
const destination = join(directory, "image.macho");
|
|
const controller = new AbortController();
|
|
const reason = { request: "cancel-after-fat-write" };
|
|
const prepared = await prepareHopperMachOImage(
|
|
path,
|
|
target.sha256,
|
|
image,
|
|
destination,
|
|
{
|
|
resourceScope: new ArtifactResourceScope(),
|
|
signal: controller.signal,
|
|
observer: {
|
|
afterChunkWritten: async (copiedBytes) => {
|
|
expect(copiedBytes).toBe(FAT32_SLICE_SIZE);
|
|
controller.abort(reason);
|
|
},
|
|
},
|
|
},
|
|
);
|
|
|
|
expect(prepared.ok).toBe(false);
|
|
if (prepared.ok) throw new Error("Cancelled FAT copy was accepted");
|
|
expect(prepared.error._tag).toBe("HopperCancelledError");
|
|
expect(prepared.error.cause).toBe(reason);
|
|
// The private session directory remains the owner of partial output.
|
|
await expect(readFile(destination)).resolves.toEqual(
|
|
fixture.container.subarray(
|
|
fixture.sliceOffsets[0],
|
|
fixture.sliceOffsets[0]! + FAT32_SLICE_SIZE,
|
|
),
|
|
);
|
|
});
|
|
|
|
it("retains a failed prepared-image close through launcher cleanup and blocks another launch", async () => {
|
|
const directory = await createTestTempDirectory("rea-hopper-copy-close-");
|
|
const bytes = thinFixture();
|
|
const path = join(directory, "source.macho");
|
|
await writeFile(path, bytes);
|
|
const target = thinTarget(path, bytes);
|
|
const image = requireResolved(await resolveHopperMachOImage(target));
|
|
const actualFs =
|
|
await vi.importActual<typeof import("node:fs/promises")>(
|
|
"node:fs/promises",
|
|
);
|
|
const firstCloseFailure = new Error("first close attempt failed");
|
|
const retryCloseFailure = new Error("cleanup retry failed");
|
|
let allowClose = false;
|
|
let sourceCloseAttempts = 0;
|
|
const imageFileSystem = {
|
|
open: async (...arguments_: Parameters<typeof actualFs.open>) => {
|
|
const handle = await actualFs.open(...arguments_);
|
|
if (arguments_[0] !== path) return handle;
|
|
const sourceClose = async (): Promise<void> => {
|
|
sourceCloseAttempts += 1;
|
|
if (!allowClose && sourceCloseAttempts === 1) throw firstCloseFailure;
|
|
if (!allowClose) throw retryCloseFailure;
|
|
await handle.close();
|
|
};
|
|
return new Proxy(handle, {
|
|
get(targetHandle, property) {
|
|
if (property === "close") return sourceClose;
|
|
const value = Reflect.get(targetHandle, property, targetHandle);
|
|
return typeof value === "function" ? value.bind(targetHandle) : value;
|
|
},
|
|
});
|
|
},
|
|
};
|
|
|
|
const sessionDirectory = join(directory, "session");
|
|
await mkdir(sessionDirectory);
|
|
const launcher = new HopperApplicationLauncher(
|
|
{
|
|
environment: {},
|
|
launcherPath: join(directory, "hopper"),
|
|
targetPath: path,
|
|
targetKind: "executable",
|
|
loaderArgs: ["-l", "Mach-O", "--aarch64"],
|
|
bridgeScriptPath: join(directory, "bridge.py"),
|
|
preparedImage: { image, sourceSha256: target.sha256 },
|
|
launchMode: "native",
|
|
},
|
|
{ platform: "linux", imageFileSystem },
|
|
);
|
|
const initial = await launcher.launch({
|
|
directory: sessionDirectory,
|
|
socketPath: join(sessionDirectory, "bridge.sock"),
|
|
token: "fixture-token",
|
|
runId: "fixture-run-1",
|
|
});
|
|
expect(initial.ok).toBe(false);
|
|
if (initial.ok) throw new Error("A copy with failed close was accepted");
|
|
expect(initial.error._tag).toBe("HopperStartError");
|
|
expect(initial.error.cause).toBe(firstCloseFailure);
|
|
expect(initial.error.cleanupIncomplete).toBe(true);
|
|
expect(initial.error.cleanupResources).toEqual([
|
|
`hopper-image-source:${path}`,
|
|
]);
|
|
const blocked = await launcher.launch({
|
|
directory: sessionDirectory,
|
|
socketPath: join(sessionDirectory, "bridge.sock"),
|
|
token: "fixture-token",
|
|
runId: "fixture-run-2",
|
|
});
|
|
expect(blocked.ok).toBe(false);
|
|
if (blocked.ok) throw new Error("A launch bypassed retained file cleanup");
|
|
expect(blocked.error.cleanupIncomplete).toBe(true);
|
|
expect(blocked.error.cleanupResources).toEqual([
|
|
`hopper-image-source:${path}`,
|
|
]);
|
|
expect(sourceCloseAttempts).toBe(3);
|
|
|
|
const cleanupPending = await launcher.cleanup();
|
|
expect(cleanupPending.ok).toBe(false);
|
|
if (cleanupPending.ok)
|
|
throw new Error("Cleanup without release was reported successful");
|
|
expect(cleanupPending.error.cleanupIncomplete).toBe(true);
|
|
expect(cleanupPending.error.cleanupResources).toEqual([
|
|
`hopper-image-source:${path}`,
|
|
]);
|
|
expect(sourceCloseAttempts).toBe(4);
|
|
|
|
allowClose = true;
|
|
await expect(launcher.cleanup()).resolves.toEqual({ ok: true, value: null });
|
|
expect(initial.error.cleanupIncomplete).toBe(false);
|
|
expect(blocked.error.cleanupIncomplete).toBe(false);
|
|
expect(cleanupPending.error.cleanupIncomplete).toBe(false);
|
|
await expect(
|
|
readFile(join(sessionDirectory, "image.macho")),
|
|
).resolves.toEqual(bytes);
|
|
});
|
|
|
|
it("observes caller cancellation that arrives during final source-handle cleanup", async () => {
|
|
const directory = await createTestTempDirectory(
|
|
"rea-hopper-copy-close-cancel-",
|
|
);
|
|
const bytes = thinFixture();
|
|
const path = join(directory, "source.macho");
|
|
await writeFile(path, bytes);
|
|
const target = thinTarget(path, bytes);
|
|
const image = requireResolved(await resolveHopperMachOImage(target));
|
|
const controller = new AbortController();
|
|
const reason = { request: "cancel-during-final-close" };
|
|
const actualFs =
|
|
await vi.importActual<typeof import("node:fs/promises")>(
|
|
"node:fs/promises",
|
|
);
|
|
const fileSystem = {
|
|
open: async (...arguments_: Parameters<typeof actualFs.open>) => {
|
|
const handle = await actualFs.open(...arguments_);
|
|
if (arguments_[0] !== path) return handle;
|
|
const closeHandle = handle.close.bind(handle);
|
|
Object.defineProperty(handle, "close", {
|
|
configurable: true,
|
|
value: async (): Promise<void> => {
|
|
controller.abort(reason);
|
|
await closeHandle();
|
|
},
|
|
});
|
|
return handle;
|
|
},
|
|
};
|
|
|
|
const prepared = await prepareHopperMachOImage(
|
|
path,
|
|
target.sha256,
|
|
image,
|
|
join(directory, "image.macho"),
|
|
{
|
|
resourceScope: new ArtifactResourceScope(),
|
|
signal: controller.signal,
|
|
fileSystem,
|
|
},
|
|
);
|
|
|
|
expect(prepared.ok).toBe(false);
|
|
if (prepared.ok) throw new Error("Finalization cancellation was ignored");
|
|
expect(prepared.error._tag).toBe("HopperCancelledError");
|
|
expect(prepared.error.cause).toBe(reason);
|
|
await expect(readFile(join(directory, "image.macho"))).resolves.toEqual(
|
|
bytes,
|
|
);
|
|
});
|
|
|
|
it("rejects FAT32 preparation when the source table changed after resolution", async () => {
|
|
const directory = await createTestTempDirectory("rea-hopper-fat32-race-");
|
|
const fixture = buildFat32Container([
|
|
{ cpuType: CPU_TYPES.arm64, cpuSubtype: ARM64E },
|
|
{ cpuType: CPU_TYPES.arm64, cpuSubtype: ARM64E_X1 },
|
|
]);
|
|
const path = await writeFixture(directory, "system.fat", fixture);
|
|
const target = fat32Target(path, fixture, "arm64");
|
|
const resolved = requireResolved(await resolveHopperMachOImage(target));
|
|
const mutated = Buffer.from(fixture.container);
|
|
mutated.writeUInt32BE(0x01000007, 8);
|
|
await writeFile(path, mutated);
|
|
const destination = join(directory, "image.macho");
|
|
const prepared = await prepareHopperMachOImage(
|
|
path,
|
|
target.sha256,
|
|
resolved,
|
|
destination,
|
|
{ resourceScope: new ArtifactResourceScope() },
|
|
);
|
|
if (prepared.ok)
|
|
throw new Error("A mutated FAT32 source passed verification");
|
|
expect(prepared.error.userMessage).toMatch(/no longer agrees/u);
|
|
});
|
|
|
|
const thinFixture = (): Buffer => {
|
|
const image = Buffer.alloc(64, 0x5a);
|
|
image.writeUInt32BE(0xfeedfacf, 0);
|
|
image.writeUInt32BE(CPU_TYPES.arm64, 4);
|
|
image.writeUInt32BE(ARM64E_WITH_CAPABILITIES, 8);
|
|
return image;
|
|
};
|
|
|
|
const thinTarget = (path: string, bytes: Buffer) => ({
|
|
path,
|
|
sha256: createHash("sha256").update(bytes).digest("hex"),
|
|
kind: "executable" as const,
|
|
format: "mach-o" as const,
|
|
architecture: "arm64" as const,
|
|
availableArchitectures: ["arm64"] as const,
|
|
});
|
|
|
|
it("copies an admitted thin Mach-O through the owned verified-image path", async () => {
|
|
const directory = await createTestTempDirectory("rea-hopper-thin-copy-");
|
|
const bytes = thinFixture();
|
|
const path = join(directory, "source.macho");
|
|
await writeFile(path, bytes);
|
|
const target = thinTarget(path, bytes);
|
|
const image = requireResolved(await resolveHopperMachOImage(target));
|
|
expect(image).toEqual({
|
|
method: "thin_verified_copy",
|
|
byte_length: bytes.length,
|
|
cpu_type: CPU_TYPES.arm64,
|
|
cpu_subtype: ARM64E_WITH_CAPABILITIES,
|
|
});
|
|
|
|
const destination = join(directory, "private", "image.macho");
|
|
await mkdir(join(directory, "private"));
|
|
const prepared = await prepareHopperMachOImage(
|
|
path,
|
|
target.sha256,
|
|
image,
|
|
destination,
|
|
{ resourceScope: new ArtifactResourceScope() },
|
|
);
|
|
if (!prepared.ok) throw new Error(prepared.error.userMessage);
|
|
expect(prepared.value).toBe(destination);
|
|
expect(await readFile(destination)).toEqual(bytes);
|
|
expect(target.path).toBe(path);
|
|
});
|
|
|
|
it("does not launch a thin selection when its admitted source changes before copy", async () => {
|
|
const directory = await createTestTempDirectory("rea-hopper-thin-race-");
|
|
const bytes = thinFixture();
|
|
const path = join(directory, "source.macho");
|
|
await writeFile(path, bytes);
|
|
const target = thinTarget(path, bytes);
|
|
const image = requireResolved(await resolveHopperMachOImage(target));
|
|
const changed = Buffer.from(bytes);
|
|
changed.writeUInt8(
|
|
changed.readUInt8(changed.length - 1) ^ 1,
|
|
changed.length - 1,
|
|
);
|
|
await writeFile(path, changed);
|
|
|
|
const sessionDirectory = join(directory, "session");
|
|
await mkdir(sessionDirectory);
|
|
const launcher = new HopperApplicationLauncher(
|
|
{
|
|
environment: process.env,
|
|
launcherPath: join(directory, "hopper"),
|
|
targetPath: path,
|
|
targetKind: "executable",
|
|
loaderArgs: ["-l", "Mach-O", "--aarch64"],
|
|
bridgeScriptPath: join(directory, "bridge.py"),
|
|
preparedImage: { image, sourceSha256: target.sha256 },
|
|
launchMode: "native",
|
|
},
|
|
{ platform: "linux" },
|
|
);
|
|
const launched = await launcher.launch({
|
|
directory: sessionDirectory,
|
|
socketPath: join(sessionDirectory, "bridge.sock"),
|
|
token: "fixture-token",
|
|
runId: "fixture-run",
|
|
});
|
|
expect(launched.ok).toBe(false);
|
|
if (launched.ok) throw new Error("Changed source reached Hopper launch");
|
|
expect(launched.error.userMessage).toContain(
|
|
"no longer agrees with its admitted digest, size, or selected header",
|
|
);
|
|
await expect(
|
|
access(join(sessionDirectory, "bootstrap.py")),
|
|
).rejects.toMatchObject({
|
|
code: "ENOENT",
|
|
});
|
|
// The session directory remains the cleanup owner for partial private output.
|
|
await expect(readdir(sessionDirectory)).resolves.toContain("image.macho");
|
|
});
|
|
|
|
it("keeps caller cancellation primary after verified thin-image preparation", async () => {
|
|
const directory = await createTestTempDirectory("rea-hopper-thin-cancel-");
|
|
const bytes = thinFixture();
|
|
const path = join(directory, "source.macho");
|
|
await writeFile(path, bytes);
|
|
const target = thinTarget(path, bytes);
|
|
const image = requireResolved(await resolveHopperMachOImage(target));
|
|
const controller = new AbortController();
|
|
controller.abort(new Error("caller cancellation"));
|
|
const prepared = await prepareHopperMachOImage(
|
|
path,
|
|
target.sha256,
|
|
image,
|
|
join(directory, "image.macho"),
|
|
{ resourceScope: new ArtifactResourceScope(), signal: controller.signal },
|
|
);
|
|
expect(prepared).toMatchObject({
|
|
ok: false,
|
|
error: { _tag: "HopperCancelledError" },
|
|
});
|
|
});
|
|
|
|
it("retains an owned partial thin image and exact cancellation reason after a write", async () => {
|
|
const directory = await createTestTempDirectory("rea-hopper-thin-partial-");
|
|
const bytes = Buffer.alloc(2 * 1024 * 1024, 0x5a);
|
|
bytes.writeUInt32BE(0xfeedfacf, 0);
|
|
bytes.writeUInt32BE(CPU_TYPES.arm64, 4);
|
|
bytes.writeUInt32BE(ARM64E_WITH_CAPABILITIES, 8);
|
|
const path = join(directory, "source.macho");
|
|
await writeFile(path, bytes);
|
|
const target = thinTarget(path, bytes);
|
|
const image = requireResolved(await resolveHopperMachOImage(target));
|
|
const sessionDirectory = join(directory, "session");
|
|
await mkdir(sessionDirectory);
|
|
const destination = join(sessionDirectory, "image.macho");
|
|
const controller = new AbortController();
|
|
const reason = { owner: "caller", request: 44 };
|
|
let releaseWrite: (() => void) | undefined;
|
|
let announceWrite: ((copiedBytes: number) => void) | undefined;
|
|
const writeGate = new Promise<void>((resolve) => {
|
|
releaseWrite = resolve;
|
|
});
|
|
const firstWrite = new Promise<number>((resolve) => {
|
|
announceWrite = resolve;
|
|
});
|
|
const preparing = prepareHopperMachOImage(
|
|
path,
|
|
target.sha256,
|
|
image,
|
|
destination,
|
|
{
|
|
resourceScope: new ArtifactResourceScope(),
|
|
signal: controller.signal,
|
|
observer: {
|
|
afterChunkWritten: async (copiedBytes) => {
|
|
announceWrite?.(copiedBytes);
|
|
await writeGate;
|
|
},
|
|
},
|
|
},
|
|
);
|
|
|
|
const copiedBytes = await firstWrite;
|
|
const partial = await readFile(destination);
|
|
expect(partial.byteLength).toBe(copiedBytes);
|
|
expect(copiedBytes).toBeGreaterThan(0);
|
|
expect(copiedBytes).toBeLessThan(bytes.length);
|
|
controller.abort(reason);
|
|
const finishWrite = releaseWrite;
|
|
if (finishWrite === undefined)
|
|
throw new Error("Copy barrier was not created");
|
|
finishWrite();
|
|
|
|
const prepared = await preparing;
|
|
expect(prepared.ok).toBe(false);
|
|
if (prepared.ok) throw new Error("Cancelled partial copy was accepted");
|
|
expect(prepared.error._tag).toBe("HopperCancelledError");
|
|
expect(prepared.error.cause).toBe(reason);
|
|
await expect(readFile(destination)).resolves.toEqual(partial);
|
|
await expect(access(destination)).resolves.toBeUndefined();
|
|
});
|