Skip to content

Commit 1e2a84d

Browse files
jmylchreestHona
authored andcommitted
fix: resolve symlinks in Instance cache to prevent duplicate contexts (anomalyco#16651)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
1 parent 73fab3e commit 1e2a84d

2 files changed

Lines changed: 61 additions & 1 deletion

File tree

packages/opencode/src/util/filesystem.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,8 +114,16 @@ export namespace Filesystem {
114114
}
115115

116116
// 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.
117+
// Also resolves symlinks so that callers using the result as a cache key
118+
// always get the same canonical path for a given physical directory.
117119
export function resolve(p: string): string {
118-
return normalizePath(pathResolve(windowsPath(p)))
120+
const resolved = pathResolve(windowsPath(p))
121+
try {
122+
return normalizePath(realpathSync(resolved))
123+
} catch (e) {
124+
if (isEnoent(e)) return normalizePath(resolved)
125+
throw e
126+
}
119127
}
120128

121129
export function windowsPath(p: string): string {

packages/opencode/test/util/filesystem.test.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -502,5 +502,57 @@ describe("filesystem", () => {
502502
const drive = tmp.path[0].toLowerCase()
503503
expect(Filesystem.resolve(`/mnt/${drive}`)).toBe(Filesystem.resolve(`${drive.toUpperCase()}:/`))
504504
})
505+
506+
test("resolves symlinked directory to canonical path", async () => {
507+
await using tmp = await tmpdir()
508+
const target = path.join(tmp.path, "real")
509+
await fs.mkdir(target)
510+
const link = path.join(tmp.path, "link")
511+
await fs.symlink(target, link)
512+
expect(Filesystem.resolve(link)).toBe(Filesystem.resolve(target))
513+
})
514+
515+
test("returns unresolved path when target does not exist", async () => {
516+
await using tmp = await tmpdir()
517+
const missing = path.join(tmp.path, "does-not-exist-" + Date.now())
518+
const result = Filesystem.resolve(missing)
519+
expect(result).toBe(Filesystem.normalizePath(path.resolve(missing)))
520+
})
521+
522+
test("throws ELOOP on symlink cycle", async () => {
523+
await using tmp = await tmpdir()
524+
const a = path.join(tmp.path, "a")
525+
const b = path.join(tmp.path, "b")
526+
await fs.symlink(b, a)
527+
await fs.symlink(a, b)
528+
expect(() => Filesystem.resolve(a)).toThrow()
529+
})
530+
531+
// Windows: chmod(0o000) is a no-op, so EACCES cannot be triggered
532+
test("throws EACCES on permission-denied symlink target", async () => {
533+
if (process.platform === "win32") return
534+
if (process.getuid?.() === 0) return // skip when running as root
535+
await using tmp = await tmpdir()
536+
const dir = path.join(tmp.path, "restricted")
537+
await fs.mkdir(dir)
538+
const link = path.join(tmp.path, "link")
539+
await fs.symlink(dir, link)
540+
await fs.chmod(dir, 0o000)
541+
try {
542+
expect(() => Filesystem.resolve(path.join(link, "child"))).toThrow()
543+
} finally {
544+
await fs.chmod(dir, 0o755)
545+
}
546+
})
547+
548+
// Windows: traversing through a file throws ENOENT (not ENOTDIR),
549+
// which resolve() catches as a fallback instead of rethrowing
550+
test("rethrows non-ENOENT errors", async () => {
551+
if (process.platform === "win32") return
552+
await using tmp = await tmpdir()
553+
const file = path.join(tmp.path, "not-a-directory")
554+
await fs.writeFile(file, "x")
555+
expect(() => Filesystem.resolve(path.join(file, "child"))).toThrow()
556+
})
505557
})
506558
})

0 commit comments

Comments
 (0)