diff --git a/src/GameCult.Networking/CultNetDatabaseServer.cs b/src/GameCult.Networking/CultNetDatabaseServer.cs index 16967d49..1746391e 100644 --- a/src/GameCult.Networking/CultNetDatabaseServer.cs +++ b/src/GameCult.Networking/CultNetDatabaseServer.cs @@ -391,6 +391,14 @@ namespace GameCult.Networking } // A refusal: error.v1 carrying the write's id for a receipt-capable connection, the v0 error for any other. + // An empty or blank schemaId or recordKey names no record: refused as a frame that does not read, exactly as the Rust + // server's decoder refuses it (write-receipt.md, "Required fields"), never handed to the store. + private static bool IsUnreadableWriteTarget(string? schemaId, string? recordKey) => + string.IsNullOrWhiteSpace(schemaId) || string.IsNullOrWhiteSpace(recordKey); + + private static CultNetErrorMessage UnreadableWriteTarget() => + CultNetErrorMessage.ForMessageInvalid("message", "the message could not be read"); + private static void Refuse(CultNetServerPeer peer, CultNetReceiptedWrite? write, CultNetErrorMessage error) { if (write != null) write.Refused(error); @@ -431,6 +439,12 @@ namespace GameCult.Networking return; } + if (IsUnreadableWriteTarget(message.Document!.SchemaId, message.Document.RecordKey)) + { + Refuse(peer, write, UnreadableWriteTarget()); + return; + } + if (write != null && !write.Begin()) return; var authorize = Authorize(peer, CultNetRemoteWriteOperation.Put, message.ShardId, message.ShardEpoch); @@ -506,6 +520,12 @@ namespace GameCult.Networking return; } + if (IsUnreadableWriteTarget(message.SchemaId, message.RecordKey)) + { + Refuse(peer, write, UnreadableWriteTarget()); + return; + } + if (write != null && !write.Begin()) return; var authorize = Authorize(peer, CultNetRemoteWriteOperation.Delete, message.ShardId, message.ShardEpoch); diff --git a/tests/GameCult.Networking.Tests/CultNetWriteReceiptServerTests.cs b/tests/GameCult.Networking.Tests/CultNetWriteReceiptServerTests.cs index 13aab811..c572cfbf 100644 --- a/tests/GameCult.Networking.Tests/CultNetWriteReceiptServerTests.cs +++ b/tests/GameCult.Networking.Tests/CultNetWriteReceiptServerTests.cs @@ -1010,6 +1010,43 @@ namespace GameCult.Networking.Tests Assert.That(error.Error, Is.EqualTo("message_invalid: schemaId is required")); } + // An empty or blank schemaId or recordKey is refused like a frame that does not read (the Rust server's rule): message_invalid, + // details.field "message", the write's id, nothing applied. A null schemaId stays "schemaId is required". + [TestCase("", null)] + [TestCase(" ", null)] + [TestCase(null, "")] + [TestCase(null, " ")] + public async Task APutWithAnEmptyOrBlankSchemaIdOrRecordKeyIsRefusedAsUnreadableAndNotApplied(string? schemaId, string? recordKey) + { + var (rig, wire) = await Capable(); + var message = rig.PutOf("m1", KeyA, "one"); + if (schemaId != null) message.Document.SchemaId = schemaId; + if (recordKey != null) message.Document.RecordKey = recordKey; + wire.Sent.Clear(); + + await rig.PutHandler(message, wire.Peer); + + var error = AssertRefused(wire.Sent.Single(), "m1", "message_invalid"); + Assert.That(error.Details!.Field, Is.EqualTo("message")); + Assert.That(rig.TextOf(KeyA), Is.Null); + } + + [TestCase("", "k")] + [TestCase(" ", "k")] + [TestCase("note.v0", "")] + public async Task ADeleteWithAnEmptyOrBlankSchemaIdOrRecordKeyIsRefusedAsUnreadable(string schemaId, string recordKey) + { + var (rig, wire) = await Capable(); + var message = rig.DeleteOf("d1", new CultRecordKey(recordKey)); + message.SchemaId = schemaId; + wire.Sent.Clear(); + + await rig.DeleteHandler(message, wire.Peer); + + var error = AssertRefused(wire.Sent.Single(), "d1", "message_invalid"); + Assert.That(error.Details!.Field, Is.EqualTo("message")); + } + [Test] public async Task ARefusalAtTheCapIsRememberedSoAResendReplaysItAndADifferentContentResendIsRefused() {