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..af7a63c2 --- /dev/null +++ b/src/GameCult.Caching.MessagePack/CultUnionGuard.cs @@ -0,0 +1,72 @@ +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) + { + var declared = string.Join(", ", keys.OrderBy(k => k)); + if (peek.NextMessagePackType != MessagePackType.Integer) + throw new MessagePackSerializationException($"Union {typeof(T).FullName} has a non-integer arm key (slot 0 of the arm); declared keys: {declared}."); + if (!TryReadKey(ref peek, out var key)) + throw new MessagePackSerializationException($"Union {typeof(T).FullName} has an arm key outside Int32 (slot 0 of the arm); declared keys: {declared}."); + if (!keys.Contains(key)) + throw new MessagePackSerializationException($"Union {typeof(T).FullName} has no arm for key {key} (slot 0 of the arm); declared keys: {declared}."); + } + + return inner.Deserialize(ref reader, options); + } + + // An arm key is an Int32; a wider or unsigned-64 integer is out of range, never an OverflowException. + private static bool TryReadKey(ref MessagePackReader reader, out int key) + { + key = 0; + if (reader.NextCode == MessagePackCode.UInt64) + { + var wide = reader.ReadUInt64(); + if (wide > int.MaxValue) return false; + key = (int)wide; + return true; + } + + var value = reader.ReadInt64(); + if (value < int.MinValue || value > int.MaxValue) return false; + key = (int)value; + return true; + } + } +} diff --git a/src/GameCult.Caching/Contracts/cultcache-schema-compatibility.md b/src/GameCult.Caching/Contracts/cultcache-schema-compatibility.md index 9ae6f553..f44ff667 100644 --- a/src/GameCult.Caching/Contracts/cultcache-schema-compatibility.md +++ b/src/GameCult.Caching/Contracts/cultcache-schema-compatibility.md @@ -73,6 +73,27 @@ Typed lookups and watches (`Get`, `GetAll`, `GetByName`, type is assignable to `T`. A single-result lookup with several candidates throws. +## Unions + +A union is an abstract class or interface carrying `[Union(key, typeof(Arm))]` +attributes on itself (a union whose arms are also a union has its own key set; +attributes are not inherited). A union value is `nil` for a null reference, and +otherwise an arm: the two-element array `[key, armSlots]`, where `armSlots` is the +arm's own slot array (a unit arm is `[key, []]`). Keys start anywhere and may +have gaps. + +- A key is the identity of an arm. It is never reused, and a retired arm's key + stays a gap. +- An arm key the reader's union does not declare is refused on read, in a field, + a list or an arm, with an error naming the union type and the key. The + payload is never echoed, and the value is never read back as `null`: a runtime + that cannot name an arm cannot re-encode it. +- A value whose runtime type is not exactly a declared arm is refused on write, + naming the union and the type. A subtype of an arm is not the arm. +- A store that holds a refused arm fails to load, and the error names the record + key and its schema id. +- Arm slots follow the slot rules below; this rule does not change them. + ## Soft-migratable drift CultCache accepts compatible drift only when the local reader can still map the diff --git a/src/GameCult.Caching/CultCache.cs b/src/GameCult.Caching/CultCache.cs index 6365261f..3de040b9 100644 --- a/src/GameCult.Caching/CultCache.cs +++ b/src/GameCult.Caching/CultCache.cs @@ -2819,7 +2819,7 @@ namespace GameCult.Caching { // A value that no longer decodes refuses exactly as a plain record holding it does. throw new InvalidOperationException( - $"Variant {key} overrides '{member.Member.Name}' (slot {slot}) with a value that does not decode as {member.MemberType.Name}: " + + $"Variant {key} (schema '{descriptor.SchemaId}') overrides '{member.Member.Name}' (slot {slot}) with a value that does not decode as {member.MemberType.Name}: " + exception.GetBaseException().Message, exception); } @@ -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.Distinct())}", 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..0dd8b125 100644 --- a/tests/GameCult.Caching.Tests/AssemblyInfo.cs +++ b/tests/GameCult.Caching.Tests/AssemblyInfo.cs @@ -5,6 +5,8 @@ using MessagePack; using MessagePack.Formatters; [assembly: CultCacheFormatterResolver(typeof(PairResolver))] +[assembly: CultCacheFormatterResolver(typeof(UnionArmGuardTests.StubArmResolver))] +[assembly: CultCacheFormatterResolver(typeof(UnionArmGuardTests.OrderResolver))] 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..df368bdd --- /dev/null +++ b/tests/GameCult.Caching.Tests/UnionArmGuardTests.cs @@ -0,0 +1,655 @@ +#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), typeof(ShapeDoc) }); + + 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 LeafArm(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 UnitArm(int key) => (ref MessagePackWriter w) => { w.WriteArrayHeader(2); w.Write(key); w.WriteArrayHeader(0); }; + + // Branch: slots [children, next]. + private static Emit BranchArm(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(LeafArm(key, "r", Canary, 7)), NodeName, key, "3, 5, 8, 9"); + + [TestCase(0)] + [TestCase(4)] + [TestCase(10)] + public void UnknownArmInListRefused(int key) => + AssertRefused(Doc(LeafArm(3, "r", "ok", 1), List(UnitArm(8), LeafArm(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(BranchArm(5, Nil, UnitArm(8), LeafArm(key, "c", Canary, 7))), NodeName, key, "3, 5, 8, 9"); + AssertRefused(Doc(BranchArm(5, LeafArm(key, "n", Canary, 7))), NodeName, key, "3, 5, 8, 9"); + AssertRefused(Doc(BranchArm(5, Nil, BranchArm(5, Nil, BranchArm(5, Nil, LeafArm(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, UnitArm(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( + BranchArm(5, LeafArm(3, "n", "z", 3), + LeafArm(3, "a", "x", 1), + UnitArm(8), + BranchArm(5, Nil, BranchArm(5, Nil, LeafArm(3, "deep", "y", 2)))), + List(UnitArm(8), LeafArm(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()); + } + + // The guard wraps the composite, so the composite's own order is still the contract: a consumer resolver outranks the + // document resolver (CultRecordRef) and MessagePack's own object formatter, here for a type it also knows. + [Test] + public void ConsumerResolversOutrankTheRestOfTheComposite() + { + var options = CultDocumentMessagePackSerialization.OptionsFor(typeof(GuardDoc).Assembly); + Assert.That(MessagePackSerializer.Serialize(new Overridden { N = 1 }, options), Is.EqualTo(Raw((ref MessagePackWriter w) => w.Write("consumer")))); + Assert.That(MessagePackSerializer.Serialize(new CultRecordRef(new CultRecordKey("k")), options), Is.EqualTo(Raw((ref MessagePackWriter w) => w.Write("consumer-ref")))); + } + + // A consumer resolver may format the union type itself. The guard wraps whatever the rest of the composite hands out, + // so that formatter is used and the key check still runs ahead of it. + [Test] + public void ConsumerFormatterForTheUnionItselfIsWrappedNotReplaced() + { + var options = CultDocumentMessagePackSerialization.OptionsFor(typeof(GuardDoc).Assembly); + Assert.That(MessagePackSerializer.Serialize(new CustomArm(), options), Is.EqualTo(Raw((ref MessagePackWriter w) => w.Write("consumer-union")))); + Assert.Throws(() => MessagePackSerializer.Deserialize( + Raw((ref MessagePackWriter w) => { w.WriteArrayHeader(2); w.Write(5); w.WriteArrayHeader(0); }), options)); + Assert.Throws(() => MessagePackSerializer.Serialize(new CustomRogue(), options)); + } + + [Union(0, typeof(CustomArm))] public abstract class Custom { } + [MessagePackObject] public sealed class CustomArm : Custom { } + [MessagePackObject] public sealed class CustomRogue : Custom { } + + // The guard asks for a union's own attributes only. MessagePack declares UnionAttribute not inherited, so an inner union + // can never see its outer union's keys through the base class; this pins that fact, on which the explicit false rests. + [Test] + public void UnionAttributeIsNotInheritedSoAUnionOwnsItsKeys() + { + Assert.That(typeof(UnionAttribute).GetCustomAttributes(typeof(AttributeUsageAttribute), false).Cast().Single().Inherited, Is.False); + Assert.That(typeof(Inner).GetCustomAttributes(typeof(UnionAttribute), true).Length, Is.EqualTo(2)); + } + + public sealed class OrderResolver : IFormatterResolver + { + public static readonly IFormatterResolver Instance = new OrderResolver(); + + public IMessagePackFormatter? GetFormatter() => + typeof(T) == typeof(Overridden) ? (IMessagePackFormatter)(object)new Fixed(new Overridden(), "consumer") + : typeof(T) == typeof(Custom) ? (IMessagePackFormatter)(object)new Fixed(new CustomArm(), "consumer-union") + : typeof(T) == typeof(CultRecordRef) ? (IMessagePackFormatter)(object)new Fixed>(default, "consumer-ref") + : null; + + private sealed class Fixed(V value, string text) : IMessagePackFormatter + { + public void Serialize(ref MessagePackWriter writer, V _, MessagePackSerializerOptions options) => writer.Write(text); + public V Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options) { reader.Skip(); return value; } + } + } + + [MessagePackObject] public sealed class Overridden { [Key(0)] public int N; } + + // An arm key outside Int32 or not an integer is the guard's own refusal, naming the union and the arm position, not an + // OverflowException from reading it and not an echo of the key or the payload. + private static Emit KeyedArm(Emit key) => (ref MessagePackWriter w) => + { + w.WriteArrayHeader(2); key(ref w); + w.WriteArrayHeader(1); w.Write(Canary); + }; + + [TestCase(2147483648L, "2147483648")] + [TestCase(-2147483649L, "2147483649")] + [TestCase(4294967295L, "4294967295")] + public void ArmKeyOutsideInt32IsRefusedNamingTheUnion(long key, string digits) => + AssertKeyRefused(Doc(KeyedArm((ref MessagePackWriter w) => w.Write(key))), "outside Int32", digits); + + [Test] + public void ArmKeyOfUint64MaxIsRefusedNamingTheUnion() => + AssertKeyRefused(Doc(KeyedArm((ref MessagePackWriter w) => w.Write(ulong.MaxValue))), "outside Int32", "18446744073709551615"); + + [Test] + public void NegativeArmKeyInInt32IsAnUnknownKey() => + AssertRefused(Doc(KeyedArm((ref MessagePackWriter w) => w.Write(-5))), NodeName, -5, "3, 5, 8, 9"); + + [Test] + public void NonIntegerArmKeyIsRefusedNamingTheUnion() + { + AssertKeyRefused(Doc(KeyedArm((ref MessagePackWriter w) => w.Write("three"))), "non-integer", "three"); + AssertKeyRefused(Doc(KeyedArm((ref MessagePackWriter w) => w.Write(3.5))), "non-integer", "3.5"); + AssertKeyRefused(Doc(KeyedArm((ref MessagePackWriter w) => w.WriteNil())), "non-integer", "nil"); + } + + // The bounds of Int32 are in range (an unknown key, not an out-of-range one), whatever width encodes them. + [Test] + public void ArmKeysAtTheInt32BoundsAreUnknownKeysNotOutOfRange() + { + AssertRefused(Doc(KeyedArm((ref MessagePackWriter w) => w.Write(int.MaxValue))), NodeName, int.MaxValue, "3, 5, 8, 9"); + AssertRefused(Doc(KeyedArm((ref MessagePackWriter w) => w.Write(int.MinValue))), NodeName, int.MinValue, "3, 5, 8, 9"); + AssertRefused(Doc(KeyedArm((ref MessagePackWriter w) => w.WriteInt64(int.MinValue))), NodeName, int.MinValue, "3, 5, 8, 9"); + AssertRefused(Doc(KeyedArm((ref MessagePackWriter w) => w.WriteUInt64(int.MaxValue))), NodeName, int.MaxValue, "3, 5, 8, 9"); + AssertKeyRefused(Doc(KeyedArm((ref MessagePackWriter w) => w.WriteUInt64((ulong)int.MaxValue + 1))), "outside Int32", "2147483648"); + AssertKeyRefused(Doc(KeyedArm((ref MessagePackWriter w) => w.WriteInt64(int.MinValue - 1L))), "outside Int32", "2147483649"); + } + + // A known key written at full width is the same key: the guard lets it through to the arm. + [Test] + public void KnownArmKeyAtFullWidthIsAccepted() + { + foreach (var key in new Emit[] { (ref MessagePackWriter w) => w.WriteUInt64(3), (ref MessagePackWriter w) => w.WriteInt64(3) }) + { + var payload = Doc((ref MessagePackWriter w) => + { + w.WriteArrayHeader(2); key(ref w); + w.WriteArrayHeader(4); w.Write("r"); w.Write("ok"); w.WriteNil(); w.Write(1); + }); + var decoded = (GuardDoc)CultDocumentMessagePackSerialization.DeserializeUntyped(typeof(GuardDoc), payload, Registry); + Assert.That(decoded.Root, Is.TypeOf()); + } + } + + private static void AssertKeyRefused(byte[] payload, string shape, string echoed) + { + var exception = Decode(payload); + var message = Chain(exception); + Assert.Multiple(() => + { + Assert.That(message, Does.Contain($"Union {NodeName} has")); + Assert.That(message, Does.Contain(shape)); + Assert.That(message, Does.Contain("(slot 0 of the arm)")); + Assert.That(message, Does.Contain("declared keys: 3, 5, 8, 9.")); + Assert.That(message, Does.Not.Contain(Canary)); + Assert.That(message, Does.Not.Contain(echoed), "the offending key is not echoed"); + for (var cause = exception; cause != null; cause = cause.InnerException) + Assert.That(cause, Is.Not.InstanceOf()); + }); + } + + // An interface is a union like an abstract class: the guard reads the interface's own [Union] attributes. + private static readonly string ShapeName = typeof(IShape).FullName!; + + // ShapeDoc: slots [id, shape, shapes]. + private static byte[] Shapes(Emit shape, Emit? shapes = null) => Raw((ref MessagePackWriter w) => + { + w.WriteArrayHeader(3); + w.Write("s1"); + shape(ref w); + (shapes ?? List())(ref w); + }); + + private static Emit CircleArm(int key, int radius) => InnerArm(key, radius); + + [TestCase(1)] + [TestCase(3)] + [TestCase(9)] + public void UnknownArmOfAnInterfaceUnionRefused(int key) + { + foreach (var payload in new[] { Shapes(CircleArm(key, 1)), Shapes(Nil, List(CircleArm(0, 1), CircleArm(key, 1))) }) + { + Exception? thrown = null; + try { CultDocumentMessagePackSerialization.DeserializeUntyped(typeof(ShapeDoc), payload, Registry); } + catch (Exception exception) { thrown = exception; } + Assert.That(thrown, Is.Not.Null, "an unknown arm of an interface union was accepted"); + var message = Chain(thrown!); + Assert.That(message, Does.Contain($"Union {ShapeName} has no arm for key {key} ")); + Assert.That(message, Does.Contain("declared keys: 0, 2.")); + } + } + + [Test] + public void UnnamedImplementationOfAnInterfaceUnionWriteRefused() + { + foreach (var doc in new[] { new ShapeDoc { Shape = new Triangle() }, new ShapeDoc { Shapes = { new Circle(), new Triangle() } }, new ShapeDoc { Shape = new BigCircle() } }) + { + var message = Chain(Assert.Catch( + () => CultDocumentMessagePackSerialization.SerializeUntyped(doc, typeof(ShapeDoc), Registry))!); + Assert.That(message, Does.Contain("is not an arm of union").And.Contain(ShapeName)); + } + } + + [Test] + public void KnownArmsOfAnInterfaceUnionByteIdentical() + { + var doc = new ShapeDoc { Id = "s1", Shape = new Square { N = 4 }, Shapes = { new Circle { N = 2 }, null! } }; + var expected = Shapes(CircleArm(2, 4), List(CircleArm(0, 2), Nil)); + Assert.That(Convert.ToHexString(CultDocumentMessagePackSerialization.SerializeUntyped(doc, typeof(ShapeDoc), Registry)), Is.EqualTo(Convert.ToHexString(expected))); + Assert.That(Convert.ToHexString(MessagePackSerializer.Serialize(doc, MessagePackSerializerOptions.Standard)), Is.EqualTo(Convert.ToHexString(expected))); + var back = (ShapeDoc)CultDocumentMessagePackSerialization.DeserializeUntyped(typeof(ShapeDoc), expected, Registry); + Assert.That(back.Shape, Is.TypeOf()); + Assert.That(back.Shapes[0], Is.TypeOf()); + Assert.That(back.Shapes[1], Is.Null); + } + + // A variant record whose override holds an unknown arm is refused naming the record, the schema id, the union and the key. + [Test] + public void VariantOverrideHoldingAnUnknownArmIsRefusedNamingTheSchema() + { + var folder = Path.Combine(Path.GetTempPath(), $"cultlib-unionguard-{Guid.NewGuid():N}"); + Directory.CreateDirectory(folder); + try + { + var descriptor = Registry.GetRequired(typeof(GuardDoc)); + var path = Path.Combine(folder, "variant.cc"); + File.WriteAllBytes(path, CultDocumentMessagePackSerialization.SerializeSnapshot(new CultPersistedStoreSnapshot + { + FormatVersion = CultPersistedStoreSnapshot.FormatV2, + SchemaCatalog = new[] { descriptor.ToCatalogEntry() }, + Records = new[] + { + new CultPersistedRecord + { + Key = "base", SchemaId = descriptor.SchemaId, StoredAt = "2026-01-01T00:00:00.0000000Z", + Payload = Doc(LeafArm(3, "r", "ok", 1)) + }, + new CultPersistedRecord + { + Key = "variant", SchemaId = descriptor.SchemaId, StoredAt = "2026-01-01T00:00:00.0000000Z", + Payload = Array.Empty(), + Variant = new CultVariantDelta("base", new[] { CultVariantOverride.Set(1, Raw(LeafArm(4, "r", Canary, 7))) }) + } + } + })); + var thrown = Assert.Catch(() => CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = Registry, ReadOnly = true }).Dispose())!; + var message = Chain(thrown); + Assert.Multiple(() => + { + Assert.That(message, Does.Contain("Variant variant")); + Assert.That(message, Does.Contain($"(schema '{descriptor.SchemaId}')")); + Assert.That(message, Does.Contain(NodeName)); + Assert.That(message, Does.Contain("key 4 ")); + Assert.That(message, Does.Not.Contain(Canary)); + }); + } + finally + { + Directory.Delete(folder, recursive: true); + } + } + + // 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(LeafArm(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(LeafArm(key, "r", Canary, 7)); + Assert.That(forged.Length, Is.EqualTo(genuine.Length)); + // A directory store names a page by the SHA-256 of its bytes and its manifest commits to that hash, so a forged + // page is renamed and the manifest told its new hash: the store then holds a well-formed page the reader must refuse. + var patched = 0; + var rehashed = new List<(byte[] Old, byte[] New)>(); + foreach (var file in Directory.EnumerateFiles(folder, "*", SearchOption.AllDirectories).ToArray()) + { + var bytes = File.ReadAllBytes(file); + var at = bytes.AsSpan().IndexOf(genuine); + if (at < 0) continue; + var before = System.Security.Cryptography.SHA256.HashData(bytes); + forged.CopyTo(bytes, at); + if (file.EndsWith(".msgpack", StringComparison.Ordinal)) + { + File.Delete(file); + var after = System.Security.Cryptography.SHA256.HashData(bytes); + File.WriteAllBytes(Path.Combine(Path.GetDirectoryName(file)!, Convert.ToHexString(after).ToLowerInvariant() + ".msgpack"), bytes); + rehashed.Add((before, after)); + } + else + { + File.WriteAllBytes(file, bytes); + } + + patched++; + } + + foreach (var file in Directory.EnumerateFiles(folder, "*", SearchOption.AllDirectories).Where(f => !f.EndsWith(".msgpack", StringComparison.Ordinal)).ToArray()) + { + var bytes = File.ReadAllBytes(file); + foreach (var (before, after) in rehashed) + for (var at = bytes.AsSpan().IndexOf(before); at >= 0; at = bytes.AsSpan().IndexOf(before)) after.CopyTo(bytes, at); + File.WriteAllBytes(file, bytes); + } + + 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 }); + }); + // The outermost message alone says it: a caller that prints only .Message must see the record and the union. + var message = thrown!.Message; + Assert.Multiple(() => + { + Assert.That(message, Does.Contain("Record 'd1'")); + Assert.That(message, Does.Contain($"(schema '{Registry.GetRequired(typeof(GuardDoc)).SchemaId}')")); + Assert.That(message, Does.Contain(NodeName)); + Assert.That(message, Does.Contain($"key {key} ")); + Assert.That(Chain(thrown), 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; + } + + // An interface union: arm keys 0 and 2, so 1 is a retired gap. + [Union(0, typeof(Circle)), Union(2, typeof(Square))] + public interface IShape { } + + [MessagePackObject] public class Circle : IShape { [Key(0)] public int N; } + [MessagePackObject] public sealed class Square : IShape { [Key(0)] public int N; } + [MessagePackObject] public sealed class Triangle : IShape { } + [MessagePackObject] public sealed class BigCircle : Circle { } + + [CultDocument("tests.shape_doc", "tests.shape_doc.v1")] + [MessagePackObject] + public sealed class ShapeDoc + { + [Key(0)] [CultName] public string Id = string.Empty; + [Key(1)] public IShape? Shape; + [Key(2)] public List Shapes = new(); + } + } +} diff --git a/unity/org.gamecult.cultlib/CHANGELOG.md b/unity/org.gamecult.cultlib/CHANGELOG.md index a5217360..9d54e996 100644 --- a/unity/org.gamecult.cultlib/CHANGELOG.md +++ b/unity/org.gamecult.cultlib/CHANGELOG.md @@ -2,6 +2,24 @@ All notable changes to this package are documented in this file. +## [Unreleased] + +### Breaking + +- CultCache refuses an unknown union arm on read and an unnamed union subtype on + write. A payload whose arm key the union does not declare used to decode to + `null` (and the next save wrote the loss back); a value whose type the union + does not name used to be written as `nil`. Both now throw, naming the union and + the key or type, and a store pull names the record key and schema id. Known arms + encode to the same bytes as before. +- A payload that fails to decode during a store pull (single-file or directory) + now throws `InvalidDataException` naming the record key and schema id, with the + original exception (for example `MessagePackSerializationException`) as its + `InnerException`. Callers that caught the codec's exception type around a store + open or pull must catch `InvalidDataException`. An arm key outside Int32 or not + an integer is refused with the same union-naming refusal, and a variant + override holding an unknown arm names the schema id. + ## [1.0.60] ### Changed