diff --git a/src/specify_cli/bundler/models/manifest.py b/src/specify_cli/bundler/models/manifest.py index 032863a2e8..39684b2327 100644 --- a/src/specify_cli/bundler/models/manifest.py +++ b/src/specify_cli/bundler/models/manifest.py @@ -237,17 +237,19 @@ def _text(raw: Any) -> str: def _parse_str_list(raw: Any, field_name: str) -> tuple[str, ...]: - """Coerce a manifest list-of-strings field into a tuple of strings. + """Parse a manifest list-of-strings field into a tuple of strings. Rejects a bare string/bytes (which would otherwise be iterated - character-by-character) and any non-list/tuple, matching the manifest - contract (``string[]``). + character-by-character), any non-list/tuple, and any non-string member, + matching the manifest contract (``string[]``). """ if raw is None: return () if isinstance(raw, (str, bytes)) or not isinstance(raw, (list, tuple)): raise BundlerError(f"'{field_name}' must be a list of strings when present.") - return tuple(str(item) for item in raw) + if any(not isinstance(item, str) for item in raw): + raise BundlerError(f"'{field_name}' must be a list of strings when present.") + return tuple(raw) def _parse_refs(kind: str, raw: Any) -> list[ComponentRef]: diff --git a/tests/contract/test_manifest_schema.py b/tests/contract/test_manifest_schema.py index 2f38620423..4784bdf462 100644 --- a/tests/contract/test_manifest_schema.py +++ b/tests/contract/test_manifest_schema.py @@ -165,6 +165,25 @@ def test_string_mcp_rejected_not_split_per_character(): BundleManifest.from_dict(data) +@pytest.mark.parametrize( + ("field", "value"), + [ + ("tags", [1]), + ("requires.tools", [False]), + ("requires.mcp", [{}]), + ], +) +def test_string_list_fields_reject_non_string_members(field, value): + data = valid_manifest_dict() + if field == "tags": + data["tags"] = value + else: + data["requires"][field.split(".", 1)[1]] = value + + with pytest.raises(BundlerError, match="must be a list of strings"): + BundleManifest.from_dict(data) + + def test_string_integration_rejected_not_silently_dropped(): # A present-but-non-mapping 'integration' (a bare string) was silently # dropped, leaving the bundle wrongly integration-agnostic. Reject it like