From 8b478d0f15bf585d0570ebdcadb4d5857cc86cb4 Mon Sep 17 00:00:00 2001 From: fly liu Date: Fri, 23 Oct 2026 06:02:37 +0800 Subject: [PATCH] fix(native): preserve Mach-O install-name whitespace (#4979) * fix(native): preserve whitespace in otool load-command strings otool prints dylib install names and rpaths as ` %s (offset %u)` with exactly one separator space around the recorded string. The parser trimmed the value and stripped the offset suffix with `\s+`, so a valid install name such as " leading.dylib " became "leading.dylib" in both the dependency inventory and the raw load-command fields. Parse these offset-suffixed string fields by their exact format and strip only the single-space offset suffix. Fixes #3384 Co-Authored-By: Claude Opus 5.5 * test(native): verify exact Mach-O install names through CLI and MCP --------- Co-authored-by: Bhaaraadwaj Co-authored-by: Claude Opus 5.5 Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> --- scripts/verify/apple/native-provenance.mjs | 121 +++++++++++++++++- src/native/parsers/otool.ts | 12 +- .../native/nativeLoadKinds.golden.test.ts | 43 +++++++ 3 files changed, 174 insertions(+), 2 deletions(-) diff --git a/scripts/verify/apple/native-provenance.mjs b/scripts/verify/apple/native-provenance.mjs index a225fafb95..32fd374de5 100644 --- a/scripts/verify/apple/native-provenance.mjs +++ b/scripts/verify/apple/native-provenance.mjs @@ -1,6 +1,10 @@ import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; import { createHash } from "node:crypto"; -import { readFile } from "node:fs/promises"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; import { toolContract } from "../../../dist/contracts/toolContracts.js"; import { @@ -10,6 +14,14 @@ import { withBundleResourcesMcp, } from "../../lib/artifact-e2e.mjs"; +const exec = promisify(execFile); + +// dyld_info abbreviates a terminal .dylib suffix; padding is part of the name. +const INSTALL_NAME_CASES = [ + { installName: "leading.dylib", importSource: "leading" }, + { installName: " leading.dylib ", importSource: " leading.dylib " }, +]; + /** Inspect the compiled bundle with real native utilities through both adapters. */ export async function verifyNativeProvenance(app, executable) { const original = await readFile(executable); @@ -140,5 +152,112 @@ export async function verifyNativeProvenance(app, executable) { target_sha256: sha256, bytes_unchanged: true, observations, + install_names: await verifyInstallNames(), }; } + +/** Compile independent inputs; their unusual loader paths are never executed. */ +async function verifyInstallNames() { + const root = await mkdtemp(join(tmpdir(), "rea-macho-install-names-")); + const observations = []; + try { + const librarySource = join(root, "library.c"); + const consumerSource = join(root, "consumer.c"); + await writeFile(librarySource, "int exported_value(void) { return 42; }\n"); + await writeFile( + consumerSource, + "extern int exported_value(void); int main(void) { return exported_value(); }\n", + ); + for (const [ + index, + { installName, importSource }, + ] of INSTALL_NAME_CASES.entries()) { + const library = join(root, `library-${index}.dylib`); + const consumer = join(root, `consumer-${index}`); + const rpath = " @loader_path/Frameworks "; + await exec("/usr/bin/xcrun", [ + "clang", + "-dynamiclib", + librarySource, + `-Wl,-install_name,${installName}`, + "-o", + library, + ]); + await exec("/usr/bin/xcrun", [ + "clang", + consumerSource, + library, + `-Wl,-rpath,${rpath}`, + "-o", + consumer, + ]); + const original = await readFile(consumer); + const originalLibrary = await readFile(library); + const { stdout } = await exec("/usr/bin/xcrun", [ + "otool", + "-l", + consumer, + ]); + const lines = stdout.split("\n").map((line) => line.trimStart()); + const nameRow = lines.find((line) => + line.startsWith(`name ${installName} (offset `), + ); + const pathRow = lines.find((line) => + line.startsWith(`path ${rpath} (offset `), + ); + assert.ok(nameRow, "linker did not retain the selected install name"); + assert.ok(pathRow, "linker did not retain the selected rpath"); + const cli = toolContract("inspect_macho").outputSchema.parse( + await artifactCliEvidence("inspect-macho", consumer), + ); + const result = cli.normalized_result; + assert.equal(cli.raw_result, null); + assert.ok( + result.dependencies.items.some(({ path }) => path === installName), + ); + assert.ok( + result.load_commands.items.some( + ({ kind, fields }) => + kind === "LC_LOAD_DYLIB" && + fields.name === nameRow.slice("name ".length), + ), + ); + assert.ok( + result.load_commands.items.some( + ({ kind, fields }) => + kind === "LC_RPATH" && + fields.path === pathRow.slice("path ".length), + ), + ); + assert.equal( + result.imports.items.find(({ name }) => name === "_exported_value") + ?.source, + importSource, + ); + assert.ok(result.provenance.some(({ tool }) => tool === "otool")); + await withArtifactMcp(consumer, async (client) => { + const response = await artifactMcpCall(client, { + name: "inspect_macho", + arguments: {}, + }); + assert.notEqual(response.isError, true, JSON.stringify(response)); + const mcp = toolContract("inspect_macho").outputSchema.parse( + response.structuredContent, + ); + assert.deepEqual(mcp.normalized_result, result); + }); + assert.deepEqual(await readFile(consumer), original); + assert.deepEqual(await readFile(library), originalLibrary); + observations.push({ + install_name: installName, + rpath, + target_sha256: createHash("sha256").update(original).digest("hex"), + bytes_unchanged: true, + cli_mcp_parity: true, + }); + } + return observations; + } finally { + await rm(root, { recursive: true, force: true }); + } +} diff --git a/src/native/parsers/otool.ts b/src/native/parsers/otool.ts index ed2203f727..ee0cc51a19 100644 --- a/src/native/parsers/otool.ts +++ b/src/native/parsers/otool.ts @@ -477,7 +477,17 @@ const parseFields = (block: string): ParsedFields => { /** Names and linker options are text even when they look numeric. */ const TEXT_FIELDS = /^(?:segname|sectname|string #\d+)$/u; +/** + * otool prints load-command strings as ` %s (offset %u)`: exactly one + * separator space on each side of the recorded string, whose own leading and + * trailing whitespace belongs to the Mach-O value. + */ +const OFFSET_STRING_FIELD = /^(name|path) (.* \(offset \d+\))$/u; + const parseField = (rawLine: string): readonly [string, string] | undefined => { + const offsetString = OFFSET_STRING_FIELD.exec(rawLine.trimStart()); + if (offsetString?.[1] !== undefined && offsetString[2] !== undefined) + return [offsetString[1], offsetString[2]]; // otool prints each linker option as `string #n