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")])