Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -22,36 +22,26 @@ internal sealed class ConditionConfiguration

public object? Value { get; set; }

internal bool MatchesRegex(object attributeValue)
internal bool HasValidRegex()
{
if (_regex == null)
try
{
var pattern = Value?.ToString() ?? string.Empty;
if (pattern is not { Length: > 0 })
{
throw new FormatException("Condition value can not be null nor empty");
}

if (pattern.StartsWith("(?u)", StringComparison.Ordinal))
{
pattern = pattern.Substring(4);
}

pattern = pattern.Replace("[:alnum:]", @"\p{L}\p{N}");

try
{
_regex = new Regex(pattern, RegexOptions.Compiled);
}
catch (ArgumentException ex)
{
throw new FormatException($"Invalid regex pattern: {pattern}", ex);
}
_ = GetOrCreateRegex();
return true;
}
catch (FormatException)
{
return false;
}
}

internal bool MatchesRegex(object attributeValue)
{
var regex = GetOrCreateRegex();

try
{
return _regex.IsMatch(ToString(attributeValue));
return regex.IsMatch(ToString(attributeValue));
}
catch
{
Expand All @@ -65,4 +55,36 @@ static string ToString(object attributeValue)
return Convert.ToString(attributeValue, CultureInfo.InvariantCulture) ?? string.Empty;
}
}

private Regex GetOrCreateRegex()
{
if (_regex is not null)
{
return _regex;
}

var pattern = Value?.ToString() ?? string.Empty;
if (pattern is not { Length: > 0 })
{
throw new FormatException("Condition value can not be null nor empty");
}

if (pattern.StartsWith("(?u)", StringComparison.Ordinal))
{
pattern = pattern.Substring(4);
}

// Rust regex treats POSIX alnum as ASCII, so preserve the canonical semantics.
pattern = pattern.Replace("[:alnum:]", "0-9A-Za-z");

try
{
_regex = new Regex(pattern, RegexOptions.Compiled);
return _regex;
}
catch (ArgumentException ex)
{
throw new FormatException($"Invalid regex pattern: {pattern}", ex);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,11 @@ private static bool IsValid(Flag flag)

private static bool HasValidOperand(ConditionConfiguration condition)
{
if (condition.Operator is ConditionOperator.MATCHES or ConditionOperator.NOT_MATCHES)
{
return condition.HasValidRegex();
}

if (condition.Operator is ConditionOperator.ONE_OF or ConditionOperator.NOT_ONE_OF)
{
return condition.Value is JArray;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,11 @@ public void BundledTest(string description, TestCase? testCase)
}

Assert.Equal(testCase.Result.Reason, ToCanonicalReason(result.Reason));
if (testCase.Result.ErrorCode is not null)
{
Assert.Equal(testCase.Result.ErrorCode, result.Error);
Assert.Equal(testCase.Result.ErrorCode, result.FlagMetadata?["errorCode"]);
}

Assert.NotNull(description);

Expand Down Expand Up @@ -221,7 +226,7 @@ public class Evaluation

public string? Variant { get; set; }

public string? Error { get; set; }
public string? ErrorCode { get; set; }

public Dictionary<string, string>? FlagMetadata { get; set; }
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,65 @@ public void EvaluateDistinguishesInvalidAndMissingFlags()
Assert.Equal(EvaluationReason.Default, valid.Reason);
}

[Fact]
public void InvalidRegexIsRejectedWithoutEvaluatingItsCondition()
{
const string json = """
{
"flags": {
"invalid-regex": {
"key": "invalid-regex",
"enabled": true,
"variationType": "STRING",
"variations": {
"targeted": { "key": "targeted", "value": "targeted" },
"catch-all": { "key": "catch-all", "value": "catch-all" }
},
"allocations": [
{
"key": "invalid-regex-allocation",
"rules": [
{
"conditions": [
{ "attribute": "email", "operator": "MATCHES", "value": "*@example.com" }
]
}
],
"splits": [{ "variationKey": "targeted", "shards": [] }]
},
{
"key": "catch-all-allocation",
"rules": [],
"splits": [{ "variationKey": "catch-all", "shards": [] }]
}
]
}
}
}
""";
var config = JsonConvert.DeserializeObject<ServerConfiguration>(json)!;
var evaluator = new FeatureFlagsEvaluator(null, config);

var result = evaluator.Evaluate("invalid-regex", ValueType.String, "default", new EvaluationContext("target"));

Assert.Equal("default", result.Value);
Assert.Equal(EvaluationReason.Error, result.Reason);
Assert.Equal("PARSE_ERROR", result.Error);
}

[Fact]
public void PosixAlnumUsesAsciiSemantics()
{
var condition = new ConditionConfiguration
{
Operator = ConditionOperator.MATCHES,
Value = "^[[:alnum:]]+$",
};

Assert.True(condition.MatchesRegex("abc123"));
Assert.False(condition.MatchesRegex("mañana"));
}

[Fact]
public void MergeReplacesFlagParsingState()
{
Expand Down
Loading