Skip to content

Commit 244d793

Browse files
jawwad-aliclaude
andcommitted
fix(bundler): reject falsy non-list bundles/contributed_components in records
load_records and InstalledBundleRecord.from_dict defaulted their list fields with `data.get(...) or []` BEFORE the isinstance(list) guard, so a FALSY non-list value (0, '', False, {}) was coerced to [] and the guard became dead code — a corrupt .specify/bundle-records.json was silently read as "no bundles"/"no components" instead of raising. Only an absent/None value should mean empty. Handle None explicitly and reject every other non-list, mirroring the merged requires/provides/integration guards (#3629, #3661) and the catalog_config sibling reader. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 3356161 commit 244d793

2 files changed

Lines changed: 35 additions & 4 deletions

File tree

src/specify_cli/bundler/models/records.py

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,13 @@ def to_dict(self) -> dict[str, Any]:
5555
def from_dict(cls, data: Any) -> "InstalledBundleRecord":
5656
if not isinstance(data, dict):
5757
raise BundlerError("Each installed-bundle record must be a mapping.")
58-
components_raw = data.get("contributed_components") or []
59-
if not isinstance(components_raw, list):
58+
components_raw = data.get("contributed_components")
59+
if components_raw is None:
60+
components_raw = []
61+
elif not isinstance(components_raw, list):
62+
# `or []` would coerce a FALSY non-list (0, '', False, {}) to []
63+
# before this guard, silently accepting a corrupt record; only an
64+
# absent/None value means "no components".
6065
raise BundlerError(
6166
"Corrupt record: 'contributed_components' must be a list."
6267
)
@@ -121,8 +126,13 @@ def load_records(project_root: Path) -> list[InstalledBundleRecord]:
121126
if not isinstance(data, dict):
122127
raise BundlerError(f"Corrupt records file: {path}")
123128
_check_schema_version(data.get("schema_version"), path=path, required=True)
124-
bundles = data.get("bundles") or []
125-
if not isinstance(bundles, list):
129+
bundles = data.get("bundles")
130+
if bundles is None:
131+
bundles = []
132+
elif not isinstance(bundles, list):
133+
# `or []` would coerce a FALSY non-list (0, '', False, {}) to [] before
134+
# this guard, silently treating a corrupt file as "no bundles"; only an
135+
# absent/None value means empty.
126136
raise BundlerError(
127137
f"Corrupt records file: {path} — 'bundles' must be a list."
128138
)

tests/unit/test_bundler_records.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,27 @@ def test_load_missing_file_returns_empty(tmp_path: Path):
4545
assert load_records(tmp_path) == []
4646

4747

48+
@pytest.mark.parametrize("bad", [0, False, "", {}])
49+
def test_load_records_rejects_falsy_non_list_bundles(tmp_path: Path, bad):
50+
# `data.get("bundles") or []` coerced a FALSY non-list (0, '', False, {})
51+
# to [] before the isinstance guard, silently treating a corrupt records
52+
# file as "no bundles". Only an absent/None value means empty.
53+
(tmp_path / ".specify").mkdir()
54+
records_path(tmp_path).write_text(
55+
json.dumps({"schema_version": "1.0", "bundles": bad}), encoding="utf-8"
56+
)
57+
with pytest.raises(BundlerError, match="'bundles' must be a list"):
58+
load_records(tmp_path)
59+
60+
61+
@pytest.mark.parametrize("bad", [0, False, "", {}])
62+
def test_from_dict_rejects_falsy_non_list_contributed_components(bad):
63+
# Same falsy-coercion hole for a record's 'contributed_components'.
64+
data = {"bundle_id": "a", "version": "1.0.0", "contributed_components": bad}
65+
with pytest.raises(BundlerError, match="'contributed_components' must be a list"):
66+
InstalledBundleRecord.from_dict(data)
67+
68+
4869
def test_corrupt_priority_raises_actionable_error(tmp_path: Path):
4970
(tmp_path / ".specify").mkdir()
5071
rec = _record("a", [("presets", "p1")])

0 commit comments

Comments
 (0)