Skip to content

Commit 1b045b7

Browse files
committed
fix: preflight shared infra and future state schemas
1 parent f585770 commit 1b045b7

6 files changed

Lines changed: 210 additions & 20 deletions

File tree

src/specify_cli/__init__.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@
6161
)
6262
from .integration_state import (
6363
INTEGRATION_JSON,
64+
INTEGRATION_STATE_SCHEMA,
6465
dedupe_integration_keys as _dedupe_integration_keys,
6566
default_integration_key as _default_integration_key,
6667
installed_integration_keys as _installed_integration_keys,
@@ -1918,6 +1919,14 @@ def _read_integration_json(project_root: Path) -> dict[str, Any]:
19181919
console.print(f"[red]Error:[/red] {path} must contain a JSON object, got {type(data).__name__}.")
19191920
console.print(f"Please fix or delete {INTEGRATION_JSON} and retry.")
19201921
raise typer.Exit(1)
1922+
schema = data.get("integration_state_schema")
1923+
if isinstance(schema, int) and not isinstance(schema, bool) and schema > INTEGRATION_STATE_SCHEMA:
1924+
console.print(
1925+
f"[red]Error:[/red] {path} uses integration state schema {schema}, "
1926+
f"but this CLI only supports schema {INTEGRATION_STATE_SCHEMA}."
1927+
)
1928+
console.print("Please upgrade Spec Kit before modifying integrations.")
1929+
raise typer.Exit(1)
19211930
return _normalize_integration_state(data)
19221931

19231932

src/specify_cli/integration_state.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,12 @@ def normalize_integration_settings(settings: Any) -> dict[str, dict[str, Any]]:
6666
return normalized
6767

6868

69+
def _normalized_integration_state_schema(value: Any) -> int:
70+
if isinstance(value, int) and not isinstance(value, bool) and value > INTEGRATION_STATE_SCHEMA:
71+
return value
72+
return INTEGRATION_STATE_SCHEMA
73+
74+
6975
def normalize_integration_state(data: dict[str, Any]) -> dict[str, Any]:
7076
"""Normalize legacy and multi-install integration metadata."""
7177
legacy_key = clean_integration_key(data.get("integration"))
@@ -81,7 +87,9 @@ def normalize_integration_state(data: dict[str, Any]) -> dict[str, Any]:
8187
settings = normalize_integration_settings(data.get("integration_settings"))
8288

8389
normalized = dict(data)
84-
normalized["integration_state_schema"] = INTEGRATION_STATE_SCHEMA
90+
normalized["integration_state_schema"] = _normalized_integration_state_schema(
91+
data.get("integration_state_schema")
92+
)
8593
if default_key:
8694
normalized["integration"] = default_key
8795
normalized["default_integration"] = default_key

src/specify_cli/shared_infra.py

Lines changed: 66 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -66,15 +66,54 @@ def _shared_destination_label(project_path: Path, dest: Path) -> str:
6666
return str(dest)
6767

6868

69-
def _ensure_safe_shared_destination(project_path: Path, dest: Path) -> None:
70-
"""Refuse shared infra writes that would escape or follow symlinks."""
71-
root = project_path.resolve()
69+
def _shared_relative_path(project_path: Path, dest: Path) -> Path:
7270
try:
73-
dest.parent.resolve().relative_to(root)
74-
except (OSError, ValueError):
71+
rel = dest.relative_to(project_path)
72+
except ValueError:
73+
label = _shared_destination_label(project_path, dest)
74+
raise ValueError(f"Shared infrastructure path escapes project root: {label}") from None
75+
76+
if rel.is_absolute() or ".." in rel.parts:
7577
label = _shared_destination_label(project_path, dest)
76-
raise ValueError(f"Shared infrastructure destination escapes project root: {label}") from None
78+
raise ValueError(f"Shared infrastructure path escapes project root: {label}")
79+
return rel
80+
81+
82+
def _ensure_safe_shared_directory(project_path: Path, directory: Path, *, create: bool = True) -> None:
83+
"""Create a shared infra directory without following symlinked parents."""
84+
root = project_path.resolve()
85+
rel = _shared_relative_path(project_path, directory)
86+
current = project_path
87+
88+
for part in rel.parts:
89+
current = current / part
90+
label = _shared_destination_label(project_path, current)
91+
if current.is_symlink():
92+
raise ValueError(f"Refusing to use symlinked shared infrastructure directory: {label}")
93+
if current.exists():
94+
if not current.is_dir():
95+
raise ValueError(f"Shared infrastructure directory path is not a directory: {label}")
96+
try:
97+
current.resolve().relative_to(root)
98+
except (OSError, ValueError):
99+
raise ValueError(f"Shared infrastructure directory escapes project root: {label}") from None
100+
continue
101+
if not create:
102+
raise ValueError(f"Shared infrastructure directory does not exist: {label}")
103+
current.mkdir()
104+
if current.is_symlink():
105+
raise ValueError(f"Refusing to use symlinked shared infrastructure directory: {label}")
106+
try:
107+
current.resolve().relative_to(root)
108+
except (OSError, ValueError):
109+
raise ValueError(f"Shared infrastructure directory escapes project root: {label}") from None
77110

111+
112+
def _ensure_safe_shared_destination(project_path: Path, dest: Path) -> None:
113+
"""Refuse shared infra writes that would escape or follow symlinks."""
114+
root = project_path.resolve()
115+
_shared_relative_path(project_path, dest)
116+
_ensure_safe_shared_directory(project_path, dest.parent, create=False)
78117
label = _shared_destination_label(project_path, dest)
79118
if dest.is_symlink():
80119
raise ValueError(f"Refusing to overwrite symlinked shared infrastructure path: {label}")
@@ -90,10 +129,6 @@ def _write_shared_text(project_path: Path, dest: Path, content: str) -> None:
90129
_write_shared_bytes(project_path, dest, content.encode("utf-8"))
91130

92131

93-
def _copy_shared_file(project_path: Path, src: Path, dest: Path) -> None:
94-
_write_shared_bytes(project_path, dest, src.read_bytes(), mode=src.stat().st_mode & 0o777)
95-
96-
97132
def _write_shared_bytes(
98133
project_path: Path,
99134
dest: Path,
@@ -134,9 +169,10 @@ def refresh_shared_templates(
134169
tracked_files = manifest.files
135170
modified = set(manifest.check_modified())
136171
skipped_files: list[str] = []
172+
planned_updates: list[tuple[Path, str, str]] = []
137173

138174
dest_templates = project_path / ".specify" / "templates"
139-
dest_templates.mkdir(parents=True, exist_ok=True)
175+
_ensure_safe_shared_directory(project_path, dest_templates)
140176
for src in templates_src.iterdir():
141177
if not src.is_file() or src.name == "vscode-settings.json" or src.name.startswith("."):
142178
continue
@@ -151,6 +187,9 @@ def refresh_shared_templates(
151187

152188
content = src.read_text(encoding="utf-8")
153189
content = IntegrationBase.resolve_command_refs(content, invoke_separator)
190+
planned_updates.append((dst, rel, content))
191+
192+
for dst, rel, content in planned_updates:
154193
_write_shared_text(project_path, dst, content)
155194
manifest.record_existing(rel)
156195

@@ -178,16 +217,18 @@ def install_shared_infra(
178217
"""Install shared scripts and templates into *project_path*."""
179218
manifest = load_speckit_manifest(project_path, version=version, console=console)
180219
skipped_files: list[str] = []
220+
planned_copies: list[tuple[Path, str, bytes, int]] = []
221+
planned_templates: list[tuple[Path, str, str]] = []
181222

182223
scripts_src = shared_scripts_source(core_pack=core_pack, repo_root=repo_root)
183224
if scripts_src.is_dir():
184225
dest_scripts = project_path / ".specify" / "scripts"
185-
dest_scripts.mkdir(parents=True, exist_ok=True)
226+
_ensure_safe_shared_directory(project_path, dest_scripts)
186227
variant_dir = "bash" if script_type == "sh" else "powershell"
187228
variant_src = scripts_src / variant_dir
188229
if variant_src.is_dir():
189230
dest_variant = dest_scripts / variant_dir
190-
dest_variant.mkdir(parents=True, exist_ok=True)
231+
_ensure_safe_shared_directory(project_path, dest_variant)
191232
for src_path in variant_src.rglob("*"):
192233
if not src_path.is_file():
193234
continue
@@ -199,15 +240,14 @@ def install_shared_infra(
199240
skipped_files.append(str(dst_path.relative_to(project_path)))
200241
continue
201242

202-
dst_path.parent.mkdir(parents=True, exist_ok=True)
203-
_copy_shared_file(project_path, src_path, dst_path)
243+
_ensure_safe_shared_directory(project_path, dst_path.parent)
204244
rel = dst_path.relative_to(project_path).as_posix()
205-
manifest.record_existing(rel)
245+
planned_copies.append((dst_path, rel, src_path.read_bytes(), src_path.stat().st_mode & 0o777))
206246

207247
templates_src = shared_templates_source(core_pack=core_pack, repo_root=repo_root)
208248
if templates_src.is_dir():
209249
dest_templates = project_path / ".specify" / "templates"
210-
dest_templates.mkdir(parents=True, exist_ok=True)
250+
_ensure_safe_shared_directory(project_path, dest_templates)
211251
for src in templates_src.iterdir():
212252
if not src.is_file() or src.name == "vscode-settings.json" or src.name.startswith("."):
213253
continue
@@ -220,9 +260,16 @@ def install_shared_infra(
220260

221261
content = src.read_text(encoding="utf-8")
222262
content = IntegrationBase.resolve_command_refs(content, invoke_separator)
223-
_write_shared_text(project_path, dst, content)
224263
rel = dst.relative_to(project_path).as_posix()
225-
manifest.record_existing(rel)
264+
planned_templates.append((dst, rel, content))
265+
266+
for dst_path, rel, content, mode in planned_copies:
267+
_write_shared_bytes(project_path, dst_path, content, mode=mode)
268+
manifest.record_existing(rel)
269+
270+
for dst, rel, content in planned_templates:
271+
_write_shared_text(project_path, dst, content)
272+
manifest.record_existing(rel)
226273

227274
if skipped_files:
228275
console.print(

tests/integrations/test_cli.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@
99
from tests.conftest import strip_ansi
1010

1111

12+
class _NoopConsole:
13+
def print(self, *args, **kwargs):
14+
pass
15+
16+
1217
def _normalize_cli_output(output: str) -> str:
1318
output = strip_ansi(output)
1419
output = " ".join(output.split())
@@ -349,6 +354,95 @@ def test_shared_template_refresh_refuses_symlinked_destination(self, tmp_path):
349354

350355
assert outside.read_text(encoding="utf-8") == "# outside\n"
351356

357+
@pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable")
358+
def test_shared_infra_refuses_symlinked_specify_directory_before_mkdir(self, tmp_path):
359+
"""Shared infra directory creation must not follow a symlinked .specify."""
360+
from specify_cli import _install_shared_infra
361+
362+
project = tmp_path / "symlink-dir-test"
363+
project.mkdir()
364+
outside = tmp_path / "outside-specify"
365+
outside.mkdir()
366+
os.symlink(outside, project / ".specify")
367+
368+
with pytest.raises(ValueError, match="symlinked shared infrastructure directory"):
369+
_install_shared_infra(project, "sh", force=True)
370+
371+
assert not (outside / "scripts").exists()
372+
assert not (outside / "templates").exists()
373+
374+
@pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable")
375+
def test_shared_template_refresh_preflights_before_writing(self, tmp_path):
376+
"""Template refresh validates all destinations before writing any file."""
377+
from specify_cli.shared_infra import refresh_shared_templates
378+
379+
project = tmp_path / "preflight-refresh-test"
380+
project.mkdir()
381+
templates_dir = project / ".specify" / "templates"
382+
templates_dir.mkdir(parents=True)
383+
384+
core_pack = tmp_path / "core-pack"
385+
templates_src = core_pack / "templates"
386+
templates_src.mkdir(parents=True)
387+
(templates_src / "a-template.md").write_text("# new a\n", encoding="utf-8")
388+
(templates_src / "z-template.md").write_text("# new z\n", encoding="utf-8")
389+
390+
existing = templates_dir / "a-template.md"
391+
existing.write_text("# old a\n", encoding="utf-8")
392+
outside = tmp_path / "outside-z.md"
393+
outside.write_text("# outside\n", encoding="utf-8")
394+
os.symlink(outside, templates_dir / "z-template.md")
395+
396+
with pytest.raises(ValueError, match="Refusing to overwrite symlinked"):
397+
refresh_shared_templates(
398+
project,
399+
version="test",
400+
core_pack=core_pack,
401+
repo_root=tmp_path / "unused",
402+
console=_NoopConsole(),
403+
invoke_separator=".",
404+
force=True,
405+
)
406+
407+
assert existing.read_text(encoding="utf-8") == "# old a\n"
408+
assert outside.read_text(encoding="utf-8") == "# outside\n"
409+
410+
@pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable")
411+
def test_shared_infra_install_preflights_before_writing(self, tmp_path):
412+
"""Full shared infra installs validate destinations before writing any file."""
413+
from specify_cli.shared_infra import install_shared_infra
414+
415+
project = tmp_path / "preflight-install-test"
416+
project.mkdir()
417+
scripts_dir = project / ".specify" / "scripts" / "bash"
418+
scripts_dir.mkdir(parents=True)
419+
420+
core_pack = tmp_path / "core-pack"
421+
scripts_src = core_pack / "scripts" / "bash"
422+
scripts_src.mkdir(parents=True)
423+
(scripts_src / "a.sh").write_text("# new a\n", encoding="utf-8")
424+
(scripts_src / "z.sh").write_text("# new z\n", encoding="utf-8")
425+
426+
existing = scripts_dir / "a.sh"
427+
existing.write_text("# old a\n", encoding="utf-8")
428+
outside = tmp_path / "outside-z.sh"
429+
outside.write_text("# outside\n", encoding="utf-8")
430+
os.symlink(outside, scripts_dir / "z.sh")
431+
432+
with pytest.raises(ValueError, match="Refusing to overwrite symlinked"):
433+
install_shared_infra(
434+
project,
435+
"sh",
436+
version="test",
437+
core_pack=core_pack,
438+
repo_root=tmp_path / "unused",
439+
console=_NoopConsole(),
440+
force=True,
441+
)
442+
443+
assert existing.read_text(encoding="utf-8") == "# old a\n"
444+
assert outside.read_text(encoding="utf-8") == "# outside\n"
445+
352446
def test_shared_infra_no_warning_when_forced(self, tmp_path, capsys):
353447
"""No skip warning when force=True (all files overwritten)."""
354448
from specify_cli import _install_shared_infra

tests/integrations/test_integration_state.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,20 @@ def test_normalize_integration_state_strips_legacy_key_fallback():
3838
assert state["installed_integrations"] == ["codex"]
3939

4040

41+
def test_normalize_integration_state_preserves_newer_schema():
42+
state = normalize_integration_state(
43+
{
44+
"integration_state_schema": 99,
45+
"integration": "claude",
46+
"installed_integrations": ["claude"],
47+
"future_field": {"keep": True},
48+
}
49+
)
50+
51+
assert state["integration_state_schema"] == 99
52+
assert state["future_field"] == {"keep": True}
53+
54+
4155
def test_default_integration_key_strips_raw_state_values():
4256
assert default_integration_key({"default_integration": " claude "}) == "claude"
4357
assert default_integration_key({"integration": " codex "}) == "codex"

tests/integrations/test_integration_subcommand.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,24 @@ def test_list_shows_multi_install_safe_status(self, tmp_path):
9696
assert _integration_list_row_cells(result.output, "claude")[-1] == "yes"
9797
assert _integration_list_row_cells(result.output, "copilot")[-1] == "no"
9898

99+
def test_list_rejects_newer_integration_state_schema(self, tmp_path):
100+
project = _init_project(tmp_path, "claude")
101+
int_json = project / ".specify" / "integration.json"
102+
data = json.loads(int_json.read_text(encoding="utf-8"))
103+
data["integration_state_schema"] = 99
104+
int_json.write_text(json.dumps(data), encoding="utf-8")
105+
106+
old_cwd = os.getcwd()
107+
try:
108+
os.chdir(project)
109+
result = runner.invoke(app, ["integration", "list"])
110+
finally:
111+
os.chdir(old_cwd)
112+
113+
assert result.exit_code != 0
114+
assert "schema 99" in result.output
115+
assert "only supports schema 1" in result.output
116+
99117

100118
# ── install ──────────────────────────────────────────────────────────
101119

0 commit comments

Comments
 (0)