From 56ef7a44eeb6548dda29f1199be702c3767cf1e0 Mon Sep 17 00:00:00 2001 From: AkshatOP Date: Sun, 5 Jul 2026 12:14:51 +0530 Subject: [PATCH 1/2] vfs: make recursive readdir iterative MemoryProvider recursive readdir walked the directory tree with a recursive helper. Rewrite it to traverse iteratively with an explicit stack so a deeply nested tree can no longer exhaust the call stack. The set of directories on the active traversal path is still tracked, so a circular symlink stops descending while its entry remains listed; the output and observable behavior are unchanged. Refs: https://github.com/nodejs/node/pull/64168 Signed-off-by: AkshatOP --- lib/internal/vfs/providers/memory.js | 105 +++++++++++++++------------ 1 file changed, 60 insertions(+), 45 deletions(-) diff --git a/lib/internal/vfs/providers/memory.js b/lib/internal/vfs/providers/memory.js index 995b8f236200d5..cb3b639259d9b4 100644 --- a/lib/internal/vfs/providers/memory.js +++ b/lib/internal/vfs/providers/memory.js @@ -2,6 +2,7 @@ const { ArrayFrom, + ArrayPrototypePop, ArrayPrototypePush, DateNow, SafeMap, @@ -612,59 +613,73 @@ class MemoryProvider extends VirtualProvider { */ #readdirRecursive(dirEntry, dirPath, withFileTypes) { const results = []; + // Directories on the current traversal path. A directory reached again + // through a symlink cycle is not descended into (but is still listed). const active = new SafeSet(); - const walk = (entry, currentPath, relativePath) => { - if (active.has(entry)) { - return; + // Traverse depth-first with an explicit stack instead of recursion, so a + // deeply nested tree cannot exhaust the call stack. Each frame is a + // directory being walked together with a snapshot of its children and the + // index of the next child to visit. + const enter = (entry, currentPath, relativePath) => { + this.#ensurePopulated(entry, currentPath); + active.add(entry); + ArrayPrototypePush(stack, { + entry, + currentPath, + relativePath, + children: ArrayFrom(entry.children), + index: 0, + }); + }; + + const stack = []; + enter(dirEntry, dirPath, ''); + + while (stack.length > 0) { + const frame = stack[stack.length - 1]; + if (frame.index >= frame.children.length) { + active.delete(frame.entry); + ArrayPrototypePop(stack); + continue; } - active.add(entry); - try { - this.#ensurePopulated(entry, currentPath); - - for (const { 0: name, 1: childEntry } of entry.children) { - const childRelative = relativePath ? - relativePath + '/' + name : name; - - if (withFileTypes) { - let type; - if (childEntry.isSymbolicLink()) { - type = UV_DIRENT_LINK; - } else if (childEntry.isDirectory()) { - type = UV_DIRENT_DIR; - } else { - type = UV_DIRENT_FILE; - } - ArrayPrototypePush(results, - new Dirent(childRelative, type, dirPath)); - } else { - ArrayPrototypePush(results, childRelative); - } + const { 0: name, 1: childEntry } = frame.children[frame.index++]; + const childRelative = frame.relativePath ? + frame.relativePath + '/' + name : name; - // Follow symlinks to directories for recursive traversal. - // Track the active traversal path to avoid symlink cycles. - let resolvedChild = childEntry; - if (childEntry.isSymbolicLink()) { - const targetPath = this.#resolveSymlinkTarget( - pathPosix.join(currentPath, name), childEntry.target, - ); - const result = this.#lookupEntry(targetPath, true, 0); - if (result.entry) { - resolvedChild = result.entry; - } - } - if (resolvedChild.isDirectory()) { - const childPath = pathPosix.join(currentPath, name); - walk(resolvedChild, childPath, childRelative); - } + if (withFileTypes) { + let type; + if (childEntry.isSymbolicLink()) { + type = UV_DIRENT_LINK; + } else if (childEntry.isDirectory()) { + type = UV_DIRENT_DIR; + } else { + type = UV_DIRENT_FILE; } - } finally { - active.delete(entry); + ArrayPrototypePush(results, new Dirent(childRelative, type, dirPath)); + } else { + ArrayPrototypePush(results, childRelative); } - }; - walk(dirEntry, dirPath, ''); + // Follow symlinks to directories for recursive traversal, skipping any + // directory already on the active path to avoid symlink cycles. + let resolvedChild = childEntry; + if (childEntry.isSymbolicLink()) { + const targetPath = this.#resolveSymlinkTarget( + pathPosix.join(frame.currentPath, name), childEntry.target, + ); + const result = this.#lookupEntry(targetPath, true, 0); + if (result.entry) { + resolvedChild = result.entry; + } + } + if (resolvedChild.isDirectory() && !active.has(resolvedChild)) { + enter(resolvedChild, pathPosix.join(frame.currentPath, name), + childRelative); + } + } + return results; } From b0e0986e3562d83d188ecb08375ab4e6f0c21295 Mon Sep 17 00:00:00 2001 From: AkshatOP Date: Tue, 7 Jul 2026 21:25:28 +0530 Subject: [PATCH 2/2] test: cover deep-tree readdir to guard against stack overflow --- .../test-vfs-readdir-symlink-recursive.js | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/test/parallel/test-vfs-readdir-symlink-recursive.js b/test/parallel/test-vfs-readdir-symlink-recursive.js index 33b52bb5d4a416..f46a12da7b9aa3 100644 --- a/test/parallel/test-vfs-readdir-symlink-recursive.js +++ b/test/parallel/test-vfs-readdir-symlink-recursive.js @@ -105,3 +105,32 @@ assert.ok( assert.ok(dirents.some((d) => d.name === 'sub' && d.isDirectory())); assert.ok(dirents.some((d) => d.name === 'lnk' && d.isSymbolicLink())); } + +// Recursive readdir on a deeply nested tree must not exhaust the call stack. +// The iterative traversal introduced in this fix handles arbitrarily deep trees +// without recursion, so this should complete without a RangeError. +{ + const DEPTH = 1000; + const v = vfs.create(); + + // Build /deep/0/1/2/.../999/leaf.txt + let path = '/deep'; + v.mkdirSync(path); + for (let i = 0; i < DEPTH; i++) { + path += `/${i}`; + v.mkdirSync(path); + } + v.writeFileSync(`${path}/leaf.txt`, 'deep'); + + const entries = v.readdirSync('/deep', { recursive: true }); + + // Every intermediate directory and the leaf file must appear. + assert.strictEqual(entries.length, DEPTH + 1); + + // Build the expected relative path to the leaf file. + const expectedLeaf = Array.from({ length: DEPTH }, (_, i) => i).join('/') + '/leaf.txt'; + assert.ok( + entries.includes(expectedLeaf), + `Expected '${expectedLeaf}' in deep-tree entries`, + ); +}