Fix: force_delete needs read+execute permissions, not just write

How cookiecutter/cookiecutter#2217 fixed PermissionError on read-only directories — why S_IWRITE alone is insufficient for shutil.rmtree on directories.

The Bug

Repo: cookiecutter/cookiecutter Issue: #2217 Status: PR-submitted PR: https://github.com/cookiecutter/cookiecutter/pull/2231

When cookiecutter creates a project from a template stored in a read-only location (e.g., the Nix store with permissions d-w-------), cleanup via shutil.rmtree fails with PermissionError. The force_delete error handler only added S_IWRITE (owner-write, 0o200), which is insufficient for directories.

Root Cause

Unix file permissions work differently for files vs directories:

PermissionFile EffectDirectory Effect
S_IREAD (0o0400)Read file contentsList directory entries (via readdir)
S_IWRITE (0o0200)Write file contentsCreate/delete entries in directory
S_IEXEC (0o0100)Execute file as programAccess 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

@@ -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)

The fix sets all three owner permission bits (S_IRWXU = 0o700), ensuring shutil.rmtree can fully traverse and delete the directory tree.

The Nix Store Connection

This bug was particularly impactful for Nix users. The Nix store (/nix/store) contains read-only copies of packages and their files. When a cookiecutter template is installed via Nix, copying from the store produces read-only directories. The original force_delete handler couldn’t clean these up, leaving users with PermissionError on project generation.

Python’s shutil.rmtree Documentation

Python’s shutil.rmtree docs explicitly provide this pattern:

import os, stat
import shutil

def remove_readonly(func, path, _):
    os.chmod(path, stat.S_IWRITE)
    func(path)

shutil.rmtree(directory, onerror=remove_readonly)

However, the Python docs example is for files, not directories. The cookiecutter fix correctly adapts this pattern for the directory case by adding S_IREAD | S_IEXEC.

This is a subtle but important distinction: the official Python documentation’s force_delete example works for read-only files but silently fails on read-only directories. Any code using this pattern should check whether it handles directories.

Transfer Pattern

When implementing error handlers for shutil.rmtree:

  1. Determine if the handler processes files, directories, or both
  2. For directories, always include S_IREAD | stat.S_IEXEC alongside S_IWRITE
  3. Test with both read-only files and read-only directories

Auto-generated from PR #2217. View all patches on GitHub.