diff --git a/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs b/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs
index 51193806..46bd8de8 100644
--- a/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs
+++ b/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs
@@ -55,9 +55,12 @@ public static class CultDocumentMessagePackSerialization
private static MessagePackSerializerOptions Compose(IFormatterResolver[] consumerResolvers)
{
+ // The guard goes first and wraps whatever formatter the rest of the composite would hand out, so a consumer
+ // resolver still encodes an arm while the key check runs.
+ var inner = CompositeResolver.Create(
+ consumerResolvers.Append(CultDocumentResolver.Instance).Append(CultMathResolver.Instance).Append(StandardResolver.Instance).ToArray());
return MessagePackSerializerOptions.Standard
- .WithResolver(CompositeResolver.Create(
- consumerResolvers.Append(CultDocumentResolver.Instance).Append(CultMathResolver.Instance).Append(StandardResolver.Instance).ToArray()))
+ .WithResolver(CompositeResolver.Create(new CultUnionGuard(inner), inner))
.WithSecurity(CultMessagePackSecurity.Instance);
}
diff --git a/src/GameCult.Caching.MessagePack/CultUnionGuard.cs b/src/GameCult.Caching.MessagePack/CultUnionGuard.cs
new file mode 100644
index 00000000..baafa6a5
--- /dev/null
+++ b/src/GameCult.Caching.MessagePack/CultUnionGuard.cs
@@ -0,0 +1,47 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Reflection;
+using MessagePack;
+using MessagePack.Formatters;
+
+namespace GameCult.Caching.MessagePack;
+
+///
+/// The one place an unknown union arm or an unnamed subtype is decided. A union is an abstract class or interface carrying
+/// its own [Union] attributes (never inherited: a union whose arms are also a union has its own key set). MessagePack-CSharp
+/// still encodes the [key, armSlots] shape; this wraps its formatter so that a key the union does not declare is refused on
+/// read, and a value whose runtime type is not a declared arm is refused on write, where MessagePack alone yields null and nil.
+///
+internal sealed class CultUnionGuard(IFormatterResolver inner) : IFormatterResolver
+{
+ public IMessagePackFormatter? GetFormatter()
+ {
+ var arms = typeof(T).GetCustomAttributes(inherit: false).ToArray();
+ return arms.Length == 0 || inner.GetFormatter() is not { } formatter ? null : new Formatter(formatter, arms);
+ }
+
+ private sealed class Formatter(IMessagePackFormatter inner, UnionAttribute[] arms) : IMessagePackFormatter
+ {
+ private readonly HashSet keys = arms.Select(arm => arm.Key).ToHashSet();
+ private readonly HashSet types = arms.Select(arm => arm.SubType).ToHashSet();
+
+ public void Serialize(ref MessagePackWriter writer, T value, MessagePackSerializerOptions options)
+ {
+ if (value != null && !types.Contains(value.GetType()))
+ throw new MessagePackSerializationException($"{value.GetType().FullName} is not an arm of union {typeof(T).FullName}; it would be written as nil.");
+ inner.Serialize(ref writer, value, options);
+ }
+
+ public T Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options)
+ {
+ // Peek only the arm's first two tokens; any other shape is the inner formatter's to refuse.
+ var peek = reader.CreatePeekReader();
+ if (!peek.TryReadNil() && peek.NextMessagePackType == MessagePackType.Array && peek.ReadArrayHeader() == 2 &&
+ peek.NextMessagePackType == MessagePackType.Integer && peek.ReadInt32() is var key && !keys.Contains(key))
+ throw new MessagePackSerializationException(
+ $"Union {typeof(T).FullName} has no arm for key {key} (slot 0 of the arm); declared keys: {string.Join(", ", keys.OrderBy(k => k))}.");
+ return inner.Deserialize(ref reader, options);
+ }
+ }
+}
diff --git a/src/GameCult.Caching/CultCache.cs b/src/GameCult.Caching/CultCache.cs
index 6365261f..a347fd58 100644
--- a/src/GameCult.Caching/CultCache.cs
+++ b/src/GameCult.Caching/CultCache.cs
@@ -3246,7 +3246,18 @@ namespace GameCult.Caching
null);
}
- var document = deserializePayload(resolution.Descriptor.DocumentType, record.Payload);
+ object document;
+ try
+ {
+ document = deserializePayload(resolution.Descriptor.DocumentType, record.Payload);
+ }
+ catch (Exception exception) when (exception is not OperationCanceledException)
+ {
+ // The codec knows what it refused but not which record; the store does. Say both, and never null the record.
+ var causes = new List();
+ for (var cause = exception; cause != null; cause = cause.InnerException) causes.Add(cause.Message);
+ throw new InvalidDataException($"Record '{record.Key}' (schema '{record.SchemaId}') cannot be decoded: {string.Join(" <- ", causes)}", exception);
+ }
return new CultStoredDocument(
new CultRecordKey(record.Key),
record.StoredAt,
diff --git a/tests/GameCult.Caching.Tests/AssemblyInfo.cs b/tests/GameCult.Caching.Tests/AssemblyInfo.cs
index f3a1dfa1..b6810a8d 100644
--- a/tests/GameCult.Caching.Tests/AssemblyInfo.cs
+++ b/tests/GameCult.Caching.Tests/AssemblyInfo.cs
@@ -5,6 +5,7 @@ using MessagePack;
using MessagePack.Formatters;
[assembly: CultCacheFormatterResolver(typeof(PairResolver))]
+[assembly: CultCacheFormatterResolver(typeof(UnionArmGuardTests.StubArmResolver))]
namespace GameCult.Caching.Tests
{
diff --git a/tests/GameCult.Caching.Tests/UnionArmGuardTests.cs b/tests/GameCult.Caching.Tests/UnionArmGuardTests.cs
new file mode 100644
index 00000000..4cca49a5
--- /dev/null
+++ b/tests/GameCult.Caching.Tests/UnionArmGuardTests.cs
@@ -0,0 +1,383 @@
+#nullable enable
+using System;
+using System.Buffers;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Threading.Tasks;
+using GameCult.Caching;
+using GameCult.Caching.MessagePack;
+using MessagePack;
+using MessagePack.Formatters;
+using NUnit.Framework;
+
+namespace GameCult.Caching.Tests
+{
+ // The contract is the Unions section of src/GameCult.Caching/Contracts/cultcache-schema-compatibility.md: an arm is
+ // [key, armSlots], keys are the identity and never reused, an unknown arm is refused on read, an unnamed subtype on write.
+ // Expected bytes are written by hand with MessagePackWriter, not captured from the code under test.
+ public class UnionArmGuardTests
+ {
+ private const string Canary = "CANARY-9d1e47";
+
+ private static readonly CultDocumentRegistry Registry = CultDocumentRegistry.ForTypes(new[] { typeof(GuardDoc) });
+
+ private delegate void Emit(ref MessagePackWriter writer);
+
+ private static byte[] Raw(Emit emit)
+ {
+ var buffer = new ArrayBufferWriter();
+ var writer = new MessagePackWriter(buffer);
+ emit(ref writer);
+ writer.Flush();
+ return buffer.WrittenSpan.ToArray();
+ }
+
+ private static readonly Emit Nil = (ref MessagePackWriter w) => w.WriteNil();
+
+ // Leaf: slots [id, content, nil (retired 2), weight].
+ private static Emit Leaf(int key, string id, string content, int weight) => (ref MessagePackWriter w) =>
+ {
+ w.WriteArrayHeader(2); w.Write(key);
+ w.WriteArrayHeader(4); w.Write(id); w.Write(content); w.WriteNil(); w.Write(weight);
+ };
+
+ private static Emit Unit(int key) => (ref MessagePackWriter w) => { w.WriteArrayHeader(2); w.Write(key); w.WriteArrayHeader(0); };
+
+ // Branch: slots [children, next].
+ private static Emit Branch(int key, Emit next, params Emit[] children) => (ref MessagePackWriter w) =>
+ {
+ w.WriteArrayHeader(2); w.Write(key);
+ w.WriteArrayHeader(2);
+ w.WriteArrayHeader(children.Length);
+ foreach (var child in children) child(ref w);
+ next(ref w);
+ };
+
+ private static Emit List(params Emit[] items) => (ref MessagePackWriter w) =>
+ {
+ w.WriteArrayHeader(items.Length);
+ foreach (var item in items) item(ref w);
+ };
+
+ // InnerA / InnerB: slots [n]. Inner and Outer both list them, under their own keys.
+ private static Emit InnerArm(int key, int n) => (ref MessagePackWriter w) =>
+ {
+ w.WriteArrayHeader(2); w.Write(key);
+ w.WriteArrayHeader(1); w.Write(n);
+ };
+
+ // GuardDoc: slots [id, root, items, crafted, any].
+ private static byte[] Doc(Emit root, Emit? items = null, Emit? crafted = null, Emit? any = null) => Raw((ref MessagePackWriter w) =>
+ {
+ w.WriteArrayHeader(5);
+ w.Write("d1");
+ root(ref w);
+ (items ?? List())(ref w);
+ (crafted ?? Nil)(ref w);
+ (any ?? Nil)(ref w);
+ });
+
+ private static Exception Decode(byte[] payload)
+ {
+ try
+ {
+ CultDocumentMessagePackSerialization.DeserializeUntyped(typeof(GuardDoc), payload, Registry);
+ }
+ catch (Exception exception)
+ {
+ return exception;
+ }
+
+ Assert.Fail("The payload was accepted.");
+ return null!;
+ }
+
+ private static string Chain(Exception exception)
+ {
+ var parts = new List();
+ for (var cause = exception; cause != null; cause = cause.InnerException) parts.Add(cause.Message);
+ return string.Join(" <- ", parts);
+ }
+
+ private static void AssertRefused(byte[] payload, string union, int key, string declared)
+ {
+ var message = Chain(Decode(payload));
+ Assert.Multiple(() =>
+ {
+ Assert.That(message, Does.Contain(union));
+ Assert.That(message, Does.Contain($"key {key} "));
+ Assert.That(message, Does.Contain($"declared keys: {declared}."));
+ Assert.That(message, Does.Not.Contain(Canary), "a refusal never echoes the payload");
+ });
+ }
+
+ private static readonly string NodeName = typeof(GuardNode).FullName!;
+
+ // Keys the union does not declare: below the first, in a retired gap, and one past the last.
+ [TestCase(0)]
+ [TestCase(2)]
+ [TestCase(4)]
+ [TestCase(7)]
+ [TestCase(10)]
+ public void UnknownArmInFieldRefused(int key) =>
+ AssertRefused(Doc(Leaf(key, "r", Canary, 7)), NodeName, key, "3, 5, 8, 9");
+
+ [TestCase(0)]
+ [TestCase(4)]
+ [TestCase(10)]
+ public void UnknownArmInListRefused(int key) =>
+ AssertRefused(Doc(Leaf(3, "r", "ok", 1), List(Unit(8), Leaf(key, "i", Canary, 7))), NodeName, key, "3, 5, 8, 9");
+
+ // Inside an arm's own list, and inside an arm's own field: the union nested under a union.
+ [TestCase(4)]
+ [TestCase(10)]
+ public void UnknownArmNestedInArmRefused(int key)
+ {
+ AssertRefused(Doc(Branch(5, Nil, Unit(8), Leaf(key, "c", Canary, 7))), NodeName, key, "3, 5, 8, 9");
+ AssertRefused(Doc(Branch(5, Leaf(key, "n", Canary, 7))), NodeName, key, "3, 5, 8, 9");
+ AssertRefused(Doc(Branch(5, Nil, Branch(5, Nil, Branch(5, Nil, Leaf(key, "deep", Canary, 7))))), NodeName, key, "3, 5, 8, 9");
+ }
+
+ // A union whose arms are also a union has its own key set: 7 is Outer's (OuterOnly) and not Inner's.
+ [Test]
+ public void InnerUnionUsesItsOwnKeys()
+ {
+ var innerName = typeof(Inner).FullName!;
+ var outerName = typeof(Outer).FullName!;
+ AssertRefused(Doc(Nil, crafted: InnerArm(7, 1)), innerName, 7, "0, 1");
+ AssertRefused(Doc(Nil, crafted: InnerArm(2, 1)), innerName, 2, "0, 1");
+
+ var accepted = (GuardDoc)CultDocumentMessagePackSerialization.DeserializeUntyped(
+ typeof(GuardDoc), Doc(Nil, crafted: InnerArm(1, 5), any: InnerArm(7, 3)), Registry);
+ Assert.That(accepted.Crafted, Is.TypeOf());
+ Assert.That(accepted.Any, Is.TypeOf());
+ AssertRefused(Doc(Nil, any: InnerArm(3, 1)), outerName, 3, "0, 1, 2, 7");
+ }
+
+ [Test]
+ public void ArmOfAnotherShapeIsTheInnerFormattersToRefuse()
+ {
+ // Not a 2-array: the guard decides keys only, so MessagePack keeps its own refusals and still never echoes.
+ var notArray = Doc((ref MessagePackWriter w) => w.Write(Canary));
+ var arity = Doc((ref MessagePackWriter w) => { w.WriteArrayHeader(3); w.Write(3); w.WriteNil(); w.WriteNil(); });
+ Assert.That(Chain(Decode(notArray)), Does.Not.Contain(Canary));
+ Assert.That(Chain(Decode(arity)), Does.Contain("Invalid Union data"));
+ }
+
+ [Test]
+ public void UnnamedSubtypeWriteRefused()
+ {
+ Assert.Multiple(() =>
+ {
+ foreach (var doc in new[]
+ {
+ new GuardDoc { Root = new Rogue() },
+ new GuardDoc { Items = { new Unit8(), new Rogue() } },
+ new GuardDoc { Root = new Branch { Children = { new Rogue() } } },
+ new GuardDoc { Root = new Branch { Next = new Rogue() } },
+ new GuardDoc { Root = new DerivedLeaf() },
+ new GuardDoc { Crafted = new Unlisted() },
+ })
+ {
+ var message = Chain(Assert.Catch(
+ () => CultDocumentMessagePackSerialization.SerializeUntyped(doc, typeof(GuardDoc), Registry))!);
+ Assert.That(message, Does.Contain("is not an arm of union"));
+ }
+ });
+ }
+
+ [Test]
+ public void NullUnionRoundTrips()
+ {
+ var bytes = Doc(Nil, List(Nil, Unit(8), Nil));
+ var decoded = (GuardDoc)CultDocumentMessagePackSerialization.DeserializeUntyped(typeof(GuardDoc), bytes, Registry);
+ Assert.That(decoded.Root, Is.Null);
+ Assert.That(decoded.Items, Has.Count.EqualTo(3));
+ Assert.That(decoded.Items[0], Is.Null);
+ Assert.That(decoded.Items[2], Is.Null);
+ Assert.That(CultDocumentMessagePackSerialization.SerializeUntyped(decoded, typeof(GuardDoc), Registry), Is.EqualTo(bytes));
+ }
+
+ private static GuardDoc KnownArms() => new()
+ {
+ Id = "d1",
+ Root = new Branch
+ {
+ Children =
+ {
+ new Leaf { Id = "a", Content = "x", Weight = 1 },
+ new Unit8(),
+ new Branch { Children = { new Branch { Children = { new Leaf { Id = "deep", Content = "y", Weight = 2 } } } } }
+ },
+ Next = new Leaf { Id = "n", Content = "z", Weight = 3 }
+ },
+ Items = { new Unit8(), new Leaf { Id = "i", Content = "w", Weight = 4 }, null! },
+ Crafted = new InnerB { N = 9 },
+ Any = new InnerA { N = 8 }
+ };
+
+ private static byte[] KnownArmBytes() => Doc(
+ Branch(5, Leaf(3, "n", "z", 3),
+ Leaf(3, "a", "x", 1),
+ Unit(8),
+ Branch(5, Nil, Branch(5, Nil, Leaf(3, "deep", "y", 2)))),
+ List(Unit(8), Leaf(3, "i", "w", 4), Nil),
+ InnerArm(1, 9),
+ InnerArm(1, 8));
+
+ // Non-zero-start keys with retired gaps, a unit arm, a union inside an arm four deep: the reference shape, unchanged.
+ [Test]
+ public void KnownArmsRoundTripByteIdentical()
+ {
+ var expected = KnownArmBytes();
+ var written = CultDocumentMessagePackSerialization.SerializeUntyped(KnownArms(), typeof(GuardDoc), Registry);
+ Assert.That(Convert.ToHexString(written), Is.EqualTo(Convert.ToHexString(expected)));
+ Assert.That(Convert.ToHexString(MessagePackSerializer.Serialize(KnownArms(), MessagePackSerializerOptions.Standard)), Is.EqualTo(Convert.ToHexString(expected)),
+ "MessagePack alone writes the same bytes");
+
+ var decoded = CultDocumentMessagePackSerialization.DeserializeUntyped(typeof(GuardDoc), expected, Registry);
+ Assert.That(CultDocumentMessagePackSerialization.SerializeUntyped(decoded, typeof(GuardDoc), Registry), Is.EqualTo(expected));
+ }
+
+ [Test]
+ public void ConsumerResolverStillEncodesArms()
+ {
+ var bytes = CultDocumentMessagePackSerialization.SerializeUntyped(new GuardDoc { Id = "d1", Root = new StubArm() }, typeof(GuardDoc), Registry);
+ Assert.That(Convert.ToHexString(bytes), Is.EqualTo(Convert.ToHexString(Doc((ref MessagePackWriter w) =>
+ {
+ w.WriteArrayHeader(2); w.Write(9); w.WriteArrayHeader(1); w.Write("via-resolver");
+ }))));
+ var back = (GuardDoc)CultDocumentMessagePackSerialization.DeserializeUntyped(typeof(GuardDoc), bytes, Registry);
+ Assert.That(back.Root, Is.TypeOf());
+ }
+
+ // A store holding an unknown arm fails to load and names the record, its schema, the union and the key; the genuine
+ // payload is replaced in the file by one that differs only in the arm key.
+ [TestCase(false, 4)]
+ [TestCase(false, 10)]
+ [TestCase(true, 4)]
+ [TestCase(true, 10)]
+ public async Task StorePullNamesTheRecord(bool directory, int key)
+ {
+ var folder = Path.Combine(Path.GetTempPath(), $"cultlib-unionguard-{Guid.NewGuid():N}");
+ Directory.CreateDirectory(folder);
+ try
+ {
+ var path = Path.Combine(folder, "guard.cc");
+ var genuine = Doc(Leaf(3, "r", Canary, 7));
+ using (var writer = CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = Registry, UseDirectoryStore = directory, StoreFlushOnDispose = true }))
+ {
+ await writer.UpsertAsync(typeof(GuardDoc), new GuardDoc { Id = "d1", Root = new Leaf { Id = "r", Content = Canary, Weight = 7 } }, new CultRecordKey("d1"));
+ await writer.FlushAsync();
+ }
+
+ var forged = Doc(Leaf(key, "r", Canary, 7));
+ Assert.That(forged.Length, Is.EqualTo(genuine.Length));
+ var patched = 0;
+ foreach (var file in Directory.EnumerateFiles(folder, "*", SearchOption.AllDirectories))
+ {
+ var bytes = File.ReadAllBytes(file);
+ var at = bytes.AsSpan().IndexOf(genuine);
+ if (at < 0) continue;
+ forged.CopyTo(bytes, at);
+ File.WriteAllBytes(file, bytes);
+ patched++;
+ }
+
+ Assert.That(patched, Is.GreaterThan(0), "the genuine payload was found in the store");
+ var thrown = Assert.CatchAsync(async () =>
+ {
+ using var cache = await CultCacheMessagePack.OpenAsync(path, new CultCacheOpenOptions { Registry = Registry, UseDirectoryStore = directory, ReadOnly = true });
+ });
+ var message = Chain(thrown!);
+ Assert.Multiple(() =>
+ {
+ Assert.That(message, Does.Contain("Record 'd1'"));
+ Assert.That(message, Does.Contain("tests.guard_doc.v1"));
+ Assert.That(message, Does.Contain(NodeName));
+ Assert.That(message, Does.Contain($"key {key} "));
+ Assert.That(message, Does.Not.Contain(Canary));
+ });
+ }
+ finally
+ {
+ Directory.Delete(folder, recursive: true);
+ }
+ }
+
+ // An arm is [key, armSlots]; the keys here start at 3, skip 0-2, 4, 6 and 7, and never reuse a retired key.
+ [Union(3, typeof(Leaf)), Union(5, typeof(Branch)), Union(8, typeof(Unit8)), Union(9, typeof(StubArm))]
+ public abstract class GuardNode { }
+
+ [MessagePackObject]
+ public class Leaf : GuardNode
+ {
+ [Key(0)] public string Id = string.Empty;
+ [Key(1)] public string Content = string.Empty;
+ [Key(3)] public int Weight;
+ }
+
+ // A subtype of an arm is not the arm: MessagePack would write it as nil.
+ [MessagePackObject] public sealed class DerivedLeaf : Leaf { }
+
+ [MessagePackObject]
+ public sealed class Branch : GuardNode
+ {
+ [Key(0)] public List Children = new();
+ [Key(1)] public GuardNode? Next;
+ }
+
+ [MessagePackObject] public sealed class Unit8 : GuardNode { }
+
+ // A subtype the union does not name.
+ [MessagePackObject] public sealed class Rogue : GuardNode { [Key(0)] public string Note = string.Empty; }
+
+ // Written and read by a consumer resolver, not by MessagePack.
+ public sealed class StubArm : GuardNode { }
+
+ public sealed class StubArmResolver : IFormatterResolver
+ {
+ public static readonly IFormatterResolver Instance = new StubArmResolver();
+ public IMessagePackFormatter? GetFormatter() => typeof(T) == typeof(StubArm) ? (IMessagePackFormatter)(object)new StubFormatter() : null;
+
+ private sealed class StubFormatter : IMessagePackFormatter
+ {
+ public void Serialize(ref MessagePackWriter writer, StubArm value, MessagePackSerializerOptions options)
+ {
+ writer.WriteArrayHeader(1);
+ writer.Write("via-resolver");
+ }
+
+ public StubArm Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options)
+ {
+ reader.Skip();
+ return new StubArm();
+ }
+ }
+ }
+
+ [Union(0, typeof(OuterA)), Union(1, typeof(InnerA)), Union(2, typeof(InnerB)), Union(7, typeof(OuterOnly))]
+ public abstract class Outer { }
+
+ [Union(0, typeof(InnerA)), Union(1, typeof(InnerB))]
+ public abstract class Inner : Outer { }
+
+ [MessagePackObject] public sealed class OuterA : Outer { }
+ [MessagePackObject] public sealed class OuterOnly : Outer { [Key(0)] public int N; }
+ [MessagePackObject] public sealed class InnerA : Inner { [Key(0)] public int N; }
+ [MessagePackObject] public sealed class InnerB : Inner { [Key(0)] public int N; }
+ [MessagePackObject] public sealed class Unlisted : Inner { }
+
+ [CultDocument("tests.guard_doc", "tests.guard_doc.v1")]
+ [MessagePackObject]
+ public sealed class GuardDoc
+ {
+ [Key(0)] [CultName] public string Id = string.Empty;
+ [Key(1)] public GuardNode? Root;
+ [Key(2)] public List Items = new();
+ [Key(3)] public Inner? Crafted;
+ [Key(4)] public Outer? Any;
+ }
+ }
+}