From 583a316e118e9f82cbc036eb02fad1d5c8362e99 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Thu, 29 Oct 2026 05:08:17 +0800 Subject: [PATCH] fix(macho): preserve POSIX app executable names (#7888) --- src/application/AppBundleExecutable.ts | 6 ++- .../acceptance/appBundleBackslashCli.test.ts | 48 +++++++++++++++++++ .../boundary/filesystem/binaryTarget.test.ts | 42 ++++++++++++++++ 3 files changed, 94 insertions(+), 2 deletions(-) create mode 100644 tests/acceptance/appBundleBackslashCli.test.ts diff --git a/src/application/AppBundleExecutable.ts b/src/application/AppBundleExecutable.ts index 955ed7a239..42a18c594f 100644 --- a/src/application/AppBundleExecutable.ts +++ b/src/application/AppBundleExecutable.ts @@ -1,5 +1,5 @@ import { lstat, opendir, readFile, realpath, stat } from "node:fs/promises"; -import { join } from "node:path"; +import { join, sep } from "node:path"; import { z } from "zod"; @@ -357,9 +357,11 @@ const parseBinaryPlistExecutable = (plist: Buffer): string => { return executableName(decodeBinaryPlistValue(plist, plan)); }; +/** Names are one local path component; backslash is literal on POSIX hosts. */ const isSafeExecutableName = (name: string): boolean => name.length > 0 && name !== "." && name !== ".." && !name.includes("\0") && - !/[/\\]/u.test(name); + !name.includes("/") && + (sep !== "\\" || !name.includes("\\")); diff --git a/tests/acceptance/appBundleBackslashCli.test.ts b/tests/acceptance/appBundleBackslashCli.test.ts new file mode 100644 index 0000000000..49fb580caf --- /dev/null +++ b/tests/acceptance/appBundleBackslashCli.test.ts @@ -0,0 +1,48 @@ +import { + copyFile, + mkdir, + readFile, + realpath, + writeFile, +} from "node:fs/promises"; +import { join } from "node:path"; +import { expect } from "vitest"; +import { parseEvidence } from "../../src/domain/evidence.js"; +import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; +import { cliTest } from "../support/cli/cliFixture.js"; + +cliTest.skipIf(process.platform !== "darwin")( + "inspects a bundle with a literal backslash in its executable name", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-app-backslash-"); + const app = join(root, "Back.app"); + const programs = join(app, "Contents", "MacOS"); + const executable = join(programs, "App\\Name"); + await mkdir(programs, { recursive: true }); + await writeFile( + join(app, "Contents", "Info.plist"), + "CFBundleExecutableApp\\Name", + ); + await copyFile("/usr/bin/true", executable); + const original = await readFile(executable); + const canonical = await realpath(executable); + for (const command of [ + "inspect-macho", + "list-architectures", + "inspect-signature", + ]) { + const result = await cli.run({ + arguments: [command, app, "--json"], + environment: + process.env.DEVELOPER_DIR === undefined + ? {} + : { DEVELOPER_DIR: process.env.DEVELOPER_DIR }, + }); + expect(result.exitCode, result.stderr).toBe(0); + const evidence = parseEvidence(result.json); + expect(evidence.caller_input_path).toBe(app); + expect(evidence.subject?.local_path).toBe(canonical); + } + expect((await readFile(executable)).equals(original)).toBe(true); + }, +); diff --git a/tests/boundary/filesystem/binaryTarget.test.ts b/tests/boundary/filesystem/binaryTarget.test.ts index f4ef009fd0..3bfab1c2e7 100644 --- a/tests/boundary/filesystem/binaryTarget.test.ts +++ b/tests/boundary/filesystem/binaryTarget.test.ts @@ -662,6 +662,48 @@ describe("binary target I/O: filesystem and app bundle target resolution", () => }); }); + it.skipIf(process.platform === "win32")( + "preserves a literal backslash in a POSIX app executable name", + async () => { + const directory = await createTestTempDirectory("rea-target-"); + const app = join(directory, "Back.app"); + const programs = join(app, "Contents", "MacOS"); + const executable = join(programs, "App\\Name"); + await mkdir(programs, { recursive: true }); + await writeFile( + join(app, "Contents", "Info.plist"), + "CFBundleExecutableApp\\Name", + ); + 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.skipIf(process.platform !== "win32")( + "rejects a Windows separator in an app executable declaration", + async () => { + const directory = await createTestTempDirectory("rea-target-"); + const app = join(directory, "Broken.app"); + await mkdir(join(app, "Contents", "MacOS"), { recursive: true }); + await writeFile( + join(app, "Contents", "Info.plist"), + "CFBundleExecutable..\\escape", + ); + const result = await parseBinaryTarget(app, { cwd: directory }); + expect(result).toMatchObject({ + ok: false, + error: { reason: "app has an unsafe CFBundleExecutable" }, + }); + }, + ); + it.each([ ["missing plist", undefined], ["missing executable name", ""],