diff --git a/src/commands/quickActions.ts b/src/commands/quickActions.ts index 78c9333..512d590 100644 --- a/src/commands/quickActions.ts +++ b/src/commands/quickActions.ts @@ -1961,11 +1961,42 @@ export function isPathInsideWorkspace(workspaceRoot: string, absolutePath: strin return isResolvedPathInsideWorkspace(workspaceRoot, absolutePath); } +function isEnoent(error: unknown): boolean { + return typeof error === "object" + && error !== null + && "code" in error + && (error as { code?: unknown }).code === "ENOENT"; +} + +/** Real path of absolutePath, or of its nearest existing ancestor plus the missing suffix. */ +function realPathAllowingMissingSuffix(absolutePath: string): string | undefined { + const missing: string[] = []; + let current = path.resolve(absolutePath); + while (true) { + try { + const realAncestor = realpathSync(current); + if (missing.length === 0) { + return realAncestor; + } + return path.join(realAncestor, ...missing.slice().reverse()); + } catch (error) { + if (!isEnoent(error)) { + return undefined; + } + const parent = path.dirname(current); + if (parent === current) { + return undefined; + } + missing.push(path.basename(current)); + current = parent; + } + } +} + /** * True when the real path stays inside the workspace. - * Missing or dangling targets fall back to the lexical path so a typed - * in-workspace miss can reach ensureWorkspaceFileReady instead of looking - * like an escape. Existing files that realpath outside stay rejected. + * A missing leaf uses the nearest existing ancestor's real path plus the + * missing suffix. Existing files that realpath outside stay rejected. */ export function isRealPathInsideWorkspace(workspaceRoot: string, absolutePath: string): boolean { let realRoot: string; @@ -1974,11 +2005,11 @@ export function isRealPathInsideWorkspace(workspaceRoot: string, absolutePath: s } catch { return false; } - try { - return isResolvedPathInsideWorkspace(realRoot, realpathSync(absolutePath)); - } catch { - return isResolvedPathInsideWorkspace(workspaceRoot, absolutePath); + const resolved = realPathAllowingMissingSuffix(absolutePath); + if (resolved === undefined) { + return false; } + return isResolvedPathInsideWorkspace(realRoot, resolved); } export interface StagedExternalPatch { diff --git a/test/unit/quickActions.test.ts b/test/unit/quickActions.test.ts index 4ca70fa..3bf2a40 100644 --- a/test/unit/quickActions.test.ts +++ b/test/unit/quickActions.test.ts @@ -995,6 +995,24 @@ test("isRealPathInsideWorkspace follows symlinks and stays fail-closed", async ( return; } assert.equal(isRealPathInsideWorkspace(workspaceRoot, linkPath), false); + + const escapeDir = path.join(workspaceRoot, "escape"); + try { + await fs.symlink(outsideRoot, escapeDir, "dir"); + } catch (error) { + const code = typeof error === "object" && error !== null && "code" in error + ? (error as { code?: unknown }).code + : undefined; + if (code === "EPERM" || code === "EACCES" || code === "ENOTSUP") { + return; + } + throw error; + } + assert.equal( + isRealPathInsideWorkspace(workspaceRoot, path.join(workspaceRoot, "escape", "missing.txt")), + false, + "missing file under a directory symlink to outside" + ); } finally { await fs.rm(workspaceRoot, { recursive: true, force: true }); await fs.rm(outsideRoot, { recursive: true, force: true });