Skip to content

Commit a826acf

Browse files
committed
gh-91133: tempfile.TemporaryDirectory: fix symlink bug in cleanup
1 parent b8024c7 commit a826acf

File tree

3 files changed

+55
-7
lines changed

3 files changed

+55
-7
lines changed

Lib/tempfile.py

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -864,15 +864,22 @@ def __init__(self, suffix=None, prefix=None, dir=None,
864864

865865
@classmethod
866866
def _rmtree(cls, name, ignore_errors=False):
867+
def without_following_symlinks(func, path, *args):
868+
# Pass follow_symlinks=False, unless not supported on this platform.
869+
if func in _os.supports_follow_symlinks:
870+
func(path, *args, follow_symlinks=False)
871+
elif not _os.path.islink(path):
872+
func(path, *args)
873+
874+
def resetperms(path):
875+
try:
876+
without_following_symlinks(_os.chflags, path, 0)
877+
except AttributeError:
878+
pass
879+
without_following_symlinks(_os.chmod, path, 0o700)
880+
867881
def onerror(func, path, exc_info):
868882
if issubclass(exc_info[0], PermissionError):
869-
def resetperms(path):
870-
try:
871-
_os.chflags(path, 0)
872-
except AttributeError:
873-
pass
874-
_os.chmod(path, 0o700)
875-
876883
try:
877884
if path != name:
878885
resetperms(_os.path.dirname(path))

Lib/test/test_tempfile.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1652,6 +1652,45 @@ def test_cleanup_with_symlink_to_a_directory(self):
16521652
"were deleted")
16531653
d2.cleanup()
16541654

1655+
@os_helper.skip_unless_symlink
1656+
@os_helper.skip_unless_working_chmod
1657+
@unittest.skipUnless(os.chmod in os.supports_follow_symlinks, 'needs chmod follow_symlinks support')
1658+
def test_cleanup_with_error_deleting_symlink(self):
1659+
# cleanup() should not follow symlinks when fixing mode bits etc. (#91133)
1660+
d1 = self.do_create()
1661+
d2 = self.do_create(recurse=0)
1662+
1663+
# Symlink d1/my_symlink -> d2, then give d2 a custom mode to see if changes.
1664+
os.symlink(d2.name, os.path.join(d1.name, "my_symlink"))
1665+
os.chmod(d2.name, 0o567)
1666+
expected_mode = os.stat(d2.name).st_mode # can be impacted by umask etc.
1667+
1668+
# There are a variety of reasons why the OS may raise a PermissionError,
1669+
# but provoking those reliably and cross-platform is not straightforward,
1670+
# so raise the error synthetically instead.
1671+
real_unlink = os.unlink
1672+
error_was_raised = False
1673+
def patched_unlink(path, **kwargs):
1674+
nonlocal error_was_raised
1675+
# unlink may be called with full path or path relative to 'fd' kwarg.
1676+
if path.endswith("my_symlink") and not error_was_raised:
1677+
error_was_raised = True
1678+
raise PermissionError()
1679+
real_unlink(path, **kwargs)
1680+
1681+
with mock.patch("tempfile._os.unlink", patched_unlink):
1682+
# This call to cleanup() should not follow my_symlink when fixing permissions
1683+
d1.cleanup()
1684+
1685+
self.assertTrue(error_was_raised, "did not see expected 'unlink' call")
1686+
self.assertFalse(os.path.exists(d1.name),
1687+
"TemporaryDirectory %s exists after cleanup" % d1.name)
1688+
self.assertTrue(os.path.exists(d2.name),
1689+
"Directory pointed to by a symlink was deleted")
1690+
self.assertEqual(os.stat(d2.name).st_mode, expected_mode,
1691+
"Mode of the directory pointed to by a symlink changed")
1692+
d2.cleanup()
1693+
16551694
@support.cpython_only
16561695
def test_del_on_collection(self):
16571696
# A TemporaryDirectory is deleted when garbage collected
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Fix a bug `:class:`tempfile.TemporaryDirectory` cleanup, which now no longer
2+
dereferences symlinks when working around file system permission errors.

0 commit comments

Comments
 (0)