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