feat: Optimizes HTML Escape (#2273)
### Summary Optimizes HTML escaping by using a vectorized search.
This commit is contained in:
parent
ee1a40bb51
commit
6f64cddd0b
2 changed files with 309 additions and 8 deletions
242
Projects/Server.Tests/Tests/Utility/HtmlEscapeTests.cs
Normal file
242
Projects/Server.Tests/Tests/Utility/HtmlEscapeTests.cs
Normal file
|
|
@ -0,0 +1,242 @@
|
||||||
|
using System;
|
||||||
|
using System.Linq;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Server.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tests for the Html.EscapeHtml extension methods.
|
||||||
|
/// These tests verify correct HTML entity escaping and edge cases.
|
||||||
|
/// </summary>
|
||||||
|
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 <world>", "Hello <world>")]
|
||||||
|
[InlineData("<div>Hello</div>", "<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("<Hello", "<Hello")]
|
||||||
|
[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("<p>Hello & goodbye</p>", "<p>Hello & goodbye</p>")]
|
||||||
|
[InlineData("<already>", "&lt;already&gt;")]
|
||||||
|
[InlineData("<tag attr=\"value\" data='test'>", "<tag attr="value" data='test'>")]
|
||||||
|
[InlineData("a<b>c&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>", "<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 = "<a>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 = "<html><body>Hello</body></html>";
|
||||||
|
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 = "<a href=\"test\" data='value'>";
|
||||||
|
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 世界 <test> & 🎉";
|
||||||
|
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 = "<div>Tom & Jerry 'in' \"quotes\"</div>";
|
||||||
|
|
||||||
|
var resultString = input.EscapeHtml();
|
||||||
|
var resultSpan = input.AsSpan().EscapeHtml();
|
||||||
|
|
||||||
|
Assert.Equal(resultString, resultSpan);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -14,9 +14,11 @@
|
||||||
*************************************************************************/
|
*************************************************************************/
|
||||||
|
|
||||||
using System;
|
using System;
|
||||||
|
using System.Buffers;
|
||||||
using System.Runtime.CompilerServices;
|
using System.Runtime.CompilerServices;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using Server.Buffers;
|
using Server.Buffers;
|
||||||
|
using Server.Text;
|
||||||
|
|
||||||
namespace Server;
|
namespace Server;
|
||||||
|
|
||||||
|
|
@ -237,13 +239,70 @@ public static class Html
|
||||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
public static RawInterpolatedStringHandler Right(this ReadOnlySpan<char> text) => text.Right(-1);
|
public static RawInterpolatedStringHandler Right(this ReadOnlySpan<char> text) => text.Right(-1);
|
||||||
|
|
||||||
|
private static readonly SearchValues<char> _htmlSearchValues = SearchValues.Create('<', '>', '&', '"', '\'');
|
||||||
|
|
||||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||||
public static string EscapeHtml(this string input) =>
|
public static string EscapeHtml(this string input)
|
||||||
new StringBuilder(input.Length).Append(input)
|
{
|
||||||
.Replace("<", "<")
|
if (string.IsNullOrEmpty(input))
|
||||||
.Replace(">", ">")
|
{
|
||||||
.Replace("&", "&")
|
return input ?? "";
|
||||||
.Replace("\"", """)
|
}
|
||||||
.Replace("'", "'")
|
|
||||||
.ToString();
|
return EscapeHtml(input.AsSpan());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string EscapeHtml(this ReadOnlySpan<char> 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue