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:
commit
ead5260c07
21 changed files with 622 additions and 21 deletions
|
|
@ -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}.");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) { }
|
||||
|
||||
|
|
|
|||
94
src/GameCult.Caching.MessagePack/CultPayloadDepth.cs
Normal file
94
src/GameCult.Caching.MessagePack/CultPayloadDepth.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
157
tests/GameCult.Caching.Tests/PayloadDepthTests.cs
Normal file
157
tests/GameCult.Caching.Tests/PayloadDepthTests.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
BIN
tests/vectors/union-parity/arm-key-above-int32.msgpack
Normal file
BIN
tests/vectors/union-parity/arm-key-above-int32.msgpack
Normal file
Binary file not shown.
BIN
tests/vectors/union-parity/arm-key-below-int32.msgpack
Normal file
BIN
tests/vectors/union-parity/arm-key-below-int32.msgpack
Normal file
Binary file not shown.
BIN
tests/vectors/union-parity/arm-key-float32.msgpack
Normal file
BIN
tests/vectors/union-parity/arm-key-float32.msgpack
Normal file
Binary file not shown.
BIN
tests/vectors/union-parity/arm-key-float64-negzero.msgpack
Normal file
BIN
tests/vectors/union-parity/arm-key-float64-negzero.msgpack
Normal file
Binary file not shown.
BIN
tests/vectors/union-parity/arm-key-int32-max.msgpack
Normal file
BIN
tests/vectors/union-parity/arm-key-int32-max.msgpack
Normal file
Binary file not shown.
BIN
tests/vectors/union-parity/arm-key-int32-min.msgpack
Normal file
BIN
tests/vectors/union-parity/arm-key-int32-min.msgpack
Normal file
Binary file not shown.
BIN
tests/vectors/union-parity/arm-key-wide-coding.msgpack
Normal file
BIN
tests/vectors/union-parity/arm-key-wide-coding.msgpack
Normal file
Binary file not shown.
BIN
tests/vectors/union-parity/bounds-outcomes.msgpack
Normal file
BIN
tests/vectors/union-parity/bounds-outcomes.msgpack
Normal file
Binary file not shown.
BIN
tests/vectors/union-parity/depth-at-limit.msgpack
Normal file
BIN
tests/vectors/union-parity/depth-at-limit.msgpack
Normal file
Binary file not shown.
BIN
tests/vectors/union-parity/depth-past-limit.msgpack
Normal file
BIN
tests/vectors/union-parity/depth-past-limit.msgpack
Normal file
Binary file not shown.
BIN
tests/vectors/union-parity/depth-skipped-at-limit.msgpack
Normal file
BIN
tests/vectors/union-parity/depth-skipped-at-limit.msgpack
Normal file
Binary file not shown.
BIN
tests/vectors/union-parity/depth-skipped-past-limit.msgpack
Normal file
BIN
tests/vectors/union-parity/depth-skipped-past-limit.msgpack
Normal file
Binary file not shown.
BIN
tests/vectors/union-parity/nil-arm-slots.msgpack
Normal file
BIN
tests/vectors/union-parity/nil-arm-slots.msgpack
Normal file
Binary file not shown.
Loading…
Reference in a new issue