From c77f055439496aa1a2ce7a40cabadd3e05e28207 Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Sat, 8 Aug 2026 22:20:07 +0300 Subject: [PATCH] gh-89760: Fix os.path.realpath() for volume GUID paths on Windows 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. --- Lib/ntpath.py | 38 +++++++++++-------- Lib/test/test_ntpath.py | 27 +++++++++++++ ...26-08-09-00-30-00.gh-issue-89760.rpvol.rst | 2 + 3 files changed, 51 insertions(+), 16 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-08-09-00-30-00.gh-issue-89760.rpvol.rst diff --git a/Lib/ntpath.py b/Lib/ntpath.py index b3c23f0abc2d889..6649298f9f67850 100644 --- a/Lib/ntpath.py +++ b/Lib/ntpath.py @@ -666,6 +666,7 @@ def realpath(path, /, *, strict=False): prefix = b'\\\\?\\' unc_prefix = b'\\\\?\\UNC\\' new_unc_prefix = b'\\\\' + colon_sep = b':\\' cwd = os.getcwdb() # bpo-38081: Special case for realpath(b'nul') devnull = b'nul' @@ -675,6 +676,7 @@ def realpath(path, /, *, strict=False): prefix = '\\\\?\\' unc_prefix = '\\\\?\\UNC\\' new_unc_prefix = '\\\\' + colon_sep = ':\\' cwd = os.getcwd() # bpo-38081: Special case for realpath('nul') devnull = 'nul' @@ -718,25 +720,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 \\?\:\. + 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: - # 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 936332bf94ffe77..df7bb011ede208e 100644 --- a/Lib/test/test_ntpath.py +++ b/Lib/test/test_ntpath.py @@ -1535,6 +1535,33 @@ def test_isjunction(self): self.assertFalse(ntpath.isjunction('tmpdir')) self.assertPathEqual(ntpath.realpath('testjunc'), ntpath.realpath('tmpdir')) + @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 000000000000000..9218ecfe9ce62e3 --- /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.