diff --git a/Lib/pathlib/__init__.py b/Lib/pathlib/__init__.py index bc39a30c6538ce..6db0d693e5a4b9 100644 --- a/Lib/pathlib/__init__.py +++ b/Lib/pathlib/__init__.py @@ -1140,13 +1140,19 @@ def _copy_from_file(self, source, preserve_metadata=False): _copy_from_file_fallback = _copy_from_file def _copy_from_file(self, source, preserve_metadata=False): try: - source = os.fspath(source) + source_path = os.fspath(source) except TypeError: - pass - else: - copyfile2(source, str(self)) + self._copy_from_file_fallback(source, preserve_metadata) return - self._copy_from_file_fallback(source, preserve_metadata) + try: + copyfile2(source_path, str(self)) + except OSError as exc: + # On Windows, OSError from file operations is guaranteed to have winerror attribute + if exc.winerror in (5, 1314): + # ERROR_ACCESS_DENIED (5) or ERROR_PRIVILEGE_NOT_HELD (1314) + self._copy_from_file_fallback(source, preserve_metadata) + return + raise if os.name == 'nt': # If a directory-symlink is copied *before* its target, then diff --git a/Lib/test/test_pathlib/test_copy.py b/Lib/test/test_pathlib/test_copy.py index 5f4cf82a0314c4..d8c8dc8d01af35 100644 --- a/Lib/test/test_pathlib/test_copy.py +++ b/Lib/test/test_pathlib/test_copy.py @@ -3,7 +3,10 @@ """ import contextlib +import errno +import os import unittest +from unittest import mock from .support import is_pypi from .support.local_path import LocalPathGround @@ -169,6 +172,74 @@ class LocalToLocalPathCopyTest(CopyTestBase, unittest.TestCase): source_ground = LocalPathGround(Path) target_ground = LocalPathGround(Path) + @unittest.skipUnless(os.name == 'nt', 'needs Windows for CopyFile2 fallback') + def test_copy_hidden_file_fallback_on_access_denied(self): + import _winapi + import ctypes + import pathlib + + if pathlib.copyfile2 is None: + self.skipTest('copyfile2 unavailable') + + source = self.source_root / 'fileA' + target = self.target_root / 'copy_hidden' + + kernel32 = ctypes.windll.kernel32 + GetFileAttributesW = kernel32.GetFileAttributesW + SetFileAttributesW = kernel32.SetFileAttributesW + GetFileAttributesW.argtypes = [ctypes.c_wchar_p] + GetFileAttributesW.restype = ctypes.c_uint32 + SetFileAttributesW.argtypes = [ctypes.c_wchar_p, ctypes.c_uint32] + SetFileAttributesW.restype = ctypes.c_int + + path_str = str(source) + original_attrs = GetFileAttributesW(path_str) + if original_attrs in (0xFFFFFFFF, ctypes.c_uint32(-1).value): + self.skipTest('GetFileAttributesW failed') + hidden_attrs = original_attrs | 0x2 # FILE_ATTRIBUTE_HIDDEN + if not SetFileAttributesW(path_str, hidden_attrs): + self.skipTest('SetFileAttributesW failed') + self.addCleanup(SetFileAttributesW, path_str, original_attrs) + + def raise_access_denied(*args, **kwargs): + exc = OSError(errno.EACCES, 'Access denied') + exc.winerror = _winapi.ERROR_ACCESS_DENIED + raise exc + + with mock.patch('pathlib.copyfile2', side_effect=raise_access_denied) as mock_copy: + result = source.copy(target) + + self.assertEqual(result, target) + self.assertTrue(self.target_ground.isfile(result)) + self.assertEqual(self.source_ground.readbytes(source), + self.target_ground.readbytes(result)) + self.assertEqual(mock_copy.call_count, 1) + + @unittest.skipUnless(os.name == 'nt', 'needs Windows for CopyFile2 fallback') + def test_copy_file_fallback_on_privilege_not_held(self): + import _winapi + import pathlib + + if pathlib.copyfile2 is None: + self.skipTest('copyfile2 unavailable') + + source = self.source_root / 'fileA' + target = self.target_root / 'copy_privilege' + + def raise_privilege_not_held(*args, **kwargs): + exc = OSError(errno.EPERM, 'Privilege not held') + exc.winerror = _winapi.ERROR_PRIVILEGE_NOT_HELD + raise exc + + with mock.patch('pathlib.copyfile2', side_effect=raise_privilege_not_held) as mock_copy: + result = source.copy(target) + + self.assertEqual(result, target) + self.assertTrue(self.target_ground.isfile(result)) + self.assertEqual(self.source_ground.readbytes(source), + self.target_ground.readbytes(result)) + self.assertEqual(mock_copy.call_count, 1) + if __name__ == "__main__": unittest.main() diff --git a/Misc/NEWS.d/next/Library/2025-09-18-16-31-45.gh-issue-139135.hL3fZ2.rst b/Misc/NEWS.d/next/Library/2025-09-18-16-31-45.gh-issue-139135.hL3fZ2.rst new file mode 100644 index 00000000000000..b4ff9d1f5f34fa --- /dev/null +++ b/Misc/NEWS.d/next/Library/2025-09-18-16-31-45.gh-issue-139135.hL3fZ2.rst @@ -0,0 +1 @@ +Fix :meth:`pathlib.Path.copy` failing on Windows when copying files that require elevated privileges (e.g., hidden or system files) by adding a fallback mechanism when ``CopyFile2()`` encounters privilege-related errors.