From 9be640a8b42cf81ace04d2b97eae3ec28cfc897a Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 27 Oct 2026 09:31:09 +0800 Subject: [PATCH] fix(providers): preserve selected filesystem identities --- src/electron/ElectronFusePath.ts | 16 +++++- src/electron/ElectronFuseProvider.test.ts | 31 +++++++++++ src/ghidra/GhidraProjectSnapshot.ts | 7 +++ .../ghidra/ghidraProjectSnapshot.test.ts | 54 ++++++++++++++++++- 4 files changed, 106 insertions(+), 2 deletions(-) diff --git a/src/electron/ElectronFusePath.ts b/src/electron/ElectronFusePath.ts index e112723894..0a03bff64f 100644 --- a/src/electron/ElectronFusePath.ts +++ b/src/electron/ElectronFusePath.ts @@ -210,7 +210,21 @@ const resolveInside = async ( nodes.push({ path: absolute, metadata }); hops += 1; if (hops > MAX_SYMLINK_HOPS) return { kind: "loop" }; - const target = await readlink(absolute); + const rawTarget = await readlink(absolute, { encoding: "buffer" }); + let target: string; + try { + // A replacement character can alias a different, valid sibling name. + // Preserve a literal UTF-8 BOM too: it is part of a filesystem name. + target = new TextDecoder("utf-8", { + fatal: true, + ignoreBOM: true, + }).decode(rawTarget); + } catch { + throw new ElectronFusePathFailure( + "invalid_value", + `Framework symlink target is not valid UTF-8 (raw target bytes: ${rawTarget.toString("hex")}): ${absolute}`, + ); + } // Lexical only: another alias of this root (/tmp vs /private/tmp) // is still an escape. Later hops stay lstat- and containment-checked. const absoluteTarget = isAbsolute(target); diff --git a/src/electron/ElectronFuseProvider.test.ts b/src/electron/ElectronFuseProvider.test.ts index bf95f55d6b..af36e1696c 100644 --- a/src/electron/ElectronFuseProvider.test.ts +++ b/src/electron/ElectronFuseProvider.test.ts @@ -41,6 +41,37 @@ const inspect = async (path: string) => { return result.value; }; +it.skipIf(process.platform === "win32")( + "rejects undecodable symlink targets rather than selecting a replacement sibling", + async () => { + const root = await createTestTempDirectory("rea-electron-link-encoding-"); + const framework = join(root, "Electron Framework.framework"); + await mkdir(framework); + await writeFile(join(framework, "\uFFFD"), thinFuseImage([0x31]).bytes); + const alias = join(framework, "alias"); + await symlink(Buffer.from([0xff]), alias); + await expect(stat(alias)).rejects.toMatchObject({ code: "ENOENT" }); + const result = await provider.inspect({ path: alias }); + expect(result.ok).toBe(false); + if (!result.ok) + expect(result.error).toMatchObject({ + issues: [ + { + reason: "invalid_value", + message: expect.stringContaining("not valid UTF-8"), + }, + ], + }); + const validName = "\uFEFFbinary"; + await writeFile(join(framework, validName), thinFuseImage([0x31]).bytes); + const validAlias = join(framework, "valid-alias"); + await symlink(validName, validAlias); + expect( + (await inspect(validAlias)).slices[0]?.sentinels[0]?.fuses[0]?.state, + ).toBe("enabled"); + }, +); + it.skipIf(process.platform === "win32")( "rejects framework links whose terminal separator requires a directory", async () => { diff --git a/src/ghidra/GhidraProjectSnapshot.ts b/src/ghidra/GhidraProjectSnapshot.ts index 42f9c97294..6caba85a13 100644 --- a/src/ghidra/GhidraProjectSnapshot.ts +++ b/src/ghidra/GhidraProjectSnapshot.ts @@ -167,6 +167,13 @@ export const resolveGhidraProject = async ( "resolve_analysis_profile", async () => { assertArtifactReadActive(signal); + // Admit the caller's exact marker before canonicalizing its parent. + // Darwin realpath(file/..) can succeed despite native lookup rejecting + // that traversal; hashing the canonical sibling would select a new input. + if (!(await lstat(selection.path)).isFile()) + throw selectionFailure( + `Existing Ghidra project marker must be a regular file: ${selection.path}`, + ); const projectName = basename(selection.path, ".gpr"); const projectPath = await realpath(dirname(selection.path)); const program = ghidraProgramNameSchema.safeParse( diff --git a/tests/boundary/providers/ghidra/ghidraProjectSnapshot.test.ts b/tests/boundary/providers/ghidra/ghidraProjectSnapshot.test.ts index d1d7ab548f..786f8d76c2 100644 --- a/tests/boundary/providers/ghidra/ghidraProjectSnapshot.test.ts +++ b/tests/boundary/providers/ghidra/ghidraProjectSnapshot.test.ts @@ -1,4 +1,12 @@ -import { mkdir, readFile, readdir, symlink, writeFile } from "node:fs/promises"; +import { + mkdir, + readFile, + readdir, + realpath, + stat, + symlink, + writeFile, +} from "node:fs/promises"; import { join } from "node:path"; import { afterEach, beforeEach, expect, it } from "vitest"; import { ArtifactResourceScope } from "../../../../src/artifacts/ArtifactResourceScope.js"; @@ -170,3 +178,47 @@ it.each([null, "caller stopped", { kind: "stop-project-read" }])( ).rejects.toMatchObject({ _tag: "AnalysisCancelledError", cause: reason }); }, ); + +it.skipIf(process.platform === "win32")( + "rejects a selected marker with a non-directory traversal before canonicalizing its parent", + async () => { + const root = await createTestTempDirectory("rea-ghidra-project-traversal-"); + await writeFile(join(root, "file"), "not a directory"); + await writeFile(join(root, "project.gpr"), "marker"); + await mkdir(join(root, "project.rep")); + const path = `${root}/file/../project.gpr`; + await expect(stat(path)).rejects.toMatchObject({ code: "ENOTDIR" }); + await expect( + resolveGhidraProject({ path }, { path: join(root, "target") }, resources), + ).rejects.toMatchObject({ diagnostics: { system_code: "ENOTDIR" } }); + }, +); + +it.skipIf(process.platform === "win32")( + "follows native parent symlinks and preserves missing marker failures", + async () => { + const root = await createTestTempDirectory("rea-ghidra-project-selection-"); + await mkdir(join(root, "actual", "nested"), { recursive: true }); + await symlink("actual/nested", join(root, "link")); + await writeFile(join(root, "actual", "project.gpr"), "marker"); + await mkdir(join(root, "actual", "project.rep")); + const path = `${root}/link/../project.gpr`; + const commitment = await resolveGhidraProject( + { path }, + { path: join(root, "target") }, + resources, + ); + expect(commitment).toMatchObject({ + configured_path: path, + project_path: await realpath(join(root, "actual")), + project_name: "project", + }); + await expect( + resolveGhidraProject( + { path: join(root, "missing.gpr") }, + { path: join(root, "target") }, + resources, + ), + ).rejects.toMatchObject({ diagnostics: { system_code: "ENOENT" } }); + }, +);