Payload depth walk: tests of every header and scalar width at the rule's own seam
Each case is a two-slot document whose chain puts the payload at the limit (accepted) or one past (refused), so a scalar or container header read at the wrong width flips one of the two. Also pins the message, truncated payloads and the first-value rule. All commits build. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
abf624ab04
commit
0789ec8327
1 changed files with 152 additions and 0 deletions
152
tests/GameCult.Caching.Tests/PayloadDepthTests.cs
Normal file
152
tests/GameCult.Caching.Tests/PayloadDepthTests.cs
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
#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))),
|
||||
("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");
|
||||
}
|
||||
|
||||
[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");
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue