Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# Changelog

## Unreleased

### Candid

* Bug fixes:
+ Bound the remaining wire-declared structural counts in the type-table header, matching the bound the type table size already carries. The number of arguments, a record or variant's field count, a function type's argument and result counts, and a service type's method count each drive a `count`-sized allocation; a well-formed message keeps every one of them proportional to the type description, so they are now capped the same way. An out-of-range count surfaces as an ordinary parse error instead of reserving a correspondingly large buffer. The argument count reuses the configurable `max_type_len` limit; the type-table-internal counts use the same default bound as the type table. The wire format is unchanged and valid messages decode identically.
+ Share the undecoded argument queue behind a reference count so the option/backtracking path no longer copies it. When decoding a present `opt` whose wire and expected types differ, the deserializer takes a snapshot to restore on a subtype mismatch. That snapshot only needs the fields a sub-decode can mutate, and the argument queue is not one of them — it is touched only at the top level — so it is now shared rather than cloned. Decoding many optional trailing arguments is correspondingly cheaper, and the decode result is unchanged.

## 2026-08-14

### candid_parser 0.4.1
Expand Down
11 changes: 11 additions & 0 deletions rust/candid/src/binary_parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,12 @@ fn read_leb_usize(name: &'static str, range_msg: &'static str) -> BinResult<usiz
pub struct Header {
#[br(args(max_type_len))]
table: Table,
// The argument count drives a `count`-sized allocation, so bound it the same
// way the type table size is bounded: a well-formed message declares an
// argument per value it carries, and that count stays proportional to the
// type table describing them rather than to the raw byte length.
#[br(parse_with = read_leb, args("len"))]
#[br(assert(len <= max_type_len.unwrap_or(MAX_TYPE_TABLE_LEN as usize) as u64, "number of arguments exceeded"))]
Comment thread
lwshang marked this conversation as resolved.
Outdated
len: u64,
#[br(count = len)]
args: Vec<IndexType>,
Expand Down Expand Up @@ -140,7 +145,10 @@ struct IndexType {
}
#[derive(BinRead, Debug)]
struct Fields {
// Each field descriptor drives a `count`-sized allocation; keep the field
// count within the same structural bound as the type table itself.
#[br(parse_with = read_leb_u32, args("len", "field length out of 32-bit range"))]
#[br(assert(len as u64 <= MAX_TYPE_TABLE_LEN, "number of fields exceeded"))]
len: u32,
#[br(count = len)]
inner: Vec<FieldType>,
Expand All @@ -154,10 +162,12 @@ struct FieldType {
#[derive(BinRead, Debug)]
struct FuncType {
#[br(parse_with = read_leb, args("arg_len"))]
#[br(assert(arg_len <= MAX_TYPE_TABLE_LEN, "number of function arguments exceeded"))]
arg_len: u64,
#[br(count = arg_len)]
args: Vec<IndexType>,
#[br(parse_with = read_leb, args("ret_len"))]
#[br(assert(ret_len <= MAX_TYPE_TABLE_LEN, "number of function results exceeded"))]
ret_len: u64,
#[br(count = ret_len)]
rets: Vec<IndexType>,
Expand All @@ -169,6 +179,7 @@ struct FuncType {
#[derive(BinRead, Debug)]
struct ServType {
#[br(parse_with = read_leb, args("len"))]
#[br(assert(len <= MAX_TYPE_TABLE_LEN, "number of service methods exceeded"))]
len: u64,
#[br(count = len)]
meths: Vec<Meths>,
Expand Down
10 changes: 7 additions & 3 deletions rust/candid/src/de.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ impl<'de> IDLDeserialize<'de> {
}
}

let (ind, ty) = self.de.types.pop_front().unwrap();
let (ind, ty) = Rc::make_mut(&mut self.de.types).pop_front().unwrap();
self.de.expect_type = if matches!(expected_type.as_ref(), TypeInner::Unknown) {
self.de.is_untyped = true;
ty.clone()
Expand Down Expand Up @@ -289,7 +289,11 @@ macro_rules! check {
struct Deserializer<'de> {
input: Cursor<&'de [u8]>,
table: Rc<TypeEnv>,
types: VecDeque<(usize, Type)>,
// The undecoded argument queue. It is only ever mutated at the top level
// (`IDLDeserialize::get_value`), never during a value sub-decode, so holding
// it behind an `Rc` lets the backtracking snapshot in `recoverable_visit_some`
// share it with a refcount bump instead of copying the whole queue.
types: Rc<VecDeque<(usize, Type)>>,
wire_type: Type,
expect_type: Type,
// Memo table for subtyping relation
Expand All @@ -316,7 +320,7 @@ impl<'de> Deserializer<'de> {
Ok(Deserializer {
input: reader,
table: env.into(),
types: types.into_iter().enumerate().collect(),
types: Rc::new(types.into_iter().enumerate().collect()),
wire_type: TypeInner::Unknown.into(),
expect_type: TypeInner::Unknown.into(),
gamma: Gamma::default(),
Expand Down
60 changes: 60 additions & 0 deletions rust/candid/tests/arg_count_alloc.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
//! Wire-declared structural lengths must not turn into oversized up-front
//! allocations. A length prefix need not match the bytes actually present, so an
//! out-of-range argument, field, function-arity or method count must fail as an
//! ordinary parse error rather than reserving a correspondingly large buffer.
//!
//! The type table already carries this bound; these cases cover the remaining
//! length prefixes so that every `count`-sized allocation stays proportional to
//! the type description rather than to the raw byte length.

use candid::de::IDLDeserialize;
use candid::Encode;

/// Drive the full deserialize, including skipping every declared argument, so a
/// healthy parser rejects a malformed length and returns normally.
fn decode(hex: &str) -> candid::Result<()> {
let bytes = hex::decode(hex).unwrap();
let mut de = IDLDeserialize::new(&bytes)?;
while !de.is_done() {
de.get_value::<candid::Reserved>()?;
}
Ok(())
}

#[test]
fn argument_count_out_of_range_is_rejected() {
// "DIDL" 00 <uleb arg-count = 2^62> with an empty type table and no args.
assert!(decode("4449444c00808080808080808040").is_err());
}

#[test]
fn record_field_count_out_of_range_is_rejected() {
// "DIDL" 01 6c <uleb field-count = 2^62>: a record type declaring 2^62 fields.
assert!(decode("4449444c016c808080808080808040").is_err());
}
Comment thread
lwshang marked this conversation as resolved.
Outdated

#[test]
fn function_arity_out_of_range_is_rejected() {
// "DIDL" 01 6a <uleb arg-len = 2^62>: a func type declaring 2^62 arguments.
assert!(decode("4449444c016a808080808080808040").is_err());
}
Comment thread
lwshang marked this conversation as resolved.
Outdated

#[test]
fn service_method_count_out_of_range_is_rejected() {
// "DIDL" 01 69 <uleb method-count = 2^62>: a service declaring 2^62 methods.
assert!(decode("4449444c0169808080808080808040").is_err());
}

/// A well-formed message with trailing arguments that are skipped through the
/// option/backtracking path still round-trips. This exercises the shared
/// argument queue across the top-level pops that follow a backtracking clone.
#[test]
fn trailing_optional_arguments_still_decode() {
// encode (nat32, opt nat32, opt nat32) and decode only the first value,
// letting `done()` drain the rest through the skip path.
let bytes = Encode!(&7u32, &Some(8u32), &Some(9u32)).unwrap();
let mut de = IDLDeserialize::new(&bytes).unwrap();
let first: u32 = de.get_value().unwrap();
assert_eq!(first, 7);
de.done().unwrap();
}
Comment thread
lwshang marked this conversation as resolved.
Outdated
Loading