633 lines
21 KiB
TypeScript
633 lines
21 KiB
TypeScript
import { chmod, mkdir, symlink, writeFile } from "node:fs/promises";
|
|
import { dirname, join } from "node:path";
|
|
|
|
import { describe, expect } from "vitest";
|
|
|
|
import {
|
|
CPU,
|
|
FILE_TYPE,
|
|
LC,
|
|
buildVersionCommand,
|
|
dylibCommand,
|
|
dyldEnvironmentCommand,
|
|
fatImage,
|
|
machoImage,
|
|
rpathCommand,
|
|
} from "../../../src/artifacts/apple/MachoImage.fixture.js";
|
|
import { dylibResolutionResultSchema } from "../../../src/domain/apple/dylibResolution.js";
|
|
import { parseEvidence } from "../../../src/domain/evidence.js";
|
|
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
|
import { cliTest, type TestCli } from "../../support/cli/cliFixture.js";
|
|
|
|
const ENVIRONMENT = { REA_LOG_LEVEL: "silent", REA_ANALYSIS_PROVIDER: "auto" };
|
|
|
|
const writeFiles = async (
|
|
root: string,
|
|
files: Readonly<Record<string, string | Uint8Array>>,
|
|
): Promise<void> => {
|
|
for (const [path, content] of Object.entries(files)) {
|
|
await mkdir(dirname(join(root, path)), { recursive: true });
|
|
await writeFile(join(root, path), content);
|
|
}
|
|
};
|
|
|
|
/** A caller-selected root that cannot be traced is an input issue on that root. */
|
|
const expectRootInputError = async (
|
|
cli: TestCli,
|
|
{
|
|
app,
|
|
root,
|
|
reason,
|
|
message,
|
|
}: {
|
|
readonly app: string;
|
|
readonly root: string;
|
|
readonly reason: string;
|
|
readonly message: string;
|
|
},
|
|
): Promise<void> => {
|
|
const rejected = await cli.run({
|
|
arguments: ["trace-dylib-resolution", app, "--root", root, "--json"],
|
|
environment: ENVIRONMENT,
|
|
});
|
|
expect(rejected.exitCode).toBe(1);
|
|
expect(rejected.json).toMatchObject({
|
|
error: "Analysis failed",
|
|
code: "invalid_request",
|
|
details: {
|
|
issues: [
|
|
{
|
|
path: ["roots", 0],
|
|
reason,
|
|
message: expect.stringContaining(message),
|
|
},
|
|
],
|
|
},
|
|
});
|
|
};
|
|
|
|
const fixtureApp = async (): Promise<string> => {
|
|
const app = join(
|
|
await createTestTempDirectory("rea-dylib-cli-"),
|
|
"Fixture.app",
|
|
);
|
|
await writeFiles(app, {
|
|
"Contents/Info.plist":
|
|
"<plist><dict><key>CFBundleExecutable</key><string>App</string></dict></plist>",
|
|
"Contents/MacOS/App": machoImage({
|
|
commands: [
|
|
buildVersionCommand(1),
|
|
rpathCommand("@executable_path/../Frameworks"),
|
|
dylibCommand(LC.LOAD_DYLIB, "@rpath/libcore.dylib"),
|
|
dylibCommand(LC.LOAD_WEAK_DYLIB, "@rpath/libgone.dylib"),
|
|
],
|
|
}),
|
|
"Contents/Frameworks/libcore.dylib": machoImage({
|
|
fileType: FILE_TYPE.dylib,
|
|
commands: [
|
|
buildVersionCommand(1),
|
|
dylibCommand(LC.ID_DYLIB, "@rpath/libcore.dylib"),
|
|
],
|
|
}),
|
|
"Contents/Helpers/tool": machoImage({
|
|
commands: [
|
|
buildVersionCommand(1),
|
|
dylibCommand(LC.LOAD_DYLIB, "@loader_path/libnone.dylib"),
|
|
],
|
|
}),
|
|
});
|
|
return app;
|
|
};
|
|
|
|
const withBrokenHelper = async (app: string): Promise<void> => {
|
|
const broken = machoImage({ commands: [rpathCommand("x")] });
|
|
// cmdsize 0 makes the command table unreadable.
|
|
new DataView(broken.buffer).setUint32(32 + 4, 0, true);
|
|
await writeFiles(app, { "Contents/Helpers/broken": broken });
|
|
};
|
|
|
|
describe("trace-dylib-resolution CLI", () => {
|
|
cliTest("traces every executable root of an app bundle", async ({ cli }) => {
|
|
const app = await fixtureApp();
|
|
const result = await cli.run({
|
|
arguments: ["trace-dylib-resolution", app, "--json"],
|
|
environment: ENVIRONMENT,
|
|
});
|
|
expect(result.exitCode, JSON.stringify(result.json)).toBe(0);
|
|
const trace = dylibResolutionResultSchema.parse(
|
|
(result.json as { normalized_result: unknown }).normalized_result,
|
|
);
|
|
expect(trace.root_path).toBe(app);
|
|
expect(trace.roots.map(({ image }) => image)).toEqual([
|
|
"Contents/Helpers/tool",
|
|
"Contents/MacOS/App",
|
|
]);
|
|
// The normalized result is the provider's only representation; Evidence
|
|
// must not carry it a second time as raw_result.
|
|
expect(result.json).toMatchObject({ raw_result: null });
|
|
expect(
|
|
trace.edges.map(({ loader, install_name: name, resolution }) => [
|
|
loader,
|
|
name,
|
|
resolution.status,
|
|
resolution.image,
|
|
]),
|
|
).toEqual([
|
|
[
|
|
"Contents/Helpers/tool",
|
|
"@loader_path/libnone.dylib",
|
|
"unresolved",
|
|
null,
|
|
],
|
|
[
|
|
"Contents/MacOS/App",
|
|
"@rpath/libcore.dylib",
|
|
"resolved",
|
|
"Contents/Frameworks/libcore.dylib",
|
|
],
|
|
["Contents/MacOS/App", "@rpath/libgone.dylib", "unresolved", null],
|
|
]);
|
|
expect(trace.findings.map(({ kind }) => kind)).toEqual([
|
|
"required-load-unresolved",
|
|
"weak-load-unresolved",
|
|
]);
|
|
expect(
|
|
trace.images.every(({ sha256 }) => /^[0-9a-f]{64}$/u.test(sha256)),
|
|
).toBe(true);
|
|
});
|
|
|
|
cliTest(
|
|
"narrows roots and architecture, and rejects escaping roots",
|
|
async ({ cli }) => {
|
|
const app = await fixtureApp();
|
|
const narrowed = await cli.run({
|
|
arguments: [
|
|
"trace-dylib-resolution",
|
|
app,
|
|
"--root",
|
|
"Contents/MacOS/App",
|
|
"--architecture",
|
|
"arm64",
|
|
"--json",
|
|
],
|
|
environment: ENVIRONMENT,
|
|
});
|
|
expect(narrowed.exitCode).toBe(0);
|
|
expect(
|
|
(narrowed.json as { normalized_result: { roots: unknown } })
|
|
.normalized_result.roots,
|
|
).toEqual([{ image: "Contents/MacOS/App", architecture: "arm64" }]);
|
|
// A root below a symlinked directory that points outside the bundle.
|
|
const outside = await createTestTempDirectory("rea-dylib-outside-");
|
|
await writeFiles(outside, { Tool: machoImage({}) });
|
|
await symlink(outside, join(app, "Contents/Linked"));
|
|
for (const [root, reason, message] of [
|
|
["../outside", "invalid_format", "Use a normalized path"],
|
|
["Contents/Info.plist", "invalid_value", "is not a Mach-O image"],
|
|
[
|
|
"Contents/Linked/Tool",
|
|
"invalid_value",
|
|
"resolves outside the analyzed root",
|
|
],
|
|
] as const) {
|
|
await expectRootInputError(cli, { app, root, reason, message });
|
|
}
|
|
},
|
|
);
|
|
|
|
cliTest(
|
|
"resolves a standalone Mach-O against its own directory",
|
|
async ({ cli }) => {
|
|
const directory = await createTestTempDirectory("rea-dylib-standalone-");
|
|
await writeFiles(directory, {
|
|
tool: machoImage({
|
|
commands: [
|
|
buildVersionCommand(1),
|
|
dylibCommand(LC.LOAD_DYLIB, "@loader_path/lib/libhelper.dylib"),
|
|
dylibCommand(LC.LOAD_DYLIB, "@loader_path/../escape.dylib"),
|
|
],
|
|
}),
|
|
"lib/libhelper.dylib": machoImage({
|
|
fileType: FILE_TYPE.dylib,
|
|
commands: [buildVersionCommand(1)],
|
|
}),
|
|
"unrelated.txt": "not traversed",
|
|
});
|
|
const result = await cli.run({
|
|
arguments: [
|
|
"trace-dylib-resolution",
|
|
join(directory, "tool"),
|
|
"--json",
|
|
],
|
|
environment: ENVIRONMENT,
|
|
});
|
|
expect(result.exitCode, JSON.stringify(result.json)).toBe(0);
|
|
const trace = dylibResolutionResultSchema.parse(
|
|
(result.json as { normalized_result: unknown }).normalized_result,
|
|
);
|
|
expect(trace.roots).toEqual([{ image: "tool", architecture: "arm64" }]);
|
|
expect(trace.edges.map(({ resolution }) => resolution)).toEqual([
|
|
{ status: "resolved", image: "lib/libhelper.dylib" },
|
|
{ status: "undetermined", image: null },
|
|
]);
|
|
},
|
|
);
|
|
|
|
cliTest(
|
|
"reports unparsed Mach-O files found while enumerating roots",
|
|
async ({ cli }) => {
|
|
const app = await fixtureApp();
|
|
await withBrokenHelper(app);
|
|
const result = await cli.run({
|
|
arguments: ["trace-dylib-resolution", app, "--json"],
|
|
environment: ENVIRONMENT,
|
|
});
|
|
expect(result.exitCode, JSON.stringify(result.json)).toBe(0);
|
|
const trace = dylibResolutionResultSchema.parse(
|
|
(result.json as { normalized_result: unknown }).normalized_result,
|
|
);
|
|
expect(trace.coverage).toMatchObject({
|
|
status: "partial",
|
|
unparsed_images: ["Contents/Helpers/broken"],
|
|
});
|
|
},
|
|
);
|
|
});
|
|
|
|
cliTest(
|
|
"rejects build-version tool records outside their load command",
|
|
async ({ cli }) => {
|
|
const app = await fixtureApp();
|
|
const withTool = new Uint8Array(32);
|
|
withTool.set(buildVersionCommand(0xffffffff));
|
|
const toolView = new DataView(withTool.buffer);
|
|
toolView.setUint32(4, 32, true);
|
|
toolView.setUint32(20, 1, true);
|
|
toolView.setUint32(24, 3, true);
|
|
toolView.setUint32(28, 0x10000, true);
|
|
|
|
const missingTool = buildVersionCommand(1);
|
|
new DataView(missingTool.buffer).setUint32(20, 1, true);
|
|
const excessiveTools = buildVersionCommand(1);
|
|
new DataView(excessiveTools.buffer).setUint32(20, 0xffffffff, true);
|
|
const missingSecondTool = withTool.slice();
|
|
new DataView(missingSecondTool.buffer).setUint32(20, 2, true);
|
|
const shortHeader = buildVersionCommand(1).slice(0, 16);
|
|
new DataView(shortHeader.buffer).setUint32(4, 16, true);
|
|
const cases = [
|
|
["no-tools", [buildVersionCommand(1)], "parsed"],
|
|
["one-tool", [withTool], "parsed"],
|
|
// The following command and the image's trailing bytes cannot satisfy ntools.
|
|
["missing-tool", [missingTool, rpathCommand("x")], "malformed"],
|
|
["excessive-tools", [excessiveTools], "malformed"],
|
|
["missing-second-tool", [missingSecondTool], "malformed"],
|
|
["short-header", [shortHeader], "malformed"],
|
|
] as const;
|
|
for (const [name, commands] of cases) {
|
|
await writeFiles(app, {
|
|
[`Contents/Helpers/${name}`]: machoImage({ commands }),
|
|
});
|
|
}
|
|
|
|
const result = await cli.run({
|
|
arguments: ["trace-dylib-resolution", app, "--json"],
|
|
environment: ENVIRONMENT,
|
|
});
|
|
expect(result.exitCode, JSON.stringify(result.json)).toBe(0);
|
|
const trace = dylibResolutionResultSchema.parse(
|
|
(result.json as { normalized_result: unknown }).normalized_result,
|
|
);
|
|
for (const [name, , status] of cases) {
|
|
const path = `Contents/Helpers/${name}`;
|
|
const image = trace.images.find((item) => item.path === path);
|
|
expect(image, name).toMatchObject({ parse_status: status });
|
|
if (status === "malformed") {
|
|
expect(image).toMatchObject({
|
|
reason: expect.stringContaining("LC_BUILD_VERSION"),
|
|
slices: [],
|
|
});
|
|
expect(trace.roots.some((root) => root.image === path)).toBe(false);
|
|
expect(trace.coverage.unparsed_images).toContain(path);
|
|
} else {
|
|
expect(image).toMatchObject({
|
|
reason: null,
|
|
slices: [{ platforms: [name === "one-tool" ? 0xffffffff : 1] }],
|
|
});
|
|
}
|
|
}
|
|
expect(trace.coverage.status).toBe("partial");
|
|
},
|
|
);
|
|
|
|
cliTest(
|
|
"retains directory requirements when resolving dylib install names",
|
|
async ({ cli }) => {
|
|
const directory = await createTestTempDirectory("rea-dylib-path-cli-");
|
|
const names = [
|
|
"@loader_path/lib/libhelper.dylib/",
|
|
"@loader_path/lib/libhelper.dylib/.",
|
|
"@rpath/libhelper.dylib/",
|
|
"@rpath/libhelper.dylib/.",
|
|
"@rpath/",
|
|
"@loader_path/lib/./libhelper.dylib",
|
|
"@loader_path/lib//libhelper.dylib",
|
|
];
|
|
await writeFiles(directory, {
|
|
tool: machoImage({
|
|
commands: [
|
|
buildVersionCommand(1),
|
|
rpathCommand("@loader_path/lib/libhelper.dylib"),
|
|
rpathCommand("@loader_path/lib"),
|
|
...names.map((name) => dylibCommand(LC.LOAD_DYLIB, name)),
|
|
],
|
|
}),
|
|
"lib/libhelper.dylib": machoImage({
|
|
fileType: FILE_TYPE.dylib,
|
|
commands: [buildVersionCommand(1)],
|
|
}),
|
|
});
|
|
const result = await cli.run({
|
|
arguments: ["trace-dylib-resolution", join(directory, "tool"), "--json"],
|
|
environment: ENVIRONMENT,
|
|
});
|
|
expect(result.exitCode, JSON.stringify(result.json)).toBe(0);
|
|
const trace = dylibResolutionResultSchema.parse(
|
|
parseEvidence(result.json).normalized_result,
|
|
);
|
|
expect(
|
|
trace.edges.map(({ install_name, candidates, resolution }) => ({
|
|
install_name,
|
|
outcome: candidates[0]?.outcome,
|
|
resolution,
|
|
})),
|
|
).toEqual(
|
|
names.map((name, index) => ({
|
|
install_name: name,
|
|
outcome: index < 5 ? "absent" : "resolved",
|
|
resolution:
|
|
index < 5
|
|
? { status: "unresolved", image: null }
|
|
: { status: "resolved", image: "lib/libhelper.dylib" },
|
|
})),
|
|
);
|
|
expect(
|
|
trace.findings.map(({ kind, edge_index }) => [kind, edge_index]),
|
|
).toEqual(
|
|
[0, 1, 2, 3, 4].map((index) => ["required-load-unresolved", index]),
|
|
);
|
|
},
|
|
);
|
|
|
|
describe.skipIf(process.getuid?.() === 0)(
|
|
"trace-dylib-resolution permissions",
|
|
() => {
|
|
cliTest(
|
|
"reports an unreadable dependency as a permission failure",
|
|
async ({ cli }) => {
|
|
const directory = await createTestTempDirectory("rea-dylib-denied-");
|
|
await writeFiles(directory, {
|
|
tool: machoImage({
|
|
commands: [
|
|
dylibCommand(LC.LOAD_DYLIB, "@loader_path/libsecret.dylib"),
|
|
],
|
|
}),
|
|
"libsecret.dylib": machoImage({ fileType: FILE_TYPE.dylib }),
|
|
});
|
|
await chmod(join(directory, "libsecret.dylib"), 0o000);
|
|
try {
|
|
const result = await cli.run({
|
|
arguments: [
|
|
"trace-dylib-resolution",
|
|
join(directory, "tool"),
|
|
"--json",
|
|
],
|
|
environment: ENVIRONMENT,
|
|
});
|
|
expect(result.exitCode).toBe(1);
|
|
expect(result.json).toMatchObject({
|
|
details: {
|
|
reason: "unavailable",
|
|
detail: expect.stringContaining(
|
|
"Permission denied (EACCES) reading libsecret.dylib",
|
|
),
|
|
},
|
|
});
|
|
} finally {
|
|
await chmod(join(directory, "libsecret.dylib"), 0o644);
|
|
}
|
|
},
|
|
);
|
|
},
|
|
);
|
|
|
|
cliTest(
|
|
"reports embedded path overrides as conditional without changing diagnostic-only resolution",
|
|
async ({ cli }) => {
|
|
for (const [setting, expected] of [
|
|
["DYLD_LIBRARY_PATH=/external", "conditional"],
|
|
["DYLD_PRINT_LIBRARIES=1", "resolved"],
|
|
["DYLD_FALLBACK_LIBRARY_PATH=/external", "resolved"],
|
|
["DYLD_FRAMEWORK_PATH=/external", "resolved"],
|
|
["DYLD_ROOT_PATH=/external", "resolved"],
|
|
["DYLD_OVERLAY_PATH=/external", "resolved"],
|
|
] as const) {
|
|
const app = await fixtureApp();
|
|
await writeFiles(app, {
|
|
"Contents/MacOS/App": machoImage({
|
|
commands: [
|
|
dyldEnvironmentCommand(setting),
|
|
buildVersionCommand(1),
|
|
rpathCommand("@executable_path/../Absent"),
|
|
rpathCommand("@executable_path/../Frameworks"),
|
|
dylibCommand(LC.LOAD_DYLIB, "@rpath/libcore.dylib"),
|
|
],
|
|
}),
|
|
});
|
|
const result = await cli.run({
|
|
arguments: [
|
|
"trace-dylib-resolution",
|
|
app,
|
|
"--root",
|
|
"Contents/MacOS/App",
|
|
"--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(expected);
|
|
expect(trace.coverage.status).toBe(
|
|
expected === "conditional" ? "partial" : "complete",
|
|
);
|
|
expect(
|
|
trace.images.find(({ path }) => path === "Contents/MacOS/App")
|
|
?.slices[0]?.dyld_environment,
|
|
).toEqual([setting]);
|
|
if (expected === "conditional")
|
|
expect(trace.findings[0]?.explanation).toContain(
|
|
"does not establish which image dyld will load",
|
|
);
|
|
}
|
|
},
|
|
);
|
|
|
|
cliTest(
|
|
"reports each same-named slice's own embedded dyld environment",
|
|
async ({ cli }) => {
|
|
// arm64e and arm64e.x1 slices both report the architecture name arm64e.
|
|
// A plain x86_64 slice keeps the bundle openable on x64 hosts.
|
|
for (const [settings, expected] of [
|
|
[[null, "DYLD_PRINT_RPATHS=1"], ["DYLD_PRINT_RPATHS=1"]],
|
|
[
|
|
["DYLD_PRINT_LIBRARIES=1", "DYLD_PRINT_RPATHS=1"],
|
|
["DYLD_PRINT_LIBRARIES=1", "DYLD_PRINT_RPATHS=1"],
|
|
],
|
|
] as const) {
|
|
const app = await fixtureApp();
|
|
await writeFiles(app, {
|
|
"Contents/MacOS/App": fatImage([
|
|
...[CPU.arm64e, CPU.arm64eX1].map((cpu, index) => {
|
|
const setting = settings[index];
|
|
return {
|
|
cpu,
|
|
bytes: machoImage({
|
|
cpu,
|
|
commands:
|
|
setting === null || setting === undefined
|
|
? []
|
|
: [dyldEnvironmentCommand(setting)],
|
|
}),
|
|
};
|
|
}),
|
|
{ cpu: CPU.x86_64, bytes: machoImage({ cpu: CPU.x86_64 }) },
|
|
]),
|
|
});
|
|
const result = await cli.run({
|
|
arguments: [
|
|
"trace-dylib-resolution",
|
|
app,
|
|
"--root",
|
|
"Contents/MacOS/App",
|
|
"--json",
|
|
],
|
|
environment: ENVIRONMENT,
|
|
});
|
|
expect(result.exitCode, JSON.stringify(result.json)).toBe(0);
|
|
const trace = dylibResolutionResultSchema.parse(
|
|
parseEvidence(result.json).normalized_result,
|
|
);
|
|
expect(
|
|
trace.roots.filter(({ architecture }) => architecture === "arm64e"),
|
|
).toHaveLength(2);
|
|
const environments = trace.findings
|
|
.filter(({ kind }) => kind === "dyld-environment-present")
|
|
.map(({ explanation }) =>
|
|
expected.filter((setting) => explanation.includes(setting)),
|
|
);
|
|
expect(environments).toEqual(expected.map((setting) => [setting]));
|
|
}
|
|
},
|
|
);
|
|
|
|
cliTest(
|
|
"retains definitive load findings for an explicitly selected app library root",
|
|
async ({ cli }) => {
|
|
const app = await fixtureApp();
|
|
const settings = [
|
|
"DYLD_LIBRARY_PATH=/external",
|
|
"DYLD_INSERT_LIBRARIES=/external/injected.dylib",
|
|
];
|
|
await writeFiles(app, {
|
|
"Contents/Frameworks/libcore.dylib": machoImage({
|
|
fileType: FILE_TYPE.dylib,
|
|
commands: [
|
|
buildVersionCommand(1),
|
|
...settings.map(dyldEnvironmentCommand),
|
|
dylibCommand(LC.LOAD_DYLIB, "@loader_path/child.dylib"),
|
|
dylibCommand(LC.LOAD_DYLIB, "@loader_path/missing.dylib"),
|
|
],
|
|
}),
|
|
"Contents/Frameworks/child.dylib": machoImage({
|
|
fileType: FILE_TYPE.dylib,
|
|
commands: [buildVersionCommand(1)],
|
|
}),
|
|
});
|
|
const result = await cli.run({
|
|
arguments: [
|
|
"trace-dylib-resolution",
|
|
app,
|
|
"--root",
|
|
"Contents/Frameworks/libcore.dylib",
|
|
"--json",
|
|
],
|
|
environment: ENVIRONMENT,
|
|
});
|
|
expect(result.exitCode, JSON.stringify(result.json)).toBe(0);
|
|
const trace = dylibResolutionResultSchema.parse(
|
|
parseEvidence(result.json).normalized_result,
|
|
);
|
|
expect(trace.edges.map(({ resolution }) => resolution.status)).toEqual([
|
|
"resolved",
|
|
"unresolved",
|
|
]);
|
|
expect(trace.coverage.status).toBe("complete");
|
|
expect(trace.findings.map(({ kind }) => kind)).toContain(
|
|
"required-load-unresolved",
|
|
);
|
|
expect(
|
|
trace.images.find(
|
|
({ path }) => path === "Contents/Frameworks/libcore.dylib",
|
|
)?.slices[0]?.dyld_environment,
|
|
).toEqual(settings);
|
|
expect(
|
|
trace.findings.find(({ kind }) => kind === "dyld-environment-present")
|
|
?.explanation,
|
|
).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");
|
|
}
|
|
},
|
|
);
|