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
6 changes: 6 additions & 0 deletions doc/api/fs.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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`.
Expand Down Expand Up @@ -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.
Expand Down
157 changes: 124 additions & 33 deletions lib/internal/fs/glob.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@
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 {
Expand Down Expand Up @@ -48,28 +48,46 @@

/**
* @param {string} path
* @param {boolean} followSymlinks
* @returns {Promise<DirentFromStats|null>}
*/
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));
}

/**
Expand Down Expand Up @@ -115,13 +133,31 @@
#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;
}
Expand All @@ -131,7 +167,7 @@
if (cached && !(cached instanceof Promise)) {
return cached;
}
const val = getDirentSync(path);
const val = getDirentSync(path, this.#followSymlinks);
this.#statsCache.set(path, val);
return val;
}
Expand Down Expand Up @@ -267,9 +303,12 @@
#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)) {
Expand Down Expand Up @@ -427,7 +466,33 @@
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

Check failure on line 482 in lib/internal/fs/glob.js

View workflow job for this annotation

GitHub Actions / lint-js-and-md

Non-ASCII character '–' detected

Check failure on line 482 in lib/internal/fs/glob.js

View workflow job for this annotation

GitHub Actions / lint-js-and-md

Comments should not begin with a lowercase character
}
}
}

// 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();
Expand All @@ -453,10 +518,10 @@
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) {
Expand All @@ -469,24 +534,24 @@
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
Expand Down Expand Up @@ -529,7 +594,7 @@
// 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);
}
}
Expand Down Expand Up @@ -637,7 +702,33 @@
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

Check failure on line 718 in lib/internal/fs/glob.js

View workflow job for this annotation

GitHub Actions / lint-js-and-md

Comments should not begin with a lowercase character
}
}
}

// 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();
Expand All @@ -663,16 +754,16 @@
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;
}
}

Expand All @@ -681,26 +772,26 @@
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
Expand Down Expand Up @@ -742,7 +833,7 @@
if (nextIndex === last) {
if (!this.#results.has(entryPath)) {
if (this.#results.add(entryPath)) {
yield this.#withFileTypes ? entry : entryPath;
yield this.#withFileTypes ? resolvedEntry : entryPath;
}
}
} else {
Expand All @@ -756,10 +847,10 @@
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);
}
}
Expand Down
Loading
Loading