Skip to content

Commit c7874bb

Browse files
[3.12] gh-113188: Fix shutil.copymode() and shutil.copystat() on Windows (GH-113285)
Previously they worked differenly if dst is a symbolic link: they modified the permission bits of dst itself rather than the file it points to if follow_symlinks is true or src is not a symbolic link, and did nothing if follow_symlinks is false and src is a symbolic link.
1 parent 4259acd commit c7874bb

File tree

3 files changed

+43
-23
lines changed

3 files changed

+43
-23
lines changed

Lib/shutil.py

+14-2
Original file line numberDiff line numberDiff line change
@@ -302,11 +302,15 @@ def copymode(src, dst, *, follow_symlinks=True):
302302
sys.audit("shutil.copymode", src, dst)
303303

304304
if not follow_symlinks and _islink(src) and os.path.islink(dst):
305-
if hasattr(os, 'lchmod'):
305+
if os.name == 'nt':
306+
stat_func, chmod_func = os.lstat, os.chmod
307+
elif hasattr(os, 'lchmod'):
306308
stat_func, chmod_func = os.lstat, os.lchmod
307309
else:
308310
return
309311
else:
312+
if os.name == 'nt' and os.path.islink(dst):
313+
dst = os.path.realpath(dst, strict=True)
310314
stat_func, chmod_func = _stat, os.chmod
311315

312316
st = stat_func(src)
@@ -382,8 +386,16 @@ def lookup(name):
382386
# We must copy extended attributes before the file is (potentially)
383387
# chmod()'ed read-only, otherwise setxattr() will error with -EACCES.
384388
_copyxattr(src, dst, follow_symlinks=follow)
389+
_chmod = lookup("chmod")
390+
if os.name == 'nt':
391+
if follow:
392+
if os.path.islink(dst):
393+
dst = os.path.realpath(dst, strict=True)
394+
else:
395+
def _chmod(*args, **kwargs):
396+
os.chmod(*args)
385397
try:
386-
lookup("chmod")(dst, mode, follow_symlinks=follow)
398+
_chmod(dst, mode, follow_symlinks=follow)
387399
except NotImplementedError:
388400
# if we got a NotImplementedError, it's because
389401
# * follow_symlinks=False,

Lib/test/test_shutil.py

+23-21
Original file line numberDiff line numberDiff line change
@@ -1046,23 +1046,23 @@ def test_copymode_follow_symlinks(self):
10461046
shutil.copymode(src, dst)
10471047
self.assertEqual(os.stat(src).st_mode, os.stat(dst).st_mode)
10481048
# On Windows, os.chmod does not follow symlinks (issue #15411)
1049-
if os.name != 'nt':
1050-
# follow src link
1051-
os.chmod(dst, stat.S_IRWXO)
1052-
shutil.copymode(src_link, dst)
1053-
self.assertEqual(os.stat(src).st_mode, os.stat(dst).st_mode)
1054-
# follow dst link
1055-
os.chmod(dst, stat.S_IRWXO)
1056-
shutil.copymode(src, dst_link)
1057-
self.assertEqual(os.stat(src).st_mode, os.stat(dst).st_mode)
1058-
# follow both links
1059-
os.chmod(dst, stat.S_IRWXO)
1060-
shutil.copymode(src_link, dst_link)
1061-
self.assertEqual(os.stat(src).st_mode, os.stat(dst).st_mode)
1062-
1063-
@unittest.skipUnless(hasattr(os, 'lchmod'), 'requires os.lchmod')
1049+
# follow src link
1050+
os.chmod(dst, stat.S_IRWXO)
1051+
shutil.copymode(src_link, dst)
1052+
self.assertEqual(os.stat(src).st_mode, os.stat(dst).st_mode)
1053+
# follow dst link
1054+
os.chmod(dst, stat.S_IRWXO)
1055+
shutil.copymode(src, dst_link)
1056+
self.assertEqual(os.stat(src).st_mode, os.stat(dst).st_mode)
1057+
# follow both links
1058+
os.chmod(dst, stat.S_IRWXO)
1059+
shutil.copymode(src_link, dst_link)
1060+
self.assertEqual(os.stat(src).st_mode, os.stat(dst).st_mode)
1061+
1062+
@unittest.skipUnless(hasattr(os, 'lchmod') or os.name == 'nt', 'requires os.lchmod')
10641063
@os_helper.skip_unless_symlink
10651064
def test_copymode_symlink_to_symlink(self):
1065+
_lchmod = os.chmod if os.name == 'nt' else os.lchmod
10661066
tmp_dir = self.mkdtemp()
10671067
src = os.path.join(tmp_dir, 'foo')
10681068
dst = os.path.join(tmp_dir, 'bar')
@@ -1074,20 +1074,20 @@ def test_copymode_symlink_to_symlink(self):
10741074
os.symlink(dst, dst_link)
10751075
os.chmod(src, stat.S_IRWXU|stat.S_IRWXG)
10761076
os.chmod(dst, stat.S_IRWXU)
1077-
os.lchmod(src_link, stat.S_IRWXO|stat.S_IRWXG)
1077+
_lchmod(src_link, stat.S_IRWXO|stat.S_IRWXG)
10781078
# link to link
1079-
os.lchmod(dst_link, stat.S_IRWXO)
1079+
_lchmod(dst_link, stat.S_IRWXO)
10801080
old_mode = os.stat(dst).st_mode
10811081
shutil.copymode(src_link, dst_link, follow_symlinks=False)
10821082
self.assertEqual(os.lstat(src_link).st_mode,
10831083
os.lstat(dst_link).st_mode)
10841084
self.assertEqual(os.stat(dst).st_mode, old_mode)
10851085
# src link - use chmod
1086-
os.lchmod(dst_link, stat.S_IRWXO)
1086+
_lchmod(dst_link, stat.S_IRWXO)
10871087
shutil.copymode(src_link, dst, follow_symlinks=False)
10881088
self.assertEqual(os.stat(src).st_mode, os.stat(dst).st_mode)
10891089
# dst link - use chmod
1090-
os.lchmod(dst_link, stat.S_IRWXO)
1090+
_lchmod(dst_link, stat.S_IRWXO)
10911091
shutil.copymode(src, dst_link, follow_symlinks=False)
10921092
self.assertEqual(os.stat(src).st_mode, os.stat(dst).st_mode)
10931093

@@ -1124,11 +1124,13 @@ def test_copystat_symlinks(self):
11241124
os.symlink(dst, dst_link)
11251125
if hasattr(os, 'lchmod'):
11261126
os.lchmod(src_link, stat.S_IRWXO)
1127+
elif os.name == 'nt':
1128+
os.chmod(src_link, stat.S_IRWXO)
11271129
if hasattr(os, 'lchflags') and hasattr(stat, 'UF_NODUMP'):
11281130
os.lchflags(src_link, stat.UF_NODUMP)
11291131
src_link_stat = os.lstat(src_link)
11301132
# follow
1131-
if hasattr(os, 'lchmod'):
1133+
if hasattr(os, 'lchmod') or os.name == 'nt':
11321134
shutil.copystat(src_link, dst_link, follow_symlinks=True)
11331135
self.assertNotEqual(src_link_stat.st_mode, os.stat(dst).st_mode)
11341136
# don't follow
@@ -1139,7 +1141,7 @@ def test_copystat_symlinks(self):
11391141
# The modification times may be truncated in the new file.
11401142
self.assertLessEqual(getattr(src_link_stat, attr),
11411143
getattr(dst_link_stat, attr) + 1)
1142-
if hasattr(os, 'lchmod'):
1144+
if hasattr(os, 'lchmod') or os.name == 'nt':
11431145
self.assertEqual(src_link_stat.st_mode, dst_link_stat.st_mode)
11441146
if hasattr(os, 'lchflags') and hasattr(src_link_stat, 'st_flags'):
11451147
self.assertEqual(src_link_stat.st_flags, dst_link_stat.st_flags)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
Fix :func:`shutil.copymode` and :func:`shutil.copystat` on Windows.
2+
Previously they worked differenly if *dst* is a symbolic link:
3+
they modified the permission bits of *dst* itself
4+
rather than the file it points to if *follow_symlinks* is true or *src* is
5+
not a symbolic link, and did not modify the permission bits if
6+
*follow_symlinks* is false and *src* is a symbolic link.

0 commit comments

Comments
 (0)