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)")], + }, + }); + }, + ); + }, +);