fix(apple): decode binary app Info.plist files without plutil (#1476)

* fix(apple): decode binary app Info.plist files without plutil

* test(apple): use Windows-legal names in the binary plist bundle test

* fix(apple): ignore __proto__ entries in binary app Info.plist files

---------

Co-authored-by: morluto <[email protected]>
This commit is contained in:
Jay Hemnani
2026-10-11 11:14:15 +08:00
committed by GitHub
co-authored by morluto
parent ab80ec640a
commit c8820e03db
2 changed files with 87 additions and 20 deletions
+21 -20
View File
@@ -1,17 +1,21 @@
import { execFile } from "node:child_process";
import { lstat, opendir, readFile, realpath, stat } from "node:fs/promises";
import { join } from "node:path";
import { promisify } from "node:util";
import { parseBinary } from "plist";
import { z } from "zod";
import { preflightPropertyListDecode } from "../artifacts/apple/InterfaceBuilderDecodeBudget.js";
import { BinaryTargetError } from "../domain/configurationErrors.js";
import { isPathWithinRoot } from "../domain/localPath.js";
import { parseXmlPropertyList } from "../domain/propertyListKeys.js";
import {
omitPrototypeKeys,
parseXmlPropertyList,
} from "../domain/propertyListKeys.js";
import { decodeXmlPlistText } from "../domain/propertyListXmlText.js";
import { err, ok, type Result } from "../domain/result.js";
const execFileAsync = promisify(execFile);
/** Decoded representation bound for one binary Info.plist. */
const BINARY_PLIST_DECODE_BYTES = 16 * 1024 * 1024;
/** Where one app bundle layout keeps its Info.plist and program file. */
interface BundleLayout {
@@ -193,9 +197,8 @@ const resolveLayoutExecutable = async (
try {
name =
plist.subarray(0, 6).toString("ascii") === "bplist"
? await (fileSystem.decodeBinaryPlist ?? readBinaryPlistExecutable)(
plistPath,
)
? await (fileSystem.decodeBinaryPlist?.(plistPath) ??
parseBinaryPlistExecutable(plist))
: parseXmlPlistExecutable(decodeXmlPlistText(plist));
} catch (cause: unknown) {
return err(
@@ -248,6 +251,10 @@ const resolveLayoutExecutable = async (
const parseXmlPlistExecutable = (plist: string): string => {
// An unrelated `__proto__` entry must not make the bundle unreadable.
const { value } = parseXmlPropertyList(plist);
return executableName(value);
};
const executableName = (value: unknown): string => {
const executable = executableEntrySchema.safeParse(value);
if (!executable.success) throw new Error("CFBundleExecutable is missing");
return executable.data.CFBundleExecutable;
@@ -255,19 +262,13 @@ const parseXmlPlistExecutable = (plist: string): string => {
const executableEntrySchema = z.looseObject({ CFBundleExecutable: z.string() });
const readBinaryPlistExecutable = async (plistPath: string): Promise<string> =>
// `-n` strips only the newline plutil appends; it requires macOS 12+.
(
await execFileAsync("/usr/bin/plutil", [
"-extract",
"CFBundleExecutable",
"raw",
"-n",
"-o",
"-",
plistPath,
])
).stdout;
/** Decode a binary plist in-process, so no host plist tool is required. */
const parseBinaryPlistExecutable = (plist: Buffer): string => {
preflightPropertyListDecode(plist, BINARY_PLIST_DECODE_BYTES);
// `plist` assigns a `__proto__` key as the prototype; copying drops it, as
// the XML path does.
return executableName(omitPrototypeKeys(parseBinary(plist)).value);
};
const isSafeExecutableName = (name: string): boolean =>
name.length > 0 &&
@@ -10,6 +10,7 @@ import {
import { join } from "node:path";
import { promisify } from "node:util";
import { buildBinary } from "plist";
import { describe, expect, it } from "vitest";
import { parseBinaryTarget } from "../../../src/application/BinaryTargetResolver.js";
@@ -691,6 +692,71 @@ describe("iOS-style app bundle targets", () => {
);
});
describe("binary Info.plist app bundle targets", () => {
it.each([
["Mac.app", ["Contents", "MacOS"], ["Contents", "Info.plist"]],
["Phone.app", [], ["Info.plist"]],
])(
"resolves %s from a binary Info.plist on every host",
async (bundle, programs, plist) => {
const directory = await createTestTempDirectory("rea-app-bplist-");
// Windows file names cannot hold a tab or end in a space.
const names =
process.platform === "win32"
? ["Ordinary"]
: ["Ordinary", " App ", "Tab\t"];
for (const name of names) {
const app = join(directory, name, bundle);
const executable = join(app, ...programs, name);
await mkdir(join(app, ...programs), { recursive: true });
await mkdir(join(app, ...plist.slice(0, -1)), { recursive: true });
await writeFile(
join(app, ...plist),
buildBinary({ CFBundleExecutable: name, CFBundleVersion: "1" }),
);
await writeFile(executable, thinMach(0xfeedfacf, 0x0100000c));
const result = await parseBinaryTarget(app, {
cwd: directory,
hostArchitecture: "arm64",
});
expect(result.ok && result.value).toMatchObject({
path: await realpath(executable),
format: "mach-o",
});
}
},
);
it.each([
[
"XML",
"<plist><dict><key>__proto__</key><dict><key>CFBundleExecutable</key><string>Forged</string></dict></dict></plist>",
],
[
"binary",
buildBinary(JSON.parse('{"__proto__":{"CFBundleExecutable":"Forged"}}')),
],
])(
"ignores CFBundleExecutable nested under __proto__ in %s Info.plist files",
async (_encoding, contents) => {
const directory = await createTestTempDirectory("rea-app-proto-only-");
const app = join(directory, "Proto.app");
await mkdir(join(app, "Contents", "MacOS"), { recursive: true });
await writeFile(join(app, "Contents", "Info.plist"), contents);
await writeFile(
join(app, "Contents", "MacOS", "Forged"),
thinMach(0xfeedfacf, 0x0100000c),
);
expect(await resolveAppBundleExecutable(app)).toMatchObject({
ok: false,
error: {
reason: expect.stringContaining("CFBundleExecutable is missing"),
},
});
},
);
});
describe("DOS binary target I/O", () => {
it("validates a complete DOS file beyond the initial metadata probe", async () => {
const directory = await createTestTempDirectory("rea-dos-target-");