Files
reverse-engineering-anything/tests/boundary/cli/nativePlistCli.test.ts
T
Kaoru0822andClaude Opus 5.5 7a66470385 fix(apple): report plist dates the decoder cannot represent as unknown (#3793)
plutil writes years past 9999 and negative years in a form the pinned
XML decoder cannot parse, and binary plists can hold dates outside the
JavaScript Date range. The decoder returns an Invalid Date. Its
projection's toISOString() call then threw, so inspect_plist failed the
whole plist as "unreadable_output" with a `rea doctor` remediation.
Keyed archive inspection and Interface Builder decoding rejected the
archive in the same way.

projectPlistValue, the shared projection, now reports such a date as
{ "$plist_type": "date", "iso8601": null } and counts it. inspect_plist,
keyed archive inspection and Interface Builder decoding each add a
counted limitation, as they already do for non-finite reals.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 19:13:01 -07:00

218 lines
7.9 KiB
TypeScript

import { execFile } from "node:child_process";
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { promisify } from "node:util";
import { describe, expect } from "vitest";
import { thinMach } from "../../../src/domain/binaryTarget.fixture.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { cliTest } from "../../support/cli/cliFixture.js";
describe.skipIf(process.platform !== "darwin")(
"native plist CLI selection",
() => {
// Targets are app-relative: the bundle, its program file, or a plist file.
cliTest.for([
["default", "", undefined, "default"],
["alternate", "", "Contents/Alternate.plist", "alternate"],
["missing", "", "Contents/Missing.plist", undefined],
["program default", "Contents/MacOS/App", undefined, "default"],
[
"program alternate",
"Contents/MacOS/App",
"Contents/Alternate.plist",
"alternate",
],
["plist file", "Contents/Alternate.plist", undefined, "alternate"],
] as const)(
"honors the $0 plist selection",
async ([, target, selected, identifier], { cli }) => {
const directory = await createTestTempDirectory("rea-plist-cli-");
const app = join(directory, "Example.app");
const contents = join(app, "Contents");
await mkdir(join(contents, "MacOS"), { recursive: true });
await writeFile(
join(contents, "MacOS/App"),
thinMach(0xfeedfacf, 0x0100000c),
);
for (const [name, value] of [
["Info", "default"],
["Alternate", "alternate"],
]) {
await writeFile(
join(contents, `${name}.plist`),
`<plist><dict><key>CFBundleExecutable</key><string>App</string><key>CFBundleIdentifier</key><string>${value}</string></dict></plist>`,
);
}
const result = await cli.run({
arguments: [
"inspect-plist",
join(app, target),
...(selected === undefined ? [] : ["--relative-path", selected]),
"--json",
],
environment: {
REA_LOG_LEVEL: "silent",
REA_ANALYSIS_PROVIDER: "auto",
},
});
if (identifier === undefined) {
expect(result.exitCode).toBe(1);
expect(result.json).toMatchObject({ error: "Analysis failed" });
} else {
expect(result.exitCode).toBe(0);
expect(result.json).toMatchObject({
normalized_result: {
source_path: join(
app,
selected ??
(target.endsWith(".plist") ? target : "Contents/Info.plist"),
),
value: { CFBundleIdentifier: identifier },
},
});
}
},
);
cliTest.for(["xml", "binary"] as const)(
"decodes $0 plists that plutil cannot express as JSON",
async (format, { cli }) => {
const directory = await createTestTempDirectory("rea-plist-types-");
const app = join(directory, "Example.app");
const contents = join(app, "Contents");
await mkdir(join(contents, "MacOS"), { recursive: true });
await writeFile(
join(contents, "MacOS/App"),
thinMach(0xfeedfacf, 0x0100000c),
);
const plist = join(contents, "Info.plist");
await writeFile(
plist,
[
"<plist><dict>",
"<key>CFBundleExecutable</key><string>App</string>",
"<key>CFBundleIdentifier</key><string>com.example.types</string>",
"<key>Blob</key><data>AAEC</data>",
"<key>Built</key><date>2020-01-01T00:00:00Z</date>",
// plutil writes years past 9999 unsigned, which the decoder rejects.
"<key>Far</key><date>11507-08-17T05:20:00Z</date>",
"<key>Ratio</key><real>nan</real>",
"<key>Nested</key><array><dict>",
"<key>Ratio</key><real>0.5</real>",
"<key>Unbounded</key><real>-infinity</real>",
"<key></key><string>empty key</string>",
"</dict></array>",
"</dict></plist>",
].join(""),
);
if (format === "binary")
await promisify(execFile)("/usr/bin/plutil", [
"-convert",
"binary1",
"--",
plist,
]);
const result = await cli.run({
arguments: ["inspect-plist", app, "--json"],
environment: {
REA_LOG_LEVEL: "silent",
REA_ANALYSIS_PROVIDER: "auto",
},
});
expect(result.exitCode).toBe(0);
expect(result.json).toMatchObject({
normalized_result: {
format,
value: {
CFBundleIdentifier: "com.example.types",
Blob: { $plist_type: "data", base64: "AAEC" },
Built: {
$plist_type: "date",
iso8601: "2020-01-01T00:00:00.000Z",
},
Far: { $plist_type: "date", iso8601: null },
Ratio: { $plist_type: "real", value: null },
Nested: [
{
Ratio: 0.5,
Unbounded: { $plist_type: "real", value: null },
"": "empty key",
},
],
},
bundle: { identifier: "com.example.types", executable: "App" },
provenance: [
{ tool: "file" },
{ command: expect.arrayContaining(["json"]), exit: { code: 1 } },
{ command: expect.arrayContaining(["xml1"]), exit: { code: 0 } },
],
limitations: [
expect.stringContaining("XML conversion"),
expect.stringContaining("2 non-finite real"),
expect.stringContaining("1 date value(s)"),
],
},
});
},
);
},
);
describe.skipIf(process.platform !== "darwin")(
"native plist CLI integers",
() => {
cliTest.for([false, true])(
"reports exact plist integers and integral reals beyond the JSON number range (data: %s)",
async (withData, { cli }) => {
const directory = await createTestTempDirectory("rea-plist-integers-");
const app = join(directory, "Example.app");
const contents = join(app, "Contents");
await mkdir(join(contents, "MacOS"), { recursive: true });
await writeFile(
join(contents, "MacOS/App"),
thinMach(0xfeedfacf, 0x0100000c),
);
await writeFile(
join(contents, "Info.plist"),
[
"<plist><dict>",
"<key>CFBundleExecutable</key><string>App</string>",
"<key>Max</key><integer>18446744073709551615</integer>",
"<key>Min</key><integer>-9223372036854775808</integer>",
"<key>Count</key><integer>42</integer>",
"<key>Real</key><real>9007199254740992</real>",
"<key>Large</key><real>1e20</real>",
...(withData ? ["<key>Blob</key><data>AAEC</data>"] : []),
"</dict></plist>",
].join(""),
);
const result = await cli.run({
arguments: ["inspect-plist", app, "--json"],
environment: {
REA_LOG_LEVEL: "silent",
REA_ANALYSIS_PROVIDER: "auto",
},
});
expect(result.exitCode).toBe(0);
expect(result.json).toMatchObject({
normalized_result: {
value: {
Max: { $plist_type: "integer", decimal: "18446744073709551615" },
Min: { $plist_type: "integer", decimal: "-9223372036854775808" },
Count: 42,
Real: 9007199254740992,
Large: 1e20,
},
limitations: [
...(withData ? [expect.stringContaining("XML conversion")] : []),
expect.stringContaining("2 integer value(s)"),
],
},
});
},
);
},
);