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", ""],