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:
4 files changed
+318
-5
No files matched your search
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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")] : [],
|
||||
);
|
||||
},
|
||||
);
|
||||
Reference in new issue
Block a user