7
High
// The Bug
How cookiecutter/cookiecutter#2217 fixed PermissionError on read-only directories — why S_IWRITE alone is insufficient for shutil.rmtree on directories.
// Root Cause
Unix file permissions work differently for files vs directories:
| Permission | File Effect | Directory Effect |
|------------|-------------|------------------|
| `S_IREAD` (0o0400) | Read file contents | List directory entries (via `readdir`) |
| `S_IWRITE` (0o0200) | Write file contents | Create/delete entries in directory |
| `S_IEXEC` (0o0100) | Execute file as program | Access entries inside (via `stat`, `open`) |
`shutil.rmtree` needs to:
1. **Read** the directory to list its contents → needs `S_IREAD`
2. **Access** each entry to inspect it → needs `S_IEXEC`
3. **Delete** entries as it traverses → needs `S_IWRITE`
The original code only set `S_IWRITE`, so `rmtree` could list the directory (write permission on the parent) but couldn't access entries inside the read-only directory.
| Permission | File Effect | Directory Effect |
|------------|-------------|------------------|
| `S_IREAD` (0o0400) | Read file contents | List directory entries (via `readdir`) |
| `S_IWRITE` (0o0200) | Write file contents | Create/delete entries in directory |
| `S_IEXEC` (0o0100) | Execute file as program | Access entries inside (via `stat`, `open`) |
`shutil.rmtree` needs to:
1. **Read** the directory to list its contents → needs `S_IREAD`
2. **Access** each entry to inspect it → needs `S_IEXEC`
3. **Delete** entries as it traverses → needs `S_IWRITE`
The original code only set `S_IWRITE`, so `rmtree` could list the directory (write permission on the parent) but couldn't access entries inside the read-only directory.
// The Fix
Diff showing the exact changes made to fix the bug.
@@ -28,7 +28,7 @@ def force_delete(func, path, _exc_info) -> None: # type: ignore[no-untyped-def]
Usage: `shutil.rmtree(path, onerror=force_delete)`
From https://docs.python.org/3/library/shutil.html#rmtree-example
"""
- os.chmod(path, stat.S_IWRITE)
+ os.chmod(path, stat.S_IWRITE | stat.S_IREAD | stat.S_IEXEC)
func(path)
@@ -24,7 +24,7 @@ def test_force_delete(mocker, tmp_path) -> None:
rmtree = mocker.Mock()
utils.force_delete(rmtree, ro_file, sys.exc_info())
- assert (ro_file.stat().st_mode & stat.S_IWRITE) == stat.S_IWRITE
+ assert (ro_file.stat().st_mode & stat.S_IRWXU) == stat.S_IRWXU
rmtree.assert_called_once_with(ro_file)
utils.rmtree(tmp_path)