diff --git a/docs/native-investigation.md b/docs/native-investigation.md index dd749f80e5..fed3529bf5 100644 --- a/docs/native-investigation.md +++ b/docs/native-investigation.md @@ -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 `-0` literal, remain the number `0`. +An XML `` 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 diff --git a/src/artifacts/apple/KeyedArchiveReader.ts b/src/artifacts/apple/KeyedArchiveReader.ts index 71fd628f2b..79bb6ecc58 100644 --- a/src/artifacts/apple/KeyedArchiveReader.ts +++ b/src/artifacts/apple/KeyedArchiveReader.ts @@ -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)]), diff --git a/src/domain/apple/keyedArchive.ts b/src/domain/apple/keyedArchive.ts index 24c56db62a..5ab26c85fb 100644 --- a/src/domain/apple/keyedArchive.ts +++ b/src/domain/apple/keyedArchive.ts @@ -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, diff --git a/src/domain/apple/plistNumbers.ts b/src/domain/apple/plistNumbers.ts index 04ca01af29..49a681b911 100644 --- a/src/domain/apple/plistNumbers.ts +++ b/src/domain/apple/plistNumbers.ts @@ -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 `` 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; + } +}; diff --git a/src/domain/apple/plistValue.test.ts b/src/domain/apple/plistValue.test.ts index fae48252b1..d34175a167 100644 --- a/src/domain/apple/plistValue.test.ts +++ b/src/domain/apple/plistValue.test.ts @@ -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]); +}); diff --git a/src/domain/apple/plistValue.ts b/src/domain/apple/plistValue.ts index 28ebe08a1d..392810cac0 100644 --- a/src/domain/apple/plistValue.ts +++ b/src/domain/apple/plistValue.ts @@ -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": "" } 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 => ({ $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) => 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, diff --git a/src/domain/propertyListKeys.ts b/src/domain/propertyListKeys.ts index 24804ea8a3..b3379e6115 100644 --- a/src/domain/propertyListKeys.ts +++ b/src/domain/propertyListKeys.ts @@ -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( diff --git a/src/domain/propertyListXml.test.ts b/src/domain/propertyListXml.test.ts index 155178a12a..c88ed337de 100644 --- a/src/domain/propertyListXml.test.ts +++ b/src/domain/propertyListXml.test.ts @@ -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"], + ["", "-infinity"], +] as const)( + "retains the canonical non-finite real value for %s", + (literal, spelling) => { + const parsed = parseXmlPropertyList( + `${literal}`, + ); + 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( + "1e999-1e999 nan -naninfinite", + ); + 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( + "-inf", + ); + expect(parsed.value).toBeNaN(); + expect(parsed.numberLiterals.incomplete).toBe(true); +}); diff --git a/src/domain/propertyListXml.ts b/src/domain/propertyListXml.ts index 558d407d4c..604911f544 100644 --- a/src/domain/propertyListXml.ts +++ b/src/domain/propertyListXml.ts @@ -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": { diff --git a/src/native/parsers/plist.test.ts b/src/native/parsers/plist.test.ts index 7459f9c767..f570d262c2 100644 --- a/src/native/parsers/plist.test.ts +++ b/src/native/parsers/plist.test.ts @@ -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( + "Up+infinityDown-INFINITYMissingnanOverflow1e999Ratio1.25", + ); + 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": "" } 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.', + ]); +}); diff --git a/src/native/parsers/plist.ts b/src/native/parsers/plist.ts index 5c79f6ade7..0b4fddea4d 100644 --- a/src/native/parsers/plist.ts +++ b/src/native/parsers/plist.ts @@ -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 ? [] diff --git a/tests/acceptance/applications/keyedArchiveScalarKinds.test.ts b/tests/acceptance/applications/keyedArchiveScalarKinds.test.ts index e709663f66..014455d749 100644 --- a/tests/acceptance/applications/keyedArchiveScalarKinds.test.ts +++ b/tests/acceptance/applications/keyedArchiveScalarKinds.test.ts @@ -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(`${placeholder}`); + xml = xml.replace( + `${placeholder}`, + `${spelling}`, + ); + } + 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": "" } 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); + }, +); diff --git a/tests/boundary/cli/nativePlistCli.test.ts b/tests/boundary/cli/nativePlistCli.test.ts index d63fdbffd7..88952029a4 100644 --- a/tests/boundary/cli/nativePlistCli.test.ts +++ b/tests/boundary/cli/nativePlistCli.test.ts @@ -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(` +positive+infinity +negative-INFINITY +nannancontrol1.25 +`); + 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", }, ],