From 4bdd6d10c98896571d089c7d9ee62dbad2c7aeab Mon Sep 17 00:00:00 2001 From: Lewis Wong Date: Sat, 14 Nov 2026 08:29:51 +0000 Subject: [PATCH] fix(native): name chained imports from the binary's import table dyld_info prints chained-fixup rows by import ordinal, and a row with two spaces after the ordinal has two readings. REA settled them against the nm undefined-symbol list, which omits weak-def coalesced names, so such imports got null or ambiguous names. inspect-macho now reads the chained-fixup import table from the binary and matches rows by ordinal, falling back to the symbol table with a recorded limitation when the table is unusable. Fixes #8723 Squashed from #8724 and rebased onto main. Co-Authored-By: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com> --- .github/workflows/ci.yml | 1 + scripts/ci/scopes.mjs | 1 + src/native/AppleMachoFixups.test.ts | 25 ++- src/native/AppleMachoFixups.ts | 183 +++++++++++------- src/native/NativeMacOSProvider.ts | 7 + src/native/NativeMachoInspection.ts | 86 +++++++- src/native/parsers/dyldInfo.ts | 28 ++- .../cli/nativeMachoImportNamesCli.test.ts | 72 +++++++ .../native/nativeInventory.golden.test.ts | 153 ++++++++++++++- 9 files changed, 469 insertions(+), 87 deletions(-) create mode 100644 tests/boundary/cli/nativeMachoImportNamesCli.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a7b982f530..7bcb23b95e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -815,6 +815,7 @@ jobs: tests/boundary/providers/native/nativeUiOffscreenWindow.test.ts tests/boundary/process/nativeUiVerifierLane.test.ts tests/boundary/cli/nativeSignatureCli.test.ts + tests/boundary/cli/nativeMachoImportNamesCli.test.ts tests/boundary/cli/nativeMachoArm64_32Cli.test.ts tests/boundary/cli/nativeMachoEncryptionCli.test.ts tests/boundary/mcp/nativeSignatureTargetBindingMcp.test.ts diff --git a/scripts/ci/scopes.mjs b/scripts/ci/scopes.mjs index 9e9f54b193..f00fff39e9 100644 --- a/scripts/ci/scopes.mjs +++ b/scripts/ci/scopes.mjs @@ -168,6 +168,7 @@ const PATH_SCOPES = { "tests/boundary/process/nativeUiVerifierLane.test.ts", "tests/conformance/native/ui-offscreen.swift", "tests/boundary/cli/nativeSignatureCli.test.ts", + "tests/boundary/cli/nativeMachoImportNamesCli.test.ts", "tests/boundary/cli/nativeMachoArm64_32Cli.test.ts", "tests/boundary/cli/nativeMachoEncryptionCli.test.ts", "tests/boundary/mcp/nativeSignatureTargetBindingMcp.test.ts", diff --git a/src/native/AppleMachoFixups.test.ts b/src/native/AppleMachoFixups.test.ts index 332e8e4ea0..e51cc224cf 100644 --- a/src/native/AppleMachoFixups.test.ts +++ b/src/native/AppleMachoFixups.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it, vi } from "vitest"; -import { decodeChainedPointer } from "./AppleMachoFixups.js"; +import { + chainedImportNames, + decodeChainedPointer, +} from "./AppleMachoFixups.js"; import { parsePointerFixups } from "./AppleMachoFixups.fixture.js"; import type { Segment } from "./AppleMachoSelection.js"; @@ -147,6 +150,26 @@ const chainedData = (format: 1 | 2 | 3): Buffer => { return data; }; +describe("chained import names", () => { + it.each([1, 2, 3] as const)( + "reads format %i import-table names without resolving libraries", + (format) => { + // Ordinals 1 and 2 name dependencies that this reader does not need. + expect(chainedImportNames(chainedData(format))).toEqual([ + "_OBJC_CLASS_$_NSObject", + "_free", + ]); + }, + ); + + it("rejects a malformed import table", () => { + const data = chainedData(1); + data.writeUInt32LE(4, 20); + expect(() => chainedImportNames(data)).toThrow(RangeError); + expect(() => chainedImportNames(Buffer.alloc(0))).toThrow(RangeError); + }); +}); + const SEGMENTS: Segment[] = [ { address: BASE, size: 0x4000n, offset: 0, executable: true }, { address: BASE + 0x4000n, size: 0x4000n, offset: 0x4000, executable: false }, diff --git a/src/native/AppleMachoFixups.ts b/src/native/AppleMachoFixups.ts index acfae8bc1b..ee4c7517b1 100644 --- a/src/native/AppleMachoFixups.ts +++ b/src/native/AppleMachoFixups.ts @@ -210,22 +210,29 @@ const sameImport = (left: Import, right: Import): boolean => left.weak === right.weak && left.addend === right.addend; -const readImport = ( - data: { - readonly bytes: Buffer; - readonly base: number; - readonly end: number; - readonly dylibs: FixupCommands["dylibs"]; - }, - header: { - readonly offset: number; - readonly count: number; - readonly format: number; - readonly symbols: number; - }, +/** Validated location of a chained-fixup import table and its symbol pool. */ +interface ChainedImportTable { + readonly offset: number; + readonly count: number; + readonly format: number; + readonly symbols: number; +} + +/** One import-table record before its library ordinal is resolved. */ +interface ChainedImportEntry { + readonly nameOffset: number; + readonly ordinal: number; + readonly weak: boolean; + readonly addend: bigint; +} + +const chainedImportEntry = ( + bytes: Buffer, + base: number, + end: number, + header: ChainedImportTable, index: number, -): Import => { - const { bytes, base, end, dylibs } = data; +): ChainedImportEntry => { const size = header.format === 3 ? 16 : header.format === 2 ? 8 : 4; if (header.format < 1 || header.format > 3) throw new RangeError(`Unsupported chained import format ${header.format}`); @@ -240,35 +247,46 @@ const readImport = ( if (header.format === 3) { const word = bytes.readBigUInt64LE(at); const encodedOrdinal = Number(bits(word, 0, 16)); - const ordinal = - encodedOrdinal > 0xfff0 ? encodedOrdinal - 0x10000 : encodedOrdinal; return { - ...cString( - bytes, - base + header.symbols + Number(bits(word, 32, 32)), - end, - ), - binding_source: "chained", - library_ordinal: ordinal, - ...libraryFacts(ordinal, dylibs), + nameOffset: Number(bits(word, 32, 32)), + ordinal: + encodedOrdinal > 0xfff0 ? encodedOrdinal - 0x10000 : encodedOrdinal, weak: bits(word, 16, 1) === 1n, addend: bytes.readBigInt64LE(at + 8), }; } const word = bytes.readUInt32LE(at); const encodedOrdinal = word & 0xff; - const ordinal = - encodedOrdinal > 0xf0 ? encodedOrdinal - 0x100 : encodedOrdinal; return { - ...cString(bytes, base + header.symbols + (word >>> 9), end), - binding_source: "chained", - library_ordinal: ordinal, - ...libraryFacts(ordinal, dylibs), + nameOffset: word >>> 9, + ordinal: encodedOrdinal > 0xf0 ? encodedOrdinal - 0x100 : encodedOrdinal, weak: ((word >>> 8) & 1) === 1, addend: header.format === 2 ? BigInt(bytes.readInt32LE(at + 4)) : 0n, }; }; +const readImport = ( + data: { + readonly bytes: Buffer; + readonly base: number; + readonly end: number; + readonly dylibs: FixupCommands["dylibs"]; + }, + header: ChainedImportTable, + index: number, +): Import => { + const { bytes, base, end, dylibs } = data; + const entry = chainedImportEntry(bytes, base, end, header, index); + return { + ...cString(bytes, base + header.symbols + entry.nameOffset, end), + binding_source: "chained", + library_ordinal: entry.ordinal, + ...libraryFacts(entry.ordinal, dylibs), + weak: entry.weak, + addend: entry.addend, + }; +}; + /** Decode one raw chained pointer for its segment's pointer format. */ export const decodeChainedPointer = ( raw: bigint, @@ -379,6 +397,68 @@ const readChainedPage = ( return { locations, lastOffset, failure }; }; +/** Validate one `LC_DYLD_CHAINED_FIXUPS` header and its import table. */ +const chainedFixupsHeader = ( + bytes: Buffer, + base: number, + size: number, +): { readonly startsOffset: number; readonly imports: ChainedImportTable } => { + const end = base + size; + if ( + !Number.isSafeInteger(base) || + base < 0 || + !Number.isSafeInteger(size) || + size < 28 || + end > bytes.length + ) + throw new RangeError("Chained fixup header exceeds the target"); + if (bytes.readUInt32LE(base) !== 0) + throw new RangeError("Unsupported chained fixup header version"); + const imports = { + offset: bytes.readUInt32LE(base + 8), + symbols: bytes.readUInt32LE(base + 12), + count: bytes.readUInt32LE(base + 16), + format: bytes.readUInt32LE(base + 20), + }; + const importSize = imports.format === 3 ? 16 : imports.format === 2 ? 8 : 4; + const importTableEnd = base + imports.offset + imports.count * importSize; + if (imports.format < 1 || imports.format > 3) + throw new RangeError(`Unsupported chained import format ${imports.format}`); + if ( + imports.offset < 28 || + imports.symbols < imports.offset || + importTableEnd > base + imports.symbols || + base + imports.symbols > end + ) + throw new RangeError("Chained import table exceeds the fixup data"); + if (bytes.readUInt32LE(base + 24) !== 0) + throw new RangeError( + "Compressed chained-fixup symbol names are not supported", + ); + return { startsOffset: bytes.readUInt32LE(base + 4), imports }; +}; + +/** + * Symbol names of one `LC_DYLD_CHAINED_FIXUPS` payload's import table, indexed + * by import ordinal. A name without a lossless text projection is null. + */ +export const chainedImportNames = ( + fixups: Buffer, +): readonly (string | null)[] => { + const { imports } = chainedFixupsHeader(fixups, 0, fixups.length); + const names: (string | null)[] = []; + for (let index = 0; index < imports.count; index++) { + const entry = chainedImportEntry(fixups, 0, fixups.length, imports, index); + const { symbol } = cString( + fixups, + imports.symbols + entry.nameOffset, + fixups.length, + ); + names.push(symbol); + } + return names; +}; + /** A known eight-byte chain word starting elsewhere overlaps this pointer read. */ const hasPartialChainedPointer = ( chain: ChainedSegment, @@ -410,44 +490,13 @@ const chainedFixups = ( const { bytes, segments, dylibs, baseAddress } = image; const base = range.offset; const end = range.offset + range.size; - if ( - !Number.isSafeInteger(base) || - base < 0 || - !Number.isSafeInteger(range.size) || - range.size < 28 || - end > bytes.length - ) - throw new RangeError("Chained fixup header exceeds the target"); - if (bytes.readUInt32LE(base) !== 0) - throw new RangeError("Unsupported chained fixup header version"); - const startsOffset = bytes.readUInt32LE(base + 4); - const importHeader = { - offset: bytes.readUInt32LE(base + 8), - symbols: bytes.readUInt32LE(base + 12), - count: bytes.readUInt32LE(base + 16), - format: bytes.readUInt32LE(base + 20), - }; - const importSize = - importHeader.format === 3 ? 16 : importHeader.format === 2 ? 8 : 4; - const importTableEnd = - base + importHeader.offset + importHeader.count * importSize; - if (importHeader.format < 1 || importHeader.format > 3) - throw new RangeError( - `Unsupported chained import format ${importHeader.format}`, - ); - if ( - importHeader.offset < 28 || - importHeader.symbols < importHeader.offset || - importTableEnd > base + importHeader.symbols || - base + importHeader.symbols > end - ) - throw new RangeError("Chained import table exceeds the fixup data"); + const { startsOffset, imports: importHeader } = chainedFixupsHeader( + bytes, + base, + range.size, + ); const imports = (index: number) => readImport({ bytes, base, end, dylibs }, importHeader, index); - if (bytes.readUInt32LE(base + 24) !== 0) - throw new RangeError( - "Compressed chained-fixup symbol names are not supported", - ); const starts = base + startsOffset; const startsEnd = base + importHeader.offset; const requireStartsRange = (at: number, size: number): void => { diff --git a/src/native/NativeMacOSProvider.ts b/src/native/NativeMacOSProvider.ts index 533bc118dc..62bf93b8c6 100644 --- a/src/native/NativeMacOSProvider.ts +++ b/src/native/NativeMacOSProvider.ts @@ -1414,6 +1414,13 @@ class NativeMacOSClient implements AnalysisClient { selected, Math.floor(NATIVE_COMMAND_OUTPUT_BUDGET_BYTES / 256), ), + readSliceRange: (offset, size, at, length) => + source.readSlice( + offset, + size ?? source.bytes - offset, + at, + length, + ), readCommandNames: async (offset, size, selected) => { const length = size ?? source.bytes - offset; // Binary strings can expand to hex and escaped JSON, and each diff --git a/src/native/NativeMachoInspection.ts b/src/native/NativeMachoInspection.ts index c97f3bed9a..1381628d35 100644 --- a/src/native/NativeMachoInspection.ts +++ b/src/native/NativeMachoInspection.ts @@ -12,7 +12,11 @@ import { import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js"; import { machoRuntimeSymbolName } from "../domain/native/machoSymbolName.js"; import { err, ok, type Result } from "../domain/result.js"; -import type { NativeCommandCapture } from "./CommandRunner.js"; +import { + NATIVE_COMMAND_OUTPUT_BUDGET_BYTES, + type NativeCommandCapture, +} from "./CommandRunner.js"; +import { chainedImportNames } from "./AppleMachoFixups.js"; import { MACHO_ARCHITECTURE_POLICIES, machoToolArchitectureName, @@ -81,6 +85,13 @@ interface NativeMachoInspectionContext { readonly resolveSlice: ( selected: LipoArchitecture, ) => Promise; + /** Read an exact selected-slice range from the same bytes as the tools. */ + readonly readSliceRange?: ( + offset: number, + size: number | null, + at: number, + length: number, + ) => Promise; /** Borrow binary framing from the same identity-bound target as the tools. */ readonly readCommandNames: ( offset: number, @@ -89,6 +100,44 @@ interface NativeMachoInspectionContext { ) => Promise; } +const LC_DYLD_CHAINED_FIXUPS = 0x80000034; + +/** + * Exact names from the selected slice's chained-fixup import table by import + * ordinal, or why that table cannot stand in for the symbol-table inventory. + * Undefined when the context has no slice reader. + */ +const readChainedImportNames = async ( + context: NativeMachoInspectionContext, + binary: MachoCommandNames, + offset: number, + size: number | null, +): Promise | undefined> => { + if (context.readSliceRange === undefined) return undefined; + const fields = binary.commands.find( + ({ command }) => command === LC_DYLD_CHAINED_FIXUPS, + )?.fixedFields; + const at = fields?.dataoff; + const length = fields?.datasize; + if (at === undefined || length === undefined) + return err("its load command fields are not decoded"); + if (length > NATIVE_COMMAND_OUTPUT_BUDGET_BYTES) + return err( + `its ${length}-byte payload exceeds the ${NATIVE_COMMAND_OUTPUT_BUDGET_BYTES}-byte native read budget`, + ); + const bytes = await context.readSliceRange(offset, size, at, length); + try { + return ok( + chainedImportNames( + Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength), + ), + ); + } catch (cause) { + if (cause instanceof RangeError) return err(cause.message); + throw cause; + } +}; + const DISCOVERY_COMMANDS = [["lipo", ["-detailed_info"]]] as const; // llvm-nm's default dyld-info pass fakes up symbols the symbol table lacks @@ -313,14 +362,33 @@ export const inspectNativeMacho = async ( hasChainedFixups, ); if (parsedImports.needsNameInventory) { - const importNames = await runSliceCommand(SYMBOL_TABLE_IMPORTS); - if (!importNames.ok) return importNames; - captures.push(importNames.value); - parsedImports = parseDyldImports( - sliceOutput(dyldImports), - hasChainedFixups, - new Set(parseNmSymbolNames(importNames.value.stdout)), - ); + // Chained rows print entries of the binary's own import table by ordinal. + // Its exact names settle the ordinal spacing even for imports that the + // symbol table omits or lists as defined, such as weak-def coalescing. + const chainedTable = hasChainedFixups + ? await readChainedImportNames(context, binary, offset, size) + : undefined; + if (chainedTable?.ok === true) + parsedImports = parseDyldImports( + sliceOutput(dyldImports), + hasChainedFixups, + undefined, + chainedTable.value, + ); + else { + if (chainedTable !== undefined) + limitations.push( + `The chained-fixup import table is unusable (${chainedTable.error}); chained import names are matched against the undefined symbol table instead.`, + ); + const importNames = await runSliceCommand(SYMBOL_TABLE_IMPORTS); + if (!importNames.ok) return importNames; + captures.push(importNames.value); + parsedImports = parseDyldImports( + sliceOutput(dyldImports), + hasChainedFixups, + new Set(parseNmSymbolNames(importNames.value.stdout)), + ); + } } const encryption = await context.readEncryption(context.signal); const selectedArchitectureName = diff --git a/src/native/parsers/dyldInfo.ts b/src/native/parsers/dyldInfo.ts index 9bb8074c3b..fcc6138cfe 100644 --- a/src/native/parsers/dyldInfo.ts +++ b/src/native/parsers/dyldInfo.ts @@ -19,11 +19,16 @@ interface ParsedDyldExport extends ParsedDyldSymbolFields { readonly sourceSymbol?: string; } -/** Decode Apple's literal import framing; request nm facts for competing names. */ +/** + * Decode Apple's literal import framing. Competing readings of a chained row + * are settled by the import-table name at the row's ordinal when the caller + * supplies the table, otherwise by symbol-table names, requested when needed. + */ export const parseDyldImports = ( output: string, hasChainedFixups: boolean, knownImportNames?: ReadonlySet, + chainedImportTable?: readonly (string | null)[], ): { readonly items: ParsedDyldImport[]; readonly needsNameInventory: boolean; @@ -64,12 +69,25 @@ export const parseDyldImports = ( source, }); } - if (readings.length > 1 && knownImportNames === undefined) + // A chained row's ordinal indexes the slice's own import table. + const tableName = + ordinal === null || chainedImportTable === undefined + ? undefined + : chainedImportTable[Number(ordinal[0].trimEnd())]; + if ( + readings.length > 1 && + knownImportNames === undefined && + tableName === undefined + ) needsNameInventory = true; const matching = - readings.length > 1 && knownImportNames !== undefined - ? readings.filter(({ name }) => knownImportNames.has(name)) - : readings; + readings.length <= 1 + ? readings + : tableName !== undefined + ? readings.filter(({ name }) => name === tableName) + : knownImportNames !== undefined + ? readings.filter(({ name }) => knownImportNames.has(name)) + : readings; const reading = matching.length === 1 ? matching[0] : undefined; const ambiguousSuffix = reading !== undefined && diff --git a/tests/boundary/cli/nativeMachoImportNamesCli.test.ts b/tests/boundary/cli/nativeMachoImportNamesCli.test.ts new file mode 100644 index 0000000000..80da5d0524 --- /dev/null +++ b/tests/boundary/cli/nativeMachoImportNamesCli.test.ts @@ -0,0 +1,72 @@ +import { execFile } from "node:child_process"; +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { promisify } from "node:util"; + +import { describe, expect } from "vitest"; + +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +const run = promisify(execFile); + +describe.skipIf(process.platform !== "darwin")( + "native Mach-O CLI chained import names", + () => { + cliTest( + "names a weak-def-coalesce import that the undefined symbol table omits", + async ({ cli }) => { + const directory = await createTestTempDirectory("rea-macho-imports-"); + const source = join(directory, "coalesce.cpp"); + // Taking an exported inline function's address makes the linker bind + // it through , as a defined weak symbol. + await writeFile( + source, + [ + 'inline __attribute__((visibility("default"))) int inl(int x) { return x * 3; }', + '__attribute__((visibility("default"))) int (*getinl())(int) { return &inl; }', + "", + ].join("\n"), + ); + const library = join(directory, "libcoalesce.dylib"); + await run("/usr/bin/xcrun", [ + "clang++", + "-arch", + "arm64", + "-dynamiclib", + source, + "-o", + library, + ]); + const { stdout } = await run("/usr/bin/xcrun", [ + "dyld_info", + "-imports", + library, + ]); + expect(stdout).toMatch(/__Z3inli {2}\(from \)/u); + const result = await cli.run({ + arguments: ["inspect-macho", library, "--json"], + environment: { + REA_LOG_LEVEL: "silent", + REA_ANALYSIS_PROVIDER: "auto", + }, + }); + expect(result.exitCode, result.stdout).toBe(0); + expect(result.json).toMatchObject({ + normalized_result: { + imports: { + items: [ + { + name: "__Z3inli", + name_status: "exact", + source: "", + }, + ], + }, + }, + }); + }, + 60_000, + ); + }, +); diff --git a/tests/boundary/providers/native/nativeInventory.golden.test.ts b/tests/boundary/providers/native/nativeInventory.golden.test.ts index eeb8efc235..fc1c52376f 100644 --- a/tests/boundary/providers/native/nativeInventory.golden.test.ts +++ b/tests/boundary/providers/native/nativeInventory.golden.test.ts @@ -27,7 +27,26 @@ import { } from "../../../fixtures/nativeCommands.js"; import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; -const inventoryTarget = async (chained = false) => { +/** One format-1 `LC_DYLD_CHAINED_FIXUPS` payload holding only import names. */ +const chainedImportPayload = (names: readonly string[]): Buffer => { + const pool = Buffer.from(`\0${names.join("\0")}\0`); + const imports = Buffer.alloc(names.length * 4); + let nameOffset = 1; + names.forEach((name, index) => { + imports.writeUInt32LE((nameOffset << 9) >>> 0, index * 4); + nameOffset += Buffer.byteLength(name) + 1; + }); + const starts = Buffer.alloc(4); + const header = Buffer.alloc(28); + header.writeUInt32LE(28, 4); + header.writeUInt32LE(28 + starts.length, 8); + header.writeUInt32LE(28 + starts.length + imports.length, 12); + header.writeUInt32LE(names.length, 16); + header.writeUInt32LE(1, 20); + return Buffer.concat([header, starts, imports, pool]); +}; + +const inventoryTarget = async (chained = false, payload?: Buffer) => { const path = join( await createTestTempDirectory("rea-native-inventory-"), "fixture", @@ -41,17 +60,21 @@ const inventoryTarget = async (chained = false) => { segment.writeBigUInt64LE(0x4000n, 48); segment.writeUInt32LE(7, 56); segment.writeUInt32LE(5, 60); + const payloadOffset = 0x2000; const fixups = Buffer.alloc(16); fixups.writeUInt32LE(0x80000034, 0); fixups.writeUInt32LE(16, 4); + if (payload !== undefined) { + fixups.writeUInt32LE(payloadOffset, 8); + fixups.writeUInt32LE(payload.length, 12); + } const image = machoImage({ cpu: CPU.arm64, commands: chained ? [segment, fixups] : [segment], }); - await writeFile( - path, - Buffer.concat([image, Buffer.alloc(0x4000 - image.byteLength)]), - ); + const file = Buffer.concat([image, Buffer.alloc(0x4000 - image.byteLength)]); + payload?.copy(file, payloadOffset); + await writeFile(path, file); return nativeMachoTargetForFile(path); }; @@ -793,6 +816,126 @@ it("uses independent undefined names to resolve chained producer spacing", async }); }); +it("names chained imports from the binary's import table when the symbol table omits them", async () => { + const outputs: Record = {}; + for (const tool of ["lipo", "nm", "dwarfdump", "vtool"]) + outputs[tool] = await fixture(`dyld-inventory/${tool}.txt`); + // Weak-def coalescing binds a defined symbol, and Swift images can import + // names that have no symbol-table entry, so nm -gju lists neither. + outputs["nm:-gju"] = ""; + outputs["dyld_info:-imports"] = [ + " -imports:", + " 0x0000 __Z3inli (from )", + " 0x0001 _$s10AppIntents0A4EnumMp (from AppIntents)", + ].join("\n"); + const client = new NativeMacOSProvider( + {}, + inventoryRunner(outputs), + "darwin", + ).createClient( + await inventoryTarget( + true, + chainedImportPayload(["__Z3inli", "_$s10AppIntents0A4EnumMp"]), + ), + ); + const execution = await client.execute("inspect_macho", {}); + if (!execution.ok) throw execution.error; + expect(execution.value.result).toMatchObject({ + imports: { + items: [ + { + name: "__Z3inli", + name_status: "exact", + source: "", + }, + { + name: "_$s10AppIntents0A4EnumMp", + name_status: "exact", + source: "AppIntents", + }, + ], + }, + }); + // The binary-owned table settles the spacing without the nm inventory. + expect(JSON.stringify(execution.value.result)).not.toContain('"-gju"'); +}); + +it("names each chained row by its ordinal when the table holds both spacing readings", async () => { + const outputs: Record = {}; + for (const tool of ["lipo", "nm", "dwarfdump", "vtool"]) + outputs[tool] = await fixture(`dyld-inventory/${tool}.txt`); + outputs["nm:-gju"] = "_foo\n"; + // Ordinal 1 names " _foo"; installed dyld_info prints two spaces before it. + outputs["dyld_info:-imports"] = [ + " -imports:", + " 0x0000 _foo (from )", + " 0x0001 _foo (from )", + ].join("\n"); + const client = new NativeMacOSProvider( + {}, + inventoryRunner(outputs), + "darwin", + ).createClient( + await inventoryTarget(true, chainedImportPayload(["_foo", " _foo"])), + ); + const execution = await client.execute("inspect_macho", {}); + if (!execution.ok) throw execution.error; + expect(execution.value.result).toMatchObject({ + imports: { + items: [ + { name: "_foo", name_status: "exact" }, + { name: " _foo", name_status: "exact" }, + ], + }, + }); +}); + +it("records why an unusable chained import table falls back to the symbol table", async () => { + const outputs: Record = {}; + for (const tool of ["lipo", "nm", "dwarfdump", "vtool"]) + outputs[tool] = await fixture(`dyld-inventory/${tool}.txt`); + outputs["nm:-gju"] = "__Z3inli\n"; + outputs["dyld_info:-imports"] = [ + " -imports:", + " 0x0000 __Z3inli (from libc++)", + ].join("\n"); + // A compressed symbol pool has no decodable names. + const payload = chainedImportPayload(["__Z3inli"]); + payload.writeUInt32LE(1, 24); + const client = new NativeMacOSProvider( + {}, + inventoryRunner(outputs), + "darwin", + ).createClient(await inventoryTarget(true, payload)); + const execution = await client.execute("inspect_macho", {}); + if (!execution.ok) throw execution.error; + expect(execution.value.result).toMatchObject({ + imports: { items: [{ name: "__Z3inli", name_status: "exact" }] }, + limitations: expect.arrayContaining([ + expect.stringContaining( + "Compressed chained-fixup symbol names are not supported", + ), + ]), + }); + expect(JSON.stringify(execution.value.result)).toContain('"-gju"'); +}); + +it("keeps a chained row unknown when its ordinal is outside the import table", () => { + const output = " 0x0005 _foo (from library)"; + expect(parseDyldImports(output, true, undefined, ["_foo"]).items).toEqual([ + { + name: null, + raw_name: output.slice(6), + name_status: "ambiguous", + addend: null, + address: null, + weak: null, + reexport: null, + source: "library", + }, + ]); +}); + it("keeps missing and competing undefined-name matches unknown", () => { const output = " 0x0000 _literal (from library)"; expect(parseDyldImports(output, true).needsNameInventory).toBe(true);