From 4b331dfe6bf852603f1c823a5df2dcc6cb6069ec Mon Sep 17 00:00:00 2001 From: jazelly Date: Thu, 9 Apr 2026 00:16:12 +0930 Subject: [PATCH] path: handle extended-length windows entry paths Normalize `\\?\` drive and UNC prefixes before resolution so namespaced entry points follow standard Windows path semantics. Signed-off-by: jazelly --- src/path.cc | 79 +++++++++++++++++-- test/cctest/test_path.cc | 33 ++++++++ test/es-module/test-esm-long-path-win.js | 22 ++++++ .../test-compile-cache-namespaced-path-win.js | 62 +++++++++++++++ test/parallel/test-fs-realpath.js | 17 ++++ 5 files changed, 208 insertions(+), 5 deletions(-) create mode 100644 test/parallel/test-compile-cache-namespaced-path-win.js diff --git a/src/path.cc b/src/path.cc index f4b8d4577bd1e6..05386769a02438 100644 --- a/src/path.cc +++ b/src/path.cc @@ -98,6 +98,68 @@ constexpr bool IsWindowsDeviceRoot(const char c) noexcept { return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z'); } +enum class WindowsNamespacedPathType { + kNotNamespaced, + kDriveAbsolutePath, + kUNCPath, + kOtherNamespacedPath, +}; + +static WindowsNamespacedPathType ClassifyWindowsNamespacedPath( + std::string_view path) { + if (!(path.size() >= 4 && path[0] == '\\' && path[1] == '\\' && + path[2] == '?' && path[3] == '\\')) { + return WindowsNamespacedPathType::kNotNamespaced; + } + + if (path.size() >= 7 && IsWindowsDeviceRoot(path[4]) && path[5] == ':' && + IsPathSeparator(path[6])) { + return WindowsNamespacedPathType::kDriveAbsolutePath; + } + + if (path.size() >= 8 && ToLower(path[4]) == 'u' && + ToLower(path[5]) == 'n' && ToLower(path[6]) == 'c' && + path[7] == '\\') { + size_t i = 8; + const size_t server_start = i; + while (i < path.size() && !IsPathSeparator(path[i])) { + i++; + } + if (i == server_start || i == path.size()) { + return WindowsNamespacedPathType::kOtherNamespacedPath; + } + + while (i < path.size() && IsPathSeparator(path[i])) { + i++; + } + const size_t share_start = i; + while (i < path.size() && !IsPathSeparator(path[i])) { + i++; + } + if (i == share_start) { + return WindowsNamespacedPathType::kOtherNamespacedPath; + } + + return WindowsNamespacedPathType::kUNCPath; + } + + return WindowsNamespacedPathType::kOtherNamespacedPath; +} + +static void StripExtendedPathPrefixForPathResolve(std::string& path) { + switch (ClassifyWindowsNamespacedPath(path)) { + case WindowsNamespacedPathType::kDriveAbsolutePath: + path = path.substr(4); + return; + case WindowsNamespacedPathType::kUNCPath: + path = "\\\\" + path.substr(8); + return; + case WindowsNamespacedPathType::kNotNamespaced: + case WindowsNamespacedPathType::kOtherNamespacedPath: + return; + } +} + std::string PathResolve(Environment* env, const std::vector& paths) { std::string resolvedDevice = ""; @@ -132,6 +194,8 @@ std::string PathResolve(Environment* env, } } + StripExtendedPathPrefixForPathResolve(path); + const size_t len = path.length(); int rootEnd = 0; std::string device = ""; @@ -330,11 +394,16 @@ void ToNamespacedPath(Environment* env, BufferValue* path) { // namespace-prefixed path. void FromNamespacedPath(std::string* path) { #ifdef _WIN32 - if (path->starts_with("\\\\?\\UNC\\")) { - *path = path->substr(8); - path->insert(0, "\\\\"); - } else if (path->starts_with("\\\\?\\")) { - *path = path->substr(4); + switch (ClassifyWindowsNamespacedPath(*path)) { + case WindowsNamespacedPathType::kUNCPath: + *path = "\\\\" + path->substr(8); + return; + case WindowsNamespacedPathType::kDriveAbsolutePath: + *path = path->substr(4); + return; + case WindowsNamespacedPathType::kNotNamespaced: + case WindowsNamespacedPathType::kOtherNamespacedPath: + return; } #endif } diff --git a/test/cctest/test_path.cc b/test/cctest/test_path.cc index 9e860d02cf77bd..4f1dbfff4b2a56 100644 --- a/test/cctest/test_path.cc +++ b/test/cctest/test_path.cc @@ -8,6 +8,7 @@ #include "v8.h" using node::BufferValue; +using node::FromNamespacedPath; using node::PathResolve; using node::ToNamespacedPath; @@ -43,6 +44,13 @@ TEST_F(PathTest, PathResolve) { "\\\\.\\PHYSICALDRIVE0"); EXPECT_EQ(PathResolve(*env, {"\\\\?\\PHYSICALDRIVE0"}), "\\\\?\\PHYSICALDRIVE0"); + EXPECT_EQ(PathResolve(*env, {"\\\\?\\C:\\foo"}), "C:\\foo"); + EXPECT_EQ(PathResolve(*env, {"\\\\?\\C:\\"}), "C:\\"); + EXPECT_EQ(PathResolve(*env, {"\\\\?\\UNC\\server\\share"}), + "\\\\server\\share\\"); + EXPECT_EQ(PathResolve(*env, {"\\\\?\\UNC\\server\\share\\dir"}), + "\\\\server\\share\\dir"); + EXPECT_EQ(PathResolve(*env, {"\\\\?\\C:foo"}), "\\\\?\\C:foo"); #else EXPECT_EQ(PathResolve(*env, {"/var/lib", "../", "file/"}), "/var/file"); EXPECT_EQ(PathResolve(*env, {"/var/lib", "/../", "file/"}), "/file"); @@ -85,6 +93,11 @@ TEST_F(PathTest, ToNamespacedPath) { .ToLocalChecked()); ToNamespacedPath(*env, &data_4); EXPECT_EQ(data_4.ToStringView(), "\\\\?\\c:\\Windows\\System"); + BufferValue data_5( + isolate_, + v8::String::NewFromUtf8(isolate_, "\\\\?\\C:\\").ToLocalChecked()); + ToNamespacedPath(*env, &data_5); + EXPECT_EQ(data_5.ToStringView(), "\\\\?\\C:\\"); #else BufferValue data( isolate_, @@ -93,3 +106,23 @@ TEST_F(PathTest, ToNamespacedPath) { EXPECT_EQ(data.ToStringView(), "hello world"); // Input should not be mutated #endif } + +TEST_F(PathTest, FromNamespacedPath) { +#ifdef _WIN32 + std::string drive_absolute = "\\\\?\\C:\\foo"; + FromNamespacedPath(&drive_absolute); + EXPECT_EQ(drive_absolute, "C:\\foo"); + + std::string unc_absolute = "\\\\?\\UNC\\server\\share\\dir"; + FromNamespacedPath(&unc_absolute); + EXPECT_EQ(unc_absolute, "\\\\server\\share\\dir"); + + std::string device_path = "\\\\?\\PHYSICALDRIVE0"; + FromNamespacedPath(&device_path); + EXPECT_EQ(device_path, "\\\\?\\PHYSICALDRIVE0"); + + std::string drive_relative = "\\\\?\\C:foo"; + FromNamespacedPath(&drive_relative); + EXPECT_EQ(drive_relative, "\\\\?\\C:foo"); +#endif +} diff --git a/test/es-module/test-esm-long-path-win.js b/test/es-module/test-esm-long-path-win.js index d125d341f09202..93fe9aa26678c9 100644 --- a/test/es-module/test-esm-long-path-win.js +++ b/test/es-module/test-esm-long-path-win.js @@ -47,6 +47,28 @@ describe('long path on Windows', () => { tmpdir.refresh(); }); + it('check extended-length path in executeUserEntryPoint', async () => { + const packageDirPath = tmpdir.resolve('issue-62446'); + const mainJsFilePath = path.resolve(packageDirPath, 'main.js'); + const namespacedMainJsPath = path.toNamespacedPath(mainJsFilePath); + + tmpdir.refresh(); + + fs.mkdirSync(packageDirPath); + fs.writeFileSync(mainJsFilePath, 'console.log("hello world");'); + + const { code, signal, stderr, stdout } = await spawnPromisified( + execPath, + [namespacedMainJsPath], + ); + assert.strictEqual(stderr.trim(), ''); + assert.strictEqual(stdout.trim(), 'hello world'); + assert.strictEqual(code, 0); + assert.strictEqual(signal, null); + + tmpdir.refresh(); + }); + it('check long path in LegacyMainResolve - 1', () => { // Module layout will be the following: // package.json diff --git a/test/parallel/test-compile-cache-namespaced-path-win.js b/test/parallel/test-compile-cache-namespaced-path-win.js new file mode 100644 index 00000000000000..d8d149612d3034 --- /dev/null +++ b/test/parallel/test-compile-cache-namespaced-path-win.js @@ -0,0 +1,62 @@ +'use strict'; + +// This tests NODE_COMPILE_CACHE works with a Windows namespaced path. + +const common = require('../common'); +if (!common.isWindows) { + common.skip('this test is Windows-specific.'); +} + +const { spawnSyncAndAssert } = require('../common/child_process'); +const assert = require('assert'); +const fixtures = require('../common/fixtures'); +const tmpdir = require('../common/tmpdir'); +const fs = require('fs'); +const path = require('path'); + +{ + tmpdir.refresh(); + const cacheDir = tmpdir.resolve('.compile_cache_dir'); + const namespacedCacheDir = path.toNamespacedPath(cacheDir); + + spawnSyncAndAssert( + process.execPath, + [fixtures.path('empty.js')], + { + env: { + ...process.env, + NODE_DEBUG_NATIVE: 'COMPILE_CACHE', + NODE_COMPILE_CACHE: namespacedCacheDir, + }, + cwd: tmpdir.path, + }, + { + stderr(output) { + assert.match(output, /writing cache for .*empty\.js.*success/); + return true; + }, + }); + + const topEntries = fs.readdirSync(cacheDir); + assert.strictEqual(topEntries.length, 1); + const cacheEntries = fs.readdirSync(path.join(cacheDir, topEntries[0])); + assert.strictEqual(cacheEntries.length, 1); + + spawnSyncAndAssert( + process.execPath, + [fixtures.path('empty.js')], + { + env: { + ...process.env, + NODE_DEBUG_NATIVE: 'COMPILE_CACHE', + NODE_COMPILE_CACHE: namespacedCacheDir, + }, + cwd: tmpdir.path, + }, + { + stderr(output) { + assert.match(output, /cache for .*empty\.js was accepted/); + return true; + }, + }); +} diff --git a/test/parallel/test-fs-realpath.js b/test/parallel/test-fs-realpath.js index 6ad074397eb364..3191a10cc9183f 100644 --- a/test/parallel/test-fs-realpath.js +++ b/test/parallel/test-fs-realpath.js @@ -558,6 +558,22 @@ function test_root_with_null_options(realpath, realpathSync, cb) { })); } +function test_windows_namespaced_path(realpath, realpathSync, cb) { + if (!common.isWindows) { + cb(); + return; + } + + const entry = tmp('issue-62446-entry.js'); + fs.writeFileSync(entry, 'console.log("ok");'); + const namespacedEntry = path.toNamespacedPath(entry); + + assertEqualPath(realpathSync(namespacedEntry), path.resolve(entry)); + asynctest(realpath, [namespacedEntry], cb, function(err, result) { + assertEqualPath(result, path.resolve(entry)); + }); +} + // ---------------------------------------------------------------------------- const tests = [ @@ -579,6 +595,7 @@ const tests = [ test_up_multiple_with_null_options, test_root, test_root_with_null_options, + test_windows_namespaced_path, ]; const numtests = tests.length; let testsRun = 0;