From 00f828f6087ebf0d7f79181e5ed40e703e4e704b Mon Sep 17 00:00:00 2001 From: Serghei <43180231+xyrolle@users.noreply.github.com> Date: Fri, 23 Oct 2026 00:36:54 +0300 Subject: [PATCH] fix: compare whole path segments when detecting recursive symlinks --- __tests__/symlinks.test.ts | 28 ++++++++++++++++++++++++++++ src/api/functions/resolve-symlink.ts | 16 ++++++++++++---- 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/__tests__/symlinks.test.ts b/__tests__/symlinks.test.ts index 00795e7..30e0e17 100644 --- a/__tests__/symlinks.test.ts +++ b/__tests__/symlinks.test.ts @@ -116,11 +116,28 @@ const fsWithSymlinkInRootDir = { }), }; +const fsWithSymlinksToPrefixedSiblings = { + "/prefixed/root": { + src: mock.symlink({ + path: resolveSymlinkRoot("/prefixed/shared"), + }), + }, + "/prefixed/shared": { + alias: mock.symlink({ + path: resolveSymlinkRoot("/prefixed/shared-other"), + }), + }, + "/prefixed/shared-other": { + "file-1": "file contents", + }, +}; + const mockFs = { ...fsWithRelativeSymlinks, ...fsWithRecursiveSymlinks, ...fsWithRecursiveRelativeSymlinks, ...fsWithSymlinkInRootDir, + ...fsWithSymlinksToPrefixedSiblings, "/sym/linked": { "file-1": "file contents", @@ -372,6 +389,17 @@ for (const type of apiTypes) { ); }); + test(`resolve a symlink to a sibling whose name starts the same (real paths: false)`, async (t) => { + const api = new fdir() + .withErrors() + .withSymlinks({ resolvePaths: false }) + .crawl("/prefixed/root"); + const files = await api[type](); + t.expect(files.sort()).toStrictEqual( + normalize(["/prefixed/root/src/alias/file-1"]) + ); + }); + test(`do not resolve symlinks`, async (t) => { const api = new fdir().withErrors().crawl("/some/dir"); const files = await api[type](); diff --git a/src/api/functions/resolve-symlink.ts b/src/api/functions/resolve-symlink.ts index b23cdb2..ce8b0dc 100644 --- a/src/api/functions/resolve-symlink.ts +++ b/src/api/functions/resolve-symlink.ts @@ -1,6 +1,6 @@ import type { Stats } from "fs"; import { WalkerState, Options } from "../../types"; -import { dirname } from "path"; +import { dirname, normalize, sep } from "path"; export type ResolveSymlinkFunction = ( path: string, @@ -72,15 +72,15 @@ function isRecursive(path: string, resolved: string, state: WalkerState) { if (state.options.useRealPaths) return isRecursiveUsingRealPaths(resolved, state); + // A custom fs can return `/` on Windows, and isInside compares with the native separator + resolved = normalize(resolved); let parent = dirname(path); let depth = 1; while (parent !== state.root && depth < 2) { const resolvedPath = state.symlinks.get(parent); const isSameRoot = !!resolvedPath && - (resolvedPath === resolved || - resolvedPath.startsWith(resolved) || - resolved.startsWith(resolvedPath)); + (isInside(resolvedPath, resolved) || isInside(resolved, resolvedPath)); if (isSameRoot) depth++; else parent = dirname(parent); } @@ -88,6 +88,14 @@ function isRecursive(path: string, resolved: string, state: WalkerState) { return depth > 1; } +// Compares whole path segments, so `/a/b-c` isn't inside `/a/b` +function isInside(path: string, directory: string) { + return ( + path === directory || + path.startsWith(directory.endsWith(sep) ? directory : directory + sep) + ); +} + function isRecursiveUsingRealPaths(resolved: string, state: WalkerState) { return state.visited.includes(resolved + state.options.pathSeparator); }