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));
+ }
}
}