Files
rea/src/hopper/HopperMachOImage.ts
T

243 lines
9.4 KiB
TypeScript

import { createHash } from "node:crypto";
import { open, type FileHandle } from "node:fs/promises";
import { z } from "zod";
import type { BinaryTarget } from "../domain/binaryTarget.js";
import { BinaryTargetError } from "../domain/configurationErrors.js";
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
import {
readFatSliceDeclarations,
validateFatSlice,
} from "../domain/apple/machoContainer.js";
import {
HopperCancelledError,
HopperStartError,
} from "../domain/hopperErrors.js";
import { err, ok, type Result } from "../domain/result.js";
/** Stable source coordinates committed before preparing an owned thin image. */
export const hopperMachOImageSchema = z.strictObject({
method: z.literal("fat64_thin_slice"),
architecture_count: z.number().int().min(1).max(128),
container_table_sha256: z.string().regex(/^[a-f0-9]{64}$/u),
source_offset: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER),
byte_length: z.number().int().positive().max(Number.MAX_SAFE_INTEGER),
cpu_type: z.number().int().nonnegative(),
cpu_subtype: z.number().int().nonnegative(),
});
export type HopperMachOImage = z.infer<typeof hopperMachOImageSchema>;
/** Validate the selected FAT64 slice without depending on Apple's lipo tool. */
export const resolveHopperMachOImage = async (
target: BinaryTarget & { readonly kind: "executable" },
signal?: AbortSignal,
): Promise<
Result<HopperMachOImage, BinaryTargetError | AnalysisCancelledError>
> => {
const invalid = (reason: string) =>
err(new BinaryTargetError(target.path, reason));
if (signal?.aborted) return err(new AnalysisCancelledError("open_binary"));
let file: FileHandle;
try {
file = await open(target.path, "r");
} catch (cause: unknown) {
return invalid(
`Cannot read FAT64 source for Hopper image preparation: ${cause instanceof Error ? cause.message : String(cause)}`,
);
}
try {
const read = async (offset: number, length: number): Promise<Buffer> => {
const bytes = Buffer.alloc(length);
const { bytesRead } = await file.read(bytes, 0, length, offset);
return bytes.subarray(0, bytesRead);
};
const header = await read(0, 8);
const magic = header.length === 8 ? header.readUInt32BE(0) : 0;
if (magic !== 0xcafebabf && magic !== 0xbfbafeca)
return invalid(
"FAT64 source header changed before Hopper image preparation",
);
const littleEndian = magic === 0xbfbafeca;
const count = littleEndian
? header.readUInt32LE(4)
: header.readUInt32BE(4);
const size = (await file.stat()).size;
const tableEnd = 8 + count * 32;
if (count === 0 || count > 128 || tableEnd > size)
return invalid(
"FAT64 architecture table is truncated or exceeds the supported 128 entries",
);
const table = await read(8, count * 32);
const parsed = readFatSliceDeclarations(table, {
count,
wide: true,
littleEndian,
});
if (parsed.status === "malformed") return invalid(parsed.reason);
const cpuTypes = { x86: 7, x86_64: 0x1000007, arm: 12, arm64: 0x100000c };
const matches = [];
const ranges = [];
for (const slice of parsed.slices) {
if (signal?.aborted)
return err(new AnalysisCancelledError("open_binary"));
const rangeError = validateFatSlice(slice, size, tableEnd);
if (rangeError !== null) return invalid(rangeError);
const reserved = littleEndian
? table.readUInt32LE(slice.index * 32 + 28)
: table.readUInt32BE(slice.index * 32 + 28);
if (reserved !== 0)
return invalid(
`FAT64 architecture ${slice.index} has a nonzero reserved field`,
);
if (slice.size < 12)
return invalid(
`FAT64 architecture ${slice.index} has a truncated Mach-O header`,
);
const embedded = await read(slice.offset, 12);
if (embedded.length !== 12)
return invalid(`FAT64 architecture ${slice.index} became truncated`);
const thinMagic = embedded.readUInt32BE(0);
if (![0xfeedface, 0xfeedfacf, 0xcefaedfe, 0xcffaedfe].includes(thinMagic))
return invalid(
`FAT64 architecture ${slice.index} does not contain a Mach-O image`,
);
const little = thinMagic === 0xcefaedfe || thinMagic === 0xcffaedfe;
const identity = {
cpuType: little ? embedded.readUInt32LE(4) : embedded.readUInt32BE(4),
cpuSubtype: little
? embedded.readUInt32LE(8)
: embedded.readUInt32BE(8),
};
const identityError = validateFatSlice(slice, size, tableEnd, identity);
if (identityError !== null) return invalid(identityError);
ranges.push(slice);
if (slice.cpuType === cpuTypes[target.architecture]) matches.push(slice);
}
ranges.sort((a, b) => a.offset - b.offset);
for (let index = 1; index < ranges.length; index++) {
const previous = ranges[index - 1];
const current = ranges[index];
if (
previous !== undefined &&
current !== undefined &&
current.offset < previous.offset + previous.size
)
return invalid("FAT64 slices overlap");
}
const selected = matches.length === 1 ? matches[0] : undefined;
if (selected === undefined)
return invalid(
`FAT64 selection for ${target.architecture} is ${matches.length === 0 ? "missing" : "ambiguous"}; extract the exact architecture into a thin image before opening it`,
);
return ok({
method: "fat64_thin_slice",
architecture_count: count,
container_table_sha256: createHash("sha256")
.update(header)
.update(table)
.digest("hex"),
source_offset: selected.offset,
byte_length: selected.size,
cpu_type: selected.cpuType,
cpu_subtype: selected.cpuSubtype,
});
} catch (cause: unknown) {
return invalid(
`Cannot validate FAT64 image: ${cause instanceof Error ? cause.message : String(cause)}`,
);
} finally {
await file.close();
}
};
/** Copy a selected slice and verify the entire original source in the same pass. */
export const prepareHopperMachOImage = async (
sourcePath: string,
sourceSha256: string,
image: HopperMachOImage,
destination: string,
signal?: AbortSignal,
): Promise<Result<string, HopperStartError | HopperCancelledError>> => {
if (signal?.aborted) return err(new HopperCancelledError());
try {
const source = await open(sourcePath, "r");
try {
const output = await open(destination, "wx", 0o600);
try {
const hash = createHash("sha256");
const tableHash = createHash("sha256");
const tableEnd = 8 + image.architecture_count * 32;
const thinHeader: Buffer[] = [];
let position = 0;
let copied = 0;
for await (const chunk of source.createReadStream({
autoClose: false,
})) {
if (signal?.aborted) return err(new HopperCancelledError());
const bytes = Buffer.from(chunk);
hash.update(bytes);
if (position < tableEnd)
tableHash.update(
bytes.subarray(0, Math.min(bytes.length, tableEnd - position)),
);
const start = Math.max(0, image.source_offset - position);
const end = Math.min(
bytes.length,
image.source_offset + image.byte_length - position,
);
if (end > start) {
if (copied < 12)
thinHeader.push(
bytes.subarray(start, Math.min(end, start + 12 - copied)),
);
await output.writeFile(bytes.subarray(start, end));
copied += end - start;
}
position += bytes.length;
}
const observed = hash.digest("hex");
const header = Buffer.concat(thinHeader);
const magic = header.length === 12 ? header.readUInt32BE(0) : 0;
const little = magic === 0xcefaedfe || magic === 0xcffaedfe;
const headerMatches =
header.length === 12 &&
[0xfeedface, 0xfeedfacf, 0xcefaedfe, 0xcffaedfe].includes(magic) &&
(little ? header.readUInt32LE(4) : header.readUInt32BE(4)) ===
image.cpu_type &&
(little ? header.readUInt32LE(8) : header.readUInt32BE(8)) ===
image.cpu_subtype;
if (
tableHash.digest("hex") !== image.container_table_sha256 ||
!headerMatches
)
return err(
new HopperStartError({
userMessage: `FAT64 source ${sourcePath} no longer agrees with its committed architecture table or selected Mach-O header. Open the stable source again.`,
}),
);
if (observed !== sourceSha256 || copied !== image.byte_length)
return err(
new HopperStartError({
userMessage: `FAT64 source ${sourcePath} changed before launch: expected SHA-256 ${sourceSha256}, observed ${observed}; copied ${copied} of ${image.byte_length} selected bytes. Open the stable source again.`,
}),
);
return ok(destination);
} finally {
await output.close();
}
} finally {
await source.close();
}
} catch (cause: unknown) {
return signal?.aborted
? err(new HopperCancelledError())
: err(
new HopperStartError({
cause,
userMessage: `Cannot prepare the selected FAT64 image from ${sourcePath}: ${cause instanceof Error ? cause.message : String(cause)}`,
}),
);
}
};