From 99e366a36d2433e436142ef05dfd1d872244538f Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 22 Sep 2026 21:48:10 +0200 Subject: [PATCH] R-AA follow-up: kill ComputeDigest's flag-swap survivors, pin EdgesFor's primary key The first Stryker pass (72.18%, up from 66%) showed the named dozen mostly dead but surfaced two related gaps the "differs" style of assertion cannot reach on its own: - ComputeDigest's field-number and cites.role presence flags each had an equality-mutation survivor (field.Number != null / selection.Cites.Role != null) that a same-vs-different digest comparison cannot catch: the mutant swaps which of two cases gets which flag value, so both cases still digest differently, just under swapped labels. A handful of plain AppendString/sb.Append deletions survived alongside it for the same reason - removing content that a same-vs-different pair didn't happen to isolate. One independently reconstructed golden HMAC value, built from the method's own doc comments rather than its private helpers, pins the whole wire shape at once: any single append or flag polarity wrong breaks the exact match. - EdgesFor's OrderBy(pageRowIndex) - the primary "page-row order" key R-B names before "(from, role, to)" - had no test forcing page position and from/role/to order to disagree, so it survived alongside (and separate from) the five ThenBy mutants already named. Co-Authored-By: Claude Sonnet 5 --- .../CultNetSelectionSurvivorTests.cs | 147 ++++++++++++++++++ 1 file changed, 147 insertions(+) diff --git a/tests/GameCult.Networking.Tests/CultNetSelectionSurvivorTests.cs b/tests/GameCult.Networking.Tests/CultNetSelectionSurvivorTests.cs index cf2ce298..1c5daae2 100644 --- a/tests/GameCult.Networking.Tests/CultNetSelectionSurvivorTests.cs +++ b/tests/GameCult.Networking.Tests/CultNetSelectionSurvivorTests.cs @@ -307,6 +307,50 @@ namespace GameCult.Networking.Tests Assert.That(evaluation.Edges.Select(e => e.Role), Is.EqualTo(new[] { "aFirstRole", "zLastRole" })); } + // A sixth EdgesFor mutant Stryker reported alongside the five R-B names: OrderBy(pageRowIndex) + // itself (the primary key, page-row position) mutated to OrderByDescending. Two page rows, each + // the anchor for exactly one edge from a differently-keyed citer, where the citers' own + // (from, role, to) order is the *opposite* of the pages' row order - so only the primary + // page-position key, not the secondary from/role/to tiebreak, can produce the right answer. + [Test] + public void EdgesFor_OrdersByPageRowPositionBeforeFromRoleTo() + { + var registry = CultDocumentRegistry.ForTypes(new[] { typeof(SurvivorAliasTarget), typeof(SurvivorPageOrderCiter) }); + var targetZ = new SurvivorAliasTarget { Name = "z" }; + var targetA = new SurvivorAliasTarget { Name = "a" }; + var citerForZ = new SurvivorPageOrderCiter { Name = "cz", Link = new CultRecordRef(new CultRecordKey("z-target")) }; + // "citer-a" sorts before "citer-z" in code-point order - the opposite of the page order below + // (targetZ's ordinal puts it first), so a from/role/to-only sort would put this edge first. + var citerForA = new SurvivorPageOrderCiter { Name = "ca", Link = new CultRecordRef(new CultRecordKey("a-target")) }; + var rows = new[] + { + Row(registry, targetZ, "z-target", 1), + Row(registry, targetA, "a-target", 2), + Row(registry, citerForZ, "citer-z", 3), + Row(registry, citerForA, "citer-a", 4) + }; + + var selection = new CultNetSelection { Cited = new CultNetIncoming { Role = "hopRole", Exists = true } }; + var evaluation = CultNetSelectionEvaluator.Select(registry, rows, selection, asOf: 1); + + // Page order is by ordinal: z-target (1) then a-target (2). + Assert.That(evaluation.Rows.Select(r => r.Key.Value), Is.EqualTo(new[] { "z-target", "a-target" })); + // Edges must follow that same page order, not "citer-a" < "citer-z". + Assert.That(evaluation.Edges.Select(e => e.From.Key.Value), Is.EqualTo(new[] { "citer-z", "citer-a" })); + } + + [CultDocument("cultnet.selection-tests.survivor_page_order_citer", "cultnet.selection-tests.survivor_page_order_citer.v1")] + [MessagePackObject(AllowPrivate = true)] + public sealed class SurvivorPageOrderCiter + { + [Key(0)] [CultName] public string Name = string.Empty; + + [Key(1)] + [CultIndex("hopRole")] + [CultReference(typeof(SurvivorAliasTarget))] + public CultRecordRef Link; + } + [CultDocument("cultnet.selection-tests.survivor_alt_citer", "cultnet.selection-tests.survivor_alt_citer.v1")] [MessagePackObject(AllowPrivate = true)] public sealed class SurvivorAltCiter @@ -399,6 +443,109 @@ namespace GameCult.Networking.Tests Is.Not.EqualTo(CultNetSelectionCursor.ComputeDigest(0, 0, "s", "k", emptyNumber))); } + // Every "differs" assertion above is symmetric to a mutant that swaps which of two cases gets + // which flag value (e.g. field.Number == null ? '0' : '1' mutated to != null) - both cases still + // produce two different digests, just with the labels swapped, so inequality alone cannot catch + // it. Nor can it catch deleting one AppendString/sb.Append call whose content happens not to be + // the only thing distinguishing a particular pair. An independently reconstructed golden value - + // built from ComputeDigest's own doc comments, not by calling its private helpers - pins the + // exact byte-for-byte wire shape: every append call and every flag's polarity must be exactly + // right for this to still match, so deleting or mislabelling any of them fails it. + [Test] + public void ComputeDigest_MatchesAnIndependentlyReconstructedGoldenValue() + { + var keyBytes = Enumerable.Range(0, 32).Select(i => (byte)i).ToArray(); + var key = new CultNetSelectionCursorKey(keyBytes); + var selection = new CultNetSelection + { + Schemas = new[] { "schema-b", "schema-a" }, + Keys = new[] { "key-a" }, + Fields = new[] + { + new CultNetFieldPredicate { Index = "idx-a", Op = "any_of", Values = new[] { "v2", "v1" } }, + new CultNetFieldPredicate { Index = "idx-b", Op = "gt", Number = "5" } + }, + Cites = new CultNetCitation + { + Target = new CultNetRecordRef { SchemaId = "cite-schema", RecordKey = "cite-key" }, + Role = "cite-role" + }, + Cited = new CultNetIncoming { Role = "cited-role", Exists = true }, + Projection = CultNetSelectionProjections.Document, + Descending = true + }; + + var expected = IndependentDigest(asOf: 42, ordinal: 7, schemaId: "row-schema", recordKey: "row-key", selection, keyBytes); + var actual = CultNetSelectionCursor.ComputeDigest(42, 7, "row-schema", "row-key", selection, key); + + Assert.That(actual, Is.EqualTo(expected)); + } + + // Independent re-implementation of ComputeDigest's wire shape (R-H/R-Y), written from the + // method's doc comments rather than by delegating to it or its private helpers. + private static string IndependentDigest(ulong asOf, long ordinal, string schemaId, string recordKey, CultNetSelection selection, byte[] keyBytes) + { + var sb = new System.Text.StringBuilder(); + void AppendString(string value) => sb.Append(System.Text.Encoding.UTF8.GetByteCount(value)).Append(':').Append(value); + void AppendList(System.Collections.Generic.IReadOnlyList? values) + { + var ordered = (values ?? Array.Empty()).OrderBy(v => v, CultNetCodePointComparer.Instance).ToArray(); + sb.Append(ordered.Length).Append(':'); + foreach (var v in ordered) AppendString(v); + } + + AppendString(asOf.ToString(System.Globalization.CultureInfo.InvariantCulture)); + AppendString(ordinal.ToString(System.Globalization.CultureInfo.InvariantCulture)); + AppendString(schemaId); + AppendString(recordKey); + AppendList(selection.Schemas); + AppendList(selection.Keys); + + var fields = selection.Fields ?? Array.Empty(); + sb.Append(fields.Length).Append(':'); + foreach (var field in fields) + { + AppendString(field.Index); + AppendString(field.Op); + AppendList(field.Values); + AppendString(field.Number ?? string.Empty); + sb.Append(field.Number == null ? '0' : '1'); + } + + if (selection.Cites != null) + { + sb.Append('1'); + AppendString(selection.Cites.Target.SchemaId); + AppendString(selection.Cites.Target.RecordKey); + AppendString(selection.Cites.Role ?? string.Empty); + sb.Append(selection.Cites.Role == null ? '0' : '1'); + } + else + { + sb.Append('0'); + } + + if (selection.Cited != null) + { + sb.Append('1'); + AppendString(selection.Cited.Role); + sb.Append(selection.Cited.Exists ? '1' : '0'); + } + else + { + sb.Append('0'); + } + + AppendString(selection.Projection); + sb.Append(selection.Descending ? '1' : '0'); + + using var hmac = new System.Security.Cryptography.HMACSHA256(keyBytes); + var bytes = hmac.ComputeHash(System.Text.Encoding.UTF8.GetBytes(sb.ToString())); + var hex = new System.Text.StringBuilder(bytes.Length * 2); + foreach (var b in bytes) hex.Append(b.ToString("x2", System.Globalization.CultureInfo.InvariantCulture)); + return hex.ToString(); + } + // --------------------------------------------------------------------------------------------- // Survivor 7: CultNetSelectionCursor.Parse / ReadLengthPrefixedFields had no coverage at all on // attacker-supplied base64. A length prefix claiming more bytes than the payload actually holds,