From 7b22c4ce621b90a67cc03dbf1f725f24609c9f48 Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Sat, 8 Aug 2026 23:10:32 +0300 Subject: [PATCH] gh-102475: Fix os.path.realpath() for names which look like a drive The unresolved part of the path was joined with the resolved part using join(), so a component containing a colon (e.g. "spam:eggs") reset the path. It is now simply appended. A path relative to a drive which does not exist (e.g. "Z:spam") is now resolved against the root directory of that drive, as the Windows path normalization does. --- Lib/ntpath.py | 25 +++++++++++----- Lib/test/test_ntpath.py | 29 +++++++++++++++++++ ...-08-09-01-30-00.gh-issue-102475.rpjoin.rst | 4 +++ 3 files changed, 51 insertions(+), 7 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-08-09-01-30-00.gh-issue-102475.rpjoin.rst diff --git a/Lib/ntpath.py b/Lib/ntpath.py index b3c23f0abc2d88..071941d3676712 100644 --- a/Lib/ntpath.py +++ b/Lib/ntpath.py @@ -626,12 +626,23 @@ def _getfinalpathname_nonstrict(path, ignored_error=OSError): allowed_winerror = 1, 2, 3, 5, 21, 32, 50, 53, 65, 67, 87, 123, 161, 1005, 1920, 1921 # Non-strict algorithm is to find as much of the target directory - # as we can and join the rest. + # as we can and join the rest. join() is not used, because the tail + # can contain a colon and be mistaken for a drive (gh-102475). + if isinstance(path, bytes): + sep = b'\\' + else: + sep = '\\' + + def join(path, tail): + if path[-1:] == sep or not tail: + return path + tail + return path + sep + tail + tail = path[:0] while path: try: path = _getfinalpathname(path) - return join(path, tail) if tail else path + return join(path, tail) except ignored_error as ex: if ex.winerror not in allowed_winerror: raise @@ -642,7 +653,7 @@ def _getfinalpathname_nonstrict(path, ignored_error=OSError): new_path = _readlink_deep(path, ignored_error=ignored_error) if new_path != path: - return join(new_path, tail) if tail else new_path + return join(new_path, tail) except ignored_error: # If we fail to readlink(), let's keep traversing pass @@ -657,7 +668,7 @@ def _getfinalpathname_nonstrict(path, ignored_error=OSError): path, name = split(path) if path and not name: return path + tail - tail = join(name, tail) if tail else name + tail = join(name, tail) return tail def realpath(path, /, *, strict=False): @@ -666,7 +677,6 @@ def realpath(path, /, *, strict=False): prefix = b'\\\\?\\' unc_prefix = b'\\\\?\\UNC\\' new_unc_prefix = b'\\\\' - cwd = os.getcwdb() # bpo-38081: Special case for realpath(b'nul') devnull = b'nul' if normcase(path) == devnull: @@ -675,7 +685,6 @@ def realpath(path, /, *, strict=False): prefix = '\\\\?\\' unc_prefix = '\\\\?\\UNC\\' new_unc_prefix = '\\\\' - cwd = os.getcwd() # bpo-38081: Special case for realpath('nul') devnull = 'nul' if normcase(path) == devnull: @@ -692,7 +701,9 @@ def realpath(path, /, *, strict=False): ignored_error = OSError if not had_prefix and not isabs(path): - path = join(cwd, path) + # abspath() is used instead of join(cwd, path), because the path + # can be relative to another drive (gh-102475). + path = abspath(path) try: path = _getfinalpathname(path) initial_winerror = 0 diff --git a/Lib/test/test_ntpath.py b/Lib/test/test_ntpath.py index 936332bf94ffe7..fcf98ad88a0f95 100644 --- a/Lib/test/test_ntpath.py +++ b/Lib/test/test_ntpath.py @@ -1535,6 +1535,35 @@ 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 on win32.") + def test_realpath_drive_like_names(self): + # gh-102475: the unresolved tail is appended, not joined, so a name + # which looks like a drive does not reset the path. + drive = ntpath.splitroot(os.getcwd())[0] + for path, expected in [ + ('C:/spam:eggs', 'C:\\spam:eggs'), + ('C:/nonexistent/spam:eggs', 'C:\\nonexistent\\spam:eggs'), + ('C:/spam:eggs/ham', 'C:\\spam:eggs\\ham'), + ('C:/nonexistent/spam:eggs/ham', 'C:\\nonexistent\\spam:eggs\\ham'), + ]: + with self.subTest(path=path): + self.assertEqual(ntpath.realpath(path), expected) + self.assertEqual(ntpath.realpath(os.fsencode(path)), + os.fsencode(expected)) + + @unittest.skipIf(sys.platform != 'win32', "Can only test on win32.") + def test_realpath_drive_relative(self): + # gh-102475: the working directory of a drive which does not exist + # is its root directory. + for drive in 'ABCDEFGHIJKLMNOPQRSTUVWXYZ': + if not ntpath.exists(drive + ':'): + break + else: + raise unittest.SkipTest('all drives exist') + self.assertEqual(ntpath.realpath(drive + ':spam'), + drive + ':\\spam') + self.assertEqual(ntpath.realpath(drive + ':'), drive + ':\\') + 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-01-30-00.gh-issue-102475.rpjoin.rst b/Misc/NEWS.d/next/Library/2026-08-09-01-30-00.gh-issue-102475.rpjoin.rst new file mode 100644 index 00000000000000..fb647720791dac --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-08-09-01-30-00.gh-issue-102475.rpjoin.rst @@ -0,0 +1,4 @@ +Fix :func:`os.path.realpath` on Windows: the unresolved part of the path is +now appended, not joined, so a file name which looks like a drive (e.g. +``spam:eggs``) no longer discards the resolved part. A path relative to +another drive is now resolved against the root directory of that drive.