gh-81441: shutil.rmtree() FileNotFoundError race condition (GH-14064)

Ignore missing files and directories while enumerating
directory entries in shutil.rmtree().

Co-authored-by: Serhiy Storchaka <storchaka@gmail.com>
This commit is contained in:
Jeffrey Kintscher 2023-12-05 01:33:51 -08:00 committed by GitHub
parent b31232ddf7
commit 268415bbb3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 97 additions and 11 deletions

View File

@ -343,6 +343,10 @@ Directory and files operations
.. versionchanged:: 3.12
Added the *onexc* parameter, deprecated *onerror*.
.. versionchanged:: 3.13
:func:`!rmtree` now ignores :exc:`FileNotFoundError` exceptions for all
but the top-level path.
.. attribute:: rmtree.avoids_symlink_attacks
Indicates whether the current platform and implementation provides a

View File

@ -590,23 +590,21 @@ def copytree(src, dst, symlinks=False, ignore=None, copy_function=copy2,
dirs_exist_ok=dirs_exist_ok)
if hasattr(os.stat_result, 'st_file_attributes'):
def _rmtree_islink(path):
try:
st = os.lstat(path)
return (stat.S_ISLNK(st.st_mode) or
(st.st_file_attributes & stat.FILE_ATTRIBUTE_REPARSE_POINT
and st.st_reparse_tag == stat.IO_REPARSE_TAG_MOUNT_POINT))
except OSError:
return False
def _rmtree_islink(st):
return (stat.S_ISLNK(st.st_mode) or
(st.st_file_attributes & stat.FILE_ATTRIBUTE_REPARSE_POINT
and st.st_reparse_tag == stat.IO_REPARSE_TAG_MOUNT_POINT))
else:
def _rmtree_islink(path):
return os.path.islink(path)
def _rmtree_islink(st):
return stat.S_ISLNK(st.st_mode)
# version vulnerable to race conditions
def _rmtree_unsafe(path, onexc):
try:
with os.scandir(path) as scandir_it:
entries = list(scandir_it)
except FileNotFoundError:
return
except OSError as err:
onexc(os.scandir, path, err)
entries = []
@ -614,6 +612,8 @@ def _rmtree_unsafe(path, onexc):
fullname = entry.path
try:
is_dir = entry.is_dir(follow_symlinks=False)
except FileNotFoundError:
continue
except OSError:
is_dir = False
@ -624,6 +624,8 @@ def _rmtree_unsafe(path, onexc):
# a directory with a symlink after the call to
# os.scandir or entry.is_dir above.
raise OSError("Cannot call rmtree on a symbolic link")
except FileNotFoundError:
continue
except OSError as err:
onexc(os.path.islink, fullname, err)
continue
@ -631,10 +633,14 @@ def _rmtree_unsafe(path, onexc):
else:
try:
os.unlink(fullname)
except FileNotFoundError:
continue
except OSError as err:
onexc(os.unlink, fullname, err)
try:
os.rmdir(path)
except FileNotFoundError:
pass
except OSError as err:
onexc(os.rmdir, path, err)
@ -643,6 +649,8 @@ def _rmtree_safe_fd(topfd, path, onexc):
try:
with os.scandir(topfd) as scandir_it:
entries = list(scandir_it)
except FileNotFoundError:
return
except OSError as err:
err.filename = path
onexc(os.scandir, path, err)
@ -651,6 +659,8 @@ def _rmtree_safe_fd(topfd, path, onexc):
fullname = os.path.join(path, entry.name)
try:
is_dir = entry.is_dir(follow_symlinks=False)
except FileNotFoundError:
continue
except OSError:
is_dir = False
else:
@ -658,6 +668,8 @@ def _rmtree_safe_fd(topfd, path, onexc):
try:
orig_st = entry.stat(follow_symlinks=False)
is_dir = stat.S_ISDIR(orig_st.st_mode)
except FileNotFoundError:
continue
except OSError as err:
onexc(os.lstat, fullname, err)
continue
@ -665,6 +677,8 @@ def _rmtree_safe_fd(topfd, path, onexc):
try:
dirfd = os.open(entry.name, os.O_RDONLY, dir_fd=topfd)
dirfd_closed = False
except FileNotFoundError:
continue
except OSError as err:
onexc(os.open, fullname, err)
else:
@ -675,6 +689,8 @@ def _rmtree_safe_fd(topfd, path, onexc):
os.close(dirfd)
dirfd_closed = True
os.rmdir(entry.name, dir_fd=topfd)
except FileNotFoundError:
continue
except OSError as err:
onexc(os.rmdir, fullname, err)
else:
@ -692,6 +708,8 @@ def _rmtree_safe_fd(topfd, path, onexc):
else:
try:
os.unlink(entry.name, dir_fd=topfd)
except FileNotFoundError:
continue
except OSError as err:
onexc(os.unlink, fullname, err)
@ -781,7 +799,12 @@ def rmtree(path, ignore_errors=False, onerror=None, *, onexc=None, dir_fd=None):
if dir_fd is not None:
raise NotImplementedError("dir_fd unavailable on this platform")
try:
if _rmtree_islink(path):
st = os.lstat(path)
except OSError as err:
onexc(os.lstat, path, err)
return
try:
if _rmtree_islink(st):
# symlinks to directories are forbidden, see bug #1669
raise OSError("Cannot call rmtree on a symbolic link")
except OSError as err:

View File

@ -633,6 +633,63 @@ class TestRmTree(BaseTest, unittest.TestCase):
finally:
shutil.rmtree(TESTFN, ignore_errors=True)
@unittest.skipIf(sys.platform[:6] == 'cygwin',
"This test can't be run on Cygwin (issue #1071513).")
@os_helper.skip_if_dac_override
@os_helper.skip_unless_working_chmod
def test_rmtree_deleted_race_condition(self):
# bpo-37260
#
# Test that a file or a directory deleted after it is enumerated
# by scandir() but before unlink() or rmdr() is called doesn't
# generate any errors.
def _onexc(fn, path, exc):
assert fn in (os.rmdir, os.unlink)
if not isinstance(exc, PermissionError):
raise
# Make the parent and the children writeable.
for p, mode in zip(paths, old_modes):
os.chmod(p, mode)
# Remove other dirs except one.
keep = next(p for p in dirs if p != path)
for p in dirs:
if p != keep:
os.rmdir(p)
# Remove other files except one.
keep = next(p for p in files if p != path)
for p in files:
if p != keep:
os.unlink(p)
os.mkdir(TESTFN)
paths = [TESTFN] + [os.path.join(TESTFN, f'child{i}')
for i in range(6)]
dirs = paths[1::2]
files = paths[2::2]
for path in dirs:
os.mkdir(path)
for path in files:
write_file(path, '')
old_modes = [os.stat(path).st_mode for path in paths]
# Make the parent and the children non-writeable.
new_mode = stat.S_IREAD|stat.S_IEXEC
for path in reversed(paths):
os.chmod(path, new_mode)
try:
shutil.rmtree(TESTFN, onexc=_onexc)
except:
# Test failed, so cleanup artifacts.
for path, mode in zip(paths, old_modes):
try:
os.chmod(path, mode)
except OSError:
pass
shutil.rmtree(TESTFN)
raise
class TestCopyTree(BaseTest, unittest.TestCase):

View File

@ -0,0 +1,2 @@
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.