mirror of
https://github.com/morluto/rea
synced 2026-10-28 15:07:26 -04:00
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:
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user