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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions src/specify_cli/bundler/commands_impl/catalog_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 22 additions & 0 deletions src/specify_cli/bundler/models/catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand Down
38 changes: 38 additions & 0 deletions tests/contract/test_catalog_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down