From 6f64cddd0b94eddfe70c1ccca58bb7ea064ca160 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sun, 16 Nov 2025 10:32:29 -0800 Subject: [PATCH] feat: Optimizes HTML Escape (#2273) ### Summary Optimizes HTML escaping by using a vectorized search. --- .../Tests/Utility/HtmlEscapeTests.cs | 242 ++++++++++++++++++ Projects/Server/Utilities/Html.cs | 75 +++++- 2 files changed, 309 insertions(+), 8 deletions(-) create mode 100644 Projects/Server.Tests/Tests/Utility/HtmlEscapeTests.cs diff --git a/Projects/Server.Tests/Tests/Utility/HtmlEscapeTests.cs b/Projects/Server.Tests/Tests/Utility/HtmlEscapeTests.cs new file mode 100644 index 000000000..089852974 --- /dev/null +++ b/Projects/Server.Tests/Tests/Utility/HtmlEscapeTests.cs @@ -0,0 +1,242 @@ +using System; +using System.Linq; +using Xunit; + +namespace Server.Tests; + +/// +/// Tests for the Html.EscapeHtml extension methods. +/// These tests verify correct HTML entity escaping and edge cases. +/// +public class HtmlEscapeTests +{ + [Theory(DisplayName = "No escaping needed")] + [InlineData("")] + [InlineData("Hello World")] + [InlineData("Plain text without special characters")] + [InlineData("123456789")] + [InlineData("!@#$%^*()_+-=[]{}|;:,.?")] + public void EscapeHtml_NoSpecialCharacters_ReturnsUnchanged(string input) + { + var result = input.EscapeHtml(); + Assert.Equal(input, result); + } + + [Theory(DisplayName = "Single character escaping")] + [InlineData("<", "<")] + [InlineData(">", ">")] + [InlineData("&", "&")] + [InlineData("\"", """)] + [InlineData("'", "'")] + public void EscapeHtml_SingleSpecialCharacter_EscapesCorrectly(string input, string expected) + { + var result = input.EscapeHtml(); + Assert.Equal(expected, result); + } + + [Theory(DisplayName = "Multiple instances of same character")] + [InlineData("<><>", "<><>")] + [InlineData("&&&&", "&&&&")] + [InlineData("\"\"\"", """"")] + [InlineData("'''", "'''")] + [InlineData(">>>", ">>>")] + public void EscapeHtml_MultipleSpecialCharacters_EscapesAll(string input, string expected) + { + var result = input.EscapeHtml(); + Assert.Equal(expected, result); + } + + [Theory(DisplayName = "Mixed content")] + [InlineData("Hello ", "Hello <world>")] + [InlineData("
Hello
", "<div>Hello</div>")] + [InlineData("Tom & Jerry", "Tom & Jerry")] + [InlineData("He said \"hello\"", "He said "hello"")] + [InlineData("It's a test", "It's a test")] + public void EscapeHtml_MixedContent_EscapesMixedSpecialCharacters(string input, string expected) + { + var result = input.EscapeHtml(); + Assert.Equal(expected, result); + } + + [Theory(DisplayName = "Starting with special character")] + [InlineData("World", ">World")] + [InlineData("&Start", "&Start")] + [InlineData("\"Quote", ""Quote")] + [InlineData("'Apostrophe", "'Apostrophe")] + public void EscapeHtml_StartsWithSpecialCharacter_EscapesStart(string input, string expected) + { + var result = input.EscapeHtml(); + Assert.Equal(expected, result); + } + + [Theory(DisplayName = "Ending with special character")] + [InlineData("Hello<", "Hello<")] + [InlineData("World>", "World>")] + [InlineData("End&", "End&")] + [InlineData("Quote\"", "Quote"")] + [InlineData("Test'", "Test'")] + public void EscapeHtml_EndsWithSpecialCharacter_EscapesEnd(string input, string expected) + { + var result = input.EscapeHtml(); + Assert.Equal(expected, result); + } + + [Theory(DisplayName = "Complex mixed scenarios")] + [InlineData("

Hello & goodbye

", "<p>Hello & goodbye</p>")] + [InlineData("<already>", "&lt;already&gt;")] + [InlineData("", "<tag attr="value" data='test'>")] + [InlineData("ac&d\"e'f", "a<b>c&d"e'f")] + [InlineData(" ", "&nbsp;")] + public void EscapeHtml_ComplexScenarios_EscapesAllSpecialCharacters(string input, string expected) + { + var result = input.EscapeHtml(); + Assert.Equal(expected, result); + } + + [Fact(DisplayName = "Null string input")] + public void EscapeHtml_NullString_ReturnsEmpty() + { + string? input = null; + var result = input.EscapeHtml(); + Assert.Empty(result); + } + + [Fact(DisplayName = "Empty string input")] + public void EscapeHtml_EmptyString_ReturnsEmpty() + { + var result = "".EscapeHtml(); + Assert.Empty(result); + } + + [Fact(DisplayName = "Only special characters")] + public void EscapeHtml_OnlySpecialCharacters_EscapesAll() + { + const string input = "<>&\"'"; + const string expected = "<>&"'"; + var result = input.EscapeHtml(); + Assert.Equal(expected, result); + } + + [Fact(DisplayName = "Ampersand must be escaped first")] + public void EscapeHtml_AmpersandFirst_PreventDoubleEscaping() + { + // This is critical: & must be escaped to & + // If we're not careful, we could double-escape already-escaped content + const string input = "<"; + const string expected = "&lt;"; + var result = input.EscapeHtml(); + Assert.Equal(expected, result); + } + + [Theory(DisplayName = "ReadOnlySpan overload - no special characters")] + [InlineData("Hello World")] + [InlineData("Plain text")] + public void EscapeHtml_ReadOnlySpan_NoSpecialCharacters_ReturnsUnchanged(string input) + { + var result = input.AsSpan().EscapeHtml(); + Assert.Equal(input, result); + } + + [Theory(DisplayName = "ReadOnlySpan overload - with special characters")] + [InlineData("
", "<div>")] + [InlineData("Tom & Jerry", "Tom & Jerry")] + public void EscapeHtml_ReadOnlySpan_WithSpecialCharacters_EscapesCorrectly(string input, string expected) + { + var result = input.AsSpan().EscapeHtml(); + Assert.Equal(expected, result); + } + + [Theory(DisplayName = "ReadOnlySpan overload - empty input")] + [InlineData("")] + public void EscapeHtml_ReadOnlySpan_Empty_ReturnsEmpty(string input) + { + var result = input.AsSpan().EscapeHtml(); + Assert.Empty(result); + } + + [Fact(DisplayName = "Consecutive special characters")] + public void EscapeHtml_ConsecutiveSpecialCharacters_EscapesAll() + { + const string input = "<<>>&&\"\"''"; + const string expected = "<<>>&&""''"; + var result = input.EscapeHtml(); + Assert.Equal(expected, result); + } + + [Fact(DisplayName = "Special characters with single normal character between")] + public void EscapeHtml_SpecialCharactersWithGaps_EscapesAll() + { + const string input = "b&c\"d'e"; + const string expected = "<a>b&c"d'e"; + var result = input.EscapeHtml(); + Assert.Equal(expected, result); + } + + [Fact(DisplayName = "HTML tags")] + public void EscapeHtml_HtmlTags_EscapesTagBrackets() + { + var input = "Hello"; + var expected = "<html><body>Hello</body></html>"; + var result = input.EscapeHtml(); + Assert.Equal(expected, result); + } + + [Fact(DisplayName = "HTML attributes with mixed quotes")] + public void EscapeHtml_HtmlAttributesWithQuotes_EscapesCorrectly() + { + var input = ""; + var expected = "<a href="test" data='value'>"; + var result = input.EscapeHtml(); + Assert.Equal(expected, result); + } + + [Theory(DisplayName = "Whitespace handling")] + [InlineData(" spaces ", " spaces ")] + [InlineData("\ttabs\t", "\ttabs\t")] + [InlineData("\nnewlines\n", "\nnewlines\n")] + public void EscapeHtml_Whitespace_PreservedAsIs(string input, string expected) + { + var result = input.EscapeHtml(); + Assert.Equal(expected, result); + } + + [Fact(DisplayName = "Performance: long string without special characters")] + public void EscapeHtml_LongStringNoSpecialCharacters_ReturnsQuickly() + { + var input = new string('a', 10000); + var result = input.EscapeHtml(); + Assert.Equal(input, result); + } + + [Fact(DisplayName = "Performance: long string with special characters")] + public void EscapeHtml_LongStringWithSpecialCharacters_HandlesCorrectly() + { + var input = $"Start{new string('<', 100)}End{new string('&', 100)}Final"; + var expected = + $"Start{string.Join("", Enumerable.Repeat("<", 100))}End{string.Join("", Enumerable.Repeat("&", 100))}Final"; + + var result = input.EscapeHtml(); + Assert.Equal(expected, result); + } + + [Fact(DisplayName = "Unicode characters")] + public void EscapeHtml_UnicodeCharacters_PreservedWithSpecialCharsEscaped() + { + const string input = "Hello δΈ–η•Œ & πŸŽ‰"; + const string expected = "Hello δΈ–η•Œ <test> & πŸŽ‰"; + var result = input.EscapeHtml(); + Assert.Equal(expected, result); + } + + [Fact(DisplayName = "String overload matches ReadOnlySpan overload")] + public void EscapeHtml_StringVsReadOnlySpan_ProduceSameResult() + { + const string input = "
Tom & Jerry 'in' \"quotes\"
"; + + var resultString = input.EscapeHtml(); + var resultSpan = input.AsSpan().EscapeHtml(); + + Assert.Equal(resultString, resultSpan); + } +} diff --git a/Projects/Server/Utilities/Html.cs b/Projects/Server/Utilities/Html.cs index 91d2b6f3b..9377c7014 100644 --- a/Projects/Server/Utilities/Html.cs +++ b/Projects/Server/Utilities/Html.cs @@ -14,9 +14,11 @@ *************************************************************************/ using System; +using System.Buffers; using System.Runtime.CompilerServices; using System.Text; using Server.Buffers; +using Server.Text; namespace Server; @@ -237,13 +239,70 @@ public static class Html [MethodImpl(MethodImplOptions.AggressiveInlining)] public static RawInterpolatedStringHandler Right(this ReadOnlySpan text) => text.Right(-1); + private static readonly SearchValues _htmlSearchValues = SearchValues.Create('<', '>', '&', '"', '\''); + [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static string EscapeHtml(this string input) => - new StringBuilder(input.Length).Append(input) - .Replace("<", "<") - .Replace(">", ">") - .Replace("&", "&") - .Replace("\"", """) - .Replace("'", "'") - .ToString(); + public static string EscapeHtml(this string input) + { + if (string.IsNullOrEmpty(input)) + { + return input ?? ""; + } + + return EscapeHtml(input.AsSpan()); + } + + public static string EscapeHtml(this ReadOnlySpan input) + { + if (input.IsEmpty) + { + return string.Empty; + } + + int indexOfAny = input.IndexOfAny(_htmlSearchValues); + if (indexOfAny < 0) + { + return input.ToString(); + } + + using var builder = ValueStringBuilder.Create(input.Length * 2); + int lastIndex = 0; + + while (indexOfAny >= 0) + { + if (indexOfAny > lastIndex) + { + builder.Append(input[lastIndex..indexOfAny]); + } + + char c = input[indexOfAny]; + var replacement = c switch + { + '&' => "&", + '<' => "<", + '>' => ">", + '"' => """, + '\'' => "'" + }; + builder.Append(replacement); + + lastIndex = indexOfAny + 1; + indexOfAny = input[lastIndex..].IndexOfAny(_htmlSearchValues); + if (indexOfAny < 0) + { + break; + } + + indexOfAny += lastIndex; + } + + if (lastIndex < input.Length) + { + builder.Append(input[lastIndex..]); + } + + var result = builder.ToString(); + builder.Dispose(); + return result; + } }