fix: rm_rf now handles directories without S_IXUSR permission#14331
fix: rm_rf now handles directories without S_IXUSR permission#14331RonnyPfannschmidt wants to merge 2 commits intopytest-dev:mainfrom
Conversation
…-dev#7940) The on_rm_rf_error handler previously only set S_IRUSR|S_IWUSR (read+write) when retrying failed removals, and silently skipped os.open/os.scandir failures. This meant directories lacking execute permission (S_IXUSR) could not be traversed or removed by shutil.rmtree. Now the handler: - Uses S_IRWXU (read+write+execute) for all permission fixes - Handles os.open and os.scandir PermissionError by fixing permissions on the path and its parent, then removing the entry directly - Includes a guard against infinite recursion when chmod has no effect Supersedes pytest-dev#7941 which took the approach of delegating to tempfile.TemporaryDirectory._rmtree. Co-authored-by: Cursor AI <ai@cursor.sh> Co-authored-by: Claude Opus <claude@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR fixes rm_rf failures when removing directory trees that include directories missing the owner execute (S_IXUSR) permission bit, by improving on_rm_rf_error and adding regression tests for the affected traversal code paths.
Changes:
- Added
_chmod_rwx()to grant ownerrwxpermissions and replaced priorchmod_rwusage. - Enhanced
on_rm_rf_errorto handleos.open/os.scandirPermissionErrorby repairing permissions and removing entries directly. - Added new tests covering no-exec directory trees and
os.openPermissionError handling; added a changelog entry.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
| testing/test_tmpdir.py | Adds regression tests for removing trees with no-exec directories and for os.open PermissionError handling. |
| src/_pytest/pathlib.py | Introduces _chmod_rwx() and updates on_rm_rf_error to fix permissions and handle traversal errors from os.open/os.scandir. |
| changelog/7940.bugfix.rst | Documents the bugfix in the changelog. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- 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>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| Returns True if permissions were actually changed, False if they were | ||
| already sufficient or couldn't be changed. | ||
| """ | ||
| import stat |
| p = Path(path) | ||
| parent_changed = False | ||
| if p.parent != p: | ||
| parent_changed = _chmod_rwx(str(p.parent)) |
There was a problem hiding this comment.
Hmm isn't chmoding parent kinda dangerous? Say I do rm_rf('/tmp/foo/'), now it might try to go and chmod /tmp?
| @@ -0,0 +1 @@ | |||
| Fixed ``rm_rf`` failing to remove directories when the ``S_IXUSR`` (owner execute) permission bit is missing -- supersedes :pr:`7941`. | |||
There was a problem hiding this comment.
- User doesn't know what
rm_rfis, it's an internal function. Better to explain in terms of user behavior. - Is the "supersedes" part needed in a changelog entry?
| # on the path and its parent, then remove it ourselves since rmtree | ||
| # skips entries after the error handler returns. | ||
| # See: https://github.com/pytest-dev/pytest/issues/7940 | ||
| p = Path(path) |
There was a problem hiding this comment.
Since we already do p = Path(path) below in the function, I think it will be clearer to just move it up above the if and do everything in terms of it (including _chmod_rwx)
| if func in (os.open, os.scandir): | ||
| # Directory traversal failed (e.g. missing S_IXUSR). Fix permissions |
There was a problem hiding this comment.
Is the implicit assumption here that os.open is always on a directory? I think it will be better to make this explicit with an is_dir check.
| if not (parent_changed or path_changed): | ||
| return False | ||
| if os.path.isdir(path): | ||
| rm_rf(Path(path)) |
There was a problem hiding this comment.
If for some reason the chmod didn't help (e.g. maybe some sandboxing prevents the deletion via some non-permission mechanism), this will end up in infinite recursion.
Summary
Closes #7940.
Supersedes #7941.
The
on_rm_rf_errorhandler had two gaps that preventedrm_rffrom removing directories lacking theS_IXUSR(owner execute) permission bit:chmod_rwonly setS_IRUSR | S_IWUSR(read+write) — missingS_IXUSRmeant directories couldn't be traversed byshutil.rmtree.os.open/os.scandirPermissionErrorwas silently skipped —shutil.rmtree(Python 3.12+ usesos.open, 3.10–3.11 usesos.scandir) could not enter directories without execute permission, and the error handler simply returnedFalse.Changes
_chmod_rwx()helper that usesstat.S_IRWXU(read+write+exec) and returns whether permissions actually changed (acts as a recursion guard).os.openandos.scandirfailures are now handled: permissions are fixed on the path and its parent, then the entry is removed directly (files viaos.unlink, directories via recursiverm_rf).chmod_rwcalls replaced with_chmod_rwx.This takes the "fix it in
on_rm_rf_errordirectly" approach rather than #7941's approach of delegating totempfile.TemporaryDirectory._rmtree, keepingrm_rfrobust for all callers.Test plan
test_rm_rf_with_no_exec_permission_directories— exact scenario from Pytest cannot delete tmp_path directories withoutstat.S_IXUSRmode bit set #7940 (nested dirs + files withchmod(mode=0))test_on_rm_rf_error_os_open_handles_file—os.openPermissionError on a filetest_on_rm_rf_error_os_open_handles_directory—os.openPermissionError on a directory treeTestRmRftests pass unchangedtesting/test_tmpdir.pysuite passes (41 passed, 1 skipped)Made with Cursor