diff --git a/doc/api/fs.md b/doc/api/fs.md index 6e86072ca6031e..b8a04e8f13dae6 100644 --- a/doc/api/fs.md +++ b/doc/api/fs.md @@ -1115,6 +1115,8 @@ changes: If a string array is provided, each string should be a glob pattern that specifies paths to exclude. Note: Negation patterns (e.g., '!foo.js') are not supported. + * `followSymlinks` {boolean} `true` if the glob should traverse symbolic + links to directories, `false` otherwise. **Default:** `false`. * `withFileTypes` {boolean} `true` if the glob should return paths as Dirents, `false` otherwise. **Default:** `false`. * Returns: {AsyncIterator} An AsyncIterator that yields the paths of files @@ -3215,6 +3217,8 @@ changes: * `exclude` {Function|string\[]} Function to filter out files/directories or a list of glob patterns to be excluded. If a function is provided, return `true` to exclude the item, `false` to include it. **Default:** `undefined`. + * `followSymlinks` {boolean} `true` if the glob should traverse symbolic + links to directories, `false` otherwise. **Default:** `false`. * `withFileTypes` {boolean} `true` if the glob should return paths as Dirents, `false` otherwise. **Default:** `false`. @@ -5772,6 +5776,8 @@ changes: * `exclude` {Function|string\[]} Function to filter out files/directories or a list of glob patterns to be excluded. If a function is provided, return `true` to exclude the item, `false` to include it. **Default:** `undefined`. + * `followSymlinks` {boolean} `true` if the glob should traverse symbolic + links to directories, `false` otherwise. **Default:** `false`. * `withFileTypes` {boolean} `true` if the glob should return paths as Dirents, `false` otherwise. **Default:** `false`. * Returns: {string\[]} paths of files that match the pattern. diff --git a/lib/internal/fs/glob.js b/lib/internal/fs/glob.js index 1bfa39150e5196..f9d9af71724285 100644 --- a/lib/internal/fs/glob.js +++ b/lib/internal/fs/glob.js @@ -16,8 +16,8 @@ const { StringPrototypeEndsWith, } = primordials; -const { lstatSync, readdirSync } = require('fs'); -const { lstat, readdir } = require('fs/promises'); +const { lstatSync, readdirSync, statSync, realpathSync } = require('fs'); +const { lstat, readdir, stat, realpath } = require('fs/promises'); const { join, resolve, basename, isAbsolute, dirname } = require('path'); const { @@ -48,28 +48,46 @@ function lazyMinimatch() { /** * @param {string} path + * @param {boolean} followSymlinks * @returns {Promise} */ -async function getDirent(path) { - let stat; +async function getDirent(path, followSymlinks = false) { + let statResult; try { - stat = await lstat(path); + statResult = await lstat(path); + // If it's a symlink and followSymlinks is true, use stat to follow it + if (followSymlinks && statResult.isSymbolicLink()) { + try { + statResult = await stat(path); + } catch { + // If stat fails (e.g., broken symlink), keep the lstat result + } + } } catch { return null; } - return new DirentFromStats(basename(path), stat, dirname(path)); + return new DirentFromStats(basename(path), statResult, dirname(path)); } /** * @param {string} path + * @param {boolean} followSymlinks * @returns {DirentFromStats|null} */ -function getDirentSync(path) { - const stat = lstatSync(path, { throwIfNoEntry: false }); - if (stat === undefined) { +function getDirentSync(path, followSymlinks = false) { + let statResult = lstatSync(path, { throwIfNoEntry: false }); + if (statResult === undefined) { return null; } - return new DirentFromStats(basename(path), stat, dirname(path)); + // If it's a symlink and followSymlinks is true, use statSync to follow it + if (followSymlinks && statResult.isSymbolicLink()) { + const followedStat = statSync(path, { throwIfNoEntry: false }); + if (followedStat !== undefined) { + statResult = followedStat; + } + // If followedStat is undefined (broken symlink), keep the lstat result + } + return new DirentFromStats(basename(path), statResult, dirname(path)); } /** @@ -115,13 +133,31 @@ class Cache { #cache = new SafeMap(); #statsCache = new SafeMap(); #readdirCache = new SafeMap(); + #followSymlinks = false; + #visitedRealpaths = new SafeSet(); + + setFollowSymlinks(followSymlinks) { + this.#followSymlinks = followSymlinks; + } + + isFollowSymlinks() { + return this.#followSymlinks; + } + + hasVisitedRealPath(path) { + return this.#visitedRealpaths.has(path); + } + + addVisitedRealPath(path) { + this.#visitedRealpaths.add(path); + } stat(path) { const cached = this.#statsCache.get(path); if (cached) { return cached; } - const promise = getDirent(path); + const promise = getDirent(path, this.#followSymlinks); this.#statsCache.set(path, promise); return promise; } @@ -131,7 +167,7 @@ class Cache { if (cached && !(cached instanceof Promise)) { return cached; } - const val = getDirentSync(path); + const val = getDirentSync(path, this.#followSymlinks); this.#statsCache.set(path, val); return val; } @@ -267,9 +303,12 @@ class Glob { #isExcluded = () => false; constructor(pattern, options = kEmptyObject) { validateObject(options, 'options'); - const { exclude, cwd, withFileTypes } = options; + const { exclude, cwd, withFileTypes, followSymlinks } = options; this.#root = toPathIfFileURL(cwd) ?? '.'; this.#withFileTypes = !!withFileTypes; + if (followSymlinks === true) { + this.#cache.setFollowSymlinks(true); + } if (exclude != null) { validateStringArrayOrFunction(exclude, 'options.exclude'); if (ArrayIsArray(exclude)) { @@ -427,7 +466,33 @@ class Glob { for (let i = 0; i < children.length; i++) { const entry = children[i]; const entryPath = join(path, entry.name); - this.#cache.addToStatCache(join(fullpath, entry.name), entry); + const entryFullPath = join(fullpath, entry.name); + + // If followSymlinks is enabled and entry is a symlink, resolve it + let resolvedEntry = entry; + let resolvedRealpath; + if (this.#cache.isFollowSymlinks() && entry.isSymbolicLink()) { + const resolved = this.#cache.statSync(entryFullPath); + if (resolved && !resolved.isSymbolicLink()) { + resolvedEntry = resolved; + resolvedEntry.name = entry.name; + try { + resolvedRealpath = realpathSync(entryFullPath); + } catch { + // broken symlink or permission issue – fall back to lstat view + } + } + } + + // Guard against cycles when following symlinks into directories + if (resolvedRealpath && resolvedEntry.isDirectory()) { + if (this.#cache.hasVisitedRealPath(resolvedRealpath)) { + continue; + } + this.#cache.addVisitedRealPath(resolvedRealpath); + } + + this.#cache.addToStatCache(entryFullPath, resolvedEntry); const subPatterns = new SafeSet(); const nSymlinks = new SafeSet(); @@ -453,10 +518,10 @@ class Glob { const matchesDot = isDot && pattern.test(nextNonGlobIndex, entry.name); if ((isDot && !matchesDot) || - (this.#exclude && this.#exclude(this.#withFileTypes ? entry : entry.name))) { + (this.#exclude && this.#exclude(this.#withFileTypes ? resolvedEntry : entry.name))) { continue; } - if (!fromSymlink && entry.isDirectory()) { + if (!fromSymlink && resolvedEntry.isDirectory()) { // If directory, add ** to its potential patterns subPatterns.add(index); } else if (!fromSymlink && index === last) { @@ -469,24 +534,24 @@ class Glob { if (nextMatches && nextIndex === last && !isLast) { // If next pattern is the last one, add to results this.#results.add(entryPath); - } else if (nextMatches && entry.isDirectory()) { + } else if (nextMatches && resolvedEntry.isDirectory()) { // Pattern matched, meaning two patterns forward // are also potential patterns // e.g **/b/c when entry is a/b - add c to potential patterns subPatterns.add(index + 2); } if ((nextMatches || pattern.at(0) === '.') && - (entry.isDirectory() || entry.isSymbolicLink()) && !fromSymlink) { + (resolvedEntry.isDirectory() || resolvedEntry.isSymbolicLink()) && !fromSymlink) { // If pattern after ** matches, or pattern starts with "." // and entry is a directory or symlink, add to potential patterns subPatterns.add(nextIndex); } - if (entry.isSymbolicLink()) { + if (resolvedEntry.isSymbolicLink()) { nSymlinks.add(index); } - if (next === '..' && entry.isDirectory()) { + if (next === '..' && resolvedEntry.isDirectory()) { // In case pattern is "**/..", // both parent and current directory should be added to the queue // if this is the last pattern, add to results instead @@ -529,7 +594,7 @@ class Glob { // add next pattern to potential patterns, or to results if it's the last pattern if (index === last) { this.#results.add(entryPath); - } else if (entry.isDirectory()) { + } else if (resolvedEntry.isDirectory()) { subPatterns.add(nextIndex); } } @@ -637,7 +702,33 @@ class Glob { for (let i = 0; i < children.length; i++) { const entry = children[i]; const entryPath = join(path, entry.name); - this.#cache.addToStatCache(join(fullpath, entry.name), entry); + const entryFullPath = join(fullpath, entry.name); + + // If followSymlinks is enabled and entry is a symlink, resolve it + let resolvedEntry = entry; + let resolvedRealpath; + if (this.#cache.isFollowSymlinks() && entry.isSymbolicLink()) { + const resolved = await this.#cache.stat(entryFullPath); + if (resolved && !resolved.isSymbolicLink()) { + resolvedEntry = resolved; + resolvedEntry.name = entry.name; + try { + resolvedRealpath = await realpath(entryFullPath); + } catch { + // broken symlink or permission issue – fall back to lstat view + } + } + } + + // Guard against cycles when following symlinks into directories + if (resolvedRealpath && resolvedEntry.isDirectory()) { + if (this.#cache.hasVisitedRealPath(resolvedRealpath)) { + continue; + } + this.#cache.addVisitedRealPath(resolvedRealpath); + } + + this.#cache.addToStatCache(entryFullPath, resolvedEntry); const subPatterns = new SafeSet(); const nSymlinks = new SafeSet(); @@ -663,16 +754,16 @@ class Glob { const matchesDot = isDot && pattern.test(nextNonGlobIndex, entry.name); if ((isDot && !matchesDot) || - (this.#exclude && this.#exclude(this.#withFileTypes ? entry : entry.name))) { + (this.#exclude && this.#exclude(this.#withFileTypes ? resolvedEntry : entry.name))) { continue; } - if (!fromSymlink && entry.isDirectory()) { + if (!fromSymlink && resolvedEntry.isDirectory()) { // If directory, add ** to its potential patterns subPatterns.add(index); } else if (!fromSymlink && index === last) { // If ** is last, add to results if (!this.#results.has(entryPath) && this.#results.add(entryPath)) { - yield this.#withFileTypes ? entry : entryPath; + yield this.#withFileTypes ? resolvedEntry : entryPath; } } @@ -681,26 +772,26 @@ class Glob { if (nextMatches && nextIndex === last && !isLast) { // If next pattern is the last one, add to results if (!this.#results.has(entryPath) && this.#results.add(entryPath)) { - yield this.#withFileTypes ? entry : entryPath; + yield this.#withFileTypes ? resolvedEntry : entryPath; } - } else if (nextMatches && entry.isDirectory()) { + } else if (nextMatches && resolvedEntry.isDirectory()) { // Pattern matched, meaning two patterns forward // are also potential patterns // e.g **/b/c when entry is a/b - add c to potential patterns subPatterns.add(index + 2); } if ((nextMatches || pattern.at(0) === '.') && - (entry.isDirectory() || entry.isSymbolicLink()) && !fromSymlink) { + (resolvedEntry.isDirectory() || resolvedEntry.isSymbolicLink()) && !fromSymlink) { // If pattern after ** matches, or pattern starts with "." // and entry is a directory or symlink, add to potential patterns subPatterns.add(nextIndex); } - if (entry.isSymbolicLink()) { + if (resolvedEntry.isSymbolicLink()) { nSymlinks.add(index); } - if (next === '..' && entry.isDirectory()) { + if (next === '..' && resolvedEntry.isDirectory()) { // In case pattern is "**/..", // both parent and current directory should be added to the queue // if this is the last pattern, add to results instead @@ -742,7 +833,7 @@ class Glob { if (nextIndex === last) { if (!this.#results.has(entryPath)) { if (this.#results.add(entryPath)) { - yield this.#withFileTypes ? entry : entryPath; + yield this.#withFileTypes ? resolvedEntry : entryPath; } } } else { @@ -756,10 +847,10 @@ class Glob { if (index === last) { if (!this.#results.has(entryPath)) { if (this.#results.add(entryPath)) { - yield this.#withFileTypes ? entry : entryPath; + yield this.#withFileTypes ? resolvedEntry : entryPath; } } - } else if (entry.isDirectory()) { + } else if (resolvedEntry.isDirectory()) { subPatterns.add(nextIndex); } } diff --git a/test/parallel/test-fs-glob-followsymlinks.mjs b/test/parallel/test-fs-glob-followsymlinks.mjs new file mode 100644 index 00000000000000..4154728cc7448a --- /dev/null +++ b/test/parallel/test-fs-glob-followsymlinks.mjs @@ -0,0 +1,246 @@ +import * as common from '../common/index.mjs'; +import tmpdir from '../common/tmpdir.js'; +import { resolve } from 'node:path'; +import { mkdir, writeFile, symlink, glob as asyncGlob } from 'node:fs/promises'; +import { globSync, mkdirSync, symlinkSync } from 'node:fs'; +import { test } from 'node:test'; +import assert from 'node:assert'; + +tmpdir.refresh(); + +const fixtureDir = tmpdir.resolve('glob-symlink-test'); + +async function setup() { + await mkdir(fixtureDir, { recursive: true }); + + // Create a real directory with files + const realDir = resolve(fixtureDir, 'real-dir'); + await mkdir(realDir, { recursive: true }); + await writeFile(resolve(realDir, 'file.txt'), 'hello NodeJS Team'); + await writeFile(resolve(realDir, 'test.js'), 'console.log("test")'); + + // Create a subdirectory in the real directory + const subDir = resolve(realDir, 'subdir'); + await mkdir(subDir, { recursive: true }); + await writeFile(resolve(subDir, 'nested.txt'), 'nested file'); + + // Create a symlink to the real directory + const symlinkDir = resolve(fixtureDir, 'symlinked-dir'); + await symlink(realDir, symlinkDir, 'dir'); + + // Create another regular directory for comparison + const regularDir = resolve(fixtureDir, 'regular-dir'); + await mkdir(regularDir, { recursive: true }); + await writeFile(resolve(regularDir, 'regular.txt'), 'regular file'); +} + +await setup(); + +test('glob does not follow symlinks by default', async () => { + if (common.isWindows) { + // Skip on Windows as symlinks require special permissions + return; + } + + const results = []; + for await (const file of asyncGlob('**/*.txt', { cwd: fixtureDir })) { + results.push(file); + } + const sortedResults = results.sort(); + + // Should not include files from symlinked-dir since symlinks are not + // followed by default + assert.ok(sortedResults.includes('real-dir/file.txt'), + 'Should include real-dir/file.txt'); + assert.ok(sortedResults.includes('real-dir/subdir/nested.txt'), + 'Should include real-dir/subdir/nested.txt'); + assert.ok(sortedResults.includes('regular-dir/regular.txt'), + 'Should include regular-dir/regular.txt'); + assert.ok(!sortedResults.includes('symlinked-dir/file.txt'), + 'Should not include symlinked-dir/file.txt by default'); + assert.ok(!sortedResults.includes('symlinked-dir/subdir/nested.txt'), + 'Should not include symlinked-dir/subdir/nested.txt by default'); +}); + +test('glob follows symlinks when followSymlinks is true', async () => { + if (common.isWindows) { + // Skip on Windows as symlinks require special permissions + return; + } + + const results = []; + for await (const file of asyncGlob('**/*.txt', + { cwd: fixtureDir, followSymlinks: true })) { + results.push(file); + } + const sortedResults = results.sort(); + + // Should include files from symlinked-dir when followSymlinks is enabled + assert.ok(sortedResults.includes('real-dir/file.txt'), + 'Should include real-dir/file.txt'); + assert.ok(sortedResults.includes('real-dir/subdir/nested.txt'), + 'Should include real-dir/subdir/nested.txt'); + assert.ok(sortedResults.includes('regular-dir/regular.txt'), + 'Should include regular-dir/regular.txt'); + assert.ok(sortedResults.includes('symlinked-dir/file.txt'), + 'Should include symlinked-dir/file.txt when followSymlinks is true'); + assert.ok(sortedResults.includes('symlinked-dir/subdir/nested.txt'), + 'Should include files in symlinked-dir/subdir when followSymlinks is true'); +}); + +test('globSync does not follow symlinks by default', () => { + if (common.isWindows) { + // Skip on Windows as symlinks require special permissions + return; + } + + const results = globSync('**/*.txt', { cwd: fixtureDir }); + const sortedResults = results.sort(); + + // Should not include files from symlinked-dir since symlinks are not + // followed by default + assert.ok(sortedResults.includes('real-dir/file.txt'), + 'Should include real-dir/file.txt'); + assert.ok(sortedResults.includes('real-dir/subdir/nested.txt'), + 'Should include real-dir/subdir/nested.txt'); + assert.ok(sortedResults.includes('regular-dir/regular.txt'), + 'Should include regular-dir/regular.txt'); + assert.ok(!sortedResults.includes('symlinked-dir/file.txt'), + 'Should not include symlinked-dir/file.txt by default'); + assert.ok(!sortedResults.includes('symlinked-dir/subdir/nested.txt'), + 'Should not include symlinked-dir/subdir/nested.txt by default'); +}); + +test('globSync follows symlinks when followSymlinks is true', () => { + if (common.isWindows) { + // Skip on Windows as symlinks require special permissions + return; + } + + const results = globSync('**/*.txt', + { cwd: fixtureDir, followSymlinks: true }); + const sortedResults = results.sort(); + + // Should include files from symlinked-dir when followSymlinks is enabled + assert.ok(sortedResults.includes('real-dir/file.txt'), + 'Should include real-dir/file.txt'); + assert.ok(sortedResults.includes('real-dir/subdir/nested.txt'), + 'Should include real-dir/subdir/nested.txt'); + assert.ok(sortedResults.includes('regular-dir/regular.txt'), + 'Should include regular-dir/regular.txt'); + assert.ok(sortedResults.includes('symlinked-dir/file.txt'), + 'Should include symlinked-dir/file.txt when followSymlinks is true'); + assert.ok(sortedResults.includes('symlinked-dir/subdir/nested.txt'), + 'Should include files in symlinked-dir/subdir when followSymlinks is true'); +}); + +test('glob with ** pattern follows symlinks when followSymlinks is true', async () => { + if (common.isWindows) { + // Skip on Windows as symlinks require special permissions + return; + } + + const results = []; + for await (const file of asyncGlob('**', { cwd: fixtureDir, followSymlinks: true })) { + results.push(file); + } + const sortedResults = results.sort(); + + // Should include the symlinked directory contents + assert.ok(sortedResults.includes('symlinked-dir'), + 'Should include symlinked-dir'); + assert.ok(sortedResults.includes('symlinked-dir/file.txt'), + 'Should include symlinked-dir/file.txt'); + assert.ok(sortedResults.includes('symlinked-dir/test.js'), + 'Should include symlinked-dir/test.js'); + assert.ok(sortedResults.includes('symlinked-dir/subdir'), + 'Should include symlinked-dir/subdir'); + assert.ok(sortedResults.includes('symlinked-dir/subdir/nested.txt'), + 'Should include symlinked-dir/subdir/nested.txt'); +}); + +test('glob with followSymlinks=false explicitly set', async () => { + if (common.isWindows) { + // Skip on Windows as symlinks require special permissions + return; + } + + const results = []; + for await (const file of asyncGlob('**/*.txt', { cwd: fixtureDir, followSymlinks: false })) { + results.push(file); + } + const sortedResults = results.sort(); + + // Should not include files from symlinked-dir + assert.ok(sortedResults.includes('real-dir/file.txt'), + 'Should include real-dir/file.txt'); + assert.ok(!sortedResults.includes('symlinked-dir/file.txt'), + 'Should not include symlinked-dir/file.txt when followSymlinks is false'); +}); + +test('glob with withFileTypes and followSymlinks', async () => { + if (common.isWindows) { + // Skip on Windows as symlinks require special permissions + return; + } + + const results = []; + for await (const entry of asyncGlob('**/*.txt', { cwd: fixtureDir, withFileTypes: true, followSymlinks: true })) { + results.push(entry); + } + + const names = results.map((r) => r.name).sort(); + + // Should include files from symlinked-dir + assert.ok(names.includes('file.txt'), + 'Should include file.txt'); + assert.ok(names.includes('nested.txt'), + 'Should include nested.txt'); + assert.ok(names.includes('regular.txt'), + 'Should include regular.txt'); + + // Verify all results are file entries (not symlinks anymore, they should be resolved as files) + results.forEach((entry) => { + assert.ok(entry.isFile() || entry.isDirectory(), `Entry ${entry.name} should be a file or directory`); + }); +}); + +test('glob with followSymlinks avoids cycles', async () => { + if (common.isWindows) { + return; + } + + // Create a symlink that points back up the tree to test cycle protection + const loopDir = resolve(fixtureDir, 'loop'); + await mkdir(loopDir, { recursive: true }); + const loopTarget = resolve(loopDir, 'back'); + await symlink(fixtureDir, loopTarget, 'dir'); + + const results = []; + for await (const file of asyncGlob('**/*.txt', { cwd: fixtureDir, followSymlinks: true })) { + results.push(file); + // Hard break guard: if we ever blow up beyond a sane bound, bail to avoid hanging tests + assert.ok(results.length < 200, 'Traversal should not loop infinitely when following symlinks'); + } + + // Ensure we still find original files + assert.ok(results.includes('real-dir/file.txt')); + assert.ok(results.includes('regular-dir/regular.txt')); +}); + +test('globSync with followSymlinks avoids cycles', () => { + if (common.isWindows) { + return; + } + + const loopDir = resolve(fixtureDir, 'loop-sync'); + mkdirSync(loopDir, { recursive: true }); + const loopTarget = resolve(loopDir, 'back'); + symlinkSync(fixtureDir, loopTarget, 'dir'); + + const results = globSync('**/*.txt', { cwd: fixtureDir, followSymlinks: true }); + + assert.ok(results.includes('real-dir/file.txt')); + assert.ok(results.includes('regular-dir/regular.txt')); + assert.ok(results.length < 200, 'Traversal should not loop infinitely when following symlinks (sync)'); +});