diff --git a/docs/mkdocs/docs/api/basic_json/patch.md b/docs/mkdocs/docs/api/basic_json/patch.md index 432e7c128..0deadc25a 100644 --- a/docs/mkdocs/docs/api/basic_json/patch.md +++ b/docs/mkdocs/docs/api/basic_json/patch.md @@ -34,6 +34,8 @@ Strong guarantee: if an exception is thrown, there are no changes in the JSON va ("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 [`out_of_range.413`](../../home/exceptions.md#jsonexceptionout_of_range413) if a "remove" 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. @@ -75,3 +77,5 @@ is thrown. In any case, the original value is not changed: the patch is applied - 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.13.0. +- Added [`out_of_range.413`](../../home/exceptions.md#jsonexceptionout_of_range413) and stopped silently ignoring a "remove" operation whose target + location has a non-object/non-array parent in version 3.13.0. diff --git a/docs/mkdocs/docs/api/basic_json/patch_inplace.md b/docs/mkdocs/docs/api/basic_json/patch_inplace.md index ae2b5e6a9..99af445d9 100644 --- a/docs/mkdocs/docs/api/basic_json/patch_inplace.md +++ b/docs/mkdocs/docs/api/basic_json/patch_inplace.md @@ -30,6 +30,8 @@ No guarantees, value may be corrupted by an unsuccessful patch operation. ("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 [`out_of_range.413`](../../home/exceptions.md#jsonexceptionout_of_range413) if a "remove" 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. @@ -72,3 +74,5 @@ function throws an exception. - 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.13.0. +- Added [`out_of_range.413`](../../home/exceptions.md#jsonexceptionout_of_range413) and stopped silently ignoring a "remove" operation whose target + location has a non-object/non-array parent in version 3.13.0. diff --git a/docs/mkdocs/docs/home/exceptions.md b/docs/mkdocs/docs/home/exceptions.md index 7c7d22e23..806b9887e 100644 --- a/docs/mkdocs/docs/home/exceptions.md +++ b/docs/mkdocs/docs/home/exceptions.md @@ -933,6 +933,20 @@ BSON stores the length of documents, arrays, strings, and binary values in a sig [`to_bson`](../api/basic_json/to_bson.md) produced documents with negative length prefixes that [`from_bson`](../api/basic_json/from_bson.md) rejected. +### json.exception.out_of_range.413 + +A JSON Patch `remove` 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), a `remove` target must reference a member of an existing object or an element of an existing array; a primitive value (string, number, boolean, etc.) or `null` has no members or elements to remove. + +!!! failure "Example message" + + ``` + cannot remove value: the JSON Patch 'remove' target's parent is of type number, but must be an object or array + ``` + +!!! note + + This exception was added in version 3.13.0. Before that, this situation was silently ignored (the `remove` operation had no effect). + ## 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 be4ccb90f..950c7efc9 100644 --- a/include/nlohmann/json.hpp +++ b/include/nlohmann/json.hpp @@ -4939,6 +4939,12 @@ class basic_json // NOLINT(cppcoreguidelines-special-member-functions,hicpp-spec // note erase performs range check parent.erase(json_pointer::template array_index(last_path)); } + else + { + // the parent of a "remove" target must be an object or array + // (see #5396) + JSON_THROW(out_of_range::create(413, detail::concat("cannot remove value: the JSON Patch 'remove' target's parent is of type ", parent.type_name(), ", but must be an object or array"), &parent)); + } }; // type check: top level value must be an array diff --git a/single_include/nlohmann/json.hpp b/single_include/nlohmann/json.hpp index 31bfb869d..a257b79ab 100644 --- a/single_include/nlohmann/json.hpp +++ b/single_include/nlohmann/json.hpp @@ -26367,6 +26367,12 @@ class basic_json // NOLINT(cppcoreguidelines-special-member-functions,hicpp-spec // note erase performs range check parent.erase(json_pointer::template array_index(last_path)); } + else + { + // the parent of a "remove" target must be an object or array + // (see #5396) + JSON_THROW(out_of_range::create(413, detail::concat("cannot remove value: the JSON Patch 'remove' target's parent is of type ", parent.type_name(), ", but must be an object or array"), &parent)); + } }; // type check: top level value must be an array diff --git a/tests/src/unit-json_patch.cpp b/tests/src/unit-json_patch.cpp index 17d30f189..5d16e8900 100644 --- a/tests/src/unit-json_patch.cpp +++ b/tests/src/unit-json_patch.cpp @@ -1389,6 +1389,61 @@ TEST_CASE("JSON patch - add to a primitive parent (regression #4292)") } } +TEST_CASE("JSON patch - remove with primitive or null parent (regression #5396)") +{ + // Regression test for https://github.com/nlohmann/json/issues/5396 + // + // RFC 6902 (ยง4.2) requires the target location of a "remove" operation + // to exist. When the target's parent resolves to a primitive value or + // null, the operation must fail. Previously operation_remove silently + // did nothing in this case (neither the "is_object" nor the "is_array" + // branch matched, and there was no final "else"), so the patch appeared + // to succeed without changing the document. It now throws + // out_of_range.413. + + SECTION("parent is a primitive (number)") + { + json const doc = {{"a", 1}}; + json const patch = {{{"op", "remove"}, {"path", "/a/b"}}}; +#if JSON_DIAGNOSTICS + CHECK_THROWS_WITH_AS(doc.patch(patch), "[json.exception.out_of_range.413] (/a) cannot remove value: the JSON Patch 'remove' 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.413] cannot remove value: the JSON Patch 'remove' target's parent is of type number, but must be an object or array", json::out_of_range&); +#endif + } + + SECTION("parent is a primitive (string)") + { + json const doc = {{"foo", {{"bar", "a string"}}}}; + json const patch = {{{"op", "remove"}, {"path", "/foo/bar/baz"}}}; +#if JSON_DIAGNOSTICS + CHECK_THROWS_WITH_AS(doc.patch(patch), "[json.exception.out_of_range.413] (/foo/bar) cannot remove value: the JSON Patch 'remove' 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.413] cannot remove value: the JSON Patch 'remove' target's parent is of type string, but must be an object or array", json::out_of_range&); +#endif + } + + SECTION("top-level document is null") + { + json const doc = nullptr; + json const patch = {{{"op", "remove"}, {"path", "/a"}}}; + CHECK_THROWS_WITH_AS(doc.patch(patch), "[json.exception.out_of_range.413] cannot remove value: the JSON Patch 'remove' target's parent is of type null, but must be an object or array", json::out_of_range&); + } + + SECTION("legitimate removes still work") + { + // object member + json const doc1 = {{"a", 1}, {"b", 2}}; + json const patch1 = {{{"op", "remove"}, {"path", "/a"}}}; + CHECK(doc1.patch(patch1) == json({{"b", 2}})); + + // array element + json const doc2 = R"([1, 2, 3])"_json; + json const patch2 = {{{"op", "remove"}, {"path", "/1"}}}; + CHECK(doc2.patch(patch2) == R"([1, 3])"_json); + } +} + TEST_CASE("JSON patch - diff emits array removals in descending index order") { SECTION("array shrunk to empty")