Skip to content

Commit 5bf98fc

Browse files
feat(security): introduce WorkspacePathResolver — async symlink-aware path canonicalization (#389) (#428)
* feat(security): introduce WorkspacePathResolver — async symlink-aware path canonicalization (#389) 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. * Update src/utils/WorkspacePathResolver.ts * Update src/utils/WorkspacePathResolver.ts --------- Co-authored-by: Armando Vaquera <263793884+proyectoauraorg@users.noreply.github.com> Co-authored-by: edelauna <54631123+edelauna@users.noreply.github.com>
1 parent 905b840 commit 5bf98fc

2 files changed

Lines changed: 201 additions & 0 deletions

File tree

src/utils/WorkspacePathResolver.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import * as path from "path"
2+
import * as fs from "fs/promises"
3+
4+
/** Narrow an unknown error to a Node errno exception with the given `code`. */
5+
function isErrnoException(err: unknown, code: string): boolean {
6+
return err instanceof Error && (err as NodeJS.ErrnoException).code === code
7+
}
8+
9+
// macOS APFS/HFS+ and Windows are case-insensitive: `realpath` can return a different case
10+
// than the one VS Code registered, so we lowercase the result before returning to keep later
11+
// comparisons (e.g. against `uri.fsPath`) reliable on those platforms only. Platform is read at
12+
// call time (not cached) so the behavior stays correct and testable.
13+
function normalizeCase(p: string): string {
14+
const caseInsensitive = process.platform === "darwin" || process.platform === "win32"
15+
return caseInsensitive ? p.toLowerCase() : p
16+
}
17+
18+
/**
19+
* Resolve a filesystem path to its canonical, symlink-followed form.
20+
*
21+
* This is the canonicalization primitive for the workspace boundary check (issue #169). It owns
22+
* **only** path resolution — no workspace policy, no settings, no tool logic. The authorization
23+
* decision (and the `allowSymlinksOutsideWorkspace` opt-in) lives in `WorkspaceFileAccess`.
24+
*
25+
* Behavior:
26+
* - **Async only** (`fs.promises.realpath`); never blocks the extension host event loop.
27+
* - If `target` does not exist yet (e.g. a file about to be created), the realpath of the nearest
28+
* existing ancestor is resolved and the remaining segments are re-appended, so a symlink
29+
* anywhere along the path is still followed while not-yet-created paths can still be evaluated.
30+
* - Only `ENOENT` triggers the walk-up. Any other error (e.g. `EACCES`, `ELOOP`) is **re-thrown**
31+
* so a caller performing a security check can fail closed. Silently walking up would mask the
32+
* symlink and could make an out-of-workspace target look "inside" (#169).
33+
* - The result is case-normalized on case-insensitive filesystems (macOS, Windows).
34+
*
35+
* Workspace folder paths should be resolved through this same function by callers, since a folder
36+
* may itself be reached via a symlink. Callers should always compare two `resolveRealPath()` results
37+
* rather than mixing with raw `uri.fsPath` — `arePathsEqual()` does not case-fold on macOS.
38+
*/
39+
export async function resolveRealPath(target: string): Promise<string> {
40+
let current = path.resolve(target)
41+
const trailing: string[] = []
42+
43+
// Walk up until an existing path can be resolved, bounded by the filesystem root.
44+
while (true) {
45+
try {
46+
const resolved = await fs.realpath(current)
47+
const joined = trailing.length > 0 ? path.join(resolved, ...trailing.reverse()) : resolved
48+
return normalizeCase(joined)
49+
} catch (err) {
50+
if (!isErrnoException(err, "ENOENT")) {
51+
// Non-ENOENT (e.g. EACCES, ELOOP, ENOTDIR): propagate so the caller's
52+
// security check can fail closed instead of falling through to the lexical path.
53+
throw err
54+
}
55+
56+
const parent = path.dirname(current)
57+
if (parent === current) {
58+
// Reached the filesystem root without finding an existing path; fall back to the
59+
// lexically resolved path (still case-normalized for consistent comparisons).
60+
return normalizeCase(path.resolve(target))
61+
}
62+
63+
trailing.push(path.basename(current))
64+
current = parent
65+
}
66+
}
67+
}
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import * as os from "os"
2+
import * as path from "path"
3+
import * as fs from "fs/promises"
4+
5+
import { resolveRealPath } from "../WorkspacePathResolver"
6+
7+
// These tests use real symlinks in a real temp directory (no fs mocking, per #389). Some
8+
// scenarios can't be reproduced everywhere: symlink creation needs privileges on Windows, and
9+
// chmod-based EACCES is meaningless as root. Such cases are skipped at runtime rather than mocked.
10+
const isWindows = process.platform === "win32"
11+
const isRoot = typeof process.getuid === "function" && process.getuid() === 0
12+
13+
/** Lowercase on case-insensitive filesystems, matching the resolver's own normalization. */
14+
const expectCase = (p: string) => (process.platform === "darwin" || process.platform === "win32" ? p.toLowerCase() : p)
15+
16+
describe("resolveRealPath", () => {
17+
let tmpRoot: string
18+
let workspace: string
19+
let outside: string
20+
let symlinksSupported = false
21+
22+
beforeEach(async () => {
23+
// realpath the temp root so comparisons aren't tripped up by /var -> /private/var (macOS).
24+
tmpRoot = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "zoo-wpr-")))
25+
workspace = path.join(tmpRoot, "workspace")
26+
outside = path.join(tmpRoot, "outside")
27+
await fs.mkdir(workspace, { recursive: true })
28+
await fs.mkdir(outside, { recursive: true })
29+
30+
// Probe symlink support once so symlink-dependent cases can skip cleanly on locked-down hosts.
31+
const probeTarget = path.join(tmpRoot, "probe-target")
32+
const probeLink = path.join(tmpRoot, "probe-link")
33+
await fs.writeFile(probeTarget, "probe")
34+
try {
35+
await fs.symlink(probeTarget, probeLink)
36+
symlinksSupported = true
37+
} catch {
38+
symlinksSupported = false
39+
}
40+
})
41+
42+
afterEach(async () => {
43+
// Restore permissions on any restricted dir (EACCES test) so cleanup can remove it.
44+
await fs.chmod(path.join(workspace, "restricted"), 0o755).catch(() => {})
45+
await fs.rm(tmpRoot, { recursive: true, force: true }).catch(() => {})
46+
})
47+
48+
it("resolves a symlink inside the workspace that points to a file outside, to the outside path", async () => {
49+
if (!symlinksSupported) return
50+
const secret = path.join(outside, "secret.txt")
51+
await fs.writeFile(secret, "x")
52+
const link = path.join(workspace, "link.txt")
53+
await fs.symlink(secret, link)
54+
55+
const resolved = await resolveRealPath(link)
56+
57+
expect(resolved).toBe(expectCase(await fs.realpath(secret)))
58+
expect(resolved.startsWith(expectCase(workspace) + path.sep)).toBe(false)
59+
})
60+
61+
it("resolves a symlink inside the workspace that points to a directory outside, to the outside path", async () => {
62+
if (!symlinksSupported) return
63+
const outsideDir = path.join(outside, "dir")
64+
await fs.mkdir(outsideDir)
65+
const linkDir = path.join(workspace, "linkdir")
66+
await fs.symlink(outsideDir, linkDir)
67+
68+
const resolved = await resolveRealPath(linkDir)
69+
70+
expect(resolved).toBe(expectCase(await fs.realpath(outsideDir)))
71+
})
72+
73+
it("resolves a not-yet-created file under a symlinked ancestor by resolving the ancestor and re-appending", async () => {
74+
if (!symlinksSupported) return
75+
const outsideDir = path.join(outside, "dir")
76+
await fs.mkdir(outsideDir)
77+
const linkDir = path.join(workspace, "linkdir")
78+
await fs.symlink(outsideDir, linkDir)
79+
80+
// Neither "nested" nor "new.txt" exists yet — the walk-up must resolve `linkDir` and
81+
// re-append the trailing segments.
82+
const notYetCreated = path.join(linkDir, "nested", "new.txt")
83+
const resolved = await resolveRealPath(notYetCreated)
84+
85+
expect(resolved).toBe(expectCase(path.join(await fs.realpath(outsideDir), "nested", "new.txt")))
86+
})
87+
88+
it("re-throws EACCES instead of swallowing it (fail closed)", async () => {
89+
if (isWindows || isRoot) return
90+
const restricted = path.join(workspace, "restricted")
91+
await fs.mkdir(restricted)
92+
const target = path.join(restricted, "file.txt")
93+
await fs.writeFile(target, "x")
94+
await fs.chmod(restricted, 0o000)
95+
96+
await expect(resolveRealPath(target)).rejects.toMatchObject({ code: "EACCES" })
97+
})
98+
99+
it("re-throws ELOOP for a circular symlink chain", async () => {
100+
if (!symlinksSupported) return
101+
const a = path.join(workspace, "a")
102+
const b = path.join(workspace, "b")
103+
// a -> b and b -> a is a cycle realpath cannot resolve.
104+
await fs.symlink(b, a)
105+
await fs.symlink(a, b)
106+
107+
await expect(resolveRealPath(a)).rejects.toMatchObject({ code: "ELOOP" })
108+
})
109+
110+
it("resolves correctly even with no workspace context (it owns no policy)", async () => {
111+
const real = path.join(outside, "plain.txt")
112+
await fs.writeFile(real, "x")
113+
114+
const resolved = await resolveRealPath(real)
115+
116+
expect(resolved).toBe(expectCase(await fs.realpath(real)))
117+
})
118+
119+
it("case-normalizes the resolved path to lowercase on case-insensitive platforms (e.g. darwin)", async () => {
120+
const mixed = path.join(outside, "MixedCase.txt")
121+
await fs.writeFile(mixed, "x")
122+
const realMixed = await fs.realpath(mixed)
123+
124+
const originalPlatform = process.platform
125+
Object.defineProperty(process, "platform", { value: "darwin", configurable: true })
126+
try {
127+
const resolved = await resolveRealPath(mixed)
128+
expect(resolved).toBe(realMixed.toLowerCase())
129+
expect(resolved).toContain("mixedcase.txt")
130+
} finally {
131+
Object.defineProperty(process, "platform", { value: originalPlatform, configurable: true })
132+
}
133+
})
134+
})

0 commit comments

Comments
 (0)