From bfa47f36a3368b82249ba168cc0f4f9d25affedf Mon Sep 17 00:00:00 2001 From: Kuang Mi Date: Fri, 2 Oct 2026 10:11:36 +0900 Subject: [PATCH] fix(jcs): count open containers for the nesting bound An empty container was accepted one level past MAX_DEPTH because the counter charges a level per value on the path (0-based) instead of per open container (outermost at 1), so the effective bound depended on whether the innermost value was a container or a scalar. `_clean_empty` runs before canonicalization and carried the same counter. Adds the jcs_depth_v1 corpus as tests: preimage digests first, published bytes and SHA-256 for the accepts, a catchable refusal for the rejects, and the empty-container boundary pair through both the canonicalizer and the pre-pass. --- src/a2a/utils/_jcs.py | 13 +- src/a2a/utils/signing.py | 18 ++- tests/utils/jcs_depth_vectors.json | 227 +++++++++++++++++++++++++++++ tests/utils/test_jcs.py | 102 +++++++++++++ 4 files changed, 352 insertions(+), 8 deletions(-) create mode 100644 tests/utils/jcs_depth_vectors.json diff --git a/src/a2a/utils/_jcs.py b/src/a2a/utils/_jcs.py index 96f185ac7..111d46995 100644 --- a/src/a2a/utils/_jcs.py +++ b/src/a2a/utils/_jcs.py @@ -81,7 +81,7 @@ def canonicalize(obj: Any) -> str: than `MAX_DEPTH`. """ out: list[str] = [] - _write(obj, out, 0) + _write(obj, out, 1) canonical = ''.join(out) try: # RFC 8785 canonical output is UTF-8. Round-tripping here rejects @@ -95,9 +95,14 @@ def canonicalize(obj: Any) -> str: return canonical +def _child_depth(value: Any, depth: int) -> int: + """Returns the depth to hand a child: containers open a level, scalars do not.""" + return depth + 1 if isinstance(value, (list, tuple, dict)) else depth + + def _write(obj: Any, out: list[str], depth: int) -> None: """Appends the canonical form of `obj` to `out`.""" - if depth > MAX_DEPTH: + if isinstance(obj, (list, tuple, dict)) and depth > MAX_DEPTH: raise CanonicalizationError( f'nesting exceeds the maximum depth of {MAX_DEPTH}' ) @@ -107,7 +112,7 @@ def _write(obj: Any, out: list[str], depth: int) -> None: for index, element in enumerate(obj): if index: out.append(',') - _write(element, out, depth + 1) + _write(element, out, _child_depth(element, depth)) out.append(']') elif isinstance(obj, dict): out.append('{') @@ -116,7 +121,7 @@ def _write(obj: Any, out: list[str], depth: int) -> None: out.append(',') out.append(_quote(key)) out.append(':') - _write(value, out, depth + 1) + _write(value, out, _child_depth(value, depth)) out.append('}') else: out.append(_format_scalar(obj)) diff --git a/src/a2a/utils/signing.py b/src/a2a/utils/signing.py index c85a80072..782fd82b6 100644 --- a/src/a2a/utils/signing.py +++ b/src/a2a/utils/signing.py @@ -164,7 +164,7 @@ def signature_verifier( return signature_verifier -def _clean_empty(d: Any, depth: int = 0) -> Any: +def _clean_empty(d: Any, depth: int = 1) -> Any: """Recursively remove empty strings, lists and dicts from a dictionary. Depth is bounded for the same reason canonicalization is: nesting reaches @@ -172,7 +172,7 @@ def _clean_empty(d: Any, depth: int = 0) -> Any: nested card exhausts the interpreter stack here, before the canonicalizer ever gets the chance to reject it. """ - if depth > MAX_DEPTH: + if isinstance(d, (dict, list)) and depth > MAX_DEPTH: raise CanonicalizationError( f'nesting exceeds the maximum depth of {MAX_DEPTH}' ) @@ -180,14 +180,24 @@ def _clean_empty(d: Any, depth: int = 0) -> Any: cleaned_dict = { k: cleaned_v for k, v in d.items() - if (cleaned_v := _clean_empty(v, depth + 1)) is not None + if ( + cleaned_v := _clean_empty( + v, depth + 1 if isinstance(v, (dict, list)) else depth + ) + ) + is not None } return cleaned_dict or None if isinstance(d, list): cleaned_list = [ cleaned_v for v in d - if (cleaned_v := _clean_empty(v, depth + 1)) is not None + if ( + cleaned_v := _clean_empty( + v, depth + 1 if isinstance(v, (dict, list)) else depth + ) + ) + is not None ] return cleaned_list or None if isinstance(d, str) and not d: diff --git a/tests/utils/jcs_depth_vectors.json b/tests/utils/jcs_depth_vectors.json new file mode 100644 index 000000000..391c9023c --- /dev/null +++ b/tests/utils/jcs_depth_vectors.json @@ -0,0 +1,227 @@ +{ + "corpus": "jcs_depth_v1", + "suite": "a2a-agent-card-signature-conformance", + "layer": "nesting-bound", + "specRef": "https://www.rfc-editor.org/rfc/rfc8785 (no nesting limit stated) and https://a2aproject.org/specification/#841-canonicalization-requirements", + "scope": "The input a canonicalizer must refuse. RFC 8785 pins the bytes a canonicalizer must produce and states no bound on how deeply an input may nest, so two implementations that agree on every byte-level vector can still disagree on whether a deep artifact canonicalizes at all. Canonicalization runs before signature verification, so the walk is reachable without a key.", + "max_depth": 128, + "depth_counting_rule": "Depth counts OPEN CONTAINERS: the outermost brace or bracket is depth 1, and every object or array opened inside it adds one, an empty container included. A counter that charges a level per parsed child instead never charges an empty container and lands one level off - which is what the empty-container vectors separate.", + "reference_impl": "Python rfc8785 0.1.4 (Trail of Bits)", + "attribution": { + "corpus": "jcs_depth_v1", + "author": "Sankalp Gilda", + "license": "Apache-2.0", + "upstream": "https://github.com/a2aproject/A2A/pull/2246 (proposals/content-integrity-profile/vectors/jcs_depth_v1)", + "source_corpus": { + "name": "agent-evidence-vectors", + "suite": "adversarial-execution-evidence-conformance", + "repository": "https://github.com/astrogilda/agent-evidence-vectors" + }, + "note": "Retained as published: every expected byte string was produced by canonicalising the materialised preimage with the reference implementation above, and all preimage digests are checked before any expectation is used." + }, + "preimage_rule_form": { + "note": "Preimages are given as nesting rules rather than literal JSON so this file stays four levels deep and can be read by a parser that enforces the bound it describes. Materialise as text: for i from 0 to count-1 emit the opener for containers[i % len(containers)] ('{\"a\":' for object, '[' for array), then emit leaf, then emit the matching closers innermost-first.", + "openers": { + "object": "{\"a\":", + "array": "[" + }, + "closers": { + "object": "}", + "array": "]" + } + }, + "vectors": [ + { + "vector_id": "jcs-depth-001-object-at-bound", + "description": "128 nested objects, the deepest input the bound admits. A canonicaliser that applies the bound one level early refuses this and is off by one.", + "outcome": "accept", + "depth": 128, + "preimage_rule": { + "form": "nest", + "containers": [ + "object" + ], + "count": 128, + "leaf": "1" + }, + "preimage_bytes": 769, + "preimage_sha256": "a4908c65856c2fb1e94d6b2b55620177bd082f54d43252b9e0648f9ccd53e3fe", + "expected_jcs_bytes_b64": "eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOjF9fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fQ==", + "expected_sha256": "a4908c65856c2fb1e94d6b2b55620177bd082f54d43252b9e0648f9ccd53e3fe", + "trace": { + "corpus": "agent-evidence-vectors" + } + }, + { + "vector_id": "jcs-depth-002-array-at-bound", + "description": "128 nested arrays at the bound. Separated from the object case because a depth counter placed only in the object branch never charges an array.", + "outcome": "accept", + "depth": 128, + "preimage_rule": { + "form": "nest", + "containers": [ + "array" + ], + "count": 128, + "leaf": "1" + }, + "preimage_bytes": 257, + "preimage_sha256": "68da6c21da4d39e99f241a56379e98c2053372e77cd74f72400af6d3a37261c3", + "expected_jcs_bytes_b64": "W1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1sxXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV0=", + "expected_sha256": "68da6c21da4d39e99f241a56379e98c2053372e77cd74f72400af6d3a37261c3", + "trace": { + "corpus": "agent-evidence-vectors" + } + }, + { + "vector_id": "jcs-depth-003-alternating-at-bound", + "description": "128 containers alternating object, array, object, array at the bound. Catches a counter that tracks one container kind and resets on the other.", + "outcome": "accept", + "depth": 128, + "preimage_rule": { + "form": "nest", + "containers": [ + "object", + "array" + ], + "count": 128, + "leaf": "1" + }, + "preimage_bytes": 513, + "preimage_sha256": "aa4e4f042129f600f2352f2ecd58559409175fd83cf4918c382afc3d33942268", + "expected_jcs_bytes_b64": "eyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbMV19XX1dfV19XX1dfV19XX1dfV19XX1dfV19XX1dfV19XX1dfV19XX1dfV19XX1dfV19XX1dfV19XX1dfV19XX1dfV19XX1dfV19XX1dfV19XX1dfV19XX1dfV19XX1dfV19XX1dfV19XX1dfV19XX1dfV19XX1dfV19XX1dfV19", + "expected_sha256": "aa4e4f042129f600f2352f2ecd58559409175fd83cf4918c382afc3d33942268", + "trace": { + "corpus": "agent-evidence-vectors" + } + }, + { + "vector_id": "jcs-depth-004-empty-object-leaf-at-bound", + "description": "127 wrapping objects around an empty object. The empty container is itself the 128th open container, so this sits exactly at the bound and is accepted.", + "outcome": "accept", + "depth": 128, + "preimage_rule": { + "form": "nest", + "containers": [ + "object" + ], + "count": 127, + "leaf": "{}" + }, + "preimage_bytes": 764, + "preimage_sha256": "95abadd19f4a27dd41c2e3b46baca7320d039f4e4686fcdaebb0a7d7146bb16e", + "expected_jcs_bytes_b64": "eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX0=", + "expected_sha256": "95abadd19f4a27dd41c2e3b46baca7320d039f4e4686fcdaebb0a7d7146bb16e", + "trace": { + "corpus": "agent-evidence-vectors" + } + }, + { + "vector_id": "jcs-depth-101-object-one-past-bound", + "description": "129 nested objects, one level past the bound. Differs from jcs-depth-001-object-at-bound by exactly one wrapping object.", + "outcome": "reject", + "depth": 129, + "preimage_rule": { + "form": "nest", + "containers": [ + "object" + ], + "count": 129, + "leaf": "1" + }, + "preimage_bytes": 775, + "preimage_sha256": "eeb23e8c9c090d0303063348ae7ca4a5914992972fc20e295e8e386802e2a95a", + "trace": { + "corpus": "agent-evidence-vectors", + "sibling_vector_id": "v08251931ec038e91" + } + }, + { + "vector_id": "jcs-depth-102-array-one-past-bound", + "description": "129 nested arrays, one level past the bound.", + "outcome": "reject", + "depth": 129, + "preimage_rule": { + "form": "nest", + "containers": [ + "array" + ], + "count": 129, + "leaf": "1" + }, + "preimage_bytes": 259, + "preimage_sha256": "84aaf90be3265f8e148bdcc72153a59156d358f74e3dedd2d6a5dd566f5944f5", + "trace": { + "corpus": "agent-evidence-vectors" + } + }, + { + "vector_id": "jcs-depth-103-empty-object-leaf-one-past-bound", + "description": "128 wrapping objects around an empty object: open-container depth 129, one past the bound. This is the case a scalar leaf cannot discriminate. A canonicaliser that charges a level per parsed child rather than per opened container never charges the empty object, reads this as depth 128 and accepts it while refusing jcs-depth-101-object-one-past-bound.", + "outcome": "reject", + "depth": 129, + "preimage_rule": { + "form": "nest", + "containers": [ + "object" + ], + "count": 128, + "leaf": "{}" + }, + "preimage_bytes": 770, + "preimage_sha256": "47ec83edd0d185f58e09a843867e31af9088c4da554f55c730c52f547161a8b1", + "trace": { + "corpus": "agent-evidence-vectors", + "sibling_vector_id": "v83f4b7fe6068ef86" + } + }, + { + "vector_id": "jcs-depth-104-unbounded-recursion", + "description": "Ten million nested arrays, about 20 MB of brackets. A canonicaliser that recurses once per level exhausts the call stack here rather than returning a rejection, and in a compiled runtime that unwind is not catchable. A byte cap alone does not close it: pure nesting stays small per level, so the input reaches any plausible size limit only long after it has passed any plausible stack. Refusal must be reached from the depth bound, before the recursive walk is entered.", + "outcome": "reject", + "depth": 10000000, + "preimage_rule": { + "form": "nest", + "containers": [ + "array" + ], + "count": 10000000, + "leaf": "1" + }, + "preimage_bytes": 20000001, + "preimage_sha256": "0cc26ae2e8031215e24f98189a86d1af2a3f47c4335b3b631b8cc4ff41dd811c", + "trace": { + "corpus": "agent-evidence-vectors" + } + } + ], + "pair_invariants": [ + { + "name": "object_bound_is_exact", + "a": "jcs-depth-001-object-at-bound", + "b": "jcs-depth-101-object-one-past-bound", + "relation": "accept_then_reject", + "why": "The two inputs differ by one wrapping object. An implementation that accepts both has no bound; one that refuses both has the bound off by one. Only the pair locates it." + }, + { + "name": "array_bound_is_exact", + "a": "jcs-depth-002-array-at-bound", + "b": "jcs-depth-102-array-one-past-bound", + "relation": "accept_then_reject", + "why": "The same boundary in the array branch, which a counter placed only in the object branch never reaches." + }, + { + "name": "empty_container_charges_a_level", + "a": "jcs-depth-004-empty-object-leaf-at-bound", + "b": "jcs-depth-103-empty-object-leaf-one-past-bound", + "relation": "accept_then_reject", + "why": "A per-child counter never charges an empty container, so it reads the second input as depth 128 and accepts it. Both scalar-leaf vectors pass under that counter, which is why this pair exists." + }, + { + "name": "refusal_is_not_a_crash", + "vector": "jcs-depth-104-unbounded-recursion", + "relation": "reject_without_stack_exhaustion", + "why": "The vector is passed only if the implementation returns a rejection to its caller. A process that dies on this input has not rejected it, and a crash on an unauthenticated artifact is the outcome the bound exists to prevent." + } + ] +} diff --git a/tests/utils/test_jcs.py b/tests/utils/test_jcs.py index f897cc542..5e351bcbd 100644 --- a/tests/utils/test_jcs.py +++ b/tests/utils/test_jcs.py @@ -6,6 +6,8 @@ own output. """ +import base64 +import hashlib import json import math import random @@ -368,6 +370,106 @@ def test_deep_arrays_are_bounded(): canonicalize(value) +# --- Nesting bound: the jcs_depth_v1 corpus ------------------------------- + +# `jcs_depth_vectors.json` is the `jcs_depth_v1` corpus. It pins the input a +# canonicalizer must REFUSE rather than the bytes it must produce, because RFC +# 8785 states no nesting limit and canonicalization runs before signature +# verification. Depth counts open containers, the outermost at depth 1, and an +# empty container is its own level. +_DEPTH_VECTORS = json.loads( + (Path(__file__).parent / 'jcs_depth_vectors.json').read_text( + encoding='utf-8' + ) +) +_DEPTH_ACCEPT = [ + v for v in _DEPTH_VECTORS['vectors'] if v['outcome'] == 'accept' +] +_DEPTH_REJECT = [ + v for v in _DEPTH_VECTORS['vectors'] if v['outcome'] == 'reject' +] + +_DEPTH_OPENERS = {'object': '{"a":', 'array': '['} +_DEPTH_CLOSERS = {'object': '}', 'array': ']'} + + +def _materialise_depth(rule: dict[str, Any]) -> str: + """Materialises a preimage rule into the JSON text it describes.""" + containers = rule['containers'] + opened = ''.join( + _DEPTH_OPENERS[containers[i % len(containers)]] + for i in range(rule['count']) + ) + closed = ''.join( + _DEPTH_CLOSERS[containers[i % len(containers)]] + for i in range(rule['count'] - 1, -1, -1) + ) + return opened + rule['leaf'] + closed + + +def _empty_leaf_nest(depth: int) -> dict[str, Any]: + """Wraps `{}` in `depth` objects: the shape an empty container sits in.""" + value: Any = {} + for _ in range(depth): + value = {'a': value} + return value + + +@pytest.mark.parametrize( + 'vector', _DEPTH_VECTORS['vectors'], ids=lambda v: v['vector_id'] +) +def test_depth_vector_preimage_is_the_pinned_one(vector): + """The input is checked before the output: a vector built wrong proves nothing.""" + encoded = _materialise_depth(vector['preimage_rule']).encode('utf-8') + assert len(encoded) == vector['preimage_bytes'] + assert hashlib.sha256(encoded).hexdigest() == vector['preimage_sha256'] + + +@pytest.mark.parametrize('vector', _DEPTH_ACCEPT, ids=lambda v: v['vector_id']) +def test_depth_at_the_bound_is_canonicalised(vector): + """The deepest input the limit admits, the empty-container leaf included.""" + canonical = canonicalize( + json.loads(_materialise_depth(vector['preimage_rule'])) + ) + encoded = canonical.encode('utf-8') + assert encoded == base64.b64decode(vector['expected_jcs_bytes_b64']) + assert hashlib.sha256(encoded).hexdigest() == vector['expected_sha256'] + + +@pytest.mark.parametrize('vector', _DEPTH_REJECT, ids=lambda v: v['vector_id']) +def test_depth_past_the_bound_is_refused(vector): + """One container past the limit is refused whatever the innermost value is. + + `jcs-depth-104` never reaches the walk: the JSON decoder refuses a + ten-million-level input first. Both refusals are catchable and neither + returns bytes, which is what the vector asks for. + """ + with pytest.raises((CanonicalizationError, RecursionError)): + canonicalize(json.loads(_materialise_depth(vector['preimage_rule']))) + + +def test_an_empty_container_leaf_is_its_own_level(): + """The case a per-child counter misses. + + A counter that charges a level on recursion into a child never charges an + empty container, so it accepts one container too many here while still + refusing the same depth wrapped around a scalar. + """ + at_bound = _empty_leaf_nest(MAX_DEPTH - 1) + assert canonicalize(at_bound) == ( + '{"a":' * (MAX_DEPTH - 1) + '{}' + '}' * (MAX_DEPTH - 1) + ) + with pytest.raises(CanonicalizationError): + canonicalize(_empty_leaf_nest(MAX_DEPTH)) + + +def test_clean_empty_counts_containers_too(): + """`_clean_empty` runs first, so it has to hold the same bound.""" + signing._clean_empty(_empty_leaf_nest(MAX_DEPTH - 1)) + with pytest.raises(CanonicalizationError): + signing._clean_empty(_empty_leaf_nest(MAX_DEPTH)) + + # --- Types with no canonical form ---