diff --git a/packages/core/src/filesystem.ts b/packages/core/src/filesystem.ts index 13f33013..c70171c0 100644 --- a/packages/core/src/filesystem.ts +++ b/packages/core/src/filesystem.ts @@ -1,6 +1,6 @@ import { NodeFileSystem } from "@effect/platform-node" import { dirname, join, relative, resolve as pathResolve, win32 } from "path" -import { existsSync, realpathSync } from "fs" +import * as fs from "fs" import * as NFS from "fs/promises" import { lookup } from "mime-types" import { Context, Effect, FileSystem, Layer, Schema } from "effect" @@ -190,7 +190,7 @@ export namespace AppFileSystem { if (process.platform !== "win32") return path const resolved = normalizeWindowsPath(path, options) try { - return realpathSync.native(resolved) + return fs.realpathSync.native(resolved) } catch { return resolved } @@ -202,13 +202,13 @@ export namespace AppFileSystem { const match = path.match(/^(.*)[\\/]\*$/) if (!match) return normalizePath(path, options) const dir = /^[A-Za-z]:$/.test(match[1]) ? match[1] + "\\" : match[1] - return join(normalizePath(dir, options), "*") + return win32.join(normalizePath(dir, options), "*") } export function resolve(path: string, options?: WindowsPathOptions): string { const resolved = process.platform === "win32" ? normalizeWindowsPath(path, options) : pathResolve(path) try { - return normalizePath(realpathSync(resolved)) + return normalizePath(fs.realpathSync(resolved)) } catch (error: any) { if (error?.code === "ENOENT") return normalizePath(resolved) throw error @@ -232,8 +232,12 @@ export namespace AppFileSystem { base?: string driveRoots?: string[] exists?: (path: string) => boolean + cache?: Map } + const rootedWindowsVariantCache = new Map() + let rootedWindowsVariantCacheEnv = "" + // Rooted but driveless paths (e.g. "/users/runner/...") arrive when callers // strip the drive letter before handing the path back. path.resolve would // bind such a path to the cwd's drive, which silently mismatches when the @@ -256,16 +260,52 @@ export namespace AppFileSystem { function resolveRootedWindowsVariant(p: string, options: WindowsPathOptions): string | undefined { if (!isRootedDriveless(p)) return + const useDefaultCache = !options.driveRoots && !options.exists + const cacheKey = rootedWindowsVariantCacheKey(p) + const cache = options.cache ?? (useDefaultCache ? rootedWindowsVariantCache : undefined) + if (useDefaultCache) { + refreshRootedWindowsVariantCache() + } + if (cache) { + const cached = cache.get(cacheKey) + if (cached && (options.exists ?? fs.existsSync)(cached)) return cached + if (cached) cache.delete(cacheKey) + } const suffix = p.replace(/^[\\/]+/, "").replaceAll("/", "\\") const matches: string[] = [] + const exists = options.exists ?? fs.existsSync for (const root of uniqueWindowsDriveRoots(options.driveRoots ?? windowsDriveRoots())) { const candidate = win32.join(root, suffix) - if ((options.exists ?? existsSync)(candidate)) matches.push(candidate) + if (exists(candidate)) matches.push(candidate) } if (matches.length > 1) { throw new Error(`Ambiguous Windows path ${p}; use a drive-qualified path.`) } - return matches[0] + const match = matches[0] + if (cache && match) cache.set(cacheKey, match) + return match + } + + function rootedWindowsVariantCacheKey(p: string): string { + return win32.normalize(p).toUpperCase() + } + + function rootedWindowsVariantCacheEnvKey(): string { + const cwdRoot = uppercaseDriveRoot(win32.parse(process.cwd()).root || "") + const systemDrive = (process.env.SystemDrive || "").toUpperCase() + return `${cwdRoot}|${systemDrive}` + } + + function refreshRootedWindowsVariantCache() { + const next = rootedWindowsVariantCacheEnvKey() + if (next === rootedWindowsVariantCacheEnv) return + rootedWindowsVariantCacheEnv = next + rootedWindowsVariantCache.clear() + } + + export function resetWindowsRootedVariantCacheForTest() { + rootedWindowsVariantCacheEnv = "" + rootedWindowsVariantCache.clear() } function uniqueWindowsDriveRoots(roots: string[]): string[] { diff --git a/packages/core/test/filesystem/normalize-path.test.ts b/packages/core/test/filesystem/normalize-path.test.ts index 557db5de..9f5e4ac9 100644 --- a/packages/core/test/filesystem/normalize-path.test.ts +++ b/packages/core/test/filesystem/normalize-path.test.ts @@ -38,6 +38,14 @@ test("normalizePath folds extended-length UNC paths on Windows", () => { }) }) +test("normalizePathPattern preserves UNC glob roots on Windows", () => { + withWin32Platform(() => { + expect(AppFileSystem.normalizePathPattern("\\\\?\\UNC\\server\\share\\dir\\*")).toBe( + "\\\\server\\share\\dir\\*", + ) + }) +}) + test("normalizeWindowsPath resolves non-existing rooted-driveless paths from an explicit base drive", () => { expect(AppFileSystem.normalizeWindowsPath("\\future\\file.txt", { base: "D:\\project\\work" })).toBe( "D:\\future\\file.txt", @@ -67,6 +75,51 @@ test("normalizeWindowsPath de-duplicates caller-supplied drive roots before ambi ).toBe("C:\\shared\\file.txt") }) +test("normalizeWindowsPath caches rooted-driveless matches until the cached path disappears", () => { + withWin32Platform(() => { + AppFileSystem.resetWindowsRootedVariantCacheForTest() + const cache = new Map() + const live = new Map([ + ["C:\\shared\\file.txt", false], + ["D:\\shared\\file.txt", true], + ]) + let probes = 0 + const exists = (candidate: string) => { + probes += 1 + return live.get(candidate) ?? false + } + + const first = AppFileSystem.normalizeWindowsPath("\\shared\\file.txt", { + cache, + exists, + driveRoots: ["C:\\", "D:\\"], + }) + const probesAfterFirst = probes + const second = AppFileSystem.normalizeWindowsPath("\\shared\\file.txt", { + cache, + exists, + driveRoots: ["C:\\", "D:\\"], + }) + + expect(first).toBe("D:\\shared\\file.txt") + expect(second).toBe("D:\\shared\\file.txt") + expect(probesAfterFirst).toBe(2) + expect(probes - probesAfterFirst).toBe(1) + + live.set("D:\\shared\\file.txt", false) + live.set("E:\\shared\\file.txt", true) + + const refreshed = AppFileSystem.normalizeWindowsPath("\\shared\\file.txt", { + cache, + exists, + driveRoots: ["C:\\", "D:\\", "E:\\"], + }) + + expect(refreshed).toBe("E:\\shared\\file.txt") + expect(probes - probesAfterFirst).toBe(5) + }) +}) + test.skipIf(process.platform !== "win32")( "normalizePath probes drive roots for rooted-but-driveless paths to existing files", () => { diff --git a/packages/opencode/src/tool/external-directory.ts b/packages/opencode/src/tool/external-directory.ts index 755fee81..2a7ba65d 100644 --- a/packages/opencode/src/tool/external-directory.ts +++ b/packages/opencode/src/tool/external-directory.ts @@ -146,10 +146,15 @@ export const assertExternalDirectoryEffect = Effect.fn("Tool.assertExternalDirec if (Instance.containsPath(resolved, scope)) return full const kind = options?.kind ?? "file" - const dir = kind === "directory" ? resolved : path.dirname(resolved) + const dir = + kind === "directory" + ? resolved + : process.platform === "win32" + ? path.win32.dirname(resolved) + : path.dirname(resolved) const glob = process.platform === "win32" - ? AppFileSystem.normalizePathPattern(path.join(dir, "*"), { base: ins.directory }) + ? AppFileSystem.normalizePathPattern(path.win32.join(dir, "*"), { base: ins.directory }) : path.join(dir, "*").replaceAll("\\", "/") yield* ctx.ask({ diff --git a/packages/opencode/test/tool/external-directory.test.ts b/packages/opencode/test/tool/external-directory.test.ts index d9ca9138..07adfdd5 100644 --- a/packages/opencode/test/tool/external-directory.test.ts +++ b/packages/opencode/test/tool/external-directory.test.ts @@ -242,6 +242,27 @@ describe("tool.assertExternalDirectory", () => { }) }) + test("returns the canonical UNC target used for permission metadata", async () => { + await withWin32Platform(async () => { + const { requests, ctx } = makeCtx() + + await Instance.provide({ + directory: "D:\\project", + fn: async () => { + const target = await assertExternalDirectory(ctx, "\\\\?\\UNC\\server\\share\\outside\\file.txt") + expect(target).toBe("\\\\server\\share\\outside\\file.txt") + }, + }) + + const req = requests.find((r) => r.permission === "external_directory") + expect(req).toBeDefined() + expect(req!.patterns).toEqual(["\\\\server\\share\\outside\\*"]) + expect(req!.always).toEqual(["\\\\server\\share\\outside\\*"]) + expect(req!.metadata.filepath).toBe("\\\\server\\share\\outside\\file.txt") + expect(req!.metadata.realpath).toBe("\\\\server\\share\\outside\\file.txt") + }) + }) + test("resolves Windows junction traversal before dot-dot normalization", async () => { await withWin32Platform(async () => { const junction = "C:\\project\\tmp\\link" @@ -280,6 +301,20 @@ describe("tool.assertExternalDirectory", () => { }) }) + test("resolves extended UNC share paths without dropping the share root", async () => { + await withWin32Platform(async () => { + const resolved = resolveExternalPathForPermission("\\\\?\\UNC\\server\\share\\dir\\file.txt", "D:\\project", { + lstat: (_candidate) => + ({ + isSymbolicLink: () => false, + }) as ReturnType, + realpath: (candidate) => candidate, + }) + + expect(resolved).toBe("\\\\server\\share\\dir\\file.txt") + }) + }) + if (process.platform === "win32") { test("normalizes Windows path variants to one glob", async () => { const { requests, ctx } = makeCtx()