Skip to content

fix: Bound the size of a type named in a decoder diagnostic - #771

Merged
lwshang merged 5 commits into
masterfrom
bound-type-diagnostics
Sep 25, 2026
Merged

lwshang merged 5 commits into
masterfrom
bound-type-diagnostics

Conversation

@lwshang

@lwshang lwshang commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

What this changes

A diagnostic that names a type now renders it through elide_large, which checks the existing text_size budget first and substitutes (type elided) when the type is over it — so nothing is built for a type whose rendering cost would follow its own width and depth. Types reaching the decoder come from the wire, so the budget applies to every diagnostic, including the verbose form set_full_error_message(true) selects, which previously short-circuited it away.

Two budgets: MAX_DIAGNOSTIC_TYPE_LEN for one type, MAX_DIAGNOSTIC_LIST_LEN for a list of them, since a per-element bound leaves a list growing with its length and the type-table dump can hold max_type_len entries.

This covers every type-naming diagnostic in de.rs and subtype.rs. The latter builds its own message text and had no budget at all, which is the larger half of the change.

With #770's 64 KiB header bound in place this is defence in depth, mainly for callers raising max_header_len and for the candid_parser paths that have no header bound.

Public behaviour change

text_size asserted a service constructor could not reach it. That held for its two original callers, but elide_large is reached from the public subtype and equal, including for a Class nested in a record, vector or option. It now returns Err(()) for one wherever it appears, and such types are elided rather than rendered — the pretty printer has no arm for one either.

Two pre-existing bugs, surfaced by removing the skip

text_size subtracted its running total from the remaining budget after each field rather than that field's cost, so the budget drained quadratically and rejected types well under it: an eleven-field ICRC-2-shaped record renders to 287 characters and was judged over a budget of 500. Hashed field ids were also charged 4 however wide they print.

Both only showed in terse errors before, because verbose mode skipped the check. Removing that skip would have turned them into elided output for ordinary interfaces, so both are fixed here.

Compatibility

Diagnostics only — no change to what decodes. subtype.rs is shared with candid_parser, so a .did typechecking error naming a very large type elides it too. binary_parser's Invalid table entry still renders a raw parse struct with {t:?}: its Debug is flat and its size follows the header, which carries its own bound.

Tests

rust/candid/tests/type_diagnostics.rs, gated on the value feature, decoding on a thread given little room: outsized types against int, nat16 and a function reference under both full_error_message settings; a service constructor at the top of a type, in a record, in a vector and under an option; a 20,000-element argument list; and, on the other side, an ordinary ICRC-2-shaped record still named in full.

Release

No version bump; the changelog entry joins the existing ## Unreleased section.

🤖 Generated with Claude Code

What it costs to render a type follows that type's own width and depth.
A type reaching the decoder is chosen by the sender, so a diagnostic that
names one has to describe it without letting its shape decide the cost of
the description.

Render types through `elide_large`, which checks the existing `text_size`
budget first and substitutes a placeholder when the type is over it, so
nothing is built for an outsized type. Applied at every diagnostic that
names a type: the argument, subtype and primitive-mismatch messages and
the state dump in `de.rs`, and the subtyping messages in `subtype.rs`,
which build their own text and were unguarded.

Two of those diagnostics guarded the budget behind
`full_error_message || text_size(..).is_ok()`, so asking for verbose
errors skipped the budget entirely. The budget now always applies; verbose
errors still carry the input and the decoder state, and an ordinary
mismatch is unchanged and still names both types.

`text_size` already existed for exactly this purpose and was already used
at two sites. This extends it to the rest rather than introducing a new
mechanism.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot review overview

🟡 Changes recommended

Critical unresolved issues remain in diagnostic rendering and test feature gating.

Get a fresh assessment by requesting another Copilot review.

Review effort: Lite
Findings: 4 High severity

Open (4)
What changed in this PR

This PR bounds rendering of sender-controlled Candid types in decoder and subtype diagnostics.

Changes:

  • Adds budgeted type elision via elide_large.
  • Applies bounded rendering across diagnostics.
  • Adds regression tests and changelog documentation.

Unresolved critical findings remain around unbounded formatting, Class handling, eager subtype formatting, and test feature gating.

File Summary
rust/​candid/​tests/​type_diagnostics.rs Adds large-type diagnostic tests.
rust/​candid/​src/​types/​subtype.rs Applies bounded rendering to subtype errors.
rust/​candid/​src/​types/​internal.rs Adds diagnostic type-rendering limits.
rust/​candid/​src/​de.rs Uses bounded rendering in decoder diagnostics.
CHANGELOG.md Documents the diagnostic size bound.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread rust/candid/src/de.rs Outdated
Comment thread rust/candid/src/types/internal.rs
Comment thread rust/candid/src/types/subtype.rs
Comment thread rust/candid/tests/type_diagnostics.rs
…rer total

Review feedback. Three diagnostics still rendered a type directly, and a
fourth path could ask the printer for a rendering it does not have.

Route through the bounded renderer:

- `deserialize_seq`'s "is not a tuple type", which names the wire type;
- `equal_impl`'s "Field .. has different types" and "Method .. has
  different types" contexts;
- `pp_args`, used by the "Mismatch in init args" context. Its two
  feature-split implementations collapse into one, since the bounded
  renderer already goes through `Display`, which picks the pretty printer
  or the fallback itself.

The first was missed because the interpolation sits on a different line
from the `format!`, which a line-oriented search does not see; the others
because they name types as `f1.ty`, `m1.1` and `pp_args(..)` rather than
through a variable a search would recognise. The audit is now structural:
walk every format-producing call and look at its arguments.

The renderer is also total now. It is reached for whatever type a
diagnostic names, including from `subtype` and `equal`, and a service
constructor has no rendering of its own -- the printer reaches an
`unreachable!()` for one on master as well. Those are elided rather than
rendered, so `equal` on a service constructor returns an error where it
previously aborted. `text_size` is left as it was.

`de.rs` drops its own copy of the budget for the one beside `text_size`,
so the two cannot drift apart.

The test target is gated on the `value` feature, following `tests/types.rs`.
It does build without it today, because the `candid_parser` dev-dependency
takes candid with `features = ["all"]` and Cargo unifies them, but that is
incidental.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot review overview

🟡 Changes recommended

Unresolved critical nested-Class handling and moderate budget and formatting issues remain.

Get a fresh assessment by requesting another Copilot review.

Review effort: Lite
Findings: 1 High severity · 1 Medium severity

Open (2)
Resolved since last review (4)

Comment thread rust/candid/src/types/internal.rs
Comment thread rust/candid/src/types/subtype.rs Outdated
…lists

Review feedback, both of which my previous round only half-addressed.

A service constructor was elided by testing for one at the top of the
type, which left it reached through any type that merely contains one: a
record, vector or option holding one still traversed into `text_size` and
its `unreachable!()`. `text_size` now refuses a service constructor
wherever it appears, so the bounded renderer is total for nested types as
well without having to search for one itself. Turning the assertion into
a refusal is what the previous round should have done; the top-level test
treated the symptom.

That also collapses the two placeholders into one. The distinction was
only ever visible for a top-level service constructor, and a nested one
would have been described as too large, which it need not be.

A per-element budget also left a list of types unbounded, since the cost
then grows with the number of elements rather than their size. `pp_args`
and the type-table dump now stop once the list reaches its own budget and
say how many entries were left out. The table is the one that matters:
`max_type_len` allows ten thousand entries, so a per-entry bound alone
still left the rendering growing with their number.

Tests cover a service constructor at the top of a type, in a record, in a
vector and under an option, and a long argument list staying bounded.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot review overview

🟡 Changes recommended

Moderate findings remain in decoder fallback handling and Class rendering, alongside documentation and placeholder inconsistencies.

Get a fresh assessment by requesting another Copilot review.

Review effort: Lite
Findings: 1 Low severity

Open (1)
Resolved since last review (2)

Comment thread rust/candid/src/types/internal.rs
@lwshang
lwshang marked this pull request as ready for review September 25, 2026 13:10
@lwshang
lwshang requested a review from a team as a code owner September 25, 2026 13:10
@zeropath-ai

zeropath-ai Bot commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

✅ No security or compliance issues detected. Reviewed everything up to 738c0f1.

Security Overview
Detected Code Changes
Change Type Relevant files
Enhancement ► rust/candid/src/de.rs
    Introduce elide_large rendering and diagnostic budgeting for types in errors
► rust/candid/src/types/internal.rs
    Add MAX_DIAGNOSTIC_TYPE_LEN, MAX_DIAGNOSTIC_LIST_LEN, ELIDED_TYPE and elide_large functionality
► rust/candid/src/types/subtype.rs
    Use elide_large and diagnostic-aware messaging in subtype comparisons
Enhancement ► rust/candid/tests/type_diagnostics.rs
    Add extensive diagnostics-focused tests for bounded rendering of types in errors

Review feedback from Marco's agent.

`text_size` subtracted its running total from the remaining budget after
every field, not the cost of the field it had just added, so the budget
ran out quadratically: an eleven-field record of the shape an ICRC-2
transfer takes renders to 287 characters and was already judged over a
budget of 500. That only showed in terse errors before, because verbose
ones skipped the check; this branch removed that skip, which would have
turned an old accounting bug into elided output for ordinary interfaces
exactly where a developer reads it. Each loop now spends what its own
item costs.

A hashed field id was charged 4 whatever its value, though it prints with
underscore separators and runs to 13 characters. Eight such fields render
to 194 characters and were priced at 89; they now price at 161.

  ICRC-2-style, 11 named fields   287 chars   over budget -> 252
  8 hashed-id fields              194 chars            89 -> 161

The estimate still runs under the rendering, which only makes the budget
slightly permissive; it stays a bound, and an outsized type is refused as
before.

`pp_args` wrote a separator after every argument, so `(nat, text)` came
out as `(nat, text, )` in every build where the pretty printer had
produced the former. It now separates between items.

`describe_table` takes `&TypeEnv`, which is already in scope.

Left alone: `binary_parser` renders a raw table entry with `{t:?}` in
`Invalid table entry`. That is a parse struct rather than a type, its
Debug is flat, and its size follows the header, which carries its own
bound. The pull request wording no longer claims otherwise.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@marc0olo marc0olo left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

LGTM. One optional nit: the Func arm of text_size doesn't count the , separators (or line breaks for long lists), so a many-arg func passes at 500 but renders ~1.5 KB. Still a constant bound, so not blocking. Either charge 2 per extra arg or phrase MAX_DIAGNOSTIC_TYPE_LEN's doc as an estimate.

Reviewer's nit. Every other arm of `text_size` covers its own separators
through a per-item charge -- a record field pays 3 for the "; " and a
little over -- but a function type charged nothing between arguments, so
a many-argument function passed a budget of 500 while rendering about
three times that.

Charge 2 per argument after the first, in each of the argument and result
lists. The worst case a function can render at falls from roughly 1.5 KB
to under 900 bytes.

What is left is the pretty printer wrapping a long list across lines and
indenting it, which is a property of the layout rather than of the type.
`MAX_DIAGNOSTIC_TYPE_LEN` is now described as an estimate for that reason:
a type that passes can still render somewhat longer, and what the bound
rests on is the estimate staying proportional to the rendering.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@lwshang
lwshang merged commit bef8857 into master Sep 25, 2026
18 checks passed
@lwshang
lwshang deleted the bound-type-diagnostics branch September 25, 2026 14:28
@lwshang lwshang mentioned this pull request Sep 25, 2026
4 of 5 tasks
lwshang added a commit that referenced this pull request Sep 25, 2026
Patch release of `candid` / `candid_derive`, 0.10.36 → 0.10.37. No
source changes — it dates the two decoder fixes already on `master`.

## Summary

- Bump `candid` and `candid_derive` 0.10.36 → 0.10.37, and the `=` pin
between them
- Date the `Unreleased` changelog entries as `2026-09-25` / `Candid
0.10.37`, covering:
+ Bounding the byte length of the type-table header, 64 KiB by default
and configurable with `set_max_header_len` (#770)
+ Bounding the size of a type named in a decoder diagnostic, so its
rendering no longer follows the type's own width and depth (#771)
- Refresh `Cargo.lock`, `rust/bench/Cargo.lock`, `tools/ui/Cargo.lock`
and `rust/candid/fuzz/Cargo.lock`

`candid_parser` is unchanged at 0.4.1; its dependency on `candid` is a
`0.10.16` floor, so it needs no bump.

The lockfile diffs are version bumps only — no dependency churn.

## All four lockfiles this time

The 0.10.36 release noted that `rust/candid/fuzz/Cargo.lock` was stale
at 0.10.34 and that all four lockfiles should move together as a
release-checklist step. This one does that, so `fuzz` goes 0.10.34 →
0.10.37.

`tools/ui` patches `candid` to the workspace path, so its lockfile pins
the path crate's version and has to move with the bump.
`candid-ui-release.yml` builds `didjs` there with `--locked` and
triggers on the date tag, so a stale lock would only fail at tag time,
after merge.

## Test plan

- [x] `cargo check -p candid -p candid_derive -p candid_parser`
- [x] `cargo test -p candid --features all` — all suites pass
- [x] `cd tools/ui && cargo build --target wasm32-unknown-unknown
--profile canister --package didjs --locked` — the release workflow's
exact command, succeeds
- [x] `cd rust/candid/fuzz && cargo check`
- [ ] Reviewer to confirm release scope and changelog date

## After merge

1. Tag `2026-09-25` on `master` — this is what `candid-ui-release.yml`
triggers on to publish the `candid_ui` canister wasm
2. Run the **Crates Publish** workflow (`workflow_dispatch`) with the
`candid` input checked, which publishes `candid_derive` then `candid`

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
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