From 98ed042fcc8b5d363ae6ddd20f9ceebccea020b6 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 02:04:33 +0200 Subject: [PATCH] C#: refuse unknown store headers and extra record slots by name Delete the trailing Skip loop in the record reader, the ?? v1 header default, and the mesh reader's copy of the record decoder. The single-file store and CultMesh single-file reads accept cultcache.store.v1 only. Persistence-format and parity docs state the rule. Co-Authored-By: Claude Sonnet 5 --- docs/runtime-parity-scope.md | 2 + .../CultDocumentMessagePackSerialization.cs | 27 ++++++-- .../Contracts/cultcache-persistence-format.md | 5 ++ .../CultMeshSingleFileDocuments.cs | 30 +++----- .../PreCut2StoreFormatTests.cs | 68 +++++++++++++++++++ .../CultMeshVariantRefusalTests.cs | 47 +++++++++++++ 6 files changed, 153 insertions(+), 26 deletions(-) create mode 100644 tests/GameCult.Mesh.Tests/CultMeshVariantRefusalTests.cs diff --git a/docs/runtime-parity-scope.md b/docs/runtime-parity-scope.md index 72768673..46b1e77b 100644 --- a/docs/runtime-parity-scope.md +++ b/docs/runtime-parity-scope.md @@ -13,6 +13,8 @@ Cross-runtime parity has three layers: - Wire parity: shared CultCache file/state contracts, schema-v0 MessagePack documents, transport profiles, and RUDP packets must cross runtime boundaries without translation folklore. + Every store reader refuses, by name, a header or record slot it does not + understand (vectors: `tests/vectors/document-variants-c0`). - Feature parity: each runtime must expose the CultCache/CultNet/CultMesh surfaces needed for its intended role, with unsupported production-server ownership called out plainly. diff --git a/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs b/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs index 1e99125e..bb62d2ed 100644 --- a/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs +++ b/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs @@ -30,6 +30,7 @@ public sealed class CultDocumentResolver : IFormatterResolver public static class CultDocumentMessagePackSerialization { + private const string SingleFileFormatVersion = "cultcache.store.v1"; private const int PersistedRecordFieldCount = 4; private const int SchemaCatalogEntryFieldCount = 7; private const int SchemaCatalogMemberFieldCount = 8; @@ -139,7 +140,8 @@ public static class CultDocumentMessagePackSerialization if (fieldCount > 0) { - snapshot.FormatVersion = reader.ReadString() ?? "cultcache.store.v1"; + snapshot.FormatVersion = reader.ReadString() + ?? throw new NotSupportedException("Store snapshot declares no format version; this runtime reads " + SingleFileFormatVersion + " only."); } if (fieldCount > 1) @@ -170,6 +172,17 @@ public static class CultDocumentMessagePackSerialization return snapshot; } + /// Refuses a single-file snapshot this runtime cannot read, naming the format it found. + public static void RequireSingleFileFormat(CultPersistedStoreSnapshot snapshot) + { + if (!string.Equals(snapshot.FormatVersion, SingleFileFormatVersion, StringComparison.Ordinal)) + { + throw new NotSupportedException( + $"Store format {snapshot.FormatVersion} is not readable; this runtime reads {SingleFileFormatVersion} only. " + + "The store needs a runtime that resolves document variants."); + } + } + private static void WritePersistedRecord(ref MessagePackWriter writer, CultPersistedRecord record) { writer.WriteArrayHeader(PersistedRecordFieldCount); @@ -179,7 +192,7 @@ public static class CultDocumentMessagePackSerialization writer.Write(record.Payload); } - private static CultPersistedRecord ReadPersistedRecord(ref MessagePackReader reader) + public static CultPersistedRecord ReadPersistedRecord(ref MessagePackReader reader) { var fieldCount = reader.ReadArrayHeader(); var record = new CultPersistedRecord(); @@ -204,9 +217,11 @@ public static class CultDocumentMessagePackSerialization record.Payload = reader.ReadBytes()?.ToArray() ?? Array.Empty(); } - for (var index = PersistedRecordFieldCount; index < fieldCount; index++) + if (fieldCount > PersistedRecordFieldCount) { - reader.Skip(); + throw new NotSupportedException( + $"Record '{record.Key}' (schema '{record.SchemaId}') has {fieldCount} slots; this runtime reads {PersistedRecordFieldCount}. " + + "The store needs a runtime that resolves document variants."); } return record; @@ -371,7 +386,9 @@ public class SingleFileMessagePackBackingStore : SingleFileBackingStore protected override CultPersistedStoreSnapshot DeserializeSnapshot(byte[] data) { - return CultDocumentMessagePackSerialization.DeserializeSnapshot(data); + var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(data); + CultDocumentMessagePackSerialization.RequireSingleFileFormat(snapshot); + return snapshot; } protected override byte[] SerializePayload(object document) diff --git a/src/GameCult.Caching/Contracts/cultcache-persistence-format.md b/src/GameCult.Caching/Contracts/cultcache-persistence-format.md index e8231c10..4511f3c5 100644 --- a/src/GameCult.Caching/Contracts/cultcache-persistence-format.md +++ b/src/GameCult.Caching/Contracts/cultcache-persistence-format.md @@ -212,6 +212,11 @@ Example: The payload is the domain object only. It does not repeat the key, schema id, or stored timestamp. +Under `cultcache.store.v1` a record has exactly these four slots. A reader refuses any +other `cultcache.store.*` header and any record with more than four slots, naming the +version or the record's key and schema id. It never skips a slot it does not +understand. + ## Canonical Semantic Schema Hash `schemaId` should be derived from a canonical semantic schema hash. diff --git a/src/GameCult.Mesh/CultMeshSingleFileDocuments.cs b/src/GameCult.Mesh/CultMeshSingleFileDocuments.cs index 5e2295df..46bfb47e 100644 --- a/src/GameCult.Mesh/CultMeshSingleFileDocuments.cs +++ b/src/GameCult.Mesh/CultMeshSingleFileDocuments.cs @@ -236,14 +236,18 @@ namespace GameCult.Mesh private static CultPersistedStoreSnapshot ReadSingleFileSnapshot(string path) { var bytes = File.ReadAllBytes(path); + CultPersistedStoreSnapshot snapshot; try { - return CultDocumentMessagePackSerialization.DeserializeSnapshot(bytes); + snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(bytes); } catch (Exception ex) when (ex is MessagePackSerializationException or InvalidOperationException) { - return ReadLegacySingleFileSnapshot(bytes); + snapshot = ReadLegacySingleFileSnapshot(bytes); } + + CultDocumentMessagePackSerialization.RequireSingleFileFormat(snapshot); + return snapshot; } private static CultPersistedStoreSnapshot ReadLegacySingleFileSnapshot(byte[] bytes) @@ -252,7 +256,8 @@ namespace GameCult.Mesh var fieldCount = reader.ReadArrayHeader(); var snapshot = new CultPersistedStoreSnapshot(); if (fieldCount > 0) - snapshot.FormatVersion = reader.ReadString() ?? "cultcache.store.v1"; + snapshot.FormatVersion = reader.ReadString() + ?? throw new NotSupportedException("Store snapshot declares no format version."); if (fieldCount > 1) { @@ -267,7 +272,7 @@ namespace GameCult.Mesh var recordCount = reader.ReadArrayHeader(); snapshot.Records = new CultPersistedRecord[recordCount]; for (var index = 0; index < recordCount; index++) - snapshot.Records[index] = ReadLegacyPersistedRecord(ref reader); + snapshot.Records[index] = CultDocumentMessagePackSerialization.ReadPersistedRecord(ref reader); } for (var index = 3; index < fieldCount; index++) @@ -303,23 +308,6 @@ namespace GameCult.Mesh return entry; } - private static CultPersistedRecord ReadLegacyPersistedRecord(ref MessagePackReader reader) - { - var fieldCount = reader.ReadArrayHeader(); - var record = new CultPersistedRecord(); - if (fieldCount > 0) record.Key = reader.ReadString() ?? string.Empty; - if (fieldCount > 1) record.SchemaId = reader.ReadString() ?? string.Empty; - if (fieldCount > 2) record.StoredAt = reader.ReadString() ?? string.Empty; - if (fieldCount > 3) - { - var payload = reader.ReadBytes(); - record.Payload = payload.HasValue ? payload.Value.ToArray() : Array.Empty(); - } - for (var index = 4; index < fieldCount; index++) - reader.Skip(); - return record; - } - private static bool PublishesSchema(CultSchemaCatalogEntry[] catalog, string schemaId) { return catalog.Any(entry => diff --git a/tests/GameCult.Caching.Tests/PreCut2StoreFormatTests.cs b/tests/GameCult.Caching.Tests/PreCut2StoreFormatTests.cs index 6089b94e..6757a806 100644 --- a/tests/GameCult.Caching.Tests/PreCut2StoreFormatTests.cs +++ b/tests/GameCult.Caching.Tests/PreCut2StoreFormatTests.cs @@ -1,6 +1,7 @@ #nullable enable using System; using System.IO; +using System.Linq; using System.Threading.Tasks; using GameCult.Caching; using GameCult.Caching.MessagePack; @@ -83,6 +84,73 @@ namespace GameCult.Caching.Tests } } + // Shared refusal vectors: tests/vectors/document-variants-c0, read by every runtime's tests. + private const string ItemSchemaId = "sha256:88d3fdf0a927acf3b163940d8f8c7fe62b3316542ce771a67ec8bc038f594788"; + + private static string VectorPath(string name) + { + for (var dir = new DirectoryInfo(TestContext.CurrentContext.TestDirectory); dir != null; dir = dir.Parent) + { + var candidate = Path.Combine(dir.FullName, "tests", "vectors", "document-variants-c0", name); + if (File.Exists(candidate)) return candidate; + } + throw new FileNotFoundException($"Shared vector {name} not found above {TestContext.CurrentContext.TestDirectory}."); + } + + private static async Task PullRefusalAsync(string vector) + { + var root = Path.Combine(Path.GetTempPath(), $"cultlib-vector-{Guid.NewGuid():N}"); + Directory.CreateDirectory(root); + try + { + var file = Path.Combine(root, "store.msgpack"); + File.Copy(VectorPath(vector), file); + using var cache = new CultCache(); + cache.AddBackingStore(new SingleFileMessagePackBackingStore(file)); + var error = Assert.CatchAsync(async () => await cache.PullAllBackingStoresAsync()); + Assert.That(error, Is.Not.Null); + Assert.That(error!.ToString(), Does.Contain(nameof(NotSupportedException))); + return error.ToString(); + } + finally + { + if (Directory.Exists(root)) Directory.Delete(root, recursive: true); + } + } + + [Test] + public async Task SingleFileRefusesUnknownHeaderByName() + { + Assert.That(await PullRefusalAsync("unknown-header.msgpack"), Does.Contain("cultcache.store.v9")); + } + + [Test] + public async Task SingleFileRefusesExtraRecordSlotNamingTheRecord() + { + var message = await PullRefusalAsync("extra-slot-full-payload.msgpack"); + Assert.That(message, Does.Contain("item:anvil")); + Assert.That(message, Does.Contain(ItemSchemaId)); + } + + [Test] + public async Task SingleFileRefusesVariantStoreByVersionOrRecord() + { + Assert.That(await PullRefusalAsync("variant-v2.msgpack"), + Does.Contain("cultcache.store.v2").Or.Contain("item:anvil-big")); + } + + [Test] + public void V1StoreWrittenAtTheBaseCommitStillDecodesByteForByte() + { + var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(VectorPath("v1-base.msgpack"))); + CultDocumentMessagePackSerialization.RequireSingleFileFormat(snapshot); + + Assert.That(snapshot.FormatVersion, Is.EqualTo("cultcache.store.v1")); + Assert.That(snapshot.Records.Select(record => record.Key), Is.EqualTo(new[] { "alpha", "beta" })); + Assert.That(snapshot.Records[0].Payload, Is.EqualTo(new byte[] { 0x92, 0xa5, (byte)'a', (byte)'l', (byte)'p', (byte)'h', (byte)'a', 0x01 })); + Assert.That(snapshot.Records[1].Payload, Is.EqualTo(new byte[] { 0x92, 0xa4, (byte)'b', (byte)'e', (byte)'t', (byte)'a', 0x02 })); + } + // Format strings taken from DirectoryMessagePackBackingStore.cs at 0db1fe5. [TestCase("cultcache.store.v1")] [TestCase("cultcache.store.v1.directory")] diff --git a/tests/GameCult.Mesh.Tests/CultMeshVariantRefusalTests.cs b/tests/GameCult.Mesh.Tests/CultMeshVariantRefusalTests.cs new file mode 100644 index 00000000..7636cacf --- /dev/null +++ b/tests/GameCult.Mesh.Tests/CultMeshVariantRefusalTests.cs @@ -0,0 +1,47 @@ +using System; +using System.IO; +using FluentAssertions; +using GameCult.Caching; +using NUnit.Framework; + +#nullable enable + +namespace GameCult.Mesh.Tests; + +// Shared refusal vectors: tests/vectors/document-variants-c0, read by every runtime's tests. +public sealed class CultMeshVariantRefusalTests +{ + private const string ItemSchemaId = "sha256:88d3fdf0a927acf3b163940d8f8c7fe62b3316542ce771a67ec8bc038f594788"; + + private static string VectorPath(string name) + { + for (var dir = new DirectoryInfo(TestContext.CurrentContext.TestDirectory); dir != null; dir = dir.Parent) + { + var candidate = Path.Combine(dir.FullName, "tests", "vectors", "document-variants-c0", name); + if (File.Exists(candidate)) return candidate; + } + throw new FileNotFoundException($"Shared vector {name} not found above {TestContext.CurrentContext.TestDirectory}."); + } + + private static NotSupportedException Refusal(string vector) => + Assert.Throws(() => + CultMesh.ReadSingleFileDocumentPayload(VectorPath(vector), new CultRecordKey("item:bellows"), ItemSchemaId))!; + + [Test] + public void ReadSingleFileDocument_RefusesUnknownHeaderByName() => + Refusal("unknown-header.msgpack").Message.Should().Contain("cultcache.store.v9"); + + [Test] + public void ReadSingleFileDocument_RefusesExtraRecordSlotNamingTheRecord() + { + var message = Refusal("extra-slot-full-payload.msgpack").Message; + message.Should().Contain("item:anvil").And.Contain(ItemSchemaId); + } + + [Test] + public void ReadSingleFileDocument_RefusesVariantStoreByVersionOrRecord() + { + var message = Refusal("variant-v2.msgpack").Message; + (message.Contains("cultcache.store.v2") || message.Contains("item:anvil-big")).Should().BeTrue(message); + } +}