From 9bca03f3b97e3e14ecf884f1e6d8956d6020e14c Mon Sep 17 00:00:00 2001 From: Stepan Neretin Date: Sat, 25 Jul 2026 18:59:23 +0700 Subject: [PATCH 1/3] pathlib: Resolve paths in ensure_distinct_paths() before comparing Path.copy() only blocked self-copies via a lexical check, so relative vs absolute paths and symlink aliases could still copy a directory into itself. Resolve when possible (like shutil._destinsrc) and add tests. --- Lib/pathlib/_os.py | 17 ++++++++++++---- Lib/test/test_pathlib/test_pathlib.py | 28 +++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/Lib/pathlib/_os.py b/Lib/pathlib/_os.py index 79a1969d5f83d6d..0cdd2e7f7de458a 100644 --- a/Lib/pathlib/_os.py +++ b/Lib/pathlib/_os.py @@ -268,16 +268,25 @@ def ensure_distinct_paths(source, target): # Note: there is no straightforward, foolproof algorithm to determine # if one directory is within another (a particularly perverse example # would be a single network share mounted in one location via NFS, and - # in another location via CIFS), so we simply checks whether the - # other path is lexically equal to, or within, this path. + # in another location via CIFS). Resolve when possible so relative vs + # absolute paths and symlink aliases are compared like shutil._destinsrc + # (realpath); otherwise fall back to a lexical check. + source_orig, target_orig = source, target + try: + source = source.resolve(strict=False) + target = target.resolve(strict=False) + except (AttributeError, TypeError): + pass + except (OSError, ValueError, RuntimeError, NotImplementedError): + pass if source == target: err = OSError(EINVAL, "Source and target are the same path") elif source in target.parents: err = OSError(EINVAL, "Source path is a parent of target path") else: return - err.filename = vfspath(source) - err.filename2 = vfspath(target) + err.filename = vfspath(source_orig) + err.filename2 = vfspath(target_orig) raise err diff --git a/Lib/test/test_pathlib/test_pathlib.py b/Lib/test/test_pathlib/test_pathlib.py index aff66c8efedbbc0..9cbb75dc7107705 100644 --- a/Lib/test/test_pathlib/test_pathlib.py +++ b/Lib/test/test_pathlib/test_pathlib.py @@ -1453,6 +1453,34 @@ def test_copy_directory_symlink_into_itself(self): self.assertRaises(OSError, source.copy, target, follow_symlinks=False) self.assertFalse(target.exists()) + def test_copy_dir_into_itself_relative_source_absolute_target(self): + # ensure_distinct_paths() must not be fooled by relative vs absolute. + with os_helper.change_cwd(self.base): + source = self.cls('dirC') + target = source.resolve() / 'copyC' + self.assertRaises(OSError, source.copy, target) + self.assertRaises(OSError, source.copy, target, follow_symlinks=False) + self.assertFalse(target.exists()) + + def test_copy_dir_into_itself_absolute_source_relative_target(self): + with os_helper.change_cwd(self.base): + source = self.cls(self.base) / 'dirC' + target = self.cls('dirC') / 'copyC' + self.assertRaises(OSError, source.copy, target) + self.assertRaises(OSError, source.copy, target, follow_symlinks=False) + self.assertFalse((source / 'copyC').exists()) + + @needs_symlinks + def test_copy_dir_into_itself_via_symlink(self): + base = self.cls(self.base) + source = base / 'dirC' + link = base / 'linkToDirC' + link.symlink_to(source, target_is_directory=True) + target = link / 'copyC' + self.assertRaises(OSError, source.copy, target) + self.assertRaises(OSError, source.copy, target, follow_symlinks=False) + self.assertFalse((source / 'copyC').exists()) + @needs_symlinks def test_copy_directory_symlink_to_existing_symlink(self): base = self.cls(self.base) From d228788f1b2d34eeb6fc4686563298c5f270c659 Mon Sep 17 00:00:00 2001 From: Stepan Neretin Date: Sat, 25 Jul 2026 19:07:18 +0700 Subject: [PATCH 2/3] gh-154690: Add news entry --- .../Library/2026-07-25-12-06-55.gh-issue-154690.cHNanK.rst | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 Misc/NEWS.d/next/Library/2026-07-25-12-06-55.gh-issue-154690.cHNanK.rst diff --git a/Misc/NEWS.d/next/Library/2026-07-25-12-06-55.gh-issue-154690.cHNanK.rst b/Misc/NEWS.d/next/Library/2026-07-25-12-06-55.gh-issue-154690.cHNanK.rst new file mode 100644 index 000000000000000..b63077c84ae7a19 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-07-25-12-06-55.gh-issue-154690.cHNanK.rst @@ -0,0 +1,3 @@ +:meth:`pathlib.Path.copy` now resolves paths when checking whether the +destination is inside the source directory, preventing bypasses via +relative/absolute path aliases and symlinks. From 33257e7c5d1d9b8caba54bfa3f6bb74d65f9e015 Mon Sep 17 00:00:00 2001 From: Stepan Neretin Date: Sat, 25 Jul 2026 20:31:26 +0700 Subject: [PATCH 3/3] gh-154690: Narrow ensure_distinct_paths() resolve() exception handling Only catch AttributeError (no resolve) and OSError (realpath failed), and test the lexical fallback for both cases. --- Lib/pathlib/_os.py | 5 ++--- Lib/test/test_pathlib/test_pathlib.py | 17 ++++++++++++++++- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/Lib/pathlib/_os.py b/Lib/pathlib/_os.py index 0cdd2e7f7de458a..96e7fbf886847e4 100644 --- a/Lib/pathlib/_os.py +++ b/Lib/pathlib/_os.py @@ -275,9 +275,8 @@ def ensure_distinct_paths(source, target): try: source = source.resolve(strict=False) target = target.resolve(strict=False) - except (AttributeError, TypeError): - pass - except (OSError, ValueError, RuntimeError, NotImplementedError): + except (AttributeError, OSError): + # No resolve(), or realpath failed. pass if source == target: err = OSError(EINVAL, "Source and target are the same path") diff --git a/Lib/test/test_pathlib/test_pathlib.py b/Lib/test/test_pathlib/test_pathlib.py index 9cbb75dc7107705..a862444033ff00e 100644 --- a/Lib/test/test_pathlib/test_pathlib.py +++ b/Lib/test/test_pathlib/test_pathlib.py @@ -1454,7 +1454,6 @@ def test_copy_directory_symlink_into_itself(self): self.assertFalse(target.exists()) def test_copy_dir_into_itself_relative_source_absolute_target(self): - # ensure_distinct_paths() must not be fooled by relative vs absolute. with os_helper.change_cwd(self.base): source = self.cls('dirC') target = source.resolve() / 'copyC' @@ -1481,6 +1480,22 @@ def test_copy_dir_into_itself_via_symlink(self): self.assertRaises(OSError, source.copy, target, follow_symlinks=False) self.assertFalse((source / 'copyC').exists()) + def test_copy_dir_into_itself_when_no_resolve(self): + base = self.cls(self.base) + source = base / 'dirC' + target = source / 'copyC' + with mock.patch.object(self.cls, 'resolve', side_effect=AttributeError): + self.assertRaises(OSError, source.copy, target) + self.assertFalse(target.exists()) + + def test_copy_dir_into_itself_when_resolve_raises_oserror(self): + base = self.cls(self.base) + source = base / 'dirC' + target = source / 'copyC' + with mock.patch.object(self.cls, 'resolve', side_effect=OSError): + self.assertRaises(OSError, source.copy, target) + self.assertFalse(target.exists()) + @needs_symlinks def test_copy_directory_symlink_to_existing_symlink(self): base = self.cls(self.base)