mirror of
https://github.com/github/spec-kit.git
synced 2026-04-01 18:23:09 +00:00
fix: symlink safety in uninstall/setup, handle invalid JSON in load
- uninstall() now uses non-resolved path for deletion so symlinks themselves are removed, not their targets; resolve only for containment validation - setup() keeps unresolved dst_file for copy; resolves separately for project-root validation - load() catches json.JSONDecodeError and re-raises as ValueError with the manifest path for clearer diagnostics - Added test for invalid JSON manifest loading
This commit is contained in:
@@ -135,8 +135,9 @@ class IntegrationBase(ABC):
|
||||
|
||||
for src_file in sorted(tpl_dir.iterdir()):
|
||||
if src_file.is_file():
|
||||
dst_file = (dest / src_file.name).resolve()
|
||||
rel = dst_file.relative_to(project_root_resolved)
|
||||
dst_file = dest / src_file.name
|
||||
dst_resolved = dst_file.resolve()
|
||||
rel = dst_resolved.relative_to(project_root_resolved)
|
||||
shutil.copy2(src_file, dst_file)
|
||||
manifest.record_existing(rel)
|
||||
created.append(dst_file)
|
||||
|
||||
@@ -144,21 +144,24 @@ class IntegrationManifest:
|
||||
skipped: list[Path] = []
|
||||
|
||||
for rel, expected_hash in self._files.items():
|
||||
abs_path = (root / rel).resolve()
|
||||
# Skip paths that escape the project root
|
||||
# Use non-resolved path for deletion so symlinks themselves
|
||||
# are removed, not their targets.
|
||||
path = root / rel
|
||||
# Validate containment via the resolved path
|
||||
try:
|
||||
abs_path.relative_to(root)
|
||||
except ValueError:
|
||||
resolved = path.resolve()
|
||||
resolved.relative_to(root)
|
||||
except (ValueError, OSError):
|
||||
continue
|
||||
if not abs_path.exists():
|
||||
if not path.exists():
|
||||
continue
|
||||
if not force and _sha256(abs_path) != expected_hash:
|
||||
skipped.append(abs_path)
|
||||
if not force and _sha256(path) != expected_hash:
|
||||
skipped.append(path)
|
||||
continue
|
||||
abs_path.unlink()
|
||||
removed.append(abs_path)
|
||||
path.unlink()
|
||||
removed.append(path)
|
||||
# Clean up empty parent directories up to project root
|
||||
parent = abs_path.parent
|
||||
parent = path.parent
|
||||
while parent != root:
|
||||
try:
|
||||
parent.rmdir() # only succeeds if empty
|
||||
@@ -204,7 +207,12 @@ class IntegrationManifest:
|
||||
"""
|
||||
inst = cls(key, project_root)
|
||||
path = inst.manifest_path
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ValueError(
|
||||
f"Integration manifest at {path} contains invalid JSON"
|
||||
) from exc
|
||||
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError(
|
||||
|
||||
@@ -411,3 +411,10 @@ class TestManifestLoadValidation:
|
||||
path.write_text(json.dumps({"files": {"a.txt": 123}}), encoding="utf-8")
|
||||
with pytest.raises(ValueError, match="mapping"):
|
||||
IntegrationManifest.load("bad", tmp_path)
|
||||
|
||||
def test_load_invalid_json_raises(self, tmp_path):
|
||||
path = tmp_path / ".specify" / "integrations" / "bad.manifest.json"
|
||||
path.parent.mkdir(parents=True)
|
||||
path.write_text("{not valid json", encoding="utf-8")
|
||||
with pytest.raises(ValueError, match="invalid JSON"):
|
||||
IntegrationManifest.load("bad", tmp_path)
|
||||
|
||||
Reference in New Issue
Block a user