From a90685f5dc4ead496dcaa9714b451790e7611c9b Mon Sep 17 00:00:00 2001 From: lipengyu Date: Wed, 19 Aug 2026 15:02:24 +0800 Subject: [PATCH 1/2] gh-156035: Validate pathlib rename and replace targets before filesystem changes Validate targets before modifying the filesystem so invalid bytes paths raise TypeError without moving the source. --- Lib/pathlib/__init__.py | 4 ++-- Lib/test/test_pathlib/test_pathlib.py | 24 +++++++++++++++++++ ...-08-19-14-58-39.gh-issue-156035.7A9axQ.rst | 4 ++++ 3 files changed, 30 insertions(+), 2 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-08-19-14-58-39.gh-issue-156035.7A9axQ.rst diff --git a/Lib/pathlib/__init__.py b/Lib/pathlib/__init__.py index ffec9c545ee11f..1e119a28c71e0f 100644 --- a/Lib/pathlib/__init__.py +++ b/Lib/pathlib/__init__.py @@ -1276,9 +1276,9 @@ def rename(self, target): Returns the new Path instance pointing to the target path. """ - os.rename(self, target) if not hasattr(target, 'with_segments'): target = self.with_segments(target) + os.rename(self, target) return target def replace(self, target): @@ -1291,9 +1291,9 @@ def replace(self, target): Returns the new Path instance pointing to the target path. """ - os.replace(self, target) if not hasattr(target, 'with_segments'): target = self.with_segments(target) + os.replace(self, target) return target def copy(self, target, **kwargs): diff --git a/Lib/test/test_pathlib/test_pathlib.py b/Lib/test/test_pathlib/test_pathlib.py index aff66c8efedbbc..dfc387fb64d2a7 100644 --- a/Lib/test/test_pathlib/test_pathlib.py +++ b/Lib/test/test_pathlib/test_pathlib.py @@ -2309,6 +2309,18 @@ def test_rename(self): self.assertEqual(os.stat(r).st_size, size) self.assertFileNotFound(q.stat) + def test_rename_bytes_target(self): + P = self.cls(self.base) + source = P / 'fileA' + target = P / 'dirA' / 'fileAA' + target_bytes = os.fsencode(target) + for target_arg in (target_bytes, FakePath(target_bytes)): + with self.subTest(target=target_arg): + with self.assertRaises(TypeError): + source.rename(target_arg) + self.assertTrue(source.exists()) + self.assertFalse(target.exists()) + def test_replace(self): P = self.cls(self.base) p = P / 'fileA' @@ -2326,6 +2338,18 @@ def test_replace(self): self.assertEqual(os.stat(r).st_size, size) self.assertFileNotFound(q.stat) + def test_replace_bytes_target(self): + P = self.cls(self.base) + source = P / 'fileA' + target = P / 'dirA' / 'fileAA' + target_bytes = os.fsencode(target) + for target_arg in (target_bytes, FakePath(target_bytes)): + with self.subTest(target=target_arg): + with self.assertRaises(TypeError): + source.replace(target_arg) + self.assertTrue(source.exists()) + self.assertFalse(target.exists()) + def test_touch_common(self): P = self.cls(self.base) p = P / 'newfileA' diff --git a/Misc/NEWS.d/next/Library/2026-08-19-14-58-39.gh-issue-156035.7A9axQ.rst b/Misc/NEWS.d/next/Library/2026-08-19-14-58-39.gh-issue-156035.7A9axQ.rst new file mode 100644 index 00000000000000..d7281abb6949ab --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-08-19-14-58-39.gh-issue-156035.7A9axQ.rst @@ -0,0 +1,4 @@ +Fix :meth:`pathlib.Path.rename` and :meth:`pathlib.Path.replace` to validate +the target before modifying the filesystem. Passing a bytes path, including +a path-like object returning bytes, now raises :exc:`TypeError` without +moving the source. From acebf7ebc8cd22e0f3f073a0413bf4886d4a4f73 Mon Sep 17 00:00:00 2001 From: lipengyu Date: Thu, 20 Aug 2026 14:24:56 +0800 Subject: [PATCH 2/2] address review --- Lib/pathlib/__init__.py | 16 ++++++++++------ Lib/test/test_pathlib/test_pathlib.py | 18 ++++++++++++++++++ 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/Lib/pathlib/__init__.py b/Lib/pathlib/__init__.py index 1e119a28c71e0f..6f2e5b496b18b8 100644 --- a/Lib/pathlib/__init__.py +++ b/Lib/pathlib/__init__.py @@ -1276,10 +1276,12 @@ def rename(self, target): Returns the new Path instance pointing to the target path. """ - if not hasattr(target, 'with_segments'): - target = self.with_segments(target) + if hasattr(target, 'with_segments'): + result = target + else: + result = self.with_segments(target) os.rename(self, target) - return target + return result def replace(self, target): """ @@ -1291,10 +1293,12 @@ def replace(self, target): Returns the new Path instance pointing to the target path. """ - if not hasattr(target, 'with_segments'): - target = self.with_segments(target) + if hasattr(target, 'with_segments'): + result = target + else: + result = self.with_segments(target) os.replace(self, target) - return target + return result def copy(self, target, **kwargs): """ diff --git a/Lib/test/test_pathlib/test_pathlib.py b/Lib/test/test_pathlib/test_pathlib.py index dfc387fb64d2a7..9d53c2760e17a6 100644 --- a/Lib/test/test_pathlib/test_pathlib.py +++ b/Lib/test/test_pathlib/test_pathlib.py @@ -2321,6 +2321,15 @@ def test_rename_bytes_target(self): self.assertTrue(source.exists()) self.assertFalse(target.exists()) + def test_rename_preserves_target(self): + P = self.cls(self.base) + source = P / 'fileA' + target = str(P / 'dirA' / 'fileAA') + self.parser.sep + with mock.patch.object(os, 'rename') as rename: + renamed = source.rename(target) + rename.assert_called_once_with(source, target) + self.assertEqual(renamed, self.cls(target)) + def test_replace(self): P = self.cls(self.base) p = P / 'fileA' @@ -2350,6 +2359,15 @@ def test_replace_bytes_target(self): self.assertTrue(source.exists()) self.assertFalse(target.exists()) + def test_replace_preserves_target(self): + P = self.cls(self.base) + source = P / 'fileA' + target = str(P / 'dirA' / 'fileAA') + self.parser.sep + with mock.patch.object(os, 'replace') as replace: + replaced = source.replace(target) + replace.assert_called_once_with(source, target) + self.assertEqual(replaced, self.cls(target)) + def test_touch_common(self): P = self.cls(self.base) p = P / 'newfileA'