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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-10-17 15:52:25 +08:00
1 parent de9371b85d
commit 4e14452ddb
4 files changed
+318 -5

No files matched your search

+7
View File
@@ -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 `<deadbeef>`, 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
+59 -5
View File
@@ -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<Result<"binary" | "xml" | "json" | "unknown", AnalysisError>> => {
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.
+97
View File
@@ -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
? "<deadbeef>\n"
: emptyData
? "<>\n"
: `{ Name = Example; Items = (a, b); ${withData ? "Blob = <deadbeef>;" : ""} }\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 }) => {
@@ -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 =
"<plist><dict><key>Name</key><string>Example</string><key>Items</key><array><string>a</string><string>b</string></array><key>Blob</key><data>3q2+7w==</data></dict></plist>";
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<PlistSourceCase>([
{ description: "ASCII text\n", expected: "unknown", xmlFallback: false },
{
description: "JSON text data\n",
contents: JSON.stringify(VALUE),
expected: "json",
xmlFallback: false,
},
...["<deadbeef>\n", "<de ad\nbe ef>\n", "<>\n"].map((contents) => ({
description: "ASCII text\n",
contents,
expected: "unknown",
xmlFallback: true,
convertedXml: `<plist><data>${contents.startsWith("<>") ? "" : DATA.base64}</data></plist>`,
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: `<!-- source plist -->\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")] : [],
);
},
);