Skip to content

Commit 49bf5e2

Browse files
rayhemclaude
andcommitted
fix: extend copy2→copyfile fix to integrations/base and copilot
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>
1 parent 70a9ef5 commit 49bf5e2

4 files changed

Lines changed: 46 additions & 3 deletions

File tree

src/specify_cli/integrations/base.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -428,7 +428,7 @@ def copy_command_to_directory(
428428
"""
429429
dest_dir.mkdir(parents=True, exist_ok=True)
430430
dst = dest_dir / filename
431-
shutil.copy2(src, dst)
431+
shutil.copyfile(src, dst)
432432
return dst
433433

434434
@staticmethod
@@ -504,7 +504,7 @@ def install_scripts(
504504
if not src_script.is_file():
505505
continue
506506
dst_script = scripts_dest / src_script.name
507-
shutil.copy2(src_script, dst_script)
507+
shutil.copyfile(src_script, dst_script)
508508
if dst_script.suffix == ".sh":
509509
dst_script.chmod(dst_script.stat().st_mode | 0o111)
510510
self.record_file_in_manifest(dst_script, project_root, manifest)

src/specify_cli/integrations/copilot/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -380,7 +380,7 @@ def _setup_default(
380380
# remove the user's settings file on uninstall.
381381
self._merge_vscode_settings(settings_src, dst_settings)
382382
else:
383-
shutil.copy2(settings_src, dst_settings)
383+
shutil.copyfile(settings_src, dst_settings)
384384
self.record_file_in_manifest(dst_settings, project_root, manifest)
385385
created.append(dst_settings)
386386

tests/integrations/test_base.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,14 @@ def test_copy_command_to_directory(self, tmp_path):
149149
assert result == dest_dir / "speckit.plan.md"
150150
assert result.read_text(encoding="utf-8") == "content"
151151

152+
def test_copy_command_to_directory_readonly_source_is_writable(self, tmp_path):
153+
src = tmp_path / "source.md"
154+
src.write_text("content", encoding="utf-8")
155+
src.chmod(0o444)
156+
dest_dir = tmp_path / "output"
157+
result = IntegrationBase.copy_command_to_directory(src, dest_dir, "speckit.plan.md")
158+
assert result.stat().st_mode & 0o200, "destination must be owner-writable"
159+
152160
def test_record_file_in_manifest(self, tmp_path):
153161
f = tmp_path / "f.txt"
154162
f.write_text("hello", encoding="utf-8")
@@ -164,6 +172,27 @@ def test_write_file_and_record(self, tmp_path):
164172
assert dest.read_text(encoding="utf-8") == "content"
165173
assert "sub/f.txt" in m.files
166174

175+
def test_install_scripts_readonly_source_files_are_writable(self, tmp_path, monkeypatch):
176+
scripts_src = tmp_path / "scripts_src"
177+
scripts_src.mkdir()
178+
helper = scripts_src / "helper.sh"
179+
helper.write_text("#!/bin/sh\necho hi\n")
180+
helper.chmod(0o444)
181+
data = scripts_src / "data.txt"
182+
data.write_text("payload")
183+
data.chmod(0o444)
184+
185+
project = tmp_path / "project"
186+
project.mkdir()
187+
i = StubIntegration()
188+
m = IntegrationManifest("stub", project)
189+
monkeypatch.setattr(i, "integration_scripts_dir", lambda: scripts_src)
190+
created = i.install_scripts(project, m)
191+
192+
assert len(created) == 2
193+
for dst in created:
194+
assert dst.stat().st_mode & 0o200, f"{dst.name} must be owner-writable"
195+
167196
def test_setup_copies_shared_templates(self, tmp_path):
168197
i = StubIntegration()
169198
m = IntegrationManifest("stub", tmp_path)

tests/integrations/test_integration_copilot.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,20 @@ def test_setup_creates_vscode_settings_new(self, tmp_path):
6767
assert settings in created
6868
assert any("settings.json" in k for k in m.files)
6969

70+
def test_setup_vscode_settings_readonly_source_is_writable(self, tmp_path, monkeypatch):
71+
from specify_cli.integrations.copilot import CopilotIntegration
72+
import json as _json
73+
copilot = CopilotIntegration()
74+
# Simulate a bundled settings file with read-only store permissions.
75+
ro_src = tmp_path / "ro_settings.json"
76+
ro_src.write_text(_json.dumps({"foo": "bar"}))
77+
ro_src.chmod(0o444)
78+
monkeypatch.setattr(copilot, "_vscode_settings_path", lambda: ro_src)
79+
m = IntegrationManifest("copilot", tmp_path)
80+
copilot.setup(tmp_path, m)
81+
settings = tmp_path / ".vscode" / "settings.json"
82+
assert settings.stat().st_mode & 0o200, ".vscode/settings.json must be owner-writable"
83+
7084
def test_setup_merges_existing_vscode_settings(self, tmp_path):
7185
from specify_cli.integrations.copilot import CopilotIntegration
7286
copilot = CopilotIntegration()

0 commit comments

Comments
 (0)