Skip to content

Commit 7ccbf69

Browse files
committed
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
1 parent 868bfd0 commit 7ccbf69

3 files changed

Lines changed: 29 additions & 13 deletions

File tree

src/specify_cli/integrations/base.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -135,8 +135,9 @@ def setup(
135135

136136
for src_file in sorted(tpl_dir.iterdir()):
137137
if src_file.is_file():
138-
dst_file = (dest / src_file.name).resolve()
139-
rel = dst_file.relative_to(project_root_resolved)
138+
dst_file = dest / src_file.name
139+
dst_resolved = dst_file.resolve()
140+
rel = dst_resolved.relative_to(project_root_resolved)
140141
shutil.copy2(src_file, dst_file)
141142
manifest.record_existing(rel)
142143
created.append(dst_file)

src/specify_cli/integrations/manifest.py

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -144,21 +144,24 @@ def uninstall(
144144
skipped: list[Path] = []
145145

146146
for rel, expected_hash in self._files.items():
147-
abs_path = (root / rel).resolve()
148-
# Skip paths that escape the project root
147+
# Use non-resolved path for deletion so symlinks themselves
148+
# are removed, not their targets.
149+
path = root / rel
150+
# Validate containment via the resolved path
149151
try:
150-
abs_path.relative_to(root)
151-
except ValueError:
152+
resolved = path.resolve()
153+
resolved.relative_to(root)
154+
except (ValueError, OSError):
152155
continue
153-
if not abs_path.exists():
156+
if not path.exists():
154157
continue
155-
if not force and _sha256(abs_path) != expected_hash:
156-
skipped.append(abs_path)
158+
if not force and _sha256(path) != expected_hash:
159+
skipped.append(path)
157160
continue
158-
abs_path.unlink()
159-
removed.append(abs_path)
161+
path.unlink()
162+
removed.append(path)
160163
# Clean up empty parent directories up to project root
161-
parent = abs_path.parent
164+
parent = path.parent
162165
while parent != root:
163166
try:
164167
parent.rmdir() # only succeeds if empty
@@ -204,7 +207,12 @@ def load(cls, key: str, project_root: Path) -> IntegrationManifest:
204207
"""
205208
inst = cls(key, project_root)
206209
path = inst.manifest_path
207-
data = json.loads(path.read_text(encoding="utf-8"))
210+
try:
211+
data = json.loads(path.read_text(encoding="utf-8"))
212+
except json.JSONDecodeError as exc:
213+
raise ValueError(
214+
f"Integration manifest at {path} contains invalid JSON"
215+
) from exc
208216

209217
if not isinstance(data, dict):
210218
raise ValueError(

tests/test_integrations.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -411,3 +411,10 @@ def test_load_bad_files_values_raises(self, tmp_path):
411411
path.write_text(json.dumps({"files": {"a.txt": 123}}), encoding="utf-8")
412412
with pytest.raises(ValueError, match="mapping"):
413413
IntegrationManifest.load("bad", tmp_path)
414+
415+
def test_load_invalid_json_raises(self, tmp_path):
416+
path = tmp_path / ".specify" / "integrations" / "bad.manifest.json"
417+
path.parent.mkdir(parents=True)
418+
path.write_text("{not valid json", encoding="utf-8")
419+
with pytest.raises(ValueError, match="invalid JSON"):
420+
IntegrationManifest.load("bad", tmp_path)

0 commit comments

Comments
 (0)