Bytes after the value: a payload is one MessagePack value (C#) (#125)

This commit is contained in:
Libby 2026-11-12 04:01:36 +00:00
commit 5e10909fe9
21 changed files with 441 additions and 30 deletions

View file

@ -7,11 +7,12 @@ 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
/// The one walk of a payload's shape: its depth and its one value. 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.
/// never payload bytes. A payload that ends early is the deserializer's to refuse; bytes after the first value are refused
/// with their count and never walked.
/// </summary>
internal static class CultPayloadDepth
{
@ -73,7 +74,13 @@ internal static class CultPayloadDepth
// 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;
if (depth == 0)
{
if (position < bytes.Length)
throw new MessagePackSerializationException(
$"{type.FullName} payload has {bytes.Length - position} bytes after its first MessagePack value (refused on {(direction == PayloadDirection.Read ? "read" : "write")}).");
return;
}
}
}

View file

@ -403,6 +403,8 @@ runtime enforces the same bound on read and on write, and the refusal names the
the depth and the limit, never payload bytes. The vectors are in
`tests/vectors/union-parity` (`bounds-outcomes.msgpack`).
A record's payload is exactly one MessagePack value: every runtime refuses a payload with bytes after its first value wherever it reads a payload under the nesting bound, and on write where a write takes bytes the library did not encode itself (a C# type's own formatter, a Rust raw envelope), without walking those bytes, so a tail past the limit is refused as bytes after the value and not as depth; the refusal names the type and the count of bytes after the value, never the bytes (vectors: `tests/vectors/union-parity/bytes-after-outcomes.msgpack`). A CDN manifest is stored and keyed as a record and follows the same rule (`contracts/cultmesh/content-acceptance-vectors.json`: `trailing_nil`, `trailing_reserved_code` and `trailing_truncated_array` are refused). Streams of values (CultNet frames, a store's record array) are different contracts and are not covered.
## Variant Records
A variant record is a record whose document is a base record's document with some

View file

@ -284,7 +284,10 @@ namespace GameCult.Mesh
throw new ArgumentException($"Expected provider command schema '{CultMeshProviderSessionWireContract.CommandSchema}'.", nameof(document));
if (!string.Equals(document.PayloadEncoding, "messagepack", StringComparison.Ordinal))
throw new ArgumentException("Provider command document must use MessagePack.", nameof(document));
var command = MessagePackSerializer.Deserialize<CultMeshProviderCommandWire>(document.Payload, Options);
// A raw record's payload is one MessagePack value (ruling trailing-bytes-refuse): a tail is refused by count, never walked.
var command = MessagePackSerializer.Deserialize<CultMeshProviderCommandWire>(new ReadOnlyMemory<byte>(document.Payload), Options, out var read);
if (read < document.Payload.Length)
throw new MessagePackSerializationException($"Provider command document: {document.Payload.Length - read} bytes after its first MessagePack value (refused on read).");
if (command == null) throw new MessagePackSerializationException("Provider command decoded to null.");
Validate(command);
return command;

View file

@ -641,23 +641,19 @@ namespace GameCult.Networking
string storedAt,
CultNetDocumentMessageOptions? options = null)
{
var binding = GetByDocumentType(descriptor.DocumentType) ??
new CultNetDocumentBinding(
descriptor.DocumentType,
descriptor.SchemaId,
value => CultDocumentMessagePackSerialization.SerializeUntyped(value, value.GetType(), _documents),
payload => CultDocumentMessagePackSerialization.DeserializeUntyped(descriptor.DocumentType, payload, _documents));
var binding = GetByDocumentType(descriptor.DocumentType);
return new CultNetRawDocumentRecord
{
SchemaId = binding.SchemaId,
SchemaId = binding?.SchemaId ?? descriptor.SchemaId,
SchemaName = descriptor.SchemaName,
SchemaVersion = descriptor.SchemaVersion,
SchemaContentHash = descriptor.ContentHash,
RecordKey = key.Value,
StoredAt = storedAt,
PayloadEncoding = "messagepack",
Payload = binding.PayloadSerializer(document),
Payload = binding?.PayloadSerializer(document) ??
CultDocumentMessagePackSerialization.SerializeUntyped(document, document.GetType(), _documents),
SourceRuntimeId = options?.SourceRuntimeId,
SourceAgentId = options?.SourceAgentId,
SourceRole = options?.SourceRole,