Skip to content

Reduce safe integer range to exclude +/- 2^53 and match JavaScript safe-integer bounds #1240

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
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
51 changes: 34 additions & 17 deletions core/vm.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -873,6 +873,40 @@ class Interpreter {
}
}

/** Safely converts a double to an int64_t, with range and validity checks.
*
* This function is used primarily for bitwise operations which require integer operands.
* It performs two safety checks:
* 1. Verifies the value is finite (not NaN or Infinity)
* 2. Ensures the value is within the safe integer range [-2^53, 2^53]
*
* The safe integer range limitation is necessary because IEEE 754 double precision
* floating point numbers can only precisely represent integers in the range [-2^53, 2^53].
* Beyond this range, precision is lost, which would lead to unpredictable results
* in bitwise operations that depend on exact bit patterns.
*
* \param value The double value to convert
* \param loc The location in source code (for error reporting)
* \throws RuntimeError if value is not finite or outside the safe integer range
* \returns The value converted to int64_t
*/
int64_t safeDoubleToInt64(double value, const internal::LocationRange& loc) {
if (std::isnan(value) || std::isinf(value)) {
throw internal::StaticError(loc, "numeric value is not finite");
}

// Constants for safe double-to-int conversion
// Jsonnet uses IEEE 754 doubles, which precisely represent integers in the range [-2^53 + 1, 2^53 - 1].
constexpr int64_t DOUBLE_MAX_SAFE_INTEGER = (1LL << 53) - 1;
constexpr int64_t DOUBLE_MIN_SAFE_INTEGER = -((1LL << 53) - 1);

// Check if the value is within the safe integer range
if (value < DOUBLE_MIN_SAFE_INTEGER || value > DOUBLE_MAX_SAFE_INTEGER) {
throw makeError(loc, "numeric value outside safe integer range for bitwise operation.");
}
return static_cast<int64_t>(value);
}

public:
/** Create a new interpreter.
*
Expand Down Expand Up @@ -3415,21 +3449,4 @@ std::vector<std::string> jsonnet_vm_execute_stream(Allocator *alloc, const AST *
return vm.manifestStream(string_output);
}

inline int64_t safeDoubleToInt64(double value, const internal::LocationRange& loc) {
if (std::isnan(value) || std::isinf(value)) {
throw internal::StaticError(loc, "numeric value is not finite");
}

// Constants for safe double-to-int conversion
// Jsonnet uses IEEE 754 doubles, which precisely represent integers in the range [-2^53, 2^53].
constexpr int64_t DOUBLE_MAX_SAFE_INTEGER = 1LL << 53;
constexpr int64_t DOUBLE_MIN_SAFE_INTEGER = -(1LL << 53);

// Check if the value is within the safe integer range
if (value < DOUBLE_MIN_SAFE_INTEGER || value > DOUBLE_MAX_SAFE_INTEGER) {
throw internal::StaticError(loc, "numeric value outside safe integer range for bitwise operation.");
}
return static_cast<int64_t>(value);
}

} // namespace jsonnet::internal
19 changes: 0 additions & 19 deletions core/vm.h
Original file line number Diff line number Diff line change
Expand Up @@ -130,25 +130,6 @@ std::vector<std::string> jsonnet_vm_execute_stream(
double gc_min_objects, double gc_growth_trigger, const VmNativeCallbackMap &natives,
JsonnetImportCallback *import_callback, void *import_callback_ctx, bool string_output);

/** Safely converts a double to an int64_t, with range and validity checks.
*
* This function is used primarily for bitwise operations which require integer operands.
* It performs two safety checks:
* 1. Verifies the value is finite (not NaN or Infinity)
* 2. Ensures the value is within the safe integer range [-2^53, 2^53]
*
* The safe integer range limitation is necessary because IEEE 754 double precision
* floating point numbers can only precisely represent integers in the range [-2^53, 2^53].
* Beyond this range, precision is lost, which would lead to unpredictable results
* in bitwise operations that depend on exact bit patterns.
*
* \param value The double value to convert
* \param loc The location in source code (for error reporting)
* \throws StaticError if value is not finite or outside the safe integer range
* \returns The value converted to int64_t
*/
int64_t safeDoubleToInt64(double value, const LocationRange& loc);

} // namespace jsonnet::internal

#endif
2 changes: 1 addition & 1 deletion doc/ref/language.html.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ Strings can be constructed as literals, slices of existing strings, concatenatio

Jsonnet numbers are 64-bit floating point numbers as defined in IEEE754 excluding `nan` and `inf` values. Operations resulting in infinity or not a number are errors.

Integers can be precisely represented as a Jsonnet number in the range [-2^53,2^53]. This is [a direct consequence of IEEE754 spec](https://en.wikipedia.org/wiki/Double-precision_floating-point_format).
Integers can be safely represented as a Jsonnet number in the range [-2^53 + 1,2^53 - 1]. This is [a direct consequence of IEEE754 spec](https://en.wikipedia.org/wiki/Double-precision_floating-point_format), with the requirements that a safe integer is representable exactly, and cannot be produced by rounding any other integer to fit the IEEE-754 representation. See also, the JavaScript [`Number.MAX_SAFE_INTEGER`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/MAX_SAFE_INTEGER), [`Number.MIN_SAFE_INTEGER`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/MIN_SAFE_INTEGER), and [`Number.isSafeInteger`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/isSafeInteger) definitions.

### Function

Expand Down
4 changes: 2 additions & 2 deletions test_suite/error.integer_conversion.jsonnet
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
// Value just beyond MAX_SAFE_INTEGER (2^53)
local beyond_max = 9007199254740994; // 2^53 + 1
// Value just beyond MAX_SAFE_INTEGER (2^53 - 1)
local beyond_max = 9007199254740992; // 2^53
beyond_max << 1 // Should throw error "numeric value outside safe integer range for bitwise operation"
3 changes: 2 additions & 1 deletion test_suite/error.integer_conversion.jsonnet.golden
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
STATIC ERROR: error.integer_conversion.jsonnet:3:1-16: numeric value outside safe integer range for bitwise operation.
RUNTIME ERROR: numeric value outside safe integer range for bitwise operation.
error.integer_conversion.jsonnet:3:1-16
3 changes: 2 additions & 1 deletion test_suite/error.integer_left_shift.jsonnet.golden
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
STATIC ERROR: error.integer_left_shift.jsonnet:3:1-17: numeric value outside safe integer range for bitwise operation.
RUNTIME ERROR: numeric value outside safe integer range for bitwise operation.
error.integer_left_shift.jsonnet:3:1-17
30 changes: 15 additions & 15 deletions test_suite/safe_integer_conversion.jsonnet
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
// Test values at boundary of safe integer range
local max_safe = 9007199254740992; // 2^53
local min_safe = -9007199254740992; // -2^53
local max_safe = 9007199254740991; // 2^53 - 1
local min_safe = -9007199254740991; // -(2^53 - 1)

std.assertEqual(max_safe & 1, 0) && // Check 2^53
std.assertEqual(min_safe & 1, 0) && // Check -2^53
std.assertEqual((max_safe - 1) & 1, 1) && // Check 2^53 - 1
std.assertEqual((min_safe + 1) & 1, 1) && // Check -2^53 + 1
std.assertEqual(max_safe & 1, 1) && // Check 2^53
std.assertEqual(min_safe & 1, 1) && // Check -2^53
std.assertEqual((max_safe - 1) & 1, 0) && // Check 2^53 - 1
std.assertEqual((min_safe + 1) & 1, 0) && // Check -2^53

std.assertEqual(~(max_safe - 1), min_safe) && // ~(2^53 - 1) == -2^53
std.assertEqual(~(min_safe + 1), max_safe - 2) && // ~(-2^53 + 1) == 2^53 - 2
Expand All @@ -17,18 +17,18 @@ std.assertEqual(~(-1), 0) &&

// Test shift operations with large values at safe boundary
// (2^53 - 1) right shift by 4 bits
std.assertEqual((max_safe - 1) >> 4, 562949953421311) &&
// MAX_SAFE_INTEGER (2^53) right shift by 1 bit
std.assertEqual(max_safe >> 1, 4503599627370496) && // 2^52
// MIN_SAFE_INTEGER (-2^53) right shift by 1 bit
std.assertEqual(min_safe >> 1, -4503599627370496) && // -2^52
std.assertEqual(max_safe >> 4, 562949953421311) &&
// MAX_SAFE_INTEGER (2^53 - 1) right shift by 1 bit is (2^52 - 1)
std.assertEqual(max_safe >> 1, 4503599627370495) && // 2^52
// MIN_SAFE_INTEGER -(2^53 - 1) right shift by 1 bit is (-2^52)
std.assertEqual(min_safe >> 1, -4503599627370496) && // -2^52

// Cannot left shift 2^53 without potential overflow/loss of precision issues
// depending on the shift amount, but can shift smaller numbers up to it.
// (2^52) left shift by 1 bit (result is 2^53)
std.assertEqual((max_safe >> 1) << 1, max_safe) &&
// (-2^52) left shift by 1 bit (result is -2^53)
std.assertEqual((min_safe >> 1) << 1, min_safe) &&
// (2^52-1) left shift by 1 bit (result is 2^53-2)
std.assertEqual(4503599627370495 << 1, max_safe - 1) &&
// (-(2^52-1)) left shift by 1 bit (result is -(2^53-2))
std.assertEqual((-4503599627370495) << 1, min_safe + 1) &&

// Test larger values within safe range
std.assertEqual(~123456789, -123456790) &&
Expand Down
28 changes: 14 additions & 14 deletions test_suite/safe_integer_conversion.jsonnet.fmt.golden
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
// Test values at boundary of safe integer range
local max_safe = 9007199254740992; // 2^53
local min_safe = -9007199254740992; // -2^53
local max_safe = 9007199254740991; // 2^53 - 1
local min_safe = -9007199254740991; // -(2^53 - 1)

std.assertEqual(max_safe & 1, 0) && // Check 2^53
std.assertEqual(min_safe & 1, 0) && // Check -2^53
std.assertEqual((max_safe - 1) & 1, 1) && // Check 2^53 - 1
std.assertEqual((min_safe + 1) & 1, 1) && // Check -2^53 + 1
std.assertEqual(max_safe & 1, 1) && // Check 2^53
std.assertEqual(min_safe & 1, 1) && // Check -2^53
std.assertEqual((max_safe - 1) & 1, 0) && // Check 2^53 - 1
std.assertEqual((min_safe + 1) & 1, 0) && // Check -2^53

std.assertEqual(~(max_safe - 1), min_safe) && // ~(2^53 - 1) == -2^53
std.assertEqual(~(min_safe + 1), max_safe - 2) && // ~(-2^53 + 1) == 2^53 - 2
Expand All @@ -17,18 +17,18 @@ std.assertEqual(~(-1), 0) &&

// Test shift operations with large values at safe boundary
// (2^53 - 1) right shift by 4 bits
std.assertEqual((max_safe - 1) >> 4, 562949953421311) &&
// MAX_SAFE_INTEGER (2^53) right shift by 1 bit
std.assertEqual(max_safe >> 1, 4503599627370496) && // 2^52
// MIN_SAFE_INTEGER (-2^53) right shift by 1 bit
std.assertEqual(max_safe >> 4, 562949953421311) &&
// MAX_SAFE_INTEGER (2^53 - 1) right shift by 1 bit is (2^52 - 1)
std.assertEqual(max_safe >> 1, 4503599627370495) && // 2^52
// MIN_SAFE_INTEGER -(2^53 - 1) right shift by 1 bit is (-2^52)
std.assertEqual(min_safe >> 1, -4503599627370496) && // -2^52

// Cannot left shift 2^53 without potential overflow/loss of precision issues
// depending on the shift amount, but can shift smaller numbers up to it.
// (2^52) left shift by 1 bit (result is 2^53)
std.assertEqual((max_safe >> 1) << 1, max_safe) &&
// (-2^52) left shift by 1 bit (result is -2^53)
std.assertEqual((min_safe >> 1) << 1, min_safe) &&
// (2^52-1) left shift by 1 bit (result is 2^53-2)
std.assertEqual(4503599627370495 << 1, max_safe - 1) &&
// (-(2^52-1)) left shift by 1 bit (result is -(2^53-2))
std.assertEqual((-4503599627370495) << 1, min_safe + 1) &&

// Test larger values within safe range
std.assertEqual(~123456789, -123456790) &&
Expand Down