https://github.com/python/cpython/commit/c4e5b213fd1fa566235bf34c8c8ef40a4e11b9ec
commit: c4e5b213fd1fa566235bf34c8c8ef40a4e11b9ec
branch: 3.13
author: Serhiy Storchaka <[email protected]>
committer: serhiy-storchaka <[email protected]>
date: 2026-08-29T20:26:27Z
summary:
[3.13] gh-89760: Fix os.path.realpath() for volume GUID paths on Windows
(GH-155396) (GH-156613)
The \\?\ prefix was stripped from the resolved path if it could not be
resolved without the prefix and failed with the same error as the original
path. This produced an invalid, seemingly relative path for a junction
which points to a volume without a drive letter. The prefix is now only
stripped for drive-letter and UNC paths.
(cherry picked from commit e48b1eb949daacaa84c87da57732866ab1894a86)
files:
A Misc/NEWS.d/next/Library/2026-08-09-00-30-00.gh-issue-89760.rpvol.rst
M Lib/ntpath.py
M Lib/test/test_ntpath.py
diff --git a/Lib/ntpath.py b/Lib/ntpath.py
index 8e5eb95474e488..ba19b193580364 100644
--- a/Lib/ntpath.py
+++ b/Lib/ntpath.py
@@ -677,6 +677,7 @@ def realpath(path, *, strict=False):
prefix = b'\\\\?\\'
unc_prefix = b'\\\\?\\UNC\\'
new_unc_prefix = b'\\\\'
+ colon_sep = b':\\'
# bpo-38081: Special case for realpath(b'nul')
devnull = b'nul'
if normcase(path) == devnull:
@@ -685,6 +686,7 @@ def realpath(path, *, strict=False):
prefix = '\\\\?\\'
unc_prefix = '\\\\?\\UNC\\'
new_unc_prefix = '\\\\'
+ colon_sep = ':\\'
# bpo-38081: Special case for realpath('nul')
devnull = 'nul'
if normcase(path) == devnull:
@@ -722,25 +724,29 @@ def realpath(path, *, strict=False):
# strip off that prefix unless it was already provided on the original
# path.
if not had_prefix and path.startswith(prefix):
- # For UNC paths, the prefix will actually be \\?\UNC\
- # Handle that case as well.
+ # For UNC drives, the path starts with \\?\UNC\.
if path.startswith(unc_prefix):
spath = new_unc_prefix + path[len(unc_prefix):]
- else:
+ # For drive-letter drives, the path starts with \\?\<letter>:\.
+ elif path.startswith(colon_sep, len(prefix) + 1):
spath = path[len(prefix):]
- # Ensure that the non-prefixed path resolves to the same path
- try:
- if _getfinalpathname(spath) == path:
- path = spath
- except ValueError as ex:
- # Unexpected, as an invalid path should not have gained a
prefix
- # at any point, but we ignore this error just in case.
- pass
- except OSError as ex:
- # If the path does not exist and originally did not exist, then
- # strip the prefix anyway.
- if ex.winerror == initial_winerror:
- path = spath
+ # For all others, e.g. volume GUID paths, it cannot be stripped.
+ else:
+ spath = None
+ if spath is not None:
+ # Ensure that the non-prefixed path resolves to the same path
+ try:
+ if _getfinalpathname(spath) == path:
+ path = spath
+ except ValueError:
+ # Unexpected, as an invalid path should not have gained a
+ # prefix at any point, but we ignore this error just in
case.
+ pass
+ except OSError as ex:
+ # If the path does not exist and originally did not exist,
+ # then strip the prefix anyway.
+ if ex.winerror == initial_winerror:
+ path = spath
return path
diff --git a/Lib/test/test_ntpath.py b/Lib/test/test_ntpath.py
index fcef4a95e17e57..bd0c443be9ec44 100644
--- a/Lib/test/test_ntpath.py
+++ b/Lib/test/test_ntpath.py
@@ -10,6 +10,7 @@
from test import support
from test.support import os_helper, is_emscripten
from test.support.os_helper import FakePath
+from test.support.testcase import ExtraAssertions
from test import test_genericpath
from tempfile import TemporaryFile
@@ -82,7 +83,7 @@ def _parameterize(*parameters):
return support.subTests('kwargs', parameters, _do_cleanups=True)
-class NtpathTestCase(unittest.TestCase):
+class NtpathTestCase(unittest.TestCase, ExtraAssertions):
def assertPathEqual(self, path1, path2):
if path1 == path2 or _norm(path1) == _norm(path2):
return
@@ -1438,6 +1439,33 @@ def test_realpath_drive_relative(self):
drive + ':\\spam')
self.assertEqual(ntpath.realpath(drive + ':'), drive + ':\\')
+ @unittest.skipIf(sys.platform != 'win32', "Can only test junctions with
creation on win32.")
+ def test_realpath_volume_guid_path(self):
+ # gh-89760: the \\?\ prefix cannot be stripped from a volume GUID path.
+ # Find a volume which is not mounted as a drive.
+ for volume in os.listvolumes():
+ if not os.listmounts(volume):
+ break
+ else:
+ raise unittest.SkipTest('no volume without a mount point')
+
+ with os_helper.temp_dir() as d:
+ with os_helper.change_cwd(d):
+ # _winapi.CreateJunction() adds the \\??\\ prefix to a path
+ # which already has a prefix.
+ try:
+ subprocess.run(['cmd', '/c', 'mklink', '/j',
+ 'testjunc', volume],
+ check=True, capture_output=True)
+ except (OSError, subprocess.CalledProcessError):
+ raise unittest.SkipTest('creating the test junction
failed')
+
+ for path in 'testjunc', 'testjunc/spam', 'testjunc/spam/eggs':
+ with self.subTest(path=path):
+ realpath = ntpath.realpath(path)
+ self.assertStartsWith(realpath, '\\\\?\\Volume{')
+ self.assertTrue(ntpath.isabs(realpath), realpath)
+
def test_isfile_invalid_paths(self):
isfile = ntpath.isfile
self.assertIs(isfile('/tmp\udfffabcds'), False)
diff --git
a/Misc/NEWS.d/next/Library/2026-08-09-00-30-00.gh-issue-89760.rpvol.rst
b/Misc/NEWS.d/next/Library/2026-08-09-00-30-00.gh-issue-89760.rpvol.rst
new file mode 100644
index 00000000000000..9218ecfe9ce62e
--- /dev/null
+++ b/Misc/NEWS.d/next/Library/2026-08-09-00-30-00.gh-issue-89760.rpvol.rst
@@ -0,0 +1,2 @@
+Fix :func:`os.path.realpath` on Windows: the ``\\?\`` prefix is no longer
+stripped from a volume GUID path, which made the result invalid.
_______________________________________________
Python-checkins mailing list -- [email protected]
To unsubscribe send an email to [email protected]
https://mail.python.org/mailman3//lists/python-checkins.python.org
Member address: [email protected]