diff --git a/src/native/NativeMachoInspection.ts b/src/native/NativeMachoInspection.ts index 5e84da5748..17f4a52e95 100644 --- a/src/native/NativeMachoInspection.ts +++ b/src/native/NativeMachoInspection.ts @@ -41,9 +41,15 @@ interface NativeMachoInspectionContext { const DISCOVERY_COMMANDS = [["lipo", ["-detailed_info"]]] as const; +// llvm-nm's default dyld-info pass fakes up symbols the symbol table lacks +// from dyld metadata. It takes seconds on large apps (Keynote arm64: 5.8 s +// versus 0.01 s without it), and under -gU it can only add export-trie names. +const SYMBOL_TABLE_EXPORTS = ["nm", ["-gjU", "--no-dyldinfo"]] as const; +const DYLD_INFO_EXPORTS = ["nm", ["-gjU"]] as const; + const REQUIRED_SLICE_COMMANDS = [ ["otool", ["-h", "-l"]], - ["nm", ["-gjU"]], + SYMBOL_TABLE_EXPORTS, ["dyld_info", ["-imports"]], ["dyld_info", ["-exports"]], ["dwarfdump", ["--uuid"]], @@ -111,6 +117,18 @@ export const inspectNativeMacho = async ( else if (optional.error._tag === "AnalysisCapabilityUnavailableError") limitations.push(VTOOL_UNAVAILABLE_LIMITATION); else return err(optional.error); + const nmIndex = captures.findIndex(({ tool }) => tool === "nm"); + const dyldExports = captures.filter(({ tool }) => tool === "dyld_info")[1]; + if (nmIndex < 0 || dyldExports === undefined) + throw new TypeError("Missing nm or dyld_info export capture"); + // A stripped symbol table (strip -T) can omit names the export trie keeps. + // Only then does llvm-nm's dyld-info pass add names, and its exact names + // disambiguate dyld_info rows, so rerun it rather than lose them. + if (!namesEveryExport(captures[nmIndex]!.stdout, dyldExports)) { + const withDyldInfo = await runSliceCommand(DYLD_INFO_EXPORTS); + if (!withDyldInfo.ok) return err(withDyldInfo.error); + captures[nmIndex] = withDyldInfo.value; + } const otool = captures.find(({ tool }) => tool === "otool"); if (otool === undefined) throw new TypeError("Missing otool capture"); let load: ReturnType; @@ -293,6 +311,34 @@ const captureLookup = return capture; }; +/** + * Whether every dyld_info export row names a symbol-table export. A row this + * parser cannot read counts as uncovered, so an unfamiliar format falls back. + */ +const namesEveryExport = ( + nmOutput: string, + dyldExports: NativeCommandCapture, +): boolean => { + const names = new Set(parseNmExports(nmOutput).map(({ name }) => name)); + return sliceOutput(dyldExports) + .split(/\r?\n/u) + .every((rawLine, index) => { + const line = rawLine.trim(); + if ( + line.length === 0 || + (index === 0 && line.endsWith(":")) || + /^-?exports:$/u.test(line) || + /^offset\s+symbol$/u.test(line) + ) + return true; + const rows = parseDyldSymbols(line, "exports", null, names); + // The parser withholds a row only when nm names both of its readings. + if (rows.length === 0) + return names.has(/^0x[\da-f]+\s+(.+)$/iu.exec(line)?.[1] ?? ""); + return rows.length === 1 && names.has(rows[0]!.name); + }); +}; + const parseNmExports = (output: string) => output .split(/\r?\n/u) diff --git a/tests/boundary/providers/native/nativeInventory.golden.test.ts b/tests/boundary/providers/native/nativeInventory.golden.test.ts index ff86f60caf..625de4fde8 100644 --- a/tests/boundary/providers/native/nativeInventory.golden.test.ts +++ b/tests/boundary/providers/native/nativeInventory.golden.test.ts @@ -552,3 +552,91 @@ it("retains annotation parsing without nm name evidence", () => { }, ]); }); + +/** Replay fixtures, answering nm by whether its dyld-info pass is disabled. */ +const symbolTableRunner = ( + outputs: Readonly>, + nm: { readonly symbolTable: string; readonly withDyldInfo: string }, +) => { + const nmArguments: (readonly string[])[] = []; + const replay = (output: string) => + new FixtureRunner({ ...outputs, nm: output }); + const runner: NativeCommandRunner = { + run: (tool, arguments_) => { + if (tool !== "nm") return replay("").run(tool, arguments_); + nmArguments.push(arguments_); + return replay( + arguments_.includes("--no-dyldinfo") ? nm.symbolTable : nm.withDyldInfo, + ).run(tool, arguments_); + }, + }; + return { runner, nmArguments }; +}; + +/** nm arguments in provenance, which records the artifact operand as a placeholder. */ +const nmCommands = (result: unknown) => + (result as { provenance: { tool: string; command: string[] }[] }).provenance + .filter(({ tool }) => tool === "nm") + .map(({ command }) => [...command.slice(1, -1), "/private/fixture"]); + +it("reads exports from the symbol table without llvm-nm's dyld-info pass", async () => { + const names = await fixture("dyld-inventory/nm.txt"); + const { runner, nmArguments } = symbolTableRunner( + { "dyld_info:-exports": await fixture("dyld-inventory/exports.txt") }, + { symbolTable: names, withDyldInfo: names }, + ); + const execution = await new NativeMacOSProvider({}, runner, "darwin") + .createClient(machoTarget("/private/fixture")) + .execute("inspect_macho", {}); + if (!execution.ok) throw execution.error; + expect(nmArguments).toEqual([ + ["-gjU", "--no-dyldinfo", "-arch", "arm64", "/private/fixture"], + ]); + expect(nmCommands(execution.value.result)).toEqual(nmArguments); +}); + +it("reruns nm with its dyld-info pass when a stripped symbol table omits trie exports", async () => { + const trieNames = await fixture("dyld-inventory/stripped-swift-nm.txt"); + const { runner, nmArguments } = symbolTableRunner( + { + "dyld_info:-exports": await fixture( + "dyld-inventory/stripped-swift-exports.txt", + ), + }, + { symbolTable: "", withDyldInfo: trieNames }, + ); + const execution = await new NativeMacOSProvider({}, runner, "darwin") + .createClient(machoTarget("/private/fixture")) + .execute("inspect_macho", {}); + if (!execution.ok) throw execution.error; + const rerun = ["-gjU", "-arch", "arm64", "/private/fixture"]; + expect(nmArguments).toEqual([ + ["-gjU", "--no-dyldinfo", "-arch", "arm64", "/private/fixture"], + rerun, + ]); + // Provenance names the nm run whose output the exports came from. + expect(nmCommands(execution.value.result)).toEqual([rerun]); + const exported = ( + execution.value.result as { exports: { items: { name: string }[] } } + ).exports.items.map(({ name }) => name); + expect(exported).toEqual(trieNames.trim().split("\n").sort()); +}); + +it("reruns nm with its dyld-info pass when a dyld_info export row is unreadable", async () => { + const names = await fixture("dyld-inventory/nm.txt"); + const exportsOutput = await fixture("dyld-inventory/exports.txt"); + const { runner, nmArguments } = symbolTableRunner( + { + "dyld_info:-exports": `${exportsOutput.trimEnd()}\n \n`, + }, + { symbolTable: names, withDyldInfo: names }, + ); + const execution = await new NativeMacOSProvider({}, runner, "darwin") + .createClient(machoTarget("/private/fixture")) + .execute("inspect_macho", {}); + if (!execution.ok) throw execution.error; + expect(nmArguments.map((arguments_) => arguments_.slice(0, 2))).toEqual([ + ["-gjU", "--no-dyldinfo"], + ["-gjU", "-arch"], + ]); +}); diff --git a/tests/fixtures/native-macos/dyld-inventory/README.md b/tests/fixtures/native-macos/dyld-inventory/README.md index 2c9b27743d..64d789104c 100644 --- a/tests/fixtures/native-macos/dyld-inventory/README.md +++ b/tests/fixtures/native-macos/dyld-inventory/README.md @@ -50,6 +50,20 @@ xcrun dyld_info -exports /tmp/libre.dylib xcrun nm -gjU /tmp/libre.dylib ``` +`stripped-swift-exports.txt` and `stripped-swift-nm.txt` are `xcrun dyld_info +-exports` and the default `xcrun nm -gjU` for a Swift dylib whose symbol table +was stripped with `strip -T`. Its export trie keeps all six names, while +`xcrun nm -gjU --no-dyldinfo` prints none: + +```sh +printf 'public func hello() -> Int { 1 }\npublic struct S { public init() {} ; public func f() -> Int { 2 } }\n' > /tmp/m.swift +xcrun swiftc -emit-library -module-name M /tmp/m.swift -o /tmp/libMT.dylib +xcrun strip -T /tmp/libMT.dylib +xcrun dyld_info -exports /tmp/libMT.dylib +xcrun nm -gjU /tmp/libMT.dylib +xcrun nm -gjU --no-dyldinfo /tmp/libMT.dylib +``` + `codesign-missing.txt` is stderr from `codesign -d --verbose=4` on a nonexistent path (exit 1). `codesign-unsigned.txt` is stderr from the same command on a copy of the fixture after `codesign --remove-signature` (exit 1). Only the latter is diff --git a/tests/fixtures/native-macos/dyld-inventory/stripped-swift-exports.txt b/tests/fixtures/native-macos/dyld-inventory/stripped-swift-exports.txt new file mode 100644 index 0000000000..c50008c1eb --- /dev/null +++ b/tests/fixtures/native-macos/dyld-inventory/stripped-swift-exports.txt @@ -0,0 +1,9 @@ +fixture [arm64]: + -exports: + offset symbol + 0x00000598 _$s1M1SV1fSiyF + 0x00000594 _$s1M1SVACycfC + 0x000005A4 _$s1M1SVMa + 0x000005CC _$s1M1SVMn + 0x00004010 _$s1M1SVN + 0x00000588 _$s1M5helloSiyF diff --git a/tests/fixtures/native-macos/dyld-inventory/stripped-swift-nm.txt b/tests/fixtures/native-macos/dyld-inventory/stripped-swift-nm.txt new file mode 100644 index 0000000000..12458b2d8c --- /dev/null +++ b/tests/fixtures/native-macos/dyld-inventory/stripped-swift-nm.txt @@ -0,0 +1,6 @@ +_$s1M1SV1fSiyF +_$s1M1SVACycfC +_$s1M1SVMa +_$s1M1SVMn +_$s1M1SVN +_$s1M5helloSiyF