From 6f62125ee018c5c44e6897c160094594b151036f Mon Sep 17 00:00:00 2001 From: Elowen <73627805+Mar-garet@users.noreply.github.com> Date: Mon, 12 Oct 2026 23:14:54 +0800 Subject: [PATCH] fix: return dot-prefixed paths in workspace suggestions (#6720) * fix(paths): allow double-dot names in contained paths * test(paths): cover dot-prefixed workspace suggestions --- .../server/daemon-e2e/filesystem.e2e.test.ts | 83 +++++++++++++++++++ packages/server/src/utils/path.test.ts | 29 +++++++ packages/server/src/utils/path.ts | 9 +- 3 files changed, 117 insertions(+), 4 deletions(-) diff --git a/packages/server/src/server/daemon-e2e/filesystem.e2e.test.ts b/packages/server/src/server/daemon-e2e/filesystem.e2e.test.ts index fae56c0ec7..c0e14d72b8 100644 --- a/packages/server/src/server/daemon-e2e/filesystem.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/filesystem.e2e.test.ts @@ -23,6 +23,89 @@ afterEach(async () => { }, 60000); describe("file explorer", () => { + test.each(["..", "..."])( + "workspace suggestions resolve exact paths starting with %s", + async (prefix) => { + const cwd = tmpCwd(); + const directoryName = `${prefix}docs`; + const nestedPath = `${directoryName}/notes.md`; + const fileName = `${prefix}notes.md`; + try { + mkdirSync(path.join(cwd, directoryName)); + writeFileSync(path.join(cwd, nestedPath), "nested payload\n"); + writeFileSync(path.join(cwd, fileName), "direct payload\n"); + const file = await ctx.client.readFile(cwd, nestedPath); + expect(new TextDecoder().decode(file.bytes)).toBe("nested payload\n"); + + const nestedEntry = { path: nestedPath, kind: "file" }; + const entries = [ + nestedEntry, + { path: directoryName, kind: "directory" }, + { path: fileName, kind: "file" }, + ]; + const cases = entries.flatMap((entry) => [ + { query: `./${entry.path}`, entry }, + { query: path.resolve(cwd, entry.path), entry }, + ]); + cases.unshift({ query: nestedPath, entry: nestedEntry }); + for (const { query, entry } of cases) { + const response = await ctx.client.getDirectorySuggestions({ + cwd, + query, + limit: 1, + includeFiles: true, + includeDirectories: true, + matchMode: "suffix", + }); + expect(response.error).toBeNull(); + expect(response.entries).toEqual([entry]); + } + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }, + ); + + test("workspace suggestions exclude exact parent and sibling paths", async () => { + const directory = tmpCwd(); + const cwd = path.join(directory, "workspace"); + const sibling = path.join(directory, "workspace-other"); + try { + mkdirSync(cwd); + mkdirSync(sibling); + writeFileSync(path.join(cwd, "visible.txt"), "inside payload\n"); + writeFileSync(path.join(sibling, "outside.txt"), "outside payload\n"); + for (const query of [ + "./..", + "../workspace-other/outside.txt", + path.join(sibling, "outside.txt"), + ]) { + const response = await ctx.client.getDirectorySuggestions({ + cwd, + query, + limit: 1, + includeFiles: true, + includeDirectories: true, + matchMode: "suffix", + }); + expect(response.error).toBeNull(); + expect(response.entries).toEqual([]); + } + const control = await ctx.client.getDirectorySuggestions({ + cwd, + query: "./visible.txt", + limit: 1, + includeFiles: true, + includeDirectories: false, + matchMode: "suffix", + }); + expect(control.error).toBeNull(); + expect(control.entries).toEqual([{ path: "visible.txt", kind: "file" }]); + } finally { + rmSync(directory, { recursive: true, force: true }); + } + }); + test.each([ { description: "a long ASCII name", fileName: `${"a".repeat(236)}.txt` }, { description: "an ordinary name", fileName: "notes.txt" }, diff --git a/packages/server/src/utils/path.test.ts b/packages/server/src/utils/path.test.ts index f72f5d82b2..bb279d6c2f 100644 --- a/packages/server/src/utils/path.test.ts +++ b/packages/server/src/utils/path.test.ts @@ -42,6 +42,35 @@ describe("path equivalence", () => { ); }); + test.each([ + { root: "/repo", candidate: "/repo/..notes.txt", inside: true }, + { root: "/repo", candidate: "/repo/..drafts/notes.md", inside: true }, + { root: "/repo", candidate: "/repo/...drafts/notes.md", inside: true }, + { root: "/repo", candidate: "/repo/docs/..notes.txt", inside: true }, + { root: "/repo", candidate: "/repo", inside: true }, + { root: "/repo", candidate: "/repo/../sibling/notes.md", inside: false }, + { root: "/repo", candidate: "/repo/..drafts/../../outside.txt", inside: false }, + { root: "/repo", candidate: "/repo-other/notes.md", inside: false }, + { root: "C:\\Repo", candidate: "c:/repo/..notes.txt", inside: true }, + { root: "C:\\Repo", candidate: "C:\\Repo\\..drafts\\notes.md", inside: true }, + { root: "C:\\Repo", candidate: "C:\\Repo\\...drafts\\notes.md", inside: true }, + { root: "C:\\Repo", candidate: "C:\\Repo\\..\\sibling\\notes.md", inside: false }, + { root: "C:\\Repo", candidate: "C:\\Repo-other\\notes.md", inside: false }, + { root: "C:\\Repo", candidate: "D:\\Repo\\..notes.txt", inside: false }, + { + root: String.raw`\\server\share\repo`, + candidate: String.raw`\\server\share\repo\..drafts\notes.md`, + inside: true, + }, + { + root: String.raw`\\?\C:\Repo`, + candidate: "C:\\Repo\\..notes.txt", + inside: true, + }, + ])("checks named path containment for $candidate", ({ root, candidate, inside }) => { + expect(isPathInsideRoot(root, candidate)).toBe(inside); + }); + test("preserves the casing of Windows relative suffixes", () => { expect(getRealpathAwareRelativePath("C:\\Repo\\.git", "c:\\repo\\.git\\HEAD")).toBe("HEAD"); expect( diff --git a/packages/server/src/utils/path.ts b/packages/server/src/utils/path.ts index 577e48efb4..a0ec908fbb 100644 --- a/packages/server/src/utils/path.ts +++ b/packages/server/src/utils/path.ts @@ -114,10 +114,11 @@ function getRelativePathInsideRoot(root: string, candidate: string): string | nu const normalizedCandidate = normalizePathForComparison(candidate, compareAsWindows); const comparableRelative = platformPath.relative(normalizedRoot, normalizedCandidate); - if ( - comparableRelative !== "" && - (comparableRelative.startsWith("..") || platformPath.isAbsolute(comparableRelative)) - ) { + const isOutsideRoot = + comparableRelative === ".." || + comparableRelative.startsWith(`..${platformPath.sep}`) || + platformPath.isAbsolute(comparableRelative); + if (isOutsideRoot) { return null; }