Merge pull request #841 from morluto/fix/plist-non-json-values
fix(native): inspect plists containing data, dates, and non-finite reals
This commit is contained in:
commit
bc2cd8b874
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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 };
|
||||
};
|
||||
|
|
@ -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 }],
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
],
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in New Issue