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
10 changes: 8 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
# Changelog

## Unreleased
## 2026-05-20

### candid_parser
### Candid 0.10.28

* Bug fixes:
+ Fix LEB128/SLEB128 fast path silently truncating `Nat`/`Int` values near the `u64`/`i64` boundary during decoding
+ Fix `Int::decode` truncating large magnitudes due to fast-path leakage

### candid_parser 0.3.2

* Bug fixes:
+ Motoko binding: emit `Float32` for Candid `float32` instead of panicking. `float32` support was added to Motoko in version 1.4.0.
Expand Down
14 changes: 7 additions & 7 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 3 additions & 3 deletions rust/bench/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions rust/candid/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
[package]
name = "candid"
# sync with the version in `candid_derive/Cargo.toml`
version = "0.10.27"
version = "0.10.28"
edition = "2021"
rust-version.workspace = true
authors = ["DFINITY Team"]
Expand All @@ -16,7 +16,7 @@ keywords = ["internet-computer", "idl", "candid", "dfinity"]
include = ["src", "Cargo.toml", "LICENSE", "README.md"]

[dependencies]
candid_derive = { path = "../candid_derive", version = "=0.10.27" }
candid_derive = { path = "../candid_derive", version = "=0.10.28" }
ic_principal = { path = "../ic_principal", version = "0.1.0" }
binread = { version = "2.2", features = ["debug_template"] }
byteorder = "1.5.0"
Expand Down
27 changes: 14 additions & 13 deletions rust/candid/src/de.rs
Original file line number Diff line number Diff line change
Expand Up @@ -354,8 +354,12 @@ impl<'de> Deserializer<'de> {
self.try_read_leb_u64()?
.ok_or_else(|| Error::msg("LEB128 overflow"))
}
/// Returns `Ok(None)` on overflow (value too large for u64), `Err` on I/O error (e.g. EOF).
/// Returns `Ok(None)` on overflow (value may not fit in u64), `Err` on I/O error (e.g. EOF).
/// This lets callers fall through to a bignum path on overflow without swallowing real errors.
///
/// The fast path covers up to 9 LEB128 bytes (values that fit in 63 bits, i.e. < 2^63).
/// Larger values bail to the bignum path, which keeps this hot loop simple — values needing
/// the 10th byte may or may not fit in u64, and the boundary check would just slow it down.
#[inline]
fn try_read_leb_u64(&mut self) -> Result<Option<u64>> {
let slice = self.input.get_ref();
Expand All @@ -369,22 +373,22 @@ impl<'de> Deserializer<'de> {
}
let byte = slice[pos];
pos += 1;
let low = (byte & 0x7f) as u64;
if shift < 64 {
result |= low << shift;
}
result |= ((byte & 0x7f) as u64) << shift;
if byte & 0x80 == 0 {
self.input.set_position(pos as u64);
return Ok(Some(result));
}
shift += 7;
if shift >= 70 {
if shift >= 63 {
return Ok(None);
}
}
}
/// Returns `Ok(None)` on overflow (value too large for i64), `Err` on I/O error (e.g. EOF).
/// Returns `Ok(None)` on overflow (value may not fit in i64), `Err` on I/O error (e.g. EOF).
/// This lets callers fall through to a bignum path on overflow without swallowing real errors.
///
/// The fast path covers up to 9 LEB128 bytes (values in roughly -2^62 .. 2^62). Larger
/// values bail to the bignum path, keeping this hot loop simple.
#[inline]
fn try_read_leb_i64(&mut self) -> Result<Option<i64>> {
let slice = self.input.get_ref();
Expand All @@ -399,19 +403,16 @@ impl<'de> Deserializer<'de> {
}
byte = slice[pos];
pos += 1;
let low = (byte & 0x7f) as i64;
if shift < 64 {
result |= low << shift;
}
result |= ((byte & 0x7f) as i64) << shift;
shift += 7;
if byte & 0x80 == 0 {
break;
}
if shift >= 70 {
if shift >= 63 {
return Ok(None);
}
}
if shift < 64 && byte & 0x40 != 0 {
if byte & 0x40 != 0 {
result |= !0i64 << shift;
}
self.input.set_position(pos as u64);
Expand Down
6 changes: 5 additions & 1 deletion rust/candid/src/types/number.rs
Original file line number Diff line number Diff line change
Expand Up @@ -319,7 +319,11 @@ impl Int {

let fits_i64 = if shift < 57 {
true
} else if shift < 64 {
} else if shift < 64 && byte & 0x80 == 0 {
// Only the terminal byte can confirm the value fits in i64:
// if continuation is set, more bytes follow at shifts >= 64
// and the value's high bits won't fit. Without this guard,
// `low_bits << shift` would silently truncate bits 1..6.
let remaining_bits = 64 - shift;
if (byte & 0x40) != 0 {
(low_bits | !0x7f) >> (remaining_bits - 1) == -1
Expand Down
81 changes: 81 additions & 0 deletions rust/candid/tests/serde.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,87 @@ fn test_integer() {
check_error(|| test_decode(&hex("4449444c00017c2a"), &42i64), "Int64");
}

// Regression for the v0.10.27 round-trip bug: the LEB128 fast path in
// `deserialize_nat`/`deserialize_int` silently truncated values that didn't
// fit in u64/i64 (e.g. `1 << 64` decoded as 0). The fast path now bails to
// the bignum decoder for any value spanning bit 63 or beyond.
#[test]
fn test_bignum_roundtrip_across_fast_path_boundary() {
use std::ops::Mul;

// The minimal reproducer from the original bug report.
#[derive(CandidType, Deserialize, Debug, PartialEq)]
struct Foo {
amount: Option<Nat>,
}
let foo = Foo {
amount: Some(Nat::from(1_u128 << 64)),
};
let bytes = Encode!(&foo).unwrap();
assert_eq!(Decode!(&bytes, Foo).unwrap(), foo);

// Sweep Nat across the 63/64/65/... bit boundary plus a value past u128.
for k in 60..=68 {
for v in [(1_u128 << k) - 1, 1_u128 << k, (1_u128 << k) + 1] {
let n = Nat::from(v);
let bytes = Encode!(&n).unwrap();
assert_eq!(Decode!(&bytes, Nat).unwrap(), n, "Nat roundtrip {v}");
}
}
let big = Nat::from(u128::MAX).mul(Nat::from(7u32));
assert_eq!(Decode!(&Encode!(&big).unwrap(), Nat).unwrap(), big);

// Int boundary: values straddling i64 limits and a 65-bit value.
for v in [
i64::MAX as i128 - 1,
i64::MAX as i128,
i64::MAX as i128 + 1,
i64::MIN as i128 - 1,
i64::MIN as i128,
i64::MIN as i128 + 1,
1_i128 << 65,
-(1_i128 << 65),
] {
let n = Int::from(v);
let bytes = Encode!(&n).unwrap();
assert_eq!(Decode!(&bytes, Int).unwrap(), n, "Int roundtrip {v}");
}
}

// Regression for a pre-existing bug in `Int::decode`'s small-value fast
// path (#717): at shift=63 the `fits_i64` check treated a chunk whose
// non-data bits matched the sign-extension pattern as "fits", even when
// the byte's continuation bit was set. The shift `low_bits << 63` then
// silently truncated, and the resulting `i64` value was carried into the
// `BigInt` slow path as a corrupt seed — e.g. `i128::MAX` round-tripped
// to -1.
#[test]
fn test_int_decode_large_magnitude_roundtrip() {
use std::ops::Mul;
for v in [
i128::MAX,
i128::MIN,
i128::MAX - 1,
i128::MIN + 1,
1_i128 << 100,
-(1_i128 << 100),
(1_i128 << 126) - 1,
-(1_i128 << 126),
] {
let n = Int::from(v);
let bytes = Encode!(&n).unwrap();
assert_eq!(Decode!(&bytes, Int).unwrap(), n, "Int roundtrip {v}");
}
// Beyond i128: ±(i128::MAX * 3).
let huge = Int::from(i128::MAX).mul(Int::from(3));
assert_eq!(Decode!(&Encode!(&huge).unwrap(), Int).unwrap(), huge);
let huge_neg = Int::from(i128::MIN).mul(Int::from(3));
assert_eq!(
Decode!(&Encode!(&huge_neg).unwrap(), Int).unwrap(),
huge_neg
);
}

#[test]
fn test_fixed_number() {
all_check(42u8, "4449444c00017b2a");
Expand Down
2 changes: 1 addition & 1 deletion rust/candid_derive/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
[package]
name = "candid_derive"
# sync with the version in `candid/Cargo.toml`
version = "0.10.27"
version = "0.10.28"
edition = "2021"
rust-version.workspace = true
authors = ["DFINITY Team"]
Expand Down
2 changes: 1 addition & 1 deletion rust/candid_parser/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "candid_parser"
version = "0.3.1"
version = "0.3.2"
edition = "2021"
rust-version.workspace = true
authors = ["DFINITY Team"]
Expand Down
Loading