fix(native): keep plist XML element types for large numbers

The XML fallback inferred an integer from `Number.isInteger`, so an
integral `<real>` beyond 2^53 was misreported as an unknown integer.
Read the `<integer>` and `<real>` literals from plutil's XML text instead:
an unsafe integral number that matches one `<integer>` literal and no
`<real>` gets its exact decimal; a number that could be either stays as
decoded with a limitation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Iwakurakaoru0822 2026-10-06 21:13:11 -07:00
parent 430c297ca4
commit f85056d83e
3 changed files with 76 additions and 14 deletions

View File

@ -26,13 +26,13 @@ it("reports no limitation when every JSON integer is exact", () => {
expect(parsed.value.limitations).toEqual([]);
});
it("marks rounded XML integers as unknown instead of reporting them", () => {
it("recovers exact XML integers from their element literals", () => {
const parsed = parsePlistXml(
"<plist><dict><key>Max</key><integer>18446744073709551615</integer><key>Safe</key><integer>7</integer><key>Blob</key><data>AA==</data></dict></plist>",
);
if (!parsed.ok) throw new Error("expected plist XML to parse");
expect(parsed.value.value).toEqual({
Max: { $plist_type: "integer", decimal: null },
Max: { $plist_type: "integer", decimal: "18446744073709551615" },
Safe: 7,
Blob: { $plist_type: "data", base64: "AA==" },
});
@ -41,3 +41,33 @@ it("marks rounded XML integers as unknown instead of reporting them", () => {
expect.stringContaining("1 integer value(s)"),
]);
});
it("keeps integral XML reals beyond the safe range as reals", () => {
const parsed = parsePlistXml(
"<plist><dict><key>Real</key><real>9007199254740992</real><key>Blob</key><data>AA==</data></dict></plist>",
);
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(
"<plist><array><integer>9007199254740993</integer><real>9007199254740992</real><data>AA==</data></array></plist>",
);
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)"),
]);
});

View File

@ -93,13 +93,38 @@ 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.
// 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),
),
);
let exactIntegerCount = 0;
let ambiguousNumberCount = 0;
const value = mapJsonNumbers(projected.value, (item) => {
if (!Number.isInteger(item) || Number.isSafeInteger(item)) return item;
unknownIntegerCount += 1;
return { $plist_type: "integer", decimal: null };
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,
@ -111,10 +136,13 @@ 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
...(exactIntegerCount === 0
? []
: [largeIntegerLimitation(exactIntegerCount)]),
...(ambiguousNumberCount === 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.`,
`${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.`,
]),
],
});

View File

@ -134,9 +134,9 @@ 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 }) => {
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");
@ -153,6 +153,7 @@ describe.skipIf(process.platform !== "darwin")(
"<key>Max</key><integer>18446744073709551615</integer>",
"<key>Min</key><integer>-9223372036854775808</integer>",
"<key>Count</key><integer>42</integer>",
...(withData ? ["<key>Blob</key><data>AAEC</data>"] : []),
"</dict></plist>",
].join(""),
);
@ -171,7 +172,10 @@ describe.skipIf(process.platform !== "darwin")(
Min: { $plist_type: "integer", decimal: "-9223372036854775808" },
Count: 42,
},
limitations: [expect.stringContaining("2 integer value(s)")],
limitations: [
...(withData ? [expect.stringContaining("XML conversion")] : []),
expect.stringContaining("2 integer value(s)"),
],
},
});
},