From 730b57775d4c8a28e8e3353d741a37126fda1f12 Mon Sep 17 00:00:00 2001 From: Niels Lohmann Date: Wed, 1 Jul 2026 06:47:24 +0200 Subject: [PATCH] :bug: avoid assertion in patch (#5222) --- docs/mkdocs/docs/api/basic_json/patch.md | 4 ++ .../docs/api/basic_json/patch_inplace.md | 4 ++ docs/mkdocs/docs/home/exceptions.md | 14 +++++ include/nlohmann/json.hpp | 21 ++++---- single_include/nlohmann/json.hpp | 21 ++++---- tests/src/unit-diagnostic-positions.cpp | 10 ++++ tests/src/unit-json_patch.cpp | 54 +++++++++++++++++++ 7 files changed, 108 insertions(+), 20 deletions(-) diff --git a/docs/mkdocs/docs/api/basic_json/patch.md b/docs/mkdocs/docs/api/basic_json/patch.md index c0fd3c113..1ec517461 100644 --- a/docs/mkdocs/docs/api/basic_json/patch.md +++ b/docs/mkdocs/docs/api/basic_json/patch.md @@ -32,6 +32,8 @@ Strong guarantee: if an exception is thrown, there are no changes in the JSON va could not be resolved successfully in the current JSON value; example: `"key baz not found"`. - Throws [`out_of_range.405`](../../home/exceptions.md#jsonexceptionout_of_range405) if JSON pointer has no parent ("add", "remove", "move") +- Throws [`out_of_range.411`](../../home/exceptions.md#jsonexceptionout_of_range411) if an "add" operation's target + location has a parent that is neither an object nor an array. - Throws [`other_error.501`](../../home/exceptions.md#jsonexceptionother_error501) if "test" operation was unsuccessful. @@ -71,3 +73,5 @@ is thrown. In any case, the original value is not changed: the patch is applied ## Version history - Added in version 2.0.0. +- Added [`out_of_range.411`](../../home/exceptions.md#jsonexceptionout_of_range411) and stopped relying on an internal assertion when an "add" operation's + target location has a non-object/non-array parent in version 3.12.x. diff --git a/docs/mkdocs/docs/api/basic_json/patch_inplace.md b/docs/mkdocs/docs/api/basic_json/patch_inplace.md index 52e279227..8f88c612f 100644 --- a/docs/mkdocs/docs/api/basic_json/patch_inplace.md +++ b/docs/mkdocs/docs/api/basic_json/patch_inplace.md @@ -28,6 +28,8 @@ No guarantees, value may be corrupted by an unsuccessful patch operation. could not be resolved successfully in the current JSON value; example: `"key baz not found"`. - Throws [`out_of_range.405`](../../home/exceptions.md#jsonexceptionout_of_range405) if JSON pointer has no parent ("add", "remove", "move") +- Throws [`out_of_range.411`](../../home/exceptions.md#jsonexceptionout_of_range411) if an "add" operation's target + location has a parent that is neither an object nor an array. - Throws [`other_error.501`](../../home/exceptions.md#jsonexceptionother_error501) if "test" operation was unsuccessful. @@ -68,3 +70,5 @@ function throws an exception. ## Version history - Added in version 3.11.0. +- Added [`out_of_range.411`](../../home/exceptions.md#jsonexceptionout_of_range411) and stopped relying on an internal assertion when an "add" operation's + target location has a non-object/non-array parent in version 3.12.x. diff --git a/docs/mkdocs/docs/home/exceptions.md b/docs/mkdocs/docs/home/exceptions.md index 0996522b8..d6e626920 100644 --- a/docs/mkdocs/docs/home/exceptions.md +++ b/docs/mkdocs/docs/home/exceptions.md @@ -881,6 +881,20 @@ a JSON pointer exceeds the range of `size_type` (e.g., on 32-bit platforms). array index 18446744073709551616 exceeds size_type ``` +### json.exception.out_of_range.411 + +A JSON Patch `add` operation cannot be applied because the target location's parent is neither an object nor an array. Per [RFC 6902](https://datatracker.ietf.org/doc/html/rfc6902), an `add` target must reference a member of an existing object or an element of an existing array; a primitive value (string, number, boolean, etc.) cannot receive a new member or element. + +!!! failure "Example message" + + ``` + cannot add value: the JSON Patch 'add' target's parent is of type string, but must be an object or array + ``` + +!!! note + + This exception was added in version 3.12.x. Before that, this situation hit an internal assertion (aborting the program in debug builds) or was silently ignored when assertions were disabled. + ## Further exceptions This exception is thrown in case of errors that cannot be classified with the diff --git a/include/nlohmann/json.hpp b/include/nlohmann/json.hpp index 28f203458..581972363 100644 --- a/include/nlohmann/json.hpp +++ b/include/nlohmann/json.hpp @@ -4890,16 +4890,17 @@ class basic_json // NOLINT(cppcoreguidelines-special-member-functions,hicpp-spec break; } - // if there exists a parent, it cannot be primitive - case value_t::string: // LCOV_EXCL_LINE - case value_t::boolean: // LCOV_EXCL_LINE - case value_t::number_integer: // LCOV_EXCL_LINE - case value_t::number_unsigned: // LCOV_EXCL_LINE - case value_t::number_float: // LCOV_EXCL_LINE - case value_t::binary: // LCOV_EXCL_LINE - case value_t::discarded: // LCOV_EXCL_LINE - default: // LCOV_EXCL_LINE - JSON_ASSERT(false); // NOLINT(cert-dcl03-c,hicpp-static-assert,misc-static-assert) LCOV_EXCL_LINE + // the parent of an "add" target must be an object or array + // (see #4292) + case value_t::string: + case value_t::boolean: + case value_t::number_integer: + case value_t::number_unsigned: + case value_t::number_float: + case value_t::binary: + case value_t::discarded: + default: + JSON_THROW(out_of_range::create(411, detail::concat("cannot add value: the JSON Patch 'add' target's parent is of type ", parent.type_name(), ", but must be an object or array"), &parent)); } }; diff --git a/single_include/nlohmann/json.hpp b/single_include/nlohmann/json.hpp index 617e97ff1..dcfb57f35 100644 --- a/single_include/nlohmann/json.hpp +++ b/single_include/nlohmann/json.hpp @@ -25408,16 +25408,17 @@ class basic_json // NOLINT(cppcoreguidelines-special-member-functions,hicpp-spec break; } - // if there exists a parent, it cannot be primitive - case value_t::string: // LCOV_EXCL_LINE - case value_t::boolean: // LCOV_EXCL_LINE - case value_t::number_integer: // LCOV_EXCL_LINE - case value_t::number_unsigned: // LCOV_EXCL_LINE - case value_t::number_float: // LCOV_EXCL_LINE - case value_t::binary: // LCOV_EXCL_LINE - case value_t::discarded: // LCOV_EXCL_LINE - default: // LCOV_EXCL_LINE - JSON_ASSERT(false); // NOLINT(cert-dcl03-c,hicpp-static-assert,misc-static-assert) LCOV_EXCL_LINE + // the parent of an "add" target must be an object or array + // (see #4292) + case value_t::string: + case value_t::boolean: + case value_t::number_integer: + case value_t::number_unsigned: + case value_t::number_float: + case value_t::binary: + case value_t::discarded: + default: + JSON_THROW(out_of_range::create(411, detail::concat("cannot add value: the JSON Patch 'add' target's parent is of type ", parent.type_name(), ", but must be an object or array"), &parent)); } }; diff --git a/tests/src/unit-diagnostic-positions.cpp b/tests/src/unit-diagnostic-positions.cpp index e6e9752df..59c21dc59 100644 --- a/tests/src/unit-diagnostic-positions.cpp +++ b/tests/src/unit-diagnostic-positions.cpp @@ -37,4 +37,14 @@ TEST_CASE("Better diagnostics with positions") CHECK_THROWS_WITH_AS(j.get(), "[json.exception.type_error.302] type must be number, but is string", 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}])"); + 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); + } } diff --git a/tests/src/unit-json_patch.cpp b/tests/src/unit-json_patch.cpp index fa375b227..404dee43c 100644 --- a/tests/src/unit-json_patch.cpp +++ b/tests/src/unit-json_patch.cpp @@ -1334,3 +1334,57 @@ TEST_CASE("JSON patch") } } } + +TEST_CASE("JSON patch - add to a primitive parent (regression #4292)") +{ + // Regression test for https://github.com/nlohmann/json/issues/4292 + // + // An "add" operation whose parent location resolves to a primitive + // (non-container) value must be rejected with a catchable exception. + // Previously this hit JSON_ASSERT(false) in operation_add, which aborts + // the process in debug builds and silently dropped the operation (leaving + // a wrong result) when assertions were compiled out (NDEBUG). It now + // throws out_of_range.411. + // + // The documents below are constructed programmatically (not parsed) so + // they carry no byte positions; the JSON_DIAGNOSTICS path prefix is + // handled by the guards. The exact message with positions is covered in + // unit-diagnostic-positions.cpp. + + SECTION("string parent") + { + json const doc = {{"foo", {{"bar", "a string"}}}}; + json const patch = {{{"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) 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] 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 + } + + SECTION("number parent") + { + json const doc = {{"foo", 1}}; + json const patch = {{{"op", "add"}, {"path", "/foo/bar"}, {"value", 2}}}; +#if JSON_DIAGNOSTICS + CHECK_THROWS_WITH_AS(doc.patch(patch), "[json.exception.out_of_range.411] (/foo) cannot add value: the JSON Patch 'add' target's parent is of type number, 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] cannot add value: the JSON Patch 'add' target's parent is of type number, but must be an object or array", json::out_of_range&); +#endif + } + + SECTION("original two-step sequence from the issue") + { + // The user's two-step patch from #4292: first turn /xyz/1 into a + // string, then try to add a member inside that string. + json const doc = R"( { "xyz": [ { "lmn": "214", "nnp": "001" } ] } )"_json; + json const patch = R"( + [ + { "op": "add", "path": "/xyz/1", "value": "" }, + { "op": "add", "path": "/xyz/1/lmn", "value": "214" } + ] + )"_json; + + CHECK_THROWS_AS(doc.patch(patch), json::out_of_range&); + } +}