Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
81 changes: 68 additions & 13 deletions rust/candid/src/types/internal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,53 @@ 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 for a type named in a diagnostic, in approximate rendered characters.
///
/// [`text_size`] estimates rather than measures, so a type that passes can still render
/// somewhat longer than this. What matters is that the estimate is proportional to the
/// rendering, which keeps the result bounded.
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 +435,29 @@ 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;
// arguments and results each render as a list separated by ", "; every
// other arm already covers its own separators through its per-item charge
for list in [&func.args, &func.rets] {
for (i, t) in list.iter().enumerate() {
let sep = if i == 0 { 0 } else { 2 };
let arg = sep + text_size(t, limit - sep)?;
cnt += arg;
limit -= arg;
}
}
cnt
}
Expand All @@ -415,14 +466,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