diff --git a/packages/opencode/src/util/filesystem.ts b/packages/opencode/src/util/filesystem.ts index fb1f5ab9e53..37f00c6b9c8 100644 --- a/packages/opencode/src/util/filesystem.ts +++ b/packages/opencode/src/util/filesystem.ts @@ -114,8 +114,16 @@ export namespace Filesystem { } // We cannot rely on path.resolve() here because git.exe may come from Git Bash, Cygwin, or MSYS2, so we need to translate these paths at the boundary. + // Also resolves symlinks so that callers using the result as a cache key + // always get the same canonical path for a given physical directory. export function resolve(p: string): string { - return normalizePath(pathResolve(windowsPath(p))) + const resolved = pathResolve(windowsPath(p)) + try { + return normalizePath(realpathSync(resolved)) + } catch (e) { + if (isEnoent(e)) return normalizePath(resolved) + throw e + } } export function windowsPath(p: string): string { diff --git a/packages/opencode/test/util/filesystem.test.ts b/packages/opencode/test/util/filesystem.test.ts index c757e3424db..dfb5791fc56 100644 --- a/packages/opencode/test/util/filesystem.test.ts +++ b/packages/opencode/test/util/filesystem.test.ts @@ -502,5 +502,67 @@ describe("filesystem", () => { const drive = tmp.path[0].toLowerCase() expect(Filesystem.resolve(`/mnt/${drive}`)).toBe(Filesystem.resolve(`${drive.toUpperCase()}:/`)) }) + + test("resolves symlinked directory to canonical path", async () => { + if (process.platform === "win32") return + await using tmp = await tmpdir() + const link = tmp.path + "-symlink" + await fs.symlink(tmp.path, link) + try { + expect(Filesystem.resolve(link)).toBe(Filesystem.resolve(tmp.path)) + } finally { + await fs.unlink(link).catch(() => {}) + } + }) + + test("returns unresolved path when target does not exist", async () => { + await using tmp = await tmpdir() + const nonExistent = path.join(tmp.path, "does-not-exist-" + Date.now()) + const result = Filesystem.resolve(nonExistent) + expect(result).toBe(Filesystem.normalizePath(path.resolve(nonExistent))) + }) + + test("throws ELOOP on symlink cycle", async () => { + if (process.platform === "win32") return + await using tmp = await tmpdir() + const a = path.join(tmp.path, "a") + const b = path.join(tmp.path, "b") + await fs.symlink(b, a) + await fs.symlink(a, b) + expect(() => Filesystem.resolve(a)).toThrow() + }) + + test("throws EACCES on permission-denied symlink target", async () => { + if (process.platform === "win32") return + if (process.getuid?.() === 0) return // skip when running as root + await using tmp = await tmpdir() + const dir = path.join(tmp.path, "restricted") + await fs.mkdir(dir) + const link = path.join(tmp.path, "link") + await fs.symlink(dir, link) + // Remove all permissions from the target directory's parent entry + // realpathSync needs execute permission on each component + await fs.chmod(dir, 0o000) + try { + // Resolving a path *inside* the restricted dir should throw EACCES + expect(() => Filesystem.resolve(path.join(link, "child"))).toThrow() + } finally { + await fs.chmod(dir, 0o755) + } + }) + + test("rethrows non-ENOENT errors", () => { + // Verify the contract: only ENOENT is caught, other errors propagate + // We test this indirectly via ELOOP above, but also verify ENOTDIR + if (process.platform === "win32") return + const tmpFile = path.join(process.env.TMPDIR || "/tmp", `oc-test-${Date.now()}`) + require("fs").writeFileSync(tmpFile, "not-a-directory") + try { + // Treating a file as a directory component should throw ENOTDIR + expect(() => Filesystem.resolve(path.join(tmpFile, "child"))).toThrow() + } finally { + require("fs").unlinkSync(tmpFile) + } + }) }) })