mirror of
https://github.com/space-wizards/RobustToolbox.git
synced 2026-02-14 19:29:36 +01:00
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:
committed by
GitHub
parent
53e1222b6b
commit
d161c3b3b8
146
Robust.Shared/RichText/FormattedString.cs
Normal file
146
Robust.Shared/RichText/FormattedString.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
50
Robust.Shared/Serialization/NetFormattedStringSerializer.cs
Normal file
50
Robust.Shared/Serialization/NetFormattedStringSerializer.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -90,7 +90,8 @@ namespace Robust.Shared.Serialization
|
||||
{
|
||||
MappedStringSerializer.TypeSerializer,
|
||||
new NetMathSerializer(),
|
||||
new NetBitArraySerializer()
|
||||
new NetBitArraySerializer(),
|
||||
new NetFormattedStringSerializer()
|
||||
}
|
||||
};
|
||||
_serializer = new Serializer(types, settings);
|
||||
|
||||
@@ -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>
|
||||
|
||||
54
Robust.UnitTesting/Shared/RichText/FormattedStringTest.cs
Normal file
54
Robust.UnitTesting/Shared/RichText/FormattedStringTest.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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()]
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user