Skip to content
Open
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
10 changes: 6 additions & 4 deletions src/specify_cli/bundler/models/manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down
19 changes: 19 additions & 0 deletions tests/contract/test_manifest_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down