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:
commit
c9f49db907
|
|
@ -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)"),
|
||||
]);
|
||||
});
|
||||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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)"),
|
||||
],
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in New Issue