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
3 changes: 3 additions & 0 deletions .changes/unreleased/fixed-20260628-211100.yaml
Original file line number Diff line number Diff line change
@@ -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
68 changes: 62 additions & 6 deletions buffa/src/json_helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -915,14 +915,70 @@ fn parse_int_from_str<I: TryFrom<i128>>(v: &str) -> Option<I> {
if let Ok(n) = v.parse::<i128>() {
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<i128> {
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::<i64>().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.
Expand Down
48 changes: 48 additions & 0 deletions buffa/src/json_helpers/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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]
Expand Down Expand Up @@ -384,6 +410,26 @@ fn int64_rejects_overflow_string() {
assert!(serde_json::from_str::<SerdeInt64>(r#""99999999999999999999""#).is_err());
}

#[test]
fn int64_rejects_inexact_exponential_string() {
assert!(serde_json::from_str::<SerdeInt64>(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::<SerdeInt64>(r#""9999999999999999999999999999999999999999""#)
.is_err()
);
}

#[test]
fn int64_rejects_huge_exponent_string() {
assert!(serde_json::from_str::<SerdeInt64>(r#""1e9999999999""#).is_err());
}

#[test]
fn uint64_rejects_negative_string() {
// "-1" parses as i64 but fails u64::try_from
Expand Down Expand Up @@ -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
];
Expand Down
Loading