From 244d793ed9b27f58dedbe421e4e9585d0ba24fa8 Mon Sep 17 00:00:00 2001 From: jawwad-ali Date: Thu, 23 Jul 2026 00:10:37 +0500 Subject: [PATCH] fix(bundler): reject falsy non-list bundles/contributed_components in records MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/specify_cli/bundler/models/records.py | 18 ++++++++++++++---- tests/unit/test_bundler_records.py | 21 +++++++++++++++++++++ 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/src/specify_cli/bundler/models/records.py b/src/specify_cli/bundler/models/records.py index 15c53523c3..2d0c8b73a0 100644 --- a/src/specify_cli/bundler/models/records.py +++ b/src/specify_cli/bundler/models/records.py @@ -55,8 +55,13 @@ def to_dict(self) -> dict[str, Any]: def from_dict(cls, data: Any) -> "InstalledBundleRecord": if not isinstance(data, dict): raise BundlerError("Each installed-bundle record must be a mapping.") - components_raw = data.get("contributed_components") or [] - if not isinstance(components_raw, list): + components_raw = data.get("contributed_components") + if components_raw is None: + components_raw = [] + elif not isinstance(components_raw, list): + # `or []` would coerce a FALSY non-list (0, '', False, {}) to [] + # before this guard, silently accepting a corrupt record; only an + # absent/None value means "no components". raise BundlerError( "Corrupt record: 'contributed_components' must be a list." ) @@ -121,8 +126,13 @@ def load_records(project_root: Path) -> list[InstalledBundleRecord]: if not isinstance(data, dict): raise BundlerError(f"Corrupt records file: {path}") _check_schema_version(data.get("schema_version"), path=path, required=True) - bundles = data.get("bundles") or [] - if not isinstance(bundles, list): + bundles = data.get("bundles") + if bundles is None: + bundles = [] + elif not isinstance(bundles, list): + # `or []` would coerce a FALSY non-list (0, '', False, {}) to [] before + # this guard, silently treating a corrupt file as "no bundles"; only an + # absent/None value means empty. raise BundlerError( f"Corrupt records file: {path} — 'bundles' must be a list." ) diff --git a/tests/unit/test_bundler_records.py b/tests/unit/test_bundler_records.py index 21771adb0f..8f6f0d6547 100644 --- a/tests/unit/test_bundler_records.py +++ b/tests/unit/test_bundler_records.py @@ -45,6 +45,27 @@ def test_load_missing_file_returns_empty(tmp_path: Path): assert load_records(tmp_path) == [] +@pytest.mark.parametrize("bad", [0, False, "", {}]) +def test_load_records_rejects_falsy_non_list_bundles(tmp_path: Path, bad): + # `data.get("bundles") or []` coerced a FALSY non-list (0, '', False, {}) + # to [] before the isinstance guard, silently treating a corrupt records + # file as "no bundles". Only an absent/None value means empty. + (tmp_path / ".specify").mkdir() + records_path(tmp_path).write_text( + json.dumps({"schema_version": "1.0", "bundles": bad}), encoding="utf-8" + ) + with pytest.raises(BundlerError, match="'bundles' must be a list"): + load_records(tmp_path) + + +@pytest.mark.parametrize("bad", [0, False, "", {}]) +def test_from_dict_rejects_falsy_non_list_contributed_components(bad): + # Same falsy-coercion hole for a record's 'contributed_components'. + data = {"bundle_id": "a", "version": "1.0.0", "contributed_components": bad} + with pytest.raises(BundlerError, match="'contributed_components' must be a list"): + InstalledBundleRecord.from_dict(data) + + def test_corrupt_priority_raises_actionable_error(tmp_path: Path): (tmp_path / ".specify").mkdir() rec = _record("a", [("presets", "p1")])