From 5ee483a2fed7c8cb26286dd09f4717d60229279a Mon Sep 17 00:00:00 2001 From: Paul Browne Date: Wed, 28 Oct 2026 09:42:37 +0000 Subject: [PATCH] use a Set for visited dirs, only when resolving real paths --- __tests__/symlinks.test.ts | 32 ++++++++++++++++++++++++++++ src/api/functions/resolve-symlink.ts | 3 ++- src/api/functions/walk-directory.ts | 4 ++-- src/api/walker.ts | 4 +++- src/types.ts | 2 +- 5 files changed, 40 insertions(+), 5 deletions(-) diff --git a/__tests__/symlinks.test.ts b/__tests__/symlinks.test.ts index 00795e7..afc2d42 100644 --- a/__tests__/symlinks.test.ts +++ b/__tests__/symlinks.test.ts @@ -116,11 +116,25 @@ const fsWithSymlinkInRootDir = { }), }; +const fsWithManySymlinksToSameDir = { + "/shared/target": { + "file-1": "file contents", + nested: { "file-2": "file contents" }, + }, + "/many/links": Object.fromEntries( + Array.from({ length: 50 }, (_, i) => [ + `link-${i}`, + mock.symlink({ path: resolveSymlinkRoot("/shared/target") }), + ]) + ), +}; + const mockFs = { ...fsWithRelativeSymlinks, ...fsWithRecursiveSymlinks, ...fsWithRecursiveRelativeSymlinks, ...fsWithSymlinkInRootDir, + ...fsWithManySymlinksToSameDir, "/sym/linked": { "file-1": "file contents", @@ -301,6 +315,24 @@ for (const type of apiTypes) { ); }); + test(`crawl a directory reached via many symlinks only once (real paths: true)`, async (t) => { + const api = new fdir().withErrors().withSymlinks().crawl("/many/links"); + const files = await api[type](); + t.expect(files.sort()).toStrictEqual( + normalize(["/shared/target/file-1", "/shared/target/nested/file-2"]) + ); + }); + + test(`count a directory reached via many symlinks only once (real paths: true)`, async (t) => { + const api = new fdir() + .withErrors() + .withSymlinks() + .onlyCounts() + .crawl("/many/links"); + const counts = await api[type](); + t.expect(counts.files).toBe(2); + }); + test(`resolve symlinks (real paths: false)`, async (t) => { const api = new fdir() .withErrors() diff --git a/src/api/functions/resolve-symlink.ts b/src/api/functions/resolve-symlink.ts index b23cdb2..7416940 100644 --- a/src/api/functions/resolve-symlink.ts +++ b/src/api/functions/resolve-symlink.ts @@ -89,5 +89,6 @@ function isRecursive(path: string, resolved: string, state: WalkerState) { } function isRecursiveUsingRealPaths(resolved: string, state: WalkerState) { - return state.visited.includes(resolved + state.options.pathSeparator); + const { visited } = state; + return !!visited && visited.has(resolved + state.options.pathSeparator); } diff --git a/src/api/functions/walk-directory.ts b/src/api/functions/walk-directory.ts index f2b9d36..053aa15 100644 --- a/src/api/functions/walk-directory.ts +++ b/src/api/functions/walk-directory.ts @@ -24,7 +24,7 @@ const walkAsync: WalkDirectoryFunction = ( const { fs } = state; - state.visited.push(crawlPath); + if (state.visited) state.visited.add(crawlPath); state.counts.directories++; // Perf: Node >= 10 introduced withFileTypes that helps us @@ -45,7 +45,7 @@ const walkSync: WalkDirectoryFunction = ( ) => { const { fs } = state; if (currentDepth < 0) return; - state.visited.push(crawlPath); + if (state.visited) state.visited.add(crawlPath); state.counts.directories++; let entries: Dirent[] = []; diff --git a/src/api/walker.ts b/src/api/walker.ts index e6edc0d..13192c1 100644 --- a/src/api/walker.ts +++ b/src/api/walker.ts @@ -49,7 +49,9 @@ export class Walker { this.callbackInvoker(state, error, callback) ), symlinks: new Map(), - visited: [""].slice(0, 0), + // Perf: only needed for recursion detection when resolving symlinks to + // real paths. A Set keeps that check O(1) instead of O(directories). + visited: options.useRealPaths ? new Set() : undefined, controller: new Aborter(), fs: options.fs || nativeFs, }; diff --git a/src/types.ts b/src/types.ts index 50ce51e..6fccc6c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -66,7 +66,7 @@ export type WalkerState = { fs: FSLike; symlinks: Map; - visited: string[]; + visited?: Set; }; export type ResultCallback = (