Skip to content

Commit f6144ef

Browse files
Preston Mooreabadger
authored andcommitted
bpo-30400: Fix race condtion in shutil.copyfile
1 parent 7ae4749 commit f6144ef

3 files changed

Lines changed: 130 additions & 37 deletions

File tree

Lib/shutil.py

Lines changed: 112 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
"""
66

77
import os
8+
import fcntl
89
import sys
910
import stat
1011
import fnmatch
@@ -280,6 +281,11 @@ def _stat(fn):
280281
def _islink(fn):
281282
return fn.is_symlink() if isinstance(fn, os.DirEntry) else os.path.islink(fn)
282283

284+
def _samefilef(fsrc, fdst):
285+
if os.path.abspath(fsrc.name) == os.path.abspath(fdst.name):
286+
return True
287+
return False
288+
283289
def copyfile(src, dst, *, follow_symlinks=True):
284290
"""Copy data from src to dst in the most efficient way possible.
285291
@@ -307,48 +313,121 @@ def copyfile(src, dst, *, follow_symlinks=True):
307313
if _WINDOWS and i == 0:
308314
file_size = st.st_size
309315

316+
# XXX Is there a race issue here with os.path.islink() operating on a
317+
# path rather than a file descriptor
310318
if not follow_symlinks and _islink(src):
311319
os.symlink(os.readlink(src), dst)
320+
return dst
321+
322+
# In Unix, we must open the files non-blocking initially as opening a
323+
# FIFO without data present in it will block.
324+
if os.name == 'posix':
325+
src_flags = os.O_RDONLY | os.O_NONBLOCK
326+
### Do we need os.O_EXCL?
327+
dst_flags = os.O_CREAT | os.O_WRONLY | os.O_NONBLOCK
312328
else:
313-
with open(src, 'rb') as fsrc:
329+
src_flags = os.O_RDONLY
330+
dst_flags = os.O_WRONLY
331+
332+
def _src_opener(path, flags):
333+
return os.open(path, flags)
334+
335+
# dst's opener is more complex. We need to atomically check and open the
336+
# destination if it doesn't exist. If it does exist, we need to detect so
337+
# we don't destroy it later should we have to fail out for some reason.
338+
dst_was_created = False
339+
def _dst_opener(path, flags):
340+
nonlocal dst_was_created
341+
try:
342+
dst_was_created = True
343+
return os.open(path, flags | dst_flags | os.O_EXCL)
344+
except FileExistsError:
345+
dst_was_created = False
314346
try:
315-
with open(dst, 'wb') as fdst:
316-
# macOS
317-
if _HAS_FCOPYFILE:
347+
# Open the already existing file in as non-blocking
348+
### We have O_CREATE and O_NONBLOCK in dst_flags. Is that intended?
349+
fd = os.open(path, flags | dst_flags)
350+
# If the destination is already a FIFO for some reason, we'll get an
351+
# OSError here as we've opened a FIFO for a non-blocking write with
352+
# no reader on the other end.
353+
except OSError as e:
354+
# Use errno to decide whether the OSError we caught is due to
355+
# the above reason or some other reason. For the above reason,
356+
# raise the appropriate special file error. Otherwise, we need
357+
# to re-raise the OSError
358+
if e.errno == errno.ENXIO:
359+
raise SpecialFileError("`%s` is a named pipe" % path)
360+
else:
361+
raise e
362+
# This check is required to catch the case where the destination
363+
# file is a FIFO that's we successfully opened non-blocking because
364+
# for some reason there was a reader listening preventing an ENXIO
365+
# situation.
366+
st = os.fstat(fd)
367+
if stat.S_ISFIFO(st.st_mode):
368+
raise SpecialFileError("`%s` is a named pipe" % path)
369+
return fd
370+
371+
with open(src, 'rb', opener=_src_opener) as fsrc:
372+
try:
373+
with open(dst, 'wb', opener=_dst_opener) as fdst:
374+
file_size = 0
375+
# file.name is not populated when using os.fdopen()
376+
st = os.fstat(fsrc.fileno())
377+
if stat.S_ISFIFO(st.st_mode):
378+
# Don't unlink dst unless we created it above
379+
if dst_was_created:
380+
os.unlink(dst)
381+
raise SpecialFileError("`%s` is a named pipe" % src)
382+
if _WINDOWS and i == 0:
383+
file_size = st.st_size
384+
385+
# In Unix, we must set the file descriptors back to blocking as that's
386+
# what the rest of this function expects.
387+
# Additonally, fsrc, and fdst must be turned into file objects
388+
if os.name == 'posix':
389+
srcfl = fcntl.fcntl(fsrc.fileno(), fcntl.F_GETFL)
390+
dstfl = fcntl.fcntl(fdst.fileno(), fcntl.F_GETFL)
391+
392+
# Unset the non-blocking flag
393+
fcntl.fcntl(fsrc.fileno(), fcntl.F_SETFL, srcfl & ~os.O_NONBLOCK)
394+
fcntl.fcntl(fdst.fileno(), fcntl.F_SETFL, dstfl & ~os.O_NONBLOCK)
395+
396+
# macOS
397+
if _HAS_FCOPYFILE:
398+
try:
399+
_fastcopy_fcopyfile(fsrc, fdst, posix._COPYFILE_DATA)
400+
return dst
401+
except _GiveupOnFastCopy:
402+
pass
403+
# Linux / Android / Solaris
404+
elif _USE_CP_SENDFILE or _USE_CP_COPY_FILE_RANGE:
405+
# reflink may be implicit in copy_file_range.
406+
if _USE_CP_COPY_FILE_RANGE:
318407
try:
319-
_fastcopy_fcopyfile(fsrc, fdst, posix._COPYFILE_DATA)
408+
_fastcopy_copy_file_range(fsrc, fdst)
409+
return dst
410+
except _GiveupOnFastCopy:
411+
pass
412+
if _USE_CP_SENDFILE:
413+
try:
414+
_fastcopy_sendfile(fsrc, fdst)
320415
return dst
321416
except _GiveupOnFastCopy:
322417
pass
323-
# Linux / Android / Solaris
324-
elif _USE_CP_SENDFILE or _USE_CP_COPY_FILE_RANGE:
325-
# reflink may be implicit in copy_file_range.
326-
if _USE_CP_COPY_FILE_RANGE:
327-
try:
328-
_fastcopy_copy_file_range(fsrc, fdst)
329-
return dst
330-
except _GiveupOnFastCopy:
331-
pass
332-
if _USE_CP_SENDFILE:
333-
try:
334-
_fastcopy_sendfile(fsrc, fdst)
335-
return dst
336-
except _GiveupOnFastCopy:
337-
pass
338-
# Windows, see:
339-
# https://github.com/python/cpython/pull/7160#discussion_r195405230
340-
elif _WINDOWS and file_size > 0:
341-
_copyfileobj_readinto(fsrc, fdst, min(file_size, COPY_BUFSIZE))
342-
return dst
343-
344-
copyfileobj(fsrc, fdst)
345418

346-
# Issue 43219, raise a less confusing exception
347-
except IsADirectoryError as e:
348-
if not os.path.exists(dst):
349-
raise FileNotFoundError(f'Directory does not exist: {dst}') from e
350-
else:
351-
raise
419+
# Windows, see:
420+
# https://github.com/python/cpython/pull/7160#discussion_r195405230
421+
elif _WINDOWS and file_size > 0:
422+
_copyfileobj_readinto(fsrc, fdst, min(file_size, COPY_BUFSIZE))
423+
return dst
424+
copyfileobj(fsrc, fdst)
425+
426+
# Issue 43219, raise a less confusing exception
427+
except IsADirectoryError as e:
428+
if not os.path.exists(dst):
429+
raise FileNotFoundError(f'Directory does not exist: {dst}') from e
430+
raise
352431

353432
return dst
354433

Lib/test/test_shutil.py

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2306,6 +2306,18 @@ def check_chown(path, uid=None, gid=None):
23062306
with self.assertRaises(ValueError):
23072307
shutil.chown(filename, dir_fd=dirfd)
23082308

2309+
def test_copyfile_same_file(self):
2310+
# copyfile() should raise SameFileError if the source and destination
2311+
# are the same.
2312+
src_dir = self.mkdtemp()
2313+
src_file = os.path.join(src_dir, 'foo')
2314+
create_file(src_file, b'foo')
2315+
self.assertRaises(SameFileError, shutil.copyfile, src_file, src_file)
2316+
# But Error should work too, to stay backward compatible.
2317+
self.assertRaises(Error, shutil.copyfile, src_file, src_file)
2318+
# Make sure file is not corrupted.
2319+
self.assertEqual(read_file(src_file), 'foo')
2320+
23092321

23102322
@support.requires_subprocess()
23112323
class TestWhich(BaseTest, unittest.TestCase):
@@ -2997,7 +3009,7 @@ def __exit__(self, exc_type, exc_val, exc_tb):
29973009
return self._suppress_at_exit
29983010

29993011
def test_w_source_open_fails(self):
3000-
def _open(filename, mode='r'):
3012+
def _open(filename, mode='r', opener=None):
30013013
if filename == 'srcfile':
30023014
raise OSError('Cannot open "srcfile"')
30033015
assert 0 # shouldn't reach here.
@@ -3010,7 +3022,7 @@ def _open(filename, mode='r'):
30103022
def test_w_dest_open_fails(self):
30113023
srcfile = self.Faux()
30123024

3013-
def _open(filename, mode='r'):
3025+
def _open(filename, mode='r', opener=None):
30143026
if filename == 'srcfile':
30153027
return srcfile
30163028
if filename == 'destfile':
@@ -3029,7 +3041,7 @@ def test_w_dest_close_fails(self):
30293041
srcfile = self.Faux()
30303042
destfile = self.Faux(True)
30313043

3032-
def _open(filename, mode='r'):
3044+
def _open(filename, mode='r', opener=None):
30333045
if filename == 'srcfile':
30343046
return srcfile
30353047
if filename == 'destfile':
@@ -3051,7 +3063,7 @@ def test_w_source_close_fails(self):
30513063
srcfile = self.Faux(True)
30523064
destfile = self.Faux()
30533065

3054-
def _open(filename, mode='r'):
3066+
def _open(filename, mode='r', opener=None):
30553067
if filename == 'srcfile':
30563068
return srcfile
30573069
if filename == 'destfile':
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Fix race condition in shutil.copyfile() by ensuring the inode of the source
2+
file does not change while it is being copied. Patch by Preston Moore.

0 commit comments

Comments
 (0)