Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions src/specify_cli/extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -1333,6 +1333,20 @@ def install_from_directory(
# Reject manifests that would shadow core commands or installed extensions.
self._validate_install_conflicts(manifest)

# Refuse to install an extension from its own install destination — with
# --force this would delete the source before copying it (issue #2990).
dest_dir = self.extensions_dir / manifest.id
try:
same_location = source_dir.resolve() == dest_dir.resolve()
except OSError:
same_location = False
if same_location:
raise ValidationError(
f"Source path is the install destination for '{manifest.id}' "
f"({dest_dir}). Refusing to proceed to avoid deleting the "
f"extension. Install from a copy in a different location instead."
)

# Remove existing installation AFTER all validations pass so that a
# validation failure doesn't leave the user with a half-uninstalled
# extension (configs stranded in .backup/).
Expand All @@ -1351,8 +1365,7 @@ def install_from_directory(
backup_config_dir.unlink()
did_remove = self.remove(manifest.id)

# Install extension
dest_dir = self.extensions_dir / manifest.id
# Install extension (dest_dir computed above during self-install guard)
if dest_dir.exists():
shutil.rmtree(dest_dir)

Expand Down
23 changes: 23 additions & 0 deletions tests/test_extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -1118,6 +1118,29 @@ def test_install_force_without_existing(self, extension_dir, project_dir):
assert manifest.id == "test-ext"
assert manager.registry.is_installed("test-ext")

def test_install_from_install_dir_is_rejected_without_data_loss(
self, extension_dir, project_dir
):
"""Installing from an extension's own install dir must fail without
deleting it (regression for issue #2990)."""
manager = ExtensionManager(project_dir)

# Install once so the extension lives at its install destination.
manager.install_from_directory(extension_dir, "0.1.0", register_commands=False)
install_dir = project_dir / ".specify" / "extensions" / "test-ext"
assert install_dir.exists()

# Re-installing from that same directory with --force must be rejected.
with pytest.raises(ValidationError, match="install destination"):
manager.install_from_directory(
install_dir, "0.1.0", register_commands=False, force=True
)

# The directory and its contents must be left intact (no data loss).
assert install_dir.exists()
assert (install_dir / "extension.yml").exists()
assert (install_dir / "commands" / "hello.md").exists()

def test_install_zip_force_reinstall(self, extension_dir, project_dir):
"""Test force-reinstalling from ZIP when already installed."""
import zipfile
Expand Down