Skip to content

Commit cab7a9f

Browse files
fs: add followSymlinks option to glob methods and tests for symlink handling
1 parent 55600e6 commit cab7a9f

File tree

3 files changed

+297
-33
lines changed

3 files changed

+297
-33
lines changed

doc/api/fs.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1115,6 +1115,8 @@ changes:
11151115
If a string array is provided, each string should be a glob pattern that
11161116
specifies paths to exclude. Note: Negation patterns (e.g., '!foo.js') are
11171117
not supported.
1118+
* `followSymlinks` {boolean} `true` if the glob should traverse symbolic
1119+
links to directories, `false` otherwise. **Default:** `false`.
11181120
* `withFileTypes` {boolean} `true` if the glob should return paths as Dirents,
11191121
`false` otherwise. **Default:** `false`.
11201122
* Returns: {AsyncIterator} An AsyncIterator that yields the paths of files
@@ -3215,6 +3217,8 @@ changes:
32153217
* `exclude` {Function|string\[]} Function to filter out files/directories or a
32163218
list of glob patterns to be excluded. If a function is provided, return
32173219
`true` to exclude the item, `false` to include it. **Default:** `undefined`.
3220+
* `followSymlinks` {boolean} `true` if the glob should traverse symbolic
3221+
links to directories, `false` otherwise. **Default:** `false`.
32183222
* `withFileTypes` {boolean} `true` if the glob should return paths as Dirents,
32193223
`false` otherwise. **Default:** `false`.
32203224
@@ -5772,6 +5776,8 @@ changes:
57725776
* `exclude` {Function|string\[]} Function to filter out files/directories or a
57735777
list of glob patterns to be excluded. If a function is provided, return
57745778
`true` to exclude the item, `false` to include it. **Default:** `undefined`.
5779+
* `followSymlinks` {boolean} `true` if the glob should traverse symbolic
5780+
links to directories, `false` otherwise. **Default:** `false`.
57755781
* `withFileTypes` {boolean} `true` if the glob should return paths as Dirents,
57765782
`false` otherwise. **Default:** `false`.
57775783
* Returns: {string\[]} paths of files that match the pattern.

lib/internal/fs/glob.js

Lines changed: 85 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@ const {
1616
StringPrototypeEndsWith,
1717
} = primordials;
1818

19-
const { lstatSync, readdirSync } = require('fs');
20-
const { lstat, readdir } = require('fs/promises');
19+
const { lstatSync, readdirSync, statSync } = require('fs');
20+
const { lstat, readdir, stat } = require('fs/promises');
2121
const { join, resolve, basename, isAbsolute, dirname } = require('path');
2222

2323
const {
@@ -48,28 +48,46 @@ function lazyMinimatch() {
4848

4949
/**
5050
* @param {string} path
51+
* @param {boolean} followSymlinks
5152
* @returns {Promise<DirentFromStats|null>}
5253
*/
53-
async function getDirent(path) {
54-
let stat;
54+
async function getDirent(path, followSymlinks = false) {
55+
let statResult;
5556
try {
56-
stat = await lstat(path);
57+
statResult = await lstat(path);
58+
// If it's a symlink and followSymlinks is true, use stat to follow it
59+
if (followSymlinks && statResult.isSymbolicLink()) {
60+
try {
61+
statResult = await stat(path);
62+
} catch {
63+
// If stat fails (e.g., broken symlink), keep the lstat result
64+
}
65+
}
5766
} catch {
5867
return null;
5968
}
60-
return new DirentFromStats(basename(path), stat, dirname(path));
69+
return new DirentFromStats(basename(path), statResult, dirname(path));
6170
}
6271

6372
/**
6473
* @param {string} path
74+
* @param {boolean} followSymlinks
6575
* @returns {DirentFromStats|null}
6676
*/
67-
function getDirentSync(path) {
68-
const stat = lstatSync(path, { throwIfNoEntry: false });
69-
if (stat === undefined) {
77+
function getDirentSync(path, followSymlinks = false) {
78+
let statResult = lstatSync(path, { throwIfNoEntry: false });
79+
if (statResult === undefined) {
7080
return null;
7181
}
72-
return new DirentFromStats(basename(path), stat, dirname(path));
82+
// If it's a symlink and followSymlinks is true, use statSync to follow it
83+
if (followSymlinks && statResult.isSymbolicLink()) {
84+
const followedStat = statSync(path, { throwIfNoEntry: false });
85+
if (followedStat !== undefined) {
86+
statResult = followedStat;
87+
}
88+
// If followedStat is undefined (broken symlink), keep the lstat result
89+
}
90+
return new DirentFromStats(basename(path), statResult, dirname(path));
7391
}
7492

7593
/**
@@ -115,13 +133,22 @@ class Cache {
115133
#cache = new SafeMap();
116134
#statsCache = new SafeMap();
117135
#readdirCache = new SafeMap();
136+
#followSymlinks = false;
137+
138+
setFollowSymlinks(followSymlinks) {
139+
this.#followSymlinks = followSymlinks;
140+
}
141+
142+
isFollowSymlinks() {
143+
return this.#followSymlinks;
144+
}
118145

119146
stat(path) {
120147
const cached = this.#statsCache.get(path);
121148
if (cached) {
122149
return cached;
123150
}
124-
const promise = getDirent(path);
151+
const promise = getDirent(path, this.#followSymlinks);
125152
this.#statsCache.set(path, promise);
126153
return promise;
127154
}
@@ -131,7 +158,7 @@ class Cache {
131158
if (cached && !(cached instanceof Promise)) {
132159
return cached;
133160
}
134-
const val = getDirentSync(path);
161+
const val = getDirentSync(path, this.#followSymlinks);
135162
this.#statsCache.set(path, val);
136163
return val;
137164
}
@@ -267,9 +294,12 @@ class Glob {
267294
#isExcluded = () => false;
268295
constructor(pattern, options = kEmptyObject) {
269296
validateObject(options, 'options');
270-
const { exclude, cwd, withFileTypes } = options;
297+
const { exclude, cwd, withFileTypes, followSymlinks } = options;
271298
this.#root = toPathIfFileURL(cwd) ?? '.';
272299
this.#withFileTypes = !!withFileTypes;
300+
if (followSymlinks === true) {
301+
this.#cache.setFollowSymlinks(true);
302+
}
273303
if (exclude != null) {
274304
validateStringArrayOrFunction(exclude, 'options.exclude');
275305
if (ArrayIsArray(exclude)) {
@@ -427,7 +457,18 @@ class Glob {
427457
for (let i = 0; i < children.length; i++) {
428458
const entry = children[i];
429459
const entryPath = join(path, entry.name);
430-
this.#cache.addToStatCache(join(fullpath, entry.name), entry);
460+
461+
// If followSymlinks is enabled and entry is a symlink, resolve it
462+
let resolvedEntry = entry;
463+
if (this.#cache.isFollowSymlinks() && entry.isSymbolicLink()) {
464+
const resolved = this.#cache.statSync(join(fullpath, entry.name));
465+
if (resolved && !resolved.isSymbolicLink()) {
466+
resolvedEntry = resolved;
467+
resolvedEntry.name = entry.name;
468+
}
469+
}
470+
471+
this.#cache.addToStatCache(join(fullpath, entry.name), resolvedEntry);
431472

432473
const subPatterns = new SafeSet();
433474
const nSymlinks = new SafeSet();
@@ -453,10 +494,10 @@ class Glob {
453494
const matchesDot = isDot && pattern.test(nextNonGlobIndex, entry.name);
454495

455496
if ((isDot && !matchesDot) ||
456-
(this.#exclude && this.#exclude(this.#withFileTypes ? entry : entry.name))) {
497+
(this.#exclude && this.#exclude(this.#withFileTypes ? resolvedEntry : entry.name))) {
457498
continue;
458499
}
459-
if (!fromSymlink && entry.isDirectory()) {
500+
if (!fromSymlink && resolvedEntry.isDirectory()) {
460501
// If directory, add ** to its potential patterns
461502
subPatterns.add(index);
462503
} else if (!fromSymlink && index === last) {
@@ -469,24 +510,24 @@ class Glob {
469510
if (nextMatches && nextIndex === last && !isLast) {
470511
// If next pattern is the last one, add to results
471512
this.#results.add(entryPath);
472-
} else if (nextMatches && entry.isDirectory()) {
513+
} else if (nextMatches && resolvedEntry.isDirectory()) {
473514
// Pattern matched, meaning two patterns forward
474515
// are also potential patterns
475516
// e.g **/b/c when entry is a/b - add c to potential patterns
476517
subPatterns.add(index + 2);
477518
}
478519
if ((nextMatches || pattern.at(0) === '.') &&
479-
(entry.isDirectory() || entry.isSymbolicLink()) && !fromSymlink) {
520+
(resolvedEntry.isDirectory() || resolvedEntry.isSymbolicLink()) && !fromSymlink) {
480521
// If pattern after ** matches, or pattern starts with "."
481522
// and entry is a directory or symlink, add to potential patterns
482523
subPatterns.add(nextIndex);
483524
}
484525

485-
if (entry.isSymbolicLink()) {
526+
if (resolvedEntry.isSymbolicLink()) {
486527
nSymlinks.add(index);
487528
}
488529

489-
if (next === '..' && entry.isDirectory()) {
530+
if (next === '..' && resolvedEntry.isDirectory()) {
490531
// In case pattern is "**/..",
491532
// both parent and current directory should be added to the queue
492533
// if this is the last pattern, add to results instead
@@ -529,7 +570,7 @@ class Glob {
529570
// add next pattern to potential patterns, or to results if it's the last pattern
530571
if (index === last) {
531572
this.#results.add(entryPath);
532-
} else if (entry.isDirectory()) {
573+
} else if (resolvedEntry.isDirectory()) {
533574
subPatterns.add(nextIndex);
534575
}
535576
}
@@ -637,7 +678,18 @@ class Glob {
637678
for (let i = 0; i < children.length; i++) {
638679
const entry = children[i];
639680
const entryPath = join(path, entry.name);
640-
this.#cache.addToStatCache(join(fullpath, entry.name), entry);
681+
682+
// If followSymlinks is enabled and entry is a symlink, resolve it
683+
let resolvedEntry = entry;
684+
if (this.#cache.isFollowSymlinks() && entry.isSymbolicLink()) {
685+
const resolved = await this.#cache.stat(join(fullpath, entry.name));
686+
if (resolved && !resolved.isSymbolicLink()) {
687+
resolvedEntry = resolved;
688+
resolvedEntry.name = entry.name;
689+
}
690+
}
691+
692+
this.#cache.addToStatCache(join(fullpath, entry.name), resolvedEntry);
641693

642694
const subPatterns = new SafeSet();
643695
const nSymlinks = new SafeSet();
@@ -663,16 +715,16 @@ class Glob {
663715
const matchesDot = isDot && pattern.test(nextNonGlobIndex, entry.name);
664716

665717
if ((isDot && !matchesDot) ||
666-
(this.#exclude && this.#exclude(this.#withFileTypes ? entry : entry.name))) {
718+
(this.#exclude && this.#exclude(this.#withFileTypes ? resolvedEntry : entry.name))) {
667719
continue;
668720
}
669-
if (!fromSymlink && entry.isDirectory()) {
721+
if (!fromSymlink && resolvedEntry.isDirectory()) {
670722
// If directory, add ** to its potential patterns
671723
subPatterns.add(index);
672724
} else if (!fromSymlink && index === last) {
673725
// If ** is last, add to results
674726
if (!this.#results.has(entryPath) && this.#results.add(entryPath)) {
675-
yield this.#withFileTypes ? entry : entryPath;
727+
yield this.#withFileTypes ? resolvedEntry : entryPath;
676728
}
677729
}
678730

@@ -681,26 +733,26 @@ class Glob {
681733
if (nextMatches && nextIndex === last && !isLast) {
682734
// If next pattern is the last one, add to results
683735
if (!this.#results.has(entryPath) && this.#results.add(entryPath)) {
684-
yield this.#withFileTypes ? entry : entryPath;
736+
yield this.#withFileTypes ? resolvedEntry : entryPath;
685737
}
686-
} else if (nextMatches && entry.isDirectory()) {
738+
} else if (nextMatches && resolvedEntry.isDirectory()) {
687739
// Pattern matched, meaning two patterns forward
688740
// are also potential patterns
689741
// e.g **/b/c when entry is a/b - add c to potential patterns
690742
subPatterns.add(index + 2);
691743
}
692744
if ((nextMatches || pattern.at(0) === '.') &&
693-
(entry.isDirectory() || entry.isSymbolicLink()) && !fromSymlink) {
745+
(resolvedEntry.isDirectory() || resolvedEntry.isSymbolicLink()) && !fromSymlink) {
694746
// If pattern after ** matches, or pattern starts with "."
695747
// and entry is a directory or symlink, add to potential patterns
696748
subPatterns.add(nextIndex);
697749
}
698750

699-
if (entry.isSymbolicLink()) {
751+
if (resolvedEntry.isSymbolicLink()) {
700752
nSymlinks.add(index);
701753
}
702754

703-
if (next === '..' && entry.isDirectory()) {
755+
if (next === '..' && resolvedEntry.isDirectory()) {
704756
// In case pattern is "**/..",
705757
// both parent and current directory should be added to the queue
706758
// if this is the last pattern, add to results instead
@@ -742,7 +794,7 @@ class Glob {
742794
if (nextIndex === last) {
743795
if (!this.#results.has(entryPath)) {
744796
if (this.#results.add(entryPath)) {
745-
yield this.#withFileTypes ? entry : entryPath;
797+
yield this.#withFileTypes ? resolvedEntry : entryPath;
746798
}
747799
}
748800
} else {
@@ -756,10 +808,10 @@ class Glob {
756808
if (index === last) {
757809
if (!this.#results.has(entryPath)) {
758810
if (this.#results.add(entryPath)) {
759-
yield this.#withFileTypes ? entry : entryPath;
811+
yield this.#withFileTypes ? resolvedEntry : entryPath;
760812
}
761813
}
762-
} else if (entry.isDirectory()) {
814+
} else if (resolvedEntry.isDirectory()) {
763815
subPatterns.add(nextIndex);
764816
}
765817
}

0 commit comments

Comments
 (0)