From 5ead2cfbc47896b43502363d3568330f5d94762e Mon Sep 17 00:00:00 2001 From: Iwakurakaoru0822 Date: Tue, 6 Oct 2026 20:54:23 -0700 Subject: [PATCH] fix(native): inspect plists containing data, dates, and non-finite reals `inspect_plist` converted every plist with `plutil -convert json`, which rejects plists containing ``, ``, or non-finite `` values. Such valid plists (including Stocks.app's Info.plist) failed with a cause-free `execution_failure`. When the JSON conversion exits nonzero, decode `plutil -convert xml1` instead. Data and dates use the `$plist_type` objects the keyed-archive reader already emits; non-finite reals become an explicit unknown because the XML decoder reports NaN and infinity alike. Provenance records both conversions and limitations explain the representation. Plists that JSON can express keep the unchanged single-conversion path. The projection moves to `src/domain/plistValue.ts` and is shared with the keyed-archive reader, which keeps rejecting non-finite reals. Co-Authored-By: Claude Opus 5.5 --- src/artifacts/KeyedArchiveReader.ts | 27 ++----- src/domain/plistValue.test.ts | 40 ++++++++++ src/domain/plistValue.ts | 50 +++++++++++++ src/native/NativeMacOSProvider.ts | 40 +++++++--- src/native/parsers/plist.ts | 91 +++++++++++++++++------ tests/boundary/cli/nativePlistCli.test.ts | 68 +++++++++++++++++ 6 files changed, 260 insertions(+), 56 deletions(-) create mode 100644 src/domain/plistValue.test.ts create mode 100644 src/domain/plistValue.ts diff --git a/src/artifacts/KeyedArchiveReader.ts b/src/artifacts/KeyedArchiveReader.ts index 2deaf83fb..e85f71960 100644 --- a/src/artifacts/KeyedArchiveReader.ts +++ b/src/artifacts/KeyedArchiveReader.ts @@ -3,6 +3,7 @@ import { TextDecoder } from "node:util"; import { parse, parseBinary } from "plist"; import { z } from "zod"; import type { JsonValue } from "../domain/jsonValue.js"; +import { projectPlistValue } from "../domain/plistValue.js"; import { keyedArchiveInputSchema, keyedArchiveResultSchema, @@ -50,27 +51,11 @@ const decodeXmlPlistText = (bytes: Buffer): string => { return text; }; -const normalizePlist = (value: unknown, depth = 0): JsonValue => { - if (depth > 128) throw new RangeError("Plist exceeds 128 nesting levels"); - if (value instanceof Uint8Array) - return { - $plist_type: "data", - base64: Buffer.from(value).toString("base64"), - }; - if (value instanceof Date) - return { $plist_type: "date", iso8601: value.toISOString() }; - if (Array.isArray(value)) - return value.map((item: unknown) => normalizePlist(item, depth + 1)); - if (value !== null && typeof value === "object") - return Object.fromEntries( - Object.entries(value).map(([key, item]) => [ - key, - normalizePlist(item, depth + 1), - ]), - ); - return z - .union([z.string(), z.number().finite(), z.boolean(), z.null()]) - .parse(value); +const normalizePlist = (value: unknown): JsonValue => { + const projected = projectPlistValue(value); + if (projected.unknownRealCount > 0) + throw new RangeError("Keyed archive contains a non-finite real"); + return projected.value; }; /** Read exactly one regular, contained bundle entry without following symlinks. */ diff --git a/src/domain/plistValue.test.ts b/src/domain/plistValue.test.ts new file mode 100644 index 000000000..1b547d56f --- /dev/null +++ b/src/domain/plistValue.test.ts @@ -0,0 +1,40 @@ +import { expect, it } from "vitest"; + +import { projectPlistValue } from "./plistValue.js"; + +it("projects plist data, dates, and non-finite reals as typed values", () => { + expect( + projectPlistValue({ + blob: new Uint8Array([0, 1, 2]), + when: new Date("2020-01-01T00:00:00Z"), + nested: [{ ratio: 0.5, missing: Number.NaN, unbounded: -Infinity }], + plain: { "": "empty key", flag: true, count: 42 }, + }), + ).toEqual({ + value: { + blob: { $plist_type: "data", base64: "AAEC" }, + when: { $plist_type: "date", iso8601: "2020-01-01T00:00:00.000Z" }, + nested: [ + { + ratio: 0.5, + missing: { $plist_type: "real", value: null }, + unbounded: { $plist_type: "real", value: null }, + }, + ], + plain: { "": "empty key", flag: true, count: 42 }, + }, + unknownRealCount: 2, + }); +}); + +it("rejects values a plist decoder cannot produce", () => { + expect(() => projectPlistValue({ value: Symbol("x") })).toThrow(); + const deep: unknown[] = []; + let cursor = deep; + for (let depth = 0; depth < 200; depth += 1) { + const next: unknown[] = []; + cursor.push(next); + cursor = next; + } + expect(() => projectPlistValue(deep)).toThrow(RangeError); +}); diff --git a/src/domain/plistValue.ts b/src/domain/plistValue.ts new file mode 100644 index 000000000..6c427a8ad --- /dev/null +++ b/src/domain/plistValue.ts @@ -0,0 +1,50 @@ +import { z } from "zod"; + +import type { JsonValue } from "./jsonValue.js"; + +const MAX_PLIST_DEPTH = 128; + +/** 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. */ + readonly unknownRealCount: number; +} + +/** + * Project a decoded plist into JSON. Data and dates become explicit + * `$plist_type` objects; a non-finite real becomes an explicit unknown + * because the XML decoder reports NaN and ±infinity alike. + */ +export const projectPlistValue = (value: unknown): ProjectedPlistValue => { + let unknownRealCount = 0; + const project = (item: unknown, depth: number): JsonValue => { + if (depth > MAX_PLIST_DEPTH) + throw new RangeError( + `Plist exceeds ${String(MAX_PLIST_DEPTH)} nesting levels`, + ); + if (item instanceof Uint8Array) + return { + $plist_type: "data", + base64: Buffer.from(item).toString("base64"), + }; + if (item instanceof Date) + return { $plist_type: "date", iso8601: item.toISOString() }; + if (typeof item === "number" && !Number.isFinite(item)) { + unknownRealCount += 1; + return { $plist_type: "real", value: null }; + } + if (Array.isArray(item)) + return item.map((entry: unknown) => project(entry, depth + 1)); + if (item !== null && typeof item === "object") + return Object.fromEntries( + Object.entries(item).map(([key, entry]) => [ + key, + project(entry, depth + 1), + ]), + ); + return z.union([z.string(), z.number(), z.boolean(), z.null()]).parse(item); + }; + const projected = project(value, 0); + return { value: projected, unknownRealCount }; +}; diff --git a/src/native/NativeMacOSProvider.ts b/src/native/NativeMacOSProvider.ts index f5c29c8fe..dc76debfe 100644 --- a/src/native/NativeMacOSProvider.ts +++ b/src/native/NativeMacOSProvider.ts @@ -45,7 +45,7 @@ import { import { parseCodeSignature } from "./parsers/codesign.js"; import { parseDemangledSymbols } from "./parsers/demangle.js"; import { parseLipoArchitectures } from "./parsers/lipo.js"; -import { parsePlistJson } from "./parsers/plist.js"; +import { parsePlistJson, parsePlistXml } from "./parsers/plist.js"; import { architectureLocations, inspectNativeMacho, @@ -460,33 +460,51 @@ class NativeMacOSClient implements AnalysisClient { { signal }, ); if (!classified.ok) return classified; - const capture = await this.#run( + const json = await this.#run( "inspect_plist", "plutil", ["-convert", "json", "-o", "-", "--", plist.value], - { signal }, + { signal, acceptNonZero: true }, ); - if (!capture.ok) return capture; - const parsed = parsePlistJson(capture.value.stdout); + 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. + const xml = + json.value.exitCode === 0 + ? undefined + : await this.#run( + "inspect_plist", + "plutil", + ["-convert", "xml1", "-o", "-", "--", plist.value], + { signal }, + ); + if (xml !== undefined && !xml.ok) return xml; + const parsed = + xml === undefined + ? parsePlistJson(json.value.stdout) + : parsePlistXml(xml.value.stdout); if (!parsed.ok) return parsed; - const provenance = [classified.value, capture.value].map((item) => - invocation(item, plist.value, "$PLIST"), - ); + const provenance = [ + classified.value, + json.value, + ...(xml === undefined ? [] : [xml.value]), + ].map((item) => invocation(item, plist.value, "$PLIST")); const result = inspectPlistSchema.parse({ format: /binary property list/iu.test(classified.value.stdout) ? "binary" : /XML|text/iu.test(classified.value.stdout) ? "xml" : "unknown", - ...parsed.value, + value: parsed.value.value, + bundle: parsed.value.bundle, source_path: plist.value, provenance, - limitations: [], + limitations: parsed.value.limitations, }); return ok({ result: jsonValueSchema.parse(result), provenance, - limitations: [], + limitations: parsed.value.limitations, locations: [{ kind: "artifact-path", path: plist.value }], }); } diff --git a/src/native/parsers/plist.ts b/src/native/parsers/plist.ts index 65f4748e2..e5c4d982a 100644 --- a/src/native/parsers/plist.ts +++ b/src/native/parsers/plist.ts @@ -1,29 +1,38 @@ +import { parse as parseXmlPlist } from "plist"; import { z } from "zod"; import { AnalysisOutputError } from "../../domain/analysisErrorCore.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()); +/** Stable bundle metadata projected from one plist value. */ +export interface PlistBundleMetadata { + readonly identifier: string | null; + readonly executable: string | null; + readonly name: string | null; + readonly version: string | null; + readonly short_version: string | null; +} + +/** One decoded plist with bundle metadata and decoding limitations. */ +export interface ParsedPlist { + readonly value: unknown; + readonly bundle: PlistBundleMetadata; + readonly limitations: readonly string[]; +} + /** Parse plutil JSON output and project stable bundle metadata. */ export const parsePlistJson = ( output: string, -): Result< - { - readonly value: unknown; - readonly bundle: { - readonly identifier: string | null; - readonly executable: string | null; - readonly name: string | null; - readonly version: string | null; - readonly short_version: string | null; - }; - }, - AnalysisOutputError -> => { +): Result => { // plutil output can carry a UTF-8 BOM, which JSON.parse rejects outright. - const parsed = safeParseJson(output.replace(/^\uFEFF/u, "")); + const parsed = safeParseJson(output.replace(/^/u, "")); if (!parsed.ok) return err( new AnalysisOutputError("inspect_plist", parsed.error, { @@ -31,20 +40,54 @@ export const parsePlistJson = ( }), ); const value: unknown = parsed.value; + return ok({ value, bundle: projectPlistBundle(value), limitations: [] }); +}; + +/** + * Parse plutil XML output for a plist that JSON cannot express, such as one + * containing data, dates, or non-finite reals. + */ +export const parsePlistXml = ( + output: string, +): Result => { + let projected: ProjectedPlistValue; + try { + projected = projectPlistValue(parseXmlPlist(output)); + } catch (cause: unknown) { + return err( + new AnalysisOutputError( + "inspect_plist", + "plutil XML conversion was not a readable plist", + { cause }, + ), + ); + } + return ok({ + value: projected.value, + bundle: projectPlistBundle(projected.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 + ? [] + : [ + `${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.`, + ]), + ], + }); +}; + +const projectPlistBundle = (value: unknown): PlistBundleMetadata => { const object = plistObject.safeParse(value); const field = (name: string): string | null => { if (!object.success) return null; const candidate = object.data[name]; return typeof candidate === "string" ? candidate : null; }; - return ok({ - value, - bundle: { - identifier: field("CFBundleIdentifier"), - executable: field("CFBundleExecutable"), - name: field("CFBundleName"), - version: field("CFBundleVersion"), - short_version: field("CFBundleShortVersionString"), - }, - }); + return { + identifier: field("CFBundleIdentifier"), + executable: field("CFBundleExecutable"), + name: field("CFBundleName"), + version: field("CFBundleVersion"), + short_version: field("CFBundleShortVersionString"), + }; }; diff --git a/tests/boundary/cli/nativePlistCli.test.ts b/tests/boundary/cli/nativePlistCli.test.ts index eca99cac5..bf13f7877 100644 --- a/tests/boundary/cli/nativePlistCli.test.ts +++ b/tests/boundary/cli/nativePlistCli.test.ts @@ -1,5 +1,7 @@ +import { execFile } from "node:child_process"; import { mkdir, writeFile } from "node:fs/promises"; import { join } from "node:path"; +import { promisify } from "node:util"; import { describe, expect } from "vitest"; @@ -60,5 +62,71 @@ describe.skipIf(process.platform !== "darwin")( } }, ); + + cliTest.for(["xml", "binary"] as const)( + "decodes $0 plists that plutil cannot express as JSON", + async (format, { cli }) => { + const directory = await createTestTempDirectory("rea-plist-types-"); + 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), + ); + const plist = join(contents, "Info.plist"); + await writeFile( + plist, + [ + "", + "CFBundleExecutableApp", + "CFBundleIdentifiercom.example.types", + "BlobAAEC", + "Built2020-01-01T00:00:00Z", + "Rationan", + "", + ].join(""), + ); + if (format === "binary") + await promisify(execFile)("/usr/bin/plutil", [ + "-convert", + "binary1", + "--", + plist, + ]); + 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: { + format, + value: { + CFBundleIdentifier: "com.example.types", + Blob: { $plist_type: "data", base64: "AAEC" }, + Built: { + $plist_type: "date", + iso8601: "2020-01-01T00:00:00.000Z", + }, + Ratio: { $plist_type: "real", value: null }, + }, + bundle: { identifier: "com.example.types", executable: "App" }, + provenance: [ + { tool: "file" }, + { command: expect.arrayContaining(["json"]), exit: { code: 1 } }, + { command: expect.arrayContaining(["xml1"]), exit: { code: 0 } }, + ], + limitations: [ + expect.stringContaining("XML conversion"), + expect.stringContaining("1 non-finite real"), + ], + }, + }); + }, + ); }, );