Skip to content

Commit 0dce8bf

Browse files
committed
fix: harden shared manifest and upgrade refresh
1 parent 4c0bc7e commit 0dce8bf

4 files changed

Lines changed: 141 additions & 10 deletions

File tree

src/specify_cli/__init__.py

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2926,7 +2926,6 @@ def integration_upgrade(
29262926
script_type=selected_script,
29272927
raw_options=raw_options,
29282928
)
2929-
new_manifest.save()
29302929
settings = _with_integration_setting(
29312930
current,
29322931
key,
@@ -2935,16 +2934,23 @@ def integration_upgrade(
29352934
raw_options=raw_options,
29362935
parsed_options=parsed_options,
29372936
)
2937+
if installed_key == key:
2938+
try:
2939+
_refresh_shared_templates(
2940+
project_root,
2941+
invoke_separator=_invoke_separator_for_integration(
2942+
integration, {"integration_settings": settings}, key, parsed_options
2943+
),
2944+
force=force,
2945+
)
2946+
except (ValueError, OSError) as exc:
2947+
raise _SharedTemplateRefreshError(
2948+
f"Failed to refresh shared templates for '{key}': {exc}"
2949+
) from exc
2950+
new_manifest.save()
29382951
_write_integration_json(project_root, installed_key, installed_keys, settings)
29392952
if installed_key == key:
29402953
_update_init_options_for_integration(project_root, integration, script_type=selected_script)
2941-
_refresh_shared_templates(
2942-
project_root,
2943-
invoke_separator=_invoke_separator_for_integration(
2944-
integration, {"integration_settings": settings}, key, parsed_options
2945-
),
2946-
force=force,
2947-
)
29482954
except Exception as exc:
29492955
# Don't teardown — setup overwrites in-place, so teardown would
29502956
# delete files that were working before the upgrade. Just report.

src/specify_cli/integrations/manifest.py

Lines changed: 67 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import hashlib
1212
import json
1313
import os
14+
import tempfile
1415
from datetime import datetime, timezone
1516
from pathlib import Path
1617
from typing import Any
@@ -47,6 +48,59 @@ def _validate_rel_path(rel: Path, root: Path) -> Path:
4748
return resolved
4849

4950

51+
def _manifest_path_label(root: Path, path: Path) -> str:
52+
try:
53+
return path.relative_to(root).as_posix()
54+
except ValueError:
55+
return path.as_posix()
56+
57+
58+
def _ensure_safe_manifest_directory(root: Path, directory: Path) -> None:
59+
"""Create a manifest directory without following symlinked parents."""
60+
root_resolved = root.resolve()
61+
try:
62+
rel = directory.relative_to(root)
63+
except ValueError:
64+
label = _manifest_path_label(root, directory)
65+
raise ValueError(f"Integration manifest directory escapes project root: {label}") from None
66+
67+
current = root
68+
for part in rel.parts:
69+
current = current / part
70+
label = _manifest_path_label(root, current)
71+
if current.is_symlink():
72+
raise ValueError(f"Refusing to use symlinked integration manifest directory: {label}")
73+
if current.exists():
74+
if not current.is_dir():
75+
raise ValueError(f"Integration manifest directory path is not a directory: {label}")
76+
try:
77+
current.resolve().relative_to(root_resolved)
78+
except (OSError, ValueError):
79+
raise ValueError(f"Integration manifest directory escapes project root: {label}") from None
80+
continue
81+
current.mkdir()
82+
try:
83+
current.resolve().relative_to(root_resolved)
84+
except (OSError, ValueError):
85+
raise ValueError(f"Integration manifest directory escapes project root: {label}") from None
86+
87+
88+
def _ensure_safe_manifest_destination(root: Path, path: Path) -> None:
89+
"""Refuse manifest writes that would escape the project or follow symlinks."""
90+
root_resolved = root.resolve()
91+
_ensure_safe_manifest_directory(root, path.parent)
92+
label = _manifest_path_label(root, path)
93+
if path.is_symlink():
94+
raise ValueError(f"Refusing to overwrite symlinked integration manifest path: {label}")
95+
if path.exists():
96+
if not path.is_file():
97+
raise ValueError(f"Integration manifest path is not a file: {label}")
98+
try:
99+
path.resolve().relative_to(root_resolved)
100+
except (OSError, ValueError):
101+
raise ValueError(f"Integration manifest path escapes project root: {label}") from None
102+
103+
50104
class IntegrationManifest:
51105
"""Tracks files installed by a single integration.
52106
@@ -217,8 +271,19 @@ def save(self) -> Path:
217271
"files": self._files,
218272
}
219273
path = self.manifest_path
220-
path.parent.mkdir(parents=True, exist_ok=True)
221-
path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
274+
content = json.dumps(data, indent=2) + "\n"
275+
_ensure_safe_manifest_destination(self.project_root, path)
276+
fd, temp_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
277+
temp_path = Path(temp_name)
278+
try:
279+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
280+
fh.write(content)
281+
temp_path.chmod(0o644)
282+
_ensure_safe_manifest_destination(self.project_root, path)
283+
os.replace(temp_path, path)
284+
finally:
285+
if temp_path.exists():
286+
temp_path.unlink()
222287
return path
223288

224289
@classmethod

tests/integrations/test_cli.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -373,6 +373,38 @@ def test_shared_infra_refuses_symlinked_specify_directory_before_mkdir(self, tmp
373373
assert not (outside / "scripts").exists()
374374
assert not (outside / "templates").exists()
375375

376+
@pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable")
377+
def test_shared_infra_refuses_symlinked_shared_manifest(self, tmp_path):
378+
"""Shared infra manifest saves must not follow destination symlinks."""
379+
from specify_cli.shared_infra import install_shared_infra
380+
381+
project = tmp_path / "symlink-shared-manifest-test"
382+
project.mkdir()
383+
integrations_dir = project / ".specify" / "integrations"
384+
integrations_dir.mkdir(parents=True)
385+
386+
outside = tmp_path / "outside-manifest.json"
387+
outside.write_text("# outside\n", encoding="utf-8")
388+
os.symlink(outside, integrations_dir / "speckit.manifest.json")
389+
390+
core_pack = tmp_path / "core-pack"
391+
templates_src = core_pack / "templates"
392+
templates_src.mkdir(parents=True)
393+
(templates_src / "plan-template.md").write_text("# plan\n", encoding="utf-8")
394+
395+
with pytest.raises(ValueError, match="symlinked integration manifest"):
396+
install_shared_infra(
397+
project,
398+
"sh",
399+
version="test",
400+
core_pack=core_pack,
401+
repo_root=tmp_path / "unused",
402+
console=_NoopConsole(),
403+
force=True,
404+
)
405+
406+
assert outside.read_text(encoding="utf-8") == "# outside\n"
407+
376408
@pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable")
377409
def test_shared_template_refresh_preflights_before_writing(self, tmp_path):
378410
"""Template refresh validates all destinations before writing any file."""

tests/integrations/test_integration_subcommand.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -967,6 +967,34 @@ def test_upgrade_invalid_manifest_reports_cli_error(self, tmp_path):
967967
assert "manifest" in result.output
968968
assert "unreadable" in result.output
969969

970+
def test_upgrade_does_not_persist_state_when_template_refresh_fails(self, tmp_path, monkeypatch):
971+
project = _init_project(tmp_path, "claude")
972+
int_json = project / ".specify" / "integration.json"
973+
init_options = project / ".specify" / "init-options.json"
974+
manifest_path = project / ".specify" / "integrations" / "claude.manifest.json"
975+
976+
before_state = json.loads(int_json.read_text(encoding="utf-8"))
977+
before_options = json.loads(init_options.read_text(encoding="utf-8"))
978+
before_manifest = manifest_path.read_text(encoding="utf-8")
979+
980+
import specify_cli
981+
982+
def fail_refresh(*args, **kwargs):
983+
raise ValueError("refuse refresh")
984+
985+
monkeypatch.setattr(specify_cli, "_refresh_shared_templates", fail_refresh)
986+
987+
result = _run_in_project(project, [
988+
"integration", "upgrade", "claude",
989+
"--force",
990+
])
991+
992+
assert result.exit_code != 0
993+
assert "Failed to refresh shared templates" in result.output
994+
assert json.loads(int_json.read_text(encoding="utf-8")) == before_state
995+
assert json.loads(init_options.read_text(encoding="utf-8")) == before_options
996+
assert manifest_path.read_text(encoding="utf-8") == before_manifest
997+
970998
def test_upgrade_non_default_keeps_default_template_invocations(self, tmp_path):
971999
project = _init_project(tmp_path, "gemini")
9721000
template = project / ".specify" / "templates" / "plan-template.md"

0 commit comments

Comments
 (0)