diff --git a/src/native/NativeMacOSProvider.ts b/src/native/NativeMacOSProvider.ts index 5cc09a326..ab4da6c0c 100644 --- a/src/native/NativeMacOSProvider.ts +++ b/src/native/NativeMacOSProvider.ts @@ -50,7 +50,11 @@ import { import { parseCodeSignature } from "./parsers/codesign.js"; import { parseDemangledSymbols } from "./parsers/demangle.js"; import { parseLipoArchitectures } from "./parsers/lipo.js"; -import { parsePlistJson, parsePlistXml } from "./parsers/plist.js"; +import { + parsePlistJson, + parsePlistXml, + plistJsonNeedsNumberTypes, +} from "./parsers/plist.js"; import { architectureLocations, inspectNativeMacho, @@ -431,9 +435,12 @@ class NativeMacOSClient implements AnalysisClient { ); if (!json.ok) return json; // JSON cannot express data, dates, or non-finite reals; plutil rejects - // such plists, so decode its lossless XML conversion instead. + // such plists, so decode its lossless XML conversion instead. JSON also + // prints an integral like an , so a number beyond the + // exact JSON range takes its element type from the XML conversion. + const jsonDecoded = json.value.exitCode === 0; const xml = - json.value.exitCode === 0 + jsonDecoded && !plistJsonNeedsNumberTypes(json.value.stdout) ? undefined : await this.#run( "inspect_plist", @@ -445,7 +452,9 @@ class NativeMacOSClient implements AnalysisClient { const parsed = xml === undefined ? parsePlistJson(json.value.stdout) - : parsePlistXml(xml.value.stdout); + : jsonDecoded + ? parsePlistJson(json.value.stdout, xml.value.stdout) + : parsePlistXml(xml.value.stdout); if (!parsed.ok) return parsed; const provenance = [ classified.value, diff --git a/src/native/parsers/plist.test.ts b/src/native/parsers/plist.test.ts index fd010be10..db1cbf135 100644 --- a/src/native/parsers/plist.test.ts +++ b/src/native/parsers/plist.test.ts @@ -1,24 +1,79 @@ import { expect, it } from "vitest"; -import { parsePlistJson, parsePlistXml } from "./plist.js"; +import { + parsePlistJson, + parsePlistXml, + plistJsonNeedsNumberTypes, +} from "./plist.js"; -it("keeps exact decimal text for JSON integers beyond the safe range", () => { +it("keeps exact decimal text for JSON integers that XML shows are integers", () => { const parsed = parsePlistJson( - '{"Max":18446744073709551615,"Min":-9223372036854775808,"Safe":9007199254740991,"Real":1e21,"Ratio":0.5}', + '{"Max":18446744073709551615,"Min":-9223372036854775808,"Near":9007199254740993,"Edge":9007199254740992,"Safe":9007199254740991,"Real":1e21,"Ratio":0.5}', + [ + "", + "Max18446744073709551615", + "Min-9223372036854775808", + "Near9007199254740993", + "Edge9007199254740992", + "Safe9007199254740991", + "Real1e+21", + "Ratio0.5", + "", + ].join(""), ); if (!parsed.ok) throw new Error("expected plist JSON to parse"); + // JSON keeps each literal's digits, so integers that round to the same + // number stay distinct. expect(parsed.value.value).toEqual({ Max: { $plist_type: "integer", decimal: "18446744073709551615" }, Min: { $plist_type: "integer", decimal: "-9223372036854775808" }, + Near: { $plist_type: "integer", decimal: "9007199254740993" }, + Edge: { $plist_type: "integer", decimal: "9007199254740992" }, Safe: 9007199254740991, Real: 1e21, Ratio: 0.5, }); expect(parsed.value.limitations).toEqual([ - expect.stringContaining("2 integer value(s)"), + expect.stringContaining("4 integer value(s)"), ]); }); +it("keeps integral JSON reals beyond the safe range as reals", () => { + const output = '{"Real":9007199254740992,"Large":100000000000000000000}'; + expect(plistJsonNeedsNumberTypes(output)).toBe(true); + const parsed = parsePlistJson( + output, + "Real9007199254740992Large1e+20", + ); + if (!parsed.ok) throw new Error("expected plist JSON to parse"); + expect(parsed.value.value).toEqual({ Real: 9007199254740992, Large: 1e20 }); + expect(parsed.value.limitations).toEqual([]); +}); + +it.each([ + ["without element types", undefined], + [ + "when an integer and a real decode alike", + "90071992547409929007199254740992", + ], +])("does not label an unsafe JSON number an integer %s", (_case, xml) => { + const parsed = parsePlistJson("[9007199254740992,9007199254740992]", xml); + if (!parsed.ok) throw new Error("expected plist JSON to parse"); + expect(parsed.value.value).toEqual([9007199254740992, 9007199254740992]); + expect(parsed.value.limitations).toEqual([ + expect.stringContaining("2 number(s)"), + ]); +}); + +it("requests element types only for unsafe integral JSON numbers", () => { + expect( + plistJsonNeedsNumberTypes( + '{"Safe":9007199254740991,"Real":1e21,"Text":"9007199254740993"}', + ), + ).toBe(false); + expect(plistJsonNeedsNumberTypes("{")).toBe(false); +}); + 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"); diff --git a/src/native/parsers/plist.ts b/src/native/parsers/plist.ts index 2d0022190..f14181454 100644 --- a/src/native/parsers/plist.ts +++ b/src/native/parsers/plist.ts @@ -27,34 +27,81 @@ export interface ParsedPlist { readonly limitations: readonly string[]; } -/** Parse plutil JSON output and project stable bundle metadata. */ -export const parsePlistJson = ( +/** Digits of a JSON number literal that has no fraction or exponent. */ +const INTEGRAL_LITERAL = /^-?\d+$/u; + +/** + * Decode plutil JSON, passing each integral number literal beyond the exact + * range of a JavaScript number, with its source digits, to `unsafe`. + */ +const decodePlistJson = ( output: string, -): Result => { + unsafe: (item: number, digits: string) => unknown, +): unknown => { // plutil output can carry a UTF-8 BOM, which JSON.parse rejects outright. 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 JSON.parse( + text, + (_key: string, item: unknown, context?: { readonly source?: string }) => { + const source = context?.source; + return typeof item !== "number" || + Number.isSafeInteger(item) || + source === undefined || + !INTEGRAL_LITERAL.test(source) + ? item + : unsafe(item, source); + }, + ); +}; + +/** + * Whether plutil JSON holds an integral number beyond the exact range of a + * JSON number. JSON prints an integral `` and an `` alike, so + * such a number's element type must be read from plutil's XML conversion. + */ +export const plistJsonNeedsNumberTypes = (output: string): boolean => { + let found = false; + try { + decodePlistJson(output, (item) => { + found = true; return item; - exactIntegerCount += 1; - return { $plist_type: "integer", decimal: source }; + }); + } catch { + // Malformed JSON is reported by `parsePlistJson`. + } + return found; +}; + +/** + * Parse plutil JSON output and project stable bundle metadata. An integer + * beyond the exact range of a JSON number keeps its decimal text when + * `xmlConversion`, plutil's XML form of the same plist, shows that it came + * from an `` rather than a ``. + */ +export const parsePlistJson = ( + output: string, + xmlConversion?: string, +): Result => { + const literals = + xmlConversion === undefined ? undefined : xmlNumberLiterals(xmlConversion); + let exactIntegerCount = 0; + let ambiguousNumberCount = 0; + const classify = (item: number, digits: string): unknown => { + const exact = BigInt(digits).toString(); + const integer = literals?.integers.get(item)?.has(exact) === true; + const real = literals?.reals.has(item) === true; + if (integer && !real) { + exactIntegerCount += 1; + return { $plist_type: "integer", decimal: digits }; + } + // An integral decodes exactly; only its JSON spelling is integral. + if (real && !integer) return item; + ambiguousNumberCount += 1; + return item; }; let value: unknown; try { - value = JSON.parse(text, keepExactIntegers); + value = decodePlistJson(output, classify); } catch (cause: unknown) { return err( new AnalysisOutputError( @@ -67,10 +114,7 @@ export const parsePlistJson = ( return ok({ value, bundle: projectPlistBundle(value), - limitations: - exactIntegerCount === 0 - ? [] - : [largeIntegerLimitation(exactIntegerCount)], + limitations: numberLimitations(exactIntegerCount, ambiguousNumberCount), }); }; @@ -94,32 +138,19 @@ 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), - ), - ); + // them, rounding integers beyond the exact range. + const literals = xmlNumberLiterals(output); let exactIntegerCount = 0; let ambiguousNumberCount = 0; const value = mapJsonNumbers(projected.value, (item) => { - const decimals = unsafeIntegers.get(item); + const decimals = literals.integers.get(item); if (decimals === undefined) return item; const [decimal] = decimals; - if (decimals.size !== 1 || decimal === undefined || reals.has(item)) { + if ( + decimals.size !== 1 || + decimal === undefined || + literals.reals.has(item) + ) { ambiguousNumberCount += 1; return item; } @@ -136,20 +167,55 @@ 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.`, - ]), + ...numberLimitations(exactIntegerCount, ambiguousNumberCount), ], }); }; -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": "" }.`; +/** `` and `` element literals in plutil's XML conversion. */ +interface XmlNumberLiterals { + /** Exact integer literals beyond the safe range, keyed by decoded number. */ + readonly integers: ReadonlyMap>; + readonly reals: ReadonlySet; +} + +// plutil escapes "<" in keys and strings, so these element literals can be +// read from the XML text. +const xmlNumberLiterals = (xml: string): XmlNumberLiterals => { + const integers = new Map>(); + for (const [, decimal = ""] of xml.matchAll( + /\s*([+-]?\d+)\s*<\/integer>/gu, + )) { + const rounded = Number(decimal); + if (Number.isSafeInteger(rounded)) continue; + integers.set( + rounded, + (integers.get(rounded) ?? new Set()).add(BigInt(decimal).toString()), + ); + } + const reals = new Set( + [...xml.matchAll(/([^<]*)<\/real>/gu)].map(([, text = ""]) => + Number.parseFloat(text), + ), + ); + return { integers, reals }; +}; + +const numberLimitations = ( + exactIntegerCount: number, + ambiguousNumberCount: number, +): string[] => [ + ...(exactIntegerCount === 0 + ? [] + : [ + `${String(exactIntegerCount)} integer value(s) exceed the exact range of a JSON number and are reported as { "$plist_type": "integer", "decimal": "" }.`, + ]), + ...(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 mapJsonNumbers = ( value: JsonValue, diff --git a/tests/boundary/cli/nativePlistCli.test.ts b/tests/boundary/cli/nativePlistCli.test.ts index 9ceabcd21..487a9b1e1 100644 --- a/tests/boundary/cli/nativePlistCli.test.ts +++ b/tests/boundary/cli/nativePlistCli.test.ts @@ -135,7 +135,7 @@ describe.skipIf(process.platform !== "darwin")( "native plist CLI integers", () => { cliTest.for([false, true])( - "reports exact plist integers beyond the JSON number range (data: %s)", + "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"); @@ -153,6 +153,8 @@ describe.skipIf(process.platform !== "darwin")( "Max18446744073709551615", "Min-9223372036854775808", "Count42", + "Real9007199254740992", + "Large1e20", ...(withData ? ["BlobAAEC"] : []), "", ].join(""), @@ -171,6 +173,8 @@ describe.skipIf(process.platform !== "darwin")( Max: { $plist_type: "integer", decimal: "18446744073709551615" }, Min: { $plist_type: "integer", decimal: "-9223372036854775808" }, Count: 42, + Real: 9007199254740992, + Large: 1e20, }, limitations: [ ...(withData ? [expect.stringContaining("XML conversion")] : []),