From 1de655330ce9ae0eb3b16548ac00b1d251c70942 Mon Sep 17 00:00:00 2001 From: yuxi-liu-wired <33951560+yuxi-liu-wired@users.noreply.github.com> Date: Thu, 1 Oct 2026 20:23:41 +0000 Subject: [PATCH 1/2] fix: reject whitespace before a numeric map key Map keys deserialized as i8..i64, u8..u64 or f64 went through `deserialize_number`, which skips leading whitespace, so whitespace inside the key's quotes was accepted and dropped: from_str::>(r#"{" 1":2}"#) Ok({1: 2}) from_str::>("{\"\t1\":2}") Ok({1: 2}) although `"\t1"` is not even a valid JSON string (raw control character), and `" 1"` is not the text of a number. serde_json rejects both ("expected key to be a number in quotes"), and sonic-rs's own i128/u128 key path already did. The single-argument arm of `deserialize_numeric_key!` now makes the same check as the two-argument arm: the first byte inside the quotes must be a digit or `-`. Test: `test_numeric_key_rejects_leading_whitespace` (fails before this change). Found by a three-way differential fuzzer (serde_json, simd-json, sonic-rs). --- src/serde/de.rs | 7 +++++++ src/serde/mod.rs | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/src/serde/de.rs b/src/serde/de.rs index db71213..f25ed02 100644 --- a/src/serde/de.rs +++ b/src/serde/de.rs @@ -1152,6 +1152,13 @@ macro_rules! deserialize_numeric_key { where V: de::Visitor<'de>, { + // The key text must be the number itself: `deserialize_number` skips leading + // whitespace, which is not part of a numeric key (`{" 1": 0}` is not key 1). + match self.de.parser.read.peek() { + Some(b'0'..=b'9' | b'-') => {} + _ => return Err(self.de.parser.error(ErrorCode::ExpectedNumericKey)), + } + let value = tri!(self.de.deserialize_number(visitor)); if self.de.parser.read.next() != Some(b'"') { return Err(self.de.parser.error(ErrorCode::ExpectedQuote)); diff --git a/src/serde/mod.rs b/src/serde/mod.rs index 1a90144..ec9e1e8 100644 --- a/src/serde/mod.rs +++ b/src/serde/mod.rs @@ -767,6 +767,41 @@ mod test { assert_eq!(collect("[1] [2"), vec![Ok(crate::json!([1])), Err(())]); } + #[test] + fn test_numeric_key_rejects_leading_whitespace() { + // A numeric map key is the number itself; whitespace inside the quotes is not skipped + // (serde_json rejects these too). It used to be: `{" 1": 2}` gave the key 1. + for json in [ + r#"{" 1":2}"#, + r#"{" -1":2}"#, + "{\"\t1\":2}", + "{\"\n1\":2}", + r#"{" 1.5":2}"#, + ] { + assert!( + crate::from_str::>(json).is_err(), + "i64 key: {json:?}" + ); + assert!( + crate::from_str::>(json).is_err(), + "u32 key: {json:?}" + ); + assert!( + crate::from_str::>(json).is_err(), + "i8 key: {json:?}" + ); + assert!( + serde_json::from_str::>(json).is_err(), + "serde_json: {json:?}" + ); + } + // as a string key it is fine, and plain numeric keys are unchanged + let m: BTreeMap = crate::from_str(r#"{" 1":2}"#).unwrap(); + assert_eq!(m.get(" 1"), Some(&2)); + let m: BTreeMap = crate::from_str(r#"{"1":2,"-3":4}"#).unwrap(); + assert_eq!(m, BTreeMap::from([(1, 2), (-3, 4)])); + } + #[test] fn test_utf8_lossy() { let data = [&[b'\"', 0xff, b'\"'][..], br#""\uD800""#, br#""\udc00""#]; From 9109ca0956f3bcc746dac8df43beda15820c3f07 Mon Sep 17 00:00:00 2001 From: yuxi-liu-wired <33951560+yuxi-liu-wired@users.noreply.github.com> Date: Thu, 1 Oct 2026 20:23:41 +0000 Subject: [PATCH 2/2] fix: numeric map keys from a Value must be the whole key `MapKeyDeserializer` (used by `from_value` and `Value` as a `Deserializer`) parsed an integer key as a prefix of the key text and ignored the rest: let v: Value = from_str(r#"{"12abc":1,"1,2":2,"08":3}"#)?; from_value::>(&v) Ok({12: 1, 1: 2, 0: 3}) Deserializing the same JSON text into the same type is an error, and so is serde_json's `from_value`. After the number, the key must be at its end; otherwise `ExpectedNumericKey`. Test: `test_numeric_map_key_from_value_must_be_whole_number` (fails before this change). Found by a differential fuzzer (serde_json, simd-json, sonic-rs, jiter). --- src/value/de.rs | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/src/value/de.rs b/src/value/de.rs index 5694482..c891d26 100644 --- a/src/value/de.rs +++ b/src/value/de.rs @@ -213,6 +213,11 @@ macro_rules! deserialize_numeric_key { _ => return Err(Error::syntax(ErrorCode::ExpectedNumericKey, b"", 0)), } let number = tri!(de.$using(visitor)); + // the whole key must be the number: `{"1x": 0}` is not key 1 (as in serde_json, and + // as when deserializing from text, where the closing quote must follow the number) + if de.parser.read.peek().is_some() { + return Err(Error::syntax(ErrorCode::ExpectedNumericKey, b"", 0)); + } Ok(number) } }; @@ -746,4 +751,37 @@ mod test { assert!(from_value::(&v).is_err(), "{bad}"); } } + + #[test] + fn test_numeric_map_key_from_value_must_be_whole_number() { + // A numeric key is the whole key text. It used to be parsed as a prefix, so + // `{"12abc": 1}` gave the key 12 through `from_value` (the text path rejects it). + use std::collections::BTreeMap; + + use crate::{from_str, from_value, Value}; + + for json in [ + r#"{"12abc":1}"#, + r#"{"1 ":1}"#, + r#"{"1,2":1}"#, + r#"{"0x1":1}"#, + r#"{"08":1}"#, + ] { + let v: Value = from_str(json).unwrap(); + assert!( + from_value::>(&v).is_err(), + "i64 key: {json}" + ); + assert!( + from_value::>(&v).is_err(), + "u32 key: {json}" + ); + assert!(from_str::>(json).is_err(), "text: {json}"); + } + let v: Value = from_str(r#"{"12":1,"-3":2}"#).unwrap(); + assert_eq!( + from_value::>(&v).unwrap(), + BTreeMap::from([(12, 1), (-3, 2)]) + ); + } }