mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-10 22:21:22 -07:00
fix(core): handle URI-encoded workspace paths in IdeClient (#17476)
Co-authored-by: Shreya Keshive <shreyakeshive@google.com>
This commit is contained in:
@@ -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 },
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<typeof fs>();
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user