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 <noreply@anthropic.com>
This commit is contained in:
parent
2cb694d612
commit
98ed042fcc
6 changed files with 153 additions and 26 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/// <summary>Refuses a single-file snapshot this runtime cannot read, naming the format it found.</summary>
|
||||
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<byte>();
|
||||
}
|
||||
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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<byte>();
|
||||
}
|
||||
for (var index = 4; index < fieldCount; index++)
|
||||
reader.Skip();
|
||||
return record;
|
||||
}
|
||||
|
||||
private static bool PublishesSchema(CultSchemaCatalogEntry[] catalog, string schemaId)
|
||||
{
|
||||
return catalog.Any(entry =>
|
||||
|
|
|
|||
|
|
@ -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<string> 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")]
|
||||
|
|
|
|||
47
tests/GameCult.Mesh.Tests/CultMeshVariantRefusalTests.cs
Normal file
47
tests/GameCult.Mesh.Tests/CultMeshVariantRefusalTests.cs
Normal file
|
|
@ -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<NotSupportedException>(() =>
|
||||
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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue