diff --git a/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs b/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs index 46bd8de8..2a3353de 100644 --- a/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs +++ b/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs @@ -83,7 +83,9 @@ public static class CultDocumentMessagePackSerialization { if (registry == null) throw new ArgumentNullException(nameof(registry)); if (value != null) registry.GetRequired(type); - return MessagePackSerializer.Serialize(type, value, OptionsFor(type.Assembly)); + var payload = MessagePackSerializer.Serialize(type, value, OptionsFor(type.Assembly)); + CultPayloadDepth.Check(payload, type, PayloadDirection.Write); + return payload; } public static object DeserializeUntyped(Type type, byte[] payload) @@ -95,6 +97,7 @@ public static class CultDocumentMessagePackSerialization { if (registry == null) throw new ArgumentNullException(nameof(registry)); registry.GetRequired(type); + CultPayloadDepth.Check(payload, type, PayloadDirection.Read); return MessagePackSerializer.Deserialize(type, payload, OptionsFor(type.Assembly)) ?? throw new InvalidOperationException($"MessagePack returned null for Cult document type {type.FullName}."); } diff --git a/src/GameCult.Caching.MessagePack/CultMessagePackSecurity.cs b/src/GameCult.Caching.MessagePack/CultMessagePackSecurity.cs index 64b237d5..01130724 100644 --- a/src/GameCult.Caching.MessagePack/CultMessagePackSecurity.cs +++ b/src/GameCult.Caching.MessagePack/CultMessagePackSecurity.cs @@ -7,7 +7,15 @@ namespace GameCult.Caching.MessagePack; // UntrustedData, plus a collision-resistant comparer for CultRecordRef dictionary keys over the key string. public sealed class CultMessagePackSecurity : MessagePackSecurity { - public static readonly CultMessagePackSecurity Instance = new(UntrustedData); + /// + /// The deepest a document payload may nest, on read and on write, in every CultCache runtime: every MessagePack array and + /// map counts, read by a formatter or skipped past it, and the outermost array is depth 1. A payload of this depth is + /// accepted and one container deeper is refused. MessagePack's own object-graph counter is set to the same value as a + /// backstop; the rule itself is CultPayloadDepth.Check, run by the document serialization entry points. + /// + public const int MaximumPayloadDepth = 256; + + public static readonly CultMessagePackSecurity Instance = new(UntrustedData.WithMaximumObjectGraphDepth(MaximumPayloadDepth)); private CultMessagePackSecurity(MessagePackSecurity copyFrom) : base(copyFrom) { } diff --git a/src/GameCult.Caching.MessagePack/CultPayloadDepth.cs b/src/GameCult.Caching.MessagePack/CultPayloadDepth.cs new file mode 100644 index 00000000..258663e8 --- /dev/null +++ b/src/GameCult.Caching.MessagePack/CultPayloadDepth.cs @@ -0,0 +1,94 @@ +using System; +using System.Buffers.Binary; +using MessagePack; + +namespace GameCult.Caching.MessagePack; + +internal enum PayloadDirection { Read, Write } + +/// +/// The one payload nesting count. Walks the MessagePack headers of a document payload iteratively, counting every array and +/// map (read by a formatter or skipped past it; the outermost array is depth 1) and refusing the first container past +/// . Scalars are skipped by length, nothing is allocated per +/// element, and the count stack is bounded by the limit. A refusal names the type, the depth reached and the limit, +/// never payload bytes. A payload that ends early is the deserializer's to refuse. +/// +internal static class CultPayloadDepth +{ + public static void Check(ReadOnlySpan bytes, Type type, PayloadDirection direction) + { + const int limit = CultMessagePackSecurity.MaximumPayloadDepth; + Span remaining = stackalloc long[limit]; + var depth = 0; + long position = 0; + while (position < bytes.Length) + { + var code = bytes[(int)position]; + long children = 0; + long header = 0; + long skip = 0; + var container = false; + switch (code) + { + case <= 0x7f or >= 0xe0 or 0xc0 or 0xc1 or 0xc2 or 0xc3: skip = 1; break; + case <= 0x8f: container = true; header = 1; children = 2L * (code & 0x0f); break; + case <= 0x9f: container = true; header = 1; children = code & 0x0f; break; + case <= 0xbf: skip = 1 + (code & 0x1f); break; + case 0xc4 or 0xd9: if (!Length(bytes, position, 1, out var l8)) return; skip = 2 + l8; break; + case 0xc5 or 0xda: if (!Length(bytes, position, 2, out var l16)) return; skip = 3 + l16; break; + case 0xc6 or 0xdb: if (!Length(bytes, position, 4, out var l32)) return; skip = 5 + l32; break; + case 0xc7: if (!Length(bytes, position, 1, out var e8)) return; skip = 3 + e8; break; + case 0xc8: if (!Length(bytes, position, 2, out var e16)) return; skip = 4 + e16; break; + case 0xc9: if (!Length(bytes, position, 4, out var e32)) return; skip = 6 + e32; break; + case 0xca or 0xce or 0xd2: skip = 5; break; + case 0xcb or 0xcf or 0xd3: skip = 9; break; + case 0xcc or 0xd0: skip = 2; break; + case 0xcd or 0xd1 or 0xd4: skip = 3; break; + case 0xd5: skip = 4; break; + case 0xd6: skip = 6; break; + case 0xd7: skip = 10; break; + case 0xd8: skip = 18; break; + case 0xdc: if (!Length(bytes, position, 2, out var a16)) return; container = true; header = 3; children = a16; break; + case 0xdd: if (!Length(bytes, position, 4, out var a32)) return; container = true; header = 5; children = a32; break; + case 0xde: if (!Length(bytes, position, 2, out var m16)) return; container = true; header = 3; children = 2 * m16; break; + default: if (!Length(bytes, position, 4, out var m32)) return; container = true; header = 5; children = 2 * m32; break; + } + + if (container) + { + if (depth == limit) + throw new MessagePackSerializationException( + $"{type.FullName} payload nests {limit + 1} MessagePack containers deep; the limit is {limit} (refused on {(direction == PayloadDirection.Read ? "read" : "write")})."); + position += header; + if (children > 0) + { + remaining[depth++] = children; + continue; + } + } + else + { + position += skip; + } + + // One element is complete; close every container it was the last element of. + while (depth > 0 && --remaining[depth - 1] == 0) depth--; + if (depth == 0) return; + } + } + + // The big-endian length that follows the code byte, when the payload is long enough to hold it. + private static bool Length(ReadOnlySpan bytes, long position, int width, out long length) + { + length = 0; + if (position + 1 + width > bytes.Length) return false; + var field = bytes.Slice((int)position + 1, width); + length = width switch + { + 1 => field[0], + 2 => BinaryPrimitives.ReadUInt16BigEndian(field), + _ => BinaryPrimitives.ReadUInt32BigEndian(field) + }; + return true; + } +} diff --git a/src/GameCult.Caching.MessagePack/CultUnionGuard.cs b/src/GameCult.Caching.MessagePack/CultUnionGuard.cs index af7a63c2..2b60c80b 100644 --- a/src/GameCult.Caching.MessagePack/CultUnionGuard.cs +++ b/src/GameCult.Caching.MessagePack/CultUnionGuard.cs @@ -46,6 +46,8 @@ internal sealed class CultUnionGuard(IFormatterResolver inner) : IFormatterResol 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}."); + if (peek.TryReadNil()) + throw new MessagePackSerializationException($"Union {typeof(T).FullName} arm {key} has nil slots (slot 1 of the arm); declared keys: {declared}."); } return inner.Deserialize(ref reader, options); diff --git a/src/GameCult.Caching/Contracts/cultcache-persistence-format.md b/src/GameCult.Caching/Contracts/cultcache-persistence-format.md index 1205d904..57b5d7fb 100644 --- a/src/GameCult.Caching/Contracts/cultcache-persistence-format.md +++ b/src/GameCult.Caching/Contracts/cultcache-persistence-format.md @@ -217,6 +217,13 @@ Under `cultcache.store.v1` a record has exactly these four slots. A reader refus header allows, naming the version or the record's key and schema id. It never skips a slot it does not understand. +A record's payload nests at most 256 MessagePack containers. Every array and map +counts, whether a reader decodes it or skips past it, and the outermost array is +depth 1: a payload of depth 256 is accepted and one of depth 257 is refused. Every +runtime enforces the same bound on read and on write, and the refusal names the type, +the depth and the limit, never payload bytes. The vectors are in +`tests/vectors/union-parity` (`bounds-outcomes.msgpack`). + ## Variant Records A variant record is a record whose document is a base record's document with some diff --git a/tests/GameCult.Caching.Tests/PayloadDepthTests.cs b/tests/GameCult.Caching.Tests/PayloadDepthTests.cs new file mode 100644 index 00000000..91aa9a6f --- /dev/null +++ b/tests/GameCult.Caching.Tests/PayloadDepthTests.cs @@ -0,0 +1,157 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Linq; +using GameCult.Caching.MessagePack; +using MessagePack; +using NUnit.Framework; + +namespace GameCult.Caching.Tests +{ + // The payload walk at its own seam: every MessagePack header and scalar width must be skipped by its length, so that a + // container after it is counted at its true depth. Each case is a two-slot document [probe, chain] whose chain brings + // the whole payload to exactly the limit (accepted) or one past it (refused); a header or length read wrongly loses the + // chain and flips one of the two. The probes' filler bytes look like container headers on purpose. + public class PayloadDepthTests + { + private const int Limit = CultMessagePackSecurity.MaximumPayloadDepth; + + private static byte[] Cat(params byte[][] parts) => parts.SelectMany(p => p).ToArray(); + + private static byte[] Bytes(params int[] values) => values.Select(v => (byte)v).ToArray(); + + private static byte[] Filler(int n) => Enumerable.Range(0, n).Select(i => (byte)(i % 2 == 0 ? 0x91 : 0xdd)).ToArray(); + + // `depth` containers nested in one another, the innermost empty; a fixarray header nests by one element. + private static byte[] Chain(int depth, byte[]? leaf = null) => + Cat(Enumerable.Repeat((byte)0x91, depth - 1).ToArray(), leaf ?? Bytes(0x90)); + + private static byte[] Doc(byte[] probe, byte[] second) => Cat(Bytes(0x92), probe, second); + + private static byte[] Len16(int n) => Bytes(n >> 8, n & 0xff); + + private static byte[] Len32(int n) => Bytes(n >> 24, (n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff); + + private static readonly (string Name, byte[] Bytes)[] Scalars = + { + ("positive fixint", Bytes(0x7f)), ("negative fixint", Bytes(0xe0)), ("max negative fixint", Bytes(0xff)), + ("nil", Bytes(0xc0)), ("false", Bytes(0xc2)), ("true", Bytes(0xc3)), + ("uint8", Bytes(0xcc, 0x91)), ("uint16", Bytes(0xcd, 0x91, 0x91)), ("uint32", Bytes(0xce, 0x91, 0x91, 0x91, 0x91)), + ("uint64", Cat(Bytes(0xcf), Filler(8))), ("int8", Bytes(0xd0, 0x91)), ("int16", Bytes(0xd1, 0x91, 0x91)), + ("int32", Bytes(0xd2, 0x91, 0x91, 0x91, 0x91)), ("int64", Cat(Bytes(0xd3), Filler(8))), + ("float32", Cat(Bytes(0xca), Filler(4))), ("float64", Cat(Bytes(0xcb), Filler(8))), + ("empty fixstr", Bytes(0xa0)), ("fixstr", Cat(Bytes(0xa3), Filler(3))), ("longest fixstr", Cat(Bytes(0xbf), Filler(31))), + ("str8", Cat(Bytes(0xd9, 5), Filler(5))), ("empty str8", Bytes(0xd9, 0)), + ("str16", Cat(Bytes(0xda), Len16(256), Filler(256))), ("str32", Cat(Bytes(0xdb), Len32(65536), Filler(65536))), + ("bin8", Cat(Bytes(0xc4, 3), Filler(3))), ("bin16", Cat(Bytes(0xc5), Len16(256), Filler(256))), + ("bin32", Cat(Bytes(0xc6), Len32(65536), Filler(65536))), + ("fixext1", Cat(Bytes(0xd4, 1), Filler(1))), ("fixext2", Cat(Bytes(0xd5, 1), Filler(2))), + ("fixext4", Cat(Bytes(0xd6, 1), Filler(4))), ("fixext8", Cat(Bytes(0xd7, 1), Filler(8))), + ("fixext16", Cat(Bytes(0xd8, 1), Filler(16))), + ("ext8", Cat(Bytes(0xc7, 3, 1), Filler(3))), ("ext16", Cat(Bytes(0xc8), Len16(256), Bytes(1), Filler(256))), + ("ext32", Cat(Bytes(0xc9), Len32(65536), Bytes(1), Filler(65536))), + }; + + // Containers holding scalar children, so that the child count (a map's is twice its pair count) is what keeps the walk in step. + private static readonly (string Name, byte[] Bytes)[] Containers = + { + ("fixarray", Cat(Bytes(0x9f), Enumerable.Repeat((byte)1, 15).ToArray())), + ("array16", Cat(Bytes(0xdc), Len16(3), Bytes(1, 1, 1))), + ("array32", Cat(Bytes(0xdd), Len32(2), Bytes(1, 1))), + ("fixmap", Cat(Bytes(0x82), Bytes(1, 1, 1, 1))), + ("largest fixmap", Cat(Bytes(0x8f), Enumerable.Repeat((byte)1, 30).ToArray())), + ("map16", Cat(Bytes(0xde), Len16(2), Bytes(1, 1, 1, 1))), + ("map32", Cat(Bytes(0xdf), Len32(1), Bytes(1, 1))), + ("empty fixarray", Bytes(0x90)), ("empty array16", Bytes(0xdc, 0, 0)), ("empty array32", Bytes(0xdd, 0, 0, 0, 0)), + ("empty fixmap", Bytes(0x80)), ("empty map16", Bytes(0xde, 0, 0)), ("empty map32", Bytes(0xdf, 0, 0, 0, 0)), + }; + + // A single-child carrier of each container kind, for nesting through every header. + private static readonly (string Name, Func Wrap)[] Carriers = + { + ("fixarray", inner => Cat(Bytes(0x91), inner)), + ("array16", inner => Cat(Bytes(0xdc, 0, 1), inner)), + ("array32", inner => Cat(Bytes(0xdd, 0, 0, 0, 1), inner)), + ("fixmap", inner => Cat(Bytes(0x81, 1), inner)), + ("map16", inner => Cat(Bytes(0xde, 0, 1, 1), inner)), + ("map32", inner => Cat(Bytes(0xdf, 0, 0, 0, 1, 1), inner)), + }; + + private static void Accepts(byte[] payload, string what) => + Assert.That(() => CultPayloadDepth.Check(payload, typeof(object), PayloadDirection.Read), Throws.Nothing, what); + + private static void Refuses(byte[] payload, string what) => + Assert.That(() => CultPayloadDepth.Check(payload, typeof(object), PayloadDirection.Read), + Throws.TypeOf().With.Message.Contains($"the limit is {Limit}"), what); + + [Test] + public void EveryScalarIsSkippedByItsLength() + { + foreach (var (name, scalar) in Scalars) + { + Accepts(Doc(scalar, Chain(Limit - 1)), $"{name} then a chain of depth {Limit}"); + Refuses(Doc(scalar, Chain(Limit)), $"{name} then a chain of depth {Limit + 1}"); + } + } + + [Test] + public void EveryContainerKnowsItsChildCount() + { + foreach (var (name, container) in Containers) + { + Accepts(Doc(container, Chain(Limit - 1)), $"{name} then a chain of depth {Limit}"); + Refuses(Doc(container, Chain(Limit)), $"{name} then a chain of depth {Limit + 1}"); + } + } + + [Test] + public void NestingThroughEveryHeaderIsCountedAndAnEmptyLeafCounts() + { + foreach (var (name, wrap) in Carriers) + { + Accepts(wrap(Chain(Limit - 1)), $"{name} carrying a chain, depth {Limit}"); + Refuses(wrap(Chain(Limit)), $"{name} carrying a chain, depth {Limit + 1}"); + } + + // The deepest container is empty and the payload ends with its header: it still counts. + foreach (var (name, leaf) in Containers.Where(c => c.Name.StartsWith("empty"))) + { + Accepts(Chain(Limit, leaf), $"{name} as the leaf at depth {Limit}"); + Refuses(Chain(Limit + 1, leaf), $"{name} as the leaf at depth {Limit + 1}"); + } + } + + [Test] + public void ARefusalNamesTheTypeTheDepthTheLimitAndTheDirection() + { + var deep = Chain(Limit + 5, Cat(Bytes(0xa5), System.Text.Encoding.ASCII.GetBytes("CANRY"))); + var read = Assert.Throws(() => CultPayloadDepth.Check(deep, typeof(PayloadDepthTests), PayloadDirection.Read))!; + var write = Assert.Throws(() => CultPayloadDepth.Check(deep, typeof(PayloadDepthTests), PayloadDirection.Write))!; + Assert.That(read.Message, Is.EqualTo($"{typeof(PayloadDepthTests).FullName} payload nests {Limit + 1} MessagePack containers deep; the limit is {Limit} (refused on read).")); + Assert.That(write.Message, Is.EqualTo($"{typeof(PayloadDepthTests).FullName} payload nests {Limit + 1} MessagePack containers deep; the limit is {Limit} (refused on write).")); + Assert.That(read.Message + write.Message, Does.Not.Contain("CANRY")); + } + + [Test] + public void ATruncatedPayloadIsTheDeserializersToRefuse() + { + foreach (var truncated in new[] { Bytes(), Bytes(0xdc), Bytes(0xdc, 0), Bytes(0xdd, 0, 0, 0), Bytes(0xd9), Bytes(0xda, 1), Bytes(0xc6, 0, 0, 1) }) + Accepts(truncated, $"{Convert.ToHexString(truncated)} ends inside a header"); + + // A header that fits exactly is read, not treated as cut short. + Refuses(Chain(Limit + 1, Bytes(0xdc, 0, 0)), "an array16 header ending the payload is read"); + Accepts(Cat(Bytes(0x91, 0xd9, 200), Filler(10)), "a string cut short is not walked past its end"); + + // A container header cut short at the limit is not yet a container: the deserializer refuses it, not the walk. + foreach (var cut in new[] { Bytes(0xdc, 0), Bytes(0xdd, 0, 0, 0), Bytes(0xde, 0), Bytes(0xdf, 0, 0, 0) }) + Accepts(Chain(Limit + 1, cut), $"{Convert.ToHexString(cut)} cut short at depth {Limit + 1}"); + } + + [Test] + public void OnlyTheFirstValueIsThePayload() + { + Accepts(Cat(Bytes(0x90), Chain(Limit + 10)), "bytes after the first value are not walked"); + Accepts(Cat(Bytes(0x01), Chain(Limit + 10)), "nor after a scalar"); + } + } +} diff --git a/tests/GameCult.Caching.Tests/UnionParityVectorTests.cs b/tests/GameCult.Caching.Tests/UnionParityVectorTests.cs index a710a96b..2561b028 100644 --- a/tests/GameCult.Caching.Tests/UnionParityVectorTests.cs +++ b/tests/GameCult.Caching.Tests/UnionParityVectorTests.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; using System.Security.Cryptography; +using System.Threading; using GameCult.Caching; using GameCult.Caching.MessagePack; using MessagePack; @@ -66,10 +67,17 @@ namespace GameCult.Caching.Tests // ---- the fixture: a recursive union, a unit arm, a union inside a record inside a union, a list of unions ---- - // Keys 0, 1, 2 and 5: 3 and 4 are retired, so the key map is not sequential. - [Union(0, typeof(Box)), Union(1, typeof(Text)), Union(2, typeof(Use)), Union(5, typeof(Image))] + // Keys 0, 1, 2, 5 and 6: 3 and 4 are retired, so the key map is not sequential. Wrap is the +2 nesting step beside + // Box's +3, so any container depth from 4 up is reachable exactly. + [Union(0, typeof(Box)), Union(1, typeof(Text)), Union(2, typeof(Use)), Union(5, typeof(Image)), Union(6, typeof(Wrap))] public abstract class Node { } + [MessagePackObject] + public sealed class Wrap : Node + { + [Key(0)] public Node? Inner; + } + [MessagePackObject] public sealed class Box : Node { @@ -109,13 +117,17 @@ namespace GameCult.Caching.Tests [Key(1)] public Node? Body; } - [Union(0, typeof(Pending)), Union(1, typeof(Completed)), Union(2, typeof(Denied))] + // Floor and Ceiling are unit arms at the ends of the Int32 key domain. + [Union(0, typeof(Pending)), Union(1, typeof(Completed)), Union(2, typeof(Denied)), + Union(int.MinValue, typeof(Floor)), Union(int.MaxValue, typeof(Ceiling))] public abstract class Outcome { } [MessagePackObject] public sealed class Pending : Outcome { [Key(0)] public string Message = string.Empty; } [MessagePackObject] public sealed class Completed : Outcome { [Key(0)] public int SourceVersion; } // A unit arm: written [2, []]. [MessagePackObject] public sealed class Denied : Outcome { } + [MessagePackObject] public sealed class Floor : Outcome { } + [MessagePackObject] public sealed class Ceiling : Outcome { } // Item names Tool and Part under keys 1 and 2; CraftedItem, the inner union both derive from, names them under 0 and 1. [Union(0, typeof(Gem)), Union(1, typeof(Tool)), Union(2, typeof(Part))] @@ -141,11 +153,13 @@ namespace GameCult.Caching.Tests [Key(6)] public Node? Missing; } - private static UnionSurface Constructed() => new() + private static UnionSurface Constructed() => Constructed(null); + + private static UnionSurface Constructed(Node? root) => new() { Id = "surface", Version = 3, - Root = new Box + Root = root ?? new Box { Id = "root", Children = @@ -183,6 +197,9 @@ namespace GameCult.Caching.Tests Pending p => $"Pending({p.Message})", Completed c => $"Completed({c.SourceVersion})", Denied => "Denied", + Floor => "Floor", + Ceiling => "Ceiling", + Wrap w => $"Wrap({Describe(w.Inner)})", Gem g => $"Gem({g.Name})", Tool t => $"Tool({t.Name},{t.Uses})", Part p => $"Part({p.Name},{p.Count})", @@ -234,7 +251,7 @@ namespace GameCult.Caching.Tests private static Emit ImageArm(string id, string uri, string alt) => ArmOf(5, Str(id), Str(uri), Nil, Str(alt)); // The constructed value's payload, with a part of the structure replaceable to build a refusal or an extra-slot case. - private static byte[] Surface(Emit? root = null, Emit? outcomes = null, Emit? crafted = null, Extra? extra = null) + private static byte[] Surface(Emit? root = null, Emit? outcomes = null, Emit? crafted = null, Extra? extra = null, Emit? trailing = null) { extra ??= new Extra(); root ??= BoxArm("root", Slots( @@ -245,10 +262,8 @@ namespace GameCult.Caching.Tests ImageArm("i2", "uri://b", "")), extra.RootArm); outcomes ??= Slots(ArmOf(0, Str("wait")), ArmOf(1, Num(7)), ArmOf(2)); crafted ??= ArmOf(1, Str("gear"), Num(4)); - return Raw(Plus(extra.Record, - Str("surface"), Num(3), root, outcomes, - ArmOf(1, Str("hammer"), Num(12)), - crafted, Nil)); + var slots = new[] { Str("surface"), Num(3), root, outcomes, ArmOf(1, Str("hammer"), Num(12)), crafted, Nil }; + return Raw(Slots(trailing != null ? slots.Append(trailing).ToArray() : extra.Record ? slots.Append(ExtraSlot).ToArray() : slots)); } private static byte[] SerializeValue(UnionSurface value) => CultDocumentMessagePackSerialization.SerializeUntyped(value, typeof(UnionSurface), Registry); @@ -258,8 +273,8 @@ namespace GameCult.Caching.Tests // ---- the refusal and extra-slot vectors, each with its expected outcome as data ---- - // file, outcome ("refused" | "read-only"), union (logical name), arm key (-1 for none), detail. - private sealed record Case(string File, string Outcome, string Union, int Key, string Detail, byte[] Payload); + // file, outcome ("refused" | "read-only" | "accepted"), union (logical name), arm key (null for none), detail. + private sealed record Case(string File, string Outcome, string Union, int? Key, string Detail, byte[] Payload); private static IReadOnlyList Cases() => new[] { @@ -273,9 +288,9 @@ namespace GameCult.Caching.Tests Surface(crafted: ArmOf(2, Str("gear"), Num(4)))), new Case("arm-arity.msgpack", "refused", "Node", 1, "arity", Surface(root: (ref MessagePackWriter w) => { w.WriteArrayHeader(1); w.Write(1); })), - new Case("extra-slot-record.msgpack", "read-only", "UnionSurface", -1, "record", + new Case("extra-slot-record.msgpack", "read-only", "UnionSurface", null, "record", Surface(extra: new Extra(Record: true))), - new Case("extra-slot-nested-record.msgpack", "read-only", "Composition", -1, "nested-record", + new Case("extra-slot-nested-record.msgpack", "read-only", "Composition", null, "nested-record", Surface(extra: new Extra(Composition: true))), new Case("extra-slot-arm.msgpack", "read-only", "Node", 0, "arm", Surface(extra: new Extra(RootArm: true))), @@ -291,7 +306,7 @@ namespace GameCult.Caching.Tests { w.WriteArrayHeader(5); w.Write(c.File); w.Write(c.Outcome); w.Write(c.Union); - if (c.Key < 0) w.WriteNil(); else w.Write(c.Key); + if (c.Key is { } key) w.Write(key); else w.WriteNil(); w.Write(c.Detail); } }); @@ -301,6 +316,7 @@ namespace GameCult.Caching.Tests "Node" => typeof(Node).FullName!, "Outcome" => typeof(Outcome).FullName!, "CraftedItem" => typeof(CraftedItem).FullName!, + "UnionSurface" => typeof(UnionSurface).FullName!, _ => throw new ArgumentOutOfRangeException(nameof(logical), logical, null) }; @@ -429,12 +445,288 @@ namespace GameCult.Caching.Tests } } - // Runs whatever the switch reads when the test starts: it sets the switch itself, and writes only a probe file. + // ---- the bounds vectors: arm keys at the ends of the Int32 domain, nil arm slots, payload nesting ---- + + private const int Limit = CultMessagePackSecurity.MaximumPayloadDepth; + + // The outermost array is depth 1, a Wrap arm ([6, [inner]]) adds 2, a Box with no children 3, a Text leaf 2. + // The at-limit and past-limit payloads are the same chain of Wrap arms; the leaf is what makes the depth odd or even. + private const int AtLimitWraps = (Limit - 1 - 3) / 2; + private const int PastLimitWraps = (Limit + 1 - 1 - 2) / 2; + + private static Emit RawKey(Emit key, params Emit[] slots) => (ref MessagePackWriter w) => + { + w.WriteArrayHeader(2); key(ref w); + Slots(slots)(ref w); + }; + + private static Emit WithOutcome(Emit element) => Slots(ArmOf(0, Str("wait")), ArmOf(1, Num(7)), ArmOf(2), element); + + private static Emit WrapChain(int wraps, Emit leaf) => wraps == 0 ? leaf : ArmOf(6, WrapChain(wraps - 1, leaf)); + + private static Node WrapChainValue(int wraps, Node leaf) + { + for (var i = 0; i < wraps; i++) leaf = new Wrap { Inner = leaf }; + return leaf; + } + + // n arrays nested inside one another around a single string. + private static Emit Nested(int n) => n == 1 ? Slots(Str(Canary)) : Slots(Nested(n - 1)); + + private static Emit AtLimitLeaf => BoxArm("leaf", Slots()); + private static Emit PastLimitLeaf => TextArm("leaf", Canary); + + private static IReadOnlyList BoundsCases() => new[] + { + new Case("arm-key-int32-min.msgpack", "accepted", "Outcome", int.MinValue, "arm-key-int32-min.msgpack", + Surface(outcomes: WithOutcome(ArmOf(int.MinValue)))), + new Case("arm-key-int32-max.msgpack", "accepted", "Outcome", int.MaxValue, "arm-key-int32-max.msgpack", + Surface(outcomes: WithOutcome(ArmOf(int.MaxValue)))), + new Case("arm-key-wide-coding.msgpack", "accepted", "Outcome", int.MaxValue, "arm-key-int32-max.msgpack", + Surface(outcomes: WithOutcome(RawKey((ref MessagePackWriter w) => w.WriteUInt64(int.MaxValue))))), + new Case("arm-key-above-int32.msgpack", "refused", "Outcome", null, "key-out-of-range", + Surface(outcomes: WithOutcome(RawKey((ref MessagePackWriter w) => w.Write(0x80000000u))))), + new Case("arm-key-below-int32.msgpack", "refused", "Outcome", null, "key-out-of-range", + Surface(outcomes: WithOutcome(RawKey((ref MessagePackWriter w) => w.Write(-2147483649L))))), + new Case("arm-key-float32.msgpack", "refused", "Outcome", null, "key-not-integer", + Surface(outcomes: WithOutcome(RawKey((ref MessagePackWriter w) => w.Write(1.0f), Num(7))))), + new Case("arm-key-float64-negzero.msgpack", "refused", "Outcome", null, "key-not-integer", + Surface(outcomes: WithOutcome(RawKey((ref MessagePackWriter w) => w.Write(-0.0), Str("wait"))))), + new Case("nil-arm-slots.msgpack", "refused", "Outcome", 1, "nil-slots", + Surface(outcomes: WithOutcome((ref MessagePackWriter w) => { w.WriteArrayHeader(2); w.Write(1); w.WriteNil(); }))), + new Case("depth-at-limit.msgpack", "accepted", "UnionSurface", null, "depth-at-limit.msgpack", + Surface(root: WrapChain(AtLimitWraps, AtLimitLeaf))), + new Case("depth-past-limit.msgpack", "refused", "UnionSurface", null, "depth", + Surface(root: WrapChain(PastLimitWraps, PastLimitLeaf))), + new Case("depth-skipped-at-limit.msgpack", "read-only", "UnionSurface", null, "depth", + Surface(trailing: Nested(Limit - 1))), + new Case("depth-skipped-past-limit.msgpack", "refused", "UnionSurface", null, "depth", + Surface(trailing: Nested(Limit))), + }; + + private static byte[] BoundsOutcomesFile() => Raw((ref MessagePackWriter w) => + { + var cases = BoundsCases(); + w.WriteArrayHeader(cases.Count + 1); + w.WriteArrayHeader(2); w.Write("limit"); w.Write(Limit); + foreach (var c in cases) + { + w.WriteArrayHeader(5); + w.Write(c.File); w.Write(c.Outcome); w.Write(c.Union); + if (c.Key is { } key) w.Write(key); else w.WriteNil(); + w.Write(c.Detail); + } + }); + + // The container depth of a payload, counted with the reader's typed reads and an explicit stack: not the rule's code. + private static int DepthOf(byte[] payload) + { + var reader = new MessagePackReader(payload); + var open = new Stack(); + var deepest = 0; + while (true) + { + switch (reader.NextMessagePackType) + { + case MessagePackType.Array: + case MessagePackType.Map: + var isArray = reader.NextMessagePackType == MessagePackType.Array; + long children = isArray ? reader.ReadArrayHeader() : 2L * reader.ReadMapHeader(); + deepest = Math.Max(deepest, open.Count + 1); + if (children > 0) { open.Push(children); continue; } + break; + case MessagePackType.Nil: reader.ReadNil(); break; + case MessagePackType.Boolean: reader.ReadBoolean(); break; + case MessagePackType.Integer: if (reader.NextCode == MessagePackCode.UInt64) reader.ReadUInt64(); else reader.ReadInt64(); break; + case MessagePackType.Float: reader.ReadDouble(); break; + case MessagePackType.String: reader.ReadString(); break; + case MessagePackType.Binary: reader.ReadBytes(); break; + case MessagePackType.Extension: reader.ReadExtensionFormat(); break; + default: throw new InvalidOperationException(reader.NextMessagePackType.ToString()); + } + + while (open.Count > 0) + { + var left = open.Pop() - 1; + if (left > 0) { open.Push(left); break; } + } + + if (open.Count == 0) return deepest; + } + } + + private static Exception Refusal(Action action) + { + try { action(); } catch (Exception exception) { return exception; } + throw new AssertionException("the payload was accepted"); + } + + [Test] + public void BoundsOutcomesFromCommittedFiles() + { + foreach (var c in BoundsCases()) Check(c.File, c.Payload); + Check("bounds-outcomes.msgpack", BoundsOutcomesFile()); + + // From here only the committed files speak. + var golden = Describe(Constructed()); + var reader = new MessagePackReader(Committed("bounds-outcomes.msgpack")); + var count = reader.ReadArrayHeader(); + Assert.That(count, Is.EqualTo(BoundsCases().Count + 1)); + Assert.That(reader.ReadArrayHeader(), Is.EqualTo(2)); + Assert.That(reader.ReadString(), Is.EqualTo("limit")); + Assert.That(reader.ReadInt32(), Is.EqualTo(CultMessagePackSecurity.MaximumPayloadDepth), "the limit entry states the reference's limit"); + for (var i = 1; i < count; i++) + { + Assert.That(reader.ReadArrayHeader(), Is.EqualTo(5)); + var file = reader.ReadString()!; + var outcome = reader.ReadString()!; + var union = reader.ReadString()!; + int? key = reader.TryReadNil() ? null : reader.ReadInt32(); + var detail = reader.ReadString()!; + var payload = Committed(file); + + switch (outcome) + { + case "accepted": + Assert.That(Convert.ToHexString(SerializeValue(Decode(payload))), Is.EqualTo(Convert.ToHexString(Committed(detail))), $"{file} re-encodes to {detail}"); + break; + case "read-only": + Assert.That(Describe(Decode(payload)), Is.EqualTo(golden), $"{file}: the extra slot is read past"); + break; + case "refused": + var message = Chain(Refusal(() => Decode(payload))); + Assert.That(message, Does.Not.Contain(Canary), $"{file}: a refusal never echoes the payload"); + Assert.That(message, Does.Contain(UnionName(union)), file); + switch (detail) + { + case "key-out-of-range": Assert.That(message, Does.Contain("outside Int32").And.Not.Contain("has no arm for key"), file); break; + case "key-not-integer": Assert.That(message, Does.Contain("non-integer"), file); break; + case "nil-slots": Assert.That(message, Does.Contain("nil slots").And.Contain($"arm {key} "), file); break; + case "depth": Assert.That(message, Does.Contain($"nests {Limit + 1} MessagePack containers deep").And.Contain($"the limit is {Limit}").And.Contain("refused on read"), file); break; + default: Assert.Fail($"{file}: unknown detail {detail}"); break; + } + + break; + default: + Assert.Fail($"{file}: unknown outcome {outcome}"); + break; + } + } + } + + [Test] + public void ArmKeyDomainVectors() + { + var min = Committed("arm-key-int32-min.msgpack"); + var max = Committed("arm-key-int32-max.msgpack"); + var wide = Committed("arm-key-wide-coding.msgpack"); + Assert.That(Convert.ToHexString(min), Does.Contain("D280000000"), "Floor is written as an int32 key"); + Assert.That(Convert.ToHexString(max), Does.Contain("CE7FFFFFFF"), "Ceiling is written as a uint32 key"); + Assert.That(Convert.ToHexString(wide), Does.Contain("CF000000007FFFFFFF"), "the wide coding is a uint64 key"); + Assert.That(Decode(min).Outcomes, Has.Count.EqualTo(4)); + Assert.That(Decode(min).Outcomes[3], Is.TypeOf()); + Assert.That(Decode(max).Outcomes[3], Is.TypeOf()); + Assert.That(Decode(wide).Outcomes[3], Is.TypeOf(), "an unsigned 64-bit coding of an in-range key is the same key"); + Assert.That(Convert.ToHexString(SerializeValue(Decode(wide))), Is.EqualTo(Convert.ToHexString(max)), "and re-encodes as the canonical coding"); + Assert.That(Convert.ToHexString(SerializeValue(Decode(min))), Is.EqualTo(Convert.ToHexString(min))); + + Assert.That(Chain(Refusal(() => Decode(Committed("arm-key-above-int32.msgpack")))), Does.Contain("outside Int32").And.Contain(UnionName("Outcome"))); + Assert.That(Chain(Refusal(() => Decode(Committed("arm-key-below-int32.msgpack")))), Does.Contain("outside Int32").And.Contain(UnionName("Outcome"))); + Assert.That(Chain(Refusal(() => Decode(Committed("arm-key-float32.msgpack")))), Does.Contain("non-integer").And.Contain(UnionName("Outcome"))); + Assert.That(Chain(Refusal(() => Decode(Committed("arm-key-float64-negzero.msgpack")))), Does.Contain("non-integer").And.Contain(UnionName("Outcome"))); + } + + [Test] + public void NilArmSlotsRefused() + { + var message = Chain(Refusal(() => Decode(Committed("nil-arm-slots.msgpack")))); + Assert.That(message, Does.Contain(UnionName("Outcome")).And.Contain("arm 1 has nil slots")); + } + + [Test] + public void DepthAtLimitAcceptedBothWays() + { + var payload = Committed("depth-at-limit.msgpack"); + Assert.That(DepthOf(payload), Is.EqualTo(Limit), "the vector nests exactly the limit, counted independently"); + Assert.That(Convert.ToHexString(SerializeValue(Decode(payload))), Is.EqualTo(Convert.ToHexString(payload)), "read, then written again"); + var built = Constructed(WrapChainValue(AtLimitWraps, new Box { Id = "leaf" })); + Assert.That(Convert.ToHexString(SerializeValue(built)), Is.EqualTo(Convert.ToHexString(payload)), "the constructed value writes the vector"); + } + + [Test] + public void DepthPastLimitRefusedBothWays() + { + var payload = Committed("depth-past-limit.msgpack"); + Assert.That(DepthOf(payload), Is.EqualTo(Limit + 1), "one container past the limit, counted independently"); + var read = Chain(Refusal(() => Decode(payload))); + Assert.That(read, Does.Contain(typeof(UnionSurface).FullName).And.Contain($"the limit is {Limit}").And.Contain("refused on read").And.Not.Contain(Canary)); + + byte[]? written = null; + var write = Chain(Refusal(() => written = SerializeValue(Constructed(WrapChainValue(PastLimitWraps, new Text { Id = "leaf", Content = Canary }))))); + Assert.That(written, Is.Null, "nothing is returned"); + Assert.That(write, Does.Contain(typeof(UnionSurface).FullName).And.Contain($"the limit is {Limit}").And.Contain("refused on write").And.Not.Contain(Canary)); + + // A single-file store holding the payload fails to load through the same entry point. + var work = Path.Combine(Path.GetTempPath(), $"cultlib-unionparity-{Guid.NewGuid():N}"); + Directory.CreateDirectory(work); + try + { + var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(Committed("union-surface.cc")); + snapshot.Records[0].Payload = payload; + var scratch = Path.Combine(work, "deep.cc"); + File.WriteAllBytes(scratch, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot)); + var load = Chain(Refusal(() => CultCacheMessagePack.Create(scratch, new CultCacheOpenOptions { Registry = Registry }).Dispose())); + Assert.That(load, Does.Contain("surface").And.Contain($"the limit is {Limit}").And.Not.Contain(Canary)); + } + finally + { + Directory.Delete(work, recursive: true); + } + } + + [Test] + public void SkippedContainersCount() + { + var past = Committed("depth-skipped-past-limit.msgpack"); + var atLimit = Committed("depth-skipped-at-limit.msgpack"); + Assert.That(DepthOf(Committed("union-surface.msgpack")), Is.LessThan(20), "what the formatters read is shallow"); + Assert.That(DepthOf(atLimit), Is.EqualTo(Limit)); + Assert.That(DepthOf(past), Is.EqualTo(Limit + 1)); + + Assert.That(Describe(Decode(atLimit)), Is.EqualTo(Describe(Constructed())), "skipped content at the limit is read past"); + var message = Chain(Refusal(() => Decode(past))); + Assert.That(message, Does.Contain(typeof(UnionSurface).FullName).And.Contain($"the limit is {Limit}").And.Not.Contain(Canary)); + + // MessagePack alone counts only what its formatters read: it accepts the payload the walk refuses. + Assert.That(() => MessagePackSerializer.Deserialize(typeof(UnionSurface), past, CultDocumentMessagePackSerialization.OptionsFor(typeof(UnionSurface).Assembly)), + Throws.Nothing, "MessagePack's own counter never sees skipped containers"); + } + + [Test] + public void WalkIsIterative() + { + // 100000 nested arrays in an extra trailing slot, on a thread with a 256 KiB stack. + var deep = Surface(trailing: (ref MessagePackWriter w) => + { + w.WriteRaw(Enumerable.Repeat((byte)0x91, 100_000).ToArray()); + w.WriteArrayHeader(0); + }); + Exception? thrown = null; + var thread = new Thread(() => { thrown = Refusal(() => Decode(deep)); }, 256 * 1024); + thread.Start(); + thread.Join(); + Assert.That(thrown, Is.Not.Null); + Assert.That(Chain(thrown!), Does.Contain($"the limit is {Limit}")); + } + + // Runs whatever the switch reads when the test starts: it sets the switch itself, runs every test body that calls Check + // under each off value, and writes only a probe file. The fingerprint is each file's bytes and write time, so a rewrite + // with identical bytes shows. [Test, NonParallelizable] public void VectorsNotRewrittenByDefault() { string Fingerprint() => string.Join("|", Directory.EnumerateFiles(VectorDirectory()).OrderBy(f => f, StringComparer.Ordinal) - .Select(f => Path.GetFileName(f) + "=" + Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(f))))); + .Select(f => Path.GetFileName(f) + "=" + Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(f))) + "@" + File.GetLastWriteTimeUtc(f).Ticks)); const string probe = "gate-probe.msgpack"; var path = VectorPath(probe); @@ -452,6 +744,9 @@ namespace GameCult.Caching.Tests Assert.Throws(() => Check(probe, new byte[] { 0x90 }), $"bytes that differ from the vector fail the run, switch {off ?? "unset"}"); Assert.That(File.ReadAllBytes(path), Is.EqualTo(new byte[] { 0x91 }), $"switch {off ?? "unset"} rewrites nothing"); File.Delete(path); + + foreach (var body in new Action[] { UnionSurfaceVectorMatches, UnionRefusalVectorsRefused, BoundsOutcomesFromCommittedFiles }) + body(); } Environment.SetEnvironmentVariable(WriteVariable, "1"); diff --git a/tests/vectors/union-parity/README.md b/tests/vectors/union-parity/README.md index 277c15ad..29101484 100644 --- a/tests/vectors/union-parity/README.md +++ b/tests/vectors/union-parity/README.md @@ -9,8 +9,8 @@ Document `tests.union_surface` (schema `tests.union_surface.v1`), a positional a | Union | Arm keys | Arm slots | |---|---|---| -| Node | 0 Box, 1 Text, 2 Use, 5 Image (3 and 4 retired) | Box `[Id, Children: Node list]`; Text `[Id, Content]`; Use `[Id, Composition, Slots: Node list]`; Image `[Id, Uri, nil (retired 2), Alt]` | -| Outcome | 0 Pending, 1 Completed, 2 Denied | Pending `[Message]`; Completed `[SourceVersion]`; Denied is a unit arm, `[2, []]` | +| Node | 0 Box, 1 Text, 2 Use, 5 Image, 6 Wrap (3 and 4 retired) | Box `[Id, Children: Node list]`; Text `[Id, Content]`; Use `[Id, Composition, Slots: Node list]`; Image `[Id, Uri, nil (retired 2), Alt]`; Wrap `[Inner: Node]` | +| Outcome | 0 Pending, 1 Completed, 2 Denied, -2147483648 Floor, 2147483647 Ceiling | Pending `[Message]`; Completed `[SourceVersion]`; Denied, Floor and Ceiling are unit arms, `[2, []]` | | Item | 0 Gem, 1 Tool, 2 Part | Gem `[Name]`; Tool `[Name, Uses]`; Part `[Name, Count]` | | CraftedItem | 0 Tool, 1 Part | the same arms as Item under its own keys; a key only Item declares is unknown to it | @@ -58,3 +58,38 @@ of it refused). A reader decodes the value of `union-surface.msgpack` from each; The C# reference reads these today and its slot-tolerance cut is what makes them read-only; this cut asserts only that the value is read. + +## Bounds (`bounds-outcomes.msgpack`) + +A second outcomes file, so runtimes that read only `outcomes.msgpack` stay green. Its first entry is +`["limit", 256]`, the payload nesting limit; every other entry is `[file, outcome, union, key, detail]` with +`outcome` `accepted`, `refused` or `read-only`. For `accepted`, `detail` names the file the value's re-encoding +must equal. Each payload is `union-surface.msgpack` with one part replaced. + +Arm keys are Int32 whatever their MessagePack width; a float is never a key. + +| File | Replaced part | outcome, detail | Must | +|---|---|---|---| +| `arm-key-int32-min.msgpack` | a fourth Outcomes element Floor, key `d2 80000000` | accepted, itself | re-encode byte-identical | +| `arm-key-int32-max.msgpack` | a fourth element Ceiling, key `ce 7fffffff` | accepted, itself | re-encode byte-identical | +| `arm-key-wide-coding.msgpack` | Ceiling's key as uint64 `cf 000000007fffffff` | accepted, `arm-key-int32-max.msgpack` | re-encode to the canonical coding | +| `arm-key-above-int32.msgpack` | a unit arm with key `ce 80000000` | refused, key-out-of-range | name Outcome, say outside Int32 | +| `arm-key-below-int32.msgpack` | a unit arm with key `d3 ffffffff7fffffff` | refused, key-out-of-range | name Outcome, say outside Int32 | +| `arm-key-float32.msgpack` | Completed with key float32 `ca 3f800000` | refused, key-not-integer | name Outcome, say non-integer | +| `arm-key-float64-negzero.msgpack` | Pending with key float64 `cb 8000000000000000` | refused, key-not-integer | name Outcome, say non-integer | +| `nil-arm-slots.msgpack` | a fourth element `[1, nil]` | refused, nil-slots | name Outcome and key 1; never read as a null element | + +Payload nesting: every array and map counts, read or skipped, and the outermost array is depth 1. Depth 256 is +accepted and 257 refused, on read and on write, naming the type, the depth reached and the limit, never the payload. +`depth-at-limit` and `depth-past-limit` are chains of Wrap arms ending in a leaf (a Box with no children, or a Text +whose content is a canary string); `depth-skipped-*` is the base document with one extra trailing slot of plain nested +arrays around a canary string, which no formatter reads. + +| File | Depth | outcome, detail | Must | +|---|---|---|---| +| `depth-at-limit.msgpack` | 256 | accepted, itself | decode, re-encode byte-identical; the constructed value writes it | +| `depth-past-limit.msgpack` | 257 | refused, depth | refuse on read; refuse to write the constructed value and return nothing | +| `depth-skipped-at-limit.msgpack` | 256 | read-only, depth | read the value of `union-surface.msgpack` | +| `depth-skipped-past-limit.msgpack` | 257 | refused, depth | refuse on read though the typed depth is under 10 | + +A walk of the payload must not recurse: 100000 nested arrays in an extra slot are refused on a 256 KiB stack. diff --git a/tests/vectors/union-parity/arm-key-above-int32.msgpack b/tests/vectors/union-parity/arm-key-above-int32.msgpack new file mode 100644 index 00000000..94402175 Binary files /dev/null and b/tests/vectors/union-parity/arm-key-above-int32.msgpack differ diff --git a/tests/vectors/union-parity/arm-key-below-int32.msgpack b/tests/vectors/union-parity/arm-key-below-int32.msgpack new file mode 100644 index 00000000..28faad4a Binary files /dev/null and b/tests/vectors/union-parity/arm-key-below-int32.msgpack differ diff --git a/tests/vectors/union-parity/arm-key-float32.msgpack b/tests/vectors/union-parity/arm-key-float32.msgpack new file mode 100644 index 00000000..e3d848d0 Binary files /dev/null and b/tests/vectors/union-parity/arm-key-float32.msgpack differ diff --git a/tests/vectors/union-parity/arm-key-float64-negzero.msgpack b/tests/vectors/union-parity/arm-key-float64-negzero.msgpack new file mode 100644 index 00000000..7abdbb56 Binary files /dev/null and b/tests/vectors/union-parity/arm-key-float64-negzero.msgpack differ diff --git a/tests/vectors/union-parity/arm-key-int32-max.msgpack b/tests/vectors/union-parity/arm-key-int32-max.msgpack new file mode 100644 index 00000000..022d4839 Binary files /dev/null and b/tests/vectors/union-parity/arm-key-int32-max.msgpack differ diff --git a/tests/vectors/union-parity/arm-key-int32-min.msgpack b/tests/vectors/union-parity/arm-key-int32-min.msgpack new file mode 100644 index 00000000..4ff4cbf7 Binary files /dev/null and b/tests/vectors/union-parity/arm-key-int32-min.msgpack differ diff --git a/tests/vectors/union-parity/arm-key-wide-coding.msgpack b/tests/vectors/union-parity/arm-key-wide-coding.msgpack new file mode 100644 index 00000000..9ca6aaf8 Binary files /dev/null and b/tests/vectors/union-parity/arm-key-wide-coding.msgpack differ diff --git a/tests/vectors/union-parity/bounds-outcomes.msgpack b/tests/vectors/union-parity/bounds-outcomes.msgpack new file mode 100644 index 00000000..c50e14d2 Binary files /dev/null and b/tests/vectors/union-parity/bounds-outcomes.msgpack differ diff --git a/tests/vectors/union-parity/depth-at-limit.msgpack b/tests/vectors/union-parity/depth-at-limit.msgpack new file mode 100644 index 00000000..93e9cc4c Binary files /dev/null and b/tests/vectors/union-parity/depth-at-limit.msgpack differ diff --git a/tests/vectors/union-parity/depth-past-limit.msgpack b/tests/vectors/union-parity/depth-past-limit.msgpack new file mode 100644 index 00000000..17c080ba Binary files /dev/null and b/tests/vectors/union-parity/depth-past-limit.msgpack differ diff --git a/tests/vectors/union-parity/depth-skipped-at-limit.msgpack b/tests/vectors/union-parity/depth-skipped-at-limit.msgpack new file mode 100644 index 00000000..386b176d Binary files /dev/null and b/tests/vectors/union-parity/depth-skipped-at-limit.msgpack differ diff --git a/tests/vectors/union-parity/depth-skipped-past-limit.msgpack b/tests/vectors/union-parity/depth-skipped-past-limit.msgpack new file mode 100644 index 00000000..d9a3b182 Binary files /dev/null and b/tests/vectors/union-parity/depth-skipped-past-limit.msgpack differ diff --git a/tests/vectors/union-parity/nil-arm-slots.msgpack b/tests/vectors/union-parity/nil-arm-slots.msgpack new file mode 100644 index 00000000..64e01617 Binary files /dev/null and b/tests/vectors/union-parity/nil-arm-slots.msgpack differ