Commit 40aad9ed15d for nodejs

commit 40aad9ed15d3f67e10982e8c63c88fb92b7d01d2
Author: Jason Zhang <xzha4350@gmail.com>
Date:   Tue Nov 10 22:53:36 2026 +1030

    fs: resolve symlinks before parent components

    The JavaScript implementation of `fs.realpath()` and
    `fs.realpathSync()` joined a symlink target with the rest of the path
    through `path.resolve()`, which collapses `..` lexically before the
    preceding components have been resolved. A link such as
    `d -> c/../d`, where `c` is itself a symlink, was therefore rewritten
    back to `d` and the walk never terminated.

    Keep `.` and `..` in the path while walking it, and apply `..` to the
    already resolved parent, matching `realpath(3)` and
    `fs.realpathSync.native()`. Only normalize the final result, and skip
    caching link targets containing `..`, whose resolution depends on the
    path walked before them.

    Thsi is a POSIX only fix. Windows is unaffected.

    Signed-off-by: jazelly <xzha4350@gmail.com>
    PR-URL: https://github.com/nodejs/node/pull/65457
    Fixes: https://github.com/nodejs/node/issues/60295
    Reviewed-By: Jake Yuesong Li <jake.yuesong@gmail.com>

diff --git a/lib/fs.js b/lib/fs.js
index 7a7c63dcb3d..6405fc11c49 100644
--- a/lib/fs.js
+++ b/lib/fs.js
@@ -3339,6 +3339,25 @@ if (isWindows) {
   };
 }

+// realpath(3) applies `..` to the resolved parent, so on POSIX `.` and `..`
+// are kept in the path while walking it. Windows applies `..` lexically, both
+// in the path being opened and in symlink targets, so the path and the link
+// targets are normalized up front there, matching `fs.realpathSync.native()`.
+function toRealpathInput(p) {
+  if (isWindows) return pathModule.resolve(p);
+  return pathModule.isAbsolute(p) ? p : `${pathModule.resolve()}/${p}`;
+}
+
+function joinRealpathLink(previous, linkTarget) {
+  if (isWindows) return pathModule.resolve(previous, linkTarget);
+  return pathModule.isAbsolute(linkTarget) ? linkTarget : `${previous}${linkTarget}`;
+}
+
+function joinRealpathRemainder(resolvedLink, remainder) {
+  if (isWindows) return pathModule.resolve(resolvedLink, remainder);
+  return remainder.length === 0 ? resolvedLink : `${resolvedLink}/${remainder}`;
+}
+
 /**
  * Returns the resolved pathname.
  * @param {string | Buffer | URL} p
@@ -3357,7 +3376,7 @@ function realpathSync(p, options) {
     p += '';
   }
   validatePath(p);
-  p = pathModule.resolve(p);
+  p = toRealpathInput(p);

   // Check the resolved input path up front so that the lstat walk below cannot
   // be used to probe the existence of paths outside the granted set.
@@ -3371,14 +3390,14 @@ function realpathSync(p, options) {
   }

   const cache = options[realpathCacheKey];
-  const maybeCachedResult = cache?.get(p);
+  const original = p;
+  const maybeCachedResult = cache?.get(original);
   if (maybeCachedResult) {
     return maybeCachedResult;
   }

   const seenLinks = new SafeMap();
   const knownHard = new SafeSet();
-  const original = p;
   // Whether the symlink this walk resolved last pointed at a pipe or a
   // socket, which is where the walk stops. It cannot be read back from the
   // shared stat buffer, which holds the last stat made anywhere in the
@@ -3414,6 +3433,9 @@ function realpathSync(p, options) {
   while (pos < p.length) {
     // find the next part
     const result = nextPart(p, pos);
+    const part = result === -1 ?
+      StringPrototypeSlice(p, pos) :
+      StringPrototypeSlice(p, pos, result);
     previous = current;
     if (result === -1) {
       const last = StringPrototypeSlice(p, pos);
@@ -3426,6 +3448,19 @@ function realpathSync(p, options) {
       pos = result + 1;
     }

+    if (part === '' || part === '.') {
+      current = previous;
+      continue;
+    }
+    if (part === '..') {
+      base = pathModule.dirname(previous);
+      current = base;
+      if (current[current.length - 1] !== CHAR_FORWARD_SLASH &&
+          current[current.length - 1] !== CHAR_BACKWARD_SLASH) {
+        current += pathModule.sep;
+      }
+      continue;
+    }
     // Continue if not a symlink, break if a pipe/socket
     if (knownHard.has(base) || cache?.get(base) === base) {
       if (reachedPipeOrSocket) {
@@ -3471,14 +3506,18 @@ function realpathSync(p, options) {
           isFileType(targetStats, S_IFSOCK);
         linkTarget = binding.readlink(base, undefined);
       }
-      resolvedLink = pathModule.resolve(previous, linkTarget);
+      resolvedLink = joinRealpathLink(previous, linkTarget);

-      cache?.set(base, resolvedLink);
+      // On POSIX, a target containing `..` resolves relative to the path
+      // walked before it, so it cannot be cached by the link alone.
+      if (isWindows || StringPrototypeIndexOf(linkTarget, '..') === -1) {
+        cache?.set(base, pathModule.resolve(resolvedLink));
+      }
       if (!isWindows) seenLinks.set(id, linkTarget);
     }

     // Resolve the link, then start over
-    p = pathModule.resolve(resolvedLink, StringPrototypeSlice(p, pos));
+    p = joinRealpathRemainder(resolvedLink, StringPrototypeSlice(p, pos));

     // Skip over roots
     current = base = splitRoot(p);
@@ -3495,6 +3534,7 @@ function realpathSync(p, options) {
     }
   }

+  p = pathModule.resolve(p);
   cache?.set(original, p);
   return encodeRealpathResult(p, options);
 }
@@ -3547,7 +3587,7 @@ function realpath(p, options, callback) {
     p += '';
   }
   validatePath(p);
-  p = pathModule.resolve(p);
+  p = toRealpathInput(p);

   const seenLinks = new SafeMap();
   const knownHard = new SafeSet();
@@ -3585,11 +3625,14 @@ function realpath(p, options, callback) {
   function LOOP() {
     // Stop if scanned past end of path
     if (pos >= p.length) {
-      return callback(null, encodeRealpathResult(p, options));
+      return callback(null, encodeRealpathResult(pathModule.resolve(p), options));
     }

     // find the next part
     const result = nextPart(p, pos);
+    const part = result === -1 ?
+      StringPrototypeSlice(p, pos) :
+      StringPrototypeSlice(p, pos, result);
     previous = current;
     if (result === -1) {
       const last = StringPrototypeSlice(p, pos);
@@ -3602,10 +3645,23 @@ function realpath(p, options, callback) {
       pos = result + 1;
     }

+    if (part === '' || part === '.') {
+      current = previous;
+      return process.nextTick(LOOP);
+    }
+    if (part === '..') {
+      base = pathModule.dirname(previous);
+      current = base;
+      if (current[current.length - 1] !== CHAR_FORWARD_SLASH &&
+          current[current.length - 1] !== CHAR_BACKWARD_SLASH) {
+        current += pathModule.sep;
+      }
+      return process.nextTick(LOOP);
+    }
     // Continue if not a symlink, break if a pipe/socket
     if (knownHard.has(base)) {
       if (reachedPipeOrSocket) {
-        return callback(null, encodeRealpathResult(p, options));
+        return callback(null, encodeRealpathResult(pathModule.resolve(p), options));
       }
       return process.nextTick(LOOP);
     }
@@ -3649,12 +3705,12 @@ function realpath(p, options, callback) {
   function gotTarget(err, target) {
     if (err) return callback(err);

-    gotResolvedLink(pathModule.resolve(previous, target));
+    gotResolvedLink(joinRealpathLink(previous, target));
   }

   function gotResolvedLink(resolvedLink) {
     // Resolve the link, then start over
-    p = pathModule.resolve(resolvedLink, StringPrototypeSlice(p, pos));
+    p = joinRealpathRemainder(resolvedLink, StringPrototypeSlice(p, pos));
     current = base = splitRoot(p);
     pos = current.length;

diff --git a/test/parallel/test-fs-realpath-symlink-dot-dot.js b/test/parallel/test-fs-realpath-symlink-dot-dot.js
new file mode 100644
index 00000000000..e8c6f9e1b80
--- /dev/null
+++ b/test/parallel/test-fs-realpath-symlink-dot-dot.js
@@ -0,0 +1,108 @@
+// Flags: --expose-internals
+
+'use strict';
+
+const common = require('../common');
+const assert = require('assert');
+const { spawnSync } = require('child_process');
+const fs = require('fs');
+const { realpathCacheKey } = require('internal/fs/utils');
+const path = require('path');
+const tmpdir = require('../common/tmpdir');
+
+if (process.argv[2] === 'child') {
+  if (process.argv[4] === 'callback') {
+    fs.realpath(process.argv[3], common.mustSucceed((result) => {
+      process.stdout.write(result);
+    }));
+  } else {
+    process.stdout.write(fs.realpathSync(process.argv[3]));
+  }
+} else {
+  // Windows applies `..` in symlink targets lexically, so `d -> c\..\d` points
+  // at itself there and the walk fails with ELOOP, like the operating system.
+  if (common.isWindows)
+    common.skip('symlink targets are normalized lexically on Windows');
+  if (!common.canCreateSymLink())
+    common.skip('insufficient privileges');
+
+  tmpdir.refresh();
+
+  // Reproduce a symlink target containing `..` after another symlink:
+  //   c -> a/b/c
+  //   d -> c/../d -> a/b/d
+  // The cache fixture also creates a separate d to ensure that c/../d stays
+  // distinct from the lexically normalized path d.
+  const target = tmpdir.resolve('a', 'b', 'd');
+  fs.mkdirSync(tmpdir.resolve('a', 'b', 'c'), { recursive: true });
+  fs.mkdirSync(target);
+  fs.symlinkSync(path.join('a', 'b', 'c'), tmpdir.resolve('c'), 'dir');
+  fs.symlinkSync(`c${path.sep}..${path.sep}d`, tmpdir.resolve('d'), 'dir');
+
+  const cacheRoot = tmpdir.resolve('cache');
+  const cacheTarget = path.join(cacheRoot, 'a', 'b', 'd');
+  const cachePlain = path.join(cacheRoot, 'd');
+  fs.mkdirSync(path.join(cacheRoot, 'a', 'b', 'c'), { recursive: true });
+  fs.mkdirSync(cacheTarget);
+  fs.mkdirSync(cachePlain);
+  fs.symlinkSync(path.join('a', 'b', 'c'), path.join(cacheRoot, 'c'), 'dir');
+  const cacheDotted = `${cacheRoot}${path.sep}c${path.sep}..${path.sep}d`;
+  const cacheExpected = fs.realpathSync(cacheTarget);
+  const plainExpected = fs.realpathSync(cachePlain);
+
+  function assertCacheIsolation(first, firstExpected, second, secondExpected) {
+    const cache = new Map();
+    const options = { [realpathCacheKey]: cache };
+    assert.strictEqual(fs.realpathSync(first, options), firstExpected);
+    assert.strictEqual(fs.realpathSync(second, options), secondExpected);
+  }
+
+  assertCacheIsolation(cacheDotted, cacheExpected, cachePlain, plainExpected);
+  assertCacheIsolation(cachePlain, plainExpected, cacheDotted, cacheExpected);
+
+  // Run realpathSync in a child because the regression does not return.
+  const result = spawnSync(process.execPath, [
+    '--expose-internals',
+    __filename,
+    'child',
+    tmpdir.resolve('d'),
+  ], {
+    encoding: 'utf8',
+    timeout: common.platformTimeout(5000),
+  });
+
+  assert.ifError(result.error);
+  assert.strictEqual(result.status, 0, result.stderr);
+  assert.strictEqual(result.stdout, target);
+
+  const directInput = `c${path.sep}..${path.sep}d`;
+  const directResult = spawnSync(process.execPath, [
+    '--expose-internals',
+    __filename,
+    'child',
+    directInput,
+  ], {
+    encoding: 'utf8',
+    cwd: tmpdir.path,
+    timeout: common.platformTimeout(5000),
+  });
+
+  assert.ifError(directResult.error);
+  assert.strictEqual(directResult.status, 0, directResult.stderr);
+  assert.strictEqual(directResult.stdout, target);
+
+  const callbackResult = spawnSync(process.execPath, [
+    '--expose-internals',
+    __filename,
+    'child',
+    tmpdir.resolve('d'),
+    'callback',
+  ], {
+    encoding: 'utf8',
+    timeout: common.platformTimeout(5000),
+  });
+
+  assert.ifError(callbackResult.error);
+  assert.strictEqual(callbackResult.status, 0, callbackResult.stderr);
+  assert.strictEqual(callbackResult.stdout, target);
+}