From e75cc75f2740bb5eceae4a50b14c7a69ce6cbaeb Mon Sep 17 00:00:00 2001 From: kuangmi-bit Date: Sun, 4 Oct 2026 09:45:44 +0800 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. Review follow-up: only containers recurse, so only containers carry a depth worth checking. The per-child `_child_depth` helper and the two `isinstance` ternaries in `_clean_empty` are gone; the recursion passes `depth + 1` outright. The deep vector drops from ten million levels to 500, shallow enough that the JSON decoder accepts it, so the reject cases now prove the bound refuses the input instead of the decoder running out of stack first. --- src/a2a/utils/_jcs.py | 11 +- src/a2a/utils/signing.py | 7 +- tests/utils/jcs_depth_vectors.json | 227 +++++++++++++++++++++++++++++ tests/utils/test_jcs.py | 103 +++++++++++++ 4 files changed, 342 insertions(+), 6 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..90fcf67dd 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 @@ -96,8 +96,13 @@ def canonicalize(obj: Any) -> str: def _write(obj: Any, out: list[str], depth: int) -> None: - """Appends the canonical form of `obj` to `out`.""" - if depth > MAX_DEPTH: + """Appends the canonical form of `obj` to `out`. + + `depth` is the number of open containers on the path to `obj`, the + outermost at 1. Only containers are recursed into, so only containers + carry a depth worth checking; a scalar's depth is never read. + """ + if isinstance(obj, (list, tuple, dict)) and depth > MAX_DEPTH: raise CanonicalizationError( f'nesting exceeds the maximum depth of {MAX_DEPTH}' ) diff --git a/src/a2a/utils/signing.py b/src/a2a/utils/signing.py index c85a80072..555b4ef07 100644 --- a/src/a2a/utils/signing.py +++ b/src/a2a/utils/signing.py @@ -164,15 +164,16 @@ 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 this function from `AgentExtension.params`, and without the bound a deeply nested card exhausts the interpreter stack here, before the canonicalizer - ever gets the chance to reject it. + ever gets the chance to reject it. `depth` counts open containers on the + path to `d`, the outermost at 1, and matches `_jcs.MAX_DEPTH`'s rule. """ - if depth > MAX_DEPTH: + if isinstance(d, (dict, list)) and depth > MAX_DEPTH: raise CanonicalizationError( f'nesting exceeds the maximum depth of {MAX_DEPTH}' ) diff --git a/tests/utils/jcs_depth_vectors.json b/tests/utils/jcs_depth_vectors.json new file mode 100644 index 000000000..168b0c7c3 --- /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-deep-input-refused-not-crashed", + "description": "500 nested arrays: well past the bound, but shallow enough that the JSON decoder accepts the input (CPython's decoder recurses to about 1000 levels), so the refusal has to come from the depth bound and reach the caller as a catchable rejection rather than as a decoder failure or a stack crash. A payload of ten million levels does not test that: json.loads refuses it before canonicalization is entered at all, so the vector would pin the decoder's own recursion limit instead of the bound.", + "outcome": "reject", + "depth": 500, + "preimage_rule": { + "form": "nest", + "containers": [ + "array" + ], + "count": 500, + "leaf": "1" + }, + "preimage_bytes": 1001, + "preimage_sha256": "a655facd9262ddb5ddf32b1f4f8d937fb2ded910b13fe7f12c44abb2783756eb", + "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-deep-input-refused-not-crashed", + "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..78992c393 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,107 @@ 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. + + The refusal is `CanonicalizationError` for every reject vector: the deepest + payload here (jcs-depth-104) is far past the bound but still shallow enough + for the JSON decoder to accept it, so the bound - not a decoder limit and + not a stack exhaustion - is what the caller sees. + """ + with pytest.raises(CanonicalizationError): + 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 ---