refactor(macho): share bounded header declarations

This commit is contained in:
morluto committed 2026-10-26 15:49:52 +08:00
1 parent f8c5a6cc10
commit 0f64f1f855
6 files changed
+130 -47

No files matched your search

@@ -1866,3 +1866,21 @@ it.each([true, false])(
});
},
);
it("observes only the common 28-byte header of a 64-bit slice", async () => {
const bytes = machoImage({ fileType: FILE_TYPE.dylib }).subarray(0, 28);
const readAt = vi.fn(readerOf(bytes));
expect(await readMachoImage(readAt, 32)).toMatchObject({
status: "parsed",
slices: [{ file_type: "dylib", file_type_code: FILE_TYPE.dylib }],
});
expect(readAt.mock.calls).toEqual([
[0, 8],
[0, 28],
[32, 0],
]);
expect(await readMachoImage(readerOf(bytes), 28)).toEqual({
status: "malformed",
reason: "load commands extend beyond the Mach-O slice",
});
});
+14 -30
View File
@@ -1,5 +1,8 @@
import { readMachoSegmentHeader } from "../../domain/apple/machoSegmentHeader.js";
import { MachoLoadCommandCursor } from "../../domain/apple/machoLoadCommands.js";
import {
MachoLoadCommandCursor,
readMachoHeaderDeclarations,
} from "../../domain/apple/machoLoadCommands.js";
import {
MAX_JVM_CLASS_IDENTITY_BYTES,
JVM_CLASS_IDENTITY_LIMIT_REASON,
@@ -21,7 +24,6 @@ import {
classifyFatContainerIdentity,
readFatContainerHeader,
readFatSliceDeclarations,
readMachoHeaderPrefixFacts,
validateFatSlice,
validateFatSliceIdentity,
type FatSliceDeclaration,
@@ -312,24 +314,13 @@ const readSlice = async (
readonly alignmentExponent: number;
} | null = null,
): Promise<MachoSlice> => {
const header = viewOf(await readExact(readAt, base, 28, "Mach-O header"));
const headerBytes = new Uint8Array(
header.buffer,
header.byteOffset,
header.byteLength,
);
const identity = readMachoHeaderPrefixFacts(headerBytes).identity;
const magic = header.getUint32(0, true);
if (magic === MH_MAGIC_64 || magic === MH_MAGIC) {
if (identity === null)
throw new MachoFormatIssue(
"malformed",
`slice at offset ${base} has no Mach-O header`,
);
const wide = magic === MH_MAGIC_64;
const headerSize = wide ? 32 : 28;
const commandCount = header.getUint32(16, true);
const commandBytes = header.getUint32(20, true);
const header = await readExact(readAt, base, 28, "Mach-O header");
const parsed = readMachoHeaderDeclarations(header, length);
if (!parsed.ok)
throw new MachoFormatIssue("malformed", "Mach-O header is truncated");
if (parsed.value !== null && parsed.value.littleEndian) {
const { wide, headerSize, commandCount, commandBytes, fileType, identity } =
parsed.value;
if (commandBytes > MAX_LOAD_COMMAND_BYTES)
throw new MachoFormatIssue(
"unsupported",
@@ -350,7 +341,7 @@ const readSlice = async (
wide,
cpuType: identity.cpuType,
cpuSubtype: identity.cpuSubtype,
fileType: header.getUint32(12, true),
fileType,
commandCount,
sliceOffset: base,
sliceSize: length,
@@ -373,10 +364,10 @@ const readSlice = async (
return slice;
}
throw new MachoFormatIssue(
magic === swap(MH_MAGIC) || magic === swap(MH_MAGIC_64)
parsed.value !== null && !parsed.value.littleEndian
? "unsupported"
: "malformed",
magic === swap(MH_MAGIC) || magic === swap(MH_MAGIC_64)
parsed.value !== null && !parsed.value.littleEndian
? "big-endian Mach-O slices are not supported"
: `slice at offset ${base} has no Mach-O header`,
);
@@ -1021,12 +1012,5 @@ const fileTypeName = (fileType: number): MachoSlice["file_type"] =>
const version = (value: number): string =>
`${value >>> 16}.${(value >>> 8) & 0xff}.${value & 0xff}`;
const swap = (value: number): number =>
(((value & 0xff) << 24) |
((value & 0xff00) << 8) |
((value >>> 8) & 0xff00) |
(value >>> 24)) >>>
0;
const viewOf = (bytes: Uint8Array): DataView =>
new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
@@ -0,0 +1,59 @@
import { expect, it } from "vitest";
import {
readMachoHeaderDeclarations,
readMachoLoadCommandDeclarations,
readMachoLoadCommandTableHeader,
} from "./machoLoadCommands.js";
it.each([
{ magic: 0xfeedface, wide: false, littleEndian: false },
{ magic: 0xcefaedfe, wide: false, littleEndian: true },
{ magic: 0xfeedfacf, wide: true, littleEndian: false },
{ magic: 0xcffaedfe, wide: true, littleEndian: true },
])(
"separates common header observations from full header and table admission ($magic)",
({ magic, wide, littleEndian }) => {
const backing = Buffer.alloc(43, 0xa5);
const bytes = backing.subarray(7, 35);
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
view.setUint32(0, magic, false);
view.setUint32(4, 0xfedcba98, littleEndian);
view.setUint32(8, 0x89abcdef, littleEndian);
view.setUint32(12, 0xffffffff, littleEndian);
view.setUint32(16, 0x80000001, littleEndian);
view.setUint32(20, 0xffffffff, littleEndian);
const expected = {
headerSize: wide ? 32 : 28,
wide,
littleEndian,
commandCount: 0x80000001,
commandBytes: 0xffffffff,
fileType: 0xffffffff,
identity: { cpuType: 0xfedcba98, cpuSubtype: 0x89abcdef },
};
expect(readMachoHeaderDeclarations(bytes, 32)).toEqual({
ok: true,
value: expected,
});
expect(readMachoLoadCommandDeclarations(bytes, 32)).toEqual(
wide
? {
ok: false,
error:
"truncated Mach-O header: a 64-bit header needs 32 bytes; the file has 32",
}
: { ok: true, value: expected },
);
const complete = backing.subarray(7, wide ? 39 : 35);
expect(readMachoLoadCommandDeclarations(complete, 32)).toEqual({
ok: true,
value: expected,
});
expect(readMachoLoadCommandTableHeader(complete, 32)).toMatchObject({
ok: false,
});
expect(
readMachoHeaderDeclarations(bytes.subarray(0, 27), 32),
).toMatchObject({ ok: false });
},
);
+37 -9
View File
@@ -1,5 +1,8 @@
import { err, ok, type Result } from "../result.js";
import { machoLoadCommandAlignment } from "./machoContainer.js";
import {
machoLoadCommandAlignment,
readMachoHeaderPrefixFacts,
} from "./machoContainer.js";
/** Header commitments needed to walk commands without retaining their payloads. */
export interface MachoLoadCommandTableHeader {
@@ -10,23 +13,31 @@ export interface MachoLoadCommandTableHeader {
readonly commandBytes: number;
}
/** Decode thin-header declarations without admitting their table extent. */
export const readMachoLoadCommandDeclarations = (
export interface MachoHeaderDeclarations extends MachoLoadCommandTableHeader {
readonly fileType: number;
readonly identity: { readonly cpuType: number; readonly cpuSubtype: number };
}
/** Decode the common 28-byte header without requiring the 64-bit reserved word. */
export const readMachoHeaderDeclarations = (
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))
): Result<MachoHeaderDeclarations | null, string> => {
const prefix = readMachoHeaderPrefixFacts(bytes);
const magic = prefix.magic;
if (
magic === null ||
![0xfeedface, 0xfeedfacf, 0xcefaedfe, 0xcffaedfe].includes(magic)
)
return ok(null);
const wide = magic === 0xfeedfacf || magic === 0xcffaedfe;
const headerSize = wide ? 32 : 28;
if (bytes.byteLength < headerSize)
if (bytes.byteLength < 28 || prefix.identity === null)
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 view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const commandBytes = view.getUint32(20, littleEndian);
return ok({
headerSize,
@@ -34,9 +45,26 @@ export const readMachoLoadCommandDeclarations = (
littleEndian,
commandCount: view.getUint32(16, littleEndian),
commandBytes,
fileType: view.getUint32(12, littleEndian),
identity: prefix.identity,
});
};
/** Require the full declared 28/32-byte thin header, without admitting its command table. */
export const readMachoLoadCommandDeclarations = (
bytes: Uint8Array,
fileSize: number,
): Result<MachoHeaderDeclarations | null, string> => {
const parsed = readMachoHeaderDeclarations(bytes, fileSize);
if (!parsed.ok || parsed.value === null) return parsed;
const { wide, headerSize } = parsed.value;
if (bytes.byteLength < headerSize)
return err(
`truncated Mach-O header: a ${wide ? 64 : 32}-bit header needs ${headerSize} bytes; the file has ${fileSize}`,
);
return parsed;
};
/** Admit the declared command table within the complete file's extent. */
export const readMachoLoadCommandTableHeader = (
bytes: Uint8Array,
+1 -7
View File
@@ -15,7 +15,6 @@ import {
import { machoCpuSubtypeBase } from "../domain/apple/machoCpuIdentity.js";
import {
readFatSliceDeclarations,
readMachoHeaderPrefixFacts,
validateFatSlice,
validateFatSliceIdentity,
type FatSliceDeclaration,
@@ -180,12 +179,7 @@ export const parseMachoLayout = (
);
}
const table = declarations.value;
const identity = readMachoHeaderPrefixFacts(selectedBytes).identity;
if (identity === null)
throw new TypeError(
"Only little-endian 64-bit Mach-O metadata is supported",
);
const { cpuType, cpuSubtype } = identity;
const { cpuType, cpuSubtype } = table.identity;
const fat = selection.fatDeclaration;
if (fat !== undefined) {
const issue = validateFatSliceIdentity(fat, {
+1 -1
View File
@@ -150,7 +150,7 @@ export const readMachoImage = (bytes: Buffer): BinaryContainer => {
}
const complete = commands.finish();
if (!complete.ok) throw new BinaryFormatFailure("malformed", complete.error);
const cpu = reader.u32(4);
const cpu = table.identity.cpuType;
const names = new Map([
[7, "386"],
[0x01000007, "amd64"],