mirror of
https://github.com/morluto/rea
synced 2026-10-27 14:42:18 -04:00
d672cc2399
Decode native binary plists from bounded owned source bytes before conversion can erase scalar values. Share scalar preflight and isolate overlapping decoder records while retaining exact integers, date uncertainty, and original bytes. Refuse numeric payloads that read structural table bytes, and avoid retaining projection metadata when callers only need structural validation. Closes #5701 Closes #5703 Closes #5705 Closes #5782 Closes #5783 Closes #5792
272 lines
10 KiB
TypeScript
272 lines
10 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
|
|
import { build, buildBinary, parseBinary } from "plist";
|
|
import { expect } from "vitest";
|
|
|
|
import { toolContract } from "../../../src/contracts/toolContracts.js";
|
|
import { interfaceBuilderAnalysisSchema } from "../../../src/domain/apple/interfaceBuilderGraph.js";
|
|
import { keyedArchiveResultSchema } from "../../../src/domain/apple/keyedArchive.js";
|
|
import { thinMach } from "../../../src/domain/binaryTarget.fixture.js";
|
|
import { parseEvidence } from "../../../src/domain/evidence.js";
|
|
import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js";
|
|
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
|
import { embedSubmillisecondDateInWideIntegerPayload } from "../../fixtures/binaryPlistScalarAlias.js";
|
|
import { cliTest } from "../../support/cli/cliFixture.js";
|
|
|
|
const MUTATION_SOURCE_SECONDS = 0.2;
|
|
const SUBMILLISECOND_SECONDS = 0.1005;
|
|
|
|
const makeArchiveBytes = (): Buffer =>
|
|
Buffer.from(
|
|
buildBinary({
|
|
$archiver: "NSKeyedArchiver",
|
|
$version: 100000,
|
|
$top: { root: { UID: 1 } },
|
|
$objects: [
|
|
"$null",
|
|
{
|
|
$class: { UID: 2 },
|
|
wide: Buffer.alloc(16),
|
|
canonicalDate: new Date("2001-01-01T00:00:00.100Z"),
|
|
preciseDate: new Date("2001-01-01T00:00:00.200Z"),
|
|
},
|
|
{ $classname: "UIView", $classes: ["UIView", "NSObject"] },
|
|
],
|
|
}),
|
|
);
|
|
|
|
const mutateDate = (
|
|
source: Buffer,
|
|
sourceSeconds: number,
|
|
mutation: "fraction" | "marker",
|
|
): Buffer => {
|
|
const bytes = Buffer.from(source);
|
|
const date = Buffer.alloc(9);
|
|
date[0] = 0x33;
|
|
date.writeDoubleBE(sourceSeconds, 1);
|
|
const offset = bytes.indexOf(date);
|
|
expect(offset).toBeGreaterThanOrEqual(0);
|
|
expect(bytes.indexOf(date, offset + date.length)).toBe(-1);
|
|
if (mutation === "fraction")
|
|
bytes.writeDoubleBE(SUBMILLISECOND_SECONDS, offset + 1);
|
|
else bytes[offset] = 0x30;
|
|
return bytes;
|
|
};
|
|
|
|
const dateValues = (value: unknown): unknown[] => {
|
|
if (Array.isArray(value)) return value.flatMap(dateValues);
|
|
if (value === null || typeof value !== "object") return [];
|
|
if ("$plist_type" in value && value.$plist_type === "date") return [value];
|
|
return Object.values(value).flatMap(dateValues);
|
|
};
|
|
|
|
const makeApp = async (root: string, bytes: Buffer): Promise<string> => {
|
|
const app = join(root, "DateFixture.app");
|
|
const contents = join(app, "Contents");
|
|
const resources = join(contents, "Resources");
|
|
await mkdir(resources, { recursive: true });
|
|
await mkdir(join(contents, "MacOS"));
|
|
await writeFile(
|
|
join(contents, "MacOS", "Fixture"),
|
|
thinMach(0xfeedfacf, 0x0100000c),
|
|
);
|
|
await writeFile(
|
|
join(contents, "Info.plist"),
|
|
build({ CFBundleExecutable: "Fixture" }),
|
|
);
|
|
await writeFile(join(resources, "Main.nib"), bytes);
|
|
return app;
|
|
};
|
|
|
|
cliTest(
|
|
"preserves binary plist date precision through keyed archive and Interface Builder CLI and MCP",
|
|
async ({ cli }) => {
|
|
const root = await createTestTempDirectory("rea-binary-plist-dates-");
|
|
const sourceBytes = makeArchiveBytes();
|
|
const parsedFixture = parseBinary(sourceBytes) as {
|
|
$top: { root: { UID: number } };
|
|
};
|
|
expect(parsedFixture.$top.root).toEqual({ UID: 1 });
|
|
const bytes = mutateDate(sourceBytes, MUTATION_SOURCE_SECONDS, "fraction");
|
|
const malformedBytes = mutateDate(
|
|
sourceBytes,
|
|
MUTATION_SOURCE_SECONDS,
|
|
"marker",
|
|
);
|
|
const archivePath = join(root, "archive.plist");
|
|
const malformedArchivePath = join(root, "malformed.plist");
|
|
await Promise.all([
|
|
writeFile(archivePath, bytes),
|
|
writeFile(malformedArchivePath, malformedBytes),
|
|
]);
|
|
|
|
const archiveOutput = await cli.run({
|
|
arguments: ["inspect-keyed-archive", archivePath, "--json"],
|
|
environment: { HOME: root, USERPROFILE: root, REA_LOG_LEVEL: "silent" },
|
|
});
|
|
expect(archiveOutput.exitCode, archiveOutput.stderr).toBe(0);
|
|
const archiveResult = keyedArchiveResultSchema.parse(
|
|
parseEvidence(archiveOutput.json).normalized_result,
|
|
);
|
|
expect(dateValues(archiveResult.objects)).toEqual(
|
|
expect.arrayContaining([
|
|
{ $plist_type: "date", iso8601: "2001-01-01T00:00:00.100Z" },
|
|
{ $plist_type: "date", iso8601: null },
|
|
]),
|
|
);
|
|
expect(archiveResult.limitations).toContain(
|
|
'1 date value(s) are reported as { "$plist_type": "date", "iso8601": null } because the decoder could not represent their timestamps.',
|
|
);
|
|
|
|
const malformedOutput = await cli.run({
|
|
arguments: ["inspect-keyed-archive", malformedArchivePath, "--json"],
|
|
environment: { HOME: root, USERPROFILE: root, REA_LOG_LEVEL: "silent" },
|
|
});
|
|
expect(malformedOutput.exitCode).not.toBe(0);
|
|
|
|
const malformedApp = await makeApp(root, bytes);
|
|
const malformedNib = join(malformedApp, "Contents/Resources/Broken.nib");
|
|
await writeFile(malformedNib, malformedBytes);
|
|
const ibOutput = await cli.run({
|
|
arguments: ["decode-interface-builder", malformedApp, "--json"],
|
|
environment: { REA_LOG_LEVEL: "silent", REA_ANALYSIS_PROVIDER: "auto" },
|
|
});
|
|
expect(ibOutput.exitCode, ibOutput.stderr).toBe(0);
|
|
const ibResult = interfaceBuilderAnalysisSchema.parse(
|
|
parseEvidence(ibOutput.json).normalized_result,
|
|
);
|
|
const view = ibResult.graph.nodes.find(
|
|
({ attributes }) => attributes.interface_builder_object_id === "1",
|
|
);
|
|
expect(view).toBeDefined();
|
|
expect(dateValues(view?.attributes)).toEqual(
|
|
expect.arrayContaining([
|
|
{ $plist_type: "date", iso8601: "2001-01-01T00:00:00.100Z" },
|
|
{ $plist_type: "date", iso8601: null },
|
|
]),
|
|
);
|
|
expect(
|
|
ibResult.documents.map(({ relative_path }) => relative_path),
|
|
).toEqual(["Contents/Resources/Main.nib"]);
|
|
expect(ibResult.graph.coverage).toContainEqual(
|
|
expect.objectContaining({
|
|
facet: "archive_decode",
|
|
status: "partial",
|
|
reason: "one_or_more_archives_invalid",
|
|
}),
|
|
);
|
|
expect(ibResult.limitations).toContain(
|
|
'Contents/Resources/Main.nib: 1 date value(s) are reported as { "$plist_type": "date", "iso8601": null } because the decoder could not represent their timestamps.',
|
|
);
|
|
expect(ibResult.limitations).toContainEqual(
|
|
expect.stringContaining(
|
|
"Contents/Resources/Broken.nib: binary plist date marker 0x30 is invalid",
|
|
),
|
|
);
|
|
|
|
const { call } = await connectLocalToolsMcp({
|
|
HOME: root,
|
|
USERPROFILE: root,
|
|
REA_LOG_LEVEL: "silent",
|
|
REA_ANALYSIS_PROVIDER: "auto",
|
|
});
|
|
expect((await call("open_binary", { path: archivePath })).isError).not.toBe(
|
|
true,
|
|
);
|
|
const archiveMcp = await call("inspect_keyed_archive", {});
|
|
expect(archiveMcp.isError, JSON.stringify(archiveMcp)).not.toBe(true);
|
|
const validatedArchive = toolContract(
|
|
"inspect_keyed_archive",
|
|
).outputSchema.parse(archiveMcp.structuredContent);
|
|
expect(
|
|
keyedArchiveResultSchema.parse(
|
|
parseEvidence(validatedArchive).normalized_result,
|
|
),
|
|
).toEqual(archiveResult);
|
|
|
|
const openedBundle = await call("open_bundle_resources", {
|
|
path: malformedApp,
|
|
});
|
|
expect(openedBundle.isError, JSON.stringify(openedBundle)).not.toBe(true);
|
|
toolContract("open_bundle_resources").outputSchema.parse(
|
|
openedBundle.structuredContent,
|
|
);
|
|
const ibMcp = await call("decode_interface_builder", {});
|
|
expect(ibMcp.isError, JSON.stringify(ibMcp)).not.toBe(true);
|
|
const validatedIb = toolContract(
|
|
"decode_interface_builder",
|
|
).outputSchema.parse(ibMcp.structuredContent);
|
|
expect(
|
|
interfaceBuilderAnalysisSchema.parse(
|
|
parseEvidence(validatedIb).normalized_result,
|
|
),
|
|
).toEqual(ibResult);
|
|
|
|
expect(await readFile(archivePath)).toEqual(bytes);
|
|
expect(await readFile(malformedArchivePath)).toEqual(malformedBytes);
|
|
expect(
|
|
await readFile(join(malformedApp, "Contents/Resources/Main.nib")),
|
|
).toEqual(bytes);
|
|
expect(await readFile(malformedNib)).toEqual(malformedBytes);
|
|
expect(createHash("sha256").update(bytes).digest("hex")).toBe(
|
|
ibResult.documents[0]?.archive_sha256,
|
|
);
|
|
},
|
|
60_000,
|
|
);
|
|
|
|
cliTest(
|
|
"preserves aliased wide-integer and unknown-date values through keyed archive CLI and MCP",
|
|
async ({ cli }) => {
|
|
const root = await createTestTempDirectory(
|
|
"rea-binary-plist-scalar-alias-",
|
|
);
|
|
const { bytes, integerDecimal } =
|
|
embedSubmillisecondDateInWideIntegerPayload(makeArchiveBytes(), 0.2);
|
|
const archivePath = join(root, "aliased-archive.plist");
|
|
await writeFile(archivePath, bytes);
|
|
const environment = {
|
|
HOME: root,
|
|
USERPROFILE: root,
|
|
REA_LOG_LEVEL: "silent",
|
|
REA_ANALYSIS_PROVIDER: "auto",
|
|
};
|
|
|
|
const cliOutput = await cli.run({
|
|
arguments: ["inspect-keyed-archive", archivePath, "--json"],
|
|
environment,
|
|
});
|
|
expect(cliOutput.exitCode, cliOutput.stderr).toBe(0);
|
|
const cliResult = keyedArchiveResultSchema.parse(
|
|
parseEvidence(cliOutput.json).normalized_result,
|
|
);
|
|
const archivedValues = cliResult.objects.find(({ id }) => id === 1)?.value;
|
|
expect(archivedValues).toMatchObject({
|
|
wide: { $plist_type: "integer", decimal: integerDecimal },
|
|
canonicalDate: {
|
|
$plist_type: "date",
|
|
iso8601: "2001-01-01T00:00:00.100Z",
|
|
},
|
|
preciseDate: { $plist_type: "date", iso8601: null },
|
|
});
|
|
|
|
const { call } = await connectLocalToolsMcp(environment);
|
|
expect((await call("open_binary", { path: archivePath })).isError).not.toBe(
|
|
true,
|
|
);
|
|
const response = await call("inspect_keyed_archive", {});
|
|
expect(response.isError, JSON.stringify(response)).not.toBe(true);
|
|
const validated = toolContract("inspect_keyed_archive").outputSchema.parse(
|
|
response.structuredContent,
|
|
);
|
|
const mcpResult = keyedArchiveResultSchema.parse(
|
|
parseEvidence(validated).normalized_result,
|
|
);
|
|
expect(mcpResult).toEqual(cliResult);
|
|
expect(await readFile(archivePath)).toEqual(bytes);
|
|
},
|
|
60_000,
|
|
);
|