From a5241112ded76eb102035821924fb03b8274595e Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 15 Oct 2026 20:52:13 +0200 Subject: [PATCH] Codec door refuses only a proven family mismatch; source-generated formatters pass WrittenAsWalked required StandardResolver's formatter to equal one of the walk's own families, which refused every source-generated formatter. WrittenAgainstWalk asks one contrary resolver per walk kind and refuses only on a reference match. DcSeq is refused as a collection written as an object; FormattedRoles passes (registration owns the attribute). Co-Authored-By: Claude Opus 5.5 --- .../CultDocumentMessagePackSerialization.cs | 25 +++++++------- .../UnionArmGuardTests.cs | 33 ++++++++++++++++--- 2 files changed, 40 insertions(+), 18 deletions(-) diff --git a/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs b/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs index 379d3e36..cd120a33 100644 --- a/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs +++ b/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs @@ -8,7 +8,6 @@ using System.Reflection; using GameCult.Caching; using MessagePack; using MessagePack.Formatters; -using MessagePack.ImmutableCollection; using MessagePack.Resolvers; namespace GameCult.Caching.MessagePack; @@ -97,10 +96,11 @@ public static class CultDocumentMessagePackSerialization return $"Cult document {documentType.Name}: MessagePack cannot build a formatter for {type.Name}, which is on the path to a list whose elements carry ids ({(exception.InnerException ?? exception).Message})."; } - if (!WrittenAsWalked(type, generic, standard)) + if (WrittenAgainstWalk(type, generic, standard)) { - var owner = CultElementIds.WalksAsOwner(type); - return $"Cult document {documentType.Name}: MessagePack writes {type.Name} {(owner ? "as a collection" : "by its [Key] layout")}, but the element-id walk reads it {(owner ? "by its [Key] layout" : "as a collection")}, so element ids would be lost or invented. A [MessagePackObject] type that is also a collection (ICollection, IList, IDictionary) is written as a collection; make it one or the other."; + return CultElementIds.WalksAsOwner(type) + ? $"Cult document {documentType.Name}: MessagePack writes {type.Name} as a collection, but the element-id walk reads it by its [Key] layout, so element ids would be lost or invented. A [MessagePackObject] type that is also a collection (ICollection, IList, IDictionary) is written as a collection; make it one or the other." + : $"Cult document {documentType.Name}: MessagePack writes {type.Name} as an object, but the element-id walk reads it as a collection, so element ids would be lost or invented. A [DataContract] type that is also enumerable is written by its members; mark it [MessagePackObject] with integer [Key]s, or drop the contract."; } var allowPrivateAsked = false; @@ -143,16 +143,15 @@ public static class CultDocumentMessagePackSerialization return null; } - // The walk's kind for a type on the id path, checked against the resolver MessagePack actually delegates to. The public - // singletons hand out the same cached formatter instances StandardResolver does, so a reference match names the family. A - // type the walk reads by key must be written by the object or union resolver, and one it reads as a collection by a - // collection resolver; a keyed class that is also an ICollection is written as a collection and its [Key] layout dropped. - private static bool WrittenAsWalked(Type type, MethodInfo generic, object? standard) + // The door refuses only a proven mismatch: StandardResolver's formatter being the very instance the contrary resolver's + // public singleton hands out (one is cached per type). An owner is contradicted by the generic resolver (a keyed + // ICollection or dictionary), a collection by the object resolver (a [DataContract] enumerable). Union types walk as + // owners and the immutable and builtin resolvers claim only framework types, so neither can contradict. Any other + // formatter, a source-generated one among them, passes: the walk owns which types carry ids and this door is its safety net. + private static bool WrittenAgainstWalk(Type type, MethodInfo generic, object? standard) { - var families = CultElementIds.WalksAsOwner(type) - ? new IFormatterResolver[] { DynamicUnionResolver.Instance, DynamicObjectResolver.Instance } - : new IFormatterResolver[] { DynamicGenericResolver.Instance, ImmutableCollectionResolver.Instance, BuiltinResolver.Instance }; - return families.Any(family => ReferenceEquals(Formatter(family, generic), standard)); + var contrary = CultElementIds.WalksAsOwner(type) ? (IFormatterResolver)DynamicGenericResolver.Instance : DynamicObjectResolver.Instance; + return standard != null && ReferenceEquals(Formatter(contrary, generic), standard); } private static object? Formatter(IFormatterResolver resolver, MethodInfo generic) diff --git a/tests/GameCult.Caching.Tests/UnionArmGuardTests.cs b/tests/GameCult.Caching.Tests/UnionArmGuardTests.cs index 91c94d3c..760bf9ca 100644 --- a/tests/GameCult.Caching.Tests/UnionArmGuardTests.cs +++ b/tests/GameCult.Caching.Tests/UnionArmGuardTests.cs @@ -4,6 +4,7 @@ using System.Buffers; using System.Collections.Generic; using System.IO; using System.Linq; +using System.Runtime.Serialization; using System.Threading.Tasks; using GameCult.Caching; using GameCult.Caching.MessagePack; @@ -380,7 +381,9 @@ namespace GameCult.Caching.Tests Assert.That(back.Bag.Items.Single().Id, Is.EqualTo("00000000000d")); } - // Every collection shape the walk reads is one MessagePack writes through a collection formatter, so none is refused. + // Every collection shape the walk reads is one MessagePack writes through a collection formatter, so none is refused. A + // [DataContract] list subclass is still written by the generic resolver; an object formatter the object resolver could also + // build (DcColl: a different instance) or fails to build (DcBad) is no mismatch. [Test] public void EveryCollectionShapeTheWalkReadsIsWrittenByACollectionFormatter() { @@ -395,6 +398,7 @@ namespace GameCult.Caching.Tests typeof(G>), typeof(G>), typeof(G>), typeof(G>), typeof(G>), typeof(G), typeof(G), typeof(G), + typeof(G), typeof(G), }) { if (holder != typeof(G)) @@ -403,12 +407,22 @@ namespace GameCult.Caching.Tests } } - // The walk reads a List subclass as a collection; a type MessagePack writes by an attribute formatter instead is a mismatch. + // The attribute formatter is of no known family, so the door passes it as it passes a source-generated one; registration + // owns the attribute. [Test] - public void ACollectionMessagePackWritesByAnotherFormatterIsRefused() + public void AFormatterOfNoKnownFamilyPassesTheDoor() { - var thrown = Assert.Throws(() => CultDocumentMessagePackSerialization.OptionsForDocument(typeof(G)))!; - Assert.That(thrown.Message, Does.Contain($"writes {nameof(FormattedRoles)} by its [Key] layout, but the element-id walk reads it as a collection")); + Assert.DoesNotThrow(() => CultDocumentMessagePackSerialization.OptionsForDocument(typeof(G))); + var problems = CultElementIds.Inspect(typeof(G)).Problems; + Assert.That(problems, Has.Count.EqualTo(1)); + Assert.That(problems.Single(), Does.Contain("FormattedRoles").And.Contain("declares [MessagePackFormatter]")); + } + + [Test] + public void ACollectionMessagePackWritesAsAnObjectIsRefused() + { + var thrown = Assert.Throws(() => CultDocumentMessagePackSerialization.OptionsForDocument(typeof(G)))!; + Assert.That(thrown.Message, Does.Contain("MessagePack writes DcSeq as an object, but the element-id walk reads it as a collection")); } // A resolver that throws while asked about an id-path type is a typed refusal, whether it is the AllowPrivate formatter @@ -499,6 +513,15 @@ namespace GameCult.Caching.Tests public FormattedRoles Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options) { reader.Skip(); return new FormattedRoles(); } } + [DataContract] public sealed class DcSeq : IEnumerable + { + [DataMember(Order = 0)] public List Items = new(); + public IEnumerator GetEnumerator() => Items.GetEnumerator(); + System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator(); + } + [DataContract] public sealed class DcColl : List { [DataMember(Order = 0)] public int N; } + [DataContract] public sealed class DcBad : List { [DataMember(Order = 0)] public int A; [DataMember(Order = 0)] public int B; } + public sealed class DkFormatter : IMessagePackFormatter { public void Serialize(ref MessagePackWriter writer, DkEl value, MessagePackSerializerOptions options) => writer.Write(value.N);