Files
logos-protocol/cpp/implementations/plain/json_mapping.cpp
T
Dario LipicarandClaude Opus 5 8b8a358c8b fix: uint64 survives the event path and the plain wire (#30)
* fix(events): the event bridge converts through the canonical helper

setEventListenerStdBridge adapts the universal event callback (name + JSON
string) to the Qt EventCallback (name + QVariantList). It is the event-path
counterpart of callMethodStdBridge, but it did the conversion itself:

    callMethodStdBridge       -> logos::nlohmannToQVariant        (canonical)
    setEventListenerStdBridge -> QJsonDocument::fromJson
                                 + QJsonValue::toVariant          (Qt's parser)

Two consequences, both measured by the LIDL conformance matrix as M6:

  * a uint64 above int64max degraded to a double. Qt 6 backs QJsonValue with
    QCborValue, so integers up to int64 DID survive — only values with no
    integral representation there fell back to double. echoUint(2^64-1) was
    exact while uintEvent(2^64-1) arrived as 1.8446744073709552e+19: same
    value, same process, one hop later.

  * canonical tagged bytes {"_bytes": ...} were not decoded, arriving as a
    QVariantMap where the method path yields a QByteArray. This never showed up
    end-to-end because the undecoded map round-trips to JSON and the python
    client decodes the tag itself — but a C++ or QML event subscriber got a map.

Both now go through logos::nlohmannArgsToQVariantList, which the generated
cdylib emitTrampoline already used. Numbers and bytes no longer depend on
whether a value left the module as a return or as an event.

Not the residue of the codec convergence, despite how M6 was originally
registered. #29 converged six copies of the VALUE codec; this was a seventh
conversion inside an ADAPTER, which that scope never touched. It is also not on
the providers' own path — a Qt provider stores its callback verbatim and a
cdylib provider already converted correctly. The one live caller is the
logoscore daemon's CoreServiceImpl, which forwards every watched module event;
that is why C++ and Rust providers measured identically.

Why it survived: the bridge appeared in the test suite once, in
test_universal_provider_dispatch.cpp, purely to satisfy the pure virtual. No
test asserted anything about an event payload. The method path got 15 contract
tests in #29; the event path got none.

tests: 11 new cells pin the bridge directly — uint64 past int64max, 2^53+1,
int64::min, large integers nested in containers, tagged bytes at top level and
at depth, plus the shapes that already worked (multi-param order, double staying
double, null elements, empty payload, the non-array raw-string fallback) so a
future rewrite cannot quietly drop them. 210/210.

verified: logos-cpp-sdk, logos-qt-sdk, logos-liblogos and logos-logoscore-cli
all green against this build; the conformance matrix goes 156 -> 158 pass with
M6's two cells retired, and the ext table stays 40/40.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(events): pin the signedness rule the convergence brings with it

nlohmannArgsToQVariantList classifies every non-negative integer as unsigned, so
a LIDL `int` event argument now arrives as ULongLong where it used to be
LongLong. That matches what nlohmannToQVariant (the method path) and the cdylib
emitTrampoline already did — the surfaces now agree — but it is an observable
metatype change that nothing asserted.

Pinned in both directions (non-negative -> ULongLong, negative -> LongLong) so
it stays a decision rather than a side effect. Value-level reads are unaffected.

212/212.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(plain): RpcValue can represent a uint64 above int64max

The plain (tcp/tcp_ssl) wire squeezed every unsigned value through int64_t, so a
LIDL `uint` above int64max wrapped — independently in each direction:

    outbound  qvariant_rpc_value.cpp  QMetaType::ULongLong -> int64_t(...)
    inbound   json_mapping.cpp        is_number_unsigned   -> get<int64_t>()

Neither wraps loudly: .get<int64_t>() past int64max returns -1 with no
exception. Two peers both running this code agreed on -1, so nothing looked
broken from inside — and no plain-tier test used an integer outside int32 range.

Measured over real tcp before the fix:

    echoUint(2^63)   -> -9223372036854775808
    echoUint(2^64-1) -> -1

This was never a wire-format constraint. Both codecs carry uint64 natively (CBOR
emits major type 0, `1b ff..ff`) and the envelope's own `id` field already
crossed this wire as uint64_t. Only RpcValue *payloads* could not represent it.

RpcValue gains a uint64_t alternative, used through `makeInteger()` and ONLY for
values above int64max — the sole case where int64_t loses information. Anything
broader would change the representation of every non-negative integer already on
this wire, and since std::variant equality compares the alternative index it
would break comparisons against int64-built values, to fix nothing. Small
unsigned values keep crossing as signed, pinned by a test so the rule stays
visible.

Also fixes an off-by-one in the QJsonValue::Double -> int64 guard while here:
double(int64max) rounds UP to exactly 2^63, so `d <= double(int64max)` admitted
2^63 and then ran int64_t(d) out of range — undefined behaviour, saturating on
arm64 and INT64_MIN on x86-64. Now a strict `<` against 2^63.

tests: 14 new. Both codecs round-trip 2^64-1 flat and nested; negatives stay
signed; the Qt boundary is exact in both directions; the narrow representation
rule and the 2^63 guard are pinned. 226/226.

verified end-to-end, cross-process, with a negative control: the new 64-bit
boundary cases in logos-logoscore-py fail on the pinned protocol over tcp with
exactly the values above, and all 68 pass with this build — on local, tcp and
tcp_ssl alike.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 00:26:32 -03:00

274 lines
9.6 KiB
C++

#include "json_mapping.h"
#include "../../logos_codec.h"
#include <type_traits>
namespace logos::plain {
using json = nlohmann::json;
// ── RpcValue ↔ json ─────────────────────────────────────────────────────────
//
// Mapping:
// null ↔ json null
// bool ↔ json boolean
// int64 ↔ json integer
// double ↔ json number (non-integer)
// string ↔ json string
// bytes ↔ {"_bytes": base64url}
// list ↔ json array
// map ↔ json object (we disambiguate bytes via the "_bytes" key)
//
// JSON has no bytes primitive, so `bytes` round-trip via a tagged object.
// CBOR has native byte strings; when we want a "real" CBOR byte
// representation we can upgrade CborCodec to bypass this hack and use
// `json::binary_t` — for now identical behaviour keeps the code paths
// uniform and tested.
namespace {
json valueToJson(const RpcValue& v);
RpcValue jsonToValue(const json& j);
json valueToJson(const RpcValue& v)
{
if (v.isNull()) return nullptr;
if (v.isBool()) return v.asBool();
if (v.isInt()) return v.asInt();
if (v.isUInt()) return v.asUInt(); // > int64max: nlohmann keeps it as number_unsigned
if (v.isDouble()) return v.asDouble();
if (v.isString()) return v.asString();
if (v.isBytes()) return logos::bytesToJson(v.asBytes().data);
if (v.isList()) {
json arr = json::array();
for (const auto& item : v.asList().items) arr.push_back(valueToJson(item));
return arr;
}
if (v.isMap()) {
json obj = json::object();
for (const auto& [k, val] : v.asMap().entries) obj[k] = valueToJson(val);
return obj;
}
return nullptr;
}
RpcValue jsonToValue(const json& j)
{
if (j.is_null()) return RpcValue{std::monostate{}};
if (j.is_boolean()) return RpcValue{j.get<bool>()};
// is_number_unsigned() is checked FIRST and routed through makeInteger:
// .get<int64_t>() on a value above int64max wraps silently (2^64-1 -> -1)
// with no exception, so a correct peer's uint64 used to arrive as -1.
if (j.is_number_unsigned())
return RpcValue::makeInteger(j.get<uint64_t>());
if (j.is_number_integer())
return RpcValue{j.get<int64_t>()};
if (j.is_number_float()) return RpcValue{j.get<double>()};
if (j.is_string()) return RpcValue{j.get<std::string>()};
if (j.is_array()) {
RpcList list;
list.items.reserve(j.size());
for (const auto& e : j) list.items.push_back(jsonToValue(e));
return RpcValue{std::move(list)};
}
if (j.is_object()) {
// Disambiguate bytes.
if (logos::isTaggedBytes(j)) {
std::vector<uint8_t> bytes;
if (!logos::b64UrlDecodeChecked(j["_bytes"].get<std::string>(), bytes))
throw CodecError("invalid base64url input");
return RpcValue{RpcBytes{std::move(bytes)}};
}
RpcMap map;
for (auto it = j.begin(); it != j.end(); ++it)
map.emplace(it.key(), jsonToValue(it.value()));
return RpcValue{std::move(map)};
}
// CBOR round-trips through nlohmann::json::binary as binary_t. Convert
// to our bytes representation so CborCodec and JsonCodec end up with
// the same logical RpcValue shape.
if (j.is_binary()) {
const auto& b = j.get_binary();
return RpcValue{RpcBytes{std::vector<uint8_t>(b.begin(), b.end())}};
}
return RpcValue{std::monostate{}};
}
// ── Message struct ↔ json helpers ──────────────────────────────────────────
json methodToJson(const MethodMetadata& m)
{
json o = json::object();
o["name"] = m.name;
o["signature"] = m.signature;
o["returnType"] = m.returnType;
o["isInvokable"] = m.isInvokable;
json pa = json::array();
for (const auto& p : m.parameters.items) pa.push_back(valueToJson(p));
o["parameters"] = std::move(pa);
return o;
}
MethodMetadata methodFromJson(const json& j)
{
MethodMetadata m;
m.name = j.value("name", std::string{});
m.signature = j.value("signature", std::string{});
m.returnType = j.value("returnType", std::string{});
m.isInvokable = j.value("isInvokable", true);
if (j.contains("parameters") && j["parameters"].is_array()) {
for (const auto& p : j["parameters"]) m.parameters.items.push_back(jsonToValue(p));
}
return m;
}
json argsToJson(const std::vector<RpcValue>& args)
{
json a = json::array();
for (const auto& v : args) a.push_back(valueToJson(v));
return a;
}
std::vector<RpcValue> argsFromJson(const json& j)
{
std::vector<RpcValue> out;
if (j.is_array()) {
out.reserve(j.size());
for (const auto& e : j) out.push_back(jsonToValue(e));
}
return out;
}
} // anonymous namespace
// ── Public entry points ────────────────────────────────────────────────────
json messageToJson(const AnyMessage& msg)
{
return std::visit([](const auto& m) -> json {
using T = std::decay_t<decltype(m)>;
json o = json::object();
if constexpr (std::is_same_v<T, CallMessage>) {
o["id"] = m.id;
o["authToken"] = m.authToken;
o["object"] = m.object;
o["method"] = m.method;
o["args"] = argsToJson(m.args);
} else if constexpr (std::is_same_v<T, ResultMessage>) {
o["id"] = m.id;
o["ok"] = m.ok;
if (m.ok) {
o["value"] = valueToJson(m.value);
} else {
o["err"] = m.err;
o["errCode"] = m.errCode;
}
} else if constexpr (std::is_same_v<T, SubscribeMessage>) {
o["object"] = m.object;
o["event"] = m.eventName;
} else if constexpr (std::is_same_v<T, UnsubscribeMessage>) {
o["object"] = m.object;
o["event"] = m.eventName;
} else if constexpr (std::is_same_v<T, EventMessage>) {
o["object"] = m.object;
o["event"] = m.eventName;
o["data"] = argsToJson(m.data);
} else if constexpr (std::is_same_v<T, TokenMessage>) {
o["authToken"] = m.authToken;
o["moduleName"] = m.moduleName;
o["token"] = m.token;
} else if constexpr (std::is_same_v<T, MethodsMessage>) {
o["id"] = m.id;
o["authToken"] = m.authToken;
o["object"] = m.object;
} else if constexpr (std::is_same_v<T, MethodsResultMessage>) {
o["id"] = m.id;
o["ok"] = m.ok;
if (m.ok) {
json ma = json::array();
for (const auto& md : m.methods) ma.push_back(methodToJson(md));
o["methods"] = std::move(ma);
} else {
o["err"] = m.err;
}
}
return o;
}, msg);
}
AnyMessage jsonToMessage(MessageType tag, const json& j)
{
if (!j.is_object()) throw CodecError("expected top-level object");
switch (tag) {
case MessageType::Call: {
CallMessage m;
m.id = j.value("id", uint64_t{0});
m.authToken = j.value("authToken", std::string{});
m.object = j.value("object", std::string{});
m.method = j.value("method", std::string{});
if (j.contains("args")) m.args = argsFromJson(j["args"]);
return m;
}
case MessageType::Result: {
ResultMessage m;
m.id = j.value("id", uint64_t{0});
m.ok = j.value("ok", false);
if (m.ok) {
if (j.contains("value")) m.value = jsonToValue(j["value"]);
} else {
m.err = j.value("err", std::string{});
m.errCode = j.value("errCode", std::string{});
}
return m;
}
case MessageType::Subscribe: {
SubscribeMessage m;
m.object = j.value("object", std::string{});
m.eventName = j.value("event", std::string{});
return m;
}
case MessageType::Unsubscribe: {
UnsubscribeMessage m;
m.object = j.value("object", std::string{});
m.eventName = j.value("event", std::string{});
return m;
}
case MessageType::Event: {
EventMessage m;
m.object = j.value("object", std::string{});
m.eventName = j.value("event", std::string{});
if (j.contains("data")) m.data = argsFromJson(j["data"]);
return m;
}
case MessageType::Token: {
TokenMessage m;
m.authToken = j.value("authToken", std::string{});
m.moduleName = j.value("moduleName", std::string{});
m.token = j.value("token", std::string{});
return m;
}
case MessageType::Methods: {
MethodsMessage m;
m.id = j.value("id", uint64_t{0});
m.authToken = j.value("authToken", std::string{});
m.object = j.value("object", std::string{});
return m;
}
case MessageType::MethodsResult: {
MethodsResultMessage m;
m.id = j.value("id", uint64_t{0});
m.ok = j.value("ok", false);
if (m.ok && j.contains("methods") && j["methods"].is_array()) {
for (const auto& md : j["methods"]) m.methods.push_back(methodFromJson(md));
} else if (!m.ok) {
m.err = j.value("err", std::string{});
}
return m;
}
}
throw CodecError("unknown message tag");
}
} // namespace logos::plain