From a4cf54f4a91be0e0c6dfce5276fa0a16238ff5ca Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 22 Sep 2026 20:04:22 +0200 Subject: [PATCH] Land fix batch 3's R-O and R-Q: keyed length-prefixed cursors, replica-apply sequence gap R-O: CultNetSelectionCursorKey is a per-process (per-CultNetDatabase-instance) 32-byte random key. CultNetSelectionCursor.ComputeDigest is now an HMAC-SHA256 under that key instead of an unkeyed SHA-256, so a cursor cannot be forged without the key and does not verify across a restart (a fresh CultNetDatabase mints a fresh key). The cursor body (asOf/ordinal/schemaId/recordKey/digest) is length-prefixed (:, no delimiter) instead of joined with a bare "\x01"-separated string, so a record key carrying any character, U+241F included, round-trips. CultNetSelectionEvaluator.Select/ Page/ComputeDigest take an optional CultNetSelectionCursorKey, defaulting to a process-wide key generated once so existing callers that never wire up a database still get a keyed digest. CultNetDatabase gets a CursorKey property (random unless CultNetDatabaseOptions.CursorKey is supplied) and passes it through CultNetDatabaseServer.CreateSelectionResponse. Rust already landed R-O (5aac120) under its own algorithm, deliberately not byte-matching the reference: its own doc comment says a cursor is never handed across runtimes, so only collision-freedom and keying are load-bearing, not the wire bytes. That is a real divergence from this pass's instruction to match Rust's wire encoding exactly - reported in full in the batch report. R-Q: the two replica-apply paths (ApplyCommittedPutAsync, ApplyCommittedDelete) recorded the mutation-log entry but never set _lastWriteSequence, so a row's order/cursor position looked like "never written" until it was written again locally. Both now set it from the entry's own sequence, matching AppendMutationLogEntry's local-commit path. InitializeLogSequencesFromStore rebuilt only _nextLogSequences at startup; it now also rebuilds _lastWriteSequence by replaying the durable log's Put/Delete entries in sequence order. CultNetDatabase.ResolveShard is a new public wrapper over the existing shard resolution; CultNetDocumentRegistry.SelectPage/CreateSelectionResponse take an optional shardIdOf callback and refuse (reusing CultNetSelectionInvalidException/selection_invalid, R-N) when a selection's matched rows resolve to more than one shard's log, because asOf is a single watermark and is not exact for a shard-spanning page. CultNetDatabaseServer.CreateSelectionResponse wires shardIdOf/cursorKey through from the database. Also fixes a defect the Rust Hands found while matching wire bytes: CultNetErrorMessage.ForReferenceOutsideTarget double-prefixed "reference_outside_target: " - the exception's own message already carries it. Now prefixed once, matching Rust's ForReferenceOutsideTarget. Co-Authored-By: Claude Sonnet 5 --- src/GameCult.Networking/CultNetDatabase.cs | 67 ++++++- .../CultNetDatabaseServer.cs | 7 +- .../CultNetDocumentRegistry.cs | 54 +++++- .../CultNetSchemaMessages.cs | 6 +- .../CultNetSelectionEvaluator.cs | 172 ++++++++++++++---- .../CultNetSelectionEvaluatorTests.cs | 45 +++++ .../CultNetSelectionFixBatchTests.cs | 89 +++++++++ .../NetworkingTests.cs | 142 +++++++++++++++ 8 files changed, 541 insertions(+), 41 deletions(-) diff --git a/src/GameCult.Networking/CultNetDatabase.cs b/src/GameCult.Networking/CultNetDatabase.cs index d5b3773d..57f221f8 100644 --- a/src/GameCult.Networking/CultNetDatabase.cs +++ b/src/GameCult.Networking/CultNetDatabase.cs @@ -305,6 +305,12 @@ namespace GameCult.Networking /// Gets or sets the durable store for accepted shard mutation logs. /// public ICultNetShardMutationLogStore? MutationLogStore { get; set; } + /// + /// Gets or sets the process cursor key (R-O). Omitted in production, where a fresh random key + /// per is exactly the point; a test that must mint a cursor under a + /// known key, or reuse the pre-restart key deliberately, sets this explicitly. + /// + public CultNetSelectionCursorKey? CursorKey { get; set; } } /// @@ -494,10 +500,25 @@ namespace GameCult.Networking : options.Shards) .ToList(); _clientAuthorityScopes = (options.ClientAuthorityScopes ?? Array.Empty()).ToList(); + CursorKey = options.CursorKey ?? CultNetSelectionCursorKey.Random(); InitializeLogSequencesFromStore(); _cache.OnUpdate += PublishCacheUpdate; } + /// + /// This process's cursor key (R-O): random per instance unless + /// supplies one, so a page minted by this database cannot be answered by a differently-keyed one, + /// and a fresh - a real process restart, or a simulated one in tests - + /// mints under a fresh key that a prior cursor's digest does not verify against. + /// + public CultNetSelectionCursorKey CursorKey { get; } + + /// + /// Resolves the shard that owns a record key (R-Q: whether a matched selection's rows span more + /// than one shard's log, and so whether the page's asOf is well defined). + /// + public CultNetShardDescriptor ResolveShard(string schemaId, CultRecordKey key) => ResolveShardInternal(schemaId, key); + /// /// Gets the local cache backing this database surface. /// @@ -1337,10 +1358,26 @@ namespace GameCult.Networking foreach (var shard in _shards) { - var highest = _mutationLogStore.Read(shard.ShardId) - .Select(entry => entry.Sequence) - .DefaultIfEmpty(0) - .Max(); + // R-Q: the log is the durable record of every commit, replica-applied ones included, so + // the last-write sequence a restart resumes with is rebuilt from it exactly as + // AppendMutationLogEntry/the replica apply paths maintain it live - a row's ordinal for + // the selection evaluator's order and cursor must not reset to "never written" on restart. + var entries = _mutationLogStore.Read(shard.ShardId); + var highest = 0L; + foreach (var entry in entries) + { + if (entry.Sequence > highest) + { + highest = entry.Sequence; + } + + var (schemaId, recordKey) = KeyOf(entry); + if (schemaId != null && recordKey != null) + { + _lastWriteSequence[(schemaId, recordKey)] = entry.Sequence; + } + } + if (highest > 0) { _nextLogSequences[shard.ShardId] = highest + 1; @@ -1348,6 +1385,21 @@ namespace GameCult.Networking } } + private static (string? SchemaId, string? RecordKey) KeyOf(CultNetShardLogEntryMessage entry) + { + if (entry.Put?.Document != null) + { + return (entry.Put.Document.SchemaId, entry.Put.Document.RecordKey); + } + + if (entry.Delete != null) + { + return (entry.Delete.SchemaId, entry.Delete.RecordKey); + } + + return (null, null); + } + private async Task ApplyCommittedShardLogEntryAsync( CultNetShardDescriptor shard, CultNetShardLogEntryMessage entry) @@ -1416,6 +1468,11 @@ namespace GameCult.Networking document, previous), entry); + // R-Q: a replica apply is a commit path too - the last-write sequence this row now carries + // is the entry's own sequence, exactly like AppendMutationLogEntry's local-commit path sets + // it, so the selection evaluator's order and cursor agree whether a row's most recent write + // landed locally or arrived through replication. + _lastWriteSequence[(descriptor.SchemaId, key.Value)] = entry.Sequence; PublishUntyped( descriptor.DocumentType, kind, @@ -1459,6 +1516,8 @@ namespace GameCult.Networking document: null, previousDocument: previous), entry); + // R-Q: see ApplyCommittedPutAsync - a replica-applied delete is a commit too. + _lastWriteSequence[(descriptor.SchemaId, key.Value)] = entry.Sequence; PublishUntyped( descriptor.DocumentType, CultNetDatabaseChangeKind.Removed, diff --git a/src/GameCult.Networking/CultNetDatabaseServer.cs b/src/GameCult.Networking/CultNetDatabaseServer.cs index 3c6f89db..1e53abf1 100644 --- a/src/GameCult.Networking/CultNetDatabaseServer.cs +++ b/src/GameCult.Networking/CultNetDatabaseServer.cs @@ -130,7 +130,12 @@ namespace GameCult.Networking string.IsNullOrWhiteSpace(request.MessageId) ? Guid.NewGuid().ToString("N") : request.MessageId, request.Selection, ordinalOf: (schemaId, key) => _database.LastWriteSequence(schemaId, key) ?? 0, - asOf: _database.CurrentAsOf()); + asOf: _database.CurrentAsOf(), + // R-Q: asOf is one shard-log watermark, so a selection whose matched rows span more than + // one shard's log is refused rather than answered against a watermark that is not + // exact for all of them. + shardIdOf: (schemaId, key) => _database.ResolveShard(schemaId, key).ShardId, + cursorKey: _database.CursorKey); } /// diff --git a/src/GameCult.Networking/CultNetDocumentRegistry.cs b/src/GameCult.Networking/CultNetDocumentRegistry.cs index 1cf0a13c..dd85ffec 100644 --- a/src/GameCult.Networking/CultNetDocumentRegistry.cs +++ b/src/GameCult.Networking/CultNetDocumentRegistry.cs @@ -344,13 +344,15 @@ namespace GameCult.Networking CultNetSelection selection, Func ordinalOf, ulong asOf, - CultNetDocumentMessageOptions? options = null) + CultNetDocumentMessageOptions? options = null, + Func? shardIdOf = null, + CultNetSelectionCursorKey? cursorKey = null) { if (cache == null) throw new ArgumentNullException(nameof(cache)); if (selection == null) throw new ArgumentNullException(nameof(selection)); if (ordinalOf == null) throw new ArgumentNullException(nameof(ordinalOf)); - var page = SelectPage(cache, selection, ordinalOf, asOf, options); + var page = SelectPage(cache, selection, ordinalOf, asOf, options, shardIdOf: shardIdOf, cursorKey: cursorKey); return new CultNetSnapshotResponseRawV1Message { MessageId = RequireNonEmpty(messageId, nameof(messageId)), @@ -374,7 +376,9 @@ namespace GameCult.Networking Func ordinalOf, ulong asOf, CultNetDocumentMessageOptions? options, - Func? rowFilter = null) + Func? rowFilter = null, + Func? shardIdOf = null, + CultNetSelectionCursorKey? cursorKey = null) { selection = ExpandSchemaBindingAliases(selection); // R-F: CultNetSelectionEvaluator.Select validates first, every time - there is no separate @@ -388,7 +392,17 @@ namespace GameCult.Networking .Select(entry => new CultNetSelectionEvaluator.Row( entry.Descriptor, entry.Key, entry.Document, ordinalOf(entry.Descriptor.SchemaId, entry.Key), entry.StoredAt)) .ToArray(); - var evaluation = CultNetSelectionEvaluator.Select(_documents, rows, selection, asOf); + var full = CultNetSelectionEvaluator.EvaluateAll(_documents, rows, selection); + // R-Q: a page's asOf is exact only when every matched row is committed through one shard's + // log - a caller that knows about shards (CultNetDatabaseServer) supplies shardIdOf; one + // that does not (the raw v0/shard-scoped rowFilter paths, which are already bounded to a + // single shard by construction) passes null and skips the check rather than pay for it. + if (shardIdOf != null) + { + EnsureSingleShard(full.Ordered, shardIdOf); + } + + var evaluation = CultNetSelectionEvaluator.Page(full, selection, asOf, cursorKey); var records = evaluation.Rows.Select(row => ToRawRecord(row, options)).ToArray(); var wantDocument = selection.Projection == CultNetSelectionProjections.Document; @@ -404,6 +418,38 @@ namespace GameCult.Networking }; } + /// + /// Refuses a selection whose matched rows come from more than one shard's mutation log (R-Q): + /// asOf is one shard-log watermark ( is a single + /// number today, not one per shard), so a page spanning two shards' logs cannot honestly claim + /// to be exact as of it. Refused door-side, the same as an unreachable index or an empty + /// keys list (R-F), rather than answered with an asOf that is not really defined - + /// reusing /selection_invalid (R-N) rather + /// than adding a second refusal shape for what is, from the wire's point of view, the same + /// "this selection cannot be answered as asked" refusal a bad field or an empty list gets. + /// + private static void EnsureSingleShard( + IReadOnlyList rows, + Func shardIdOf) + { + string? shardId = null; + foreach (var row in rows) + { + var rowShardId = shardIdOf(row.Descriptor.SchemaId, row.Key); + if (shardId == null) + { + shardId = rowShardId; + } + else if (!string.Equals(shardId, rowShardId, StringComparison.Ordinal)) + { + throw new CultNetSelectionInvalidException( + "asOf", + null, + $"The selection matches rows from more than one shard's log (at least '{shardId}' and '{rowShardId}'); asOf is not defined for a shard-spanning page."); + } + } + } + /// /// Evaluates a selection over every row the cache holds and projects every matched row - no /// cursor, no 200-row cap. The v0 lowering and the shard-bounded snapshot both answer their diff --git a/src/GameCult.Networking/CultNetSchemaMessages.cs b/src/GameCult.Networking/CultNetSchemaMessages.cs index bc2b2379..f343fad7 100644 --- a/src/GameCult.Networking/CultNetSchemaMessages.cs +++ b/src/GameCult.Networking/CultNetSchemaMessages.cs @@ -553,7 +553,11 @@ namespace GameCult.Networking public static CultNetErrorMessage ForReferenceOutsideTarget(CultNetSelectionReferenceOutsideTargetException ex) => new CultNetErrorMessage { - Error = $"reference_outside_target: {ex.Message}", + // ex.Message already begins "reference_outside_target: " (the exception's own + // constructor prefixes it) - this used to prefix it a second time, so a peer received + // "reference_outside_target: reference_outside_target: ...". Rust's ForReferenceOutsideTarget + // (packages/cultnet-rs) emits the single-prefixed form; this now matches it. + Error = ex.Message, Code = "reference_outside_target" }; } diff --git a/src/GameCult.Networking/CultNetSelectionEvaluator.cs b/src/GameCult.Networking/CultNetSelectionEvaluator.cs index aaeeacec..0e01ec0e 100644 --- a/src/GameCult.Networking/CultNetSelectionEvaluator.cs +++ b/src/GameCult.Networking/CultNetSelectionEvaluator.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Globalization; using System.Linq; @@ -221,10 +221,11 @@ namespace GameCult.Networking CultDocumentRegistry registry, IReadOnlyList allRows, CultNetSelection selection, - ulong asOf) + ulong asOf, + CultNetSelectionCursorKey? cursorKey = null) { var full = EvaluateAll(registry, allRows, selection); - return Page(full, selection, asOf); + return Page(full, selection, asOf, cursorKey); } /// @@ -232,17 +233,25 @@ namespace GameCult.Networking /// v0/shard full walk calls this once per 200-row chunk of a single /// result instead of re-evaluating per page. /// - public static Evaluation Page(FullEvaluation full, CultNetSelection selection, ulong asOf) + /// + /// The answering process's cursor key (R-O). Defaults to a key generated once per process the + /// first time this method runs, so a caller that never sets up its own + /// still gets a keyed digest; holds and passes its own instance so a + /// fresh database (a simulated restart in tests, a real process restart in production) mints + /// under a fresh key and a prior cursor's digest stops verifying. + /// + public static Evaluation Page(FullEvaluation full, CultNetSelection selection, ulong asOf, CultNetSelectionCursorKey? cursorKey = null) { if (full == null) throw new ArgumentNullException(nameof(full)); if (selection == null) throw new ArgumentNullException(nameof(selection)); + var key = cursorKey ?? CultNetSelectionCursorKey.ProcessDefault; var ordered = full.Ordered; var startIndex = 0; if (!string.IsNullOrEmpty(selection.Cursor)) { var cursor = CultNetSelectionCursor.Parse(selection.Cursor!); - if (cursor.Digest != CultNetSelectionCursor.ComputeDigest(selection)) + if (!cursor.VerifyDigest(selection, key)) throw new CultNetSelectionCursorException("cursor_invalid", "The cursor's selection digest does not match this selection."); if (cursor.AsOf != asOf) throw new CultNetSelectionCursorException( @@ -257,7 +266,7 @@ namespace GameCult.Networking var page = ordered.Skip(startIndex).Take(limit).ToArray(); var hasNext = startIndex + page.Length < ordered.Count; var nextCursor = hasNext && page.Length > 0 - ? CultNetSelectionCursor.Mint(asOf, page[^1], selection) + ? CultNetSelectionCursor.Mint(asOf, page[^1], selection, key) : null; var pageEdges = selection.HasHop @@ -439,8 +448,49 @@ namespace GameCult.Networking } /// - /// The opaque cursor: asOf, the last (ordinal, schemaId, recordKey), and a digest of the selection - /// with cursor and limit cleared (section 2). Minted only by the answering server. + /// The answering process's key for cursor digests (R-O, F6: "cursors are keyed"). A digest is an + /// HMAC-SHA256 under this key, so a cursor cannot be forged without it and cannot be verified by + /// a different key. is never persisted or serialized; the answering server + /// holds one instance for its process lifetime (), so a + /// cursor minted before a restart carries a digest the new process's key does not produce, and + /// the restart's first page for it answers cursor_invalid - acceptable, because cursors are + /// short-lived and section 2 already calls them "minted by the answering server". + /// + public sealed class CultNetSelectionCursorKey + { + /// + /// The key / + /// fall back to when no caller-owned key is supplied - generated once, the first time this type + /// is touched in the process, so a caller that evaluates selections without wiring up its own + /// (most unit tests) still gets a keyed, per-process digest instead + /// of an unkeyed one. + /// + internal static readonly CultNetSelectionCursorKey ProcessDefault = Random(); + + private readonly byte[] _key; + + public CultNetSelectionCursorKey(byte[] key) + { + if (key == null) throw new ArgumentNullException(nameof(key)); + if (key.Length == 0) throw new ArgumentException("Value must be non-empty.", nameof(key)); + _key = key; + } + + /// A fresh 32-byte key from the OS RNG. + public static CultNetSelectionCursorKey Random() + { + var bytes = new byte[32]; + RandomNumberGenerator.Fill(bytes); + return new CultNetSelectionCursorKey(bytes); + } + + internal byte[] KeyBytes => _key; + } + + /// + /// The opaque cursor: asOf, the last (ordinal, schemaId, recordKey), and an HMAC digest of the + /// selection with cursor and limit cleared, keyed under the answering process's + /// (R-O). Minted only by the answering server. /// public readonly struct CultNetSelectionCursor { @@ -459,50 +509,67 @@ namespace GameCult.Networking public string RecordKey { get; } public string Digest { get; } - public static string Mint(ulong asOf, CultNetSelectionEvaluator.Row lastRow, CultNetSelection selection) + public static string Mint(ulong asOf, CultNetSelectionEvaluator.Row lastRow, CultNetSelection selection, CultNetSelectionCursorKey key) { - var digest = ComputeDigest(selection); - var raw = string.Join( - "", - asOf.ToString(CultureInfo.InvariantCulture), - lastRow.Ordinal.ToString(CultureInfo.InvariantCulture), - lastRow.Descriptor.SchemaId, - lastRow.Key.Value, - digest); - return Convert.ToBase64String(Encoding.UTF8.GetBytes(raw)); + var digest = ComputeDigest(selection, key); + // R-O: the cursor body is length-prefixed fields, not a delimiter-joined string - a record + // key carrying any character at all, including whatever delimiter an earlier scheme would + // have chosen (Soul found a key containing U+241F broke a delimiter split), round-trips. + var body = new StringBuilder(); + AppendString(body, asOf.ToString(CultureInfo.InvariantCulture)); + AppendString(body, lastRow.Ordinal.ToString(CultureInfo.InvariantCulture)); + AppendString(body, lastRow.Descriptor.SchemaId); + AppendString(body, lastRow.Key.Value); + AppendString(body, digest); + return Base64UrlEncode(Encoding.UTF8.GetBytes(body.ToString())); } public static CultNetSelectionCursor Parse(string cursor) { - string raw; + byte[] bytes; try { - raw = Encoding.UTF8.GetString(Convert.FromBase64String(cursor)); + bytes = Base64UrlDecode(cursor); } catch (FormatException) { throw new CultNetSelectionCursorException("cursor_invalid", "The cursor does not decode."); } - var parts = raw.Split(''); - if (parts.Length != 5 || - !ulong.TryParse(parts[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out var asOf) || - !long.TryParse(parts[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var ordinal)) + var fields = ReadLengthPrefixedFields(bytes, 5); + if (fields == null || + !ulong.TryParse(fields[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out var asOf) || + !long.TryParse(fields[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var ordinal)) { throw new CultNetSelectionCursorException("cursor_invalid", "The cursor does not decode."); } - return new CultNetSelectionCursor(asOf, ordinal, parts[2], parts[3], parts[4]); + return new CultNetSelectionCursor(asOf, ordinal, fields[2], fields[3], fields[4]); } /// - /// A digest of the selection with cursor and limit cleared, so a cursor is bound to the - /// selection that minted it. R-H: every string and list is length-prefixed, so no delimiter - /// choice can make two different selections collide - the prior delimiter-joined form digested + /// Verifies this cursor's digest against under + /// in fixed time (R-O): a cursor forged without the key, or minted under a different process's + /// key (a restart), does not verify. + /// + public bool VerifyDigest(CultNetSelection selection, CultNetSelectionCursorKey key) + { + var expected = ComputeDigest(selection, key); + return CryptographicOperations.FixedTimeEquals( + Encoding.UTF8.GetBytes(Digest), + Encoding.UTF8.GetBytes(expected)); + } + + /// + /// An HMAC-SHA256 digest, keyed under , of the selection with cursor and + /// limit cleared, so a cursor is bound both to the selection that minted it and to the process + /// that minted it (R-O). R-H: every string and list is length-prefixed, so no delimiter choice + /// can make two different selections collide - a delimiter-joined form used to digest /// values ["a|b"] the same as ["a","b"], and keys ["a,b"] the same as ["a","b"]. /// - public static string ComputeDigest(CultNetSelection selection) + public static string ComputeDigest(CultNetSelection selection, CultNetSelectionCursorKey? key = null) { + key ??= CultNetSelectionCursorKey.ProcessDefault; var sb = new StringBuilder(); AppendList(sb, selection.Schemas); AppendList(sb, selection.Keys); @@ -545,8 +612,8 @@ namespace GameCult.Networking AppendString(sb, selection.Projection); sb.Append(selection.Descending ? '1' : '0'); - using var sha = SHA256.Create(); - var bytes = sha.ComputeHash(Encoding.UTF8.GetBytes(sb.ToString())); + using var hmac = new HMACSHA256(key.KeyBytes); + var bytes = hmac.ComputeHash(Encoding.UTF8.GetBytes(sb.ToString())); var hex = new StringBuilder(bytes.Length * 2); foreach (var b in bytes) hex.Append(b.ToString("x2", CultureInfo.InvariantCulture)); return hex.ToString(); @@ -566,6 +633,49 @@ namespace GameCult.Networking foreach (var value in ordered) AppendString(sb, value); } + + private static string Base64UrlEncode(byte[] bytes) => + Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + + private static byte[] Base64UrlDecode(string value) + { + var padded = value.Replace('-', '+').Replace('_', '/'); + switch (padded.Length % 4) + { + case 2: padded += "=="; break; + case 3: padded += "="; break; + case 0: break; + default: throw new FormatException("Invalid base64url string length."); + } + + return Convert.FromBase64String(padded); + } + + /// + /// Reads exactly length-prefixed fields written by + /// back out of , in order. Null on any + /// malformed length, a truncated value, a non-UTF-8 value, or leftover bytes after the last + /// field - the cursor body has no field this reader may skip. + /// + private static List? ReadLengthPrefixedFields(byte[] bytes, int count) + { + var fields = new List(count); + var pos = 0; + for (var i = 0; i < count; i++) + { + var colon = Array.IndexOf(bytes, (byte)':', pos); + if (colon < 0) return null; + var lengthText = Encoding.UTF8.GetString(bytes, pos, colon - pos); + if (!int.TryParse(lengthText, NumberStyles.None, CultureInfo.InvariantCulture, out var length)) + return null; + pos = colon + 1; + if (length < 0 || pos + length > bytes.Length) return null; + fields.Add(Encoding.UTF8.GetString(bytes, pos, length)); + pos += length; + } + + return pos == bytes.Length ? fields : null; + } } /// diff --git a/tests/GameCult.Networking.Tests/CultNetSelectionEvaluatorTests.cs b/tests/GameCult.Networking.Tests/CultNetSelectionEvaluatorTests.cs index 9e96d0e3..b2dfcf3c 100644 --- a/tests/GameCult.Networking.Tests/CultNetSelectionEvaluatorTests.cs +++ b/tests/GameCult.Networking.Tests/CultNetSelectionEvaluatorTests.cs @@ -246,6 +246,11 @@ namespace GameCult.Networking.Tests Assert.That(decoded.Code, Is.EqualTo("reference_outside_target")); Assert.That(decoded.Details, Is.Null); + // ForReferenceOutsideTarget used to prefix "reference_outside_target: " a second time on + // top of the exception's own message, which already carries it - a peer received the code + // name twice. One prefix only, on the wire bytes a peer actually decodes. + Assert.That(decoded.Error, Does.StartWith("reference_outside_target: ")); + Assert.That(decoded.Error, Does.Not.Contain("reference_outside_target: reference_outside_target:")); } // S6: the hop follows one declared reference by role; a second role is not followed. @@ -441,6 +446,46 @@ namespace GameCult.Networking.Tests Assert.That(evaluation.Rows.Select(r => r.Key.Value).OrderBy(k => k, StringComparer.Ordinal), Is.EqualTo(new[] { "1", "2", "3" })); } + // S1-Loose (docs/cultnet-selection-cut.md, "Self's rulings for fix batch 3" -> "Surviving + // mutants"): ReachableSchemas narrows to the schemas selection.schemas actually names; a mutant + // that widens it back to every registered descriptor lets a predicate on an index only an + // EXCLUDED schema declares reach the door silently (matching nothing, never refusing), instead + // of the typed "not declared by any schema this selection can reach" refusal. "kind" is declared + // on SelLeafA/SelLeafB, not on SelCiter; Schemas here names only SelCiter. + [Test] + public void ValidationRefusesAnIndexDeclaredOnlyOnASchemaTheSelectionDoesNotReach() + { + var registry = Registry(); + var descriptors = registry.AllDescriptors.ToArray(); + var selection = new CultNetSelection + { + Schemas = new[] { registry.GetRequired().SchemaId }, + Fields = new[] { new CultNetFieldPredicate { Index = "kind", Op = "any_of", Values = new[] { "x" } } } + }; + + var ex = Assert.Throws(() => selection.Validate(descriptors)); + Assert.That(ex!.Field, Is.EqualTo("fields[0].index")); + } + + // S2-AllOf (fix batch 3 "Surviving mutants"): any_of matches when the row's declared value + // equals ANY one of the predicate's values, not when it equals every one of them - impossible + // for a single-valued index whenever Values carries more than one entry, so a mutant that + // requires all of them makes a real multi-value any_of predicate match nothing. + [Test] + public void EvaluatorAnyOfMatchesWhenTheRowsValueIsAnyOneOfSeveralPredicateValues() + { + var registry = Registry(); + var row = Row(registry, new SelLeafA { Name = "a", Kind = "weapon", Mass = 1 }, "a", 1); + var selection = new CultNetSelection + { + Fields = new[] { new CultNetFieldPredicate { Index = "kind", Op = "any_of", Values = new[] { "armor", "weapon" } } } + }; + + var evaluation = CultNetSelectionEvaluator.Select(registry, new[] { row }, selection, asOf: 1); + + Assert.That(evaluation.Rows.Select(r => r.Key.Value), Is.EqualTo(new[] { "a" })); + } + public abstract class SelFixtureMiddle { [Key(0)] diff --git a/tests/GameCult.Networking.Tests/CultNetSelectionFixBatchTests.cs b/tests/GameCult.Networking.Tests/CultNetSelectionFixBatchTests.cs index 20e1b015..04401aba 100644 --- a/tests/GameCult.Networking.Tests/CultNetSelectionFixBatchTests.cs +++ b/tests/GameCult.Networking.Tests/CultNetSelectionFixBatchTests.cs @@ -190,6 +190,95 @@ namespace GameCult.Networking.Tests Assert.That(CultNetSelectionCursor.ComputeDigest(valuesOne), Is.Not.EqualTo(CultNetSelectionCursor.ComputeDigest(valuesTwo))); } + // S5-DigestNoFields (fix batch 3 "Surviving mutants"): the digest reads selection.Fields - a + // mutant that drops the fields loop would digest two selections identically as long as their + // schemas/keys/hop/projection agree, letting a cursor minted for one selection page a + // differently-fielded one. + [Test] + public void ComputeDigest_DiffersWhenOnlyFieldsDiffer() + { + var withFieldA = new CultNetSelection { Fields = new[] { new CultNetFieldPredicate { Index = "kind", Op = "any_of", Values = new[] { "a" } } } }; + var withFieldB = new CultNetSelection { Fields = new[] { new CultNetFieldPredicate { Index = "kind", Op = "any_of", Values = new[] { "b" } } } }; + var noFields = new CultNetSelection(); + + Assert.That(CultNetSelectionCursor.ComputeDigest(withFieldA), Is.Not.EqualTo(CultNetSelectionCursor.ComputeDigest(withFieldB))); + Assert.That(CultNetSelectionCursor.ComputeDigest(withFieldA), Is.Not.EqualTo(CultNetSelectionCursor.ComputeDigest(noFields))); + } + + // R-O (fix batch 3, F6 "cursors are keyed"): a cursor's digest is an HMAC under the answering + // process's key - a page request carrying a cursor minted under a different key (forged, or + // minted by a different process) is refused cursor_invalid, never answered. + [Test] + public void Page_RefusesACursorMintedUnderADifferentKey() + { + var registry = Registry(); + var rows = Enumerable.Range(0, 3) + .Select(i => Row(registry, new CultNetSelectionEvaluatorTests.SelLeafA { Name = $"n{i}", Kind = "k", Mass = i }, $"k{i}", i)) + .ToArray(); + var selection = new CultNetSelection { Limit = 1 }; + var mintingKey = CultNetSelectionCursorKey.Random(); + var otherKey = CultNetSelectionCursorKey.Random(); + + var first = CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 1, cursorKey: mintingKey); + Assert.That(first.NextCursor, Is.Not.Null); + + selection.Cursor = first.NextCursor; + var ex = Assert.Throws( + () => CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 1, cursorKey: otherKey)); + Assert.That(ex!.Code, Is.EqualTo("cursor_invalid")); + } + + // R-O: a cursor minted before a (simulated) restart does not verify against the restarted + // process's fresh key - the same refusal as a forged cursor, because from the answering + // process's point of view they are indistinguishable (F6: "not surviving a restart is the + // accepted cost, not a bug"). + [Test] + public void Page_RefusesACursorMintedBeforeARestart() + { + var registry = Registry(); + var rows = Enumerable.Range(0, 3) + .Select(i => Row(registry, new CultNetSelectionEvaluatorTests.SelLeafA { Name = $"n{i}", Kind = "k", Mass = i }, $"k{i}", i)) + .ToArray(); + var selection = new CultNetSelection { Limit = 1 }; + var preRestartKey = CultNetSelectionCursorKey.Random(); + + var first = CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 1, cursorKey: preRestartKey); + Assert.That(first.NextCursor, Is.Not.Null); + + // A restart is a fresh process key, exactly as CultNetDatabase's constructor mints a fresh + // CursorKey per instance unless one is supplied. + var postRestartKey = CultNetSelectionCursorKey.Random(); + selection.Cursor = first.NextCursor; + var ex = Assert.Throws( + () => CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 1, cursorKey: postRestartKey)); + Assert.That(ex!.Code, Is.EqualTo("cursor_invalid")); + } + + // R-O: the cursor body is length-prefixed, not delimiter-joined, so a record key carrying any + // character at all - including U+241F, which broke the pre-fix delimiter-split parse - round-trips. + [Test] + public void Cursor_RoundTripsARecordKeyContainingU241F() + { + var registry = Registry(); + var weirdKey = "row-␟-two"; + var rows = new[] + { + Row(registry, new CultNetSelectionEvaluatorTests.SelLeafA { Name = "a", Kind = "k", Mass = 0 }, "a-first", 0), + Row(registry, new CultNetSelectionEvaluatorTests.SelLeafA { Name = "b", Kind = "k", Mass = 1 }, weirdKey, 1) + }; + var selection = new CultNetSelection { Limit = 1 }; + var key = CultNetSelectionCursorKey.Random(); + + var first = CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 1, cursorKey: key); + Assert.That(first.Rows.Select(r => r.Key.Value), Is.EqualTo(new[] { "a-first" })); + Assert.That(first.NextCursor, Is.Not.Null); + + selection.Cursor = first.NextCursor; + var second = CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 1, cursorKey: key); + Assert.That(second.Rows.Select(r => r.Key.Value), Is.EqualTo(new[] { weirdKey })); + Assert.That(second.NextCursor, Is.Null); + } + // R-J: any_of on a numeric alias compares TryGetIndexNumber's canonical rendering, not the // string getter's culture-dependent ToString() - a float large enough that .NET's default // ToString() renders exponent notation ("1E+21") must still match its canonical decimal form. diff --git a/tests/GameCult.Networking.Tests/NetworkingTests.cs b/tests/GameCult.Networking.Tests/NetworkingTests.cs index 55a7d28d..5ab0d13b 100644 --- a/tests/GameCult.Networking.Tests/NetworkingTests.cs +++ b/tests/GameCult.Networking.Tests/NetworkingTests.cs @@ -5493,6 +5493,148 @@ namespace GameCult.Networking.Tests Assert.That(replicated, Is.Not.Null); Assert.That(replicated!.Username, Is.EqualTo("Replica")); Assert.That(targetDatabase.GetMutationLog("players-replica"), Has.Count.EqualTo(1)); + // R-Q (docs/cultnet-selection-cut.md, fix batch 3): a replica apply is a commit path too - + // it must set LastWriteSequence exactly like a local commit does (CultNetDatabase.cs, the + // ApplyCommittedPutAsync/ApplyCommittedDelete gap), or the selection evaluator's order and + // cursor treat a replicated row as never written. + Assert.That(targetDatabase.LastWriteSequence(schemaId, key), Is.EqualTo(1)); + } + + [Test] + public async Task CultNetDatabase_ReplicaApply_DeleteSetsLastWriteSequence() + { + var sourceCache = new CultCache(); + var targetCache = new CultCache(); + var sourceRegistry = new CultNetDocumentRegistry(sourceCache.Registry) + .Register(CultNetDocumentBinding.ForDocument( + sourceCache.Registry, + payloadSerializer: SerializePlayerDataPayload, + payloadDeserializer: DeserializePlayerDataPayload)); + var targetRegistry = new CultNetDocumentRegistry(targetCache.Registry) + .Register(CultNetDocumentBinding.ForDocument( + targetCache.Registry, + payloadSerializer: SerializePlayerDataPayload, + payloadDeserializer: DeserializePlayerDataPayload)); + var schemaId = sourceCache.Registry.GetRequired().SchemaId; + CultNetShardDescriptor Shard(bool isPrimary) => new( + "players-replica-delete", + "runtime-a", + epoch: 1, + isPrimary: isPrimary, + schemaIds: [schemaId], + keyPrefix: "player:"); + var sourceDatabase = new CultNetDatabase(sourceCache, new CultNetDatabaseOptions + { + DocumentRegistry = sourceRegistry, + Shards = [Shard(true)] + }); + var targetDatabase = new CultNetDatabase(targetCache, new CultNetDatabaseOptions + { + DocumentRegistry = targetRegistry, + Shards = [Shard(false)] + }); + using var server = new Server(sourceCache, DevelopmentServerSecurity); + using var databaseServer = new CultNetDatabaseServer(server, sourceDatabase); + var key = new CultRecordKey("player:replica-delete"); + var player = new PlayerData { PlayerId = Guid.NewGuid(), Email = "d@example.test", PasswordHash = "hash", Username = "D" }; + + await sourceDatabase.PutAsync(key, player); + await sourceDatabase.DeleteAsync(key); + var response = databaseServer.CreateShardLogResponse(new CultNetShardLogRequestMessage + { + ShardId = "players-replica-delete", + ShardEpoch = 1 + }); + + await targetDatabase.ApplyShardLogResponseAsync(response); + + Assert.That(targetCache.Get(key), Is.Null); + Assert.That(targetDatabase.LastWriteSequence(schemaId, key), Is.EqualTo(2)); + } + + [Test] + public async Task CultNetDatabase_RebuildsLastWriteSequence_FromTheDurableLogAfterRestart() + { + var rootPath = Path.Combine(TestContext.CurrentContext.WorkDirectory, "shard-logs", Guid.NewGuid().ToString("N")); + var schemaId = new CultCache().Registry.GetRequired().SchemaId; + var shard = new CultNetShardDescriptor( + "players-durable-sequence", + "runtime-a", + epoch: 1, + isPrimary: true, + schemaIds: [schemaId], + keyPrefix: "player:"); + + var sourceCache = new CultCache(); + var sourceRegistry = new CultNetDocumentRegistry(sourceCache.Registry) + .Register(CultNetDocumentBinding.ForDocument( + sourceCache.Registry, + payloadSerializer: SerializePlayerDataPayload, + payloadDeserializer: DeserializePlayerDataPayload)); + var sourceDatabase = new CultNetDatabase(sourceCache, new CultNetDatabaseOptions + { + DocumentRegistry = sourceRegistry, + MutationLogStore = new CultNetFileShardMutationLogStore(rootPath), + Shards = [shard] + }); + var key = new CultRecordKey("player:durable-sequence"); + var player = new PlayerData { PlayerId = Guid.NewGuid(), Email = "r@example.test", PasswordHash = "hash", Username = "R" }; + await sourceDatabase.PutAsync(key, player); + Assert.That(sourceDatabase.LastWriteSequence(schemaId, key), Is.EqualTo(1)); + sourceDatabase.Dispose(); + + // R-Q: a restart is a fresh CultNetDatabase instance over the same durable log - the + // sequence InitializeLogSequencesFromStore used to rebuild only _nextLogSequences + // (what the next local commit gets), never _lastWriteSequence (what a row's own last + // write was), so a selection evaluated right after restart would order/page the row as + // "never written" until it was written again. + var restartedCache = new CultCache(); + var restartedDatabase = new CultNetDatabase(restartedCache, new CultNetDatabaseOptions + { + MutationLogStore = new CultNetFileShardMutationLogStore(rootPath), + Shards = [shard] + }); + + Assert.That(restartedDatabase.LastWriteSequence(schemaId, key), Is.EqualTo(1)); + } + + [Test] + public async Task CultNetDatabaseServer_RefusesASelectionSpanningMoreThanOneShardsLog() + { + var cache = new CultCache(); + var registry = new CultNetDocumentRegistry(cache.Registry) + .Register(CultNetDocumentBinding.ForDocument( + cache.Registry, + payloadSerializer: SerializePlayerDataPayload, + payloadDeserializer: DeserializePlayerDataPayload)); + var schemaId = cache.Registry.GetRequired().SchemaId; + var database = new CultNetDatabase(cache, new CultNetDatabaseOptions + { + DocumentRegistry = registry, + Shards = + [ + new CultNetShardDescriptor("shard-a", "runtime-a", epoch: 1, isPrimary: true, schemaIds: [schemaId], keyPrefix: "a:"), + new CultNetShardDescriptor("shard-b", "runtime-a", epoch: 1, isPrimary: true, schemaIds: [schemaId], keyPrefix: "b:") + ] + }); + using var server = new Server(cache, DevelopmentServerSecurity); + using var databaseServer = new CultNetDatabaseServer(server, database); + + await database.PutAsync( + new CultRecordKey("a:one"), + new PlayerData { PlayerId = Guid.NewGuid(), Email = "a@example.test", PasswordHash = "hash", Username = "A" }); + await database.PutAsync( + new CultRecordKey("b:one"), + new PlayerData { PlayerId = Guid.NewGuid(), Email = "b@example.test", PasswordHash = "hash", Username = "B" }); + + // R-Q: asOf is one shard-log watermark - a selection whose matched rows span shard-a's log + // and shard-b's log cannot honestly claim to be exact as of it, so it is refused rather than + // answered, reusing the selection_invalid door refusal (R-N) rather than a second shape. + var ex = Assert.Throws(() => databaseServer.CreateSelectionResponse(new CultNetSnapshotRequestV1Message + { + Selection = new CultNetSelection { Schemas = [schemaId] } + })); + Assert.That(ex!.Field, Is.EqualTo("asOf")); } [Test]