Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions src/utils/WorkspacePathResolver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
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
}
Comment on lines +13 to +16
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🌐 Web query:

Can APFS volumes on macOS be configured as case-sensitive, and is it valid to assume all darwin paths are case-insensitive for security path comparisons?

💡 Result:

Yes. APFS volumes on macOS can be formatted as either case-insensitive or case-sensitive (with case-insensitive being the default on macOS) [1]. No, you should not assume that “all darwin paths” are case-insensitive for security path comparisons. macOS runs on top of filesystems/volumes that can be configured case-sensitively; Apple explicitly documents that APFS has both case-sensitive and case-insensitive variants on macOS [1]. Therefore, security-sensitive comparisons must be based on the actual filesystem/volume behavior for the path being validated, not just the OS (“Darwin”) itself. A common example is software behavior (e.g., nix) that breaks when it assumes Darwin implies a case-insensitive filesystem, because users can mount case-sensitive volumes on macOS [2]. Practical implication for path-boundary checks: treat case-folding/case-normalization as filesystem-dependent, or perform comparisons on canonicalized paths resolved by the OS (e.g., after resolving symlinks and walking to the real target) rather than trusting raw string equality/case-insensitive heuristics. Relying on blanket “case-insensitive” assumptions can create security bugs when the target volume is case-sensitive [2].

Citations:


Avoid platform-wide lowercasing in normalizeCase()

Lowercasing whenever process.platform === "darwin" || "win32" assumes those platforms are case-insensitive, but APFS on macOS can be configured as case-sensitive. This can conflate distinct paths on case-sensitive volumes and break security/path-boundary comparisons. Normalize/compare using filesystem-actual case behavior (e.g., detect case-sensitivity per root/mount or rely on OS-level canonicalization) rather than a Darwin/Windows heuristic.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/utils/WorkspacePathResolver.ts` around lines 13 - 16, The normalizeCase
function currently lowercases paths based solely on process.platform which is
unsafe on macOS with case-sensitive APFS; update normalizeCase to avoid global
lowercasing and instead canonicalize per-path using filesystem-aware APIs (e.g.,
fs.realpathSync / fs.realpath or other OS canonicalization) to obtain the actual
on-disk case for the given path, and only fall back to a platform heuristic if
canonicalization fails; locate and modify the normalizeCase function and any
callers that rely on its behavior so comparisons use the fs-canonicalized path
or an explicit per-mount case-sensitivity probe rather than always lowercasing
when process.platform === "darwin" || "win32".


/**
* 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. 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<string> {
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, ENOTDIR): 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
}
}
}
134 changes: 134 additions & 0 deletions src/utils/__tests__/WorkspacePathResolver.spec.ts
Original file line number Diff line number Diff line change
@@ -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 })
}
})
})
Loading