Skip to content

Commit feb7870

Browse files
authored
[3.12] GH-89727: Fix shutil.rmtree() recursion error on deep trees (GH-119808) (#119919)
Implement `shutil._rmtree_safe_fd()` using a list as a stack to avoid emitting recursion errors on deeply nested trees. `shutil._rmtree_unsafe()` was fixed in a150679. (cherry picked from commit 53b1981)
1 parent 60393f5 commit feb7870

File tree

3 files changed

+64
-89
lines changed

3 files changed

+64
-89
lines changed

Lib/shutil.py

Lines changed: 62 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -639,69 +639,68 @@ def onerror(err):
639639
onexc(os.rmdir, path, err)
640640

641641
# Version using fd-based APIs to protect against races
642-
def _rmtree_safe_fd(topfd, path, onexc):
642+
def _rmtree_safe_fd(stack, onexc):
643+
# Each stack item has four elements:
644+
# * func: The first operation to perform: os.lstat, os.close or os.rmdir.
645+
# Walking a directory starts with an os.lstat() to detect symlinks; in
646+
# this case, func is updated before subsequent operations and passed to
647+
# onexc() if an error occurs.
648+
# * dirfd: Open file descriptor, or None if we're processing the top-level
649+
# directory given to rmtree() and the user didn't supply dir_fd.
650+
# * path: Path of file to operate upon. This is passed to onexc() if an
651+
# error occurs.
652+
# * orig_entry: os.DirEntry, or None if we're processing the top-level
653+
# directory given to rmtree(). We used the cached stat() of the entry to
654+
# save a call to os.lstat() when walking subdirectories.
655+
func, dirfd, path, orig_entry = stack.pop()
656+
name = path if orig_entry is None else orig_entry.name
643657
try:
658+
if func is os.close:
659+
os.close(dirfd)
660+
return
661+
if func is os.rmdir:
662+
os.rmdir(name, dir_fd=dirfd)
663+
return
664+
665+
# Note: To guard against symlink races, we use the standard
666+
# lstat()/open()/fstat() trick.
667+
assert func is os.lstat
668+
if orig_entry is None:
669+
orig_st = os.lstat(name, dir_fd=dirfd)
670+
else:
671+
orig_st = orig_entry.stat(follow_symlinks=False)
672+
673+
func = os.open # For error reporting.
674+
topfd = os.open(name, os.O_RDONLY | os.O_NONBLOCK, dir_fd=dirfd)
675+
676+
func = os.path.islink # For error reporting.
677+
try:
678+
if not os.path.samestat(orig_st, os.fstat(topfd)):
679+
# Symlinks to directories are forbidden, see GH-46010.
680+
raise OSError("Cannot call rmtree on a symbolic link")
681+
stack.append((os.rmdir, dirfd, path, orig_entry))
682+
finally:
683+
stack.append((os.close, topfd, path, orig_entry))
684+
685+
func = os.scandir # For error reporting.
644686
with os.scandir(topfd) as scandir_it:
645687
entries = list(scandir_it)
646-
except OSError as err:
647-
err.filename = path
648-
onexc(os.scandir, path, err)
649-
return
650-
for entry in entries:
651-
fullname = os.path.join(path, entry.name)
652-
try:
653-
is_dir = entry.is_dir(follow_symlinks=False)
654-
except OSError:
655-
is_dir = False
656-
else:
657-
if is_dir:
658-
try:
659-
orig_st = entry.stat(follow_symlinks=False)
660-
is_dir = stat.S_ISDIR(orig_st.st_mode)
661-
except OSError as err:
662-
onexc(os.lstat, fullname, err)
663-
continue
664-
if is_dir:
688+
for entry in entries:
689+
fullname = os.path.join(path, entry.name)
665690
try:
666-
dirfd = os.open(entry.name, os.O_RDONLY | os.O_NONBLOCK, dir_fd=topfd)
667-
dirfd_closed = False
668-
except OSError as err:
669-
onexc(os.open, fullname, err)
670-
else:
671-
try:
672-
if os.path.samestat(orig_st, os.fstat(dirfd)):
673-
_rmtree_safe_fd(dirfd, fullname, onexc)
674-
try:
675-
os.close(dirfd)
676-
except OSError as err:
677-
# close() should not be retried after an error.
678-
dirfd_closed = True
679-
onexc(os.close, fullname, err)
680-
dirfd_closed = True
681-
try:
682-
os.rmdir(entry.name, dir_fd=topfd)
683-
except OSError as err:
684-
onexc(os.rmdir, fullname, err)
685-
else:
686-
try:
687-
# This can only happen if someone replaces
688-
# a directory with a symlink after the call to
689-
# os.scandir or stat.S_ISDIR above.
690-
raise OSError("Cannot call rmtree on a symbolic "
691-
"link")
692-
except OSError as err:
693-
onexc(os.path.islink, fullname, err)
694-
finally:
695-
if not dirfd_closed:
696-
try:
697-
os.close(dirfd)
698-
except OSError as err:
699-
onexc(os.close, fullname, err)
700-
else:
691+
if entry.is_dir(follow_symlinks=False):
692+
# Traverse into sub-directory.
693+
stack.append((os.lstat, topfd, fullname, entry))
694+
continue
695+
except OSError:
696+
pass
701697
try:
702698
os.unlink(entry.name, dir_fd=topfd)
703699
except OSError as err:
704700
onexc(os.unlink, fullname, err)
701+
except OSError as err:
702+
err.filename = path
703+
onexc(func, path, err)
705704

706705
_use_fd_functions = ({os.open, os.stat, os.unlink, os.rmdir} <=
707706
os.supports_dir_fd and
@@ -754,41 +753,16 @@ def onexc(*args):
754753
# While the unsafe rmtree works fine on bytes, the fd based does not.
755754
if isinstance(path, bytes):
756755
path = os.fsdecode(path)
757-
# Note: To guard against symlink races, we use the standard
758-
# lstat()/open()/fstat() trick.
759-
try:
760-
orig_st = os.lstat(path, dir_fd=dir_fd)
761-
except Exception as err:
762-
onexc(os.lstat, path, err)
763-
return
756+
stack = [(os.lstat, dir_fd, path, None)]
764757
try:
765-
fd = os.open(path, os.O_RDONLY | os.O_NONBLOCK, dir_fd=dir_fd)
766-
fd_closed = False
767-
except Exception as err:
768-
onexc(os.open, path, err)
769-
return
770-
try:
771-
if os.path.samestat(orig_st, os.fstat(fd)):
772-
_rmtree_safe_fd(fd, path, onexc)
773-
try:
774-
os.close(fd)
775-
except OSError as err:
776-
# close() should not be retried after an error.
777-
fd_closed = True
778-
onexc(os.close, path, err)
779-
fd_closed = True
780-
try:
781-
os.rmdir(path, dir_fd=dir_fd)
782-
except OSError as err:
783-
onexc(os.rmdir, path, err)
784-
else:
785-
try:
786-
# symlinks to directories are forbidden, see bug #1669
787-
raise OSError("Cannot call rmtree on a symbolic link")
788-
except OSError as err:
789-
onexc(os.path.islink, path, err)
758+
while stack:
759+
_rmtree_safe_fd(stack, onexc)
790760
finally:
791-
if not fd_closed:
761+
# Close any file descriptors still on the stack.
762+
while stack:
763+
func, fd, path, entry = stack.pop()
764+
if func is not os.close:
765+
continue
792766
try:
793767
os.close(fd)
794768
except OSError as err:

Lib/test/test_shutil.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -686,7 +686,6 @@ def test_rmtree_on_named_pipe(self):
686686
shutil.rmtree(TESTFN)
687687
self.assertFalse(os.path.exists(TESTFN))
688688

689-
@unittest.skipIf(shutil._use_fd_functions, "fd-based functions remain unfixed (GH-89727)")
690689
def test_rmtree_above_recursion_limit(self):
691690
recursion_limit = 40
692691
# directory_depth > recursion_limit
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Fix issue with :func:`shutil.rmtree` where a :exc:`RecursionError` is raised
2+
on deep directory trees.

0 commit comments

Comments
 (0)