From 4e14452ddbf4642235c8a68435f72837510b2075 Mon Sep 17 00:00:00 2001 From: You Wish <29498139+Y0UW1SH@users.noreply.github.com> Date: Sat, 17 Oct 2026 03:52:25 -0400 Subject: [PATCH] fix(native): report unidentified text plists as unknown (#4320) * fix(native): report unidentified text plists as unknown Require explicit XML identification in file output instead of treating all text as XML. Preserve decoded plist values and source-format provenance when plutil falls back to XML conversion. Add provider-boundary regressions and real macOS CLI controls for OpenStep, data-bearing OpenStep, XML, UTF-16 XML and binary property lists. Fixes #4294 * fix(native): keep XML plists without a declaration labeled xml `file -b` names XML only when a document declaration is present, so the narrowed `\bXML\b` check reported a declaration-less XML plist as unknown and broke the real-plutil `decodes xml plists that plutil cannot express as JSON` CLI test. When `file` reports only text, read the source head once and label it XML only when its first non-whitespace character (after a UTF-8 BOM) is `<`; OpenStep and other text stay unknown. Co-Authored-By: Claude Opus 5.5 * fix(native): distinguish OpenStep data from XML source markup Recognize XML markup instead of treating every leading angle bracket as XML. Keep data-only OpenStep sources unknown, honor explicit JSON identification, and decode BOM-marked UTF-16 prefixes before detecting declarationless XML. Add source-format regressions and macOS CLI cases for data-only sources. --------- Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- docs/native-investigation.md | 7 + src/native/NativeMacOSProvider.ts | 64 +++++++- tests/boundary/cli/nativePlistCli.test.ts | 97 +++++++++++ .../native/plistSourceFormat.test.ts | 155 ++++++++++++++++++ 4 files changed, 318 insertions(+), 5 deletions(-) create mode 100644 tests/boundary/providers/native/plistSourceFormat.test.ts diff --git a/docs/native-investigation.md b/docs/native-investigation.md index 6d0dffc33..e7fdd3f16 100644 --- a/docs/native-investigation.md +++ b/docs/native-investigation.md @@ -261,6 +261,13 @@ Mach-O fixtures check each case against source-owned return values. ## Static inspection +`inspect_plist.format` describes the selected source, not plutil's conversion +format. Sources explicitly identified as XML, binary or JSON retain those +labels. For generic text, a bounded source-prefix probe recognizes leading XML +markup, including UTF-8 and BOM-marked UTF-16 sources. Unidentified text and +OpenStep plists, including a data-only value such as ``, report +`unknown` even when plutil decodes their values through an XML conversion. + `inspect_macho.selected_architecture` identifies the exact selected lipo slice and its `container_file_offset` in the input file. A thin Mach-O has a known offset of zero; an unknown universal-slice offset stays null. The Evidence diff --git a/src/native/NativeMacOSProvider.ts b/src/native/NativeMacOSProvider.ts index 8e7c938cf..13d319840 100644 --- a/src/native/NativeMacOSProvider.ts +++ b/src/native/NativeMacOSProvider.ts @@ -822,12 +822,14 @@ class NativeMacOSClient implements AnalysisClient { json.value, ...(xml === undefined ? [] : [xml.value]), ].map((item) => invocation(item, plist.value, "$PLIST")); + const format = await plistSourceFormat( + classified.value.stdout, + plist.value, + requested, + ); + if (!format.ok) return format; const result = inspectPlistSchema.parse({ - format: /binary property list/iu.test(classified.value.stdout) - ? "binary" - : /XML|text/iu.test(classified.value.stdout) - ? "xml" - : "unknown", + format: format.value, value: parsed.value.value, bundle: parsed.value.bundle, source_path: plist.value, @@ -1175,6 +1177,58 @@ const plutilDiagnostic = (stderr: string, path: string): string => { return text.startsWith(`${path}: `) ? text.slice(path.length + 2) : text; }; +/** Bytes examined for leading markup when `file` reports only text. */ +const PLIST_MARKUP_PROBE_BYTES = 4_096; + +/** + * Preserve explicit source identities. For generic text, recognize XML + * markup rather than a lone `<`, which also opens an OpenStep data value. + * A prefix without recognizable markup stays unknown. + */ +const plistSourceFormat = async ( + description: string, + path: string, + requested: string | undefined, +): Promise> => { + if (/binary property list/iu.test(description)) return ok("binary"); + if (/\bXML\b/iu.test(description)) return ok("xml"); + if (/\bJSON\b/iu.test(description)) return ok("json"); + if (!/\btext\b/iu.test(description)) return ok("unknown"); + try { + const handle = await open(path, "r"); + try { + const head = Buffer.alloc(PLIST_MARKUP_PROBE_BYTES); + const { bytesRead } = await handle.read(head, 0, head.length, 0); + const encoding = + head[0] === 0xff && head[1] === 0xfe + ? "utf-16le" + : head[0] === 0xfe && head[1] === 0xff + ? "utf-16be" + : "utf-8"; + const text = new TextDecoder(encoding) + .decode(head.subarray(0, bytesRead)) + .trimStart(); + return ok( + /^<(?:[!?]|(?:plist|dict|array|string|integer|real|data|date|true|false)[\s/>])/u.test( + text, + ) + ? "xml" + : "unknown", + ); + } finally { + await handle.close(); + } + } catch (cause: unknown) { + const code = + cause instanceof Error && "code" in cause ? cause.code : undefined; + if (code === "ENOENT" || code === "ENOTDIR") + return err(unreadablePlist(path, requested, "no file exists there")); + return err( + new ProviderAdapterError(IDENTITY.id, "inspect_plist", { cause }), + ); + } +}; + /** * Admit a readable regular file before plutil runs, so a later plutil failure * reflects the bytes rather than a missing path or a host permission denial. diff --git a/tests/boundary/cli/nativePlistCli.test.ts b/tests/boundary/cli/nativePlistCli.test.ts index 62ec9ce98..d63fdbffd 100644 --- a/tests/boundary/cli/nativePlistCli.test.ts +++ b/tests/boundary/cli/nativePlistCli.test.ts @@ -75,7 +75,104 @@ describe.skipIf(process.platform !== "darwin")( } }, ); + }, +); +describe.skipIf(process.platform !== "darwin")( + "native plist CLI source formats", + () => { + cliTest.for([ + ["openstep", "unknown"], + ["openstep-data", "unknown"], + ["openstep-data-root", "unknown"], + ["openstep-empty-data", "unknown"], + ["xml", "xml"], + ["xml16", "xml"], + ["binary", "binary"], + ] as const)( + "reports the original $0 plist format through the compiled CLI", + async ([encoding, format], { cli }) => { + const directory = await createTestTempDirectory("rea-plist-format-"); + const target = join(directory, "target"); + const plist = join(directory, "selected.plist"); + const withData = encoding === "openstep-data"; + const dataRoot = encoding === "openstep-data-root"; + const emptyData = encoding === "openstep-empty-data"; + await writeFile(target, thinMach(0xfeedfacf, 0x0100000c)); + await writeFile( + plist, + dataRoot + ? "\n" + : emptyData + ? "<>\n" + : `{ Name = Example; Items = (a, b); ${withData ? "Blob = ;" : ""} }\n`, + ); + if (encoding === "binary" || encoding.startsWith("xml")) { + await promisify(execFile)("/usr/bin/plutil", [ + "-convert", + encoding === "binary" ? "binary1" : "xml1", + "--", + plist, + ]); + if (encoding === "xml16") { + const { stdout } = await promisify(execFile)("/usr/bin/plutil", [ + "-convert", + "xml1", + "-o", + "-", + "--", + plist, + ]); + await writeFile( + plist, + Buffer.concat([ + Buffer.from([0xfe, 0xff]), + Buffer.from( + stdout.replace("UTF-8", "UTF-16"), + "utf16le", + ).swap16(), + ]), + ); + } + } + const result = await cli.run({ + arguments: [ + "inspect-plist", + target, + "--relative-path", + plist, + "--json", + ], + environment: { + REA_LOG_LEVEL: "silent", + REA_ANALYSIS_PROVIDER: "auto", + }, + }); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.json).toMatchObject({ + normalized_result: { + format, + source_path: plist, + value: + dataRoot || emptyData + ? { $plist_type: "data", base64: emptyData ? "" : "3q2+7w==" } + : { + Name: "Example", + Items: ["a", "b"], + ...(withData + ? { Blob: { $plist_type: "data", base64: "3q2+7w==" } } + : {}), + }, + }, + }); + }, + ); + }, +); + +describe.skipIf(process.platform !== "darwin")( + "native plist CLI typed values", + () => { cliTest.for(["xml", "binary"] as const)( "decodes $0 plists that plutil cannot express as JSON", async (format, { cli }) => { diff --git a/tests/boundary/providers/native/plistSourceFormat.test.ts b/tests/boundary/providers/native/plistSourceFormat.test.ts new file mode 100644 index 000000000..ab267b576 --- /dev/null +++ b/tests/boundary/providers/native/plistSourceFormat.test.ts @@ -0,0 +1,155 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { expect, it } from "vitest"; + +import { inspectPlistSchema } from "../../../../src/domain/native/nativeInspection.js"; +import { ok } from "../../../../src/domain/result.js"; +import { NativeMacOSProvider } from "../../../../src/native/NativeMacOSProvider.js"; +import { + NativeFixtureRunner, + nativeMachoTarget, +} from "../../../fixtures/nativeCommands.js"; +import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; + +const VALUE = { Name: "Example", Items: ["a", "b"] }; +const XML = + "NameExampleItemsabBlob3q2+7w=="; +const DATA = { $plist_type: "data", base64: "3q2+7w==" }; + +class PlistSourceRunner extends NativeFixtureRunner { + constructor( + description: string, + private readonly xmlFallback: boolean, + private readonly convertedXml: string = XML, + ) { + super({ file: description, plutil: JSON.stringify(VALUE) }); + } + + override async run(tool: string, arguments_: readonly string[]) { + const result = await super.run(tool, arguments_); + if (!result.ok || tool !== "plutil" || !this.xmlFallback) return result; + const json = arguments_.includes("json"); + const stdout = json ? "" : this.convertedXml; + return ok({ + ...result.value, + stdout, + stdoutBytes: Buffer.byteLength(stdout), + exitCode: json ? 1 : 0, + }); + } +} + +const OPENSTEP = "{ Name = Example; Items = (a, b); }\n"; + +interface PlistSourceCase { + readonly description: string; + readonly contents?: string | Buffer; + readonly expected: string; + readonly xmlFallback: boolean; + readonly convertedXml?: string; + readonly expectedValue?: unknown; +} + +it.for([ + { description: "ASCII text\n", expected: "unknown", xmlFallback: false }, + { + description: "JSON text data\n", + contents: JSON.stringify(VALUE), + expected: "json", + xmlFallback: false, + }, + ...["\n", "\n", "<>\n"].map((contents) => ({ + description: "ASCII text\n", + contents, + expected: "unknown", + xmlFallback: true, + convertedXml: `${contents.startsWith("<>") ? "" : DATA.base64}`, + expectedValue: { + ...DATA, + base64: contents.startsWith("<>") ? "" : DATA.base64, + }, + })), + // `file` reports XML without a declaration as plain text. + { + description: "ASCII text, with no line terminators\n", + contents: ` \n${XML}`, + expected: "xml", + xmlFallback: true, + }, + { + description: "ASCII text\n", + contents: `\n${XML}`, + expected: "xml", + xmlFallback: true, + }, + ...[false, true].map((bigEndian) => ({ + description: `Unicode text, UTF-16, ${bigEndian ? "big" : "little"}-endian text\n`, + contents: bigEndian + ? Buffer.from(`\uFEFF${XML}`, "utf16le").swap16() + : Buffer.from(`\uFEFF${XML}`, "utf16le"), + expected: "xml", + xmlFallback: true, + })), + { description: "ASCII text\n", expected: "unknown", xmlFallback: true }, + { + description: "Unicode text, UTF-8 text\n", + expected: "unknown", + xmlFallback: false, + }, + { + description: "XML 1.0 document text, ASCII text\n", + expected: "xml", + xmlFallback: false, + }, + { + description: + "XML 1.0 document text, Unicode text, UTF-16, big-endian text\n", + expected: "xml", + xmlFallback: true, + }, + { + description: "Apple binary property list\n", + expected: "binary", + xmlFallback: true, + }, +])( + "keeps source format $expected for $description (XML fallback: $xmlFallback)", + async ( + { + description, + contents = OPENSTEP, + expected, + xmlFallback, + convertedXml = XML, + expectedValue = { ...VALUE, ...(xmlFallback ? { Blob: DATA } : {}) }, + }, + { onTestFinished }, + ) => { + const root = await createTestTempDirectory("rea-plist-source-format-"); + const path = join(root, "selected.plist"); + await writeFile(path, contents); + const client = new NativeMacOSProvider( + {}, + new PlistSourceRunner(description, xmlFallback, convertedXml), + "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 }); + if (!inspected.ok) throw inspected.error; + const result = inspectPlistSchema.parse(inspected.value.result); + expect(result.format).toBe(expected); + expect(result.source_path).toBe(path); + expect(result.value).toEqual(expectedValue); + expect(result.provenance.map(({ tool }) => tool)).toEqual( + xmlFallback ? ["file", "plutil", "plutil"] : ["file", "plutil"], + ); + expect(result.limitations).toEqual( + xmlFallback ? [expect.stringContaining("XML conversion")] : [], + ); + }, +);