diff --git a/.changes/unreleased/fixed-20260702-235813.yaml b/.changes/unreleased/fixed-20260702-235813.yaml new file mode 100644 index 0000000..739fbb6 --- /dev/null +++ b/.changes/unreleased/fixed-20260702-235813.yaml @@ -0,0 +1,3 @@ +kind: Fixed +body: '64-bit integer JSON helpers now reject unquoted decimal/exponent numbers at or above 2^52 in magnitude. serde_json''s default float parsing is not correctly rounded, and from 2^52 a one-ulp parse error is a whole integer — an unquoted integer token there could silently decode to the adjacent `i64`/`u64`. Below 2^52 the same error breaks integrality and is rejected loudly by the existing exactness check. Quote large integer values to parse them exactly. (#274)' +time: 2026-07-02T23:58:13.000000000Z diff --git a/buffa/src/json_helpers.rs b/buffa/src/json_helpers.rs index e868e8f..bb0a6e8 100644 --- a/buffa/src/json_helpers.rs +++ b/buffa/src/json_helpers.rs @@ -908,6 +908,21 @@ fn is_exact_integer(f: f64) -> bool { f.is_finite() && f == (f as i128 as f64) } +/// Largest integer-valued `f64` this visitor accepts from an unquoted JSON +/// decimal/exponent token: 2^52 - 1. +/// +/// The hazard is not only f64 representability (integers up to 2^53 are +/// exact) but `serde_json`'s *default* float parsing, which is not +/// correctly rounded (that is its opt-in `float_roundtrip` feature) and can +/// be off by an ulp. In `[2^52, 2^53)` an ulp is exactly 1, so a one-ulp +/// parse error silently turns an integer token into the *adjacent* integer +/// — observed: `"9007199254740991.0"` parsing as `…990.0`. Below 2^52 an +/// ulp is at most 0.5, so the same parse error breaks integrality instead +/// and the exactness check below rejects the token loudly rather than +/// mis-decoding it. Quote larger integer values to parse them exactly — +/// the quoted-string path parses the token text digit-by-digit. +const MAX_SAFE_JSON_INTEGER_F64: f64 = 4_503_599_627_370_495.0; + /// Try to parse a string as an integer, handling float notation like `"1.0"`, /// `"1e5"`, `"1.0e2"`. Returns `None` if the value is not an exact integer. fn parse_int_from_str>(v: &str) -> Option { @@ -981,8 +996,13 @@ fn parse_exact_decimal_int(v: &str) -> Option { } } -/// Try to interpret an f64 as an exact integer. +/// Try to interpret an f64 as an exact integer, rejecting magnitudes above +/// [`MAX_SAFE_JSON_INTEGER_F64`] where the value no longer uniquely +/// identifies the JSON token it was parsed from. fn f64_to_int>(v: f64) -> Option { + if !(-MAX_SAFE_JSON_INTEGER_F64..=MAX_SAFE_JSON_INTEGER_F64).contains(&v) { + return None; + } if !is_exact_integer(v) { return None; } diff --git a/buffa/src/json_helpers/tests.rs b/buffa/src/json_helpers/tests.rs index d9fc4e3..6588baa 100644 --- a/buffa/src/json_helpers/tests.rs +++ b/buffa/src/json_helpers/tests.rs @@ -266,6 +266,45 @@ fn int64_deserializes_exact_negative_float_notation_string() { assert_eq!(val.0, -9007199254740993i64); } +#[test] +fn int64_deserializes_safe_unquoted_float_notation() { + let cases: &[(&str, i64)] = &[ + ("4503599627370495.0", 4503599627370495), + ("4.503599627370495e15", 4503599627370495), + ("-4503599627370495.0", -4503599627370495), + ("-4.503599627370495e15", -4503599627370495), + ]; + for &(json, expected) in cases { + let val: SerdeInt64 = serde_json::from_str(json).unwrap(); + assert_eq!(val.0, expected, "input: {json}"); + } +} + +#[test] +fn int64_rejects_unsafe_unquoted_float_notation() { + // From 2^52 an ulp is a whole integer, so serde_json's default + // (not-correctly-rounded) float parse can silently produce the adjacent + // integer — "9007199254740991.0" is observed to parse as …990.0. + let cases: &[&str] = &[ + "4503599627370496.0", + "9007199254740991.0", + "9007199254740992.0", + "9007199254740993.0", + "9.007199254740993e15", + "-4503599627370496.0", + "-9007199254740991.0", + "-9007199254740992.0", + "-9007199254740993.0", + "-9.007199254740993e15", + ]; + for &json in cases { + assert!( + serde_json::from_str::(json).is_err(), + "input should reject: {json}" + ); + } +} + // ── uint64 ────────────────────────────────────────────────────────────── #[test] @@ -286,6 +325,29 @@ fn uint64_deserializes_exact_float_notation_string() { assert_eq!(val.0, u64::MAX); } +#[test] +fn uint64_deserializes_safe_unquoted_float_notation() { + let val: SerdeUint64 = serde_json::from_str("4503599627370495.0").unwrap(); + assert_eq!(val.0, 4503599627370495); +} + +#[test] +fn uint64_rejects_unsafe_unquoted_float_notation() { + let cases: &[&str] = &[ + "4503599627370496.0", + "9007199254740991.0", + "9007199254740992.0", + "9007199254740993.0", + "9.007199254740993e15", + ]; + for &json in cases { + assert!( + serde_json::from_str::(json).is_err(), + "input should reject: {json}" + ); + } +} + // ── float ─────────────────────────────────────────────────────────────── #[test]