fix(native): keep integral plist reals beyond the JSON range as reals (#860)

plutil's JSON spells an integral <real> such as 9007199254740992 like an
<integer>, so the JSON path labeled it an exact integer. When the JSON
holds an unsafe integral number, read element types from plutil's XML
conversion; numbers whose origin stays ambiguous are reported as decoded
with an explicit limitation.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kaoru0822 2026-10-06 22:01:26 -07:00 committed by GitHub
parent 8b9940a7b5
commit 3b92a2bced
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 199 additions and 65 deletions

View File

@ -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 <real> like an <integer>, 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,

View File

@ -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}',
[
"<plist><dict>",
"<key>Max</key><integer>18446744073709551615</integer>",
"<key>Min</key><integer>-9223372036854775808</integer>",
"<key>Near</key><integer>9007199254740993</integer>",
"<key>Edge</key><integer>9007199254740992</integer>",
"<key>Safe</key><integer>9007199254740991</integer>",
"<key>Real</key><real>1e+21</real>",
"<key>Ratio</key><real>0.5</real>",
"</dict></plist>",
].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,
"<plist><dict><key>Real</key><real>9007199254740992</real><key>Large</key><real>1e+20</real></dict></plist>",
);
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",
"<plist><array><integer>9007199254740992</integer><real>9007199254740992</real></array></plist>",
],
])("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");

View File

@ -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<ParsedPlist, AnalysisOutputError> => {
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 `<real>` and an `<integer>` 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 `<integer>` rather than a `<real>`.
*/
export const parsePlistJson = (
output: string,
xmlConversion?: string,
): Result<ParsedPlist, AnalysisOutputError> => {
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 <real> 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 <integer> and <real> 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<number, Set<string>>();
for (const [, decimal = ""] of output.matchAll(
/<integer>\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>([^<]*)<\/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 <integer> or a <real>, 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": "<exact digits>" }.`;
/** `<integer>` and `<real>` element literals in plutil's XML conversion. */
interface XmlNumberLiterals {
/** Exact integer literals beyond the safe range, keyed by decoded number. */
readonly integers: ReadonlyMap<number, ReadonlySet<string>>;
readonly reals: ReadonlySet<number>;
}
// 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<number, Set<string>>();
for (const [, decimal = ""] of xml.matchAll(
/<integer>\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>([^<]*)<\/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": "<exact digits>" }.`,
]),
...(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 <integer> or a <real>, cannot be identified.`,
]),
];
const mapJsonNumbers = (
value: JsonValue,

View File

@ -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")(
"<key>Max</key><integer>18446744073709551615</integer>",
"<key>Min</key><integer>-9223372036854775808</integer>",
"<key>Count</key><integer>42</integer>",
"<key>Real</key><real>9007199254740992</real>",
"<key>Large</key><real>1e20</real>",
...(withData ? ["<key>Blob</key><data>AAEC</data>"] : []),
"</dict></plist>",
].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")] : []),