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 `<key> %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 <noreply@anthropic.com>

* test(native): verify exact Mach-O install names through CLI and MCP

---------

Co-authored-by: Bhaaraadwaj <bharadwajdaya@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
This commit is contained in:
fly liu
2026-10-23 06:02:37 +08:00
committed by GitHub
parent 15ec8e98db
commit 8b478d0f15
3 changed files with 174 additions and 2 deletions
+120 -1
View File
@@ -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 });
}
}
+11 -1
View File
@@ -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 `<key> %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 <option>`; keep the option
// exactly, including surrounding spaces, under otool's ordinal label.
const option = /^\s*(string #[1-9]\d*)(?: (.*))?$/u.exec(rawLine);
@@ -640,7 +650,7 @@ const hexField = (
};
const stripOffsetSuffix = (value: string | null): string =>
value?.replace(/\s+\(offset\s+\d+\)$/u, "") ?? "unknown";
value?.replace(/ \(offset \d+\)$/u, "") ?? "unknown";
const sectionAlignment = (raw: string | null): number | null => {
if (raw === null) return null;
@@ -235,6 +235,49 @@ it("preserves dependency install names that begin with the word stamp", () => {
).toMatch(/^2 Thu Jan/);
});
it.each([
["surrounding spaces", " leading.dylib "],
["surrounding tabs", "\tleading.dylib\t"],
["version prefix", "version 2.dylib"],
["embedded offset annotation", "named (offset 99)"],
["numeric name", "123"],
["ordinary control", "leading.dylib"],
])(
"preserves dependency install-name whitespace: %s",
(_label, installName) => {
const load = parseOtoolLoadCommands(
withOtoolMachHeader(
[
"Load command 0",
" cmd LC_LOAD_DYLIB",
" cmdsize 48",
` name ${installName} (offset 24)`,
" time stamp 2 Thu Jan 1 00:00:02 1970",
" current version 0.0.0",
"compatibility version 0.0.0",
"Load command 1",
" cmd LC_RPATH",
" cmdsize 32",
` path ${installName} (offset 12)`,
"",
].join("\n"),
),
);
expect(load.dependencies).toEqual([
{
path: installName,
kind: "LC_LOAD_DYLIB",
current_version: "0.0.0",
compatibility_version: "0.0.0",
},
]);
expect(load.commands.map((command) => command.fields)).toMatchObject([
{ name: `${installName} (offset 24)` },
{ path: `${installName} (offset 12)` },
]);
},
);
it("normalizes legacy version-minimum commands into build metadata", () => {
const load = parseOtoolLoadCommands(
withOtoolMachHeader(