From 189b0ecac4fbd57ca4637f9d7d2407a0fd823181 Mon Sep 17 00:00:00 2001 From: Abdullah Atta Date: Mon, 14 Oct 2024 21:38:42 +0500 Subject: [PATCH] fix: handle recursive symlinks --- src/api/functions/resolve-symlink.ts | 122 ++++++++++++++++----------- src/api/functions/walk-directory.ts | 28 +++--- src/api/walker.ts | 6 +- src/types.ts | 22 +++-- 4 files changed, 101 insertions(+), 77 deletions(-) diff --git a/src/api/functions/resolve-symlink.ts b/src/api/functions/resolve-symlink.ts index 185291f..1ba838d 100644 --- a/src/api/functions/resolve-symlink.ts +++ b/src/api/functions/resolve-symlink.ts @@ -1,5 +1,7 @@ import fs from "fs"; import { WalkerState, Options } from "../../types"; +import { dirname } from "path"; +import { readlink } from "fs/promises"; export type ResolveSymlinkFunction = ( path: string, @@ -14,70 +16,50 @@ const resolveSymlinksAsync: ResolveSymlinkFunction = function ( ) { const { queue, - options: { suppressErrors }, + options: { suppressErrors, useRealPaths }, } = state; queue.enqueue(); - fs.stat(path, (error, stat) => { - if (error) { - queue.dequeue(suppressErrors ? null : error, state); - return; - } + fs.realpath(path, (error, resolvedPath) => { + if (error) return queue.dequeue(suppressErrors ? null : error, state); + + fs.stat(resolvedPath, (error, stat) => { + if (error) return queue.dequeue(suppressErrors ? null : error, state); - callback(stat, path); - queue.dequeue(null, state); + if (stat.isDirectory()) { + isRecursiveAsync(path, resolvedPath, state).then((isRecursive) => { + if (isRecursive) return queue.dequeue(null, state); + callback(stat, useRealPaths ? resolvedPath : path); + queue.dequeue(null, state); + }); + } else { + callback(stat, useRealPaths ? resolvedPath : path); + queue.dequeue(null, state); + } + }); }); }; -const resolveSymlinksWithRealPathsAsync: ResolveSymlinkFunction = function ( +const resolveSymlinks: ResolveSymlinkFunction = function ( path, state, callback ) { const { queue, - options: { suppressErrors }, + options: { suppressErrors, useRealPaths }, } = state; queue.enqueue(); - fs.realpath(path, (error, resolvedPath) => { - if (error) { - queue.dequeue(suppressErrors ? null : error, state); - return; - } - - fs.lstat(resolvedPath, (_error, stat) => { - callback(stat, resolvedPath); - - queue.dequeue(null, state); - }); - }); -}; - -const resolveSymlinksSync: ResolveSymlinkFunction = function ( - path, - state, - callback -) { - try { - const stat = fs.statSync(path); - callback(stat, path); - } catch (e) { - if (!state.options.suppressErrors) throw e; - } -}; - -const resolveSymlinksWithRealPathsSync: ResolveSymlinkFunction = function ( - path, - state, - callback -) { try { const resolvedPath = fs.realpathSync(path); - const stat = fs.lstatSync(resolvedPath); - callback(stat, resolvedPath); + const stat = fs.statSync(resolvedPath); + + if (stat.isDirectory() && isRecursive(path, resolvedPath, state)) return; + + callback(stat, useRealPaths ? resolvedPath : path); } catch (e) { - if (!state.options.suppressErrors) throw e; + if (!suppressErrors) throw e; } }; @@ -87,9 +69,49 @@ export function build( ): ResolveSymlinkFunction | null { if (!options.resolveSymlinks || options.excludeSymlinks) return null; - if (options.useRealPaths) - return isSynchronous - ? resolveSymlinksWithRealPathsSync - : resolveSymlinksWithRealPathsAsync; - return isSynchronous ? resolveSymlinksSync : resolveSymlinksAsync; + return isSynchronous ? resolveSymlinks : resolveSymlinksAsync; +} + +async function isRecursiveAsync( + path: string, + resolved: string, + state: WalkerState +) { + if (state.options.useRealPaths) + return isRecursiveUsingRealPaths(resolved, state); + + let parent = dirname(path); + if (parent + state.options.pathSeparator === state.root || parent === path) + return false; + try { + const resolvedParent = + state.symlinks.get(parent) || (await readlink(parent)); + if (resolvedParent !== resolved) return false; + state.symlinks.set(path, resolved); + return true; + } catch (e) { + return isRecursiveAsync(parent, resolved, state); + } +} + +function isRecursiveUsingRealPaths(resolved: string, state: WalkerState) { + return state.visited.includes(resolved + state.options.pathSeparator); +} + +function isRecursive(path: string, resolved: string, state: WalkerState) { + if (state.options.useRealPaths) + return isRecursiveUsingRealPaths(resolved, state); + + let parent = dirname(path); + if (parent + state.options.pathSeparator === state.root || parent === path) + return false; + try { + const resolvedParent = + state.symlinks.get(parent) || fs.readlinkSync(parent); + if (resolvedParent !== resolved) return false; + state.symlinks.set(path, resolved); + return true; + } catch (e) { + return isRecursive(parent, resolved, state); + } } diff --git a/src/api/functions/walk-directory.ts b/src/api/functions/walk-directory.ts index af9aa47..9e09638 100644 --- a/src/api/functions/walk-directory.ts +++ b/src/api/functions/walk-directory.ts @@ -16,26 +16,19 @@ const walkAsync: WalkDirectoryFunction = ( currentDepth, callback ) => { - state.queue.enqueue(); - - if (currentDepth < 0) { - state.queue.dequeue(null, state); - return; - } + if (currentDepth < 0) return; + state.visited.push(directoryPath); state.counts.directories++; + state.queue.enqueue(); // Perf: Node >= 10 introduced withFileTypes that helps us // skip an extra fs.stat call. - fs.readdir( - directoryPath || ".", - readdirOpts, - function process(error, entries = []) { - callback(entries, directoryPath, currentDepth); - - state.queue.dequeue(state.options.suppressErrors ? null : error, state); - } - ); + fs.readdir(directoryPath || ".", readdirOpts, (error, entries = []) => { + callback(entries, directoryPath, currentDepth); + + state.queue.dequeue(state.options.suppressErrors ? null : error, state); + }); }; const walkSync: WalkDirectoryFunction = ( @@ -44,9 +37,8 @@ const walkSync: WalkDirectoryFunction = ( currentDepth, callback ) => { - if (currentDepth < 0) { - return; - } + if (currentDepth < 0) return; + state.visited.push(directoryPath); state.counts.directories++; let entries: fs.Dirent[] = []; diff --git a/src/api/walker.ts b/src/api/walker.ts index db078e9..ddb5501 100644 --- a/src/api/walker.ts +++ b/src/api/walker.ts @@ -35,7 +35,9 @@ export class Walker { this.isSynchronous = !callback; this.callbackInvoker = invokeCallback.build(options, this.isSynchronous); + this.root = normalizePath(root, options); this.state = { + root: this.root, // Perf: we explicitly tell the compiler to optimize for String arrays paths: [""].slice(0, 0), groups: [], @@ -44,10 +46,10 @@ export class Walker { queue: new Queue((error, state) => this.callbackInvoker(state, error, callback) ), + symlinks: new Map(), + visited: [""].slice(0, 0), }; - this.root = normalizePath(root, this.state.options); - /* * Perf: We conditionally change functions according to options. This gives a slight * performance boost. Since these functions are so small, they are automatically inlined diff --git a/src/types.ts b/src/types.ts index c290a3a..1dd5f46 100644 --- a/src/types.ts +++ b/src/types.ts @@ -26,11 +26,15 @@ export type PathsOutput = string[]; export type Output = OnlyCountsOutput | PathsOutput | GroupOutput; export type WalkerState = { + root: string; paths: string[]; groups: Group[]; counts: Counts; options: Options; queue: Queue; + + symlinks: Map; + visited: string[]; }; export type ResultCallback = ( @@ -60,13 +64,17 @@ export type Options = { relativePaths?: boolean; pathSeparator: PathSeparator; signal?: AbortSignal; - globFunction?: TGlobFunction + globFunction?: TGlobFunction; }; export type GlobMatcher = (test: string) => boolean; -export type GlobFunction = - ((glob: string | string[], ...params: unknown[]) => GlobMatcher); -export type GlobParams = - T extends (globs: string|string[], ...params: infer TParams extends unknown[]) => GlobMatcher - ? TParams - : []; +export type GlobFunction = ( + glob: string | string[], + ...params: unknown[] +) => GlobMatcher; +export type GlobParams = T extends ( + globs: string | string[], + ...params: infer TParams extends unknown[] +) => GlobMatcher + ? TParams + : [];