Skip to content

fix: ensure installed files are owner-writable regardless of source permissions#2884

Open
rayhem wants to merge 5 commits into
github:mainfrom
rayhem:main
Open

fix: ensure installed files are owner-writable regardless of source permissions#2884
rayhem wants to merge 5 commits into
github:mainfrom
rayhem:main

Conversation

@rayhem

@rayhem rayhem commented Jun 6, 2026

Copy link
Copy Markdown

Description

shutil.copy2 and shutil.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. Any subsequent write to .specify/ (re-running specify init, upgrading, editing installed config) then fails with PermissionError.

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 copy2copyfile change 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. specify assumes it can write to the files it creates, but the copy utilities preserve the read-only permissions resulting in an error.

Testing

  • Tested locally with uv run specify --help
  • Ran existing tests with uv sync && uv run pytest
  • Tested with a sample project (if applicable)

AI Disclosure

  • I did not use AI assistance for this contribution
  • I did use AI assistance (describe below)

Fix devised and generated by Claude Opus 4.6

…ermissions

shutil.copy2 and copytree propagate permission bits from the source,
leaving destination files at 0o444 and dirs at 0o555 when copying from
any read-only source (Nix store, read-only mounts, etc.). Subsequent
writes to .specify/ then fail with PermissionError.

Replace copy2 with copyfile (content-only, no permission bits) at all
four install-path call sites. Add ensure_writable_tree() to fix directory
permissions after copytree calls — copytree always stamps dest dirs via
copystat() regardless of copy_function.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@rayhem rayhem requested a review from mnriem as a code owner June 6, 2026 16:44

@mnriem mnriem left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please add some positive and negative tests

rayhem and others added 2 commits June 13, 2026 14:44
…ad-only source installs

Unit tests for ensure_writable_tree() in test_utils.py:
- read-only dirs gain owner w+x bits
- existing writable permissions preserved
- nested read-only tree fully fixed
- file permissions left untouched
- no-op on Windows (os.name == 'nt')
- OSError on individual chmod silently swallowed
- empty directory handled without error

Integration tests in test_extensions.py and test_presets.py:
- install_from_directory with read-only source produces writable dest dirs
- install_from_directory with read-only source produces writable dest files

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 after copytree() installs to force destination directories to be owner-writable.
  • Replace copy2 usage with copyfile in 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

Comment thread src/specify_cli/_utils.py
Comment on lines +233 to +237
shutil.copytree always calls copystat() on directories, propagating
read-only bits from any read-only source. Call this after copytree to
guarantee destination directories accept writes regardless of source
permissions.
"""
rayhem and others added 2 commits June 16, 2026 11:56
Two install paths missed in f70bdf9: copy_command_to_directory(),
install_scripts() in IntegrationBase, and the vscode-settings.json
copy in CopilotIntegration all used copy2, propagating 0o444/0o555
from bundled package data (Nix store) to destination files.

Switch to copyfile at all three sites. Add permission regression tests
for each.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot's findings

  • Files reviewed: 11/11 changed files
  • Comments generated: 4

Comment thread src/specify_cli/_utils.py
for dirpath, _, _ in os.walk(path):
dp = Path(dirpath)
try:
dp.chmod(dp.stat().st_mode | 0o300)
shutil.copy2(src_script, dst_script)
shutil.copyfile(src_script, dst_script)
if dst_script.suffix == ".sh":
dst_script.chmod(dst_script.stat().st_mode | 0o111)
Comment on lines 1360 to +1362
ignore_fn = self._load_extensionignore(source_dir)
shutil.copytree(source_dir, dest_dir, ignore=ignore_fn)
shutil.copytree(source_dir, dest_dir, ignore=ignore_fn, copy_function=shutil.copyfile)
ensure_writable_tree(dest_dir)
Comment on lines +1538 to +1539
shutil.copytree(source_dir, dest_dir, copy_function=shutil.copyfile)
ensure_writable_tree(dest_dir)
@mnriem

mnriem commented Jun 16, 2026

Copy link
Copy Markdown
Collaborator

Please address Copilot feedback and rebase on upstream/main

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants