From 31d01c449d64a7016a63a29cb265fbefc379c0c3 Mon Sep 17 00:00:00 2001 From: SandRock Date: Tue, 15 Sep 2026 09:59:02 +0000 Subject: [PATCH 1/6] Add regression tests for catastrophic backtracking on partial JSON Adds a small xUnit project (Tests/ColorCode.Core.UnitTests) covering the JSON language's Regex_String. Partial_json_does_not_catastrophically_backtrack feeds an array with an unterminated string value and asserts highlighting finishes within a time budget; against current main it does not (O(2^n) backtracking, issue #45), so this commit fails and the following fix commit makes it pass. Valid_json_still_highlights_keys_strings_and_escapes pins the tokenisation of valid JSON so the fix can be shown to change nothing for good input. Co-Authored-By: Claude Opus 4.8 Signed-off-by: SandRock --- ColorCode.sln | 23 +++++++++ .../ColorCode.Core.UnitTests.csproj | 19 +++++++ .../JsonBacktrackingTests.cs | 49 +++++++++++++++++++ 3 files changed, 91 insertions(+) create mode 100644 Tests/ColorCode.Core.UnitTests/ColorCode.Core.UnitTests.csproj create mode 100644 Tests/ColorCode.Core.UnitTests/JsonBacktrackingTests.cs diff --git a/ColorCode.sln b/ColorCode.sln index b67cc75..dcd5d87 100644 --- a/ColorCode.sln +++ b/ColorCode.sln @@ -32,6 +32,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ColorCode.UWP", "ColorCode. EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ColorCode.UWPTests", "Tests\ColorCode.UWPTests\ColorCode.UWPTests.csproj", "{A3D5A8A5-1D1F-412D-A829-C3DC1C7D4DF3}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ColorCode.Core.UnitTests", "Tests\ColorCode.Core.UnitTests\ColorCode.Core.UnitTests.csproj", "{277FBC4B-50A4-440A-811F-39AEEB088978}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -201,6 +203,26 @@ Global {A3D5A8A5-1D1F-412D-A829-C3DC1C7D4DF3}.Release|x86.ActiveCfg = Release|x86 {A3D5A8A5-1D1F-412D-A829-C3DC1C7D4DF3}.Release|x86.Build.0 = Release|x86 {A3D5A8A5-1D1F-412D-A829-C3DC1C7D4DF3}.Release|x86.Deploy.0 = Release|x86 + {277FBC4B-50A4-440A-811F-39AEEB088978}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {277FBC4B-50A4-440A-811F-39AEEB088978}.Debug|Any CPU.Build.0 = Debug|Any CPU + {277FBC4B-50A4-440A-811F-39AEEB088978}.Debug|ARM.ActiveCfg = Debug|Any CPU + {277FBC4B-50A4-440A-811F-39AEEB088978}.Debug|ARM.Build.0 = Debug|Any CPU + {277FBC4B-50A4-440A-811F-39AEEB088978}.Debug|ARM64.ActiveCfg = Debug|Any CPU + {277FBC4B-50A4-440A-811F-39AEEB088978}.Debug|ARM64.Build.0 = Debug|Any CPU + {277FBC4B-50A4-440A-811F-39AEEB088978}.Debug|x64.ActiveCfg = Debug|Any CPU + {277FBC4B-50A4-440A-811F-39AEEB088978}.Debug|x64.Build.0 = Debug|Any CPU + {277FBC4B-50A4-440A-811F-39AEEB088978}.Debug|x86.ActiveCfg = Debug|Any CPU + {277FBC4B-50A4-440A-811F-39AEEB088978}.Debug|x86.Build.0 = Debug|Any CPU + {277FBC4B-50A4-440A-811F-39AEEB088978}.Release|Any CPU.ActiveCfg = Release|Any CPU + {277FBC4B-50A4-440A-811F-39AEEB088978}.Release|Any CPU.Build.0 = Release|Any CPU + {277FBC4B-50A4-440A-811F-39AEEB088978}.Release|ARM.ActiveCfg = Release|Any CPU + {277FBC4B-50A4-440A-811F-39AEEB088978}.Release|ARM.Build.0 = Release|Any CPU + {277FBC4B-50A4-440A-811F-39AEEB088978}.Release|ARM64.ActiveCfg = Release|Any CPU + {277FBC4B-50A4-440A-811F-39AEEB088978}.Release|ARM64.Build.0 = Release|Any CPU + {277FBC4B-50A4-440A-811F-39AEEB088978}.Release|x64.ActiveCfg = Release|Any CPU + {277FBC4B-50A4-440A-811F-39AEEB088978}.Release|x64.Build.0 = Release|Any CPU + {277FBC4B-50A4-440A-811F-39AEEB088978}.Release|x86.ActiveCfg = Release|Any CPU + {277FBC4B-50A4-440A-811F-39AEEB088978}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -210,6 +232,7 @@ Global {F1ED6BD6-2690-4BF3-835C-C3927C33424F} = {5B9F207C-2EAB-4F77-95C7-206D65C87137} {DD20D31A-915E-43A2-B819-3A7AE39CA25C} = {5B9F207C-2EAB-4F77-95C7-206D65C87137} {A3D5A8A5-1D1F-412D-A829-C3DC1C7D4DF3} = {5B9F207C-2EAB-4F77-95C7-206D65C87137} + {277FBC4B-50A4-440A-811F-39AEEB088978} = {5B9F207C-2EAB-4F77-95C7-206D65C87137} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {5A764590-3191-47F4-9257-5D5F63BC5713} diff --git a/Tests/ColorCode.Core.UnitTests/ColorCode.Core.UnitTests.csproj b/Tests/ColorCode.Core.UnitTests/ColorCode.Core.UnitTests.csproj new file mode 100644 index 0000000..3a38847 --- /dev/null +++ b/Tests/ColorCode.Core.UnitTests/ColorCode.Core.UnitTests.csproj @@ -0,0 +1,19 @@ + + + + net8.0 + false + disable + + + + + + + + + + + + + diff --git a/Tests/ColorCode.Core.UnitTests/JsonBacktrackingTests.cs b/Tests/ColorCode.Core.UnitTests/JsonBacktrackingTests.cs new file mode 100644 index 0000000..828f73e --- /dev/null +++ b/Tests/ColorCode.Core.UnitTests/JsonBacktrackingTests.cs @@ -0,0 +1,49 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Threading.Tasks; +using Xunit; + +namespace ColorCode.Core.UnitTests +{ + /// + /// Regression tests for the JSON language's Regex_String catastrophic backtracking + /// (issue #45): malformed JSON must not send the highlighter into exponential-time matching. + /// + public class JsonBacktrackingTests + { + [Fact] + public void Partial_json_does_not_catastrophically_backtrack() + { + // A JSON array whose last string value is unterminated. With the old Regex_String the + // key rule explores O(2^n) ways to partition the string body and never returns; with + // the fix it is linear. Bound the call so the pre-fix state fails fast (this test + // running long IS the bug) instead of hanging the whole run. + var partial = "[\n { \"field\": \"" + new string('a', 40) + "\n"; + + string html = null; + var task = Task.Run(() => html = new HtmlClassFormatter().GetHtmlString(partial, Languages.FindById("json"))); + + Assert.True( + task.Wait(TimeSpan.FromSeconds(10)), + "Highlighting partial JSON did not finish in 10s — Regex_String is backtracking catastrophically (issue #45)."); + Assert.NotNull(html); + } + + [Fact] + public void Valid_json_still_highlights_keys_strings_and_escapes() + { + // The fix must not change how valid JSON is tokenised: keys, string values, and strings + // containing escaped quotes/backslashes all still colour correctly. + var json = "{ \"name\": \"John Doe\", \"path\": \"C:\\\\a\\\"b\" }"; + + var html = new HtmlClassFormatter().GetHtmlString(json, Languages.FindById("json")); + + Assert.Contains("jsonKey", html); + Assert.Contains("jsonString", html); + Assert.Contains("John Doe", html); + } + } +} From 5ef30a69b3baab4fd7a362573690bf4b3cdba22c Mon Sep 17 00:00:00 2001 From: SandRock Date: Tue, 15 Sep 2026 09:59:12 +0000 Subject: [PATCH 2/6] Fix catastrophic backtracking in JSON Regex_String (fixes #45) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The string pattern used Friedl's problem shape: the repeating group (?:\[^\r\n]|[^"\\]*)* has an alternative ([^"\\]*) that can match the empty string, so when a match ultimately fails (e.g. a string value in an array, matched by the key rule but with no ':' following) the engine backtracks through every way to partition the string body — O(2^n) states. A 25-character unterminated value already exceeds seconds; longer values hang indefinitely. Replace it with Friedl's unrolled loop, "[^"\\]*(?:\\.[^"\\]*)*", which removes the ambiguity: linear time, and it matches valid strings (including escaped quotes and backslashes) identically to before. Co-Authored-By: Claude Opus 4.8 Signed-off-by: SandRock --- ColorCode.Core/Compilation/Languages/Json.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ColorCode.Core/Compilation/Languages/Json.cs b/ColorCode.Core/Compilation/Languages/Json.cs index ab568d9..b5c5518 100644 --- a/ColorCode.Core/Compilation/Languages/Json.cs +++ b/ColorCode.Core/Compilation/Languages/Json.cs @@ -13,7 +13,7 @@ namespace ColorCode.Compilation.Languages /// public class Json : ILanguage { - private const string Regex_String = @"""[^""\\]*(?:\\[^\r\n]|[^""\\]*)*"""; + private const string Regex_String = @"""[^""\\]*(?:\\.[^""\\]*)*"""; private const string Regex_Number = @"-?(?:0|[1-9][0-9]*)(?:\.[0-9]*)?(?:[eE][-+]?[0-9]+)?"; public string Id From 93f490495e234b0a8d9c86357c3e8711f63ff936 Mon Sep 17 00:00:00 2001 From: SandRock Date: Tue, 15 Sep 2026 11:11:35 +0000 Subject: [PATCH 3/6] Add language registry smoke tests (#13) Verify the built-in language registry is populated, every language is findable by id, and all rule patterns compile as valid regular expressions. Co-Authored-By: Claude Opus 4.8 --- .../LanguagesSmokeTests.cs | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 Tests/ColorCode.Core.UnitTests/LanguagesSmokeTests.cs diff --git a/Tests/ColorCode.Core.UnitTests/LanguagesSmokeTests.cs b/Tests/ColorCode.Core.UnitTests/LanguagesSmokeTests.cs new file mode 100644 index 0000000..e02cce6 --- /dev/null +++ b/Tests/ColorCode.Core.UnitTests/LanguagesSmokeTests.cs @@ -0,0 +1,60 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using Xunit; + +namespace ColorCode.Core.UnitTests +{ + /// + /// Smoke tests over the built-in language registry: every language is retrievable and + /// all of its rule patterns are valid, compilable regular expressions. A malformed pattern + /// then fails here at test time rather than at a consumer's runtime (issue #13). + /// + public class LanguagesSmokeTests + { + public static IEnumerable AllLanguageIds => + Languages.All.Select(language => new object[] { language.Id }); + + [Fact] + public void Registry_exposes_languages() + { + Assert.NotEmpty(Languages.All); + } + + [Theory] + [MemberData(nameof(AllLanguageIds))] + public void Language_has_identity_and_is_findable_by_id(string id) + { + var language = Languages.FindById(id); + + Assert.NotNull(language); + Assert.False(string.IsNullOrWhiteSpace(language.Id)); + Assert.False(string.IsNullOrWhiteSpace(language.Name)); + Assert.Equal(id, language.Id); + } + + [Theory] + [MemberData(nameof(AllLanguageIds))] + public void Language_rule_patterns_compile(string id) + { + var language = Languages.FindById(id); + + Assert.NotNull(language.Rules); + Assert.NotEmpty(language.Rules); + + foreach (var rule in language.Rules) + { + // Constructing the Regex validates the pattern; an invalid pattern throws and + // fails the test, naming the offending language id and pattern. + var exception = Record.Exception(() => new Regex(rule.Regex)); + Assert.True( + exception == null, + $"Language '{id}' has an invalid rule pattern: {rule.Regex}\n{exception}"); + } + } + } +} From c4fd31228aaba33c31b3735feec688e98be7be2c Mon Sep 17 00:00:00 2001 From: SandRock Date: Tue, 15 Sep 2026 11:19:50 +0000 Subject: [PATCH 4/6] Add cross-language highlighting robustness tests (#13) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Feed malformed / partial / oversized input to every language and assert that highlighting completes within a bounded time (failsafe: a catastrophic rule fails fast rather than hanging the run). Generalizes the JSON backtracking regression (#45) across the whole language set. One case is intentionally red at this commit: 'koka' hangs on an unterminated string — the same catastrophic-backtracking class as #45 — and is fixed in a following commit. Co-Authored-By: Claude Opus 4.8 --- .../HighlightRobustnessTests.cs | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 Tests/ColorCode.Core.UnitTests/HighlightRobustnessTests.cs diff --git a/Tests/ColorCode.Core.UnitTests/HighlightRobustnessTests.cs b/Tests/ColorCode.Core.UnitTests/HighlightRobustnessTests.cs new file mode 100644 index 0000000..00ca4d2 --- /dev/null +++ b/Tests/ColorCode.Core.UnitTests/HighlightRobustnessTests.cs @@ -0,0 +1,60 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Xunit; + +namespace ColorCode.Core.UnitTests +{ + /// + /// Robustness guard: highlighting malformed / partial / oversized input for any language must + /// complete quickly and never hang. Generalizes the JSON regression (issue #45) across the whole + /// language set, so a future catastrophic-backtracking rule is caught by the suite (issue #13). + /// + public class HighlightRobustnessTests + { + // Structurally awkward inputs that a real document can contain: empty / whitespace, unbalanced + // delimiters, oversized tokens, unterminated comments, and the partial-JSON array that used to + // send Json.cs into exponential-time matching (#45). None of these should take measurable time. + public static readonly string[] MalformedInputs = + { + "", + " \n\t\r ", + new string('{', 500), + new string('a', 5000), + "// " + new string('x', 3000), + "/* " + new string('y', 3000), + "<" + new string('a', 2000), + "[\n { \"field\": \"" + new string('a', 60) + "\n", + string.Join(" ", Enumerable.Repeat("a=b,c;", 500)), + }; + + public static IEnumerable LanguageAndInput => + from language in Languages.All + from index in Enumerable.Range(0, MalformedInputs.Length) + select new object[] { language.Id, index }; + + [Theory] + [MemberData(nameof(LanguageAndInput))] + public void Highlighting_malformed_input_completes_quickly(string id, int inputIndex) + { + var language = Languages.FindById(id); + var input = MalformedInputs[inputIndex]; + + // Bound the call: a pre-fix catastrophic rule would never return, so the test failing fast + // (rather than hanging the whole run) IS the signal. A healthy rule finishes in milliseconds. + string html = null; + var task = Task.Run(() => html = new HtmlClassFormatter().GetHtmlString(input, language)); + + Assert.True( + task.Wait(TimeSpan.FromSeconds(5)), + $"Highlighting malformed input did not finish in 5s (language '{id}', input #{inputIndex}, " + + $"length {input.Length}) — likely catastrophic regex backtracking."); + Assert.NotNull(html); + } + } +} From 791bb5c6b744fe633a278f3d9c7e197dd6b73743 Mon Sep 17 00:00:00 2001 From: SandRock Date: Tue, 15 Sep 2026 11:23:19 +0000 Subject: [PATCH 5/6] Fix catastrophic backtracking in the Koka string rules (#13) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The normal string, verbatim string, and char-literal rules each used a nested quantifier (?:X+|...)* that backtracks exponentially on an unterminated literal, hanging the highlighter — the same class of bug as the JSON parser (#45). Rewritten with Friedl's unrolled loop: linear, and preserving both matching and the escape captures on valid input. Turns the cross-language robustness test (previous commit) green, and adds targeted KokaBacktrackingTests covering all three literal kinds plus a valid-string sanity check. Co-Authored-By: Claude Opus 4.8 --- ColorCode.Core/Compilation/Languages/Koka.cs | 6 +-- .../KokaBacktrackingTests.cs | 51 +++++++++++++++++++ 2 files changed, 54 insertions(+), 3 deletions(-) create mode 100644 Tests/ColorCode.Core.UnitTests/KokaBacktrackingTests.cs diff --git a/ColorCode.Core/Compilation/Languages/Koka.cs b/ColorCode.Core/Compilation/Languages/Koka.cs index c08ec76..61f96e4 100644 --- a/ColorCode.Core/Compilation/Languages/Koka.cs +++ b/ColorCode.Core/Compilation/Languages/Koka.cs @@ -196,21 +196,21 @@ public IList Rules }), new LanguageRule( - @"(?s)'(?:[^\t\n\\']+|(" + escape + @")|\\)*'", + @"(?s)'[^\t\n\\']*(?:(?:(" + escape + @")|\\)[^\t\n\\']*)*'", new Dictionary { { 0, ScopeName.String }, { 1, ScopeName.StringEscape }, }), new LanguageRule( - @"(?s)@""(?:("""")|[^""]+)*""(?!"")", + @"(?s)@""[^""]*(?:("""")[^""]*)*""(?!"")", new Dictionary { { 0, ScopeName.StringCSharpVerbatim }, { 1, ScopeName.StringEscape } }), new LanguageRule( - @"(?s)""(?:[^\t\n\\""]+|(" + escape + @")|\\)*""", + @"(?s)""[^\t\n\\""]*(?:(?:(" + escape + @")|\\)[^\t\n\\""]*)*""", new Dictionary { { 0, ScopeName.String }, diff --git a/Tests/ColorCode.Core.UnitTests/KokaBacktrackingTests.cs b/Tests/ColorCode.Core.UnitTests/KokaBacktrackingTests.cs new file mode 100644 index 0000000..1e3716f --- /dev/null +++ b/Tests/ColorCode.Core.UnitTests/KokaBacktrackingTests.cs @@ -0,0 +1,51 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Threading.Tasks; +using Xunit; + +namespace ColorCode.Core.UnitTests +{ + /// + /// Regression tests for catastrophic backtracking in the Koka language's string rules. The + /// normal string, verbatim string, and char-literal patterns each used a nested quantifier + /// ((?:X+|...)*) that went exponential on an unterminated literal; the unrolled-loop + /// form fixes it. Same class of bug as the JSON one (issue #45). + /// + public class KokaBacktrackingTests + { + private static ILanguage Koka => Languages.FindById("koka"); + + [Theory] + [InlineData("\"")] // unterminated normal string + [InlineData("@\"")] // unterminated verbatim string + [InlineData("'")] // unterminated char literal + public void Unterminated_literal_does_not_catastrophically_backtrack(string opener) + { + // An unterminated literal followed by a long run of ordinary characters. With the old + // nested-quantifier rules this never returns; with the unrolled form it is linear. Bound + // the call so the pre-fix state fails fast instead of hanging the whole run. + var input = opener + new string('a', 200) + "\n"; + + string html = null; + var task = Task.Run(() => html = new HtmlClassFormatter().GetHtmlString(input, Koka)); + + Assert.True( + task.Wait(TimeSpan.FromSeconds(5)), + $"Highlighting an unterminated Koka literal (opener '{opener}') did not finish in 5s — catastrophic backtracking."); + Assert.NotNull(html); + } + + [Fact] + public void Valid_string_still_highlights_with_the_string_scope() + { + // The fix must not change how a valid string tokenises. + var html = new HtmlClassFormatter().GetHtmlString("\"hello world\"", Koka); + + Assert.Contains("class=\"string\"", html); + Assert.Contains("hello world", html); + } + } +} From 13a9dd378f5b39e11a2b3a7dd73a7e79d5e11e44 Mon Sep 17 00:00:00 2001 From: SandRock Date: Tue, 15 Sep 2026 11:24:14 +0000 Subject: [PATCH 6/6] Add HtmlClassFormatter output tests (#13) Verify tokens receive their scope CSS class, the container div carries the language class, source text is HTML-encoded, and a stylesheet is produced. Co-Authored-By: Claude Opus 4.8 --- .../HtmlClassFormatterTests.cs | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 Tests/ColorCode.Core.UnitTests/HtmlClassFormatterTests.cs diff --git a/Tests/ColorCode.Core.UnitTests/HtmlClassFormatterTests.cs b/Tests/ColorCode.Core.UnitTests/HtmlClassFormatterTests.cs new file mode 100644 index 0000000..cfb6dc5 --- /dev/null +++ b/Tests/ColorCode.Core.UnitTests/HtmlClassFormatterTests.cs @@ -0,0 +1,54 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Xunit; + +namespace ColorCode.Core.UnitTests +{ + /// + /// Output tests for : tokens are wrapped in spans carrying the + /// expected CSS class, the container div carries the language class, source text is HTML-encoded, + /// and a stylesheet is produced (issue #13). Assertions are structural, not exact-markup, so they + /// do not couple the suite to incidental formatting. + /// + public class HtmlClassFormatterTests + { + [Fact] + public void Json_keys_and_strings_get_their_scope_classes() + { + var html = new HtmlClassFormatter().GetHtmlString("{ \"name\": \"John\" }", Languages.FindById("json")); + + Assert.Contains("class=\"json\"", html); // container div uses the language's CssClassName + Assert.Contains("jsonKey", html); + Assert.Contains("jsonString", html); + Assert.Contains("John", html); + } + + [Fact] + public void CSharp_keywords_get_the_keyword_class() + { + var html = new HtmlClassFormatter().GetHtmlString("public class Foo { }", Languages.CSharp); + + Assert.Contains("class=\"keyword\"", html); + } + + [Fact] + public void Source_text_is_html_encoded() + { + var html = new HtmlClassFormatter().GetHtmlString("if (a < b && c) { }", Languages.CSharp); + + Assert.Contains("<", html); + Assert.Contains("&", html); + } + + [Fact] + public void Css_stylesheet_is_produced() + { + var css = new HtmlClassFormatter().GetCSSString(); + + Assert.False(string.IsNullOrWhiteSpace(css)); + Assert.Contains("{", css); + } + } +}