Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
163 changes: 163 additions & 0 deletions PowerKit.Tests/XmlTests.cs
Comment thread
Tyrrrz marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
using System;
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 &lt; 2");
}

[Fact]
public void Escape_GreaterThan_Test()
{
// Act
var result = Xml.Escape("2 > 1");

// Assert
result.Should().Be("2 &gt; 1");
}

[Fact]
public void Escape_DoubleQuote_Test()
{
// Act
var result = Xml.Escape("say \"hello\"");

// Assert
result.Should().Be("say &quot;hello&quot;");
}

[Fact]
public void Escape_SingleQuote_Test()
{
// Act
var result = Xml.Escape("it's");

// Assert
result.Should().Be("it&apos;s");
}

[Fact]
public void Escape_AllSpecialChars_Test()
{
// Act
var result = Xml.Escape("& < > \" '");

// Assert
result.Should().Be("&amp; &lt; &gt; &quot; &apos;");
}

[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<ArgumentNullException>();
}
}
83 changes: 83 additions & 0 deletions PowerKit/Xml.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
using System;
using System.Text;

namespace PowerKit;

/// <summary>
/// Helper methods for working with XML.
/// </summary>
public static class Xml
{
private static bool IsValidXmlChar(char ch) =>
ch == '\t'
|| ch == '\n'
|| ch == '\r'
|| (ch >= '\x20' && ch <= '\xD7FF')
|| (ch >= '\xE000' && ch <= '\xFFFD');

/// <summary>
/// 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 (<c>&amp;</c>, <c>&lt;</c>, <c>&gt;</c>, <c>&quot;</c>, <c>&apos;</c>)
/// are replaced with their corresponding XML entities.
/// </summary>
public static string Escape(string str)
{
ArgumentNullException.ThrowIfNull(str);
StringBuilder? builder = null;

var i = 0;
while (i < str.Length)
{
var ch = str[i];

string? replacement;
if (ch == '&')
replacement = "&amp;";
else if (ch == '<')
replacement = "&lt;";
else if (ch == '>')
replacement = "&gt;";
else if (ch == '"')
replacement = "&quot;";
else if (ch == '\'')
replacement = "&apos;";
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;
}
}
Loading