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); + } +}