using System; using System.Collections.Generic; using System.Security.Cryptography; using GameCult.Caching; using MessagePack; namespace GameCult.Networking { /// /// The content hash a write-receipt server binds to a (peer, messageId) dedupe key /// (contracts/cultnet/interop/write-receipt.md, rule 5). /// public static class CultNetWriteContentHash { /// /// SHA-256 of the MessagePack array [operation, schemaId, recordKey, payload], payload a bin /// for a put and nil for a delete. /// public static byte[] Compute(string operation, string schemaId, string recordKey, byte[]? payload) { var buffer = new System.Buffers.ArrayBufferWriter(); var writer = new MessagePackWriter(buffer); writer.WriteArrayHeader(4); writer.Write(operation); writer.Write(schemaId); writer.Write(recordKey); if (payload == null) writer.WriteNil(); else writer.Write(payload); writer.Flush(); using var sha = SHA256.Create(); return sha.ComputeHash(buffer.WrittenSpan.ToArray()); } } /// /// Whether a peer's hello opted in to write receipts: it lists both cultnet.write_admitted.v0 and /// cultnet.error.v1. The capability changes framing only; it never authorizes. /// internal static class CultNetWriteReceiptCapability { public static bool Declares(CultNetHelloMessage hello) { var versions = hello.SupportedMessageVersions; return versions != null && Array.IndexOf(versions, CultNetSchemaVersions.WriteAdmitted) >= 0 && Array.IndexOf(versions, CultNetSchemaVersions.ErrorV1) >= 0; } } /// /// 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. /// 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; /// 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 sealed class Entry { public Entry(byte[] hash) { Hash = hash; } public byte[] Hash { get; } public bool Silent { get; set; } public ICultNetSchemaMessage? Answer { get; set; } } public enum Begin { New, Replay, InFlight, Silent, DifferentContent, Full } /// /// 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. /// public Begin TryBegin(string messageId, byte[] hash, out ICultNetSchemaMessage? answer) { lock (_gate) { answer = null; if (_writes.TryGetValue(messageId, out var entry)) { if (!CryptographicOperations.FixedTimeEquals(entry.Hash, hash)) return Begin.DifferentContent; if (entry.Answer != null) { answer = entry.Answer; return Begin.Replay; } return entry.Silent ? Begin.Silent : Begin.InFlight; } if (_writes.Count >= MaxRememberedWrites) return Begin.Full; _writes[messageId] = new Entry(hash); 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) { lock (_gate) { if (_writes.TryGetValue(messageId, out var entry)) entry.Answer = answer; } } /// /// The primary left a forward unanswered: it may have applied it, so the id is never forwarded again on this session and /// a resend is answered with nothing (the client settles it unconfirmed). /// public void LeftSilent(string messageId) { lock (_gate) { if (_writes.TryGetValue(messageId, out var entry)) entry.Silent = true; } } /// The write never reached anyone who could have applied it: forget it so a repeat is judged afresh. public void Abandoned(string messageId) { lock (_gate) { _writes.Remove(messageId); } } } /// /// The messageId of a schema frame that does not decode (write-receipt.md, rule 3: a refusal carries the write's id whenever /// the frame is readable enough to find it). /// internal static class CultNetReadableMessageId { /// The frame's top-level string messageId, or null when the bytes are not a map holding one. public static string? From(byte[] payload) { try { 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) { var id = reader.ReadString(); return string.IsNullOrEmpty(id) ? null : id; } if (key == null) reader.Skip(); reader.Skip(); } } catch (Exception) { // Not a readable map: no id. } return null; } } /// /// One write on a receipt-capable connection: its dedupe claim and its single answer (write-receipt.md, rules 1 to 5). /// internal sealed class CultNetReceiptedWrite { private readonly CultNetWriteReceiptSession _session; private readonly Action _send; private readonly string _operation; private readonly string _messageId; private readonly string _schemaId; private readonly string _recordKey; private readonly byte[]? _payload; private bool _claimed; private bool _answered; public CultNetReceiptedWrite( CultNetWriteReceiptSession session, Action send, string operation, string? messageId, string? schemaId, string? recordKey, byte[]? payload) { _session = session; _send = send; _operation = operation; _messageId = messageId ?? string.Empty; _schemaId = schemaId ?? string.Empty; _recordKey = recordKey ?? string.Empty; _payload = payload; } /// /// Looks the write's id up before anything else is decided (rule 5). True when the id is new and the write should go on /// to authorization and the commit; false when it was answered here (a replay, a reused id, a full table) or is owed no /// further answer (still in flight, or left silent by its primary). /// public bool Begin() { var outcome = _session.TryBegin(_messageId, CultNetWriteContentHash.Compute(_operation, _schemaId, _recordKey, _payload), out var answer); switch (outcome) { case CultNetWriteReceiptSession.Begin.New: _claimed = true; return true; case CultNetWriteReceiptSession.Begin.Replay: _answered = true; _send(answer!); return false; case CultNetWriteReceiptSession.Begin.InFlight: // The first attempt is still being applied and will answer this id. _answered = true; return false; case CultNetWriteReceiptSession.Begin.Silent: // The first attempt went to a primary that never answered; this id is never sent again, and stays unanswered. _answered = true; return false; case CultNetWriteReceiptSession.Begin.DifferentContent: Refused(CultNetErrorMessage.ForMessageInvalid("messageId", "the message id was already used for different content")); return false; default: _answered = true; _send(CultNetErrorV1Message.LimitReached(_messageId)); return false; } } /// 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); } /// The write was refused: remember the refusal (a resend repeats it) and send its one error.v1. public void Refused(CultNetErrorMessage error) { if (_answered) return; _answered = true; var refusal = CultNetErrorV1Message.From(error, _messageId); if (_claimed) _session.Answered(_messageId, refusal); _send(refusal); } /// /// A forward its primary left unanswered: no answer is owed or possible, and the id is never forwarded again on this /// session. Sends nothing. /// public void LeftSilent() { if (_answered) return; _answered = true; if (_claimed) _session.LeftSilent(_messageId); } /// A forward that never reached a primary that could have applied it: forget the claim, send nothing. public void Unanswered() { if (_answered) return; _answered = true; if (_claimed) _session.Abandoned(_messageId); _claimed = false; } /// A missing id cannot be answered under rule 1; refuse it with no id. public bool HasId => _messageId.Length > 0; } }