Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 23 additions & 3 deletions server/modules/file-tree/file-tree.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -172,6 +177,7 @@ export function createFileTreeService(dependencies: FileTreeServiceDependencies)
maximumDepth: number,
currentDepth = 0,
includeEntry: FileTreeEntryFilter = () => true,
remainingEntries = { value: MAXIMUM_FILE_TREE_ENTRIES },
): Promise<FileTreeNode[]> {
let entries;
try {
Expand All @@ -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<FileTreeNode> => {
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,
Expand Down Expand Up @@ -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) {
Expand Down
53 changes: 53 additions & 0 deletions server/modules/file-tree/tests/file-tree.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [];
Expand Down