mirror of
https://github.com/morluto/rea.git
synced 2026-10-12 02:19:24 +00:00
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:
@@ -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-");
|
||||
|
||||
Reference in New Issue
Block a user