mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-10 18:10:43 -07:00
fix(core-tools): resolve defensive path resolution for at-reference files and fix macOS tests (#28053)
Co-authored-by: David Pierce <davidapierce@google.com>
This commit is contained in:
@@ -20,7 +20,10 @@ describe('pathCorrector', () => {
|
||||
let mockConfig: Config;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'path-corrector-test-'));
|
||||
const rawTempDir = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'path-corrector-test-'),
|
||||
);
|
||||
tempDir = fs.realpathSync(rawTempDir);
|
||||
rootDir = path.join(tempDir, 'root');
|
||||
otherWorkspaceDir = path.join(tempDir, 'other');
|
||||
fs.mkdirSync(rootDir, { recursive: true });
|
||||
|
||||
@@ -8,6 +8,7 @@ import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import type { Config } from '../config/config.js';
|
||||
import { bfsFileSearchSync } from './bfsFileSearch.js';
|
||||
import { resolveDefensiveToolPath } from './paths.js';
|
||||
|
||||
type SuccessfulPathCorrection = {
|
||||
success: true;
|
||||
@@ -34,8 +35,13 @@ export function correctPath(
|
||||
filePath: string,
|
||||
config: Config,
|
||||
): PathCorrectionResult {
|
||||
const sanitizedPath = resolveDefensiveToolPath(
|
||||
filePath,
|
||||
config.getTargetDir(),
|
||||
);
|
||||
|
||||
// Check for direct path relative to the primary target directory.
|
||||
const directPath = path.join(config.getTargetDir(), filePath);
|
||||
const directPath = path.join(config.getTargetDir(), sanitizedPath);
|
||||
if (fs.existsSync(directPath)) {
|
||||
return { success: true, correctedPath: directPath };
|
||||
}
|
||||
@@ -43,8 +49,8 @@ export function correctPath(
|
||||
// If not found directly, search across all workspace directories for ambiguous matches.
|
||||
const workspaceContext = config.getWorkspaceContext();
|
||||
const searchPaths = workspaceContext.getDirectories();
|
||||
const basename = path.basename(filePath);
|
||||
const normalizedTarget = filePath.replace(/\\/g, '/');
|
||||
const basename = path.basename(sanitizedPath);
|
||||
const normalizedTarget = sanitizedPath.replace(/\\/g, '/');
|
||||
|
||||
// Normalize path for matching and check if it ends with the provided relative path
|
||||
const foundFiles = searchPaths
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
toAbsolutePath,
|
||||
toPathKey,
|
||||
isTrustedSystemPath,
|
||||
resolveDefensiveToolPath,
|
||||
} from './paths.js';
|
||||
|
||||
vi.mock('node:fs', async (importOriginal) => {
|
||||
@@ -918,4 +919,20 @@ describe('normalizePath', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveDefensiveToolPath', () => {
|
||||
it('should sanitize paths by stripping null bytes', () => {
|
||||
const targetDir = '/workspace';
|
||||
const filePathWithNull = 'src/index.ts\0.exe';
|
||||
const result = resolveDefensiveToolPath(filePathWithNull, targetDir);
|
||||
expect(result).toBe('src/index.ts.exe');
|
||||
});
|
||||
|
||||
it('should sanitize @ prefixed paths by stripping null bytes', () => {
|
||||
const targetDir = '/workspace';
|
||||
const filePathWithNull = '@/components/Button.tsx\0';
|
||||
const result = resolveDefensiveToolPath(filePathWithNull, targetDir);
|
||||
expect(result).toBe('components/Button.tsx');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -572,3 +572,54 @@ export function isTrustedSystemPath(filePath: string): boolean {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Defensively resolves and sanitizes a file path generated by the LLM,
|
||||
* stripping user-facing reference prefixes if necessary.
|
||||
*/
|
||||
export function resolveDefensiveToolPath(
|
||||
filePath: string,
|
||||
targetDir: string,
|
||||
): string {
|
||||
const cleanPath = filePath.replace(/\0/g, '');
|
||||
|
||||
try {
|
||||
const literalPath = path.resolve(targetDir, cleanPath);
|
||||
|
||||
// If the file literally exists on disk as-is, return the resolved literal path immediately
|
||||
if (fs.existsSync(literalPath)) {
|
||||
return cleanPath;
|
||||
}
|
||||
|
||||
// If the model supplied a leading @ prefix and the literal path doesn't exist:
|
||||
if (cleanPath.startsWith('@') && cleanPath.length > 1) {
|
||||
if (cleanPath.startsWith('@/') || cleanPath.startsWith('@\\')) {
|
||||
const stripped = cleanPath.substring(1).replace(/^[\\/]+/, '');
|
||||
return stripped.length > 0 ? stripped : cleanPath;
|
||||
}
|
||||
|
||||
const strippedPath = cleanPath.substring(1).replace(/^[\\/]+/, '');
|
||||
|
||||
// Check if a literal directory/file starting with '@' exists for the first segment.
|
||||
// If it does, we should preserve the '@' prefix.
|
||||
const parts = strippedPath.split(/[\\/]/);
|
||||
const firstSegment = parts[0];
|
||||
if (firstSegment) {
|
||||
const literalFirstSegment = path.resolve(targetDir, '@' + firstSegment);
|
||||
if (fs.existsSync(literalFirstSegment)) {
|
||||
return cleanPath;
|
||||
}
|
||||
|
||||
// Otherwise, strip the '@' prefix to resolve to the standard directory name,
|
||||
// preventing the accidental creation of literal '@'-prefixed directories (e.g. '@src', '@policies')
|
||||
// when creating new files or directories.
|
||||
return strippedPath;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Fallback to original path if any filesystem or resolution error occurs
|
||||
}
|
||||
|
||||
// Fallback: return the original path
|
||||
return cleanPath;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user