From a05b2fe747e686013c12440eee794bbf7d2b3262 Mon Sep 17 00:00:00 2001 From: Linwei Shang Date: Mon, 21 Sep 2026 17:33:20 +0800 Subject: [PATCH 1/2] fix: Scope the big-integer decode fast path to map values `deserialize_map` derives a big-integer fast path from the map's value type. That flag lived on the deserializer for the whole map, so it was still active while an entry's key was decoded, and the value relied on the expected/wire types set up before the first entry. Scope it to values instead: `Style::Map` carries the map's own fast path, `next_key_seed` clears it for the duration of the key, and `next_value_seed` restores it and re-establishes the value's expected and wire types on every entry. A key now always goes through its own type's entry point, keeping its own encoding (SLEB128 for `int`, LEB128 for `nat`) and its own subtype check, and a value keeps its own types, for every combination of key and value type. Restoring per entry also means a nested compound inside a value cannot leave the fast path cleared for the entries that follow it. The wire format is unchanged, entries whose key and value types agree decode identically, and cost accounting is unchanged: `any_fast` reads the value stashed on `Style::Map`, which held the same value on every entry. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 7 ++++ rust/candid/src/de.rs | 42 ++++++++++++++++----- rust/candid/tests/serde.rs | 77 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 117 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ee12ca4..d44e6df9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## Unreleased + +### Candid + +* Bug fixes: + + Scope the big-integer decode fast path to map values, so a map entry's key and value each decode under their own declared type. `deserialize_map` derives that fast path from the map's value type; it is now cleared for the duration of the key and restored for the value, and the value's expected and wire types are re-established on every entry. A key therefore always goes through its own type's entry point, keeping its own encoding (SLEB128 for `int`, LEB128 for `nat`) and its own subtype check, for every combination of key and value type. The wire format is unchanged, and entries whose key and value types agree decode identically. + ## 2026-08-14 ### candid_parser 0.4.1 diff --git a/rust/candid/src/de.rs b/rust/candid/src/de.rs index 7c0bc4b3..10acefe4 100644 --- a/rust/candid/src/de.rs +++ b/rust/candid/src/de.rs @@ -1297,6 +1297,8 @@ impl<'de> de::Deserializer<'de> for &mut Deserializer<'de> { expect, wire, key_text_fast, + #[cfg(feature = "bignum")] + value_bignum_fast, }, )); self.text_fast_path = false; @@ -1444,6 +1446,8 @@ enum Style { expect: (Type, Type), wire: (Type, Type), key_text_fast: bool, + #[cfg(feature = "bignum")] + value_bignum_fast: Option, }, } @@ -1760,13 +1764,15 @@ impl<'de> de::MapAccess<'de> for Compound<'_, 'de> { ref expect, ref wire, key_text_fast, + #[cfg(feature = "bignum")] + value_bignum_fast, } => { if *len == 0 { return Ok(None); } *len -= 1; #[cfg(feature = "bignum")] - let any_fast = key_text_fast || self.de.bignum_vec_fast_path.is_some(); + let any_fast = key_text_fast || value_bignum_fast.is_some(); #[cfg(not(feature = "bignum"))] let any_fast = key_text_fast; if !any_fast { @@ -1778,6 +1784,15 @@ impl<'de> de::MapAccess<'de> for Compound<'_, 'de> { // of this (inner) map, leading to a "Type mismatch" when deserializing // those keys. self.de.text_fast_path = key_text_fast; + // The bignum fast path is derived from the map's *value* type and applies + // only to values, so it stays off for the duration of the key. This keeps + // a key decoding under its own declared type, through the regular + // deserialize_* entry point and its subtype check, whatever the value type + // happens to be. next_value_seed restores it for the value. + #[cfg(feature = "bignum")] + { + self.de.bignum_vec_fast_path = None; + } if !key_text_fast { self.de.expect_type = expect.0.clone(); self.de.wire_type = wire.0.clone(); @@ -1792,21 +1807,30 @@ impl<'de> de::MapAccess<'de> for Compound<'_, 'de> { V: de::DeserializeSeed<'de>, { match &self.style { - Style::Map { expect, wire, .. } => { + Style::Map { + expect, + wire, + #[cfg(feature = "bignum")] + value_bignum_fast, + .. + } => { #[cfg(feature = "bignum")] - let any_fast = self.de.text_fast_path || self.de.bignum_vec_fast_path.is_some(); + let value_bignum_fast = *value_bignum_fast; + #[cfg(feature = "bignum")] + let any_fast = self.de.text_fast_path || value_bignum_fast.is_some(); #[cfg(not(feature = "bignum"))] let any_fast = self.de.text_fast_path; if !any_fast { self.de.add_cost(3)?; } + // Re-establish the value's types unconditionally: the key is decoded under + // the key type, and the bignum fast path still reads according to + // wire_type, so the value needs its own types in place here. + self.de.expect_type = expect.1.clone(); + self.de.wire_type = wire.1.clone(); #[cfg(feature = "bignum")] - let value_fast = self.de.bignum_vec_fast_path.is_some(); - #[cfg(not(feature = "bignum"))] - let value_fast = false; - if !value_fast { - self.de.expect_type = expect.1.clone(); - self.de.wire_type = wire.1.clone(); + { + self.de.bignum_vec_fast_path = value_bignum_fast; } seed.deserialize(&mut *self.de) } diff --git a/rust/candid/tests/serde.rs b/rust/candid/tests/serde.rs index 339c68c4..ce4dec2a 100644 --- a/rust/candid/tests/serde.rs +++ b/rust/candid/tests/serde.rs @@ -856,6 +856,83 @@ fn test_nested_map_non_text_key() { assert_eq!(outer, decoded); } +/// A map entry's key and value each decode under their own declared type: the key +/// through the key type and the value through the value type, independently of each +/// other. In particular the bignum fast path, which `deserialize_map` derives from the +/// value type, applies only to values, so a key keeps its own encoding (SLEB128 for +/// `int`, LEB128 for `nat`) and its own subtype check for every combination of key and +/// value type. +#[test] +fn test_map_key_and_value_decode_under_own_type() { + use std::collections::BTreeMap; + + fn round_trip(k: K, v: V) + where + K: CandidType + for<'a> Deserialize<'a> + Ord + std::fmt::Debug + Clone, + V: CandidType + for<'a> Deserialize<'a> + PartialEq + std::fmt::Debug + Clone, + { + let mut m: BTreeMap = BTreeMap::new(); + m.insert(k, v); + let bytes = encode_one(&m).unwrap(); + let decoded: BTreeMap = decode_one_with_config(&bytes, &get_config()).unwrap(); + assert_eq!( + m.keys().collect::>(), + decoded.keys().collect::>() + ); + assert_eq!( + m.values().collect::>(), + decoded.values().collect::>() + ); + } + + // Signed keys keep their sign whatever the value type is. + for k in [-1i64, -2, -100, -128, -1000, -2147483648] { + round_trip(Int::from(k), Nat::from(42u64)); + round_trip(Int::from(k), Int::from(-7)); + round_trip(Int::from(k), 42u32); + } + // Signed values keep their sign whatever the key type is. + for v in [-1i64, -2, -128, -2147483648] { + round_trip(Nat::from(5u64), Int::from(v)); + round_trip(Int::from(9), Int::from(v)); + round_trip("k".to_string(), Int::from(v)); + } + // Above u64, where the big-integer representation is used. + let big_neg: Int = "-99999999999999999999999999".parse().unwrap(); + let big_nat: Nat = "99999999999999999999999999".parse().unwrap(); + round_trip(big_neg.clone(), big_nat.clone()); + round_trip(big_neg.clone(), big_neg.clone()); + round_trip(big_nat.clone(), big_neg.clone()); + + // Distinct keys stay distinct entries. + let mut m: BTreeMap = BTreeMap::new(); + m.insert(Int::from(127), Nat::from(1000u64)); + m.insert(Int::from(-1), Nat::from(1u64)); + let bytes = encode_one(&m).unwrap(); + assert_eq!( + decode_one_with_config::>(&bytes, &get_config()).unwrap(), + m + ); + + // A key's subtype check runs: int is not a subtype of nat, whatever the value type. + let mut int_key: BTreeMap = BTreeMap::new(); + int_key.insert(Int::from(5), Nat::from(7u64)); + let bytes = encode_one(&int_key).unwrap(); + assert!(decode_one_with_config::>(&bytes, &get_config()).is_err()); + + let mut int_key: BTreeMap = BTreeMap::new(); + int_key.insert(Int::from(5), Int::from(7)); + let bytes = encode_one(&int_key).unwrap(); + assert!(decode_one_with_config::>(&bytes, &get_config()).is_err()); + + // nat is a subtype of int, so widening a key is still accepted. + let mut nat_key: BTreeMap = BTreeMap::new(); + nat_key.insert(Nat::from(5u64), Nat::from(7u64)); + let bytes = encode_one(&nat_key).unwrap(); + let widened = decode_one_with_config::>(&bytes, &get_config()).unwrap(); + assert_eq!(widened[&Int::from(5)], Int::from(7)); +} + /// Regression test: elements decoded through the bulk primitive-vec fast path must /// report the same `is_human_readable()` answer as the main deserializer, which is /// `false` because candid is a binary format. From 16c5bb7048dbec5d308ebd542996de78cc0783b4 Mon Sep 17 00:00:00 2001 From: Linwei Shang Date: Tue, 22 Sep 2026 00:22:17 +0800 Subject: [PATCH 2/2] fix: Clear the text fast path for a map value MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The text fast path is the mirror image of the big-integer one: it is derived from the map's key type, but stayed active while the value was decoded, letting `deserialize_str` read a value without its subtype check. `blob` shares text's length-prefixed encoding, so under a text key a `blob` value was accepted in a text value's place and decoded silently — the same coercion is rejected under a non-text key and outside a map. Clear it for the duration of the value; `next_key_seed` sets it again for the following key. Both fast paths are now scoped to their own half of an entry. Extends the regression test with the text side: `blob` against a text value rejected under a text key and under a non-text key, and text keys and values still round-tripping in both positions. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 2 +- rust/candid/src/de.rs | 6 ++++++ rust/candid/tests/serde.rs | 19 +++++++++++++++++++ 3 files changed, 26 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d44e6df9..5895cdc6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ ### Candid * Bug fixes: - + Scope the big-integer decode fast path to map values, so a map entry's key and value each decode under their own declared type. `deserialize_map` derives that fast path from the map's value type; it is now cleared for the duration of the key and restored for the value, and the value's expected and wire types are re-established on every entry. A key therefore always goes through its own type's entry point, keeping its own encoding (SLEB128 for `int`, LEB128 for `nat`) and its own subtype check, for every combination of key and value type. The wire format is unchanged, and entries whose key and value types agree decode identically. + + Scope the decode fast paths of a map to their own half of an entry, so a map entry's key and value each decode under their own declared type. `deserialize_map` derives a big-integer fast path from the map's value type and a text fast path from its key type; each previously stayed active for the whole entry. The big-integer path is now cleared for the duration of the key and restored for the value, the text path is cleared for the duration of the value, and the value's expected and wire types are re-established on every entry. Each half of an entry therefore goes through its own type's entry point, keeping its own encoding (SLEB128 for `int`, LEB128 for `nat`) and its own subtype check, for every combination of key and value type — including a value whose type shares an encoding with the key's, such as `blob` against `text`. The wire format is unchanged, and entries whose key and value types agree decode identically. ## 2026-08-14 diff --git a/rust/candid/src/de.rs b/rust/candid/src/de.rs index 10acefe4..6ad0cdae 100644 --- a/rust/candid/src/de.rs +++ b/rust/candid/src/de.rs @@ -1828,6 +1828,12 @@ impl<'de> de::MapAccess<'de> for Compound<'_, 'de> { // wire_type, so the value needs its own types in place here. self.de.expect_type = expect.1.clone(); self.de.wire_type = wire.1.clone(); + // The text fast path is the mirror image: it is derived from the map's key + // type, so it too applies only to its own half of the entry. Left on, it + // lets a value be read as text without its subtype check -- `blob` shares + // text's length-prefixed encoding, so it would be accepted in a text + // value's place. next_key_seed sets it again for the following key. + self.de.text_fast_path = false; #[cfg(feature = "bignum")] { self.de.bignum_vec_fast_path = value_bignum_fast; diff --git a/rust/candid/tests/serde.rs b/rust/candid/tests/serde.rs index ce4dec2a..6860f834 100644 --- a/rust/candid/tests/serde.rs +++ b/rust/candid/tests/serde.rs @@ -931,6 +931,25 @@ fn test_map_key_and_value_decode_under_own_type() { let bytes = encode_one(&nat_key).unwrap(); let widened = decode_one_with_config::>(&bytes, &get_config()).unwrap(); assert_eq!(widened[&Int::from(5)], Int::from(7)); + + // The mirror image of the same property: a text *key* must not let the value skip + // its own subtype check. `blob` shares text's length-prefixed encoding, so a value + // read under the key's text fast path would be accepted in a text value's place. + let mut blob_val: BTreeMap> = BTreeMap::new(); + blob_val.insert("k".to_string(), b"hello".to_vec()); + let bytes = encode_one(&blob_val).unwrap(); + assert!(decode_one_with_config::>(&bytes, &get_config()).is_err()); + // Rejected for the same reason outside a map and under a non-text key. + let mut blob_val2: BTreeMap> = BTreeMap::new(); + blob_val2.insert(1, b"hello".to_vec()); + let bytes2 = encode_one(&blob_val2).unwrap(); + assert!(decode_one_with_config::>(&bytes2, &get_config()).is_err()); + + // Text keys and text values still round-trip, including a text value under a + // non-text key and a non-text value under a text key. + round_trip("k".to_string(), "v".to_string()); + round_trip(Nat::from(1u64), "v".to_string()); + round_trip("k".to_string(), Nat::from(1u64)); } /// Regression test: elements decoded through the bulk primitive-vec fast path must