fix: ensure installed files are owner-writable regardless of source permissions#2884
fix: ensure installed files are owner-writable regardless of source permissions#2884rayhem wants to merge 2 commits into
Conversation
mnriem
left a comment
There was a problem hiding this comment.
Please add some positive and negative tests
There was a problem hiding this comment.
Pull request overview
This PR addresses a packaging/installation pitfall where shutil.copy2()/copytree() preserve source permission bits, causing files and directories installed from read-only sources (e.g., Nix store) to become non-writable in the project and later fail with PermissionError. It switches key copy operations to copyfile() (to avoid propagating perms/mtime) and introduces a helper to make copied directory trees owner-writable.
Changes:
- Add
ensure_writable_tree()and apply it aftercopytree()installs to force destination directories to be owner-writable. - Replace
copy2usage withcopyfilein several install/copy paths to avoid inheriting read-only perms and source mtimes. - Add regression tests covering read-only source trees for extensions/presets and unit tests for
ensure_writable_tree().
Show a summary per file
| File | Description |
|---|---|
src/specify_cli/_utils.py |
Adds ensure_writable_tree() and switches settings copy fallback from copy2 to copyfile. |
src/specify_cli/presets.py |
Uses copytree(..., copy_function=copyfile) and applies ensure_writable_tree() after preset installs. |
src/specify_cli/extensions.py |
Uses copytree(..., copy_function=copyfile) and applies ensure_writable_tree() after extension installs. |
src/specify_cli/commands/init.py |
Switches bundled workflow copy from copy2 to copyfile to avoid inheriting perms/mtime. |
tests/test_utils.py |
New unit tests for ensure_writable_tree() behavior (POSIX-focused). |
tests/test_presets.py |
Adds regression tests ensuring preset installs from read-only sources yield writable destinations. |
tests/test_extensions.py |
Adds regression tests ensuring extension installs from read-only sources yield writable destinations. |
Copilot's findings
Tip
Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- Files reviewed: 7/7 changed files
- Comments generated: 1
|
Please address Copilot feedback and rebase on upstream/main |
|
Please address Copilot feedback and rebase on upstream/main |
…ermissions shutil.copy2 and copytree (which uses copy2 by default) propagate permission bits from source to destination. When the source lives on a read-only filesystem — the Nix store, any read-only mount, or a permission-restricted directory — installed files land at 0o444 and directories at 0o555, causing PermissionError on any subsequent write to .specify/ (re-running specify init, upgrading, editing installed config). Changes: - Replace shutil.copy2 with shutil.copyfile in all install paths (_utils.py, commands/init.py, integrations/base.py, integrations/copilot) so mtime and permission bits are never propagated from the source - Add copy_file_preserving_exec() to _utils.py: copies content via copyfile then sets 0o644 | source_exec_bits, so data files land at rw-r--r-- and shell scripts at rwxr-xr-x regardless of source perms - Use copy_file_preserving_exec as copy_function in extensions.py and presets/__init__.py so bundled shell scripts remain executable after install - Add ensure_writable_tree() to _utils.py and call it after copytree in extensions and presets: walks the destination tree and ORs 0o300 onto every directory, since copytree always calls copystat() on directories even when copy_function skips it for files - Mask st_mode with & 0o7777 before all chmod calls (ensure_writable_tree and install_scripts) to strip file-type bits; passing them to chmod is undefined per POSIX and raises EINVAL on some platforms - Add unit tests for ensure_writable_tree (positive and negative) and copy_file_preserving_exec, plus regression tests asserting read-only source trees produce owner-writable, executable-where-appropriate destinations for extensions, presets, integrations, and copilot Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… permission-bit tests
| shutil.copytree(source_dir, dest_dir, ignore=ignore_fn, copy_function=copy_file_preserving_exec) | ||
| ensure_writable_tree(dest_dir) |
| from ..extensions import REINSTALL_COMMAND, ExtensionRegistry, normalize_priority | ||
| from .._init_options import is_ai_skills_enabled | ||
| from .._utils import copy_file_preserving_exec, ensure_writable_tree | ||
| from ..integrations.base import IntegrationBase | ||
| from .._utils import dump_frontmatter | ||
|
|
Description
shutil.copy2andshutil.copytree(which usescopy2by default) propagate permission bits from source to destination. When the source lives on a read-only filesystem — the Nix store, any read-only mount, or a permission-restricted directory — installed files land at0o444and directories at0o555. Any subsequent write to.specify/(re-runningspecify init, upgrading, editing installed config) then fails withPermissionError.An install operation should produce owner-writable destinations. The installed file's mtime should also reflect when it was installed, not when the bundled asset was built — so the
copy2→copyfilechange is correct on both counts.I encountered this problem attempting to package spec-kit on NixOS. The repository gets added read-only to the Nix store.
specifyassumes it can write to the files it creates, but the copy utilities preserve the read-only permissions resulting in an error.Testing
uv run specify --helpuv sync && uv run pytestAI Disclosure
Fix devised and generated by Claude Opus 4.6