using System; using System.Buffers; using System.Globalization; using MessagePack; namespace GameCult.Networking { /// /// Serializes and deserializes the explicit modern CultNet schema-v0 message family. /// public static class CultNetSchemaMessageSerialization { /// /// Gets the serializer options used for CultNet schema-v0 messages. /// public static readonly MessagePackSerializerOptions Options = MessagePackSerializerOptions.Standard.WithSecurity(MessagePackSecurity.UntrustedData); /// /// Serializes a typed CultNet schema message. /// public static byte[] Serialize(T message) where T : ICultNetSchemaMessage { if (message == null) throw new ArgumentNullException(nameof(message)); return MessagePackSerializer.Serialize(message, Options); } /// /// Deserializes a typed CultNet schema message. /// public static T Deserialize(byte[] payload) where T : ICultNetSchemaMessage { if (payload == null) throw new ArgumentNullException(nameof(payload)); if (typeof(T) == typeof(CultNetWriteAdmittedMessage)) CultNetAdmittedMessageId.Require(payload); return MessagePackSerializer.Deserialize(payload, Options); } /// /// Deserializes a CultNet schema message by inspecting its schema version. /// public static ICultNetSchemaMessage Deserialize(byte[] payload) { if (payload == null) throw new ArgumentNullException(nameof(payload)); var header = MessagePackSerializer.Deserialize(payload, Options); if (header == null || string.IsNullOrWhiteSpace(header.SchemaVersion)) { throw new MessagePackSerializationException("CultNet schema-v0 payload is missing schemaVersion."); } return header.SchemaVersion switch { CultNetSchemaVersions.Hello => Deserialize(payload), CultNetSchemaVersions.Login => Deserialize(payload), CultNetSchemaVersions.Register => Deserialize(payload), CultNetSchemaVersions.Verify => Deserialize(payload), CultNetSchemaVersions.LoginSuccess => Deserialize(payload), CultNetSchemaVersions.Error => Deserialize(payload), CultNetSchemaVersions.ErrorV1 => Deserialize(payload), CultNetSchemaVersions.WriteAdmitted => Deserialize(payload), CultNetSchemaVersions.SampleChangeName => Deserialize(payload), CultNetSchemaVersions.SampleChat => Deserialize(payload), CultNetSchemaVersions.OperationRequest => Deserialize(payload), CultNetSchemaVersions.OperationResponse => Deserialize(payload), CultNetSchemaVersions.DocumentDelete => Deserialize(payload), CultNetSchemaVersions.DocumentPutRaw => Deserialize(payload), CultNetSchemaVersions.SnapshotRequest => Deserialize(payload), CultNetSchemaVersions.SnapshotResponseRaw => Deserialize(payload), CultNetSchemaVersions.SnapshotRequestV1 => Deserialize(payload), CultNetSchemaVersions.DatabaseSubscribeV1 => Deserialize(payload), CultNetSchemaVersions.SnapshotResponseRawV1 => Deserialize(payload), CultNetSchemaVersions.DatabaseSubscribe => Deserialize(payload), CultNetSchemaVersions.DatabaseUnsubscribe => Deserialize(payload), CultNetSchemaVersions.DatabaseChangeRaw => Deserialize(payload), CultNetSchemaVersions.ShardCatalogRequest => Deserialize(payload), CultNetSchemaVersions.ShardCatalogResponse => Deserialize(payload), CultNetSchemaVersions.ShardLogRequest => Deserialize(payload), CultNetSchemaVersions.ShardLogResponse => Deserialize(payload), CultNetSchemaVersions.SimulationObservation => Deserialize(payload), CultNetSchemaVersions.SimulationConsensusCandidate => Deserialize(payload), CultNetSchemaVersions.VerseCatalogRequest => Deserialize(payload), CultNetSchemaVersions.VerseCatalogResponse => Deserialize(payload), CultNetSchemaVersions.SessionOpen => Deserialize(payload), CultNetSchemaVersions.SessionAccepted => Deserialize(payload), CultNetSchemaVersions.PeerExchangeRequest => Deserialize(payload), CultNetSchemaVersions.PeerExchangeResponse => Deserialize(payload), CultNetSchemaVersions.ContentChunkRequest => Deserialize(payload), CultNetSchemaVersions.ContentChunkResponse => Deserialize(payload), CultNetSchemaVersions.BodyReadRequest => Deserialize(payload), CultNetSchemaVersions.BodyReadResponse => Deserialize(payload), CultNetSchemaVersions.SchemaCatalogRequest => Deserialize(payload), CultNetSchemaVersions.SchemaCatalogResponse => Deserialize(payload), _ => throw new MessagePackSerializationException( $"Unsupported CultNet schema-v0 message \"{header.SchemaVersion}\".") }; } [MessagePackObject(AllowPrivate = true)] internal sealed class CultNetSchemaHeader { [Key("schemaVersion")] public string SchemaVersion { get; set; } = string.Empty; } } /// /// 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. /// 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); /// Throws unless the frame's top-level messageId is a valid write_admitted id; other keys are left to the serializer. 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; } } }