Skip to content

Commit 85df769

Browse files
fs: add option for recursive symlink traversal
1 parent ae5cbda commit 85df769

File tree

3 files changed

+58
-15
lines changed

3 files changed

+58
-15
lines changed

doc/api/fs.md

Lines changed: 2 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` to traverse matching symbolic links to directories,
1119+
`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

lib/internal/fs/glob.js

Lines changed: 37 additions & 15 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: fsStatSync } = require('fs');
20+
const { lstat, readdir, stat: fsStat } = require('fs/promises');
2121
const { join, resolve, basename, isAbsolute, dirname } = require('path');
2222

2323
const {
@@ -264,12 +264,14 @@ class Glob {
264264
#subpatterns = new SafeMap();
265265
#patterns;
266266
#withFileTypes;
267+
#followSymlinks;
267268
#isExcluded = () => false;
268269
constructor(pattern, options = kEmptyObject) {
269270
validateObject(options, 'options');
270-
const { exclude, cwd, withFileTypes } = options;
271+
const { exclude, cwd, withFileTypes, followSymlinks } = options;
271272
this.#root = toPathIfFileURL(cwd) ?? '.';
272273
this.#withFileTypes = !!withFileTypes;
274+
this.#followSymlinks = !!followSymlinks;
273275
if (exclude != null) {
274276
validateStringArrayOrFunction(exclude, 'options.exclude');
275277
if (ArrayIsArray(exclude)) {
@@ -429,6 +431,16 @@ class Glob {
429431
const entryPath = join(path, entry.name);
430432
this.#cache.addToStatCache(join(fullpath, entry.name), entry);
431433

434+
let isDirectory = entry.isDirectory();
435+
if (entry.isSymbolicLink() && this.#followSymlinks) {
436+
try {
437+
const stat = fsStatSync(join(fullpath, entry.name));
438+
isDirectory = stat.isDirectory();
439+
} catch {
440+
// ignore
441+
}
442+
}
443+
432444
const subPatterns = new SafeSet();
433445
const nSymlinks = new SafeSet();
434446
for (const index of pattern.indexes) {
@@ -456,7 +468,7 @@ class Glob {
456468
(this.#exclude && this.#exclude(this.#withFileTypes ? entry : entry.name))) {
457469
continue;
458470
}
459-
if (!fromSymlink && entry.isDirectory()) {
471+
if (!fromSymlink && isDirectory) {
460472
// If directory, add ** to its potential patterns
461473
subPatterns.add(index);
462474
} else if (!fromSymlink && index === last) {
@@ -469,24 +481,24 @@ class Glob {
469481
if (nextMatches && nextIndex === last && !isLast) {
470482
// If next pattern is the last one, add to results
471483
this.#results.add(entryPath);
472-
} else if (nextMatches && entry.isDirectory()) {
484+
} else if (nextMatches && isDirectory) {
473485
// Pattern matched, meaning two patterns forward
474486
// are also potential patterns
475487
// e.g **/b/c when entry is a/b - add c to potential patterns
476488
subPatterns.add(index + 2);
477489
}
478490
if ((nextMatches || pattern.at(0) === '.') &&
479-
(entry.isDirectory() || entry.isSymbolicLink()) && !fromSymlink) {
491+
(isDirectory || entry.isSymbolicLink()) && !fromSymlink) {
480492
// If pattern after ** matches, or pattern starts with "."
481493
// and entry is a directory or symlink, add to potential patterns
482494
subPatterns.add(nextIndex);
483495
}
484496

485-
if (entry.isSymbolicLink()) {
497+
if (entry.isSymbolicLink() && !this.#followSymlinks) {
486498
nSymlinks.add(index);
487499
}
488500

489-
if (next === '..' && entry.isDirectory()) {
501+
if (next === '..' && isDirectory) {
490502
// In case pattern is "**/..",
491503
// both parent and current directory should be added to the queue
492504
// if this is the last pattern, add to results instead
@@ -529,7 +541,7 @@ class Glob {
529541
// add next pattern to potential patterns, or to results if it's the last pattern
530542
if (index === last) {
531543
this.#results.add(entryPath);
532-
} else if (entry.isDirectory()) {
544+
} else if (isDirectory) {
533545
subPatterns.add(nextIndex);
534546
}
535547
}
@@ -639,6 +651,16 @@ class Glob {
639651
const entryPath = join(path, entry.name);
640652
this.#cache.addToStatCache(join(fullpath, entry.name), entry);
641653

654+
let isDirectory = entry.isDirectory();
655+
if (entry.isSymbolicLink() && this.#followSymlinks) {
656+
try {
657+
const s = await fsStat(join(fullpath, entry.name));
658+
isDirectory = s.isDirectory();
659+
} catch {
660+
// ignore
661+
}
662+
}
663+
642664
const subPatterns = new SafeSet();
643665
const nSymlinks = new SafeSet();
644666
for (const index of pattern.indexes) {
@@ -666,7 +688,7 @@ class Glob {
666688
(this.#exclude && this.#exclude(this.#withFileTypes ? entry : entry.name))) {
667689
continue;
668690
}
669-
if (!fromSymlink && entry.isDirectory()) {
691+
if (!fromSymlink && isDirectory) {
670692
// If directory, add ** to its potential patterns
671693
subPatterns.add(index);
672694
} else if (!fromSymlink && index === last) {
@@ -683,24 +705,24 @@ class Glob {
683705
if (!this.#results.has(entryPath) && this.#results.add(entryPath)) {
684706
yield this.#withFileTypes ? entry : entryPath;
685707
}
686-
} else if (nextMatches && entry.isDirectory()) {
708+
} else if (nextMatches && isDirectory) {
687709
// Pattern matched, meaning two patterns forward
688710
// are also potential patterns
689711
// e.g **/b/c when entry is a/b - add c to potential patterns
690712
subPatterns.add(index + 2);
691713
}
692714
if ((nextMatches || pattern.at(0) === '.') &&
693-
(entry.isDirectory() || entry.isSymbolicLink()) && !fromSymlink) {
715+
(isDirectory || entry.isSymbolicLink()) && !fromSymlink) {
694716
// If pattern after ** matches, or pattern starts with "."
695717
// and entry is a directory or symlink, add to potential patterns
696718
subPatterns.add(nextIndex);
697719
}
698720

699-
if (entry.isSymbolicLink()) {
721+
if (entry.isSymbolicLink() && !this.#followSymlinks) {
700722
nSymlinks.add(index);
701723
}
702724

703-
if (next === '..' && entry.isDirectory()) {
725+
if (next === '..' && isDirectory) {
704726
// In case pattern is "**/..",
705727
// both parent and current directory should be added to the queue
706728
// if this is the last pattern, add to results instead
@@ -759,7 +781,7 @@ class Glob {
759781
yield this.#withFileTypes ? entry : entryPath;
760782
}
761783
}
762-
} else if (entry.isDirectory()) {
784+
} else if (isDirectory) {
763785
subPatterns.add(nextIndex);
764786
}
765787
}

test/parallel/test-fs-glob.mjs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -543,3 +543,22 @@ describe('glob - with restricted directory', function() {
543543
}
544544
});
545545
});
546+
547+
describe('glob follow', () => {
548+
test('should return matched files in symlinked directory when follow is true', async () => {
549+
if (common.isWindows) return;
550+
const relFilesPromise = asyncGlob('**', { cwd: fixtureDir, followSymlinks: true });
551+
let count = 0;
552+
// eslint-disable-next-line no-unused-vars
553+
for await (const file of relFilesPromise) {
554+
count++;
555+
}
556+
assert.ok(count > 0);
557+
});
558+
559+
test('should return matched files in symlinked directory when follow is true (sync)', () => {
560+
if (common.isWindows) return;
561+
const relFiles = globSync('**', { cwd: fixtureDir, followSymlinks: true });
562+
assert.ok(relFiles.length > 0);
563+
});
564+
});

0 commit comments

Comments
 (0)