diff --git a/packages/core/src/ide/ide-client.test.ts b/packages/core/src/ide/ide-client.test.ts index 64bfc022b1..24a87143cb 100644 --- a/packages/core/src/ide/ide-client.test.ts +++ b/packages/core/src/ide/ide-client.test.ts @@ -1131,4 +1131,92 @@ describe('getIdeServerHost', () => { '/run/.containerenv', ); // Short-circuiting }); + + describe('validateWorkspacePath', () => { + describe('with special characters and encoding', () => { + it('should return true for a URI-encoded path with spaces', () => { + const workspacePath = 'file:///test/my%20workspace'; + const cwd = '/test/my workspace/sub-dir'; + const result = IdeClient.validateWorkspacePath(workspacePath, cwd); + expect(result.isValid).toBe(true); + }); + + it('should return true for a URI-encoded path with Korean characters', () => { + const workspacePath = 'file:///test/%ED%85%8C%EC%8A%A4%ED%8A%B8'; // "테스트" + const cwd = '/test/테스트/sub-dir'; + const result = IdeClient.validateWorkspacePath(workspacePath, cwd); + expect(result.isValid).toBe(true); + }); + + it('should return true for a plain decoded path with Korean characters', () => { + const workspacePath = '/test/테스트'; + const cwd = '/test/테스트/sub-dir'; + const result = IdeClient.validateWorkspacePath(workspacePath, cwd); + expect(result.isValid).toBe(true); + }); + + it('should return true when one of multi-root paths is a valid URI-encoded path', () => { + const workspacePath = [ + '/another/workspace', + 'file:///test/%ED%85%8C%EC%8A%A4%ED%8A%B8', // "테스트" + ].join(path.delimiter); + const cwd = '/test/테스트/sub-dir'; + const result = IdeClient.validateWorkspacePath(workspacePath, cwd); + expect(result.isValid).toBe(true); + }); + + it('should return true for paths containing a literal % sign', () => { + const workspacePath = '/test/a%path'; + const cwd = '/test/a%path/sub-dir'; + const result = IdeClient.validateWorkspacePath(workspacePath, cwd); + expect(result.isValid).toBe(true); + }); + + it.skipIf(process.platform !== 'win32')( + 'should correctly convert a Windows file URI', + () => { + const workspacePath = 'file:///C:\\Users\\test'; + const cwd = 'C:\\Users\\test\\sub-dir'; + + const result = IdeClient.validateWorkspacePath(workspacePath, cwd); + + expect(result.isValid).toBe(true); + }, + ); + }); + }); + + describe('validateWorkspacePath (sanitization)', () => { + it.each([ + { + description: 'should return true for identical paths', + workspacePath: '/test/ws', + cwd: '/test/ws', + expectedValid: true, + }, + { + description: 'should return true when workspace has file:// protocol', + workspacePath: 'file:///test/ws', + cwd: '/test/ws', + expectedValid: true, + }, + { + description: 'should return true when workspace has encoded spaces', + workspacePath: '/test/my%20ws', + cwd: '/test/my ws', + expectedValid: true, + }, + { + description: + 'should return true when cwd needs normalization matching workspace', + workspacePath: '/test/my ws', + cwd: '/test/my%20ws', + expectedValid: true, + }, + ])('$description', ({ workspacePath, cwd, expectedValid }) => { + expect(IdeClient.validateWorkspacePath(workspacePath, cwd)).toMatchObject( + { isValid: expectedValid }, + ); + }); + }); }); diff --git a/packages/core/src/ide/ide-client.ts b/packages/core/src/ide/ide-client.ts index a4d9234bd0..928c411395 100644 --- a/packages/core/src/ide/ide-client.ts +++ b/packages/core/src/ide/ide-client.ts @@ -5,7 +5,7 @@ */ import * as fs from 'node:fs'; -import { isSubpath } from '../utils/paths.js'; +import { isSubpath, resolveToRealPath } from '../utils/paths.js'; import { detectIde, type IdeInfo } from '../ide/detect-ide.js'; import { ideContextStore } from './ideContext.js'; import { @@ -65,16 +65,6 @@ type ConnectionConfig = { stdio?: StdioConfig; }; -function getRealPath(path: string): string { - try { - return fs.realpathSync(path); - } catch (_e) { - // If realpathSync fails, it might be because the path doesn't exist. - // In that case, we can fall back to the original path. - return path; - } -} - /** * Manages the connection to and interaction with the IDE server. */ @@ -521,12 +511,14 @@ export class IdeClient { }; } - const ideWorkspacePaths = ideWorkspacePath.split(path.delimiter); - const realCwd = getRealPath(cwd); - const isWithinWorkspace = ideWorkspacePaths.some((workspacePath) => { - const idePath = getRealPath(workspacePath); - return isSubpath(idePath, realCwd); - }); + const ideWorkspacePaths = ideWorkspacePath + .split(path.delimiter) + .map((p) => resolveToRealPath(p)) + .filter((e) => !!e); + const realCwd = resolveToRealPath(cwd); + const isWithinWorkspace = ideWorkspacePaths.some((workspacePath) => + isSubpath(workspacePath, realCwd), + ); if (!isWithinWorkspace) { return { diff --git a/packages/core/src/utils/paths.test.ts b/packages/core/src/utils/paths.test.ts index 210dc8b448..38b00628e5 100644 --- a/packages/core/src/utils/paths.test.ts +++ b/packages/core/src/utils/paths.test.ts @@ -4,8 +4,23 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect, beforeAll, afterAll } from 'vitest'; -import { escapePath, unescapePath, isSubpath, shortenPath } from './paths.js'; +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import * as fs from 'node:fs'; +import { + escapePath, + unescapePath, + isSubpath, + shortenPath, + resolveToRealPath, +} from './paths.js'; + +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...(actual as object), + realpathSync: (p: string) => p, + }; +}); describe('escapePath', () => { it.each([ @@ -472,3 +487,42 @@ describe('shortenPath', () => { }); }); }); + +describe('resolveToRealPath', () => { + it.each([ + { + description: + 'should return path as-is if no special characters or protocol', + input: '/simple/path', + expected: '/simple/path', + }, + { + description: 'should remove file:// protocol', + input: 'file:///path/to/file', + expected: '/path/to/file', + }, + { + description: 'should decode URI components', + input: '/path/to/some%20folder', + expected: '/path/to/some folder', + }, + { + description: 'should handle both file protocol and encoding', + input: 'file:///path/to/My%20Project', + expected: '/path/to/My Project', + }, + ])('$description', ({ input, expected }) => { + expect(resolveToRealPath(input)).toBe(expected); + }); + + it('should return decoded path even if fs.realpathSync fails', () => { + vi.spyOn(fs, 'realpathSync').mockImplementationOnce(() => { + throw new Error('File not found'); + }); + + const input = 'file:///path/to/New%20Project'; + const expected = '/path/to/New Project'; + + expect(resolveToRealPath(input)).toBe(expected); + }); +}); diff --git a/packages/core/src/utils/paths.ts b/packages/core/src/utils/paths.ts index 4d14a6d230..94ccd96cf3 100644 --- a/packages/core/src/utils/paths.ts +++ b/packages/core/src/utils/paths.ts @@ -8,6 +8,8 @@ import path from 'node:path'; import os from 'node:os'; import process from 'node:process'; import * as crypto from 'node:crypto'; +import * as fs from 'node:fs'; +import { fileURLToPath } from 'node:url'; export const GEMINI_DIR = '.gemini'; export const GOOGLE_ACCOUNTS_FILENAME = 'google_accounts.json'; @@ -343,3 +345,34 @@ export function isSubpath(parentPath: string, childPath: string): boolean { !pathModule.isAbsolute(relative) ); } + +/** + * Resolves a path to its real path, sanitizing it first. + * - Removes 'file://' protocol if present. + * - Decodes URI components (e.g. %20 -> space). + * - Resolves symbolic links using fs.realpathSync. + * + * @param pathStr The path string to resolve. + * @returns The resolved real path. + */ +export function resolveToRealPath(path: string): string { + let resolvedPath = path; + + try { + if (resolvedPath.startsWith('file://')) { + resolvedPath = fileURLToPath(resolvedPath); + } + + resolvedPath = decodeURIComponent(resolvedPath); + } catch (_e) { + // Ignore error (e.g. malformed URI), keep path from previous step + } + + try { + return fs.realpathSync(resolvedPath); + } catch (_e) { + // If realpathSync fails, it might be because the path doesn't exist. + // In that case, we can fall back to the path processed. + return resolvedPath; + } +}