refactor(macho): centralize dylib command and string facts

This commit is contained in:
morluto committed 2026-10-27 01:38:20 +08:00
1 parent 6d5532a204
commit e405d685bc
5 files changed
+207 -63

No files matched your search

+43 -52
View File
@@ -1,7 +1,13 @@
import {
machoDependencyCommand,
readMachoDylibCommandFacts,
} from "../../domain/apple/machoDylibCommand.js";
import { readMachoSegmentHeader } from "../../domain/apple/machoSegmentHeader.js";
import {
MachoLoadCommandCursor,
readMachoHeaderDeclarations,
readMachoCommandStringBytes,
type MachoCommandStringFailure,
} from "../../domain/apple/machoLoadCommands.js";
import {
MAX_JVM_CLASS_IDENTITY_BYTES,
@@ -53,12 +59,7 @@ export const MAX_LAZY_INFO_SYMBOL_REFERENCES = 64 * 1024;
const LC_SEGMENT = 0x1;
const LC_SEGMENT_64 = 0x19;
const LC_ID_DYLIB = 0xd;
const LC_LOAD_DYLIB = 0xc;
const LC_LOAD_WEAK_DYLIB = 0x80000018;
const LC_REEXPORT_DYLIB = 0x8000001f;
const LC_LAZY_LOAD_DYLIB = 0x20;
const LC_LAZY_LOAD_DYLIB_INFO = 0x3a;
const LC_LOAD_UPWARD_DYLIB = 0x80000023;
const LC_RPATH = 0x8000001c;
const LC_DYLD_ENVIRONMENT = 0x27;
const LC_CODE_SIGNATURE = 0x1d;
@@ -75,8 +76,6 @@ const VERSION_MIN_PLATFORMS: ReadonlyMap<number, MachoVersionMinPlatform> =
[0x30, 4], // LC_VERSION_MIN_WATCHOS
]);
/** `mach-o/loader.h` `dylib_use_command`: `nameoff == 28` and this marker. */
const DYLIB_USE_MARKER = 0x1a741800;
const LAZY_INFO_HEADER_BYTES = 24;
/** ld64 may-be-missing bit: the lazy image is allowed to be absent. */
const LAZY_INFO_MAY_BE_MISSING = 0x1;
@@ -90,15 +89,6 @@ const DYLIB_USE_REEXPORT = 0x2;
const DYLIB_USE_UPWARD = 0x4;
const DYLIB_USE_DELAYED_INIT = 0x8;
const DEPENDENCY_COMMANDS: ReadonlyMap<number, MachoDependency["command"]> =
new Map([
[LC_LOAD_DYLIB, "LC_LOAD_DYLIB"],
[LC_LOAD_WEAK_DYLIB, "LC_LOAD_WEAK_DYLIB"],
[LC_REEXPORT_DYLIB, "LC_REEXPORT_DYLIB"],
[LC_LAZY_LOAD_DYLIB, "LC_LAZY_LOAD_DYLIB"],
[LC_LOAD_UPWARD_DYLIB, "LC_LOAD_UPWARD_DYLIB"],
]);
/** Malformed or unsupported structure found while decoding one image. */
class MachoFormatIssue extends Error {
constructor(
@@ -634,8 +624,8 @@ const decodeCommand = (
}
if (command === LC_LAZY_LOAD_DYLIB_INFO)
return decodeLazyLoadDylibInfoCommand(body, index);
const name = DEPENDENCY_COMMANDS.get(command);
if (name !== undefined)
const name = machoDependencyCommand(command);
if (name !== null)
return { kind: "parsed", value: decodeDependency(name, body, index) };
return undefined;
};
@@ -903,22 +893,21 @@ const decodeDependency = (
body: Uint8Array,
index: number,
): MachoDependency => {
if (body.byteLength < 24)
throw new MachoFormatIssue(
"malformed",
`load command ${index} is too short for a dylib command`,
);
const view = viewOf(body);
const nameOffset = view.getUint32(8, true);
const usesFlags =
nameOffset === 28 &&
body.byteLength >= 28 &&
view.getUint32(12, true) === DYLIB_USE_MARKER;
const flags = usesFlags ? view.getUint32(24, true) : 0;
const parsed = readMachoDylibCommandFacts(body, true);
if (!parsed.ok) {
if (parsed.error._tag === "InvalidMachoDylibCommand")
throw new MachoFormatIssue(
"malformed",
`load command ${index} is too short for a dylib command`,
);
return throwCommandStringIssue(parsed.error, index);
}
const facts = parsed.value;
const flags = facts.flags ?? 0;
return {
command,
encoding: usesFlags ? "dylib_use_command" : "dylib_command",
install_name: commandString(body, usesFlags ? 28 : 24, index),
encoding: facts.encoding,
install_name: decodeCommandString(facts.nameBytes, index),
weak:
command === "LC_LOAD_WEAK_DYLIB" || (flags & DYLIB_USE_WEAK_LINK) !== 0,
upward:
@@ -926,8 +915,8 @@ const decodeDependency = (
reexport:
command === "LC_REEXPORT_DYLIB" || (flags & DYLIB_USE_REEXPORT) !== 0,
delayed_init: (flags & DYLIB_USE_DELAYED_INIT) !== 0,
current_version: version(view.getUint32(16, true)),
compatibility_version: version(view.getUint32(20, true)),
current_version: version(facts.currentVersion),
compatibility_version: version(facts.compatibilityVersion),
};
};
@@ -937,27 +926,29 @@ const commandString = (
minimumOffset: number,
index: number,
): string => {
if (body.byteLength < 12)
throw new MachoFormatIssue(
"malformed",
`load command ${index} is too short to hold a string offset`,
);
const offset = viewOf(body).getUint32(8, true);
if (offset < minimumOffset || offset >= body.byteLength)
throw new MachoFormatIssue(
"malformed",
`load command ${index} has string offset ${offset} outside the command`,
);
const end = body.indexOf(0, offset);
if (end < 0)
throw new MachoFormatIssue(
"malformed",
`load command ${index} has an unterminated string`,
);
const parsed = readMachoCommandStringBytes(body, minimumOffset, true);
if (!parsed.ok) return throwCommandStringIssue(parsed.error, index);
return decodeCommandString(parsed.value.bytes, index);
};
const throwCommandStringIssue = (
failure: MachoCommandStringFailure,
index: number,
): never => {
const reason =
failure.reason === "truncated-offset"
? `load command ${index} is too short to hold a string offset`
: failure.reason === "invalid-offset"
? `load command ${index} has string offset ${failure.offset} outside the command`
: `load command ${index} has an unterminated string`;
throw new MachoFormatIssue("malformed", reason);
};
const decodeCommandString = (bytes: Uint8Array, index: number): string => {
try {
// lc_str contains name/path data, so a leading U+FEFF is part of its identity.
return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(
body.subarray(offset, end),
bytes,
);
} catch {
throw new MachoFormatIssue(
+67
View File
@@ -0,0 +1,67 @@
import type { MachoDependency } from "./dylibResolution.js";
import { err, ok, type Result } from "../result.js";
import {
readMachoCommandStringBytes,
type MachoCommandStringFailure,
} from "./machoLoadCommands.js";
const DEPENDENCY_COMMANDS: ReadonlyMap<number, MachoDependency["command"]> =
new Map([
[0xc, "LC_LOAD_DYLIB"],
[0x80000018, "LC_LOAD_WEAK_DYLIB"],
[0x8000001f, "LC_REEXPORT_DYLIB"],
[0x20, "LC_LAZY_LOAD_DYLIB"],
[0x80000023, "LC_LOAD_UPWARD_DYLIB"],
]);
/** Dependency order follows command order; LC_ID_DYLIB does not consume an ordinal. */
export const machoDependencyCommand = (code: number) =>
DEPENDENCY_COMMANDS.get(code) ?? null;
interface MachoDylibCommandFacts {
readonly encoding: "dylib_command" | "dylib_use_command";
/** Classic commands have no flags field. Unknown modern bits remain source facts. */
readonly flags: number | null;
readonly currentVersion: number;
readonly compatibilityVersion: number;
readonly timestampOrMarker: number;
readonly nameOffset: number;
readonly nameBytes: Uint8Array;
}
/** Decode classic or marker-bearing dylib fields without imposing text or flag policy. */
export const readMachoDylibCommandFacts = (
body: Uint8Array,
littleEndian: boolean,
): Result<
MachoDylibCommandFacts,
| {
readonly _tag: "InvalidMachoDylibCommand";
readonly reason: "truncated-header";
}
| MachoCommandStringFailure
> => {
if (body.byteLength < 24)
return err({
_tag: "InvalidMachoDylibCommand",
reason: "truncated-header",
});
const name = readMachoCommandStringBytes(body, 24, littleEndian);
if (!name.ok) return name;
const view = new DataView(body.buffer, body.byteOffset, body.byteLength);
const timestampOrMarker = view.getUint32(12, littleEndian);
// Recognition already establishes the modern minimum name offset of 28.
const usesFlags =
name.value.offset === 28 &&
body.byteLength >= 28 &&
timestampOrMarker === 0x1a741800;
return ok({
encoding: usesFlags ? "dylib_use_command" : "dylib_command",
flags: usesFlags ? view.getUint32(24, littleEndian) : null,
currentVersion: view.getUint32(16, littleEndian),
compatibilityVersion: view.getUint32(20, littleEndian),
timestampOrMarker,
nameOffset: name.value.offset,
nameBytes: name.value.bytes,
});
};
+39
View File
@@ -134,3 +134,42 @@ export class MachoLoadCommandCursor {
);
}
}
export type MachoCommandStringFailure =
| {
readonly _tag: "InvalidMachoCommandString";
readonly reason: "truncated-offset" | "unterminated";
}
| {
readonly _tag: "InvalidMachoCommandString";
readonly reason: "invalid-offset";
readonly offset: number;
};
/** Locate an lc_str payload within one admitted command; callers own text interpretation. */
export const readMachoCommandStringBytes = (
body: Uint8Array,
minimumOffset: number,
littleEndian: boolean,
): Result<
{ readonly offset: number; readonly bytes: Uint8Array },
MachoCommandStringFailure
> => {
if (body.byteLength < 12)
return err({
_tag: "InvalidMachoCommandString",
reason: "truncated-offset",
});
const view = new DataView(body.buffer, body.byteOffset, body.byteLength);
const offset = view.getUint32(8, littleEndian);
if (offset < minimumOffset || offset >= body.byteLength)
return err({
_tag: "InvalidMachoCommandString",
reason: "invalid-offset",
offset,
});
const end = body.indexOf(0, offset);
if (end < 0)
return err({ _tag: "InvalidMachoCommandString", reason: "unterminated" });
return ok({ offset, bytes: body.subarray(offset, end) });
};
+35
View File
@@ -5,10 +5,12 @@ import {
CPU,
LC,
dylibCommand,
dylibUseCommand,
fatImage,
machoImage,
machoImageWithUnknownCommand,
} from "../artifacts/apple/MachoImage.fixture.js";
import { readMachoImage } from "../artifacts/apple/MachoLoadCommandReader.js";
import {
parseMachoLayout,
selectMachoSlice,
@@ -340,6 +342,39 @@ describe("native Mach-O fixup load command boundaries", () => {
"\ufeff/libé",
]);
});
it.each<[string, Uint8Array]>([
["load", dylibCommand(LC.LOAD_DYLIB, "/lib?")],
["weak", dylibCommand(LC.LOAD_WEAK_DYLIB, "/lib?")],
["reexport", dylibCommand(LC.REEXPORT_DYLIB, "/lib?")],
["lazy", dylibCommand(LC.LAZY_LOAD_DYLIB, "/lib?")],
["upward", dylibCommand(LC.LOAD_UPWARD_DYLIB, "/lib?")],
["modern use", dylibUseCommand(LC.LOAD_DYLIB, "/lib?", 0x8)],
])(
"retains opaque %s install-name identity while refusing text resolution",
async (_kind, original) => {
const command = Buffer.from(original);
const nameOffset = command.readUInt32LE(8);
command[nameOffset + 4] = 0x80;
const bytes = Buffer.from(machoImage({ commands: [command] }));
expect(parseMachoLayout(bytes, "arm64").fixups.dylibs).toEqual([
{
bytes_base64: Buffer.from([0x2f, 0x6c, 0x69, 0x62, 0x80]).toString(
"base64",
),
},
]);
expect(
await readMachoImage(
async (offset, length) => bytes.subarray(offset, offset + length),
bytes.length,
),
).toMatchObject({
status: "malformed",
reason: expect.stringContaining("not UTF-8"),
});
},
);
});
it.each([0, 129])(
+23 -11
View File
@@ -1,3 +1,7 @@
import {
machoDependencyCommand,
readMachoDylibCommandFacts,
} from "../domain/apple/machoDylibCommand.js";
import { readMachoSectionHeader } from "../domain/apple/machoSectionHeader.js";
import { readMachoSegmentHeader } from "../domain/apple/machoSegmentHeader.js";
import {
@@ -328,9 +332,6 @@ const collectSegment = (
const LC_DYLD_INFO = 0x22;
const LC_DYLD_INFO_ONLY = 0x80000022;
const LC_DYLD_CHAINED_FIXUPS = 0x80000034;
const DYLIB_COMMANDS: readonly number[] = [
0xc, 0x80000018, 0x8000001f, 0x20, 0x80000023,
];
/** Record fixup data ranges and dylib install names from one load command. */
const collectFixupCommand = (
@@ -360,16 +361,27 @@ const collectFixupCommand = (
const bound = range(at);
if (bound.size > 0) state.binds.push({ ...bound, stream });
}
} else if (DYLIB_COMMANDS.includes(kind)) {
if (size < 24) throw new RangeError("Truncated dylib command");
const nameOffset = bytes.readUInt32LE(cursor + 8);
if (nameOffset < 24 || nameOffset >= size)
} else if (machoDependencyCommand(kind) !== null) {
const parsed = readMachoDylibCommandFacts(
bytes.subarray(cursor, cursor + size),
true,
);
if (!parsed.ok) {
if (parsed.error._tag === "InvalidMachoDylibCommand")
throw new RangeError("Truncated dylib command");
if (parsed.error.reason === "unterminated")
throw new RangeError("Unterminated dylib install name");
throw new RangeError("Dylib install name is outside its load command");
const end = bytes.indexOf(0, cursor + nameOffset);
if (end < 0 || end >= cursor + size)
throw new RangeError("Unterminated dylib install name");
}
const { nameBytes } = parsed.value;
state.dylibs.push(
decodeMachoName(bytes.subarray(cursor + nameOffset, end)),
decodeMachoName(
Buffer.from(
nameBytes.buffer,
nameBytes.byteOffset,
nameBytes.byteLength,
),
),
);
}
};