Skip to content

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

Open
kuangmi-bit wants to merge 1 commit into
a2aproject:mainfrom
kuangmi-bit:fix/jcs-depth-counts-containers
Open

kuangmi-bit wants to merge 1 commit 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.

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.
@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

This branch has not been deployed

No deployments
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.

1 participant