diff --git a/src/specify_cli/bundler/models/catalog.py b/src/specify_cli/bundler/models/catalog.py index 53e83a52e7..59a45bcb1d 100644 --- a/src/specify_cli/bundler/models/catalog.py +++ b/src/specify_cli/bundler/models/catalog.py @@ -106,10 +106,11 @@ def to_dict(self) -> dict[str, Any]: def _parse_tags(value: Any, entry_id: str) -> tuple[str, ...]: - """Coerce a catalog entry's ``tags`` into a tuple of strings. + """Parse a catalog entry's ``tags`` into a tuple of strings. Catalogs are untrusted input: a bare string would otherwise be iterated - character-by-character, so reject anything that is not a list/tuple. + character-by-character, so reject anything that is not a list/tuple, and + reject any non-string member instead of silently coercing it. """ if value is None: return () @@ -117,7 +118,11 @@ def _parse_tags(value: Any, entry_id: str) -> tuple[str, ...]: raise BundlerError( f"Catalog entry '{entry_id}': 'tags' must be a list of strings." ) - return tuple(str(t) for t in value) + if any(not isinstance(item, str) for item in value): + raise BundlerError( + f"Catalog entry '{entry_id}': 'tags' must be a list of strings." + ) + return tuple(value) def _parse_verified(value: Any, entry_id: str) -> bool: diff --git a/tests/contract/test_catalog_schema.py b/tests/contract/test_catalog_schema.py index 15a844118b..0e360ac1aa 100644 --- a/tests/contract/test_catalog_schema.py +++ b/tests/contract/test_catalog_schema.py @@ -238,6 +238,15 @@ def test_catalog_entry_rejects_string_tags(): CatalogEntry.from_dict(data) +def test_catalog_entry_rejects_non_string_tag_members(): + from specify_cli.bundler.models.catalog import CatalogEntry + + data = catalog_entry_dict("demo") + data["tags"] = ["valid", 1] + with pytest.raises(BundlerError, match="'tags' must be a list of strings"): + CatalogEntry.from_dict(data) + + def test_catalog_entry_rejects_non_boolean_verified(): from specify_cli.bundler.models.catalog import CatalogEntry