From feaaa1f13d8d5e783b2a13cbbe95793795c10f96 Mon Sep 17 00:00:00 2001 From: Marcin Olko Date: Mon, 7 Sep 2026 12:10:59 +0000 Subject: [PATCH 1/5] Implemented fractional v3 with tests Signed-off-by: Marcin Olko --- MODULE.bazel | 7 + providers/flagd/qcbor.BUILD | 16 + providers/flagd/src/evaluator/BUILD | 1 + providers/flagd/src/evaluator/evaluator.cpp | 42 ++ providers/flagd/src/evaluator/flagd_ops.cpp | 310 ++++++----- .../evaluator/flagd_fractional_op_test.cpp | 504 ++++++++++++++---- 6 files changed, 662 insertions(+), 218 deletions(-) create mode 100644 providers/flagd/qcbor.BUILD diff --git a/MODULE.bazel b/MODULE.bazel index 6f2f34f..a06dbc9 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -36,6 +36,13 @@ git_repository( remote = "https://github.com/open-feature/flagd-schemas.git", ) +git_repository( + name = "qcbor", + build_file = "//providers/flagd:qcbor.BUILD", + remote = "https://github.com/laurencelundblade/QCBOR.git", + tag = "v1.6.1", +) + git_repository( name = "nlohmann_json_schema_validator", build_file = "//providers/flagd:json_schema_validator.BUILD", diff --git a/providers/flagd/qcbor.BUILD b/providers/flagd/qcbor.BUILD new file mode 100644 index 0000000..2a3b7a7 --- /dev/null +++ b/providers/flagd/qcbor.BUILD @@ -0,0 +1,16 @@ +load("@rules_cc//cc:defs.bzl", "cc_library") + +cc_library( + name = "qcbor", + srcs = glob([ + "src/*.c", + "src/*.h", + ]), + hdrs = glob([ + "inc/qcbor/*.h", + "inc/*.h", + ]), + includes = ["inc"], + copts = ["-include stdint.h"], + visibility = ["//visibility:public"], +) diff --git a/providers/flagd/src/evaluator/BUILD b/providers/flagd/src/evaluator/BUILD index 263391a..65fdfea 100644 --- a/providers/flagd/src/evaluator/BUILD +++ b/providers/flagd/src/evaluator/BUILD @@ -13,6 +13,7 @@ cc_library( "@abseil-cpp//absl/log", "@abseil-cpp//absl/strings", "@nlohmann_json//:json", + "@qcbor//:qcbor", ], ) diff --git a/providers/flagd/src/evaluator/evaluator.cpp b/providers/flagd/src/evaluator/evaluator.cpp index 1987b5c..4b05540 100644 --- a/providers/flagd/src/evaluator/evaluator.cpp +++ b/providers/flagd/src/evaluator/evaluator.cpp @@ -62,6 +62,44 @@ openfeature::Value JsonToValue(const nlohmann::json& json_val) { return {}; } +nlohmann::json ValueToJson(const openfeature::Value& value) { + if (value.IsBool()) { + return value.AsBool().value(); + } + if (value.IsString()) { + return value.AsString().value(); + } + if (value.IsNumber()) { + std::optional val_i = value.AsInt(); + std::optional val_d = value.AsDouble(); + if (val_i && val_d && static_cast(*val_i) == *val_d) { + return *val_i; + } + return val_d.value_or(0.0); + } + if (value.IsStructure()) { + nlohmann::json obj = nlohmann::json::object(); + const auto* structure = value.AsStructure(); + if (structure != nullptr) { + for (const auto& [key, val] : *structure) { + obj[key] = ValueToJson(val); + } + } + return obj; + } + if (value.IsList()) { + nlohmann::json arr = nlohmann::json::array(); + const auto* list = value.AsList(); + if (list != nullptr) { + for (const auto& val : *list) { + arr.push_back(ValueToJson(val)); + } + } + return arr; + } + return nullptr; +} + std::optional JsonToMetadataValue( const nlohmann::json& json) { if (json.is_boolean()) { @@ -113,6 +151,10 @@ nlohmann::json ContextToJson(const openfeature::EvaluationContext& ctx) { json[key] = *dbl_val; } else if (const auto* bool_val = std::any_cast(&value)) { json[key] = *bool_val; + } else if (const auto* of_val = std::any_cast(&value)) { + json[key] = ValueToJson(*of_val); + } else if (const auto* json_val = std::any_cast(&value)) { + json[key] = *json_val; } else { LOG(WARNING) << "Unsupported attribute type for key: " << key; } diff --git a/providers/flagd/src/evaluator/flagd_ops.cpp b/providers/flagd/src/evaluator/flagd_ops.cpp index 6f621d5..7384ef3 100644 --- a/providers/flagd/src/evaluator/flagd_ops.cpp +++ b/providers/flagd/src/evaluator/flagd_ops.cpp @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -19,6 +20,7 @@ #include "absl/strings/str_split.h" #include "flagd/evaluator/json_logic/json_logic.h" #include "flagd/evaluator/murmur_hash/MurmurHash3.h" +#include "qcbor/qcbor.h" namespace flagd { @@ -217,124 +219,88 @@ class SemanticVersion { }; struct Distribution { - std::string variant; + nlohmann::json variant; int32_t weight; }; -struct FractionalContext { - std::vector distributions; - uint64_t sum_of_weights; -}; - -// Resolves the bucketing value based on the rule and data. -// If the first argument evaluates to a string, it's used as the bucketing -// property. Otherwise, it falls back to flagKey + targetingKey. -absl::StatusOr ResolveBucketingValue( - const json_logic::JsonLogic& eval, const nlohmann::json& values, - const nlohmann::json& data, bool& first_value_used) { - absl::StatusOr bucketing_property_eval = - eval.Apply(values[0], data); - if (!bucketing_property_eval.ok()) return bucketing_property_eval.status(); - - if (bucketing_property_eval.value().is_string()) { - first_value_used = true; - return bucketing_property_eval.value().get(); - } - - first_value_used = false; - // Fallback logic from spec: Concatenate flagKey and targetingKey if property - // is missing - std::string flag_key; - if (data.contains("$flagd") && data["$flagd"].is_object() && - data["$flagd"].contains("flagKey") && - data["$flagd"]["flagKey"].is_string()) { - flag_key = data["$flagd"]["flagKey"].get(); - } - - std::string targeting_key; - if (data.contains("targetingKey") && data["targetingKey"].is_string()) { - targeting_key = data["targetingKey"].get(); +// Encodes a single string key to deterministic CBOR bytes for map key sorting. +std::vector EncodeCborKey(const std::string& key) { + QCBOREncodeContext ctx; + UsefulBuf_MAKE_STACK_UB(buf, 256); + QCBOREncode_Init(&ctx, buf); + QCBOREncode_AddText(&ctx, {key.data(), key.size()}); + UsefulBufC out; + if (QCBOREncode_Finish(&ctx, &out) == QCBOR_SUCCESS) { + return std::vector(static_cast(out.ptr), + static_cast(out.ptr) + out.len); } - return absl::StrCat(flag_key, targeting_key); + UsefulBufC size_info; + QCBOREncode_Init(&ctx, SizeCalculateUsefulBuf); + QCBOREncode_AddText(&ctx, {key.data(), key.size()}); + QCBOREncode_Finish(&ctx, &size_info); + std::vector dyn_buf(size_info.len); + UsefulBuf dyn_ub = {dyn_buf.data(), dyn_buf.size()}; + QCBOREncode_Init(&ctx, dyn_ub); + QCBOREncode_AddText(&ctx, {key.data(), key.size()}); + QCBOREncode_Finish(&ctx, &out); + return std::vector(static_cast(out.ptr), + static_cast(out.ptr) + out.len); } -// Parses the distributions from the values array. -absl::StatusOr ParseDistributions( - const json_logic::JsonLogic& eval, const nlohmann::json& values, - const nlohmann::json& data, bool first_value_used) { - std::vector distributions; - uint64_t sum_of_weights = 0; - - for (size_t i = first_value_used ? 1 : 0; i < values.size(); i++) { - absl::StatusOr item = eval.Apply(values[i], data); - if (!item.ok()) return item.status(); - if (!item.value().is_array() || item.value().empty()) { - return absl::InvalidArgumentError("Invalid distribution element"); +// Encodes nlohmann::json to deterministic CBOR per RFC 8949. +void EncodeJson(QCBOREncodeContext* enc_ctx, const nlohmann::json& data) { + if (data.is_object()) { + QCBOREncode_OpenMap(enc_ctx); + std::vector, std::string>> keys; + keys.reserve(data.size()); + for (auto it = data.begin(); it != data.end(); ++it) { + keys.emplace_back(EncodeCborKey(it.key()), it.key()); } - - if (!item.value()[0].is_string()) { - return absl::InvalidArgumentError("Variant name must be a string"); + std::sort(keys.begin(), keys.end(), [](const auto& lhs, const auto& rhs) { + return lhs.first < rhs.first; + }); + for (const auto& [_, key] : keys) { + QCBOREncode_AddText(enc_ctx, {key.data(), key.size()}); + EncodeJson(enc_ctx, data[key]); } - - int32_t weight = 1; - if (item.value().size() >= 2) { - if (!item.value()[1].is_number()) { - return absl::InvalidArgumentError("Bucket weight must be a number"); - } - weight = item.value()[1].get(); - weight = std::max(weight, 0); + QCBOREncode_CloseMap(enc_ctx); + } else if (data.is_array()) { + QCBOREncode_OpenArray(enc_ctx); + for (const auto& element : data) { + EncodeJson(enc_ctx, element); } - - distributions.push_back({item.value()[0].get(), weight}); - sum_of_weights += weight; - } - - if (distributions.empty()) { - return absl::InvalidArgumentError("No distributions found"); - } - - if (sum_of_weights == 0) { - return absl::InvalidArgumentError("Sum of weights must be positive"); - } - - if (sum_of_weights >= - static_cast(std::numeric_limits::max())) { - return absl::InvalidArgumentError("Sum of weights exceeds maximum limit"); - } - - return FractionalContext{std::move(distributions), sum_of_weights}; -} - -// Calculates the hash value for the given input using MurmurHash3. -absl::StatusOr CalculateHash(const std::string& input) { - if (input.length() > static_cast(std::numeric_limits::max())) { - return absl::InvalidArgumentError( - "Input string is too long for MurmurHash3"); - } - uint32_t hash_value; - MurmurHash3_x86_32(input.data(), static_cast(input.length()), 0, - &hash_value); - return hash_value; -} - -// Calculates the bucket and selects the variant based on the hash value. -absl::StatusOr SelectVariant( - const std::vector& distributions, uint64_t sum_of_weights, - uint32_t hash_value) { - // High-precision bucketing using 64-bit math to distribute hash over - // sum_of_weights - uint64_t bucket = (static_cast(hash_value) * sum_of_weights) >> - std::numeric_limits::digits; - - uint64_t range_end = 0; - for (const Distribution& dist : distributions) { - range_end += dist.weight; - if (bucket < range_end) { - return dist.variant; + QCBOREncode_CloseArray(enc_ctx); + } else if (data.is_string()) { + const std::string& str = data.get(); + QCBOREncode_AddText(enc_ctx, {str.data(), str.size()}); + } else if (data.is_boolean()) { + QCBOREncode_AddBool(enc_ctx, data.get()); + } else if (data.is_number_unsigned()) { + QCBOREncode_AddUInt64(enc_ctx, data.get()); + } else if (data.is_number_integer()) { + int64_t val = data.get(); + if (val >= 0) { + QCBOREncode_AddUInt64(enc_ctx, static_cast(val)); + } else { + QCBOREncode_AddInt64(enc_ctx, val); } + } else if (data.is_number_float()) { + double val = data.get(); + if (val == 0.0) { + QCBOREncode_AddUInt64(enc_ctx, 0); + } else if (std::trunc(val) == val && val >= -9223372036854775808.0 && + val < 18446744073709551616.0) { + if (val < 0.0) { + QCBOREncode_AddInt64(enc_ctx, static_cast(val)); + } else { + QCBOREncode_AddUInt64(enc_ctx, static_cast(val)); + } + } else { + QCBOREncode_AddDouble(enc_ctx, val); + } + } else if (data.is_null()) { + QCBOREncode_AddNULL(enc_ctx); } - - return absl::InternalError("Fractional bucketing failed to find a variant"); } } // namespace @@ -421,30 +387,128 @@ absl::StatusOr Fractional(const json_logic::JsonLogic& eval, "fractional evaluation data is not an array"); } - if (values.size() < 2) { + if (values.empty()) { + return absl::InvalidArgumentError("fractional evaluation data is empty"); + } + + // 1. Get the target property value used for bucketing + absl::StatusOr bucketing_property_eval = + eval.Apply(values[0], data); + if (!bucketing_property_eval.ok()) return bucketing_property_eval.status(); + + if (bucketing_property_eval.value().is_null()) { return absl::InvalidArgumentError( - "fractional evaluation data has length under 2"); + "Fractional evaluation data cannot be null"); } + nlohmann::json bucketing_property_value; bool first_value_used = false; - absl::StatusOr bucketing_property_res = - ResolveBucketingValue(eval, values, data, first_value_used); - if (!bucketing_property_res.ok()) return bucketing_property_res.status(); - absl::StatusOr context_res = - ParseDistributions(eval, values, data, first_value_used); - if (!context_res.ok()) return context_res.status(); + if (bucketing_property_eval.value().is_array()) { + // Shorthand or implicit targetingKey mode + if (!data.is_object() || !data.contains("targetingKey") || + data["targetingKey"].is_null()) { + return absl::InvalidArgumentError( + "Missing targetingKey for fractional evaluation"); + } + if (!data["targetingKey"].is_string()) { + return absl::InvalidArgumentError("targetingKey must be a string"); + } + + std::string flag_key; + if (data.contains("$flagd") && data["$flagd"].is_object() && + data["$flagd"].contains("flagKey") && + data["$flagd"]["flagKey"].is_string()) { + flag_key = data["$flagd"]["flagKey"].get(); + } + + std::string targeting_key = data["targetingKey"].get(); + bucketing_property_value = nlohmann::json::array({flag_key, targeting_key}); + first_value_used = false; + } else { + bucketing_property_value = bucketing_property_eval.value(); + first_value_used = true; + } + + // 2. Parse the fractional distribution + std::vector distributions; + uint64_t sum_of_weights = 0; + + for (size_t i = first_value_used ? 1 : 0; i < values.size(); i++) { + absl::StatusOr item = eval.Apply(values[i], data); + if (!item.ok()) return item.status(); + if (!item.value().is_array() || item.value().empty()) { + return absl::InvalidArgumentError("Invalid distribution element"); + } + + int32_t weight = 1; + if (item.value().size() >= 2 && item.value()[1].is_number()) { + weight = item.value()[1].get(); + if (weight < 0) { + weight = 0; + } + } + + distributions.push_back({item.value()[0], weight}); + sum_of_weights += weight; + } + + if (distributions.empty()) { + return absl::InvalidArgumentError("No distributions found"); + } + + if (sum_of_weights == 0) { + return absl::InvalidArgumentError("Sum of weights must be positive"); + } + + if (sum_of_weights > + static_cast(std::numeric_limits::max())) { + return absl::InvalidArgumentError("Sum of weights exceeds maximum limit"); + } - absl::StatusOr hash_res = - CalculateHash(bucketing_property_res.value()); - if (!hash_res.ok()) return hash_res.status(); + // 3. Serialize hashing value to deterministic CBOR representation using qcbor + QCBOREncodeContext encode_ctx; + UsefulBufC size_info; + QCBORError err; + + QCBOREncode_Init(&encode_ctx, SizeCalculateUsefulBuf); + EncodeJson(&encode_ctx, bucketing_property_value); + err = QCBOREncode_Finish(&encode_ctx, &size_info); + if (err != QCBOR_SUCCESS) { + return absl::InternalError( + absl::StrCat("QCBOR size calculation failed: ", err)); + } - absl::StatusOr variant_res = - SelectVariant(context_res->distributions, context_res->sum_of_weights, - hash_res.value()); - if (!variant_res.ok()) return variant_res.status(); + std::vector buffer(size_info.len); + UsefulBuf cbor_buffer = {buffer.data(), buffer.size()}; - return variant_res.value(); + QCBOREncode_Init(&encode_ctx, cbor_buffer); + EncodeJson(&encode_ctx, bucketing_property_value); + UsefulBufC encoded; + err = QCBOREncode_Finish(&encode_ctx, &encoded); + if (err != QCBOR_SUCCESS) { + return absl::InternalError(absl::StrCat("QCBOR encoding failed: ", err)); + } + + // 4. Calculate MurmurHash3_x86_32 on the CBOR bytes + uint32_t hash_value = 0; + MurmurHash3_x86_32(encoded.ptr, static_cast(encoded.len), 0, + &hash_value); + + // 5. Calculate bucket using high-precision 64-bit integer arithmetic + uint64_t bucket = (static_cast(hash_value) * sum_of_weights) >> + std::numeric_limits::digits; + + // 6. Select variant + uint64_t range_end = 0; + for (const Distribution& dist : distributions) { + range_end += dist.weight; + if (bucket < range_end) { + return dist.variant; + } + } + + return absl::InternalError("Fractional bucketing failed to find a variant"); } } // namespace flagd diff --git a/providers/flagd/tests/evaluator/flagd_fractional_op_test.cpp b/providers/flagd/tests/evaluator/flagd_fractional_op_test.cpp index 5d32f2a..3d5f5ce 100644 --- a/providers/flagd/tests/evaluator/flagd_fractional_op_test.cpp +++ b/providers/flagd/tests/evaluator/flagd_fractional_op_test.cpp @@ -18,9 +18,42 @@ class FlagdOpsTest : public ::testing::Test { } JsonLogic json_logic_; + + json MakeBasicFlagLogic() { + return json::parse(R"({ + "fractional": [ + { "var": "hashing_input" }, + [ "bucket1", 4 ], + [ "bucket2", 4 ], + [ "bucket3", 4 ], + [ "bucket4", 4 ], + [ "bucket5", 4 ], + [ "bucket6", 4 ], + [ "bucket7", 4 ], + [ "bucket8", 4 ], + [ "bucket9", 4 ], + [ "bucket10", 4 ], + [ "bucket11", 4 ], + [ "bucket12", 4 ], + [ "bucket13", 4 ], + [ "bucket14", 4 ], + [ "bucket15", 4 ], + [ "bucket16", 4 ], + [ "bucket17", 4 ], + [ "bucket18", 4 ], + [ "bucket19", 4 ], + [ "bucket20", 4 ], + [ "bucket21", 4 ], + [ "bucket22", 4 ], + [ "bucket23", 4 ], + [ "bucket24", 4 ], + [ "bucket25", 4 ] + ] + })"); + } }; -TEST_F(FlagdOpsTest, FractionalV2BasicDistribution) { +TEST_F(FlagdOpsTest, FractionalV3BasicDistribution) { json data = json::object(); data["$flagd"] = json::object(); data["$flagd"]["flagKey"] = "fractional-flag"; @@ -38,24 +71,24 @@ TEST_F(FlagdOpsTest, FractionalV2BasicDistribution) { ] })"); - // V2 Expected Values + // V3 Expected Values from Gherkin suite data["user"]["name"] = "jack"; - EXPECT_EQ(json_logic_.Apply(logic, data).value(), "hearts"); + EXPECT_EQ(json_logic_.Apply(logic, data).value(), "diamonds"); data["user"]["name"] = "queen"; - EXPECT_EQ(json_logic_.Apply(logic, data).value(), "spades"); + EXPECT_EQ(json_logic_.Apply(logic, data).value(), "diamonds"); data["user"]["name"] = "ten"; EXPECT_EQ(json_logic_.Apply(logic, data).value(), "clubs"); data["user"]["name"] = "nine"; - EXPECT_EQ(json_logic_.Apply(logic, data).value(), "diamonds"); + EXPECT_EQ(json_logic_.Apply(logic, data).value(), "clubs"); data["user"]["name"] = "3"; - EXPECT_EQ(json_logic_.Apply(logic, data).value(), "clubs"); + EXPECT_EQ(json_logic_.Apply(logic, data).value(), "spades"); } -TEST_F(FlagdOpsTest, FractionalV2Shorthand) { +TEST_F(FlagdOpsTest, FractionalV3Shorthand) { json data = json::object(); data["$flagd"] = json::object(); data["$flagd"]["flagKey"] = "fractional-flag-shorthand"; @@ -67,18 +100,42 @@ TEST_F(FlagdOpsTest, FractionalV2Shorthand) { ] })"); - // V2 Expected Values + // V3 Expected Values from Gherkin suite data["targetingKey"] = "jon@company.com"; - EXPECT_EQ(json_logic_.Apply(logic, data).value(), "heads"); + EXPECT_EQ(json_logic_.Apply(logic, data).value(), "tails"); data["targetingKey"] = "jane@company.com"; EXPECT_EQ(json_logic_.Apply(logic, data).value(), "tails"); + + data["targetingKey"] = "user-1"; + EXPECT_EQ(json_logic_.Apply(logic, data).value(), "heads"); + + data["targetingKey"] = "user-2"; + EXPECT_EQ(json_logic_.Apply(logic, data).value(), "tails"); + + data["targetingKey"] = "user-3"; + EXPECT_EQ(json_logic_.Apply(logic, data).value(), "heads"); } -TEST_F(FlagdOpsTest, FractionalV2SharedSeed) { +TEST_F(FlagdOpsTest, FractionalV3SingleEntry) { json data = json::object(); + data["$flagd"] = json::object(); + data["$flagd"]["flagKey"] = "fractional-single-entry-flag"; + data["targetingKey"] = "some-targeting-key"; json logic = json::parse(R"({ + "fractional": [ + [ "single", 1 ] + ] + })"); + + EXPECT_EQ(json_logic_.Apply(logic, data).value(), "single"); +} + +TEST_F(FlagdOpsTest, FractionalV3SharedSeed) { + json data = json::object(); + + json logic_a = json::parse(R"({ "fractional": [ { "cat": [ "shared-seed", @@ -91,122 +148,386 @@ TEST_F(FlagdOpsTest, FractionalV2SharedSeed) { ] })"); - // V2 Expected Values + // V3 Expected Values for flag A data["user"]["name"] = "seven"; - EXPECT_EQ(json_logic_.Apply(logic, data).value(), "hearts"); + EXPECT_EQ(json_logic_.Apply(logic_a, data).value(), "hearts"); data["user"]["name"] = "eight"; - EXPECT_EQ(json_logic_.Apply(logic, data).value(), "diamonds"); + EXPECT_EQ(json_logic_.Apply(logic_a, data).value(), "hearts"); data["user"]["name"] = "nine"; - EXPECT_EQ(json_logic_.Apply(logic, data).value(), "clubs"); + EXPECT_EQ(json_logic_.Apply(logic_a, data).value(), "diamonds"); data["user"]["name"] = "two"; - EXPECT_EQ(json_logic_.Apply(logic, data).value(), "spades"); -} + EXPECT_EQ(json_logic_.Apply(logic_a, data).value(), "diamonds"); -TEST_F(FlagdOpsTest, FractionalV2HashEdgeCases) { - json data = json::object(); - - json logic = json::parse(R"({ + // Flag B shared seed (ace-of-* variants) + json logic_b = json::parse(R"({ "fractional": [ - { "var": "targetingKey" }, - [ "lower", 50 ], - [ "upper", 50 ] + { "cat": [ + "shared-seed", + { "var": "user.name" } + ]}, + [ "ace-of-clubs", 25 ], + [ "ace-of-diamonds", 25 ], + [ "ace-of-hearts", 25 ], + [ "ace-of-spades", 25 ] ] })"); - // hash = 0 - data["targetingKey"] = "ejOoVL"; - EXPECT_EQ(json_logic_.Apply(logic, data).value(), "lower"); + data["user"]["name"] = "seven"; + EXPECT_EQ(json_logic_.Apply(logic_b, data).value(), "ace-of-hearts"); - // hash = 1 - data["targetingKey"] = "bY9fO-"; - EXPECT_EQ(json_logic_.Apply(logic, data).value(), "lower"); + data["user"]["name"] = "eight"; + EXPECT_EQ(json_logic_.Apply(logic_b, data).value(), "ace-of-hearts"); - // hash = 2147483647 (INT32_MAX) - data["targetingKey"] = "SI7p-"; - EXPECT_EQ(json_logic_.Apply(logic, data).value(), "lower"); + data["user"]["name"] = "nine"; + EXPECT_EQ(json_logic_.Apply(logic_b, data).value(), "ace-of-diamonds"); - // hash = 2147483648 (INT32_MIN when cast to signed 32-bit - critical - // threshold) - data["targetingKey"] = "6LvT0"; - EXPECT_EQ(json_logic_.Apply(logic, data).value(), "upper"); + data["user"]["name"] = "two"; + EXPECT_EQ(json_logic_.Apply(logic_b, data).value(), "ace-of-diamonds"); +} - // hash = 4294967295 (UINT32_MAX / -1 when signed) - data["targetingKey"] = "ceQdGm"; - EXPECT_EQ(json_logic_.Apply(logic, data).value(), "upper"); +TEST_F(FlagdOpsTest, FractionalV3BasicTypes) { + json basic_logic = MakeBasicFlagLogic(); + auto eval = [&](const json& val) { + json data = json::object(); + data["hashing_input"] = val; + auto res = json_logic_.Apply(basic_logic, data); + EXPECT_TRUE(res.ok()) << res.status().message(); + return res.value(); + }; + + EXPECT_EQ(eval(true), "bucket5"); + EXPECT_EQ(eval(false), "bucket1"); + EXPECT_EQ(eval("user1"), "bucket22"); + EXPECT_EQ(eval("user2"), "bucket16"); + EXPECT_EQ(eval(123), "bucket20"); + EXPECT_EQ(eval(456), "bucket12"); + EXPECT_EQ(eval(1.23), "bucket14"); + EXPECT_EQ(eval(4.56), "bucket18"); + EXPECT_EQ(eval("123"), "bucket22"); + EXPECT_EQ(eval("true"), "bucket24"); + EXPECT_EQ(eval("false"), "bucket1"); + EXPECT_EQ(eval("null"), "bucket8"); + EXPECT_EQ(eval("1.23"), "bucket4"); + EXPECT_EQ(eval(0), "bucket8"); } -TEST_F(FlagdOpsTest, FractionalV2NestedIfVariantName) { - json data = json::object(); +TEST_F(FlagdOpsTest, FractionalV3FloatMapping) { + json basic_logic = MakeBasicFlagLogic(); + auto eval = [&](const json& val) { + json data = json::object(); + data["hashing_input"] = val; + auto res = json_logic_.Apply(basic_logic, data); + EXPECT_TRUE(res.ok()) << res.status().message(); + return res.value(); + }; + + EXPECT_EQ(eval(1.0), "bucket23"); + EXPECT_EQ(eval(1), "bucket23"); + EXPECT_EQ(eval(1.0000000000000001), "bucket23"); + EXPECT_EQ(eval(-2.0), "bucket12"); + EXPECT_EQ(eval(-2), "bucket12"); + EXPECT_EQ(eval(9007199254740992.0), "bucket10"); + EXPECT_EQ(eval(int64_t{9007199254740992LL}), "bucket10"); +} - json logic = json::parse(R"({ - "fractional": [ - { "var": "targetingKey" }, - [ - { - "if": [ - { "==": [{ "var": "tier" }, "premium"] }, - "premium", - "standard" - ] - }, - 50 - ], - [ "standard", 50 ] - ] - })"); +TEST_F(FlagdOpsTest, FractionalV3ZeroValues) { + json basic_logic = MakeBasicFlagLogic(); + auto eval = [&](const json& val) { + json data = json::object(); + data["hashing_input"] = val; + auto res = json_logic_.Apply(basic_logic, data); + EXPECT_TRUE(res.ok()) << res.status().message(); + return res.value(); + }; + + EXPECT_EQ(eval(0.0), "bucket8"); + EXPECT_EQ(eval(-0.0), "bucket8"); + EXPECT_EQ(eval(0), "bucket8"); +} - data["targetingKey"] = "jon@company.com"; - data["tier"] = "premium"; - EXPECT_EQ(json_logic_.Apply(logic, data).value(), "premium"); +TEST_F(FlagdOpsTest, FractionalV3IntegerLimits) { + json basic_logic = MakeBasicFlagLogic(); + auto eval = [&](const json& val) { + json data = json::object(); + data["hashing_input"] = val; + auto res = json_logic_.Apply(basic_logic, data); + EXPECT_TRUE(res.ok()) << res.status().message(); + return res.value(); + }; + + EXPECT_EQ(eval(int64_t{2147483647LL}), "bucket10"); + EXPECT_EQ(eval(int64_t{2147483648LL}), "bucket25"); + EXPECT_EQ(eval(int64_t{-2147483648LL}), "bucket8"); + EXPECT_EQ(eval(int64_t{-2147483649LL}), "bucket18"); + EXPECT_EQ(eval(int64_t{9007199254740991LL}), "bucket2"); + EXPECT_EQ(eval(23), "bucket21"); + EXPECT_EQ(eval(24), "bucket11"); + EXPECT_EQ(eval(255), "bucket13"); + EXPECT_EQ(eval(256), "bucket13"); + EXPECT_EQ(eval(65535), "bucket4"); + EXPECT_EQ(eval(65536), "bucket11"); + EXPECT_EQ(eval(int64_t{4294967295LL}), "bucket14"); + EXPECT_EQ(eval(int64_t{4294967296LL}), "bucket19"); + EXPECT_EQ(eval(-24), "bucket4"); + EXPECT_EQ(eval(-25), "bucket16"); +} - data["tier"] = "basic"; - EXPECT_EQ(json_logic_.Apply(logic, data).value(), "standard"); +TEST_F(FlagdOpsTest, FractionalV3FloatLimits) { + json basic_logic = MakeBasicFlagLogic(); + auto eval = [&](const json& val) { + json data = json::object(); + data["hashing_input"] = val; + auto res = json_logic_.Apply(basic_logic, data); + EXPECT_TRUE(res.ok()) << res.status().message(); + return res.value(); + }; + + EXPECT_EQ(eval(1e20), "bucket19"); + EXPECT_EQ(eval(-1e20), "bucket25"); + EXPECT_EQ(eval(65504.0), "bucket16"); + EXPECT_EQ(eval(-65504.0), "bucket9"); + EXPECT_EQ(eval(0.00006103515625), "bucket22"); + EXPECT_EQ(eval(0.000000059604644775390625), "bucket23"); + EXPECT_EQ(eval(65505.0), "bucket17"); + EXPECT_EQ(eval(-65505.0), "bucket17"); + EXPECT_EQ(eval(3.4028234663852886e+38), "bucket10"); + EXPECT_EQ(eval(-3.4028234663852886e+38), "bucket3"); + EXPECT_EQ(eval(1.1754943508222875e-38), "bucket24"); + EXPECT_EQ(eval(3.5e+38), "bucket1"); + EXPECT_EQ(eval(-3.5e+38), "bucket6"); + EXPECT_EQ(eval(1.7976931348623157e+308), "bucket17"); + EXPECT_EQ(eval(-1.7976931348623157e+308), "bucket9"); + EXPECT_EQ(eval(2.2250738585072014e-308), "bucket11"); + EXPECT_EQ(eval(4.9406564584124654e-324), "bucket2"); +} - data["targetingKey"] = "user1"; - data["tier"] = "premium"; - EXPECT_EQ(json_logic_.Apply(logic, data).value(), "standard"); +TEST_F(FlagdOpsTest, FractionalV3MapKeyOrdering) { + json basic_logic = MakeBasicFlagLogic(); + auto eval = [&](const json& val) { + json data = json::object(); + data["hashing_input"] = val; + auto res = json_logic_.Apply(basic_logic, data); + EXPECT_TRUE(res.ok()) << res.status().message(); + return res.value(); + }; + + EXPECT_EQ(eval(json::parse(R"({"a": 1, "b": 2})")), "bucket23"); + EXPECT_EQ(eval(json::parse(R"({"b": 2, "a": 1})")), "bucket23"); + EXPECT_EQ(eval(json::parse(R"({"a": 1, "b": 2, "c": 3})")), "bucket18"); + EXPECT_EQ(eval(json::parse(R"({"c": 3, "a": 1, "b": 2})")), "bucket18"); + EXPECT_EQ(eval(json::parse(R"({"z": 1, "aa": 2})")), "bucket21"); + EXPECT_EQ(eval(json::parse(R"({"aa": 2, "z": 1})")), "bucket21"); + EXPECT_EQ(eval(json::parse(R"({"a": {"b": 1}, "b": 2})")), "bucket9"); + EXPECT_EQ(eval(json::parse(R"({"b": 2, "a": {"b": 1}})")), "bucket9"); + EXPECT_EQ( + eval(json::parse(R"({"a": true, "bbb": 1, "c": "text", "dddd": 2.5})")), + "bucket25"); + EXPECT_EQ( + eval(json::parse(R"({"c": "text", "bbb": 1, "a": true, "dddd": 2.5})")), + "bucket25"); + EXPECT_EQ(eval(json::parse(R"({"aaa": 1, "ÿ": 2})")), "bucket12"); + EXPECT_EQ(eval(json::parse(R"({"ÿ": 2, "aaa": 1})")), "bucket12"); + EXPECT_EQ(eval(json::parse(R"({"": 2, "abc": 1})")), "bucket25"); + EXPECT_EQ(eval(json::parse(R"({"key": -0.0})")), "bucket10"); + EXPECT_EQ(eval(json::parse(R"({"key": 0})")), "bucket10"); + EXPECT_EQ(eval(json::parse(R"({"key": 0.0})")), "bucket10"); + EXPECT_EQ(eval(json::parse(R"({"b": -0.0, "a": {"b": 1}, "c": 0.0})")), + "bucket8"); + EXPECT_EQ(eval(json::parse(R"({"c": 0.0, "b": -0.0, "a": {"b": 1}})")), + "bucket8"); + EXPECT_EQ( + eval(json::parse(R"({"café": "façade", "b": {"résumé": {"ÿ": true}}})")), + "bucket22"); +} - data["tier"] = "basic"; - EXPECT_EQ(json_logic_.Apply(logic, data).value(), "standard"); +TEST_F(FlagdOpsTest, FractionalV3AdvancedStructures) { + json basic_logic = MakeBasicFlagLogic(); + auto eval = [&](const json& val) { + json data = json::object(); + data["hashing_input"] = val; + auto res = json_logic_.Apply(basic_logic, data); + EXPECT_TRUE(res.ok()) << res.status().message(); + return res.value(); + }; + + EXPECT_EQ(eval(json::parse(R"({})")), "bucket3"); + EXPECT_EQ(eval(json::parse(R"({"a": {}})")), "bucket20"); + EXPECT_EQ(eval(json::parse(R"({"b": 2, "a": {}})")), "bucket20"); + EXPECT_EQ(eval(json::parse(R"({"a": {}, "b": {}})")), "bucket21"); + EXPECT_EQ(eval(json::parse(R"({"a": {}, "b": []})")), "bucket7"); + EXPECT_EQ(eval(json::parse(R"({"c": 0.0, "a": []})")), "bucket18"); + EXPECT_EQ(eval(json::parse(R"({"c": 0.0, "b": -0.0, "a": {"b": []}})")), + "bucket9"); + EXPECT_EQ(eval(json::parse(R"({"a": []})")), "bucket12"); + EXPECT_EQ(eval(json::parse(R"({"a": [1, [], 3], "b": null})")), "bucket3"); + EXPECT_EQ(eval(json::parse(R"({"a": ["a", "b", {}], "b": [1, null, 2]})")), + "bucket23"); + EXPECT_EQ(eval(json::parse(R"({"a": [1, "two", true, [], null]})")), + "bucket23"); + EXPECT_EQ(eval(json::parse(R"({"a": [false, 2.5, ""], "b": [], "c": null})")), + "bucket15"); + EXPECT_EQ(eval(json::parse(R"({"x": [{"a": 1}, {"b": 2}, {"b": null}]})")), + "bucket3"); + EXPECT_EQ(eval(json::parse(R"({"x": [{"a": {"b": []}}]})")), "bucket16"); + EXPECT_EQ(eval(json::parse(R"({"x": [{"a": {"b": null}}]})")), "bucket6"); + EXPECT_EQ(eval(json::parse(R"({"x": [{"a": {"b": {}}}]})")), "bucket2"); + EXPECT_EQ(eval(json::parse(R"({"": null})")), "bucket25"); + EXPECT_EQ(eval(json::parse(R"({"": {"": {"": ""}}})")), "bucket11"); + EXPECT_EQ(eval(json::parse(R"({"a": [[[[[]]]]]})")), "bucket15"); + EXPECT_EQ(eval(json::parse(R"({"a": [[[[null]]]]})")), "bucket7"); + EXPECT_EQ(eval(json::parse(R"({"a": {"b": {"c": {"d": {}}}}})")), "bucket21"); + EXPECT_EQ(eval(json::parse(R"({"a": {"b": {"c": {"d": null}}}})")), + "bucket19"); } -TEST_F(FlagdOpsTest, FractionalV2NestedVarVariantName) { - json data = json::object(); +TEST_F(FlagdOpsTest, FractionalV3StringLengthBoundaries) { + json basic_logic = MakeBasicFlagLogic(); + auto eval = [&](const json& val) { + json data = json::object(); + data["hashing_input"] = val; + auto res = json_logic_.Apply(basic_logic, data); + EXPECT_TRUE(res.ok()) << res.status().message(); + return res.value(); + }; + + EXPECT_EQ(eval(""), "bucket4"); + EXPECT_EQ(eval("a"), "bucket6"); + EXPECT_EQ(eval("12345678901234567890123"), "bucket4"); + EXPECT_EQ(eval("123456789012345678901234"), "bucket14"); + EXPECT_EQ(eval(std::string(255, 'a')), "bucket18"); + EXPECT_EQ(eval(std::string(256, 'a')), "bucket7"); +} + +TEST_F(FlagdOpsTest, FractionalV3Utf8Strings) { + json basic_logic = MakeBasicFlagLogic(); + auto eval = [&](const json& val) { + json data = json::object(); + data["hashing_input"] = val; + auto res = json_logic_.Apply(basic_logic, data); + EXPECT_TRUE(res.ok()) << res.status().message(); + return res.value(); + }; + + EXPECT_EQ(eval("あいうえおかき"), "bucket17"); + EXPECT_EQ(eval("あいうえおかきく"), "bucket16"); + EXPECT_EQ(eval("ééééééééééé"), "bucket16"); + EXPECT_EQ(eval("éééééééééééé"), "bucket19"); + EXPECT_EQ(eval("café façade résumé café"), "bucket9"); + EXPECT_EQ(eval("A\\u0000B"), "bucket15"); + EXPECT_EQ(eval("e\\u0301"), "bucket7"); + EXPECT_EQ(eval("\\uD83D\\uDE00"), "bucket23"); +} +TEST_F(FlagdOpsTest, FractionalV3ImplicitTargetingKeyValidation) { json logic = json::parse(R"({ "fractional": [ - { "var": "targetingKey" }, - [ { "var": "color" }, 50 ], - [ "blue", 50 ] + [ "heads" ], + [ "tails", 1 ] ] })"); - data["targetingKey"] = "jon@company.com"; + // Missing targetingKey returns error + json data_missing = json::object(); + data_missing["$flagd"] = json::object(); + data_missing["$flagd"]["flagKey"] = "shorthand-flag"; + EXPECT_FALSE(json_logic_.Apply(logic, data_missing).ok()); + + // Integer targetingKey returns error + json data_int = data_missing; + data_int["targetingKey"] = 12345; + EXPECT_FALSE(json_logic_.Apply(logic, data_int).ok()); + + // Boolean targetingKey returns error + json data_bool = data_missing; + data_bool["targetingKey"] = true; + EXPECT_FALSE(json_logic_.Apply(logic, data_bool).ok()); +} - data["color"] = "red"; - EXPECT_EQ(json_logic_.Apply(logic, data).value(), "red"); +TEST_F(FlagdOpsTest, FractionalV3NullInputRejection) { + // First element evaluating to null (e.g. missing variable) + json logic_missing_var = json::parse(R"({ + "fractional": [ + { "var": "missing_key" }, + [ "one", 50 ], + [ "two", 50 ] + ] + })"); + EXPECT_FALSE(json_logic_.Apply(logic_missing_var, json::object()).ok()); - data["color"] = "green"; - EXPECT_EQ(json_logic_.Apply(logic, data).value(), "green"); + // First element literal null + json logic_null = json::parse(R"({ + "fractional": [ + null, + [ "one", 50 ], + [ "two", 50 ] + ] + })"); + EXPECT_FALSE(json_logic_.Apply(logic_null, json::object()).ok()); +} - data["targetingKey"] = "user1"; - data["color"] = "red"; - EXPECT_EQ(json_logic_.Apply(logic, data).value(), "blue"); +TEST_F(FlagdOpsTest, FractionalV3WeightsEdgeCases) { + // All-zero bucket weights must return error + json logic_zeros = json::parse(R"({ + "fractional": [ + { "var": "targetingKey" }, + [ "one", 0 ], + [ "two", 0 ] + ] + })"); + json data = {{"targetingKey", "any-user"}}; + EXPECT_FALSE(json_logic_.Apply(logic_zeros, data).ok()); - // Fallbacks handled by outer engine, json_logic returns the literal var - // evaluations - data["targetingKey"] = "jon@company.com"; - data["color"] = "yellow"; - EXPECT_EQ(json_logic_.Apply(logic, data).value(), "yellow"); + // Negative bucket weight is clamped to zero: ["one", -50] -> ["one", 0] + json logic_neg = json::parse(R"({ + "fractional": [ + { "var": "targetingKey" }, + [ "one", -50 ], + [ "two", 100 ] + ] + })"); + EXPECT_EQ(json_logic_.Apply(logic_neg, data).value(), "two"); +} - data["color"] = ""; - EXPECT_EQ(json_logic_.Apply(logic, data).value(), ""); +TEST_F(FlagdOpsTest, FractionalV3AsCondition) { + // Fractional as condition evaluating truthy path + json logic_true = json::parse(R"({ + "if": [ + { + "fractional": [ + [ false, 0 ], + [ true, 100 ] + ] + }, + "big", + "small" + ] + })"); + json data = json::object(); + data["$flagd"] = {{"flagKey", "fractional-as-condition-flag"}}; + data["targetingKey"] = "some-targeting-key"; + EXPECT_EQ(json_logic_.Apply(logic_true, data).value(), "big"); + + // Fractional as condition evaluating false path + json logic_false = json::parse(R"({ + "if": [ + { + "fractional": [ + [ false, 100 ], + [ true, 0 ] + ] + }, + "big", + "small" + ] + })"); + data["$flagd"] = {{"flagKey", "fractional-as-condition-false-flag"}}; + EXPECT_EQ(json_logic_.Apply(logic_false, data).value(), "small"); } -TEST_F(FlagdOpsTest, FractionalV2NestedWeightLogic) { +TEST_F(FlagdOpsTest, FractionalV3NestedWeightLogic) { json data = json::object(); json logic = json::parse(R"({ @@ -232,11 +553,4 @@ TEST_F(FlagdOpsTest, FractionalV2NestedWeightLogic) { data["tier"] = "basic"; EXPECT_EQ(json_logic_.Apply(logic, data).value(), "blue"); - - data["targetingKey"] = "user1"; - data["tier"] = "premium"; - EXPECT_EQ(json_logic_.Apply(logic, data).value(), "red"); - - data["tier"] = "basic"; - EXPECT_EQ(json_logic_.Apply(logic, data).value(), "blue"); } From 8c14f732e19581856748f398ac7fd82de06f3f7a Mon Sep 17 00:00:00 2001 From: Marcin Olko Date: Mon, 7 Sep 2026 13:16:34 +0000 Subject: [PATCH 2/5] Fixed linter Signed-off-by: Marcin Olko --- providers/flagd/src/evaluator/flagd_ops.cpp | 62 +++++++++++---------- 1 file changed, 34 insertions(+), 28 deletions(-) diff --git a/providers/flagd/src/evaluator/flagd_ops.cpp b/providers/flagd/src/evaluator/flagd_ops.cpp index 7384ef3..00ac283 100644 --- a/providers/flagd/src/evaluator/flagd_ops.cpp +++ b/providers/flagd/src/evaluator/flagd_ops.cpp @@ -32,18 +32,19 @@ bool HasLeadingZero(std::string_view str) { } // Parses number according to SemVer 2.0.0 specification. -absl::Status ParseSemVerNum(std::string_view num_str, std::string_view name, - uint64_t* out) { +absl::StatusOr ParseSemVerNum(std::string_view num_str, + std::string_view name) { if (HasLeadingZero(num_str)) { return absl::InvalidArgumentError(absl::StrCat( name, " version MUST NOT contain leading zeros: ", num_str)); } - if (!absl::SimpleAtoi(num_str, out)) { + uint64_t out = 0; + if (!absl::SimpleAtoi(num_str, &out)) { return absl::InvalidArgumentError( absl::StrCat("Invalid SemVer ", name, " digits: ", num_str)); } - return absl::OkStatus(); -}; + return out; +} // Evaluates and retrieves a fixed number of string arguments from JsonLogic. absl::StatusOr> GetStrings( @@ -118,26 +119,29 @@ class SemanticVersion { absl::StrCat("Invalid SemVer core: ", core)); } - uint64_t major = 0; - uint64_t minor = 0; - uint64_t patch = 0; - if (absl::Status status = ParseSemVerNum(core_parts[0], "Major", &major); - !status.ok()) { - return status; + absl::StatusOr major = ParseSemVerNum(core_parts[0], "Major"); + if (!major.ok()) { + return major.status(); } + uint64_t minor = 0; if (core_parts.size() >= 2) { - if (absl::Status status = ParseSemVerNum(core_parts[1], "Minor", &minor); - !status.ok()) { - return status; + absl::StatusOr minor_res = + ParseSemVerNum(core_parts[1], "Minor"); + if (!minor_res.ok()) { + return minor_res.status(); } + minor = *minor_res; } + uint64_t patch = 0; if (core_parts.size() == 3) { - if (absl::Status status = ParseSemVerNum(core_parts[2], "Patch", &patch); - !status.ok()) { - return status; + absl::StatusOr patch_res = + ParseSemVerNum(core_parts[2], "Patch"); + if (!patch_res.ok()) { + return patch_res.status(); } + patch = *patch_res; } // 4. Parse pre-release identifiers @@ -162,7 +166,7 @@ class SemanticVersion { } } - return SemanticVersion(major, minor, patch, std::move(pre_release)); + return SemanticVersion(*major, minor, patch, std::move(pre_release)); } // Compares two SemanticVersion objects based on SemVer 2.0.0 precedence @@ -223,10 +227,12 @@ struct Distribution { int32_t weight; }; +constexpr size_t kStackKeyBufferSize = 256; + // Encodes a single string key to deterministic CBOR bytes for map key sorting. std::vector EncodeCborKey(const std::string& key) { QCBOREncodeContext ctx; - UsefulBuf_MAKE_STACK_UB(buf, 256); + UsefulBuf_MAKE_STACK_UB(buf, kStackKeyBufferSize); QCBOREncode_Init(&ctx, buf); QCBOREncode_AddText(&ctx, {key.data(), key.size()}); UsefulBufC out; @@ -253,13 +259,14 @@ void EncodeJson(QCBOREncodeContext* enc_ctx, const nlohmann::json& data) { QCBOREncode_OpenMap(enc_ctx); std::vector, std::string>> keys; keys.reserve(data.size()); - for (auto it = data.begin(); it != data.end(); ++it) { - keys.emplace_back(EncodeCborKey(it.key()), it.key()); + for (const auto& item : data.items()) { + keys.emplace_back(EncodeCborKey(item.key()), item.key()); } std::sort(keys.begin(), keys.end(), [](const auto& lhs, const auto& rhs) { return lhs.first < rhs.first; }); - for (const auto& [_, key] : keys) { + for (const auto& key_pair : keys) { + const std::string& key = key_pair.second; QCBOREncode_AddText(enc_ctx, {key.data(), key.size()}); EncodeJson(enc_ctx, data[key]); } @@ -285,11 +292,13 @@ void EncodeJson(QCBOREncodeContext* enc_ctx, const nlohmann::json& data) { QCBOREncode_AddInt64(enc_ctx, val); } } else if (data.is_number_float()) { + constexpr double kMinInt64AsDouble = -9223372036854775808.0; // -2^63 + constexpr double kMaxUInt64LimitAsDouble = 18446744073709551616.0; // 2^64 double val = data.get(); if (val == 0.0) { QCBOREncode_AddUInt64(enc_ctx, 0); - } else if (std::trunc(val) == val && val >= -9223372036854775808.0 && - val < 18446744073709551616.0) { + } else if (std::trunc(val) == val && val >= kMinInt64AsDouble && + val < kMaxUInt64LimitAsDouble) { if (val < 0.0) { QCBOREncode_AddInt64(enc_ctx, static_cast(val)); } else { @@ -443,10 +452,7 @@ absl::StatusOr Fractional(const json_logic::JsonLogic& eval, int32_t weight = 1; if (item.value().size() >= 2 && item.value()[1].is_number()) { - weight = item.value()[1].get(); - if (weight < 0) { - weight = 0; - } + weight = std::max(item.value()[1].get(), 0); } distributions.push_back({item.value()[0], weight}); From 79dfde1bcefc99a87404ecaf1510c7fec70e1ba0 Mon Sep 17 00:00:00 2001 From: Marcin Olko Date: Mon, 7 Sep 2026 14:37:09 +0000 Subject: [PATCH 3/5] Improved the code Signed-off-by: Marcin Olko --- providers/flagd/src/evaluator/flagd_ops.cpp | 23 ++++++++------------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/providers/flagd/src/evaluator/flagd_ops.cpp b/providers/flagd/src/evaluator/flagd_ops.cpp index 00ac283..361fabc 100644 --- a/providers/flagd/src/evaluator/flagd_ops.cpp +++ b/providers/flagd/src/evaluator/flagd_ops.cpp @@ -292,13 +292,9 @@ void EncodeJson(QCBOREncodeContext* enc_ctx, const nlohmann::json& data) { QCBOREncode_AddInt64(enc_ctx, val); } } else if (data.is_number_float()) { - constexpr double kMinInt64AsDouble = -9223372036854775808.0; // -2^63 - constexpr double kMaxUInt64LimitAsDouble = 18446744073709551616.0; // 2^64 double val = data.get(); - if (val == 0.0) { - QCBOREncode_AddUInt64(enc_ctx, 0); - } else if (std::trunc(val) == val && val >= kMinInt64AsDouble && - val < kMaxUInt64LimitAsDouble) { + if (std::trunc(val) == val && val <= static_cast(INT64_MAX) && + val >= static_cast(INT64_MIN)) { if (val < 0.0) { QCBOREncode_AddInt64(enc_ctx, static_cast(val)); } else { @@ -414,7 +410,13 @@ absl::StatusOr Fractional(const json_logic::JsonLogic& eval, bool first_value_used = false; if (bucketing_property_eval.value().is_array()) { - // Shorthand or implicit targetingKey mode + std::string flag_key; + if (data.contains("$flagd") && data["$flagd"].is_object() && + data["$flagd"].contains("flagKey") && + data["$flagd"]["flagKey"].is_string()) { + flag_key = data["$flagd"]["flagKey"].get(); + } + if (!data.is_object() || !data.contains("targetingKey") || data["targetingKey"].is_null()) { return absl::InvalidArgumentError( @@ -424,13 +426,6 @@ absl::StatusOr Fractional(const json_logic::JsonLogic& eval, return absl::InvalidArgumentError("targetingKey must be a string"); } - std::string flag_key; - if (data.contains("$flagd") && data["$flagd"].is_object() && - data["$flagd"].contains("flagKey") && - data["$flagd"]["flagKey"].is_string()) { - flag_key = data["$flagd"]["flagKey"].get(); - } - std::string targeting_key = data["targetingKey"].get(); bucketing_property_value = nlohmann::json::array({flag_key, targeting_key}); first_value_used = false; From a5b406abe69fae4bfc38f91519462d09eb4a13d0 Mon Sep 17 00:00:00 2001 From: Marcin Olko Date: Mon, 7 Sep 2026 15:33:29 +0000 Subject: [PATCH 4/5] Simplified code Signed-off-by: Marcin Olko --- providers/flagd/src/evaluator/flagd_ops.cpp | 40 ++++++--------------- 1 file changed, 10 insertions(+), 30 deletions(-) diff --git a/providers/flagd/src/evaluator/flagd_ops.cpp b/providers/flagd/src/evaluator/flagd_ops.cpp index 361fabc..40a37a8 100644 --- a/providers/flagd/src/evaluator/flagd_ops.cpp +++ b/providers/flagd/src/evaluator/flagd_ops.cpp @@ -227,46 +227,26 @@ struct Distribution { int32_t weight; }; -constexpr size_t kStackKeyBufferSize = 256; - -// Encodes a single string key to deterministic CBOR bytes for map key sorting. -std::vector EncodeCborKey(const std::string& key) { - QCBOREncodeContext ctx; - UsefulBuf_MAKE_STACK_UB(buf, kStackKeyBufferSize); - QCBOREncode_Init(&ctx, buf); - QCBOREncode_AddText(&ctx, {key.data(), key.size()}); - UsefulBufC out; - if (QCBOREncode_Finish(&ctx, &out) == QCBOR_SUCCESS) { - return std::vector(static_cast(out.ptr), - static_cast(out.ptr) + out.len); +// CBOR Canonical sorting for string keys per RFC 7049 Section 3.9 / RFC 8949 +// Section 4.2.1: length first, then lexicographical. +bool CompareCborKeys(const std::string& lhs, const std::string& rhs) { + if (lhs.length() != rhs.length()) { + return lhs.length() < rhs.length(); } - UsefulBufC size_info; - QCBOREncode_Init(&ctx, SizeCalculateUsefulBuf); - QCBOREncode_AddText(&ctx, {key.data(), key.size()}); - QCBOREncode_Finish(&ctx, &size_info); - std::vector dyn_buf(size_info.len); - UsefulBuf dyn_ub = {dyn_buf.data(), dyn_buf.size()}; - QCBOREncode_Init(&ctx, dyn_ub); - QCBOREncode_AddText(&ctx, {key.data(), key.size()}); - QCBOREncode_Finish(&ctx, &out); - return std::vector(static_cast(out.ptr), - static_cast(out.ptr) + out.len); + return lhs < rhs; } // Encodes nlohmann::json to deterministic CBOR per RFC 8949. void EncodeJson(QCBOREncodeContext* enc_ctx, const nlohmann::json& data) { if (data.is_object()) { QCBOREncode_OpenMap(enc_ctx); - std::vector, std::string>> keys; + std::vector keys; keys.reserve(data.size()); for (const auto& item : data.items()) { - keys.emplace_back(EncodeCborKey(item.key()), item.key()); + keys.push_back(item.key()); } - std::sort(keys.begin(), keys.end(), [](const auto& lhs, const auto& rhs) { - return lhs.first < rhs.first; - }); - for (const auto& key_pair : keys) { - const std::string& key = key_pair.second; + std::sort(keys.begin(), keys.end(), CompareCborKeys); + for (const auto& key : keys) { QCBOREncode_AddText(enc_ctx, {key.data(), key.size()}); EncodeJson(enc_ctx, data[key]); } From 705fe9f065738d565fb3bbb9279778bc7bfc3d82 Mon Sep 17 00:00:00 2001 From: Marcin Olko Date: Tue, 8 Sep 2026 10:54:07 +0000 Subject: [PATCH 5/5] Improved weight checks Signed-off-by: Marcin Olko --- providers/flagd/src/evaluator/flagd_ops.cpp | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/providers/flagd/src/evaluator/flagd_ops.cpp b/providers/flagd/src/evaluator/flagd_ops.cpp index 40a37a8..3a89d01 100644 --- a/providers/flagd/src/evaluator/flagd_ops.cpp +++ b/providers/flagd/src/evaluator/flagd_ops.cpp @@ -426,8 +426,19 @@ absl::StatusOr Fractional(const json_logic::JsonLogic& eval, } int32_t weight = 1; - if (item.value().size() >= 2 && item.value()[1].is_number()) { - weight = std::max(item.value()[1].get(), 0); + if (item.value().size() >= 2) { + const nlohmann::json& weight_json = item.value()[1]; + if (!weight_json.is_number()) { + return absl::InvalidArgumentError("Fractional weight must be a number"); + } + if (weight_json.is_number_float()) { + double val = weight_json.get(); + if (!std::isfinite(val) || std::trunc(val) != val) { + return absl::InvalidArgumentError( + "Fractional weight must be an integer"); + } + } + weight = std::max(weight_json.get(), 0); } distributions.push_back({item.value()[0], weight});