Validating host values
Constructing HostValue trees under limits and checking them against a Contract's type graph.
A host value is one concrete Candid value, held in a form
that keeps its exact bits: the HostValue type, gated behind the
host-value Cargo feature, which is in the
default set. Turning it on adds exactly one crate to your dependency graph,
ic_principal, for checking canonical principal text. No Candid
parser is needed to build or validate values.
This page is the mechanics: how to build a value, how to point at a type
inside a Contract, how to run
validate_host_value and read what comes back, and how to decode a
value that arrived as JSON from somewhere you do not control. For why the
encoding looks the way it does, read Host
values first.
[dependencies]
candid-core = "=0.1.0-beta.3"The crate is a prerelease, so the requirement has to be exact. A caret
requirement never selects a prerelease. Everything below also needs the
compiler feature, because the examples
compile a .did source to get a Contract to validate against; both
features are on by default.
Constructing a value#
Scalars#
Scalars whose representation cannot be wrong are plain constructors:
HostValue::null(), boolean(bool),
nat8(u8), nat16(u16), nat32(u32),
int8(i8), int16(i16), int32(i32),
text(impl Into<String>) and reserved(). The
Rust type already constrains them.
The rest take a string and return a Result, because there is
exactly one accepted spelling and the constructor is where a wrong one is
caught:
use candid_core::{HostValue, Limits};
// Unbounded integers, as canonical decimal text.
let big = HostValue::nat("340282366920938463463374607431768211456")?;
let negative = HostValue::int("-1")?;
assert!(HostValue::nat("01").is_err()); // leading zero
assert!(HostValue::int("-0").is_err()); // negative zero
// 64-bit integers: canonical digits *and* in range.
let max = HostValue::nat64("18446744073709551615")?;
assert!(HostValue::nat64("18446744073709551616").is_err());
// Floats as raw IEEE-754 bits: 8 lowercase hex digits, or 16.
let nan = HostValue::float64("7ff8000000000001")?;
assert!(HostValue::float32("DEADBEEF").is_err()); // uppercase
assert!(HostValue::float64("7ff800000000001").is_err()); // 15 digits
// Principals are parsed and re-rendered; only the canonical form survives.
let owner = HostValue::principal("aaaaa-aa")?;
assert!(HostValue::principal("AAAAA-AA").is_err());
let reference = HostValue::service("aaaaa-aa")?;
let callback = HostValue::func("aaaaa-aa", "go")?;A failed constructor returns
HostValueJsonError::Malformed carrying a message rooted at
$, for example $: non-canonical nat.
Containers take limits#
The four container constructors are fallible and each takes the caller's limits:
pub fn opt(value: Option<Self>, limits: &Limits) -> Result<Self, HostValueJsonError>
pub fn vector(values: Vec<Self>, limits: &Limits) -> Result<Self, HostValueJsonError>
pub fn record(fields: Vec<HostFieldValue>, limits: &Limits) -> Result<Self, HostValueJsonError>
pub fn variant(id: u32, value: Self, limits: &Limits) -> Result<Self, HostValueJsonError>A fallible builder is unusual. HostValue
is recursive, and Drop, Clone,
PartialEq, Debug and Serialize all walk
one stack frame per level. A value deep enough to exhaust the stack aborts the
process, and the first four of those have no way to report a problem.
Construction is the only chokepoint that covers all five, so it is where the
policy is applied. Each value caches its own measured extent: how deep it
nests, and how many nodes it holds. A constructor therefore checks
max_value_depth (default 256) and
max_value_elements (default 1 000 000) against the children's
already-known extents rather than re-walking the tree.
Breaching either bound returns
HostValueJsonError::ValueLimit naming
value_depth or value_elements, with the limit, the
observed figure, and the path $.
Field IDs#
A record field is a HostFieldValue, and it is addressed by its
32-bit Candid ID, not by a name:
pub fn new(id: u32, value: HostValue) -> SelfThis one is infallible on purpose: a field carries no bound of its own, and
the enclosing HostValue::record call measures the combined
extent. id() and value() read it back.
The crate does not export the Candid name hash. It is
pub(crate), used internally to assign each service method's ID
when a source is lowered and to check that a presented ID matches its name. So
you get field IDs from one of two places: read them straight off the Contract's
TypeNode::Record { fields }, or compute them with
candid_parser::candid::idl_hash, which is what this repository's
own examples and tests do. candid_parser enters this crate's graph
only with the compiler feature (and, separately,
as a dev-dependency for the test suites), so a consumer building only
host-value would have to add it deliberately or take the IDs from
the graph.
Binding a selector to a Contract#
Validation needs to know which type in which interface. That pair
is a selector, and Contract mints them:
pub fn bind_type(&self, type_ref: TypeRef) -> Result<ContractTypeRef, HostValueValidationError>Turns a bare arena index into {contract_id, type_ref},
rejecting an index outside the types array up front with
value_type_ref_out_of_bounds.
pub fn bind_method(&self, method: impl Into<String>) -> Result<ContractMethodRef, HostValueValidationError>Resolves a method name through the actor, either a service
or a class's service, and returns
{contract_id, method_name}. An actorless Contract fails with
actorless_contract, an unknown name with
unknown_method.
Both selectors serialize with exactly those key names and reject unknown
ones, so they are safe to persist. The contract_id half is what
makes storing one safe: if the interface changes, its identity changes, and a
stale selector is rejected instead of silently pointing at whatever now sits
at that index.
validate_host_value takes a ContractTypeRef.
ContractMethodRef is the persistable selector shape the planned
HostValue-to-Candid-binary bridge will take; that encode/decode step is not
part of this release. To check a method's arguments today, resolve the
method's func node yourself and bind_type its
argument type.
Validating against the graph#
There are two entry points, and only two. No
validate_host_value_with_limits exists, because the plain
function already takes limits.
pub fn validate_host_value(
contract: &Contract,
selector: &ContractTypeRef,
value: &HostValue,
limits: &Limits,
) -> Result<(), HostValueValidationError>
pub fn validate_host_value_with_context(
contract: &Contract,
selector: &ContractTypeRef,
value: &HostValue,
context: &RuntimeContext,
) -> Result<(), HostValueValidationError>The first builds a fresh RuntimeContext from your limits and
calls the second. Use the _with_context form when you already own
a budget. A RuntimeContext carries the limits, including
deadline_unix_ms, plus a cancellation token, so a long walk can
be interrupted and reports operation_deadline_exceeded or
operation_cancelled rather than running to completion.
Reading the verdict#
Success is Ok(()). Failure is a
HostValueValidationError holding
violations: Vec<HostValueViolation>, where
HostValueViolation is an alias of the crate-wide
Diagnostic. Value violations always carry path and
never carry phase or severity, so the shape you
actually see is {code, path, message, resource_limit?}. Paths are
rooted at $ and address the value, not the type:
$.fields[3573748184], $.values[7],
$.value.
| Code | What it means |
|---|---|
value_contract_id_mismatch | The selector names a different Contract. |
value_type_ref_out_of_bounds | The selector's index is outside this Contract's arena. |
host_value_kind_mismatch | The value's kind does not pair with the type node at this position. |
duplicate_host_field | The same record field ID appears more than once in the value. |
record_field_set_mismatch | The field IDs present do not match the type's, exactly. Both sorted sets are in the message. |
unknown_variant_id | The variant's tag is not an arm of the expected type. |
invalid_principal | Principal text is unparseable, or parses but is not canonical. |
empty_function_method | A func value carries an empty method name. |
empty_has_no_value | The type at this position is Candid's uninhabited empty, which has no value. |
class_has_no_host_value | The type at this position is a service constructor, which is not a first-class value. |
resource_limit_exceeded | A budget ran out. Carries {resource, limit, observed}. |
operation_deadline_exceeded | The context's deadline elapsed mid-walk. |
operation_cancelled | The caller tripped the context's cancellation token. |
A complete program#
This is the example the repository ships, verbatim. It compiles a two-field
record, finds its declaration, binds a selector, builds a value whose
nat is larger than a u128 and whose
float64 is a specific NaN payload, validates it, and prints the
tagged JSON.
use candid_core::{compile_did, validate_host_value, HostFieldValue, HostValue, Limits};
use std::error::Error;
fn main() -> Result<(), Box<dyn Error>> {
let compilation = compile_did(
r#"
type Measurement = record { count: nat; reading: float64 };
service : { submit: (Measurement) -> () };
"#,
)?;
let contract = compilation.contract();
let measurement = contract
.declarations()
.iter()
.find(|declaration| declaration.name == "Measurement")
.ok_or("missing Measurement declaration")?;
let selector = contract.bind_type(measurement.ty)?;
let value = HostValue::record(
vec![
HostFieldValue::new(
candid_parser::candid::idl_hash("count"),
HostValue::nat("340282366920938463463374607431768211456")?,
),
HostFieldValue::new(
candid_parser::candid::idl_hash("reading"),
// A NaN payload preserved exactly as IEEE-754 bits.
HostValue::float64("7ff8000000000001")?,
),
],
&Limits::default(),
)?;
validate_host_value(contract, &selector, &value, &Limits::default())?;
println!(
"selector: {} / type {}",
selector.contract_id, selector.type_ref
);
println!("{}", serde_json::to_string_pretty(&value)?);
Ok(())
}cargo run --example host_value_validationA Declaration is a {name, ty} pair, a name table
pointing into the arena, serialized with the JSON key
"type". The two field IDs printed in the output are 1248019663
for count and 48364364 for reading. Nothing in the
Contract stores those spellings; if you want them travelling alongside the
interface, that is what the envelope's
org.candid-core.field-names/v1 extension is for.
When validation fails#
The next program is built from two tests in
tests/host_value_conformance.rs and shows both failure shapes. The
first is a value that is perfectly well formed and still does not fit the
type. nat and nat8 are different types, and nothing
widens. The second is a value the type accepts but the caller's policy does
not.
use candid_core::{compile_did, validate_host_value, Contract, HostValue, Limits};
use std::error::Error;
fn declaration(contract: &Contract, name: &str) -> Option<u32> {
contract
.declarations()
.iter()
.find(|declaration| declaration.name == name)
.map(|declaration| declaration.ty)
}
fn main() -> Result<(), Box<dyn Error>> {
let compilation = compile_did("type Small = nat8; type Items = vec nat; service : {};")?;
let contract = compilation.contract();
// 1. A locally valid value that does not fit the type.
let small = contract.bind_type(declaration(contract, "Small").ok_or("missing Small")?)?;
let one = HostValue::from_json_with_limits(
r#"{ "kind": "nat", "value": "1" }"#,
&Limits::default(),
)?;
let error = validate_host_value(contract, &small, &one, &Limits::default())
.expect_err("a nat is not a nat8");
for violation in &error.violations {
println!(
"{} at {}: {}",
violation.code,
violation.path.as_deref().unwrap_or("$"),
violation.message
);
}
// host_value_kind_mismatch at $: expected primitive Nat8, found nat or a
// non-canonical representation
// 2. A value the type accepts, under a policy that does not.
let items = contract.bind_type(declaration(contract, "Items").ok_or("missing Items")?)?;
let hundred = HostValue::vector(
(0..100)
.map(|_| HostValue::nat("1"))
.collect::<Result<Vec<_>, _>>()?,
&Limits::default(),
)?;
let limits = Limits::default().with_max_value_elements(10);
let error =
validate_host_value(contract, &items, &hundred, &limits).expect_err("101 elements > 10");
let violation = &error.violations[0];
let info = violation
.resource_limit
.as_ref()
.ok_or("a resource failure carries its metadata")?;
println!(
"{} at {}: {} observed {}, limit {}",
violation.code,
violation.path.as_deref().unwrap_or("$"),
info.resource,
info.observed,
info.limit
);
// resource_limit_exceeded at $: value_elements observed 101, limit 10
Ok(())
}Two details in the second half matter. The observed
count is 101, not 11: before recursing into a container the
validator adds the whole child count to what it has already consumed and
compares once, so the failure names the real size rather than the point at
which counting stopped. And the reported path is $, the root,
because that is where the over-large container sits.
Record validation scans field IDs pairwise instead of building an index,
charging one unit of max_canonicalization_work per comparison
across three scans, roughly 1.5n2 units for an n-field
record. At the default of 10 000 000 that binds a single record at 2 581
fields; 2 582 fails closed with resource_limit_exceeded naming
canonicalization_work. Neither max_fields nor
max_value_elements is the binding limit there. The counter is
per operation and shared with Contract canonicalization, so several wide
records in one tree accumulate against it.
Decoding JSON from outside#
HostValue deliberately does not implement
Deserialize: serde_json::from_str::<HostValue>(…)
does not compile. A trait impl has no argument position for a resource policy,
so it could only ever decode under limits the library picked. The bounded
paths are:
pub fn from_json_with_limits(input: &str, limits: &Limits) -> Result<Self, HostValueJsonError>
pub fn from_json_with_context(input: &str, context: &RuntimeContext) -> Result<Self, HostValueJsonError>They check the input length, run a constant-stack scan of the raw bytes for
over-nested JSON, hand the text to serde_json as a private raw
DTO, and only then check canonical scalar forms level by level. A
HostValue therefore cannot exist without having passed all of
that.
Every other bounded parse in the crate checks
max_input_bytes before decoding. This one checks
max_value_bytes, 16 MiB by default, and reports
HostValueJsonError::Limit, which carries a limit
and an observed but no resource name. Lowering
max_input_bytes alone does nothing to bound HostValue
decoding; lower max_value_bytes too.
HostValueJsonError | Fields | Raised by |
|---|---|---|
Malformed | String | Bad JSON, an unknown key, or a non-canonical scalar, including from the Rust constructors. |
Limit | limit, observed | The whole-document max_value_bytes gate. No resource name. |
ValueLimit | resource, limit, observed, path | A named budget: value_nesting, value_depth, value_elements, value_bytes or canonicalization_work. |
Deadline | path | The context's deadline elapsed mid-decode. |
Cancelled | path | The caller tripped the context's cancellation token. |
value_nesting and value_depth count in different
units. max_value_nesting (default 64) counts
{ and [ in the text, before serde_json
runs; max_value_depth (default 256) counts semantic container
levels afterwards. One vec level costs two JSON containers and one
record level costs three, so a rejection names one or the other,
never both. Raising max_value_nesting above 128 has no effect:
serde_json's own fixed 128-frame ceiling sits underneath and is
deliberately left in place.
Serialization is ordinary. HostValue implements
Serialize, so
serde_json::to_string(&value) gives you the tagged document
back, and decoding that text returns an equal value.
Next#
The limits page covers profiles, builders, deadlines and cancellation in full, and Limits reference lists every default. For the same values on the other side of the boundary, see Schemas at runtime, which builds TypeScript schemas from a Contract or envelope document and consumes the field-name extension described in Host values.