Skip to content

Commit 35f21fd

Browse files
Merge pull request #14331 from RonnyPfannschmidt/fix/rm-rf-no-exec-permission-7940
fix: rm_rf now handles directories without S_IXUSR permission
2 parents 532b201 + d103697 commit 35f21fd

3 files changed

Lines changed: 221 additions & 23 deletions

File tree

changelog/7940.bugfix.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Fixed cleanup of temporary directories failing when subdirectories have their execute permission removed.

src/_pytest/pathlib.py

Lines changed: 60 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
from pathlib import PurePath
2525
from posixpath import sep as posix_sep
2626
import shutil
27+
import stat
2728
import sys
2829
import types
2930
from types import ModuleType
@@ -69,6 +70,33 @@ def get_lock_path(path: _AnyPurePath) -> _AnyPurePath:
6970
return path.joinpath(".lock")
7071

7172

73+
def _chmod_rwx(p: str) -> bool:
74+
"""Grant owner sufficient permissions for deletion.
75+
76+
Directories get ``S_IRWXU`` (read+write+exec for traversal).
77+
Regular files get ``S_IRUSR | S_IWUSR`` only, to avoid making
78+
non-executable files executable as a side effect.
79+
80+
Returns True if permissions were actually changed, False if they were
81+
already sufficient or couldn't be changed.
82+
"""
83+
try:
84+
old_mode = os.stat(p).st_mode
85+
except OSError:
86+
# Path may have been removed concurrently, or be inaccessible.
87+
return False
88+
perm_mode = stat.S_IMODE(old_mode)
89+
bits = stat.S_IRWXU if stat.S_ISDIR(old_mode) else stat.S_IRUSR | stat.S_IWUSR
90+
new_mode = perm_mode | bits
91+
if perm_mode == new_mode:
92+
return False
93+
try:
94+
os.chmod(p, new_mode)
95+
except OSError:
96+
return False
97+
return True
98+
99+
72100
def on_rm_rf_error(
73101
func: Callable[..., Any] | None,
74102
path: str,
@@ -97,32 +125,48 @@ def on_rm_rf_error(
97125
)
98126
return False
99127

128+
p = Path(path)
129+
130+
if func in (os.open, os.scandir):
131+
# Directory traversal failed (e.g. missing S_IXUSR). Fix permissions
132+
# on the path and its parent (bounded by start_path), then remove it
133+
# ourselves since rmtree skips entries after the error handler returns.
134+
# See: https://github.com/pytest-dev/pytest/issues/7940
135+
parent_changed = False
136+
parent = p.parent
137+
# Never chmod outside the tree rooted at start_path.
138+
if parent not in (p, start_path):
139+
parent_changed = _chmod_rwx(str(parent))
140+
path_changed = _chmod_rwx(str(p))
141+
if not (parent_changed or path_changed):
142+
return False
143+
if p.is_dir():
144+
rm_rf(p)
145+
else:
146+
try:
147+
os.unlink(str(p))
148+
except OSError:
149+
return False
150+
return True
151+
100152
if func not in (os.rmdir, os.remove, os.unlink):
101-
if func not in (os.open,):
102-
warnings.warn(
103-
PytestWarning(
104-
f"(rm_rf) unknown function {func} when removing {path}:\n{type(exc)}: {exc}"
105-
)
153+
warnings.warn(
154+
PytestWarning(
155+
f"(rm_rf) unknown function {func} when removing {path}:\n{type(exc)}: {exc}"
106156
)
157+
)
107158
return False
108159

109160
# Chmod + retry.
110-
import stat
111-
112-
def chmod_rw(p: str) -> None:
113-
mode = os.stat(p).st_mode
114-
os.chmod(p, mode | stat.S_IRUSR | stat.S_IWUSR)
115-
116161
# For files, we need to recursively go upwards in the directories to
117-
# ensure they all are also writable.
118-
p = Path(path)
162+
# ensure they all are also accessible and writable.
119163
if p.is_file():
120-
for parent in p.parents:
121-
chmod_rw(str(parent))
164+
for parent in p.parents: # pragma: no branch
165+
_chmod_rwx(str(parent))
122166
# Stop when we reach the original path passed to rm_rf.
123167
if parent == start_path:
124168
break
125-
chmod_rw(str(path))
169+
_chmod_rwx(str(path))
126170

127171
func(path)
128172
return True

testing/test_tmpdir.py

Lines changed: 160 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from _pytest import pathlib
1515
from _pytest.config import Config
1616
from _pytest.monkeypatch import MonkeyPatch
17+
from _pytest.pathlib import _chmod_rwx
1718
from _pytest.pathlib import cleanup_numbered_dir
1819
from _pytest.pathlib import create_cleanup_lock
1920
from _pytest.pathlib import make_numbered_dir
@@ -522,6 +523,10 @@ def test_removal_accepts_lock(self, tmp_path):
522523
assert dir.is_dir()
523524

524525

526+
def _raise_oserror(*args: object, **kwargs: object) -> None:
527+
raise OSError("simulated failure")
528+
529+
525530
class TestRmRf:
526531
def test_rm_rf(self, tmp_path):
527532
adir = tmp_path / "adir"
@@ -566,6 +571,25 @@ def test_rm_rf_with_read_only_directory(self, tmp_path):
566571

567572
assert not adir.is_dir()
568573

574+
@pytest.mark.skipif(not hasattr(os, "getuid"), reason="unix permissions")
575+
def test_rm_rf_with_no_exec_permission_directories(self, tmp_path):
576+
"""Ensure rm_rf can remove directories without S_IXUSR (#7940).
577+
578+
This is the exact scenario from the original issue: nested directories
579+
and files with all permissions stripped.
580+
"""
581+
p = tmp_path / "foo" / "bar" / "baz"
582+
p.parent.mkdir(parents=True)
583+
p.touch(mode=0)
584+
for parent in p.parents: # pragma: no branch
585+
if parent == tmp_path:
586+
break
587+
parent.chmod(mode=0)
588+
589+
rm_rf(tmp_path / "foo")
590+
591+
assert not (tmp_path / "foo").exists()
592+
569593
def test_on_rm_rf_error(self, tmp_path: Path) -> None:
570594
adir = tmp_path / "dir"
571595
adir.mkdir()
@@ -593,17 +617,146 @@ def test_on_rm_rf_error(self, tmp_path: Path) -> None:
593617
on_rm_rf_error(None, str(fn), exc_info3, start_path=tmp_path)
594618
assert fn.is_file()
595619

596-
# ignored function
597-
with warnings.catch_warnings(record=True) as w:
598-
exc_info4 = PermissionError()
599-
on_rm_rf_error(os.open, str(fn), exc_info4, start_path=tmp_path)
600-
assert fn.is_file()
601-
assert not [x.message for x in w]
602-
620+
# os.unlink PermissionError is handled (chmod + retry)
603621
exc_info5 = PermissionError()
604622
on_rm_rf_error(os.unlink, str(fn), exc_info5, start_path=tmp_path)
605623
assert not fn.is_file()
606624

625+
def test_on_rm_rf_error_os_open_handles_file(self, tmp_path: Path) -> None:
626+
"""os.open PermissionError on a file is handled by fixing
627+
permissions and removing it (#7940)."""
628+
adir = tmp_path / "dir"
629+
adir.mkdir()
630+
fn = adir / "foo.txt"
631+
fn.touch()
632+
self.chmod_r(fn)
633+
634+
with warnings.catch_warnings(record=True) as w:
635+
exc_info = PermissionError()
636+
on_rm_rf_error(os.open, str(fn), exc_info, start_path=tmp_path)
637+
assert not fn.exists()
638+
assert not [x.message for x in w]
639+
640+
@pytest.mark.skipif(not hasattr(os, "getuid"), reason="unix permissions")
641+
def test_on_rm_rf_error_os_open_handles_directory(self, tmp_path: Path) -> None:
642+
"""os.open PermissionError on a directory is handled by fixing
643+
permissions and recursively removing it (#7940)."""
644+
adir = tmp_path / "dir"
645+
adir.mkdir()
646+
(adir / "child").mkdir()
647+
(adir / "child" / "file.txt").touch()
648+
os.chmod(str(adir), stat.S_IRUSR | stat.S_IWUSR)
649+
650+
with warnings.catch_warnings(record=True) as w:
651+
exc_info = PermissionError()
652+
on_rm_rf_error(os.open, str(adir), exc_info, start_path=tmp_path)
653+
assert not adir.exists()
654+
assert not [x.message for x in w]
655+
656+
@pytest.mark.skipif(not hasattr(os, "getuid"), reason="unix permissions")
657+
def test_on_rm_rf_error_os_open_parent_perms(self, tmp_path: Path) -> None:
658+
"""When the PermissionError is caused by the *parent* directory lacking
659+
S_IXUSR, fixing the parent is sufficient even if the child already has
660+
correct permissions."""
661+
parent = tmp_path / "parent"
662+
parent.mkdir()
663+
child = parent / "child"
664+
child.mkdir()
665+
(child / "file.txt").touch()
666+
# Child has full perms, but parent lacks execute -> os.open(child) fails.
667+
os.chmod(str(parent), stat.S_IRUSR | stat.S_IWUSR)
668+
669+
with warnings.catch_warnings(record=True) as w:
670+
exc_info = PermissionError()
671+
result = on_rm_rf_error(os.open, str(child), exc_info, start_path=tmp_path)
672+
assert result is True
673+
assert not child.exists()
674+
assert not [x.message for x in w]
675+
676+
def test_chmod_rwx_returns_false_on_nonexistent_path(self, tmp_path: Path) -> None:
677+
"""_chmod_rwx returns False when the path doesn't exist (OSError)."""
678+
nonexistent = tmp_path / "does_not_exist"
679+
assert _chmod_rwx(str(nonexistent)) is False
680+
681+
def test_chmod_rwx_returns_false_when_chmod_fails(
682+
self, tmp_path: Path, monkeypatch: MonkeyPatch
683+
) -> None:
684+
"""_chmod_rwx returns False when os.chmod raises OSError.
685+
686+
A real FS layout that makes chmod fail while the path remains
687+
(immutable bit, RO mount, foreign ownership) is not portable in CI,
688+
so pin this branch with a monkeypatch.
689+
"""
690+
fn = tmp_path / "file.txt"
691+
fn.touch(mode=0) # needs bits so we reach os.chmod
692+
monkeypatch.setattr(os, "chmod", _raise_oserror)
693+
assert _chmod_rwx(str(fn)) is False
694+
695+
def test_chmod_rwx_returns_false_when_already_sufficient(
696+
self, tmp_path: Path
697+
) -> None:
698+
"""_chmod_rwx returns False when permissions are already sufficient."""
699+
d = tmp_path / "dir"
700+
d.mkdir(mode=stat.S_IRWXU)
701+
assert _chmod_rwx(str(d)) is False
702+
703+
f = tmp_path / "file"
704+
f.touch(mode=stat.S_IRUSR | stat.S_IWUSR)
705+
assert _chmod_rwx(str(f)) is False
706+
707+
def test_on_rm_rf_error_os_open_returns_false_when_chmod_ineffective(
708+
self, tmp_path: Path
709+
) -> None:
710+
"""os.open handler returns False when neither parent nor path chmod
711+
changes anything (recursion guard)."""
712+
adir = tmp_path / "dir"
713+
adir.mkdir(mode=stat.S_IRWXU)
714+
exc_info = PermissionError()
715+
result = on_rm_rf_error(os.open, str(adir), exc_info, start_path=tmp_path)
716+
assert result is False
717+
assert adir.exists()
718+
719+
def test_on_rm_rf_error_os_open_unlink_fails(
720+
self, tmp_path: Path, monkeypatch: MonkeyPatch
721+
) -> None:
722+
"""os.open handler returns False when chmod succeeds but os.unlink
723+
still raises OSError (e.g. sandbox or other mechanism)."""
724+
fn = tmp_path / "stubborn.txt"
725+
fn.touch(mode=0)
726+
727+
monkeypatch.setattr(os, "unlink", _raise_oserror)
728+
729+
exc_info = PermissionError()
730+
result = on_rm_rf_error(os.open, str(fn), exc_info, start_path=tmp_path)
731+
assert result is False
732+
assert fn.exists()
733+
734+
@pytest.mark.skipif(not hasattr(os, "getuid"), reason="unix permissions")
735+
def test_on_rm_rf_error_chmod_retry_walks_parents(self, tmp_path: Path) -> None:
736+
"""The os.rmdir/os.unlink handler walks up through multiple parent
737+
directories to fix permissions before retrying."""
738+
deep = tmp_path / "a" / "b" / "c"
739+
deep.mkdir(parents=True)
740+
fn = deep / "file.txt"
741+
fn.touch()
742+
# Remove write from intermediate dirs (keep exec so traversal works,
743+
# but os.unlink needs write on the parent).
744+
for parent in fn.parents: # pragma: no branch
745+
if parent == tmp_path:
746+
break
747+
parent.chmod(
748+
stat.S_IRUSR
749+
| stat.S_IXUSR
750+
| stat.S_IRGRP
751+
| stat.S_IXGRP
752+
| stat.S_IROTH
753+
| stat.S_IXOTH
754+
)
755+
756+
exc_info = PermissionError()
757+
on_rm_rf_error(os.unlink, str(fn), exc_info, start_path=tmp_path)
758+
assert not fn.exists()
759+
607760

608761
def attempt_symlink_to(path, to_path):
609762
"""Try to make a symlink from "path" to "to_path", skipping in case this platform

0 commit comments

Comments
 (0)