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 | ||
|
|
mnriem
left a comment
There was a problem hiding this comment.
Please address Copilot feedback
|
This pull request has been automatically marked as stale because it has not had any activity for 60 days. It will be closed in 30 days if no further activity occurs. |
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