From 31d01c449d64a7016a63a29cb265fbefc379c0c3 Mon Sep 17 00:00:00 2001 From: SandRock Date: Tue, 15 Sep 2026 09:59:02 +0000 Subject: [PATCH 1/2] 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/2] 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