Skip to content

Commit fec7e68

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 bbd9a19 commit fec7e68

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
@@ -71,7 +71,11 @@ def get_lock_path(path: _AnyPurePath) -> _AnyPurePath:
7171

7272

7373
def _chmod_rwx(p: str) -> bool:
74-
"""Grant owner read, write, and execute permissions.
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.
7579
7680
Returns True if permissions were actually changed, False if they were
7781
already sufficient or couldn't be changed.
@@ -80,8 +84,10 @@ def _chmod_rwx(p: str) -> bool:
8084

8185
try:
8286
old_mode = os.stat(p).st_mode
83-
new_mode = old_mode | stat.S_IRWXU
84-
if old_mode == new_mode:
87+
perm_mode = stat.S_IMODE(old_mode)
88+
bits = stat.S_IRWXU if stat.S_ISDIR(old_mode) else stat.S_IRUSR | stat.S_IWUSR
89+
new_mode = perm_mode | bits
90+
if perm_mode == new_mode:
8591
return False
8692
os.chmod(p, new_mode)
8793
except OSError:
@@ -123,9 +129,11 @@ def on_rm_rf_error(
123129
# skips entries after the error handler returns.
124130
# See: https://github.com/pytest-dev/pytest/issues/7940
125131
p = Path(path)
132+
parent_changed = False
126133
if p.parent != p:
127-
_chmod_rwx(str(p.parent))
128-
if not _chmod_rwx(path):
134+
parent_changed = _chmod_rwx(str(p.parent))
135+
path_changed = _chmod_rwx(path)
136+
if not (parent_changed or path_changed):
129137
return False
130138
if os.path.isdir(path):
131139
rm_rf(Path(path))

testing/test_tmpdir.py

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

585+
@pytest.mark.skipif(not hasattr(os, "getuid"), reason="unix permissions")
586+
def test_on_rm_rf_error_os_open_parent_perms(self, tmp_path: Path) -> None:
587+
"""When the PermissionError is caused by the *parent* directory lacking
588+
S_IXUSR, fixing the parent is sufficient even if the child already has
589+
correct permissions."""
590+
parent = tmp_path / "parent"
591+
parent.mkdir()
592+
child = parent / "child"
593+
child.mkdir()
594+
(child / "file.txt").touch()
595+
# Child has full perms, but parent lacks execute -> os.open(child) fails.
596+
os.chmod(str(parent), 0o600)
597+
598+
with warnings.catch_warnings(record=True) as w:
599+
exc_info = PermissionError()
600+
result = on_rm_rf_error(os.open, str(child), exc_info, start_path=tmp_path)
601+
assert result is True
602+
assert not child.exists()
603+
assert not [x.message for x in w]
604+
585605

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

0 commit comments

Comments
 (0)