diff --git a/src/application/AppBundleExecutable.ts b/src/application/AppBundleExecutable.ts
index 3303b44478..955ed7a239 100644
--- a/src/application/AppBundleExecutable.ts
+++ b/src/application/AppBundleExecutable.ts
@@ -119,6 +119,9 @@ export const findAppBundleResourceRoot = async (
options.cwd === undefined ? {} : { cwd: options.cwd },
);
try {
+ // Admit the selected spelling before Darwin realpath can collapse file/..
+ // into a different, otherwise valid bundle resource root.
+ await stat(callerPath);
const canonicalInput = await realpath(callerPath);
const directLayout = await presentLayout(canonicalInput);
const wrapped =
diff --git a/src/application/AppBundleResourceRoot.test.ts b/src/application/AppBundleResourceRoot.test.ts
index c8f76a12bb..fd1a716a08 100644
--- a/src/application/AppBundleResourceRoot.test.ts
+++ b/src/application/AppBundleResourceRoot.test.ts
@@ -2,6 +2,7 @@ import {
chmod,
mkdir,
mkdtemp,
+ realpath,
rm,
symlink,
writeFile,
@@ -57,6 +58,32 @@ it("resolves resource roots without requiring or hashing CFBundleExecutable", as
}
});
+it.skipIf(process.platform === "win32")(
+ "preserves valid symlink-parent traversal when selecting resource roots",
+ async () => {
+ const temp = await mkdtemp(join(tmpdir(), "rea-resource-parent-"));
+ try {
+ await mkdir(join(temp, "physical", "child"), { recursive: true });
+ await mkdir(join(temp, "physical", "Fixture.app"));
+ await writeFile(
+ join(temp, "physical", "Fixture.app", "Info.plist"),
+ "",
+ );
+ await symlink(join(temp, "physical", "child"), join(temp, "alias"));
+ const selected = `${temp}/alias/../Fixture.app`;
+ const resolved = await resolveAppBundleResourceRoot(selected);
+ expect(resolved.ok).toBe(true);
+ if (!resolved.ok) throw resolved.error;
+ expect(resolved.value.callerPath).toBe(selected);
+ expect(resolved.value.canonicalPath).toBe(
+ await realpath(join(temp, "physical", "Fixture.app")),
+ );
+ } finally {
+ await rm(temp, { recursive: true, force: true });
+ }
+ },
+);
+
it("resolves a flat bundle resource root without inferring executable identity", async () => {
const temp = await mkdtemp(join(tmpdir(), "rea-ios-resource-root-"));
try {
diff --git a/src/application/binary/BinarySessionBundleResources.test.ts b/src/application/binary/BinarySessionBundleResources.test.ts
index 76e7d574bd..506ec45635 100644
--- a/src/application/binary/BinarySessionBundleResources.test.ts
+++ b/src/application/binary/BinarySessionBundleResources.test.ts
@@ -1,4 +1,4 @@
-import { mkdtemp, rm, writeFile } from "node:fs/promises";
+import { mkdir, mkdtemp, rm, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, expect, it } from "vitest";
@@ -44,6 +44,35 @@ afterEach(async () => {
);
});
+it.skipIf(process.platform === "win32")(
+ "refuses invalid selected traversal before creating a bundle resource client",
+ async () => {
+ const root = await mkdtemp(join(tmpdir(), "rea-resource-selected-"));
+ temporaryDirectories.push(root);
+ await mkdir(join(root, "Fixture.app"));
+ await writeFile(
+ join(root, "Fixture.app", "Info.plist"),
+ "",
+ );
+ await writeFile(join(root, "regular"), "file");
+ let created = false;
+ const session = new BinarySession(
+ SessionProviderRouter.selectable(new AnalysisProviderRegistry([]), []),
+ new InvestigationRecords(),
+ {
+ createClient: () => {
+ created = true;
+ throw new Error("Invalid selection must not create a client");
+ },
+ },
+ );
+ const selected = `${root}/regular/../Fixture.app`;
+ await expect(stat(selected)).rejects.toMatchObject({ code: "ENOTDIR" });
+ expect((await session.openBundleResources(selected)).ok).toBe(false);
+ expect(created).toBe(false);
+ },
+);
+
it("opens a resource-only bundle without executable identity and keeps snapshot operations inapplicable", async () => {
const rootPath = await mkdtemp(join(tmpdir(), "rea-resource-session-"));
temporaryDirectories.push(rootPath);