diff --git a/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs b/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs index 1cafd052..21278fa8 100644 --- a/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs +++ b/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs @@ -40,7 +40,8 @@ public sealed class CultDocumentResolver : IFormatterResolver public static class CultDocumentMessagePackSerialization { private const int PersistedRecordFieldCount = 4; - private const int SchemaCatalogEntryFieldCount = 6; + private const int SchemaCatalogEntryFieldCount = 7; + private const int SchemaCatalogMemberFieldCount = 8; private const int StoreSnapshotFieldCount = 3; /// @@ -277,6 +278,12 @@ public static class CultDocumentMessagePackSerialization { writer.Write(schemaId); } + + writer.WriteArrayHeader(entry.Members.Length); + foreach (var member in entry.Members) + { + WriteSchemaCatalogMember(ref writer, member); + } } private static CultSchemaCatalogEntry ReadSchemaCatalogEntry(ref MessagePackReader reader) @@ -319,6 +326,16 @@ public static class CultDocumentMessagePackSerialization } } + if (fieldCount > 6) + { + var memberCount = reader.ReadArrayHeader(); + entry.Members = new CultSchemaMemberCatalogEntry[memberCount]; + for (var index = 0; index < memberCount; index++) + { + entry.Members[index] = ReadSchemaCatalogMember(ref reader); + } + } + for (var index = SchemaCatalogEntryFieldCount; index < fieldCount; index++) { reader.Skip(); @@ -326,6 +343,72 @@ public static class CultDocumentMessagePackSerialization return entry; } + + private static void WriteSchemaCatalogMember(ref MessagePackWriter writer, CultSchemaMemberCatalogEntry member) + { + writer.WriteArrayHeader(SchemaCatalogMemberFieldCount); + writer.Write(member.Slot); + writer.Write(member.MemberName); + writer.Write(member.TypeName); + writer.Write(member.IsReference); + writer.Write(member.IsMany); + writer.Write(member.TargetSchemaName); + writer.Write(member.IsName); + writer.Write(member.IndexAlias); + } + + private static CultSchemaMemberCatalogEntry ReadSchemaCatalogMember(ref MessagePackReader reader) + { + var fieldCount = reader.ReadArrayHeader(); + var member = new CultSchemaMemberCatalogEntry(); + + if (fieldCount > 0) + { + member.Slot = reader.ReadInt32(); + } + + if (fieldCount > 1) + { + member.MemberName = reader.ReadString() ?? string.Empty; + } + + if (fieldCount > 2) + { + member.TypeName = reader.ReadString() ?? string.Empty; + } + + if (fieldCount > 3) + { + member.IsReference = reader.ReadBoolean(); + } + + if (fieldCount > 4) + { + member.IsMany = reader.ReadBoolean(); + } + + if (fieldCount > 5) + { + member.TargetSchemaName = reader.ReadString(); + } + + if (fieldCount > 6) + { + member.IsName = reader.ReadBoolean(); + } + + if (fieldCount > 7) + { + member.IndexAlias = reader.ReadString(); + } + + for (var index = SchemaCatalogMemberFieldCount; index < fieldCount; index++) + { + reader.Skip(); + } + + return member; + } } /// diff --git a/src/GameCult.Caching/Contracts/cultcache-schema-compatibility.md b/src/GameCult.Caching/Contracts/cultcache-schema-compatibility.md new file mode 100644 index 00000000..26e9e205 --- /dev/null +++ b/src/GameCult.Caching/Contracts/cultcache-schema-compatibility.md @@ -0,0 +1,60 @@ +# CultCache Schema Compatibility + +CultCache compatibility is slot-driven and explicit. The cache will not pretend +that "same-ish looking" schemas are fine just because the names feel friendly. + +## Semantic identity + +Two CultCache document schemas are considered the same shape when they agree on: + +- `schemaName` +- `schemaVersion` +- member slots +- member persisted type names +- reference/cardinality semantics +- target schema names for references +- name/index lookup participation + +The canonical fixtures currently exercised in C# are: + +- `tests.named_entry` + - schema id: `sha256:e7b97801b94190f3159012ede45b0069bb09ebf7920f7432c971bc86a0e08de8` + - content hash: `sha256:23150930afcc1d84f0cb3012ccc2debcb9b4685f62083033bbaab0083f1e832e` +- `tests.reference_holder` + - schema id: `sha256:bd85064961cc74565fb73e3ccbc4217cfba4dc4869e365a08bea4f704739bd8f` + +See `GameCult.Caching.Tests/BackingStoreTests.cs` for the canonical fixture +documents and the expected receipts. + +## Soft-migratable drift + +CultCache accepts compatible drift only when the local reader can still map the +persisted slots honestly: + +- persisted schema id differs, but the embedded catalog points to a compatible + local schema id +- persisted slot is missing locally and can be ignored +- local slot is missing in persisted data and can fall back to the local + default value + +The cache emits a typed migration report with: + +- exact vs compatible-drift classification +- ignored extra slots +- defaulted missing slots +- warning codes and messages + +## Hard rejection + +CultCache rejects persisted schemas when any slot changes in a way that would +lie about the payload: + +- type change +- reference vs value change +- one vs many change +- target schema change +- name/index lookup semantics change +- missing embedded catalog entry +- no compatible local schema candidate + +This is not negotiable. Better a loud refusal than quiet bit-rot. diff --git a/src/GameCult.Caching/CultCache.cs b/src/GameCult.Caching/CultCache.cs index 31c155af..a2e33c7f 100644 --- a/src/GameCult.Caching/CultCache.cs +++ b/src/GameCult.Caching/CultCache.cs @@ -42,6 +42,57 @@ namespace GameCult.Caching /// Gets or sets schema identifiers compatible with this entry. /// public string[] CompatibleSchemaIds { get; set; } = Array.Empty(); + + /// + /// Gets or sets the persisted member descriptors used for compatibility checks. + /// + public CultSchemaMemberCatalogEntry[] Members { get; set; } = Array.Empty(); + } + + /// + /// Persisted catalog entry describing one schema member. + /// + public sealed class CultSchemaMemberCatalogEntry + { + /// + /// Gets or sets the persisted slot number. + /// + public int Slot { get; set; } + + /// + /// Gets or sets the member name. + /// + public string MemberName { get; set; } = string.Empty; + + /// + /// Gets or sets the persisted type name. + /// + public string TypeName { get; set; } = string.Empty; + + /// + /// Gets or sets whether the member is a reference. + /// + public bool IsReference { get; set; } + + /// + /// Gets or sets whether the member stores many values. + /// + public bool IsMany { get; set; } + + /// + /// Gets or sets the referenced schema name, when the member is a reference. + /// + public string? TargetSchemaName { get; set; } + + /// + /// Gets or sets whether the member provides the name lookup. + /// + public bool IsName { get; set; } + + /// + /// Gets or sets the index alias, when the member participates in lookups. + /// + public string? IndexAlias { get; set; } } /// @@ -86,6 +137,97 @@ namespace GameCult.Caching public CultPersistedRecord[] Records { get; set; } = Array.Empty(); } + /// + /// Classifies how a persisted schema mapped onto the local document registry. + /// + public enum CultSchemaMigrationKind + { + /// + /// The persisted schema matched the local schema exactly. + /// + Exact, + + /// + /// The persisted schema required a compatible soft-migration path. + /// + CompatibleDrift + } + + /// + /// Describes one schema-resolution warning emitted during load. + /// + public sealed class CultSchemaMigrationWarning + { + /// + /// Gets or sets the stable warning code. + /// + public string Code { get; set; } = string.Empty; + + /// + /// Gets or sets the human-readable warning text. + /// + public string Message { get; set; } = string.Empty; + } + + /// + /// Reports how one persisted schema entry resolved against the local schema catalog. + /// + public sealed class CultSchemaMigrationReport + { + /// + /// Gets or sets the persisted schema identifier. + /// + public string PersistedSchemaId { get; set; } = string.Empty; + + /// + /// Gets or sets the local schema identifier selected for reading. + /// + public string LocalSchemaId { get; set; } = string.Empty; + + /// + /// Gets or sets the persisted schema name. + /// + public string PersistedSchemaName { get; set; } = string.Empty; + + /// + /// Gets or sets the local schema name. + /// + public string LocalSchemaName { get; set; } = string.Empty; + + /// + /// Gets or sets how the persisted schema matched locally. + /// + public CultSchemaMigrationKind Kind { get; set; } + + /// + /// Gets or sets the persisted member slots ignored by the local reader. + /// + public int[] IgnoredExtraSlots { get; set; } = Array.Empty(); + + /// + /// Gets or sets the local member slots defaulted because the persisted record did not contain them. + /// + public int[] DefaultedMissingSlots { get; set; } = Array.Empty(); + + /// + /// Gets or sets emitted migration warnings. + /// + public CultSchemaMigrationWarning[] Warnings { get; set; } = Array.Empty(); + } + + internal sealed class CultSchemaResolutionResult + { + public CultSchemaResolutionResult(CultDocumentDescriptor descriptor, CultSchemaMigrationReport report) + { + Descriptor = descriptor; + Report = report; + } + + public CultDocumentDescriptor Descriptor { get; } + + public CultSchemaMigrationReport Report { get; } + } + /// /// Runtime descriptor for a CultCache document type. /// @@ -177,7 +319,21 @@ namespace GameCult.Caching SchemaVersion = SchemaVersion, ContentHash = ContentHash, CanonicalSchemaJson = CanonicalSchemaJson, - CompatibleSchemaIds = [SchemaId] + CompatibleSchemaIds = [SchemaId], + Members = Members + .OrderBy(member => member.Slot) + .Select(member => new CultSchemaMemberCatalogEntry + { + Slot = member.Slot, + MemberName = member.MemberName, + TypeName = member.TypeName, + IsReference = member.IsReference, + IsMany = member.IsMany, + TargetSchemaName = member.TargetSchemaName, + IsName = member.IsName, + IndexAlias = member.IndexAlias + }) + .ToArray() }; } @@ -349,10 +505,32 @@ namespace GameCult.Caching /// Resolves a persisted schema identifier against the local registry and embedded catalog. /// public CultDocumentDescriptor ResolvePersistedSchema(string schemaId, IReadOnlyCollection catalog) + { + return ResolvePersistedSchemaDetailed(schemaId, catalog).Descriptor; + } + + /// + /// Resolves a persisted schema identifier and returns the selected descriptor with migration diagnostics. + /// + public CultSchemaMigrationReport ResolvePersistedSchemaReport(string schemaId, IReadOnlyCollection catalog) + { + return ResolvePersistedSchemaDetailed(schemaId, catalog).Report; + } + + internal CultSchemaResolutionResult ResolvePersistedSchemaDetailed(string schemaId, IReadOnlyCollection catalog) { if (_bySchemaId.TryGetValue(schemaId, out var exact)) { - return exact; + return new CultSchemaResolutionResult( + exact, + new CultSchemaMigrationReport + { + PersistedSchemaId = schemaId, + LocalSchemaId = exact.SchemaId, + PersistedSchemaName = exact.SchemaName, + LocalSchemaName = exact.SchemaName, + Kind = CultSchemaMigrationKind.Exact + }); } var persisted = catalog.FirstOrDefault(entry => string.Equals(entry.SchemaId, schemaId, StringComparison.Ordinal)); @@ -361,9 +539,17 @@ namespace GameCult.Caching throw new InvalidOperationException($"Persisted schema '{schemaId}' is not present in the embedded catalog."); } + foreach (var compatibleSchemaId in persisted.CompatibleSchemaIds.Where(candidate => !string.IsNullOrWhiteSpace(candidate))) + { + if (_bySchemaId.TryGetValue(compatibleSchemaId, out var compatibleLocal)) + { + return BuildCompatibleResolutionResult(persisted, compatibleLocal); + } + } + if (_bySchemaName.TryGetValue(persisted.SchemaName, out var local)) { - return local; + return BuildCompatibleResolutionResult(persisted, local); } throw new InvalidOperationException( @@ -556,6 +742,255 @@ namespace GameCult.Caching return builder.ToString(); } + private static CultSchemaResolutionResult BuildCompatibleResolutionResult( + CultSchemaCatalogEntry persisted, + CultDocumentDescriptor local) + { + if (string.Equals(persisted.ContentHash, local.ContentHash, StringComparison.Ordinal)) + { + return new CultSchemaResolutionResult( + local, + new CultSchemaMigrationReport + { + PersistedSchemaId = persisted.SchemaId, + LocalSchemaId = local.SchemaId, + PersistedSchemaName = persisted.SchemaName, + LocalSchemaName = local.SchemaName, + Kind = CultSchemaMigrationKind.Exact + }); + } + + var persistedSchema = new CanonicalSchemaShape( + persisted.SchemaName, + persisted.SchemaVersion, + persisted.Members.Select(member => new CanonicalSchemaMember( + member.Slot, + member.MemberName, + member.TypeName, + member.IsReference, + member.IsMany, + member.TargetSchemaName, + member.IsName, + member.IndexAlias)).ToArray()); + var localSchema = new CanonicalSchemaShape( + local.SchemaName, + local.SchemaVersion, + local.Members.Select(member => new CanonicalSchemaMember( + member.Slot, + member.MemberName, + member.TypeName, + member.IsReference, + member.IsMany, + member.TargetSchemaName, + member.IsName, + member.IndexAlias)).ToArray()); + var comparison = CompareSchemaShapes(persistedSchema, localSchema); + if (!comparison.IsCompatible) + { + throw new InvalidOperationException( + $"Persisted schema '{persisted.SchemaName}' ({persisted.SchemaId}) is incompatible with local schema '{local.SchemaName}' ({local.SchemaId}): {string.Join("; ", comparison.Errors)}"); + } + + var warnings = new List(); + if (!string.Equals(persisted.SchemaId, local.SchemaId, StringComparison.Ordinal)) + { + warnings.Add(new CultSchemaMigrationWarning + { + Code = "compatible_schema_id_drift", + Message = $"Persisted schema '{persisted.SchemaId}' resolved to local schema '{local.SchemaId}'." + }); + } + + if (!string.Equals(persisted.ContentHash, local.ContentHash, StringComparison.Ordinal)) + { + warnings.Add(new CultSchemaMigrationWarning + { + Code = "content_hash_drift", + Message = $"Persisted schema content hash '{persisted.ContentHash}' differs from local hash '{local.ContentHash}'." + }); + } + + warnings.AddRange(comparison.Warnings); + + return new CultSchemaResolutionResult( + local, + new CultSchemaMigrationReport + { + PersistedSchemaId = persisted.SchemaId, + LocalSchemaId = local.SchemaId, + PersistedSchemaName = persisted.SchemaName, + LocalSchemaName = local.SchemaName, + Kind = CultSchemaMigrationKind.CompatibleDrift, + IgnoredExtraSlots = comparison.IgnoredExtraSlots.OrderBy(slot => slot).ToArray(), + DefaultedMissingSlots = comparison.DefaultedMissingSlots.OrderBy(slot => slot).ToArray(), + Warnings = warnings.ToArray() + }); + } + + private static SchemaShapeComparison CompareSchemaShapes(CanonicalSchemaShape persisted, CanonicalSchemaShape local) + { + var errors = new List(); + var warnings = new List(); + var ignoredExtraSlots = new List(); + var defaultedMissingSlots = new List(); + + if (!string.Equals(persisted.SchemaName, local.SchemaName, StringComparison.Ordinal)) + { + errors.Add($"schema name drift '{persisted.SchemaName}' -> '{local.SchemaName}'"); + } + + var persistedBySlot = persisted.Members.ToDictionary(member => member.Slot); + var localBySlot = local.Members.ToDictionary(member => member.Slot); + + foreach (var localMember in local.Members.OrderBy(member => member.Slot)) + { + if (!persistedBySlot.TryGetValue(localMember.Slot, out var persistedMember)) + { + defaultedMissingSlots.Add(localMember.Slot); + warnings.Add(new CultSchemaMigrationWarning + { + Code = "defaulted_missing_slot", + Message = $"Local slot {localMember.Slot} ('{localMember.Name}') is missing from the persisted schema and will use the local default value." + }); + continue; + } + + if (!string.Equals(localMember.TypeName, persistedMember.TypeName, StringComparison.Ordinal)) + { + errors.Add($"slot {localMember.Slot} changed type '{persistedMember.TypeName}' -> '{localMember.TypeName}'"); + } + + if (localMember.IsReference != persistedMember.IsReference) + { + errors.Add($"slot {localMember.Slot} changed reference semantics"); + } + + if (localMember.IsMany != persistedMember.IsMany) + { + errors.Add($"slot {localMember.Slot} changed cardinality"); + } + + if (!string.Equals(localMember.TargetSchemaName, persistedMember.TargetSchemaName, StringComparison.Ordinal)) + { + errors.Add($"slot {localMember.Slot} changed target schema '{persistedMember.TargetSchemaName ?? ""}' -> '{localMember.TargetSchemaName ?? ""}'"); + } + + if (localMember.IsName != persistedMember.IsName) + { + errors.Add($"slot {localMember.Slot} changed name-lookup semantics"); + } + + if (!string.Equals(localMember.IndexAlias, persistedMember.IndexAlias, StringComparison.Ordinal)) + { + errors.Add($"slot {localMember.Slot} changed index alias '{persistedMember.IndexAlias ?? ""}' -> '{localMember.IndexAlias ?? ""}'"); + } + } + + foreach (var persistedMember in persisted.Members.OrderBy(member => member.Slot)) + { + if (localBySlot.ContainsKey(persistedMember.Slot)) + { + continue; + } + + ignoredExtraSlots.Add(persistedMember.Slot); + warnings.Add(new CultSchemaMigrationWarning + { + Code = "ignored_extra_slot", + Message = $"Persisted slot {persistedMember.Slot} ('{persistedMember.Name}') is not present locally and will be ignored." + }); + } + + return new SchemaShapeComparison( + errors.Count == 0, + errors.ToArray(), + warnings.ToArray(), + ignoredExtraSlots.ToArray(), + defaultedMissingSlots.ToArray()); + } + + private sealed class CanonicalSchemaShape + { + public CanonicalSchemaShape(string schemaName, string schemaVersion, CanonicalSchemaMember[] members) + { + SchemaName = schemaName; + SchemaVersion = schemaVersion; + Members = members; + } + + public string SchemaName { get; } + + public string SchemaVersion { get; } + + public CanonicalSchemaMember[] Members { get; } + } + + private sealed class CanonicalSchemaMember + { + public CanonicalSchemaMember( + int slot, + string name, + string typeName, + bool isReference, + bool isMany, + string? targetSchemaName, + bool isName, + string? indexAlias) + { + Slot = slot; + Name = name; + TypeName = typeName; + IsReference = isReference; + IsMany = isMany; + TargetSchemaName = targetSchemaName; + IsName = isName; + IndexAlias = indexAlias; + } + + public int Slot { get; } + + public string Name { get; } + + public string TypeName { get; } + + public bool IsReference { get; } + + public bool IsMany { get; } + + public string? TargetSchemaName { get; } + + public bool IsName { get; } + + public string? IndexAlias { get; } + } + + private sealed class SchemaShapeComparison + { + public SchemaShapeComparison( + bool isCompatible, + string[] errors, + CultSchemaMigrationWarning[] warnings, + int[] ignoredExtraSlots, + int[] defaultedMissingSlots) + { + IsCompatible = isCompatible; + Errors = errors; + Warnings = warnings; + IgnoredExtraSlots = ignoredExtraSlots; + DefaultedMissingSlots = defaultedMissingSlots; + } + + public bool IsCompatible { get; } + + public string[] Errors { get; } + + public CultSchemaMigrationWarning[] Warnings { get; } + + public int[] IgnoredExtraSlots { get; } + + public int[] DefaultedMissingSlots { get; } + } + private static IReadOnlyList DiscoverMembers(Type type) { var members = new List(); @@ -725,6 +1160,7 @@ namespace GameCult.Caching private readonly ConcurrentDictionary _globalKeys = new(); private readonly ConditionalWeakTable _documentHandles = new(); private ILogger _logger = new NullLogger(); + private bool _hasUnflushedMutations; /// /// Creates a cache using the supplied document registry or the shared registry. @@ -744,11 +1180,31 @@ namespace GameCult.Caching set => _logger = value ?? new NullLogger(); } + /// + /// Gets whether the cache currently holds unflushed mutations in any attached backing store or only in memory. + /// + public bool IsDirty => _hasUnflushedMutations || _backingStores.Any(store => store.IsDirty); + + /// + /// Gets the UTC timestamp of the last successful flush across all attached backing stores. + /// + public DateTimeOffset? LastSuccessfulFlushAtUtc { get; private set; } + + /// + /// Gets or sets whether disposing the cache should flush attached dirty backing stores first. + /// + public bool FlushAttachedStoresOnDispose { get; set; } + /// /// Raised when a backing store adds, updates, or removes a document. /// public event Action? OnUpdate; + /// + /// Gets attached backing stores in registration order. + /// + public IReadOnlyList BackingStores => _backingStores; + /// /// Gets all document instances currently held by the cache. /// @@ -770,6 +1226,12 @@ namespace GameCult.Caching store.EntryAdded.Subscribe(entry => AddStoredDocumentInternal(entry, store, raiseUpdate: true).GetAwaiter().GetResult()); store.EntryUpdated.Subscribe(entry => AddStoredDocumentInternal(entry, store, raiseUpdate: true).GetAwaiter().GetResult()); store.EntryDeleted.Subscribe(entry => RemoveStoredDocumentInternal(entry, store, raiseUpdate: true)); + + foreach (var entry in _entries.Values.OrderBy(entry => entry.Key.Value, StringComparer.Ordinal)) + { + store.Push(entry); + } + _backingStores.Add(store); } @@ -783,9 +1245,54 @@ namespace GameCult.Caching store.PullAll(); } + _hasUnflushedMutations = _backingStores.Any(store => store.IsDirty); await Task.CompletedTask; } + /// + /// Flushes all attached backing stores. + /// + public void FlushAllBackingStores(bool soft = false) + { + foreach (var store in _backingStores) + { + FlushBackingStore(store, soft); + } + + RecomputeDirtyState(); + if (!IsDirty) + { + LastSuccessfulFlushAtUtc = DateTimeOffset.UtcNow; + } + } + + /// + /// Flushes one attached backing store. + /// + public void FlushBackingStore(CacheBackingStore store, bool soft = false) + { + if (store == null) throw new ArgumentNullException(nameof(store)); + if (!_backingStores.Contains(store)) + { + throw new InvalidOperationException("Backing store is not attached to this cache."); + } + + store.PushAll(soft); + RecomputeDirtyState(); + if (!store.IsDirty) + { + LastSuccessfulFlushAtUtc = DateTimeOffset.UtcNow; + } + } + + /// + /// Flushes attached backing stores at a lifecycle boundary such as shutdown or assembly reload. + /// + public void PrepareForReloadOrShutdown(bool soft = false) + { + FlushAllBackingStores(soft); + } + /// /// Adds or replaces a typed document and returns its record handle. /// @@ -902,6 +1409,11 @@ namespace GameCult.Caching /// public void Dispose() { + if (FlushAttachedStoresOnDispose && IsDirty) + { + FlushAllBackingStores(); + } + foreach (var store in _backingStores.OfType()) { store.Dispose(); @@ -944,6 +1456,15 @@ namespace GameCult.Caching } } + if (source == null) + { + _hasUnflushedMutations = true; + } + else + { + RecomputeDirtyState(); + } + if (raiseUpdate) { OnUpdate?.Invoke(existing?.Document, stored.Document); @@ -974,6 +1495,15 @@ namespace GameCult.Caching } } + if (source == null) + { + _hasUnflushedMutations = true; + } + else + { + RecomputeDirtyState(); + } + if (raiseUpdate) { OnUpdate?.Invoke(existing.Document, null); @@ -1088,6 +1618,13 @@ namespace GameCult.Caching public CultRecordKey Key { get; } } + + private void RecomputeDirtyState() + { + _hasUnflushedMutations = _backingStores.Count == 0 + ? _hasUnflushedMutations + : _backingStores.Any(store => store.IsDirty); + } } /// @@ -1097,6 +1634,8 @@ namespace GameCult.Caching { private CultDocumentRegistry? _registry; private ILogger _logger = new NullLogger(); + private bool _isDirty; + private CultSchemaMigrationReport[] _lastSchemaMigrationReports = Array.Empty(); /// /// Gets the attached document registry. @@ -1119,6 +1658,30 @@ namespace GameCult.Caching set => _logger = value ?? new NullLogger(); } + /// + /// Gets whether the backing store holds staged mutations not yet durably flushed. + /// + public bool IsDirty + { + get => _isDirty; + protected set => _isDirty = value; + } + + /// + /// Gets the UTC timestamp of the last successful durable flush. + /// + public DateTimeOffset? LastSuccessfulFlushAtUtc { get; protected set; } + + /// + /// Gets or sets whether disposing the backing store should flush staged mutations first. + /// + public bool FlushOnDispose { get; set; } + + /// + /// Gets schema migration reports emitted during the most recent pull. + /// + public IReadOnlyList LastSchemaMigrationReports => _lastSchemaMigrationReports; + /// /// Publishes documents added by the backing store. /// @@ -1158,6 +1721,11 @@ namespace GameCult.Caching /// public virtual void Dispose() { + if (FlushOnDispose && IsDirty) + { + PushAll(); + } + EntryAdded.Dispose(); EntryUpdated.Dispose(); EntryDeleted.Dispose(); @@ -1185,14 +1753,31 @@ namespace GameCult.Caching IReadOnlyCollection catalog, Func deserializePayload) { - var descriptor = Registry.ResolvePersistedSchema(record.SchemaId, catalog); - var document = deserializePayload(descriptor.DocumentType, record.Payload); + var resolution = Registry.ResolvePersistedSchemaDetailed(record.SchemaId, catalog); + var document = deserializePayload(resolution.Descriptor.DocumentType, record.Payload); return new CultStoredDocument( new CultRecordKey(record.Key), record.StoredAt, - descriptor, + resolution.Descriptor, document); } + + /// + /// Records schema migration reports captured during a pull. + /// + protected void SetLastSchemaMigrationReports(IEnumerable reports) + { + _lastSchemaMigrationReports = reports?.ToArray() ?? Array.Empty(); + } + + /// + /// Marks the backing store as durably flushed. + /// + protected void MarkFlushSucceeded() + { + IsDirty = false; + LastSuccessfulFlushAtUtc = DateTimeOffset.UtcNow; + } } /// @@ -1236,16 +1821,23 @@ namespace GameCult.Caching { if (!FileInfo.Exists) { + SetLastSchemaMigrationReports(Array.Empty()); + IsDirty = false; return; } var snapshot = DeserializeSnapshot(File.ReadAllBytes(FileInfo.FullName)); + var reports = new List(snapshot.Records.Length); foreach (var record in snapshot.Records) { + reports.Add(Registry.ResolvePersistedSchemaReport(record.SchemaId, snapshot.SchemaCatalog)); var stored = ToStoredDocument(record, snapshot.SchemaCatalog, DeserializePayload); Entries[stored.Key.Value] = stored; EntryAdded.OnNext(stored); } + + SetLastSchemaMigrationReports(reports); + IsDirty = false; } /// @@ -1254,6 +1846,7 @@ namespace GameCult.Caching public override void Push(CultStoredDocument entry) { Entries[entry.Key.Value] = entry; + IsDirty = true; } /// @@ -1262,6 +1855,7 @@ namespace GameCult.Caching public override void Delete(CultStoredDocument entry) { Entries.TryRemove(entry.Key.Value, out _); + IsDirty = true; } /// @@ -1285,6 +1879,7 @@ namespace GameCult.Caching Directory.CreateDirectory(FileInfo.DirectoryName!); WriteSnapshotAtomically(FileInfo.FullName, SerializeSnapshot(snapshot)); + MarkFlushSucceeded(); } private static void WriteSnapshotAtomically(string path, byte[] payload) diff --git a/src/GameCult.Caching/README.md b/src/GameCult.Caching/README.md index 42dcbd1c..9b87598d 100644 --- a/src/GameCult.Caching/README.md +++ b/src/GameCult.Caching/README.md @@ -133,3 +133,39 @@ var store = new SingleFileMessagePackBackingStore("Data.msgpack"); cache.AddBackingStore(store); await cache.PullAllBackingStoresAsync(); ``` + +## Persistence discipline + +Persistence stays manual by default. Mutations mark the cache and attached +backing stores dirty; nothing silently flushes every object write just because +that felt convenient in the moment. + +Useful surfaces: + +- `cache.IsDirty` +- `store.IsDirty` +- `cache.FlushAllBackingStores()` +- `cache.FlushBackingStore(store)` +- `cache.PrepareForReloadOrShutdown()` +- `cache.FlushAttachedStoresOnDispose` +- `store.FlushOnDispose` + +This is built for callers like Aquarium that want durable runtime state while +still choosing *when* to pay the write cost. + +Single-file MessagePack remains a single-writer format. The atomic replace path +protects against partial writes, not competing writers trampling each other. + +## Schema migration diagnostics + +CultCache does not quietly fall back from exact `schemaId` resolution to +"close enough, probably." When a persisted schema drifts, the cache now emits a +typed migration report describing: + +- exact vs compatible-drift resolution +- ignored extra slots +- defaulted missing slots +- warning codes/messages + +Compatibility rules and canonical fixture receipts live in +`Contracts/cultcache-schema-compatibility.md`. diff --git a/src/GameCult.Networking/Client.cs b/src/GameCult.Networking/Client.cs index e3ba7c3e..3adf7090 100644 --- a/src/GameCult.Networking/Client.cs +++ b/src/GameCult.Networking/Client.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Concurrent; +using System.Security.Cryptography; using GameCult.Logging; using LiteNetLib; using MessagePack; @@ -7,6 +8,27 @@ using R3; namespace GameCult.Networking { + /// + /// Describes the client's reconnect posture. + /// + public enum ClientReconnectState + { + /// + /// The client is connected or otherwise not trying to reconnect. + /// + Idle, + + /// + /// The client is waiting for the next reconnect attempt. + /// + WaitingToReconnect, + + /// + /// The client is actively attempting a reconnect. + /// + Reconnecting + } + /// /// Provides client-side connection, authentication, and message dispatch logic. /// @@ -16,6 +38,10 @@ namespace GameCult.Networking /// Raised when the client encounters a user-visible error. /// public event Action? OnError; // Hook for important user-facing feedback (e.g. via modal UI element) + /// + /// Raised when reconnect state changes. + /// + public event Action? OnReconnectStateChanged; private NetManager? _client; private NetPeer? _peer; private IDisposable? _pollSubscription; @@ -23,6 +49,7 @@ namespace GameCult.Networking private bool _disposed; private bool _manualDisconnect; private bool _isReconnecting; + private int _reconnectAttemptCount; private readonly ConcurrentDictionary _messageDelegates = new(); @@ -31,7 +58,9 @@ namespace GameCult.Networking private int _lastPort; private readonly ClientSecurityOptions _security; private ILogger _logger = new NullLogger(); - private static readonly TimeSpan ReconnectDelay = TimeSpan.FromSeconds(1); + private static readonly TimeSpan BaseReconnectDelay = TimeSpan.FromSeconds(1); + private static readonly TimeSpan MaxReconnectDelay = TimeSpan.FromSeconds(30); + private static readonly TimeSpan MaxReconnectJitter = TimeSpan.FromMilliseconds(250); /// /// Gets or sets the logger used by the client. @@ -47,6 +76,16 @@ namespace GameCult.Networking /// public int Ping { get; private set; } + /// + /// Gets the current reconnect posture exposed to consumers. + /// + public ClientReconnectState ReconnectState { get; private set; } = ClientReconnectState.Idle; + + /// + /// Gets or sets whether raw payload bodies may be logged for diagnostics. + /// + public bool LogSensitivePayloads { get; set; } + /// /// Gets a value indicating whether the client currently has a verified session. /// @@ -83,7 +122,14 @@ namespace GameCult.Networking { if (Verified || m is LoginMessage || m is RegisterMessage || m is VerifyMessage) { - Logger.LogDebug($"Sending message {MessagePackSerializer.SerializeToJson(m)}"); + if (LogSensitivePayloads) + { + Logger.LogDebug($"Sending message {MessagePackSerializer.SerializeToJson(m)}"); + } + else + { + Logger.LogDebug($"Sending message {typeof(T).Name}"); + } _peer.Send(m); } else Logger.LogError("Cannot send, client is not verified!"); @@ -192,6 +238,11 @@ namespace GameCult.Networking /// The remote host name or IP address. /// The remote server port. public void Connect(string host = "localhost", int port = 3075) + { + ConnectCore(host, port, resetReconnectBackoff: true); + } + + private void ConnectCore(string host, int port, bool resetReconnectBackoff) { if (_disposed) { @@ -200,6 +251,11 @@ namespace GameCult.Networking _manualDisconnect = false; _isReconnecting = false; + if (resetReconnectBackoff) + { + _reconnectAttemptCount = 0; + } + SetReconnectState(ClientReconnectState.Idle); _reconnectSubscription?.Dispose(); _reconnectSubscription = null; _lastHost = host; @@ -221,8 +277,15 @@ namespace GameCult.Networking try { var bytes = reader.GetRemainingBytes(); - Logger.LogDebug($"Received message: {MessagePackSerializer.ConvertToJson(new ReadOnlyMemory(bytes))}"); var message = MessageSerialization.Deserialize(bytes); + if (LogSensitivePayloads) + { + Logger.LogDebug($"Received message: {MessagePackSerializer.ConvertToJson(new ReadOnlyMemory(bytes))}"); + } + else + { + Logger.LogDebug($"Received message {message.GetType().Name}"); + } var type = message.GetType(); switch (message) @@ -252,6 +315,8 @@ namespace GameCult.Networking { Logger.LogInfo($"Peer {peer.Address}:{peer.Port} connected."); _peer = peer; + _reconnectAttemptCount = 0; + SetReconnectState(ClientReconnectState.Idle); if (Verified) { var nonce = Secret.NewNonce; @@ -282,6 +347,7 @@ namespace GameCult.Networking _manualDisconnect = true; _reconnectSubscription?.Dispose(); _reconnectSubscription = null; + SetReconnectState(ClientReconnectState.Idle); DisposeTransport(); } @@ -297,6 +363,7 @@ namespace GameCult.Networking _manualDisconnect = true; _reconnectSubscription?.Dispose(); _reconnectSubscription = null; + SetReconnectState(ClientReconnectState.Idle); DisposeTransport(); } @@ -308,15 +375,19 @@ namespace GameCult.Networking } _isReconnecting = true; - _reconnectSubscription = Observable.Timer(ReconnectDelay).Subscribe(_ => + _reconnectAttemptCount++; + var delay = GetReconnectDelayForAttempt(_reconnectAttemptCount); + SetReconnectState(ClientReconnectState.WaitingToReconnect); + _reconnectSubscription = Observable.Timer(delay).Subscribe(_ => { _isReconnecting = false; _reconnectSubscription?.Dispose(); _reconnectSubscription = null; + SetReconnectState(ClientReconnectState.Reconnecting); try { - Connect(_lastHost, _lastPort); + ConnectCore(_lastHost, _lastPort, resetReconnectBackoff: false); } catch (Exception ex) { @@ -326,6 +397,16 @@ namespace GameCult.Networking }); } + internal static TimeSpan GetReconnectDelayForAttempt(int attempt) + { + var normalizedAttempt = Math.Max(1, attempt); + var exponentialSeconds = Math.Min( + MaxReconnectDelay.TotalSeconds, + BaseReconnectDelay.TotalSeconds * Math.Pow(2, normalizedAttempt - 1)); + var jitterMilliseconds = RandomNumberGenerator.GetInt32((int)MaxReconnectJitter.TotalMilliseconds + 1); + return TimeSpan.FromSeconds(exponentialSeconds) + TimeSpan.FromMilliseconds(jitterMilliseconds); + } + private void DisposeTransport() { _pollSubscription?.Dispose(); @@ -356,5 +437,16 @@ namespace GameCult.Networking } } } + + private void SetReconnectState(ClientReconnectState state) + { + if (ReconnectState == state) + { + return; + } + + ReconnectState = state; + OnReconnectStateChanged?.Invoke(state); + } } } diff --git a/src/GameCult.Networking/PlayerData.cs b/src/GameCult.Networking/PlayerData.cs index f0f19019..997db848 100644 --- a/src/GameCult.Networking/PlayerData.cs +++ b/src/GameCult.Networking/PlayerData.cs @@ -7,7 +7,7 @@ namespace GameCult.Networking /// /// Basic player account document used by the sample networking stack. /// - [CultDocument("gamecult.player_data", "gamecult.player_data.v1")] + [CultDocument("gamecult.player_data", "gamecult.player_data.v2")] public class PlayerData { /// @@ -36,5 +36,11 @@ namespace GameCult.Networking [Key(3)] [CultName] public string Username = string.Empty; + + /// + /// Monotonic session version used to supersede older issued tokens. + /// + [Key(4)] + public long SessionVersion; } } diff --git a/src/GameCult.Networking/README.md b/src/GameCult.Networking/README.md index 8aa17e77..a1c39de2 100644 --- a/src/GameCult.Networking/README.md +++ b/src/GameCult.Networking/README.md @@ -23,6 +23,11 @@ The library currently includes: The library is focused on the built-in authentication and session flows in this repository. +Current readiness target: + +- trusted local mesh / self-hosted swarm use: yes +- hostile public internet edge: not yet + Keep the distinction clean: - auth/session semantics belong to the core library @@ -98,6 +103,9 @@ The built-in flow is: 5. server validates the token and re-establishes the session The session token is signed and validated by the server. +Each newly issued session token also carries a monotonic session version so +older signed tokens can be superseded cleanly instead of haunting reconnect +paths forever. ## Runtime Secrets @@ -218,8 +226,9 @@ The client: - disposes its prior polling subscription before reconnecting - stops the prior `NetManager` -- schedules reconnect with a short delay +- schedules reconnect with bounded exponential backoff and jitter - automatically re-verifies using the stored signed session token +- exposes reconnect state for UI/operator surfaces ## Important Constraints @@ -227,6 +236,10 @@ The client: - Message authorization is based on the built-in verify/login/register flow. - Transport is LiteNetLib, so this is not a drop-in substitute for HTTP or WebSocket stacks. +Sensitive payload logging is gated. Raw message JSON should only appear when a +caller explicitly opts into diagnostic logging instead of accidentally bleeding +live auth/session payloads into ordinary logs. + ## Message Example Messages derive from `Message` and are MessagePack-serializable: @@ -258,3 +271,10 @@ For the newer schema-v0 lane, the equivalent rule is: - keep payload codecs in sync - advertise supported contracts through schema discovery - do not rely on implicit runtime magic to decide what a peer meant + +## Future hostile-network work + +The next hardening tranche is documented in +`production-hardening-checklist.md`. That ledger names the missing transport, +replay, abuse-handling, and adversarial-test organs directly instead of +pretending local-mesh readiness and internet readiness are the same rite. diff --git a/src/GameCult.Networking/Secret.cs b/src/GameCult.Networking/Secret.cs index 86ec99ad..b685e015 100644 --- a/src/GameCult.Networking/Secret.cs +++ b/src/GameCult.Networking/Secret.cs @@ -193,14 +193,20 @@ namespace GameCult.Networking /// The UTC expiration time for the token. /// A tamper-evident session token. public static string CreateSessionToken(Guid userId, DateTimeOffset expiresAtUtc) => - CreateSessionToken(userId, expiresAtUtc, GetDefaultOptions()); + CreateSessionToken(userId, expiresAtUtc, sessionVersion: 0, GetDefaultOptions()); /// /// Creates a signed session token for the supplied user. /// - public static string CreateSessionToken(Guid userId, DateTimeOffset expiresAtUtc, ServerSecurityOptions options) + public static string CreateSessionToken(Guid userId, DateTimeOffset expiresAtUtc, ServerSecurityOptions options) => + CreateSessionToken(userId, expiresAtUtc, sessionVersion: 0, options); + + /// + /// Creates a signed session token for the supplied user and session version. + /// + public static string CreateSessionToken(Guid userId, DateTimeOffset expiresAtUtc, long sessionVersion, ServerSecurityOptions options) { - var payload = $"{userId:N}|{expiresAtUtc.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture)}"; + var payload = $"{userId:N}|{expiresAtUtc.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture)}|{sessionVersion.ToString(CultureInfo.InvariantCulture)}"; var payloadBytes = Encoding.UTF8.GetBytes(payload); var signatureBytes = ComputeHmacSha256(options.GetSessionSigningKey(), payloadBytes); return $"{ToBase64Url(payloadBytes)}.{ToBase64Url(signatureBytes)}"; @@ -214,7 +220,17 @@ namespace GameCult.Networking /// The extracted UTC expiration time. /// true when the token is valid and unexpired. public static bool TryValidateSessionToken(string? token, out Guid userId, out DateTimeOffset expiresAtUtc) => - TryValidateSessionToken(token, GetDefaultOptions(), out userId, out expiresAtUtc); + TryValidateSessionToken(token, GetDefaultOptions(), out userId, out expiresAtUtc, out _); + + /// + /// Validates and parses a signed session token. + /// + public static bool TryValidateSessionToken( + string? token, + out Guid userId, + out DateTimeOffset expiresAtUtc, + out long sessionVersion) => + TryValidateSessionToken(token, GetDefaultOptions(), out userId, out expiresAtUtc, out sessionVersion); /// /// Validates and parses a signed session token. @@ -224,9 +240,23 @@ namespace GameCult.Networking ServerSecurityOptions options, out Guid userId, out DateTimeOffset expiresAtUtc) + { + return TryValidateSessionToken(token, options, out userId, out expiresAtUtc, out _); + } + + /// + /// Validates and parses a signed session token. + /// + public static bool TryValidateSessionToken( + string? token, + ServerSecurityOptions options, + out Guid userId, + out DateTimeOffset expiresAtUtc, + out long sessionVersion) { userId = Guid.Empty; expiresAtUtc = DateTimeOffset.MinValue; + sessionVersion = 0; if (string.IsNullOrWhiteSpace(token)) return false; @@ -245,7 +275,7 @@ namespace GameCult.Networking var payload = Encoding.UTF8.GetString(payloadBytes); var payloadParts = payload.Split('|'); - if (payloadParts.Length != 2 || + if ((payloadParts.Length != 2 && payloadParts.Length != 3) || !Guid.TryParseExact(payloadParts[0], "N", out userId) || !long.TryParse(payloadParts[1], NumberStyles.None, CultureInfo.InvariantCulture, out var expiryUnixSeconds)) { @@ -253,6 +283,15 @@ namespace GameCult.Networking return false; } + if (payloadParts.Length == 3 && + !long.TryParse(payloadParts[2], NumberStyles.Integer, CultureInfo.InvariantCulture, out sessionVersion)) + { + userId = Guid.Empty; + expiresAtUtc = DateTimeOffset.MinValue; + sessionVersion = 0; + return false; + } + expiresAtUtc = DateTimeOffset.FromUnixTimeSeconds(expiryUnixSeconds); return expiresAtUtc > DateTimeOffset.UtcNow; } @@ -260,6 +299,7 @@ namespace GameCult.Networking { userId = Guid.Empty; expiresAtUtc = DateTimeOffset.MinValue; + sessionVersion = 0; return false; } } diff --git a/src/GameCult.Networking/Server.cs b/src/GameCult.Networking/Server.cs index f83d488e..f9232328 100644 --- a/src/GameCult.Networking/Server.cs +++ b/src/GameCult.Networking/Server.cs @@ -25,12 +25,15 @@ namespace GameCult.Networking private const int ServerPort = 3075; private const float SessionTimeoutSeconds = 1800; // 30 minutes private const float SessionRefreshThresholdSeconds = 300; // 5 minutes + private const int MaxConnectionAttemptsPerMinute = 30; private const int MaxLoginAttemptsPerMinute = 5; private readonly ConcurrentDictionary _messageDelegates = new(); private readonly ConcurrentDictionary _users = new(); - private readonly ConcurrentDictionary> _loginAttempts = new(); - private readonly ConcurrentDictionary _loginAttemptLocks = new(); + private readonly ConcurrentDictionary> _connectionAttempts = new(); + private readonly ConcurrentDictionary _connectionAttemptLocks = new(); + private readonly ConcurrentDictionary> _authAttempts = new(); + private readonly ConcurrentDictionary _authAttemptLocks = new(); private readonly IDisposable _cleanupSubscription; private readonly CultCache _database; private readonly ServerSecurityOptions _security; @@ -48,6 +51,11 @@ namespace GameCult.Networking set => _logger = value ?? new NullLogger(); } + /// + /// Gets or sets whether raw payload bodies may be logged for diagnostics. + /// + public bool LogSensitivePayloads { get; set; } + private float Time => (float)(_timer?.Elapsed.TotalSeconds ?? 0d); /// @@ -59,6 +67,7 @@ namespace GameCult.Networking { _database = cache; _security = security ?? ServerSecurityOptions.FromEnvironment(); + LogSensitivePayloads = _security.IsDevelopment; _cleanupSubscription = Observable.Timer(TimeSpan.FromSeconds(60), TimeSpan.FromSeconds(60)) .Subscribe(_ => CleanupExpiredSessions()); } @@ -150,7 +159,7 @@ namespace GameCult.Networking listener.ConnectionRequestEvent += request => { - if (CheckRateLimit(request.RemoteEndPoint.Address.ToString())) + if (CheckConnectionRateLimit(request.RemoteEndPoint.Address.ToString())) { request.AcceptIfKey(_security.ConnectionKey); } @@ -186,7 +195,14 @@ namespace GameCult.Networking { var bytes = reader.GetRemainingBytes(); var message = MessageSerialization.Deserialize(bytes); - Logger.LogDebug($"Received message: {MessagePackSerializer.ConvertToJson(new ReadOnlyMemory(bytes))}"); + if (LogSensitivePayloads) + { + Logger.LogDebug($"Received message: {MessagePackSerializer.ConvertToJson(new ReadOnlyMemory(bytes))}"); + } + else + { + Logger.LogDebug($"Received message {message?.GetType().Name ?? "unknown"}"); + } if (message == null) { return; @@ -203,7 +219,7 @@ namespace GameCult.Networking return; } - if (message is LoginMessage or RegisterMessage && !CheckRateLimit(peer.Address.ToString())) + if (message is LoginMessage or RegisterMessage && !CheckAuthRateLimit(peer.Address.ToString())) { peer.Send(new ErrorMessage { Error = "Too Many Attempts" }); return; @@ -343,20 +359,27 @@ namespace GameCult.Networking private void HandleVerify(NetPeer peer, User user, VerifyMessage verify) { var token = Secret.DecryptString(verify.Session, verify.Nonce, _security); - if (!Secret.TryValidateSessionToken(token, _security, out var playerId, out _)) + if (!Secret.TryValidateSessionToken(token, _security, out var playerId, out _, out var sessionVersion)) { peer.Send(new ErrorMessage { Error = "Session Invalid" }); return; } - if (_database.GetByIndex("PlayerId", playerId.ToString("D")) == null) + var player = _database.GetByIndex("PlayerId", playerId.ToString("D")); + if (player == null) { peer.Send(new ErrorMessage { Error = "Session Not Found" }); return; } + if (player.SessionVersion != sessionVersion) + { + peer.Send(new ErrorMessage { Error = "Session Superseded" }); + return; + } + AttachUser(user, playerId); - SendSessionToken(peer, playerId); + SendSessionToken(peer, player); } private void HandleLogin(NetPeer peer, User user, LoginMessage login) @@ -387,7 +410,7 @@ namespace GameCult.Networking } AttachUser(user, userData.PlayerId); - SendSessionToken(peer, userData.PlayerId); + SendSessionToken(peer, userData); } private bool IsVerified(User? user) => @@ -399,23 +422,14 @@ namespace GameCult.Networking private PlayerData? SessionData(User user) => IsVerified(user) ? _database.GetByIndex("PlayerId", user.PlayerId.ToString("D")) : null; - private bool CheckRateLimit(string ip) + internal bool CheckConnectionRateLimit(string ip) { - var now = DateTimeOffset.UtcNow; - var windowStart = now.AddMinutes(-1); - var queue = _loginAttempts.GetOrAdd(ip, _ => new Queue()); - var gate = _loginAttemptLocks.GetOrAdd(ip, _ => new object()); + return CheckRateLimit(ip, MaxConnectionAttemptsPerMinute, _connectionAttempts, _connectionAttemptLocks); + } - lock (gate) - { - while (queue.Count > 0 && queue.Peek() < windowStart) - { - queue.Dequeue(); - } - - queue.Enqueue(now); - return queue.Count <= MaxLoginAttemptsPerMinute; - } + internal bool CheckAuthRateLimit(string ip) + { + return CheckRateLimit(ip, MaxLoginAttemptsPerMinute, _authAttempts, _authAttemptLocks); } private void CleanupExpiredSessions() @@ -439,14 +453,29 @@ namespace GameCult.Networking } private void SendSessionToken(NetPeer peer, Guid playerId) + { + var player = _database.GetByIndex("PlayerId", playerId.ToString("D")); + if (player == null) + { + peer.Send(new ErrorMessage { Error = "Session Not Found" }); + return; + } + + SendSessionToken(peer, player); + } + + private void SendSessionToken(NetPeer peer, PlayerData player) { var expiresAtUtc = DateTimeOffset.UtcNow.AddSeconds(SessionTimeoutSeconds); - var token = Secret.CreateSessionToken(playerId, expiresAtUtc, _security); + player.SessionVersion++; + var token = Secret.CreateSessionToken(player.PlayerId, expiresAtUtc, player.SessionVersion, _security); + _database.AddAsync(player).GetAwaiter().GetResult(); if (_users.TryGetValue(peer.Id, out var user)) { - user.PlayerId = playerId; + user.PlayerId = player.PlayerId; user.SessionExpiresAt = expiresAtUtc; + user.SessionVersion = player.SessionVersion; user.SessionToken = token; } @@ -467,6 +496,29 @@ namespace GameCult.Networking SendSessionToken(peer, user.PlayerId); } + + private static bool CheckRateLimit( + string ip, + int maxAttemptsPerMinute, + ConcurrentDictionary> attemptBuckets, + ConcurrentDictionary attemptLocks) + { + var now = DateTimeOffset.UtcNow; + var windowStart = now.AddMinutes(-1); + var queue = attemptBuckets.GetOrAdd(ip, _ => new Queue()); + var gate = attemptLocks.GetOrAdd(ip, _ => new object()); + + lock (gate) + { + while (queue.Count > 0 && queue.Peek() < windowStart) + { + queue.Dequeue(); + } + + queue.Enqueue(now); + return queue.Count <= maxAttemptsPerMinute; + } + } } /// @@ -514,5 +566,10 @@ namespace GameCult.Networking /// The latest signed session token issued to the peer. /// public string SessionToken = string.Empty; + + /// + /// The session version associated with the latest issued token. + /// + public long SessionVersion; } } diff --git a/src/GameCult.Networking/production-hardening-checklist.md b/src/GameCult.Networking/production-hardening-checklist.md new file mode 100644 index 00000000..ff05808a --- /dev/null +++ b/src/GameCult.Networking/production-hardening-checklist.md @@ -0,0 +1,76 @@ +# GameCult.Networking Production Hardening Checklist + +This library is readying itself for trusted local mesh use: localhost, LAN, or +controlled self-hosted runtime clusters such as Epiphany and Aquarium. It is +not yet claiming sainthood on the public internet. + +## Local-mesh baseline now covered + +- separate connection-attempt and auth-attempt throttles in `Server` +- versioned session tokens that supersede older issued tokens +- bounded reconnect backoff with jitter in `Client` +- raw payload logging gated behind explicit diagnostic opt-in +- schema-first raw document/snapshot lane with exact `schemaId` routing + +## Missing hostile-network organs + +### Transport posture + +- LiteNetLib remains the transport substrate. +- There is no transport-level TLS story in this library today. +- Before internet exposure, decide whether LiteNetLib stays and gains a real + outer protection model, or whether another transport becomes the canonical + public edge. + +### Credential and replay posture + +- login/register payloads are AES-GCM encrypted with per-message nonces +- session tokens are signed and versioned +- replay resistance is still scoped to token expiry + supersession semantics, + not a broader anti-replay protocol + +Before hostile exposure: + +- define replay expectations explicitly +- add adversarial tests around token reuse, stale reconnects, and reordered + message delivery + +### Session rotation and revocation + +- superseded tokens are rejected once a newer session version is issued +- there is still no broader account-session management surface for: + - explicit logout-all + - administrative revocation + - multi-device policy + +### Input abuse and malformed payload handling + +- malformed MessagePack is rejected by the serializer path +- there is no explicit message-size policy or backpressure contract yet + +Before hostile exposure: + +- add payload-size ceilings +- add stress tests for oversized and malformed inputs +- decide what gets dropped, logged, or disconnected + +### Replication abuse cases + +- raw snapshot/document replication assumes peers already share payload schema + and trust boundaries +- there is not yet an adversarial policy for untrusted replication partners + +Before hostile exposure: + +- define peer authorization for raw snapshot/document lanes +- test unknown schema spam, snapshot flooding, and incompatible payload storms + +### Missing tests + +Still wanted before claiming internet-grade readiness: + +- live client/server auth integration tests +- reconnect storm tests +- session supersession across multiple clients +- message-size and malformed-frame abuse tests +- load and soak tests for rate limiting and session refresh diff --git a/tests/GameCult.Caching.Tests/BackingStoreTests.cs b/tests/GameCult.Caching.Tests/BackingStoreTests.cs index 1bddfc20..145fcfcd 100644 --- a/tests/GameCult.Caching.Tests/BackingStoreTests.cs +++ b/tests/GameCult.Caching.Tests/BackingStoreTests.cs @@ -40,6 +40,88 @@ namespace GameCult.Caching.Tests Assert.That(loaded, Is.Not.Null); Assert.That(loaded!.Value, Is.EqualTo("payload")); + Assert.That(readStore.LastSchemaMigrationReports, Has.Count.EqualTo(1)); + Assert.That(readStore.LastSchemaMigrationReports[0].Kind, Is.EqualTo(CultSchemaMigrationKind.Exact)); + } + finally + { + if (File.Exists(filePath)) + { + File.Delete(filePath); + } + } + } + + [Test] + public async Task CultCache_DirtyState_Tracks_Mutations_And_ExplicitFlush() + { + var filePath = Path.Combine(Path.GetTempPath(), $"cultlib-tests-{Guid.NewGuid():N}.msgpack"); + + try + { + var store = new SingleFileMessagePackBackingStore(filePath); + var cache = new CultCache(); + cache.AddBackingStore(store); + + Assert.That(cache.IsDirty, Is.False); + Assert.That(store.IsDirty, Is.False); + + await cache.AddAsync(new NamedTestEntry + { + Name = "dirty", + Value = "pending" + }); + + Assert.That(cache.IsDirty, Is.True); + Assert.That(store.IsDirty, Is.True); + Assert.That(cache.LastSuccessfulFlushAtUtc, Is.Null); + + cache.FlushAllBackingStores(); + + Assert.That(cache.IsDirty, Is.False); + Assert.That(store.IsDirty, Is.False); + Assert.That(cache.LastSuccessfulFlushAtUtc, Is.Not.Null); + Assert.That(store.LastSuccessfulFlushAtUtc, Is.Not.Null); + } + finally + { + if (File.Exists(filePath)) + { + File.Delete(filePath); + } + } + } + + [Test] + public async Task CultCache_FlushOnDispose_Persists_When_Enabled() + { + var filePath = Path.Combine(Path.GetTempPath(), $"cultlib-tests-{Guid.NewGuid():N}.msgpack"); + + try + { + var store = new SingleFileMessagePackBackingStore(filePath); + var cache = new CultCache + { + FlushAttachedStoresOnDispose = true + }; + cache.AddBackingStore(store); + + var handle = await cache.AddAsync(new NamedTestEntry + { + Name = "dispose", + Value = "flush" + }); + + cache.Dispose(); + + var readStore = new SingleFileMessagePackBackingStore(filePath); + var readCache = new CultCache(); + readCache.AddBackingStore(readStore); + await readCache.PullAllBackingStoresAsync(); + + var loaded = readCache.Get(handle.Key); + Assert.That(loaded, Is.Not.Null); + Assert.That(loaded!.Value, Is.EqualTo("flush")); } finally { @@ -171,6 +253,66 @@ namespace GameCult.Caching.Tests Assert.That(descriptor.CanonicalSchemaJson, Does.Contain("\"isReference\":true")); } + [Test] + public void Registry_CanonicalSchema_Fixtures_Are_Stable() + { + var named = CultDocumentRegistry.Shared.GetRequired(); + var referenceHolder = CultDocumentRegistry.Shared.GetRequired(); + + Assert.That(named.CanonicalSchemaJson, Is.EqualTo(NamedFixtureCanonicalSchemaJson)); + Assert.That(named.SchemaId, Is.EqualTo(NamedFixtureSchemaId)); + Assert.That(named.ContentHash, Is.EqualTo(NamedFixtureContentHash)); + Assert.That(referenceHolder.SchemaId, Is.EqualTo(ReferenceFixtureSchemaId)); + } + + [Test] + public void ResolvePersistedSchemaReport_Classifies_Compatible_And_Incompatible_Drift() + { + var registry = CultDocumentRegistry.Shared; + var namedV1 = registry.GetRequired(); + var namedV2 = registry.GetRequired(); + var namedV3 = registry.GetRequired(); + var namedMismatch = registry.GetRequired(); + var referenceV1 = registry.GetRequired(); + var referenceRetargeted = registry.GetRequired(); + + var additiveCatalog = namedV1.ToCatalogEntry(); + additiveCatalog.SchemaId = "persisted.tests.named_entry.v1"; + additiveCatalog.CompatibleSchemaIds = new[] { namedV2.SchemaId }; + var additiveReport = registry.ResolvePersistedSchemaReport(additiveCatalog.SchemaId, new[] { additiveCatalog }); + Assert.That(additiveReport.Kind, Is.EqualTo(CultSchemaMigrationKind.CompatibleDrift)); + Assert.That(additiveReport.DefaultedMissingSlots, Is.EqualTo(new[] { 2 })); + Assert.That(additiveReport.IgnoredExtraSlots, Is.Empty); + + var removedCatalog = namedV2.ToCatalogEntry(); + removedCatalog.SchemaId = "persisted.tests.named_entry.v2"; + removedCatalog.CompatibleSchemaIds = new[] { namedV3.SchemaId }; + var removedReport = registry.ResolvePersistedSchemaReport(removedCatalog.SchemaId, new[] { removedCatalog }); + Assert.That(removedReport.Kind, Is.EqualTo(CultSchemaMigrationKind.CompatibleDrift)); + Assert.That(removedReport.DefaultedMissingSlots, Is.Empty); + Assert.That(removedReport.IgnoredExtraSlots, Is.EqualTo(new[] { 1, 2 })); + + var mismatchCatalog = namedV1.ToCatalogEntry(); + mismatchCatalog.SchemaId = "persisted.tests.named_entry.type_mismatch"; + mismatchCatalog.CompatibleSchemaIds = new[] { namedMismatch.SchemaId }; + Assert.That( + () => registry.ResolvePersistedSchemaReport(mismatchCatalog.SchemaId, new[] { mismatchCatalog }), + Throws.TypeOf().With.Message.Contains("changed type")); + + var retargetedCatalog = referenceV1.ToCatalogEntry(); + retargetedCatalog.SchemaId = "persisted.tests.reference_holder.v1"; + retargetedCatalog.CompatibleSchemaIds = new[] { referenceRetargeted.SchemaId }; + Assert.That( + () => registry.ResolvePersistedSchemaReport(retargetedCatalog.SchemaId, new[] { retargetedCatalog }), + Throws.TypeOf().With.Message.Contains("changed target schema")); + } + + private const string NamedFixtureCanonicalSchemaJson = + "{\"schemaName\":\"tests.named_entry\",\"schemaVersion\":\"tests.named_entry.v1\",\"members\":[{\"slot\":0,\"name\":\"Name\",\"type\":\"System.String\",\"isReference\":false,\"many\":false,\"targetSchemaName\":null,\"indexAlias\":null,\"isName\":true},{\"slot\":1,\"name\":\"Value\",\"type\":\"System.String\",\"isReference\":false,\"many\":false,\"targetSchemaName\":null,\"indexAlias\":null,\"isName\":false}]}"; + private const string NamedFixtureSchemaId = "sha256:e7b97801b94190f3159012ede45b0069bb09ebf7920f7432c971bc86a0e08de8"; + private const string NamedFixtureContentHash = "sha256:23150930afcc1d84f0cb3012ccc2debcb9b4685f62083033bbaab0083f1e832e"; + private const string ReferenceFixtureSchemaId = "sha256:bd85064961cc74565fb73e3ccbc4217cfba4dc4869e365a08bea4f704739bd8f"; + [CultDocument("tests.named_entry", "tests.named_entry.v1")] internal sealed class NamedTestEntry { @@ -192,5 +334,57 @@ namespace GameCult.Caching.Tests [Key(1)] public CultRecordRef Parent = new(new CultRecordKey("parent")); } + + [CultDocument("tests.named_entry", "tests.named_entry.v2")] + internal sealed class NamedTestEntryAdditive + { + [Key(0)] + [CultName] + public string Name = string.Empty; + + [Key(1)] + public string Value = string.Empty; + + [Key(2)] + public string Notes = string.Empty; + } + + [CultDocument("tests.named_entry", "tests.named_entry.v3")] + internal sealed class NamedTestEntryRemoved + { + [Key(0)] + [CultName] + public string Name = string.Empty; + } + + [CultDocument("tests.named_entry", "tests.named_entry.v4")] + internal sealed class NamedTestEntryTypeMismatch + { + [Key(0)] + [CultName] + public string Name = string.Empty; + + [Key(1)] + public int Value; + } + + [CultDocument("tests.alt_named_entry", "tests.alt_named_entry.v1")] + internal sealed class AlternateNamedTestEntry + { + [Key(0)] + [CultName] + public string Name = string.Empty; + } + + [CultDocument("tests.reference_holder", "tests.reference_holder.v2")] + internal sealed class ReferenceHolderRetargetedEntry + { + [Key(0)] + [CultName] + public string Name = string.Empty; + + [Key(1)] + public CultRecordRef Parent = new(new CultRecordKey("parent")); + } } } diff --git a/tests/GameCult.Networking.Tests/NetworkingTests.cs b/tests/GameCult.Networking.Tests/NetworkingTests.cs index df331540..fc0e8c58 100644 --- a/tests/GameCult.Networking.Tests/NetworkingTests.cs +++ b/tests/GameCult.Networking.Tests/NetworkingTests.cs @@ -38,6 +38,19 @@ namespace GameCult.Networking.Tests Assert.That(Secret.TryValidateSessionToken(tamperedToken, DevelopmentServerSecurity, out _, out _), Is.False); } + [Test] + public void SessionToken_Validates_SessionVersion() + { + var userId = Guid.NewGuid(); + var token = Secret.CreateSessionToken(userId, DateTimeOffset.UtcNow.AddMinutes(5), 42, DevelopmentServerSecurity); + + Assert.That( + Secret.TryValidateSessionToken(token, DevelopmentServerSecurity, out var parsedUserId, out _, out var sessionVersion), + Is.True); + Assert.That(parsedUserId, Is.EqualTo(userId)); + Assert.That(sessionVersion, Is.EqualTo(42)); + } + [Test] public void SessionToken_RejectsExpiredTokens() { @@ -267,6 +280,60 @@ namespace GameCult.Networking.Tests Assert.That(ghostlight.Kind, Is.EqualTo("document_payload")); } + [Test] + public void Server_Separates_Connection_And_Auth_RateLimits() + { + var cache = new CultCache(); + var server = new Server(cache, DevelopmentServerSecurity); + var serverType = typeof(Server); + var connectionMethod = serverType.GetMethod("CheckConnectionRateLimit", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public)!; + var authMethod = serverType.GetMethod("CheckAuthRateLimit", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public)!; + + for (var index = 0; index < 5; index++) + { + Assert.That((bool)connectionMethod.Invoke(server, new object[] { "127.0.0.1" })!, Is.True); + } + + Assert.That((bool)authMethod.Invoke(server, new object[] { "127.0.0.1" })!, Is.True); + + for (var index = 0; index < 5; index++) + { + _ = authMethod.Invoke(server, new object[] { "127.0.0.2" }); + } + + Assert.That((bool)authMethod.Invoke(server, new object[] { "127.0.0.2" })!, Is.False); + Assert.That((bool)connectionMethod.Invoke(server, new object[] { "127.0.0.2" })!, Is.True); + } + + [Test] + public void Client_Reconnect_Backoff_Is_Bounded_And_Grows() + { + var method = typeof(Client).GetMethod("GetReconnectDelayForAttempt", BindingFlags.Static | BindingFlags.NonPublic)!; + + var first = (TimeSpan)method.Invoke(null, new object[] { 1 })!; + var third = (TimeSpan)method.Invoke(null, new object[] { 3 })!; + var tenth = (TimeSpan)method.Invoke(null, new object[] { 10 })!; + + Assert.That(first, Is.GreaterThanOrEqualTo(TimeSpan.FromSeconds(1))); + Assert.That(first, Is.LessThanOrEqualTo(TimeSpan.FromSeconds(1.25))); + Assert.That(third, Is.GreaterThanOrEqualTo(TimeSpan.FromSeconds(4))); + Assert.That(third, Is.LessThanOrEqualTo(TimeSpan.FromSeconds(4.25))); + Assert.That(tenth, Is.GreaterThanOrEqualTo(TimeSpan.FromSeconds(30))); + Assert.That(tenth, Is.LessThanOrEqualTo(TimeSpan.FromSeconds(30.25))); + } + + [Test] + public void SensitivePayloadLogging_Is_Gated_By_Default() + { + var client = new Client(DevelopmentClientSecurity); + var productionServer = new Server(new CultCache(), new ServerSecurityOptions("prod-connection-key", "prod-session-signing-secret")); + var developmentServer = new Server(new CultCache(), DevelopmentServerSecurity); + + Assert.That(client.LogSensitivePayloads, Is.False); + Assert.That(productionServer.LogSensitivePayloads, Is.False); + Assert.That(developmentServer.LogSensitivePayloads, Is.True); + } + private sealed class EnvironmentVariableScope : IDisposable { private readonly (string Name, string? Value)[] _originalValues; @@ -297,6 +364,7 @@ namespace GameCult.Networking.Tests [MessagePack.Key(1)] public string Email { get; set; } = string.Empty; [MessagePack.Key(2)] public string PasswordHash { get; set; } = string.Empty; [MessagePack.Key(3)] public string Username { get; set; } = string.Empty; + [MessagePack.Key(4)] public long SessionVersion { get; set; } } private static byte[] SerializePlayerDataPayload(PlayerData entry) @@ -306,7 +374,8 @@ namespace GameCult.Networking.Tests PlayerId = entry.PlayerId, Email = entry.Email, PasswordHash = entry.PasswordHash, - Username = entry.Username + Username = entry.Username, + SessionVersion = entry.SessionVersion }); } @@ -318,7 +387,8 @@ namespace GameCult.Networking.Tests PlayerId = decoded.PlayerId, Email = decoded.Email, PasswordHash = decoded.PasswordHash, - Username = decoded.Username + Username = decoded.Username, + SessionVersion = decoded.SessionVersion }; } }