From 2972d97b2e3249b4e7eccb7626ad838257c96783 Mon Sep 17 00:00:00 2001 From: Alexander Lichter Date: Fri, 19 Jun 2026 13:23:47 +0200 Subject: [PATCH] fs: add maxDepth option to glob --- benchmark/fs/bench-glob.js | 11 +- doc/api/fs.md | 6 + lib/internal/fs/glob.js | 416 ++++++++++++++++++++++++++++++--- test/parallel/test-fs-glob.mjs | 253 +++++++++++++++++++- 4 files changed, 654 insertions(+), 32 deletions(-) diff --git a/benchmark/fs/bench-glob.js b/benchmark/fs/bench-glob.js index 74612701e2182a..4652a48f5241ae 100644 --- a/benchmark/fs/bench-glob.js +++ b/benchmark/fs/bench-glob.js @@ -16,6 +16,7 @@ const configs = { dir: ['lib'], pattern: ['**/*', '*.js', '**/**.js'], mode: ['sync', 'promise', 'callback'], + maxDepth: ['default', '2'], recursive: ['true', 'false'], }; @@ -23,8 +24,11 @@ const bench = common.createBenchmark(main, configs); async function main(config) { const fullPath = path.resolve(benchmarkDirectory, config.dir); - const { pattern, recursive, mode } = config; + const { pattern, recursive, mode, maxDepth } = config; const options = { cwd: fullPath, recursive }; + if (maxDepth !== 'default') { + options.maxDepth = Number(maxDepth); + } const callback = (resolve, reject) => { glob(pattern, options, (err, matches) => { if (err) { @@ -44,7 +48,10 @@ async function main(config) { noDead = globSync(pattern, options); break; case 'promise': - noDead = await globAsync(pattern, options); + noDead = []; + for await (const match of globAsync(pattern, options)) { + noDead.push(match); + } break; case 'callback': noDead = await new Promise(callback); diff --git a/doc/api/fs.md b/doc/api/fs.md index a38a0151e0a9e9..49afb1a18642fd 100644 --- a/doc/api/fs.md +++ b/doc/api/fs.md @@ -1436,6 +1436,8 @@ changes: not supported. * `followSymlinks` {boolean} When `true`, symbolic links to directories are followed while expanding `**` patterns. **Default:** `false`. + * `maxDepth` {integer} Maximum number of directory levels to traverse. + The `cwd` directory has a depth of `0`. **Default:** `Infinity`. * `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 @@ -3590,6 +3592,8 @@ changes: `true` to exclude the item, `false` to include it. **Default:** `undefined`. * `followSymlinks` {boolean} When `true`, symbolic links to directories are followed while expanding `**` patterns. **Default:** `false`. + * `maxDepth` {integer} Maximum number of directory levels to traverse. + The `cwd` directory has a depth of `0`. **Default:** `Infinity`. * `withFileTypes` {boolean} `true` if the glob should return paths as Dirents, `false` otherwise. **Default:** `false`. @@ -6220,6 +6224,8 @@ changes: `true` to exclude the item, `false` to include it. **Default:** `undefined`. * `followSymlinks` {boolean} When `true`, symbolic links to directories are followed while expanding `**` patterns. **Default:** `false`. + * `maxDepth` {integer} Maximum number of directory levels to traverse. + The `cwd` directory has a depth of `0`. **Default:** `Infinity`. * `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 6cda2c9e6da073..50950b291a61a9 100644 --- a/lib/internal/fs/glob.js +++ b/lib/internal/fs/glob.js @@ -5,6 +5,7 @@ const { ArrayIsArray, ArrayPrototypeAt, ArrayPrototypeFlatMap, + ArrayPrototypeJoin, ArrayPrototypeMap, ArrayPrototypePop, ArrayPrototypePush, @@ -14,6 +15,10 @@ const { SafeMap, SafeSet, StringPrototypeEndsWith, + StringPrototypeSlice, + StringPrototypeSplit, + StringPrototypeStartsWith, + StringPrototypeToLowerCase, } = primordials; const { @@ -22,13 +27,14 @@ const { realpathSync, statSync, } = require('fs'); +const realpathSyncNative = realpathSync.native; const { lstat, readdir, realpath, stat, } = require('fs/promises'); -const { join, resolve, basename, isAbsolute, dirname } = require('path'); +const { join, resolve, relative, basename, isAbsolute, dirname, parse, sep } = require('path'); const { kEmptyObject, @@ -37,6 +43,7 @@ const { } = require('internal/util'); const { validateBoolean, + validateInteger, validateObject, validateString, validateStringArray, @@ -132,10 +139,71 @@ function cloneSet(values) { return cloned; } +function isOutsidePath(path) { + return isAbsolute(path) || + path === '..' || + StringPrototypeStartsWith(path, `..${sep}`); +} + +function isStrictDescendant(path) { + return path !== '' && !isOutsidePath(path); +} + +function pathDepth(path) { + if (path === '') { + return 0; + } + if (isAbsolute(path)) { + return Infinity; + } + return StringPrototypeSplit(path, sep).length; +} + +function ancestorAtDepth(path, relativePath) { + let ancestor = path; + if (relativePath !== '') { + const depth = pathDepth(relativePath); + for (let i = 0; i < depth; i++) { + ancestor = dirname(ancestor); + } + } + return ancestor; +} + +function caseSensitiveRelative(path, otherPath) { + const pathRoot = parse(path).root; + const otherPathRoot = parse(otherPath).root; + if (StringPrototypeToLowerCase(pathRoot) !== + StringPrototypeToLowerCase(otherPathRoot)) { + return otherPath; + } + const pathTail = StringPrototypeSlice(path, pathRoot.length); + const otherPathTail = StringPrototypeSlice(otherPath, otherPathRoot.length); + const pathParts = pathTail === '' ? [] : StringPrototypeSplit(pathTail, sep); + const otherPathParts = otherPathTail === '' ? + [] : StringPrototypeSplit(otherPathTail, sep); + let common = 0; + while (common < pathParts.length && + common < otherPathParts.length && + pathParts[common] === otherPathParts[common]) { + common++; + } + const parts = []; + for (let i = common; i < pathParts.length; i++) { + ArrayPrototypePush(parts, '..'); + } + for (let i = common; i < otherPathParts.length; i++) { + ArrayPrototypePush(parts, otherPathParts[i]); + } + return ArrayPrototypeJoin(parts, sep); +} + class Cache { #cache = new SafeMap(); #statsCache = new SafeMap(); #followStatsCache = new SafeMap(); + #identityCache = new SafeMap(); + #identityRealpathCache = new SafeMap(); #readdirCache = new SafeMap(); #realpathCache = new SafeMap(); @@ -181,6 +249,57 @@ class Cache { this.#followStatsCache.set(path, val); return val; } + identity(path) { + const cached = this.#identityCache.get(path); + if (cached) { + return cached; + } + const promise = PromisePrototypeThen( + lstat(path), + ({ dev, ino }) => `${dev}:${ino}`, + () => null, + ); + this.#identityCache.set(path, promise); + return promise; + } + identitySync(path) { + const cached = this.#identityCache.get(path); + if (cached && !(cached instanceof Promise)) { + return cached; + } + let val; + try { + const { dev, ino } = lstatSync(path); + val = `${dev}:${ino}`; + } catch { + val = null; + } + this.#identityCache.set(path, val); + return val; + } + identityRealpath(path) { + const cached = this.#identityRealpathCache.get(path); + if (cached) { + return cached; + } + const promise = PromisePrototypeThen(realpath(path), null, () => null); + this.#identityRealpathCache.set(path, promise); + return promise; + } + identityRealpathSync(path) { + const cached = this.#identityRealpathCache.get(path); + if (cached && !(cached instanceof Promise)) { + return cached; + } + let val; + try { + val = realpathSyncNative(path); + } catch { + val = null; + } + this.#identityRealpathCache.set(path, val); + return val; + } realpath(path) { const cached = this.#realpathCache.get(path); if (cached) { @@ -245,6 +364,192 @@ class Cache { } } +class DepthLimiter { + #root; + #maxDepth; + #cache; + #relativeCache = new SafeMap(); + + constructor(root, maxDepth, cache) { + this.#root = resolve(root); + this.#maxDepth = maxDepth; + this.#cache = cache; + } + + #relativeCandidate(path) { + const fullpath = resolve(this.#root, path); + const pathFromRoot = relative(this.#root, fullpath); + if (isWindows && !isOutsidePath(pathFromRoot)) { + return { + __proto__: null, + pathFromRoot, + caseVariantRoot: ancestorAtDepth(fullpath, pathFromRoot), + differentPath: caseSensitiveRelative(this.#root, fullpath), + }; + } + if (isMacOS && !isAbsolute(pathFromRoot) && isOutsidePath(pathFromRoot)) { + const foldedPathFromRoot = relative( + StringPrototypeToLowerCase(this.#root), + StringPrototypeToLowerCase(fullpath), + ); + if (!isOutsidePath(foldedPathFromRoot)) { + return { + __proto__: null, + pathFromRoot: foldedPathFromRoot, + caseVariantRoot: ancestorAtDepth(fullpath, foldedPathFromRoot), + differentPath: pathFromRoot, + }; + } + } + return { __proto__: null, pathFromRoot }; + } + + #isSamePath(path, otherPath) { + if (path === otherPath) { + return true; + } + if (isWindows) { + const realPath = this.#cache.identityRealpathSync(path); + return realPath !== null && + realPath === this.#cache.identityRealpathSync(otherPath); + } + const identity = this.#cache.identitySync(path); + return identity !== null && + identity === this.#cache.identitySync(otherPath); + } + + async #isSamePathAsync(path, otherPath) { + if (path === otherPath) { + return true; + } + if (isWindows) { + const realPath = await this.#cache.identityRealpath(path); + return realPath !== null && + realPath === await this.#cache.identityRealpath(otherPath); + } + const identity = await this.#cache.identity(path); + return identity !== null && + identity === await this.#cache.identity(otherPath); + } + + #relativeFromRoot(path) { + const cached = this.#relativeCache.get(path); + if (cached !== undefined && !(cached instanceof Promise)) { + return cached; + } + const candidate = this.#relativeCandidate(path); + const relativePath = candidate.caseVariantRoot === undefined || + this.#isSamePath(this.#root, candidate.caseVariantRoot) ? + candidate.pathFromRoot : + candidate.differentPath; + this.#relativeCache.set(path, relativePath); + return relativePath; + } + + #relativeFromRootAsync(path) { + const cached = this.#relativeCache.get(path); + if (cached !== undefined) { + return cached; + } + const candidate = this.#relativeCandidate(path); + if (candidate.caseVariantRoot === undefined) { + this.#relativeCache.set(path, candidate.pathFromRoot); + return candidate.pathFromRoot; + } + const promise = PromisePrototypeThen( + this.#isSamePathAsync(this.#root, candidate.caseVariantRoot), + (isSamePath) => (isSamePath ? + candidate.pathFromRoot : + candidate.differentPath), + ); + this.#relativeCache.set(path, promise); + return promise; + } + + #ancestorCandidate(path) { + const fullpath = resolve(this.#root, path); + const rootFromPath = relative(fullpath, this.#root); + if (isStrictDescendant(rootFromPath)) { + return { __proto__: null, fullpath, needsIdentity: isWindows }; + } + if (isMacOS) { + const foldedRootFromPath = relative( + StringPrototypeToLowerCase(fullpath), + StringPrototypeToLowerCase(this.#root), + ); + if (isStrictDescendant(foldedRootFromPath)) { + return { __proto__: null, fullpath, needsIdentity: true }; + } + } + return null; + } + + #isAncestorOfRoot(path) { + const candidate = this.#ancestorCandidate(path); + if (candidate === null) { + return false; + } + if (!candidate.needsIdentity) { + return true; + } + const realRoot = this.#cache.identityRealpathSync(this.#root); + const realFullpath = this.#cache.identityRealpathSync(candidate.fullpath); + if (realRoot === null || realFullpath === null) { + return false; + } + const realRootFromPath = isWindows ? + caseSensitiveRelative(realFullpath, realRoot) : + relative(realFullpath, realRoot); + return isStrictDescendant(realRootFromPath); + } + + async #isAncestorOfRootAsync(path) { + const candidate = this.#ancestorCandidate(path); + if (candidate === null) { + return false; + } + if (!candidate.needsIdentity) { + return true; + } + const realRoot = await this.#cache.identityRealpath(this.#root); + const realFullpath = + await this.#cache.identityRealpath(candidate.fullpath); + if (realRoot === null || realFullpath === null) { + return false; + } + const realRootFromPath = isWindows ? + caseSensitiveRelative(realFullpath, realRoot) : + relative(realFullpath, realRoot); + return isStrictDescendant(realRootFromPath); + } + + includes(path) { + return pathDepth(this.#relativeFromRoot(path)) <= this.#maxDepth; + } + + async includesAsync(path) { + return pathDepth(await this.#relativeFromRootAsync(path)) <= this.#maxDepth; + } + + canTraverse(path, isAbsolutePattern) { + return (isAbsolutePattern && this.#isAncestorOfRoot(path)) || + pathDepth(this.#relativeFromRoot(path)) < this.#maxDepth; + } + + async canTraverseAsync(path, isAbsolutePattern) { + return (isAbsolutePattern && await this.#isAncestorOfRootAsync(path)) || + pathDepth(await this.#relativeFromRootAsync(path)) < this.#maxDepth; + } +} + +function createDepthLimiter(root, maxDepth, cache) { + if (maxDepth == null || maxDepth === Infinity) { + return null; + } + validateInteger(maxDepth, 'options.maxDepth', 0); + return new DepthLimiter(root, maxDepth, cache); +} + class Pattern { #pattern; #globStrings; @@ -252,12 +557,14 @@ class Pattern { symlinks; realpaths; last; + isAbsolute; - constructor(pattern, globStrings, indexes, symlinks, realpaths = new SafeSet()) { + constructor(pattern, globStrings, indexes, symlinks, isAbsolute, realpaths = new SafeSet()) { this.#pattern = pattern; this.#globStrings = globStrings; this.indexes = indexes; this.symlinks = symlinks; + this.isAbsolute = isAbsolute; this.realpaths = realpaths; this.last = pattern.length - 1; } @@ -277,7 +584,7 @@ class Pattern { return ArrayPrototypeAt(this.#pattern, index); } child(indexes, symlinks = new SafeSet(), realpaths = this.realpaths) { - return new Pattern(this.#pattern, this.#globStrings, indexes, symlinks, realpaths); + return new Pattern(this.#pattern, this.#globStrings, indexes, symlinks, this.isAbsolute, realpaths); } test(index, path) { if (index > this.#pattern.length) { @@ -336,15 +643,18 @@ class Glob { #patterns; #withFileTypes; #followSymlinks = false; + #depthLimiter; #isExcluded = () => false; constructor(pattern, options = kEmptyObject) { validateObject(options, 'options'); - const { exclude, cwd, followSymlinks, withFileTypes } = options; + const { exclude, cwd, followSymlinks, maxDepth, withFileTypes } = options; this.#root = toPathIfFileURL(cwd) ?? '.'; if (followSymlinks != null) { validateBoolean(followSymlinks, 'options.followSymlinks'); this.#followSymlinks = followSymlinks; } + this.#depthLimiter = + createDepthLimiter(this.#root, maxDepth, this.#cache); this.#withFileTypes = !!withFileTypes; if (exclude != null) { validateStringArrayOrFunction(exclude, 'options.exclude'); @@ -371,13 +681,34 @@ class Glob { patterns = [pattern]; } this.matchers = ArrayPrototypeMap(patterns, (pattern) => createMatcher(pattern)); - this.#patterns = ArrayPrototypeFlatMap(this.matchers, (matcher) => ArrayPrototypeMap(matcher.set, - (pattern, i) => new Pattern( - pattern, - matcher.globParts[i], - new SafeSet().add(0), - new SafeSet(), - ))); + this.#patterns = ArrayPrototypeFlatMap(this.matchers, (matcher) => + ArrayPrototypeMap(matcher.set, (pattern, i) => new Pattern( + pattern, + matcher.globParts[i], + new SafeSet().add(0), + new SafeSet(), + isAbsolute(ArrayPrototypeJoin(matcher.globParts[i], sep)), + ))); + } + + #addResult(path) { + return (this.#depthLimiter === null || + this.#depthLimiter.includes(path)) && + this.#results.add(path); + } + + async #addResultAsync(path) { + return await this.#depthLimiter.includesAsync(path) && + this.#results.add(path); + } + + #canTraverse(path, pattern) { + return this.#depthLimiter === null || + this.#depthLimiter.canTraverse(path, pattern.isAbsolute); + } + + async #canTraverseAsync(path, pattern) { + return this.#depthLimiter.canTraverseAsync(path, pattern.isAbsolute); } globSync() { @@ -532,7 +863,7 @@ class Glob { const p = pattern.at(-1); const stat = this.#cache.statSync(join(fullpath, p)); if (stat && (p || isDirectory)) { - this.#results.add(join(path, p)); + this.#addResult(join(path, p)); } if (pattern.indexes.size === 1 && pattern.indexes.has(last)) { return; @@ -541,10 +872,12 @@ class Glob { (path !== '.' || pattern.at(0) === '.' || (last === 0 && stat))) { // If pattern ends with **, add to results // if path is ".", add it only if pattern starts with "." or pattern is exactly "**" - this.#results.add(path); + this.#addResult(path); } - if (!isDirectory || this.#isCyclicSync(fullpath, isDirectory, pattern)) { + if (!isDirectory || + !this.#canTraverse(path, pattern) || + this.#isCyclicSync(fullpath, isDirectory, pattern)) { return; } @@ -604,14 +937,14 @@ class Glob { subPatterns.add(index); } else if (!fromSymlink && index === last) { // If ** is last, add to results - this.#results.add(entryPath); + this.#addResult(entryPath); } // Any pattern after ** is also a potential pattern // so we can already test it here if (nextMatches && nextIndex === last && !isLast) { // If next pattern is the last one, add to results - this.#results.add(entryPath); + this.#addResult(entryPath); } else if (nextMatches && entryIsDirectory) { // Pattern matched, meaning two patterns forward // are also potential patterns @@ -644,11 +977,11 @@ class Glob { } else { if (!this.#cache.seen(path, pattern, nextIndex)) { this.#cache.add(path, pattern.child(new SafeSet().add(nextIndex))); - this.#results.add(path); + this.#addResult(path); } if (!this.#cache.seen(path, pattern, nextIndex) || !this.#cache.seen(parent, pattern, nextIndex)) { this.#cache.add(parent, pattern.child(new SafeSet().add(nextIndex))); - this.#results.add(parent); + this.#addResult(parent); } } } @@ -661,7 +994,7 @@ class Glob { } else if (current === '.' && pattern.test(nextIndex, entry.name)) { // If current pattern is ".", proceed to test next pattern if (nextIndex === last) { - this.#results.add(entryPath); + this.#addResult(entryPath); } else { subPatterns.add(nextIndex + 1); } @@ -671,7 +1004,7 @@ class Glob { // If current pattern is a regex that matches entry name (e.g *.js) // add next pattern to potential patterns, or to results if it's the last pattern if (index === last) { - this.#results.add(entryPath); + this.#addResult(entryPath); } else if (entryIsDirectory) { subPatterns.add(nextIndex); } @@ -708,6 +1041,7 @@ class Glob { const isDirectory = await this.#isDirectory(fullpath, stat, pattern); const isLast = pattern.isLast(isDirectory); const isFirst = pattern.isFirst(); + const finiteDepth = this.#depthLimiter !== null; if (this.#isExcluded(fullpath)) { return; @@ -740,7 +1074,9 @@ class Glob { if (stat && (p || isDirectory)) { const result = join(path, p); if (!this.#results.has(result)) { - if (this.#results.add(result)) { + if (!finiteDepth ? + this.#results.add(result) : + await this.#addResultAsync(result)) { yield this.#withFileTypes ? stat : result; } } @@ -753,13 +1089,21 @@ class Glob { // If pattern ends with **, add to results // if path is ".", add it only if pattern starts with "." or pattern is exactly "**" if (!this.#results.has(path)) { - if (this.#results.add(path)) { + if (!finiteDepth ? + this.#results.add(path) : + await this.#addResultAsync(path)) { yield this.#withFileTypes ? stat : path; } } } - if (!isDirectory || await this.#isCyclic(fullpath, isDirectory, pattern)) { + let canTraverse = true; + if (isDirectory && finiteDepth) { + canTraverse = await this.#canTraverseAsync(path, pattern); + } + if (!isDirectory || + !canTraverse || + await this.#isCyclic(fullpath, isDirectory, pattern)) { return; } @@ -821,7 +1165,10 @@ class Glob { subPatterns.add(index); } else if (!fromSymlink && index === last) { // If ** is last, add to results - if (!this.#results.has(entryPath) && this.#results.add(entryPath)) { + if (!this.#results.has(entryPath) && + (!finiteDepth ? + this.#results.add(entryPath) : + await this.#addResultAsync(entryPath))) { yield this.#withFileTypes ? entry : entryPath; } } @@ -830,7 +1177,10 @@ class Glob { // so we can already test it here if (nextMatches && nextIndex === last && !isLast) { // If next pattern is the last one, add to results - if (!this.#results.has(entryPath) && this.#results.add(entryPath)) { + if (!this.#results.has(entryPath) && + (!finiteDepth ? + this.#results.add(entryPath) : + await this.#addResultAsync(entryPath))) { yield this.#withFileTypes ? entry : entryPath; } } else if (nextMatches && entryIsDirectory) { @@ -866,7 +1216,9 @@ class Glob { if (!this.#cache.seen(path, pattern, nextIndex)) { this.#cache.add(path, pattern.child(new SafeSet().add(nextIndex))); if (!this.#results.has(path)) { - if (this.#results.add(path)) { + if (!finiteDepth ? + this.#results.add(path) : + await this.#addResultAsync(path)) { yield this.#withFileTypes ? this.#cache.statSync(fullpath) : path; } } @@ -874,7 +1226,9 @@ class Glob { if (!this.#cache.seen(path, pattern, nextIndex) || !this.#cache.seen(parent, pattern, nextIndex)) { this.#cache.add(parent, pattern.child(new SafeSet().add(nextIndex))); if (!this.#results.has(parent)) { - if (this.#results.add(parent)) { + if (!finiteDepth ? + this.#results.add(parent) : + await this.#addResultAsync(parent)) { yield this.#withFileTypes ? this.#cache.statSync(join(this.#root, parent)) : parent; } } @@ -891,7 +1245,9 @@ class Glob { // If current pattern is ".", proceed to test next pattern if (nextIndex === last) { if (!this.#results.has(entryPath)) { - if (this.#results.add(entryPath)) { + if (!finiteDepth ? + this.#results.add(entryPath) : + await this.#addResultAsync(entryPath)) { yield this.#withFileTypes ? entry : entryPath; } } @@ -905,7 +1261,9 @@ class Glob { // add next pattern to potential patterns, or to results if it's the last pattern if (index === last) { if (!this.#results.has(entryPath)) { - if (this.#results.add(entryPath)) { + if (!finiteDepth ? + this.#results.add(entryPath) : + await this.#addResultAsync(entryPath)) { yield this.#withFileTypes ? entry : entryPath; } } diff --git a/test/parallel/test-fs-glob.mjs b/test/parallel/test-fs-glob.mjs index 560b4e72e4adec..53a91b65ce1f21 100644 --- a/test/parallel/test-fs-glob.mjs +++ b/test/parallel/test-fs-glob.mjs @@ -2,10 +2,21 @@ import * as common from '../common/index.mjs'; import tmpdir from '../common/tmpdir.js'; import { resolve, dirname, sep, relative, join, isAbsolute } from 'node:path'; import { mkdir, writeFile, symlink, glob as asyncGlob } from 'node:fs/promises'; -import { glob, globSync, Dirent, chmodSync, writeFileSync, rmSync } from 'node:fs'; +import { + glob, + globSync, + Dirent, + chmodSync, + writeFileSync, + rmSync, + realpathSync, + mkdirSync, + existsSync, +} from 'node:fs'; import { test, describe } from 'node:test'; import { pathToFileURL } from 'node:url'; import { promisify } from 'node:util'; +import { execFileSync } from 'node:child_process'; import assert from 'node:assert'; function assertDirents(dirents) { @@ -15,10 +26,12 @@ function assertDirents(dirents) { tmpdir.refresh(); const fixtureDir = tmpdir.resolve('fixtures'); +const caseVariantFixtureDir = tmpdir.resolve('FIXTURES'); const absDir = tmpdir.resolve('abs'); async function setup() { await mkdir(fixtureDir, { recursive: true }); + await mkdir(caseVariantFixtureDir, { recursive: true }); await mkdir(absDir, { recursive: true }); const files = [ 'a/.abcdef/x/y/z/a', @@ -67,6 +80,21 @@ async function setup() { await setup(); +const isCaseSensitiveFileSystem = + realpathSync.native(fixtureDir) !== realpathSync.native(caseVariantFixtureDir); + +let differentWindowsVolume; +if (common.isWindows) { + const currentRoot = resolve(fixtureDir).slice(0, 3).toLowerCase(); + for (let code = 65; code <= 90; code++) { + const root = `${String.fromCharCode(code)}:\\`; + if (root.toLowerCase() !== currentRoot && existsSync(root)) { + differentWindowsVolume = root; + break; + } + } +} + const patterns = { 'a/c/d/*/b': ['a/c/d/c/b'], 'a//c//d//*//b': ['a/c/d/c/b'], @@ -538,6 +566,229 @@ describe('fsPromises glob - exclude', function() { } }); +const maxDepthExpected = [ + '.', + 'a', + 'a/abcdef', + 'a/abcfed', + 'a/b', + 'a/bc', + 'a/c', + 'a/cb', + ...(common.isWindows ? [] : ['a/symlink']), + 'a/x', + 'a/z', + 'follow', + 'follow/cycle', + 'follow/link', + 'follow/target', +].map((item) => item.replaceAll('/', sep)).sort(); + +async function collectAsyncGlob(pattern, options) { + const results = []; + for await (const item of asyncGlob(pattern, options)) { + results.push(item); + } + return results; +} + +const maxDepthApis = [ + ['globSync', globSync], + ['fsPromises glob', collectAsyncGlob], +]; + +for (const [name, runGlob] of maxDepthApis) { + describe(`${name} - maxDepth`, function() { + test('limits traversal depth', async () => { + const actual = (await runGlob('**', { cwd: fixtureDir, maxDepth: 2 })).sort(); + assert.deepStrictEqual(actual, maxDepthExpected); + assert.deepStrictEqual( + await runGlob('../**', { cwd: fixtureDir, maxDepth: 0 }), + [], + ); + }); + + test('limits literal patterns', async () => { + assert.deepStrictEqual( + await runGlob('a/b/c/d', { cwd: fixtureDir, maxDepth: 3 }), + [], + ); + assert.deepStrictEqual( + await runGlob(fixtureDir, { cwd: fixtureDir, maxDepth: 0 }), + [fixtureDir], + ); + + const absolutePattern = resolve(fixtureDir, 'a/b/c/d'); + assert.deepStrictEqual( + await runGlob(absolutePattern, { cwd: fixtureDir, maxDepth: 3 }), + [], + ); + assert.deepStrictEqual( + await runGlob(absolutePattern, { cwd: fixtureDir, maxDepth: 4 }), + [absolutePattern], + ); + }); + + test('limits absolute wildcard patterns rooted above cwd', async () => { + assert.deepStrictEqual( + await runGlob(join(dirname(fixtureDir), '*'), { + cwd: fixtureDir, + maxDepth: 0, + }), + [fixtureDir], + ); + }); + + test('supports case-variant descendants', { + skip: isCaseSensitiveFileSystem, + }, async () => { + const expected = [ + join(caseVariantFixtureDir, 'a'), + join(caseVariantFixtureDir, 'follow'), + ].sort(); + const actual = await runGlob( + join(caseVariantFixtureDir, '*'), + { cwd: fixtureDir, maxDepth: 1 }, + ); + assert.deepStrictEqual(actual.sort(), expected); + }); + + test('supports absolute alternatives after brace expansion', async () => { + const pattern = `{${join(dirname(fixtureDir), '*')},other}`; + assert.deepStrictEqual( + await runGlob(pattern, { cwd: fixtureDir, maxDepth: 0 }), + [fixtureDir], + ); + }); + + test('preserves filesystem case sensitivity', { + skip: !isCaseSensitiveFileSystem, + }, async () => { + assert.deepStrictEqual( + await runGlob(join(dirname(fixtureDir), '*'), { + cwd: fixtureDir, + maxDepth: 0, + }), + [fixtureDir], + ); + }); + + test('recognizes equivalent path casing', { + skip: isCaseSensitiveFileSystem, + }, async () => { + assert.deepStrictEqual( + await runGlob(caseVariantFixtureDir, { + cwd: fixtureDir, + maxDepth: 0, + }), + [caseVariantFixtureDir], + ); + }); + + test('rejects paths on a different Windows volume', { + skip: differentWindowsVolume === undefined, + }, async () => { + assert.deepStrictEqual( + await runGlob(differentWindowsVolume, { + cwd: fixtureDir, + maxDepth: 100, + }), + [], + ); + }); + }); +} + +test('glob forwards maxDepth', async () => { + assert.deepStrictEqual( + await promisify(glob)('**', { cwd: fixtureDir, maxDepth: 0 }), + ['.'], + ); +}); + +test('async glob does not use synchronous path identity calls', () => { + const script = ` + const fs = require('fs'); + const path = require('path'); + const { promisify } = require('util'); + const fail = () => { + throw new Error('unexpected synchronous filesystem call'); + }; + fs.lstatSync = fail; + fs.realpathSync.native = fail; + (async () => { + const pattern = path.join(${JSON.stringify(caseVariantFixtureDir)}, '*'); + const options = { + cwd: ${JSON.stringify(fixtureDir)}, + maxDepth: 1, + }; + await promisify(fs.glob)(pattern, options); + for await (const entry of fs.promises.glob(pattern, options)) { + void entry; + } + })().catch((error) => { + console.error(error); + process.exitCode = 1; + }); + `; + execFileSync(process.execPath, ['--expose-internals', '-e', script]); +}); + +function assertDoesNotTraverse(sibling, cwd) { + const script = ` + const fs = require('fs'); + const path = require('path'); + const sibling = ${JSON.stringify(sibling)}; + const original = fs.readdirSync; + let reads = 0; + fs.readdirSync = function(pathname, ...args) { + const resolved = path.resolve(pathname); + if (resolved === sibling || resolved.startsWith(sibling + path.sep)) { + reads++; + } + return Reflect.apply(original, this, [pathname, ...args]); + }; + fs.globSync(path.join(sibling, '**'), { + cwd: ${JSON.stringify(cwd)}, + maxDepth: 0, + }); + process.stdout.write(String(reads)); + `; + assert.strictEqual( + execFileSync(process.execPath, ['--expose-internals', '-e', script], { + encoding: 'utf8', + }), + '0', + ); +} + +describe('globSync - maxDepth traversal', function() { + test('does not traverse absolute sibling subtrees', () => { + const siblingDir = tmpdir.resolve('sibling'); + mkdirSync(join(siblingDir, 'nested'), { recursive: true }); + assertDoesNotTraverse(siblingDir, fixtureDir); + }); + + test('does not traverse case-variant sibling subtrees', { + skip: !isCaseSensitiveFileSystem, + }, () => { + assertDoesNotTraverse(caseVariantFixtureDir, join(fixtureDir, 'a')); + }); +}); + +test('globSync validates maxDepth', () => { + assert.deepStrictEqual( + globSync('a', { cwd: fixtureDir, maxDepth: Infinity }), + ['a'], + ); + + for (const maxDepth of [-1, 1.5, NaN, '1']) { + assert.throws(() => globSync('**', { cwd: fixtureDir, maxDepth }), { + code: typeof maxDepth === 'string' ? 'ERR_INVALID_ARG_TYPE' : 'ERR_OUT_OF_RANGE', + }); + } +}); + const followSymlinkPattern = 'follow/**'; const followSymlinkExpected = [ 'follow',