From 61b68188bc84097b10f7005425f424e628e1009b Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Tue, 6 Oct 2026 07:34:51 -0500 Subject: [PATCH 01/23] feat(bundles): reconcile exact component pins and version conflicts Assisted-by: GitHub Copilot (model: GPT-6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/reference/bundles.md | 16 +- src/specify_cli/bundles/_commands.py | 2 +- src/specify_cli/bundles/adapters.py | 26 ++- src/specify_cli/bundles/conflict.py | 57 ++++--- src/specify_cli/bundles/installer.py | 90 +++++----- src/specify_cli/bundles/manifest.py | 12 +- src/specify_cli/bundles/primitives.py | 131 +++++++++----- src/specify_cli/bundles/references.py | 94 +++++++--- tests/specify_cli/bundles/helpers.py | 8 + .../bundles/test_command_validate.py | 9 +- tests/specify_cli/bundles/test_conflict.py | 40 ++++- tests/specify_cli/bundles/test_installer.py | 117 ++++++++++++- tests/specify_cli/bundles/test_primitives.py | 161 +++++++++++++++++- tests/specify_cli/bundles/test_references.py | 104 ++++++++++- tests/specify_cli/bundles/test_validator.py | 15 +- 15 files changed, 728 insertions(+), 154 deletions(-) diff --git a/docs/reference/bundles.md b/docs/reference/bundles.md index 613d4e0564..c48c7978eb 100644 --- a/docs/reference/bundles.md +++ b/docs/reference/bundles.md @@ -2,7 +2,7 @@ Bundles compose existing Spec Kit components — extensions, presets, workflows, and steps — into a single, versioned, installable unit. Where extensions and presets are primitives, a bundle is a curated stack that declares everything a team or role needs and installs it in one step through each component's own machinery. Bundles add no new runtime behavior of their own: they are a distribution and composition layer over the primitives you already use. -A bundle is described by a `bundle.yml` manifest and is discovered through the same catalog stack as other components. Installing a bundle resolves its declared components against pinned versions, checks for the single cross-bundle conflict point (the active integration), and applies each component idempotently with full provenance tracking so it can be cleanly removed or refreshed later. +A bundle is described by a `bundle.yml` manifest and is discovered through the same catalog stack as other components. Installing a bundle resolves its declared components against pinned versions, checks active-integration and shared-component-version conflicts, and applies each component idempotently with full provenance tracking so it can be cleanly removed or refreshed later. For a concrete starting point, see the [example bundle manifests](https://github.com/github/spec-kit/tree/main/examples/bundles) @@ -53,7 +53,7 @@ specify bundle info | `--offline` | Do not access the network | | `--json` | Emit machine-readable JSON | -Shows full metadata for a bundle along with the **fully expanded component set** it installs — every extension, preset, step, and workflow with its pinned version, plus preset priority and strategy. The output also includes a trust indicator (`verified` vs `community`) so you can judge trust before installing. This preview is the same plan `install` applies, so you can see exactly what will be added before committing. Foreseeable overlaps with components already provided by installed bundles are surfaced here as well. +Shows full metadata for a bundle along with the **fully expanded component set** it installs — every extension, preset, step, and workflow with its pinned version, plus preset priority and strategy. The output also includes a trust indicator (`verified` vs `community`) so you can judge trust before installing. This preview is the same plan `install` applies, so you can see exactly what will be added before committing. Foreseeable overlaps and version conflicts with components already provided by installed bundles are surfaced here as well. ## Install a Bundle @@ -69,7 +69,7 @@ specify bundle install Installs a bundle's full component set through each primitive's machinery. The argument may be a catalog bundle id, or a local path to a built `.zip` artifact, a bundle directory, or a `bundle.yml` file; local sources install directly without consulting the catalog stack. -If the current directory is not yet a Spec Kit project, `install` initializes one first so a fresh checkout reaches a working state in a single command. `--integration` selects the integration when initializing a new project, and confirms the target when a bundle pins a specific integration but the project's active integration can't be determined (missing or unreadable `.specify/integration.json`). It does **not** override an already-initialized project's active integration: if a bundle targets a different integration than the project's, install aborts with no changes. Integration-agnostic bundles inherit the project's active integration. Without `--refresh`, installation is idempotent — components already present are skipped. Components installed outside any bundle are skipped and never adopted, so their installed version must match the manifest pin; if it doesn't, or can't be read, install and refresh stop before changing anything and name the component, so you can remove it or install the pinned version yourself. On failure, no provenance record is written (a failed install records nothing), and the components installed during that run are removed on a best-effort basis — removal errors are swallowed, so partial on-disk state may remain. +If the current directory is not yet a Spec Kit project, `install` initializes one first so a fresh checkout reaches a working state in a single command. `--integration` selects the integration when initializing a new project, and confirms the target when a bundle pins a specific integration but the project's active integration can't be determined (missing or unreadable `.specify/integration.json`). It does **not** override an already-initialized project's active integration: if a bundle targets a different integration than the project's, install aborts with no changes. Integration-agnostic bundles inherit the project's active integration. Without `--refresh`, installation is idempotent — already-installed components matching their pins are skipped. A missing or different installed version cannot satisfy a pin. Only `--refresh` can repair a drifted version owned exclusively by this bundle; independently installed components are skipped and never adopted or replaced. On failure, no provenance record is written (a failed install records nothing), and the components installed during that run are removed on a best-effort basis — removal errors are swallowed, so partial on-disk state may remain. A normal install rejects a change to an already-recorded bundle's version or owned component metadata (version, source, preset priority, or strategy), including removal of an owned component. This applies even if a local manifest keeps the same bundle version. Reordering unchanged components or adding new components does not require refresh. To apply changes to a local bundle without adding it to a catalog, pass the revised source with `--refresh`: @@ -97,9 +97,13 @@ specify bundle update [] Re-resolves a bundle and **refreshes** its components through each primitive's update path, bringing already-installed components up to the bundle's newly pinned versions while preserving primitive-level overrides (such as preset priority). Provide a bundle id, or use `--all` to update everything installed. -**Pinned catalog releases.** An extension or preset pinned to a version other than the one its catalog currently advertises installs that exact release when the winning catalog entry lists it under `releases`, using that release's own download URL and SHA-256 digest. The downloaded archive must declare the pinned ID and version. If the winning catalog entry has no release for the pinned version, install stops with an error rather than substituting the advertised release or falling through to a lower-priority catalog. Workflows and components bundled with Spec Kit still require the pin to match the version they resolve to. +**Pinned catalog releases.** Extensions, presets, workflows, and steps with version pins select that exact release from the highest-priority active catalog entry. Historical releases must be advertised under `releases` with their own artifact URL and SHA-256 digest; the bundler never guesses an old URL from the current one or falls through to another catalog. The primitive installer verifies downloaded archive or workflow/step metadata against the selected release. A pinned workflow that ships with Spec Kit uses its bundled copy when the version matches, and the catalog release when it differs and network access is allowed. A step without a pin installs the current catalog release. -> **Pin enforcement is install-time only.** Idempotency checks are id-based, not version-aware: a component owned by a bundle that is already present is skipped during `install` without comparing its on-disk version to the manifest pin. Version pins are therefore guaranteed to be applied only when the bundler actually installs a component for the first time or refreshes it. Run `specify bundle update ` for catalog bundles or `specify bundle install --refresh` for local sources to re-apply owned components at their pinned versions. +> **One installed version per component ID.** Bundles sharing a component must agree on its pinned version. A different or unknown pin from another bundle is rejected before installation, including during `bundle update`; refreshing one bundle cannot replace a version required by another. + +A bundle may list a component ID only once per kind; duplicate references in the same manifest are invalid, even if their pins agree. + +An optional `source` in a `provides.` reference names the expected winning component catalog (as displayed by `specify catalog list`, or `specify workflow step catalog list` for steps). It is **not** an artifact URL and does not override catalog priority or install policy. When specified, the component is verified against that catalog even if already installed, rather than resolved from a Spec Kit-bundled copy; a different winning catalog or a discovery-only source prevents installation. Verifying an explicit source requires network access. Direct primitive `--from` URLs are a separate, explicitly requested route. ## Remove a Bundle @@ -145,7 +149,7 @@ specify bundle validate | `--path` | Bundle directory or `bundle.yml` (default: current directory) | | `--offline` | Verify references against bundled/installed components only | -Reports whether a `bundle.yml` is well-formed and whether every declared component reference resolves. References are checked against bundled components, the project's installed components, and — when online — the active catalogs. Validation fails only when a reference is definitively absent everywhere it could be checked: that is, when an active catalog is reachable and confirms the component is missing. References that cannot be verified — because validation is offline, or because a catalog is unreachable — are downgraded to warnings so authoring can continue, rather than failing the run. +Reports whether a `bundle.yml` is well-formed and whether every declared component reference resolves at its pinned version. References are checked against matching bundled or installed components and — when online — the exact release in the winning install-allowed catalog. An explicit `source` is verified against the winning catalog instead of resolving locally. Missing releases, mismatched sources, and discovery-only sources fail validation; references that cannot be checked offline or because a catalog is unreachable produce warnings. ## Build a Bundle Artifact diff --git a/src/specify_cli/bundles/_commands.py b/src/specify_cli/bundles/_commands.py index 5619ecc04b..66318831d8 100644 --- a/src/specify_cli/bundles/_commands.py +++ b/src/specify_cli/bundles/_commands.py @@ -136,7 +136,7 @@ def _bundle_overlaps(project_root: Path, manifest, *, offline: bool) -> list[str active_integration(project_root), load_records(project_root), ) - return list(report.overlaps) + return [*report.overlaps, *report.version_clashes] except BundlerError: return [] diff --git a/src/specify_cli/bundles/adapters.py b/src/specify_cli/bundles/adapters.py index ea3739c576..5427f01255 100644 --- a/src/specify_cli/bundles/adapters.py +++ b/src/specify_cli/bundles/adapters.py @@ -22,9 +22,9 @@ from .._assets import _locate_core_pack, _repo_root from .._download_security import MAX_JSON_CATALOG_BYTES, read_response_limited from . import BundlerError -from .yamlio import loads_json from .catalogs import CatalogSource from .manifest import ComponentRef +from .yamlio import loads_json COMMUNITY_CATALOG_URL = ( "https://raw.githubusercontent.com/github/spec-kit/main/" @@ -330,6 +330,30 @@ def installed_version( manager = self._manager_for(component, project_root) return manager.installed_version(component) + def validate_source(self, project_root: Path, component: ComponentRef) -> None: + if not self._allow_network: + raise BundlerError( + f"Cannot verify catalog source '{component.source}' for " + f"{component.kind[:-1]} '{component.id}' offline; " + "re-run without --offline." + ) + from .references import _resolved_in_catalog + + result = _resolved_in_catalog(project_root, component) + if result is True: + return + if result is None: + detail = "catalog unreachable" + elif result is False: + detail = "the winning install-allowed catalog has no matching release" + else: + detail = result + raise BundlerError( + f"Cannot verify {component.kind[:-1]} '{component.id}' at " + f"{component.version or 'current'} from catalog " + f"'{component.source}': {detail}." + ) + def install(self, project_root: Path, component: ComponentRef) -> None: manager = self._manager_for(component, project_root) manager.install(component) diff --git a/src/specify_cli/bundles/conflict.py b/src/specify_cli/bundles/conflict.py index 78938c5887..a471b13cf0 100644 --- a/src/specify_cli/bundles/conflict.py +++ b/src/specify_cli/bundles/conflict.py @@ -1,27 +1,22 @@ -"""Conflict detection across the installed-bundle stack. - -The single cross-bundle conflict point is the active integration (FR-019). -Component-level overlaps (same preset id at different priorities, etc.) are -resolved by the existing primitive machinery's own precedence rules, so the -bundler only needs to guard the integration invariant and surface informational -overlaps. -""" +"""Conflict detection across the installed-bundle stack.""" from __future__ import annotations from dataclasses import dataclass, field from .manifest import BundleManifest from .records import InstalledBundleRecord +from .versioning import same_version @dataclass class ConflictReport: integration_clash: str | None = None # message when a hard clash exists + version_clashes: list[str] = field(default_factory=list) overlaps: list[str] = field(default_factory=list) # components already provided @property def has_blocking_conflict(self) -> bool: - return self.integration_clash is not None + return self.integration_clash is not None or bool(self.version_clashes) def detect_conflicts( @@ -31,24 +26,40 @@ def detect_conflicts( ) -> ConflictReport: report = ConflictReport() - if manifest.integration is not None and active_integration: - if manifest.integration.id != active_integration: - report.integration_clash = ( - f"Bundle targets integration '{manifest.integration.id}' but the " - f"project's active integration is '{active_integration}'." - ) + if ( + manifest.integration is not None + and active_integration + and manifest.integration.id != active_integration + ): + report.integration_clash = ( + f"Bundle targets integration '{manifest.integration.id}' but the " + f"project's active integration is '{active_integration}'." + ) - already: dict[tuple[str, str], str] = {} + already: dict[tuple[str, str], list[tuple[str, str | None]]] = {} for record in installed: for component in record.contributed_components: - already[(component.kind, component.id)] = record.bundle_id + already.setdefault((component.kind, component.id), []).append( + (record.bundle_id, component.version) + ) for component in manifest.components: - owner = already.get((component.kind, component.id)) - if owner and owner != manifest.bundle.id: - report.overlaps.append( - f"{component.kind[:-1]} '{component.id}' is already provided by " - f"bundle '{owner}'." - ) + for owner, version in already.get((component.kind, component.id), []): + if owner == manifest.bundle.id: + continue + if component.version and ( + not version or not same_version(component.version, version) + ): + report.version_clashes.append( + f"{component.kind[:-1]} '{component.id}' requires version " + f"{component.version}, but bundle '{owner}' already requires " + f"version {version or ''}. Only one version can be " + "installed per ID." + ) + else: + report.overlaps.append( + f"{component.kind[:-1]} '{component.id}' is already provided by " + f"bundle '{owner}'." + ) return report diff --git a/src/specify_cli/bundles/installer.py b/src/specify_cli/bundles/installer.py index 29c7cfae61..3e213c0cbd 100644 --- a/src/specify_cli/bundles/installer.py +++ b/src/specify_cli/bundles/installer.py @@ -16,6 +16,7 @@ from typing import Protocol from . import BundlerError +from .conflict import detect_conflicts from .manifest import BundleManifest, ComponentRef from .records import ( InstalledBundleRecord, @@ -26,7 +27,6 @@ save_records, upsert_record, ) -from .conflict import detect_conflicts from .resolver import InstallPlan from .versioning import same_version @@ -36,6 +36,12 @@ class PrimitiveInstaller(Protocol): def is_installed(self, project_root: Path, component: ComponentRef) -> bool: ... + def installed_version( + self, project_root: Path, component: ComponentRef + ) -> str | None: ... + + def validate_source(self, project_root: Path, component: ComponentRef) -> None: ... + def install(self, project_root: Path, component: ComponentRef) -> None: ... def remove(self, project_root: Path, component: ComponentRef) -> None: ... @@ -78,26 +84,23 @@ def install_bundle( config (e.g. preset priority overrides) is preserved by the underlying machinery. - Version-pin enforcement is install-time only. The primitive ``is_installed`` - checks are id-based (they do not compare versions), so when a component is - already present and *refresh* is False it is skipped without verifying that - the on-disk version matches the manifest pin. Changes to a recorded bundle's - version or owned component metadata, including removals, are rejected unless - *refresh* is True, preventing stale or orphaned components. Pins are only - guaranteed to be applied when the bundler actually performs an install or a - refresh; running ``specify bundle update`` re-applies every owned component - at its pinned version. - - The exception is a component installed independently of any bundle: it is - never installed or refreshed here, so its installed version must already - match the pin, or the call fails before changing anything. + Already-installed components must match their pins before they can be + skipped. A refresh may repair drift in components owned exclusively by + this bundle, but cannot change a version required by another bundle or an + independently installed component. Changes to owned component metadata + still require refresh. """ records = load_records(project_root) if manifest is not None: report = detect_conflicts(manifest, plan.effective_integration, records) if report.has_blocking_conflict: - raise BundlerError(report.integration_clash) + raise BundlerError( + "; ".join( + [*([report.integration_clash] if report.integration_clash else []), + *report.version_clashes] + ) + ) result = InstallResult(bundle_id=plan.bundle_id) existing = find_record(records, plan.bundle_id) @@ -135,7 +138,9 @@ def install_bundle( contributed: list[ComponentRef] = [] done: list[ComponentRef] = [] try: - _check_unowned_pins(project_root, plan, installer, prior_ours | other_tracked) + _check_installed_pins( + project_root, plan, installer, prior_ours, other_tracked, refresh=refresh + ) for component in plan.components: key = (component.kind, component.id) if installer.is_installed(project_root, component): @@ -182,7 +187,7 @@ def install_bundle( except BundlerError: _rollback(project_root, installer, done) raise - except Exception as exc: # noqa: BLE001 + except Exception as exc: _rollback(project_root, installer, done) raise BundlerError( f"Failed to install bundle '{plan.bundle_id}': {exc}. " @@ -227,7 +232,7 @@ def remove_bundle( installer.remove(project_root, component) result.uninstalled.append(component) save_records(project_root, remove_record(records, bundle_id)) - except Exception as exc: # noqa: BLE001 + except Exception as exc: if result.uninstalled: detail = ( f"{len(result.uninstalled)} component(s) were already removed " @@ -252,41 +257,46 @@ def remove_bundle( return result -def _check_unowned_pins( +def _check_installed_pins( project_root: Path, plan: InstallPlan, installer: PrimitiveInstaller, - owned: set[tuple[str, str]], + prior_ours: set[tuple[str, str]], + other_tracked: set[tuple[str, str]], + *, + refresh: bool, ) -> None: - """Refuse to skip an independently installed component that misses its pin. - - A component tracked by no bundle is skipped and never refreshed (FR-022), so - skipping it is only correct when it already has the pinned version. - Otherwise the bundle record would advance while the project keeps running a - different version (#4434). Runs before any primitive is touched. A component - whose installed version can't be read fails too, since it can't be shown to - match. Installers without an ``installed_version`` hook are not checked. - """ - installed_version = getattr(installer, "installed_version", None) - if not callable(installed_version): - return + """Check installed pins before any mutation, including shared components.""" mismatches = [] for component in plan.components: - if not component.version or (component.kind, component.id) in owned: + if component.source: + installer.validate_source(project_root, component) + key = component.kind, component.id + if refresh and not component.version and key in other_tracked: + raise BundlerError( + f"Cannot refresh unpinned shared {component.kind[:-1]} " + f"'{component.id}': another bundle may require its installed " + "version. Pin this component before refreshing." + ) + if not component.version: continue if not installer.is_installed(project_root, component): continue - actual = installed_version(project_root, component) + actual = installer.installed_version(project_root, component) + if actual and same_version(actual, component.version): + continue + if refresh and key in prior_ours and key not in other_tracked: + continue pinned = f"{component.kind[:-1]} '{component.id}' to {component.version}" - if actual is None: + if not actual: mismatches.append(f"{pinned}, but its installed version is unknown") - elif not same_version(actual, component.version): + else: mismatches.append(f"{pinned}, but {actual} is installed") if mismatches: raise BundlerError( - f"Bundle '{plan.bundle_id}' pins {'; '.join(mismatches)}. Bundles " - "leave components installed outside any bundle unchanged, so remove " - "the installed version or install the pinned one yourself, then re-run." + f"Bundle '{plan.bundle_id}' pins {'; '.join(mismatches)}. Only one " + "version can be installed per ID; independently installed and " + "other bundles' components cannot be replaced by this bundle." ) @@ -315,5 +325,5 @@ def _rollback( for component in reversed(done): try: installer.remove(project_root, component) - except Exception: # noqa: BLE001 - best-effort rollback + except Exception: # noqa: BLE001, S112 - rollback cannot mask the original failure continue diff --git a/src/specify_cli/bundles/manifest.py b/src/specify_cli/bundles/manifest.py index 71304bc682..179abebd12 100644 --- a/src/specify_cli/bundles/manifest.py +++ b/src/specify_cli/bundles/manifest.py @@ -85,14 +85,14 @@ def components(self) -> list[ComponentRef]: # -- construction --------------------------------------------------------- @classmethod - def from_file(cls, path: Path) -> "BundleManifest": + def from_file(cls, path: Path) -> BundleManifest: data = load_yaml(path) manifest = cls.from_dict(data) manifest.source_path = Path(path) return manifest @classmethod - def from_dict(cls, data: Any) -> "BundleManifest": + def from_dict(cls, data: Any) -> BundleManifest: if not isinstance(data, dict): raise BundlerError("Manifest must be a YAML mapping at the top level.") @@ -192,9 +192,17 @@ def structural_errors(self) -> list[str]: "(lowercase letters, digits, '.', '_', '-'; no path separators)." ) + seen_components: set[tuple[str, str]] = set() for ref in self.components: if not ref.id: errors.append(f"A {ref.kind[:-1]} entry is missing its 'id'.") + elif (ref.kind, ref.id) in seen_components: + errors.append( + f"Duplicate {ref.kind[:-1]} '{ref.id}' in provides; " + "declare each component ID only once per kind." + ) + else: + seen_components.add((ref.kind, ref.id)) if ref.kind != "steps" and not ref.version: errors.append( f"{ref.kind[:-1]} '{ref.id or ''}' must be pinned to a 'version'." diff --git a/src/specify_cli/bundles/primitives.py b/src/specify_cli/bundles/primitives.py index 26a0518d44..b33fb3739c 100644 --- a/src/specify_cli/bundles/primitives.py +++ b/src/specify_cli/bundles/primitives.py @@ -26,6 +26,7 @@ from . import BundlerError from .manifest import ComponentRef +from .versioning import same_version DEFAULT_PRIORITY = 10 @@ -68,6 +69,7 @@ def _select_pinned_release( lower-priority catalog. An entry advertising no version cannot enforce the pin, so it is installed as resolved (mirrors ``_assert_pinned_version``). """ + _assert_catalog_source(kind, component, info) pinned = component.version advertised = info.get("version") if not pinned or advertised is None or not str(advertised).strip(): @@ -83,6 +85,51 @@ def _select_pinned_release( return selected, selected["version"] +def _assert_catalog_source(kind: str, component: ComponentRef, info: dict) -> None: + """A source identifies the winning catalog; it cannot select another one.""" + if component.source and component.source != info.get("_catalog_name"): + raise BundlerError( + f"{kind} '{component.id}' requests catalog '{component.source}', " + f"but its highest-priority source is " + f"'{info.get('_catalog_name', '')}'. " + "A bundle source cannot bypass catalog precedence." + ) + if not info.get("_install_allowed", True): + raise BundlerError( + f"{kind} '{component.id}' is from a discovery-only catalog; " + "installation is not allowed." + ) + + +def _selected_catalog_info(kind: str, component: ComponentRef, get_info) -> dict: + """Resolve an exact workflow/step release within the winning catalog.""" + current = get_info(component.id) + if current is None: + raise BundlerError(f"{kind} '{component.id}' not found in any catalog.") + _assert_catalog_source(kind, component, current) + if component.version is None: + return current + selected = get_info(component.id, version=component.version) + if selected is None: + raise BundlerError( + f"{kind} '{component.id}' has no catalog release for pinned version " + f"{component.version} in the highest-priority source." + ) + if selected.get("_catalog_name") != current.get("_catalog_name"): + raise BundlerError( + f"{kind} '{component.id}' changed catalog sources during version lookup." + ) + _assert_catalog_source(kind, component, selected) + if not selected.get("version") or not same_version( + selected["version"], component.version + ): + raise BundlerError( + f"{kind} '{component.id}' has no verifiable catalog release for " + f"pinned version {component.version}." + ) + return selected + + def _bundled_manifest_version(manifest_path: Path, root_key: str) -> str | None: """Best-effort read of a bundled asset's declared version from its manifest. @@ -102,7 +149,7 @@ def _bundled_manifest_version(manifest_path: Path, root_key: str) -> str | None: # (missing / non-string / whitespace) means "cannot enforce". if isinstance(version, str) and version.strip(): return version - except Exception: # noqa: BLE001 - unreadable/invalid manifest: skip pin + except Exception: # noqa: BLE001 - unreadable manifest: version unknown return None return None @@ -213,7 +260,7 @@ def _do_install(self, component: ComponentRef, *, force: bool) -> None: priority = DEFAULT_PRIORITY if component.priority is None else component.priority bundled = _locate_bundled_preset(component.id) - if bundled is not None: + if bundled is not None and component.source is None: # Enforce the manifest pin against the bundled asset's own version, # mirroring the catalog path below (the bundled path previously # skipped the pin entirely). @@ -241,11 +288,6 @@ def _do_install(self, component: ComponentRef, *, force: bool) -> None: info = catalog.get_pack_info(component.id) if not info: raise BundlerError(f"Preset '{component.id}' not found in any catalog.") - if not info.get("_install_allowed", True): - raise BundlerError( - f"Preset '{component.id}' is from a discovery-only catalog; " - "installation is not allowed." - ) from ..presets._catalog_versions import select_release info, expected_version = _select_pinned_release( @@ -273,7 +315,7 @@ def _do_install(self, component: ComponentRef, *, force: bool) -> None: def remove(self, component: ComponentRef) -> None: try: self._manager.remove(component.id) - except Exception as exc: # noqa: BLE001 + except Exception as exc: raise BundlerError( f"Failed to remove preset '{component.id}': {exc}" ) from exc @@ -310,7 +352,7 @@ def _do_install(self, component: ComponentRef, *, force: bool) -> None: priority = DEFAULT_PRIORITY if component.priority is None else component.priority bundled = _locate_bundled_extension(component.id) - if bundled is not None: + if bundled is not None and component.source is None: # Enforce the manifest pin against the bundled asset's own version, # mirroring the catalog path below (the bundled path previously # skipped the pin entirely). @@ -341,11 +383,6 @@ def _do_install(self, component: ComponentRef, *, force: bool) -> None: raise BundlerError( f"Extension '{component.id}' not found in any catalog." ) - if not info.get("_install_allowed", True): - raise BundlerError( - f"Extension '{component.id}' is from a discovery-only catalog; " - "installation is not allowed." - ) from ..extensions._catalog_versions import select_release info, expected_version = _select_pinned_release( @@ -374,7 +411,7 @@ def _do_install(self, component: ComponentRef, *, force: bool) -> None: def remove(self, component: ComponentRef) -> None: try: self._manager.remove(component.id) - except Exception as exc: # noqa: BLE001 + except Exception as exc: raise BundlerError( f"Failed to remove extension '{component.id}': {exc}" ) from exc @@ -401,7 +438,7 @@ def install(self, component: ComponentRef) -> None: from .._assets import _locate_bundled_workflow bundled = _locate_bundled_workflow(component.id) - if bundled is not None: + if bundled is not None and component.source is None: workflow_file = bundled / "workflow.yml" try: from ..workflows.engine import WorkflowDefinition @@ -416,18 +453,22 @@ def install(self, component: ComponentRef) -> None: f"Bundled workflow at {workflow_file} declares ID " f"'{definition.id}', expected '{component.id}'." ) - _assert_pinned_version( - "Workflow", component.id, component.version, definition.version - ) - from .. import workflow_add - - with _chdir(self._root): - _delegate_command( - "install", - f"workflow '{component.id}'", - lambda: workflow_add(str(workflow_file), dev=True, from_url=None), + if component.version is None or ( + definition.version and same_version(definition.version, component.version) + ): + from .. import workflow_add + + with _chdir(self._root): + _delegate_command( + "install", + f"workflow '{component.id}'", + lambda: workflow_add(str(workflow_file), dev=True, from_url=None), + ) + return + if not self._allow_network: + _assert_pinned_version( + "Workflow", component.id, component.version, definition.version ) - return if not self._allow_network: raise BundlerError( @@ -435,13 +476,20 @@ def install(self, component: ComponentRef) -> None: "access is disabled. Installing or refreshing this component " "requires network access; re-run without --offline." ) - self._assert_pinned_version(component) + from ..workflows.catalog import WorkflowCatalog + + _selected_catalog_info( + "Workflow", component, WorkflowCatalog(self._root).get_workflow_info + ) from .. import workflow_add with _chdir(self._root): _delegate_command( "install", f"workflow '{component.id}'", - lambda: workflow_add(component.id, dev=False, from_url=None), + lambda: workflow_add( + component.id, dev=False, from_url=None, + **({"version": component.version} if component.version else {}), + ), ) def refresh(self, component: ComponentRef) -> None: @@ -449,20 +497,6 @@ def refresh(self, component: ComponentRef) -> None: # to the standard install path which handles version refresh correctly. self.install(component) - def _assert_pinned_version(self, component: ComponentRef) -> None: - if not component.version: - return - try: - from ..workflows.catalog import WorkflowCatalog - - info = WorkflowCatalog(self._root).get_workflow_info(component.id) - except Exception: # noqa: BLE001 - catalog unreachable: cannot enforce - return - if info: - _assert_pinned_version( - "Workflow", component.id, component.version, info.get("version") - ) - def remove(self, component: ComponentRef) -> None: from .. import workflow_remove @@ -498,11 +532,20 @@ def install(self, component: ComponentRef) -> None: "network access; re-run without --offline." ) from .. import workflow_step_add + if component.version or component.source: + from ..workflows.catalog import StepCatalog + + _selected_catalog_info( + "Step", component, StepCatalog(self._root).get_step_info + ) with _chdir(self._root): _delegate_command( "install", f"step '{component.id}'", - lambda: workflow_step_add(component.id), + lambda: workflow_step_add( + component.id, + **({"version": component.version} if component.version else {}), + ), ) def refresh(self, component: ComponentRef) -> None: diff --git a/src/specify_cli/bundles/references.py b/src/specify_cli/bundles/references.py index 822b3d991a..de13b1bcfb 100644 --- a/src/specify_cli/bundles/references.py +++ b/src/specify_cli/bundles/references.py @@ -13,35 +13,48 @@ from pathlib import Path from .manifest import ComponentRef +from .versioning import same_version + + +def _matches_pin(component: ComponentRef, actual: str | None) -> bool: + return component.version is None or ( + bool(actual) and same_version(actual, component.version) + ) def _resolved_locally(root: Path, component: ComponentRef) -> bool: kind = component.kind try: + if component.source: + return False + from .primitives import _bundled_manifest_version, primitive_manager + if kind == "presets": from .._assets import _locate_bundled_preset - from ..presets import PresetManager - if _locate_bundled_preset(component.id) is not None: + bundled = _locate_bundled_preset(component.id) + if bundled is not None and _matches_pin( + component, _bundled_manifest_version(bundled / "preset.yml", "preset") + ): return True - return PresetManager(root).get_pack(component.id) is not None if kind == "extensions": from .._assets import _locate_bundled_extension - from ..extensions import ExtensionManager - if _locate_bundled_extension(component.id) is not None: + bundled = _locate_bundled_extension(component.id) + if bundled is not None and _matches_pin( + component, _bundled_manifest_version(bundled / "extension.yml", "extension") + ): return True - return ExtensionManager(root).registry.is_installed(component.id) if kind == "workflows": from .._assets import _locate_bundled_workflow - from ..workflows.catalog import WorkflowRegistry - if _locate_bundled_workflow(component.id) is not None: + bundled = _locate_bundled_workflow(component.id) + if bundled is not None and _matches_pin( + component, _bundled_manifest_version(bundled / "workflow.yml", "workflow") + ): return True - return WorkflowRegistry(root).is_installed(component.id) if kind == "steps": from ..workflows import BUILTIN_STEP_TYPES - from ..workflows.catalog import StepRegistry # Step types ship with Spec Kit as built-ins (shell, gate, if, ...) # rather than as an on-disk asset directory, so there is no @@ -53,36 +66,72 @@ def _resolved_locally(root: Path, component: ComponentRef) -> bool: # loaded for one project would be accepted as "bundled" when # validating another. Without any bundled check at all, every # built-in step type looked unresolved. - if component.id in BUILTIN_STEP_TYPES: + if component.id in BUILTIN_STEP_TYPES and component.version is None: return True - return StepRegistry(root).is_installed(component.id) + manager = primitive_manager(kind, root, allow_network=False) + return manager.is_installed(component) and _matches_pin( + component, manager.installed_version(component) + ) except Exception: # noqa: BLE001 - resolution is best-effort return False return False -def _resolved_in_catalog(root: Path, component: ComponentRef) -> bool | None: - """Return True/False if a catalog could be consulted, or None on failure.""" +def _catalog_has_release(component: ComponentRef, get_info) -> bool: + current = get_info(component.id) + if current is None or not current.get("_install_allowed", True): + return False + if component.source and current.get("_catalog_name") != component.source: + return False + if component.version is None: + return True + selected = get_info(component.id, version=component.version) + return ( + selected is not None + and selected.get("_catalog_name") == current.get("_catalog_name") + and selected.get("_install_allowed", True) + and _matches_pin(component, selected.get("version")) + ) + + +def _resolved_in_catalog(root: Path, component: ComponentRef) -> bool | str | None: + """Return the lookup result, a validation error, or None if unreachable.""" kind = component.kind try: if kind == "presets": from ..presets import PresetCatalog - return PresetCatalog(root).get_pack_info(component.id) is not None + return _catalog_has_release(component, PresetCatalog(root).get_pack_info) if kind == "extensions": from ..extensions import ExtensionCatalog - return ExtensionCatalog(root).get_extension_info(component.id) is not None + return _catalog_has_release( + component, ExtensionCatalog(root).get_extension_info + ) if kind == "workflows": from ..workflows.catalog import WorkflowCatalog - return WorkflowCatalog(root).get_workflow_info(component.id) is not None + return _catalog_has_release( + component, WorkflowCatalog(root).get_workflow_info + ) if kind == "steps": from ..workflows.catalog import StepCatalog - return StepCatalog(root).get_step_info(component.id) is not None - except Exception: # noqa: BLE001 - catalog may be unreachable/misconfigured + return _catalog_has_release(component, StepCatalog(root).get_step_info) + except (ConnectionError, TimeoutError): return None + except Exception as exc: # noqa: BLE001 - report malformed catalog errors + from ..workflows.catalog import StepCatalogError, WorkflowCatalogError + + if ( + isinstance(exc, WorkflowCatalogError) + and str(exc) == "All configured catalogs failed to fetch." + ) or ( + isinstance(exc, StepCatalogError) + and str(exc) == "All configured step catalogs failed to fetch." + ): + return None + return f"Catalog lookup failed: {exc}" return None @@ -109,9 +158,12 @@ def check(component: ComponentRef) -> str | None: return None if in_catalog is False: return ( - f"{component.kind[:-1]} '{component.id}' is not bundled, " - "installed, or present in any active catalog." + f"{component.kind[:-1]} '{component.id}' at " + f"{component.version or 'current'} is not available " + "locally or from the selected install-allowed catalog." ) + if isinstance(in_catalog, str): + return in_catalog warnings.append( f"Could not verify {component.kind[:-1]} '{component.id}' " "(catalog unreachable); reference left unchecked." diff --git a/tests/specify_cli/bundles/helpers.py b/tests/specify_cli/bundles/helpers.py index e536b7e554..0d793ea5a7 100644 --- a/tests/specify_cli/bundles/helpers.py +++ b/tests/specify_cli/bundles/helpers.py @@ -132,6 +132,9 @@ def is_installed(self, project_root: Path, component: ComponentRef) -> bool: def installed_version(self, project_root: Path, component: ComponentRef) -> str | None: return self.versions.get(self._key(component)) + def validate_source(self, project_root: Path, component: ComponentRef) -> None: + pass + def install(self, project_root: Path, component: ComponentRef) -> None: from specify_cli.bundler import BundlerError @@ -139,11 +142,16 @@ def install(self, project_root: Path, component: ComponentRef) -> None: if self._fail_on is not None and component.id == self._fail_on: raise BundlerError(f"Simulated failure installing {component.id}") self.installed.add(self._key(component)) + if component.version is not None: + self.versions[self._key(component)] = component.version def remove(self, project_root: Path, component: ComponentRef) -> None: self.remove_calls.append(self._key(component)) self.installed.discard(self._key(component)) + self.versions.pop(self._key(component), None) def refresh(self, project_root: Path, component: ComponentRef) -> None: self.refresh_calls.append(self._key(component)) self.installed.add(self._key(component)) + if component.version is not None: + self.versions[self._key(component)] = component.version diff --git a/tests/specify_cli/bundles/test_command_validate.py b/tests/specify_cli/bundles/test_command_validate.py index 28da55bdd8..0468f9f193 100644 --- a/tests/specify_cli/bundles/test_command_validate.py +++ b/tests/specify_cli/bundles/test_command_validate.py @@ -5,14 +5,15 @@ from pathlib import Path from unittest.mock import patch # noqa: F401 -import yaml # noqa: F401 import pytest +import yaml from typer.testing import CliRunner from specify_cli import app from specify_cli.bundles.packager import build_bundle # noqa: F401 -from tests.conftest import strip_ansi # noqa: F401 +from tests.conftest import strip_ansi from tests.specify_cli.bundles.helpers import ( + bundled_extension_version, valid_manifest_dict, ) @@ -98,7 +99,9 @@ def test_validate_rejects_broken_reference(project: Path): def test_validate_accepts_bundled_reference(project: Path): data = valid_manifest_dict() - data["provides"] = {"extensions": [{"id": "agent-context", "version": "1.0.0"}]} + data["provides"] = {"extensions": [{ + "id": "agent-context", "version": bundled_extension_version("agent-context") + }]} (project / "bundle.yml").write_text(yaml.safe_dump(data), encoding="utf-8") result = runner.invoke(app, ["bundle", "validate"]) assert result.exit_code == 0, result.output diff --git a/tests/specify_cli/bundles/test_conflict.py b/tests/specify_cli/bundles/test_conflict.py index ca92f0e825..39d01fcafb 100644 --- a/tests/specify_cli/bundles/test_conflict.py +++ b/tests/specify_cli/bundles/test_conflict.py @@ -1,9 +1,10 @@ """Unit tests for conflict detection (T034): integration clash and overlap precedence.""" from __future__ import annotations -from specify_cli.bundles.manifest import BundleManifest, ComponentRef -from specify_cli.bundles.records import InstalledBundleRecord +from specify_cli.bundles._commands import _bundle_overlaps from specify_cli.bundles.conflict import detect_conflicts +from specify_cli.bundles.manifest import BundleManifest, ComponentRef +from specify_cli.bundles.records import InstalledBundleRecord, save_records from tests.specify_cli.bundles.helpers import valid_manifest_dict @@ -36,7 +37,7 @@ def test_overlap_with_other_bundle_is_reported(): other = InstalledBundleRecord.create( bundle_id="other", version="1.0.0", - components=[ComponentRef(kind="presets", id="preset-a")], + components=[ComponentRef(kind="presets", id="preset-a", version="2.0.0")], ) report = detect_conflicts(manifest, active_integration="copilot", installed=[other]) assert any("preset-a" in o and "other" in o for o in report.overlaps) @@ -52,3 +53,36 @@ def test_same_bundle_reinstall_is_not_overlap(): ) report = detect_conflicts(manifest, active_integration="copilot", installed=[same]) assert report.overlaps == [] + + +def test_incompatible_pins_from_multiple_bundles_are_blocking(tmp_path): + manifest = _manifest() + records = [ + InstalledBundleRecord.create( + bundle_id=name, version="1.0.0", + components=[ComponentRef(kind="presets", id="preset-a", version=version)], + ) + for name, version in (("compatible", "v2.0.0"), ("incompatible", "3.0.0")) + ] + report = detect_conflicts(manifest, "copilot", records) + + assert report.has_blocking_conflict + assert len(report.version_clashes) == 1 + assert "incompatible" in report.version_clashes[0] + assert "3.0.0" in report.version_clashes[0] + assert len(report.overlaps) == 1 + + save_records(tmp_path, records) + assert report.version_clashes[0] in _bundle_overlaps(tmp_path, manifest, offline=True) + + +def test_unknown_existing_pin_cannot_satisfy_an_exact_pin(): + manifest = _manifest() + other = InstalledBundleRecord.create( + bundle_id="other", version="1.0.0", + components=[ComponentRef(kind="presets", id="preset-a")], + ) + report = detect_conflicts(manifest, "copilot", [other]) + + assert report.has_blocking_conflict + assert "" in report.version_clashes[0] diff --git a/tests/specify_cli/bundles/test_installer.py b/tests/specify_cli/bundles/test_installer.py index 57bf044568..d420411db6 100644 --- a/tests/specify_cli/bundles/test_installer.py +++ b/tests/specify_cli/bundles/test_installer.py @@ -10,11 +10,15 @@ import pytest from specify_cli.bundler import BundlerError +from specify_cli.bundles.installer import install_bundle, remove_bundle from specify_cli.bundles.manifest import BundleManifest from specify_cli.bundles.records import load_records, records_path -from specify_cli.bundles.installer import install_bundle, remove_bundle from specify_cli.bundles.resolver import resolve_install_plan -from tests.specify_cli.bundles.helpers import FakeInstaller, make_project, valid_manifest_dict +from tests.specify_cli.bundles.helpers import ( + FakeInstaller, + make_project, + valid_manifest_dict, +) def _plan(manifest): @@ -51,6 +55,115 @@ def test_install_is_idempotent(tmp_path: Path): assert len(load_records(tmp_path)) == 1 +def test_second_bundle_cannot_claim_different_pin_before_mutation(tmp_path: Path): + make_project(tmp_path) + installer = FakeInstaller() + first = _bundle("first", ["ext-a"], version="1.0.0") + install_bundle(tmp_path, _plan(first), installer, manifest=first) + other = _bundle("other", ["ext-a", "ext-b"], version="2.0.0") + + with pytest.raises(BundlerError, match="Only one version"): + install_bundle(tmp_path, _plan(other), installer, manifest=other) + + assert [r.bundle_id for r in load_records(tmp_path)] == ["first"] + assert installer.install_calls == [("extensions", "ext-a")] + + +def test_owned_component_drift_is_rejected_without_refresh(tmp_path: Path): + make_project(tmp_path) + installer = FakeInstaller() + manifest = _bundle("first", ["ext-a"], version="1.0.0") + install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest) + installer.versions[("extensions", "ext-a")] = "0.9.0" + + with pytest.raises(BundlerError, match="0.9.0"): + install_bundle(tmp_path, _plan(manifest), installer, manifest=manifest) + assert installer.refresh_calls == [] + + result = install_bundle( + tmp_path, _plan(manifest), installer, manifest=manifest, refresh=True + ) + assert result.refreshed == manifest.components + + +def test_shared_component_drift_cannot_be_refreshed(tmp_path: Path): + make_project(tmp_path) + installer = FakeInstaller() + first = _bundle("first", ["ext-a"], version="1.0.0") + second = _bundle("second", ["ext-a"], version="1.0.0") + install_bundle(tmp_path, _plan(first), installer, manifest=first) + install_bundle(tmp_path, _plan(second), installer, manifest=second) + installer.versions[("extensions", "ext-a")] = "0.9.0" + + with pytest.raises(BundlerError, match="0.9.0"): + install_bundle(tmp_path, _plan(first), installer, manifest=first, refresh=True) + assert installer.refresh_calls == [] + + +def test_unpinned_shared_step_cannot_refresh_another_bundles_pin(tmp_path: Path): + make_project(tmp_path) + installer = FakeInstaller() + pinned_data = valid_manifest_dict() + pinned_data["bundle"]["id"] = "pinned" + pinned_data["provides"] = {"steps": [{"id": "shared", "version": "1.0.0"}]} + pinned = BundleManifest.from_dict(pinned_data) + unpinned_data = valid_manifest_dict() + unpinned_data["bundle"]["id"] = "unpinned" + unpinned_data["provides"] = {"steps": [{"id": "shared"}]} + unpinned = BundleManifest.from_dict(unpinned_data) + install_bundle(tmp_path, _plan(pinned), installer, manifest=pinned) + install_bundle(tmp_path, _plan(unpinned), installer, manifest=unpinned) + + with pytest.raises(BundlerError, match="unpinned shared"): + install_bundle( + tmp_path, _plan(unpinned), installer, manifest=unpinned, refresh=True + ) + assert installer.refresh_calls == [] + + +@pytest.mark.parametrize("winning,accepted", [("expected", True), ("other", False)]) +def test_source_is_checked_even_when_component_is_already_installed( + tmp_path: Path, monkeypatch, winning: str, accepted: bool, +): + from specify_cli.bundles.adapters import DefaultPrimitiveInstaller + from specify_cli.extensions import ExtensionCatalog, ExtensionRegistry + + make_project(tmp_path) + ExtensionRegistry(tmp_path / ".specify" / "extensions").add( + "ext-a", {"version": "1.0.0"} + ) + lookups = [] + + def get_info(_self, _id, version=None): + lookups.append(version) + return { + "version": "1.0.0", "_catalog_name": winning, + "_install_allowed": True, + } + + monkeypatch.setattr(ExtensionCatalog, "get_extension_info", get_info) + data = valid_manifest_dict() + data["provides"] = { + "extensions": [ + {"id": "ext-a", "version": "1.0.0", "source": "expected"} + ] + } + manifest = BundleManifest.from_dict(data) + + if accepted: + result = install_bundle( + tmp_path, _plan(manifest), DefaultPrimitiveInstaller(), manifest=manifest + ) + assert result.skipped == manifest.components + assert lookups == [None, "1.0.0"] + else: + with pytest.raises(BundlerError, match="expected"): + install_bundle( + tmp_path, _plan(manifest), DefaultPrimitiveInstaller(), manifest=manifest + ) + assert not records_path(tmp_path).exists() + + def test_install_rejects_version_change_without_refresh(tmp_path: Path): """A normal install must not advance a record past stale components. diff --git a/tests/specify_cli/bundles/test_primitives.py b/tests/specify_cli/bundles/test_primitives.py index 9cf6d3adb4..58de47e7cc 100644 --- a/tests/specify_cli/bundles/test_primitives.py +++ b/tests/specify_cli/bundles/test_primitives.py @@ -83,6 +83,160 @@ def _add(step_id: str) -> None: assert calls == [("catalog-step", tmp_path)] +@pytest.mark.parametrize( + "kind,catalog_module,catalog_class,lookup", + [ + ("workflows", "specify_cli.workflows.catalog", "WorkflowCatalog", "get_workflow_info"), + ("steps", "specify_cli.workflows.catalog", "StepCatalog", "get_step_info"), + ], +) +def test_catalog_workflow_and_step_pins_delegate_exact_release( + tmp_path, monkeypatch, kind, catalog_module, catalog_class, lookup, +): + import importlib + + import specify_cli + import specify_cli._assets as assets + + if kind == "workflows": + monkeypatch.setattr(assets, "_locate_bundled_workflow", lambda _id: None) + catalog = getattr(importlib.import_module(catalog_module), catalog_class) + lookups = [] + + def get_info(_self, _id, version=None): + lookups.append(version) + return { + "version": version or "2.0.0", + "_catalog_name": "trusted", + "_install_allowed": True, + } + + monkeypatch.setattr(catalog, lookup, get_info) + calls = [] + command = "workflow_add" if kind == "workflows" else "workflow_step_add" + monkeypatch.setattr( + specify_cli, command, + lambda cid, **options: calls.append((cid, options, Path.cwd())), + ) + component = ComponentRef(kind=kind, id="catalog-id", version="1.0.0", source="trusted") + + primitive_manager(kind, tmp_path).install(component) + + assert lookups == [None, "1.0.0"] + assert calls[0][0] == component.id + assert calls[0][1]["version"] == "1.0.0" + assert calls[0][2] == tmp_path + + +@pytest.mark.parametrize("kind", ["extensions", "presets", "workflows", "steps"]) +def test_explicit_source_cannot_bypass_winning_catalog(tmp_path, monkeypatch, kind): + import specify_cli._assets as assets + from specify_cli.extensions import ExtensionCatalog + from specify_cli.presets import PresetCatalog + from specify_cli.workflows.catalog import StepCatalog, WorkflowCatalog + + catalogs = { + "extensions": (ExtensionCatalog, "get_extension_info", "_locate_bundled_extension"), + "presets": (PresetCatalog, "get_pack_info", "_locate_bundled_preset"), + "workflows": (WorkflowCatalog, "get_workflow_info", "_locate_bundled_workflow"), + "steps": (StepCatalog, "get_step_info", None), + } + catalog, method, asset = catalogs[kind] + if asset: + monkeypatch.setattr(assets, asset, lambda _id: None) + monkeypatch.setattr( + catalog, method, + lambda _self, _id, version=None: { + "version": version or "2.0.0", + "_catalog_name": "winning", + "_install_allowed": True, + }, + ) + component = ComponentRef(kind=kind, id="catalog-id", version="1.0.0", source="lower") + + with pytest.raises(BundlerError, match="winning"): + primitive_manager(kind, tmp_path).install(component) + + +@pytest.mark.parametrize("kind", ["workflows", "steps"]) +def test_missing_exact_release_never_delegates_install(tmp_path, monkeypatch, kind): + import specify_cli + import specify_cli._assets as assets + from specify_cli.workflows.catalog import StepCatalog, WorkflowCatalog + + if kind == "workflows": + monkeypatch.setattr(assets, "_locate_bundled_workflow", lambda _id: None) + catalog, lookup, command = ( + (WorkflowCatalog, "get_workflow_info", "workflow_add") + if kind == "workflows" + else (StepCatalog, "get_step_info", "workflow_step_add") + ) + monkeypatch.setattr( + catalog, lookup, + lambda _self, _id, version=None: ( + {"version": "2.0.0", "_catalog_name": "winning"} + if version is None else None + ), + ) + calls = [] + monkeypatch.setattr(specify_cli, command, lambda *a, **k: calls.append((a, k))) + + with pytest.raises(BundlerError, match="no catalog release"): + primitive_manager(kind, tmp_path).install( + ComponentRef(kind=kind, id="catalog-id", version="1.0.0") + ) + assert calls == [] + + +def test_bundled_workflow_with_older_pin_uses_catalog_release(tmp_path, monkeypatch): + import specify_cli + import specify_cli._assets as assets + from specify_cli.workflows.catalog import WorkflowCatalog + + bundled = _write_manifest(tmp_path / "bundled", "workflow", "2.0.0") + monkeypatch.setattr(assets, "_locate_bundled_workflow", lambda _id: bundled) + monkeypatch.setattr( + WorkflowCatalog, "get_workflow_info", + lambda _self, _id, version=None: { + "version": version or "2.0.0", "_catalog_name": "winning", + }, + ) + calls = [] + monkeypatch.setattr( + specify_cli, "workflow_add", + lambda source, **options: calls.append((source, options)), + ) + + primitive_manager("workflows", tmp_path).install( + ComponentRef(kind="workflows", id="x", version="1.0.0") + ) + + assert calls == [("x", {"dev": False, "from_url": None, "version": "1.0.0"})] + + +def test_source_on_bundled_extension_requires_catalog(tmp_path, monkeypatch): + import specify_cli._assets as assets + from specify_cli.extensions import ExtensionCatalog + + monkeypatch.setattr( + assets, "_locate_bundled_extension", + lambda _id: _write_manifest(tmp_path / "bundled", "extension", "1.0.0"), + ) + monkeypatch.setattr( + ExtensionCatalog, "get_extension_info", + lambda _self, _id: { + "version": "1.0.0", "_catalog_name": "other", + "_install_allowed": True, + }, + ) + with pytest.raises(BundlerError, match="other"): + primitive_manager("extensions", tmp_path).install( + ComponentRef( + kind="extensions", id="x", version="1.0.0", source="expected" + ) + ) + + def test_default_installer_threads_allow_network(tmp_path: Path): installer = DefaultPrimitiveInstaller(allow_network=False) with pytest.raises(BundlerError, match="network access is disabled"): @@ -169,11 +323,14 @@ def test_workflow_version_mismatch_refuses(tmp_path: Path, monkeypatch): from specify_cli.workflows.catalog import WorkflowCatalog monkeypatch.setattr( - WorkflowCatalog, "get_workflow_info", lambda self, wid: {"version": "9.9.9"} + WorkflowCatalog, "get_workflow_info", + lambda self, wid, version=None: ( + {"version": "9.9.9"} if version is None else None + ), ) manager = primitive_manager("workflows", tmp_path, allow_network=True) component = ComponentRef(kind="workflows", id="wf-a", version="0.3.0") - with pytest.raises(BundlerError, match="pinned to version 0.3.0"): + with pytest.raises(BundlerError, match="no catalog release for pinned version 0.3.0"): manager.install(component) diff --git a/tests/specify_cli/bundles/test_references.py b/tests/specify_cli/bundles/test_references.py index a020d64a9d..11999e0372 100644 --- a/tests/specify_cli/bundles/test_references.py +++ b/tests/specify_cli/bundles/test_references.py @@ -9,18 +9,20 @@ from specify_cli.bundles.manifest import ComponentRef from specify_cli.bundles.references import make_reference_checker -from tests.specify_cli.bundles.helpers import make_project +from tests.specify_cli.bundles.helpers import bundled_extension_version, make_project -def _ref(kind: str, id_: str) -> ComponentRef: - return ComponentRef(kind=kind, id=id_, version="1.0.0") +def _ref(kind: str, id_: str, version: str | None = "1.0.0") -> ComponentRef: + return ComponentRef(kind=kind, id=id_, version=version) def test_bundled_extension_resolves(tmp_path: Path): root = make_project(tmp_path) warnings: list[str] = [] check = make_reference_checker(root, allow_network=True, warnings=warnings) - assert check(_ref("extensions", "agent-context")) is None + assert check( + _ref("extensions", "agent-context", bundled_extension_version("agent-context")) + ) is None assert warnings == [] @@ -42,7 +44,7 @@ def test_builtin_step_type_resolves(tmp_path: Path): for step_id in ("shell", "gate", "command", "if", "slot"): assert step_id in BUILTIN_STEP_TYPES, step_id - assert check(_ref("steps", step_id)) is None, step_id + assert check(_ref("steps", step_id, None)) is None, step_id assert warnings == [] @@ -110,3 +112,95 @@ def test_unknown_reference_warns_offline(tmp_path: Path): check = make_reference_checker(root, allow_network=False, warnings=warnings) assert check(_ref("presets", "does-not-exist")) is None assert any("does-not-exist" in w for w in warnings) + + +def test_wrong_bundled_pin_does_not_resolve_locally(tmp_path): + root = make_project(tmp_path) + warnings = [] + check = make_reference_checker(root, allow_network=False, warnings=warnings) + + assert check(_ref("extensions", "agent-context", "999.0.0")) is None + assert any("agent-context" in message for message in warnings) + + +def test_online_validation_checks_winning_exact_release_and_source(tmp_path, monkeypatch): + import specify_cli._assets as assets + from specify_cli.workflows.catalog import WorkflowCatalog + + monkeypatch.setattr(assets, "_locate_bundled_workflow", lambda _id: None) + lookups = [] + + def lookup(_self, _id, version=None): + lookups.append(version) + if version == "1.0.0": + return { + "version": "1.0.0", "_catalog_name": "winning", + "_install_allowed": True, + } + if version is None: + return { + "version": "2.0.0", "_catalog_name": "winning", + "_install_allowed": True, + } + return None + + monkeypatch.setattr(WorkflowCatalog, "get_workflow_info", lookup) + warnings = [] + check = make_reference_checker(tmp_path, allow_network=True, warnings=warnings) + requested = ComponentRef( + kind="workflows", id="catalog-workflow", version="1.0.0", source="winning" + ) + + assert check(requested) is None + assert lookups == [None, "1.0.0"] + assert check(ComponentRef(kind="workflows", id=requested.id, version="3.0.0")) + assert check(ComponentRef( + kind="workflows", id=requested.id, version="1.0.0", source="lower" + )) + assert warnings == [] + + +def test_online_validation_rejects_discovery_only_exact_release(tmp_path, monkeypatch): + from specify_cli.workflows.catalog import StepCatalog + + monkeypatch.setattr( + StepCatalog, "get_step_info", + lambda _self, _id, version=None: { + "version": version or "2.0.0", "_catalog_name": "winning", + "_install_allowed": version is None, + }, + ) + warnings = [] + check = make_reference_checker(tmp_path, allow_network=True, warnings=warnings) + + assert check(_ref("steps", "catalog-step", "1.0.0")) + assert warnings == [] + + +def test_online_validation_reports_invalid_release_metadata(tmp_path, monkeypatch): + from specify_cli.workflows.catalog import StepCatalog, StepCatalogError + + def invalid_release(_self, _id, version=None): + raise StepCatalogError("Step release needs a SHA-256 digest.") + + monkeypatch.setattr(StepCatalog, "get_step_info", invalid_release) + warnings = [] + check = make_reference_checker(tmp_path, allow_network=True, warnings=warnings) + + assert "SHA-256" in check(_ref("steps", "invalid-release")) + assert warnings == [] + + +def test_online_validation_warns_when_catalogs_are_unreachable(tmp_path, monkeypatch): + from specify_cli.workflows.catalog import WorkflowCatalog, WorkflowCatalogError + + def unavailable(_self, _id, version=None): + raise WorkflowCatalogError("All configured catalogs failed to fetch.") + + monkeypatch.setattr(WorkflowCatalog, "get_workflow_info", unavailable) + warnings = [] + check = make_reference_checker(tmp_path, allow_network=True, warnings=warnings) + + assert check(_ref("workflows", "unreachable")) is None + assert len(warnings) == 1 + assert "unreachable" in warnings[0] diff --git a/tests/specify_cli/bundles/test_validator.py b/tests/specify_cli/bundles/test_validator.py index 855fb80cc1..19b6ba380e 100644 --- a/tests/specify_cli/bundles/test_validator.py +++ b/tests/specify_cli/bundles/test_validator.py @@ -3,8 +3,8 @@ import pytest -from specify_cli.bundles.manifest import BundleManifest from specify_cli.bundles import validator as validator_mod +from specify_cli.bundles.manifest import BundleManifest from specify_cli.bundles.validator import validate_manifest from tests.specify_cli.bundles.helpers import valid_manifest_dict @@ -20,6 +20,19 @@ def test_invalid_speckit_constraint_reported_as_error(): assert any("speckit_version" in e for e in report.errors) +def test_duplicate_component_id_cannot_request_two_versions(): + data = valid_manifest_dict() + data["provides"]["extensions"].append({"id": "ext-a", "version": "2.0.0"}) + + report = validate_manifest(BundleManifest.from_dict(data)) + + assert not report.ok + assert any( + "ext-a" in error and "duplicate" in error.lower() + for error in report.errors + ) + + def test_non_bundler_error_not_swallowed(monkeypatch): # A programming error inside constraint parsing must propagate, not be # masked behind an "invalid constraint" validation message. From 68aad7da00100a2db90434b266031d48ab9d068d Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Tue, 6 Oct 2026 08:16:51 -0500 Subject: [PATCH 02/23] Fix selected bundle release installation and catalog outage validation Assisted-by: GitHub Copilot (model: GPT-6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/reference/bundles.md | 4 +- src/specify_cli/bundles/primitives.py | 47 +++--- src/specify_cli/bundles/references.py | 16 ++- src/specify_cli/extensions/__init__.py | 34 ++++- src/specify_cli/presets/_catalog.py | 12 +- src/specify_cli/workflows/_commands.py | 26 ++-- src/specify_cli/workflows/command_add.py | 64 ++++++--- src/specify_cli/workflows/step/command_add.py | 36 +++-- tests/specify_cli/bundles/test_primitives.py | 136 +++++++++++++++--- tests/specify_cli/bundles/test_references.py | 82 +++++++++++ .../workflows/step/test_command_add.py | 42 ++++++ .../workflows/test_catalog_versions.py | 54 +++++++ 12 files changed, 460 insertions(+), 93 deletions(-) diff --git a/docs/reference/bundles.md b/docs/reference/bundles.md index c48c7978eb..902c56c32c 100644 --- a/docs/reference/bundles.md +++ b/docs/reference/bundles.md @@ -97,7 +97,7 @@ specify bundle update [] Re-resolves a bundle and **refreshes** its components through each primitive's update path, bringing already-installed components up to the bundle's newly pinned versions while preserving primitive-level overrides (such as preset priority). Provide a bundle id, or use `--all` to update everything installed. -**Pinned catalog releases.** Extensions, presets, workflows, and steps with version pins select that exact release from the highest-priority active catalog entry. Historical releases must be advertised under `releases` with their own artifact URL and SHA-256 digest; the bundler never guesses an old URL from the current one or falls through to another catalog. The primitive installer verifies downloaded archive or workflow/step metadata against the selected release. A pinned workflow that ships with Spec Kit uses its bundled copy when the version matches, and the catalog release when it differs and network access is allowed. A step without a pin installs the current catalog release. +**Pinned catalog releases.** Extensions, presets, workflows, and steps with version pins select that exact release from the highest-priority active catalog entry. Historical releases must be advertised under `releases` with their own artifact URL and SHA-256 digest; the bundler never guesses an old URL from the current one or falls through to another catalog. The primitive installer uses the selected workflow or step record without re-reading the catalog, and verifies downloaded archive or workflow/step metadata against that release. A pinned workflow that ships with Spec Kit uses its bundled copy when the version matches, and the catalog release when it differs and network access is allowed. A step without a pin installs the current catalog release. > **One installed version per component ID.** Bundles sharing a component must agree on its pinned version. A different or unknown pin from another bundle is rejected before installation, including during `bundle update`; refreshing one bundle cannot replace a version required by another. @@ -149,7 +149,7 @@ specify bundle validate | `--path` | Bundle directory or `bundle.yml` (default: current directory) | | `--offline` | Verify references against bundled/installed components only | -Reports whether a `bundle.yml` is well-formed and whether every declared component reference resolves at its pinned version. References are checked against matching bundled or installed components and — when online — the exact release in the winning install-allowed catalog. An explicit `source` is verified against the winning catalog instead of resolving locally. Missing releases, mismatched sources, and discovery-only sources fail validation; references that cannot be checked offline or because a catalog is unreachable produce warnings. +Reports whether a `bundle.yml` is well-formed and whether every declared component reference resolves at its pinned version. References are checked against matching bundled or installed components and — when online — the exact release in the winning install-allowed catalog. An explicit `source` is verified against the winning catalog instead of resolving locally. Missing releases, mismatched sources, discovery-only sources, and malformed catalog metadata fail validation; references that cannot be checked offline or because a catalog is unreachable produce warnings. ## Build a Bundle Artifact diff --git a/src/specify_cli/bundles/primitives.py b/src/specify_cli/bundles/primitives.py index b33fb3739c..7014b29195 100644 --- a/src/specify_cli/bundles/primitives.py +++ b/src/specify_cli/bundles/primitives.py @@ -478,17 +478,16 @@ def install(self, component: ComponentRef) -> None: ) from ..workflows.catalog import WorkflowCatalog - _selected_catalog_info( + selected = _selected_catalog_info( "Workflow", component, WorkflowCatalog(self._root).get_workflow_info ) - from .. import workflow_add + from ..workflows.command_add import _install_preselected_workflow with _chdir(self._root): _delegate_command( "install", f"workflow '{component.id}'", - lambda: workflow_add( - component.id, dev=False, from_url=None, - **({"version": component.version} if component.version else {}), + lambda: _install_preselected_workflow( + component.id, version=component.version, selected_info=selected, ), ) @@ -531,22 +530,38 @@ def install(self, component: ComponentRef) -> None: "is disabled. Installing or refreshing this component requires " "network access; re-run without --offline." ) - from .. import workflow_step_add - if component.version or component.source: - from ..workflows.catalog import StepCatalog + from ..workflows.catalog import StepCatalog, StepCatalogError + from ..workflows.step.installer import StepInstallError, validate_step_id - _selected_catalog_info( + try: + validate_step_id(component.id) + except StepInstallError as exc: + raise BundlerError( + f"Invalid step '{component.id}': {exc}" + ) from exc + + try: + selected = _selected_catalog_info( "Step", component, StepCatalog(self._root).get_step_info ) + except StepCatalogError as exc: + raise BundlerError( + f"Failed to resolve step '{component.id}': {exc}" + ) from exc + from ..workflows.step.command_add import _install_preselected_step with _chdir(self._root): - _delegate_command( - "install", f"step '{component.id}'", - lambda: workflow_step_add( - component.id, - **({"version": component.version} if component.version else {}), - ), - ) + try: + _delegate_command( + "install", f"step '{component.id}'", + lambda: _install_preselected_step( + component.id, version=component.version, selected_info=selected, + ), + ) + except StepInstallError as exc: + raise BundlerError( + f"Failed to install step '{component.id}': {exc}" + ) from exc def refresh(self, component: ComponentRef) -> None: # Preserve an existing step until we've validated we can perform refresh. diff --git a/src/specify_cli/bundles/references.py b/src/specify_cli/bundles/references.py index de13b1bcfb..89c8e84541 100644 --- a/src/specify_cli/bundles/references.py +++ b/src/specify_cli/bundles/references.py @@ -121,14 +121,18 @@ def _resolved_in_catalog(root: Path, component: ComponentRef) -> bool | str | No except (ConnectionError, TimeoutError): return None except Exception as exc: # noqa: BLE001 - report malformed catalog errors + from ..extensions import ExtensionCatalogFetchError + from ..presets._catalog import PresetCatalogFetchError from ..workflows.catalog import StepCatalogError, WorkflowCatalogError - if ( - isinstance(exc, WorkflowCatalogError) - and str(exc) == "All configured catalogs failed to fetch." - ) or ( - isinstance(exc, StepCatalogError) - and str(exc) == "All configured step catalogs failed to fetch." + if isinstance(exc, (ExtensionCatalogFetchError, PresetCatalogFetchError)): + return None + if isinstance(exc, WorkflowCatalogError) and str(exc) == ( + "All configured catalogs failed to fetch." + ): + return None + if isinstance(exc, StepCatalogError) and str(exc) == ( + "All configured step catalogs failed to fetch." ): return None return f"Catalog lookup failed: {exc}" diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index 6242dc2c05..e0e78d6645 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -180,6 +180,14 @@ class ExtensionError(Exception): pass +class ExtensionCatalogFetchError(ExtensionError): + """Raised when no configured extension catalog can be fetched.""" + + +class ExtensionCatalogValidationError(ExtensionError): + """Raised when an extension catalog response is malformed.""" + + class ValidationError(ExtensionError): """Raised when extension manifest validation fails.""" @@ -4557,13 +4565,13 @@ def _validate_catalog_payload(self, catalog_data: Any, url: str) -> None: ExtensionError: If the payload's shape is invalid. """ if not isinstance(catalog_data, dict): - raise ExtensionError( + raise ExtensionCatalogValidationError( f"Invalid catalog format from {url}: expected a JSON object" ) if "schema_version" not in catalog_data or "extensions" not in catalog_data: - raise ExtensionError(f"Invalid catalog format from {url}") + raise ExtensionCatalogValidationError(f"Invalid catalog format from {url}") if not isinstance(catalog_data.get("extensions"), dict): - raise ExtensionError( + raise ExtensionCatalogValidationError( f"Invalid catalog format from {url}: 'extensions' must be a JSON object" ) @@ -4753,7 +4761,7 @@ def _validate_redirect(_old_url: str, new_url: str) -> None: read_response_limited( response, max_bytes=MAX_JSON_CATALOG_BYTES, - error_type=ExtensionError, + error_type=ExtensionCatalogValidationError, label=f"extension catalog {entry.url}", ) ) @@ -4791,9 +4799,13 @@ def _validate_redirect(_old_url: str, new_url: str) -> None: return catalog_data except urllib.error.URLError as e: - raise ExtensionError(f"Failed to fetch catalog from {entry.url}: {e}") + raise ExtensionCatalogFetchError( + f"Failed to fetch catalog from {entry.url}: {e}" + ) from e except json.JSONDecodeError as e: - raise ExtensionError(f"Invalid JSON in catalog from {entry.url}: {e}") + raise ExtensionCatalogValidationError( + f"Invalid JSON in catalog from {entry.url}: {e}" + ) from e def _get_merged_extensions( self, force_refresh: bool = False @@ -4822,12 +4834,18 @@ def _get_merged_extensions( active_catalogs = self.get_active_catalogs() merged: Dict[str, Dict[str, Any]] = {} any_success = False + validation_error: ExtensionCatalogValidationError | None = None for catalog_entry in active_catalogs: try: catalog_data = self._fetch_single_catalog(catalog_entry, force_refresh) any_success = True except ExtensionError as e: + if ( + isinstance(e, ExtensionCatalogValidationError) + and validation_error is None + ): + validation_error = e print( f"Warning: Could not fetch catalog '{catalog_entry.name}': {e}", file=sys.stderr, @@ -4854,7 +4872,9 @@ def _get_merged_extensions( } if not any_success and active_catalogs: - raise ExtensionError("Failed to fetch any extension catalog") + if validation_error is not None: + raise validation_error + raise ExtensionCatalogFetchError("Failed to fetch any extension catalog") return list(merged.values()) diff --git a/src/specify_cli/presets/_catalog.py b/src/specify_cli/presets/_catalog.py index 7f99443551..6b3cfcdf23 100644 --- a/src/specify_cli/presets/_catalog.py +++ b/src/specify_cli/presets/_catalog.py @@ -27,6 +27,10 @@ class PresetCatalogValidationError(PresetError): """A catalog supplied invalid content rather than being unreachable.""" +class PresetCatalogFetchError(PresetError): + """A configured preset catalog could not be fetched.""" + + def _decode_catalog_json(raw: str | bytes, url: str) -> Any: """Reject duplicate keys before JSON parsing discards conflicting records.""" @@ -547,9 +551,9 @@ def _validate_redirect(_old_url: str, new_url: str) -> None: except (ImportError, Exception) as e: if isinstance(e, PresetError): raise - raise PresetError( + raise PresetCatalogFetchError( f"Failed to fetch preset catalog from {entry.url}: {e}" - ) + ) from e def _get_merged_packs( self, force_refresh: bool = False, *, pack_id: str | None = None @@ -739,9 +743,9 @@ def _validate_redirect(_old_url: str, new_url: str) -> None: except (ImportError, Exception) as e: if isinstance(e, PresetError): raise - raise PresetError( + raise PresetCatalogFetchError( f"Failed to fetch preset catalog from {catalog_url}: {e}" - ) + ) from e def search( self, diff --git a/src/specify_cli/workflows/_commands.py b/src/specify_cli/workflows/_commands.py index e3dfb0bb2d..44ccf9a432 100644 --- a/src/specify_cli/workflows/_commands.py +++ b/src/specify_cli/workflows/_commands.py @@ -1036,6 +1036,7 @@ def _install_workflow_from_catalog( expected_version: str | None = None, expected_installed_version: str | None = None, requested_version: str | None = None, + selected_info: dict | None = None, ) -> None: """Download, validate, and register a catalog workflow. @@ -1045,6 +1046,8 @@ def _install_workflow_from_catalog( version does not match the catalog version that triggered the install. ``expected_installed_version``, when given by ``workflow update``, aborts if another process changes the installed source or version before commit. + ``selected_info`` is a bundle-selected catalog record, used without a + second catalog lookup while retaining the same download and commit checks. """ from .catalog import WorkflowCatalog, WorkflowCatalogError from .engine import WorkflowDefinition @@ -1061,16 +1064,19 @@ def versions_match(actual: object, expected: str) -> bool: safe_wf_id = _escape_markup(workflow_id) - catalog = WorkflowCatalog(project_root) - try: - info = ( - catalog.get_workflow_info(workflow_id, requested_version) - if requested_version is not None - else catalog.get_workflow_info(workflow_id) - ) - except WorkflowCatalogError as exc: - console.print(f"[red]Error:[/red] {_escape_markup(str(exc))}") - raise typer.Exit(1) + if selected_info is not None: + info = selected_info + else: + catalog = WorkflowCatalog(project_root) + try: + info = ( + catalog.get_workflow_info(workflow_id, requested_version) + if requested_version is not None + else catalog.get_workflow_info(workflow_id) + ) + except WorkflowCatalogError as exc: + console.print(f"[red]Error:[/red] {_escape_markup(str(exc))}") + raise typer.Exit(1) if not info: if requested_version is not None: diff --git a/src/specify_cli/workflows/command_add.py b/src/specify_cli/workflows/command_add.py index 0ea615817b..5d5bb45b98 100644 --- a/src/specify_cli/workflows/command_add.py +++ b/src/specify_cli/workflows/command_add.py @@ -30,24 +30,12 @@ def _workflow_package_has_companions(package_dir: cli.Path) -> bool: return any(path.name != "workflow.yml" for path in package_dir.iterdir()) -@cli.workflow_app.command("add") -def workflow_add( - source: str = cli.typer.Argument(..., help="Workflow ID, URL, or local path"), - dev: bool = cli.typer.Option( - False, "--dev", help="Install from a local workflow YAML file or directory" - ), - from_url: str | None = cli.typer.Option( - None, "--from", help="Install from a custom URL" - ), - version: Annotated[ - str | None, cli.typer.Option(help="Install an exact catalog release") - ] = None, -): - """Install a workflow from catalog, URL, or local path.""" +def _prepare_workflow_add( + project_root: cli.Path, source: str, *, dev: bool, from_url: str | None, + version: str | None, +) -> cli.Path: from . import load_custom_steps - from .engine import WorkflowDefinition - project_root = cli._require_specify_project() if version is not None and ( dev or from_url is not None or source.startswith(("http://", "https://")) or cli.Path(source).exists() @@ -61,15 +49,49 @@ def workflow_add( load_custom_steps(project_root) cli._open_workflow_registry(project_root) workflows_dir = project_root / ".specify" / "workflows" - # With --from, source names the expected workflow ID: validate it up - # front so a URL/path/typo fails without a network fetch. if from_url is not None and not dev: cli._validate_workflow_id_or_exit(source) - # Reject a symlinked .specify / .specify/workflows before any write so an - # install can't escape the project root (covers the local, URL, and - # catalog branches below — all write beneath workflows_dir). cli._reject_unsafe_dir(project_root / ".specify", ".specify") cli._reject_unsafe_dir(workflows_dir, ".specify/workflows") + return workflows_dir + + +def _install_preselected_workflow( + workflow_id: str, *, version: str | None, selected_info: dict, +) -> None: + """Install a bundle-selected release with the normal workflow preflights.""" + project_root = cli._require_specify_project() + workflows_dir = _prepare_workflow_add( + project_root, workflow_id, dev=False, from_url=None, version=version, + ) + if version is None: + cli._validate_workflow_id_or_exit(workflow_id) + cli._install_workflow_from_catalog( + project_root, workflows_dir, workflow_id, + requested_version=version, selected_info=selected_info, + ) + + +@cli.workflow_app.command("add") +def workflow_add( + source: str = cli.typer.Argument(..., help="Workflow ID, URL, or local path"), + dev: bool = cli.typer.Option( + False, "--dev", help="Install from a local workflow YAML file or directory" + ), + from_url: str | None = cli.typer.Option( + None, "--from", help="Install from a custom URL" + ), + version: Annotated[ + str | None, cli.typer.Option(help="Install an exact catalog release") + ] = None, +): + """Install a workflow from catalog, URL, or local path.""" + from .engine import WorkflowDefinition + + project_root = cli._require_specify_project() + workflows_dir = _prepare_workflow_add( + project_root, source, dev=dev, from_url=from_url, version=version, + ) def _validate_and_install_local( yaml_path: cli.Path, source_label: str, expected_id: str | None = None diff --git a/src/specify_cli/workflows/step/command_add.py b/src/specify_cli/workflows/step/command_add.py index 4a0c2f55db..517fd6126c 100644 --- a/src/specify_cli/workflows/step/command_add.py +++ b/src/specify_cli/workflows/step/command_add.py @@ -259,7 +259,8 @@ def _install_from_url( def _install_from_catalog( - project_root: cli.Path, step_id: str, *, force: bool, version: str | None = None + project_root: cli.Path, step_id: str, *, force: bool, version: str | None = None, + selected_info: dict | None = None, ) -> None: """Install a step package from the step catalog. @@ -272,15 +273,18 @@ def _install_from_catalog( from .catalog import StepCatalog, StepCatalogError from .catalog._versions import validate_checksums - catalog = StepCatalog(project_root) - try: - info = ( - catalog.get_step_info(step_id, version=version) - if version is not None - else catalog.get_step_info(step_id) - ) - except StepCatalogError as exc: - raise installer.StepInstallError(str(exc)) from exc + if selected_info is not None: + info = selected_info + else: + catalog = StepCatalog(project_root) + try: + info = ( + catalog.get_step_info(step_id, version=version) + if version is not None + else catalog.get_step_info(step_id) + ) + except StepCatalogError as exc: + raise installer.StepInstallError(str(exc)) from exc if not info: raise installer.StepInstallError( @@ -538,6 +542,18 @@ def _fetch_checked(url: str, name: str) -> bytes: _print_installed(step_id, entry) +def _install_preselected_step( + step_id: str, *, version: str | None, selected_info: dict, +) -> None: + """Install a bundle-selected release with the normal step ID preflight.""" + project_root = cli._require_specify_project() + step_helpers._validate_step_id_or_exit(step_id) + _install_from_catalog( + project_root, step_id, force=False, version=version, + selected_info=selected_info, + ) + + @step_app.command("add") def workflow_step_add( step_id: str = cli.typer.Argument(..., help="Step type ID"), diff --git a/tests/specify_cli/bundles/test_primitives.py b/tests/specify_cli/bundles/test_primitives.py index 58de47e7cc..6ad940700a 100644 --- a/tests/specify_cli/bundles/test_primitives.py +++ b/tests/specify_cli/bundles/test_primitives.py @@ -68,14 +68,19 @@ def test_offline_step_refuses_without_network(tmp_path: Path): def test_step_manager_delegates_catalog_install_from_bundle_root(tmp_path, monkeypatch): - import specify_cli + from specify_cli.workflows.catalog import StepCatalog + from specify_cli.workflows.step import command_add calls: list[tuple[str, Path]] = [] - def _add(step_id: str) -> None: + def _add(project_root, step_id, **options) -> None: calls.append((step_id, Path.cwd())) - monkeypatch.setattr(specify_cli, "workflow_step_add", _add) + (tmp_path / ".specify").mkdir() + monkeypatch.setattr(StepCatalog, "get_step_info", lambda self, step_id: { + "id": step_id, "_catalog_name": "trusted", "_install_allowed": True, + }) + monkeypatch.setattr(command_add, "_install_from_catalog", _add) manager = _StepKindManager(tmp_path, allow_network=True) manager.install(_component("steps", "catalog-step")) @@ -83,6 +88,20 @@ def _add(step_id: str) -> None: assert calls == [("catalog-step", tmp_path)] +def test_step_manager_rejects_unsafe_id_before_catalog_lookup(tmp_path, monkeypatch): + from specify_cli.workflows.catalog import StepCatalog + + monkeypatch.setattr( + StepCatalog, "get_step_info", + lambda *args, **kwargs: pytest.fail("invalid ID reached catalog lookup"), + ) + + with pytest.raises(BundlerError, match="step"): + primitive_manager("steps", tmp_path).install( + _component("steps", "../outside"), + ) + + @pytest.mark.parametrize( "kind,catalog_module,catalog_class,lookup", [ @@ -95,9 +114,11 @@ def test_catalog_workflow_and_step_pins_delegate_exact_release( ): import importlib - import specify_cli import specify_cli._assets as assets + from specify_cli.workflows import _commands as workflow_cli + from specify_cli.workflows.step import command_add as step_add + (tmp_path / ".specify").mkdir() if kind == "workflows": monkeypatch.setattr(assets, "_locate_bundled_workflow", lambda _id: None) catalog = getattr(importlib.import_module(catalog_module), catalog_class) @@ -113,10 +134,16 @@ def get_info(_self, _id, version=None): monkeypatch.setattr(catalog, lookup, get_info) calls = [] - command = "workflow_add" if kind == "workflows" else "workflow_step_add" + installer = ( + (workflow_cli, "_install_workflow_from_catalog") + if kind == "workflows" + else (step_add, "_install_from_catalog") + ) monkeypatch.setattr( - specify_cli, command, - lambda cid, **options: calls.append((cid, options, Path.cwd())), + *installer, + lambda project_root, destination, cid=None, **options: calls.append( + (cid or destination, options, Path.cwd()) + ), ) component = ComponentRef(kind=kind, id="catalog-id", version="1.0.0", source="trusted") @@ -124,10 +151,66 @@ def get_info(_self, _id, version=None): assert lookups == [None, "1.0.0"] assert calls[0][0] == component.id - assert calls[0][1]["version"] == "1.0.0" + assert calls[0][1][ + "requested_version" if kind == "workflows" else "version" + ] == "1.0.0" assert calls[0][2] == tmp_path +@pytest.mark.parametrize("kind", ["workflows", "steps"]) +def test_catalog_component_installs_preselected_release_without_second_lookup( + tmp_path, monkeypatch, kind, +): + import specify_cli + import specify_cli._assets as assets + from specify_cli.workflows import _commands as workflow_cli + from specify_cli.workflows.catalog import StepCatalog, WorkflowCatalog + from specify_cli.workflows.step import command_add as step_add + + selected = { + "id": "catalog-id", "version": "1.0.0", "_catalog_name": "trusted", + "_install_allowed": True, "url": "https://example.com/old-release", + } + current = {**selected, "version": "2.0.0", "url": "https://example.com/latest"} + lookups = [] + (tmp_path / ".specify").mkdir() + + def get_info(_self, _id, version=None): + lookups.append(version) + return selected if version == "1.0.0" else current + + calls = [] + if kind == "workflows": + monkeypatch.setattr(assets, "_locate_bundled_workflow", lambda _id: None) + monkeypatch.setattr(WorkflowCatalog, "get_workflow_info", get_info) + monkeypatch.setattr( + workflow_cli, "_install_workflow_from_catalog", + lambda *args, **kwargs: calls.append(kwargs), + ) + monkeypatch.setattr( + specify_cli, "workflow_add", + lambda *args, **kwargs: pytest.fail("workflow_add re-resolved the catalog"), + ) + else: + monkeypatch.setattr(StepCatalog, "get_step_info", get_info) + monkeypatch.setattr( + step_add, "_install_from_catalog", + lambda *args, **kwargs: calls.append(kwargs), + ) + monkeypatch.setattr( + specify_cli, "workflow_step_add", + lambda *args, **kwargs: pytest.fail("workflow_step_add re-resolved the catalog"), + ) + + primitive_manager(kind, tmp_path).install(ComponentRef( + kind=kind, id="catalog-id", version="1.0.0", source="trusted" + )) + + assert lookups == [None, "1.0.0"] + assert len(calls) == 1 + assert calls[0]["selected_info"] is selected + + @pytest.mark.parametrize("kind", ["extensions", "presets", "workflows", "steps"]) def test_explicit_source_cannot_bypass_winning_catalog(tmp_path, monkeypatch, kind): import specify_cli._assets as assets @@ -189,10 +272,11 @@ def test_missing_exact_release_never_delegates_install(tmp_path, monkeypatch, ki def test_bundled_workflow_with_older_pin_uses_catalog_release(tmp_path, monkeypatch): - import specify_cli import specify_cli._assets as assets + from specify_cli.workflows import _commands as workflow_cli from specify_cli.workflows.catalog import WorkflowCatalog + (tmp_path / ".specify").mkdir() bundled = _write_manifest(tmp_path / "bundled", "workflow", "2.0.0") monkeypatch.setattr(assets, "_locate_bundled_workflow", lambda _id: bundled) monkeypatch.setattr( @@ -203,15 +287,21 @@ def test_bundled_workflow_with_older_pin_uses_catalog_release(tmp_path, monkeypa ) calls = [] monkeypatch.setattr( - specify_cli, "workflow_add", - lambda source, **options: calls.append((source, options)), + workflow_cli, "_install_workflow_from_catalog", + lambda project_root, workflows_dir, source, **options: + calls.append((source, options)), ) primitive_manager("workflows", tmp_path).install( ComponentRef(kind="workflows", id="x", version="1.0.0") ) - assert calls == [("x", {"dev": False, "from_url": None, "version": "1.0.0"})] + assert calls == [("x", { + "requested_version": "1.0.0", + "selected_info": { + "version": "1.0.0", "_catalog_name": "winning", + }, + })] def test_source_on_bundled_extension_requires_catalog(tmp_path, monkeypatch): @@ -741,8 +831,9 @@ def _plan(manifest): ) +@pytest.mark.parametrize("failure_stage", ["installer", "catalog"]) def test_step_refresh_restores_registry_entry_when_reinstall_fails( - tmp_path: Path, monkeypatch + tmp_path: Path, monkeypatch, failure_stage ): """A failed step refresh must leave the registry entry restored. @@ -759,8 +850,10 @@ def test_step_refresh_restores_registry_entry_when_reinstall_fails( """ import json - import specify_cli - from specify_cli.workflows.catalog import StepRegistry + from specify_cli.bundles import primitives + from specify_cli.workflows.catalog import StepCatalogError, StepRegistry + from specify_cli.workflows.step import command_add + from specify_cli.workflows.step.installer import StepInstallError steps_dir = tmp_path / ".specify" / "workflows" / "steps" (steps_dir / "my-step").mkdir(parents=True) @@ -795,9 +888,18 @@ def test_step_refresh_restores_registry_entry_when_reinstall_fails( # Removal succeeds (real code path); only the re-install fails, which is # what a catalog 404 / size-limit / type_key mismatch produces. def _boom(step_id, *args, **kwargs): - raise BundlerError(f"Failed to install step '{step_id}'.") + raise StepInstallError(f"Failed to install step '{step_id}'.") - monkeypatch.setattr(specify_cli, "workflow_step_add", _boom) + if failure_stage == "catalog": + def _catalog_failure(*args): + raise StepCatalogError("catalog became unreachable") + + monkeypatch.setattr(primitives, "_selected_catalog_info", _catalog_failure) + else: + monkeypatch.setattr(primitives, "_selected_catalog_info", lambda *args: { + "_catalog_name": "trusted", + }) + monkeypatch.setattr(command_add, "_install_preselected_step", _boom) manager = primitive_manager("steps", tmp_path, allow_network=True) with pytest.raises(BundlerError): diff --git a/tests/specify_cli/bundles/test_references.py b/tests/specify_cli/bundles/test_references.py index 11999e0372..01a28d848d 100644 --- a/tests/specify_cli/bundles/test_references.py +++ b/tests/specify_cli/bundles/test_references.py @@ -7,6 +7,8 @@ from pathlib import Path +import pytest + from specify_cli.bundles.manifest import ComponentRef from specify_cli.bundles.references import make_reference_checker from tests.specify_cli.bundles.helpers import bundled_extension_version, make_project @@ -204,3 +206,83 @@ def unavailable(_self, _id, version=None): assert check(_ref("workflows", "unreachable")) is None assert len(warnings) == 1 assert "unreachable" in warnings[0] + + +@pytest.mark.parametrize("kind", ["extensions", "presets"]) +def test_online_validation_warns_for_unreachable_component_catalog( + tmp_path, monkeypatch, kind, +): + from specify_cli.extensions import ExtensionCatalog, ExtensionCatalogFetchError + from specify_cli.presets import PresetCatalog + from specify_cli.presets._catalog import PresetCatalogFetchError + + if kind == "extensions": + catalog, method, failure = ( + ExtensionCatalog, "get_extension_info", + ExtensionCatalogFetchError("Failed to fetch any extension catalog"), + ) + else: + catalog, method, failure = ( + PresetCatalog, "get_pack_info", + PresetCatalogFetchError("Failed to fetch preset catalog from https://example.com: timed out"), + ) + + def unavailable(_self, _id, version=None): + raise failure + + monkeypatch.setattr(catalog, method, unavailable) + warnings = [] + check = make_reference_checker(tmp_path, allow_network=True, warnings=warnings) + + assert check(_ref(kind, "unreachable-component")) is None + assert len(warnings) == 1 + assert "unreachable" in warnings[0] + + +@pytest.mark.parametrize("kind", ["extensions", "presets"]) +def test_online_validation_rejects_malformed_component_release( + tmp_path, monkeypatch, kind, +): + from specify_cli.extensions import ExtensionCatalog, ExtensionError + from specify_cli.presets import PresetCatalog + from specify_cli.presets._manifest import PresetError + + catalog, method, failure = ( + (ExtensionCatalog, "get_extension_info", ExtensionError("Invalid release digest")) + if kind == "extensions" + else (PresetCatalog, "get_pack_info", PresetError("Invalid release digest")) + ) + + def invalid(_self, _id, version=None): + raise failure + + monkeypatch.setattr(catalog, method, invalid) + warnings = [] + check = make_reference_checker(tmp_path, allow_network=True, warnings=warnings) + + assert "Invalid release digest" in check(_ref(kind, "invalid-component")) + assert warnings == [] + + +def test_online_validation_rejects_malformed_extension_catalog( + tmp_path, monkeypatch, +): + from specify_cli.extensions import ( + CatalogEntry, + ExtensionCatalog, + ) + + monkeypatch.setattr( + ExtensionCatalog, "get_active_catalogs", + lambda self: [CatalogEntry("https://example.com/catalog.json", "trusted", 1, True)], + ) + monkeypatch.setattr( + ExtensionCatalog, "_fetch_single_catalog", + lambda self, entry, force_refresh=False: + self._validate_catalog_payload({"extensions": []}, entry.url), + ) + warnings = [] + check = make_reference_checker(tmp_path, allow_network=True, warnings=warnings) + + assert "Invalid catalog format" in check(_ref("extensions", "invalid")) + assert warnings == [] diff --git a/tests/specify_cli/workflows/step/test_command_add.py b/tests/specify_cli/workflows/step/test_command_add.py index 191398b3f5..b5be4ed829 100644 --- a/tests/specify_cli/workflows/step/test_command_add.py +++ b/tests/specify_cli/workflows/step/test_command_add.py @@ -1756,6 +1756,48 @@ def test_install_exact_selected_files(self, project_dir, monkeypatch): project_dir / ".specify/workflows/steps/deploy/helper.py" ).read_bytes() == b"# helper\n" + def test_preselected_step_installs_without_reloading_catalog( + self, project_dir, monkeypatch, + ): + from specify_cli.workflows.step.catalog import StepCatalog, StepRegistry + from specify_cli.workflows.step.command_add import _install_preselected_step + + requested = self._setup(project_dir, monkeypatch) + selected = StepCatalog(project_dir).get_step_info("deploy", version="1.0") + monkeypatch.setattr( + StepCatalog, "get_step_info", + lambda *args, **kwargs: pytest.fail("catalog was re-resolved"), + ) + monkeypatch.chdir(project_dir) + _install_preselected_step("deploy", version="1.0", selected_info=selected) + + assert requested == [ + f"https://example.com/old/{name}" + for name in ("step.yml", "__init__.py", "helper.py") + ] + assert StepRegistry(project_dir).get("deploy")["version"] == "1.0" + + def test_preselected_step_rejects_bad_digest_without_reloading_catalog( + self, project_dir, monkeypatch, + ): + from specify_cli.workflows.step.catalog import StepCatalog, StepRegistry + from specify_cli.workflows.step.command_add import _install_preselected_step + from specify_cli.workflows.step.installer import StepInstallError + + self._setup(project_dir, monkeypatch, corrupt="checksum") + selected = StepCatalog(project_dir).get_step_info("deploy", version="1.0") + monkeypatch.setattr( + StepCatalog, "get_step_info", + lambda *args, **kwargs: pytest.fail("catalog was re-resolved"), + ) + monkeypatch.chdir(project_dir) + + with pytest.raises(StepInstallError, match="checksum mismatch"): + _install_preselected_step( + "deploy", version="1.0", selected_info=selected, + ) + assert not StepRegistry(project_dir).is_installed("deploy") + @pytest.mark.parametrize( ("corrupt", "error"), [ diff --git a/tests/specify_cli/workflows/test_catalog_versions.py b/tests/specify_cli/workflows/test_catalog_versions.py index 6a1900f7be..7270656ef0 100644 --- a/tests/specify_cli/workflows/test_catalog_versions.py +++ b/tests/specify_cli/workflows/test_catalog_versions.py @@ -8,6 +8,7 @@ from unittest.mock import patch import pytest +import typer from typer.testing import CliRunner from specify_cli import app @@ -259,6 +260,59 @@ def open_url(url, **kwargs): assert WorkflowRegistry(project_dir).get("history-wf")["version"] == "1.0.0" +def test_preselected_workflow_installs_without_reloading_catalog( + monkeypatch, project_dir, +): + from specify_cli.authentication import http + from specify_cli.workflows.command_add import _install_preselected_workflow + + catalog = _catalog(monkeypatch, project_dir, _entry()) + selected = catalog.get_workflow_info("history-wf", "1.0.0") + assert selected["url"] == OLD_URL + monkeypatch.setattr( + WorkflowCatalog, "get_workflow_info", + lambda *args, **kwargs: pytest.fail("catalog was re-resolved"), + ) + requested = [] + + def open_url(url, **kwargs): + requested.append(url) + return _Response(_archive("1.0.0", requires={"integrations": ["copilot"]}), url) + + monkeypatch.setattr(http, "open_url", open_url) + monkeypatch.chdir(project_dir) + _install_preselected_workflow( + "history-wf", version="1.0.0", selected_info=selected, + ) + + assert requested == [OLD_URL] + assert WorkflowRegistry(project_dir).get("history-wf")["version"] == "1.0.0" + + +def test_preselected_workflow_rejects_bad_digest_without_reloading_catalog( + monkeypatch, project_dir, +): + from specify_cli.authentication import http + from specify_cli.workflows.command_add import _install_preselected_workflow + catalog = _catalog(monkeypatch, project_dir, _entry()) + selected = catalog.get_workflow_info("history-wf", "1.0.0") + selected["sha256"] = "0" * 64 + monkeypatch.setattr( + WorkflowCatalog, "get_workflow_info", + lambda *args, **kwargs: pytest.fail("catalog was re-resolved"), + ) + monkeypatch.setattr( + http, "open_url", lambda url, **kwargs: _Response(_archive("1.0.0"), url), + ) + monkeypatch.chdir(project_dir) + + with pytest.raises(typer.Exit): + _install_preselected_workflow( + "history-wf", version="1.0.0", selected_info=selected, + ) + assert WorkflowRegistry(project_dir).get("history-wf") is None + + def test_exact_yaml_release_verifies_digest_and_version(monkeypatch, project_dir): from specify_cli.authentication import http From 346893897c3942c205be72c52c51eb6931504244 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Tue, 6 Oct 2026 08:50:59 -0500 Subject: [PATCH 03/23] Fix catalog validation precedence and selected workflow paths Assisted-by: GitHub Copilot (model: GPT-6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/specify_cli/bundles/references.py | 18 +- src/specify_cli/extensions/__init__.py | 27 +- src/specify_cli/workflows/catalog/__init__.py | 4 + src/specify_cli/workflows/catalog/_domain.py | 110 +++++++-- src/specify_cli/workflows/command_add.py | 17 +- .../workflows/step/catalog/__init__.py | 2 + .../workflows/step/catalog/_domain.py | 106 ++++++-- tests/specify_cli/bundles/test_references.py | 232 +++++++++++++++++- .../extensions/test_catalog_versions.py | 34 ++- .../workflows/step/test_catalog_versions.py | 6 +- .../workflows/step/test_command_add.py | 3 +- .../workflows/step/test_command_info.py | 2 +- .../workflows/test_catalog_versions.py | 30 +++ .../workflows/test_command_search.py | 4 +- 14 files changed, 513 insertions(+), 82 deletions(-) diff --git a/src/specify_cli/bundles/references.py b/src/specify_cli/bundles/references.py index 89c8e84541..9ee8de1827 100644 --- a/src/specify_cli/bundles/references.py +++ b/src/specify_cli/bundles/references.py @@ -123,17 +123,15 @@ def _resolved_in_catalog(root: Path, component: ComponentRef) -> bool | str | No except Exception as exc: # noqa: BLE001 - report malformed catalog errors from ..extensions import ExtensionCatalogFetchError from ..presets._catalog import PresetCatalogFetchError - from ..workflows.catalog import StepCatalogError, WorkflowCatalogError + from ..workflows.catalog import ( + StepCatalogFetchError, + WorkflowCatalogFetchError, + ) - if isinstance(exc, (ExtensionCatalogFetchError, PresetCatalogFetchError)): - return None - if isinstance(exc, WorkflowCatalogError) and str(exc) == ( - "All configured catalogs failed to fetch." - ): - return None - if isinstance(exc, StepCatalogError) and str(exc) == ( - "All configured step catalogs failed to fetch." - ): + if isinstance(exc, ( + ExtensionCatalogFetchError, PresetCatalogFetchError, + WorkflowCatalogFetchError, StepCatalogFetchError, + )): return None return f"Catalog lookup failed: {exc}" return None diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index e0e78d6645..27fd55ed75 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -4808,7 +4808,7 @@ def _validate_redirect(_old_url: str, new_url: str) -> None: ) from e def _get_merged_extensions( - self, force_refresh: bool = False + self, force_refresh: bool = False, *, extension_id: str | None = None ) -> List[Dict[str, Any]]: """Fetch and merge extensions from all active catalogs. @@ -4817,8 +4817,9 @@ def _get_merged_extensions( - _catalog_name: name of the source catalog - _install_allowed: whether installation is allowed from this catalog - Catalogs that fail to fetch are skipped. Raises ExtensionError only if - ALL catalogs fail. + An ID lookup stops at its first matching source and refuses malformed + higher-priority catalogs. Untargeted searches continue past malformed + sources so other catalog results remain discoverable. Args: force_refresh: If True, bypass all caches @@ -4827,7 +4828,8 @@ def _get_merged_extensions( List of merged extension dicts Raises: - ExtensionError: If all catalogs fail to fetch + ExtensionError: If no catalog is readable, or an ID lookup + encounters malformed catalog data. """ import sys @@ -4846,6 +4848,10 @@ def _get_merged_extensions( and validation_error is None ): validation_error = e + if extension_id is not None and isinstance( + e, ExtensionCatalogValidationError + ): + raise print( f"Warning: Could not fetch catalog '{catalog_entry.name}': {e}", file=sys.stderr, @@ -4853,6 +4859,8 @@ def _get_merged_extensions( continue for ext_id, ext_data in catalog_data.get("extensions", {}).items(): + if extension_id is not None and ext_id != extension_id: + continue # Per-entry guard: ``_fetch_single_catalog`` already validates # that ``catalog_data["extensions"]`` is a mapping, but it # does not (and should not) validate every entry shape there @@ -4862,6 +4870,11 @@ def _get_merged_extensions( # the valid entries without crashing on ``**ext_data``. # Mirrors ``integrations/catalog.py:245``. if not isinstance(ext_data, dict): + if extension_id is not None: + raise ExtensionCatalogValidationError( + f"Invalid extension catalog entry for '{ext_id}' " + f"from {catalog_entry.url}: expected a JSON object" + ) continue if ext_id not in merged: # Higher-priority catalog wins merged[ext_id] = { @@ -4870,6 +4883,8 @@ def _get_merged_extensions( "_catalog_name": catalog_entry.name, "_install_allowed": catalog_entry.install_allowed, } + if extension_id is not None: + return list(merged.values()) if not any_success and active_catalogs: if validation_error is not None: @@ -5093,7 +5108,7 @@ def get_extension_info( Extension metadata (annotated with ``_catalog_name`` and ``_install_allowed``) or None if not found. """ - all_extensions = self._get_merged_extensions() + all_extensions = self._get_merged_extensions(extension_id=extension_id) for ext_data in all_extensions: if ext_data["id"] == extension_id: from ._catalog_versions import select_release @@ -5105,7 +5120,7 @@ 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(): + for ext_data in self._get_merged_extensions(extension_id=extension_id): if ext_data["id"] == extension_id: return available_versions(ext_data) return [] diff --git a/src/specify_cli/workflows/catalog/__init__.py b/src/specify_cli/workflows/catalog/__init__.py index 0e2667558b..56f24d7ae2 100644 --- a/src/specify_cli/workflows/catalog/__init__.py +++ b/src/specify_cli/workflows/catalog/__init__.py @@ -29,6 +29,8 @@ def register(app: typer.Typer) -> None: "WorkflowCatalog", "WorkflowCatalogEntry", "WorkflowCatalogError", + "WorkflowCatalogFetchError", + "WorkflowCatalogValidationError", "WorkflowRegistry", "WorkflowValidationError", } @@ -36,6 +38,8 @@ def register(app: typer.Typer) -> None: "StepCatalog", "StepCatalogEntry", "StepCatalogError", + "StepCatalogFetchError", + "StepCatalogValidationError", "StepRegistry", "StepValidationError", } diff --git a/src/specify_cli/workflows/catalog/_domain.py b/src/specify_cli/workflows/catalog/_domain.py index 352618fee3..02c33b2c9d 100644 --- a/src/specify_cli/workflows/catalog/_domain.py +++ b/src/specify_cli/workflows/catalog/_domain.py @@ -43,6 +43,14 @@ class WorkflowCatalogError(Exception): """Base error for workflow catalog operations.""" +class WorkflowCatalogFetchError(WorkflowCatalogError): + """A configured workflow catalog could not be fetched.""" + + +class WorkflowCatalogValidationError(WorkflowCatalogError): + """A workflow catalog supplied malformed metadata.""" + + class WorkflowValidationError(WorkflowCatalogError): """Validation error for catalog config or workflow data.""" @@ -517,18 +525,36 @@ def _fetch_single_catalog( """Fetch a single catalog, using cache when possible.""" cache_file, meta_file = self._get_cache_paths(entry.url) + def validate_payload(data: Any) -> dict[str, Any]: + if not isinstance(data, dict): + raise WorkflowCatalogValidationError( + f"Catalog from {entry.url} is not a valid JSON object." + ) + if "workflows" in data and not isinstance(data["workflows"], (dict, list)): + raise WorkflowCatalogValidationError( + f"Catalog from {entry.url} has malformed workflows metadata." + ) + return data + if not force_refresh and self._is_url_cache_valid(entry.url): try: with open(cache_file, encoding="utf-8") as f: cached = json.load(f) - if isinstance(cached, dict): - return cached - except (UnicodeDecodeError, json.JSONDecodeError, OSError): + return validate_payload(cached) + except ( + UnicodeDecodeError, + json.JSONDecodeError, + OSError, + WorkflowCatalogValidationError, + ): # Ignore invalid/unreadable cache and fall back to fetching from source. pass # Fetch from URL — validate scheme before opening and after redirects + from http.client import HTTPException from urllib.parse import urlparse + + from specify_cli.authentication.http import RedirectPolicyError from specify_cli.authentication.http import open_url as _open_url def _validate_catalog_url(url: str) -> None: @@ -542,18 +568,18 @@ def _validate_catalog_url(url: str) -> None: hostname = parsed.hostname _ = parsed.port except (TypeError, ValueError): - raise WorkflowCatalogError( + raise WorkflowCatalogValidationError( f"Refusing to fetch catalog from malformed URL: {url}" ) from None is_localhost = hostname in ("localhost", "127.0.0.1", "::1") if parsed.scheme != "https" and not ( parsed.scheme == "http" and is_localhost ): - raise WorkflowCatalogError( + raise WorkflowCatalogValidationError( f"Refusing to fetch catalog from non-HTTPS URL: {url}" ) if not hostname: - raise WorkflowCatalogError( + raise WorkflowCatalogValidationError( f"Refusing to fetch catalog from URL with no hostname: {url}" ) @@ -578,29 +604,39 @@ def _validate_redirect(_old_url: str, new_url: str) -> None: read_response_limited( resp, max_bytes=_max_json_catalog_bytes(), - error_type=WorkflowCatalogError, + error_type=WorkflowCatalogValidationError, label="workflow catalog", ).decode("utf-8") ) - except Exception as exc: + except ( + WorkflowCatalogValidationError, + RedirectPolicyError, + UnicodeError, + json.JSONDecodeError, + ) as exc: + raise WorkflowCatalogValidationError( + f"Invalid workflow catalog from {entry.url}: {exc}" + ) from exc + except (OSError, HTTPException) as exc: # Fall back to cache if available if cache_file.exists(): try: with open(cache_file, encoding="utf-8") as f: cached = json.load(f) - if isinstance(cached, dict): - return cached - except (json.JSONDecodeError, ValueError, OSError): + return validate_payload(cached) + except ( + json.JSONDecodeError, + ValueError, + OSError, + WorkflowCatalogValidationError, + ): # Stale-cache read failed; let the original fetch error propagate. pass - raise WorkflowCatalogError( + raise WorkflowCatalogFetchError( f"Failed to fetch catalog from {entry.url}: {exc}" ) from exc - if not isinstance(data, dict): - raise WorkflowCatalogError( - f"Catalog from {entry.url} is not a valid JSON object." - ) + data = validate_payload(data) # Write cache try: @@ -615,26 +651,44 @@ def _validate_redirect(_old_url: str, new_url: str) -> None: return data def _get_merged_workflows( - self, force_refresh: bool = False + self, force_refresh: bool = False, *, workflow_id: str | None = None ) -> dict[str, dict[str, Any]]: - """Merge workflows from all active catalogs (lower priority number wins).""" + """Merge for search, or resolve one ID from the first valid winning source.""" catalogs = self.get_active_catalogs() merged: dict[str, dict[str, Any]] = {} fetch_errors = 0 + validation_error: WorkflowCatalogValidationError | None = None - # Process later/higher-numbered entries first so earlier/lower-numbered - # entries overwrite them on workflow ID conflicts. - for entry in reversed(catalogs): + # Search uses overwrite order; exact-ID lookup visits the highest + # priority source first and stops at its matching entry. + sources = catalogs if workflow_id is not None else reversed(catalogs) + for entry in sources: try: data = self._fetch_single_catalog(entry, force_refresh) - except WorkflowCatalogError: + except WorkflowCatalogError as exc: + if workflow_id is not None and isinstance( + exc, WorkflowCatalogValidationError + ): + raise + if ( + isinstance(exc, WorkflowCatalogValidationError) + and validation_error is None + ): + validation_error = exc fetch_errors += 1 continue workflows = data.get("workflows", {}) # Handle both dict and list formats if isinstance(workflows, dict): for wf_id, wf_data in workflows.items(): + if workflow_id is not None and wf_id != workflow_id: + continue if not isinstance(wf_data, dict): + if workflow_id is not None: + raise WorkflowCatalogValidationError( + f"Invalid workflow catalog entry for '{wf_id}' " + f"from {entry.url}: expected a JSON object" + ) continue wf_data["_catalog_name"] = entry.name wf_data["_install_allowed"] = entry.install_allowed @@ -645,11 +699,17 @@ def _get_merged_workflows( continue wf_id = wf_data.get("id", "") if wf_id: + if workflow_id is not None and wf_id != workflow_id: + continue wf_data["_catalog_name"] = entry.name wf_data["_install_allowed"] = entry.install_allowed merged[wf_id] = wf_data + if workflow_id is not None and workflow_id in merged: + return merged if fetch_errors == len(catalogs) and catalogs: - raise WorkflowCatalogError( + if validation_error is not None: + raise validation_error + raise WorkflowCatalogFetchError( "All configured catalogs failed to fetch." ) return merged @@ -698,7 +758,7 @@ def get_workflow_info( """Get the current or an exact advertised release from the winning source.""" from ._versions import select_release - merged = self._get_merged_workflows() + merged = self._get_merged_workflows(workflow_id=workflow_id) wf = merged.get(workflow_id) if wf is None: return None @@ -716,7 +776,7 @@ def get_workflow_version_details( """Return advertised versions and whether their source allows installation.""" from ._versions import available_versions - merged = self._get_merged_workflows() + merged = self._get_merged_workflows(workflow_id=workflow_id) wf = merged.get(workflow_id) if wf is None: return None diff --git a/src/specify_cli/workflows/command_add.py b/src/specify_cli/workflows/command_add.py index 5d5bb45b98..4d1cfb6567 100644 --- a/src/specify_cli/workflows/command_add.py +++ b/src/specify_cli/workflows/command_add.py @@ -32,19 +32,23 @@ def _workflow_package_has_companions(package_dir: cli.Path) -> bool: def _prepare_workflow_add( project_root: cli.Path, source: str, *, dev: bool, from_url: str | None, - version: str | None, + version: str | None, selected_catalog: bool = False, ) -> cli.Path: from . import load_custom_steps if version is not None and ( - dev or from_url is not None or source.startswith(("http://", "https://")) - or cli.Path(source).exists() + dev or from_url is not None or ( + not selected_catalog and ( + source.startswith(("http://", "https://")) + or cli.Path(source).exists() + ) + ) ): cli.console.print( "[red]Error:[/red] --version requires a workflow ID from a catalog." ) raise cli.typer.Exit(1) - if version is not None: + if version is not None or selected_catalog: cli._validate_workflow_id_or_exit(source) load_custom_steps(project_root) cli._open_workflow_registry(project_root) @@ -62,10 +66,9 @@ def _install_preselected_workflow( """Install a bundle-selected release with the normal workflow preflights.""" project_root = cli._require_specify_project() workflows_dir = _prepare_workflow_add( - project_root, workflow_id, dev=False, from_url=None, version=version, + project_root, workflow_id, dev=False, from_url=None, + version=version, selected_catalog=True, ) - if version is None: - cli._validate_workflow_id_or_exit(workflow_id) cli._install_workflow_from_catalog( project_root, workflows_dir, workflow_id, requested_version=version, selected_info=selected_info, diff --git a/src/specify_cli/workflows/step/catalog/__init__.py b/src/specify_cli/workflows/step/catalog/__init__.py index da783f45e8..0e65bb8387 100644 --- a/src/specify_cli/workflows/step/catalog/__init__.py +++ b/src/specify_cli/workflows/step/catalog/__init__.py @@ -29,6 +29,8 @@ def register(app: typer.Typer) -> None: "StepCatalog", "StepCatalogEntry", "StepCatalogError", + "StepCatalogFetchError", + "StepCatalogValidationError", "StepRegistry", "StepValidationError", } diff --git a/src/specify_cli/workflows/step/catalog/_domain.py b/src/specify_cli/workflows/step/catalog/_domain.py index d1f9ecf75c..945b97f1a4 100644 --- a/src/specify_cli/workflows/step/catalog/_domain.py +++ b/src/specify_cli/workflows/step/catalog/_domain.py @@ -36,11 +36,19 @@ class StepCatalogError(Exception): """Base error for step catalog operations.""" +class StepCatalogFetchError(StepCatalogError): + """A configured step catalog could not be fetched.""" + + +class StepCatalogValidationError(StepCatalogError): + """A step catalog supplied malformed metadata.""" + + class StepValidationError(StepCatalogError): """Validation error for step catalog config or step data.""" -class _DuplicateCatalogField(StepCatalogError): +class _DuplicateCatalogField(StepCatalogValidationError): """An ambiguous JSON object in a step catalog.""" @@ -480,20 +488,37 @@ def _fetch_single_catalog( cache_safe = self._is_cache_path_safe() cache_file, meta_file = self._get_cache_paths(entry.url) + def validate_payload(data: Any) -> dict[str, Any]: + if not isinstance(data, dict): + raise StepCatalogValidationError( + f"Catalog from {entry.url} is not a valid JSON object." + ) + if "steps" in data and not isinstance(data["steps"], (dict, list)): + raise StepCatalogValidationError( + f"Catalog from {entry.url} has malformed steps metadata." + ) + return data + if cache_safe and not force_refresh and self._is_url_cache_valid(entry.url): try: with open(cache_file, encoding="utf-8") as f: cached = json.load(f, object_pairs_hook=_unique_json_fields) - if isinstance(cached, dict): - return cached + return validate_payload(cached) except _DuplicateCatalogField: raise - except (UnicodeDecodeError, json.JSONDecodeError, OSError): + except ( + UnicodeDecodeError, + json.JSONDecodeError, + OSError, + StepCatalogValidationError, + ): # Ignore invalid/unreadable cache and fall back to fetching from source. pass + from http.client import HTTPException from urllib.parse import urlparse + from specify_cli.authentication.http import RedirectPolicyError from specify_cli.authentication.http import open_url as _open_url def _validate_url(url: str) -> None: @@ -507,18 +532,18 @@ def _validate_url(url: str) -> None: hostname = parsed.hostname _ = parsed.port except (TypeError, ValueError): - raise StepCatalogError( + raise StepCatalogValidationError( f"Refusing to fetch catalog from malformed URL: {url}" ) from None is_localhost = hostname in ("localhost", "127.0.0.1", "::1") if parsed.scheme != "https" and not ( parsed.scheme == "http" and is_localhost ): - raise StepCatalogError( + raise StepCatalogValidationError( f"Refusing to fetch catalog from non-HTTPS URL: {url}" ) if not hostname: - raise StepCatalogError( + raise StepCatalogValidationError( f"Refusing to fetch catalog from URL with no hostname: {url}" ) @@ -543,33 +568,43 @@ def _validate_redirect(_old_url: str, new_url: str) -> None: read_response_limited( resp, max_bytes=_max_json_catalog_bytes(), - error_type=StepCatalogError, + error_type=StepCatalogValidationError, label="step catalog", ).decode("utf-8"), object_pairs_hook=_unique_json_fields, ) except _DuplicateCatalogField: raise - except Exception as exc: + except ( + StepCatalogValidationError, + RedirectPolicyError, + UnicodeError, + json.JSONDecodeError, + ) as exc: + raise StepCatalogValidationError( + f"Invalid step catalog from {entry.url}: {exc}" + ) from exc + except (OSError, HTTPException) as exc: if cache_safe and cache_file.exists(): try: with open(cache_file, encoding="utf-8") as f: cached = json.load(f, object_pairs_hook=_unique_json_fields) - if isinstance(cached, dict): - return cached + return validate_payload(cached) except _DuplicateCatalogField: raise - except (json.JSONDecodeError, ValueError, OSError): + except ( + json.JSONDecodeError, + ValueError, + OSError, + StepCatalogValidationError, + ): # Stale-cache read failed; let the original fetch error propagate. pass - raise StepCatalogError( + raise StepCatalogFetchError( f"Failed to fetch catalog from {entry.url}: {exc}" ) from exc - if not isinstance(data, dict): - raise StepCatalogError( - f"Catalog from {entry.url} is not a valid JSON object." - ) + data = validate_payload(data) if cache_safe: try: @@ -584,25 +619,44 @@ def _validate_redirect(_old_url: str, new_url: str) -> None: return data def _get_merged_steps( - self, force_refresh: bool = False + self, force_refresh: bool = False, *, step_id: str | None = None ) -> dict[str, dict[str, Any]]: - """Merge steps from all active catalogs (lower priority number wins).""" + """Merge for search, or resolve one ID from the first valid winning source.""" catalogs = self.get_active_catalogs() merged: dict[str, dict[str, Any]] = {} fetch_errors = 0 + validation_error: StepCatalogValidationError | None = None + target_id = step_id - for entry in reversed(catalogs): + sources = catalogs if target_id is not None else reversed(catalogs) + for entry in sources: try: data = self._fetch_single_catalog(entry, force_refresh) except _DuplicateCatalogField: raise - except StepCatalogError: + except StepCatalogError as exc: + if target_id is not None and isinstance( + exc, StepCatalogValidationError + ): + raise + if ( + isinstance(exc, StepCatalogValidationError) + and validation_error is None + ): + validation_error = exc fetch_errors += 1 continue steps = data.get("steps", {}) if isinstance(steps, dict): for step_id, step_data in steps.items(): + if target_id is not None and step_id != target_id: + continue if not isinstance(step_data, dict): + if target_id is not None: + raise StepCatalogValidationError( + f"Invalid step catalog entry for '{step_id}' " + f"from {entry.url}: expected a JSON object" + ) continue step_data["_catalog_name"] = entry.name step_data["_install_allowed"] = entry.install_allowed @@ -622,12 +676,18 @@ def _get_merged_steps( f"Duplicate step ID '{step_id}' in catalog '{entry.name}'." ) seen_in_source.add(step_id) + if target_id is not None and step_id != target_id: + continue step_data["id"] = step_id step_data["_catalog_name"] = entry.name step_data["_install_allowed"] = entry.install_allowed merged[step_id] = step_data + if target_id is not None and target_id in merged: + return merged if fetch_errors == len(catalogs) and catalogs: - raise StepCatalogError("All configured step catalogs failed to fetch.") + if validation_error is not None: + raise validation_error + raise StepCatalogFetchError("All configured step catalogs failed to fetch.") return merged # -- Public API ------------------------------------------------------- @@ -665,7 +725,7 @@ def get_step_info( """Get the current or an exact release from the winning catalog.""" from ._versions import select_release - merged = self._get_merged_steps() + merged = self._get_merged_steps(step_id=step_id) step = merged.get(step_id) if step: step.setdefault("id", step_id) diff --git a/tests/specify_cli/bundles/test_references.py b/tests/specify_cli/bundles/test_references.py index 01a28d848d..986ea6facc 100644 --- a/tests/specify_cli/bundles/test_references.py +++ b/tests/specify_cli/bundles/test_references.py @@ -194,10 +194,10 @@ def invalid_release(_self, _id, version=None): def test_online_validation_warns_when_catalogs_are_unreachable(tmp_path, monkeypatch): - from specify_cli.workflows.catalog import WorkflowCatalog, WorkflowCatalogError + from specify_cli.workflows.catalog import WorkflowCatalog, WorkflowCatalogFetchError def unavailable(_self, _id, version=None): - raise WorkflowCatalogError("All configured catalogs failed to fetch.") + raise WorkflowCatalogFetchError("All configured catalogs failed to fetch.") monkeypatch.setattr(WorkflowCatalog, "get_workflow_info", unavailable) warnings = [] @@ -286,3 +286,231 @@ def test_online_validation_rejects_malformed_extension_catalog( assert "Invalid catalog format" in check(_ref("extensions", "invalid")) assert warnings == [] + + +def test_online_validation_rejects_malformed_winning_extension_catalog( + tmp_path, monkeypatch, +): + from specify_cli.extensions import CatalogEntry, ExtensionCatalog + + sources = [ + CatalogEntry("https://example.com/high.json", "high", 1, True), + CatalogEntry("https://example.com/low.json", "low", 2, True), + ] + monkeypatch.setattr(ExtensionCatalog, "get_active_catalogs", lambda self: sources) + + def fetch(self, entry, force_refresh=False): + if entry.name == "high": + self._validate_catalog_payload({"extensions": []}, entry.url) + return { + "schema_version": "1.0", + "extensions": {"requested": {"version": "1.0.0"}}, + } + + monkeypatch.setattr(ExtensionCatalog, "_fetch_single_catalog", fetch) + warnings = [] + check = make_reference_checker(tmp_path, allow_network=True, warnings=warnings) + + assert "Invalid catalog format" in check(_ref("extensions", "requested")) + assert warnings == [] + + +@pytest.mark.parametrize("kind", ["workflows", "steps"]) +@pytest.mark.parametrize("payload", [b"{invalid", b"[]"]) +def test_online_validation_rejects_malformed_workflow_catalogs( + tmp_path, monkeypatch, kind, payload, +): + import io + + from specify_cli.authentication import http + from specify_cli.workflows.catalog import ( + StepCatalog, + StepCatalogEntry, + WorkflowCatalog, + WorkflowCatalogEntry, + ) + + catalog, entry = ( + (WorkflowCatalog, WorkflowCatalogEntry) + if kind == "workflows" + else (StepCatalog, StepCatalogEntry) + ) + url = "https://example.com/catalog.json" + monkeypatch.setattr( + catalog, "get_active_catalogs", + lambda self: [entry(url, "trusted", 1, True)], + ) + + class Response(io.BytesIO): + def geturl(self): + return url + + monkeypatch.setattr(http, "open_url", lambda *args, **kwargs: Response(payload)) + warnings = [] + check = make_reference_checker(tmp_path, allow_network=True, warnings=warnings) + + assert "Catalog lookup failed" in check(_ref(kind, "requested")) + assert warnings == [] + + +@pytest.mark.parametrize("kind", ["workflows", "steps"]) +def test_online_validation_warns_for_unreachable_workflow_catalogs( + tmp_path, monkeypatch, kind, +): + from urllib.error import URLError + + from specify_cli.authentication import http + from specify_cli.workflows.catalog import ( + StepCatalog, + StepCatalogEntry, + WorkflowCatalog, + WorkflowCatalogEntry, + ) + + catalog, entry = ( + (WorkflowCatalog, WorkflowCatalogEntry) + if kind == "workflows" + else (StepCatalog, StepCatalogEntry) + ) + monkeypatch.setattr( + catalog, "get_active_catalogs", + lambda self: [ + entry("https://example.com/catalog.json", "trusted", 1, True), + ], + ) + + def unavailable(*args, **kwargs): + raise URLError("timed out") + + monkeypatch.setattr(http, "open_url", unavailable) + warnings = [] + check = make_reference_checker(tmp_path, allow_network=True, warnings=warnings) + + assert check(_ref(kind, "requested")) is None + assert len(warnings) == 1 + assert "unreachable" in warnings[0] + + +@pytest.mark.parametrize("kind", ["workflows", "steps"]) +def test_online_validation_rejects_unsafe_catalog_redirect( + tmp_path, monkeypatch, kind, +): + from specify_cli.authentication import http + from specify_cli.workflows.catalog import ( + StepCatalog, + StepCatalogEntry, + WorkflowCatalog, + WorkflowCatalogEntry, + ) + + catalog, entry = ( + (WorkflowCatalog, WorkflowCatalogEntry) + if kind == "workflows" + else (StepCatalog, StepCatalogEntry) + ) + monkeypatch.setattr( + catalog, "get_active_catalogs", + lambda self: [ + entry("https://example.com/catalog.json", "trusted", 1, True), + ], + ) + + def unsafe_redirect(*args, **kwargs): + raise http.RedirectPolicyError("unsafe catalog redirect") + + monkeypatch.setattr(http, "open_url", unsafe_redirect) + warnings = [] + check = make_reference_checker(tmp_path, allow_network=True, warnings=warnings) + + assert "unsafe catalog redirect" in check(_ref(kind, "requested")) + assert warnings == [] + + +@pytest.mark.parametrize("kind", ["workflows", "steps"]) +def test_online_validation_does_not_skip_malformed_higher_priority_catalog( + tmp_path, monkeypatch, kind, +): + import io + import json + + from specify_cli.authentication import http + from specify_cli.workflows.catalog import ( + StepCatalog, + StepCatalogEntry, + WorkflowCatalog, + WorkflowCatalogEntry, + ) + + catalog, entry = ( + (WorkflowCatalog, WorkflowCatalogEntry) + if kind == "workflows" + else (StepCatalog, StepCatalogEntry) + ) + sources = [ + entry("https://example.com/high.json", "high", 1, True), + entry("https://example.com/low.json", "low", 2, True), + ] + monkeypatch.setattr(catalog, "get_active_catalogs", lambda self: sources) + + class Response(io.BytesIO): + def __init__(self, url, payload): + super().__init__(payload) + self.url = url + + def geturl(self): + return self.url + + def open_url(url, **kwargs): + payload = ( + b"[]" if url.endswith("high.json") + else json.dumps({ + kind: {"requested": {"version": "1.0.0"}}, + }).encode() + ) + return Response(url, payload) + + monkeypatch.setattr(http, "open_url", open_url) + warnings = [] + check = make_reference_checker(tmp_path, allow_network=True, warnings=warnings) + + assert "Catalog lookup failed" in check(ComponentRef(kind=kind, id="requested")) + assert warnings == [] + + +@pytest.mark.parametrize("kind", ["extensions", "workflows", "steps"]) +def test_targeted_lookup_stops_before_lower_priority_catalog( + tmp_path, monkeypatch, kind, +): + from specify_cli.extensions import CatalogEntry, ExtensionCatalog + from specify_cli.workflows.catalog import ( + StepCatalog, + StepCatalogEntry, + WorkflowCatalog, + WorkflowCatalogEntry, + ) + + catalog, entry, key = { + "extensions": (ExtensionCatalog, CatalogEntry, "extensions"), + "workflows": (WorkflowCatalog, WorkflowCatalogEntry, "workflows"), + "steps": (StepCatalog, StepCatalogEntry, "steps"), + }[kind] + sources = [ + entry("https://example.com/high.json", "high", 1, True), + entry("https://example.com/low.json", "low", 2, True), + ] + monkeypatch.setattr(catalog, "get_active_catalogs", lambda self: sources) + + def fetch(self, source, force_refresh=False): + if source.name != "high": + pytest.fail("lower-priority catalog was fetched after finding the ID") + return { + "schema_version": "1.0", + key: {"requested": {"version": "1.0.0"}}, + } + + monkeypatch.setattr(catalog, "_fetch_single_catalog", fetch) + warnings = [] + check = make_reference_checker(tmp_path, allow_network=True, warnings=warnings) + + assert check(ComponentRef(kind=kind, id="requested", source="high")) is None + assert warnings == [] diff --git a/tests/specify_cli/extensions/test_catalog_versions.py b/tests/specify_cli/extensions/test_catalog_versions.py index 404256baa3..b10a9dd9a7 100644 --- a/tests/specify_cli/extensions/test_catalog_versions.py +++ b/tests/specify_cli/extensions/test_catalog_versions.py @@ -69,7 +69,8 @@ def _catalog( monkeypatch: pytest.MonkeyPatch, project: Path, entry: dict ) -> ExtensionCatalog: monkeypatch.setattr( - ExtensionCatalog, "_get_merged_extensions", lambda self: [entry] + ExtensionCatalog, "_get_merged_extensions", + lambda self, *, extension_id=None: [entry] ) return ExtensionCatalog(project) @@ -161,6 +162,33 @@ def test_requested_version_does_not_fall_through_to_lower_priority_catalog( assert catalog.get_extension_versions("demo-history") == ["0.5.1"] +def test_targeted_lookup_rejects_malformed_higher_catalog_but_search_continues( + tmp_path, monkeypatch, +): + from specify_cli.extensions import ExtensionCatalogValidationError + + 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) + + def fetch(source, _force=False): + if source.name == "high": + catalog._validate_catalog_payload({"extensions": []}, source.url) + return { + "schema_version": "1.0", + "extensions": {"demo-history": {"version": "1.0.0"}}, + } + + monkeypatch.setattr(catalog, "_fetch_single_catalog", fetch) + + with pytest.raises(ExtensionCatalogValidationError, match="Invalid catalog format"): + catalog.get_extension_info("demo-history") + assert catalog.search("demo-history")[0]["_catalog_name"] == "low" + + @pytest.mark.parametrize("second_lookup", ["lower_priority", "unavailable"]) def test_exact_cli_install_uses_first_resolved_catalog_snapshot( tmp_path, monkeypatch, second_lookup @@ -174,7 +202,7 @@ def test_exact_cli_install_uses_first_resolved_catalog_snapshot( fetches = [] selected = [] - def merged(_self): + def merged(_self, *, extension_id=None): fetches.append(True) if len(fetches) == 1: return [high] @@ -478,7 +506,7 @@ def test_info_versions_uses_first_resolved_catalog_snapshot(tmp_path, monkeypatc low["version"] = "0.8.0" fetches = [] - def merged(_self): + def merged(_self, *, extension_id=None): fetches.append(True) return [high if len(fetches) == 1 else low] diff --git a/tests/specify_cli/workflows/step/test_catalog_versions.py b/tests/specify_cli/workflows/step/test_catalog_versions.py index dfdee40b4a..5c9d0b3d85 100644 --- a/tests/specify_cli/workflows/step/test_catalog_versions.py +++ b/tests/specify_cli/workflows/step/test_catalog_versions.py @@ -45,7 +45,7 @@ def _entry() -> dict: def test_current_and_exact_release_keep_separate_metadata(project_dir, monkeypatch): catalog = StepCatalog(project_dir) - monkeypatch.setattr(catalog, "_get_merged_steps", lambda: {"deploy": _entry()}) + monkeypatch.setattr(catalog, "_get_merged_steps", lambda *, step_id=None: {"deploy": _entry()}) current = catalog.get_step_info("deploy") old = catalog.get_step_info("deploy", version="v1.0") @@ -70,7 +70,7 @@ def test_legacy_current_and_exact_spelling(project_dir, monkeypatch): "version": "release-1", "url": "https://example.com/step.yml", } - monkeypatch.setattr(catalog, "_get_merged_steps", lambda: {"deploy": legacy}) + monkeypatch.setattr(catalog, "_get_merged_steps", lambda *, step_id=None: {"deploy": legacy}) assert catalog.get_step_info("deploy") is legacy assert catalog.get_step_info("deploy", version="release-1") is legacy assert catalog.get_step_info("deploy", version="release-2") is None @@ -213,6 +213,6 @@ def getheader(self, _name): def test_bad_history_rejected_not_ignored(project_dir, monkeypatch, change, error): catalog = StepCatalog(project_dir) entry = {**_entry(), **change} - monkeypatch.setattr(catalog, "_get_merged_steps", lambda: {"deploy": entry}) + monkeypatch.setattr(catalog, "_get_merged_steps", lambda *, step_id=None: {"deploy": entry}) with pytest.raises(StepCatalogError, match=error): catalog.get_step_info("deploy") diff --git a/tests/specify_cli/workflows/step/test_command_add.py b/tests/specify_cli/workflows/step/test_command_add.py index b5be4ed829..8eda3941e4 100644 --- a/tests/specify_cli/workflows/step/test_command_add.py +++ b/tests/specify_cli/workflows/step/test_command_add.py @@ -1700,7 +1700,8 @@ def _setup(project_dir, monkeypatch, *, discovery=False, corrupt=None): elif corrupt == "url": entry["releases"]["1.0"]["init_url"] = "http://evil.example/__init__.py" monkeypatch.setattr( - StepCatalog, "_get_merged_steps", lambda self: {"deploy": entry} + StepCatalog, "_get_merged_steps", + lambda self, *, step_id=None: {"deploy": entry} ) requested = [] diff --git a/tests/specify_cli/workflows/step/test_command_info.py b/tests/specify_cli/workflows/step/test_command_info.py index edee4d2883..5575266568 100644 --- a/tests/specify_cli/workflows/step/test_command_info.py +++ b/tests/specify_cli/workflows/step/test_command_info.py @@ -119,7 +119,7 @@ def test_info_versions_shows_current_and_history_from_discovery_catalog( monkeypatch.chdir(project_dir) monkeypatch.setattr( StepCatalog, "_get_merged_steps", - lambda self: { + lambda self, *, step_id=None: { "deploy": { "id": "deploy", "name": "Deploy", "version": "2.0", "_install_allowed": False, diff --git a/tests/specify_cli/workflows/test_catalog_versions.py b/tests/specify_cli/workflows/test_catalog_versions.py index 7270656ef0..2eafe38594 100644 --- a/tests/specify_cli/workflows/test_catalog_versions.py +++ b/tests/specify_cli/workflows/test_catalog_versions.py @@ -289,6 +289,36 @@ def open_url(url, **kwargs): assert WorkflowRegistry(project_dir).get("history-wf")["version"] == "1.0.0" +def test_preselected_workflow_ignores_same_named_local_path( + monkeypatch, project_dir, +): + from specify_cli.authentication import http + from specify_cli.workflows.command_add import _install_preselected_workflow + + catalog = _catalog(monkeypatch, project_dir, _entry()) + selected = catalog.get_workflow_info("history-wf", "1.0.0") + shadow = project_dir / "history-wf" + shadow.write_text("not a workflow", encoding="utf-8") + monkeypatch.setattr( + WorkflowCatalog, "get_workflow_info", + lambda *args, **kwargs: pytest.fail("catalog was re-resolved"), + ) + monkeypatch.setattr( + http, "open_url", + lambda url, **kwargs: _Response( + _archive("1.0.0", requires={"integrations": ["copilot"]}), url + ), + ) + monkeypatch.chdir(project_dir) + + _install_preselected_workflow( + "history-wf", version="1.0.0", selected_info=selected, + ) + + assert WorkflowRegistry(project_dir).get("history-wf")["version"] == "1.0.0" + assert shadow.read_text(encoding="utf-8") == "not a workflow" + + def test_preselected_workflow_rejects_bad_digest_without_reloading_catalog( monkeypatch, project_dir, ): diff --git a/tests/specify_cli/workflows/test_command_search.py b/tests/specify_cli/workflows/test_command_search.py index 2afdb19b80..db002a6e93 100644 --- a/tests/specify_cli/workflows/test_command_search.py +++ b/tests/specify_cli/workflows/test_command_search.py @@ -82,7 +82,9 @@ def test_search_and_info_tolerate_non_list_tags(self, project_dir, monkeypatch): monkeypatch.setattr( WorkflowCatalog, "_get_merged_workflows", - lambda self, force_refresh=False: {k: dict(v) for k, v in workflows.items()}, + lambda self, force_refresh=False, *, workflow_id=None: { + k: dict(v) for k, v in workflows.items() + }, ) runner = CliRunner() searched = runner.invoke(app, ["workflow", "search"]) From 5868e5a6807f3360db65d36903bce05374167731 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Tue, 6 Oct 2026 09:19:38 -0500 Subject: [PATCH 04/23] Fix catalog redirect and encoding validation Reject unsafe extension and preset catalog redirects during exact-ID lookups and classify malformed extension encodings. Cover stacked and legacy fetch paths with before-and-after regressions. Assisted-by: GitHub Copilot (model: GPT-6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/specify_cli/extensions/__init__.py | 32 +++- src/specify_cli/presets/_catalog.py | 12 +- tests/specify_cli/bundles/test_references.py | 169 +++++++++++++++++++ 3 files changed, 210 insertions(+), 3 deletions(-) diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index 27fd55ed75..0cb86a4f66 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -4739,6 +4739,8 @@ def _fetch_single_catalog( # Fetch from network try: + from specify_cli.authentication.http import RedirectPolicyError + # Validate EVERY redirect hop, not just the terminal URL. _open_url # follows redirects; _StripAuthOnRedirect drops auth on an HTTPS->HTTP # downgrade AND whenever the redirect leaves the configured trusted @@ -4798,6 +4800,14 @@ def _validate_redirect(_old_url: str, new_url: str) -> None: return catalog_data + except ValidationError as e: + raise ExtensionCatalogValidationError( + f"Invalid catalog URL from {entry.url}: {e}" + ) from e + except RedirectPolicyError as e: + raise ExtensionCatalogValidationError( + f"Invalid catalog redirect from {entry.url}: {e}" + ) from e except urllib.error.URLError as e: raise ExtensionCatalogFetchError( f"Failed to fetch catalog from {entry.url}: {e}" @@ -4806,6 +4816,10 @@ def _validate_redirect(_old_url: str, new_url: str) -> None: raise ExtensionCatalogValidationError( f"Invalid JSON in catalog from {entry.url}: {e}" ) from e + except UnicodeError as e: + raise ExtensionCatalogValidationError( + f"Invalid encoding in catalog from {entry.url}: {e}" + ) from e def _get_merged_extensions( self, force_refresh: bool = False, *, extension_id: str | None = None @@ -4967,6 +4981,8 @@ def fetch_catalog(self, force_refresh: bool = False) -> Dict[str, Any]: try: import urllib.error + from specify_cli.authentication.http import RedirectPolicyError + # Same redirect hardening as _fetch_single_catalog: validate every # redirect hop AND the final URL so this legacy single-catalog path # is not vulnerable to an HTTPS->HTTP redirected payload either. @@ -5020,10 +5036,22 @@ def _validate_redirect(_old_url: str, new_url: str) -> None: return catalog_data + except ValidationError as e: + raise ExtensionCatalogValidationError( + f"Invalid catalog URL from {catalog_url}: {e}" + ) from e + except RedirectPolicyError as e: + raise ExtensionCatalogValidationError( + f"Invalid catalog redirect from {catalog_url}: {e}" + ) from e except urllib.error.URLError as e: - raise ExtensionError(f"Failed to fetch catalog from {catalog_url}: {e}") + raise ExtensionError(f"Failed to fetch catalog from {catalog_url}: {e}") from e except json.JSONDecodeError as e: - raise ExtensionError(f"Invalid JSON in catalog: {e}") + raise ExtensionError(f"Invalid JSON in catalog: {e}") from e + except UnicodeError as e: + raise ExtensionCatalogValidationError( + f"Invalid encoding in catalog from {catalog_url}: {e}" + ) from e def search( self, diff --git a/src/specify_cli/presets/_catalog.py b/src/specify_cli/presets/_catalog.py index 6b3cfcdf23..0e2b86244f 100644 --- a/src/specify_cli/presets/_catalog.py +++ b/src/specify_cli/presets/_catalog.py @@ -468,6 +468,7 @@ def _fetch_single_catalog(self, entry: PresetCatalogEntry, force_refresh: bool = PresetError: If catalog cannot be fetched """ # Honor the established package-level patch points during extraction. + from ..authentication.http import RedirectPolicyError from . import MAX_JSON_CATALOG_BYTES, read_response_limited cache_file, metadata_file = self._get_cache_paths(entry.url) @@ -549,6 +550,10 @@ def _validate_redirect(_old_url: str, new_url: str) -> None: return catalog_data except (ImportError, Exception) as e: + if isinstance(e, RedirectPolicyError): + raise PresetCatalogValidationError( + f"Invalid preset catalog redirect from {entry.url}: {e}" + ) from e if isinstance(e, PresetError): raise raise PresetCatalogFetchError( @@ -594,7 +599,7 @@ def _get_merged_packs( merged[found_id] = pack_data_with_catalog if pack_id is not None: return merged - except PresetCatalogValidationError: + except (PresetCatalogValidationError, PresetValidationError): raise except PresetError as exc: if first_fetch_error is None: @@ -659,6 +664,7 @@ def fetch_catalog(self, force_refresh: bool = False) -> Dict[str, Any]: Raises: PresetError: If catalog cannot be fetched """ + from ..authentication.http import RedirectPolicyError from . import MAX_JSON_CATALOG_BYTES, read_response_limited catalog_url = self.get_catalog_url() @@ -741,6 +747,10 @@ def _validate_redirect(_old_url: str, new_url: str) -> None: return catalog_data except (ImportError, Exception) as e: + if isinstance(e, RedirectPolicyError): + raise PresetCatalogValidationError( + f"Invalid preset catalog redirect from {catalog_url}: {e}" + ) from e if isinstance(e, PresetError): raise raise PresetCatalogFetchError( diff --git a/tests/specify_cli/bundles/test_references.py b/tests/specify_cli/bundles/test_references.py index 986ea6facc..261a0183ed 100644 --- a/tests/specify_cli/bundles/test_references.py +++ b/tests/specify_cli/bundles/test_references.py @@ -426,6 +426,175 @@ def unsafe_redirect(*args, **kwargs): assert warnings == [] +@pytest.mark.parametrize("kind", ["extensions", "presets"]) +@pytest.mark.parametrize("redirect", ["validator", "policy"]) +def test_online_validation_does_not_skip_unsafe_higher_priority_catalog( + tmp_path, monkeypatch, kind, redirect, +): + import io + import json + + from specify_cli.authentication.http import RedirectPolicyError + from specify_cli.extensions import CatalogEntry, ExtensionCatalog + from specify_cli.presets import PresetCatalog, PresetCatalogEntry + + catalog, entry = ( + (ExtensionCatalog, CatalogEntry) + if kind == "extensions" + else (PresetCatalog, PresetCatalogEntry) + ) + sources = [ + entry("https://example.com/high.json", "high", 1, True), + entry("https://example.com/low.json", "low", 2, True), + ] + monkeypatch.setattr(catalog, "get_active_catalogs", lambda self: sources) + + class Response(io.BytesIO): + def __init__(self, url, payload): + super().__init__(payload) + self.url = url + + def geturl(self): + return self.url + + def open_url(self, url, **kwargs): + if url.endswith("high.json"): + if redirect == "validator": + kwargs["redirect_validator"](url, "http://evil.test/catalog.json") + pytest.fail("unsafe redirect was accepted") + raise RedirectPolicyError("unsafe catalog redirect") + return Response( + url, + json.dumps({ + "schema_version": "1.0", + kind: { + "requested": { + "version": "1.0.0", + "download_url": "https://example.com/archive.zip", + "sha256": "a" * 64, + } + }, + }).encode(), + ) + + monkeypatch.setattr(catalog, "_open_url", open_url) + warnings = [] + check = make_reference_checker(tmp_path, allow_network=True, warnings=warnings) + + problem = check(_ref(kind, "requested")) + assert problem is not None and ( + "HTTPS" in problem or "unsafe catalog redirect" in problem + ) + assert warnings == [] + + +@pytest.mark.parametrize("legacy", [False, True]) +def test_extension_catalog_invalid_utf8_is_validation_error( + tmp_path, monkeypatch, legacy, +): + import io + + from specify_cli.extensions import ( + CatalogEntry, + ExtensionCatalog, + ExtensionCatalogValidationError, + ) + + class Response(io.BytesIO): + def geturl(self): + return "https://example.com/catalog.json" + + catalog = ExtensionCatalog(tmp_path) + entry = CatalogEntry("https://example.com/catalog.json", "bad", 1, True) + monkeypatch.setattr(catalog, "get_catalog_url", lambda: entry.url) + monkeypatch.setattr(catalog, "_open_url", lambda url, **kwargs: Response(b"\xff")) + + with pytest.raises(ExtensionCatalogValidationError, match="encoding"): + if legacy: + catalog.fetch_catalog(force_refresh=True) + else: + catalog._fetch_single_catalog(entry, force_refresh=True) + + +def test_online_validation_does_not_skip_invalid_utf8_extension_catalog( + tmp_path, monkeypatch, +): + import io + import json + + from specify_cli.extensions import CatalogEntry, ExtensionCatalog + + sources = [ + CatalogEntry("https://example.com/high.json", "high", 1, True), + CatalogEntry("https://example.com/low.json", "low", 2, True), + ] + monkeypatch.setattr(ExtensionCatalog, "get_active_catalogs", lambda self: sources) + + class Response(io.BytesIO): + def __init__(self, url, payload): + super().__init__(payload) + self.url = url + + def geturl(self): + return self.url + + def open_url(self, url, **kwargs): + payload = ( + b"\xff" if url.endswith("high.json") + else json.dumps({ + "schema_version": "1.0", + "extensions": {"requested": {"version": "1.0.0"}}, + }).encode() + ) + return Response(url, payload) + + monkeypatch.setattr(ExtensionCatalog, "_open_url", open_url) + warnings = [] + check = make_reference_checker(tmp_path, allow_network=True, warnings=warnings) + + assert "Invalid encoding" in check(_ref("extensions", "requested")) + assert warnings == [] + + +@pytest.mark.parametrize("kind", ["extensions", "presets"]) +@pytest.mark.parametrize("legacy", [False, True]) +def test_catalog_redirect_policy_is_validation_error( + tmp_path, monkeypatch, kind, legacy, +): + from specify_cli.authentication.http import RedirectPolicyError + from specify_cli.extensions import ( + CatalogEntry, + ExtensionCatalog, + ExtensionCatalogValidationError, + ) + from specify_cli.presets import ( + PresetCatalog, + PresetCatalogEntry, + ) + from specify_cli.presets._catalog import PresetCatalogValidationError + + catalog_type, entry_type, error_type = ( + (ExtensionCatalog, CatalogEntry, ExtensionCatalogValidationError) + if kind == "extensions" + else (PresetCatalog, PresetCatalogEntry, PresetCatalogValidationError) + ) + catalog = catalog_type(tmp_path) + url = "https://example.com/catalog.json" + monkeypatch.setattr(catalog, "get_catalog_url", lambda: url) + + def unsafe_redirect(*args, **kwargs): + raise RedirectPolicyError("unsafe catalog redirect") + + monkeypatch.setattr(catalog, "_open_url", unsafe_redirect) + with pytest.raises(error_type, match="unsafe catalog redirect"): + if legacy: + catalog.fetch_catalog(force_refresh=True) + else: + catalog._fetch_single_catalog( + entry_type(url, "trusted", 1, True), force_refresh=True + ) + + @pytest.mark.parametrize("kind", ["workflows", "steps"]) def test_online_validation_does_not_skip_malformed_higher_priority_catalog( tmp_path, monkeypatch, kind, From 059d73c231f1a2d1733f814f1346bd4f0a849b72 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Tue, 6 Oct 2026 09:48:58 -0500 Subject: [PATCH 05/23] Validate deeply nested workflow and step catalogs Classify JSON recursion during parsing or cache writes as malformed catalog data, recover from poisoned caches, and keep non-fetch step catalog errors blocking exact-ID lookup. Assisted-by: GitHub Copilot (model: GPT-6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/specify_cli/workflows/catalog/_domain.py | 7 ++ .../workflows/step/catalog/_domain.py | 11 +- tests/specify_cli/bundles/test_references.py | 102 ++++++++++++++++++ .../workflows/step/test_catalog_versions.py | 28 +++++ 4 files changed, 146 insertions(+), 2 deletions(-) diff --git a/src/specify_cli/workflows/catalog/_domain.py b/src/specify_cli/workflows/catalog/_domain.py index 02c33b2c9d..6fbdeb5e55 100644 --- a/src/specify_cli/workflows/catalog/_domain.py +++ b/src/specify_cli/workflows/catalog/_domain.py @@ -544,6 +544,7 @@ def validate_payload(data: Any) -> dict[str, Any]: except ( UnicodeDecodeError, json.JSONDecodeError, + RecursionError, OSError, WorkflowCatalogValidationError, ): @@ -613,6 +614,7 @@ def _validate_redirect(_old_url: str, new_url: str) -> None: RedirectPolicyError, UnicodeError, json.JSONDecodeError, + RecursionError, ) as exc: raise WorkflowCatalogValidationError( f"Invalid workflow catalog from {entry.url}: {exc}" @@ -627,6 +629,7 @@ def _validate_redirect(_old_url: str, new_url: str) -> None: except ( json.JSONDecodeError, ValueError, + RecursionError, OSError, WorkflowCatalogValidationError, ): @@ -647,6 +650,10 @@ def _validate_redirect(_old_url: str, new_url: str) -> None: json.dump({"url": entry.url, "fetched_at": time.time()}, f) except OSError: pass # Proceed without caching if disk write fails + except RecursionError as exc: + raise WorkflowCatalogValidationError( + f"Invalid workflow catalog from {entry.url}: excessive nesting ({exc})" + ) from exc return data diff --git a/src/specify_cli/workflows/step/catalog/_domain.py b/src/specify_cli/workflows/step/catalog/_domain.py index 945b97f1a4..96cf394cf9 100644 --- a/src/specify_cli/workflows/step/catalog/_domain.py +++ b/src/specify_cli/workflows/step/catalog/_domain.py @@ -509,6 +509,7 @@ def validate_payload(data: Any) -> dict[str, Any]: except ( UnicodeDecodeError, json.JSONDecodeError, + RecursionError, OSError, StepCatalogValidationError, ): @@ -580,6 +581,7 @@ def _validate_redirect(_old_url: str, new_url: str) -> None: RedirectPolicyError, UnicodeError, json.JSONDecodeError, + RecursionError, ) as exc: raise StepCatalogValidationError( f"Invalid step catalog from {entry.url}: {exc}" @@ -595,6 +597,7 @@ def _validate_redirect(_old_url: str, new_url: str) -> None: except ( json.JSONDecodeError, ValueError, + RecursionError, OSError, StepCatalogValidationError, ): @@ -615,6 +618,10 @@ def _validate_redirect(_old_url: str, new_url: str) -> None: json.dump({"url": entry.url, "fetched_at": time.time()}, f) except OSError: pass # Proceed without caching if disk write fails + except RecursionError as exc: + raise StepCatalogValidationError( + f"Invalid step catalog from {entry.url}: excessive nesting ({exc})" + ) from exc return data @@ -635,8 +642,8 @@ def _get_merged_steps( except _DuplicateCatalogField: raise except StepCatalogError as exc: - if target_id is not None and isinstance( - exc, StepCatalogValidationError + if target_id is not None and not isinstance( + exc, StepCatalogFetchError ): raise if ( diff --git a/tests/specify_cli/bundles/test_references.py b/tests/specify_cli/bundles/test_references.py index 261a0183ed..0c72991944 100644 --- a/tests/specify_cli/bundles/test_references.py +++ b/tests/specify_cli/bundles/test_references.py @@ -646,6 +646,108 @@ def open_url(url, **kwargs): assert warnings == [] +@pytest.mark.parametrize("kind", ["workflows", "steps"]) +@pytest.mark.parametrize("source", ["network", "fresh-cache", "stale-cache"]) +def test_deeply_nested_catalog_is_handled_as_malformed_data( + tmp_path, monkeypatch, kind, source, +): + import io + import json + from urllib.error import URLError + + from specify_cli.authentication import http + from specify_cli.workflows.catalog import ( + StepCatalog, + StepCatalogEntry, + StepCatalogFetchError, + StepCatalogValidationError, + WorkflowCatalog, + WorkflowCatalogEntry, + WorkflowCatalogFetchError, + WorkflowCatalogValidationError, + ) + + catalog_type, entry_type, validation_error, fetch_error = { + "workflows": ( + WorkflowCatalog, WorkflowCatalogEntry, + WorkflowCatalogValidationError, WorkflowCatalogFetchError, + ), + "steps": ( + StepCatalog, StepCatalogEntry, + StepCatalogValidationError, StepCatalogFetchError, + ), + }[kind] + catalog = catalog_type(tmp_path) + entry = entry_type("https://example.com/catalog.json", "trusted", 1, True) + nested = b'{"nested":' + b"[" * 10000 + b"0" + b"]" * 10000 + b"}" + valid = {"schema_version": "1.0", kind: {"requested": {"version": "1.0.0"}}} + + class Response(io.BytesIO): + def geturl(self): + return entry.url + + if source != "network": + cache_file, _ = catalog._get_cache_paths(entry.url) + cache_file.parent.mkdir(parents=True, exist_ok=True) + cache_file.write_bytes(nested) + monkeypatch.setattr( + catalog, "_is_url_cache_valid", lambda _url: source == "fresh-cache" + ) + + def open_url(*args, **kwargs): + if source == "stale-cache": + raise URLError("connection failed") + payload = nested if source == "network" else json.dumps(valid).encode() + return Response(payload) + + monkeypatch.setattr(http, "open_url", open_url) + if source == "network": + with pytest.raises(validation_error, match="Invalid.*catalog"): + catalog._fetch_single_catalog(entry, force_refresh=True) + elif source == "stale-cache": + with pytest.raises(fetch_error, match="Failed to fetch catalog"): + catalog._fetch_single_catalog(entry) + else: + assert catalog._fetch_single_catalog(entry) == valid + + +@pytest.mark.parametrize("kind", ["workflows", "steps"]) +def test_deep_catalog_does_not_escape_during_cache_write( + tmp_path, monkeypatch, kind, +): + import io + + from specify_cli.authentication import http + from specify_cli.workflows.catalog import ( + StepCatalog, + StepCatalogEntry, + StepCatalogValidationError, + WorkflowCatalog, + WorkflowCatalogEntry, + WorkflowCatalogValidationError, + ) + + catalog_type, entry_type, error_type = { + "workflows": ( + WorkflowCatalog, WorkflowCatalogEntry, WorkflowCatalogValidationError, + ), + "steps": (StepCatalog, StepCatalogEntry, StepCatalogValidationError), + }[kind] + catalog = catalog_type(tmp_path) + entry = entry_type("https://example.com/catalog.json", "trusted", 1, True) + payload = b'{"nested":' + b"[" * 1200 + b"0" + b"]" * 1200 + b"}" + + class Response(io.BytesIO): + def geturl(self): + return entry.url + + monkeypatch.setattr( + http, "open_url", lambda *args, **kwargs: Response(payload) + ) + with pytest.raises(error_type, match="Invalid.*catalog"): + catalog._fetch_single_catalog(entry, force_refresh=True) + + @pytest.mark.parametrize("kind", ["extensions", "workflows", "steps"]) def test_targeted_lookup_stops_before_lower_priority_catalog( tmp_path, monkeypatch, kind, diff --git a/tests/specify_cli/workflows/step/test_catalog_versions.py b/tests/specify_cli/workflows/step/test_catalog_versions.py index 5c9d0b3d85..97e6b82cb7 100644 --- a/tests/specify_cli/workflows/step/test_catalog_versions.py +++ b/tests/specify_cli/workflows/step/test_catalog_versions.py @@ -114,6 +114,34 @@ def test_list_catalog_rejects_duplicate_step_ids(project_dir, monkeypatch): catalog.get_step_info("deploy") +@pytest.mark.parametrize("raised_during_fetch", [False, True]) +def test_targeted_lookup_rejects_duplicate_ids_before_lower_catalog( + project_dir, monkeypatch, raised_during_fetch, +): + catalog = StepCatalog(project_dir) + sources = [ + StepCatalogEntry("https://example.com/high.json", "high", 1, True), + StepCatalogEntry("https://example.com/low.json", "low", 2, True), + ] + monkeypatch.setattr(catalog, "get_active_catalogs", lambda: sources) + + def fetch(source, force_refresh=False): + if source.name == "high": + if raised_during_fetch: + raise StepCatalogError("Duplicate step ID 'deploy' in catalog 'high'.") + return {"steps": [ + {"id": "deploy", "version": "1.0"}, + {"id": "deploy", "version": "2.0"}, + ]} + return {"steps": {"deploy": {"version": "3.0"}}} + + monkeypatch.setattr(catalog, "_fetch_single_catalog", fetch) + with pytest.raises(StepCatalogError, match="Duplicate step ID 'deploy'"): + catalog.get_step_info("deploy") + if raised_during_fetch: + assert catalog.search(query="deploy")[0]["version"] == "3.0" + + @pytest.mark.parametrize("cached", [True, False]) def test_duplicate_json_release_key_is_not_silently_overwritten( project_dir, monkeypatch, cached From 682732bd9488a1f3a55ffa0e990f6f232284c1e7 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Tue, 6 Oct 2026 10:12:28 -0500 Subject: [PATCH 06/23] Align bundled preset validation with installation Reject unsourced bundled preset pins that the installer cannot satisfy, classify extension HTTP protocol failures as unreachable catalogs, and make deep-cache regressions deterministic across Python versions. Assisted-by: GitHub Copilot (model: GPT-6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/specify_cli/bundles/references.py | 17 +++ src/specify_cli/extensions/__init__.py | 11 +- .../bundles/test_command_validate.py | 26 ++++ tests/specify_cli/bundles/test_references.py | 127 ++++++++++++++++++ 4 files changed, 178 insertions(+), 3 deletions(-) diff --git a/src/specify_cli/bundles/references.py b/src/specify_cli/bundles/references.py index 9ee8de1827..c48c84adec 100644 --- a/src/specify_cli/bundles/references.py +++ b/src/specify_cli/bundles/references.py @@ -154,6 +154,23 @@ def check(component: ComponentRef) -> str | None: if _resolved_locally(project_root, component): return None + if component.kind == "presets" and component.source is None: + from .._assets import _locate_bundled_preset + from . import BundlerError + from .primitives import _assert_pinned_version, _bundled_manifest_version + + bundled = _locate_bundled_preset(component.id) + if bundled is not None: + try: + _assert_pinned_version( + "Preset", + component.id, + component.version, + _bundled_manifest_version(bundled / "preset.yml", "preset"), + ) + except BundlerError as exc: + return str(exc) + if allow_network: in_catalog = _resolved_in_catalog(project_root, component) if in_catalog is True: diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index 0cb86a4f66..da5dddfc00 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -4739,6 +4739,8 @@ def _fetch_single_catalog( # Fetch from network try: + from http.client import HTTPException + from specify_cli.authentication.http import RedirectPolicyError # Validate EVERY redirect hop, not just the terminal URL. _open_url @@ -4808,7 +4810,7 @@ def _validate_redirect(_old_url: str, new_url: str) -> None: raise ExtensionCatalogValidationError( f"Invalid catalog redirect from {entry.url}: {e}" ) from e - except urllib.error.URLError as e: + except (urllib.error.URLError, OSError, HTTPException) as e: raise ExtensionCatalogFetchError( f"Failed to fetch catalog from {entry.url}: {e}" ) from e @@ -4980,6 +4982,7 @@ def fetch_catalog(self, force_refresh: bool = False) -> Dict[str, Any]: try: import urllib.error + from http.client import HTTPException from specify_cli.authentication.http import RedirectPolicyError @@ -5044,8 +5047,10 @@ def _validate_redirect(_old_url: str, new_url: str) -> None: raise ExtensionCatalogValidationError( f"Invalid catalog redirect from {catalog_url}: {e}" ) from e - except urllib.error.URLError as e: - raise ExtensionError(f"Failed to fetch catalog from {catalog_url}: {e}") from e + except (urllib.error.URLError, OSError, HTTPException) as e: + raise ExtensionCatalogFetchError( + f"Failed to fetch catalog from {catalog_url}: {e}" + ) from e except json.JSONDecodeError as e: raise ExtensionError(f"Invalid JSON in catalog: {e}") from e except UnicodeError as e: diff --git a/tests/specify_cli/bundles/test_command_validate.py b/tests/specify_cli/bundles/test_command_validate.py index 0468f9f193..7f8757a9e0 100644 --- a/tests/specify_cli/bundles/test_command_validate.py +++ b/tests/specify_cli/bundles/test_command_validate.py @@ -106,3 +106,29 @@ def test_validate_accepts_bundled_reference(project: Path): result = runner.invoke(app, ["bundle", "validate"]) assert result.exit_code == 0, result.output assert "valid" in result.output + + +@pytest.mark.parametrize("offline", [False, True]) +def test_validate_rejects_mismatched_bundled_preset( + project: Path, monkeypatch, offline: bool, +): + from specify_cli.presets import PresetCatalog + + data = valid_manifest_dict( + provides={"presets": [{"id": "lean", "version": "9.9.9"}]} + ) + (project / "bundle.yml").write_text(yaml.safe_dump(data), encoding="utf-8") + monkeypatch.setattr( + PresetCatalog, "get_pack_info", + lambda self, _id, version=None: { + "version": version or "9.9.9", + "_catalog_name": "trusted", + "_install_allowed": True, + }, + ) + + command = ["bundle", "validate", *(["--offline"] if offline else [])] + result = runner.invoke(app, command) + + assert result.exit_code == 1, result.output + assert "resolved version is 1.0.0" in result.output diff --git a/tests/specify_cli/bundles/test_references.py b/tests/specify_cli/bundles/test_references.py index 0c72991944..2580604cd1 100644 --- a/tests/specify_cli/bundles/test_references.py +++ b/tests/specify_cli/bundles/test_references.py @@ -125,6 +125,119 @@ def test_wrong_bundled_pin_does_not_resolve_locally(tmp_path): assert any("agent-context" in message for message in warnings) +@pytest.mark.parametrize("allow_network", [False, True]) +def test_bundled_preset_pin_mismatch_is_definitive( + tmp_path, monkeypatch, allow_network, +): + import specify_cli._assets as assets + from specify_cli.presets import PresetCatalog + + bundled = tmp_path / "preset" + bundled.mkdir() + (bundled / "preset.yml").write_text( + "preset:\n id: requested\n version: 1.0.0\n", encoding="utf-8" + ) + monkeypatch.setattr(assets, "_locate_bundled_preset", lambda _id: bundled) + monkeypatch.setattr( + PresetCatalog, "get_pack_info", + lambda self, _id, version=None: { + "version": version or "2.0.0", + "_catalog_name": "trusted", + "_install_allowed": True, + }, + ) + warnings = [] + check = make_reference_checker(tmp_path, allow_network=allow_network, warnings=warnings) + + problem = check(_ref("presets", "requested", "2.0.0")) + assert problem is not None and "resolved version is 1.0.0" in problem + assert warnings == [] + if allow_network: + assert check(ComponentRef( + kind="presets", id="requested", version="2.0.0", source="trusted" + )) is None + assert warnings == [] + assert check(_ref("presets", "requested", "1.0.0")) is None + + +def test_bundled_preset_mismatch_allows_matching_installed_version( + tmp_path, monkeypatch, +): + from types import SimpleNamespace + + import specify_cli._assets as assets + from specify_cli.bundles import primitives + + bundled = tmp_path / "preset" + bundled.mkdir() + (bundled / "preset.yml").write_text( + "preset:\n id: requested\n version: 1.0.0\n", encoding="utf-8" + ) + monkeypatch.setattr(assets, "_locate_bundled_preset", lambda _id: bundled) + monkeypatch.setattr( + primitives, "primitive_manager", + lambda *args, **kwargs: SimpleNamespace( + is_installed=lambda _component: True, + installed_version=lambda _component: "2.0.0", + ), + ) + warnings = [] + check = make_reference_checker(tmp_path, allow_network=False, warnings=warnings) + + assert check(_ref("presets", "requested", "2.0.0")) is None + assert warnings == [] + + +@pytest.mark.parametrize("legacy", [False, True]) +def test_extension_http_protocol_error_is_unreachable_catalog( + tmp_path, monkeypatch, legacy, +): + from http.client import BadStatusLine + + from specify_cli.extensions import ( + CatalogEntry, + ExtensionCatalog, + ExtensionCatalogFetchError, + ) + + catalog = ExtensionCatalog(tmp_path) + entry = CatalogEntry("https://example.com/catalog.json", "trusted", 1, True) + monkeypatch.setattr(catalog, "get_catalog_url", lambda: entry.url) + + def bad_status(*args, **kwargs): + raise BadStatusLine("bad response") + + monkeypatch.setattr(catalog, "_open_url", bad_status) + with pytest.raises(ExtensionCatalogFetchError, match="bad response"): + if legacy: + catalog.fetch_catalog(force_refresh=True) + else: + catalog._fetch_single_catalog(entry, force_refresh=True) + + +def test_online_validation_warns_for_extension_http_protocol_error( + tmp_path, monkeypatch, +): + from http.client import BadStatusLine + + from specify_cli.extensions import CatalogEntry, ExtensionCatalog + + monkeypatch.setattr( + ExtensionCatalog, "get_active_catalogs", + lambda self: [CatalogEntry("https://example.com/catalog.json", "trusted", 1, True)], + ) + + def bad_status(*args, **kwargs): + raise BadStatusLine("bad response") + + monkeypatch.setattr(ExtensionCatalog, "_open_url", bad_status) + warnings = [] + check = make_reference_checker(tmp_path, allow_network=True, warnings=warnings) + + assert check(_ref("extensions", "requested")) is None + assert len(warnings) == 1 and "unreachable" in warnings[0] + + def test_online_validation_checks_winning_exact_release_and_source(tmp_path, monkeypatch): import specify_cli._assets as assets from specify_cli.workflows.catalog import WorkflowCatalog @@ -694,6 +807,20 @@ def geturl(self): catalog, "_is_url_cache_valid", lambda _url: source == "fresh-cache" ) + def read_nested_cache(*args, **kwargs): + raise RecursionError("catalog nesting limit exceeded") + + monkeypatch.setattr(json, "load", read_nested_cache) + else: + original_loads = json.loads + + def decode_nested_catalog(raw, *args, **kwargs): + if raw == nested.decode("utf-8"): + raise RecursionError("catalog nesting limit exceeded") + return original_loads(raw, *args, **kwargs) + + monkeypatch.setattr(json, "loads", decode_nested_catalog) + def open_url(*args, **kwargs): if source == "stale-cache": raise URLError("connection failed") From a427208776edf40992ff059e67a696577936b0df Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Tue, 6 Oct 2026 13:23:51 -0500 Subject: [PATCH 07/23] Reject missing catalog collections and bundled extension pin mismatches Require workflow and step collections in fetched or cached catalogs before exact-ID lookup may fall through. Align bundle validation with bundled-first extension installs while retaining explicit catalog sources and matching installed versions. Assisted-by: GitHub Copilot (model: GPT-6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/reference/bundles.md | 2 + src/specify_cli/bundles/references.py | 16 ++- src/specify_cli/workflows/catalog/_domain.py | 2 +- .../workflows/step/catalog/_domain.py | 2 +- .../bundles/test_command_validate.py | 21 +++- tests/specify_cli/bundles/test_references.py | 108 ++++++++++++++++-- 6 files changed, 130 insertions(+), 21 deletions(-) diff --git a/docs/reference/bundles.md b/docs/reference/bundles.md index 902c56c32c..71c57d3f65 100644 --- a/docs/reference/bundles.md +++ b/docs/reference/bundles.md @@ -99,6 +99,8 @@ Re-resolves a bundle and **refreshes** its components through each primitive's u **Pinned catalog releases.** Extensions, presets, workflows, and steps with version pins select that exact release from the highest-priority active catalog entry. Historical releases must be advertised under `releases` with their own artifact URL and SHA-256 digest; the bundler never guesses an old URL from the current one or falls through to another catalog. The primitive installer uses the selected workflow or step record without re-reading the catalog, and verifies downloaded archive or workflow/step metadata against that release. A pinned workflow that ships with Spec Kit uses its bundled copy when the version matches, and the catalog release when it differs and network access is allowed. A step without a pin installs the current catalog release. +Without an explicit `source`, bundled extensions and presets take precedence over catalog releases. Validation rejects a pin that differs from the bundled version even when a matching catalog release exists; specify the winning catalog as `source` to opt into its release. + > **One installed version per component ID.** Bundles sharing a component must agree on its pinned version. A different or unknown pin from another bundle is rejected before installation, including during `bundle update`; refreshing one bundle cannot replace a version required by another. A bundle may list a component ID only once per kind; duplicate references in the same manifest are invalid, even if their pins agree. diff --git a/src/specify_cli/bundles/references.py b/src/specify_cli/bundles/references.py index c48c84adec..c543094db5 100644 --- a/src/specify_cli/bundles/references.py +++ b/src/specify_cli/bundles/references.py @@ -154,19 +154,25 @@ def check(component: ComponentRef) -> str | None: if _resolved_locally(project_root, component): return None - if component.kind == "presets" and component.source is None: - from .._assets import _locate_bundled_preset + if component.kind in ("presets", "extensions") and component.source is None: + from .._assets import _locate_bundled_extension, _locate_bundled_preset from . import BundlerError from .primitives import _assert_pinned_version, _bundled_manifest_version - bundled = _locate_bundled_preset(component.id) + kind = component.kind[:-1] + locate = ( + _locate_bundled_preset + if component.kind == "presets" + else _locate_bundled_extension + ) + bundled = locate(component.id) if bundled is not None: try: _assert_pinned_version( - "Preset", + kind.capitalize(), component.id, component.version, - _bundled_manifest_version(bundled / "preset.yml", "preset"), + _bundled_manifest_version(bundled / f"{kind}.yml", kind), ) except BundlerError as exc: return str(exc) diff --git a/src/specify_cli/workflows/catalog/_domain.py b/src/specify_cli/workflows/catalog/_domain.py index 6fbdeb5e55..4fa9f183d0 100644 --- a/src/specify_cli/workflows/catalog/_domain.py +++ b/src/specify_cli/workflows/catalog/_domain.py @@ -530,7 +530,7 @@ def validate_payload(data: Any) -> dict[str, Any]: raise WorkflowCatalogValidationError( f"Catalog from {entry.url} is not a valid JSON object." ) - if "workflows" in data and not isinstance(data["workflows"], (dict, list)): + if not isinstance(data.get("workflows"), (dict, list)): raise WorkflowCatalogValidationError( f"Catalog from {entry.url} has malformed workflows metadata." ) diff --git a/src/specify_cli/workflows/step/catalog/_domain.py b/src/specify_cli/workflows/step/catalog/_domain.py index 96cf394cf9..13370f64a1 100644 --- a/src/specify_cli/workflows/step/catalog/_domain.py +++ b/src/specify_cli/workflows/step/catalog/_domain.py @@ -493,7 +493,7 @@ def validate_payload(data: Any) -> dict[str, Any]: raise StepCatalogValidationError( f"Catalog from {entry.url} is not a valid JSON object." ) - if "steps" in data and not isinstance(data["steps"], (dict, list)): + if not isinstance(data.get("steps"), (dict, list)): raise StepCatalogValidationError( f"Catalog from {entry.url} has malformed steps metadata." ) diff --git a/tests/specify_cli/bundles/test_command_validate.py b/tests/specify_cli/bundles/test_command_validate.py index 7f8757a9e0..be096b7003 100644 --- a/tests/specify_cli/bundles/test_command_validate.py +++ b/tests/specify_cli/bundles/test_command_validate.py @@ -108,14 +108,19 @@ def test_validate_accepts_bundled_reference(project: Path): assert "valid" in result.output +@pytest.mark.parametrize("kind,id,version", [ + ("presets", "lean", "1.0.0"), + ("extensions", "agent-context", bundled_extension_version("agent-context")), +]) @pytest.mark.parametrize("offline", [False, True]) -def test_validate_rejects_mismatched_bundled_preset( - project: Path, monkeypatch, offline: bool, +def test_validate_rejects_mismatched_bundled_component( + project: Path, monkeypatch, kind: str, id: str, version: str, offline: bool, ): + from specify_cli.extensions import ExtensionCatalog from specify_cli.presets import PresetCatalog data = valid_manifest_dict( - provides={"presets": [{"id": "lean", "version": "9.9.9"}]} + provides={kind: [{"id": id, "version": "9.9.9"}]} ) (project / "bundle.yml").write_text(yaml.safe_dump(data), encoding="utf-8") monkeypatch.setattr( @@ -126,9 +131,17 @@ def test_validate_rejects_mismatched_bundled_preset( "_install_allowed": True, }, ) + monkeypatch.setattr( + ExtensionCatalog, "get_extension_info", + lambda self, _id, version=None: { + "version": version or "9.9.9", + "_catalog_name": "trusted", + "_install_allowed": True, + }, + ) command = ["bundle", "validate", *(["--offline"] if offline else [])] result = runner.invoke(app, command) assert result.exit_code == 1, result.output - assert "resolved version is 1.0.0" in result.output + assert f"resolved version is {version}" in result.output diff --git a/tests/specify_cli/bundles/test_references.py b/tests/specify_cli/bundles/test_references.py index 2580604cd1..eaa418ddae 100644 --- a/tests/specify_cli/bundles/test_references.py +++ b/tests/specify_cli/bundles/test_references.py @@ -116,13 +116,35 @@ def test_unknown_reference_warns_offline(tmp_path: Path): assert any("does-not-exist" in w for w in warnings) -def test_wrong_bundled_pin_does_not_resolve_locally(tmp_path): +@pytest.mark.parametrize("allow_network", [False, True]) +def test_wrong_bundled_extension_pin_is_definitive( + tmp_path, monkeypatch, allow_network, +): + from specify_cli.extensions import ExtensionCatalog + root = make_project(tmp_path) + monkeypatch.setattr( + ExtensionCatalog, "get_extension_info", + lambda self, _id, version=None: { + "version": version or "999.0.0", + "_catalog_name": "trusted", + "_install_allowed": True, + }, + ) warnings = [] - check = make_reference_checker(root, allow_network=False, warnings=warnings) + check = make_reference_checker(root, allow_network=allow_network, warnings=warnings) - assert check(_ref("extensions", "agent-context", "999.0.0")) is None - assert any("agent-context" in message for message in warnings) + problem = check(_ref("extensions", "agent-context", "999.0.0")) + assert problem is not None and "resolved version is" in problem + assert warnings == [] + if allow_network: + assert check(ComponentRef( + kind="extensions", id="agent-context", version="999.0.0", source="trusted" + )) is None + assert warnings == [] + assert check( + _ref("extensions", "agent-context", bundled_extension_version("agent-context")) + ) is None @pytest.mark.parametrize("allow_network", [False, True]) @@ -709,8 +731,10 @@ def unsafe_redirect(*args, **kwargs): @pytest.mark.parametrize("kind", ["workflows", "steps"]) +@pytest.mark.parametrize("payload", [b"[]", b"{}", b'{"schema_version":"1.0"}']) +@pytest.mark.parametrize("cached", [False, True]) def test_online_validation_does_not_skip_malformed_higher_priority_catalog( - tmp_path, monkeypatch, kind, + tmp_path, monkeypatch, kind, payload, cached, ): import io import json @@ -733,6 +757,15 @@ def test_online_validation_does_not_skip_malformed_higher_priority_catalog( entry("https://example.com/low.json", "low", 2, True), ] monkeypatch.setattr(catalog, "get_active_catalogs", lambda self: sources) + if cached: + catalog_instance = catalog(tmp_path) + cache_file, _ = catalog_instance._get_cache_paths(sources[0].url) + cache_file.parent.mkdir(parents=True, exist_ok=True) + cache_file.write_bytes(payload) + monkeypatch.setattr( + catalog, "_is_url_cache_valid", + lambda self, url: url == sources[0].url, + ) class Response(io.BytesIO): def __init__(self, url, payload): @@ -743,13 +776,13 @@ def geturl(self): return self.url def open_url(url, **kwargs): - payload = ( - b"[]" if url.endswith("high.json") + response_payload = ( + payload if url.endswith("high.json") else json.dumps({ kind: {"requested": {"version": "1.0.0"}}, }).encode() ) - return Response(url, payload) + return Response(url, response_payload) monkeypatch.setattr(http, "open_url", open_url) warnings = [] @@ -759,6 +792,55 @@ def open_url(url, **kwargs): assert warnings == [] +@pytest.mark.parametrize("kind", ["workflows", "steps"]) +@pytest.mark.parametrize("empty", [{}, []]) +def test_valid_empty_higher_priority_catalog_allows_lower_source( + tmp_path, monkeypatch, kind, empty, +): + import io + import json + + from specify_cli.authentication import http + from specify_cli.workflows.catalog import ( + StepCatalog, + StepCatalogEntry, + WorkflowCatalog, + WorkflowCatalogEntry, + ) + + catalog, entry = ( + (WorkflowCatalog, WorkflowCatalogEntry) + if kind == "workflows" + else (StepCatalog, StepCatalogEntry) + ) + sources = [ + entry("https://example.com/high.json", "high", 1, True), + entry("https://example.com/low.json", "low", 2, True), + ] + monkeypatch.setattr(catalog, "get_active_catalogs", lambda self: sources) + + class Response(io.BytesIO): + def __init__(self, url, payload): + super().__init__(payload) + self.url = url + + def geturl(self): + return self.url + + def open_url(url, **kwargs): + entries = empty if url.endswith("high.json") else { + "requested": {"version": "1.0.0"} + } + return Response(url, json.dumps({kind: entries}).encode()) + + monkeypatch.setattr(http, "open_url", open_url) + warnings = [] + check = make_reference_checker(tmp_path, allow_network=True, warnings=warnings) + + assert check(ComponentRef(kind=kind, id="requested")) is None + assert warnings == [] + + @pytest.mark.parametrize("kind", ["workflows", "steps"]) @pytest.mark.parametrize("source", ["network", "fresh-cache", "stale-cache"]) def test_deeply_nested_catalog_is_handled_as_malformed_data( @@ -792,7 +874,10 @@ def test_deeply_nested_catalog_is_handled_as_malformed_data( }[kind] catalog = catalog_type(tmp_path) entry = entry_type("https://example.com/catalog.json", "trusted", 1, True) - nested = b'{"nested":' + b"[" * 10000 + b"0" + b"]" * 10000 + b"}" + nested = ( + b'{"' + kind.encode() + b'":{},"nested":' + + b"[" * 10000 + b"0" + b"]" * 10000 + b"}" + ) valid = {"schema_version": "1.0", kind: {"requested": {"version": "1.0.0"}}} class Response(io.BytesIO): @@ -862,7 +947,10 @@ def test_deep_catalog_does_not_escape_during_cache_write( }[kind] catalog = catalog_type(tmp_path) entry = entry_type("https://example.com/catalog.json", "trusted", 1, True) - payload = b'{"nested":' + b"[" * 1200 + b"0" + b"]" * 1200 + b"}" + payload = ( + b'{"' + kind.encode() + b'":{},"nested":' + + b"[" * 1200 + b"0" + b"]" * 1200 + b"}" + ) class Response(io.BytesIO): def geturl(self): From 95c4929b8bd72fe728fec7ac29c38f5f2996b4a9 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:37:31 -0500 Subject: [PATCH 08/23] Reject duplicate IDs in list-form workflow catalogs Treat duplicate workflow IDs within one list-form catalog as invalid during exact-ID lookup and search, while retaining priority-based resolution across distinct sources. Assisted-by: GitHub Copilot (model: GPT-6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/reference/workflows.md | 2 +- src/specify_cli/workflows/catalog/_domain.py | 6 ++ .../workflows/test_catalog_versions.py | 57 +++++++++++++++++++ 3 files changed, 64 insertions(+), 1 deletion(-) diff --git a/docs/reference/workflows.md b/docs/reference/workflows.md index 9e524c1c85..c3e1420c9c 100644 --- a/docs/reference/workflows.md +++ b/docs/reference/workflows.md @@ -421,7 +421,7 @@ installed locally. ## Catalog Management -Workflow catalogs control where `search` and `add` look for workflows. Catalogs are checked in priority order. +Workflow catalogs control where `search` and `add` look for workflows. Catalogs are checked in priority order. List-form catalogs must contain unique workflow IDs within each source; duplicate IDs are rejected rather than selecting an ambiguous release. The same ID may appear in different catalogs, with the highest-priority source winning. > **A project's `.specify/workflow-catalogs.yml` can point `add` and `search` at a catalog you didn't choose.** Before running a workflow from an unfamiliar project, run `specify workflow catalog list` (and `specify workflow step catalog list` for the step catalogs its steps can pull in) — a project supplying that config is not evidence its workflows or steps were vetted. Maintainers do not audit `run` fields; read a workflow's shell steps yourself before running it (see [Who maintains workflows?](#who-maintains-workflows)). diff --git a/src/specify_cli/workflows/catalog/_domain.py b/src/specify_cli/workflows/catalog/_domain.py index 4fa9f183d0..327ad89207 100644 --- a/src/specify_cli/workflows/catalog/_domain.py +++ b/src/specify_cli/workflows/catalog/_domain.py @@ -701,11 +701,17 @@ def _get_merged_workflows( wf_data["_install_allowed"] = entry.install_allowed merged[wf_id] = wf_data elif isinstance(workflows, list): + seen_in_source: set[str] = set() for wf_data in workflows: if not isinstance(wf_data, dict): continue wf_id = wf_data.get("id", "") if wf_id: + if wf_id in seen_in_source: + raise WorkflowCatalogValidationError( + f"Duplicate workflow ID '{wf_id}' in catalog '{entry.name}'." + ) + seen_in_source.add(wf_id) if workflow_id is not None and wf_id != workflow_id: continue wf_data["_catalog_name"] = entry.name diff --git a/tests/specify_cli/workflows/test_catalog_versions.py b/tests/specify_cli/workflows/test_catalog_versions.py index 2eafe38594..626bdc406e 100644 --- a/tests/specify_cli/workflows/test_catalog_versions.py +++ b/tests/specify_cli/workflows/test_catalog_versions.py @@ -15,6 +15,7 @@ from specify_cli.workflows.catalog import ( WorkflowCatalog, WorkflowCatalogEntry, + WorkflowCatalogValidationError, WorkflowRegistry, WorkflowValidationError, ) @@ -143,6 +144,62 @@ def test_legacy_entry_and_winning_source(monkeypatch, project_dir): assert catalog.search(query="history-wf")[0]["version"] == "2.0.0" +@pytest.mark.parametrize("duplicate_id", ["history-wf", "other"]) +@pytest.mark.parametrize("lookup", ["exact", "search"]) +def test_duplicate_list_workflow_ids_are_rejected( + monkeypatch, project_dir, duplicate_id, lookup, +): + catalog = WorkflowCatalog(project_dir) + sources = [ + WorkflowCatalogEntry("https://example.com/high.json", "high", 1, True), + WorkflowCatalogEntry("https://example.com/low.json", "low", 2, True), + ] + monkeypatch.setattr(catalog, "get_active_catalogs", lambda: sources) + fetched = [] + + def fetch(source, force_refresh=False): + fetched.append(source.name) + if source.name == "high": + return {"workflows": [ + {"id": "history-wf", "version": "1.0.0"}, + {"id": duplicate_id, "version": "2.0.0"}, + {"id": duplicate_id, "version": "3.0.0"}, + ]} + return {"workflows": [{"id": "history-wf", "version": "4.0.0"}]} + + monkeypatch.setattr(catalog, "_fetch_single_catalog", fetch) + with pytest.raises( + WorkflowCatalogValidationError, match=f"Duplicate workflow ID '{duplicate_id}'" + ): + if lookup == "exact": + catalog.get_workflow_info("history-wf") + else: + catalog.search() + if lookup == "exact": + assert fetched == ["high"] + + +def test_list_workflow_id_can_appear_in_distinct_catalogs(monkeypatch, project_dir): + catalog = WorkflowCatalog(project_dir) + sources = [ + WorkflowCatalogEntry("https://example.com/high.json", "high", 1, True), + WorkflowCatalogEntry("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_refresh=False: { + "workflows": [{ + "id": "history-wf", + "version": "1.0.0" if source.name == "high" else "2.0.0", + }] + }, + ) + + assert catalog.get_workflow_info("history-wf")["version"] == "1.0.0" + assert catalog.search(query="history-wf")[0]["version"] == "1.0.0" + + @pytest.mark.parametrize( "history", [ From dddb14ec1aafbbcec5af3a53b7f2764476b21811 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Tue, 6 Oct 2026 15:27:01 -0500 Subject: [PATCH 09/23] Report partial catalog outages during targeted lookup Preserve uncertainty when an exact bundle reference is absent from readable catalogs but another configured catalog could not be fetched. Retain winning-source and untargeted search behavior; cover all four catalog families and CLI validation. Assisted-by: GitHub Copilot (model: GPT-6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/reference/bundles.md | 2 +- src/specify_cli/extensions/__init__.py | 5 ++ src/specify_cli/presets/_catalog.py | 5 ++ src/specify_cli/workflows/catalog/_domain.py | 5 ++ .../workflows/step/catalog/_domain.py | 5 ++ .../bundles/test_command_validate.py | 33 +++++++++ tests/specify_cli/bundles/test_references.py | 74 +++++++++++++++++++ .../presets/test_catalog_versions.py | 15 ++-- 8 files changed, 138 insertions(+), 6 deletions(-) diff --git a/docs/reference/bundles.md b/docs/reference/bundles.md index 71c57d3f65..421449c2a6 100644 --- a/docs/reference/bundles.md +++ b/docs/reference/bundles.md @@ -151,7 +151,7 @@ specify bundle validate | `--path` | Bundle directory or `bundle.yml` (default: current directory) | | `--offline` | Verify references against bundled/installed components only | -Reports whether a `bundle.yml` is well-formed and whether every declared component reference resolves at its pinned version. References are checked against matching bundled or installed components and — when online — the exact release in the winning install-allowed catalog. An explicit `source` is verified against the winning catalog instead of resolving locally. Missing releases, mismatched sources, discovery-only sources, and malformed catalog metadata fail validation; references that cannot be checked offline or because a catalog is unreachable produce warnings. +Reports whether a `bundle.yml` is well-formed and whether every declared component reference resolves at its pinned version. References are checked against matching bundled or installed components and — when online — the exact release in the winning install-allowed catalog. An explicit `source` is verified against the winning catalog instead of resolving locally. Missing releases, mismatched sources, discovery-only sources, and malformed catalog metadata fail validation; references that cannot be checked offline or because a catalog is unreachable produce warnings. A reference is not reported missing when a readable catalog lacks it but another configured catalog is unreachable; the lookup remains unverified. ## Build a Bundle Artifact diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index da5dddfc00..70dded5f06 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -4853,6 +4853,7 @@ def _get_merged_extensions( merged: Dict[str, Dict[str, Any]] = {} any_success = False validation_error: ExtensionCatalogValidationError | None = None + fetch_error: ExtensionCatalogFetchError | None = None for catalog_entry in active_catalogs: try: @@ -4868,6 +4869,8 @@ def _get_merged_extensions( e, ExtensionCatalogValidationError ): raise + if isinstance(e, ExtensionCatalogFetchError) and fetch_error is None: + fetch_error = e print( f"Warning: Could not fetch catalog '{catalog_entry.name}': {e}", file=sys.stderr, @@ -4906,6 +4909,8 @@ def _get_merged_extensions( if validation_error is not None: raise validation_error raise ExtensionCatalogFetchError("Failed to fetch any extension catalog") + if extension_id is not None and fetch_error is not None: + raise fetch_error return list(merged.values()) diff --git a/src/specify_cli/presets/_catalog.py b/src/specify_cli/presets/_catalog.py index 0e2b86244f..1729814f94 100644 --- a/src/specify_cli/presets/_catalog.py +++ b/src/specify_cli/presets/_catalog.py @@ -575,6 +575,7 @@ def _get_merged_packs( active_catalogs = self.get_active_catalogs() merged: Dict[str, Dict[str, Any]] = {} first_fetch_error: PresetError | None = None + catalog_fetch_error: PresetCatalogFetchError | None = None readable_source = False sources = active_catalogs if pack_id is not None else reversed(active_catalogs) @@ -604,10 +605,14 @@ def _get_merged_packs( except PresetError as exc: if first_fetch_error is None: first_fetch_error = exc + if isinstance(exc, PresetCatalogFetchError) and catalog_fetch_error is None: + catalog_fetch_error = exc continue if not readable_source and first_fetch_error is not None: raise first_fetch_error + if pack_id is not None and catalog_fetch_error is not None: + raise catalog_fetch_error return merged def is_cache_valid(self) -> bool: diff --git a/src/specify_cli/workflows/catalog/_domain.py b/src/specify_cli/workflows/catalog/_domain.py index 327ad89207..87c885a7d8 100644 --- a/src/specify_cli/workflows/catalog/_domain.py +++ b/src/specify_cli/workflows/catalog/_domain.py @@ -665,6 +665,7 @@ def _get_merged_workflows( merged: dict[str, dict[str, Any]] = {} fetch_errors = 0 validation_error: WorkflowCatalogValidationError | None = None + fetch_error: WorkflowCatalogFetchError | None = None # Search uses overwrite order; exact-ID lookup visits the highest # priority source first and stops at its matching entry. @@ -682,6 +683,8 @@ def _get_merged_workflows( and validation_error is None ): validation_error = exc + if isinstance(exc, WorkflowCatalogFetchError) and fetch_error is None: + fetch_error = exc fetch_errors += 1 continue workflows = data.get("workflows", {}) @@ -725,6 +728,8 @@ def _get_merged_workflows( raise WorkflowCatalogFetchError( "All configured catalogs failed to fetch." ) + if workflow_id is not None and fetch_error is not None: + raise fetch_error return merged # -- Public API ------------------------------------------------------- diff --git a/src/specify_cli/workflows/step/catalog/_domain.py b/src/specify_cli/workflows/step/catalog/_domain.py index 13370f64a1..a1c4503cb3 100644 --- a/src/specify_cli/workflows/step/catalog/_domain.py +++ b/src/specify_cli/workflows/step/catalog/_domain.py @@ -633,6 +633,7 @@ def _get_merged_steps( merged: dict[str, dict[str, Any]] = {} fetch_errors = 0 validation_error: StepCatalogValidationError | None = None + fetch_error: StepCatalogFetchError | None = None target_id = step_id sources = catalogs if target_id is not None else reversed(catalogs) @@ -651,6 +652,8 @@ def _get_merged_steps( and validation_error is None ): validation_error = exc + if isinstance(exc, StepCatalogFetchError) and fetch_error is None: + fetch_error = exc fetch_errors += 1 continue steps = data.get("steps", {}) @@ -695,6 +698,8 @@ def _get_merged_steps( if validation_error is not None: raise validation_error raise StepCatalogFetchError("All configured step catalogs failed to fetch.") + if target_id is not None and fetch_error is not None: + raise fetch_error return merged # -- Public API ------------------------------------------------------- diff --git a/tests/specify_cli/bundles/test_command_validate.py b/tests/specify_cli/bundles/test_command_validate.py index be096b7003..b458dbbfac 100644 --- a/tests/specify_cli/bundles/test_command_validate.py +++ b/tests/specify_cli/bundles/test_command_validate.py @@ -97,6 +97,39 @@ def test_validate_rejects_broken_reference(project: Path): assert "preset-a" in result.output or "ext-a" in result.output +def test_validate_warns_instead_of_rejecting_reference_during_partial_outage( + project: Path, monkeypatch, +): + from specify_cli.workflows.catalog import ( + StepCatalog, + StepCatalogEntry, + StepCatalogFetchError, + ) + + data = valid_manifest_dict( + provides={"steps": [{"id": "requested", "version": "1.0.0"}]} + ) + (project / "bundle.yml").write_text(yaml.safe_dump(data), encoding="utf-8") + sources = [ + StepCatalogEntry("https://example.com/high.json", "high", 1, True), + StepCatalogEntry("https://example.com/low.json", "low", 2, True), + ] + monkeypatch.setattr(StepCatalog, "get_active_catalogs", lambda self: sources) + + def fetch(self, entry, force_refresh=False): + if entry.name == "low": + raise StepCatalogFetchError("catalog timed out") + return {"steps": {"other-step": {"version": "1.0.0"}}} + + monkeypatch.setattr(StepCatalog, "_fetch_single_catalog", fetch) + + result = runner.invoke(app, ["bundle", "validate"]) + + assert result.exit_code == 0, result.output + assert "unreachable" in result.output + assert "not available" not in result.output + + def test_validate_accepts_bundled_reference(project: Path): data = valid_manifest_dict() data["provides"] = {"extensions": [{ diff --git a/tests/specify_cli/bundles/test_references.py b/tests/specify_cli/bundles/test_references.py index eaa418ddae..51377e71e9 100644 --- a/tests/specify_cli/bundles/test_references.py +++ b/tests/specify_cli/bundles/test_references.py @@ -343,6 +343,80 @@ def unavailable(_self, _id, version=None): assert "unreachable" in warnings[0] +@pytest.mark.parametrize("kind", ["extensions", "presets", "workflows", "steps"]) +@pytest.mark.parametrize( + ("unreachable", "has_match"), + [ + ("high", False), + ("low", False), + ("high", True), + ("low", True), + (None, False), + ], +) +def test_online_validation_distinguishes_partial_outage_from_missing_reference( + tmp_path, monkeypatch, kind, unreachable, has_match, +): + from specify_cli.extensions import ( + CatalogEntry, + ExtensionCatalog, + ExtensionCatalogFetchError, + ) + from specify_cli.presets import PresetCatalog, PresetCatalogEntry + from specify_cli.presets._catalog import PresetCatalogFetchError + from specify_cli.workflows.catalog import ( + StepCatalog, + StepCatalogEntry, + StepCatalogFetchError, + WorkflowCatalog, + WorkflowCatalogEntry, + WorkflowCatalogFetchError, + ) + + catalog, entry_type, fetch_error = { + "extensions": (ExtensionCatalog, CatalogEntry, ExtensionCatalogFetchError), + "presets": (PresetCatalog, PresetCatalogEntry, PresetCatalogFetchError), + "workflows": (WorkflowCatalog, WorkflowCatalogEntry, WorkflowCatalogFetchError), + "steps": (StepCatalog, StepCatalogEntry, StepCatalogFetchError), + }[kind] + sources = [ + entry_type("https://example.com/high.json", "high", 1, True), + entry_type("https://example.com/low.json", "low", 2, True), + ] + monkeypatch.setattr(catalog, "get_active_catalogs", lambda self: sources) + visited = [] + + def fetch(self, entry, force_refresh=False): + visited.append(entry.name) + if entry.name == unreachable: + raise fetch_error("catalog timed out") + contents = {"requested": {"version": "1.0.0"}} if has_match else {} + return {kind: contents} + + monkeypatch.setattr(catalog, "_fetch_single_catalog", fetch) + warnings = [] + check = make_reference_checker(tmp_path, allow_network=True, warnings=warnings) + + problem = check(_ref(kind, "requested")) + if has_match: + assert problem is None + assert warnings == [] + assert visited == ( + ["high", "high"] + if unreachable == "low" + else ["high", "low", "high", "low"] + ) + elif unreachable is not None: + assert problem is None + assert len(warnings) == 1 + assert "unreachable" in warnings[0] + assert visited == ["high", "low"] + else: + assert problem is not None and "not available" in problem + assert warnings == [] + assert visited == ["high", "low"] + + @pytest.mark.parametrize("kind", ["extensions", "presets"]) def test_online_validation_warns_for_unreachable_component_catalog( tmp_path, monkeypatch, kind, diff --git a/tests/specify_cli/presets/test_catalog_versions.py b/tests/specify_cli/presets/test_catalog_versions.py index 4080de89a9..405f06961f 100644 --- a/tests/specify_cli/presets/test_catalog_versions.py +++ b/tests/specify_cli/presets/test_catalog_versions.py @@ -657,7 +657,9 @@ def fetch(source, _refresh): assert "No catalog versions found" not in result.output -def test_versions_report_missing_preset_when_catalog_is_readable(project_dir): +def test_versions_report_partial_outage_instead_of_missing_preset(project_dir): + from specify_cli.presets._catalog import PresetCatalogFetchError + sources = [ PresetCatalogEntry("https://example.com/high.json", "high", 1, True), PresetCatalogEntry("https://example.com/low.json", "low", 2, True), @@ -665,7 +667,9 @@ def test_versions_report_missing_preset_when_catalog_is_readable(project_dir): def fetch(source, _refresh): if source.name == "high": - raise PresetError(f"Failed to fetch preset catalog from {source.url}: offline") + raise PresetCatalogFetchError( + f"Failed to fetch preset catalog from {source.url}: offline" + ) return {"presets": {"another-preset": _entry()}} with ( @@ -673,12 +677,13 @@ def fetch(source, _refresh): patch.object(PresetCatalog, "_fetch_single_catalog", side_effect=fetch), patch.object(Path, "cwd", return_value=project_dir), ): - assert PresetCatalog(project_dir).get_pack_info("sample") is None + with pytest.raises(PresetCatalogFetchError, match="high.json: offline"): + PresetCatalog(project_dir).get_pack_info("sample") result = CliRunner().invoke(app, ["preset", "info", "sample", "--versions"]) assert result.exit_code == 1, result.output - assert "No catalog versions found for sample" in result.output - assert "offline" not in result.output + assert "high.json:" in result.output and "offline" in result.output + assert "No catalog versions found" not in result.output def test_discovery_only_winner_does_not_delegate_exact_release(project_dir): From 40f2c346e166c387ab9a059ee1efcc8e7b2774ca Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Tue, 6 Oct 2026 16:41:46 -0500 Subject: [PATCH 10/23] Persist all installed bundle requirements for conflict checks Keep independently installed component pins separate from bundle-owned contributions. Reject incompatible later installs, retain requirements on remove, and preserve legacy records' known pins. Assisted-by: GitHub Copilot (model: GPT-6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/reference/bundles.md | 2 + src/specify_cli/bundles/conflict.py | 2 +- src/specify_cli/bundles/installer.py | 1 + src/specify_cli/bundles/records.py | 44 ++++++++--- tests/specify_cli/bundles/test_installer.py | 48 ++++++++++++ tests/specify_cli/bundles/test_records.py | 84 +++++++++++++++++++++ 6 files changed, 170 insertions(+), 11 deletions(-) diff --git a/docs/reference/bundles.md b/docs/reference/bundles.md index 421449c2a6..88bd87a74c 100644 --- a/docs/reference/bundles.md +++ b/docs/reference/bundles.md @@ -71,6 +71,8 @@ Installs a bundle's full component set through each primitive's machinery. The a If the current directory is not yet a Spec Kit project, `install` initializes one first so a fresh checkout reaches a working state in a single command. `--integration` selects the integration when initializing a new project, and confirms the target when a bundle pins a specific integration but the project's active integration can't be determined (missing or unreadable `.specify/integration.json`). It does **not** override an already-initialized project's active integration: if a bundle targets a different integration than the project's, install aborts with no changes. Integration-agnostic bundles inherit the project's active integration. Without `--refresh`, installation is idempotent — already-installed components matching their pins are skipped. A missing or different installed version cannot satisfy a pin. Only `--refresh` can repair a drifted version owned exclusively by this bundle; independently installed components are skipped and never adopted or replaced. On failure, no provenance record is written (a failed install records nothing), and the components installed during that run are removed on a best-effort basis — removal errors are swallowed, so partial on-disk state may remain. +Installed-bundle records store required component pins separately from contributed components. A compatible independently installed component satisfies a bundle's requirement but remains independently owned: its pin still blocks a later bundle from installing an incompatible version, while removing the bundle does not uninstall it. Older records without the requirement list retain their known contributed pins; reinstall or refresh the bundle to record its full requirements. + A normal install rejects a change to an already-recorded bundle's version or owned component metadata (version, source, preset priority, or strategy), including removal of an owned component. This applies even if a local manifest keeps the same bundle version. Reordering unchanged components or adding new components does not require refresh. To apply changes to a local bundle without adding it to a catalog, pass the revised source with `--refresh`: ```bash diff --git a/src/specify_cli/bundles/conflict.py b/src/specify_cli/bundles/conflict.py index a471b13cf0..f9ae3fe4ef 100644 --- a/src/specify_cli/bundles/conflict.py +++ b/src/specify_cli/bundles/conflict.py @@ -38,7 +38,7 @@ def detect_conflicts( already: dict[tuple[str, str], list[tuple[str, str | None]]] = {} for record in installed: - for component in record.contributed_components: + for component in record.required_components: already.setdefault((component.kind, component.id), []).append( (record.bundle_id, component.version) ) diff --git a/src/specify_cli/bundles/installer.py b/src/specify_cli/bundles/installer.py index 3e213c0cbd..1613f92af1 100644 --- a/src/specify_cli/bundles/installer.py +++ b/src/specify_cli/bundles/installer.py @@ -198,6 +198,7 @@ def install_bundle( bundle_id=plan.bundle_id, version=plan.version, components=contributed, + required_components=plan.components, # Preserve the original install time across refresh/update so # ``bundle list`` keeps reporting when the bundle was first installed. installed_at=existing.installed_at if existing is not None else None, diff --git a/src/specify_cli/bundles/records.py b/src/specify_cli/bundles/records.py index 1b0628833e..67ebb6a3d4 100644 --- a/src/specify_cli/bundles/records.py +++ b/src/specify_cli/bundles/records.py @@ -1,8 +1,9 @@ -"""Installed-bundle records — provenance for precise list/remove/update. +"""Installed-bundle records — requirements and ownership for list/remove/update. Records are stored as JSON at ``.specify/bundle-records.json``. Each record -captures exactly which components a bundle contributed so removal touches only -that bundle's components and never collateral (FR-022, SC-004). +stores every required component for conflict detection and separately records +which components the bundle contributed, so removal never touches independently +installed components (FR-022, SC-004). """ from __future__ import annotations @@ -24,6 +25,7 @@ class InstalledBundleRecord: bundle_id: str version: str contributed_components: tuple[ComponentRef, ...] + required_components: tuple[ComponentRef, ...] installed_at: str @classmethod @@ -33,11 +35,15 @@ def create( version: str, components: list[ComponentRef], installed_at: str | None = None, + required_components: list[ComponentRef] | None = None, ) -> "InstalledBundleRecord": return cls( bundle_id=bundle_id, version=version, contributed_components=tuple(components), + required_components=tuple( + components if required_components is None else required_components + ), installed_at=installed_at or _utc_now(), ) @@ -49,6 +55,9 @@ def to_dict(self) -> dict[str, Any]: "contributed_components": [ _component_to_dict(c) for c in self.contributed_components ], + "required_components": [ + _component_to_dict(c) for c in self.required_components + ], } @classmethod @@ -65,6 +74,13 @@ def from_dict(cls, data: Any) -> "InstalledBundleRecord": raise BundlerError( "Corrupt record: 'contributed_components' must be a list." ) + # Older records contain only contributed components. Retain those + # known pins until a successful reinstall writes the full requirements. + required_raw = data.get("required_components", components_raw) + if not isinstance(required_raw, list): + raise BundlerError( + "Corrupt record: 'required_components' must be a list." + ) # ``.get(key, "")`` defaults only a *missing* key. A key that is # present but null -- how a hand-edited or corrupt record spells an # empty field -- yields ``None``, and ``str(None)`` is the non-empty @@ -83,13 +99,21 @@ def from_dict(cls, data: Any) -> "InstalledBundleRecord": f"Corrupt records file: record for bundle '{bundle_id}' is " "missing its 'version'." ) + contributed = tuple(_component_from_dict(c) for c in components_raw) + required = tuple( + _component_from_dict(c, label="required") for c in required_raw + ) + if not set(contributed).issubset(required): + raise BundlerError( + "Corrupt record: 'required_components' must include all " + "'contributed_components'." + ) return cls( bundle_id=bundle_id, version=version, installed_at=_text(data.get("installed_at")), - contributed_components=tuple( - _component_from_dict(c) for c in components_raw - ), + contributed_components=contributed, + required_components=required, ) @@ -186,7 +210,7 @@ def components_still_needed( for record in records: if record.bundle_id == exclude_bundle_id: continue - for component in record.contributed_components: + for component in record.required_components: needed.add((component.kind, component.id)) return needed @@ -204,9 +228,9 @@ def _component_to_dict(ref: ComponentRef) -> dict[str, Any]: return data -def _component_from_dict(data: Any) -> ComponentRef: +def _component_from_dict(data: Any, *, label: str = "contributed") -> ComponentRef: if not isinstance(data, dict): - raise BundlerError("Each contributed component must be a mapping.") + raise BundlerError(f"Each {label} component must be a mapping.") kind = _text(data.get("kind")) cid = _text(data.get("id")) if kind not in COMPONENT_KINDS: @@ -216,7 +240,7 @@ def _component_from_dict(data: Any) -> ComponentRef: ) if not cid: raise BundlerError( - "Corrupt records file: a contributed component is missing its 'id'." + f"Corrupt records file: a {label} component is missing its 'id'." ) return ComponentRef( kind=kind, diff --git a/tests/specify_cli/bundles/test_installer.py b/tests/specify_cli/bundles/test_installer.py index d420411db6..9fbc0c07a6 100644 --- a/tests/specify_cli/bundles/test_installer.py +++ b/tests/specify_cli/bundles/test_installer.py @@ -69,6 +69,54 @@ def test_second_bundle_cannot_claim_different_pin_before_mutation(tmp_path: Path assert installer.install_calls == [("extensions", "ext-a")] +def test_independent_requirement_blocks_conflicting_bundle_after_removal( + tmp_path: Path, +): + make_project(tmp_path) + installer = FakeInstaller() + key = ("extensions", "ext-a") + installer.installed.add(key) + installer.versions[key] = "1.0.0" + first = _bundle("first", ["ext-a"], version="1.0.0") + install_bundle(tmp_path, _plan(first), installer, manifest=first) + + record = load_records(tmp_path)[0] + assert record.contributed_components == () + assert record.required_components == tuple(first.components) + + installer.installed.remove(key) + installer.versions.pop(key) + second = _bundle("second", ["ext-a"], version="2.0.0") + with pytest.raises(BundlerError, match="bundle 'first' already requires version 1.0.0"): + install_bundle(tmp_path, _plan(second), installer, manifest=second) + + assert installer.install_calls == [] + assert [r.bundle_id for r in load_records(tmp_path)] == ["first"] + + +def test_removal_preserves_component_required_but_not_owned_by_other_bundle( + tmp_path: Path, +): + make_project(tmp_path) + installer = FakeInstaller() + key = ("extensions", "ext-a") + installer.installed.add(key) + installer.versions[key] = "1.0.0" + first = _bundle("first", ["ext-a"]) + install_bundle(tmp_path, _plan(first), installer, manifest=first) + + installer.installed.remove(key) + installer.versions.pop(key) + second = _bundle("second", ["ext-a"]) + install_bundle(tmp_path, _plan(second), installer, manifest=second) + + assert load_records(tmp_path)[1].contributed_components == tuple(second.components) + result = remove_bundle(tmp_path, "second", installer) + assert key in {(c.kind, c.id) for c in result.skipped} + assert key in installer.installed + assert installer.remove_calls == [] + + def test_owned_component_drift_is_rejected_without_refresh(tmp_path: Path): make_project(tmp_path) installer = FakeInstaller() diff --git a/tests/specify_cli/bundles/test_records.py b/tests/specify_cli/bundles/test_records.py index 458b0d01f0..f8caa2c5e2 100644 --- a/tests/specify_cli/bundles/test_records.py +++ b/tests/specify_cli/bundles/test_records.py @@ -38,6 +38,90 @@ def test_save_and_load_roundtrip(tmp_path: Path): ("presets", "p1"), ("steps", "s1"), } + assert loaded[0].required_components == loaded[0].contributed_components + + +def test_roundtrip_retains_unowned_required_pin(tmp_path: Path): + (tmp_path / ".specify").mkdir() + required = ComponentRef( + kind="extensions", id="independent", version="1.0.0", source="trusted" + ) + record = InstalledBundleRecord.create( + bundle_id="a", version="1.0.0", components=[], required_components=[required] + ) + + save_records(tmp_path, [record]) + restored = load_records(tmp_path)[0] + + assert restored.required_components == (required,) + assert restored.contributed_components == () + serialized = json.loads(records_path(tmp_path).read_text()) + assert serialized["bundles"][0]["required_components"] == [{ + "kind": "extensions", + "id": "independent", + "version": "1.0.0", + "source": "trusted", + }] + + +def test_legacy_record_uses_contributions_as_known_requirements(): + record = InstalledBundleRecord.create( + bundle_id="legacy", + version="1.0.0", + components=[ComponentRef(kind="extensions", id="ext-a", version="1.0.0")], + ) + data = record.to_dict() + data.pop("required_components", None) + + restored = InstalledBundleRecord.from_dict(data) + + assert restored.required_components == record.contributed_components + + +@pytest.mark.parametrize("bad", [None, 0, False, "", {}]) +def test_from_dict_rejects_invalid_required_components(bad): + data = { + "bundle_id": "a", + "version": "1.0.0", + "contributed_components": [], + "required_components": bad, + } + with pytest.raises(BundlerError, match="'required_components' must be a list"): + InstalledBundleRecord.from_dict(data) + + +@pytest.mark.parametrize( + ("component", "error"), + [ + ({"kind": "bogus", "id": "ext-a"}, "kind' must be one of"), + ({"kind": "extensions", "id": ""}, "required component is missing its 'id'"), + ], +) +def test_from_dict_rejects_corrupt_required_component(component, error): + data = { + "bundle_id": "a", + "version": "1.0.0", + "contributed_components": [], + "required_components": [component], + } + with pytest.raises(BundlerError, match=error): + InstalledBundleRecord.from_dict(data) + + +@pytest.mark.parametrize( + "required", [[], [{"kind": "extensions", "id": "ext-a", "version": "2.0.0"}]] +) +def test_from_dict_rejects_requirements_missing_contributed_pin(required): + data = { + "bundle_id": "a", + "version": "1.0.0", + "contributed_components": [ + {"kind": "extensions", "id": "ext-a", "version": "1.0.0"} + ], + "required_components": required, + } + with pytest.raises(BundlerError, match="must include all"): + InstalledBundleRecord.from_dict(data) def test_load_missing_file_returns_empty(tmp_path: Path): From f7a61b44f918361dd73423f337ad2269f736cfc4 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Tue, 6 Oct 2026 16:50:59 -0500 Subject: [PATCH 11/23] Protect other bundles' pins during unpinned installs Preserve ownership attribution while consulting all recorded pinned requirements in preflight. Reject unpinned installs or refreshes that may replace a required version and allow no-op sharing of a matching installation. Assisted-by: GitHub Copilot (model: GPT-6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/reference/bundles.md | 2 +- src/specify_cli/bundles/installer.py | 33 ++++++++- tests/specify_cli/bundles/test_installer.py | 82 +++++++++++++++++++++ 3 files changed, 114 insertions(+), 3 deletions(-) diff --git a/docs/reference/bundles.md b/docs/reference/bundles.md index 88bd87a74c..d54b893fad 100644 --- a/docs/reference/bundles.md +++ b/docs/reference/bundles.md @@ -71,7 +71,7 @@ Installs a bundle's full component set through each primitive's machinery. The a If the current directory is not yet a Spec Kit project, `install` initializes one first so a fresh checkout reaches a working state in a single command. `--integration` selects the integration when initializing a new project, and confirms the target when a bundle pins a specific integration but the project's active integration can't be determined (missing or unreadable `.specify/integration.json`). It does **not** override an already-initialized project's active integration: if a bundle targets a different integration than the project's, install aborts with no changes. Integration-agnostic bundles inherit the project's active integration. Without `--refresh`, installation is idempotent — already-installed components matching their pins are skipped. A missing or different installed version cannot satisfy a pin. Only `--refresh` can repair a drifted version owned exclusively by this bundle; independently installed components are skipped and never adopted or replaced. On failure, no provenance record is written (a failed install records nothing), and the components installed during that run are removed on a best-effort basis — removal errors are swallowed, so partial on-disk state may remain. -Installed-bundle records store required component pins separately from contributed components. A compatible independently installed component satisfies a bundle's requirement but remains independently owned: its pin still blocks a later bundle from installing an incompatible version, while removing the bundle does not uninstall it. Older records without the requirement list retain their known contributed pins; reinstall or refresh the bundle to record its full requirements. +Installed-bundle records store required component pins separately from contributed components. A compatible independently installed component satisfies a bundle's requirement but remains independently owned: its pin still blocks a later bundle from installing an incompatible version, while removing the bundle does not uninstall it. An unpinned install or refresh cannot replace a component another bundle requires at a pinned version; sharing an already installed matching version without refreshing remains allowed. Older records without the requirement list retain their known contributed pins; reinstall or refresh the bundle to record its full requirements. A normal install rejects a change to an already-recorded bundle's version or owned component metadata (version, source, preset priority, or strategy), including removal of an owned component. This applies even if a local manifest keeps the same bundle version. Reordering unchanged components or adding new components does not require refresh. To apply changes to a local bundle without adding it to a catalog, pass the revised source with `--refresh`: diff --git a/src/specify_cli/bundles/installer.py b/src/specify_cli/bundles/installer.py index 1613f92af1..11abc18468 100644 --- a/src/specify_cli/bundles/installer.py +++ b/src/specify_cli/bundles/installer.py @@ -135,11 +135,21 @@ def install_bundle( if r.bundle_id != plan.bundle_id for c in r.contributed_components } + other_pins: dict[tuple[str, str], set[str]] = {} + for record in records: + if record.bundle_id == plan.bundle_id: + continue + for component in record.required_components: + if component.version: + other_pins.setdefault((component.kind, component.id), set()).add( + component.version + ) contributed: list[ComponentRef] = [] done: list[ComponentRef] = [] try: _check_installed_pins( - project_root, plan, installer, prior_ours, other_tracked, refresh=refresh + project_root, plan, installer, prior_ours, other_tracked, + other_pins, refresh=refresh, ) for component in plan.components: key = (component.kind, component.id) @@ -264,10 +274,11 @@ def _check_installed_pins( installer: PrimitiveInstaller, prior_ours: set[tuple[str, str]], other_tracked: set[tuple[str, str]], + other_pins: dict[tuple[str, str], set[str]], *, refresh: bool, ) -> None: - """Check installed pins before any mutation, including shared components.""" + """Check installed pins and other bundles' requirements before mutation.""" mismatches = [] for component in plan.components: if component.source: @@ -280,6 +291,24 @@ def _check_installed_pins( "version. Pin this component before refreshing." ) if not component.version: + pins = other_pins.get(key) + if pins: + installed = installer.is_installed(project_root, component) + actual = ( + installer.installed_version(project_root, component) + if installed else None + ) + if ( + (refresh and key in prior_ours) + or not actual + or any(not same_version(actual, pin) for pin in pins) + ): + raise BundlerError( + f"Cannot install or refresh unpinned {component.kind[:-1]} " + f"'{component.id}': another bundle requires version " + f"{', '.join(sorted(pins))}. Pin this component to a " + "compatible version before installing or refreshing." + ) continue if not installer.is_installed(project_root, component): continue diff --git a/tests/specify_cli/bundles/test_installer.py b/tests/specify_cli/bundles/test_installer.py index 9fbc0c07a6..e624fbf054 100644 --- a/tests/specify_cli/bundles/test_installer.py +++ b/tests/specify_cli/bundles/test_installer.py @@ -117,6 +117,78 @@ def test_removal_preserves_component_required_but_not_owned_by_other_bundle( assert installer.remove_calls == [] +@pytest.mark.parametrize("actual", [None, "2.0.0"]) +def test_unpinned_install_cannot_bypass_unowned_bundle_pin( + tmp_path: Path, actual: str | None, +): + make_project(tmp_path) + installer = FakeInstaller() + key = ("steps", "shared") + installer.installed.add(key) + installer.versions[key] = "1.0.0" + pinned = _step_bundle("pinned", "1.0.0") + install_bundle(tmp_path, _plan(pinned), installer, manifest=pinned) + assert load_records(tmp_path)[0].contributed_components == () + + if actual is None: + installer.installed.remove(key) + installer.versions.pop(key) + else: + installer.versions[key] = actual + unpinned = _step_bundle("unpinned") + with pytest.raises(BundlerError, match="unpinned.*shared.*requires"): + install_bundle(tmp_path, _plan(unpinned), installer, manifest=unpinned) + + assert installer.install_calls == [] + assert [r.bundle_id for r in load_records(tmp_path)] == ["pinned"] + + +def test_unpinned_install_can_share_matching_unowned_requirement(tmp_path: Path): + make_project(tmp_path) + installer = FakeInstaller() + key = ("steps", "shared") + installer.installed.add(key) + installer.versions[key] = "1.0.0" + pinned = _step_bundle("pinned", "1.0.0") + install_bundle(tmp_path, _plan(pinned), installer, manifest=pinned) + + unpinned = _step_bundle("unpinned") + result = install_bundle(tmp_path, _plan(unpinned), installer, manifest=unpinned) + + assert result.skipped == unpinned.components + assert installer.install_calls == [] + assert load_records(tmp_path)[1].contributed_components == () + refreshed = install_bundle( + tmp_path, _plan(unpinned), installer, manifest=unpinned, refresh=True + ) + assert refreshed.skipped == unpinned.components + assert installer.refresh_calls == [] + + +def test_unpinned_refresh_cannot_change_unowned_bundle_pin(tmp_path: Path): + make_project(tmp_path) + installer = FakeInstaller() + key = ("steps", "shared") + installer.installed.add(key) + installer.versions[key] = "1.0.0" + pinned = _step_bundle("pinned", "1.0.0") + install_bundle(tmp_path, _plan(pinned), installer, manifest=pinned) + installer.installed.remove(key) + installer.versions.pop(key) + + owned = _step_bundle("owned", "1.0.0") + install_bundle(tmp_path, _plan(owned), installer, manifest=owned) + original_record = records_path(tmp_path).read_bytes() + unpinned = _step_bundle("owned") + with pytest.raises(BundlerError, match="unpinned.*shared.*requires"): + install_bundle( + tmp_path, _plan(unpinned), installer, manifest=unpinned, refresh=True + ) + + assert installer.refresh_calls == [] + assert records_path(tmp_path).read_bytes() == original_record + + def test_owned_component_drift_is_rejected_without_refresh(tmp_path: Path): make_project(tmp_path) installer = FakeInstaller() @@ -804,6 +876,16 @@ def _bundle(manifest_id, ext_ids, *, version="1.0.0"): return BundleManifest.from_dict(data) +def _step_bundle(bundle_id: str, version: str | None = None) -> BundleManifest: + data = valid_manifest_dict() + data["bundle"]["id"] = bundle_id + step = {"id": "shared"} + if version is not None: + step["version"] = version + data["provides"] = {"steps": [step]} + return BundleManifest.from_dict(data) + + def test_update_uninstalls_components_dropped_by_new_version(tmp_path: Path): """`bundle update` must uninstall components the new version no longer ships, instead of orphaning them (installed on disk, tracked by nothing).""" From b54323d8d6a9cec77f6913e3df46bf04f1f366c4 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Tue, 6 Oct 2026 17:30:57 -0500 Subject: [PATCH 12/23] Describe bundle overlaps as shared requirements Avoid attributing ownership when another bundle only requires an independently installed component. Cover conflict reports and bundle info output for required-only records. Assisted-by: GitHub Copilot (model: GPT-6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/reference/bundles.md | 2 +- src/specify_cli/bundles/conflict.py | 4 ++-- .../specify_cli/bundles/test_command_info.py | 23 ++++++++++++++++++ tests/specify_cli/bundles/test_conflict.py | 24 ++++++++++++++++++- 4 files changed, 49 insertions(+), 4 deletions(-) diff --git a/docs/reference/bundles.md b/docs/reference/bundles.md index d54b893fad..0ff9f4707f 100644 --- a/docs/reference/bundles.md +++ b/docs/reference/bundles.md @@ -53,7 +53,7 @@ specify bundle info | `--offline` | Do not access the network | | `--json` | Emit machine-readable JSON | -Shows full metadata for a bundle along with the **fully expanded component set** it installs — every extension, preset, step, and workflow with its pinned version, plus preset priority and strategy. The output also includes a trust indicator (`verified` vs `community`) so you can judge trust before installing. This preview is the same plan `install` applies, so you can see exactly what will be added before committing. Foreseeable overlaps and version conflicts with components already provided by installed bundles are surfaced here as well. +Shows full metadata for a bundle along with the **fully expanded component set** it installs — every extension, preset, step, and workflow with its pinned version, plus preset priority and strategy. The output also includes a trust indicator (`verified` vs `community`) so you can judge trust before installing. This preview is the same plan `install` applies, so you can see exactly what will be added before committing. Foreseeable overlaps and version conflicts with components already required by installed bundles are surfaced here as well; a bundle can require a component without having installed it. ## Install a Bundle diff --git a/src/specify_cli/bundles/conflict.py b/src/specify_cli/bundles/conflict.py index f9ae3fe4ef..db79614486 100644 --- a/src/specify_cli/bundles/conflict.py +++ b/src/specify_cli/bundles/conflict.py @@ -12,7 +12,7 @@ class ConflictReport: integration_clash: str | None = None # message when a hard clash exists version_clashes: list[str] = field(default_factory=list) - overlaps: list[str] = field(default_factory=list) # components already provided + overlaps: list[str] = field(default_factory=list) # components already required @property def has_blocking_conflict(self) -> bool: @@ -58,7 +58,7 @@ def detect_conflicts( ) else: report.overlaps.append( - f"{component.kind[:-1]} '{component.id}' is already provided by " + f"{component.kind[:-1]} '{component.id}' is already required by " f"bundle '{owner}'." ) diff --git a/tests/specify_cli/bundles/test_command_info.py b/tests/specify_cli/bundles/test_command_info.py index 673e004ea0..954f8d019f 100644 --- a/tests/specify_cli/bundles/test_command_info.py +++ b/tests/specify_cli/bundles/test_command_info.py @@ -9,7 +9,9 @@ from typer.testing import CliRunner from specify_cli import app +from specify_cli.bundles.manifest import ComponentRef from specify_cli.bundles.packager import build_bundle # noqa: F401 +from specify_cli.bundles.records import InstalledBundleRecord, save_records from tests.conftest import strip_ansi # noqa: F401 from tests.specify_cli.bundles._command_helpers import ( MARKUP_BUNDLE_ID, @@ -101,7 +103,28 @@ def test_info_expands_full_component_set(project: Path, monkeypatch): assert preset["strategy"] == "append" assert payload["trust"] == "verified" + save_records( + project, + [ + InstalledBundleRecord.create( + bundle_id="other", + version="1.0.0", + components=[], + required_components=[ + ComponentRef(kind="presets", id="preset-a", version="2.0.0") + ], + ) + ], + ) + overlap = "preset 'preset-a' is already required by bundle 'other'." + json_with_overlap = runner.invoke( + app, ["bundle", "info", "demo-bundle", "--json", "--offline"] + ) + assert json_with_overlap.exit_code == 0, json_with_overlap.output + assert json.loads(json_with_overlap.output)["overlaps"] == [overlap] + text = runner.invoke(app, ["bundle", "info", "demo-bundle", "--offline"]) + assert overlap in text.output assert "preset-a v2.0.0" in text.output assert "Trust" in text.output diff --git a/tests/specify_cli/bundles/test_conflict.py b/tests/specify_cli/bundles/test_conflict.py index 39d01fcafb..5572f2b6e3 100644 --- a/tests/specify_cli/bundles/test_conflict.py +++ b/tests/specify_cli/bundles/test_conflict.py @@ -40,8 +40,30 @@ def test_overlap_with_other_bundle_is_reported(): components=[ComponentRef(kind="presets", id="preset-a", version="2.0.0")], ) report = detect_conflicts(manifest, active_integration="copilot", installed=[other]) - assert any("preset-a" in o and "other" in o for o in report.overlaps) + assert report.overlaps == [ + "preset 'preset-a' is already required by bundle 'other'." + ] + assert report.has_blocking_conflict is False + + +def test_required_only_overlap_does_not_attribute_component_ownership(tmp_path): + manifest = _manifest() + other = InstalledBundleRecord.create( + bundle_id="other", + version="1.0.0", + components=[], + required_components=[ + ComponentRef(kind="presets", id="preset-a", version="2.0.0") + ], + ) + report = detect_conflicts(manifest, active_integration="copilot", installed=[other]) + + assert report.overlaps == [ + "preset 'preset-a' is already required by bundle 'other'." + ] assert report.has_blocking_conflict is False + save_records(tmp_path, [other]) + assert _bundle_overlaps(tmp_path, manifest, offline=True) == report.overlaps def test_same_bundle_reinstall_is_not_overlap(): From 24d66cfd0be8e7588f47463c06b9c5935306821e Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Tue, 6 Oct 2026 17:47:10 -0500 Subject: [PATCH 13/23] Reject lower-priority catalog matches after source outages Preserve precedence for exact component lookups when an earlier catalog could not be fetched. Keep broad search available and warn when bundle validation cannot verify the winning source. Assisted-by: GitHub Copilot (model: GPT-6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/specify_cli/extensions/__init__.py | 2 ++ src/specify_cli/presets/_catalog.py | 2 ++ src/specify_cli/workflows/catalog/_domain.py | 2 ++ .../workflows/step/catalog/_domain.py | 2 ++ tests/specify_cli/bundles/test_references.py | 13 +++++---- .../extensions/test_catalog_versions.py | 29 +++++++++++++++++++ .../presets/test_catalog_versions.py | 17 +++++++---- .../workflows/step/test_catalog_versions.py | 25 ++++++++++++++++ .../workflows/test_catalog_versions.py | 25 ++++++++++++++++ 9 files changed, 106 insertions(+), 11 deletions(-) diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index 70dded5f06..0366151a40 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -4903,6 +4903,8 @@ def _get_merged_extensions( "_install_allowed": catalog_entry.install_allowed, } if extension_id is not None: + if fetch_error is not None: + raise fetch_error return list(merged.values()) if not any_success and active_catalogs: diff --git a/src/specify_cli/presets/_catalog.py b/src/specify_cli/presets/_catalog.py index 1729814f94..357a109038 100644 --- a/src/specify_cli/presets/_catalog.py +++ b/src/specify_cli/presets/_catalog.py @@ -599,6 +599,8 @@ def _get_merged_packs( pack_data_with_catalog = {**pack_data, "_catalog_name": entry.name, "_install_allowed": entry.install_allowed} merged[found_id] = pack_data_with_catalog if pack_id is not None: + if first_fetch_error is not None: + raise first_fetch_error return merged except (PresetCatalogValidationError, PresetValidationError): raise diff --git a/src/specify_cli/workflows/catalog/_domain.py b/src/specify_cli/workflows/catalog/_domain.py index 87c885a7d8..9214054fe1 100644 --- a/src/specify_cli/workflows/catalog/_domain.py +++ b/src/specify_cli/workflows/catalog/_domain.py @@ -721,6 +721,8 @@ def _get_merged_workflows( wf_data["_install_allowed"] = entry.install_allowed merged[wf_id] = wf_data if workflow_id is not None and workflow_id in merged: + if fetch_error is not None: + raise fetch_error return merged if fetch_errors == len(catalogs) and catalogs: if validation_error is not None: diff --git a/src/specify_cli/workflows/step/catalog/_domain.py b/src/specify_cli/workflows/step/catalog/_domain.py index a1c4503cb3..2fd8a074cb 100644 --- a/src/specify_cli/workflows/step/catalog/_domain.py +++ b/src/specify_cli/workflows/step/catalog/_domain.py @@ -693,6 +693,8 @@ def _get_merged_steps( step_data["_install_allowed"] = entry.install_allowed merged[step_id] = step_data if target_id is not None and target_id in merged: + if fetch_error is not None: + raise fetch_error return merged if fetch_errors == len(catalogs) and catalogs: if validation_error is not None: diff --git a/tests/specify_cli/bundles/test_references.py b/tests/specify_cli/bundles/test_references.py index 51377e71e9..3e87d27389 100644 --- a/tests/specify_cli/bundles/test_references.py +++ b/tests/specify_cli/bundles/test_references.py @@ -398,14 +398,15 @@ def fetch(self, entry, force_refresh=False): check = make_reference_checker(tmp_path, allow_network=True, warnings=warnings) problem = check(_ref(kind, "requested")) - if has_match: + if has_match and unreachable == "high": + assert problem is None + assert len(warnings) == 1 + assert "unreachable" in warnings[0] + assert visited == ["high", "low"] + elif has_match: assert problem is None assert warnings == [] - assert visited == ( - ["high", "high"] - if unreachable == "low" - else ["high", "low", "high", "low"] - ) + assert visited == ["high", "high"] elif unreachable is not None: assert problem is None assert len(warnings) == 1 diff --git a/tests/specify_cli/extensions/test_catalog_versions.py b/tests/specify_cli/extensions/test_catalog_versions.py index b10a9dd9a7..ce7a3e7020 100644 --- a/tests/specify_cli/extensions/test_catalog_versions.py +++ b/tests/specify_cli/extensions/test_catalog_versions.py @@ -189,6 +189,35 @@ def fetch(source, _force=False): assert catalog.search("demo-history")[0]["_catalog_name"] == "low" +def test_targeted_lookup_rejects_lower_match_when_higher_catalog_is_unreachable( + tmp_path, monkeypatch, +): + from specify_cli.extensions import ExtensionCatalogFetchError + + 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) + high_available = False + + def fetch(source, _force=False): + if source.name == "high" and not high_available: + raise ExtensionCatalogFetchError("high catalog is offline") + return { + "schema_version": "1.0", + "extensions": {"demo-history": {"version": "1.0.0"}}, + } + + monkeypatch.setattr(catalog, "_fetch_single_catalog", fetch) + with pytest.raises(ExtensionCatalogFetchError, match="offline"): + catalog.get_extension_info("demo-history", "1.0.0") + assert catalog.search("demo-history")[0]["_catalog_name"] == "low" + high_available = True + assert catalog.get_extension_info("demo-history")["_catalog_name"] == "high" + + @pytest.mark.parametrize("second_lookup", ["lower_priority", "unavailable"]) def test_exact_cli_install_uses_first_resolved_catalog_snapshot( tmp_path, monkeypatch, second_lookup diff --git a/tests/specify_cli/presets/test_catalog_versions.py b/tests/specify_cli/presets/test_catalog_versions.py index 405f06961f..b96299c9de 100644 --- a/tests/specify_cli/presets/test_catalog_versions.py +++ b/tests/specify_cli/presets/test_catalog_versions.py @@ -613,25 +613,32 @@ def open_url(_self, url, **_kwargs): assert PresetManager(project_dir).get_pack("sample") is None -def test_unreachable_high_priority_catalog_still_uses_lower_source(project_dir): +def test_unreachable_high_priority_catalog_does_not_select_lower_source(project_dir): + from specify_cli.presets._catalog import PresetCatalogFetchError + catalog = PresetCatalog(project_dir) sources = [ PresetCatalogEntry("https://example.com/unavailable.json", "high", 1, False), PresetCatalogEntry("https://example.com/trusted.json", "low", 2, True), ] + high_available = False def fetch(source, _refresh): - if source.name == "high": - raise PresetError("Failed to fetch preset catalog: offline") + if source.name == "high" and not high_available: + raise PresetCatalogFetchError("Failed to fetch preset catalog: offline") return {"presets": {"sample": _entry()}} with ( patch.object(catalog, "get_active_catalogs", return_value=sources), patch.object(catalog, "_fetch_single_catalog", side_effect=fetch), ): + with pytest.raises(PresetCatalogFetchError, match="offline"): + catalog.get_pack_info("sample", "1.0.0") + assert catalog.search("sample")[0]["_catalog_name"] == "low" + high_available = True selected = catalog.get_pack_info("sample", "1.0.0") - assert selected["_catalog_name"] == "low" - assert selected["_install_allowed"] is True + assert selected["_catalog_name"] == "high" + assert selected["_install_allowed"] is False def test_versions_report_all_source_outage_instead_of_missing_preset(project_dir): diff --git a/tests/specify_cli/workflows/step/test_catalog_versions.py b/tests/specify_cli/workflows/step/test_catalog_versions.py index 97e6b82cb7..e2181140b2 100644 --- a/tests/specify_cli/workflows/step/test_catalog_versions.py +++ b/tests/specify_cli/workflows/step/test_catalog_versions.py @@ -11,6 +11,7 @@ StepCatalog, StepCatalogEntry, StepCatalogError, + StepCatalogFetchError, ) from specify_cli.workflows.step.catalog._versions import available_versions @@ -96,6 +97,30 @@ def test_winning_source_never_falls_back_for_missing_release(project_dir, monkey assert catalog.get_step_info("deploy", version="1.0") is None +def test_targeted_lookup_rejects_lower_match_after_higher_fetch_failure( + project_dir, monkeypatch, +): + catalog = StepCatalog(project_dir) + sources = [ + StepCatalogEntry("https://example.com/high.json", "high", 1, True), + StepCatalogEntry("https://example.com/low.json", "low", 2, True), + ] + monkeypatch.setattr(catalog, "get_active_catalogs", lambda: sources) + high_available = False + + def fetch(source, force_refresh=False): + if source.name == "high" and not high_available: + raise StepCatalogFetchError("high catalog is offline") + return {"steps": {"deploy": _entry()}} + + monkeypatch.setattr(catalog, "_fetch_single_catalog", fetch) + with pytest.raises(StepCatalogFetchError, match="offline"): + catalog.get_step_info("deploy", version="1.0") + assert catalog.search(query="deploy")[0]["_catalog_name"] == "low" + high_available = True + assert catalog.get_step_info("deploy")["_catalog_name"] == "high" + + def test_list_catalog_rejects_duplicate_step_ids(project_dir, monkeypatch): catalog = StepCatalog(project_dir) source = StepCatalogEntry("https://example.com/steps.json", "test", 1, True) diff --git a/tests/specify_cli/workflows/test_catalog_versions.py b/tests/specify_cli/workflows/test_catalog_versions.py index 626bdc406e..5034b16512 100644 --- a/tests/specify_cli/workflows/test_catalog_versions.py +++ b/tests/specify_cli/workflows/test_catalog_versions.py @@ -15,6 +15,7 @@ from specify_cli.workflows.catalog import ( WorkflowCatalog, WorkflowCatalogEntry, + WorkflowCatalogFetchError, WorkflowCatalogValidationError, WorkflowRegistry, WorkflowValidationError, @@ -144,6 +145,30 @@ def test_legacy_entry_and_winning_source(monkeypatch, project_dir): assert catalog.search(query="history-wf")[0]["version"] == "2.0.0" +def test_targeted_lookup_rejects_lower_match_after_higher_fetch_failure( + monkeypatch, project_dir, +): + catalog = WorkflowCatalog(project_dir) + sources = [ + WorkflowCatalogEntry("https://example.com/high.json", "high", 1, True), + WorkflowCatalogEntry("https://example.com/low.json", "low", 2, True), + ] + monkeypatch.setattr(catalog, "get_active_catalogs", lambda: sources) + high_available = False + + def fetch(source, force_refresh=False): + if source.name == "high" and not high_available: + raise WorkflowCatalogFetchError("high catalog is offline") + return {"workflows": {"history-wf": _entry()}} + + monkeypatch.setattr(catalog, "_fetch_single_catalog", fetch) + with pytest.raises(WorkflowCatalogFetchError, match="offline"): + catalog.get_workflow_info("history-wf", "1.0.0") + assert catalog.search(query="history-wf")[0]["_catalog_name"] == "low" + high_available = True + assert catalog.get_workflow_info("history-wf")["_catalog_name"] == "high" + + @pytest.mark.parametrize("duplicate_id", ["history-wf", "other"]) @pytest.mark.parametrize("lookup", ["exact", "search"]) def test_duplicate_list_workflow_ids_are_rejected( From 824b526099814e913752ab4e7abefee9fda6955d Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Tue, 6 Oct 2026 18:04:12 -0500 Subject: [PATCH 14/23] Protect shared bundle install requirements Reject sharing or refreshing a component when another bundle requires different source or preset priority/strategy. Check required-only records before any primitive mutation and retain compatible sharing. Assisted-by: GitHub Copilot (model: GPT-6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/reference/bundles.md | 2 +- src/specify_cli/bundles/installer.py | 31 ++++-- tests/specify_cli/bundles/test_installer.py | 103 +++++++++++++++++++- 3 files changed, 127 insertions(+), 9 deletions(-) diff --git a/docs/reference/bundles.md b/docs/reference/bundles.md index 0ff9f4707f..abe02884a9 100644 --- a/docs/reference/bundles.md +++ b/docs/reference/bundles.md @@ -71,7 +71,7 @@ Installs a bundle's full component set through each primitive's machinery. The a If the current directory is not yet a Spec Kit project, `install` initializes one first so a fresh checkout reaches a working state in a single command. `--integration` selects the integration when initializing a new project, and confirms the target when a bundle pins a specific integration but the project's active integration can't be determined (missing or unreadable `.specify/integration.json`). It does **not** override an already-initialized project's active integration: if a bundle targets a different integration than the project's, install aborts with no changes. Integration-agnostic bundles inherit the project's active integration. Without `--refresh`, installation is idempotent — already-installed components matching their pins are skipped. A missing or different installed version cannot satisfy a pin. Only `--refresh` can repair a drifted version owned exclusively by this bundle; independently installed components are skipped and never adopted or replaced. On failure, no provenance record is written (a failed install records nothing), and the components installed during that run are removed on a best-effort basis — removal errors are swallowed, so partial on-disk state may remain. -Installed-bundle records store required component pins separately from contributed components. A compatible independently installed component satisfies a bundle's requirement but remains independently owned: its pin still blocks a later bundle from installing an incompatible version, while removing the bundle does not uninstall it. An unpinned install or refresh cannot replace a component another bundle requires at a pinned version; sharing an already installed matching version without refreshing remains allowed. Older records without the requirement list retain their known contributed pins; reinstall or refresh the bundle to record its full requirements. +Installed-bundle records store required component pins separately from contributed components. A compatible independently installed component satisfies a bundle's requirement but remains independently owned: its pin still blocks a later bundle from installing an incompatible version, while removing the bundle does not uninstall it. An unpinned install or refresh cannot replace a component another bundle requires at a pinned version; sharing an already installed matching version without refreshing remains allowed. Shared bundles must also agree on component source and, for presets, priority and strategy before installing or refreshing the shared component. Older records without the requirement list retain their known contributed pins; reinstall or refresh the bundle to record its full requirements. A normal install rejects a change to an already-recorded bundle's version or owned component metadata (version, source, preset priority, or strategy), including removal of an owned component. This applies even if a local manifest keeps the same bundle version. Reordering unchanged components or adding new components does not require refresh. To apply changes to a local bundle without adding it to a catalog, pass the revised source with `--refresh`: diff --git a/src/specify_cli/bundles/installer.py b/src/specify_cli/bundles/installer.py index 11abc18468..12e7489711 100644 --- a/src/specify_cli/bundles/installer.py +++ b/src/specify_cli/bundles/installer.py @@ -88,7 +88,8 @@ def install_bundle( skipped. A refresh may repair drift in components owned exclusively by this bundle, but cannot change a version required by another bundle or an independently installed component. Changes to owned component metadata - still require refresh. + still require refresh. Other bundles' source and preset settings cannot be + changed by sharing or refreshing a component. """ records = load_records(project_root) @@ -136,20 +137,21 @@ def install_bundle( for c in r.contributed_components } other_pins: dict[tuple[str, str], set[str]] = {} + other_requirements: dict[tuple[str, str], list[tuple[str, ComponentRef]]] = {} for record in records: if record.bundle_id == plan.bundle_id: continue for component in record.required_components: + key = component.kind, component.id + other_requirements.setdefault(key, []).append((record.bundle_id, component)) if component.version: - other_pins.setdefault((component.kind, component.id), set()).add( - component.version - ) + other_pins.setdefault(key, set()).add(component.version) contributed: list[ComponentRef] = [] done: list[ComponentRef] = [] try: _check_installed_pins( project_root, plan, installer, prior_ours, other_tracked, - other_pins, refresh=refresh, + other_pins, other_requirements, refresh=refresh, ) for component in plan.components: key = (component.kind, component.id) @@ -275,15 +277,32 @@ def _check_installed_pins( prior_ours: set[tuple[str, str]], other_tracked: set[tuple[str, str]], other_pins: dict[tuple[str, str], set[str]], + other_requirements: dict[tuple[str, str], list[tuple[str, ComponentRef]]], *, refresh: bool, ) -> None: """Check installed pins and other bundles' requirements before mutation.""" mismatches = [] for component in plan.components: + key = component.kind, component.id + for bundle_id, required in other_requirements.get(key, []): + different = [] + if component.source != required.source: + different.append("source") + if component.kind == "presets": + if component.priority != required.priority: + different.append("priority") + if component.strategy != required.strategy: + different.append("strategy") + if different: + raise BundlerError( + f"Cannot install or refresh shared {component.kind[:-1]} " + f"'{component.id}': bundle '{bundle_id}' requires different " + f"{', '.join(different)}. Shared components must agree on " + "install-affecting requirements." + ) if component.source: installer.validate_source(project_root, component) - key = component.kind, component.id if refresh and not component.version and key in other_tracked: raise BundlerError( f"Cannot refresh unpinned shared {component.kind[:-1]} " diff --git a/tests/specify_cli/bundles/test_installer.py b/tests/specify_cli/bundles/test_installer.py index e624fbf054..fd374ec65c 100644 --- a/tests/specify_cli/bundles/test_installer.py +++ b/tests/specify_cli/bundles/test_installer.py @@ -11,8 +11,13 @@ from specify_cli.bundler import BundlerError from specify_cli.bundles.installer import install_bundle, remove_bundle -from specify_cli.bundles.manifest import BundleManifest -from specify_cli.bundles.records import load_records, records_path +from specify_cli.bundles.manifest import BundleManifest, ComponentRef +from specify_cli.bundles.records import ( + InstalledBundleRecord, + load_records, + records_path, + save_records, +) from specify_cli.bundles.resolver import resolve_install_plan from tests.specify_cli.bundles.helpers import ( FakeInstaller, @@ -220,6 +225,84 @@ def test_shared_component_drift_cannot_be_refreshed(tmp_path: Path): assert installer.refresh_calls == [] +@pytest.mark.parametrize( + ("change", "field"), + [ + ({"priority": 20}, "priority"), + ({"strategy": "replace"}, "strategy"), + ({"source": "trusted"}, "source"), + ], +) +@pytest.mark.parametrize("required_only", [False, True]) +def test_shared_refresh_preserves_other_bundles_install_requirements( + tmp_path: Path, change: dict, field: str, required_only: bool, +): + make_project(tmp_path) + installer = FakeInstaller() + first = _preset_bundle("first") + other = _preset_bundle("other") + install_bundle(tmp_path, _plan(first), installer, manifest=first) + if required_only: + save_records( + tmp_path, + [ + *load_records(tmp_path), + InstalledBundleRecord.create( + "other", "1.0.0", [], required_components=other.components + ), + ], + ) + else: + install_bundle(tmp_path, _plan(other), installer, manifest=other) + before = records_path(tmp_path).read_bytes() + + changed = _preset_bundle("first", **change) + with pytest.raises(BundlerError, match=rf"shared preset.*{field}"): + install_bundle( + tmp_path, _plan(changed), installer, manifest=changed, refresh=True + ) + + assert installer.refresh_calls == [] + assert records_path(tmp_path).read_bytes() == before + + +def test_shared_install_rejects_conflicting_preset_priority(tmp_path: Path): + make_project(tmp_path) + installer = FakeInstaller() + first = _preset_bundle("first") + install_bundle(tmp_path, _plan(first), installer, manifest=first) + before = records_path(tmp_path).read_bytes() + second = _preset_bundle("second", priority=20) + second.extensions.append( + ComponentRef(kind="extensions", id="ext-new", version="1.0.0") + ) + + with pytest.raises(BundlerError, match="shared preset.*priority"): + install_bundle(tmp_path, _plan(second), installer, manifest=second) + + assert installer.install_calls == [("presets", "preset-a")] + assert ("extensions", "ext-new") not in installer.installed + assert installer.refresh_calls == [] + assert records_path(tmp_path).read_bytes() == before + + +def test_shared_refresh_allows_matching_install_requirements(tmp_path: Path): + make_project(tmp_path) + installer = FakeInstaller() + first = _preset_bundle("first") + second = _preset_bundle("second") + install_bundle(tmp_path, _plan(first), installer, manifest=first) + install_bundle(tmp_path, _plan(second), installer, manifest=second) + + result = install_bundle( + tmp_path, _plan(second), installer, manifest=second, refresh=True + ) + + assert result.refreshed == second.components + assert installer.refresh_calls == [("presets", "preset-a")] + assert len(load_records(tmp_path)) == 2 + + def test_unpinned_shared_step_cannot_refresh_another_bundles_pin(tmp_path: Path): make_project(tmp_path) installer = FakeInstaller() @@ -886,6 +969,22 @@ def _step_bundle(bundle_id: str, version: str | None = None) -> BundleManifest: return BundleManifest.from_dict(data) +def _preset_bundle( + bundle_id: str, *, priority: int = 10, strategy: str = "append", + source: str | None = None, +) -> BundleManifest: + data = valid_manifest_dict() + data["bundle"]["id"] = bundle_id + preset = { + "id": "preset-a", "version": "2.0.0", + "priority": priority, "strategy": strategy, + } + if source is not None: + preset["source"] = source + data["provides"] = {"presets": [preset]} + return BundleManifest.from_dict(data) + + def test_update_uninstalls_components_dropped_by_new_version(tmp_path: Path): """`bundle update` must uninstall components the new version no longer ships, instead of orphaning them (installed on disk, tracked by nothing).""" From 69a2e8ea86f80ca895e5faca8e17e637b6cc4ccb Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Tue, 6 Oct 2026 18:19:34 -0500 Subject: [PATCH 15/23] Transfer ownership when bundle contributions remain needed Keep bundle-installed components attributed after removal or update drops the sole contributor while another bundle still requires them. Preserve independent ownership and cover sequential removal and update cleanup. Assisted-by: GitHub Copilot (model: GPT-6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/reference/bundles.md | 2 +- src/specify_cli/bundles/installer.py | 18 +++++++++-- src/specify_cli/bundles/records.py | 35 +++++++++++++++++++- tests/specify_cli/bundles/test_installer.py | 36 +++++++++++++++++++++ 4 files changed, 87 insertions(+), 4 deletions(-) diff --git a/docs/reference/bundles.md b/docs/reference/bundles.md index abe02884a9..753ec3439e 100644 --- a/docs/reference/bundles.md +++ b/docs/reference/bundles.md @@ -115,7 +115,7 @@ An optional `source` in a `provides.` reference names the expected winning specify bundle remove ``` -Uninstalls only the components this bundle contributed, leaving any component that another installed bundle still needs in place (no collateral removals). +Uninstalls only the components this bundle contributed, leaving any component that another installed bundle still needs in place (no collateral removals). If no other bundle owns a retained component, its contribution is transferred to a remaining requiring bundle so removing that bundle later can clean it up. Independently installed components are never adopted. ## List Installed Bundles diff --git a/src/specify_cli/bundles/installer.py b/src/specify_cli/bundles/installer.py index 12e7489711..23fcf1e94c 100644 --- a/src/specify_cli/bundles/installer.py +++ b/src/specify_cli/bundles/installer.py @@ -25,6 +25,7 @@ load_records, remove_record, save_records, + transfer_contributions, upsert_record, ) from .resolver import InstallPlan @@ -215,7 +216,17 @@ def install_bundle( # ``bundle list`` keeps reporting when the bundle was first installed. installed_at=existing.installed_at if existing is not None else None, ) - save_records(project_root, upsert_record(records, record)) + updated = upsert_record(records, record) + if refresh and existing is not None: + planned = {(c.kind, c.id) for c in plan.components} + updated = transfer_contributions( + updated, + [ + component for component in existing.contributed_components + if (component.kind, component.id) not in planned + ], + ) + save_records(project_root, updated) return result @@ -244,7 +255,10 @@ def remove_bundle( remove_attempted = True installer.remove(project_root, component) result.uninstalled.append(component) - save_records(project_root, remove_record(records, bundle_id)) + remaining = transfer_contributions( + remove_record(records, bundle_id), target.contributed_components + ) + save_records(project_root, remaining) except Exception as exc: if result.uninstalled: detail = ( diff --git a/src/specify_cli/bundles/records.py b/src/specify_cli/bundles/records.py index 67ebb6a3d4..c3a616cd93 100644 --- a/src/specify_cli/bundles/records.py +++ b/src/specify_cli/bundles/records.py @@ -7,7 +7,7 @@ """ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, replace from datetime import datetime, timezone from pathlib import Path from typing import Any @@ -202,6 +202,39 @@ def remove_record( return [r for r in records if r.bundle_id != bundle_id] +def transfer_contributions( + records: list[InstalledBundleRecord], + released: list[ComponentRef] | tuple[ComponentRef, ...], +) -> list[InstalledBundleRecord]: + """Keep a bundle-installed component attributed while another bundle needs it.""" + updated = list(records) + owned = { + (component.kind, component.id) + for record in updated + for component in record.contributed_components + } + for component in released: + key = component.kind, component.id + if key in owned: + continue + for index, record in enumerate(updated): + required = next( + ( + ref for ref in record.required_components + if (ref.kind, ref.id) == key + ), + None, + ) + if required is not None: + updated[index] = replace( + record, + contributed_components=(*record.contributed_components, required), + ) + owned.add(key) + break + return updated + + def components_still_needed( records: list[InstalledBundleRecord], exclude_bundle_id: str ) -> set[tuple[str, str]]: diff --git a/tests/specify_cli/bundles/test_installer.py b/tests/specify_cli/bundles/test_installer.py index fd374ec65c..cc4992935f 100644 --- a/tests/specify_cli/bundles/test_installer.py +++ b/tests/specify_cli/bundles/test_installer.py @@ -121,6 +121,42 @@ def test_removal_preserves_component_required_but_not_owned_by_other_bundle( assert key in installer.installed assert installer.remove_calls == [] + remaining = load_records(tmp_path) + assert remaining[0].bundle_id == "first" + assert remaining[0].contributed_components == tuple(first.components) + remove_bundle(tmp_path, "first", installer) + assert key not in installer.installed + assert installer.remove_calls == [key] + + +def test_update_transfers_dropped_contribution_to_requiring_bundle(tmp_path: Path): + make_project(tmp_path) + installer = FakeInstaller() + first = _bundle("first", ["ext-a"]) + install_bundle(tmp_path, _plan(first), installer, manifest=first) + save_records( + tmp_path, + [ + *load_records(tmp_path), + InstalledBundleRecord.create( + "other", "1.0.0", [], required_components=first.components + ), + ], + ) + reduced = _bundle("first", []) + result = install_bundle( + tmp_path, _plan(reduced), installer, manifest=reduced, refresh=True + ) + + assert result.uninstalled == [] + assert ("extensions", "ext-a") in installer.installed + remaining = {record.bundle_id: record for record in load_records(tmp_path)} + assert remaining["first"].contributed_components == () + assert remaining["other"].contributed_components == tuple(first.components) + + remove_bundle(tmp_path, "other", installer) + assert installer.remove_calls == [("extensions", "ext-a")] + @pytest.mark.parametrize("actual", [None, "2.0.0"]) def test_unpinned_install_cannot_bypass_unowned_bundle_pin( From 4f1d19447b13a6825b09d22882cb540323cb1e8f Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Tue, 6 Oct 2026 18:45:30 -0500 Subject: [PATCH 16/23] Classify malformed extension and step catalogs consistently Treat deeply nested extension JSON as catalog validation failures across network and cache paths, and classify duplicate step IDs as malformed catalog data. Cover targeted and discovery lookups with regressions. Assisted-by: GitHub Copilot (model: GPT-6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/specify_cli/extensions/__init__.py | 30 ++++++-- .../workflows/step/catalog/_domain.py | 2 +- .../extensions/test_catalog_versions.py | 72 +++++++++++++++++++ .../workflows/step/test_catalog_versions.py | 11 ++- 4 files changed, 107 insertions(+), 8 deletions(-) diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index 0366151a40..449d2da0ad 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -4706,6 +4706,7 @@ def _fetch_single_catalog( KeyError, TypeError, AttributeError, + RecursionError, ): # Cache validity is best-effort: invalid/missing metadata # fields, an unreadable metadata file (permissions / disk), @@ -4729,7 +4730,13 @@ def _fetch_single_catalog( cached_data = json.loads(cache_file.read_text(encoding="utf-8")) self._validate_catalog_payload(cached_data, entry.url) return cached_data - except (json.JSONDecodeError, OSError, UnicodeError, ExtensionError): + except ( + json.JSONDecodeError, + OSError, + UnicodeError, + RecursionError, + ExtensionError, + ): # Cache is best-effort: a JSON-decode failure, an OS-level # read failure (permissions / disk / handle limit), or a # text-encoding failure on a cache file written by an older @@ -4797,7 +4804,7 @@ def _validate_redirect(_old_url: str, new_url: str) -> None: ), encoding="utf-8", ) - except OSError: + except (OSError, RecursionError): pass # Cache is best-effort; proceed with fetched data return catalog_data @@ -4818,6 +4825,10 @@ def _validate_redirect(_old_url: str, new_url: str) -> None: raise ExtensionCatalogValidationError( f"Invalid JSON in catalog from {entry.url}: {e}" ) from e + except RecursionError as e: + raise ExtensionCatalogValidationError( + f"Invalid JSON nesting in catalog from {entry.url}: {e}" + ) from e except UnicodeError as e: raise ExtensionCatalogValidationError( f"Invalid encoding in catalog from {entry.url}: {e}" @@ -4946,6 +4957,7 @@ def is_cache_valid(self) -> bool: KeyError, TypeError, AttributeError, + RecursionError, ): # ``AttributeError`` covers the case where the metadata file is # valid JSON but parses to a non-mapping (``[]``, ``"oops"``, @@ -4984,7 +4996,13 @@ def fetch_catalog(self, force_refresh: bool = False) -> Dict[str, Any]: cached_data = json.loads(self.cache_file.read_text(encoding="utf-8")) self._validate_catalog_payload(cached_data, catalog_url) return cached_data - except (json.JSONDecodeError, OSError, UnicodeError, ExtensionError): + except ( + json.JSONDecodeError, + OSError, + UnicodeError, + RecursionError, + ExtensionError, + ): pass # Fall through to network fetch try: @@ -5041,7 +5059,7 @@ def _validate_redirect(_old_url: str, new_url: str) -> None: self.cache_metadata_file.write_text( json.dumps(metadata, indent=2), encoding="utf-8" ) - except OSError: + except (OSError, RecursionError): pass # Cache is best-effort; proceed with fetched data return catalog_data @@ -5060,6 +5078,10 @@ def _validate_redirect(_old_url: str, new_url: str) -> None: ) from e except json.JSONDecodeError as e: raise ExtensionError(f"Invalid JSON in catalog: {e}") from e + except RecursionError as e: + raise ExtensionCatalogValidationError( + f"Invalid JSON nesting in catalog from {catalog_url}: {e}" + ) from e except UnicodeError as e: raise ExtensionCatalogValidationError( f"Invalid encoding in catalog from {catalog_url}: {e}" diff --git a/src/specify_cli/workflows/step/catalog/_domain.py b/src/specify_cli/workflows/step/catalog/_domain.py index 2fd8a074cb..1c766e4a44 100644 --- a/src/specify_cli/workflows/step/catalog/_domain.py +++ b/src/specify_cli/workflows/step/catalog/_domain.py @@ -682,7 +682,7 @@ def _get_merged_steps( step_id = str(raw_step_id).strip() if step_id: if step_id in seen_in_source: - raise StepCatalogError( + raise StepCatalogValidationError( f"Duplicate step ID '{step_id}' in catalog '{entry.name}'." ) seen_in_source.add(step_id) diff --git a/tests/specify_cli/extensions/test_catalog_versions.py b/tests/specify_cli/extensions/test_catalog_versions.py index ce7a3e7020..8135a0df45 100644 --- a/tests/specify_cli/extensions/test_catalog_versions.py +++ b/tests/specify_cli/extensions/test_catalog_versions.py @@ -15,6 +15,7 @@ from specify_cli.extensions import ( CatalogEntry, ExtensionCatalog, + ExtensionCatalogValidationError, ExtensionError, ExtensionManifest, ) @@ -87,6 +88,77 @@ def getheader(self, _name): return "application/zip" +@pytest.mark.parametrize("legacy", [False, True]) +def test_deeply_nested_catalog_json_is_a_validation_error( + tmp_path, monkeypatch, legacy +): + source = CatalogEntry("https://example.com/deep.json", "deep", 1, True) + catalog = ExtensionCatalog(tmp_path) + payload = b"[" * 12000 + b"0" + b"]" * 12000 + monkeypatch.setattr( + catalog, + "_open_url", + lambda *_args, **_kwargs: _ArchiveResponse(payload, source.url), + ) + if legacy: + monkeypatch.setattr(catalog, "get_catalog_url", lambda: source.url) + + with pytest.raises(ExtensionCatalogValidationError, match="nesting"): + if legacy: + catalog.fetch_catalog(force_refresh=True) + else: + catalog._fetch_single_catalog(source, force_refresh=True) + + +@pytest.mark.parametrize("legacy", [False, True]) +def test_deeply_nested_cached_catalog_refetches(tmp_path, monkeypatch, legacy): + catalog = ExtensionCatalog(tmp_path) + url = catalog.DEFAULT_CATALOG_URL + source = CatalogEntry(url, "default", 1, True) + catalog.cache_file.parent.mkdir(parents=True, exist_ok=True) + catalog.cache_file.write_bytes(b"[" * 12000 + b"0" + b"]" * 12000) + monkeypatch.setattr(catalog, "is_cache_valid", lambda: True) + monkeypatch.setattr(catalog, "get_catalog_url", lambda: url) + monkeypatch.setattr( + catalog, + "_open_url", + lambda *_args, **_kwargs: _ArchiveResponse( + b'{"schema_version":"1.0","extensions":{}}', url + ), + ) + + data = catalog.fetch_catalog() if legacy else catalog._fetch_single_catalog(source) + assert data["extensions"] == {} + + +@pytest.mark.parametrize("legacy", [False, True]) +def test_deeply_nested_cache_metadata_refetches(tmp_path, monkeypatch, legacy): + catalog = ExtensionCatalog(tmp_path) + url = catalog.DEFAULT_CATALOG_URL if legacy else "https://example.com/deep.json" + source = CatalogEntry(url, "deep", 1, True) + if legacy: + cache_file = catalog.cache_file + metadata_file = catalog.cache_metadata_file + monkeypatch.setattr(catalog, "get_catalog_url", lambda: url) + else: + url_hash = hashlib.sha256(url.encode()).hexdigest()[:16] + cache_file = catalog.cache_dir / f"catalog-{url_hash}.json" + metadata_file = catalog.cache_dir / f"catalog-{url_hash}-metadata.json" + cache_file.parent.mkdir(parents=True, exist_ok=True) + cache_file.write_bytes(b'{"schema_version":"1.0","extensions":{}}') + metadata_file.write_bytes(b"[" * 12000 + b"0" + b"]" * 12000) + monkeypatch.setattr( + catalog, + "_open_url", + lambda *_args, **_kwargs: _ArchiveResponse( + b'{"schema_version":"1.0","extensions":{}}', url + ), + ) + + data = catalog.fetch_catalog() if legacy else catalog._fetch_single_catalog(source) + assert data["extensions"] == {} + + def test_legacy_entry_still_selects_its_current_release(tmp_path, monkeypatch): entry = { "id": "legacy", diff --git a/tests/specify_cli/workflows/step/test_catalog_versions.py b/tests/specify_cli/workflows/step/test_catalog_versions.py index e2181140b2..c50a073c40 100644 --- a/tests/specify_cli/workflows/step/test_catalog_versions.py +++ b/tests/specify_cli/workflows/step/test_catalog_versions.py @@ -12,6 +12,7 @@ StepCatalogEntry, StepCatalogError, StepCatalogFetchError, + StepCatalogValidationError, ) from specify_cli.workflows.step.catalog._versions import available_versions @@ -121,7 +122,8 @@ def fetch(source, force_refresh=False): assert catalog.get_step_info("deploy")["_catalog_name"] == "high" -def test_list_catalog_rejects_duplicate_step_ids(project_dir, monkeypatch): +@pytest.mark.parametrize("targeted", [False, True]) +def test_list_catalog_rejects_duplicate_step_ids(project_dir, monkeypatch, targeted): catalog = StepCatalog(project_dir) source = StepCatalogEntry("https://example.com/steps.json", "test", 1, True) monkeypatch.setattr(catalog, "get_active_catalogs", lambda: [source]) @@ -135,8 +137,11 @@ def test_list_catalog_rejects_duplicate_step_ids(project_dir, monkeypatch): ] }, ) - with pytest.raises(StepCatalogError, match="Duplicate step ID 'deploy'"): - catalog.get_step_info("deploy") + with pytest.raises(StepCatalogValidationError, match="Duplicate step ID 'deploy'"): + if targeted: + catalog.get_step_info("deploy") + else: + catalog.search(query="deploy") @pytest.mark.parametrize("raised_during_fetch", [False, True]) From 82a66db897b39936beafe36861116f213f55da66 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Tue, 6 Oct 2026 19:42:37 -0500 Subject: [PATCH 17/23] Reject non-string IDs in workflow catalogs Classify malformed list-form workflow IDs as catalog validation failures before duplicate detection or exact-version resolution. Cover targeted and search lookups for unhashable, numeric, boolean, and null IDs. Assisted-by: GitHub Copilot (model: GPT-6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/specify_cli/workflows/catalog/_domain.py | 5 ++++ .../workflows/test_catalog_versions.py | 26 +++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/src/specify_cli/workflows/catalog/_domain.py b/src/specify_cli/workflows/catalog/_domain.py index 9214054fe1..8a271d602f 100644 --- a/src/specify_cli/workflows/catalog/_domain.py +++ b/src/specify_cli/workflows/catalog/_domain.py @@ -709,6 +709,11 @@ def _get_merged_workflows( if not isinstance(wf_data, dict): continue wf_id = wf_data.get("id", "") + if not isinstance(wf_id, str): + raise WorkflowCatalogValidationError( + f"Invalid workflow ID in catalog '{entry.name}': " + "expected a string." + ) if wf_id: if wf_id in seen_in_source: raise WorkflowCatalogValidationError( diff --git a/tests/specify_cli/workflows/test_catalog_versions.py b/tests/specify_cli/workflows/test_catalog_versions.py index 5034b16512..0c66a81445 100644 --- a/tests/specify_cli/workflows/test_catalog_versions.py +++ b/tests/specify_cli/workflows/test_catalog_versions.py @@ -204,6 +204,32 @@ def fetch(source, force_refresh=False): assert fetched == ["high"] +@pytest.mark.parametrize("invalid_id", [{"bad": "id"}, ["bad"], 42, True, None]) +@pytest.mark.parametrize("lookup", ["exact", "search"]) +def test_list_catalog_rejects_non_string_workflow_ids( + monkeypatch, project_dir, invalid_id, lookup, +): + catalog = WorkflowCatalog(project_dir) + source = WorkflowCatalogEntry("https://example.com/workflows.json", "test", 1, True) + monkeypatch.setattr(catalog, "get_active_catalogs", lambda: [source]) + monkeypatch.setattr( + catalog, + "_fetch_single_catalog", + lambda *_args, **_kwargs: { + "workflows": [ + {"id": "history-wf", "version": "1.0.0"}, + {"id": invalid_id, "version": "2.0.0"}, + ] + }, + ) + + with pytest.raises(WorkflowCatalogValidationError, match="Invalid workflow ID"): + if lookup == "exact": + catalog.get_workflow_info("history-wf") + else: + catalog.search() + + def test_list_workflow_id_can_appear_in_distinct_catalogs(monkeypatch, project_dir): catalog = WorkflowCatalog(project_dir) sources = [ From afed3a4308e3c2a4d027d8190917dce3614b362b Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Wed, 7 Oct 2026 08:01:31 -0500 Subject: [PATCH 18/23] Reject ambiguous bundle sources and malformed step IDs Fail closed when an explicit bundle component source names multiple active catalogs, including validation and direct primitive installation. Reject non-string list-form step IDs and update the former coercion regression. Simulate JSON parser recursion deterministically across Python versions so CI tests the exception contract rather than interpreter-specific depth limits. Assisted-by: GitHub Copilot (model: GPT-6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/reference/bundles.md | 2 +- src/specify_cli/bundles/primitives.py | 22 +++++- src/specify_cli/bundles/references.py | 17 +++-- .../workflows/step/catalog/_domain.py | 11 +-- tests/specify_cli/bundles/test_primitives.py | 36 ++++++++++ tests/specify_cli/bundles/test_references.py | 72 +++++++++++++++++++ .../extensions/test_catalog_versions.py | 26 +++++-- .../workflows/step/test_catalog_versions.py | 26 +++++++ tests/test_workflows.py | 16 +++-- 9 files changed, 204 insertions(+), 24 deletions(-) diff --git a/docs/reference/bundles.md b/docs/reference/bundles.md index 753ec3439e..b5b2e02f58 100644 --- a/docs/reference/bundles.md +++ b/docs/reference/bundles.md @@ -107,7 +107,7 @@ Without an explicit `source`, bundled extensions and presets take precedence ove A bundle may list a component ID only once per kind; duplicate references in the same manifest are invalid, even if their pins agree. -An optional `source` in a `provides.` reference names the expected winning component catalog (as displayed by `specify catalog list`, or `specify workflow step catalog list` for steps). It is **not** an artifact URL and does not override catalog priority or install policy. When specified, the component is verified against that catalog even if already installed, rather than resolved from a Spec Kit-bundled copy; a different winning catalog or a discovery-only source prevents installation. Verifying an explicit source requires network access. Direct primitive `--from` URLs are a separate, explicitly requested route. +An optional `source` in a `provides.` reference names the expected winning component catalog (as displayed by `specify catalog list`, or `specify workflow step catalog list` for steps). It is **not** an artifact URL and does not override catalog priority or install policy. When specified, the component is verified against that catalog even if already installed, rather than resolved from a Spec Kit-bundled copy; a different winning catalog or a discovery-only source prevents installation. If multiple active catalogs share the named source, bundle validation and installation reject it as ambiguous; give each catalog a unique name. Verifying an explicit source requires network access. Direct primitive `--from` URLs are a separate, explicitly requested route. ## Remove a Bundle diff --git a/src/specify_cli/bundles/primitives.py b/src/specify_cli/bundles/primitives.py index 7014b29195..7300b100f8 100644 --- a/src/specify_cli/bundles/primitives.py +++ b/src/specify_cli/bundles/primitives.py @@ -101,8 +101,20 @@ def _assert_catalog_source(kind: str, component: ComponentRef, info: dict) -> No ) -def _selected_catalog_info(kind: str, component: ComponentRef, get_info) -> dict: +def _assert_unambiguous_catalog_source(component: ComponentRef, catalog) -> None: + if component.source and sum( + entry.name == component.source for entry in catalog.get_active_catalogs() + ) > 1: + raise BundlerError( + f"{component.kind[:-1]} '{component.id}' requests ambiguous catalog " + f"source '{component.source}': multiple active catalogs share this name. " + "Give each catalog a unique name before installing." + ) + + +def _selected_catalog_info(kind: str, component: ComponentRef, get_info, catalog) -> dict: """Resolve an exact workflow/step release within the winning catalog.""" + _assert_unambiguous_catalog_source(component, catalog) current = get_info(component.id) if current is None: raise BundlerError(f"{kind} '{component.id}' not found in any catalog.") @@ -285,6 +297,7 @@ def _do_install(self, component: ComponentRef, *, force: bool) -> None: from ..presets import PresetCatalog catalog = PresetCatalog(self._root) + _assert_unambiguous_catalog_source(component, catalog) info = catalog.get_pack_info(component.id) if not info: raise BundlerError(f"Preset '{component.id}' not found in any catalog.") @@ -378,6 +391,7 @@ def _do_install(self, component: ComponentRef, *, force: bool) -> None: from ..extensions import ExtensionCatalog catalog = ExtensionCatalog(self._root) + _assert_unambiguous_catalog_source(component, catalog) info = catalog.get_extension_info(component.id) if not info: raise BundlerError( @@ -478,8 +492,9 @@ def install(self, component: ComponentRef) -> None: ) from ..workflows.catalog import WorkflowCatalog + catalog = WorkflowCatalog(self._root) selected = _selected_catalog_info( - "Workflow", component, WorkflowCatalog(self._root).get_workflow_info + "Workflow", component, catalog.get_workflow_info, catalog ) from ..workflows.command_add import _install_preselected_workflow @@ -541,8 +556,9 @@ def install(self, component: ComponentRef) -> None: ) from exc try: + catalog = StepCatalog(self._root) selected = _selected_catalog_info( - "Step", component, StepCatalog(self._root).get_step_info + "Step", component, catalog.get_step_info, catalog ) except StepCatalogError as exc: raise BundlerError( diff --git a/src/specify_cli/bundles/references.py b/src/specify_cli/bundles/references.py index c543094db5..d0a9191526 100644 --- a/src/specify_cli/bundles/references.py +++ b/src/specify_cli/bundles/references.py @@ -77,7 +77,10 @@ def _resolved_locally(root: Path, component: ComponentRef) -> bool: return False -def _catalog_has_release(component: ComponentRef, get_info) -> bool: +def _catalog_has_release(component: ComponentRef, catalog, get_info) -> bool: + from .primitives import _assert_unambiguous_catalog_source + + _assert_unambiguous_catalog_source(component, catalog) current = get_info(component.id) if current is None or not current.get("_install_allowed", True): return False @@ -101,23 +104,27 @@ def _resolved_in_catalog(root: Path, component: ComponentRef) -> bool | str | No if kind == "presets": from ..presets import PresetCatalog - return _catalog_has_release(component, PresetCatalog(root).get_pack_info) + catalog = PresetCatalog(root) + return _catalog_has_release(component, catalog, catalog.get_pack_info) if kind == "extensions": from ..extensions import ExtensionCatalog + catalog = ExtensionCatalog(root) return _catalog_has_release( - component, ExtensionCatalog(root).get_extension_info + component, catalog, catalog.get_extension_info ) if kind == "workflows": from ..workflows.catalog import WorkflowCatalog + catalog = WorkflowCatalog(root) return _catalog_has_release( - component, WorkflowCatalog(root).get_workflow_info + component, catalog, catalog.get_workflow_info ) if kind == "steps": from ..workflows.catalog import StepCatalog - return _catalog_has_release(component, StepCatalog(root).get_step_info) + catalog = StepCatalog(root) + return _catalog_has_release(component, catalog, catalog.get_step_info) except (ConnectionError, TimeoutError): return None except Exception as exc: # noqa: BLE001 - report malformed catalog errors diff --git a/src/specify_cli/workflows/step/catalog/_domain.py b/src/specify_cli/workflows/step/catalog/_domain.py index 1c766e4a44..51438ce9ac 100644 --- a/src/specify_cli/workflows/step/catalog/_domain.py +++ b/src/specify_cli/workflows/step/catalog/_domain.py @@ -676,10 +676,13 @@ def _get_merged_steps( for step_data in steps: if not isinstance(step_data, dict): continue - raw_step_id = step_data.get("id") - if raw_step_id is None: - continue - step_id = str(raw_step_id).strip() + raw_step_id = step_data.get("id", "") + if not isinstance(raw_step_id, str): + raise StepCatalogValidationError( + f"Invalid step ID in catalog '{entry.name}': " + "expected a string." + ) + step_id = raw_step_id.strip() if step_id: if step_id in seen_in_source: raise StepCatalogValidationError( diff --git a/tests/specify_cli/bundles/test_primitives.py b/tests/specify_cli/bundles/test_primitives.py index 6ad940700a..d62ff0692b 100644 --- a/tests/specify_cli/bundles/test_primitives.py +++ b/tests/specify_cli/bundles/test_primitives.py @@ -241,6 +241,42 @@ def test_explicit_source_cannot_bypass_winning_catalog(tmp_path, monkeypatch, ki primitive_manager(kind, tmp_path).install(component) +@pytest.mark.parametrize("kind", ["extensions", "presets", "workflows", "steps"]) +def test_primitive_install_rejects_ambiguous_catalog_source(tmp_path, monkeypatch, kind): + from specify_cli.extensions import CatalogEntry, ExtensionCatalog + from specify_cli.presets import PresetCatalog, PresetCatalogEntry + from specify_cli.workflows.catalog import ( + StepCatalog, + StepCatalogEntry, + WorkflowCatalog, + WorkflowCatalogEntry, + ) + + catalog_type, entry_type, lookup = { + "extensions": (ExtensionCatalog, CatalogEntry, "get_extension_info"), + "presets": (PresetCatalog, PresetCatalogEntry, "get_pack_info"), + "workflows": (WorkflowCatalog, WorkflowCatalogEntry, "get_workflow_info"), + "steps": (StepCatalog, StepCatalogEntry, "get_step_info"), + }[kind] + sources = [ + entry_type("https://example.com/expected.json", "trusted", 1, True), + entry_type("https://example.com/other.json", "trusted", 2, True), + ] + monkeypatch.setattr(catalog_type, "get_active_catalogs", lambda self: sources) + monkeypatch.setattr( + catalog_type, + lookup, + lambda *_args, **_kwargs: pytest.fail( + "catalog lookup must not start with an ambiguous source" + ), + ) + + with pytest.raises(BundlerError, match="ambiguous catalog source"): + primitive_manager(kind, tmp_path).install( + ComponentRef(kind=kind, id="catalog-id", source="trusted") + ) + + @pytest.mark.parametrize("kind", ["workflows", "steps"]) def test_missing_exact_release_never_delegates_install(tmp_path, monkeypatch, kind): import specify_cli diff --git a/tests/specify_cli/bundles/test_references.py b/tests/specify_cli/bundles/test_references.py index 3e87d27389..d5e4cbf2a0 100644 --- a/tests/specify_cli/bundles/test_references.py +++ b/tests/specify_cli/bundles/test_references.py @@ -8,6 +8,7 @@ from pathlib import Path import pytest +import yaml from specify_cli.bundles.manifest import ComponentRef from specify_cli.bundles.references import make_reference_checker @@ -1075,3 +1076,74 @@ def fetch(self, source, force_refresh=False): assert check(ComponentRef(kind=kind, id="requested", source="high")) is None assert warnings == [] + + +@pytest.mark.parametrize("kind", ["extensions", "presets", "workflows", "steps"]) +@pytest.mark.parametrize("duplicate_name", [False, True]) +def test_explicit_source_requires_unique_catalog_name( + tmp_path, monkeypatch, kind, duplicate_name, +): + from specify_cli.bundles import BundlerError + from specify_cli.bundles.adapters import DefaultPrimitiveInstaller + from specify_cli.extensions import ExtensionCatalog + from specify_cli.presets import PresetCatalog + from specify_cli.workflows.catalog import ( + StepCatalog, + WorkflowCatalog, + ) + + catalog_type, lookup, env_key = { + "extensions": (ExtensionCatalog, "get_extension_info", "SPECKIT_CATALOG_URL"), + "presets": (PresetCatalog, "get_pack_info", "SPECKIT_PRESET_CATALOG_URL"), + "workflows": ( + WorkflowCatalog, "get_workflow_info", "SPECKIT_WORKFLOW_CATALOG_URL" + ), + "steps": (StepCatalog, "get_step_info", "SPECKIT_STEP_CATALOG_URL"), + }[kind] + monkeypatch.delenv(env_key, raising=False) + config = tmp_path / ".specify" / f"{kind[:-1]}-catalogs.yml" + config.parent.mkdir() + config.write_text( + yaml.safe_dump({"catalogs": [ + { + "url": "https://example.com/expected.json", + "name": "trusted", + "priority": 1, + "install_allowed": True, + }, + { + "url": "https://example.com/other.json", + "name": "trusted" if duplicate_name else "other", + "priority": 2, + "install_allowed": True, + }, + ]}), + encoding="utf-8", + ) + assert [entry.name for entry in catalog_type(tmp_path).get_active_catalogs()] == [ + "trusted", + "trusted" if duplicate_name else "other", + ] + monkeypatch.setattr( + catalog_type, + lookup, + lambda self, component_id, version=None: { + "id": component_id, + "version": version or "1.0.0", + "_catalog_name": "trusted", + "_install_allowed": True, + }, + ) + ref = ComponentRef(kind=kind, id="requested", source="trusted") + warnings: list[str] = [] + check = make_reference_checker(tmp_path, allow_network=True, warnings=warnings) + installer = DefaultPrimitiveInstaller() + + if duplicate_name: + assert "ambiguous" in check(ref).lower() + with pytest.raises(BundlerError, match="ambiguous"): + installer.validate_source(tmp_path, ref) + else: + assert check(ref) is None + installer.validate_source(tmp_path, ref) + assert warnings == [] diff --git a/tests/specify_cli/extensions/test_catalog_versions.py b/tests/specify_cli/extensions/test_catalog_versions.py index 8135a0df45..3917c414cb 100644 --- a/tests/specify_cli/extensions/test_catalog_versions.py +++ b/tests/specify_cli/extensions/test_catalog_versions.py @@ -3,6 +3,7 @@ from __future__ import annotations import hashlib +import json import zipfile from io import BytesIO from pathlib import Path @@ -88,17 +89,32 @@ def getheader(self, _name): return "application/zip" +_DEEP_JSON = b"[" * 12000 + b"0" + b"]" * 12000 + + +def _raise_on_deep_json(monkeypatch): + """Exercise parser recursion independent of Python's nesting threshold.""" + loads = json.loads + + def decode(data, *args, **kwargs): + if data == _DEEP_JSON or data == _DEEP_JSON.decode("utf-8"): + raise RecursionError("maximum recursion depth exceeded while decoding JSON") + return loads(data, *args, **kwargs) + + monkeypatch.setattr(json, "loads", decode) + + @pytest.mark.parametrize("legacy", [False, True]) def test_deeply_nested_catalog_json_is_a_validation_error( tmp_path, monkeypatch, legacy ): source = CatalogEntry("https://example.com/deep.json", "deep", 1, True) catalog = ExtensionCatalog(tmp_path) - payload = b"[" * 12000 + b"0" + b"]" * 12000 + _raise_on_deep_json(monkeypatch) monkeypatch.setattr( catalog, "_open_url", - lambda *_args, **_kwargs: _ArchiveResponse(payload, source.url), + lambda *_args, **_kwargs: _ArchiveResponse(_DEEP_JSON, source.url), ) if legacy: monkeypatch.setattr(catalog, "get_catalog_url", lambda: source.url) @@ -116,7 +132,8 @@ def test_deeply_nested_cached_catalog_refetches(tmp_path, monkeypatch, legacy): url = catalog.DEFAULT_CATALOG_URL source = CatalogEntry(url, "default", 1, True) catalog.cache_file.parent.mkdir(parents=True, exist_ok=True) - catalog.cache_file.write_bytes(b"[" * 12000 + b"0" + b"]" * 12000) + catalog.cache_file.write_bytes(_DEEP_JSON) + _raise_on_deep_json(monkeypatch) monkeypatch.setattr(catalog, "is_cache_valid", lambda: True) monkeypatch.setattr(catalog, "get_catalog_url", lambda: url) monkeypatch.setattr( @@ -146,7 +163,8 @@ def test_deeply_nested_cache_metadata_refetches(tmp_path, monkeypatch, legacy): metadata_file = catalog.cache_dir / f"catalog-{url_hash}-metadata.json" cache_file.parent.mkdir(parents=True, exist_ok=True) cache_file.write_bytes(b'{"schema_version":"1.0","extensions":{}}') - metadata_file.write_bytes(b"[" * 12000 + b"0" + b"]" * 12000) + metadata_file.write_bytes(_DEEP_JSON) + _raise_on_deep_json(monkeypatch) monkeypatch.setattr( catalog, "_open_url", diff --git a/tests/specify_cli/workflows/step/test_catalog_versions.py b/tests/specify_cli/workflows/step/test_catalog_versions.py index c50a073c40..e3b8b36abd 100644 --- a/tests/specify_cli/workflows/step/test_catalog_versions.py +++ b/tests/specify_cli/workflows/step/test_catalog_versions.py @@ -144,6 +144,32 @@ def test_list_catalog_rejects_duplicate_step_ids(project_dir, monkeypatch, targe catalog.search(query="deploy") +@pytest.mark.parametrize("invalid_id", [True, 42, {"bad": "id"}, ["bad"], None]) +@pytest.mark.parametrize("targeted", [False, True]) +def test_list_catalog_rejects_non_string_step_ids( + project_dir, monkeypatch, invalid_id, targeted +): + catalog = StepCatalog(project_dir) + source = StepCatalogEntry("https://example.com/steps.json", "test", 1, True) + monkeypatch.setattr(catalog, "get_active_catalogs", lambda: [source]) + monkeypatch.setattr( + catalog, + "_fetch_single_catalog", + lambda *_args, **_kwargs: { + "steps": [ + {"id": invalid_id, "version": "1.0"}, + {"id": "deploy", "version": "2.0"}, + ] + }, + ) + + with pytest.raises(StepCatalogValidationError, match="Invalid step ID"): + if targeted: + catalog.get_step_info(str(invalid_id) if invalid_id is not None else "deploy") + else: + catalog.search() + + @pytest.mark.parametrize("raised_during_fetch", [False, True]) def test_targeted_lookup_rejects_duplicate_ids_before_lower_catalog( project_dir, monkeypatch, raised_during_fetch, diff --git a/tests/test_workflows.py b/tests/test_workflows.py index bb83f3eda3..c09a12ec5f 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -10485,9 +10485,13 @@ def test_search_with_non_string_fields(self, project_dir, monkeypatch): results = catalog.search(query="missing") assert len(results) == 0 - def test_get_merged_steps_normalizes_list_ids_to_strings(self, project_dir, monkeypatch): - """List-based catalog entries with non-string ids must be normalized.""" - from specify_cli.workflows.step.catalog import StepCatalog, StepCatalogEntry + def test_get_merged_steps_rejects_non_string_list_ids(self, project_dir, monkeypatch): + """List-based catalog entries must declare string IDs.""" + from specify_cli.workflows.step.catalog import ( + StepCatalog, + StepCatalogEntry, + StepCatalogValidationError, + ) catalog = StepCatalog(project_dir) entry = StepCatalogEntry( @@ -10505,10 +10509,8 @@ def test_get_merged_steps_normalizes_list_ids_to_strings(self, project_dir, monk }, ) - merged = catalog._get_merged_steps() - assert "42" in merged - assert 42 not in merged - assert merged["42"]["id"] == "42" + with pytest.raises(StepCatalogValidationError, match="Invalid step ID"): + catalog._get_merged_steps() def test_get_step_info_returns_entry_or_none(self, project_dir, monkeypatch): """get_step_info returns matching entry or None for missing ids.""" From 992f804429501ad0ea61c1d0b57b2d293e2145f2 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Wed, 7 Oct 2026 08:14:30 -0500 Subject: [PATCH 19/23] Count required components in bundle list output Report the complete required component set even when every component was already installed independently. Preserve legacy record fallback and JSON provenance, document unversioned catalog pin behavior, and make unknown-step reference testing independent of network availability. Assisted-by: GitHub Copilot (model: GPT-6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/reference/bundles.md | 4 +-- src/specify_cli/bundles/command_list.py | 2 +- .../specify_cli/bundles/test_command_list.py | 35 +++++++++++++++++++ tests/specify_cli/bundles/test_references.py | 7 +++- 4 files changed, 44 insertions(+), 4 deletions(-) diff --git a/docs/reference/bundles.md b/docs/reference/bundles.md index b5b2e02f58..529e4a446f 100644 --- a/docs/reference/bundles.md +++ b/docs/reference/bundles.md @@ -99,7 +99,7 @@ specify bundle update [] Re-resolves a bundle and **refreshes** its components through each primitive's update path, bringing already-installed components up to the bundle's newly pinned versions while preserving primitive-level overrides (such as preset priority). Provide a bundle id, or use `--all` to update everything installed. -**Pinned catalog releases.** Extensions, presets, workflows, and steps with version pins select that exact release from the highest-priority active catalog entry. Historical releases must be advertised under `releases` with their own artifact URL and SHA-256 digest; the bundler never guesses an old URL from the current one or falls through to another catalog. The primitive installer uses the selected workflow or step record without re-reading the catalog, and verifies downloaded archive or workflow/step metadata against that release. A pinned workflow that ships with Spec Kit uses its bundled copy when the version matches, and the catalog release when it differs and network access is allowed. A step without a pin installs the current catalog release. +**Pinned catalog releases.** Extensions, presets, workflows, and steps with version pins select that exact release from the highest-priority active catalog entry. Historical releases must be advertised under `releases` with their own artifact URL and SHA-256 digest; the bundler never guesses an old URL from the current one or falls through to another catalog. Legacy extension and preset entries that advertise no version are an exception: the bundler uses the winning entry as-is and cannot enforce its pin or verify the installed archive's version. Publish a versioned entry to make that pin enforceable. The primitive installer uses the selected workflow or step record without re-reading the catalog, and verifies downloaded archive or workflow/step metadata against that release. A pinned workflow that ships with Spec Kit uses its bundled copy when the version matches, and the catalog release when it differs and network access is allowed. A step without a pin installs the current catalog release. Without an explicit `source`, bundled extensions and presets take precedence over catalog releases. Validation rejects a pin that differs from the bundled version even when a matching catalog release exists; specify the winning catalog as `source` to opt into its release. @@ -127,7 +127,7 @@ specify bundle list | -------- | ---------------------------- | | `--json` | Emit machine-readable JSON | -Lists the bundles installed in the project with their versions, component counts, and install timestamps. +Lists the bundles installed in the project with their versions, required component counts (including components supplied independently), and install timestamps. ## Initialize a Project with a Bundle diff --git a/src/specify_cli/bundles/command_list.py b/src/specify_cli/bundles/command_list.py index 42acea2c38..e6b4da6817 100644 --- a/src/specify_cli/bundles/command_list.py +++ b/src/specify_cli/bundles/command_list.py @@ -40,6 +40,6 @@ def bundle_list( console.print( f" [bold]{_escape_markup(str(record.bundle_id))}[/bold] " f"v{_escape_markup(str(record.version))} " - f"[dim]({len(record.contributed_components)} components, " + f"[dim]({len(record.required_components)} components, " f"installed {_escape_markup(str(record.installed_at))})[/dim]" ) diff --git a/tests/specify_cli/bundles/test_command_list.py b/tests/specify_cli/bundles/test_command_list.py index 8e2fe90a9f..f97f0fda65 100644 --- a/tests/specify_cli/bundles/test_command_list.py +++ b/tests/specify_cli/bundles/test_command_list.py @@ -6,6 +6,7 @@ from unittest.mock import patch # noqa: F401 import yaml # noqa: F401 +import pytest from typer.testing import CliRunner from specify_cli import app @@ -67,6 +68,40 @@ def test_list_escapes_markup_in_records(project: Path): assert "2026-01-01T00:00:00Z[/dim]" in output +@pytest.mark.parametrize( + ("contributed", "required", "expected_count"), + [ + ([], [{"kind": "extensions", "id": "first"}, {"kind": "steps", "id": "second"}], 2), + ([{"kind": "extensions", "id": "first"}], None, 1), + ], +) +def test_list_counts_required_components( + project: Path, contributed, required, expected_count +): + record = { + "bundle_id": "demo", + "version": "1.0.0", + "installed_at": "2026-01-01T00:00:00Z", + "contributed_components": contributed, + } + if required is not None: + record["required_components"] = required + (project / ".specify" / "bundle-records.json").write_text( + json.dumps({"schema_version": "1.0", "bundles": [record]}), + encoding="utf-8", + ) + + result = runner.invoke(app, ["bundle", "list"]) + assert result.exit_code == 0, result.output + assert f"({expected_count} components," in strip_ansi(result.output) + + json_result = runner.invoke(app, ["bundle", "list", "--json"]) + assert json_result.exit_code == 0, json_result.output + parsed = json.loads(json_result.stdout)[0] + assert len(parsed["required_components"]) == expected_count + assert len(parsed["contributed_components"]) == len(contributed) + + def test_override_redirects_bundle_commands(tmp_path, monkeypatch): web = _make_project(tmp_path, "web") elsewhere = tmp_path / "elsewhere" diff --git a/tests/specify_cli/bundles/test_references.py b/tests/specify_cli/bundles/test_references.py index d5e4cbf2a0..972bbb6b25 100644 --- a/tests/specify_cli/bundles/test_references.py +++ b/tests/specify_cli/bundles/test_references.py @@ -90,8 +90,13 @@ def execute(self, config, context): # pragma: no cover - never run STEP_REGISTRY.pop("community-only-step", None) -def test_unknown_step_type_still_errors_online(tmp_path: Path): +def test_unknown_step_type_still_errors_online(tmp_path: Path, monkeypatch): """The guard must not make every step id resolve.""" + from specify_cli.workflows.catalog import StepCatalog + + monkeypatch.setattr( + StepCatalog, "get_step_info", lambda self, _id, version=None: None + ) root = make_project(tmp_path) warnings: list[str] = [] check = make_reference_checker(root, allow_network=True, warnings=warnings) From 6bd20124b52d176f00d777dbca9aa2c29fd33b30 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Wed, 7 Oct 2026 10:59:13 -0500 Subject: [PATCH 20/23] fix(bundle): confine exact-pin resolution to bundler Keep strict catalog precedence and selected historical releases within bundle validation and installation. Restore general catalog behavior to upstream main while preserving workflow and step preselected installer hooks. Assisted-by: GitHub Copilot (model: GPT-6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/reference/bundles.md | 2 +- docs/reference/workflows.md | 2 +- src/specify_cli/bundles/component_catalog.py | 149 +++++ src/specify_cli/bundles/primitives.py | 32 +- src/specify_cli/bundles/references.py | 47 +- src/specify_cli/extensions/__init__.py | 139 +--- src/specify_cli/presets/_catalog.py | 31 +- src/specify_cli/workflows/catalog/__init__.py | 4 - src/specify_cli/workflows/catalog/_domain.py | 135 +--- .../workflows/step/catalog/__init__.py | 2 - .../workflows/step/catalog/_domain.py | 133 +--- .../bundles/test_command_install.py | 7 +- .../bundles/test_command_validate.py | 5 +- tests/specify_cli/bundles/test_installer.py | 22 +- tests/specify_cli/bundles/test_primitives.py | 258 ++++---- tests/specify_cli/bundles/test_references.py | 608 ++++++++++-------- .../extensions/test_catalog_versions.py | 153 +---- .../presets/test_catalog_versions.py | 32 +- .../workflows/step/test_catalog_versions.py | 92 +-- .../workflows/step/test_command_info.py | 2 +- .../workflows/test_catalog_versions.py | 108 ---- .../workflows/test_command_search.py | 4 +- tests/test_workflows.py | 16 +- 23 files changed, 791 insertions(+), 1192 deletions(-) create mode 100644 src/specify_cli/bundles/component_catalog.py diff --git a/docs/reference/bundles.md b/docs/reference/bundles.md index 529e4a446f..76880b68d9 100644 --- a/docs/reference/bundles.md +++ b/docs/reference/bundles.md @@ -153,7 +153,7 @@ specify bundle validate | `--path` | Bundle directory or `bundle.yml` (default: current directory) | | `--offline` | Verify references against bundled/installed components only | -Reports whether a `bundle.yml` is well-formed and whether every declared component reference resolves at its pinned version. References are checked against matching bundled or installed components and — when online — the exact release in the winning install-allowed catalog. An explicit `source` is verified against the winning catalog instead of resolving locally. Missing releases, mismatched sources, discovery-only sources, and malformed catalog metadata fail validation; references that cannot be checked offline or because a catalog is unreachable produce warnings. A reference is not reported missing when a readable catalog lacks it but another configured catalog is unreachable; the lookup remains unverified. +Reports whether a `bundle.yml` is well-formed and whether every declared component reference resolves at its pinned version. References are checked against matching bundled or installed components and — when online — the exact release in the winning install-allowed catalog. An explicit `source` is verified against the winning catalog instead of resolving locally. Missing releases, mismatched sources, discovery-only sources, and malformed catalog metadata fail validation; references that cannot be checked offline or because a catalog is unreachable produce warnings. If a higher-priority catalog is unreachable before a match is found, the lookup remains unverified; a lower-priority match cannot settle it. As with installation, legacy extension and preset entries advertising no version are accepted without verifying their pins. ## Build a Bundle Artifact diff --git a/docs/reference/workflows.md b/docs/reference/workflows.md index c3e1420c9c..9e524c1c85 100644 --- a/docs/reference/workflows.md +++ b/docs/reference/workflows.md @@ -421,7 +421,7 @@ installed locally. ## Catalog Management -Workflow catalogs control where `search` and `add` look for workflows. Catalogs are checked in priority order. List-form catalogs must contain unique workflow IDs within each source; duplicate IDs are rejected rather than selecting an ambiguous release. The same ID may appear in different catalogs, with the highest-priority source winning. +Workflow catalogs control where `search` and `add` look for workflows. Catalogs are checked in priority order. > **A project's `.specify/workflow-catalogs.yml` can point `add` and `search` at a catalog you didn't choose.** Before running a workflow from an unfamiliar project, run `specify workflow catalog list` (and `specify workflow step catalog list` for the step catalogs its steps can pull in) — a project supplying that config is not evidence its workflows or steps were vetted. Maintainers do not audit `run` fields; read a workflow's shell steps yourself before running it (see [Who maintains workflows?](#who-maintains-workflows)). diff --git a/src/specify_cli/bundles/component_catalog.py b/src/specify_cli/bundles/component_catalog.py new file mode 100644 index 0000000000..f52413c745 --- /dev/null +++ b/src/specify_cli/bundles/component_catalog.py @@ -0,0 +1,149 @@ +"""Resolve bundle components from the first trustworthy catalog source.""" + +from __future__ import annotations + +import ssl +from http.client import HTTPException +from urllib.error import HTTPError, URLError + +from ..authentication.http import RedirectPolicyError +from . import BundlerError +from .manifest import ComponentRef + + +class CatalogUnavailable(BundlerError): + """The winning component source cannot be determined right now.""" + + +def _is_unavailable(exc: BaseException) -> bool: + seen: set[int] = set() + while exc is not None and id(exc) not in seen: + seen.add(id(exc)) + if isinstance(exc, RedirectPolicyError): + return False + if isinstance(exc, HTTPError): + return exc.code in (408, 429) or 500 <= exc.code <= 599 + if isinstance(exc, URLError): + return not isinstance(exc.reason, (ssl.SSLError, ValueError)) + if isinstance(exc, (ConnectionError, TimeoutError, HTTPException)): + return True + exc = exc.__cause__ or exc.__context__ + return False + + +def _source_entries(data: object, component: ComponentRef, url: str) -> dict | list: + entries = data.get(component.kind) if isinstance(data, dict) else None + if not isinstance(entries, (dict, list)): + raise BundlerError( + f"Invalid {component.kind[:-1]} catalog from {url}: " + f"missing or malformed {component.kind} metadata." + ) + return entries + + +def _matching_entry(entries: dict | list, component: ComponentRef, url: str) -> dict | None: + if isinstance(entries, dict): + if component.id not in entries: + return None + found = entries[component.id] + if not isinstance(found, dict): + raise BundlerError( + f"Invalid {component.kind[:-1]} catalog entry for '{component.id}' " + f"from {url}: expected an object." + ) + return {**found, "id": component.id} + + seen: set[str] = set() + found = None + for item in entries: + if not isinstance(item, dict): + raise BundlerError( + f"Invalid {component.kind[:-1]} catalog entry from {url}: " + "expected an object." + ) + item_id = item.get("id") + if not isinstance(item_id, str): + raise BundlerError( + f"Invalid {component.kind[:-1]} ID in catalog {url}: expected a string." + ) + item_id = item_id.strip() if component.kind == "steps" else item_id + if not item_id: + raise BundlerError( + f"Invalid {component.kind[:-1]} ID in catalog {url}: empty ID." + ) + if item_id in seen: + raise BundlerError( + f"Duplicate {component.kind[:-1]} ID '{item_id}' in catalog {url}." + ) + seen.add(item_id) + if item_id == component.id: + found = {**item, "id": item_id} + return found + + +def winning_catalog_entry(catalog, component: ComponentRef) -> dict | None: + """Read sources in priority order rather than accepting a lower match on error. + + The component catalogs' public ID lookup skips failed catalogs. Bundles + cannot do that: an unreadable higher source may own the requested ID. + Reuse each catalog's authenticated, cached, redirect-checked fetcher and + its existing release selector; only the strict winner policy lives here. + """ + from ..extensions import ExtensionError + from ..presets import PresetError + from ..workflows.catalog import StepCatalogError, WorkflowCatalogError + + sources = catalog.get_active_catalogs() + if component.source and sum( + source.name == component.source for source in sources + ) > 1: + raise BundlerError( + f"{component.kind[:-1]} '{component.id}' requests ambiguous catalog " + f"source '{component.source}': multiple active catalogs share this name. " + "Give each catalog a unique name before installing." + ) + + for source in sources: + try: + data = catalog._fetch_single_catalog(source) + except ( + ExtensionError, PresetError, WorkflowCatalogError, StepCatalogError, + OSError, HTTPException, UnicodeError, ValueError, TypeError, + RecursionError, + ) as exc: + if _is_unavailable(exc): + raise CatalogUnavailable( + f"Catalog '{source.name}' is unreachable: {exc}" + ) from exc + raise BundlerError( + f"Invalid {component.kind[:-1]} catalog from {source.url}: {exc}" + ) from exc + + entries = _source_entries(data, component, source.url) + found = _matching_entry(entries, component, source.url) + if found is not None: + return { + **found, + "_catalog_name": source.name, + "_install_allowed": source.install_allowed, + } + return None + + +def select_catalog_release(component: ComponentRef, entry: dict) -> dict | None: + """Select from the already-fetched winning entry without a second lookup.""" + if component.kind == "extensions": + from ..extensions._catalog_versions import select_release + + return select_release(entry, component.version) + if component.kind == "presets": + from ..presets._catalog_versions import select_release + + return select_release(entry, component.version) + if component.kind == "workflows": + from ..workflows.catalog._versions import select_release + + return select_release(entry, component.version) + from ..workflows.step.catalog._versions import select_release + + return select_release(entry, component.id, component.version) diff --git a/src/specify_cli/bundles/primitives.py b/src/specify_cli/bundles/primitives.py index 7300b100f8..bb37955ab0 100644 --- a/src/specify_cli/bundles/primitives.py +++ b/src/specify_cli/bundles/primitives.py @@ -101,27 +101,17 @@ def _assert_catalog_source(kind: str, component: ComponentRef, info: dict) -> No ) -def _assert_unambiguous_catalog_source(component: ComponentRef, catalog) -> None: - if component.source and sum( - entry.name == component.source for entry in catalog.get_active_catalogs() - ) > 1: - raise BundlerError( - f"{component.kind[:-1]} '{component.id}' requests ambiguous catalog " - f"source '{component.source}': multiple active catalogs share this name. " - "Give each catalog a unique name before installing." - ) - - -def _selected_catalog_info(kind: str, component: ComponentRef, get_info, catalog) -> dict: +def _selected_catalog_info(kind: str, component: ComponentRef, catalog) -> dict: """Resolve an exact workflow/step release within the winning catalog.""" - _assert_unambiguous_catalog_source(component, catalog) - current = get_info(component.id) + from .component_catalog import select_catalog_release, winning_catalog_entry + + current = winning_catalog_entry(catalog, component) if current is None: raise BundlerError(f"{kind} '{component.id}' not found in any catalog.") _assert_catalog_source(kind, component, current) if component.version is None: return current - selected = get_info(component.id, version=component.version) + selected = select_catalog_release(component, current) if selected is None: raise BundlerError( f"{kind} '{component.id}' has no catalog release for pinned version " @@ -295,10 +285,10 @@ def _do_install(self, component: ComponentRef, *, force: bool) -> None: ) from ..presets import PresetCatalog + from .component_catalog import winning_catalog_entry catalog = PresetCatalog(self._root) - _assert_unambiguous_catalog_source(component, catalog) - info = catalog.get_pack_info(component.id) + info = winning_catalog_entry(catalog, component) if not info: raise BundlerError(f"Preset '{component.id}' not found in any catalog.") from ..presets._catalog_versions import select_release @@ -389,10 +379,10 @@ def _do_install(self, component: ComponentRef, *, force: bool) -> None: ) from ..extensions import ExtensionCatalog + from .component_catalog import winning_catalog_entry catalog = ExtensionCatalog(self._root) - _assert_unambiguous_catalog_source(component, catalog) - info = catalog.get_extension_info(component.id) + info = winning_catalog_entry(catalog, component) if not info: raise BundlerError( f"Extension '{component.id}' not found in any catalog." @@ -494,7 +484,7 @@ def install(self, component: ComponentRef) -> None: catalog = WorkflowCatalog(self._root) selected = _selected_catalog_info( - "Workflow", component, catalog.get_workflow_info, catalog + "Workflow", component, catalog ) from ..workflows.command_add import _install_preselected_workflow @@ -558,7 +548,7 @@ def install(self, component: ComponentRef) -> None: try: catalog = StepCatalog(self._root) selected = _selected_catalog_info( - "Step", component, catalog.get_step_info, catalog + "Step", component, catalog ) except StepCatalogError as exc: raise BundlerError( diff --git a/src/specify_cli/bundles/references.py b/src/specify_cli/bundles/references.py index d0a9191526..5e86e2c026 100644 --- a/src/specify_cli/bundles/references.py +++ b/src/specify_cli/bundles/references.py @@ -77,18 +77,21 @@ def _resolved_locally(root: Path, component: ComponentRef) -> bool: return False -def _catalog_has_release(component: ComponentRef, catalog, get_info) -> bool: - from .primitives import _assert_unambiguous_catalog_source +def _catalog_has_release(component: ComponentRef, catalog) -> bool: + from .component_catalog import select_catalog_release, winning_catalog_entry - _assert_unambiguous_catalog_source(component, catalog) - current = get_info(component.id) + current = winning_catalog_entry(catalog, component) if current is None or not current.get("_install_allowed", True): return False if component.source and current.get("_catalog_name") != component.source: return False if component.version is None: return True - selected = get_info(component.id, version=component.version) + if component.kind in ("extensions", "presets") and not current.get("version"): + # These legacy catalogs cannot attest a version; the primitive + # installer likewise accepts their unversioned current entry. + return True + selected = select_catalog_release(component, current) return ( selected is not None and selected.get("_catalog_name") == current.get("_catalog_name") @@ -99,47 +102,39 @@ def _catalog_has_release(component: ComponentRef, catalog, get_info) -> bool: def _resolved_in_catalog(root: Path, component: ComponentRef) -> bool | str | None: """Return the lookup result, a validation error, or None if unreachable.""" + from ..extensions import ExtensionError + from ..presets import PresetError + from ..workflows.catalog import StepCatalogError, WorkflowCatalogError + from . import BundlerError + from .component_catalog import CatalogUnavailable + kind = component.kind try: if kind == "presets": from ..presets import PresetCatalog catalog = PresetCatalog(root) - return _catalog_has_release(component, catalog, catalog.get_pack_info) + return _catalog_has_release(component, catalog) if kind == "extensions": from ..extensions import ExtensionCatalog catalog = ExtensionCatalog(root) - return _catalog_has_release( - component, catalog, catalog.get_extension_info - ) + return _catalog_has_release(component, catalog) if kind == "workflows": from ..workflows.catalog import WorkflowCatalog catalog = WorkflowCatalog(root) - return _catalog_has_release( - component, catalog, catalog.get_workflow_info - ) + return _catalog_has_release(component, catalog) if kind == "steps": from ..workflows.catalog import StepCatalog catalog = StepCatalog(root) - return _catalog_has_release(component, catalog, catalog.get_step_info) + return _catalog_has_release(component, catalog) except (ConnectionError, TimeoutError): return None - except Exception as exc: # noqa: BLE001 - report malformed catalog errors - from ..extensions import ExtensionCatalogFetchError - from ..presets._catalog import PresetCatalogFetchError - from ..workflows.catalog import ( - StepCatalogFetchError, - WorkflowCatalogFetchError, - ) - - if isinstance(exc, ( - ExtensionCatalogFetchError, PresetCatalogFetchError, - WorkflowCatalogFetchError, StepCatalogFetchError, - )): - return None + except CatalogUnavailable: + return None + except (BundlerError, ExtensionError, PresetError, WorkflowCatalogError, StepCatalogError) as exc: return f"Catalog lookup failed: {exc}" return None diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py index 449d2da0ad..6242dc2c05 100644 --- a/src/specify_cli/extensions/__init__.py +++ b/src/specify_cli/extensions/__init__.py @@ -180,14 +180,6 @@ class ExtensionError(Exception): pass -class ExtensionCatalogFetchError(ExtensionError): - """Raised when no configured extension catalog can be fetched.""" - - -class ExtensionCatalogValidationError(ExtensionError): - """Raised when an extension catalog response is malformed.""" - - class ValidationError(ExtensionError): """Raised when extension manifest validation fails.""" @@ -4565,13 +4557,13 @@ def _validate_catalog_payload(self, catalog_data: Any, url: str) -> None: ExtensionError: If the payload's shape is invalid. """ if not isinstance(catalog_data, dict): - raise ExtensionCatalogValidationError( + raise ExtensionError( f"Invalid catalog format from {url}: expected a JSON object" ) if "schema_version" not in catalog_data or "extensions" not in catalog_data: - raise ExtensionCatalogValidationError(f"Invalid catalog format from {url}") + raise ExtensionError(f"Invalid catalog format from {url}") if not isinstance(catalog_data.get("extensions"), dict): - raise ExtensionCatalogValidationError( + raise ExtensionError( f"Invalid catalog format from {url}: 'extensions' must be a JSON object" ) @@ -4706,7 +4698,6 @@ def _fetch_single_catalog( KeyError, TypeError, AttributeError, - RecursionError, ): # Cache validity is best-effort: invalid/missing metadata # fields, an unreadable metadata file (permissions / disk), @@ -4730,13 +4721,7 @@ def _fetch_single_catalog( cached_data = json.loads(cache_file.read_text(encoding="utf-8")) self._validate_catalog_payload(cached_data, entry.url) return cached_data - except ( - json.JSONDecodeError, - OSError, - UnicodeError, - RecursionError, - ExtensionError, - ): + except (json.JSONDecodeError, OSError, UnicodeError, ExtensionError): # Cache is best-effort: a JSON-decode failure, an OS-level # read failure (permissions / disk / handle limit), or a # text-encoding failure on a cache file written by an older @@ -4746,10 +4731,6 @@ def _fetch_single_catalog( # Fetch from network try: - from http.client import HTTPException - - from specify_cli.authentication.http import RedirectPolicyError - # Validate EVERY redirect hop, not just the terminal URL. _open_url # follows redirects; _StripAuthOnRedirect drops auth on an HTTPS->HTTP # downgrade AND whenever the redirect leaves the configured trusted @@ -4772,7 +4753,7 @@ def _validate_redirect(_old_url: str, new_url: str) -> None: read_response_limited( response, max_bytes=MAX_JSON_CATALOG_BYTES, - error_type=ExtensionCatalogValidationError, + error_type=ExtensionError, label=f"extension catalog {entry.url}", ) ) @@ -4804,38 +4785,18 @@ def _validate_redirect(_old_url: str, new_url: str) -> None: ), encoding="utf-8", ) - except (OSError, RecursionError): + except OSError: pass # Cache is best-effort; proceed with fetched data return catalog_data - except ValidationError as e: - raise ExtensionCatalogValidationError( - f"Invalid catalog URL from {entry.url}: {e}" - ) from e - except RedirectPolicyError as e: - raise ExtensionCatalogValidationError( - f"Invalid catalog redirect from {entry.url}: {e}" - ) from e - except (urllib.error.URLError, OSError, HTTPException) as e: - raise ExtensionCatalogFetchError( - f"Failed to fetch catalog from {entry.url}: {e}" - ) from e + except urllib.error.URLError as e: + raise ExtensionError(f"Failed to fetch catalog from {entry.url}: {e}") except json.JSONDecodeError as e: - raise ExtensionCatalogValidationError( - f"Invalid JSON in catalog from {entry.url}: {e}" - ) from e - except RecursionError as e: - raise ExtensionCatalogValidationError( - f"Invalid JSON nesting in catalog from {entry.url}: {e}" - ) from e - except UnicodeError as e: - raise ExtensionCatalogValidationError( - f"Invalid encoding in catalog from {entry.url}: {e}" - ) from e + raise ExtensionError(f"Invalid JSON in catalog from {entry.url}: {e}") def _get_merged_extensions( - self, force_refresh: bool = False, *, extension_id: str | None = None + self, force_refresh: bool = False ) -> List[Dict[str, Any]]: """Fetch and merge extensions from all active catalogs. @@ -4844,9 +4805,8 @@ def _get_merged_extensions( - _catalog_name: name of the source catalog - _install_allowed: whether installation is allowed from this catalog - An ID lookup stops at its first matching source and refuses malformed - higher-priority catalogs. Untargeted searches continue past malformed - sources so other catalog results remain discoverable. + Catalogs that fail to fetch are skipped. Raises ExtensionError only if + ALL catalogs fail. Args: force_refresh: If True, bypass all caches @@ -4855,33 +4815,19 @@ def _get_merged_extensions( List of merged extension dicts Raises: - ExtensionError: If no catalog is readable, or an ID lookup - encounters malformed catalog data. + ExtensionError: If all catalogs fail to fetch """ import sys active_catalogs = self.get_active_catalogs() merged: Dict[str, Dict[str, Any]] = {} any_success = False - validation_error: ExtensionCatalogValidationError | None = None - fetch_error: ExtensionCatalogFetchError | None = None for catalog_entry in active_catalogs: try: catalog_data = self._fetch_single_catalog(catalog_entry, force_refresh) any_success = True except ExtensionError as e: - if ( - isinstance(e, ExtensionCatalogValidationError) - and validation_error is None - ): - validation_error = e - if extension_id is not None and isinstance( - e, ExtensionCatalogValidationError - ): - raise - if isinstance(e, ExtensionCatalogFetchError) and fetch_error is None: - fetch_error = e print( f"Warning: Could not fetch catalog '{catalog_entry.name}': {e}", file=sys.stderr, @@ -4889,8 +4835,6 @@ def _get_merged_extensions( continue for ext_id, ext_data in catalog_data.get("extensions", {}).items(): - if extension_id is not None and ext_id != extension_id: - continue # Per-entry guard: ``_fetch_single_catalog`` already validates # that ``catalog_data["extensions"]`` is a mapping, but it # does not (and should not) validate every entry shape there @@ -4900,11 +4844,6 @@ def _get_merged_extensions( # the valid entries without crashing on ``**ext_data``. # Mirrors ``integrations/catalog.py:245``. if not isinstance(ext_data, dict): - if extension_id is not None: - raise ExtensionCatalogValidationError( - f"Invalid extension catalog entry for '{ext_id}' " - f"from {catalog_entry.url}: expected a JSON object" - ) continue if ext_id not in merged: # Higher-priority catalog wins merged[ext_id] = { @@ -4913,17 +4852,9 @@ def _get_merged_extensions( "_catalog_name": catalog_entry.name, "_install_allowed": catalog_entry.install_allowed, } - if extension_id is not None: - if fetch_error is not None: - raise fetch_error - return list(merged.values()) if not any_success and active_catalogs: - if validation_error is not None: - raise validation_error - raise ExtensionCatalogFetchError("Failed to fetch any extension catalog") - if extension_id is not None and fetch_error is not None: - raise fetch_error + raise ExtensionError("Failed to fetch any extension catalog") return list(merged.values()) @@ -4957,7 +4888,6 @@ def is_cache_valid(self) -> bool: KeyError, TypeError, AttributeError, - RecursionError, ): # ``AttributeError`` covers the case where the metadata file is # valid JSON but parses to a non-mapping (``[]``, ``"oops"``, @@ -4996,20 +4926,11 @@ def fetch_catalog(self, force_refresh: bool = False) -> Dict[str, Any]: cached_data = json.loads(self.cache_file.read_text(encoding="utf-8")) self._validate_catalog_payload(cached_data, catalog_url) return cached_data - except ( - json.JSONDecodeError, - OSError, - UnicodeError, - RecursionError, - ExtensionError, - ): + except (json.JSONDecodeError, OSError, UnicodeError, ExtensionError): pass # Fall through to network fetch try: import urllib.error - from http.client import HTTPException - - from specify_cli.authentication.http import RedirectPolicyError # Same redirect hardening as _fetch_single_catalog: validate every # redirect hop AND the final URL so this legacy single-catalog path @@ -5059,33 +4980,15 @@ def _validate_redirect(_old_url: str, new_url: str) -> None: self.cache_metadata_file.write_text( json.dumps(metadata, indent=2), encoding="utf-8" ) - except (OSError, RecursionError): + except OSError: pass # Cache is best-effort; proceed with fetched data return catalog_data - except ValidationError as e: - raise ExtensionCatalogValidationError( - f"Invalid catalog URL from {catalog_url}: {e}" - ) from e - except RedirectPolicyError as e: - raise ExtensionCatalogValidationError( - f"Invalid catalog redirect from {catalog_url}: {e}" - ) from e - except (urllib.error.URLError, OSError, HTTPException) as e: - raise ExtensionCatalogFetchError( - f"Failed to fetch catalog from {catalog_url}: {e}" - ) from e + except urllib.error.URLError as e: + raise ExtensionError(f"Failed to fetch catalog from {catalog_url}: {e}") except json.JSONDecodeError as e: - raise ExtensionError(f"Invalid JSON in catalog: {e}") from e - except RecursionError as e: - raise ExtensionCatalogValidationError( - f"Invalid JSON nesting in catalog from {catalog_url}: {e}" - ) from e - except UnicodeError as e: - raise ExtensionCatalogValidationError( - f"Invalid encoding in catalog from {catalog_url}: {e}" - ) from e + raise ExtensionError(f"Invalid JSON in catalog: {e}") def search( self, @@ -5170,7 +5073,7 @@ def get_extension_info( Extension metadata (annotated with ``_catalog_name`` and ``_install_allowed``) or None if not found. """ - all_extensions = self._get_merged_extensions(extension_id=extension_id) + all_extensions = self._get_merged_extensions() for ext_data in all_extensions: if ext_data["id"] == extension_id: from ._catalog_versions import select_release @@ -5182,7 +5085,7 @@ 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(extension_id=extension_id): + for ext_data in self._get_merged_extensions(): if ext_data["id"] == extension_id: return available_versions(ext_data) return [] diff --git a/src/specify_cli/presets/_catalog.py b/src/specify_cli/presets/_catalog.py index 357a109038..7f99443551 100644 --- a/src/specify_cli/presets/_catalog.py +++ b/src/specify_cli/presets/_catalog.py @@ -27,10 +27,6 @@ class PresetCatalogValidationError(PresetError): """A catalog supplied invalid content rather than being unreachable.""" -class PresetCatalogFetchError(PresetError): - """A configured preset catalog could not be fetched.""" - - def _decode_catalog_json(raw: str | bytes, url: str) -> Any: """Reject duplicate keys before JSON parsing discards conflicting records.""" @@ -468,7 +464,6 @@ def _fetch_single_catalog(self, entry: PresetCatalogEntry, force_refresh: bool = PresetError: If catalog cannot be fetched """ # Honor the established package-level patch points during extraction. - from ..authentication.http import RedirectPolicyError from . import MAX_JSON_CATALOG_BYTES, read_response_limited cache_file, metadata_file = self._get_cache_paths(entry.url) @@ -550,15 +545,11 @@ def _validate_redirect(_old_url: str, new_url: str) -> None: return catalog_data except (ImportError, Exception) as e: - if isinstance(e, RedirectPolicyError): - raise PresetCatalogValidationError( - f"Invalid preset catalog redirect from {entry.url}: {e}" - ) from e if isinstance(e, PresetError): raise - raise PresetCatalogFetchError( + raise PresetError( f"Failed to fetch preset catalog from {entry.url}: {e}" - ) from e + ) def _get_merged_packs( self, force_refresh: bool = False, *, pack_id: str | None = None @@ -575,7 +566,6 @@ def _get_merged_packs( active_catalogs = self.get_active_catalogs() merged: Dict[str, Dict[str, Any]] = {} first_fetch_error: PresetError | None = None - catalog_fetch_error: PresetCatalogFetchError | None = None readable_source = False sources = active_catalogs if pack_id is not None else reversed(active_catalogs) @@ -599,22 +589,16 @@ def _get_merged_packs( pack_data_with_catalog = {**pack_data, "_catalog_name": entry.name, "_install_allowed": entry.install_allowed} merged[found_id] = pack_data_with_catalog if pack_id is not None: - if first_fetch_error is not None: - raise first_fetch_error return merged - except (PresetCatalogValidationError, PresetValidationError): + except PresetCatalogValidationError: raise except PresetError as exc: if first_fetch_error is None: first_fetch_error = exc - if isinstance(exc, PresetCatalogFetchError) and catalog_fetch_error is None: - catalog_fetch_error = exc continue if not readable_source and first_fetch_error is not None: raise first_fetch_error - if pack_id is not None and catalog_fetch_error is not None: - raise catalog_fetch_error return merged def is_cache_valid(self) -> bool: @@ -671,7 +655,6 @@ def fetch_catalog(self, force_refresh: bool = False) -> Dict[str, Any]: Raises: PresetError: If catalog cannot be fetched """ - from ..authentication.http import RedirectPolicyError from . import MAX_JSON_CATALOG_BYTES, read_response_limited catalog_url = self.get_catalog_url() @@ -754,15 +737,11 @@ def _validate_redirect(_old_url: str, new_url: str) -> None: return catalog_data except (ImportError, Exception) as e: - if isinstance(e, RedirectPolicyError): - raise PresetCatalogValidationError( - f"Invalid preset catalog redirect from {catalog_url}: {e}" - ) from e if isinstance(e, PresetError): raise - raise PresetCatalogFetchError( + raise PresetError( f"Failed to fetch preset catalog from {catalog_url}: {e}" - ) from e + ) def search( self, diff --git a/src/specify_cli/workflows/catalog/__init__.py b/src/specify_cli/workflows/catalog/__init__.py index 56f24d7ae2..0e2667558b 100644 --- a/src/specify_cli/workflows/catalog/__init__.py +++ b/src/specify_cli/workflows/catalog/__init__.py @@ -29,8 +29,6 @@ def register(app: typer.Typer) -> None: "WorkflowCatalog", "WorkflowCatalogEntry", "WorkflowCatalogError", - "WorkflowCatalogFetchError", - "WorkflowCatalogValidationError", "WorkflowRegistry", "WorkflowValidationError", } @@ -38,8 +36,6 @@ def register(app: typer.Typer) -> None: "StepCatalog", "StepCatalogEntry", "StepCatalogError", - "StepCatalogFetchError", - "StepCatalogValidationError", "StepRegistry", "StepValidationError", } diff --git a/src/specify_cli/workflows/catalog/_domain.py b/src/specify_cli/workflows/catalog/_domain.py index 8a271d602f..352618fee3 100644 --- a/src/specify_cli/workflows/catalog/_domain.py +++ b/src/specify_cli/workflows/catalog/_domain.py @@ -43,14 +43,6 @@ class WorkflowCatalogError(Exception): """Base error for workflow catalog operations.""" -class WorkflowCatalogFetchError(WorkflowCatalogError): - """A configured workflow catalog could not be fetched.""" - - -class WorkflowCatalogValidationError(WorkflowCatalogError): - """A workflow catalog supplied malformed metadata.""" - - class WorkflowValidationError(WorkflowCatalogError): """Validation error for catalog config or workflow data.""" @@ -525,37 +517,18 @@ def _fetch_single_catalog( """Fetch a single catalog, using cache when possible.""" cache_file, meta_file = self._get_cache_paths(entry.url) - def validate_payload(data: Any) -> dict[str, Any]: - if not isinstance(data, dict): - raise WorkflowCatalogValidationError( - f"Catalog from {entry.url} is not a valid JSON object." - ) - if not isinstance(data.get("workflows"), (dict, list)): - raise WorkflowCatalogValidationError( - f"Catalog from {entry.url} has malformed workflows metadata." - ) - return data - if not force_refresh and self._is_url_cache_valid(entry.url): try: with open(cache_file, encoding="utf-8") as f: cached = json.load(f) - return validate_payload(cached) - except ( - UnicodeDecodeError, - json.JSONDecodeError, - RecursionError, - OSError, - WorkflowCatalogValidationError, - ): + if isinstance(cached, dict): + return cached + except (UnicodeDecodeError, json.JSONDecodeError, OSError): # Ignore invalid/unreadable cache and fall back to fetching from source. pass # Fetch from URL — validate scheme before opening and after redirects - from http.client import HTTPException from urllib.parse import urlparse - - from specify_cli.authentication.http import RedirectPolicyError from specify_cli.authentication.http import open_url as _open_url def _validate_catalog_url(url: str) -> None: @@ -569,18 +542,18 @@ def _validate_catalog_url(url: str) -> None: hostname = parsed.hostname _ = parsed.port except (TypeError, ValueError): - raise WorkflowCatalogValidationError( + raise WorkflowCatalogError( f"Refusing to fetch catalog from malformed URL: {url}" ) from None is_localhost = hostname in ("localhost", "127.0.0.1", "::1") if parsed.scheme != "https" and not ( parsed.scheme == "http" and is_localhost ): - raise WorkflowCatalogValidationError( + raise WorkflowCatalogError( f"Refusing to fetch catalog from non-HTTPS URL: {url}" ) if not hostname: - raise WorkflowCatalogValidationError( + raise WorkflowCatalogError( f"Refusing to fetch catalog from URL with no hostname: {url}" ) @@ -605,41 +578,29 @@ def _validate_redirect(_old_url: str, new_url: str) -> None: read_response_limited( resp, max_bytes=_max_json_catalog_bytes(), - error_type=WorkflowCatalogValidationError, + error_type=WorkflowCatalogError, label="workflow catalog", ).decode("utf-8") ) - except ( - WorkflowCatalogValidationError, - RedirectPolicyError, - UnicodeError, - json.JSONDecodeError, - RecursionError, - ) as exc: - raise WorkflowCatalogValidationError( - f"Invalid workflow catalog from {entry.url}: {exc}" - ) from exc - except (OSError, HTTPException) as exc: + except Exception as exc: # Fall back to cache if available if cache_file.exists(): try: with open(cache_file, encoding="utf-8") as f: cached = json.load(f) - return validate_payload(cached) - except ( - json.JSONDecodeError, - ValueError, - RecursionError, - OSError, - WorkflowCatalogValidationError, - ): + if isinstance(cached, dict): + return cached + except (json.JSONDecodeError, ValueError, OSError): # Stale-cache read failed; let the original fetch error propagate. pass - raise WorkflowCatalogFetchError( + raise WorkflowCatalogError( f"Failed to fetch catalog from {entry.url}: {exc}" ) from exc - data = validate_payload(data) + if not isinstance(data, dict): + raise WorkflowCatalogError( + f"Catalog from {entry.url} is not a valid JSON object." + ) # Write cache try: @@ -650,93 +611,47 @@ def _validate_redirect(_old_url: str, new_url: str) -> None: json.dump({"url": entry.url, "fetched_at": time.time()}, f) except OSError: pass # Proceed without caching if disk write fails - except RecursionError as exc: - raise WorkflowCatalogValidationError( - f"Invalid workflow catalog from {entry.url}: excessive nesting ({exc})" - ) from exc return data def _get_merged_workflows( - self, force_refresh: bool = False, *, workflow_id: str | None = None + self, force_refresh: bool = False ) -> dict[str, dict[str, Any]]: - """Merge for search, or resolve one ID from the first valid winning source.""" + """Merge workflows from all active catalogs (lower priority number wins).""" catalogs = self.get_active_catalogs() merged: dict[str, dict[str, Any]] = {} fetch_errors = 0 - validation_error: WorkflowCatalogValidationError | None = None - fetch_error: WorkflowCatalogFetchError | None = None - # Search uses overwrite order; exact-ID lookup visits the highest - # priority source first and stops at its matching entry. - sources = catalogs if workflow_id is not None else reversed(catalogs) - for entry in sources: + # Process later/higher-numbered entries first so earlier/lower-numbered + # entries overwrite them on workflow ID conflicts. + for entry in reversed(catalogs): try: data = self._fetch_single_catalog(entry, force_refresh) - except WorkflowCatalogError as exc: - if workflow_id is not None and isinstance( - exc, WorkflowCatalogValidationError - ): - raise - if ( - isinstance(exc, WorkflowCatalogValidationError) - and validation_error is None - ): - validation_error = exc - if isinstance(exc, WorkflowCatalogFetchError) and fetch_error is None: - fetch_error = exc + except WorkflowCatalogError: fetch_errors += 1 continue workflows = data.get("workflows", {}) # Handle both dict and list formats if isinstance(workflows, dict): for wf_id, wf_data in workflows.items(): - if workflow_id is not None and wf_id != workflow_id: - continue if not isinstance(wf_data, dict): - if workflow_id is not None: - raise WorkflowCatalogValidationError( - f"Invalid workflow catalog entry for '{wf_id}' " - f"from {entry.url}: expected a JSON object" - ) continue wf_data["_catalog_name"] = entry.name wf_data["_install_allowed"] = entry.install_allowed merged[wf_id] = wf_data elif isinstance(workflows, list): - seen_in_source: set[str] = set() for wf_data in workflows: if not isinstance(wf_data, dict): continue wf_id = wf_data.get("id", "") - if not isinstance(wf_id, str): - raise WorkflowCatalogValidationError( - f"Invalid workflow ID in catalog '{entry.name}': " - "expected a string." - ) if wf_id: - if wf_id in seen_in_source: - raise WorkflowCatalogValidationError( - f"Duplicate workflow ID '{wf_id}' in catalog '{entry.name}'." - ) - seen_in_source.add(wf_id) - if workflow_id is not None and wf_id != workflow_id: - continue wf_data["_catalog_name"] = entry.name wf_data["_install_allowed"] = entry.install_allowed merged[wf_id] = wf_data - if workflow_id is not None and workflow_id in merged: - if fetch_error is not None: - raise fetch_error - return merged if fetch_errors == len(catalogs) and catalogs: - if validation_error is not None: - raise validation_error - raise WorkflowCatalogFetchError( + raise WorkflowCatalogError( "All configured catalogs failed to fetch." ) - if workflow_id is not None and fetch_error is not None: - raise fetch_error return merged # -- Public API ------------------------------------------------------- @@ -783,7 +698,7 @@ def get_workflow_info( """Get the current or an exact advertised release from the winning source.""" from ._versions import select_release - merged = self._get_merged_workflows(workflow_id=workflow_id) + merged = self._get_merged_workflows() wf = merged.get(workflow_id) if wf is None: return None @@ -801,7 +716,7 @@ def get_workflow_version_details( """Return advertised versions and whether their source allows installation.""" from ._versions import available_versions - merged = self._get_merged_workflows(workflow_id=workflow_id) + merged = self._get_merged_workflows() wf = merged.get(workflow_id) if wf is None: return None diff --git a/src/specify_cli/workflows/step/catalog/__init__.py b/src/specify_cli/workflows/step/catalog/__init__.py index 0e65bb8387..da783f45e8 100644 --- a/src/specify_cli/workflows/step/catalog/__init__.py +++ b/src/specify_cli/workflows/step/catalog/__init__.py @@ -29,8 +29,6 @@ def register(app: typer.Typer) -> None: "StepCatalog", "StepCatalogEntry", "StepCatalogError", - "StepCatalogFetchError", - "StepCatalogValidationError", "StepRegistry", "StepValidationError", } diff --git a/src/specify_cli/workflows/step/catalog/_domain.py b/src/specify_cli/workflows/step/catalog/_domain.py index 51438ce9ac..d1f9ecf75c 100644 --- a/src/specify_cli/workflows/step/catalog/_domain.py +++ b/src/specify_cli/workflows/step/catalog/_domain.py @@ -36,19 +36,11 @@ class StepCatalogError(Exception): """Base error for step catalog operations.""" -class StepCatalogFetchError(StepCatalogError): - """A configured step catalog could not be fetched.""" - - -class StepCatalogValidationError(StepCatalogError): - """A step catalog supplied malformed metadata.""" - - class StepValidationError(StepCatalogError): """Validation error for step catalog config or step data.""" -class _DuplicateCatalogField(StepCatalogValidationError): +class _DuplicateCatalogField(StepCatalogError): """An ambiguous JSON object in a step catalog.""" @@ -488,38 +480,20 @@ def _fetch_single_catalog( cache_safe = self._is_cache_path_safe() cache_file, meta_file = self._get_cache_paths(entry.url) - def validate_payload(data: Any) -> dict[str, Any]: - if not isinstance(data, dict): - raise StepCatalogValidationError( - f"Catalog from {entry.url} is not a valid JSON object." - ) - if not isinstance(data.get("steps"), (dict, list)): - raise StepCatalogValidationError( - f"Catalog from {entry.url} has malformed steps metadata." - ) - return data - if cache_safe and not force_refresh and self._is_url_cache_valid(entry.url): try: with open(cache_file, encoding="utf-8") as f: cached = json.load(f, object_pairs_hook=_unique_json_fields) - return validate_payload(cached) + if isinstance(cached, dict): + return cached except _DuplicateCatalogField: raise - except ( - UnicodeDecodeError, - json.JSONDecodeError, - RecursionError, - OSError, - StepCatalogValidationError, - ): + except (UnicodeDecodeError, json.JSONDecodeError, OSError): # Ignore invalid/unreadable cache and fall back to fetching from source. pass - from http.client import HTTPException from urllib.parse import urlparse - from specify_cli.authentication.http import RedirectPolicyError from specify_cli.authentication.http import open_url as _open_url def _validate_url(url: str) -> None: @@ -533,18 +507,18 @@ def _validate_url(url: str) -> None: hostname = parsed.hostname _ = parsed.port except (TypeError, ValueError): - raise StepCatalogValidationError( + raise StepCatalogError( f"Refusing to fetch catalog from malformed URL: {url}" ) from None is_localhost = hostname in ("localhost", "127.0.0.1", "::1") if parsed.scheme != "https" and not ( parsed.scheme == "http" and is_localhost ): - raise StepCatalogValidationError( + raise StepCatalogError( f"Refusing to fetch catalog from non-HTTPS URL: {url}" ) if not hostname: - raise StepCatalogValidationError( + raise StepCatalogError( f"Refusing to fetch catalog from URL with no hostname: {url}" ) @@ -569,45 +543,33 @@ def _validate_redirect(_old_url: str, new_url: str) -> None: read_response_limited( resp, max_bytes=_max_json_catalog_bytes(), - error_type=StepCatalogValidationError, + error_type=StepCatalogError, label="step catalog", ).decode("utf-8"), object_pairs_hook=_unique_json_fields, ) except _DuplicateCatalogField: raise - except ( - StepCatalogValidationError, - RedirectPolicyError, - UnicodeError, - json.JSONDecodeError, - RecursionError, - ) as exc: - raise StepCatalogValidationError( - f"Invalid step catalog from {entry.url}: {exc}" - ) from exc - except (OSError, HTTPException) as exc: + except Exception as exc: if cache_safe and cache_file.exists(): try: with open(cache_file, encoding="utf-8") as f: cached = json.load(f, object_pairs_hook=_unique_json_fields) - return validate_payload(cached) + if isinstance(cached, dict): + return cached except _DuplicateCatalogField: raise - except ( - json.JSONDecodeError, - ValueError, - RecursionError, - OSError, - StepCatalogValidationError, - ): + except (json.JSONDecodeError, ValueError, OSError): # Stale-cache read failed; let the original fetch error propagate. pass - raise StepCatalogFetchError( + raise StepCatalogError( f"Failed to fetch catalog from {entry.url}: {exc}" ) from exc - data = validate_payload(data) + if not isinstance(data, dict): + raise StepCatalogError( + f"Catalog from {entry.url} is not a valid JSON object." + ) if cache_safe: try: @@ -618,55 +580,29 @@ def _validate_redirect(_old_url: str, new_url: str) -> None: json.dump({"url": entry.url, "fetched_at": time.time()}, f) except OSError: pass # Proceed without caching if disk write fails - except RecursionError as exc: - raise StepCatalogValidationError( - f"Invalid step catalog from {entry.url}: excessive nesting ({exc})" - ) from exc return data def _get_merged_steps( - self, force_refresh: bool = False, *, step_id: str | None = None + self, force_refresh: bool = False ) -> dict[str, dict[str, Any]]: - """Merge for search, or resolve one ID from the first valid winning source.""" + """Merge steps from all active catalogs (lower priority number wins).""" catalogs = self.get_active_catalogs() merged: dict[str, dict[str, Any]] = {} fetch_errors = 0 - validation_error: StepCatalogValidationError | None = None - fetch_error: StepCatalogFetchError | None = None - target_id = step_id - sources = catalogs if target_id is not None else reversed(catalogs) - for entry in sources: + for entry in reversed(catalogs): try: data = self._fetch_single_catalog(entry, force_refresh) except _DuplicateCatalogField: raise - except StepCatalogError as exc: - if target_id is not None and not isinstance( - exc, StepCatalogFetchError - ): - raise - if ( - isinstance(exc, StepCatalogValidationError) - and validation_error is None - ): - validation_error = exc - if isinstance(exc, StepCatalogFetchError) and fetch_error is None: - fetch_error = exc + except StepCatalogError: fetch_errors += 1 continue steps = data.get("steps", {}) if isinstance(steps, dict): for step_id, step_data in steps.items(): - if target_id is not None and step_id != target_id: - continue if not isinstance(step_data, dict): - if target_id is not None: - raise StepCatalogValidationError( - f"Invalid step catalog entry for '{step_id}' " - f"from {entry.url}: expected a JSON object" - ) continue step_data["_catalog_name"] = entry.name step_data["_install_allowed"] = entry.install_allowed @@ -676,35 +612,22 @@ def _get_merged_steps( for step_data in steps: if not isinstance(step_data, dict): continue - raw_step_id = step_data.get("id", "") - if not isinstance(raw_step_id, str): - raise StepCatalogValidationError( - f"Invalid step ID in catalog '{entry.name}': " - "expected a string." - ) - step_id = raw_step_id.strip() + raw_step_id = step_data.get("id") + if raw_step_id is None: + continue + step_id = str(raw_step_id).strip() if step_id: if step_id in seen_in_source: - raise StepCatalogValidationError( + raise StepCatalogError( f"Duplicate step ID '{step_id}' in catalog '{entry.name}'." ) seen_in_source.add(step_id) - if target_id is not None and step_id != target_id: - continue step_data["id"] = step_id step_data["_catalog_name"] = entry.name step_data["_install_allowed"] = entry.install_allowed merged[step_id] = step_data - if target_id is not None and target_id in merged: - if fetch_error is not None: - raise fetch_error - return merged if fetch_errors == len(catalogs) and catalogs: - if validation_error is not None: - raise validation_error - raise StepCatalogFetchError("All configured step catalogs failed to fetch.") - if target_id is not None and fetch_error is not None: - raise fetch_error + raise StepCatalogError("All configured step catalogs failed to fetch.") return merged # -- Public API ------------------------------------------------------- @@ -742,7 +665,7 @@ def get_step_info( """Get the current or an exact release from the winning catalog.""" from ._versions import select_release - merged = self._get_merged_steps(step_id=step_id) + merged = self._get_merged_steps() step = merged.get(step_id) if step: step.setdefault("id", step_id) diff --git a/tests/specify_cli/bundles/test_command_install.py b/tests/specify_cli/bundles/test_command_install.py index 4f1cc538e1..a87d3615c9 100644 --- a/tests/specify_cli/bundles/test_command_install.py +++ b/tests/specify_cli/bundles/test_command_install.py @@ -437,8 +437,11 @@ def download_extension_info(self, info): monkeypatch.setattr( ExtensionCatalog, - "get_extension_info", - lambda self, cid: {"id": cid, "version": version, "_install_allowed": True}, + "_fetch_single_catalog", + lambda self, entry, force_refresh=False: { + "schema_version": "1.0", + "extensions": {"catalog-ext": {"version": version}}, + }, ) monkeypatch.setattr( ExtensionCatalog, "download_extension_info", download_extension_info diff --git a/tests/specify_cli/bundles/test_command_validate.py b/tests/specify_cli/bundles/test_command_validate.py index b458dbbfac..cf4d015243 100644 --- a/tests/specify_cli/bundles/test_command_validate.py +++ b/tests/specify_cli/bundles/test_command_validate.py @@ -100,10 +100,11 @@ def test_validate_rejects_broken_reference(project: Path): def test_validate_warns_instead_of_rejecting_reference_during_partial_outage( project: Path, monkeypatch, ): + from urllib.error import URLError + from specify_cli.workflows.catalog import ( StepCatalog, StepCatalogEntry, - StepCatalogFetchError, ) data = valid_manifest_dict( @@ -118,7 +119,7 @@ def test_validate_warns_instead_of_rejecting_reference_during_partial_outage( def fetch(self, entry, force_refresh=False): if entry.name == "low": - raise StepCatalogFetchError("catalog timed out") + raise URLError("catalog timed out") return {"steps": {"other-step": {"version": "1.0.0"}}} monkeypatch.setattr(StepCatalog, "_fetch_single_catalog", fetch) diff --git a/tests/specify_cli/bundles/test_installer.py b/tests/specify_cli/bundles/test_installer.py index cc4992935f..baed8d8732 100644 --- a/tests/specify_cli/bundles/test_installer.py +++ b/tests/specify_cli/bundles/test_installer.py @@ -371,16 +371,24 @@ def test_source_is_checked_even_when_component_is_already_installed( ExtensionRegistry(tmp_path / ".specify" / "extensions").add( "ext-a", {"version": "1.0.0"} ) - lookups = [] + fetches = [] - def get_info(_self, _id, version=None): - lookups.append(version) + def fetch(self, source, force_refresh=False): + fetches.append(source.name) return { - "version": "1.0.0", "_catalog_name": winning, - "_install_allowed": True, + "schema_version": "1.0", + "extensions": {"ext-a": {"version": "1.0.0"}}, } - monkeypatch.setattr(ExtensionCatalog, "get_extension_info", get_info) + from specify_cli.extensions import CatalogEntry + + monkeypatch.setattr( + ExtensionCatalog, "get_active_catalogs", + lambda self: [ + CatalogEntry("https://example.com/catalog.json", winning, 1, True) + ], + ) + monkeypatch.setattr(ExtensionCatalog, "_fetch_single_catalog", fetch) data = valid_manifest_dict() data["provides"] = { "extensions": [ @@ -394,7 +402,7 @@ def get_info(_self, _id, version=None): tmp_path, _plan(manifest), DefaultPrimitiveInstaller(), manifest=manifest ) assert result.skipped == manifest.components - assert lookups == [None, "1.0.0"] + assert fetches == [winning] else: with pytest.raises(BundlerError, match="expected"): install_bundle( diff --git a/tests/specify_cli/bundles/test_primitives.py b/tests/specify_cli/bundles/test_primitives.py index d62ff0692b..4c62012aac 100644 --- a/tests/specify_cli/bundles/test_primitives.py +++ b/tests/specify_cli/bundles/test_primitives.py @@ -12,6 +12,7 @@ import pytest from specify_cli.bundler import BundlerError +from specify_cli.bundles import component_catalog from specify_cli.bundles.adapters import DefaultPrimitiveInstaller from specify_cli.bundles.manifest import ComponentRef from specify_cli.bundles.primitives import ( @@ -31,6 +32,47 @@ def _component(kind: str, cid: str = "x") -> ComponentRef: return ComponentRef(kind=kind, id=cid) +def _patch_winning_entry(monkeypatch, entry: dict | None) -> None: + monkeypatch.setattr( + component_catalog, "winning_catalog_entry", + lambda _catalog, _component: entry, + ) + + +def _patch_catalog_sources(monkeypatch, catalog_type, kind, sources, fetches=None): + """Keep the bundle resolver real while replacing only network catalog reads.""" + configured = [ + SimpleNamespace(name=name, url=f"https://example.com/{name}.json", + install_allowed=True) + for name, _entries in sources + ] + payloads = { + source.url: {kind: entries} + for source, (_name, entries) in zip(configured, sources) + } + monkeypatch.setattr(catalog_type, "get_active_catalogs", lambda self: configured) + + def fetch(_self, source): + if fetches is not None: + fetches.append(source.name) + return payloads[source.url] + + monkeypatch.setattr(catalog_type, "_fetch_single_catalog", fetch) + + +def _workflow_or_step_entry(kind: str, cid: str) -> dict: + old = {"url": "https://example.com/old/step.yml" if kind == "steps" + else "https://example.com/old/workflow.yml"} + if kind == "steps": + old["sha256"] = {"step.yml": "a" * 64, "__init__.py": "b" * 64} + else: + old["sha256"] = "a" * 64 + return { + "id": cid, "version": "2.0.0", "url": "https://example.com/latest", + "releases": {"1.0.0": old}, + } + + def test_primitive_manager_routes_each_kind(tmp_path: Path): assert isinstance(primitive_manager("presets", tmp_path), _PresetKindManager) assert isinstance(primitive_manager("extensions", tmp_path), _ExtensionKindManager) @@ -68,7 +110,6 @@ def test_offline_step_refuses_without_network(tmp_path: Path): def test_step_manager_delegates_catalog_install_from_bundle_root(tmp_path, monkeypatch): - from specify_cli.workflows.catalog import StepCatalog from specify_cli.workflows.step import command_add calls: list[tuple[str, Path]] = [] @@ -77,8 +118,8 @@ def _add(project_root, step_id, **options) -> None: calls.append((step_id, Path.cwd())) (tmp_path / ".specify").mkdir() - monkeypatch.setattr(StepCatalog, "get_step_info", lambda self, step_id: { - "id": step_id, "_catalog_name": "trusted", "_install_allowed": True, + _patch_winning_entry(monkeypatch, { + "id": "catalog-step", "_catalog_name": "trusted", "_install_allowed": True, }) monkeypatch.setattr(command_add, "_install_from_catalog", _add) manager = _StepKindManager(tmp_path, allow_network=True) @@ -89,11 +130,9 @@ def _add(project_root, step_id, **options) -> None: def test_step_manager_rejects_unsafe_id_before_catalog_lookup(tmp_path, monkeypatch): - from specify_cli.workflows.catalog import StepCatalog - monkeypatch.setattr( - StepCatalog, "get_step_info", - lambda *args, **kwargs: pytest.fail("invalid ID reached catalog lookup"), + component_catalog, "winning_catalog_entry", + lambda *args: pytest.fail("invalid ID reached catalog lookup"), ) with pytest.raises(BundlerError, match="step"): @@ -103,14 +142,14 @@ def test_step_manager_rejects_unsafe_id_before_catalog_lookup(tmp_path, monkeypa @pytest.mark.parametrize( - "kind,catalog_module,catalog_class,lookup", + "kind,catalog_module,catalog_class", [ - ("workflows", "specify_cli.workflows.catalog", "WorkflowCatalog", "get_workflow_info"), - ("steps", "specify_cli.workflows.catalog", "StepCatalog", "get_step_info"), + ("workflows", "specify_cli.workflows.catalog", "WorkflowCatalog"), + ("steps", "specify_cli.workflows.catalog", "StepCatalog"), ], ) def test_catalog_workflow_and_step_pins_delegate_exact_release( - tmp_path, monkeypatch, kind, catalog_module, catalog_class, lookup, + tmp_path, monkeypatch, kind, catalog_module, catalog_class, ): import importlib @@ -122,17 +161,12 @@ def test_catalog_workflow_and_step_pins_delegate_exact_release( if kind == "workflows": monkeypatch.setattr(assets, "_locate_bundled_workflow", lambda _id: None) catalog = getattr(importlib.import_module(catalog_module), catalog_class) - lookups = [] - - def get_info(_self, _id, version=None): - lookups.append(version) - return { - "version": version or "2.0.0", - "_catalog_name": "trusted", - "_install_allowed": True, - } - - monkeypatch.setattr(catalog, lookup, get_info) + fetches = [] + _patch_catalog_sources( + monkeypatch, catalog, kind, + [("trusted", {"catalog-id": _workflow_or_step_entry(kind, "catalog-id")})], + fetches, + ) calls = [] installer = ( (workflow_cli, "_install_workflow_from_catalog") @@ -149,11 +183,13 @@ def get_info(_self, _id, version=None): primitive_manager(kind, tmp_path).install(component) - assert lookups == [None, "1.0.0"] + assert fetches == ["trusted"] assert calls[0][0] == component.id assert calls[0][1][ "requested_version" if kind == "workflows" else "version" ] == "1.0.0" + assert calls[0][1]["selected_info"]["version"] == "1.0.0" + assert calls[0][1]["selected_info"]["_catalog_name"] == "trusted" assert calls[0][2] == tmp_path @@ -167,22 +203,21 @@ def test_catalog_component_installs_preselected_release_without_second_lookup( from specify_cli.workflows.catalog import StepCatalog, WorkflowCatalog from specify_cli.workflows.step import command_add as step_add - selected = { - "id": "catalog-id", "version": "1.0.0", "_catalog_name": "trusted", - "_install_allowed": True, "url": "https://example.com/old-release", - } - current = {**selected, "version": "2.0.0", "url": "https://example.com/latest"} - lookups = [] + catalog = WorkflowCatalog if kind == "workflows" else StepCatalog + current = _workflow_or_step_entry(kind, "catalog-id") + fetches = [] + _patch_catalog_sources( + monkeypatch, catalog, kind, [("trusted", {"catalog-id": current})], fetches, + ) (tmp_path / ".specify").mkdir() - - def get_info(_self, _id, version=None): - lookups.append(version) - return selected if version == "1.0.0" else current + monkeypatch.setattr( + catalog, "get_workflow_info" if kind == "workflows" else "get_step_info", + lambda *args, **kwargs: pytest.fail("installer re-resolved the catalog"), + ) calls = [] if kind == "workflows": monkeypatch.setattr(assets, "_locate_bundled_workflow", lambda _id: None) - monkeypatch.setattr(WorkflowCatalog, "get_workflow_info", get_info) monkeypatch.setattr( workflow_cli, "_install_workflow_from_catalog", lambda *args, **kwargs: calls.append(kwargs), @@ -192,7 +227,6 @@ def get_info(_self, _id, version=None): lambda *args, **kwargs: pytest.fail("workflow_add re-resolved the catalog"), ) else: - monkeypatch.setattr(StepCatalog, "get_step_info", get_info) monkeypatch.setattr( step_add, "_install_from_catalog", lambda *args, **kwargs: calls.append(kwargs), @@ -206,9 +240,13 @@ def get_info(_self, _id, version=None): kind=kind, id="catalog-id", version="1.0.0", source="trusted" )) - assert lookups == [None, "1.0.0"] + assert fetches == ["trusted"] assert len(calls) == 1 - assert calls[0]["selected_info"] is selected + assert calls[0]["selected_info"] == { + "id": "catalog-id", "version": "1.0.0", + **current["releases"]["1.0.0"], + "_catalog_name": "trusted", "_install_allowed": True, + } @pytest.mark.parametrize("kind", ["extensions", "presets", "workflows", "steps"]) @@ -219,26 +257,26 @@ def test_explicit_source_cannot_bypass_winning_catalog(tmp_path, monkeypatch, ki from specify_cli.workflows.catalog import StepCatalog, WorkflowCatalog catalogs = { - "extensions": (ExtensionCatalog, "get_extension_info", "_locate_bundled_extension"), - "presets": (PresetCatalog, "get_pack_info", "_locate_bundled_preset"), - "workflows": (WorkflowCatalog, "get_workflow_info", "_locate_bundled_workflow"), - "steps": (StepCatalog, "get_step_info", None), + "extensions": (ExtensionCatalog, "_locate_bundled_extension"), + "presets": (PresetCatalog, "_locate_bundled_preset"), + "workflows": (WorkflowCatalog, "_locate_bundled_workflow"), + "steps": (StepCatalog, None), } - catalog, method, asset = catalogs[kind] + catalog, asset = catalogs[kind] if asset: monkeypatch.setattr(assets, asset, lambda _id: None) - monkeypatch.setattr( - catalog, method, - lambda _self, _id, version=None: { - "version": version or "2.0.0", - "_catalog_name": "winning", - "_install_allowed": True, - }, + fetches = [] + _patch_catalog_sources( + monkeypatch, catalog, kind, [ + ("winning", {"catalog-id": {"version": "2.0.0"}}), + ("lower", {"catalog-id": {"version": "1.0.0"}}), + ], fetches, ) component = ComponentRef(kind=kind, id="catalog-id", version="1.0.0", source="lower") with pytest.raises(BundlerError, match="winning"): primitive_manager(kind, tmp_path).install(component) + assert fetches == ["winning"] @pytest.mark.parametrize("kind", ["extensions", "presets", "workflows", "steps"]) @@ -252,11 +290,11 @@ def test_primitive_install_rejects_ambiguous_catalog_source(tmp_path, monkeypatc WorkflowCatalogEntry, ) - catalog_type, entry_type, lookup = { - "extensions": (ExtensionCatalog, CatalogEntry, "get_extension_info"), - "presets": (PresetCatalog, PresetCatalogEntry, "get_pack_info"), - "workflows": (WorkflowCatalog, WorkflowCatalogEntry, "get_workflow_info"), - "steps": (StepCatalog, StepCatalogEntry, "get_step_info"), + catalog_type, entry_type = { + "extensions": (ExtensionCatalog, CatalogEntry), + "presets": (PresetCatalog, PresetCatalogEntry), + "workflows": (WorkflowCatalog, WorkflowCatalogEntry), + "steps": (StepCatalog, StepCatalogEntry), }[kind] sources = [ entry_type("https://example.com/expected.json", "trusted", 1, True), @@ -265,9 +303,9 @@ def test_primitive_install_rejects_ambiguous_catalog_source(tmp_path, monkeypatc monkeypatch.setattr(catalog_type, "get_active_catalogs", lambda self: sources) monkeypatch.setattr( catalog_type, - lookup, + "_fetch_single_catalog", lambda *_args, **_kwargs: pytest.fail( - "catalog lookup must not start with an ambiguous source" + "catalog fetch must not start with an ambiguous source" ), ) @@ -281,24 +319,27 @@ def test_primitive_install_rejects_ambiguous_catalog_source(tmp_path, monkeypatc def test_missing_exact_release_never_delegates_install(tmp_path, monkeypatch, kind): import specify_cli import specify_cli._assets as assets - from specify_cli.workflows.catalog import StepCatalog, WorkflowCatalog + from specify_cli.workflows import command_add as workflow_add + from specify_cli.workflows.step import command_add as step_add if kind == "workflows": monkeypatch.setattr(assets, "_locate_bundled_workflow", lambda _id: None) - catalog, lookup, command = ( - (WorkflowCatalog, "get_workflow_info", "workflow_add") + command = ( + "workflow_add" if kind == "workflows" - else (StepCatalog, "get_step_info", "workflow_step_add") - ) - monkeypatch.setattr( - catalog, lookup, - lambda _self, _id, version=None: ( - {"version": "2.0.0", "_catalog_name": "winning"} - if version is None else None - ), + else "workflow_step_add" ) + _patch_winning_entry(monkeypatch, { + "id": "catalog-id", "version": "2.0.0", "_catalog_name": "winning", + "_install_allowed": True, + }) calls = [] monkeypatch.setattr(specify_cli, command, lambda *a, **k: calls.append((a, k))) + monkeypatch.setattr( + workflow_add if kind == "workflows" else step_add, + "_install_preselected_workflow" if kind == "workflows" else "_install_preselected_step", + lambda *a, **k: calls.append((a, k)), + ) with pytest.raises(BundlerError, match="no catalog release"): primitive_manager(kind, tmp_path).install( @@ -310,17 +351,14 @@ def test_missing_exact_release_never_delegates_install(tmp_path, monkeypatch, ki def test_bundled_workflow_with_older_pin_uses_catalog_release(tmp_path, monkeypatch): import specify_cli._assets as assets from specify_cli.workflows import _commands as workflow_cli - from specify_cli.workflows.catalog import WorkflowCatalog (tmp_path / ".specify").mkdir() bundled = _write_manifest(tmp_path / "bundled", "workflow", "2.0.0") monkeypatch.setattr(assets, "_locate_bundled_workflow", lambda _id: bundled) - monkeypatch.setattr( - WorkflowCatalog, "get_workflow_info", - lambda _self, _id, version=None: { - "version": version or "2.0.0", "_catalog_name": "winning", - }, - ) + _patch_winning_entry(monkeypatch, { + **_workflow_or_step_entry("workflows", "x"), + "_catalog_name": "winning", "_install_allowed": True, + }) calls = [] monkeypatch.setattr( workflow_cli, "_install_workflow_from_catalog", @@ -335,26 +373,24 @@ def test_bundled_workflow_with_older_pin_uses_catalog_release(tmp_path, monkeypa assert calls == [("x", { "requested_version": "1.0.0", "selected_info": { - "version": "1.0.0", "_catalog_name": "winning", + "id": "x", "version": "1.0.0", "_catalog_name": "winning", + "_install_allowed": True, + **_workflow_or_step_entry("workflows", "x")["releases"]["1.0.0"], }, })] def test_source_on_bundled_extension_requires_catalog(tmp_path, monkeypatch): import specify_cli._assets as assets - from specify_cli.extensions import ExtensionCatalog monkeypatch.setattr( assets, "_locate_bundled_extension", lambda _id: _write_manifest(tmp_path / "bundled", "extension", "1.0.0"), ) - monkeypatch.setattr( - ExtensionCatalog, "get_extension_info", - lambda _self, _id: { - "version": "1.0.0", "_catalog_name": "other", - "_install_allowed": True, - }, - ) + _patch_winning_entry(monkeypatch, { + "version": "1.0.0", "_catalog_name": "other", + "_install_allowed": True, + }) with pytest.raises(BundlerError, match="other"): primitive_manager("extensions", tmp_path).install( ComponentRef( @@ -446,14 +482,10 @@ def test_assert_pinned_version_mismatch_raises(): def test_workflow_version_mismatch_refuses(tmp_path: Path, monkeypatch): - from specify_cli.workflows.catalog import WorkflowCatalog - - monkeypatch.setattr( - WorkflowCatalog, "get_workflow_info", - lambda self, wid, version=None: ( - {"version": "9.9.9"} if version is None else None - ), - ) + _patch_winning_entry(monkeypatch, { + "id": "wf-a", "version": "9.9.9", "_catalog_name": "trusted", + "_install_allowed": True, + }) manager = primitive_manager("workflows", tmp_path, allow_network=True) component = ComponentRef(kind="workflows", id="wf-a", version="0.3.0") with pytest.raises(BundlerError, match="no catalog release for pinned version 0.3.0"): @@ -494,15 +526,11 @@ def install_from_zip(self, *args, **kwargs): calls.append(kwargs) monkeypatch.setattr(assets, "_locate_bundled_preset", lambda _id: None) - monkeypatch.setattr( - PresetCatalog, - "get_pack_info", - lambda _self, _id: { - "version": "1.0.0", - "_install_allowed": True, - "_catalog_name": "bundle-preset-catalog", - }, - ) + _patch_winning_entry(monkeypatch, { + "version": "1.0.0", + "_install_allowed": True, + "_catalog_name": "bundle-preset-catalog", + }) monkeypatch.setattr( PresetCatalog, "download_pack_info", lambda _self, _info: archive ) @@ -541,15 +569,11 @@ def scaffold_config(self, extension_id): scaffolded.append(extension_id) monkeypatch.setattr(assets, "_locate_bundled_extension", lambda _id: None) - monkeypatch.setattr( - ExtensionCatalog, - "get_extension_info", - lambda _self, _id: { - "version": "1.0.0", - "_install_allowed": True, - "_catalog_name": "bundle-extension-catalog", - }, - ) + _patch_winning_entry(monkeypatch, { + "version": "1.0.0", + "_install_allowed": True, + "_catalog_name": "bundle-extension-catalog", + }) monkeypatch.setattr( ExtensionCatalog, "download_extension_info", lambda _self, _info: archive ) @@ -690,11 +714,7 @@ def test_catalog_extension_install_scaffolds_config(tmp_path: Path, monkeypatch) # No bundled asset located: forces the catalog/ZIP branch (install_from_zip). monkeypatch.setattr(assets, "_locate_bundled_extension", lambda cid: None) - monkeypatch.setattr( - ExtensionCatalog, - "get_extension_info", - lambda self, eid: {"id": eid, "_install_allowed": True}, - ) + _patch_winning_entry(monkeypatch, {"id": "my-ext", "_install_allowed": True}) monkeypatch.setattr( ExtensionCatalog, "download_extension_info", lambda self, info: zip_path ) @@ -978,8 +998,10 @@ def _patch_extension_catalog(monkeypatch, entry: dict, archive: Path, downloads: from specify_cli.extensions import ExtensionCatalog monkeypatch.setattr(assets, "_locate_bundled_extension", lambda _id: None) + _patch_winning_entry(monkeypatch, entry) monkeypatch.setattr( - ExtensionCatalog, "get_extension_info", lambda _self, _id: entry + ExtensionCatalog, "get_extension_info", + lambda *args, **kwargs: pytest.fail("extension catalog was re-resolved"), ) def _download(_self, info): @@ -994,7 +1016,11 @@ def _patch_preset_catalog(monkeypatch, entry: dict, archive: Path, downloads: li from specify_cli.presets import PresetCatalog monkeypatch.setattr(assets, "_locate_bundled_preset", lambda _id: None) - monkeypatch.setattr(PresetCatalog, "get_pack_info", lambda _self, _id: entry) + _patch_winning_entry(monkeypatch, entry) + monkeypatch.setattr( + PresetCatalog, "get_pack_info", + lambda *args, **kwargs: pytest.fail("preset catalog was re-resolved"), + ) def _download(_self, info): downloads.append(info) diff --git a/tests/specify_cli/bundles/test_references.py b/tests/specify_cli/bundles/test_references.py index 972bbb6b25..9840088b56 100644 --- a/tests/specify_cli/bundles/test_references.py +++ b/tests/specify_cli/bundles/test_references.py @@ -6,6 +6,8 @@ from __future__ import annotations from pathlib import Path +from types import SimpleNamespace +from urllib.error import URLError import pytest import yaml @@ -19,6 +21,20 @@ def _ref(kind: str, id_: str, version: str | None = "1.0.0") -> ComponentRef: return ComponentRef(kind=kind, id=id_, version=version) +def _mock_catalog(monkeypatch, catalog_type, kind, component_id, record, *, name="trusted"): + source = SimpleNamespace( + name=name, url="https://example.com/catalog.json", install_allowed=True + ) + monkeypatch.setattr(catalog_type, "get_active_catalogs", lambda self: [source]) + monkeypatch.setattr( + catalog_type, + "_fetch_single_catalog", + lambda self, entry, force_refresh=False: { + "schema_version": "1.0", kind: {component_id: record} + }, + ) + + def test_bundled_extension_resolves(tmp_path: Path): root = make_project(tmp_path) warnings: list[str] = [] @@ -94,9 +110,7 @@ def test_unknown_step_type_still_errors_online(tmp_path: Path, monkeypatch): """The guard must not make every step id resolve.""" from specify_cli.workflows.catalog import StepCatalog - monkeypatch.setattr( - StepCatalog, "get_step_info", lambda self, _id, version=None: None - ) + _mock_catalog(monkeypatch, StepCatalog, "steps", "other-step", {"version": "1.0.0"}) root = make_project(tmp_path) warnings: list[str] = [] check = make_reference_checker(root, allow_network=True, warnings=warnings) @@ -129,13 +143,9 @@ def test_wrong_bundled_extension_pin_is_definitive( from specify_cli.extensions import ExtensionCatalog root = make_project(tmp_path) - monkeypatch.setattr( - ExtensionCatalog, "get_extension_info", - lambda self, _id, version=None: { - "version": version or "999.0.0", - "_catalog_name": "trusted", - "_install_allowed": True, - }, + _mock_catalog( + monkeypatch, ExtensionCatalog, "extensions", + "agent-context", {"version": "999.0.0"}, ) warnings = [] check = make_reference_checker(root, allow_network=allow_network, warnings=warnings) @@ -166,13 +176,8 @@ def test_bundled_preset_pin_mismatch_is_definitive( "preset:\n id: requested\n version: 1.0.0\n", encoding="utf-8" ) monkeypatch.setattr(assets, "_locate_bundled_preset", lambda _id: bundled) - monkeypatch.setattr( - PresetCatalog, "get_pack_info", - lambda self, _id, version=None: { - "version": version or "2.0.0", - "_catalog_name": "trusted", - "_install_allowed": True, - }, + _mock_catalog( + monkeypatch, PresetCatalog, "presets", "requested", {"version": "2.0.0"} ) warnings = [] check = make_reference_checker(tmp_path, allow_network=allow_network, warnings=warnings) @@ -216,31 +221,26 @@ def test_bundled_preset_mismatch_allows_matching_installed_version( assert warnings == [] -@pytest.mark.parametrize("legacy", [False, True]) -def test_extension_http_protocol_error_is_unreachable_catalog( - tmp_path, monkeypatch, legacy, +@pytest.mark.parametrize("kind", ["extensions", "presets"]) +def test_bundle_http_protocol_error_is_unreachable_catalog( + tmp_path, monkeypatch, kind, ): from http.client import BadStatusLine - from specify_cli.extensions import ( - CatalogEntry, - ExtensionCatalog, - ExtensionCatalogFetchError, - ) + from specify_cli.extensions import ExtensionCatalog + from specify_cli.presets import PresetCatalog - catalog = ExtensionCatalog(tmp_path) - entry = CatalogEntry("https://example.com/catalog.json", "trusted", 1, True) - monkeypatch.setattr(catalog, "get_catalog_url", lambda: entry.url) + catalog_type = ExtensionCatalog if kind == "extensions" else PresetCatalog + _mock_catalog(monkeypatch, catalog_type, kind, "requested", {}) - def bad_status(*args, **kwargs): + def bad_status(self, source, force_refresh=False): raise BadStatusLine("bad response") - monkeypatch.setattr(catalog, "_open_url", bad_status) - with pytest.raises(ExtensionCatalogFetchError, match="bad response"): - if legacy: - catalog.fetch_catalog(force_refresh=True) - else: - catalog._fetch_single_catalog(entry, force_refresh=True) + monkeypatch.setattr(catalog_type, "_fetch_single_catalog", bad_status) + warnings = [] + check = make_reference_checker(tmp_path, allow_network=True, warnings=warnings) + assert check(_ref(kind, "requested")) is None + assert len(warnings) == 1 and "unreachable" in warnings[0] def test_online_validation_warns_for_extension_http_protocol_error( @@ -271,23 +271,27 @@ def test_online_validation_checks_winning_exact_release_and_source(tmp_path, mon from specify_cli.workflows.catalog import WorkflowCatalog monkeypatch.setattr(assets, "_locate_bundled_workflow", lambda _id: None) - lookups = [] - - def lookup(_self, _id, version=None): - lookups.append(version) - if version == "1.0.0": - return { - "version": "1.0.0", "_catalog_name": "winning", - "_install_allowed": True, - } - if version is None: - return { - "version": "2.0.0", "_catalog_name": "winning", - "_install_allowed": True, - } - return None - - monkeypatch.setattr(WorkflowCatalog, "get_workflow_info", lookup) + fetches = [] + _mock_catalog( + monkeypatch, WorkflowCatalog, "workflows", "catalog-workflow", + { + "version": "2.0.0", + "releases": { + "1.0.0": { + "url": "https://example.com/old.yml", + "sha256": "a" * 64, + } + }, + }, + name="winning", + ) + original_fetch = WorkflowCatalog._fetch_single_catalog + + def fetch(self, source, force_refresh=False): + fetches.append(source.name) + return original_fetch(self, source, force_refresh) + + monkeypatch.setattr(WorkflowCatalog, "_fetch_single_catalog", fetch) warnings = [] check = make_reference_checker(tmp_path, allow_network=True, warnings=warnings) requested = ComponentRef( @@ -295,7 +299,7 @@ def lookup(_self, _id, version=None): ) assert check(requested) is None - assert lookups == [None, "1.0.0"] + assert fetches == ["winning"] assert check(ComponentRef(kind="workflows", id=requested.id, version="3.0.0")) assert check(ComponentRef( kind="workflows", id=requested.id, version="1.0.0", source="lower" @@ -304,13 +308,14 @@ def lookup(_self, _id, version=None): def test_online_validation_rejects_discovery_only_exact_release(tmp_path, monkeypatch): - from specify_cli.workflows.catalog import StepCatalog + from specify_cli.workflows.catalog import StepCatalog, StepCatalogEntry + source = StepCatalogEntry("https://example.com/catalog.json", "winning", 1, False) + monkeypatch.setattr(StepCatalog, "get_active_catalogs", lambda self: [source]) monkeypatch.setattr( - StepCatalog, "get_step_info", - lambda _self, _id, version=None: { - "version": version or "2.0.0", "_catalog_name": "winning", - "_install_allowed": version is None, + StepCatalog, "_fetch_single_catalog", + lambda self, entry, force_refresh=False: { + "steps": {"catalog-step": {"version": "2.0.0"}} }, ) warnings = [] @@ -321,12 +326,17 @@ def test_online_validation_rejects_discovery_only_exact_release(tmp_path, monkey def test_online_validation_reports_invalid_release_metadata(tmp_path, monkeypatch): - from specify_cli.workflows.catalog import StepCatalog, StepCatalogError - - def invalid_release(_self, _id, version=None): - raise StepCatalogError("Step release needs a SHA-256 digest.") + from specify_cli.workflows.catalog import StepCatalog - monkeypatch.setattr(StepCatalog, "get_step_info", invalid_release) + _mock_catalog( + monkeypatch, StepCatalog, "steps", "invalid-release", + { + "version": "2.0.0", + "releases": { + "1.0.0": {"step_yml_url": "https://example.com/step.yml"} + }, + }, + ) warnings = [] check = make_reference_checker(tmp_path, allow_network=True, warnings=warnings) @@ -335,12 +345,14 @@ def invalid_release(_self, _id, version=None): def test_online_validation_warns_when_catalogs_are_unreachable(tmp_path, monkeypatch): - from specify_cli.workflows.catalog import WorkflowCatalog, WorkflowCatalogFetchError + from specify_cli.workflows.catalog import WorkflowCatalog + + _mock_catalog(monkeypatch, WorkflowCatalog, "workflows", "unreachable", {}) - def unavailable(_self, _id, version=None): - raise WorkflowCatalogFetchError("All configured catalogs failed to fetch.") + def unavailable(self, source, force_refresh=False): + raise URLError("timed out") - monkeypatch.setattr(WorkflowCatalog, "get_workflow_info", unavailable) + monkeypatch.setattr(WorkflowCatalog, "_fetch_single_catalog", unavailable) warnings = [] check = make_reference_checker(tmp_path, allow_network=True, warnings=warnings) @@ -363,27 +375,20 @@ def unavailable(_self, _id, version=None): def test_online_validation_distinguishes_partial_outage_from_missing_reference( tmp_path, monkeypatch, kind, unreachable, has_match, ): - from specify_cli.extensions import ( - CatalogEntry, - ExtensionCatalog, - ExtensionCatalogFetchError, - ) + from specify_cli.extensions import CatalogEntry, ExtensionCatalog from specify_cli.presets import PresetCatalog, PresetCatalogEntry - from specify_cli.presets._catalog import PresetCatalogFetchError from specify_cli.workflows.catalog import ( StepCatalog, StepCatalogEntry, - StepCatalogFetchError, WorkflowCatalog, WorkflowCatalogEntry, - WorkflowCatalogFetchError, ) - catalog, entry_type, fetch_error = { - "extensions": (ExtensionCatalog, CatalogEntry, ExtensionCatalogFetchError), - "presets": (PresetCatalog, PresetCatalogEntry, PresetCatalogFetchError), - "workflows": (WorkflowCatalog, WorkflowCatalogEntry, WorkflowCatalogFetchError), - "steps": (StepCatalog, StepCatalogEntry, StepCatalogFetchError), + catalog, entry_type = { + "extensions": (ExtensionCatalog, CatalogEntry), + "presets": (PresetCatalog, PresetCatalogEntry), + "workflows": (WorkflowCatalog, WorkflowCatalogEntry), + "steps": (StepCatalog, StepCatalogEntry), }[kind] sources = [ entry_type("https://example.com/high.json", "high", 1, True), @@ -395,7 +400,7 @@ def test_online_validation_distinguishes_partial_outage_from_missing_reference( def fetch(self, entry, force_refresh=False): visited.append(entry.name) if entry.name == unreachable: - raise fetch_error("catalog timed out") + raise URLError("catalog timed out") contents = {"requested": {"version": "1.0.0"}} if has_match else {} return {kind: contents} @@ -404,20 +409,24 @@ def fetch(self, entry, force_refresh=False): check = make_reference_checker(tmp_path, allow_network=True, warnings=warnings) problem = check(_ref(kind, "requested")) - if has_match and unreachable == "high": + if unreachable == "high": assert problem is None assert len(warnings) == 1 assert "unreachable" in warnings[0] - assert visited == ["high", "low"] - elif has_match: + assert visited == ["high"] + elif has_match and unreachable == "low": assert problem is None assert warnings == [] - assert visited == ["high", "high"] + assert visited == ["high"] elif unreachable is not None: assert problem is None assert len(warnings) == 1 assert "unreachable" in warnings[0] assert visited == ["high", "low"] + elif has_match: + assert problem is None + assert warnings == [] + assert visited == ["high"] else: assert problem is not None and "not available" in problem assert warnings == [] @@ -428,25 +437,16 @@ def fetch(self, entry, force_refresh=False): def test_online_validation_warns_for_unreachable_component_catalog( tmp_path, monkeypatch, kind, ): - from specify_cli.extensions import ExtensionCatalog, ExtensionCatalogFetchError + from specify_cli.extensions import ExtensionCatalog from specify_cli.presets import PresetCatalog - from specify_cli.presets._catalog import PresetCatalogFetchError - if kind == "extensions": - catalog, method, failure = ( - ExtensionCatalog, "get_extension_info", - ExtensionCatalogFetchError("Failed to fetch any extension catalog"), - ) - else: - catalog, method, failure = ( - PresetCatalog, "get_pack_info", - PresetCatalogFetchError("Failed to fetch preset catalog from https://example.com: timed out"), - ) + catalog = ExtensionCatalog if kind == "extensions" else PresetCatalog + _mock_catalog(monkeypatch, catalog, kind, "unreachable-component", {}) - def unavailable(_self, _id, version=None): - raise failure + def unavailable(self, source, force_refresh=False): + raise URLError("timed out") - monkeypatch.setattr(catalog, method, unavailable) + monkeypatch.setattr(catalog, "_fetch_single_catalog", unavailable) warnings = [] check = make_reference_checker(tmp_path, allow_network=True, warnings=warnings) @@ -459,24 +459,39 @@ def unavailable(_self, _id, version=None): def test_online_validation_rejects_malformed_component_release( tmp_path, monkeypatch, kind, ): - from specify_cli.extensions import ExtensionCatalog, ExtensionError + from specify_cli.extensions import ExtensionCatalog from specify_cli.presets import PresetCatalog - from specify_cli.presets._manifest import PresetError - catalog, method, failure = ( - (ExtensionCatalog, "get_extension_info", ExtensionError("Invalid release digest")) - if kind == "extensions" - else (PresetCatalog, "get_pack_info", PresetError("Invalid release digest")) + catalog = ExtensionCatalog if kind == "extensions" else PresetCatalog + _mock_catalog( + monkeypatch, catalog, kind, "invalid-component", + { + "version": "2.0.0", + "releases": { + "1.0.0": {"download_url": "https://example.com/release.zip"} + }, + }, ) + warnings = [] + check = make_reference_checker(tmp_path, allow_network=True, warnings=warnings) - def invalid(_self, _id, version=None): - raise failure + assert "SHA-256" in check(_ref(kind, "invalid-component")) + assert warnings == [] - monkeypatch.setattr(catalog, method, invalid) + +@pytest.mark.parametrize("kind", ["extensions", "presets"]) +def test_online_validation_accepts_unversioned_legacy_component( + tmp_path, monkeypatch, kind, +): + from specify_cli.extensions import ExtensionCatalog + from specify_cli.presets import PresetCatalog + + catalog = ExtensionCatalog if kind == "extensions" else PresetCatalog + _mock_catalog(monkeypatch, catalog, kind, "legacy-component", {}) warnings = [] check = make_reference_checker(tmp_path, allow_network=True, warnings=warnings) - assert "Invalid release digest" in check(_ref(kind, "invalid-component")) + assert check(_ref(kind, "legacy-component", "1.0.0")) is None assert warnings == [] @@ -704,32 +719,28 @@ def open_url(self, url, **kwargs): assert warnings == [] -@pytest.mark.parametrize("legacy", [False, True]) -def test_extension_catalog_invalid_utf8_is_validation_error( - tmp_path, monkeypatch, legacy, +@pytest.mark.parametrize("kind", ["extensions", "presets"]) +def test_bundle_invalid_utf8_catalog_is_validation_error( + tmp_path, monkeypatch, kind, ): import io - from specify_cli.extensions import ( - CatalogEntry, - ExtensionCatalog, - ExtensionCatalogValidationError, - ) + from specify_cli.extensions import ExtensionCatalog + from specify_cli.presets import PresetCatalog class Response(io.BytesIO): def geturl(self): return "https://example.com/catalog.json" - catalog = ExtensionCatalog(tmp_path) - entry = CatalogEntry("https://example.com/catalog.json", "bad", 1, True) - monkeypatch.setattr(catalog, "get_catalog_url", lambda: entry.url) - monkeypatch.setattr(catalog, "_open_url", lambda url, **kwargs: Response(b"\xff")) - - with pytest.raises(ExtensionCatalogValidationError, match="encoding"): - if legacy: - catalog.fetch_catalog(force_refresh=True) - else: - catalog._fetch_single_catalog(entry, force_refresh=True) + catalog_type = ExtensionCatalog if kind == "extensions" else PresetCatalog + original_fetch = catalog_type._fetch_single_catalog + _mock_catalog(monkeypatch, catalog_type, kind, "requested", {}) + monkeypatch.setattr(catalog_type, "_open_url", lambda self, url, **kw: Response(b"\xff")) + monkeypatch.setattr(catalog_type, "_fetch_single_catalog", original_fetch) + warnings = [] + check = make_reference_checker(tmp_path, allow_network=True, warnings=warnings) + assert "decode" in check(_ref(kind, "requested")) + assert warnings == [] def test_online_validation_does_not_skip_invalid_utf8_extension_catalog( @@ -768,47 +779,36 @@ def open_url(self, url, **kwargs): warnings = [] check = make_reference_checker(tmp_path, allow_network=True, warnings=warnings) - assert "Invalid encoding" in check(_ref("extensions", "requested")) + assert "decode" in check(_ref("extensions", "requested")) assert warnings == [] @pytest.mark.parametrize("kind", ["extensions", "presets"]) -@pytest.mark.parametrize("legacy", [False, True]) -def test_catalog_redirect_policy_is_validation_error( - tmp_path, monkeypatch, kind, legacy, +@pytest.mark.parametrize("status", [404, 503]) +def test_bundle_catalog_http_errors_preserve_failure_type( + tmp_path, monkeypatch, kind, status, ): - from specify_cli.authentication.http import RedirectPolicyError - from specify_cli.extensions import ( - CatalogEntry, - ExtensionCatalog, - ExtensionCatalogValidationError, - ) - from specify_cli.presets import ( - PresetCatalog, - PresetCatalogEntry, - ) - from specify_cli.presets._catalog import PresetCatalogValidationError + from urllib.error import HTTPError - catalog_type, entry_type, error_type = ( - (ExtensionCatalog, CatalogEntry, ExtensionCatalogValidationError) - if kind == "extensions" - else (PresetCatalog, PresetCatalogEntry, PresetCatalogValidationError) - ) - catalog = catalog_type(tmp_path) - url = "https://example.com/catalog.json" - monkeypatch.setattr(catalog, "get_catalog_url", lambda: url) + from specify_cli.extensions import ExtensionCatalog + from specify_cli.presets import PresetCatalog - def unsafe_redirect(*args, **kwargs): - raise RedirectPolicyError("unsafe catalog redirect") + catalog_type = ExtensionCatalog if kind == "extensions" else PresetCatalog + _mock_catalog(monkeypatch, catalog_type, kind, "requested", {}) - monkeypatch.setattr(catalog, "_open_url", unsafe_redirect) - with pytest.raises(error_type, match="unsafe catalog redirect"): - if legacy: - catalog.fetch_catalog(force_refresh=True) - else: - catalog._fetch_single_catalog( - entry_type(url, "trusted", 1, True), force_refresh=True - ) + def failed(self, source, force_refresh=False): + raise HTTPError(source.url, status, "catalog failure", {}, None) + + monkeypatch.setattr(catalog_type, "_fetch_single_catalog", failed) + warnings = [] + check = make_reference_checker(tmp_path, allow_network=True, warnings=warnings) + problem = check(_ref(kind, "requested")) + if status == 503: + assert problem is None + assert len(warnings) == 1 and "unreachable" in warnings[0] + else: + assert problem is not None and "404" in problem + assert warnings == [] @pytest.mark.parametrize("kind", ["workflows", "steps"]) @@ -924,124 +924,54 @@ def open_url(url, **kwargs): @pytest.mark.parametrize("kind", ["workflows", "steps"]) @pytest.mark.parametrize("source", ["network", "fresh-cache", "stale-cache"]) -def test_deeply_nested_catalog_is_handled_as_malformed_data( +def test_bundle_catalog_handles_recursion_without_fallback( tmp_path, monkeypatch, kind, source, ): - import io - import json - from urllib.error import URLError - - from specify_cli.authentication import http from specify_cli.workflows.catalog import ( StepCatalog, - StepCatalogEntry, - StepCatalogFetchError, - StepCatalogValidationError, WorkflowCatalog, - WorkflowCatalogEntry, - WorkflowCatalogFetchError, - WorkflowCatalogValidationError, ) - catalog_type, entry_type, validation_error, fetch_error = { - "workflows": ( - WorkflowCatalog, WorkflowCatalogEntry, - WorkflowCatalogValidationError, WorkflowCatalogFetchError, - ), - "steps": ( - StepCatalog, StepCatalogEntry, - StepCatalogValidationError, StepCatalogFetchError, - ), - }[kind] - catalog = catalog_type(tmp_path) - entry = entry_type("https://example.com/catalog.json", "trusted", 1, True) - nested = ( - b'{"' + kind.encode() + b'":{},"nested":' - + b"[" * 10000 + b"0" + b"]" * 10000 + b"}" - ) - valid = {"schema_version": "1.0", kind: {"requested": {"version": "1.0.0"}}} - - class Response(io.BytesIO): - def geturl(self): - return entry.url - - if source != "network": - cache_file, _ = catalog._get_cache_paths(entry.url) - cache_file.parent.mkdir(parents=True, exist_ok=True) - cache_file.write_bytes(nested) - monkeypatch.setattr( - catalog, "_is_url_cache_valid", lambda _url: source == "fresh-cache" - ) - - def read_nested_cache(*args, **kwargs): - raise RecursionError("catalog nesting limit exceeded") - - monkeypatch.setattr(json, "load", read_nested_cache) - else: - original_loads = json.loads - - def decode_nested_catalog(raw, *args, **kwargs): - if raw == nested.decode("utf-8"): - raise RecursionError("catalog nesting limit exceeded") - return original_loads(raw, *args, **kwargs) - - monkeypatch.setattr(json, "loads", decode_nested_catalog) + catalog_type = WorkflowCatalog if kind == "workflows" else StepCatalog + _mock_catalog(monkeypatch, catalog_type, kind, "requested", {"version": "1.0.0"}) - def open_url(*args, **kwargs): + def fetch(self, entry, force_refresh=False): + if source == "fresh-cache": + return {kind: {"requested": {"version": "1.0.0"}}} if source == "stale-cache": raise URLError("connection failed") - payload = nested if source == "network" else json.dumps(valid).encode() - return Response(payload) + raise RecursionError("catalog nesting limit exceeded") - monkeypatch.setattr(http, "open_url", open_url) + monkeypatch.setattr(catalog_type, "_fetch_single_catalog", fetch) + warnings = [] + check = make_reference_checker(tmp_path, allow_network=True, warnings=warnings) + problem = check(_ref(kind, "requested")) if source == "network": - with pytest.raises(validation_error, match="Invalid.*catalog"): - catalog._fetch_single_catalog(entry, force_refresh=True) + assert problem is not None and "nesting limit exceeded" in problem + assert warnings == [] elif source == "stale-cache": - with pytest.raises(fetch_error, match="Failed to fetch catalog"): - catalog._fetch_single_catalog(entry) + assert problem is None + assert len(warnings) == 1 and "unreachable" in warnings[0] else: - assert catalog._fetch_single_catalog(entry) == valid + assert problem is None + assert warnings == [] @pytest.mark.parametrize("kind", ["workflows", "steps"]) -def test_deep_catalog_does_not_escape_during_cache_write( - tmp_path, monkeypatch, kind, -): - import io +def test_bundle_catalog_handles_cache_write_recursion(tmp_path, monkeypatch, kind): + from specify_cli.workflows.catalog import StepCatalog, WorkflowCatalog - from specify_cli.authentication import http - from specify_cli.workflows.catalog import ( - StepCatalog, - StepCatalogEntry, - StepCatalogValidationError, - WorkflowCatalog, - WorkflowCatalogEntry, - WorkflowCatalogValidationError, - ) - - catalog_type, entry_type, error_type = { - "workflows": ( - WorkflowCatalog, WorkflowCatalogEntry, WorkflowCatalogValidationError, - ), - "steps": (StepCatalog, StepCatalogEntry, StepCatalogValidationError), - }[kind] - catalog = catalog_type(tmp_path) - entry = entry_type("https://example.com/catalog.json", "trusted", 1, True) - payload = ( - b'{"' + kind.encode() + b'":{},"nested":' - + b"[" * 1200 + b"0" + b"]" * 1200 + b"}" - ) + catalog_type = WorkflowCatalog if kind == "workflows" else StepCatalog + _mock_catalog(monkeypatch, catalog_type, kind, "requested", {"version": "1.0.0"}) - class Response(io.BytesIO): - def geturl(self): - return entry.url + def fetch(self, entry, force_refresh=False): + raise RecursionError("cache write nesting limit exceeded") - monkeypatch.setattr( - http, "open_url", lambda *args, **kwargs: Response(payload) - ) - with pytest.raises(error_type, match="Invalid.*catalog"): - catalog._fetch_single_catalog(entry, force_refresh=True) + monkeypatch.setattr(catalog_type, "_fetch_single_catalog", fetch) + warnings = [] + check = make_reference_checker(tmp_path, allow_network=True, warnings=warnings) + assert "cache write nesting limit exceeded" in check(_ref(kind, "requested")) + assert warnings == [] @pytest.mark.parametrize("kind", ["extensions", "workflows", "steps"]) @@ -1083,6 +1013,146 @@ def fetch(self, source, force_refresh=False): assert warnings == [] +@pytest.mark.parametrize("kind", ["extensions", "presets", "workflows", "steps"]) +@pytest.mark.parametrize("malformed", [None, 42, "invalid", True]) +def test_bundle_rejects_malformed_higher_source_before_lower_match( + tmp_path, monkeypatch, kind, malformed, +): + from specify_cli.extensions import CatalogEntry, ExtensionCatalog + from specify_cli.presets import PresetCatalog, PresetCatalogEntry + from specify_cli.workflows.catalog import ( + StepCatalog, + StepCatalogEntry, + WorkflowCatalog, + WorkflowCatalogEntry, + ) + + catalog, entry_type = { + "extensions": (ExtensionCatalog, CatalogEntry), + "presets": (PresetCatalog, PresetCatalogEntry), + "workflows": (WorkflowCatalog, WorkflowCatalogEntry), + "steps": (StepCatalog, StepCatalogEntry), + }[kind] + sources = [ + entry_type("https://example.com/high.json", "high", 1, True), + entry_type("https://example.com/low.json", "low", 2, True), + ] + monkeypatch.setattr(catalog, "get_active_catalogs", lambda self: sources) + fetched = [] + + def fetch(self, source, force_refresh=False): + fetched.append(source.name) + if source.name == "high": + return {"schema_version": "1.0", kind: malformed} + return {"schema_version": "1.0", kind: {"requested": {"version": "1.0.0"}}} + + monkeypatch.setattr(catalog, "_fetch_single_catalog", fetch) + warnings = [] + check = make_reference_checker(tmp_path, allow_network=True, warnings=warnings) + + problem = check(_ref(kind, "requested")) + assert problem is not None and "malformed" in problem + assert fetched == ["high"] + assert warnings == [] + + +@pytest.mark.parametrize("kind", ["workflows", "steps"]) +@pytest.mark.parametrize("duplicate_id", ["requested", "other"]) +def test_bundle_rejects_duplicate_list_ids_before_lower_source( + tmp_path, monkeypatch, kind, duplicate_id, +): + from specify_cli.workflows.catalog import ( + StepCatalog, + StepCatalogEntry, + WorkflowCatalog, + WorkflowCatalogEntry, + ) + + catalog, entry_type = ( + (WorkflowCatalog, WorkflowCatalogEntry) + if kind == "workflows" else (StepCatalog, StepCatalogEntry) + ) + sources = [ + entry_type("https://example.com/high.json", "high", 1, True), + entry_type("https://example.com/low.json", "low", 2, True), + ] + monkeypatch.setattr(catalog, "get_active_catalogs", lambda self: sources) + visited = [] + + def fetch(self, source, force_refresh=False): + visited.append(source.name) + if source.name == "low": + pytest.fail("duplicate IDs in the higher source were ignored") + return {kind: [ + {"id": "requested", "version": "1.0.0"}, + {"id": duplicate_id, "version": "2.0.0"}, + {"id": duplicate_id, "version": "3.0.0"}, + ]} + + monkeypatch.setattr(catalog, "_fetch_single_catalog", fetch) + warnings = [] + check = make_reference_checker(tmp_path, allow_network=True, warnings=warnings) + assert f"Duplicate {kind[:-1]} ID '{duplicate_id}'" in check(_ref(kind, "requested")) + assert visited == ["high"] + assert warnings == [] + + +@pytest.mark.parametrize("kind", ["workflows", "steps"]) +@pytest.mark.parametrize("invalid_id", [{"bad": "id"}, ["bad"], 42, True, None, ""]) +def test_bundle_rejects_invalid_list_ids( + tmp_path, monkeypatch, kind, invalid_id, +): + from specify_cli.workflows.catalog import StepCatalog, WorkflowCatalog + + catalog = WorkflowCatalog if kind == "workflows" else StepCatalog + _mock_catalog(monkeypatch, catalog, kind, "requested", {}) + monkeypatch.setattr( + catalog, "_fetch_single_catalog", + lambda self, source, force_refresh=False: { + kind: [ + {"id": "requested", "version": "1.0.0"}, + {"id": invalid_id, "version": "2.0.0"}, + ] + }, + ) + warnings = [] + check = make_reference_checker(tmp_path, allow_network=True, warnings=warnings) + assert f"Invalid {kind[:-1]} ID" in check(_ref(kind, "requested")) + assert warnings == [] + + +@pytest.mark.parametrize("kind", ["workflows", "steps"]) +def test_bundle_accepts_list_id_from_first_source(tmp_path, monkeypatch, kind): + from specify_cli.workflows.catalog import ( + StepCatalog, + StepCatalogEntry, + WorkflowCatalog, + WorkflowCatalogEntry, + ) + + catalog, entry_type = ( + (WorkflowCatalog, WorkflowCatalogEntry) + if kind == "workflows" else (StepCatalog, StepCatalogEntry) + ) + sources = [ + entry_type("https://example.com/high.json", "high", 1, True), + entry_type("https://example.com/low.json", "low", 2, True), + ] + monkeypatch.setattr(catalog, "get_active_catalogs", lambda self: sources) + visited = [] + + def fetch(self, source, force_refresh=False): + visited.append(source.name) + return {kind: [{"id": "requested", "version": source.name}]} + + monkeypatch.setattr(catalog, "_fetch_single_catalog", fetch) + warnings = [] + check = make_reference_checker(tmp_path, allow_network=True, warnings=warnings) + assert check(ComponentRef(kind=kind, id="requested")) is None + assert visited == ["high"] + assert warnings == [] + + @pytest.mark.parametrize("kind", ["extensions", "presets", "workflows", "steps"]) @pytest.mark.parametrize("duplicate_name", [False, True]) def test_explicit_source_requires_unique_catalog_name( @@ -1097,13 +1167,13 @@ def test_explicit_source_requires_unique_catalog_name( WorkflowCatalog, ) - catalog_type, lookup, env_key = { - "extensions": (ExtensionCatalog, "get_extension_info", "SPECKIT_CATALOG_URL"), - "presets": (PresetCatalog, "get_pack_info", "SPECKIT_PRESET_CATALOG_URL"), + catalog_type, env_key = { + "extensions": (ExtensionCatalog, "SPECKIT_CATALOG_URL"), + "presets": (PresetCatalog, "SPECKIT_PRESET_CATALOG_URL"), "workflows": ( - WorkflowCatalog, "get_workflow_info", "SPECKIT_WORKFLOW_CATALOG_URL" + WorkflowCatalog, "SPECKIT_WORKFLOW_CATALOG_URL" ), - "steps": (StepCatalog, "get_step_info", "SPECKIT_STEP_CATALOG_URL"), + "steps": (StepCatalog, "SPECKIT_STEP_CATALOG_URL"), }[kind] monkeypatch.delenv(env_key, raising=False) config = tmp_path / ".specify" / f"{kind[:-1]}-catalogs.yml" @@ -1131,12 +1201,10 @@ def test_explicit_source_requires_unique_catalog_name( ] monkeypatch.setattr( catalog_type, - lookup, - lambda self, component_id, version=None: { - "id": component_id, - "version": version or "1.0.0", - "_catalog_name": "trusted", - "_install_allowed": True, + "_fetch_single_catalog", + lambda self, source, force_refresh=False: { + "schema_version": "1.0", + kind: {"requested": {"version": "1.0.0"}}, }, ) ref = ComponentRef(kind=kind, id="requested", source="trusted") diff --git a/tests/specify_cli/extensions/test_catalog_versions.py b/tests/specify_cli/extensions/test_catalog_versions.py index 3917c414cb..404256baa3 100644 --- a/tests/specify_cli/extensions/test_catalog_versions.py +++ b/tests/specify_cli/extensions/test_catalog_versions.py @@ -3,7 +3,6 @@ from __future__ import annotations import hashlib -import json import zipfile from io import BytesIO from pathlib import Path @@ -16,7 +15,6 @@ from specify_cli.extensions import ( CatalogEntry, ExtensionCatalog, - ExtensionCatalogValidationError, ExtensionError, ExtensionManifest, ) @@ -71,8 +69,7 @@ def _catalog( monkeypatch: pytest.MonkeyPatch, project: Path, entry: dict ) -> ExtensionCatalog: monkeypatch.setattr( - ExtensionCatalog, "_get_merged_extensions", - lambda self, *, extension_id=None: [entry] + ExtensionCatalog, "_get_merged_extensions", lambda self: [entry] ) return ExtensionCatalog(project) @@ -89,94 +86,6 @@ def getheader(self, _name): return "application/zip" -_DEEP_JSON = b"[" * 12000 + b"0" + b"]" * 12000 - - -def _raise_on_deep_json(monkeypatch): - """Exercise parser recursion independent of Python's nesting threshold.""" - loads = json.loads - - def decode(data, *args, **kwargs): - if data == _DEEP_JSON or data == _DEEP_JSON.decode("utf-8"): - raise RecursionError("maximum recursion depth exceeded while decoding JSON") - return loads(data, *args, **kwargs) - - monkeypatch.setattr(json, "loads", decode) - - -@pytest.mark.parametrize("legacy", [False, True]) -def test_deeply_nested_catalog_json_is_a_validation_error( - tmp_path, monkeypatch, legacy -): - source = CatalogEntry("https://example.com/deep.json", "deep", 1, True) - catalog = ExtensionCatalog(tmp_path) - _raise_on_deep_json(monkeypatch) - monkeypatch.setattr( - catalog, - "_open_url", - lambda *_args, **_kwargs: _ArchiveResponse(_DEEP_JSON, source.url), - ) - if legacy: - monkeypatch.setattr(catalog, "get_catalog_url", lambda: source.url) - - with pytest.raises(ExtensionCatalogValidationError, match="nesting"): - if legacy: - catalog.fetch_catalog(force_refresh=True) - else: - catalog._fetch_single_catalog(source, force_refresh=True) - - -@pytest.mark.parametrize("legacy", [False, True]) -def test_deeply_nested_cached_catalog_refetches(tmp_path, monkeypatch, legacy): - catalog = ExtensionCatalog(tmp_path) - url = catalog.DEFAULT_CATALOG_URL - source = CatalogEntry(url, "default", 1, True) - catalog.cache_file.parent.mkdir(parents=True, exist_ok=True) - catalog.cache_file.write_bytes(_DEEP_JSON) - _raise_on_deep_json(monkeypatch) - monkeypatch.setattr(catalog, "is_cache_valid", lambda: True) - monkeypatch.setattr(catalog, "get_catalog_url", lambda: url) - monkeypatch.setattr( - catalog, - "_open_url", - lambda *_args, **_kwargs: _ArchiveResponse( - b'{"schema_version":"1.0","extensions":{}}', url - ), - ) - - data = catalog.fetch_catalog() if legacy else catalog._fetch_single_catalog(source) - assert data["extensions"] == {} - - -@pytest.mark.parametrize("legacy", [False, True]) -def test_deeply_nested_cache_metadata_refetches(tmp_path, monkeypatch, legacy): - catalog = ExtensionCatalog(tmp_path) - url = catalog.DEFAULT_CATALOG_URL if legacy else "https://example.com/deep.json" - source = CatalogEntry(url, "deep", 1, True) - if legacy: - cache_file = catalog.cache_file - metadata_file = catalog.cache_metadata_file - monkeypatch.setattr(catalog, "get_catalog_url", lambda: url) - else: - url_hash = hashlib.sha256(url.encode()).hexdigest()[:16] - cache_file = catalog.cache_dir / f"catalog-{url_hash}.json" - metadata_file = catalog.cache_dir / f"catalog-{url_hash}-metadata.json" - cache_file.parent.mkdir(parents=True, exist_ok=True) - cache_file.write_bytes(b'{"schema_version":"1.0","extensions":{}}') - metadata_file.write_bytes(_DEEP_JSON) - _raise_on_deep_json(monkeypatch) - monkeypatch.setattr( - catalog, - "_open_url", - lambda *_args, **_kwargs: _ArchiveResponse( - b'{"schema_version":"1.0","extensions":{}}', url - ), - ) - - data = catalog.fetch_catalog() if legacy else catalog._fetch_single_catalog(source) - assert data["extensions"] == {} - - def test_legacy_entry_still_selects_its_current_release(tmp_path, monkeypatch): entry = { "id": "legacy", @@ -252,62 +161,6 @@ def test_requested_version_does_not_fall_through_to_lower_priority_catalog( assert catalog.get_extension_versions("demo-history") == ["0.5.1"] -def test_targeted_lookup_rejects_malformed_higher_catalog_but_search_continues( - tmp_path, monkeypatch, -): - from specify_cli.extensions import ExtensionCatalogValidationError - - 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) - - def fetch(source, _force=False): - if source.name == "high": - catalog._validate_catalog_payload({"extensions": []}, source.url) - return { - "schema_version": "1.0", - "extensions": {"demo-history": {"version": "1.0.0"}}, - } - - monkeypatch.setattr(catalog, "_fetch_single_catalog", fetch) - - with pytest.raises(ExtensionCatalogValidationError, match="Invalid catalog format"): - catalog.get_extension_info("demo-history") - assert catalog.search("demo-history")[0]["_catalog_name"] == "low" - - -def test_targeted_lookup_rejects_lower_match_when_higher_catalog_is_unreachable( - tmp_path, monkeypatch, -): - from specify_cli.extensions import ExtensionCatalogFetchError - - 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) - high_available = False - - def fetch(source, _force=False): - if source.name == "high" and not high_available: - raise ExtensionCatalogFetchError("high catalog is offline") - return { - "schema_version": "1.0", - "extensions": {"demo-history": {"version": "1.0.0"}}, - } - - monkeypatch.setattr(catalog, "_fetch_single_catalog", fetch) - with pytest.raises(ExtensionCatalogFetchError, match="offline"): - catalog.get_extension_info("demo-history", "1.0.0") - assert catalog.search("demo-history")[0]["_catalog_name"] == "low" - high_available = True - assert catalog.get_extension_info("demo-history")["_catalog_name"] == "high" - - @pytest.mark.parametrize("second_lookup", ["lower_priority", "unavailable"]) def test_exact_cli_install_uses_first_resolved_catalog_snapshot( tmp_path, monkeypatch, second_lookup @@ -321,7 +174,7 @@ def test_exact_cli_install_uses_first_resolved_catalog_snapshot( fetches = [] selected = [] - def merged(_self, *, extension_id=None): + def merged(_self): fetches.append(True) if len(fetches) == 1: return [high] @@ -625,7 +478,7 @@ def test_info_versions_uses_first_resolved_catalog_snapshot(tmp_path, monkeypatc low["version"] = "0.8.0" fetches = [] - def merged(_self, *, extension_id=None): + def merged(_self): fetches.append(True) return [high if len(fetches) == 1 else low] diff --git a/tests/specify_cli/presets/test_catalog_versions.py b/tests/specify_cli/presets/test_catalog_versions.py index b96299c9de..4080de89a9 100644 --- a/tests/specify_cli/presets/test_catalog_versions.py +++ b/tests/specify_cli/presets/test_catalog_versions.py @@ -613,32 +613,25 @@ def open_url(_self, url, **_kwargs): assert PresetManager(project_dir).get_pack("sample") is None -def test_unreachable_high_priority_catalog_does_not_select_lower_source(project_dir): - from specify_cli.presets._catalog import PresetCatalogFetchError - +def test_unreachable_high_priority_catalog_still_uses_lower_source(project_dir): catalog = PresetCatalog(project_dir) sources = [ PresetCatalogEntry("https://example.com/unavailable.json", "high", 1, False), PresetCatalogEntry("https://example.com/trusted.json", "low", 2, True), ] - high_available = False def fetch(source, _refresh): - if source.name == "high" and not high_available: - raise PresetCatalogFetchError("Failed to fetch preset catalog: offline") + if source.name == "high": + raise PresetError("Failed to fetch preset catalog: offline") return {"presets": {"sample": _entry()}} with ( patch.object(catalog, "get_active_catalogs", return_value=sources), patch.object(catalog, "_fetch_single_catalog", side_effect=fetch), ): - with pytest.raises(PresetCatalogFetchError, match="offline"): - catalog.get_pack_info("sample", "1.0.0") - assert catalog.search("sample")[0]["_catalog_name"] == "low" - high_available = True selected = catalog.get_pack_info("sample", "1.0.0") - assert selected["_catalog_name"] == "high" - assert selected["_install_allowed"] is False + assert selected["_catalog_name"] == "low" + assert selected["_install_allowed"] is True def test_versions_report_all_source_outage_instead_of_missing_preset(project_dir): @@ -664,9 +657,7 @@ def fetch(source, _refresh): assert "No catalog versions found" not in result.output -def test_versions_report_partial_outage_instead_of_missing_preset(project_dir): - from specify_cli.presets._catalog import PresetCatalogFetchError - +def test_versions_report_missing_preset_when_catalog_is_readable(project_dir): sources = [ PresetCatalogEntry("https://example.com/high.json", "high", 1, True), PresetCatalogEntry("https://example.com/low.json", "low", 2, True), @@ -674,9 +665,7 @@ def test_versions_report_partial_outage_instead_of_missing_preset(project_dir): def fetch(source, _refresh): if source.name == "high": - raise PresetCatalogFetchError( - f"Failed to fetch preset catalog from {source.url}: offline" - ) + raise PresetError(f"Failed to fetch preset catalog from {source.url}: offline") return {"presets": {"another-preset": _entry()}} with ( @@ -684,13 +673,12 @@ def fetch(source, _refresh): patch.object(PresetCatalog, "_fetch_single_catalog", side_effect=fetch), patch.object(Path, "cwd", return_value=project_dir), ): - with pytest.raises(PresetCatalogFetchError, match="high.json: offline"): - PresetCatalog(project_dir).get_pack_info("sample") + assert PresetCatalog(project_dir).get_pack_info("sample") is None result = CliRunner().invoke(app, ["preset", "info", "sample", "--versions"]) assert result.exit_code == 1, result.output - assert "high.json:" in result.output and "offline" in result.output - assert "No catalog versions found" not in result.output + assert "No catalog versions found for sample" in result.output + assert "offline" not in result.output def test_discovery_only_winner_does_not_delegate_exact_release(project_dir): diff --git a/tests/specify_cli/workflows/step/test_catalog_versions.py b/tests/specify_cli/workflows/step/test_catalog_versions.py index e3b8b36abd..dfdee40b4a 100644 --- a/tests/specify_cli/workflows/step/test_catalog_versions.py +++ b/tests/specify_cli/workflows/step/test_catalog_versions.py @@ -11,8 +11,6 @@ StepCatalog, StepCatalogEntry, StepCatalogError, - StepCatalogFetchError, - StepCatalogValidationError, ) from specify_cli.workflows.step.catalog._versions import available_versions @@ -47,7 +45,7 @@ def _entry() -> dict: def test_current_and_exact_release_keep_separate_metadata(project_dir, monkeypatch): catalog = StepCatalog(project_dir) - monkeypatch.setattr(catalog, "_get_merged_steps", lambda *, step_id=None: {"deploy": _entry()}) + monkeypatch.setattr(catalog, "_get_merged_steps", lambda: {"deploy": _entry()}) current = catalog.get_step_info("deploy") old = catalog.get_step_info("deploy", version="v1.0") @@ -72,7 +70,7 @@ def test_legacy_current_and_exact_spelling(project_dir, monkeypatch): "version": "release-1", "url": "https://example.com/step.yml", } - monkeypatch.setattr(catalog, "_get_merged_steps", lambda *, step_id=None: {"deploy": legacy}) + monkeypatch.setattr(catalog, "_get_merged_steps", lambda: {"deploy": legacy}) assert catalog.get_step_info("deploy") is legacy assert catalog.get_step_info("deploy", version="release-1") is legacy assert catalog.get_step_info("deploy", version="release-2") is None @@ -98,32 +96,7 @@ def test_winning_source_never_falls_back_for_missing_release(project_dir, monkey assert catalog.get_step_info("deploy", version="1.0") is None -def test_targeted_lookup_rejects_lower_match_after_higher_fetch_failure( - project_dir, monkeypatch, -): - catalog = StepCatalog(project_dir) - sources = [ - StepCatalogEntry("https://example.com/high.json", "high", 1, True), - StepCatalogEntry("https://example.com/low.json", "low", 2, True), - ] - monkeypatch.setattr(catalog, "get_active_catalogs", lambda: sources) - high_available = False - - def fetch(source, force_refresh=False): - if source.name == "high" and not high_available: - raise StepCatalogFetchError("high catalog is offline") - return {"steps": {"deploy": _entry()}} - - monkeypatch.setattr(catalog, "_fetch_single_catalog", fetch) - with pytest.raises(StepCatalogFetchError, match="offline"): - catalog.get_step_info("deploy", version="1.0") - assert catalog.search(query="deploy")[0]["_catalog_name"] == "low" - high_available = True - assert catalog.get_step_info("deploy")["_catalog_name"] == "high" - - -@pytest.mark.parametrize("targeted", [False, True]) -def test_list_catalog_rejects_duplicate_step_ids(project_dir, monkeypatch, targeted): +def test_list_catalog_rejects_duplicate_step_ids(project_dir, monkeypatch): catalog = StepCatalog(project_dir) source = StepCatalogEntry("https://example.com/steps.json", "test", 1, True) monkeypatch.setattr(catalog, "get_active_catalogs", lambda: [source]) @@ -137,65 +110,8 @@ def test_list_catalog_rejects_duplicate_step_ids(project_dir, monkeypatch, targe ] }, ) - with pytest.raises(StepCatalogValidationError, match="Duplicate step ID 'deploy'"): - if targeted: - catalog.get_step_info("deploy") - else: - catalog.search(query="deploy") - - -@pytest.mark.parametrize("invalid_id", [True, 42, {"bad": "id"}, ["bad"], None]) -@pytest.mark.parametrize("targeted", [False, True]) -def test_list_catalog_rejects_non_string_step_ids( - project_dir, monkeypatch, invalid_id, targeted -): - catalog = StepCatalog(project_dir) - source = StepCatalogEntry("https://example.com/steps.json", "test", 1, True) - monkeypatch.setattr(catalog, "get_active_catalogs", lambda: [source]) - monkeypatch.setattr( - catalog, - "_fetch_single_catalog", - lambda *_args, **_kwargs: { - "steps": [ - {"id": invalid_id, "version": "1.0"}, - {"id": "deploy", "version": "2.0"}, - ] - }, - ) - - with pytest.raises(StepCatalogValidationError, match="Invalid step ID"): - if targeted: - catalog.get_step_info(str(invalid_id) if invalid_id is not None else "deploy") - else: - catalog.search() - - -@pytest.mark.parametrize("raised_during_fetch", [False, True]) -def test_targeted_lookup_rejects_duplicate_ids_before_lower_catalog( - project_dir, monkeypatch, raised_during_fetch, -): - catalog = StepCatalog(project_dir) - sources = [ - StepCatalogEntry("https://example.com/high.json", "high", 1, True), - StepCatalogEntry("https://example.com/low.json", "low", 2, True), - ] - monkeypatch.setattr(catalog, "get_active_catalogs", lambda: sources) - - def fetch(source, force_refresh=False): - if source.name == "high": - if raised_during_fetch: - raise StepCatalogError("Duplicate step ID 'deploy' in catalog 'high'.") - return {"steps": [ - {"id": "deploy", "version": "1.0"}, - {"id": "deploy", "version": "2.0"}, - ]} - return {"steps": {"deploy": {"version": "3.0"}}} - - monkeypatch.setattr(catalog, "_fetch_single_catalog", fetch) with pytest.raises(StepCatalogError, match="Duplicate step ID 'deploy'"): catalog.get_step_info("deploy") - if raised_during_fetch: - assert catalog.search(query="deploy")[0]["version"] == "3.0" @pytest.mark.parametrize("cached", [True, False]) @@ -297,6 +213,6 @@ def getheader(self, _name): def test_bad_history_rejected_not_ignored(project_dir, monkeypatch, change, error): catalog = StepCatalog(project_dir) entry = {**_entry(), **change} - monkeypatch.setattr(catalog, "_get_merged_steps", lambda *, step_id=None: {"deploy": entry}) + monkeypatch.setattr(catalog, "_get_merged_steps", lambda: {"deploy": entry}) with pytest.raises(StepCatalogError, match=error): catalog.get_step_info("deploy") diff --git a/tests/specify_cli/workflows/step/test_command_info.py b/tests/specify_cli/workflows/step/test_command_info.py index 5575266568..edee4d2883 100644 --- a/tests/specify_cli/workflows/step/test_command_info.py +++ b/tests/specify_cli/workflows/step/test_command_info.py @@ -119,7 +119,7 @@ def test_info_versions_shows_current_and_history_from_discovery_catalog( monkeypatch.chdir(project_dir) monkeypatch.setattr( StepCatalog, "_get_merged_steps", - lambda self, *, step_id=None: { + lambda self: { "deploy": { "id": "deploy", "name": "Deploy", "version": "2.0", "_install_allowed": False, diff --git a/tests/specify_cli/workflows/test_catalog_versions.py b/tests/specify_cli/workflows/test_catalog_versions.py index 0c66a81445..2eafe38594 100644 --- a/tests/specify_cli/workflows/test_catalog_versions.py +++ b/tests/specify_cli/workflows/test_catalog_versions.py @@ -15,8 +15,6 @@ from specify_cli.workflows.catalog import ( WorkflowCatalog, WorkflowCatalogEntry, - WorkflowCatalogFetchError, - WorkflowCatalogValidationError, WorkflowRegistry, WorkflowValidationError, ) @@ -145,112 +143,6 @@ def test_legacy_entry_and_winning_source(monkeypatch, project_dir): assert catalog.search(query="history-wf")[0]["version"] == "2.0.0" -def test_targeted_lookup_rejects_lower_match_after_higher_fetch_failure( - monkeypatch, project_dir, -): - catalog = WorkflowCatalog(project_dir) - sources = [ - WorkflowCatalogEntry("https://example.com/high.json", "high", 1, True), - WorkflowCatalogEntry("https://example.com/low.json", "low", 2, True), - ] - monkeypatch.setattr(catalog, "get_active_catalogs", lambda: sources) - high_available = False - - def fetch(source, force_refresh=False): - if source.name == "high" and not high_available: - raise WorkflowCatalogFetchError("high catalog is offline") - return {"workflows": {"history-wf": _entry()}} - - monkeypatch.setattr(catalog, "_fetch_single_catalog", fetch) - with pytest.raises(WorkflowCatalogFetchError, match="offline"): - catalog.get_workflow_info("history-wf", "1.0.0") - assert catalog.search(query="history-wf")[0]["_catalog_name"] == "low" - high_available = True - assert catalog.get_workflow_info("history-wf")["_catalog_name"] == "high" - - -@pytest.mark.parametrize("duplicate_id", ["history-wf", "other"]) -@pytest.mark.parametrize("lookup", ["exact", "search"]) -def test_duplicate_list_workflow_ids_are_rejected( - monkeypatch, project_dir, duplicate_id, lookup, -): - catalog = WorkflowCatalog(project_dir) - sources = [ - WorkflowCatalogEntry("https://example.com/high.json", "high", 1, True), - WorkflowCatalogEntry("https://example.com/low.json", "low", 2, True), - ] - monkeypatch.setattr(catalog, "get_active_catalogs", lambda: sources) - fetched = [] - - def fetch(source, force_refresh=False): - fetched.append(source.name) - if source.name == "high": - return {"workflows": [ - {"id": "history-wf", "version": "1.0.0"}, - {"id": duplicate_id, "version": "2.0.0"}, - {"id": duplicate_id, "version": "3.0.0"}, - ]} - return {"workflows": [{"id": "history-wf", "version": "4.0.0"}]} - - monkeypatch.setattr(catalog, "_fetch_single_catalog", fetch) - with pytest.raises( - WorkflowCatalogValidationError, match=f"Duplicate workflow ID '{duplicate_id}'" - ): - if lookup == "exact": - catalog.get_workflow_info("history-wf") - else: - catalog.search() - if lookup == "exact": - assert fetched == ["high"] - - -@pytest.mark.parametrize("invalid_id", [{"bad": "id"}, ["bad"], 42, True, None]) -@pytest.mark.parametrize("lookup", ["exact", "search"]) -def test_list_catalog_rejects_non_string_workflow_ids( - monkeypatch, project_dir, invalid_id, lookup, -): - catalog = WorkflowCatalog(project_dir) - source = WorkflowCatalogEntry("https://example.com/workflows.json", "test", 1, True) - monkeypatch.setattr(catalog, "get_active_catalogs", lambda: [source]) - monkeypatch.setattr( - catalog, - "_fetch_single_catalog", - lambda *_args, **_kwargs: { - "workflows": [ - {"id": "history-wf", "version": "1.0.0"}, - {"id": invalid_id, "version": "2.0.0"}, - ] - }, - ) - - with pytest.raises(WorkflowCatalogValidationError, match="Invalid workflow ID"): - if lookup == "exact": - catalog.get_workflow_info("history-wf") - else: - catalog.search() - - -def test_list_workflow_id_can_appear_in_distinct_catalogs(monkeypatch, project_dir): - catalog = WorkflowCatalog(project_dir) - sources = [ - WorkflowCatalogEntry("https://example.com/high.json", "high", 1, True), - WorkflowCatalogEntry("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_refresh=False: { - "workflows": [{ - "id": "history-wf", - "version": "1.0.0" if source.name == "high" else "2.0.0", - }] - }, - ) - - assert catalog.get_workflow_info("history-wf")["version"] == "1.0.0" - assert catalog.search(query="history-wf")[0]["version"] == "1.0.0" - - @pytest.mark.parametrize( "history", [ diff --git a/tests/specify_cli/workflows/test_command_search.py b/tests/specify_cli/workflows/test_command_search.py index db002a6e93..2afdb19b80 100644 --- a/tests/specify_cli/workflows/test_command_search.py +++ b/tests/specify_cli/workflows/test_command_search.py @@ -82,9 +82,7 @@ def test_search_and_info_tolerate_non_list_tags(self, project_dir, monkeypatch): monkeypatch.setattr( WorkflowCatalog, "_get_merged_workflows", - lambda self, force_refresh=False, *, workflow_id=None: { - k: dict(v) for k, v in workflows.items() - }, + lambda self, force_refresh=False: {k: dict(v) for k, v in workflows.items()}, ) runner = CliRunner() searched = runner.invoke(app, ["workflow", "search"]) diff --git a/tests/test_workflows.py b/tests/test_workflows.py index c09a12ec5f..bb83f3eda3 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -10485,13 +10485,9 @@ def test_search_with_non_string_fields(self, project_dir, monkeypatch): results = catalog.search(query="missing") assert len(results) == 0 - def test_get_merged_steps_rejects_non_string_list_ids(self, project_dir, monkeypatch): - """List-based catalog entries must declare string IDs.""" - from specify_cli.workflows.step.catalog import ( - StepCatalog, - StepCatalogEntry, - StepCatalogValidationError, - ) + def test_get_merged_steps_normalizes_list_ids_to_strings(self, project_dir, monkeypatch): + """List-based catalog entries with non-string ids must be normalized.""" + from specify_cli.workflows.step.catalog import StepCatalog, StepCatalogEntry catalog = StepCatalog(project_dir) entry = StepCatalogEntry( @@ -10509,8 +10505,10 @@ def test_get_merged_steps_rejects_non_string_list_ids(self, project_dir, monkeyp }, ) - with pytest.raises(StepCatalogValidationError, match="Invalid step ID"): - catalog._get_merged_steps() + merged = catalog._get_merged_steps() + assert "42" in merged + assert 42 not in merged + assert merged["42"]["id"] == "42" def test_get_step_info_returns_entry_or_none(self, project_dir, monkeypatch): """get_step_info returns matching entry or None for missing ids.""" From b4edd6dc6ce9183461c7b2d4c31cf259b26bdd49 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Wed, 7 Oct 2026 12:23:03 -0500 Subject: [PATCH 21/23] fix(bundle): validate pinned current release metadata Reject malformed workflow and step install URLs and checksums during online bundle validation, including the advertised current release. Preserve legacy workflow digest compatibility and cover both valid and invalid pins. Assisted-by: GitHub Copilot (model: GPT-6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/specify_cli/bundles/references.py | 66 +++++++++++++++++-- tests/specify_cli/bundles/test_references.py | 68 +++++++++++++++++++- 2 files changed, 125 insertions(+), 9 deletions(-) diff --git a/src/specify_cli/bundles/references.py b/src/specify_cli/bundles/references.py index 5e86e2c026..7b9c887268 100644 --- a/src/specify_cli/bundles/references.py +++ b/src/specify_cli/bundles/references.py @@ -77,6 +77,56 @@ def _resolved_locally(root: Path, component: ComponentRef) -> bool: return False +def _validate_pinned_install_metadata(component: ComponentRef, selected: dict) -> None: + from .._download_security import is_https_or_localhost_http + from . import BundlerError + + def require_url(value: object, label: str) -> str: + if not isinstance(value, str) or not is_https_or_localhost_http(value): + raise BundlerError( + f"{component.kind[:-1]} '{component.id}' has an invalid {label} " + f"for pinned version {component.version}." + ) + return value + + if component.kind == "workflows": + from ..workflows.catalog._versions import _SHA256 + + require_url(selected.get("url"), "install URL") + digest = selected.get("sha256") + if digest is not None and ( + not isinstance(digest, str) or not _SHA256.fullmatch(digest) + ): + raise BundlerError( + f"workflow '{component.id}' has an invalid SHA-256 digest " + f"for pinned version {component.version}." + ) + if "requires" in selected and not isinstance(selected["requires"], dict): + raise BundlerError( + f"workflow '{component.id}' has invalid requirements " + f"for pinned version {component.version}." + ) + return + + from ..workflows.step.catalog._versions import validate_checksums + + validate_checksums(selected, component.id, required=True) + step_url = require_url( + selected.get("step_yml_url") or selected.get("url"), "step.yml URL" + ) + init_url = selected.get("init_url") + if init_url is None: + if not step_url.endswith("step.yml"): + raise BundlerError( + f"step '{component.id}' has no __init__.py URL " + f"for pinned version {component.version}." + ) + else: + require_url(init_url, "__init__.py URL") + for url in selected.get("extra_files", {}).values(): + require_url(url, "extra file URL") + + def _catalog_has_release(component: ComponentRef, catalog) -> bool: from .component_catalog import select_catalog_release, winning_catalog_entry @@ -92,12 +142,16 @@ def _catalog_has_release(component: ComponentRef, catalog) -> bool: # installer likewise accepts their unversioned current entry. return True selected = select_catalog_release(component, current) - return ( - selected is not None - and selected.get("_catalog_name") == current.get("_catalog_name") - and selected.get("_install_allowed", True) - and _matches_pin(component, selected.get("version")) - ) + if ( + selected is None + or selected.get("_catalog_name") != current.get("_catalog_name") + or not selected.get("_install_allowed", True) + or not _matches_pin(component, selected.get("version")) + ): + return False + if component.kind in ("workflows", "steps"): + _validate_pinned_install_metadata(component, selected) + return True def _resolved_in_catalog(root: Path, component: ComponentRef) -> bool | str | None: diff --git a/tests/specify_cli/bundles/test_references.py b/tests/specify_cli/bundles/test_references.py index 9840088b56..9e846f77c0 100644 --- a/tests/specify_cli/bundles/test_references.py +++ b/tests/specify_cli/bundles/test_references.py @@ -35,6 +35,17 @@ def _mock_catalog(monkeypatch, catalog_type, kind, component_id, record, *, name ) +def _installable_current(kind: str) -> dict: + return { + "version": "1.0.0", + "url": f"https://example.com/{'workflow.yml' if kind == 'workflows' else 'step.yml'}", + "sha256": ( + "a" * 64 if kind == "workflows" + else {"step.yml": "a" * 64, "__init__.py": "b" * 64} + ), + } + + def test_bundled_extension_resolves(tmp_path: Path): root = make_project(tmp_path) warnings: list[str] = [] @@ -344,6 +355,57 @@ def test_online_validation_reports_invalid_release_metadata(tmp_path, monkeypatc assert warnings == [] +@pytest.mark.parametrize( + ("kind", "field", "bad_value", "message"), + [ + ("workflows", "url", None, "install URL"), + ("workflows", "url", 42, "install URL"), + ("workflows", "url", "http://example.com/workflow.yml", "install URL"), + ("workflows", "sha256", "not-a-digest", "SHA-256"), + ("steps", "url", None, "step.yml URL"), + ("steps", "url", 42, "step.yml URL"), + ("steps", "url", "http://example.com/step.yml", "step.yml URL"), + ("steps", "url", "https://example.com/other.yml", "__init__.py URL"), + ("steps", "init_url", "http://example.com/__init__.py", "__init__.py URL"), + ("steps", "sha256", {"step.yml": "a" * 64}, "SHA-256"), + ("steps", "extra_files", {"helper.py": "http://example.com/helper.py"}, "extra file URL"), + ], +) +def test_online_validation_rejects_malformed_pinned_current_release( + tmp_path, monkeypatch, kind, field, bad_value, message, +): + from specify_cli.workflows.catalog import StepCatalog, WorkflowCatalog + + catalog = WorkflowCatalog if kind == "workflows" else StepCatalog + record = _installable_current(kind) + record[field] = bad_value + if field == "extra_files": + record["sha256"]["helper.py"] = "c" * 64 + _mock_catalog(monkeypatch, catalog, kind, "requested", record) + warnings = [] + check = make_reference_checker(tmp_path, allow_network=True, warnings=warnings) + + problem = check(_ref(kind, "requested")) + assert problem is not None and message in problem + assert warnings == [] + + +@pytest.mark.parametrize("kind", ["workflows", "steps"]) +def test_online_validation_accepts_installable_pinned_current_release( + tmp_path, monkeypatch, kind, +): + from specify_cli.workflows.catalog import StepCatalog, WorkflowCatalog + + catalog = WorkflowCatalog if kind == "workflows" else StepCatalog + record = _installable_current(kind) + _mock_catalog(monkeypatch, catalog, kind, "requested", record) + warnings = [] + check = make_reference_checker(tmp_path, allow_network=True, warnings=warnings) + + assert check(_ref(kind, "requested")) is None + assert warnings == [] + + def test_online_validation_warns_when_catalogs_are_unreachable(tmp_path, monkeypatch): from specify_cli.workflows.catalog import WorkflowCatalog @@ -401,7 +463,7 @@ def fetch(self, entry, force_refresh=False): visited.append(entry.name) if entry.name == unreachable: raise URLError("catalog timed out") - contents = {"requested": {"version": "1.0.0"}} if has_match else {} + contents = {"requested": _installable_current(kind)} if has_match else {} return {kind: contents} monkeypatch.setattr(catalog, "_fetch_single_catalog", fetch) @@ -933,11 +995,11 @@ def test_bundle_catalog_handles_recursion_without_fallback( ) catalog_type = WorkflowCatalog if kind == "workflows" else StepCatalog - _mock_catalog(monkeypatch, catalog_type, kind, "requested", {"version": "1.0.0"}) + _mock_catalog(monkeypatch, catalog_type, kind, "requested", _installable_current(kind)) def fetch(self, entry, force_refresh=False): if source == "fresh-cache": - return {kind: {"requested": {"version": "1.0.0"}}} + return {kind: {"requested": _installable_current(kind)}} if source == "stale-cache": raise URLError("connection failed") raise RecursionError("catalog nesting limit exceeded") From 953c4e1c03ffec2f4816dce517f2ef27f7cc4ac4 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Wed, 7 Oct 2026 17:49:49 -0500 Subject: [PATCH 22/23] fix(bundle): reject unsafe pinned extension URLs Validate selected current and historical extension release URLs during online bundle checks so invalid references fail before installation. Add regression coverage and keep catalog fixtures installable. Assisted-by: GitHub Copilot (model: GPT-6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/specify_cli/bundles/references.py | 6 +- tests/specify_cli/bundles/test_installer.py | 7 ++- tests/specify_cli/bundles/test_references.py | 58 +++++++++++++++++++- 3 files changed, 68 insertions(+), 3 deletions(-) diff --git a/src/specify_cli/bundles/references.py b/src/specify_cli/bundles/references.py index 7b9c887268..b1a42c846e 100644 --- a/src/specify_cli/bundles/references.py +++ b/src/specify_cli/bundles/references.py @@ -89,6 +89,10 @@ def require_url(value: object, label: str) -> str: ) return value + if component.kind == "extensions": + require_url(selected.get("download_url"), "download URL") + return + if component.kind == "workflows": from ..workflows.catalog._versions import _SHA256 @@ -149,7 +153,7 @@ def _catalog_has_release(component: ComponentRef, catalog) -> bool: or not _matches_pin(component, selected.get("version")) ): return False - if component.kind in ("workflows", "steps"): + if component.kind in ("extensions", "workflows", "steps"): _validate_pinned_install_metadata(component, selected) return True diff --git a/tests/specify_cli/bundles/test_installer.py b/tests/specify_cli/bundles/test_installer.py index baed8d8732..6dafa36869 100644 --- a/tests/specify_cli/bundles/test_installer.py +++ b/tests/specify_cli/bundles/test_installer.py @@ -377,7 +377,12 @@ def fetch(self, source, force_refresh=False): fetches.append(source.name) return { "schema_version": "1.0", - "extensions": {"ext-a": {"version": "1.0.0"}}, + "extensions": { + "ext-a": { + "version": "1.0.0", + "download_url": "https://example.com/release.zip", + } + }, } from specify_cli.extensions import CatalogEntry diff --git a/tests/specify_cli/bundles/test_references.py b/tests/specify_cli/bundles/test_references.py index 9e846f77c0..3e5990f3f3 100644 --- a/tests/specify_cli/bundles/test_references.py +++ b/tests/specify_cli/bundles/test_references.py @@ -36,6 +36,12 @@ def _mock_catalog(monkeypatch, catalog_type, kind, component_id, record, *, name def _installable_current(kind: str) -> dict: + if kind == "extensions": + return { + "version": "1.0.0", + "download_url": "https://example.com/release.zip", + "sha256": "a" * 64, + } return { "version": "1.0.0", "url": f"https://example.com/{'workflow.yml' if kind == 'workflows' else 'step.yml'}", @@ -156,7 +162,10 @@ def test_wrong_bundled_extension_pin_is_definitive( root = make_project(tmp_path) _mock_catalog( monkeypatch, ExtensionCatalog, "extensions", - "agent-context", {"version": "999.0.0"}, + "agent-context", { + "version": "999.0.0", + "download_url": "https://example.com/release.zip", + }, ) warnings = [] check = make_reference_checker(root, allow_network=allow_network, warnings=warnings) @@ -541,6 +550,53 @@ def test_online_validation_rejects_malformed_component_release( assert warnings == [] +@pytest.mark.parametrize("historical", [False, True]) +@pytest.mark.parametrize( + "url", + ["http://example.com/release.zip", "https://[::1", "https:///release.zip", 42], +) +def test_online_validation_rejects_invalid_pinned_extension_url( + tmp_path, monkeypatch, historical, url, +): + from specify_cli.extensions import ExtensionCatalog + + record = {"download_url": url, "sha256": "a" * 64} + current = {"version": "2.0.0", **record} + if historical: + current["releases"] = {"1.0.0": record} + _mock_catalog( + monkeypatch, ExtensionCatalog, "extensions", "invalid-extension", current + ) + warnings = [] + check = make_reference_checker(tmp_path, allow_network=True, warnings=warnings) + + problem = check(_ref( + "extensions", "invalid-extension", "1.0.0" if historical else "2.0.0" + )) + assert problem is not None and ("URL" in problem or "download_url" in problem) + assert warnings == [] + + +@pytest.mark.parametrize("historical", [False, True]) +def test_online_validation_accepts_safe_pinned_extension_url( + tmp_path, monkeypatch, historical, +): + from specify_cli.extensions import ExtensionCatalog + + record = {"download_url": "https://example.com/release.zip", "sha256": "a" * 64} + current = {"version": "2.0.0", **record} + if historical: + current["releases"] = {"1.0.0": record} + _mock_catalog(monkeypatch, ExtensionCatalog, "extensions", "valid-extension", current) + warnings = [] + check = make_reference_checker(tmp_path, allow_network=True, warnings=warnings) + + assert check(_ref( + "extensions", "valid-extension", "1.0.0" if historical else "2.0.0" + )) is None + assert warnings == [] + + @pytest.mark.parametrize("kind", ["extensions", "presets"]) def test_online_validation_accepts_unversioned_legacy_component( tmp_path, monkeypatch, kind, From 182564adb9447aec7ba9bef5bf59016a2ac31d30 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Thu, 8 Oct 2026 08:07:59 -0500 Subject: [PATCH 23/23] fix(bundle): validate pinned current component metadata Reject invalid current preset URLs and malformed declared extension/preset digests during online exact-pin validation. Keep omitted digests and unversioned legacy entries compatible; cover failing and valid cases. Assisted-by: GitHub Copilot (model: GPT-6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/specify_cli/bundles/references.py | 16 ++++++- tests/specify_cli/bundles/test_references.py | 49 +++++++++++++++++++- 2 files changed, 61 insertions(+), 4 deletions(-) diff --git a/src/specify_cli/bundles/references.py b/src/specify_cli/bundles/references.py index b1a42c846e..5f4eab9f88 100644 --- a/src/specify_cli/bundles/references.py +++ b/src/specify_cli/bundles/references.py @@ -89,8 +89,20 @@ def require_url(value: object, label: str) -> str: ) return value - if component.kind == "extensions": + if component.kind in ("extensions", "presets"): + from ..shared_infra import _SHA256_HEX_RE + require_url(selected.get("download_url"), "download URL") + digest = selected.get("sha256") + if digest is not None: + value = str(digest).strip() + if value[:7].lower() == "sha256:": + value = value[7:].strip() + if not _SHA256_HEX_RE.fullmatch(value.lower()): + raise BundlerError( + f"{component.kind[:-1]} '{component.id}' has an invalid SHA-256 " + f"digest for pinned version {component.version}." + ) return if component.kind == "workflows": @@ -153,7 +165,7 @@ def _catalog_has_release(component: ComponentRef, catalog) -> bool: or not _matches_pin(component, selected.get("version")) ): return False - if component.kind in ("extensions", "workflows", "steps"): + if component.kind in ("extensions", "presets", "workflows", "steps"): _validate_pinned_install_metadata(component, selected) return True diff --git a/tests/specify_cli/bundles/test_references.py b/tests/specify_cli/bundles/test_references.py index 3e5990f3f3..7bef9206b7 100644 --- a/tests/specify_cli/bundles/test_references.py +++ b/tests/specify_cli/bundles/test_references.py @@ -36,7 +36,7 @@ def _mock_catalog(monkeypatch, catalog_type, kind, component_id, record, *, name def _installable_current(kind: str) -> dict: - if kind == "extensions": + if kind in ("extensions", "presets"): return { "version": "1.0.0", "download_url": "https://example.com/release.zip", @@ -197,7 +197,8 @@ def test_bundled_preset_pin_mismatch_is_definitive( ) monkeypatch.setattr(assets, "_locate_bundled_preset", lambda _id: bundled) _mock_catalog( - monkeypatch, PresetCatalog, "presets", "requested", {"version": "2.0.0"} + monkeypatch, PresetCatalog, "presets", "requested", + {"version": "2.0.0", "download_url": "https://example.com/release.zip"} ) warnings = [] check = make_reference_checker(tmp_path, allow_network=allow_network, warnings=warnings) @@ -597,6 +598,50 @@ def test_online_validation_accepts_safe_pinned_extension_url( assert warnings == [] +@pytest.mark.parametrize("kind", ["extensions", "presets"]) +@pytest.mark.parametrize( + "change", + [ + {"download_url": None}, + {"download_url": "http://example.com/release.zip"}, + {"sha256": "not-a-digest"}, + ], +) +def test_online_validation_rejects_malformed_current_component_metadata( + tmp_path, monkeypatch, kind, change, +): + from specify_cli.extensions import ExtensionCatalog + from specify_cli.presets import PresetCatalog + + catalog = ExtensionCatalog if kind == "extensions" else PresetCatalog + _mock_catalog( + monkeypatch, catalog, kind, "malformed-current", + {**_installable_current(kind), **change}, + ) + warnings = [] + check = make_reference_checker(tmp_path, allow_network=True, warnings=warnings) + + problem = check(_ref(kind, "malformed-current")) + assert problem is not None and "Catalog lookup failed" in problem + assert warnings == [] + + +@pytest.mark.parametrize("digest", [None, "a" * 64, "sha256:" + "A" * 64]) +def test_online_validation_accepts_current_preset_metadata(tmp_path, monkeypatch, digest): + from specify_cli.presets import PresetCatalog + + record = _installable_current("presets") + record["sha256"] = digest + _mock_catalog( + monkeypatch, PresetCatalog, "presets", "valid-current", record, + ) + warnings = [] + check = make_reference_checker(tmp_path, allow_network=True, warnings=warnings) + + assert check(_ref("presets", "valid-current")) is None + assert warnings == [] + + @pytest.mark.parametrize("kind", ["extensions", "presets"]) def test_online_validation_accepts_unversioned_legacy_component( tmp_path, monkeypatch, kind,