From c182f72ad3d9eea10b084a43527acb7c8ca2d6bf Mon Sep 17 00:00:00 2001 From: Iwakurakaoru0822 Date: Tue, 6 Oct 2026 21:40:32 -0700 Subject: [PATCH] fix(target): decode app Info.plist executables with an XML parser The pattern-based reader decoded entities twice, ignored numeric character references and CDATA, and could match a commented or nested CFBundleExecutable key, so valid app bundles reported a missing program. Co-Authored-By: Claude Opus 5.5 --- src/application/BinaryTargetResolver.ts | 29 +++++----- .../boundary/filesystem/binaryTarget.test.ts | 56 +++++++++++++++++++ 2 files changed, 71 insertions(+), 14 deletions(-) diff --git a/src/application/BinaryTargetResolver.ts b/src/application/BinaryTargetResolver.ts index 9d9b414b6b..3107729b43 100644 --- a/src/application/BinaryTargetResolver.ts +++ b/src/application/BinaryTargetResolver.ts @@ -12,6 +12,8 @@ import { execFile } from "node:child_process"; import { extname, isAbsolute, join, resolve } from "node:path"; import { promisify } from "node:util"; +import { parse as parsePlist } from "plist"; + import { isPathWithinRoot } from "../domain/localPath.js"; import { BinaryTargetError } from "../domain/configurationErrors.js"; import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; @@ -264,14 +266,21 @@ const resolveAppBundle = async ( } }; +/** Decode the top-level executable name with an XML parser, not a pattern. */ const parseXmlPlistExecutable = (plist: string): string => { - const match = - /\s*CFBundleExecutable\s*<\/key>\s*([^<]+)<\/string>/u.exec( - plist, - ); - if (match?.[1] === undefined) + const value = parsePlist(plist); + const executable = + typeof value === "object" && + value !== null && + !Array.isArray(value) && + !(value instanceof Date) && + !(value instanceof Uint8Array) && + Object.hasOwn(value, "CFBundleExecutable") + ? value.CFBundleExecutable + : undefined; + if (typeof executable !== "string") throw new Error("CFBundleExecutable is missing"); - return decodeXml(match[1]); + return executable; }; const readBinaryPlistExecutable = async (plistPath: string): Promise => @@ -295,14 +304,6 @@ const isSafeExecutableName = (name: string): boolean => !name.includes("\0") && !/[/\\]/u.test(name); -const decodeXml = (value: string): string => - value - .replaceAll("&", "&") - .replaceAll("<", "<") - .replaceAll(">", ">") - .replaceAll(""", '"') - .replaceAll("'", "'"); - const readExecutableMetadata = async ( handle: FileHandle, hostArchitecture: NodeJS.Architecture, diff --git a/tests/boundary/filesystem/binaryTarget.test.ts b/tests/boundary/filesystem/binaryTarget.test.ts index 227b3bc4a9..8597420a58 100644 --- a/tests/boundary/filesystem/binaryTarget.test.ts +++ b/tests/boundary/filesystem/binaryTarget.test.ts @@ -187,6 +187,62 @@ describe("app executable filename fidelity", () => { }, ); + it.each([ + ["an escaped entity reference", "a&lt;b", "a<b"], + ["a numeric character reference", "a b", "a b"], + ["a CDATA section", "", "a&b"], + ])( + "decodes an XML plist executable name containing %s", + async (_case, encoded, name) => { + const directory = await createTestTempDirectory("rea-app-xml-name-"); + const app = join(directory, "Encoded.app"); + const contents = join(app, "Contents"); + const executable = join(contents, "MacOS", name); + await mkdir(join(contents, "MacOS"), { recursive: true }); + await writeFile( + join(contents, "Info.plist"), + `CFBundleExecutable${encoded}`, + ); + await writeFile(executable, thinMach(0xfeedfacf, 0x0100000c)); + const result = await parseBinaryTarget(app, directory, "arm64"); + expect(result.ok && result.value).toMatchObject({ + path: await realpath(executable), + format: "mach-o", + }); + }, + ); + + it("reads the top-level executable key rather than commented or nested text", async () => { + const directory = await createTestTempDirectory("rea-app-xml-key-"); + const app = join(directory, "Nested.app"); + const contents = join(app, "Contents"); + const executable = join(contents, "MacOS", "App"); + await mkdir(join(contents, "MacOS"), { recursive: true }); + await writeFile( + join(contents, "Info.plist"), + [ + "", + "", + "NSExtensionCFBundleExecutableInner", + "CFBundleExecutableApp", + "", + ].join(""), + ); + await writeFile(executable, thinMach(0xfeedfacf, 0x0100000c)); + await writeFile( + join(contents, "MacOS", "Old"), + thinMach(0xfeedfacf, 0x0100000c), + ); + await writeFile( + join(contents, "MacOS", "Inner"), + thinMach(0xfeedfacf, 0x0100000c), + ); + const result = await parseBinaryTarget(app, directory, "arm64"); + expect(result.ok && result.value).toMatchObject({ + path: await realpath(executable), + }); + }); + it.skipIf(process.platform !== "darwin")( "preserves executable filename whitespace from native binary plists", async () => {