Merge pull request #846 from morluto/fix/plist-exact-large-integers

fix(native): report exact plist integers beyond the JSON number range
This commit is contained in:
Kaoru0822 2026-10-06 21:31:49 -07:00 committed by GitHub
commit c9f49db907
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 227 additions and 10 deletions

View File

@ -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(
"<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: "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(
"<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

@ -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<ParsedPlist, AnalysisOutputError> => {
// 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 <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) => {
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 <integer> or a <real>, 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": "<exact digits>" }.`;
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 => {

View File

@ -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"),
[
"<plist><dict>",
"<key>CFBundleExecutable</key><string>App</string>",
"<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(""),
);
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)"),
],
},
});
},
);
},
);