From c4d1efe000928f28ac3a223b96993e1a5d05be48 Mon Sep 17 00:00:00 2001 From: Libby Date: Sun, 8 Nov 2026 15:07:23 +0000 Subject: [PATCH] C#: receipt ids capped at 256 bytes and held as digests; limit refusals remembered, no slot freed; required fields refused with fixed text; RUDP server advertises no receipt frames Co-Authored-By: Claude Sonnet 5.5 --- .../CultNetDatabaseServer.cs | 23 ++- .../CultNetWriteReceipts.cs | 150 ++++++++++---- .../CultNetRudpSchemaServerPeerInputTests.cs | 40 ++++ .../CultNetWriteReceiptServerTests.cs | 187 ++++++++++++++++++ 4 files changed, 360 insertions(+), 40 deletions(-) diff --git a/src/GameCult.Networking/CultNetDatabaseServer.cs b/src/GameCult.Networking/CultNetDatabaseServer.cs index efe23be2..32688434 100644 --- a/src/GameCult.Networking/CultNetDatabaseServer.cs +++ b/src/GameCult.Networking/CultNetDatabaseServer.cs @@ -416,9 +416,17 @@ namespace GameCult.Networking private async Task HandlePutAsync(CultNetDocumentPutRawMessage message, CultNetServerPeer peer) { var write = BeginReceipted(peer, "put", message.MessageId, message.Document?.SchemaId, message.Document?.RecordKey, message.Document?.Payload); - if (write != null && !write.HasId) + var idRefusal = write?.IdRefusal; + if (idRefusal != null) { - write.Refused(CultNetErrorMessage.ForMessageInvalid("messageId", "a write needs a messageId")); + write!.Refused(idRefusal); + return; + } + + var missing = message.Document == null ? "document" : message.Document.SchemaId == null ? "schemaId" : null; + if (missing != null) + { + Refuse(peer, write, CultNetErrorMessage.ForMessageInvalid(missing, $"{missing} is required")); return; } @@ -484,9 +492,16 @@ namespace GameCult.Networking private async Task HandleDeleteAsync(CultNetDocumentDeleteMessage message, CultNetServerPeer peer) { var write = BeginReceipted(peer, "delete", message.MessageId, message.SchemaId, message.RecordKey, null); - if (write != null && !write.HasId) + var idRefusal = write?.IdRefusal; + if (idRefusal != null) { - write.Refused(CultNetErrorMessage.ForMessageInvalid("messageId", "a write needs a messageId")); + write!.Refused(idRefusal); + return; + } + + if (message.SchemaId == null) + { + Refuse(peer, write, CultNetErrorMessage.ForMessageInvalid("schemaId", "schemaId is required")); return; } diff --git a/src/GameCult.Networking/CultNetWriteReceipts.cs b/src/GameCult.Networking/CultNetWriteReceipts.cs index cb13eeae..7e984287 100644 --- a/src/GameCult.Networking/CultNetWriteReceipts.cs +++ b/src/GameCult.Networking/CultNetWriteReceipts.cs @@ -47,73 +47,138 @@ namespace GameCult.Networking } } + /// A SHA-256 digest held by value: the fixed-size stand-in for an id or a content hash. + internal readonly struct CultNetDigest : IEquatable + { + private readonly ulong _a, _b, _c, _d; + + private CultNetDigest(ulong a, ulong b, ulong c, ulong d) { _a = a; _b = b; _c = c; _d = d; } + + public static CultNetDigest Of(byte[] bytes) + { + using var sha = SHA256.Create(); + var hash = sha.ComputeHash(bytes); + return new CultNetDigest( + BitConverter.ToUInt64(hash, 0), BitConverter.ToUInt64(hash, 8), + BitConverter.ToUInt64(hash, 16), BitConverter.ToUInt64(hash, 24)); + } + + public bool Equals(CultNetDigest other) => _a == other._a && _b == other._b && _c == other._c && _d == other._d; + public override bool Equals(object? obj) => obj is CultNetDigest other && Equals(other); + public override int GetHashCode() => unchecked((int)_a ^ (int)(_a >> 32)); + public static bool operator ==(CultNetDigest left, CultNetDigest right) => left.Equals(right); + public static bool operator !=(CultNetDigest left, CultNetDigest right) => !left.Equals(right); + } + /// /// One receipt-capable peer's writes: message id to content hash and the answer given, refusals included, so a resend of the - /// same content is answered again without being decided again and the same id with other content is refused. + /// same content is answered again without being decided again and the same id with other content is refused. Ids and content + /// hashes are held as fixed-size digests; the answer is held as its kind (and, for a refusal, its code, text and details), + /// never the strings of the write. /// internal sealed class CultNetWriteReceiptSession { /// The most writes a peer is remembered for, refused ones included; a peer at the limit has new writes refused. internal const int MaxRememberedWrites = 1024; + /// The most ids refused at the limit that are remembered, so their resend is judged the same way. + internal const int MaxRememberedLimitRefusals = 1024; + + /// The longest messageId a write may carry, in UTF-8 bytes (write-receipt.md, Id size). + internal const int MaxMessageIdBytes = 256; + /// What a write is refused with when it failed for a reason no typed refusal names. Never an exception's own text. internal const string WriteFailedText = "the write could not be applied"; private readonly object _gate = new(); - private readonly Dictionary _writes = new(StringComparer.Ordinal); + private readonly Dictionary _writes = new(); + private readonly Dictionary _limitRefusals = new(); private sealed class Entry { - public Entry(byte[] hash) { Hash = hash; } - public byte[] Hash { get; } - public ICultNetSchemaMessage? Answer { get; set; } + public Entry(CultNetDigest hash) { Hash = hash; } + public CultNetDigest Hash { get; set; } + public bool Abandoned { get; set; } + public bool Admitted { get; set; } + public CultNetErrorMessage? Refusal { get; set; } } - public enum Begin { New, Replay, Unanswered, DifferentContent, Full } + public enum Begin { New, ReplayAdmitted, ReplayRefusal, Unanswered, DifferentContent, Full } + + private static CultNetDigest IdDigest(string messageId) => CultNetDigest.Of(System.Text.Encoding.UTF8.GetBytes(messageId)); /// /// Looks up first. An id not seen is claimed for this content (); - /// otherwise the outcome says what already holds it, and is the answer to replay. + /// otherwise the outcome says what already holds it, and is the refusal to repeat. /// - public Begin TryBegin(string messageId, byte[] hash, out ICultNetSchemaMessage? answer) + public Begin TryBegin(string messageId, byte[] hash, out CultNetErrorMessage? refusal) { + var id = IdDigest(messageId); + var content = CultNetDigest.Of(hash); lock (_gate) { - answer = null; - if (_writes.TryGetValue(messageId, out var entry)) + refusal = null; + if (_writes.TryGetValue(id, out var entry)) { - if (!CryptographicOperations.FixedTimeEquals(entry.Hash, hash)) return Begin.DifferentContent; - if (entry.Answer != null) + if (entry.Abandoned) { - answer = entry.Answer; - return Begin.Replay; + // Not sent, so judged afresh in the slot it already holds; no slot is ever freed. + entry.Abandoned = false; + entry.Hash = content; + return Begin.New; + } + + if (entry.Hash != content) return Begin.DifferentContent; + if (entry.Admitted) return Begin.ReplayAdmitted; + if (entry.Refusal != null) + { + refusal = entry.Refusal; + return Begin.ReplayRefusal; } // No answer yet: the first attempt is still being applied, or its primary left it unanswered for good. return Begin.Unanswered; } - if (_writes.Count >= MaxRememberedWrites) return Begin.Full; - _writes[messageId] = new Entry(hash); + if (_writes.Count >= MaxRememberedWrites) + { + if (_limitRefusals.TryGetValue(id, out var refused)) return refused == content ? Begin.Full : Begin.DifferentContent; + if (_limitRefusals.Count < MaxRememberedLimitRefusals) _limitRefusals[id] = content; + return Begin.Full; + } + + _writes[id] = new Entry(content); return Begin.New; } } - /// The write was answered, admitted or refused: remember the answer so a resend repeats it. - public void Answered(string messageId, ICultNetSchemaMessage answer) + /// The write was admitted: remember it so a resend repeats the receipt. + public void Admitted(string messageId) { lock (_gate) { - if (_writes.TryGetValue(messageId, out var entry)) entry.Answer = answer; + if (_writes.TryGetValue(IdDigest(messageId), out var entry)) entry.Admitted = true; } } - /// The write never reached anyone who could have applied it: forget it so a repeat is judged afresh. + /// The write was refused: remember the refusal so a resend repeats it. + public void Refused(string messageId, CultNetErrorMessage refusal) + { + lock (_gate) + { + if (_writes.TryGetValue(IdDigest(messageId), out var entry)) entry.Refusal = refusal; + } + } + + /// + /// The write never reached anyone who could have applied it: a repeat is judged afresh. The entry keeps its slot, so + /// forgetting a write never makes room for an id that was refused at the limit. + /// public void Abandoned(string messageId) { lock (_gate) { - _writes.Remove(messageId); + if (_writes.TryGetValue(IdDigest(messageId), out var entry)) entry.Abandoned = true; } } } @@ -193,15 +258,19 @@ namespace GameCult.Networking /// public bool Begin() { - var outcome = _session.TryBegin(_messageId, CultNetWriteContentHash.Compute(_operation, _schemaId, _recordKey, _payload), out var answer); + var outcome = _session.TryBegin(_messageId, CultNetWriteContentHash.Compute(_operation, _schemaId, _recordKey, _payload), out var refusal); switch (outcome) { case CultNetWriteReceiptSession.Begin.New: _claimed = true; return true; - case CultNetWriteReceiptSession.Begin.Replay: + case CultNetWriteReceiptSession.Begin.ReplayAdmitted: _answered = true; - _send(answer!); + _send(AdmittedReceipt()); + return false; + case CultNetWriteReceiptSession.Begin.ReplayRefusal: + _answered = true; + _send(CultNetErrorV1Message.From(refusal!, _messageId)); return false; case CultNetWriteReceiptSession.Begin.Unanswered: // The first attempt is still being applied and will answer this id, or its primary left it unanswered: @@ -218,20 +287,21 @@ namespace GameCult.Networking } } + private CultNetWriteAdmittedMessage AdmittedReceipt() => new() + { + MessageId = _messageId, + Operation = _operation, + SchemaId = _schemaId, + RecordKey = _recordKey + }; + /// The write committed: remember the receipt and send it. public void Admitted() { if (_answered) return; _answered = true; - var receipt = new CultNetWriteAdmittedMessage - { - MessageId = _messageId, - Operation = _operation, - SchemaId = _schemaId, - RecordKey = _recordKey - }; - if (_claimed) _session.Answered(_messageId, receipt); - _send(receipt); + if (_claimed) _session.Admitted(_messageId); + _send(AdmittedReceipt()); } /// The write was refused: remember the refusal (a resend repeats it) and send its one error.v1. @@ -240,7 +310,7 @@ namespace GameCult.Networking if (_answered) return; _answered = true; var refusal = CultNetErrorV1Message.From(error, _messageId); - if (_claimed) _session.Answered(_messageId, refusal); + if (_claimed) _session.Refused(_messageId, error); _send(refusal); } @@ -263,7 +333,15 @@ namespace GameCult.Networking _claimed = false; } - /// A missing id cannot be answered under rule 1; refuse it with no id. - public bool HasId => _messageId.Length > 0; + /// + /// Why the write's id cannot be used at all, before anything is remembered: it is missing (refused with no id) or longer + /// than UTF-8 bytes. Null when the id is usable. + /// + public CultNetErrorMessage? IdRefusal => + _messageId.Length == 0 + ? CultNetErrorMessage.ForMessageInvalid("messageId", "a write needs a messageId") + : System.Text.Encoding.UTF8.GetByteCount(_messageId) > CultNetWriteReceiptSession.MaxMessageIdBytes + ? CultNetErrorMessage.ForMessageInvalid("messageId", "messageId exceeds 256 bytes") + : null; } } diff --git a/tests/GameCult.Networking.Tests/CultNetRudpSchemaServerPeerInputTests.cs b/tests/GameCult.Networking.Tests/CultNetRudpSchemaServerPeerInputTests.cs index e7900e7c..2bb59952 100644 --- a/tests/GameCult.Networking.Tests/CultNetRudpSchemaServerPeerInputTests.cs +++ b/tests/GameCult.Networking.Tests/CultNetRudpSchemaServerPeerInputTests.cs @@ -178,6 +178,46 @@ namespace GameCult.Networking.Tests Assert.That(scene.Logger.Errors, Has.Count.EqualTo(1)); } + // The RUDP schema server implements no receipt rules, so it must not advertise them (write-receipt.md, Capability and the + // conformance table): whatever hello it sends lists neither frame. + [Test] + public async Task TheRudpSchemaServerNeverAdvertisesTheReceiptFrames() + { + using var server = new RudpCultNetSchemaServer(new RudpCultNetSchemaServerOptions + { + RuntimeId = "csharp-rudp-no-receipts", + ConnectionId = ConnectionId + }); + var client = await Connect(server); + try + { + SendSchema(server, client, CultNetSchemaMessageSerialization.Serialize(new CultNetHelloMessage + { + RuntimeId = "receipt-client", + RuntimeKind = "test", + SupportedMessageVersions = [CultNetSchemaVersions.WriteAdmitted, CultNetSchemaVersions.ErrorV1] + })); + await server.PollAvailableAsync(16); + + var hellos = new List(); + foreach (var packet in Drain(client.Socket)) + foreach (var frame in client.Session.Receive(packet, 1).Delivered) + if (CultNetSchemaMessageSerialization.Deserialize(frame.Payload) is CultNetHelloMessage hello) + hellos.Add(hello); + + foreach (var hello in hellos) + { + var listed = hello.SupportedMessageVersions ?? Array.Empty(); + Assert.That(listed, Has.None.EqualTo(CultNetSchemaVersions.WriteAdmitted)); + Assert.That(listed, Has.None.EqualTo(CultNetSchemaVersions.ErrorV1)); + } + } + finally + { + client.Socket.Dispose(); + } + } + private static List ErrorsReceivedBy(Client client) { var errors = new List(); diff --git a/tests/GameCult.Networking.Tests/CultNetWriteReceiptServerTests.cs b/tests/GameCult.Networking.Tests/CultNetWriteReceiptServerTests.cs index cab649e2..0cbc0c0b 100644 --- a/tests/GameCult.Networking.Tests/CultNetWriteReceiptServerTests.cs +++ b/tests/GameCult.Networking.Tests/CultNetWriteReceiptServerTests.cs @@ -873,5 +873,192 @@ namespace GameCult.Networking.Tests { Assert.That(new CultNetSchemaWriteForwarderOptions().ResultWindow, Is.EqualTo(CultNetWriteCompletion.ReceiptResultWindow)); } + + // ---- Heimdall round two ---- + + private static string IdOfBytes(int bytes) => new string('i', bytes); + + [Test] + public async Task AnIdOver256BytesIsRefusedBeforeAnythingIsRemembered() + { + var (rig, wire) = await Capable(); + var tooLong = IdOfBytes(257); + + await rig.Put(wire, tooLong, KeyA, "one"); + await rig.Put(wire, tooLong, KeyA, "one"); + + Assert.That(wire.Sent, Has.Count.EqualTo(2)); + foreach (var sent in wire.Sent) + { + var error = AssertRefused(sent, tooLong, "message_invalid"); + Assert.That(error.Details!.Field, Is.EqualTo("messageId")); + Assert.That(error.Error, Is.EqualTo("message_invalid: messageId exceeds 256 bytes")); + } + + Assert.That(rig.TextOf(KeyA), Is.Null); + // Nothing was remembered: the whole table is still free for admitted writes. + wire.Sent.Clear(); + for (var index = 0; index < CultNetWriteReceiptSession.MaxRememberedWrites; index++) + await rig.Put(wire, $"w{index}", new CultRecordKey($"tests:receipt:{index}"), "x"); + Assert.That(wire.Sent.OfType().Count(), Is.EqualTo(CultNetWriteReceiptSession.MaxRememberedWrites)); + } + + [Test] + public async Task TheIdLimitCountsUtf8BytesNotCharacters() + { + var (rig, wire) = await Capable(); + var atLimit = IdOfBytes(256); + var multibyte = new string('\u00e9', 129); // 129 chars, 258 bytes + + await rig.Put(wire, atLimit, KeyA, "one"); + await rig.Put(wire, multibyte, KeyB, "two"); + + AssertAdmitted(wire.Sent[0], atLimit, "put", KeyA.Value); + AssertRefused(wire.Sent[1], multibyte, "message_invalid"); + } + + [Test] + public void ARememberedEntryHoldsOnlyFixedSizeDigestsNotTheStrings() + { + var flags = BindingFlags.NonPublic | BindingFlags.Instance; + var table = typeof(CultNetWriteReceiptSession).GetFields(flags) + .Where(f => f.FieldType.IsGenericType && f.FieldType.GetGenericTypeDefinition() == typeof(Dictionary<,>)); + Assert.That(table, Is.Not.Empty); + foreach (var field in table) + { + var args = field.FieldType.GetGenericArguments(); + Assert.That(args[0].IsValueType, Is.True, $"{field.Name} is keyed by {args[0]}"); + foreach (var held in args[1].IsValueType || args[1] == typeof(string) ? new[] { args[1] } : args[1].GetFields(flags).Select(f => f.FieldType).Concat(args[1].GetProperties(flags | BindingFlags.Public).Select(p => p.PropertyType))) + { + Assert.That(held == typeof(string) || held == typeof(byte[]), Is.False, $"{field.Name} holds {held}"); + } + } + } + + [Test] + public async Task AnAdmittedResendIsAnsweredFromDigestsWithTheResendsOwnBody() + { + var (rig, wire) = await Capable(); + await rig.Put(wire, "m1", KeyA, "one"); + await rig.Put(wire, "m1", KeyA, "one"); + + AssertAdmitted(wire.Sent[1], "m1", "put", KeyA.Value); + Assert.That(rig.LogLength, Is.EqualTo(1)); + } + + [Test] + public async Task ARefusalIsReplayedForAResendAfterTheDigestChange() + { + var (rig, wire) = await Capable(); + rig.Authority.Denied.Add(KeyA.Value); + await rig.Put(wire, "m1", KeyA, "one"); + await rig.Put(wire, "m1", KeyA, "one"); + + AssertRefused(wire.Sent[0], "m1", "write_not_authorized"); + AssertRefused(wire.Sent[1], "m1", "write_not_authorized"); + Assert.That(rig.Authority.Asked, Is.EqualTo(1)); + } + + [Test] + public async Task APutWithNoDocumentIsRefusedNamingTheDocumentWithFixedText([Values] bool capable) + { + var rig = Build(); + var wire = new Wire(); + if (capable) await rig.Hello(wire, BothFrames); + wire.Sent.Clear(); + var message = rig.PutOf("m1", KeyA, "one"); + message.Document = null!; + + await rig.PutHandler(message, wire.Peer); + + var sent = wire.Sent.Single(); + var error = capable ? AssertRefused(sent, "m1", "message_invalid").ToV0() : (CultNetErrorMessage)sent; + Assert.That(error.Code, Is.EqualTo("message_invalid")); + Assert.That(error.Details!.Field, Is.EqualTo("document")); + Assert.That(error.Error, Is.EqualTo("message_invalid: document is required")); + } + + [Test] + public async Task APutWithNoSchemaIdIsRefusedNamingTheSchemaIdWithFixedText() + { + var (rig, wire) = await Capable(); + var message = rig.PutOf("m1", KeyA, "one"); + message.Document.SchemaId = null!; + + await rig.PutHandler(message, wire.Peer); + + var error = AssertRefused(wire.Sent.Single(), "m1", "message_invalid"); + Assert.That(error.Details!.Field, Is.EqualTo("schemaId")); + Assert.That(error.Error, Is.EqualTo("message_invalid: schemaId is required")); + } + + [Test] + public async Task ADeleteWithNoSchemaIdIsRefusedNamingTheSchemaIdWithFixedText([Values] bool capable) + { + var rig = Build(); + var wire = new Wire(); + if (capable) await rig.Hello(wire, BothFrames); + wire.Sent.Clear(); + var message = rig.DeleteOf("d1", KeyA); + message.SchemaId = null!; + + await rig.DeleteHandler(message, wire.Peer); + + var sent = wire.Sent.Single(); + var error = capable ? AssertRefused(sent, "d1", "message_invalid").ToV0() : (CultNetErrorMessage)sent; + Assert.That(error.Details!.Field, Is.EqualTo("schemaId")); + Assert.That(error.Error, Is.EqualTo("message_invalid: schemaId is required")); + } + + [Test] + public async Task ARefusalAtTheCapIsRememberedSoAResendReplaysItAndADifferentContentResendIsRefused() + { + var (rig, wire) = await Capable(); + for (var index = 0; index < CultNetWriteReceiptSession.MaxRememberedWrites; index++) + await rig.Put(wire, $"w{index}", new CultRecordKey($"tests:receipt:{index}"), "x"); + wire.Sent.Clear(); + + await rig.Put(wire, "over", KeyA, "x"); + await rig.Put(wire, "over", KeyA, "x"); + await rig.Put(wire, "over", KeyB, "other"); + + AssertRefused(wire.Sent[0], "over", "limit_reached"); + AssertRefused(wire.Sent[1], "over", "limit_reached"); + var changed = AssertRefused(wire.Sent[2], "over", "message_invalid"); + Assert.That(changed.Details!.Field, Is.EqualTo("messageId")); + Assert.That(rig.TextOf(KeyA), Is.Null); + } + + private static byte[] HashOf(string text) => CultNetWriteContentHash.Compute("put", "s", text, new byte[] { 1 }); + + [Test] + public void AnAbandonedClaimFreesNoSlotSoARefusedIdNeverBecomesAdmissible() + { + var session = new CultNetWriteReceiptSession(); + for (var index = 0; index < CultNetWriteReceiptSession.MaxRememberedWrites; index++) + Assert.That(session.TryBegin($"w{index}", HashOf($"k{index}"), out _), Is.EqualTo(CultNetWriteReceiptSession.Begin.New)); + Assert.That(session.TryBegin("over", HashOf("o"), out _), Is.EqualTo(CultNetWriteReceiptSession.Begin.Full)); + + session.Abandoned("w0"); + + Assert.That(session.TryBegin("over", HashOf("o"), out _), Is.EqualTo(CultNetWriteReceiptSession.Begin.Full)); + Assert.That(session.TryBegin("another", HashOf("a"), out _), Is.EqualTo(CultNetWriteReceiptSession.Begin.Full)); + // The abandoned id itself is judged afresh, in its own slot. + Assert.That(session.TryBegin("w0", HashOf("k0"), out _), Is.EqualTo(CultNetWriteReceiptSession.Begin.New)); + } + + [Test] + public void TheRefusalSetIsBoundedAndNeverAdmitsAnId() + { + var session = new CultNetWriteReceiptSession(); + for (var index = 0; index < CultNetWriteReceiptSession.MaxRememberedWrites; index++) + session.TryBegin($"w{index}", HashOf($"k{index}"), out _); + for (var index = 0; index < CultNetWriteReceiptSession.MaxRememberedWrites + 5; index++) + Assert.That(session.TryBegin($"o{index}", HashOf("o"), out _), Is.EqualTo(CultNetWriteReceiptSession.Begin.Full)); + + session.Abandoned("w1"); + + Assert.That(session.TryBegin("o1030", HashOf("o"), out _), Is.EqualTo(CultNetWriteReceiptSession.Begin.Full)); + } } }