From 8851c1bbdfaf30ca19650ede95eb0e5160b3fec6 Mon Sep 17 00:00:00 2001 From: dongwook-chan Date: Sat, 1 Aug 2026 17:39:44 +0000 Subject: [PATCH] fix(file-tree): bound recursive project traversal --- server/modules/file-tree/file-tree.service.ts | 26 +++++++-- .../file-tree/tests/file-tree.service.test.ts | 53 +++++++++++++++++++ 2 files changed, 76 insertions(+), 3 deletions(-) diff --git a/server/modules/file-tree/file-tree.service.ts b/server/modules/file-tree/file-tree.service.ts index 4d1f2b6e5c..627ebb17ab 100644 --- a/server/modules/file-tree/file-tree.service.ts +++ b/server/modules/file-tree/file-tree.service.ts @@ -28,6 +28,11 @@ const COMMON_WORKSPACE_DIRECTORY_NAMES = [ 'workspace', ]; +// File Tree consumes this guard when recursively listing a project so a very +// broad workspace (for example, a user's home directory) cannot exhaust the +// server heap before the browser has a chance to switch to a narrower project. +const MAXIMUM_FILE_TREE_ENTRIES = 10_000; + type FileTreeEntryFilter = (entryPath: string, isDirectory: boolean) => boolean; function createFileTreeError(message: string, statusCode: number, code: string): AppError { @@ -172,6 +177,7 @@ export function createFileTreeService(dependencies: FileTreeServiceDependencies) maximumDepth: number, currentDepth = 0, includeEntry: FileTreeEntryFilter = () => true, + remainingEntries = { value: MAXIMUM_FILE_TREE_ENTRIES }, ): Promise { let entries; try { @@ -197,7 +203,20 @@ export function createFileTreeService(dependencies: FileTreeServiceDependencies) return includeEntry(path.join(directoryPath, entry.name), isDirectory); }); - const items = await Promise.all(visibleEntries.map(async (entry): Promise => { + if (visibleEntries.length > remainingEntries.value) { + throw createFileTreeError( + `Project file tree exceeds the ${MAXIMUM_FILE_TREE_ENTRIES.toLocaleString()} entry limit. Choose a narrower project directory or add ignore rules.`, + 413, + 'FILE_TREE_TOO_LARGE', + ); + } + remainingEntries.value -= visibleEntries.length; + + const items: FileTreeNode[] = []; + // Walk one directory branch at a time. A recursive Promise.all here creates + // every pending filesystem operation up front and can retain gigabytes of + // promises/tree nodes for broad project roots even when I/O is limited. + for (const entry of visibleEntries) { const itemPath = path.join(directoryPath, entry.name); const item: FileTreeNode = { name: entry.name, @@ -245,11 +264,12 @@ export function createFileTreeService(dependencies: FileTreeServiceDependencies) maximumDepth, currentDepth + 1, includeEntry, + remainingEntries, ); } - return item; - })); + items.push(item); + } return items.sort((left, right) => { if (left.type !== right.type) { diff --git a/server/modules/file-tree/tests/file-tree.service.test.ts b/server/modules/file-tree/tests/file-tree.service.test.ts index 1c6cf89209..4c6679c110 100644 --- a/server/modules/file-tree/tests/file-tree.service.test.ts +++ b/server/modules/file-tree/tests/file-tree.service.test.ts @@ -172,6 +172,59 @@ test('listProjectFiles returns the normal tree when no gitignore exists', async assert.deepEqual(tree.map((entry) => entry.name), ['debug.log']); }); +test('listProjectFiles rejects a tree that exceeds the server entry limit', async () => { + const projectRoot = path.resolve('file-tree-test-project'); + const fileSystem = createFakeFileSystem({ + access: async () => undefined, + readdir: async (directoryPath) => directoryPath === projectRoot + ? Array.from({ length: 10_001 }, (_, index) => createDirectoryEntry(`file-${index}.txt`, false)) + : [], + lstat: async () => createStats(false, 0o644), + }); + const service = createFileTreeService(createDependencies(fileSystem, projectRoot)); + + await assert.rejects( + service.listProjectFiles('project-1'), + (error: unknown) => error instanceof AppError + && error.code === 'FILE_TREE_TOO_LARGE' + && error.statusCode === 413, + ); +}); + +test('listProjectFiles shares the entry limit across nested directories', async () => { + const projectRoot = path.resolve('file-tree-test-project'); + const firstDirectory = path.join(projectRoot, 'first'); + const secondDirectory = path.join(projectRoot, 'second'); + const directoryPaths = new Set([firstDirectory, secondDirectory]); + const fileSystem = createFakeFileSystem({ + access: async () => undefined, + readdir: async (directoryPath) => { + if (directoryPath === projectRoot) { + return [ + createDirectoryEntry('first', true), + createDirectoryEntry('second', true), + ]; + } + if (directoryPaths.has(directoryPath)) { + return Array.from( + { length: 5_000 }, + (_, index) => createDirectoryEntry(`${path.basename(directoryPath)}-${index}.txt`, false), + ); + } + return []; + }, + lstat: async (candidatePath) => createStats(directoryPaths.has(candidatePath), 0o644), + }); + const service = createFileTreeService(createDependencies(fileSystem, projectRoot)); + + await assert.rejects( + service.listProjectFiles('project-1'), + (error: unknown) => error instanceof AppError + && error.code === 'FILE_TREE_TOO_LARGE' + && error.statusCode === 413, + ); +}); + test('readTextFile rejects traversal before invoking the filesystem adapter', async () => { const projectRoot = path.resolve('file-tree-test-project'); const readPaths: string[] = [];