ResolverClaim: a resolver returning MessagePack's standard formatter for an id-path type claims nothing

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-15 00:26:53 +02:00
commit b0cc36bc7e
4 changed files with 181 additions and 5 deletions

View file

@ -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.";
}
}

View file

@ -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>`, `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<float2>`, 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<T>` is a
permitted dictionary key, compared by its key string.

View file

@ -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
{

View file

@ -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<InvalidOperationException>(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<LcArm> 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<LcOther> Items = new();
[Key(2)] public List<LcValue> Values = new();
[Key(3)] public List<Pair> 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<FbArm> Items = new();
}
// Claims List<LcArm> (an object list's type: refused), List<LcValue> and List<Pair> (value lists: accepted, as a math
// resolver claims List<float2>), and answers FbArm with MessagePack's own formatter (no claim).
public sealed class ListClaimResolver : IFormatterResolver
{
public static readonly IFormatterResolver Instance = new ListClaimResolver();
public IMessagePackFormatter<T>? GetFormatter<T>()
{
if (typeof(T) == typeof(List<LcArm>)) return (IMessagePackFormatter<T>)(object)new NilFormatter();
if (typeof(T) == typeof(List<LcValue>)) return (IMessagePackFormatter<T>)(object)new ValuesFormatter();
if (typeof(T) == typeof(List<Pair>)) return (IMessagePackFormatter<T>)(object)new PointsFormatter();
if (typeof(T) == typeof(FbArm)) return StandardResolver.Instance.GetFormatter<T>();
return null;
}
private sealed class NilFormatter : IMessagePackFormatter<List<LcArm>>
{
public void Serialize(ref MessagePackWriter writer, List<LcArm> value, MessagePackSerializerOptions options) => writer.WriteNil();
public List<LcArm> Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options)
{
reader.Skip();
return new List<LcArm>();
}
}
private sealed class ValuesFormatter : IMessagePackFormatter<List<LcValue>>
{
public void Serialize(ref MessagePackWriter writer, List<LcValue> value, MessagePackSerializerOptions options)
{
writer.WriteArrayHeader(value.Count);
foreach (var item in value) writer.Write(item.N);
}
public List<LcValue> Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options)
{
var count = reader.ReadArrayHeader();
var list = new List<LcValue>(count);
for (var index = 0; index < count; index++) list.Add(new LcValue { N = reader.ReadInt32() });
return list;
}
}
private sealed class PointsFormatter : IMessagePackFormatter<List<Pair>>
{
public void Serialize(ref MessagePackWriter writer, List<Pair> 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<Pair> Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options)
{
var count = reader.ReadArrayHeader();
var list = new List<Pair>(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()
{