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; } } /// 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); } /// /// What a session keeps of a refusal (write-receipt.md, What is remembered): its code, the field its details name, and its /// routing hint (a shard descriptor the server owns). Never the write's strings or an authority's reason: the replay is built /// from fixed text and the fields of the resend, which carries the same content. /// internal readonly struct CultNetRememberedRefusal { public string? Code { get; } public bool HasDetails { get; } public string? Field { get; } public CultNetShardRoutingHint? Hint { get; } public CultNetRememberedRefusal(string? code, bool hasDetails, string? field, CultNetShardRoutingHint? hint) { Code = code; HasDetails = hasDetails; Field = field; Hint = hint; } public static CultNetRememberedRefusal Of(CultNetErrorMessage error) => new(error.Code, error.Details != null, error.Details?.Field, error.RoutingHint); private static string? NullIfEmpty(string value) => value.Length == 0 ? null : value; /// The refusal, in fixed text, naming the write's own record key or schema id where its details do. public CultNetErrorMessage ToError(string schemaId, string recordKey) => new() { Error = Code switch { "unowned_schema" => "unowned_schema: no shard owns the schema and key", null => Hint != null ? CultNetDatabaseServer.RoutingRefusalText : CultNetWriteReceiptSession.WriteFailedText, _ => Code }, Code = Code, RoutingHint = Hint, Details = HasDetails ? new CultNetErrorDetails { Field = Field, Value = Field switch { "recordKey" => NullIfEmpty(recordKey), "schemaId" => NullIfEmpty(schemaId), _ => null } } : null }; } /// /// 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. Ids and content /// hashes are held as fixed-size digests; the answer is held as its kind (and, for a refusal, its code and the field its details name), /// 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(); private readonly Dictionary _limitRefusals = new(); private sealed class Entry { public Entry(CultNetDigest hash) { Hash = hash; } public CultNetDigest Hash { get; set; } public bool Abandoned { get; set; } public bool Admitted { get; set; } public CultNetRememberedRefusal? Refusal { get; set; } } 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 refusal to repeat. /// public Begin TryBegin(string messageId, byte[] hash, out CultNetRememberedRefusal? refusal) { var id = IdDigest(messageId); var content = CultNetDigest.Of(hash); lock (_gate) { refusal = null; if (_writes.TryGetValue(id, out var entry)) { if (entry.Abandoned) { // 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) { 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 admitted: remember it so a resend repeats the receipt. public void Admitted(string messageId) { lock (_gate) { if (_writes.TryGetValue(IdDigest(messageId), out var entry)) entry.Admitted = true; } } /// The write was refused: remember the refusal so a resend repeats it. public void Refused(string messageId, CultNetRememberedRefusal 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) { if (_writes.TryGetValue(IdDigest(messageId), out var entry)) entry.Abandoned = true; } } } /// /// 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 refusal); switch (outcome) { case CultNetWriteReceiptSession.Begin.New: _claimed = true; return true; case CultNetWriteReceiptSession.Begin.ReplayAdmitted: _answered = true; _send(AdmittedReceipt()); return false; case CultNetWriteReceiptSession.Begin.ReplayRefusal: _answered = true; _send(CultNetErrorV1Message.From(refusal!.Value.ToError(_schemaId, _recordKey), _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: // then the claim stays, so the id is never sent again on this session, and the write stays unconfirmed. _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; } } 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; if (_claimed) _session.Admitted(_messageId); _send(AdmittedReceipt()); } /// 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; if (_claimed) { // A remembered refusal is answered from what is remembered, so the replay is the refusal again. var kept = CultNetRememberedRefusal.Of(error); _session.Refused(_messageId, kept); error = kept.ToError(_schemaId, _recordKey); } _send(CultNetErrorV1Message.From(error, _messageId)); } /// /// A forward that may have reached its primary and is unconfirmed (silence, reset, timeout, an ambiguous send): no answer is /// owed or possible, and the claim stays so the id is never forwarded again on this session. Sends nothing. /// public void LeftSilent() { if (_answered) return; _answered = true; } /// A forward that was provably not sent (connect failed, or the send failed before any byte was written): forget the claim, send nothing. public void NotSent() { if (_answered) return; _answered = true; if (_claimed) _session.Abandoned(_messageId); _claimed = false; } /// /// 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; } }