", "<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;
+ }
}