Skip to content

Commit c4ee4e7

Browse files
authored
GH-122890: Fix low-level error handling in pathlib.Path.copy() (#122897)
Give unique names to our low-level FD copying functions, and try each one in turn. Handle errors appropriately for each implementation: - `fcntl.FICLONE`: suppress `EBADF`, `EOPNOTSUPP`, `ETXTBSY`, `EXDEV` - `posix._fcopyfile`: suppress `EBADF`, `ENOTSUP` - `os.copy_file_range`: suppress `ETXTBSY`, `EXDEV` - `os.sendfile`: suppress `ENOTSOCK`
1 parent 127660b commit c4ee4e7

File tree

2 files changed

+90
-16
lines changed

2 files changed

+90
-16
lines changed

Lib/pathlib/_os.py

Lines changed: 42 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
_winapi = None
2121

2222

23-
def get_copy_blocksize(infd):
23+
def _get_copy_blocksize(infd):
2424
"""Determine blocksize for fastcopying on Linux.
2525
Hopefully the whole file will be copied in a single call.
2626
The copying itself should be performed in a loop 'till EOF is
@@ -40,56 +40,64 @@ def get_copy_blocksize(infd):
4040

4141

4242
if fcntl and hasattr(fcntl, 'FICLONE'):
43-
def clonefd(source_fd, target_fd):
43+
def _ficlone(source_fd, target_fd):
4444
"""
4545
Perform a lightweight copy of two files, where the data blocks are
4646
copied only when modified. This is known as Copy on Write (CoW),
4747
instantaneous copy or reflink.
4848
"""
4949
fcntl.ioctl(target_fd, fcntl.FICLONE, source_fd)
5050
else:
51-
clonefd = None
51+
_ficlone = None
5252

5353

5454
if posix and hasattr(posix, '_fcopyfile'):
55-
def copyfd(source_fd, target_fd):
55+
def _fcopyfile(source_fd, target_fd):
5656
"""
5757
Copy a regular file content using high-performance fcopyfile(3)
5858
syscall (macOS).
5959
"""
6060
posix._fcopyfile(source_fd, target_fd, posix._COPYFILE_DATA)
61-
elif hasattr(os, 'copy_file_range'):
62-
def copyfd(source_fd, target_fd):
61+
else:
62+
_fcopyfile = None
63+
64+
65+
if hasattr(os, 'copy_file_range'):
66+
def _copy_file_range(source_fd, target_fd):
6367
"""
6468
Copy data from one regular mmap-like fd to another by using a
6569
high-performance copy_file_range(2) syscall that gives filesystems
6670
an opportunity to implement the use of reflinks or server-side
6771
copy.
6872
This should work on Linux >= 4.5 only.
6973
"""
70-
blocksize = get_copy_blocksize(source_fd)
74+
blocksize = _get_copy_blocksize(source_fd)
7175
offset = 0
7276
while True:
7377
sent = os.copy_file_range(source_fd, target_fd, blocksize,
7478
offset_dst=offset)
7579
if sent == 0:
7680
break # EOF
7781
offset += sent
78-
elif hasattr(os, 'sendfile'):
79-
def copyfd(source_fd, target_fd):
82+
else:
83+
_copy_file_range = None
84+
85+
86+
if hasattr(os, 'sendfile'):
87+
def _sendfile(source_fd, target_fd):
8088
"""Copy data from one regular mmap-like fd to another by using
8189
high-performance sendfile(2) syscall.
8290
This should work on Linux >= 2.6.33 only.
8391
"""
84-
blocksize = get_copy_blocksize(source_fd)
92+
blocksize = _get_copy_blocksize(source_fd)
8593
offset = 0
8694
while True:
8795
sent = os.sendfile(target_fd, source_fd, offset, blocksize)
8896
if sent == 0:
8997
break # EOF
9098
offset += sent
9199
else:
92-
copyfd = None
100+
_sendfile = None
93101

94102

95103
if _winapi and hasattr(_winapi, 'CopyFile2'):
@@ -114,18 +122,36 @@ def copyfileobj(source_f, target_f):
114122
else:
115123
try:
116124
# Use OS copy-on-write where available.
117-
if clonefd:
125+
if _ficlone:
118126
try:
119-
clonefd(source_fd, target_fd)
127+
_ficlone(source_fd, target_fd)
120128
return
121129
except OSError as err:
122130
if err.errno not in (EBADF, EOPNOTSUPP, ETXTBSY, EXDEV):
123131
raise err
124132

125133
# Use OS copy where available.
126-
if copyfd:
127-
copyfd(source_fd, target_fd)
128-
return
134+
if _fcopyfile:
135+
try:
136+
_fcopyfile(source_fd, target_fd)
137+
return
138+
except OSError as err:
139+
if err.errno not in (EINVAL, ENOTSUP):
140+
raise err
141+
if _copy_file_range:
142+
try:
143+
_copy_file_range(source_fd, target_fd)
144+
return
145+
except OSError as err:
146+
if err.errno not in (ETXTBSY, EXDEV):
147+
raise err
148+
if _sendfile:
149+
try:
150+
_sendfile(source_fd, target_fd)
151+
return
152+
except OSError as err:
153+
if err.errno != ENOTSOCK:
154+
raise err
129155
except OSError as err:
130156
# Produce more useful error messages.
131157
err.filename = source_f.name

Lib/test/test_pathlib/test_pathlib.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import contextlib
12
import io
23
import os
34
import sys
@@ -22,10 +23,18 @@
2223
from test.test_pathlib import test_pathlib_abc
2324
from test.test_pathlib.test_pathlib_abc import needs_posix, needs_windows, needs_symlinks
2425

26+
try:
27+
import fcntl
28+
except ImportError:
29+
fcntl = None
2530
try:
2631
import grp, pwd
2732
except ImportError:
2833
grp = pwd = None
34+
try:
35+
import posix
36+
except ImportError:
37+
posix = None
2938

3039

3140
root_in_posix = False
@@ -707,6 +716,45 @@ def test_copy_link_preserve_metadata(self):
707716
if hasattr(source_st, 'st_flags'):
708717
self.assertEqual(source_st.st_flags, target_st.st_flags)
709718

719+
def test_copy_error_handling(self):
720+
def make_raiser(err):
721+
def raiser(*args, **kwargs):
722+
raise OSError(err, os.strerror(err))
723+
return raiser
724+
725+
base = self.cls(self.base)
726+
source = base / 'fileA'
727+
target = base / 'copyA'
728+
729+
# Raise non-fatal OSError from all available fast copy functions.
730+
with contextlib.ExitStack() as ctx:
731+
if fcntl and hasattr(fcntl, 'FICLONE'):
732+
ctx.enter_context(mock.patch('fcntl.ioctl', make_raiser(errno.EXDEV)))
733+
if posix and hasattr(posix, '_fcopyfile'):
734+
ctx.enter_context(mock.patch('posix._fcopyfile', make_raiser(errno.ENOTSUP)))
735+
if hasattr(os, 'copy_file_range'):
736+
ctx.enter_context(mock.patch('os.copy_file_range', make_raiser(errno.EXDEV)))
737+
if hasattr(os, 'sendfile'):
738+
ctx.enter_context(mock.patch('os.sendfile', make_raiser(errno.ENOTSOCK)))
739+
740+
source.copy(target)
741+
self.assertTrue(target.exists())
742+
self.assertEqual(source.read_text(), target.read_text())
743+
744+
# Raise fatal OSError from first available fast copy function.
745+
if fcntl and hasattr(fcntl, 'FICLONE'):
746+
patchpoint = 'fcntl.ioctl'
747+
elif posix and hasattr(posix, '_fcopyfile'):
748+
patchpoint = 'posix._fcopyfile'
749+
elif hasattr(os, 'copy_file_range'):
750+
patchpoint = 'os.copy_file_range'
751+
elif hasattr(os, 'sendfile'):
752+
patchpoint = 'os.sendfile'
753+
else:
754+
return
755+
with mock.patch(patchpoint, make_raiser(errno.ENOENT)):
756+
self.assertRaises(FileNotFoundError, source.copy, target)
757+
710758
@unittest.skipIf(sys.platform == "win32" or sys.platform == "wasi", "directories are always readable on Windows and WASI")
711759
@unittest.skipIf(root_in_posix, "test fails with root privilege")
712760
def test_copy_dir_no_read_permission(self):

0 commit comments

Comments
 (0)