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
18 changes: 14 additions & 4 deletions src/specify_cli/bundler/models/records.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."
)
Expand Down Expand Up @@ -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."
)
Expand Down
21 changes: 21 additions & 0 deletions tests/unit/test_bundler_records.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")])
Expand Down