// The canonical LIDL <-> JSON codec (cpp/logos_codec.h). // // This is the single implementation of an encoding that previously existed six // times with divergent semantics, so the tests pin the CONTRACT, not just the // happy path: which shapes decode, which throw, and that bytes stay tagged at // any nesting depth (the case that used to compile and then either throw at // call time or emit untagged number arrays). #include #include "logos_codec.h" #include #include #include #include #include using nlohmann::json; namespace { const std::vector kSpan = {0x00, 0x7f, 0x80, 0xff}; // spans the UTF-8 boundary const char* kSpanB64 = "AH-A_w"; } // namespace // ── leaves ──────────────────────────────────────────────────────────────── TEST(Codec, ScalarsRoundTrip) { EXPECT_EQ(logos::fromJson(logos::toJson(std::string("hi"))), "hi"); EXPECT_EQ(logos::fromJson(logos::toJson(-7)), -7); EXPECT_EQ(logos::fromJson(logos::toJson(9u)), 9u); EXPECT_DOUBLE_EQ(logos::fromJson(logos::toJson(1.5)), 1.5); EXPECT_TRUE(logos::fromJson(logos::toJson(true))); } // The codec itself is width-agnostic — it is a library, usable from any C++ that // has an integer. The 64-bit-only rule is a MODULE CONTRACT enforced by the // cdylib gate (logos-cpp-sdk), which rejects a uint32_t parameter and tells the // author to write uint64_t. Keeping those concerns apart means internal callers // are not forced to widen, while a published module interface cannot disagree // with its declared C++ type about range. TEST(Codec, CodecItselfIsWidthAgnostic) { EXPECT_EQ(logos::fromJson(logos::toJson(42)), 42); EXPECT_EQ(logos::fromJson(logos::toJson(42u)), 42u); EXPECT_EQ(logos::fromJson(logos::toJson(-3)), -3); EXPECT_EQ(logos::fromJson(logos::toJson(7u)), 7u); EXPECT_EQ(logos::fromJson(logos::toJson(255u)), 255u); EXPECT_FLOAT_EQ(logos::fromJson(logos::toJson(0.5f)), 0.5f); } // uint64 above 2^63 must survive: nlohmann keeps unsigned and signed apart, so // the value round-trips rather than wrapping through int64. TEST(Codec, LargeUnsignedSurvives) { const uint64_t big = 18446744073709551615ull; EXPECT_EQ(logos::fromJson(logos::toJson(big)), big); } // A whole-valued double may arrive as an integer (JSON has one number type); // float64 accepts it rather than failing a strict is_number_float check. TEST(Codec, IntegralJsonNumberDecodesAsFloat64) { EXPECT_DOUBLE_EQ(logos::fromJson(json(2)), 2.0); } // ── bytes ───────────────────────────────────────────────────────────────── TEST(Codec, BytesUseTheTaggedForm) { const json j = logos::toJson(kSpan); ASSERT_TRUE(logos::isTaggedBytes(j)); EXPECT_EQ(j["_bytes"], kSpanB64); EXPECT_EQ(logos::fromJson>(j), kSpan); } TEST(Codec, EmptyBytesRoundTrip) { const std::vector empty; const json j = logos::toJson(empty); EXPECT_EQ(j["_bytes"], ""); EXPECT_TRUE(logos::fromJson>(j).empty()); } // The size()==1 check: a map that merely CONTAINS a "_bytes" entry is a map, not // bytes. The lp helper omitted this check while the plain wire enforced it, so // the same value decoded two ways depending on the layer. TEST(Codec, MultiKeyObjectIsNotBytes) { const json j = json{{"_bytes", "AA"}, {"x", 1}}; EXPECT_FALSE(logos::isTaggedBytes(j)); EXPECT_THROW(logos::bytesFromJson(j), logos::CodecError); } // Padding tolerance: a padded encoder on the other side used to yield correct // bytes in one copy, empty in another and None in Rust. TEST(Codec, PaddedBase64Decodes) { EXPECT_EQ(logos::bytesFromJson(json{{"_bytes", "AH-A_w=="}}), kSpan); } // The documented lenient set, for provider-side argument decoding only. TEST(Codec, LenientBytesAcceptsWhatOtherLayersProduce) { EXPECT_EQ(logos::bytesFromJsonLenient(json("ab")), (std::vector{'a', 'b'})); EXPECT_EQ(logos::bytesFromJsonLenient(json(12)), (std::vector{'1', '2'})); EXPECT_EQ(logos::bytesFromJsonLenient(json::array({0, 255})), (std::vector{0x00, 0xff})); // ...and the canonical form still wins over the array reading. EXPECT_EQ(logos::bytesFromJsonLenient(logos::toJson(kSpan)), kSpan); } // ── composition ─────────────────────────────────────────────────────────── TEST(Codec, TypedScalarArraysRoundTripIncludingEmpty) { const std::vector ints = {1, -2, 3}; EXPECT_EQ(logos::fromJson>(logos::toJson(ints)), ints); const std::vector strs; EXPECT_TRUE(logos::fromJson>(logos::toJson(strs)).empty()); EXPECT_TRUE(logos::toJson(strs).is_array()); } TEST(Codec, ListOfBytesTagsEachElement) { const std::vector> list = {kSpan, {}, {0xde, 0xad}}; const json j = logos::toJson(list); ASSERT_TRUE(j.is_array()); ASSERT_EQ(j.size(), 3u); EXPECT_TRUE(logos::isTaggedBytes(j[0])); EXPECT_TRUE(logos::isTaggedBytes(j[1])); // the empty element stays an element EXPECT_EQ(logos::fromJson>>(j), list); } // The case that used to compile and then emit untagged nested number arrays. TEST(Codec, ListOfListOfBytesTagsAtDepth) { const std::vector>> nested = {{kSpan}, {}, {{}, {0x01}}}; const json j = logos::toJson(nested); ASSERT_TRUE(j.is_array()); ASSERT_TRUE(j[0].is_array()); EXPECT_TRUE(logos::isTaggedBytes(j[0][0])); EXPECT_EQ(logos::fromJson>>>(j), nested); } TEST(Codec, MapOfBytesAndMapOfListsCompose) { const std::map> m = {{"a", kSpan}, {"b", {}}}; const json j = logos::toJson(m); ASSERT_TRUE(j.is_object()); EXPECT_TRUE(logos::isTaggedBytes(j["a"])); EXPECT_EQ((logos::fromJson>>(j)), m); const std::map>> deep = {{"k", {kSpan, {}}}}; EXPECT_EQ((logos::fromJson>>>( logos::toJson(deep))), deep); const std::unordered_map um = {{"n", 5}}; EXPECT_EQ((logos::fromJson>(logos::toJson(um))), um); } // `any` stops the recursion — the value passes through byte-identically, so a // LogosMap keeps whatever the peer sent (tagged bytes included). TEST(Codec, AnyPassesThroughVerbatim) { const json payload = json{{"nested", json{{"_bytes", kSpanB64}}}, {"n", 1}}; EXPECT_EQ(logos::fromJson(logos::toJson(payload)), payload); const std::vector anyList = {json(1), json("s"), payload}; EXPECT_EQ(logos::fromJson>(logos::toJson(anyList)), anyList); } // ── failure modes ───────────────────────────────────────────────────────── // A shape mismatch throws with the path, rather than silently substituting a // default. Callers turn this into a structured error; the old behaviour differed // per layer (throw in C++, silently-empty in Rust). TEST(Codec, MismatchThrowsWithPath) { EXPECT_THROW(logos::fromJson(json("nope")), logos::CodecError); EXPECT_THROW(logos::fromJson>(json("nope")), logos::CodecError); try { logos::fromJson>>(json::array({json::array({1, "x"})})); FAIL() << "expected CodecError"; } catch (const logos::CodecError& e) { const std::string what = e.what(); EXPECT_NE(what.find("[0][1]"), std::string::npos) << what; } try { logos::fromJson>(json{{"k", "x"}}); FAIL() << "expected CodecError"; } catch (const logos::CodecError& e) { EXPECT_NE(std::string(e.what()).find(".k"), std::string::npos) << e.what(); } } // The plain wire validates frames with the strict decode: a corrupt base64 body // must be rejected, not silently decoded to fewer bytes. Consumer-facing decodes // stay tolerant (PaddedBase64Decodes above), so both behaviours come from one // implementation instead of four disagreeing copies. TEST(Codec, CheckedDecodeRejectsCorruptInput) { std::vector out; EXPECT_TRUE(logos::b64UrlDecodeChecked("AH-A_w", out)); EXPECT_EQ(out, kSpan); EXPECT_TRUE(logos::b64UrlDecodeChecked("AH-A_w==", out)); // padding tolerated EXPECT_EQ(out, kSpan); EXPECT_FALSE(logos::b64UrlDecodeChecked("AH-A_w!!", out)); // stray character EXPECT_TRUE(out.empty()); EXPECT_FALSE(logos::b64UrlDecodeChecked("AH-A_wQQQ??", out)); EXPECT_FALSE(logos::b64UrlDecodeChecked("A", out)); // impossible length }