mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
fix(apple): classify empty dyld settings by consumer semantics (#1110)
This commit is contained in:
1 parent
1e63144390
commit
fe297e56e8
8 files changed
+474
-11
No files matched your search
@@ -186,6 +186,14 @@ cache rather than on disk, so REA does not check them against the host.
|
||||
other non-executable roots do not alter resolution or coverage. Applicable
|
||||
executable image-selection overrides are not modeled.
|
||||
|
||||
Empty dyld settings have different consumers. Modern dyld appends a slash and
|
||||
image name to ordinary and fallback directory entries, so an empty entry can
|
||||
search `/child.dylib` or `/Foo.framework/Foo`. REA retains uncertainty for those
|
||||
unchecked paths; an empty fallback does not preempt an ordinary-search hit.
|
||||
Empty versioned-directory lists do not scan a directory, and empty suffixes
|
||||
only repeat the original candidate, so they do not add search uncertainty.
|
||||
Reported command values are retained in all cases.
|
||||
|
||||
`verify:macos-bundle` checks the parser against `otool -l` for every traced
|
||||
image. It also compares the predicted load order of two process roots with the
|
||||
images dyld actually loads (`DYLD_PRINT_LIBRARIES`).
|
||||
@@ -113,6 +113,12 @@ covers `trace-dylib-resolution`:
|
||||
- for the main executable and an XPC service, a predicted load order equal to
|
||||
the images dyld actually loads under `DYLD_PRINT_LIBRARIES`.
|
||||
|
||||
The lane also compiles executable/library pairs with empty embedded directory,
|
||||
versioned-path, and suffix settings. It checks present and removed dependencies
|
||||
through CLI and MCP, and compares actual `DYLD_PRINT_SEARCHING` diagnostics for
|
||||
root-level candidates. These cases distinguish an empty search directory from
|
||||
an empty versioned scan or a suffix that only repeats the original path.
|
||||
|
||||
It runs in macOS CI.
|
||||
|
||||
Apple artifact verifiers live in `scripts/verify/apple/`, with the macOS bundle
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFile } from "node:child_process";
|
||||
import { mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { basename, dirname, join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import {
|
||||
artifactCli,
|
||||
artifactMcpResult,
|
||||
withArtifactMcp,
|
||||
} from "../../lib/artifact-e2e.mjs";
|
||||
|
||||
const exec = promisify(execFile);
|
||||
|
||||
const SEARCH_CASES = [
|
||||
{
|
||||
setting: "DYLD_FALLBACK_LIBRARY_PATH=",
|
||||
image: "library",
|
||||
found: "resolved",
|
||||
missing: "undetermined",
|
||||
foundCoverage: "partial",
|
||||
missingCoverage: "partial",
|
||||
rootSearch: true,
|
||||
},
|
||||
{
|
||||
setting: "DYLD_FALLBACK_LIBRARY_PATH=:",
|
||||
image: "library",
|
||||
found: "resolved",
|
||||
missing: "undetermined",
|
||||
foundCoverage: "partial",
|
||||
missingCoverage: "partial",
|
||||
rootSearch: true,
|
||||
},
|
||||
{
|
||||
setting: "DYLD_LIBRARY_PATH=",
|
||||
image: "library",
|
||||
found: "conditional",
|
||||
missing: "undetermined",
|
||||
foundCoverage: "partial",
|
||||
missingCoverage: "partial",
|
||||
rootSearch: true,
|
||||
},
|
||||
{
|
||||
setting: "DYLD_FALLBACK_FRAMEWORK_PATH=",
|
||||
image: "framework",
|
||||
found: "resolved",
|
||||
missing: "undetermined",
|
||||
foundCoverage: "complete",
|
||||
missingCoverage: "partial",
|
||||
rootSearch: true,
|
||||
},
|
||||
{
|
||||
setting: "DYLD_FRAMEWORK_PATH=",
|
||||
image: "framework",
|
||||
found: "conditional",
|
||||
missing: "undetermined",
|
||||
foundCoverage: "partial",
|
||||
missingCoverage: "partial",
|
||||
rootSearch: true,
|
||||
},
|
||||
{
|
||||
setting: "DYLD_VERSIONED_LIBRARY_PATH=",
|
||||
image: "library",
|
||||
found: "resolved",
|
||||
missing: "unresolved",
|
||||
foundCoverage: "complete",
|
||||
missingCoverage: "complete",
|
||||
rootSearch: false,
|
||||
},
|
||||
{
|
||||
setting: "DYLD_VERSIONED_LIBRARY_PATH=:",
|
||||
image: "library",
|
||||
found: "resolved",
|
||||
missing: "unresolved",
|
||||
foundCoverage: "complete",
|
||||
missingCoverage: "complete",
|
||||
rootSearch: false,
|
||||
},
|
||||
{
|
||||
setting: "DYLD_VERSIONED_FRAMEWORK_PATH=",
|
||||
image: "framework",
|
||||
found: "resolved",
|
||||
missing: "unresolved",
|
||||
foundCoverage: "complete",
|
||||
missingCoverage: "complete",
|
||||
rootSearch: false,
|
||||
},
|
||||
{
|
||||
setting: "DYLD_IMAGE_SUFFIX=:",
|
||||
image: "library",
|
||||
found: "resolved",
|
||||
missing: "unresolved",
|
||||
foundCoverage: "complete",
|
||||
missingCoverage: "complete",
|
||||
rootSearch: false,
|
||||
},
|
||||
{
|
||||
setting: "DYLD_IMAGE_SUFFIX=:_debug:",
|
||||
image: "library",
|
||||
found: "conditional",
|
||||
missing: "undetermined",
|
||||
foundCoverage: "partial",
|
||||
missingCoverage: "partial",
|
||||
rootSearch: false,
|
||||
},
|
||||
];
|
||||
|
||||
const verifyTrace = async (program, expected) => {
|
||||
const { setting, installName, status, coverage } = expected;
|
||||
const trace = await artifactCli("trace-dylib-resolution", program);
|
||||
const edge = trace.edges.find(
|
||||
({ install_name }) => install_name === installName,
|
||||
);
|
||||
assert.equal(edge?.resolution.status, status, setting);
|
||||
assert.equal(trace.coverage.status, coverage, setting);
|
||||
assert.deepEqual(
|
||||
trace.images.find(({ path }) => path === "program")?.slices[0]
|
||||
?.dyld_environment,
|
||||
[setting],
|
||||
);
|
||||
await withArtifactMcp(program, async (client) => {
|
||||
assert.deepEqual(
|
||||
await artifactMcpResult(client, "trace_dylib_resolution"),
|
||||
trace,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const missingRuntime = async (program) => {
|
||||
try {
|
||||
await exec(program, [], {
|
||||
env: { PATH: "/usr/bin:/bin", DYLD_PRINT_SEARCHING: "1" },
|
||||
});
|
||||
} catch (cause) {
|
||||
assert.equal(typeof cause?.stderr, "string");
|
||||
assert.ok(cause.stderr.includes("Library not loaded:"), cause.stderr);
|
||||
return cause.stderr;
|
||||
}
|
||||
assert.fail(
|
||||
"The required dependency was removed, but the executable launched",
|
||||
);
|
||||
};
|
||||
|
||||
const verifyCase = async (directory, source, sample) => {
|
||||
const {
|
||||
setting,
|
||||
installName,
|
||||
found,
|
||||
missing,
|
||||
foundCoverage,
|
||||
missingCoverage,
|
||||
rootSearch,
|
||||
} = sample;
|
||||
const library = join(directory, installName.slice("@loader_path/".length));
|
||||
const program = join(directory, "program");
|
||||
await mkdir(dirname(library), { recursive: true });
|
||||
await exec("/usr/bin/xcrun", [
|
||||
"clang",
|
||||
"-dynamiclib",
|
||||
source.library,
|
||||
"-o",
|
||||
library,
|
||||
`-Wl,-install_name,${installName}`,
|
||||
]);
|
||||
await exec("/usr/bin/xcrun", [
|
||||
"clang",
|
||||
source.main,
|
||||
library,
|
||||
"-o",
|
||||
program,
|
||||
`-Wl,-dyld_env,${setting}`,
|
||||
]);
|
||||
await exec(program, [], { env: { PATH: "/usr/bin:/bin" } });
|
||||
await verifyTrace(program, {
|
||||
setting,
|
||||
installName,
|
||||
status: found,
|
||||
coverage: foundCoverage,
|
||||
});
|
||||
await rm(library);
|
||||
const searches = await missingRuntime(program);
|
||||
const rootCandidate = installName.replace("@loader_path/", "/");
|
||||
assert.equal(searches.includes(`"${rootCandidate}"`), rootSearch, searches);
|
||||
await verifyTrace(program, {
|
||||
setting,
|
||||
installName,
|
||||
status: missing,
|
||||
coverage: missingCoverage,
|
||||
});
|
||||
};
|
||||
|
||||
/** Check empty embedded settings against real dyld search diagnostics and CLI/MCP results. */
|
||||
export async function verifyDyldEnvironment(root) {
|
||||
const directory = join(root, "dyld-environment");
|
||||
await mkdir(directory);
|
||||
const source = {
|
||||
main: join(directory, "main.c"),
|
||||
library: join(directory, "child.c"),
|
||||
};
|
||||
await writeFile(
|
||||
source.main,
|
||||
"int value(void); int main(void) { return value() == 7 ? 0 : 1; }",
|
||||
);
|
||||
await writeFile(source.library, "int value(void) { return 7; }");
|
||||
// A per-workspace name avoids collisions with real files in filesystem root.
|
||||
const name = `rea-${basename(root)}-child`;
|
||||
const libraryName = `@loader_path/${name}.dylib`;
|
||||
const frameworkName = `@loader_path/${name}.framework/${name}`;
|
||||
|
||||
for (const [index, { image, ...sample }] of SEARCH_CASES.entries()) {
|
||||
await verifyCase(join(directory, String(index)), source, {
|
||||
...sample,
|
||||
installName: image === "framework" ? frameworkName : libraryName,
|
||||
});
|
||||
}
|
||||
return {
|
||||
settings: SEARCH_CASES.length,
|
||||
dependency_states: ["present", "missing"],
|
||||
cli: true,
|
||||
stdio_mcp: true,
|
||||
real_dyld: true,
|
||||
};
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
buildMacosBundleFixture,
|
||||
preflightMacosBundleFixture,
|
||||
} from "../../fixtures/apple/macos-bundle.mjs";
|
||||
import { verifyDyldEnvironment } from "./dyld-environment.mjs";
|
||||
import { verifyDylibResolution } from "./macos-dylib-e2e.mjs";
|
||||
|
||||
const exec = promisify(execFile);
|
||||
@@ -152,6 +153,7 @@ try {
|
||||
const { stdout: attached } = await exec("/usr/bin/hdiutil", ["info"]);
|
||||
assert.ok(!attached.includes(dmg), "DMG remained attached after inventory");
|
||||
const dylibs = await verifyDylibResolution(app);
|
||||
const dyldEnvironment = await verifyDyldEnvironment(root);
|
||||
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({
|
||||
@@ -163,6 +165,7 @@ try {
|
||||
bundles: EXPECTED_BUNDLES.length,
|
||||
dmg_detached: true,
|
||||
dylib_resolution: dylibs,
|
||||
dyld_environment: dyldEnvironment,
|
||||
})}\n`,
|
||||
);
|
||||
} finally {
|
||||
|
||||
@@ -17,7 +17,7 @@ export const embeddedDyldOverrides = (
|
||||
if (delimiter <= 0) continue;
|
||||
const variable = setting.slice(0, delimiter);
|
||||
const value = setting.slice(delimiter + 1);
|
||||
const effect = overrideEffect(variable);
|
||||
const effect = overrideEffect(variable, value);
|
||||
if (effect === undefined) continue;
|
||||
// ROOT_PATH is accepted only for simulator processes. Missing or unknown
|
||||
// platform metadata cannot establish that dyld ignores it.
|
||||
@@ -28,13 +28,6 @@ export const embeddedDyldOverrides = (
|
||||
!platforms.some((platform) => [7, 8, 9, 12].includes(platform))
|
||||
)
|
||||
continue;
|
||||
if (
|
||||
(variable === "DYLD_INSERT_LIBRARIES" ||
|
||||
variable === "DYLD_IMAGE_SUFFIX" ||
|
||||
effect.scope === "prefix") &&
|
||||
value === ""
|
||||
)
|
||||
continue;
|
||||
overrides.push({ variable, value, ...effect });
|
||||
}
|
||||
return overrides;
|
||||
@@ -42,8 +35,11 @@ export const embeddedDyldOverrides = (
|
||||
|
||||
const overrideEffect = (
|
||||
variable: string,
|
||||
value: string,
|
||||
): Pick<DyldSearchOverride, "phase" | "scope"> | undefined => {
|
||||
switch (variable) {
|
||||
// Empty directory components still produce /leaf candidates in dyld.
|
||||
// In particular, an empty fallback value is not a no-op.
|
||||
case "DYLD_LIBRARY_PATH":
|
||||
return { phase: "override", scope: "library" };
|
||||
case "DYLD_FRAMEWORK_PATH":
|
||||
@@ -56,12 +52,17 @@ const overrideEffect = (
|
||||
// so the directory's kind does not establish the replaced image's kind.
|
||||
case "DYLD_VERSIONED_LIBRARY_PATH":
|
||||
case "DYLD_VERSIONED_FRAMEWORK_PATH":
|
||||
case "DYLD_INSERT_LIBRARIES":
|
||||
case "DYLD_IMAGE_SUFFIX":
|
||||
return { phase: "override", scope: "all" };
|
||||
// Versioned paths scan directories; suffixes create path variants.
|
||||
// Empty directories cannot be scanned and empty suffixes repeat a path.
|
||||
return /[^:]/u.test(value)
|
||||
? { phase: "override", scope: "all" }
|
||||
: undefined;
|
||||
case "DYLD_INSERT_LIBRARIES":
|
||||
return value === "" ? undefined : { phase: "override", scope: "all" };
|
||||
case "DYLD_ROOT_PATH":
|
||||
case "DYLD_OVERLAY_PATH":
|
||||
return { phase: "override", scope: "prefix" };
|
||||
return value === "" ? undefined : { phase: "override", scope: "prefix" };
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -1105,3 +1105,130 @@ it("keeps inserted-library uncertainty scoped to the executable's process", asyn
|
||||
expect(limitation).toContain(MAIN);
|
||||
expect(limitation).not.toContain(library);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"DYLD_LIBRARY_PATH=",
|
||||
"@loader_path/child.dylib",
|
||||
"conditional",
|
||||
"undetermined",
|
||||
],
|
||||
[
|
||||
"DYLD_LIBRARY_PATH=: ",
|
||||
"@loader_path/child.dylib",
|
||||
"conditional",
|
||||
"undetermined",
|
||||
],
|
||||
[
|
||||
"DYLD_FRAMEWORK_PATH=",
|
||||
"@loader_path/Foo.framework/Foo",
|
||||
"conditional",
|
||||
"undetermined",
|
||||
],
|
||||
[
|
||||
"DYLD_FALLBACK_LIBRARY_PATH=",
|
||||
"@loader_path/child.dylib",
|
||||
"resolved",
|
||||
"undetermined",
|
||||
],
|
||||
[
|
||||
"DYLD_FALLBACK_LIBRARY_PATH=:",
|
||||
"@loader_path/child.dylib",
|
||||
"resolved",
|
||||
"undetermined",
|
||||
],
|
||||
[
|
||||
"DYLD_FALLBACK_FRAMEWORK_PATH=",
|
||||
"@loader_path/Foo.framework/Foo",
|
||||
"resolved",
|
||||
"undetermined",
|
||||
],
|
||||
[
|
||||
"DYLD_VERSIONED_LIBRARY_PATH=",
|
||||
"@loader_path/child.dylib",
|
||||
"resolved",
|
||||
"unresolved",
|
||||
],
|
||||
[
|
||||
"DYLD_VERSIONED_LIBRARY_PATH=:",
|
||||
"@loader_path/child.dylib",
|
||||
"resolved",
|
||||
"unresolved",
|
||||
],
|
||||
[
|
||||
"DYLD_VERSIONED_FRAMEWORK_PATH=",
|
||||
"@loader_path/Foo.framework/Foo",
|
||||
"resolved",
|
||||
"unresolved",
|
||||
],
|
||||
[
|
||||
"DYLD_VERSIONED_FRAMEWORK_PATH=::",
|
||||
"@loader_path/Foo.framework/Foo",
|
||||
"resolved",
|
||||
"unresolved",
|
||||
],
|
||||
[
|
||||
"DYLD_VERSIONED_LIBRARY_PATH= ",
|
||||
"@loader_path/child.dylib",
|
||||
"conditional",
|
||||
"undetermined",
|
||||
],
|
||||
["DYLD_IMAGE_SUFFIX=:", "@loader_path/child.dylib", "resolved", "unresolved"],
|
||||
[
|
||||
"DYLD_IMAGE_SUFFIX=::",
|
||||
"@loader_path/Foo.framework/Foo",
|
||||
"resolved",
|
||||
"unresolved",
|
||||
],
|
||||
[
|
||||
"DYLD_IMAGE_SUFFIX=:_debug:",
|
||||
"@loader_path/child.dylib",
|
||||
"conditional",
|
||||
"undetermined",
|
||||
],
|
||||
[
|
||||
"DYLD_INSERT_LIBRARIES=",
|
||||
"@loader_path/child.dylib",
|
||||
"resolved",
|
||||
"unresolved",
|
||||
],
|
||||
[
|
||||
"DYLD_INSERT_LIBRARIES=:",
|
||||
"@loader_path/child.dylib",
|
||||
"conditional",
|
||||
"undetermined",
|
||||
],
|
||||
] as const)(
|
||||
"interprets the empty components of %s according to their consumer for %s",
|
||||
async (setting, installName, foundStatus, missingStatus) => {
|
||||
const child = installName.replace("@loader_path", "Contents/MacOS");
|
||||
for (const [exists, status] of [
|
||||
[true, foundStatus],
|
||||
[false, missingStatus],
|
||||
] as const) {
|
||||
const trace = await traceDylibLoading(
|
||||
memoryView({
|
||||
[MAIN]: executable({
|
||||
dyld_environment: [setting],
|
||||
dependencies: [dependency(installName)],
|
||||
}),
|
||||
...(exists ? { [child]: parsed(slice()) } : {}),
|
||||
}),
|
||||
{ roots: [MAIN] },
|
||||
);
|
||||
expect(trace.edges[0]?.resolution.status).toBe(status);
|
||||
expect(trace.coverage.status).toBe(
|
||||
status === "conditional" || status === "undetermined"
|
||||
? "partial"
|
||||
: "complete",
|
||||
);
|
||||
expect(
|
||||
trace.images.find(({ path }) => path === MAIN)?.slices[0]
|
||||
?.dyld_environment,
|
||||
).toEqual([setting]);
|
||||
expect(
|
||||
trace.findings.some(({ kind }) => kind === "required-load-unresolved"),
|
||||
).toBe(status === "unresolved");
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -366,3 +366,46 @@ cliTest(
|
||||
).toContain("entries in this non-executable root are observations");
|
||||
},
|
||||
);
|
||||
|
||||
cliTest(
|
||||
"distinguishes empty search directories from empty versioned or suffix lists",
|
||||
async ({ cli }) => {
|
||||
for (const [setting, status] of [
|
||||
["DYLD_FALLBACK_LIBRARY_PATH=", "undetermined"],
|
||||
["DYLD_VERSIONED_LIBRARY_PATH=", "unresolved"],
|
||||
["DYLD_IMAGE_SUFFIX=:", "unresolved"],
|
||||
] as const) {
|
||||
const root = await createTestTempDirectory("rea-dyld-empty-cli-");
|
||||
const path = join(root, "program");
|
||||
await writeFile(
|
||||
path,
|
||||
machoImage({
|
||||
commands: [
|
||||
buildVersionCommand(1),
|
||||
dyldEnvironmentCommand(setting),
|
||||
dylibCommand(LC.LOAD_DYLIB, "@loader_path/missing.dylib"),
|
||||
],
|
||||
}),
|
||||
);
|
||||
const result = await cli.run({
|
||||
arguments: ["trace-dylib-resolution", path, "--json"],
|
||||
environment: ENVIRONMENT,
|
||||
});
|
||||
expect(result.exitCode, JSON.stringify(result.json)).toBe(0);
|
||||
const trace = dylibResolutionResultSchema.parse(
|
||||
parseEvidence(result.json).normalized_result,
|
||||
);
|
||||
expect(trace.edges[0]?.resolution.status).toBe(status);
|
||||
expect(trace.coverage.status).toBe(
|
||||
status === "undetermined" ? "partial" : "complete",
|
||||
);
|
||||
expect(
|
||||
trace.images.find(({ path }) => path === "program")?.slices[0]
|
||||
?.dyld_environment,
|
||||
).toEqual([setting]);
|
||||
expect(
|
||||
trace.findings.some(({ kind }) => kind === "required-load-unresolved"),
|
||||
).toBe(status === "unresolved");
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -323,3 +323,55 @@ it("ignores process settings on an explicitly selected library root through MCP"
|
||||
).toContain("entries in this non-executable root are observations");
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["DYLD_FALLBACK_LIBRARY_PATH=", "undetermined"],
|
||||
["DYLD_VERSIONED_LIBRARY_PATH=", "unresolved"],
|
||||
["DYLD_IMAGE_SUFFIX=:", "unresolved"],
|
||||
] as const)(
|
||||
"retains the missing-dependency semantics of %s through MCP",
|
||||
async (setting, status) => {
|
||||
const root = await createTestTempDirectory("rea-dyld-empty-mcp-");
|
||||
const path = join(root, "program");
|
||||
await writeFile(
|
||||
path,
|
||||
machoImage({
|
||||
commands: [
|
||||
buildVersionCommand(1),
|
||||
dyldEnvironmentCommand(setting),
|
||||
dylibCommand(LC.LOAD_DYLIB, "@loader_path/missing.dylib"),
|
||||
],
|
||||
}),
|
||||
);
|
||||
await withClient(async (client) => {
|
||||
const opened = await client.callTool({
|
||||
name: "open_binary",
|
||||
arguments: { path },
|
||||
});
|
||||
expect(opened.isError, JSON.stringify(opened.structuredContent)).not.toBe(
|
||||
true,
|
||||
);
|
||||
const result = await client.callTool({
|
||||
name: "trace_dylib_resolution",
|
||||
arguments: {},
|
||||
});
|
||||
expect(result.isError, JSON.stringify(result.structuredContent)).not.toBe(
|
||||
true,
|
||||
);
|
||||
const trace = dylibResolutionResultSchema.parse(
|
||||
structuredResult(result.structuredContent),
|
||||
);
|
||||
expect(trace.edges[0]?.resolution.status).toBe(status);
|
||||
expect(trace.coverage.status).toBe(
|
||||
status === "undetermined" ? "partial" : "complete",
|
||||
);
|
||||
expect(
|
||||
trace.images.find(({ path }) => path === "program")?.slices[0]
|
||||
?.dyld_environment,
|
||||
).toEqual([setting]);
|
||||
expect(
|
||||
trace.findings.some(({ kind }) => kind === "required-load-unresolved"),
|
||||
).toBe(status === "unresolved");
|
||||
});
|
||||
},
|
||||
);
|
||||
Reference in new issue
Block a user