diff --git a/CHANGELOG.md b/CHANGELOG.md index 4024354..d996141 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,26 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.2.10] - 2026-11-04 + +### Added +- Warning messages via callback when precision is lost due to truncated whole number or fraction digits (see https://github.com/FFMG/tinyjson/issues/1). + +### Changed +- Incremented version to 0.2.10. +- **Breaking Change**: `get_number()` now clamps whole numbers that exceed `long long` range to `LLONG_MAX` or `LLONG_MIN` instead of wrapping around (e.g. `18446744073709551615` used to return `-1`). Use `get_float()` or `dump()` to retrieve the full value (see https://github.com/FFMG/tinyjson/issues/55). + +### Fixed +- Fixed a stack buffer overflow when dumping numbers with more than 254 fraction digits by dynamically sizing the buffer (see https://github.com/FFMG/tinyjson/issues/53). +- Fixed silent integer overflows and parsing errors for large numbers and exponents (see https://github.com/FFMG/tinyjson/issues/1, https://github.com/FFMG/tinyjson/issues/54): + - Whole numbers exceeding `ULLONG_MAX` are converted to exponent notation instead of wrapping around. + - Fractions are capped at 19 significant digits without wrapping around. + - Exponents exceeding 32 bits or outside `[-100000, 100000]` are rejected with an out-of-range parse error. + - Fixed exponent normalisation and shifting for fractions with leading zeros or large offsets (e.g. `0.12e20`, `0.12e30`, `0.1234e-1`). + - Fixed `fast_power_of_10()` overflow for exponents >= 64. +- Fixed `get_float()` for whole numbers between 2^63 and 2^64-1 returning a negative value, and for fractions with more than 19 digits (see https://github.com/FFMG/tinyjson/issues/54, https://github.com/FFMG/tinyjson/issues/55). +- Fixed undefined behaviour in `get_number()` when negating `LLONG_MIN` (`-9223372036854775808`) and when casting out-of-range floats or exponents to `long long` (see https://github.com/FFMG/tinyjson/issues/55). + ## [0.2.9] - 2026-10-29 ### Added diff --git a/README.md b/README.md index 235397c..b12de27 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![CI](https://github.com/FFMG/TinyJSON/actions/workflows/c-cpp.yml/badge.svg)](https://github.com/FFMG/TinyJSON/actions/workflows/c-cpp.yml) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) -[![Version](https://img.shields.io/badge/version-0.2.9-blue.svg)](src/TinyJSON.h) +[![Version](https://img.shields.io/badge/version-0.2.10-blue.svg)](src/TinyJSON.h) A lightweight and lightning-fast C++ JSON & JSON5 parser designed for high performance and minimal footprint. @@ -131,8 +131,8 @@ The version is set in the `TinyJSON.h` file. ```cpp static const short TJ_VERSION_MAJOR = 0; static const short TJ_VERSION_MINOR = 2; -static const short TJ_VERSION_PATCH = 9; -static const char TJ_VERSION_STRING[] = "0.2.9"; +static const short TJ_VERSION_PATCH = 10; +static const char TJ_VERSION_STRING[] = "0.2.10"; ``` ### Simple Value Access @@ -785,7 +785,7 @@ include(FetchContent) FetchContent_Declare( TinyJSON GIT_REPOSITORY https://github.com/FFMG/TinyJSON.git - GIT_TAG v0.2.9 # or main + GIT_TAG v0.2.10 # or main ) FetchContent_MakeAvailable(TinyJSON) target_link_libraries(your_target PUBLIC TinyJSON) diff --git a/src/TinyJSON.cpp b/src/TinyJSON.cpp index 118f3e5..36f9d27 100644 --- a/src/TinyJSON.cpp +++ b/src/TinyJSON.cpp @@ -42,6 +42,8 @@ #include static constexpr short TJ_MAX_NUMBER_OF_DIGGITS = 19; +// the largest exponent we accept when parsing, 1e100000 or 1e-100000, anything larger is rejected. +static constexpr long long TJ_MAX_EXPONENT = 100000; static constexpr short TJ_DEFAULT_STRING_READ_SIZE = 10; static constexpr unsigned int TJ_DEFAULT_STRING_MAX_READ_SIZE = 4294967295; @@ -1546,12 +1548,23 @@ namespace TinyJSON return (number << 3) + (number << 1); } + /// + /// Raise 10 to the power of exponent. + /// 10^19 is the largest power of 10 that fits in an unsigned long long, + /// anything larger returns the max unsigned long long rather than silently wrapping around, (or returning 0). + /// + /// + /// static unsigned long long fast_power_of_10(unsigned int exponent) { if (exponent == 0) { return 1; } + if (exponent > static_cast(TJ_MAX_NUMBER_OF_DIGGITS)) + { + return std::numeric_limits::max(); + } unsigned long long base = 10; for (unsigned int i = 1; i < exponent; ++i) @@ -1614,86 +1627,42 @@ namespace TinyJSON /// static TJCHAR* fast_number_to_string(unsigned long long number, unsigned int fraction_exponent, bool is_negative, unsigned int& length, bool force_positive_sign = false) { - TJCHAR reverse_buffer[255]; - unsigned reverse_position = 0; - length = 0; - - if (number == 0) - { - reverse_buffer[reverse_position++] = '0'; - } - else - { - while (number > 0) - { - auto mod = number % 10; - switch (mod) - { - case 0: - reverse_buffer[reverse_position++] = '0'; - break; + // Work out the exact length needed first; fraction_exponent has no upper limit, so we cannot use a fixed-size buffer: - case 1: - reverse_buffer[reverse_position++] = '1'; - break; + // - the number of digits, (zero is a single digit). + // - the number of leading zeros, (if the fraction_exponent is longer than the number). + // - the sign, if we need one. + const unsigned int number_of_digits = number == 0 ? 1 : get_number_of_digits(number); + const unsigned int number_of_leading_zeros = fraction_exponent > number_of_digits ? fraction_exponent - number_of_digits : 0; + const unsigned int number_of_signs = (is_negative || force_positive_sign) ? 1 : 0; + length = number_of_signs + number_of_leading_zeros + number_of_digits; - case 2: - reverse_buffer[reverse_position++] = '2'; - break; - case 3: - reverse_buffer[reverse_position++] = '3'; - break; - case 4: - reverse_buffer[reverse_position++] = '4'; - break; - case 5: - reverse_buffer[reverse_position++] = '5'; - break; - case 6: - reverse_buffer[reverse_position++] = '6'; - break; - case 7: - reverse_buffer[reverse_position++] = '7'; - break; - case 8: - reverse_buffer[reverse_position++] = '8'; - break; - case 9: - reverse_buffer[reverse_position++] = '9'; - break; + TJCHAR* buffer = new TJCHAR[length + 1]; + buffer[length] = TJ_NULL_TERMINATOR; - default: - break; - } - number /= 10; - } + // write the digits from the end of the buffer. + unsigned int position = length; + for (unsigned int i = 0; i < number_of_digits; ++i) + { + buffer[--position] = static_cast('0' + static_cast(number % 10)); + number /= 10; } - if (fraction_exponent > reverse_position) + // then the leading zeros, if any. + while (position > number_of_signs) { - const unsigned int zeros = fraction_exponent - reverse_position; - for (unsigned int j = 0; j < zeros; ++j) - { - reverse_buffer[reverse_position++] = '0'; - } + buffer[--position] = '0'; } + + // and finally the sign if (is_negative) { - reverse_buffer[reverse_position++] = '-'; + buffer[0] = '-'; } else if (force_positive_sign) { - reverse_buffer[reverse_position++] = '+'; - } - - TJCHAR* buffer = new TJCHAR[reverse_position + 1]; - buffer[reverse_position] = TJ_NULL_TERMINATOR; - for (unsigned int i = 0; i < reverse_position; ++i) - { - buffer[reverse_position - 1 - i] = reverse_buffer[i]; + buffer[0] = '+'; } - - length = reverse_position; return buffer; } @@ -2102,6 +2071,12 @@ namespace TinyJSON { return 0ull; } + if (number >= 10000000000000000000ull) + { + // 10^19 is the largest power of 10 that fits in an unsigned long long + // so we cannot multiply by 10 again without wrapping around. + return 20; + } unsigned int digit = 0; for (unsigned long long i = 0; i <= number; ++digit) { @@ -2900,38 +2875,101 @@ namespace TinyJSON return shifted_unsigned_fraction; } - static TJValue* try_create_number_from_parts_positive_exponent_no_whole_number(const bool& is_negative, const unsigned long long& unsigned_fraction, const unsigned int fraction_exponent, const unsigned int exponent, const parse_options& options = {}) + /// + /// Create an exponent number with a single digit whole number, for example 123.45e+6 becomes 1.2345e+8 + /// and 0.0012e+6 becomes 1.2e+3 + /// If the fraction would need more than TJ_MAX_NUMBER_OF_DIGGITS digits then it is truncated + /// rather than overflowing + /// + /// + /// + /// + /// + /// The signed exponent + /// + /// + static TJValue* create_number_from_parts_as_exponent(const bool& is_negative, const unsigned long long& unsigned_whole_number, const unsigned long long& unsigned_fraction, const unsigned int fraction_exponent, const int exponent, const parse_options& options = {}) { - if (exponent >= fraction_exponent) + const auto& max_number_of_digits = static_cast(TJ_MAX_NUMBER_OF_DIGGITS); + if (unsigned_whole_number == 0) { - unsigned long long shifted_unsigned_fraction = 0; - unsigned int shitfed_unsigned_fraction_exponent = 0; + if (unsigned_fraction == 0) + { + // zero is a positive number + return new TJValueNumberInt(0ull, false, options); + } + + // the first significant digit of the fraction becomes the whole number. + // 0.0012e+6 has 2 leading zeros so the 1 is moved 3 places to the left, 1.2e+3 const auto& fraction_length = get_number_of_digits(unsigned_fraction); const auto& leading_zeros = fraction_exponent - fraction_length; - // we just want the first number so we are passing a 1x exponent only - // but we need to add the number of leading zeros to make sure that we shift properly. - const auto& shifted_unsigned_whole_number = shift_fraction_left(unsigned_fraction, fraction_exponent, leading_zeros + 1, shifted_unsigned_fraction, shitfed_unsigned_fraction_exponent); + unsigned long long shifted_unsigned_fraction = 0; + const auto& shifted_unsigned_whole_number = shift_number_right(unsigned_fraction, fraction_length - 1, shifted_unsigned_fraction); + return new TJValueNumberExponent( + shifted_unsigned_whole_number, + shifted_unsigned_fraction, + fraction_length - 1, + exponent - static_cast(leading_zeros + 1), + is_negative, options); + } - const auto& shifted_fraction_exponent = exponent - fraction_exponent; - if (shifted_fraction_exponent <= TJ_MAX_NUMBER_OF_DIGGITS) - { - if (shifted_unsigned_fraction == 0) - { - return new TJValueNumberInt(shift_number_left(shifted_unsigned_whole_number, shifted_fraction_exponent), is_negative, options); - } + // the whole number is moved to a single digit, the rest of the digits are moved to the fraction. + // 123.45e+6 the 23 is moved to the front of the fraction, 1.2345e+8 + const auto& number_of_digit_whole = get_number_of_digits(unsigned_whole_number); + const unsigned int whole_number_shift = number_of_digit_whole - 1; + unsigned long long whole_number_remainder = 0; + const auto& shifted_unsigned_whole_number = shift_number_right(unsigned_whole_number, whole_number_shift, whole_number_remainder); + + unsigned long long shifted_unsigned_fraction = 0; + unsigned int shifted_fraction_exponent = 0; + if (whole_number_remainder == 0) + { + // only zeros were moved, (or nothing at all), so the fraction just has more leading zeros. + // 100.5e+6 becomes 1.005e+8 + shifted_unsigned_fraction = unsigned_fraction; + shifted_fraction_exponent = unsigned_fraction == 0 ? 0 : whole_number_shift + fraction_exponent; + } + else if (whole_number_shift + fraction_exponent <= max_number_of_digits) + { + // everything fits, we just put the remainder in front of the fraction. + shifted_unsigned_fraction = shift_number_left(whole_number_remainder, fraction_exponent) + unsigned_fraction; + shifted_fraction_exponent = whole_number_shift + fraction_exponent; + } + else + { + // we cannot keep all the digits, so we keep as many of the fraction as we can. + const unsigned int fraction_digits_to_keep = max_number_of_digits - whole_number_shift; + unsigned long long ignored = 0; + shifted_unsigned_fraction = shift_number_left(whole_number_remainder, fraction_digits_to_keep) + + shift_number_right(unsigned_fraction, fraction_exponent - fraction_digits_to_keep, ignored); + shifted_fraction_exponent = max_number_of_digits; + } - return new TJValueNumberFloat( - shift_number_left(shifted_unsigned_whole_number, shifted_fraction_exponent), - shifted_unsigned_fraction, - shifted_fraction_exponent, - is_negative, options); + return new TJValueNumberExponent( + shifted_unsigned_whole_number, + shifted_unsigned_fraction, + shifted_fraction_exponent, + exponent + static_cast(whole_number_shift), + is_negative, options); + } + + static TJValue* try_create_number_from_parts_positive_exponent_no_whole_number(const bool& is_negative, const unsigned long long& unsigned_fraction, const unsigned int fraction_exponent, const unsigned int exponent, const parse_options& options = {}) + { + if (exponent >= fraction_exponent) + { + // the whole fraction moves to the whole number, 0.0012e+6 becomes 1200 + // so the number is the fraction shifted by what is left of the exponent. + const auto& shift = exponent - fraction_exponent; + if (shift <= static_cast(TJ_MAX_NUMBER_OF_DIGGITS) && + unsigned_fraction <= std::numeric_limits::max() / fast_power_of_10(shift)) + { + return new TJValueNumberInt(shift_number_left(unsigned_fraction, shift), is_negative, options); } - // TODO: Cases where exponent is > than TJ_MAX_NUMBER_OF_DIGGITS - return nullptr; + // the number is too big, so we keep it as an exponent, 0.12e+30 becomes 1.2e+29 + return create_number_from_parts_as_exponent(is_negative, 0ull, unsigned_fraction, fraction_exponent, static_cast(exponent), options); } - // the number is something like 0.00001e+3 the fraction_exponent is 4 and the exponent is 3 // so we can just move the fraction to the left the whole number will remain zero const auto& shifted_fraction_exponent = fraction_exponent - exponent; @@ -2942,8 +2980,8 @@ namespace TinyJSON return new TJValueNumberFloat(0ull, unsigned_fraction, shifted_fraction_exponent, is_negative, options); } - // TODO: Cases where exponent is > than TJ_MAX_NUMBER_OF_DIGGITS - return nullptr; + // the fraction is too long to be kept as a float, so we keep it as an exponent. + return create_number_from_parts_as_exponent(is_negative, 0ull, unsigned_fraction, fraction_exponent, static_cast(exponent), options); } static TJValue* try_create_number_from_parts_positive_exponent(const bool& is_negative, const unsigned long long& unsigned_whole_number, const unsigned long long& unsigned_fraction, const unsigned int fraction_exponent, const unsigned int exponent, const parse_options& options = {}) @@ -3000,23 +3038,8 @@ namespace TinyJSON // case 2b: // The whole number is more than zero _and_ the fraction is also non zero. // The total is, (currently), greater than TJ_MAX_NUMBER_OF_DIGGITS - const unsigned int shifted_unsigned_whole_number_exponent = number_of_digit_whole - 1; - unsigned long long shifted_unsigned_fraction = 0; - const auto& shifted_unsigned_whole_number = shift_number_right(unsigned_whole_number, shifted_unsigned_whole_number_exponent, shifted_unsigned_fraction); - - // we then need to add shifted_unsigned_fraction in front of unsigned_fraction - auto shifted_fraction_exponent = shifted_unsigned_whole_number_exponent + (fraction_exponent - shifted_unsigned_whole_number_exponent); - shifted_unsigned_fraction = (shifted_unsigned_fraction * fast_power_of_10(shifted_fraction_exponent)) + unsigned_fraction; - - // and the exponent also shitt byt the number we moved. - const unsigned int shifted_exponent = exponent + shifted_unsigned_whole_number_exponent; - - return new TJValueNumberExponent( - shifted_unsigned_whole_number, - shifted_unsigned_fraction, - (shifted_unsigned_whole_number_exponent + fraction_exponent), - shifted_exponent, - is_negative, options); + // so we move the whole number to be a single digit and the exponent is moved accordingly. + return create_number_from_parts_as_exponent(is_negative, unsigned_whole_number, unsigned_fraction, fraction_exponent, static_cast(exponent), options); } static TJValue* try_create_number_from_parts_negative_exponent(const bool& is_negative, const unsigned long long& unsigned_whole_number, const unsigned long long& unsigned_fraction, const unsigned int fraction_exponent, const unsigned int exponent, const parse_options& options = {}) @@ -3030,7 +3053,10 @@ namespace TinyJSON // case 1: // The total number is less than TJ_MAX_NUMBER_OF_DIGGITS // so we can get rid of the exponent altogether. - if (number_of_digit_whole + number_of_digit_fraction + exponent <= TJ_MAX_NUMBER_OF_DIGGITS) + // But if the whole number is not zero, the digits we move from the whole number are shifted + // by the fraction_exponent, (leading zeros included), so that must also fit. + if (number_of_digit_whole + number_of_digit_fraction + exponent <= TJ_MAX_NUMBER_OF_DIGGITS && + (unsigned_whole_number == 0 || fraction_exponent + exponent <= static_cast(TJ_MAX_NUMBER_OF_DIGGITS))) { // we will shift the whole number to the left right by the number of exponents. // so 2e-3 would become 0.002 @@ -3073,24 +3099,8 @@ namespace TinyJSON // case 2b: // The whole number is more than zero _and_ the fraction is also non zero. // The total is, (currently), greater than TJ_MAX_NUMBER_OF_DIGGITS - const unsigned int shifted_unsigned_whole_number_exponent = number_of_digit_whole - 1; - unsigned long long shifted_unsigned_fraction = 0; - const auto& shifted_unsigned_whole_number = shift_number_right(unsigned_whole_number, shifted_unsigned_whole_number_exponent, shifted_unsigned_fraction); - - // we then need to add shifted_unsigned_fraction in front of unsigned_fraction - auto shifted_fraction_exponent = shifted_unsigned_whole_number_exponent + (fraction_exponent - shifted_unsigned_whole_number_exponent); - shifted_unsigned_fraction = (shifted_unsigned_fraction * fast_power_of_10(shifted_fraction_exponent)) + unsigned_fraction; - - // and the exponent also shitt by the number we moved. - // as it is a negative exponent we need to move to the left. - const unsigned int shifted_exponent = exponent - shifted_unsigned_whole_number_exponent; - - return new TJValueNumberExponent( - shifted_unsigned_whole_number, - shifted_unsigned_fraction, - (shifted_unsigned_whole_number_exponent + fraction_exponent), - -1 * shifted_exponent, - is_negative, options); + // so we move the whole number to be a single digit and the exponent is moved accordingly. + return create_number_from_parts_as_exponent(is_negative, unsigned_whole_number, unsigned_fraction, fraction_exponent, -1 * static_cast(exponent), options); } static TJValue* try_create_number_from_parts_negative_exponent_no_whole_number(const bool& is_negative, const unsigned long long& unsigned_fraction, const unsigned int fraction_exponent, const unsigned int exponent, const parse_options& options = {}) @@ -3112,15 +3122,9 @@ namespace TinyJSON if (actual_shifted_fraction_exponent <= TJ_MAX_NUMBER_OF_DIGGITS) { - if (shifted_unsigned_fraction == 0) - { - return new TJValueNumberInt(shift_number_left(shifted_unsigned_whole_number, shifted_unsigned_fraction_exponent), is_negative, options); - } - return new TJValueNumberFloat( - shift_number_left(shifted_unsigned_whole_number, shifted_unsigned_fraction_exponent), - shifted_unsigned_fraction, - shifted_unsigned_fraction_exponent, - is_negative, options); + // there is no whole number and the exponent is negative so the number is less than one + // the fraction remains the same, it just has more leading zeros, 0.12e-1 becomes 0.012 + return new TJValueNumberFloat(0ull, unsigned_fraction, fraction_exponent + exponent, is_negative, options); } // TODO: Cases where exponent is > than TJ_MAX_NUMBER_OF_DIGGITS @@ -3232,6 +3236,59 @@ namespace TinyJSON return whole_number; } + /// + /// Convert a string of digits to an unsigned long long, but stop before the number overflows + /// or before we read more than maximum_number_of_significant_digits, (leading zeros are not significant). + /// + /// The string of digits, it should only contain digits. + /// The maximum number of significant digits we want to read. + /// The number we read. + /// The number of digits we read, if it is less than the length of the string then the rest of the digits were ignored. + static unsigned int fast_string_to_unsigned_long_long_without_overflow(const TJCHAR* p, const unsigned int maximum_number_of_significant_digits, unsigned long long& number) + { + number = 0; + unsigned int number_of_digits_read = 0; + unsigned int number_of_significant_digits = 0; + while (*p >= '0' && *p <= '9') + { + const auto& digit = static_cast(*p - '0'); + if (number != 0 || digit != 0) + { + if (number_of_significant_digits >= maximum_number_of_significant_digits) + { + break; + } + if (number > (std::numeric_limits::max() - digit) / 10) + { + break; + } + ++number_of_significant_digits; + } + number = fast_multiply_by_10(number) + digit; + ++number_of_digits_read; + ++p; + } + return number_of_digits_read; + } + + /// + /// Check if a string of digits contains at least one non zero digit. + /// + /// The string of digits, it should only contain digits. + /// True if at least one of the digits is not zero. + static bool has_non_zero_digit(const TJCHAR* p) + { + while (*p >= '0' && *p <= '9') + { + if (*p != '0') + { + return true; + } + ++p; + } + return false; + } + static unsigned long long fast_string_to_long_long(const TJCHAR* p) { // the numbers are unsigned and should only contain digits. @@ -3417,11 +3474,20 @@ namespace TinyJSON return nullptr; } - // convert that number to an unsigned long, long - const auto& unsigned_whole_number = fast_string_to_long_long(possible_number); + // convert that number to an unsigned long long + // if the number is too big, (more than 18446744073709551615), we keep the digits that fit + // and the digits we could not read are added to the exponent, (we lose some precision). + unsigned long long unsigned_whole_number = 0; + const auto& number_of_whole_digits = string_length(possible_number); + const auto& number_of_whole_digits_read = fast_string_to_unsigned_long_long_without_overflow(possible_number, number_of_whole_digits, unsigned_whole_number); + const auto& number_of_whole_digits_ignored = number_of_whole_digits - number_of_whole_digits_read; + if (has_non_zero_digit(possible_number + number_of_whole_digits_read)) + { + parse_result.raise(parse_options::message_type::warning, number_start, TJCHARPREFIX("Whole number exceeds supported precision, some digits were truncated.")); + } delete[] possible_number; - // read the faction if there is one. + // read the fraction if there is one. unsigned long long unsigned_fraction = 0; unsigned int fraction_exponent = 0; if (*p == '.') @@ -3444,15 +3510,26 @@ namespace TinyJSON } else { - // so 001 become exponent = 3 - fraction_exponent = string_length(possible_fraction_number); - unsigned_fraction = fast_string_to_long_long(possible_fraction_number); + if (number_of_whole_digits_ignored == 0) + { + // Keep at most TJ_MAX_NUMBER_OF_DIGGITS significant digits; leading zeros are preserved. + fraction_exponent = fast_string_to_unsigned_long_long_without_overflow(possible_fraction_number, static_cast(TJ_MAX_NUMBER_OF_DIGGITS), unsigned_fraction); + if (has_non_zero_digit(possible_fraction_number + fraction_exponent)) + { + parse_result.raise(parse_options::message_type::warning, number_start, TJCHARPREFIX("Fraction exceeds supported precision, some digits were truncated.")); + } + } + else if (has_non_zero_digit(possible_fraction_number)) + { + parse_result.raise(parse_options::message_type::warning, number_start, TJCHARPREFIX("Whole number exceeds supported precision, the fraction was ignored.")); + } delete[] possible_fraction_number; } } // try read the exponent if there is one. - int exponent = 0; + // the digits of the whole number we could not read are added to the exponent + long long exponent = static_cast(number_of_whole_digits_ignored); if (*p == 'e' || *p == 'E') { p++; @@ -3475,22 +3552,28 @@ namespace TinyJSON return nullptr; } - const auto& unsigned_exponent = static_cast(fast_string_to_long_long(possible_exponent)); + // read the exponent without overflowing, if we cannot read all of it then it is too big. + unsigned long long unsigned_exponent = 0; + const auto& number_of_exponent_digits = string_length(possible_exponent); + const auto& number_of_exponent_digits_read = fast_string_to_unsigned_long_long_without_overflow(possible_exponent, number_of_exponent_digits, unsigned_exponent); delete[] possible_exponent; - - // as per the spec it is allowed to have 1e00 - // https://github.com/FFMG/tinyjson/issues/14 - if (0 == unsigned_exponent) + if (number_of_exponent_digits_read != number_of_exponent_digits || unsigned_exponent > static_cast(TJ_MAX_EXPONENT)) { - exponent = 0; - is_negative_exponent = false; - } - else - { - exponent = is_negative_exponent ? unsigned_exponent * -1 : unsigned_exponent; + parse_result.assign_exception_message(number_start, "Number exponent is out of range."); + return nullptr; } + + // as per the spec it is allowed to have 1e00 + exponent += is_negative_exponent ? -1 * static_cast(unsigned_exponent) : static_cast(unsigned_exponent); + } + + // the exponent could have grown because the whole number was too big. + if (exponent > TJ_MAX_EXPONENT || exponent < -1 * TJ_MAX_EXPONENT) + { + parse_result.assign_exception_message(number_start, "Number exponent is out of range."); + return nullptr; } - return try_create_number_from_parts(is_negative, unsigned_whole_number, unsigned_fraction, fraction_exponent, exponent, parse_result.options()); + return try_create_number_from_parts(is_negative, unsigned_whole_number, unsigned_fraction, fraction_exponent, static_cast(exponent), parse_result.options()); } /// @@ -6884,7 +6967,7 @@ namespace TinyJSON auto value_int = dynamic_cast(this); if (nullptr != value_int) { - return static_cast(value_int->get_number()); + return value_int->get_float(); } auto value_exponent = dynamic_cast(this); @@ -6899,12 +6982,35 @@ namespace TinyJSON return 0.0L; } + /// + /// Convert a long double to a long long, clamping the values that do not fit + /// as casting them is undefined behaviour. + /// + /// + /// + static long long clamp_long_double_to_long_long(const long double value) + { + if (std::isnan(value)) + { + return 0; + } + if (value >= static_cast(std::numeric_limits::max())) + { + return std::numeric_limits::max(); + } + if (value <= static_cast(std::numeric_limits::min())) + { + return std::numeric_limits::min(); + } + return static_cast(value); + } + long long TJValueNumber::get_number() const { auto value_float = dynamic_cast(this); if (nullptr != value_float) { - return static_cast(value_float->get_number()); + return clamp_long_double_to_long_long(value_float->get_number()); } auto value_int = dynamic_cast(this); @@ -6917,7 +7023,7 @@ namespace TinyJSON if (nullptr != value_exponent) { // probably over/underflow - return static_cast(value_exponent->get_number()); + return clamp_long_double_to_long_long(value_exponent->get_number()); } ParseResult _parse_result(_parse_options); @@ -7000,7 +7106,30 @@ namespace TinyJSON long long TJValueNumberInt::get_number() const { - return _is_negative ? -1 * static_cast(_number) : static_cast(_number); + // the number is unsigned so it can be bigger than a long long, in that case we clamp it + // rather than silently wrapping around + const auto& max_long_long = static_cast(std::numeric_limits::max()); + if (_is_negative) + { + if (_number > max_long_long) + { + // this includes LLONG_MIN itself, (max+1), as we cannot do -1 * (max+1) + return std::numeric_limits::min(); + } + return -1 * static_cast(_number); + } + if (_number > max_long_long) + { + return std::numeric_limits::max(); + } + return static_cast(_number); + } + + long double TJValueNumberInt::get_float() const + { + // use the unsigned number directly so we do not lose the numbers that do not fit in a long long. + const auto& number = static_cast(_number); + return _is_negative ? -1 * number : number; } /////////////////////////////////////// @@ -7192,6 +7321,13 @@ namespace TinyJSON return static_cast(_number); } + if (_fraction_exponent > static_cast(TJ_MAX_NUMBER_OF_DIGGITS)) + { + // the fraction has a lot of leading zeros, 10^_fraction_exponent does not fit in an unsigned long long + const auto& fraction = static_cast(_fraction) / std::pow(10.0L, static_cast(_fraction_exponent)); + return (_is_negative ? -1 : 1) * (static_cast(_number) + fraction); + } + // Convert b to its fractional form auto pow = static_cast(TJHelper::fast_power_of_10(_fraction_exponent)); const auto& whole_number = _number * pow + _fraction; diff --git a/src/TinyJSON.h b/src/TinyJSON.h index 9a286f0..1cf965e 100644 --- a/src/TinyJSON.h +++ b/src/TinyJSON.h @@ -51,10 +51,11 @@ // v0.2.7 - improved parse_file error reporting with specific system errors and file name. // v0.2.8 - conformed empty input parsing to RFC specifications (returns nullptr / throws instead of empty string). // v0.2.9 - added line and column numbers to parse messages and TJParseException. +// v0.2.10 - fixed number overflows when parsing, dumping and getting numbers, (issues #53, #54 and #55). static const short TJ_VERSION_MAJOR = 0; static const short TJ_VERSION_MINOR = 2; -static const short TJ_VERSION_PATCH = 9; -static const char TJ_VERSION_STRING[] = "0.2.9"; +static const short TJ_VERSION_PATCH = 10; +static const char TJ_VERSION_STRING[] = "0.2.10"; #ifndef TJ_USE_CHAR # define TJ_USE_CHAR 1 @@ -1819,6 +1820,7 @@ class TJDictionary; virtual ~TJValueNumberInt() = default; long long get_number() const; + long double get_float() const; protected: /// diff --git a/tests/testtinyjsondump.cpp b/tests/testtinyjsondump.cpp index 7c7a76f..1dd13ad 100644 --- a/tests/testtinyjsondump.cpp +++ b/tests/testtinyjsondump.cpp @@ -5,6 +5,9 @@ #define TJ_USE_CHAR 1 #include "../src/TinyJSON.h" +#include +#include + TEST(TestDump, EmptyArray) { auto json = TinyJSON::TJ::parse("[]"); ASSERT_NE(nullptr, json); @@ -411,4 +414,97 @@ TEST(TestDump, DumpOfAStringWillExcapeTheNewLine) { ASSERT_STREQ(R"("This is a string.\nAnd this is a new line")", text); delete json; -} \ No newline at end of file +} + +// https://github.com/FFMG/tinyjson/issues/53 +// Build a number with a very long run of zeros, for example "0." + "000...000" + "1" +static std::string make_number_with_zeros(const std::string& prefix, const unsigned int number_of_zeros, const std::string& suffix) +{ + return prefix + std::string(number_of_zeros, '0') + suffix; +} + +// https://github.com/FFMG/tinyjson/issues/53 +// The fraction is written into a 255 char buffer, anything longer overflows the stack. +TEST(TestDump, Issue53_FloatWithMoreThan254FractionDigitsCanBeDumped) +{ + const auto value = make_number_with_zeros("0.", 300, "1"); + auto json = TinyJSON::TJ::parse(value.c_str()); + ASSERT_NE(nullptr, json); + + const auto& text = json->dump(TinyJSON::formatting::minify); + ASSERT_NE(nullptr, text); + + ASSERT_STREQ(value.c_str(), text); + delete json; +} + +// https://github.com/FFMG/tinyjson/issues/53 +TEST(TestDump, Issue53_NegativeFloatWithMoreThan254FractionDigitsCanBeDumped) +{ + const auto value = make_number_with_zeros("-0.", 300, "1"); + auto json = TinyJSON::TJ::parse(value.c_str()); + ASSERT_NE(nullptr, json); + + const auto& text = json->dump(TinyJSON::formatting::minify); + ASSERT_NE(nullptr, text); + + ASSERT_STREQ(value.c_str(), text); + delete json; +} + +// https://github.com/FFMG/tinyjson/issues/53 +// Walk across the 255 char boundary, (fraction lengths 250 to 300). +TEST(TestDump, Issue53_FloatFractionLengthAroundTheBufferBoundaryCanBeDumped) +{ + for (unsigned int number_of_zeros = 249; number_of_zeros < 300; ++number_of_zeros) + { + const auto value = make_number_with_zeros("0.", number_of_zeros, "1"); + auto json = TinyJSON::TJ::parse(value.c_str()); + ASSERT_NE(nullptr, json) << "Number of zeros: " << number_of_zeros; + + const auto& text = json->dump(TinyJSON::formatting::minify); + ASSERT_NE(nullptr, text) << "Number of zeros: " << number_of_zeros; + + ASSERT_STREQ(value.c_str(), text) << "Number of zeros: " << number_of_zeros; + delete json; + } +} + +// https://github.com/FFMG/tinyjson/issues/53 +// Same overflow but via fast_number_fraction_and_exponent_to_string(...) +TEST(TestDump, Issue53_ExponentWithMoreThan254FractionDigitsCanBeDumped) +{ + std::vector values; + values.push_back(make_number_with_zeros("1.", 300, "1e-20")); + values.push_back(make_number_with_zeros("1.", 300, "1e+20")); + values.push_back(make_number_with_zeros("-1.", 300, "1e-20")); + values.push_back(make_number_with_zeros("-1.", 300, "1e+20")); + for (auto& value : values) + { + auto json = TinyJSON::TJ::parse(value.c_str()); + ASSERT_NE(nullptr, json); + + const auto& text = json->dump(TinyJSON::formatting::minify); + ASSERT_NE(nullptr, text); + + ASSERT_STREQ(value.c_str(), text); + delete json; + } +} + +// https://github.com/FFMG/tinyjson/issues/53 +// The example given in the issue, "1.000...0001e-1" cannot be moved to "0.1000...0001" +// without overflowing, so it stays as an exponent. +TEST(TestDump, Issue53_SmallNegativeExponentWithMoreThan254FractionDigitsCanBeDumped) +{ + const auto value = make_number_with_zeros("1.", 300, "1e-1"); + auto json = TinyJSON::TJ::parse(value.c_str()); + ASSERT_NE(nullptr, json); + + const auto& text = json->dump(TinyJSON::formatting::minify); + ASSERT_NE(nullptr, text); + + ASSERT_STREQ(value.c_str(), text); + ASSERT_NEAR(0.1, static_cast(json->get_float()), 1e-15); + delete json; +} diff --git a/tests/testtinyjsonexponents.cpp b/tests/testtinyjsonexponents.cpp index d2566fb..2aa0edd 100644 --- a/tests/testtinyjsonexponents.cpp +++ b/tests/testtinyjsonexponents.cpp @@ -5,6 +5,7 @@ #define TJ_USE_CHAR 1 #include "../src/TinyJSON.h" #include +#include constexpr long double EPSILON = 1e-10; @@ -350,7 +351,11 @@ TEST(TestExponents, ShortNumberWithLongPositiveExponentShiftsEnoughToBecomeANumb auto valuea = dynamic_cast(jobject->try_get_value("a")); ASSERT_NE(nullptr, valuea); - ASSERT_EQ(10000000000000000000ULL, (unsigned long long)valuea->get_number()); + // 10^19 does not fit in a long long so get_number() is clamped, (see https://github.com/FFMG/tinyjson/issues/55) + // but the number itself is still kept. + ASSERT_EQ(std::numeric_limits::max(), valuea->get_number()); + ASSERT_DOUBLE_EQ(1e19, static_cast(valuea->get_float())); + ASSERT_STREQ("10000000000000000000", valuea->dump(TinyJSON::formatting::minify)); delete json; } @@ -706,4 +711,43 @@ TEST(TestExponents, PositiveExponentToString) auto json = TinyJSON::TJ::parse("2.5e+2"); EXPECT_STREQ("250", json->dump(TinyJSON::formatting::minify)); delete json; -} \ No newline at end of file +} + +// https://github.com/FFMG/tinyjson/issues/54 +// The whole fraction becomes the whole number, the fraction has more than one digit. +TEST(TestExponents, Issue54_LongFractionWithLargePositiveExponentBecomesAWholeNumber) +{ + auto json = TinyJSON::TJ::parse("0.12e20"); + ASSERT_NE(nullptr, json); + + auto value = dynamic_cast(json); + ASSERT_NE(nullptr, value); + ASSERT_STREQ("12000000000000000000", json->dump(TinyJSON::formatting::minify)); + ASSERT_DOUBLE_EQ(1.2e19, static_cast(json->get_float())); + delete json; +} + +// https://github.com/FFMG/tinyjson/issues/54 +// The number is too big to be a whole number so it remains an exponent. +TEST(TestExponents, Issue54_LongFractionWithVeryLargePositiveExponentRemainsAnExponent) +{ + auto json = TinyJSON::TJ::parse("0.12e30"); + ASSERT_NE(nullptr, json); + + auto value = dynamic_cast(json); + ASSERT_NE(nullptr, value); + ASSERT_STREQ("1.2e+29", json->dump(TinyJSON::formatting::minify)); + ASSERT_DOUBLE_EQ(1.2e29, static_cast(json->get_float())); + delete json; +} + +// https://github.com/FFMG/tinyjson/issues/54 +// The fraction has TJ_MAX_NUMBER_OF_DIGGITS digits and a small negative exponent. +TEST(TestExponents, Issue54_LongFractionWithSmallNegativeExponent) +{ + auto json = TinyJSON::TJ::parse("0.1234567890123456789e-1"); + ASSERT_NE(nullptr, json); + + ASSERT_DOUBLE_EQ(0.01234567890123456789, static_cast(json->get_float())); + delete json; +} diff --git a/tests/testtinyjsonnumbers.cpp b/tests/testtinyjsonnumbers.cpp index dac2698..1ed2c97 100644 --- a/tests/testtinyjsonnumbers.cpp +++ b/tests/testtinyjsonnumbers.cpp @@ -6,6 +6,8 @@ #define TJ_USE_CHAR 1 #include "../src/TinyJSON.h" #include +#include +#include TEST(TestNumbers, NumberIsAfterMissingColon) { auto json = TinyJSON::TJ::parse(R"( @@ -526,3 +528,417 @@ TEST(TestNumbers, CreateFloatByPassingNegativeNumber) { delete json; } + + +// https://github.com/FFMG/tinyjson/issues/54 +// A number that does not fit must either be rejected, (parse returns nullptr), +// or keep (roughly) its real value, what it must never do is silently wrap around. +static void assert_rejected_or_close_to(const std::string& value, const long double expected) +{ + auto json = TinyJSON::TJ::parse(value.c_str()); + if (nullptr == json) + { + return; + } + const auto actual = json->get_float(); + delete json; + ASSERT_NEAR(1.0, static_cast(actual / expected), 1e-9) << "Value: " << value << " was parsed as " << actual; +} + +// https://github.com/FFMG/tinyjson/issues/54 +// A huge positive exponent must either be rejected or become infinite. +static void assert_rejected_or_infinite(const std::string& value) +{ + auto json = TinyJSON::TJ::parse(value.c_str()); + if (nullptr == json) + { + return; + } + const auto actual = json->get_float(); + delete json; + ASSERT_TRUE(std::isinf(actual)) << "Value: " << value << " was parsed as " << actual; +} + +// https://github.com/FFMG/tinyjson/issues/54 +// A huge negative exponent must either be rejected or become zero. +static void assert_rejected_or_zero(const std::string& value) +{ + auto json = TinyJSON::TJ::parse(value.c_str()); + if (nullptr == json) + { + return; + } + const auto actual = json->get_float(); + delete json; + ASSERT_EQ(0.0L, actual) << "Value: " << value << " was parsed as " << actual; +} + +// https://github.com/FFMG/tinyjson/issues/54 +TEST(TestNumbers, Issue54_WholeNumberLargerThan64BitsDoesNotWrap) +{ + std::vector values; + values.push_back("18446744073709551616"); // 2^64, wraps to 0 + values.push_back("18446744073709551617"); // 2^64+1, wraps to 1 + values.push_back("-18446744073709551617"); // wraps to -1 + values.push_back("99999999999999999999"); + values.push_back("123456789012345678901234567890"); + values.push_back("18446744073709551617.5"); + values.push_back("18446744073709551617e2"); + for (auto& value : values) + { + assert_rejected_or_close_to(value, std::stold(value)); + } +} + +// https://github.com/FFMG/tinyjson/issues/54 +TEST(TestNumbers, Issue54_FractionLongerThan64BitsDoesNotWrap) +{ + std::vector values; + values.push_back("0.18446744073709551617"); // fraction wraps to 1, (0.00000000000000000001) + values.push_back("1.18446744073709551617"); + values.push_back("-0.18446744073709551617"); + values.push_back("0.99999999999999999999"); + values.push_back("3.14159265358979323846264338327950288"); + values.push_back("1.18446744073709551617e2"); + values.push_back("1.18446744073709551617e-2"); + for (auto& value : values) + { + assert_rejected_or_close_to(value, std::stold(value)); + } +} + +// https://github.com/FFMG/tinyjson/issues/54 +// The exponent is cast to an unsigned int so 2^32 wraps to 0 and 2^32+1 wraps to 1 +TEST(TestNumbers, Issue54_PositiveExponentLargerThan32BitsDoesNotWrap) +{ + std::vector values; + values.push_back("1e4294967296"); // becomes 1 + values.push_back("1e4294967297"); // becomes 10 + values.push_back("1e+4294967296"); + values.push_back("1E4294967296"); + values.push_back("1.5e4294967296"); + values.push_back("1e18446744073709551617"); + for (auto& value : values) + { + assert_rejected_or_infinite(value); + } +} + +// https://github.com/FFMG/tinyjson/issues/54 +// The unsigned exponent is multiplied by -1 so 2^32-1 becomes +1 +TEST(TestNumbers, Issue54_NegativeExponentLargerThan32BitsDoesNotWrap) +{ + std::vector values; + values.push_back("1e-4294967295"); // becomes 10 + values.push_back("1e-4294967296"); // becomes 1 + values.push_back("1.5e-4294967295"); + values.push_back("1e-18446744073709551617"); + for (auto& value : values) + { + assert_rejected_or_zero(value); + } +} + +// https://github.com/FFMG/tinyjson/issues/54 +// 2^31 becomes INT_MIN, and -1 * INT_MIN is undefined behaviour. +TEST(TestNumbers, Issue54_ExponentOfIntMinDoesNotWrap) +{ + assert_rejected_or_zero("1e-2147483648"); + assert_rejected_or_infinite("1e2147483648"); +} + +// https://github.com/FFMG/tinyjson/issues/54 +// The reproduction given in the issue. +TEST(TestNumbers, Issue54_ExampleFromTheIssue) +{ + auto json = TinyJSON::TJ::parse("[18446744073709551617, 1e4294967296]"); + if (nullptr == json) + { + return; + } + auto jarray = dynamic_cast(json); + ASSERT_NE(nullptr, jarray); + ASSERT_EQ(2u, jarray->get_number_of_items()); + + const auto first = jarray->at(0)->get_float(); + const auto second = jarray->at(1)->get_float(); + delete json; + + ASSERT_NEAR(1.0, static_cast(first / 18446744073709551617.0L), 1e-9); + ASSERT_TRUE(std::isinf(second)); +} + + +// https://github.com/FFMG/tinyjson/issues/55 +// Whole numbers between 2^63 and 2^64-1 fit in an unsigned long long but not in a long long. +// Either the number is rejected, (parse returns nullptr), or get_number() must not flip the sign, +// (clamping or throwing are both acceptable), get_float() must keep (roughly) the real value +// and dump() must give the original value back. +static void assert_rejected_or_does_not_wrap_to_long_long(const std::string& value) +{ + auto json = TinyJSON::TJ::parse(value.c_str()); + if (nullptr == json) + { + return; + } + + const auto& text = json->dump(TinyJSON::formatting::minify); + ASSERT_NE(nullptr, text) << "Value: " << value; + const std::string dumped(text); + + const auto is_negative = value[0] == '-'; + bool sign_is_preserved = true; + long long actual_number = 0; + try + { + actual_number = json->get_number(); + sign_is_preserved = is_negative ? actual_number <= 0 : actual_number >= 0; + } + catch (const TinyJSON::TJParseException&) + { + // throwing because the number does not fit is acceptable. + } + delete json; + + ASSERT_EQ(value, dumped); + ASSERT_TRUE(sign_is_preserved) << "Value: " << value << " was returned as " << actual_number; + assert_rejected_or_close_to(value, std::stold(value)); +} + +// https://github.com/FFMG/tinyjson/issues/55 +TEST(TestNumbers, Issue55_WholeNumberLargerThanLongLongDoesNotWrap) +{ + std::vector values; + values.push_back("9223372036854775808"); // LLONG_MAX+1, becomes LLONG_MIN + values.push_back("9223372036854775809"); + values.push_back("18446744073709551615"); // ULLONG_MAX, becomes -1 + values.push_back("-9223372036854775809"); // LLONG_MIN-1, becomes LLONG_MAX + values.push_back("-18446744073709551615"); // becomes 1 + for (auto& value : values) + { + assert_rejected_or_does_not_wrap_to_long_long(value); + } +} + +// https://github.com/FFMG/tinyjson/issues/55 +// LLONG_MIN is a valid long long, but get_number() does -1 * LLONG_MIN which is undefined behaviour. +TEST(TestNumbers, Issue55_WholeNumberLongLongMinIsValid) +{ + auto json = TinyJSON::TJ::parse("-9223372036854775808"); + ASSERT_NE(nullptr, json); + + const auto& text = json->dump(TinyJSON::formatting::minify); + ASSERT_NE(nullptr, text); + ASSERT_STREQ("-9223372036854775808", text); + + ASSERT_EQ(std::numeric_limits::min(), json->get_number()); + delete json; +} + +// https://github.com/FFMG/tinyjson/issues/55 +// LLONG_MAX is a valid long long and must not be affected by any fix. +TEST(TestNumbers, Issue55_WholeNumberLongLongMaxIsValid) +{ + auto json = TinyJSON::TJ::parse("9223372036854775807"); + ASSERT_NE(nullptr, json); + + const auto& text = json->dump(TinyJSON::formatting::minify); + ASSERT_NE(nullptr, text); + ASSERT_STREQ("9223372036854775807", text); + + ASSERT_EQ(std::numeric_limits::max(), json->get_number()); + delete json; +} + +// https://github.com/FFMG/tinyjson/issues/1 +// When numbers exceed supported precision or memory bounds, they should be truncated or converted. + +// Functor to track warnings without using lambdas +struct Issue1WarningTracker +{ + int count = 0; + std::string last_message; + + void operator()(TinyJSON::parse_options::message_type type, const TJCHAR* message, int, int) + { + if (type == TinyJSON::parse_options::message_type::warning) + { + count++; + if (nullptr != message) + { + last_message = message; + } + } + } +}; + +// The example from the issue: fraction with 27 digits is truncated to at most 19 significant digits. +TEST(TestNumbers, Issue1_ExampleFractionTruncatedToSignificantDigits) +{ + auto json = TinyJSON::TJ::parse(R"({ "a" : 0.123456789123456789123456789 })"); + ASSERT_NE(nullptr, json); + + auto jobject = dynamic_cast(json); + ASSERT_NE(nullptr, jobject); + + auto val = jobject->try_get_value("a"); + ASSERT_NE(nullptr, val); + + ASSERT_STREQ("0.1234567891234567891", val->dump(TinyJSON::formatting::minify)); + ASSERT_NEAR(0.1234567891234567891, static_cast(val->get_float()), 1e-15); + + delete json; +} + +// Negative fraction with 27 digits is truncated to at most 19 significant digits. +TEST(TestNumbers, Issue1_NegativeFractionTruncatedToSignificantDigits) +{ + auto json = TinyJSON::TJ::parse(R"({ "a" : -0.123456789123456789123456789 })"); + ASSERT_NE(nullptr, json); + + auto jobject = dynamic_cast(json); + ASSERT_NE(nullptr, jobject); + + auto val = jobject->try_get_value("a"); + ASSERT_NE(nullptr, val); + + ASSERT_STREQ("-0.1234567891234567891", val->dump(TinyJSON::formatting::minify)); + ASSERT_NEAR(-0.1234567891234567891, static_cast(val->get_float()), 1e-15); + + delete json; +} + +// The example from the issue: whole number with 27 digits is converted to an exponent number. +TEST(TestNumbers, Issue1_ExampleWholeNumberConvertedToExponent) +{ + auto json = TinyJSON::TJ::parse(R"({ "a" : 123456789123456789123456789 })"); + ASSERT_NE(nullptr, json); + + auto jobject = dynamic_cast(json); + ASSERT_NE(nullptr, jobject); + + auto val = jobject->try_get_value("a"); + ASSERT_NE(nullptr, val); + + // 20 digits kept (1.2345678912345678912) and remaining 7 digits shifted to exponent (+26) + ASSERT_STREQ("1.2345678912345678912e+26", val->dump(TinyJSON::formatting::minify)); + ASSERT_NEAR(1.2345678912345678912e26, static_cast(val->get_float()), 1e11); + + delete json; +} + +// Negative whole number with 27 digits is converted to an exponent number. +TEST(TestNumbers, Issue1_NegativeWholeNumberConvertedToExponent) +{ + auto json = TinyJSON::TJ::parse(R"({ "a" : -123456789123456789123456789 })"); + ASSERT_NE(nullptr, json); + + auto jobject = dynamic_cast(json); + ASSERT_NE(nullptr, jobject); + + auto val = jobject->try_get_value("a"); + ASSERT_NE(nullptr, val); + + ASSERT_STREQ("-1.2345678912345678912e+26", val->dump(TinyJSON::formatting::minify)); + ASSERT_NEAR(-1.2345678912345678912e26, static_cast(val->get_float()), 1e11); + + delete json; +} + +// When the whole number exceeds ULLONG_MAX, the fraction digits are negligible. +TEST(TestNumbers, Issue1_WholeNumberAndFractionBothExceedingPrecision) +{ + auto json = TinyJSON::TJ::parse(R"({ "a" : 123456789123456789123456789.9876543210123456789 })"); + ASSERT_NE(nullptr, json); + + auto jobject = dynamic_cast(json); + ASSERT_NE(nullptr, jobject); + + auto val = jobject->try_get_value("a"); + ASSERT_NE(nullptr, val); + + ASSERT_STREQ("1.2345678912345678912e+26", val->dump(TinyJSON::formatting::minify)); + + delete json; +} + +// An extremely long run of digits (5,000 zeros) parses and dumps without overflow or crash. +TEST(TestNumbers, Issue1_ExtremelyLongWholeNumberRunsDoNotOverflow) +{ + const std::string long_number = "1" + std::string(5000, '0'); + auto json = TinyJSON::TJ::parse(long_number.c_str()); + ASSERT_NE(nullptr, json); + + const auto& text = json->dump(TinyJSON::formatting::minify); + ASSERT_NE(nullptr, text); + ASSERT_STREQ("1e+5000", text); + + delete json; +} + +// An extremely long fraction (5,000 digits) is truncated to 19 significant digits. +TEST(TestNumbers, Issue1_ExtremelyLongFractionRunsDoNotOverflow) +{ + const std::string long_fraction = "0." + std::string(5000, '9'); + auto json = TinyJSON::TJ::parse(long_fraction.c_str()); + ASSERT_NE(nullptr, json); + + const auto& text = json->dump(TinyJSON::formatting::minify); + ASSERT_NE(nullptr, text); + ASSERT_STREQ("0.9999999999999999999", text); + + delete json; +} + +// A whole number with more than TJ_MAX_EXPONENT digits (> 100,000) exceeds maximum exponent and is rejected. +TEST(TestNumbers, Issue1_WholeNumberExceedingMaxExponentIsRejected) +{ + const std::string huge_number = "1" + std::string(100021, '0'); + auto json = TinyJSON::TJ::parse(huge_number.c_str()); + EXPECT_EQ(nullptr, json); +} + +// A warning should be emitted when precision is lost by truncating whole number digits. +TEST(TestNumbers, Issue1_WarningTriggeredWhenWholeNumberPrecisionIsLost) +{ + Issue1WarningTracker tracker; + TinyJSON::parse_options options = {}; + options.callback_function = std::ref(tracker); + + auto json = TinyJSON::TJ::parse(R"({ "a" : 123456789123456789123456789 })", options); + ASSERT_NE(nullptr, json); + + EXPECT_GT(tracker.count, 0); + + delete json; +} + +// A warning should be emitted when precision is lost by truncating fraction digits. +TEST(TestNumbers, Issue1_WarningTriggeredWhenFractionPrecisionIsLost) +{ + Issue1WarningTracker tracker; + TinyJSON::parse_options options = {}; + options.callback_function = std::ref(tracker); + + auto json = TinyJSON::TJ::parse(R"({ "a" : 0.123456789123456789123456789 })", options); + ASSERT_NE(nullptr, json); + + EXPECT_GT(tracker.count, 0); + + delete json; +} + +// No warning should be emitted when numbers fit within supported precision. +TEST(TestNumbers, Issue1_NoWarningWhenNumberFitsWithinPrecision) +{ + Issue1WarningTracker tracker; + TinyJSON::parse_options options = {}; + options.callback_function = std::ref(tracker); + + auto json = TinyJSON::TJ::parse(R"([ 123456789012345678, 0.123456789012345678 ])", options); + ASSERT_NE(nullptr, json); + + EXPECT_EQ(0, tracker.count); + + delete json; +} diff --git a/tests/testtinyjsonversion.cpp b/tests/testtinyjsonversion.cpp index 9c9ecb5..0014ab1 100644 --- a/tests/testtinyjsonversion.cpp +++ b/tests/testtinyjsonversion.cpp @@ -17,10 +17,10 @@ TEST(TestVersion, CheckVersionMinor) TEST(TestVersion, CheckVersionPatch) { - ASSERT_EQ(9, TJ_VERSION_PATCH); + ASSERT_EQ(10, TJ_VERSION_PATCH); } TEST(TestVersion, CheckVersionString) { - ASSERT_STREQ("0.2.9", TJ_VERSION_STRING); + ASSERT_STREQ("0.2.10", TJ_VERSION_STRING); }