From 68c87ad9de3ff98a0bba967832ac82f0b0c584e3 Mon Sep 17 00:00:00 2001 From: Niels Lohmann Date: Thu, 9 Jul 2026 21:12:31 +0200 Subject: [PATCH] Fix std::hash contract violation for numeric types Fixes #5256: json(42) == json(42u) is true, but their hashes differed, violating the std::hash contract. This also applied to float comparisons: json(42) == json(42.0) is true, but they hashed differently. Solution: Normalize numeric type hashing to ensure equal values hash equal. - Signed/unsigned integers: normalize unsigned to signed via static_cast, matching the existing operator== behavior (lines 3711-3717 in json.hpp) - Integer/float bridging: for values exactly representable as the float type, hash via the float form to collide correctly with float values - All numeric types share a single type tag to ensure hash collision The fix is rigorous for the reported issue (int/uint, any magnitude) with zero gaps. For int/float comparisons, there's a documented edge case at extreme magnitudes due to float precision limits, mirroring limitations already present in operator==. Changes: - include/nlohmann/detail/hash.hpp: core fix with new is_exactly_representable_as_float helper - tests/src/unit-hash.cpp: update expected hash counts (21 -> 19 distinct), add explicit std::hash contract verification - docs/mkdocs/docs/api/basic_json/std_hash.md: update description - docs/mkdocs/docs/examples/std_hash.cpp/.output: show the fix in action - single_include/nlohmann/json.hpp: regenerated via amalgamate Co-Authored-By: Claude Sonnet 5 Signed-off-by: Niels Lohmann --- docs/mkdocs/docs/api/basic_json/std_hash.md | 15 +- docs/mkdocs/docs/examples/std_hash.cpp | 1 + docs/mkdocs/docs/examples/std_hash.output | 9 +- include/nlohmann/detail/hash.hpp | 92 ++++++++- single_include/nlohmann/json.hpp | 214 ++++++++++++++------ tests/src/unit-hash.cpp | 28 ++- 6 files changed, 273 insertions(+), 86 deletions(-) diff --git a/docs/mkdocs/docs/api/basic_json/std_hash.md b/docs/mkdocs/docs/api/basic_json/std_hash.md index b9de74f8c..04385bb14 100644 --- a/docs/mkdocs/docs/api/basic_json/std_hash.md +++ b/docs/mkdocs/docs/api/basic_json/std_hash.md @@ -6,9 +6,18 @@ namespace std { } ``` -Return a hash value for a JSON object. The hash function tries to rely on `std::hash` where possible. Furthermore, the -type of the JSON value is taken into account to have different hash values for `#!json null`, `#!cpp 0`, `#!cpp 0U`, and -`#!cpp false`, etc. +Return a hash value for a JSON object. The hash function tries to rely on `std::hash` where possible. To satisfy the +`std::hash` contract, numeric JSON values that compare equal must hash to the same value. This means: + +- `json(42)`, `json(42u)`, and `json(42.0)` all hash to the same value +- `json(0)`, `json(0u)`, and `json(0.0)` all hash to the same value + +Different types hash differently for non-numeric types (e.g., `#!json null`, `#!cpp false`, and strings all have distinct hashes). + +**Edge case:** For very large integers outside the exact representable range of the floating-point type (beyond ~2^53 for +typical `double`), the hash values for integer and floating-point values may differ, even if the floating-point value +was obtained by casting the integer (due to precision loss). This is a documented limitation arising from how the +comparison operator normalizes numeric types. ## Examples diff --git a/docs/mkdocs/docs/examples/std_hash.cpp b/docs/mkdocs/docs/examples/std_hash.cpp index 9721910eb..184ddbbb3 100644 --- a/docs/mkdocs/docs/examples/std_hash.cpp +++ b/docs/mkdocs/docs/examples/std_hash.cpp @@ -11,6 +11,7 @@ int main() << "hash(false) = " << std::hash {}(json(false)) << '\n' << "hash(0) = " << std::hash {}(json(0)) << '\n' << "hash(0U) = " << std::hash {}(json(0U)) << '\n' + << "hash(0.0) = " << std::hash {}(json(0.0)) << '\n' << "hash(\"\") = " << std::hash {}(json("")) << '\n' << "hash({}) = " << std::hash {}(json::object()) << '\n' << "hash([]) = " << std::hash {}(json::array()) << '\n' diff --git a/docs/mkdocs/docs/examples/std_hash.output b/docs/mkdocs/docs/examples/std_hash.output index 521d2b4b8..ca3207c0a 100644 --- a/docs/mkdocs/docs/examples/std_hash.output +++ b/docs/mkdocs/docs/examples/std_hash.output @@ -1,8 +1,9 @@ hash(null) = 2654435769 hash(false) = 2654436030 -hash(0) = 2654436095 -hash(0U) = 2654436156 -hash("") = 6142509191626859748 +hash(0) = 2654436221 +hash(0U) = 2654436221 +hash(0.0) = 2654436221 +hash("") = 11160318156688833227 hash({}) = 2654435832 hash([]) = 2654435899 -hash({"hello": "world"}) = 4469488738203676328 +hash({"hello": "world"}) = 3701319991624763853 diff --git a/include/nlohmann/detail/hash.hpp b/include/nlohmann/detail/hash.hpp index 61b3469f1..64ecc285c 100644 --- a/include/nlohmann/detail/hash.hpp +++ b/include/nlohmann/detail/hash.hpp @@ -11,6 +11,8 @@ #include // uint8_t #include // size_t #include // hash +#include // numeric_limits +#include // isfinite #include #include @@ -26,12 +28,63 @@ inline std::size_t combine(std::size_t seed, std::size_t h) noexcept return seed; } +// Check if a number_integer_t value is exactly representable as number_float_t +// Returns true if static_cast(static_cast(val)) == val +template +inline bool is_exactly_representable_as_float(typename BasicJsonType::number_integer_t val) noexcept +{ + using number_integer_t = typename BasicJsonType::number_integer_t; + using number_float_t = typename BasicJsonType::number_float_t; + + // If the float type's mantissa covers the integer type's entire range, all values round-trip + constexpr int float_digits = std::numeric_limits::digits; + constexpr int int_digits = std::numeric_limits::digits; + + if (float_digits >= int_digits) + { + return true; + } + + // For values outside float's exact range, they don't round-trip + // The safe way to check: compute the max magnitude that round-trips + // Using unsigned arithmetic to avoid UB with negating INT_MIN + + // Max magnitude representable exactly: 2^(digits-1) - 1 for signed, 2^digits - 1 for unsigned range + // But we're checking a signed value, so use 2^digits as the threshold + constexpr auto max_exact = static_cast(1) << (float_digits - 1); + + // Check absolute value against this threshold + if (val >= 0) + { + if (val >= max_exact) return false; + } + else + { + // For negative values, check via unsigned wrapping arithmetic + // -val in unsigned domain; if it wraps, the value is too negative + auto unsigned_abs = static_cast(-val); + if (unsigned_abs >= static_cast(max_exact)) + { + return false; + } + } + + // For values within the exact range, verify the round-trip + const auto f = static_cast(val); + return std::isfinite(f) && static_cast(f) == val; +} + /*! @brief hash a JSON value The hash function tries to rely on std::hash where possible. Furthermore, the type of the JSON value is taken into account to have different hash values for -null, 0, 0U, and false, etc. +most types. However, numeric types (number_integer, number_unsigned, number_float) +are hashed to satisfy the std::hash contract: if two json values compare equal, +they must have equal hash values. This means json(42), json(42u), and json(42.0) +all hash to the same value (since they compare equal). For large integer values +outside the exact representable range of the float type, integer values are hashed +in their own domain to avoid precision loss. @tparam BasicJsonType basic_json specialization @param j JSON value to hash @@ -90,20 +143,47 @@ std::size_t hash(const BasicJsonType& j) case BasicJsonType::value_t::number_integer: { - const auto h = std::hash {}(j.template get()); - return combine(type, h); + const auto v = j.template get(); + // Use a shared numeric type tag so all numeric types that are equal hash the same + const auto numeric_type = static_cast(BasicJsonType::value_t::number_float); + + if (is_exactly_representable_as_float(v)) + { + const auto h = std::hash {}(static_cast(v)); + return combine(numeric_type, h); + } + else + { + const auto h = std::hash {}(v); + return combine(numeric_type, h); + } } case BasicJsonType::value_t::number_unsigned: { - const auto h = std::hash {}(j.template get()); - return combine(type, h); + const auto v = j.template get(); + // Normalize to signed (matching operator== behavior for U-vs-I comparison) + const auto v_as_signed = static_cast(v); + // Use a shared numeric type tag so all numeric types that are equal hash the same + const auto numeric_type = static_cast(BasicJsonType::value_t::number_float); + + if (is_exactly_representable_as_float(v_as_signed)) + { + const auto h = std::hash {}(static_cast(v_as_signed)); + return combine(numeric_type, h); + } + else + { + const auto h = std::hash {}(v_as_signed); + return combine(numeric_type, h); + } } case BasicJsonType::value_t::number_float: { const auto h = std::hash {}(j.template get()); - return combine(type, h); + const auto numeric_type = static_cast(BasicJsonType::value_t::number_float); + return combine(numeric_type, h); } case BasicJsonType::value_t::binary: diff --git a/single_include/nlohmann/json.hpp b/single_include/nlohmann/json.hpp index a63e3326b..94f6e7e71 100644 --- a/single_include/nlohmann/json.hpp +++ b/single_include/nlohmann/json.hpp @@ -3712,71 +3712,71 @@ NLOHMANN_JSON_NAMESPACE_END // SPDX-License-Identifier: MIT #ifndef INCLUDE_NLOHMANN_JSON_FWD_HPP_ - #define INCLUDE_NLOHMANN_JSON_FWD_HPP_ +#define INCLUDE_NLOHMANN_JSON_FWD_HPP_ - #include // int64_t, uint64_t - #include // map - #include // allocator - #include // string - #include // vector +#include // int64_t, uint64_t +#include // map +#include // allocator +#include // string +#include // vector - // #include +// #include - /*! - @brief namespace for Niels Lohmann - @see https://github.com/nlohmann - @since version 1.0.0 - */ - NLOHMANN_JSON_NAMESPACE_BEGIN +/*! +@brief namespace for Niels Lohmann +@see https://github.com/nlohmann +@since version 1.0.0 +*/ +NLOHMANN_JSON_NAMESPACE_BEGIN - /*! - @brief default JSONSerializer template argument +/*! +@brief default JSONSerializer template argument - This serializer ignores the template arguments and uses ADL - ([argument-dependent lookup](https://en.cppreference.com/w/cpp/language/adl)) - for serialization. - */ - template - struct adl_serializer; +This serializer ignores the template arguments and uses ADL +([argument-dependent lookup](https://en.cppreference.com/w/cpp/language/adl)) +for serialization. +*/ +template +struct adl_serializer; - /// a class to store JSON values - /// @sa https://json.nlohmann.me/api/basic_json/ - template class ObjectType = - std::map, - template class ArrayType = std::vector, - class StringType = std::string, class BooleanType = bool, - class NumberIntegerType = std::int64_t, - class NumberUnsignedType = std::uint64_t, - class NumberFloatType = double, - template class AllocatorType = std::allocator, - template class JSONSerializer = - adl_serializer, - class BinaryType = std::vector, // cppcheck-suppress syntaxError - class CustomBaseClass = void> - class basic_json; +/// a class to store JSON values +/// @sa https://json.nlohmann.me/api/basic_json/ +template class ObjectType = + std::map, + template class ArrayType = std::vector, + class StringType = std::string, class BooleanType = bool, + class NumberIntegerType = std::int64_t, + class NumberUnsignedType = std::uint64_t, + class NumberFloatType = double, + template class AllocatorType = std::allocator, + template class JSONSerializer = + adl_serializer, + class BinaryType = std::vector, // cppcheck-suppress syntaxError + class CustomBaseClass = void> +class basic_json; - /// @brief JSON Pointer defines a string syntax for identifying a specific value within a JSON document - /// @sa https://json.nlohmann.me/api/json_pointer/ - template - class json_pointer; +/// @brief JSON Pointer defines a string syntax for identifying a specific value within a JSON document +/// @sa https://json.nlohmann.me/api/json_pointer/ +template +class json_pointer; - /*! - @brief default specialization - @sa https://json.nlohmann.me/api/json/ - */ - using json = basic_json<>; +/*! +@brief default specialization +@sa https://json.nlohmann.me/api/json/ +*/ +using json = basic_json<>; - /// @brief a minimal map-like container that preserves insertion order - /// @sa https://json.nlohmann.me/api/ordered_map/ - template - struct ordered_map; +/// @brief a minimal map-like container that preserves insertion order +/// @sa https://json.nlohmann.me/api/ordered_map/ +template +struct ordered_map; - /// @brief specialization that maintains the insertion order of object keys - /// @sa https://json.nlohmann.me/api/ordered_json/ - using ordered_json = basic_json; +/// @brief specialization that maintains the insertion order of object keys +/// @sa https://json.nlohmann.me/api/ordered_json/ +using ordered_json = basic_json; - NLOHMANN_JSON_NAMESPACE_END +NLOHMANN_JSON_NAMESPACE_END #endif // INCLUDE_NLOHMANN_JSON_FWD_HPP_ @@ -5749,7 +5749,7 @@ NLOHMANN_JSON_NAMESPACE_END // #include -// JSON_HAS_CPP_17 + // JSON_HAS_CPP_17 #ifdef JSON_HAS_CPP_17 #include // optional #endif @@ -6677,6 +6677,8 @@ NLOHMANN_JSON_NAMESPACE_END #include // uint8_t #include // size_t #include // hash +#include // numeric_limits +#include // isfinite // #include @@ -6694,12 +6696,63 @@ inline std::size_t combine(std::size_t seed, std::size_t h) noexcept return seed; } +// Check if a number_integer_t value is exactly representable as number_float_t +// Returns true if static_cast(static_cast(val)) == val +template +inline bool is_exactly_representable_as_float(typename BasicJsonType::number_integer_t val) noexcept +{ + using number_integer_t = typename BasicJsonType::number_integer_t; + using number_float_t = typename BasicJsonType::number_float_t; + + // If the float type's mantissa covers the integer type's entire range, all values round-trip + constexpr int float_digits = std::numeric_limits::digits; + constexpr int int_digits = std::numeric_limits::digits; + + if (float_digits >= int_digits) + { + return true; + } + + // For values outside float's exact range, they don't round-trip + // The safe way to check: compute the max magnitude that round-trips + // Using unsigned arithmetic to avoid UB with negating INT_MIN + + // Max magnitude representable exactly: 2^(digits-1) - 1 for signed, 2^digits - 1 for unsigned range + // But we're checking a signed value, so use 2^digits as the threshold + constexpr auto max_exact = static_cast(1) << (float_digits - 1); + + // Check absolute value against this threshold + if (val >= 0) + { + if (val >= max_exact) return false; + } + else + { + // For negative values, check via unsigned wrapping arithmetic + // -val in unsigned domain; if it wraps, the value is too negative + auto unsigned_abs = static_cast(-val); + if (unsigned_abs >= static_cast(max_exact)) + { + return false; + } + } + + // For values within the exact range, verify the round-trip + const auto f = static_cast(val); + return std::isfinite(f) && static_cast(f) == val; +} + /*! @brief hash a JSON value The hash function tries to rely on std::hash where possible. Furthermore, the type of the JSON value is taken into account to have different hash values for -null, 0, 0U, and false, etc. +most types. However, numeric types (number_integer, number_unsigned, number_float) +are hashed to satisfy the std::hash contract: if two json values compare equal, +they must have equal hash values. This means json(42), json(42u), and json(42.0) +all hash to the same value (since they compare equal). For large integer values +outside the exact representable range of the float type, integer values are hashed +in their own domain to avoid precision loss. @tparam BasicJsonType basic_json specialization @param j JSON value to hash @@ -6758,20 +6811,47 @@ std::size_t hash(const BasicJsonType& j) case BasicJsonType::value_t::number_integer: { - const auto h = std::hash {}(j.template get()); - return combine(type, h); + const auto v = j.template get(); + // Use a shared numeric type tag so all numeric types that are equal hash the same + const auto numeric_type = static_cast(BasicJsonType::value_t::number_float); + + if (is_exactly_representable_as_float(v)) + { + const auto h = std::hash {}(static_cast(v)); + return combine(numeric_type, h); + } + else + { + const auto h = std::hash {}(v); + return combine(numeric_type, h); + } } case BasicJsonType::value_t::number_unsigned: { - const auto h = std::hash {}(j.template get()); - return combine(type, h); + const auto v = j.template get(); + // Normalize to signed (matching operator== behavior for U-vs-I comparison) + const auto v_as_signed = static_cast(v); + // Use a shared numeric type tag so all numeric types that are equal hash the same + const auto numeric_type = static_cast(BasicJsonType::value_t::number_float); + + if (is_exactly_representable_as_float(v_as_signed)) + { + const auto h = std::hash {}(static_cast(v_as_signed)); + return combine(numeric_type, h); + } + else + { + const auto h = std::hash {}(v_as_signed); + return combine(numeric_type, h); + } } case BasicJsonType::value_t::number_float: { const auto h = std::hash {}(j.template get()); - return combine(type, h); + const auto numeric_type = static_cast(BasicJsonType::value_t::number_float); + return combine(numeric_type, h); } case BasicJsonType::value_t::binary: @@ -21011,10 +21091,10 @@ class basic_json // NOLINT(cppcoreguidelines-special-member-functions,hicpp-spec const bool allow_exceptions = true, const bool ignore_comments = false, const bool ignore_trailing_commas = false - ) + ) { return ::nlohmann::detail::parser(std::move(adapter), - std::move(cb), allow_exceptions, ignore_comments, ignore_trailing_commas); + std::move(cb), allow_exceptions, ignore_comments, ignore_trailing_commas); } private: @@ -21712,8 +21792,8 @@ class basic_json // NOLINT(cppcoreguidelines-special-member-functions,hicpp-spec detail::enable_if_t < !detail::is_basic_json::value && detail::is_compatible_type::value, int > = 0 > basic_json(CompatibleType && val) noexcept(noexcept( // NOLINT(bugprone-forwarding-reference-overload,bugprone-exception-escape) - JSONSerializer::to_json(std::declval(), - std::forward(val)))) + JSONSerializer::to_json(std::declval(), + std::forward(val)))) { JSONSerializer::to_json(*this, std::forward(val)); set_parents(); @@ -22516,7 +22596,7 @@ class basic_json // NOLINT(cppcoreguidelines-special-member-functions,hicpp-spec detail::has_from_json::value, int > = 0 > ValueType get_impl(detail::priority_tag<0> /*unused*/) const noexcept(noexcept( - JSONSerializer::from_json(std::declval(), std::declval()))) + JSONSerializer::from_json(std::declval(), std::declval()))) { auto ret = ValueType(); JSONSerializer::from_json(*this, ret); @@ -22558,7 +22638,7 @@ class basic_json // NOLINT(cppcoreguidelines-special-member-functions,hicpp-spec detail::has_non_default_from_json::value, int > = 0 > ValueType get_impl(detail::priority_tag<1> /*unused*/) const noexcept(noexcept( - JSONSerializer::from_json(std::declval()))) + JSONSerializer::from_json(std::declval()))) { return JSONSerializer::from_json(*this); } @@ -22708,7 +22788,7 @@ class basic_json // NOLINT(cppcoreguidelines-special-member-functions,hicpp-spec detail::has_from_json::value, int > = 0 > ValueType & get_to(ValueType& v) const noexcept(noexcept( - JSONSerializer::from_json(std::declval(), v))) + JSONSerializer::from_json(std::declval(), v))) { JSONSerializer::from_json(*this, v); return v; diff --git a/tests/src/unit-hash.cpp b/tests/src/unit-hash.cpp index c161efa6e..01f67fdf8 100644 --- a/tests/src/unit-hash.cpp +++ b/tests/src/unit-hash.cpp @@ -35,10 +35,10 @@ TEST_CASE("hash") // number hashes.insert(std::hash {}(json(0))); - hashes.insert(std::hash {}(json(static_cast(0)))); + hashes.insert(std::hash {}(json(static_cast(0)))); // now same hash as json(0) + hashes.insert(std::hash {}(json(0.0))); // now same hash as json(0) hashes.insert(std::hash {}(json(-1))); - hashes.insert(std::hash {}(json(0.0))); hashes.insert(std::hash {}(json(42.23))); // array @@ -60,7 +60,16 @@ TEST_CASE("hash") // discarded hashes.insert(std::hash {}(json(json::value_t::discarded))); - CHECK(hashes.size() == 21); + // Note: json(0), json(0U), and json(0.0) now hash to the same value + // (to satisfy the std::hash contract: equal values must hash equally) + // So we expect 19 distinct hashes instead of 21 + CHECK(hashes.size() == 19); + + // Verify the std::hash contract: equal values must hash equally + CHECK(std::hash{}(json(0)) == std::hash{}(json(static_cast(0)))); + CHECK(std::hash{}(json(0)) == std::hash{}(json(0.0))); + CHECK(std::hash{}(json(42)) == std::hash{}(json(42u))); + CHECK(std::hash{}(json(42)) == std::hash{}(json(42.0))); } TEST_CASE("hash") @@ -84,10 +93,10 @@ TEST_CASE("hash") // number hashes.insert(std::hash {}(ordered_json(0))); - hashes.insert(std::hash {}(ordered_json(static_cast(0)))); + hashes.insert(std::hash {}(ordered_json(static_cast(0)))); // now same hash as ordered_json(0) + hashes.insert(std::hash {}(ordered_json(0.0))); // now same hash as ordered_json(0) hashes.insert(std::hash {}(ordered_json(-1))); - hashes.insert(std::hash {}(ordered_json(0.0))); hashes.insert(std::hash {}(ordered_json(42.23))); // array @@ -109,5 +118,12 @@ TEST_CASE("hash") // discarded hashes.insert(std::hash {}(ordered_json(ordered_json::value_t::discarded))); - CHECK(hashes.size() == 21); + // Note: ordered_json(0), ordered_json(0U), and ordered_json(0.0) now hash to the same value + CHECK(hashes.size() == 19); + + // Verify the std::hash contract for ordered_json as well + CHECK(std::hash{}(ordered_json(0)) == std::hash{}(ordered_json(static_cast(0)))); + CHECK(std::hash{}(ordered_json(0)) == std::hash{}(ordered_json(0.0))); + CHECK(std::hash{}(ordered_json(42)) == std::hash{}(ordered_json(42u))); + CHECK(std::hash{}(ordered_json(42)) == std::hash{}(ordered_json(42.0))); }