From f9bad81476cff63738ce767d40a7672bd0386d3b Mon Sep 17 00:00:00 2001 From: Niels Lohmann Date: Wed, 28 Oct 2026 20:38:03 +0100 Subject: [PATCH] Add a test for the object key contract and correct the BON8 note Pin the guarantees documented for json_sax::key and the key event of parser callbacks: decoded names, validation before delivery, delivery before the colon, one event per repeated name, and the binary formats. BON8 rejects ill-formed UTF-8 in a name even with sax_parse, so the note no longer lists it with the formats that pass bytes through. See discussion #5955. Signed-off-by: Niels Lohmann --- docs/mkdocs/docs/api/json_sax/key.md | 5 +- tests/src/unit-class_parser.cpp | 130 +++++++++++++++++++++++++++ 2 files changed, 133 insertions(+), 2 deletions(-) diff --git a/docs/mkdocs/docs/api/json_sax/key.md b/docs/mkdocs/docs/api/json_sax/key.md index a92d4230e..4b42353ab 100644 --- a/docs/mkdocs/docs/api/json_sax/key.md +++ b/docs/mkdocs/docs/api/json_sax/key.md @@ -32,8 +32,9 @@ When parsing JSON text, `val` is the decoded object name, not its spelling in th - `key` is called before the following `:` is read, so a parse error may still follow for the same member. - A name that occurs several times in the same object is passed once per occurrence. -For the binary formats, object keys contain no escape sequences: `val` holds the bytes stored in the input. -[`sax_parse`](../basic_json/sax_parse.md) passes them unchanged, without checking them for valid UTF-8. +For the binary formats, object keys contain no escape sequences. For CBOR, MessagePack, UBJSON, and BSON, `val` holds +the bytes stored in the input, and [`sax_parse`](../basic_json/sax_parse.md) passes them unchanged, without checking them +for valid UTF-8. BON8 encodes characters itself, so an ill-formed name is a parse error and `key` is not called for it. ## Examples diff --git a/tests/src/unit-class_parser.cpp b/tests/src/unit-class_parser.cpp index dd78f7ff4..fce6e9f18 100644 --- a/tests/src/unit-class_parser.cpp +++ b/tests/src/unit-class_parser.cpp @@ -3030,3 +3030,133 @@ TEST_CASE("a json_sax_dom_parser keeps the values read before a parse error") CHECK(read_before_error(c.first) == c.second); } } + +TEST_CASE("object keys are delivered decoded and validated") +{ + // json_sax::key and the key event of a parser callback see the decoded name, and + // only after the whole name was scanned and validated + const auto keys_of = [](const std::string & text, const json::input_format_t format = json::input_format_t::json) + { + SaxEventLogger l; + json::sax_parse(text, &l, format); + std::vector keys; + for (const auto& e : l.events) + { + if (e.compare(0, 4, "key(") == 0) + { + keys.push_back(e.substr(4, e.size() - 5)); + } + } + return keys; + }; + + const auto callback_keys_of = [](const std::string & text) + { + std::vector keys; + const json::parser_callback_t cb = [&keys](int /*depth*/, json::parse_event_t event, json & parsed) + { + if (event == json::parse_event_t::key) + { + keys.push_back(parsed.get()); + } + return true; + }; + static_cast(json::parse(text, cb, false)); + return keys; + }; + + SECTION("different spellings of a name yield the same string") + { + const std::string e_acute = "\xc3\xa9"; + const std::string text = std::string(R"({"/":1,"\/":2,"\u002f":3,"\u00e9":4,"\u00E9":5,")") + e_acute + R"(":6})"; + const std::vector expected = {"/", "/", "/", e_acute, e_acute, e_acute}; + CHECK(keys_of(text) == expected); + CHECK(callback_keys_of(text) == expected); + } + + SECTION("escape sequences are decoded") + { + const std::string simple = R"({"\"\\\/\b\f\n\r\t":1})"; + const std::vector simple_expected = {std::string("\"\\/\b\f\n\r\t")}; + CHECK(keys_of(simple) == simple_expected); + CHECK(callback_keys_of(simple) == simple_expected); + + const std::string nul = R"({"\u0000":1})"; + const std::vector nul_expected = {std::string(1, '\0')}; + CHECK(keys_of(nul) == nul_expected); + CHECK(callback_keys_of(nul) == nul_expected); + + const std::string pair = R"({"\ud83d\ude00":1})"; + const std::vector pair_expected = {"\xf0\x9f\x98\x80"}; + CHECK(keys_of(pair) == pair_expected); + CHECK(callback_keys_of(pair) == pair_expected); + } + + SECTION("no normalization and no case conversion") + { + const std::string text = std::string("{\"\xc3\xa9\":1,\"e\xcc\x81\":2,\"A\":3,\"a\":4}"); + const std::vector expected = {"\xc3\xa9", "e\xcc\x81", "A", "a"}; + CHECK(keys_of(text) == expected); + CHECK(callback_keys_of(text) == expected); + } + + SECTION("a name that is not valid is not delivered") + { + const std::vector texts = + { + R"({"\u12G4":1})", // malformed escape + R"({"\udc00":1})", // lone low surrogate + R"({"\ud800":1})", // lone high surrogate + R"({"\ud800\u0041":1})", // high surrogate not followed by a low one + R"({"\x":1})", // unknown escape + std::string("{\"a\x01\":1}"), // control character + std::string("{\"\xff\":1}") // ill-formed UTF-8 + }; + for (const auto& text : texts) + { + CAPTURE(text) + CHECK(!json::accept(text)); + CHECK(keys_of(text).empty()); + CHECK(callback_keys_of(text).empty()); + } + } + + SECTION("a name is delivered before the following colon is read") + { + const std::string text = R"({"a" 1})"; + const std::vector expected = {"a"}; + CHECK(!json::accept(text)); + CHECK(keys_of(text) == expected); + CHECK(callback_keys_of(text) == expected); + } + + SECTION("a repeated name is delivered once per occurrence") + { + const std::string text = R"({"a":1,"a":2})"; + const std::vector expected = {"a", "a"}; + CHECK(keys_of(text) == expected); + CHECK(callback_keys_of(text) == expected); + } + + SECTION("binary formats pass the stored bytes through") + { + const std::string stored = "\xff\\"; + const std::vector expected = {stored}; + const auto keys_of_bytes = [&keys_of](const std::vector& v, json::input_format_t format) + { + return keys_of(std::string(v.begin(), v.end()), format); + }; + + CHECK(keys_of_bytes({0xA1, 0x62, 0xFF, 0x5C, 0x01}, json::input_format_t::cbor) == expected); + CHECK(keys_of_bytes({0x81, 0xA2, 0xFF, 0x5C, 0x01}, json::input_format_t::msgpack) == expected); + CHECK(keys_of_bytes({'{', 'i', 2, 0xFF, 0x5C, 'i', 1, '}'}, json::input_format_t::ubjson) == expected); + CHECK(keys_of_bytes({0x0D, 0, 0, 0, 0x10, 0xFF, 0x5C, 0, 1, 0, 0, 0, 0}, json::input_format_t::bson) == expected); + } + + SECTION("BON8 rejects ill-formed UTF-8 in a name") + { + const std::vector valid = {"\\"}; + CHECK(keys_of(std::string("\x87\x5C\x91"), json::input_format_t::bon8) == valid); + CHECK(keys_of(std::string("\x87\xC3\x28\x91"), json::input_format_t::bon8).empty()); + } +}