refactor(native): retain one plist JSON parse owner

Keep the JSON parse attempt and exact numeric source literals together through optional XML classification. Replace the probing parse and raw-string parser with readPlistJson, preserving duplicate-key behavior, signed zero, ambiguous number types, prototype omissions and failure precedence.

Project fresh results from private facts and preserve native command controls, captures and provenance. Verify parser reuse and real provider-boundary conversion choices, plus compiled CLI and MCP behavior with real plutil.
This commit is contained in:
morluto
2026-10-24 08:13:04 +08:00
parent 76500f12b5
commit bf6e49a8f4
5 changed files with 508 additions and 134 deletions
+42 -38
View File
@@ -112,10 +112,9 @@ import { parseCodeSignature } from "./parsers/codesign.js";
import { parseDemangledSymbols } from "./parsers/demangle.js";
import { parseLipoCapture } from "./parsers/lipo.js";
import {
parsePlistJson,
readPlistJson,
parsePlistBinary,
parsePlistXml,
plistJsonNeedsNumberTypes,
type ParsedPlist,
} from "./parsers/plist.js";
import {
@@ -1081,49 +1080,54 @@ class NativeMacOSClient implements AnalysisClient {
// such plists, so decode its XML conversion instead. JSON also prints an
// integral <real> like an <integer>, so XML identifies unsafe number types.
const jsonDecoded = json.value.exitCode === 0;
const xml =
jsonDecoded && !plistJsonNeedsNumberTypes(json.value.stdout)
? undefined
: await this.#run(
"inspect_plist",
"plutil",
["-convert", "xml1", "-o", "-", "--", path],
{ signal, acceptNonZero: !jsonDecoded, timeoutMs },
);
if (xml !== undefined && !xml.ok) return xml;
if (xml !== undefined && xml.value.exitCode !== 0) {
const capture = failureCapture(xml.value, false);
return err(
unreadablePlist(
fileInput.selection,
`plutil could not decode it as a property list (${plutilDiagnostic(capture, path)})`,
{
capturedOutput: {
stdout: capture.stdout,
stderr: capture.stderr,
truncated: capture.truncated,
stdout_bytes: capture.stdoutBytes,
stderr_bytes: capture.stderrBytes,
exit_code: capture.exitCode,
signal: capture.signal,
},
},
),
const jsonAttempt = jsonDecoded
? readPlistJson(json.value.stdout)
: undefined;
let xml: NativeCommandCapture | undefined;
let parsed: Result<ParsedPlist, AnalysisOutputError>;
if (jsonAttempt === undefined || jsonAttempt.needsNumberTypes) {
const converted = await this.#run(
"inspect_plist",
"plutil",
["-convert", "xml1", "-o", "-", "--", path],
{ signal, acceptNonZero: !jsonDecoded, timeoutMs },
);
if (!converted.ok) return converted;
xml = converted.value;
if (xml.exitCode !== 0) {
const capture = failureCapture(xml, false);
return err(
unreadablePlist(
fileInput.selection,
`plutil could not decode it as a property list (${plutilDiagnostic(capture, path)})`,
{
capturedOutput: {
stdout: capture.stdout,
stderr: capture.stderr,
truncated: capture.truncated,
stdout_bytes: capture.stdoutBytes,
stderr_bytes: capture.stderrBytes,
exit_code: capture.exitCode,
signal: capture.signal,
},
},
),
);
}
parsed =
jsonAttempt === undefined
? parsePlistXml(xml.stdout)
: jsonAttempt.project(xml.stdout);
} else {
parsed = jsonAttempt.project();
}
const parsed =
xml === undefined
? parsePlistJson(json.value.stdout)
: jsonDecoded
? parsePlistJson(json.value.stdout, xml.value.stdout)
: parsePlistXml(xml.value.stdout);
if (!parsed.ok) return parsed;
const provenance = [
classified,
json.value,
...(xml === undefined ? [] : [xml.value]),
...(xml === undefined ? [] : [xml]),
].map((item) => invocation(item, path, "$PLIST"));
const converted = xml?.value ?? json.value;
const converted = xml ?? json.value;
const format = await plistSourceFormat(classified.stdout, {
...fileInput,
capturedOutput: {
+136 -19
View File
@@ -5,12 +5,7 @@ import {
scanBinaryPlistScalars,
} from "../../artifacts/apple/PlistScalars.js";
import {
parsePlistBinary,
parsePlistJson,
parsePlistXml,
plistJsonNeedsNumberTypes,
} from "./plist.js";
import { parsePlistBinary, readPlistJson, parsePlistXml } from "./plist.js";
import { estimateBinaryPlistExpansion } from "../../artifacts/apple/PropertyListDecodeBudget.js";
import {
binaryPlistObjectTable,
@@ -453,7 +448,9 @@ it("does not relabel integral CDATA reals as integers in native JSON or XML evid
const xml =
"<plist><dict><key>integer</key><integer>9007199254740992</integer><key>real</key><real>9007199254<![CDATA[740992]]></real></dict></plist>";
for (const parsed of [
parsePlistJson('{"integer":9007199254740992,"real":9007199254740992}', xml),
readPlistJson(
'{"integer":9007199254740992,"real":9007199254740992}',
).project(xml),
parsePlistXml(xml),
]) {
if (!parsed.ok) throw parsed.error;
@@ -468,8 +465,10 @@ it("does not relabel integral CDATA reals as integers in native JSON or XML evid
});
it("keeps exact decimal text for JSON integers that XML shows are integers", () => {
const parsed = parsePlistJson(
const attempt = readPlistJson(
'{"Max":18446744073709551615,"Min":-9223372036854775808,"Near":9007199254740993,"Edge":9007199254740992,"Safe":9007199254740991,"Real":1e21,"Ratio":0.5}',
);
const parsed = attempt.project(
[
"<plist><dict>",
"<key>Max</key><integer>18446744073709551615</integer>",
@@ -501,9 +500,9 @@ it("keeps exact decimal text for JSON integers that XML shows are integers", ()
it("keeps integral JSON reals beyond the safe range as reals", () => {
const output = '{"Real":9007199254740992,"Large":100000000000000000000}';
expect(plistJsonNeedsNumberTypes(output)).toBe(true);
const parsed = parsePlistJson(
output,
const attempt = readPlistJson(output);
expect(attempt.needsNumberTypes).toBe(true);
const parsed = attempt.project(
"<plist><dict><key>Real</key><real>9007199254740992</real><key>Large</key><real>1e+20</real></dict></plist>",
);
if (!parsed.ok) throw new Error("expected plist JSON to parse");
@@ -518,7 +517,9 @@ it.each([
"<plist><array><integer>9007199254740992</integer><real>9007199254740992</real></array></plist>",
],
])("does not label an unsafe JSON number an integer %s", (_case, xml) => {
const parsed = parsePlistJson("[9007199254740992,9007199254740992]", xml);
const parsed = readPlistJson("[9007199254740992,9007199254740992]").project(
xml,
);
if (!parsed.ok) throw new Error("expected plist JSON to parse");
expect(parsed.value.value).toEqual([9007199254740992, 9007199254740992]);
expect(parsed.value.limitations).toEqual([
@@ -528,20 +529,136 @@ it.each([
it("requests element types only for unsafe integral JSON numbers", () => {
expect(
plistJsonNeedsNumberTypes(
readPlistJson(
'{"Safe":9007199254740991,"Real":1e21,"Text":"9007199254740993"}',
),
).needsNumberTypes,
).toBe(false);
expect(plistJsonNeedsNumberTypes("{")).toBe(false);
expect(readPlistJson("{").needsNumberTypes).toBe(false);
});
it("reports no limitation when every JSON integer is exact", () => {
const parsed = parsePlistJson('{"Count":42,"Nested":[-1,0]}');
const parsed = readPlistJson('{"Count":42,"Nested":[-1,0]}').project();
if (!parsed.ok) throw new Error("expected plist JSON to parse");
expect(parsed.value.value).toEqual({ Count: 42, Nested: [-1, 0] });
expect(parsed.value.limitations).toEqual([]);
});
it("projects retained JSON facts afresh for each XML classification", () => {
const attempt = readPlistJson('[9007199254740993,-0.0,{"nested":[42]}]');
const xml =
"<plist><array><integer>9007199254740993</integer><real>-0.0</real><dict><key>nested</key><array><integer>42</integer></array></dict></array></plist>";
expect(attempt.needsNumberTypes).toBe(true);
const first = attempt.project(xml);
if (!first.ok) throw first.error;
expect(first.value.value).toEqual([
{ $plist_type: "integer", decimal: "9007199254740993" },
{ $plist_type: "real", value: "-0" },
{ nested: [42] },
]);
if (!Array.isArray(first.value.value)) throw new Error("expected an array");
for (const item of first.value.value) {
if (item !== null && typeof item === "object")
Object.defineProperty(item, "changed", { value: true, enumerable: true });
}
first.value.value.length = 0;
const repeated = attempt.project(xml);
if (!repeated.ok) throw repeated.error;
expect(repeated.value.value).toEqual([
{ $plist_type: "integer", decimal: "9007199254740993" },
{ $plist_type: "real", value: "-0" },
{ nested: [42] },
]);
expect(repeated.value.limitations).toEqual(first.value.limitations);
const unclassified = attempt.project();
if (!unclassified.ok) throw unclassified.error;
expect(unclassified.value.value).toEqual([
9007199254740992,
{ $plist_type: "real", value: "-0" },
{ nested: [42] },
]);
expect(unclassified.value.limitations).toEqual([
expect.stringContaining("1 negative-zero real value(s)"),
expect.stringContaining("1 number(s)"),
]);
});
it("keeps marker-shaped source dictionaries as ordinary JSON data", () => {
const attempt = readPlistJson(
'{"value":42,"digits":"9007199254740995","$plist_type":"integer","decimal":"authored","unsafe":9007199254740993}',
);
const parsed = attempt.project(
"<plist><dict><key>value</key><integer>42</integer><key>digits</key><string>9007199254740995</string><key>$plist_type</key><string>integer</string><key>decimal</key><string>authored</string><key>unsafe</key><integer>9007199254740993</integer></dict></plist>",
);
if (!parsed.ok) throw parsed.error;
expect(parsed.value.value).toEqual({
value: 42,
digits: "9007199254740995",
$plist_type: "integer",
decimal: "authored",
unsafe: { $plist_type: "integer", decimal: "9007199254740993" },
});
});
it("uses only surviving duplicate JSON entries for number types and limitations", () => {
const overwritten = readPlistJson(
'{"Count":9007199254740993,"Count":42,"Zero":-0.0,"Zero":0}',
);
expect(overwritten.needsNumberTypes).toBe(false);
const overwrittenParsed = overwritten.project();
if (!overwrittenParsed.ok) throw overwrittenParsed.error;
expect(overwrittenParsed.value.value).toEqual({ Count: 42, Zero: 0 });
expect(overwrittenParsed.value.limitations).toEqual([]);
const surviving = readPlistJson('{"Count":42,"Count":9007199254740993}');
expect(surviving.needsNumberTypes).toBe(true);
const parsed = surviving.project(
"<plist><dict><key>Count</key><integer>9007199254740993</integer></dict></plist>",
);
if (!parsed.ok) throw parsed.error;
expect(parsed.value.value).toEqual({
Count: { $plist_type: "integer", decimal: "9007199254740993" },
});
expect(parsed.value.limitations).toEqual([
expect.stringContaining("1 integer value(s)"),
]);
});
it("counts numeric source facts before omitting prototype dictionary entries", () => {
const attempt = readPlistJson(
'{"__proto__":{"integer":9007199254740993,"zero":-0.0,"__proto__":7},"kept":42}',
);
expect(attempt.needsNumberTypes).toBe(true);
const parsed = attempt.project();
if (!parsed.ok) throw parsed.error;
expect(parsed.value.value).toEqual({ kept: 42 });
expect(parsed.value.limitations).toEqual([
expect.stringContaining("1 negative-zero real value(s)"),
expect.stringContaining("1 number(s)"),
"1 dictionary entry keyed __proto__ was omitted because REA results cannot represent that key.",
]);
});
it("retains malformed JSON without requesting XML and preserves final XML error precedence", () => {
const malformed = readPlistJson('{"Count":9007199254740993} trailing');
expect(malformed.needsNumberTypes).toBe(false);
const jsonFailure = malformed.project();
if (jsonFailure.ok) throw new Error("expected JSON syntax failure");
expect(jsonFailure.error.cause).toBeInstanceOf(SyntaxError);
const repeatedFailure = malformed.project();
if (repeatedFailure.ok)
throw new Error("expected repeated JSON syntax failure");
expect(repeatedFailure.error).not.toBe(jsonFailure.error);
expect(repeatedFailure.error.message).toBe(jsonFailure.error.message);
expect(repeatedFailure.error.cause).toBe(jsonFailure.error.cause);
const invalidXml = "<plist><dict><key>unclosed";
const xmlFailure = malformed.project(invalidXml);
const validJsonXmlFailure = readPlistJson("42").project(invalidXml);
if (xmlFailure.ok || validJsonXmlFailure.ok)
throw new Error("expected XML syntax failure");
expect(xmlFailure.error.message).toBe(validJsonXmlFailure.error.message);
expect(xmlFailure.error.message).not.toBe(jsonFailure.error.message);
});
it("recovers exact XML integers from their element literals", () => {
const parsed = parsePlistXml(
"<plist><dict><key>Max</key><integer>18446744073709551615</integer><key>Safe</key><integer>7</integer><key>Blob</key><data>AA==</data></dict></plist>",
@@ -574,9 +691,9 @@ it("keeps integral XML reals beyond the safe range as reals", () => {
it("keeps negative-zero reals distinct in native JSON and XML evidence", () => {
for (const parsed of [
parsePlistJson(
readPlistJson(
'{"Negative":-0.0,"Spelled":-0,"Positive":0.0,"Integer":0,"Ratio":1.25}',
),
).project(),
parsePlistXml(
"<plist><dict><key>Negative</key><real>-0.0</real><key>Spelled</key><real>-0</real><key>Positive</key><real>0.0</real><key>Integer</key><integer>-0</integer><key>Ratio</key><real>1.25</real></dict></plist>",
),
@@ -592,7 +709,7 @@ it("keeps negative-zero reals distinct in native JSON and XML evidence", () => {
});
it("reports no negative-zero limitation for positive zero", () => {
const parsed = parsePlistJson('{"Positive":0.0,"Integer":0}');
const parsed = readPlistJson('{"Positive":0.0,"Integer":0}').project();
if (!parsed.ok) throw parsed.error;
expect(parsed.value.limitations).toEqual([]);
});
+88 -72
View File
@@ -76,101 +76,117 @@ export interface ParsedPlist {
/** Digits of a JSON number literal that has no fraction or exponent. */
const INTEGRAL_LITERAL = /^-?\d+$/u;
/**
* Decode plutil JSON, passing each integral number literal beyond the exact
* range of a JavaScript number, with its source digits, to `unsafe`, and
* each negative zero to `negativeZero`.
*/
const decodePlistJson = (
output: string,
unsafe: (item: number, digits: string) => unknown,
negativeZero: (item: number) => unknown = (item) => item,
): unknown => {
// plutil output can carry a UTF-8 BOM, which JSON.parse rejects outright.
const text = output.replace(/^\uFEFF/u, "");
return JSON.parse(
text,
(_key: string, item: unknown, context?: { readonly source?: string }) => {
// A plist integer has no signed zero, so -0 can only come from a real.
if (typeof item === "number" && Object.is(item, -0))
return negativeZero(item);
const source = context?.source;
return typeof item !== "number" ||
Number.isSafeInteger(item) ||
source === undefined ||
!INTEGRAL_LITERAL.test(source)
? item
: unsafe(item, source);
},
/** An unsafe integral JSON literal, kept separate from caller-authored objects. */
class PlistJsonIntegerLiteral {
constructor(
readonly value: number,
readonly digits: string,
) {}
}
/** One JSON parse attempt, retaining its facts or failure for final projection. */
export interface PlistJsonParseAttempt {
/** Whether plutil should supply XML number types; false for malformed JSON. */
readonly needsNumberTypes: boolean;
project(xmlConversion?: string): Result<ParsedPlist, AnalysisOutputError>;
}
const plistJsonOutputError = (cause: unknown): AnalysisOutputError =>
new AnalysisOutputError(
"inspect_plist",
cause instanceof Error ? cause.message : String(cause),
{ cause },
);
};
/**
* Whether plutil JSON holds an integral number beyond the exact range of a
* JSON number. JSON prints an integral `<real>` and an `<integer>` alike, so
* such a number's element type must be read from plutil's XML conversion.
* Read plutil JSON once, retaining exact unsafe integral literals. Optional XML
* classification and each independent output projection use these same facts.
*/
export const plistJsonNeedsNumberTypes = (output: string): boolean => {
let found = false;
export const readPlistJson = (output: string): PlistJsonParseAttempt => {
let decoded: Result<unknown, unknown>;
let needsNumberTypes = false;
try {
decodePlistJson(output, (item) => {
found = true;
return item;
});
} catch {
// Malformed JSON is reported by `parsePlistJson`.
// plutil output can carry a UTF-8 BOM, which JSON.parse rejects outright.
const text = output.replace(/^\uFEFF/u, "");
decoded = ok(
JSON.parse(
text,
(
_key: string,
item: unknown,
context?: { readonly source?: string },
) => {
const source = context?.source;
if (
typeof item !== "number" ||
Number.isSafeInteger(item) ||
source === undefined ||
!INTEGRAL_LITERAL.test(source)
)
return item;
// JSON prints integral <real> and <integer> values alike.
needsNumberTypes = true;
return new PlistJsonIntegerLiteral(item, source);
},
),
);
} catch (cause: unknown) {
decoded = err(cause);
}
return found;
return {
needsNumberTypes: decoded.ok && needsNumberTypes,
project: (xmlConversion) => projectPlistJsonAttempt(decoded, xmlConversion),
};
};
/**
* Parse plutil JSON output and project stable bundle metadata. An integer
* beyond the exact range of a JSON number keeps its decimal text when
* `xmlConversion`, plutil's XML form of the same plist, shows that it came
* from an `<integer>` rather than a `<real>`.
*/
export const parsePlistJson = (
output: string,
xmlConversion?: string,
const projectPlistJsonAttempt = (
attempt: Result<unknown, unknown>,
xmlConversion: string | undefined,
): Result<ParsedPlist, AnalysisOutputError> => {
let literals: PlistNumberLiterals | undefined;
let exactIntegerCount = 0;
let ambiguousNumberCount = 0;
let negativeZeroRealCount = 0;
const signedZero = (): unknown => {
negativeZeroRealCount += 1;
return negativeZeroReal();
};
const classify = (item: number, digits: string): unknown => {
const exact = BigInt(digits).toString();
const integer =
literals?.incomplete === false &&
literals.integers.get(item)?.has(exact) === true;
const real = literals?.incomplete === false && literals.reals.has(item);
if (integer && !real) {
exactIntegerCount += 1;
return { $plist_type: "integer", decimal: digits };
const project = (item: unknown): unknown => {
// A plist integer has no signed zero, so -0 can only come from a real.
if (typeof item === "number" && Object.is(item, -0)) {
negativeZeroRealCount += 1;
return negativeZeroReal();
}
// An integral <real> decodes exactly; only its JSON spelling is integral.
if (real && !integer) return item;
ambiguousNumberCount += 1;
if (item instanceof PlistJsonIntegerLiteral) {
const integer =
literals?.incomplete === false &&
literals.integers.get(item.value)?.has(item.digits) === true;
const real =
literals?.incomplete === false && literals.reals.has(item.value);
if (integer && !real) {
exactIntegerCount += 1;
return { $plist_type: "integer", decimal: item.digits };
}
// An integral <real> decodes exactly; only its JSON spelling is integral.
if (real && !integer) return item.value;
ambiguousNumberCount += 1;
return item.value;
}
if (Array.isArray(item)) return item.map(project);
if (item !== null && typeof item === "object")
return Object.fromEntries(
Object.entries(item).map(([key, value]) => [key, project(value)]),
);
return item;
};
let decoded: ReturnType<typeof omitPrototypeKeys>;
try {
// Preserve XML-before-JSON failure precedence during final projection.
literals =
xmlConversion === undefined
? undefined
: parseXmlPropertyList(xmlConversion).numberLiterals;
decoded = omitPrototypeKeys(decodePlistJson(output, classify, signedZero));
if (!attempt.ok) return err(plistJsonOutputError(attempt.error));
// Numeric limitations also account for values under omitted source keys.
decoded = omitPrototypeKeys(project(attempt.value));
} catch (cause: unknown) {
return err(
new AnalysisOutputError(
"inspect_plist",
cause instanceof Error ? cause.message : String(cause),
{ cause },
),
);
return err(plistJsonOutputError(cause));
}
const { value } = decoded;
return ok({
@@ -1,7 +1,7 @@
import { expect, it } from "vitest";
import { parseOtoolLoadCommands } from "../../../../src/native/parsers/otool.js";
import { parsePlistJson } from "../../../../src/native/parsers/plist.js";
import { readPlistJson } from "../../../../src/native/parsers/plist.js";
import { parseLipoOutput } from "../../../../src/native/parsers/lipo.js";
import { nativeFixture } from "../../../fixtures/nativeCommands.js";
@@ -73,9 +73,9 @@ it("does not leak section fields into command fields under CRLF captures", async
});
it("accepts a byte-order mark before plist JSON", () => {
const parsed = parsePlistJson(
const parsed = readPlistJson(
'\uFEFF{"CFBundleIdentifier":"com.owned.fixture"}',
);
).project();
if (!parsed.ok) throw new Error("expected plist JSON to parse");
expect(parsed.value.bundle.identifier).toBe("com.owned.fixture");
});
@@ -1,11 +1,18 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect, it } from "vitest";
import { expect, it, onTestFinished } from "vitest";
import { buildBinary } from "plist";
import { inspectPlistSchema } from "../../../../src/domain/native/nativeInspection.js";
import { ok } from "../../../../src/domain/result.js";
import { err, ok, type Result } from "../../../../src/domain/result.js";
import { AnalysisOutputError } from "../../../../src/domain/analysisErrorCore.js";
import { ProviderAdapterError } from "../../../../src/domain/providerAdapterError.js";
import {
NativeCommandFailure,
type NativeCommandOptions,
type NativeCommandRunner,
} from "../../../../src/native/CommandRunner.js";
import { NativeMacOSProvider } from "../../../../src/native/NativeMacOSProvider.js";
import {
NativeFixtureRunner,
@@ -194,3 +201,233 @@ it.for<PlistSourceCase>([
);
},
);
interface PlistConversionCommand {
readonly format: "json" | "xml1";
readonly output: Result<string, NativeCommandFailure>;
}
/** Exercise conversion decisions with a closed command script and caller-selected deadline. */
const inspectConvertedPlist = async (
conversions: readonly PlistConversionCommand[],
) => {
const root = await createTestTempDirectory("rea-plist-conversion-");
const path = join(root, "selected numbers.plist");
await writeFile(path, OPENSTEP);
const signal = new AbortController().signal;
const commands = [
{
tool: "file",
arguments: ["-b", path],
acceptNonZero: false,
output: ok("ASCII text\n"),
},
...conversions.map(({ format, output }) => ({
tool: "plutil",
arguments: ["-convert", format, "-o", "-", "--", path],
acceptNonZero: format === "json",
output,
})),
];
const observed: {
tool: string;
arguments: readonly string[];
options: NativeCommandOptions;
}[] = [];
const runner: NativeCommandRunner = {
async run(tool, arguments_, options) {
const command = commands[observed.length];
observed.push({ tool, arguments: [...arguments_], options });
if (command === undefined) throw new Error("Unexpected native command");
if (!command.output.ok) return command.output;
const stdout = command.output.value;
return ok({
tool,
executable: `/usr/bin/${tool}`,
executableSha256: "a".repeat(64),
toolVersion: null,
versionReason: "fixture",
arguments: [...arguments_],
stdout,
stderr: "",
stdoutBytes: Buffer.byteLength(stdout),
stderrBytes: 0,
exitCode: 0,
signal: null,
});
},
};
const client = new NativeMacOSProvider({}, runner, "darwin").createClient(
nativeMachoTarget(join(root, "target")),
);
onTestFinished(async () => {
const closed = await client.close();
if (!closed.ok) throw closed.error;
});
const inspected = await client.execute(
"inspect_plist",
{ path, native_command_timeout_ms: 1234 },
{ signal },
);
expect(observed).toEqual(
commands.map((command) => ({
tool: command.tool,
arguments: command.arguments,
options: {
signal,
timeoutMs: 1234,
acceptNonZero: command.acceptNonZero,
},
})),
);
return { inspected, path };
};
it("skips XML conversion for safe integers, signed zero, exponential reals and numeric strings", async () => {
const { inspected, path } = await inspectConvertedPlist([
{
format: "json",
output: ok(
'\uFEFF{"Safe":9007199254740991,"Negative":-0.0,"Spelled":-0,"Positive":0.0,"Real":1e21,"Text":"9007199254740993"}',
),
},
]);
if (!inspected.ok) throw inspected.error;
const result = inspectPlistSchema.parse(inspected.value.result);
expect(result.source_path).toBe(path);
expect(result.value).toEqual({
Safe: 9007199254740991,
Negative: { $plist_type: "real", value: "-0" },
Spelled: { $plist_type: "real", value: "-0" },
Positive: 0,
Real: 1e21,
Text: "9007199254740993",
});
expect(result.limitations).toEqual([
'2 negative-zero real value(s) are reported as { "$plist_type": "real", "value": "-0" } because a JSON number cannot carry the sign of zero.',
]);
expect(result.provenance.map(({ command }) => command)).toEqual([
["/usr/bin/file", "-b", "$PLIST"],
["/usr/bin/plutil", "-convert", "json", "-o", "-", "--", "$PLIST"],
]);
});
it("uses XML element types for unsafe JSON digits without confusing integral reals or ambiguous values", async () => {
const { inspected, path } = await inspectConvertedPlist([
{
format: "json",
output: ok(
'{"Max":18446744073709551615,"Near":9007199254740993,"Edge":9007199254740992,"Real":100000000000000000000,"AmbiguousInteger":9007199254740994,"AmbiguousReal":9007199254740994}',
),
},
{
format: "xml1",
output: ok(
"<plist><dict><key>Max</key><integer>18446744073709551615</integer><key>Near</key><integer>9007199254740993</integer><key>Edge</key><integer>9007199254740992</integer><key>Real</key><real>1e20</real><key>AmbiguousInteger</key><integer>9007199254740994</integer><key>AmbiguousReal</key><real>9007199254740994</real></dict></plist>",
),
},
]);
if (!inspected.ok) throw inspected.error;
const result = inspectPlistSchema.parse(inspected.value.result);
expect(result.source_path).toBe(path);
expect(result.value).toEqual({
Max: { $plist_type: "integer", decimal: "18446744073709551615" },
Near: { $plist_type: "integer", decimal: "9007199254740993" },
Edge: { $plist_type: "integer", decimal: "9007199254740992" },
Real: 1e20,
AmbiguousInteger: 9007199254740994,
AmbiguousReal: 9007199254740994,
});
expect(result.limitations).toEqual([
expect.stringContaining("3 integer value(s)"),
expect.stringContaining("2 number(s)"),
]);
expect(
result.provenance.map(({ command, exit }) => ({ command, exit })),
).toEqual([
{
command: ["/usr/bin/file", "-b", "$PLIST"],
exit: { code: 0, signal: null },
},
{
command: [
"/usr/bin/plutil",
"-convert",
"json",
"-o",
"-",
"--",
"$PLIST",
],
exit: { code: 0, signal: null },
},
{
command: [
"/usr/bin/plutil",
"-convert",
"xml1",
"-o",
"-",
"--",
"$PLIST",
],
exit: { code: 0, signal: null },
},
]);
});
it("rejects malformed JSON after an unsafe token without requesting XML", async () => {
const { inspected } = await inspectConvertedPlist([
{ format: "json", output: ok('{"Wide":9007199254740993,"Broken":') },
]);
if (inspected.ok) throw new Error("Expected malformed conversion output");
expect(inspected.error).toBeInstanceOf(AnalysisOutputError);
expect(inspected.error.cause).toBeInstanceOf(SyntaxError);
});
it("rejects malformed XML requested to classify valid unsafe JSON", async () => {
const { inspected } = await inspectConvertedPlist([
{ format: "json", output: ok('{"Wide":9007199254740993}') },
{ format: "xml1", output: ok("<not-plist/>") },
]);
if (inspected.ok) throw new Error("Expected malformed XML conversion output");
expect(inspected.error).toBeInstanceOf(AnalysisOutputError);
expect(inspected.error.cause).toBeInstanceOf(TypeError);
expect(inspected.error.message).toContain("First element should be <plist>");
});
it("preserves the XML command failure before interpreting its malformed output", async () => {
const stdout = "<not-plist/>";
const stderr = "fixture plutil refused XML";
const failure = new NativeCommandFailure(
"plutil",
"nonzero-exit",
7,
undefined,
{
stdout,
stderr,
stdoutBytes: Buffer.byteLength(stdout),
stderrBytes: Buffer.byteLength(stderr),
exitCode: 7,
signal: null,
truncated: false,
},
);
const { inspected } = await inspectConvertedPlist([
{ format: "json", output: ok('{"Wide":9007199254740993}') },
{ format: "xml1", output: err(failure) },
]);
if (inspected.ok) throw new Error("Expected XML command refusal");
expect(inspected.error).toBeInstanceOf(ProviderAdapterError);
expect(inspected.error.cause).toBe(failure);
expect(inspected.error.capturedOutput).toEqual({
stdout,
stderr,
stdout_bytes: Buffer.byteLength(stdout),
stderr_bytes: Buffer.byteLength(stderr),
exit_code: 7,
signal: null,
truncated: false,
});
});