Skip to content

Commit fffed3b

Browse files
[Breaking] Enforce case-sensitive date math parsing per Elasticsearch spec
Align DateMath parsing with the Elasticsearch date math specification by enforcing case-sensitive matching. The anchor 'now' must be lowercase, and date-math units are case-sensitive (M=months, m=minutes, d=days, D=invalid). Additionally improve string comparison quality across all parsers by replacing instance .Equals() and == with String.Equals(), and eliminating culture-sensitive .ToLower() allocations in favor of String.Equals with OrdinalIgnoreCase or .ToLowerInvariant() where switch statements require it. Breaking changes: - 'Now', 'NOW', and other mixed-case variants of 'now' are no longer valid - Uppercase units D, Y, W, S are no longer valid (were silently accepted) Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 486a6c0 commit fffed3b

17 files changed

+186
-55
lines changed

Exceptionless.DateTimeExtensions.slnx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
<Folder Name="/Solution Items/">
33
<File Path=".github/workflows/build.yml" />
44
<File Path="build/common.props" />
5+
<File Path="global.json" />
56
<File Path="README.md" />
67
</Folder>
78
<Project Path="src/Exceptionless.DateTimeExtensions/Exceptionless.DateTimeExtensions.csproj" />

src/Exceptionless.DateTimeExtensions/DateMath.cs

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,14 @@ public static class DateMath
2323
// Match date math expressions with positional and end anchors for flexible matching
2424
// Uses \G for positional matching and lookahead for boundary detection to support both
2525
// full string parsing and positional matching within TwoPartFormatParser
26+
// NOTE: Case-sensitive matching is intentional per Elasticsearch spec. The anchor 'now' must
27+
// be lowercase, and date-math units are case-sensitive: y, M, w, d, h, H, m, s.
28+
// Uppercase D is NOT a valid unit. See:
29+
// https://www.elastic.co/docs/reference/elasticsearch/rest-apis/common-options
2630
internal static readonly Regex Parser = new(
2731
@"\G(?<anchor>now|(?<date>\d{4}-?\d{2}-?\d{2}(?:[T\s](?:\d{1,2}(?::?\d{2}(?::?\d{2})?)?(?:\.\d{1,3})?)?(?:[+-]\d{2}:?\d{2}|Z)?)?)\|\|)" +
2832
@"(?<operations>(?:[+\-/]\d*[yMwdhHms])*)(?=\s|$|[\]\}])",
29-
RegexOptions.Compiled | RegexOptions.IgnoreCase);
33+
RegexOptions.Compiled);
3034

3135
// Pre-compiled regex for operation parsing to avoid repeated compilation
3236
private static readonly Regex _operationRegex = new(@"([+\-/])(\d*)([yMwdhHms])", RegexOptions.Compiled);
@@ -141,7 +145,7 @@ public static bool TryParseFromMatch(Match match, DateTimeOffset relativeBaseTim
141145
DateTimeOffset baseTime;
142146
string anchor = match.Groups["anchor"].Value;
143147

144-
if (anchor.Equals("now", StringComparison.OrdinalIgnoreCase))
148+
if (String.Equals(anchor, "now"))
145149
{
146150
baseTime = relativeBaseTime;
147151
}
@@ -183,7 +187,7 @@ public static bool TryParseFromMatch(Match match, TimeZoneInfo timeZone, bool is
183187
DateTimeOffset baseTime;
184188
string anchor = match.Groups["anchor"].Value;
185189

186-
if (anchor.Equals("now", StringComparison.OrdinalIgnoreCase))
190+
if (String.Equals(anchor, "now"))
187191
{
188192
// Use current time in the specified timezone
189193
baseTime = TimeZoneInfo.ConvertTime(DateTimeOffset.UtcNow, timeZone);
@@ -463,7 +467,7 @@ public static DateTimeOffset ApplyOperations(DateTimeOffset baseTime, string ope
463467
for (int i = 0; i < matches.Count; i++)
464468
{
465469
string operation = matches[i].Groups[1].Value;
466-
if (operation == "/")
470+
if (String.Equals(operation, "/"))
467471
{
468472
if (foundRounding)
469473
{

src/Exceptionless.DateTimeExtensions/FormatParsers/FormatParsers/Helper.cs

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
using System;
1+
using System;
22
using System.Collections.Generic;
33
using System.Linq;
44

@@ -21,21 +21,26 @@ internal static class Helper
2121

2222
internal static TimeSpan GetTimeSpanFromName(string name)
2323
{
24-
return name.ToLower() switch
25-
{
26-
"minutes" => TimeSpan.FromMinutes(1),
27-
"minute" => TimeSpan.FromMinutes(1),
28-
"hours" => TimeSpan.FromHours(1),
29-
"hour" => TimeSpan.FromHours(1),
30-
"days" => TimeSpan.FromDays(1),
31-
"day" => TimeSpan.FromDays(1),
32-
_ => TimeSpan.Zero
33-
};
24+
if (String.Equals(name, "minutes", StringComparison.OrdinalIgnoreCase) ||
25+
String.Equals(name, "minute", StringComparison.OrdinalIgnoreCase))
26+
return TimeSpan.FromMinutes(1);
27+
28+
if (String.Equals(name, "hours", StringComparison.OrdinalIgnoreCase) ||
29+
String.Equals(name, "hour", StringComparison.OrdinalIgnoreCase))
30+
return TimeSpan.FromHours(1);
31+
32+
if (String.Equals(name, "days", StringComparison.OrdinalIgnoreCase) ||
33+
String.Equals(name, "day", StringComparison.OrdinalIgnoreCase))
34+
return TimeSpan.FromDays(1);
35+
36+
return TimeSpan.Zero;
3437
}
3538

3639
internal static int GetMonthNumber(string name)
3740
{
38-
int index = MonthNames.FindIndex(m => m.Equals(name, StringComparison.OrdinalIgnoreCase) || m.Substring(0, 3).Equals(name, StringComparison.OrdinalIgnoreCase));
41+
int index = MonthNames.FindIndex(m =>
42+
String.Equals(m, name, StringComparison.OrdinalIgnoreCase) ||
43+
String.Equals(m.Substring(0, 3), name, StringComparison.OrdinalIgnoreCase));
3944
return index >= 0 ? index + 1 : -1;
4045
}
4146

src/Exceptionless.DateTimeExtensions/FormatParsers/FormatParsers/MonthRelationFormatParser.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ public virtual DateTimeRange Parse(string content, DateTimeOffset relativeBaseTi
1414
if (!m.Success)
1515
return null;
1616

17-
string relation = m.Groups["relation"].Value.ToLower();
17+
string relation = m.Groups["relation"].Value.ToLowerInvariant();
1818
int month = Helper.GetMonthNumber(m.Groups["month"].Value);
1919
return FromMonthRelation(relation, month, relativeBaseTime);
2020
}

src/Exceptionless.DateTimeExtensions/FormatParsers/FormatParsers/NamedDayFormatParser.cs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,12 @@ public DateTimeRange Parse(string content, DateTimeOffset relativeBaseTime)
1414
if (!m.Success)
1515
return null;
1616

17-
string value = m.Groups["name"].Value.ToLower();
18-
if (value == "today")
17+
string value = m.Groups["name"].Value;
18+
if (String.Equals(value, "today", StringComparison.OrdinalIgnoreCase))
1919
return new DateTimeRange(relativeBaseTime.StartOfDay(), relativeBaseTime.EndOfDay());
20-
if (value == "yesterday")
20+
if (String.Equals(value, "yesterday", StringComparison.OrdinalIgnoreCase))
2121
return new DateTimeRange(relativeBaseTime.SubtractDays(1).StartOfDay(), relativeBaseTime.SubtractDays(1).EndOfDay());
22-
if (value == "tomorrow")
22+
if (String.Equals(value, "tomorrow", StringComparison.OrdinalIgnoreCase))
2323
return new DateTimeRange(relativeBaseTime.AddDays(1).StartOfDay(), relativeBaseTime.AddDays(1).EndOfDay());
2424

2525
return null;

src/Exceptionless.DateTimeExtensions/FormatParsers/FormatParsers/PartParsers/AmountTimeRelationPartParser.cs

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
using System;
1+
using System;
22
using System.Text.RegularExpressions;
33

44
namespace Exceptionless.DateTimeExtensions.FormatParsers.PartParsers;
@@ -21,8 +21,7 @@ public class AmountTimeRelationPartParser : IPartParser
2121

2222
protected DateTimeOffset? FromRelationAmountTime(string relation, int amount, string size, DateTimeOffset relativeBaseTime, bool isUpperLimit)
2323
{
24-
relation = relation.ToLower();
25-
size = size.ToLower();
24+
relation = relation.ToLowerInvariant();
2625
if (amount < 1)
2726
throw new ArgumentException("Time amount can't be 0.");
2827
var intervalSpan = Helper.GetTimeSpanFromName(size);
@@ -38,7 +37,7 @@ public class AmountTimeRelationPartParser : IPartParser
3837
return isUpperLimit ? relativeBaseTime.SafeAdd(totalSpan).Ceiling(intervalSpan).SubtractMilliseconds(1) : relativeBaseTime.SafeAdd(totalSpan).Floor(intervalSpan);
3938
}
4039
}
41-
else if (size == "week" || size == "weeks")
40+
else if (String.Equals(size, "week", StringComparison.OrdinalIgnoreCase) || String.Equals(size, "weeks", StringComparison.OrdinalIgnoreCase))
4241
{
4342
switch (relation)
4443
{
@@ -48,7 +47,7 @@ public class AmountTimeRelationPartParser : IPartParser
4847
return isUpperLimit ? relativeBaseTime.AddWeeks(amount).EndOfDay() : relativeBaseTime.AddWeeks(amount).StartOfDay();
4948
}
5049
}
51-
else if (size == "month" || size == "months")
50+
else if (String.Equals(size, "month", StringComparison.OrdinalIgnoreCase) || String.Equals(size, "months", StringComparison.OrdinalIgnoreCase))
5251
{
5352
switch (relation)
5453
{
@@ -58,7 +57,7 @@ public class AmountTimeRelationPartParser : IPartParser
5857
return isUpperLimit ? relativeBaseTime.AddMonths(amount).EndOfDay() : relativeBaseTime.AddMonths(amount).StartOfDay();
5958
}
6059
}
61-
else if (size == "year" || size == "years")
60+
else if (String.Equals(size, "year", StringComparison.OrdinalIgnoreCase) || String.Equals(size, "years", StringComparison.OrdinalIgnoreCase))
6261
{
6362
switch (relation)
6463
{

src/Exceptionless.DateTimeExtensions/FormatParsers/FormatParsers/PartParsers/MonthRelationPartParser.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ public class MonthRelationPartParser : IPartParser
1111

1212
public virtual DateTimeOffset? Parse(Match match, DateTimeOffset relativeBaseTime, bool isUpperLimit)
1313
{
14-
string relation = match.Groups["relation"].Value.ToLower();
14+
string relation = match.Groups["relation"].Value.ToLowerInvariant();
1515
int month = Helper.GetMonthNumber(match.Groups["month"].Value);
1616
return FromMonthRelation(relation, month, relativeBaseTime, isUpperLimit);
1717
}

src/Exceptionless.DateTimeExtensions/FormatParsers/FormatParsers/PartParsers/NamedDayPartParser.cs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
using System;
1+
using System;
22
using System.Text.RegularExpressions;
33

44
namespace Exceptionless.DateTimeExtensions.FormatParsers.PartParsers;
@@ -12,14 +12,14 @@ public class NamedDayPartParser : IPartParser
1212

1313
public DateTimeOffset? Parse(Match match, DateTimeOffset relativeBaseTime, bool isUpperLimit)
1414
{
15-
string value = match.Groups["name"].Value.ToLower();
16-
if (value == "now")
15+
string value = match.Groups["name"].Value;
16+
if (String.Equals(value, "now", StringComparison.OrdinalIgnoreCase))
1717
return relativeBaseTime;
18-
if (value == "today")
18+
if (String.Equals(value, "today", StringComparison.OrdinalIgnoreCase))
1919
return isUpperLimit ? relativeBaseTime.EndOfDay() : relativeBaseTime.StartOfDay();
20-
if (value == "yesterday")
20+
if (String.Equals(value, "yesterday", StringComparison.OrdinalIgnoreCase))
2121
return isUpperLimit ? relativeBaseTime.SubtractDays(1).EndOfDay() : relativeBaseTime.SubtractDays(1).StartOfDay();
22-
if (value == "tomorrow")
22+
if (String.Equals(value, "tomorrow", StringComparison.OrdinalIgnoreCase))
2323
return isUpperLimit ? relativeBaseTime.AddDays(1).EndOfDay() : relativeBaseTime.AddDays(1).StartOfDay();
2424

2525
return null;

src/Exceptionless.DateTimeExtensions/FormatParsers/FormatParsers/RelationAmountTimeFormatParser.cs

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,7 @@ public virtual DateTimeRange Parse(string content, DateTimeOffset relativeBaseTi
1919

2020
protected DateTimeRange FromRelationAmountTime(string relation, int amount, string size, DateTimeOffset relativeBaseTime)
2121
{
22-
relation = relation.ToLower();
23-
size = size.ToLower();
22+
relation = relation.ToLowerInvariant();
2423
if (amount < 1)
2524
throw new ArgumentException("Time amount can't be 0.");
2625

@@ -40,7 +39,7 @@ protected DateTimeRange FromRelationAmountTime(string relation, int amount, stri
4039
return new DateTimeRange(relativeBaseTime, relativeBaseTime.SafeAdd(totalSpan).Ceiling(intervalSpan).SubtractMilliseconds(1));
4140
}
4241
}
43-
else if (size == "week" || size == "weeks")
42+
else if (String.Equals(size, "week", StringComparison.OrdinalIgnoreCase) || String.Equals(size, "weeks", StringComparison.OrdinalIgnoreCase))
4443
{
4544
switch (relation)
4645
{
@@ -53,7 +52,7 @@ protected DateTimeRange FromRelationAmountTime(string relation, int amount, stri
5352
return new DateTimeRange(relativeBaseTime, relativeBaseTime.AddWeeks(amount).EndOfDay());
5453
}
5554
}
56-
else if (size == "month" || size == "months")
55+
else if (String.Equals(size, "month", StringComparison.OrdinalIgnoreCase) || String.Equals(size, "months", StringComparison.OrdinalIgnoreCase))
5756
{
5857
switch (relation)
5958
{
@@ -66,7 +65,7 @@ protected DateTimeRange FromRelationAmountTime(string relation, int amount, stri
6665
return new DateTimeRange(relativeBaseTime, relativeBaseTime.AddMonths(amount).EndOfDay());
6766
}
6867
}
69-
else if (size == "year" || size == "years")
68+
else if (String.Equals(size, "year", StringComparison.OrdinalIgnoreCase) || String.Equals(size, "years", StringComparison.OrdinalIgnoreCase))
7069
{
7170
switch (relation)
7271
{

src/Exceptionless.DateTimeExtensions/FormatParsers/FormatParsers/TwoPartFormatParser.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ private static bool IsValidBracketPair(string opening, string closing)
102102
return false;
103103

104104
// Check for proper matching pairs
105-
return (opening == "[" && closing == "]") ||
106-
(opening == "{" && closing == "}");
105+
return (String.Equals(opening, "[") && String.Equals(closing, "]")) ||
106+
(String.Equals(opening, "{") && String.Equals(closing, "}"));
107107
}
108108
}

0 commit comments

Comments
 (0)