From 7b598749dd66a866bfc0011b63881a994312a60b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 23:33:36 +0000 Subject: [PATCH 1/2] Let a caller ask for case-insensitive matching [minor] Glob and regex matching were case sensitive at every TextFilterMatchOptions value, with no way to ask for anything else, even though DotNet.Glob supports case-insensitivity perfectly well and the wrapper simply never passed the option through. Adds TextFilterCaseSensitivity, defaulting to CaseSensitive so no existing behaviour changes. Glob routes it to GlobOptions.Evaluation.CaseInsensitive and regex to RegexOptions.IgnoreCase. Both caches are keyed by pattern text, so the sensitivity is folded into the key; without that, whichever variant was compiled first would decide the answer for the other. Fuzzy matching does not take the setting and is documented as always case insensitive, which is what it already was. Fixes #97 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VECND7h5BXVvufyf9jbpwT --- README.md | 32 +++++++++ TextFilter.Test/TextFilterTests.cs | 93 ++++++++++++++++++++++++ TextFilter/TextFilter.cs | 109 +++++++++++++++++++++-------- 3 files changed, 205 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 7d90825..6eec78b 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ ktsu.TextFilter is a .NET library that provides methods for filtering text based - **Regular Expression Matching**: Filter text using regular expressions. - **Fuzzy Matching**: Rank text based on how well it matches a fuzzy pattern. - **Customizable Match Options**: Match by whole string, all words, or any word. +- **Case Sensitivity**: Opt into case-insensitive glob and regex matching; case sensitive by default. ## Installation @@ -98,6 +99,30 @@ bool allWordsMatch = TextFilter.Match(text, pattern, MatchOptions.AllWords); bool wholeStringMatch = TextFilter.Match(text, pattern, MatchOptions.WholeString); ``` +### Case Sensitivity + +Glob and regular expression matching are **case sensitive by default**. Pass +`TextFilterCaseSensitivity.CaseInsensitive` to fold case on both the text and the pattern: + +```csharp +using ktsu.TextFilter; + +// Case sensitive (the default) - a camera writes IMG_1234.JPG, so this does not match +bool sensitive = TextFilter.IsMatch("IMG_1234.JPG", "*.jpg", + TextFilterType.Glob, TextFilterMatchOptions.ByWholeString); // false + +// Case insensitive +bool insensitive = TextFilter.IsMatch("IMG_1234.JPG", "*.jpg", + TextFilterType.Glob, TextFilterMatchOptions.ByWholeString, + TextFilterCaseSensitivity.CaseInsensitive); // true +``` + +The setting is available on `IsMatch`, `Filter`, `DoesMatchGlob`, `DoesMatchRegex`, +`AnyTokenMatchesGlobFilter` and `AllTokensMatchGlobFilter`, and applies to required and excluded +tokens as well as optional ones. + +`TextFilterType.Fuzzy` does not take the setting: fuzzy matching is always case insensitive. + ### Filter Types TextFilter supports different filter types: @@ -151,6 +176,13 @@ The primary class for text filtering operations. | `Regex` | Use regular expression matching | | `Fuzzy` | Use fuzzy matching | +#### `TextFilterCaseSensitivity` + +| Value | Description | +|-------|-------------| +| `CaseSensitive` | Uppercase and lowercase are distinct (the default) | +| `CaseInsensitive` | Uppercase and lowercase are equivalent, for glob and regex matching | + ## Contributing Contributions are welcome! For feature requests, bug reports, or questions, please open an issue on GitHub. If you would like to contribute code, please open a pull request with your changes. diff --git a/TextFilter.Test/TextFilterTests.cs b/TextFilter.Test/TextFilterTests.cs index e74ca60..812c468 100644 --- a/TextFilter.Test/TextFilterTests.cs +++ b/TextFilter.Test/TextFilterTests.cs @@ -527,4 +527,97 @@ public void DoesMatchGlobHandlesPartialFilter() bool result = TextFilter.DoesMatchGlob("hello world", "-", TextFilterMatchOptions.ByWordAll); Assert.IsTrue(result, "Partial filter with only '-' should return true."); } + + // ---- Case sensitivity (issue #97) ---- + + [TestMethod] + public void GlobIsCaseSensitiveByDefault() + { + bool result = TextFilter.IsMatch("IMG_1234.JPG", "*.jpg", TextFilterType.Glob, TextFilterMatchOptions.ByWholeString); + Assert.IsFalse(result, "Glob matching should remain case sensitive when no sensitivity is requested."); + } + + [TestMethod] + public void GlobMatchesAcrossCaseWhenCaseInsensitiveIsRequested() + { + bool result = TextFilter.IsMatch("IMG_1234.JPG", "*.jpg", TextFilterType.Glob, TextFilterMatchOptions.ByWholeString, TextFilterCaseSensitivity.CaseInsensitive); + Assert.IsTrue(result, "'*.jpg' should match 'IMG_1234.JPG' when case insensitive matching is requested."); + } + + [TestMethod] + public void GlobCaseInsensitivityAppliesToTheFilterAsWellAsTheText() + { + bool result = TextFilter.IsMatch("photo.png", "*.PNG", TextFilterType.Glob, TextFilterMatchOptions.ByWholeString, TextFilterCaseSensitivity.CaseInsensitive); + Assert.IsTrue(result, "An uppercase filter should match lowercase text when case insensitive matching is requested."); + } + + [TestMethod] + public void GlobCaseInsensitivityDoesNotMatchUnrelatedText() + { + bool result = TextFilter.IsMatch("IMG_1234.PNG", "*.jpg", TextFilterType.Glob, TextFilterMatchOptions.ByWholeString, TextFilterCaseSensitivity.CaseInsensitive); + Assert.IsFalse(result, "Case insensitivity should fold case only, not widen the match to a different extension."); + } + + [TestMethod] + public void RegexIsCaseSensitiveByDefault() + { + bool result = TextFilter.IsMatch("IMG_1234.JPG", @".*\.jpg", TextFilterType.Regex, TextFilterMatchOptions.ByWholeString); + Assert.IsFalse(result, "Regex matching should remain case sensitive when no sensitivity is requested."); + } + + [TestMethod] + public void RegexMatchesAcrossCaseWhenCaseInsensitiveIsRequested() + { + bool result = TextFilter.IsMatch("IMG_1234.JPG", @".*\.jpg", TextFilterType.Regex, TextFilterMatchOptions.ByWholeString, TextFilterCaseSensitivity.CaseInsensitive); + Assert.IsTrue(result, "The sensitivity setting should reach the regex path, not only the glob path."); + } + + [TestMethod] + public void TheTwoSensitivitiesDoNotCollideInTheGlobCache() + { + // Both caches are keyed by pattern text. Without the sensitivity in the key, whichever of + // these ran first would decide the answer for the other, in whichever order they ran. + Assert.IsFalse(TextFilter.IsMatch("A.TXT", "*.txt", TextFilterType.Glob, TextFilterMatchOptions.ByWholeString, TextFilterCaseSensitivity.CaseSensitive)); + Assert.IsTrue(TextFilter.IsMatch("A.TXT", "*.txt", TextFilterType.Glob, TextFilterMatchOptions.ByWholeString, TextFilterCaseSensitivity.CaseInsensitive)); + + // Same check with the insensitive variant cached first, on a pattern used nowhere else, so + // the isolation holds in both orders rather than only the one the pair above happens to take. + Assert.IsTrue(TextFilter.IsMatch("B.MD", "*.md", TextFilterType.Glob, TextFilterMatchOptions.ByWholeString, TextFilterCaseSensitivity.CaseInsensitive)); + Assert.IsFalse(TextFilter.IsMatch("B.MD", "*.md", TextFilterType.Glob, TextFilterMatchOptions.ByWholeString, TextFilterCaseSensitivity.CaseSensitive)); + } + + [TestMethod] + public void TheTwoSensitivitiesDoNotCollideInTheRegexCache() + { + Assert.IsFalse(TextFilter.IsMatch("C.TXT", @".*\.txt", TextFilterType.Regex, TextFilterMatchOptions.ByWholeString, TextFilterCaseSensitivity.CaseSensitive)); + Assert.IsTrue(TextFilter.IsMatch("C.TXT", @".*\.txt", TextFilterType.Regex, TextFilterMatchOptions.ByWholeString, TextFilterCaseSensitivity.CaseInsensitive)); + } + + [TestMethod] + public void CaseInsensitivityReachesRequiredAndExcludedTokens() + { + // Required and excluded tokens go through their own call sites, so they need their own guard: + // threading the sensitivity into the optional branch alone would leave these two behind. + Assert.IsTrue(TextFilter.IsMatch("READ ME", "+read", TextFilterType.Glob, TextFilterMatchOptions.ByWordAny, TextFilterCaseSensitivity.CaseInsensitive), + "A required token should honour case insensitivity."); + Assert.IsFalse(TextFilter.IsMatch("READ ME", "-read", TextFilterType.Glob, TextFilterMatchOptions.ByWordAny, TextFilterCaseSensitivity.CaseInsensitive), + "An excluded token should honour case insensitivity."); + } + + [TestMethod] + public void FilterHonoursCaseInsensitivity() + { + List strings = ["IMG_1.JPG", "IMG_2.PNG", "notes.txt"]; + List result = [.. TextFilter.Filter(strings, "*.jpg", TextFilterType.Glob, TextFilterMatchOptions.ByWholeString, TextFilterCaseSensitivity.CaseInsensitive)]; + CollectionAssert.AreEqual(new List { "IMG_1.JPG" }, result, "Filter should pass the sensitivity through to IsMatch."); + } + + [TestMethod] + public void FuzzyMatchingIsAlwaysCaseInsensitive() + { + // Pins the claim made in TextFilterCaseSensitivity's own docs. The setting is deliberately + // ignored here, so both values must agree - and both must agree with today's behaviour. + Assert.IsTrue(TextFilter.IsMatch("HELLO", "hello", TextFilterType.Fuzzy, TextFilterMatchOptions.ByWholeString, TextFilterCaseSensitivity.CaseSensitive)); + Assert.IsTrue(TextFilter.IsMatch("HELLO", "hello", TextFilterType.Fuzzy, TextFilterMatchOptions.ByWholeString, TextFilterCaseSensitivity.CaseInsensitive)); + } } diff --git a/TextFilter/TextFilter.cs b/TextFilter/TextFilter.cs index 408752a..68fd291 100644 --- a/TextFilter/TextFilter.cs +++ b/TextFilter/TextFilter.cs @@ -47,6 +47,26 @@ public enum TextFilterMatchOptions ByWordAny, } +/// +/// Specifies whether a filter distinguishes uppercase from lowercase characters. +/// +/// +/// Applies to the and filter +/// types, which are case sensitive by default. matching is +/// unaffected: it is always case insensitive, and there is no way to make it otherwise. +/// +public enum TextFilterCaseSensitivity +{ + /// + /// Uppercase and lowercase characters are distinct, so *.jpg does not match IMG.JPG. + /// + CaseSensitive, + /// + /// Uppercase and lowercase characters are equivalent, so *.jpg matches IMG.JPG. + /// + CaseInsensitive, +} + internal enum TextFilterTokenType { Optional, @@ -64,6 +84,14 @@ public static partial class TextFilter private static ConcurrentDictionary RegexCache { get; } = []; private static ConcurrentDictionary GlobCache { get; } = []; + // Both caches are keyed by pattern text, so the same pattern compiled at two sensitivities would + // otherwise collide on the first one cached. The sensitivity is folded into the key rather than + // using a tuple key, which netstandard2.0 does not get for free. + private static string CacheKey(string pattern, TextFilterCaseSensitivity caseSensitivity) => + caseSensitivity is TextFilterCaseSensitivity.CaseInsensitive ? "i:" + pattern : "s:" + pattern; + + private static readonly GlobOptions CaseInsensitiveGlobOptions = new() { Evaluation = { CaseInsensitive = true } }; + [System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "SYSLIB1045:Convert to 'GeneratedRegexAttribute'.", Justification = "Not available in older frameworks")] private static Regex RegexMatchAnything() => new(".*", RegexOptions.Compiled); @@ -90,10 +118,11 @@ public static string GetHint(TextFilterType filterType) /// The filter pattern. /// The type of the filter. /// The options for matching text filters. + /// Whether the match distinguishes uppercase from lowercase. Ignored by . /// A collection of strings that match the filter. /// When using fuzzy matching, the strings are sorted by their match score. - public static IEnumerable Filter(IEnumerable strings, string filter, TextFilterType filterType = TextFilterType.Glob, TextFilterMatchOptions textFilterMatchOptions = TextFilterMatchOptions.ByWordAny) => - Filter(strings, s => s, filter, filterType, textFilterMatchOptions); + public static IEnumerable Filter(IEnumerable strings, string filter, TextFilterType filterType = TextFilterType.Glob, TextFilterMatchOptions textFilterMatchOptions = TextFilterMatchOptions.ByWordAny, TextFilterCaseSensitivity caseSensitivity = TextFilterCaseSensitivity.CaseSensitive) => + Filter(strings, s => s, filter, filterType, textFilterMatchOptions, caseSensitivity); /// /// Filters the specified collection of items based on the provided filter and filter type. @@ -104,9 +133,10 @@ public static IEnumerable Filter(IEnumerable strings, string fil /// The filter pattern. /// The type of the filter. /// The options for matching text filters. + /// Whether the match distinguishes uppercase from lowercase. Ignored by . /// A collection of items that match the filter. /// When using fuzzy matching, the items are sorted by their match score. - public static IEnumerable Filter(IEnumerable items, Func keySelector, string filter, TextFilterType filterType = TextFilterType.Glob, TextFilterMatchOptions textFilterMatchOptions = TextFilterMatchOptions.ByWordAny) + public static IEnumerable Filter(IEnumerable items, Func keySelector, string filter, TextFilterType filterType = TextFilterType.Glob, TextFilterMatchOptions textFilterMatchOptions = TextFilterMatchOptions.ByWordAny, TextFilterCaseSensitivity caseSensitivity = TextFilterCaseSensitivity.CaseSensitive) { Ensure.NotNull(items); Ensure.NotNull(keySelector); @@ -114,7 +144,7 @@ public static IEnumerable Filter(IEnumerable items, Func { - bool isMatch = IsMatch(keySelector(item), filter, out int score, filterType, textFilterMatchOptions); + bool isMatch = IsMatch(keySelector(item), filter, out int score, filterType, textFilterMatchOptions, caseSensitivity); return (item, isMatch, score); }) .Where(t => t.isMatch) @@ -164,8 +194,9 @@ public static IEnumerable Rank(IEnumerable items, FuncThe score of the match (used with fuzzy matching). /// The type of the filter. /// The options for matching text filters. + /// Whether the match distinguishes uppercase from lowercase. Ignored by . /// true if the text matches the filter pattern; otherwise, false. - public static bool IsMatch(string text, string filter, out int score, TextFilterType filterType = TextFilterType.Glob, TextFilterMatchOptions textFilterMatchOptions = TextFilterMatchOptions.ByWordAny) + public static bool IsMatch(string text, string filter, out int score, TextFilterType filterType = TextFilterType.Glob, TextFilterMatchOptions textFilterMatchOptions = TextFilterMatchOptions.ByWordAny, TextFilterCaseSensitivity caseSensitivity = TextFilterCaseSensitivity.CaseSensitive) { Ensure.NotNull(text); Ensure.NotNull(filter); @@ -175,8 +206,8 @@ public static bool IsMatch(string text, string filter, out int score, TextFilter return string.IsNullOrWhiteSpace(filter) || filterType switch { - TextFilterType.Glob => DoesMatchGlob(text, filter, textFilterMatchOptions), - TextFilterType.Regex => DoesMatchRegex(text, filter, textFilterMatchOptions), + TextFilterType.Glob => DoesMatchGlob(text, filter, textFilterMatchOptions, caseSensitivity), + TextFilterType.Regex => DoesMatchRegex(text, filter, textFilterMatchOptions, caseSensitivity), TextFilterType.Fuzzy => Fuzzy.Contains(text.AsSpan(), filter.AsSpan(), out score), _ => throw new NotImplementedException($"{nameof(TextFilterType)}.{filterType} has not been implemented"), }; @@ -189,9 +220,10 @@ public static bool IsMatch(string text, string filter, out int score, TextFilter /// The filter pattern. /// The type of the filter. /// The options for matching text filters. + /// Whether the match distinguishes uppercase from lowercase. Ignored by . /// true if the text matches the filter pattern; otherwise, false. - public static bool IsMatch(string text, string filter, TextFilterType filterType = TextFilterType.Glob, TextFilterMatchOptions textFilterMatchOptions = TextFilterMatchOptions.ByWordAny) - => IsMatch(text, filter, out _, filterType, textFilterMatchOptions); + public static bool IsMatch(string text, string filter, TextFilterType filterType = TextFilterType.Glob, TextFilterMatchOptions textFilterMatchOptions = TextFilterMatchOptions.ByWordAny, TextFilterCaseSensitivity caseSensitivity = TextFilterCaseSensitivity.CaseSensitive) + => IsMatch(text, filter, out _, filterType, textFilterMatchOptions, caseSensitivity); internal static HashSet ExtractTextTokens(string text, TextFilterMatchOptions textFilterMatchOptions) { @@ -236,8 +268,9 @@ internal static Dictionary> ExtractGlobFilt /// The text to match. /// The glob filter pattern. /// The options for matching text filters. + /// Whether the match distinguishes uppercase from lowercase. /// true if the text matches the glob filter pattern; otherwise, false. - public static bool DoesMatchGlob(string text, string filter, TextFilterMatchOptions textFilterMatchOptions) + public static bool DoesMatchGlob(string text, string filter, TextFilterMatchOptions textFilterMatchOptions, TextFilterCaseSensitivity caseSensitivity = TextFilterCaseSensitivity.CaseSensitive) { Ensure.NotNull(text); Ensure.NotNull(filter); @@ -265,7 +298,7 @@ public static bool DoesMatchGlob(string text, string filter, TextFilterMatchOpti optionalTokens = []; } - bool anyExcludedMatches = excludedTokens.Any(filterToken => AnyTokenMatchesGlobFilter(filterToken, textTokens)); + bool anyExcludedMatches = excludedTokens.Any(filterToken => AnyTokenMatchesGlobFilter(filterToken, textTokens, caseSensitivity)); if (anyExcludedMatches) { @@ -276,16 +309,18 @@ public static bool DoesMatchGlob(string text, string filter, TextFilterMatchOpti ? Enumerable.Any : Enumerable.All; - bool anyOptionalMatches = optionalMatchFunc(optionalTokens, filterToken => AnyTokenMatchesGlobFilter(filterToken, textTokens)); + bool anyOptionalMatches = optionalMatchFunc(optionalTokens, filterToken => AnyTokenMatchesGlobFilter(filterToken, textTokens, caseSensitivity)); if (optionalTokens.Count != 0 && !anyOptionalMatches) { return false; // optional tokens were set but text does not contain any optional tokens } + // Lambdas rather than method groups: a method group conversion will not bind the optional + // caseSensitivity parameter, so the sensitivity has to be captured explicitly. Func, bool> requiredMatchFunc = textFilterMatchOptions is TextFilterMatchOptions.ByWordAny - ? AnyTokenMatchesGlobFilter - : AllTokensMatchGlobFilter; + ? (filterToken, tokens) => AnyTokenMatchesGlobFilter(filterToken, tokens, caseSensitivity) + : (filterToken, tokens) => AllTokensMatchGlobFilter(filterToken, tokens, caseSensitivity); bool allRequiredMatches = requiredTokens.All(filterToken => requiredMatchFunc(filterToken, textTokens)); @@ -302,17 +337,14 @@ public static bool DoesMatchGlob(string text, string filter, TextFilterMatchOpti /// /// The glob filter token. /// The set of text tokens to match against. + /// Whether the match distinguishes uppercase from lowercase. /// true if any token matches the glob filter token; otherwise, false. - public static bool AnyTokenMatchesGlobFilter(string filterToken, HashSet textTokens) + public static bool AnyTokenMatchesGlobFilter(string filterToken, HashSet textTokens, TextFilterCaseSensitivity caseSensitivity = TextFilterCaseSensitivity.CaseSensitive) { Ensure.NotNull(filterToken); Ensure.NotNull(textTokens); - if (!GlobCache.TryGetValue(filterToken, out Glob? glob)) - { - glob = Glob.Parse(filterToken); - GlobCache.TryAdd(filterToken, glob); - } + Glob glob = ResolveGlob(filterToken, caseSensitivity); return textTokens.Any(glob.IsMatch); } @@ -322,19 +354,32 @@ public static bool AnyTokenMatchesGlobFilter(string filterToken, HashSet /// /// The glob filter token. /// The set of text tokens to match against. + /// Whether the match distinguishes uppercase from lowercase. /// true if all tokens match the glob filter token; otherwise, false. - public static bool AllTokensMatchGlobFilter(string filterToken, HashSet textTokens) + public static bool AllTokensMatchGlobFilter(string filterToken, HashSet textTokens, TextFilterCaseSensitivity caseSensitivity = TextFilterCaseSensitivity.CaseSensitive) { Ensure.NotNull(filterToken); Ensure.NotNull(textTokens); - if (!GlobCache.TryGetValue(filterToken, out Glob? glob)) + Glob glob = ResolveGlob(filterToken, caseSensitivity); + + return textTokens.All(glob.IsMatch); + } + + private static Glob ResolveGlob(string filterToken, TextFilterCaseSensitivity caseSensitivity) + { + string cacheKey = CacheKey(filterToken, caseSensitivity); + + if (!GlobCache.TryGetValue(cacheKey, out Glob? glob)) { - glob = Glob.Parse(filterToken); - GlobCache.TryAdd(filterToken, glob); + glob = caseSensitivity is TextFilterCaseSensitivity.CaseInsensitive + ? Glob.Parse(filterToken, CaseInsensitiveGlobOptions) + : Glob.Parse(filterToken); + + GlobCache.TryAdd(cacheKey, glob); } - return textTokens.All(glob.IsMatch); + return glob; } /// @@ -343,19 +388,25 @@ public static bool AllTokensMatchGlobFilter(string filterToken, HashSet /// The text to match. /// The regex filter pattern. /// The options for matching text filters. + /// Whether the match distinguishes uppercase from lowercase. /// true if the text matches the regex filter pattern; otherwise, false. - public static bool DoesMatchRegex(string text, string filter, TextFilterMatchOptions textFilterMatchOptions) + public static bool DoesMatchRegex(string text, string filter, TextFilterMatchOptions textFilterMatchOptions, TextFilterCaseSensitivity caseSensitivity = TextFilterCaseSensitivity.CaseSensitive) { Ensure.NotNull(text); Ensure.NotNull(filter); // check if regex is valid HashSet textTokens = ExtractTextTokens(text, textFilterMatchOptions); - if (!RegexCache.TryGetValue(filter, out Regex? regex)) + string cacheKey = CacheKey(filter, caseSensitivity); + if (!RegexCache.TryGetValue(cacheKey, out Regex? regex)) { + RegexOptions regexOptions = caseSensitivity is TextFilterCaseSensitivity.CaseInsensitive + ? RegexOptions.Compiled | RegexOptions.IgnoreCase + : RegexOptions.Compiled; + try { - regex = new Regex(filter, RegexOptions.Compiled); + regex = new Regex(filter, regexOptions); } catch (ArgumentException) { @@ -364,7 +415,7 @@ public static bool DoesMatchRegex(string text, string filter, TextFilterMatchOpt regex = RegexMatchAnything(); } - RegexCache.TryAdd(filter, regex); + RegexCache.TryAdd(cacheKey, regex); } Func, Func, bool> matchFunc = textFilterMatchOptions is TextFilterMatchOptions.ByWordAny From b249339e8e03a09a31640f620bcf7d094b470433 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 23:49:24 +0000 Subject: [PATCH 2/2] Bound regex evaluation with a match timeout [patch] SonarCloud S6444 on the previous commit: the Regex constructed from the caller-supplied filter had no timeout, so a pattern with catastrophic backtracking runs unbounded on the calling thread. The line is pre-existing but this branch rewrote it, which is fair enough - it is a real ReDoS on a public entry point that takes arbitrary pattern text. Passes a one-second match timeout, and catches RegexMatchTimeoutException at the match site rather than letting the new throw path reach callers: filtering is a predicate, and a list that throws mid-keystroke on a pathological pattern is a worse contract than one that returns nothing for it. That mirrors how an invalid pattern already degrades. Also switches the one new CollectionAssert.AreEqual to Assert.AreSequenceEqual for MSTEST0068. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VECND7h5BXVvufyf9jbpwT --- TextFilter.Test/TextFilterTests.cs | 27 ++++++++++++++++++++++++++- TextFilter/TextFilter.cs | 28 ++++++++++++++++++++++++++-- 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/TextFilter.Test/TextFilterTests.cs b/TextFilter.Test/TextFilterTests.cs index 812c468..aa20cb1 100644 --- a/TextFilter.Test/TextFilterTests.cs +++ b/TextFilter.Test/TextFilterTests.cs @@ -609,7 +609,7 @@ public void FilterHonoursCaseInsensitivity() { List strings = ["IMG_1.JPG", "IMG_2.PNG", "notes.txt"]; List result = [.. TextFilter.Filter(strings, "*.jpg", TextFilterType.Glob, TextFilterMatchOptions.ByWholeString, TextFilterCaseSensitivity.CaseInsensitive)]; - CollectionAssert.AreEqual(new List { "IMG_1.JPG" }, result, "Filter should pass the sensitivity through to IsMatch."); + Assert.AreSequenceEqual(["IMG_1.JPG"], result, "Filter should pass the sensitivity through to IsMatch."); } [TestMethod] @@ -620,4 +620,29 @@ public void FuzzyMatchingIsAlwaysCaseInsensitive() Assert.IsTrue(TextFilter.IsMatch("HELLO", "hello", TextFilterType.Fuzzy, TextFilterMatchOptions.ByWholeString, TextFilterCaseSensitivity.CaseSensitive)); Assert.IsTrue(TextFilter.IsMatch("HELLO", "hello", TextFilterType.Fuzzy, TextFilterMatchOptions.ByWholeString, TextFilterCaseSensitivity.CaseInsensitive)); } + + [TestMethod] + public void ACatastrophicallyBacktrackingPatternTimesOutInsteadOfHanging() + { + // Filter patterns are caller-supplied, so this is the ReDoS shape: (a+)+$ against a run of + // 'a' terminated by a non-matching character backtracks exponentially. Without a timeout on + // the Regex this call does not return; with one it must come back quickly and report false. + string pattern = "(a+)+$"; + string text = new string('a', 40) + "X"; + + System.Diagnostics.Stopwatch stopwatch = System.Diagnostics.Stopwatch.StartNew(); + bool result = TextFilter.IsMatch(text, pattern, TextFilterType.Regex, TextFilterMatchOptions.ByWholeString); + stopwatch.Stop(); + + Assert.IsFalse(result, "A pattern that cannot be evaluated in time should report no match, not throw."); + Assert.IsLessThan(15_000, stopwatch.ElapsedMilliseconds, "The match should be bounded by the regex timeout rather than running unbounded."); + } + + [TestMethod] + public void AnOrdinaryRegexIsUnaffectedByTheTimeout() + { + // Guards the direction the timeout could have broken: a normal pattern still matches. + Assert.IsTrue(TextFilter.IsMatch("hello world", "^hello", TextFilterType.Regex, TextFilterMatchOptions.ByWholeString)); + Assert.IsFalse(TextFilter.IsMatch("hello world", "^goodbye", TextFilterType.Regex, TextFilterMatchOptions.ByWholeString)); + } } diff --git a/TextFilter/TextFilter.cs b/TextFilter/TextFilter.cs index 68fd291..3fc3359 100644 --- a/TextFilter/TextFilter.cs +++ b/TextFilter/TextFilter.cs @@ -92,6 +92,11 @@ private static string CacheKey(string pattern, TextFilterCaseSensitivity caseSen private static readonly GlobOptions CaseInsensitiveGlobOptions = new() { Evaluation = { CaseInsensitive = true } }; + // Filter patterns are caller-supplied text, so a pattern with catastrophic backtracking would + // otherwise run unbounded on the calling thread. One second is far longer than any legitimate + // filter needs and short enough that a pathological one cannot wedge a UI. + private static readonly TimeSpan RegexMatchTimeout = TimeSpan.FromSeconds(1); + [System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "SYSLIB1045:Convert to 'GeneratedRegexAttribute'.", Justification = "Not available in older frameworks")] private static Regex RegexMatchAnything() => new(".*", RegexOptions.Compiled); @@ -390,6 +395,11 @@ private static Glob ResolveGlob(string filterToken, TextFilterCaseSensitivity ca /// The options for matching text filters. /// Whether the match distinguishes uppercase from lowercase. /// true if the text matches the regex filter pattern; otherwise, false. + /// + /// An invalid pattern matches everything. A pattern that cannot be evaluated within one second — + /// catastrophic backtracking, for instance — reports no match for the token that timed out + /// rather than throwing, so a caller-supplied pattern cannot hang the calling thread. + /// public static bool DoesMatchRegex(string text, string filter, TextFilterMatchOptions textFilterMatchOptions, TextFilterCaseSensitivity caseSensitivity = TextFilterCaseSensitivity.CaseSensitive) { Ensure.NotNull(text); @@ -406,7 +416,7 @@ public static bool DoesMatchRegex(string text, string filter, TextFilterMatchOpt try { - regex = new Regex(filter, regexOptions); + regex = new Regex(filter, regexOptions, RegexMatchTimeout); } catch (ArgumentException) { @@ -422,6 +432,20 @@ public static bool DoesMatchRegex(string text, string filter, TextFilterMatchOpt ? Enumerable.Any : Enumerable.All; - return matchFunc(textTokens, textToken => regex.IsMatch(textToken)); + return matchFunc(textTokens, textToken => + { + try + { + return regex.IsMatch(textToken); + } + catch (RegexMatchTimeoutException) + { + // A pattern that cannot be evaluated within the timeout is treated as not matching + // this token rather than thrown at the caller. Filtering is a predicate, and a list + // that throws mid-keystroke on a pathological pattern is a worse contract than one + // that returns nothing for it. This mirrors how an invalid pattern degrades above. + return false; + } + }); } }