fix(plist): retain known binary non-finite value identity

Make XML numeric uncertainty explicit at its producing boundary, separate
from decoded IEEE NaN and signed infinities. Shared projection preserves
known values using canonical numeric labels; unresolved XML numeric
values remain explicit unknowns. Remove the superseded known-value
wrapper and spelling alias, keeping numeric interpretation with its owner.

Cover public CLI/MCP binary archives, lookalike dictionaries, authored
32/64-bit IEEE payloads, signed zero, and XML parsing/overflow uncertainty.
Document canonical labels and unrepresented NaN payload/sign details.

Fixes #4919. Related: #4906, #4852.

Validation: baseline 2 failures/5 controls through public CLI/MCP; stable
final 439 passed across25files, check:fast, docs:check and discovery.
Native macOS plutil and existing codesign controls executed; no
Linux/Windows run or real Interface Builder engine qualification.
This commit is contained in:
morluto committed 2026-10-18 07:55:51 +08:00
1 parent 69324b0f86
commit f2b7bbb312
9 files changed
+243 -81

No files matched your search

+17 -11
View File
@@ -634,17 +634,23 @@ 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.
Known non-finite reals are reported as
`{ "$plist_type": "real", "value": "nan" | "+infinity" | "-infinity" }`.
These are canonical numeric labels, not a claim about original source spelling.
Binary IEEE reals retain NaN versus signed infinity; NaN payload bits and sign
are not represented in the JSON value, and the original bytes remain the source
for those details. XML CFPropertyList literals (`nan`, `+infinity`, `-infinity`,
`infinity`, `inf`, `+inf`, `-inf`, case-insensitively) decode to the same known
values. Case and aliases normalize; original archive bytes retain source text.
XML numeric text whose identity the decoder cannot establish, including
unrecognized non-finite text and finite literals that overflow the numeric
representation, remains `{ "$plist_type": "real", "value": null }` with a
counted limitation. The XML decoder marks these values explicitly before shared
projection, so they cannot be confused with known binary IEEE values.
Incomplete numeric markup retains its existing compatibility behavior described
above. Known non-finite and unresolved numeric values have separate counts.
Native `inspect_plist` uses these same labels for plutil's XML conversion.
## Locate a byte signature
+7 -7
View File
@@ -1,5 +1,8 @@
import type { JsonValue } from "../jsonValue.js";
/** XML numeric decoding could not establish a representable value identity. */
export class UnknownPlistReal {}
/** Exact decimal or signed hexadecimal XML integer text. */
export const parsePlistIntegerLiteral = (text: string): bigint | undefined => {
const match = /^([+-]?)(?:0[xX]([\da-fA-F]+)|(\d+))$/u.exec(text.trim());
@@ -134,9 +137,6 @@ 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
@@ -146,19 +146,19 @@ export type PlistNonFiniteRealSpelling = "nan" | "+infinity" | "-infinity";
*/
export const parsePlistNonFiniteRealLiteral = (
text: string,
): PlistNonFiniteRealSpelling | undefined => {
): number | undefined => {
if (text.length < 3 || text.length > 9) return undefined;
switch (text.toLowerCase()) {
case "nan":
return "nan";
return Number.NaN;
case "+infinity":
case "infinity":
case "+inf":
case "inf":
return "+infinity";
return Number.POSITIVE_INFINITY;
case "-infinity":
case "-inf":
return "-infinity";
return Number.NEGATIVE_INFINITY;
default:
return undefined;
}
+84 -7
View File
@@ -1,6 +1,8 @@
import { expect, it } from "vitest";
import { parseBinary } from "plist";
import { PlistNonFiniteReal, projectPlistValue } from "./plistValue.js";
import { projectPlistValue } from "./plistValue.js";
import { UnknownPlistReal } from "./plistNumbers.js";
// Binary decoders build dates from seconds; beyond the Date range they are invalid.
it("projects an unrepresentable date as unknown and counts it", () => {
@@ -128,15 +130,15 @@ it("keeps retained integer scalars distinct from lookalike dictionaries", () =>
expect(observed[0]).not.toBe(projected.value[1]);
});
it("projects spelled non-finite reals as typed scalars and keeps unspelled ones unknown", () => {
it("projects known non-finite numbers while retaining explicit XML unknowns", () => {
const observed: object[] = [];
const projected = projectPlistValue(
[
new PlistNonFiniteReal("+infinity"),
new PlistNonFiniteReal("-infinity"),
new PlistNonFiniteReal("nan"),
Number.NEGATIVE_INFINITY,
Number.NaN,
Infinity,
-Infinity,
NaN,
new UnknownPlistReal(),
new UnknownPlistReal(),
{ $plist_type: "real", value: "nan" },
],
(wrapper) => observed.push(wrapper),
@@ -153,3 +155,78 @@ it("projects spelled non-finite reals as typed scalars and keeps unspelled ones
expect(observed).toEqual(projected.value.slice(0, 5));
expect(observed).not.toContain(projected.value[5]);
});
it.each([
[
"float32",
[
"7f800000",
"ff800000",
"7fc00001",
"ffc00002",
"80000000",
"00000000",
"3fa00000",
],
],
[
"float64",
[
"7ff0000000000000",
"fff0000000000000",
"7ff8000000000001",
"fff8000000000002",
"8000000000000000",
"0000000000000000",
"3ff4000000000000",
],
],
] as const)("retains independently authored %s IEEE values", (_, payloads) => {
// An array object followed by seven real objects; no plist builder oracle.
const start = 8 + 1 + payloads.length;
const width = payloads[0].length / 2;
const offsets = [
8,
...payloads.map((_, index) => start + index * (width + 1)),
];
const table = start + payloads.length * (width + 1);
const trailer = Buffer.alloc(32);
trailer[6] = 1;
trailer[7] = 1;
trailer.writeBigUInt64BE(BigInt(offsets.length), 8);
trailer.writeBigUInt64BE(BigInt(table), 24);
const bytes = Buffer.concat([
Buffer.from("bplist00"),
Buffer.from([
0xa0 | payloads.length,
...payloads.map((_, index) => index + 1),
]),
...payloads.map((hex) =>
Buffer.concat([
Buffer.from([width === 4 ? 0x22 : 0x23]),
Buffer.from(hex, "hex"),
]),
),
Buffer.from(offsets),
trailer,
]);
const original = Buffer.from(bytes);
const observed: object[] = [];
const projected = projectPlistValue(parseBinary(bytes), (value) =>
observed.push(value),
);
expect(projected.value).toEqual([
{ $plist_type: "real", value: "+infinity" },
{ $plist_type: "real", value: "-infinity" },
{ $plist_type: "real", value: "nan" },
{ $plist_type: "real", value: "nan" },
{ $plist_type: "real", value: "-0" },
0,
1.25,
]);
expect(projected.nonFiniteRealCount).toBe(4);
expect(projected.negativeZeroRealCount).toBe(1);
expect(projected.unknownRealCount).toBe(0);
expect(observed).toHaveLength(5);
expect(bytes).toEqual(original);
});
+19 -19
View File
@@ -2,7 +2,7 @@ import { types } from "node:util";
import { z } from "zod";
import type { JsonValue } from "../jsonValue.js";
import type { PlistNonFiniteRealSpelling } from "./plistNumbers.js";
import { UnknownPlistReal } from "./plistNumbers.js";
/**
* Maximum plist value nesting the JSON projection supports. The XML decode
@@ -11,17 +11,12 @@ import type { PlistNonFiniteRealSpelling } from "./plistNumbers.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;
/** Non-finite reals decoded with one of CFPropertyList's spellings. */
/** Known non-finite values, rendered with canonical numeric labels. */
readonly nonFiniteRealCount: number;
/** Non-finite numbers without a spelling, such as NaN from unreadable text. */
/** XML numeric values whose identity could not be established. */
readonly unknownRealCount: number;
/** Negative-zero reals, which a JSON number would report as positive zero. */
readonly negativeZeroRealCount: number;
@@ -33,13 +28,13 @@ 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. */
/** Limitation for known non-finite values; the labels do not imply XML provenance. */
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.`;
`${String(count)} non-finite real value(s) are reported as { "$plist_type": "real", "value": "<spelling>" } using canonical numeric labels nan, +infinity or -infinity because a JSON number cannot carry them.`;
/** Limitation for results whose plist held non-finite numbers without a spelling. */
/** Limitation for XML numeric values the decoder could not establish. */
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.`;
`${String(count)} numeric value(s) are reported as { "$plist_type": "real", "value": null } because XML numeric decoding could not establish their value identity.`;
/** The typed scalar that keeps a negative-zero real distinct from positive zero. */
export const negativeZeroReal = (): Record<string, JsonValue> => ({
@@ -53,8 +48,8 @@ export const negativeZeroRealLimitation = (count: number): string =>
/**
* 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
* known non-finite reals become explicit `$plist_type` objects. Unresolved XML
* numeric values remain explicit unknowns, and a negative-zero
* real keeps its sign, which JSON serialization would drop.
*/
export const projectPlistValue = (
@@ -82,9 +77,9 @@ 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 UnknownPlistReal) {
unknownRealCount += 1;
return scalar({ $plist_type: "real", value: null });
}
if (item instanceof Date) {
if (!Number.isNaN(Date.prototype.getTime.call(item)))
@@ -96,8 +91,13 @@ export const projectPlistValue = (
return scalar({ $plist_type: "date", iso8601: null });
}
if (typeof item === "number" && !Number.isFinite(item)) {
unknownRealCount += 1;
return scalar({ $plist_type: "real", value: null });
nonFiniteRealCount += 1;
const label = Number.isNaN(item)
? "nan"
: item > 0
? "+infinity"
: "-infinity";
return scalar({ $plist_type: "real", value: label });
}
// Integer decoders never yield -0, so a signed zero here is a real.
if (typeof item === "number" && Object.is(item, -0)) {
+3 -2
View File
@@ -3,11 +3,12 @@ import { parseOpenStep } from "plist";
import { decodeXmlPropertyList } from "./propertyListXml.js";
import {
UnknownPlistReal,
createPlistNumberCollector,
type PlistNumberLiterals,
} from "./apple/plistNumbers.js";
import { MAX_PLIST_DEPTH, PlistNonFiniteReal } from "./apple/plistValue.js";
import { MAX_PLIST_DEPTH } from "./apple/plistValue.js";
import { parseXmlDocument } from "./xmlDocument.js";
/** The dictionary key REA results cannot carry: validation strips it. */
@@ -42,7 +43,7 @@ export const omitPrototypeKeys = (
typeof item !== "object" ||
item instanceof Date ||
item instanceof Uint8Array ||
item instanceof PlistNonFiniteReal
item instanceof UnknownPlistReal
)
return item;
return Object.fromEntries(
+37 -25
View File
@@ -1,6 +1,7 @@
import { expect, it } from "vitest";
import { PlistNonFiniteReal } from "./apple/plistValue.js";
import { projectPlistValue } from "./apple/plistValue.js";
import { UnknownPlistReal } from "./apple/plistNumbers.js";
import { parseXmlPropertyList } from "./propertyListKeys.js";
it.each([
@@ -86,41 +87,34 @@ it("retains native decoder null and nested-envelope behavior", () => {
});
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"],
["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) => {
(literal, expected) => {
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.value).toBe(expected);
expect(parsed.numberLiterals.reals).toEqual(new Set([expected]));
expect(parsed.numberLiterals.incomplete).toBe(false);
},
);
it("keeps non-finite reals without a property-list spelling as plain numbers", () => {
it("keeps XML numeric overflow and unreadable text explicitly unknown", () => {
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.value).toEqual(Array.from({ length: 5 }, () => new UnknownPlistReal()));
expect(parsed.numberLiterals.incomplete).toBe(false);
});
@@ -128,6 +122,24 @@ 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.value).toBeInstanceOf(UnknownPlistReal);
expect(parsed.numberLiterals.incomplete).toBe(true);
});
it.each([
["integer", "unreadable"],
["integer", `${"9".repeat(400)}invalid`],
["real", "1e999"],
["real", "-1e999"],
["real", "Infinitysuffix"],
["real", "nan<!-- unsupported -->"],
])("keeps unresolved <%s> numeric identity explicit", (tag, text) => {
const parsed = parseXmlPropertyList(
`<plist><${tag}>${text}</${tag}></plist>`,
);
expect(parsed.value).toBeInstanceOf(UnknownPlistReal);
const projected = projectPlistValue(parsed.value);
expect(projected.value).toEqual({ $plist_type: "real", value: null });
expect(projected.unknownRealCount).toBe(1);
expect(projected.nonFiniteRealCount).toBe(0);
});
+12 -4
View File
@@ -1,11 +1,11 @@
import type { Document, Node } from "@xmldom/xmldom";
import {
UnknownPlistReal,
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) => {
@@ -102,7 +102,11 @@ export const decodeXmlPropertyList = (document: Document) => {
numbers.markIncomplete();
const partial = Number.parseInt(first.nodeValue ?? "", 10);
// An integer has no signed zero; keep -0 from reading as a real.
return partial === 0 ? 0 : partial;
return !Number.isFinite(partial)
? new UnknownPlistReal()
: partial === 0
? 0
: partial;
}
numbers.integer(exact);
const decoded = Number(exact);
@@ -116,11 +120,15 @@ export const decodeXmlPropertyList = (document: Document) => {
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
const known = literal.complete
? parsePlistNonFiniteRealLiteral(literal.text)
: undefined;
if (spelling !== undefined) return new PlistNonFiniteReal(spelling);
if (known !== undefined) {
numbers.real(known);
return known;
}
const value = Number.parseFloat(literal.text);
if (!Number.isFinite(value)) return new UnknownPlistReal();
if (literal.complete) numbers.real(value);
return value;
}
+2 -2
View File
@@ -194,7 +194,7 @@ it("keeps CFPropertyList's non-finite real spellings in XML evidence", () => {
);
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.',
'3 non-finite real value(s) are reported as { "$plist_type": "real", "value": "<spelling>" } using canonical numeric labels nan, +infinity or -infinity because a JSON number cannot carry them.',
'1 numeric value(s) are reported as { "$plist_type": "real", "value": null } because XML numeric decoding could not establish their value identity.',
]);
});
@@ -108,11 +108,10 @@ 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.
// XML and binary decoding preserve the same non-finite value identity.
expect(result.objects[7]?.value).toEqual({
$plist_type: "real",
value: format === "xml" ? "+infinity" : null,
value: "+infinity",
});
expect(result.objects[10]?.value).toEqual({
$plist_type: "real",
@@ -287,6 +286,65 @@ cliTest(
},
);
cliTest("preserves binary IEEE non-finite scalar identity", async ({ cli }) => {
const root = await createTestTempDirectory("rea-binary-nonfinite-");
const path = join(root, "archive.plist");
const bytes = Buffer.from(
buildBinary({
$archiver: "NSKeyedArchiver",
$version: 100000,
$top: {
positive: { CF$UID: 1 },
negative: { CF$UID: 2 },
nan: { CF$UID: 3 },
},
$objects: [
"$null",
Infinity,
-Infinity,
NaN,
{ $plist_type: "real", value: "nan" },
1.25,
],
}),
);
await writeFile(path, bytes);
const output = await cli.run({
arguments: ["inspect-keyed-archive", path, "--json"],
environment: { HOME: root, USERPROFILE: root, REA_LOG_LEVEL: "silent" },
});
expect(output.exitCode, output.stderr).toBe(0);
const result = keyedArchiveResultSchema.parse(
parseEvidence(output.json).normalized_result,
);
expect(
result.objects.slice(1).map(({ kind, value }) => ({ kind, value })),
).toEqual([
{ kind: "scalar", value: { $plist_type: "real", value: "+infinity" } },
{ kind: "scalar", value: { $plist_type: "real", value: "-infinity" } },
{ kind: "scalar", value: { $plist_type: "real", value: "nan" } },
{ kind: "dictionary", value: { $plist_type: "real", value: "nan" } },
{ kind: "scalar", value: 1.25 },
]);
expect(result.archive_sha256).toBe(
createHash("sha256").update(bytes).digest("hex"),
);
const { call } = await connectLocalToolsMcp({
HOME: root,
USERPROFILE: root,
});
const opened = await call("open_binary", { path });
expect(opened.isError).not.toBe(true);
const mcp = await call("inspect_keyed_archive", {});
expect(mcp.isError).not.toBe(true);
expect(
keyedArchiveResultSchema.parse(
parseEvidence(mcp.structuredContent).normalized_result,
),
).toEqual(result);
expect(await readFile(path)).toEqual(bytes);
});
cliTest(
"keeps CFPropertyList's non-finite real spellings in an XML keyed archive",
async ({ cli }) => {
@@ -352,7 +410,7 @@ cliTest(
'["$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.',
'3 non-finite real value(s) are reported as { "$plist_type": "real", "value": "<spelling>" } using canonical numeric labels nan, +infinity or -infinity because a JSON number cannot carry them.',
);
expect(result.limitations).not.toContainEqual(
expect.stringContaining('"value": null'),