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.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<char> text) => text.Right(-1);
|
||||
|
||||
private static readonly SearchValues<char> _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<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