Merge hands/union-bounds-cs: one 256 nesting bound on read and write in the C# reference, and the arm-key, depth and nil-slot boundary vectors

Soul holds at cultlib-gaps:verdict:cut-union-bounds-cs.s1; limit per cultlib-gaps:ruling:payload-depth-limit-256.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-11 10:06:32 +02:00
commit ead5260c07
21 changed files with 622 additions and 21 deletions

View file

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

View file

@ -7,7 +7,15 @@ namespace GameCult.Caching.MessagePack;
// UntrustedData, plus a collision-resistant comparer for CultRecordRef<T> dictionary keys over the key string.
public sealed class CultMessagePackSecurity : MessagePackSecurity
{
public static readonly CultMessagePackSecurity Instance = new(UntrustedData);
/// <summary>
/// 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 <c>CultPayloadDepth.Check</c>, run by the document serialization entry points.
/// </summary>
public const int MaximumPayloadDepth = 256;
public static readonly CultMessagePackSecurity Instance = new(UntrustedData.WithMaximumObjectGraphDepth(MaximumPayloadDepth));
private CultMessagePackSecurity(MessagePackSecurity copyFrom) : base(copyFrom) { }

View file

@ -0,0 +1,94 @@
using System;
using System.Buffers.Binary;
using MessagePack;
namespace GameCult.Caching.MessagePack;
internal enum PayloadDirection { Read, Write }
/// <summary>
/// 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
/// <see cref="CultMessagePackSecurity.MaximumPayloadDepth"/>. 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.
/// </summary>
internal static class CultPayloadDepth
{
public static void Check(ReadOnlySpan<byte> bytes, Type type, PayloadDirection direction)
{
const int limit = CultMessagePackSecurity.MaximumPayloadDepth;
Span<long> 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<byte> 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;
}
}

View file

@ -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);

View file

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

View file

@ -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<byte[], byte[]> 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<MessagePackSerializationException>().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<MessagePackSerializationException>(() => CultPayloadDepth.Check(deep, typeof(PayloadDepthTests), PayloadDirection.Read))!;
var write = Assert.Throws<MessagePackSerializationException>(() => 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");
}
}
}

View file

@ -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<Case> 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<Case> 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<long>();
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<Floor>());
Assert.That(Decode(max).Outcomes[3], Is.TypeOf<Ceiling>());
Assert.That(Decode(wide).Outcomes[3], Is.TypeOf<Ceiling>(), "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<AssertionException>(() => 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");

View file

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.