2026-10-10 21:17:56 +02:00
#nullable enable
using System ;
using System.Buffers ;
using System.Collections.Generic ;
using System.IO ;
using System.Linq ;
2026-10-15 20:52:13 +02:00
using System.Runtime.Serialization ;
2026-10-10 21:17:56 +02:00
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" ;
2026-10-14 21:52:36 +02:00
private static readonly CultDocumentRegistry Registry = CultDocumentRegistry . ForTypes ( new [ ] { typeof ( GuardDoc ) , typeof ( ShapeDoc ) , typeof ( StubDoc ) } ) ;
2026-10-10 21:17:56 +02:00
private delegate void Emit ( ref MessagePackWriter writer ) ;
private static byte [ ] Raw ( Emit emit )
{
var buffer = new ArrayBufferWriter < byte > ( ) ;
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].
2026-10-10 21:34:56 +02:00
private static Emit LeafArm ( int key , string id , string content , int weight ) = > ( ref MessagePackWriter w ) = >
2026-10-10 21:17:56 +02:00
{
w . WriteArrayHeader ( 2 ) ; w . Write ( key ) ;
w . WriteArrayHeader ( 4 ) ; w . Write ( id ) ; w . Write ( content ) ; w . WriteNil ( ) ; w . Write ( weight ) ;
} ;
2026-10-14 19:40:37 +02:00
// Unit8: slots [id].
2026-10-14 19:41:50 +02:00
private static Emit UnitArm ( int key ) = > ( ref MessagePackWriter w ) = > { w . WriteArrayHeader ( 2 ) ; w . Write ( key ) ; w . WriteArrayHeader ( 1 ) ; w . Write ( "000000000007" ) ; } ;
2026-10-10 21:17:56 +02:00
2026-10-14 19:40:37 +02:00
// Branch: slots [children, next, id].
2026-10-10 21:34:56 +02:00
private static Emit BranchArm ( int key , Emit next , params Emit [ ] children ) = > ( ref MessagePackWriter w ) = >
2026-10-10 21:17:56 +02:00
{
w . WriteArrayHeader ( 2 ) ; w . Write ( key ) ;
2026-10-14 19:40:37 +02:00
w . WriteArrayHeader ( 3 ) ;
2026-10-10 21:17:56 +02:00
w . WriteArrayHeader ( children . Length ) ;
foreach ( var child in children ) child ( ref w ) ;
next ( ref w ) ;
2026-10-14 19:41:50 +02:00
w . Write ( "000000000008" ) ;
2026-10-10 21:17:56 +02:00
} ;
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 < string > ( ) ;
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 ) = >
2026-10-14 19:41:50 +02:00
AssertRefused ( Doc ( LeafArm ( key , "000000000005" , Canary , 7 ) ) , NodeName , key , "3, 5, 8, 9" ) ;
2026-10-10 21:17:56 +02:00
[TestCase(0)]
[TestCase(4)]
[TestCase(10)]
public void UnknownArmInListRefused ( int key ) = >
2026-10-14 19:41:50 +02:00
AssertRefused ( Doc ( LeafArm ( 3 , "000000000005" , "ok" , 1 ) , List ( UnitArm ( 8 ) , LeafArm ( key , "000000000004" , Canary , 7 ) ) ) , NodeName , key , "3, 5, 8, 9" ) ;
2026-10-10 21:17:56 +02:00
// 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 )
{
2026-10-14 19:41:50 +02:00
AssertRefused ( Doc ( BranchArm ( 5 , Nil , UnitArm ( 8 ) , LeafArm ( key , "000000000006" , Canary , 7 ) ) ) , NodeName , key , "3, 5, 8, 9" ) ;
AssertRefused ( Doc ( BranchArm ( 5 , LeafArm ( key , "000000000003" , Canary , 7 ) ) ) , NodeName , key , "3, 5, 8, 9" ) ;
AssertRefused ( Doc ( BranchArm ( 5 , Nil , BranchArm ( 5 , Nil , BranchArm ( 5 , Nil , LeafArm ( key , "000000000002" , Canary , 7 ) ) ) ) ) , NodeName , key , "3, 5, 8, 9" ) ;
2026-10-10 21:17:56 +02:00
}
// 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 < InnerB > ( ) ) ;
Assert . That ( accepted . Any , Is . TypeOf < OuterOnly > ( ) ) ;
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 ( ) } ,
2026-10-14 19:41:50 +02:00
new GuardDoc { Items = { new Unit8 { Id = "000000000007" } , new Rogue ( ) } } ,
new GuardDoc { Root = new Branch { Id = "000000000008" , Children = { new Rogue ( ) } } } ,
new GuardDoc { Root = new Branch { Id = "000000000008" , Next = new Rogue ( ) } } ,
2026-10-10 21:17:56 +02:00
new GuardDoc { Root = new DerivedLeaf ( ) } ,
new GuardDoc { Crafted = new Unlisted ( ) } ,
} )
{
var message = Chain ( Assert . Catch < MessagePackSerializationException > (
( ) = > CultDocumentMessagePackSerialization . SerializeUntyped ( doc , typeof ( GuardDoc ) , Registry ) ) ! ) ;
Assert . That ( message , Does . Contain ( "is not an arm of union" ) ) ;
}
} ) ;
}
[Test]
public void NullUnionRoundTrips ( )
{
2026-10-10 21:34:56 +02:00
var bytes = Doc ( Nil , List ( Nil , UnitArm ( 8 ) , Nil ) ) ;
2026-10-10 21:17:56 +02:00
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
{
2026-10-14 19:41:50 +02:00
Id = "000000000008" ,
2026-10-10 21:17:56 +02:00
Children =
{
2026-10-14 19:41:50 +02:00
new Leaf { Id = "000000000001" , Content = "x" , Weight = 1 } ,
new Unit8 { Id = "000000000007" } ,
new Branch { Id = "000000000008" , Children = { new Branch { Id = "000000000008" , Children = { new Leaf { Id = "000000000002" , Content = "y" , Weight = 2 } } } } }
2026-10-10 21:17:56 +02:00
} ,
2026-10-14 19:41:50 +02:00
Next = new Leaf { Id = "000000000003" , Content = "z" , Weight = 3 }
2026-10-10 21:17:56 +02:00
} ,
2026-10-14 19:41:50 +02:00
Items = { new Unit8 { Id = "000000000007" } , new Leaf { Id = "000000000004" , Content = "w" , Weight = 4 } , null ! } ,
2026-10-10 21:17:56 +02:00
Crafted = new InnerB { N = 9 } ,
Any = new InnerA { N = 8 }
} ;
private static byte [ ] KnownArmBytes ( ) = > Doc (
2026-10-14 19:41:50 +02:00
BranchArm ( 5 , LeafArm ( 3 , "000000000003" , "z" , 3 ) ,
LeafArm ( 3 , "000000000001" , "x" , 1 ) ,
2026-10-10 21:34:56 +02:00
UnitArm ( 8 ) ,
2026-10-14 19:41:50 +02:00
BranchArm ( 5 , Nil , BranchArm ( 5 , Nil , LeafArm ( 3 , "000000000002" , "y" , 2 ) ) ) ) ,
List ( UnitArm ( 8 ) , LeafArm ( 3 , "000000000004" , "w" , 4 ) , Nil ) ,
2026-10-10 21:17:56 +02:00
InnerArm ( 1 , 9 ) ,
InnerArm ( 1 , 8 ) ) ;
2026-10-14 20:31:44 +02:00
// Non-zero-start keys with retired gaps, an id-only arm, a union inside an arm four deep: the reference shape, unchanged.
2026-10-10 21:17:56 +02:00
[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 ( )
{
2026-10-14 21:52:36 +02:00
var bytes = CultDocumentMessagePackSerialization . SerializeUntyped ( new StubDoc { Id = "d1" , Root = new StubArm ( ) } , typeof ( StubDoc ) , Registry ) ;
Assert . That ( Convert . ToHexString ( bytes ) , Is . EqualTo ( Convert . ToHexString ( Raw ( ( ref MessagePackWriter w ) = >
2026-10-10 21:17:56 +02:00
{
2026-10-14 21:52:36 +02:00
w . WriteArrayHeader ( 2 ) ; w . Write ( "d1" ) ;
2026-10-10 21:17:56 +02:00
w . WriteArrayHeader ( 2 ) ; w . Write ( 9 ) ; w . WriteArrayHeader ( 1 ) ; w . Write ( "via-resolver" ) ;
} ) ) ) ) ;
2026-10-14 21:52:36 +02:00
var back = ( StubDoc ) CultDocumentMessagePackSerialization . DeserializeUntyped ( typeof ( StubDoc ) , bytes , Registry ) ;
2026-10-10 21:17:56 +02:00
Assert . That ( back . Root , Is . TypeOf < StubArm > ( ) ) ;
}
2026-10-14 21:52:36 +02:00
// A list element is written by its [Key] layout or its id never reaches disk: a consumer resolver that claims the arm of
// a list's union is refused on every path that encodes or decodes the document, naming the arm and the resolver, and
// writes nothing.
[Test]
public void ListElementsClaimedByAResolverAreRefused ( )
{
var registry = CultDocumentRegistry . ForTypes ( new [ ] { typeof ( ClaimDoc ) } ) ;
var doc = new ClaimDoc { Id = "c1" , Items = { new ClaimArm { N = 4 } } } ;
void Refused ( TestDelegate attempt )
{
var thrown = Assert . Throws < InvalidOperationException > ( attempt ) ! ;
Assert . That ( thrown . Message , Does . Contain ( "ClaimArm" ) . And . Contain ( nameof ( ClaimArmResolver ) ) . And . Contain ( nameof ( ClaimDoc ) ) ) ;
}
Refused ( ( ) = > CultDocumentMessagePackSerialization . SerializeUntyped ( doc , typeof ( ClaimDoc ) , registry ) ) ;
var held = Raw ( ( ref MessagePackWriter w ) = > { w . WriteArrayHeader ( 2 ) ; w . Write ( "c1" ) ; w . WriteArrayHeader ( 0 ) ; } ) ;
Refused ( ( ) = > CultDocumentMessagePackSerialization . DeserializeUntyped ( typeof ( ClaimDoc ) , held , registry ) ) ;
Refused ( ( ) = > CultCacheMessagePack . CreateCodec ( registry ) . Serialize ( doc . Items , typeof ( List < ClaimNode > ) , typeof ( ClaimDoc ) ) ) ;
var scratch = Directory . CreateTempSubdirectory ( ) . FullName ;
try
{
var path = Path . Combine ( scratch , "claim.cc" ) ;
using var cache = CultCacheMessagePack . Create ( path , new CultCacheOpenOptions { Registry = registry } ) ;
Assert . That ( Assert . Catch ( ( ) = > cache . Commit ( batch = > batch . Upsert ( typeof ( ClaimDoc ) , doc , new CultRecordKey ( "c1" ) ) ) ) ! . ToString ( ) ,
Does . Contain ( nameof ( ClaimArmResolver ) ) ) ;
Assert . That ( File . Exists ( path ) , Is . False , "nothing reached disk" ) ;
}
finally
{
Directory . Delete ( scratch , true ) ;
}
}
2026-10-15 00:26:53 +02:00
// 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" ) ) ;
}
2026-10-15 01:23:40 +02:00
// A consumer whose resolver composite ends in StandardResolverAllowPrivate (some models keep private [Key] members) gets
// that resolver's formatter for every keyed type. It writes the [Key] layout plus the private members, so it is no
// claim: the document encodes, the id is on disk, and the private value survives.
[Test]
public void AStandardResolverAllowPrivateFormatterIsNoClaim ( )
{
var registry = CultDocumentRegistry . ForTypes ( new [ ] { typeof ( ApDoc ) , typeof ( ApPlainDoc ) } ) ;
var doc = new ApDoc { Id = "a1" , Items = { new ApArm { Id = "00000000000a" , N = 4 } . WithSecret ( 11 ) } } ;
var bytes = CultDocumentMessagePackSerialization . SerializeUntyped ( doc , typeof ( ApDoc ) , registry ) ;
var back = ( ApDoc ) CultDocumentMessagePackSerialization . DeserializeUntyped ( typeof ( ApDoc ) , bytes , registry ) ;
Assert . That ( back . Items . Single ( ) . Id , Is . EqualTo ( "00000000000a" ) ) ;
Assert . That ( back . Items . Single ( ) . Secret , Is . EqualTo ( 11 ) , "a private plain-value [Key] member is written" ) ;
var plain = new ApPlainDoc { Id = "a2" , Items = { new ApPlainArm { Id = "00000000000b" , N = 5 } } } ;
var plainBytes = CultDocumentMessagePackSerialization . SerializeUntyped ( plain , typeof ( ApPlainDoc ) , registry ) ;
Assert . That ( Convert . ToHexString ( plainBytes ) , Is . EqualTo ( Convert . ToHexString ( MessagePackSerializer . Serialize ( plain , CultDocumentMessagePackSerialization . Options ) ) ) ) ;
}
2026-10-15 02:53:28 +02:00
// The walk reads a [MessagePackObject] type by its keys, but MessagePack writes one that is also an ICollection<T> as a
// collection and drops the keys. The codec compares the two for every type on the id path and refuses the document.
[Test]
public void AKeyedTypeMessagePackWritesAsACollectionIsRefused ( )
{
var registry = CultDocumentRegistry . ForTypes ( new [ ] { typeof ( KCollDoc ) , typeof ( KBagDoc ) } ) ;
Assert . That ( CultElementIds . Inspect ( typeof ( KCollDoc ) ) . IdPath , Does . Contain ( typeof ( KColl ) ) ) ;
var coll = new KCollDoc { Id = "k1" } ;
coll . Items . Add ( new FbArm { Id = "00000000000c" } ) ;
var thrown = Assert . Throws < InvalidOperationException > ( ( ) = > CultDocumentMessagePackSerialization . SerializeUntyped ( coll , typeof ( KCollDoc ) , registry ) ) ! ;
2026-10-15 03:09:39 +02:00
Assert . That ( thrown . Message , Does . Contain ( $"writes {nameof(KColl)} as a collection, but the element-id walk reads it by its [Key] layout" ) ) ;
// A keyed map is read by key too, so a keyed Dictionary subclass is on the path and refused the same way.
var map = Assert . Throws < InvalidOperationException > ( ( ) = > CultDocumentMessagePackSerialization . OptionsForDocument ( typeof ( KDictDoc ) ) ) ! ;
Assert . That ( map . Message , Does . Contain ( $"writes {nameof(KDict)} as a collection" ) ) ;
2026-10-15 02:53:28 +02:00
var bag = new KBagDoc { Id = "b1" , Bag = new KBagE { Items = { new FbArm { Id = "00000000000d" } } } } ;
var bytes = CultDocumentMessagePackSerialization . SerializeUntyped ( bag , typeof ( KBagDoc ) , registry ) ;
var back = ( KBagDoc ) CultDocumentMessagePackSerialization . DeserializeUntyped ( typeof ( KBagDoc ) , bytes , registry ) ;
Assert . That ( back . Bag . Items . Single ( ) . Id , Is . EqualTo ( "00000000000d" ) ) ;
}
2026-10-16 00:54:37 +02:00
// Every collection shape the walk reads is one MessagePack writes through a collection formatter, so none is refused.
2026-10-15 02:53:28 +02:00
[Test]
public void EveryCollectionShapeTheWalkReadsIsWrittenByACollectionFormatter ( )
{
foreach ( var holder in new [ ]
{
typeof ( G < List < FbArm > > ) , typeof ( G < FbArm [ ] > ) , typeof ( G < FbArm [ , ] > ) , typeof ( G < IList < FbArm > > ) , typeof ( G < IReadOnlyList < FbArm > > ) ,
typeof ( G < ICollection < FbArm > > ) , typeof ( G < IReadOnlyCollection < FbArm > > ) , typeof ( G < IEnumerable < FbArm > > ) , typeof ( G < HashSet < FbArm > > ) ,
typeof ( G < Queue < FbArm > > ) , typeof ( G < Stack < FbArm > > ) , typeof ( G < LinkedList < FbArm > > ) ,
typeof ( G < System . Collections . ObjectModel . ReadOnlyCollection < FbArm > > ) , typeof ( G < System . Collections . ObjectModel . ObservableCollection < FbArm > > ) ,
typeof ( G < System . Collections . Immutable . ImmutableList < FbArm > > ) , typeof ( G < ILookup < string , FbArm > > ) , typeof ( G < List < List < FbArm > > > ) ,
2026-10-15 02:56:18 +02:00
typeof ( G < Dictionary < string , GHold > > ) , typeof ( G < IDictionary < string , GHold > > ) , typeof ( G < IReadOnlyDictionary < string , GHold > > ) ,
typeof ( G < SortedDictionary < string , GHold > > ) , typeof ( G < System . Collections . Concurrent . ConcurrentDictionary < string , GHold > > ) ,
typeof ( G < System . Collections . ObjectModel . ReadOnlyDictionary < string , GHold > > ) , typeof ( G < System . Collections . Immutable . ImmutableDictionary < string , GHold > > ) ,
2026-10-15 03:35:30 +02:00
typeof ( G < SortedList < string , GHold > > ) , typeof ( G < FbArm > ) , typeof ( G < IGu > ) , typeof ( G < GStruct > ) ,
2026-10-15 02:53:28 +02:00
} )
{
if ( holder ! = typeof ( G < FbArm > ) )
2026-10-15 02:54:25 +02:00
Assert . That ( CultElementIds . Inspect ( holder ) . IdPath , Is . Not . Empty , holder . ToString ( ) ) ;
2026-10-15 02:53:28 +02:00
Assert . DoesNotThrow ( ( ) = > CultDocumentMessagePackSerialization . OptionsForDocument ( holder ) , holder . ToString ( ) ) ;
}
}
2026-10-16 00:54:37 +02:00
// One recogniser decides what the walk owns and the door only verifies it: the same types give no registration problem and a
// passing door. A type formatted by attribute or written by the object resolver is one whole value, off the id path.
2026-10-15 03:09:39 +02:00
[Test]
2026-10-16 00:54:37 +02:00
public void OneRecogniserDecidesAndTheDoorAgrees ( )
2026-10-15 03:09:39 +02:00
{
2026-10-16 00:54:37 +02:00
foreach ( var holder in new [ ] { typeof ( G < FormattedRoles > ) , typeof ( G < ApTrueHold > ) , typeof ( G < DcSeq > ) , typeof ( G < DcColl > ) , typeof ( G < DcBad > ) } )
{
Assert . That ( CultElementIds . Inspect ( holder ) . Problems , Is . Empty , holder . ToString ( ) ) ;
Assert . DoesNotThrow ( ( ) = > CultDocumentMessagePackSerialization . OptionsForDocument ( holder ) , holder . ToString ( ) ) ;
}
foreach ( var whole in new [ ] { typeof ( G < FormattedRoles > ) , typeof ( G < DcSeq > ) , typeof ( G < DcColl > ) , typeof ( G < DcBad > ) } )
Assert . That ( CultElementIds . Inspect ( whole ) . IdPath , Is . Empty , whole . ToString ( ) ) ;
var ap = CultElementIds . Inspect ( typeof ( G < ApTrueHold > ) ) . IdPath ;
Assert . That ( ap , Does . Contain ( typeof ( ApTrueHold ) ) . And . Contain ( typeof ( List < FbArm > ) ) ) ;
var seq = new G < DcSeq > { Value = new DcSeq { Items = { new FbArm ( ) } } } ;
Assert . That ( CultElementIds . Plan ( seq . GetType ( ) , seq , "r" , deterministic : false ) . Count , Is . EqualTo ( 0 ) ) ;
2026-10-15 20:52:13 +02:00
}
2026-10-16 00:54:37 +02:00
// MessagePack throws for a type, or StandardResolver has no formatter for it (an interface or abstract class that is only
// IEnumerable<T>): such a type cannot be persisted, so the door refuses it with the same typed text as a throwing build.
2026-10-15 20:52:13 +02:00
[Test]
2026-10-16 00:54:37 +02:00
public void ATypeMessagePackCannotBuildIsATypedRefusal ( )
2026-10-15 20:52:13 +02:00
{
2026-10-16 00:54:37 +02:00
foreach ( var ( holder , name ) in new [ ] { ( typeof ( G < IArms > ) , nameof ( IArms ) ) , ( typeof ( G < AbsArms > ) , nameof ( AbsArms ) ) } )
{
var thrown = Assert . Throws < InvalidOperationException > ( ( ) = > CultDocumentMessagePackSerialization . OptionsForDocument ( holder ) , holder . ToString ( ) ) ! ;
Assert . That ( thrown . Message , Does . Contain ( name ) . And . Contain ( "cannot build a formatter" ) ) ;
2026-10-16 01:28:00 +02:00
Assert . That ( thrown . Message , Does . Contain ( "StandardResolver has no formatter for it" ) , "a null formatter says so, without a thrown message" ) ;
2026-10-16 00:54:37 +02:00
}
}
// A keyed type that enumerates lists is read by key and is on the id path, so the door checks it: MessagePack writes it as a
// collection, which loses the ids the walk would mint.
[Test]
public void AKeyedTypeThatEnumeratesListsIsCheckedByTheDoor ( )
{
Assert . That ( CultElementIds . Inspect ( typeof ( G < KLL > ) ) . IdPath , Does . Contain ( typeof ( KLL ) ) ) ;
var thrown = Assert . Throws < InvalidOperationException > ( ( ) = > CultDocumentMessagePackSerialization . OptionsForDocument ( typeof ( G < KLL > ) ) ) ! ;
Assert . That ( thrown . Message , Does . Contain ( $"writes {nameof(KLL)} as a collection" ) ) ;
2026-10-15 03:09:39 +02:00
}
2026-10-15 02:53:28 +02:00
// A resolver that throws while asked about an id-path type is a typed refusal, whether it is the AllowPrivate formatter
// the claim check asks about (the claim stands) or a resolver ahead (it fails to build).
[Test]
public void AResolverThatThrowsIsATypedRefusal ( )
{
var first = Assert . Throws < InvalidOperationException > ( ( ) = > CultDocumentMessagePackSerialization . OptionsForDocument ( typeof ( DkDoc ) ) ) ! ;
Assert . That ( first . Message , Does . Contain ( nameof ( ListClaimResolver ) ) . And . Contain ( nameof ( DkEl ) ) ) ;
var second = Assert . Throws < InvalidOperationException > ( ( ) = > CultDocumentMessagePackSerialization . OptionsForDocument ( typeof ( DkDoc2 ) ) ) ! ;
2026-10-15 03:09:39 +02:00
Assert . That ( second . Message , Does . Contain ( nameof ( ListClaimResolver ) ) . And . Contain ( nameof ( DkEl2 ) ) . And . Contain ( "failed to build" ) . And . Not . Contain ( "target of an invocation" ) ,
"the resolver's own exception is named, not the reflection wrapper" ) ;
2026-10-15 03:00:58 +02:00
// A type MessagePack itself cannot build a formatter for (two public members on one key) is refused, naming the type.
var third = Assert . Throws < InvalidOperationException > ( ( ) = > CultDocumentMessagePackSerialization . OptionsForDocument ( typeof ( DkDoc3 ) ) ) ! ;
2026-10-15 03:09:39 +02:00
Assert . That ( third . Message , Does . Contain ( nameof ( DkEl3 ) ) . And . Contain ( "cannot build a formatter" ) . And . Not . Contain ( "target of an invocation" ) ) ;
2026-10-15 02:53:28 +02:00
}
[MessagePackObject]
public sealed class KColl : List < FbArm >
{
[Key(0)] public int N ;
}
[CultDocument("tests.kcoll_doc", "tests.kcoll_doc.v1")]
[MessagePackObject]
public sealed class KCollDoc
{
[Key(0)] [ CultName ] public string Id = string . Empty ;
[Key(1)] public KColl Items = new ( ) ;
}
2026-10-15 03:09:39 +02:00
[MessagePackObject]
public sealed class KDict : Dictionary < string , GHold >
{
[Key(0)] public int N ;
}
[CultDocument("tests.kdict_doc", "tests.kdict_doc.v1")]
[MessagePackObject]
public sealed class KDictDoc
{
[Key(0)] [ CultName ] public string Id = string . Empty ;
[Key(1)] public KDict Items = new ( ) ;
}
2026-10-15 02:53:28 +02:00
[MessagePackObject]
public sealed class KBagE : IEnumerable < FbArm >
{
[Key(0)] public List < FbArm > Items = new ( ) ;
public IEnumerator < FbArm > GetEnumerator ( ) { yield break ; }
System . Collections . IEnumerator System . Collections . IEnumerable . GetEnumerator ( ) = > GetEnumerator ( ) ;
}
[CultDocument("tests.kbag_doc", "tests.kbag_doc.v1")]
[MessagePackObject]
public sealed class KBagDoc
{
[Key(0)] [ CultName ] public string Id = string . Empty ;
[Key(1)] public KBagE Bag = new ( ) ;
}
[MessagePackObject] public sealed class G < T > { [ Key ( 0 ) ] public T ? Value ; }
[Union(0, typeof(GuArm))] public interface IGu { }
[MessagePackObject] public sealed class GuArm : IGu { [ Key ( 0 ) ] [ CultElementId ] public string Id = string . Empty ; [ Key ( 1 ) ] public List < FbArm > Items = new ( ) ; }
2026-10-15 02:56:18 +02:00
[MessagePackObject] public sealed class GHold { [ Key ( 0 ) ] public List < FbArm > Items = new ( ) ; }
2026-10-15 02:53:28 +02:00
[MessagePackObject] public struct GStruct { [ Key ( 0 ) ] public List < FbArm > Items ; }
#pragma warning disable CS0169 , CS0414 , CS0649
[MessagePackObject] public sealed class DkEl { [ Key ( 0 ) ] [ CultElementId ] public string Id = string . Empty ; [ Key ( 1 ) ] public int N ; [ Key ( 1 ) ] private int shadow ; }
[MessagePackObject] public sealed class DkEl2 { [ Key ( 0 ) ] [ CultElementId ] public string Id = string . Empty ; [ Key ( 1 ) ] public int N ; [ Key ( 1 ) ] private int shadow ; }
#pragma warning restore CS0169 , CS0414 , CS0649
2026-10-15 03:00:58 +02:00
[MessagePackObject] public sealed class DkEl3 { [ Key ( 0 ) ] [ CultElementId ] public string Id = string . Empty ; [ Key ( 1 ) ] public int N ; [ Key ( 1 ) ] public int M ; }
[CultDocument("tests.dk_doc3", "tests.dk_doc3.v1")]
[MessagePackObject]
public sealed class DkDoc3 { [ Key ( 0 ) ] [ CultName ] public string Id = string . Empty ; [ Key ( 1 ) ] public List < DkEl3 > Items = new ( ) ; }
2026-10-15 02:53:28 +02:00
[CultDocument("tests.dk_doc", "tests.dk_doc.v1")]
[MessagePackObject]
public sealed class DkDoc { [ Key ( 0 ) ] [ CultName ] public string Id = string . Empty ; [ Key ( 1 ) ] public List < DkEl > Items = new ( ) ; }
[CultDocument("tests.dk_doc2", "tests.dk_doc2.v1")]
[MessagePackObject]
public sealed class DkDoc2 { [ Key ( 0 ) ] [ CultName ] public string Id = string . Empty ; [ Key ( 1 ) ] public List < DkEl2 > Items = new ( ) ; }
2026-10-15 03:09:39 +02:00
[MessagePackFormatter(typeof(FormattedRolesFormatter))] public sealed class FormattedRoles : List < FbArm > { }
public sealed class FormattedRolesFormatter : IMessagePackFormatter < FormattedRoles >
{
public void Serialize ( ref MessagePackWriter writer , FormattedRoles value , MessagePackSerializerOptions options ) = > writer . WriteNil ( ) ;
public FormattedRoles Deserialize ( ref MessagePackReader reader , MessagePackSerializerOptions options ) { reader . Skip ( ) ; return new FormattedRoles ( ) ; }
}
2026-10-15 20:52:13 +02:00
[DataContract] public sealed class DcSeq : IEnumerable < FbArm >
{
[DataMember(Order = 0)] public List < FbArm > Items = new ( ) ;
public IEnumerator < FbArm > GetEnumerator ( ) = > Items . GetEnumerator ( ) ;
System . Collections . IEnumerator System . Collections . IEnumerable . GetEnumerator ( ) = > GetEnumerator ( ) ;
}
2026-10-16 00:54:37 +02:00
public interface IArms : IEnumerable < FbArm > { }
public abstract class AbsArms : IEnumerable < FbArm >
{
public abstract IEnumerator < FbArm > GetEnumerator ( ) ;
System . Collections . IEnumerator System . Collections . IEnumerable . GetEnumerator ( ) = > GetEnumerator ( ) ;
}
[MessagePackObject] public sealed class KLL : List < List < FbArm > > { [ Key ( 0 ) ] public int N ; }
#pragma warning disable CS0169 , CS0414 , CS0649
[MessagePackObject(AllowPrivate = true)] public sealed class ApTrueHold { [ Key ( 0 ) ] private List < FbArm > items = new ( ) ; }
#pragma warning restore CS0169 , CS0414 , CS0649
2026-10-15 20:52:13 +02:00
[DataContract] public sealed class DcColl : List < FbArm > { [ DataMember ( Order = 0 ) ] public int N ; }
[DataContract] public sealed class DcBad : List < FbArm > { [ DataMember ( Order = 0 ) ] public int A ; [ DataMember ( Order = 0 ) ] public int B ; }
2026-10-15 02:53:28 +02:00
public sealed class DkFormatter : IMessagePackFormatter < DkEl >
{
public void Serialize ( ref MessagePackWriter writer , DkEl value , MessagePackSerializerOptions options ) = > writer . Write ( value . N ) ;
public DkEl Deserialize ( ref MessagePackReader reader , MessagePackSerializerOptions options ) = > new DkEl { N = reader . ReadInt32 ( ) } ;
}
2026-10-15 01:23:40 +02:00
[MessagePackObject]
public sealed class ApArm
{
[Key(0)] [ CultElementId ] public string Id = string . Empty ;
[Key(1)] public int N ;
[Key(2)] private int secret ;
[IgnoreMember] public int Secret = > secret ;
public ApArm WithSecret ( int value )
{
secret = value ;
return this ;
}
}
[CultDocument("tests.ap_doc", "tests.ap_doc.v1")]
[MessagePackObject]
public sealed class ApDoc
{
[Key(0)] [ CultName ] public string Id = string . Empty ;
[Key(1)] public List < ApArm > Items = new ( ) ;
}
[MessagePackObject]
public sealed class ApPlainArm
{
[Key(0)] [ CultElementId ] public string Id = string . Empty ;
[Key(1)] public int N ;
}
[CultDocument("tests.ap_plain_doc", "tests.ap_plain_doc.v1")]
[MessagePackObject]
public sealed class ApPlainDoc
{
[Key(0)] [ CultName ] public string Id = string . Empty ;
[Key(1)] public List < ApPlainArm > Items = new ( ) ;
}
2026-10-15 00:26:53 +02:00
[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 ( ) ;
2026-10-15 00:33:46 +02:00
if ( typeof ( T ) = = typeof ( FbArm ) ) return global :: MessagePack . Resolvers . StandardResolver . Instance . GetFormatter < T > ( ) ;
2026-10-15 02:53:28 +02:00
if ( typeof ( T ) = = typeof ( DkEl ) ) return ( IMessagePackFormatter < T > ) ( object ) new DkFormatter ( ) ;
if ( typeof ( T ) = = typeof ( DkEl2 ) ) return global :: MessagePack . Resolvers . StandardResolverAllowPrivate . Instance . GetFormatter < T > ( ) ;
2026-10-15 01:23:40 +02:00
if ( typeof ( T ) = = typeof ( ApArm ) | | typeof ( T ) = = typeof ( ApDoc ) | | typeof ( T ) = = typeof ( List < ApArm > ) | |
typeof ( T ) = = typeof ( ApPlainArm ) | | typeof ( T ) = = typeof ( ApPlainDoc ) | | typeof ( T ) = = typeof ( List < ApPlainArm > ) )
return global :: MessagePack . Resolvers . StandardResolverAllowPrivate . Instance . GetFormatter < T > ( ) ;
2026-10-15 00:26:53 +02:00
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 ;
}
}
}
2026-10-15 00:35:57 +02:00
// Composed after ListClaimResolver: once that resolver has answered FbArm with the standard formatter the composite never
// asks this one, so its claim on the same type must not refuse the document.
public sealed class FbLaterClaimResolver : IFormatterResolver
{
public static readonly IFormatterResolver Instance = new FbLaterClaimResolver ( ) ;
public IMessagePackFormatter < T > ? GetFormatter < T > ( ) = > typeof ( T ) = = typeof ( FbArm ) ? ( IMessagePackFormatter < T > ) ( object ) new LaterFormatter ( ) : null ;
private sealed class LaterFormatter : IMessagePackFormatter < FbArm >
{
public void Serialize ( ref MessagePackWriter writer , FbArm value , MessagePackSerializerOptions options ) = > writer . WriteNil ( ) ;
public FbArm Deserialize ( ref MessagePackReader reader , MessagePackSerializerOptions options )
{
reader . Skip ( ) ;
return new FbArm ( ) ;
}
}
}
2026-10-14 22:33:39 +02:00
[Test]
public void OptionsRefuseANullDocumentTypeOrAssembly ( )
{
Assert . Throws < ArgumentNullException > ( ( ) = > CultDocumentMessagePackSerialization . OptionsFor ( null ! ) ) ;
Assert . Throws < ArgumentNullException > ( ( ) = > CultDocumentMessagePackSerialization . OptionsForDocument ( null ! ) ) ;
}
2026-10-14 21:52:36 +02:00
[Union(0, typeof(ClaimArm))]
public abstract class ClaimNode { }
[MessagePackObject]
public sealed class ClaimArm : ClaimNode
{
[Key(0)] [ CultElementId ] public string Id = string . Empty ;
[Key(1)] public int N ;
}
[CultDocument("tests.claim_doc", "tests.claim_doc.v1")]
[MessagePackObject]
public sealed class ClaimDoc
{
[Key(0)] [ CultName ] public string Id = string . Empty ;
[Key(1)] public List < ClaimNode > Items = new ( ) ;
}
public sealed class ClaimArmResolver : IFormatterResolver
{
public static readonly IFormatterResolver Instance = new ClaimArmResolver ( ) ;
public IMessagePackFormatter < T > ? GetFormatter < T > ( ) = > typeof ( T ) = = typeof ( ClaimArm ) ? ( IMessagePackFormatter < T > ) ( object ) new ClaimFormatter ( ) : null ;
private sealed class ClaimFormatter : IMessagePackFormatter < ClaimArm >
{
public void Serialize ( ref MessagePackWriter writer , ClaimArm value , MessagePackSerializerOptions options )
{
writer . WriteArrayHeader ( 1 ) ;
writer . Write ( value . N ) ;
}
public ClaimArm Deserialize ( ref MessagePackReader reader , MessagePackSerializerOptions options )
{
reader . ReadArrayHeader ( ) ;
return new ClaimArm { N = reader . ReadInt32 ( ) } ;
}
}
}
2026-10-10 22:41:04 +02:00
// 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 < GuardDoc > ( new CultRecordKey ( "k" ) ) , options ) , Is . EqualTo ( Raw ( ( ref MessagePackWriter w ) = > w . Write ( "consumer-ref" ) ) ) ) ;
}
2026-10-10 22:56:53 +02:00
// 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 < Custom > ( new CustomArm ( ) , options ) , Is . EqualTo ( Raw ( ( ref MessagePackWriter w ) = > w . Write ( "consumer-union" ) ) ) ) ;
Assert . Throws < MessagePackSerializationException > ( ( ) = > MessagePackSerializer . Deserialize < Custom > (
Raw ( ( ref MessagePackWriter w ) = > { w . WriteArrayHeader ( 2 ) ; w . Write ( 5 ) ; w . WriteArrayHeader ( 0 ) ; } ) , options ) ) ;
Assert . Throws < MessagePackSerializationException > ( ( ) = > MessagePackSerializer . Serialize < Custom > ( new CustomRogue ( ) , options ) ) ;
}
[Union(0, typeof(CustomArm))] public abstract class Custom { }
[MessagePackObject] public sealed class CustomArm : Custom { }
[MessagePackObject] public sealed class CustomRogue : Custom { }
2026-10-10 22:41:04 +02:00
// 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 < AttributeUsageAttribute > ( ) . 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 < T > ? GetFormatter < T > ( ) = >
typeof ( T ) = = typeof ( Overridden ) ? ( IMessagePackFormatter < T > ) ( object ) new Fixed < Overridden > ( new Overridden ( ) , "consumer" )
2026-10-10 22:56:53 +02:00
: typeof ( T ) = = typeof ( Custom ) ? ( IMessagePackFormatter < T > ) ( object ) new Fixed < Custom > ( new CustomArm ( ) , "consumer-union" )
2026-10-10 22:41:04 +02:00
: typeof ( T ) = = typeof ( CultRecordRef < GuardDoc > ) ? ( IMessagePackFormatter < T > ) ( object ) new Fixed < CultRecordRef < GuardDoc > > ( default , "consumer-ref" )
: null ;
private sealed class Fixed < V > ( V value , string text ) : IMessagePackFormatter < V >
{
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 ; }
2026-10-10 23:48:49 +02:00
// 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" ) ;
}
2026-10-11 00:07:37 +02:00
// 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 < Leaf > ( ) ) ;
}
}
2026-10-10 23:48:49 +02:00
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 < OverflowException > ( ) ) ;
} ) ;
}
// 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 ) ;
} ) ;
2026-10-14 19:42:39 +02:00
private static Emit CircleArm ( int key , int radius ) = > ( ref MessagePackWriter w ) = >
{
w . WriteArrayHeader ( 2 ) ; w . Write ( key ) ;
w . WriteArrayHeader ( 2 ) ; w . Write ( radius ) ; w . Write ( "0000000000a1" ) ;
} ;
2026-10-10 23:48:49 +02:00
[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 < MessagePackSerializationException > (
( ) = > CultDocumentMessagePackSerialization . SerializeUntyped ( doc , typeof ( ShapeDoc ) , Registry ) ) ! ) ;
Assert . That ( message , Does . Contain ( "is not an arm of union" ) . And . Contain ( ShapeName ) ) ;
}
}
[Test]
public void KnownArmsOfAnInterfaceUnionByteIdentical ( )
{
2026-10-14 19:42:39 +02:00
var doc = new ShapeDoc { Id = "s1" , Shape = new Square { N = 4 , Id = "0000000000a1" } , Shapes = { new Circle { N = 2 , Id = "0000000000a1" } , null ! } } ;
2026-10-10 23:48:49 +02:00
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 < Square > ( ) ) ;
Assert . That ( back . Shapes [ 0 ] , Is . TypeOf < Circle > ( ) ) ;
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" ,
2026-10-14 19:41:50 +02:00
Payload = Doc ( LeafArm ( 3 , "000000000005" , "ok" , 1 ) )
2026-10-10 23:48:49 +02:00
} ,
new CultPersistedRecord
{
Key = "variant" , SchemaId = descriptor . SchemaId , StoredAt = "2026-01-01T00:00:00.0000000Z" ,
Payload = Array . Empty < byte > ( ) ,
2026-10-14 19:41:50 +02:00
Variant = new CultVariantDelta ( "base" , new [ ] { CultVariantOverride . Set ( 1 , Raw ( LeafArm ( 4 , "000000000005" , Canary , 7 ) ) ) } )
2026-10-10 23:48:49 +02:00
}
}
} ) ) ;
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 ) ;
}
}
2026-10-10 21:17:56 +02:00
// 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" ) ;
2026-10-14 19:41:50 +02:00
var genuine = Doc ( LeafArm ( 3 , "000000000005" , Canary , 7 ) ) ;
2026-10-10 21:17:56 +02:00
using ( var writer = CultCacheMessagePack . Create ( path , new CultCacheOpenOptions { Registry = Registry , UseDirectoryStore = directory , StoreFlushOnDispose = true } ) )
{
2026-10-14 19:41:50 +02:00
await writer . UpsertAsync ( typeof ( GuardDoc ) , new GuardDoc { Id = "d1" , Root = new Leaf { Id = "000000000005" , Content = Canary , Weight = 7 } } , new CultRecordKey ( "d1" ) ) ;
2026-10-10 21:17:56 +02:00
await writer . FlushAsync ( ) ;
}
2026-10-14 19:41:50 +02:00
var forged = Doc ( LeafArm ( key , "000000000005" , Canary , 7 ) ) ;
2026-10-10 21:17:56 +02:00
Assert . That ( forged . Length , Is . EqualTo ( genuine . Length ) ) ;
2026-10-10 21:50:53 +02:00
// 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.
2026-10-10 21:17:56 +02:00
var patched = 0 ;
2026-10-10 21:50:53 +02:00
var rehashed = new List < ( byte [ ] Old , byte [ ] New ) > ( ) ;
foreach ( var file in Directory . EnumerateFiles ( folder , "*" , SearchOption . AllDirectories ) . ToArray ( ) )
2026-10-10 21:17:56 +02:00
{
var bytes = File . ReadAllBytes ( file ) ;
var at = bytes . AsSpan ( ) . IndexOf ( genuine ) ;
if ( at < 0 ) continue ;
2026-10-10 21:50:53 +02:00
var before = System . Security . Cryptography . SHA256 . HashData ( bytes ) ;
2026-10-10 21:17:56 +02:00
forged . CopyTo ( bytes , at ) ;
2026-10-10 21:50:53 +02:00
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 ) ;
}
2026-10-10 21:17:56 +02:00
patched + + ;
}
2026-10-10 21:50:53 +02:00
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 ) ;
}
2026-10-10 21:17:56 +02:00
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 } ) ;
} ) ;
2026-10-10 22:56:53 +02:00
// The outermost message alone says it: a caller that prints only .Message must see the record and the union.
var message = thrown ! . Message ;
2026-10-10 21:17:56 +02:00
Assert . Multiple ( ( ) = >
{
Assert . That ( message , Does . Contain ( "Record 'd1'" ) ) ;
2026-10-10 21:35:37 +02:00
Assert . That ( message , Does . Contain ( $"(schema '{Registry.GetRequired(typeof(GuardDoc)).SchemaId}')" ) ) ;
2026-10-10 21:17:56 +02:00
Assert . That ( message , Does . Contain ( NodeName ) ) ;
Assert . That ( message , Does . Contain ( $"key {key} " ) ) ;
2026-10-10 22:56:53 +02:00
Assert . That ( Chain ( thrown ) , Does . Not . Contain ( Canary ) ) ;
2026-10-10 21:17:56 +02:00
} ) ;
}
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.
2026-10-14 21:52:36 +02:00
[Union(3, typeof(Leaf)), Union(5, typeof(Branch)), Union(8, typeof(Unit8)), Union(9, typeof(Nine))]
2026-10-10 21:17:56 +02:00
public abstract class GuardNode { }
[MessagePackObject]
public class Leaf : GuardNode
{
2026-10-14 19:40:37 +02:00
[Key(0)] [ CultElementId ] public string Id = string . Empty ;
2026-10-10 21:17:56 +02:00
[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 < GuardNode > Children = new ( ) ;
[Key(1)] public GuardNode ? Next ;
2026-10-14 19:40:37 +02:00
[Key(2)] [ CultElementId ] public string Id = string . Empty ;
2026-10-10 21:17:56 +02:00
}
2026-10-14 19:40:37 +02:00
[MessagePackObject] public sealed class Unit8 : GuardNode { [ Key ( 0 ) ] [ CultElementId ] public string Id = string . Empty ; }
2026-10-10 21:17:56 +02:00
2026-10-14 21:52:36 +02:00
[MessagePackObject] public sealed class Nine : GuardNode { [ Key ( 0 ) ] [ CultElementId ] public string Id = string . Empty ; }
2026-10-10 21:17:56 +02:00
// A subtype the union does not name.
[MessagePackObject] public sealed class Rogue : GuardNode { [ Key ( 0 ) ] public string Note = string . Empty ; }
2026-10-14 21:52:36 +02:00
// Written and read by a consumer resolver, not by MessagePack. It can only sit outside a list: a list element is written
// by its [Key] layout, or its id never reaches disk (ListElementsClaimedByAResolverAreRefused).
[Union(9, typeof(StubArm))]
public abstract class StubNode { }
public sealed class StubArm : StubNode { [ Key ( 0 ) ] [ CultElementId ] public string Id = string . Empty ; }
[CultDocument("tests.stub_doc", "tests.stub_doc.v1")]
[MessagePackObject]
public sealed class StubDoc
{
[Key(0)] [ CultName ] public string Id = string . Empty ;
[Key(1)] public StubNode ? Root ;
}
2026-10-10 21:17:56 +02:00
public sealed class StubArmResolver : IFormatterResolver
{
public static readonly IFormatterResolver Instance = new StubArmResolver ( ) ;
public IMessagePackFormatter < T > ? GetFormatter < T > ( ) = > typeof ( T ) = = typeof ( StubArm ) ? ( IMessagePackFormatter < T > ) ( object ) new StubFormatter ( ) : null ;
private sealed class StubFormatter : IMessagePackFormatter < StubArm >
{
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 < GuardNode > Items = new ( ) ;
[Key(3)] public Inner ? Crafted ;
[Key(4)] public Outer ? Any ;
}
2026-10-10 23:48:49 +02:00
// 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 { }
2026-10-14 19:42:39 +02:00
[MessagePackObject] public class Circle : IShape { [ Key ( 0 ) ] public int N ; [ Key ( 1 ) ] [ CultElementId ] public string Id = string . Empty ; }
[MessagePackObject] public sealed class Square : IShape { [ Key ( 0 ) ] public int N ; [ Key ( 1 ) ] [ CultElementId ] public string Id = string . Empty ; }
2026-10-10 23:48:49 +02:00
[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 < IShape > Shapes = new ( ) ;
}
2026-10-10 21:17:56 +02:00
}
}