Skip to content

Commit ce05986

Browse files
RonnyPfannschmidtCursor AIclaude
committed
Address Copilot review feedback on rm_rf permission handling
- Use stat.S_IMODE() to extract only permission bits before comparing/setting, avoiding file-type bit leakage into os.chmod - Only add S_IXUSR for directories; regular files get S_IRUSR|S_IWUSR only, avoiding unnecessary execute side-effect on files - Fix recursion guard: track whether *either* parent or path chmod changed permissions, so fixing the parent alone (the common case for missing S_IXUSR) is sufficient to proceed with removal - Add test for parent-only permission fix scenario Co-authored-by: Cursor AI <ai@cursor.sh> Co-authored-by: Claude Opus <claude@anthropic.com>
1 parent 2afd74f commit ce05986

2 files changed

Lines changed: 33 additions & 5 deletions

File tree

src/_pytest/pathlib.py

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,11 @@ def get_lock_path(path: _AnyPurePath) -> _AnyPurePath:
7070

7171

7272
def _chmod_rwx(p: str) -> bool:
73-
"""Grant owner read, write, and execute permissions.
73+
"""Grant owner sufficient permissions for deletion.
74+
75+
Directories get ``S_IRWXU`` (read+write+exec for traversal).
76+
Regular files get ``S_IRUSR | S_IWUSR`` only, to avoid making
77+
non-executable files executable as a side effect.
7478
7579
Returns True if permissions were actually changed, False if they were
7680
already sufficient or couldn't be changed.
@@ -79,8 +83,10 @@ def _chmod_rwx(p: str) -> bool:
7983

8084
try:
8185
old_mode = os.stat(p).st_mode
82-
new_mode = old_mode | stat.S_IRWXU
83-
if old_mode == new_mode:
86+
perm_mode = stat.S_IMODE(old_mode)
87+
bits = stat.S_IRWXU if stat.S_ISDIR(old_mode) else stat.S_IRUSR | stat.S_IWUSR
88+
new_mode = perm_mode | bits
89+
if perm_mode == new_mode:
8490
return False
8591
os.chmod(p, new_mode)
8692
except OSError:
@@ -122,9 +128,11 @@ def on_rm_rf_error(
122128
# skips entries after the error handler returns.
123129
# See: https://github.com/pytest-dev/pytest/issues/7940
124130
p = Path(path)
131+
parent_changed = False
125132
if p.parent != p:
126-
_chmod_rwx(str(p.parent))
127-
if not _chmod_rwx(path):
133+
parent_changed = _chmod_rwx(str(p.parent))
134+
path_changed = _chmod_rwx(path)
135+
if not (parent_changed or path_changed):
128136
return False
129137
if os.path.isdir(path):
130138
rm_rf(Path(path))

testing/test_tmpdir.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -648,6 +648,26 @@ def test_on_rm_rf_error_os_open_handles_directory(self, tmp_path: Path) -> None:
648648
assert not adir.exists()
649649
assert not [x.message for x in w]
650650

651+
@pytest.mark.skipif(not hasattr(os, "getuid"), reason="unix permissions")
652+
def test_on_rm_rf_error_os_open_parent_perms(self, tmp_path: Path) -> None:
653+
"""When the PermissionError is caused by the *parent* directory lacking
654+
S_IXUSR, fixing the parent is sufficient even if the child already has
655+
correct permissions."""
656+
parent = tmp_path / "parent"
657+
parent.mkdir()
658+
child = parent / "child"
659+
child.mkdir()
660+
(child / "file.txt").touch()
661+
# Child has full perms, but parent lacks execute -> os.open(child) fails.
662+
os.chmod(str(parent), 0o600)
663+
664+
with warnings.catch_warnings(record=True) as w:
665+
exc_info = PermissionError()
666+
result = on_rm_rf_error(os.open, str(child), exc_info, start_path=tmp_path)
667+
assert result is True
668+
assert not child.exists()
669+
assert not [x.message for x in w]
670+
651671

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

0 commit comments

Comments
 (0)