diff --git a/src/native/NativeMacOSProvider.ts b/src/native/NativeMacOSProvider.ts index b03d2f4033..dd16cddcc3 100644 --- a/src/native/NativeMacOSProvider.ts +++ b/src/native/NativeMacOSProvider.ts @@ -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 like an , 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; + 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: { diff --git a/src/native/parsers/plist.test.ts b/src/native/parsers/plist.test.ts index 819b1315c1..a3eaaa9e3e 100644 --- a/src/native/parsers/plist.test.ts +++ b/src/native/parsers/plist.test.ts @@ -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 = "integer9007199254740992real9007199254"; 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( [ "", "Max18446744073709551615", @@ -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( "Real9007199254740992Large1e+20", ); if (!parsed.ok) throw new Error("expected plist JSON to parse"); @@ -518,7 +517,9 @@ it.each([ "90071992547409929007199254740992", ], ])("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 = + "9007199254740993-0.0nested42"; + 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( + "value42digits9007199254740995$plist_typeintegerdecimalauthoredunsafe9007199254740993", + ); + 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( + "Count9007199254740993", + ); + 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 = "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( "Max18446744073709551615Safe7BlobAA==", @@ -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( "Negative-0.0Spelled-0Positive0.0Integer-0Ratio1.25", ), @@ -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([]); }); diff --git a/src/native/parsers/plist.ts b/src/native/parsers/plist.ts index 1e1d52b39d..b3834c4d40 100644 --- a/src/native/parsers/plist.ts +++ b/src/native/parsers/plist.ts @@ -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; +} + +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 `` and an `` 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; + 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 and 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 `` rather than a ``. - */ -export const parsePlistJson = ( - output: string, - xmlConversion?: string, +const projectPlistJsonAttempt = ( + attempt: Result, + xmlConversion: string | undefined, ): Result => { 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 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 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; 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({ diff --git a/tests/boundary/providers/native/nativeTextRobustness.test.ts b/tests/boundary/providers/native/nativeTextRobustness.test.ts index ce82bd5201..5b39dd1c85 100644 --- a/tests/boundary/providers/native/nativeTextRobustness.test.ts +++ b/tests/boundary/providers/native/nativeTextRobustness.test.ts @@ -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"); }); diff --git a/tests/boundary/providers/native/plistSourceFormat.test.ts b/tests/boundary/providers/native/plistSourceFormat.test.ts index 2af7d83871..20df0133e3 100644 --- a/tests/boundary/providers/native/plistSourceFormat.test.ts +++ b/tests/boundary/providers/native/plistSourceFormat.test.ts @@ -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([ ); }, ); + +interface PlistConversionCommand { + readonly format: "json" | "xml1"; + readonly output: Result; +} + +/** 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( + "Max18446744073709551615Near9007199254740993Edge9007199254740992Real1e20AmbiguousInteger9007199254740994AmbiguousReal9007199254740994", + ), + }, + ]); + 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("") }, + ]); + 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 "); +}); + +it("preserves the XML command failure before interpreting its malformed output", async () => { + const stdout = ""; + 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, + }); +});