diff --git a/tracer/src/Datadog.Trace/FeatureFlags/FeatureFlagsEvaluator.cs b/tracer/src/Datadog.Trace/FeatureFlags/FeatureFlagsEvaluator.cs index 073f50ff6c87..5a137706e682 100644 --- a/tracer/src/Datadog.Trace/FeatureFlags/FeatureFlagsEvaluator.cs +++ b/tracer/src/Datadog.Trace/FeatureFlags/FeatureFlagsEvaluator.cs @@ -40,6 +40,10 @@ public FeatureFlagsEvaluator(ReportExposureDelegate? onExposureEvent, ServerConf _onExposureEvent = onExposureEvent; _config = config; _spanEnrichmentEnabled = spanEnrichmentEnabled; + + // Eagerly validate the config: pre-parse SemVer comparands and + // identify flags with invalid configuration before any evaluation. + _config?.Validate(); if (_config is null) { Log.Debug("Creating Evaluator without config"); @@ -83,6 +87,23 @@ public Evaluation Evaluate(string flagKey, ValueType resultType, object? default }); } + // Check if the flag was marked invalid during config validation + // (e.g., invalid SemVer comparand). Return PARSE_ERROR before + // attempting evaluation. + if (config.InvalidFlags is not null && config.InvalidFlags.TryGetValue(flagKey, out var validationError)) + { + return new Evaluation( + flagKey, + defaultValue, + EvaluationReason.Error, + error: "PARSE_ERROR", + metadata: new Dictionary + { + ["errorCode"] = "PARSE_ERROR", + ["message"] = validationError + }); + } + if (flag.Enabled != true) { return new Evaluation( @@ -302,6 +323,13 @@ private static bool EvaluateCondition(ConditionConfiguration condition, Evaluati return CompareNumber(attributeValue, condition.Value, (a, b) => a <= b); case ConditionOperator.LT: return CompareNumber(attributeValue, condition.Value, (a, b) => a < b); + case ConditionOperator.SEMVER_EQ: + case ConditionOperator.SEMVER_NEQ: + case ConditionOperator.SEMVER_LT: + case ConditionOperator.SEMVER_LTE: + case ConditionOperator.SEMVER_GT: + case ConditionOperator.SEMVER_GTE: + return EvaluateSemverCondition(attributeValue, condition, condition.Operator.Value); default: throw new FormatException($"Unknown condition operator {condition.Operator.ToString()}"); } @@ -371,6 +399,41 @@ private static bool CompareNumber(object attributeValue, object? conditionValue, return comparator(a, b); } + private static bool EvaluateSemverCondition(object attributeValue, ConditionConfiguration condition, ConditionOperator operatorValue) + { + if (attributeValue is not string attribute) + { + return false; + } + + // Comparand was pre-parsed during config validation. If null, the + // flag should have been marked invalid and this method should not + // be reached. Return false as a safety net. + var comparand = condition.SemverComparand; + if (comparand is null) + { + return false; + } + + if (!SemVer.TryParse(attribute, out var parsedAttribute)) + { + return false; + } + + var ordering = SemVer.Compare(parsedAttribute, comparand.Value); + + return operatorValue switch + { + ConditionOperator.SEMVER_EQ => ordering == 0, + ConditionOperator.SEMVER_NEQ => ordering != 0, + ConditionOperator.SEMVER_LT => ordering < 0, + ConditionOperator.SEMVER_LTE => ordering <= 0, + ConditionOperator.SEMVER_GT => ordering > 0, + ConditionOperator.SEMVER_GTE => ordering >= 0, + _ => false, + }; + } + private static bool MatchesShard(Shard shard, string? targetingKey) { if (shard.Ranges is null) diff --git a/tracer/src/Datadog.Trace/FeatureFlags/ParsedSemVer.cs b/tracer/src/Datadog.Trace/FeatureFlags/ParsedSemVer.cs new file mode 100644 index 000000000000..8d243c77e419 --- /dev/null +++ b/tracer/src/Datadog.Trace/FeatureFlags/ParsedSemVer.cs @@ -0,0 +1,39 @@ +// +// Unless explicitly stated otherwise all files in this repository are licensed under the Apache 2 License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). Copyright 2017 Datadog, Inc. +// + +#nullable enable + +using System; + +namespace Datadog.Trace.FeatureFlags; + +/// +/// The language-neutral representation of the Rust/Eppo SemVer subset used by FFE. +/// +internal readonly struct ParsedSemVer : IEquatable +{ + public ParsedSemVer(ulong major, ulong minor, ulong patch, string prerelease) + { + Major = major; + Minor = minor; + Patch = patch; + Prerelease = prerelease; + } + + public ulong Major { get; } + + public ulong Minor { get; } + + public ulong Patch { get; } + + public string Prerelease { get; } + + public bool Equals(ParsedSemVer other) + => Major == other.Major && Minor == other.Minor && Patch == other.Patch && Prerelease == other.Prerelease; + + public override bool Equals(object? obj) => obj is ParsedSemVer other && Equals(other); + + public override int GetHashCode() => HashCode.Combine(Major, Minor, Patch, Prerelease); +} diff --git a/tracer/src/Datadog.Trace/FeatureFlags/Rcm/Model/ConditionConfiguration.cs b/tracer/src/Datadog.Trace/FeatureFlags/Rcm/Model/ConditionConfiguration.cs index f62084c914f3..f63ff802b292 100644 --- a/tracer/src/Datadog.Trace/FeatureFlags/Rcm/Model/ConditionConfiguration.cs +++ b/tracer/src/Datadog.Trace/FeatureFlags/Rcm/Model/ConditionConfiguration.cs @@ -15,6 +15,7 @@ namespace Datadog.Trace.FeatureFlags.Rcm.Model; internal sealed class ConditionConfiguration { private Regex? _regex; + private ParsedSemVer? _semverComparand; public ConditionOperator? Operator { get; set; } @@ -22,6 +23,36 @@ internal sealed class ConditionConfiguration public object? Value { get; set; } + /// + /// Gets the validated, parsed SemVer condition value for SEMVER_* operators. + /// Populated eagerly during config validation by . + /// Null if the comparand is invalid or the operator is not a SEMVER_* operator. + /// + internal ParsedSemVer? SemverComparand => _semverComparand; + + /// + /// Eagerly parses the SemVer comparand for SEMVER_* operators. + /// Called during config validation, not during evaluation. + /// Returns true if the comparand is valid or the operator is not a SEMVER_* operator. + /// Returns false if the operator is a SEMVER_* operator and the comparand is invalid. + /// + internal bool TryPreparseSemverComparand() + { + if (Operator is ConditionOperator.SEMVER_EQ or ConditionOperator.SEMVER_NEQ or ConditionOperator.SEMVER_LT + or ConditionOperator.SEMVER_LTE or ConditionOperator.SEMVER_GT or ConditionOperator.SEMVER_GTE) + { + if (Value is string comparand && SemVer.TryParse(comparand, out var parsed)) + { + _semverComparand = parsed; + return true; + } + + return false; + } + + return true; + } + internal bool MatchesRegex(object attributeValue) { if (_regex == null) diff --git a/tracer/src/Datadog.Trace/FeatureFlags/Rcm/Model/ConditionOperator.cs b/tracer/src/Datadog.Trace/FeatureFlags/Rcm/Model/ConditionOperator.cs index c2a0b6fb3cb3..fdc33df2b62a 100644 --- a/tracer/src/Datadog.Trace/FeatureFlags/Rcm/Model/ConditionOperator.cs +++ b/tracer/src/Datadog.Trace/FeatureFlags/Rcm/Model/ConditionOperator.cs @@ -21,5 +21,11 @@ internal enum ConditionOperator NOT_MATCHES, ONE_OF, NOT_ONE_OF, - IS_NULL + IS_NULL, + SEMVER_EQ, + SEMVER_NEQ, + SEMVER_LT, + SEMVER_LTE, + SEMVER_GT, + SEMVER_GTE } diff --git a/tracer/src/Datadog.Trace/FeatureFlags/Rcm/Model/ServerConfiguration.cs b/tracer/src/Datadog.Trace/FeatureFlags/Rcm/Model/ServerConfiguration.cs index 937af3519e97..80bf1eb4964b 100644 --- a/tracer/src/Datadog.Trace/FeatureFlags/Rcm/Model/ServerConfiguration.cs +++ b/tracer/src/Datadog.Trace/FeatureFlags/Rcm/Model/ServerConfiguration.cs @@ -12,6 +12,8 @@ namespace Datadog.Trace.FeatureFlags.Rcm.Model; internal sealed class ServerConfiguration { + private bool _validated; + public string? CreatedAt { get; set; } public string? Format { get; set; } @@ -20,8 +22,87 @@ internal sealed class ServerConfiguration public Dictionary? Flags { get; set; } + /// + /// Gets or sets the collection of flags that failed validation (e.g., invalid SemVer comparands). + /// Maps flag key to an error message describing the validation failure. + /// Populated by . + /// + internal Dictionary? InvalidFlags { get; set; } + + /// + /// Validates all flags by pre-parsing SemVer comparands and identifying + /// flags with invalid configuration. This is called eagerly during config + /// loading (before evaluation) so that invalid flags can be detected and + /// reported without waiting for an evaluation to trigger the error. + /// Idempotent: safe to call multiple times. + /// + internal void Validate() + { + if (_validated) + { + return; + } + + _validated = true; + + if (Flags is null) + { + return; + } + + foreach (var pair in Flags) + { + var flagKey = pair.Key; + var flag = pair.Value; + if (flag?.Allocations is null) + { + continue; + } + + foreach (var allocation in flag.Allocations) + { + if (allocation.Rules is null) + { + continue; + } + + foreach (var rule in allocation.Rules) + { + if (rule.Conditions is null) + { + continue; + } + + foreach (var condition in rule.Conditions) + { + if (!condition.TryPreparseSemverComparand()) + { + InvalidFlags ??= new Dictionary(); + InvalidFlags[flagKey] = $"Invalid semantic version comparand for flag \"{flagKey}\""; + break; + } + } + + if (InvalidFlags is not null && InvalidFlags.ContainsKey(flagKey)) + { + break; + } + } + + if (InvalidFlags is not null && InvalidFlags.ContainsKey(flagKey)) + { + break; + } + } + } + } + internal void Merge(ServerConfiguration other) { + // Merging changes the flag set, so the merged config needs re-validation. + _validated = false; + InvalidFlags = null; + if (other.CreatedAt is not null) { CreatedAt = other.CreatedAt; diff --git a/tracer/src/Datadog.Trace/FeatureFlags/SemVer.cs b/tracer/src/Datadog.Trace/FeatureFlags/SemVer.cs new file mode 100644 index 000000000000..79cea7b9c24f --- /dev/null +++ b/tracer/src/Datadog.Trace/FeatureFlags/SemVer.cs @@ -0,0 +1,323 @@ +// +// Unless explicitly stated otherwise all files in this repository are licensed under the Apache 2 License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). Copyright 2017 Datadog, Inc. +// + +#nullable enable + +using System; + +namespace Datadog.Trace.FeatureFlags; + +/// +/// Semantic version parsing and comparison matching the Rust/Eppo SemVer subset used by FFE. +/// This is a direct port of the Go implementation in dd-trace-go's openfeature/semver.go. +/// +internal static class SemVer +{ + /// + /// Parses a semantic version string. Accepts the same syntax as Rust's semver::Version::parse. + /// Core identifiers are limited to ulong, while numeric prerelease identifiers may be + /// arbitrarily large. Build metadata is validated but not retained because it does not + /// affect SemVer precedence. + /// + public static bool TryParse(string version, out ParsedSemVer result) + { + result = default; + + if (StringUtil.IsNullOrEmpty(version)) + { + return false; + } + + if (!TryParseCoreIdentifier(version, 0, out var major, out int next) || + next >= version.Length || version[next] != '.') + { + return false; + } + + if (!TryParseCoreIdentifier(version, next + 1, out var minor, out next) || + next >= version.Length || version[next] != '.') + { + return false; + } + + if (!TryParseCoreIdentifier(version, next + 1, out var patch, out next)) + { + return false; + } + + if (next == version.Length) + { + result = new ParsedSemVer(major, minor, patch, string.Empty); + return true; + } + + var remainder = version.Substring(next); + var prerelease = string.Empty; + + if (remainder[0] == '-') + { + remainder = remainder.Substring(1); + var buildStart = remainder.IndexOf('+'); + if (buildStart == -1) + { + if (!ValidSemverIdentifiers(remainder, allowLeadingZeros: false)) + { + return false; + } + + result = new ParsedSemVer(major, minor, patch, remainder); + return true; + } + + prerelease = remainder.Substring(0, buildStart); + if (!ValidSemverIdentifiers(prerelease, allowLeadingZeros: false)) + { + return false; + } + + remainder = remainder.Substring(buildStart + 1); + } + else if (remainder[0] == '+') + { + remainder = remainder.Substring(1); + } + else + { + return false; + } + + if (!ValidSemverIdentifiers(remainder, allowLeadingZeros: true)) + { + return false; + } + + result = new ParsedSemVer(major, minor, patch, prerelease); + return true; + } + + /// + /// Parses a core identifier (major, minor, or patch). Enforces ulong bounds without + /// accepting shorthand or prefixes. Leading zeros are rejected (except for "0" itself). + /// + private static bool TryParseCoreIdentifier(string version, int start, out ulong value, out int next) + { + value = 0; + next = start; + + if (start >= version.Length || !IsAsciiDigit(version[start])) + { + return false; + } + + if (version[start] == '0') + { + next = start + 1; + return true; + } + + const ulong maxUint64 = ulong.MaxValue; + var end = start; + while (end < version.Length && IsAsciiDigit(version[end])) + { + var digit = (ulong)(version[end] - '0'); + if (value > (maxUint64 - digit) / 10) + { + // Overflow + return false; + } + + value = (value * 10) + digit; + end++; + } + + next = end; + return true; + } + + /// + /// Validates dot-separated identifiers. Build metadata allows leading zeros; + /// numeric prerelease identifiers reject them. + /// + private static bool ValidSemverIdentifiers(string value, bool allowLeadingZeros) + { + if (value.Length == 0) + { + return false; + } + + var identifierStart = 0; + var identifierNumeric = true; + + for (var i = 0; i <= value.Length; i++) + { + if (i == value.Length || value[i] == '.') + { + if (i == identifierStart) + { + // Empty identifier + return false; + } + + if (!allowLeadingZeros && identifierNumeric && i - identifierStart > 1 && value[identifierStart] == '0') + { + // Numeric identifier with leading zero + return false; + } + + identifierStart = i + 1; + identifierNumeric = true; + continue; + } + + if (!IsAsciiAlphanumeric(value[i]) && value[i] != '-') + { + return false; + } + + if (!IsAsciiDigit(value[i])) + { + identifierNumeric = false; + } + } + + return true; + } + + /// + /// Compares SemVer precedence. Returns -1, 0, or 1. + /// Build metadata is intentionally ignored. + /// + public static int Compare(ParsedSemVer left, ParsedSemVer right) + { + if (left.Major != right.Major) + { + return left.Major < right.Major ? -1 : 1; + } + + if (left.Minor != right.Minor) + { + return left.Minor < right.Minor ? -1 : 1; + } + + if (left.Patch != right.Patch) + { + return left.Patch < right.Patch ? -1 : 1; + } + + return ComparePrerelease(left.Prerelease, right.Prerelease); + } + + private static int ComparePrerelease(string left, string right) + { + if (left == right) + { + return 0; + } + + if (left.Length == 0) + { + return 1; // release > prerelease + } + + if (right.Length == 0) + { + return -1; // prerelease < release + } + + while (true) + { + NextIdentifier(left, out var leftIdentifier, out var leftRemainder); + NextIdentifier(right, out var rightIdentifier, out var rightRemainder); + + var ordering = CompareIdentifier(leftIdentifier, rightIdentifier); + if (ordering != 0) + { + return ordering; + } + + if (leftRemainder.Length == 0) + { + if (rightRemainder.Length == 0) + { + return 0; + } + + return -1; // left has fewer identifiers + } + + if (rightRemainder.Length == 0) + { + return 1; // right has fewer identifiers + } + + // Skip the dot + left = leftRemainder.Substring(1); + right = rightRemainder.Substring(1); + } + } + + private static void NextIdentifier(string value, out string identifier, out string remainder) + { + var dot = value.IndexOf('.'); + if (dot != -1) + { + identifier = value.Substring(0, dot); + remainder = value.Substring(dot); + } + else + { + identifier = value; + remainder = string.Empty; + } + } + + private static int CompareIdentifier(string left, string right) + { + var leftNumeric = IsNumericIdentifier(left); + var rightNumeric = IsNumericIdentifier(right); + + if (leftNumeric && rightNumeric) + { + // Compare by length first (shorter = smaller), then lexicographically + if (left.Length < right.Length) + { + return -1; + } + + if (left.Length > right.Length) + { + return 1; + } + } + else if (leftNumeric) + { + // Numeric identifiers always have lower precedence than alphanumeric + return -1; + } + else if (rightNumeric) + { + return 1; + } + + return string.Compare(left, right, StringComparison.Ordinal); + } + + private static bool IsNumericIdentifier(string value) + { + for (var i = 0; i < value.Length; i++) + { + if (!IsAsciiDigit(value[i])) + { + return false; + } + } + + return value.Length > 0; + } + + private static bool IsAsciiDigit(char c) => c >= '0' && c <= '9'; + + private static bool IsAsciiAlphanumeric(char c) => IsAsciiDigit(c) || (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z'); +} diff --git a/tracer/test/Datadog.Trace.Tests/FeatureFlags/FeatureFlagsEvaluatorTests.Bundle.cs b/tracer/test/Datadog.Trace.Tests/FeatureFlags/FeatureFlagsEvaluatorTests.Bundle.cs index bd4e10f63280..783fd5b2bd68 100644 --- a/tracer/test/Datadog.Trace.Tests/FeatureFlags/FeatureFlagsEvaluatorTests.Bundle.cs +++ b/tracer/test/Datadog.Trace.Tests/FeatureFlags/FeatureFlagsEvaluatorTests.Bundle.cs @@ -49,7 +49,18 @@ public void BundledTest(string description, TestCase? testCase) } AssertEqual(testCase.Result.Value, result.Value); - AssertEqual(testCase.Result.Variant, result.Variant); + + // Check variant only when the test case specifies one (some shared fixtures omit it) + if (testCase.Result.Variant is not null) + { + AssertEqual(testCase.Result.Variant, result.Variant); + } + + // Check reason when the test case specifies one + if (testCase.Result.Reason is not null) + { + Assert.Equal(ParseReason(testCase.Result.Reason), result.Reason); + } Assert.NotNull(description); @@ -77,6 +88,19 @@ void AssertEqual(object? expected, object? obj) } } + private static EvaluationReason ParseReason(string reason) => reason switch + { + "DEFAULT" => EvaluationReason.Default, + "STATIC" => EvaluationReason.Static, + "TARGETING_MATCH" => EvaluationReason.TargetingMatch, + "SPLIT" => EvaluationReason.Split, + "DISABLED" => EvaluationReason.Disabled, + "CACHED" => EvaluationReason.Cached, + "UNKNOWN" => EvaluationReason.Unknown, + "ERROR" => EvaluationReason.Error, + _ => throw new ArgumentException($"Unknown evaluation reason: {reason}"), + }; + private static Trace.FeatureFlags.ValueType GetVariationType(string? variationType) { return variationType switch @@ -179,7 +203,11 @@ public class Evaluation { public object? Value { get; set; } - public EvaluationReason Reason { get; set; } + /// + /// Expected reason as a SCREAMING_SNAKE_CASE string (e.g. "TARGETING_MATCH", "DEFAULT", "ERROR"). + /// Null when the test case does not assert on the reason. + /// + public string? Reason { get; set; } public string? Variant { get; set; } diff --git a/tracer/test/Datadog.Trace.Tests/FeatureFlags/SemVerTests.cs b/tracer/test/Datadog.Trace.Tests/FeatureFlags/SemVerTests.cs new file mode 100644 index 000000000000..18a9b5ef3536 --- /dev/null +++ b/tracer/test/Datadog.Trace.Tests/FeatureFlags/SemVerTests.cs @@ -0,0 +1,285 @@ +// +// Unless explicitly stated otherwise all files in this repository are licensed under the Apache 2 License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). Copyright 2017 Datadog, Inc. +// + +#nullable enable + +using System; +using System.Collections.Generic; +using Datadog.Trace.FeatureFlags; +using Datadog.Trace.FeatureFlags.Rcm.Model; +using FluentAssertions; +using Xunit; +using ValueType = Datadog.Trace.FeatureFlags.ValueType; + +namespace Datadog.Trace.Tests.FeatureFlags; + +/// +/// Unit tests for SemVer parsing and comparison, ported from dd-trace-go's +/// openfeature/semver_test.go and evaluator_test.go (PR #5128). +/// +public class SemVerTests +{ + // --------------------------------------------------------------------- + // ParseSemver tests (ported from Go TestParseSemver) + // --------------------------------------------------------------------- + + [Theory] + [InlineData("0.0.0", 0UL, 0UL, 0UL, "")] + [InlineData("18446744073709551615.18446744073709551615.18446744073709551615", ulong.MaxValue, ulong.MaxValue, ulong.MaxValue, "")] + [InlineData("1.2.3-alpha.1", 1UL, 2UL, 3UL, "alpha.1")] + [InlineData("1.2.3-18446744073709551616", 1UL, 2UL, 3UL, "18446744073709551616")] + [InlineData("1.2.3+build.001", 1UL, 2UL, 3UL, "")] + [InlineData("1.2.3-alpha-1+build.001", 1UL, 2UL, 3UL, "alpha-1")] + public void TryParseValidVersions(string version, ulong major, ulong minor, ulong patch, string prerelease) + { + var ok = SemVer.TryParse(version, out var result); + ok.Should().BeTrue(); + result.Major.Should().Be(major); + result.Minor.Should().Be(minor); + result.Patch.Should().Be(patch); + result.Prerelease.Should().Be(prerelease); + } + + [Theory] + [InlineData("")] + [InlineData("1")] + [InlineData("1.2")] + [InlineData("1.2.3.4")] + [InlineData("v1.2.3")] + [InlineData("01.2.3")] + [InlineData("1.02.3")] + [InlineData("1.2.03")] + [InlineData("18446744073709551616.0.0")] + [InlineData("0.18446744073709551616.0")] + [InlineData("0.0.18446744073709551616")] + [InlineData("1.2.3-")] + [InlineData("1.2.3+")] + [InlineData("1.2.3-alpha..1")] + [InlineData("1.2.3+build..1")] + [InlineData("1.2.3-01")] + [InlineData("1.2.3-alpha_1")] + [InlineData("1.2.3-alpha+build+other")] + [InlineData("1.2.3-α")] + [InlineData(" 1.2.3")] + [InlineData("1.2.3 ")] + public void TryParseInvalidVersions(string version) + { + var ok = SemVer.TryParse(version, out _); + ok.Should().BeFalse(); + } + + // --------------------------------------------------------------------- + // CompareSemver tests (ported from Go TestCompareSemver) + // --------------------------------------------------------------------- + + [Fact] + public void CompareSemverOrdersCorrectly() + { + // The canonical SemVer 2.0.0 precedence chain + var ordered = new[] + { + "1.0.0-alpha", + "1.0.0-alpha.1", + "1.0.0-alpha.beta", + "1.0.0-beta", + "1.0.0-beta.2", + "1.0.0-beta.11", + "1.0.0-rc.1", + "1.0.0", + "1.0.1", + "1.1.0", + "2.0.0", + }; + + for (var i = 0; i < ordered.Length; i++) + { + SemVer.TryParse(ordered[i], out var left).Should().BeTrue(); + for (var j = 0; j < ordered.Length; j++) + { + SemVer.TryParse(ordered[j], out var right).Should().BeTrue(); + var ordering = SemVer.Compare(left, right); + if (i < j) + { + ordering.Should().BeNegative($"{ordered[i]} should precede {ordered[j]}"); + } + else if (i > j) + { + ordering.Should().BePositive($"{ordered[i]} should follow {ordered[j]}"); + } + else + { + ordering.Should().Be(0); + } + } + } + } + + [Fact] + public void CompareSemverArbitrarilyLargeNumericPrereleaseIdentifiers() + { + SemVer.TryParse("1.0.0-99999999999999999999", out var left).Should().BeTrue(); + SemVer.TryParse("1.0.0-100000000000000000000", out var right).Should().BeTrue(); + SemVer.Compare(left, right).Should().BeNegative(); + } + + [Fact] + public void CompareSemverBuildMetadataIsIgnored() + { + SemVer.TryParse("1.0.0+build.1", out var left).Should().BeTrue(); + SemVer.TryParse("1.0.0+build.2", out var right).Should().BeTrue(); + SemVer.Compare(left, right).Should().Be(0); + } + + // --------------------------------------------------------------------- + // EvaluateSemverCondition tests (ported from Go TestEvaluateSemverCondition) + // --------------------------------------------------------------------- + + [Theory] + [InlineData("SEMVER_EQ", "1.2.3", "1.2.3", true)] + [InlineData("SEMVER_EQ", "1.2.4", "1.2.3", false)] + [InlineData("SEMVER_NEQ", "1.2.4", "1.2.3", true)] + [InlineData("SEMVER_NEQ", "1.2.3", "1.2.3", false)] + [InlineData("SEMVER_LT", "1.9.9", "2.0.0", true)] + [InlineData("SEMVER_LT", "2.0.0", "2.0.0", false)] + [InlineData("SEMVER_LTE", "2.0.0", "2.0.0", true)] + [InlineData("SEMVER_LTE", "2.0.1", "2.0.0", false)] + [InlineData("SEMVER_GT", "1.0.1", "1.0.0", true)] + [InlineData("SEMVER_GT", "1.0.0", "1.0.0", false)] + [InlineData("SEMVER_GTE", "1.0.0", "1.0.0", true)] + [InlineData("SEMVER_GTE", "0.9.9", "1.0.0", false)] + // Prerelease ordering + [InlineData("SEMVER_LT", "1.0.0-beta.1", "1.0.0", true)] + [InlineData("SEMVER_LT", "1.0.0-beta.2", "1.0.0-beta.11", true)] + // Build metadata is ignored + [InlineData("SEMVER_EQ", "4.0.0+build.42", "4.0.0", true)] + [InlineData("SEMVER_EQ", "4.0.0+exp.sha.5114f85", "4.0.0", true)] + [InlineData("SEMVER_NEQ", "4.0.0+build.42", "4.0.0", false)] + [InlineData("SEMVER_LT", "4.0.0+build.42", "4.0.0", false)] + [InlineData("SEMVER_LTE", "4.0.0+build.42", "4.0.0", true)] + [InlineData("SEMVER_GT", "4.0.0+build.42", "4.0.0", false)] + [InlineData("SEMVER_GTE", "4.0.0+build.42", "4.0.0", true)] + [InlineData("SEMVER_EQ", "1.0.0+linux", "1.0.0+darwin", true)] + // Invalid attribute does not match + [InlineData("SEMVER_NEQ", "not-a-version", "1.0.0", false)] + [InlineData("SEMVER_GTE", "1.2", "1.0.0", false)] + [InlineData("SEMVER_GTE", "v1.2.3", "1.0.0", false)] + [InlineData("SEMVER_GTE", "18446744073709551616.0.0", "1.0.0", false)] + public void EvaluateSemverConditionTests(string operatorName, string attribute, string comparand, bool want) + { + var op = ParseOperator(operatorName); + var condition = new ConditionConfiguration + { + Operator = op, + Attribute = "version", + Value = comparand, + }; + + var context = new EvaluationContext("user", new Dictionary { { "version", attribute } }); + + // Use the evaluator to test the full path + var flags = CreateSemverTestFlag(op, comparand, want ? "matched" : "unmatched"); + var evaluator = new FeatureFlagsEvaluator(null, new ServerConfiguration { Flags = flags }); + + var result = evaluator.Evaluate("test-flag", ValueType.String, "default", context); + + if (want) + { + result.Value.Should().Be("matched"); + result.Reason.Should().Be(EvaluationReason.TargetingMatch); + } + else + { + result.Value.Should().Be("default"); + result.Reason.Should().Be(EvaluationReason.Default); + } + } + + [Fact] + public void EvaluateSemverConditionMissingAttributeDoesNotMatch() + { + var condition = new ConditionConfiguration + { + Operator = ConditionOperator.SEMVER_EQ, + Attribute = "version", + Value = "1.2.3", + }; + + var flags = CreateSemverTestFlag(ConditionOperator.SEMVER_EQ, "1.2.3", "matched"); + var evaluator = new FeatureFlagsEvaluator(null, new ServerConfiguration { Flags = flags }); + + var context = new EvaluationContext("user"); // No attributes + var result = evaluator.Evaluate("test-flag", ValueType.String, "default", context); + + result.Value.Should().Be("default"); + result.Reason.Should().Be(EvaluationReason.Default); + } + + [Fact] + public void EvaluateSemverConditionNonStringAttributeDoesNotMatch() + { + var flags = CreateSemverTestFlag(ConditionOperator.SEMVER_EQ, "1.2.0", "matched"); + var evaluator = new FeatureFlagsEvaluator(null, new ServerConfiguration { Flags = flags }); + + var context = new EvaluationContext("user", new Dictionary { { "version", 1.2 } }); // Non-string + var result = evaluator.Evaluate("test-flag", ValueType.String, "default", context); + + result.Value.Should().Be("default"); + result.Reason.Should().Be(EvaluationReason.Default); + } + + [Fact] + public void EvaluateSemverConditionInvalidComparandReturnsParseError() + { + var flags = CreateSemverTestFlag(ConditionOperator.SEMVER_EQ, "not-a-version", "matched"); + var evaluator = new FeatureFlagsEvaluator(null, new ServerConfiguration { Flags = flags }); + + var context = new EvaluationContext("user", new Dictionary { { "version", "1.2.3" } }); + var result = evaluator.Evaluate("test-flag", ValueType.String, "default", context); + + result.Value.Should().Be("default"); + result.Reason.Should().Be(EvaluationReason.Error); + result.Error.Should().Be("PARSE_ERROR"); + } + + private static ConditionOperator ParseOperator(string name) => name switch + { + "SEMVER_EQ" => ConditionOperator.SEMVER_EQ, + "SEMVER_NEQ" => ConditionOperator.SEMVER_NEQ, + "SEMVER_LT" => ConditionOperator.SEMVER_LT, + "SEMVER_LTE" => ConditionOperator.SEMVER_LTE, + "SEMVER_GT" => ConditionOperator.SEMVER_GT, + "SEMVER_GTE" => ConditionOperator.SEMVER_GTE, + _ => throw new ArgumentException($"Unknown operator: {name}"), + }; + + private static Dictionary CreateSemverTestFlag(ConditionOperator op, string comparand, string variantKey) + { + var variants = new Dictionary + { + ["matched"] = new Variant { Key = "matched", Value = "matched" }, + ["unmatched"] = new Variant { Key = "unmatched", Value = "unmatched" }, + }; + + var conditions = new List + { + new ConditionConfiguration { Operator = op, Attribute = "version", Value = comparand }, + }; + + var rules = new List { new Rule(conditions) }; + var splits = new List { new Split { Shards = new List(), VariationKey = variantKey } }; + var alloc = new Allocation { Key = "test-alloc", Rules = rules, Splits = splits, DoLog = false }; + + var flag = new Flag + { + Key = "test-flag", + Enabled = true, + VariationType = ValueType.String, + Variations = variants, + Allocations = new List { alloc }, + }; + + return new Dictionary { ["test-flag"] = flag }; + } +} diff --git a/tracer/test/Datadog.Trace.Tests/FeatureFlags/resources/config/flags-v1.json b/tracer/test/Datadog.Trace.Tests/FeatureFlags/resources/config/flags-v1.json index 5b21a9f36615..c38a1f9d674f 100644 --- a/tracer/test/Datadog.Trace.Tests/FeatureFlags/resources/config/flags-v1.json +++ b/tracer/test/Datadog.Trace.Tests/FeatureFlags/resources/config/flags-v1.json @@ -2946,6 +2946,228 @@ "doLog": true } ] + }, + "semver-comparison-test": { + "key": "semver-comparison-test", + "enabled": true, + "variationType": "STRING", + "variations": { + "legacy": { + "key": "legacy", + "value": "legacy" + }, + "stable": { + "key": "stable", + "value": "stable" + }, + "beta": { + "key": "beta", + "value": "beta" + }, + "launch": { + "key": "launch", + "value": "launch" + }, + "future": { + "key": "future", + "value": "future" + }, + "maximum": { + "key": "maximum", + "value": "maximum" + } + }, + "allocations": [ + { + "key": "maximum-version", + "rules": [ + { + "conditions": [ + { + "attribute": "app_version", + "operator": "SEMVER_EQ", + "value": "9007199254740991.9007199254740991.9007199254740991" + } + ] + } + ], + "splits": [ + { + "variationKey": "maximum", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "legacy-versions", + "rules": [ + { + "conditions": [ + { + "attribute": "app_version", + "operator": "SEMVER_LTE", + "value": "1.2.3-alpha.4" + } + ] + }, + { + "conditions": [ + { + "attribute": "app_version", + "operator": "SEMVER_GT", + "value": "1.2.3-alpha.4" + }, + { + "attribute": "app_version", + "operator": "SEMVER_LT", + "value": "2.3.4" + } + ] + } + ], + "splits": [ + { + "variationKey": "legacy", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "stable-versions", + "rules": [ + { + "conditions": [ + { + "attribute": "app_version", + "operator": "SEMVER_GTE", + "value": "2.3.4" + }, + { + "attribute": "app_version", + "operator": "SEMVER_LT", + "value": "3.4.5-beta.2" + } + ] + } + ], + "splits": [ + { + "variationKey": "stable", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "beta-versions", + "rules": [ + { + "conditions": [ + { + "attribute": "app_version", + "operator": "SEMVER_GTE", + "value": "3.4.5-beta.2" + }, + { + "attribute": "app_version", + "operator": "SEMVER_LT", + "value": "4.5.6-rc.1" + } + ] + } + ], + "splits": [ + { + "variationKey": "beta", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "launch-version", + "rules": [ + { + "conditions": [ + { + "attribute": "app_version", + "operator": "SEMVER_EQ", + "value": "4.5.6-rc.1" + } + ] + } + ], + "splits": [ + { + "variationKey": "launch", + "shards": [] + } + ], + "doLog": true + }, + { + "key": "future-versions", + "rules": [ + { + "conditions": [ + { + "attribute": "app_version", + "operator": "SEMVER_GT", + "value": "4.5.6-rc.1" + }, + { + "attribute": "app_version", + "operator": "SEMVER_NEQ", + "value": "5.6.7" + } + ] + } + ], + "splits": [ + { + "variationKey": "future", + "shards": [] + } + ], + "doLog": true + } + ] + }, + "semver-invalid-comparand-test": { + "key": "semver-invalid-comparand-test", + "enabled": true, + "variationType": "STRING", + "variations": { + "matched": { + "key": "matched", + "value": "matched" + } + }, + "allocations": [ + { + "key": "invalid-comparand", + "rules": [ + { + "conditions": [ + { + "attribute": "app_version", + "operator": "SEMVER_EQ", + "value": "18446744073709551616.0.0" + } + ] + } + ], + "splits": [ + { + "variationKey": "matched", + "shards": [] + } + ], + "doLog": true + } + ] } } } diff --git a/tracer/test/Datadog.Trace.Tests/FeatureFlags/resources/data/test-case-semver-comparison-flag.json b/tracer/test/Datadog.Trace.Tests/FeatureFlags/resources/data/test-case-semver-comparison-flag.json new file mode 100644 index 000000000000..bc6fe8b92bc0 --- /dev/null +++ b/tracer/test/Datadog.Trace.Tests/FeatureFlags/resources/data/test-case-semver-comparison-flag.json @@ -0,0 +1,169 @@ +[ + { + "attributes": { + "app_version": "1.2.3-alpha.4" + }, + "defaultValue": "unknown", + "flag": "semver-comparison-test", + "result": { + "reason": "TARGETING_MATCH", + "value": "legacy" + }, + "targetingKey": "alice", + "variationType": "STRING" + }, + { + "attributes": { + "app_version": "1.2.3-alpha.5" + }, + "defaultValue": "unknown", + "flag": "semver-comparison-test", + "result": { + "reason": "TARGETING_MATCH", + "value": "legacy" + }, + "targetingKey": "bob", + "variationType": "STRING" + }, + { + "attributes": { + "app_version": "2.3.4" + }, + "defaultValue": "unknown", + "flag": "semver-comparison-test", + "result": { + "reason": "TARGETING_MATCH", + "value": "stable" + }, + "targetingKey": "carol", + "variationType": "STRING" + }, + { + "attributes": { + "app_version": "2.9.1" + }, + "defaultValue": "unknown", + "flag": "semver-comparison-test", + "result": { + "reason": "TARGETING_MATCH", + "value": "stable" + }, + "targetingKey": "dave", + "variationType": "STRING" + }, + { + "attributes": { + "app_version": "3.4.5-beta.1" + }, + "defaultValue": "unknown", + "flag": "semver-comparison-test", + "result": { + "reason": "TARGETING_MATCH", + "value": "stable" + }, + "targetingKey": "eve", + "variationType": "STRING" + }, + { + "attributes": { + "app_version": "3.4.5-beta.2" + }, + "defaultValue": "unknown", + "flag": "semver-comparison-test", + "result": { + "reason": "TARGETING_MATCH", + "value": "beta" + }, + "targetingKey": "frank", + "variationType": "STRING" + }, + { + "attributes": { + "app_version": "4.5.6-rc.0" + }, + "defaultValue": "unknown", + "flag": "semver-comparison-test", + "result": { + "reason": "TARGETING_MATCH", + "value": "beta" + }, + "targetingKey": "grace", + "variationType": "STRING" + }, + { + "attributes": { + "app_version": "4.5.6-rc.1" + }, + "defaultValue": "unknown", + "flag": "semver-comparison-test", + "result": { + "reason": "TARGETING_MATCH", + "value": "launch" + }, + "targetingKey": "henry", + "variationType": "STRING" + }, + { + "attributes": { + "app_version": "4.5.6-rc.1+build.42" + }, + "defaultValue": "unknown", + "flag": "semver-comparison-test", + "result": { + "reason": "TARGETING_MATCH", + "value": "launch" + }, + "targetingKey": "mia", + "variationType": "STRING" + }, + { + "attributes": { + "app_version": "4.5.6" + }, + "defaultValue": "unknown", + "flag": "semver-comparison-test", + "result": { + "reason": "TARGETING_MATCH", + "value": "future" + }, + "targetingKey": "iris", + "variationType": "STRING" + }, + { + "attributes": { + "app_version": "5.6.7" + }, + "defaultValue": "unknown", + "flag": "semver-comparison-test", + "result": { + "reason": "DEFAULT", + "value": "unknown" + }, + "targetingKey": "jane", + "variationType": "STRING" + }, + { + "attributes": { + "app_version": "not-a-semver" + }, + "defaultValue": "unknown", + "flag": "semver-comparison-test", + "result": { + "reason": "DEFAULT", + "value": "unknown" + }, + "targetingKey": "kyle", + "variationType": "STRING" + }, + { + "attributes": {}, + "defaultValue": "unknown", + "flag": "semver-comparison-test", + "result": { + "reason": "DEFAULT", + "value": "unknown" + }, + "targetingKey": "liam", + "variationType": "STRING" + } +] diff --git a/tracer/test/Datadog.Trace.Tests/FeatureFlags/resources/data/test-case-semver-validation-flag.json b/tracer/test/Datadog.Trace.Tests/FeatureFlags/resources/data/test-case-semver-validation-flag.json new file mode 100644 index 000000000000..1ba5fffc577c --- /dev/null +++ b/tracer/test/Datadog.Trace.Tests/FeatureFlags/resources/data/test-case-semver-validation-flag.json @@ -0,0 +1,128 @@ +[ + { + "description": "The maximum safe integer value is valid in every core version component.", + "attributes": { + "app_version": "9007199254740991.9007199254740991.9007199254740991" + }, + "defaultValue": "unknown", + "flag": "semver-comparison-test", + "result": { + "reason": "TARGETING_MATCH", + "value": "maximum" + }, + "targetingKey": "max-core", + "variationType": "STRING" + }, + { + "description": "Semver requires all three core components.", + "attributes": { + "app_version": "1.2" + }, + "defaultValue": "unknown", + "flag": "semver-comparison-test", + "result": { + "reason": "DEFAULT", + "value": "unknown" + }, + "targetingKey": "short-version", + "variationType": "STRING" + }, + { + "description": "Semver does not accept a v prefix.", + "attributes": { + "app_version": "v1.2.3" + }, + "defaultValue": "unknown", + "flag": "semver-comparison-test", + "result": { + "reason": "DEFAULT", + "value": "unknown" + }, + "targetingKey": "v-prefix", + "variationType": "STRING" + }, + { + "description": "Core numeric components cannot have leading zeros.", + "attributes": { + "app_version": "01.2.3" + }, + "defaultValue": "unknown", + "flag": "semver-comparison-test", + "result": { + "reason": "DEFAULT", + "value": "unknown" + }, + "targetingKey": "core-leading-zero", + "variationType": "STRING" + }, + { + "description": "Numeric prerelease identifiers cannot have leading zeros.", + "attributes": { + "app_version": "1.2.3-01" + }, + "defaultValue": "unknown", + "flag": "semver-comparison-test", + "result": { + "reason": "DEFAULT", + "value": "unknown" + }, + "targetingKey": "prerelease-leading-zero", + "variationType": "STRING" + }, + { + "description": "Prerelease identifiers cannot be empty.", + "attributes": { + "app_version": "1.2.3-alpha..1" + }, + "defaultValue": "unknown", + "flag": "semver-comparison-test", + "result": { + "reason": "DEFAULT", + "value": "unknown" + }, + "targetingKey": "empty-prerelease-identifier", + "variationType": "STRING" + }, + { + "description": "Build metadata identifiers cannot be empty.", + "attributes": { + "app_version": "1.2.3+build..1" + }, + "defaultValue": "unknown", + "flag": "semver-comparison-test", + "result": { + "reason": "DEFAULT", + "value": "unknown" + }, + "targetingKey": "empty-build-identifier", + "variationType": "STRING" + }, + { + "description": "Prerelease and build identifiers are ASCII-only.", + "attributes": { + "app_version": "1.2.3-α" + }, + "defaultValue": "unknown", + "flag": "semver-comparison-test", + "result": { + "reason": "DEFAULT", + "value": "unknown" + }, + "targetingKey": "non-ascii-prerelease", + "variationType": "STRING" + }, + { + "description": "An invalid configured SemVer comparand aborts flag evaluation with an error.", + "attributes": { + "app_version": "1.2.3" + }, + "defaultValue": "unknown", + "flag": "semver-invalid-comparand-test", + "result": { + "reason": "ERROR", + "value": "unknown" + }, + "targetingKey": "invalid-comparand", + "variationType": "STRING" + } +]