fix(plist): preserve observed non-finite XML reals

Recognize complete CFPropertyList literals at the XML owner and retain
canonical NaN and signed infinity identity as typed scalars. Keep scalar
wrappers distinct from lookalike dictionaries, share limitation text and
document alias normalization. Incomplete markup retains its existing
compatibility fallback and limitation. Bound token matching before case
normalization using the accepted grammar.

Adapted from PR #4908. Fixes #4906.

Validation: public CLI/MCP regression baseline 2 failed, 4 passed.
Final focused validation 430 passed across 25 files, including controlled
entitlement response coverage in the following commit. Real macOS plutil
CLI, check:fast, docs:check and discovery passed. Binary value identity
remains tracked in #4919; malformed literal policy remains #4852.

Co-authored-by: Mert Kaya <47145541+mertkayacs@users.noreply.github.com>
This commit is contained in:
morlutoandMert Kaya committed 2026-10-18 07:48:18 +08:00
1 parent d205c528e3
commit 6c98708793
13 files changed
+302 -19

No files matched your search

+12
View File
@@ -634,6 +634,18 @@ because a JSON number cannot carry the sign of zero. `inspect_plist` and
Builder node attributes use the same value. Positive zero and integer zero,
including an `<integer>-0</integer>` literal, remain the number `0`.
An XML `<real>` spelled the way CFPropertyList reads and writes non-finite
reals (`nan`, `+infinity`, `-infinity`, and the parser's `infinity`, `inf`,
`+inf` and `-inf`, compared case-insensitively) is reported as
`{ "$plist_type": "real", "value": "nan" | "+infinity" | "-infinity" }` with
the kind and sign preserved using CFPropertyList's canonical writer spelling.
Case and `inf` aliases are normalized; the original archive bytes retain the
source literal. A non-finite number without a completely observed spelling,
such as a directly decoded binary plist double or XML text the decoder could
not read as a number, is reported as `{ "$plist_type": "real", "value": null }`. Both are
counted in separate limitations. Native `inspect_plist` can recover canonical
non-finite values from plutil's XML conversion even for a binary source.
## Locate a byte signature
Use `search_bytes` after opening a target with Ghidra to locate a known constant
+7 -3
View File
@@ -8,7 +8,9 @@ import {
} from "../../domain/analysisErrorCore.js";
import {
negativeZeroRealLimitation,
nonFiniteRealLimitation,
unknownDateLimitation,
unknownRealLimitation,
} from "../../domain/apple/plistValue.js";
import { createPlistNumberProjection } from "../../domain/apple/plistNumbers.js";
import {
@@ -86,6 +88,7 @@ export const decodeKeyedArchiveBytes = (
}
: parseXmlPropertyList(xmlText ?? "");
const {
nonFiniteRealCount,
unknownRealCount,
negativeZeroRealCount,
unknownDateCount,
@@ -115,11 +118,12 @@ export const decodeKeyedArchiveBytes = (
...(parsed.omittedPrototypeKeys === 0
? []
: [omittedPrototypeKeysLimitation(parsed.omittedPrototypeKeys)]),
...(nonFiniteRealCount === 0
? []
: [nonFiniteRealLimitation(nonFiniteRealCount)]),
...(unknownRealCount === 0
? []
: [
`${String(unknownRealCount)} non-finite real value(s) are reported as { "$plist_type": "real", "value": null } because JSON evidence cannot preserve NaN vs infinity.`,
]),
: [unknownRealLimitation(unknownRealCount)]),
...(negativeZeroRealCount === 0
? []
: [negativeZeroRealLimitation(negativeZeroRealCount)]),
+1
View File
@@ -247,6 +247,7 @@ export const projectKeyedArchive = (
const page = objects.slice(selection.offset, end);
const ids = new Set(page.map(({ id }) => id));
return {
nonFiniteRealCount: projected.nonFiniteRealCount,
unknownRealCount: projected.unknownRealCount,
negativeZeroRealCount: projected.negativeZeroRealCount,
unknownDateCount: projected.unknownDateCount,
+30
View File
@@ -133,3 +133,33 @@ export const createPlistNumberProjection = (literals: PlistNumberLiterals) => {
],
};
};
/** The spellings CFPropertyList writes for reals a JSON number cannot carry. */
export type PlistNonFiniteRealSpelling = "nan" | "+infinity" | "-infinity";
/**
* Read a whole `<real>` text as one of CFPropertyList's non-finite spellings.
* Its parser compares the complete text case-insensitively and also accepts
* `infinity`, `inf`, `+inf` and `-inf`; its writer emits `nan`, `+infinity`
* and `-infinity`. Any other text, including surrounding whitespace, is not
* a spelled non-finite real.
*/
export const parsePlistNonFiniteRealLiteral = (
text: string,
): PlistNonFiniteRealSpelling | undefined => {
if (text.length < 3 || text.length > 9) return undefined;
switch (text.toLowerCase()) {
case "nan":
return "nan";
case "+infinity":
case "infinity":
case "+inf":
case "inf":
return "+infinity";
case "-infinity":
case "-inf":
return "-infinity";
default:
return undefined;
}
};
+28 -1
View File
@@ -1,6 +1,6 @@
import { expect, it } from "vitest";
import { projectPlistValue } from "./plistValue.js";
import { PlistNonFiniteReal, projectPlistValue } from "./plistValue.js";
// Binary decoders build dates from seconds; beyond the Date range they are invalid.
it("projects an unrepresentable date as unknown and counts it", () => {
@@ -11,6 +11,7 @@ it("projects an unrepresentable date as unknown and counts it", () => {
far: { $plist_type: "date", iso8601: null },
near: { $plist_type: "date", iso8601: "1970-01-01T00:00:00.000Z" },
},
nonFiniteRealCount: 0,
unknownRealCount: 0,
negativeZeroRealCount: 0,
unknownDateCount: 1,
@@ -126,3 +127,29 @@ it("keeps retained integer scalars distinct from lookalike dictionaries", () =>
expect(observed[0]).toBe(projected.value[0]);
expect(observed[0]).not.toBe(projected.value[1]);
});
it("projects spelled non-finite reals as typed scalars and keeps unspelled ones unknown", () => {
const observed: object[] = [];
const projected = projectPlistValue(
[
new PlistNonFiniteReal("+infinity"),
new PlistNonFiniteReal("-infinity"),
new PlistNonFiniteReal("nan"),
Number.NEGATIVE_INFINITY,
Number.NaN,
{ $plist_type: "real", value: "nan" },
],
(wrapper) => observed.push(wrapper),
);
expect(projected.nonFiniteRealCount).toBe(3);
expect(projected.unknownRealCount).toBe(2);
expect(projected.negativeZeroRealCount).toBe(0);
expect(JSON.stringify(projected.value)).toBe(
'[{"$plist_type":"real","value":"+infinity"},{"$plist_type":"real","value":"-infinity"},{"$plist_type":"real","value":"nan"},{"$plist_type":"real","value":null},{"$plist_type":"real","value":null},{"$plist_type":"real","value":"nan"}]',
);
expect(Array.isArray(projected.value)).toBe(true);
if (!Array.isArray(projected.value)) throw new Error("Expected array");
// Only generated wrappers are reported; the lookalike stays a dictionary.
expect(observed).toEqual(projected.value.slice(0, 5));
expect(observed).not.toContain(projected.value[5]);
});
+27 -5
View File
@@ -2,6 +2,7 @@ import { types } from "node:util";
import { z } from "zod";
import type { JsonValue } from "../jsonValue.js";
import type { PlistNonFiniteRealSpelling } from "./plistNumbers.js";
/**
* Maximum plist value nesting the JSON projection supports. The XML decode
@@ -10,10 +11,17 @@ import type { JsonValue } from "../jsonValue.js";
*/
export const MAX_PLIST_DEPTH = 128;
/** A decoded non-finite real with its canonical CFPropertyList writer spelling. */
export class PlistNonFiniteReal {
constructor(readonly spelling: PlistNonFiniteRealSpelling) {}
}
/** A decoded plist projected into JSON, with values JSON cannot express. */
export interface ProjectedPlistValue {
readonly value: JsonValue;
/** Reals decoded as NaN, which the decoder also uses for ±infinity. */
/** Non-finite reals decoded with one of CFPropertyList's spellings. */
readonly nonFiniteRealCount: number;
/** Non-finite numbers without a spelling, such as NaN from unreadable text. */
readonly unknownRealCount: number;
/** Negative-zero reals, which a JSON number would report as positive zero. */
readonly negativeZeroRealCount: number;
@@ -25,6 +33,14 @@ export interface ProjectedPlistValue {
export const unknownDateLimitation = (count: number): string =>
`${String(count)} date value(s) are reported as { "$plist_type": "date", "iso8601": null } because the decoder could not represent them, such as XML dates with years beyond 9999 or negative years, or dates outside the JavaScript date range.`;
/** Limitation for results whose plist held spelled non-finite reals. */
export const nonFiniteRealLimitation = (count: number): string =>
`${String(count)} non-finite real value(s) are reported as { "$plist_type": "real", "value": "<spelling>" } with the CFPropertyList spelling nan, +infinity or -infinity because a JSON number cannot carry them.`;
/** Limitation for results whose plist held non-finite numbers without a spelling. */
export const unknownRealLimitation = (count: number): string =>
`${String(count)} non-finite real value(s) are reported as { "$plist_type": "real", "value": null } because the decoder observed a non-finite number without a property-list spelling.`;
/** The typed scalar that keeps a negative-zero real distinct from positive zero. */
export const negativeZeroReal = (): Record<string, JsonValue> => ({
$plist_type: "real",
@@ -36,16 +52,17 @@ export const negativeZeroRealLimitation = (count: number): string =>
`${String(count)} negative-zero real value(s) are reported as { "$plist_type": "real", "value": "-0" } because a JSON number cannot carry the sign of zero.`;
/**
* Project a decoded plist into JSON. Data, dates and retained big integers become explicit
* `$plist_type` objects; a non-finite real becomes an explicit unknown
* because the XML decoder reports NaN and ±infinity alike, and a
* negative-zero real keeps its sign, which JSON serialization would drop.
* Project a decoded plist into JSON. Data, dates, retained big integers and
* spelled non-finite reals become explicit `$plist_type` objects; a non-finite
* number without a spelling becomes an explicit unknown, and a negative-zero
* real keeps its sign, which JSON serialization would drop.
*/
export const projectPlistValue = (
value: unknown,
/** Observe generated wrappers before consumers classify the JSON value tree. */
onWrappedScalar?: (value: Record<string, JsonValue>) => void,
): ProjectedPlistValue => {
let nonFiniteRealCount = 0;
let unknownRealCount = 0;
let negativeZeroRealCount = 0;
let unknownDateCount = 0;
@@ -65,6 +82,10 @@ export const projectPlistValue = (
$plist_type: "data",
base64: Buffer.from(item).toString("base64"),
});
if (item instanceof PlistNonFiniteReal) {
nonFiniteRealCount += 1;
return scalar({ $plist_type: "real", value: item.spelling });
}
if (item instanceof Date) {
if (!Number.isNaN(Date.prototype.getTime.call(item)))
return scalar({
@@ -115,6 +136,7 @@ export const projectPlistValue = (
const projected = project(value, 0);
return {
value: projected,
nonFiniteRealCount,
unknownRealCount,
negativeZeroRealCount,
unknownDateCount,
+3 -2
View File
@@ -7,7 +7,7 @@ import {
type PlistNumberLiterals,
} from "./apple/plistNumbers.js";
import { MAX_PLIST_DEPTH } from "./apple/plistValue.js";
import { MAX_PLIST_DEPTH, PlistNonFiniteReal } from "./apple/plistValue.js";
import { parseXmlDocument } from "./xmlDocument.js";
/** The dictionary key REA results cannot carry: validation strips it. */
@@ -41,7 +41,8 @@ export const omitPrototypeKeys = (
item === null ||
typeof item !== "object" ||
item instanceof Date ||
item instanceof Uint8Array
item instanceof Uint8Array ||
item instanceof PlistNonFiniteReal
)
return item;
return Object.fromEntries(
+48
View File
@@ -1,5 +1,6 @@
import { expect, it } from "vitest";
import { PlistNonFiniteReal } from "./apple/plistValue.js";
import { parseXmlPropertyList } from "./propertyListKeys.js";
it.each([
@@ -83,3 +84,50 @@ it("retains native decoder null and nested-envelope behavior", () => {
).value,
).toEqual([{ value: null }, [null]]);
});
it.each([
["nan", "nan"],
["NaN", "nan"],
["+infinity", "+infinity"],
["infinity", "+infinity"],
["INFINITY", "+infinity"],
["inf", "+infinity"],
["+inf", "+infinity"],
["-infinity", "-infinity"],
["-Infinity", "-infinity"],
["-inf", "-infinity"],
["<![CDATA[-inf]]>", "-infinity"],
] as const)(
"retains the canonical non-finite real value for %s",
(literal, spelling) => {
const parsed = parseXmlPropertyList(
`<plist><real>${literal}</real></plist>`,
);
expect(parsed.value).toBeInstanceOf(PlistNonFiniteReal);
expect(parsed.value).toEqual(new PlistNonFiniteReal(spelling));
expect(parsed.numberLiterals.reals.size).toBe(0);
expect(parsed.numberLiterals.incomplete).toBe(false);
},
);
it("keeps non-finite reals without a property-list spelling as plain numbers", () => {
const parsed = parseXmlPropertyList(
"<plist><array><real>1e999</real><real>-1e999</real><real> nan </real><real>-nan</real><real>infinite</real></array></plist>",
);
expect(parsed.value).toEqual([
Number.POSITIVE_INFINITY,
Number.NEGATIVE_INFINITY,
Number.NaN,
Number.NaN,
Number.NaN,
]);
expect(parsed.numberLiterals.incomplete).toBe(false);
});
it("does not infer a spelled real from incomplete numeric markup", () => {
const parsed = parseXmlPropertyList(
"<plist><real>-inf<!-- unsupported --></real></plist>",
);
expect(parsed.value).toBeNaN();
expect(parsed.numberLiterals.incomplete).toBe(true);
});
+9 -1
View File
@@ -3,7 +3,9 @@ import type { Document, Node } from "@xmldom/xmldom";
import {
createPlistNumberCollector,
parsePlistIntegerLiteral,
parsePlistNonFiniteRealLiteral,
} from "./apple/plistNumbers.js";
import { PlistNonFiniteReal } from "./apple/plistValue.js";
/** Interpret an already validated, depth-bounded DOM, owning values and number facts together. */
export const decodeXmlPropertyList = (document: Document) => {
@@ -111,9 +113,15 @@ export const decodeXmlPropertyList = (document: Document) => {
case "real": {
requireContent(node);
const literal = content(node, characterNode);
if (!literal.complete) numbers.markIncomplete();
// CFPropertyList spells NaN and the infinities; parseFloat would read
// the lowercase spellings as NaN and lose the kind and sign.
const spelling = literal.complete
? parsePlistNonFiniteRealLiteral(literal.text)
: undefined;
if (spelling !== undefined) return new PlistNonFiniteReal(spelling);
const value = Number.parseFloat(literal.text);
if (literal.complete) numbers.real(value);
else numbers.markIncomplete();
return value;
}
case "data": {
+15
View File
@@ -183,3 +183,18 @@ it("reports XML numbers whose integer or real origin is ambiguous", () => {
expect.stringContaining("2 number(s)"),
]);
});
it("keeps CFPropertyList's non-finite real spellings in XML evidence", () => {
const parsed = parsePlistXml(
"<plist><dict><key>Up</key><real>+infinity</real><key>Down</key><real>-INFINITY</real><key>Missing</key><real>nan</real><key>Overflow</key><real>1e999</real><key>Ratio</key><real>1.25</real></dict></plist>",
);
if (!parsed.ok) throw parsed.error;
expect(JSON.stringify(parsed.value.value)).toBe(
'{"Up":{"$plist_type":"real","value":"+infinity"},"Down":{"$plist_type":"real","value":"-infinity"},"Missing":{"$plist_type":"real","value":"nan"},"Overflow":{"$plist_type":"real","value":null},"Ratio":1.25}',
);
expect(parsed.value.limitations).toEqual([
expect.stringContaining("XML conversion"),
'3 non-finite real value(s) are reported as { "$plist_type": "real", "value": "<spelling>" } with the CFPropertyList spelling nan, +infinity or -infinity because a JSON number cannot carry them.',
'1 non-finite real value(s) are reported as { "$plist_type": "real", "value": null } because the decoder observed a non-finite number without a property-list spelling.',
]);
});
+6 -3
View File
@@ -5,8 +5,10 @@ import type { JsonValue } from "../../domain/jsonValue.js";
import {
negativeZeroReal,
negativeZeroRealLimitation,
nonFiniteRealLimitation,
projectPlistValue,
unknownDateLimitation,
unknownRealLimitation,
type ProjectedPlistValue,
} from "../../domain/apple/plistValue.js";
import {
@@ -202,11 +204,12 @@ export const parsePlistXml = (
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.nonFiniteRealCount === 0
? []
: [nonFiniteRealLimitation(projected.nonFiniteRealCount)]),
...(projected.unknownRealCount === 0
? []
: [
`${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.`,
]),
: [unknownRealLimitation(projected.unknownRealCount)]),
...negativeZeroLimitations(projected.negativeZeroRealCount),
...(projected.unknownDateCount === 0
? []
@@ -108,9 +108,11 @@ cliTest.for(["xml", "binary"] as const)(
$plist_type: "date",
iso8601: "2001-01-01T00:00:00.000Z",
});
// plist@5 writes JavaScript's Infinity, which CFPropertyList reads as
// +infinity; the binary double carries no spelling.
expect(result.objects[7]?.value).toEqual({
$plist_type: "real",
value: null,
value: format === "xml" ? "+infinity" : null,
});
expect(result.objects[10]?.value).toEqual({
$plist_type: "real",
@@ -284,3 +286,77 @@ cliTest(
}
},
);
cliTest(
"keeps CFPropertyList's non-finite real spellings in an XML keyed archive",
async ({ cli }) => {
const root = await createTestTempDirectory("rea-keyed-non-finite-");
const archive = {
$archiver: "NSKeyedArchiver",
$version: 100000,
$top: {
up: { CF$UID: 1 },
down: { CF$UID: 2 },
missing: { CF$UID: 3 },
control: { CF$UID: 4 },
},
$objects: ["$null", 1.5, 0.25, 1.75, 1.25],
};
// plist@5 writes its own spellings for non-finite reals, so encode
// placeholder reals and patch them to the text CFPropertyList writes.
let xml = build(archive);
for (const [placeholder, spelling] of [
["1.5", "+infinity"],
["0.25", "-INFINITY"],
["1.75", "nan"],
] as const) {
expect(xml).toContain(`<real>${placeholder}</real>`);
xml = xml.replace(
`<real>${placeholder}</real>`,
`<real>${spelling}</real>`,
);
}
const bytes = Buffer.from(xml);
const path = join(root, "archive.plist");
await writeFile(path, bytes);
const output = await cli.run({
arguments: ["inspect-keyed-archive", path, "--json"],
environment: {
HOME: root,
USERPROFILE: root,
REA_LOG_LEVEL: "silent",
REA_ANALYSIS_PROVIDER: "auto",
},
});
expect(output.exitCode, output.stderr).toBe(0);
const result = keyedArchiveResultSchema.parse(
parseEvidence(output.json).normalized_result,
);
const { call } = await connectLocalToolsMcp({
HOME: root,
USERPROFILE: root,
});
const opened = await call("open_binary", { path });
expect(opened.isError, JSON.stringify(opened)).not.toBe(true);
const mcp = await call("inspect_keyed_archive", {});
expect(mcp.isError, JSON.stringify(mcp)).not.toBe(true);
expect(
keyedArchiveResultSchema.parse(
parseEvidence(mcp.structuredContent).normalized_result,
),
).toEqual(result);
for (const object of result.objects.slice(1)) {
expect(object).toMatchObject({ kind: "scalar", status: "observed" });
}
expect(JSON.stringify(result.objects.map(({ value }) => value))).toBe(
'["$null",{"$plist_type":"real","value":"+infinity"},{"$plist_type":"real","value":"-infinity"},{"$plist_type":"real","value":"nan"},1.25]',
);
expect(result.limitations).toContain(
'3 non-finite real value(s) are reported as { "$plist_type": "real", "value": "<spelling>" } with the CFPropertyList spelling nan, +infinity or -infinity because a JSON number cannot carry them.',
);
expect(result.limitations).not.toContainEqual(
expect.stringContaining('"value": null'),
);
expect(await readFile(path)).toEqual(bytes);
},
);
+39 -3
View File
@@ -1,5 +1,5 @@
import { execFile } from "node:child_process";
import { mkdir, writeFile } from "node:fs/promises";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { promisify } from "node:util";
@@ -81,6 +81,42 @@ describe.skipIf(process.platform !== "darwin")(
describe.skipIf(process.platform !== "darwin")(
"native plist CLI source formats",
() => {
cliTest(
"retains non-finite values through real plutil XML conversion",
async ({ cli }) => {
const directory = await createTestTempDirectory("rea-plist-reals-");
const path = join(directory, "selected.plist");
const source = Buffer.from(`<plist version="1.0"><dict>
<key>positive</key><real>+infinity</real>
<key>negative</key><real>-INFINITY</real>
<key>nan</key><real>nan</real><key>control</key><real>1.25</real>
</dict></plist>`);
await writeFile(path, source);
const result = await cli.run({
arguments: ["inspect-plist", path, "--json"],
environment: {
REA_LOG_LEVEL: "silent",
REA_ANALYSIS_PROVIDER: "auto",
},
});
expect(result.exitCode, result.stderr).toBe(0);
expect(result.json).toMatchObject({
normalized_result: {
value: {
positive: { $plist_type: "real", value: "+infinity" },
negative: { $plist_type: "real", value: "-infinity" },
nan: { $plist_type: "real", value: "nan" },
control: 1.25,
},
limitations: expect.arrayContaining([
expect.stringContaining("3 non-finite real value(s)"),
]),
},
});
expect(await readFile(path)).toEqual(source);
},
);
cliTest.for([
["openstep", "unknown"],
["openstep-data", "unknown"],
@@ -230,11 +266,11 @@ describe.skipIf(process.platform !== "darwin")(
iso8601: "2020-01-01T00:00:00.000Z",
},
Far: { $plist_type: "date", iso8601: null },
Ratio: { $plist_type: "real", value: null },
Ratio: { $plist_type: "real", value: "nan" },
Nested: [
{
Ratio: 0.5,
Unbounded: { $plist_type: "real", value: null },
Unbounded: { $plist_type: "real", value: "-infinity" },
"": "empty key",
},
],