Skip to content

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

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 21 commits into from
Dec 5, 2023
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
03d4a2d
bpo-37260: ignore missing files and directories while enumerating
websurfer5 Jun 13, 2019
2fd3869
Merge branch 'master' of github.com:python/cpython into fix-issue-37260
websurfer5 Jun 13, 2019
e7b5cc5
bpo-37260: use os.path to build paths in tests; abort shutil.rmtree()
websurfer5 Jun 14, 2019
1d960dc
Merge branch 'fix-issue-37260' of github.com:websurfer5/cpython into …
websurfer5 Jun 14, 2019
b56f2a4
Merge branch 'master' of github.com:python/cpython into fix-issue-37260
websurfer5 Jun 14, 2019
39d04de
bpo-37260: fix whitespace issues
websurfer5 Jun 14, 2019
8268448
bpo-37260: fix whitespace issues
websurfer5 Jun 14, 2019
5a9da9c
📜🤖 Added by blurb_it.
blurb-it[bot] Jun 14, 2019
e1e386b
Merge branch 'fix-issue-37260' of github.com:websurfer5/cpython into …
websurfer5 Jun 14, 2019
13b31e1
Merge branch 'master' of github.com:python/cpython into fix-issue-37260
websurfer5 Jun 14, 2019
905f649
bpo-37260: continue, not pass, within loops
websurfer5 Jun 15, 2019
d6789d7
Update Lib/shutil.py
websurfer5 Jun 25, 2019
561047c
bpo-37260: return immediately if path is missing instead of falling
websurfer5 Jun 25, 2019
bb6d000
Merge branch 'main' into fix-issue-37260
serhiy-storchaka Dec 1, 2023
a9273a8
Fix tests.
serhiy-storchaka Dec 1, 2023
d3fbad7
Update the NEWS entry.
serhiy-storchaka Dec 2, 2023
9b0eb77
Use onexc instead of onerror in tests.
serhiy-storchaka Dec 2, 2023
05c626f
Merge exists() and islink() checks.
serhiy-storchaka Dec 3, 2023
dac6e39
Rewrite tests.
serhiy-storchaka Dec 4, 2023
45c73dc
Merge branch 'main' into fix-issue-37260
serhiy-storchaka Dec 4, 2023
dd731da
Documentation.
serhiy-storchaka Dec 5, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions Lib/shutil.py
Original file line number Diff line number Diff line change
Expand Up @@ -541,13 +541,17 @@ def _rmtree_unsafe(path, onerror):
try:
with os.scandir(path) as scandir_it:
entries = list(scandir_it)
except FileNotFoundError:
entries = []
except OSError:
onerror(os.scandir, path, sys.exc_info())
entries = []
for entry in entries:
fullname = entry.path
try:
is_dir = entry.is_dir(follow_symlinks=False)
except FileNotFoundError:
continue
except OSError:
is_dir = False
if is_dir:
Expand All @@ -557,17 +561,23 @@ def _rmtree_unsafe(path, onerror):
# 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:
onerror(os.path.islink, fullname, sys.exc_info())
continue
_rmtree_unsafe(fullname, onerror)
else:
try:
os.unlink(fullname)
except FileNotFoundError:
continue
except OSError:
onerror(os.unlink, fullname, sys.exc_info())
try:
os.rmdir(path)
except FileNotFoundError:
pass
except OSError:
onerror(os.rmdir, path, sys.exc_info())

Expand All @@ -576,6 +586,8 @@ def _rmtree_safe_fd(topfd, path, onerror):
try:
with os.scandir(topfd) as scandir_it:
entries = list(scandir_it)
except FileNotFoundError:
return
except OSError as err:
err.filename = path
onerror(os.scandir, path, sys.exc_info())
Expand All @@ -584,19 +596,25 @@ def _rmtree_safe_fd(topfd, path, onerror):
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:
if is_dir:
try:
orig_st = entry.stat(follow_symlinks=False)
is_dir = stat.S_ISDIR(orig_st.st_mode)
except FileNotFoundError:
continue
except OSError:
onerror(os.lstat, fullname, sys.exc_info())
continue
if is_dir:
try:
dirfd = os.open(entry.name, os.O_RDONLY, dir_fd=topfd)
except FileNotFoundError:
continue
except OSError:
onerror(os.open, fullname, sys.exc_info())
else:
Expand All @@ -605,6 +623,8 @@ def _rmtree_safe_fd(topfd, path, onerror):
_rmtree_safe_fd(dirfd, fullname, onerror)
try:
os.rmdir(entry.name, dir_fd=topfd)
except FileNotFoundError:
continue
except OSError:
onerror(os.rmdir, fullname, sys.exc_info())
else:
Expand All @@ -621,6 +641,8 @@ def _rmtree_safe_fd(topfd, path, onerror):
else:
try:
os.unlink(entry.name, dir_fd=topfd)
except FileNotFoundError:
continue
except OSError:
onerror(os.unlink, fullname, sys.exc_info())

Expand All @@ -646,6 +668,12 @@ def onerror(*args):
elif onerror is None:
def onerror(*args):
raise
try:
if not os.path.exists(path):
raise FileNotFoundError("Cannot call rmtree on a non-existent path")
except:
onerror(os.path.exists, path, sys.exc_info())
return
if _use_fd_functions:
# While the unsafe rmtree works fine on bytes, the fd based does not.
if isinstance(path, bytes):
Expand Down
103 changes: 103 additions & 0 deletions Lib/test/test_shutil.py
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,109 @@ def raiser(fn, *args, **kwargs):
finally:
os.lstat = orig_lstat

def test_rmtree_deleted_file_race_condition(self):
# bpo-37260
#
# Test that a file deleted after it is enumerated
# by scandir() but before unlink() is called
# doesn't generate any errors.
def _onerror(fn, path, exc_info):
if path == paths[0]:
os.chmod(paths[0], mode)
os.rmdir(paths[0])
os.unlink(paths[1])
pass
else:
raise

paths = [os.path.join(TESTFN, 'foo'),
os.path.join(TESTFN, 'bar')]
os.mkdir(TESTFN)
os.mkdir(paths[0])
write_file((TESTFN, 'bar'), 'bar')
mode = os.stat(paths[0]).st_mode
os.chmod(paths[0], 0)

try:
shutil.rmtree(TESTFN, onerror=_onerror)
except:
# test failed, so cleanup artifacts
try:
os.chmod(paths[0], mode)
except:
pass

shutil.rmtree(TESTFN)

def test_rmtree_deleted_dir_race_condition(self):
# bpo-37260
#
# Test that a directory deleted after it is enumerated
# by scandir() but before rmdr() is called
# doesn't generate any errors.
def _onerror(fn, path, exc_info):
if path == paths[0]:
os.chmod(paths[0], mode)
os.rmdir(paths[0])
os.rmdir(paths[1])
pass
else:
raise

paths = [os.path.join(TESTFN, 'foo'),
os.path.join(TESTFN, 'bar')]
os.mkdir(TESTFN)
os.mkdir(paths[0])
os.mkdir(paths[1])
mode = os.stat(paths[0]).st_mode
os.chmod(paths[0], 0)

try:
shutil.rmtree(TESTFN, onerror=_onerror)
except:
# test failed, so cleanup artifacts
try:
os.chmod(paths[0], mode)
except:
pass

shutil.rmtree(TESTFN)

@support.skip_unless_symlink
def test_rmtree_deleted_symlink_race_condition(self):
# bpo-37260
#
# Test that a symlink deleted after it is enumerated
# by scandir() but before unlink() is called
# doesn't generate any errors.
def _onerror(fn, path, exc_info):
if path == paths[0]:
os.chmod(paths[0], mode)
os.rmdir(paths[0])
os.unlink(paths[1])
pass
else:
raise

paths = [os.path.join(TESTFN, 'foo'),
os.path.join(TESTFN, 'bar')]
os.mkdir(TESTFN)
os.mkdir(paths[0])
os.symlink('foo', paths[1])
mode = os.stat(paths[0]).st_mode
os.chmod(paths[0], 0)

try:
shutil.rmtree(TESTFN, onerror=_onerror)
except:
# test failed, so cleanup artifacts
try:
os.chmod(paths[0], mode)
except:
pass

shutil.rmtree(TESTFN)

@support.skip_unless_symlink
def test_copymode_follow_symlinks(self):
tmp_dir = self.mkdtemp()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fixed a race condition in shutil.rmtree() in which directory entries removed by another process or thread while shutil.rmtree() is running can cause shutil.rmtree() to raise FileNotFoundError. Patch by Jeffrey Kintscher.