diff --git a/Lib/pathlib/_os.py b/Lib/pathlib/_os.py index 79a1969d5f83d6d..96e7fbf886847e4 100644 --- a/Lib/pathlib/_os.py +++ b/Lib/pathlib/_os.py @@ -268,16 +268,24 @@ 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, OSError): + # No resolve(), or realpath failed. + 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..a862444033ff00e 100644 --- a/Lib/test/test_pathlib/test_pathlib.py +++ b/Lib/test/test_pathlib/test_pathlib.py @@ -1453,6 +1453,49 @@ 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): + 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()) + + 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) 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.