From d7dd6491f4ea594eda4b1d2913069aa211f391fc Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Wed, 13 Dec 2023 21:12:21 +0200 Subject: [PATCH 1/4] gh-113188: Fix shutil.copymode() and shutil.copystat() on Windows Previously it worked differenly if dst is a symbolic link: it 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. --- Lib/shutil.py | 16 ++++++++++-- Lib/test/test_shutil.py | 25 +++++++++---------- ...-12-15-20-29-49.gh-issue-113188.AvoraB.rst | 5 ++++ 3 files changed, 31 insertions(+), 15 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2023-12-15-20-29-49.gh-issue-113188.AvoraB.rst diff --git a/Lib/shutil.py b/Lib/shutil.py index b99614fabcf4ce..fe08e87d2647e9 100644 --- a/Lib/shutil.py +++ b/Lib/shutil.py @@ -302,11 +302,15 @@ def copymode(src, dst, *, follow_symlinks=True): sys.audit("shutil.copymode", src, dst) if not follow_symlinks and _islink(src) and os.path.islink(dst): - if hasattr(os, 'lchmod'): + if os.name == 'nt': + stat_func, chmod_func = os.lstat, os.chmod + elif hasattr(os, 'lchmod'): stat_func, chmod_func = os.lstat, os.lchmod else: return else: + if os.name == 'nt' and os.path.islink(dst): + dst = os.realpath(dst, strict=True) stat_func, chmod_func = _stat, os.chmod st = stat_func(src) @@ -382,8 +386,16 @@ def lookup(name): # We must copy extended attributes before the file is (potentially) # chmod()'ed read-only, otherwise setxattr() will error with -EACCES. _copyxattr(src, dst, follow_symlinks=follow) + _chmod = lookup("chmod") + if os.name == 'nt': + if follow: + if os.path.islink(dst): + dst = os.path.realpath(dst, strict=True) + else: + def _chmod(*args, **kwargs): + os.chmod(*args) try: - lookup("chmod")(dst, mode, follow_symlinks=follow) + _chmod(dst, mode, follow_symlinks=follow) except NotImplementedError: # if we got a NotImplementedError, it's because # * follow_symlinks=False, diff --git a/Lib/test/test_shutil.py b/Lib/test/test_shutil.py index e70daf978e70ce..90fd8be1150218 100644 --- a/Lib/test/test_shutil.py +++ b/Lib/test/test_shutil.py @@ -1046,19 +1046,18 @@ def test_copymode_follow_symlinks(self): shutil.copymode(src, dst) self.assertEqual(os.stat(src).st_mode, os.stat(dst).st_mode) # On Windows, os.chmod does not follow symlinks (issue #15411) - if os.name != 'nt': - # follow src link - os.chmod(dst, stat.S_IRWXO) - shutil.copymode(src_link, dst) - self.assertEqual(os.stat(src).st_mode, os.stat(dst).st_mode) - # follow dst link - os.chmod(dst, stat.S_IRWXO) - shutil.copymode(src, dst_link) - self.assertEqual(os.stat(src).st_mode, os.stat(dst).st_mode) - # follow both links - os.chmod(dst, stat.S_IRWXO) - shutil.copymode(src_link, dst_link) - self.assertEqual(os.stat(src).st_mode, os.stat(dst).st_mode) + # follow src link + os.chmod(dst, stat.S_IRWXO) + shutil.copymode(src_link, dst) + self.assertEqual(os.stat(src).st_mode, os.stat(dst).st_mode) + # follow dst link + os.chmod(dst, stat.S_IRWXO) + shutil.copymode(src, dst_link) + self.assertEqual(os.stat(src).st_mode, os.stat(dst).st_mode) + # follow both links + os.chmod(dst, stat.S_IRWXO) + shutil.copymode(src_link, dst_link) + self.assertEqual(os.stat(src).st_mode, os.stat(dst).st_mode) @unittest.skipUnless(hasattr(os, 'lchmod'), 'requires os.lchmod') @os_helper.skip_unless_symlink diff --git a/Misc/NEWS.d/next/Library/2023-12-15-20-29-49.gh-issue-113188.AvoraB.rst b/Misc/NEWS.d/next/Library/2023-12-15-20-29-49.gh-issue-113188.AvoraB.rst new file mode 100644 index 00000000000000..b230b3978eed30 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2023-12-15-20-29-49.gh-issue-113188.AvoraB.rst @@ -0,0 +1,5 @@ +Fix :func:`shutil.copymode` on Windows. Previously it worked differenly if +*dst* is a symbolic link: it 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 not modify the permission bits if +*follow_symlinks* is false and *src* is a symbolic link. From ce883a7ae65ff5c3e6c7b5268b24af61bba178ed Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Wed, 13 Dec 2023 21:12:21 +0200 Subject: [PATCH 2/4] gh-113188: Fix shutil.copymode() and shutil.copystat() on Windows Previously it worked differenly if dst is a symbolic link: it 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. --- Lib/shutil.py | 16 ++++++- Lib/test/test_shutil.py | 45 ++++++++++--------- ...-12-15-20-29-49.gh-issue-113188.AvoraB.rst | 5 +++ 3 files changed, 43 insertions(+), 23 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2023-12-15-20-29-49.gh-issue-113188.AvoraB.rst diff --git a/Lib/shutil.py b/Lib/shutil.py index b99614fabcf4ce..e02608832650b0 100644 --- a/Lib/shutil.py +++ b/Lib/shutil.py @@ -302,11 +302,15 @@ def copymode(src, dst, *, follow_symlinks=True): sys.audit("shutil.copymode", src, dst) if not follow_symlinks and _islink(src) and os.path.islink(dst): - if hasattr(os, 'lchmod'): + if os.name == 'nt': + stat_func, chmod_func = os.lstat, os.chmod + elif hasattr(os, 'lchmod'): stat_func, chmod_func = os.lstat, os.lchmod else: return else: + if os.name == 'nt' and os.path.islink(dst): + dst = os.path.realpath(dst, strict=True) stat_func, chmod_func = _stat, os.chmod st = stat_func(src) @@ -382,8 +386,16 @@ def lookup(name): # We must copy extended attributes before the file is (potentially) # chmod()'ed read-only, otherwise setxattr() will error with -EACCES. _copyxattr(src, dst, follow_symlinks=follow) + _chmod = lookup("chmod") + if os.name == 'nt': + if follow: + if os.path.islink(dst): + dst = os.path.realpath(dst, strict=True) + else: + def _chmod(*args, **kwargs): + os.chmod(*args) try: - lookup("chmod")(dst, mode, follow_symlinks=follow) + _chmod(dst, mode, follow_symlinks=follow) except NotImplementedError: # if we got a NotImplementedError, it's because # * follow_symlinks=False, diff --git a/Lib/test/test_shutil.py b/Lib/test/test_shutil.py index e70daf978e70ce..ac4af3639eb229 100644 --- a/Lib/test/test_shutil.py +++ b/Lib/test/test_shutil.py @@ -1046,23 +1046,23 @@ def test_copymode_follow_symlinks(self): shutil.copymode(src, dst) self.assertEqual(os.stat(src).st_mode, os.stat(dst).st_mode) # On Windows, os.chmod does not follow symlinks (issue #15411) - if os.name != 'nt': - # follow src link - os.chmod(dst, stat.S_IRWXO) - shutil.copymode(src_link, dst) - self.assertEqual(os.stat(src).st_mode, os.stat(dst).st_mode) - # follow dst link - os.chmod(dst, stat.S_IRWXO) - shutil.copymode(src, dst_link) - self.assertEqual(os.stat(src).st_mode, os.stat(dst).st_mode) - # follow both links - os.chmod(dst, stat.S_IRWXO) - shutil.copymode(src_link, dst_link) - self.assertEqual(os.stat(src).st_mode, os.stat(dst).st_mode) - - @unittest.skipUnless(hasattr(os, 'lchmod'), 'requires os.lchmod') + # follow src link + os.chmod(dst, stat.S_IRWXO) + shutil.copymode(src_link, dst) + self.assertEqual(os.stat(src).st_mode, os.stat(dst).st_mode) + # follow dst link + os.chmod(dst, stat.S_IRWXO) + shutil.copymode(src, dst_link) + self.assertEqual(os.stat(src).st_mode, os.stat(dst).st_mode) + # follow both links + os.chmod(dst, stat.S_IRWXO) + shutil.copymode(src_link, dst_link) + self.assertEqual(os.stat(src).st_mode, os.stat(dst).st_mode) + + @unittest.skipUnless(hasattr(os, 'lchmod') or os.name == 'nt', 'requires os.lchmod') @os_helper.skip_unless_symlink def test_copymode_symlink_to_symlink(self): + _lchmod = os.chmod if os.name == 'nt' else os.lchmod tmp_dir = self.mkdtemp() src = os.path.join(tmp_dir, 'foo') dst = os.path.join(tmp_dir, 'bar') @@ -1074,20 +1074,20 @@ def test_copymode_symlink_to_symlink(self): os.symlink(dst, dst_link) os.chmod(src, stat.S_IRWXU|stat.S_IRWXG) os.chmod(dst, stat.S_IRWXU) - os.lchmod(src_link, stat.S_IRWXO|stat.S_IRWXG) + _lchmod(src_link, stat.S_IRWXO|stat.S_IRWXG) # link to link - os.lchmod(dst_link, stat.S_IRWXO) + _lchmod(dst_link, stat.S_IRWXO) old_mode = os.stat(dst).st_mode shutil.copymode(src_link, dst_link, follow_symlinks=False) self.assertEqual(os.lstat(src_link).st_mode, os.lstat(dst_link).st_mode) self.assertEqual(os.stat(dst).st_mode, old_mode) # src link - use chmod - os.lchmod(dst_link, stat.S_IRWXO) + _lchmod(dst_link, stat.S_IRWXO) shutil.copymode(src_link, dst, follow_symlinks=False) self.assertEqual(os.stat(src).st_mode, os.stat(dst).st_mode) # dst link - use chmod - os.lchmod(dst_link, stat.S_IRWXO) + _lchmod(dst_link, stat.S_IRWXO) shutil.copymode(src, dst_link, follow_symlinks=False) self.assertEqual(os.stat(src).st_mode, os.stat(dst).st_mode) @@ -1109,6 +1109,7 @@ def test_copymode_symlink_to_symlink_wo_lchmod(self): @os_helper.skip_unless_symlink def test_copystat_symlinks(self): + _lchmod = os.chmod if os.name == 'nt' else getattr(os, 'lchmod', None) tmp_dir = self.mkdtemp() src = os.path.join(tmp_dir, 'foo') dst = os.path.join(tmp_dir, 'bar') @@ -1124,11 +1125,13 @@ def test_copystat_symlinks(self): os.symlink(dst, dst_link) if hasattr(os, 'lchmod'): os.lchmod(src_link, stat.S_IRWXO) + elif os.name == 'nt': + os.chmod(src_link, stat.S_IRWXO) if hasattr(os, 'lchflags') and hasattr(stat, 'UF_NODUMP'): os.lchflags(src_link, stat.UF_NODUMP) src_link_stat = os.lstat(src_link) # follow - if hasattr(os, 'lchmod'): + if hasattr(os, 'lchmod') or os.name == 'nt': shutil.copystat(src_link, dst_link, follow_symlinks=True) self.assertNotEqual(src_link_stat.st_mode, os.stat(dst).st_mode) # don't follow @@ -1139,7 +1142,7 @@ def test_copystat_symlinks(self): # The modification times may be truncated in the new file. self.assertLessEqual(getattr(src_link_stat, attr), getattr(dst_link_stat, attr) + 1) - if hasattr(os, 'lchmod'): + if hasattr(os, 'lchmod') or os.name == 'nt': self.assertEqual(src_link_stat.st_mode, dst_link_stat.st_mode) if hasattr(os, 'lchflags') and hasattr(src_link_stat, 'st_flags'): self.assertEqual(src_link_stat.st_flags, dst_link_stat.st_flags) diff --git a/Misc/NEWS.d/next/Library/2023-12-15-20-29-49.gh-issue-113188.AvoraB.rst b/Misc/NEWS.d/next/Library/2023-12-15-20-29-49.gh-issue-113188.AvoraB.rst new file mode 100644 index 00000000000000..b230b3978eed30 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2023-12-15-20-29-49.gh-issue-113188.AvoraB.rst @@ -0,0 +1,5 @@ +Fix :func:`shutil.copymode` on Windows. Previously it worked differenly if +*dst* is a symbolic link: it 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 not modify the permission bits if +*follow_symlinks* is false and *src* is a symbolic link. From 3a8e663af4b566c9b2343eb10b54260d27764cb0 Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Tue, 19 Dec 2023 15:05:24 +0200 Subject: [PATCH 3/4] Update 2023-12-15-20-29-49.gh-issue-113188.AvoraB.rst --- .../Library/2023-12-15-20-29-49.gh-issue-113188.AvoraB.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Misc/NEWS.d/next/Library/2023-12-15-20-29-49.gh-issue-113188.AvoraB.rst b/Misc/NEWS.d/next/Library/2023-12-15-20-29-49.gh-issue-113188.AvoraB.rst index b230b3978eed30..5b57b49359fc58 100644 --- a/Misc/NEWS.d/next/Library/2023-12-15-20-29-49.gh-issue-113188.AvoraB.rst +++ b/Misc/NEWS.d/next/Library/2023-12-15-20-29-49.gh-issue-113188.AvoraB.rst @@ -1,5 +1,5 @@ -Fix :func:`shutil.copymode` on Windows. Previously it worked differenly if -*dst* is a symbolic link: it modified the permission bits of *dst* itself +Fix :func:`shutil.copymode` and :func:`shutil.copystat` on Windows. +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 not modify the permission bits if *follow_symlinks* is false and *src* is a symbolic link. From 3cf42d423dedeb4a8650119af06f21b11492190c Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Sat, 23 Dec 2023 12:04:26 +0200 Subject: [PATCH 4/4] Clean up. --- Lib/test/test_shutil.py | 1 - 1 file changed, 1 deletion(-) diff --git a/Lib/test/test_shutil.py b/Lib/test/test_shutil.py index ac4af3639eb229..2fa81f14c78426 100644 --- a/Lib/test/test_shutil.py +++ b/Lib/test/test_shutil.py @@ -1109,7 +1109,6 @@ def test_copymode_symlink_to_symlink_wo_lchmod(self): @os_helper.skip_unless_symlink def test_copystat_symlinks(self): - _lchmod = os.chmod if os.name == 'nt' else getattr(os, 'lchmod', None) tmp_dir = self.mkdtemp() src = os.path.join(tmp_dir, 'foo') dst = os.path.join(tmp_dir, 'bar')