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:
parent
8b9940a7b5
commit
3b92a2bced
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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")] : []),
|
||||
|
|
|
|||
Loading…
Reference in New Issue