Skip to content

Commit b804065

Browse files
iamaeroplaneclaude
andcommitted
fix(extensions): always backup registry, verify extension ID on update
1. Backup registry metadata and extensions.yml even when extension directory is missing/corrupted. Previously backups were only created if the directory existed, leaving registry in inconsistent state on failed updates. 2. Verify that the downloaded ZIP contains the expected extension ID. If the manifest ID doesn't match the extension being updated, treat it as a failed update and trigger rollback. This prevents installing a different extension if the catalog is compromised. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent fe26741 commit b804065

1 file changed

Lines changed: 33 additions & 26 deletions

File tree

src/specify_cli/__init__.py

Lines changed: 33 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -2575,20 +2575,21 @@ def extension_update(
25752575
# This ensures we don't remove the old version if download fails
25762576
zip_path = catalog.download_extension(extension_id)
25772577

2578-
# Backup extension directory and registry metadata before removing
2578+
# Backup registry metadata and extensions.yml before removing
2579+
# (always backup these even if directory is missing/corrupted)
25792580
extension_dir = manager.extensions_dir / extension_id
25802581
backup_dir = None
2581-
backup_metadata = None
2582+
backup_metadata = manager.registry.get(extension_id)
25822583
backup_command_files = {} # {path: content}
25832584
backup_extensions_yml = None
25842585

2585-
if extension_dir.exists():
2586-
backup_dir = Path(tempfile.mkdtemp(prefix=f"speckit-update-{extension_id}-"))
2587-
shutil.copytree(extension_dir, backup_dir / extension_id)
2588-
# Save registry metadata for restoration on failure
2589-
backup_metadata = manager.registry.get(extension_id)
2586+
# Backup extensions.yml (remove() will modify hooks)
2587+
extensions_yml = project_root / ".specify" / "extensions.yml"
2588+
if extensions_yml.exists():
2589+
backup_extensions_yml = extensions_yml.read_text()
25902590

2591-
# Backup registered command files (remove() will delete these)
2591+
# Backup registered command files (remove() will delete these)
2592+
if backup_metadata:
25922593
from .extensions import CommandRegistrar
25932594
registered_commands = backup_metadata.get("registered_commands", {})
25942595
registrar = CommandRegistrar()
@@ -2607,17 +2608,22 @@ def extension_update(
26072608
if prompt_file.exists():
26082609
backup_command_files[prompt_file] = prompt_file.read_text()
26092610

2610-
# Backup extensions.yml (remove() will modify hooks)
2611-
extensions_yml = project_root / ".specify" / "extensions.yml"
2612-
if extensions_yml.exists():
2613-
backup_extensions_yml = extensions_yml.read_text()
2611+
# Backup extension directory if it exists
2612+
if extension_dir.exists():
2613+
backup_dir = Path(tempfile.mkdtemp(prefix=f"speckit-update-{extension_id}-"))
2614+
shutil.copytree(extension_dir, backup_dir / extension_id)
26142615

26152616
try:
26162617
# Remove old version (keep config files)
26172618
manager.remove(extension_id, keep_config=True)
26182619

2619-
# Install new version
2620-
manager.install_from_zip(zip_path, speckit_version)
2620+
# Install new version and verify ID matches
2621+
installed_manifest = manager.install_from_zip(zip_path, speckit_version)
2622+
if installed_manifest.id != extension_id:
2623+
raise ValueError(
2624+
f"Extension ID mismatch: expected '{extension_id}', "
2625+
f"got '{installed_manifest.id}'"
2626+
)
26212627
console.print(f" [green]✓[/green] Updated to v{update['available']}")
26222628
updated_count += 1
26232629

@@ -2626,25 +2632,26 @@ def extension_update(
26262632
shutil.rmtree(backup_dir)
26272633
except Exception as install_error:
26282634
# Restore from backup if install fails
2635+
# Restore directory if we have a backup
26292636
if backup_dir and (backup_dir / extension_id).exists():
26302637
# Remove any partial install
26312638
if extension_dir.exists():
26322639
shutil.rmtree(extension_dir)
26332640
# Restore backup files
26342641
shutil.copytree(backup_dir / extension_id, extension_dir)
26352642
shutil.rmtree(backup_dir)
2636-
# Restore registry entry (use data dict directly to preserve installed_at)
2637-
if backup_metadata:
2638-
manager.registry.data["extensions"][extension_id] = backup_metadata
2639-
manager.registry._save()
2640-
# Restore command files
2641-
for cmd_path, cmd_content in backup_command_files.items():
2642-
cmd_path.parent.mkdir(parents=True, exist_ok=True)
2643-
cmd_path.write_text(cmd_content)
2644-
# Restore extensions.yml (hooks)
2645-
if backup_extensions_yml is not None:
2646-
extensions_yml = project_root / ".specify" / "extensions.yml"
2647-
extensions_yml.write_text(backup_extensions_yml)
2643+
# Always restore registry if we have backup (even without directory)
2644+
if backup_metadata:
2645+
manager.registry.data["extensions"][extension_id] = backup_metadata
2646+
manager.registry._save()
2647+
# Restore command files
2648+
for cmd_path, cmd_content in backup_command_files.items():
2649+
cmd_path.parent.mkdir(parents=True, exist_ok=True)
2650+
cmd_path.write_text(cmd_content)
2651+
# Restore extensions.yml (hooks)
2652+
if backup_extensions_yml is not None:
2653+
extensions_yml = project_root / ".specify" / "extensions.yml"
2654+
extensions_yml.write_text(backup_extensions_yml)
26482655
raise install_error
26492656
finally:
26502657
# Clean up downloaded ZIP

0 commit comments

Comments
 (0)