From 2cb694d612b238b0161053700676bb75bce6a590 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 02:04:33 +0200 Subject: [PATCH 1/7] Pin the C0 refusal vectors shared by every runtime An unknown store header, a record slot beyond four with a full payload, the v2 variant record C1 will write, and a v1 store written by the base-commit Python writer. Every runtime's tests read these bytes. Co-Authored-By: Claude Sonnet 5 --- .../document-variants-c0/.gitattributes | 3 ++ .../extra-slot-full-payload.msgpack | Bin 0 -> 1666 bytes .../vectors/document-variants-c0/generate.py | 34 ++++++++++++++++++ .../unknown-header.msgpack | Bin 0 -> 1651 bytes .../document-variants-c0/v1-base.msgpack | 1 + .../document-variants-c0/variant-v2.msgpack | Bin 0 -> 1798 bytes 6 files changed, 38 insertions(+) create mode 100644 tests/vectors/document-variants-c0/.gitattributes create mode 100644 tests/vectors/document-variants-c0/extra-slot-full-payload.msgpack create mode 100644 tests/vectors/document-variants-c0/generate.py create mode 100644 tests/vectors/document-variants-c0/unknown-header.msgpack create mode 100644 tests/vectors/document-variants-c0/v1-base.msgpack create mode 100644 tests/vectors/document-variants-c0/variant-v2.msgpack diff --git a/tests/vectors/document-variants-c0/.gitattributes b/tests/vectors/document-variants-c0/.gitattributes new file mode 100644 index 00000000..8efeaf12 --- /dev/null +++ b/tests/vectors/document-variants-c0/.gitattributes @@ -0,0 +1,3 @@ +# Refusal vectors are byte-exact; never convert line endings. +* binary +*.py text diff --git a/tests/vectors/document-variants-c0/extra-slot-full-payload.msgpack b/tests/vectors/document-variants-c0/extra-slot-full-payload.msgpack new file mode 100644 index 0000000000000000000000000000000000000000..a9bf1bd94985cce8d369c1f25adde8c5991574ee GIT binary patch literal 1666 zcmcJQyKWOf6o!dt5D!2dwgktT`^;{KC=d+=iD(eTT+TQvt#_l{aV|o62jT>g2OvU% z&>)Z~EyGKg7vMFRbsUq(Nhm_fojEiA|DW?|Xa9>G6v1k{4&5eHHFP)e@ZID|v#tpj znNp5==aEi{)Yemj1x*=p$}1~92x2J3f-_=4O00zhtBgfdd7d(<)Hk1Rhd~WZccTV7 z2&9__=zqU(H7d__opzogV^e|@O9500jys90l@t+)Byvu2i7od^Draztu&3Hs##3vN z{k635I%#64zJ8|rkYve)l1XP)^aAQ8ugc5nqP1AshrWq*N%k_{T~r~-P$wx*uAi!O zlCZPUimmQ6F=lr))Op#9C-dexc&MSYao6jjfq0uPcjg~K*F6ZUXAGBvqKF4e2iuQ| zTu0T}N^=q@)C1V=8KUHQCr`^j$;}v<#@T(fyEh-+b)gv^-g@7L{CuvB zyXgkOo|BQAdFLz3dt-+Oh}EmVcJN)5 zNOua}8fy&3i_Df)fJJ88)w9eh&WUB#ag8+N+*?L9m6lM<98m&GBTee407d~Mc;XyZ z)?gO-^+5UL_rH*B72^<3xV{3}nX_1W6|!rvy$H%pBD+Y+bw{-qNoiv&kTM?azx~+$ zYBF7J=EZ+Qb8?>`B34kkg6SIO8D<$3U4|$jL_9<&L+Iz7;n7s|_V9S>z(7${Tg{t; zbDuYD31cSNsinKa4>K1J4nDPXWL2H_@^WtCx`Vi+FF1CKu~}d4?S0*<^AMn1HsIq^ Kvm76UW%36Piz$!* literal 0 HcmV?d00001 diff --git a/tests/vectors/document-variants-c0/generate.py b/tests/vectors/document-variants-c0/generate.py new file mode 100644 index 00000000..fafd5dfe --- /dev/null +++ b/tests/vectors/document-variants-c0/generate.py @@ -0,0 +1,34 @@ +"""Generates the shared C0 refusal vectors from the pre-Cut 2 v1 store (written before any +variant code existed). Every runtime's tests read the committed bytes; nothing regenerates them +at test time. Run: python generate.py (needs msgpack). + + unknown-header.msgpack header cultcache.store.v9, otherwise the v1 store + extra-slot-full-payload.msgpack v1 header, record item:anvil gains slot 4, payload stays full + v1-base.msgpack NOT generated here: written once by the Python runtime at CultLib + 69a21bb (SingleFileMessagePackBackingStore.push of alpha and + beta, type vectors.item, before any C0 code existed) + variant-v2.msgpack what C1 writes for a variant: header v2, record slot 4 = + [baseKey, overrides[]], empty payload (override op Set = 0, + path steps are [slot, elementId]) +""" +import pathlib + +import msgpack + +here = pathlib.Path(__file__).resolve().parent +source = here.parent.parent / "GameCult.Caching.Tests" / "Fixtures" / "pre-cut2" / "single-file-v1" / "store.msgpack" +header, catalog, records = msgpack.unpackb(source.read_bytes(), raw=False) +assert header == "cultcache.store.v1" +anvil = records[0] +assert anvil[0] == "item:anvil" + + +def write(name, snapshot): + (here / name).write_bytes(msgpack.packb(snapshot, use_bin_type=True)) + + +write("unknown-header.msgpack", ["cultcache.store.v9", catalog, records]) +write("extra-slot-full-payload.msgpack", + [header, catalog, [anvil + [["item:bellows", []]]] + records[1:]]) +variant = ["item:anvil-big", anvil[1], anvil[2], b"", ["item:anvil", [[0, [[1, ""]], "", 99]]]] +write("variant-v2.msgpack", ["cultcache.store.v2", catalog, records + [variant]]) diff --git a/tests/vectors/document-variants-c0/unknown-header.msgpack b/tests/vectors/document-variants-c0/unknown-header.msgpack new file mode 100644 index 0000000000000000000000000000000000000000..82f07c2c69a4d24ea55be081b8ad55a6d6446ce5 GIT binary patch literal 1651 zcmcJQyKdA#6o!dt5D!2dT*Ahh`;6To3PeLeA{s<7moteauN~R5xd`PUvRR2d01*;| z27yGmT6l@@0=x#}&1OloNhm@}&zzb6|Ihj4Irw4+Ww6?AK-z}7fpjYyzB_r=ZfL?q zu9TzRd89KUwe{3sK{JM&^2$mNf*4A%;EY(15^EvBDq|5C zfuu!%{`X5)(=9yLb=rA~jLisAECWy(IPN5}R#HSHlE^v9CAQovshq(X!k%hl8PBXm z_Se$p+oX-5`uc_LLy{+#N+!Ko(JN@$qOPv0i`HUkANnTNCHd=kcUgxdN4=yvy?(0F zOTzAEC$_rV#+d19Xo_k*o-Epz;Gu!a#$B(=7UFHX+MRy{-K;}cJ!iNYlw~|vIoNqp z7CNfVR@&1*p&7tl@5~6E8+lp}st_eFI(c3NN^ZxvaOUSzha11vJzt)6FAaZW6=j%%bD=iV}^skDS*=7Rh0h?&69@&5wU`@6-?JK&oRrXNExDp5b+qH9HF20hDTG;yQAZ&0|RAQZ@0JY z0b?fFnTPws4>Q?EqfZ?UdEFH2MKw2Z)6QSf2prqP*sKu`_P=g7MF>zW8}RXMS&lEj GGWi3ckRs>+ literal 0 HcmV?d00001 diff --git a/tests/vectors/document-variants-c0/v1-base.msgpack b/tests/vectors/document-variants-c0/v1-base.msgpack new file mode 100644 index 00000000..dcdb411f --- /dev/null +++ b/tests/vectors/document-variants-c0/v1-base.msgpack @@ -0,0 +1 @@ +“²cultcache.store.v1‘—¬vectors.item¬vectors.item¯vectors.item.v1¬vectors.itemÙL{"schemaName":"vectors.item","schemaVersion":"vectors.item.v1","members":[]}‘¬vectors.item�’”¥alpha¬vectors.item¹2026-09-29T00:00:00+00:00Ä’¥alpha”¤beta¬vectors.item¹2026-09-29T00:00:01+00:00Ä’¤beta \ No newline at end of file diff --git a/tests/vectors/document-variants-c0/variant-v2.msgpack b/tests/vectors/document-variants-c0/variant-v2.msgpack new file mode 100644 index 0000000000000000000000000000000000000000..3f3efe42d6587dd3e1369f33e29128f2c528b925 GIT binary patch literal 1798 zcmcJQyKWOf6owPgARd4^YzdAv_nF-eQ6L%$644-vxtwuUTCbzsaV|o62pdPFOhJSM zfdYXAyR9T5o8= zMW&Ra-g%@`BDM9@U_n!cobt*_4}usSl!75`BRi39zD)rsxJE2=a-Q29e zb^~eV0op&VTy1XQnXc2$Q)FyPkYXu-O2Kg_k+qT{B9TPSNiMPFUPCkYuQll&9BE zR2oUx-5iUp?bb17b1hVP*@`Fg`UQBXptNz<>!OBun=W@}A3;|w2x}J%m))X>2TKP# zkBeMK)#*xo8Yom<*lU~_!80RII^8lv$;(cjm4TAmF*1p>_j<248{VSO^beNak3)XG z)W*?h_;Yx$d^8DoJl&e)iaVp>IkO_d0opnwG&qwYLOBTIl%zs3ixCr2BW{>>)(PSw zt~k=2g15#RgYi7GWhcNqv*Xo^%qq@_W!7Zkxl0VH_h z99Gs~7Wwr+`N^MuAv;!#Lpu&#OB6??VJaJ&4C_3Bq zZF|6&Np|MpUjM^X_Tk{u7>BG=<*mG&nOL;**E9mh_AoYW#Qom4?J5re$`u1X{##b! mA7F*-e>oq_s>xe3Pm8`A*Lb=#oTdH+EP8{bUVr)U(EbIpD^I%s literal 0 HcmV?d00001 From 98ed042fcc8b5d363ae6ddd20f9ceebccea020b6 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 02:04:33 +0200 Subject: [PATCH 2/7] C#: refuse unknown store headers and extra record slots by name Delete the trailing Skip loop in the record reader, the ?? v1 header default, and the mesh reader's copy of the record decoder. The single-file store and CultMesh single-file reads accept cultcache.store.v1 only. Persistence-format and parity docs state the rule. Co-Authored-By: Claude Sonnet 5 --- docs/runtime-parity-scope.md | 2 + .../CultDocumentMessagePackSerialization.cs | 27 ++++++-- .../Contracts/cultcache-persistence-format.md | 5 ++ .../CultMeshSingleFileDocuments.cs | 30 +++----- .../PreCut2StoreFormatTests.cs | 68 +++++++++++++++++++ .../CultMeshVariantRefusalTests.cs | 47 +++++++++++++ 6 files changed, 153 insertions(+), 26 deletions(-) create mode 100644 tests/GameCult.Mesh.Tests/CultMeshVariantRefusalTests.cs diff --git a/docs/runtime-parity-scope.md b/docs/runtime-parity-scope.md index 72768673..46b1e77b 100644 --- a/docs/runtime-parity-scope.md +++ b/docs/runtime-parity-scope.md @@ -13,6 +13,8 @@ Cross-runtime parity has three layers: - Wire parity: shared CultCache file/state contracts, schema-v0 MessagePack documents, transport profiles, and RUDP packets must cross runtime boundaries without translation folklore. + Every store reader refuses, by name, a header or record slot it does not + understand (vectors: `tests/vectors/document-variants-c0`). - Feature parity: each runtime must expose the CultCache/CultNet/CultMesh surfaces needed for its intended role, with unsupported production-server ownership called out plainly. diff --git a/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs b/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs index 1e99125e..bb62d2ed 100644 --- a/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs +++ b/src/GameCult.Caching.MessagePack/CultDocumentMessagePackSerialization.cs @@ -30,6 +30,7 @@ public sealed class CultDocumentResolver : IFormatterResolver public static class CultDocumentMessagePackSerialization { + private const string SingleFileFormatVersion = "cultcache.store.v1"; private const int PersistedRecordFieldCount = 4; private const int SchemaCatalogEntryFieldCount = 7; private const int SchemaCatalogMemberFieldCount = 8; @@ -139,7 +140,8 @@ public static class CultDocumentMessagePackSerialization if (fieldCount > 0) { - snapshot.FormatVersion = reader.ReadString() ?? "cultcache.store.v1"; + snapshot.FormatVersion = reader.ReadString() + ?? throw new NotSupportedException("Store snapshot declares no format version; this runtime reads " + SingleFileFormatVersion + " only."); } if (fieldCount > 1) @@ -170,6 +172,17 @@ public static class CultDocumentMessagePackSerialization return snapshot; } + /// Refuses a single-file snapshot this runtime cannot read, naming the format it found. + public static void RequireSingleFileFormat(CultPersistedStoreSnapshot snapshot) + { + if (!string.Equals(snapshot.FormatVersion, SingleFileFormatVersion, StringComparison.Ordinal)) + { + throw new NotSupportedException( + $"Store format {snapshot.FormatVersion} is not readable; this runtime reads {SingleFileFormatVersion} only. " + + "The store needs a runtime that resolves document variants."); + } + } + private static void WritePersistedRecord(ref MessagePackWriter writer, CultPersistedRecord record) { writer.WriteArrayHeader(PersistedRecordFieldCount); @@ -179,7 +192,7 @@ public static class CultDocumentMessagePackSerialization writer.Write(record.Payload); } - private static CultPersistedRecord ReadPersistedRecord(ref MessagePackReader reader) + public static CultPersistedRecord ReadPersistedRecord(ref MessagePackReader reader) { var fieldCount = reader.ReadArrayHeader(); var record = new CultPersistedRecord(); @@ -204,9 +217,11 @@ public static class CultDocumentMessagePackSerialization record.Payload = reader.ReadBytes()?.ToArray() ?? Array.Empty(); } - for (var index = PersistedRecordFieldCount; index < fieldCount; index++) + if (fieldCount > PersistedRecordFieldCount) { - reader.Skip(); + throw new NotSupportedException( + $"Record '{record.Key}' (schema '{record.SchemaId}') has {fieldCount} slots; this runtime reads {PersistedRecordFieldCount}. " + + "The store needs a runtime that resolves document variants."); } return record; @@ -371,7 +386,9 @@ public class SingleFileMessagePackBackingStore : SingleFileBackingStore protected override CultPersistedStoreSnapshot DeserializeSnapshot(byte[] data) { - return CultDocumentMessagePackSerialization.DeserializeSnapshot(data); + var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(data); + CultDocumentMessagePackSerialization.RequireSingleFileFormat(snapshot); + return snapshot; } protected override byte[] SerializePayload(object document) diff --git a/src/GameCult.Caching/Contracts/cultcache-persistence-format.md b/src/GameCult.Caching/Contracts/cultcache-persistence-format.md index e8231c10..4511f3c5 100644 --- a/src/GameCult.Caching/Contracts/cultcache-persistence-format.md +++ b/src/GameCult.Caching/Contracts/cultcache-persistence-format.md @@ -212,6 +212,11 @@ Example: The payload is the domain object only. It does not repeat the key, schema id, or stored timestamp. +Under `cultcache.store.v1` a record has exactly these four slots. A reader refuses any +other `cultcache.store.*` header and any record with more than four slots, naming the +version or the record's key and schema id. It never skips a slot it does not +understand. + ## Canonical Semantic Schema Hash `schemaId` should be derived from a canonical semantic schema hash. diff --git a/src/GameCult.Mesh/CultMeshSingleFileDocuments.cs b/src/GameCult.Mesh/CultMeshSingleFileDocuments.cs index 5e2295df..46bfb47e 100644 --- a/src/GameCult.Mesh/CultMeshSingleFileDocuments.cs +++ b/src/GameCult.Mesh/CultMeshSingleFileDocuments.cs @@ -236,14 +236,18 @@ namespace GameCult.Mesh private static CultPersistedStoreSnapshot ReadSingleFileSnapshot(string path) { var bytes = File.ReadAllBytes(path); + CultPersistedStoreSnapshot snapshot; try { - return CultDocumentMessagePackSerialization.DeserializeSnapshot(bytes); + snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(bytes); } catch (Exception ex) when (ex is MessagePackSerializationException or InvalidOperationException) { - return ReadLegacySingleFileSnapshot(bytes); + snapshot = ReadLegacySingleFileSnapshot(bytes); } + + CultDocumentMessagePackSerialization.RequireSingleFileFormat(snapshot); + return snapshot; } private static CultPersistedStoreSnapshot ReadLegacySingleFileSnapshot(byte[] bytes) @@ -252,7 +256,8 @@ namespace GameCult.Mesh var fieldCount = reader.ReadArrayHeader(); var snapshot = new CultPersistedStoreSnapshot(); if (fieldCount > 0) - snapshot.FormatVersion = reader.ReadString() ?? "cultcache.store.v1"; + snapshot.FormatVersion = reader.ReadString() + ?? throw new NotSupportedException("Store snapshot declares no format version."); if (fieldCount > 1) { @@ -267,7 +272,7 @@ namespace GameCult.Mesh var recordCount = reader.ReadArrayHeader(); snapshot.Records = new CultPersistedRecord[recordCount]; for (var index = 0; index < recordCount; index++) - snapshot.Records[index] = ReadLegacyPersistedRecord(ref reader); + snapshot.Records[index] = CultDocumentMessagePackSerialization.ReadPersistedRecord(ref reader); } for (var index = 3; index < fieldCount; index++) @@ -303,23 +308,6 @@ namespace GameCult.Mesh return entry; } - private static CultPersistedRecord ReadLegacyPersistedRecord(ref MessagePackReader reader) - { - var fieldCount = reader.ReadArrayHeader(); - var record = new CultPersistedRecord(); - if (fieldCount > 0) record.Key = reader.ReadString() ?? string.Empty; - if (fieldCount > 1) record.SchemaId = reader.ReadString() ?? string.Empty; - if (fieldCount > 2) record.StoredAt = reader.ReadString() ?? string.Empty; - if (fieldCount > 3) - { - var payload = reader.ReadBytes(); - record.Payload = payload.HasValue ? payload.Value.ToArray() : Array.Empty(); - } - for (var index = 4; index < fieldCount; index++) - reader.Skip(); - return record; - } - private static bool PublishesSchema(CultSchemaCatalogEntry[] catalog, string schemaId) { return catalog.Any(entry => diff --git a/tests/GameCult.Caching.Tests/PreCut2StoreFormatTests.cs b/tests/GameCult.Caching.Tests/PreCut2StoreFormatTests.cs index 6089b94e..6757a806 100644 --- a/tests/GameCult.Caching.Tests/PreCut2StoreFormatTests.cs +++ b/tests/GameCult.Caching.Tests/PreCut2StoreFormatTests.cs @@ -1,6 +1,7 @@ #nullable enable using System; using System.IO; +using System.Linq; using System.Threading.Tasks; using GameCult.Caching; using GameCult.Caching.MessagePack; @@ -83,6 +84,73 @@ namespace GameCult.Caching.Tests } } + // Shared refusal vectors: tests/vectors/document-variants-c0, read by every runtime's tests. + private const string ItemSchemaId = "sha256:88d3fdf0a927acf3b163940d8f8c7fe62b3316542ce771a67ec8bc038f594788"; + + private static string VectorPath(string name) + { + for (var dir = new DirectoryInfo(TestContext.CurrentContext.TestDirectory); dir != null; dir = dir.Parent) + { + var candidate = Path.Combine(dir.FullName, "tests", "vectors", "document-variants-c0", name); + if (File.Exists(candidate)) return candidate; + } + throw new FileNotFoundException($"Shared vector {name} not found above {TestContext.CurrentContext.TestDirectory}."); + } + + private static async Task PullRefusalAsync(string vector) + { + var root = Path.Combine(Path.GetTempPath(), $"cultlib-vector-{Guid.NewGuid():N}"); + Directory.CreateDirectory(root); + try + { + var file = Path.Combine(root, "store.msgpack"); + File.Copy(VectorPath(vector), file); + using var cache = new CultCache(); + cache.AddBackingStore(new SingleFileMessagePackBackingStore(file)); + var error = Assert.CatchAsync(async () => await cache.PullAllBackingStoresAsync()); + Assert.That(error, Is.Not.Null); + Assert.That(error!.ToString(), Does.Contain(nameof(NotSupportedException))); + return error.ToString(); + } + finally + { + if (Directory.Exists(root)) Directory.Delete(root, recursive: true); + } + } + + [Test] + public async Task SingleFileRefusesUnknownHeaderByName() + { + Assert.That(await PullRefusalAsync("unknown-header.msgpack"), Does.Contain("cultcache.store.v9")); + } + + [Test] + public async Task SingleFileRefusesExtraRecordSlotNamingTheRecord() + { + var message = await PullRefusalAsync("extra-slot-full-payload.msgpack"); + Assert.That(message, Does.Contain("item:anvil")); + Assert.That(message, Does.Contain(ItemSchemaId)); + } + + [Test] + public async Task SingleFileRefusesVariantStoreByVersionOrRecord() + { + Assert.That(await PullRefusalAsync("variant-v2.msgpack"), + Does.Contain("cultcache.store.v2").Or.Contain("item:anvil-big")); + } + + [Test] + public void V1StoreWrittenAtTheBaseCommitStillDecodesByteForByte() + { + var snapshot = CultDocumentMessagePackSerialization.DeserializeSnapshot(File.ReadAllBytes(VectorPath("v1-base.msgpack"))); + CultDocumentMessagePackSerialization.RequireSingleFileFormat(snapshot); + + Assert.That(snapshot.FormatVersion, Is.EqualTo("cultcache.store.v1")); + Assert.That(snapshot.Records.Select(record => record.Key), Is.EqualTo(new[] { "alpha", "beta" })); + Assert.That(snapshot.Records[0].Payload, Is.EqualTo(new byte[] { 0x92, 0xa5, (byte)'a', (byte)'l', (byte)'p', (byte)'h', (byte)'a', 0x01 })); + Assert.That(snapshot.Records[1].Payload, Is.EqualTo(new byte[] { 0x92, 0xa4, (byte)'b', (byte)'e', (byte)'t', (byte)'a', 0x02 })); + } + // Format strings taken from DirectoryMessagePackBackingStore.cs at 0db1fe5. [TestCase("cultcache.store.v1")] [TestCase("cultcache.store.v1.directory")] diff --git a/tests/GameCult.Mesh.Tests/CultMeshVariantRefusalTests.cs b/tests/GameCult.Mesh.Tests/CultMeshVariantRefusalTests.cs new file mode 100644 index 00000000..7636cacf --- /dev/null +++ b/tests/GameCult.Mesh.Tests/CultMeshVariantRefusalTests.cs @@ -0,0 +1,47 @@ +using System; +using System.IO; +using FluentAssertions; +using GameCult.Caching; +using NUnit.Framework; + +#nullable enable + +namespace GameCult.Mesh.Tests; + +// Shared refusal vectors: tests/vectors/document-variants-c0, read by every runtime's tests. +public sealed class CultMeshVariantRefusalTests +{ + private const string ItemSchemaId = "sha256:88d3fdf0a927acf3b163940d8f8c7fe62b3316542ce771a67ec8bc038f594788"; + + private static string VectorPath(string name) + { + for (var dir = new DirectoryInfo(TestContext.CurrentContext.TestDirectory); dir != null; dir = dir.Parent) + { + var candidate = Path.Combine(dir.FullName, "tests", "vectors", "document-variants-c0", name); + if (File.Exists(candidate)) return candidate; + } + throw new FileNotFoundException($"Shared vector {name} not found above {TestContext.CurrentContext.TestDirectory}."); + } + + private static NotSupportedException Refusal(string vector) => + Assert.Throws(() => + CultMesh.ReadSingleFileDocumentPayload(VectorPath(vector), new CultRecordKey("item:bellows"), ItemSchemaId))!; + + [Test] + public void ReadSingleFileDocument_RefusesUnknownHeaderByName() => + Refusal("unknown-header.msgpack").Message.Should().Contain("cultcache.store.v9"); + + [Test] + public void ReadSingleFileDocument_RefusesExtraRecordSlotNamingTheRecord() + { + var message = Refusal("extra-slot-full-payload.msgpack").Message; + message.Should().Contain("item:anvil").And.Contain(ItemSchemaId); + } + + [Test] + public void ReadSingleFileDocument_RefusesVariantStoreByVersionOrRecord() + { + var message = Refusal("variant-v2.msgpack").Message; + (message.Contains("cultcache.store.v2") || message.Contains("item:anvil-big")).Should().BeTrue(message); + } +} From 569d668141b3c76929d10ec5adfd4de40a2685a8 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 02:04:34 +0200 Subject: [PATCH 3/7] TypeScript: refuse unknown store headers and extra record slots by name The store reader and the inspector share one header and record-slot rule; an unknown cultcache.store.* header no longer falls through to the legacy envelope decoder. Co-Authored-By: Claude Sonnet 5 --- .../cultcache-ts/src/cult-cache-inspector.ts | 9 ++-- .../single-file-messagepack-backing-store.ts | 6 +-- packages/cultcache-ts/src/store-format.ts | 33 ++++++++++++++ packages/cultcache-ts/test/cult-cache.test.ts | 45 +++++++++++++++++++ 4 files changed, 84 insertions(+), 9 deletions(-) create mode 100644 packages/cultcache-ts/src/store-format.ts diff --git a/packages/cultcache-ts/src/cult-cache-inspector.ts b/packages/cultcache-ts/src/cult-cache-inspector.ts index 1301fe47..54e75700 100644 --- a/packages/cultcache-ts/src/cult-cache-inspector.ts +++ b/packages/cultcache-ts/src/cult-cache-inspector.ts @@ -1,5 +1,7 @@ import { decode } from "@msgpack/msgpack"; +import { STORE_FORMAT_VERSION, isV1Snapshot, requireV1RecordSlots } from "./store-format"; + export interface CultCacheInspection { filePath: string; fileSizeBytes: number; @@ -39,8 +41,6 @@ export interface InspectedRecord { payloadDecodeError?: string; } -const STORE_FORMAT_VERSION = "cultcache.store.v1"; - export function inspectCultCacheBytes( filePath: string, bytes: Uint8Array, @@ -197,6 +197,7 @@ function decodeV1Record(value: unknown, catalogBySchemaId: Map { return typeof value === "object" && value !== null; } diff --git a/packages/cultcache-ts/src/single-file-messagepack-backing-store.ts b/packages/cultcache-ts/src/single-file-messagepack-backing-store.ts index 4d2bb4f9..b7d9684c 100644 --- a/packages/cultcache-ts/src/single-file-messagepack-backing-store.ts +++ b/packages/cultcache-ts/src/single-file-messagepack-backing-store.ts @@ -4,6 +4,7 @@ import { dirname, resolve } from "node:path"; import { decode, encode } from "@msgpack/msgpack"; import { z } from "zod"; +import { STORE_FORMAT_VERSION, isV1Snapshot, requireV1RecordSlots } from "./store-format"; import type { CacheBackingStore, CultCacheEnvelope, @@ -12,8 +13,6 @@ import type { PushAllOptions, } from "./types"; -const STORE_FORMAT_VERSION = "cultcache.store.v1"; - const envelopeSchema = z.object({ key: z.string().min(1), type: z.string().min(1), @@ -285,7 +284,7 @@ function encodeCatalogMember(member: CultCacheSchemaCatalogMember): unknown[] { } function decodeSnapshot(decoded: unknown): DecodedSnapshot | undefined { - if (!Array.isArray(decoded) || decoded.length === 0 || decoded[0] !== STORE_FORMAT_VERSION) { + if (!isV1Snapshot(decoded)) { return undefined; } @@ -436,6 +435,7 @@ function decodeRecord(value: unknown): PersistedRecord { throw new Error("CultCache persisted records must be MessagePack arrays."); } + requireV1RecordSlots(value); const [key = "", schemaId = "", storedAt = "", payload = new Uint8Array()] = value; if (!isNonEmptyString(key) || !isNonEmptyString(schemaId) || !isNonEmptyString(storedAt)) { throw new Error("CultCache persisted records must declare key, schemaId, and storedAt."); diff --git a/packages/cultcache-ts/src/store-format.ts b/packages/cultcache-ts/src/store-format.ts new file mode 100644 index 00000000..02de60e7 --- /dev/null +++ b/packages/cultcache-ts/src/store-format.ts @@ -0,0 +1,33 @@ +export const STORE_FORMAT_VERSION = "cultcache.store.v1"; + +const STORE_FORMAT_PREFIX = "cultcache.store."; +const PERSISTED_RECORD_SLOTS = 4; + +/** + * True for a v1 snapshot, false for anything that is not a CultCache store header (the legacy + * envelope array). Any other `cultcache.store.*` header is refused by name. + */ +export function isV1Snapshot(decoded: unknown): decoded is unknown[] { + if (!Array.isArray(decoded) || typeof decoded[0] !== "string" || !decoded[0].startsWith(STORE_FORMAT_PREFIX)) { + return false; + } + + if (decoded[0] !== STORE_FORMAT_VERSION) { + throw new Error( + `CultCache store format "${decoded[0]}" is not readable; this runtime reads "${STORE_FORMAT_VERSION}" only. ` + + "The store needs a runtime that resolves document variants.", + ); + } + + return true; +} + +/** Refuses a persisted record with more slots than v1 defines, naming its key and schema id. */ +export function requireV1RecordSlots(record: unknown[]): void { + if (record.length > PERSISTED_RECORD_SLOTS) { + throw new Error( + `CultCache record "${String(record[0])}" (schema "${String(record[1])}") has ${record.length} slots; ` + + `this runtime reads ${PERSISTED_RECORD_SLOTS}. The store needs a runtime that resolves document variants.`, + ); + } +} diff --git a/packages/cultcache-ts/test/cult-cache.test.ts b/packages/cultcache-ts/test/cult-cache.test.ts index 5932b2f8..a743ba00 100644 --- a/packages/cultcache-ts/test/cult-cache.test.ts +++ b/packages/cultcache-ts/test/cult-cache.test.ts @@ -1413,3 +1413,48 @@ async function runJsonCommand( return JSON.parse(trimmed.split(/\r?\n/).at(-1) as string); } + +// Shared refusal vectors: tests/vectors/document-variants-c0, read by every runtime's tests. +const variantVectors = resolve(cultLibRoot, "tests", "vectors", "document-variants-c0"); +const itemSchemaId = "sha256:88d3fdf0a927acf3b163940d8f8c7fe62b3316542ce771a67ec8bc038f594788"; + +async function pullVector(name: string): Promise { + const dir = await mkdtemp(join(tmpdir(), "cultcache-vector-")); + const file = join(dir, "store.msgpack"); + await writeFile(file, await readFile(join(variantVectors, name))); + return new SingleFileMessagePackBackingStore(file).pullAll(); +} + +test("SingleFileMessagePackBackingStore refuses an unknown store header by name", async () => { + await assert.rejects(() => pullVector("unknown-header.msgpack"), /cultcache\.store\.v9/u); +}); + +test("SingleFileMessagePackBackingStore refuses an extra record slot naming the record", async () => { + await assert.rejects( + () => pullVector("extra-slot-full-payload.msgpack"), + (error: Error) => error.message.includes("item:anvil") && error.message.includes(itemSchemaId), + ); +}); + +test("SingleFileMessagePackBackingStore refuses a variant store by version or record", async () => { + await assert.rejects( + () => pullVector("variant-v2.msgpack"), + (error: Error) => error.message.includes("cultcache.store.v2") || error.message.includes("item:anvil-big"), + ); +}); + +test("CultCache inspector refuses the same vectors by name", async () => { + const inspect = async (name: string) => inspectCultCacheBytes(name, await readFile(join(variantVectors, name))); + await assert.rejects(() => inspect("unknown-header.msgpack"), /cultcache\.store\.v9/u); + await assert.rejects( + () => inspect("extra-slot-full-payload.msgpack"), + (error: Error) => error.message.includes("item:anvil") && error.message.includes(itemSchemaId), + ); +}); + +test("a v1 store written at the base commit still reads byte for byte", async () => { + const envelopes = await pullVector("v1-base.msgpack"); + assert.deepEqual(envelopes.map((entry) => [entry.key, entry.type]), [["alpha", "vectors.item"], ["beta", "vectors.item"]]); + assert.deepEqual([...envelopes[0]!.payload], [0x92, 0xa5, 0x61, 0x6c, 0x70, 0x68, 0x61, 0x01]); + assert.deepEqual([...envelopes[1]!.payload], [0x92, 0xa4, 0x62, 0x65, 0x74, 0x61, 0x02]); +}); From c8777fe08d4b7bd4de9df327b60387be29d4e50a Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 02:04:34 +0200 Subject: [PATCH 4/7] Python: refuse unknown store headers and extra record slots by name Delete the raw_record[:4] slice and the legacy-envelope fallthrough for unknown cultcache.store.* headers. Co-Authored-By: Claude Sonnet 5 --- .../cultcache-py/src/cultcache_py/stores.py | 18 ++++++++-- packages/cultcache-py/tests/test_cultcache.py | 34 +++++++++++++++++++ 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/packages/cultcache-py/src/cultcache_py/stores.py b/packages/cultcache-py/src/cultcache_py/stores.py index 697882e0..0980f743 100644 --- a/packages/cultcache-py/src/cultcache_py/stores.py +++ b/packages/cultcache-py/src/cultcache_py/stores.py @@ -14,6 +14,8 @@ from .backing_store import ( ) STORE_FORMAT_VERSION = "cultcache.store.v1" +_STORE_FORMAT_PREFIX = "cultcache.store." +_PERSISTED_RECORD_SLOTS = 4 class JsonLinesBackingStore: @@ -153,7 +155,14 @@ def _encode_v1_snapshot(envelopes: list[CultCacheEnvelope]) -> list[Any]: def _decode_v1_snapshot(decoded: Any) -> list[CultCacheEnvelope] | None: - if not isinstance(decoded, list) or not decoded or decoded[0] != STORE_FORMAT_VERSION: + if not isinstance(decoded, list) or not decoded: + return None + if isinstance(decoded[0], str) and decoded[0].startswith(_STORE_FORMAT_PREFIX) and decoded[0] != STORE_FORMAT_VERSION: + raise ValueError( + f"CultCache store format {decoded[0]!r} is not readable; this runtime reads {STORE_FORMAT_VERSION!r} only. " + "The store needs a runtime that resolves document variants." + ) + if decoded[0] != STORE_FORMAT_VERSION: return None if len(decoded) < 3 or not isinstance(decoded[1], list) or not isinstance(decoded[2], list): raise ValueError("CultCache v1 snapshot must contain a schema catalog and record array") @@ -169,7 +178,12 @@ def _decode_v1_snapshot(decoded: Any) -> list[CultCacheEnvelope] | None: for raw_record in decoded[2]: if not isinstance(raw_record, list) or len(raw_record) < 4: raise ValueError("CultCache persisted records must be MessagePack arrays") - key, schema_id, stored_at, payload = raw_record[:4] + if len(raw_record) > _PERSISTED_RECORD_SLOTS: + raise ValueError( + f"CultCache record {raw_record[0]!r} (schema {raw_record[1]!r}) has {len(raw_record)} slots; " + f"this runtime reads {_PERSISTED_RECORD_SLOTS}. The store needs a runtime that resolves document variants." + ) + key, schema_id, stored_at, payload = raw_record if not isinstance(key, str) or not key: raise ValueError("CultCache persisted records must declare a key") if not isinstance(schema_id, str) or not schema_id: diff --git a/packages/cultcache-py/tests/test_cultcache.py b/packages/cultcache-py/tests/test_cultcache.py index ac81cc6d..b4168690 100644 --- a/packages/cultcache-py/tests/test_cultcache.py +++ b/packages/cultcache-py/tests/test_cultcache.py @@ -802,5 +802,39 @@ class CultCacheTests(unittest.TestCase): self.assertEqual(cache.get_required_envelope(document, "item:one").key, "item:one") + # Shared refusal vectors: tests/vectors/document-variants-c0, read by every runtime's tests. + _VECTORS = Path(__file__).resolve().parents[3] / "tests" / "vectors" / "document-variants-c0" + _ITEM_SCHEMA_ID = "sha256:88d3fdf0a927acf3b163940d8f8c7fe62b3316542ce771a67ec8bc038f594788" + + def _pull_vector(self, name: str) -> list[CultCacheEnvelope]: + with tempfile.TemporaryDirectory() as tmp: + store_path = Path(tmp) / "store.msgpack" + store_path.write_bytes((self._VECTORS / name).read_bytes()) + return SingleFileMessagePackBackingStore(store_path).pull_all() + + def test_single_file_refuses_unknown_header_by_name(self) -> None: + with self.assertRaises(ValueError) as caught: + self._pull_vector("unknown-header.msgpack") + self.assertIn("cultcache.store.v9", str(caught.exception)) + + def test_single_file_refuses_extra_record_slot_naming_the_record(self) -> None: + with self.assertRaises(ValueError) as caught: + self._pull_vector("extra-slot-full-payload.msgpack") + self.assertIn("item:anvil", str(caught.exception)) + self.assertIn(self._ITEM_SCHEMA_ID, str(caught.exception)) + + def test_single_file_refuses_variant_store_by_version_or_record(self) -> None: + with self.assertRaises(ValueError) as caught: + self._pull_vector("variant-v2.msgpack") + message = str(caught.exception) + self.assertTrue("cultcache.store.v2" in message or "item:anvil-big" in message, message) + + def test_v1_store_written_at_the_base_commit_still_reads_byte_for_byte(self) -> None: + envelopes = self._pull_vector("v1-base.msgpack") + self.assertEqual([(e.key, e.type) for e in envelopes], [("alpha", "vectors.item"), ("beta", "vectors.item")]) + self.assertEqual(envelopes[0].payload, b"\x92\xa5alpha\x01") + self.assertEqual(envelopes[1].payload, b"\x92\xa4beta\x02") + + if __name__ == "__main__": unittest.main() From 95f04c1efb5788042b3b17d75a7916a33cbec95f Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 02:04:34 +0200 Subject: [PATCH 5/7] Rust: name the record and the version when a store is refused PersistedRecord refuses a fifth slot naming its key and schema id, and an unknown cultcache.store.* header no longer falls through to the legacy envelope decode that hid the cause. Co-Authored-By: Claude Sonnet 5 --- packages/cultcache-rs/src/lib.rs | 128 +++++++++++++++++++++++++++++-- 1 file changed, 121 insertions(+), 7 deletions(-) diff --git a/packages/cultcache-rs/src/lib.rs b/packages/cultcache-rs/src/lib.rs index ed9cae32..a535b935 100644 --- a/packages/cultcache-rs/src/lib.rs +++ b/packages/cultcache-rs/src/lib.rs @@ -271,7 +271,7 @@ struct PersistedSchemaCatalogMember( Option, ); -#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +#[derive(Clone, Debug, serde::Serialize)] struct PersistedRecord( String, String, @@ -279,6 +279,66 @@ struct PersistedRecord( #[serde(with = "serde_bytes")] Vec, ); +impl<'de> serde::Deserialize<'de> for PersistedRecord { + /// Reads the four v1 slots and refuses a fifth, naming the record. A record with more + /// slots was written by a runtime that resolves document variants. + fn deserialize>(deserializer: D) -> std::result::Result { + use serde::de::{Error, IgnoredAny, SeqAccess, Visitor}; + + struct RecordVisitor; + impl<'de> Visitor<'de> for RecordVisitor { + type Value = PersistedRecord; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("a CultCache persisted record of 4 slots") + } + + fn visit_seq>(self, mut seq: A) -> std::result::Result { + let key: String = seq.next_element()?.ok_or_else(|| A::Error::invalid_length(0, &self))?; + let schema_id: String = seq.next_element()?.ok_or_else(|| A::Error::invalid_length(1, &self))?; + let stored_at: String = seq.next_element()?.ok_or_else(|| A::Error::invalid_length(2, &self))?; + let payload: serde_bytes::ByteBuf = + seq.next_element()?.ok_or_else(|| A::Error::invalid_length(3, &self))?; + if seq.next_element::()?.is_some() { + return Err(A::Error::custom(format!( + "CultCache record {key:?} (schema {schema_id:?}) has more than 4 slots; this runtime reads 4. The store needs a runtime that resolves document variants." + ))); + } + Ok(PersistedRecord(key, schema_id, stored_at, payload.into_vec())) + } + } + + deserializer.deserialize_seq(RecordVisitor) + } +} + +/// The store header string, or `None` when the bytes are not a snapshot-shaped array +/// (the legacy envelope array starts with a map). +fn store_header(bytes: &[u8]) -> Option { + struct Header(String); + impl<'de> serde::Deserialize<'de> for Header { + fn deserialize>(deserializer: D) -> std::result::Result { + struct HeaderVisitor; + impl<'de> serde::de::Visitor<'de> for HeaderVisitor { + type Value = Header; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("an array whose first slot is the store header") + } + + fn visit_seq>(self, mut seq: A) -> std::result::Result { + let header: String = seq + .next_element()? + .ok_or_else(|| ::invalid_length(0, &self))?; + Ok(Header(header)) + } + } + deserializer.deserialize_seq(HeaderVisitor) + } + } + rmp_serde::from_slice::
(bytes).ok().map(|header| header.0) +} + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub struct PushAllOptions { pub soft: bool, @@ -658,9 +718,11 @@ impl SingleFileMessagePackBackingStore { if bytes.is_empty() { return Ok(Vec::new()); } - decode_store_snapshot(&bytes) - .or_else(|_| rmp_serde::from_slice(&bytes)) - .with_context(|| format!("failed to decode MessagePack {}", self.path.display())) + match store_header(&bytes) { + Some(header) if header.starts_with("cultcache.store.") => decode_store_snapshot(&bytes), + _ => rmp_serde::from_slice(&bytes).map_err(anyhow::Error::from), + } + .map_err(|error| anyhow!("failed to decode MessagePack {}: {error:#}", self.path.display())) } /// Reads one filesystem snapshot without creating or opening the sibling @@ -2518,11 +2580,15 @@ fn encode_store_snapshot(entries: &[CultCacheEnvelope]) -> Result Result> { + if let Some(header) = store_header(bytes) { + if header != "cultcache.store.v1" { + return Err(anyhow!( + "CultCache store format {header:?} is not readable; this runtime reads \"cultcache.store.v1\" only. The store needs a runtime that resolves document variants." + )); + } + } let snapshot: PersistedStoreSnapshot = rmp_serde::from_slice(bytes).context("failed to decode CultCache v1 snapshot")?; - if snapshot.0 != "cultcache.store.v1" { - return Err(anyhow!("unsupported CultCache snapshot {}", snapshot.0)); - } let catalog = snapshot .1 @@ -4573,4 +4639,52 @@ mod tests { assert_eq!(store.pull_all_read_only_snapshot()?, vec![admitted]); Ok(()) } + + // Shared refusal vectors: tests/vectors/document-variants-c0, read by every runtime's tests. + const ITEM_SCHEMA_ID: &str = "sha256:88d3fdf0a927acf3b163940d8f8c7fe62b3316542ce771a67ec8bc038f594788"; + + fn pull_vector(name: &str) -> Result> { + let temp = tempfile::tempdir()?; + let path = temp.path().join("store.msgpack"); + let vector = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../tests/vectors/document-variants-c0") + .join(name); + std::fs::copy(vector, &path)?; + SingleFileMessagePackBackingStore::new(&path).pull_all() + } + + fn refusal(name: &str) -> String { + format!("{:#}", pull_vector(name).unwrap_err()) + } + + #[test] + fn single_file_refuses_unknown_header_by_name() { + assert!(refusal("unknown-header.msgpack").contains("cultcache.store.v9")); + } + + #[test] + fn single_file_refuses_extra_record_slot_naming_the_record() { + let message = refusal("extra-slot-full-payload.msgpack"); + assert!(message.contains("item:anvil"), "{message}"); + assert!(message.contains(ITEM_SCHEMA_ID), "{message}"); + } + + #[test] + fn single_file_refuses_variant_store_by_version_or_record() { + let message = refusal("variant-v2.msgpack"); + assert!( + message.contains("cultcache.store.v2") || message.contains("item:anvil-big"), + "{message}" + ); + } + + #[test] + fn v1_store_written_at_the_base_commit_still_reads_byte_for_byte() -> Result<()> { + let envelopes = pull_vector("v1-base.msgpack")?; + let rows: Vec<_> = envelopes.iter().map(|e| (e.key.as_str(), e.r#type.as_str())).collect(); + assert_eq!(rows, vec![("alpha", "vectors.item"), ("beta", "vectors.item")]); + assert_eq!(envelopes[0].payload, b"\x92\xa5alpha\x01"); + assert_eq!(envelopes[1].payload, b"\x92\xa4beta\x02"); + Ok(()) + } } From 36aa8d8791ee80f93fbd0ddd04a68a24ed29a3fb Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 02:34:11 +0200 Subject: [PATCH 6/7] Rust: drain the header peek so rmp-serde accepts the array The peek left snapshot slots unread and rmp-serde refused the length mismatch, so every store fell through to the legacy decode. Co-Authored-By: Claude Sonnet 5 --- packages/cultcache-rs/src/lib.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/cultcache-rs/src/lib.rs b/packages/cultcache-rs/src/lib.rs index a535b935..e48ebad8 100644 --- a/packages/cultcache-rs/src/lib.rs +++ b/packages/cultcache-rs/src/lib.rs @@ -330,6 +330,7 @@ fn store_header(bytes: &[u8]) -> Option { let header: String = seq .next_element()? .ok_or_else(|| ::invalid_length(0, &self))?; + while seq.next_element::()?.is_some() {} Ok(Header(header)) } } From 66bd07b9416510969600248545151fd0cbbf1fb0 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 02:35:57 +0200 Subject: [PATCH 7/7] Pin the legacy-layout and missing-header refusals The legacy catalog layout reaches CultMesh's fallback reader, and a nil header was the only defaulted header; each gets one vector or test. The C# store tests catch the refusal where the store attaches. Co-Authored-By: Claude Sonnet 5 --- .../PreCut2StoreFormatTests.cs | 27 ++++++++++++------- .../CultMeshVariantRefusalTests.cs | 7 +++++ .../vectors/document-variants-c0/generate.py | 7 +++++ .../legacy-catalog-extra-slot.msgpack | 1 + 4 files changed, 33 insertions(+), 9 deletions(-) create mode 100644 tests/vectors/document-variants-c0/legacy-catalog-extra-slot.msgpack diff --git a/tests/GameCult.Caching.Tests/PreCut2StoreFormatTests.cs b/tests/GameCult.Caching.Tests/PreCut2StoreFormatTests.cs index 6757a806..479eb762 100644 --- a/tests/GameCult.Caching.Tests/PreCut2StoreFormatTests.cs +++ b/tests/GameCult.Caching.Tests/PreCut2StoreFormatTests.cs @@ -97,7 +97,7 @@ namespace GameCult.Caching.Tests throw new FileNotFoundException($"Shared vector {name} not found above {TestContext.CurrentContext.TestDirectory}."); } - private static async Task PullRefusalAsync(string vector) + private static string Refusal(string vector) { var root = Path.Combine(Path.GetTempPath(), $"cultlib-vector-{Guid.NewGuid():N}"); Directory.CreateDirectory(root); @@ -106,8 +106,7 @@ namespace GameCult.Caching.Tests var file = Path.Combine(root, "store.msgpack"); File.Copy(VectorPath(vector), file); using var cache = new CultCache(); - cache.AddBackingStore(new SingleFileMessagePackBackingStore(file)); - var error = Assert.CatchAsync(async () => await cache.PullAllBackingStoresAsync()); + var error = Assert.Catch(() => cache.AddBackingStore(new SingleFileMessagePackBackingStore(file))); Assert.That(error, Is.Not.Null); Assert.That(error!.ToString(), Does.Contain(nameof(NotSupportedException))); return error.ToString(); @@ -119,26 +118,36 @@ namespace GameCult.Caching.Tests } [Test] - public async Task SingleFileRefusesUnknownHeaderByName() + public void SingleFileRefusesUnknownHeaderByName() { - Assert.That(await PullRefusalAsync("unknown-header.msgpack"), Does.Contain("cultcache.store.v9")); + Assert.That(Refusal("unknown-header.msgpack"), Does.Contain("cultcache.store.v9")); } [Test] - public async Task SingleFileRefusesExtraRecordSlotNamingTheRecord() + public void SingleFileRefusesExtraRecordSlotNamingTheRecord() { - var message = await PullRefusalAsync("extra-slot-full-payload.msgpack"); + var message = Refusal("extra-slot-full-payload.msgpack"); Assert.That(message, Does.Contain("item:anvil")); Assert.That(message, Does.Contain(ItemSchemaId)); } [Test] - public async Task SingleFileRefusesVariantStoreByVersionOrRecord() + public void SingleFileRefusesVariantStoreByVersionOrRecord() { - Assert.That(await PullRefusalAsync("variant-v2.msgpack"), + Assert.That(Refusal("variant-v2.msgpack"), Does.Contain("cultcache.store.v2").Or.Contain("item:anvil-big")); } + [Test] + public void SnapshotWithoutAFormatVersionIsRefusedNotDefaultedToV1() + { + // [nil, [], []] + var bytes = new byte[] { 0x93, 0xc0, 0x90, 0x90 }; + + Assert.That(() => CultDocumentMessagePackSerialization.DeserializeSnapshot(bytes), + Throws.TypeOf().With.Message.Contains("format version")); + } + [Test] public void V1StoreWrittenAtTheBaseCommitStillDecodesByteForByte() { diff --git a/tests/GameCult.Mesh.Tests/CultMeshVariantRefusalTests.cs b/tests/GameCult.Mesh.Tests/CultMeshVariantRefusalTests.cs index 7636cacf..a5be069a 100644 --- a/tests/GameCult.Mesh.Tests/CultMeshVariantRefusalTests.cs +++ b/tests/GameCult.Mesh.Tests/CultMeshVariantRefusalTests.cs @@ -38,6 +38,13 @@ public sealed class CultMeshVariantRefusalTests message.Should().Contain("item:anvil").And.Contain(ItemSchemaId); } + [Test] + public void ReadSingleFileDocument_RefusesExtraRecordSlotInTheLegacyCatalogLayoutToo() + { + var message = Refusal("legacy-catalog-extra-slot.msgpack").Message; + message.Should().Contain("item:anvil").And.Contain(ItemSchemaId); + } + [Test] public void ReadSingleFileDocument_RefusesVariantStoreByVersionOrRecord() { diff --git a/tests/vectors/document-variants-c0/generate.py b/tests/vectors/document-variants-c0/generate.py index fafd5dfe..e3d2547d 100644 --- a/tests/vectors/document-variants-c0/generate.py +++ b/tests/vectors/document-variants-c0/generate.py @@ -4,6 +4,10 @@ at test time. Run: python generate.py (needs msgpack). unknown-header.msgpack header cultcache.store.v9, otherwise the v1 store extra-slot-full-payload.msgpack v1 header, record item:anvil gains slot 4, payload stays full + legacy-catalog-extra-slot.msgpack + v1 header, catalog entry in the older layout (contentHash at slot + 5), so CultMesh's legacy snapshot reader takes it, plus the + extra-slot record v1-base.msgpack NOT generated here: written once by the Python runtime at CultLib 69a21bb (SingleFileMessagePackBackingStore.push of alpha and beta, type vectors.item, before any C0 code existed) @@ -32,3 +36,6 @@ write("extra-slot-full-payload.msgpack", [header, catalog, [anvil + [["item:bellows", []]]] + records[1:]]) variant = ["item:anvil-big", anvil[1], anvil[2], b"", ["item:anvil", [[0, [[1, ""]], "", 99]]]] write("variant-v2.msgpack", ["cultcache.store.v2", catalog, records + [variant]]) +legacy_entry = [catalog[0][0], catalog[0][1], catalog[0][2], catalog[0][4], None, catalog[0][3], []] +write("legacy-catalog-extra-slot.msgpack", + [header, [legacy_entry], [anvil + [["item:bellows", []]]]]) diff --git a/tests/vectors/document-variants-c0/legacy-catalog-extra-slot.msgpack b/tests/vectors/document-variants-c0/legacy-catalog-extra-slot.msgpack new file mode 100644 index 00000000..7ae1a542 --- /dev/null +++ b/tests/vectors/document-variants-c0/legacy-catalog-extra-slot.msgpack @@ -0,0 +1 @@ +“²cultcache.store.v1‘—ÙGsha256:88d3fdf0a927acf3b163940d8f8c7fe62b3316542ce771a67ec8bc038f594788µfixtures.precut2.item¸fixtures.precut2.item.v1Úp{"schemaName":"fixtures.precut2.item","schemaVersion":"fixtures.precut2.item.v1","members":[{"slot":0,"name":"Name","type":"System.String","isReference":false,"many":false,"targetSchemaName":null,"indexAlias":null,"isName":true},{"slot":1,"name":"Count","type":"System.Int32","isReference":false,"many":false,"targetSchemaName":null,"indexAlias":null,"isName":false}]}ÀÙGsha256:dadf30bc920849ee89ed5d70cc73027070dd7571c5f878db1901f3abc459cc0c�‘•ªitem:anvilÙGsha256:88d3fdf0a927acf3b163940d8f8c7fe62b3316542ce771a67ec8bc038f594788Ù!2026-09-13T15:14:36.4032226+00:00Ä’¥anvil’¬item:bellows� \ No newline at end of file