diff --git a/.changes/unreleased/fixed-20260628-211100.yaml b/.changes/unreleased/fixed-20260628-211100.yaml new file mode 100644 index 00000000..8bbad089 --- /dev/null +++ b/.changes/unreleased/fixed-20260628-211100.yaml @@ -0,0 +1,3 @@ +kind: Fixed +body: 'JSON: quoted integer strings in decimal/exponent notation (e.g. `"9007199254740993.0"`, `"9.007199254740993e15"`) now parse exactly instead of routing through `f64`, which silently rounded magnitudes above 2^53. Non-integral and overflowing strings are still rejected. (#255)' +time: 2026-06-28T21:11:00.000000000Z diff --git a/buffa/src/json_helpers.rs b/buffa/src/json_helpers.rs index 5103f96e..e868e8f5 100644 --- a/buffa/src/json_helpers.rs +++ b/buffa/src/json_helpers.rs @@ -915,14 +915,70 @@ fn parse_int_from_str>(v: &str) -> Option { if let Ok(n) = v.parse::() { return I::try_from(n).ok(); } - // Fall back to parsing as f64 for exponential/float notation (e.g. "1e5"). - // The f64 intermediate silently rounds values > 2^53, but the direct - // integer parse above handles those cases exactly. - let f: f64 = v.parse().ok()?; - if !is_exact_integer(f) { + let n = parse_exact_decimal_int(v)?; + I::try_from(n).ok() +} + +/// Parse a decimal/exponential string as an exact integer. +/// +/// Accepts the numeric string forms the JSON integer helpers intentionally +/// support beyond plain integers: zero-fraction decimals like `"1.0"` and +/// decimal scientific notation like `"1e5"` / `"1.0e2"`. Returns `None` if +/// the value is not mathematically integral or would overflow `i128`. +fn parse_exact_decimal_int(v: &str) -> Option { + let (mantissa, exp) = match v.split_once(['e', 'E']) { + // `i64::from_str` handles the exponent's sign and rejects empty, + // non-digit, and i64-overflowing exponents. + Some((m, e)) => (m, e.parse::().ok()?), + None => (v, 0), + }; + let (negative, mantissa) = match mantissa.strip_prefix('-') { + Some(rest) => (true, rest), + None => (false, mantissa.strip_prefix('+').unwrap_or(mantissa)), + }; + let (int_part, frac_part) = mantissa + .split_once('.') + .map_or((mantissa, ""), |(i, f)| (i, f)); + if int_part.is_empty() && frac_part.is_empty() { return None; } - I::try_from(f as i128).ok() + // Trailing fractional zeros do not affect integrality and would only + // inflate the significand before we re-scale it. + let frac_part = frac_part.trim_end_matches('0'); + + let mut significand = 0i128; + for digit in int_part.bytes().chain(frac_part.bytes()) { + if !digit.is_ascii_digit() { + return None; + } + significand = significand + .checked_mul(10)? + .checked_add(i128::from(digit - b'0'))?; + } + if significand == 0 { + // Zero is integral under any exponent; short-circuit before the + // power-of-ten scaling below can overflow on e.g. "0e100". + return Some(0); + } + + // value = significand × 10^(exp − frac_len) + let scale = exp.checked_sub(frac_part.len() as i64)?; + if scale >= 0 { + let pow10 = 10i128.checked_pow(u32::try_from(scale).ok()?)?; + significand = significand.checked_mul(pow10)?; + } else { + let divisor = 10i128.checked_pow(u32::try_from(scale.unsigned_abs()).ok()?)?; + if significand % divisor != 0 { + return None; + } + significand /= divisor; + } + + if negative { + significand.checked_neg() + } else { + Some(significand) + } } /// Try to interpret an f64 as an exact integer. diff --git a/buffa/src/json_helpers/tests.rs b/buffa/src/json_helpers/tests.rs index 2a7e988c..d9fc4e33 100644 --- a/buffa/src/json_helpers/tests.rs +++ b/buffa/src/json_helpers/tests.rs @@ -246,6 +246,26 @@ fn int64_deserializes_from_quoted_string() { assert_eq!(val.0, -9007199254740993i64); } +#[test] +fn int64_deserializes_exact_float_notation_strings() { + #[rustfmt::skip] + let cases: &[(&str, i64)] = &[ + (r#""9007199254740993.0""#, 9007199254740993), + (r#""9.007199254740993e15""#, 9007199254740993), + (r#""1200e-2""#, 12), + ]; + for &(json, expected) in cases { + let val: SerdeInt64 = serde_json::from_str(json).unwrap(); + assert_eq!(val.0, expected, "input: {json}"); + } +} + +#[test] +fn int64_deserializes_exact_negative_float_notation_string() { + let val: SerdeInt64 = serde_json::from_str(r#""-9007199254740993.0""#).unwrap(); + assert_eq!(val.0, -9007199254740993i64); +} + // ── uint64 ────────────────────────────────────────────────────────────── #[test] @@ -260,6 +280,12 @@ fn uint64_deserializes_from_quoted_string() { assert_eq!(val.0, u64::MAX); } +#[test] +fn uint64_deserializes_exact_float_notation_string() { + let val: SerdeUint64 = serde_json::from_str(r#""18446744073709551615.0""#).unwrap(); + assert_eq!(val.0, u64::MAX); +} + // ── float ─────────────────────────────────────────────────────────────── #[test] @@ -384,6 +410,26 @@ fn int64_rejects_overflow_string() { assert!(serde_json::from_str::(r#""99999999999999999999""#).is_err()); } +#[test] +fn int64_rejects_inexact_exponential_string() { + assert!(serde_json::from_str::(r#""1e-1""#).is_err()); +} + +#[test] +fn int64_rejects_significand_overflow_string() { + // 40 digits overflows even the i128 significand accumulator, exercising + // the checked-arithmetic path rather than the i64 try_from narrowing. + assert!( + serde_json::from_str::(r#""9999999999999999999999999999999999999999""#) + .is_err() + ); +} + +#[test] +fn int64_rejects_huge_exponent_string() { + assert!(serde_json::from_str::(r#""1e9999999999""#).is_err()); +} + #[test] fn uint64_rejects_negative_string() { // "-1" parses as i64 but fails u64::try_from @@ -963,11 +1009,13 @@ fn int32_deserialize_table() { ("42", Some(42)), // bare number (r#""42""#, Some(42)), // quoted string (r#""1e2""#, Some(100)), // exponential string notation + (r#""1200e-2""#, Some(12)), // exact negative exponent ("null", Some(0)), // null → 0 ("1.0", Some(1)), // integer-valued f64 (visit_f64) ("-2147483648", Some(i32::MIN)), // boundary ("2147483647", Some(i32::MAX)), // boundary ("9223372036854775807", None), // i64::MAX overflow → error + (r#""1e-1""#, None), // non-integral exponential string → error (r#""1.5""#, None), // fractional string → error ("1.5", None), // fractional f64 → error ];