From 145155140f4f25870b2fb832c4b781bb76a48cff Mon Sep 17 00:00:00 2001 From: lkk7 Date: Sun, 26 Jul 2026 11:47:45 +0200 Subject: [PATCH 1/4] gh-154726: Fix `shutil.copyfile()` for device symlinks with `follow_symlinks=False` --- Lib/shutil.py | 5 ++++- Lib/test/test_shutil.py | 13 +++++++++++++ .../2026-07-26-11-45-34.gh-issue-154726.hLJk5-.rst | 3 +++ 3 files changed, 20 insertions(+), 1 deletion(-) create mode 100644 Misc/NEWS.d/next/Library/2026-07-26-11-45-34.gh-issue-154726.hLJk5-.rst diff --git a/Lib/shutil.py b/Lib/shutil.py index 6a2e2b2ffdae2c0..94617ec296f5087 100644 --- a/Lib/shutil.py +++ b/Lib/shutil.py @@ -292,8 +292,11 @@ def copyfile(src, dst, *, follow_symlinks=True): if _samefile(src, dst): raise SameFileError("{!r} and {!r} are the same file".format(src, dst)) + copy_symlink = not follow_symlinks and _islink(src) file_size = 0 for i, fn in enumerate([src, dst]): + if copy_symlink and i == 0: + continue try: st = _stat(fn) except OSError: @@ -315,7 +318,7 @@ def copyfile(src, dst, *, follow_symlinks=True): if _WINDOWS and i == 0: file_size = st.st_size - if not follow_symlinks and _islink(src): + if copy_symlink: os.symlink(os.readlink(src), dst) else: with open(src, 'rb') as fsrc: diff --git a/Lib/test/test_shutil.py b/Lib/test/test_shutil.py index 6832bea094fc1dc..bbbd0ca66edb7f1 100644 --- a/Lib/test/test_shutil.py +++ b/Lib/test/test_shutil.py @@ -1583,6 +1583,19 @@ def test_copyfile_character_device(self): self.assertRaisesRegex(shutil.SpecialFileError, 'is a character device', shutil.copyfile, src_file, '/dev/null') + @os_helper.skip_unless_symlink + @unittest.skipUnless(os.path.exists('/dev/null'), 'requires /dev/null') + def test_copyfile_symlink_to_character_device(self): + tmp_dir = self.mkdtemp() + src = os.path.join(tmp_dir, 'src') + dst = os.path.join(tmp_dir, 'dst') + os.symlink('/dev/null', src) + + shutil.copyfile(src, dst, follow_symlinks=False) + + self.assertTrue(os.path.islink(dst)) + self.assertEqual(os.readlink(dst), '/dev/null') + def test_copyfile_block_device(self): block_dev = None for dev in ['/dev/loop0', '/dev/sda', '/dev/vda', '/dev/disk0']: diff --git a/Misc/NEWS.d/next/Library/2026-07-26-11-45-34.gh-issue-154726.hLJk5-.rst b/Misc/NEWS.d/next/Library/2026-07-26-11-45-34.gh-issue-154726.hLJk5-.rst new file mode 100644 index 000000000000000..73cf40a72077439 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-07-26-11-45-34.gh-issue-154726.hLJk5-.rst @@ -0,0 +1,3 @@ +Fix :func:`shutil.copyfile` to copy a symbolic link to a special file when +``follow_symlinks=False`` instead of raising +:exc:`~shutil.SpecialFileError`. From 88fa4c7a2f72da212b11f46fd379b0c68b5ce00f Mon Sep 17 00:00:00 2001 From: lkk7 Date: Mon, 27 Jul 2026 10:56:47 +0200 Subject: [PATCH 2/4] gh-154726: Test FIFO and socket symlinks in `shutil.copyfile()` --- Lib/test/test_shutil.py | 51 +++++++++++++++++++++++++++++++++-------- 1 file changed, 42 insertions(+), 9 deletions(-) diff --git a/Lib/test/test_shutil.py b/Lib/test/test_shutil.py index bbbd0ca66edb7f1..4c225a0080e5d52 100644 --- a/Lib/test/test_shutil.py +++ b/Lib/test/test_shutil.py @@ -1574,6 +1574,47 @@ def test_copyfile_socket(self): self.assertRaisesRegex(shutil.SpecialFileError, 'is a socket', shutil.copyfile, __file__, sock_path) + def _check_copyfile_symlink_to_special_file(self, target): + tmp_dir = self.mkdtemp() + src = os.path.join(tmp_dir, 'src') + dst = os.path.join(tmp_dir, 'dst') + os.symlink(target, src) + + shutil.copyfile(src, dst, follow_symlinks=False) + + self.assertTrue(os.path.islink(dst)) + self.assertEqual(os.readlink(dst), target) + + @os_helper.skip_unless_symlink + @unittest.skipUnless(hasattr(os, "mkfifo"), 'requires os.mkfifo()') + @unittest.skipIf(sys.platform == "vxworks", + "fifo requires special path on VxWorks") + def test_copyfile_symlink_to_named_pipe(self): + fifo_path = os.path.join(self.mkdtemp(), 'fifo') + try: + os.mkfifo(fifo_path) + except PermissionError as e: + self.skipTest('os.mkfifo(): %s' % e) + self._check_copyfile_symlink_to_special_file(fifo_path) + + @os_helper.skip_unless_symlink + @socket_helper.skip_unless_bind_unix_socket + def test_copyfile_symlink_to_socket(self): + sock_path = os.path.join(self.mkdtemp(), 'sock') + sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + self.addCleanup(sock.close) + try: + socket_helper.bind_unix_socket(sock, sock_path) + except OSError as e: + if str(e) == "AF_UNIX path too long": + self.skipTest( + "Pathname {0!a} is too long to serve as an AF_UNIX path" + .format(sock_path)) + else: + raise + self.addCleanup(os_helper.unlink, sock_path) + self._check_copyfile_symlink_to_special_file(sock_path) + @unittest.skipUnless(os.path.exists('/dev/null'), 'requires /dev/null') def test_copyfile_character_device(self): self.assertRaisesRegex(shutil.SpecialFileError, 'is a character device', @@ -1586,15 +1627,7 @@ def test_copyfile_character_device(self): @os_helper.skip_unless_symlink @unittest.skipUnless(os.path.exists('/dev/null'), 'requires /dev/null') def test_copyfile_symlink_to_character_device(self): - tmp_dir = self.mkdtemp() - src = os.path.join(tmp_dir, 'src') - dst = os.path.join(tmp_dir, 'dst') - os.symlink('/dev/null', src) - - shutil.copyfile(src, dst, follow_symlinks=False) - - self.assertTrue(os.path.islink(dst)) - self.assertEqual(os.readlink(dst), '/dev/null') + self._check_copyfile_symlink_to_special_file('/dev/null') def test_copyfile_block_device(self): block_dev = None From 2fb2134bf7ce429c2eaf9389b9064cf12c8ce71a Mon Sep 17 00:00:00 2001 From: lkk7 Date: Mon, 27 Jul 2026 16:46:49 +0200 Subject: [PATCH 3/4] gh-154726: Match socket bind error handling in shutil tests --- Lib/test/test_shutil.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/Lib/test/test_shutil.py b/Lib/test/test_shutil.py index 4c225a0080e5d52..add661263b616c8 100644 --- a/Lib/test/test_shutil.py +++ b/Lib/test/test_shutil.py @@ -1606,12 +1606,7 @@ def test_copyfile_symlink_to_socket(self): try: socket_helper.bind_unix_socket(sock, sock_path) except OSError as e: - if str(e) == "AF_UNIX path too long": - self.skipTest( - "Pathname {0!a} is too long to serve as an AF_UNIX path" - .format(sock_path)) - else: - raise + self.skipTest(f'cannot bind AF_UNIX socket: {e}') self.addCleanup(os_helper.unlink, sock_path) self._check_copyfile_symlink_to_special_file(sock_path) From bf7e3d264cf61f193e9e028d894d4645b5f382ab Mon Sep 17 00:00:00 2001 From: lkk7 Date: Mon, 27 Jul 2026 16:47:28 +0200 Subject: [PATCH 4/4] gh-154726: Group shutil special-file symlink tests --- Lib/test/test_shutil.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Lib/test/test_shutil.py b/Lib/test/test_shutil.py index add661263b616c8..ed5d15ecc7ddad6 100644 --- a/Lib/test/test_shutil.py +++ b/Lib/test/test_shutil.py @@ -1585,6 +1585,11 @@ def _check_copyfile_symlink_to_special_file(self, target): self.assertTrue(os.path.islink(dst)) self.assertEqual(os.readlink(dst), target) + @os_helper.skip_unless_symlink + @unittest.skipUnless(os.path.exists('/dev/null'), 'requires /dev/null') + def test_copyfile_symlink_to_character_device(self): + self._check_copyfile_symlink_to_special_file('/dev/null') + @os_helper.skip_unless_symlink @unittest.skipUnless(hasattr(os, "mkfifo"), 'requires os.mkfifo()') @unittest.skipIf(sys.platform == "vxworks", @@ -1619,11 +1624,6 @@ def test_copyfile_character_device(self): self.assertRaisesRegex(shutil.SpecialFileError, 'is a character device', shutil.copyfile, src_file, '/dev/null') - @os_helper.skip_unless_symlink - @unittest.skipUnless(os.path.exists('/dev/null'), 'requires /dev/null') - def test_copyfile_symlink_to_character_device(self): - self._check_copyfile_symlink_to_special_file('/dev/null') - def test_copyfile_block_device(self): block_dev = None for dev in ['/dev/loop0', '/dev/sda', '/dev/vda', '/dev/disk0']: