mirror of
https://github.com/morluto/rea
synced 2026-10-25 22:08:11 -04:00
fix(native): validate Mach-O command framing before admission
This commit is contained in:
@@ -358,6 +358,13 @@ Mach-O fixtures check each case against source-owned return values.
|
||||
|
||||
## Static inspection
|
||||
|
||||
Thin Mach-O admission and each admitted FAT slice walk declared load-command headers, checking
|
||||
width-specific alignment, command sizes and exact `ncmds`/`sizeofcmds` agreement
|
||||
before opening a provider. Headers beyond the initial probe use another
|
||||
4096-byte window; admission skips command payloads. Native image readers use
|
||||
the same framing owner and perform their own payload checks. This validation
|
||||
does not add a global command-table byte cap.
|
||||
|
||||
Native target admission checks Mach-O slice headers before assigning FAT
|
||||
identity. A shared `CAFEBABE` prefix whose declarations do not identify Mach-O
|
||||
images receives the generic unsupported-format refusal, including JVM class
|
||||
|
||||
@@ -79,7 +79,15 @@ import {
|
||||
type FatSliceHeaderEvidence,
|
||||
type MachoHeaderPrefixFacts,
|
||||
} from "../domain/apple/machoContainer.js";
|
||||
import type { PeCodeViewRecord } from "../domain/binaryTargetTypes.js";
|
||||
import {
|
||||
MachoLoadCommandCursor,
|
||||
readMachoLoadCommandTableHeader,
|
||||
type MachoLoadCommandTableHeader,
|
||||
} from "../domain/apple/machoLoadCommands.js";
|
||||
import type {
|
||||
PeCodeViewRecord,
|
||||
UniversalMachOSlice,
|
||||
} from "../domain/binaryTargetTypes.js";
|
||||
import { absoluteCallerPath } from "../filesystem/callerPath.js";
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
|
||||
@@ -844,6 +852,12 @@ const readExecutableMetadata = async (
|
||||
signatureEvidence,
|
||||
);
|
||||
}
|
||||
const thin = await readThinMachoMetadata(handle, bytes, {
|
||||
fileSize,
|
||||
hostArchitecture,
|
||||
signal,
|
||||
});
|
||||
if (thin !== null) return thin;
|
||||
const fatEvidence = await readFatSliceHeaderEvidence(
|
||||
handle,
|
||||
bytes,
|
||||
@@ -857,6 +871,16 @@ const readExecutableMetadata = async (
|
||||
undefined,
|
||||
fatEvidence,
|
||||
);
|
||||
if (
|
||||
parsed.ok &&
|
||||
parsed.value.format === "mach-o-universal" &&
|
||||
fatEvidence !== undefined
|
||||
) {
|
||||
for (const slice of fatEvidence.header.slices) {
|
||||
const framed = await readMachoSliceCommandFrames(handle, slice, signal);
|
||||
if (!framed.ok) return framed;
|
||||
}
|
||||
}
|
||||
if (
|
||||
!parsed.ok ||
|
||||
parsed.value.architecture !== "mips" ||
|
||||
@@ -921,6 +945,95 @@ const unresolvedMzHeaderFailure = (
|
||||
};
|
||||
};
|
||||
|
||||
/** Establish a complete thin-command framing proof before provider admission. */
|
||||
const readThinMachoMetadata = async (
|
||||
handle: FileHandle,
|
||||
prefix: Buffer,
|
||||
input: {
|
||||
readonly fileSize: number;
|
||||
readonly hostArchitecture: NodeJS.Architecture;
|
||||
readonly signal?: AbortSignal | undefined;
|
||||
},
|
||||
): Promise<Result<ExecutableMetadata, string> | null> => {
|
||||
const table = readMachoLoadCommandTableHeader(prefix, input.fileSize);
|
||||
if (!table.ok) return table;
|
||||
if (table.value === null) return null;
|
||||
const parsed = parseExecutableHeader(
|
||||
prefix.subarray(0, table.value.headerSize),
|
||||
input.hostArchitecture,
|
||||
input.fileSize,
|
||||
);
|
||||
if (!parsed.ok) return parsed;
|
||||
const framed = await readMachoCommandFrames(handle, table.value, {
|
||||
prefix,
|
||||
fileSize: input.fileSize,
|
||||
signal: input.signal,
|
||||
});
|
||||
return framed.ok ? parsed : framed;
|
||||
};
|
||||
|
||||
/** Walk thin command headers through one probe window, skipping payload bytes. */
|
||||
const readMachoCommandFrames = async (
|
||||
handle: FileHandle,
|
||||
table: MachoLoadCommandTableHeader,
|
||||
input: {
|
||||
readonly prefix: Buffer;
|
||||
readonly fileSize: number;
|
||||
readonly fileOffset?: number | undefined;
|
||||
readonly signal?: AbortSignal | undefined;
|
||||
},
|
||||
): Promise<Result<null, string>> => {
|
||||
const commands = new MachoLoadCommandCursor(table);
|
||||
let window = input.prefix;
|
||||
let windowOffset = 0;
|
||||
while (commands.nextOffset !== null) {
|
||||
const offset = table.headerSize + commands.nextOffset;
|
||||
if (
|
||||
offset + 8 <= input.fileSize &&
|
||||
(offset < windowOffset || offset + 8 > windowOffset + window.length)
|
||||
) {
|
||||
window = Buffer.alloc(Math.min(4096, input.fileSize - offset));
|
||||
windowOffset = offset;
|
||||
const observed = await handle.read(
|
||||
window,
|
||||
0,
|
||||
window.length,
|
||||
(input.fileOffset ?? 0) + offset,
|
||||
);
|
||||
throwIfTargetResolutionCancelled(input.signal);
|
||||
window = window.subarray(0, observed.bytesRead);
|
||||
}
|
||||
const relative = offset - windowOffset;
|
||||
const frame = commands.read(
|
||||
relative >= 0 ? window.subarray(relative, relative + 8) : Buffer.alloc(0),
|
||||
);
|
||||
if (!frame.ok) return frame;
|
||||
}
|
||||
return commands.finish();
|
||||
};
|
||||
|
||||
/** Validate declared FAT slices after the canonical table/identity admission succeeds. */
|
||||
const readMachoSliceCommandFrames = async (
|
||||
handle: FileHandle,
|
||||
slice: UniversalMachOSlice,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Result<null, string>> => {
|
||||
const prefix = Buffer.alloc(Math.min(4096, slice.size));
|
||||
const observed = await handle.read(prefix, 0, prefix.length, slice.offset);
|
||||
throwIfTargetResolutionCancelled(signal);
|
||||
const bytes = prefix.subarray(0, observed.bytesRead);
|
||||
const table = readMachoLoadCommandTableHeader(bytes, slice.size);
|
||||
if (!table.ok) return table;
|
||||
if (table.value === null)
|
||||
return err("FAT slice does not identify a thin Mach-O image");
|
||||
return readMachoCommandFrames(handle, table.value, {
|
||||
prefix: bytes,
|
||||
fileSize: slice.size,
|
||||
fileOffset: slice.offset,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
/** Read the validated FAT table and bounded thin-header identity prefixes. */
|
||||
const readFatSliceHeaderEvidence = async (
|
||||
handle: FileHandle,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { MachoLoadCommandCursor } from "../../domain/apple/machoLoadCommands.js";
|
||||
import type {
|
||||
MachoDependency,
|
||||
MachoImageFacts,
|
||||
@@ -5,7 +6,6 @@ import type {
|
||||
} from "../../domain/apple/dylibResolution.js";
|
||||
import { machoCpuSubtypeBase } from "../../domain/apple/machoCpuIdentity.js";
|
||||
import {
|
||||
machoLoadCommandAlignment,
|
||||
classifyFatContainerIdentity,
|
||||
readFatContainerHeader,
|
||||
readFatSliceDeclarations,
|
||||
@@ -427,7 +427,6 @@ const decodeCommands = (
|
||||
)[];
|
||||
readonly maxVmOffset: bigint;
|
||||
} => {
|
||||
const view = viewOf(bytes);
|
||||
const slice: MachoSlice = {
|
||||
architecture: architectureName(header.cpuType, header.cpuSubtype),
|
||||
slice_offset: header.sliceOffset,
|
||||
@@ -455,24 +454,17 @@ const decodeCommands = (
|
||||
let unreadableSegment: number | undefined;
|
||||
let lazyInfo = false;
|
||||
let lazyInfoCount = 0;
|
||||
let offset = 0;
|
||||
for (let index = 0; index < header.commandCount; index++) {
|
||||
if (offset + 8 > bytes.byteLength)
|
||||
throw new MachoFormatIssue(
|
||||
"malformed",
|
||||
`load command ${index} starts beyond sizeofcmds`,
|
||||
);
|
||||
const command = view.getUint32(offset, true);
|
||||
const size = view.getUint32(offset + 4, true);
|
||||
if (
|
||||
size < 8 ||
|
||||
size % machoLoadCommandAlignment(header.wide) !== 0 ||
|
||||
offset + size > bytes.byteLength
|
||||
)
|
||||
throw new MachoFormatIssue(
|
||||
"malformed",
|
||||
`load command ${index} at offset ${offset} has invalid cmdsize ${size}`,
|
||||
);
|
||||
const commands = new MachoLoadCommandCursor({
|
||||
wide: header.wide,
|
||||
littleEndian: true,
|
||||
commandCount: header.commandCount,
|
||||
commandBytes: bytes.byteLength,
|
||||
});
|
||||
while (commands.nextOffset !== null) {
|
||||
const offset = commands.nextOffset;
|
||||
const frame = commands.read(bytes.subarray(offset, offset + 8));
|
||||
if (!frame.ok) throw new MachoFormatIssue("malformed", frame.error);
|
||||
const { index, command, size } = frame.value;
|
||||
const body = bytes.subarray(offset, offset + size);
|
||||
if (command === LC_SEGMENT || command === LC_SEGMENT_64) {
|
||||
const span = readSegmentSpan(body, command === LC_SEGMENT_64);
|
||||
@@ -490,13 +482,9 @@ const decodeCommands = (
|
||||
}
|
||||
const decoded = decodeCommand(command, body, index, slice);
|
||||
if (decoded !== undefined) dependencies.push(decoded);
|
||||
offset += size;
|
||||
}
|
||||
if (offset !== bytes.byteLength)
|
||||
throw new MachoFormatIssue(
|
||||
"malformed",
|
||||
`load commands declare ${header.commandCount} commands but sizeofcmds holds ${bytes.byteLength} bytes`,
|
||||
);
|
||||
const complete = commands.finish();
|
||||
if (!complete.ok) throw new MachoFormatIssue("malformed", complete.error);
|
||||
if (lazyInfo && unreadableSegment !== undefined)
|
||||
throw new MachoFormatIssue(
|
||||
"malformed",
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { err, ok, type Result } from "../result.js";
|
||||
import { machoLoadCommandAlignment } from "./machoContainer.js";
|
||||
|
||||
/** Header commitments needed to walk commands without retaining their payloads. */
|
||||
export interface MachoLoadCommandTableHeader {
|
||||
readonly headerSize: 28 | 32;
|
||||
readonly wide: boolean;
|
||||
readonly littleEndian: boolean;
|
||||
readonly commandCount: number;
|
||||
readonly commandBytes: number;
|
||||
}
|
||||
|
||||
/** Read only the thin header's load-command framing; other formats return null. */
|
||||
export const readMachoLoadCommandTableHeader = (
|
||||
bytes: Uint8Array,
|
||||
fileSize: number,
|
||||
): Result<MachoLoadCommandTableHeader | null, string> => {
|
||||
if (bytes.byteLength < 4) return ok(null);
|
||||
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
const magic = view.getUint32(0, false);
|
||||
if (![0xfeedface, 0xfeedfacf, 0xcefaedfe, 0xcffaedfe].includes(magic))
|
||||
return ok(null);
|
||||
const wide = magic === 0xfeedfacf || magic === 0xcffaedfe;
|
||||
const headerSize = wide ? 32 : 28;
|
||||
if (bytes.byteLength < headerSize)
|
||||
return err(
|
||||
`truncated Mach-O header: a ${wide ? 64 : 32}-bit header needs ${headerSize} bytes; the file has ${fileSize}`,
|
||||
);
|
||||
const littleEndian = magic === 0xcefaedfe || magic === 0xcffaedfe;
|
||||
const commandBytes = view.getUint32(20, littleEndian);
|
||||
if (commandBytes > fileSize - headerSize)
|
||||
return err(
|
||||
`truncated Mach-O load commands: the header declares ${commandBytes} bytes after its ${headerSize}-byte header; the file has ${fileSize}`,
|
||||
);
|
||||
return ok({
|
||||
headerSize,
|
||||
wide,
|
||||
littleEndian,
|
||||
commandCount: view.getUint32(16, littleEndian),
|
||||
commandBytes,
|
||||
});
|
||||
};
|
||||
|
||||
/** One owner's count, alignment and extent proof for a sequential command walk. */
|
||||
export class MachoLoadCommandCursor {
|
||||
#index = 0;
|
||||
#offset = 0;
|
||||
|
||||
constructor(
|
||||
private readonly table: Pick<
|
||||
MachoLoadCommandTableHeader,
|
||||
"wide" | "littleEndian" | "commandCount" | "commandBytes"
|
||||
>,
|
||||
) {}
|
||||
|
||||
/** Offset relative to the command table; null when every declared row was read. */
|
||||
get nextOffset(): number | null {
|
||||
return this.#index < this.table.commandCount ? this.#offset : null;
|
||||
}
|
||||
|
||||
/** Consume exactly one command's eight-byte header, leaving its payload to callers. */
|
||||
read(bytes: Uint8Array): Result<
|
||||
{
|
||||
readonly index: number;
|
||||
readonly offset: number;
|
||||
readonly command: number;
|
||||
readonly size: number;
|
||||
},
|
||||
string
|
||||
> {
|
||||
const { commandBytes, littleEndian, wide } = this.table;
|
||||
if (this.#offset + 8 > commandBytes || bytes.byteLength < 8)
|
||||
return err(`load command ${this.#index} starts beyond sizeofcmds`);
|
||||
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
const size = view.getUint32(4, littleEndian);
|
||||
if (
|
||||
size < 8 ||
|
||||
size % machoLoadCommandAlignment(wide) !== 0 ||
|
||||
size > commandBytes - this.#offset
|
||||
)
|
||||
return err(
|
||||
`load command ${this.#index} at offset ${this.#offset} has invalid cmdsize ${size}`,
|
||||
);
|
||||
const frame = {
|
||||
index: this.#index,
|
||||
offset: this.#offset,
|
||||
command: view.getUint32(0, littleEndian),
|
||||
size,
|
||||
};
|
||||
this.#index += 1;
|
||||
this.#offset += size;
|
||||
return ok(frame);
|
||||
}
|
||||
|
||||
/** A successful walk must exhaust both ncmds and sizeofcmds. */
|
||||
finish(): Result<null, string> {
|
||||
return this.#index === this.table.commandCount &&
|
||||
this.#offset === this.table.commandBytes
|
||||
? ok(null)
|
||||
: err(
|
||||
`load commands declare ${this.table.commandCount} commands but sizeofcmds holds ${this.table.commandBytes} bytes`,
|
||||
);
|
||||
}
|
||||
}
|
||||
+25
-22
@@ -27,6 +27,10 @@ import {
|
||||
validateFatSliceIdentity,
|
||||
type FatSliceHeaderEvidence,
|
||||
} from "./apple/machoContainer.js";
|
||||
import {
|
||||
MachoLoadCommandCursor,
|
||||
readMachoLoadCommandTableHeader,
|
||||
} from "./apple/machoLoadCommands.js";
|
||||
|
||||
/** Format and architecture facts recovered from an executable header. */
|
||||
export type ExecutableMetadata =
|
||||
@@ -123,7 +127,7 @@ export const parseExecutableHeader = (
|
||||
if (bytes.length < 8) return err("truncated or unsupported binary header");
|
||||
const magic = bytes.readUInt32BE(0);
|
||||
if ([0xfeedface, 0xfeedfacf, 0xcefaedfe, 0xcffaedfe].includes(magic))
|
||||
return parseThinMachO(bytes, magic, fileSize);
|
||||
return parseThinMachO(bytes, fileSize);
|
||||
const fatHeader =
|
||||
fatEvidence?.header ?? readFatContainerHeader(bytes, fileSize);
|
||||
if (fatHeader.state !== "not-fat")
|
||||
@@ -136,36 +140,35 @@ const CPU_TYPE_ARM64_32 = 0x0200000c;
|
||||
|
||||
const parseThinMachO = (
|
||||
bytes: Buffer,
|
||||
magic: number,
|
||||
fileSize: number,
|
||||
): Result<ExecutableMetadata, string> => {
|
||||
const is64 = magic === 0xfeedfacf || magic === 0xcffaedfe;
|
||||
const headerSize = is64 ? 32 : 28;
|
||||
if (bytes.length < headerSize)
|
||||
return err(
|
||||
`truncated Mach-O header: a ${is64 ? 64 : 32}-bit header needs ${headerSize} bytes; the file has ${fileSize}`,
|
||||
);
|
||||
const table = readMachoLoadCommandTableHeader(bytes, fileSize);
|
||||
if (!table.ok) return table;
|
||||
if (table.value === null) return err("unsupported binary format");
|
||||
const identity = readMachoHeaderPrefixFacts(bytes.subarray(0, 12)).identity;
|
||||
if (identity === null) return err("unsupported Mach-O architecture");
|
||||
const little = magic === 0xcefaedfe || magic === 0xcffaedfe;
|
||||
const read = (offset: number): number =>
|
||||
little ? bytes.readUInt32LE(offset) : bytes.readUInt32BE(offset);
|
||||
const architecture =
|
||||
identity.cpuType === CPU_TYPE_ARM64_32
|
||||
? "arm64_32"
|
||||
: machArchitecture(identity.cpuType);
|
||||
if (architecture === undefined) return err("unsupported Mach-O architecture");
|
||||
const commandBytes = read(20);
|
||||
if (headerSize + commandBytes > fileSize)
|
||||
return err(
|
||||
`truncated Mach-O load commands: the header declares ${commandBytes} bytes after its ${headerSize}-byte header; the file has ${fileSize}`,
|
||||
);
|
||||
if (architecture === "arm64_32")
|
||||
return ok({
|
||||
format: "mach-o",
|
||||
architecture,
|
||||
availableArchitectures: [architecture],
|
||||
});
|
||||
const commands = new MachoLoadCommandCursor(table.value);
|
||||
while (commands.nextOffset !== null) {
|
||||
const offset = table.value.headerSize + commands.nextOffset;
|
||||
// Header-only callers may retain a prefix. File-backed admission walks
|
||||
// every remaining command header through its bounded reader.
|
||||
if (
|
||||
offset + 8 > bytes.length &&
|
||||
commands.nextOffset + 8 <= table.value.commandBytes
|
||||
)
|
||||
break;
|
||||
const frame = commands.read(bytes.subarray(offset, offset + 8));
|
||||
if (!frame.ok) return frame;
|
||||
}
|
||||
if (commands.nextOffset === null) {
|
||||
const complete = commands.finish();
|
||||
if (!complete.ok) return complete;
|
||||
}
|
||||
return ok({
|
||||
format: "mach-o",
|
||||
architecture,
|
||||
|
||||
@@ -42,7 +42,7 @@ describe("native Mach-O slice selection", () => {
|
||||
Buffer.from(machoImageWithUnknownCommand(true, 12)),
|
||||
"arm64",
|
||||
),
|
||||
).toThrow(/Invalid load command size/u);
|
||||
).toThrow(/invalid cmdsize/u);
|
||||
});
|
||||
|
||||
it("accepts an aligned load command", () => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { MachoLoadCommandCursor } from "../domain/apple/machoLoadCommands.js";
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import type { FixupCommands } from "./AppleMachoFixups.js";
|
||||
@@ -7,7 +8,6 @@ import {
|
||||
} from "./MachoArchitectureSelection.js";
|
||||
import { machoCpuSubtypeBase } from "../domain/apple/machoCpuIdentity.js";
|
||||
import {
|
||||
machoLoadCommandAlignment,
|
||||
readFatSliceDeclarations,
|
||||
validateFatSlice,
|
||||
type FatSliceDeclaration,
|
||||
@@ -229,24 +229,26 @@ export const parseMachoLayout = (
|
||||
chained: null,
|
||||
baseAddress: undefined,
|
||||
};
|
||||
let cursor = slice + 32;
|
||||
for (let index = 0; index < commands; index++) {
|
||||
if (cursor + 8 > commandEnd) throw new RangeError("Truncated load command");
|
||||
const framing = new MachoLoadCommandCursor({
|
||||
wide: true,
|
||||
littleEndian: true,
|
||||
commandCount: commands,
|
||||
commandBytes: commandEnd - slice - 32,
|
||||
});
|
||||
while (framing.nextOffset !== null) {
|
||||
const cursor = slice + 32 + framing.nextOffset;
|
||||
const frame = framing.read(bytes.subarray(cursor, cursor + 8));
|
||||
if (!frame.ok) throw new RangeError(frame.error);
|
||||
const command = {
|
||||
cursor,
|
||||
kind: bytes.readUInt32LE(cursor),
|
||||
size: bytes.readUInt32LE(cursor + 4),
|
||||
kind: frame.value.command,
|
||||
size: frame.value.size,
|
||||
};
|
||||
if (
|
||||
command.size < 8 ||
|
||||
command.size % machoLoadCommandAlignment(true) !== 0 ||
|
||||
cursor + command.size > commandEnd
|
||||
)
|
||||
throw new RangeError("Invalid load command size");
|
||||
if (command.kind === 0x19) collectSegment(bytes, command, state);
|
||||
else collectFixupCommand(bytes, command, state);
|
||||
cursor += command.size;
|
||||
}
|
||||
const complete = framing.finish();
|
||||
if (!complete.ok) throw new RangeError(complete.error);
|
||||
const { segments, sections, chained, binds, rebases, dylibs } = state;
|
||||
const offset = (address: bigint, size = 1): number => {
|
||||
const matches = segments.filter(
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
type LoadRegion,
|
||||
type ZeroFillMapping,
|
||||
} from "./BinaryContainer.js";
|
||||
import { machoLoadCommandAlignment } from "../../domain/apple/machoContainer.js";
|
||||
import { MachoLoadCommandCursor } from "../../domain/apple/machoLoadCommands.js";
|
||||
|
||||
/** Read thin Mach-O images, retaining the slice's exact file-backed mappings. */
|
||||
export const readMachoImage = (bytes: Buffer): BinaryContainer => {
|
||||
@@ -26,31 +26,23 @@ export const readMachoImage = (bytes: Buffer): BinaryContainer => {
|
||||
1,
|
||||
"Mach-O load commands",
|
||||
);
|
||||
if (count > Math.floor(commandBytes / 8))
|
||||
throw new BinaryFormatFailure(
|
||||
"malformed",
|
||||
"Mach-O command count exceeds its load-command range",
|
||||
);
|
||||
const mappings: FileMapping[] = [];
|
||||
const loadRegions: LoadRegion[] = [];
|
||||
const executableMappings: FileMapping[] = [];
|
||||
const zeroFills: ZeroFillMapping[] = [];
|
||||
const sections: ImageSection[] = [];
|
||||
let chainedFixups = false;
|
||||
let offset = commandStart;
|
||||
const end = commandStart + commandBytes;
|
||||
for (let index = 0; index < count; index++) {
|
||||
const command = reader.u32(offset);
|
||||
const size = reader.u32(offset + 4);
|
||||
if (
|
||||
size < 8 ||
|
||||
size % machoLoadCommandAlignment(bits === 64) !== 0 ||
|
||||
size > end - offset
|
||||
)
|
||||
throw new BinaryFormatFailure(
|
||||
"malformed",
|
||||
"Mach-O load command size is invalid or truncated",
|
||||
);
|
||||
const commands = new MachoLoadCommandCursor({
|
||||
wide: bits === 64,
|
||||
littleEndian: little,
|
||||
commandCount: count,
|
||||
commandBytes,
|
||||
});
|
||||
while (commands.nextOffset !== null) {
|
||||
const offset = commandStart + commands.nextOffset;
|
||||
const frame = commands.read(bytes.subarray(offset, offset + 8));
|
||||
if (!frame.ok) throw new BinaryFormatFailure("malformed", frame.error);
|
||||
const { command, size } = frame.value;
|
||||
if (command === 0x80000034) chainedFixups = true;
|
||||
if (command === 1 || command === 0x19) {
|
||||
const wide = command === 0x19;
|
||||
@@ -143,13 +135,9 @@ export const readMachoImage = (bytes: Buffer): BinaryContainer => {
|
||||
);
|
||||
}
|
||||
}
|
||||
offset += size;
|
||||
}
|
||||
if (offset !== end)
|
||||
throw new BinaryFormatFailure(
|
||||
"malformed",
|
||||
"Mach-O load-command byte count does not match its commands",
|
||||
);
|
||||
const complete = commands.finish();
|
||||
if (!complete.ok) throw new BinaryFormatFailure("malformed", complete.error);
|
||||
const cpu = reader.u32(4);
|
||||
const names = new Map([
|
||||
[7, "386"],
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
import { parseBinaryTarget } from "../../../src/application/BinaryTargetResolver.js";
|
||||
import { thinMach } from "../../../src/domain/binaryTarget.fixture.js";
|
||||
import { parseExecutableHeader } from "../../../src/domain/binaryTarget.js";
|
||||
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
|
||||
import { BinaryTargetError } from "../../../src/domain/configurationErrors.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import {
|
||||
CPU,
|
||||
fatImage,
|
||||
} from "../../../src/artifacts/apple/MachoImage.fixture.js";
|
||||
|
||||
const commandImage = (
|
||||
wide: boolean,
|
||||
littleEndian: boolean,
|
||||
sizes: readonly number[],
|
||||
): Buffer => {
|
||||
const magic = wide
|
||||
? littleEndian
|
||||
? 0xcffaedfe
|
||||
: 0xfeedfacf
|
||||
: littleEndian
|
||||
? 0xcefaedfe
|
||||
: 0xfeedface;
|
||||
const header = thinMach(magic, wide ? 0x0100000c : 7);
|
||||
const bytes = Buffer.alloc(
|
||||
header.length + sizes.reduce((total, size) => total + size, 0),
|
||||
);
|
||||
header.copy(bytes);
|
||||
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
view.setUint32(16, sizes.length, littleEndian);
|
||||
view.setUint32(20, bytes.length - header.length, littleEndian);
|
||||
let offset = header.length;
|
||||
for (const size of sizes) {
|
||||
view.setUint32(offset, 0x7fffffff, littleEndian); // Unknown commands still have framing.
|
||||
view.setUint32(offset + 4, size, littleEndian);
|
||||
offset += size;
|
||||
}
|
||||
return bytes;
|
||||
};
|
||||
|
||||
it.each([
|
||||
{ label: "undersized", field: 36, value: 4 },
|
||||
{ label: "unaligned", field: 36, value: 12 },
|
||||
{ label: "outside table", field: 36, value: 0xffffffff },
|
||||
{ label: "too many commands", field: 16, value: 2 },
|
||||
{ label: "unclaimed table bytes", field: 16, value: 0 },
|
||||
{ label: "trailing table bytes", field: 36, value: 8 },
|
||||
])(
|
||||
"rejects $label thin command framing before provider admission",
|
||||
async ({ field, value }) => {
|
||||
const bytes = commandImage(true, true, [16]);
|
||||
bytes.writeUInt32LE(value, field);
|
||||
const path = join(
|
||||
await createTestTempDirectory("rea-macho-command-"),
|
||||
"image",
|
||||
);
|
||||
await writeFile(path, bytes);
|
||||
expect(parseExecutableHeader(bytes, "arm64").ok).toBe(false);
|
||||
const resolved = await parseBinaryTarget(path);
|
||||
expect(resolved.ok).toBe(false);
|
||||
if (resolved.ok || !(resolved.error instanceof BinaryTargetError))
|
||||
throw new Error("Expected target format refusal");
|
||||
expect(projectAnalysisError(resolved.error)).toMatchObject({
|
||||
code: "target_unavailable",
|
||||
category: "unsupported_target",
|
||||
});
|
||||
expect(resolved.error.reason).toMatch(/load command/iu);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{ wide: false, littleEndian: false, size: 12 },
|
||||
{ wide: false, littleEndian: true, size: 12 },
|
||||
{ wide: true, littleEndian: false, size: 16 },
|
||||
{ wide: true, littleEndian: true, size: 16 },
|
||||
])(
|
||||
"retains width-specific alignment and endian controls ($wide/$littleEndian)",
|
||||
async ({ wide, littleEndian, size }) => {
|
||||
const bytes = commandImage(wide, littleEndian, [size]);
|
||||
const path = join(
|
||||
await createTestTempDirectory("rea-macho-command-control-"),
|
||||
"image",
|
||||
);
|
||||
await writeFile(path, bytes);
|
||||
expect(await parseBinaryTarget(path)).toMatchObject({
|
||||
ok: true,
|
||||
value: { format: "mach-o", architecture: wide ? "arm64" : "x86" },
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each([false, true])(
|
||||
"walks command headers beyond the probe window (corrupt=%s)",
|
||||
async (corrupt) => {
|
||||
const bytes = commandImage(true, true, [4104, 16]);
|
||||
if (corrupt) bytes.writeUInt32LE(0xffffffff, 32 + 4104 + 4);
|
||||
const path = join(
|
||||
await createTestTempDirectory("rea-macho-command-window-"),
|
||||
"image",
|
||||
);
|
||||
await writeFile(path, bytes);
|
||||
const resolved = await parseBinaryTarget(path);
|
||||
expect(resolved.ok).toBe(!corrupt);
|
||||
if (!resolved.ok) {
|
||||
if (!(resolved.error instanceof BinaryTargetError)) throw resolved.error;
|
||||
expect(resolved.error.reason).toContain("load command 1");
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each([null, 0, 1])(
|
||||
"checks every admitted FAT slice's framing (corrupt slice %s)",
|
||||
async (corruptIndex) => {
|
||||
const slices = [
|
||||
commandImage(true, true, [16]),
|
||||
commandImage(true, true, [16]),
|
||||
];
|
||||
const x86 = slices[0]!;
|
||||
x86.writeUInt32LE(CPU.x86_64.type, 4);
|
||||
x86.writeUInt32LE(CPU.x86_64.subtype, 8);
|
||||
if (corruptIndex !== null)
|
||||
slices[corruptIndex]!.writeUInt32LE(0xffffffff, 36);
|
||||
const bytes = fatImage([
|
||||
{ cpu: CPU.x86_64, bytes: x86 },
|
||||
{ cpu: CPU.arm64, bytes: slices[1]! },
|
||||
]);
|
||||
const path = join(
|
||||
await createTestTempDirectory("rea-fat-command-"),
|
||||
"image",
|
||||
);
|
||||
await writeFile(path, bytes);
|
||||
const resolved = await parseBinaryTarget(path, {
|
||||
hostArchitecture: "arm64",
|
||||
});
|
||||
expect(resolved.ok).toBe(corruptIndex === null);
|
||||
if (!resolved.ok) {
|
||||
if (!(resolved.error instanceof BinaryTargetError)) throw resolved.error;
|
||||
expect(resolved.error.reason).toContain("invalid cmdsize");
|
||||
expect(projectAnalysisError(resolved.error)).toMatchObject({
|
||||
code: "target_unavailable",
|
||||
});
|
||||
} else
|
||||
expect(resolved.value).toMatchObject({
|
||||
format: "mach-o-universal",
|
||||
architecture: "arm64",
|
||||
});
|
||||
},
|
||||
);
|
||||
Reference in New Issue
Block a user