Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

* Bug fixes:
+ Bound the byte length of the type-table header, 64 KiB by default and configurable with `DecoderConfig::set_max_header_len`. Headers over the bound, far above any realistic interface, are now rejected; the value section is unaffected, and `set_max_type_len` still separately bounds the number of type-table entries.
+ Bound the size of a type named in a decoder diagnostic. What it costs to render a type follows that type's own width and depth, and a type reaching the decoder is chosen by the sender, so a diagnostic now elides one whose rendering would exceed a fixed budget instead of rendering it in full. This holds for every diagnostic that names a type, including the subtyping messages and the verbose form that `set_full_error_message(true)` selects; ordinary mismatches are unchanged and still name both types.

## 2026-09-22

Expand Down
5 changes: 5 additions & 0 deletions rust/candid/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,11 @@ all = ["default", "value", "ic_principal/arbitrary"]
name = "types"
path = "tests/types.rs"
required-features = ["value"]

[[test]]
name = "type_diagnostics"
path = "tests/type_diagnostics.rs"
required-features = ["value"]
[[test]]
name = "serde"
path = "tests/serde.rs"
Expand Down
58 changes: 41 additions & 17 deletions rust/candid/src/de.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

use super::{
error::{Error, Result},
types::internal::{text_size, type_of, TypeId},
types::internal::{
elide_large, text_size, type_of, TypeId, MAX_DIAGNOSTIC_LIST_LEN, MAX_DIAGNOSTIC_TYPE_LEN,
},
types::{Field, Label, SharedLabel, Type, TypeEnv, TypeInner},
CandidType,
};
Expand All @@ -19,7 +21,22 @@ use serde::de::{self, Visitor};
use std::fmt::Write;
use std::{collections::VecDeque, io::Cursor, mem::replace, rc::Rc};

const MAX_TYPE_LEN: i32 = 500;
/// Render a type table for a diagnostic, eliding entries too large to render and
/// stopping once the table itself reaches its budget.
///
/// A table holds as many entries as `max_type_len` allows, so bounding each entry on
/// its own would still leave the whole rendering growing with their number.
fn describe_table(env: &TypeEnv) -> String {
let mut out = String::new();
for (i, (name, ty)) in env.0.iter().enumerate() {
if out.len() >= MAX_DIAGNOSTIC_LIST_LEN {
let _ = writeln!(&mut out, "... and {} more", env.0.len() - i);
break;
}
let _ = writeln!(&mut out, "type {name} = {}", elide_large(ty));
}
out
}

/// Use this struct to deserialize a sequence of Rust values (heterogeneous) from IDL binary message.
pub struct IDLDeserialize<'de> {
Expand Down Expand Up @@ -73,9 +90,7 @@ impl<'de> IDLDeserialize<'de> {
self.de.expect_type = expected_type;
self.de.wire_type = TypeInner::Null.into();
return T::deserialize(&mut self.de);
} else if self.de.config.full_error_message
|| text_size(&expected_type, MAX_TYPE_LEN).is_ok()
{
} else if text_size(&expected_type, MAX_DIAGNOSTIC_TYPE_LEN).is_ok() {
return Err(Error::msg(format!(
"No more values on the wire, the expected type {expected_type} is not opt, null, or reserved"
)));
Expand All @@ -94,9 +109,8 @@ impl<'de> IDLDeserialize<'de> {
self.de.wire_type = ty.clone();

let mut v = T::deserialize(&mut self.de).with_context(|| {
if self.de.config.full_error_message
|| (text_size(&ty, MAX_TYPE_LEN).is_ok()
&& text_size(&expected_type, MAX_TYPE_LEN).is_ok())
if text_size(&ty, MAX_DIAGNOSTIC_TYPE_LEN).is_ok()
&& text_size(&expected_type, MAX_DIAGNOSTIC_TYPE_LEN).is_ok()
{
format!("Fail to decode argument {ind} from {ty} to {expected_type}")
} else {
Expand Down Expand Up @@ -389,12 +403,13 @@ impl<'de> Deserializer<'de> {
let (before, after) = hex.split_at(pos);
let mut res = format!("input: {before}_{after}\n");
if !self.table.0.is_empty() {
write!(&mut res, "table: {}", self.table).unwrap();
write!(&mut res, "table: {}", describe_table(&self.table)).unwrap();
}
write!(
&mut res,
"wire_type: {}, expect_type: {}",
self.wire_type, self.expect_type
elide_large(&self.wire_type),
elide_large(&self.expect_type)
)
.unwrap();
if let Some(field) = &self.field_name {
Expand Down Expand Up @@ -507,9 +522,8 @@ impl<'de> Deserializer<'de> {
&self.expect_type,
)
.with_context(|| {
if self.config.full_error_message
|| (text_size(&self.wire_type, MAX_TYPE_LEN).is_ok()
&& text_size(&self.expect_type, MAX_TYPE_LEN).is_ok())
if text_size(&self.wire_type, MAX_DIAGNOSTIC_TYPE_LEN).is_ok()
&& text_size(&self.expect_type, MAX_DIAGNOSTIC_TYPE_LEN).is_ok()
{
format!(
"{} is not a subtype of {}",
Expand Down Expand Up @@ -619,7 +633,7 @@ impl<'de> Deserializer<'de> {
} else {
return Err(Error::subtype(format!(
"{} cannot be deserialized to int",
self.wire_type
elide_large(&self.wire_type)
)));
}
}
Expand All @@ -628,7 +642,12 @@ impl<'de> Deserializer<'de> {
let int = match self.wire_type.as_ref() {
TypeInner::Int => Int::decode(&mut self.input).map_err(Error::msg)?,
TypeInner::Nat => Int(Nat::decode(&mut self.input).map_err(Error::msg)?.0.into()),
t => return Err(Error::subtype(format!("{t} cannot be deserialized to int"))),
_ => {
return Err(Error::subtype(format!(
"{} cannot be deserialized to int",
elide_large(&self.wire_type)
)))
}
};
self.add_cost((self.input.position() - bignum_pos) as usize)?;
bytes.extend_from_slice(&int.0.to_signed_bytes_le());
Expand Down Expand Up @@ -1038,7 +1057,12 @@ impl<'de> de::Deserializer<'de> for &mut Deserializer<'de> {
TypeInner::Int => decode_int(&mut self.input)?,
TypeInner::Nat => i128::try_from(decode_nat(&mut self.input)?)
.map_err(|_| Error::msg("Cannot convert nat to i128"))?,
t => return Err(Error::subtype(format!("{t} cannot be deserialized to int"))),
_ => {
return Err(Error::subtype(format!(
"{} cannot be deserialized to int",
elide_large(&self.wire_type)
)))
}
};
visitor.visit_i128(value)
}
Expand Down Expand Up @@ -1244,7 +1268,7 @@ impl<'de> de::Deserializer<'de> for &mut Deserializer<'de> {
if !self.wire_type.is_tuple() {
return Err(Error::subtype(format!(
"{} is not a tuple type",
self.wire_type
elide_large(&self.wire_type)
)));
}
let value = visitor.visit_seq(Compound::new(
Expand Down
72 changes: 59 additions & 13 deletions rust/candid/src/types/internal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,49 @@ impl fmt::Display for TypeInner {
write!(f, "{:?}", self)
}
}
/// Width of a numeric field id as the printer writes it, with underscore separators,
/// so `1136829802` counts as the 13 characters of `1_136_829_802`.
fn printed_num_width(n: u32) -> i32 {
let digits = if n == 0 { 1 } else { n.ilog10() as i32 + 1 };
digits + (digits - 1) / 3
}

/// Budget, in rendered characters, for a type named in a diagnostic.
pub(crate) const MAX_DIAGNOSTIC_TYPE_LEN: i32 = 500;

/// Stands in for a type a diagnostic cannot render within its budget.
pub(crate) const ELIDED_TYPE: &str = "(type elided)";
Comment thread
lwshang marked this conversation as resolved.

/// Budget, in characters, for a list of types in a diagnostic. Bounds the list itself,
/// which a per-element budget does not.
pub(crate) const MAX_DIAGNOSTIC_LIST_LEN: usize = 2048;

/// Renders a type for a diagnostic, eliding it when rendering would be unreasonable.
///
/// What it costs to render a type follows that type's own width and depth, so an
/// outsized one must not be rendered at all: [`text_size`] settles that against a
/// budget before any of the work is done. Types reaching a decoder come from the wire
/// and so are chosen by the sender, which is why the budget holds for every diagnostic
/// naming one, however verbose the caller asked its errors to be.
///
/// Total for every type: one that cannot be rendered at all, such as a service
/// constructor, is refused by [`text_size`] and elided like any oversized one.
pub(crate) fn elide_large(t: &Type) -> ElidedType<'_> {
ElidedType(t)
}

pub(crate) struct ElidedType<'a>(&'a Type);

impl fmt::Display for ElidedType<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if text_size(self.0, MAX_DIAGNOSTIC_TYPE_LEN).is_ok() {
write!(f, "{}", self.0)
Comment thread
lwshang marked this conversation as resolved.
Comment thread
lwshang marked this conversation as resolved.
} else {
f.write_str(ELIDED_TYPE)
}
}
}

#[allow(clippy::result_unit_err)]
pub fn text_size(t: &Type, limit: i32) -> Result<i32, ()> {
use TypeInner::*;
Expand All @@ -388,25 +431,24 @@ pub fn text_size(t: &Type, limit: i32) -> Result<i32, ()> {
for f in fs {
let id_size = match f.id.as_ref() {
Label::Named(n) => n.len() as i32,
Label::Id(_) => 4,
Label::Id(n) => printed_num_width(*n),
// a tuple prints its fields without labels
Label::Unnamed(_) => 0,
};
cnt += id_size + text_size(&f.ty, limit - id_size - 3)? + 3;
limit -= cnt;
let field = id_size + text_size(&f.ty, limit - id_size - 3)? + 3;
cnt += field;
limit -= field;
}
9 + cnt
}
Func(func) => {
let mode = if func.modes.is_empty() { 0 } else { 6 };
let mut cnt = mode + 6;
let mut limit = limit - cnt;
for t in &func.args {
cnt += text_size(t, limit)?;
limit -= cnt;
}
for t in &func.rets {
cnt += text_size(t, limit)?;
limit -= cnt;
for t in func.args.iter().chain(func.rets.iter()) {
let arg = text_size(t, limit)?;
cnt += arg;
limit -= arg;
}
cnt
}
Expand All @@ -415,14 +457,18 @@ pub fn text_size(t: &Type, limit: i32) -> Result<i32, ()> {
let mut limit = limit;
for (name, f) in ms {
let len = name.len() as i32;
cnt += len + text_size(f, limit - len - 3)? + 3;
limit -= cnt;
let method = len + text_size(f, limit - len - 3)? + 3;
cnt += method;
limit -= method;
}
10 + cnt
}
Future => 6,
Unknown => 7,
Class(..) => unreachable!(),
// A service constructor has no rendering of its own: the pretty printer has no
// arm for one. Refusing it here, rather than asserting it cannot appear, keeps
// every caller total for a type that merely contains one.
Class(..) => return Err(()),
};
if cost > limit {
Err(())
Expand Down
Loading
Loading