fix(artifacts): distinguish Java classes from universal Mach-O

Classify FAT containers using shared architecture, range, and observed slice-header facts rather than the CAFEBABE signature alone. Preserve typed unsupported and malformed reasons, reject malformed sibling slices consistently, and remove repeated range parsing in consumers.

Validation: valid small and large JVM classes in public JAR/ZIP inspection, four FAT width/byte-order controls, and focused executable/Mach-O boundary tests. The class fixtures were checked with JDK javap; no new JVM analysis capability is advertised.

The Apple selection and Hopper readers retain their existing parsing because they also own oversized-count diagnostics, reserved fields, streaming final-size validation, or source freshness that the shared header result does not represent.

Representative public mixed-ZIP inventory probe, four fresh processes with an already warm filesystem cache: first process elapsed 626.8 -> 548.3 ms, CPU 740 -> 740 ms, sampled family peak RSS 375.5 -> 345.4 MiB. Subsequent three-process medians: elapsed 767.2 -> 454.0 ms, CPU 890 -> 640 ms, peak RSS 368.7 -> 346.2 MiB. Estimated output tokens 2310 -> 2292. Concurrent load was uncontrolled, so these observations do not establish a speed improvement. Whole-agent task tokens and schema/call overhead were not measured.

Fixes #2317
This commit is contained in:
morluto 2026-10-13 04:12:04 +08:00
parent c339f9ebe7
commit 921bb95755
8 changed files with 535 additions and 170 deletions

View File

@ -26,6 +26,7 @@ import {
} from "./MachoLoadCommandReader.js";
import { traceDylibResolution } from "./DylibResolutionReader.js";
import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js";
import { javaClassFile } from "../../../tests/fixtures/javaClassFile.js";
const read = (bytes: Uint8Array) =>
readMachoImage(readerOf(bytes), bytes.length);
@ -345,10 +346,13 @@ describe("Mach-O load command reader inputs", () => {
expect(i386.status === "parsed" && i386.slices[0]?.architecture).toBe(
"i386",
);
// A Java class file shares the FAT magic; its version is not a slice count.
const javaClass = Uint8Array.from([0xca, 0xfe, 0xba, 0xbe, 0, 0, 0, 0x34]);
expect(hasMachoMagic(javaClass)).toBe(true);
expect(await read(javaClass)).toEqual({ status: "not-mach-o" });
// A Java class file shares the FAT magic; version fields are not a slice count.
const javaClasses = [javaClassFile(), javaClassFile(4000)];
expect(javaClasses.map(({ length }) => length)).toEqual([57, 4060]);
for (const javaClass of javaClasses) {
expect(hasMachoMagic(javaClass)).toBe(true);
expect(await read(javaClass)).toEqual({ status: "not-mach-o" });
}
expect(await read(new TextEncoder().encode("#!/bin/sh\n"))).toEqual({
status: "not-mach-o",
});
@ -489,6 +493,16 @@ describe("Mach-O load command reader failures", () => {
reason: "FAT architecture 0 slice range extends beyond the file",
});
});
it("preserves unsupported status for an unsupported FAT alignment exponent", async () => {
const fat = fatImage([{ cpu: CPU.arm64, bytes: machoImage({}) }]);
new DataView(fat.buffer).setUint32(24, 53, false);
expect(await read(fat)).toMatchObject({
status: "unsupported",
reason: expect.stringContaining("alignment exponent 53 is unsupported"),
});
});
});
it.each([true, false])(

View File

@ -4,8 +4,9 @@ import type {
MachoSlice,
} from "../../domain/apple/dylibResolution.js";
import {
readFatSliceDeclarations,
validateFatSlice,
classifyFatContainerIdentity,
readFatContainerHeader,
validateFatSliceIdentity,
type FatSliceDeclaration,
} from "../../domain/apple/machoContainer.js";
@ -14,13 +15,6 @@ export type ReadAt = (offset: number, length: number) => Promise<Uint8Array>;
/** Load commands larger than this are refused instead of buffered. */
export const MAX_LOAD_COMMAND_BYTES = 16 * 1024 * 1024;
/** Target opening accepts up to 128 FAT records; Java class files share the FAT magic. */
const MAX_FAT_ARCHITECTURES = 128;
/** CPU types of real Mach-O slices (x86, x86_64, arm, arm64, arm64_32, ppc, ppc64). */
const KNOWN_CPU_TYPES: readonly number[] = [
7, 0x01000007, 12, 0x0100000c, 0x0200000c, 18, 0x01000012,
];
const MH_MAGIC = 0xfeedface;
const MH_MAGIC_64 = 0xfeedfacf;
const MH_CIGAM = 0xcefaedfe;
@ -146,69 +140,41 @@ export const readMachoImage = async (
: { status: "not-mach-o" };
const view = viewOf(head);
const magic = view.getUint32(0, false);
if ([FAT_MAGIC, FAT_MAGIC_64, FAT_CIGAM, FAT_CIGAM_64].includes(magic)) {
// FAT headers are big-endian; the swapped magics store them little-endian.
const littleEndian = magic === FAT_CIGAM || magic === FAT_CIGAM_64;
const count = view.getUint32(4, littleEndian);
const wide = magic === FAT_MAGIC_64 || magic === FAT_CIGAM_64;
// A Java class file's nonzero major version reads as the count, so an
// empty architecture table can only be a malformed universal header.
if (count === 0)
return {
status: "malformed",
reason: "FAT header declares no architectures",
};
if (count > MAX_FAT_ARCHITECTURES || 8 + count * (wide ? 32 : 20) > size)
return { status: "not-mach-o" };
const tableBytes = await readExact(
readAt,
8,
count * (wide ? 32 : 20),
"FAT table",
);
const declarations = readFatSliceDeclarations(tableBytes, {
count,
wide,
littleEndian,
});
if (declarations.status !== "parsed")
throw new MachoFormatIssue("malformed", declarations.reason);
// A Java class file's version fields read as a FAT count; its "table" names no CPU.
if (
!declarations.slices.some(({ cpuType }) =>
KNOWN_CPU_TYPES.includes(cpuType),
)
) {
let hasMachOSliceHeader = false;
for (const declaration of declarations.slices) {
if (
validateFatSlice(
declaration,
size,
8 + count * (wide ? 32 : 20),
) !== null
)
continue;
const bytes = await readAt(declaration.offset, 4);
if (bytes.byteLength < 4) continue;
const sliceMagic = viewOf(bytes).getUint32(0, false);
if (
[MH_MAGIC, MH_MAGIC_64, MH_CIGAM, MH_CIGAM_64].includes(sliceMagic)
) {
hasMachOSliceHeader = true;
break;
}
}
if (!hasMachOSliceHeader) return { status: "not-mach-o" };
let header = readFatContainerHeader(head, size);
if (header.state !== "not-fat") {
if (header.state === "needs-data") {
header = readFatContainerHeader(
await readAt(0, header.requiredBytes),
size,
);
}
if (header.state === "not-fat") return { status: "not-mach-o" };
if (header.state === "needs-data")
throw new MachoFormatIssue("malformed", "FAT table is truncated");
if (header.state === "malformed")
throw new MachoFormatIssue("malformed", header.reason);
let identity = classifyFatContainerIdentity(header, () => undefined);
if (identity.state === "needs-slice-header") {
const sliceHeaders = new Map<number, number | null>();
for (const offset of identity.offsets) {
const bytes = await readAt(offset, 4);
sliceHeaders.set(
offset,
bytes.byteLength < 4 ? null : viewOf(bytes).getUint32(0, false),
);
}
identity = classifyFatContainerIdentity(header, (offset) =>
sliceHeaders.get(offset),
);
}
if (identity.state === "not-mach-o") return { status: "not-mach-o" };
if (identity.state === "malformed")
throw new MachoFormatIssue(identity.category, identity.reason);
if (identity.state === "needs-slice-header")
return { status: "not-mach-o" };
return {
status: "parsed",
slices: await readFatSlices(
readAt,
size,
8 + count * (wide ? 32 : 20),
declarations.slices,
),
slices: await readFatSlices(readAt, header.slices),
};
}
if (magic === MH_CIGAM || magic === MH_CIGAM_64)
@ -228,25 +194,17 @@ export const readMachoImage = async (
const readFatSlices = async (
readAt: ReadAt,
size: number,
tableEnd: number,
declarations: readonly FatSliceDeclaration[],
): Promise<MachoSlice[]> => {
const slices: MachoSlice[] = [];
for (const declaration of declarations) {
const { offset, size: length, cpuType, cpuSubtype } = declaration;
const rangeIssue = validateFatSlice(declaration, size, tableEnd);
if (rangeIssue !== null)
throw new MachoFormatIssue(
rangeIssue.includes("unsupported") ? "unsupported" : "malformed",
rangeIssue,
);
const slice = await readSlice(readAt, offset, length, {
fatCpuType: cpuType,
fatCpuSubtype: cpuSubtype,
alignmentExponent: declaration.alignmentExponent,
});
const identityIssue = validateFatSlice(declaration, size, tableEnd, {
const identityIssue = validateFatSliceIdentity(declaration, {
cpuType: slice.cpu_type,
cpuSubtype: slice.cpu_subtype,
});

View File

@ -22,6 +22,10 @@ import {
observeMzWindowsHeader,
parseDosMzHeader,
} from "../../domain/dosMz.js";
import {
classifyFatContainerIdentity,
readFatContainerHeader,
} from "../../domain/apple/machoContainer.js";
/** Mutable internal occurrence used until root-bound IDs are known. */
export interface MutableOccurrence {
@ -437,10 +441,10 @@ export const classifyArtifactBytes = (
): ArtifactNode["format"] => {
if (hasZipSignature(prefix)) return "zip";
if (prefix.subarray(0, 8).equals(Buffer.from("bplist00"))) return "plist";
const fatFormat = classifyFatArtifact(prefix, fileSize);
if (fatFormat !== null) return fatFormat;
if (prefix.length >= 4) {
const magic = prefix.readUInt32BE(0);
if ([0xcafebabe, 0xbebafeca, 0xcafebabf, 0xbfbafeca].includes(magic))
return "mach-o-universal";
if ([0xfeedface, 0xfeedfacf, 0xcefaedfe, 0xcffaedfe].includes(magic))
return "mach-o";
if (prefix.subarray(0, 4).equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46])))
@ -471,6 +475,22 @@ export const classifyArtifactBytes = (
return "file";
};
const classifyFatArtifact = (
prefix: Buffer,
fileSize: number,
): ArtifactNode["format"] | null => {
const fatHeader = 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);
});
return identity.state === "mach-o" ? "mach-o-universal" : "unknown";
};
const relationFor = (path: string): ArtifactEdge["relation"] => {
const lower = path.toLowerCase();
if (lower.endsWith(".map")) return "maps-source";

View File

@ -8,6 +8,136 @@ export interface FatSliceDeclaration {
readonly cpuSubtype: number;
}
export interface FatSliceRangeIssue {
readonly category: "malformed" | "unsupported";
readonly reason: string;
}
export type FatContainerHeader =
| { 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[];
readonly rangeIssues: readonly (FatSliceRangeIssue | null)[];
};
export type FatContainerIdentity =
| { readonly state: "mach-o" }
| { readonly state: "not-mach-o" }
| {
readonly state: "needs-slice-header";
readonly offsets: readonly number[];
}
| {
readonly state: "malformed";
readonly category: "malformed" | "unsupported";
readonly reason: string;
};
const FAT_MAGIC = 0xcafebabe;
const FAT_MAGIC_64 = 0xcafebabf;
const FAT_CIGAM = 0xbebafeca;
const FAT_CIGAM_64 = 0xbfbafeca;
const MAX_FAT_ARCHITECTURES = 128;
const KNOWN_MACHO_CPU_TYPES: readonly number[] = [
7, 0x01000007, 12, 0x0100000c, 0x0200000c, 18, 0x01000012,
];
const MACHO_HEADER_MAGICS: readonly number[] = [
0xfeedface, 0xfeedfacf, 0xcefaedfe, 0xcffaedfe,
];
/** Parse a bounded FAT header and table without treating the magic alone as proof. */
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 (bytes.byteLength < 8) {
return fileSize < 8
? { state: "malformed", reason: "FAT header is truncated" }
: { state: "needs-data", requiredBytes: 8 };
}
const littleEndian = magic === FAT_CIGAM || magic === FAT_CIGAM_64;
const wide = magic === FAT_MAGIC_64 || magic === FAT_CIGAM_64;
const count = view.getUint32(4, littleEndian);
if (count === 0)
return {
state: "malformed",
reason: "FAT header declares no architectures",
};
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), {
count,
wide,
littleEndian,
});
return declarations.status === "parsed"
? {
state: "parsed",
magic,
count,
wide,
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. */
export const classifyFatContainerIdentity = (
header: Extract<FatContainerHeader, { readonly state: "parsed" }>,
sliceHeaderAt: (offset: number) => number | null | undefined,
): FatContainerIdentity => {
const hasKnownCpu = header.slices.some(({ cpuType }) =>
KNOWN_MACHO_CPU_TYPES.includes(cpuType),
);
const invalidRange = header.rangeIssues.find(
(issue): issue is FatSliceRangeIssue => issue !== null,
);
if (hasKnownCpu)
return invalidRange === undefined
? { state: "mach-o" }
: { state: "malformed", ...invalidRange };
const validRanges = header.slices.filter(
(_slice, index) => header.rangeIssues[index] === null,
);
const missingHeaders: number[] = [];
let observedMachO = false;
for (const slice of validRanges) {
const magic = sliceHeaderAt(slice.offset);
if (magic === undefined) missingHeaders.push(slice.offset);
else if (magic !== null && MACHO_HEADER_MAGICS.includes(magic))
observedMachO = true;
}
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" };
};
/** Decode FAT table entries once so artifact and native readers share identity facts. */
export const readFatSliceDeclarations = (
tableBytes: Uint8Array,
@ -64,32 +194,61 @@ export const validateFatSlice = (
tableEnd: number,
embedded?: { readonly cpuType: number; readonly cpuSubtype: number },
): string | null => {
const { index, offset, size, alignmentExponent, cpuType, cpuSubtype } =
declaration;
const rangeIssue = inspectFatSliceRange(declaration, containerSize, tableEnd);
if (rangeIssue !== null) return rangeIssue.reason;
return embedded === undefined
? null
: validateFatSliceIdentity(declaration, embedded);
};
/** Validate table placement, container bounds, and alignment with a typed failure. */
export const inspectFatSliceRange = (
declaration: FatSliceDeclaration,
containerSize: number,
tableEnd: number,
): FatSliceRangeIssue | null => {
const { index, offset, size, alignmentExponent } = declaration;
if (
!Number.isSafeInteger(offset) ||
!Number.isSafeInteger(size) ||
size <= 0 ||
offset < tableEnd
)
return `FAT architecture ${index} has an invalid slice range`;
return {
category: "malformed",
reason: `FAT architecture ${index} has an invalid slice range`,
};
if (offset > containerSize || size > containerSize - offset)
return `FAT architecture ${index} slice range extends beyond the file`;
return {
category: "malformed",
reason: `FAT architecture ${index} slice range extends beyond the file`,
};
if (
!Number.isInteger(alignmentExponent) ||
alignmentExponent < 0 ||
alignmentExponent > 52
)
return `FAT architecture ${index} alignment exponent ${alignmentExponent} is unsupported`;
return {
category: "unsupported",
reason: `FAT architecture ${index} alignment exponent ${alignmentExponent} is unsupported`,
};
if (offset % 2 ** alignmentExponent !== 0)
return `FAT architecture ${index} offset is not aligned to 2^${alignmentExponent}`;
if (
embedded !== undefined &&
(embedded.cpuType !== cpuType || embedded.cpuSubtype !== cpuSubtype)
)
return `FAT architecture ${index} CPU type/subtype disagrees with its Mach-O header`;
return {
category: "malformed",
reason: `FAT architecture ${index} offset is not aligned to 2^${alignmentExponent}`,
};
return null;
};
/** Validate only the identity claim after the owner has validated its range. */
export const validateFatSliceIdentity = (
declaration: FatSliceDeclaration,
embedded: { readonly cpuType: number; readonly cpuSubtype: number },
): string | null =>
embedded.cpuType === declaration.cpuType &&
embedded.cpuSubtype === declaration.cpuSubtype
? null
: `FAT architecture ${declaration.index} CPU type/subtype disagrees with its Mach-O header`;
const safeNumber = (value: bigint): number | null =>
value > BigInt(Number.MAX_SAFE_INTEGER) ? null : Number(value);

View File

@ -176,42 +176,53 @@ describe("executable header completeness", () => {
expect(reason(header, 132)).toBeNull();
});
it("rejects a FAT file whose host slice extends past the file", () => {
it("rejects an invalid FAT slice even when another host slice is valid", () => {
const bytes = fat([0x01000007, 0x0100000c]);
bytes.writeUInt32BE(4096, 8 + 20 + 8);
bytes.writeUInt32BE(64, 8 + 20 + 12);
expect(reason(bytes, 4100)).toBe(
"truncated FAT slice: the arm64 slice ends at byte 4160; the file has 4100",
"FAT architecture 1 slice range extends beyond the file",
);
expect(reason(bytes, 4160)).toBeNull();
// Only the slice REA would analyze must be present.
expect(parseExecutableHeader(bytes, "x64", 4100).ok).toBe(true);
expect(parseExecutableHeader(bytes, "x64", 4100)).toMatchObject({
ok: false,
error: "FAT architecture 1 slice range extends beyond the file",
});
});
it.each([
{
label: "empty range",
corruptRange: (bytes: Buffer, wide: boolean, little: boolean) =>
setFatSliceRange(bytes, wide, little, "size", 0n),
error: "FAT slice for arm64 is shorter than its Mach-O header",
corruptRange: (bytes: Buffer, wide: boolean, littleEndian: boolean) =>
setFatSliceRange(bytes, {
wide,
littleEndian,
field: "size",
value: 0n,
}),
error: "FAT architecture 1 has an invalid slice range",
},
{
label: "table overlap",
corruptRange: (bytes: Buffer, wide: boolean, little: boolean) =>
setFatSliceRange(
bytes,
corruptRange: (bytes: Buffer, wide: boolean, littleEndian: boolean) =>
setFatSliceRange(bytes, {
wide,
little,
"offset",
BigInt(8 + 2 * (wide ? 32 : 20) - 1),
),
error: "FAT slice for arm64 overlaps the architecture table",
littleEndian,
field: "offset",
value: BigInt(8 + 2 * (wide ? 32 : 20) - 1),
}),
error: "FAT architecture 1 has an invalid slice range",
},
{
label: "undersized 64-bit header",
corruptRange: (bytes: Buffer, wide: boolean, little: boolean) =>
setFatSliceRange(bytes, wide, little, "size", 31n),
corruptRange: (bytes: Buffer, wide: boolean, littleEndian: boolean) =>
setFatSliceRange(bytes, {
wide,
littleEndian,
field: "size",
value: 31n,
}),
error: "FAT slice for arm64 is shorter than its Mach-O header",
},
])("rejects a selected FAT slice with $label", ({ corruptRange, error }) => {
@ -234,11 +245,23 @@ describe("executable header completeness", () => {
it("uses a 28-byte minimum for a selected 32-bit Mach-O header", () => {
const exactHeader = fat([7]);
setFatSliceRange(exactHeader, false, false, "size", 28n, 0);
setFatSliceRange(exactHeader, {
wide: false,
littleEndian: false,
field: "size",
value: 28n,
index: 0,
});
expect(parseExecutableHeader(exactHeader, "ia32").ok).toBe(true);
const undersizedHeader = Buffer.from(exactHeader);
setFatSliceRange(undersizedHeader, false, false, "size", 27n, 0);
setFatSliceRange(undersizedHeader, {
wide: false,
littleEndian: false,
field: "size",
value: 27n,
index: 0,
});
expect(parseExecutableHeader(undersizedHeader, "ia32")).toMatchObject({
ok: false,
@ -249,12 +272,16 @@ describe("executable header completeness", () => {
const setFatSliceRange = (
bytes: Buffer,
wide: boolean,
littleEndian: boolean,
field: "offset" | "size",
value: bigint,
index = 1,
options: {
readonly wide: boolean;
readonly littleEndian: boolean;
readonly field: "offset" | "size";
readonly value: bigint;
readonly index?: number;
},
): void => {
const { wide, littleEndian, field, value } = options;
const index = options.index ?? 1;
const entry = 8 + index * (wide ? 32 : 20);
const fieldOffset = entry + (field === "offset" ? 8 : wide ? 16 : 12);
if (wide) {

View File

@ -9,6 +9,10 @@ import type {
import { err, ok, type Result } from "./result.js";
import { observeMzWindowsHeader, parseDosMzHeader } from "./dosMz.js";
import { decodePeMachine } from "./peMachine.js";
import {
classifyFatContainerIdentity,
readFatContainerHeader,
} from "./apple/machoContainer.js";
/** Format and architecture facts recovered from an executable header. */
export type ExecutableMetadata =
@ -68,8 +72,9 @@ export const parseExecutableHeader = (
const magic = bytes.readUInt32BE(0);
if ([0xfeedface, 0xfeedfacf, 0xcefaedfe, 0xcffaedfe].includes(magic))
return parseThinMachO(bytes, magic, fileSize);
if ([0xcafebabe, 0xbebafeca, 0xcafebabf, 0xbfbafeca].includes(magic))
return parseFatMachO(bytes, magic, hostArchitecture, fileSize);
const fatHeader = readFatContainerHeader(bytes, fileSize);
if (fatHeader.state !== "not-fat")
return parseFatMachO(bytes, hostArchitecture, fatHeader);
return err("unsupported binary format");
};
@ -103,79 +108,85 @@ const parseThinMachO = (
const parseFatMachO = (
bytes: Buffer,
magic: number,
host: NodeJS.Architecture,
fileSize: number,
header: Exclude<
ReturnType<typeof readFatContainerHeader>,
{ state: "not-fat" }
>,
): Result<ExecutableMetadata, string> => {
const little = magic === 0xbebafeca || magic === 0xbfbafeca;
const is64 = magic === 0xcafebabf || magic === 0xbfbafeca;
if (bytes.length < 8) return err("truncated FAT header");
const read = (offset: number): number =>
little ? bytes.readUInt32LE(offset) : bytes.readUInt32BE(offset);
const count = read(4);
const entrySize = is64 ? 32 : 20;
if (count === 0 || count > 128 || bytes.length < 8 + count * entrySize)
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);
if (identityError !== null) return err(identityError);
const architectures: HeaderArchitecture[] = [];
const slices = new Map<
HeaderArchitecture,
{ readonly offset: bigint; readonly size: bigint }
{ readonly offset: number; readonly size: number }
>();
for (let index = 0; index < count; index += 1) {
const entry = 8 + index * entrySize;
const architecture = machArchitecture(read(entry));
for (const declaration of header.slices) {
const architecture = machArchitecture(declaration.cpuType);
if (architecture === undefined || architectures.includes(architecture))
continue;
architectures.push(architecture);
const offset = is64
? little
? bytes.readBigUInt64LE(entry + 8)
: bytes.readBigUInt64BE(entry + 8)
: BigInt(read(entry + 8));
const size = is64
? little
? bytes.readBigUInt64LE(entry + 16)
: bytes.readBigUInt64BE(entry + 16)
: BigInt(read(entry + 12));
slices.set(architecture, { offset, size });
slices.set(architecture, {
offset: declaration.offset,
size: declaration.size,
});
}
const preferred =
host === "arm64"
? "arm64"
: host === "x64"
? "x86_64"
: host === "ia32"
? "x86"
: host === "arm"
? "arm"
: undefined;
const preferred = fatHostArchitecture(host);
const slice = preferred === undefined ? undefined : slices.get(preferred);
if (preferred === undefined || slice === undefined)
return err(`FAT binary has no host-compatible ${host} architecture`);
const tableEnd = BigInt(8 + count * entrySize);
if (slice.offset < tableEnd)
return err(`FAT slice for ${preferred} overlaps the architecture table`);
const minimumHeaderSize =
preferred === "arm64" || preferred === "x86_64" ? 32n : 28n;
preferred === "arm64" || preferred === "x86_64" ? 32 : 28;
if (slice.size < minimumHeaderSize)
return err(`FAT slice for ${preferred} is shorter than its Mach-O header`);
const sliceEnd = slice.offset + slice.size;
if (sliceEnd > BigInt(fileSize))
return err(
`truncated FAT slice: the ${preferred} slice ends at byte ${sliceEnd}; the file has ${fileSize}`,
);
const sliceOffset = Number(slice.offset);
const sliceSize = Number(slice.size);
if (!Number.isSafeInteger(sliceOffset) || !Number.isSafeInteger(sliceSize))
return err(`FAT slice for ${preferred} exceeds addressable byte bounds`);
return ok({
format: "mach-o-universal",
architecture: preferred,
availableArchitectures: architectures,
universalSlice: { offset: sliceOffset, size: sliceSize },
universalSlice: { offset: slice.offset, size: slice.size },
});
};
const fatHostArchitecture = (
host: NodeJS.Architecture,
): HeaderArchitecture | undefined => {
switch (host) {
case "arm64":
return "arm64";
case "x64":
return "x86_64";
case "ia32":
return "x86";
case "arm":
return "arm";
default:
return undefined;
}
};
const fatIdentityError = (
bytes: Buffer,
header: Extract<
ReturnType<typeof readFatContainerHeader>,
{ state: "parsed" }
>,
): 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 (identity.state === "needs-slice-header")
return "FAT slice identity is outside the bounded prefix";
if (identity.state === "not-mach-o")
return "FAT table does not identify a Mach-O image";
if (identity.state === "malformed") return identity.reason;
return null;
};
const parseElf = (
bytes: Buffer,
fileSize: number,

View File

@ -0,0 +1,148 @@
import { execFile } from "node:child_process";
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { Uint8ArrayReader, Uint8ArrayWriter, ZipWriter } from "@zip.js/zip.js";
import { expect, it } from "vitest";
import { artifactInspectionResultSchema } from "../../src/domain/artifactInspection.js";
import { artifactInventoryResultSchema } from "../../src/domain/artifactGraph.js";
import { parseEvidence } from "../../src/domain/evidence.js";
import {
fatImage,
CPU,
machoImage,
} from "../../src/artifacts/apple/MachoImage.fixture.js";
import { javaClassFile } from "../fixtures/javaClassFile.js";
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
const execute = promisify(execFile);
const cli = fileURLToPath(new URL("../../scripts/rea.mjs", import.meta.url));
const archive = async (
entries: readonly (readonly [string, Uint8Array])[],
): Promise<Buffer> => {
const writer = new ZipWriter(new Uint8ArrayWriter());
for (const [name, bytes] of entries)
await writer.add(name, new Uint8ArrayReader(bytes));
return Buffer.from(await writer.close());
};
const inspectArchive = async (path: string) => {
const result = await execute(process.execPath, [
cli,
"inspect-artifact",
path,
"--json",
]);
const inspection = artifactInspectionResultSchema.parse(
parseEvidence(JSON.parse(result.stdout)).normalized_result,
);
const evidence = inspection.substeps[0]?.evidence;
if (evidence === undefined)
throw new Error("CLI inspection did not retain inventory Evidence");
return artifactInventoryResultSchema.parse(evidence.normalized_result);
};
it("keeps valid JVM classes from being classified as Mach-O while identifying FAT controls", async () => {
const directory = await createTestTempDirectory("rea-jvm-inventory-");
const javaEntries = [
["Hello.class", javaClassFile()],
["pkg/Big.class", javaClassFile(4000)],
] as const;
const fatControls = [
[
"fat-be32.bin",
fatImage([{ cpu: CPU.arm64, bytes: machoImage({ cpu: CPU.arm64 }) }]),
],
[
"fat-le32.bin",
fatImage(
[{ cpu: CPU.arm64, bytes: machoImage({ cpu: CPU.arm64 }) }],
false,
true,
),
],
[
"fat-be64.bin",
fatImage(
[{ cpu: CPU.arm64, bytes: machoImage({ cpu: CPU.arm64 }) }],
true,
),
],
[
"fat-le64.bin",
fatImage(
[{ cpu: CPU.arm64, bytes: machoImage({ cpu: CPU.arm64 }) }],
true,
true,
),
],
] as const;
const jarPath = join(directory, "classes.jar");
await writeFile(
jarPath,
await archive([
["META-INF/MANIFEST.MF", Buffer.from("Manifest-Version: 1.0\n\n")],
...javaEntries,
]),
);
const jar = await inspectArchive(jarPath);
expect(
jar.occurrences
.filter(({ logical_path }) => logical_path.endsWith(".class"))
.map(({ logical_path, artifact_kind, artifact_format }) => ({
logical_path,
artifact_kind,
artifact_format,
})),
).toEqual([
{
logical_path: "Hello.class",
artifact_kind: "resource",
artifact_format: "file",
},
{
logical_path: "pkg/Big.class",
artifact_kind: "unknown",
artifact_format: "unknown",
},
]);
const jarClassIds = new Set(
jar.occurrences
.filter(({ logical_path }) => logical_path.endsWith(".class"))
.map(({ artifact_id }) => artifact_id),
);
expect(
jar.nodes
.filter(({ artifact_id }) => jarClassIds.has(artifact_id))
.map(({ format }) => format)
.sort(),
).toEqual(["file", "unknown"]);
const mixedPath = join(directory, "mixed.zip");
await writeFile(mixedPath, await archive([...javaEntries, ...fatControls]));
const mixed = await inspectArchive(mixedPath);
const formatsByPath = new Map(
mixed.occurrences.map(
({ logical_path, artifact_kind, artifact_format }) => [
logical_path,
{ artifact_kind, artifact_format },
],
),
);
for (const [name] of javaEntries)
expect(formatsByPath.get(name)).toEqual(
name === "Hello.class"
? { artifact_kind: "resource", artifact_format: "file" }
: { artifact_kind: "unknown", artifact_format: "unknown" },
);
for (const [name] of fatControls)
expect(formatsByPath.get(name)).toEqual({
artifact_kind: "executable",
artifact_format: "mach-o-universal",
});
});

28
tests/fixtures/javaClassFile.ts vendored Normal file
View File

@ -0,0 +1,28 @@
/** Build a valid minimal JVM class file, optionally with an unused UTF-8 constant. */
export const javaClassFile = (extraUtf8Bytes = 0): Buffer => {
const utf8 = (value: string): Buffer => {
const bytes = Buffer.from(value, "utf8");
const entry = Buffer.alloc(3 + bytes.length);
entry[0] = 1;
entry.writeUInt16BE(bytes.length, 1);
bytes.copy(entry, 3);
return entry;
};
const constants = [
Buffer.from([7, 0, 2]),
utf8("Hello"),
Buffer.from([7, 0, 4]),
utf8("java/lang/Object"),
...(extraUtf8Bytes === 0 ? [] : [utf8("x".repeat(extraUtf8Bytes))]),
];
const headerAndPool = Buffer.alloc(10);
headerAndPool.writeUInt32BE(0xcafebabe, 0);
headerAndPool.writeUInt16BE(0, 4);
headerAndPool.writeUInt16BE(65, 6);
headerAndPool.writeUInt16BE(constants.length + 1, 8);
const classBody = Buffer.alloc(14);
[0x21, 1, 3, 0, 0, 0, 0].forEach((value, index) =>
classBody.writeUInt16BE(value, index * 2),
);
return Buffer.concat([headerAndPool, ...constants, classBody]);
};