From b21d936d0706ac416458dc6b2e84ed9112243ed7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E6=B0=B8=E7=A5=BA?= Date: Thu, 24 Sep 2026 17:13:24 +0800 Subject: [PATCH 1/5] feat(extensions): select exact catalog releases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep current release metadata compatible with existing catalogs while allowing trusted catalogs to publish historical release URLs and digests. Add exact version selection, archive identity and discovery-policy checks, tests, and documentation. Refs #4719; follows up #4712. Assisted-by: OpenAI Codex (model: GPT-6, autonomous) Signed-off-by: 李永祺 --- docs/reference/extensions.md | 42 +++ src/specify_cli/extensions/__init__.py | 62 +++- .../extensions/_catalog_versions.py | 135 +++++++ src/specify_cli/extensions/command_add.py | 63 +++- src/specify_cli/extensions/command_info.py | 27 +- .../extensions/test_catalog_versions.py | 343 ++++++++++++++++++ 6 files changed, 664 insertions(+), 8 deletions(-) create mode 100644 src/specify_cli/extensions/_catalog_versions.py create mode 100644 tests/specify_cli/extensions/test_catalog_versions.py diff --git a/docs/reference/extensions.md b/docs/reference/extensions.md index 643c749142..c4e40cbc56 100644 --- a/docs/reference/extensions.md +++ b/docs/reference/extensions.md @@ -26,11 +26,19 @@ specify extension add | --------------- | -------------------------------------------------------- | | `--dev` | Install from a local directory (for development) | | `--from ` | Install from a custom URL instead of the catalog | +| `--version ` | Install an exact version advertised by a catalog | | `--force` | Overwrite if the extension is already installed | | `--priority `| Resolution priority (default: 10; lower = higher precedence) | Installs an extension from the catalog, a URL, or a local directory. Extension commands are automatically registered with the currently installed AI coding agent integration. +An unqualified catalog install still selects the advertised current version. +`--version` uses only the winning catalog source for that extension ID; it does +not fall back to a lower-priority source when the requested version is absent. +Discovery-only catalogs remain non-installable. `--version` cannot be combined +with `--dev` or the direct-URL `--from` option. The downloaded archive's extension +ID and version are checked before installation. + > **Note:** All extension commands require a project already initialized with `specify init`. ## Remove an Extension @@ -79,9 +87,43 @@ including for help, the existing human-readable behavior is unchanged. ```bash specify extension info +specify extension info --versions ``` Shows detailed information about an installed or available extension, including its description, version, commands, and configuration. +`--versions` lists the current and historical versions advertised by the +winning catalog source; it labels discovery-only sources as non-installable. + +Catalogs may keep the current release in the existing top-level fields and add +historical releases in a `releases` mapping. Older single-version catalogs +continue to work unchanged. Each historical release needs its own download URL +and SHA-256 digest; release-specific requirements or provided capabilities must +be placed in that release's record rather than inherited from the current one. + +```json +{ + "extensions": { + "my-extension": { + "name": "My Extension", + "version": "0.5.1", + "download_url": "https://example.com/my-extension-0.5.1.zip", + "sha256": "<64-character SHA-256 for 0.5.1>", + "releases": { + "0.4.12": { + "download_url": "https://example.com/my-extension-0.4.12.zip", + "sha256": "<64-character SHA-256 for 0.4.12>" + } + } + } + } +} +``` + +The example omits other catalog metadata for brevity. The current version must +not be repeated in `releases`; malformed or duplicate release records are +rejected. Bundle pins still use the current catalog resolution path until the +separate bundle work described in [#4719](https://github.com/github/spec-kit/issues/4719) +adds exact-version component lookup. ## Update Extensions diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index e4b9e7de9d..7a2116070a 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -2722,6 +2722,8 @@ def install_from_archive( source_name: str | None = None, content_type: str | None = None, catalog_name: str | None = None, + expected_id: str | None = None, + expected_version: str | None = None, ) -> ExtensionManifest: """Install an extension from a supported archive. @@ -2771,6 +2773,23 @@ def install_from_archive( if not manifest_path.exists(): raise ValidationError("No extension.yml found in archive") + if expected_id is not None or expected_version is not None: + archive_manifest = ExtensionManifest(manifest_path) + if expected_id is not None and archive_manifest.id != expected_id: + raise ValidationError( + f"Downloaded extension declares ID '{archive_manifest.id}', " + f"expected '{expected_id}'." + ) + if ( + expected_version is not None + and pkg_version.Version(archive_manifest.version) + != pkg_version.Version(expected_version) + ): + raise ValidationError( + f"Downloaded extension '{archive_manifest.id}' declares version " + f"{archive_manifest.version}, expected {expected_version}." + ) + # Install from extracted directory return self.install_from_directory( extension_dir, @@ -2934,6 +2953,8 @@ def install_from_zip( source_name: str | None = None, content_type: str | None = None, catalog_name: str | None = None, + expected_id: str | None = None, + expected_version: str | None = None, ) -> ExtensionManifest: """Backward-compatible wrapper for archive installation.""" return self.install_from_archive( @@ -2945,6 +2966,8 @@ def install_from_zip( source_name=source_name, content_type=content_type, catalog_name=catalog_name, + expected_id=expected_id, + expected_version=expected_version, ) def remove(self, extension_id: str, keep_config: bool = False) -> bool: @@ -4336,13 +4359,16 @@ def search( return results - def get_extension_info(self, extension_id: str) -> Optional[Dict[str, Any]]: + def get_extension_info( + self, extension_id: str, version: str | None = None + ) -> Optional[Dict[str, Any]]: """Get detailed information about a specific extension. Searches all active catalogs in priority order. Args: extension_id: ID of the extension + version: Exact catalog version, or ``None`` for the advertised current release Returns: Extension metadata (annotated with ``_catalog_name`` and @@ -4351,9 +4377,20 @@ def get_extension_info(self, extension_id: str) -> Optional[Dict[str, Any]]: all_extensions = self._get_merged_extensions() for ext_data in all_extensions: if ext_data["id"] == extension_id: - return ext_data + from ._catalog_versions import select_release + + return select_release(ext_data, version) return None + def get_extension_versions(self, extension_id: str) -> list[str]: + """List versions advertised by the winning catalog source.""" + from ._catalog_versions import available_versions + + for ext_data in self._get_merged_extensions(): + if ext_data["id"] == extension_id: + return available_versions(ext_data) + return [] + def download_extension( self, extension_id: str, target_dir: Optional[Path] = None ) -> Path: @@ -4369,13 +4406,30 @@ def download_extension( Raises: ExtensionError: If extension not found or download fails """ - import urllib.error - # Get extension info from catalog ext_info = self.get_extension_info(extension_id) if not ext_info: raise ExtensionError(f"Extension '{extension_id}' not found in catalog") + return self.download_extension_info(ext_info, target_dir=target_dir) + + def download_extension_info( + self, ext_info: Dict[str, Any], target_dir: Optional[Path] = None + ) -> Path: + """Download a selected release without looking up its ID again. + + Exact-version callers pass the already-selected record so a catalog + change between lookup and download cannot substitute the current URL. + """ + import urllib.error + + extension_id = ext_info["id"] + if not ext_info.get("_install_allowed", True): + raise ExtensionError( + f"Extension '{extension_id}' is from a discovery-only catalog; " + "installation is not allowed." + ) + # Bundled extensions without a download URL must be installed locally if ext_info.get("bundled") and not ext_info.get("download_url"): raise ExtensionError( diff --git a/src/specify_cli/extensions/_catalog_versions.py b/src/specify_cli/extensions/_catalog_versions.py new file mode 100644 index 0000000000..85bda774d7 --- /dev/null +++ b/src/specify_cli/extensions/_catalog_versions.py @@ -0,0 +1,135 @@ +"""Exact-version selection for extension catalog entries. + +Legacy entries advertise one release at the top level. A versioned entry keeps +that current release unchanged for older clients and adds historical releases +under ``releases``. Historical records must carry their own URL and digest; a +new current release must never supply either for an older version by accident. +""" + +from __future__ import annotations + +import re +from typing import Any + +from packaging.version import InvalidVersion, Version + +from . import ExtensionError + +_SHA256 = re.compile(r"^[0-9a-fA-F]{64}$") +_CURRENT_ONLY = frozenset( + { + "version", + "download_url", + "sha256", + "requires", + "provides", + "bundled", + "verified", + "releases", + } +) + + +def _validated_releases(entry: dict[str, Any]) -> dict[str, dict[str, Any]]: + """Return a checked history, or reject an ambiguous catalog entry.""" + if "releases" not in entry: + return {} + releases = entry["releases"] + extension_id = entry.get("id", "") + if not isinstance(releases, dict): + raise ExtensionError( + f"Extension '{extension_id}' has an invalid releases mapping." + ) + + current = entry.get("version") + if not isinstance(current, str) or not current.strip(): + raise ExtensionError( + f"Extension '{extension_id}' has releases but no current version." + ) + try: + normalized_current = Version(current) + except InvalidVersion: + raise ExtensionError( + f"Extension '{extension_id}' has an invalid current version '{current}'." + ) from None + + seen = {normalized_current} + for release_version, record in releases.items(): + if not isinstance(release_version, str) or not release_version.strip(): + raise ExtensionError( + f"Extension '{extension_id}' has an invalid release version key." + ) + try: + normalized = Version(release_version) + except InvalidVersion: + raise ExtensionError( + f"Extension '{extension_id}' has invalid release version '{release_version}'." + ) from None + if normalized in seen: + raise ExtensionError( + f"Extension '{extension_id}' repeats release version '{release_version}'." + ) + seen.add(normalized) + if not isinstance(record, dict): + raise ExtensionError( + f"Extension '{extension_id}' release '{release_version}' must be an object." + ) + if any( + key in record + for key in ( + "id", + "version", + "releases", + "_catalog_name", + "_install_allowed", + ) + ): + raise ExtensionError( + f"Extension '{extension_id}' release '{release_version}' contains reserved fields." + ) + if ( + not isinstance(record.get("download_url"), str) + or not record["download_url"].strip() + ): + raise ExtensionError( + f"Extension '{extension_id}' release '{release_version}' needs a download_url." + ) + if not isinstance(record.get("sha256"), str) or not _SHA256.fullmatch( + record["sha256"] + ): + raise ExtensionError( + f"Extension '{extension_id}' release '{release_version}' needs a SHA-256 digest." + ) + for field in ("requires", "provides"): + if field in record and not isinstance(record[field], dict): + raise ExtensionError( + f"Extension '{extension_id}' release '{release_version}' has invalid {field}." + ) + + return releases + + +def select_release(entry: dict[str, Any], version: str | None) -> dict[str, Any] | None: + """Select from the winning catalog entry without consulting lower sources. + + ``None`` is returned when the requested version is absent. Callers can then + report a missing historical release without falling through to another + catalog or silently substituting the current release. + """ + releases = _validated_releases(entry) + if version is None or version == entry.get("version"): + return entry + record = releases.get(version) + if record is None: + return None + common = {key: value for key, value in entry.items() if key not in _CURRENT_ONLY} + return {**common, **record, "version": version} + + +def available_versions(entry: dict[str, Any]) -> list[str]: + """Current version first, then historical versions in descending order.""" + releases = _validated_releases(entry) + current = entry.get("version") + if not isinstance(current, str) or not current: + return [] + return [current, *sorted(releases, key=Version, reverse=True)] diff --git a/src/specify_cli/extensions/command_add.py b/src/specify_cli/extensions/command_add.py index 9a5bd7cad2..2ca172210e 100644 --- a/src/specify_cli/extensions/command_add.py +++ b/src/specify_cli/extensions/command_add.py @@ -23,15 +23,24 @@ def extension_add( from_url: Optional[str] = typer.Option(None, "--from", help="Install from custom URL"), force: bool = typer.Option(False, "--force", help="Overwrite if already installed"), priority: int = typer.Option(10, "--priority", help="Resolution priority (lower = higher precedence, default 10)"), + version: Optional[str] = typer.Option(None, "--version", help="Install an exact version from a catalog"), ): """Install an extension.""" from . import ExtensionManager, ExtensionCatalog, ExtensionError, ValidationError, CompatibilityError, REINSTALL_COMMAND + # Compatibility callers invoke this function directly, in which case + # Typer supplies its OptionInfo object instead of a parsed option value. + if not isinstance(version, str): + version = None + project_root = _commands._require_specify_project() # Validate priority if priority < 1: console.print("[red]Error:[/red] Priority must be a positive integer (1 or higher)") raise typer.Exit(1) + if version is not None and (not version.strip() or dev or from_url): + console.print("[red]Error:[/red] --version requires a catalog install (without --dev or --from).") + raise typer.Exit(1) manager = ExtensionManager(project_root) speckit_version = _commands.get_speckit_version() @@ -133,7 +142,10 @@ def extension_add( else: # Try bundled extensions first (shipped with spec-kit) - bundled_path = _commands._locate_bundled_extension(extension) + bundled_path = ( + _commands._locate_bundled_extension(extension) + if version is None else None + ) if bundled_path is not None: manifest = manager.install_from_directory( bundled_path, speckit_version, priority=priority, force=force @@ -155,9 +167,46 @@ def extension_add( console.print(" specify extension search") raise typer.Exit(1) + if version is not None: + # Resolve within the winning catalog source. A missing + # historical release must not fall through to a lower + # priority (or discovery-only) catalog. + selected = catalog.get_extension_info(ext_info["id"], version) + if selected is None: + console.print( + f"[red]Error:[/red] Extension '{_escape_markup(str(ext_info['id']))}' " + f"has no catalog release for version {_escape_markup(version)}." + ) + raise typer.Exit(1) + ext_info = selected + if not ext_info.get("_install_allowed", True): + console.print( + f"[red]Error:[/red] Extension '{_escape_markup(str(ext_info['id']))}' " + "is from a discovery-only catalog and cannot be installed." + ) + raise typer.Exit(1) + # If catalog resolved a display name to an ID, check bundled again resolved_id = ext_info['id'] - if resolved_id != extension: + if version is not None and ext_info.get("bundled") and not ext_info.get("download_url"): + from . import ExtensionManifest + + candidate = _commands._locate_bundled_extension(resolved_id) + if candidate is not None: + bundled_manifest = ExtensionManifest(candidate / "extension.yml") + if bundled_manifest.version == version: + bundled_path = candidate + manifest = manager.install_from_directory( + bundled_path, speckit_version, priority=priority, force=force + ) + if bundled_path is None: + console.print( + f"[red]Error:[/red] Bundled extension '{_escape_markup(resolved_id)}' " + f"version {_escape_markup(version)} is not shipped with this Spec Kit release. " + "Upgrade Spec Kit or choose an available catalog archive." + ) + raise typer.Exit(1) + if version is None and resolved_id != extension: bundled_path = _commands._locate_bundled_extension(resolved_id) if bundled_path is not None: manifest = manager.install_from_directory( @@ -206,7 +255,11 @@ def extension_add( # Download extension archive (use the resolved catalog ID). extension_id = ext_info['id'] console.print(f"Downloading {_escape_markup(str(ext_info['name']))} v{_escape_markup(str(ext_info.get('version', 'unknown')))}...") - archive_path = catalog.download_extension(extension_id) + archive_path = ( + catalog.download_extension_info(ext_info) + if version is not None + else catalog.download_extension(extension_id) + ) try: manifest = manager.install_from_zip( @@ -215,6 +268,10 @@ def extension_add( priority=priority, force=force, catalog_name=ext_info.get("_catalog_name"), + **( + {"expected_id": extension_id, "expected_version": version} + if version is not None else {} + ), ) finally: archive_path.unlink(missing_ok=True) diff --git a/src/specify_cli/extensions/command_info.py b/src/specify_cli/extensions/command_info.py index a4c68c7a9b..07dfa8269c 100644 --- a/src/specify_cli/extensions/command_info.py +++ b/src/specify_cli/extensions/command_info.py @@ -14,9 +14,10 @@ @_commands.extension_app.command("info") def extension_info( extension: str = typer.Argument(help="Extension ID or name"), + versions: bool = typer.Option(False, "--versions", help="List catalog versions"), ): """Show detailed information about an extension.""" - from . import ExtensionCatalog, ExtensionManager, normalize_priority + from . import ExtensionCatalog, ExtensionManager, ExtensionError, normalize_priority project_root = _commands._require_specify_project() catalog = ExtensionCatalog(project_root) @@ -36,12 +37,36 @@ def extension_info( ext_info, catalog_error = _commands._resolve_catalog_extension( lookup_key, catalog, "info" ) + # Direct compatibility callers receive Typer's OptionInfo default rather + # than a parsed bool; only the CLI's explicit True enables this view. + show_versions = versions is True # Case 1: Found in catalog - show full catalog info if ext_info: + if show_versions: + try: + available = catalog.get_extension_versions(ext_info["id"]) + except ExtensionError as exc: + _commands.console.print(f"[red]Error:[/red] {_escape_markup(str(exc))}") + raise typer.Exit(1) from exc + _commands.console.print( + f"Catalog versions for {_escape_markup(str(ext_info['id']))}:" + ) + for index, available_version in enumerate(available): + current = " (current)" if index == 0 else "" + _commands.console.print(f" {_escape_markup(available_version)}{current}") + if not ext_info.get("_install_allowed", True): + _commands.console.print("[yellow]Discovery only; catalog installation is disabled.[/yellow]") + return _print_extension_info(ext_info, manager) return + if show_versions: + _commands.console.print( + f"[red]Error:[/red] No catalog versions found for {_escape_markup(extension)}." + ) + raise typer.Exit(1) + # Case 2: Installed locally but catalog lookup failed or not in catalog if resolved_installed_id: # Get local manifest info diff --git a/tests/specify_cli/extensions/test_catalog_versions.py b/tests/specify_cli/extensions/test_catalog_versions.py new file mode 100644 index 0000000000..1fc2f92f5c --- /dev/null +++ b/tests/specify_cli/extensions/test_catalog_versions.py @@ -0,0 +1,343 @@ +"""Regression coverage for exact releases in an extension catalog (#4719).""" + +from __future__ import annotations + +from io import BytesIO +import hashlib +from pathlib import Path +import zipfile + +import pytest +import yaml +from typer.testing import CliRunner + +from specify_cli import app +from specify_cli.extensions import ( + CatalogEntry, + ExtensionCatalog, + ExtensionError, + ExtensionManifest, +) + + +def _archive(tmp_path: Path, version: str) -> Path: + path = tmp_path / f"demo-{version}.zip" + manifest = { + "schema_version": "1.0", + "extension": { + "id": "demo-history", + "name": "Demo History", + "version": version, + "description": "Historical release test", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "commands": [ + {"name": "speckit.demo-history.hello", "file": "commands/hello.md"} + ] + }, + } + with zipfile.ZipFile(path, "w") as archive: + archive.writestr("extension.yml", yaml.safe_dump(manifest)) + archive.writestr("commands/hello.md", "---\ndescription: hello\n---\n\nhi\n") + return path + + +def _entry(old_archive: Path) -> dict: + return { + "id": "demo-history", + "name": "Demo History", + "description": "Current description", + "version": "0.5.1", + "download_url": "https://example.com/demo-0.5.1.zip", + "sha256": "a" * 64, + "requires": {"speckit_version": ">=1.0.0"}, + "provides": {"commands": 3}, + "_catalog_name": "trusted", + "_install_allowed": True, + "releases": { + "0.4.12": { + "download_url": "https://example.com/demo-0.4.12.zip", + "sha256": hashlib.sha256(old_archive.read_bytes()).hexdigest(), + "requires": {"speckit_version": ">=0.1.0"}, + } + }, + } + + +def _catalog( + monkeypatch: pytest.MonkeyPatch, project: Path, entry: dict +) -> ExtensionCatalog: + monkeypatch.setattr( + ExtensionCatalog, "_get_merged_extensions", lambda self: [entry] + ) + return ExtensionCatalog(project) + + +def test_legacy_entry_still_selects_its_current_release(tmp_path, monkeypatch): + entry = { + "id": "legacy", + "version": "1.0.0", + "download_url": "https://example.com/a.zip", + } + catalog = _catalog(monkeypatch, tmp_path, entry) + + assert catalog.get_extension_info("legacy") == entry + assert catalog.get_extension_info("legacy", "1.0.0") == entry + assert catalog.get_extension_info("legacy", "0.9.0") is None + assert catalog.get_extension_versions("legacy") == ["1.0.0"] + + +def test_historical_release_selects_its_own_url_digest_and_requirements( + tmp_path, monkeypatch +): + entry = _entry(_archive(tmp_path, "0.4.12")) + catalog = _catalog(monkeypatch, tmp_path, entry) + + current = catalog.get_extension_info("demo-history") + old = catalog.get_extension_info("demo-history", "0.4.12") + + assert current["download_url"].endswith("0.5.1.zip") + assert old["download_url"].endswith("0.4.12.zip") + assert old["version"] == "0.4.12" + assert old["sha256"] != current["sha256"] + assert old["requires"] == {"speckit_version": ">=0.1.0"} + assert "provides" not in old + assert old["_catalog_name"] == "trusted" + assert "releases" not in old + assert catalog.get_extension_info("demo-history", "0.3.0") is None + assert catalog.get_extension_versions("demo-history") == ["0.5.1", "0.4.12"] + + +def test_requested_version_does_not_fall_through_to_lower_priority_catalog( + tmp_path, monkeypatch +): + old_archive = _archive(tmp_path, "0.4.12") + high = _entry(old_archive) + high["releases"] = {} + low = _entry(old_archive) + catalog = ExtensionCatalog(tmp_path) + sources = [ + CatalogEntry("https://example.com/high.json", "high", 1, True), + CatalogEntry("https://example.com/low.json", "low", 2, True), + ] + monkeypatch.setattr(catalog, "get_active_catalogs", lambda: sources) + monkeypatch.setattr( + catalog, + "_fetch_single_catalog", + lambda source, _force=False: { + "schema_version": "1.0", + "extensions": {"demo-history": high if source.name == "high" else low}, + }, + ) + + assert catalog.get_extension_info("demo-history", "0.4.12") is None + assert catalog.get_extension_versions("demo-history") == ["0.5.1"] + + +@pytest.mark.parametrize( + "bad_history", + [ + [], + None, + { + "not-a-version": { + "download_url": "https://example.com/a.zip", + "sha256": "a" * 64, + } + }, + {"0.5.1": {"download_url": "https://example.com/a.zip", "sha256": "a" * 64}}, + {"0.4.12": {"download_url": "https://example.com/a.zip"}}, + { + "0.4.12": { + "download_url": "https://example.com/a.zip", + "sha256": "a" * 64, + "id": "other", + } + }, + ], +) +def test_malformed_history_is_rejected(tmp_path, monkeypatch, bad_history): + entry = {"id": "demo-history", "version": "0.5.1", "releases": bad_history} + catalog = _catalog(monkeypatch, tmp_path, entry) + with pytest.raises(ExtensionError): + catalog.get_extension_info("demo-history", "0.4.12") + + +def test_selected_release_download_uses_its_url_without_relookup(tmp_path, monkeypatch): + archive = _archive(tmp_path, "0.4.12") + entry = _entry(archive) + catalog = _catalog(monkeypatch, tmp_path, entry) + selected = catalog.get_extension_info("demo-history", "0.4.12") + requested = [] + + class Response(BytesIO): + def geturl(self): + return requested[-1] + + def getheader(self, _name): + return "application/zip" + + def open_url(url, **_kwargs): + requested.append(url) + return Response(archive.read_bytes()) + + monkeypatch.setattr(catalog, "_open_url", open_url) + monkeypatch.setattr( + catalog, + "get_extension_info", + lambda *_args: pytest.fail("unexpected second lookup"), + ) + downloaded = catalog.download_extension_info( + selected, target_dir=tmp_path / "downloads" + ) + + assert requested == ["https://example.com/demo-0.4.12.zip"] + assert downloaded.read_bytes() == archive.read_bytes() + + +def test_selected_discovery_release_cannot_be_downloaded(tmp_path, monkeypatch): + entry = _entry(_archive(tmp_path, "0.4.12")) + entry["_install_allowed"] = False + catalog = _catalog(monkeypatch, tmp_path, entry) + selected = catalog.get_extension_info("demo-history", "0.4.12") + + with pytest.raises(ExtensionError, match="discovery-only"): + catalog.download_extension_info(selected) + + +def test_exact_cli_install_rejects_wrong_archive_before_writing(tmp_path, monkeypatch): + project = tmp_path / "project" + (project / ".specify").mkdir(parents=True) + old_archive = _archive(tmp_path, "0.4.12") + new_archive = _archive(tmp_path, "0.5.1") + _catalog(monkeypatch, project, _entry(old_archive)) + monkeypatch.chdir(project) + monkeypatch.setattr( + ExtensionCatalog, "download_extension_info", lambda self, _info: new_archive + ) + + result = CliRunner().invoke( + app, ["extension", "add", "demo-history", "--version", "0.4.12"] + ) + + assert result.exit_code == 1 + assert "declares version 0.5.1, expected 0.4.12" in " ".join(result.output.split()) + assert not ( + project / ".specify" / "extensions" / "demo-history" / "extension.yml" + ).exists() + + +def test_exact_cli_install_historical_release(tmp_path, monkeypatch): + project = tmp_path / "project" + (project / ".specify").mkdir(parents=True) + old_archive = _archive(tmp_path, "0.4.12") + _catalog(monkeypatch, project, _entry(old_archive)) + monkeypatch.chdir(project) + monkeypatch.setattr( + ExtensionCatalog, "download_extension_info", lambda self, _info: old_archive + ) + + result = CliRunner().invoke( + app, ["extension", "add", "demo-history", "--version", "0.4.12"] + ) + + assert result.exit_code == 0, result.output + installed = yaml.safe_load( + ( + project / ".specify" / "extensions" / "demo-history" / "extension.yml" + ).read_text(encoding="utf-8") + ) + assert installed["extension"]["version"] == "0.4.12" + + +def test_info_lists_versions_and_discovery_only_policy(tmp_path, monkeypatch): + project = tmp_path / "project" + (project / ".specify").mkdir(parents=True) + entry = _entry(_archive(tmp_path, "0.4.12")) + entry["_install_allowed"] = False + _catalog(monkeypatch, project, entry) + monkeypatch.chdir(project) + + result = CliRunner().invoke( + app, ["extension", "info", "demo-history", "--versions"] + ) + + assert result.exit_code == 0, result.output + assert "0.5.1 (current)" in result.output + assert "0.4.12" in result.output + assert "Discovery only" in result.output + + +def test_exact_cli_install_rejects_missing_version_in_winning_catalog( + tmp_path, monkeypatch +): + project = tmp_path / "project" + (project / ".specify").mkdir(parents=True) + _catalog(monkeypatch, project, _entry(_archive(tmp_path, "0.4.12"))) + monkeypatch.chdir(project) + monkeypatch.setattr( + ExtensionCatalog, + "download_extension_info", + lambda *_args: pytest.fail("must not download a different version"), + ) + + result = CliRunner().invoke( + app, ["extension", "add", "demo-history", "--version", "0.3.0"] + ) + + assert result.exit_code == 1 + assert "no catalog release for version 0.3.0" in " ".join(result.output.split()) + + +def test_exact_cli_install_refuses_discovery_only_catalog(tmp_path, monkeypatch): + project = tmp_path / "project" + (project / ".specify").mkdir(parents=True) + entry = _entry(_archive(tmp_path, "0.4.12")) + entry["_install_allowed"] = False + _catalog(monkeypatch, project, entry) + monkeypatch.chdir(project) + monkeypatch.setattr( + ExtensionCatalog, + "download_extension_info", + lambda *_args: pytest.fail("discovery-only catalogs must not download"), + ) + + result = CliRunner().invoke( + app, ["extension", "add", "demo-history", "--version", "0.4.12"] + ) + + assert result.exit_code == 1 + assert "discovery-only" in result.output + + +def test_exact_cli_does_not_bypass_discovery_policy_via_bundled_copy( + tmp_path, monkeypatch +): + from specify_cli._assets import _locate_bundled_extension + + bundled = _locate_bundled_extension("agent-context") + assert bundled is not None + version = ExtensionManifest(bundled / "extension.yml").version + project = tmp_path / "project" + (project / ".specify").mkdir(parents=True) + entry = { + "id": "agent-context", + "name": "Agent Context", + "version": version, + "bundled": True, + "_catalog_name": "discovery", + "_install_allowed": False, + } + _catalog(monkeypatch, project, entry) + monkeypatch.chdir(project) + + result = CliRunner().invoke( + app, ["extension", "add", "agent-context", "--version", version] + ) + + assert result.exit_code == 1 + assert "discovery-only" in result.output + assert not ( + project / ".specify" / "extensions" / "agent-context" / "extension.yml" + ).exists() From 4615cac8813b7efb0f80daed00ab045deae1ae88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E6=B0=B8=E7=A5=BA?= Date: Fri, 25 Sep 2026 07:25:57 +0800 Subject: [PATCH 2/5] test(extensions): cover historical and bundled version validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assisted-by: OpenAI Codex (model: GPT-6 Sol, autonomous) Signed-off-by: 李永祺 --- .../extensions/test_catalog_versions.py | 160 ++++++++++++++++-- 1 file changed, 147 insertions(+), 13 deletions(-) diff --git a/tests/specify_cli/extensions/test_catalog_versions.py b/tests/specify_cli/extensions/test_catalog_versions.py index 1fc2f92f5c..033500866f 100644 --- a/tests/specify_cli/extensions/test_catalog_versions.py +++ b/tests/specify_cli/extensions/test_catalog_versions.py @@ -2,10 +2,10 @@ from __future__ import annotations -from io import BytesIO import hashlib -from pathlib import Path import zipfile +from io import BytesIO +from pathlib import Path import pytest import yaml @@ -20,12 +20,12 @@ ) -def _archive(tmp_path: Path, version: str) -> Path: - path = tmp_path / f"demo-{version}.zip" +def _archive(tmp_path: Path, version: str, extension_id: str = "demo-history") -> Path: + path = tmp_path / f"{extension_id}-{version}.zip" manifest = { "schema_version": "1.0", "extension": { - "id": "demo-history", + "id": extension_id, "name": "Demo History", "version": version, "description": "Historical release test", @@ -74,6 +74,18 @@ def _catalog( return ExtensionCatalog(project) +class _ArchiveResponse(BytesIO): + def __init__(self, data: bytes, url: str): + super().__init__(data) + self.url = url + + def geturl(self): + return self.url + + def getheader(self, _name): + return "application/zip" + + def test_legacy_entry_still_selects_its_current_release(tmp_path, monkeypatch): entry = { "id": "legacy", @@ -171,16 +183,9 @@ def test_selected_release_download_uses_its_url_without_relookup(tmp_path, monke selected = catalog.get_extension_info("demo-history", "0.4.12") requested = [] - class Response(BytesIO): - def geturl(self): - return requested[-1] - - def getheader(self, _name): - return "application/zip" - def open_url(url, **_kwargs): requested.append(url) - return Response(archive.read_bytes()) + return _ArchiveResponse(archive.read_bytes(), url) monkeypatch.setattr(catalog, "_open_url", open_url) monkeypatch.setattr( @@ -228,6 +233,94 @@ def test_exact_cli_install_rejects_wrong_archive_before_writing(tmp_path, monkey ).exists() +def test_exact_cli_install_rejects_wrong_archive_id_before_writing( + tmp_path, monkeypatch +): + project = tmp_path / "project" + (project / ".specify").mkdir(parents=True) + old_archive = _archive(tmp_path, "0.4.12") + wrong_id_archive = _archive(tmp_path, "0.4.12", "another-extension") + _catalog(monkeypatch, project, _entry(old_archive)) + monkeypatch.chdir(project) + monkeypatch.setattr( + ExtensionCatalog, + "download_extension_info", + lambda self, _info: wrong_id_archive, + ) + + result = CliRunner().invoke( + app, ["extension", "add", "demo-history", "--version", "0.4.12"] + ) + + assert result.exit_code == 1 + assert "declares ID 'another-extension', expected 'demo-history'" in " ".join( + result.output.split() + ) + assert not (project / ".specify" / "extensions" / "another-extension").exists() + assert not (project / ".specify" / "extensions" / "demo-history").exists() + + +def test_exact_cli_install_rejects_wrong_historical_digest_before_writing( + tmp_path, monkeypatch +): + project = tmp_path / "project" + (project / ".specify").mkdir(parents=True) + old_archive = _archive(tmp_path, "0.4.12") + entry = _entry(old_archive) + entry["releases"]["0.4.12"]["sha256"] = "0" * 64 + _catalog(monkeypatch, project, entry) + monkeypatch.chdir(project) + requested = [] + + def open_url(self, url, **_kwargs): + requested.append(url) + return _ArchiveResponse(old_archive.read_bytes(), url) + + monkeypatch.setattr(ExtensionCatalog, "_open_url", open_url) + + result = CliRunner().invoke( + app, ["extension", "add", "demo-history", "--version", "0.4.12"] + ) + + assert result.exit_code == 1 + assert requested == ["https://example.com/demo-0.4.12.zip"] + assert "Integrity check failed for 'demo-history'" in " ".join( + result.output.split() + ) + assert not (project / ".specify" / "extensions" / "demo-history").exists() + + +def test_unqualified_cli_install_uses_current_release_with_history( + tmp_path, monkeypatch +): + project = tmp_path / "project" + (project / ".specify").mkdir(parents=True) + old_archive = _archive(tmp_path, "0.4.12") + current_archive = _archive(tmp_path, "0.5.1") + entry = _entry(old_archive) + entry["sha256"] = hashlib.sha256(current_archive.read_bytes()).hexdigest() + _catalog(monkeypatch, project, entry) + monkeypatch.chdir(project) + requested = [] + + def open_url(self, url, **_kwargs): + requested.append(url) + return _ArchiveResponse(current_archive.read_bytes(), url) + + monkeypatch.setattr(ExtensionCatalog, "_open_url", open_url) + + result = CliRunner().invoke(app, ["extension", "add", "demo-history"]) + + assert result.exit_code == 0, result.output + assert requested == ["https://example.com/demo-0.5.1.zip"] + installed = yaml.safe_load( + ( + project / ".specify" / "extensions" / "demo-history" / "extension.yml" + ).read_text(encoding="utf-8") + ) + assert installed["extension"]["version"] == "0.5.1" + + def test_exact_cli_install_historical_release(tmp_path, monkeypatch): project = tmp_path / "project" (project / ".specify").mkdir(parents=True) @@ -341,3 +434,44 @@ def test_exact_cli_does_not_bypass_discovery_policy_via_bundled_copy( assert not ( project / ".specify" / "extensions" / "agent-context" / "extension.yml" ).exists() + + +@pytest.mark.parametrize("matches_package", [True, False]) +def test_exact_cli_bundled_version_must_match_packaged_manifest( + tmp_path, monkeypatch, matches_package +): + from specify_cli._assets import _locate_bundled_extension + + bundled = _locate_bundled_extension("agent-context") + assert bundled is not None + packaged_version = ExtensionManifest(bundled / "extension.yml").version + requested_version = packaged_version if matches_package else "9999.0.0" + project = tmp_path / "project" + (project / ".specify").mkdir(parents=True) + entry = { + "id": "agent-context", + "name": "Agent Context", + "version": requested_version, + "bundled": True, + "_catalog_name": "trusted", + "_install_allowed": True, + } + _catalog(monkeypatch, project, entry) + monkeypatch.chdir(project) + + result = CliRunner().invoke( + app, ["extension", "add", "agent-context", "--version", requested_version] + ) + + installed_manifest = ( + project / ".specify" / "extensions" / "agent-context" / "extension.yml" + ) + if matches_package: + assert result.exit_code == 0, result.output + assert ExtensionManifest(installed_manifest).version == packaged_version + else: + assert result.exit_code == 1 + assert f"version {requested_version} is not shipped" in " ".join( + result.output.split() + ) + assert not installed_manifest.exists() From 7fb0973bdc825993f973afdd73c6285515663801 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E6=B0=B8=E7=A5=BA?= Date: Fri, 25 Sep 2026 23:25:41 +0800 Subject: [PATCH 3/5] fix(extensions): compare equivalent catalog release versions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assisted-by: OpenAI Codex (model: GPT-6 Sol, autonomous) Signed-off-by: 李永祺 --- docs/reference/extensions.md | 2 + .../extensions/_catalog_versions.py | 25 ++++++-- src/specify_cli/extensions/command_add.py | 9 ++- src/specify_cli/extensions/command_info.py | 5 ++ .../extensions/test_catalog_versions.py | 59 ++++++++++++++++--- 5 files changed, 87 insertions(+), 13 deletions(-) diff --git a/docs/reference/extensions.md b/docs/reference/extensions.md index c4e40cbc56..d4ef53df1a 100644 --- a/docs/reference/extensions.md +++ b/docs/reference/extensions.md @@ -93,6 +93,8 @@ specify extension info --versions Shows detailed information about an installed or available extension, including its description, version, commands, and configuration. `--versions` lists the current and historical versions advertised by the winning catalog source; it labels discovery-only sources as non-installable. +Equivalent PEP 440 version spellings (for example, `v1.0` and `1.0`) select +the same release; the catalog's advertised spelling remains visible. Catalogs may keep the current release in the existing top-level fields and add historical releases in a `releases` mapping. Older single-version catalogs diff --git a/src/specify_cli/extensions/_catalog_versions.py b/src/specify_cli/extensions/_catalog_versions.py index 85bda774d7..0f110be1e3 100644 --- a/src/specify_cli/extensions/_catalog_versions.py +++ b/src/specify_cli/extensions/_catalog_versions.py @@ -117,13 +117,28 @@ def select_release(entry: dict[str, Any], version: str | None) -> dict[str, Any] catalog or silently substituting the current release. """ releases = _validated_releases(entry) - if version is None or version == entry.get("version"): + current = entry.get("version") + if version is None or version == current: return entry - record = releases.get(version) - if record is None: + try: + requested = Version(version) + except InvalidVersion: return None - common = {key: value for key, value in entry.items() if key not in _CURRENT_ONLY} - return {**common, **record, "version": version} + if isinstance(current, str): + try: + if requested == Version(current): + return entry + except InvalidVersion: + # Legacy entries without history are still selectable by exact + # spelling above, even if their version is not PEP 440 compliant. + pass + for advertised, record in releases.items(): + if requested == Version(advertised): + common = { + key: value for key, value in entry.items() if key not in _CURRENT_ONLY + } + return {**common, **record, "version": advertised} + return None def available_versions(entry: dict[str, Any]) -> list[str]: diff --git a/src/specify_cli/extensions/command_add.py b/src/specify_cli/extensions/command_add.py index 2ca172210e..a5679e1482 100644 --- a/src/specify_cli/extensions/command_add.py +++ b/src/specify_cli/extensions/command_add.py @@ -9,6 +9,7 @@ from typing import Optional import typer +from packaging.version import InvalidVersion, Version from rich.markup import escape as _escape_markup from rich.panel import Panel @@ -194,7 +195,13 @@ def extension_add( candidate = _commands._locate_bundled_extension(resolved_id) if candidate is not None: bundled_manifest = ExtensionManifest(candidate / "extension.yml") - if bundled_manifest.version == version: + try: + packaged_matches = Version(bundled_manifest.version) == Version( + version + ) + except InvalidVersion: + packaged_matches = False + if packaged_matches: bundled_path = candidate manifest = manager.install_from_directory( bundled_path, speckit_version, priority=priority, force=force diff --git a/src/specify_cli/extensions/command_info.py b/src/specify_cli/extensions/command_info.py index 07dfa8269c..83784c4cb5 100644 --- a/src/specify_cli/extensions/command_info.py +++ b/src/specify_cli/extensions/command_info.py @@ -62,6 +62,11 @@ def extension_info( return if show_versions: + if catalog_error: + _commands.console.print( + f"[red]Error:[/red] Could not query extension catalog: {_escape_markup(str(catalog_error))}" + ) + raise typer.Exit(1) _commands.console.print( f"[red]Error:[/red] No catalog versions found for {_escape_markup(extension)}." ) diff --git a/tests/specify_cli/extensions/test_catalog_versions.py b/tests/specify_cli/extensions/test_catalog_versions.py index 033500866f..95bc71907a 100644 --- a/tests/specify_cli/extensions/test_catalog_versions.py +++ b/tests/specify_cli/extensions/test_catalog_versions.py @@ -96,6 +96,7 @@ def test_legacy_entry_still_selects_its_current_release(tmp_path, monkeypatch): assert catalog.get_extension_info("legacy") == entry assert catalog.get_extension_info("legacy", "1.0.0") == entry + assert catalog.get_extension_info("legacy", "v1.0") == entry assert catalog.get_extension_info("legacy", "0.9.0") is None assert catalog.get_extension_versions("legacy") == ["1.0.0"] @@ -121,6 +122,19 @@ def test_historical_release_selects_its_own_url_digest_and_requirements( assert catalog.get_extension_versions("demo-history") == ["0.5.1", "0.4.12"] +def test_equivalent_version_spelling_preserves_advertised_release( + tmp_path, monkeypatch +): + entry = _entry(_archive(tmp_path, "0.4.12")) + catalog = _catalog(monkeypatch, tmp_path, entry) + + assert catalog.get_extension_info("demo-history", "v0.5.1") == entry + historical = catalog.get_extension_info("demo-history", "0.4.12.0") + assert historical["version"] == "0.4.12" + assert historical["download_url"] == entry["releases"]["0.4.12"]["download_url"] + assert catalog.get_extension_info("demo-history", "not-a-version") is None + + def test_requested_version_does_not_fall_through_to_lower_priority_catalog( tmp_path, monkeypatch ): @@ -159,6 +173,7 @@ def test_requested_version_does_not_fall_through_to_lower_priority_catalog( } }, {"0.5.1": {"download_url": "https://example.com/a.zip", "sha256": "a" * 64}}, + {"0.5.1.0": {"download_url": "https://example.com/a.zip", "sha256": "a" * 64}}, {"0.4.12": {"download_url": "https://example.com/a.zip"}}, { "0.4.12": { @@ -321,7 +336,8 @@ def open_url(self, url, **_kwargs): assert installed["extension"]["version"] == "0.5.1" -def test_exact_cli_install_historical_release(tmp_path, monkeypatch): +@pytest.mark.parametrize("requested_version", ["0.4.12", "v0.4.12"]) +def test_exact_cli_install_historical_release(tmp_path, monkeypatch, requested_version): project = tmp_path / "project" (project / ".specify").mkdir(parents=True) old_archive = _archive(tmp_path, "0.4.12") @@ -332,7 +348,7 @@ def test_exact_cli_install_historical_release(tmp_path, monkeypatch): ) result = CliRunner().invoke( - app, ["extension", "add", "demo-history", "--version", "0.4.12"] + app, ["extension", "add", "demo-history", "--version", requested_version] ) assert result.exit_code == 0, result.output @@ -362,6 +378,29 @@ def test_info_lists_versions_and_discovery_only_policy(tmp_path, monkeypatch): assert "Discovery only" in result.output +def test_info_versions_preserves_catalog_failure(tmp_path, monkeypatch): + from specify_cli.extensions import _commands + + project = tmp_path / "project" + (project / ".specify").mkdir(parents=True) + monkeypatch.chdir(project) + monkeypatch.setattr( + _commands, + "_resolve_catalog_extension", + lambda *_args: (None, ExtensionError("catalog fetch failed")), + ) + + result = CliRunner().invoke( + app, ["extension", "info", "demo-history", "--versions"] + ) + + assert result.exit_code == 1 + assert "Could not query extension catalog: catalog fetch failed" in " ".join( + result.output.split() + ) + assert "No catalog versions found" not in result.output + + def test_exact_cli_install_rejects_missing_version_in_winning_catalog( tmp_path, monkeypatch ): @@ -436,22 +475,28 @@ def test_exact_cli_does_not_bypass_discovery_policy_via_bundled_copy( ).exists() -@pytest.mark.parametrize("matches_package", [True, False]) +@pytest.mark.parametrize("version_case", ["exact", "equivalent", "mismatch"]) def test_exact_cli_bundled_version_must_match_packaged_manifest( - tmp_path, monkeypatch, matches_package + tmp_path, monkeypatch, version_case ): from specify_cli._assets import _locate_bundled_extension bundled = _locate_bundled_extension("agent-context") assert bundled is not None packaged_version = ExtensionManifest(bundled / "extension.yml").version - requested_version = packaged_version if matches_package else "9999.0.0" + requested_version = { + "exact": packaged_version, + "equivalent": f"v{packaged_version}", + "mismatch": "9999.0.0", + }[version_case] project = tmp_path / "project" (project / ".specify").mkdir(parents=True) entry = { "id": "agent-context", "name": "Agent Context", - "version": requested_version, + "version": requested_version + if version_case == "mismatch" + else packaged_version, "bundled": True, "_catalog_name": "trusted", "_install_allowed": True, @@ -466,7 +511,7 @@ def test_exact_cli_bundled_version_must_match_packaged_manifest( installed_manifest = ( project / ".specify" / "extensions" / "agent-context" / "extension.yml" ) - if matches_package: + if version_case != "mismatch": assert result.exit_code == 0, result.output assert ExtensionManifest(installed_manifest).version == packaged_version else: From ce44a1d94e9f4ff6acbe82f41de2e8275e38fe91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E6=B0=B8=E7=A5=BA?= Date: Tue, 29 Sep 2026 07:33:52 +0800 Subject: [PATCH 4/5] fix(extensions): select releases from resolved catalog entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Avoid refetching catalogs after resolving the winning extension source. Preserve that snapshot for exact installs and version listings, including when a later fetch would fail or return a lower-priority entry. Assisted-by: OpenAI Codex (model: GPT-6, autonomous) Signed-off-by: 李永祺 --- src/specify_cli/extensions/command_add.py | 7 ++- src/specify_cli/extensions/command_info.py | 4 +- .../extensions/test_catalog_versions.py | 63 +++++++++++++++++++ 3 files changed, 71 insertions(+), 3 deletions(-) diff --git a/src/specify_cli/extensions/command_add.py b/src/specify_cli/extensions/command_add.py index a5679e1482..30a4ff717c 100644 --- a/src/specify_cli/extensions/command_add.py +++ b/src/specify_cli/extensions/command_add.py @@ -171,8 +171,11 @@ def extension_add( if version is not None: # Resolve within the winning catalog source. A missing # historical release must not fall through to a lower - # priority (or discovery-only) catalog. - selected = catalog.get_extension_info(ext_info["id"], version) + # priority (or discovery-only) catalog. Reuse the entry + # already resolved above rather than fetching again. + from ._catalog_versions import select_release + + selected = select_release(ext_info, version) if selected is None: console.print( f"[red]Error:[/red] Extension '{_escape_markup(str(ext_info['id']))}' " diff --git a/src/specify_cli/extensions/command_info.py b/src/specify_cli/extensions/command_info.py index 83784c4cb5..d1acd599a9 100644 --- a/src/specify_cli/extensions/command_info.py +++ b/src/specify_cli/extensions/command_info.py @@ -45,7 +45,9 @@ def extension_info( if ext_info: if show_versions: try: - available = catalog.get_extension_versions(ext_info["id"]) + from ._catalog_versions import available_versions + + available = available_versions(ext_info) except ExtensionError as exc: _commands.console.print(f"[red]Error:[/red] {_escape_markup(str(exc))}") raise typer.Exit(1) from exc diff --git a/tests/specify_cli/extensions/test_catalog_versions.py b/tests/specify_cli/extensions/test_catalog_versions.py index 95bc71907a..0e17242ea3 100644 --- a/tests/specify_cli/extensions/test_catalog_versions.py +++ b/tests/specify_cli/extensions/test_catalog_versions.py @@ -161,6 +161,44 @@ def test_requested_version_does_not_fall_through_to_lower_priority_catalog( assert catalog.get_extension_versions("demo-history") == ["0.5.1"] +@pytest.mark.parametrize("second_lookup", ["lower_priority", "unavailable"]) +def test_exact_cli_install_uses_first_resolved_catalog_snapshot( + tmp_path, monkeypatch, second_lookup +): + project = tmp_path / "project" + (project / ".specify").mkdir(parents=True) + old_archive = _archive(tmp_path, "0.4.12") + high = _entry(old_archive) + low = _entry(old_archive) + low["releases"]["0.4.12"]["download_url"] = "https://example.com/low.zip" + fetches = [] + selected = [] + + def merged(_self): + fetches.append(True) + if len(fetches) == 1: + return [high] + if second_lookup == "unavailable": + raise ExtensionError("winning catalog is unavailable") + return [low] + + def download(_self, info): + selected.append(info) + return old_archive + + monkeypatch.setattr(ExtensionCatalog, "_get_merged_extensions", merged) + monkeypatch.setattr(ExtensionCatalog, "download_extension_info", download) + monkeypatch.chdir(project) + + result = CliRunner().invoke( + app, ["extension", "add", "demo-history", "--version", "0.4.12"] + ) + + assert result.exit_code == 0, result.output + assert len(fetches) == 1 + assert selected[0]["download_url"] == "https://example.com/demo-0.4.12.zip" + + @pytest.mark.parametrize( "bad_history", [ @@ -378,6 +416,31 @@ def test_info_lists_versions_and_discovery_only_policy(tmp_path, monkeypatch): assert "Discovery only" in result.output +def test_info_versions_uses_first_resolved_catalog_snapshot(tmp_path, monkeypatch): + project = tmp_path / "project" + (project / ".specify").mkdir(parents=True) + high = _entry(_archive(tmp_path, "0.4.12")) + low = _entry(_archive(tmp_path, "0.4.12")) + low["version"] = "0.8.0" + fetches = [] + + def merged(_self): + fetches.append(True) + return [high if len(fetches) == 1 else low] + + monkeypatch.setattr(ExtensionCatalog, "_get_merged_extensions", merged) + monkeypatch.chdir(project) + + result = CliRunner().invoke( + app, ["extension", "info", "demo-history", "--versions"] + ) + + assert result.exit_code == 0, result.output + assert len(fetches) == 1 + assert "0.5.1 (current)" in result.output + assert "0.8.0" not in result.output + + def test_info_versions_preserves_catalog_failure(tmp_path, monkeypatch): from specify_cli.extensions import _commands From 1d9398747276405f5429beb2a4f01bb7aa953a24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E6=B0=B8=E7=A5=BA?= Date: Wed, 30 Sep 2026 10:01:59 +0800 Subject: [PATCH 5/5] fix(extensions): validate legacy versions and prefixed digests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Report a controlled validation error for an exact legacy catalog version that cannot match a valid archive manifest. Accept the SHA-256 prefix and whitespace supported by the archive verifier in historical release records. Assisted-by: OpenAI Codex (model: GPT-6, autonomous) Signed-off-by: 李永祺 --- docs/reference/extensions.md | 2 + src/specify_cli/extensions/__init__.py | 21 ++++---- .../extensions/_catalog_versions.py | 9 ++-- .../extensions/test_catalog_versions.py | 54 +++++++++++++++++++ 4 files changed, 74 insertions(+), 12 deletions(-) diff --git a/docs/reference/extensions.md b/docs/reference/extensions.md index d4ef53df1a..7c130c1261 100644 --- a/docs/reference/extensions.md +++ b/docs/reference/extensions.md @@ -101,6 +101,8 @@ historical releases in a `releases` mapping. Older single-version catalogs continue to work unchanged. Each historical release needs its own download URL and SHA-256 digest; release-specific requirements or provided capabilities must be placed in that release's record rather than inherited from the current one. +As with current releases, a digest may use a case-insensitive `sha256:` prefix +and surrounding whitespace. ```json { diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index 7a2116070a..cca23f5868 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -2780,15 +2780,18 @@ def install_from_archive( f"Downloaded extension declares ID '{archive_manifest.id}', " f"expected '{expected_id}'." ) - if ( - expected_version is not None - and pkg_version.Version(archive_manifest.version) - != pkg_version.Version(expected_version) - ): - raise ValidationError( - f"Downloaded extension '{archive_manifest.id}' declares version " - f"{archive_manifest.version}, expected {expected_version}." - ) + if expected_version is not None: + try: + version_matches = pkg_version.Version( + archive_manifest.version + ) == pkg_version.Version(expected_version) + except pkg_version.InvalidVersion: + version_matches = False + if not version_matches: + raise ValidationError( + f"Downloaded extension '{archive_manifest.id}' declares version " + f"{archive_manifest.version}, expected {expected_version}." + ) # Install from extracted directory return self.install_from_directory( diff --git a/src/specify_cli/extensions/_catalog_versions.py b/src/specify_cli/extensions/_catalog_versions.py index 0f110be1e3..63965cbeef 100644 --- a/src/specify_cli/extensions/_catalog_versions.py +++ b/src/specify_cli/extensions/_catalog_versions.py @@ -94,9 +94,12 @@ def _validated_releases(entry: dict[str, Any]) -> dict[str, dict[str, Any]]: raise ExtensionError( f"Extension '{extension_id}' release '{release_version}' needs a download_url." ) - if not isinstance(record.get("sha256"), str) or not _SHA256.fullmatch( - record["sha256"] - ): + digest = record.get("sha256") + if isinstance(digest, str): + digest = digest.strip() + if digest[:7].lower() == "sha256:": + digest = digest[7:].strip() + if not isinstance(digest, str) or not _SHA256.fullmatch(digest): raise ExtensionError( f"Extension '{extension_id}' release '{release_version}' needs a SHA-256 digest." ) diff --git a/tests/specify_cli/extensions/test_catalog_versions.py b/tests/specify_cli/extensions/test_catalog_versions.py index 0e17242ea3..404256baa3 100644 --- a/tests/specify_cli/extensions/test_catalog_versions.py +++ b/tests/specify_cli/extensions/test_catalog_versions.py @@ -213,6 +213,12 @@ def download(_self, info): {"0.5.1": {"download_url": "https://example.com/a.zip", "sha256": "a" * 64}}, {"0.5.1.0": {"download_url": "https://example.com/a.zip", "sha256": "a" * 64}}, {"0.4.12": {"download_url": "https://example.com/a.zip"}}, + { + "0.4.12": { + "download_url": "https://example.com/a.zip", + "sha256": "md5:" + "a" * 64, + } + }, { "0.4.12": { "download_url": "https://example.com/a.zip", @@ -254,6 +260,27 @@ def open_url(url, **_kwargs): assert downloaded.read_bytes() == archive.read_bytes() +def test_historical_release_accepts_prefixed_digest(tmp_path, monkeypatch): + archive = _archive(tmp_path, "0.4.12") + entry = _entry(archive) + digest = entry["releases"]["0.4.12"]["sha256"] + entry["releases"]["0.4.12"]["sha256"] = f" SHA256: {digest.upper()} " + catalog = _catalog(monkeypatch, tmp_path, entry) + selected = catalog.get_extension_info("demo-history", "0.4.12") + + monkeypatch.setattr( + catalog, + "_open_url", + lambda url, **_kwargs: _ArchiveResponse(archive.read_bytes(), url), + ) + downloaded = catalog.download_extension_info( + selected, target_dir=tmp_path / "downloads" + ) + + assert selected["sha256"] == f" SHA256: {digest.upper()} " + assert downloaded.read_bytes() == archive.read_bytes() + + def test_selected_discovery_release_cannot_be_downloaded(tmp_path, monkeypatch): entry = _entry(_archive(tmp_path, "0.4.12")) entry["_install_allowed"] = False @@ -286,6 +313,33 @@ def test_exact_cli_install_rejects_wrong_archive_before_writing(tmp_path, monkey ).exists() +def test_exact_cli_install_reports_invalid_legacy_catalog_version( + tmp_path, monkeypatch +): + project = tmp_path / "project" + (project / ".specify").mkdir(parents=True) + archive = _archive(tmp_path, "0.4.12") + entry = _entry(archive) + entry["version"] = "legacy-current" + del entry["releases"] + _catalog(monkeypatch, project, entry) + monkeypatch.chdir(project) + monkeypatch.setattr( + ExtensionCatalog, "download_extension_info", lambda self, _info: archive + ) + + result = CliRunner().invoke( + app, ["extension", "add", "demo-history", "--version", "legacy-current"] + ) + + assert result.exit_code == 1 + assert "Validation Error" in result.output + assert "declares version 0.4.12, expected legacy-current" in " ".join( + result.output.split() + ) + assert not (project / ".specify" / "extensions" / "demo-history").exists() + + def test_exact_cli_install_rejects_wrong_archive_id_before_writing( tmp_path, monkeypatch ):