fix(native): report exact plist integers beyond the JSON number range
`inspect_plist` parsed plutil JSON with `JSON.parse`, silently rounding
integers outside ±(2^53 - 1): `18446744073709551615` became
`18446744073709552000` with no limitation. Keep the exact decimal text
from the JSON source as `{ "$plist_type": "integer", "decimal": "..." }`
and add a limitation. On the XML fallback path, the decoder has already
rounded such values, so they become `decimal: null` instead of a wrong
number.
Also restore the `` escape in the BOM regex, which an earlier
rewrite had turned into a raw invisible character.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
bc2cd8b874
commit
430c297ca4
|
|
@ -0,0 +1,43 @@
|
|||
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("marks rounded XML integers as unknown instead of reporting them", () => {
|
||||
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 },
|
||||
Safe: 7,
|
||||
Blob: { $plist_type: "data", base64: "AA==" },
|
||||
});
|
||||
expect(parsed.value.limitations).toEqual([
|
||||
expect.stringContaining("XML conversion"),
|
||||
expect.stringContaining("1 integer value(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,17 @@ 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.
|
||||
const value = mapJsonNumbers(projected.value, (item) => {
|
||||
if (!Number.isInteger(item) || Number.isSafeInteger(item)) return item;
|
||||
unknownIntegerCount += 1;
|
||||
return { $plist_type: "integer", decimal: null };
|
||||
});
|
||||
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 +111,35 @@ 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
|
||||
? []
|
||||
: [
|
||||
`${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.`,
|
||||
]),
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
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,51 @@ 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 }) => {
|
||||
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>",
|
||||
"</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: [expect.stringContaining("2 integer value(s)")],
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in New Issue