Skip to content
Merged
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
138 changes: 138 additions & 0 deletions server/src/util/__tests__/fs.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
136 changes: 136 additions & 0 deletions server/src/util/fs.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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<fastGlob.FileSystemAdapter> {
// Symbolic links to directories, by normalized path.
const symlinkedDirectories = new Set<string>()
// Real paths of the directories that had to be checked, by normalized path.
const realPaths = new Map<string, string>()
// `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<fastGlob.FileSystemAdapter>
}

export async function getFilePaths({
globPattern,
rootPath,
Expand All @@ -29,6 +164,7 @@ export async function getFilePaths({
onlyFiles: true,
cwd: rootPath,
followSymbolicLinks: true,
fs: createCycleSafeFileSystemAdapter(),
suppressErrors: true,
})

Expand Down
Loading