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-20260702-235813.yaml
Original file line number Diff line number Diff line change
@@ -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
22 changes: 21 additions & 1 deletion buffa/src/json_helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<I: TryFrom<i128>>(v: &str) -> Option<I> {
Expand Down Expand Up @@ -981,8 +996,13 @@ fn parse_exact_decimal_int(v: &str) -> Option<i128> {
}
}

/// 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<I: TryFrom<i128>>(v: f64) -> Option<I> {
if !(-MAX_SAFE_JSON_INTEGER_F64..=MAX_SAFE_JSON_INTEGER_F64).contains(&v) {
return None;
}
if !is_exact_integer(v) {
return None;
}
Expand Down
62 changes: 62 additions & 0 deletions buffa/src/json_helpers/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<SerdeInt64>(json).is_err(),
"input should reject: {json}"
);
}
}

// ── uint64 ──────────────────────────────────────────────────────────────

#[test]
Expand All @@ -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::<SerdeUint64>(json).is_err(),
"input should reject: {json}"
);
}
}

// ── float ───────────────────────────────────────────────────────────────

#[test]
Expand Down
Loading