GameCult.Networking: refuse a write_admitted messageId without a letter or number, with a Default_Ignorable code point, or with invalid UTF-8

Raw compare. .NET Unicode categories for L/N, an embedded Default_Ignorable range table (Unicode 16.0).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
soul 2026-11-09 14:49:50 +00:00
commit d546ef9c7f
2 changed files with 70 additions and 1 deletions

View file

@ -1,4 +1,6 @@
using System;
using System.Buffers;
using System.Globalization;
using MessagePack;
namespace GameCult.Networking
@ -29,6 +31,7 @@ namespace GameCult.Networking
public static T Deserialize<T>(byte[] payload) where T : ICultNetSchemaMessage
{
if (payload == null) throw new ArgumentNullException(nameof(payload));
if (typeof(T) == typeof(CultNetWriteAdmittedMessage)) CultNetAdmittedMessageId.Require(payload);
return MessagePackSerializer.Deserialize<T>(payload, Options);
}
@ -98,4 +101,70 @@ namespace GameCult.Networking
[Key("schemaVersion")] public string SchemaVersion { get; set; } = string.Empty;
}
}
/// <summary>
/// The write_admitted messageId rule (write-receipt.md): a Unicode letter or number somewhere, no Default_Ignorable code
/// point anywhere, wire bytes that are valid UTF-8, compared raw (never trimmed). Letters and numbers come from the
/// runtime's Unicode categories (L*, Nd, Nl, No); a code point it does not assign (U+0378 in every version) has neither.
/// .NET has no Default_Ignorable property, so the ranges are embedded from DerivedCoreProperties.txt, Unicode 16.0
/// (unchanged since 15.1); the Kotlin, Python and Rust decoders embed the same ranges, TypeScript uses its regex engine's.
/// </summary>
internal static class CultNetAdmittedMessageId
{
private static readonly (int Low, int High)[] DefaultIgnorable =
{
(0x00AD, 0x00AD), (0x034F, 0x034F), (0x061C, 0x061C), (0x115F, 0x1160), (0x17B4, 0x17B5), (0x180B, 0x180F),
(0x200B, 0x200F), (0x202A, 0x202E), (0x2060, 0x206F), (0x3164, 0x3164), (0xFE00, 0xFE0F), (0xFEFF, 0xFEFF),
(0xFFA0, 0xFFA0), (0xFFF0, 0xFFF8), (0x1BCA0, 0x1BCA3), (0x1D173, 0x1D17A), (0xE0000, 0xE0FFF),
};
private static readonly System.Text.UTF8Encoding StrictUtf8 = new(false, true);
/// <summary>Throws unless the frame's top-level <c>messageId</c> is a valid write_admitted id; other keys are left to the serializer.</summary>
public static void Require(byte[] payload)
{
var reader = new MessagePackReader(payload);
var entries = reader.ReadMapHeader();
for (var index = 0; index < entries; index++)
{
var key = reader.NextMessagePackType == MessagePackType.String ? reader.ReadString() : null;
if (key == "messageId" && reader.NextMessagePackType == MessagePackType.String)
{
string id;
try { id = StrictUtf8.GetString(reader.ReadStringSequence()!.Value.ToArray()); }
catch (System.Text.DecoderFallbackException) { throw new MessagePackSerializationException("cultnet.write_admitted.v0 messageId is not valid UTF-8."); }
if (!IsValid(id)) throw new MessagePackSerializationException("cultnet.write_admitted.v0 messageId must contain a letter or number and no Default_Ignorable code point.");
return;
}
if (key == null) reader.Skip();
reader.Skip();
}
}
public static bool IsValid(string id)
{
var hasLetterOrNumber = false;
for (var at = 0; at < id.Length; at += char.IsSurrogatePair(id, at) ? 2 : 1)
{
var point = char.IsSurrogatePair(id, at) ? char.ConvertToUtf32(id, at) : id[at];
if (Array.Exists(DefaultIgnorable, range => point >= range.Low && point <= range.High)) return false;
switch (CharUnicodeInfo.GetUnicodeCategory(point))
{
case UnicodeCategory.UppercaseLetter:
case UnicodeCategory.LowercaseLetter:
case UnicodeCategory.TitlecaseLetter:
case UnicodeCategory.ModifierLetter:
case UnicodeCategory.OtherLetter:
case UnicodeCategory.DecimalDigitNumber:
case UnicodeCategory.LetterNumber:
case UnicodeCategory.OtherNumber:
hasLetterOrNumber = true;
break;
}
}
return hasLetterOrNumber;
}
}
}

View file

@ -35,7 +35,7 @@ namespace GameCult.Networking.Tests
.Single(c => c.GetProperty("name").GetString() == name);
var id = string.Concat(Enumerable.Repeat(
string.Concat(vector.GetProperty("codePoints").EnumerateArray()
.Select(cp => char.ConvertFromUtf32(Convert.ToInt32(cp.GetString(), 16)))),
.Select(cp => Convert.ToInt32(cp.GetString(), 16)).Where(point => point < 0xD800 || point > 0xDFFF).Select(char.ConvertFromUtf32)),
vector.TryGetProperty("repeat", out var repeat) ? repeat.GetInt32() : 1));
var bytes = vector.TryGetProperty("wireHex", out var wire)
? Convert.FromHexString(wire.GetString()!)