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"), + ], + }, + }); + }, + ); }, );