Skip to content

Commit 3c4bccb

Browse files
committed
Fix #6: Add eager/non-throwing validation for expression syntax errors
- Add ExpressionValidator to validate expressions before compilation - Validate regex patterns in IsMatch()/IndexOfMatch() without throwing - Detect unknown function names gracefully - Validate CI modifier usage on functions that support it - Return validation errors via TryCompile() instead of throwing - Add test suite for validation scenarios
1 parent 1b6a42d commit 3c4bccb

File tree

4 files changed

+345
-9
lines changed

4 files changed

+345
-9
lines changed

src/Serilog.Expressions/Expressions/Compilation/Text/TextMatchingTransformer.cs

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -51,11 +51,19 @@ Expression TryCompileIndexOfMatch(bool ignoreCase, Expression corpus, Expression
5151
{
5252
if (regex is ConstantExpression { Constant: ScalarValue { Value: string s } })
5353
{
54-
var opts = RegexOptions.Compiled | RegexOptions.ExplicitCapture;
55-
if (ignoreCase)
56-
opts |= RegexOptions.IgnoreCase;
57-
var compiled = new Regex(s, opts, TimeSpan.FromMilliseconds(100));
58-
return new IndexOfMatchExpression(Transform(corpus), compiled);
54+
try
55+
{
56+
var opts = RegexOptions.Compiled | RegexOptions.ExplicitCapture;
57+
if (ignoreCase)
58+
opts |= RegexOptions.IgnoreCase;
59+
var compiled = new Regex(s, opts, TimeSpan.FromMilliseconds(100));
60+
return new IndexOfMatchExpression(Transform(corpus), compiled);
61+
}
62+
catch (ArgumentException ex)
63+
{
64+
SelfLog.WriteLine($"Serilog.Expressions: Invalid regular expression in `IndexOfMatch()`: {ex.Message}");
65+
return new CallExpression(false, Operators.OpUndefined);
66+
}
5967
}
6068

6169
SelfLog.WriteLine($"Serilog.Expressions: `IndexOfMatch()` requires a constant string regular expression argument; found ${regex}.");
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
// Copyright © Serilog Contributors
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
using System.Text;
16+
using System.Text.RegularExpressions;
17+
using Serilog.Events;
18+
using Serilog.Expressions.Ast;
19+
using Serilog.Expressions.Compilation.Transformations;
20+
21+
namespace Serilog.Expressions.Compilation.Validation;
22+
23+
class ExpressionValidator : IdentityTransformer
24+
{
25+
readonly NameResolver _nameResolver;
26+
readonly List<string> _errors = new();
27+
28+
// Functions that support case-insensitive operations (have StringComparison parameter)
29+
static readonly HashSet<string> CaseSensitiveFunctions = new(StringComparer.OrdinalIgnoreCase)
30+
{
31+
// String operations
32+
Operators.OpContains,
33+
Operators.OpStartsWith,
34+
Operators.OpEndsWith,
35+
Operators.OpIndexOf,
36+
Operators.OpLastIndexOf,
37+
Operators.OpReplace,
38+
39+
// Pattern matching
40+
Operators.OpIndexOfMatch,
41+
Operators.OpIsMatch,
42+
Operators.IntermediateOpLike,
43+
Operators.IntermediateOpNotLike,
44+
45+
// Comparisons
46+
Operators.RuntimeOpEqual,
47+
Operators.RuntimeOpNotEqual,
48+
Operators.RuntimeOpIn,
49+
Operators.RuntimeOpNotIn,
50+
51+
// Element access
52+
Operators.OpElementAt,
53+
54+
// Special functions
55+
Operators.OpUndefined // undefined() always accepts ci
56+
};
57+
58+
ExpressionValidator(NameResolver nameResolver)
59+
{
60+
_nameResolver = nameResolver;
61+
}
62+
63+
public static bool Validate(Expression expression, NameResolver nameResolver, out string? error)
64+
{
65+
var validator = new ExpressionValidator(nameResolver);
66+
validator.Transform(expression);
67+
68+
if (validator._errors.Count == 0)
69+
{
70+
error = null;
71+
return true;
72+
}
73+
74+
if (validator._errors.Count == 1)
75+
{
76+
error = validator._errors[0];
77+
}
78+
else
79+
{
80+
var sb = new StringBuilder("Multiple errors found: ");
81+
for (var i = 0; i < validator._errors.Count; i++)
82+
{
83+
if (i > 0)
84+
sb.Append("; ");
85+
sb.Append(validator._errors[i]);
86+
}
87+
error = sb.ToString();
88+
}
89+
90+
return false;
91+
}
92+
93+
protected override Expression Transform(CallExpression call)
94+
{
95+
// Skip validation for intermediate operators (they get transformed later)
96+
if (!call.OperatorName.StartsWith("_Internal_"))
97+
{
98+
// Check for unknown function names
99+
if (!_nameResolver.TryResolveFunctionName(call.OperatorName, out _))
100+
{
101+
_errors.Add($"The function name `{call.OperatorName}` was not recognized.");
102+
}
103+
}
104+
105+
// Check for invalid CI modifier usage
106+
if (call.IgnoreCase && !CaseSensitiveFunctions.Contains(call.OperatorName))
107+
{
108+
_errors.Add($"The function `{call.OperatorName}` does not support case-insensitive operation.");
109+
}
110+
111+
// Validate regex patterns in IsMatch and IndexOfMatch
112+
if (Operators.SameOperator(call.OperatorName, Operators.OpIsMatch) ||
113+
Operators.SameOperator(call.OperatorName, Operators.OpIndexOfMatch))
114+
{
115+
ValidateRegexPattern(call);
116+
}
117+
118+
return base.Transform(call);
119+
}
120+
121+
void ValidateRegexPattern(CallExpression call)
122+
{
123+
if (call.Operands.Length != 2)
124+
return;
125+
126+
var pattern = call.Operands[1];
127+
if (pattern is ConstantExpression { Constant: ScalarValue { Value: string s } })
128+
{
129+
try
130+
{
131+
var opts = RegexOptions.Compiled | RegexOptions.ExplicitCapture;
132+
if (call.IgnoreCase)
133+
opts |= RegexOptions.IgnoreCase;
134+
135+
// Try to compile the regex with timeout to catch invalid patterns
136+
_ = new Regex(s, opts, TimeSpan.FromMilliseconds(100));
137+
}
138+
catch (ArgumentException ex)
139+
{
140+
_errors.Add($"Invalid regular expression in {call.OperatorName}: {ex.Message}");
141+
}
142+
}
143+
}
144+
}

src/Serilog.Expressions/Expressions/SerilogExpression.cs

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
using System.Diagnostics.CodeAnalysis;
1616
using System.Globalization;
1717
using Serilog.Expressions.Compilation;
18+
using Serilog.Expressions.Compilation.Validation;
1819
using Serilog.Expressions.Parsing;
1920

2021
// ReSharper disable MemberCanBePrivate.Global
@@ -101,10 +102,30 @@ static bool TryCompileImpl(string expression,
101102
return false;
102103
}
103104

104-
var evaluate = ExpressionCompiler.Compile(root, formatProvider, DefaultFunctionNameResolver.Build(nameResolver));
105-
result = evt => evaluate(new(evt));
106-
error = null;
107-
return true;
105+
var resolver = DefaultFunctionNameResolver.Build(nameResolver);
106+
107+
// Validate the expression before compilation
108+
if (!ExpressionValidator.Validate(root, resolver, out var validationError))
109+
{
110+
result = null;
111+
error = validationError ?? "Unknown validation error";
112+
return false;
113+
}
114+
115+
try
116+
{
117+
var evaluate = ExpressionCompiler.Compile(root, formatProvider, resolver);
118+
result = evt => evaluate(new(evt));
119+
error = null;
120+
return true;
121+
}
122+
catch (ArgumentException ex)
123+
{
124+
// Catch any remaining exceptions that weren't caught by validation
125+
result = null;
126+
error = ex.Message;
127+
return false;
128+
}
108129
}
109130

110131
/// <summary>
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
using Xunit;
2+
3+
namespace Serilog.Expressions.Tests;
4+
5+
public class ExpressionValidationTests
6+
{
7+
[Theory]
8+
[InlineData("IsMatch(Name, '[invalid')", "Invalid regular expression")]
9+
[InlineData("IndexOfMatch(Text, '(?<')", "Invalid regular expression")]
10+
[InlineData("IsMatch(Name, '(?P<name>)')", "Invalid regular expression")]
11+
[InlineData("IsMatch(Name, '(unclosed')", "Invalid regular expression")]
12+
[InlineData("IndexOfMatch(Text, '*invalid')", "Invalid regular expression")]
13+
public void InvalidRegularExpressionsAreReportedGracefully(string expression, string expectedErrorFragment)
14+
{
15+
var result = SerilogExpression.TryCompile(expression, out var compiled, out var error);
16+
Assert.False(result);
17+
Assert.Contains(expectedErrorFragment, error);
18+
Assert.Null(compiled);
19+
}
20+
21+
[Theory]
22+
[InlineData("UnknownFunction()", "The function name `UnknownFunction` was not recognized.")]
23+
[InlineData("foo(1, 2, 3)", "The function name `foo` was not recognized.")]
24+
[InlineData("MyCustomFunc(Name)", "The function name `MyCustomFunc` was not recognized.")]
25+
[InlineData("notAFunction()", "The function name `notAFunction` was not recognized.")]
26+
public void UnknownFunctionNamesAreReportedGracefully(string expression, string expectedError)
27+
{
28+
var result = SerilogExpression.TryCompile(expression, out var compiled, out var error);
29+
Assert.False(result);
30+
Assert.Equal(expectedError, error);
31+
Assert.Null(compiled);
32+
}
33+
34+
[Theory]
35+
[InlineData("Length(Name) ci", "The function `Length` does not support case-insensitive operation.")]
36+
[InlineData("Round(Value, 2) ci", "The function `Round` does not support case-insensitive operation.")]
37+
[InlineData("Now() ci", "The function `Now` does not support case-insensitive operation.")]
38+
[InlineData("TypeOf(Value) ci", "The function `TypeOf` does not support case-insensitive operation.")]
39+
[InlineData("IsDefined(Prop) ci", "The function `IsDefined` does not support case-insensitive operation.")]
40+
public void InvalidCiModifierUsageIsReported(string expression, string expectedError)
41+
{
42+
var result = SerilogExpression.TryCompile(expression, out var compiled, out var error);
43+
Assert.False(result);
44+
Assert.Equal(expectedError, error);
45+
Assert.Null(compiled);
46+
}
47+
48+
[Theory]
49+
[InlineData("Contains(Name, 'test') ci")]
50+
[InlineData("StartsWith(Path, '/api') ci")]
51+
[InlineData("EndsWith(File, '.txt') ci")]
52+
[InlineData("IsMatch(Email, '@example') ci")]
53+
[InlineData("IndexOfMatch(Text, 'pattern') ci")]
54+
[InlineData("IndexOf(Name, 'x') ci")]
55+
[InlineData("Name = 'test' ci")]
56+
[InlineData("Name <> 'test' ci")]
57+
[InlineData("Name like '%test%' ci")]
58+
public void ValidCiModifierUsageCompiles(string expression)
59+
{
60+
var result = SerilogExpression.TryCompile(expression, out var compiled, out var error);
61+
Assert.True(result, $"Failed to compile: {error}");
62+
Assert.NotNull(compiled);
63+
Assert.Null(error);
64+
}
65+
66+
[Fact]
67+
public void MultipleErrorsAreCollectedAndReported()
68+
{
69+
var expression = "UnknownFunc() and IsMatch(Name, '[invalid') and Length(Value) ci";
70+
var result = SerilogExpression.TryCompile(expression, out var compiled, out var error);
71+
72+
Assert.False(result);
73+
Assert.Null(compiled);
74+
75+
// Should report all three errors
76+
Assert.Contains("UnknownFunc", error);
77+
Assert.Contains("Invalid regular expression", error);
78+
Assert.Contains("does not support case-insensitive", error);
79+
Assert.Contains("Multiple errors found", error);
80+
}
81+
82+
[Fact]
83+
public void ValidExpressionsStillCompileWithoutErrors()
84+
{
85+
var validExpressions = new[]
86+
{
87+
"IsMatch(Name, '^[A-Z]')",
88+
"IndexOfMatch(Text, '\\d+')",
89+
"Contains(Name, 'test') ci",
90+
"Length(Items) > 5",
91+
"Round(Value, 2)",
92+
"TypeOf(Data) = 'String'",
93+
"Name like '%test%'",
94+
"StartsWith(Path, '/') and EndsWith(Path, '.json')"
95+
};
96+
97+
foreach (var expr in validExpressions)
98+
{
99+
var result = SerilogExpression.TryCompile(expr, out var compiled, out var error);
100+
Assert.True(result, $"Failed to compile: {expr}. Error: {error}");
101+
Assert.NotNull(compiled);
102+
Assert.Null(error);
103+
}
104+
}
105+
106+
[Fact]
107+
public void CompileMethodStillThrowsForInvalidExpressions()
108+
{
109+
// Ensure Compile() method (not TryCompile) maintains throwing behavior for invalid expressions
110+
Assert.Throws<ArgumentException>(() =>
111+
SerilogExpression.Compile("UnknownFunction()"));
112+
113+
Assert.Throws<ArgumentException>(() =>
114+
SerilogExpression.Compile("IsMatch(Name, '[invalid')"));
115+
116+
Assert.Throws<ArgumentException>(() =>
117+
SerilogExpression.Compile("Length(Name) ci"));
118+
}
119+
120+
[Theory]
121+
[InlineData("IsMatch(Name, Name)")] // Non-constant pattern
122+
[InlineData("IndexOfMatch(Text, Value)")] // Non-constant pattern
123+
public void NonConstantRegexPatternsHandledGracefully(string expression)
124+
{
125+
// These should compile but may log warnings (not errors)
126+
var result = SerilogExpression.TryCompile(expression, out var compiled, out var error);
127+
128+
// These compile successfully but return undefined at runtime
129+
Assert.True(result);
130+
Assert.NotNull(compiled);
131+
Assert.Null(error);
132+
}
133+
134+
[Fact]
135+
public void RegexTimeoutIsRespected()
136+
{
137+
// This regex should compile fine - timeout only matters at runtime
138+
var expression = @"IsMatch(Text, '(a+)+b')";
139+
140+
var result = SerilogExpression.TryCompile(expression, out var compiled, out var error);
141+
142+
Assert.True(result);
143+
Assert.NotNull(compiled);
144+
Assert.Null(error);
145+
}
146+
147+
[Fact]
148+
public void ComplexExpressionsWithMixedIssues()
149+
{
150+
var expression = "(UnknownFunc1() or IsMatch(Name, '(invalid')) and NotRealFunc() ci";
151+
var result = SerilogExpression.TryCompile(expression, out var compiled, out var error);
152+
153+
Assert.False(result);
154+
Assert.Null(compiled);
155+
Assert.NotNull(error);
156+
157+
// Should report multiple errors
158+
Assert.Contains("Multiple errors found", error);
159+
Assert.Contains("UnknownFunc1", error);
160+
Assert.Contains("NotRealFunc", error);
161+
Assert.Contains("Invalid regular expression", error);
162+
}
163+
}

0 commit comments

Comments
 (0)