From d7a47e842c1b9b6f5da989e8f596ab50627373c7 Mon Sep 17 00:00:00 2001 From: chris Date: Fri, 19 Sep 2025 00:24:52 +0800 Subject: [PATCH 1/3] gh-139134: fix pathlib.Path.copy() Windows privilege errors with copyfile2 fallback (GH-139134) --- Lib/pathlib/__init__.py | 17 ++++- Lib/test/test_pathlib/test_copy.py | 71 +++++++++++++++++++ ...-09-18-16-31-45.gh-issue-139135.hL3fZ2.rst | 1 + 3 files changed, 87 insertions(+), 2 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2025-09-18-16-31-45.gh-issue-139135.hL3fZ2.rst diff --git a/Lib/pathlib/__init__.py b/Lib/pathlib/__init__.py index bc39a30c6538ce..38c1fe4f9c956d 100644 --- a/Lib/pathlib/__init__.py +++ b/Lib/pathlib/__init__.py @@ -25,6 +25,10 @@ import grp except ImportError: grp = None +try: + import _winapi +except ImportError: + _winapi = None from pathlib._os import ( PathInfo, DirEntryInfo, @@ -1140,11 +1144,20 @@ 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_fspath = os.fspath(source) except TypeError: pass else: - copyfile2(source, str(self)) + try: + copyfile2(source_fspath, str(self)) + except OSError as exc: + winerror = getattr(exc, 'winerror', None) + if (_winapi is not None and + winerror in (_winapi.ERROR_PRIVILEGE_NOT_HELD, + _winapi.ERROR_ACCESS_DENIED)): + self._copy_from_file_fallback(source, preserve_metadata) + return + raise return self._copy_from_file_fallback(source, preserve_metadata) 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..0584022f0c401e --- /dev/null +++ b/Misc/NEWS.d/next/Library/2025-09-18-16-31-45.gh-issue-139135.hL3fZ2.rst @@ -0,0 +1 @@ +Fix ``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. From 234edc12a45f31d3f261e943f49367a7b6350a40 Mon Sep 17 00:00:00 2001 From: chris Date: Fri, 19 Sep 2025 21:55:36 +0800 Subject: [PATCH 2/3] fixed as suggestions --- Lib/pathlib/__init__.py | 24 ++++++++++-------------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/Lib/pathlib/__init__.py b/Lib/pathlib/__init__.py index 38c1fe4f9c956d..fb74913dac406c 100644 --- a/Lib/pathlib/__init__.py +++ b/Lib/pathlib/__init__.py @@ -1144,22 +1144,18 @@ 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_fspath = os.fspath(source) + source_path = os.fspath(source) except TypeError: - pass - else: - try: - copyfile2(source_fspath, str(self)) - except OSError as exc: - winerror = getattr(exc, 'winerror', None) - if (_winapi is not None and - winerror in (_winapi.ERROR_PRIVILEGE_NOT_HELD, - _winapi.ERROR_ACCESS_DENIED)): - self._copy_from_file_fallback(source, preserve_metadata) - return - raise + 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: + if hasattr(exc, "winerror") and 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 From 762f4c7a12d32767e4cfddeedaf612bd62e8bcb0 Mon Sep 17 00:00:00 2001 From: chris Date: Sun, 21 Sep 2025 04:22:57 +0800 Subject: [PATCH 3/3] fixed as suggestions --- Lib/pathlib/__init__.py | 7 ++----- .../Library/2025-09-18-16-31-45.gh-issue-139135.hL3fZ2.rst | 2 +- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/Lib/pathlib/__init__.py b/Lib/pathlib/__init__.py index fb74913dac406c..6db0d693e5a4b9 100644 --- a/Lib/pathlib/__init__.py +++ b/Lib/pathlib/__init__.py @@ -25,10 +25,6 @@ import grp except ImportError: grp = None -try: - import _winapi -except ImportError: - _winapi = None from pathlib._os import ( PathInfo, DirEntryInfo, @@ -1151,7 +1147,8 @@ def _copy_from_file(self, source, preserve_metadata=False): try: copyfile2(source_path, str(self)) except OSError as exc: - if hasattr(exc, "winerror") and exc.winerror in (5, 1314): + # 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 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 index 0584022f0c401e..b4ff9d1f5f34fa 100644 --- 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 @@ -1 +1 @@ -Fix ``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. +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.