Add FormattedString type (#6339)

This is basically a lightweight marker type saying "this string contains markup". Intended to avoid injection accidents if people don't realize they should escape stuff.
This commit is contained in:
Pieter-Jan Briers
2025-12-15 19:36:16 +01:00
committed by GitHub
parent 53e1222b6b
commit d161c3b3b8
6 changed files with 330 additions and 1 deletions

View File

@@ -0,0 +1,146 @@
using System;
using Robust.Shared.Serialization;
using Robust.Shared.Utility;
namespace Robust.Shared.RichText;
/// <summary>
/// Contains a simple string of text formatted with markup tags.
/// </summary>
/// <remarks>
/// <para>
/// This type differs from <see cref="FormattedMessage"/> by storing purely a markup string,
/// rather than a full parsed object model.
/// This makes it significantly more lightweight than <see cref="FormattedMessage"/>,
/// and suitable for places where markup only has to be <i>passed around</i>, rather than modified or interpreted.
/// </para>
/// </remarks>
public struct FormattedString : IEquatable<FormattedString>, ISelfSerialize
{
// NOTE: This type has a custom network type serializer.
/// <summary>
/// Represents an empty (<see cref="string.Empty"/>) string.
/// </summary>
public static readonly FormattedString Empty = new("");
/// <summary>
/// The contained markup text.
/// </summary>
/// <remarks>
/// This must always be strict valid markup, i.e. parseable by <see cref="FormattedMessage.ParseOrThrow"/>.
/// </remarks>
public readonly string Markup;
[Obsolete("Do not construct FormattedString directly")]
public FormattedString()
{
throw new NotSupportedException("Do not construct FormattedString directly");
}
/// <summary>
/// Internal constructor, does not validate markup is strictly valid.
/// </summary>
/// <param name="markup"></param>
private FormattedString(string markup)
{
Markup = markup;
}
/// <summary>
/// Create a <see cref="FormattedString"/> from a strict markup string.
/// </summary>
/// <remarks>
/// The provided markup string must be strict valid markup,
/// i.e. parseable by <see cref="FormattedMessage.ParseOrThrow"/>.
/// </remarks>
/// <exception cref="ArgumentException">
/// Thrown of <paramref name="markup"/> is not strict valid markup.
/// </exception>
/// <seealso cref="FromMarkupPermissive"/>
public static FormattedString FromMarkup(string markup)
{
if (!FormattedMessage.ValidMarkup(markup))
throw new ArgumentException("Invalid markup string");
return new FormattedString(markup);
}
/// <summary>
/// Create a <see cref="FormattedString"/> from a permissive markup string.
/// </summary>
/// <remarks>
/// The provided markup string does not need to be strict valid markup,
/// but it will be normalized to be strict if it's not.
/// </remarks>
/// <seealso cref="FromMarkup"/>
public static FormattedString FromMarkupPermissive(string markup)
{
// We round trip here to ensure the contents are valid.
var permissive = FormattedMessage.FromMarkupPermissive(markup);
return (FormattedString)permissive;
}
/// <summary>
/// Create a <see cref="FormattedString"/> from plaintext (escaping it if necessary).
/// </summary>
/// <remarks>
/// This is equivalent to <see cref="FormattedMessage.EscapeText"/>
/// </remarks>
public static FormattedString FromPlainText(string plainText)
{
return new FormattedString(FormattedMessage.EscapeText(plainText));
}
public static explicit operator FormattedString(FormattedMessage message)
{
// Assumed to be valid markup returned by ToMarkup().
return new FormattedString(message.ToMarkup());
}
public static explicit operator FormattedMessage(FormattedString str)
{
// This should never throw.
return FormattedMessage.FromMarkupOrThrow(str.Markup);
}
public static explicit operator string(FormattedString str)
{
return str.Markup;
}
public readonly bool Equals(FormattedString other)
{
return other.Markup == Markup;
}
public readonly override bool Equals(object? obj)
{
return obj is FormattedString other && Equals(other);
}
public readonly override int GetHashCode()
{
return Markup.GetHashCode();
}
public static bool operator ==(FormattedString left, FormattedString right)
{
return left.Equals(right);
}
public static bool operator !=(FormattedString left, FormattedString right)
{
return !left.Equals(right);
}
void ISelfSerialize.Deserialize(string value)
{
this = FromMarkup(value);
}
readonly string ISelfSerialize.Serialize()
{
return Markup;
}
}

View File

@@ -0,0 +1,50 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Reflection;
using JetBrains.Annotations;
using NetSerializer;
using Robust.Shared.RichText;
namespace Robust.Shared.Serialization;
/// <summary>
/// Special network serializer for <see cref="FormattedString"/> to make sure validation runs for network values.
/// </summary>
internal sealed class NetFormattedStringSerializer : IStaticTypeSerializer
{
public bool Handles(Type type)
{
return type == typeof(FormattedString);
}
public IEnumerable<Type> GetSubtypes(Type type)
{
return [typeof(string)];
}
public MethodInfo GetStaticWriter(Type type)
{
return typeof(NetFormattedStringSerializer).GetMethod("Write", BindingFlags.Static | BindingFlags.NonPublic)!;
}
public MethodInfo GetStaticReader(Type type)
{
return typeof(NetFormattedStringSerializer).GetMethod("Read", BindingFlags.Static | BindingFlags.NonPublic)!;
}
[UsedImplicitly]
private static void Write(Stream stream, FormattedString value)
{
Primitives.WritePrimitive(stream, value.Markup);
}
[UsedImplicitly]
private static void Read(Stream stream, out FormattedString value)
{
Primitives.ReadPrimitive(stream, out string markup);
// Must be valid formed strict markup, do not trust the client!
value = FormattedString.FromMarkup(markup);
}
}

View File

@@ -90,7 +90,8 @@ namespace Robust.Shared.Serialization
{
MappedStringSerializer.TypeSerializer,
new NetMathSerializer(),
new NetBitArraySerializer()
new NetBitArraySerializer(),
new NetFormattedStringSerializer()
}
};
_serializer = new Serializer(types, settings);

View File

@@ -6,6 +6,7 @@ using System.Text;
using JetBrains.Annotations;
using Nett.Parser;
using Robust.Shared.Maths;
using Robust.Shared.RichText;
using Robust.Shared.Serialization;
namespace Robust.Shared.Utility;
@@ -14,6 +15,7 @@ namespace Robust.Shared.Utility;
/// Represents a formatted message in the form of a list of "tags".
/// Does not do any concrete formatting, simply useful as an API surface.
/// </summary>
/// <seealso cref="FormattedString"/>
[PublicAPI]
[Serializable, NetSerializable]
public sealed partial class FormattedMessage : IEquatable<FormattedMessage>, IReadOnlyList<MarkupNode>

View File

@@ -0,0 +1,54 @@
using NUnit.Framework;
using Robust.Shared.RichText;
namespace Robust.UnitTesting.Shared.RichText;
[Parallelizable(ParallelScope.All)]
[TestOf(typeof(FormattedString))]
[TestFixture]
internal sealed class FormattedStringTest
{
/// <summary>
/// Test that permissive parsing properly normalizes & passes through markup, as appropriate.
/// </summary>
[Test]
[TestCase("", ExpectedResult = "")]
[TestCase("foobar", ExpectedResult = "foobar")]
[TestCase("[whaaaaaa", ExpectedResult = "\\[whaaaaaa")]
[TestCase("\\[whaaaaaa", ExpectedResult = "\\[whaaaaaa")]
[TestCase("[whaaaaaa]wow[/whaaaaaa]", ExpectedResult = "[whaaaaaa]wow[/whaaaaaa]")]
[TestCase("[whaaaaaa]\\[womp[/whaaaaaa]", ExpectedResult = "[whaaaaaa]\\[womp[/whaaaaaa]")]
public static string TestPermissiveNormalize(string input)
{
return FormattedString.FromMarkupPermissive(input).Markup;
}
[Test]
[TestCase("")]
[TestCase("real")]
[TestCase("[whaaaaaa]wow[/whaaaaaa]")]
public static void TestStrictParse(string input)
{
var str = FormattedString.FromMarkup(input);
Assert.That(str.Markup, Is.EqualTo(input));
}
[Test]
[TestCase("", ExpectedResult = "")]
[TestCase("real", ExpectedResult = "real")]
[TestCase("[real", ExpectedResult = "\\[real")]
[TestCase("\\", ExpectedResult = @"\\")]
public static string TestFromPlainText(string input)
{
return FormattedString.FromPlainText(input).Markup;
}
[Test]
[TestCase("[whaaaaaawow")]
[TestCase("[whaaaaaawow val=\"]")]
public static void TestStrictThrows(string input)
{
Assert.That(() => FormattedString.FromMarkup(input), Throws.ArgumentException);
}
}

View File

@@ -0,0 +1,76 @@
using System.IO;
using NetSerializer;
using NUnit.Framework;
using Robust.Shared.RichText;
using Robust.Shared.Serialization;
namespace Robust.UnitTesting.Shared.Serialization;
[Parallelizable(ParallelScope.All)]
[TestFixture, TestOf(typeof(NetFormattedStringSerializer))]
internal sealed class NetSerializerFormattedStringTest
{
[Test]
[TestCase("")]
[TestCase("real")]
[TestCase("[i]heck[/i]")]
public void TestBasic(string markup)
{
var serializer = MakeSerializer();
var str = FormattedString.FromMarkup(markup);
var stream = new MemoryStream();
serializer.Serialize(stream, str);
stream.Position = 0;
var deserialized = (FormattedString) serializer.Deserialize(stream);
Assert.That(deserialized, NUnit.Framework.Is.EqualTo(str));
}
/// <summary>
/// Test that the on-wire representation of a <see cref="FormattedString"/> is the same as a regular string.
/// This is to ensure <see cref="TestInvalid"/> is a valid test.
/// </summary>
[Test]
[TestCase("")]
[TestCase("real")]
[TestCase("[i]heck[/i]")]
public void TestEqualToString(string markup)
{
var serializer = MakeSerializer();
var stream = new MemoryStream();
serializer.SerializeDirect(stream, markup);
stream.Position = 0;
serializer.DeserializeDirect(stream, out FormattedString str);
Assert.That(str.Markup, Is.EqualTo(markup));
}
/// <summary>
/// Test that deserialization fails if a malicious client sends broken markup.
/// </summary>
[Test]
public void TestInvalid()
{
var serializer = MakeSerializer();
var stream = new MemoryStream();
serializer.SerializeDirect(stream, "[wahoooo");
stream.Position = 0;
Assert.That(() => serializer.DeserializeDirect(stream, out FormattedString _), Throws.Exception);
}
private static Serializer MakeSerializer()
{
return new Serializer([typeof(FormattedString)],
new Settings
{
CustomTypeSerializers = [new NetFormattedStringSerializer()]
});
}
}