Skip to content

Commit f99610b

Browse files
committed
fix: multipleOf validation for integer values between 2^53 and i64::MAX with arbitrary-precision feature
Signed-off-by: Dmitry Dygalo <[email protected]>
1 parent c07b399 commit f99610b

File tree

4 files changed

+46
-11
lines changed

4 files changed

+46
-11
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
## [Unreleased]
44

5+
### Fixed
6+
7+
- `multipleOf` validation for integer values between `2^53` and `i64::MAX` with `arbitrary-precision` feature.
8+
59
## [0.38.0] - 2025-12-24
610

711
### Added

crates/jsonschema-py/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
## [Unreleased]
44

5+
### Fixed
6+
7+
- `multipleOf` validation for integer values between `2^53` and `i64::MAX`.
8+
59
## [0.38.0] - 2025-12-24
610

711
### Added

crates/jsonschema/src/ext/numeric.rs

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -86,19 +86,34 @@ pub(crate) fn is_multiple_of_float(value: &Number, multiple: f64) -> bool {
8686
}
8787
}
8888

89+
/// The maximum integer that can be exactly represented in f64.
90+
/// Beyond this value, f64 loses precision and arithmetic operations become unreliable.
91+
#[cfg(feature = "arbitrary-precision")]
92+
const MAX_SAFE_INTEGER: u64 = 1u64 << 53;
93+
8994
pub(crate) fn is_multiple_of_integer(value: &Number, multiple: f64) -> bool {
90-
// For large u64 values beyond i64::MAX, as_f64() loses precision (since they exceed 2^53).
91-
// Use u64 arithmetic directly for these cases.
95+
// For large integer values beyond 2^53, as_f64() loses precision.
96+
// Use integer arithmetic directly for these cases.
9297
#[cfg(feature = "arbitrary-precision")]
93-
if let Some(v) = value.as_u64() {
94-
// If as_i64() returns None, the value is > i64::MAX, which is > 2^53
95-
// and therefore cannot be exactly represented in f64
96-
if value.as_i64().is_none() {
97-
// Use u64 modulo when the divisor fits in u64
98-
if multiple > 0.0 && multiple <= u64::MAX as f64 && multiple.fract() == 0.0 {
98+
{
99+
if let Some(v) = value.as_u64() {
100+
if v > MAX_SAFE_INTEGER
101+
&& multiple > 0.0
102+
&& multiple <= u64::MAX as f64
103+
&& multiple.fract() == 0.0
104+
{
99105
return (v % (multiple as u64)) == 0;
100106
}
101107
}
108+
if let Some(v) = value.as_i64() {
109+
if v.unsigned_abs() > MAX_SAFE_INTEGER
110+
&& multiple > 0.0
111+
&& multiple <= i64::MAX as f64
112+
&& multiple.fract() == 0.0
113+
{
114+
return (v % (multiple as i64)) == 0;
115+
}
116+
}
102117
}
103118

104119
if let Some(value_f64) = value.as_f64() {

crates/jsonschema/src/keywords/multiple_of.rs

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -431,9 +431,21 @@ mod tests {
431431
#[test_case(r#"{"multipleOf": 2}"#, "1e1000", true; "huge scientific integer is even")]
432432
#[test_case(r#"{"multipleOf": 3}"#, "1e1000", false; "10^1000 not multiple of 3")]
433433
#[test_case(r#"{"multipleOf": 0.5}"#, "1e1000", true; "huge scientific integer multiple of 0.5")]
434-
// Regression test: u64 values beyond i64::MAX lose precision when converted to f64.
435-
// 9223372036854775870 ends in '0' and IS a multiple of 10, but when converted to f64
436-
// it becomes 9223372036854775808 which ends in '8' and is NOT a multiple of 10.
434+
// Values between 2^53 and i64::MAX (these fit in i64 but lose f64 precision):
435+
#[test_case(r#"{"multipleOf": 10}"#, "9007199254740990", true; "at 2^53 boundary multiple of 10")]
436+
#[test_case(r#"{"multipleOf": 10}"#, "9007199254740991", false; "at 2^53 boundary not multiple of 10")]
437+
#[test_case(r#"{"multipleOf": 10}"#, "9223372036854775800", true; "near i64 max multiple of 10")]
438+
#[test_case(r#"{"multipleOf": 10}"#, "9223372036854775801", false; "near i64 max not multiple of 10")]
439+
#[test_case(r#"{"multipleOf": 100}"#, "9007199254741000", true; "above 2^53 multiple of 100")]
440+
#[test_case(r#"{"multipleOf": 100}"#, "9007199254741050", false; "above 2^53 not multiple of 100")]
441+
#[test_case(r#"{"multipleOf": 7}"#, "9007199254740995", true; "above 2^53 multiple of 7")]
442+
#[test_case(r#"{"multipleOf": 7}"#, "9007199254740994", false; "above 2^53 not multiple of 7")]
443+
// Negative values with absolute value > 2^53 (these fit in i64 but lose f64 precision):
444+
#[test_case(r#"{"multipleOf": 10}"#, "-9007199254740990", true; "negative beyond 2^53 multiple of 10")]
445+
#[test_case(r#"{"multipleOf": 10}"#, "-9007199254740991", false; "negative beyond 2^53 not multiple of 10")]
446+
#[test_case(r#"{"multipleOf": 100}"#, "-9223372036854775800", true; "negative near i64 min multiple of 100")]
447+
#[test_case(r#"{"multipleOf": 100}"#, "-9223372036854775801", false; "negative near i64 min not multiple of 100")]
448+
// Values beyond i64::MAX (these only fit in u64):
437449
#[test_case(r#"{"multipleOf": 10}"#, "9223372036854775870", true; "u64 beyond i64 max multiple of 10")]
438450
#[test_case(r#"{"multipleOf": 10}"#, "9223372036854775871", false; "u64 beyond i64 max not multiple of 10")]
439451
#[test_case(r#"{"type": "integer", "minimum": 9223372036854775800, "maximum": 9223372036854775900, "multipleOf": 10}"#, "9223372036854775870", true; "combined schema with u64 beyond i64 max")]

0 commit comments

Comments
 (0)