fix(native): inspect plists containing data, dates, and non-finite reals

`inspect_plist` converted every plist with `plutil -convert json`, which
rejects plists containing `<data>`, `<date>`, or non-finite `<real>`
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 <noreply@anthropic.com>
This commit is contained in:
Iwakurakaoru0822andClaude Opus 5.5 committed 2026-10-06 20:54:23 -07:00
1 parent be3cfbffdf
commit 5ead2cfbc4
6 files changed
+260 -56

No files matched your search

+6 -21
View File
@@ -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. */
+40
View File
@@ -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);
});
+50
View File
@@ -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 };
};
+29 -11
View File
@@ -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 }],
});
}
+67 -24
View File
@@ -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<ParsedPlist, AnalysisOutputError> => {
// 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<ParsedPlist, AnalysisOutputError> => {
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"),
};
};
+68
View File
@@ -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,
[
"<plist><dict>",
"<key>CFBundleExecutable</key><string>App</string>",
"<key>CFBundleIdentifier</key><string>com.example.types</string>",
"<key>Blob</key><data>AAEC</data>",
"<key>Built</key><date>2020-01-01T00:00:00Z</date>",
"<key>Ratio</key><real>nan</real>",
"</dict></plist>",
].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"),
],
},
});
},
);
},
);