diff --git a/server/src/util/__tests__/fs.test.ts b/server/src/util/__tests__/fs.test.ts new file mode 100644 index 00000000..62dcb04e --- /dev/null +++ b/server/src/util/__tests__/fs.test.ts @@ -0,0 +1,138 @@ +import * as fs from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' + +import { getFilePaths } from '../fs' + +const symlinkType = process.platform === 'win32' ? 'junction' : 'dir' + +const relativePaths = (filePaths: string[], rootPath: string): string[] => + filePaths.map((filePath) => path.relative(rootPath, filePath).split(path.sep).join('/')) + +describe('getFilePaths', () => { + let rootPath: string + + beforeEach(() => { + rootPath = fs.mkdtempSync(path.join(os.tmpdir(), 'bash-language-server-fs-')) + }) + + afterEach(() => { + fs.rmSync(rootPath, { recursive: true, force: true }) + }) + + it('returns the paths of files matching the glob pattern', async () => { + fs.writeFileSync(path.join(rootPath, 'script.sh'), '') + fs.mkdirSync(path.join(rootPath, 'nested')) + fs.writeFileSync(path.join(rootPath, 'nested', 'nested.sh'), '') + + const filePaths = await getFilePaths({ + globPattern: '**/*.sh', + rootPath, + maxItems: 100, + }) + + expect(relativePaths(filePaths, rootPath).sort()).toEqual([ + 'nested/nested.sh', + 'script.sh', + ]) + }) + + it('follows symbolic links to directories', async () => { + const targetPath = fs.mkdtempSync( + path.join(os.tmpdir(), 'bash-language-server-fs-target-'), + ) + + try { + fs.writeFileSync(path.join(targetPath, 'linked.sh'), '') + fs.symlinkSync(targetPath, path.join(rootPath, 'link'), symlinkType) + + const filePaths = await getFilePaths({ + globPattern: '**/*.sh', + rootPath, + maxItems: 100, + }) + + expect(relativePaths(filePaths, rootPath)).toEqual(['link/linked.sh']) + } finally { + fs.rmSync(targetPath, { recursive: true, force: true }) + } + }) + + it('follows several symbolic links to the same directory', async () => { + const targetPath = fs.mkdtempSync( + path.join(os.tmpdir(), 'bash-language-server-fs-target-'), + ) + + try { + fs.writeFileSync(path.join(targetPath, 'shared.sh'), '') + fs.symlinkSync(targetPath, path.join(rootPath, 'a'), symlinkType) + fs.symlinkSync(targetPath, path.join(rootPath, 'b'), symlinkType) + + const filePaths = await getFilePaths({ + globPattern: '{a,b}/**/*.sh', + rootPath, + maxItems: 100, + }) + + expect(relativePaths(filePaths, rootPath).sort()).toEqual([ + 'a/shared.sh', + 'b/shared.sh', + ]) + } finally { + fs.rmSync(targetPath, { recursive: true, force: true }) + } + }) + + it('does not follow cyclic symbolic links', async () => { + fs.writeFileSync(path.join(rootPath, 'script.sh'), '') + + const loopPath = path.join(rootPath, 'loop') + fs.symlinkSync(rootPath, loopPath, symlinkType) + + try { + const filePaths = await getFilePaths({ + globPattern: '**/*.sh', + rootPath, + maxItems: 100, + }) + + expect(relativePaths(filePaths, rootPath)).toEqual(['script.sh']) + } finally { + fs.unlinkSync(loopPath) + } + }) + + it('does not follow cyclic symbolic links to an ancestor', async () => { + fs.mkdirSync(path.join(rootPath, 'nested')) + fs.writeFileSync(path.join(rootPath, 'nested', 'nested.sh'), '') + + const loopPath = path.join(rootPath, 'nested', 'loop') + fs.symlinkSync(rootPath, loopPath, symlinkType) + + try { + const filePaths = await getFilePaths({ + globPattern: '**/*.sh', + rootPath, + maxItems: 100, + }) + + expect(relativePaths(filePaths, rootPath)).toEqual(['nested/nested.sh']) + } finally { + fs.unlinkSync(loopPath) + } + }) + + it('stops after the maximum number of files', async () => { + for (let i = 0; i < 10; i++) { + fs.writeFileSync(path.join(rootPath, `script-${i}.sh`), '') + } + + const filePaths = await getFilePaths({ + globPattern: '**/*.sh', + rootPath, + maxItems: 3, + }) + + expect(filePaths).toHaveLength(3) + }) +}) diff --git a/server/src/util/fs.ts b/server/src/util/fs.ts index 3c822f9c..4ee742f1 100644 --- a/server/src/util/fs.ts +++ b/server/src/util/fs.ts @@ -1,4 +1,6 @@ +import * as fs from 'node:fs' import * as os from 'node:os' +import * as path from 'node:path' import { fileURLToPath } from 'node:url' import * as fastGlob from 'fast-glob' @@ -11,6 +13,139 @@ export function untildify(pathWithTilde: string): string { : pathWithTilde } +/** + * Create a file system adapter for `fast-glob` that stops walking a directory + * when it links back to one of its own ancestors. + * + * `fast-glob` follows symbolic links by default and, like `node-glob`, walks a + * cyclic symbolic link forever until the process runs out of memory. Upstream + * tracks this as a known limitation with no fix, and the suggested workarounds + * (`followSymbolicLinks: false` or a `deep` limit) either drop symlink support + * or truncate deep trees — see https://github.com/mrmlnc/fast-glob/issues/74. + * + * Only the directories that are symbolic links are checked: the walk can only + * descend, so every cycle has to be entered through a symbolic link, and a + * plain directory can never link back to an ancestor. Directories are listed + * with `withFileTypes`, which is what `fast-glob` does on supported Node + * versions, so the common case does not pay for a `realpath` call at all. + */ +function createCycleSafeFileSystemAdapter(): Partial { + // Symbolic links to directories, by normalized path. + const symlinkedDirectories = new Set() + // Real paths of the directories that had to be checked, by normalized path. + const realPaths = new Map() + // `readdir` without `withFileTypes` cannot report symbolic links, and from + // that point on every directory has to be checked. + let canDetectSymlinks = true + + const realPathOf = (directoryPath: string): string => { + let realPath = realPaths.get(directoryPath) + + if (realPath === undefined) { + try { + // The native implementation resolves paths in a single system call, + // which is considerably cheaper than the JavaScript fallback. + realPath = fs.realpathSync.native(directoryPath) + } catch { + realPath = directoryPath + } + + realPaths.set(directoryPath, realPath) + } + + return realPath + } + + const linksBackToAncestor = (directoryPath: string): boolean => { + const realPath = realPathOf(directoryPath) + let parentPath = path.dirname(directoryPath) + + while (parentPath !== directoryPath) { + if (realPathOf(parentPath) === realPath) { + return true + } + + directoryPath = parentPath + parentPath = path.dirname(directoryPath) + } + + return false + } + + const isCycle = (directoryPath: string): boolean => { + const normalizedPath = path.normalize(directoryPath) + + if (canDetectSymlinks && !symlinkedDirectories.has(normalizedPath)) { + return false + } + + return linksBackToAncestor(normalizedPath) + } + + const recordEntries = (directoryPath: string, entries: unknown): void => { + if (!Array.isArray(entries)) { + canDetectSymlinks = false + return + } + + for (const entry of entries) { + if (typeof entry === 'string') { + canDetectSymlinks = false + return + } + + const dirent = entry as fs.Dirent + + if (typeof dirent?.isSymbolicLink === 'function' && dirent.isSymbolicLink()) { + symlinkedDirectories.add(path.normalize(path.join(directoryPath, dirent.name))) + } + } + } + + return { + readdir: (directoryPath: string, optionsOrCallback: any, callback?: any) => { + const options = + typeof optionsOrCallback === 'function' ? undefined : optionsOrCallback + const done = typeof optionsOrCallback === 'function' ? optionsOrCallback : callback + + if (isCycle(directoryPath)) { + done(null, []) + return + } + + const onRead = (error: NodeJS.ErrnoException | null, entries: unknown) => { + if (error != null) { + done(error) + return + } + + recordEntries(directoryPath, entries) + done(null, entries) + } + + if (options == null) { + fs.readdir(directoryPath, onRead) + } else { + fs.readdir(directoryPath, options, onRead) + } + }, + readdirSync: (directoryPath: string, options?: any) => { + if (isCycle(directoryPath)) { + return [] + } + + const entries = + options == null + ? fs.readdirSync(directoryPath) + : fs.readdirSync(directoryPath, options) + + recordEntries(directoryPath, entries) + + return entries + }, + } as Partial +} + export async function getFilePaths({ globPattern, rootPath, @@ -29,6 +164,7 @@ export async function getFilePaths({ onlyFiles: true, cwd: rootPath, followSymbolicLinks: true, + fs: createCycleSafeFileSystemAdapter(), suppressErrors: true, })