From 5667017874c4084b137b82bee48de3b6703c7f3e Mon Sep 17 00:00:00 2001 From: Bohica <53943479+Bohicatv@users.noreply.github.com> Date: Sat, 15 Nov 2025 10:15:10 -0800 Subject: [PATCH 1/6] fix: Fixes pets from attacking themselves (#2265) ### Summary Stops pets from attacking themselves. --- Projects/UOContent/Mobiles/AI/AnimalAI.cs | 8 ++++++++ Projects/UOContent/Mobiles/AI/MageAI.cs | 8 ++++++++ Projects/UOContent/Mobiles/AI/MeleeAI.cs | 8 ++++++++ 3 files changed, 24 insertions(+) diff --git a/Projects/UOContent/Mobiles/AI/AnimalAI.cs b/Projects/UOContent/Mobiles/AI/AnimalAI.cs index 9e33414c7..a25355078 100644 --- a/Projects/UOContent/Mobiles/AI/AnimalAI.cs +++ b/Projects/UOContent/Mobiles/AI/AnimalAI.cs @@ -46,6 +46,14 @@ public class AnimalAI : BaseAI { var combatant = Mobile.Combatant; + if (Mobile.Controlled && combatant == Mobile) + { + DebugSay("I should not attack myself!"); + Mobile.Combatant = null; + Action = ActionType.Guard; + return true; + } + if (combatant == null || combatant.Deleted || combatant.Map != Mobile.Map || !combatant.Alive || combatant.IsDeadBondedPet) { diff --git a/Projects/UOContent/Mobiles/AI/MageAI.cs b/Projects/UOContent/Mobiles/AI/MageAI.cs index 03fe60a6a..c1e6b70f4 100644 --- a/Projects/UOContent/Mobiles/AI/MageAI.cs +++ b/Projects/UOContent/Mobiles/AI/MageAI.cs @@ -624,6 +624,14 @@ public class MageAI : BaseAI var c = Mobile.Combatant; Mobile.Warmode = true; + if (Mobile.Controlled && c == Mobile) + { + DebugSay("I should not attack myself!"); + Mobile.Combatant = null; + Action = ActionType.Guard; + return true; + } + if (c?.Deleted != false || !c.Alive || c.IsDeadBondedPet || !Mobile.CanSee(c) || !Mobile.CanBeHarmful(c, false) || c.Map != Mobile.Map) { diff --git a/Projects/UOContent/Mobiles/AI/MeleeAI.cs b/Projects/UOContent/Mobiles/AI/MeleeAI.cs index edf0b108e..44fe40370 100644 --- a/Projects/UOContent/Mobiles/AI/MeleeAI.cs +++ b/Projects/UOContent/Mobiles/AI/MeleeAI.cs @@ -33,6 +33,14 @@ public class MeleeAI : BaseAI { var combatant = Mobile.Combatant; + if (Mobile.Controlled && combatant == Mobile) + { + DebugSay("I should not attack myself!"); + Mobile.Combatant = null; + Action = ActionType.Guard; + return true; + } + if (combatant == null || combatant.Deleted || combatant.Map != Mobile.Map || !combatant.Alive || combatant.IsDeadBondedPet) { From 6cabac5a4f1981f01403040003e82556f87c48f4 Mon Sep 17 00:00:00 2001 From: Bohica <53943479+Bohicatv@users.noreply.github.com> Date: Sat, 15 Nov 2025 14:21:18 -0800 Subject: [PATCH 2/6] fix: Fixes pets from attacking themselves (Again) (#2267) --- Projects/UOContent/Mobiles/AI/AnimalAI.cs | 8 -------- Projects/UOContent/Mobiles/AI/MageAI.cs | 16 ++++++++-------- Projects/UOContent/Mobiles/AI/MeleeAI.cs | 8 -------- 3 files changed, 8 insertions(+), 24 deletions(-) diff --git a/Projects/UOContent/Mobiles/AI/AnimalAI.cs b/Projects/UOContent/Mobiles/AI/AnimalAI.cs index a25355078..9e33414c7 100644 --- a/Projects/UOContent/Mobiles/AI/AnimalAI.cs +++ b/Projects/UOContent/Mobiles/AI/AnimalAI.cs @@ -46,14 +46,6 @@ public class AnimalAI : BaseAI { var combatant = Mobile.Combatant; - if (Mobile.Controlled && combatant == Mobile) - { - DebugSay("I should not attack myself!"); - Mobile.Combatant = null; - Action = ActionType.Guard; - return true; - } - if (combatant == null || combatant.Deleted || combatant.Map != Mobile.Map || !combatant.Alive || combatant.IsDeadBondedPet) { diff --git a/Projects/UOContent/Mobiles/AI/MageAI.cs b/Projects/UOContent/Mobiles/AI/MageAI.cs index c1e6b70f4..9ed02cd02 100644 --- a/Projects/UOContent/Mobiles/AI/MageAI.cs +++ b/Projects/UOContent/Mobiles/AI/MageAI.cs @@ -624,14 +624,6 @@ public class MageAI : BaseAI var c = Mobile.Combatant; Mobile.Warmode = true; - if (Mobile.Controlled && c == Mobile) - { - DebugSay("I should not attack myself!"); - Mobile.Combatant = null; - Action = ActionType.Guard; - return true; - } - if (c?.Deleted != false || !c.Alive || c.IsDeadBondedPet || !Mobile.CanSee(c) || !Mobile.CanBeHarmful(c, false) || c.Map != Mobile.Map) { @@ -721,6 +713,14 @@ public class MageAI : BaseAI } else if (Mobile.Spell == null && Core.TickCount - _nextCastTime >= 0) { + if (Mobile.Controlled && c == Mobile) + { + DebugSay("I should not attack myself!"); + Mobile.Combatant = null; + Action = ActionType.Guard; + return true; + } + // We are ready to cast a spell Spell spell; var toDispel = FindDispelTarget(true); diff --git a/Projects/UOContent/Mobiles/AI/MeleeAI.cs b/Projects/UOContent/Mobiles/AI/MeleeAI.cs index 44fe40370..edf0b108e 100644 --- a/Projects/UOContent/Mobiles/AI/MeleeAI.cs +++ b/Projects/UOContent/Mobiles/AI/MeleeAI.cs @@ -33,14 +33,6 @@ public class MeleeAI : BaseAI { var combatant = Mobile.Combatant; - if (Mobile.Controlled && combatant == Mobile) - { - DebugSay("I should not attack myself!"); - Mobile.Combatant = null; - Action = ActionType.Guard; - return true; - } - if (combatant == null || combatant.Deleted || combatant.Map != Mobile.Map || !combatant.Alive || combatant.IsDeadBondedPet) { From 5e65f9f0b24674d516015675f39541e878bacc72 Mon Sep 17 00:00:00 2001 From: Bohica <53943479+Bohicatv@users.noreply.github.com> Date: Sun, 16 Nov 2025 10:09:44 -0800 Subject: [PATCH 3/6] fix: Fixes descending comparer in admin gump (#2272) --- Projects/UOContent/Gumps/AdminGump.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Projects/UOContent/Gumps/AdminGump.cs b/Projects/UOContent/Gumps/AdminGump.cs index e9c91dba3..00e65887d 100644 --- a/Projects/UOContent/Gumps/AdminGump.cs +++ b/Projects/UOContent/Gumps/AdminGump.cs @@ -4095,15 +4095,15 @@ namespace Server.Gumps { if (x is not KeyValuePair> a) { - return -1; + return 1; } if (y is not KeyValuePair> b) { - return 1; + return -1; } - return a.Value.Count - b.Value.Count; + return b.Value.Count - a.Value.Count; } } From 051f22ff93f0dfdd009aa4f3c6edb233e8b63d81 Mon Sep 17 00:00:00 2001 From: Bohica <53943479+Bohicatv@users.noreply.github.com> Date: Sun, 16 Nov 2025 10:12:12 -0800 Subject: [PATCH 4/6] fix: Fixes vendor look spam (#2269) --- Projects/UOContent/Mobiles/BaseCreature.cs | 28 ++-------------------- 1 file changed, 2 insertions(+), 26 deletions(-) diff --git a/Projects/UOContent/Mobiles/BaseCreature.cs b/Projects/UOContent/Mobiles/BaseCreature.cs index f9898d6cd..eb70e8454 100644 --- a/Projects/UOContent/Mobiles/BaseCreature.cs +++ b/Projects/UOContent/Mobiles/BaseCreature.cs @@ -2676,19 +2676,7 @@ namespace Server.Mobiles if (Body.IsHuman) { - switch (Utility.Random(2)) - { - case 0: - { - CheckedAnimate(5, 5, 1, true, true, 1); - break; - } - case 1: - { - CheckedAnimate(6, 5, 1, true, false, 1); - break; - } - } + CheckedAnimate(Utility.RandomBool() ? 5 : 6, 5, 1, true, false, 1); } else if (Body.IsAnimal) { @@ -2713,19 +2701,7 @@ namespace Server.Mobiles } else if (Body.IsMonster) { - switch (Utility.Random(2)) - { - case 0: - { - CheckedAnimate(17, 5, 1, true, false, 1); - break; - } - case 1: - { - CheckedAnimate(18, 5, 1, true, false, 1); - break; - } - } + CheckedAnimate(Utility.RandomBool() ? 17 : 18, 5, 1, true, false, 1); } PlaySound(GetIdleSound()); From ee1a40bb51fc467ae1e6795fd0c7efab71ae0a97 Mon Sep 17 00:00:00 2001 From: Bohica <53943479+Bohicatv@users.noreply.github.com> Date: Sun, 16 Nov 2025 10:14:06 -0800 Subject: [PATCH 5/6] fix: Fixes movement pathing while in combat (#2271) --- Projects/UOContent/Mobiles/AI/AnimalAI.cs | 2 +- Projects/UOContent/Mobiles/AI/ArcherAI.cs | 8 +------- Projects/UOContent/Mobiles/AI/BerserkAI.cs | 2 +- Projects/UOContent/Mobiles/AI/MageAI.cs | 6 +++--- Projects/UOContent/Mobiles/AI/MeleeAI.cs | 2 +- Projects/UOContent/Mobiles/AI/PredatorAI.cs | 2 +- Projects/UOContent/Mobiles/AI/ThiefAI.cs | 2 +- Projects/UOContent/Mobiles/Familiars/BaseFamiliar.cs | 2 +- 8 files changed, 10 insertions(+), 16 deletions(-) diff --git a/Projects/UOContent/Mobiles/AI/AnimalAI.cs b/Projects/UOContent/Mobiles/AI/AnimalAI.cs index 9e33414c7..5107b57fe 100644 --- a/Projects/UOContent/Mobiles/AI/AnimalAI.cs +++ b/Projects/UOContent/Mobiles/AI/AnimalAI.cs @@ -55,7 +55,7 @@ public class AnimalAI : BaseAI return true; } - if (!WalkMobileRange(combatant, 1, true, Mobile.RangeFight, Mobile.RangeFight)) + if (!WalkMobileRange(combatant, 1, false, Mobile.RangeFight, Mobile.RangeFight)) { if (Mobile.GetDistanceToSqrt(combatant) > Mobile.RangePerception + 1) { diff --git a/Projects/UOContent/Mobiles/AI/ArcherAI.cs b/Projects/UOContent/Mobiles/AI/ArcherAI.cs index 6491c42d5..19e0375c4 100644 --- a/Projects/UOContent/Mobiles/AI/ArcherAI.cs +++ b/Projects/UOContent/Mobiles/AI/ArcherAI.cs @@ -41,13 +41,7 @@ public class ArcherAI : BaseAI return true; } - if (Core.TickCount - Mobile.LastMoveTime > 1000 && !WalkMobileRange( - combatant, - 1, - true, - Mobile.RangeFight, - Mobile.Weapon.MaxRange - )) + if (!WalkMobileRange(combatant, 1, false, Mobile.RangeFight, Mobile.Weapon.MaxRange)) { this.DebugSayFormatted($"I am still not in range of {combatant.Name}"); diff --git a/Projects/UOContent/Mobiles/AI/BerserkAI.cs b/Projects/UOContent/Mobiles/AI/BerserkAI.cs index f882d6fb5..4663ae8d0 100644 --- a/Projects/UOContent/Mobiles/AI/BerserkAI.cs +++ b/Projects/UOContent/Mobiles/AI/BerserkAI.cs @@ -38,7 +38,7 @@ public class BerserkAI : BaseAI return true; } - if (!WalkMobileRange(combatant, 1, true, Mobile.RangeFight, Mobile.RangeFight)) + if (!WalkMobileRange(combatant, 1, false, Mobile.RangeFight, Mobile.RangeFight)) { this.DebugSayFormatted($"I am still not in range of {combatant.Name}"); diff --git a/Projects/UOContent/Mobiles/AI/MageAI.cs b/Projects/UOContent/Mobiles/AI/MageAI.cs index 9ed02cd02..0ca4893e5 100644 --- a/Projects/UOContent/Mobiles/AI/MageAI.cs +++ b/Projects/UOContent/Mobiles/AI/MageAI.cs @@ -168,7 +168,7 @@ public class MageAI : BaseAI { if (!SmartAI) { - if (!MoveTo(m, true, Mobile.RangeFight)) + if (!MoveTo(m, false, Mobile.RangeFight)) { OnFailedMove(); } @@ -182,14 +182,14 @@ public class MageAI : BaseAI { RunFrom(m); } - else if (!Mobile.InRange(m, Math.Max(Mobile.RangeFight, 2)) && !MoveTo(m, true, 1)) + else if (!Mobile.InRange(m, Math.Max(Mobile.RangeFight, 2)) && !MoveTo(m, false, 1)) { OnFailedMove(); } } else if (!Mobile.InRange(m, Mobile.RangeFight)) { - if (!MoveTo(m, true, 1)) + if (!MoveTo(m, false, 1)) { OnFailedMove(); } diff --git a/Projects/UOContent/Mobiles/AI/MeleeAI.cs b/Projects/UOContent/Mobiles/AI/MeleeAI.cs index edf0b108e..01ef3c06f 100644 --- a/Projects/UOContent/Mobiles/AI/MeleeAI.cs +++ b/Projects/UOContent/Mobiles/AI/MeleeAI.cs @@ -67,7 +67,7 @@ public class MeleeAI : BaseAI } } - if (!MoveTo(combatant, true, Mobile.RangeFight)) + if (!MoveTo(combatant, false, Mobile.RangeFight)) { if (AcquireFocusMob(Mobile.RangePerception, Mobile.FightMode, false, false, true)) { diff --git a/Projects/UOContent/Mobiles/AI/PredatorAI.cs b/Projects/UOContent/Mobiles/AI/PredatorAI.cs index fe5b06a6f..5e0e01520 100644 --- a/Projects/UOContent/Mobiles/AI/PredatorAI.cs +++ b/Projects/UOContent/Mobiles/AI/PredatorAI.cs @@ -41,7 +41,7 @@ public class PredatorAI : BaseAI return true; } - if (!WalkMobileRange(combatant, 1, true, Mobile.RangeFight, Mobile.RangeFight)) + if (!WalkMobileRange(combatant, 1, false, Mobile.RangeFight, Mobile.RangeFight)) { if (Mobile.GetDistanceToSqrt(combatant) > Mobile.RangePerception + 1) { diff --git a/Projects/UOContent/Mobiles/AI/ThiefAI.cs b/Projects/UOContent/Mobiles/AI/ThiefAI.cs index ff707f3df..2d67de801 100644 --- a/Projects/UOContent/Mobiles/AI/ThiefAI.cs +++ b/Projects/UOContent/Mobiles/AI/ThiefAI.cs @@ -43,7 +43,7 @@ public class ThiefAI : BaseAI return true; } - if (!WalkMobileRange(combatant, 1, true, Mobile.RangeFight, Mobile.RangeFight)) + if (!WalkMobileRange(combatant, 1, false, Mobile.RangeFight, Mobile.RangeFight)) { this.DebugSayFormatted($"I should be closer to {combatant.Name}"); } diff --git a/Projects/UOContent/Mobiles/Familiars/BaseFamiliar.cs b/Projects/UOContent/Mobiles/Familiars/BaseFamiliar.cs index 3997a3856..3ecc94db2 100644 --- a/Projects/UOContent/Mobiles/Familiars/BaseFamiliar.cs +++ b/Projects/UOContent/Mobiles/Familiars/BaseFamiliar.cs @@ -93,7 +93,7 @@ public abstract partial class BaseFamiliar : BaseCreature Hidden = m_LastHidden = master.Hidden; } - if (AIObject?.WalkMobileRange(master, 5, true, 1, 1) == true) + if (AIObject?.WalkMobileRange(master, 5, false, 1, 1) == true) { Warmode = master.Warmode; Combatant = master.Combatant; 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 6/6] 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; + } }