mirror of
https://github.com/morluto/rea
synced 2026-10-26 06:16:17 -04:00
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:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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";
|
||||
};
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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" };
|
||||
};
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user