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 viascandir(). - Execute (
0o100): to traverse the directory and access child inodes duringunlink()/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=0o400stat.S_IWRITE=0o200stat.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:
- Read (
r): List directory entries (scandir,getdents). - Write (
w): Create or remove entries (unlink,rmdir,rename,creat,openwithO_CREAT). - Execute (
x): Traverse the directory and access inodes by name (chdir,openwith absolute path,staton 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.