From e7caeec564081a2a48ebb38599265ff67c005307 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 1 Apr 2026 00:05:18 +0000 Subject: [PATCH] path: strip extended-length path prefix in PathResolve (C++) and win32.resolve (JS) Move the fix for Windows extended-length path prefix handling from lib/fs.js to the C++ PathResolve function (src/path.cc) and the JS path.resolve function (lib/path.js). The \\?\ prefix is a Win32 API mechanism for bypassing MAX_PATH limits. When path.resolve encounters paths like \\?\C:\foo or \\?\UNC\server\share, it should strip the prefix and resolve the underlying standard path. Device paths like \\?\PHYSICALDRIVE0 are left unchanged. This fix addresses the root cause in the path resolution layer rather than working around it in fs.realpathSync/realpath. Agent-Logs-Url: https://github.com/jazelly/node/sessions/b73cebf2-9b87-42a3-a50e-c5da1b454971 Co-authored-by: jazelly <28685065+jazelly@users.noreply.github.com> --- lib/fs.js | 68 ------------------- lib/path.js | 28 +++++++- src/path.cc | 26 +++++++ test/cctest/test_path.cc | 8 +++ .../test-fs-realpath-extended-windows-path.js | 55 --------------- test/parallel/test-path-resolve.js | 6 ++ 6 files changed, 67 insertions(+), 124 deletions(-) delete mode 100644 test/parallel/test-fs-realpath-extended-windows-path.js diff --git a/lib/fs.js b/lib/fs.js index 02cc109535e936..4a03fada49ea8a 100644 --- a/lib/fs.js +++ b/lib/fs.js @@ -132,14 +132,6 @@ const { const { CHAR_FORWARD_SLASH, CHAR_BACKWARD_SLASH, - CHAR_COLON, - CHAR_QUESTION_MARK, - CHAR_UPPERCASE_A, - CHAR_UPPERCASE_C, - CHAR_UPPERCASE_Z, - CHAR_LOWERCASE_A, - CHAR_LOWERCASE_N, - CHAR_LOWERCASE_Z, } = require('internal/constants'); const { isInt32, @@ -2636,43 +2628,6 @@ function unwatchFile(filename, listener) { } -// Strips the Windows extended-length path prefix (\\?\) from a resolved path. -// Extended-length paths (\\?\C:\... or \\?\UNC\...) are a Win32 API mechanism -// to bypass MAX_PATH limits. Node.js should handle them transparently by -// converting to standard paths for internal processing. The \\?\ prefix is -// re-added when needed via path.toNamespacedPath() before system calls. -// See: https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file -function stripExtendedPathPrefix(p) { - // Check for \\?\ prefix - if (p.length >= 4 && - StringPrototypeCharCodeAt(p, 0) === CHAR_BACKWARD_SLASH && - StringPrototypeCharCodeAt(p, 1) === CHAR_BACKWARD_SLASH && - StringPrototypeCharCodeAt(p, 2) === CHAR_QUESTION_MARK && - StringPrototypeCharCodeAt(p, 3) === CHAR_BACKWARD_SLASH) { - // \\?\C:\ -> C:\ (extended drive path) - if (p.length >= 6) { - const drive = StringPrototypeCharCodeAt(p, 4); - if (((drive >= CHAR_UPPERCASE_A && drive <= CHAR_UPPERCASE_Z) || - (drive >= CHAR_LOWERCASE_A && drive <= CHAR_LOWERCASE_Z)) && - StringPrototypeCharCodeAt(p, 5) === CHAR_COLON) { - return StringPrototypeSlice(p, 4); - } - } - // \\?\UNC\server\share -> \\server\share (extended UNC path) - if (p.length >= 8 && - (StringPrototypeCharCodeAt(p, 4) === 85 /* U */ || - StringPrototypeCharCodeAt(p, 4) === 117 /* u */) && - (StringPrototypeCharCodeAt(p, 5) === 78 /* N */ || - StringPrototypeCharCodeAt(p, 5) === CHAR_LOWERCASE_N) && - (StringPrototypeCharCodeAt(p, 6) === CHAR_UPPERCASE_C || - StringPrototypeCharCodeAt(p, 6) === 99 /* c */) && - StringPrototypeCharCodeAt(p, 7) === CHAR_BACKWARD_SLASH) { - return '\\\\' + StringPrototypeSlice(p, 8); - } - } - return p; -} - let splitRoot; if (isWindows) { // Regex to find the device root on Windows (e.g. 'c:\\'), including trailing @@ -2735,12 +2690,6 @@ function realpathSync(p, options) { validatePath(p); p = pathModule.resolve(p); - // On Windows, strip the extended-length path prefix (\\?\) so that the - // path walking logic below works with standard drive-letter or UNC roots. - if (isWindows) { - p = stripExtendedPathPrefix(p); - } - const cache = options[realpathCacheKey]; const maybeCachedResult = cache?.get(p); if (maybeCachedResult) { @@ -2844,11 +2793,6 @@ function realpathSync(p, options) { // Resolve the link, then start over p = pathModule.resolve(resolvedLink, StringPrototypeSlice(p, pos)); - // Strip extended path prefix again in case pathModule.resolve re-added it - if (isWindows) { - p = stripExtendedPathPrefix(p); - } - // Skip over roots current = base = splitRoot(p); pos = current.length; @@ -2907,12 +2851,6 @@ function realpath(p, options, callback) { validatePath(p); p = pathModule.resolve(p); - // On Windows, strip the extended-length path prefix (\\?\) so that the - // path walking logic below works with standard drive-letter or UNC roots. - if (isWindows) { - p = stripExtendedPathPrefix(p); - } - const seenLinks = new SafeMap(); const knownHard = new SafeSet(); @@ -3013,12 +2951,6 @@ function realpath(p, options, callback) { function gotResolvedLink(resolvedLink) { // Resolve the link, then start over p = pathModule.resolve(resolvedLink, StringPrototypeSlice(p, pos)); - - // Strip extended path prefix again in case pathModule.resolve re-added it - if (isWindows) { - p = stripExtendedPathPrefix(p); - } - current = base = splitRoot(p); pos = current.length; diff --git a/lib/path.js b/lib/path.js index 63b037cddfb986..7e26ba7a22d528 100644 --- a/lib/path.js +++ b/lib/path.js @@ -230,10 +230,36 @@ const win32 = { } } - const len = path.length; let rootEnd = 0; let device = ''; let isAbsolute = false; + + // Strip extended-length path prefix (\\?\C:\... -> C:\..., + // \\?\UNC\... -> \\...) before processing. + if (path.length >= 6 && + StringPrototypeCharCodeAt(path, 0) === CHAR_BACKWARD_SLASH && + StringPrototypeCharCodeAt(path, 1) === CHAR_BACKWARD_SLASH && + StringPrototypeCharCodeAt(path, 2) === CHAR_QUESTION_MARK && + StringPrototypeCharCodeAt(path, 3) === CHAR_BACKWARD_SLASH) { + const drive = StringPrototypeCharCodeAt(path, 4); + if (isWindowsDeviceRoot(drive) && + StringPrototypeCharCodeAt(path, 5) === CHAR_COLON) { + // \\?\C:\ -> C:\ + path = StringPrototypeSlice(path, 4); + } else if (path.length >= 8 && + (drive === 85 || drive === 117) && // U/u + (StringPrototypeCharCodeAt(path, 5) === 78 || + StringPrototypeCharCodeAt(path, 5) === 110) && // N/n + (StringPrototypeCharCodeAt(path, 6) === 67 || + StringPrototypeCharCodeAt(path, 6) === 99) && // C/c + StringPrototypeCharCodeAt(path, 7) === + CHAR_BACKWARD_SLASH) { + // \\?\UNC\server\share -> \\server\share + path = `\\\\${StringPrototypeSlice(path, 8)}`; + } + } + + const len = path.length; const code = StringPrototypeCharCodeAt(path, 0); // Try to match a root diff --git a/src/path.cc b/src/path.cc index f4b8d4577bd1e6..fd0d9202670550 100644 --- a/src/path.cc +++ b/src/path.cc @@ -98,6 +98,28 @@ constexpr bool IsWindowsDeviceRoot(const char c) noexcept { return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z'); } +// Strip Windows extended-length path prefix (\\?\) only when it wraps a +// drive letter path (\\?\C:\...) or a UNC path (\\?\UNC\...). +// Device paths like \\?\PHYSICALDRIVE0 are left unchanged. +// See: https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file +static void StripExtendedPathPrefix(std::string& path) { + if (path.size() >= 4 && path[0] == '\\' && path[1] == '\\' && + path[2] == '?' && path[3] == '\\') { + // \\?\C:\ -> C:\ (extended drive path) + if (path.size() >= 6 && IsWindowsDeviceRoot(path[4]) && path[5] == ':') { + path = path.substr(4); + return; + } + // \\?\UNC\server\share -> \\server\share (extended UNC path) + if (path.size() >= 8 && ToLower(path[4]) == 'u' && + ToLower(path[5]) == 'n' && ToLower(path[6]) == 'c' && + path[7] == '\\') { + path = "\\\\" + path.substr(8); + return; + } + } +} + std::string PathResolve(Environment* env, const std::vector& paths) { std::string resolvedDevice = ""; @@ -132,6 +154,10 @@ std::string PathResolve(Environment* env, } } + // Strip extended-length path prefix (\\?\C:\... -> C:\..., + // \\?\UNC\... -> \\...) before processing. + StripExtendedPathPrefix(path); + const size_t len = path.length(); int rootEnd = 0; std::string device = ""; diff --git a/test/cctest/test_path.cc b/test/cctest/test_path.cc index 9e860d02cf77bd..6abb679bfd0410 100644 --- a/test/cctest/test_path.cc +++ b/test/cctest/test_path.cc @@ -43,6 +43,14 @@ TEST_F(PathTest, PathResolve) { "\\\\.\\PHYSICALDRIVE0"); EXPECT_EQ(PathResolve(*env, {"\\\\?\\PHYSICALDRIVE0"}), "\\\\?\\PHYSICALDRIVE0"); + // Extended-length path prefix (\\?\) should be stripped for drive paths + EXPECT_EQ(PathResolve(*env, {"\\\\?\\C:\\foo"}), "C:\\foo"); + EXPECT_EQ(PathResolve(*env, {"\\\\?\\C:\\"}), "C:\\"); + // Extended-length UNC path prefix (\\?\UNC\) should be stripped + EXPECT_EQ(PathResolve(*env, {"\\\\?\\UNC\\server\\share"}), + "\\\\server\\share\\"); + EXPECT_EQ(PathResolve(*env, {"\\\\?\\UNC\\server\\share\\dir"}), + "\\\\server\\share\\dir"); #else EXPECT_EQ(PathResolve(*env, {"/var/lib", "../", "file/"}), "/var/file"); EXPECT_EQ(PathResolve(*env, {"/var/lib", "/../", "file/"}), "/file"); diff --git a/test/parallel/test-fs-realpath-extended-windows-path.js b/test/parallel/test-fs-realpath-extended-windows-path.js deleted file mode 100644 index acbbb8f8522d81..00000000000000 --- a/test/parallel/test-fs-realpath-extended-windows-path.js +++ /dev/null @@ -1,55 +0,0 @@ -'use strict'; -const common = require('../common'); - -// This test verifies that fs.realpathSync and fs.realpath correctly handle -// Windows extended-length path prefixes (\\?\C:\... and \\?\UNC\...). -// See: https://github.com/nodejs/node/issues/62446 - -if (!common.isWindows) - common.skip('Windows-specific test.'); - -const assert = require('assert'); -const fs = require('fs'); -const path = require('path'); -const tmpdir = require('../common/tmpdir'); - -tmpdir.refresh(); - -const testFile = tmpdir.resolve('extended-path-test.js'); -fs.writeFileSync(testFile, 'module.exports = 42;'); - -// Construct the extended-length path for the test file. -// The \\?\ prefix is a Win32 API mechanism to bypass MAX_PATH limits. -const extendedPath = `\\\\?\\${testFile}`; - -// fs.realpathSync should handle the \\?\ prefix and return a standard path. -{ - const result = fs.realpathSync(extendedPath); - // The result should be the resolved path without the \\?\ prefix. - assert.strictEqual(result.toLowerCase(), testFile.toLowerCase()); -} - -// fs.realpath (async) should also handle the \\?\ prefix. -fs.realpath(extendedPath, common.mustSucceed((result) => { - assert.strictEqual(result.toLowerCase(), testFile.toLowerCase()); -})); - -// Also test that the extended path for the drive root works. -{ - const driveRoot = path.parse(testFile).root; // e.g., 'C:\' - const extendedRoot = `\\\\?\\${driveRoot}`; - const result = fs.realpathSync(extendedRoot); - assert.strictEqual(result.toLowerCase(), driveRoot.toLowerCase()); -} - -// Test extended-length path with subdirectory. -const subDir = tmpdir.resolve('sub', 'dir'); -fs.mkdirSync(subDir, { recursive: true }); -const subFile = path.join(subDir, 'file.txt'); -fs.writeFileSync(subFile, 'hello'); - -{ - const extendedSubFile = `\\\\?\\${subFile}`; - const result = fs.realpathSync(extendedSubFile); - assert.strictEqual(result.toLowerCase(), subFile.toLowerCase()); -} diff --git a/test/parallel/test-path-resolve.js b/test/parallel/test-path-resolve.js index 088ed4b3ff183f..ca2a656b974cfb 100644 --- a/test/parallel/test-path-resolve.js +++ b/test/parallel/test-path-resolve.js @@ -37,6 +37,12 @@ const resolveTests = [ 'C:\\foo\\tmp.3\\cycles\\root.js'], [['\\\\.\\PHYSICALDRIVE0'], '\\\\.\\PHYSICALDRIVE0'], [['\\\\?\\PHYSICALDRIVE0'], '\\\\?\\PHYSICALDRIVE0'], + // Extended-length path prefix (\\?\) should be stripped for drive paths + [['\\\\?\\C:\\foo'], 'C:\\foo'], + [['\\\\?\\C:\\'], 'C:\\'], + // Extended-length UNC path prefix (\\?\UNC\) should be stripped + [['\\\\?\\UNC\\server\\share'], '\\\\server\\share\\'], + [['\\\\?\\UNC\\server\\share\\dir'], '\\\\server\\share\\dir'], ], ], [ path.posix.resolve,