Skip to content

Commit 4321655

Browse files
committed
fix: refuse symlinked shared infra paths
1 parent 8ed8910 commit 4321655

2 files changed

Lines changed: 91 additions & 0 deletions

File tree

src/specify_cli/shared_infra.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,33 @@ def shared_scripts_source(
5858
return repo_root / "scripts"
5959

6060

61+
def _shared_destination_label(project_path: Path, dest: Path) -> str:
62+
try:
63+
return dest.relative_to(project_path).as_posix()
64+
except ValueError:
65+
return str(dest)
66+
67+
68+
def _ensure_safe_shared_destination(project_path: Path, dest: Path) -> None:
69+
"""Refuse shared infra writes that would escape or follow symlinks."""
70+
root = project_path.resolve()
71+
try:
72+
dest.parent.resolve().relative_to(root)
73+
except (OSError, ValueError):
74+
label = _shared_destination_label(project_path, dest)
75+
raise ValueError(f"Shared infrastructure destination escapes project root: {label}") from None
76+
77+
label = _shared_destination_label(project_path, dest)
78+
if dest.is_symlink():
79+
raise ValueError(f"Refusing to overwrite symlinked shared infrastructure path: {label}")
80+
81+
if dest.exists():
82+
try:
83+
dest.resolve().relative_to(root)
84+
except (OSError, ValueError):
85+
raise ValueError(f"Shared infrastructure destination escapes project root: {label}") from None
86+
87+
6188
def refresh_shared_templates(
6289
project_path: Path,
6390
*,
@@ -85,6 +112,7 @@ def refresh_shared_templates(
85112
continue
86113

87114
dst = dest_templates / src.name
115+
_ensure_safe_shared_destination(project_path, dst)
88116
rel = dst.relative_to(project_path).as_posix()
89117
if dst.exists() and not force:
90118
if rel not in tracked_files or rel in modified:
@@ -136,6 +164,7 @@ def install_shared_infra(
136164

137165
rel_path = src_path.relative_to(variant_src)
138166
dst_path = dest_variant / rel_path
167+
_ensure_safe_shared_destination(project_path, dst_path)
139168
if dst_path.exists() and not force:
140169
skipped_files.append(str(dst_path.relative_to(project_path)))
141170
continue
@@ -154,6 +183,7 @@ def install_shared_infra(
154183
continue
155184

156185
dst = dest_templates / src.name
186+
_ensure_safe_shared_destination(project_path, dst)
157187
if dst.exists() and not force:
158188
skipped_files.append(str(dst.relative_to(project_path)))
159189
continue

tests/integrations/test_cli.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import json
44
import os
55

6+
import pytest
67
import yaml
78

89
from tests.conftest import strip_ansi
@@ -288,6 +289,66 @@ def test_shared_infra_warns_when_manifest_cannot_be_decoded(self, tmp_path, caps
288289
assert "Could not read shared infrastructure manifest" in captured.out
289290
assert "A new shared manifest will be created" in captured.out
290291

292+
@pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable")
293+
def test_shared_infra_refuses_symlinked_script_destination(self, tmp_path):
294+
"""Shared script refreshes must not follow destination symlinks."""
295+
from specify_cli import _install_shared_infra
296+
297+
project = tmp_path / "symlink-script-test"
298+
project.mkdir()
299+
(project / ".specify").mkdir()
300+
301+
outside = tmp_path / "outside-script.sh"
302+
outside.write_text("# outside\n", encoding="utf-8")
303+
scripts_dir = project / ".specify" / "scripts" / "bash"
304+
scripts_dir.mkdir(parents=True)
305+
os.symlink(outside, scripts_dir / "common.sh")
306+
307+
with pytest.raises(ValueError, match="Refusing to overwrite symlinked"):
308+
_install_shared_infra(project, "sh", force=True)
309+
310+
assert outside.read_text(encoding="utf-8") == "# outside\n"
311+
312+
@pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable")
313+
def test_shared_infra_refuses_symlinked_template_destination(self, tmp_path):
314+
"""Shared template installs must not follow destination symlinks."""
315+
from specify_cli import _install_shared_infra
316+
317+
project = tmp_path / "symlink-template-test"
318+
project.mkdir()
319+
(project / ".specify").mkdir()
320+
321+
outside = tmp_path / "outside-template.md"
322+
outside.write_text("# outside\n", encoding="utf-8")
323+
templates_dir = project / ".specify" / "templates"
324+
templates_dir.mkdir(parents=True)
325+
os.symlink(outside, templates_dir / "plan-template.md")
326+
327+
with pytest.raises(ValueError, match="Refusing to overwrite symlinked"):
328+
_install_shared_infra(project, "sh", force=True)
329+
330+
assert outside.read_text(encoding="utf-8") == "# outside\n"
331+
332+
@pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable")
333+
def test_shared_template_refresh_refuses_symlinked_destination(self, tmp_path):
334+
"""Template-only refreshes must not follow destination symlinks."""
335+
from specify_cli import _refresh_shared_templates
336+
337+
project = tmp_path / "symlink-refresh-test"
338+
project.mkdir()
339+
(project / ".specify").mkdir()
340+
341+
outside = tmp_path / "outside-refresh.md"
342+
outside.write_text("# outside\n", encoding="utf-8")
343+
templates_dir = project / ".specify" / "templates"
344+
templates_dir.mkdir(parents=True)
345+
os.symlink(outside, templates_dir / "plan-template.md")
346+
347+
with pytest.raises(ValueError, match="Refusing to overwrite symlinked"):
348+
_refresh_shared_templates(project, invoke_separator=".", force=True)
349+
350+
assert outside.read_text(encoding="utf-8") == "# outside\n"
351+
291352
def test_shared_infra_no_warning_when_forced(self, tmp_path, capsys):
292353
"""No skip warning when force=True (all files overwritten)."""
293354
from specify_cli import _install_shared_infra

0 commit comments

Comments
 (0)