diff --git a/src/native/parsers/plist.test.ts b/src/native/parsers/plist.test.ts
new file mode 100644
index 000000000..40eb69938
--- /dev/null
+++ b/src/native/parsers/plist.test.ts
@@ -0,0 +1,43 @@
+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("marks rounded XML integers as unknown instead of reporting them", () => {
+ 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: null },
+ Safe: 7,
+ Blob: { $plist_type: "data", base64: "AA==" },
+ });
+ expect(parsed.value.limitations).toEqual([
+ expect.stringContaining("XML conversion"),
+ expect.stringContaining("1 integer value(s)"),
+ ]);
+});
diff --git a/src/native/parsers/plist.ts b/src/native/parsers/plist.ts
index e5c4d982a..06a4fb2d2 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,17 @@ export const parsePlistXml = (
),
);
}
+ let unknownIntegerCount = 0;
+ // The XML decoder parses integers into numbers before REA sees their text,
+ // so a value outside the exact range is already rounded and stays unknown.
+ const value = mapJsonNumbers(projected.value, (item) => {
+ if (!Number.isInteger(item) || Number.isSafeInteger(item)) return item;
+ unknownIntegerCount += 1;
+ return { $plist_type: "integer", decimal: null };
+ });
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 +111,35 @@ 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.`,
]),
+ ...(unknownIntegerCount === 0
+ ? []
+ : [
+ `${String(unknownIntegerCount)} integer value(s) exceed the exact range of a JSON number and are reported as { "$plist_type": "integer", "decimal": null } because the XML decoder rounds them.`,
+ ]),
],
});
};
+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..dd6868d9e 100644
--- a/tests/boundary/cli/nativePlistCli.test.ts
+++ b/tests/boundary/cli/nativePlistCli.test.ts
@@ -130,3 +130,51 @@ describe.skipIf(process.platform !== "darwin")(
);
},
);
+
+describe.skipIf(process.platform !== "darwin")(
+ "native plist CLI integers",
+ () => {
+ cliTest(
+ "reports exact plist integers beyond the JSON number range",
+ async ({ 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",
+ "",
+ ].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: [expect.stringContaining("2 integer value(s)")],
+ },
+ });
+ },
+ );
+ },
+);