Skip to content

Commit 95d0787

Browse files
committed
fix: validate .backup root on force reinstall, abort preset force reinstall on refused removal, keep update refusal non-destructive
1 parent 5ed47cc commit 95d0787

5 files changed

Lines changed: 42 additions & 1 deletion

File tree

‎src/specify_cli/extensions/__init__.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2159,6 +2159,11 @@ def install_from_directory(
21592159
# Clear any stale backup from a previous remove so that only the
21602160
# backup produced by the current remove() call is restored later.
21612161
backup_config_dir = self.extensions_dir / ".backup" / manifest.id
2162+
if backup_config_dir.parent.is_symlink():
2163+
raise ExtensionError(
2164+
f"Refusing to reinstall '{manifest.id}': "
2165+
f"'.backup' is a symlink"
2166+
)
21622167
# Check is_symlink first: is_dir() follows symlinks so a
21632168
# symlink-to-directory would pass, but rmtree() raises on them.
21642169
if backup_config_dir.is_symlink():

‎src/specify_cli/extensions/_command_update_transaction.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -521,6 +521,7 @@ def backup_extension_skills(skill_names, *, skills_dir=None):
521521
# 7. Remove old extension (handles command file cleanup and registry removal)
522522
installation_modified = True
523523
if manager.remove(extension_id, keep_config=True) is False:
524+
installation_modified = False
524525
raise RuntimeError(
525526
f"Could not safely remove existing '{extension_id}'"
526527
)

‎src/specify_cli/presets/_manager.py‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -395,7 +395,11 @@ def install_from_directory(
395395
f"Preset '{manifest.id}' is already installed. "
396396
f"Use 'specify preset remove {manifest.id}' first."
397397
)
398-
self.remove(manifest.id)
398+
if not self.remove(manifest.id):
399+
raise PresetError(
400+
f"Refusing to reinstall '{manifest.id}': existing "
401+
f"installation could not be safely removed"
402+
)
399403

400404
dest_dir = self.presets_dir / manifest.id
401405
if dest_dir.exists():

‎tests/specify_cli/presets/test_manager.py‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Tests for preset installation and removal in specify_cli.presets._manager."""
22

33
import json
4+
import shutil
45
import tarfile
56
import zipfile
67
from pathlib import Path
@@ -335,6 +336,19 @@ def test_remove_refuses_symlinked_preset_dir(self, project_dir):
335336
assert pack_dir.is_symlink()
336337
assert manager.registry.is_installed("test-pack")
337338

339+
def test_force_reinstall_aborts_when_removal_refused(self, project_dir, pack_dir):
340+
"""install_from_directory(force=True) must abort if remove() refuses."""
341+
manager = PresetManager(project_dir)
342+
manager.install_from_directory(pack_dir, "0.1.5")
343+
installed = project_dir / ".specify" / "presets" / "test-pack"
344+
real_target = project_dir.parent / "real-target"
345+
shutil.move(str(installed), str(real_target))
346+
installed.symlink_to(real_target, target_is_directory=True)
347+
348+
with pytest.raises(PresetError, match="could not be safely removed"):
349+
manager.install_from_directory(pack_dir, "0.1.5", force=True)
350+
assert (real_target / "preset.yml").exists()
351+
338352
def test_remove_refuses_dangling_symlinked_preset_dir(self, project_dir):
339353
"""A dangling symlink must fail explicitly rather than be silently skipped.
340354

‎tests/test_extensions.py‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3337,6 +3337,23 @@ def test_force_reinstall_aborts_when_removal_refused(self, extension_dir, projec
33373337
)
33383338
assert (real_target / "extension.yml").exists()
33393339

3340+
def test_force_reinstall_refuses_symlinked_backup_root(self, extension_dir, project_dir):
3341+
"""Stale-backup cleanup must not delete through a symlinked .backup root."""
3342+
manager = ExtensionManager(project_dir)
3343+
manager.install_from_directory(extension_dir, "0.1.0", register_commands=False)
3344+
outside = project_dir.parent / "outside-backup"
3345+
(outside / "test-ext").mkdir(parents=True)
3346+
(outside / "test-ext" / "keep.txt").write_text("keep")
3347+
(project_dir / ".specify" / "extensions" / ".backup").symlink_to(
3348+
outside, target_is_directory=True
3349+
)
3350+
3351+
with pytest.raises(ExtensionError, match="symlink"):
3352+
manager.install_from_directory(
3353+
extension_dir, "0.1.0", register_commands=False, force=True
3354+
)
3355+
assert (outside / "test-ext" / "keep.txt").exists()
3356+
33403357
def test_remove_refuses_symlinked_backup_dir(self, extension_dir, project_dir):
33413358
"""Config backups must not be redirected through a symlinked destination."""
33423359
manager = ExtensionManager(project_dir)

0 commit comments

Comments
 (0)