diff --git a/lib/fs.js b/lib/fs.js index 4a03fada49ea8a..02cc109535e936 100644 --- a/lib/fs.js +++ b/lib/fs.js @@ -132,6 +132,14 @@ 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, @@ -2628,6 +2636,43 @@ 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 @@ -2690,6 +2735,12 @@ 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) { @@ -2793,6 +2844,11 @@ 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; @@ -2851,6 +2907,12 @@ 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(); @@ -2951,6 +3013,12 @@ 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/test/parallel/test-fs-realpath-extended-windows-path.js b/test/parallel/test-fs-realpath-extended-windows-path.js new file mode 100644 index 00000000000000..acbbb8f8522d81 --- /dev/null +++ b/test/parallel/test-fs-realpath-extended-windows-path.js @@ -0,0 +1,55 @@ +'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()); +}