Fix: fix: force_delete adds read+execute permissions, not just write

Fixed cookiecutter/cookiecutter#2217 — 2 line bug-fix.

The Bug

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

Description: force_delete adds read+execute permissions, not just write

Fix scope: 2 lines changed in cookiecutter/utils.py and tests/test_utils.py

When shutil.rmtree() encounters a PermissionError during recursive directory removal, cookiecutter’s force_delete callback is meant to patch up permissions and retry the operation. The original code granted only owner-write permission (stat.S_IWRITE, octal 0o200), which is sufficient for files but not for directories. Removing a directory entry requires both read permission (to list the directory contents via os.scandir()) and execute permission (to traverse the directory and access child inodes via unlink() / rmdir()). Without these, the recursive deletion fails — even though the intent was to make the path deletable.

The bug manifests specifically when cookiecutter copies templates from a read-only source — such as the Nix store or a protected system path — into a temporary working directory. The copied directory tree inherits restrictive permissions (d-w-------, write-only on the directory node), which makes it undeletable by shutil.rmtree. Users encounter a PermissionError during project generation cleanup, leaving stale temporary directories behind.

Root Cause

The edge case lives in cookiecutter/utils.py inside the force_delete() function — an error handler passed as the onerror callback to shutil.rmtree(). The function signature follows the standard shutil.rmtree callback protocol: (func, path, exc_info).

The original line was:

os.chmod(path, stat.S_IWRITE)

On Unix, stat.S_IWRITE is defined as 0o200 — owner-write permission only. This works for regular files because unlinking a file from a directory requires write permission on the parent directory, not on the file itself. But force_delete applies os.chmod to the path itself, which for directories is the same as the directory entry being deleted. The shutil.rmtree implementation internally calls os.scandir() to list directory children — that requires read permission on the directory. It then calls os.unlink() on files and os.rmdir() on subdirectories — both require execute (traverse) permission on the directory being entered.

With only S_IWRITE set, the directory is write-only. os.scandir() raises PermissionError because reading the directory listing is denied. The onerror callback re-triggers, but it sets the same insufficient permissions, causing an infinite failure loop. This is why the fix needs to grant read and execute in addition to write.

The pattern is easy to miss because standard Python test suites for cookiecutter use tmp_path fixtures that grant default permissive 0o755 directories. The bug only surfaces when the source tree was created under restrictive umask or copied from a read-only filesystem — a boundary condition not covered by happy-path tests.

Impact: Users who generate projects from templates stored in Nix-style read-only stores or protected system paths hit a hard PermissionError during cleanup. The directory cannot be removed, leading to stale temporary directories that accumulate on disk. There is no silent data corruption — the failure is loud and immediate — but it blocks automated workflows and CI pipelines that depend on clean temporary state.

Code Analysis

Before (broken)

In cookiecutter/utils.py, the force_delete function:

def force_delete(func, path, _exc_info) -> None:
    """Error handler for shutil.rmtree."""
    os.chmod(path, stat.S_IWRITE)   # only 0o200 (--w-------)
    func(path)

This sets only the owner-write bit (0o200). For directories, the operating system needs:

  • Read (0o400): to list directory entries via scandir().
  • Execute (0o100): to traverse the directory and access child inodes during unlink() / rmdir() system calls.

Without both bits, shutil.rmtree cannot enumerate or access children, and the func(path) retry fails with another PermissionError.

After (fixed)

def force_delete(func, path, _exc_info) -> None:
    """Error handler for shutil.rmtree."""
    os.chmod(path, stat.S_IWRITE | stat.S_IREAD | stat.S_IEXEC)  # 0o700
    func(path)

The fix ORs together three permission constants:

  • stat.S_IREAD = 0o400
  • stat.S_IWRITE = 0o200
  • stat.S_IEXEC = 0o100
  • Combined = 0o700 = stat.S_IRWXU = owner read-write-execute

This is equivalent to os.chmod(path, stat.S_IRWXU) — the shorthand constant for all three owner permission bits. The test side also updated the assertion from checking only S_IWRITE to checking S_IRWXU, ensuring the fix is properly validated.

Why not group or world permissions?

Setting only owner bits (0o700) is deliberate. The force_delete callback is called during temporary directory cleanup — the directory was created by the current process under its own UID. Granting group or world access would be unnecessary and a potential security concern if the path happens to contain sensitive template data. Owner-only read-write-execute is the minimal set that satisfies both Unix filesystem semantics for directory deletion and the principle of least privilege.

The Fix

This is a textbook surgical fix — one line changed in the source, one line changed in the test. Every bit in the OR expression is deliberate:

S_IWRITE → S_IWRITE | S_IREAD | S_IEXEC

The bitwise OR of these three constants produces 0o700 (owner read-write-execute). This is exactly stat.S_IRWXU, but the explicit OR form communicates the intent: we are adding read and execute permissions that were missing, not just switching to a magic constant. The diff is self-documenting.

The corresponding test fix in tests/test_utils.py tightens the assertion from:

assert (ro_file.stat().st_mode & stat.S_IWRITE) == stat.S_IWRITE

to:

assert (ro_file.stat().st_mode & stat.S_IRWXU) == stat.S_IRWXU

This ensures the test actually verifies all three bits are present, preventing regression to the write-only state.

Pattern & Takeaways

Pattern: Permission boundary edge case in cookiecutter/utils.py force_delete() — the code assumed write permission alone was sufficient for directory removal. This is true for the file content but not for the directory inode. The fix demonstrates that the most reliable approach is to understand what system calls shutil.rmtree actually performs and grant the minimum permissions each call requires.

Key insight about Unix permissions: Three operations need three different bits on a directory:

  1. Read (r): List directory entries (scandir, getdents).
  2. Write (w): Create or remove entries (unlink, rmdir, rename, creat, open with O_CREAT).
  3. Execute (x): Traverse the directory and access inodes by name (chdir, open with absolute path, stat on a child entry).

Many developers conflate “write permission” with “can delete.” For files you delete from a directory, you need write+execute on the parent directory — not on the file itself. For the directory node you want to rmdir(), you need write+execute on its grandparent and read+execute on the directory itself. The original force_delete code got this wrong because it thought about the file case alone.

Code review lens: When reviewing permission-related changes, ask: (1) Is the target a file or a directory? (2) What system calls will operate on it? (3) Does the parent directory have the right permissions too? Recipe-style permissions (chmod 777) mask bugs; explicit bit-level review catches them.

Transfer Potential

Varies — This specific fix is about Unix permission semantics on directories versus files, which is OS-specific. But the underlying pattern — a utility function that only handles the common case and silently fails on boundary inputs — transfers to any codebase. Every onerror handler, fallback path, and defensive guard deserves the same scrutiny: is the fallback actually sufficient for all inputs that reach it?

Reading this post helps recognize similar permission mismatch patterns in your own projects — especially in cleanup code, temporary directory management, and recursive filesystem operations where a single os.chmod call is expected to cover both files and directories.


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