Skip to content

Commit 6e5ae19

Browse files
Reject non-JSON-compatible values in canonical_json before hashing
Assisted-by: GitHub Copilot (model: claude-opus, autonomous) Co-authored-by: nicolehaugen <10600161+nicolehaugen@users.noreply.github.com>
1 parent 6088ff3 commit 6e5ae19

2 files changed

Lines changed: 27 additions & 1 deletion

File tree

src/specify_cli/_identifier.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,15 @@ def canonical_json(value: Any) -> bytes:
136136
are emitted verbatim. This is the byte string that the hook discriminator
137137
hashes and that the manifest loader uses to detect byte-identical duplicate
138138
hook entries.
139+
140+
Only JSON-compatible scalars (``None``, ``str``, ``bool``, ``int``,
141+
``float``), mappings, and lists/tuples are accepted. ``yaml.safe_load()``
142+
can produce values outside that set — for example an unquoted date is
143+
parsed as ``datetime.date`` — and passing one through here would otherwise
144+
surface as a raw, uncaught ``TypeError`` from ``json.dumps()`` deep inside
145+
this function. Callers get a clean ``IdentifierComponentError`` instead, so
146+
a manifest with such a value can be rejected the same way as any other
147+
invalid declared field.
139148
"""
140149
normalized = _normalize_for_canonical_json(value)
141150
return json.dumps(
@@ -147,11 +156,16 @@ def canonical_json(value: Any) -> bytes:
147156

148157

149158
def _normalize_for_canonical_json(value: Any) -> Any:
159+
if value is None or isinstance(value, (str, bool, int, float)):
160+
return value
150161
if isinstance(value, Mapping):
151162
return {str(k): _normalize_for_canonical_json(v) for k, v in value.items()}
152163
if isinstance(value, (list, tuple)):
153164
return [_normalize_for_canonical_json(v) for v in value]
154-
return value
165+
raise IdentifierComponentError(
166+
f"Value of type {type(value).__name__!r} is not JSON-compatible and "
167+
"cannot be canonicalized"
168+
)
155169

156170

157171
def _has_hook_sibling_collision(

tests/test_contribution_ids.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,18 @@ def test_preserves_list_order(self):
173173
def test_utf8_no_ensure_ascii(self):
174174
assert canonical_json({"k": "café"}).decode("utf-8") == '{"k":"café"}'
175175

176+
def test_rejects_non_json_scalar(self):
177+
with pytest.raises(IdentifierComponentError):
178+
canonical_json(datetime.date(2026, 1, 1))
179+
180+
def test_rejects_non_json_scalar_nested_in_mapping(self):
181+
with pytest.raises(IdentifierComponentError):
182+
canonical_json({"prompt": datetime.date(2026, 1, 1)})
183+
184+
def test_rejects_non_json_scalar_nested_in_list(self):
185+
with pytest.raises(IdentifierComponentError):
186+
canonical_json([datetime.date(2026, 1, 1)])
187+
176188

177189
# ---------------------------------------------------------------------------
178190
# Hook discriminator behaviour

0 commit comments

Comments
 (0)