Files
reverse-engineering-anything/tests/acceptance/applications/appleBundleTraversalCoverage.test.ts
T
morluto 5e4004c413 fix(artifacts): preserve traversal coverage through bundle workflows
Retain producer traversal uncertainty through inventory, extraction, Interface Builder decoding and executable-root discovery. Keep known copied files and decoded facts with honest coverage; share omission normalization and bound omitted archive paths with their source reasons.

Fixes #6852
Fixes #6825
Fixes #6582
2026-10-24 02:00:09 +08:00

180 lines
7.1 KiB
TypeScript

import { createHash } from "node:crypto";
import { mkdir, readFile, rename, symlink, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect } from "vitest";
import { encodeNibArchiveFixture } from "../../../src/artifacts/apple/NibArchive.fixture.js";
import { machoImage } from "../../../src/artifacts/apple/MachoImage.fixture.js";
import { interfaceBuilderAnalysisSchema } from "../../../src/domain/apple/interfaceBuilderGraph.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 bundle = async () => {
const root = await createTestTempDirectory("rea-apple-traversal-");
const app = join(root, "Fixture.app");
const resources = join(app, "Contents", "Resources");
const executables = join(app, "Contents", "MacOS");
await mkdir(resources, { recursive: true });
await mkdir(executables);
await writeFile(
join(app, "Contents", "Info.plist"),
"<plist><dict><key>CFBundleExecutable</key><string>main</string></dict></plist>",
);
const executable = machoImage({});
await writeFile(join(executables, "main"), executable);
const nib = encodeNibArchiveFixture({
classes: ["NSView"],
objects: [{ classIndex: 0, values: { title: "Healthy view" } }],
});
await writeFile(join(resources, "Healthy.nib"), nib);
return { app, resources, executables, executable, nib };
};
const interfaceBuilderReports = async (cli: TestCli, app: string) => {
const output = await cli.run({
arguments: ["decode-interface-builder", app, "--json"],
});
expect(output.exitCode, output.stdout + output.stderr).toBe(0);
const call = await cli.sharedMcpCall?.();
if (call === undefined) throw new Error("Expected stdio MCP session");
const opened = await call("open_bundle_resources", { path: app });
expect(opened.isError, JSON.stringify(opened)).not.toBe(true);
const reply = await call("decode_interface_builder", {});
expect(reply.isError, JSON.stringify(reply)).not.toBe(true);
return [
parseEvidence(output.json),
parseEvidence(reply.structuredContent),
].map((evidence) => ({
evidence,
report: interfaceBuilderAnalysisSchema.parse(evidence.normalized_result),
}));
};
cliTest.skipIf(process.platform === "win32")(
"reports symlinked nibs as unexamined while preserving healthy UI documents through CLI and MCP",
async ({ cli }) => {
const fixture = await bundle();
const link = join(fixture.resources, "Panel.nib");
await writeFile(join(fixture.resources, "Panel.data"), fixture.nib);
await symlink("Panel.data", link);
for (const { evidence, report } of await interfaceBuilderReports(
cli,
fixture.app,
)) {
expect(report.documents).toHaveLength(1);
expect(report.documents[0]).toMatchObject({
relative_path: "Contents/Resources/Healthy.nib",
archive_sha256: createHash("sha256").update(fixture.nib).digest("hex"),
});
expect(report.graph.coverage).toContainEqual(
expect.objectContaining({
facet: "archive_decode",
status: "partial",
reason: "archive_symlink_not_followed",
examined: 1,
omitted: 1,
}),
);
expect(report.graph.truncated).toBe(true);
for (const limitations of [report.limitations, evidence.limitations])
expect(limitations).toContain(
"Contents/Resources/Panel.nib: Symlink target was not followed or disclosed.",
);
}
expect(await readFile(link)).toEqual(fixture.nib);
},
);
cliTest.skipIf(process.platform !== "linux")(
"retains unknown Apple resource and executable filename coverage and same-byte rename controls through CLI and MCP",
async ({ cli }) => {
const fixture = await bundle();
const rawNib = Buffer.concat([
Buffer.from(join(fixture.resources, "Panel")),
Buffer.from([0xff]),
Buffer.from(".nib"),
]);
const rawExecutable = Buffer.concat([
Buffer.from(join(fixture.executables, "helper")),
Buffer.from([0xff]),
]);
await writeFile(rawNib, fixture.nib);
await writeFile(rawExecutable, fixture.executable);
for (const { evidence, report } of await interfaceBuilderReports(
cli,
fixture.app,
)) {
expect(report.documents).toHaveLength(1);
expect(report.graph.coverage).toContainEqual(
expect.objectContaining({
facet: "archive_decode",
status: "partial",
reason: "source_traversal_unknown",
examined: 1,
omitted: 0,
}),
);
expect(report.graph.truncated).toBe(true);
for (const limitations of [report.limitations, evidence.limitations])
expect(limitations).toContainEqual(
expect.stringContaining("not valid UTF-8"),
);
}
const call = await cli.sharedMcpCall?.();
if (call === undefined) throw new Error("Expected stdio MCP session");
const opened = await call("open_binary", { path: fixture.app });
expect(opened.isError, JSON.stringify(opened)).not.toBe(true);
const cliTrace = await cli.run({
arguments: ["trace-dylib-resolution", fixture.app, "--json"],
});
expect(cliTrace.exitCode, cliTrace.stdout + cliTrace.stderr).toBe(0);
const mcpTrace = await call("trace_dylib_resolution", {});
expect(mcpTrace.isError, JSON.stringify(mcpTrace)).not.toBe(true);
for (const evidence of [
parseEvidence(cliTrace.json),
parseEvidence(mcpTrace.structuredContent),
]) {
const trace = dylibResolutionResultSchema.parse(
evidence.normalized_result,
);
expect(trace.roots).toEqual([
{ image: "Contents/MacOS/main", architecture: "arm64" },
]);
expect(trace.coverage.status).toBe("partial");
for (const limitations of [trace.limitations, evidence.limitations])
expect(limitations).toContainEqual(
expect.stringContaining("not valid UTF-8"),
);
}
await rename(rawNib, join(fixture.resources, "Panel.nib"));
await rename(rawExecutable, join(fixture.executables, "helper"));
for (const { report } of await interfaceBuilderReports(cli, fixture.app)) {
expect(report.documents).toHaveLength(2);
expect(report.graph.coverage).toContainEqual(
expect.objectContaining({
facet: "archive_decode",
status: "complete",
examined: 2,
omitted: 0,
}),
);
expect(report.limitations.join(" ")).not.toContain("not valid UTF-8");
}
const control = await cli.run({
arguments: ["trace-dylib-resolution", fixture.app, "--json"],
});
expect(control.exitCode, control.stdout + control.stderr).toBe(0);
const trace = dylibResolutionResultSchema.parse(
parseEvidence(control.json).normalized_result,
);
expect(trace.roots.map(({ image }) => image)).toEqual([
"Contents/MacOS/helper",
"Contents/MacOS/main",
]);
expect(trace.coverage.status).toBe("complete");
},
);