diff --git a/src/client/src/controllers/fileExplorerController.ts b/src/client/src/controllers/fileExplorerController.ts index 9574db4..6633147 100644 --- a/src/client/src/controllers/fileExplorerController.ts +++ b/src/client/src/controllers/fileExplorerController.ts @@ -50,13 +50,26 @@ export class FileExplorerController { if (project === undefined || workspace === undefined) return; this.setState({ selectedFilePath: path, selectedFileContent: undefined }); try { - this.setState({ selectedFileContent: await api.workspaceFile(project.id, workspace.id, path), error: "" }); + const content = await api.workspaceFile(project.id, workspace.id, path); + if (this.getState().selectedFilePath === path) this.setState({ selectedFileContent: content, error: "" }); } catch (error) { + if (this.getState().selectedFilePath !== path) return; + if (isUnavailableFileError(error)) { + this.setState({ selectedFilePath: undefined, selectedFileContent: undefined, error: "" }); + setNamespacedQueryKey(FILES_ROUTE_NAMESPACE, "file", undefined, { replace: true }); + this.updateUrl({ replace: true }); + return; + } this.setState({ error: String(error) }); } } } +function isUnavailableFileError(error: unknown): boolean { + const message = String(error); + return message.includes("Path does not exist") || message.includes("ENOENT") || message.includes("no such file or directory"); +} + function omitKey(record: Record, keyToOmit: string): Record { return Object.fromEntries(Object.entries(record).filter(([key]) => key !== keyToOmit)); } diff --git a/src/server/workspaces/fileContentService.test.ts b/src/server/workspaces/fileContentService.test.ts index db313b4..9aa236c 100644 --- a/src/server/workspaces/fileContentService.test.ts +++ b/src/server/workspaces/fileContentService.test.ts @@ -42,6 +42,7 @@ describe("readWorkspaceFile", () => { await expect(readWorkspaceFile(root, undefined)).rejects.toThrow("path query parameter is required"); await expect(readWorkspaceFile(root, "dir")).rejects.toThrow("Path is not a file"); + await expect(readWorkspaceFile(root, "missing.txt")).rejects.toThrow("Path does not exist"); await expect(readWorkspaceFile(root, "../secret.txt")).rejects.toThrow("Path traversal is not allowed"); await expect(readWorkspaceFile(root, "/etc/passwd")).rejects.toThrow("Absolute paths are not allowed"); }); diff --git a/src/server/workspaces/fileTreeService.test.ts b/src/server/workspaces/fileTreeService.test.ts index 2a7a82f..f5d9f18 100644 --- a/src/server/workspaces/fileTreeService.test.ts +++ b/src/server/workspaces/fileTreeService.test.ts @@ -58,6 +58,7 @@ describe("listWorkspaceTree", () => { await writeFile(join(root, "file.txt"), "content"); await expect(listWorkspaceTree(root, "file.txt")).rejects.toThrow("Path is not a directory"); + await expect(listWorkspaceTree(root, "missing-dir")).rejects.toThrow("Path does not exist"); await expect(listWorkspaceTree(root, "../outside")).rejects.toThrow("Path traversal is not allowed"); await expect(listWorkspaceTree(root, "/tmp")).rejects.toThrow("Absolute paths are not allowed"); }); diff --git a/src/server/workspaces/pathSafety.ts b/src/server/workspaces/pathSafety.ts index c6820a2..36d2cbd 100644 --- a/src/server/workspaces/pathSafety.ts +++ b/src/server/workspaces/pathSafety.ts @@ -5,7 +5,10 @@ export async function resolveInsideWorkspace(rootPath: string, relativePath: str const requested = normalizeRelativePath(relativePath); const root = await realpath(rootPath); const joined = join(root, requested); - const target = await realpath(joined); + const target = await realpath(joined).catch((error: unknown) => { + if (isNodeErrorWithCode(error, "ENOENT")) throw new Error("Path does not exist"); + throw error; + }); ensureInside(root, target); return { root, target, relativePath: requested }; } @@ -27,6 +30,10 @@ export function normalizeRelativePath(input: string | undefined): string { return parts.join("/"); } +function isNodeErrorWithCode(error: unknown, code: string): error is NodeJS.ErrnoException { + return typeof error === "object" && error !== null && "code" in error && error.code === code; +} + function ensureInside(root: string, target: string): void { const rel = relative(root, target); if (rel === "") return;