diff --git a/src/specify_cli/bundler/commands_impl/catalog_config.py b/src/specify_cli/bundler/commands_impl/catalog_config.py index 37e45bf990..42485fd1a8 100644 --- a/src/specify_cli/bundler/commands_impl/catalog_config.py +++ b/src/specify_cli/bundler/commands_impl/catalog_config.py @@ -14,14 +14,13 @@ from ..lib.yamlio import dump_yaml, ensure_within, load_yaml from ..models.catalog import ( CONFIG_FILENAME, + CONFIG_SCHEMA_VERSION, BUILTIN_DEFAULT_STACK, CatalogSource, InstallPolicy, Scope, ) -CONFIG_SCHEMA_VERSION = "1.0" - _BUILTIN_IDS = {raw["id"] for raw in BUILTIN_DEFAULT_STACK} # Windows absolute paths like ``C:\catalog.json`` parse with a single-letter diff --git a/src/specify_cli/bundler/models/catalog.py b/src/specify_cli/bundler/models/catalog.py index 9621b5a334..88a08818f9 100644 --- a/src/specify_cli/bundler/models/catalog.py +++ b/src/specify_cli/bundler/models/catalog.py @@ -15,6 +15,11 @@ from ..lib.yamlio import ensure_within, load_yaml CONFIG_FILENAME = "bundle-catalogs.yml" +# Supported bundle-catalogs.yml schema (major version). Both readers of the +# file — this module's _merge_config and commands_impl/catalog_config._read — +# reject an unsupported major version so a file written by a newer/incompatible +# Spec Kit fails fast instead of being parsed under the wrong assumptions. +CONFIG_SCHEMA_VERSION = "1.0" class InstallPolicy(str, Enum): @@ -267,6 +272,23 @@ def _merge_config(by_id: dict[str, CatalogSource], config_path: Path, scope: Sco f"Malformed catalog config at {config_path}: expected a mapping at " f"the top level, got {type(data).__name__}." ) + # Reject an unsupported major schema version, matching the sibling reader + # commands_impl/catalog_config._read. Without this, a file written by a + # newer/incompatible Spec Kit was silently parsed under v1 assumptions on + # the resolution path (bundle search/install), while the other reader + # rejected it — the two readers disagreed. An absent schema_version stays + # valid (backward compatible with configs that omit it). + schema_version = data.get("schema_version") + if schema_version is not None and ( + str(schema_version).strip().split(".")[0] + != CONFIG_SCHEMA_VERSION.split(".")[0] + ): + raise BundlerError( + f"Unsupported catalog config schema version " + f"'{str(schema_version).strip()}' at {config_path}; this Spec Kit " + f"understands version {CONFIG_SCHEMA_VERSION}. The file may have been " + "written by a newer version or is corrupt." + ) catalogs = data.get("catalogs") if catalogs is None: return diff --git a/tests/contract/test_catalog_schema.py b/tests/contract/test_catalog_schema.py index 97600d21ef..5ec3d88973 100644 --- a/tests/contract/test_catalog_schema.py +++ b/tests/contract/test_catalog_schema.py @@ -113,6 +113,44 @@ def test_absent_or_empty_catalogs_is_noop(tmp_path: Path, body: str): assert len(sources) > 0 +def test_load_source_stack_rejects_unknown_schema_version(tmp_path: Path): + """A bundle-catalogs.yml with an unsupported MAJOR schema_version must raise + on the resolution path (load_source_stack -> _merge_config), matching the + sibling reader commands_impl/catalog_config._read. Without this a file + written by a newer/incompatible Spec Kit was silently parsed under v1 + assumptions on the install/search path, while the other reader rejected it.""" + make_project(tmp_path) + config = { + "schema_version": "2.0", + "catalogs": [{"id": "corp", "url": "https://corp/catalog.json", + "priority": 1, "install_policy": "install-allowed"}], + } + (tmp_path / ".specify" / "bundle-catalogs.yml").write_text( + yaml.safe_dump(config), encoding="utf-8" + ) + with pytest.raises(BundlerError, match="Unsupported catalog config schema version"): + load_source_stack(tmp_path) + + +def test_load_source_stack_accepts_matching_or_absent_schema_version(tmp_path: Path): + """A matching major version (1.x) and an absent schema_version both stay + valid — the guard rejects only a different major, so existing configs that + omit the key are unaffected.""" + make_project(tmp_path) + cfg = tmp_path / ".specify" / "bundle-catalogs.yml" + cfg.write_text(yaml.safe_dump({ + "schema_version": "1.5", # same major as CONFIG_SCHEMA_VERSION (1.0) + "catalogs": [{"id": "corp", "url": "https://corp/catalog.json", + "priority": 1, "install_policy": "install-allowed"}], + }), encoding="utf-8") + assert "corp" in {s.id for s in load_source_stack(tmp_path)} + cfg.write_text(yaml.safe_dump({ # no schema_version key + "catalogs": [{"id": "corp2", "url": "https://corp2/catalog.json", + "priority": 1, "install_policy": "install-allowed"}], + }), encoding="utf-8") + assert "corp2" in {s.id for s in load_source_stack(tmp_path)} + + def test_project_config_overrides_same_id(tmp_path: Path): make_project(tmp_path) config = {