From c47e47c0c5d480f2701c0d71cbd022a10d9d4475 Mon Sep 17 00:00:00 2001 From: David Sherret Date: Sun, 26 Jul 2026 09:56:36 -0400 Subject: [PATCH 1/2] fix(serde): error instead of returning `null` for numbers out of range (#85) A number too large for an `f64` (ex. `1e400`) deserialized to `serde_json::Value::Null` with no error, because `str::parse::` resolves to infinity instead of erroring and `serde_json` turns non-finite floats into `null`. Now these error with "Number is out of range", which matches what `serde_json::from_str` does for the same input. The hexadecimal branch had the same class of bug, where an out of range number was visited as a string. It now errors as well and additionally handles values that fit in a `u64` (ex. `0x8000000000000000`) or that are exactly `i64::MIN` (`-0x8000000000000000`), which previously deserialized as strings. --- src/serde.rs | 78 ++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 70 insertions(+), 8 deletions(-) diff --git a/src/serde.rs b/src/serde.rs index 38665a0..fe3921b 100644 --- a/src/serde.rs +++ b/src/serde.rs @@ -230,13 +230,20 @@ fn visit_number<'de, V: Visitor<'de>>(raw: &str, visitor: V) -> Result 2 && (trimmed.starts_with("0x") || trimmed.starts_with("0X")) { let hex_part = &trimmed[2..]; - match i64::from_str_radix(hex_part, 16) { + // parse as an i128 so that values up to u64::MAX and down to i64::MIN fit + return match i128::from_str_radix(hex_part, 16) { Ok(val) => { let val = if raw.starts_with('-') { -val } else { val }; - return visitor.visit_i64(val); + if let Ok(val) = i64::try_from(val) { + visitor.visit_i64(val) + } else if let Ok(val) = u64::try_from(val) { + visitor.visit_u64(val) + } else { + Err(number_out_of_range_error()) + } } - Err(_) => return visitor.visit_str(raw), - } + Err(_) => Err(number_out_of_range_error()), + }; } // strip unary plus @@ -248,12 +255,19 @@ fn visit_number<'de, V: Visitor<'de>>(raw: &str, visitor: V) -> Result() { return visitor.visit_u64(v); } - if let Ok(v) = num_str.parse::() { - return visitor.visit_f64(v); + match num_str.parse::() { + Ok(v) if v.is_finite() => visitor.visit_f64(v), + // `parse` resolves to infinity instead of erroring for numbers that are + // too large and the scanner only ever provides numbers an f64 can parse + _ => Err(number_out_of_range_error()), } +} - // fallback for unparseable numbers - visitor.visit_str(raw) +/// Errors instead of silently changing the value's JSON type, which is what +/// visiting a non-finite float would do (ex. `serde_json::Value` turns those +/// into `null`). +fn number_out_of_range_error() -> ParseError { + ParseError::custom_err("Number is out of range".to_string()) } // array handling @@ -530,6 +544,54 @@ mod tests { assert_eq!(result, SerdeValue::Object(expected_value)); } + #[test] + fn it_should_error_when_number_is_out_of_f64_range() { + assert_has_error(r#"{ "amount": 1e400 }"#, "Number is out of range on line 1 column 13"); + assert_has_error("[-1e400]", "Number is out of range on line 1 column 2"); + assert_has_error("1.7976931348623159e308", "Number is out of range on line 1 column 1"); + + // deserializing to a float should error as well instead of resolving to infinity + let err = parse_to_serde_value::("1e400", &Default::default()).unwrap_err(); + assert_eq!(err.to_string(), "Number is out of range on line 1 column 1"); + + // the number being out of range still errors when the value is ignored + #[derive(::serde::Deserialize, Debug, PartialEq)] + #[serde(crate = "::serde")] + struct Amount { + amount: u32, + } + let err = parse_to_serde_value::(r#"{ "amount": 1, "other": 1e400 }"#, &Default::default()).unwrap_err(); + assert_eq!(err.to_string(), "Number is out of range on line 1 column 25"); + + // numbers that underflow are fine + let result = parse_to_serde_value::("1e-400", &Default::default()).unwrap(); + assert_eq!(result, 0.0); + let result = parse_to_serde_value::("-1e-400", &Default::default()).unwrap(); + assert_eq!(result, -0.0); + assert!(result.is_sign_negative()); + + // the largest finite f64 is fine + let result = parse_to_serde_value::("1.7976931348623157e308", &Default::default()).unwrap(); + assert_eq!(result, f64::MAX); + } + + #[test] + fn it_should_error_when_hexadecimal_number_is_out_of_range() { + assert_has_error( + r#"{ "value": 0xFFFFFFFFFFFFFFFFFF }"#, + "Number is out of range on line 1 column 12", + ); + assert_has_error("-0x8000000000000001", "Number is out of range on line 1 column 1"); + + // hexadecimal numbers that don't fit in an i64, but fit in a u64 + let result = parse_to_serde_value::("0x8000000000000000", &Default::default()).unwrap(); + assert_eq!(result, 9223372036854775808); + + // i64::MIN + let result = parse_to_serde_value::("-0x8000000000000000", &Default::default()).unwrap(); + assert_eq!(result, i64::MIN); + } + #[test] fn it_should_deserialize_to_struct() { #[derive(::serde::Deserialize, Debug, PartialEq)] From a36872c5814a244d8849bd6b9e6dedf836cdd61a Mon Sep 17 00:00:00 2001 From: David Sherret Date: Sun, 26 Jul 2026 10:04:54 -0400 Subject: [PATCH 2/2] perf(serde): parse hexadecimal numbers as an i64 first Only falls back to an i128 for the values an i64 can't hold, which are the ones that fit in a u64 and `i64::MIN`. --- src/serde.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/serde.rs b/src/serde.rs index fe3921b..70400da 100644 --- a/src/serde.rs +++ b/src/serde.rs @@ -230,10 +230,15 @@ fn visit_number<'de, V: Visitor<'de>>(raw: &str, visitor: V) -> Result 2 && (trimmed.starts_with("0x") || trimmed.starts_with("0X")) { let hex_part = &trimmed[2..]; - // parse as an i128 so that values up to u64::MAX and down to i64::MIN fit + let negative = raw.starts_with('-'); + if let Ok(val) = i64::from_str_radix(hex_part, 16) { + return visitor.visit_i64(if negative { -val } else { val }); + } + // fall back to an i128 for the values that don't fit in an i64, which + // are the ones that fit in a u64 and `i64::MIN` return match i128::from_str_radix(hex_part, 16) { Ok(val) => { - let val = if raw.starts_with('-') { -val } else { val }; + let val = if negative { -val } else { val }; if let Ok(val) = i64::try_from(val) { visitor.visit_i64(val) } else if let Ok(val) = u64::try_from(val) {