diff --git a/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs b/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs index cba6fdea..96a4b985 100644 --- a/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs +++ b/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs @@ -82,10 +82,17 @@ public static class CultDocumentMessagePackSerialization foreach (var type in CultElementIds.Inspect(documentType).IdPath) { var generic = GetFormatterMethod.MakeGenericMethod(type); + // A resolver that hands back MessagePack's own formatter for the type writes its [Key] layout: that is no claim, and the + // composite stops there, so no later resolver is asked. + var standard = generic.Invoke(StandardResolver.Instance, null); foreach (var resolver in ahead) { - if (generic.Invoke(resolver, null) != null) - return $"Cult document {documentType.Name}: formatter resolver {resolver.GetType().FullName} formats {type.Name}, which is on the path to a list whose elements carry ids, so its element ids would never reach disk. A resolver may not claim such a type; write it by its [Key] layout or keep it out of the document's lists."; + var formatter = generic.Invoke(resolver, null); + if (formatter == null) + continue; + if (ReferenceEquals(formatter, standard)) + break; + return $"Cult document {documentType.Name}: formatter resolver {resolver.GetType().FullName} formats {type.Name}, which is on the path to a list whose elements carry ids, so its element ids would never reach disk. A resolver may not claim such a type; write it by its [Key] layout or keep it out of the document's lists."; } } diff --git a/src/GameCult.Caching/Contracts/cultcache-store-composition.md b/src/GameCult.Caching/Contracts/cultcache-store-composition.md index 32a92c43..545d9a8f 100644 --- a/src/GameCult.Caching/Contracts/cultcache-store-composition.md +++ b/src/GameCult.Caching/Contracts/cultcache-store-composition.md @@ -295,9 +295,13 @@ exclusive open of the same lock file exclude each other is not established. - An assembly that declares nothing, and the store envelope, use the base options. - A resolver may not claim a type a document's element lists pass through: the list's - element types, the types and members on the way to them. Those are written by their - `[Key]` layout so each element's id reaches disk, and the codec refuses the document - on first encode or decode, naming the resolver and the type. + element types, the types and members on the way to them, and the collection types + the walk passes (`List`, `T[]`, a collection class, a dictionary on the way). + Those are written by their `[Key]` layout so each element's id reaches disk, and the + codec refuses the document on first encode or decode, naming the resolver and the + type. A resolver that returns MessagePack's standard formatter for a type does not + claim it. A value list (`List`, a list of a non-keyed class) is not on the + path, so a resolver may claim it. - Deserialization runs under untrusted-data security. `CultRecordRef` is a permitted dictionary key, compared by its key string. diff --git a/tests/GameCult.Caching.Tests/AssemblyInfo.cs b/tests/GameCult.Caching.Tests/AssemblyInfo.cs index 03e1780f..c63262f6 100644 --- a/tests/GameCult.Caching.Tests/AssemblyInfo.cs +++ b/tests/GameCult.Caching.Tests/AssemblyInfo.cs @@ -8,6 +8,7 @@ using MessagePack.Formatters; [assembly: CultCacheFormatterResolver(typeof(UnionArmGuardTests.StubArmResolver))] [assembly: CultCacheFormatterResolver(typeof(UnionArmGuardTests.ClaimArmResolver))] [assembly: CultCacheFormatterResolver(typeof(UnionArmGuardTests.OrderResolver))] +[assembly: CultCacheFormatterResolver(typeof(UnionArmGuardTests.ListClaimResolver))] namespace GameCult.Caching.Tests { diff --git a/tests/GameCult.Caching.Tests/UnionArmGuardTests.cs b/tests/GameCult.Caching.Tests/UnionArmGuardTests.cs index eea8c4dc..aab52ec4 100644 --- a/tests/GameCult.Caching.Tests/UnionArmGuardTests.cs +++ b/tests/GameCult.Caching.Tests/UnionArmGuardTests.cs @@ -290,6 +290,170 @@ namespace GameCult.Caching.Tests } } + // A resolver's formatter for the list type of an object list drops the elements' ids as surely as one for the element, + // so the collection type is on the id path (Soul s1 P1). A claim on a value list, even beside an object list, is none. + [Test] + public void AResolverClaimingTheListTypeOfAnObjectListIsRefused() + { + var registry = CultDocumentRegistry.ForTypes(new[] { typeof(LcDoc), typeof(LcValueDoc) }); + var doc = new LcDoc { Id = "l1", Items = { new LcArm { Id = "000000000001", N = 4 } } }; + var held = Raw((ref MessagePackWriter w) => { w.WriteArrayHeader(2); w.Write("l1"); w.WriteArrayHeader(0); }); + foreach (var attempt in new TestDelegate[] + { + () => CultDocumentMessagePackSerialization.SerializeUntyped(doc, typeof(LcDoc), registry), + () => CultDocumentMessagePackSerialization.DeserializeUntyped(typeof(LcDoc), held, registry) + }) + { + var thrown = Assert.Throws(attempt)!; + Assert.That(thrown.Message, Does.Contain(nameof(ListClaimResolver)).And.Contain("List`1").And.Contain(nameof(LcDoc))); + } + + var values = new LcValueDoc + { + Id = "v1", + Items = { new LcOther { Id = "000000000002" } }, + Values = { new LcValue { N = 3 }, new LcValue { N = 5 } }, + Points = { new Pair { A = 1, B = 2 } } + }; + var bytes = CultDocumentMessagePackSerialization.SerializeUntyped(values, typeof(LcValueDoc), registry); + var back = (LcValueDoc)CultDocumentMessagePackSerialization.DeserializeUntyped(typeof(LcValueDoc), bytes, registry); + Assert.That(back.Items.Single().Id, Is.EqualTo("000000000002")); + Assert.That(back.Values.Select(value => value.N), Is.EqualTo(new[] { 3, 5 })); + Assert.That(back.Points.Single(), Is.EqualTo(new Pair { A = 1, B = 2 })); + } + + // A resolver that hands out MessagePack's own formatter for a type writes its [Key] layout: it claims nothing, however it + // got there, and the document encodes with the id present (Soul s1 P2). + [Test] + public void AResolverHandingBackTheStandardFormatterIsNoClaim() + { + var registry = CultDocumentRegistry.ForTypes(new[] { typeof(FbDoc) }); + var doc = new FbDoc { Id = "f1", Items = { new FbArm { Id = "000000000009", N = 7 } } }; + + var bytes = CultDocumentMessagePackSerialization.SerializeUntyped(doc, typeof(FbDoc), registry); + + Assert.That(Convert.ToHexString(bytes), Is.EqualTo(Convert.ToHexString(MessagePackSerializer.Serialize(doc, CultDocumentMessagePackSerialization.Options)))); + var back = (FbDoc)CultDocumentMessagePackSerialization.DeserializeUntyped(typeof(FbDoc), bytes, registry); + Assert.That(back.Items.Single().Id, Is.EqualTo("000000000009")); + } + + [MessagePackObject] + public sealed class LcArm + { + [Key(0)] [CultElementId] public string Id = string.Empty; + [Key(1)] public int N; + } + + [MessagePackObject] + public sealed class LcOther + { + [Key(0)] [CultElementId] public string Id = string.Empty; + } + + public sealed class LcValue + { + public int N; + } + + [CultDocument("tests.lc_doc", "tests.lc_doc.v1")] + [MessagePackObject] + public sealed class LcDoc + { + [Key(0)] [CultName] public string Id = string.Empty; + [Key(1)] public List Items = new(); + } + + [CultDocument("tests.lc_value_doc", "tests.lc_value_doc.v1")] + [MessagePackObject] + public sealed class LcValueDoc + { + [Key(0)] [CultName] public string Id = string.Empty; + [Key(1)] public List Items = new(); + [Key(2)] public List Values = new(); + [Key(3)] public List Points = new(); + } + + [MessagePackObject] + public sealed class FbArm + { + [Key(0)] [CultElementId] public string Id = string.Empty; + [Key(1)] public int N; + } + + [CultDocument("tests.fb_doc", "tests.fb_doc.v1")] + [MessagePackObject] + public sealed class FbDoc + { + [Key(0)] [CultName] public string Id = string.Empty; + [Key(1)] public List Items = new(); + } + + // Claims List (an object list's type: refused), List and List (value lists: accepted, as a math + // resolver claims List), and answers FbArm with MessagePack's own formatter (no claim). + public sealed class ListClaimResolver : IFormatterResolver + { + public static readonly IFormatterResolver Instance = new ListClaimResolver(); + + public IMessagePackFormatter? GetFormatter() + { + if (typeof(T) == typeof(List)) return (IMessagePackFormatter)(object)new NilFormatter(); + if (typeof(T) == typeof(List)) return (IMessagePackFormatter)(object)new ValuesFormatter(); + if (typeof(T) == typeof(List)) return (IMessagePackFormatter)(object)new PointsFormatter(); + if (typeof(T) == typeof(FbArm)) return StandardResolver.Instance.GetFormatter(); + return null; + } + + private sealed class NilFormatter : IMessagePackFormatter> + { + public void Serialize(ref MessagePackWriter writer, List value, MessagePackSerializerOptions options) => writer.WriteNil(); + + public List Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options) + { + reader.Skip(); + return new List(); + } + } + + private sealed class ValuesFormatter : IMessagePackFormatter> + { + public void Serialize(ref MessagePackWriter writer, List value, MessagePackSerializerOptions options) + { + writer.WriteArrayHeader(value.Count); + foreach (var item in value) writer.Write(item.N); + } + + public List Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options) + { + var count = reader.ReadArrayHeader(); + var list = new List(count); + for (var index = 0; index < count; index++) list.Add(new LcValue { N = reader.ReadInt32() }); + return list; + } + } + + private sealed class PointsFormatter : IMessagePackFormatter> + { + public void Serialize(ref MessagePackWriter writer, List value, MessagePackSerializerOptions options) + { + writer.WriteArrayHeader(value.Count); + foreach (var item in value) { writer.WriteArrayHeader(2); writer.Write(item.A); writer.Write(item.B); } + } + + public List Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options) + { + var count = reader.ReadArrayHeader(); + var list = new List(count); + for (var index = 0; index < count; index++) + { + reader.ReadArrayHeader(); + list.Add(new Pair { A = reader.ReadSingle(), B = reader.ReadSingle() }); + } + + return list; + } + } + } + [Test] public void OptionsRefuseANullDocumentTypeOrAssembly() {