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:
parent
c339f9ebe7
commit
921bb95755
|
|
@ -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])(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
});
|
||||
|
|
@ -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]);
|
||||
};
|
||||
Loading…
Reference in New Issue