From 392c16d8307d3cfe6380a39117fb325d686157cb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Jul 2026 13:43:41 +0000 Subject: [PATCH 1/8] Add Xml.Escape helper method with tests --- PowerKit.Tests/XmlTests.cs | 121 +++++++++++++++++++++++++++++++++++++ PowerKit/Xml.cs | 81 +++++++++++++++++++++++++ 2 files changed, 202 insertions(+) create mode 100644 PowerKit.Tests/XmlTests.cs create mode 100644 PowerKit/Xml.cs diff --git a/PowerKit.Tests/XmlTests.cs b/PowerKit.Tests/XmlTests.cs new file mode 100644 index 0000000..875960f --- /dev/null +++ b/PowerKit.Tests/XmlTests.cs @@ -0,0 +1,121 @@ +using FluentAssertions; +using Xunit; + +namespace PowerKit.Tests; + +public class XmlTests +{ + [Fact] + public void Escape_NoSpecialChars_Test() + { + // Act + var result = Xml.Escape("hello world"); + + // Assert + result.Should().Be("hello world"); + } + + [Fact] + public void Escape_Ampersand_Test() + { + // Act + var result = Xml.Escape("foo & bar"); + + // Assert + result.Should().Be("foo & bar"); + } + + [Fact] + public void Escape_LessThan_Test() + { + // Act + var result = Xml.Escape("1 < 2"); + + // Assert + result.Should().Be("1 < 2"); + } + + [Fact] + public void Escape_GreaterThan_Test() + { + // Act + var result = Xml.Escape("2 > 1"); + + // Assert + result.Should().Be("2 > 1"); + } + + [Fact] + public void Escape_DoubleQuote_Test() + { + // Act + var result = Xml.Escape("say \"hello\""); + + // Assert + result.Should().Be("say "hello""); + } + + [Fact] + public void Escape_SingleQuote_Test() + { + // Act + var result = Xml.Escape("it's"); + + // Assert + result.Should().Be("it's"); + } + + [Fact] + public void Escape_AllSpecialChars_Test() + { + // Act + var result = Xml.Escape("& < > \" '"); + + // Assert + result.Should().Be("& < > " '"); + } + + [Fact] + public void Escape_InvalidControlChar_Test() + { + // Arrange + // U+0001 is invalid in XML 1.0 and should be removed + var result = Xml.Escape("foo\u0001bar"); + + // Assert + result.Should().Be("foobar"); + } + + [Fact] + public void Escape_ValidWhitespace_Test() + { + // Tab, newline, and carriage return are valid XML characters + var result = Xml.Escape("foo\t\n\rbar"); + + // Assert + result.Should().Be("foo\t\n\rbar"); + } + + [Fact] + public void Escape_SurrogatePair_Test() + { + // Supplementary character U+1F600 (😀), encoded as a surrogate pair in .NET + var input = "\U0001F600"; + + // Act + var result = Xml.Escape(input); + + // Assert + result.Should().Be(input); + } + + [Fact] + public void Escape_EmptyString_Test() + { + // Act + var result = Xml.Escape(""); + + // Assert + result.Should().BeEmpty(); + } +} diff --git a/PowerKit/Xml.cs b/PowerKit/Xml.cs new file mode 100644 index 0000000..f8aa5fe --- /dev/null +++ b/PowerKit/Xml.cs @@ -0,0 +1,81 @@ +using System.Text; + +namespace PowerKit; + +/// +/// Helper methods for working with XML. +/// +public static class Xml +{ + private static bool IsValidXmlChar(char ch) => + ch == '\t' + || ch == '\n' + || ch == '\r' + || (ch >= '\x20' && ch <= '\xD7FF') + || (ch >= '\xE000' && ch <= '\xFFFD'); + + /// + /// Escapes invalid XML characters in the specified string, returning a valid XML string. + /// Characters that cannot be represented in XML (such as most control characters) are removed. + /// Special XML characters (&, <, >, ", ') + /// are replaced with their corresponding XML entities. + /// + public static string Escape(string str) + { + StringBuilder? builder = null; + + var i = 0; + while (i < str.Length) + { + var ch = str[i]; + + string? replacement; + if (ch == '&') + replacement = "&"; + else if (ch == '<') + replacement = "<"; + else if (ch == '>') + replacement = ">"; + else if (ch == '"') + replacement = """; + else if (ch == '\'') + replacement = "'"; + else if ( + char.IsHighSurrogate(ch) + && i + 1 < str.Length + && char.IsLowSurrogate(str[i + 1]) + ) + { + // Valid surrogate pair — represents a supplementary character (U+10000..U+10FFFF), + // which is valid in XML. Append both chars and advance past the pair. + builder?.Append(ch); + builder?.Append(str[i + 1]); + i += 2; + continue; + } + else if (IsValidXmlChar(ch)) + replacement = null; + else + { + // Truly invalid XML character — skip it. + builder ??= new StringBuilder(str, 0, i, str.Length); + i++; + continue; + } + + if (replacement is not null) + { + builder ??= new StringBuilder(str, 0, i, str.Length); + builder.Append(replacement); + } + else + { + builder?.Append(ch); + } + + i++; + } + + return builder?.ToString() ?? str; + } +} From f3b0f4d5f6249bfafc83b71b30a6b0654e985034 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Jul 2026 13:46:57 +0000 Subject: [PATCH 2/8] Add null check and fix test structure in Xml.Escape --- PowerKit.Tests/XmlTests.cs | 16 +++++++++++++++- PowerKit/Xml.cs | 2 ++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/PowerKit.Tests/XmlTests.cs b/PowerKit.Tests/XmlTests.cs index 875960f..00f5044 100644 --- a/PowerKit.Tests/XmlTests.cs +++ b/PowerKit.Tests/XmlTests.cs @@ -1,3 +1,4 @@ +using System; using FluentAssertions; using Xunit; @@ -80,7 +81,10 @@ public void Escape_InvalidControlChar_Test() { // Arrange // U+0001 is invalid in XML 1.0 and should be removed - var result = Xml.Escape("foo\u0001bar"); + var input = "foo\u0001bar"; + + // Act + var result = Xml.Escape(input); // Assert result.Should().Be("foobar"); @@ -118,4 +122,14 @@ public void Escape_EmptyString_Test() // Assert result.Should().BeEmpty(); } + + [Fact] + public void Escape_Null_Test() + { + // Act + var act = () => Xml.Escape(null!); + + // Assert + act.Should().ThrowExactly(); + } } diff --git a/PowerKit/Xml.cs b/PowerKit/Xml.cs index f8aa5fe..9224b77 100644 --- a/PowerKit/Xml.cs +++ b/PowerKit/Xml.cs @@ -1,3 +1,4 @@ +using System; using System.Text; namespace PowerKit; @@ -22,6 +23,7 @@ private static bool IsValidXmlChar(char ch) => /// public static string Escape(string str) { + ArgumentNullException.ThrowIfNull(str); StringBuilder? builder = null; var i = 0; From ccaa030db6540f560cc4c8ab3e422f20d6a96637 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Jul 2026 13:48:57 +0000 Subject: [PATCH 3/8] Add isolated surrogate tests for Xml.Escape --- PowerKit.Tests/XmlTests.cs | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/PowerKit.Tests/XmlTests.cs b/PowerKit.Tests/XmlTests.cs index 00f5044..aca7879 100644 --- a/PowerKit.Tests/XmlTests.cs +++ b/PowerKit.Tests/XmlTests.cs @@ -113,6 +113,34 @@ public void Escape_SurrogatePair_Test() result.Should().Be(input); } + [Fact] + public void Escape_IsolatedHighSurrogate_Test() + { + // Arrange + // An isolated high surrogate (no paired low surrogate) is invalid in XML and should be removed + var input = "foo\uD800bar"; + + // Act + var result = Xml.Escape(input); + + // Assert + result.Should().Be("foobar"); + } + + [Fact] + public void Escape_IsolatedLowSurrogate_Test() + { + // Arrange + // An isolated low surrogate is invalid in XML and should be removed + var input = "foo\uDC00bar"; + + // Act + var result = Xml.Escape(input); + + // Assert + result.Should().Be("foobar"); + } + [Fact] public void Escape_EmptyString_Test() { From 723e2810ed17b8af88a301ea72e51622583892d4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Jul 2026 13:55:58 +0000 Subject: [PATCH 4/8] Refactor Xml tests into unified act/assert style --- PowerKit.Tests/XmlTests.cs | 169 ++++--------------------------------- 1 file changed, 17 insertions(+), 152 deletions(-) diff --git a/PowerKit.Tests/XmlTests.cs b/PowerKit.Tests/XmlTests.cs index aca7879..2ba731a 100644 --- a/PowerKit.Tests/XmlTests.cs +++ b/PowerKit.Tests/XmlTests.cs @@ -7,157 +7,22 @@ namespace PowerKit.Tests; public class XmlTests { [Fact] - public void Escape_NoSpecialChars_Test() - { - // Act - var result = Xml.Escape("hello world"); - - // Assert - result.Should().Be("hello world"); - } - - [Fact] - public void Escape_Ampersand_Test() - { - // Act - var result = Xml.Escape("foo & bar"); - - // Assert - result.Should().Be("foo & bar"); - } - - [Fact] - public void Escape_LessThan_Test() - { - // Act - var result = Xml.Escape("1 < 2"); - - // Assert - result.Should().Be("1 < 2"); - } - - [Fact] - public void Escape_GreaterThan_Test() - { - // Act - var result = Xml.Escape("2 > 1"); - - // Assert - result.Should().Be("2 > 1"); - } - - [Fact] - public void Escape_DoubleQuote_Test() - { - // Act - var result = Xml.Escape("say \"hello\""); - - // Assert - result.Should().Be("say "hello""); - } - - [Fact] - public void Escape_SingleQuote_Test() - { - // Act - var result = Xml.Escape("it's"); - - // Assert - result.Should().Be("it's"); - } - - [Fact] - public void Escape_AllSpecialChars_Test() - { - // Act - var result = Xml.Escape("& < > \" '"); - - // Assert - result.Should().Be("& < > " '"); - } - - [Fact] - public void Escape_InvalidControlChar_Test() - { - // Arrange - // U+0001 is invalid in XML 1.0 and should be removed - var input = "foo\u0001bar"; - - // Act - var result = Xml.Escape(input); - - // Assert - result.Should().Be("foobar"); - } - - [Fact] - public void Escape_ValidWhitespace_Test() - { - // Tab, newline, and carriage return are valid XML characters - var result = Xml.Escape("foo\t\n\rbar"); - - // Assert - result.Should().Be("foo\t\n\rbar"); - } - - [Fact] - public void Escape_SurrogatePair_Test() - { - // Supplementary character U+1F600 (😀), encoded as a surrogate pair in .NET - var input = "\U0001F600"; - - // Act - var result = Xml.Escape(input); - - // Assert - result.Should().Be(input); - } - - [Fact] - public void Escape_IsolatedHighSurrogate_Test() - { - // Arrange - // An isolated high surrogate (no paired low surrogate) is invalid in XML and should be removed - var input = "foo\uD800bar"; - - // Act - var result = Xml.Escape(input); - - // Assert - result.Should().Be("foobar"); - } - - [Fact] - public void Escape_IsolatedLowSurrogate_Test() - { - // Arrange - // An isolated low surrogate is invalid in XML and should be removed - var input = "foo\uDC00bar"; - - // Act - var result = Xml.Escape(input); - - // Assert - result.Should().Be("foobar"); - } - - [Fact] - public void Escape_EmptyString_Test() - { - // Act - var result = Xml.Escape(""); - - // Assert - result.Should().BeEmpty(); - } - - [Fact] - public void Escape_Null_Test() - { - // Act - var act = () => Xml.Escape(null!); - - // Assert - act.Should().ThrowExactly(); + public void Escape_Test() + { + // Act & assert + Xml.Escape("hello world").Should().Be("hello world"); + Xml.Escape("foo & bar").Should().Be("foo & bar"); + Xml.Escape("1 < 2").Should().Be("1 < 2"); + Xml.Escape("2 > 1").Should().Be("2 > 1"); + Xml.Escape("say \"hello\"").Should().Be("say "hello""); + Xml.Escape("it's").Should().Be("it's"); + Xml.Escape("& < > \" '").Should().Be("& < > " '"); + Xml.Escape("foo\u0001bar").Should().Be("foobar"); + Xml.Escape("foo\t\n\rbar").Should().Be("foo\t\n\rbar"); + Xml.Escape("\U0001F600").Should().Be("\U0001F600"); + Xml.Escape("foo\uD800bar").Should().Be("foobar"); + Xml.Escape("foo\uDC00bar").Should().Be("foobar"); + Xml.Escape("").Should().BeEmpty(); + Assert.Throws(() => Xml.Escape(null!)); } } From 44378b73b0897b28cbeb3e7998237cece7d54817 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Jul 2026 13:56:44 +0000 Subject: [PATCH 5/8] Use FluentAssertions throw assertion in Xml test --- PowerKit.Tests/XmlTests.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/PowerKit.Tests/XmlTests.cs b/PowerKit.Tests/XmlTests.cs index 2ba731a..9911478 100644 --- a/PowerKit.Tests/XmlTests.cs +++ b/PowerKit.Tests/XmlTests.cs @@ -23,6 +23,9 @@ public void Escape_Test() Xml.Escape("foo\uD800bar").Should().Be("foobar"); Xml.Escape("foo\uDC00bar").Should().Be("foobar"); Xml.Escape("").Should().BeEmpty(); - Assert.Throws(() => Xml.Escape(null!)); + FluentActions + .Invoking(() => Xml.Escape(null!)) + .Should() + .ThrowExactly(); } } From 9e24b0464652d5cfb6cbe145184a382a2cca90a1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Jul 2026 18:34:20 +0000 Subject: [PATCH 6/8] Use Assert.Throws in XmlTests null assertion --- PowerKit.Tests/XmlTests.cs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/PowerKit.Tests/XmlTests.cs b/PowerKit.Tests/XmlTests.cs index 9911478..2ba731a 100644 --- a/PowerKit.Tests/XmlTests.cs +++ b/PowerKit.Tests/XmlTests.cs @@ -23,9 +23,6 @@ public void Escape_Test() Xml.Escape("foo\uD800bar").Should().Be("foobar"); Xml.Escape("foo\uDC00bar").Should().Be("foobar"); Xml.Escape("").Should().BeEmpty(); - FluentActions - .Invoking(() => Xml.Escape(null!)) - .Should() - .ThrowExactly(); + Assert.Throws(() => Xml.Escape(null!)); } } From edb982c901e2ccf8cc0c1c4434865ec8cf1d4db3 Mon Sep 17 00:00:00 2001 From: Oleksii Holub <1935960+Tyrrrz@users.noreply.github.com> Date: Sat, 11 Jul 2026 21:51:32 +0300 Subject: [PATCH 7/8] Update XmlTests.cs --- PowerKit.Tests/XmlTests.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/PowerKit.Tests/XmlTests.cs b/PowerKit.Tests/XmlTests.cs index 2ba731a..14ca8da 100644 --- a/PowerKit.Tests/XmlTests.cs +++ b/PowerKit.Tests/XmlTests.cs @@ -23,6 +23,5 @@ public void Escape_Test() Xml.Escape("foo\uD800bar").Should().Be("foobar"); Xml.Escape("foo\uDC00bar").Should().Be("foobar"); Xml.Escape("").Should().BeEmpty(); - Assert.Throws(() => Xml.Escape(null!)); } } From 2d8f6a711ff301fc101fcf810c9bd7d41265c902 Mon Sep 17 00:00:00 2001 From: Oleksii Holub <1935960+Tyrrrz@users.noreply.github.com> Date: Sat, 11 Jul 2026 21:52:31 +0300 Subject: [PATCH 8/8] Update Xml.cs --- PowerKit/Xml.cs | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/PowerKit/Xml.cs b/PowerKit/Xml.cs index 9224b77..bedafe9 100644 --- a/PowerKit/Xml.cs +++ b/PowerKit/Xml.cs @@ -23,8 +23,7 @@ private static bool IsValidXmlChar(char ch) => /// public static string Escape(string str) { - ArgumentNullException.ThrowIfNull(str); - StringBuilder? builder = null; + var builder = default(StringBuilder); var i = 0; while (i < str.Length) @@ -33,15 +32,25 @@ public static string Escape(string str) string? replacement; if (ch == '&') + { replacement = "&"; + } else if (ch == '<') + { replacement = "<"; + } else if (ch == '>') + { replacement = ">"; + } else if (ch == '"') + { replacement = """; + } else if (ch == '\'') + { replacement = "'"; + } else if ( char.IsHighSurrogate(ch) && i + 1 < str.Length @@ -56,7 +65,9 @@ public static string Escape(string str) continue; } else if (IsValidXmlChar(ch)) + { replacement = null; + } else { // Truly invalid XML character — skip it.