Skip to content

fix(jcs): count open containers for the nesting bound, so an empty container leaf is refused - #1294

Merged
mykytanetipa merged 2 commits into
a2aproject:mainfrom
kuangmi-bit:fix/jcs-depth-counts-containers
Oct 5, 2026
Merged

mykytanetipa merged 2 commits into
a2aproject:mainfrom
kuangmi-bit:fix/jcs-depth-counts-containers

Conversation

@kuangmi-bit

Copy link
Copy Markdown
Contributor

What

_jcs.canonicalize bounds nesting at MAX_DEPTH = 128, but the counter tracks values on the path (0-based) rather than open containers (outermost at 1). An empty container is therefore accepted one level past the bound, while the same shape wrapped around a scalar is refused — the effective limit depends on what the innermost value is.

This counts open containers instead, and applies the same rule to signing._clean_empty, which runs before canonicalization and would otherwise be the exhaustion path.

Why

jcs_depth_v1 (a2aproject/A2A#2246) pins eight inputs and their verdicts; it is added here as tests/utils/jcs_depth_vectors.json, retained as published with attribution. On the current code the empty-container case is mis-accepted:

vector depth expected before after
jcs-depth-001 … -004 128 accept bytes bytes
jcs-depth-101, -102 129 reject CanonicalizationError CanonicalizationError
jcs-depth-103 (empty-container leaf) 129 reject bytes returned CanonicalizationError
jcs-depth-104 10⁷ reject RecursionError, at the decode RecursionError, at the decode

jcs-depth-104 never reaches the walk: the JSON decoder refuses that input first, in both trees. The test accepts either refusal, as the vector asks, rather than claiming the canonicalizer produced it.

The intended rule is already written down in this repo — the docstring of test_nesting_at_the_limit_is_accepted says "the deepest container is at depth n + 1", i.e. containers with the outermost at 1 — so this makes the implementation match its own stated intent, and that boundary test keeps passing unchanged.

Tests

  • tests/utils/test_jcs.py gains the corpus-driven tests: every preimage digest checked before any expectation is used; accepts compared byte-for-byte against the published bytes and SHA-256; rejects asserted to raise; plus a direct empty-container boundary pair and the same pair through _clean_empty.
  • Without the fix: 3 failed, 180 passed (the two empty-container cases and the pre-pass case). With it: 205 passed, test_jcs.py and test_signing.py both green.
  • ruff check and ruff format --check clean on all four files.

Scope

Independent of a2aproject/A2A#2122 and of #1287, which are about canonicalization scope, not the nesting bound. Nothing at or below the bound changes: the 24 cards in a2aproject/a2a-tck#246 canonicalize byte-identically before and after.

Reported at a2aproject/A2A#2255. Corpus authored by Sankalp Gilda (Apache-2.0); the JSON keeps the upstream pointer and states that every expected byte string came from the reference implementation it names.

@github-actions

github-actions Bot commented Oct 2, 2026 •

Copy link
Copy Markdown

🧪 Code Coverage (vs main)

⬇️ Download Full Report

No coverage changes.

Generated by coverage-comment.yml

@astrogilda

Copy link
Copy Markdown
Contributor

@a2aproject/google-a2a-eng, could you review this for merge? It fixes the depth-boundary defect and incorporates Probity's attributed regression corpus. All 16 checks pass at bfa47f3.

The fix is independent of the pending canonicalization-scope decision in A2A #2122. Please merge it if the approach is acceptable, or identify the remaining blocker here.

Please retain the Probity source reference and digest checks when landing it, so future changes continue to run against the same published cases.

Comment thread src/a2a/utils/_jcs.py Outdated
Comment thread src/a2a/utils/signing.py Outdated
Comment thread src/a2a/utils/signing.py Outdated
Comment thread tests/utils/jcs_depth_vectors.json Outdated
Comment thread tests/utils/test_jcs.py Outdated
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.
@kuangmi-bit
kuangmi-bit force-pushed the fix/jcs-depth-counts-containers branch from bfa47f3 to e75cc75 Compare October 4, 2026 01:48
@kuangmi-bit

Copy link
Copy Markdown
Contributor Author

All five review points are in e75cc75, on top of current main (e649325) — the branch was rebuilt as a single commit on the new base, so the 10-commit behind state is gone and the diff is the same four files.

  • _jcs.py: _child_depth removed; _write passes depth + 1 at both recursion sites (per thread, below).
  • signing.py: both _clean_empty branches recurse with depth + 1; the isinstance ternaries are gone.
  • jcs_depth_vectors.json: jcs-depth-104 is 500 arrays instead of ten million, with depth/preimage_bytes/preimage_sha256 recomputed and the description rewritten.
  • test_jcs.py: reject vectors are asserted as CanonicalizationError only.

Evidence on this head: 4 files changed, +342/−6; ./scripts/lint.sh clean; pytest tests/utils/test_jcs.py tests/utils/test_signing.py 205 passed; full pytest 2225 passed, 183 skipped, 3 xfailed, 1 xpassed. The depth boundary semantics are unchanged — the at-bound vectors (128 containers including an empty container leaf) still canonicalize to the published bytes, and the one-past vectors still refuse — so the previous approval-relevant behaviour is byte-identical while the counter is now stated as one rule in one place.

Thanks for the careful read: the RecursionError tolerance was a symptom of the vector, not of the design, and removing it made the test assert the intended property.

@mykytanetipa mykytanetipa left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@mykytanetipa
mykytanetipa merged commit 5653daf into a2aproject:main Oct 5, 2026
18 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants