Skip to content

Commit cc505da

Browse files
jawwad-aliclaude
andauthored
fix(bundler): treat an explicit-null records field as missing, not "None" (#4136)
`InstalledBundleRecord.from_dict` and `_component_from_dict` read required fields with `str(data.get(key, "")).strip()`. The `""` default covers only a MISSING key. A key that is present but JSON `null` — how a hand-edited or corrupt `.specify/bundle-records.json` spells an empty field — yields `None`, and `str(None)` is the literal `"None"`. That text is non-empty, so it sails past the required-field guards that exist to reject such a file: bundle_id=null -> ACCEPTED: bundle_id='None' version='1.0.0' version=null -> ACCEPTED: bundle_id='a' version='None' id=null -> ACCEPTED: components=[('presets', 'None')] The phantom component then feeds the collateral-protection refcount, so `components_still_needed()` reports ('presets', 'None') as protected. Reuse `manifest._text`, the sibling helper whose docstring describes this exact trap and which fixed the same bug for bundle.yml in #3798. The falsy-non-list half of this hardening already landed in this file as #3666; the explicit-null half was never mirrored here. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent ea5b856 commit cc505da

2 files changed

Lines changed: 82 additions & 6 deletions

File tree

src/specify_cli/bundler/models/records.py

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313

1414
from .. import BundlerError
1515
from ..lib.yamlio import dump_json, ensure_within, load_json
16-
from .manifest import COMPONENT_KINDS, ComponentRef
16+
from .manifest import COMPONENT_KINDS, ComponentRef, _text
1717

1818
RECORDS_FILENAME = "bundle-records.json"
1919
RECORDS_SCHEMA_VERSION = "1.0"
@@ -65,8 +65,14 @@ def from_dict(cls, data: Any) -> "InstalledBundleRecord":
6565
raise BundlerError(
6666
"Corrupt record: 'contributed_components' must be a list."
6767
)
68-
bundle_id = str(data.get("bundle_id", "")).strip()
69-
version = str(data.get("version", "")).strip()
68+
# ``.get(key, "")`` defaults only a *missing* key. A key that is
69+
# present but null -- how a hand-edited or corrupt record spells an
70+
# empty field -- yields ``None``, and ``str(None)`` is the non-empty
71+
# literal ``"None"``, which sails past the required-field checks
72+
# below. Reuse the manifest's ``_text`` so records and bundle.yml
73+
# agree on what an explicit null means.
74+
bundle_id = _text(data.get("bundle_id"))
75+
version = _text(data.get("version"))
7076
if not bundle_id:
7177
raise BundlerError(
7278
"Corrupt records file: an installed-bundle record is missing "
@@ -80,7 +86,7 @@ def from_dict(cls, data: Any) -> "InstalledBundleRecord":
8086
return cls(
8187
bundle_id=bundle_id,
8288
version=version,
83-
installed_at=str(data.get("installed_at", "")).strip(),
89+
installed_at=_text(data.get("installed_at")),
8490
contributed_components=tuple(
8591
_component_from_dict(c) for c in components_raw
8692
),
@@ -201,8 +207,8 @@ def _component_to_dict(ref: ComponentRef) -> dict[str, Any]:
201207
def _component_from_dict(data: Any) -> ComponentRef:
202208
if not isinstance(data, dict):
203209
raise BundlerError("Each contributed component must be a mapping.")
204-
kind = str(data.get("kind", "")).strip()
205-
cid = str(data.get("id", "")).strip()
210+
kind = _text(data.get("kind"))
211+
cid = _text(data.get("id"))
206212
if kind not in COMPONENT_KINDS:
207213
raise BundlerError(
208214
f"Corrupt records file: component 'kind' must be one of "

tests/unit/test_bundler_records.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,3 +209,73 @@ def test_load_records_accepts_forward_compatible_minor_schema(tmp_path: Path):
209209
payload = {"schema_version": "1.5", "bundles": []}
210210
records_path(tmp_path).write_text(json.dumps(payload), encoding="utf-8")
211211
assert load_records(tmp_path) == []
212+
213+
214+
@pytest.mark.parametrize(
215+
"field,message",
216+
[
217+
("bundle_id", "missing its 'bundle_id'"),
218+
("version", "missing its 'version'"),
219+
],
220+
)
221+
def test_load_records_rejects_explicit_null_record_field(
222+
tmp_path: Path, field: str, message: str
223+
):
224+
"""An explicit JSON ``null`` is how a corrupt record spells an empty field.
225+
226+
``str(data.get(field, ""))`` defaults only a *missing* key, so a
227+
present-but-null value became the literal text ``"None"`` — non-empty, so
228+
it sailed past the required-field checks and the record was accepted as a
229+
bundle actually named ``"None"``. Mirrors ``manifest._text``.
230+
"""
231+
(tmp_path / ".specify").mkdir()
232+
record = {"bundle_id": "a", "version": "1.0.0", "contributed_components": []}
233+
record[field] = None
234+
payload = {"schema_version": "1.0", "bundles": [record]}
235+
records_path(tmp_path).write_text(json.dumps(payload), encoding="utf-8")
236+
237+
with pytest.raises(BundlerError, match=message):
238+
load_records(tmp_path)
239+
240+
241+
def test_load_records_rejects_explicit_null_component_id(tmp_path: Path):
242+
"""A null component id became ``"None"`` and entered the refcount.
243+
244+
``components_still_needed`` would then report a phantom
245+
``('presets', 'None')`` as protected.
246+
"""
247+
(tmp_path / ".specify").mkdir()
248+
payload = {
249+
"schema_version": "1.0",
250+
"bundles": [
251+
{
252+
"bundle_id": "a",
253+
"version": "1.0.0",
254+
"contributed_components": [{"kind": "presets", "id": None}],
255+
}
256+
],
257+
}
258+
records_path(tmp_path).write_text(json.dumps(payload), encoding="utf-8")
259+
260+
with pytest.raises(BundlerError, match="missing its 'id'"):
261+
load_records(tmp_path)
262+
263+
264+
def test_load_records_accepts_explicit_null_installed_at(tmp_path: Path):
265+
"""``installed_at`` is optional, so a null must become "" — not "None"."""
266+
(tmp_path / ".specify").mkdir()
267+
payload = {
268+
"schema_version": "1.0",
269+
"bundles": [
270+
{
271+
"bundle_id": "a",
272+
"version": "1.0.0",
273+
"installed_at": None,
274+
"contributed_components": [],
275+
}
276+
],
277+
}
278+
records_path(tmp_path).write_text(json.dumps(payload), encoding="utf-8")
279+
280+
records = load_records(tmp_path)
281+
assert records[0].installed_at == ""

0 commit comments

Comments
 (0)