mirror of
https://github.com/nlohmann/json.git
synced 2026-11-15 03:48:21 +00:00
Copying, comparing, serializing, hashing, merging, diffing, converting and destroying a value, and the CBOR, MessagePack, UBJSON, BJData and BON8 writers recurse a fixed number of nesting levels on the call stack and finish deeper levels with an explicit stack (#5387). The number was hard-coded to 128. That is too much for small stacks: a 128-level dump() needs about 288 KiB at -O0 with Clang (2.3 KiB per level; 256 KiB with GCC) and crashed in a thread with a 256 KiB stack, and musl threads have 128 KiB by default. Others may want the opposite. The macro JSON_RECURSION_DEPTH_LIMIT, defined before including the library, sets the number; the default stays 128. The value must be an integer constant from 0 to 254 (not a floating-point number or bool), otherwise a static_assert names the macro. 254 is the largest value because the copy constructor and the comparison operators cannot pass a depth down and count it in a byte per thread (nesting_depth()). The guard that counts now counts only the levels it enters, so the count never exceeds the limit. It used to count the level that finds the limit reached as well, which made the count 255 at limit 254: a copy made by a custom base class while a copy was going on then wrapped the byte to 0 and recursed another 254 levels (twice the stack). 0 is allowed: every walk then uses its explicit stack from the first level on and needs a constant amount of stack (16 KiB or less at -O0 with Clang and GCC, for a value nested 20,000 levels deep). All uses of the limit compare a depth with it; none computes limit - 1 or limit + 1, and no walk has a constant 128 of its own. The macro changes only stack use and speed, never a result. Like JSON_NO_THREAD_LOCAL it is not part of the ABI tag and must be the same in all translation units (ODR); the page of the macro says what happens otherwise. Supporting a limit of 0 needed one fix: update() without merge_objects reached the explicit-stack merge at depth 0, which always merges objects. update_members now takes that path only with merge_objects, which is the only case in which it recurses at all. Measurements (macOS arm64, Apple clang 21 and g++ 16, documented in the macro page with a table per operation and optimization level, and a program to reproduce them): the stack needed at the default of 128 levels is up to 288 KiB (dump at -O0, Clang), 224 KiB (copy), and 48 KiB or less at -O2; at 254 levels up to 576 KiB at -O0 and 112 KiB at -O2. Speed at -O2 relative to the default: ordinary documents (nested 2 to 10 levels) are 1.1 to 2.4 times as slow with a limit of 0 or 1 and unchanged (within about 0.1) with 16 or 254; values nested 1,000 or 100,000 levels deep show no difference for any limit. The default build does the same work as before: the instructions retired for copy and conversion (the walks whose guard changed; net of loading, canada, citm, twitter, jeopardy and deep values), ==, dump and hash differ from the base by at most 0.02 % for copy and 0.71 % in either direction for the others (noise), and the cycles for copy by at most 0.65 %. Tests: the existing tests that depended on 128 (comparison, allocator, custom base class, diagnostics, binary writers, patch, merge_diff, conversions, custom array type) derive their depths from the limit, and two assumed a limit of at least 1 (limit - 1 wrapped around). tests/src/unit-recursion_depth_limit.cpp checks that the macro is honoured (the array type of a value counts the levels that a copy and a comparison descend) and that all walks return the same results for values nested 1 to 300 levels deep as arrays, objects and both, against expected strings and bytes built independently of the library; CMake builds it with limits 0, 1 and 254 besides the default, and the CI target ci_test_recursion_depth_limit_0 runs the whole suite with a limit of 0. A test with a base class that copies the value from its copy constructor covers the wrap-around at 254. The new test does not compile against the previous header with -DJSON_RECURSION_DEPTH_LIMIT=1. Docs: new macro page (range, stack and speed tables, small-stack guidance, ODR), index, nav, macro overview, "Nesting depth" of the untrusted-input page (which claimed that no walk could exhaust the stack however deep a value is nested; the default assumes a stack of a few hundred KiB), the assurance case, and the page of JSON_NO_THREAD_LOCAL. No breaking changes: the default is unchanged, and the macro is new. Signed-off-by: Niels Lohmann <mail@nlohmann.me>
285 lines
11 KiB
C++
285 lines
11 KiB
C++
// __ _____ _____ _____
|
|
// __| | __| | | | JSON for Modern C++ (supporting code)
|
|
// | | |__ | | | | | | version 3.12.0
|
|
// |_____|_____|_____|_|___| https://github.com/nlohmann/json
|
|
//
|
|
// SPDX-FileCopyrightText: 2013-2026 Niels Lohmann <https://nlohmann.me>
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
#include "doctest_compatibility.h"
|
|
|
|
#ifndef JSON_DIAGNOSTICS
|
|
#define JSON_DIAGNOSTICS 1
|
|
#endif
|
|
#define JSON_DIAGNOSTIC_POSITIONS 1
|
|
#include <nlohmann/json.hpp>
|
|
|
|
using nlohmann::json;
|
|
|
|
TEST_CASE("Better diagnostics with positions")
|
|
{
|
|
SECTION("invalid type")
|
|
{
|
|
const std::string json_invalid_string = R"(
|
|
{
|
|
"address": {
|
|
"street": "Fake Street",
|
|
"housenumber": "1"
|
|
}
|
|
}
|
|
)";
|
|
json j = json::parse(json_invalid_string);
|
|
#if JSON_DIAGNOSTICS
|
|
CHECK_THROWS_WITH_AS(j.at("address").at("housenumber").get<int>(),
|
|
"[json.exception.type_error.302] (/address/housenumber) (bytes 108-111) type must be number, but is string", json::type_error);
|
|
#else
|
|
CHECK_THROWS_WITH_AS(j.at("address").at("housenumber").get<int>(),
|
|
"[json.exception.type_error.302] (bytes 108-111) type must be number, but is string", json::type_error);
|
|
#endif
|
|
}
|
|
|
|
SECTION("invalid type without positions")
|
|
{
|
|
const json j = "foo";
|
|
CHECK_THROWS_WITH_AS(j.get<int>(),
|
|
"[json.exception.type_error.302] type must be number, but is string", json::type_error);
|
|
}
|
|
|
|
SECTION("positions of strings containing escape sequences")
|
|
{
|
|
// escape sequences make the token longer than the string it parses to,
|
|
// so the positions must not be derived from the parsed value's length
|
|
const auto check = [](const std::string & text, const std::string & token)
|
|
{
|
|
CAPTURE(text)
|
|
CAPTURE(token)
|
|
const json j = json::parse(text);
|
|
const json& v = j.at("a");
|
|
CHECK(text.substr(v.start_pos(), v.end_pos() - v.start_pos()) == token);
|
|
};
|
|
|
|
check(R"({"a":"plain"})", R"("plain")");
|
|
check(R"({"a":"tab\there"})", R"("tab\there")");
|
|
check(R"({"a":"\n\n\n\n\n\n"})", R"("\n\n\n\n\n\n")");
|
|
check(R"({"a":"\""})", R"("\"")");
|
|
check(R"({"a":"\\"})", R"("\\")");
|
|
check(R"({"a":"é"})", R"("é")");
|
|
check(R"({"a":"🌞"})", R"("🌞")");
|
|
check("{\"a\":\"\xc3\xa9\"}", "\"\xc3\xa9\""); // multi-byte UTF-8, no escapes
|
|
|
|
// a string at the root, where an escape would otherwise push the
|
|
// reported start position past the opening quote
|
|
const std::string root = R"("a\tb")";
|
|
const json j = json::parse(root);
|
|
CHECK(j.start_pos() == 0);
|
|
CHECK(j.end_pos() == root.size());
|
|
}
|
|
|
|
SECTION("copying keeps the positions of nested values (#5387)")
|
|
{
|
|
// Values nested deeper than the copy constructor's descent bound are
|
|
// copied without the call stack, on a path that has to carry the
|
|
// positions over itself; shallower ones copy their containers, which
|
|
// bring the positions along. Both sides of the bound are checked here.
|
|
const auto check_copy = [](std::size_t depth, bool objects)
|
|
{
|
|
CAPTURE(depth)
|
|
CAPTURE(objects)
|
|
|
|
const std::string opening = objects ? R"({"a":)" : "[";
|
|
const std::string closing = objects ? "}" : "]";
|
|
|
|
std::string text;
|
|
for (std::size_t i = 0; i < depth; ++i)
|
|
{
|
|
text += opening;
|
|
}
|
|
text += "12";
|
|
for (std::size_t i = 0; i < depth; ++i)
|
|
{
|
|
text += closing;
|
|
}
|
|
|
|
const json original = json::parse(text);
|
|
const json copy(original); // NOLINT(performance-unnecessary-copy-initialization)
|
|
|
|
const json* o = &original;
|
|
const json* c = ©
|
|
for (std::size_t level = 0; level <= depth; ++level)
|
|
{
|
|
CAPTURE(level)
|
|
REQUIRE(c->start_pos() == o->start_pos());
|
|
REQUIRE(c->end_pos() == o->end_pos());
|
|
|
|
if (level < depth)
|
|
{
|
|
o = objects ? &o->at("a") : &o->at(0);
|
|
c = objects ? &c->at("a") : &c->at(0);
|
|
}
|
|
}
|
|
};
|
|
|
|
const auto check_arrays = [&check_copy](std::size_t depth)
|
|
{
|
|
check_copy(depth, false);
|
|
};
|
|
const auto check_objects = [&check_copy](std::size_t depth)
|
|
{
|
|
check_copy(depth, true);
|
|
};
|
|
|
|
// around detail::recursion_depth_limit() (127, 128, and 129 by default)
|
|
const std::size_t limit = nlohmann::detail::recursion_depth_limit();
|
|
|
|
check_arrays(1);
|
|
check_arrays(limit > 0 ? limit - 1 : 0);
|
|
check_arrays(limit);
|
|
check_arrays(limit + 1);
|
|
check_arrays(300);
|
|
|
|
check_objects(1);
|
|
check_objects(limit > 0 ? limit - 1 : 0);
|
|
check_objects(limit);
|
|
check_objects(limit + 1);
|
|
check_objects(300);
|
|
}
|
|
|
|
SECTION("converting keeps the positions of nested values (#5650)")
|
|
{
|
|
// Values nested deeper than the converting constructor's descent bound
|
|
// are converted without the call stack, on a path that has to carry the
|
|
// positions of every value over itself. Objects and arrays take turns,
|
|
// and the innermost value is null, which used to lose its positions.
|
|
const auto check_conversion = [](std::size_t depth)
|
|
{
|
|
CAPTURE(depth)
|
|
|
|
std::string text;
|
|
std::string closing;
|
|
for (std::size_t i = 0; i < depth; ++i)
|
|
{
|
|
text += (i % 2 == 0) ? "[12, " : R"({"b":1, "a":)";
|
|
closing += (i % 2 == 0) ? ']' : '}';
|
|
}
|
|
text += "null";
|
|
text.append(closing.rbegin(), closing.rend());
|
|
|
|
const json original = json::parse(text);
|
|
const nlohmann::ordered_json converted = original;
|
|
|
|
const json* o = &original;
|
|
const nlohmann::ordered_json* c = &converted;
|
|
for (std::size_t level = 0; level <= depth; ++level)
|
|
{
|
|
CAPTURE(level)
|
|
REQUIRE(c->start_pos() == o->start_pos());
|
|
REQUIRE(c->end_pos() == o->end_pos());
|
|
|
|
if (level < depth)
|
|
{
|
|
// the number beside the value nested next
|
|
const json& o_number = o->is_object() ? o->at("b") : o->at(0);
|
|
const nlohmann::ordered_json& c_number = c->is_object() ? c->at("b") : c->at(0);
|
|
REQUIRE(c_number.start_pos() == o_number.start_pos());
|
|
REQUIRE(c_number.end_pos() == o_number.end_pos());
|
|
|
|
o = o->is_object() ? &o->at("a") : &o->at(1);
|
|
c = c->is_object() ? &c->at("a") : &c->at(1);
|
|
}
|
|
}
|
|
};
|
|
|
|
// around detail::recursion_depth_limit() (127, 128, and 129 by default)
|
|
const std::size_t limit = nlohmann::detail::recursion_depth_limit();
|
|
|
|
check_conversion(1);
|
|
check_conversion(limit > 0 ? limit - 1 : 0);
|
|
check_conversion(limit);
|
|
check_conversion(limit + 1);
|
|
check_conversion(300);
|
|
}
|
|
|
|
SECTION("exception about the root value (#5987)")
|
|
{
|
|
// the root has no path, but still has a byte range
|
|
const json root = json::parse("true");
|
|
const json nested = json::parse("[1, true]");
|
|
const json constructed = true;
|
|
#if JSON_DIAGNOSTICS
|
|
CHECK_THROWS_WITH_AS(root.get<std::string>(),
|
|
"[json.exception.type_error.302] (bytes 0-4) type must be string, but is boolean", json::type_error);
|
|
CHECK_THROWS_WITH_AS(nested[1].get<std::string>(),
|
|
"[json.exception.type_error.302] (/1) (bytes 4-8) type must be string, but is boolean", json::type_error);
|
|
#else
|
|
CHECK_THROWS_WITH_AS(root.get<std::string>(),
|
|
"[json.exception.type_error.302] (bytes 0-4) type must be string, but is boolean", json::type_error);
|
|
CHECK_THROWS_WITH_AS(nested[1].get<std::string>(),
|
|
"[json.exception.type_error.302] (bytes 4-8) type must be string, but is boolean", json::type_error);
|
|
#endif
|
|
// a value without positions gets no range and no stray whitespace
|
|
CHECK_THROWS_WITH_AS(constructed.get<std::string>(),
|
|
"[json.exception.type_error.302] type must be string, but is boolean", json::type_error);
|
|
}
|
|
|
|
SECTION("JSON patch add to primitive parent (#4292)")
|
|
{
|
|
// the JSON Patch "add" target /foo/bar/baz has a string parent
|
|
// (/foo/bar); the position of that parent is reported in the message
|
|
const json doc = json::parse(R"({"foo":{"bar":"a string"}})");
|
|
const json patch = json::parse(R"([{"op":"add","path":"/foo/bar/baz","value":1}])");
|
|
#if JSON_DIAGNOSTICS
|
|
CHECK_THROWS_WITH_AS(doc.patch(patch),
|
|
"[json.exception.out_of_range.411] (/foo/bar) (bytes 14-24) cannot add value: the JSON Patch 'add' target's parent is of type string, but must be an object or array", json::out_of_range);
|
|
#else
|
|
CHECK_THROWS_WITH_AS(doc.patch(patch),
|
|
"[json.exception.out_of_range.411] (bytes 14-24) cannot add value: the JSON Patch 'add' target's parent is of type string, but must be an object or array", json::out_of_range);
|
|
#endif
|
|
}
|
|
}
|
|
|
|
TEST_CASE("values read from a binary format have no positions")
|
|
{
|
|
// only the JSON lexer knows where a value started and ended
|
|
const json source = {{"a", {1, "x", json::binary({1})}}, {"b", {{"c", true}}}, {"d", nullptr}, {"e", 1.5}};
|
|
const std::vector<std::uint8_t> cbor = json::to_cbor(source);
|
|
|
|
const auto check_no_positions = [](const json & j)
|
|
{
|
|
CHECK(j.start_pos() == std::string::npos);
|
|
CHECK(j.end_pos() == std::string::npos);
|
|
CHECK(j.at("a").start_pos() == std::string::npos);
|
|
CHECK(j.at("a").at(1).end_pos() == std::string::npos);
|
|
CHECK(j.at("b").at("c").start_pos() == std::string::npos);
|
|
};
|
|
|
|
SECTION("DOM parser")
|
|
{
|
|
const json j = json::from_cbor(cbor);
|
|
CHECK(j == source);
|
|
check_no_positions(j);
|
|
}
|
|
|
|
SECTION("DOM parser with a callback")
|
|
{
|
|
json j;
|
|
nlohmann::detail::json_sax_dom_callback_parser<json, decltype(nlohmann::detail::input_adapter(cbor))> sdp(j, [](int /*unused*/, json::parse_event_t /*unused*/, const json& /*unused*/) noexcept
|
|
{
|
|
return true;
|
|
});
|
|
CHECK(json::sax_parse(cbor, &sdp, json::input_format_t::cbor));
|
|
CHECK(j == source);
|
|
check_no_positions(j);
|
|
}
|
|
}
|
|
|
|
TEST_CASE("comparing a value-initialized iterator throws invalid_iterator.212")
|
|
{
|
|
// such an iterator has no value to take the byte positions from; the
|
|
// comparison used to dereference the null pointer instead of throwing
|
|
json j = {1, 2};
|
|
const json::iterator it{};
|
|
CHECK_THROWS_WITH_AS(it == j.begin(), "[json.exception.invalid_iterator.212] cannot compare iterators of different containers", json::invalid_iterator);
|
|
CHECK_THROWS_WITH_AS(it < j.begin(), "[json.exception.invalid_iterator.212] cannot compare iterators of different containers", json::invalid_iterator);
|
|
CHECK_THROWS_WITH_AS(j.begin() == it, "[json.exception.invalid_iterator.212] cannot compare iterators of different containers", json::invalid_iterator);
|
|
}
|