Candid Compliance
icpp-pro decodes every message of the Candid Interface Specification (spec 0.1.8, dfinity/candid@572c80a), within its limits: all 472 asserts of the official suite pass, from icpp-pro 7.1.0rc1. Full compliance, recursive types included in encoding, comes in 7.1.0rc2.
Every message your canister receives is decoded by one decoder, which follows the spec: the typed API (CandidType* with ic_api.from_wire) and the dynamic CandidValue both use it. So what is tested here holds for your own methods.
The tests
The compliance canister runs the official test suite of the specification, and the Candid tests of the Rust and Motoko CDKs, ported. Every assert passes, natively and deployed to a local network, in CI on every push. There is no skip list.
| Source | Asserts |
|---|---|
Official suite: prim.test.did |
168 |
Official suite: construct.test.did |
169 |
Official suite: reference.test.did |
50 |
Official suite: subtypes.test.did |
58 |
Official suite: spacebomb.test.did |
17 |
Official suite: overshoot.test.did |
10 |
| Official suite, dfinity/candid@572c80a | 472 |
Rust CDK, rust/candid and rust/candid_parser tests, same commit |
508 |
Motoko, test/run-deser and test/run-drun/idl-*.mo, at 01aee06 |
181 |
| icpp-candid's own encode vectors | 14 |
| Total | 1175 |
An assert is one of:
- A message that must decode at the given types, or must be rejected.
- Two values that must decode to the same value, or to different ones. A side is a message or a textual Candid value.
- A value that the typed API must encode to exactly the reference's types (from
didc), and that must decode back to the same value: 109 of the asserts.
Motoko's test/idl/*.did tests check the parsing of .did files, which icpp-candid does not do, and are not ported.
Decoding follows the spec
From 7.1.0rc1, the decoder coerces each value to the type you expect, as the spec defines it:
- Subtyping. A
natdecodes as anint; a value of typeTdecodes as anopt T; anything decodes asreserved; aservicedecodes as aprincipal. A record may have more fields on the wire than you expect, and a variant may have fewer cases. - Opt. A value that does not coerce to the type in an
optdecodes asnull, as donullandreservedon the wire. - Arguments. Arguments on the wire that you do not expect are read and skipped. An argument you expect that is not on the wire must be an
opt,nullorreserved, and isnull. - References. A
funcorservicereference decodes when its type is a subtype of the one you expect, also for recursive types.
The decoder rejects, with a trap and a clear message, what earlier versions accepted:
- Bytes after the last argument.
- A
boolother than 0 or 1. - Text that is not valid UTF-8, also in a value that is skipped.
- Record or variant field ids that are not sorted, are repeated, or are larger than 32 bits.
- A principal longer than 29 bytes.
- Argument types that are not valid, also when you expect no arguments.
Encoding
You encode with the typed API, for every type that does not refer to itself. A recursive type, such as type List = opt record { head : int; tail : List }, can be decoded into a CandidValue, but cannot be encoded yet: that comes in 7.1.0rc2.
Limits
The spec leaves limits to the implementation. A message over a limit traps.
| Limit | Value |
|---|---|
| Nesting depth of values, also of skipped ones | 128 |
| Values that take no bytes on the wire, per message | 1,000,000 |
| Entries in the type table | 10,000 |
A value that takes no bytes on the wire is a null, a reserved, or a record whose fields are all such values. A few bytes can declare a great many of them, so they are counted, as the spacebomb tests require.
The decoder holds such values once: a vec null holds one element and its length, and the records of a nested record of such fields are held once per type. A message of a few bytes does not make the decoder hold much more memory than its size.
Two things to know when you decode untrusted messages:
- The typed API gives every element of a vec to your code. A
vec nullof a million elements, decoded as avec opt natinto astd::vector, makes a millionstd::nulloptelements in your vector, as it does in Rust'sVec<Option<T>>. CandidValue::to_text()writes anatorintin decimal, which takes time quadratic in its length. Decoding a large number is linear; printing one from an untrusted message is not.
Hardening
- The decoder reads the message through two functions only, both bounds-checked.
make check-decoder-reads, part of the static checks in CI, fails when any other code reads it. - The decoder is fuzzed with libFuzzer, under AddressSanitizer and UndefinedBehaviorSanitizer. The seeds are every message of the compliance tests. Each input is also checked for the memory it makes the decoder hold, against the size of the message.
- CI fuzzes on every push: every seed and every saved input, then 60 seconds per fuzz target. Before each release, each target is fuzzed for an hour, and the result is in the release notes.