fix(macho): validate FAT slice format and CPU identity

Use observed thin-header facts before target architecture admission, reject archive payloads, and retain far-offset root inventory through bounded reads. Retry complete bounded FAT table reads.

Refs #5462, #5542. Focused native/filesystem checks passed (245 plus 70 after the table retry). Public MCP inventory parity and unchanged token estimates verified; no external analysis engine execution claimed.
This commit is contained in:
morluto
2026-10-20 22:18:03 +08:00
parent 25d3805e7f
commit 497037e6e3
12 changed files with 1003 additions and 71 deletions
+82 -19
View File
@@ -53,6 +53,12 @@ import {
peRvaToFileOffset,
type ExecutableMetadata,
} from "../domain/binaryTarget.js";
import {
readFatContainerHeader,
readFatSliceHeaderFacts,
type FatSliceHeaderEvidence,
type FatSliceHeaderFacts,
} from "../domain/apple/machoContainer.js";
import type { PeCodeViewRecord } from "../domain/binaryTargetTypes.js";
import { absoluteCallerPath } from "../filesystem/callerPath.js";
@@ -532,25 +538,19 @@ const readExecutableMetadata = async (
signatureBytes,
);
}
if (bytes.length >= 8) {
const magic = bytes.readUInt32BE(0);
if ([0xcafebabf, 0xbfbafeca].includes(magic)) {
const little = magic === 0xbfbafeca;
const count = little ? bytes.readUInt32LE(4) : bytes.readUInt32BE(4);
const required = 8 + count * 32;
if (count <= 128 && required > bytes.length) {
const header = Buffer.alloc(required);
const headerRead = await handle.read(header, 0, header.length, 0);
throwIfTargetResolutionCancelled(signal);
return parseExecutableHeader(
header.subarray(0, headerRead.bytesRead),
hostArchitecture,
fileSize,
);
}
}
}
const parsed = parseExecutableHeader(bytes, hostArchitecture, fileSize);
const fatEvidence = await readFatSliceHeaderEvidence(
handle,
bytes,
fileSize,
signal,
);
const parsed = parseExecutableHeader(
bytes,
hostArchitecture,
fileSize,
undefined,
fatEvidence,
);
if (
!parsed.ok ||
parsed.value.architecture !== "mips" ||
@@ -579,6 +579,69 @@ const readExecutableMetadata = async (
});
};
/** Read the validated FAT table and bounded thin-header identity prefixes. */
const readFatSliceHeaderEvidence = async (
handle: FileHandle,
prefix: Buffer,
fileSize: number,
signal?: AbortSignal,
): Promise<FatSliceHeaderEvidence | undefined> => {
let header = readFatContainerHeader(prefix, fileSize);
if (header.state === "needs-data") {
// readFatContainerHeader enforces the 128-entry format bound before this
// allocation; this is only the table, never a synthesized file image.
const table = Buffer.alloc(header.requiredBytes);
let bytesRead = 0;
while (bytesRead < table.length) {
throwIfTargetResolutionCancelled(signal);
const read = await handle.read(
table,
bytesRead,
table.length - bytesRead,
bytesRead,
);
throwIfTargetResolutionCancelled(signal);
if (read.bytesRead === 0) break;
bytesRead += read.bytesRead;
}
header = readFatContainerHeader(table.subarray(0, bytesRead), fileSize);
}
if (header.state !== "parsed") return undefined;
const sliceHeadersByOffset = new Map<number, FatSliceHeaderFacts>();
for (let index = 0; index < header.slices.length; index += 1) {
const slice = header.slices[index];
if (slice === undefined) continue;
if (header.rangeIssues[index] !== null) {
sliceHeadersByOffset.set(
slice.offset,
readFatSliceHeaderFacts(Buffer.alloc(0)),
);
continue;
}
const observedLength = Math.min(12, slice.size);
const thinHeader = Buffer.alloc(observedLength);
let bytesRead = 0;
while (bytesRead < thinHeader.length) {
throwIfTargetResolutionCancelled(signal);
const read = await handle.read(
thinHeader,
bytesRead,
thinHeader.length - bytesRead,
slice.offset + bytesRead,
);
throwIfTargetResolutionCancelled(signal);
if (read.bytesRead === 0) break;
bytesRead += read.bytesRead;
}
sliceHeadersByOffset.set(
slice.offset,
readFatSliceHeaderFacts(thinHeader.subarray(0, bytesRead)),
);
}
return { header, sliceHeadersByOffset };
};
const readPeMetadata = async (
handle: FileHandle,
offset: number,
+124 -9
View File
@@ -6,16 +6,104 @@ import {
createXmlPlistSignatureProbe,
MAX_XML_PLIST_PROBE_BYTES,
} from "../domain/apple/appleArchiveFormat.js";
import {
readFatContainerHeader,
readFatContainerTable,
readFatSliceHeaderFacts,
type FatContainerTable,
type FatSliceHeaderFacts,
type FatSliceHeaderEvidence,
} from "../domain/apple/machoContainer.js";
/** Initial bytes required by the other bounded format classifiers. */
export const ARTIFACT_CLASSIFICATION_PREFIX_BYTES = 8_192;
/** Retained bytes and independently parsed ASAR signature facts. */
/** Retained prefix plus bounded format-specific observations from the stream. */
export interface ArtifactClassificationEvidence {
readonly prefix: Buffer;
readonly asarHeader: FinalAsarHeaderProbeObservation;
readonly fatSliceHeaders?: FatSliceHeaderEvidence;
}
const observeFatSliceHeaders = (input: {
readonly table: FatContainerTable | undefined;
readonly observedHeaders: Map<number, FatSliceHeaderFacts>;
readonly partialHeaders: Map<
number,
{ readonly bytes: Buffer; count: number }
>;
readonly streamOffset: number;
readonly bytes: Uint8Array;
}): void => {
const { table, observedHeaders, partialHeaders, streamOffset, bytes } = input;
if (table?.state !== "parsed") return;
for (const slice of table.slices) {
if (observedHeaders.has(slice.offset) && !partialHeaders.has(slice.offset))
continue;
const headerLength = Math.min(12, slice.size);
const headerEnd = slice.offset + headerLength;
if (!Number.isSafeInteger(headerEnd)) continue;
const start = Math.max(slice.offset, streamOffset);
const end = Math.min(headerEnd, streamOffset + bytes.length);
if (start >= end) continue;
let pending = partialHeaders.get(slice.offset);
if (pending === undefined) {
pending = { bytes: Buffer.alloc(12), count: 0 };
partialHeaders.set(slice.offset, pending);
}
Buffer.from(bytes.subarray(start - streamOffset, end - streamOffset)).copy(
pending.bytes,
start - slice.offset,
);
pending.count += end - start;
observedHeaders.set(
slice.offset,
readFatSliceHeaderFacts(
pending.bytes.subarray(0, Math.min(pending.count, headerLength)),
),
);
if (pending.count === headerLength) {
partialHeaders.delete(slice.offset);
}
}
};
const finishClassificationEvidence = (
prefix: Buffer,
archiveBytes: number,
finishAsar: (archiveBytes: number) => FinalAsarHeaderProbeObservation,
sliceHeadersByOffset: Map<number, FatSliceHeaderFacts>,
): ArtifactClassificationEvidence => {
const finalHeader = readFatContainerHeader(prefix, archiveBytes);
if (finalHeader.state === "parsed") {
for (let index = 0; index < finalHeader.slices.length; index += 1) {
const slice = finalHeader.slices[index];
if (slice === undefined || sliceHeadersByOffset.has(slice.offset))
continue;
const available = Math.max(
0,
Math.min(slice.size, prefix.length - slice.offset),
);
if (available === 0) continue;
sliceHeadersByOffset.set(
slice.offset,
readFatSliceHeaderFacts(
prefix.subarray(slice.offset, slice.offset + available),
),
);
}
}
const fatSliceHeaders =
finalHeader.state === "parsed"
? { header: finalHeader, sliceHeadersByOffset }
: undefined;
return {
prefix,
asarHeader: finishAsar(archiveBytes),
...(fatSliceHeaders === undefined ? {} : { fatSliceHeaders }),
};
};
/** Share acquisition bounds and signature observations across files and streams. */
export const createArtifactClassificationProbe = () => {
const xml = createXmlPlistSignatureProbe();
@@ -23,6 +111,12 @@ export const createArtifactClassificationProbe = () => {
const blocks: Buffer[] = [];
let retainedBytes = 0;
let observedBytes = 0;
let fatTable: FatContainerTable | undefined;
const fatSliceHeadersByOffset = new Map<number, FatSliceHeaderFacts>();
const partialFatMagics = new Map<
number,
{ readonly bytes: Buffer; count: number }
>();
const retain = (bytes: Uint8Array): void => {
let offset = 0;
while (offset < bytes.length) {
@@ -41,6 +135,16 @@ export const createArtifactClassificationProbe = () => {
offset += count;
}
};
const retainedPrefix = (): Buffer => {
const prefix = Buffer.allocUnsafe(retainedBytes);
let copied = 0;
for (const block of blocks) {
const count = Math.min(block.length, retainedBytes - copied);
prefix.set(block.subarray(0, count), copied);
copied += count;
}
return prefix;
};
return {
get length(): number {
return observedBytes;
@@ -55,6 +159,7 @@ export const createArtifactClassificationProbe = () => {
);
},
write(bytes: Uint8Array): void {
const streamOffset = observedBytes;
asar.write(bytes);
let offset = 0;
while (offset < bytes.length) {
@@ -73,17 +178,27 @@ export const createArtifactClassificationProbe = () => {
xml.write(selected);
offset += selected.length;
}
if (
(fatTable === undefined || fatTable.state === "needs-data") &&
retainedBytes >= 8
)
fatTable = readFatContainerTable(retainedPrefix());
observeFatSliceHeaders({
table: fatTable,
observedHeaders: fatSliceHeadersByOffset,
partialHeaders: partialFatMagics,
streamOffset,
bytes,
});
observedBytes += bytes.length;
},
finish(archiveBytes: number): ArtifactClassificationEvidence {
const prefix = Buffer.allocUnsafe(retainedBytes);
let copied = 0;
for (const block of blocks) {
const count = Math.min(block.length, retainedBytes - copied);
prefix.set(block.subarray(0, count), copied);
copied += count;
}
return { prefix, asarHeader: asar.finish(archiveBytes) };
return finishClassificationEvidence(
retainedPrefix(),
archiveBytes,
(bytes) => asar.finish(bytes),
fatSliceHeadersByOffset,
);
},
};
};
+19
View File
@@ -188,6 +188,25 @@ export const fatImage = (
return bytes;
};
/** A minimal BSD ar archive containing one inert member for container tests. */
export const arArchive = (memberName = "fixture.o"): Uint8Array => {
const name = `${memberName}/`.slice(0, 16).padEnd(16, " ");
const header = [
name,
"0 ",
"0 ",
"0 ",
"100644 ",
"1 ",
"`\n",
].join("");
const bytes = new Uint8Array(8 + 60 + 2);
bytes.set(encoder.encode("!<arch>\n"), 0);
bytes.set(encoder.encode(header), 8);
bytes[68] = 0x78;
return bytes;
};
/** In-memory `ReadAt` over one buffer. */
export const readerOf =
(bytes: Uint8Array) =>
+235
View File
@@ -0,0 +1,235 @@
import { describe, expect, it } from "vitest";
import {
CPU,
arArchive,
fatImage,
machoImage,
readerOf,
} from "./MachoImage.fixture.js";
import { readMachoImage } from "./MachoLoadCommandReader.js";
import { javaClassFile } from "../../../tests/fixtures/javaClassFile.js";
import { parseExecutableHeader } from "../../domain/binaryTarget.js";
import {
classifyFatContainerIdentity,
readFatContainerHeader,
readFatSliceHeaderFacts,
} from "../../domain/apple/machoContainer.js";
const classify = (bytes: Uint8Array) => {
const header = readFatContainerHeader(bytes, bytes.byteLength);
if (header.state !== "parsed") return header;
return classifyFatContainerIdentity(header, (offset) => {
if (offset + 4 > bytes.byteLength) return undefined;
return new DataView(
bytes.buffer,
bytes.byteOffset,
bytes.byteLength,
).getUint32(offset, false);
});
};
describe("FAT Mach-O identity", () => {
it("does not treat known CPU declarations as proof when slices are ar archives", async () => {
const archive = arArchive();
const bytes = fatImage([
{ cpu: CPU.x86_64, bytes: archive },
{ cpu: CPU.arm64, bytes: archive },
]);
expect(classify(bytes)).toEqual({ state: "not-mach-o" });
expect(await readMachoImage(readerOf(bytes), bytes.byteLength)).toEqual({
status: "not-mach-o",
});
expect(parseExecutableHeader(Buffer.from(bytes), "arm64")).toEqual({
ok: false,
error: "FAT table does not identify a Mach-O image",
});
});
it("rejects a mixed FAT container instead of selecting an ar slice as Mach-O", async () => {
const bytes = fatImage([
{
cpu: CPU.x86_64,
bytes: machoImage({ cpu: CPU.x86_64 }),
},
{ cpu: CPU.arm64, bytes: arArchive() },
]);
expect(classify(bytes)).toEqual({ state: "not-mach-o" });
expect(await readMachoImage(readerOf(bytes), bytes.byteLength)).toEqual({
status: "not-mach-o",
});
expect(parseExecutableHeader(Buffer.from(bytes), "arm64")).toEqual({
ok: false,
error: "FAT table does not identify a Mach-O image",
});
});
it("confirms universal and thin Mach-O controls from their image headers", async () => {
const slice = machoImage({ cpu: CPU.arm64 });
const universal = fatImage([{ cpu: CPU.arm64, bytes: slice }]);
expect(classify(universal)).toMatchObject({ state: "mach-o" });
expect(
await readMachoImage(readerOf(universal), universal.byteLength),
).toMatchObject({
status: "parsed",
});
expect(
parseExecutableHeader(Buffer.from(universal), "arm64"),
).toMatchObject({
ok: true,
value: { format: "mach-o-universal", architecture: "arm64" },
});
expect(
await readMachoImage(readerOf(slice), slice.byteLength),
).toMatchObject({
status: "parsed",
});
expect(parseExecutableHeader(Buffer.from(slice), "arm64")).toMatchObject({
ok: true,
value: { format: "mach-o", architecture: "arm64" },
});
const undeclaredCpu = { type: 0x12345678, subtype: 0 };
const unlistedCpuImage = fatImage([
{ cpu: undeclaredCpu, bytes: machoImage({ cpu: undeclaredCpu }) },
]);
expect(classify(unlistedCpuImage)).toMatchObject({ state: "mach-o" });
});
it("parses CPU identity from the actual thin-header byte order", () => {
const littleEndian = machoImage({ cpu: CPU.arm64 });
const bigEndian = Buffer.alloc(12);
bigEndian.writeUInt32BE(0xfeedfacf, 0);
bigEndian.writeUInt32BE(CPU.x86_64.type, 4);
bigEndian.writeUInt32BE(CPU.x86_64.subtype, 8);
expect(readFatSliceHeaderFacts(littleEndian.subarray(0, 12))).toEqual({
magic: 0xcffaedfe,
identity: {
cpuType: CPU.arm64.type,
cpuSubtype: CPU.arm64.subtype,
},
});
expect(readFatSliceHeaderFacts(bigEndian)).toEqual({
magic: 0xfeedfacf,
identity: {
cpuType: CPU.x86_64.type,
cpuSubtype: CPU.x86_64.subtype,
},
});
expect(readFatSliceHeaderFacts(littleEndian.subarray(0, 11))).toEqual({
magic: 0xcffaedfe,
identity: null,
});
expect(readFatSliceHeaderFacts(littleEndian.subarray(0, 3))).toEqual({
magic: null,
identity: null,
});
});
it.each([
{ wrongIndex: 0, label: "selected host slice" },
{ wrongIndex: 1, label: "non-host slice" },
])(
"rejects table CPU claims that disagree with the $label header",
({ wrongIndex }) => {
const bytes = fatImage([
{ cpu: CPU.x86_64, bytes: machoImage({ cpu: CPU.x86_64 }) },
{ cpu: CPU.arm64, bytes: machoImage({ cpu: CPU.arm64 }) },
]);
const entry = 8 + wrongIndex * 20;
// Change only the FAT claim; both embedded thin headers remain intact.
new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).setUint32(
entry,
wrongIndex === 0 ? CPU.arm64.type : CPU.x86_64.type,
false,
);
const result = parseExecutableHeader(Buffer.from(bytes), "arm64");
expect(result).toEqual({
ok: false,
error: `FAT architecture ${wrongIndex} CPU type/subtype disagrees with its Mach-O header`,
});
},
);
});
describe("FAT slice header identity and bounds", () => {
it("keeps Java CAFEBABE class files outside FAT and Mach-O identity", async () => {
const bytes = javaClassFile();
expect(readFatContainerHeader(bytes, bytes.byteLength)).toEqual({
state: "not-fat",
});
expect(await readMachoImage(readerOf(bytes), bytes.byteLength)).toEqual({
status: "not-mach-o",
});
expect(parseExecutableHeader(Buffer.from(bytes), "arm64")).toEqual({
ok: false,
error: "unsupported binary format",
});
});
it("uses the shared exact subtype identity rule, including capability bits", () => {
const bytes = fatImage([
{ cpu: CPU.arm64e, bytes: machoImage({ cpu: CPU.arm64e }) },
]);
// The FAT table drops the subtype capability bits while the thin header
// retains them. Admission follows validateFatSliceIdentity's exact rule.
new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).setUint32(
12,
CPU.arm64e.subtype & 0x00ffffff,
false,
);
expect(parseExecutableHeader(Buffer.from(bytes), "arm64")).toEqual({
ok: false,
error:
"FAT architecture 0 CPU type/subtype disagrees with its Mach-O header",
});
});
it("keeps unresolved headers unknown and preserves declared-range failures", () => {
const bytes = Buffer.alloc(28);
bytes.writeUInt32BE(0xcafebabe, 0);
bytes.writeUInt32BE(1, 4);
bytes.writeUInt32BE(CPU.arm64.type, 8);
bytes.writeUInt32BE(4096, 16);
bytes.writeUInt32BE(32, 20);
bytes.writeUInt32BE(12, 24);
const header = readFatContainerHeader(bytes, 4128);
if (header.state !== "parsed") throw new Error("expected parsed FAT table");
expect(classifyFatContainerIdentity(header, () => undefined)).toEqual({
state: "needs-slice-header",
offsets: [4096],
});
const badRange = {
...header,
rangeIssues: [
{ category: "malformed" as const, reason: "bad test range" },
],
};
expect(classifyFatContainerIdentity(badRange, () => null)).toEqual({
state: "malformed",
category: "malformed",
reason: "bad test range",
});
});
it("classifies a valid but too-short slice without inventing header bytes", () => {
const bytes = Buffer.alloc(31);
bytes.writeUInt32BE(0xcafebabe, 0);
bytes.writeUInt32BE(1, 4);
bytes.writeUInt32BE(CPU.arm64.type, 8);
bytes.writeUInt32BE(28, 16);
bytes.writeUInt32BE(3, 20);
bytes.set([0xcf, 0xfa, 0xed], 28);
const header = readFatContainerHeader(bytes, bytes.length);
if (header.state !== "parsed") throw new Error("expected parsed FAT table");
expect(
classifyFatContainerIdentity(header, () => {
throw new Error("a three-byte slice cannot provide a four-byte magic");
}),
).toEqual({ state: "not-mach-o" });
});
});
@@ -1,12 +1,19 @@
import { describe, expect, it } from "vitest";
import { CPU, fatImage, machoImage } from "../apple/MachoImage.fixture.js";
import {
CPU,
arArchive,
fatImage,
machoImage,
} from "../apple/MachoImage.fixture.js";
import {
createArtifactEdges,
classifyArtifactContent,
createOccurrence,
indexOccurrencesByPath,
resolveOccurrenceParents,
} from "./ArtifactGraphConstruction.js";
import { createArtifactClassificationProbe } from "../ArtifactClassificationProbe.js";
import { artifactIdForContent } from "../../domain/artifactIdentity.js";
import type { ArtifactEntry } from "../ArtifactReader.js";
@@ -164,8 +171,49 @@ describe("source-map artifact relationships", () => {
const fat = (wide = false): Buffer =>
Buffer.from(fatImage([{ cpu: CPU.arm64, bytes: machoImage({}) }], wide));
const expectStreamedFatClassification = (testCase: {
readonly slices: readonly {
readonly cpu: { readonly type: number; readonly subtype: number };
readonly bytes: Uint8Array;
}[];
readonly expected: string;
}): void => {
const bytes = Buffer.alloc(24_128);
const offsets = testCase.slices.map((_, index) => 20_000 + index * 4_000);
bytes.writeUInt32BE(0xcafebabe, 0);
bytes.writeUInt32BE(testCase.slices.length, 4);
testCase.slices.forEach((slice, index) => {
const entry = 8 + index * 20;
const sliceOffset = offsets[index]!;
bytes.writeUInt32BE(slice.cpu.type, entry);
bytes.writeUInt32BE(slice.cpu.subtype, entry + 4);
bytes.writeUInt32BE(sliceOffset, entry + 8);
bytes.writeUInt32BE(slice.bytes.length, entry + 12);
bytes.writeUInt32BE(0, entry + 16);
Buffer.from(slice.bytes).copy(bytes, sliceOffset);
});
const probe = createArtifactClassificationProbe();
const boundaries = [
8_192,
...offsets.flatMap((offset) => [offset + 3, offset + 4]),
bytes.length,
].sort((left, right) => left - right);
let previous = 0;
for (const boundary of boundaries) {
if (boundary <= previous) continue;
probe.write(bytes.subarray(previous, boundary));
previous = boundary;
}
const evidence = probe.finish(bytes.length);
expect(evidence.prefix.length).toBeLessThan(bytes.length);
expect(classifyArtifactContent("native", evidence, bytes.length).format).toBe(
testCase.expected,
);
};
describe("bounded FAT inventory classification", () => {
it("requires the complete table but not slice bytes in the retained prefix", () => {
it("requires the complete table and an observed slice header", () => {
const bytes = fat();
for (const length of [4, 7, 8, 27]) {
expect(
@@ -182,7 +230,25 @@ describe("bounded FAT inventory classification", () => {
bytes.subarray(0, 28),
bytes.length,
),
).toEqual({ kind: "executable", format: "mach-o-universal" });
).toEqual({ kind: "unknown", format: "unknown" });
expect(classifyArtifactBytesAtPath("native.class", bytes)).toEqual({
kind: "executable",
format: "mach-o-universal",
});
});
it("does not classify known-CPU FAT archive slices as Mach-O", () => {
const archive = arArchive();
const bytes = Buffer.from(
fatImage([
{ cpu: CPU.x86_64, bytes: archive },
{ cpu: CPU.arm64, bytes: archive },
]),
);
expect(classifyArtifactBytesAtPath("libfat.a", bytes)).toEqual({
kind: "unknown",
format: "unknown",
});
});
it.each([
@@ -252,6 +318,65 @@ describe("bounded FAT inventory classification", () => {
});
});
it("classifies distant FAT headers from bounded streaming observations", () => {
const cases = [
{
name: "Mach-O",
slices: [{ cpu: CPU.arm64, bytes: machoImage({}) }],
expected: "mach-o-universal",
},
{
name: "archive",
slices: [{ cpu: CPU.arm64, bytes: arArchive() }],
expected: "unknown",
},
{
name: "mixed Mach-O and archive",
slices: [
{ cpu: CPU.x86_64, bytes: machoImage({ cpu: CPU.x86_64 }) },
{ cpu: CPU.arm64, bytes: arArchive() },
],
expected: "unknown",
},
] as const;
for (const testCase of cases) expectStreamedFatClassification(testCase);
});
});
describe("FAT prefix observation coverage", () => {
it("does not invent an observation for a header beyond the retained prefix", () => {
const bytes = Buffer.alloc(8_224);
bytes.writeUInt32BE(0xcafebabe, 0);
bytes.writeUInt32BE(2, 4);
for (const [index, slice] of [
{ cpu: CPU.x86_64, offset: 4_096 },
{ cpu: CPU.arm64, offset: 8_192 },
].entries()) {
const entry = 8 + index * 20;
bytes.writeUInt32BE(slice.cpu.type, entry);
bytes.writeUInt32BE(slice.cpu.subtype, entry + 4);
bytes.writeUInt32BE(slice.offset, entry + 8);
bytes.writeUInt32BE(32, entry + 12);
bytes.writeUInt32BE(12, entry + 16);
bytes.writeUInt32LE(0xfeedfacf, slice.offset);
bytes.writeUInt32LE(slice.cpu.type, slice.offset + 4);
bytes.writeUInt32LE(slice.cpu.subtype, slice.offset + 8);
}
const probe = createArtifactClassificationProbe();
probe.write(bytes.subarray(0, 8_192));
const evidence = probe.finish(bytes.length);
expect(
evidence.fatSliceHeaders?.sliceHeadersByOffset.get(4_096)?.magic,
).toBe(0xcffaedfe);
expect(evidence.fatSliceHeaders?.sliceHeadersByOffset.has(8_192)).toBe(
false,
);
});
});
describe("bounded FAT inventory classification", () => {
it.each([NaN, Infinity, -1, 8.5, Number.MAX_SAFE_INTEGER + 1, 27])(
"keeps invalid or contradictory file size %s unknown",
(size) => {
@@ -33,6 +33,7 @@ import {
import {
classifyFatContainerIdentity,
readFatContainerHeader,
readFatSliceHeaderFacts,
} from "../../domain/apple/machoContainer.js";
/** Mutable internal occurrence used until root-bound IDs are known. */
@@ -191,6 +192,7 @@ export const createRootNode = (input: {
readonly sha256: string;
readonly bytes: number;
readonly prefix: Buffer;
readonly fatSliceHeaders?: ArtifactClassificationEvidence["fatSliceHeaders"];
} | null;
readonly occurrences: readonly MutableOccurrence[];
}): ArtifactNode =>
@@ -215,7 +217,11 @@ export const createRootNode = (input: {
format:
input.digest === null
? "directory"
: classifyArtifactBytes(input.digest.prefix, input.digest.bytes),
: classifyArtifactBytes(
input.digest.prefix,
input.digest.bytes,
input.digest.fatSliceHeaders,
),
contentState: "materialized",
});
@@ -498,8 +504,8 @@ export const classifyArtifactContent = (
readonly kind: ArtifactOccurrence["artifact_kind"];
readonly format: ArtifactOccurrence["artifact_format"];
} => {
const { prefix, asarHeader } = evidence;
const format = classifyArtifactBytes(prefix, fileSize);
const { prefix, asarHeader, fatSliceHeaders } = evidence;
const format = classifyArtifactBytes(prefix, fileSize, fatSliceHeaders);
const byPath = classifyArtifactPath(path);
if (
format === "plist" &&
@@ -550,12 +556,13 @@ const artifactRoleForFormat = (
export const classifyArtifactBytes = (
prefix: Buffer,
fileSize = prefix.length,
fatSliceHeaders?: ArtifactClassificationEvidence["fatSliceHeaders"],
): ArtifactNode["format"] => {
if (hasZipSignature(prefix)) return "zip";
if (hasGodotPckHeader(prefix)) return "godot-pck";
if (hasBinaryPlistSignature(prefix)) return "plist";
if (hasXmlPlistSignature(prefix)) return "plist";
const fatFormat = classifyFatArtifact(prefix, fileSize);
const fatFormat = classifyFatArtifact(prefix, fileSize, fatSliceHeaders);
if (fatFormat !== null) return fatFormat;
if (prefix.length >= 4) {
const magic = prefix.readUInt32BE(0);
@@ -592,15 +599,26 @@ export const classifyArtifactBytes = (
const classifyFatArtifact = (
prefix: Buffer,
fileSize: number,
fatSliceHeaders?: ArtifactClassificationEvidence["fatSliceHeaders"],
): ArtifactNode["format"] | null => {
const fatHeader = readFatContainerHeader(prefix, fileSize);
const fatHeader =
fatSliceHeaders?.header ?? readFatContainerHeader(prefix, fileSize);
if (fatHeader.state === "needs-data" || fatHeader.state === "malformed")
return "unknown";
if (fatHeader.state === "not-fat") return null;
const identity = classifyFatContainerIdentity(fatHeader, (offset) => {
if (offset >= prefix.length) return undefined;
if (offset + 4 > prefix.length) return null;
return prefix.readUInt32BE(offset);
if (fatSliceHeaders?.sliceHeadersByOffset.has(offset) === true)
return fatSliceHeaders.sliceHeadersByOffset.get(offset)?.magic;
const slice = fatHeader.slices.find(
(candidate) => candidate.offset === offset,
);
if (slice === undefined || offset >= prefix.length) return undefined;
const available = Math.max(
0,
Math.min(slice.size, prefix.length - offset, 12),
);
return readFatSliceHeaderFacts(prefix.subarray(offset, offset + available))
.magic;
});
return identity.state === "mach-o" ? "mach-o-universal" : "unknown";
};
+58 -1
View File
@@ -23,6 +23,8 @@ import {
openRootArtifact,
} from "./hashStableRootArtifact.js";
import type { ArtifactInventoryOptions } from "./types.js";
import type { FatSliceHeaderEvidence } from "../../domain/apple/machoContainer.js";
import { readFatSliceHeaderFacts } from "../../domain/apple/machoContainer.js";
interface RootClassification {
readonly format: ArtifactOccurrence["artifact_format"];
@@ -171,7 +173,62 @@ const classifyRootFormat = async (
return "dmg";
if (await locateGodotPck(handle, archiveBytes, signal, evidence.prefix))
return "godot-pck";
return classifyArtifactContent(path, evidence, archiveBytes).format;
const fatSliceHeaders = await readRootFatSliceHeaders(
handle,
evidence.fatSliceHeaders,
signal,
);
return classifyArtifactContent(
path,
{
...evidence,
...(fatSliceHeaders === undefined ? {} : { fatSliceHeaders }),
},
archiveBytes,
).format;
};
/** Read exact FAT header windows missed by the bounded prefix probe. */
const readRootFatSliceHeaders = async (
handle: FileHandle,
existing: FatSliceHeaderEvidence | undefined,
signal?: AbortSignal,
): Promise<FatSliceHeaderEvidence | undefined> => {
if (existing === undefined) return undefined;
const { header } = existing;
const sliceHeadersByOffset = new Map(existing.sliceHeadersByOffset);
for (let index = 0; index < header.slices.length; index += 1) {
const slice = header.slices[index];
const observed =
slice === undefined ? undefined : sliceHeadersByOffset.get(slice.offset);
if (
slice === undefined ||
header.rangeIssues[index] !== null ||
slice.size < 4 ||
(observed !== undefined && observed.magic !== null)
)
continue;
const headerLength = Math.min(12, slice.size);
const thinHeader = Buffer.alloc(headerLength);
let bytesRead = 0;
while (bytesRead < thinHeader.length) {
abortIfNeeded(signal);
const read = await handle.read(
thinHeader,
bytesRead,
thinHeader.length - bytesRead,
slice.offset + bytesRead,
);
abortIfNeeded(signal);
if (read.bytesRead === 0) break;
bytesRead += read.bytesRead;
}
sliceHeadersByOffset.set(
slice.offset,
readFatSliceHeaderFacts(thinHeader.subarray(0, bytesRead)),
);
}
return { header, sliceHeadersByOffset };
};
/** App Pack is a verified ZIP root role; nested .app members retain their observed format. */
+102 -24
View File
@@ -34,6 +34,57 @@ export type FatContainerHeader =
readonly rangeIssues: readonly (FatSliceRangeIssue | null)[];
};
/** Exact slice-header observations collected by a complete, bounded reader. */
export interface FatSliceHeaderEvidence {
readonly header: Extract<FatContainerHeader, { readonly state: "parsed" }>;
readonly sliceHeadersByOffset: ReadonlyMap<number, FatSliceHeaderFacts>;
}
/** Parsed observations from the bounded prefix of one declared slice header. */
export interface FatSliceHeaderFacts {
/** Raw format evidence; null means fewer than four bytes were observed. */
readonly magic: number | null;
/** Present only for a recognized Mach-O magic and a complete 12-byte prefix. */
readonly identity: {
readonly cpuType: number;
readonly cpuSubtype: number;
} | null;
}
/** Parse only facts present in the observed prefix of a thin Mach-O header. */
export const readFatSliceHeaderFacts = (
bytes: Uint8Array,
): FatSliceHeaderFacts => {
if (bytes.byteLength < 4) return { magic: null, identity: null };
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const magic = view.getUint32(0, false);
if (bytes.byteLength < 12 || !MACHO_HEADER_MAGICS.includes(magic))
return { magic, identity: null };
const littleEndian = magic === 0xcefaedfe || magic === 0xcffaedfe;
return {
magic,
identity: {
cpuType: view.getUint32(4, littleEndian),
cpuSubtype: view.getUint32(8, littleEndian),
},
};
};
/** FAT declarations decoded before the caller knows the final stream length. */
export type FatContainerTable =
| { readonly state: "not-fat" }
| { readonly state: "needs-data"; readonly requiredBytes: number }
| { readonly state: "malformed"; readonly reason: string }
| {
readonly state: "parsed";
readonly magic: number;
readonly count: number;
readonly wide: boolean;
readonly littleEndian: boolean;
readonly tableEnd: number;
readonly slices: readonly FatSliceDeclaration[];
};
export type FatContainerIdentity =
| { readonly state: "mach-o" }
| { readonly state: "not-mach-o" }
@@ -64,22 +115,39 @@ export const readFatContainerHeader = (
bytes: Uint8Array,
fileSize: number,
): FatContainerHeader => {
if (bytes.byteLength < 4) return { state: "not-fat" };
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const magic = view.getUint32(0, false);
if (![FAT_MAGIC, FAT_MAGIC_64, FAT_CIGAM, FAT_CIGAM_64].includes(magic))
return { state: "not-fat" };
if (!hasFatMagic(bytes)) return { state: "not-fat" };
if (!Number.isSafeInteger(fileSize) || fileSize < bytes.byteLength)
return {
state: "malformed",
reason: "FAT file size is invalid or contradicts retained bytes",
};
const table = readFatContainerTable(bytes);
if (table.state !== "parsed") {
if (table.state === "needs-data" && table.requiredBytes > fileSize) {
return table.requiredBytes === 8 && fileSize < 8
? { state: "malformed", reason: "FAT header is truncated" }
: { state: "not-fat" };
}
return table;
}
if (bytes.byteLength === fileSize && isJvmClassFile(bytes))
return { state: "not-fat" };
if (table.tableEnd > fileSize) return { state: "not-fat" };
return {
...table,
rangeIssues: table.slices.map((slice) =>
inspectFatSliceRange(slice, fileSize, table.tableEnd),
),
};
};
/** Decode bounded FAT declarations without assuming the final file size. */
export const readFatContainerTable = (bytes: Uint8Array): FatContainerTable => {
if (!hasFatMagic(bytes)) return { state: "not-fat" };
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const magic = view.getUint32(0, false);
if (bytes.byteLength < 8) {
return fileSize < 8
? { state: "malformed", reason: "FAT header is truncated" }
: { state: "needs-data", requiredBytes: 8 };
return { state: "needs-data", requiredBytes: 8 };
}
const littleEndian = magic === FAT_CIGAM || magic === FAT_CIGAM_64;
const wide = magic === FAT_MAGIC_64 || magic === FAT_CIGAM_64;
@@ -91,7 +159,6 @@ export const readFatContainerHeader = (
};
if (count > MAX_FAT_ARCHITECTURES) return { state: "not-fat" };
const tableEnd = 8 + count * (wide ? 32 : 20);
if (tableEnd > fileSize) return { state: "not-fat" };
if (tableEnd > bytes.byteLength)
return { state: "needs-data", requiredBytes: tableEnd };
const declarations = readFatSliceDeclarations(bytes.subarray(8, tableEnd), {
@@ -108,14 +175,19 @@ export const readFatContainerHeader = (
littleEndian,
tableEnd,
slices: declarations.slices,
rangeIssues: declarations.slices.map((slice) =>
inspectFatSliceRange(slice, fileSize, tableEnd),
),
}
: { state: "malformed", reason: declarations.reason };
};
/** Confirm FAT identity from architecture records and observed slice headers. */
const hasFatMagic = (bytes: Uint8Array): boolean => {
if (bytes.byteLength < 4) return false;
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
return [FAT_MAGIC, FAT_MAGIC_64, FAT_CIGAM, FAT_CIGAM_64].includes(
view.getUint32(0, false),
);
};
/** Confirm FAT identity from observed slice headers, not CPU declarations. */
export const classifyFatContainerIdentity = (
header: Extract<FatContainerHeader, { readonly state: "parsed" }>,
sliceHeaderAt: (offset: number) => number | null | undefined,
@@ -126,28 +198,34 @@ export const classifyFatContainerIdentity = (
const invalidRange = header.rangeIssues.find(
(issue): issue is FatSliceRangeIssue => issue !== null,
);
if (hasKnownCpu)
return invalidRange === undefined
? { state: "mach-o" }
: { state: "malformed", ...invalidRange };
// Preserve the existing malformed-range result for recognized CPU tables;
// a valid CPU declaration by itself does not establish a Mach-O image.
if (hasKnownCpu && invalidRange !== undefined)
return { state: "malformed", ...invalidRange };
const validRanges = header.slices.filter(
(_slice, index) => header.rangeIssues[index] === null,
);
const missingHeaders: number[] = [];
let observedMachO = false;
let observedNonMachO = false;
for (const slice of validRanges) {
if (slice.size < 4) {
observedNonMachO = true;
continue;
}
const magic = sliceHeaderAt(slice.offset);
if (magic === undefined) missingHeaders.push(slice.offset);
else if (magic !== null && MACHO_HEADER_MAGICS.includes(magic))
observedMachO = true;
if (magic === undefined || magic === null)
missingHeaders.push(slice.offset);
else if (MACHO_HEADER_MAGICS.includes(magic)) observedMachO = true;
else observedNonMachO = true;
}
if (observedMachO) {
if (observedNonMachO) return { state: "not-mach-o" };
if (missingHeaders.length > 0)
return { state: "needs-slice-header", offsets: missingHeaders };
if (observedMachO)
return invalidRange === undefined
? { state: "mach-o" }
: { state: "malformed", ...invalidRange };
}
if (missingHeaders.length > 0)
return { state: "needs-slice-header", offsets: missingHeaders };
return { state: "not-mach-o" };
};
+3 -1
View File
@@ -192,7 +192,9 @@ describe("executable header completeness", () => {
expect(reason(bytes, 4100)).toBe(
"FAT architecture 1 slice range extends beyond the file",
);
expect(reason(bytes, 4160)).toBeNull();
expect(reason(bytes, 4160)).toBe(
"FAT slice identity is outside the bounded prefix",
);
expect(parseExecutableHeader(bytes, "x64", 4100)).toMatchObject({
ok: false,
error: "FAT architecture 1 slice range extends beyond the file",
+70 -6
View File
@@ -19,6 +19,9 @@ import {
import {
classifyFatContainerIdentity,
readFatContainerHeader,
readFatSliceHeaderFacts,
validateFatSliceIdentity,
type FatSliceHeaderEvidence,
} from "./apple/machoContainer.js";
/** Format and architecture facts recovered from an executable header. */
@@ -81,6 +84,7 @@ export const parseExecutableHeader = (
hostArchitecture: NodeJS.Architecture,
fileSize = bytes.length,
mzWindowsHeaderSignature?: Buffer,
fatEvidence?: FatSliceHeaderEvidence,
): Result<ExecutableMetadata, string> => {
if (
bytes.length >= 4 &&
@@ -93,9 +97,10 @@ export const parseExecutableHeader = (
const magic = bytes.readUInt32BE(0);
if ([0xfeedface, 0xfeedfacf, 0xcefaedfe, 0xcffaedfe].includes(magic))
return parseThinMachO(bytes, magic, fileSize);
const fatHeader = readFatContainerHeader(bytes, fileSize);
const fatHeader =
fatEvidence?.header ?? readFatContainerHeader(bytes, fileSize);
if (fatHeader.state !== "not-fat")
return parseFatMachO(bytes, hostArchitecture, fatHeader);
return parseFatMachO(bytes, hostArchitecture, fatHeader, fatEvidence);
return err("unsupported binary format");
};
@@ -134,12 +139,23 @@ const parseFatMachO = (
ReturnType<typeof readFatContainerHeader>,
{ state: "not-fat" }
>,
fatEvidence?: FatSliceHeaderEvidence,
): Result<ExecutableMetadata, string> => {
if (header.state === "needs-data")
return err("truncated or invalid FAT architecture table");
if (header.state === "malformed") return err(header.reason);
const identityError = fatIdentityError(bytes, header);
const identityError = fatIdentityError(
bytes,
header,
fatEvidence?.sliceHeadersByOffset,
);
if (identityError !== null) return err(identityError);
const sliceIdentityError = fatSliceIdentityError(
bytes,
header,
fatEvidence?.sliceHeadersByOffset,
);
if (sliceIdentityError !== null) return err(sliceIdentityError);
const architectures: HeaderArchitecture[] = [];
const slices = new Map<
HeaderArchitecture,
@@ -171,6 +187,41 @@ const parseFatMachO = (
});
};
const fatSliceIdentityError = (
bytes: Buffer,
header: Extract<
ReturnType<typeof readFatContainerHeader>,
{ state: "parsed" }
>,
sliceHeadersByOffset?: ReadonlyMap<
number,
ReturnType<typeof readFatSliceHeaderFacts>
>,
): string | null => {
for (let index = 0; index < header.slices.length; index += 1) {
const declaration = header.slices[index];
if (declaration === undefined || header.rangeIssues[index] !== null)
continue;
let facts: ReturnType<typeof readFatSliceHeaderFacts> | undefined;
if (sliceHeadersByOffset?.has(declaration.offset)) {
facts = sliceHeadersByOffset.get(declaration.offset);
} else if (
declaration.size >= 12 &&
declaration.offset <= bytes.length - 12
) {
facts = readFatSliceHeaderFacts(
bytes.subarray(declaration.offset, declaration.offset + 12),
);
}
const identity = facts?.identity;
if (identity === null || identity === undefined)
return `FAT architecture ${declaration.index} thin Mach-O CPU identity is outside the bounded header`;
const identityError = validateFatSliceIdentity(declaration, identity);
if (identityError !== null) return identityError;
}
return null;
};
const fatHostArchitecture = (
host: NodeJS.Architecture,
): HeaderArchitecture | undefined => {
@@ -194,11 +245,24 @@ const fatIdentityError = (
ReturnType<typeof readFatContainerHeader>,
{ state: "parsed" }
>,
sliceHeadersByOffset?: ReadonlyMap<
number,
ReturnType<typeof readFatSliceHeaderFacts>
>,
): string | null => {
const identity = classifyFatContainerIdentity(header, (offset) => {
if (offset >= bytes.length) return undefined;
if (offset + 4 > bytes.length) return null;
return bytes.readUInt32BE(offset);
if (sliceHeadersByOffset?.has(offset))
return sliceHeadersByOffset.get(offset)?.magic;
const slice = header.slices.find(
(candidate) => candidate.offset === offset,
);
if (slice === undefined || offset >= bytes.length) return undefined;
const available = Math.max(
0,
Math.min(slice.size, bytes.length - offset, 12),
);
return readFatSliceHeaderFacts(bytes.subarray(offset, offset + available))
.magic;
});
if (identity.state === "needs-slice-header")
return "FAT slice identity is outside the bounded prefix";
@@ -81,6 +81,34 @@ it("keeps unavailable container traversal distinct from observed root bytes", as
});
});
it("admits a FAT root when a valid slice header starts at the prefix boundary", async () => {
const root = await createTestTempDirectory("rea-fat-root-header-boundary-");
const path = join(root, "universal");
const bytes = Buffer.alloc(8_224);
bytes.writeUInt32BE(0xcafebabe, 0);
bytes.writeUInt32BE(2, 4);
const slices = [
{ cpu: 0x01000007, subtype: 3, offset: 4_096 },
{ cpu: 0x0100000c, subtype: 0, offset: 8_192 },
];
for (const [index, slice] of slices.entries()) {
const entry = 8 + index * 20;
bytes.writeUInt32BE(slice.cpu, entry);
bytes.writeUInt32BE(slice.subtype, entry + 4);
bytes.writeUInt32BE(slice.offset, entry + 8);
bytes.writeUInt32BE(32, entry + 12);
bytes.writeUInt32BE(12, entry + 16);
bytes.writeUInt32LE(0xfeedfacf, slice.offset);
bytes.writeUInt32LE(slice.cpu, slice.offset + 4);
bytes.writeUInt32LE(slice.subtype, slice.offset + 8);
bytes.writeUInt32LE(2, slice.offset + 12);
}
await writeFile(path, bytes);
const classified = await classifyAndHashRoot(path, false, await lstat(path));
expect(classified.format).toBe("mach-o-universal");
});
it("recognizes a root ASAR when its exact JSON extent has legal surrounding whitespace", async () => {
const root = await createTestTempDirectory("rea-asar-root-whitespace-");
const path = join(root, "app.asar");
@@ -1,5 +1,6 @@
import { execFile } from "node:child_process";
import {
open,
chmod,
mkdir,
readFile,
@@ -30,6 +31,12 @@ import { hasXmlPlistSignature } from "../../../src/domain/apple/appleArchiveForm
import { parseExecutableHeader } from "../../../src/domain/binaryTarget.js";
import { createEvidence, parseEvidence } from "../../../src/domain/evidence.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import {
arArchive,
CPU,
fatImage,
machoImage,
} from "../../../src/artifacts/apple/MachoImage.fixture.js";
it.each([0x01c0, 0x01c2, 0x01c4, 0xaa64])(
"retains original PE COFF machine %i through file-backed target resolution",
@@ -84,6 +91,127 @@ it.each([{ cpus: [0x0100000c] }, { cpus: [0x01000007, 0x0100000c] }])(
},
);
it("reads a sparse FAT slice header beyond the bounded prefix", async () => {
const directory = await createTestTempDirectory("rea-sparse-fat-target-");
const path = join(directory, "image");
const sliceOffset = 64 * 1024;
const file = await open(path, "w+");
try {
const fatHeader = Buffer.alloc(28);
fatHeader.writeUInt32BE(0xcafebabe, 0);
fatHeader.writeUInt32BE(1, 4);
fatHeader.writeUInt32BE(CPU.arm64.type, 8);
fatHeader.writeUInt32BE(CPU.arm64.subtype, 12);
fatHeader.writeUInt32BE(sliceOffset, 16);
fatHeader.writeUInt32BE(32, 20);
await file.write(fatHeader, 0, fatHeader.length, 0);
const sliceHeader = machoImage({ cpu: CPU.arm64 }).subarray(0, 12);
await file.write(sliceHeader, 0, sliceHeader.length, sliceOffset);
await file.truncate(sliceOffset + 32);
} finally {
await file.close();
}
expect(
await parseBinaryTarget(path, { hostArchitecture: "arm64" }),
).toMatchObject({
ok: true,
value: { format: "mach-o-universal", architecture: "arm64" },
});
});
it("reads the complete bounded 128-entry FAT64 table beyond the prefix", async () => {
const directory = await createTestTempDirectory("rea-fat64-max-table-");
const path = join(directory, "image");
const entryCount = 128;
const tableEnd = 8 + entryCount * 32;
const sliceSize = 32;
const file = await open(path, "w+");
try {
const table = Buffer.alloc(tableEnd);
table.writeUInt32BE(0xcafebabf, 0);
table.writeUInt32BE(entryCount, 4);
const thinHeader = machoImage({ cpu: CPU.arm64 }).subarray(0, 12);
for (let index = 0; index < entryCount; index += 1) {
const entry = 8 + index * 32;
const sliceOffset = tableEnd + index * sliceSize;
table.writeUInt32BE(CPU.arm64.type, entry);
table.writeUInt32BE(CPU.arm64.subtype, entry + 4);
table.writeBigUInt64BE(BigInt(sliceOffset), entry + 8);
table.writeBigUInt64BE(BigInt(sliceSize), entry + 16);
await file.write(thinHeader, 0, thinHeader.length, sliceOffset);
}
await file.write(table, 0, table.length, 0);
await file.truncate(tableEnd + entryCount * sliceSize);
} finally {
await file.close();
}
expect(
await parseBinaryTarget(path, { hostArchitecture: "arm64" }),
).toMatchObject({
ok: true,
value: { format: "mach-o-universal", architecture: "arm64" },
});
});
it.each([0, 1])(
"rejects a file-backed FAT CPU mismatch in slice %i before host selection",
async (wrongIndex) => {
const directory = await createTestTempDirectory("rea-fat-cpu-mismatch-");
const path = join(directory, "image");
const bytes = fatImage([
{ cpu: CPU.x86_64, bytes: machoImage({ cpu: CPU.x86_64 }) },
{ cpu: CPU.arm64, bytes: machoImage({ cpu: CPU.arm64 }) },
]);
const entry = 8 + wrongIndex * 20;
new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).setUint32(
entry,
wrongIndex === 0 ? CPU.arm64.type : CPU.x86_64.type,
false,
);
await writeFile(path, Buffer.from(bytes));
expect(
await parseBinaryTarget(path, { hostArchitecture: "arm64" }),
).toMatchObject({
ok: false,
error: {
_tag: "BinaryTargetError",
reason: `FAT architecture ${wrongIndex} CPU type/subtype disagrees with its Mach-O header`,
constraint: "file_format_rejected",
},
});
},
);
it.each([
{
name: "archive slices",
slices: [
{ cpu: CPU.x86_64, bytes: arArchive() },
{ cpu: CPU.arm64, bytes: arArchive("second.o") },
],
},
{
name: "mixed Mach-O and archive slices",
slices: [
{ cpu: CPU.x86_64, bytes: machoImage({ cpu: CPU.x86_64 }) },
{ cpu: CPU.arm64, bytes: arArchive() },
],
},
])("refuses FAT $name before provider selection", async ({ slices }) => {
const directory = await createTestTempDirectory("rea-fat-archive-target-");
const path = join(directory, "image");
await writeFile(path, fatImage(slices));
expect(
await parseBinaryTarget(path, { hostArchitecture: "arm64" }),
).toMatchObject({
ok: false,
error: {
_tag: "BinaryTargetError",
constraint: "file_format_rejected",
},
});
});
describe("binary target I/O: app plist permissions and decoding", () => {
it.each([
["EACCES", "permission denied reading app Info.plist"],