From e405d685bc320fbdf07c1e4a27010f2374f066e7 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 27 Oct 2026 01:37:35 +0800 Subject: [PATCH] refactor(macho): centralize dylib command and string facts --- src/artifacts/apple/MachoLoadCommandReader.ts | 95 +++++++++---------- src/domain/apple/machoDylibCommand.ts | 67 +++++++++++++ src/domain/apple/machoLoadCommands.ts | 39 ++++++++ src/native/AppleMachoSelection.test.ts | 35 +++++++ src/native/AppleMachoSelection.ts | 34 ++++--- 5 files changed, 207 insertions(+), 63 deletions(-) create mode 100644 src/domain/apple/machoDylibCommand.ts diff --git a/src/artifacts/apple/MachoLoadCommandReader.ts b/src/artifacts/apple/MachoLoadCommandReader.ts index 5bd1429a3b..561959d931 100644 --- a/src/artifacts/apple/MachoLoadCommandReader.ts +++ b/src/artifacts/apple/MachoLoadCommandReader.ts @@ -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 = [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 = - 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( diff --git a/src/domain/apple/machoDylibCommand.ts b/src/domain/apple/machoDylibCommand.ts new file mode 100644 index 0000000000..08bd30b2f6 --- /dev/null +++ b/src/domain/apple/machoDylibCommand.ts @@ -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 = + 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, + }); +}; diff --git a/src/domain/apple/machoLoadCommands.ts b/src/domain/apple/machoLoadCommands.ts index 7c9ca95ff1..243870ab62 100644 --- a/src/domain/apple/machoLoadCommands.ts +++ b/src/domain/apple/machoLoadCommands.ts @@ -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) }); +}; diff --git a/src/native/AppleMachoSelection.test.ts b/src/native/AppleMachoSelection.test.ts index 3fa6738afd..1283fe2b29 100644 --- a/src/native/AppleMachoSelection.test.ts +++ b/src/native/AppleMachoSelection.test.ts @@ -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])( diff --git a/src/native/AppleMachoSelection.ts b/src/native/AppleMachoSelection.ts index 413ccecba7..032347ddf5 100644 --- a/src/native/AppleMachoSelection.ts +++ b/src/native/AppleMachoSelection.ts @@ -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, + ), + ), ); } };