From a3f6b4006e3d59a8b4c740d4ff46b32c91fd16dd Mon Sep 17 00:00:00 2001 From: Armando Vaquera <263793884+proyectoauraorg@users.noreply.github.com> Date: Sun, 31 May 2026 18:34:04 -0600 Subject: [PATCH 1/3] =?UTF-8?q?feat(security):=20introduce=20WorkspacePath?= =?UTF-8?q?Resolver=20=E2=80=94=20async=20symlink-aware=20path=20canonical?= =?UTF-8?q?ization=20(#389)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First of four sub-issues under #169. Adds `src/utils/WorkspacePathResolver.ts` with a single async `resolveRealPath(target)` that canonicalizes a path by following symlinks via `fs.promises.realpath`: - Walks up to the nearest existing ancestor on ENOENT and re-appends the trailing segments, so not-yet-created files under a symlinked ancestor still resolve correctly. - Re-throws non-ENOENT errors (EACCES, ELOOP) so callers can fail closed rather than fall back to a lexical path. - Case-normalizes the result on macOS/Windows for reliable comparison against uri.fsPath. Pure utility — no workspace policy, settings, or tool changes (those land in WorkspaceFileAccess (#390) and the migrations in #391/#392). Covered by integration tests using real symlinks in a temp directory. --- src/utils/WorkspacePathResolver.ts | 66 +++++++++ .../__tests__/WorkspacePathResolver.spec.ts | 134 ++++++++++++++++++ 2 files changed, 200 insertions(+) create mode 100644 src/utils/WorkspacePathResolver.ts create mode 100644 src/utils/__tests__/WorkspacePathResolver.spec.ts diff --git a/src/utils/WorkspacePathResolver.ts b/src/utils/WorkspacePathResolver.ts new file mode 100644 index 0000000000..dbab90bce3 --- /dev/null +++ b/src/utils/WorkspacePathResolver.ts @@ -0,0 +1,66 @@ +import * as path from "path" +import * as fs from "fs/promises" + +/** Narrow an unknown error to a Node errno exception with the given `code`. */ +function isErrnoException(err: unknown, code: string): boolean { + return err instanceof Error && (err as NodeJS.ErrnoException).code === code +} + +// macOS APFS/HFS+ and Windows are case-insensitive: `realpath` can return a different case +// than the one VS Code registered, so we lowercase the result before returning to keep later +// comparisons (e.g. against `uri.fsPath`) reliable on those platforms only. Platform is read at +// call time (not cached) so the behavior stays correct and testable. +function normalizeCase(p: string): string { + const caseInsensitive = process.platform === "darwin" || process.platform === "win32" + return caseInsensitive ? p.toLowerCase() : p +} + +/** + * Resolve a filesystem path to its canonical, symlink-followed form. + * + * This is the canonicalization primitive for the workspace boundary check (issue #169). It owns + * **only** path resolution — no workspace policy, no settings, no tool logic. The authorization + * decision (and the `allowSymlinksOutsideWorkspace` opt-in) lives in `WorkspaceFileAccess`. + * + * Behavior: + * - **Async only** (`fs.promises.realpath`); never blocks the extension host event loop. + * - If `target` does not exist yet (e.g. a file about to be created), the realpath of the nearest + * existing ancestor is resolved and the remaining segments are re-appended, so a symlink + * anywhere along the path is still followed while not-yet-created paths can still be evaluated. + * - Only `ENOENT` triggers the walk-up. Any other error (e.g. `EACCES`, `ELOOP`) is **re-thrown** + * so a caller performing a security check can fail closed. Silently walking up would mask the + * symlink and could make an out-of-workspace target look "inside" (#169). + * - The result is case-normalized on case-insensitive filesystems (macOS, Windows). + * + * Workspace folder paths should be resolved through this same function by callers, since a folder + * may itself be reached via a symlink. + */ +export async function resolveRealPath(target: string): Promise { + let current = path.resolve(target) + const trailing: string[] = [] + + // Walk up until an existing path can be resolved, bounded by the filesystem root. + while (true) { + try { + const resolved = await fs.realpath(current) + const joined = trailing.length > 0 ? path.join(resolved, ...trailing.reverse()) : resolved + return normalizeCase(joined) + } catch (err) { + if (!isErrnoException(err, "ENOENT")) { + // Non-ENOENT (e.g. EACCES, ELOOP): propagate so the caller's security check can + // fail closed instead of falling through to the lexical path. + throw err + } + + const parent = path.dirname(current) + if (parent === current) { + // Reached the filesystem root without finding an existing path; fall back to the + // lexically resolved path (still case-normalized for consistent comparisons). + return normalizeCase(path.resolve(target)) + } + + trailing.push(path.basename(current)) + current = parent + } + } +} diff --git a/src/utils/__tests__/WorkspacePathResolver.spec.ts b/src/utils/__tests__/WorkspacePathResolver.spec.ts new file mode 100644 index 0000000000..f77f365609 --- /dev/null +++ b/src/utils/__tests__/WorkspacePathResolver.spec.ts @@ -0,0 +1,134 @@ +import * as os from "os" +import * as path from "path" +import * as fs from "fs/promises" + +import { resolveRealPath } from "../WorkspacePathResolver" + +// These tests use real symlinks in a real temp directory (no fs mocking, per #389). Some +// scenarios can't be reproduced everywhere: symlink creation needs privileges on Windows, and +// chmod-based EACCES is meaningless as root. Such cases are skipped at runtime rather than mocked. +const isWindows = process.platform === "win32" +const isRoot = typeof process.getuid === "function" && process.getuid() === 0 + +/** Lowercase on case-insensitive filesystems, matching the resolver's own normalization. */ +const expectCase = (p: string) => (process.platform === "darwin" || process.platform === "win32" ? p.toLowerCase() : p) + +describe("resolveRealPath", () => { + let tmpRoot: string + let workspace: string + let outside: string + let symlinksSupported = false + + beforeEach(async () => { + // realpath the temp root so comparisons aren't tripped up by /var -> /private/var (macOS). + tmpRoot = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "zoo-wpr-"))) + workspace = path.join(tmpRoot, "workspace") + outside = path.join(tmpRoot, "outside") + await fs.mkdir(workspace, { recursive: true }) + await fs.mkdir(outside, { recursive: true }) + + // Probe symlink support once so symlink-dependent cases can skip cleanly on locked-down hosts. + const probeTarget = path.join(tmpRoot, "probe-target") + const probeLink = path.join(tmpRoot, "probe-link") + await fs.writeFile(probeTarget, "probe") + try { + await fs.symlink(probeTarget, probeLink) + symlinksSupported = true + } catch { + symlinksSupported = false + } + }) + + afterEach(async () => { + // Restore permissions on any restricted dir (EACCES test) so cleanup can remove it. + await fs.chmod(path.join(workspace, "restricted"), 0o755).catch(() => {}) + await fs.rm(tmpRoot, { recursive: true, force: true }).catch(() => {}) + }) + + it("resolves a symlink inside the workspace that points to a file outside, to the outside path", async () => { + if (!symlinksSupported) return + const secret = path.join(outside, "secret.txt") + await fs.writeFile(secret, "x") + const link = path.join(workspace, "link.txt") + await fs.symlink(secret, link) + + const resolved = await resolveRealPath(link) + + expect(resolved).toBe(expectCase(await fs.realpath(secret))) + expect(resolved.startsWith(expectCase(workspace) + path.sep)).toBe(false) + }) + + it("resolves a symlink inside the workspace that points to a directory outside, to the outside path", async () => { + if (!symlinksSupported) return + const outsideDir = path.join(outside, "dir") + await fs.mkdir(outsideDir) + const linkDir = path.join(workspace, "linkdir") + await fs.symlink(outsideDir, linkDir) + + const resolved = await resolveRealPath(linkDir) + + expect(resolved).toBe(expectCase(await fs.realpath(outsideDir))) + }) + + it("resolves a not-yet-created file under a symlinked ancestor by resolving the ancestor and re-appending", async () => { + if (!symlinksSupported) return + const outsideDir = path.join(outside, "dir") + await fs.mkdir(outsideDir) + const linkDir = path.join(workspace, "linkdir") + await fs.symlink(outsideDir, linkDir) + + // Neither "nested" nor "new.txt" exists yet — the walk-up must resolve `linkDir` and + // re-append the trailing segments. + const notYetCreated = path.join(linkDir, "nested", "new.txt") + const resolved = await resolveRealPath(notYetCreated) + + expect(resolved).toBe(expectCase(path.join(await fs.realpath(outsideDir), "nested", "new.txt"))) + }) + + it("re-throws EACCES instead of swallowing it (fail closed)", async () => { + if (isWindows || isRoot) return + const restricted = path.join(workspace, "restricted") + await fs.mkdir(restricted) + const target = path.join(restricted, "file.txt") + await fs.writeFile(target, "x") + await fs.chmod(restricted, 0o000) + + await expect(resolveRealPath(target)).rejects.toMatchObject({ code: "EACCES" }) + }) + + it("re-throws ELOOP for a circular symlink chain", async () => { + if (!symlinksSupported) return + const a = path.join(workspace, "a") + const b = path.join(workspace, "b") + // a -> b and b -> a is a cycle realpath cannot resolve. + await fs.symlink(b, a) + await fs.symlink(a, b) + + await expect(resolveRealPath(a)).rejects.toMatchObject({ code: "ELOOP" }) + }) + + it("resolves correctly even with no workspace context (it owns no policy)", async () => { + const real = path.join(outside, "plain.txt") + await fs.writeFile(real, "x") + + const resolved = await resolveRealPath(real) + + expect(resolved).toBe(expectCase(await fs.realpath(real))) + }) + + it("case-normalizes the resolved path to lowercase on case-insensitive platforms (e.g. darwin)", async () => { + const mixed = path.join(outside, "MixedCase.txt") + await fs.writeFile(mixed, "x") + const realMixed = await fs.realpath(mixed) + + const originalPlatform = process.platform + Object.defineProperty(process, "platform", { value: "darwin", configurable: true }) + try { + const resolved = await resolveRealPath(mixed) + expect(resolved).toBe(realMixed.toLowerCase()) + expect(resolved).toContain("mixedcase.txt") + } finally { + Object.defineProperty(process, "platform", { value: originalPlatform, configurable: true }) + } + }) +}) From 8aa638e2d4e5d9b374dac75b46db64a7ee94316c Mon Sep 17 00:00:00 2001 From: edelauna <54631123+edelauna@users.noreply.github.com> Date: Thu, 4 Jun 2026 07:59:04 -0400 Subject: [PATCH 2/3] Update src/utils/WorkspacePathResolver.ts --- src/utils/WorkspacePathResolver.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/utils/WorkspacePathResolver.ts b/src/utils/WorkspacePathResolver.ts index dbab90bce3..449c8ea05f 100644 --- a/src/utils/WorkspacePathResolver.ts +++ b/src/utils/WorkspacePathResolver.ts @@ -47,8 +47,8 @@ export async function resolveRealPath(target: string): Promise { return normalizeCase(joined) } catch (err) { if (!isErrnoException(err, "ENOENT")) { - // Non-ENOENT (e.g. EACCES, ELOOP): propagate so the caller's security check can - // fail closed instead of falling through to the lexical path. + // Non-ENOENT (e.g. EACCES, ELOOP, ENOTDIR): propagate so the caller's + // security check can fail closed instead of falling through to the lexical path. throw err } From f53994d30224d46cdb35842a7c83ad417d09dd40 Mon Sep 17 00:00:00 2001 From: edelauna <54631123+edelauna@users.noreply.github.com> Date: Thu, 4 Jun 2026 07:59:13 -0400 Subject: [PATCH 3/3] Update src/utils/WorkspacePathResolver.ts --- src/utils/WorkspacePathResolver.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/utils/WorkspacePathResolver.ts b/src/utils/WorkspacePathResolver.ts index 449c8ea05f..c11fd3f780 100644 --- a/src/utils/WorkspacePathResolver.ts +++ b/src/utils/WorkspacePathResolver.ts @@ -33,7 +33,8 @@ function normalizeCase(p: string): string { * - The result is case-normalized on case-insensitive filesystems (macOS, Windows). * * Workspace folder paths should be resolved through this same function by callers, since a folder - * may itself be reached via a symlink. + * may itself be reached via a symlink. Callers should always compare two `resolveRealPath()` results + * rather than mixing with raw `uri.fsPath` — `arePathsEqual()` does not case-fold on macOS. */ export async function resolveRealPath(target: string): Promise { let current = path.resolve(target)