diff --git a/src/native/parsers/plist.test.ts b/src/native/parsers/plist.test.ts
new file mode 100644
index 000000000..fd010be10
--- /dev/null
+++ b/src/native/parsers/plist.test.ts
@@ -0,0 +1,73 @@
+import { expect, it } from "vitest";
+
+import { parsePlistJson, parsePlistXml } from "./plist.js";
+
+it("keeps exact decimal text for JSON integers beyond the safe range", () => {
+ const parsed = parsePlistJson(
+ '{"Max":18446744073709551615,"Min":-9223372036854775808,"Safe":9007199254740991,"Real":1e21,"Ratio":0.5}',
+ );
+ if (!parsed.ok) throw new Error("expected plist JSON to parse");
+ expect(parsed.value.value).toEqual({
+ Max: { $plist_type: "integer", decimal: "18446744073709551615" },
+ Min: { $plist_type: "integer", decimal: "-9223372036854775808" },
+ Safe: 9007199254740991,
+ Real: 1e21,
+ Ratio: 0.5,
+ });
+ expect(parsed.value.limitations).toEqual([
+ expect.stringContaining("2 integer value(s)"),
+ ]);
+});
+
+it("reports no limitation when every JSON integer is exact", () => {
+ const parsed = parsePlistJson('{"Count":42,"Nested":[-1,0]}');
+ if (!parsed.ok) throw new Error("expected plist JSON to parse");
+ expect(parsed.value.value).toEqual({ Count: 42, Nested: [-1, 0] });
+ expect(parsed.value.limitations).toEqual([]);
+});
+
+it("recovers exact XML integers from their element literals", () => {
+ const parsed = parsePlistXml(
+ "Max18446744073709551615Safe7BlobAA==",
+ );
+ if (!parsed.ok) throw new Error("expected plist XML to parse");
+ expect(parsed.value.value).toEqual({
+ Max: { $plist_type: "integer", decimal: "18446744073709551615" },
+ Safe: 7,
+ Blob: { $plist_type: "data", base64: "AA==" },
+ });
+ expect(parsed.value.limitations).toEqual([
+ expect.stringContaining("XML conversion"),
+ expect.stringContaining("1 integer value(s)"),
+ ]);
+});
+
+it("keeps integral XML reals beyond the safe range as reals", () => {
+ const parsed = parsePlistXml(
+ "Real9007199254740992BlobAA==",
+ );
+ if (!parsed.ok) throw new Error("expected plist XML to parse");
+ expect(parsed.value.value).toEqual({
+ Real: 9007199254740992,
+ Blob: { $plist_type: "data", base64: "AA==" },
+ });
+ expect(parsed.value.limitations).toEqual([
+ expect.stringContaining("XML conversion"),
+ ]);
+});
+
+it("reports XML numbers whose integer or real origin is ambiguous", () => {
+ const parsed = parsePlistXml(
+ "90071992547409939007199254740992AA==",
+ );
+ if (!parsed.ok) throw new Error("expected plist XML to parse");
+ expect(parsed.value.value).toEqual([
+ 9007199254740992,
+ 9007199254740992,
+ { $plist_type: "data", base64: "AA==" },
+ ]);
+ expect(parsed.value.limitations).toEqual([
+ expect.stringContaining("XML conversion"),
+ expect.stringContaining("2 number(s)"),
+ ]);
+});
diff --git a/src/native/parsers/plist.ts b/src/native/parsers/plist.ts
index e5c4d982a..2d0022190 100644
--- a/src/native/parsers/plist.ts
+++ b/src/native/parsers/plist.ts
@@ -2,12 +2,12 @@ import { parse as parseXmlPlist } from "plist";
import { z } from "zod";
import { AnalysisOutputError } from "../../domain/analysisErrorCore.js";
+import type { JsonValue } from "../../domain/jsonValue.js";
import {
projectPlistValue,
type ProjectedPlistValue,
} from "../../domain/plistValue.js";
import { err, ok, type Result } from "../../domain/result.js";
-import { safeParseJson } from "../../domain/safeJson.js";
const plistObject = z.record(z.string(), z.unknown());
@@ -32,15 +32,46 @@ export const parsePlistJson = (
output: string,
): Result => {
// plutil output can carry a UTF-8 BOM, which JSON.parse rejects outright.
- const parsed = safeParseJson(output.replace(/^/u, ""));
- if (!parsed.ok)
+ const text = output.replace(/^\uFEFF/u, "");
+ let exactIntegerCount = 0;
+ // A plist integer can exceed the exact range of a JavaScript number; keep
+ // its decimal text from the JSON source instead of a rounded value.
+ const keepExactIntegers = (
+ _key: string,
+ item: unknown,
+ context?: { readonly source?: string },
+ ): unknown => {
+ const source = context?.source;
+ if (
+ typeof item !== "number" ||
+ Number.isSafeInteger(item) ||
+ source === undefined ||
+ !/^-?\d+$/u.test(source)
+ )
+ return item;
+ exactIntegerCount += 1;
+ return { $plist_type: "integer", decimal: source };
+ };
+ let value: unknown;
+ try {
+ value = JSON.parse(text, keepExactIntegers);
+ } catch (cause: unknown) {
return err(
- new AnalysisOutputError("inspect_plist", parsed.error, {
- cause: parsed.cause,
- }),
+ new AnalysisOutputError(
+ "inspect_plist",
+ cause instanceof Error ? cause.message : String(cause),
+ { cause },
+ ),
);
- const value: unknown = parsed.value;
- return ok({ value, bundle: projectPlistBundle(value), limitations: [] });
+ }
+ return ok({
+ value,
+ bundle: projectPlistBundle(value),
+ limitations:
+ exactIntegerCount === 0
+ ? []
+ : [largeIntegerLimitation(exactIntegerCount)],
+ });
};
/**
@@ -62,9 +93,42 @@ export const parsePlistXml = (
),
);
}
+ // The XML decoder turns and into numbers before REA sees
+ // them, rounding integers beyond the exact range. plutil escapes "<" in keys
+ // and strings, so the element literals can be read from the XML text.
+ const unsafeIntegers = new Map>();
+ for (const [, decimal = ""] of output.matchAll(
+ /\s*([+-]?\d+)\s*<\/integer>/gu,
+ )) {
+ const rounded = Number(decimal);
+ if (Number.isSafeInteger(rounded)) continue;
+ const exact = BigInt(decimal).toString();
+ unsafeIntegers.set(
+ rounded,
+ (unsafeIntegers.get(rounded) ?? new Set()).add(exact),
+ );
+ }
+ const reals = new Set(
+ [...output.matchAll(/([^<]*)<\/real>/gu)].map(([, text = ""]) =>
+ Number.parseFloat(text),
+ ),
+ );
+ let exactIntegerCount = 0;
+ let ambiguousNumberCount = 0;
+ const value = mapJsonNumbers(projected.value, (item) => {
+ const decimals = unsafeIntegers.get(item);
+ if (decimals === undefined) return item;
+ const [decimal] = decimals;
+ if (decimals.size !== 1 || decimal === undefined || reals.has(item)) {
+ ambiguousNumberCount += 1;
+ return item;
+ }
+ exactIntegerCount += 1;
+ return { $plist_type: "integer", decimal };
+ });
return ok({
- value: projected.value,
- bundle: projectPlistBundle(projected.value),
+ value,
+ bundle: projectPlistBundle(value),
limitations: [
'plutil cannot express this plist as JSON, so it was decoded from plutil\'s XML conversion; data and date values are typed objects such as { "$plist_type": "data", "base64": ... }.',
...(projected.unknownRealCount === 0
@@ -72,10 +136,38 @@ export const parsePlistXml = (
: [
`${String(projected.unknownRealCount)} non-finite real value(s) are reported as { "$plist_type": "real", "value": null } because the XML decoder does not distinguish NaN from infinity.`,
]),
+ ...(exactIntegerCount === 0
+ ? []
+ : [largeIntegerLimitation(exactIntegerCount)]),
+ ...(ambiguousNumberCount === 0
+ ? []
+ : [
+ `${String(ambiguousNumberCount)} number(s) beyond the exact range of a JSON number are reported as decoded because the XML literal they came from, a rounded or a , cannot be identified.`,
+ ]),
],
});
};
+const largeIntegerLimitation = (count: number): string =>
+ `${String(count)} integer value(s) exceed the exact range of a JSON number and are reported as { "$plist_type": "integer", "decimal": "" }.`;
+
+const mapJsonNumbers = (
+ value: JsonValue,
+ map: (item: number) => JsonValue,
+): JsonValue => {
+ if (typeof value === "number") return map(value);
+ if (Array.isArray(value))
+ return value.map((item) => mapJsonNumbers(item, map));
+ if (value !== null && typeof value === "object")
+ return Object.fromEntries(
+ Object.entries(value).map(([key, item]) => [
+ key,
+ mapJsonNumbers(item, map),
+ ]),
+ );
+ return value;
+};
+
const projectPlistBundle = (value: unknown): PlistBundleMetadata => {
const object = plistObject.safeParse(value);
const field = (name: string): string | null => {
diff --git a/tests/boundary/cli/nativePlistCli.test.ts b/tests/boundary/cli/nativePlistCli.test.ts
index bf13f7877..9ceabcd21 100644
--- a/tests/boundary/cli/nativePlistCli.test.ts
+++ b/tests/boundary/cli/nativePlistCli.test.ts
@@ -130,3 +130,55 @@ describe.skipIf(process.platform !== "darwin")(
);
},
);
+
+describe.skipIf(process.platform !== "darwin")(
+ "native plist CLI integers",
+ () => {
+ cliTest.for([false, true])(
+ "reports exact plist integers 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"),
+ [
+ "",
+ "CFBundleExecutableApp",
+ "Max18446744073709551615",
+ "Min-9223372036854775808",
+ "Count42",
+ ...(withData ? ["BlobAAEC"] : []),
+ "",
+ ].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,
+ },
+ limitations: [
+ ...(withData ? [expect.stringContaining("XML conversion")] : []),
+ expect.stringContaining("2 integer value(s)"),
+ ],
+ },
+ });
+ },
+ );
+ },
+);