Skip to content

Commit 268415b

Browse files
pythongh-81441: shutil.rmtree() FileNotFoundError race condition (pythonGH-14064)
Ignore missing files and directories while enumerating directory entries in shutil.rmtree(). Co-authored-by: Serhiy Storchaka <[email protected]>
1 parent b31232d commit 268415b

File tree

4 files changed

+97
-11
lines changed

4 files changed

+97
-11
lines changed

Doc/library/shutil.rst

+4
Original file line numberDiff line numberDiff line change
@@ -343,6 +343,10 @@ Directory and files operations
343343
.. versionchanged:: 3.12
344344
Added the *onexc* parameter, deprecated *onerror*.
345345

346+
.. versionchanged:: 3.13
347+
:func:`!rmtree` now ignores :exc:`FileNotFoundError` exceptions for all
348+
but the top-level path.
349+
346350
.. attribute:: rmtree.avoids_symlink_attacks
347351

348352
Indicates whether the current platform and implementation provides a

Lib/shutil.py

+34-11
Original file line numberDiff line numberDiff line change
@@ -590,30 +590,30 @@ def copytree(src, dst, symlinks=False, ignore=None, copy_function=copy2,
590590
dirs_exist_ok=dirs_exist_ok)
591591

592592
if hasattr(os.stat_result, 'st_file_attributes'):
593-
def _rmtree_islink(path):
594-
try:
595-
st = os.lstat(path)
596-
return (stat.S_ISLNK(st.st_mode) or
597-
(st.st_file_attributes & stat.FILE_ATTRIBUTE_REPARSE_POINT
598-
and st.st_reparse_tag == stat.IO_REPARSE_TAG_MOUNT_POINT))
599-
except OSError:
600-
return False
593+
def _rmtree_islink(st):
594+
return (stat.S_ISLNK(st.st_mode) or
595+
(st.st_file_attributes & stat.FILE_ATTRIBUTE_REPARSE_POINT
596+
and st.st_reparse_tag == stat.IO_REPARSE_TAG_MOUNT_POINT))
601597
else:
602-
def _rmtree_islink(path):
603-
return os.path.islink(path)
598+
def _rmtree_islink(st):
599+
return stat.S_ISLNK(st.st_mode)
604600

605601
# version vulnerable to race conditions
606602
def _rmtree_unsafe(path, onexc):
607603
try:
608604
with os.scandir(path) as scandir_it:
609605
entries = list(scandir_it)
606+
except FileNotFoundError:
607+
return
610608
except OSError as err:
611609
onexc(os.scandir, path, err)
612610
entries = []
613611
for entry in entries:
614612
fullname = entry.path
615613
try:
616614
is_dir = entry.is_dir(follow_symlinks=False)
615+
except FileNotFoundError:
616+
continue
617617
except OSError:
618618
is_dir = False
619619

@@ -624,17 +624,23 @@ def _rmtree_unsafe(path, onexc):
624624
# a directory with a symlink after the call to
625625
# os.scandir or entry.is_dir above.
626626
raise OSError("Cannot call rmtree on a symbolic link")
627+
except FileNotFoundError:
628+
continue
627629
except OSError as err:
628630
onexc(os.path.islink, fullname, err)
629631
continue
630632
_rmtree_unsafe(fullname, onexc)
631633
else:
632634
try:
633635
os.unlink(fullname)
636+
except FileNotFoundError:
637+
continue
634638
except OSError as err:
635639
onexc(os.unlink, fullname, err)
636640
try:
637641
os.rmdir(path)
642+
except FileNotFoundError:
643+
pass
638644
except OSError as err:
639645
onexc(os.rmdir, path, err)
640646

@@ -643,6 +649,8 @@ def _rmtree_safe_fd(topfd, path, onexc):
643649
try:
644650
with os.scandir(topfd) as scandir_it:
645651
entries = list(scandir_it)
652+
except FileNotFoundError:
653+
return
646654
except OSError as err:
647655
err.filename = path
648656
onexc(os.scandir, path, err)
@@ -651,20 +659,26 @@ def _rmtree_safe_fd(topfd, path, onexc):
651659
fullname = os.path.join(path, entry.name)
652660
try:
653661
is_dir = entry.is_dir(follow_symlinks=False)
662+
except FileNotFoundError:
663+
continue
654664
except OSError:
655665
is_dir = False
656666
else:
657667
if is_dir:
658668
try:
659669
orig_st = entry.stat(follow_symlinks=False)
660670
is_dir = stat.S_ISDIR(orig_st.st_mode)
671+
except FileNotFoundError:
672+
continue
661673
except OSError as err:
662674
onexc(os.lstat, fullname, err)
663675
continue
664676
if is_dir:
665677
try:
666678
dirfd = os.open(entry.name, os.O_RDONLY, dir_fd=topfd)
667679
dirfd_closed = False
680+
except FileNotFoundError:
681+
continue
668682
except OSError as err:
669683
onexc(os.open, fullname, err)
670684
else:
@@ -675,6 +689,8 @@ def _rmtree_safe_fd(topfd, path, onexc):
675689
os.close(dirfd)
676690
dirfd_closed = True
677691
os.rmdir(entry.name, dir_fd=topfd)
692+
except FileNotFoundError:
693+
continue
678694
except OSError as err:
679695
onexc(os.rmdir, fullname, err)
680696
else:
@@ -692,6 +708,8 @@ def _rmtree_safe_fd(topfd, path, onexc):
692708
else:
693709
try:
694710
os.unlink(entry.name, dir_fd=topfd)
711+
except FileNotFoundError:
712+
continue
695713
except OSError as err:
696714
onexc(os.unlink, fullname, err)
697715

@@ -781,7 +799,12 @@ def onexc(*args):
781799
if dir_fd is not None:
782800
raise NotImplementedError("dir_fd unavailable on this platform")
783801
try:
784-
if _rmtree_islink(path):
802+
st = os.lstat(path)
803+
except OSError as err:
804+
onexc(os.lstat, path, err)
805+
return
806+
try:
807+
if _rmtree_islink(st):
785808
# symlinks to directories are forbidden, see bug #1669
786809
raise OSError("Cannot call rmtree on a symbolic link")
787810
except OSError as err:

Lib/test/test_shutil.py

+57
Original file line numberDiff line numberDiff line change
@@ -633,6 +633,63 @@ def test_rmtree_on_junction(self):
633633
finally:
634634
shutil.rmtree(TESTFN, ignore_errors=True)
635635

636+
@unittest.skipIf(sys.platform[:6] == 'cygwin',
637+
"This test can't be run on Cygwin (issue #1071513).")
638+
@os_helper.skip_if_dac_override
639+
@os_helper.skip_unless_working_chmod
640+
def test_rmtree_deleted_race_condition(self):
641+
# bpo-37260
642+
#
643+
# Test that a file or a directory deleted after it is enumerated
644+
# by scandir() but before unlink() or rmdr() is called doesn't
645+
# generate any errors.
646+
def _onexc(fn, path, exc):
647+
assert fn in (os.rmdir, os.unlink)
648+
if not isinstance(exc, PermissionError):
649+
raise
650+
# Make the parent and the children writeable.
651+
for p, mode in zip(paths, old_modes):
652+
os.chmod(p, mode)
653+
# Remove other dirs except one.
654+
keep = next(p for p in dirs if p != path)
655+
for p in dirs:
656+
if p != keep:
657+
os.rmdir(p)
658+
# Remove other files except one.
659+
keep = next(p for p in files if p != path)
660+
for p in files:
661+
if p != keep:
662+
os.unlink(p)
663+
664+
os.mkdir(TESTFN)
665+
paths = [TESTFN] + [os.path.join(TESTFN, f'child{i}')
666+
for i in range(6)]
667+
dirs = paths[1::2]
668+
files = paths[2::2]
669+
for path in dirs:
670+
os.mkdir(path)
671+
for path in files:
672+
write_file(path, '')
673+
674+
old_modes = [os.stat(path).st_mode for path in paths]
675+
676+
# Make the parent and the children non-writeable.
677+
new_mode = stat.S_IREAD|stat.S_IEXEC
678+
for path in reversed(paths):
679+
os.chmod(path, new_mode)
680+
681+
try:
682+
shutil.rmtree(TESTFN, onexc=_onexc)
683+
except:
684+
# Test failed, so cleanup artifacts.
685+
for path, mode in zip(paths, old_modes):
686+
try:
687+
os.chmod(path, mode)
688+
except OSError:
689+
pass
690+
shutil.rmtree(TESTFN)
691+
raise
692+
636693

637694
class TestCopyTree(BaseTest, unittest.TestCase):
638695

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Fixed a race condition in :func:`shutil.rmtree` in which directory entries removed by another process or thread while ``shutil.rmtree()`` is running can cause it to raise FileNotFoundError. Patch by Jeffrey Kintscher.
2+

0 commit comments

Comments
 (0)