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 1 commit 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 |
…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>
| def test_copy_command_to_directory_readonly_source_is_writable(self, tmp_path): | ||
| src = tmp_path / "source.md" | ||
| src.write_text("content", encoding="utf-8") | ||
| src.chmod(0o444) | ||
| dest_dir = tmp_path / "output" | ||
| result = IntegrationBase.copy_command_to_directory(src, dest_dir, "speckit.plan.md") | ||
| assert result.stat().st_mode & 0o200, "destination must be owner-writable" | ||
|
|
| def test_install_scripts_readonly_source_files_are_writable(self, tmp_path, monkeypatch): | ||
| scripts_src = tmp_path / "scripts_src" | ||
| scripts_src.mkdir() | ||
| helper = scripts_src / "helper.sh" | ||
| helper.write_text("#!/bin/sh\necho hi\n") | ||
| helper.chmod(0o444) | ||
| data = scripts_src / "data.txt" | ||
| data.write_text("payload") | ||
| data.chmod(0o444) | ||
|
|
||
| project = tmp_path / "project" | ||
| project.mkdir() | ||
| i = StubIntegration() | ||
| m = IntegrationManifest("stub", project) | ||
| monkeypatch.setattr(i, "integration_scripts_dir", lambda: scripts_src) | ||
| created = i.install_scripts(project, m) | ||
|
|
||
| assert len(created) == 2 | ||
| for dst in created: | ||
| assert dst.stat().st_mode & 0o200, f"{dst.name} must be owner-writable" | ||
|
|
| def test_setup_vscode_settings_readonly_source_is_writable(self, tmp_path, monkeypatch): | ||
| from specify_cli.integrations.copilot import CopilotIntegration | ||
| import json as _json | ||
| copilot = CopilotIntegration() | ||
| # Simulate a bundled settings file with read-only store permissions. | ||
| ro_src = tmp_path / "ro_settings.json" | ||
| ro_src.write_text(_json.dumps({"foo": "bar"})) | ||
| ro_src.chmod(0o444) | ||
| monkeypatch.setattr(copilot, "_vscode_settings_path", lambda: ro_src) | ||
| m = IntegrationManifest("copilot", tmp_path) | ||
| copilot.setup(tmp_path, m) | ||
| settings = tmp_path / ".vscode" / "settings.json" | ||
| assert settings.stat().st_mode & 0o200, ".vscode/settings.json must be owner-writable" | ||
|
|
| def copy_file_preserving_exec(src: str, dst: str) -> None: | ||
| """Copy file content without propagating source mtime or read-only bits. | ||
|
|
||
| shutil.copyfile transfers only content, leaving destination permissions at | ||
| the umask default (typically 0o644). This wrapper additionally re-applies | ||
| any execute bits present on the source so that installed shell scripts | ||
| remain executable while still being owner-writable. | ||
| """ | ||
| shutil.copyfile(src, dst) | ||
| if os.name != "nt": | ||
| exec_bits = os.stat(src).st_mode & 0o111 | ||
| os.chmod(dst, 0o644 | exec_bits) | ||
|
|
|
Please address Copilot feedback and rebase on upstream/main |
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