Skip to content

Commit 0a7f288

Browse files
jawwad-aliclaude
andauthored
fix(bundler): reject falsy non-mapping requires/provides in manifest from_dict (#3661)
* fix(bundler): reject falsy non-mapping requires/provides in manifest from_dict BundleManifest.from_dict used `data.get("requires") or {}` and `data.get("provides") or {}`, so a FALSY non-mapping value ([], '', 0, false) was coerced to {} BEFORE the isinstance guard — a malformed manifest passed validation as one that requires/provides nothing. Only a truthy non-mapping (e.g. "extensions") was rejected. Handle None explicitly (default to {}) and reject every other non-mapping, matching the sibling 'integration' guard added in #3629. Absent fields still parse to the empty default. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(bundler): correct absent-optional-mapping regression assertion --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 5e384bb commit 0a7f288

2 files changed

Lines changed: 39 additions & 4 deletions

File tree

src/specify_cli/bundler/models/manifest.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -111,8 +111,10 @@ def from_dict(cls, data: Any) -> "BundleManifest":
111111
license=str(bundle_raw.get("license", "")).strip(),
112112
)
113113

114-
requires_raw = data.get("requires") or {}
115-
if not isinstance(requires_raw, dict):
114+
requires_raw = data.get("requires")
115+
if requires_raw is None:
116+
requires_raw = {}
117+
elif not isinstance(requires_raw, dict):
116118
raise BundlerError("'requires' must be a mapping when present.")
117119
requires = Requires(
118120
speckit_version=str(requires_raw.get("speckit_version", "")).strip(),
@@ -130,8 +132,10 @@ def from_dict(cls, data: Any) -> "BundleManifest":
130132
if isinstance(integration_raw, dict) and integration_raw.get("id"):
131133
integration = IntegrationRef(id=str(integration_raw["id"]).strip())
132134

133-
provides = data.get("provides") or {}
134-
if not isinstance(provides, dict):
135+
provides = data.get("provides")
136+
if provides is None:
137+
provides = {}
138+
elif not isinstance(provides, dict):
135139
raise BundlerError("'provides' must be a mapping when present.")
136140

137141
tags_raw = data.get("tags")

tests/contract/test_manifest_schema.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,3 +134,34 @@ def test_string_integration_rejected_not_silently_dropped():
134134
data["integration"] = "copilot"
135135
with pytest.raises(BundlerError, match="'integration' must be a mapping when present"):
136136
BundleManifest.from_dict(data)
137+
138+
139+
@pytest.mark.parametrize("bad", [[], "", 0, False, "extensions"])
140+
def test_non_mapping_provides_rejected_including_falsy(bad):
141+
# `data.get("provides") or {}` coerced a FALSY non-mapping ([], '', 0, False)
142+
# to {} before the type check, so a malformed manifest passed validation as
143+
# a bundle that provides nothing. Only an absent/None value means "empty".
144+
data = valid_manifest_dict()
145+
data["provides"] = bad
146+
with pytest.raises(BundlerError, match="'provides' must be a mapping when present"):
147+
BundleManifest.from_dict(data)
148+
149+
150+
@pytest.mark.parametrize("bad", [[], "", 0, False, "speckit>=0.1"])
151+
def test_non_mapping_requires_rejected_including_falsy(bad):
152+
# Same falsy-coercion hole for `requires`.
153+
data = valid_manifest_dict()
154+
data["requires"] = bad
155+
with pytest.raises(BundlerError, match="'requires' must be a mapping when present"):
156+
BundleManifest.from_dict(data)
157+
158+
159+
def test_absent_provides_and_requires_do_not_raise_mapping_error():
160+
# Absent (None) optional mappings default to empty and must NOT trigger the
161+
# "must be a mapping when present" guard — that is reserved for present
162+
# non-mappings. (Structural completeness, e.g. requires.speckit_version, is
163+
# a separate concern checked by structural_errors().)
164+
data = valid_manifest_dict()
165+
data.pop("provides", None)
166+
data.pop("requires", None)
167+
BundleManifest.from_dict(data) # does not raise BundlerError

0 commit comments

Comments
 (0)