Skip to content

Commit 7b22c4c

Browse files
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.
1 parent 998b890 commit 7b22c4c

3 files changed

Lines changed: 51 additions & 7 deletions

File tree

Lib/ntpath.py

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -626,12 +626,23 @@ def _getfinalpathname_nonstrict(path, ignored_error=OSError):
626626
allowed_winerror = 1, 2, 3, 5, 21, 32, 50, 53, 65, 67, 87, 123, 161, 1005, 1920, 1921
627627

628628
# Non-strict algorithm is to find as much of the target directory
629-
# as we can and join the rest.
629+
# as we can and join the rest. join() is not used, because the tail
630+
# can contain a colon and be mistaken for a drive (gh-102475).
631+
if isinstance(path, bytes):
632+
sep = b'\\'
633+
else:
634+
sep = '\\'
635+
636+
def join(path, tail):
637+
if path[-1:] == sep or not tail:
638+
return path + tail
639+
return path + sep + tail
640+
630641
tail = path[:0]
631642
while path:
632643
try:
633644
path = _getfinalpathname(path)
634-
return join(path, tail) if tail else path
645+
return join(path, tail)
635646
except ignored_error as ex:
636647
if ex.winerror not in allowed_winerror:
637648
raise
@@ -642,7 +653,7 @@ def _getfinalpathname_nonstrict(path, ignored_error=OSError):
642653
new_path = _readlink_deep(path,
643654
ignored_error=ignored_error)
644655
if new_path != path:
645-
return join(new_path, tail) if tail else new_path
656+
return join(new_path, tail)
646657
except ignored_error:
647658
# If we fail to readlink(), let's keep traversing
648659
pass
@@ -657,7 +668,7 @@ def _getfinalpathname_nonstrict(path, ignored_error=OSError):
657668
path, name = split(path)
658669
if path and not name:
659670
return path + tail
660-
tail = join(name, tail) if tail else name
671+
tail = join(name, tail)
661672
return tail
662673

663674
def realpath(path, /, *, strict=False):
@@ -666,7 +677,6 @@ def realpath(path, /, *, strict=False):
666677
prefix = b'\\\\?\\'
667678
unc_prefix = b'\\\\?\\UNC\\'
668679
new_unc_prefix = b'\\\\'
669-
cwd = os.getcwdb()
670680
# bpo-38081: Special case for realpath(b'nul')
671681
devnull = b'nul'
672682
if normcase(path) == devnull:
@@ -675,7 +685,6 @@ def realpath(path, /, *, strict=False):
675685
prefix = '\\\\?\\'
676686
unc_prefix = '\\\\?\\UNC\\'
677687
new_unc_prefix = '\\\\'
678-
cwd = os.getcwd()
679688
# bpo-38081: Special case for realpath('nul')
680689
devnull = 'nul'
681690
if normcase(path) == devnull:
@@ -692,7 +701,9 @@ def realpath(path, /, *, strict=False):
692701
ignored_error = OSError
693702

694703
if not had_prefix and not isabs(path):
695-
path = join(cwd, path)
704+
# abspath() is used instead of join(cwd, path), because the path
705+
# can be relative to another drive (gh-102475).
706+
path = abspath(path)
696707
try:
697708
path = _getfinalpathname(path)
698709
initial_winerror = 0

Lib/test/test_ntpath.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1535,6 +1535,35 @@ def test_isjunction(self):
15351535
self.assertFalse(ntpath.isjunction('tmpdir'))
15361536
self.assertPathEqual(ntpath.realpath('testjunc'), ntpath.realpath('tmpdir'))
15371537

1538+
@unittest.skipIf(sys.platform != 'win32', "Can only test on win32.")
1539+
def test_realpath_drive_like_names(self):
1540+
# gh-102475: the unresolved tail is appended, not joined, so a name
1541+
# which looks like a drive does not reset the path.
1542+
drive = ntpath.splitroot(os.getcwd())[0]
1543+
for path, expected in [
1544+
('C:/spam:eggs', 'C:\\spam:eggs'),
1545+
('C:/nonexistent/spam:eggs', 'C:\\nonexistent\\spam:eggs'),
1546+
('C:/spam:eggs/ham', 'C:\\spam:eggs\\ham'),
1547+
('C:/nonexistent/spam:eggs/ham', 'C:\\nonexistent\\spam:eggs\\ham'),
1548+
]:
1549+
with self.subTest(path=path):
1550+
self.assertEqual(ntpath.realpath(path), expected)
1551+
self.assertEqual(ntpath.realpath(os.fsencode(path)),
1552+
os.fsencode(expected))
1553+
1554+
@unittest.skipIf(sys.platform != 'win32', "Can only test on win32.")
1555+
def test_realpath_drive_relative(self):
1556+
# gh-102475: the working directory of a drive which does not exist
1557+
# is its root directory.
1558+
for drive in 'ABCDEFGHIJKLMNOPQRSTUVWXYZ':
1559+
if not ntpath.exists(drive + ':'):
1560+
break
1561+
else:
1562+
raise unittest.SkipTest('all drives exist')
1563+
self.assertEqual(ntpath.realpath(drive + ':spam'),
1564+
drive + ':\\spam')
1565+
self.assertEqual(ntpath.realpath(drive + ':'), drive + ':\\')
1566+
15381567
def test_isfile_invalid_paths(self):
15391568
isfile = ntpath.isfile
15401569
self.assertIs(isfile('/tmp\udfffabcds'), False)
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Fix :func:`os.path.realpath` on Windows: the unresolved part of the path is
2+
now appended, not joined, so a file name which looks like a drive (e.g.
3+
``spam:eggs``) no longer discards the resolved part. A path relative to
4+
another drive is now resolved against the root directory of that drive.

0 commit comments

Comments
 (0)