diff --git a/Doc/library/shutil.rst b/Doc/library/shutil.rst index 2822e979990c4d..0372408ae95a77 100644 --- a/Doc/library/shutil.rst +++ b/Doc/library/shutil.rst @@ -311,7 +311,8 @@ Directory and files operations .. index:: single: directory; deleting Delete an entire directory tree; *path* must point to a directory (but not a - symbolic link to a directory). If *ignore_errors* is true, errors resulting + symbolic link to a directory, with or without a trailing path separator). + If *ignore_errors* is true, errors resulting from failed removals will be ignored; if false or omitted, such errors are handled by calling a handler specified by *onexc* or *onerror* or, if both are omitted, exceptions are propagated to the caller. @@ -367,6 +368,12 @@ Directory and files operations Exceptions other than :exc:`OSError` and subclasses of :exc:`!OSError` are now always propagated to the caller. + .. versionchanged:: next + A symbolic link to a directory is now also refused when *path* has a + trailing path separator, instead of deleting the contents of the + directory it points to. Error handlers receive *path* without + trailing separators. + .. attribute:: rmtree.avoids_symlink_attacks Indicates whether the current platform and implementation provides a diff --git a/Lib/shutil.py b/Lib/shutil.py index d6d7806802bd17..1c100efa6b7d1b 100644 --- a/Lib/shutil.py +++ b/Lib/shutil.py @@ -847,6 +847,16 @@ def rmtree(path, ignore_errors=False, onerror=None, *, onexc=None, dir_fd=None, """ sys.audit("shutil.rmtree", path, dir_fd) + # Strip trailing separators: "link/" names the target of a symlink, which + # would defeat the symlink check and delete the target's contents. + fspath = os.fspath(path) + drive, root, tail = os.path.splitroot(fspath) + seps = os.sep + (os.altsep or '') + if isinstance(fspath, bytes): + seps = os.fsencode(seps) + stripped = tail.rstrip(seps) + if stripped != tail: + path = drive + root + stripped if ignore_errors: def onexc(*args, **kwargs): pass diff --git a/Lib/test/test_shutil.py b/Lib/test/test_shutil.py index 87b6453fba8024..a9ec79e9d065fc 100644 --- a/Lib/test/test_shutil.py +++ b/Lib/test/test_shutil.py @@ -232,6 +232,42 @@ def onexc(*args): self.assertEqual(errors[0][1], link) self.assertIsInstance(errors[0][2], OSError) + @os_helper.skip_unless_symlink + def test_rmtree_fails_on_symlink_with_trailing_sep(self): + tmp = self.mkdtemp() + dir_ = os.path.join(tmp, 'dir') + os.mkdir(dir_) + create_file(os.path.join(dir_, 'file'), 'foo') + link = os.path.join(tmp, 'link') + os.symlink(dir_, link) + for path in (link + os.sep, link + os.sep * 2, + os.fsencode(link + os.sep)): + with self.subTest(path=path): + self.assertRaises(OSError, shutil.rmtree, path) + self.assertTrue(os.path.exists(os.path.join(dir_, 'file'))) + self.assertTrue(os.path.lexists(link)) + errors = [] + def onexc(*args): + errors.append(args) + shutil.rmtree(link + os.sep, onexc=onexc) + self.assertEqual(len(errors), 1) + self.assertIs(errors[0][0], os.path.islink) + self.assertEqual(errors[0][1], link) + self.assertIsInstance(errors[0][2], OSError) + self.assertTrue(os.path.exists(os.path.join(dir_, 'file'))) + + def test_rmtree_works_with_trailing_sep(self): + tmp = self.mkdtemp() + victim = os.path.join(tmp, 'killme') + os.mkdir(victim) + create_file(os.path.join(victim, 'somefile'), 'foo') + shutil.rmtree(victim + os.sep) + self.assertFalse(os.path.lexists(victim)) + os.mkdir(victim) + create_file(os.path.join(victim, 'somefile'), 'foo') + shutil.rmtree(os.fsencode(victim + os.sep * 2)) + self.assertFalse(os.path.lexists(victim)) + @os_helper.skip_unless_symlink def test_rmtree_works_on_symlinks(self): tmp = self.mkdtemp() diff --git a/Misc/NEWS.d/next/Library/2026-10-22-08-15-35.gh-issue-160322.rT4slk.rst b/Misc/NEWS.d/next/Library/2026-10-22-08-15-35.gh-issue-160322.rT4slk.rst new file mode 100644 index 00000000000000..56862ee80ebaa1 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-10-22-08-15-35.gh-issue-160322.rT4slk.rst @@ -0,0 +1,3 @@ +Fix :func:`shutil.rmtree` deleting the contents of the target directory when +given a symbolic link to a directory with a trailing path separator. It now +fails like it does for the link without the separator.