diff --git a/Lib/shutil.py b/Lib/shutil.py index f678601e6758ebc..c3d032e16694096 100644 --- a/Lib/shutil.py +++ b/Lib/shutil.py @@ -292,6 +292,9 @@ def copyfile(src, dst, *, follow_symlinks=True): symlink will be created instead of copying the file it points to. """ + return _copyfile(src, dst, follow_symlinks, private=False) + +def _copyfile(src, dst, follow_symlinks, *, private): sys.audit("shutil.copyfile", src, dst) if _samefile(src, dst): @@ -328,7 +331,16 @@ def copyfile(src, dst, *, follow_symlinks=True): else: with open(src, 'rb') as fsrc: try: - with open(dst, 'wb') as fdst: + if private: + def opener(path, flags): + # Only the owner can access a new file until copystat() + # applies the source mode. Setting xattrs needs write access. + mode = os.fstat(fsrc.fileno()).st_mode & stat.S_IRWXU + return os.open(path, flags, mode | stat.S_IWUSR) + fdst = open(dst, 'wb', opener=opener) + else: + fdst = open(dst, 'wb') + with fdst: # macOS if _HAS_FCOPYFILE: try: @@ -542,7 +554,7 @@ def copy2(src, dst, *, follow_symlinks=True): else: raise - copyfile(src, dst, follow_symlinks=follow_symlinks) + _copyfile(src, dst, follow_symlinks, private=os.name == 'posix') copystat(src, dst, follow_symlinks=follow_symlinks) return dst diff --git a/Lib/test/test_shutil.py b/Lib/test/test_shutil.py index 1a1a77ecdaeb768..f5fff629e7a226c 100644 --- a/Lib/test/test_shutil.py +++ b/Lib/test/test_shutil.py @@ -1513,12 +1513,36 @@ def test_copy2_xattr(self): dst = os.path.join(tmp_dir, 'bar') create_file(src, 'foo') os.setxattr(src, 'user.foo', b'42') + os.chmod(src, 0o444) shutil.copy2(src, dst) self.assertEqual( os.getxattr(src, 'user.foo'), os.getxattr(dst, 'user.foo')) + self.assertEqual(stat.S_IMODE(os.stat(dst).st_mode), 0o444) os.remove(dst) + @unittest.skipUnless(hasattr(os, 'listxattr'), 'requires os.listxattr') + def test_copy2_listxattr_permission_error(self): + tmp_dir = self.mkdtemp() + src = os.path.join(tmp_dir, 'src') + create_file(src, 'contents') + for err, mode in ((errno.EACCES, 0o600), (errno.EPERM, 0o700)): + with self.subTest(errno=err, mode=mode), os_helper.temp_umask(0o022): + os.chmod(src, mode) + dst = os.path.join(tmp_dir, f'dst{err}') + error = PermissionError(err, os.strerror(err)) + + def deny_listxattr(*args, **kwargs): + self.assertEqual(stat.S_IMODE(os.stat(dst).st_mode), mode) + raise error + + with unittest.mock.patch('os.listxattr', deny_listxattr): + with self.assertRaises(PermissionError) as cm: + shutil.copy2(src, dst) + self.assertIs(cm.exception, error) + self.assertEqual(stat.S_IMODE(os.stat(dst).st_mode), mode) + self.assertEqual(read_file(dst), 'contents') + def test_copy_return_value(self): # copy and copy2 both return their destination path. for fn in (shutil.copy, shutil.copy2): diff --git a/Misc/NEWS.d/next/Library/2026-10-24-00-57-00.gh-issue-59305.p7tA2r.rst b/Misc/NEWS.d/next/Library/2026-10-24-00-57-00.gh-issue-59305.p7tA2r.rst new file mode 100644 index 000000000000000..263ed1a2a8904ea --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-10-24-00-57-00.gh-issue-59305.p7tA2r.rst @@ -0,0 +1,2 @@ +On POSIX, :func:`shutil.copy2` creates new files with owner-only permissions +until it applies the source metadata.