No manifest exemption: one entry decides one value (ruling trailing-bytes-refuse); mesh reads through the production binding; per-path tail tests; TS decodeManifest refuses tails

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Libby 2026-11-12 02:06:56 +00:00
commit 2dbb41dee7
13 changed files with 216 additions and 69 deletions

View file

@ -899,23 +899,23 @@
{
"label": "trailing_nil",
"inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdsA=",
"accepted": true,
"reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWugkaF4gaFroXY=",
"recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
"accepted": false,
"reencodedBase64": null,
"recordKey": null
},
{
"label": "trailing_reserved_code",
"inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdsE=",
"accepted": true,
"reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWugkaF4gaFroXY=",
"recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
"accepted": false,
"reencodedBase64": null,
"recordKey": null
},
{
"label": "trailing_truncated_array",
"inputBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRlNlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWuRoXiBoWuhdpI=",
"accepted": true,
"reencodedBase64": "mqNhL2KlYXNzZXShMdlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQCvYXBwbGljYXRpb24veC10oXSRldlAZTNiMGM0NDI5OGZjMWMxNDlhZmJmNGM4OTk2ZmI5MjQyN2FlNDFlNDY0OWI5MzRjYTQ5NTk5MWI3ODUyYjg1NQAAoWugkaF4gaFroXY=",
"recordKey": "mesh:cdn:artifact:a-b:1:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
"accepted": false,
"reencodedBase64": null,
"recordKey": null
},
{
"label": "top_nil",

View file

@ -5,6 +5,10 @@ All notable changes to `@gamecult/cultnet-ts` are recorded here, following
## Unreleased
### Breaking
- `decodeManifest` refuses a manifest with bytes after its first MessagePack value (`CDN artifact manifest has N bytes after its first MessagePack value.`), as the C# reference does; before, the tail was ignored. `contracts/cultmesh/content-acceptance-vectors.json` now holds `trailing_nil`, `trailing_reserved_code` and `trailing_truncated_array` as refused.
### Added
- Fork card (`contracts/cultnet/governance/fork-card.md`): `raised_at`, a required key holding an RFC3339 instant string or nil (the question's first admission time), outside `options_hash`; the card vectors are re-pinned with the key, three negatives (epoch integer, date-only string, missing key) and a nil-`raised_at` valid card; every `optionsHash` digest is unchanged.

View file

@ -144,8 +144,8 @@ imports no `node:` module, so a browser bundle can take it.
past the shared depth limit are refused.
- The decoder reads what MessagePack-CSharp reads and refuses what it refuses:
nil in a string, tag or metadata slot is `null` and is written back as nil,
a missing trailing slot takes the reference's default, extra slots and trailing
bytes are skipped (a chunk reference's fifth slot is its element id, a string
a missing trailing slot takes the reference's default, extra slots are skipped and bytes
after the manifest's first value are refused (a chunk reference's fifth slot is its element id, a string
or nil, `""` when absent; `packContent` writes the decimal offset as the id, as the
reference does), a float-encoded integer is refused, and invalid UTF-8
decodes to U+FFFD. A nil `chunks` is refused, as the reference's

View file

@ -249,7 +249,7 @@ class Reader {
export function decodeManifest(bytes: Uint8Array): CultMeshCdnArtifactManifest {
checkPayloadDepth(bytes, "CDN artifact manifest");
const reader = new Reader(bytes);
// A slot the array does not reach keeps the property initialiser; a slot past the tenth is skipped. Trailing bytes are not read.
// A slot the array does not reach keeps the property initialiser; a slot past the tenth is skipped.
const count = reader.array("manifest");
let index = 0;
const slot = <T>(read: () => T, initial: T): T => index++ < count ? read() : initial;
@ -267,6 +267,8 @@ export function decodeManifest(bytes: Uint8Array): CultMeshCdnArtifactManifest {
[reader.string("metadata") ?? wrongType("metadata"), reader.string("metadata")] as const), []),
};
for (; index < count; index += 1) reader.skip();
// A manifest is one record payload: exactly one MessagePack value, as the reference's one-value rule has it.
if (reader.position < bytes.length) throw new Error(`CDN artifact manifest has ${bytes.length - reader.position} bytes after its first MessagePack value.`);
validateManifest(manifest);
return manifest;
}

View file

@ -227,20 +227,6 @@ public static class CultDocumentMessagePackSerialization
return new CultDecoded(document, scope.Finding());
}
/// <summary>
/// Reads a document payload that is not a stored record under the nesting bound alone: bytes after the first value are
/// ignored, as the CultMesh CDN manifest contract has them (contracts/cultmesh/content-acceptance-vectors.json). A record
/// payload goes through <see cref="DeserializeUntyped(Type, byte[], CultDocumentRegistry)"/>, which refuses them.
/// </summary>
public static object DeserializeBoundedUntyped(Type type, byte[] payload, CultDocumentRegistry registry)
{
if (registry == null) throw new ArgumentNullException(nameof(registry));
registry.GetRequired(type);
CultPayloadDepth.CheckDepth(payload, type, PayloadDirection.Read);
return MessagePackSerializer.Deserialize(type, payload, OptionsForDocument(type))
?? throw new InvalidOperationException($"MessagePack returned null for Cult document type {type.FullName}.");
}
/// <summary>
/// Replaces slots of a serialized document payload (a MessagePack array indexed by slot) with already-encoded member
/// values, leaving every other slot byte for byte as it was. A slot past the end extends the array with nil.

View file

@ -16,20 +16,7 @@ internal enum PayloadDirection { Read, Write }
/// </summary>
internal static class CultPayloadDepth
{
/// <summary>A record's payload: the depth bound, and exactly one value. Bytes after the first value are refused, never walked.</summary>
public static void Check(ReadOnlySpan<byte> bytes, Type type, PayloadDirection direction)
{
var end = Walk(bytes, type, direction);
if (end < bytes.Length)
throw new MessagePackSerializationException(
$"{type.FullName} payload has {bytes.Length - end} bytes after its first MessagePack value (refused on {(direction == PayloadDirection.Read ? "read" : "write")}).");
}
/// <summary>The depth bound alone, for a reader whose contract tolerates bytes after the value (the CDN manifest vectors).</summary>
public static void CheckDepth(ReadOnlySpan<byte> bytes, Type type, PayloadDirection direction) => Walk(bytes, type, direction);
// The one walk: refuses past the depth limit and returns where the first value ends (the payload's length when it ends early).
private static long Walk(ReadOnlySpan<byte> bytes, Type type, PayloadDirection direction)
{
const int limit = CultMessagePackSecurity.MaximumPayloadDepth;
Span<long> remaining = stackalloc long[limit];
@ -48,12 +35,12 @@ internal static class CultPayloadDepth
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 bytes.Length; skip = 2 + l8; break;
case 0xc5 or 0xda: if (!Length(bytes, position, 2, out var l16)) return bytes.Length; skip = 3 + l16; break;
case 0xc6 or 0xdb: if (!Length(bytes, position, 4, out var l32)) return bytes.Length; skip = 5 + l32; break;
case 0xc7: if (!Length(bytes, position, 1, out var e8)) return bytes.Length; skip = 3 + e8; break;
case 0xc8: if (!Length(bytes, position, 2, out var e16)) return bytes.Length; skip = 4 + e16; break;
case 0xc9: if (!Length(bytes, position, 4, out var e32)) return bytes.Length; skip = 6 + e32; 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;
@ -62,10 +49,10 @@ internal static class CultPayloadDepth
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 bytes.Length; container = true; header = 3; children = a16; break;
case 0xdd: if (!Length(bytes, position, 4, out var a32)) return bytes.Length; container = true; header = 5; children = a32; break;
case 0xde: if (!Length(bytes, position, 2, out var m16)) return bytes.Length; container = true; header = 3; children = 2 * m16; break;
default: if (!Length(bytes, position, 4, out var m32)) return bytes.Length; container = true; header = 5; children = 2 * m32; 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)
@ -87,10 +74,14 @@ 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 position;
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;
}
}
return bytes.Length;
}
// The big-endian length that follows the code byte, when the payload is long enough to hold it.

View file

@ -403,7 +403,7 @@ 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`). The CultMesh CDN manifest is a different contract: its wire bytes may carry bytes after the value (`contracts/cultmesh/content-acceptance-vectors.json`), so its reader keeps the nesting bound through `DeserializeBoundedUntyped` and does not refuse them.
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

View file

@ -99,16 +99,6 @@ namespace GameCult.Caching.Tests
}
}
[Test]
public void TheBoundedEntryKeepsTheDepthBoundAndToleratesATail()
{
foreach (var (file, _) in Cases())
Assert.That(CultDocumentMessagePackSerialization.DeserializeBoundedUntyped(typeof(UnionSurface), VectorFiles.Committed(Vector, file), Registry), Is.TypeOf<UnionSurface>(), file);
var deep = VectorFiles.Committed(Vector, "depth-past-limit.msgpack");
Assert.That(Message(() => CultDocumentMessagePackSerialization.DeserializeBoundedUntyped(typeof(UnionSurface), deep, Registry)), Does.Contain($"the limit is {Limit}"));
}
[Test]
public void StoreLoadRefuses()
{
@ -117,7 +107,7 @@ namespace GameCult.Caching.Tests
try
{
var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(VectorFiles.Committed(Vector, "union-surface.cc"));
snapshot.Records[0].Payload = VectorFiles.Committed(Vector, "bytes-after-nil.msgpack");
snapshot.Records[0].Payload = Base.Concat(new byte[] { 0xc0 }).ToArray();
var scratch = Path.Combine(work, "tail.cc");
File.WriteAllBytes(scratch, CultDocumentMessagePackSerialization.SerializeSnapshot(snapshot));
var load = Message(() => CultCacheMessagePack.Create(scratch, new CultCacheOpenOptions { Registry = Registry }).Dispose());
@ -129,6 +119,57 @@ namespace GameCult.Caching.Tests
}
}
// A directory store names a page by the SHA-256 of its bytes and its manifest commits to that hash: the page holding the
// payload is rewritten with a tail, renamed, and the manifest told its new hash, so the store is well formed and only the payload is wrong.
[Test]
public async System.Threading.Tasks.Task DirectoryLoadPageRefuses()
{
var folder = Path.Combine(Path.GetTempPath(), $"cultlib-bytesafter-dir-{Guid.NewGuid():N}");
Directory.CreateDirectory(folder);
try
{
var path = Path.Combine(folder, "tail.cc");
var options = new CultCacheOpenOptions { Registry = Registry, UseDirectoryStore = true, StoreFlushOnDispose = true };
using (var writer = CultCacheMessagePack.Create(path, options))
{
var value = CultDocumentMessagePackSerialization.DeserializeUntyped(typeof(UnionSurface), Base, Registry);
await writer.UpsertAsync(typeof(UnionSurface), value, new CultRecordKey("surface"));
await writer.FlushAsync();
}
var rehashed = new System.Collections.Generic.List<(byte[] Old, byte[] New)>();
foreach (var file in Directory.EnumerateFiles(folder, "*.msgpack", SearchOption.AllDirectories).ToArray())
{
var bytes = File.ReadAllBytes(file);
if (bytes.AsSpan().IndexOf(Base) < 0) continue;
var page = CultDocumentMessagePackSerialization.DeserializePersistedRecord(bytes);
page.Payload = Base.Concat(new byte[] { 0xc0 }).ToArray();
var forged = CultDocumentMessagePackSerialization.SerializePersistedRecord(page);
File.Delete(file);
File.WriteAllBytes(Path.Combine(Path.GetDirectoryName(file)!, Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(forged)).ToLowerInvariant() + ".msgpack"), forged);
rehashed.Add((System.Security.Cryptography.SHA256.HashData(bytes), System.Security.Cryptography.SHA256.HashData(forged)));
}
Assert.That(rehashed, Is.Not.Empty, "the record page was found");
foreach (var file in Directory.EnumerateFiles(folder, "*", SearchOption.AllDirectories).Where(f => !f.EndsWith(".msgpack", StringComparison.Ordinal)).ToArray())
{
var bytes = File.ReadAllBytes(file);
foreach (var (old, @new) in rehashed)
for (var at = bytes.AsSpan().IndexOf(old); at >= 0; at = bytes.AsSpan().IndexOf(old)) @new.CopyTo(bytes, at);
File.WriteAllBytes(file, bytes);
}
var thrown = Assert.CatchAsync(async () => { using var cache = await CultCacheMessagePack.OpenAsync(path, new CultCacheOpenOptions { Registry = Registry, UseDirectoryStore = true, ReadOnly = true }); });
var chain = "";
for (var e = thrown; e != null; e = e.InnerException) chain += e.Message + "\n";
Assert.That(chain, Does.Contain($"1 {Clause}"));
}
finally
{
Directory.Delete(folder, recursive: true);
}
}
public sealed class TwoValueFormatter : global::MessagePack.Formatters.IMessagePackFormatter<TwoValues>
{
public void Serialize(ref MessagePackWriter writer, TwoValues value, MessagePackSerializerOptions options)

View file

@ -0,0 +1,61 @@
using System;
using System.Linq;
using System.Reflection;
using FluentAssertions;
using GameCult.Caching;
using GameCult.Caching.MessagePack;
using GameCult.Networking;
using NUnit.Framework;
namespace GameCult.Mesh.Tests;
// The mesh's typed reads of a raw record decode the payload through the one-value rule: a tail is refused, never ignored.
public class BytesAfterPathTests
{
private const string Clause = "bytes after its first MessagePack value";
private static CultNetRawDocumentRecord WithTail(CultMeshCdnArtifactManifest manifest)
{
var payload = CultDocumentMessagePackSerialization.SerializeUntyped(manifest, typeof(CultMeshCdnArtifactManifest)).Concat(new byte[] { 0xc0 }).ToArray();
return new CultNetRawDocumentRecord
{
SchemaId = CultDocumentRegistry.Shared.GetRequired<CultMeshCdnArtifactManifest>().SchemaId,
RecordKey = "k",
StoredAt = "1970-01-01T00:00:00.000Z",
Payload = payload,
};
}
private static CultMeshCdnArtifactManifest Manifest() => new() { ArtifactId = "a", Version = "1", ContentHash = "h" };
private static string Refusal(Action read)
{
var thrown = Assert.Catch(read)!;
return thrown is TargetInvocationException { InnerException: { } inner } ? inner.ToString() : thrown.ToString();
}
[Test]
public void ASnapshotDecodeThroughABindingRefusesATail()
{
var registry = CultMeshCdn.CreateDocumentRegistry();
var snapshot = new CultNetSnapshotResponseRawMessage { Documents = new[] { WithTail(Manifest()) } };
Refusal(() => CultMesh.DecodeSnapshotDocuments<CultMeshCdnArtifactManifest>(snapshot, registry)).Should().Contain(Clause);
}
[Test]
public void ASnapshotDecodeWithNoBindingRefusesATail()
{
var snapshot = new CultNetSnapshotResponseRawMessage { Documents = new[] { WithTail(Manifest()) } };
Refusal(() => CultMesh.DecodeSnapshotDocuments<CultMeshCdnArtifactManifest>(snapshot, new CultNetDocumentRegistry())).Should().Contain(Clause);
}
[Test]
public void ATypedReadOfOneSnapshotRecordRefusesATail()
{
var record = WithTail(Manifest());
var snapshot = new CultNetSnapshotResponseRawMessage { MessageId = "m", Documents = new[] { record } };
var read = typeof(CultMesh).GetMethod("ReadDocumentFromSnapshotResponse", BindingFlags.NonPublic | BindingFlags.Static)!
.MakeGenericMethod(typeof(CultMeshCdnArtifactManifest));
Refusal(() => read.Invoke(null, new object[] { snapshot, record.SchemaId, record.RecordKey })).Should().Contain(Clause);
}
}

View file

@ -462,12 +462,12 @@ public sealed class CultMeshContentVectorTests
private static byte[] Reencode(CultMeshContentChunkResponseMessage message) =>
CultNetSchemaMessageSerialization.Serialize(message);
/// <summary>The one reader of manifest bytes: the bounded document reader (CultPayloadDepth 256 included): the CDN manifest contract tolerates bytes after the value, a record payload does not.</summary>
/// <summary>The one reader of manifest bytes: the production CultNet binding of the manifest (CultPayloadDepth 256 and the one-value rule included).</summary>
private static CultMeshCdnArtifactManifest ReadManifest(byte[] bytes) =>
(CultMeshCdnArtifactManifest)CultDocumentMessagePackSerialization.DeserializeBoundedUntyped(typeof(CultMeshCdnArtifactManifest), bytes, CultDocumentRegistry.Shared);
(CultMeshCdnArtifactManifest)CultMeshCdn.CreateDocumentRegistry().GetByDocumentType(typeof(CultMeshCdnArtifactManifest))!.PayloadDeserializer(bytes);
private static byte[] Reencode(CultMeshCdnArtifactManifest manifest) =>
CultDocumentMessagePackSerialization.SerializeUntyped(manifest, typeof(CultMeshCdnArtifactManifest));
CultMeshCdn.CreateDocumentRegistry().GetByDocumentType(typeof(CultMeshCdnArtifactManifest))!.PayloadSerializer(manifest);
private static CultMeshContentChunkRequestMessage Request(string id, string hash, string key, int size) => new()
{

View file

@ -0,0 +1,62 @@
using System;
using System.Linq;
using GameCult.Caching;
using GameCult.Caching.MessagePack;
using MessagePack;
using NUnit.Framework;
namespace GameCult.Networking.Tests
{
// A record's payload is one MessagePack value: each CultNet path that decodes a payload refuses a tail, by the one rule in
// CultPayloadDepth.Check. One test per path family, so that pointing a path at a tolerant reader turns it red.
public class BytesAfterPathTests
{
private const string Clause = "1 bytes after its first MessagePack value";
[CultDocument("tests.bytes_after_net", "tests.bytes_after_net.v1")]
[MessagePackObject]
public sealed class TailDoc
{
[Key(0)] [CultName] public string Id = "a";
[Key(1)] public int N = 3;
}
private static readonly CultDocumentRegistry Documents = CultDocumentRegistry.ForTypes(new[] { typeof(TailDoc) });
private static byte[] WithTail() =>
CultDocumentMessagePackSerialization.SerializeUntyped(new TailDoc(), typeof(TailDoc), Documents).Concat(new byte[] { 0xc0 }).ToArray();
private static CultNetRawDocumentRecord Raw(CultNetDocumentRegistry registry, byte[] payload) => new()
{
SchemaId = Documents.GetRequired(typeof(TailDoc)).SchemaId,
RecordKey = "a",
StoredAt = "1970-01-01T00:00:00.000Z",
Payload = payload,
};
private static void Refuses(TestDelegate read) =>
Assert.That(Assert.Catch(read)!.ToString(), Does.Contain(Clause));
[Test]
public void TheGenericBindingRefusesATail() =>
Refuses(() => CultNetDocumentBinding.ForDocument<TailDoc>(Documents).PayloadDeserializer(WithTail()));
[Test]
public void TheRuntimeTypeBindingRefusesATail() =>
Refuses(() => CultNetDocumentBinding.ForDocument(typeof(TailDoc), Documents).PayloadDeserializer(WithTail()));
[Test]
public void ARawEnvelopeThroughARegisteredBindingRefusesATail()
{
var registry = new CultNetDocumentRegistry(Documents).Register(CultNetDocumentBinding.ForDocument<TailDoc>(Documents));
Refuses(() => registry.DeserializeRawDocument(Raw(registry, WithTail())));
}
[Test]
public void ARawEnvelopeWithNoBindingRefusesATail()
{
var registry = new CultNetDocumentRegistry(Documents);
Refuses(() => registry.DeserializeRawDocument(Raw(registry, WithTail())));
}
}
}

View file

@ -188,4 +188,4 @@ Must: refuse on read, naming the type and the count of bytes after the first val
What a runtime walks: only the first value of the payload, to find where it ends (a leaf counts as complete at its own length, a container when its last child completes). It compares that end with the payload length; a payload that ends early is not bytes after the value and stays the decoder's to refuse. It never walks the tail, so the tail's own shape (a stray `c1`, 300 levels) is never judged.
The CultMesh CDN manifest is not covered by these vectors: its contract (`contracts/cultmesh/content-acceptance-vectors.json`) accepts bytes after the value, and its reader keeps the depth bound through `DeserializeBoundedUntyped`.
A CDN manifest read as a record follows the same rule; its vectors are `contracts/cultmesh/content-acceptance-vectors.json` (`trailing_*` are refusals).

View file

@ -6,7 +6,7 @@ All notable changes to this package are documented in this file.
### Breaking
- A document payload with bytes after its first MessagePack value is refused on read and on write (`MessagePackSerializationException` naming the type and the count of bytes after the value, never the bytes); the tail is not walked, so a tail nested past the limit is refused as bytes after the value, not as depth. Before, the tail was ignored on read and dropped on the next write. A payload that ends early is still the deserializer's to refuse. The CultMesh CDN manifest contract still accepts bytes after the value; `CultDocumentMessagePackSerialization.DeserializeBoundedUntyped` (new) reads under the nesting bound alone for it.
- A document payload with bytes after its first MessagePack value is refused on read and on write (`MessagePackSerializationException` naming the type and the count of bytes after the value, never the bytes); the tail is not walked, so a tail nested past the limit is refused as bytes after the value, not as depth. Before, the tail was ignored on read and dropped on the next write. A payload that ends early is still the deserializer's to refuse. A CDN manifest is a record payload and follows the same rule: the acceptance vectors `trailing_nil`, `trailing_reserved_code` and `trailing_truncated_array` in `contracts/cultmesh/content-acceptance-vectors.json` are now refusals.
- A single-file store holding two records under one key is unreadable: the reader refuses it with `CultStoreUnreadableException` naming the key (keys compare by ordinal code units, no case folding or normalisation), so a cache, `ReadStoreSnapshot` and a flush refuse the file and leave it as it is. Before, the later record silently replaced the earlier. A directory store reads its manifest through the same reader and refuses a duplicate key the same way. Repair the duplicate with the runtime that wrote it before reading the store here.
- A record whose payload carries a slot past the slots its reading type declares, at any depth (the record, a nested object, a list element, a union arm or a unit arm), is loaded read-only: it is served, and every staged write or removal of its key (`Upsert`, `UpsertVariant`, `Remove`, a batch, and `Flatten` of a variant) is refused with `CultSchemaConflictException` before anything is staged, and the store keeps the record byte for byte. Before, the extra slots were skipped on read and dropped on the next write. Declaring an id compatible consents only to depth-0 slots the schema comparison reports ignored under that id; any other extra slot keeps the record read-only whatever is declared, including under the reader's own id. A variant overriding a slot its type does not have is read-only too (it used to be shed on flush). Write such a record from the build that declares those slots. Missing trailing slots still default and stay writable.
- `Flatten` (and `FlattenAsync`) of a variant whose base chain holds a record its store does not own (a record held read-only for extra slots, or undeclared drift that drops slots) is refused with `CultSchemaConflictException` naming that base record, and the store is left as it was. Before, the flatten wrote the base's decoded view into a plain record and the base's unread data was lost for that variant. Writing and removing the variant itself are still accepted: they persist only its delta.